From b06b9f73e52645ee384f9065ff293de5e0a73951 Mon Sep 17 00:00:00 2001 From: Christian Fehmer Date: Mon, 9 Sep 2024 10:39:08 +0200 Subject: [PATCH] refactor: rework rate limiting (@fehmer) (#5845) !nuf --- .github/workflows/monkey-ci.yml | 2 +- backend/__tests__/__testData__/rate-limit.ts | 29 + .../__tests__/api/controllers/admin.spec.ts | 58 +- .../__tests__/api/controllers/result.spec.ts | 33 + backend/__tests__/setup-tests.ts | 1 + backend/__tests__/tsconfig.json | 2 +- backend/__tests__/vitest.d.ts | 24 + backend/package.json | 2 +- backend/scripts/openapi.ts | 86 ++- backend/src/api/routes/admin.ts | 4 +- backend/src/api/routes/ape-keys.ts | 9 +- backend/src/api/routes/configs.ts | 5 - backend/src/api/routes/configuration.ts | 5 +- backend/src/api/routes/index.ts | 3 +- backend/src/api/routes/leaderboards.ts | 12 +- backend/src/api/routes/presets.ts | 5 - backend/src/api/routes/psas.ts | 3 +- backend/src/api/routes/public.ts | 3 - backend/src/api/routes/quotes.ts | 12 +- backend/src/api/routes/results.ts | 10 +- backend/src/api/routes/users.ts | 58 +- backend/src/middlewares/ape-rate-limit.ts | 71 --- backend/src/middlewares/auth.ts | 3 +- backend/src/middlewares/rate-limit.ts | 589 ++++-------------- backend/src/types/types.d.ts | 4 + .../src/ts/controllers/sound-controller.ts | 2 +- .../src/ts/elements/account/result-filters.ts | 4 +- frontend/src/ts/elements/keymap.ts | 2 +- frontend/src/ts/modals/quote-search.ts | 2 +- frontend/src/ts/pages/account.ts | 2 +- package.json | 2 +- packages/contracts/src/admin.ts | 7 +- packages/contracts/src/ape-keys.ts | 18 +- packages/contracts/src/configs.ts | 25 +- packages/contracts/src/configuration.ts | 20 +- packages/contracts/src/dev.ts | 10 +- packages/contracts/src/leaderboards.ts | 23 +- packages/contracts/src/presets.ts | 22 +- packages/contracts/src/psas.ts | 11 +- packages/contracts/src/public.ts | 11 +- packages/contracts/src/quotes.ts | 32 +- packages/contracts/src/rate-limit/index.ts | 374 +++++++++++ packages/contracts/src/results.ts | 33 +- packages/contracts/src/schemas/api.ts | 17 + packages/contracts/src/users.ts | 167 +++-- pnpm-lock.yaml | 24 +- 46 files changed, 1041 insertions(+), 800 deletions(-) create mode 100644 backend/__tests__/__testData__/rate-limit.ts create mode 100644 backend/__tests__/vitest.d.ts delete mode 100644 backend/src/middlewares/ape-rate-limit.ts create mode 100644 packages/contracts/src/rate-limit/index.ts diff --git a/.github/workflows/monkey-ci.yml b/.github/workflows/monkey-ci.yml index 0757a783d..7193bb64d 100644 --- a/.github/workflows/monkey-ci.yml +++ b/.github/workflows/monkey-ci.yml @@ -134,7 +134,7 @@ jobs: version: ${{ env.PNPM_VERSION }} - name: Install prettier - run: pnpm add -g prettier@2.5.1 + run: pnpm add -g prettier@2.8.8 - name: Get changed files if: github.event_name == 'pull_request' diff --git a/backend/__tests__/__testData__/rate-limit.ts b/backend/__tests__/__testData__/rate-limit.ts new file mode 100644 index 000000000..39d6515cf --- /dev/null +++ b/backend/__tests__/__testData__/rate-limit.ts @@ -0,0 +1,29 @@ +import { REQUEST_MULTIPLIER } from "../../src/middlewares/rate-limit"; +import { MatcherResult, ExpectedRateLimit } from "../vitest"; +import { Test as SuperTest } from "supertest"; + +export function enableRateLimitExpects(): void { + expect.extend({ + toBeRateLimited: async ( + received: SuperTest, + expected: ExpectedRateLimit + ): Promise => { + const now = Date.now(); + const { headers } = await received.expect(200); + + const max = + parseInt(headers["x-ratelimit-limit"] as string) / REQUEST_MULTIPLIER; + const windowMs = + parseInt(headers["x-ratelimit-reset"] as string) * 1000 - now; + + return { + pass: + max === expected.max && Math.abs(expected.windowMs - windowMs) < 2500, + message: () => + "Rate limit max not matching or windowMs is off by more then 2500ms", + actual: { max, windowMs }, + expected: expected, + }; + }, + }); +} diff --git a/backend/__tests__/api/controllers/admin.spec.ts b/backend/__tests__/api/controllers/admin.spec.ts index 9cf789c96..7c362506f 100644 --- a/backend/__tests__/api/controllers/admin.spec.ts +++ b/backend/__tests__/api/controllers/admin.spec.ts @@ -8,12 +8,14 @@ import * as ReportDal from "../../../src/dal/report"; import GeorgeQueue from "../../../src/queues/george-queue"; import * as AuthUtil from "../../../src/utils/auth"; import _ from "lodash"; +import { enableRateLimitExpects } from "../../__testData__/rate-limit"; const mockApp = request(app); const configuration = Configuration.getCachedConfiguration(); const uid = new ObjectId().toHexString(); +enableRateLimitExpects(); -describe("ApeKeyController", () => { +describe("AdminController", () => { const isAdminMock = vi.spyOn(AdminUuidDal, "isAdmin"); beforeEach(async () => { @@ -50,6 +52,11 @@ describe("ApeKeyController", () => { mockApp.get("/admin").set("authorization", `Uid ${uid}`) ); }); + it("should be rate limited", async () => { + await expect( + mockApp.get("/admin").set("authorization", `Uid ${uid}`) + ).toBeRateLimited({ max: 1, windowMs: 5000 }); + }); }); describe("toggle ban", () => { @@ -167,6 +174,22 @@ describe("ApeKeyController", () => { .set("authorization", `Uid ${uid}`) ); }); + it("should be rate limited", async () => { + //GIVEN + const victimUid = new ObjectId().toHexString(); + getUserMock.mockResolvedValue({ + banned: false, + discordId: "discordId", + } as any); + + //WHEN + await expect( + mockApp + .post("/admin/toggleBan") + .send({ uid: victimUid }) + .set("authorization", `Uid ${uid}`) + ).toBeRateLimited({ max: 1, windowMs: 5000 }); + }); }); describe("accept reports", () => { const getReportsMock = vi.spyOn(ReportDal, "getReports"); @@ -269,6 +292,18 @@ describe("ApeKeyController", () => { .set("authorization", `Uid ${uid}`) ); }); + it("should be rate limited", async () => { + //GIVEN + getReportsMock.mockResolvedValue([{ id: "1", reason: "one" } as any]); + + //WHEN + await expect( + mockApp + .post("/admin/report/accept") + .send({ reports: [{ reportId: "1" }] }) + .set("authorization", `Uid ${uid}`) + ).toBeRateLimited({ max: 1, windowMs: 5000 }); + }); }); describe("reject reports", () => { const getReportsMock = vi.spyOn(ReportDal, "getReports"); @@ -374,6 +409,18 @@ describe("ApeKeyController", () => { .set("authorization", `Uid ${uid}`) ); }); + it("should be rate limited", async () => { + //GIVEN + getReportsMock.mockResolvedValue([{ id: "1", reason: "one" } as any]); + + //WHEN + await expect( + mockApp + .post("/admin/report/reject") + .send({ reports: [{ reportId: "1" }] }) + .set("authorization", `Uid ${uid}`) + ).toBeRateLimited({ max: 1, windowMs: 5000 }); + }); }); describe("send forgot password email", () => { const sendForgotPasswordEmailMock = vi.spyOn( @@ -405,6 +452,15 @@ describe("ApeKeyController", () => { "meowdec@example.com" ); }); + it("should be rate limited", async () => { + //WHEN + await expect( + mockApp + .post("/admin/sendForgotPasswordEmail") + .send({ email: "meowdec@example.com" }) + .set("authorization", `Uid ${uid}`) + ).toBeRateLimited({ max: 1, windowMs: 5000 }); + }); }); async function expectFailForNonAdmin(call: SuperTest): Promise { diff --git a/backend/__tests__/api/controllers/result.spec.ts b/backend/__tests__/api/controllers/result.spec.ts index 9e1851442..07981f614 100644 --- a/backend/__tests__/api/controllers/result.spec.ts +++ b/backend/__tests__/api/controllers/result.spec.ts @@ -9,6 +9,7 @@ import * as AuthUtils from "../../../src/utils/auth"; import { DecodedIdToken } from "firebase-admin/lib/auth/token-verifier"; import { ObjectId } from "mongodb"; import { mockAuthenticateWithApeKey } from "../../__testData__/auth"; +import { enableRateLimitExpects } from "../../__testData__/rate-limit"; const uid = "123456"; const mockDecodedToken: DecodedIdToken = { @@ -20,6 +21,7 @@ const mockDecodedToken: DecodedIdToken = { const mockApp = request(app); const configuration = Configuration.getCachedConfiguration(); +enableRateLimitExpects(); describe("result controller test", () => { const verifyIdTokenMock = vi.spyOn(AuthUtils, "verifyIdToken"); @@ -322,6 +324,21 @@ describe("result controller test", () => { expect(body.data[1]).not.toHaveProperty("correctChars"); expect(body.data[1]).not.toHaveProperty("incorrectChars"); }); + it("should be rate limited", async () => { + await expect( + mockApp.get("/results").set("Authorization", `Bearer ${uid}`) + ).toBeRateLimited({ max: 60, windowMs: 60 * 60 * 1000 }); + }); + it("should be rate limited for ape keys", async () => { + //GIVEN + await acceptApeKeys(true); + const apeKey = await mockAuthenticateWithApeKey(uid, await configuration); + + //WHEN + await expect( + mockApp.get("/results").set("Authorization", `ApeKey ${apeKey}`) + ).toBeRateLimited({ max: 30, windowMs: 24 * 60 * 60 * 1000 }); + }); }); describe("getLastResult", () => { const getLastResultMock = vi.spyOn(ResultDal, "getLastResult"); @@ -385,6 +402,22 @@ describe("result controller test", () => { expect(body.data).not.toHaveProperty("correctChars"); expect(body.data).not.toHaveProperty("incorrectChars"); }); + it("should rate limit get last result with ape key", async () => { + //GIVEN + const result = givenDbResult(uid, { + charStats: undefined, + incorrectChars: 5, + correctChars: 12, + }); + getLastResultMock.mockResolvedValue(result); + await acceptApeKeys(true); + const apeKey = await mockAuthenticateWithApeKey(uid, await configuration); + + //WHEN + await expect( + mockApp.get("/results/last").set("Authorization", `ApeKey ${apeKey}`) + ).toBeRateLimited({ max: 30, windowMs: 60 * 1000 }); //should use defaultApeRateLimit + }); }); describe("deleteAll", () => { const deleteAllMock = vi.spyOn(ResultDal, "deleteAll"); diff --git a/backend/__tests__/setup-tests.ts b/backend/__tests__/setup-tests.ts index 0a1f16757..693b0eacb 100644 --- a/backend/__tests__/setup-tests.ts +++ b/backend/__tests__/setup-tests.ts @@ -2,6 +2,7 @@ import { Collection, Db, MongoClient, WithId } from "mongodb"; import { afterAll, beforeAll, afterEach } from "vitest"; import * as MongoDbMock from "vitest-mongodb"; import { MongoDbMockConfig } from "./global-setup"; +import { enableRateLimitExpects } from "./__testData__/rate-limit"; process.env["MODE"] = "dev"; //process.env["MONGOMS_DISTRO"] = "ubuntu-22.04"; diff --git a/backend/__tests__/tsconfig.json b/backend/__tests__/tsconfig.json index 1069df217..001f654f4 100644 --- a/backend/__tests__/tsconfig.json +++ b/backend/__tests__/tsconfig.json @@ -7,6 +7,6 @@ "ts-node": { "files": true }, - "files": ["../src/types/types.d.ts"], + "files": ["../src/types/types.d.ts", "vitest.d.ts"], "include": ["./**/*.ts", "./**/*.spec.ts", "./setup-tests.ts"] } diff --git a/backend/__tests__/vitest.d.ts b/backend/__tests__/vitest.d.ts new file mode 100644 index 000000000..5b124763b --- /dev/null +++ b/backend/__tests__/vitest.d.ts @@ -0,0 +1,24 @@ +import type { Assertion, AsymmetricMatchersContaining } from "vitest"; +import type { Test as SuperTest } from "supertest"; + +type ExpectedRateLimit = { + /** max calls */ + max: number; + /** window in milliseconds. Needs to be within 2500ms */ + windowMs: number; +}; +interface RestRequestMatcher { + toBeRateLimited: (expected: ExpectedRateLimit) => RestRequestMatcher; +} + +declare module "vitest" { + interface Assertion extends RestRequestMatcher {} + interface AsymmetricMatchersContaining extends RestRequestMatcher {} +} + +interface MatcherResult { + pass: boolean; + message: () => string; + actual?: unknown; + expected?: unknown; +} diff --git a/backend/package.json b/backend/package.json index ca7297436..6e976b455 100644 --- a/backend/package.json +++ b/backend/package.json @@ -35,7 +35,7 @@ "date-fns": "3.6.0", "dotenv": "16.4.5", "express": "4.19.2", - "express-rate-limit": "6.2.1", + "express-rate-limit": "7.4.0", "firebase-admin": "12.0.0", "helmet": "4.6.0", "ioredis": "4.28.5", diff --git a/backend/scripts/openapi.ts b/backend/scripts/openapi.ts index a1394736f..ffa5648df 100644 --- a/backend/scripts/openapi.ts +++ b/backend/scripts/openapi.ts @@ -1,8 +1,18 @@ import { generateOpenApi } from "@ts-rest/open-api"; import { contract } from "@monkeytype/contracts/index"; import { writeFileSync, mkdirSync } from "fs"; -import { EndpointMetadata } from "@monkeytype/contracts/schemas/api"; +import { + ApeKeyRateLimit, + EndpointMetadata, +} from "@monkeytype/contracts/schemas/api"; import type { OpenAPIObject } from "openapi3-ts"; +import { + getLimits, + limits, + RateLimit, + Window, +} from "@monkeytype/contracts/rate-limit/index"; +import { formatDuration } from "date-fns"; type SecurityRequirementObject = { [name: string]: string[]; @@ -130,11 +140,19 @@ export function getOpenApi(): OpenAPIObject { { jsonQuery: true, setOperationId: "concatenated-path", - operationMapper: (operation, route) => ({ - ...operation, - ...addAuth(route.metadata as EndpointMetadata), - ...addTags(route.metadata as EndpointMetadata), - }), + operationMapper: (operation, route) => { + const metadata = route.metadata as EndpointMetadata; + + addRateLimit(operation, metadata); + + const result = { + ...operation, + ...addAuth(metadata), + ...addTags(metadata), + }; + + return result; + }, } ); return openApiDocument; @@ -167,6 +185,62 @@ function addTags(metadata: EndpointMetadata | undefined): object { }; } +function addRateLimit(operation, metadata: EndpointMetadata | undefined): void { + if (metadata === undefined || metadata.rateLimit === undefined) return; + const okResponse = operation.responses["200"]; + if (okResponse === undefined) return; + + if (!operation.description.trim().endsWith(".")) operation.description += "."; + + operation.description += getRateLimitDescription(metadata.rateLimit); + + okResponse["headers"] = { + ...okResponse["headers"], + "x-ratelimit-limit": { + schema: { type: "integer" }, + description: "The number of allowed requests in the current period", + }, + "x-ratelimit-remaining": { + schema: { type: "integer" }, + description: "The number of remaining requests in the current period", + }, + "x-ratelimit-reset": { + schema: { type: "integer" }, + description: "The timestamp of the start of the next period", + }, + }; +} + +function getRateLimitDescription(limit: RateLimit | ApeKeyRateLimit): string { + const limits = getLimits(limit); + + let result = ` This operation can be called up to ${ + limits.limiter.max + } times ${formatWindow(limits.limiter.window)} for regular users`; + + if (limits.apeKeyLimiter !== undefined) { + result += ` and up to ${limits.apeKeyLimiter.max} times ${formatWindow( + limits.apeKeyLimiter.window + )} with ApeKeys`; + } + + return result + "."; +} + +function formatWindow(window: Window): string { + if (typeof window === "number") { + const seconds = Math.floor(window / 1000); + const duration = formatDuration({ + hours: Math.floor(seconds / 3600), + minutes: Math.floor(seconds / 60) % 60, + seconds: seconds % 60, + }); + + return `every ${duration}`; + } + return "per " + window; +} + //detect if we run this as a main if (require.main === module) { const args = process.argv.slice(2); diff --git a/backend/src/api/routes/admin.ts b/backend/src/api/routes/admin.ts index 882249567..4bed00ea3 100644 --- a/backend/src/api/routes/admin.ts +++ b/backend/src/api/routes/admin.ts @@ -1,5 +1,5 @@ // import joi from "joi"; -import { adminLimit } from "../../middlewares/rate-limit"; + import * as AdminController from "../controllers/admin"; import { adminContract } from "@monkeytype/contracts/admin"; @@ -9,8 +9,6 @@ import { checkIfUserIsAdmin } from "../../middlewares/permission"; import { callController } from "../ts-rest-adapter"; const commonMiddleware = [ - adminLimit, - validate({ criteria: (configuration) => { return configuration.admin.endpointsEnabled; diff --git a/backend/src/api/routes/ape-keys.ts b/backend/src/api/routes/ape-keys.ts index 56cb0a465..d59aca12e 100644 --- a/backend/src/api/routes/ape-keys.ts +++ b/backend/src/api/routes/ape-keys.ts @@ -1,6 +1,5 @@ import { apeKeysContract } from "@monkeytype/contracts/ape-keys"; import { initServer } from "@ts-rest/express"; -import * as RateLimit from "../../middlewares/rate-limit"; import * as ApeKeyController from "../controllers/ape-key"; import { callController } from "../ts-rest-adapter"; import { checkUserPermissions } from "../../middlewares/permission"; @@ -24,19 +23,19 @@ const commonMiddleware = [ const s = initServer(); export default s.router(apeKeysContract, { get: { - middleware: [...commonMiddleware, RateLimit.apeKeysGet], + middleware: commonMiddleware, handler: async (r) => callController(ApeKeyController.getApeKeys)(r), }, add: { - middleware: [...commonMiddleware, RateLimit.apeKeysGenerate], + middleware: commonMiddleware, handler: async (r) => callController(ApeKeyController.generateApeKey)(r), }, save: { - middleware: [...commonMiddleware, RateLimit.apeKeysUpdate], + middleware: commonMiddleware, handler: async (r) => callController(ApeKeyController.editApeKey)(r), }, delete: { - middleware: [...commonMiddleware, RateLimit.apeKeysDelete], + middleware: commonMiddleware, handler: async (r) => callController(ApeKeyController.deleteApeKey)(r), }, }); diff --git a/backend/src/api/routes/configs.ts b/backend/src/api/routes/configs.ts index 132e74f34..f37a7d78e 100644 --- a/backend/src/api/routes/configs.ts +++ b/backend/src/api/routes/configs.ts @@ -1,6 +1,5 @@ import { configsContract } from "@monkeytype/contracts/configs"; import { initServer } from "@ts-rest/express"; -import * as RateLimit from "../../middlewares/rate-limit"; import * as ConfigController from "../controllers/config"; import { callController } from "../ts-rest-adapter"; @@ -8,16 +7,12 @@ const s = initServer(); export default s.router(configsContract, { get: { - middleware: [RateLimit.configGet], handler: async (r) => callController(ConfigController.getConfig)(r), }, - save: { - middleware: [RateLimit.configUpdate], handler: async (r) => callController(ConfigController.saveConfig)(r), }, delete: { - middleware: [RateLimit.configDelete], handler: async (r) => callController(ConfigController.deleteConfig)(r), }, }); diff --git a/backend/src/api/routes/configuration.ts b/backend/src/api/routes/configuration.ts index f7b7b160d..e0b5df40e 100644 --- a/backend/src/api/routes/configuration.ts +++ b/backend/src/api/routes/configuration.ts @@ -1,7 +1,6 @@ import { configurationContract } from "@monkeytype/contracts/configuration"; import { initServer } from "@ts-rest/express"; import { checkIfUserIsAdmin } from "../../middlewares/permission"; -import * as RateLimit from "../../middlewares/rate-limit"; import * as ConfigurationController from "../controllers/configuration"; import { callController } from "../ts-rest-adapter"; @@ -14,12 +13,12 @@ export default s.router(configurationContract, { }, update: { - middleware: [checkIfUserIsAdmin(), RateLimit.adminLimit], + middleware: [checkIfUserIsAdmin()], handler: async (r) => callController(ConfigurationController.updateConfiguration)(r), }, getSchema: { - middleware: [checkIfUserIsAdmin(), RateLimit.adminLimit], + middleware: [checkIfUserIsAdmin()], handler: async (r) => callController(ConfigurationController.getSchema)(r), }, }); diff --git a/backend/src/api/routes/index.ts b/backend/src/api/routes/index.ts index 294b7406b..786b488d8 100644 --- a/backend/src/api/routes/index.ts +++ b/backend/src/api/routes/index.ts @@ -34,6 +34,7 @@ import { createExpressEndpoints, initServer } from "@ts-rest/express"; import { ZodIssue } from "zod"; import { MonkeyValidationError } from "@monkeytype/contracts/schemas/api"; import { authenticateTsRestRequest } from "../../middlewares/auth"; +import { rateLimitRequest } from "../../middlewares/rate-limit"; const pathOverride = process.env["API_PATH_OVERRIDE"]; const BASE_ROUTE = pathOverride !== undefined ? `/${pathOverride}` : ""; @@ -111,7 +112,7 @@ function applyTsRestApiRoutes(app: IRouter): void { .status(422) .json({ message, validationErrors } as MonkeyValidationError); }, - globalMiddleware: [authenticateTsRestRequest()], + globalMiddleware: [authenticateTsRestRequest(), rateLimitRequest()], }); } diff --git a/backend/src/api/routes/leaderboards.ts b/backend/src/api/routes/leaderboards.ts index 08fffee8e..171fa09e6 100644 --- a/backend/src/api/routes/leaderboards.ts +++ b/backend/src/api/routes/leaderboards.ts @@ -1,7 +1,5 @@ import { initServer } from "@ts-rest/express"; -import { withApeRateLimiter } from "../../middlewares/ape-rate-limit"; import { validate } from "../../middlewares/configuration"; -import * as RateLimit from "../../middlewares/rate-limit"; import * as LeaderboardController from "../controllers/leaderboard"; import { leaderboardsContract } from "@monkeytype/contracts/leaderboards"; @@ -24,32 +22,30 @@ const requireWeeklyXpLeaderboardEnabled = validate({ const s = initServer(); export default s.router(leaderboardsContract, { get: { - middleware: [RateLimit.leaderboardsGet], handler: async (r) => callController(LeaderboardController.getLeaderboard)(r), }, getRank: { - middleware: [withApeRateLimiter(RateLimit.leaderboardsGet)], handler: async (r) => callController(LeaderboardController.getRankFromLeaderboard)(r), }, getDaily: { - middleware: [requireDailyLeaderboardsEnabled, RateLimit.leaderboardsGet], + middleware: [requireDailyLeaderboardsEnabled], handler: async (r) => callController(LeaderboardController.getDailyLeaderboard)(r), }, getDailyRank: { - middleware: [requireDailyLeaderboardsEnabled, RateLimit.leaderboardsGet], + middleware: [requireDailyLeaderboardsEnabled], handler: async (r) => callController(LeaderboardController.getDailyLeaderboardRank)(r), }, getWeeklyXp: { - middleware: [requireWeeklyXpLeaderboardEnabled, RateLimit.leaderboardsGet], + middleware: [requireWeeklyXpLeaderboardEnabled], handler: async (r) => callController(LeaderboardController.getWeeklyXpLeaderboardResults)(r), }, getWeeklyXpRank: { - middleware: [requireWeeklyXpLeaderboardEnabled, RateLimit.leaderboardsGet], + middleware: [requireWeeklyXpLeaderboardEnabled], handler: async (r) => callController(LeaderboardController.getWeeklyXpLeaderboardRank)(r), }, diff --git a/backend/src/api/routes/presets.ts b/backend/src/api/routes/presets.ts index a995cd977..0e795cd30 100644 --- a/backend/src/api/routes/presets.ts +++ b/backend/src/api/routes/presets.ts @@ -1,25 +1,20 @@ import { presetsContract } from "@monkeytype/contracts/presets"; import { initServer } from "@ts-rest/express"; -import * as RateLimit from "../../middlewares/rate-limit"; import * as PresetController from "../controllers/preset"; import { callController } from "../ts-rest-adapter"; const s = initServer(); export default s.router(presetsContract, { get: { - middleware: [RateLimit.presetsGet], handler: async (r) => callController(PresetController.getPresets)(r), }, add: { - middleware: [RateLimit.presetsAdd], handler: async (r) => callController(PresetController.addPreset)(r), }, save: { - middleware: [RateLimit.presetsEdit], handler: async (r) => callController(PresetController.editPreset)(r), }, delete: { - middleware: [RateLimit.presetsRemove], handler: async (r) => callController(PresetController.removePreset)(r), }, }); diff --git a/backend/src/api/routes/psas.ts b/backend/src/api/routes/psas.ts index 093b4c870..fe946a222 100644 --- a/backend/src/api/routes/psas.ts +++ b/backend/src/api/routes/psas.ts @@ -1,6 +1,5 @@ import { psasContract } from "@monkeytype/contracts/psas"; import { initServer } from "@ts-rest/express"; -import * as RateLimit from "../../middlewares/rate-limit"; import * as PsaController from "../controllers/psa"; import { callController } from "../ts-rest-adapter"; import { recordClientVersion } from "../../middlewares/utility"; @@ -8,7 +7,7 @@ import { recordClientVersion } from "../../middlewares/utility"; const s = initServer(); export default s.router(psasContract, { get: { - middleware: [recordClientVersion(), RateLimit.psaGet], + middleware: [recordClientVersion()], handler: async (r) => callController(PsaController.getPsas)(r), }, }); diff --git a/backend/src/api/routes/public.ts b/backend/src/api/routes/public.ts index b5e310fb7..8e8e37e89 100644 --- a/backend/src/api/routes/public.ts +++ b/backend/src/api/routes/public.ts @@ -1,17 +1,14 @@ import { publicContract } from "@monkeytype/contracts/public"; import { initServer } from "@ts-rest/express"; -import * as RateLimit from "../../middlewares/rate-limit"; import * as PublicController from "../controllers/public"; import { callController } from "../ts-rest-adapter"; const s = initServer(); export default s.router(publicContract, { getSpeedHistogram: { - middleware: [RateLimit.publicStatsGet], handler: async (r) => callController(PublicController.getSpeedHistogram)(r), }, getTypingStats: { - middleware: [RateLimit.publicStatsGet], handler: async (r) => callController(PublicController.getTypingStats)(r), }, }); diff --git a/backend/src/api/routes/quotes.ts b/backend/src/api/routes/quotes.ts index afc5dca5d..84711a730 100644 --- a/backend/src/api/routes/quotes.ts +++ b/backend/src/api/routes/quotes.ts @@ -2,7 +2,6 @@ import { quotesContract } from "@monkeytype/contracts/quotes"; import { initServer } from "@ts-rest/express"; import { validate } from "../../middlewares/configuration"; import { checkUserPermissions } from "../../middlewares/permission"; -import * as RateLimit from "../../middlewares/rate-limit"; import * as QuoteController from "../controllers/quote"; import { callController } from "../ts-rest-adapter"; @@ -18,11 +17,10 @@ const checkIfUserIsQuoteMod = checkUserPermissions(["quoteMod"], { const s = initServer(); export default s.router(quotesContract, { get: { - middleware: [checkIfUserIsQuoteMod, RateLimit.newQuotesGet], + middleware: [checkIfUserIsQuoteMod], handler: async (r) => callController(QuoteController.getQuotes)(r), }, isSubmissionEnabled: { - middleware: [RateLimit.newQuotesIsSubmissionEnabled], handler: async (r) => callController(QuoteController.isSubmissionEnabled)(r), }, @@ -35,24 +33,21 @@ export default s.router(quotesContract, { invalidMessage: "Quote submission is disabled temporarily. The queue is quite long and we need some time to catch up.", }), - RateLimit.newQuotesAdd, ], handler: async (r) => callController(QuoteController.addQuote)(r), }, approveSubmission: { - middleware: [checkIfUserIsQuoteMod, RateLimit.newQuotesAction], + middleware: [checkIfUserIsQuoteMod], handler: async (r) => callController(QuoteController.approveQuote)(r), }, rejectSubmission: { - middleware: [checkIfUserIsQuoteMod, RateLimit.newQuotesAction], + middleware: [checkIfUserIsQuoteMod], handler: async (r) => callController(QuoteController.refuseQuote)(r), }, getRating: { - middleware: [RateLimit.quoteRatingsGet], handler: async (r) => callController(QuoteController.getRating)(r), }, addRating: { - middleware: [RateLimit.quoteRatingsSubmit], handler: async (r) => callController(QuoteController.submitRating)(r), }, report: { @@ -63,7 +58,6 @@ export default s.router(quotesContract, { }, invalidMessage: "Quote reporting is unavailable.", }), - RateLimit.quoteReportSubmit, checkUserPermissions(["canReport"], { criteria: (user) => { return user.canReport !== false; diff --git a/backend/src/api/routes/results.ts b/backend/src/api/routes/results.ts index 5edfb731e..210a5951b 100644 --- a/backend/src/api/routes/results.ts +++ b/backend/src/api/routes/results.ts @@ -1,8 +1,6 @@ import { resultsContract } from "@monkeytype/contracts/results"; import { initServer } from "@ts-rest/express"; -import { withApeRateLimiter2 as withApeRateLimiter } from "../../middlewares/ape-rate-limit"; import { validate } from "../../middlewares/configuration"; -import * as RateLimit from "../../middlewares/rate-limit"; import * as ResultController from "../controllers/result"; import { callController } from "../ts-rest-adapter"; @@ -16,25 +14,19 @@ const validateResultSavingEnabled = validate({ const s = initServer(); export default s.router(resultsContract, { get: { - middleware: [ - withApeRateLimiter(RateLimit.resultsGet, RateLimit.resultsGetApe), - ], handler: async (r) => callController(ResultController.getResults)(r), }, add: { - middleware: [validateResultSavingEnabled, RateLimit.resultsAdd], + middleware: [validateResultSavingEnabled], handler: async (r) => callController(ResultController.addResult)(r), }, updateTags: { - middleware: [RateLimit.resultsTagsUpdate], handler: async (r) => callController(ResultController.updateTags)(r), }, deleteAll: { - middleware: [RateLimit.resultsDeleteAll], handler: async (r) => callController(ResultController.deleteAll)(r), }, getLast: { - middleware: [withApeRateLimiter(RateLimit.resultsGet)], handler: async (r) => callController(ResultController.getLastResult)(r), }, }); diff --git a/backend/src/api/routes/users.ts b/backend/src/api/routes/users.ts index 9736847e3..1537149f3 100644 --- a/backend/src/api/routes/users.ts +++ b/backend/src/api/routes/users.ts @@ -1,9 +1,7 @@ import { usersContract } from "@monkeytype/contracts/users"; import { initServer } from "@ts-rest/express"; -import { withApeRateLimiter2 as withApeRateLimiter } from "../../middlewares/ape-rate-limit"; import { validate } from "../../middlewares/configuration"; import { checkUserPermissions } from "../../middlewares/permission"; -import * as RateLimit from "../../middlewares/rate-limit"; import * as UserController from "../controllers/user"; import { callController } from "../ts-rest-adapter"; @@ -38,7 +36,6 @@ const requireInboxEnabled = validate({ const s = initServer(); export default s.router(usersContract, { get: { - middleware: [RateLimit.userGet], handler: async (r) => callController(UserController.getUser)(r), }, create: { @@ -49,149 +46,117 @@ export default s.router(usersContract, { }, invalidMessage: "Sign up is temporarily disabled", }), - RateLimit.userSignup, ], handler: async (r) => callController(UserController.createNewUser)(r), }, getNameAvailability: { - middleware: [RateLimit.userCheckName], handler: async (r) => callController(UserController.checkName)(r), }, delete: { - middleware: [RateLimit.userDelete], handler: async (r) => callController(UserController.deleteUser)(r), }, reset: { - middleware: [RateLimit.userReset], handler: async (r) => callController(UserController.resetUser)(r), }, updateName: { - middleware: [RateLimit.userUpdateName], handler: async (r) => callController(UserController.updateName)(r), }, updateLeaderboardMemory: { - middleware: [RateLimit.userUpdateLBMemory], handler: async (r) => callController(UserController.updateLbMemory)(r), }, updateEmail: { - middleware: [RateLimit.userUpdateEmail], handler: async (r) => callController(UserController.updateEmail)(r), }, updatePassword: { - middleware: [RateLimit.userUpdateEmail], handler: async (r) => callController(UserController.updatePassword)(r), }, getPersonalBests: { - middleware: [withApeRateLimiter(RateLimit.userGet)], handler: async (r) => callController(UserController.getPersonalBests)(r), }, deletePersonalBests: { - middleware: [RateLimit.userClearPB], handler: async (r) => callController(UserController.clearPb)(r), }, optOutOfLeaderboards: { - middleware: [RateLimit.userOptOutOfLeaderboards], handler: async (r) => callController(UserController.optOutOfLeaderboards)(r), }, addResultFilterPreset: { - middleware: [requireFilterPresetsEnabled, RateLimit.userCustomFilterAdd], + middleware: [requireFilterPresetsEnabled], handler: async (r) => callController(UserController.addResultFilterPreset)(r), }, removeResultFilterPreset: { - middleware: [requireFilterPresetsEnabled, RateLimit.userCustomFilterRemove], + middleware: [requireFilterPresetsEnabled], handler: async (r) => callController(UserController.removeResultFilterPreset)(r), }, getTags: { - middleware: [withApeRateLimiter(RateLimit.userTagsGet)], handler: async (r) => callController(UserController.getTags)(r), }, createTag: { - middleware: [RateLimit.userTagsAdd], handler: async (r) => callController(UserController.addTag)(r), }, editTag: { - middleware: [RateLimit.userTagsEdit], handler: async (r) => callController(UserController.editTag)(r), }, deleteTag: { - middleware: [RateLimit.userTagsRemove], handler: async (r) => callController(UserController.removeTag)(r), }, deleteTagPersonalBest: { - middleware: [RateLimit.userTagsClearPB], handler: async (r) => callController(UserController.clearTagPb)(r), }, getCustomThemes: { - middleware: [RateLimit.userCustomThemeGet], handler: async (r) => callController(UserController.getCustomThemes)(r), }, addCustomTheme: { - middleware: [RateLimit.userCustomThemeAdd], handler: async (r) => callController(UserController.addCustomTheme)(r), }, deleteCustomTheme: { - middleware: [RateLimit.userCustomThemeRemove], handler: async (r) => callController(UserController.removeCustomTheme)(r), }, editCustomTheme: { - middleware: [RateLimit.userCustomThemeEdit], handler: async (r) => callController(UserController.editCustomTheme)(r), }, getDiscordOAuth: { - middleware: [requireDiscordIntegrationEnabled, RateLimit.userDiscordLink], + middleware: [requireDiscordIntegrationEnabled], handler: async (r) => callController(UserController.getOauthLink)(r), }, linkDiscord: { - middleware: [requireDiscordIntegrationEnabled, RateLimit.userDiscordLink], + middleware: [requireDiscordIntegrationEnabled], handler: async (r) => callController(UserController.linkDiscord)(r), }, unlinkDiscord: { - middleware: [RateLimit.userDiscordUnlink], handler: async (r) => callController(UserController.unlinkDiscord)(r), }, getStats: { - middleware: [withApeRateLimiter(RateLimit.userGet)], handler: async (r) => callController(UserController.getStats)(r), }, setStreakHourOffset: { - middleware: [RateLimit.setStreakHourOffset], handler: async (r) => callController(UserController.setStreakHourOffset)(r), }, getFavoriteQuotes: { - middleware: [RateLimit.quoteFavoriteGet], handler: async (r) => callController(UserController.getFavoriteQuotes)(r), }, addQuoteToFavorites: { - middleware: [RateLimit.quoteFavoritePost], handler: async (r) => callController(UserController.addFavoriteQuote)(r), }, removeQuoteFromFavorites: { - middleware: [RateLimit.quoteFavoriteDelete], handler: async (r) => callController(UserController.removeFavoriteQuote)(r), }, getProfile: { - middleware: [ - requireProfilesEnabled, - withApeRateLimiter(RateLimit.userProfileGet), - ], + middleware: [requireProfilesEnabled], handler: async (r) => callController(UserController.getProfile)(r), }, updateProfile: { - middleware: [ - requireProfilesEnabled, - withApeRateLimiter(RateLimit.userProfileUpdate), - ], + middleware: [requireProfilesEnabled], handler: async (r) => callController(UserController.updateProfile)(r), }, getInbox: { - middleware: [requireInboxEnabled, RateLimit.userMailGet], + middleware: [requireInboxEnabled], handler: async (r) => callController(UserController.getInbox)(r), }, updateInbox: { - middleware: [requireInboxEnabled, RateLimit.userMailUpdate], + middleware: [requireInboxEnabled], handler: async (r) => callController(UserController.updateInbox)(r), }, report: { @@ -207,35 +172,28 @@ export default s.router(usersContract, { return user.canReport !== false; }, }), - RateLimit.quoteReportSubmit, ], handler: async (r) => callController(UserController.reportUser)(r), }, verificationEmail: { - middleware: [RateLimit.userRequestVerificationEmail], handler: async (r) => callController(UserController.sendVerificationEmail)(r), }, forgotPasswordEmail: { - middleware: [RateLimit.userForgotPasswordEmail], handler: async (r) => callController(UserController.sendForgotPasswordEmail)(r), }, revokeAllTokens: { - middleware: [RateLimit.userRevokeAllTokens], handler: async (r) => callController(UserController.revokeAllTokens)(r), }, getTestActivity: { - middleware: [RateLimit.userTestActivity], handler: async (r) => callController(UserController.getTestActivity)(r), }, getCurrentTestActivity: { - middleware: [withApeRateLimiter(RateLimit.userCurrentTestActivity)], handler: async (r) => callController(UserController.getCurrentTestActivity)(r), }, getStreak: { - middleware: [withApeRateLimiter(RateLimit.userStreak)], handler: async (r) => callController(UserController.getStreak)(r), }, }); diff --git a/backend/src/middlewares/ape-rate-limit.ts b/backend/src/middlewares/ape-rate-limit.ts deleted file mode 100644 index 6197b1f8c..000000000 --- a/backend/src/middlewares/ape-rate-limit.ts +++ /dev/null @@ -1,71 +0,0 @@ -import MonkeyError from "../utils/error"; -import type { Response, NextFunction, RequestHandler } from "express"; -import statuses from "../constants/monkey-status-codes"; -import rateLimit, { - type RateLimitRequestHandler, - type Options, -} from "express-rate-limit"; -import { isDevEnvironment } from "../utils/misc"; -import { TsRestRequestWithCtx } from "./auth"; - -const REQUEST_MULTIPLIER = isDevEnvironment() ? 1 : 1; - -const getKey = (req: MonkeyTypes.Request, _res: Response): string => { - return req?.ctx?.decodedToken?.uid; -}; - -const ONE_MINUTE = 1000 * 60; - -const { - APE_KEY_RATE_LIMIT_EXCEEDED: { message, code }, -} = statuses; - -export const customHandler = ( - _req: MonkeyTypes.Request, - _res: Response, - _next: NextFunction, - _options: Options -): void => { - throw new MonkeyError(code, message); -}; - -const apeRateLimiter = rateLimit({ - windowMs: ONE_MINUTE, - max: 30 * REQUEST_MULTIPLIER, - keyGenerator: getKey, - handler: customHandler, -}); - -export function withApeRateLimiter( - defaultRateLimiter: RateLimitRequestHandler, - apeRateLimiterOverride?: RateLimitRequestHandler -): RequestHandler { - return (req: MonkeyTypes.Request, res: Response, next: NextFunction) => { - if (req.ctx.decodedToken.type === "ApeKey") { - const rateLimiter = apeRateLimiterOverride ?? apeRateLimiter; - // TODO: bump version? - // eslint-disable-next-line @typescript-eslint/no-unsafe-return - return rateLimiter(req, res, next); - } - - // eslint-disable-next-line @typescript-eslint/no-unsafe-return - return defaultRateLimiter(req, res, next); - }; -} - -export function withApeRateLimiter2( - defaultRateLimiter: RateLimitRequestHandler, - apeRateLimiterOverride?: RateLimitRequestHandler -): MonkeyTypes.RequestHandler { - return (req: TsRestRequestWithCtx, res: Response, next: NextFunction) => { - if (req.ctx.decodedToken.type === "ApeKey") { - const rateLimiter = apeRateLimiterOverride ?? apeRateLimiter; - // TODO: bump version? - // eslint-disable-next-line @typescript-eslint/no-unsafe-return - return rateLimiter(req, res, next); - } - - // eslint-disable-next-line @typescript-eslint/no-unsafe-return - return defaultRateLimiter(req, res, next); - }; -} diff --git a/backend/src/middlewares/auth.ts b/backend/src/middlewares/auth.ts index 372586114..74727f0aa 100644 --- a/backend/src/middlewares/auth.ts +++ b/backend/src/middlewares/auth.ts @@ -26,7 +26,8 @@ const DEFAULT_OPTIONS: RequestAuthenticationOptions = { export type TsRestRequestWithCtx = { ctx: Readonly; -} & TsRestRequest; +} & TsRestRequest & + ExpressRequest; /** * Authenticate request based on the auth settings of the route. diff --git a/backend/src/middlewares/rate-limit.ts b/backend/src/middlewares/rate-limit.ts index 660f5db24..a1dcfcc27 100644 --- a/backend/src/middlewares/rate-limit.ts +++ b/backend/src/middlewares/rate-limit.ts @@ -2,10 +2,39 @@ import _ from "lodash"; import MonkeyError from "../utils/error"; import type { Response, NextFunction } from "express"; import { RateLimiterMemory } from "rate-limiter-flexible"; -import rateLimit, { type Options } from "express-rate-limit"; +import { + rateLimit, + RateLimitRequestHandler, + type Options, +} from "express-rate-limit"; import { isDevEnvironment } from "../utils/misc"; +import { EndpointMetadata } from "@monkeytype/contracts/schemas/api"; +import { TsRestRequestWithCtx } from "./auth"; +import { TsRestRequestHandler } from "@ts-rest/express"; +import { + limits, + RateLimiterId, + RateLimitOptions, + Window, +} from "@monkeytype/contracts/rate-limit/index"; +import statuses from "../constants/monkey-status-codes"; -const REQUEST_MULTIPLIER = isDevEnvironment() ? 100 : 1; +export const REQUEST_MULTIPLIER = isDevEnvironment() ? 100 : 1; + +export const customHandler = ( + req: MonkeyTypes.ExpressRequestWithContext, + _res: Response, + _next: NextFunction, + _options: Options +): void => { + if (req.ctx.decodedToken.type === "ApeKey") { + throw new MonkeyError( + statuses.APE_KEY_RATE_LIMIT_EXCEEDED.code, + statuses.APE_KEY_RATE_LIMIT_EXCEEDED.message + ); + } + throw new MonkeyError(429, "Request limit reached, please try again later."); +}; const getKey = (req: MonkeyTypes.Request, _res: Response): string => { return ( @@ -23,22 +52,85 @@ const getKeyWithUid = (req: MonkeyTypes.Request, _res: Response): string => { return useUid ? uid : getKey(req, _res); }; -export const customHandler = ( - _req: MonkeyTypes.Request, - _res: Response, - _next: NextFunction, - _options: Options -): void => { - throw new MonkeyError(429, "Request limit reached, please try again later."); -}; +function initialiseLimiters(): Record { + const keys = Object.keys(limits) as RateLimiterId[]; -const ONE_HOUR_SECONDS = 60 * 60; -const ONE_HOUR_MS = 1000 * ONE_HOUR_SECONDS; -const ONE_DAY_MS = 24 * ONE_HOUR_MS; + const convert = (options: RateLimitOptions): RateLimitRequestHandler => { + return rateLimit({ + windowMs: convertWindowToMs(options.window), + max: options.max * REQUEST_MULTIPLIER, + handler: customHandler, + keyGenerator: getKeyWithUid, + }); + }; + + return keys.reduce( + (output, key) => ({ ...output, [key]: convert(limits[key]) }), + {} + ) as Record; +} + +function convertWindowToMs(window: Window): number { + if (typeof window === "number") return window; + switch (window) { + case "second": + return 1000; + case "minute": + return 60 * 1000; + case "hour": + return 60 * 60 * 1000; + case "day": + return 24 * 60 * 60 * 1000; + } +} + +//visible for testing +export const requestLimiters: Record = + initialiseLimiters(); + +export function rateLimitRequest< + T extends AppRouter | AppRoute +>(): TsRestRequestHandler { + return async ( + req: TsRestRequestWithCtx, + res: Response, + next: NextFunction + ): Promise => { + const rateLimit = (req.tsRestRoute["metadata"] as EndpointMetadata) + ?.rateLimit; + if (rateLimit === undefined) { + next(); + return; + } + + const hasApeKeyLimiterId = typeof rateLimit === "object"; + let rateLimiterId: RateLimiterId; + + if (req.ctx.decodedToken.type === "ApeKey") { + rateLimiterId = hasApeKeyLimiterId + ? rateLimit.apeKey + : "defaultApeRateLimit"; + } else { + rateLimiterId = hasApeKeyLimiterId ? rateLimit.normal : rateLimit; + } + + const rateLimiter = requestLimiters[rateLimiterId]; + if (rateLimiter === undefined) { + next( + new MonkeyError( + 500, + `Unknown rateLimiterId '${rateLimiterId}', how did you manage to do this?` + ) + ); + } else { + rateLimiter(req, res, next); + } + }; +} // Root Rate Limit export const rootRateLimiter = rateLimit({ - windowMs: ONE_HOUR_MS, + windowMs: 60 * 1000 * 60, max: 1000 * REQUEST_MULTIPLIER, keyGenerator: getKey, handler: (_req, _res, _next, _options): void => { @@ -52,7 +144,7 @@ export const rootRateLimiter = rateLimit({ // Bad Authentication Rate Limiter const badAuthRateLimiter = new RateLimiterMemory({ points: 30 * REQUEST_MULTIPLIER, - duration: ONE_HOUR_SECONDS, + duration: 60 * 60, //one hour seconds }); export async function badAuthRateLimiterHandler( @@ -103,476 +195,9 @@ export async function incrementBadAuth( } catch (error) {} } -export const adminLimit = rateLimit({ - windowMs: 5000, - max: 1 * REQUEST_MULTIPLIER, - keyGenerator: getKeyWithUid, - handler: customHandler, -}); - -// Config Routing -export const configUpdate = rateLimit({ - windowMs: ONE_HOUR_MS, - max: 500 * REQUEST_MULTIPLIER, - keyGenerator: getKeyWithUid, - handler: customHandler, -}); - -export const configGet = rateLimit({ - windowMs: ONE_HOUR_MS, - max: 120 * REQUEST_MULTIPLIER, - keyGenerator: getKeyWithUid, - handler: customHandler, -}); - -export const configDelete = rateLimit({ - windowMs: ONE_HOUR_MS, - max: 120 * REQUEST_MULTIPLIER, - keyGenerator: getKeyWithUid, - handler: customHandler, -}); - -// Leaderboards Routing -export const leaderboardsGet = rateLimit({ - windowMs: ONE_HOUR_MS, - max: 500 * REQUEST_MULTIPLIER, - keyGenerator: getKeyWithUid, - handler: customHandler, -}); - -// New Quotes Routing -export const newQuotesGet = rateLimit({ - windowMs: ONE_HOUR_MS, - max: 500 * REQUEST_MULTIPLIER, - keyGenerator: getKeyWithUid, - handler: customHandler, -}); - -export const newQuotesIsSubmissionEnabled = rateLimit({ - windowMs: 60 * 1000, - max: 60 * REQUEST_MULTIPLIER, - keyGenerator: getKeyWithUid, - handler: customHandler, -}); - -export const newQuotesAdd = rateLimit({ - windowMs: ONE_HOUR_MS, - max: 60 * REQUEST_MULTIPLIER, - keyGenerator: getKeyWithUid, - handler: customHandler, -}); - -export const newQuotesAction = rateLimit({ - windowMs: ONE_HOUR_MS, - max: 500 * REQUEST_MULTIPLIER, - keyGenerator: getKeyWithUid, - handler: customHandler, -}); - -// Quote Ratings Routing -export const quoteRatingsGet = rateLimit({ - windowMs: ONE_HOUR_MS, - max: 500 * REQUEST_MULTIPLIER, - keyGenerator: getKeyWithUid, - handler: customHandler, -}); - -export const quoteRatingsSubmit = rateLimit({ - windowMs: ONE_HOUR_MS, - max: 500 * REQUEST_MULTIPLIER, - keyGenerator: getKeyWithUid, - handler: customHandler, -}); - -// Quote reporting -export const quoteReportSubmit = rateLimit({ - windowMs: 30 * 60 * 1000, // 30 min - max: 50 * REQUEST_MULTIPLIER, - keyGenerator: getKeyWithUid, - handler: customHandler, -}); - -// Quote favorites -export const quoteFavoriteGet = rateLimit({ - windowMs: 30 * 60 * 1000, // 30 min - max: 50 * REQUEST_MULTIPLIER, - keyGenerator: getKeyWithUid, - handler: customHandler, -}); - -export const quoteFavoritePost = rateLimit({ - windowMs: 30 * 60 * 1000, // 30 min - max: 50 * REQUEST_MULTIPLIER, - keyGenerator: getKeyWithUid, - handler: customHandler, -}); - -export const quoteFavoriteDelete = rateLimit({ - windowMs: 30 * 60 * 1000, // 30 min - max: 50 * REQUEST_MULTIPLIER, - keyGenerator: getKeyWithUid, - handler: customHandler, -}); - -// Presets Routing -export const presetsGet = rateLimit({ - windowMs: ONE_HOUR_MS, - max: 60 * REQUEST_MULTIPLIER, - keyGenerator: getKeyWithUid, - handler: customHandler, -}); - -export const presetsAdd = rateLimit({ - windowMs: ONE_HOUR_MS, - max: 60 * REQUEST_MULTIPLIER, - keyGenerator: getKeyWithUid, - handler: customHandler, -}); - -export const presetsRemove = rateLimit({ - windowMs: ONE_HOUR_MS, - max: 60 * REQUEST_MULTIPLIER, - keyGenerator: getKeyWithUid, - handler: customHandler, -}); - -export const presetsEdit = rateLimit({ - windowMs: ONE_HOUR_MS, - max: 60 * REQUEST_MULTIPLIER, - keyGenerator: getKeyWithUid, - handler: customHandler, -}); - -// PSA (Public Service Announcement) Routing -export const psaGet = rateLimit({ - windowMs: 60 * 1000, - max: 60 * REQUEST_MULTIPLIER, - keyGenerator: getKeyWithUid, - handler: customHandler, -}); - -// get public speed stats -export const publicStatsGet = rateLimit({ - windowMs: 60 * 1000, - max: 60 * REQUEST_MULTIPLIER, - keyGenerator: getKeyWithUid, - handler: customHandler, -}); - -// Results Routing -export const resultsGet = rateLimit({ - windowMs: ONE_HOUR_MS, - max: 60 * REQUEST_MULTIPLIER, - keyGenerator: getKeyWithUid, - handler: customHandler, -}); - -// Results Routing -export const resultsGetApe = rateLimit({ - windowMs: ONE_DAY_MS, - max: 30 * REQUEST_MULTIPLIER, - keyGenerator: getKeyWithUid, - handler: customHandler, -}); - -export const resultsAdd = rateLimit({ - windowMs: ONE_HOUR_MS, - max: 300 * REQUEST_MULTIPLIER, - keyGenerator: getKeyWithUid, - handler: customHandler, -}); - -export const resultsTagsUpdate = rateLimit({ - windowMs: ONE_HOUR_MS, - max: 100 * REQUEST_MULTIPLIER, - keyGenerator: getKeyWithUid, - handler: customHandler, -}); - -export const resultsDeleteAll = rateLimit({ - windowMs: ONE_HOUR_MS, - max: 10 * REQUEST_MULTIPLIER, - keyGenerator: getKeyWithUid, - handler: customHandler, -}); - -export const resultsLeaderboardGet = rateLimit({ - windowMs: ONE_HOUR_MS, - max: 60 * REQUEST_MULTIPLIER, - keyGenerator: getKeyWithUid, - handler: customHandler, -}); - -export const resultsLeaderboardQualificationGet = rateLimit({ - windowMs: ONE_HOUR_MS, - max: 60 * REQUEST_MULTIPLIER, - keyGenerator: getKeyWithUid, - handler: customHandler, -}); - -// Users Routing -export const userGet = rateLimit({ - windowMs: ONE_HOUR_MS, - max: 60 * REQUEST_MULTIPLIER, - keyGenerator: getKeyWithUid, - handler: customHandler, -}); - -export const setStreakHourOffset = rateLimit({ - windowMs: ONE_HOUR_MS, - max: 5 * REQUEST_MULTIPLIER, - keyGenerator: getKeyWithUid, - handler: customHandler, -}); - -export const userSignup = rateLimit({ - windowMs: 24 * ONE_HOUR_MS, // 1 day - max: 2 * REQUEST_MULTIPLIER, - keyGenerator: getKey, - handler: customHandler, -}); - -export const userDelete = rateLimit({ - windowMs: 24 * ONE_HOUR_MS, // 1 day - max: 3 * REQUEST_MULTIPLIER, - keyGenerator: getKeyWithUid, - handler: customHandler, -}); - -export const userReset = rateLimit({ - windowMs: 24 * ONE_HOUR_MS, // 1 day - max: 3 * REQUEST_MULTIPLIER, - keyGenerator: getKeyWithUid, - handler: customHandler, -}); - -export const userCheckName = rateLimit({ - windowMs: 60 * 1000, - max: 60 * REQUEST_MULTIPLIER, - keyGenerator: getKeyWithUid, - handler: customHandler, -}); - -export const userUpdateName = rateLimit({ - windowMs: 24 * ONE_HOUR_MS, // 1 day - max: 3 * REQUEST_MULTIPLIER, - keyGenerator: getKeyWithUid, - handler: customHandler, -}); - -export const userUpdateLBMemory = rateLimit({ - windowMs: 60 * 1000, - max: 60 * REQUEST_MULTIPLIER, - keyGenerator: getKeyWithUid, - handler: customHandler, -}); - -export const userUpdateEmail = rateLimit({ - windowMs: ONE_HOUR_MS, - max: 60 * REQUEST_MULTIPLIER, - keyGenerator: getKeyWithUid, - handler: customHandler, -}); - -export const userClearPB = rateLimit({ - windowMs: ONE_HOUR_MS, - max: 60 * REQUEST_MULTIPLIER, - keyGenerator: getKeyWithUid, - 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, - keyGenerator: getKeyWithUid, - handler: customHandler, -}); - -export const userCustomFilterRemove = rateLimit({ - windowMs: ONE_HOUR_MS, - max: 60 * REQUEST_MULTIPLIER, - keyGenerator: getKeyWithUid, - handler: customHandler, -}); - -export const userTagsGet = rateLimit({ - windowMs: ONE_HOUR_MS, - max: 60 * REQUEST_MULTIPLIER, - keyGenerator: getKeyWithUid, - handler: customHandler, -}); - -export const userTagsRemove = rateLimit({ - windowMs: ONE_HOUR_MS, - max: 30 * REQUEST_MULTIPLIER, - keyGenerator: getKeyWithUid, - handler: customHandler, -}); - -export const userTagsClearPB = rateLimit({ - windowMs: ONE_HOUR_MS, - max: 60 * REQUEST_MULTIPLIER, - keyGenerator: getKeyWithUid, - handler: customHandler, -}); - -export const userTagsEdit = rateLimit({ - windowMs: ONE_HOUR_MS, - max: 30 * REQUEST_MULTIPLIER, - keyGenerator: getKeyWithUid, - handler: customHandler, -}); - -export const userTagsAdd = rateLimit({ - windowMs: ONE_HOUR_MS, - max: 30 * REQUEST_MULTIPLIER, - keyGenerator: getKeyWithUid, - handler: customHandler, -}); - -export const userCustomThemeGet = rateLimit({ - windowMs: ONE_HOUR_MS, - max: 30 * REQUEST_MULTIPLIER, - keyGenerator: getKeyWithUid, - handler: customHandler, -}); - -export const userCustomThemeAdd = rateLimit({ - windowMs: ONE_HOUR_MS, - max: 30 * REQUEST_MULTIPLIER, - keyGenerator: getKeyWithUid, - handler: customHandler, -}); - -export const userCustomThemeRemove = rateLimit({ - windowMs: ONE_HOUR_MS, - max: 30 * REQUEST_MULTIPLIER, - keyGenerator: getKeyWithUid, - handler: customHandler, -}); - -export const userCustomThemeEdit = rateLimit({ - windowMs: ONE_HOUR_MS, - max: 30 * REQUEST_MULTIPLIER, - keyGenerator: getKeyWithUid, - handler: customHandler, -}); - -export const userDiscordLink = rateLimit({ - windowMs: ONE_HOUR_MS, - max: 15 * REQUEST_MULTIPLIER, - keyGenerator: getKeyWithUid, - handler: customHandler, -}); - -export const usersTagsEdit = userDiscordLink; - -export const userDiscordUnlink = rateLimit({ - windowMs: ONE_HOUR_MS, - max: 15 * REQUEST_MULTIPLIER, - keyGenerator: getKeyWithUid, - handler: customHandler, -}); - -export const userRequestVerificationEmail = rateLimit({ - windowMs: ONE_HOUR_MS / 4, - max: 1 * REQUEST_MULTIPLIER, - keyGenerator: getKeyWithUid, - handler: customHandler, -}); - -export const userForgotPasswordEmail = rateLimit({ - windowMs: ONE_HOUR_MS / 60, - max: 1 * REQUEST_MULTIPLIER, - keyGenerator: getKeyWithUid, - handler: customHandler, -}); - -export const userRevokeAllTokens = rateLimit({ - windowMs: ONE_HOUR_MS, - max: 10 * REQUEST_MULTIPLIER, - keyGenerator: getKeyWithUid, - handler: customHandler, -}); - -export const userProfileGet = rateLimit({ - windowMs: ONE_HOUR_MS, - max: 100 * REQUEST_MULTIPLIER, - keyGenerator: getKeyWithUid, - handler: customHandler, -}); - -export const userProfileUpdate = rateLimit({ - windowMs: ONE_HOUR_MS, - max: 60 * REQUEST_MULTIPLIER, - keyGenerator: getKeyWithUid, - handler: customHandler, -}); - -export const userMailGet = rateLimit({ - windowMs: ONE_HOUR_MS, - max: 60 * REQUEST_MULTIPLIER, - keyGenerator: getKeyWithUid, - handler: customHandler, -}); - -export const userMailUpdate = rateLimit({ - windowMs: ONE_HOUR_MS, - max: 60 * REQUEST_MULTIPLIER, - keyGenerator: getKeyWithUid, - handler: customHandler, -}); - -export const userTestActivity = rateLimit({ - windowMs: ONE_HOUR_MS, - max: 60 * REQUEST_MULTIPLIER, - keyGenerator: getKeyWithUid, - handler: customHandler, -}); - -export const userCurrentTestActivity = rateLimit({ - windowMs: ONE_HOUR_MS, - max: 60 * REQUEST_MULTIPLIER, - keyGenerator: getKeyWithUid, - handler: customHandler, -}); - -export const userStreak = rateLimit({ - windowMs: ONE_HOUR_MS, - max: 60 * REQUEST_MULTIPLIER, - keyGenerator: getKeyWithUid, - handler: customHandler, -}); - -// ApeKeys Routing -export const apeKeysGet = rateLimit({ - windowMs: ONE_HOUR_MS, - max: 120 * REQUEST_MULTIPLIER, - keyGenerator: getKeyWithUid, - handler: customHandler, -}); - -export const apeKeysGenerate = rateLimit({ - windowMs: ONE_HOUR_MS, - max: 15 * REQUEST_MULTIPLIER, - keyGenerator: getKeyWithUid, - handler: customHandler, -}); - export const webhookLimit = rateLimit({ windowMs: 1000, max: 1 * REQUEST_MULTIPLIER, keyGenerator: getKeyWithUid, handler: customHandler, }); - -export const apeKeysUpdate = apeKeysGenerate; - -export const apeKeysDelete = apeKeysGenerate; diff --git a/backend/src/types/types.d.ts b/backend/src/types/types.d.ts index 3a7a4a85f..dff561c7d 100644 --- a/backend/src/types/types.d.ts +++ b/backend/src/types/types.d.ts @@ -22,6 +22,10 @@ declare namespace MonkeyTypes { ctx: Readonly; } & ExpressRequest; + type ExpressRequestWithContext = { + ctx: Readonly; + } & ExpressRequest; + type Request2 = { query: Readonly; body: Readonly; diff --git a/frontend/src/ts/controllers/sound-controller.ts b/frontend/src/ts/controllers/sound-controller.ts index 8ac913893..2455a0570 100644 --- a/frontend/src/ts/controllers/sound-controller.ts +++ b/frontend/src/ts/controllers/sound-controller.ts @@ -440,7 +440,7 @@ const notes = { } as const; type ValidNotes = keyof typeof notes; -type ValidFrequencies = typeof notes[ValidNotes]; +type ValidFrequencies = (typeof notes)[ValidNotes]; type GetNoteFrequencyCallback = (octave: number) => number; diff --git a/frontend/src/ts/elements/account/result-filters.ts b/frontend/src/ts/elements/account/result-filters.ts index c886f1f17..bc8f99e15 100644 --- a/frontend/src/ts/elements/account/result-filters.ts +++ b/frontend/src/ts/elements/account/result-filters.ts @@ -264,12 +264,12 @@ function setFilter( filter: ResultFiltersGroupItem, value: boolean ): void { - filters[group][filter] = value as typeof filters[G][typeof filter]; + filters[group][filter] = value as (typeof filters)[G][typeof filter]; } function setAllFilters(group: ResultFiltersGroup, value: boolean): void { Object.keys(getGroup(group)).forEach((filter) => { - filters[group][filter as keyof typeof filters[typeof group]] = + filters[group][filter as keyof (typeof filters)[typeof group]] = value as never; }); } diff --git a/frontend/src/ts/elements/keymap.ts b/frontend/src/ts/elements/keymap.ts index c1053f4fb..c1259efca 100644 --- a/frontend/src/ts/elements/keymap.ts +++ b/frontend/src/ts/elements/keymap.ts @@ -144,7 +144,7 @@ export async function refresh( const showTopRow = (TestWords.hasNumbers && Config.keymapMode === "next") || Config.keymapShowTopRow === "always" || - ((lts as typeof layouts["qwerty"]).keymapShowTopRow && + ((lts as (typeof layouts)["qwerty"]).keymapShowTopRow && Config.keymapShowTopRow !== "never"); const isMatrix = diff --git a/frontend/src/ts/modals/quote-search.ts b/frontend/src/ts/modals/quote-search.ts index d54b7dcd7..53f95b38b 100644 --- a/frontend/src/ts/modals/quote-search.ts +++ b/frontend/src/ts/modals/quote-search.ts @@ -40,7 +40,7 @@ function getSearchService( const newSearchService = buildSearchService(data, textExtractor); searchServiceCache[language] = - newSearchService as unknown as typeof searchServiceCache[typeof language]; + newSearchService as unknown as (typeof searchServiceCache)[typeof language]; return newSearchService; } diff --git a/frontend/src/ts/pages/account.ts b/frontend/src/ts/pages/account.ts index 1276b1bd9..e06b3d330 100644 --- a/frontend/src/ts/pages/account.ts +++ b/frontend/src/ts/pages/account.ts @@ -1036,7 +1036,7 @@ function sortAndRefreshHistory( if (filteredResults.length < 2) return; - const key = keyString as keyof typeof filteredResults[0]; + const key = keyString as keyof (typeof filteredResults)[0]; // This allows to reverse the sorting order when clicking multiple times on the table header let descending = true; diff --git a/package.json b/package.json index 040466200..e2079921d 100644 --- a/package.json +++ b/package.json @@ -65,7 +65,7 @@ "knip": "2.19.2", "lint-staged": "13.2.3", "only-allow": "1.2.1", - "prettier": "2.5.1", + "prettier": "2.8.8", "turbo": "2.0.12", "vitest": "2.0.5" }, diff --git a/packages/contracts/src/admin.ts b/packages/contracts/src/admin.ts index 76595e930..230ed3421 100644 --- a/packages/contracts/src/admin.ts +++ b/packages/contracts/src/admin.ts @@ -2,7 +2,7 @@ import { initContract } from "@ts-rest/core"; import { z } from "zod"; import { CommonResponses, - EndpointMetadata, + meta, MonkeyResponseSchema, responseWithData, } from "./schemas/api"; @@ -107,10 +107,11 @@ export const adminContract = c.router( { pathPrefix: "/admin", strictStatusCodes: true, - metadata: { + metadata: meta({ openApiTags: "admin", authenticationOptions: { noCache: true }, - } as EndpointMetadata, + rateLimit: "adminLimit", + }), commonResponses: CommonResponses, } diff --git a/packages/contracts/src/ape-keys.ts b/packages/contracts/src/ape-keys.ts index 4b6801646..860b675a3 100644 --- a/packages/contracts/src/ape-keys.ts +++ b/packages/contracts/src/ape-keys.ts @@ -3,7 +3,7 @@ import { z } from "zod"; import { CommonResponses, - EndpointMetadata, + meta, MonkeyResponseSchema, responseWithData, } from "./schemas/api"; @@ -50,6 +50,9 @@ export const apeKeysContract = c.router( responses: { 200: GetApeKeyResponseSchema, }, + metadata: meta({ + rateLimit: "apeKeysGet", + }), }, add: { summary: "add ape key", @@ -60,6 +63,9 @@ export const apeKeysContract = c.router( responses: { 200: AddApeKeyResponseSchema, }, + metadata: meta({ + rateLimit: "apeKeysGenerate", + }), }, save: { summary: "update ape key", @@ -71,6 +77,9 @@ export const apeKeysContract = c.router( responses: { 200: MonkeyResponseSchema, }, + metadata: meta({ + rateLimit: "apeKeysGenerate", + }), }, delete: { summary: "delete ape key", @@ -82,14 +91,17 @@ export const apeKeysContract = c.router( responses: { 200: MonkeyResponseSchema, }, + metadata: meta({ + rateLimit: "apeKeysGenerate", + }), }, }, { pathPrefix: "/ape-keys", strictStatusCodes: true, - metadata: { + metadata: meta({ openApiTags: "ape-keys", - } as EndpointMetadata, + }), commonResponses: CommonResponses, } diff --git a/packages/contracts/src/configs.ts b/packages/contracts/src/configs.ts index 115a27d5d..351b917d9 100644 --- a/packages/contracts/src/configs.ts +++ b/packages/contracts/src/configs.ts @@ -3,7 +3,7 @@ import { z } from "zod"; import { CommonResponses, - EndpointMetadata, + meta, MonkeyResponseSchema, responseWithNullableData, } from "./schemas/api"; @@ -26,35 +26,44 @@ export const configsContract = c.router( responses: { 200: GetConfigResponseSchema, }, + metadata: meta({ + rateLimit: "configGet", + }), }, save: { + summary: "update config", + description: + "Update the config of the current user. Only provided values will be updated while the missing values will be unchanged.", method: "PATCH", path: "", body: PartialConfigSchema.strict(), responses: { 200: MonkeyResponseSchema, }, - summary: "update config", - description: - "Update the config of the current user. Only provided values will be updated while the missing values will be unchanged.", + metadata: meta({ + rateLimit: "configUpdate", + }), }, delete: { + summary: "delete config", + description: "Delete/reset the config for the current user.", method: "DELETE", path: "", body: c.noBody(), responses: { 200: MonkeyResponseSchema, }, - summary: "delete config", - description: "Delete/reset the config for the current user.", + metadata: meta({ + rateLimit: "configDelete", + }), }, }, { pathPrefix: "/configs", strictStatusCodes: true, - metadata: { + metadata: meta({ openApiTags: "configs", - } as EndpointMetadata, + }), commonResponses: CommonResponses, } diff --git a/packages/contracts/src/configuration.ts b/packages/contracts/src/configuration.ts index 428e1a51e..0f03d67f3 100644 --- a/packages/contracts/src/configuration.ts +++ b/packages/contracts/src/configuration.ts @@ -3,7 +3,7 @@ import { z } from "zod"; import { CommonResponses, - EndpointMetadata, + meta, MonkeyResponseSchema, responseWithData, } from "./schemas/api"; @@ -45,11 +45,11 @@ export const configurationContract = c.router( responses: { 200: GetConfigurationResponseSchema, }, - metadata: { + metadata: meta({ authenticationOptions: { isPublic: true, }, - } as EndpointMetadata, + }), }, update: { summary: "update configuration", @@ -61,12 +61,13 @@ export const configurationContract = c.router( responses: { 200: MonkeyResponseSchema, }, - metadata: { + metadata: meta({ authenticationOptions: { noCache: true, isPublicOnDev: true, }, - } as EndpointMetadata, + rateLimit: "adminLimit", + }), }, getSchema: { summary: "get configuration schema", @@ -76,20 +77,21 @@ export const configurationContract = c.router( responses: { 200: ConfigurationSchemaResponseSchema, }, - metadata: { + metadata: meta({ authenticationOptions: { isPublicOnDev: true, noCache: true, }, - } as EndpointMetadata, + rateLimit: "adminLimit", + }), }, }, { pathPrefix: "/configuration", strictStatusCodes: true, - metadata: { + metadata: meta({ openApiTags: "configuration", - } as EndpointMetadata, + }), commonResponses: CommonResponses, } diff --git a/packages/contracts/src/dev.ts b/packages/contracts/src/dev.ts index d0656b60b..55de09bb8 100644 --- a/packages/contracts/src/dev.ts +++ b/packages/contracts/src/dev.ts @@ -1,10 +1,6 @@ import { initContract } from "@ts-rest/core"; import { z } from "zod"; -import { - CommonResponses, - EndpointMetadata, - responseWithData, -} from "./schemas/api"; +import { CommonResponses, meta, responseWithData } from "./schemas/api"; import { IdSchema } from "./schemas/util"; export const GenerateDataRequestSchema = z.object({ @@ -47,12 +43,12 @@ export const devContract = c.router( { pathPrefix: "/dev", strictStatusCodes: true, - metadata: { + metadata: meta({ openApiTags: "development", authenticationOptions: { isPublicOnDev: true, }, - } as EndpointMetadata, + }), commonResponses: CommonResponses, } ); diff --git a/packages/contracts/src/leaderboards.ts b/packages/contracts/src/leaderboards.ts index d5c0aba0a..d3a52f8e3 100644 --- a/packages/contracts/src/leaderboards.ts +++ b/packages/contracts/src/leaderboards.ts @@ -1,7 +1,7 @@ import { z } from "zod"; import { CommonResponses, - EndpointMetadata, + meta, responseWithData, responseWithNullableData, } from "./schemas/api"; @@ -98,9 +98,9 @@ export const leaderboardsContract = c.router( responses: { 200: GetLeaderboardResponseSchema, }, - metadata: { + metadata: meta({ authenticationOptions: { isPublic: true }, - } as EndpointMetadata, + }), }, getRank: { summary: "get leaderboard rank", @@ -112,9 +112,9 @@ export const leaderboardsContract = c.router( responses: { 200: GetLeaderboardRankResponseSchema, }, - metadata: { + metadata: meta({ authenticationOptions: { acceptApeKeys: true }, - } as EndpointMetadata, + }), }, getDaily: { summary: "get daily leaderboard", @@ -125,9 +125,9 @@ export const leaderboardsContract = c.router( responses: { 200: GetLeaderboardResponseSchema, }, - metadata: { + metadata: meta({ authenticationOptions: { isPublic: true }, - } as EndpointMetadata, + }), }, getDailyRank: { summary: "get daily leaderboard rank", @@ -148,9 +148,9 @@ export const leaderboardsContract = c.router( responses: { 200: GetWeeklyXpLeaderboardResponseSchema, }, - metadata: { + metadata: meta({ authenticationOptions: { isPublic: true }, - } as EndpointMetadata, + }), }, getWeeklyXpRank: { summary: "get weekly xp leaderboard rank", @@ -166,9 +166,10 @@ export const leaderboardsContract = c.router( { pathPrefix: "/leaderboards", strictStatusCodes: true, - metadata: { + metadata: meta({ openApiTags: "leaderboards", - } as EndpointMetadata, + rateLimit: "leaderboardsGet", + }), commonResponses: CommonResponses, } ); diff --git a/packages/contracts/src/presets.ts b/packages/contracts/src/presets.ts index 9c82da01e..b1d3daec8 100644 --- a/packages/contracts/src/presets.ts +++ b/packages/contracts/src/presets.ts @@ -3,7 +3,7 @@ import { z } from "zod"; import { CommonResponses, - EndpointMetadata, + meta, MonkeyResponseSchema, responseWithData, } from "./schemas/api"; @@ -38,6 +38,9 @@ export const presetsContract = c.router( responses: { 200: GetPresetResponseSchema, }, + metadata: meta({ + rateLimit: "presetsGet", + }), }, add: { summary: "add preset", @@ -48,6 +51,9 @@ export const presetsContract = c.router( responses: { 200: AddPresetResponseSchemna, }, + metadata: meta({ + rateLimit: "presetsAdd", + }), }, save: { summary: "update preset", @@ -58,8 +64,13 @@ export const presetsContract = c.router( responses: { 200: MonkeyResponseSchema, }, + metadata: meta({ + rateLimit: "presetsEdit", + }), }, delete: { + summary: "delete preset", + description: "Delete preset by id.", method: "DELETE", path: "/:presetId", pathParams: DeletePresetsParamsSchema, @@ -67,16 +78,17 @@ export const presetsContract = c.router( responses: { 200: MonkeyResponseSchema, }, - summary: "delete preset", - description: "Delete preset by id.", + metadata: meta({ + rateLimit: "presetsRemove", + }), }, }, { pathPrefix: "/presets", strictStatusCodes: true, - metadata: { + metadata: meta({ openApiTags: "presets", - } as EndpointMetadata, + }), commonResponses: CommonResponses, } diff --git a/packages/contracts/src/psas.ts b/packages/contracts/src/psas.ts index a37835732..49a978f83 100644 --- a/packages/contracts/src/psas.ts +++ b/packages/contracts/src/psas.ts @@ -2,11 +2,7 @@ import { initContract } from "@ts-rest/core"; import { z } from "zod"; import { PSASchema } from "./schemas/psas"; -import { - CommonResponses, - EndpointMetadata, - responseWithData, -} from "./schemas/api"; +import { CommonResponses, meta, responseWithData } from "./schemas/api"; export const GetPsaResponseSchema = responseWithData(z.array(PSASchema)); export type GetPsaResponse = z.infer; @@ -26,12 +22,13 @@ export const psasContract = c.router( { pathPrefix: "/psas", strictStatusCodes: true, - metadata: { + metadata: meta({ openApiTags: "psas", authenticationOptions: { isPublic: true, }, - } as EndpointMetadata, + rateLimit: "psaGet", + }), commonResponses: CommonResponses, } ); diff --git a/packages/contracts/src/public.ts b/packages/contracts/src/public.ts index 93b2b81f9..91e87ca48 100644 --- a/packages/contracts/src/public.ts +++ b/packages/contracts/src/public.ts @@ -1,10 +1,6 @@ import { initContract } from "@ts-rest/core"; import { z } from "zod"; -import { - CommonResponses, - EndpointMetadata, - responseWithData, -} from "./schemas/api"; +import { CommonResponses, meta, responseWithData } from "./schemas/api"; import { SpeedHistogramSchema, TypingStatsSchema } from "./schemas/public"; import { Mode2Schema, ModeSchema } from "./schemas/shared"; import { LanguageSchema } from "./schemas/util"; @@ -59,12 +55,13 @@ export const publicContract = c.router( { pathPrefix: "/public", strictStatusCodes: true, - metadata: { + metadata: meta({ openApiTags: "public", authenticationOptions: { isPublic: true, }, - } as EndpointMetadata, + rateLimit: "publicStatsGet", + }), commonResponses: CommonResponses, } ); diff --git a/packages/contracts/src/quotes.ts b/packages/contracts/src/quotes.ts index 324939ec4..dd964dae6 100644 --- a/packages/contracts/src/quotes.ts +++ b/packages/contracts/src/quotes.ts @@ -3,7 +3,7 @@ import { z } from "zod"; import { CommonResponses, - EndpointMetadata, + meta, MonkeyResponseSchema, responseWithData, responseWithNullableData, @@ -96,6 +96,9 @@ export const quotesContract = c.router( responses: { 200: GetQuotesResponseSchema, }, + metadata: meta({ + rateLimit: "newQuotesGet", + }), }, isSubmissionEnabled: { summary: "is submission enabled", @@ -105,9 +108,10 @@ export const quotesContract = c.router( responses: { 200: IsSubmissionEnabledResponseSchema, }, - metadata: { + metadata: meta({ authenticationOptions: { isPublic: true }, - } as EndpointMetadata, + rateLimit: "newQuotesIsSubmissionEnabled", + }), }, add: { summary: "submit quote", @@ -118,6 +122,9 @@ export const quotesContract = c.router( responses: { 200: MonkeyResponseSchema, }, + metadata: meta({ + rateLimit: "newQuotesAdd", + }), }, approveSubmission: { summary: "submit quote", @@ -128,6 +135,9 @@ export const quotesContract = c.router( responses: { 200: ApproveQuoteResponseSchema, }, + metadata: meta({ + rateLimit: "newQuotesAction", + }), }, rejectSubmission: { summary: "reject quote", @@ -138,6 +148,9 @@ export const quotesContract = c.router( responses: { 200: MonkeyResponseSchema, }, + metadata: meta({ + rateLimit: "newQuotesAction", + }), }, getRating: { summary: "get rating", @@ -148,6 +161,9 @@ export const quotesContract = c.router( responses: { 200: GetQuoteRatingResponseSchema, }, + metadata: meta({ + rateLimit: "quoteRatingsGet", + }), }, addRating: { summary: "add rating", @@ -158,6 +174,9 @@ export const quotesContract = c.router( responses: { 200: MonkeyResponseSchema, }, + metadata: meta({ + rateLimit: "quoteRatingsSubmit", + }), }, report: { summary: "report quote", @@ -168,14 +187,17 @@ export const quotesContract = c.router( responses: { 200: MonkeyResponseSchema, }, + metadata: meta({ + rateLimit: "quoteReportSubmit", + }), }, }, { pathPrefix: "/quotes", strictStatusCodes: true, - metadata: { + metadata: meta({ openApiTags: "quotes", - } as EndpointMetadata, + }), commonResponses: CommonResponses, } ); diff --git a/packages/contracts/src/rate-limit/index.ts b/packages/contracts/src/rate-limit/index.ts new file mode 100644 index 000000000..14a72bc34 --- /dev/null +++ b/packages/contracts/src/rate-limit/index.ts @@ -0,0 +1,374 @@ +export type Window = "second" | "minute" | "hour" | "day" | number; +export type RateLimitOptions = { + /** Timeframe or time in milliseconds */ + window: Window; + /** Max request within the given window */ + max: number; +}; + +export const limits = { + defaultApeRateLimit: { + window: "minute", + max: 30, + }, + + adminLimit: { + window: 5000, //5 seconds + max: 1, + }, + + // Config Routing + configUpdate: { + window: "hour", + max: 500, + }, + + configGet: { + window: "hour", + max: 120, + }, + + configDelete: { + window: "hour", + max: 120, + }, + + // Leaderboards Routing + leaderboardsGet: { + window: "hour", + max: 500, + }, + + // New Quotes Routing + newQuotesGet: { + window: "hour", + max: 500, + }, + + newQuotesIsSubmissionEnabled: { + window: "minute", + max: 60, + }, + + newQuotesAdd: { + window: "hour", + max: 60, + }, + + newQuotesAction: { + window: "hour", + max: 500, + }, + + // Quote Ratings Routing + quoteRatingsGet: { + window: "hour", + max: 500, + }, + + quoteRatingsSubmit: { + window: "hour", + max: 500, + }, + + // Quote reporting + quoteReportSubmit: { + window: 30 * 60 * 1000, // 30 min + max: 50, + }, + + // Quote favorites + quoteFavoriteGet: { + window: 30 * 60 * 1000, // 30 min + max: 50, + }, + + quoteFavoritePost: { + window: 30 * 60 * 1000, // 30 min + max: 50, + }, + + quoteFavoriteDelete: { + window: 30 * 60 * 1000, // 30 min + max: 50, + }, + + // Presets Routing + presetsGet: { + window: "hour", + max: 60, + }, + + presetsAdd: { + window: "hour", + max: 60, + }, + + presetsRemove: { + window: "hour", + max: 60, + }, + + presetsEdit: { + window: "hour", + max: 60, + }, + + // PSA (Public Service Announcement) Routing + psaGet: { + window: "minute", + max: 60, + }, + + // get public speed stats + publicStatsGet: { + window: "minute", + max: 60, + }, + + // Results Routing + resultsGet: { + window: "hour", + max: 60, + }, + + // Results Routing + resultsGetApe: { + window: "day", + max: 30, + }, + + resultsAdd: { + window: "hour", + max: 300, + }, + + resultsTagsUpdate: { + window: "hour", + max: 100, + }, + + resultsDeleteAll: { + window: "hour", + max: 10, + }, + + resultsLeaderboardGet: { + window: "hour", + max: 60, + }, + + resultsLeaderboardQualificationGet: { + window: "hour", + max: 60, + }, + + // Users Routing + userGet: { + window: "hour", + max: 60, + }, + + setStreakHourOffset: { + window: "hour", + max: 5, + }, + + userSignup: { + window: "day", + max: 2, + }, + + userDelete: { + window: "day", + max: 3, + }, + + userReset: { + window: "day", + max: 3, + }, + + userCheckName: { + window: "minute", + max: 60, + }, + + userUpdateName: { + window: "day", + max: 3, + }, + + userUpdateLBMemory: { + window: "minute", + max: 60, + }, + + userUpdateEmail: { + window: "hour", + max: 60, + }, + + userClearPB: { + window: "hour", + max: 60, + }, + + userOptOutOfLeaderboards: { + window: "hour", + max: 10, + }, + + userCustomFilterAdd: { + window: "hour", + max: 60, + }, + + userCustomFilterRemove: { + window: "hour", + max: 60, + }, + + userTagsGet: { + window: "hour", + max: 60, + }, + + userTagsRemove: { + window: "hour", + max: 30, + }, + + userTagsClearPB: { + window: "hour", + max: 60, + }, + + userTagsEdit: { + window: "hour", + max: 30, + }, + + userTagsAdd: { + window: "hour", + max: 30, + }, + + userCustomThemeGet: { + window: "hour", + max: 30, + }, + + userCustomThemeAdd: { + window: "hour", + max: 30, + }, + + userCustomThemeRemove: { + window: "hour", + max: 30, + }, + + userCustomThemeEdit: { + window: "hour", + max: 30, + }, + + userDiscordLink: { + window: "hour", + max: 15, + }, + + userDiscordUnlink: { + window: "hour", + max: 15, + }, + + userRequestVerificationEmail: { + window: 15 * 60 * 1000, //15 minutes + max: 1, + }, + + userForgotPasswordEmail: { + window: "minute", + max: 1, + }, + + userRevokeAllTokens: { + window: "hour", + max: 10, + }, + + userProfileGet: { + window: "hour", + max: 100, + }, + + userProfileUpdate: { + window: "hour", + max: 60, + }, + + userMailGet: { + window: "hour", + max: 60, + }, + + userMailUpdate: { + window: "hour", + max: 60, + }, + + userTestActivity: { + window: "hour", + max: 60, + }, + + userCurrentTestActivity: { + window: "hour", + max: 60, + }, + + userStreak: { + window: "hour", + max: 60, + }, + + // ApeKeys Routing + apeKeysGet: { + window: "hour", + max: 120, + }, + + apeKeysGenerate: { + window: "hour", + max: 15, + }, + + webhookLimit: { + window: "second", + max: 1, + }, +} satisfies Record; + +export type RateLimiterId = keyof typeof limits; +export type RateLimitIds = { + /** rate limiter options for non-apeKey requests */ + normal: RateLimiterId; + /** Rate limiter options for apeKey requests */ + apeKey: RateLimiterId; +}; + +export function getLimits(limit: RateLimiterId | RateLimitIds): { + limiter: RateLimitOptions; + apeKeyLimiter?: RateLimitOptions; +} { + const isApeKeyLimiter = typeof limit === "object"; + const limiter = isApeKeyLimiter ? limit.normal : limit; + const apeLimiter = isApeKeyLimiter ? limit.apeKey : undefined; + + return { + limiter: limits[limiter], + apeKeyLimiter: apeLimiter !== undefined ? limits[apeLimiter] : undefined, + }; +} diff --git a/packages/contracts/src/results.ts b/packages/contracts/src/results.ts index d99664803..793c9ac7c 100644 --- a/packages/contracts/src/results.ts +++ b/packages/contracts/src/results.ts @@ -2,7 +2,7 @@ import { initContract } from "@ts-rest/core"; import { z } from "zod"; import { CommonResponses, - EndpointMetadata, + meta, MonkeyResponseSchema, responseWithData, } from "./schemas/api"; @@ -75,19 +75,22 @@ export const resultsContract = c.router( { get: { summary: "get results", - description: - "Gets up to 1000 results (endpoint limited to 30 requests per day for ape keys)", + description: "Gets up to 1000 results", method: "GET", path: "", query: GetResultsQuerySchema.strict(), responses: { 200: GetResultsResponseSchema, }, - metadata: { + metadata: meta({ authenticationOptions: { acceptApeKeys: true, }, - } as EndpointMetadata, + rateLimit: { + normal: "resultsGet", + apeKey: "resultsGetApe", + }, + }), }, add: { summary: "add result", @@ -98,6 +101,9 @@ export const resultsContract = c.router( responses: { 200: AddResultResponseSchema, }, + metadata: meta({ + rateLimit: "resultsAdd", + }), }, updateTags: { summary: "update result tags", @@ -108,6 +114,9 @@ export const resultsContract = c.router( responses: { 200: UpdateResultTagsResponseSchema, }, + metadata: meta({ + rateLimit: "resultsTagsUpdate", + }), }, deleteAll: { summary: "delete all results", @@ -118,11 +127,12 @@ export const resultsContract = c.router( responses: { 200: MonkeyResponseSchema, }, - metadata: { + metadata: meta({ authenticationOptions: { requireFreshToken: true, }, - } as EndpointMetadata, + rateLimit: "resultsDeleteAll", + }), }, getLast: { summary: "get last result", @@ -132,19 +142,20 @@ export const resultsContract = c.router( responses: { 200: GetLastResultResponseSchema, }, - metadata: { + metadata: meta({ authenticationOptions: { acceptApeKeys: true, }, - } as EndpointMetadata, + rateLimit: "resultsGet", + }), }, }, { pathPrefix: "/results", strictStatusCodes: true, - metadata: { + metadata: meta({ openApiTags: "results", - } as EndpointMetadata, + }), commonResponses: CommonResponses, } ); diff --git a/packages/contracts/src/schemas/api.ts b/packages/contracts/src/schemas/api.ts index ac05bd219..df18aa2cc 100644 --- a/packages/contracts/src/schemas/api.ts +++ b/packages/contracts/src/schemas/api.ts @@ -1,4 +1,5 @@ import { z, ZodSchema } from "zod"; +import { RateLimitIds, RateLimiterId } from "../rate-limit"; export type OpenApiTag = | "configs" @@ -17,9 +18,25 @@ export type OpenApiTag = export type EndpointMetadata = { /** Authentication options, by default a bearer token is required. */ authenticationOptions?: RequestAuthenticationOptions; + openApiTags?: OpenApiTag | OpenApiTag[]; + + /** RateLimitId or RateLimitIds. + * Only specifying RateLimiterId will use a default limiter with 30 requests/minute for ApeKey requests. + */ + rateLimit?: RateLimiterId | RateLimitIds; }; +/** + * + * @param meta Ensure the type of metadata is `EndpointMetadata`. + * Ts-rest does not allow to specify the type of `metadata`. + * @returns + */ +export function meta(meta: EndpointMetadata): EndpointMetadata { + return meta; +} + export type RequestAuthenticationOptions = { /** Endpoint is accessible without any authentication. If `false` bearer authentication is required. */ isPublic?: boolean; diff --git a/packages/contracts/src/users.ts b/packages/contracts/src/users.ts index c6445f76b..f391beddd 100644 --- a/packages/contracts/src/users.ts +++ b/packages/contracts/src/users.ts @@ -2,7 +2,7 @@ import { initContract } from "@ts-rest/core"; import { z } from "zod"; import { CommonResponses, - EndpointMetadata, + meta, MonkeyClientError, MonkeyResponseSchema, responseWithData, @@ -334,6 +334,9 @@ export const usersContract = c.router( responses: { 200: GetUserResponseSchema, }, + metadata: meta({ + rateLimit: "userGet", + }), }, create: { summary: "create user", @@ -344,6 +347,9 @@ export const usersContract = c.router( responses: { 200: MonkeyResponseSchema, }, + metadata: meta({ + rateLimit: "userSignup", + }), }, getNameAvailability: { summary: "check name", @@ -355,9 +361,10 @@ export const usersContract = c.router( 200: MonkeyResponseSchema.describe("Name is available"), 409: MonkeyResponseSchema.describe("Name is not available"), }, - metadata: { + metadata: meta({ authenticationOptions: { isPublic: true }, - } as EndpointMetadata, + rateLimit: "userCheckName", + }), }, delete: { summary: "delete user", @@ -368,9 +375,10 @@ export const usersContract = c.router( responses: { 200: MonkeyResponseSchema, }, - metadata: { + metadata: meta({ authenticationOptions: { requireFreshToken: true }, - } as EndpointMetadata, + rateLimit: "userDelete", + }), }, reset: { summary: "reset user", @@ -381,9 +389,10 @@ export const usersContract = c.router( responses: { 200: MonkeyResponseSchema, }, - metadata: { + metadata: meta({ authenticationOptions: { requireFreshToken: true }, - } as EndpointMetadata, + rateLimit: "userReset", + }), }, updateName: { summary: "update username", @@ -394,9 +403,10 @@ export const usersContract = c.router( responses: { 200: MonkeyResponseSchema, }, - metadata: { + metadata: meta({ authenticationOptions: { requireFreshToken: true }, - } as EndpointMetadata, + rateLimit: "userUpdateName", + }), }, updateLeaderboardMemory: { summary: "update lbMemory", @@ -407,6 +417,9 @@ export const usersContract = c.router( responses: { 200: MonkeyResponseSchema, }, + metadata: meta({ + rateLimit: "userUpdateLBMemory", + }), }, updateEmail: { summary: "update email", @@ -417,9 +430,10 @@ export const usersContract = c.router( responses: { 200: MonkeyResponseSchema, }, - metadata: { + metadata: meta({ authenticationOptions: { requireFreshToken: true }, - } as EndpointMetadata, + rateLimit: "userUpdateEmail", + }), }, updatePassword: { summary: "update password", @@ -430,9 +444,10 @@ export const usersContract = c.router( responses: { 200: MonkeyResponseSchema, }, - metadata: { + metadata: meta({ authenticationOptions: { requireFreshToken: true }, - } as EndpointMetadata, + rateLimit: "userUpdateEmail", + }), }, getPersonalBests: { summary: "get personal bests", @@ -443,9 +458,10 @@ export const usersContract = c.router( responses: { 200: GetPersonalBestsResponseSchema, }, - metadata: { + metadata: meta({ authenticationOptions: { acceptApeKeys: true }, - } as EndpointMetadata, + rateLimit: "userGet", + }), }, deletePersonalBests: { summary: "delete personal bests", @@ -456,9 +472,10 @@ export const usersContract = c.router( responses: { 200: MonkeyResponseSchema, }, - metadata: { + metadata: meta({ authenticationOptions: { requireFreshToken: true }, - } as EndpointMetadata, + rateLimit: "userClearPB", + }), }, optOutOfLeaderboards: { summary: "leaderboards opt out", @@ -469,9 +486,10 @@ export const usersContract = c.router( responses: { 200: MonkeyResponseSchema, }, - metadata: { + metadata: meta({ authenticationOptions: { requireFreshToken: true }, - } as EndpointMetadata, + rateLimit: "userOptOutOfLeaderboards", + }), }, addResultFilterPreset: { summary: "add result filter preset", @@ -482,6 +500,9 @@ export const usersContract = c.router( responses: { 200: AddResultFilterPresetResponseSchema, }, + metadata: meta({ + rateLimit: "userCustomFilterAdd", + }), }, removeResultFilterPreset: { summary: "remove result filter preset", @@ -493,6 +514,9 @@ export const usersContract = c.router( responses: { 200: MonkeyResponseSchema, }, + metadata: meta({ + rateLimit: "userCustomFilterRemove", + }), }, getTags: { summary: "get tags", @@ -502,9 +526,10 @@ export const usersContract = c.router( responses: { 200: GetTagsResponseSchema, }, - metadata: { + metadata: meta({ authenticationOptions: { acceptApeKeys: true }, - } as EndpointMetadata, + rateLimit: "userTagsGet", + }), }, createTag: { summary: "add tag", @@ -515,6 +540,9 @@ export const usersContract = c.router( responses: { 200: AddTagResponseSchema, }, + metadata: meta({ + rateLimit: "userTagsAdd", + }), }, editTag: { summary: "edit tag", @@ -525,6 +553,9 @@ export const usersContract = c.router( responses: { 200: MonkeyResponseSchema, }, + metadata: meta({ + rateLimit: "userTagsEdit", + }), }, deleteTag: { summary: "delete tag", @@ -536,6 +567,9 @@ export const usersContract = c.router( responses: { 200: MonkeyResponseSchema, }, + metadata: meta({ + rateLimit: "userTagsRemove", + }), }, deleteTagPersonalBest: { summary: "delete tag PBs", @@ -547,6 +581,9 @@ export const usersContract = c.router( responses: { 200: MonkeyResponseSchema, }, + metadata: meta({ + rateLimit: "userTagsClearPB", + }), }, getCustomThemes: { summary: "get custom themes", @@ -556,6 +593,9 @@ export const usersContract = c.router( responses: { 200: GetCustomThemesResponseSchema, }, + metadata: meta({ + rateLimit: "userCustomThemeGet", + }), }, addCustomTheme: { summary: "add custom themes", @@ -566,6 +606,9 @@ export const usersContract = c.router( responses: { 200: AddCustomThemeResponseSchema, }, + metadata: meta({ + rateLimit: "userCustomThemeAdd", + }), }, deleteCustomTheme: { summary: "delete custom themes", @@ -576,6 +619,9 @@ export const usersContract = c.router( responses: { 200: MonkeyResponseSchema, }, + metadata: meta({ + rateLimit: "userCustomThemeRemove", + }), }, editCustomTheme: { summary: "edit custom themes", @@ -586,6 +632,9 @@ export const usersContract = c.router( responses: { 200: MonkeyResponseSchema, }, + metadata: meta({ + rateLimit: "userCustomThemeEdit", + }), }, getDiscordOAuth: { summary: "discord oauth", @@ -595,6 +644,9 @@ export const usersContract = c.router( responses: { 200: GetDiscordOauthLinkResponseSchema, }, + metadata: meta({ + rateLimit: "userDiscordLink", + }), }, linkDiscord: { summary: "link with discord", @@ -605,7 +657,9 @@ export const usersContract = c.router( responses: { 200: LinkDiscordResponseSchema, }, - metadata: {} as EndpointMetadata, + metadata: meta({ + rateLimit: "userDiscordLink", + }), }, unlinkDiscord: { summary: "unlink discord", @@ -616,6 +670,9 @@ export const usersContract = c.router( responses: { 200: MonkeyResponseSchema, }, + metadata: meta({ + rateLimit: "userDiscordUnlink", + }), }, getStats: { summary: "get stats", @@ -625,9 +682,10 @@ export const usersContract = c.router( responses: { 200: GetStatsResponseSchema, }, - metadata: { + metadata: meta({ authenticationOptions: { acceptApeKeys: true }, - } as EndpointMetadata, + rateLimit: "userGet", + }), }, setStreakHourOffset: { summary: "set streak hour offset", @@ -638,6 +696,9 @@ export const usersContract = c.router( responses: { 200: MonkeyResponseSchema, }, + metadata: meta({ + rateLimit: "setStreakHourOffset", + }), }, getFavoriteQuotes: { summary: "get favorite quotes", @@ -647,6 +708,9 @@ export const usersContract = c.router( responses: { 200: GetFavoriteQuotesResponseSchema, }, + metadata: meta({ + rateLimit: "quoteFavoriteGet", + }), }, addQuoteToFavorites: { summary: "add favorite quotes", @@ -657,6 +721,9 @@ export const usersContract = c.router( responses: { 200: MonkeyResponseSchema, }, + metadata: meta({ + rateLimit: "quoteFavoritePost", + }), }, removeQuoteFromFavorites: { summary: "remove favorite quotes", @@ -667,6 +734,9 @@ export const usersContract = c.router( responses: { 200: MonkeyResponseSchema, }, + metadata: meta({ + rateLimit: "quoteFavoriteDelete", + }), }, getProfile: { summary: "get profile", @@ -679,9 +749,10 @@ export const usersContract = c.router( 200: GetProfileResponseSchema, 404: MonkeyClientError.describe("User not found"), }, - metadata: { + metadata: meta({ authenticationOptions: { isPublic: true }, - } as EndpointMetadata, + rateLimit: "userProfileGet", + }), }, updateProfile: { summary: "update profile", @@ -692,6 +763,9 @@ export const usersContract = c.router( responses: { 200: UpdateUserProfileResponseSchema, }, + metadata: meta({ + rateLimit: "userProfileUpdate", + }), }, getInbox: { summary: "get inbox", @@ -701,6 +775,9 @@ export const usersContract = c.router( responses: { 200: GetUserInboxResponseSchema, }, + metadata: meta({ + rateLimit: "userMailGet", + }), }, updateInbox: { summary: "update inbox", @@ -711,6 +788,9 @@ export const usersContract = c.router( responses: { 200: MonkeyResponseSchema, }, + metadata: meta({ + rateLimit: "userMailUpdate", + }), }, report: { summary: "report user", @@ -721,6 +801,9 @@ export const usersContract = c.router( responses: { 200: MonkeyResponseSchema, }, + metadata: meta({ + rateLimit: "quoteReportSubmit", + }), }, verificationEmail: { summary: "send verification email", @@ -730,9 +813,10 @@ export const usersContract = c.router( responses: { 200: MonkeyResponseSchema, }, - metadata: { + metadata: meta({ authenticationOptions: { noCache: true }, - } as EndpointMetadata, + rateLimit: "userRequestVerificationEmail", + }), }, forgotPasswordEmail: { summary: "send forgot password email", @@ -743,9 +827,10 @@ export const usersContract = c.router( responses: { 200: MonkeyResponseSchema, }, - metadata: { + metadata: meta({ authenticationOptions: { isPublic: true }, - } as EndpointMetadata, + rateLimit: "userForgotPasswordEmail", + }), }, revokeAllTokens: { summary: "revoke all tokens", @@ -756,9 +841,10 @@ export const usersContract = c.router( responses: { 200: MonkeyResponseSchema, }, - metadata: { + metadata: meta({ authenticationOptions: { requireFreshToken: true, noCache: true }, - } as EndpointMetadata, + rateLimit: "userRevokeAllTokens", + }), }, getTestActivity: { summary: "get test activity", @@ -768,6 +854,9 @@ export const usersContract = c.router( responses: { 200: GetTestActivityResponseSchema, }, + metadata: meta({ + rateLimit: "userTestActivity", + }), }, getCurrentTestActivity: { summary: "get current test activity", @@ -778,9 +867,10 @@ export const usersContract = c.router( responses: { 200: GetCurrentTestActivityResponseSchema, }, - metadata: { + metadata: meta({ authenticationOptions: { acceptApeKeys: true }, - } as EndpointMetadata, + rateLimit: "userCurrentTestActivity", + }), }, getStreak: { summary: "get streak", @@ -790,17 +880,18 @@ export const usersContract = c.router( responses: { 200: GetStreakResponseSchema, }, - metadata: { + metadata: meta({ authenticationOptions: { acceptApeKeys: true }, - } as EndpointMetadata, + rateLimit: "userStreak", + }), }, }, { pathPrefix: "/users", strictStatusCodes: true, - metadata: { + metadata: meta({ openApiTags: "users", - } as EndpointMetadata, + }), commonResponses: CommonResponses, } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 39bdcc2bf..730ff76fa 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -36,8 +36,8 @@ importers: specifier: 1.2.1 version: 1.2.1 prettier: - specifier: 2.5.1 - version: 2.5.1 + specifier: 2.8.8 + version: 2.8.8 turbo: specifier: 2.0.12 version: 2.0.12 @@ -87,8 +87,8 @@ importers: specifier: 4.19.2 version: 4.19.2 express-rate-limit: - specifier: 6.2.1 - version: 6.2.1(express@4.19.2) + specifier: 7.4.0 + version: 7.4.0(express@4.19.2) firebase-admin: specifier: 12.0.0 version: 12.0.0(encoding@0.1.13) @@ -4557,11 +4557,11 @@ packages: exponential-backoff@3.1.1: resolution: {integrity: sha512-dX7e/LHVJ6W3DE1MHWi9S1EYzDESENfLrYohG2G++ovZrYOkm4Knwa0mc1cn84xJOR4KEU0WSchhLbd0UklbHw==} - express-rate-limit@6.2.1: - resolution: {integrity: sha512-22ovnpEiKR5iAMXDOQ7A6aOvb078JLvoHGlyrrWBl3PeJ34coyakaviPelj4Nc8d+yDoVIWYmaUNP5aYT4ICDQ==} - engines: {node: '>= 14.5.0'} + express-rate-limit@7.4.0: + resolution: {integrity: sha512-v1204w3cXu5gCDmAvgvzI6qjzZzoMWKnyVDk3ACgfswTQLYiGen+r8w0VnXnGMmzEN/g8fwIQ4JrFFd4ZP6ssg==} + engines: {node: '>= 16'} peerDependencies: - express: ^4 + express: 4 || 5 || ^5.0.0-beta.1 express@4.19.2: resolution: {integrity: sha512-5T6nhjsT+EOMzuck8JjBHARTHfMht0POzlA60WV2pMD3gyXw2LZnZ+ueGdNxG+0calOJcWKbpFcuzLZ91YWq9Q==} @@ -7365,8 +7365,8 @@ packages: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} - prettier@2.5.1: - resolution: {integrity: sha512-vBZcPRUR5MZJwoyi3ZoyQlc1rXeEck8KgeC9AwwOn+exuxLxq5toTRDTSaVrXHxelDMHy9zlicw8u66yxoSUFg==} + prettier@2.8.8: + resolution: {integrity: sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q==} engines: {node: '>=10.13.0'} hasBin: true @@ -14221,7 +14221,7 @@ snapshots: exponential-backoff@3.1.1: optional: true - express-rate-limit@6.2.1(express@4.19.2): + express-rate-limit@7.4.0(express@4.19.2): dependencies: express: 4.19.2 @@ -17662,7 +17662,7 @@ snapshots: prelude-ls@1.2.1: {} - prettier@2.5.1: {} + prettier@2.8.8: {} pretty-bytes@5.6.0: {}