diff --git a/app/controllers/Analyse.scala b/app/controllers/Analyse.scala index 197e79169d..0ce5a95d9d 100644 --- a/app/controllers/Analyse.scala +++ b/app/controllers/Analyse.scala @@ -74,9 +74,7 @@ final class Analyse( pov, data, initialFen, - env.analyse - .annotator(pgn, analysis, pov.game.opening, pov.game.winnerColor, pov.game.status) - .toString, + env.analyse.annotator(pgn, pov.game, analysis).toString, analysis, analysisInProgress, simul, @@ -136,9 +134,7 @@ final class Analyse( html.analyse.replayBot( pov, initialFen, - env.analyse - .annotator(pgn, analysis, pov.game.opening, pov.game.winnerColor, pov.game.status) - .toString, + env.analyse.annotator(pgn, pov.game, analysis).toString, simul, crosstable ) diff --git a/app/controllers/Appeal.scala b/app/controllers/Appeal.scala index 93a8821934..2172395a09 100644 --- a/app/controllers/Appeal.scala +++ b/app/controllers/Appeal.scala @@ -82,7 +82,7 @@ final class Appeal(env: Env, reportC: => Report) extends LilaController(env) { Secure(_.Appeals) { implicit ctx => me => asMod(username) { (appeal, suspect) => env.appeal.api.toggleMute(appeal) >> - env.report.api.inquiries.toggle(lila.report.Mod(me), appeal.id) inject + env.report.api.inquiries.toggle(lila.report.Mod(me.user), appeal.id) inject Redirect(routes.Appeal.queue) } } diff --git a/app/controllers/Auth.scala b/app/controllers/Auth.scala index e73a35f6ab..b7e1a76b8e 100644 --- a/app/controllers/Auth.scala +++ b/app/controllers/Auth.scala @@ -297,8 +297,8 @@ final class Auth( _ ?? { hash => !me.lame ?? (for { otherIds <- api.recentUserIdsByFingerHash(hash).map(_.filter(me.id.!=)) - _ <- (otherIds.sizeIs >= 2) ?? env.user.repo.countEngines(otherIds).flatMap { - case nb if nb >= 2 && nb >= otherIds.size / 2 => env.report.api.autoCheatPrintReport(me.id) + _ <- (otherIds.sizeIs >= 2) ?? env.user.repo.countLameOrTroll(otherIds).flatMap { + case nb if nb >= 2 && nb >= otherIds.size / 2 => env.report.api.autoAltPrintReport(me.id) case _ => funit } } yield ()) diff --git a/app/controllers/Clas.scala b/app/controllers/Clas.scala index 4c28306c0f..af65e40fdf 100644 --- a/app/controllers/Clas.scala +++ b/app/controllers/Clas.scala @@ -8,6 +8,7 @@ import views._ import lila.api.Context import lila.app._ +import lila.user.Holder final class Clas( env: Env, @@ -55,7 +56,7 @@ final class Clas( .fold( err => BadRequest(html.clas.clas.create(err)).fuccess, data => - env.clas.api.clas.create(data, me) map { clas => + env.clas.api.clas.create(data, me.user) map { clas => Redirect(routes.Clas.show(clas.id.value)) } ) @@ -68,7 +69,7 @@ final class Clas( def show(id: String) = Auth { implicit ctx => me => WithClassAny(id, me)( - forTeacher = WithClass(me, id) { clas => + forTeacher = WithClass(Holder(me), id) { clas => env.clas.api.student.activeWithUsers(clas) map { students => preloadStudentUsers(students) views.html.clas.teacherDashboard.overview(clas, students) @@ -115,7 +116,7 @@ final class Clas( def wall(id: String) = Secure(_.Teacher) { implicit ctx => me => - WithClassAny(id, me)( + WithClassAny(id, me.user)( forTeacher = WithClass(me, id) { clas => env.clas.api.student.allWithUsers(clas) map { students => val wall = scalatags.Text.all.raw(env.clas.markup(clas.wall)) @@ -265,7 +266,7 @@ final class Clas( def archive(id: String, v: Boolean) = SecureBody(_.Teacher) { _ => me => WithClass(me, id) { clas => - env.clas.api.clas.archive(clas, me, v) inject + env.clas.api.clas.archive(clas, me.user, v) inject Redirect(routes.Clas.show(clas.id.value)).flashSuccess } } @@ -322,7 +323,7 @@ final class Clas( ) }, data => - env.clas.api.student.create(clas, data, me) map { s => + env.clas.api.student.create(clas, data, me.user) map { s => Redirect(routes.Clas.studentForm(clas.id.value)) .flashing("created" -> s"${s.student.userId} ${s.password.value}") } @@ -373,7 +374,7 @@ final class Clas( .fold( err => BadRequest(html.clas.student.manyForm(clas, students, err, nbStudents)).fuccess, data => - env.clas.api.student.manyCreate(clas, data, me) flatMap { many => + env.clas.api.student.manyCreate(clas, data, me.user) flatMap { many => env.user.lightUserApi.preloadMany(many.map(_.student.userId)) inject Redirect(routes.Clas.studentManyForm(clas.id.value)) .flashing( @@ -595,12 +596,12 @@ final class Clas( if (students.sizeIs <= lila.clas.Clas.maxStudents) f else Unauthorized(views.html.clas.teacherDashboard.unreasonable(clas, students, active)).fuccess - private def WithClass(me: lila.user.User, clasId: String)( + private def WithClass(me: Holder, clasId: String)( f: lila.clas.Clas => Fu[Result] ): Fu[Result] = - env.clas.api.clas.getAndView(lila.clas.Clas.Id(clasId), me) flatMap { _ ?? f } + env.clas.api.clas.getAndView(lila.clas.Clas.Id(clasId), me.user) flatMap { _ ?? f } - private def WithClassAndStudents(me: lila.user.User, clasId: String)( + private def WithClassAndStudents(me: Holder, clasId: String)( f: (lila.clas.Clas, List[lila.clas.Student]) => Fu[Result] ): Fu[Result] = WithClass(me, clasId) { c => diff --git a/app/controllers/Coach.scala b/app/controllers/Coach.scala index b0a64cd44c..6785db84fc 100644 --- a/app/controllers/Coach.scala +++ b/app/controllers/Coach.scala @@ -73,7 +73,7 @@ final class Coach(env: Env) extends LilaController(env) { def approveReview(id: String) = SecureBody(_.Coach) { implicit ctx => me => OptionFuResult(api.reviews.byId(id)) { review => - api.byId(review.coachId).map(_ ?? (_ is me)) flatMap { + api.byId(review.coachId).dmap(_.exists(_ is me.user)) flatMap { case false => notFound case true => api.reviews.approve(review, getBool("v")) inject Ok } @@ -123,7 +123,7 @@ final class Coach(env: Env) extends LilaController(env) { } def pictureApply = - AuthBody(parse.multipartFormData) { implicit ctx => me => + SecureBody(parse.multipartFormData)(lila.security.Permission.Coach) { implicit ctx => me => OptionFuResult(api findOrInit me) { c => ctx.body.body.file("picture") match { case Some(pic) => diff --git a/app/controllers/ForumTopic.scala b/app/controllers/ForumTopic.scala index 467b9821e0..3814e90484 100644 --- a/app/controllers/ForumTopic.scala +++ b/app/controllers/ForumTopic.scala @@ -7,6 +7,7 @@ import views._ import lila.app._ import lila.common.{ HTTPRequest, IpAddress } +import lila.user.Holder final class ForumTopic(env: Env) extends LilaController(env) with ForumController { @@ -78,7 +79,7 @@ final class ForumTopic(env: Env) extends LilaController(env) with ForumControlle Auth { implicit ctx => me => CategGrantMod(categSlug) { OptionFuRedirect(topicApi.show(categSlug, slug, 1, ctx.me)) { case (categ, topic, pag) => - topicApi.toggleClose(categ, topic, me) inject + topicApi.toggleClose(categ, topic, Holder(me)) inject routes.ForumTopic.show(categSlug, slug, pag.nbPages) } } @@ -96,7 +97,7 @@ final class ForumTopic(env: Env) extends LilaController(env) with ForumControlle Auth { implicit ctx => me => CategGrantMod(categSlug) { OptionFuRedirect(topicApi.show(categSlug, slug, 1, ctx.me)) { case (categ, topic, pag) => - topicApi.toggleSticky(categ, topic, me) inject + topicApi.toggleSticky(categ, topic, Holder(me)) inject routes.ForumTopic.show(categSlug, slug, pag.nbPages) } } diff --git a/app/controllers/GameMod.scala b/app/controllers/GameMod.scala index 3c20625058..2bd86b5323 100644 --- a/app/controllers/GameMod.scala +++ b/app/controllers/GameMod.scala @@ -12,6 +12,7 @@ import lila.common.HTTPRequest import lila.db.dsl._ import lila.api.GameApiV2 import lila.common.config +import lila.user.Holder final class GameMod(env: Env) extends LilaController(env) { @@ -54,7 +55,7 @@ final class GameMod(env: Env) extends LilaController(env) { } } - private def multipleAnalysis(me: lila.user.User, gameIds: Seq[lila.game.Game.ID])(implicit ctx: Context) = + private def multipleAnalysis(me: Holder, gameIds: Seq[lila.game.Game.ID])(implicit ctx: Context) = env.game.gameRepo.unanalysedGames(gameIds).flatMap { games => games.map { game => env.fishnet.analyser( @@ -107,7 +108,7 @@ object GameMod { val emptyFilter = Filter(none, none, none) def toDbSelect(filter: Filter): Bdoc = - lila.game.Query.notSimul ++ filter.arena.?? { id => + lila.game.Query.notSimul ++ lila.game.Query.clock(true) ++ filter.arena.?? { id => $doc(lila.game.Game.BSONFields.tournamentId -> id) } ++ filter.swiss.?? { id => $doc(lila.game.Game.BSONFields.swissId -> id) diff --git a/app/controllers/LilaController.scala b/app/controllers/LilaController.scala index 44929c1ab8..4bbb140649 100644 --- a/app/controllers/LilaController.scala +++ b/app/controllers/LilaController.scala @@ -17,7 +17,7 @@ import lila.i18n.I18nLangPicker import lila.notify.Notification.Notifies import lila.oauth.{ OAuthScope, OAuthServer } import lila.security.{ FingerPrintedUser, Granter, Permission } -import lila.user.{ UserContext, User => UserModel } +import lila.user.{ UserContext, User => UserModel, Holder } abstract private[controllers] class LilaController(val env: Env) extends BaseController @@ -171,17 +171,17 @@ abstract private[controllers] class LilaController(val env: Env) } } - protected def Secure(perm: Permission.Selector)(f: Context => UserModel => Fu[Result]): Action[AnyContent] = + protected def Secure(perm: Permission.Selector)(f: Context => Holder => Fu[Result]): Action[AnyContent] = Secure(perm(Permission))(f) - protected def Secure(perm: Permission)(f: Context => UserModel => Fu[Result]): Action[AnyContent] = + protected def Secure(perm: Permission)(f: Context => Holder => Fu[Result]): Action[AnyContent] = Secure(parse.anyContent)(perm)(f) protected def Secure[A]( parser: BodyParser[A] - )(perm: Permission)(f: Context => UserModel => Fu[Result]): Action[A] = + )(perm: Permission)(f: Context => Holder => Fu[Result]): Action[A] = Auth(parser) { implicit ctx => me => - if (isGranted(perm)) f(ctx)(me) else authorizationFailed + if (isGranted(perm)) f(ctx)(Holder(me)) else authorizationFailed } protected def SecureF(s: UserModel => Boolean)(f: Context => UserModel => Fu[Result]): Action[AnyContent] = @@ -191,14 +191,14 @@ abstract private[controllers] class LilaController(val env: Env) protected def SecureBody[A]( parser: BodyParser[A] - )(perm: Permission)(f: BodyContext[A] => UserModel => Fu[Result]): Action[A] = + )(perm: Permission)(f: BodyContext[A] => Holder => Fu[Result]): Action[A] = AuthBody(parser) { implicit ctx => me => - if (isGranted(perm)) f(ctx)(me) else authorizationFailed + if (isGranted(perm)) f(ctx)(Holder(me)) else authorizationFailed } protected def SecureBody( perm: Permission.Selector - )(f: BodyContext[_] => UserModel => Fu[Result]): Action[AnyContent] = + )(f: BodyContext[_] => Holder => Fu[Result]): Action[AnyContent] = SecureBody(parse.anyContent)(perm(Permission))(f) protected def Scoped[A]( @@ -247,26 +247,26 @@ abstract private[controllers] class LilaController(val env: Env) } protected def SecureOrScoped(perm: Permission.Selector)( - secure: Context => UserModel => Fu[Result], - scoped: RequestHeader => UserModel => Fu[Result] + secure: Context => Holder => Fu[Result], + scoped: RequestHeader => Holder => Fu[Result] ): Action[Unit] = Action.async(parse.empty) { req => if (HTTPRequest isOAuth req) securedScopedAction(perm, req)(scoped) else Secure(parse.empty)(perm(Permission))(secure)(req) } protected def SecureOrScopedBody(perm: Permission.Selector)( - secure: BodyContext[_] => UserModel => Fu[Result], - scoped: RequestHeader => UserModel => Fu[Result] + secure: BodyContext[_] => Holder => Fu[Result], + scoped: RequestHeader => Holder => Fu[Result] ): Action[AnyContent] = Action.async(parse.anyContent) { req => if (HTTPRequest isOAuth req) securedScopedAction(perm, req.map(_ => ()))(scoped) else SecureBody(parse.anyContent)(perm(Permission))(secure)(req) } private def securedScopedAction(perm: Permission.Selector, req: Request[Unit])( - f: RequestHeader => UserModel => Fu[Result] + f: RequestHeader => Holder => Fu[Result] ) = Scoped() { req => me => - IfGranted(perm, req, me)(f(req)(me)) + IfGranted(perm, req, me)(f(req)(Holder(me))) }(req) def IfGranted(perm: Permission.Selector)(f: => Fu[Result])(implicit ctx: Context): Fu[Result] = diff --git a/app/controllers/Mod.scala b/app/controllers/Mod.scala index 0087da3858..9b7bfc84eb 100644 --- a/app/controllers/Mod.scala +++ b/app/controllers/Mod.scala @@ -14,7 +14,7 @@ import lila.common.{ EmailAddress, HTTPRequest, IpAddress } import lila.mod.UserSearch import lila.report.{ Suspect, Mod => AsMod } import lila.security.{ FingerHash, Permission } -import lila.user.{ User => UserModel, Title } +import lila.user.{ User => UserModel, Title, Holder } final class Mod( env: Env, @@ -26,12 +26,14 @@ final class Mod( private def modLogApi = env.mod.logApi private def assessApi = env.mod.assessApi + implicit private def asMod(holder: Holder) = AsMod(holder.user) + def alt(username: String, v: Boolean) = OAuthModBody(_.CloseAccount) { me => withSuspect(username) { sus => for { inquiry <- env.report.api.inquiries ofModId me.id - _ <- modApi.setAlt(AsMod(me), sus, v) + _ <- modApi.setAlt(me, sus, v) _ <- (v && sus.user.enabled) ?? env.closeAccount(sus.user.id, self = false) } yield (inquiry, sus).some } @@ -46,7 +48,7 @@ final class Mod( withSuspect(username) { sus => for { inquiry <- env.report.api.inquiries ofModId me.id - _ <- modApi.setEngine(AsMod(me), sus, v) + _ <- modApi.setEngine(me, sus, v) } yield (inquiry, sus).some } }(ctx => @@ -62,12 +64,19 @@ final class Mod( } } + def publicChatTimeout = + SecureBody(_.ChatTimeout) { implicit ctx => me => + FormResult(lila.chat.ChatTimeout.form) { data => + env.chat.api.userChat.publicTimeout(data, me) + }(ctx.body) + } + def booster(username: String, v: Boolean) = OAuthModBody(_.MarkBooster) { me => withSuspect(username) { prev => for { inquiry <- env.report.api.inquiries ofModId me.id - suspect <- modApi.setBoost(AsMod(me), prev, v) + suspect <- modApi.setBoost(me, prev, v) } yield (inquiry, suspect).some } }(ctx => @@ -81,7 +90,7 @@ final class Mod( withSuspect(username) { prev => for { inquiry <- env.report.api.inquiries ofModId me.id - suspect <- modApi.setTroll(AsMod(me), prev, v) + suspect <- modApi.setTroll(me, prev, v) } yield (inquiry, suspect).some } }(ctx => @@ -96,7 +105,7 @@ final class Mod( withSuspect(username) { prev => for { inquiry <- env.report.api.inquiries ofModId me.id - suspect <- modApi.setTroll(AsMod(me), prev, prev.user.marks.troll) + suspect <- modApi.setTroll(me, prev, prev.user.marks.troll) _ <- env.msg.api.systemPost(suspect.user.id, preset.text) _ <- env.mod.logApi.modMessage(me.id, suspect.user.id, preset.name) } yield (inquiry, suspect).some @@ -123,7 +132,7 @@ final class Mod( def disableTwoFactor(username: String) = Secure(_.DisableTwoFactor) { implicit ctx => me => - modApi.disableTwoFactor(me.id, username) >> userC.modZoneOrRedirect(username) + modApi.disableTwoFactor(me.id, username) >> userC.modZoneOrRedirect(me, username) } def closeAccount(username: String) = @@ -144,14 +153,14 @@ final class Mod( def reportban(username: String, v: Boolean) = OAuthMod(_.ReportBan) { _ => me => withSuspect(username) { sus => - modApi.setReportban(AsMod(me), sus, v) map some + modApi.setReportban(me, sus, v) map some } }(actionResult(username)) def rankban(username: String, v: Boolean) = OAuthMod(_.RemoveRanking) { _ => me => withSuspect(username) { sus => - modApi.setRankban(AsMod(me), sus, v) map some + modApi.setRankban(me, sus, v) map some } }(actionResult(username)) @@ -219,6 +228,7 @@ final class Mod( if (priv) perms.ViewPrivateComms else perms.Shadowban } { implicit ctx => me => OptionFuOk(env.user.repo named username) { user => + implicit val renderIp = env.mod.ipRender(me) env.game.gameRepo .recentPovsByUserFromSecondary(user, 80) .mon(_.mod.comm.segment("recentPovs")) @@ -259,6 +269,7 @@ final class Mod( } } html.mod.communication( + me, user, (povs zip chats) collect { case (p, Some(c)) if c.nonEmpty => p -> c @@ -288,7 +299,7 @@ final class Mod( Secure(_.MarkEngine) { implicit ctx => me => OptionFuResult(env.user.repo named username) { user => assessApi.refreshAssessOf(user) >> - env.irwin.api.requests.fromMod(Suspect(user), AsMod(me)) >> + env.irwin.api.requests.fromMod(Suspect(user), me) >> userC.renderModZoneActions(username) } } @@ -300,7 +311,7 @@ final class Mod( val f = if (isAppeal) env.report.api.inquiries.appeal _ else env.report.api.inquiries.spontaneous _ - f(AsMod(me), Suspect(user)) inject { + f(me, Suspect(user)) inject { if (isAppeal) Redirect(routes.Appeal.show(user.username)) else redirect(user.username, mod = true) } @@ -343,7 +354,7 @@ final class Mod( } def print(fh: String) = - SecureBody(_.PrintBan) { implicit ctx => _ => + SecureBody(_.ViewPrintNoIP) { implicit ctx => _ => val hash = FingerHash(fh) for { uids <- env.security.api recentUserIdsByFingerHash hash @@ -360,8 +371,9 @@ final class Mod( } def singleIp(ip: String) = - SecureBody(_.IpBan) { implicit ctx => _ => - IpAddress.from(ip) ?? { address => + SecureBody(_.ViewPrintNoIP) { implicit ctx => me => + implicit val renderIp = env.mod.ipRender(me) + env.mod.ipRender.decrypt(ip) ?? { address => for { uids <- env.security.api recentUserIdsByIp address users <- env.user.repo usersFromSecondary uids.reverse @@ -409,7 +421,7 @@ final class Mod( _ => BadRequest(html.mod.permissions(user, me)).fuccess, permissions => { val newPermissions = Permission(permissions) diff Permission(user.roles) - modApi.setPermissions(AsMod(me), user.username, Permission(permissions)) >> { + modApi.setPermissions(me, user.username, Permission(permissions)) >> { newPermissions(Permission.Coach) ?? env.security.automaticEmail.onBecomeCoach(user) } >> { Permission(permissions).exists(_ is Permission.SeeReport) ?? env.plan.api.setLifetime(user) @@ -493,8 +505,8 @@ final class Mod( _ ?? f } - private def OAuthMod[A](perm: Permission.Selector)(f: RequestHeader => UserModel => Fu[Option[A]])( - secure: Context => UserModel => A => Fu[Result] + private def OAuthMod[A](perm: Permission.Selector)(f: RequestHeader => Holder => Fu[Option[A]])( + secure: Context => Holder => A => Fu[Result] ): Action[Unit] = SecureOrScoped(perm)( secure = ctx => me => f(ctx.req)(me) flatMap { _ ?? secure(ctx)(me) }, @@ -504,8 +516,8 @@ final class Mod( res.isDefined ?? fuccess(jsonOkResult) } ) - private def OAuthModBody[A](perm: Permission.Selector)(f: UserModel => Fu[Option[A]])( - secure: BodyContext[_] => UserModel => A => Fu[Result] + private def OAuthModBody[A](perm: Permission.Selector)(f: Holder => Fu[Option[A]])( + secure: BodyContext[_] => Holder => A => Fu[Result] ): Action[AnyContent] = SecureOrScopedBody(perm)( secure = ctx => me => f(me) flatMap { _ ?? secure(ctx)(me) }, @@ -518,7 +530,7 @@ final class Mod( private def actionResult( username: String - )(ctx: Context)(@nowarn("cat=unused") user: UserModel)(@nowarn("cat=unused") res: Any) = + )(ctx: Context)(@nowarn("cat=unused") user: Holder)(@nowarn("cat=unused") res: Any) = if (HTTPRequest isSynchronousHttp ctx.req) fuccess(redirect(username)) else userC.renderModZoneActions(username)(ctx) } diff --git a/app/controllers/Relation.scala b/app/controllers/Relation.scala index 7129846ea9..b43609bc3c 100644 --- a/app/controllers/Relation.scala +++ b/app/controllers/Relation.scala @@ -1,6 +1,7 @@ package controllers import play.api.libs.json.Json +import scala.concurrent.duration._ import lila.api.Context import lila.app._ @@ -42,29 +43,40 @@ final class Relation( ) } + private val FollowLimitPerUser = new lila.memo.RateLimit[lila.user.User.ID]( + credits = 150, + duration = 72.hour, + key = "follow.user" + ) + def follow(userId: String) = Auth { implicit ctx => me => - api.reachedMaxFollowing(me.id) flatMap { - case true => - env.msg.api - .postPreset( - me.id, - lila.msg.MsgPreset.maxFollow(me.username, env.relation.maxFollow.value) - ) - .void - case _ => - api.follow(me.id, UserModel normalize userId).nevermind >> renderActions(userId, getBool("mini")) - } + FollowLimitPerUser(me.id) { + api.reachedMaxFollowing(me.id) flatMap { + case true => + env.msg.api + .postPreset( + me.id, + lila.msg.MsgPreset.maxFollow(me.username, env.relation.maxFollow.value) + ) inject Ok + case _ => + api.follow(me.id, UserModel normalize userId).nevermind >> renderActions(userId, getBool("mini")) + } + }(rateLimitedFu) } def unfollow(userId: String) = Auth { implicit ctx => me => - api.unfollow(me.id, UserModel normalize userId).nevermind >> renderActions(userId, getBool("mini")) + FollowLimitPerUser(me.id) { + api.unfollow(me.id, UserModel normalize userId).nevermind >> renderActions(userId, getBool("mini")) + }(rateLimitedFu) } def block(userId: String) = Auth { implicit ctx => me => - api.block(me.id, UserModel normalize userId).nevermind >> renderActions(userId, getBool("mini")) + FollowLimitPerUser(me.id) { + api.block(me.id, UserModel normalize userId).nevermind >> renderActions(userId, getBool("mini")) + }(rateLimitedFu) } def unblock(userId: String) = diff --git a/app/controllers/Report.scala b/app/controllers/Report.scala index fca024c00e..3e5c9a025d 100644 --- a/app/controllers/Report.scala +++ b/app/controllers/Report.scala @@ -7,7 +7,7 @@ import lila.api.{ BodyContext, Context } import lila.app._ import lila.common.HTTPRequest import lila.report.{ Room, Report => ReportModel, Mod => AsMod, Reporter, Suspect } -import lila.user.{ User => UserModel } +import lila.user.{ User => UserModel, Holder } final class Report( env: Env, @@ -17,9 +17,11 @@ final class Report( private def api = env.report.api + implicit private def asMod(holder: Holder) = AsMod(holder.user) + def list = Secure(_.SeeReport) { implicit ctx => me => - if (env.streamer.liveStreamApi.isStreaming(me.id) && !getBool("force")) + if (env.streamer.liveStreamApi.isStreaming(me.user.id) && !getBool("force")) fuccess(Forbidden(html.site.message.streamingMod)) else renderList(me, env.report.modFilters.get(me).fold("all")(_.key)) } @@ -34,7 +36,7 @@ final class Report( protected[controllers] def getScores = api.maxScores zip env.streamer.api.approval.countRequests zip env.appeal.api.countUnread - private def renderList(me: UserModel, room: String)(implicit ctx: Context) = + private def renderList(me: Holder, room: String)(implicit ctx: Context) = api.openAndRecentWithFilter(12, Room(room)) zip getScores flatMap { case (reports, scores ~ streamers ~ appeals) => (env.user.lightUserApi preloadMany reports.flatMap(_.report.userIds)) inject @@ -52,7 +54,7 @@ final class Report( def inquiry(id: String) = Secure(_.SeeReport) { _ => me => - api.inquiries.toggle(AsMod(me), id) flatMap { case (prev, next) => + api.inquiries.toggle(me, id) flatMap { case (prev, next) => prev.filter(_.isAppeal).map(_.user).??(env.appeal.api.unreadById) inject next.fold( Redirect { @@ -70,7 +72,7 @@ final class Report( protected[controllers] def onInquiryClose( inquiry: Option[ReportModel], - me: UserModel, + me: Holder, goTo: Option[Suspect], force: Boolean = false )(implicit ctx: BodyContext[_]): Fu[Result] = @@ -80,7 +82,7 @@ final class Report( inquiry match { case None => goTo.fold(Redirect(routes.Report.list).fuccess) { s => - userC.modZoneOrRedirect(s.user.username) + userC.modZoneOrRedirect(me, s.user.username) } case Some(prev) => val dataOpt = ctx.body.body match { @@ -98,12 +100,12 @@ final class Report( def redirectToList = Redirect(routes.Report.listWithFilter(prev.room.key)) if (prev.isAppeal) Redirect(routes.Appeal.queue).fuccess else if (dataOpt.flatMap(_ get "next").exists(_.headOption contains "1")) - api.inquiries.toggleNext(AsMod(me), prev.room) map { + api.inquiries.toggleNext(me, prev.room) map { _.fold(redirectToList)(onInquiryStart) } - else if (force) userC.modZoneOrRedirect(prev.user) + else if (force) userC.modZoneOrRedirect(me, prev.user) else - api.inquiries.toggle(AsMod(me), prev.id) map { case (prev, next) => + api.inquiries.toggle(me, prev.id) map { case (prev, next) => next .fold( if (prev.exists(_.isAppeal)) Redirect(routes.Appeal.queue) @@ -118,7 +120,7 @@ final class Report( SecureBody(_.SeeReport) { implicit ctx => me => api byId id flatMap { inquiry => inquiry.filter(_.isAppeal).map(_.user).??(env.appeal.api.readById) >> - api.process(AsMod(me), id) >> + api.process(me, id) >> onInquiryClose(inquiry, me, none, force = true) } } @@ -133,7 +135,7 @@ final class Report( OptionFuResult(env.user.repo named username) { user => api.currentCheatReport(lila.report.Suspect(user)) flatMap { _ ?? { report => - api.inquiries.toggle(lila.report.Mod(me), report.id).void + api.inquiries.toggle(me, report.id).void } inject modC.redirect(username, mod = true) } } diff --git a/app/controllers/Team.scala b/app/controllers/Team.scala index 5811d1e866..de4d02cd8b 100644 --- a/app/controllers/Team.scala +++ b/app/controllers/Team.scala @@ -11,7 +11,7 @@ import lila.api.Context import lila.app._ import lila.common.config.MaxPerSecond import lila.team.{ Requesting, Team => TeamModel } -import lila.user.{ User => UserModel } +import lila.user.{ User => UserModel, Holder } final class Team( env: Env, @@ -514,7 +514,7 @@ final class Team( --- You received this because you are subscribed to messages of the team $url.""" env.msg.api - .multiPost(me, env.team.memberStream.subscribedIds(team, MaxPerSecond(50)), full) + .multiPost(Holder(me), env.team.memberStream.subscribedIds(team, MaxPerSecond(50)), full) .addEffect { nb => lila.mon.msg.teamBulk(team.id).record(nb).unit } diff --git a/app/controllers/TournamentCrud.scala b/app/controllers/TournamentCrud.scala index 0326881a77..955e91f57d 100644 --- a/app/controllers/TournamentCrud.scala +++ b/app/controllers/TournamentCrud.scala @@ -48,7 +48,7 @@ final class TournamentCrud(env: Env) extends LilaController(env) { .fold( err => BadRequest(html.tournament.crud.create(err)).fuccess, data => - crud.create(data, me) map { tour => + crud.create(data, me.user) map { tour => Redirect { if (tour.isTeamBattle) routes.Tournament.teamBattleEdit(tour.id) else routes.TournamentCrud.edit(tour.id) diff --git a/app/controllers/User.scala b/app/controllers/User.scala index 03f1bba21f..7ac32bf1ff 100644 --- a/app/controllers/User.scala +++ b/app/controllers/User.scala @@ -19,9 +19,10 @@ import lila.common.paginator.Paginator import lila.common.{ HTTPRequest, IpAddress } import lila.game.{ Pov, Game => GameModel } import lila.rating.PerfType -import lila.socket.UserLagCache -import lila.user.{ User => UserModel } import lila.security.UserLogins +import lila.socket.UserLagCache +import lila.user.{ User => UserModel, Holder } +import lila.security.Granter final class User( env: Env, @@ -323,12 +324,14 @@ final class User( } def mod(username: String) = - Secure(_.UserModView) { implicit ctx => _ => - modZoneOrRedirect(username) + Secure(_.UserModView) { implicit ctx => holder => + modZoneOrRedirect(holder, username) } - protected[controllers] def modZoneOrRedirect(username: String)(implicit ctx: Context): Fu[Result] = - if (HTTPRequest isEventSource ctx.req) renderModZone(username) + protected[controllers] def modZoneOrRedirect(holder: Holder, username: String)(implicit + ctx: Context + ): Fu[Result] = + if (HTTPRequest isEventSource ctx.req) renderModZone(holder, username) else fuccess(modC.redirect(username)) private def modZoneSegment(fu: Fu[Frag], name: String, user: UserModel): Source[Frag, _] = @@ -352,11 +355,14 @@ final class User( } } - protected[controllers] def renderModZone(username: String)(implicit ctx: Context): Fu[Result] = { + protected[controllers] def renderModZone(holder: Holder, username: String)(implicit + ctx: Context + ): Fu[Result] = { env.user.repo withEmails username orFail s"No such user $username" map { case UserModel.WithEmails(user, emails) => import html.user.{ mod => view } import lila.app.ui.ScalatagsExtensions.LilaFragZero + implicit val renderIp = env.mod.ipRender(holder) val nbOthers = getInt("nbOthers") | 100 @@ -386,12 +392,12 @@ final class User( val userLoginsFu = env.security.userLogins(user, nbOthers) val others = userLoginsFu flatMap { userLogins => loginsTableData(user, userLogins, nbOthers) map { - html.user.mod.otherUsers(user, _) + html.user.mod.otherUsers(holder, user, _) } } - val identification = userLoginsFu map { spy => - (isGranted(_.Doxing) || (user.lameOrAlt && !user.hasTitle)) ?? - html.user.mod.identification(spy) + val identification = userLoginsFu map { logins => + Granter.is(_.ViewPrintNoIP)(holder) ?? + html.user.mod.identification(holder, logins) } val irwin = isGranted(_.MarkEngine) ?? env.irwin.api.reports.withPovs(user).map { _ ?? { reps => diff --git a/app/views/appeal/tree.scala b/app/views/appeal/tree.scala index e1f38f0912..d9e5be0b22 100644 --- a/app/views/appeal/tree.scala +++ b/app/views/appeal/tree.scala @@ -86,7 +86,7 @@ object tree { ) ) ), - content = p( + content = frag( "We define this as using any external assistance to strengthen your knowledge and, or, calculation ability to gain an unfair advantage over your opponent. Some examples would include computer engine assistance, opening books (except for correspondence games), endgame tablebases, and asking another player for help, although these aren’t the only things we would consider cheating." ).some ) @@ -233,7 +233,12 @@ object tree { none ) ), - p(cls := "appeal__moderators text", dataIcon := "")(doNotMessageModerators()) + div(cls := "appeal__rules")( + p(cls := "text", dataIcon := "")(doNotMessageModerators()), + a(cls := "text", dataIcon := "", href := routes.Page.loneBookmark("appeal"))( + "Read more about the appeal process" + ) + ) ) } diff --git a/app/views/mod/communication.scala b/app/views/mod/communication.scala index 6bc4c04fbb..c310d99020 100644 --- a/app/views/mod/communication.scala +++ b/app/views/mod/communication.scala @@ -1,17 +1,20 @@ package views.html.mod +import controllers.routes + import lila.api.Context import lila.app.templating.Environment._ import lila.app.ui.ScalatagsTemplate._ import lila.common.String.html.richText import lila.hub.actorApi.shutup.PublicSource - -import controllers.routes +import lila.mod.IpRender.RenderIp +import lila.user.{ Holder, User } object communication { def apply( - u: lila.user.User, + mod: Holder, + u: User, players: List[(lila.game.Pov, lila.chat.MixedChat)], convos: List[lila.msg.MsgConvo], publicLines: List[lila.shutup.PublicLine], @@ -19,7 +22,7 @@ object communication { history: List[lila.mod.Modlog], logins: lila.security.UserLogins.TableData, priv: Boolean - )(implicit ctx: Context) = + )(implicit ctx: Context, renderIp: RenderIp) = views.html.base.layout( title = u.username + " communications", moreCss = frag( @@ -54,7 +57,7 @@ object communication { ), isGranted(_.UserModView) option frag( div(cls := "mod-zone none"), - views.html.user.mod.otherUsers(u, logins)(ctx)(cls := "communication__logins") + views.html.user.mod.otherUsers(mod, u, logins)(ctx, renderIp)(cls := "communication__logins") ), history.nonEmpty option frag( h2("Moderation history"), @@ -78,7 +81,7 @@ object communication { h2("Notes from other users"), div(cls := "notes")( notes.map { note => - (isGranted(_.Doxing) || !note.dox) option + (isGranted(_.Admin) || !note.dox) option div(userIdLink(note.from.some), " ", momentFromNowOnce(note.date), ": ", richText(note.text)) } ) @@ -129,7 +132,7 @@ object communication { "author" -> (line.author.toLowerCase == u.id) ) )( - userIdLink(line.author.toLowerCase.some, withOnline = false, withTitle = false), + userIdLink(line.userIdMaybe, withOnline = false, withTitle = false), nbsp, richText(line.text) ) diff --git a/app/views/mod/games.scala b/app/views/mod/games.scala index 57acacfcf3..1e5a3381ab 100644 --- a/app/views/mod/games.scala +++ b/app/views/mod/games.scala @@ -130,7 +130,7 @@ object games { ) } ), - td(dataSort := pov.game.playedTurns)(pov.game.playedTurns), + td(dataSort := pov.moves)(pov.moves), td(dataSort := ~pov.player.ratingDiff)( pov.win match { case Some(true) => goodTag(cls := "result")("1") diff --git a/app/views/mod/inquiry.scala b/app/views/mod/inquiry.scala index 6b4244a6bc..e35c10c8b5 100644 --- a/app/views/mod/inquiry.scala +++ b/app/views/mod/inquiry.scala @@ -54,7 +54,7 @@ object inquiry { ) def renderNote(r: lila.user.Note)(implicit ctx: Context) = - (!r.dox || isGranted(_.Doxing)) option div(cls := "doc note")( + (!r.dox || isGranted(_.Admin)) option div(cls := "doc note")( h3("by ", userIdLink(r.from.some, withOnline = false), ", ", momentFromNow(r.date)), p(richText(r.text, expandImg = false)) ) @@ -239,16 +239,18 @@ object inquiry { .withFilter(_.byLichess) .flatMap(_.text.linesIterator) .collect { - case farmWithRegex(userId) => userId - case sandbagWithRegex(userId) => userId + case farmWithRegex(userId) => List(userId) + case sandbagWithRegex(userIds) => userIds.split(' ').toList.map(_.trim.replace("@", "")) } + .flatten + .distinct .toNel } private val farmWithRegex = ("^Boosting: farms rating points from @(" + User.historicalUsernameRegex.pattern + ")").r.unanchored private val sandbagWithRegex = - ("^Sandbagging: throws games to @(" + User.historicalUsernameRegex.pattern + ")").r.unanchored + "^Sandbagging: throws games to (.+)".r.unanchored private def thenForms(url: String, button: Tag) = div( diff --git a/app/views/mod/permissions.scala b/app/views/mod/permissions.scala index f1c38300be..1cf6f46c27 100644 --- a/app/views/mod/permissions.scala +++ b/app/views/mod/permissions.scala @@ -3,14 +3,14 @@ package views.html.mod import lila.api.Context import lila.app.templating.Environment._ import lila.app.ui.ScalatagsTemplate._ -import lila.user.User +import lila.user.{ Holder, User } import lila.security.Permission import controllers.routes object permissions { - def apply(u: User, me: User)(implicit ctx: Context) = + def apply(u: User, me: Holder)(implicit ctx: Context) = views.html.base.layout( title = s"${u.username} permissions", moreCss = frag( diff --git a/app/views/mod/publicChat.scala b/app/views/mod/publicChat.scala index c39cd33b73..707c97b8e3 100644 --- a/app/views/mod/publicChat.scala +++ b/app/views/mod/publicChat.scala @@ -6,36 +6,30 @@ import lila.app.ui.ScalatagsTemplate._ import lila.common.String.html.richText import controllers.routes +import play.api.mvc.Call +import lila.chat.UserChat +import lila.chat.ChatTimeout object publicChat { def apply( - tourChats: List[(lila.tournament.Tournament, lila.chat.UserChat)], - simulChats: List[(lila.simul.Simul, lila.chat.UserChat)] + tourChats: List[(lila.tournament.Tournament, UserChat)], + simulChats: List[(lila.simul.Simul, UserChat)] )(implicit ctx: Context) = views.html.base.layout( title = "Public Chats", - moreCss = cssTag("mod.communication"), - moreJs = jsTag("public-chat.js") + moreCss = cssTag("mod.publicChats"), + moreJs = jsModule("publicChats") ) { main(cls := "page-menu")( views.html.mod.menu("public-chat"), div(id := "comm-wrap")( - div(id := "communication", cls := "page-menu__content public_chat box box-pad")( + div(id := "communication", cls := "page-menu__content public-chat box box-pad")( h2("Tournament Chats"), div(cls := "player_chats")( tourChats.map { case (tournament, chat) => - div(cls := "game")( - a(cls := "title", href := routes.Tournament.show(tournament.id))(tournament.name), - div(cls := "chat")( - chat.lines.filter(_.isVisible).map { line => - div(cls := "line")( - userIdLink(line.author.toLowerCase.some, withOnline = false, withTitle = false), - " ", - richText(line.text, expandImg = false) - ) - } - ) + div(cls := "game", dataChan := "tournament", dataRoom := tournament.id)( + chatOf(routes.Tournament.show(tournament.id), tournament.name, chat) ) } ), @@ -43,23 +37,45 @@ object publicChat { h2("Simul Chats"), div(cls := "player_chats")( simulChats.map { case (simul, chat) => - div(cls := "game")( - a(cls := "title", href := routes.Simul.show(simul.id))(simul.name), - div(cls := "chat")( - chat.lines.filter(_.isVisible).map { line => - div(cls := "line")( - userIdLink(line.author.toLowerCase.some, withOnline = false, withTitle = false), - " ", - richText(line.text, expandImg = false) - ) - } - ) + div(cls := "game", dataChan := "simul", dataRoom := simul.id)( + chatOf(routes.Simul.show(simul.id), simul.name, chat) ) } ) + ), + div(cls := "timeout-modal none")( + h2(cls := "username")("username"), + p(cls := "text")("text"), + div(cls := "continue-with")( + ChatTimeout.Reason.all.map { reason => + button(cls := "button", value := reason.key)(reason.shortName) + } + ) ) ) ) ) } + + private val dataRoom = attr("data-room") + private val dataChan = attr("data-chan") + + private def chatOf(url: Call, name: String, chat: UserChat)(implicit ctx: Context) = + frag( + a(cls := "title", href := url)(name), + div(cls := "chat")( + chat.lines.filter(_.isVisible).map { line => + div( + cls := List( + "line" -> true, + "lichess" -> line.isLichess + ) + )( + userIdLink(line.author.toLowerCase.some, withOnline = false, withTitle = false), + " ", + richText(line.text, expandImg = false) + ) + } + ) + ) } diff --git a/app/views/mod/search.scala b/app/views/mod/search.scala index 3f9ad96500..9ab7baad8a 100644 --- a/app/views/mod/search.scala +++ b/app/views/mod/search.scala @@ -7,6 +7,7 @@ import lila.app.templating.Environment._ import lila.app.ui.ScalatagsTemplate._ import lila.common.IpAddress import lila.security.FingerHash +import lila.mod.IpRender.RenderIp import controllers.routes @@ -80,7 +81,7 @@ object search { users: List[lila.user.User.WithEmails], uas: List[String], blocked: Boolean - )(implicit ctx: Context) = + )(implicit ctx: Context, renderIp: RenderIp) = views.html.base.layout( title = "IP address", moreCss = cssTag("mod.misc") @@ -89,7 +90,7 @@ object search { views.html.mod.menu("search"), div(cls := "mod-search page-menu__content box")( div(cls := "box__top")( - h1("IP address: ", address.value), + h1("IP address: ", renderIp(address)), postForm(cls := "box__top__actions", action := routes.Mod.singleIpBan(!blocked, address.value))( submitButton( cls := List( @@ -99,7 +100,7 @@ object search { )(if (blocked) "Banned" else "Ban this IP") ) ), - div(cls := "box__pad")( + isGranted(_.Admin) option div(cls := "box__pad")( h2("User agents"), ul(uas map { ua => li(ua) @@ -151,7 +152,7 @@ object search { tr( td( userLink(u, withBestRating = true, params = "?mod"), - (isGranted(_.Doxing) && isGranted(_.SetEmail)) option + (isGranted(_.Admin) && isGranted(_.SetEmail)) option email(emails.list.map(_.value).mkString(", ")) ), td(u.count.game.localize), diff --git a/app/views/relay/form.scala b/app/views/relay/form.scala index 2eeded7f1f..b60b87f2ab 100644 --- a/app/views/relay/form.scala +++ b/app/views/relay/form.scala @@ -51,6 +51,11 @@ object form { private def inner(form: Form[Data], url: play.api.mvc.Call)(implicit ctx: Context) = postForm(cls := "form3", action := url)( + div(cls := "form-group")( + a(dataIcon := "", cls := "text", href := routes.Page.loneBookmark("broadcasts"))( + "How to use Lichess Broadcasts" + ) + ), form3.globalError(form), form3.group(form("name"), eventName())(form3.input(_)(autofocus)), form3.group(form("description"), eventDescription())(form3.textarea(_)(rows := 2)), diff --git a/app/views/report/form.scala b/app/views/report/form.scala index 63e5f81607..f9194be772 100644 --- a/app/views/report/form.scala +++ b/app/views/report/form.scala @@ -25,6 +25,11 @@ object form { cls := "form3", action := s"${routes.Report.create}${reqUser.??(u => "?username=" + u.username)}" )( + div(cls := "form-group")( + a(href := routes.Page.loneBookmark("report-faq"), dataIcon := "", cls := "text")( + "Read more about Lichess reports" + ) + ), form3.globalError(form), form3.group(form("username"), trans.user(), klass = "field_to complete-parent") { f => reqUser diff --git a/app/views/report/list.scala b/app/views/report/list.scala index 9d28f54d77..77a91715c7 100644 --- a/app/views/report/list.scala +++ b/app/views/report/list.scala @@ -6,6 +6,7 @@ import lila.api.Context import lila.app.templating.Environment._ import lila.app.ui.ScalatagsTemplate._ import lila.report.Report.WithSuspect +import lila.user.Holder object list { @@ -111,7 +112,7 @@ object list { scoreTag(scores.highest) ), ctx.me ?? { me => - lila.report.Room.all.filter(lila.report.Room.isGrantedFor(me)).map { room => + lila.report.Room.all.filter(lila.report.Room.isGrantedFor(Holder(me))).map { room => a( href := routes.Report.listWithFilter(room.key), cls := List( diff --git a/app/views/site/faq.scala b/app/views/site/faq.scala index deed39bc0f..9b2c2cd512 100644 --- a/app/views/site/faq.scala +++ b/app/views/site/faq.scala @@ -312,6 +312,9 @@ object faq { whyAreRatingHigher.txt(), p( whyAreRatingHigherExplanation() + ), + p( + a(href := routes.Page.loneBookmark("rating-systems"))("More about rating systems") ) ), question( diff --git a/app/views/streamer/bits.scala b/app/views/streamer/bits.scala index d175f471c0..c154bdceef 100644 --- a/app/views/streamer/bits.scala +++ b/app/views/streamer/bits.scala @@ -95,7 +95,7 @@ object bits extends Context.ToLang { ul( li(rule1()), li(rule2()), - li(rule3()) + li(a(href := routes.Page.loneBookmark("streamer-page-activation"))(rule3())) ), h2(perks()), ul( diff --git a/app/views/team/form.scala b/app/views/team/form.scala index 7c0ee95a0a..e7c59fc112 100644 --- a/app/views/team/form.scala +++ b/app/views/team/form.scala @@ -28,8 +28,7 @@ object form { form3.group(form("name"), trans.name())(form3.input(_)), requestField(form), passwordField(form), - form3.group(form("location"), trans.location())(form3.input(_)), - form3.group(form("description"), trans.description())(form3.textarea(_)(rows := 10)), + textFields(form), views.html.base.captcha(form, captcha), form3.actions( a(href := routes.Team.home(1))(trans.cancel()), @@ -54,8 +53,7 @@ object form { ), requestField(form), passwordField(form), - form3.group(form("location"), trans.location())(form3.input(_)), - form3.group(form("description"), trans.description())(form3.textarea(_)(rows := 10)), + textFields(form), form3.group(form("chat"), frag("Team chat")) { f => form3.select( f, @@ -94,6 +92,14 @@ object form { } } + private def textFields(form: Form[_])(implicit ctx: Context) = frag( + form3.group(form("location"), trans.location())(form3.input(_)), + form3.group(form("description"), trans.description())(form3.textarea(_)(rows := 10)), + form3.group(form("descPrivate"), trans.descPrivate(), help = trans.descPrivateHelp().some)( + form3.textarea(_)(rows := 10) + ) + ) + private def requestField(form: Form[_])(implicit lang: Lang) = form3.checkbox( form("request"), diff --git a/app/views/team/show.scala b/app/views/team/show.scala index b6e8a4964c..b014f4a89b 100644 --- a/app/views/team/show.scala +++ b/app/views/team/show.scala @@ -184,7 +184,9 @@ object show { div(cls := "team-show__content__col2")( standardFlash(), st.section(cls := "team-show__desc")( - markdownLinksOrRichText(t.description), + markdownLinksOrRichText { + t.descPrivate.ifTrue(info.mine) | t.description + }, t.location.map { loc => frag(br, trans.location(), ": ", richText(loc)) } diff --git a/app/views/user/mod.scala b/app/views/user/mod.scala index 136e17bd4a..4e73539f23 100644 --- a/app/views/user/mod.scala +++ b/app/views/user/mod.scala @@ -9,8 +9,10 @@ import lila.app.ui.ScalatagsTemplate._ import lila.evaluation.Display import lila.mod.ModPresets import lila.playban.RageSit +import lila.security.Granter import lila.security.{ Permission, UserLogins } -import lila.user.User +import lila.user.{ Holder, User } +import lila.mod.IpRender.RenderIp object mod { private def mzSection(key: String) = div(id := s"mz_$key", cls := "mz-section") @@ -44,6 +46,13 @@ object mod { submitButton(cls := "btn-rack__btn")("Evaluate") ) }, + isGranted(_.Hunter) option { + a( + cls := "btn-rack__btn", + href := routes.GameMod.index(u.username), + title := "View games" + )("Games") + }, isGranted(_.Shadowban) option { a( cls := "btn-rack__btn", @@ -190,7 +199,7 @@ object mod { ) ) }, - (isGranted(_.Doxing) && isGranted(_.SetEmail)) ?? frag( + (isGranted(_.Admin) && isGranted(_.SetEmail)) ?? frag( postForm(cls := "email", action := routes.Mod.setEmail(u.username))( st.input( tpe := "email", @@ -516,7 +525,10 @@ object mod { if (nb > 0) td(cls := "i", dataSort := nb)(content) else td - def otherUsers(u: User, data: UserLogins.TableData)(implicit ctx: Context): Tag = { + def otherUsers(mod: Holder, u: User, data: UserLogins.TableData)(implicit + ctx: Context, + renderIp: RenderIp + ): Tag = { import data._ mzSection("others")( table(cls := "slist")( @@ -529,7 +541,7 @@ object mod { a(cls := "more-others")("Load more") ) ), - th("Email"), + isGranted(_.Admin) option th("Email"), sortNumberTh("Same"), th("Games"), sortNumberTh(playban)(cls := "i", title := "Playban"), @@ -547,17 +559,16 @@ object mod { ), tbody( othersWithEmail.others.map { case other @ UserLogins.OtherUser(o, _, _) => - val dox = isGranted(_.Doxing) || (o.lameOrAlt && !o.hasTitle) val userNotes = - notes.filter(n => n.to == o.id && (ctx.me.exists(n.isFrom) || isGranted(_.Doxing))) + notes.filter(n => n.to == o.id && (ctx.me.exists(n.isFrom) || isGranted(_.Admin))) tr( - dataTags := s"${other.ips.mkString(" ")} ${other.fps.mkString(" ")}", + dataTags := s"${other.ips.map(renderIp).mkString(" ")} ${other.fps.mkString(" ")}", cls := (o == u) option "same" )( - if (dox || o == u) td(dataSort := o.id)(userLink(o, withBestRating = true, params = "?mod")) - else td, - if (dox) td(othersWithEmail emailValueOf o) + if (o == u || Granter.canViewAltUsername(mod, o)) + td(dataSort := o.id)(userLink(o, withBestRating = true, params = "?mod")) else td, + isGranted(_.Admin) option td(othersWithEmail emailValueOf o), td( // show prints and ips separately dataSort := other.score + (other.ips.nonEmpty ?? 1000000) + (other.fps.nonEmpty ?? 3000000) @@ -602,7 +613,10 @@ object mod { ) } - def identification(spy: UserLogins)(implicit ctx: Context): Frag = { + def identification(mod: Holder, logins: UserLogins)(implicit + ctx: Context, + renderIp: RenderIp + ): Frag = { val canIpBan = isGranted(_.IpBan) val canFpBan = isGranted(_.PrintBan) mzSection("identification")( @@ -617,7 +631,7 @@ object mod { ) ), tbody( - spy.distinctLocations.toList + logins.distinctLocations.toList .sortBy(-_.seconds) .map { loc => tr( @@ -635,7 +649,7 @@ object mod { table(cls := "slist slist--sort")( thead( tr( - th(pluralize("Device", spy.uas.size)), + th(pluralize("Device", logins.uas.size)), th("OS"), th("Client"), sortNumberTh("Date"), @@ -643,7 +657,7 @@ object mod { ) ), tbody( - spy.uas + logins.uas .sortBy(-_.seconds) .map { ua => import ua.value.client._ @@ -666,7 +680,7 @@ object mod { table(cls := "slist spy_filter slist--sort")( thead( tr( - th(pluralize("IP", spy.prints.size)), + th(pluralize("IP", logins.prints.size)), sortNumberTh("Alts"), th, sortNumberTh("Date"), @@ -674,9 +688,10 @@ object mod { ) ), tbody( - spy.ips.sortBy(ip => (-ip.alts.score, -ip.ip.seconds)).map { ip => + logins.ips.sortBy(ip => (-ip.alts.score, -ip.ip.seconds)).map { ip => + val renderedIp = renderIp(ip.ip.value) tr(cls := ip.blocked option "blocked")( - td(a(href := routes.Mod.singleIp(ip.ip.value.value))(ip.ip.value)), + td(a(href := routes.Mod.singleIp(renderedIp))(renderedIp)), td(dataSort := ip.alts.score)(altMarks(ip.alts)), td(ip.proxy option span(cls := "proxy")("PROXY")), td(dataSort := ip.ip.date.getMillis)(momentFromNowServer(ip.ip.date)), @@ -698,14 +713,14 @@ object mod { table(cls := "slist spy_filter slist--sort")( thead( tr( - th(pluralize("Print", spy.prints.size)), + th(pluralize("Print", logins.prints.size)), sortNumberTh("Alts"), sortNumberTh("Date"), canFpBan option sortNumberTh ) ), tbody( - spy.prints.sortBy(fp => (-fp.alts.score, -fp.fp.seconds)).map { fp => + logins.prints.sortBy(fp => (-fp.alts.score, -fp.fp.seconds)).map { fp => tr(cls := fp.banned option "blocked")( td(a(href := routes.Mod.print(fp.fp.value.value))(fp.fp.value)), td(dataSort := fp.alts.score)(altMarks(fp.alts)), diff --git a/app/views/user/show/header.scala b/app/views/user/show/header.scala index bd802e3f0e..862ece0bb9 100644 --- a/app/views/user/show/header.scala +++ b/app/views/user/show/header.scala @@ -138,7 +138,7 @@ object header { div(form3.cmnToggle("note-mod", "mod", checked = true)), label(`for` := "note-mod")("For moderators only") ), - isGranted(_.Doxing) option div( + isGranted(_.Admin) option div( div(form3.cmnToggle("note-dox", "dox", checked = false)), label(`for` := "note-dox")("Doxing info") ) @@ -153,7 +153,7 @@ object header { social.notes .filter { n => ctx.me.exists(n.isFrom) || - isGranted(_.Doxing) || + isGranted(_.Admin) || (!n.dox && isGranted(_.ModNote)) } .map { note => diff --git a/conf/routes b/conf/routes index 718765ebec..c27df9e6ef 100644 --- a/conf/routes +++ b/conf/routes @@ -452,6 +452,7 @@ GET /mod/chat-user/:username controllers.Mod.chatUser(username: String GET /mod/:username/permissions controllers.Mod.permissions(username: String) POST /mod/:username/permissions controllers.Mod.savePermissions(username: String) GET /mod/public-chat controllers.Mod.publicChat +POST /mod/public-chat/timeout controllers.Mod.publicChatTimeout GET /mod/email-confirm controllers.Mod.emailConfirm GET /mod/chat-panic controllers.Mod.chatPanic POST /mod/chat-panic controllers.Mod.chatPanicPost diff --git a/modules/analyse/src/main/Annotator.scala b/modules/analyse/src/main/Annotator.scala index eee549112d..50f6d4d52f 100644 --- a/modules/analyse/src/main/Annotator.scala +++ b/modules/analyse/src/main/Annotator.scala @@ -4,18 +4,18 @@ import chess.format.pgn.{ Glyphs, Move, Pgn, Tag, Turn } import chess.opening._ import chess.{ Color, Status } +import lila.game.GameDrawOffers +import lila.game.Game + final class Annotator(netDomain: lila.common.config.NetDomain) { - def apply( - p: Pgn, - analysis: Option[Analysis], - opening: Option[FullOpening.AtPly], - winner: Option[Color], - status: Status - ): Pgn = - annotateStatus(winner, status) { - annotateOpening(opening) { - annotateTurns(p, analysis ?? (_.advices)) + def apply(p: Pgn, game: Game, analysis: Option[Analysis]): Pgn = + annotateStatus(game.winnerColor, game.status) { + annotateOpening(game.opening) { + annotateTurns( + annotateDrawOffers(p, game.drawOffers), + analysis.??(_.advices) + ) }.copy( tags = p.tags + Tag(_.Annotator, netDomain) ) @@ -49,6 +49,19 @@ final class Annotator(netDomain: lila.common.config.NetDomain) { ) } + private def annotateDrawOffers(pgn: Pgn, drawOffers: GameDrawOffers): Pgn = + if (drawOffers.isEmpty) pgn + else + drawOffers.normalizedPlies.foldLeft(pgn) { case (pgn, ply) => + pgn.updatePly( + ply, + move => { + val color = !Color.fromPly(ply) + move.copy(comments = s"$color offers draw" :: move.comments) + } + ) + } + private def makeVariation(turn: Turn, advice: Advice): List[Turn] = Turn.fromMoves( advice.info.variation take 20 map { san => diff --git a/modules/api/src/main/PgnDump.scala b/modules/api/src/main/PgnDump.scala index 832e02816e..4f17582d7b 100644 --- a/modules/api/src/main/PgnDump.scala +++ b/modules/api/src/main/PgnDump.scala @@ -35,7 +35,7 @@ final class PgnDump( else fuccess(pgn) } map { pgn => val evaled = analysis.ifTrue(flags.evals).fold(pgn)(addEvals(pgn, _)) - if (flags.literate) annotator(evaled, analysis, game.opening, game.winnerColor, game.status) + if (flags.literate) annotator(evaled, game, analysis) else evaled } map { pgn => realPlayers.fold(pgn)(_.update(game, pgn)) diff --git a/modules/api/src/main/RoundApi.scala b/modules/api/src/main/RoundApi.scala index 7aa5a8709c..ac31a3631a 100644 --- a/modules/api/src/main/RoundApi.scala +++ b/modules/api/src/main/RoundApi.scala @@ -204,15 +204,7 @@ final private[api] class RoundApi( obj: JsObject ) = obj + ("treeParts" -> partitionTreeJsonWriter.writes( - lila.round.TreeBuilder( - id = pov.gameId, - pgnMoves = pov.game.pgnMoves, - variant = pov.game.variant, - analysis = analysis, - initialFen = initialFen | pov.game.variant.initialFen, - withFlags = withFlags, - clocks = withFlags.clocks ?? pov.game.bothClockStates - ) + lila.round.TreeBuilder(pov.game, analysis, initialFen | pov.game.variant.initialFen, withFlags) )) private def withSteps(pov: Pov, initialFen: Option[FEN])(obj: JsObject) = diff --git a/modules/appeal/src/main/AppealApi.scala b/modules/appeal/src/main/AppealApi.scala index bb4eabc30e..f56485c72d 100644 --- a/modules/appeal/src/main/AppealApi.scala +++ b/modules/appeal/src/main/AppealApi.scala @@ -3,6 +3,7 @@ package lila.appeal import lila.db.dsl._ import lila.user.User import org.joda.time.DateTime +import lila.user.Holder final class AppealApi( coll: Coll @@ -40,8 +41,8 @@ final class AppealApi( coll.update.one($id(appeal.id), appeal) inject appeal } - def reply(text: String, prev: Appeal, mod: User) = { - val appeal = prev.post(text, mod) + def reply(text: String, prev: Appeal, mod: Holder) = { + val appeal = prev.post(text, mod.user) coll.update.one($id(appeal.id), appeal) inject appeal } diff --git a/modules/chat/src/main/ChatApi.scala b/modules/chat/src/main/ChatApi.scala index d8d8640de9..e8a5af2b1d 100644 --- a/modules/chat/src/main/ChatApi.scala +++ b/modules/chat/src/main/ChatApi.scala @@ -10,7 +10,7 @@ import lila.common.String.noShouting import lila.db.dsl._ import lila.hub.actorApi.shutup.{ PublicSource, RecordPrivateChat, RecordPublicChat } import lila.memo.CacheApi._ -import lila.user.{ User, UserRepo } +import lila.user.{ Holder, User, UserRepo } final class ChatApi( coll: Coll, @@ -154,6 +154,19 @@ final class ChatApi( case _ => funit } + def publicTimeout(data: ChatTimeout.TimeoutFormData, me: Holder): Funit = + ChatTimeout.Reason(data.reason) ?? { reason => + timeout( + chatId = Chat.Id(data.roomId), + modId = me.id, + userId = data.userId, + reason = reason, + scope = ChatTimeout.Scope.Global, + text = data.text, + busChan = if (data.chan == "tournament") _.Tournament else _.Simul + ) + } + def userModInfo(username: String): Fu[Option[UserModInfo]] = userRepo named username flatMap { _ ?? { user => @@ -169,36 +182,42 @@ final class ChatApi( scope: ChatTimeout.Scope, text: String, busChan: BusChan.Select - ): Funit = { - val line = c.hasRecentLine(user) option UserLine( - username = systemUserId, - title = None, - text = s"${user.username} was timed out 15 minutes for ${reason.name}.", - troll = false, - deleted = false - ) - val c2 = c.markDeleted(user) - val chat = line.fold(c2)(c2.add) - coll.update.one($id(chat.id), chat).void >> - chatTimeout.add(c, mod, user, reason, scope) >>- { - cached invalidate chat.id - publish(chat.id, actorApi.OnTimeout(chat.id, user.id), busChan) - line foreach { l => - publish(chat.id, actorApi.ChatLine(chat.id, l), busChan) + ): Funit = + chatTimeout.add(c, mod, user, reason, scope) flatMap { + _ ?? { + val lineText = scope match { + case ChatTimeout.Scope.Global => s"${user.username} was timed out 15 minutes for ${reason.name}." + case _ => s"${user.username} was timed out 15 minutes by a page mod (not a Lichess mod)" + } + val line = c.hasRecentLine(user) option UserLine( + username = systemUserId, + title = None, + text = lineText, + troll = false, + deleted = false + ) + val c2 = c.markDeleted(user) + val chat = line.fold(c2)(c2.add) + coll.update.one($id(chat.id), chat).void >>- { + cached.invalidate(chat.id) + publish(chat.id, actorApi.OnTimeout(chat.id, user.id), busChan) + line foreach { l => + publish(chat.id, actorApi.ChatLine(chat.id, l), busChan) + } + if (isMod(mod)) + lila.common.Bus.publish( + lila.hub.actorApi.mod.ChatTimeout( + mod = mod.id, + user = user.id, + reason = reason.key, + text = text + ), + "chatTimeout" + ) + else logger.info(s"${mod.username} times out ${user.username} in #${c.id} for ${reason.key}") } - if (isMod(mod)) - lila.common.Bus.publish( - lila.hub.actorApi.mod.ChatTimeout( - mod = mod.id, - user = user.id, - reason = reason.key, - text = text - ), - "chatTimeout" - ) - else logger.info(s"${mod.username} times out ${user.username} in #${c.id} for ${reason.key}") } - } + } def delete(c: UserChat, user: User, busChan: BusChan.Select): Funit = { val chat = c.markDeleted(user) diff --git a/modules/chat/src/main/ChatTimeout.scala b/modules/chat/src/main/ChatTimeout.scala index 343b7ff980..b7ae69b83c 100644 --- a/modules/chat/src/main/ChatTimeout.scala +++ b/modules/chat/src/main/ChatTimeout.scala @@ -1,12 +1,14 @@ package lila.chat +import org.joda.time.DateTime +import play.api.data.Form +import play.api.data.Forms._ +import reactivemongo.api.bson._ +import scala.concurrent.duration._ + import lila.db.dsl._ import lila.user.User -import org.joda.time.DateTime -import reactivemongo.api.bson._ -import scala.concurrent.duration._ - final class ChatTimeout( coll: Coll, duration: FiniteDuration @@ -16,9 +18,9 @@ final class ChatTimeout( private val global = new lila.memo.ExpireSetMemo(duration) - def add(chat: UserChat, mod: User, user: User, reason: Reason, scope: Scope): Funit = + def add(chat: UserChat, mod: User, user: User, reason: Reason, scope: Scope): Fu[Boolean] = isActive(chat.id, user.id) flatMap { - case true => funit + case true => fuccess(false) case false => if (scope == Scope.Global) global put user.id coll.insert @@ -32,8 +34,7 @@ final class ChatTimeout( "createdAt" -> DateTime.now, "expiresAt" -> DateTime.now.plusSeconds(duration.toSeconds.toInt) ) - ) - .void + ) inject true } def isActive(chatId: Chat.Id, userId: User.ID): Fu[Boolean] = @@ -66,7 +67,9 @@ final class ChatTimeout( object ChatTimeout { - sealed abstract class Reason(val key: String, val name: String) + sealed abstract class Reason(val key: String, val name: String) { + lazy val shortName = name.split(';').lift(0) | name + } object Reason { case object PublicShaming extends Reason("shaming", "public shaming; please use lichess.org/report") @@ -93,4 +96,16 @@ object ChatTimeout { case object Local extends Scope case object Global extends Scope } + + val form = Form( + mapping( + "roomId" -> nonEmptyText, + "chan" -> lila.common.Form.stringIn(Set("tournament", "simul")), + "userId" -> nonEmptyText, + "reason" -> nonEmptyText, + "text" -> nonEmptyText + )(TimeoutFormData.apply)(TimeoutFormData.unapply) + ) + + case class TimeoutFormData(roomId: String, chan: String, userId: User.ID, reason: String, text: String) } diff --git a/modules/chat/src/main/Line.scala b/modules/chat/src/main/Line.scala index f21402712a..941ffc61b1 100644 --- a/modules/chat/src/main/Line.scala +++ b/modules/chat/src/main/Line.scala @@ -12,6 +12,7 @@ sealed trait Line { def isHuman = !isSystem def humanAuthor = isHuman option author def troll: Boolean + def userIdMaybe: Option[User.ID] } case class UserLine( @@ -26,17 +27,22 @@ case class UserLine( def userId = User normalize username + def userIdMaybe = userId.some + def delete = copy(deleted = true) def isVisible = !troll && !deleted + + def isLichess = userId == User.lichessId } case class PlayerLine( color: Color, text: String ) extends Line { - def deleted = false - def author = color.name - def troll = false + def deleted = false + def author = color.name + def troll = false + def userIdMaybe = none } object Line { diff --git a/modules/clas/src/main/ClasApi.scala b/modules/clas/src/main/ClasApi.scala index c262882988..bfe1131d90 100644 --- a/modules/clas/src/main/ClasApi.scala +++ b/modules/clas/src/main/ClasApi.scala @@ -10,6 +10,7 @@ import lila.db.dsl._ import lila.msg.MsgApi import lila.security.Permission import lila.user.{ Authenticator, User, UserRepo } +import lila.user.Holder final class ClasApi( colls: ClasColls, @@ -250,12 +251,12 @@ final class ClasApi( authenticator.setPassword(s.userId, password) inject password } - def archive(sId: Student.Id, t: User, v: Boolean): Fu[Option[Student]] = + def archive(sId: Student.Id, by: Holder, v: Boolean): Fu[Option[Student]] = coll.ext .findAndUpdate[Student]( selector = $id(sId), update = - if (v) $set("archived" -> Clas.Recorded(t.id, DateTime.now)) + if (v) $set("archived" -> Clas.Recorded(by.id, DateTime.now)) else $unset("archived"), fetchNewObject = true ) @@ -280,9 +281,9 @@ ${clas.desc}""", import ClasInvite.Feedback._ - def create(clas: Clas, user: User, realName: String, teacher: User): Fu[ClasInvite.Feedback] = + def create(clas: Clas, user: User, realName: String, teacher: Holder): Fu[ClasInvite.Feedback] = student - .archive(Student.id(user.id, clas.id), user, v = false) + .archive(Student.id(user.id, clas.id), teacher, v = false) .map2[ClasInvite.Feedback](_ => Already) getOrElse { lila.mon.clas.student.invite(teacher.id).increment() val invite = ClasInvite.make(clas, user, realName, teacher) @@ -342,7 +343,7 @@ ${clas.desc}""", colls.invite.delete.one($id(id)).void private def sendInviteMessage( - teacher: User, + teacher: Holder, student: User, clas: Clas, invite: ClasInvite diff --git a/modules/clas/src/main/ClasInvite.scala b/modules/clas/src/main/ClasInvite.scala index e64ec3973b..cc878ca78b 100644 --- a/modules/clas/src/main/ClasInvite.scala +++ b/modules/clas/src/main/ClasInvite.scala @@ -2,7 +2,7 @@ package lila.clas import org.joda.time.DateTime -import lila.user.User +import lila.user.{ Holder, User } case class ClasInvite( _id: ClasInvite.Id, // random @@ -17,7 +17,7 @@ object ClasInvite { case class Id(value: String) extends AnyVal with StringValue - def make(clas: Clas, user: User, realName: String, teacher: User) = + def make(clas: Clas, user: User, realName: String, teacher: Holder) = ClasInvite( _id = Id(lila.common.ThreadLocalRandom nextString 8), userId = user.id, diff --git a/modules/coach/src/main/CoachApi.scala b/modules/coach/src/main/CoachApi.scala index a9e55f36f5..8f2b7c5814 100644 --- a/modules/coach/src/main/CoachApi.scala +++ b/modules/coach/src/main/CoachApi.scala @@ -7,7 +7,7 @@ import lila.db.dsl._ import lila.db.Photographer import lila.notify.{ Notification, NotifyApi } import lila.security.Granter -import lila.user.{ User, UserRepo } +import lila.user.{ Holder, User, UserRepo } final class CoachApi( coachColl: Coll, @@ -32,10 +32,10 @@ final class CoachApi( } } - def findOrInit(user: User): Fu[Option[Coach.WithUser]] = - Granter(_.Coach)(user) ?? { - find(user) orElse { - val c = Coach.WithUser(Coach make user, user) + def findOrInit(coach: Holder): Fu[Option[Coach.WithUser]] = + Granter.is(_.Coach)(coach) ?? { + find(coach.user) orElse { + val c = Coach.WithUser(Coach make coach.user, coach.user) coachColl.insert.one(c.coach) inject c.some } } diff --git a/modules/db/src/main/dsl.scala b/modules/db/src/main/dsl.scala index ee91d43bc9..0f37f58fee 100644 --- a/modules/db/src/main/dsl.scala +++ b/modules/db/src/main/dsl.scala @@ -34,9 +34,7 @@ trait dsl { def $doc(elements: Iterable[(String, BSONValue)]): Bdoc = BSONDocument.strict(elements) - def $arr(elements: Producer[BSONValue]*): Barr = { - BSONArray(elements: _*) - } + def $arr(elements: Producer[BSONValue]*): Barr = BSONArray(elements: _*) def $id[T: BSONWriter](id: T): Bdoc = $doc("_id" -> id) diff --git a/modules/evaluation/src/main/PlayerAggregateAssessment.scala b/modules/evaluation/src/main/PlayerAggregateAssessment.scala index f35d56c53f..ce584fa85d 100644 --- a/modules/evaluation/src/main/PlayerAggregateAssessment.scala +++ b/modules/evaluation/src/main/PlayerAggregateAssessment.scala @@ -24,7 +24,7 @@ case class PlayerAggregateAssessment( def scoreLikelyCheatingGames(x: Double) = (weightedCheatingSum + weightedLikelyCheatingSum) / assessmentsCount >= (x / 100) - val markable: Boolean = !isGreatUser && isWorthLookingAt && + val markable: Boolean = !user.hasTitle && !isGreatUser && isWorthLookingAt && (weightedCheatingSum >= 3 || weightedCheatingSum + weightedLikelyCheatingSum >= 6) && (scoreCheatingGames(8) || scoreLikelyCheatingGames(16)) diff --git a/modules/evaluation/src/main/PlayerAssessment.scala b/modules/evaluation/src/main/PlayerAssessment.scala index d3189e4c5b..9f538c8cc7 100644 --- a/modules/evaluation/src/main/PlayerAssessment.scala +++ b/modules/evaluation/src/main/PlayerAssessment.scala @@ -60,17 +60,19 @@ object PlayerAssessment { val basics = makeBasics(pov, holdAlerts) + def blursMatter = !game.isSimul && game.hasClock + lazy val highBlurRate: Boolean = - !game.isSimul && game.playerBlurPercent(color) > 90 + blursMatter && game.playerBlurPercent(color) > 90 lazy val moderateBlurRate: Boolean = - !game.isSimul && game.playerBlurPercent(color) > 70 + blursMatter && game.playerBlurPercent(color) > 70 val highestChunkBlurs = highestChunkBlursOf(pov) - val highChunkBlurRate: Boolean = highestChunkBlurs >= 11 + val highChunkBlurRate: Boolean = blursMatter && highestChunkBlurs >= 11 - val moderateChunkBlurRate: Boolean = highestChunkBlurs >= 8 + val moderateChunkBlurRate: Boolean = blursMatter && highestChunkBlurs >= 8 lazy val highlyConsistentMoveTimes: Boolean = game.clock.exists(_.estimateTotalSeconds > 60) && { diff --git a/modules/forum/src/main/TopicApi.scala b/modules/forum/src/main/TopicApi.scala index 71844dc5c6..087f6e5836 100644 --- a/modules/forum/src/main/TopicApi.scala +++ b/modules/forum/src/main/TopicApi.scala @@ -8,7 +8,7 @@ import lila.db.dsl._ import lila.db.paginator._ import lila.hub.actorApi.timeline.{ ForumPost, Propagate } import lila.security.{ Granter => MasterGranter } -import lila.user.User +import lila.user.{ Holder, User } final private[forum] class TopicApi( env: Env, @@ -159,23 +159,23 @@ final private[forum] class TopicApi( env.recent.invalidate() } - def toggleClose(categ: Categ, topic: Topic, mod: User): Funit = + def toggleClose(categ: Categ, topic: Topic, mod: Holder): Funit = env.topicRepo.close(topic.id, topic.open) >> { - MasterGranter(_.ModerateForum)(mod) ?? + MasterGranter.is(_.ModerateForum)(mod) ?? modLog.toggleCloseTopic(mod.id, categ.name, topic.name, topic.open) } - def toggleHide(categ: Categ, topic: Topic, mod: User): Funit = + def toggleHide(categ: Categ, topic: Topic, mod: Holder): Funit = env.topicRepo.hide(topic.id, topic.visibleOnHome) >> { - MasterGranter(_.ModerateForum)(mod) ?? { + MasterGranter.is(_.ModerateForum)(mod) ?? { env.postRepo.hideByTopic(topic.id, topic.visibleOnHome) >> modLog.toggleHideTopic(mod.id, categ.name, topic.name, topic.visibleOnHome) } >>- env.recent.invalidate() } - def toggleSticky(categ: Categ, topic: Topic, mod: User): Funit = + def toggleSticky(categ: Categ, topic: Topic, mod: Holder): Funit = env.topicRepo.sticky(topic.id, !topic.isSticky) >> { - MasterGranter(_.ModerateForum)(mod) ?? + MasterGranter.is(_.ModerateForum)(mod) ?? modLog.toggleStickyTopic(mod.id, categ.name, topic.name, !topic.isSticky) } diff --git a/modules/game/src/main/BSONHandlers.scala b/modules/game/src/main/BSONHandlers.scala index 572ce53704..f04c5df09d 100644 --- a/modules/game/src/main/BSONHandlers.scala +++ b/modules/game/src/main/BSONHandlers.scala @@ -66,6 +66,18 @@ object BSONHandlers { ) } + implicit private[game] val gameDrawOffersHandler = tryHandler[GameDrawOffers]( + { case arr: BSONArray => + Success(arr.values.foldLeft(GameDrawOffers.empty) { + case (offers, BSONInteger(p)) => + if (p > 0) offers.copy(white = offers.white incl p) + else offers.copy(black = offers.black incl -p) + case (offers, _) => offers + }) + }, + offers => BSONArray((offers.white ++ offers.black.map(-_)).view.map(BSONInteger.apply).toIndexedSeq) + ) + import Player.playerBSONHandler private val emptyPlayerBuilder = playerBSONHandler.read($empty) @@ -160,7 +172,8 @@ object BSONHandlers { tournamentId = r strO F.tournamentId, swissId = r strO F.swissId, simulId = r strO F.simulId, - analysed = r boolD F.analysed + analysed = r boolD F.analysed, + drawOffers = r.getD(F.drawOffers, GameDrawOffers.empty) ) ) } diff --git a/modules/game/src/main/Event.scala b/modules/game/src/main/Event.scala index 14b7a2f589..9c97ed0551 100644 --- a/modules/game/src/main/Event.scala +++ b/modules/game/src/main/Event.scala @@ -317,9 +317,8 @@ object Event { } case class DrawOffer(by: Option[Color]) extends Event { - def typ = "reload" - def data = reloadOr("drawOffer", by) - override def owner = true + def typ = "reload" + def data = reloadOr("drawOffer", by) } case class ClockInc(color: Color, time: Centis) extends Event { diff --git a/modules/game/src/main/Game.scala b/modules/game/src/main/Game.scala index 92be48f48a..634551f798 100644 --- a/modules/game/src/main/Game.scala +++ b/modules/game/src/main/Game.scala @@ -315,15 +315,21 @@ case class Game( blackPlayer = f(blackPlayer) ) + def drawOffers = metadata.drawOffers + def playerCanOfferDraw(color: Color) = started && playable && turns >= 2 && !player(color).isOfferingDraw && !opponent(color).isAi && - !playerHasOfferedDraw(color) + !playerHasOfferedDrawRecently(color) - def playerHasOfferedDraw(color: Color) = - player(color).lastDrawOffer ?? (_ >= turns - 20) + def playerHasOfferedDrawRecently(color: Color) = + drawOffers.lastBy(color) ?? (_ >= turns - 20) + + def offerDraw(color: Color) = copy( + metadata = metadata.copy(drawOffers = drawOffers.add(color, turns)) + ).updatePlayer(color, _.offerDraw) def playerCouldRematch = finishedOrAborted && @@ -735,14 +741,7 @@ object Game { status = Status.Created, daysPerTurn = daysPerTurn, mode = mode, - metadata = Metadata( - source = source.some, - pgnImport = pgnImport, - tournamentId = none, - swissId = none, - simulId = none, - analysed = false - ), + metadata = metadata(source).copy(pgnImport = pgnImport), createdAt = createdAt, movedAt = createdAt ) @@ -756,7 +755,8 @@ object Game { tournamentId = none, swissId = none, simulId = none, - analysed = false + analysed = false, + drawOffers = GameDrawOffers.empty ) object BSONFields { @@ -800,6 +800,7 @@ object Game { val initialFen = "if" val checkAt = "ck" val perfType = "pt" // only set on student games for aggregation + val drawOffers = "do" } } diff --git a/modules/game/src/main/GameDiff.scala b/modules/game/src/main/GameDiff.scala index 1d634e36bb..a3ef7db3e4 100644 --- a/modules/game/src/main/GameDiff.scala +++ b/modules/game/src/main/GameDiff.scala @@ -96,11 +96,11 @@ object GameDiff { BSONHandlers.clockBSONWrite(a.createdAt, c).toOption } ) + dTry(drawOffers, _.drawOffers, BSONHandlers.gameDrawOffersHandler.writeTry) for (i <- 0 to 1) { import Player.BSONFields._ val name = s"p$i." val player: Game => Player = if (i == 0) (_.whitePlayer) else (_.blackPlayer) - dOpt(s"$name$lastDrawOffer", player(_).lastDrawOffer, (l: Option[Int]) => l flatMap w.intO) dOpt(s"$name$isOfferingDraw", player(_).isOfferingDraw, w.boolO) dOpt(s"$name$proposeTakebackAt", player(_).proposeTakebackAt, w.intO) dTry(s"$name$blursBits", player(_).blurs, Blurs.BlursBSONHandler.writeTry) diff --git a/modules/game/src/main/GameRepo.scala b/modules/game/src/main/GameRepo.scala index d189f82a28..3e86f36906 100644 --- a/modules/game/src/main/GameRepo.scala +++ b/modules/game/src/main/GameRepo.scala @@ -327,8 +327,6 @@ final class GameRepo(val coll: Coll)(implicit ec: scala.concurrent.ExecutionCont F.positionHashes -> true, F.playingUids -> true, F.unmovedRooks -> true, - ("p0." + Player.BSONFields.lastDrawOffer) -> true, - ("p1." + Player.BSONFields.lastDrawOffer) -> true, ("p0." + Player.BSONFields.isOfferingDraw) -> true, ("p1." + Player.BSONFields.isOfferingDraw) -> true, ("p0." + Player.BSONFields.proposeTakebackAt) -> true, diff --git a/modules/game/src/main/JsonView.scala b/modules/game/src/main/JsonView.scala index de3cbe04a1..13563f7bb2 100644 --- a/modules/game/src/main/JsonView.scala +++ b/modules/game/src/main/JsonView.scala @@ -35,6 +35,7 @@ final class JsonView(rematches: Rematches) { .add("lastMove" -> game.lastMoveKeys) .add("check" -> game.situation.checkSquare.map(_.key)) .add("rematch" -> rematches.of(game.id)) + .add("drawOffers" -> (!game.drawOffers.isEmpty).option(game.drawOffers.normalizedPlies)) } object JsonView { diff --git a/modules/game/src/main/Metadata.scala b/modules/game/src/main/Metadata.scala index d0ba322cf2..ffba0946f7 100644 --- a/modules/game/src/main/Metadata.scala +++ b/modules/game/src/main/Metadata.scala @@ -2,6 +2,7 @@ package lila.game import java.security.MessageDigest import lila.db.ByteArray +import chess.Color private[game] case class Metadata( source: Option[Source], @@ -9,7 +10,8 @@ private[game] case class Metadata( tournamentId: Option[String], swissId: Option[String], simulId: Option[String], - analysed: Boolean + analysed: Boolean, + drawOffers: GameDrawOffers ) { def pgnDate = pgnImport flatMap (_.date) @@ -21,7 +23,30 @@ private[game] case class Metadata( private[game] object Metadata { - val empty = Metadata(None, None, None, None, None, analysed = false) + val empty = Metadata(None, None, None, None, None, analysed = false, GameDrawOffers.empty) +} + +// plies +case class GameDrawOffers(white: Set[Int], black: Set[Int]) { + + def lastBy(color: Color): Option[Int] = color.fold(white, black).maxOption + + def add(color: Color, ply: Int) = + color.fold(copy(white = white incl ply), copy(black = black incl ply)) + + def isEmpty = this == GameDrawOffers.empty + + // lichess allows to offer draw on either turn, + // normalize to pretend it was done on the opponent turn. + def normalize(color: Color): Set[Int] = color.fold(white, black) map { + case ply if (ply % 2 == 0) == color.white => ply + 1 + case ply => ply + } + def normalizedPlies: Set[Int] = normalize(chess.White) ++ normalize(chess.Black) +} + +object GameDrawOffers { + val empty = GameDrawOffers(Set.empty, Set.empty) } case class PgnImport( diff --git a/modules/game/src/main/Player.scala b/modules/game/src/main/Player.scala index 809170cb6a..cf3eed3302 100644 --- a/modules/game/src/main/Player.scala +++ b/modules/game/src/main/Player.scala @@ -14,7 +14,6 @@ case class Player( aiLevel: Option[Int], isWinner: Option[Boolean] = None, isOfferingDraw: Boolean = false, - lastDrawOffer: Option[Int] = None, proposeTakebackAt: Int = 0, // ply when takeback was proposed userId: Player.UserId = None, rating: Option[Int] = None, @@ -49,11 +48,7 @@ case class Player( def finish(winner: Boolean) = copy(isWinner = winner option true) - def offerDraw(turn: Int) = - copy( - isOfferingDraw = true, - lastDrawOffer = Some(turn) - ) + def offerDraw = copy(isOfferingDraw = true) def removeDrawOffer = copy(isOfferingDraw = false) @@ -165,7 +160,6 @@ object Player { val aiLevel = "ai" val isOfferingDraw = "od" - val lastDrawOffer = "ld" val proposeTakebackAt = "ta" val rating = "e" val ratingDiff = "d" @@ -206,7 +200,6 @@ object Player { aiLevel = r intO aiLevel, isWinner = win, isOfferingDraw = r boolD isOfferingDraw, - lastDrawOffer = r intO lastDrawOffer, proposeTakebackAt = r intD proposeTakebackAt, userId = userId, rating = r intO rating flatMap ratingRange, @@ -222,7 +215,6 @@ object Player { BSONDocument( aiLevel -> p.aiLevel, isOfferingDraw -> w.boolO(p.isOfferingDraw), - lastDrawOffer -> p.lastDrawOffer, proposeTakebackAt -> w.intO(p.proposeTakebackAt), rating -> p.rating, ratingDiff -> p.ratingDiff, diff --git a/modules/game/src/main/Pov.scala b/modules/game/src/main/Pov.scala index 0c89e86f12..5ec491288a 100644 --- a/modules/game/src/main/Pov.scala +++ b/modules/game/src/main/Pov.scala @@ -35,6 +35,8 @@ case class Pov(game: Game, color: Color) { def hasMoved = game playerHasMoved color + def moves = game playerMoves color + def win = game wonBy color def loss = game lostBy color diff --git a/modules/i18n/src/main/I18nKeys.scala b/modules/i18n/src/main/I18nKeys.scala index b372a07ce3..b549d6d3cc 100644 --- a/modules/i18n/src/main/I18nKeys.scala +++ b/modules/i18n/src/main/I18nKeys.scala @@ -356,6 +356,8 @@ val `latestUpdates` = new I18nKey("latestUpdates") val `tournamentWinners` = new I18nKey("tournamentWinners") val `name` = new I18nKey("name") val `description` = new I18nKey("description") +val `descPrivate` = new I18nKey("descPrivate") +val `descPrivateHelp` = new I18nKey("descPrivateHelp") val `no` = new I18nKey("no") val `yes` = new I18nKey("yes") val `help` = new I18nKey("help") diff --git a/modules/irc/src/main/SlackApi.scala b/modules/irc/src/main/SlackApi.scala index e02d4fc8ec..f20dba575a 100644 --- a/modules/irc/src/main/SlackApi.scala +++ b/modules/irc/src/main/SlackApi.scala @@ -5,6 +5,7 @@ import org.joda.time.DateTime import lila.common.{ ApiVersion, EmailAddress, Heapsort, IpAddress, LightUser } import lila.hub.actorApi.slack._ import lila.user.User +import lila.user.Holder final class SlackApi( client: SlackClient, @@ -22,16 +23,9 @@ final class SlackApi( implicit private val amountOrdering = Ordering.by[ChargeEvent, Int](_.amount) - def apply(event: ChargeEvent): Funit = - if (event.amount < 10000) addToBuffer(event) - else - displayMessage { - s"${userAt(event.username)} donated ${amount(event.amount)}. Monthly progress: ${event.percent}%" - } - - private def addToBuffer(event: ChargeEvent): Funit = { + def apply(event: ChargeEvent): Funit = { buffer = buffer :+ event - (buffer.head.date isBefore DateTime.now.minusHours(12)) ?? { + buffer.head.date.isBefore(DateTime.now.minusHours(12)) ?? { val firsts = Heapsort.topN(buffer, 10, amountOrdering).map(_.username).map(userAt).mkString(", ") val amountSum = buffer.map(_.amount).sum val patrons = @@ -80,10 +74,10 @@ final class SlackApi( ) } - def commlog(mod: User, user: User, reportBy: Option[User.ID]): Funit = + def commlog(mod: Holder, user: User, reportBy: Option[User.ID]): Funit = client( SlackMessage( - username = mod.username, + username = mod.user.username, icon = "eye", text = { val finalS = if (user.username endsWith "s") "" else "s" @@ -123,10 +117,10 @@ final class SlackApi( } } - def chatPanic(mod: User, v: Boolean): Funit = + def chatPanic(mod: Holder, v: Boolean): Funit = client( SlackMessage( - username = mod.username, + username = mod.user.username, icon = if (v) "anger" else "information_source", text = s"${if (v) "Enabled" else "Disabled"} $chatPanicLink", channel = rooms.tavern @@ -218,21 +212,21 @@ final class SlackApi( private def linkifyUsers(msg: String) = userRegex matcher msg replaceAll userReplace - def userMod(user: User, mod: User): Funit = + def userMod(user: User, mod: Holder): Funit = noteApi .forMod(user.id) .map(_.headOption.filter(_.date isAfter DateTime.now.minusMinutes(5))) .map { case None => SlackMessage( - username = mod.username, + username = mod.user.username, icon = "eyes", text = s"Let's have a look at _*${userLink(user.username)}*_", channel = rooms.tavern ) case Some(note) => SlackMessage( - username = mod.username, + username = mod.user.username, icon = "spiral_note_pad", text = s"_*${userLink(user.username)}*_ (${userNotesLink(user.username)}):\n" + linkifyUsers(note.text take 2000), @@ -251,10 +245,10 @@ final class SlackApi( ) ) - def userAppeal(user: User, mod: User): Funit = + def userAppeal(user: User, mod: Holder): Funit = client( SlackMessage( - username = mod.username, + username = mod.user.username, icon = "eyes", text = s"Let's have a look at the appeal of _*${lichessLink(s"/appeal/${user.username}", user.username)}*_", diff --git a/modules/irwin/src/main/IrwinApi.scala b/modules/irwin/src/main/IrwinApi.scala index bb73ff95f4..31a3ca9a49 100644 --- a/modules/irwin/src/main/IrwinApi.scala +++ b/modules/irwin/src/main/IrwinApi.scala @@ -4,6 +4,7 @@ import org.joda.time.DateTime import reactivemongo.api.bson._ import reactivemongo.api.ReadPreference +import lila.analyse.Analysis import lila.analyse.Analysis.Analyzed import lila.analyse.AnalysisRepo import lila.common.Bus @@ -11,8 +12,7 @@ import lila.db.dsl._ import lila.game.{ Game, GameRepo, Pov, Query } import lila.report.{ Mod, ModId, Report, Reporter, Suspect, SuspectId } import lila.tournament.{ Tournament, TournamentTop } -import lila.user.{ User, UserRepo } -import lila.analyse.Analysis +import lila.user.{ Holder, User, UserRepo } final class IrwinApi( reportColl: Coll, @@ -86,8 +86,8 @@ final class IrwinApi( import IrwinRequest.Origin - def fromMod(suspect: Suspect, mod: Mod) = { - notification.add(suspect.id, mod.id) + def fromMod(suspect: Suspect, mod: Holder) = { + notification.add(suspect.id, ModId(mod.id)) insert(suspect, _.Moderator) } diff --git a/modules/mod/src/main/AssessApi.scala b/modules/mod/src/main/AssessApi.scala index a06f1666ec..df6a1f2c3a 100644 --- a/modules/mod/src/main/AssessApi.scala +++ b/modules/mod/src/main/AssessApi.scala @@ -153,7 +153,7 @@ final class AssessApi( def assessUser(userId: User.ID): Funit = getPlayerAggregateAssessment(userId) flatMap { - case Some(playerAggregateAssessment) => + _ ?? { playerAggregateAssessment => playerAggregateAssessment.action match { case AccountAction.Engine | AccountAction.EngineAndBan => userRepo.getTitle(userId).flatMap { @@ -171,7 +171,7 @@ final class AssessApi( // reporter ! lila.hub.actorApi.report.Clean(userId) funit } - case _ => funit + } } private val assessableSources: Set[Source] = @@ -219,6 +219,8 @@ final class AssessApi( val shouldAnalyse: Fu[Option[AutoAnalysis.Reason]] = if (!game.analysable) fuccess(none) + else if (game.speed >= chess.Speed.Blitz && (white.hasTitle || black.hasTitle)) + fuccess(TitledPlayer.some) else if (!game.source.exists(assessableSources.contains)) fuccess(none) // give up on correspondence games else if (game.isCorrespondence) fuccess(none) diff --git a/modules/mod/src/main/AutoAnalysis.scala b/modules/mod/src/main/AutoAnalysis.scala index 1c77d696ae..9eb06e23c5 100644 --- a/modules/mod/src/main/AutoAnalysis.scala +++ b/modules/mod/src/main/AutoAnalysis.scala @@ -13,5 +13,6 @@ object AutoAnalysis { case object Blurs extends Reason case object WinnerRatingProgress extends Reason case object NewPlayerWin extends Reason + case object TitledPlayer extends Reason } } diff --git a/modules/mod/src/main/Env.scala b/modules/mod/src/main/Env.scala index b0c45c5465..e71e179120 100644 --- a/modules/mod/src/main/Env.scala +++ b/modules/mod/src/main/Env.scala @@ -78,6 +78,8 @@ final class Env( lazy val presets = wire[ModPresetsApi] + lazy val ipRender = wire[IpRender] + private lazy val sandbagWatch = wire[SandbagWatch] lila.common.Bus.subscribeFuns( @@ -91,8 +93,11 @@ final class Env( if (game.status == chess.Status.Cheat) game.loserUserId foreach { userId => logApi.cheatDetected(userId, game.id) >> - logApi.countRecentCheatDetected(userId) flatMap { - reportApi.autoCheatDetectedReport(userId, _) + logApi.countRecentCheatDetected(userId) flatMap { count => + (count >= 3) ?? { + if (game.hasClock) api.autoMark(lila.report.SuspectId(userId), lila.report.ModId.lichess) + else reportApi.autoCheatDetectedReport(userId, count) + } } } }, diff --git a/modules/mod/src/main/IpRender.scala b/modules/mod/src/main/IpRender.scala new file mode 100644 index 0000000000..fe2edc4126 --- /dev/null +++ b/modules/mod/src/main/IpRender.scala @@ -0,0 +1,43 @@ +package lila.mod + +import com.github.blemale.scaffeine.Cache +import com.github.blemale.scaffeine.LoadingCache +import scala.concurrent.duration._ +import scala.jdk.CollectionConverters._ + +import lila.common.CuteNameGenerator +import lila.common.IpAddress +import lila.memo.CacheApi +import lila.security.Granter +import lila.user.Holder +import lila.common.ThreadLocalRandom + +object IpRender { + + type Raw = String + type Rendered = String + type RenderIp = IpAddress => Rendered +} + +final class IpRender { + + import IpRender._ + + def apply(mod: Holder): RenderIp = if (Granter.is(_.Admin)(mod)) visible else encrypted + + val visible = (ip: IpAddress) => ip.value + + val encrypted = (ip: IpAddress) => cache get ip + + def decrypt(str: String): Option[IpAddress] = IpAddress.from(str) orElse + cache.underlying.asMap.asScala.collectFirst { + case (ip, encrypted) if encrypted == str => + ip + } + + private val cache: LoadingCache[IpAddress, Rendered] = CacheApi.scaffeineNoScheduler + .expireAfterAccess(30 minutes) + .build((_: IpAddress) => + s"NoIP:${~CuteNameGenerator.make(maxSize = 30)}-${ThreadLocalRandom.nextString(3)}" + ) +} diff --git a/modules/mod/src/main/ModApi.scala b/modules/mod/src/main/ModApi.scala index b360cce2f1..025d51f592 100644 --- a/modules/mod/src/main/ModApi.scala +++ b/modules/mod/src/main/ModApi.scala @@ -3,7 +3,7 @@ package lila.mod import lila.common.{ Bus, EmailAddress } import lila.report.{ Mod, ModId, Room, Suspect, SuspectId } import lila.security.{ Granter, Permission } -import lila.user.{ LightUserApi, Title, User, UserRepo } +import lila.user.{ Holder, LightUserApi, Title, User, UserRepo } final class ModApi( userRepo: UserRepo, @@ -133,14 +133,14 @@ final class ModApi( logApi.setEmail(mod, user.id) } - def setPermissions(mod: Mod, username: String, permissions: Set[Permission]): Funit = + def setPermissions(mod: Holder, username: String, permissions: Set[Permission]): Funit = withUser(username) { user => val finalPermissions = Permission(user.roles).filter { p => // only remove permissions the mod can actually grant - permissions.contains(p) || !Granter.canGrant(mod.user, p) + permissions.contains(p) || !Granter.canGrant(mod, p) } ++ // only add permissions the mod can actually grant - permissions.filter(Granter.canGrant(mod.user, _)) + permissions.filter(Granter.canGrant(mod, _)) userRepo.setRoles(user.id, finalPermissions.map(_.dbKey).toList) >> { Bus.publish( lila.hub.actorApi.mod.SetPermissions(user.id, finalPermissions.map(_.dbKey).toList), diff --git a/modules/mod/src/main/ModlogApi.scala b/modules/mod/src/main/ModlogApi.scala index ef78f9d4ae..86b052b659 100644 --- a/modules/mod/src/main/ModlogApi.scala +++ b/modules/mod/src/main/ModlogApi.scala @@ -5,7 +5,7 @@ import org.joda.time.DateTime import lila.db.dsl._ import lila.report.{ Mod, ModId, Report, Suspect } import lila.security.Permission -import lila.user.{ User, UserRepo } +import lila.user.{ Holder, User, UserRepo } final class ModlogApi(repo: ModlogRepo, userRepo: UserRepo, slackApi: lila.irc.SlackApi)(implicit ec: scala.concurrent.ExecutionContext @@ -170,10 +170,10 @@ final class ModlogApi(repo: ModlogRepo, userRepo: UserRepo, slackApi: lila.irc.S Modlog(mod, user.some, Modlog.chatTimeout, details = s"$reason: $text".some) } - def setPermissions(mod: Mod, user: User.ID, permissions: Map[Permission, Boolean]) = + def setPermissions(mod: Holder, user: User.ID, permissions: Map[Permission, Boolean]) = add { Modlog( - mod.id.value, + mod.id, user.some, Modlog.permissions, details = permissions diff --git a/modules/mod/src/main/SandbagWatch.scala b/modules/mod/src/main/SandbagWatch.scala index 1e36041dd8..5d87b541e1 100644 --- a/modules/mod/src/main/SandbagWatch.scala +++ b/modules/mod/src/main/SandbagWatch.scala @@ -36,16 +36,15 @@ final private class SandbagWatch( } else { records.put(userId, record) - if (record.latest has Sandbag) { - if (record.count(Sandbag) == 3) sendMessage(userId, MsgPreset.sandbagAuto) - else if (record.count(Sandbag) == 4) withWinnerAndLoser(game)(reportApi.autoSandbagReport) - else funit - } else { - val boostCount = record.samePlayerBoostCount - if (boostCount == 3) sendMessage(userId, MsgPreset.boostAuto) - else if (boostCount == 4) withWinnerAndLoser(game)(reportApi.autoBoostReport) - else funit + val sandbagCount = record.countSandbagWithLatest + val boostCount = record.samePlayerBoostCount + if (sandbagCount == 3) sendMessage(userId, MsgPreset.sandbagAuto) + else if (sandbagCount == 4) game.loserUserId ?? { loser => + reportApi.autoSandbagReport(record.sandbagOpponents, loser) } + else if (boostCount == 3) sendMessage(userId, MsgPreset.boostAuto) + else if (boostCount == 4) withWinnerAndLoser(game)(reportApi.autoBoostReport) + else funit } private def sendMessage(userId: User.ID, preset: MsgPreset): Funit = @@ -70,7 +69,8 @@ final private class SandbagWatch( .playerByUserId(userId) .ifTrue(isSandbag(game)) .fold[Outcome](Good) { player => - if (player.color == loser) Sandbag else game.loserUserId.fold[Outcome](Good)(Boost.apply) + if (player.color == loser) game.winnerUserId.fold[Outcome](Good)(Sandbag.apply) + else game.loserUserId.fold[Outcome](Good)(Boost.apply) } private def isSandbag(game: Game): Boolean = @@ -83,9 +83,9 @@ final private class SandbagWatch( private object SandbagWatch { sealed trait Outcome - case object Good extends Outcome - case object Sandbag extends Outcome - case class Boost(opponent: User.ID) extends Outcome + case object Good extends Outcome + case class Sandbag(opponent: User.ID) extends Outcome + case class Boost(opponent: User.ID) extends Outcome val maxOutcomes = 7 @@ -99,6 +99,20 @@ private object SandbagWatch { def immaculate = outcomes.sizeIs == maxOutcomes && outcomes.forall(Good ==) + def latestIsSandbag = latest exists { + case Sandbag(_) => true + case _ => false + } + + def countSandbagWithLatest: Int = latestIsSandbag ?? outcomes.count { + case Sandbag(_) => true + case _ => false + } + + def sandbagOpponents = outcomes.collect { case Sandbag(opponent) => + opponent + }.distinct + def samePlayerBoostCount = latest ?? { case Boost(opponent) => outcomes.count { diff --git a/modules/msg/src/main/MsgApi.scala b/modules/msg/src/main/MsgApi.scala index b6bd46a31f..6ae80870d3 100644 --- a/modules/msg/src/main/MsgApi.scala +++ b/modules/msg/src/main/MsgApi.scala @@ -11,6 +11,7 @@ import lila.common.LilaStream import lila.common.{ Bus, LightUser } import lila.db.dsl._ import lila.user.{ User, UserRepo } +import lila.user.Holder final class MsgApi( colls: MsgColls, @@ -154,7 +155,7 @@ final class MsgApi( def systemPost(destId: User.ID, text: String) = post(User.lichessId, destId, text, multi = true) - def multiPost(orig: User, destSource: Source[User.ID, _], text: String): Fu[Int] = + def multiPost(orig: Holder, destSource: Source[User.ID, _], text: String): Fu[Int] = destSource .filter(orig.id !=) .mapAsync(4) { @@ -166,7 +167,7 @@ final class MsgApi( def cliMultiPost(orig: String, dests: Seq[User.ID], text: String): Fu[String] = userRepo named orig flatMap { case None => fuccess(s"Unknown sender $orig") - case Some(sender) => multiPost(sender, Source(dests), text) inject "done" + case Some(sender) => multiPost(Holder(sender), Source(dests), text) inject "done" } def recentByForMod(user: User, nb: Int): Fu[List[MsgConvo]] = diff --git a/modules/puzzle/src/main/DailyPuzzle.scala b/modules/puzzle/src/main/DailyPuzzle.scala index 7b8d9858af..a6fc14c353 100644 --- a/modules/puzzle/src/main/DailyPuzzle.scala +++ b/modules/puzzle/src/main/DailyPuzzle.scala @@ -52,8 +52,8 @@ final private[puzzle] class DailyPuzzle( .path { _.aggregateOne() { framework => import framework._ - Match(pathApi.select(PuzzleTheme.mix.key, PuzzleTier.Top, 1900 to 2100)) -> List( - Sample(2), + Match(pathApi.select(PuzzleTheme.mix.key, PuzzleTier.Top, 2000 to 2100)) -> List( + Sample(3), Project($doc("ids" -> true, "_id" -> false)), UnwindField("ids"), PipelineOperator( diff --git a/modules/report/src/main/ModReportFilter.scala b/modules/report/src/main/ModReportFilter.scala index 90b9130819..cf666aeba8 100644 --- a/modules/report/src/main/ModReportFilter.scala +++ b/modules/report/src/main/ModReportFilter.scala @@ -1,14 +1,14 @@ package lila.report -import lila.user.User +import lila.user.{ Holder, User } final class ModReportFilter { // mutable storage, because I cba to put it in DB private var modIdFilter = Map.empty[User.ID, Option[Room]] - def get(mod: User): Option[Room] = modIdFilter.get(mod.id).flatten + def get(mod: Holder): Option[Room] = modIdFilter.get(mod.id).flatten - def set(mod: User, filter: Option[Room]) = + def set(mod: Holder, filter: Option[Room]) = modIdFilter = modIdFilter + (mod.id -> filter) } diff --git a/modules/report/src/main/Reason.scala b/modules/report/src/main/Reason.scala index 3724a179d2..2956f8a5d7 100644 --- a/modules/report/src/main/Reason.scala +++ b/modules/report/src/main/Reason.scala @@ -1,6 +1,6 @@ package lila.report -import lila.user.User +import lila.user.{ Holder, User } sealed trait Reason { @@ -12,7 +12,10 @@ sealed trait Reason { object Reason { case object Cheat extends Reason - case object CheatPrint extends Reason { + case object CheatPrint extends Reason { // BC, replaced with AltPrint + override def name = "Print" + } + case object AltPrint extends Reason { override def name = "Print" } case object Comm extends Reason { @@ -22,9 +25,7 @@ object Reason { case object Other extends Reason case object Playbans extends Reason - // val communication: Set[Reason] = Set(Insult, Troll, CommFlag, Other) - - val all = List(Cheat, CheatPrint, Comm, Boost, Other) + val all = List(Cheat, AltPrint, Comm, Boost, Other, CheatPrint) val keys = all map (_.key) val byKey = all map { v => (v.key, v) @@ -39,18 +40,19 @@ object Reason { def isCheat = reason == Cheat def isOther = reason == Other - def isPrint = reason == CheatPrint + def isPrint = reason == AltPrint || reason == CheatPrint def isComm = reason == Comm + def isBoost = reason == Boost def isPlaybans = reason == Playbans } - def isGrantedFor(mod: User)(reason: Reason) = { + def isGrantedFor(mod: Holder)(reason: Reason) = { import lila.security.Granter reason match { - case Cheat => Granter(_.MarkEngine)(mod) - case CheatPrint => Granter(_.ViewIpPrint)(mod) - case Comm => Granter(_.Shadowban)(mod) - case Boost | Playbans | Other => Granter(_.MarkBooster)(mod) + case Cheat => Granter.is(_.MarkEngine)(mod) + case AltPrint | CheatPrint => Granter.is(_.Admin)(mod) + case Comm => Granter.is(_.Shadowban)(mod) + case Boost | Playbans | Other => Granter.is(_.MarkBooster)(mod) } } } diff --git a/modules/report/src/main/Report.scala b/modules/report/src/main/Report.scala index 0d5545610c..324a139585 100644 --- a/modules/report/src/main/Report.scala +++ b/modules/report/src/main/Report.scala @@ -128,6 +128,7 @@ object Report { def scored(score: Score) = Candidate.Scored(this, score) def isAutomatic = reporter.id == ReporterId.lichess def isAutoComm = isAutomatic && isComm + def isAutoBoost = isAutomatic && isBoost def isCoachReview = isOther && text.contains("COACH REVIEW") def isCommFlag = text contains Reason.Comm.flagText } diff --git a/modules/report/src/main/ReportApi.scala b/modules/report/src/main/ReportApi.scala index 0ee1da17e0..a272ca6e01 100644 --- a/modules/report/src/main/ReportApi.scala +++ b/modules/report/src/main/ReportApi.scala @@ -121,11 +121,11 @@ final class ReportApi( def getSuspect(username: String): Fu[Option[Suspect]] = userRepo named username dmap2 Suspect.apply - def autoCheatPrintReport(userId: String): Funit = + def autoAltPrintReport(userId: String): Funit = coll.exists( $doc( "user" -> userId, - "reason" -> Reason.CheatPrint.key + "reason" -> Reason.AltPrint.key ) ) flatMap { case true => funit // only report once @@ -136,8 +136,8 @@ final class ReportApi( Candidate( reporter = reporter, suspect = suspect, - reason = Reason.CheatPrint, - text = "Shares print with known cheaters" + reason = Reason.AltPrint, + text = "Shares print with suspicious accounts" ) ) case _ => funit @@ -163,14 +163,14 @@ final class ReportApi( def autoCheatDetectedReport(userId: User.ID, cheatedGames: Int): Funit = userRepo.byId(userId) zip getLichessReporter flatMap { - case Some(user) ~ reporter if !user.lame && cheatedGames >= 3 => + case Some(user) ~ reporter if !user.marks.engine => lila.mon.cheat.autoReport.increment() create( Candidate( reporter = reporter, suspect = Suspect(user), reason = Reason.Cheat, - text = s"$cheatedGames cheat detected in the last 6 months" + text = s"$cheatedGames cheat detected in the last 6 months; last one is correspondence" ) ) case _ => funit @@ -251,15 +251,15 @@ final class ReportApi( case _ => funit } - def autoSandbagReport(winnerId: User.ID, loserId: User.ID): Funit = - userRepo.pair(winnerId, loserId) zip getLichessReporter flatMap { - case Some((winner, loser)) ~ reporter if !winner.lame && !loser.lame => + def autoSandbagReport(winnerIds: List[User.ID], loserId: User.ID): Funit = + userRepo.byId(loserId) zip getLichessReporter flatMap { + case Some(loser) ~ reporter if !loser.lame => create( Candidate( reporter = reporter, suspect = Suspect(loser), reason = Reason.Boost, - text = s"Sandbagging: throws games to @${winner.username}" + text = s"Sandbagging: throws games to ${winnerIds.map("@" + _) mkString " "}" ) ) case _ => funit diff --git a/modules/report/src/main/ReportScore.scala b/modules/report/src/main/ReportScore.scala index 62116790d1..637c446972 100644 --- a/modules/report/src/main/ReportScore.scala +++ b/modules/report/src/main/ReportScore.scala @@ -12,14 +12,14 @@ final private class ReportScore( impl.autoScore(candidate) } map impl.fixedAutoCommPrintScore(candidate) map + impl.fixedBoostScore(candidate) map impl.commFlagScore(candidate) map { score => candidate scored Report.Score(score atLeast 5 atMost 100) } private object impl { - val baseScore = 20 - val baseScoreAboveThreshold = 50 + val baseScore = 20 def accuracyScore(a: Option[Accuracy]): Double = a ?? { accuracy => @@ -34,7 +34,11 @@ final private class ReportScore( // https://github.com/ornicar/lila/issues/4587 def fixedAutoCommPrintScore(c: Report.Candidate)(score: Double): Double = if (c.isAutoComm) baseScore - else if (c.isPrint || c.isCoachReview || c.isPlaybans) baseScoreAboveThreshold + else if (c.isPrint || c.isCoachReview || c.isPlaybans) baseScore * 2 + else score + + def fixedBoostScore(c: Report.Candidate)(score: Double): Double = + if (c.isAutoBoost) baseScore else score def commFlagScore(c: Report.Candidate)(score: Double): Double = diff --git a/modules/report/src/main/Room.scala b/modules/report/src/main/Room.scala index ed594daebe..06625b4ed7 100644 --- a/modules/report/src/main/Room.scala +++ b/modules/report/src/main/Room.scala @@ -1,6 +1,6 @@ package lila.report -import lila.user.User +import lila.user.{ Holder, User } sealed trait Room { @@ -33,7 +33,7 @@ object Room { def apply(reason: Reason): Room = reason match { case Reason.Cheat => Cheat - case Reason.CheatPrint => Print + case Reason.AltPrint | Reason.CheatPrint => Print case Reason.Comm => Comm case Reason.Boost | Reason.Playbans | Reason.Other => Other } @@ -41,7 +41,7 @@ object Room { def toReasons(room: Room): Set[Reason] = room match { case Cheat => Set(Reason.Cheat) - case Print => Set(Reason.CheatPrint) + case Print => Set(Reason.AltPrint) case Comm => Set(Reason.Comm) case Other => Set(Reason.Boost, Reason.Other) case Xfiles => Set.empty @@ -52,14 +52,14 @@ object Room { def highest = ~value.values.maxOption } - def isGrantedFor(mod: User)(room: Room) = { + def isGrantedFor(mod: Holder)(room: Room) = { import lila.security.Granter room match { - case Cheat => Granter(_.MarkEngine)(mod) - case Print => Granter(_.ViewIpPrint)(mod) - case Comm => Granter(_.Shadowban)(mod) - case Other => Granter(_.MarkBooster)(mod) - case Xfiles => Granter(_.MarkEngine)(mod) + case Cheat => Granter.is(_.MarkEngine)(mod) + case Print => Granter.is(_.Admin)(mod) + case Comm => Granter.is(_.Shadowban)(mod) + case Other => Granter.is(_.MarkBooster)(mod) + case Xfiles => Granter.is(_.MarkEngine)(mod) } } } diff --git a/modules/round/src/main/Drawer.scala b/modules/round/src/main/Drawer.scala index ad11634ff3..f6ab5fc2d4 100644 --- a/modules/round/src/main/Drawer.scala +++ b/modules/round/src/main/Drawer.scala @@ -20,7 +20,7 @@ final private[round] class Drawer( Pov(game) .map { pov => import Pref.PrefZero - if (game.playerHasOfferedDraw(pov.color)) fuccess(pov.some) + if (game.playerHasOfferedDrawRecently(pov.color)) fuccess(pov.some) else pov.player.userId ?? prefApi.getPref map { pref => pref.autoThreefold == Pref.AutoThreefold.ALWAYS || { @@ -41,9 +41,7 @@ final private[round] class Drawer( case Pov(g, color) if g playerCanOfferDraw color => proxy.save { messenger.system(g, color.fold(trans.whiteOffersDraw, trans.blackOffersDraw).txt()) - Progress(g) map { g => - g.updatePlayer(color, _ offerDraw g.turns) - } + Progress(g) map { _ offerDraw color } } >>- publishDrawOffer(pov) inject List(Event.DrawOffer(by = color.some)) case _ => fuccess(List(Event.ReloadOwner)) } diff --git a/modules/round/src/main/RoundDuct.scala b/modules/round/src/main/RoundDuct.scala index 9d214c4e17..4aa333a239 100644 --- a/modules/round/src/main/RoundDuct.scala +++ b/modules/round/src/main/RoundDuct.scala @@ -203,13 +203,10 @@ final private[round] class RoundDuct( "analysis" -> lila.analyse.JsonView.bothPlayers(a.game, a.analysis), "tree" -> lila.tree.Node.minimalNodeJsonWriter.writes { TreeBuilder( - id = a.analysis.id, - pgnMoves = a.game.pgnMoves, - variant = a.variant, - analysis = a.analysis.some, - initialFen = a.initialFen, - withFlags = JsonView.WithFlags(), - clocks = none + a.game, + a.analysis.some, + a.initialFen, + JsonView.WithFlags() ) } ) diff --git a/modules/round/src/main/TreeBuilder.scala b/modules/round/src/main/TreeBuilder.scala index f9aeb470b3..35f665bc08 100644 --- a/modules/round/src/main/TreeBuilder.scala +++ b/modules/round/src/main/TreeBuilder.scala @@ -1,6 +1,6 @@ package lila.round -import chess.Centis +import chess.{ Centis, Color } import chess.format.pgn.Glyphs import chess.format.{ FEN, Forsyth, Uci, UciCharPair } import chess.opening._ @@ -26,32 +26,14 @@ object TreeBuilder { analysis: Option[Analysis], initialFen: FEN, withFlags: WithFlags - ): Root = - apply( - id = game.id, - pgnMoves = game.pgnMoves, - variant = game.variant, - analysis = analysis, - initialFen = initialFen, - withFlags = withFlags, - clocks = withFlags.clocks ?? game.bothClockStates - ) - - def apply( - id: String, - pgnMoves: Vector[String], - variant: Variant, - analysis: Option[Analysis], - initialFen: FEN, - withFlags: WithFlags, - clocks: Option[Vector[Centis]] ): Root = { - val withClocks: Option[Vector[Centis]] = withFlags.clocks ?? clocks - chess.Replay.gameMoveWhileValid(pgnMoves, initialFen, variant) match { + val withClocks: Option[Vector[Centis]] = withFlags.clocks ?? game.bothClockStates + val drawOfferPlies = game.drawOffers.normalizedPlies + chess.Replay.gameMoveWhileValid(game.pgnMoves, initialFen, game.variant) match { case (init, games, error) => - error foreach logChessError(id) + error foreach logChessError(game.id) val openingOf: OpeningOf = - if (withFlags.opening && Variant.openingSensibleVariants(variant)) FullOpeningDB.findByFen + if (withFlags.opening && Variant.openingSensibleVariants(game.variant)) FullOpeningDB.findByFen else _ => None val fen = Forsyth >> init val infos: Vector[Info] = analysis.??(_.infos.toVector) @@ -83,18 +65,18 @@ object TreeBuilder { eval = info map makeEval, glyphs = Glyphs.fromList(advice.map(_.judgment.glyph).toList), comments = Node.Comments { - advice.map(_.makeComment(withEval = false, withBestMove = true)).toList.map { text => - Node.Comment( - Node.Comment.Id.make, - Node.Comment.Text(text), - Node.Comment.Author.Lichess - ) - } + drawOfferPlies(g.turns) + .option(makeLichessComment(s"${!Color.fromPly(g.turns)} offers draw")) + .toList ::: + advice + .map(_.makeComment(withEval = false, withBestMove = true)) + .toList + .map(makeLichessComment) } ) advices.get(g.turns + 1).flatMap { adv => games.lift(index - 1).map { case (fromGame, _) => - withAnalysisChild(id, branch, variant, Forsyth >> fromGame, openingOf)(adv.info) + withAnalysisChild(game.id, branch, game.variant, Forsyth >> fromGame, openingOf)(adv.info) } } getOrElse branch } @@ -108,6 +90,13 @@ object TreeBuilder { } } + private def makeLichessComment(text: String) = + Node.Comment( + Node.Comment.Id.make, + Node.Comment.Text(text), + Node.Comment.Author.Lichess + ) + private def withAnalysisChild( id: String, root: Branch, diff --git a/modules/security/src/main/Granter.scala b/modules/security/src/main/Granter.scala index 0f739aa907..2cbfe136bb 100644 --- a/modules/security/src/main/Granter.scala +++ b/modules/security/src/main/Granter.scala @@ -1,6 +1,6 @@ package lila.security -import lila.user.User +import lila.user.{ Holder, User } object Granter { @@ -10,21 +10,33 @@ object Granter { def apply(f: Permission.Selector)(user: User): Boolean = apply(f(Permission), user.roles) + def is(permission: Permission)(holder: Holder): Boolean = + apply(permission)(holder.user) + + def is(f: Permission.Selector)(holder: Holder): Boolean = + apply(f)(holder.user) + def apply(permission: Permission, roles: Seq[String]): Boolean = Permission(roles).exists(_ is permission) def byRoles(f: Permission.Selector)(roles: Seq[String]): Boolean = apply(f(Permission), roles) - def canGrant(user: User, permission: Permission): Boolean = - apply(_.SuperAdmin)(user) || { - apply(_.ChangePermission)(user) && Permission.nonModPermissions(permission) + def canGrant(user: Holder, permission: Permission): Boolean = + is(_.SuperAdmin)(user) || { + is(_.ChangePermission)(user) && Permission.nonModPermissions(permission) } || { - apply(_.Admin)(user) && { - apply(permission)(user) || Set[Permission]( + is(_.Admin)(user) && { + is(permission)(user) || Set[Permission]( Permission.MonitoredMod, Permission.PublicMod )(permission) } } + + def canViewAltUsername(mod: Holder, user: User): Boolean = + is(_.Admin)(mod) || { + (is(_.Hunter)(mod) && user.marks.engine) || + (is(_.Shusher)(mod) && user.marks.troll) + } } diff --git a/modules/security/src/main/Permission.scala b/modules/security/src/main/Permission.scala index 632459d332..b54aa218b8 100644 --- a/modules/security/src/main/Permission.scala +++ b/modules/security/src/main/Permission.scala @@ -25,8 +25,9 @@ object Permission { case object SetKidMode extends Permission("SET_KID_MODE", List(UserModView), "Set Kid Mode") case object MarkEngine extends Permission("ADJUST_CHEATER", List(UserModView), "Mark as cheater") case object MarkBooster extends Permission("ADJUST_BOOSTER", List(UserModView), "Mark as booster") - case object IpBan extends Permission("IP_BAN", List(UserModView), "IP ban") + case object IpBan extends Permission("IP_BAN", List(UserModView, ViewPrintNoIP), "IP ban") case object PrintBan extends Permission("PRINT_BAN", List(UserModView), "Print ban") + case object ViewPrintNoIP extends Permission("VIEW_PRINT_NOIP", "View Print & NoIP") case object DisableTwoFactor extends Permission("DISABLE_2FA", "Disable 2FA") case object CloseAccount extends Permission("CLOSE_ACCOUNT", List(UserModView), "Close/reopen account") case object SetTitle extends Permission("SET_TITLE", List(UserModView), "Set/unset title") @@ -50,7 +51,6 @@ object Permission { case object Coach extends Permission("COACH", "Is a coach") case object Teacher extends Permission("TEACHER", "Is a class teacher") case object ModNote extends Permission("MOD_NOTE", "Mod notes") - case object ViewIpPrint extends Permission("VIEW_IP_PRINT", "View IP/print") case object RemoveRanking extends Permission("REMOVE_RANKING", "Remove from ranking") case object ReportBan extends Permission("REPORT_BAN", "Report ban") case object ModMessage extends Permission("MOD_MESSAGE", "Send mod messages") @@ -92,7 +92,8 @@ object Permission { UserSearch, RemoveRanking, ModMessage, - ModNote + ModNote, + ViewPrintNoIP ), "Hunter" ) @@ -110,20 +111,12 @@ object Permission { ModMessage, SeeReport, ModLog, - ModNote + ModNote, + ViewPrintNoIP ), "Shusher" ) - case object Doxing - extends Permission( - "DOXING", - List( - ViewIpPrint - ), - "Doxing" - ) - case object Admin extends Permission( "ADMIN", @@ -131,7 +124,6 @@ object Permission { Hunter, Shusher, Appeals, - Doxing, IpBan, PrintBan, CloseAccount, @@ -190,7 +182,6 @@ object Permission { ), "Account mod" -> List( UserModView, - ViewIpPrint, IpBan, PrintBan, DisableTwoFactor, @@ -242,7 +233,6 @@ object Permission { LichessTeam, Hunter, Shusher, - Doxing, Admin, SuperAdmin ) diff --git a/modules/study/src/main/StudyApi.scala b/modules/study/src/main/StudyApi.scala index d5a9cf9fc8..017e5fc2e9 100644 --- a/modules/study/src/main/StudyApi.scala +++ b/modules/study/src/main/StudyApi.scala @@ -11,7 +11,7 @@ import lila.common.Bus import lila.hub.actorApi.timeline.{ Propagate, StudyCreate, StudyLike } import lila.socket.Socket.Sri import lila.tree.Node.{ Comment, Gamebook, Shapes } -import lila.user.User +import lila.user.{ Holder, User } final class StudyApi( studyRepo: StudyRepo, @@ -832,7 +832,7 @@ final class StudyApi( } } - def adminInvite(studyId: Study.Id, me: User): Funit = + def adminInvite(studyId: Study.Id, me: Holder): Funit = sequenceStudy(studyId) { inviter.admin(_, me) } def erase(user: User) = diff --git a/modules/study/src/main/StudyInvite.scala b/modules/study/src/main/StudyInvite.scala index 0dd7eab66b..b8f6fb53b8 100644 --- a/modules/study/src/main/StudyInvite.scala +++ b/modules/study/src/main/StudyInvite.scala @@ -6,7 +6,7 @@ import lila.db.dsl._ import lila.notify.{ InvitedToStudy, Notification, NotifyApi } import lila.pref.Pref import lila.relation.{ Block, Follow } -import lila.user.User +import lila.user.{ Holder, User } final private class StudyInvite( studyRepo: StudyRepo, @@ -72,7 +72,7 @@ final private class StudyInvite( }(funit) } yield invited - def admin(study: Study, user: User): Funit = + def admin(study: Study, user: Holder): Funit = studyRepo.coll { _.update .one( diff --git a/modules/team/src/main/Team.scala b/modules/team/src/main/Team.scala index 6de4cdab50..e73278c776 100644 --- a/modules/team/src/main/Team.scala +++ b/modules/team/src/main/Team.scala @@ -12,6 +12,7 @@ case class Team( location: Option[String], password: Option[String], description: String, + descPrivate: Option[String], nbMembers: Int, enabled: Boolean, open: Boolean, @@ -82,6 +83,7 @@ object Team { location: Option[String], password: Option[String], description: String, + descPrivate: Option[String], open: Boolean, createdBy: User ): Team = @@ -91,6 +93,7 @@ object Team { location = location, password = password, description = description, + descPrivate = descPrivate, nbMembers = 1, enabled = true, open = open, diff --git a/modules/team/src/main/TeamApi.scala b/modules/team/src/main/TeamApi.scala index 422afb79e8..ed7b42e069 100644 --- a/modules/team/src/main/TeamApi.scala +++ b/modules/team/src/main/TeamApi.scala @@ -53,6 +53,7 @@ final class TeamApi( location = s.location, password = s.password, description = s.description, + descPrivate = s.descPrivate, open = s.isOpen, createdBy = me ) @@ -74,6 +75,7 @@ final class TeamApi( location = e.location, password = e.password, description = e.description, + descPrivate = e.descPrivate, open = e.isOpen, chat = e.chat ) pipe { team => diff --git a/modules/team/src/main/TeamForm.scala b/modules/team/src/main/TeamForm.scala index 9d72d53ed8..7e9a94cfdb 100644 --- a/modules/team/src/main/TeamForm.scala +++ b/modules/team/src/main/TeamForm.scala @@ -4,7 +4,7 @@ import play.api.data._ import play.api.data.Forms._ import scala.concurrent.duration._ -import lila.common.Form.{ cleanText, numberIn } +import lila.common.Form.{ cleanNonEmptyText, cleanText, numberIn } import lila.db.dsl._ final private[team] class TeamForm( @@ -26,6 +26,7 @@ final private[team] class TeamForm( "message" -> optional(cleanText(minLength = 30, maxLength = 2000)) .verifying("Request message required", msg => msg.isDefined || team.open) val description = "description" -> cleanText(minLength = 30, maxLength = 4000) + val descPrivate = "descPrivate" -> optional(cleanNonEmptyText(maxLength = 4000)) val request = "request" -> boolean val gameId = "gameId" -> text val move = "move" -> text @@ -38,6 +39,7 @@ final private[team] class TeamForm( Fields.location, Fields.password, Fields.description, + Fields.descPrivate, Fields.request, Fields.gameId, Fields.move @@ -52,6 +54,7 @@ final private[team] class TeamForm( Fields.location, Fields.password, Fields.description, + Fields.descPrivate, Fields.request, Fields.chat )(TeamEdit.apply)(TeamEdit.unapply) @@ -59,6 +62,7 @@ final private[team] class TeamForm( location = team.location, password = team.password, description = team.description, + descPrivate = team.descPrivate, request = !team.open, chat = team.chat ) @@ -114,6 +118,7 @@ private[team] case class TeamSetup( location: Option[String], password: Option[String], description: String, + descPrivate: Option[String], request: Boolean, gameId: String, move: String @@ -125,7 +130,8 @@ private[team] case class TeamSetup( copy( name = name.trim, location = location map (_.trim) filter (_.nonEmpty), - description = description.trim + description = description.trim, + descPrivate = descPrivate map (_.trim) filter (_.nonEmpty) ) } @@ -133,6 +139,7 @@ private[team] case class TeamEdit( location: Option[String], password: Option[String], description: String, + descPrivate: Option[String], request: Boolean, chat: Team.ChatFor ) { @@ -142,7 +149,8 @@ private[team] case class TeamEdit( def trim = copy( location = location map (_.trim) filter (_.nonEmpty), - description = description.trim + description = description.trim, + descPrivate = descPrivate map (_.trim) filter (_.nonEmpty) ) } diff --git a/modules/timeline/src/main/Push.scala b/modules/timeline/src/main/Push.scala index 448aa9fdad..aae0d51e7e 100644 --- a/modules/timeline/src/main/Push.scala +++ b/modules/timeline/src/main/Push.scala @@ -52,7 +52,6 @@ final private[timeline] class Push( private def modPermissions = List( Permission.ModNote, - Permission.Doxing, Permission.Admin, Permission.SuperAdmin ) diff --git a/modules/user/src/main/UserMark.scala b/modules/user/src/main/UserMark.scala index 11f8359762..c63d469942 100644 --- a/modules/user/src/main/UserMark.scala +++ b/modules/user/src/main/UserMark.scala @@ -17,6 +17,7 @@ object UserMark { val indexed: Map[String, UserMark] = all.view.map { m => m.key -> m }.toMap + val bannable: Set[UserMark] = Set(Boost, Engine, Troll, Alt) implicit val markBsonHandler = stringAnyValHandler[UserMark](_.key, indexed.apply) } @@ -30,7 +31,7 @@ case class UserMarks(value: List[UserMark]) extends AnyVal { def alt = apply(UserMark.Alt) def nonEmpty = value.nonEmpty option this - def clean = value.isEmpty + def clean = !value.exists(UserMark.bannable.contains) def set(sel: UserMark.type => UserMark, v: Boolean) = UserMarks { diff --git a/modules/user/src/main/UserRepo.scala b/modules/user/src/main/UserRepo.scala index 3b4f9ba35d..1a2bc1a728 100644 --- a/modules/user/src/main/UserRepo.scala +++ b/modules/user/src/main/UserRepo.scala @@ -255,9 +255,14 @@ final class UserRepo(val coll: Coll)(implicit ec: scala.concurrent.ExecutionCont val disabledSelect = $doc(F.enabled -> false) def markSelect(mark: UserMark)(v: Boolean): Bdoc = if (v) $doc(F.marks -> mark.key) - else F.marks $ne (mark.key) + else F.marks $ne mark.key def engineSelect = markSelect(UserMark.Engine) _ def trollSelect = markSelect(UserMark.Troll) _ + val lameOrTroll = $or( + $doc(F.marks -> UserMark.Engine.key), + $doc(F.marks -> UserMark.Boost.key), + $doc(F.marks -> UserMark.Troll.key) + ) def stablePerfSelect(perf: String) = $doc(s"perfs.$perf.gl.d" -> $lt(lila.rating.Glicko.provisionalDeviation)) val patronSelect = $doc(s"${F.plan}.active" -> true) @@ -597,6 +602,9 @@ final class UserRepo(val coll: Coll)(implicit ec: scala.concurrent.ExecutionCont def countEngines(userIds: List[User.ID]): Fu[Int] = coll.secondaryPreferred.countSel($inIds(userIds) ++ engineSelect(true)) + def countLameOrTroll(userIds: List[User.ID]): Fu[Int] = + coll.secondaryPreferred.countSel($inIds(userIds) ++ lameOrTroll) + def containsEngine(userIds: List[User.ID]): Fu[Boolean] = coll.exists($inIds(userIds) ++ engineSelect(true)) diff --git a/modules/user/src/main/model.scala b/modules/user/src/main/model.scala index 170d7f6b65..9465f2db19 100644 --- a/modules/user/src/main/model.scala +++ b/modules/user/src/main/model.scala @@ -3,3 +3,8 @@ package lila.user final class GetBotIds(f: () => Fu[Set[User.ID]]) extends (() => Fu[Set[User.ID]]) { def apply() = f() } + +// permission holder +case class Holder(user: User) extends AnyVal { + def id = user.id +} diff --git a/project/Dependencies.scala b/project/Dependencies.scala index 81189e9f1f..a7223c6b85 100644 --- a/project/Dependencies.scala +++ b/project/Dependencies.scala @@ -15,7 +15,7 @@ object Dependencies { val scrimage = "com.sksamuel.scrimage" % "scrimage-core" % "4.0.17" val scaffeine = "com.github.blemale" %% "scaffeine" % "4.0.2" % "compile" val googleOAuth = "com.google.auth" % "google-auth-library-oauth2-http" % "0.24.1" - val scalaUri = "io.lemonlabs" %% "scala-uri" % "2.3.1" + val scalaUri = "io.lemonlabs" %% "scala-uri" % "3.1.0" val scalatags = "com.lihaoyi" %% "scalatags" % "0.9.3" val lettuce = "io.lettuce" % "lettuce-core" % "5.3.6.RELEASE" val epoll = "io.netty" % "netty-transport-native-epoll" % "4.1.58.Final" classifier "linux-x86_64" diff --git a/project/build.properties b/project/build.properties index 0b2e09c5ac..dbae93bcfd 100644 --- a/project/build.properties +++ b/project/build.properties @@ -1 +1 @@ -sbt.version=1.4.7 +sbt.version=1.4.9 diff --git a/public/javascripts/public-chat.js b/public/javascripts/public-chat.js deleted file mode 100644 index 768b126991..0000000000 --- a/public/javascripts/public-chat.js +++ /dev/null @@ -1,45 +0,0 @@ -$(function () { - var autoRefreshEnabled = true; - var autoRefreshOnHold = false; - - var renderButton = function () { - $('#auto_refresh').toggleClass('active', autoRefreshEnabled).toggleClass('hold', autoRefreshOnHold); - }; - - var onPageReload = function () { - $('#communication').append( - $('Auto refresh').on('click', () => { - autoRefreshEnabled = !autoRefreshEnabled; - renderButton(); - }) - ); - renderButton(); - - $('#communication .chat').each(function () { - this.scrollTop = 99999; - }); - - $('#communication') - .on('mouseenter', '.chat', function () { - autoRefreshOnHold = true; - $('#auto_refresh').addClass('hold'); - }) - .on('mouseleave', '.chat', function () { - autoRefreshOnHold = false; - $('#auto_refresh').removeClass('hold'); - }); - }; - onPageReload(); - - setInterval(function () { - if (!autoRefreshEnabled || document.visibilityState === 'hidden' || autoRefreshOnHold) return; - - // Reload only the chat grid portions of the page - fetch('/mod/public-chat') - .then(r => r.text()) - .then(html => { - $(html).find('#communication').appendTo($('#comm-wrap').empty()); - onPageReload(); - }); - }, 5000); -}); diff --git a/public/sound/lisp/Berserk.mp3 b/public/sound/lisp/Berserk.mp3 index f38fc08727..cdfeb47698 100644 Binary files a/public/sound/lisp/Berserk.mp3 and b/public/sound/lisp/Berserk.mp3 differ diff --git a/public/sound/lisp/Berserk.ogg b/public/sound/lisp/Berserk.ogg index 867bd34dc6..b4e22b87c5 100644 Binary files a/public/sound/lisp/Berserk.ogg and b/public/sound/lisp/Berserk.ogg differ diff --git a/public/sound/lisp/Capture.mp3 b/public/sound/lisp/Capture.mp3 index 3a0414036c..0002b08836 100644 Binary files a/public/sound/lisp/Capture.mp3 and b/public/sound/lisp/Capture.mp3 differ diff --git a/public/sound/lisp/Capture.ogg b/public/sound/lisp/Capture.ogg index db692ee5d7..7efd293fd0 100644 Binary files a/public/sound/lisp/Capture.ogg and b/public/sound/lisp/Capture.ogg differ diff --git a/public/sound/lisp/Check.mp3 b/public/sound/lisp/Check.mp3 index 3c7608b19c..961dccee02 100644 Binary files a/public/sound/lisp/Check.mp3 and b/public/sound/lisp/Check.mp3 differ diff --git a/public/sound/lisp/Check.ogg b/public/sound/lisp/Check.ogg index 28f148d006..06d6940055 100644 Binary files a/public/sound/lisp/Check.ogg and b/public/sound/lisp/Check.ogg differ diff --git a/public/sound/lisp/Confirmation.mp3 b/public/sound/lisp/Confirmation.mp3 index 5d5e7c208f..c7d9e53c63 100644 Binary files a/public/sound/lisp/Confirmation.mp3 and b/public/sound/lisp/Confirmation.mp3 differ diff --git a/public/sound/lisp/Confirmation.ogg b/public/sound/lisp/Confirmation.ogg index 9aeae83e07..b033d3034c 100644 Binary files a/public/sound/lisp/Confirmation.ogg and b/public/sound/lisp/Confirmation.ogg differ diff --git a/public/sound/lisp/Defeat.mp3 b/public/sound/lisp/Defeat.mp3 index a22575cbcc..c1e54c8aa3 100644 Binary files a/public/sound/lisp/Defeat.mp3 and b/public/sound/lisp/Defeat.mp3 differ diff --git a/public/sound/lisp/Defeat.ogg b/public/sound/lisp/Defeat.ogg index 00e347bc1a..950b5ad3d8 100644 Binary files a/public/sound/lisp/Defeat.ogg and b/public/sound/lisp/Defeat.ogg differ diff --git a/public/sound/lisp/Draw.mp3 b/public/sound/lisp/Draw.mp3 index 71c14e16c2..daef1bed7b 100644 Binary files a/public/sound/lisp/Draw.mp3 and b/public/sound/lisp/Draw.mp3 differ diff --git a/public/sound/lisp/Draw.ogg b/public/sound/lisp/Draw.ogg index ae8987ea4d..bc56ec03bb 100644 Binary files a/public/sound/lisp/Draw.ogg and b/public/sound/lisp/Draw.ogg differ diff --git a/public/sound/lisp/Error.mp3 b/public/sound/lisp/Error.mp3 index 923cc1542d..7cf1d5f6f9 100644 Binary files a/public/sound/lisp/Error.mp3 and b/public/sound/lisp/Error.mp3 differ diff --git a/public/sound/lisp/Error.ogg b/public/sound/lisp/Error.ogg index 6e7f8f6bf9..74c78a295e 100644 Binary files a/public/sound/lisp/Error.ogg and b/public/sound/lisp/Error.ogg differ diff --git a/public/sound/lisp/Explosion.mp3 b/public/sound/lisp/Explosion.mp3 index b3f81fbdb9..7f8495f21b 100644 Binary files a/public/sound/lisp/Explosion.mp3 and b/public/sound/lisp/Explosion.mp3 differ diff --git a/public/sound/lisp/Explosion.ogg b/public/sound/lisp/Explosion.ogg index e419a72dd9..15a54bd9c9 100644 Binary files a/public/sound/lisp/Explosion.ogg and b/public/sound/lisp/Explosion.ogg differ diff --git a/public/sound/lisp/GenericNotify.mp3 b/public/sound/lisp/GenericNotify.mp3 index 7c17fcfcba..28e0602999 100644 Binary files a/public/sound/lisp/GenericNotify.mp3 and b/public/sound/lisp/GenericNotify.mp3 differ diff --git a/public/sound/lisp/GenericNotify.ogg b/public/sound/lisp/GenericNotify.ogg index e9225dc47b..38130b90c4 100644 Binary files a/public/sound/lisp/GenericNotify.ogg and b/public/sound/lisp/GenericNotify.ogg differ diff --git a/public/sound/lisp/Move.mp3 b/public/sound/lisp/Move.mp3 index 4c114be5f4..479e98f3c0 100644 Binary files a/public/sound/lisp/Move.mp3 and b/public/sound/lisp/Move.mp3 differ diff --git a/public/sound/lisp/Move.ogg b/public/sound/lisp/Move.ogg index e4196bfa80..d0db838233 100644 Binary files a/public/sound/lisp/Move.ogg and b/public/sound/lisp/Move.ogg differ diff --git a/public/sound/lisp/Victory.mp3 b/public/sound/lisp/Victory.mp3 index bf5f0b5b50..29b77d1b3e 100644 Binary files a/public/sound/lisp/Victory.mp3 and b/public/sound/lisp/Victory.mp3 differ diff --git a/public/sound/lisp/Victory.ogg b/public/sound/lisp/Victory.ogg index 06dfa92bd3..87231ea45f 100644 Binary files a/public/sound/lisp/Victory.ogg and b/public/sound/lisp/Victory.ogg differ diff --git a/public/sound/test.html b/public/sound/test.html index 351f71bcfb..ad0d0b52d6 100644 --- a/public/sound/test.html +++ b/public/sound/test.html @@ -34,13 +34,16 @@ -




+

+
+

- Puzzle Storm + + Puzzle Storm + - - +