static ghost ids

pull/8647/head
Thibault Duplessis 2021-04-13 19:16:37 +02:00
parent ffb63ed2e0
commit 2e80974a74
12 changed files with 68 additions and 72 deletions

View File

@ -136,19 +136,25 @@ final class User(
}
private def EnabledUser(username: String)(f: UserModel => Fu[Result])(implicit ctx: Context): Fu[Result] =
env.user.repo named username flatMap {
case None if isGranted(_.UserModView) => ctx.me.map(Holder) ?? { modC.searchTerm(_, username.trim) }
case None => notFound
case Some(u) if u.enabled || isGranted(_.UserModView) => f(u)
case Some(u) =>
negotiate(
html = env.user.repo isErased u flatMap { erased =>
if (erased.value) notFound
else NotFound(html.user.show.page.disabled(u)).fuccess
},
api = _ => fuccess(NotFound(jsonError("No such user, or account closed")))
)
}
if (UserModel.isGhost(username))
negotiate(
html = Ok(html.site.bits.ghost).fuccess,
api = _ => notFoundJson("Deleted user")
)
else
env.user.repo named username flatMap {
case None if isGranted(_.UserModView) => ctx.me.map(Holder) ?? { modC.searchTerm(_, username.trim) }
case None => notFound
case Some(u) if u.enabled || isGranted(_.UserModView) => f(u)
case Some(u) =>
negotiate(
html = env.user.repo isErased u flatMap { erased =>
if (erased.value) notFound
else NotFound(html.user.show.page.disabled(u)).fuccess
},
api = _ => fuccess(NotFound(jsonError("No such user, or account closed")))
)
}
def showMini(username: String) =
Open { implicit ctx =>
OptionFuResult(env.user.repo named username) { user =>

View File

@ -150,13 +150,6 @@ trait GameHelper { self: I18nHelper with UserHelper with AiHelper with StringHel
player.rating.ifTrue(withRating) map { rating => s" ($rating)" },
statusIcon
)
case Some(user) if user.id == "ghost" =>
span(cls := "user-link")(
"Ghost",
(player.ratingDiff ifTrue withDiff) map { d =>
frag(" ", showRatingDiff(d))
}
)
case Some(user) =>
frag(
(if (link) a else span)(

View File

@ -53,4 +53,18 @@ object bits {
)
)
}
def ghost(implicit ctx: Context) =
views.html.base.layout(
moreCss = cssTag("ghost"),
title = "Deleted user"
) {
main(cls := "page-small box box-pad page")(
h1("Deleted user"),
div(
p("This player account is gone!"),
p("Nothing to see here, move along.")
)
)
}
}

View File

@ -9,11 +9,12 @@ if (typeof user == 'undefined') throw 'Usage: mongo lichess --eval \'user="usern
user = db.user4.findOne({ _id: user });
// if (!user || user.enabled || !user.erasedAt) throw 'Erase with lichess CLI first.';
if (!user || user.enabled || !user.erasedAt) throw 'Erase with lichess CLI first.';
print(`\n\n Delete user ${user.username} and all references to their username!\n\n`);
sleep(5000);
const ghostId = 'ghost';
const newGhostId = () => {
const idChars = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
const idLength = 8;
@ -31,9 +32,8 @@ const scrub = (collName, inDb) => f => {
const byId = doc => ({ _id: doc._id });
const deleteAllIn = (db, collName, field, value) => scrub(collName, db)(c => c.remove({ [field]: value || userId }));
const deleteAll = (collName, field, value) => deleteAllIn(mainDb, collName, field, value);
const setNewGhostId = (coll, doc, field) => coll.update(byId(doc), { $set: { [field]: newGhostId() } });
const replaceWithNewGhostIds = (collName, field, inDb) =>
scrub(collName, inDb)(c => c.find({ [field]: userId }, { _id: 1 }).forEach(doc => setNewGhostId(c, doc, field)));
const replaceWithGhostId = (collName, field, inDb) =>
scrub(collName, inDb)(c => c.updateMany({ [field]: userId }, { $set: { [field]: ghostId } }));
const userId = user._id;
const tosViolation = user.marks && user.marks.length;
@ -49,7 +49,7 @@ deleteAll('bookmark', 'u');
deleteAll('challenge', 'challenger.id');
deleteAll('challenge', 'destUser.id');
replaceWithNewGhostIds('clas_clas', 'created.by');
replaceWithGhostId('clas_clas', 'created.by');
scrub('clas_clas')(c => c.updateMany({ teachers: userId }, { $pull: { teachers: userId } }));
deleteAll('clas_student', 'userId');
@ -64,20 +64,15 @@ deleteAll('coordinate_score', '_id');
deleteAll('crosstable2', '_id', new RegExp(`^${userId}/`));
scrub('f_post')(c =>
c
.find({ userId: userId }, { _id: 1 })
.forEach(doc => coll.update(byId(doc), { $set: { userId: newGhostId(), text: '', erasedAt: new Date() } }))
);
scrub('f_post')(c => c.updateMany({ userId: userId }, { $set: { userId: ghostId, text: '', erasedAt: new Date() } }));
scrub('game5')(c =>
c.find({ us: userId }, { us: 1, wid: 1 }).forEach(doc => {
const gameGhostId = newGhostId();
c.update(byId(doc), {
$set: {
[`us.${doc.us.indexOf(userId)}`]: gameGhostId, // replace player usernames
...(doc.wid == userId ? { wid: gameGhostId } : {}), // replace winner username
[`us.${doc.us.indexOf(userId)}`]: ghostId, // replace in player usernames
},
...(doc.wid == userId ? { $unset: { wid: 1 } } : {}), // remove winner username
});
})
);
@ -111,13 +106,13 @@ scrub('note')(c => {
deleteAll('notify', 'notifies');
replaceWithNewGhostIds('oauth_client', 'author', oauthDb);
replaceWithGhostId('oauth_client', 'author', oauthDb);
deleteAllIn(oauthDb, 'oauth_access_token', 'user_id');
deleteAll('perf_stat', new RegExp(`^${userId}/`));
replaceWithNewGhostIds('plan_charge', 'userId');
replaceWithGhostId('plan_charge', 'userId');
deleteAll('plan_patron', '_id');
@ -131,18 +126,7 @@ deleteAll('pref', '_id');
deleteAll('push_device', 'userId');
scrub(
'puzzle2_puzzle',
puzzleDb
)(c =>
c.find({ userId: userId }, { users: 1 }).forEach(doc =>
c.update(byId(doc), {
$set: {
[`users.${doc.users.indexOf(userId)}`]: newGhostId(),
},
})
)
);
scrub('puzzle2_puzzle', puzzleDb)(c => c.updateMany({ users: userId }, { $set: { 'users.$': ghostId } }));
deleteAllIn(puzzleDb, 'puzzle2_round', '_id', new RegExp(`^${userId}:`));
@ -153,14 +137,13 @@ deleteAll('relation', 'u2');
scrub('report2')(c => {
c.find({ 'atoms.by': userId }, { atoms: 1 }).forEach(doc => {
const reportGhostId = newGhostId();
const newAtoms = doc.atoms.map(a => ({
...a,
by: a.by == userId ? reportGhostId : a.by,
by: a.by == userId ? ghostId : a.by,
}));
c.update(byId(doc), { $set: { atoms: newAtoms } });
});
!tosViolation && c.updateMany({ user: userId }, { $set: { user: newGhostId() } });
!tosViolation && c.updateMany({ user: userId }, { $set: { user: ghostId } });
});
if (!tosViolation) deleteAll('security', 'user');
@ -169,15 +152,8 @@ deleteAll('seek', 'user.id');
deleteAll('shutup', '_id');
replaceWithNewGhostIds('simul', 'hostId');
scrub('simul')(c =>
c.find({ 'pairings.player.user': userId }, { pairings: 1 }).forEach(doc => {
doc.pairings.forEach(p => {
if (p.player.user == userId) p.player.user = newGhostId();
});
c.update(byId(doc), { $set: { pairings: doc.pairings } });
})
);
replaceWithGhostId('simul', 'hostId');
scrub('simul')(c => c.updateMany({ 'pairings.player.user': userId }, { $set: { 'pairings.$.player.user': ghostId } }));
deleteAll('storm_day', '_id', new RegExp(`^${userId}:`));
@ -198,7 +174,7 @@ scrub('study_chapter_flat', studyDb)(c => c.remove({ studyId: { $in: studyIds }
deleteAllIn(studyDb, 'study_user_topic', '_id');
replaceWithNewGhostIds('swiss', 'winnerId');
replaceWithGhostId('swiss', 'winnerId');
const swissIds = scrub('swiss_player')(c => c.distinct('s', { u: userId }));
@ -217,15 +193,15 @@ if (swissIds.length) {
scrub('swiss_pairing')(c => c.updateMany({ s: { $in: swissIds }, p: userId }, { $set: { 'p.$': swissGhostId } }));
}
replaceWithNewGhostIds('team', 'createdBy');
replaceWithGhostId('team', 'createdBy');
scrub('team')(c => c.updateMany({ leaders: userId }, { $pull: { leaders: userId } }));
deleteAll('team_request', 'user');
deleteAll('team_member', 'user');
replaceWithNewGhostIds('tournament2', 'createdBy');
replaceWithNewGhostIds('tournament2', 'winnerId');
replaceWithGhostId('tournament2', 'createdBy');
replaceWithGhostId('tournament2', 'winnerId');
const arenaIds = scrub('tournament_leaderboard')(c => c.distinct('t', { u: userId }));
if (arenaIds.length) {

View File

@ -41,6 +41,7 @@ final private[api] class Cli(
case None => "No such user."
case Some(user) if user.enabled => "That user account is not closed. Can't erase."
case Some(user) =>
userRepo setErasedAt user
Bus.publish(lila.user.User.GDPRErase(user), "gdprErase")
s"Erasing all search data about ${user.username} now"
}

View File

@ -18,7 +18,7 @@ object LightUser {
private type UserID = String
val ghost = LightUser("ghost", "Ghost", none, false)
val ghost = LightUser("ghost", "ghost", none, false)
implicit val lightUserWrites = OWrites[LightUser] { u =>
writeNoId(u) + ("id" -> JsString(u.id))

View File

@ -106,8 +106,8 @@ final class PostRepo(val coll: Coll, filter: Filter = Safe)(implicit
def allUserIdsByTopicId(topicId: String): Fu[List[User.ID]] =
coll.distinctEasy[User.ID, List]("userId", $doc("topicId" -> topicId), ReadPreference.secondaryPreferred)
def cursor =
def nonGhostCursor =
coll
.find($empty)
.find($doc("userId" $ne User.ghostId))
.cursor[Post](ReadPreference.secondaryPreferred)
}

View File

@ -45,7 +45,7 @@ final class ForumSearchApi(
client match {
case c: ESClientHttp =>
c.putMapping >> {
postRepo.cursor
postRepo.nonGhostCursor
.documentSource()
.via(lila.common.LilaStream.logRate[Post]("forum index")(logger))
.grouped(200)

View File

@ -64,7 +64,10 @@ final class SecurityForm(
)
)
.verifying("usernameUnacceptable", u => !lameNameCheck.value || !LameName.username(u))
.verifying("usernameAlreadyUsed", u => !userRepo.nameExists(u).await(3 seconds, "signupUsername"))
.verifying(
"usernameAlreadyUsed",
u => !User.isGhost(u) && !userRepo.nameExists(u).await(3 seconds, "signupUsername")
)
private val agreementBool = boolean.verifying(b => b)

View File

@ -1,6 +1,5 @@
package lila.user
import reactivemongo.api.bson._
import scala.concurrent.duration._
import scala.util.Success

View File

@ -163,6 +163,7 @@ object User {
val anonymous = "Anonymous"
val lichessId = "lichess"
val broadcasterId = "broadcaster"
val ghostId = "ghost"
def isOfficial(username: String) = normalize(username) == lichessId || normalize(username) == broadcasterId
val seenRecently = 2.minutes
@ -227,13 +228,13 @@ object User {
val newUsernameChars = "(?i)^[a-z0-9_-]*$".r
val newUsernameLetters = "(?i)^([a-z0-9][_-]?)+$".r
def couldBeUsername(str: User.ID) = historicalUsernameRegex.matches(str)
def couldBeUsername(str: User.ID) = noGhost(str) && historicalUsernameRegex.matches(str)
def normalize(username: String) = username.toLowerCase
def validateId(name: String): Option[User.ID] = couldBeUsername(name) option normalize(name)
def isGhost(name: String) = name.headOption has '!'
def isGhost(name: String) = normalize(name) == ghostId || name.headOption.has('!')
def noGhost(name: String) = !isGhost(name)

View File

@ -95,7 +95,7 @@ final class UserRepo(val coll: Coll)(implicit ec: scala.concurrent.ExecutionCont
User.noGhost(id) ?? coll.one[User](disabledSelect ++ $id(id))
def named(username: String): Fu[Option[User]] =
User.noGhost(username) ?? coll.byId[User](normalize(username)) recover {
User.noGhost(username) ?? coll.byId[User](normalize(username)).recover {
case _: reactivemongo.api.bson.exceptions.BSONValueNotFoundException => none // probably GDPRed user
}
@ -668,6 +668,9 @@ final class UserRepo(val coll: Coll)(implicit ec: scala.concurrent.ExecutionCont
ReadPreference.secondaryPreferred
)
def setErasedAt(user: User) =
coll.updateField($id(user.id), F.erasedAt, DateTime.now plusDays 1).void
private def newUser(
username: String,
passwordHash: HashedPassword,