From 3af0fa2932f3fbd7238b19ae6c7671b452f4d841 Mon Sep 17 00:00:00 2001 From: Lawrence Tong Date: Thu, 6 Aug 2026 20:59:53 -0400 Subject: [PATCH 1/2] feat: add a public /api/leaderboard endpoint Ranks every user by the number of questions they have heard. The data already existed behind /api/admin/leaderboard; this exposes it on the public, CORS-enabled API and widens it beyond raw play counts. database/account-info/leaderboard.js now also reports numCorrect, powers, tens, negs, tossupPoints, bonusPoints, points, pptu, ppb, accuracy, and averageCorrectCelerity. The existing fields are kept, so /api/admin/leaderboard and client/admin/leaderboard are unaffected. Also fixes three problems in that aggregation: - slice(0, limit) returned [] when limit was omitted, since Array.prototype.slice treats a null end index as 0. Omitting the limit now returns every row. - Users deleted since their buzzes were recorded produced rows with no username. They are dropped. - mergeTwoSortedArrays compares usernames with < and >, but the inputs were ordered by MongoDB's byte-wise sort. The two disagree outside ASCII, which split a non-ASCII user into two rows. Both inputs are now sorted in JS with the same comparison the merge uses, and the unindexed $sort stages are gone. The route catches rejections from the aggregation and returns 503. Express 4 does not forward a rejected promise to the error handler, so without this a database error takes down the process. Co-Authored-By: Claude Opus 5 --- client/tools/api-docs/bonus.html | 2 + client/tools/api-docs/check-answer.html | 2 + client/tools/api-docs/frequency-list.html | 2 + client/tools/api-docs/index.html | 12 + client/tools/api-docs/leaderboard.html | 148 +++++++++++++ .../tools/api-docs/multiplayer/room-list.html | 2 + client/tools/api-docs/num-packets.html | 2 + client/tools/api-docs/packet.html | 2 + client/tools/api-docs/query.html | 2 + .../tools/api-docs/question-stats/bonus.html | 2 + .../tools/api-docs/question-stats/tossup.html | 2 + client/tools/api-docs/random-bonus.html | 2 + client/tools/api-docs/random-name.html | 2 + client/tools/api-docs/random-tossup.html | 2 + client/tools/api-docs/report-question.html | 2 + client/tools/api-docs/schemas/index.html | 2 + client/tools/api-docs/set-list.html | 2 + client/tools/api-docs/tossup.html | 2 + database/account-info/leaderboard.js | 209 ++++++++++++++---- routes/api/index.js | 2 + routes/api/leaderboard.js | 39 ++++ 21 files changed, 401 insertions(+), 41 deletions(-) create mode 100644 client/tools/api-docs/leaderboard.html create mode 100644 routes/api/leaderboard.js diff --git a/client/tools/api-docs/bonus.html b/client/tools/api-docs/bonus.html index 77a948bdd..a9d2b8c59 100644 --- a/client/tools/api-docs/bonus.html +++ b/client/tools/api-docs/bonus.html @@ -33,6 +33,8 @@
Multiplayer
Question Stats
bonus tossup +
Leaderboard
+ leaderboard
Other
check-answer random-name diff --git a/client/tools/api-docs/check-answer.html b/client/tools/api-docs/check-answer.html index 1c30755e6..19ed8ca66 100644 --- a/client/tools/api-docs/check-answer.html +++ b/client/tools/api-docs/check-answer.html @@ -33,6 +33,8 @@
Multiplayer
Question Stats
bonus tossup +
Leaderboard
+ leaderboard
Other
check-answer random-name diff --git a/client/tools/api-docs/frequency-list.html b/client/tools/api-docs/frequency-list.html index 56f4c9252..96d965fb0 100644 --- a/client/tools/api-docs/frequency-list.html +++ b/client/tools/api-docs/frequency-list.html @@ -33,6 +33,8 @@
Multiplayer
Question Stats
bonus tossup +
Leaderboard
+ leaderboard
Other
check-answer random-name diff --git a/client/tools/api-docs/index.html b/client/tools/api-docs/index.html index 65fb66ee6..3cedf2d32 100644 --- a/client/tools/api-docs/index.html +++ b/client/tools/api-docs/index.html @@ -33,6 +33,8 @@
Multiplayer
Question Stats
bonus tossup +
Leaderboard
+ leaderboard
Other
check-answer random-name @@ -152,6 +154,16 @@
Question Stats
+
Leaderboard
+ +
Other
diff --git a/client/tools/api-docs/leaderboard.html b/client/tools/api-docs/leaderboard.html index 40ae2ce8a..19ac362d3 100644 --- a/client/tools/api-docs/leaderboard.html +++ b/client/tools/api-docs/leaderboard.html @@ -52,10 +52,27 @@

