refactor: rework rate limiting (@fehmer) (#5845)

!nuf
This commit is contained in:
Christian Fehmer 2024-09-09 10:39:08 +02:00 committed by GitHub
parent e655aa741a
commit b06b9f73e5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
46 changed files with 1041 additions and 800 deletions

View file

@ -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'

View file

@ -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<MatcherResult> => {
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,
};
},
});
}

View file

@ -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<void> {

View file

@ -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");

View file

@ -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";

View file

@ -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"]
}

24
backend/__tests__/vitest.d.ts vendored Normal file
View file

@ -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<R = Supertest> {
toBeRateLimited: (expected: ExpectedRateLimit) => RestRequestMatcher<R>;
}
declare module "vitest" {
interface Assertion<T = any> extends RestRequestMatcher<T> {}
interface AsymmetricMatchersContaining extends RestRequestMatcher {}
}
interface MatcherResult {
pass: boolean;
message: () => string;
actual?: unknown;
expected?: unknown;
}

View file

@ -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",

View file

@ -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);

View file

@ -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;

View file

@ -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),
},
});

View file

@ -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),
},
});

View file

@ -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),
},
});

View file

@ -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()],
});
}

View file

@ -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),
},

View file

@ -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),
},
});

View file

@ -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),
},
});

View file

@ -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),
},
});

View file

@ -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;

View file

@ -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),
},
});

View file

@ -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),
},
});

View file

@ -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);
};
}

View file

@ -26,7 +26,8 @@ const DEFAULT_OPTIONS: RequestAuthenticationOptions = {
export type TsRestRequestWithCtx = {
ctx: Readonly<MonkeyTypes.Context>;
} & TsRestRequest;
} & TsRestRequest &
ExpressRequest;
/**
* Authenticate request based on the auth settings of the route.

View file

@ -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<RateLimiterId, RateLimitRequestHandler> {
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<RateLimiterId, RateLimitRequestHandler>;
}
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<RateLimiterId, RateLimitRequestHandler> =
initialiseLimiters();
export function rateLimitRequest<
T extends AppRouter | AppRoute
>(): TsRestRequestHandler<T> {
return async (
req: TsRestRequestWithCtx,
res: Response,
next: NextFunction
): Promise<void> => {
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;

View file

@ -22,6 +22,10 @@ declare namespace MonkeyTypes {
ctx: Readonly<Context>;
} & ExpressRequest;
type ExpressRequestWithContext = {
ctx: Readonly<Context>;
} & ExpressRequest;
type Request2<TQuery = undefined, TBody = undefined, TParams = undefined> = {
query: Readonly<TQuery>;
body: Readonly<TBody>;

View file

@ -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;

View file

@ -264,12 +264,12 @@ function setFilter<G extends ResultFiltersGroup>(
filter: ResultFiltersGroupItem<G>,
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;
});
}

View file

@ -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 =

View file

@ -40,7 +40,7 @@ function getSearchService<T>(
const newSearchService = buildSearchService<T>(data, textExtractor);
searchServiceCache[language] =
newSearchService as unknown as typeof searchServiceCache[typeof language];
newSearchService as unknown as (typeof searchServiceCache)[typeof language];
return newSearchService;
}

View file

@ -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;

View file

@ -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"
},

View file

@ -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,
}

View file

@ -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,
}

View file

@ -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,
}

View file

@ -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,
}

View file

@ -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,
}
);

View file

@ -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,
}
);

View file

@ -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,
}

View file

@ -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<typeof GetPsaResponseSchema>;
@ -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,
}
);

View file

@ -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,
}
);

View file

@ -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,
}
);

View file

@ -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<string, RateLimitOptions>;
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,
};
}

View file

@ -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,
}
);

View file

@ -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;

View file

@ -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,
}

View file

@ -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: {}