diff --git a/backend/src/api/controllers/result.ts b/backend/src/api/controllers/result.ts index 7a77ebacf..2403dd158 100644 --- a/backend/src/api/controllers/result.ts +++ b/backend/src/api/controllers/result.ts @@ -283,7 +283,8 @@ export async function addResult( result.mode === "time" && result.wpm > 130 && result.testDuration < 122 && - (user.verified === false || user.verified === undefined) + (user.verified === false || user.verified === undefined) && + user.lbOptOut !== true ) { if (!result.keySpacingStats || !result.keyDurationStats) { const status = MonkeyStatusCodes.MISSING_KEY_DATA; @@ -389,7 +390,8 @@ export async function addResult( const validResultCriteria = (funbox === "none" || funbox === "plus_one" || funbox === "plus_two") && !bailedOut && - !user.banned && + user.banned !== true && + user.lbOptOut !== true && (process.env.MODE === "dev" || (user.timeTyping ?? 0) > 7200); const selectedBadgeId = user.inventory?.badges?.find((b) => b.selected)?.id; @@ -438,7 +440,8 @@ export async function addResult( const weeklyXpLeaderboardConfig = req.ctx.configuration.leaderboards.weeklyXp; let weeklyXpLeaderboardRank = -1; const eligibleForWeeklyXpLeaderboard = - !user.banned && + user.banned !== true && + user.lbOptOut !== true && (process.env.MODE === "dev" || (user.timeTyping ?? 0) > 7200); const weeklyXpLeaderboard = WeeklyXpLeaderboard.get( diff --git a/backend/src/api/controllers/user.ts b/backend/src/api/controllers/user.ts index 4854a3dcc..a7f6f5505 100644 --- a/backend/src/api/controllers/user.ts +++ b/backend/src/api/controllers/user.ts @@ -201,6 +201,21 @@ export async function clearPb( return new MonkeyResponse("User's PB cleared"); } +export async function optOutOfLeaderboards( + req: MonkeyTypes.Request +): Promise { + const { uid } = req.ctx.decodedToken; + + await UserDAL.optOutOfLeaderboards(uid); + await purgeUserFromDailyLeaderboards( + uid, + req.ctx.configuration.dailyLeaderboards + ); + Logger.logToDb("user_opted_out_of_leaderboards", "", uid); + + return new MonkeyResponse("User opted out of leaderboards"); +} + export async function checkName( req: MonkeyTypes.Request ): Promise { @@ -606,6 +621,7 @@ export async function getProfile( discordAvatar, xp, streak, + lbOptOut, } = user; const validTimePbs = _.pick(personalBests?.time, "15", "30", "60", "120"); @@ -633,6 +649,7 @@ export async function getProfile( xp, streak: streak?.length ?? 0, maxStreak: streak?.maxLength ?? 0, + lbOptOut, }; if (banned) { diff --git a/backend/src/api/routes/users.ts b/backend/src/api/routes/users.ts index 7ef805861..d5f1dac7c 100644 --- a/backend/src/api/routes/users.ts +++ b/backend/src/api/routes/users.ts @@ -202,6 +202,15 @@ router.delete( asyncHandler(UserController.clearPb) ); +router.post( + "/optOutOfLeaderboards", + authenticateRequest({ + requireFreshToken: true, + }), + RateLimit.userOptOutOfLeaderboards, + asyncHandler(UserController.optOutOfLeaderboards) +); + const requireFilterPresetsEnabled = validateConfiguration({ criteria: (configuration) => { return configuration.results.filterPresets.enabled; diff --git a/backend/src/dal/user.ts b/backend/src/dal/user.ts index a6eb2bed3..18ec60a30 100644 --- a/backend/src/dal/user.ts +++ b/backend/src/dal/user.ts @@ -79,6 +79,7 @@ export async function resetUser(uid: string): Promise { $unset: { discordAvatar: "", discordId: "", + lbOptOut: "", }, } ); @@ -136,6 +137,20 @@ export async function clearPb(uid: string): Promise { ); } +export async function optOutOfLeaderboards(uid: string): Promise { + await getUsersCollection().updateOne( + { uid }, + { + $set: { + lbOptOut: true, + lbPersonalBests: { + time: {}, + }, + }, + } + ); +} + export async function updateQuoteRatings( uid: string, quoteRatings: MonkeyTypes.UserQuoteRatings diff --git a/backend/src/middlewares/rate-limit.ts b/backend/src/middlewares/rate-limit.ts index 8c4afbac5..34a1fbe32 100644 --- a/backend/src/middlewares/rate-limit.ts +++ b/backend/src/middlewares/rate-limit.ts @@ -348,6 +348,13 @@ export const userClearPB = rateLimit({ handler: customHandler, }); +export const userOptOutOfLeaderboards = rateLimit({ + windowMs: ONE_HOUR_MS, + max: 10 * REQUEST_MULTIPLIER, + keyGenerator: getKeyWithUid, + handler: customHandler, +}); + export const userCustomFilterAdd = rateLimit({ windowMs: ONE_HOUR_MS, max: 60 * REQUEST_MULTIPLIER, diff --git a/backend/src/types/types.d.ts b/backend/src/types/types.d.ts index 410c9c99a..e4bac68e6 100644 --- a/backend/src/types/types.d.ts +++ b/backend/src/types/types.d.ts @@ -189,6 +189,7 @@ declare namespace MonkeyTypes { inbox?: MonkeyMail[]; streak?: UserStreak; lastReultHashes?: string[]; + lbOptOut?: boolean; } interface UserStreak { diff --git a/frontend/src/styles/settings.scss b/frontend/src/styles/settings.scss index f98a0e920..0f0a506d1 100644 --- a/frontend/src/styles/settings.scss +++ b/frontend/src/styles/settings.scss @@ -209,6 +209,9 @@ align-self: normal; color: var(--text-color); grid-area: text; + .red { + color: var(--error-color); + } } .inputs { diff --git a/frontend/src/ts/ape/endpoints/users.ts b/frontend/src/ts/ape/endpoints/users.ts index 90b8e42d6..13d1851dc 100644 --- a/frontend/src/ts/ape/endpoints/users.ts +++ b/frontend/src/ts/ape/endpoints/users.ts @@ -37,6 +37,10 @@ export default class Users { return await this.httpClient.patch(`${BASE_PATH}/reset`); } + async optOutOfLeaderboards(): Ape.EndpointData { + return await this.httpClient.post(`${BASE_PATH}/optOutOfLeaderboards`); + } + async updateName(name: string): Ape.EndpointData { return await this.httpClient.patch(`${BASE_PATH}/name`, { payload: { name }, diff --git a/frontend/src/ts/db.ts b/frontend/src/ts/db.ts index 3809817f2..2bb4c6ae1 100644 --- a/frontend/src/ts/db.ts +++ b/frontend/src/ts/db.ts @@ -18,6 +18,7 @@ export function setSnapshot( ): void { const originalBanned = dbSnapshot?.banned; const originalVerified = dbSnapshot?.verified; + const lbOptOut = dbSnapshot?.lbOptOut; //not allowing user to override these values i guess? try { @@ -26,10 +27,14 @@ export function setSnapshot( try { delete newSnapshot?.verified; } catch {} + try { + delete newSnapshot?.lbOptOut; + } catch {} dbSnapshot = newSnapshot; if (dbSnapshot) { dbSnapshot.banned = originalBanned; dbSnapshot.verified = originalVerified; + dbSnapshot.lbOptOut = lbOptOut; } } @@ -91,6 +96,7 @@ export async function initSnapshot(): Promise< snap.name = userData.name; snap.personalBests = userData.personalBests; snap.banned = userData.banned; + snap.lbOptOut = userData.lbOptOut; snap.verified = userData.verified; snap.discordId = userData.discordId; snap.discordAvatar = userData.discordAvatar; diff --git a/frontend/src/ts/elements/leaderboards.ts b/frontend/src/ts/elements/leaderboards.ts index aef9c5439..d5f4f2bf5 100644 --- a/frontend/src/ts/elements/leaderboards.ts +++ b/frontend/src/ts/elements/leaderboards.ts @@ -150,11 +150,20 @@ function updateFooter(lb: LbKey): void { return; } - $(`#leaderboardsWrapper table.${side} tfoot`).html(` + if (DB.getSnapshot()?.lbOptOut === true) { + $(`#leaderboardsWrapper table.${side} tfoot`).html(` + + You have opted out of the leaderboards + + `); + return; + } else { + $(`#leaderboardsWrapper table.${side} tfoot`).html(` Not qualified `); + } let toppercent; if (currentTimeRange === "allTime" && currentRank[lb]) { diff --git a/frontend/src/ts/elements/profile.ts b/frontend/src/ts/elements/profile.ts index 862e210fa..e192f6303 100644 --- a/frontend/src/ts/elements/profile.ts +++ b/frontend/src/ts/elements/profile.ts @@ -31,6 +31,8 @@ export async function update( const banned = profile.banned === true; + const lbOptOut = profile.lbOptOut === true; + if (!details || !profile || !profile.name || !profile.addedAt) return; details.find(".placeholderAvatar").removeClass("hidden"); @@ -75,6 +77,14 @@ export async function update( ); } + if (lbOptOut) { + details + .find(".name") + .append( + `
` + ); + } + updateNameFontSize(where); const joinedText = "Joined " + format(profile.addedAt ?? 0, "dd MMM yyyy"); diff --git a/frontend/src/ts/pages/settings.ts b/frontend/src/ts/pages/settings.ts index f6f5eb751..2ebc5babc 100644 --- a/frontend/src/ts/pages/settings.ts +++ b/frontend/src/ts/pages/settings.ts @@ -828,6 +828,10 @@ export function showAccountSection(): void { refreshTagsSettingsSection(); refreshPresetsSettingsSection(); updateDiscordSection(); + + if (DB.getSnapshot()?.lbOptOut === true) { + $(".pageSettings .section.optOutOfLeaderboards").remove(); + } } export async function update(groupUpdate = true): Promise { diff --git a/frontend/src/ts/popups/simple-popups.ts b/frontend/src/ts/popups/simple-popups.ts index f72b5f486..48b1a4790 100644 --- a/frontend/src/ts/popups/simple-popups.ts +++ b/frontend/src/ts/popups/simple-popups.ts @@ -734,7 +734,7 @@ list["resetAccount"] = new SimplePopup( Notifications.add("Resetting settings...", 0); UpdateConfig.reset(); Loader.show(); - Notifications.add("Resetting account and stats...", 0); + Notifications.add("Resetting account...", 0); const response = await Ape.users.reset(); if (response.status !== 200) { @@ -772,6 +772,71 @@ list["resetAccount"] = new SimplePopup( } ); +list["optOutOfLeaderboards"] = new SimplePopup( + "optOutOfLeaderboards", + "text", + "Opt out of leaderboards", + [ + { + placeholder: "Password", + type: "password", + initVal: "", + }, + ], + "Are you sure you want to opt out of leaderboards?", + "Opt out", + async (_thisPopup, password: string) => { + try { + const user = Auth?.currentUser; + if (!user) return; + if (user.providerData.find((p) => p?.providerId === "password")) { + const credential = EmailAuthProvider.credential( + user.email as string, + password + ); + await reauthenticateWithCredential(user, credential); + } else { + await reauthenticateWithPopup(user, AccountController.gmailProvider); + } + + Loader.show(); + const response = await Ape.users.optOutOfLeaderboards(); + + if (response.status !== 200) { + Loader.hide(); + return Notifications.add( + `Failed to opt out of leaderboards: ${response.message}`, + -1 + ); + } + Loader.hide(); + Notifications.add("Leaderboard opt out successful", 1); + setTimeout(() => { + location.reload(); + }, 3000); + } catch (e) { + const typedError = e as FirebaseError; + Loader.hide(); + if (typedError.code === "auth/wrong-password") { + Notifications.add("Incorrect password", -1); + } else { + Notifications.add("Something went wrong: " + e, -1); + } + } + }, + (thisPopup) => { + const user = Auth?.currentUser; + if (!user) return; + if (!user.providerData.find((p) => p?.providerId === "password")) { + thisPopup.inputs = []; + thisPopup.buttonText = "Reauthenticate to reset"; + } + }, + (_thisPopup) => { + // + } +); + list["clearTagPb"] = new SimplePopup( "clearTagPb", "text", @@ -1376,6 +1441,14 @@ $(".pageSettings #resetAccount").on("click", () => { list["resetAccount"].show(); }); +$(".pageSettings #optOutOfLeaderboardsButton").on("click", () => { + if (!ConnectionState.get()) { + Notifications.add("You are offline", 0, 2); + return; + } + list["optOutOfLeaderboards"].show(); +}); + $("#popups").on("click", "#apeKeysPopup .generateApeKey", () => { if (!ConnectionState.get()) { Notifications.add("You are offline", 0, 2); diff --git a/frontend/src/ts/types/types.d.ts b/frontend/src/ts/types/types.d.ts index 9097be841..8e5c6fede 100644 --- a/frontend/src/ts/types/types.d.ts +++ b/frontend/src/ts/types/types.d.ts @@ -565,6 +565,7 @@ declare namespace MonkeyTypes { inboxUnreadSize: number; streak: number; maxStreak: number; + lbOptOut?: boolean; } interface UserDetails { diff --git a/frontend/static/html/pages/settings.html b/frontend/static/html/pages/settings.html index c1312e069..8c5323749 100644 --- a/frontend/static/html/pages/settings.html +++ b/frontend/static/html/pages/settings.html @@ -2760,8 +2760,9 @@ reset settings
- Resets settings to the default (but doesn't touch your tags). Warning: - you can't undo this action! + Resets settings to the default (but doesn't touch your tags). +
+ You can't undo this action!
Resets all your personal bests (but doesn't delete any tests from your - history). Warning: you can't undo this action! + history). +
+ You can't undo this action!
+