Results are recomputed at most once every five minutes.

+
+ This endpoint is not open. It returns every user's + username alongside their play stats, so it requires a key. Contact + a maintainer for access; unauthorized requests receive + 401. +
+

Parameters

    +
  • +
    + key: string + Required +
    +
    + The access key you were given. Requests with a missing or + incorrect key receive 401. +
    +
  • limit: number @@ -135,10 +152,19 @@

    Returns

    Errors

    -
    - Returns 503 with a JSON object containing an - error string if the rankings cannot be computed. -
    +
      +
    • + 401 +
      The key parameter is missing or incorrect.
      +
    • +
    • + 503 +
      + The rankings could not be computed. Returns a JSON + object containing an error string. +
      +
    • +
    diff --git a/routes/api/leaderboard.js b/routes/api/leaderboard.js index 06cb956eb..e64238f2c 100644 --- a/routes/api/leaderboard.js +++ b/routes/api/leaderboard.js @@ -1,6 +1,7 @@ import getLeaderboard, { lastGeneratedAt } from '../../database/account-info/leaderboard.js'; import validateInt from '../validators/int.js'; +import { createHash, timingSafeEqual } from 'crypto'; import { Router } from 'express'; // matches the five minute cache in database/account-info/leaderboard.js @@ -8,7 +9,36 @@ const MAX_AGE_SECONDS = 300; const router = Router(); +/** + * This endpoint returns every user's username and play stats, so it is gated + * behind a shared secret rather than served publicly. Set LEADERBOARD_KEY in + * the environment and share it only with sites you want to have access; + * requests without a matching ?key= are rejected. If LEADERBOARD_KEY is + * unset, every request is rejected rather than the endpoint quietly + * becoming public. + * @param {unknown} providedKey - req.query.key; unvalidated, so may be anything + * @returns {boolean} + */ +function isAuthorized (providedKey) { + const expectedKey = process.env.LEADERBOARD_KEY; + if (!expectedKey || typeof providedKey !== 'string') { return false; } + + const expected = Buffer.from(expectedKey); + const provided = Buffer.from(providedKey); + // encode as a fixed-length hash first so timingSafeEqual doesn't throw + // and comparing keys of different lengths doesn't leak the true length + return timingSafeEqual( + createHash('sha256').update(expected).digest(), + createHash('sha256').update(provided).digest() + ); +} + router.get('/', async (req, res) => { + if (!isAuthorized(req.query.key)) { + res.sendStatus(401); + return; + } + req.query = validateInt(req.query, 'limit', { defaultValue: 50, lowerBound: 1, upperBound: 500 }); req.query = validateInt(req.query, 'minQuestions', { defaultValue: 0, lowerBound: 0 }); const { limit, minQuestions } = req.query; @@ -28,7 +58,9 @@ router.get('/', async (req, res) => { .filter((row) => row.total >= minQuestions) .slice(0, limit); - res.set('Cache-Control', `public, max-age=${MAX_AGE_SECONDS}`); + // private: a shared/CDN cache must not serve this response to a caller + // that did not supply its own key + res.set('Cache-Control', `private, max-age=${MAX_AGE_SECONDS}`); res.json({ leaderboard, count: leaderboard.length,