From dfe1ac04593ee951b1eaf43079fc78db1a1b601c Mon Sep 17 00:00:00 2001 From: Thibault Duplessis Date: Thu, 16 Jan 2025 21:45:37 +0100 Subject: [PATCH] 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: