From d82b1a78105d8bf7ed743924484bc8f19741d71c Mon Sep 17 00:00:00 2001 From: Thibault Duplessis Date: Wed, 18 Dec 2024 15:45:03 +0100 Subject: [PATCH 01/24] account delete WIP --- app/controllers/Account.scala | 28 +++++++++++++++++--- app/controllers/LilaController.scala | 2 +- bin/mongodb/recap-notif.js | 4 +-- conf/routes | 2 ++ modules/api/src/main/AccountClosure.scala | 11 +++++++- modules/gathering/src/main/Quote.scala | 2 +- modules/oauth/src/main/AccessTokenApi.scala | 4 +++ modules/pref/src/main/ui/AccountPages.scala | 28 ++++++++++++++++++++ modules/security/src/main/SecurityForm.scala | 8 ++++++ modules/ui/src/main/helper/Form3.scala | 2 +- 10 files changed, 81 insertions(+), 10 deletions(-) diff --git a/app/controllers/Account.scala b/app/controllers/Account.scala index fcb6727c2bd02..e57e8628c531c 100644 --- a/app/controllers/Account.scala +++ b/app/controllers/Account.scala @@ -240,10 +240,11 @@ final class Account( } def close = Auth { _ ?=> me ?=> - env.clas.api.student.isManaged(me).flatMap { managed => - env.security.forms.closeAccount.flatMap: form => - Ok.page(pages.close(form, managed)) - } + for + managed <- env.clas.api.student.isManaged(me) + form <- env.security.forms.closeAccount + res <- Ok.page(pages.close(form, managed)) + yield res } def closeConfirm = AuthBody { ctx ?=> me ?=> @@ -257,6 +258,25 @@ final class Account( Redirect(routes.User.show(me.username)).withCookies(env.security.lilaCookie.newSession) } + def delete = Auth { _ ?=> me ?=> + for + managed <- env.clas.api.student.isManaged(me) + form <- env.security.forms.deleteAccount + res <- Ok.page(pages.delete(form, managed)) + yield res + } + + def deleteConfirm = AuthBody { ctx ?=> me ?=> + NotManaged: + auth.HasherRateLimit: + env.security.forms.deleteAccount.flatMap: form => + FormFuResult(form)(err => renderPage(pages.delete(err, managed = false))): _ => + env.api.accountClosure + .close(me.value) + .inject: + Redirect(routes.User.show(me.username)).withCookies(env.security.lilaCookie.newSession) + } + def kid = Auth { _ ?=> me ?=> for managed <- env.clas.api.student.isManaged(me) diff --git a/app/controllers/LilaController.scala b/app/controllers/LilaController.scala index 6f9fea966dc79..05448e150eaeb 100644 --- a/app/controllers/LilaController.scala +++ b/app/controllers/LilaController.scala @@ -351,4 +351,4 @@ abstract private[controllers] class LilaController(val env: Env) def anyCaptcha = env.game.captcha.any def bindForm[T, R](form: Form[T])(error: Form[T] => R, success: T => R)(using Request[?], FormBinding): R = - form.bindFromRequest().fold(error, success) + form.bindFromRequest().pp.fold(error, success) diff --git a/bin/mongodb/recap-notif.js b/bin/mongodb/recap-notif.js index cf22182bc9c4c..4958402b9149b 100644 --- a/bin/mongodb/recap-notif.js +++ b/bin/mongodb/recap-notif.js @@ -75,9 +75,9 @@ function sendToRandomOfflinePlayers() { } db.user4.find({ enabled: true, - createdAt: { $lt: new Date(year, 9, 1) }, + createdAt: { $lt: new Date(year, 10, 1) }, seenAt: { - $gt: new Date(Date.now() - 1000 * 60 * 60 * 24 * 30 * 3), + $gt: new Date(Date.now() - 1000 * 60 * 60 * 24 * 30 * 4), // $lt: new Date(Date.now() - 1000 * 60 * 20) // avoid the lila notif cache! }, marks: { $nin: ['boost', 'engine', 'troll'] } diff --git a/conf/routes b/conf/routes index 0f9a6ffd0bff0..76dd3c53c5149 100644 --- a/conf/routes +++ b/conf/routes @@ -775,6 +775,8 @@ GET /contact/email-confirm/help controllers.Account.emailConfirmHelp GET /account/email/confirm/:token controllers.Account.emailConfirm(token) GET /account/close controllers.Account.close POST /account/closeConfirm controllers.Account.closeConfirm +GET /account/delete controllers.Account.delete +POST /account/deleteConfirm controllers.Account.deleteConfirm GET /account/profile controllers.Account.profile POST /account/profile controllers.Account.profileApply GET /account/username controllers.Account.username diff --git a/modules/api/src/main/AccountClosure.scala b/modules/api/src/main/AccountClosure.scala index 2ec885fe0784e..0f0e200d41956 100644 --- a/modules/api/src/main/AccountClosure.scala +++ b/modules/api/src/main/AccountClosure.scala @@ -3,6 +3,15 @@ package lila.api import lila.common.Bus import lila.core.perm.Granter +/* There are 3 stages to account eradication. + * - close: + * - disable the account; the user can reopen it later on + * - close all open sessions + * - cancel patron sub + * - leave teams and tournaments + * - unfollow everyone + * - + */ final class AccountClosure( userRepo: lila.user.UserRepo, playbanApi: lila.playban.PlaybanApi, @@ -78,5 +87,5 @@ final class AccountClosure( def closeThenErase(username: UserStr)(using Me): Fu[Either[String, String]] = userRepo.byId(username).flatMap { case None => fuccess(Left("No such user.")) - case Some(u) => (u.enabled.yes.so(close(u))) >> eraseClosed(u.id) + case Some(u) => u.enabled.yes.so(close(u)) >> eraseClosed(u.id) } diff --git a/modules/gathering/src/main/Quote.scala b/modules/gathering/src/main/Quote.scala index 755da6a098131..b2ea9b01acdc2 100644 --- a/modules/gathering/src/main/Quote.scala +++ b/modules/gathering/src/main/Quote.scala @@ -1374,7 +1374,7 @@ object Quote: "Garry Kasparov" ), Quote( - "For me, chess is at the same time a game, a sport, a science and an art. And perhaps even more than that,. There is something hard to explain to those who do not know the game well. One must first learn to play it correctly in order to savor its richness.", + "For me, chess is at the same time a game, a sport, a science and an art. And perhaps even more than that. There is something hard to explain to those who do not know the game well. One must first learn to play it correctly in order to savor its richness.", "Bent Larsen" ), Quote( diff --git a/modules/oauth/src/main/AccessTokenApi.scala b/modules/oauth/src/main/AccessTokenApi.scala index 3fdf8bb686a48..423f1abf376c8 100644 --- a/modules/oauth/src/main/AccessTokenApi.scala +++ b/modules/oauth/src/main/AccessTokenApi.scala @@ -168,6 +168,10 @@ final class AccessTokenApi( for _ <- coll.delete.one($doc(F.id -> id, F.userId -> me)) yield onRevoke(id) + def revokeAllByUser(using me: MyId): Funit = + for _ <- coll.delete.one($doc(F.id -> id, F.userId -> me)) + yield onRevoke(id) + def revokeByClientOrigin(clientOrigin: String)(using me: MyId): Funit = coll .find( diff --git a/modules/pref/src/main/ui/AccountPages.scala b/modules/pref/src/main/ui/AccountPages.scala index fc049f12eb09b..8374dce4a82ae 100644 --- a/modules/pref/src/main/ui/AccountPages.scala +++ b/modules/pref/src/main/ui/AccountPages.scala @@ -19,6 +19,7 @@ final class AccountPages(helpers: Helpers, ui: AccountUi, flagApi: lila.core.use if managed then p(trs.managedAccountCannotBeClosed()) else postForm(cls := "form3", action := routes.Account.closeConfirm)( + div(cls := "form-group")(h2("We're sorry to see you go.")), div(cls := "form-group")(trs.closeAccountExplanation()), div(cls := "form-group")(trs.cantOpenSimilarAccount()), form3.passwordModified(form("passwd"), trans.site.password())(autofocus, autocomplete := "off"), @@ -35,6 +36,33 @@ final class AccountPages(helpers: Helpers, ui: AccountUi, flagApi: lila.core.use ) ) + def delete(form: Form[?], managed: Boolean)(using Context)(using me: Me) = + AccountPage(s"${me.username} - Delete your account", "delete"): + div(cls := "box box-pad")( + boxTop(h1(cls := "text", dataIcon := Icon.CautionCircle)("Delete your account")), + if managed then p(trs.managedAccountCannotBeClosed()) + else + postForm(cls := "form3", action := routes.Account.deleteConfirm)( + div(cls := "form-group")(h2("We're sorry to see you go.")), + div(cls := "form-group")( + "Once you delete your account, your profile and username are permanently removed from Lichess and your posts, comments, and game are disassociated (not deleted) from your account." + ), + form3.passwordModified(form("passwd"), trans.site.password())(autofocus, autocomplete := "off"), + form3.checkbox(form("understand"), "I understand that deleted accounts aren't recoverable"), + form3.errors(form("understand")), + form3.actions( + frag( + a(href := routes.User.show(me.username))(trans.site.cancel()), + form3.submit( + "Delete my account", + icon = Icon.CautionCircle.some, + confirm = trs.closingIsDefinitive.txt().some + )(cls := "button-red") + ) + ) + ) + ) + private def linksHelp()(using Translate) = frag( "Mastodon, Facebook, GitHub, Chess.com, ...", br, diff --git a/modules/security/src/main/SecurityForm.scala b/modules/security/src/main/SecurityForm.scala index 500c30d00e826..c72ba736b3563 100644 --- a/modules/security/src/main/SecurityForm.scala +++ b/modules/security/src/main/SecurityForm.scala @@ -211,6 +211,14 @@ final class SecurityForm( )(Reopen.apply)(_ => None) ) + def deleteAccount(using Me) = + authenticator.loginCandidate.map: candidate => + Form: + mapping( + "passwd" -> passwordMapping(candidate), + "understand" -> boolean.verifying("It's an important point.", identity[Boolean]) + )((pass, _) => pass)(_ => None) + private def passwordMapping(candidate: LoginCandidate) = text.verifying("incorrectPassword", p => candidate.check(ClearPassword(p))) diff --git a/modules/ui/src/main/helper/Form3.scala b/modules/ui/src/main/helper/Form3.scala index 3ad27aa98bf3d..998e42862a767 100644 --- a/modules/ui/src/main/helper/Form3.scala +++ b/modules/ui/src/main/helper/Form3.scala @@ -17,8 +17,8 @@ final class Form3(formHelper: FormHelper & I18nHelper & AssetHelper, flairApi: F private def groupLabel(field: Field) = label(cls := "form-label", `for` := id(field)) private val helper = small(cls := "form-help") + def errors(field: Field)(using Translate): Frag = errors(field.errors) private def errors(errs: Seq[FormError])(using Translate): Frag = errs.distinct.map(error) - private def errors(field: Field)(using Translate): Frag = errors(field.errors) private def error(err: FormError)(using Translate): Frag = p(cls := "error")(transKey(trans(err.message), err.args)) From e827318978fab417929345a4d800854761147a2c Mon Sep 17 00:00:00 2001 From: Thibault Duplessis Date: Wed, 15 Jan 2025 15:08:23 +0100 Subject: [PATCH 02/24] revoke all oauth tokens on account closure --- app/controllers/LilaController.scala | 2 +- modules/api/src/main/AccountClosure.scala | 1 + modules/oauth/src/main/AccessTokenApi.scala | 13 ++++++++++--- 3 files changed, 12 insertions(+), 4 deletions(-) diff --git a/app/controllers/LilaController.scala b/app/controllers/LilaController.scala index da57d0d8d9525..8fda065ba753c 100644 --- a/app/controllers/LilaController.scala +++ b/app/controllers/LilaController.scala @@ -353,4 +353,4 @@ abstract private[controllers] class LilaController(val env: Env) def anyCaptcha = env.game.captcha.any def bindForm[T, R](form: Form[T])(error: Form[T] => R, success: T => R)(using Request[?], FormBinding): R = - form.bindFromRequest().pp.fold(error, success) + form.bindFromRequest().fold(error, success) diff --git a/modules/api/src/main/AccountClosure.scala b/modules/api/src/main/AccountClosure.scala index 3550343918119..ad88892eef766 100644 --- a/modules/api/src/main/AccountClosure.scala +++ b/modules/api/src/main/AccountClosure.scala @@ -58,6 +58,7 @@ final class AccountClosure( _ <- planApi.cancelIfAny(u).recoverDefault _ <- seekApi.removeByUser(u) _ <- securityStore.closeAllSessionsOf(u.id) + _ <- tokenApi.revokeAllByUser _ <- pushEnv.webSubscriptionApi.unsubscribeByUser(u) _ <- pushEnv.unregisterDevices(u) _ <- streamerApi.demote(u.id) diff --git a/modules/oauth/src/main/AccessTokenApi.scala b/modules/oauth/src/main/AccessTokenApi.scala index 3047e65dbe622..6c4a830ec97a0 100644 --- a/modules/oauth/src/main/AccessTokenApi.scala +++ b/modules/oauth/src/main/AccessTokenApi.scala @@ -2,6 +2,8 @@ package lila.oauth import play.api.libs.json.* import reactivemongo.api.bson.* +import reactivemongo.akkastream.cursorProducer +import akka.stream.scaladsl.* import lila.common.Json.given import lila.core.misc.oauth.TokenRevoke @@ -12,7 +14,7 @@ final class AccessTokenApi( coll: Coll, cacheApi: lila.memo.CacheApi, userApi: lila.core.user.UserApi -)(using Executor): +)(using Executor, akka.stream.Materializer): import OAuthScope.given import AccessToken.{ BSONFields as F, given } @@ -169,8 +171,13 @@ final class AccessTokenApi( yield onRevoke(id) def revokeAllByUser(using me: MyId): Funit = - for _ <- coll.delete.one($doc(F.id -> id, F.userId -> me)) - yield onRevoke(id) + coll + .find($doc(F.userId -> me)) + .cursor[AccessToken]() + .documentSource() + .mapAsyncUnordered(4)(token => revokeById(token.id)) + .runWith(Sink.ignore) + .void def revokeByClientOrigin(clientOrigin: String)(using me: MyId): Funit = coll From 50ddd69f4ddef196195c5c5cd7090640c99888a7 Mon Sep 17 00:00:00 2001 From: Thibault Duplessis Date: Wed, 15 Jan 2025 15:23:55 +0100 Subject: [PATCH 03/24] document account termination --- modules/api/src/main/AccountClosure.scala | 37 ++++++++++++++++++----- 1 file changed, 29 insertions(+), 8 deletions(-) diff --git a/modules/api/src/main/AccountClosure.scala b/modules/api/src/main/AccountClosure.scala index ad88892eef766..e0658b12b6b40 100644 --- a/modules/api/src/main/AccountClosure.scala +++ b/modules/api/src/main/AccountClosure.scala @@ -3,14 +3,35 @@ package lila.api import lila.common.Bus import lila.core.perm.Granter -/* There are 3 stages to account eradication. - * - close: - * - disable the account; the user can reopen it later on - * - close all open sessions - * - cancel patron sub - * - leave teams and tournaments - * - unfollow everyone - * - +enum Termination: + case disable, delete, erase + +/* There are 3 stages to account termination. +| | disable | delete | erase | +|---------------------------|----------------------------------|-----------------------|--------------------------------| +| how | from settings menu | from /account/delete | request to contact@lichess.org | +| reopen | available to user | strictly impossible | strictly impossible | +| games | intact | anonymized | anonymized | +| username | intact, no reuse | anonymized, no reuse | anonymized, no reuse | +| email | kept for reopening, no reuse[^1] | deleted, no reuse[^1] | deleted, no reuse[^1] | +| profile data | hidden | deleted | deleted | +| sessions and oauth tokens | closed | deleted | deleted | +| patron subscription | canceled | canceled | canceled | +| blog posts | unlisted | deleted | deleted | +| studies | hidden | deleted | deleted | +| activity | hidden | deleted | deleted | +| coach/streamer profiles | hidden | deleted | deleted | +| tournaments joined | unlisted | anonymized | anonymized | +| tournaments created | hidden | anonymized | anonymized | +| forum posts | intact | anonymized | deleted | +| teams/classes joined | quit | quit | quit | +| team/classes created | intact[^2] | intact[^2] | intact[^2] | +| classes joiated | intact[^2] | intact[^2] | intact[^2] | +| puzzle history | hidden | deleted | deleted | +| follows and blocks | deleted | deleted | deleted | + +[^1] the email address of a closed account can be re-used to make a new account, up to 4 times per month. +[^2] classes and teams have a life of their own. Close them manually if you want to, before deleting your account. */ final class AccountClosure( userRepo: lila.user.UserRepo, From a0dc19780ea5c6aa57c83db189eed4db41e2c816 Mon Sep 17 00:00:00 2001 From: Thibault Duplessis Date: Wed, 15 Jan 2025 15:25:16 +0100 Subject: [PATCH 04/24] add missing dependency --- modules/oauth/src/main/Env.scala | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/oauth/src/main/Env.scala b/modules/oauth/src/main/Env.scala index a2d6e1bd3072e..842885d0c4b64 100644 --- a/modules/oauth/src/main/Env.scala +++ b/modules/oauth/src/main/Env.scala @@ -16,7 +16,7 @@ final class Env( settingStore: lila.memo.SettingStore.Builder, appConfig: Configuration, db: lila.db.Db -)(using Executor): +)(using Executor, akka.stream.Materializer): lazy val originBlocklistSetting = settingStore[Strings]( "oauthOriginBlocklist", From 93946e0e91e31055bba68b22be5d2e476a1993e2 Mon Sep 17 00:00:00 2001 From: Thibault Duplessis Date: Wed, 15 Jan 2025 18:34:25 +0100 Subject: [PATCH 05/24] mongo scheduler and progress on account termination strategies --- app/controllers/Mod.scala | 7 +- modules/api/src/main/AccountClosure.scala | 22 ++++-- modules/api/src/main/Env.scala | 4 +- modules/memo/src/main/Env.scala | 2 + modules/memo/src/main/MongoScheduler.scala | 69 +++++++++++++++++++ .../memo/src/main/ParallelMongoQueue.scala | 1 - modules/mod/src/main/ModApi.scala | 2 +- modules/oauth/src/main/AccessTokenApi.scala | 6 +- modules/user/src/main/BSONHandlers.scala | 1 - modules/user/src/main/UserRepo.scala | 6 +- 10 files changed, 96 insertions(+), 24 deletions(-) create mode 100644 modules/memo/src/main/MongoScheduler.scala diff --git a/app/controllers/Mod.scala b/app/controllers/Mod.scala index 7e4ac64d43bfd..6ec860ac445d4 100644 --- a/app/controllers/Mod.scala +++ b/app/controllers/Mod.scala @@ -381,12 +381,9 @@ final class Mod( } def gdprErase(username: UserStr) = Secure(_.GdprErase) { _ ?=> me ?=> - val res = Redirect(routes.User.show(username)) env.api.accountClosure - .closeThenErase(username) - .map: - case Right(msg) => res.flashSuccess(msg) - case Left(err) => res.flashFailure(err) + .scheduleErasure(username.id) + .inject(Redirect(routes.User.show(username)).flashSuccess("Erasure scheduled")) } protected[controllers] def searchTerm(query: String)(using Context, Me) = diff --git a/modules/api/src/main/AccountClosure.scala b/modules/api/src/main/AccountClosure.scala index e0658b12b6b40..46ed5a22e9f95 100644 --- a/modules/api/src/main/AccountClosure.scala +++ b/modules/api/src/main/AccountClosure.scala @@ -53,7 +53,9 @@ final class AccountClosure( appealApi: lila.appeal.AppealApi, ublogApi: lila.ublog.UblogApi, activityWrite: lila.activity.ActivityWriteApi, - email: lila.mailer.AutomaticEmail + email: lila.mailer.AutomaticEmail, + tokenApi: lila.oauth.AccessTokenApi, + mongoScheduler: lila.memo.MongoSchedulerApi )(using Executor): Bus.subscribeFuns( @@ -63,6 +65,14 @@ final class AccountClosure( "rageSitClose" -> { case lila.core.playban.RageSitClose(userId) => lichessClose(userId) } ) + private val eraserScheduler = + import lila.db.dsl.userIdHandler + mongoScheduler.make[UserId]("eraser")(doEraseNow) + + def scheduleErasure(u: User)(using Me): Funit = + for _ <- close(u) + yield eraserScheduler.schedule(u.id, 24.hours) + def close(u: User)(using me: Me): Funit = for playbanned <- playbanApi.hasCurrentPlayban(u.id) selfClose = me.is(u) @@ -79,7 +89,7 @@ final class AccountClosure( _ <- planApi.cancelIfAny(u).recoverDefault _ <- seekApi.removeByUser(u) _ <- securityStore.closeAllSessionsOf(u.id) - _ <- tokenApi.revokeAllByUser + _ <- tokenApi.revokeAllByUser(u) _ <- pushEnv.webSubscriptionApi.unsubscribeByUser(u) _ <- pushEnv.unregisterDevices(u) _ <- streamerApi.demote(u.id) @@ -106,8 +116,6 @@ final class AccountClosure( Right(s"Erasing all data about $username in 24h") } - def closeThenErase(username: UserStr)(using Me): Fu[Either[String, String]] = - userRepo.byId(username).flatMap { - case None => fuccess(Left("No such user.")) - case Some(u) => u.enabled.yes.so(close(u)) >> eraseClosed(u.id) - } + private def doEraseNow(userId: UserId): Funit = + fuccess: + println(s"Time to wipe $userId") diff --git a/modules/api/src/main/Env.scala b/modules/api/src/main/Env.scala index a328aad0997ef..0f8fc36dde5bb 100644 --- a/modules/api/src/main/Env.scala +++ b/modules/api/src/main/Env.scala @@ -58,7 +58,9 @@ final class Env( webConfig: lila.web.WebConfig, realPlayerApi: lila.web.RealPlayerApi, bookmarkExists: lila.core.bookmark.BookmarkExists, - manifest: lila.web.AssetManifest + manifest: lila.web.AssetManifest, + tokenApi: lila.oauth.AccessTokenApi, + mongoScheduler: lila.memo.MongoSchedulerApi )(using val mode: Mode, scheduler: Scheduler)(using Executor, ActorSystem, diff --git a/modules/memo/src/main/Env.scala b/modules/memo/src/main/Env.scala index 61bbaf26d72a4..dd6fd956bb3bc 100644 --- a/modules/memo/src/main/Env.scala +++ b/modules/memo/src/main/Env.scala @@ -41,6 +41,8 @@ final class Env( val mongoRateLimitApi = wire[MongoRateLimitApi] + val mongoSchedulerApi = wire[MongoSchedulerApi] + val picfitUrl = lila.memo.PicfitUrl(config.picfit) val picfitApi = PicfitApi(db(config.picfit.collection), picfitUrl, ws, config.picfit) diff --git a/modules/memo/src/main/MongoScheduler.scala b/modules/memo/src/main/MongoScheduler.scala new file mode 100644 index 0000000000000..6d22101105f28 --- /dev/null +++ b/modules/memo/src/main/MongoScheduler.scala @@ -0,0 +1,69 @@ +package lila.memo + +import lila.db.dsl.* +import lila.core.config.* +import reactivemongo.api.bson.* +import reactivemongo.api.bson.Macros.Annotations.Key +import lila.common.LilaScheduler +import akka.actor.Cancellable +import play.api.Mode + +final class MongoSchedulerApi(db: lila.db.Db)(using Executor, Scheduler, Mode): + + def make[Data: BSONHandler](key: String)(computation: Data => Funit): MongoScheduler[Data] = + MongoScheduler(db(CollName(s"mongo_scheduler_$key")), key)(computation) + +object MongoScheduler: + + case class Entry[Data](data: Data, runAfter: Instant) + object F: + val data = "data" + val runAfter = "runAfter" + +/* Schedules computations in a MongoDB collection + * persists the queue to survive restarts + * the granularity is 10.seconds so it's not suitable for high frequency tasks + */ +final class MongoScheduler[Data: BSONHandler]( + coll: Coll, + name: String +)(computation: Data => Funit)(using Executor)(using scheduler: Scheduler, mode: Mode): + + import MongoScheduler.* + + private type E = Entry[Data] + + private given BSONDocumentHandler[E] = Macros.handler + + def schedule(data: Data, delay: FiniteDuration): Funit = + coll.insert.one($doc(F.data -> data, F.runAfter -> nowInstant.plusMillis(delay.toMillis))).void + + private val startAfter = if mode.isProd then 28.seconds else 1.seconds + + scheduler.scheduleOnce(startAfter)(lookupAndRun()) + + private def lookupAndRun(): Unit = + popNext() + .map: + case Some(data) => + computation(data) + .withTimeout(30.seconds, s"MongoScheduler $name") + .addEffectAnyway: + lookupAndRunIn(1.second) + case None => lookupAndRunIn(10.seconds) + .addFailureEffect: _ => + lookupAndRunIn(10.seconds) + + private var cancel = none[Cancellable] + private def lookupAndRunIn(delay: FiniteDuration): Unit = + cancel.foreach(_.cancel()) + cancel = scheduler.scheduleOnce(delay)(lookupAndRun()).some + + private def popNext(): Fu[Option[Data]] = + coll + .findAndRemove( + selector = $doc(F.runAfter.$gte(nowInstant)), + sort = $sort.asc(F.runAfter).some + ) + .map: + _.result[Bdoc].flatMap(_.getAsOpt[Data](F.data)) diff --git a/modules/memo/src/main/ParallelMongoQueue.scala b/modules/memo/src/main/ParallelMongoQueue.scala index 51c4dd856de97..294a6f3929157 100644 --- a/modules/memo/src/main/ParallelMongoQueue.scala +++ b/modules/memo/src/main/ParallelMongoQueue.scala @@ -1,6 +1,5 @@ package lila.memo -import com.softwaremill.tagging.* import lila.memo.SettingStore import lila.db.dsl.* import akka.stream.scaladsl.* diff --git a/modules/mod/src/main/ModApi.scala b/modules/mod/src/main/ModApi.scala index dd57652def4cc..87fd5ec5129a2 100644 --- a/modules/mod/src/main/ModApi.scala +++ b/modules/mod/src/main/ModApi.scala @@ -123,7 +123,7 @@ final class ModApi( def reopenAccount(username: UserStr)(using Me): Funit = withUser(username): user => user.enabled.no.so: - userRepo.reopen(user.id) >> logApi.reopenAccount(user.id) + userApi.reopen(user.id) >> logApi.reopenAccount(user.id) def setKid(mod: ModId, username: UserStr): Funit = withUser(username): user => diff --git a/modules/oauth/src/main/AccessTokenApi.scala b/modules/oauth/src/main/AccessTokenApi.scala index 6c4a830ec97a0..db20451a27100 100644 --- a/modules/oauth/src/main/AccessTokenApi.scala +++ b/modules/oauth/src/main/AccessTokenApi.scala @@ -170,12 +170,12 @@ final class AccessTokenApi( for _ <- coll.delete.one($doc(F.id -> id, F.userId -> me)) yield onRevoke(id) - def revokeAllByUser(using me: MyId): Funit = + def revokeAllByUser(user: User): Funit = coll - .find($doc(F.userId -> me)) + .find($doc(F.userId -> user.id)) .cursor[AccessToken]() .documentSource() - .mapAsyncUnordered(4)(token => revokeById(token.id)) + .mapAsyncUnordered(4)(token => revokeById(token.id)(using Me(user))) .runWith(Sink.ignore) .void diff --git a/modules/user/src/main/BSONHandlers.scala b/modules/user/src/main/BSONHandlers.scala index 9c28c8a0763b5..43302f14f431b 100644 --- a/modules/user/src/main/BSONHandlers.scala +++ b/modules/user/src/main/BSONHandlers.scala @@ -28,7 +28,6 @@ object BSONFields: val sha512 = "sha512" val totpSecret = "totp" val changedCase = "changedCase" - val eraseAt = "eraseAt" val erasedAt = "erasedAt" val blind = "blind" diff --git a/modules/user/src/main/UserRepo.scala b/modules/user/src/main/UserRepo.scala index c5eb9eed0c44d..0ab82486cbfa0 100644 --- a/modules/user/src/main/UserRepo.scala +++ b/modules/user/src/main/UserRepo.scala @@ -332,8 +332,7 @@ final class UserRepo(c: Coll)(using Executor) extends lila.core.user.UserRepo(c) coll.update .one( $id(id) ++ $doc(F.email.$exists(false)), - $doc("$rename" -> $doc(F.prevEmail -> F.email)) ++ - $doc("$unset" -> $doc(F.eraseAt -> true)) + $doc("$rename" -> $doc(F.prevEmail -> F.email)) ) .void .recover(lila.db.recoverDuplicateKey(_ => ())) @@ -497,9 +496,6 @@ final class UserRepo(c: Coll)(using Executor) extends lila.core.user.UserRepo(c) _.sec ) - def setEraseAt(user: User) = - coll.updateField($id(user.id), F.eraseAt, nowInstant.plusDays(1)).void - private val defaultCount = lila.core.user.Count(0, 0, 0, 0, 0, 0, 0, 0, 0) private def newUser( From 43ed35ebdcd06a97a05b33c8b1b6273ca6858bfb Mon Sep 17 00:00:00 2001 From: Thibault Duplessis Date: Thu, 16 Jan 2025 10:51:05 +0100 Subject: [PATCH 06/24] rename MongoScheduler.nextLookup --- modules/memo/src/main/MongoScheduler.scala | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/modules/memo/src/main/MongoScheduler.scala b/modules/memo/src/main/MongoScheduler.scala index 6d22101105f28..4c4cbba3be457 100644 --- a/modules/memo/src/main/MongoScheduler.scala +++ b/modules/memo/src/main/MongoScheduler.scala @@ -54,10 +54,10 @@ final class MongoScheduler[Data: BSONHandler]( .addFailureEffect: _ => lookupAndRunIn(10.seconds) - private var cancel = none[Cancellable] + private var nextLookup = none[Cancellable] private def lookupAndRunIn(delay: FiniteDuration): Unit = - cancel.foreach(_.cancel()) - cancel = scheduler.scheduleOnce(delay)(lookupAndRun()).some + nextLookup.foreach(_.cancel()) + nextLookup = scheduler.scheduleOnce(delay)(lookupAndRun()).some private def popNext(): Fu[Option[Data]] = coll From c3974f170c7409705a9f2b4d24dafedddba0cc10 Mon Sep 17 00:00:00 2001 From: Thibault Duplessis Date: Thu, 16 Jan 2025 10:57:27 +0100 Subject: [PATCH 07/24] delete MongoScheduler there's a simpler way to schedule user erasure --- modules/memo/src/main/Env.scala | 2 - modules/memo/src/main/MongoScheduler.scala | 69 ---------------------- 2 files changed, 71 deletions(-) delete mode 100644 modules/memo/src/main/MongoScheduler.scala diff --git a/modules/memo/src/main/Env.scala b/modules/memo/src/main/Env.scala index dd6fd956bb3bc..61bbaf26d72a4 100644 --- a/modules/memo/src/main/Env.scala +++ b/modules/memo/src/main/Env.scala @@ -41,8 +41,6 @@ final class Env( val mongoRateLimitApi = wire[MongoRateLimitApi] - val mongoSchedulerApi = wire[MongoSchedulerApi] - val picfitUrl = lila.memo.PicfitUrl(config.picfit) val picfitApi = PicfitApi(db(config.picfit.collection), picfitUrl, ws, config.picfit) diff --git a/modules/memo/src/main/MongoScheduler.scala b/modules/memo/src/main/MongoScheduler.scala deleted file mode 100644 index 4c4cbba3be457..0000000000000 --- a/modules/memo/src/main/MongoScheduler.scala +++ /dev/null @@ -1,69 +0,0 @@ -package lila.memo - -import lila.db.dsl.* -import lila.core.config.* -import reactivemongo.api.bson.* -import reactivemongo.api.bson.Macros.Annotations.Key -import lila.common.LilaScheduler -import akka.actor.Cancellable -import play.api.Mode - -final class MongoSchedulerApi(db: lila.db.Db)(using Executor, Scheduler, Mode): - - def make[Data: BSONHandler](key: String)(computation: Data => Funit): MongoScheduler[Data] = - MongoScheduler(db(CollName(s"mongo_scheduler_$key")), key)(computation) - -object MongoScheduler: - - case class Entry[Data](data: Data, runAfter: Instant) - object F: - val data = "data" - val runAfter = "runAfter" - -/* Schedules computations in a MongoDB collection - * persists the queue to survive restarts - * the granularity is 10.seconds so it's not suitable for high frequency tasks - */ -final class MongoScheduler[Data: BSONHandler]( - coll: Coll, - name: String -)(computation: Data => Funit)(using Executor)(using scheduler: Scheduler, mode: Mode): - - import MongoScheduler.* - - private type E = Entry[Data] - - private given BSONDocumentHandler[E] = Macros.handler - - def schedule(data: Data, delay: FiniteDuration): Funit = - coll.insert.one($doc(F.data -> data, F.runAfter -> nowInstant.plusMillis(delay.toMillis))).void - - private val startAfter = if mode.isProd then 28.seconds else 1.seconds - - scheduler.scheduleOnce(startAfter)(lookupAndRun()) - - private def lookupAndRun(): Unit = - popNext() - .map: - case Some(data) => - computation(data) - .withTimeout(30.seconds, s"MongoScheduler $name") - .addEffectAnyway: - lookupAndRunIn(1.second) - case None => lookupAndRunIn(10.seconds) - .addFailureEffect: _ => - lookupAndRunIn(10.seconds) - - private var nextLookup = none[Cancellable] - private def lookupAndRunIn(delay: FiniteDuration): Unit = - nextLookup.foreach(_.cancel()) - nextLookup = scheduler.scheduleOnce(delay)(lookupAndRun()).some - - private def popNext(): Fu[Option[Data]] = - coll - .findAndRemove( - selector = $doc(F.runAfter.$gte(nowInstant)), - sort = $sort.asc(F.runAfter).some - ) - .map: - _.result[Bdoc].flatMap(_.getAsOpt[Data](F.data)) From 45468c84bb25cfce767c6a46f62b3349330fae03 Mon Sep 17 00:00:00 2001 From: Thibault Duplessis Date: Thu, 16 Jan 2025 10:57:35 +0100 Subject: [PATCH 08/24] account termination WIP --- app/controllers/Mod.scala | 9 ++-- modules/api/src/main/AccountClosure.scala | 44 ++++++++++---------- modules/api/src/main/Env.scala | 3 +- modules/common/src/main/LilaScheduler.scala | 21 +++++++++- modules/mailer/src/main/AutomaticEmail.scala | 2 +- modules/mod/src/main/Env.scala | 3 +- modules/mod/src/main/ModApi.scala | 2 +- modules/user/src/main/BSONHandlers.scala | 1 + modules/user/src/main/UserRepo.scala | 9 +++- 9 files changed, 62 insertions(+), 32 deletions(-) diff --git a/app/controllers/Mod.scala b/app/controllers/Mod.scala index 6ec860ac445d4..7a98c088de7a6 100644 --- a/app/controllers/Mod.scala +++ b/app/controllers/Mod.scala @@ -380,10 +380,11 @@ final class Mod( env.user.noteApi.search(q.trim, page, withDox = true).map(views.mod.search.notes(q, _)) } - def gdprErase(username: UserStr) = Secure(_.GdprErase) { _ ?=> me ?=> - env.api.accountClosure - .scheduleErasure(username.id) - .inject(Redirect(routes.User.show(username)).flashSuccess("Erasure scheduled")) + def gdprErase(username: UserStr) = Secure(_.GdprErase) { _ ?=> _ ?=> + Found(env.user.repo.byId(username)): user => + env.api.accountClosure + .scheduleErasure(user) + .inject(Redirect(routes.User.show(username)).flashSuccess("Erasure scheduled")) } protected[controllers] def searchTerm(query: String)(using Context, Me) = diff --git a/modules/api/src/main/AccountClosure.scala b/modules/api/src/main/AccountClosure.scala index 46ed5a22e9f95..65b29dd47f06b 100644 --- a/modules/api/src/main/AccountClosure.scala +++ b/modules/api/src/main/AccountClosure.scala @@ -54,9 +54,8 @@ final class AccountClosure( ublogApi: lila.ublog.UblogApi, activityWrite: lila.activity.ActivityWriteApi, email: lila.mailer.AutomaticEmail, - tokenApi: lila.oauth.AccessTokenApi, - mongoScheduler: lila.memo.MongoSchedulerApi -)(using Executor): + tokenApi: lila.oauth.AccessTokenApi +)(using Executor, Scheduler): Bus.subscribeFuns( "garbageCollect" -> { case lila.core.security.GarbageCollect(userId) => @@ -65,13 +64,12 @@ final class AccountClosure( "rageSitClose" -> { case lila.core.playban.RageSitClose(userId) => lichessClose(userId) } ) - private val eraserScheduler = - import lila.db.dsl.userIdHandler - mongoScheduler.make[UserId]("eraser")(doEraseNow) - - def scheduleErasure(u: User)(using Me): Funit = - for _ <- close(u) - yield eraserScheduler.schedule(u.id, 24.hours) + lila.common.LilaScheduler.variableDelay( + "accountTermination.erase", + prev => _.Delay(if prev.isDefined then 1.second else 10.seconds), + timeout = _.AtMost(1.minute), + initialDelay = _.Delay(111.seconds) + )(findAndErase) def close(u: User)(using me: Me): Funit = for playbanned <- playbanApi.hasCurrentPlayban(u.id) @@ -107,15 +105,19 @@ final class AccountClosure( private def lichessClose(userId: UserId) = userRepo.lichessAnd(userId).flatMapz { (lichess, user) => close(user)(using Me(lichess)) } - def eraseClosed(username: UserId): Fu[Either[String, String]] = - userRepo.byId(username).map { - case None => Left("No such user.") - case Some(user) => - userRepo.setEraseAt(user) - email.gdprErase(user) - Right(s"Erasing all data about $username in 24h") - } + def scheduleErasure(user: User)(using Me): Funit = for + _ <- user.enabled.yes.so(close(user)) + _ <- email.gdprErase(user) + _ <- userRepo.scheduleErasure(user.id, true) + yield () + + private def findAndErase: Fu[Option[User]] = + userRepo.findNextToErase.flatMapz: user => + doEraseNow(user).inject(user.some) - private def doEraseNow(userId: UserId): Funit = - fuccess: - println(s"Time to wipe $userId") + private def doEraseNow(user: User): Funit = + if user.enabled.yes + then userRepo.scheduleErasure(user.id, false) + else + fuccess: + println(s"Time to wipe $user") diff --git a/modules/api/src/main/Env.scala b/modules/api/src/main/Env.scala index 0f8fc36dde5bb..452545e4b6a90 100644 --- a/modules/api/src/main/Env.scala +++ b/modules/api/src/main/Env.scala @@ -59,8 +59,7 @@ final class Env( realPlayerApi: lila.web.RealPlayerApi, bookmarkExists: lila.core.bookmark.BookmarkExists, manifest: lila.web.AssetManifest, - tokenApi: lila.oauth.AccessTokenApi, - mongoScheduler: lila.memo.MongoSchedulerApi + tokenApi: lila.oauth.AccessTokenApi )(using val mode: Mode, scheduler: Scheduler)(using Executor, ActorSystem, diff --git a/modules/common/src/main/LilaScheduler.scala b/modules/common/src/main/LilaScheduler.scala index a2647fbaae892..de98e7707e8fa 100644 --- a/modules/common/src/main/LilaScheduler.scala +++ b/modules/common/src/main/LilaScheduler.scala @@ -13,7 +13,7 @@ object LilaScheduler: every: config.type => config.Every, timeout: config.type => config.AtMost, initialDelay: config.type => config.Delay - )(f: => Funit)(using ec: Executor, scheduler: Scheduler): Unit = + )(f: => Funit)(using Executor)(using scheduler: Scheduler): Unit = val run = () => f @@ -26,3 +26,22 @@ object LilaScheduler: scheduler .scheduleOnce(initialDelay(config).value): runAndScheduleNext() + + def variableDelay[A]( + name: String, + delay: Option[A] => config.type => config.Delay, + timeout: config.type => config.AtMost, + initialDelay: config.type => config.Delay + )(f: => Fu[A])(using Executor)(using scheduler: Scheduler): Unit = + + val run = () => f + + def runAndScheduleNext(): Unit = + run() + .withTimeout(timeout(config).value, s"LilaScheduler $name") + .addEffects: prev => + scheduler.scheduleOnce(delay(prev.toOption)(config).value) { runAndScheduleNext() } + + scheduler + .scheduleOnce(initialDelay(config).value): + runAndScheduleNext() diff --git a/modules/mailer/src/main/AutomaticEmail.scala b/modules/mailer/src/main/AutomaticEmail.scala index 75e4b35b57234..5a23ddc214154 100644 --- a/modules/mailer/src/main/AutomaticEmail.scala +++ b/modules/mailer/src/main/AutomaticEmail.scala @@ -117,7 +117,7 @@ $regards val body = s"""Hello, -Following your request, the Lichess account "${user.username}" will be fully erased in 24h from now. +Following your request, the Lichess account "${user.username}" will be fully erased in 7 days from now. $regards """ diff --git a/modules/mod/src/main/Env.scala b/modules/mod/src/main/Env.scala index b2eecfad3eb49..cc5e86fdf345a 100644 --- a/modules/mod/src/main/Env.scala +++ b/modules/mod/src/main/Env.scala @@ -23,8 +23,8 @@ final class Env( gameApi: lila.core.game.GameApi, analysisRepo: lila.analyse.AnalysisRepo, userRepo: lila.user.UserRepo, - perfsRepo: lila.user.UserPerfsRepo, userApi: lila.user.UserApi, + perfsRepo: lila.user.UserPerfsRepo, chatApi: lila.chat.ChatApi, notifyApi: lila.core.notify.NotifyApi, historyApi: lila.core.history.HistoryApi, @@ -35,6 +35,7 @@ final class Env( ircApi: lila.core.irc.IrcApi, msgApi: lila.core.msg.MsgApi )(using Executor, Scheduler, lila.core.i18n.Translator, akka.stream.Materializer): + private lazy val logRepo = ModlogRepo(db(CollName("modlog"))) private lazy val assessmentRepo = AssessmentRepo(db(CollName("player_assessment"))) private lazy val historyRepo = HistoryRepo(db(CollName("mod_gaming_history"))) diff --git a/modules/mod/src/main/ModApi.scala b/modules/mod/src/main/ModApi.scala index 87fd5ec5129a2..dd57652def4cc 100644 --- a/modules/mod/src/main/ModApi.scala +++ b/modules/mod/src/main/ModApi.scala @@ -123,7 +123,7 @@ final class ModApi( def reopenAccount(username: UserStr)(using Me): Funit = withUser(username): user => user.enabled.no.so: - userApi.reopen(user.id) >> logApi.reopenAccount(user.id) + userRepo.reopen(user.id) >> logApi.reopenAccount(user.id) def setKid(mod: ModId, username: UserStr): Funit = withUser(username): user => diff --git a/modules/user/src/main/BSONHandlers.scala b/modules/user/src/main/BSONHandlers.scala index 43302f14f431b..9c28c8a0763b5 100644 --- a/modules/user/src/main/BSONHandlers.scala +++ b/modules/user/src/main/BSONHandlers.scala @@ -28,6 +28,7 @@ object BSONFields: val sha512 = "sha512" val totpSecret = "totp" val changedCase = "changedCase" + val eraseAt = "eraseAt" val erasedAt = "erasedAt" val blind = "blind" diff --git a/modules/user/src/main/UserRepo.scala b/modules/user/src/main/UserRepo.scala index 0ab82486cbfa0..ba3d160ef7ca3 100644 --- a/modules/user/src/main/UserRepo.scala +++ b/modules/user/src/main/UserRepo.scala @@ -332,7 +332,8 @@ final class UserRepo(c: Coll)(using Executor) extends lila.core.user.UserRepo(c) coll.update .one( $id(id) ++ $doc(F.email.$exists(false)), - $doc("$rename" -> $doc(F.prevEmail -> F.email)) + $doc("$rename" -> $doc(F.prevEmail -> F.email)) ++ + $doc("$unset" -> $doc(F.eraseAt -> true)) ) .void .recover(lila.db.recoverDuplicateKey(_ => ())) @@ -348,6 +349,12 @@ final class UserRepo(c: Coll)(using Executor) extends lila.core.user.UserRepo(c) ) .void + def findNextToErase: Fu[Option[User]] = + coll.find($doc(F.eraseAt.$lt(nowInstant))).sort($doc(F.eraseAt -> 1)).one[User] + + def scheduleErasure(userId: UserId, erase: Boolean): Funit = + coll.updateOrUnsetField($id(userId), F.eraseAt, erase.option(nowInstant.plusDays(7))).void + def getPasswordHash(id: UserId): Fu[Option[String]] = coll.byId[AuthData](id, AuthData.projection).map2(_.bpass.bytes.sha512.hex) From 89aa1112cae2506769816bea0e01ab0c1d6b3ea3 Mon Sep 17 00:00:00 2001 From: Thibault Duplessis Date: Thu, 16 Jan 2025 12:12:54 +0100 Subject: [PATCH 09/24] account termination WIP, schedule deletion --- app/controllers/Account.scala | 8 +-- app/controllers/Clas.scala | 2 +- app/controllers/Mod.scala | 13 ++-- app/controllers/User.scala | 8 +-- bin/mongodb/indexes.js | 2 +- ...Closure.scala => AccountTermination.scala} | 62 +++++++++++-------- modules/api/src/main/Env.scala | 2 +- modules/api/src/main/PersonalDataExport.scala | 6 +- modules/core/src/main/round.scala | 1 + modules/mailer/src/main/AutomaticEmail.scala | 20 ++++++ modules/mod/src/main/ui/ModUserUi.scala | 54 ++++++---------- modules/round/src/main/Env.scala | 18 +++--- modules/user/src/main/BSONHandlers.scala | 6 +- modules/user/src/main/User.scala | 6 +- modules/user/src/main/UserRepo.scala | 35 ++++++----- 15 files changed, 129 insertions(+), 114 deletions(-) rename modules/api/src/main/{AccountClosure.scala => AccountTermination.scala} (82%) diff --git a/app/controllers/Account.scala b/app/controllers/Account.scala index c0a4aa5002b8a..73fd6b4584b46 100644 --- a/app/controllers/Account.scala +++ b/app/controllers/Account.scala @@ -251,8 +251,8 @@ final class Account( auth.HasherRateLimit: env.security.forms.closeAccount.flatMap: form => FormFuResult(form)(err => renderPage(pages.close(err, managed = false))): _ => - env.api.accountClosure - .close(me.value) + env.api.accountTermination + .disable(me.value) .inject: Redirect(routes.User.show(me.username)).withCookies(env.security.lilaCookie.newSession) } @@ -270,8 +270,8 @@ final class Account( auth.HasherRateLimit: env.security.forms.deleteAccount.flatMap: form => FormFuResult(form)(err => renderPage(pages.delete(err, managed = false))): _ => - env.api.accountClosure - .close(me.value) + env.api.accountTermination + .disable(me.value) .inject: Redirect(routes.User.show(me.username)).withCookies(env.security.lilaCookie.newSession) } diff --git a/app/controllers/Clas.scala b/app/controllers/Clas.scala index 2be227ae37109..fd554a7cfa1f6 100644 --- a/app/controllers/Clas.scala +++ b/app/controllers/Clas.scala @@ -464,7 +464,7 @@ final class Clas(env: Env, authC: Auth) extends LilaController(env): WithStudent(clas, username): s => if s.student.managed then (env.clas.api.student.closeAccount(s) >> - env.api.accountClosure.close(s.user)).inject(redirectTo(clas).flashSuccess) + env.api.accountTermination.disable(s.user)).inject(redirectTo(clas).flashSuccess) else if s.student.isArchived then env.clas.api.student.closeAccount(s) >> redirectTo(clas).flashSuccess diff --git a/app/controllers/Mod.scala b/app/controllers/Mod.scala index 7a98c088de7a6..b00a0de8d5f8b 100644 --- a/app/controllers/Mod.scala +++ b/app/controllers/Mod.scala @@ -30,7 +30,7 @@ final class Mod( withSuspect(username): sus => for _ <- api.setAlt(sus, v) - _ <- (v && sus.user.enabled.yes).so(env.api.accountClosure.close(sus.user)) + _ <- (v && sus.user.enabled.yes).so(env.api.accountTermination.disable(sus.user)) _ <- (!v && sus.user.enabled.no).so(api.reopenAccount(sus.user.id)) yield sus.some }(reportC.onModAction) @@ -40,7 +40,7 @@ final class Mod( Source(ctx.body.body.split(' ').toList.flatMap(UserStr.read)) .mapAsync(1): username => withSuspect(username): sus => - api.setAlt(sus, true) >> (sus.user.enabled.yes.so(env.api.accountClosure.close(sus.user))) + api.setAlt(sus, true) >> (sus.user.enabled.yes.so(env.api.accountTermination.disable(sus.user))) .runWith(Sink.ignore) .void .inject(NoContent) @@ -114,7 +114,7 @@ final class Mod( def closeAccount(username: UserStr) = OAuthMod(_.CloseAccount) { _ ?=> me ?=> meOrFetch(username).flatMapz: user => - env.api.accountClosure.close(user).map(some) + env.api.accountTermination.disable(user).map(some) }(actionResult(username)) def reopenAccount(username: UserStr) = OAuthMod(_.CloseAccount) { _ ?=> me ?=> @@ -382,9 +382,8 @@ final class Mod( def gdprErase(username: UserStr) = Secure(_.GdprErase) { _ ?=> _ ?=> Found(env.user.repo.byId(username)): user => - env.api.accountClosure - .scheduleErasure(user) - .inject(Redirect(routes.User.show(username)).flashSuccess("Erasure scheduled")) + for _ <- env.api.accountTermination.scheduleErase(user) + yield Redirect(routes.User.show(username)).flashSuccess("Erasure scheduled") } protected[controllers] def searchTerm(query: String)(using Context, Me) = @@ -409,7 +408,7 @@ final class Mod( def printBan(v: Boolean, fh: String) = Secure(_.PrintBan) { _ ?=> me ?=> val hash = FingerHash(fh) - env.security.printBan.toggle(hash, v).inject(Redirect(routes.Mod.print(fh))) + for _ <- env.security.printBan.toggle(hash, v) yield Redirect(routes.Mod.print(fh)) } def singleIp(ip: String) = SecureBody(_.ViewPrintNoIP) { ctx ?=> me ?=> diff --git a/app/controllers/User.scala b/app/controllers/User.scala index b273fff03adc9..fba31668374c8 100644 --- a/app/controllers/User.scala +++ b/app/controllers/User.scala @@ -396,8 +396,8 @@ final class User( .map(ui.showRageSitAndPlaybans) ) - val actions = env.user.repo.isErased(user).map { erased => - ui.actions(user, emails, erased, env.mod.presets.getPmPresets) + val actions = env.user.repo.isDeleted(user).map { deleted => + ui.actions(user, emails, deleted, env.mod.presets.getPmPresets) } val userLoginsFu = env.security.userLogins(user, nbOthers) @@ -458,12 +458,12 @@ final class User( protected[controllers] def renderModZoneActions(username: UserStr)(using ctx: Context) = env.user.api.withPerfsAndEmails(username).orFail(s"No such user $username").flatMap { case WithPerfsAndEmails(user, emails) => - env.user.repo.isErased(user).flatMap { erased => + env.user.repo.isDeleted(user).flatMap { deleted => Ok.snip: views.mod.user.actions( user, emails, - erased, + deleted, env.mod.presets.getPmPresetsOpt ) } diff --git a/bin/mongodb/indexes.js b/bin/mongodb/indexes.js index a674cfa31a0aa..6c000ed954003 100644 --- a/bin/mongodb/indexes.js +++ b/bin/mongodb/indexes.js @@ -105,7 +105,7 @@ db.user4.createIndex({ title: 1 }, { sparse: true }); db.user4.createIndex({ email: 1 }, { unique: true, sparse: 1 }); db.user4.createIndex({ roles: 1 }, { background: 1, partialFilterExpression: { roles: { $exists: 1 } } }); db.user4.createIndex({ prevEmail: 1 }, { sparse: 1, background: 1 }); -db.user4.createIndex({ eraseAt: 1 }, { partialFilterExpression: { eraseAt: { $exists: true } } }); +db.user4.createIndex({ 'delete.scheduled': 1 }, { partialFilterExpression: { 'delete.scheduled': { $exists: 1 }, 'delete.done': false } }) db.f_topic.createIndex({ categId: 1, troll: 1 }); db.f_topic.createIndex({ categId: 1, updatedAt: -1, troll: 1 }); db.f_topic.createIndex({ categId: 1, slug: 1 }); diff --git a/modules/api/src/main/AccountClosure.scala b/modules/api/src/main/AccountTermination.scala similarity index 82% rename from modules/api/src/main/AccountClosure.scala rename to modules/api/src/main/AccountTermination.scala index 65b29dd47f06b..7c98bb022fe9a 100644 --- a/modules/api/src/main/AccountClosure.scala +++ b/modules/api/src/main/AccountTermination.scala @@ -2,6 +2,7 @@ package lila.api import lila.common.Bus import lila.core.perm.Granter +import lila.user.UserDelete enum Termination: case disable, delete, erase @@ -33,7 +34,7 @@ enum Termination: [^1] the email address of a closed account can be re-used to make a new account, up to 4 times per month. [^2] classes and teams have a life of their own. Close them manually if you want to, before deleting your account. */ -final class AccountClosure( +final class AccountTermination( userRepo: lila.user.UserRepo, playbanApi: lila.playban.PlaybanApi, relationApi: lila.relation.RelationApi, @@ -54,30 +55,25 @@ final class AccountClosure( ublogApi: lila.ublog.UblogApi, activityWrite: lila.activity.ActivityWriteApi, email: lila.mailer.AutomaticEmail, - tokenApi: lila.oauth.AccessTokenApi + tokenApi: lila.oauth.AccessTokenApi, + roundApi: lila.core.round.RoundApi )(using Executor, Scheduler): Bus.subscribeFuns( "garbageCollect" -> { case lila.core.security.GarbageCollect(userId) => - (modApi.garbageCollect(userId) >> lichessClose(userId)) + (modApi.garbageCollect(userId) >> lichessDisable(userId)) }, - "rageSitClose" -> { case lila.core.playban.RageSitClose(userId) => lichessClose(userId) } + "rageSitClose" -> { case lila.core.playban.RageSitClose(userId) => lichessDisable(userId) } ) - lila.common.LilaScheduler.variableDelay( - "accountTermination.erase", - prev => _.Delay(if prev.isDefined then 1.second else 10.seconds), - timeout = _.AtMost(1.minute), - initialDelay = _.Delay(111.seconds) - )(findAndErase) - - def close(u: User)(using me: Me): Funit = for + def disable(u: User)(using me: Me): Funit = for playbanned <- playbanApi.hasCurrentPlayban(u.id) selfClose = me.is(u) teacherClose = !selfClose && !Granter(_.CloseAccount) && Granter(_.Teacher) modClose = !selfClose && Granter(_.CloseAccount) badApple = u.lameOrTroll || u.marks.alt || modClose _ <- userRepo.disable(u, keepEmail = badApple || playbanned) + _ <- roundApi.resignAllGamesOf(u.id) _ <- relationApi.unfollowAll(u.id) _ <- rankingApi.remove(u.id) teamIds <- teamApi.quitAllOnAccountClosure(u.id) @@ -102,22 +98,34 @@ final class AccountClosure( relationApi.fetchFollowing(u.id).flatMap(activityWrite.unfollowAll(u, _)) yield Bus.publish(lila.core.security.CloseAccount(u.id), "accountClose") - private def lichessClose(userId: UserId) = - userRepo.lichessAnd(userId).flatMapz { (lichess, user) => close(user)(using Me(lichess)) } + def scheduleDelete(u: User): Funit = for + _ <- disable(u)(using Me(u)) + _ <- email.delete(u) + _ <- userRepo.scheduleDelete(u.id, UserDelete(nowInstant, erase = false).some) + yield () - def scheduleErasure(user: User)(using Me): Funit = for - _ <- user.enabled.yes.so(close(user)) - _ <- email.gdprErase(user) - _ <- userRepo.scheduleErasure(user.id, true) + def scheduleErase(u: User): Funit = for + _ <- disable(u)(using Me(u)) + _ <- email.gdprErase(u) + _ <- userRepo.scheduleDelete(u.id, UserDelete(nowInstant, erase = true).some) yield () - private def findAndErase: Fu[Option[User]] = - userRepo.findNextToErase.flatMapz: user => - doEraseNow(user).inject(user.some) + private def lichessDisable(userId: UserId) = + userRepo.lichessAnd(userId).flatMapz { (lichess, user) => disable(user)(using Me(lichess)) } + + lila.common.LilaScheduler.variableDelay( + "accountTermination.delete", + prev => _.Delay(if prev.isDefined then 1.second else 10.seconds), + timeout = _.AtMost(1.minute), + initialDelay = _.Delay(111.seconds) + ): + userRepo + .findNextToDelete(7.days) + .flatMapz: (user, del) => + if user.enabled.yes + then userRepo.scheduleDelete(user.id, none).inject(none) + else doDeleteNow(user, del).inject(user.some) - private def doEraseNow(user: User): Funit = - if user.enabled.yes - then userRepo.scheduleErasure(user.id, false) - else - fuccess: - println(s"Time to wipe $user") + private def doDeleteNow(user: User, del: UserDelete): Funit = + fuccess: + println(s"Time to wipe $user") diff --git a/modules/api/src/main/Env.scala b/modules/api/src/main/Env.scala index 452545e4b6a90..642bfe04bf5b6 100644 --- a/modules/api/src/main/Env.scala +++ b/modules/api/src/main/Env.scala @@ -88,7 +88,7 @@ final class Env( lazy val personalDataExport = wire[PersonalDataExport] - lazy val accountClosure = wire[AccountClosure] + lazy val accountTermination = wire[AccountTermination] lazy val anySearch = wire[AnySearch] diff --git a/modules/api/src/main/PersonalDataExport.scala b/modules/api/src/main/PersonalDataExport.scala index 0370915ecd9f8..bf862a1f7d5bf 100644 --- a/modules/api/src/main/PersonalDataExport.scala +++ b/modules/api/src/main/PersonalDataExport.scala @@ -14,7 +14,7 @@ final class PersonalDataExport( msgEnv: lila.msg.Env, forumEnv: lila.forum.Env, gameEnv: lila.game.Env, - roundEnv: lila.round.Env, + noteApi: lila.round.NoteApi, chatEnv: lila.chat.Env, relationEnv: lila.relation.Env, userRepo: lila.user.UserRepo, @@ -155,7 +155,7 @@ final class PersonalDataExport( Project($id(true)), PipelineOperator( $lookup.pipelineFull( - from = roundEnv.noteApi.collName, + from = noteApi.collName, as = "note", let = $doc("id" -> $doc("$concat" -> $arr("$_id", user.id))), pipe = List($doc("$match" -> $expr($doc("$eq" -> $arr("$_id", "$$id"))))) @@ -166,7 +166,7 @@ final class PersonalDataExport( Project($doc("_id" -> false, "t" -> true)) ) .documentSource() - .map { ~_.string("t") } + .map(~_.string("t")) .throttle(heavyPerSecond, 1.second) ) diff --git a/modules/core/src/main/round.scala b/modules/core/src/main/round.scala index ecfba7a7d4eae..d657e0050aab3 100644 --- a/modules/core/src/main/round.scala +++ b/modules/core/src/main/round.scala @@ -86,3 +86,4 @@ trait RoundApi: def tell(gameId: GameId, msg: Matchable): Unit def ask[A](gameId: GameId)(makeMsg: Promise[A] => Matchable): Fu[A] def getGames(gameIds: List[GameId]): Fu[List[(GameId, Option[Game])]] + def resignAllGamesOf(userId: UserId): Funit diff --git a/modules/mailer/src/main/AutomaticEmail.scala b/modules/mailer/src/main/AutomaticEmail.scala index 5a23ddc214154..180f241cbf4a0 100644 --- a/modules/mailer/src/main/AutomaticEmail.scala +++ b/modules/mailer/src/main/AutomaticEmail.scala @@ -113,6 +113,26 @@ $regards """ ) + def delete(user: User): Funit = + val body = + s"""Hello, + +Following your request, the Lichess account "${user.username}" will be deleted in 7 days from now. + +$regards +""" + userApi.emailOrPrevious(user.id).flatMapz { email => + given Lang = userLang(user) + mailer.send( + Mailer.Message( + to = email, + subject = "lichess.org account deletion", + text = Mailer.txt.addServiceNote(body), + htmlBody = standardEmail(body).some + ) + ) + } + def gdprErase(user: User): Funit = val body = s"""Hello, diff --git a/modules/mod/src/main/ui/ModUserUi.scala b/modules/mod/src/main/ui/ModUserUi.scala index 40706c4b1b008..49ef2d3524f57 100644 --- a/modules/mod/src/main/ui/ModUserUi.scala +++ b/modules/mod/src/main/ui/ModUserUi.scala @@ -42,7 +42,7 @@ final class ModUserUi(helpers: Helpers, modUi: ModUi): def actions( u: User, emails: lila.core.user.Emails, - erased: lila.user.Erased, + deleted: Boolean, pmPresets: ModPresets )(using Context): Frag = mzSection("actions")( @@ -57,9 +57,8 @@ final class ModUserUi(helpers: Helpers, modUi: ModUi): action := routes.Mod.refreshUserAssess(u.username), title := "Collect data and ask irwin and Kaladin", cls := "xhr" - )( + ): submitButton(cls := "btn-rack__btn")("Evaluate") - ) }, Granter.opt(_.GamesModView).option { a( @@ -82,27 +81,24 @@ final class ModUserUi(helpers: Helpers, modUi: ModUi): action := routes.Mod.alt(u.username, !u.marks.alt), title := "Preemptively close unauthorized alt.", cls := "xhr" - )( + ): submitButton(cls := List("btn-rack__btn" -> true, "active" -> u.marks.alt))("Alt") - ) }, Granter.opt(_.MarkEngine).option { postForm( action := routes.Mod.engine(u.username, !u.marks.engine), title := "This user is clearly cheating.", cls := "xhr" - )( + ): submitButton(cls := List("btn-rack__btn" -> true, "active" -> u.marks.engine))("Engine") - ) }, Granter.opt(_.MarkBooster).option { postForm( action := routes.Mod.booster(u.username, !u.marks.boost), title := "Marks the user as a booster or sandbagger.", cls := "xhr" - )( + ): submitButton(cls := List("btn-rack__btn" -> true, "active" -> u.marks.boost))("Booster") - ) }, Granter .opt(_.Shadowban) @@ -121,9 +117,9 @@ final class ModUserUi(helpers: Helpers, modUi: ModUi): action := routes.Mod.deletePmsAndChats(u.username), title := "Delete all PMs and public chat messages", cls := "xhr" - )( + ): submitButton(cls := "btn-rack__btn yes-no-confirm")("Clear PMs & chats") - ), + , postForm( action := routes.Mod.isolate(u.username, !u.marks.isolate), title := "Isolate user by preventing all PMs, follows and challenges", @@ -131,9 +127,7 @@ final class ModUserUi(helpers: Helpers, modUi: ModUi): )( submitButton( cls := List("btn-rack__btn yes-no-confirm" -> true, "active" -> u.marks.isolate) - )( - "Isolate" - ) + )("Isolate") ) ) ) @@ -143,45 +137,40 @@ final class ModUserUi(helpers: Helpers, modUi: ModUi): action := routes.Mod.kid(u.username), title := "Activate kid mode if not already the case", cls := "xhr" - )( + ): submitButton(cls := "btn-rack__btn yes-no-confirm", cls := u.kid.option("active"))("Kid") - ) }, Granter.opt(_.RemoveRanking).option { postForm( action := routes.Mod.rankban(u.username, !u.marks.rankban), title := "Include/exclude this user from the rankings.", cls := "xhr" - )( + ): submitButton(cls := List("btn-rack__btn" -> true, "active" -> u.marks.rankban))("Rankban") - ) }, Granter.opt(_.ArenaBan).option { postForm( action := routes.Mod.arenaBan(u.username, !u.marks.arenaBan), title := "Enable/disable this user from joining all arenas.", cls := "xhr" - )( + ): submitButton(cls := List("btn-rack__btn" -> true, "active" -> u.marks.arenaBan))("Arena ban") - ) }, Granter.opt(_.PrizeBan).option { postForm( action := routes.Mod.prizeban(u.username, !u.marks.prizeban), title := "Enable/disable this user from joining prized tournaments.", cls := "xhr" - )( + ): submitButton(cls := List("btn-rack__btn" -> true, "active" -> u.marks.prizeban))("Prizeban") - ) }, Granter.opt(_.ReportBan).option { postForm( action := routes.Mod.reportban(u.username, !u.marks.reportban), title := "Enable/disable the report feature for this user.", cls := "xhr" - )( + ): submitButton(cls := List("btn-rack__btn" -> true, "active" -> u.marks.reportban))("Reportban") - ) } ), Granter @@ -196,7 +185,7 @@ final class ModUserUi(helpers: Helpers, modUi: ModUi): )( submitButton(cls := "btn-rack__btn")("Close") ) - else if erased.value then "Erased" + else if deleted then "Deleted" else frag( postForm( @@ -214,14 +203,12 @@ final class ModUserUi(helpers: Helpers, modUi: ModUi): action := routes.Mod.disableTwoFactor(u.username), title := "Disables two-factor authentication for this account.", cls := "xhr" - )( + ): submitButton(cls := "btn-rack__btn yes-no-confirm")("Disable 2FA") - ) }, (Granter.opt(_.Impersonate) || (Granter.opt(_.Admin) && u.id == UserId.lichess)).option { - postForm(action := routes.Mod.impersonate(u.username.value))( + postForm(action := routes.Mod.impersonate(u.username.value)): submitButton(cls := "btn-rack__btn")("Impersonate") - ) } ), Granter @@ -230,9 +217,8 @@ final class ModUserUi(helpers: Helpers, modUi: ModUi): postForm(action := routes.Mod.warn(u.username, ""), cls := "pm-preset")( st.select( st.option(value := "")("Send PM"), - pmPresets.value.map { preset => + pmPresets.value.map: preset => st.option(st.value := preset.name, title := preset.text)(preset.name) - } ) ) ), @@ -259,9 +245,8 @@ final class ModUserUi(helpers: Helpers, modUi: ModUi): ), submitButton(cls := "button", dataIcon := Icon.Checkmark) ), - emails.previous.map { email => + emails.previous.map: email => s"Previously $email" - } ) ) ) @@ -323,7 +308,7 @@ final class ModUserUi(helpers: Helpers, modUi: ModUi): "Moderation history", history.isEmpty.option(": nothing to show") ), - history.nonEmpty.so( + history.nonEmpty.so: frag( ul: history.map: e => @@ -343,7 +328,6 @@ final class ModUserUi(helpers: Helpers, modUi: ModUi): , br ) - ) ) def reportLog(u: User, reports: List[Report])(using Translate): Frag = diff --git a/modules/round/src/main/Env.scala b/modules/round/src/main/Env.scala index 01fcb1271bde8..d1eb76914521f 100644 --- a/modules/round/src/main/Env.scala +++ b/modules/round/src/main/Env.scala @@ -91,12 +91,6 @@ final class Env( lazy val roundSocket: RoundSocket = wire[RoundSocket] - private def resignAllGamesOf(userId: UserId) = - gameRepo - .allPlaying(userId) - .foreach: - _.foreach { pov => roundApi.tell(pov.gameId, Resign(pov.playerId)) } - lazy val ratingFactorsSetting: SettingStore[RatingFactor.ByKey] = import play.api.data.Form import play.api.data.Forms.{ single, text } @@ -113,9 +107,6 @@ final class Env( private val getFactors: () => Map[PerfKey, RatingFactor] = ratingFactorsSetting.get Bus.subscribeFuns( - "accountClose" -> { case lila.core.security.CloseAccount(userId) => - resignAllGamesOf(userId) - }, "gameStartId" -> { case lila.core.game.GameStart(gameId) => onStart.exec(gameId) }, @@ -123,7 +114,7 @@ final class Env( selfReport(userId, ip, fullId, name) }, "adjustCheater" -> { case lila.core.mod.MarkCheater(userId, true) => - resignAllGamesOf(userId) + roundApi.resignAllGamesOf(userId) } ) @@ -209,9 +200,16 @@ final class Env( export proxyRepo.{ game, updateIfPresent, flushIfPresent, upgradeIfPresent } val roundJson: lila.core.round.RoundJson = new: export mobile.offline as mobileOffline + val roundApi: lila.core.round.RoundApi = new: export roundSocket.rounds.{ tell, ask } export roundSocket.getGames + def resignAllGamesOf(userId: UserId) = + gameRepo + .allPlaying(userId) + .map: + _.foreach { pov => roundApi.tell(pov.gameId, Resign(pov.playerId)) } + val onTvGame: lila.game.core.OnTvGame = recentTvGames.put MoveLatMonitor.start(scheduler) diff --git a/modules/user/src/main/BSONHandlers.scala b/modules/user/src/main/BSONHandlers.scala index 9c28c8a0763b5..7319daadf33e5 100644 --- a/modules/user/src/main/BSONHandlers.scala +++ b/modules/user/src/main/BSONHandlers.scala @@ -28,8 +28,7 @@ object BSONFields: val sha512 = "sha512" val totpSecret = "totp" val changedCase = "changedCase" - val eraseAt = "eraseAt" - val erasedAt = "erasedAt" + val delete = "delete" val blind = "blind" def withFields[A](f: BSONFields.type => A): A = f(BSONFields) @@ -74,7 +73,8 @@ object BSONHandlers: v => BSONBinary(v.bytes, Subtype.GenericBinarySubtype) ) - given BSONDocumentHandler[AuthData] = Macros.handler[AuthData] + given BSONDocumentHandler[AuthData] = Macros.handler[AuthData] + given BSONDocumentHandler[UserDelete] = Macros.handler[UserDelete] given userHandler: BSONDocumentHandler[User] = new BSON[User]: diff --git a/modules/user/src/main/User.scala b/modules/user/src/main/User.scala index ff86e6c98ad59..5d43b0ee51d93 100644 --- a/modules/user/src/main/User.scala +++ b/modules/user/src/main/User.scala @@ -1,4 +1,5 @@ package lila.user + import play.api.i18n.Lang import java.time.Duration @@ -10,13 +11,12 @@ import lila.core.user.{ Emails, PlayTime } object UserExt: extension (u: User) def userLanguage: Option[Language] = u.realLang.map(Language.apply) -opaque type Erased = Boolean -object Erased extends YesNo[Erased] - case class WithPerfsAndEmails(user: UserWithPerfs, emails: Emails) case class TotpToken(value: String) extends AnyVal +case class UserDelete(requested: Instant, erase: Boolean, done: Boolean = false) + object PlayTime: extension (p: PlayTime) def totalDuration = Duration.ofSeconds(p.total) diff --git a/modules/user/src/main/UserRepo.scala b/modules/user/src/main/UserRepo.scala index ba3d160ef7ca3..55d9f7f61cd2c 100644 --- a/modules/user/src/main/UserRepo.scala +++ b/modules/user/src/main/UserRepo.scala @@ -20,7 +20,7 @@ final class UserRepo(c: Coll)(using Executor) extends lila.core.user.UserRepo(c) import lila.user.BSONFields as F export lila.user.BSONHandlers.given - private def recoverErased(user: Fu[Option[User]]): Fu[Option[User]] = + private def recoverDeleted(user: Fu[Option[User]]): Fu[Option[User]] = user.recover: case _: reactivemongo.api.bson.exceptions.BSONValueNotFoundException => none @@ -31,7 +31,7 @@ final class UserRepo(c: Coll)(using Executor) extends lila.core.user.UserRepo(c) def byId[U: UserIdOf](u: U): Fu[Option[User]] = u.id.noGhost.so: - recoverErased: + recoverDeleted: coll.byId[User](u.id) def byIds[U: UserIdOf](us: Iterable[U]): Fu[List[User]] = @@ -43,7 +43,7 @@ final class UserRepo(c: Coll)(using Executor) extends lila.core.user.UserRepo(c) def enabledById[U: UserIdOf](u: U): Fu[Option[User]] = u.id.noGhost.so: - recoverErased: + recoverDeleted: coll.one[User](enabledSelect ++ $id(u.id)) def enabledByIds[U: UserIdOf](us: Iterable[U]): Fu[List[User]] = @@ -333,7 +333,7 @@ final class UserRepo(c: Coll)(using Executor) extends lila.core.user.UserRepo(c) .one( $id(id) ++ $doc(F.email.$exists(false)), $doc("$rename" -> $doc(F.prevEmail -> F.email)) ++ - $doc("$unset" -> $doc(F.eraseAt -> true)) + $doc("$unset" -> $doc(F.delete -> true)) ) .void .recover(lila.db.recoverDuplicateKey(_ => ())) @@ -349,11 +349,20 @@ final class UserRepo(c: Coll)(using Executor) extends lila.core.user.UserRepo(c) ) .void - def findNextToErase: Fu[Option[User]] = - coll.find($doc(F.eraseAt.$lt(nowInstant))).sort($doc(F.eraseAt -> 1)).one[User] + def findNextToDelete(delay: FiniteDuration): Fu[Option[(User, UserDelete)]] = + coll + .find: + $doc( // hits the delete.scheduled_1 index + s"${F.delete}.scheduled".$lt(nowInstant.minusMillis(delay.toMillis)), + s"${F.delete}.done" -> false + ) + .sort($doc(s"${F.delete}.scheduled" -> 1)) + .one[User] + .flatMapz: user => + coll.primitiveOne[UserDelete]($id(user.id), F.delete).mapz(delete => (user -> delete).some) - def scheduleErasure(userId: UserId, erase: Boolean): Funit = - coll.updateOrUnsetField($id(userId), F.eraseAt, erase.option(nowInstant.plusDays(7))).void + def scheduleDelete(userId: UserId, delete: Option[UserDelete]): Funit = + coll.updateOrUnsetField($id(userId), F.delete, delete).void def getPasswordHash(id: UserId): Fu[Option[String]] = coll.byId[AuthData](id, AuthData.projection).map2(_.bpass.bytes.sha512.hex) @@ -492,16 +501,12 @@ final class UserRepo(c: Coll)(using Executor) extends lila.core.user.UserRepo(c) def byIdAs[A: BSONDocumentReader](id: String, proj: Bdoc): Fu[Option[A]] = coll.one[A]($id(id), proj) - def isErased(user: User): Fu[Erased] = Erased.from: + def isDeleted(user: User): Fu[Boolean] = user.enabled.no.so: - coll.exists($id(user.id) ++ $doc(F.erasedAt.$exists(true))) + coll.exists($id(user.id) ++ $doc(s"${F.delete}.done" -> true)) def filterClosedOrInactiveIds(since: Instant)(ids: Iterable[UserId]): Fu[List[UserId]] = - coll.distinctEasy[UserId, List]( - F.id, - $inIds(ids) ++ $or(disabledSelect, F.seenAt.$lt(since)), - _.sec - ) + coll.distinctEasy[UserId, List](F.id, $inIds(ids) ++ $or(disabledSelect, F.seenAt.$lt(since)), _.sec) private val defaultCount = lila.core.user.Count(0, 0, 0, 0, 0, 0, 0, 0, 0) From 495f561bee97306339c2fbf4d9f05d7272971f8a Mon Sep 17 00:00:00 2001 From: Thibault Duplessis Date: Thu, 16 Jan 2025 13:38:40 +0100 Subject: [PATCH 10/24] cleanup analysis_requester collection --- bin/mongodb/analysis-requester-cleanup.js | 26 +++++++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 bin/mongodb/analysis-requester-cleanup.js diff --git a/bin/mongodb/analysis-requester-cleanup.js b/bin/mongodb/analysis-requester-cleanup.js new file mode 100644 index 0000000000000..c3a8861d7a0eb --- /dev/null +++ b/bin/mongodb/analysis-requester-cleanup.js @@ -0,0 +1,26 @@ +count = 0; +db.analysis_requester.find().forEach(function(doc) { + const entries = Object.entries(doc); + const reduced = entries.filter(([key]) => !key.includes('-') || key.startsWith('2025-')); + if (entries.length !== reduced.length) { + const newDoc = Object.fromEntries(reduced); + db.analysis_requester.replaceOne({ _id: doc._id }, newDoc); + } + if (count % 1000 === 0) print(count); + count++; +}); + +print(count, "documents processed"); + +/* + size: 2710719734, + count: 6218537, + storageSize: 627077120, + totalIndexSize: 178106368, + totalSize: 805183488, + indexSizes: { 'total_-1': 51482624, _id_: 126623744 }, + avgObjSize: 435, + ns: 'lichess.analysis_requester', + nindexes: 2, + scaleFactor: 1 +*/ From 944019f56efef709f4a960ab5afd6ffe1853b98e Mon Sep 17 00:00:00 2001 From: Thibault Duplessis Date: Thu, 16 Jan 2025 14:41:18 +0100 Subject: [PATCH 11/24] account termination WIP --- .../activity/src/main/ActivityWriteApi.scala | 2 ++ .../src/main/AnalyseBsonHandlers.scala | 4 +++- modules/analyse/src/main/AnalysisRepo.scala | 11 ++++++---- modules/analyse/src/main/RequesterApi.scala | 9 ++++---- modules/api/src/main/AccountTermination.scala | 19 +++++++++++------ modules/bookmark/src/main/BookmarkApi.scala | 3 +++ .../challenge/src/main/ChallengeRepo.scala | 4 +--- modules/clas/src/main/ClasApi.scala | 5 +++++ modules/coach/src/main/CoachApi.scala | 21 +++++++++++-------- .../coordinate/src/main/CoordinateApi.scala | 3 +++ modules/core/src/main/user.scala | 3 +++ modules/forum/src/main/Env.scala | 3 +++ modules/forum/src/main/ForumPostRepo.scala | 6 ++++++ modules/game/src/main/GameRepo.scala | 7 +++++++ modules/game/src/main/Query.scala | 5 +++++ 15 files changed, 78 insertions(+), 27 deletions(-) diff --git a/modules/activity/src/main/ActivityWriteApi.scala b/modules/activity/src/main/ActivityWriteApi.scala index 733020fce373c..ebb76b5618857 100644 --- a/modules/activity/src/main/ActivityWriteApi.scala +++ b/modules/activity/src/main/ActivityWriteApi.scala @@ -16,6 +16,8 @@ final class ActivityWriteApi( import BSONHandlers.{ *, given } import activities.* + def deleteAll(u: User): Funit = withColl(_.delete.one(regexId(u.id)).void) + def game(game: Game): Funit = (for userId <- game.userIds diff --git a/modules/analyse/src/main/AnalyseBsonHandlers.scala b/modules/analyse/src/main/AnalyseBsonHandlers.scala index 7875af76c7f0a..799481b2589c0 100644 --- a/modules/analyse/src/main/AnalyseBsonHandlers.scala +++ b/modules/analyse/src/main/AnalyseBsonHandlers.scala @@ -9,6 +9,8 @@ import lila.tree.{ Analysis, Info } object AnalyseBsonHandlers: + given BSONWriter[Analysis.Id] = BSONWriter(id => BSONString(id.value)) + given BSON[Analysis] with def reads(r: BSON.Reader) = val startPly = Ply(r.intD("ply")) @@ -28,7 +30,7 @@ object AnalyseBsonHandlers: ) def writes(w: BSON.Writer, a: Analysis) = BSONDocument( - "_id" -> a.id.value, + "_id" -> a.id, "studyId" -> a.studyId, "data" -> Info.encodeList(a.infos), "ply" -> w.intO(a.startPly.value), diff --git a/modules/analyse/src/main/AnalysisRepo.scala b/modules/analyse/src/main/AnalysisRepo.scala index 308cd1a6a9034..d7d17a6be2620 100644 --- a/modules/analyse/src/main/AnalysisRepo.scala +++ b/modules/analyse/src/main/AnalysisRepo.scala @@ -2,6 +2,7 @@ package lila.analyse import lila.db.dsl.* import lila.tree.Analysis +import reactivemongo.api.bson.* final class AnalysisRepo(val coll: Coll)(using Executor): @@ -9,13 +10,13 @@ final class AnalysisRepo(val coll: Coll)(using Executor): def save(analysis: Analysis) = coll.insert.one(analysis).void - def byId(id: Analysis.Id): Fu[Option[Analysis]] = coll.byId[Analysis](id.value) + def byId(id: Analysis.Id): Fu[Option[Analysis]] = coll.byId[Analysis](id) def byGame(game: Game): Fu[Option[Analysis]] = game.metadata.analysed.so(byId(Analysis.Id(game.id))) def byIds(ids: Seq[Analysis.Id]): Fu[Seq[Option[Analysis]]] = - coll.optionsByOrderedIds[Analysis, String](ids.map(_.value))(_.id.value) + coll.optionsByOrderedIds[Analysis, Analysis.Id](ids)(_.id) def associateToGames(games: List[Game]): Fu[List[(Game, Analysis)]] = byIds(games.map(g => Analysis.Id(g.id))).map: as => @@ -23,7 +24,9 @@ final class AnalysisRepo(val coll: Coll)(using Executor): game -> analysis } - def remove(id: GameId) = coll.delete.one($id(Analysis.Id(id).value)) + def remove(id: GameId) = coll.delete.one($id(Analysis.Id(id))) - def exists(id: GameId) = coll.exists($id(id.value)) + def remove(ids: List[GameId]) = coll.delete.one($inIds(ids.map(Analysis.Id(_)))) + + def exists(id: GameId) = coll.exists($id(Analysis.Id(id))) def chapterExists(id: StudyChapterId) = coll.exists($id(id.value)) diff --git a/modules/analyse/src/main/RequesterApi.scala b/modules/analyse/src/main/RequesterApi.scala index d52353c49e717..53e3b8e227f8f 100644 --- a/modules/analyse/src/main/RequesterApi.scala +++ b/modules/analyse/src/main/RequesterApi.scala @@ -27,15 +27,16 @@ final class RequesterApi(coll: Coll)(using Executor): coll .one( $id(userId), - $doc { + $doc: (7 to 0 by -1).toList.map(now.minusDays).map(formatter.print).map(_ -> BSONBoolean(true)) - } ) - .map { doc => + .map: doc => val daily = doc.flatMap(_.int(formatter.print(now))) val weekly = doc.so: _.values.foldLeft(0): case (acc, BSONInteger(v)) => acc + v case (acc, _) => acc (~daily, weekly) - } + + lila.common.Bus.sub[lila.core.user.UserDelete]: del => + coll.delete.one($id(del.id)).void diff --git a/modules/api/src/main/AccountTermination.scala b/modules/api/src/main/AccountTermination.scala index 7c98bb022fe9a..1b662d6e06794 100644 --- a/modules/api/src/main/AccountTermination.scala +++ b/modules/api/src/main/AccountTermination.scala @@ -56,7 +56,9 @@ final class AccountTermination( activityWrite: lila.activity.ActivityWriteApi, email: lila.mailer.AutomaticEmail, tokenApi: lila.oauth.AccessTokenApi, - roundApi: lila.core.round.RoundApi + roundApi: lila.core.round.RoundApi, + gameRepo: lila.game.GameRepo, + analysisRepo: lila.analyse.AnalysisRepo )(using Executor, Scheduler): Bus.subscribeFuns( @@ -71,8 +73,8 @@ final class AccountTermination( selfClose = me.is(u) teacherClose = !selfClose && !Granter(_.CloseAccount) && Granter(_.Teacher) modClose = !selfClose && Granter(_.CloseAccount) - badApple = u.lameOrTroll || u.marks.alt || modClose - _ <- userRepo.disable(u, keepEmail = badApple || playbanned) + tos = u.lameOrTroll || u.marks.alt || modClose + _ <- userRepo.disable(u, keepEmail = tos || playbanned) _ <- roundApi.resignAllGamesOf(u.id) _ <- relationApi.unfollowAll(u.id) _ <- rankingApi.remove(u.id) @@ -126,6 +128,11 @@ final class AccountTermination( then userRepo.scheduleDelete(user.id, none).inject(none) else doDeleteNow(user, del).inject(user.some) - private def doDeleteNow(user: User, del: UserDelete): Funit = - fuccess: - println(s"Time to wipe $user") + private def doDeleteNow(u: User, del: UserDelete): Funit = for + _ <- activityWrite.deleteAll(u) + tos = u.lameOrTroll || u.marks.alt + singlePlayerGameIds <- gameRepo.deleteAllSinglePlayerOf(u.id) + _ <- analysisRepo.remove(singlePlayerGameIds) + yield + // a lot of work is done by modules listening to the following event: + Bus.pub(lila.core.user.UserDelete(u.id, del.erase)) diff --git a/modules/bookmark/src/main/BookmarkApi.scala b/modules/bookmark/src/main/BookmarkApi.scala index 37ee43497e573..6981005765d05 100644 --- a/modules/bookmark/src/main/BookmarkApi.scala +++ b/modules/bookmark/src/main/BookmarkApi.scala @@ -68,3 +68,6 @@ final class BookmarkApi(coll: Coll, gameApi: GameApi, paginator: PaginatorBuilde private def userIdQuery(userId: UserId) = $doc("u" -> userId) private def makeId(gameId: GameId, userId: UserId) = s"$gameId$userId" private def selectId(gameId: GameId, userId: UserId) = $id(makeId(gameId, userId)) + + lila.common.Bus.sub[lila.core.user.UserDelete]: del => + coll.delete.one(userIdQuery(del.id)).void diff --git a/modules/challenge/src/main/ChallengeRepo.scala b/modules/challenge/src/main/ChallengeRepo.scala index aeea49b6c003b..3c2a70625d0da 100644 --- a/modules/challenge/src/main/ChallengeRepo.scala +++ b/modules/challenge/src/main/ChallengeRepo.scala @@ -58,9 +58,7 @@ final private class ChallengeRepo(colls: ChallengeColls)(using .void private[challenge] def allWithUserId(userId: UserId): Fu[List[Challenge]] = - createdByChallengerId()(userId).zip(createdByDestId()(userId)).dmap { case (x, y) => - x ::: y - } + (createdByChallengerId()(userId), createdByDestId()(userId)).mapN(_ ::: _) private def sameOrigAndDest(c: Challenge): Fu[Option[Challenge]] = ~(for diff --git a/modules/clas/src/main/ClasApi.scala b/modules/clas/src/main/ClasApi.scala index 8f494960682c6..639bcc0eb4a9b 100644 --- a/modules/clas/src/main/ClasApi.scala +++ b/modules/clas/src/main/ClasApi.scala @@ -24,6 +24,11 @@ final class ClasApi( import BsonHandlers.given + lila.common.Bus.sub[lila.core.user.UserDelete]: del => + colls.clas.update.one($doc("created.by" -> del.id), $set("created.by" -> UserId.ghost), multi = true) + colls.clas.update.one($doc("teachers" -> del.id), $pull("teachers" -> del.id), multi = true) + colls.student.delete.one($doc("userId" -> del.id)) + object clas: val coll = colls.clas diff --git a/modules/coach/src/main/CoachApi.scala b/modules/coach/src/main/CoachApi.scala index 9dc1def978836..acec1fca6cf59 100644 --- a/modules/coach/src/main/CoachApi.scala +++ b/modules/coach/src/main/CoachApi.scala @@ -6,7 +6,7 @@ import lila.memo.PicfitApi import lila.rating.UserPerfsExt.bestStandardRating final class CoachApi( - coachColl: Coll, + coll: Coll, userRepo: lila.core.user.UserRepo, userApi: lila.core.user.UserApi, flagApi: lila.core.user.FlagApi, @@ -16,7 +16,10 @@ final class CoachApi( import BsonHandlers.given - def byId[U: UserIdOf](u: U): Fu[Option[Coach]] = coachColl.byId[Coach](u.id) + lila.common.Bus.sub[lila.core.user.UserDelete]: del => + coll.delete.one($id(del.id)).void + + def byId[U: UserIdOf](u: U): Fu[Option[Coach]] = coll.byId[Coach](u.id) def find(username: UserStr): Fu[Option[Coach.WithUser]] = userApi.byId(username).flatMapz(find) @@ -33,7 +36,7 @@ final class CoachApi( canCoach(user).so: find(user).orElse(userApi.withPerfs(user).flatMap { user => val c = Coach.make(user).withUser(user) - coachColl.insert.one(c.coach).inject(c.some) + coll.insert.one(c.coach).inject(c.some) }) def isListedCoach(user: User): Fu[Boolean] = @@ -41,23 +44,23 @@ final class CoachApi( user.enabled.yes .so(user.marks.clean) .so( - coachColl.exists( + coll.exists( $id(user.id) ++ $doc("listed" -> true) ) ) def setSeenAt(user: User): Funit = canCoach(user).so: - coachColl.update.one($id(user.id), $set("user.seenAt" -> nowInstant)).void + coll.update.one($id(user.id), $set("user.seenAt" -> nowInstant)).void def updateRatingFromDb(user: User): Funit = canCoach(user).so: userApi.perfsOf(user).flatMap { perfs => - coachColl.update.one($id(perfs.id), $set("user.rating" -> perfs.bestStandardRating)).void + coll.update.one($id(perfs.id), $set("user.rating" -> perfs.bestStandardRating)).void } def update(c: Coach.WithUser, data: CoachProfileForm.Data): Funit = - coachColl.update + coll.update .one( $id(c.coach.id), data(c.coach), @@ -69,12 +72,12 @@ final class CoachApi( picfitApi .uploadFile(s"coach:${c.coach.id}", picture, userId = c.user.id) .flatMap { pic => - coachColl.update.one($id(c.coach.id), $set("picture" -> pic.id)).void + coll.update.one($id(c.coach.id), $set("picture" -> pic.id)).void } private val languagesCache = cacheApi.unit[Set[String]]: _.refreshAfterWrite(1.hour).buildAsyncFuture: _ => - coachColl.secondaryPreferred.distinctEasy[String, Set]("languages", $empty) + coll.secondaryPreferred.distinctEasy[String, Set]("languages", $empty) def allLanguages: Fu[Set[String]] = languagesCache.get {} diff --git a/modules/coordinate/src/main/CoordinateApi.scala b/modules/coordinate/src/main/CoordinateApi.scala index 3527dba43c9ac..70733b0e77316 100644 --- a/modules/coordinate/src/main/CoordinateApi.scala +++ b/modules/coordinate/src/main/CoordinateApi.scala @@ -9,6 +9,9 @@ final class CoordinateApi(scoreColl: Coll)(using Executor): private given BSONDocumentHandler[Score] = Macros.handler[Score] + lila.common.Bus.sub[lila.core.user.UserDelete]: del => + scoreColl.delete.one($id(del.id)).void + def getScore(userId: UserId): Fu[Score] = scoreColl.byId[Score](userId).dmap(_ | Score(userId)) diff --git a/modules/core/src/main/user.scala b/modules/core/src/main/user.scala index 75e618f377815..fba48feebacc5 100644 --- a/modules/core/src/main/user.scala +++ b/modules/core/src/main/user.scala @@ -161,6 +161,9 @@ object user: case class ChangeEmail(id: UserId, email: EmailAddress) + case class UserDelete(id: UserId, erase: Boolean) + object UserDelete extends bus.GivenChannel[UserDelete]("userDelete") + trait UserApi: def byId[U: UserIdOf](u: U): Fu[Option[User]] def enabledById[U: UserIdOf](u: U): Fu[Option[User]] diff --git a/modules/forum/src/main/Env.scala b/modules/forum/src/main/Env.scala index 4def4b2e88c64..5316bb0adae50 100644 --- a/modules/forum/src/main/Env.scala +++ b/modules/forum/src/main/Env.scala @@ -65,6 +65,9 @@ final class Env( lila.common.Bus.subscribeFun("team", "gdprErase"): case lila.core.team.TeamCreate(t) => categApi.makeTeam(t.id, t.name, t.userId) + lila.common.Bus.sub[lila.core.user.UserDelete]: del => + postRepo.eraseAllBy(del.id) + private type RecentTeamPostsType = TeamId => Fu[List[ForumPostMiniView]] opaque type RecentTeamPosts <: RecentTeamPostsType = RecentTeamPostsType object RecentTeamPosts extends TotalWrapper[RecentTeamPosts, RecentTeamPostsType] diff --git a/modules/forum/src/main/ForumPostRepo.scala b/modules/forum/src/main/ForumPostRepo.scala index 27fcc19ef52c3..b80e3285f415e 100644 --- a/modules/forum/src/main/ForumPostRepo.scala +++ b/modules/forum/src/main/ForumPostRepo.scala @@ -110,6 +110,12 @@ final class ForumPostRepo(val coll: Coll, filter: Filter = Safe)(using Executor) _.sec ) + def eraseAllBy(id: UserId) = + coll.update.one( + $doc("userId" -> id), + $set($doc("userId" -> UserId.ghost, "text" -> "", "erasedAt" -> nowInstant)) + ) + private[forum] def nonGhostCursor(since: Option[Instant]): AkkaStreamCursor[ForumPostMini] = val noGhost = $doc("userId".$ne(UserId.ghost)) val filter = since.fold(noGhost)(instant => $and(noGhost, $doc("createdAt".$gt(instant)))) diff --git a/modules/game/src/main/GameRepo.scala b/modules/game/src/main/GameRepo.scala index 5ebd09dcb7c97..7f27808694c23 100644 --- a/modules/game/src/main/GameRepo.scala +++ b/modules/game/src/main/GameRepo.scala @@ -556,3 +556,10 @@ final class GameRepo(c: Coll)(using Executor) extends lila.core.game.GameRepo(c) .sort(Query.sortCreated) .cursor[Game](ReadPref.sec) .list(nb) + + def deleteAllSinglePlayerOf(id: UserId): Fu[List[GameId]] = for + aiIds <- coll.primitive[GameId](Query.user(id) ++ Query.hasAi, "_id") + importIds <- coll.primitive[GameId](Query.imported(id), "_id") + allIds = aiIds ::: importIds + _ <- coll.delete.one($inIds(allIds)) + yield allIds diff --git a/modules/game/src/main/Query.scala b/modules/game/src/main/Query.scala index c877cfe1127f7..c3d940948a765 100644 --- a/modules/game/src/main/Query.scala +++ b/modules/game/src/main/Query.scala @@ -59,6 +59,11 @@ object Query: "p1.ai".$exists(false) ) + val hasAi: Bdoc = $or( + "p0.ai".$exists(true), + "p1.ai".$exists(true) + ) + def nowPlaying[U: UserIdOf](u: U) = $doc(F.playingUids -> u.id) def recentlyPlaying(u: UserId) = From a58a9610796ee0e503a5f3766fcfe68f90f51681 Mon Sep 17 00:00:00 2001 From: Thibault Duplessis Date: Thu, 16 Jan 2025 16:01:04 +0100 Subject: [PATCH 12/24] account termination WIP --- modules/api/src/main/AccountTermination.scala | 17 +++++++++++++++-- modules/chat/src/main/ChatApi.scala | 9 +++++++++ modules/chat/src/main/Line.scala | 5 ++++- modules/game/src/main/CrosstableApi.scala | 4 ++++ modules/game/src/main/Env.scala | 2 +- modules/game/src/main/GameRepo.scala | 3 ++- modules/history/src/main/HistoryApi.scala | 3 +++ modules/irwin/src/main/IrwinApi.scala | 3 +++ modules/learn/src/main/LearnApi.scala | 3 +++ modules/memo/src/main/Picfit.scala | 6 ++++++ modules/mod/src/main/AssessApi.scala | 3 +++ modules/notify/src/main/NotifyApi.scala | 6 ++++++ modules/perfStat/src/main/Env.scala | 3 +++ modules/perfStat/src/main/PerfStatStorage.scala | 3 +++ modules/plan/src/main/PlanApi.scala | 6 ++++++ modules/playban/src/main/PlaybanApi.scala | 3 +++ modules/practice/src/main/PracticeApi.scala | 7 ++++--- modules/pref/src/main/PrefApi.scala | 3 +++ modules/push/src/main/DeviceApi.scala | 3 +++ modules/puzzle/src/main/PuzzleDashboard.scala | 11 +++++------ modules/user/src/main/NoteApi.scala | 6 ++++++ 21 files changed, 95 insertions(+), 14 deletions(-) diff --git a/modules/api/src/main/AccountTermination.scala b/modules/api/src/main/AccountTermination.scala index 1b662d6e06794..b5f8c1be9948b 100644 --- a/modules/api/src/main/AccountTermination.scala +++ b/modules/api/src/main/AccountTermination.scala @@ -3,6 +3,8 @@ package lila.api import lila.common.Bus import lila.core.perm.Granter import lila.user.UserDelete +import akka.stream.scaladsl.* +import lila.db.dsl.{ *, given } enum Termination: case disable, delete, erase @@ -58,8 +60,9 @@ final class AccountTermination( tokenApi: lila.oauth.AccessTokenApi, roundApi: lila.core.round.RoundApi, gameRepo: lila.game.GameRepo, - analysisRepo: lila.analyse.AnalysisRepo -)(using Executor, Scheduler): + analysisRepo: lila.analyse.AnalysisRepo, + chatApi: lila.chat.ChatApi +)(using Executor, Scheduler, akka.stream.Materializer): Bus.subscribeFuns( "garbageCollect" -> { case lila.core.security.GarbageCollect(userId) => @@ -77,6 +80,7 @@ final class AccountTermination( _ <- userRepo.disable(u, keepEmail = tos || playbanned) _ <- roundApi.resignAllGamesOf(u.id) _ <- relationApi.unfollowAll(u.id) + _ <- relationApi.removeAllFollowers(u.id) _ <- rankingApi.remove(u.id) teamIds <- teamApi.quitAllOnAccountClosure(u.id) _ <- challengeApi.removeByUserId(u.id) @@ -133,6 +137,15 @@ final class AccountTermination( tos = u.lameOrTroll || u.marks.alt singlePlayerGameIds <- gameRepo.deleteAllSinglePlayerOf(u.id) _ <- analysisRepo.remove(singlePlayerGameIds) + _ <- deleteAllGameChats(u) yield // a lot of work is done by modules listening to the following event: Bus.pub(lila.core.user.UserDelete(u.id, del.erase)) + + private def deleteAllGameChats(u: User) = gameRepo + .docCursor(lila.game.Query.user(u.id), $id(true).some) + .documentSource() + .mapConcat(_.getAsOpt[GameId]("_id").toList) + .grouped(100) + .mapAsync(1)(ids => chatApi.userChat.removeMessagesBy(ids, u.id)) + .runWith(Sink.ignore) diff --git a/modules/chat/src/main/ChatApi.scala b/modules/chat/src/main/ChatApi.scala index a09b3b883fad2..f011d0da09813 100644 --- a/modules/chat/src/main/ChatApi.scala +++ b/modules/chat/src/main/ChatApi.scala @@ -260,6 +260,15 @@ final class ChatApi( case _ => none } + def removeMessagesBy(gameIds: Seq[GameId], userId: UserId) = + val regex = s"^$userId[" + Line.separatorChars.mkString("") + "]" + val update = $pull("l".$regex(regex, "i")) + val allIds = for + id <- gameIds + both <- List(id.value, s"${id.value}/w") + yield both + coll.update.one($inIds(allIds), update, multi = true).void + private object Speaker: def get(userId: UserId): Fu[Option[Speaker]] = userApi.byIdAs[Speaker](userId.value, Speaker.projection) import lila.core.user.BSONFields as F diff --git a/modules/chat/src/main/Line.scala b/modules/chat/src/main/Line.scala index 790d5feedc963..a90aa737e75c9 100644 --- a/modules/chat/src/main/Line.scala +++ b/modules/chat/src/main/Line.scala @@ -51,13 +51,16 @@ object Line: lineToStr ) + private val baseChar = " " private val trollChar = "!" private val deletedChar = "?" private val patronChar = "&" private val flairChar = ":" private val patronFlairChar = ";" + private[chat] val separatorChars = + List(baseChar, trollChar, deletedChar, patronChar, flairChar, patronFlairChar) private val UserLineRegex = { - """(?s)([\w-~]{2,}+)([ """ + s"$trollChar$deletedChar$patronChar$flairChar$patronFlairChar" + """])(.++)""" + """(?s)([\w-~]{2,}+)([""" + separatorChars.mkString("") + """])(.++)""" }.r private[chat] def strToUserLine(str: String): Option[UserLine] = str match case UserLineRegex(username, sep, text) => diff --git a/modules/game/src/main/CrosstableApi.scala b/modules/game/src/main/CrosstableApi.scala index 56b236f921174..a86b7c08ef0e8 100644 --- a/modules/game/src/main/CrosstableApi.scala +++ b/modules/game/src/main/CrosstableApi.scala @@ -12,6 +12,10 @@ final class CrosstableApi( import Crosstable.{ Matchup, Result } import Crosstable.BSONFields as F + lila.common.Bus.sub[lila.core.user.UserDelete]: del => + matchupColl: + _.delete.one($doc("_id".$regex(s"^${del.id}/"))).void + def apply(game: Game): Fu[Option[Crosstable]] = game.twoUserIds.soFu(apply.tupled) diff --git a/modules/game/src/main/Env.scala b/modules/game/src/main/Env.scala index 6808a42d2f3bd..4063d9544d166 100644 --- a/modules/game/src/main/Env.scala +++ b/modules/game/src/main/Env.scala @@ -39,7 +39,7 @@ final class Env( ): private val config = appConfig.get[GameConfig]("game")(AutoConfig.loader) - val gameRepo = new GameRepo(db(config.gameColl)) + val gameRepo = GameRepo(db(config.gameColl)) val idGenerator = wire[IdGenerator] diff --git a/modules/game/src/main/GameRepo.scala b/modules/game/src/main/GameRepo.scala index 7f27808694c23..00f53dffde454 100644 --- a/modules/game/src/main/GameRepo.scala +++ b/modules/game/src/main/GameRepo.scala @@ -141,9 +141,10 @@ final class GameRepo(c: Coll)(using Executor) extends lila.core.game.GameRepo(c) def docCursor( selector: Bdoc, + project: Option[Bdoc] = none, readPref: ReadPref = _.priTemp ): AkkaStreamCursor[Bdoc] = - coll.find(selector).cursor[Bdoc](readPref) + coll.find(selector, project).cursor[Bdoc](readPref) def sortedCursor( selector: Bdoc, diff --git a/modules/history/src/main/HistoryApi.scala b/modules/history/src/main/HistoryApi.scala index be5c626228f44..9b0eb36c27da5 100644 --- a/modules/history/src/main/HistoryApi.scala +++ b/modules/history/src/main/HistoryApi.scala @@ -18,6 +18,9 @@ final class HistoryApi( import History.{ given, * } + lila.common.Bus.sub[lila.core.user.UserDelete]: del => + withColl(_.delete.one($id(del.id)).void) + def addPuzzle(user: User, completedAt: Instant, perf: lila.core.perf.Perf): Funit = withColl: coll => val days = daysBetween(user.createdAt, completedAt) diff --git a/modules/irwin/src/main/IrwinApi.scala b/modules/irwin/src/main/IrwinApi.scala index 7e6a4f9343896..3434b1fb006ca 100644 --- a/modules/irwin/src/main/IrwinApi.scala +++ b/modules/irwin/src/main/IrwinApi.scala @@ -25,6 +25,9 @@ final class IrwinApi( import BSONHandlers.given + lila.common.Bus.sub[lila.core.user.UserDelete]: del => + reportColl.delete.one($id(del.id)) + def dashboard: Fu[IrwinReport.Dashboard] = reportColl .find($empty) diff --git a/modules/learn/src/main/LearnApi.scala b/modules/learn/src/main/LearnApi.scala index faaf0ffdec568..dd5b1909fac75 100644 --- a/modules/learn/src/main/LearnApi.scala +++ b/modules/learn/src/main/LearnApi.scala @@ -6,6 +6,9 @@ final class LearnApi(coll: Coll)(using Executor): import BSONHandlers.given + lila.common.Bus.sub[lila.core.user.UserDelete]: del => + reset(del.id) + def get(user: UserId): Fu[LearnProgress] = coll.one[LearnProgress]($id(user)).dmap { _ | LearnProgress.empty(user.id) } diff --git a/modules/memo/src/main/Picfit.scala b/modules/memo/src/main/Picfit.scala index ab0193bb4ad41..df5e9fb4837ec 100644 --- a/modules/memo/src/main/Picfit.scala +++ b/modules/memo/src/main/Picfit.scala @@ -111,6 +111,12 @@ final class PicfitApi(coll: Coll, val url: PicfitUrl, ws: StandaloneWSClient, co case _ => funit } + lila.common.Bus.sub[lila.core.user.UserDelete]: del => + for + ids <- coll.primitive[ImageId]($doc("user" -> del.id), "_id") + _ <- deleteByIdsAndUser(ids, del.id) + yield () + object PicfitApi: val uploadMaxMb = 6 diff --git a/modules/mod/src/main/AssessApi.scala b/modules/mod/src/main/AssessApi.scala index b3da9d56310c4..ee46e4c0bec44 100644 --- a/modules/mod/src/main/AssessApi.scala +++ b/modules/mod/src/main/AssessApi.scala @@ -28,6 +28,9 @@ final class AssessApi( import lila.evaluation.EvaluationBsonHandlers.given import lila.analyse.AnalyseBsonHandlers.given + lila.common.Bus.sub[lila.core.user.UserDelete]: del => + assessRepo.coll.delete.one($id(del.id)) + private def createPlayerAssessment(assessed: PlayerAssessment) = assessRepo.coll.update.one($id(assessed._id), assessed, upsert = true).void diff --git a/modules/notify/src/main/NotifyApi.scala b/modules/notify/src/main/NotifyApi.scala index 1b07e97c2d1af..f8a8c6da4c657 100644 --- a/modules/notify/src/main/NotifyApi.scala +++ b/modules/notify/src/main/NotifyApi.scala @@ -30,6 +30,12 @@ final class NotifyApi( import BSONHandlers.given import jsonHandlers.* + lila.common.Bus.sub[lila.core.user.UserDelete]: del => + for + _ <- colls.pref.delete.one($id(del.id)) + _ <- colls.notif.delete.one($doc("notifies" -> del.id)) + yield () + object prefs: import NotificationPref.{ *, given } diff --git a/modules/perfStat/src/main/Env.scala b/modules/perfStat/src/main/Env.scala index fb9e299afc291..5a18ceb1793e0 100644 --- a/modules/perfStat/src/main/Env.scala +++ b/modules/perfStat/src/main/Env.scala @@ -31,3 +31,6 @@ final class Env( indexer.addGame(game).addFailureEffect { e => lila.log("perfStat").error(s"index game ${game.id}", e) } + + lila.common.Bus.sub[lila.core.user.UserDelete]: del => + storage.deleteAllFor(del.id) diff --git a/modules/perfStat/src/main/PerfStatStorage.scala b/modules/perfStat/src/main/PerfStatStorage.scala index a2a4fb1767b43..5c1a1252ef55b 100644 --- a/modules/perfStat/src/main/PerfStatStorage.scala +++ b/modules/perfStat/src/main/PerfStatStorage.scala @@ -28,6 +28,9 @@ final class PerfStatStorage(coll: AsyncCollFailingSilently)(using Executor): def insert(perfStat: PerfStat): Funit = coll(_.insert.one(perfStat).void) + private[perfStat] def deleteAllFor(userId: UserId): Funit = + coll(_.delete.one($doc("_id".$regex(s"^$userId/"))).void) + def update(a: PerfStat, b: PerfStat): Funit = coll: c => val sets = $doc( docDiff(a.count, b.count).mapKeys(k => s"count.$k").toList ::: diff --git a/modules/plan/src/main/PlanApi.scala b/modules/plan/src/main/PlanApi.scala index d78f8a7fcb6ea..66ce91aea410c 100644 --- a/modules/plan/src/main/PlanApi.scala +++ b/modules/plan/src/main/PlanApi.scala @@ -32,6 +32,12 @@ final class PlanApi( import BsonHandlers.PatronHandlers.given import BsonHandlers.ChargeHandlers.given + lila.common.Bus.sub[lila.core.user.UserDelete]: del => + for + _ <- mongo.patron.delete.one($id(del.id)) + _ <- mongo.charge.update.one($doc("userId" -> del.id), $set("userId" -> UserId.ghost), multi = true) + yield () + def switch(user: User, money: Money): Fu[StripeSubscription] = stripe.userCustomer(user).flatMap { case None => fufail(s"Can't switch non-existent customer ${user.id}") diff --git a/modules/playban/src/main/PlaybanApi.scala b/modules/playban/src/main/PlaybanApi.scala index 00cc86470ffc3..1a8fde0320a19 100644 --- a/modules/playban/src/main/PlaybanApi.scala +++ b/modules/playban/src/main/PlaybanApi.scala @@ -28,6 +28,9 @@ final class PlaybanApi( private given BSONDocumentHandler[TempBan] = Macros.handler private given BSONDocumentHandler[UserRecord] = Macros.handler + lila.common.Bus.sub[lila.core.user.UserDelete]: del => + coll.delete.one($id(del.id)).void + private def blameableSource(game: Game): Boolean = game.source.exists: s => s == Source.Lobby || s == Source.Pool || s == Source.Arena diff --git a/modules/practice/src/main/PracticeApi.scala b/modules/practice/src/main/PracticeApi.scala index 4500b1affe001..a2b80ed45b86b 100644 --- a/modules/practice/src/main/PracticeApi.scala +++ b/modules/practice/src/main/PracticeApi.scala @@ -81,12 +81,13 @@ final class PracticeApi( object progress: + lila.common.Bus.sub[lila.core.user.UserDelete]: del => + coll.delete.one($id(del.id)).void + import PracticeProgress.NbMoves def get(user: User): Fu[PracticeProgress] = - coll.one[PracticeProgress]($id(user.id)).dmap { - _ | PracticeProgress.empty(user.id) - } + coll.one[PracticeProgress]($id(user.id)).dmap(_ | PracticeProgress.empty(user.id)) private def save(p: PracticeProgress): Funit = coll.update.one($id(p.id), p, upsert = true).void diff --git a/modules/pref/src/main/PrefApi.scala b/modules/pref/src/main/PrefApi.scala index e86afa08bf0db..791d11046851c 100644 --- a/modules/pref/src/main/PrefApi.scala +++ b/modules/pref/src/main/PrefApi.scala @@ -16,6 +16,9 @@ final class PrefApi( import PrefHandlers.given + lila.common.Bus.sub[lila.core.user.UserDelete]: del => + coll.delete.one($id(del.id)).void + private def fetchPref(id: UserId): Fu[Option[Pref]] = coll.find($id(id)).one[Pref] private val cache = cacheApi[UserId, Option[Pref]](200_000, "pref.fetchPref"): diff --git a/modules/push/src/main/DeviceApi.scala b/modules/push/src/main/DeviceApi.scala index 527dce3cd4448..6778e79f67319 100644 --- a/modules/push/src/main/DeviceApi.scala +++ b/modules/push/src/main/DeviceApi.scala @@ -9,6 +9,9 @@ final private class DeviceApi(coll: Coll)(using Executor): private given BSONDocumentHandler[Device] = Macros.handler + lila.common.Bus.sub[lila.core.user.UserDelete]: del => + coll.delete.one($doc("userId" -> del.id)).void + private[push] def findByDeviceId(deviceId: String): Fu[Option[Device]] = coll.find($id(deviceId)).one[Device] diff --git a/modules/puzzle/src/main/PuzzleDashboard.scala b/modules/puzzle/src/main/PuzzleDashboard.scala index 3bf821a456f8d..bf2946ee9badb 100644 --- a/modules/puzzle/src/main/PuzzleDashboard.scala +++ b/modules/puzzle/src/main/PuzzleDashboard.scala @@ -85,14 +85,13 @@ final class PuzzleDashboardApi( import PuzzleDashboard.* + lila.common.Bus.sub[lila.core.user.UserDelete]: del => + colls.round(_.delete.one($doc("u" -> del.id))) + def apply(u: User, days: Days): Fu[Option[PuzzleDashboard]] = cache.get(u.id -> days) - private val cache = - cacheApi[(UserId, Days), Option[PuzzleDashboard]](1024, "puzzle.dashboard") { - _.expireAfterWrite(10.seconds).buildAsyncFuture { case (userId, days) => - compute(userId, days) - } - } + private val cache = cacheApi[(UserId, Days), Option[PuzzleDashboard]](32, "puzzle.dashboard"): + _.expireAfterWrite(10.seconds).buildAsyncFuture(compute) private def compute(userId: UserId, days: Days): Fu[Option[PuzzleDashboard]] = colls.round { diff --git a/modules/user/src/main/NoteApi.scala b/modules/user/src/main/NoteApi.scala index b8b281dc516ee..ae7996a54db8a 100644 --- a/modules/user/src/main/NoteApi.scala +++ b/modules/user/src/main/NoteApi.scala @@ -25,6 +25,12 @@ final class NoteApi(coll: Coll)(using Executor) extends lila.core.user.NoteApi: import reactivemongo.api.bson.* private given bsonHandler: BSONDocumentHandler[Note] = Macros.handler[Note] + lila.common.Bus.sub[lila.core.user.UserDelete]: del => + for + _ <- coll.delete.one($doc("from" -> del.id)) // no index, expensive! + _ <- coll.delete.one($doc("to" -> del.id)) + yield () + def getForMyPermissions(user: User, max: Max = Max(30))(using me: Me): Fu[List[Note]] = coll .find( From 16790db29e749b3c7290462ce03f54fa8ba6f5f5 Mon Sep 17 00:00:00 2001 From: Thibault Duplessis Date: Thu, 16 Jan 2025 18:14:36 +0100 Subject: [PATCH 13/24] account termination WIP --- modules/api/src/main/AccountTermination.scala | 9 +++++++-- modules/core/src/main/user.scala | 3 ++- modules/report/src/main/ReportApi.scala | 12 ++++++++++++ modules/security/src/main/Store.scala | 4 ++++ modules/shutup/src/main/ShutupApi.scala | 5 ++++- modules/simul/src/main/Env.scala | 4 ++++ modules/simul/src/main/SimulRepo.scala | 6 ++++++ modules/storm/src/main/StormDay.scala | 3 +++ modules/streamer/src/main/StreamerApi.scala | 7 +++---- modules/study/src/main/ChapterRepo.scala | 3 ++- modules/study/src/main/Env.scala | 7 +++++++ modules/study/src/main/StudyRepo.scala | 8 ++++++++ modules/study/src/main/StudyTopic.scala | 3 +++ modules/swiss/src/main/SwissApi.scala | 14 ++++++++++++++ modules/team/src/main/TeamApi.scala | 2 +- modules/team/src/main/TeamRepo.scala | 5 +++++ modules/user/src/main/NoteApi.scala | 3 ++- 17 files changed, 87 insertions(+), 11 deletions(-) diff --git a/modules/api/src/main/AccountTermination.scala b/modules/api/src/main/AccountTermination.scala index b5f8c1be9948b..521a43b01f89e 100644 --- a/modules/api/src/main/AccountTermination.scala +++ b/modules/api/src/main/AccountTermination.scala @@ -121,7 +121,7 @@ final class AccountTermination( lila.common.LilaScheduler.variableDelay( "accountTermination.delete", - prev => _.Delay(if prev.isDefined then 1.second else 10.seconds), + delay = prev => _.Delay(if prev.isDefined then 1.second else 10.seconds), timeout = _.AtMost(1.minute), initialDelay = _.Delay(111.seconds) ): @@ -138,9 +138,14 @@ final class AccountTermination( singlePlayerGameIds <- gameRepo.deleteAllSinglePlayerOf(u.id) _ <- analysisRepo.remove(singlePlayerGameIds) _ <- deleteAllGameChats(u) + _ <- streamerApi.delete(u) + _ <- swissApi.onUserDelete(u.id) + _ <- teamApi.onUserDelete(u.id) + _ <- u.marks.clean.so: + securityStore.deleteAllSessionsOf(u.id) yield // a lot of work is done by modules listening to the following event: - Bus.pub(lila.core.user.UserDelete(u.id, del.erase)) + Bus.pub(lila.core.user.UserDelete(u, del.erase)) private def deleteAllGameChats(u: User) = gameRepo .docCursor(lila.game.Query.user(u.id), $id(true).some) diff --git a/modules/core/src/main/user.scala b/modules/core/src/main/user.scala index fba48feebacc5..8155d77d5f493 100644 --- a/modules/core/src/main/user.scala +++ b/modules/core/src/main/user.scala @@ -161,7 +161,8 @@ object user: case class ChangeEmail(id: UserId, email: EmailAddress) - case class UserDelete(id: UserId, erase: Boolean) + case class UserDelete(user: User, erase: Boolean): + export user.id object UserDelete extends bus.GivenChannel[UserDelete]("userDelete") trait UserApi: diff --git a/modules/report/src/main/ReportApi.scala b/modules/report/src/main/ReportApi.scala index 785a9d8ac6f81..d1736d73ffdbd 100644 --- a/modules/report/src/main/ReportApi.scala +++ b/modules/report/src/main/ReportApi.scala @@ -527,6 +527,18 @@ final class ReportApi( "atoms.reason" -> reason ) + def deleteAllBy(u: User) = for + reports <- coll.list[Report]($doc("atoms.by" -> u.id), 500) + _ <- reports.traverse: r => + val newAtoms = r.atoms.map: a => + if a.by.is(u) + then a.copy(by = UserId.ghost.into(ReporterId)) + else a + coll.update.one($id(r.id), $set("atoms" -> newAtoms)) + _ <- u.marks.clean.so: + coll.update.one($doc("user" -> u.id), $set("user" -> UserId.ghost)).void + yield () + object inquiries: private val workQueue = scalalib.actor.AsyncActorSequencer( diff --git a/modules/security/src/main/Store.scala b/modules/security/src/main/Store.scala index 86e4563cce639..f4a0193122b91 100644 --- a/modules/security/src/main/Store.scala +++ b/modules/security/src/main/Store.scala @@ -142,6 +142,10 @@ final class Store(val coll: Coll, cacheApi: lila.memo.CacheApi)(using Executor): ) yield uncacheAllOf(userId) + def deleteAllSessionsOf(userId: UserId): Funit = + for _ <- coll.delete.one($doc("user" -> userId)) + yield uncacheAllOf(userId) + private given BSONDocumentHandler[UserSession] = Macros.handler[UserSession] def openSessions(userId: UserId, nb: Int): Fu[List[UserSession]] = coll diff --git a/modules/shutup/src/main/ShutupApi.scala b/modules/shutup/src/main/ShutupApi.scala index 7364dd345a4b1..09785f229d70f 100644 --- a/modules/shutup/src/main/ShutupApi.scala +++ b/modules/shutup/src/main/ShutupApi.scala @@ -17,9 +17,12 @@ final class ShutupApi( private given BSONDocumentHandler[UserRecord] = Macros.handler import PublicLine.given + lila.common.Bus.sub[lila.core.user.UserDelete]: del => + coll.delete.one($id(del.id)) + def getPublicLines(userId: UserId): Fu[List[PublicLine]] = coll - .find($doc("_id" -> userId), $doc("pub" -> 1).some) + .find($id(userId), $doc("pub" -> 1).some) .one[Bdoc] .map: ~_.flatMap(_.getAsOpt[List[PublicLine]]("pub")) diff --git a/modules/simul/src/main/Env.scala b/modules/simul/src/main/Env.scala index 4417d98d6c623..5fe5c4d4657b8 100644 --- a/modules/simul/src/main/Env.scala +++ b/modules/simul/src/main/Env.scala @@ -84,5 +84,9 @@ final class Env( } ) + lila.common.Bus.sub[lila.core.user.UserDelete]: del => + repo.anonymizeHost(del.id) + repo.anonymizePlayers(del.id) + final class SimulIsFeaturable(f: Simul => Boolean) extends (Simul => Boolean): def apply(simul: Simul) = f(simul) diff --git a/modules/simul/src/main/SimulRepo.scala b/modules/simul/src/main/SimulRepo.scala index 4bd71684da045..126f5afe1a786 100644 --- a/modules/simul/src/main/SimulRepo.scala +++ b/modules/simul/src/main/SimulRepo.scala @@ -170,3 +170,9 @@ final private[simul] class SimulRepo(val coll: Coll, gameRepo: GameRepo)(using E "createdAt" -> $doc("$lt" -> (nowInstant.minusMinutes(60))) ) ) + + private[simul] def anonymizeHost(id: UserId) = + coll.update.one($doc("hostId" -> id), $set("hostId" -> UserId.ghost)) + + private[simul] def anonymizePlayers(id: UserId) = + coll.update.one($doc("pairings.player.user" -> id), $set("pairings.$.player.user" -> UserId.ghost)) diff --git a/modules/storm/src/main/StormDay.scala b/modules/storm/src/main/StormDay.scala index cadebce067ada..d24a8cd66f651 100644 --- a/modules/storm/src/main/StormDay.scala +++ b/modules/storm/src/main/StormDay.scala @@ -53,6 +53,9 @@ final class StormDayApi(coll: Coll, highApi: StormHighApi, userApi: lila.core.us import StormDay.* import StormBsonHandlers.given + lila.common.Bus.sub[lila.core.user.UserDelete]: del => + coll.delete.one(idRegexFor(del.id)) + def addRun( data: StormForm.RunData, user: Option[User], diff --git a/modules/streamer/src/main/StreamerApi.scala b/modules/streamer/src/main/StreamerApi.scala index 2f3802a5a29e9..1057ed465b916 100644 --- a/modules/streamer/src/main/StreamerApi.scala +++ b/modules/streamer/src/main/StreamerApi.scala @@ -140,9 +140,9 @@ final class StreamerApi( coll .find($id(user.id)) .one[Streamer] - .map(_.foreach: s => + .flatMapz: s => s.youTube.foreach(tuber => ytApi.channelSubscribe(tuber.channelId, false)) - coll.delete.one($id(user.id)).void) + coll.delete.one($id(user.id)).void def create(u: User): Funit = coll.insert.one(Streamer.make(u)).void.recover(lila.db.ignoreDuplicateKey) @@ -159,9 +159,8 @@ final class StreamerApi( def uploadPicture(s: Streamer, picture: PicfitApi.FilePart, by: User): Funit = picfitApi .uploadFile(s"streamer:${s.id}", picture, userId = by.id) - .flatMap { pic => + .flatMap: pic => coll.update.one($id(s.id), $set("picture" -> pic.id)).void - } // unapprove after 6 weeks if you never streamed (was originally 1 week) def autoDemoteFakes: Funit = diff --git a/modules/study/src/main/ChapterRepo.scala b/modules/study/src/main/ChapterRepo.scala index a6549196c8a26..3e0fac0c5abad 100644 --- a/modules/study/src/main/ChapterRepo.scala +++ b/modules/study/src/main/ChapterRepo.scala @@ -27,7 +27,8 @@ final class ChapterRepo(val coll: AsyncColl)(using Executor, akka.stream.Materia def deleteByStudy(s: Study): Funit = coll(_.delete.one($studyId(s.id))).void - def deleteByStudyIds(ids: List[StudyId]): Funit = coll(_.delete.one($doc("studyId".$in(ids)))).void + def deleteByStudyIds(ids: List[StudyId]): Funit = ids.nonEmpty.so: + coll(_.delete.one($doc("studyId".$in(ids)))).void // studyId is useful to ensure that the chapter belongs to the study def byIdAndStudy(id: StudyChapterId, studyId: StudyId): Fu[Option[Chapter]] = diff --git a/modules/study/src/main/Env.scala b/modules/study/src/main/Env.scala index b0a9be849aac4..3b3834881377f 100644 --- a/modules/study/src/main/Env.scala +++ b/modules/study/src/main/Env.scala @@ -109,3 +109,10 @@ final class Env( lila.common.Bus.subscribeFun("studyAnalysisProgress"): case lila.tree.StudyAnalysisProgress(analysis, complete) => serverEvalMerger(analysis, complete) + + lila.common.Bus.sub[lila.core.user.UserDelete]: del => + for + studyIds <- studyRepo.deleteByOwner(del.id) + _ <- chapterRepo.deleteByStudyIds(studyIds) + _ <- topicApi.userTopicsDelete(del.id) + yield () diff --git a/modules/study/src/main/StudyRepo.scala b/modules/study/src/main/StudyRepo.scala index 1aab5640c8f51..bc45e6d80f8b2 100644 --- a/modules/study/src/main/StudyRepo.scala +++ b/modules/study/src/main/StudyRepo.scala @@ -319,6 +319,14 @@ final class StudyRepo(private[study] val coll: AsyncColl)(using private[study] def isAdminMember(study: Study, userId: UserId): Fu[Boolean] = coll(_.exists($id(study.id) ++ $doc(s"members.$userId.admin" -> true))) + private[study] def deleteByOwner(u: UserId): Fu[List[StudyId]] = for + c <- coll.get + ids <- c.distinctEasy[StudyId, List]("_id", selectOwnerId(u)) + _ <- c.delete.one(selectOwnerId(u)) + _ <- c.update.one($doc(F.likers -> u), $pull(F.likers -> u)) + _ <- c.update.one($doc(F.uids -> u), $pull(F.uids -> u) ++ $unset(s"members.$u")) + yield ids + private def countLikes(studyId: StudyId): Fu[Option[(Study.Likes, Instant)]] = coll: _.aggregateWith[Bdoc](): framework => diff --git a/modules/study/src/main/StudyTopic.scala b/modules/study/src/main/StudyTopic.scala index 26a7723704575..dff6086599f12 100644 --- a/modules/study/src/main/StudyTopic.scala +++ b/modules/study/src/main/StudyTopic.scala @@ -97,6 +97,9 @@ final class StudyTopicApi(topicRepo: StudyTopicRepo, userTopicRepo: StudyUserTop ) }) + def userTopicsDelete(userId: UserId) = + userTopicRepo.coll(_.delete.one($id(userId))) + def popular(nb: Int): Fu[StudyTopics] = StudyTopics.from( topicRepo diff --git a/modules/swiss/src/main/SwissApi.scala b/modules/swiss/src/main/SwissApi.scala index 74eb5a8633457..c825aedd70b90 100644 --- a/modules/swiss/src/main/SwissApi.scala +++ b/modules/swiss/src/main/SwissApi.scala @@ -683,6 +683,20 @@ final class SwissApi( def idNames(ids: List[SwissId]): Fu[List[IdName]] = mongo.swiss.find($inIds(ids), idNameProjection.some).cursor[IdName]().listAll() + def onUserDelete(u: UserId) = for + _ <- mongo.swiss.update.one($doc("winnerId" -> u), $set("winnerId" -> UserId.ghost), multi = true) + players <- mongo.player.list[SwissPlayer]($doc("u" -> u), _.priTemp) // no index!!! + swissIds = players.map(_.swissId).distinct + // here we use a single ghost ID for all swiss players and pairings, + // because the mapping of swiss player to swiss pairings must be preserved + ghostId = UserId(s"!${scalalib.ThreadLocalRandom.nextString(8)}") + newPlayers = players.map: p => + p.copy(id = SwissPlayer.makeId(p.swissId, ghostId), userId = ghostId) + _ <- mongo.player.delete.one($inIds(players.map(_.id))) + _ <- mongo.player.insert.many(newPlayers) + _ <- mongo.pairing.update.one($inIds(swissIds) ++ $doc("p" -> u), $set("p.$" -> ghostId), multi = true) + yield () + private def Sequencing[A <: Matchable: alleycats.Zero]( id: SwissId )(fetch: SwissId => Fu[Option[Swiss]])(run: Swiss => Fu[A]): Fu[A] = diff --git a/modules/team/src/main/TeamApi.scala b/modules/team/src/main/TeamApi.scala index 7c6e35fe73059..91fbb1499d969 100644 --- a/modules/team/src/main/TeamApi.scala +++ b/modules/team/src/main/TeamApi.scala @@ -27,7 +27,7 @@ final class TeamApi( import BSONHandlers.given - export teamRepo.filterHideForum + export teamRepo.{ filterHideForum, onUserDelete } def team(id: TeamId) = teamRepo.byId(id) diff --git a/modules/team/src/main/TeamRepo.scala b/modules/team/src/main/TeamRepo.scala index 3f2bba27a714c..3674f392fbcff 100644 --- a/modules/team/src/main/TeamRepo.scala +++ b/modules/team/src/main/TeamRepo.scala @@ -79,6 +79,11 @@ final class TeamRepo(val coll: Coll)(using Executor): coll.secondaryPreferred .distinctEasy[TeamId, Set]("_id", $inIds(ids) ++ $doc("forum".$ne(Access.Everyone))) + def onUserDelete(userId: UserId): Funit = for + _ <- coll.update.one($doc("createdBy" -> userId), $set("createdBy" -> UserId.ghost)) + _ <- coll.update.one($doc("leaders" -> userId), $pull("leaders" -> userId)) + yield () + private[team] val enabledSelect = $doc("enabled" -> true) private[team] val sortPopular = $sort.desc("nbMembers") diff --git a/modules/user/src/main/NoteApi.scala b/modules/user/src/main/NoteApi.scala index ae7996a54db8a..370e05b7ff26d 100644 --- a/modules/user/src/main/NoteApi.scala +++ b/modules/user/src/main/NoteApi.scala @@ -28,7 +28,8 @@ final class NoteApi(coll: Coll)(using Executor) extends lila.core.user.NoteApi: lila.common.Bus.sub[lila.core.user.UserDelete]: del => for _ <- coll.delete.one($doc("from" -> del.id)) // no index, expensive! - _ <- coll.delete.one($doc("to" -> del.id)) + maybeKeepModNotes = del.user.marks.dirty.so($doc("mod" -> false)) + _ <- coll.delete.one($doc("to" -> del.id) ++ maybeKeepModNotes) yield () def getForMyPermissions(user: User, max: Max = Max(30))(using me: Me): Fu[List[Note]] = From dfe1ac04593ee951b1eaf43079fc78db1a1b601c Mon Sep 17 00:00:00 2001 From: Thibault Duplessis Date: Thu, 16 Jan 2025 21:45:37 +0100 Subject: [PATCH 14/24] account termination WIP --- modules/api/src/main/AccountTermination.scala | 14 +++++--- .../tournament/src/main/LeaderboardApi.scala | 10 +++--- modules/tournament/src/main/PairingRepo.scala | 3 ++ modules/tournament/src/main/PlayerRepo.scala | 3 ++ .../tournament/src/main/TournamentApi.scala | 15 ++++++++ .../tournament/src/main/TournamentRepo.scala | 5 +++ modules/ublog/src/main/UblogApi.scala | 5 +++ modules/user/src/main/TrophyApi.scala | 3 ++ modules/user/src/main/UserRepo.scala | 35 +++++++++++++++++++ 9 files changed, 83 insertions(+), 10 deletions(-) diff --git a/modules/api/src/main/AccountTermination.scala b/modules/api/src/main/AccountTermination.scala index 521a43b01f89e..71897e5eb8a78 100644 --- a/modules/api/src/main/AccountTermination.scala +++ b/modules/api/src/main/AccountTermination.scala @@ -76,8 +76,8 @@ final class AccountTermination( selfClose = me.is(u) teacherClose = !selfClose && !Granter(_.CloseAccount) && Granter(_.Teacher) modClose = !selfClose && Granter(_.CloseAccount) - tos = u.lameOrTroll || u.marks.alt || modClose - _ <- userRepo.disable(u, keepEmail = tos || playbanned) + tos = u.marks.dirty || modClose || playbanned + _ <- userRepo.disable(u, keepEmail = tos) _ <- roundApi.resignAllGamesOf(u.id) _ <- relationApi.unfollowAll(u.id) _ <- relationApi.removeAllFollowers(u.id) @@ -133,18 +133,22 @@ final class AccountTermination( else doDeleteNow(user, del).inject(user.some) private def doDeleteNow(u: User, del: UserDelete): Funit = for - _ <- activityWrite.deleteAll(u) - tos = u.lameOrTroll || u.marks.alt + playbanned <- playbanApi.hasCurrentPlayban(u.id) + closedByMod <- modLogApi.closedByMod(u) + tos = u.marks.dirty || closedByMod || playbanned + _ <- if tos then userRepo.deleteWithTosViolation(u) else userRepo.deleteFully(u) + _ <- activityWrite.deleteAll(u) singlePlayerGameIds <- gameRepo.deleteAllSinglePlayerOf(u.id) _ <- analysisRepo.remove(singlePlayerGameIds) _ <- deleteAllGameChats(u) _ <- streamerApi.delete(u) _ <- swissApi.onUserDelete(u.id) _ <- teamApi.onUserDelete(u.id) + _ <- ublogApi.onAccountDelete(u) _ <- u.marks.clean.so: securityStore.deleteAllSessionsOf(u.id) yield - // a lot of work is done by modules listening to the following event: + // a lot of deletion is done by modules listening to the following event: Bus.pub(lila.core.user.UserDelete(u, del.erase)) private def deleteAllGameChats(u: User) = gameRepo diff --git a/modules/tournament/src/main/LeaderboardApi.scala b/modules/tournament/src/main/LeaderboardApi.scala index 0b725b075e6fd..0d15743997c95 100644 --- a/modules/tournament/src/main/LeaderboardApi.scala +++ b/modules/tournament/src/main/LeaderboardApi.scala @@ -69,19 +69,19 @@ final class LeaderboardApi( yield entries.map(_.tourId) def byPlayerStream( - user: User, + userId: UserId, withPerformance: Boolean, perSecond: MaxPerSecond, nb: Int ): Source[TourEntry, ?] = repo.coll .aggregateWith[Bdoc](): fw => - aggregateByPlayer(user, fw, fw.Descending("d"), withPerformance, nb, offset = 0).toList + aggregateByPlayer(userId, fw, fw.Descending("d"), withPerformance, nb, offset = 0).toList .documentSource() .mapConcat(readTourEntry) private def aggregateByPlayer( - user: User, + userId: UserId, framework: repo.coll.AggregationFramework.type, sort: framework.SortOrder, withPerformance: Boolean, @@ -91,7 +91,7 @@ final class LeaderboardApi( import framework.* NonEmptyList .of( - Match($doc("u" -> user.id)), + Match($doc("u" -> userId)), Sort(sort), Skip(offset), Limit(nb), @@ -132,7 +132,7 @@ final class LeaderboardApi( .aggregateList(length, _.sec): framework => import framework.* val sort = if sortBest then framework.Ascending("w") else framework.Descending("d") - val pipe = aggregateByPlayer(user, framework, sort, false, length, offset) + val pipe = aggregateByPlayer(user.id, framework, sort, false, length, offset) pipe.head -> pipe.tail .map(_.flatMap(readTourEntry)) ) diff --git a/modules/tournament/src/main/PairingRepo.scala b/modules/tournament/src/main/PairingRepo.scala index 54fa1876a30d5..832fafdbfb6a9 100644 --- a/modules/tournament/src/main/PairingRepo.scala +++ b/modules/tournament/src/main/PairingRepo.scala @@ -225,3 +225,6 @@ final class PairingRepo(coll: Coll)(using Executor, Materializer): "b2" -> SumField("b2") ) ) + + private[tournament] def anonymize(tourId: TourId, userId: UserId)(ghostId: UserId) = + coll.update.one($doc("tid" -> tourId, "u" -> userId), $set("u.$" -> ghostId)).void diff --git a/modules/tournament/src/main/PlayerRepo.scala b/modules/tournament/src/main/PlayerRepo.scala index 68d4b42df30a8..5db4caa6992da 100644 --- a/modules/tournament/src/main/PlayerRepo.scala +++ b/modules/tournament/src/main/PlayerRepo.scala @@ -335,3 +335,6 @@ final class PlayerRepo(private[tournament] val coll: Coll)(using Executor): .sort($sort.desc("m")) .batchSize(batchSize) .cursor[Player](readPref) + + private[tournament] def anonymize(tourId: TourId, userId: UserId)(ghostId: UserId) = + coll.update.one($doc("tid" -> tourId, "uid" -> userId), $set("uid" -> ghostId)).void diff --git a/modules/tournament/src/main/TournamentApi.scala b/modules/tournament/src/main/TournamentApi.scala index afa8f41a93877..af4e7826c38fb 100644 --- a/modules/tournament/src/main/TournamentApi.scala +++ b/modules/tournament/src/main/TournamentApi.scala @@ -702,6 +702,21 @@ final class TournamentApi( } else tournamentRepo.setSchedule(tourId, none) + def onUserDelete(u: UserId) = + leaderboard + .byPlayerStream(u, withPerformance = false, perSecond = MaxPerSecond(100), nb = Int.MaxValue) + .mapAsync(1): result => + import result.tour + for + _ <- tournamentRepo.anonymize(tour, u) + // here we use a single ghost ID for all arena players and pairings, + // because the mapping of arena player to arena pairings must be preserved + ghostId = UserId(s"!${scalalib.ThreadLocalRandom.nextString(8)}") + _ <- playerRepo.anonymize(tour.id, u)(ghostId) + _ <- pairingRepo.anonymize(tour.id, u)(ghostId) + yield () + .runWith(Sink.ignore) + private def playerPovs(tour: Tournament, userId: UserId, nb: Int): Fu[List[LightPov]] = pairingRepo.recentIdsByTourAndUserId(tour.id, userId, nb).flatMap(gameRepo.light.gamesFromPrimary).map { _.flatMap { LightPov(_, userId) } diff --git a/modules/tournament/src/main/TournamentRepo.scala b/modules/tournament/src/main/TournamentRepo.scala index b4de5214e2b1b..ba79595bd62f7 100644 --- a/modules/tournament/src/main/TournamentRepo.scala +++ b/modules/tournament/src/main/TournamentRepo.scala @@ -325,6 +325,11 @@ final class TournamentRepo(val coll: Coll, playerCollName: CollName)(using Execu .cursor[Tournament](ReadPref.sec) .list(500) + def anonymize(tour: Tournament, u: UserId) = for + _ <- tour.winnerId.has(u).so(coll.updateField($id(tour.id), "winner", UserId.ghost).void) + _ <- tour.createdBy.is(u).so(coll.updateField($id(tour.id), "createdBy", UserId.ghost).void) + yield () + private[tournament] def sortedCursor( owner: User, status: List[Status], diff --git a/modules/ublog/src/main/UblogApi.scala b/modules/ublog/src/main/UblogApi.scala index 813f5b72750f3..5c73b9b63f776 100644 --- a/modules/ublog/src/main/UblogApi.scala +++ b/modules/ublog/src/main/UblogApi.scala @@ -177,6 +177,11 @@ final class UblogApi( _ <- blog.filter(_.visible).so(b => setTier(b.id, UblogRank.Tier.HIDDEN)) yield () + def onAccountDelete(user: User) = for + _ <- colls.blog.delete.one($id(UblogBlog.Id.User(user.id))) + _ <- colls.post.delete.one($doc("blog" -> UblogBlog.Id.User(user.id))) + yield () + def postCursor(user: User): AkkaStreamCursor[UblogPost] = colls.post.find($doc("blog" -> s"user:${user.id}")).cursor[UblogPost](ReadPref.priTemp) diff --git a/modules/user/src/main/TrophyApi.scala b/modules/user/src/main/TrophyApi.scala index 815b5d6d12c16..60ad9188f7fe2 100644 --- a/modules/user/src/main/TrophyApi.scala +++ b/modules/user/src/main/TrophyApi.scala @@ -29,6 +29,9 @@ final class TrophyApi( private given BSONHandler[TrophyKind] = BSONStringHandler.as[TrophyKind](kindCache.sync, _._id) private given BSONDocumentHandler[Trophy] = Macros.handler[Trophy] + lila.common.Bus.sub[lila.core.user.UserDelete]: del => + coll.delete.one($doc("user" -> del.id)) + def findByUser(user: User, max: Int = 50): Fu[List[Trophy]] = coll.list[Trophy]($doc("user" -> user.id), max).map(_.filter(_.kind != TrophyKind.Unknown)) diff --git a/modules/user/src/main/UserRepo.scala b/modules/user/src/main/UserRepo.scala index 55d9f7f61cd2c..3d26852634c39 100644 --- a/modules/user/src/main/UserRepo.scala +++ b/modules/user/src/main/UserRepo.scala @@ -349,6 +349,41 @@ final class UserRepo(c: Coll)(using Executor) extends lila.core.user.UserRepo(c) ) .void + def deleteWithTosViolation(user: User) = + import F.* + coll.update.one( + $id(user.id), + $unset( + profile, + roles, + toints, + "time", + kid, + lang, + title, + plan, + totpSecret, + changedCase, + blind, + salt, + bpass, + "mustConfirmEmail", + colorIt + ) ++ $set(s"${F.delete}.done" -> true) + ) + + def deleteFully(user: User) = for + lockEmail <- emailOrPrevious(user.id) + _ <- coll.update.one( + $id(user.id), + $doc( + "prevEmail" -> lockEmail, + "createdAt" -> user.createdAt, + s"${F.delete}.done" -> true + ) + ) + yield () + def findNextToDelete(delay: FiniteDuration): Fu[Option[(User, UserDelete)]] = coll .find: From 80a9c756a7406c3ffd681dfa8b81a87a237529e1 Mon Sep 17 00:00:00 2001 From: Thibault Duplessis Date: Fri, 17 Jan 2025 09:20:55 +0100 Subject: [PATCH 15/24] fix compilation --- app/controllers/UserTournament.scala | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/controllers/UserTournament.scala b/app/controllers/UserTournament.scala index 15849e296459a..6e1d7a2ace054 100644 --- a/app/controllers/UserTournament.scala +++ b/app/controllers/UserTournament.scala @@ -46,7 +46,7 @@ final class UserTournament(env: Env, apiC: => Api) extends LilaController(env): apiC.jsonDownload: env.tournament.leaderboardApi .byPlayerStream( - user, + user.id, withPerformance = getBool("performance"), maxPerSecond(name), getInt("nb") | Int.MaxValue From 491241f122840d2f869241370e8099a965271440 Mon Sep 17 00:00:00 2001 From: Thibault Duplessis Date: Fri, 17 Jan 2025 10:37:19 +0100 Subject: [PATCH 16/24] account deletion UI --- app/controllers/Account.scala | 9 ++- app/views/user/show/page.scala | 2 +- conf/routes | 5 +- modules/api/src/main/AccountTermination.scala | 20 +++--- .../main/lilaism/LilaLibraryExtensions.scala | 1 + modules/pref/src/main/ui/AccountPages.scala | 28 ++++++-- .../security/src/main/GarbageCollector.scala | 23 ++++--- modules/security/src/main/SecurityForm.scala | 16 ++++- modules/ui/src/main/helper/Form3.scala | 4 +- modules/user/src/main/UserRepo.scala | 65 ++++++++++--------- 10 files changed, 103 insertions(+), 70 deletions(-) diff --git a/app/controllers/Account.scala b/app/controllers/Account.scala index 73fd6b4584b46..9bfa99f434997 100644 --- a/app/controllers/Account.scala +++ b/app/controllers/Account.scala @@ -271,9 +271,14 @@ final class Account( env.security.forms.deleteAccount.flatMap: form => FormFuResult(form)(err => renderPage(pages.delete(err, managed = false))): _ => env.api.accountTermination - .disable(me.value) + .scheduleDelete(me.value) .inject: - Redirect(routes.User.show(me.username)).withCookies(env.security.lilaCookie.newSession) + Redirect(routes.Account.deleteDone).withCookies(env.security.lilaCookie.newSession) + } + + def deleteDone = Open { ctx ?=> + if ctx.isAuth then Redirect(routes.Lobby.home) + else FoundPage(env.cms.renderKey("delete-done"))(views.site.page.lone) } def kid = Auth { _ ?=> me ?=> diff --git a/app/views/user/show/page.scala b/app/views/user/show/page.scala index 6c9d0ee0bd108..06f154e60171d 100644 --- a/app/views/user/show/page.scala +++ b/app/views/user/show/page.scala @@ -88,7 +88,7 @@ object page: h1(cls := "box__top")("No such player"), div( p("This username doesn't match any Lichess player."), - (!canCreate).option(p("It cannot be used to create a new account.")) + canCreate.not.option(p("It cannot be used to create a new account.")) ) ) diff --git a/conf/routes b/conf/routes index ee0ba717d7b21..0b071065de6d7 100644 --- a/conf/routes +++ b/conf/routes @@ -774,9 +774,10 @@ POST /account/email controllers.Account.emailApply GET /contact/email-confirm/help controllers.Account.emailConfirmHelp GET /account/email/confirm/:token controllers.Account.emailConfirm(token) GET /account/close controllers.Account.close -POST /account/closeConfirm controllers.Account.closeConfirm +POST /account/close controllers.Account.closeConfirm GET /account/delete controllers.Account.delete -POST /account/deleteConfirm controllers.Account.deleteConfirm +POST /account/delete controllers.Account.deleteConfirm +GET /account/delete/done controllers.Account.deleteDone GET /account/profile controllers.Account.profile POST /account/profile controllers.Account.profileApply GET /account/username controllers.Account.username diff --git a/modules/api/src/main/AccountTermination.scala b/modules/api/src/main/AccountTermination.scala index 71897e5eb8a78..f51e2f60ba80c 100644 --- a/modules/api/src/main/AccountTermination.scala +++ b/modules/api/src/main/AccountTermination.scala @@ -104,16 +104,16 @@ final class AccountTermination( relationApi.fetchFollowing(u.id).flatMap(activityWrite.unfollowAll(u, _)) yield Bus.publish(lila.core.security.CloseAccount(u.id), "accountClose") - def scheduleDelete(u: User): Funit = for - _ <- disable(u)(using Me(u)) + def scheduleDelete(u: User)(using Me): Funit = for + _ <- disable(u) _ <- email.delete(u) - _ <- userRepo.scheduleDelete(u.id, UserDelete(nowInstant, erase = false).some) + _ <- userRepo.delete.schedule(u.id, UserDelete(nowInstant, erase = false).some) yield () - def scheduleErase(u: User): Funit = for - _ <- disable(u)(using Me(u)) + def scheduleErase(u: User)(using Me): Funit = for + _ <- disable(u) _ <- email.gdprErase(u) - _ <- userRepo.scheduleDelete(u.id, UserDelete(nowInstant, erase = true).some) + _ <- userRepo.delete.schedule(u.id, UserDelete(nowInstant, erase = true).some) yield () private def lichessDisable(userId: UserId) = @@ -125,18 +125,18 @@ final class AccountTermination( timeout = _.AtMost(1.minute), initialDelay = _.Delay(111.seconds) ): - userRepo - .findNextToDelete(7.days) + userRepo.delete + .findNextScheduled(7.days) .flatMapz: (user, del) => if user.enabled.yes - then userRepo.scheduleDelete(user.id, none).inject(none) + then userRepo.delete.schedule(user.id, none).inject(none) else doDeleteNow(user, del).inject(user.some) private def doDeleteNow(u: User, del: UserDelete): Funit = for playbanned <- playbanApi.hasCurrentPlayban(u.id) closedByMod <- modLogApi.closedByMod(u) tos = u.marks.dirty || closedByMod || playbanned - _ <- if tos then userRepo.deleteWithTosViolation(u) else userRepo.deleteFully(u) + _ <- if tos then userRepo.delete.nowWithTosViolation(u) else userRepo.delete.nowFully(u) _ <- activityWrite.deleteAll(u) singlePlayerGameIds <- gameRepo.deleteAllSinglePlayerOf(u.id) _ <- analysisRepo.remove(singlePlayerGameIds) diff --git a/modules/core/src/main/lilaism/LilaLibraryExtensions.scala b/modules/core/src/main/lilaism/LilaLibraryExtensions.scala index 617c0e5124a97..4b1cfa3b88148 100644 --- a/modules/core/src/main/lilaism/LilaLibraryExtensions.scala +++ b/modules/core/src/main/lilaism/LilaLibraryExtensions.scala @@ -38,6 +38,7 @@ trait LilaLibraryExtensions extends CoreExports: def err(message: => String): A = self.getOrElse(sys.error(message)) extension (self: Boolean) + def not: Boolean = !self // move to scalalib? generalize Future away? def soFu[B](f: => Future[B]): Future[Option[B]] = if self then f.map(Some(_))(scala.concurrent.ExecutionContext.parasitic) diff --git a/modules/pref/src/main/ui/AccountPages.scala b/modules/pref/src/main/ui/AccountPages.scala index ca51da764caf7..ec006bfeb1580 100644 --- a/modules/pref/src/main/ui/AccountPages.scala +++ b/modules/pref/src/main/ui/AccountPages.scala @@ -12,6 +12,14 @@ final class AccountPages(helpers: Helpers, ui: AccountUi, flagApi: lila.core.use import trans.settings as trs import ui.AccountPage + private def myUsernamePasswordFields(form: Form[?])(using Context) = + form3.split( + form3.group(form("username"), trans.site.username(), half = true)( + form3.input(_)(required, autocomplete := "off") + ), + form3.passwordModified(form("passwd"), trans.site.password(), half = true)() + ) + def close(form: Form[?], managed: Boolean)(using Context)(using me: Me) = AccountPage(s"${me.username} - ${trans.settings.closeAccount.txt()}", "close"): div(cls := "box box-pad")( @@ -22,7 +30,7 @@ final class AccountPages(helpers: Helpers, ui: AccountUi, flagApi: lila.core.use div(cls := "form-group")(h2("We're sorry to see you go.")), div(cls := "form-group")(trs.closeAccountExplanation()), div(cls := "form-group")(trs.cantOpenSimilarAccount()), - form3.passwordModified(form("passwd"), trans.site.password())(autofocus, autocomplete := "off"), + myUsernamePasswordFields(form), form3.actions( frag( a(href := routes.User.show(me.username))(trs.changedMindDoNotCloseAccount()), @@ -45,9 +53,17 @@ final class AccountPages(helpers: Helpers, ui: AccountUi, flagApi: lila.core.use postForm(cls := "form3", action := routes.Account.deleteConfirm)( div(cls := "form-group")(h2("We're sorry to see you go.")), div(cls := "form-group")( - "Once you delete your account, your profile and username are permanently removed from Lichess and your posts, comments, and game are disassociated (not deleted) from your account." + "Once you delete your account, it’s removed from Lichess and our administrators won’t be able to bring it back for you." ), - form3.passwordModified(form("passwd"), trans.site.password())(autofocus, autocomplete := "off"), + div(cls := "form-group")( + "The username will NOT be available for registration again." + ), + div(cls := "form-group")( + "Would you like to ", + a(href := routes.Account.close)("close your account"), + " instead?" + ), + myUsernamePasswordFields(form), form3.checkbox(form("understand"), "I understand that deleted accounts aren't recoverable"), form3.errors(form("understand")), form3.actions( @@ -56,7 +72,7 @@ final class AccountPages(helpers: Helpers, ui: AccountUi, flagApi: lila.core.use form3.submit( "Delete my account", icon = Icon.CautionCircle.some, - confirm = trs.closingIsDefinitive.txt().some + confirm = "Deleting is definitive, there is no going back. Are you sure?".some )(cls := "button-red") ) ) @@ -220,9 +236,7 @@ final class AccountPages(helpers: Helpers, ui: AccountUi, flagApi: lila.core.use form("username"), trans.site.username(), help = trans.site.changeUsernameDescription().some - )( - form3.input(_)(autofocus, required, autocomplete := "username") - ), + )(form3.input(_)(autofocus, required, autocomplete := "username")), form3.action(form3.submit(trans.site.apply())) ) ) diff --git a/modules/security/src/main/GarbageCollector.scala b/modules/security/src/main/GarbageCollector.scala index b1c37d18b3455..72abdaae2ee74 100644 --- a/modules/security/src/main/GarbageCollector.scala +++ b/modules/security/src/main/GarbageCollector.scala @@ -93,18 +93,17 @@ final class GarbageCollector( private def collect(user: User, email: EmailAddress, msg: => String, quickly: Boolean): Funit = justOnce(user.id).so: - hasBeenCollectedBefore(user).not.map { - if _ then - val armed = isArmed() - val wait = if quickly then 3.seconds else (30 + ThreadLocalRandom.nextInt(240)).seconds - val message = - s"Will dispose of https://lichess.org/${user.username} in $wait. Email: ${email.value}. $msg${(!armed) - .so(" [SIMULATION]")}" - logger.info(message) - noteApi.lichessWrite(user, s"Garbage collected because of $msg") - if armed then - scheduler.scheduleOnce(wait): - doCollect(user.id) + hasBeenCollectedBefore(user).not.mapz { + val armed = isArmed() + val wait = if quickly then 3.seconds else (30 + ThreadLocalRandom.nextInt(240)).seconds + val message = + s"Will dispose of https://lichess.org/${user.username} in $wait. Email: ${email.value}. $msg${(!armed) + .so(" [SIMULATION]")}" + logger.info(message) + noteApi.lichessWrite(user, s"Garbage collected because of $msg") + if armed then + scheduler.scheduleOnce(wait): + doCollect(user.id) } private def hasBeenCollectedBefore(user: User): Fu[Boolean] = diff --git a/modules/security/src/main/SecurityForm.scala b/modules/security/src/main/SecurityForm.scala index f860e62096a6f..3ca2cab69e567 100644 --- a/modules/security/src/main/SecurityForm.scala +++ b/modules/security/src/main/SecurityForm.scala @@ -26,6 +26,9 @@ final class SecurityForm( private def newPasswordFieldForMe(using me: Me) = newPasswordField.verifying(PasswordCheck.sameConstraint(me.username.into(UserStr))) + def myUsernameField(using me: Me) = + LilaForm.cleanNonEmptyText.into[UserStr].verifying("Please log into your account first.", _.is(me)) + private val anyEmail: Mapping[EmailAddress] = LilaForm .cleanNonEmptyText(minLength = 6, maxLength = EmailAddress.maxLength) @@ -199,7 +202,13 @@ final class SecurityForm( authenticator.loginCandidate.map: candidate => Form(single("passwd" -> passwordMapping(candidate))) - def closeAccount(using Me) = passwordProtected + def closeAccount(using Me) = + authenticator.loginCandidate.map: candidate => + Form: + mapping( + "username" -> myUsernameField, + "passwd" -> passwordMapping(candidate) + )((_, _) => ())(_ => None) def toggleKid(using Me) = passwordProtected @@ -211,13 +220,14 @@ final class SecurityForm( )(Reopen.apply)(_ => None) ) - def deleteAccount(using Me) = + def deleteAccount(using me: Me) = authenticator.loginCandidate.map: candidate => Form: mapping( + "username" -> myUsernameField, "passwd" -> passwordMapping(candidate), "understand" -> boolean.verifying("It's an important point.", identity[Boolean]) - )((pass, _) => pass)(_ => None) + )((_, _, _) => ())(_ => None) private def passwordMapping(candidate: LoginCandidate) = text.verifying("incorrectPassword", p => candidate.check(ClearPassword(p))) diff --git a/modules/ui/src/main/helper/Form3.scala b/modules/ui/src/main/helper/Form3.scala index 998e42862a767..68a292bffb965 100644 --- a/modules/ui/src/main/helper/Form3.scala +++ b/modules/ui/src/main/helper/Form3.scala @@ -197,10 +197,10 @@ final class Form3(formHelper: FormHelper & I18nHelper & AssetHelper, flairApi: F // allows disabling of a field that defaults to true def hiddenFalse(field: Field): Tag = hidden(field, "false".some) - def passwordModified(field: Field, content: Frag, reveal: Boolean = true)( + def passwordModified(field: Field, content: Frag, reveal: Boolean = true, half: Boolean = false)( modifiers: Modifier* )(using Translate): Frag = - group(field, content): f => + group(field, content, half = half): f => div(cls := "password-wrapper")( input(f, typ = "password")(required)(modifiers), reveal.option(button(cls := "password-reveal", tpe := "button", dataIcon := Icon.Eye)) diff --git a/modules/user/src/main/UserRepo.scala b/modules/user/src/main/UserRepo.scala index 3d26852634c39..767e869c86f5d 100644 --- a/modules/user/src/main/UserRepo.scala +++ b/modules/user/src/main/UserRepo.scala @@ -349,11 +349,11 @@ final class UserRepo(c: Coll)(using Executor) extends lila.core.user.UserRepo(c) ) .void - def deleteWithTosViolation(user: User) = - import F.* - coll.update.one( - $id(user.id), - $unset( + object delete: + + def nowWithTosViolation(user: User) = + import F.* + val fields = List( profile, roles, toints, @@ -369,35 +369,38 @@ final class UserRepo(c: Coll)(using Executor) extends lila.core.user.UserRepo(c) bpass, "mustConfirmEmail", colorIt - ) ++ $set(s"${F.delete}.done" -> true) - ) - - def deleteFully(user: User) = for - lockEmail <- emailOrPrevious(user.id) - _ <- coll.update.one( - $id(user.id), - $doc( - "prevEmail" -> lockEmail, - "createdAt" -> user.createdAt, - s"${F.delete}.done" -> true ) - ) - yield () + coll.update.one( + $id(user.id), + $unset(fields) ++ $set(s"${F.delete}.done" -> true) + ) - def findNextToDelete(delay: FiniteDuration): Fu[Option[(User, UserDelete)]] = - coll - .find: - $doc( // hits the delete.scheduled_1 index - s"${F.delete}.scheduled".$lt(nowInstant.minusMillis(delay.toMillis)), - s"${F.delete}.done" -> false + def nowFully(user: User) = for + lockEmail <- emailOrPrevious(user.id) + _ <- coll.update.one( + $id(user.id), + $doc( + "prevEmail" -> lockEmail, + "createdAt" -> user.createdAt, + s"${F.delete}.done" -> true ) - .sort($doc(s"${F.delete}.scheduled" -> 1)) - .one[User] - .flatMapz: user => - coll.primitiveOne[UserDelete]($id(user.id), F.delete).mapz(delete => (user -> delete).some) - - def scheduleDelete(userId: UserId, delete: Option[UserDelete]): Funit = - coll.updateOrUnsetField($id(userId), F.delete, delete).void + ) + yield () + + def findNextScheduled(delay: FiniteDuration): Fu[Option[(User, UserDelete)]] = + coll + .find: + $doc( // hits the delete.scheduled_1 index + s"${F.delete}.scheduled".$lt(nowInstant.minusMillis(delay.toMillis)), + s"${F.delete}.done" -> false + ) + .sort($doc(s"${F.delete}.scheduled" -> 1)) + .one[User] + .flatMapz: user => + coll.primitiveOne[UserDelete]($id(user.id), F.delete).mapz(delete => (user -> delete).some) + + def schedule(userId: UserId, delete: Option[UserDelete]): Funit = + coll.updateOrUnsetField($id(userId), F.delete, delete).void def getPasswordHash(id: UserId): Fu[Option[String]] = coll.byId[AuthData](id, AuthData.projection).map2(_.bpass.bytes.sha512.hex) From 091923b686bdc12e649bdaadd80ea177c79db764 Mon Sep 17 00:00:00 2001 From: Thibault Duplessis Date: Fri, 17 Jan 2025 10:51:15 +0100 Subject: [PATCH 17/24] only allow missing important indexes when gdpr erasure is requested --- modules/api/src/main/AccountTermination.scala | 2 +- modules/swiss/src/main/SwissApi.scala | 9 +++++++-- modules/user/src/main/NoteApi.scala | 3 ++- 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/modules/api/src/main/AccountTermination.scala b/modules/api/src/main/AccountTermination.scala index f51e2f60ba80c..cfbe6ae82e487 100644 --- a/modules/api/src/main/AccountTermination.scala +++ b/modules/api/src/main/AccountTermination.scala @@ -142,7 +142,7 @@ final class AccountTermination( _ <- analysisRepo.remove(singlePlayerGameIds) _ <- deleteAllGameChats(u) _ <- streamerApi.delete(u) - _ <- swissApi.onUserDelete(u.id) + _ <- del.erase.so(swissApi.onUserErase(u.id)) _ <- teamApi.onUserDelete(u.id) _ <- ublogApi.onAccountDelete(u) _ <- u.marks.clean.so: diff --git a/modules/swiss/src/main/SwissApi.scala b/modules/swiss/src/main/SwissApi.scala index c825aedd70b90..0e6aef1d6aea3 100644 --- a/modules/swiss/src/main/SwissApi.scala +++ b/modules/swiss/src/main/SwissApi.scala @@ -683,8 +683,13 @@ final class SwissApi( def idNames(ids: List[SwissId]): Fu[List[IdName]] = mongo.swiss.find($inIds(ids), idNameProjection.some).cursor[IdName]().listAll() - def onUserDelete(u: UserId) = for - _ <- mongo.swiss.update.one($doc("winnerId" -> u), $set("winnerId" -> UserId.ghost), multi = true) + // very expensive, misses several indexes + def onUserErase(u: UserId) = for + _ <- mongo.swiss.update.one( + $doc("winnerId" -> u), + $set("winnerId" -> UserId.ghost), + multi = true + ) // no index!!! players <- mongo.player.list[SwissPlayer]($doc("u" -> u), _.priTemp) // no index!!! swissIds = players.map(_.swissId).distinct // here we use a single ghost ID for all swiss players and pairings, diff --git a/modules/user/src/main/NoteApi.scala b/modules/user/src/main/NoteApi.scala index 370e05b7ff26d..04dda802a8f02 100644 --- a/modules/user/src/main/NoteApi.scala +++ b/modules/user/src/main/NoteApi.scala @@ -27,7 +27,8 @@ final class NoteApi(coll: Coll)(using Executor) extends lila.core.user.NoteApi: lila.common.Bus.sub[lila.core.user.UserDelete]: del => for - _ <- coll.delete.one($doc("from" -> del.id)) // no index, expensive! + _ <- del.erase.so: + coll.delete.one($doc("from" -> del.id)).void // no index, expensive! maybeKeepModNotes = del.user.marks.dirty.so($doc("mod" -> false)) _ <- coll.delete.one($doc("to" -> del.id) ++ maybeKeepModNotes) yield () From ec6f47ba6d21a8c016d3f495a30ecb1e4e2beed7 Mon Sep 17 00:00:00 2001 From: Thibault Duplessis Date: Fri, 17 Jan 2025 13:16:56 +0100 Subject: [PATCH 18/24] detect weird UAs that probably come from randomizers --- modules/common/src/main/HTTPRequest.scala | 2 - modules/security/src/main/Signup.scala | 2 +- .../security/src/main/UserAgentParser.scala | 26 ++++++++++ .../security/src/main/UserAgentTrust.scala | 29 +++++++++++ .../src/test/UserAgentTrustTest.scala | 49 +++++++++++++++++++ 5 files changed, 105 insertions(+), 3 deletions(-) create mode 100644 modules/security/src/main/UserAgentTrust.scala create mode 100644 modules/security/src/test/UserAgentTrustTest.scala diff --git a/modules/common/src/main/HTTPRequest.scala b/modules/common/src/main/HTTPRequest.scala index cc80f7938cc53..8e83449dadb00 100644 --- a/modules/common/src/main/HTTPRequest.scala +++ b/modules/common/src/main/HTTPRequest.scala @@ -86,8 +86,6 @@ object HTTPRequest: def hasFileExtension(req: RequestHeader) = fileExtensionRegex.find(req.path) - def weirdUA(req: RequestHeader) = userAgent(req).forall(_.value.lengthIs < 30) - def print(req: RequestHeader) = s"${printReq(req)} ${printClient(req)}" def printReq(req: RequestHeader) = s"${req.method} ${req.domain}${req.uri}" diff --git a/modules/security/src/main/Signup.scala b/modules/security/src/main/Signup.scala index 02fc1b1af8c7d..549b2d0e17591 100644 --- a/modules/security/src/main/Signup.scala +++ b/modules/security/src/main/Signup.scala @@ -42,7 +42,7 @@ final class Signup( val ip = HTTPRequest.ipAddress(req) store.recentByIpExists(ip, 7.days).flatMap { ipExists => if ipExists then fuccess(YesBecauseIpExists) - else if HTTPRequest.weirdUA(req) then fuccess(YesBecauseUA) + else if UserAgentParser.trust.isSuspicious(req) then fuccess(YesBecauseUA) else print.fold[Fu[MustConfirmEmail]](fuccess(YesBecausePrintMissing)): fp => store diff --git a/modules/security/src/main/UserAgentParser.scala b/modules/security/src/main/UserAgentParser.scala index 6348083743a3f..4157ac3c03a4d 100644 --- a/modules/security/src/main/UserAgentParser.scala +++ b/modules/security/src/main/UserAgentParser.scala @@ -3,6 +3,8 @@ package lila.security import org.uaparser.scala.* import lila.core.net.UserAgent as UA +import play.api.mvc.RequestHeader +import lila.common.HTTPRequest object UserAgentParser: @@ -31,3 +33,27 @@ object UserAgentParser: OS(m.osName, m.osVersion.some), Device(m.device) ) + + object trust: + + def isSuspicious(req: RequestHeader): Boolean = HTTPRequest.userAgent(req).forall(isSuspicious) + + def isSuspicious(ua: UA): Boolean = + ua.value.lengthIs < 30 || !looksNormal(ua) + + private def looksNormal(ua: UA) = + val sections = ua.value.toLowerCase.split(' ') + sections.exists: s => + isRecentChrome(s) || isRecentFirefox(s) || isRecentSafari(s) + + // based on https://caniuse.com/usage-table + private val isRecentChrome = isRecentBrowser("chrome", 109) // also covers Edge and Opera + private val isRecentFirefox = isRecentBrowser("firefox", 128) + private val isRecentSafari = isRecentBrowser("safari", 605) // most safaris also have a chrome/ section + + private def isRecentBrowser(name: String, minVersion: Int): String => Boolean = + val slashed = name + "/" + val prefixLength = slashed.length + (s: String) => + s.startsWith(slashed) && + s.drop(prefixLength).takeWhile(_ != '.').toIntOption.exists(_ >= minVersion) diff --git a/modules/security/src/main/UserAgentTrust.scala b/modules/security/src/main/UserAgentTrust.scala new file mode 100644 index 0000000000000..074947c3956bd --- /dev/null +++ b/modules/security/src/main/UserAgentTrust.scala @@ -0,0 +1,29 @@ +package lila.security + +import play.api.mvc.RequestHeader +import lila.common.HTTPRequest +import lila.core.net.UserAgent + +object UserAgentTrust: + + def isSuspicious(req: RequestHeader): Boolean = HTTPRequest.userAgent(req).forall(isSuspicious) + + def isSuspicious(ua: UserAgent): Boolean = + ua.value.lengthIs < 30 || !looksNormal(ua) + + private def looksNormal(ua: UserAgent) = + val sections = ua.value.toLowerCase.split(' ') + sections.exists: s => + isRecentChrome(s) || isRecentFirefox(s) || isRecentSafari(s) + + // based on https://caniuse.com/usage-table + private val isRecentChrome = isRecentBrowser("chrome", 109) // also covers Edge and Opera + private val isRecentFirefox = isRecentBrowser("firefox", 128) + private val isRecentSafari = isRecentBrowser("safari", 605) // most safaris also have a chrome/ section + + private def isRecentBrowser(name: String, minVersion: Int): String => Boolean = + val slashed = name + "/" + val prefixLength = slashed.length + (s: String) => + s.startsWith(slashed) && + s.drop(prefixLength).takeWhile(_ != '.').toIntOption.exists(_ >= minVersion) diff --git a/modules/security/src/test/UserAgentTrustTest.scala b/modules/security/src/test/UserAgentTrustTest.scala new file mode 100644 index 0000000000000..b7962d6b5bc6a --- /dev/null +++ b/modules/security/src/test/UserAgentTrustTest.scala @@ -0,0 +1,49 @@ +package lila.security + +import lila.core.net.UserAgent + +class UserAgentTrustTest extends munit.FunSuite: + + def susp(ua: String) = UserAgentParser.trust.isSuspicious(UserAgent(ua)) + + test("normal"): + assert: + !susp: + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36" + assert: + !susp: + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36" + assert: + !susp: + "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:133.0) Gecko/20100101 Firefox/133.0" + assert: + !susp: + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0" + assert: + !susp: + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36" + assert: + !susp: + "Mozilla/5.0 (X11; Linux x86_64; rv:133.0) Gecko/20100101 Firefox/133.0" + assert: + !susp: + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.2 Safari/605.1.15" + + test("susp"): + assert: + susp("") + assert: + susp("too short") + assert: + susp("Mozilla/5.0 (X11; U; FreeBSD i386; zh-tw; rv:31.0) Gecko/20100101 Opera/13.0") + assert: + susp: + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/61.0.3163.100 Safari/537.36 OPR/48.0.2685.52" + assert: + susp: + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/51.0.2704.103 Safari/537.36 Vivaldi/1.2.490.43" + assert: + susp("Mozilla/5.0 (Android 6.0.1; Mobile; rv:43.0) Gecko/43.0 Firefox/43.0") + assert: + susp: + "Mozilla/5.0 (Macintosh; Intel Mac OS X 13_0_1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/107.0.0.0 Safari/537.36" From 5ee5c06e8c915068e69c4673348a4b72f31bfa07 Mon Sep 17 00:00:00 2001 From: Thibault Duplessis Date: Fri, 17 Jan 2025 15:42:20 +0100 Subject: [PATCH 19/24] user trust and pool join tweaking --- modules/core/src/main/pool.scala | 2 +- modules/core/src/main/security.scala | 5 +++ modules/lobby/src/main/Env.scala | 1 + modules/lobby/src/main/LobbySocket.scala | 28 +++++++++------ modules/pool/src/main/MatchMaking.scala | 5 ++- modules/pool/src/main/PoolApi.scala | 15 ++++---- modules/rating/src/main/Glicko.scala | 2 +- modules/security/src/main/Env.scala | 2 ++ .../security/src/main/UserAgentParser.scala | 12 ++++--- .../security/src/main/UserAgentTrust.scala | 29 --------------- modules/security/src/main/UserTrust.scala | 35 +++++++++++++++++++ ...stTest.scala => UserAgentParserTest.scala} | 3 ++ 12 files changed, 83 insertions(+), 56 deletions(-) delete mode 100644 modules/security/src/main/UserAgentTrust.scala create mode 100644 modules/security/src/main/UserTrust.scala rename modules/security/src/test/{UserAgentTrustTest.scala => UserAgentParserTest.scala} (91%) diff --git a/modules/core/src/main/pool.scala b/modules/core/src/main/pool.scala index 95ce19389c759..31f596101af74 100644 --- a/modules/core/src/main/pool.scala +++ b/modules/core/src/main/pool.scala @@ -23,7 +23,7 @@ object IsClockCompatible extends FunctionWrapper[IsClockCompatible, Clock.Config case class PoolMember( userId: UserId, - sri: lila.core.socket.Sri, + sri: Sri, rating: IntRating, ratingRange: Option[RatingRange], lame: Boolean, diff --git a/modules/core/src/main/security.scala b/modules/core/src/main/security.scala index 351d9e96643e9..f85b2301cce85 100644 --- a/modules/core/src/main/security.scala +++ b/modules/core/src/main/security.scala @@ -98,3 +98,8 @@ object IsProxy extends OpaqueString[IsProxy]: trait Ip2ProxyApi: def apply(ip: IpAddress): Fu[IsProxy] def keepProxies(ips: Seq[IpAddress]): Fu[Map[IpAddress, String]] + +opaque type UserTrust = Boolean +object UserTrust extends YesNo[UserTrust] +trait UserTrustApi: + def get(id: UserId): Fu[UserTrust] diff --git a/modules/lobby/src/main/Env.scala b/modules/lobby/src/main/Env.scala index 95aeb9c86e431..ab1bb13cf893b 100644 --- a/modules/lobby/src/main/Env.scala +++ b/modules/lobby/src/main/Env.scala @@ -17,6 +17,7 @@ final class Env( newPlayer: lila.core.game.NewPlayer, poolApi: lila.core.pool.PoolApi, cacheApi: lila.memo.CacheApi, + userTrustApi: lila.core.security.UserTrustApi, socketKit: lila.core.socket.SocketKit )(using Executor, diff --git a/modules/lobby/src/main/LobbySocket.scala b/modules/lobby/src/main/LobbySocket.scala index 0fe4fcf6b778f..8f0d94350e423 100644 --- a/modules/lobby/src/main/LobbySocket.scala +++ b/modules/lobby/src/main/LobbySocket.scala @@ -2,11 +2,13 @@ package lila.lobby import play.api.libs.json.* import scalalib.actor.SyncActor +import scalalib.Maths.boxedNormalDistribution import chess.IntRating import lila.common.Json.given import lila.core.game.ChangeFeatured import lila.core.pool.PoolConfigId +import lila.core.security.{ UserTrust, UserTrustApi } import lila.core.socket.{ protocol as P, * } import lila.core.timeline.* import lila.rating.{ Glicko, RatingRange } @@ -20,7 +22,8 @@ final class LobbySocket( socketKit: SocketKit, lobby: LobbySyncActor, relationApi: lila.core.relation.RelationApi, - poolApi: lila.core.pool.PoolApi + poolApi: lila.core.pool.PoolApi, + userTrustApi: UserTrustApi )(using ec: Executor, scheduler: Scheduler)(using lila.core.config.RateLimit): import LobbySocket.* @@ -183,7 +186,7 @@ final class LobbySocket( case ("idle", o) => actor ! SetIdle(member.sri, ~(o.boolean("d"))) // entering a pool case ("poolIn", o) if !member.bot => - HookPoolLimit(member, cost = 1, msg = s"poolIn $o") { + HookPoolLimit(member, cost = 1, msg = s"poolIn $o"): for user <- member.user d <- o.obj("d") @@ -193,23 +196,21 @@ final class LobbySocket( blocking = d.get[UserId]("blocking") yield lobby ! CancelHook(member.sri) // in case there's one... - userApi.glicko(user.id, perfType).foreach { glicko => - val pairingGlicko = glicko | Glicko.pairingDefault + for + glicko <- userApi.glicko(user.id, perfType) + trust <- + if glicko.exists(_.established) then fuccess(UserTrust.Yes) else userTrustApi.get(user.id) + do poolApi.join( PoolConfigId(id), lila.core.pool.Joiner( sri = member.sri, - rating = pairingGlicko.establishedIntRating | IntRating( - scalalib.Maths - .boxedNormalDistribution(pairingGlicko.intRating.value, pairingGlicko.intDeviation, 0.3) - ), + rating = toJoinRating(user, glicko, trust), ratingRange = ratingRange, lame = user.lame, blocking = user.blocking.map(_ ++ blocking) )(using user.id.into(MyId)) ) - } - } // leaving a pool case ("poolOut", o) => HookPoolLimit(member, cost = 1, msg = s"poolOut $o"): @@ -281,6 +282,13 @@ private object LobbySocket: def userId = user.map(_.id) def isAuth = userId.isDefined + def toJoinRating(user: LobbyUser, g: Option[chess.rating.glicko.Glicko], trust: UserTrust) = + val glicko = g | Glicko.pairingDefault + glicko.establishedIntRating | IntRating: + if trust.yes + then boxedNormalDistribution(glicko.intRating.value, glicko.intDeviation, 0.3) + else boxedNormalDistribution(glicko.intRating.value - 200, glicko.intDeviation / 2, 0.3) + object Protocol: object In: case class Counters(members: Int, rounds: Int) extends P.In diff --git a/modules/pool/src/main/MatchMaking.scala b/modules/pool/src/main/MatchMaking.scala index b7b80a8455774..e335e7d3bbb70 100644 --- a/modules/pool/src/main/MatchMaking.scala +++ b/modules/pool/src/main/MatchMaking.scala @@ -21,9 +21,8 @@ object MatchMaking: members .sortBy(_.rating)(using intOrdering[IntRating].reverse) .grouped(2) - .collect { case Vector(p1, p2) => - Couple(p1, p2) - } + .collect: + case Vector(p1, p2) => Couple(p1, p2) .toVector private object wmMatching: diff --git a/modules/pool/src/main/PoolApi.scala b/modules/pool/src/main/PoolApi.scala index accd551ceb566..1a6d3f6622906 100644 --- a/modules/pool/src/main/PoolApi.scala +++ b/modules/pool/src/main/PoolApi.scala @@ -31,14 +31,13 @@ final class PoolApi( .toMap def join(poolId: PoolConfigId, joiner: Joiner): Unit = - HasCurrentPlayban(joiner.me.id) - .foreach: - case false => - actors.foreach: - case (id, actor) if id == poolId => - rageSitOf(joiner.me.id).foreach(actor ! Join(joiner, _)) - case (_, actor) => actor ! Leave(joiner.me) - case _ => + HasCurrentPlayban(joiner.me.id).foreach: + case false => + actors.foreach: + case (id, actor) if id == poolId => + rageSitOf(joiner.me.id).foreach(actor ! Join(joiner, _)) + case (_, actor) => actor ! Leave(joiner.me) + case _ => def leave(poolId: PoolConfigId, userId: UserId) = sendTo(poolId, Leave(userId)) diff --git a/modules/rating/src/main/Glicko.scala b/modules/rating/src/main/Glicko.scala index b57707d9050a9..dd6c25bc4d9af 100644 --- a/modules/rating/src/main/Glicko.scala +++ b/modules/rating/src/main/Glicko.scala @@ -56,7 +56,7 @@ object Glicko: // Virtual rating for first pairing to make the expected score 50% without // actually changing the default rating - val pairingDefault = new Glicko(1460d, maxDeviation, defaultVolatility) + val pairingDefault = new Glicko(1450d, maxDeviation, defaultVolatility) // managed is for students invited to a class val defaultManaged = new Glicko(800d, 400d, defaultVolatility) diff --git a/modules/security/src/main/Env.scala b/modules/security/src/main/Env.scala index 9f87f4a4069d1..bc9457251ffae 100644 --- a/modules/security/src/main/Env.scala +++ b/modules/security/src/main/Env.scala @@ -147,6 +147,8 @@ final class Env( lazy val ipTrust: IpTrust = wire[IpTrust] + lazy val userTrust: UserTrustApi = wire[UserTrustApi] + lazy val pwned: Pwned = Pwned(ws, config.pwnedRangeUrl) lazy val proxy2faSetting: SettingStore[Strings] @@ Proxy2faSetting = settingStore[Strings]( diff --git a/modules/security/src/main/UserAgentParser.scala b/modules/security/src/main/UserAgentParser.scala index 4157ac3c03a4d..e42e06f713ef5 100644 --- a/modules/security/src/main/UserAgentParser.scala +++ b/modules/security/src/main/UserAgentParser.scala @@ -39,17 +39,18 @@ object UserAgentParser: def isSuspicious(req: RequestHeader): Boolean = HTTPRequest.userAgent(req).forall(isSuspicious) def isSuspicious(ua: UA): Boolean = - ua.value.lengthIs < 30 || !looksNormal(ua) + val str = ua.value.take(200).toLowerCase + str.lengthIs < 30 || isMacOsEdge(str) || !looksNormal(str) - private def looksNormal(ua: UA) = - val sections = ua.value.toLowerCase.split(' ') + private def looksNormal(ua: String) = + val sections = ua.split(' ') sections.exists: s => isRecentChrome(s) || isRecentFirefox(s) || isRecentSafari(s) // based on https://caniuse.com/usage-table private val isRecentChrome = isRecentBrowser("chrome", 109) // also covers Edge and Opera private val isRecentFirefox = isRecentBrowser("firefox", 128) - private val isRecentSafari = isRecentBrowser("safari", 605) // most safaris also have a chrome/ section + private val isRecentSafari = isRecentBrowser("safari", 604) // most safaris also have a chrome/ section private def isRecentBrowser(name: String, minVersion: Int): String => Boolean = val slashed = name + "/" @@ -57,3 +58,6 @@ object UserAgentParser: (s: String) => s.startsWith(slashed) && s.drop(prefixLength).takeWhile(_ != '.').toIntOption.exists(_ >= minVersion) + + private def isMacOsEdge(ua: String) = + ua.contains("macintosh") && ua.contains("edg/") diff --git a/modules/security/src/main/UserAgentTrust.scala b/modules/security/src/main/UserAgentTrust.scala deleted file mode 100644 index 074947c3956bd..0000000000000 --- a/modules/security/src/main/UserAgentTrust.scala +++ /dev/null @@ -1,29 +0,0 @@ -package lila.security - -import play.api.mvc.RequestHeader -import lila.common.HTTPRequest -import lila.core.net.UserAgent - -object UserAgentTrust: - - def isSuspicious(req: RequestHeader): Boolean = HTTPRequest.userAgent(req).forall(isSuspicious) - - def isSuspicious(ua: UserAgent): Boolean = - ua.value.lengthIs < 30 || !looksNormal(ua) - - private def looksNormal(ua: UserAgent) = - val sections = ua.value.toLowerCase.split(' ') - sections.exists: s => - isRecentChrome(s) || isRecentFirefox(s) || isRecentSafari(s) - - // based on https://caniuse.com/usage-table - private val isRecentChrome = isRecentBrowser("chrome", 109) // also covers Edge and Opera - private val isRecentFirefox = isRecentBrowser("firefox", 128) - private val isRecentSafari = isRecentBrowser("safari", 605) // most safaris also have a chrome/ section - - private def isRecentBrowser(name: String, minVersion: Int): String => Boolean = - val slashed = name + "/" - val prefixLength = slashed.length - (s: String) => - s.startsWith(slashed) && - s.drop(prefixLength).takeWhile(_ != '.').toIntOption.exists(_ >= minVersion) diff --git a/modules/security/src/main/UserTrust.scala b/modules/security/src/main/UserTrust.scala new file mode 100644 index 0000000000000..e92f76634ab7c --- /dev/null +++ b/modules/security/src/main/UserTrust.scala @@ -0,0 +1,35 @@ +package lila.security + +import lila.user.UserRepo +import lila.core.security.UserTrust + +private final class UserTrustApi( + cacheApi: lila.memo.CacheApi, + ipTrust: IpTrust, + sessionStore: Store, + userRepo: UserRepo +)(using Executor) + extends lila.core.security.UserTrustApi: + + private val cache = cacheApi[UserId, Boolean](16_384, "security.userTrust"): + _.expireAfterWrite(30.minutes).buildAsyncFuture(computeTrust) + + def get(id: UserId): Fu[UserTrust] = UserTrust.from(cache.get(id)) + + private def computeTrust(id: UserId): Fu[Boolean] = + userRepo + .byId(id) + .flatMapz: user => + if user.isVerifiedOrAdmin then fuccess(true) + else if user.hasTitle || user.isPatron then fuccess(true) + else if user.createdSinceDays(30) then fuccess(true) + else if user.count.game > 20 then fuccess(true) + else + sessionStore + .openSessions(id, 3) + .flatMap: sessions => + if sessions.map(_.ua).exists(UserAgentParser.trust.isSuspicious) + then fuccess(false) + else sessions.map(_.ip).existsM(ipTrust.isSuspicious).not + .addEffect: trust => + if !trust then logger.info(s"User $id is not trusted") diff --git a/modules/security/src/test/UserAgentTrustTest.scala b/modules/security/src/test/UserAgentParserTest.scala similarity index 91% rename from modules/security/src/test/UserAgentTrustTest.scala rename to modules/security/src/test/UserAgentParserTest.scala index b7962d6b5bc6a..f872d7ff9eb02 100644 --- a/modules/security/src/test/UserAgentTrustTest.scala +++ b/modules/security/src/test/UserAgentParserTest.scala @@ -47,3 +47,6 @@ class UserAgentTrustTest extends munit.FunSuite: assert: susp: "Mozilla/5.0 (Macintosh; Intel Mac OS X 13_0_1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/107.0.0.0 Safari/537.36" + assert: + susp: + "Mozilla/5.0 (Macintosh; Intel Mac OS X 14) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.6812.83 Safari/537.36 Edg/130.0.2876.112" From 2c80390b2e53f11edc1ceb07d176ac03ba9a8ce0 Mon Sep 17 00:00:00 2001 From: Thibault Duplessis Date: Fri, 17 Jan 2025 15:55:48 +0100 Subject: [PATCH 20/24] better pair provisional players --- modules/core/src/main/pool.scala | 2 ++ modules/lobby/src/main/Hook.scala | 1 + modules/lobby/src/main/HookRepo.scala | 1 + modules/lobby/src/main/LobbySocket.scala | 1 + modules/pool/src/main/MatchMaking.scala | 4 ++++ modules/pool/src/main/PoolMember.scala | 1 + 6 files changed, 10 insertions(+) diff --git a/modules/core/src/main/pool.scala b/modules/core/src/main/pool.scala index 31f596101af74..9b6f56a8ea6c6 100644 --- a/modules/core/src/main/pool.scala +++ b/modules/core/src/main/pool.scala @@ -25,6 +25,7 @@ case class PoolMember( userId: UserId, sri: Sri, rating: IntRating, + provisional: Boolean, ratingRange: Option[RatingRange], lame: Boolean, blocking: Blocking, @@ -51,6 +52,7 @@ object HookThieve: case class Joiner( sri: Sri, rating: IntRating, + provisional: Boolean, ratingRange: Option[RatingRange], lame: Boolean, blocking: Blocking diff --git a/modules/lobby/src/main/Hook.scala b/modules/lobby/src/main/Hook.scala index 800151b3d0570..b061e5888d203 100644 --- a/modules/lobby/src/main/Hook.scala +++ b/modules/lobby/src/main/Hook.scala @@ -63,6 +63,7 @@ case class Hook( lazy val perf: Option[LobbyPerf] = user.map(_.perfAt(perfType)) def rating: Option[IntRating] = perf.map(_.rating) + def provisional = perf.forall(_.provisional.yes) import lila.common.Json.given def render: JsObject = Json diff --git a/modules/lobby/src/main/HookRepo.scala b/modules/lobby/src/main/HookRepo.scala index b505ce4c4cc86..865dea5b547ac 100644 --- a/modules/lobby/src/main/HookRepo.scala +++ b/modules/lobby/src/main/HookRepo.scala @@ -77,6 +77,7 @@ final private class HookRepo: userId = u.id, sri = h.sri, rating = h.rating | lila.rating.Glicko.default.intRating, + provisional = h.provisional, ratingRange = h.manualRatingRange, lame = h.user.so(_.lame), blocking = h.user.so(_.blocking), diff --git a/modules/lobby/src/main/LobbySocket.scala b/modules/lobby/src/main/LobbySocket.scala index 8f0d94350e423..f720a352d9dc9 100644 --- a/modules/lobby/src/main/LobbySocket.scala +++ b/modules/lobby/src/main/LobbySocket.scala @@ -206,6 +206,7 @@ final class LobbySocket( lila.core.pool.Joiner( sri = member.sri, rating = toJoinRating(user, glicko, trust), + provisional = glicko.forall(_.provisional.yes), ratingRange = ratingRange, lame = user.lame, blocking = user.blocking.map(_ ++ blocking) diff --git a/modules/pool/src/main/MatchMaking.scala b/modules/pool/src/main/MatchMaking.scala index e335e7d3bbb70..bd73a0f3a9c19 100644 --- a/modules/pool/src/main/MatchMaking.scala +++ b/modules/pool/src/main/MatchMaking.scala @@ -54,6 +54,7 @@ object MatchMaking: - missBonus(a).atMost(missBonus(b)) - rangeBonus(a, b) - ragesitBonus(a, b) + - provisionalBonus(a, b) score.some.filter(_ <= ratingToMaxScore(a.rating.atMost(b.rating))) // score bonus based on how many waves the member missed @@ -86,6 +87,9 @@ object MatchMaking: else if a.rageSitCounter <= -5 && b.rageSitCounter <= -5 then 30 // bad players else (abs(a.rageSitCounter - b.rageSitCounter).atMost(10)) * -20 // match of good and bad player + private def provisionalBonus(a: PoolMember, b: PoolMember) = + if a.provisional && b.provisional then 30 else 0 + def apply(members: Vector[PoolMember]): Option[Vector[Couple]] = WMMatching(members.toArray, pairScore).fold( err => diff --git a/modules/pool/src/main/PoolMember.scala b/modules/pool/src/main/PoolMember.scala index e1fa514d14d74..bc19ba2624b47 100644 --- a/modules/pool/src/main/PoolMember.scala +++ b/modules/pool/src/main/PoolMember.scala @@ -22,6 +22,7 @@ object PoolMember: sri = joiner.sri, lame = joiner.lame, rating = joiner.rating, + provisional = joiner.provisional, ratingRange = joiner.ratingRange, blocking = joiner.blocking, rageSitCounter = rageSit.counterView From 5b0bb8f90567b453fc7128c27fbaecd399c38ccd Mon Sep 17 00:00:00 2001 From: Thibault Duplessis Date: Fri, 17 Jan 2025 16:16:20 +0100 Subject: [PATCH 21/24] use computed trust in playban calculations --- modules/playban/src/main/Env.scala | 1 + modules/playban/src/main/PlaybanApi.scala | 4 ++- modules/playban/src/main/model.scala | 26 +++++++++++-------- modules/playban/src/test/PlaybanTest.scala | 30 ++++++++++++++-------- 4 files changed, 39 insertions(+), 22 deletions(-) diff --git a/modules/playban/src/main/Env.scala b/modules/playban/src/main/Env.scala index 6731822e2b352..d16c33d21575a 100644 --- a/modules/playban/src/main/Env.scala +++ b/modules/playban/src/main/Env.scala @@ -10,6 +10,7 @@ final class Env( gameApi: lila.core.game.GameApi, noteApi: lila.core.user.NoteApi, userApi: lila.core.user.UserApi, + userTrustApi: lila.core.security.UserTrustApi, lightUser: lila.core.LightUser.Getter, db: lila.db.Db, cacheApi: lila.memo.CacheApi diff --git a/modules/playban/src/main/PlaybanApi.scala b/modules/playban/src/main/PlaybanApi.scala index 1a8fde0320a19..a25c8e1697394 100644 --- a/modules/playban/src/main/PlaybanApi.scala +++ b/modules/playban/src/main/PlaybanApi.scala @@ -18,6 +18,7 @@ final class PlaybanApi( userApi: lila.core.user.UserApi, noteApi: lila.core.user.NoteApi, cacheApi: lila.memo.CacheApi, + userTrustApi: lila.core.security.UserTrustApi, messenger: MsgApi )(using ec: Executor, mode: play.api.Mode): @@ -260,8 +261,9 @@ final class PlaybanApi( }.void.logFailure(lila.log("playban")) private def legiferate(record: UserRecord, age: Days, source: Option[Source]): Fu[UserRecord] = for + trust <- userTrustApi.get(record.userId) newRec <- record - .bannable(age) + .bannable(age, trust) .ifFalse(record.banInEffect) .so: ban => lila.mon.playban.ban.count.increment() diff --git a/modules/playban/src/main/model.scala b/modules/playban/src/main/model.scala index 9c1a198e951f5..6a4839a47be8a 100644 --- a/modules/playban/src/main/model.scala +++ b/modules/playban/src/main/model.scala @@ -5,6 +5,7 @@ import scalalib.model.Days import lila.common.Json.given import lila.core.playban.RageSit as RageSitCounter +import lila.core.security.UserTrust case class UserRecord( _id: UserId, @@ -33,10 +34,12 @@ case class UserRecord( case o if o != Outcome.Good => 1 }.sum - def badOutcomeTolerance(age: Days): Float = - if age <= 1 then 0.3f - else if bans.sizeIs < 3 then 0.4f - else 0.3f + def badOutcomeTolerance(age: Days, trust: UserTrust): Float = + val base = + if age <= 1 then 0.3f + else if bans.sizeIs < 3 then 0.4f + else 0.3f + base - trust.no.so(0.5f) def minBadOutcomes: Int = bansThisWeek match @@ -52,11 +55,11 @@ case class UserRecord( else if bans.size < 10 then 4 else 3 - def bannable(age: Days): Option[TempBan] = { + def bannable(age: Days, trust: UserTrust): Option[TempBan] = { rageSitRecidive || { outcomes.lastOption.exists(_ != Outcome.Good) && { // too many bad overall - val toleranceRatio = badOutcomeTolerance(age) + val toleranceRatio = badOutcomeTolerance(age, trust) badOutcomeScore >= ((toleranceRatio * nbOutcomes).atLeast(minBadOutcomes.toFloat)) || { // bad result streak val streakSize = badOutcomesStreakSize(age) @@ -64,14 +67,14 @@ case class UserRecord( outcomes.takeRight(streakSize).forall(Outcome.Good !=) } || { // last 2 games - age < 1 && + (age < 1 || trust.no) && outcomes.sizeIs < 9 && outcomes.sizeIs > 1 && outcomes.reverse.take(2).forall(Outcome.Good !=) } } } - }.option(TempBan.make(bans, age)) + }.option(TempBan.make(bans, age, trust)) def rageSitRecidive = outcomes.lastOption.exists(Outcome.rageSitLike.contains) && { @@ -109,7 +112,7 @@ object TempBan: * - 0 - 3 days: linear scale from 3x to 1x * - >3 days quick drop off Account less than 3 days old --> 2x the usual time */ - def make(bans: Vector[TempBan], age: Days): TempBan = + def make(bans: Vector[TempBan], age: Days, trust: UserTrust): TempBan = make { val base = bans.lastOption .so: prev => @@ -117,8 +120,9 @@ object TempBan: case h if h < 72 => prev.mins * (132 - h) / 60 case h => (55.6 * prev.mins / (Math.pow(5.56 * prev.mins - 54.6, h / 720) + 54.6)).toInt .atLeast(baseMinutes) - val multiplier = if age == Days(0) then 3 else if age <= 3 then 2 else 1 - base * multiplier + val multiplier = if age == Days(0) then 3 else if age <= 3 then 2 else 1 + val trustMultiplier = if trust.yes then 1 else 2 + base * multiplier * trustMultiplier } enum Outcome(val id: Int, val name: String): diff --git a/modules/playban/src/test/PlaybanTest.scala b/modules/playban/src/test/PlaybanTest.scala index e44fc2364f926..ae8d43a3ddc2d 100644 --- a/modules/playban/src/test/PlaybanTest.scala +++ b/modules/playban/src/test/PlaybanTest.scala @@ -6,41 +6,51 @@ class PlaybanTest extends munit.FunSuite: import Outcome.* - val userId = UserId("user") - val brandNew = Days(scalalib.time.daysBetween(nowInstant.minusHours(1), nowInstant)) // 0 + val userId = UserId("user") + val brandNew = Days(scalalib.time.daysBetween(nowInstant.minusHours(1), nowInstant)) // 0 + val trusted = lila.core.security.UserTrust.Yes + val untrusted = lila.core.security.UserTrust.No test("empty"): val rec = UserRecord(userId, none, none, none) - assertEquals(rec.bannable(brandNew), None) + assertEquals(rec.bannable(brandNew, trusted), None) test("new one abort"): val rec = UserRecord(userId, Vector(Abort).some, none, none) - assertEquals(rec.bannable(brandNew), None) + assertEquals(rec.bannable(brandNew, trusted), None) test("new 2 aborts"): val rec = UserRecord(userId, Vector.fill(2)(Abort).some, none, none) - assert(rec.bannable(brandNew).isDefined) + assert(rec.bannable(brandNew, trusted).isDefined) test("new 1 good and 2 aborts"): val rec = UserRecord(userId, Some(Good +: Vector.fill(2)(Abort)), none, none) - assert(rec.bannable(brandNew).isDefined) + assert(rec.bannable(brandNew, trusted).isDefined) test("new account abuse"): val outcomes = Vector(Abort, Good, Abort, Abort) val rec = UserRecord(userId, Some(outcomes), none, none) - assert(rec.bannable(brandNew).isDefined) + assert(rec.bannable(brandNew, trusted).isDefined) test("older account"): val outcomes = Vector(Abort, Good, Abort, Abort) val rec = UserRecord(userId, Some(outcomes), none, none) - assert(rec.bannable(Days(1)).isEmpty) + assert(rec.bannable(Days(1), trusted).isEmpty) test("good and aborts"): val outcomes = Vector(Good, Abort, Good, Abort, Abort) val rec = UserRecord(userId, outcomes.some, none, none) - assert(rec.bannable(brandNew).isDefined) + assert(rec.bannable(brandNew, trusted).isDefined) test("sandbag and aborts"): val outcomes = Vector(Sandbag, Sandbag, Abort, Sandbag, Abort, Abort) val rec = UserRecord(userId, outcomes.some, none, none) - assert(rec.bannable(brandNew).isDefined) + assert(rec.bannable(brandNew, trusted).isDefined) + + test("untrusted new single abort"): + val rec = UserRecord(userId, Vector(Abort).some, none, none) + assertEquals(rec.bannable(brandNew, untrusted), None) + + test("untrusted old many aborts"): + val rec = UserRecord(userId, Vector(Abort, Abort).some, none, none) + assert(rec.bannable(Days(7), untrusted).isDefined) From 489b98f2db43131e7900c075f99e69aaacc71c62 Mon Sep 17 00:00:00 2001 From: Thibault Duplessis Date: Fri, 17 Jan 2025 16:39:45 +0100 Subject: [PATCH 22/24] faster and better user trust --- .../security/src/main/UserAgentParser.scala | 9 ++++--- modules/security/src/main/UserTrust.scala | 26 +++++++++++-------- .../src/test/UserAgentParserTest.scala | 6 +++++ modules/user/src/main/UserRepo.scala | 11 ++++++++ 4 files changed, 37 insertions(+), 15 deletions(-) diff --git a/modules/security/src/main/UserAgentParser.scala b/modules/security/src/main/UserAgentParser.scala index e42e06f713ef5..3b75a1588f8d9 100644 --- a/modules/security/src/main/UserAgentParser.scala +++ b/modules/security/src/main/UserAgentParser.scala @@ -45,10 +45,10 @@ object UserAgentParser: private def looksNormal(ua: String) = val sections = ua.split(' ') sections.exists: s => - isRecentChrome(s) || isRecentFirefox(s) || isRecentSafari(s) + isRecentChrome(s) || isLastWindows8Chrome(s) || isRecentFirefox(s) || isRecentSafari(s) // based on https://caniuse.com/usage-table - private val isRecentChrome = isRecentBrowser("chrome", 109) // also covers Edge and Opera + private val isRecentChrome = isRecentBrowser("chrome", 125) // also covers Edge and Opera private val isRecentFirefox = isRecentBrowser("firefox", 128) private val isRecentSafari = isRecentBrowser("safari", 604) // most safaris also have a chrome/ section @@ -59,5 +59,6 @@ object UserAgentParser: s.startsWith(slashed) && s.drop(prefixLength).takeWhile(_ != '.').toIntOption.exists(_ >= minVersion) - private def isMacOsEdge(ua: String) = - ua.contains("macintosh") && ua.contains("edg/") + private def isLastWindows8Chrome(s: String) = s.startsWith("chrome/109.") + + private def isMacOsEdge(ua: String) = ua.contains("macintosh") && ua.contains("edg/") diff --git a/modules/security/src/main/UserTrust.scala b/modules/security/src/main/UserTrust.scala index e92f76634ab7c..63521074f8bb2 100644 --- a/modules/security/src/main/UserTrust.scala +++ b/modules/security/src/main/UserTrust.scala @@ -18,18 +18,22 @@ private final class UserTrustApi( private def computeTrust(id: UserId): Fu[Boolean] = userRepo - .byId(id) - .flatMapz: user => - if user.isVerifiedOrAdmin then fuccess(true) - else if user.hasTitle || user.isPatron then fuccess(true) - else if user.createdSinceDays(30) then fuccess(true) - else if user.count.game > 20 then fuccess(true) + .trustable(id) + .flatMap: + if _ then fuccess(true) else sessionStore .openSessions(id, 3) .flatMap: sessions => - if sessions.map(_.ua).exists(UserAgentParser.trust.isSuspicious) - then fuccess(false) - else sessions.map(_.ip).existsM(ipTrust.isSuspicious).not - .addEffect: trust => - if !trust then logger.info(s"User $id is not trusted") + sessions.map(_.ua).find(UserAgentParser.trust.isSuspicious) match + case Some(ua) => + logger.info(s"Not trusting user $id because of suspicious user agent: $ua") + fuccess(false) + case None => + sessions + .map(_.ip) + .findM(ipTrust.isSuspicious) + .map: found => + found.foreach: ip => + logger.info(s"Not trusting user $id because of suspicious IP: $ip") + found.isEmpty diff --git a/modules/security/src/test/UserAgentParserTest.scala b/modules/security/src/test/UserAgentParserTest.scala index f872d7ff9eb02..12f7bd10998ae 100644 --- a/modules/security/src/test/UserAgentParserTest.scala +++ b/modules/security/src/test/UserAgentParserTest.scala @@ -28,12 +28,18 @@ class UserAgentTrustTest extends munit.FunSuite: assert: !susp: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.2 Safari/605.1.15" + assert: + !susp: + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/109.0.0.0 Safari/537.36" test("susp"): assert: susp("") assert: susp("too short") + assert: + susp: + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/108.0.0.0 Safari/537.36" assert: susp("Mozilla/5.0 (X11; U; FreeBSD i386; zh-tw; rv:31.0) Gecko/20100101 Opera/13.0") assert: diff --git a/modules/user/src/main/UserRepo.scala b/modules/user/src/main/UserRepo.scala index 767e869c86f5d..48efe136fb519 100644 --- a/modules/user/src/main/UserRepo.scala +++ b/modules/user/src/main/UserRepo.scala @@ -546,6 +546,17 @@ final class UserRepo(c: Coll)(using Executor) extends lila.core.user.UserRepo(c) def filterClosedOrInactiveIds(since: Instant)(ids: Iterable[UserId]): Fu[List[UserId]] = coll.distinctEasy[UserId, List](F.id, $inIds(ids) ++ $or(disabledSelect, F.seenAt.$lt(since)), _.sec) + def trustable(id: UserId): Fu[Boolean] = coll.exists: + $doc( + F.id -> id, + $or( + F.title.$exists(true), + patronSelect, + $doc(F.createdAt.$lt(nowInstant.minusDays(15))), + $doc(s"${F.count}.lossH".$gt(10)) + ) + ) + private val defaultCount = lila.core.user.Count(0, 0, 0, 0, 0, 0, 0, 0, 0) private def newUser( From dc99ac470d4a76a21a2ad59f36db2dda9a47f725 Mon Sep 17 00:00:00 2001 From: Thibault Duplessis Date: Fri, 17 Jan 2025 17:05:46 +0100 Subject: [PATCH 23/24] better delete/anonymize studies --- modules/api/src/main/AccountTermination.scala | 3 ++- modules/study/src/main/Env.scala | 2 +- modules/study/src/main/StudyRepo.scala | 10 ++++++---- 3 files changed, 9 insertions(+), 6 deletions(-) diff --git a/modules/api/src/main/AccountTermination.scala b/modules/api/src/main/AccountTermination.scala index cfbe6ae82e487..dace6eac8f6fd 100644 --- a/modules/api/src/main/AccountTermination.scala +++ b/modules/api/src/main/AccountTermination.scala @@ -21,7 +21,8 @@ enum Termination: | sessions and oauth tokens | closed | deleted | deleted | | patron subscription | canceled | canceled | canceled | | blog posts | unlisted | deleted | deleted | -| studies | hidden | deleted | deleted | +| public studies | unlisted | anonymized | deleted | +| private studies | hidden | deleted | deleted | | activity | hidden | deleted | deleted | | coach/streamer profiles | hidden | deleted | deleted | | tournaments joined | unlisted | anonymized | anonymized | diff --git a/modules/study/src/main/Env.scala b/modules/study/src/main/Env.scala index 3b3834881377f..cb5a48b8a61f2 100644 --- a/modules/study/src/main/Env.scala +++ b/modules/study/src/main/Env.scala @@ -112,7 +112,7 @@ final class Env( lila.common.Bus.sub[lila.core.user.UserDelete]: del => for - studyIds <- studyRepo.deleteByOwner(del.id) + studyIds <- studyRepo.deletePrivateByOwner(del.id) _ <- chapterRepo.deleteByStudyIds(studyIds) _ <- topicApi.userTopicsDelete(del.id) yield () diff --git a/modules/study/src/main/StudyRepo.scala b/modules/study/src/main/StudyRepo.scala index bc45e6d80f8b2..0f7bf15b6ff0c 100644 --- a/modules/study/src/main/StudyRepo.scala +++ b/modules/study/src/main/StudyRepo.scala @@ -319,10 +319,12 @@ final class StudyRepo(private[study] val coll: AsyncColl)(using private[study] def isAdminMember(study: Study, userId: UserId): Fu[Boolean] = coll(_.exists($id(study.id) ++ $doc(s"members.$userId.admin" -> true))) - private[study] def deleteByOwner(u: UserId): Fu[List[StudyId]] = for - c <- coll.get - ids <- c.distinctEasy[StudyId, List]("_id", selectOwnerId(u)) - _ <- c.delete.one(selectOwnerId(u)) + private[study] def deletePrivateByOwner(u: UserId): Fu[List[StudyId]] = for + c <- coll.get + privateSelector = selectOwnerId(u) ++ selectPrivateOrUnlisted + ids <- c.distinctEasy[StudyId, List]("_id", privateSelector) + _ <- c.delete.one(privateSelector) + _ <- c.update.one(selectOwnerId(u), $set("ownerId" -> UserId.ghost), multi = true) _ <- c.update.one($doc(F.likers -> u), $pull(F.likers -> u)) _ <- c.update.one($doc(F.uids -> u), $pull(F.uids -> u) ++ $unset(s"members.$u")) yield ids From 26e018b05d6ba93b4bbb61bf39cff30c589efb34 Mon Sep 17 00:00:00 2001 From: Thibault Duplessis Date: Fri, 17 Jan 2025 17:16:13 +0100 Subject: [PATCH 24/24] log user deletion --- modules/api/src/main/AccountTermination.scala | 1 + 1 file changed, 1 insertion(+) diff --git a/modules/api/src/main/AccountTermination.scala b/modules/api/src/main/AccountTermination.scala index dace6eac8f6fd..b7d0a5c81b52d 100644 --- a/modules/api/src/main/AccountTermination.scala +++ b/modules/api/src/main/AccountTermination.scala @@ -137,6 +137,7 @@ final class AccountTermination( playbanned <- playbanApi.hasCurrentPlayban(u.id) closedByMod <- modLogApi.closedByMod(u) tos = u.marks.dirty || closedByMod || playbanned + _ = logger.info(s"Deleting user ${u.username} tos=$tos erase=${del.erase}") _ <- if tos then userRepo.delete.nowWithTosViolation(u) else userRepo.delete.nowFully(u) _ <- activityWrite.deleteAll(u) singlePlayerGameIds <- gameRepo.deleteAllSinglePlayerOf(u.id)