diff --git a/backend/scripts/openapi.ts b/backend/scripts/openapi.ts index 0e14174f752fe..c053b3d066efb 100644 --- a/backend/scripts/openapi.ts +++ b/backend/scripts/openapi.ts @@ -139,11 +139,14 @@ export function getOpenApi(): OpenAPIObject { setOperationId: "concatenated-path", operationMapper: (operation, route) => { const metadata = route.metadata as EndpointMetadata; + if (!operation.description?.trim()?.endsWith(".")) + operation.description += "."; + operation.description += "\n\n"; addAuth(operation, metadata); - addTags(operation, metadata); addRateLimit(operation, metadata); - + addRequiredConfiguration(operation, metadata); + addTags(operation, metadata); return operation; }, } @@ -169,6 +172,10 @@ function addAuth( const includeInPublic = auth.isPublic === true || auth.acceptApeKeys === true; operation["x-public"] = includeInPublic ? "yes" : "no"; operation.security = security; + + if (roles.length !== 0) { + operation.description += ` **Required roles:** ${roles.join(", ")}\n\n`; + } } function getRequiredRoles( @@ -199,9 +206,6 @@ function addRateLimit( const okResponse = operation.responses["200"]; if (okResponse === undefined) return; - if (!operation.description?.trim()?.endsWith(".")) - operation.description += "."; - operation.description += getRateLimitDescription(metadata.rateLimit); okResponse["headers"] = { @@ -223,7 +227,7 @@ function addRateLimit( function getRateLimitDescription(limit: RateLimit | ApeKeyRateLimit): string { const limits = getLimits(limit); - let result = ` This operation can be called up to ${ + let result = ` **Rate limit:** This operation can be called up to ${ limits.limiter.max } times ${formatWindow(limits.limiter.window)} for regular users`; @@ -233,7 +237,7 @@ function getRateLimitDescription(limit: RateLimit | ApeKeyRateLimit): string { )} with ApeKeys`; } - return result + "."; + return result + ".\n\n"; } function formatWindow(window: Window): string { @@ -261,6 +265,16 @@ function formatWindow(window: Window): string { } } +function addRequiredConfiguration( + operation: OperationObject, + metadata: EndpointMetadata | undefined +): void { + if (metadata === undefined || metadata.requireConfiguration === undefined) + return; + + operation.description += `**Required configuration:** This operation can only be called if the [configuration](#tag/configuration/operation/configuration.get) for \`${metadata.requireConfiguration.path}\` is \`true\`.\n\n`; +} + //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/ape-keys.ts b/backend/src/api/routes/ape-keys.ts index 08ff067b67381..e5c027548b400 100644 --- a/backend/src/api/routes/ape-keys.ts +++ b/backend/src/api/routes/ape-keys.ts @@ -3,33 +3,18 @@ import { initServer } from "@ts-rest/express"; import * as ApeKeyController from "../controllers/ape-key"; import { callController } from "../ts-rest-adapter"; -import { validate } from "../../middlewares/configuration"; - -const commonMiddleware = [ - validate({ - criteria: (configuration) => { - return configuration.apeKeys.endpointsEnabled; - }, - invalidMessage: "ApeKeys are currently disabled.", - }), -]; - const s = initServer(); export default s.router(apeKeysContract, { get: { - middleware: commonMiddleware, handler: async (r) => callController(ApeKeyController.getApeKeys)(r), }, add: { - middleware: commonMiddleware, handler: async (r) => callController(ApeKeyController.generateApeKey)(r), }, save: { - middleware: commonMiddleware, handler: async (r) => callController(ApeKeyController.editApeKey)(r), }, delete: { - middleware: commonMiddleware, handler: async (r) => callController(ApeKeyController.deleteApeKey)(r), }, }); diff --git a/backend/src/api/routes/index.ts b/backend/src/api/routes/index.ts index 72faa9cbc8121..9fc4533f612ad 100644 --- a/backend/src/api/routes/index.ts +++ b/backend/src/api/routes/index.ts @@ -36,6 +36,7 @@ import { MonkeyValidationError } from "@monkeytype/contracts/schemas/api"; import { authenticateTsRestRequest } from "../../middlewares/auth"; import { rateLimitRequest } from "../../middlewares/rate-limit"; import { checkRequiredRole } from "../../middlewares/permission"; +import { checkRequiredConfiguration } from "../../middlewares/configuration"; const pathOverride = process.env["API_PATH_OVERRIDE"]; const BASE_ROUTE = pathOverride !== undefined ? `/${pathOverride}` : ""; @@ -115,6 +116,7 @@ function applyTsRestApiRoutes(app: IRouter): void { }, globalMiddleware: [ authenticateTsRestRequest(), + checkRequiredConfiguration(), rateLimitRequest(), checkRequiredRole(), ], diff --git a/backend/src/api/routes/leaderboards.ts b/backend/src/api/routes/leaderboards.ts index 171fa09e601ac..1533fd562d677 100644 --- a/backend/src/api/routes/leaderboards.ts +++ b/backend/src/api/routes/leaderboards.ts @@ -1,24 +1,8 @@ import { initServer } from "@ts-rest/express"; -import { validate } from "../../middlewares/configuration"; import * as LeaderboardController from "../controllers/leaderboard"; - import { leaderboardsContract } from "@monkeytype/contracts/leaderboards"; import { callController } from "../ts-rest-adapter"; -const requireDailyLeaderboardsEnabled = validate({ - criteria: (configuration) => { - return configuration.dailyLeaderboards.enabled; - }, - invalidMessage: "Daily leaderboards are not available at this time.", -}); - -const requireWeeklyXpLeaderboardEnabled = validate({ - criteria: (configuration) => { - return configuration.leaderboards.weeklyXp.enabled; - }, - invalidMessage: "Weekly XP leaderboards are not available at this time.", -}); - const s = initServer(); export default s.router(leaderboardsContract, { get: { @@ -30,22 +14,18 @@ export default s.router(leaderboardsContract, { callController(LeaderboardController.getRankFromLeaderboard)(r), }, getDaily: { - middleware: [requireDailyLeaderboardsEnabled], handler: async (r) => callController(LeaderboardController.getDailyLeaderboard)(r), }, getDailyRank: { - middleware: [requireDailyLeaderboardsEnabled], handler: async (r) => callController(LeaderboardController.getDailyLeaderboardRank)(r), }, getWeeklyXp: { - middleware: [requireWeeklyXpLeaderboardEnabled], handler: async (r) => callController(LeaderboardController.getWeeklyXpLeaderboardResults)(r), }, getWeeklyXpRank: { - middleware: [requireWeeklyXpLeaderboardEnabled], handler: async (r) => callController(LeaderboardController.getWeeklyXpLeaderboardRank)(r), }, diff --git a/backend/src/api/routes/quotes.ts b/backend/src/api/routes/quotes.ts index bc48cd4d1bb30..33ecf29fa4bea 100644 --- a/backend/src/api/routes/quotes.ts +++ b/backend/src/api/routes/quotes.ts @@ -1,6 +1,5 @@ import { quotesContract } from "@monkeytype/contracts/quotes"; import { initServer } from "@ts-rest/express"; -import { validate } from "../../middlewares/configuration"; import * as QuoteController from "../controllers/quote"; import { callController } from "../ts-rest-adapter"; @@ -14,15 +13,6 @@ export default s.router(quotesContract, { callController(QuoteController.isSubmissionEnabled)(r), }, add: { - middleware: [ - validate({ - criteria: (configuration) => { - return configuration.quotes.submissionsEnabled; - }, - invalidMessage: - "Quote submission is disabled temporarily. The queue is quite long and we need some time to catch up.", - }), - ], handler: async (r) => callController(QuoteController.addQuote)(r), }, approveSubmission: { @@ -38,14 +28,6 @@ export default s.router(quotesContract, { handler: async (r) => callController(QuoteController.submitRating)(r), }, report: { - middleware: [ - validate({ - criteria: (configuration) => { - return configuration.quotes.reporting.enabled; - }, - invalidMessage: "Quote reporting is unavailable.", - }), - ], handler: async (r) => callController(QuoteController.reportQuote)(r), }, }); diff --git a/backend/src/api/routes/results.ts b/backend/src/api/routes/results.ts index 210a5951b1e03..bc3d6772a20ec 100644 --- a/backend/src/api/routes/results.ts +++ b/backend/src/api/routes/results.ts @@ -1,23 +1,14 @@ import { resultsContract } from "@monkeytype/contracts/results"; import { initServer } from "@ts-rest/express"; -import { validate } from "../../middlewares/configuration"; import * as ResultController from "../controllers/result"; import { callController } from "../ts-rest-adapter"; -const validateResultSavingEnabled = validate({ - criteria: (configuration) => { - return configuration.results.savingEnabled; - }, - invalidMessage: "Results are not being saved at this time.", -}); - const s = initServer(); export default s.router(resultsContract, { get: { handler: async (r) => callController(ResultController.getResults)(r), }, add: { - middleware: [validateResultSavingEnabled], handler: async (r) => callController(ResultController.addResult)(r), }, updateTags: { diff --git a/backend/src/api/routes/users.ts b/backend/src/api/routes/users.ts index af6ecf82c3790..8e34831a8c0d3 100644 --- a/backend/src/api/routes/users.ts +++ b/backend/src/api/routes/users.ts @@ -1,51 +1,14 @@ import { usersContract } from "@monkeytype/contracts/users"; import { initServer } from "@ts-rest/express"; -import { validate } from "../../middlewares/configuration"; import * as UserController from "../controllers/user"; import { callController } from "../ts-rest-adapter"; -const requireFilterPresetsEnabled = validate({ - criteria: (configuration) => { - return configuration.results.filterPresets.enabled; - }, - invalidMessage: "Result filter presets are not available at this time.", -}); - -const requireDiscordIntegrationEnabled = validate({ - criteria: (configuration) => { - return configuration.users.discordIntegration.enabled; - }, - invalidMessage: "Discord integration is not available at this time", -}); - -const requireProfilesEnabled = validate({ - criteria: (configuration) => { - return configuration.users.profiles.enabled; - }, - invalidMessage: "Profiles are not available at this time", -}); - -const requireInboxEnabled = validate({ - criteria: (configuration) => { - return configuration.users.inbox.enabled; - }, - invalidMessage: "Your inbox is not available at this time.", -}); - const s = initServer(); export default s.router(usersContract, { get: { handler: async (r) => callController(UserController.getUser)(r), }, create: { - middleware: [ - validate({ - criteria: (configuration) => { - return configuration.users.signUp; - }, - invalidMessage: "Sign up is temporarily disabled", - }), - ], handler: async (r) => callController(UserController.createNewUser)(r), }, getNameAvailability: { @@ -80,12 +43,10 @@ export default s.router(usersContract, { callController(UserController.optOutOfLeaderboards)(r), }, addResultFilterPreset: { - middleware: [requireFilterPresetsEnabled], handler: async (r) => callController(UserController.addResultFilterPreset)(r), }, removeResultFilterPreset: { - middleware: [requireFilterPresetsEnabled], handler: async (r) => callController(UserController.removeResultFilterPreset)(r), }, @@ -117,11 +78,9 @@ export default s.router(usersContract, { handler: async (r) => callController(UserController.editCustomTheme)(r), }, getDiscordOAuth: { - middleware: [requireDiscordIntegrationEnabled], handler: async (r) => callController(UserController.getOauthLink)(r), }, linkDiscord: { - middleware: [requireDiscordIntegrationEnabled], handler: async (r) => callController(UserController.linkDiscord)(r), }, unlinkDiscord: { @@ -143,30 +102,18 @@ export default s.router(usersContract, { handler: async (r) => callController(UserController.removeFavoriteQuote)(r), }, getProfile: { - middleware: [requireProfilesEnabled], handler: async (r) => callController(UserController.getProfile)(r), }, updateProfile: { - middleware: [requireProfilesEnabled], handler: async (r) => callController(UserController.updateProfile)(r), }, getInbox: { - middleware: [requireInboxEnabled], handler: async (r) => callController(UserController.getInbox)(r), }, updateInbox: { - middleware: [requireInboxEnabled], handler: async (r) => callController(UserController.updateInbox)(r), }, report: { - middleware: [ - validate({ - criteria: (configuration) => { - return configuration.quotes.reporting.enabled; - }, - invalidMessage: "User reporting is unavailable.", - }), - ], handler: async (r) => callController(UserController.reportUser)(r), }, verificationEmail: { diff --git a/backend/src/middlewares/configuration.ts b/backend/src/middlewares/configuration.ts index 042728cedd22e..4e481fb57e7f7 100644 --- a/backend/src/middlewares/configuration.ts +++ b/backend/src/middlewares/configuration.ts @@ -1,33 +1,42 @@ import type { Response, NextFunction } from "express"; -import MonkeyError from "../utils/error"; -import { Configuration } from "@monkeytype/contracts/schemas/configuration"; import { TsRestRequestWithCtx } from "./auth"; +import { TsRestRequestHandler } from "@ts-rest/express"; +import { EndpointMetadata } from "@monkeytype/contracts/schemas/api"; +import MonkeyError from "../utils/error"; -export type ValidationOptions = { - criteria: (data: T) => boolean; - invalidMessage?: string; -}; - -/** - * This utility checks that the server's configuration matches - * the criteria. - */ -export function validate( - options: ValidationOptions -): MonkeyTypes.RequestHandler { - const { - criteria, - invalidMessage = "This service is currently unavailable.", - } = options; - - return (req: TsRestRequestWithCtx, _res: Response, next: NextFunction) => { - const configuration = req.ctx.configuration; +export function checkRequiredConfiguration< + T extends AppRouter | AppRoute +>(): TsRestRequestHandler { + return async ( + req: TsRestRequestWithCtx, + _res: Response, + next: NextFunction + ): Promise => { + const requiredConfig = (req.tsRestRoute["metadata"] as EndpointMetadata) + ?.requireConfiguration; + if (requiredConfig === undefined) { + next(); + return; + } + const keys = (requiredConfig.path as string).split("."); + let value = req.ctx.configuration; + for (const key of keys) { + value = value[key]; + } - const validated = criteria(configuration); - if (!validated) { - throw new MonkeyError(503, invalidMessage); + //@ts-expect-error + if (value !== true) { + next( + new MonkeyError( + 503, + requiredConfig.invalidMessage ?? + "This service is currently unavailable." + ) + ); + return; } next(); + return; }; } diff --git a/backend/src/middlewares/utility.ts b/backend/src/middlewares/utility.ts index 0ae2386e76d28..3af1a0fdd18a6 100644 --- a/backend/src/middlewares/utility.ts +++ b/backend/src/middlewares/utility.ts @@ -2,8 +2,9 @@ import _ from "lodash"; import type { Request, Response, NextFunction, RequestHandler } from "express"; import { handleMonkeyResponse, MonkeyResponse } from "../utils/monkey-response"; import { recordClientVersion as prometheusRecordClientVersion } from "../utils/prometheus"; -import { validate } from "./configuration"; import { isDevEnvironment } from "../utils/misc"; +import MonkeyError from "../utils/error"; +import { TsRestRequestWithCtx } from "./auth"; export const emptyMiddleware = ( _req: MonkeyTypes.Request, @@ -53,10 +54,16 @@ export function recordClientVersion(): RequestHandler { } export function onlyAvailableOnDev(): MonkeyTypes.RequestHandler { - return validate({ - criteria: () => { - return isDevEnvironment(); - }, - invalidMessage: "Development endpoints are only available in DEV mode.", - }); + return (_req: TsRestRequestWithCtx, _res: Response, next: NextFunction) => { + if (!isDevEnvironment()) { + next( + new MonkeyError( + 503, + "Development endpoints are only available in DEV mode." + ) + ); + } else { + next(); + } + }; } diff --git a/packages/contracts/src/ape-keys.ts b/packages/contracts/src/ape-keys.ts index 14a2c844b5df5..d75ea7fd70cbc 100644 --- a/packages/contracts/src/ape-keys.ts +++ b/packages/contracts/src/ape-keys.ts @@ -102,6 +102,10 @@ export const apeKeysContract = c.router( metadata: meta({ openApiTags: "ape-keys", requireRole: "canManageApeKeys", + requireConfiguration: { + path: "apeKeys.endpointsEnabled", + invalidMessage: "ApeKeys are currently disabled.", + }, }), commonResponses: CommonResponses, diff --git a/packages/contracts/src/leaderboards.ts b/packages/contracts/src/leaderboards.ts index d3a52f8e3f276..6c48460ccea67 100644 --- a/packages/contracts/src/leaderboards.ts +++ b/packages/contracts/src/leaderboards.ts @@ -127,6 +127,10 @@ export const leaderboardsContract = c.router( }, metadata: meta({ authenticationOptions: { isPublic: true }, + requireConfiguration: { + path: "dailyLeaderboards.enabled", + invalidMessage: "Daily leaderboards are not available at this time.", + }, }), }, getDailyRank: { @@ -138,6 +142,12 @@ export const leaderboardsContract = c.router( responses: { 200: GetLeaderboardDailyRankResponseSchema, }, + metadata: meta({ + requireConfiguration: { + path: "dailyLeaderboards.enabled", + invalidMessage: "Daily leaderboards are not available at this time.", + }, + }), }, getWeeklyXp: { summary: "get weekly xp leaderboard", @@ -150,6 +160,11 @@ export const leaderboardsContract = c.router( }, metadata: meta({ authenticationOptions: { isPublic: true }, + requireConfiguration: { + path: "leaderboards.weeklyXp.enabled", + invalidMessage: + "Weekly XP leaderboards are not available at this time.", + }, }), }, getWeeklyXpRank: { @@ -161,6 +176,13 @@ export const leaderboardsContract = c.router( responses: { 200: GetWeeklyXpLeaderboardRankResponseSchema, }, + metadata: meta({ + requireConfiguration: { + path: "leaderboards.weeklyXp.enabled", + invalidMessage: + "Weekly XP leaderboards are not available at this time.", + }, + }), }, }, { diff --git a/packages/contracts/src/quotes.ts b/packages/contracts/src/quotes.ts index 81ceb5cf7a12d..a0013305bec41 100644 --- a/packages/contracts/src/quotes.ts +++ b/packages/contracts/src/quotes.ts @@ -125,6 +125,11 @@ export const quotesContract = c.router( }, metadata: meta({ rateLimit: "newQuotesAdd", + requireConfiguration: { + path: "quotes.submissionsEnabled", + invalidMessage: + "Quote submission is disabled temporarily. The queue is quite long and we need some time to catch up.", + }, }), }, approveSubmission: { @@ -193,6 +198,10 @@ export const quotesContract = c.router( metadata: meta({ rateLimit: "quoteReportSubmit", requireRole: "canReport", + requireConfiguration: { + path: "quotes.reporting.enabled", + invalidMessage: "Quote reporting is unavailable.", + }, }), }, }, diff --git a/packages/contracts/src/results.ts b/packages/contracts/src/results.ts index cec3f836cd87f..d7c84a107b095 100644 --- a/packages/contracts/src/results.ts +++ b/packages/contracts/src/results.ts @@ -104,6 +104,10 @@ export const resultsContract = c.router( }, metadata: meta({ rateLimit: "resultsAdd", + requireConfiguration: { + path: "results.savingEnabled", + invalidMessage: "Results are not being saved at this time.", + }, }), }, updateTags: { diff --git a/packages/contracts/src/users.ts b/packages/contracts/src/users.ts index 11fc96a4cca65..21269e2defda1 100644 --- a/packages/contracts/src/users.ts +++ b/packages/contracts/src/users.ts @@ -349,6 +349,10 @@ export const usersContract = c.router( }, metadata: meta({ rateLimit: "userSignup", + requireConfiguration: { + path: "users.signUp", + invalidMessage: "Sign up is temporarily disabled", + }, }), }, getNameAvailability: { @@ -502,6 +506,11 @@ export const usersContract = c.router( }, metadata: meta({ rateLimit: "userCustomFilterAdd", + requireConfiguration: { + path: "results.filterPresets.enabled", + invalidMessage: + "Result filter presets are not available at this time.", + }, }), }, removeResultFilterPreset: { @@ -516,6 +525,11 @@ export const usersContract = c.router( }, metadata: meta({ rateLimit: "userCustomFilterRemove", + requireConfiguration: { + path: "results.filterPresets.enabled", + invalidMessage: + "Result filter presets are not available at this time.", + }, }), }, getTags: { @@ -646,6 +660,10 @@ export const usersContract = c.router( }, metadata: meta({ rateLimit: "userDiscordLink", + requireConfiguration: { + path: "users.discordIntegration.enabled", + invalidMessage: "Discord integration is not available at this time", + }, }), }, linkDiscord: { @@ -659,6 +677,10 @@ export const usersContract = c.router( }, metadata: meta({ rateLimit: "userDiscordLink", + requireConfiguration: { + path: "users.discordIntegration.enabled", + invalidMessage: "Discord integration is not available at this time", + }, }), }, unlinkDiscord: { @@ -752,6 +774,10 @@ export const usersContract = c.router( metadata: meta({ authenticationOptions: { isPublic: true }, rateLimit: "userProfileGet", + requireConfiguration: { + path: "users.profiles.enabled", + invalidMessage: "Profiles are not available at this time", + }, }), }, updateProfile: { @@ -765,6 +791,10 @@ export const usersContract = c.router( }, metadata: meta({ rateLimit: "userProfileUpdate", + requireConfiguration: { + path: "users.profiles.enabled", + invalidMessage: "Profiles are not available at this time", + }, }), }, getInbox: { @@ -777,6 +807,10 @@ export const usersContract = c.router( }, metadata: meta({ rateLimit: "userMailGet", + requireConfiguration: { + path: "users.inbox.enabled", + invalidMessage: "Your inbox is not available at this time.", + }, }), }, updateInbox: { @@ -790,6 +824,10 @@ export const usersContract = c.router( }, metadata: meta({ rateLimit: "userMailUpdate", + requireConfiguration: { + path: "users.inbox.enabled", + invalidMessage: "Your inbox is not available at this time.", + }, }), }, report: { @@ -804,6 +842,10 @@ export const usersContract = c.router( metadata: meta({ rateLimit: "quoteReportSubmit", requireRole: "canReport", + requireConfiguration: { + path: "quotes.reporting.enabled", + invalidMessage: "User reporting is unavailable.", + }, }), }, verificationEmail: {