diff --git a/backend/src/api/controllers/admin.ts b/backend/src/api/controllers/admin.ts new file mode 100644 index 000000000..0db718021 --- /dev/null +++ b/backend/src/api/controllers/admin.ts @@ -0,0 +1,5 @@ +import { MonkeyResponse } from "../../utils/monkey-response"; + +export async function test(): Promise { + return new MonkeyResponse("OK"); +} diff --git a/backend/src/api/routes/admin.ts b/backend/src/api/routes/admin.ts new file mode 100644 index 000000000..2fe6e0b96 --- /dev/null +++ b/backend/src/api/routes/admin.ts @@ -0,0 +1,18 @@ +// import joi from "joi"; +import { Router } from "express"; +import { authenticateRequest } from "../../middlewares/auth"; +import { asyncHandler, checkIfUserIsAdmin } from "../../middlewares/api-utils"; +import * as AdminController from "../controllers/admin"; + +const router = Router(); + +router.get( + "/", + authenticateRequest({ + noCache: true, + }), + checkIfUserIsAdmin(), + asyncHandler(AdminController.test) +); + +export default router; diff --git a/backend/src/api/routes/index.ts b/backend/src/api/routes/index.ts index d577fd3d8..bca77442d 100644 --- a/backend/src/api/routes/index.ts +++ b/backend/src/api/routes/index.ts @@ -8,6 +8,7 @@ import configs from "./configs"; import results from "./results"; import presets from "./presets"; import apeKeys from "./ape-keys"; +import admin from "./admin"; import configuration from "./configuration"; import { version } from "../../version"; import leaderboards from "./leaderboards"; @@ -37,6 +38,7 @@ const API_ROUTE_MAP = { "/leaderboards": leaderboards, "/quotes": quotes, "/ape-keys": apeKeys, + "/admin": admin, }; function addApiRoutes(app: Application): void { diff --git a/backend/src/dal/admin-uids.ts b/backend/src/dal/admin-uids.ts new file mode 100644 index 000000000..cd2bbfaf5 --- /dev/null +++ b/backend/src/dal/admin-uids.ts @@ -0,0 +1,10 @@ +import * as db from "../init/db"; + +export async function isAdmin(uid: string): Promise { + const doc = await db.collection("admin-uids").findOne({ uid }); + if (doc) { + return true; + } else { + return false; + } +} diff --git a/backend/src/middlewares/api-utils.ts b/backend/src/middlewares/api-utils.ts index ccda2670e..52d280df6 100644 --- a/backend/src/middlewares/api-utils.ts +++ b/backend/src/middlewares/api-utils.ts @@ -4,6 +4,7 @@ import MonkeyError from "../utils/error"; import { Response, NextFunction, RequestHandler } from "express"; import { handleMonkeyResponse, MonkeyResponse } from "../utils/monkey-response"; import { getUser } from "../dal/user"; +import { isAdmin } from "../dal/admin-uids"; interface ValidationOptions { criteria: (data: T) => boolean; @@ -40,6 +41,31 @@ function validateConfiguration( }; } +/** + * Check if the user is an admin before handling request. + * Note that this middleware must be used after authentication in the middleware stack. + */ +function checkIfUserIsAdmin(): RequestHandler { + return async ( + req: MonkeyTypes.Request, + _res: Response, + next: NextFunction + ) => { + try { + const { uid } = req.ctx.decodedToken; + const admin = await isAdmin(uid); + + if (!admin) { + throw new MonkeyError(403, "You don't have permission to do this."); + } + } catch (error) { + next(error); + } + + next(); + }; +} + /** * Check user permissions before handling request. * Note that this middleware must be used after authentication in the middleware stack. @@ -158,6 +184,7 @@ function useInProduction(middlewares: RequestHandler[]): RequestHandler[] { export { validateConfiguration, checkUserPermissions, + checkIfUserIsAdmin, asyncHandler, validateRequest, useInProduction, diff --git a/backend/src/middlewares/auth.ts b/backend/src/middlewares/auth.ts index 39f23922b..6e98cc4c5 100644 --- a/backend/src/middlewares/auth.ts +++ b/backend/src/middlewares/auth.ts @@ -17,6 +17,7 @@ interface RequestAuthenticationOptions { isPublic?: boolean; acceptApeKeys?: boolean; requireFreshToken?: boolean; + noCache?: boolean; } const DEFAULT_OPTIONS: RequestAuthenticationOptions = { @@ -149,7 +150,10 @@ async function authenticateWithBearerToken( options: RequestAuthenticationOptions ): Promise { try { - const decodedToken = await verifyIdToken(token, options.requireFreshToken); + const decodedToken = await verifyIdToken( + token, + options.requireFreshToken || options.noCache + ); if (options.requireFreshToken) { const now = Date.now(); diff --git a/frontend/src/ts/controllers/challenge-controller.ts b/frontend/src/ts/controllers/challenge-controller.ts index ac2ca74b4..cd5d7ea3d 100644 --- a/frontend/src/ts/controllers/challenge-controller.ts +++ b/frontend/src/ts/controllers/challenge-controller.ts @@ -231,6 +231,7 @@ export async function setup(challengeName: string): Promise { UpdateConfig.setMode("words", true); UpdateConfig.setDifficulty("normal", true); } else if (challenge.type === "customText") { + CustomText.setPopupTextareaState(challenge.parameters[0] as string); CustomText.setText((challenge.parameters[0] as string).split(" ")); CustomText.setIsTimeRandom(false); CustomText.setIsWordRandom(challenge.parameters[1] as boolean); @@ -249,6 +250,7 @@ export async function setup(challengeName: string): Promise { let text = scriptdata.trim(); text = text.replace(/[\n\r\t ]/gm, " "); text = text.replace(/ +/gm, " "); + CustomText.setPopupTextareaState(text); CustomText.setText(text.split(" ")); CustomText.setIsWordRandom(false); CustomText.setTime(-1); diff --git a/frontend/src/ts/elements/profile.ts b/frontend/src/ts/elements/profile.ts index e192f6303..e6b579985 100644 --- a/frontend/src/ts/elements/profile.ts +++ b/frontend/src/ts/elements/profile.ts @@ -6,6 +6,7 @@ import { getHTMLById } from "../controllers/badge-controller"; import { throttle } from "throttle-debounce"; import * as EditProfilePopup from "../popups/edit-profile-popup"; import * as ActivePage from "../states/active-page"; +import { formatDistanceToNowStrict } from "date-fns/esm"; type ProfileViewPaths = "profile" | "account"; @@ -93,6 +94,8 @@ export async function update( const balloonText = `${diffDays} day${diffDays != 1 ? "s" : ""} ago`; details.find(".joined").text(joinedText).attr("aria-label", balloonText); + let hoverText = ""; + if (profile.streak && profile?.streak > 1) { details .find(".streak") @@ -100,17 +103,42 @@ export async function update( `Current streak: ${profile.streak} ${ profile.streak === 1 ? "day" : "days" }` - ) - .attr( - "aria-label", - `Longest streak: ${profile.maxStreak} ${ - profile.maxStreak === 1 ? "day" : "days" - }` ); + hoverText = `Longest streak: ${profile.maxStreak} ${ + profile.maxStreak === 1 ? "day" : "days" + }`; } else { - details.find(".streak").text("").attr("aria-label", ""); + details.find(".streak").text(""); + hoverText = ""; } + if (where === "account") { + const results = DB.getSnapshot()?.results; + const lastResult = results?.[0]; + + const dayInMilis = 1000 * 60 * 60 * 24; + const timeDif = formatDistanceToNowStrict( + Misc.getCurrentDayTimestamp() + dayInMilis + ); + + if (lastResult) { + //check if the last result is from today + const isToday = Misc.isToday(lastResult.timestamp); + if (isToday) { + hoverText += `\nClaimed today: yes`; + hoverText += `\nCome back in: ${timeDif}`; + } else { + hoverText += `\nClaimed today: no`; + hoverText += `\nStreak lost in: ${timeDif}`; + } + } + } + + details + .find(".streak") + .attr("aria-label", hoverText) + .attr("data-balloon-break", ""); + const typingStatsEl = details.find(".typingStats"); typingStatsEl .find(".started .value") diff --git a/frontend/src/ts/pages/account.ts b/frontend/src/ts/pages/account.ts index 4360c6315..8e1dd59b9 100644 --- a/frontend/src/ts/pages/account.ts +++ b/frontend/src/ts/pages/account.ts @@ -798,9 +798,17 @@ function fillContent(): void { Math.floor(maxWpmChartVal) + (10 - (Math.floor(maxWpmChartVal) % 10)); if (!Config.startGraphsAtZero) { - accountHistoryScaleOptions["wpm"].min = Math.floor(minWpmChartVal); + const minWpmChartValFloor = Math.floor(minWpmChartVal); + + accountHistoryScaleOptions["wpm"].min = minWpmChartValFloor; + accountHistoryScaleOptions["pb"].min = minWpmChartValFloor; + accountHistoryScaleOptions["wpmAvgTen"].min = minWpmChartValFloor; + accountHistoryScaleOptions["wpmAvgHundred"].min = minWpmChartValFloor; } else { accountHistoryScaleOptions["wpm"].min = 0; + accountHistoryScaleOptions["pb"].min = 0; + accountHistoryScaleOptions["wpmAvgTen"].min = 0; + accountHistoryScaleOptions["wpmAvgHundred"].min = 0; } if (!chartData || chartData.length == 0) { diff --git a/frontend/src/ts/test/practise-words.ts b/frontend/src/ts/test/practise-words.ts index 7af2473ee..c02a6464e 100644 --- a/frontend/src/ts/test/practise-words.ts +++ b/frontend/src/ts/test/practise-words.ts @@ -93,6 +93,7 @@ export function init(missed: boolean, slow: boolean): boolean { const numbers = before.numbers === null ? Config.numbers : before.numbers; UpdateConfig.setMode("custom", true); + CustomText.setPopupTextareaState(newCustomText.join(CustomText.delimiter)); CustomText.setText(newCustomText); CustomText.setIsWordRandom(true); CustomText.setIsTimeRandom(false); diff --git a/frontend/src/ts/test/result.ts b/frontend/src/ts/test/result.ts index 8a5a90253..a49244748 100644 --- a/frontend/src/ts/test/result.ts +++ b/frontend/src/ts/test/result.ts @@ -823,9 +823,6 @@ export async function update( $("#words").empty(); ChartController.result.resize(); - if (Config.alwaysShowWordsHistory && Config.burstHeatmap) { - TestUI.applyBurstHeatmap(); - } $("#result").trigger("focus"); window.scrollTo({ top: 0 }); $("#testModesNotice").addClass("hidden"); @@ -838,7 +835,7 @@ export async function update( 125 ); if (Config.alwaysShowWordsHistory && !GlarsesMode.get()) { - TestUI.toggleResultWords(); + TestUI.toggleResultWords(true); } Keymap.hide(); AdController.updateTestPageAds(true); diff --git a/frontend/src/ts/test/test-logic.ts b/frontend/src/ts/test/test-logic.ts index 862bbe019..e9cf6744e 100644 --- a/frontend/src/ts/test/test-logic.ts +++ b/frontend/src/ts/test/test-logic.ts @@ -881,13 +881,17 @@ export async function init(): Promise { } if (Config.tapeMode !== "off" && !language.leftToRight) { - Notifications.add("This language does not support tape mode.", 0); + Notifications.add("This language does not support tape mode.", 0, { + important: true, + }); UpdateConfig.setTapeMode("off"); } if (Config.lazyMode === true && language.noLazyMode) { rememberLazyMode = true; - Notifications.add("This language does not support lazy mode.", 0); + Notifications.add("This language does not support lazy mode.", 0, { + important: true, + }); UpdateConfig.setLazyMode(false, true); } else if (rememberLazyMode === true && !language.noLazyMode) { UpdateConfig.setLazyMode(true, true); @@ -1078,7 +1082,8 @@ export async function init(): Promise { `No ${Config.language .replace(/_\d*k$/g, "") .replace(/_/g, " ")} quotes found`, - 0 + 0, + { important: true } ); if (Auth?.currentUser) { QuoteSubmitPopup.show(false); @@ -1095,7 +1100,7 @@ export async function init(): Promise { ); if (targetQuote === undefined) { rq = quotesCollection.groups[0][0]; - Notifications.add("Quote Id Does Not Exist", 0); + Notifications.add("Quote Id Does Not Exist", 0, { important: true }); } else { rq = targetQuote; } @@ -1105,7 +1110,7 @@ export async function init(): Promise { ); if (randomQuote === null) { - Notifications.add("No favorite quotes found", 0); + Notifications.add("No favorite quotes found", 0, { important: true }); UpdateConfig.setQuoteLength(-1); restart(); return; @@ -1115,7 +1120,9 @@ export async function init(): Promise { } else { const randomQuote = QuotesController.getRandomQuote(); if (randomQuote === null) { - Notifications.add("No quotes found for selected quote length", 0); + Notifications.add("No quotes found for selected quote length", 0, { + important: true, + }); TestUI.setTestRestarting(false); return; } @@ -1312,6 +1319,7 @@ export async function retrySavingResult(): Promise { 0, { duration: 5, + important: true, } ); @@ -1668,17 +1676,22 @@ export async function finish(difficultyFailed = false): Promise { CustomText.setCustomTextLongProgress(customTextName, newProgress); Notifications.add("Long custom text progress saved", 1, { duration: 5, + important: true, }); let newText = CustomText.getCustomText(customTextName, true); newText = newText.slice(newProgress); + CustomText.setPopupTextareaState(newText.join(CustomText.delimiter)); CustomText.setText(newText); } else { // They finished the test CustomText.setCustomTextLongProgress(customTextName, 0); - CustomText.setText(CustomText.getCustomText(customTextName, true)); + const text = CustomText.getCustomText(customTextName, true); + CustomText.setPopupTextareaState(text.join(CustomText.delimiter)); + CustomText.setText(text); Notifications.add("Long custom text completed", 1, { duration: 5, + important: true, }); } } @@ -1762,6 +1775,7 @@ async function saveResult( Notifications.add("Result not saved: disabled by user", -1, { duration: 3, customTitle: "Notice", + important: true, }); AccountButton.loading(false); return; @@ -1771,6 +1785,7 @@ async function saveResult( Notifications.add("Result not saved: offline", -1, { duration: 2, customTitle: "Notice", + important: true, }); AccountButton.loading(false); retrySaving.canRetry = true; @@ -1892,7 +1907,7 @@ async function saveResult( $("#retrySavingResultButton").addClass("hidden"); if (isRetrying) { - Notifications.add("Result saved", 1); + Notifications.add("Result saved", 1, { important: true }); } } diff --git a/frontend/src/ts/test/test-ui.ts b/frontend/src/ts/test/test-ui.ts index ca07878eb..cd3bb3717 100644 --- a/frontend/src/ts/test/test-ui.ts +++ b/frontend/src/ts/test/test-ui.ts @@ -985,7 +985,7 @@ async function loadWordsHistory(): Promise { return true; } -export function toggleResultWords(): void { +export function toggleResultWords(noAnimation = false): void { if (resultVisible) { if ($("#resultWordsHistory").stop(true, true).hasClass("hidden")) { //show @@ -1001,7 +1001,7 @@ export function toggleResultWords(): void { $("#resultWordsHistory") .removeClass("hidden") .css("display", "none") - .slideDown(250, () => { + .slideDown(noAnimation ? 0 : 250, () => { if (Config.burstHeatmap) { applyBurstHeatmap(); } @@ -1014,7 +1014,7 @@ export function toggleResultWords(): void { $("#resultWordsHistory") .removeClass("hidden") .css("display", "none") - .slideDown(250); + .slideDown(noAnimation ? 0 : 250); } } else { //hide diff --git a/frontend/src/ts/utils/misc.ts b/frontend/src/ts/utils/misc.ts index 87a95e09e..87593e235 100644 --- a/frontend/src/ts/utils/misc.ts +++ b/frontend/src/ts/utils/misc.ts @@ -1522,3 +1522,26 @@ export async function checkIfLanguageSupportsZipf( if (lang.orderedByFrequency === false) return "no"; return "unknown"; } + +export function getStartOfDayTimestamp(timestamp: number): number { + return timestamp - (timestamp % 86400000); +} + +export function getCurrentDayTimestamp(): number { + const currentTime = Date.now(); + return getStartOfDayTimestamp(currentTime); +} + +export function isYesterday(timestamp: number): boolean { + const yesterday = getStartOfDayTimestamp(Date.now() - 86400000); + const date = getStartOfDayTimestamp(timestamp); + + return yesterday === date; +} + +export function isToday(timestamp: number): boolean { + const today = getStartOfDayTimestamp(Date.now()); + const date = getStartOfDayTimestamp(timestamp); + + return today === date; +} diff --git a/frontend/src/ts/utils/url-handler.ts b/frontend/src/ts/utils/url-handler.ts index 9b5c9c787..b869208b8 100644 --- a/frontend/src/ts/utils/url-handler.ts +++ b/frontend/src/ts/utils/url-handler.ts @@ -124,6 +124,9 @@ export function loadTestSettingsFromUrl(getOverride?: string): void { if (de[2]) { const customTextSettings = de[2]; + CustomText.setPopupTextareaState( + customTextSettings["text"].join(customTextSettings["delimiter"]) + ); CustomText.setText(customTextSettings["text"]); CustomText.setIsTimeRandom(customTextSettings["isTimeRandom"]); CustomText.setIsWordRandom(customTextSettings["isWordRandom"]); diff --git a/frontend/static/quotes/english.json b/frontend/static/quotes/english.json index 569cc3deb..7b65c38be 100644 --- a/frontend/static/quotes/english.json +++ b/frontend/static/quotes/english.json @@ -38812,6 +38812,12 @@ "source": "The World Was Wide Enough, Hamilton", "length": 81, "id": 6814 + }, + { + "text": "You are a very special person. There is only one like you in the whole world. There's never been anyone exactly like you before, and there will never be again. Only you. And people can like you exactly as you are.", + "source": "Fred Rogers", + "length": 213, + "id": 6815 } ] }