diff --git a/modules/api/src/main/Env.scala b/modules/api/src/main/Env.scala index abea50f373..313e442a28 100644 --- a/modules/api/src/main/Env.scala +++ b/modules/api/src/main/Env.scala @@ -7,7 +7,9 @@ import play.api.{ Configuration, Mode } import scala.concurrent.duration._ import lila.common.config._ +import lila.common.Bus import lila.user.User +import lila.chat.GetLinkCheck @Module final class Env( @@ -55,6 +57,7 @@ final class Env( val config = ApiConfig loadFrom appConfig import config.apiToken + import net.domain lazy val pgnDump: PgnDump = wire[PgnDump] @@ -85,6 +88,12 @@ final class Env( ) if (mode == Mode.Prod) system.scheduler.scheduleOnce(5 seconds)(influxEvent.start()) + private lazy val linkCheck = wire[LinkCheck] + + Bus.subscribeFun("chatLinkCheck") { + case GetLinkCheck(line, source, promise) => promise completeWith linkCheck(line, source) + } + system.scheduler.scheduleWithFixedDelay(1 minute, 1 minute) { () => lila.mon.bus.classifiers.update(lila.common.Bus.size) // ensure the Lichess user is online diff --git a/modules/api/src/main/LinkCheck.scala b/modules/api/src/main/LinkCheck.scala new file mode 100644 index 0000000000..56adcf04f6 --- /dev/null +++ b/modules/api/src/main/LinkCheck.scala @@ -0,0 +1,130 @@ +package lila.api + +import cats.implicits._ +import scala.concurrent.ExecutionContext + +import lila.chat.UserLine +import lila.common.config.NetDomain +import lila.hub.actorApi.shutup.PublicSource +import lila.simul.Simul +import lila.simul.SimulApi +import lila.swiss.Swiss +import lila.swiss.SwissApi +import lila.team.Team +import lila.team.TeamRepo +import lila.tournament.Tournament +import lila.tournament.TournamentRepo +import lila.user.User +import lila.study.Study +import lila.study.StudyRepo + +/* Determine if a link to a lichess resource + * can be posted from another lichess resource. + * Owners of a resource can post any link on it (but not to it). + * Links to a team resource can be posted from another resource of the same team. + * Links to official resources can be posted from anywhere. + * */ +final private class LinkCheck( + domain: NetDomain, + teamRepo: TeamRepo, + tournamentRepo: TournamentRepo, + simulApi: SimulApi, + swissApi: SwissApi, + studyRepo: StudyRepo +)(implicit ec: ExecutionContext) { + + import LinkCheck._ + + def apply(line: UserLine, source: PublicSource): Fu[Boolean] = { + if (multipleLinks find line.text) fuFalse + else + line.text match { + case tournamentLinkR(id) => withSource(source, tourLink)(id, line) + case simulLinkR(id) => withSource(source, simulLink)(id, line) + case swissLinkR(id) => withSource(source, swissLink)(id, line) + case studyLinkR(id) => withSource(source, studyLink)(id, line) + case _ => fuTrue + } + } + + private def withSource( + source: PublicSource, + f: (String, FullSource) => Fu[Boolean] + )(id: String, line: UserLine): Fu[Boolean] = { + source match { + case PublicSource.Tournament(id) => tournamentRepo byId id map2 FullSource.TournamentSource + case PublicSource.Simul(id) => simulApi find id map2 FullSource.SimulSource + case PublicSource.Swiss(id) => swissApi byId Swiss.Id(id) map2 FullSource.SwissSource + case PublicSource.Team(id) => teamRepo byId id map2 FullSource.TeamSource + case PublicSource.Study(id) => studyRepo byId Study.Id(id) map2 FullSource.StudySource + case _ => fuccess(none) + } + } flatMap { + _ ?? { source => + // the owners of a chat can post whichever link they like + if (source.owners(line.userId)) fuTrue + else f(id, source) + } + } + + private def tourLink(tourId: Tournament.ID, source: FullSource) = + tournamentRepo byId tourId flatMap { + _ ?? { tour => + fuccess(tour.isScheduled) >>| { + source.teamId ?? { sourceTeamId => + fuccess(tour.conditions.teamMember.exists(_.teamId == sourceTeamId)) >>| + tournamentRepo.isForTeam(tour.id, sourceTeamId) + } + } + } + } + + private def simulLink(simulId: Tournament.ID, source: FullSource) = + simulApi teamOf simulId map { + _ exists source.teamId.has + } + + private def swissLink(swissId: String, source: FullSource) = + swissApi teamOf Swiss.Id(swissId) map { + _ exists source.teamId.has + } + + private def studyLink(studyId: String, source: FullSource) = fuFalse + + private val multipleLinks = s"$domain.+$domain".r.unanchored + private val tournamentLinkR = s"$domain/tournament/(\\w+)".r.unanchored + private val simulLinkR = s"$domain/simul/(\\w+)".r.unanchored + private val swissLinkR = s"$domain/swiss/(\\w+)".r.unanchored + private val studyLinkR = s"$domain/study/(\\w+)".r.unanchored +} + +private object LinkCheck { + + sealed trait FullSource { + def owners: Set[User.ID] + def teamId: Option[Team.ID] + } + + object FullSource { + case class TournamentSource(value: Tournament) extends FullSource { + def owners = Set(value.createdBy) + def teamId = value.conditions.teamMember.map(_.teamId) + } + case class SimulSource(value: Simul) extends FullSource { + def owners = Set(value.hostId) + def teamId = value.team + } + case class SwissSource(value: Swiss) extends FullSource { + def owners = Set(value.createdBy) + def teamId = value.teamId.some + } + case class TeamSource(value: Team) extends FullSource { + def owners = value.leaders + def teamId = value.id.some + } + case class StudySource(value: Study) extends FullSource { + def owners = value.members.idSet + def teamId = none + } + } +} diff --git a/modules/chat/src/main/ChatApi.scala b/modules/chat/src/main/ChatApi.scala index 74fd524528..e54f8f6044 100644 --- a/modules/chat/src/main/ChatApi.scala +++ b/modules/chat/src/main/ChatApi.scala @@ -4,9 +4,9 @@ import chess.Color import reactivemongo.api.ReadPreference import scala.concurrent.duration._ +import lila.common.Bus import lila.common.config.NetDomain import lila.common.String.noShouting -import lila.common.Bus import lila.db.dsl._ import lila.hub.actorApi.shutup.{ PublicSource, RecordPrivateChat, RecordPublicChat } import lila.memo.CacheApi._ @@ -23,7 +23,7 @@ final class ChatApi( cacheApi: lila.memo.CacheApi, maxLinesPerChat: Chat.MaxLines, netDomain: NetDomain -)(implicit ec: scala.concurrent.ExecutionContext) { +)(implicit ec: scala.concurrent.ExecutionContext, actorSystem: akka.actor.ActorSystem) { import Chat.{ chatIdBSONHandler, userChatBSONHandler } @@ -88,20 +88,31 @@ final class ChatApi( ): Funit = makeLine(chatId, userId, text) flatMap { _ ?? { line => - pushLine(chatId, line) >>- { - if (publicSource.isDefined) cached invalidate chatId - shutup ! { - publicSource match { - case Some(source) => RecordPublicChat(userId, text, source) - case _ => RecordPrivateChat(chatId.value, userId, text) + linkCheck(line, publicSource) flatMap { + case false => + logger.info(s"Link check rejected $line in $publicSource") + funit + case true => + pushLine(chatId, line) >>- { + if (publicSource.isDefined) cached invalidate chatId + shutup ! { + publicSource match { + case Some(source) => RecordPublicChat(userId, text, source) + case _ => RecordPrivateChat(chatId.value, userId, text) + } + } + publish(chatId, actorApi.ChatLine(chatId, line), busChan) + lila.mon.chat.message(publicSource.fold("player")(_.parentName), line.troll).increment() } - } - publish(chatId, actorApi.ChatLine(chatId, line), busChan) - lila.mon.chat.message(publicSource.fold("player")(_.parentName), line.troll).increment() } } } + private def linkCheck(line: UserLine, source: Option[PublicSource]) = + source.fold(fuccess(true)) { s => + Bus.ask[Boolean]("chatLinkCheck") { GetLinkCheck(line, s, _) } + } + def clear(chatId: Chat.Id) = coll.delete.one($id(chatId)).void def system(chatId: Chat.Id, text: String, busChan: BusChan.Select): Funit = { diff --git a/modules/chat/src/main/model.scala b/modules/chat/src/main/model.scala index 8d79db4451..7efbd622a9 100644 --- a/modules/chat/src/main/model.scala +++ b/modules/chat/src/main/model.scala @@ -1,6 +1,8 @@ package lila.chat import lila.user.User +import scala.concurrent.Promise +import lila.hub.actorApi.shutup.PublicSource case class UserModInfo( user: User, @@ -21,3 +23,5 @@ object BusChan { type Select = BusChan.type => BusChan } + +case class GetLinkCheck(line: UserLine, source: PublicSource, promise: Promise[Boolean]) diff --git a/modules/common/src/main/HTTPRequest.scala b/modules/common/src/main/HTTPRequest.scala index 45a08e7993..2d9f0c6fda 100644 --- a/modules/common/src/main/HTTPRequest.scala +++ b/modules/common/src/main/HTTPRequest.scala @@ -67,7 +67,7 @@ object HTTPRequest { case class UaMatcher(rStr: String) { private val regex = rStr.r - def apply(req: RequestHeader): Boolean = userAgent(req) ?? { regex.find(_) } + def apply(req: RequestHeader): Boolean = userAgent(req) ?? regex.find } def isFishnet(req: RequestHeader) = req.path startsWith "/fishnet/" diff --git a/modules/security/src/main/Promotion.scala b/modules/security/src/main/Promotion.scala index b5df6925a2..7d37236759 100644 --- a/modules/security/src/main/Promotion.scala +++ b/modules/security/src/main/Promotion.scala @@ -9,7 +9,7 @@ import lila.common.config.NetDomain final class PromotionApi(domain: NetDomain) { def test(user: User)(text: String): Boolean = - user.isVerified || { + user.isVerified || user.isAdmin || { val promotions = extract(text) promotions.isEmpty || { val prev = ~cache.getIfPresent(user.id) @@ -35,6 +35,8 @@ final class PromotionApi(domain: NetDomain) { s"$domain/team/([\\w-]+)", s"$domain/tournament/(\\w+)", s"$domain/swiss/(\\w+)", + s"$domain/simul/(\\w+)", + s"$domain/study/(\\w+)", """(?:youtube\.com|youtu\.be)/(?:watch)?(?:\?v=)?([^"&?/ ]{11})""", """youtube\.com/channel/([\w-]{24})""", """twitch\.tv/([a-zA-Z0-9](?:\w{2,24}+))""" diff --git a/modules/simul/src/main/Simul.scala b/modules/simul/src/main/Simul.scala index 0a59890202..83f81824b7 100644 --- a/modules/simul/src/main/Simul.scala +++ b/modules/simul/src/main/Simul.scala @@ -17,7 +17,7 @@ case class Simul( variants: List[Variant], position: Option[StartingPosition], createdAt: DateTime, - hostId: String, + hostId: User.ID, hostRating: Int, hostGameId: Option[String], // game the host is focusing on startedAt: Option[DateTime], diff --git a/modules/simul/src/main/SimulApi.scala b/modules/simul/src/main/SimulApi.scala index 4e0a94d669..df87014d72 100644 --- a/modules/simul/src/main/SimulApi.scala +++ b/modules/simul/src/main/SimulApi.scala @@ -6,8 +6,10 @@ import play.api.libs.json.Json import scala.concurrent.duration._ import lila.common.{ Bus, Debouncer } +import lila.db.dsl._ import lila.game.{ Game, GameRepo, PerfPicker } import lila.hub.actorApi.timeline.{ Propagate, SimulCreate, SimulJoin } +import lila.hub.LightTeam.TeamID import lila.memo.CacheApi._ import lila.socket.Socket.SendToFlag import lila.user.{ User, UserRepo } @@ -222,6 +224,9 @@ final class SimulApi( def idToName(id: Simul.ID): Fu[Option[String]] = repo find id dmap2 { _.fullName } + def teamOf(id: Simul.ID): Fu[Option[TeamID]] = + repo.coll.primitiveOne[TeamID]($id(id), "team") + private def makeGame(simul: Simul, host: User)( pairingAndNumber: (SimulPairing, Int) ): Fu[(Game, chess.Color)] = diff --git a/modules/simul/src/main/SimulRepo.scala b/modules/simul/src/main/SimulRepo.scala index b0954dca58..a1811822be 100644 --- a/modules/simul/src/main/SimulRepo.scala +++ b/modules/simul/src/main/SimulRepo.scala @@ -10,7 +10,7 @@ import lila.db.BSON.BSONJodaDateTimeHandler import lila.db.dsl._ import lila.user.User -final private[simul] class SimulRepo(simulColl: Coll)(implicit ec: scala.concurrent.ExecutionContext) { +final private[simul] class SimulRepo(val coll: Coll)(implicit ec: scala.concurrent.ExecutionContext) { implicit private val SimulStatusBSONHandler = tryHandler[SimulStatus]( { case BSONInteger(v) => SimulStatus(v) toTry s"No such simul status: $v" }, @@ -57,13 +57,13 @@ final private[simul] class SimulRepo(simulColl: Coll)(implicit ec: scala.concurr private val createdSort = $sort desc "createdAt" def find(id: Simul.ID): Fu[Option[Simul]] = - simulColl.byId[Simul](id) + coll.byId[Simul](id) def byIds(ids: List[Simul.ID]): Fu[List[Simul]] = - simulColl.byIds[Simul](ids) + coll.byIds[Simul](ids) def exists(id: Simul.ID): Fu[Boolean] = - simulColl.exists($id(id)) + coll.exists($id(id)) def findStarted(id: Simul.ID): Fu[Option[Simul]] = find(id) map (_ filter (_.isStarted)) @@ -72,22 +72,22 @@ final private[simul] class SimulRepo(simulColl: Coll)(implicit ec: scala.concurr find(id) map (_ filter (_.isCreated)) def findPending(hostId: User.ID): Fu[List[Simul]] = - simulColl.list[Simul](createdSelect ++ $doc("hostId" -> hostId)) + coll.list[Simul](createdSelect ++ $doc("hostId" -> hostId)) def byTeamLeaders(teamId: String, hostIds: Seq[User.ID]): Fu[List[Simul]] = - simulColl + coll .find( createdSelect ++ $doc("hostId" $in hostIds, "team" $in List(BSONString(teamId))) ) - .hint(simulColl hint $doc("hostId" -> 1)) + .hint(coll hint $doc("hostId" -> 1)) .cursor[Simul]() .list() private val featurableSelect = $doc("featurable" -> true) def allCreatedFeaturable: Fu[List[Simul]] = - simulColl + coll .find( // hits partial index hostSeenAt_-1 createdSelect ++ featurableSelect ++ $doc( @@ -96,7 +96,7 @@ final private[simul] class SimulRepo(simulColl: Coll)(implicit ec: scala.concurr ) ) .sort(createdSort) - .hint(simulColl hint $doc("hostSeenAt" -> -1)) + .hint(coll hint $doc("hostSeenAt" -> -1)) .cursor[Simul]() .list() map { _.foldLeft(List.empty[Simul]) { @@ -106,29 +106,29 @@ final private[simul] class SimulRepo(simulColl: Coll)(implicit ec: scala.concurr } def allStarted: Fu[List[Simul]] = - simulColl + coll .find(startedSelect) .sort(createdSort) .cursor[Simul]() .list() def allFinishedFeaturable(max: Int): Fu[List[Simul]] = - simulColl + coll .find(finishedSelect ++ featurableSelect) .sort($sort desc "finishedAt") .cursor[Simul]() .list(max) def allNotFinished = - simulColl.list[Simul]($doc("status" $ne SimulStatus.Finished.id)) + coll.list[Simul]($doc("status" $ne SimulStatus.Finished.id)) def create(simul: Simul, featurable: Boolean): Funit = - simulColl.insert one { + coll.insert one { SimulBSONHandler.writeTry(simul).get ++ featurable.??(featurableSelect) } void def update(simul: Simul, featurable: Option[Boolean]) = - simulColl.update + coll.update .one( $id(simul.id), $set(SimulBSONHandler writeTry simul get) ++ featurable.?? { feat => @@ -138,10 +138,10 @@ final private[simul] class SimulRepo(simulColl: Coll)(implicit ec: scala.concurr .void def remove(simul: Simul) = - simulColl.delete.one($id(simul.id)).void + coll.delete.one($id(simul.id)).void def setHostGameId(simul: Simul, gameId: String) = - simulColl.update + coll.update .one( $id(simul.id), $set("hostGameId" -> gameId) @@ -149,7 +149,7 @@ final private[simul] class SimulRepo(simulColl: Coll)(implicit ec: scala.concurr .void def setHostSeenNow(simul: Simul) = - simulColl.update + coll.update .one( $id(simul.id), $set("hostSeenAt" -> DateTime.now) @@ -157,7 +157,7 @@ final private[simul] class SimulRepo(simulColl: Coll)(implicit ec: scala.concurr .void def setText(simul: Simul, text: String) = - simulColl.update + coll.update .one( $id(simul.id), $set("text" -> text) @@ -165,7 +165,7 @@ final private[simul] class SimulRepo(simulColl: Coll)(implicit ec: scala.concurr .void def cleanup = - simulColl.delete.one( + coll.delete.one( createdSelect ++ $doc( "createdAt" -> $doc("$lt" -> (DateTime.now minusMinutes 60)) ) diff --git a/modules/study/src/main/StudyMember.scala b/modules/study/src/main/StudyMember.scala index 64e9fe62a9..af92aa7b7a 100644 --- a/modules/study/src/main/StudyMember.scala +++ b/modules/study/src/main/StudyMember.scala @@ -32,7 +32,8 @@ case class StudyMembers(members: StudyMember.MemberMap) { def get = members.get _ - def ids = members.keys + def ids = members.keys + def idSet = members.keySet def contributorIds: Set[User.ID] = members.view.collect { diff --git a/modules/swiss/src/main/SwissApi.scala b/modules/swiss/src/main/SwissApi.scala index 147c0a8ced..ed9324492d 100644 --- a/modules/swiss/src/main/SwissApi.scala +++ b/modules/swiss/src/main/SwissApi.scala @@ -452,6 +452,9 @@ final class SwissApi( .sort($sort desc "startsAt") .cursor[Swiss]() + def teamOf(id: Swiss.Id): Fu[Option[TeamID]] = + colls.swiss.primitiveOne[TeamID]($id(id), "teamId") + private def recomputeAndUpdateAll(id: Swiss.Id): Funit = scoring(id).flatMap { _ ?? { res => diff --git a/modules/team/src/main/TeamApi.scala b/modules/team/src/main/TeamApi.scala index f5c8388152..e69d6d4cfe 100644 --- a/modules/team/src/main/TeamApi.scala +++ b/modules/team/src/main/TeamApi.scala @@ -30,7 +30,7 @@ final class TeamApi( import BSONHandlers._ - def team(id: Team.ID) = teamRepo.coll.byId[Team](id) + def team(id: Team.ID) = teamRepo byId id def leaderTeam(id: Team.ID) = teamRepo.coll.byId[LeaderTeam](id, $doc("name" -> true)) diff --git a/modules/team/src/main/TeamRepo.scala b/modules/team/src/main/TeamRepo.scala index c831f7da47..269b85d172 100644 --- a/modules/team/src/main/TeamRepo.scala +++ b/modules/team/src/main/TeamRepo.scala @@ -15,6 +15,8 @@ final class TeamRepo(val coll: Coll)(implicit ec: scala.concurrent.ExecutionCont private val lightProjection = $doc("name" -> true).some + def byId(id: Team.ID) = coll.byId[Team](id) + def byOrderedIds(ids: Seq[Team.ID]) = coll.byOrderedIds[Team, Team.ID](ids)(_.id) def byLeader(id: Team.ID, leaderId: User.ID): Fu[Option[Team]] = diff --git a/modules/tournament/src/main/TournamentRepo.scala b/modules/tournament/src/main/TournamentRepo.scala index 72610f7d74..2f7454f21b 100644 --- a/modules/tournament/src/main/TournamentRepo.scala +++ b/modules/tournament/src/main/TournamentRepo.scala @@ -144,6 +144,9 @@ final class TournamentRepo(val coll: Coll, playerCollName: CollName)(implicit private[tournament] def setForTeam(tourId: Tournament.ID, teamId: TeamID) = coll.update.one($id(tourId), $addToSet("forTeams" -> teamId)) + def isForTeam(tourId: Tournament.ID, teamId: TeamID) = + coll.exists($id(tourId) ++ $doc("forTeams" -> teamId)) + private[tournament] def withdrawableIds( userId: User.ID, teamId: Option[TeamID] = None diff --git a/modules/user/src/main/User.scala b/modules/user/src/main/User.scala index e73e0d11c9..96f50d2fe2 100644 --- a/modules/user/src/main/User.scala +++ b/modules/user/src/main/User.scala @@ -119,8 +119,10 @@ case class User( def addRole(role: String) = copy(roles = role :: roles) - def isVerified = roles.exists(_ contains "ROLE_VERIFIED") - def isApiHog = roles.exists(_ contains "ROLE_API_HOG") + def isVerified = roles.exists(_ contains "ROLE_VERIFIED") + def isSuperAdmin = roles.exists(_ contains "ROLE_SUPER_ADMIN") + def isAdmin = roles.exists(_ contains "ROLE_ADMIN") || isSuperAdmin + def isApiHog = roles.exists(_ contains "ROLE_API_HOG") } object User {