From e2d574444a84d58a6cf570d10ac2a292d0ef9fa8 Mon Sep 17 00:00:00 2001 From: Christian Fehmer Date: Fri, 23 Aug 2024 19:06:41 +0200 Subject: [PATCH] impr: use tsrest for configurations endpoint (@fehmer) (#5796) !nuf --- backend/__tests__/__testData__/auth.ts | 2 +- .../api/controllers/configuration.spec.ts | 194 ++++++++++++++++++ backend/__tests__/middlewares/auth.spec.ts | 139 +++++++++++-- backend/scripts/openapi.ts | 10 +- backend/src/api/controllers/admin.ts | 2 +- backend/src/api/controllers/configuration.ts | 28 ++- backend/src/api/controllers/leaderboard.ts | 2 +- backend/src/api/controllers/result.ts | 2 +- backend/src/api/routes/configuration.ts | 60 ++---- backend/src/api/routes/index.ts | 10 +- backend/src/constants/base-configuration.ts | 2 +- backend/src/dal/user.ts | 2 +- backend/src/init/configuration.ts | 7 +- backend/src/middlewares/auth.ts | 23 ++- backend/src/middlewares/configuration.ts | 2 +- backend/src/middlewares/permission.ts | 18 +- backend/src/middlewares/utility.ts | 12 -- backend/src/queues/later-queue.ts | 2 +- backend/src/services/weekly-xp-leaderboard.ts | 2 +- backend/src/types/types.d.ts | 2 +- backend/src/utils/daily-leaderboards.ts | 12 +- .../src/ts/ape/endpoints/configuration.ts | 11 - frontend/src/ts/ape/endpoints/index.ts | 2 - frontend/src/ts/ape/index.ts | 1 - frontend/src/ts/ape/server-configuration.ts | 6 +- packages/contracts/src/configuration.ts | 96 +++++++++ packages/contracts/src/index.ts | 2 + packages/contracts/src/schemas/api.ts | 5 +- .../contracts/src/schemas/configuration.ts | 121 +++++++++++ packages/shared-types/src/index.ts | 111 ---------- 30 files changed, 654 insertions(+), 234 deletions(-) create mode 100644 backend/__tests__/api/controllers/configuration.spec.ts delete mode 100644 frontend/src/ts/ape/endpoints/configuration.ts create mode 100644 packages/contracts/src/configuration.ts create mode 100644 packages/contracts/src/schemas/configuration.ts diff --git a/backend/__tests__/__testData__/auth.ts b/backend/__tests__/__testData__/auth.ts index 3a6c1f604..a1c0f94de 100644 --- a/backend/__tests__/__testData__/auth.ts +++ b/backend/__tests__/__testData__/auth.ts @@ -1,4 +1,4 @@ -import { Configuration } from "@monkeytype/shared-types"; +import { Configuration } from "@monkeytype/contracts/schemas/configuration"; import { randomBytes } from "crypto"; import { hash } from "bcrypt"; import { ObjectId } from "mongodb"; diff --git a/backend/__tests__/api/controllers/configuration.spec.ts b/backend/__tests__/api/controllers/configuration.spec.ts new file mode 100644 index 000000000..e5cc0fb81 --- /dev/null +++ b/backend/__tests__/api/controllers/configuration.spec.ts @@ -0,0 +1,194 @@ +import request from "supertest"; +import app from "../../../src/app"; +import { + BASE_CONFIGURATION, + CONFIGURATION_FORM_SCHEMA, +} from "../../../src/constants/base-configuration"; +import * as Configuration from "../../../src/init/configuration"; +import type { Configuration as ConfigurationType } from "@monkeytype/contracts/schemas/configuration"; +import { ObjectId } from "mongodb"; +import * as Misc from "../../../src/utils/misc"; +import { DecodedIdToken } from "firebase-admin/auth"; +import * as AuthUtils from "../../../src/utils/auth"; +import * as AdminUuids from "../../../src/dal/admin-uids"; + +const mockApp = request(app); +const uid = new ObjectId().toHexString(); +const mockDecodedToken = { + uid, + email: "newuser@mail.com", + iat: 0, +} as DecodedIdToken; + +describe("Configuration Controller", () => { + const isDevEnvironmentMock = vi.spyOn(Misc, "isDevEnvironment"); + const verifyIdTokenMock = vi.spyOn(AuthUtils, "verifyIdToken"); + const isAdminMock = vi.spyOn(AdminUuids, "isAdmin"); + + beforeEach(() => { + isAdminMock.mockReset(); + verifyIdTokenMock.mockReset(); + isDevEnvironmentMock.mockReset(); + + isDevEnvironmentMock.mockReturnValue(true); + isAdminMock.mockResolvedValue(true); + }); + + describe("getConfiguration", () => { + it("should get without authentication", async () => { + //GIVEN + + //WHEN + const { body } = await mockApp.get("/configuration").expect(200); + + //THEN + expect(body).toEqual({ + message: "Configuration retrieved", + data: BASE_CONFIGURATION, + }); + }); + }); + + describe("getConfigurationSchema", () => { + it("should get without authentication on dev", async () => { + //GIVEN + + //WHEN + const { body } = await mockApp.get("/configuration/schema").expect(200); + + //THEN + expect(body).toEqual({ + message: "Configuration schema retrieved", + data: CONFIGURATION_FORM_SCHEMA, + }); + }); + + it("should fail without authentication on prod", async () => { + //GIVEN + isDevEnvironmentMock.mockReturnValue(false); + + //WHEN + await mockApp.get("/configuration/schema").expect(401); + }); + it("should get with authentication on prod", async () => { + //GIVEN + isDevEnvironmentMock.mockReturnValue(false); + verifyIdTokenMock.mockResolvedValue(mockDecodedToken); + + //WHEN + const { body } = await mockApp + .get("/configuration/schema") + .set("Authorization", "Bearer 123456789") + .expect(200); + + //THEN + expect(body).toEqual({ + message: "Configuration schema retrieved", + data: CONFIGURATION_FORM_SCHEMA, + }); + + expect(verifyIdTokenMock).toHaveBeenCalled(); + }); + it("should fail with non-admin user on prod", async () => { + //GIVEN + isDevEnvironmentMock.mockReturnValue(false); + verifyIdTokenMock.mockResolvedValue(mockDecodedToken); + isAdminMock.mockResolvedValue(false); + + //WHEN + const { body } = await mockApp + .get("/configuration/schema") + .set("Authorization", "Bearer 123456789") + .expect(403); + + //THEN + expect(body.message).toEqual("You don't have permission to do this."); + expect(verifyIdTokenMock).toHaveBeenCalled(); + expect(isAdminMock).toHaveBeenCalledWith(uid); + }); + }); + + describe("updateConfiguration", () => { + const patchConfigurationMock = vi.spyOn( + Configuration, + "patchConfiguration" + ); + beforeEach(() => { + patchConfigurationMock.mockReset(); + patchConfigurationMock.mockResolvedValue(true); + }); + + it("should update without authentication on dev", async () => { + //GIVEN + const patch = { + users: { + premium: { + enabled: true, + }, + }, + } as Partial; + + //WHEN + const { body } = await mockApp + .patch("/configuration") + .send({ configuration: patch }) + .expect(200); + + //THEN + expect(body).toEqual({ + message: "Configuration updated", + data: null, + }); + + expect(patchConfigurationMock).toHaveBeenCalledWith(patch); + }); + + it("should fail update without authentication on prod", async () => { + //GIVEN + isDevEnvironmentMock.mockReturnValue(false); + + //WHEN + await request(app) + .patch("/configuration") + .send({ configuration: {} }) + .expect(401); + + //THEN + expect(patchConfigurationMock).not.toHaveBeenCalled(); + }); + it("should update with authentication on prod", async () => { + //GIVEN + isDevEnvironmentMock.mockReturnValue(false); + verifyIdTokenMock.mockResolvedValue(mockDecodedToken); + + //WHEN + await mockApp + .patch("/configuration") + .set("Authorization", "Bearer 123456789") + .send({ configuration: {} }) + .expect(200); + + //THEN + expect(patchConfigurationMock).toHaveBeenCalled(); + expect(verifyIdTokenMock).toHaveBeenCalled(); + }); + + it("should fail for non admin users on prod", async () => { + //GIVEN + isDevEnvironmentMock.mockReturnValue(false); + isAdminMock.mockResolvedValue(false); + verifyIdTokenMock.mockResolvedValue(mockDecodedToken); + + //WHEN + await mockApp + .patch("/configuration") + .set("Authorization", "Bearer 123456789") + .send({ configuration: {} }) + .expect(403); + + //THEN + expect(patchConfigurationMock).not.toHaveBeenCalled(); + expect(isAdminMock).toHaveBeenCalledWith(uid); + }); + }); +}); diff --git a/backend/__tests__/middlewares/auth.spec.ts b/backend/__tests__/middlewares/auth.spec.ts index 5cc1a5b52..49af130f4 100644 --- a/backend/__tests__/middlewares/auth.spec.ts +++ b/backend/__tests__/middlewares/auth.spec.ts @@ -86,22 +86,15 @@ describe("middlewares/auth", () => { requireFreshToken: true, }); - let result; - - try { - result = await authenticateRequest( + expect(() => + authenticateRequest( mockRequest as Request, mockResponse as Response, nextFunction - ); - } catch (e) { - result = e; - } - - expect(result.message).toBe( + ) + ).rejects.toThrowError( "Unauthorized\nStack: This endpoint requires a fresh token" ); - expect(nextFunction).toHaveBeenCalledTimes(1); }); it("should allow the request if token is fresh", async () => { Date.now = vi.fn(() => 10000); @@ -321,7 +314,7 @@ describe("middlewares/auth", () => { expect(decodedToken?.uid).toBe("123"); expect(nextFunction).toHaveBeenCalledTimes(1); }); - it("should fail wit apeKey if apeKey is not supported", async () => { + it("should fail with apeKey if apeKey is not supported", async () => { //WHEN await expect(() => authenticate( @@ -332,6 +325,22 @@ describe("middlewares/auth", () => { //THEN }); + it("should fail with apeKey if apeKeys are disabled", async () => { + //GIVEN + + //@ts-expect-error + mockRequest.ctx.configuration.apeKeys.acceptKeys = false; + + //WHEN + await expect(() => + authenticate( + { headers: { authorization: "ApeKey aWQua2V5" } }, + { acceptApeKeys: false } + ) + ).rejects.toThrowError("ApeKeys are not being accepted at this time"); + + //THEN + }); it("should allow the request with authentation on public endpoint", async () => { //WHEN const result = await authenticate({}, { isPublic: true }); @@ -489,6 +498,112 @@ describe("middlewares/auth", () => { expect.anything() ); }); + it("should allow the request with authentation on dev public endpoint", async () => { + //WHEN + const result = await authenticate({}, { isPublicOnDev: true }); + + //THEN + const decodedToken = result.decodedToken; + expect(decodedToken?.type).toBe("Bearer"); + expect(decodedToken?.email).toBe(mockDecodedToken.email); + expect(decodedToken?.uid).toBe(mockDecodedToken.uid); + expect(nextFunction).toHaveBeenCalledTimes(1); + }); + it("should allow the request without authentication on dev public endpoint", async () => { + //WHEN + const result = await authenticate( + { headers: {} }, + { isPublicOnDev: true } + ); + + //THEN + const decodedToken = result.decodedToken; + expect(decodedToken?.type).toBe("None"); + expect(decodedToken?.email).toBe(""); + expect(decodedToken?.uid).toBe(""); + expect(nextFunction).toHaveBeenCalledTimes(1); + + expect(prometheusIncrementAuthMock).toHaveBeenCalledWith("None"); + expect(prometheusRecordAuthTimeMock).toHaveBeenCalledOnce(); + }); + it("should allow the request with apeKey on dev public endpoint", async () => { + //WHEN + const result = await authenticate( + { headers: { authorization: "ApeKey aWQua2V5" } }, + { acceptApeKeys: true, isPublicOnDev: true } + ); + + //THEN + const decodedToken = result.decodedToken; + expect(decodedToken?.type).toBe("ApeKey"); + expect(decodedToken?.email).toBe(""); + expect(decodedToken?.uid).toBe("123"); + expect(nextFunction).toHaveBeenCalledTimes(1); + + expect(prometheusIncrementAuthMock).toHaveBeenCalledWith("ApeKey"); + expect(prometheusRecordAuthTimeMock).toHaveBeenCalledOnce(); + }); + it("should allow with apeKey if apeKeys are disabled on dev public endpoint", async () => { + //GIVEN + + //@ts-expect-error + mockRequest.ctx.configuration.apeKeys.acceptKeys = false; + + //WHEN + const result = await authenticate( + { headers: { authorization: "ApeKey aWQua2V5" } }, + { acceptApeKeys: true, isPublicOnDev: true } + ); + + //THEN + const decodedToken = result.decodedToken; + expect(decodedToken?.type).toBe("ApeKey"); + expect(decodedToken?.email).toBe(""); + expect(decodedToken?.uid).toBe("123"); + expect(nextFunction).toHaveBeenCalledTimes(1); + + expect(prometheusIncrementAuthMock).toHaveBeenCalledWith("ApeKey"); + expect(prometheusRecordAuthTimeMock).toHaveBeenCalledOnce(); + }); + it("should allow the request with authentation on dev public endpoint in production", async () => { + //WHEN + isDevModeMock.mockReturnValue(false); + const result = await authenticate({}, { isPublicOnDev: true }); + + //THEN + const decodedToken = result.decodedToken; + expect(decodedToken?.type).toBe("Bearer"); + expect(decodedToken?.email).toBe(mockDecodedToken.email); + expect(decodedToken?.uid).toBe(mockDecodedToken.uid); + expect(nextFunction).toHaveBeenCalledTimes(1); + }); + it("should fail without authentication on dev public endpoint in production", async () => { + //WHEN + isDevModeMock.mockReturnValue(false); + + //THEN + await expect(() => + authenticate({ headers: {} }, { isPublicOnDev: true }) + ).rejects.toThrowError("Unauthorized"); + }); + it("should allow with apeKey on dev public endpoint in production", async () => { + //WHEN + isDevModeMock.mockReturnValue(false); + const result = await authenticate( + { headers: { authorization: "ApeKey aWQua2V5" } }, + { acceptApeKeys: true, isPublicOnDev: true } + ); + + //THEN + const decodedToken = result.decodedToken; + expect(decodedToken?.type).toBe("ApeKey"); + expect(decodedToken?.email).toBe(""); + expect(decodedToken?.uid).toBe("123"); + expect(nextFunction).toHaveBeenCalledTimes(1); + + expect(prometheusIncrementAuthMock).toHaveBeenCalledWith("ApeKey"); + expect(prometheusRecordAuthTimeMock).toHaveBeenCalledOnce(); + }); }); }); diff --git a/backend/scripts/openapi.ts b/backend/scripts/openapi.ts index 0eaa6cc8c..d3a9354d1 100644 --- a/backend/scripts/openapi.ts +++ b/backend/scripts/openapi.ts @@ -53,8 +53,8 @@ export function getOpenApi(): OpenAPIObject { { name: "configs", description: - "User specific configurations like test settings, theme or tags.", - "x-displayName": "User configuration", + "User specific configs like test settings, theme or tags.", + "x-displayName": "User configs", "x-public": "no", }, { @@ -99,6 +99,12 @@ export function getOpenApi(): OpenAPIObject { "x-displayName": "Admin", "x-public": "no", }, + { + name: "configuration", + description: "Server configuration", + "x-displayName": "Server configuration", + "x-public": "yes", + }, ], }, diff --git a/backend/src/api/controllers/admin.ts b/backend/src/api/controllers/admin.ts index 39c37cb14..8779efe9c 100644 --- a/backend/src/api/controllers/admin.ts +++ b/backend/src/api/controllers/admin.ts @@ -12,7 +12,7 @@ import { ToggleBanResponse, } from "@monkeytype/contracts/admin"; import MonkeyError from "../../utils/error"; -import { Configuration } from "@monkeytype/shared-types"; +import { Configuration } from "@monkeytype/contracts/schemas/configuration"; import { addImportantLog } from "../../dal/logs"; export async function test( diff --git a/backend/src/api/controllers/configuration.ts b/backend/src/api/controllers/configuration.ts index df9900707..6a76ab210 100644 --- a/backend/src/api/controllers/configuration.ts +++ b/backend/src/api/controllers/configuration.ts @@ -1,32 +1,38 @@ import * as Configuration from "../../init/configuration"; -import { MonkeyResponse } from "../../utils/monkey-response"; +import { MonkeyResponse2 } from "../../utils/monkey-response"; import { CONFIGURATION_FORM_SCHEMA } from "../../constants/base-configuration"; +import { + ConfigurationSchemaResponse, + GetConfigurationResponse, + PatchConfigurationRequest, +} from "@monkeytype/contracts/configuration"; +import MonkeyError from "../../utils/error"; export async function getConfiguration( - _req: MonkeyTypes.Request -): Promise { + _req: MonkeyTypes.Request2 +): Promise { const currentConfiguration = await Configuration.getLiveConfiguration(); - return new MonkeyResponse("Configuration retrieved", currentConfiguration); + return new MonkeyResponse2("Configuration retrieved", currentConfiguration); } export async function getSchema( - _req: MonkeyTypes.Request -): Promise { - return new MonkeyResponse( + _req: MonkeyTypes.Request2 +): Promise { + return new MonkeyResponse2( "Configuration schema retrieved", CONFIGURATION_FORM_SCHEMA ); } export async function updateConfiguration( - req: MonkeyTypes.Request -): Promise { + req: MonkeyTypes.Request2 +): Promise { const { configuration } = req.body; const success = await Configuration.patchConfiguration(configuration); if (!success) { - return new MonkeyResponse("Configuration update failed", {}, 500); + throw new MonkeyError(500, "Configuration update failed"); } - return new MonkeyResponse("Configuration updated"); + return new MonkeyResponse2("Configuration updated", null); } diff --git a/backend/src/api/controllers/leaderboard.ts b/backend/src/api/controllers/leaderboard.ts index 17aa0a810..b06439250 100644 --- a/backend/src/api/controllers/leaderboard.ts +++ b/backend/src/api/controllers/leaderboard.ts @@ -21,7 +21,7 @@ import { GetWeeklyXpLeaderboardResponse, LanguageAndModeQuery, } from "@monkeytype/contracts/leaderboards"; -import { Configuration } from "@monkeytype/shared-types"; +import { Configuration } from "@monkeytype/contracts/schemas/configuration"; export async function getLeaderboard( req: MonkeyTypes.Request2 diff --git a/backend/src/api/controllers/result.ts b/backend/src/api/controllers/result.ts index 4c3970f8a..f4cc3ec08 100644 --- a/backend/src/api/controllers/result.ts +++ b/backend/src/api/controllers/result.ts @@ -36,7 +36,7 @@ import * as WeeklyXpLeaderboard from "../../services/weekly-xp-leaderboard"; import { UAParser } from "ua-parser-js"; import { canFunboxGetPb } from "../../utils/pb"; import { buildDbResult, replaceLegacyValues } from "../../utils/result"; -import { Configuration } from "@monkeytype/shared-types"; +import { Configuration } from "@monkeytype/contracts/schemas/configuration"; import { addLog } from "../../dal/logs"; import { AddResultRequest, diff --git a/backend/src/api/routes/configuration.ts b/backend/src/api/routes/configuration.ts index 54cc3a963..f7b7b160d 100644 --- a/backend/src/api/routes/configuration.ts +++ b/backend/src/api/routes/configuration.ts @@ -1,43 +1,25 @@ -import joi from "joi"; -import { Router } from "express"; -import * as ConfigurationController from "../controllers/configuration"; -import { authenticateRequest } from "../../middlewares/auth"; -import { adminLimit } from "../../middlewares/rate-limit"; -import { asyncHandler, useInProduction } from "../../middlewares/utility"; +import { configurationContract } from "@monkeytype/contracts/configuration"; +import { initServer } from "@ts-rest/express"; import { checkIfUserIsAdmin } from "../../middlewares/permission"; -import { validateRequest } from "../../middlewares/validation"; +import * as RateLimit from "../../middlewares/rate-limit"; +import * as ConfigurationController from "../controllers/configuration"; +import { callController } from "../ts-rest-adapter"; -const router = Router(); +const s = initServer(); -router.get("/", asyncHandler(ConfigurationController.getConfiguration)); +export default s.router(configurationContract, { + get: { + handler: async (r) => + callController(ConfigurationController.getConfiguration)(r), + }, -router.patch( - "/", - adminLimit, - useInProduction([ - authenticateRequest({ - noCache: true, - }), - checkIfUserIsAdmin(), - ]), - validateRequest({ - body: { - configuration: joi.object(), - }, - }), - asyncHandler(ConfigurationController.updateConfiguration) -); - -router.get( - "/schema", - adminLimit, - useInProduction([ - authenticateRequest({ - noCache: true, - }), - checkIfUserIsAdmin(), - ]), - asyncHandler(ConfigurationController.getSchema) -); - -export default router; + update: { + middleware: [checkIfUserIsAdmin(), RateLimit.adminLimit], + handler: async (r) => + callController(ConfigurationController.updateConfiguration)(r), + }, + getSchema: { + middleware: [checkIfUserIsAdmin(), RateLimit.adminLimit], + handler: async (r) => callController(ConfigurationController.getSchema)(r), + }, +}); diff --git a/backend/src/api/routes/index.ts b/backend/src/api/routes/index.ts index 5fd58e335..df4f7988a 100644 --- a/backend/src/api/routes/index.ts +++ b/backend/src/api/routes/index.ts @@ -56,6 +56,7 @@ const router = s.router(contract, { public: publicStats, leaderboards, results, + configuration, }); export function addApiRoutes(app: Application): void { @@ -145,13 +146,16 @@ function applyDevApiRoutes(app: Application): void { } function applyApiRoutes(app: Application): void { - // Cannot be added to the route map because it needs to be added before the maintenance handler - app.use("/configuration", configuration); - addSwaggerMiddlewares(app); + //TODO move to globalMiddleware when all endpoints use tsrest app.use( (req: MonkeyTypes.Request, res: Response, next: NextFunction): void => { + if (req.path.startsWith("/configuration")) { + next(); + return; + } + const inMaintenance = process.env["MAINTENANCE"] === "true" || req.ctx.configuration.maintenance; diff --git a/backend/src/constants/base-configuration.ts b/backend/src/constants/base-configuration.ts index 8f137ee91..6e5c44a8e 100644 --- a/backend/src/constants/base-configuration.ts +++ b/backend/src/constants/base-configuration.ts @@ -1,4 +1,4 @@ -import { Configuration } from "@monkeytype/shared-types"; +import { Configuration } from "@monkeytype/contracts/schemas/configuration"; /** * This is the base schema for the configuration of the API backend. diff --git a/backend/src/dal/user.ts b/backend/src/dal/user.ts index 8252b7e41..da65ebaa3 100644 --- a/backend/src/dal/user.ts +++ b/backend/src/dal/user.ts @@ -17,7 +17,6 @@ import { UTCDate } from "@date-fns/utc"; import { AllRewards, Badge, - Configuration, CustomTheme, MonkeyMail, UserInventory, @@ -33,6 +32,7 @@ import { import { addImportantLog } from "./logs"; import { ResultFilters } from "@monkeytype/contracts/schemas/users"; import { Result as ResultType } from "@monkeytype/contracts/schemas/results"; +import { Configuration } from "@monkeytype/contracts/schemas/configuration"; const SECONDS_PER_HOUR = 3600; diff --git a/backend/src/init/configuration.ts b/backend/src/init/configuration.ts index 5de338bc1..ffe5ebc05 100644 --- a/backend/src/init/configuration.ts +++ b/backend/src/init/configuration.ts @@ -4,14 +4,15 @@ import { ObjectId } from "mongodb"; import Logger from "../utils/logger"; import { identity } from "../utils/misc"; import { BASE_CONFIGURATION } from "../constants/base-configuration"; -import { Configuration } from "@monkeytype/shared-types"; +import { Configuration } from "@monkeytype/contracts/schemas/configuration"; import { addLog } from "../dal/logs"; +import { PartialConfiguration } from "@monkeytype/contracts/configuration"; const CONFIG_UPDATE_INTERVAL = 10 * 60 * 1000; // 10 Minutes function mergeConfigurations( baseConfiguration: Configuration, - liveConfiguration: Partial + liveConfiguration: PartialConfiguration ): void { if ( !_.isPlainObject(baseConfiguration) || @@ -111,7 +112,7 @@ async function pushConfiguration(configuration: Configuration): Promise { } export async function patchConfiguration( - configurationUpdates: Partial + configurationUpdates: PartialConfiguration ): Promise { try { const currentConfiguration = _.cloneDeep(configuration); diff --git a/backend/src/middlewares/auth.ts b/backend/src/middlewares/auth.ts index 81b21ec6b..372586114 100644 --- a/backend/src/middlewares/auth.ts +++ b/backend/src/middlewares/auth.ts @@ -15,12 +15,13 @@ import { performance } from "perf_hooks"; import { TsRestRequestHandler } from "@ts-rest/express"; import { AppRoute, AppRouter } from "@ts-rest/core"; import { RequestAuthenticationOptions } from "@monkeytype/contracts/schemas/api"; -import { Configuration } from "@monkeytype/shared-types"; +import { Configuration } from "@monkeytype/contracts/schemas/configuration"; const DEFAULT_OPTIONS: RequestAuthenticationOptions = { isPublic: false, acceptApeKeys: false, requireFreshToken: false, + isPublicOnDev: false, }; export type TsRestRequestWithCtx = { @@ -73,6 +74,9 @@ async function _authenticateRequestInternal( let token: MonkeyTypes.DecodedToken; let authType = "None"; + const isPublic = + options.isPublic || (options.isPublicOnDev && isDevEnvironment()); + const { authorization: authHeader } = req.headers; try { @@ -82,7 +86,7 @@ async function _authenticateRequestInternal( req.ctx.configuration, options ); - } else if (options.isPublic === true) { + } else if (isPublic === true) { token = { type: "None", uid: "", @@ -239,12 +243,17 @@ async function authenticateWithApeKey( configuration: Configuration, options: RequestAuthenticationOptions ): Promise { - if (!configuration.apeKeys.acceptKeys) { - throw new MonkeyError(503, "ApeKeys are not being accepted at this time"); - } + const isPublic = + options.isPublic || (options.isPublicOnDev && isDevEnvironment()); - if (!options.acceptApeKeys && !options.isPublic) { - throw new MonkeyError(401, "This endpoint does not accept ApeKeys"); + if (!isPublic) { + if (!configuration.apeKeys.acceptKeys) { + throw new MonkeyError(503, "ApeKeys are not being accepted at this time"); + } + + if (!options.acceptApeKeys) { + throw new MonkeyError(401, "This endpoint does not accept ApeKeys"); + } } try { diff --git a/backend/src/middlewares/configuration.ts b/backend/src/middlewares/configuration.ts index 1d0ecf6c2..d6e475ef6 100644 --- a/backend/src/middlewares/configuration.ts +++ b/backend/src/middlewares/configuration.ts @@ -1,6 +1,6 @@ import type { Response, NextFunction, RequestHandler } from "express"; import MonkeyError from "../utils/error"; -import { Configuration } from "@monkeytype/shared-types"; +import { Configuration } from "@monkeytype/contracts/schemas/configuration"; export type ValidationOptions = { criteria: (data: T) => boolean; diff --git a/backend/src/middlewares/permission.ts b/backend/src/middlewares/permission.ts index cd10afad8..8288dee89 100644 --- a/backend/src/middlewares/permission.ts +++ b/backend/src/middlewares/permission.ts @@ -4,18 +4,32 @@ import type { Response, NextFunction, RequestHandler } from "express"; import { getPartialUser } from "../dal/user"; import { isAdmin } from "../dal/admin-uids"; import type { ValidationOptions } from "./configuration"; +import { TsRestRequestHandler } from "@ts-rest/express"; +import { TsRestRequestWithCtx } from "./auth"; +import { RequestAuthenticationOptions } from "@monkeytype/contracts/schemas/api"; +import { isDevEnvironment } from "../utils/misc"; /** * Check if the user is an admin before handling request. * Note that this middleware must be used after authentication in the middleware stack. */ -export function checkIfUserIsAdmin(): RequestHandler { +export function checkIfUserIsAdmin< + T extends AppRouter | AppRoute +>(): TsRestRequestHandler { return async ( - req: MonkeyTypes.Request, + req: TsRestRequestWithCtx, _res: Response, next: NextFunction ) => { try { + const options: RequestAuthenticationOptions = + req.tsRestRoute["metadata"]?.["authenticationOptions"] ?? {}; + + if (options.isPublicOnDev && isDevEnvironment()) { + next(); + return; + } + const { uid } = req.ctx.decodedToken; const admin = await isAdmin(uid); diff --git a/backend/src/middlewares/utility.ts b/backend/src/middlewares/utility.ts index ff443dc3e..d202ffc6a 100644 --- a/backend/src/middlewares/utility.ts +++ b/backend/src/middlewares/utility.ts @@ -1,7 +1,6 @@ import _ from "lodash"; import type { Request, Response, NextFunction, RequestHandler } from "express"; import { handleMonkeyResponse, MonkeyResponse } from "../utils/monkey-response"; -import { isDevEnvironment } from "../utils/misc"; import { recordClientVersion as prometheusRecordClientVersion } from "../utils/prometheus"; export const emptyMiddleware = ( @@ -36,17 +35,6 @@ export function asyncHandler(handler: AsyncHandler): RequestHandler { }; } -/** - * Uses the middlewares only in production. Otherwise, uses an empty middleware. - */ -export function useInProduction( - middlewares: RequestHandler[] -): RequestHandler[] { - return middlewares.map((middleware) => - isDevEnvironment() ? emptyMiddleware : middleware - ); -} - /** * record the client version from the `x-client-version` or ` client-version` header to prometheus */ diff --git a/backend/src/queues/later-queue.ts b/backend/src/queues/later-queue.ts index a7d99b7f2..71b5b2633 100644 --- a/backend/src/queues/later-queue.ts +++ b/backend/src/queues/later-queue.ts @@ -2,7 +2,7 @@ import LRUCache from "lru-cache"; import Logger from "../utils/logger"; import { MonkeyQueue } from "./monkey-queue"; import { getCurrentDayTimestamp, getCurrentWeekTimestamp } from "../utils/misc"; -import { ValidModeRule } from "@monkeytype/shared-types"; +import { ValidModeRule } from "@monkeytype/contracts/schemas/configuration"; const QUEUE_NAME = "later"; diff --git a/backend/src/services/weekly-xp-leaderboard.ts b/backend/src/services/weekly-xp-leaderboard.ts index 80ee39782..57295e3d5 100644 --- a/backend/src/services/weekly-xp-leaderboard.ts +++ b/backend/src/services/weekly-xp-leaderboard.ts @@ -1,4 +1,4 @@ -import { Configuration } from "@monkeytype/shared-types"; +import { Configuration } from "@monkeytype/contracts/schemas/configuration"; import * as RedisClient from "../init/redis"; import LaterQueue from "../queues/later-queue"; import { getCurrentWeekTimestamp } from "../utils/misc"; diff --git a/backend/src/types/types.d.ts b/backend/src/types/types.d.ts index d0165b0d0..772f55b42 100644 --- a/backend/src/types/types.d.ts +++ b/backend/src/types/types.d.ts @@ -14,7 +14,7 @@ declare namespace MonkeyTypes { }; export type Context = { - configuration: import("@monkeytype/shared-types").Configuration; + configuration: import("@monkeytype/contracts/schemas/configuration").Configuration; decodedToken: DecodedToken; }; diff --git a/backend/src/utils/daily-leaderboards.ts b/backend/src/utils/daily-leaderboards.ts index e013d8d94..4ebfdf422 100644 --- a/backend/src/utils/daily-leaderboards.ts +++ b/backend/src/utils/daily-leaderboards.ts @@ -2,12 +2,16 @@ import _, { omit } from "lodash"; import * as RedisClient from "../init/redis"; import LaterQueue from "../queues/later-queue"; import { getCurrentDayTimestamp, matchesAPattern, kogascore } from "./misc"; -import { Configuration, ValidModeRule } from "@monkeytype/shared-types"; +import { + Configuration, + ValidModeRule, +} from "@monkeytype/contracts/schemas/configuration"; import { DailyLeaderboardRank, LeaderboardEntry, } from "@monkeytype/contracts/schemas/leaderboards"; import MonkeyError from "./error"; +import { Mode, Mode2 } from "@monkeytype/contracts/schemas/shared"; const dailyLeaderboardNamespace = "monkeytype:dailyleaderboard"; const scoresNamespace = `${dailyLeaderboardNamespace}:scores`; @@ -221,14 +225,14 @@ function isValidModeRule( export function getDailyLeaderboard( language: string, - mode: string, - mode2: string, + mode: Mode, + mode2: Mode2, dailyLeaderboardsConfig: Configuration["dailyLeaderboards"], customTimestamp = -1 ): DailyLeaderboard | null { const { validModeRules, enabled } = dailyLeaderboardsConfig; - const modeRule = { language, mode, mode2 }; + const modeRule: ValidModeRule = { language, mode, mode2 }; const isValidMode = isValidModeRule(modeRule, validModeRules); if (!enabled || !isValidMode) { diff --git a/frontend/src/ts/ape/endpoints/configuration.ts b/frontend/src/ts/ape/endpoints/configuration.ts deleted file mode 100644 index 19013364d..000000000 --- a/frontend/src/ts/ape/endpoints/configuration.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { Configuration } from "@monkeytype/shared-types"; - -export default class Root { - constructor(private httpClient: Ape.HttpClient) { - this.httpClient = httpClient; - } - - async get(): Ape.EndpointResponse { - return await this.httpClient.get("/configuration"); - } -} diff --git a/frontend/src/ts/ape/endpoints/index.ts b/frontend/src/ts/ape/endpoints/index.ts index 0c8b813b4..240fbcbf1 100644 --- a/frontend/src/ts/ape/endpoints/index.ts +++ b/frontend/src/ts/ape/endpoints/index.ts @@ -1,11 +1,9 @@ import Quotes from "./quotes"; import Users from "./users"; -import Configuration from "./configuration"; import Dev from "./dev"; export default { Quotes, Users, - Configuration, Dev, }; diff --git a/frontend/src/ts/ape/index.ts b/frontend/src/ts/ape/index.ts index 2434f3ae7..79013111f 100644 --- a/frontend/src/ts/ape/index.ts +++ b/frontend/src/ts/ape/index.ts @@ -16,7 +16,6 @@ const Ape = { ...tsRestClient, users: new endpoints.Users(httpClient), quotes: new endpoints.Quotes(httpClient), - configuration: new endpoints.Configuration(httpClient), dev: new endpoints.Dev(buildHttpClient(API_URL, 240_000)), }; diff --git a/frontend/src/ts/ape/server-configuration.ts b/frontend/src/ts/ape/server-configuration.ts index 3d76c411d..c514bb8cd 100644 --- a/frontend/src/ts/ape/server-configuration.ts +++ b/frontend/src/ts/ape/server-configuration.ts @@ -1,4 +1,4 @@ -import { Configuration } from "@monkeytype/shared-types"; +import { Configuration } from "@monkeytype/contracts/schemas/configuration"; import Ape from "."; let config: Configuration | undefined = undefined; @@ -11,9 +11,9 @@ export async function sync(): Promise { const response = await Ape.configuration.get(); if (response.status !== 200) { - console.error("Could not fetch configuration", response.message); + console.error("Could not fetch configuration", response.body.message); return; } else { - config = response.data ?? undefined; + config = response.body.data ?? undefined; } } diff --git a/packages/contracts/src/configuration.ts b/packages/contracts/src/configuration.ts new file mode 100644 index 000000000..428e1a51e --- /dev/null +++ b/packages/contracts/src/configuration.ts @@ -0,0 +1,96 @@ +import { initContract } from "@ts-rest/core"; +import { z } from "zod"; + +import { + CommonResponses, + EndpointMetadata, + MonkeyResponseSchema, + responseWithData, +} from "./schemas/api"; +import { ConfigurationSchema } from "./schemas/configuration"; + +export const GetConfigurationResponseSchema = + responseWithData(ConfigurationSchema); + +export type GetConfigurationResponse = z.infer< + typeof GetConfigurationResponseSchema +>; + +export const PartialConfigurationSchema = ConfigurationSchema.deepPartial(); +export type PartialConfiguration = z.infer; + +export const PatchConfigurationRequestSchema = z + .object({ + configuration: PartialConfigurationSchema.strict(), + }) + .strict(); +export type PatchConfigurationRequest = z.infer< + typeof PatchConfigurationRequestSchema +>; + +export const ConfigurationSchemaResponseSchema = responseWithData(z.object({})); //TODO define schema? +export type ConfigurationSchemaResponse = z.infer< + typeof ConfigurationSchemaResponseSchema +>; + +const c = initContract(); + +export const configurationContract = c.router( + { + get: { + summary: "get configuration", + description: "Get server configuration", + method: "GET", + path: "", + responses: { + 200: GetConfigurationResponseSchema, + }, + metadata: { + authenticationOptions: { + isPublic: true, + }, + } as EndpointMetadata, + }, + update: { + summary: "update configuration", + description: + "Update the server configuration. Only provided values will be updated while the missing values will be unchanged.", + method: "PATCH", + path: "", + body: PatchConfigurationRequestSchema, + responses: { + 200: MonkeyResponseSchema, + }, + metadata: { + authenticationOptions: { + noCache: true, + isPublicOnDev: true, + }, + } as EndpointMetadata, + }, + getSchema: { + summary: "get configuration schema", + description: "Get schema definition of the server configuration.", + method: "GET", + path: "/schema", + responses: { + 200: ConfigurationSchemaResponseSchema, + }, + metadata: { + authenticationOptions: { + isPublicOnDev: true, + noCache: true, + }, + } as EndpointMetadata, + }, + }, + { + pathPrefix: "/configuration", + strictStatusCodes: true, + metadata: { + openApiTags: "configuration", + } as EndpointMetadata, + + commonResponses: CommonResponses, + } +); diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 398bd663b..321af82ac 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -7,6 +7,7 @@ import { psasContract } from "./psas"; import { publicContract } from "./public"; import { leaderboardsContract } from "./leaderboards"; import { resultsContract } from "./results"; +import { configurationContract } from "./configuration"; const c = initContract(); @@ -19,4 +20,5 @@ export const contract = c.router({ public: publicContract, leaderboards: leaderboardsContract, results: resultsContract, + configuration: configurationContract, }); diff --git a/packages/contracts/src/schemas/api.ts b/packages/contracts/src/schemas/api.ts index a7385ada4..ff7a39240 100644 --- a/packages/contracts/src/schemas/api.ts +++ b/packages/contracts/src/schemas/api.ts @@ -8,7 +8,8 @@ export type OpenApiTag = | "psas" | "public" | "leaderboards" - | "results"; + | "results" + | "configuration"; export type EndpointMetadata = { /** Authentication options, by default a bearer token is required. */ @@ -24,6 +25,8 @@ export type RequestAuthenticationOptions = { /** Endpoint requires an authentication token which is younger than one minute. */ requireFreshToken?: boolean; noCache?: boolean; + /** Allow unauthenticated requests on dev */ + isPublicOnDev?: boolean; }; export const MonkeyResponseSchema = z.object({ diff --git a/packages/contracts/src/schemas/configuration.ts b/packages/contracts/src/schemas/configuration.ts new file mode 100644 index 000000000..6a6cc3d09 --- /dev/null +++ b/packages/contracts/src/schemas/configuration.ts @@ -0,0 +1,121 @@ +import { z } from "zod"; + +/* ValidModeRuleSchema allows complex rules like `"mode2": "(15|60)"`. We don't want a strict validation here. */ +export const ValidModeRuleSchema = z + .object({ + language: z.string(), + mode: z.string(), + mode2: z.string(), + }) + .strict(); +export type ValidModeRule = z.infer; + +export const RewardBracketSchema = z + .object({ + minRank: z.number().int().nonnegative(), + maxRank: z.number().int().nonnegative(), + minReward: z.number().int().nonnegative(), + maxReward: z.number().int().nonnegative(), + }) + .strict(); +export type RewardBracket = z.infer; + +export const ConfigurationSchema = z.object({ + maintenance: z.boolean(), + dev: z.object({ + responseSlowdownMs: z.number().int().nonnegative(), + }), + quotes: z.object({ + reporting: z.object({ + enabled: z.boolean(), + maxReports: z.number().int().nonnegative(), + contentReportLimit: z.number().int().nonnegative(), + }), + submissionsEnabled: z.boolean(), + maxFavorites: z.number().int().nonnegative(), + }), + results: z.object({ + savingEnabled: z.boolean(), + objectHashCheckEnabled: z.boolean(), + filterPresets: z.object({ + enabled: z.boolean(), + maxPresetsPerUser: z.number().int().nonnegative(), + }), + limits: z.object({ + regularUser: z.number().int().nonnegative(), + premiumUser: z.number().int().nonnegative(), + }), + maxBatchSize: z.number().int().nonnegative(), + }), + users: z.object({ + signUp: z.boolean(), + lastHashesCheck: z.object({ + enabled: z.boolean(), + maxHashes: z.number().int().nonnegative(), + }), + autoBan: z.object({ + enabled: z.boolean(), + maxCount: z.number().int().nonnegative(), + maxHours: z.number().int().nonnegative(), + }), + profiles: z.object({ + enabled: z.boolean(), + }), + discordIntegration: z.object({ + enabled: z.boolean(), + }), + xp: z.object({ + enabled: z.boolean(), + funboxBonus: z.number(), + gainMultiplier: z.number(), + maxDailyBonus: z.number(), + minDailyBonus: z.number(), + streak: z.object({ + enabled: z.boolean(), + maxStreakDays: z.number().nonnegative(), + maxStreakMultiplier: z.number(), + }), + }), + inbox: z.object({ + enabled: z.boolean(), + maxMail: z.number().int().nonnegative(), + }), + premium: z.object({ + enabled: z.boolean(), + }), + }), + admin: z.object({ + endpointsEnabled: z.boolean(), + }), + apeKeys: z.object({ + endpointsEnabled: z.boolean(), + acceptKeys: z.boolean(), + maxKeysPerUser: z.number().int().nonnegative(), + apeKeyBytes: z.number().int().nonnegative(), + apeKeySaltRounds: z.number().int().nonnegative(), + }), + rateLimiting: z.object({ + badAuthentication: z.object({ + enabled: z.boolean(), + penalty: z.number(), + flaggedStatusCodes: z.array(z.number().int().nonnegative()), + }), + }), + dailyLeaderboards: z.object({ + enabled: z.boolean(), + leaderboardExpirationTimeInDays: z.number().nonnegative(), + maxResults: z.number().int().nonnegative(), + validModeRules: z.array(ValidModeRuleSchema), + scheduleRewardsModeRules: z.array(ValidModeRuleSchema), + topResultsToAnnounce: z.number().int().positive(), // This should never be 0. Setting to zero will announce all results. + xpRewardBrackets: z.array(RewardBracketSchema), + }), + leaderboards: z.object({ + weeklyXp: z.object({ + enabled: z.boolean(), + expirationTimeInDays: z.number().nonnegative(), + xpRewardBrackets: z.array(RewardBracketSchema), + }), + }), +}); +export type Configuration = z.infer; diff --git a/packages/shared-types/src/index.ts b/packages/shared-types/src/index.ts index 2b9edfde6..c6a0bb8ae 100644 --- a/packages/shared-types/src/index.ts +++ b/packages/shared-types/src/index.ts @@ -2,117 +2,6 @@ type PersonalBest = import("@monkeytype/contracts/schemas/shared").PersonalBest; type PersonalBests = import("@monkeytype/contracts/schemas/shared").PersonalBests; -export type ValidModeRule = { - language: string; - mode: string; - mode2: string; -}; -export type RewardBracket = { - minRank: number; - maxRank: number; - minReward: number; - maxReward: number; -}; - -export type Configuration = { - maintenance: boolean; - dev: { - responseSlowdownMs: number; - }; - quotes: { - reporting: { - enabled: boolean; - maxReports: number; - contentReportLimit: number; - }; - submissionsEnabled: boolean; - maxFavorites: number; - }; - results: { - savingEnabled: boolean; - objectHashCheckEnabled: boolean; - filterPresets: { - enabled: boolean; - maxPresetsPerUser: number; - }; - limits: { - regularUser: number; - premiumUser: number; - }; - maxBatchSize: number; - }; - users: { - signUp: boolean; - lastHashesCheck: { - enabled: boolean; - maxHashes: number; - }; - autoBan: { - enabled: boolean; - maxCount: number; - maxHours: number; - }; - profiles: { - enabled: boolean; - }; - discordIntegration: { - enabled: boolean; - }; - xp: { - enabled: boolean; - funboxBonus: number; - gainMultiplier: number; - maxDailyBonus: number; - minDailyBonus: number; - streak: { - enabled: boolean; - maxStreakDays: number; - maxStreakMultiplier: number; - }; - }; - inbox: { - enabled: boolean; - maxMail: number; - }; - premium: { - enabled: boolean; - }; - }; - admin: { - endpointsEnabled: boolean; - }; - apeKeys: { - endpointsEnabled: boolean; - acceptKeys: boolean; - maxKeysPerUser: number; - apeKeyBytes: number; - apeKeySaltRounds: number; - }; - rateLimiting: { - badAuthentication: { - enabled: boolean; - penalty: number; - flaggedStatusCodes: number[]; - }; - }; - dailyLeaderboards: { - enabled: boolean; - leaderboardExpirationTimeInDays: number; - maxResults: number; - validModeRules: ValidModeRule[]; - scheduleRewardsModeRules: ValidModeRule[]; - topResultsToAnnounce: number; - xpRewardBrackets: RewardBracket[]; - }; - leaderboards: { - weeklyXp: { - enabled: boolean; - expirationTimeInDays: number; - xpRewardBrackets: RewardBracket[]; - }; - }; -}; - export type CustomTextLimit = { value: number; mode: import("@monkeytype/contracts/schemas/util").CustomTextLimitMode;