mirror of
https://github.com/monkeytypegame/monkeytype.git
synced 2024-09-20 07:16:17 +08:00
parent
c6daef0e9d
commit
b315836dee
185
backend/__tests__/middlewares/configuration.spec.ts
Normal file
185
backend/__tests__/middlewares/configuration.spec.ts
Normal file
|
@ -0,0 +1,185 @@
|
|||
import { RequireConfiguration } from "@monkeytype/contracts/require-configuration/index";
|
||||
import { verifyRequiredConfiguration } from "../../src/middlewares/configuration";
|
||||
import { Configuration } from "@monkeytype/contracts/schemas/configuration";
|
||||
import { Response } from "express";
|
||||
import MonkeyError from "../../src/utils/error";
|
||||
|
||||
describe("configuration middleware", () => {
|
||||
const handler = verifyRequiredConfiguration();
|
||||
const res: Response = {} as any;
|
||||
const next = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
next.mockReset();
|
||||
});
|
||||
afterEach(() => {
|
||||
//next function must only be called once
|
||||
expect(next).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("should pass without requireConfiguration", async () => {
|
||||
//GIVEN
|
||||
const req = { tsRestRoute: { metadata: {} } } as any;
|
||||
|
||||
//WHEN
|
||||
await handler(req, res, next);
|
||||
|
||||
//THEN
|
||||
expect(next).toHaveBeenCalledWith();
|
||||
});
|
||||
it("should pass for enabled configuration", async () => {
|
||||
//GIVEN
|
||||
const req = givenRequest({ path: "maintenance" }, { maintenance: true });
|
||||
|
||||
//WHEN
|
||||
await handler(req, res, next);
|
||||
|
||||
//THEN
|
||||
expect(next).toHaveBeenCalledWith();
|
||||
});
|
||||
it("should pass for enabled configuration with complex path", async () => {
|
||||
//GIVEN
|
||||
const req = givenRequest(
|
||||
{ path: "users.xp.streak.enabled" },
|
||||
{ users: { xp: { streak: { enabled: true } as any } as any } as any }
|
||||
);
|
||||
|
||||
//WHEN
|
||||
await handler(req, res, next);
|
||||
|
||||
//THEN
|
||||
expect(next).toHaveBeenCalledWith();
|
||||
});
|
||||
it("should fail for disabled configuration", async () => {
|
||||
//GIVEN
|
||||
const req = givenRequest({ path: "maintenance" }, { maintenance: false });
|
||||
|
||||
//WHEN
|
||||
await handler(req, res, next);
|
||||
|
||||
//THEN
|
||||
expect(next).toHaveBeenCalledWith(
|
||||
new MonkeyError(503, "This endpoint is currently unavailable.")
|
||||
);
|
||||
});
|
||||
it("should fail for disabled configuration and custom message", async () => {
|
||||
//GIVEN
|
||||
const req = givenRequest(
|
||||
{ path: "maintenance", invalidMessage: "Feature not enabled." },
|
||||
{ maintenance: false }
|
||||
);
|
||||
|
||||
//WHEN
|
||||
await handler(req, res, next);
|
||||
|
||||
//THEN
|
||||
expect(next).toHaveBeenCalledWith(
|
||||
new MonkeyError(503, "Feature not enabled.")
|
||||
);
|
||||
});
|
||||
it("should fail for invalid path", async () => {
|
||||
//GIVEN
|
||||
const req = givenRequest({ path: "invalid.path" as any }, {});
|
||||
|
||||
//WHEN
|
||||
await handler(req, res, next);
|
||||
|
||||
//THEN
|
||||
expect(next).toHaveBeenCalledWith(
|
||||
new MonkeyError(503, 'Invalid configuration path: "invalid.path"')
|
||||
);
|
||||
});
|
||||
it("should fail for undefined value", async () => {
|
||||
//GIVEN
|
||||
const req = givenRequest(
|
||||
{ path: "admin.endpointsEnabled" },
|
||||
{ admin: {} as any }
|
||||
);
|
||||
|
||||
//WHEN
|
||||
await handler(req, res, next);
|
||||
|
||||
//THEN
|
||||
expect(next).toHaveBeenCalledWith(
|
||||
new MonkeyError(
|
||||
500,
|
||||
'Required configuration doesnt exist: "admin.endpointsEnabled"'
|
||||
)
|
||||
);
|
||||
});
|
||||
it("should fail for null value", async () => {
|
||||
//GIVEN
|
||||
const req = givenRequest(
|
||||
{ path: "admin.endpointsEnabled" },
|
||||
{ admin: { endpointsEnabled: null as any } }
|
||||
);
|
||||
|
||||
//WHEN
|
||||
await handler(req, res, next);
|
||||
|
||||
//THEN
|
||||
expect(next).toHaveBeenCalledWith(
|
||||
new MonkeyError(
|
||||
500,
|
||||
'Required configuration doesnt exist: "admin.endpointsEnabled"'
|
||||
)
|
||||
);
|
||||
});
|
||||
it("should fail for non booean value", async () => {
|
||||
//GIVEN
|
||||
const req = givenRequest(
|
||||
{ path: "admin.endpointsEnabled" },
|
||||
{ admin: { endpointsEnabled: "disabled" as any } }
|
||||
);
|
||||
|
||||
//WHEN
|
||||
await handler(req, res, next);
|
||||
|
||||
//THEN
|
||||
expect(next).toHaveBeenCalledWith(
|
||||
new MonkeyError(
|
||||
500,
|
||||
'Required configuration is not a boolean: "admin.endpointsEnabled"'
|
||||
)
|
||||
);
|
||||
});
|
||||
it("should pass for multiple configurations", async () => {
|
||||
//GIVEN
|
||||
const req = givenRequest(
|
||||
[{ path: "maintenance" }, { path: "admin.endpointsEnabled" }],
|
||||
{ maintenance: true, admin: { endpointsEnabled: true } }
|
||||
);
|
||||
|
||||
//WHEN
|
||||
await handler(req, res, next);
|
||||
|
||||
//THEN
|
||||
expect(next).toHaveBeenCalledWith();
|
||||
});
|
||||
it("should fail for multiple configurations", async () => {
|
||||
//GIVEN
|
||||
const req = givenRequest(
|
||||
[
|
||||
{ path: "maintenance", invalidMessage: "maintenance mode" },
|
||||
{ path: "admin.endpointsEnabled", invalidMessage: "admin disabled" },
|
||||
],
|
||||
{ maintenance: true, admin: { endpointsEnabled: false } }
|
||||
);
|
||||
|
||||
//WHEN
|
||||
await handler(req, res, next);
|
||||
|
||||
//THEN
|
||||
expect(next).toHaveBeenCalledWith(new MonkeyError(503, "admin disabled"));
|
||||
});
|
||||
});
|
||||
|
||||
function givenRequest(
|
||||
requireConfiguration: RequireConfiguration | RequireConfiguration[],
|
||||
configuration: Partial<Configuration>
|
||||
): TsRestRequest {
|
||||
return {
|
||||
tsRestRoute: { metadata: { requireConfiguration } },
|
||||
ctx: { configuration },
|
||||
} as any;
|
||||
}
|
|
@ -3,7 +3,7 @@ import { contract } from "@monkeytype/contracts/index";
|
|||
import { writeFileSync, mkdirSync } from "fs";
|
||||
import {
|
||||
EndpointMetadata,
|
||||
Permission,
|
||||
PermissionId,
|
||||
} from "@monkeytype/contracts/schemas/api";
|
||||
import type { OpenAPIObject, OperationObject } from "openapi3-ts";
|
||||
import {
|
||||
|
@ -142,15 +142,15 @@ export function getOpenApi(): OpenAPIObject {
|
|||
setOperationId: "concatenated-path",
|
||||
operationMapper: (operation, route) => {
|
||||
const metadata = route.metadata as EndpointMetadata;
|
||||
|
||||
if (!operation.description?.trim()?.endsWith("."))
|
||||
if (!operation.description?.trim()?.endsWith(".")) {
|
||||
operation.description += ".";
|
||||
}
|
||||
operation.description += "\n\n";
|
||||
|
||||
addAuth(operation, metadata);
|
||||
addRateLimit(operation, metadata);
|
||||
addRequiredConfiguration(operation, metadata);
|
||||
addTags(operation, metadata);
|
||||
|
||||
return operation;
|
||||
},
|
||||
}
|
||||
|
@ -262,6 +262,16 @@ function formatWindow(window: Window): string {
|
|||
return "per " + window;
|
||||
}
|
||||
|
||||
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);
|
||||
|
|
|
@ -1,41 +1,25 @@
|
|||
// import joi from "joi";
|
||||
|
||||
import * as AdminController from "../controllers/admin";
|
||||
|
||||
import { adminContract } from "@monkeytype/contracts/admin";
|
||||
import { initServer } from "@ts-rest/express";
|
||||
import { validate } from "../../middlewares/configuration";
|
||||
import { callController } from "../ts-rest-adapter";
|
||||
|
||||
const commonMiddleware = [
|
||||
validate({
|
||||
criteria: (configuration) => {
|
||||
return configuration.admin.endpointsEnabled;
|
||||
},
|
||||
invalidMessage: "Admin endpoints are currently disabled.",
|
||||
}),
|
||||
];
|
||||
|
||||
const s = initServer();
|
||||
export default s.router(adminContract, {
|
||||
test: {
|
||||
middleware: commonMiddleware,
|
||||
handler: async (r) => callController(AdminController.test)(r),
|
||||
},
|
||||
toggleBan: {
|
||||
middleware: commonMiddleware,
|
||||
handler: async (r) => callController(AdminController.toggleBan)(r),
|
||||
},
|
||||
acceptReports: {
|
||||
middleware: commonMiddleware,
|
||||
handler: async (r) => callController(AdminController.acceptReports)(r),
|
||||
},
|
||||
rejectReports: {
|
||||
middleware: commonMiddleware,
|
||||
handler: async (r) => callController(AdminController.rejectReports)(r),
|
||||
},
|
||||
sendForgotPasswordEmail: {
|
||||
middleware: commonMiddleware,
|
||||
handler: async (r) =>
|
||||
callController(AdminController.sendForgotPasswordEmail)(r),
|
||||
},
|
||||
|
|
|
@ -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),
|
||||
},
|
||||
});
|
||||
|
|
|
@ -36,6 +36,7 @@ import { MonkeyValidationError } from "@monkeytype/contracts/schemas/api";
|
|||
import { authenticateTsRestRequest } from "../../middlewares/auth";
|
||||
import { rateLimitRequest } from "../../middlewares/rate-limit";
|
||||
import { verifyPermissions } from "../../middlewares/permission";
|
||||
import { verifyRequiredConfiguration } from "../../middlewares/configuration";
|
||||
|
||||
const pathOverride = process.env["API_PATH_OVERRIDE"];
|
||||
const BASE_ROUTE = pathOverride !== undefined ? `/${pathOverride}` : "";
|
||||
|
@ -116,6 +117,7 @@ function applyTsRestApiRoutes(app: IRouter): void {
|
|||
globalMiddleware: [
|
||||
authenticateTsRestRequest(),
|
||||
rateLimitRequest(),
|
||||
verifyRequiredConfiguration(),
|
||||
verifyPermissions(),
|
||||
],
|
||||
});
|
||||
|
|
|
@ -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),
|
||||
},
|
||||
|
|
|
@ -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),
|
||||
},
|
||||
});
|
||||
|
|
|
@ -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: {
|
||||
|
|
|
@ -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: {
|
||||
|
|
|
@ -1,33 +1,86 @@
|
|||
import type { Response, NextFunction } from "express";
|
||||
import { TsRestRequestWithCtx } from "./auth";
|
||||
import { TsRestRequestHandler } from "@ts-rest/express";
|
||||
import { EndpointMetadata } from "@monkeytype/contracts/schemas/api";
|
||||
import MonkeyError from "../utils/error";
|
||||
import { Configuration } from "@monkeytype/contracts/schemas/configuration";
|
||||
import { TsRestRequestWithCtx } from "./auth";
|
||||
import {
|
||||
ConfigurationPath,
|
||||
RequireConfiguration,
|
||||
} from "@monkeytype/contracts/require-configuration/index";
|
||||
|
||||
export type ValidationOptions<T> = {
|
||||
criteria: (data: T) => boolean;
|
||||
invalidMessage?: string;
|
||||
};
|
||||
export function verifyRequiredConfiguration<
|
||||
T extends AppRouter | AppRoute
|
||||
>(): TsRestRequestHandler<T> {
|
||||
return async (
|
||||
req: TsRestRequestWithCtx,
|
||||
_res: Response,
|
||||
next: NextFunction
|
||||
): Promise<void> => {
|
||||
const requiredConfigurations = getRequireConfigurations(
|
||||
req.tsRestRoute["metadata"]
|
||||
);
|
||||
|
||||
/**
|
||||
* This utility checks that the server's configuration matches
|
||||
* the criteria.
|
||||
*/
|
||||
export function validate(
|
||||
options: ValidationOptions<Configuration>
|
||||
): MonkeyTypes.RequestHandler {
|
||||
const {
|
||||
criteria,
|
||||
invalidMessage = "This service is currently unavailable.",
|
||||
} = options;
|
||||
|
||||
return (req: TsRestRequestWithCtx, _res: Response, next: NextFunction) => {
|
||||
const configuration = req.ctx.configuration;
|
||||
|
||||
const validated = criteria(configuration);
|
||||
if (!validated) {
|
||||
throw new MonkeyError(503, invalidMessage);
|
||||
if (requiredConfigurations === undefined) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
for (const requireConfiguration of requiredConfigurations) {
|
||||
const value = getValue(
|
||||
req.ctx.configuration,
|
||||
requireConfiguration.path
|
||||
);
|
||||
if (!value) {
|
||||
throw new MonkeyError(
|
||||
503,
|
||||
requireConfiguration.invalidMessage ??
|
||||
"This endpoint is currently unavailable."
|
||||
);
|
||||
}
|
||||
}
|
||||
next();
|
||||
return;
|
||||
} catch (e) {
|
||||
next(e);
|
||||
return;
|
||||
}
|
||||
|
||||
next();
|
||||
};
|
||||
}
|
||||
|
||||
function getValue(
|
||||
configuration: Configuration,
|
||||
path: ConfigurationPath
|
||||
): boolean {
|
||||
const keys = (path as string).split(".");
|
||||
let result = configuration;
|
||||
|
||||
for (const key of keys) {
|
||||
if (result === undefined || result === null)
|
||||
throw new MonkeyError(500, `Invalid configuration path: "${path}"`);
|
||||
result = result[key];
|
||||
}
|
||||
|
||||
if (result === undefined || result === null)
|
||||
throw new MonkeyError(
|
||||
500,
|
||||
`Required configuration doesnt exist: "${path}"`
|
||||
);
|
||||
if (typeof result !== "boolean")
|
||||
throw new MonkeyError(
|
||||
500,
|
||||
`Required configuration is not a boolean: "${path}"`
|
||||
);
|
||||
return result;
|
||||
}
|
||||
|
||||
function getRequireConfigurations(
|
||||
metadata: EndpointMetadata | undefined
|
||||
): RequireConfiguration[] | undefined {
|
||||
if (metadata === undefined || metadata.requireConfiguration === undefined)
|
||||
return undefined;
|
||||
|
||||
if (Array.isArray(metadata.requireConfiguration))
|
||||
return metadata.requireConfiguration;
|
||||
return [metadata.requireConfiguration];
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
|
@ -112,6 +112,10 @@ export const adminContract = c.router(
|
|||
authenticationOptions: { noCache: true },
|
||||
rateLimit: "adminLimit",
|
||||
requirePermission: "admin",
|
||||
requireConfiguration: {
|
||||
path: "admin.endpointsEnabled",
|
||||
invalidMessage: "Admin endpoints are currently disabled.",
|
||||
},
|
||||
}),
|
||||
|
||||
commonResponses: CommonResponses,
|
||||
|
|
|
@ -102,6 +102,10 @@ export const apeKeysContract = c.router(
|
|||
metadata: meta({
|
||||
openApiTags: "ape-keys",
|
||||
requirePermission: "canManageApeKeys",
|
||||
requireConfiguration: {
|
||||
path: "apeKeys.endpointsEnabled",
|
||||
invalidMessage: "ApeKeys are currently disabled.",
|
||||
},
|
||||
}),
|
||||
|
||||
commonResponses: CommonResponses,
|
||||
|
|
|
@ -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.",
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
{
|
||||
|
|
|
@ -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",
|
||||
requirePermission: "canReport",
|
||||
requireConfiguration: {
|
||||
path: "quotes.reporting.enabled",
|
||||
invalidMessage: "Quote reporting is unavailable.",
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
|
|
27
packages/contracts/src/require-configuration/index.ts
Normal file
27
packages/contracts/src/require-configuration/index.ts
Normal file
|
@ -0,0 +1,27 @@
|
|||
import { Configuration } from "../schemas/configuration";
|
||||
|
||||
type BooleanPaths<T, P extends string = ""> = {
|
||||
[K in keyof T]: T[K] extends boolean
|
||||
? P extends ""
|
||||
? K
|
||||
: `${P}.${Extract<K, string | number>}`
|
||||
: T[K] extends object
|
||||
? `${P}.${Extract<K, string | number>}` extends infer D
|
||||
? D extends string
|
||||
? BooleanPaths<T[K], D>
|
||||
: never
|
||||
: never
|
||||
: never;
|
||||
}[keyof T];
|
||||
|
||||
// Helper type to remove leading dot
|
||||
type RemoveLeadingDot<T> = T extends `.${infer U}` ? U : T;
|
||||
|
||||
export type ConfigurationPath = RemoveLeadingDot<BooleanPaths<Configuration>>;
|
||||
|
||||
export type RequireConfiguration = {
|
||||
/** path to the configuration, needs to be a boolean value */
|
||||
path: ConfigurationPath;
|
||||
/** message of the ErrorResponse in case the value is `false` */
|
||||
invalidMessage?: string;
|
||||
};
|
|
@ -103,6 +103,10 @@ export const resultsContract = c.router(
|
|||
},
|
||||
metadata: meta({
|
||||
rateLimit: "resultsAdd",
|
||||
requireConfiguration: {
|
||||
path: "results.savingEnabled",
|
||||
invalidMessage: "Results are not being saved at this time.",
|
||||
},
|
||||
}),
|
||||
},
|
||||
updateTags: {
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import { z, ZodSchema } from "zod";
|
||||
import { RateLimitIds, RateLimiterId } from "../rate-limit";
|
||||
import { RequireConfiguration } from "../require-configuration";
|
||||
|
||||
export type OpenApiTag =
|
||||
| "configs"
|
||||
|
@ -34,6 +35,9 @@ export type EndpointMetadata = {
|
|||
|
||||
/** Role/Rples needed to access the endpoint*/
|
||||
requirePermission?: PermissionId | PermissionId[];
|
||||
|
||||
/** Endpoint is only available if configuration allows it */
|
||||
requireConfiguration?: RequireConfiguration | RequireConfiguration[];
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
@ -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",
|
||||
requirePermission: "canReport",
|
||||
requireConfiguration: {
|
||||
path: "quotes.reporting.enabled",
|
||||
invalidMessage: "User reporting is unavailable.",
|
||||
},
|
||||
}),
|
||||
},
|
||||
verificationEmail: {
|
||||
|
|
Loading…
Reference in a new issue