Revert "impr: use tsrest/zod for type-safety on both ends (@fehmer) (#5479)" (#5619)

This reverts commit 4c9e949f10.
This commit is contained in:
Jack 2024-07-16 17:29:09 +02:00 committed by GitHub
parent 4c9e949f10
commit 8f4d291fcf
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
64 changed files with 3577 additions and 7488 deletions

View file

@ -1,4 +1,3 @@
backend/build
backend/__migration__
backend/scripts
docker
docker

View file

@ -69,7 +69,7 @@ jobs:
node-version: ${{ env.NODE_VERSION }}
- name: Install dependencies
run: npm ci && (cd shared && npm ci ) && cd backend && npm ci
run: npm ci & cd backend && npm ci
- name: Check pretty
run: npm run pretty-code-be
@ -100,7 +100,7 @@ jobs:
run: mv ./firebase-config-example.ts ./firebase-config.ts && cp ./firebase-config.ts ./firebase-config-live.ts
- name: Install dependencies
run: npm ci && (cd shared && npm ci ) && cd frontend && npm ci
run: npm ci & cd frontend && npm ci
- name: Check pretty
run: npm run pretty-code-fe

View file

@ -1,132 +0,0 @@
import request from "supertest";
import app from "../../../src/app";
import * as ConfigDal from "../../../src/dal/config";
import { ObjectId } from "mongodb";
const mockApp = request(app);
describe("ConfigController", () => {
describe("get config", () => {
const getConfigMock = vi.spyOn(ConfigDal, "getConfig");
afterEach(() => {
getConfigMock.mockReset();
});
it("should get the users config", async () => {
//GIVEN
getConfigMock.mockResolvedValue({
_id: new ObjectId(),
uid: "123456789",
config: { language: "english" },
});
//WHEN
const { body } = await mockApp
.get("/configs")
.set("authorization", "Uid 123456789")
.expect(200);
//THEN
expect(body).toStrictEqual({
message: "Configuration retrieved",
data: { language: "english" },
});
expect(getConfigMock).toHaveBeenCalledWith("123456789");
});
});
describe("update config", () => {
const saveConfigMock = vi.spyOn(ConfigDal, "saveConfig");
afterEach(() => {
saveConfigMock.mockReset();
});
it("should update the users config", async () => {
//GIVEN
saveConfigMock.mockResolvedValue({} as any);
//WHEN
const { body } = await mockApp
.patch("/configs")
.set("authorization", "Uid 123456789")
.accept("application/json")
.send({ language: "english" })
.expect(200);
//THEN
expect(body).toStrictEqual({
message: "Config updated",
data: null,
});
expect(saveConfigMock).toHaveBeenCalledWith("123456789", {
language: "english",
});
});
it("should fail with unknown config", async () => {
//WHEN
const { body } = await mockApp
.patch("/configs")
.set("authorization", "Uid 123456789")
.accept("application/json")
.send({ unknownValue: "unknown" })
.expect(422);
//THEN
expect(body).toStrictEqual({
message: "Invalid request data schema",
validationErrors: [`Unrecognized key(s) in object: 'unknownValue'`],
});
expect(saveConfigMock).not.toHaveBeenCalled();
});
it("should fail with invalid configs", async () => {
//WHEN
const { body } = await mockApp
.patch("/configs")
.set("authorization", "Uid 123456789")
.accept("application/json")
.send({ autoSwitchTheme: "yes", confidenceMode: "pretty" })
.expect(422);
//THEN
expect(body).toStrictEqual({
message: "Invalid request data schema",
validationErrors: [
`"autoSwitchTheme" Expected boolean, received string`,
`"confidenceMode" Invalid enum value. Expected 'off' | 'on' | 'max', received 'pretty'`,
],
});
expect(saveConfigMock).not.toHaveBeenCalled();
});
});
describe("delete config", () => {
const deleteConfigMock = vi.spyOn(ConfigDal, "deleteConfig");
afterEach(() => {
deleteConfigMock.mockReset();
});
it("should delete the users config", async () => {
//GIVEN
deleteConfigMock.mockResolvedValue();
//WHEN
const { body } = await mockApp
.delete("/configs")
.set("authorization", "Uid 123456789")
.expect(200);
//THEN
expect(body).toStrictEqual({
message: "Config deleted",
data: null,
});
expect(deleteConfigMock).toHaveBeenCalledWith("123456789");
});
});
});

View file

@ -8,9 +8,6 @@ import { ObjectId } from "mongodb";
import { hashSync } from "bcrypt";
import MonkeyError from "../../src/utils/error";
import * as Misc from "../../src/utils/misc";
import { Metadata, RequestAuthenticationOptions } from "shared/schemas/util";
import * as Prometheus from "../../src/utils/prometheus";
import { unknown } from "zod";
const mockDecodedToken: DecodedIdToken = {
uid: "123456789",
@ -34,11 +31,12 @@ const mockApeKey = {
vi.spyOn(ApeKeys, "getApeKey").mockResolvedValue(mockApeKey);
vi.spyOn(ApeKeys, "updateLastUsedOn").mockResolvedValue();
const isDevModeMock = vi.spyOn(Misc, "isDevEnvironment");
let mockRequest: Partial<MonkeyTypes.Request>;
let mockResponse: Partial<Response>;
let nextFunction: NextFunction;
describe("middlewares/auth", () => {
let mockRequest: Partial<MonkeyTypes.Request>;
let mockResponse: Partial<Response>;
let nextFunction: NextFunction;
beforeEach(async () => {
isDevModeMock.mockReturnValue(true);
let config = await getCachedConfiguration(true);
@ -260,253 +258,4 @@ describe("middlewares/auth", () => {
);
});
});
describe("authenticateTsRestRequest", () => {
const prometheusRecordAuthTimeMock = vi.spyOn(Prometheus, "recordAuthTime");
const prometheusIncrementAuthMock = vi.spyOn(Prometheus, "incrementAuth");
beforeEach(() =>
[prometheusIncrementAuthMock, prometheusRecordAuthTimeMock].forEach(
(it) => it.mockReset()
)
);
it("should fail if token is not fresh", async () => {
//GIVEN
Date.now = vi.fn(() => 60001);
//WHEN
expect(() =>
authenticate({}, { requireFreshToken: true })
).rejects.toThrowError(
"Unauthorized\nStack: This endpoint requires a fresh token"
);
//THEN
expect(nextFunction).not.toHaveBeenCalled();
expect(prometheusIncrementAuthMock).not.toHaveBeenCalled();
expect(prometheusRecordAuthTimeMock).not.toHaveBeenCalled();
});
it("should allow the request if token is fresh", async () => {
//GIVEN
Date.now = vi.fn(() => 10000);
//WHEN
const result = await authenticate({}, { requireFreshToken: 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).toHaveBeenCalledOnce();
expect(prometheusIncrementAuthMock).toHaveBeenCalledWith("Bearer");
expect(prometheusRecordAuthTimeMock).toHaveBeenCalledOnce();
});
it("should allow the request if apeKey is supported", async () => {
//WHEN
const result = await authenticate(
{ headers: { authorization: "ApeKey aWQua2V5" } },
{ acceptApeKeys: true }
);
//THEN
const decodedToken = result.decodedToken;
expect(decodedToken?.type).toBe("ApeKey");
expect(decodedToken?.email).toBe("");
expect(decodedToken?.uid).toBe("123");
expect(nextFunction).toHaveBeenCalledTimes(1);
});
it("should fail wit apeKey if apeKey is not supported", async () => {
//WHEN
await expect(() =>
authenticate(
{ headers: { authorization: "ApeKey aWQua2V5" } },
{ acceptApeKeys: false }
)
).rejects.toThrowError("This endpoint does not accept ApeKeys");
//THEN
});
it("should allow the request with authentation on public endpoint", async () => {
//WHEN
const result = await authenticate({}, { isPublic: 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 public endpoint", async () => {
//WHEN
const result = await authenticate({ headers: {} }, { isPublic: 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 public endpoint", async () => {
//WHEN
const result = await authenticate(
{ headers: { authorization: "ApeKey aWQua2V5" } },
{ isPublic: 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 request with Uid on dev", async () => {
//WHEN
const result = await authenticate({
headers: { authorization: "Uid 123" },
});
//THEN
const decodedToken = result.decodedToken;
expect(decodedToken?.type).toBe("Bearer");
expect(decodedToken?.email).toBe("");
expect(decodedToken?.uid).toBe("123");
expect(nextFunction).toHaveBeenCalledTimes(1);
});
it("should allow request with Uid and email on dev", async () => {
const result = await authenticate({
headers: { authorization: "Uid 123|test@example.com" },
});
//THEN
const decodedToken = result.decodedToken;
expect(decodedToken?.type).toBe("Bearer");
expect(decodedToken?.email).toBe("test@example.com");
expect(decodedToken?.uid).toBe("123");
expect(nextFunction).toHaveBeenCalledTimes(1);
});
it("should fail request with Uid on non-dev", async () => {
//GIVEN
isDevModeMock.mockReturnValue(false);
//WHEN / THEN
await expect(() =>
authenticate({ headers: { authorization: "Uid 123" } })
).rejects.toThrow(
new MonkeyError(401, "Baerer type uid is not supported")
);
});
it("should fail without authentication", async () => {
await expect(() => authenticate({ headers: {} })).rejects.toThrowError(
"Unauthorized\nStack: endpoint: /api/v1 no authorization header found"
);
//THEH
expect(prometheusIncrementAuthMock).not.toHaveBeenCalled();
expect(prometheusRecordAuthTimeMock).toHaveBeenCalledWith(
"None",
"failure",
expect.anything(),
expect.anything()
);
});
it("should fail with empty authentication", async () => {
await expect(() =>
authenticate({ headers: { authorization: "" } })
).rejects.toThrowError(
"Unauthorized\nStack: endpoint: /api/v1 no authorization header found"
);
//THEH
expect(prometheusIncrementAuthMock).not.toHaveBeenCalled();
expect(prometheusRecordAuthTimeMock).toHaveBeenCalledWith(
"",
"failure",
expect.anything(),
expect.anything()
);
});
it("should fail with missing authentication token", async () => {
await expect(() =>
authenticate({ headers: { authorization: "Bearer" } })
).rejects.toThrowError(
"Missing authentication token\nStack: authenticateWithAuthHeader"
);
//THEH
expect(prometheusIncrementAuthMock).not.toHaveBeenCalled();
expect(prometheusRecordAuthTimeMock).toHaveBeenCalledWith(
"Bearer",
"failure",
expect.anything(),
expect.anything()
);
});
it("should fail with unknown authentication scheme", async () => {
await expect(() =>
authenticate({ headers: { authorization: "unknown format" } })
).rejects.toThrowError(
'Unknown authentication scheme\nStack: The authentication scheme "unknown" is not implemented'
);
//THEH
expect(prometheusIncrementAuthMock).not.toHaveBeenCalled();
expect(prometheusRecordAuthTimeMock).toHaveBeenCalledWith(
"unknown",
"failure",
expect.anything(),
expect.anything()
);
});
it("should record country if provided", async () => {
const prometheusRecordRequestCountryMock = vi.spyOn(
Prometheus,
"recordRequestCountry"
);
await authenticate(
{ headers: { "cf-ipcountry": "gb" } },
{ isPublic: true }
);
//THEN
expect(prometheusRecordRequestCountryMock).toHaveBeenCalledWith(
"gb",
expect.anything()
);
});
});
});
async function authenticate(
request: Partial<Request>,
authenticationOptions?: RequestAuthenticationOptions
): Promise<{ decodedToken: MonkeyTypes.DecodedToken }> {
const mergedRequest = {
...mockRequest,
...request,
tsRestRoute: {
metadata: { authenticationOptions } as Metadata,
},
} as any;
await Auth.authenticateTsRestRequest()(
mergedRequest,
mockResponse as Response,
nextFunction
);
return { decodedToken: mergedRequest.ctx.decodedToken };
}

View file

@ -24,6 +24,6 @@
"./**/*.ts",
"./**/*.spec.ts",
"./setup-tests.ts",
"../../shared/types/**/*.d.ts"
"../../shared-types/**/*.d.ts"
]
}

View file

@ -1,6 +1,5 @@
import _ from "lodash";
import * as misc from "../../src/utils/misc";
import { ObjectId } from "mongodb";
describe("Misc Utils", () => {
afterAll(() => {
@ -606,18 +605,4 @@ describe("Misc Utils", () => {
expect(misc.formatSeconds(seconds)).toBe(expected);
});
});
describe("replaceObjectId", () => {
it("replaces objecId with string", () => {
const fromDatabase = {
_id: new ObjectId(),
test: "test",
number: 1,
};
expect(misc.replaceObjectId(fromDatabase)).toStrictEqual({
_id: fromDatabase._id.toHexString(),
test: "test",
number: 1,
});
});
});
});

2449
backend/package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -4,7 +4,7 @@
"license": "GPL-3.0",
"private": true,
"scripts": {
"build": "npm run gen-docs && tsc --build",
"build": "tsc --build",
"watch": "tsc --build --watch",
"clean": "tsc --build --clean",
"start": "npm run build && node ./build/server.js",
@ -13,8 +13,7 @@
"dev": "ts-node-dev --transpile-only --inspect -- ./src/server.ts",
"knip": "knip",
"docker-db-only": "docker compose -f docker/compose.db-only.yml up",
"docker": "docker compose -f docker/compose.yml up",
"gen-docs": "ts-node scripts/openapi.ts build/static/api/openapi.json && redocly build-docs -o build/static/api/internal.html internal@v2 && redocly bundle -o build/static/api/public.json public-filter && redocly build-docs -o build/static/api/public.html public@v2"
"docker": "docker compose -f docker/compose.yml up"
},
"engines": {
"node": "18.19.1",
@ -22,9 +21,6 @@
},
"dependencies": {
"@date-fns/utc": "1.2.0",
"@ts-rest/core": "3.45.2",
"@ts-rest/express": "3.45.2",
"@ts-rest/open-api": "3.45.2",
"bcrypt": "5.1.1",
"bullmq": "1.91.1",
"chalk": "4.1.2",
@ -50,18 +46,15 @@
"path": "0.12.7",
"prom-client": "14.0.1",
"rate-limiter-flexible": "2.3.7",
"shared": "file:../shared",
"simple-git": "3.16.0",
"string-similarity": "4.0.4",
"swagger-stats": "0.99.5",
"swagger-ui-express": "4.3.0",
"ua-parser-js": "0.7.28",
"uuid": "9.0.1",
"winston": "3.6.0",
"zod": "3.23.8"
"winston": "3.6.0"
},
"devDependencies": {
"@redocly/cli": "1.18.0",
"@types/bcrypt": "5.0.0",
"@types/cors": "2.8.12",
"@types/cron": "1.7.3",

View file

@ -1,46 +0,0 @@
extends:
- recommended
apis:
internal@v2:
root: build/static/api/openapi.json
public-filter:
root: build/static/api/openapi.json
decorators:
filter-in:
property: x-public
value: yes
public@v2:
root: build/static/api/public.json
features.openapi:
theme:
logo:
gutter: "10px"
colors:
primary:
main: "#e2b714"
border:
dark: "#e2b714"
light: "#e2b714"
error:
main: "#da3333"
success:
main: "#009400"
text:
primary: "#646669"
secondary: "#d1d0c5"
warning:
main: "#FF00FF"
http:
delete: "#da3333"
post: "#004D94"
patch: "#e2b714"
get: "#009400"
sidebar:
backgroundColor: "#323437"
textColor: "#d1d0c5"
activeTextColor: "#e2b714"
rightPanel:
backgroundColor: "#323437"
textColor: "#d1d0c5"

View file

@ -1,119 +0,0 @@
import { generateOpenApi } from "@ts-rest/open-api";
import { contract } from "../../shared/contracts/index";
import { writeFileSync, mkdirSync } from "fs";
import { EndpointMetadata } from "../../shared/schemas/api";
type SecurityRequirementObject = {
[name: string]: string[];
};
export function getOpenApi() {
const openApiDocument = generateOpenApi(
contract,
{
openapi: "3.1.0",
info: {
title: "Monkeytype API",
description:
"Documentation for the public endpoints provided by the Monkeytype API server.\n\nNote that authentication is performed with the Authorization HTTP header in the format `Authorization: ApeKey YOUR_APE_KEY`\n\nThere is a rate limit of `30 requests per minute` across all endpoints with some endpoints being more strict. Rate limit rates are shared across all ape keys.",
version: "2.0.0",
termsOfService: "https://monkeytype.com/terms-of-service",
contact: {
name: "Support",
email: "support@monkeytype.com",
},
"x-logo": {
url: "https://monkeytype.com/images/mtfulllogo.png",
},
license: {
name: "GPL-3.0",
url: "https://www.gnu.org/licenses/gpl-3.0.html",
},
},
servers: [
{
url: "https://api.monkeytype.com",
description: "Production server",
},
],
components: {
securitySchemes: {
BearerAuth: {
type: "http",
scheme: "bearer",
},
ApeKey: {
type: "http",
scheme: "ApeKey",
},
},
},
tags: [
{
name: "configs",
description:
"User specific configurations like test settings, theme or tags.",
"x-displayName": "User configuration",
},
],
},
{
jsonQuery: true,
setOperationId: "concatenated-path",
operationMapper: (operation, route) => ({
...operation,
...addAuth(route.metadata as EndpointMetadata),
...addTags(route.metadata as EndpointMetadata),
}),
}
);
return openApiDocument;
}
function addAuth(metadata: EndpointMetadata | undefined): Object {
const auth = metadata?.["authenticationOptions"] ?? {};
const security: SecurityRequirementObject[] = [];
if (!auth.isPublic === true) {
security.push({ BearerAuth: [] });
if (auth.acceptApeKeys === true) {
security.push({ ApeKey: [] });
}
}
const includeInPublic = auth.isPublic === true || auth.acceptApeKeys === true;
return {
"x-public": includeInPublic ? "yes" : "no",
security,
};
}
function addTags(metadata: EndpointMetadata | undefined): Object {
if (metadata === undefined || metadata.openApiTags === undefined) return {};
return {
tags: Array.isArray(metadata.openApiTags)
? metadata.openApiTags
: [metadata.openApiTags],
};
}
//detect if we run this as a main
if (require.main === module) {
const args = process.argv.slice(2);
if (args.length !== 1) {
console.error("Provide filename.");
process.exit(1);
}
const outFile = args[0] as string;
//create directories if needed
const lastSlash = outFile.lastIndexOf("/");
if (lastSlash > 1) {
const dir = outFile.substring(0, lastSlash);
mkdirSync(dir, { recursive: true });
}
const openapi = getOpenApi();
writeFileSync(args[0] as string, JSON.stringify(openapi, null, 2));
}

View file

@ -1,33 +1,22 @@
import { PartialConfig } from "shared/schemas/config";
import * as ConfigDAL from "../../dal/config";
import { MonkeyResponse2 } from "../../utils/monkey-response";
import { GetConfigResponse } from "shared/contracts/configs";
import { MonkeyResponse } from "../../utils/monkey-response";
export async function getConfig(
req: MonkeyTypes.Request2
): Promise<GetConfigResponse> {
req: MonkeyTypes.Request
): Promise<MonkeyResponse> {
const { uid } = req.ctx.decodedToken;
const data = (await ConfigDAL.getConfig(uid))?.config ?? null;
return new MonkeyResponse2("Configuration retrieved", data);
const data = await ConfigDAL.getConfig(uid);
return new MonkeyResponse("Configuration retrieved", data);
}
export async function saveConfig(
req: MonkeyTypes.Request2<undefined, PartialConfig>
): Promise<MonkeyResponse2> {
const config = req.body;
req: MonkeyTypes.Request
): Promise<MonkeyResponse> {
const { config } = req.body;
const { uid } = req.ctx.decodedToken;
await ConfigDAL.saveConfig(uid, config);
return new MonkeyResponse2("Config updated");
}
export async function deleteConfig(
req: MonkeyTypes.Request2
): Promise<MonkeyResponse2> {
const { uid } = req.ctx.decodedToken;
await ConfigDAL.deleteConfig(uid);
return new MonkeyResponse2("Config deleted");
return new MonkeyResponse("Config updated");
}

View file

@ -1,22 +1,30 @@
import { configsContract } from "shared/contracts/configs";
import { initServer } from "@ts-rest/express";
import * as RateLimit from "../../middlewares/rate-limit";
import { Router } from "express";
import { authenticateRequest } from "../../middlewares/auth";
import configSchema from "../schemas/config-schema";
import * as ConfigController from "../controllers/config";
import { callController } from "../ts-rest-adapter";
import * as RateLimit from "../../middlewares/rate-limit";
import { asyncHandler } from "../../middlewares/utility";
import { validateRequest } from "../../middlewares/validation";
const s = initServer();
export const configsRoutes = s.router(configsContract, {
get: {
middleware: [RateLimit.configGet],
handler: async (r) => callController(ConfigController.getConfig)(r),
},
const router = Router();
save: {
middleware: [RateLimit.configUpdate],
handler: async (r) => callController(ConfigController.saveConfig)(r),
},
delete: {
middleware: [RateLimit.configDelete],
handler: async (r) => callController(ConfigController.deleteConfig)(r),
},
});
router.get(
"/",
authenticateRequest(),
RateLimit.configGet,
asyncHandler(ConfigController.getConfig)
);
router.patch(
"/",
authenticateRequest(),
RateLimit.configUpdate,
validateRequest({
body: {
config: configSchema.required(),
},
}),
asyncHandler(ConfigController.saveConfig)
);
export default router;

View file

@ -1,10 +1,10 @@
import _ from "lodash";
import { contract } from "shared/contracts/index";
import psas from "./psas";
import publicStats from "./public";
import users from "./users";
import { join } from "path";
import quotes from "./quotes";
import configs from "./configs";
import results from "./results";
import presets from "./presets";
import apeKeys from "./ape-keys";
@ -20,7 +20,6 @@ import { MonkeyResponse } from "../../utils/monkey-response";
import { recordClientVersion } from "../../utils/prometheus";
import {
Application,
IRouter,
NextFunction,
Response,
Router,
@ -29,12 +28,6 @@ import {
import { isDevEnvironment } from "../../utils/misc";
import { getLiveConfiguration } from "../../init/configuration";
import Logger from "../../utils/logger";
import { createExpressEndpoints, initServer } from "@ts-rest/express";
import { configsRoutes } from "./configs";
import { ZodIssue } from "zod";
import { MonkeyValidationError } from "shared/schemas/api";
import { addRedocMiddlewares } from "./redoc";
import { authenticateTsRestRequest } from "../../middlewares/auth";
const pathOverride = process.env["API_PATH_OVERRIDE"];
const BASE_ROUTE = pathOverride !== undefined ? `/${pathOverride}` : "";
@ -42,6 +35,7 @@ const APP_START_TIME = Date.now();
const API_ROUTE_MAP = {
"/users": users,
"/configs": configs,
"/results": results,
"/presets": presets,
"/psas": psas,
@ -53,49 +47,11 @@ const API_ROUTE_MAP = {
"/webhooks": webhooks,
};
const s = initServer();
const router = s.router(contract, {
configs: configsRoutes,
});
export function addApiRoutes(app: Application): void {
applyDevApiRoutes(app);
applyApiRoutes(app);
applyTsRestApiRoutes(app);
app.use(
asyncHandler(async (req, _res) => {
return new MonkeyResponse(
`Unknown request URL (${req.method}: ${req.path})`,
null,
404
);
})
);
}
function applyTsRestApiRoutes(app: IRouter): void {
createExpressEndpoints(contract, router, app, {
jsonQuery: true,
requestValidationErrorHandler(err, req, res, next) {
if (err.body?.issues === undefined) return next();
const issues = err.body?.issues.map(prettyErrorMessage);
res.status(422).json({
message: "Invalid request data schema",
validationErrors: issues,
} as MonkeyValidationError);
},
globalMiddleware: [authenticateTsRestRequest()],
function addApiRoutes(app: Application): void {
app.get("/leaderboard", (_req, res) => {
res.sendStatus(404);
});
}
function prettyErrorMessage(issue: ZodIssue | undefined): string {
if (issue === undefined) return "";
const path = issue.path.length > 0 ? `"${issue.path.join(".")}" ` : "";
return `${path}${issue.message}`;
}
function applyDevApiRoutes(app: Application): void {
if (isDevEnvironment()) {
//disable csp to allow assets to load from unsecured http
app.use((req, res, next) => {
@ -116,14 +72,11 @@ function applyDevApiRoutes(app: Application): void {
//enable dev edpoints
app.use("/dev", dev);
}
}
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);
addRedocMiddlewares(app);
app.use(
(req: MonkeyTypes.Request, res: Response, next: NextFunction): void => {
@ -157,10 +110,6 @@ function applyApiRoutes(app: Application): void {
})
);
//legacy routes
app.get("/leaderboard", (_req, res) => {
res.sendStatus(404);
});
app.get("/psa", (_req, res) => {
res.json([
{
@ -175,4 +124,16 @@ function applyApiRoutes(app: Application): void {
const apiRoute = `${BASE_ROUTE}${route}`;
app.use(apiRoute, router);
});
app.use(
asyncHandler(async (req, _res) => {
return new MonkeyResponse(
`Unknown request URL (${req.method}: ${req.path})`,
null,
404
);
})
);
}
export default addApiRoutes;

View file

@ -1,25 +0,0 @@
import { Application } from "express";
export function addRedocMiddlewares(app: Application): void {
app.use("/v2/docs-internal", (req, res) => {
res.sendFile("api/internal.html", {
root: __dirname + "../../../../build/static",
});
});
app.use("/v2/docs-internal.json", (req, res) => {
res.setHeader("Content-Type", "application/json");
res.sendFile("api/openapi.json", {
root: __dirname + "../../../../build/static",
});
});
app.use("/v2/docs", (req, res) => {
res.sendFile("api/public.html", {
root: __dirname + "../../../../build/static",
});
});
app.use("/v2/docs.json", (req, res) => {
res.setHeader("Content-Type", "application/json");
res.sendFile("api/public.json", {
root: __dirname + "../../../../build/static",
});
});
}

View file

@ -1,8 +1,12 @@
import _ from "lodash";
import { Application } from "express";
import { getMiddleware as getSwaggerMiddleware } from "swagger-stats";
import * as swaggerUi from "swagger-ui-express";
import internalSwaggerSpec from "../../documentation/internal-swagger.json";
import {
serve as serveSwagger,
setup as setupSwaggerUi,
} from "swagger-ui-express";
import publicSwaggerSpec from "../../documentation/public-swagger.json";
import internalSwaggerSpec from "../../documentation/internal-swagger.json";
import { isDevEnvironment } from "../../utils/misc";
const SWAGGER_UI_OPTIONS = {
@ -27,11 +31,10 @@ function addSwaggerMiddlewares(app: Application): void {
})
);
const options = {};
app.use(
["/documentation", "/docs"],
swaggerUi.serveFiles(publicSwaggerSpec, options),
swaggerUi.setup(publicSwaggerSpec, SWAGGER_UI_OPTIONS)
serveSwagger,
setupSwaggerUi(publicSwaggerSpec, SWAGGER_UI_OPTIONS)
);
}

View file

@ -11,7 +11,6 @@ const CARET_STYLES = [
"banana",
];
//TODO replaced
const CONFIG_SCHEMA = joi.object({
theme: joi.string().max(50).token(),
themeLight: joi.string().max(50).token(),
@ -81,7 +80,7 @@ const CONFIG_SCHEMA = joi.object({
.valid("lowercase", "uppercase", "blank", "dynamic"),
keymapLayout: joi
.string()
.regex(/[\w\-_]+/)
.regex(/[\w-_]+/)
.valid()
.max(50),
keymapShowTopRow: joi.string().valid("always", "layout", "never"),

View file

@ -1,73 +0,0 @@
import { AppRoute, AppRouter } from "@ts-rest/core";
import { TsRestRequest } from "@ts-rest/express";
import { MonkeyResponse2 } from "../utils/monkey-response";
export function callController<
TRoute extends AppRoute | AppRouter,
TQuery,
TBody,
TParams,
TResponse,
TStatus = 200
>(
handler: Handler<TQuery, TBody, TParams, TResponse>
): (all: RequestType2<TRoute, TQuery, TBody, TParams>) => Promise<{
status: TStatus;
body: { message: string; data: TResponse };
}> {
return async (all) => {
const req: MonkeyTypes.Request2<TQuery, TBody, TParams> = {
body: all.body as TBody,
query: all.query as TQuery,
params: all.params as TParams,
raw: all.req,
ctx: all.req["ctx"],
};
const result = await handler(req);
const response = {
status: 200 as TStatus,
body: {
message: result.message,
data: result.data as TResponse,
},
};
return response;
};
}
type WithBody<T> = {
body: T;
};
type WithQuery<T> = {
query: T;
};
type WithParams<T> = {
params: T;
};
type WithoutBody = {
body?: never;
};
type WithoutQuery = {
query?: never;
};
type WithoutParams = {
params?: never;
};
type Handler<TQuery, TBody, TParams, TResponse> = (
req: MonkeyTypes.Request2<TQuery, TBody, TParams>
) => Promise<MonkeyResponse2<TResponse>>;
type RequestType2<
TRoute extends AppRoute | AppRouter,
TQuery,
TBody,
TParams
> = {
req: TsRestRequest<TRoute>;
} & (TQuery extends undefined ? WithoutQuery : WithQuery<TQuery>) &
(TBody extends undefined ? WithoutBody : WithBody<TBody>) &
(TParams extends undefined ? WithoutParams : WithParams<TParams>);

View file

@ -1,6 +1,6 @@
import cors from "cors";
import helmet from "helmet";
import { addApiRoutes } from "./api/routes";
import addApiRoutes from "./api/routes";
import express, { urlencoded, json } from "express";
import contextMiddleware from "./middlewares/context";
import errorHandlingMiddleware from "./middlewares/error";

View file

@ -1,7 +1,6 @@
import { Collection, UpdateResult } from "mongodb";
import { UpdateResult } from "mongodb";
import * as db from "../init/db";
import _ from "lodash";
import { Config, PartialConfig } from "shared/schemas/config";
const configLegacyProperties = [
"swapEscAndTab",
@ -23,19 +22,9 @@ const configLegacyProperties = [
"enableAds",
];
type DBConfig = {
_id: ObjectId;
uid: string;
config: PartialConfig;
};
// Export for use in tests
export const getConfigCollection = (): Collection<DBConfig> =>
db.collection<DBConfig>("configs");
export async function saveConfig(
uid: string,
config: Partial<Config>
config: SharedTypes.Config
): Promise<UpdateResult> {
const configChanges = _.mapKeys(config, (_value, key) => `config.${key}`);
@ -43,18 +32,24 @@ export async function saveConfig(
_.map(configLegacyProperties, (key) => [`config.${key}`, ""])
) as Record<string, "">;
return await getConfigCollection().updateOne(
{ uid },
{ $set: configChanges, $unset: unset },
{ upsert: true }
);
return await db
.collection<SharedTypes.Config>("configs")
.updateOne(
{ uid },
{ $set: configChanges, $unset: unset },
{ upsert: true }
);
}
export async function getConfig(uid: string): Promise<DBConfig | null> {
const config = await getConfigCollection().findOne({ uid });
export async function getConfig(
uid: string
): Promise<SharedTypes.Config | null> {
const config = await db
.collection<SharedTypes.Config>("configs")
.findOne({ uid });
return config;
}
export async function deleteConfig(uid: string): Promise<void> {
await getConfigCollection().deleteOne({ uid });
await db.collection<SharedTypes.Config>("configs").deleteOne({ uid });
}

View file

@ -9,12 +9,17 @@ import {
incrementAuth,
recordAuthTime,
recordRequestCountry,
// recordRequestForUid,
} from "../utils/prometheus";
import crypto from "crypto";
import { performance } from "perf_hooks";
import { TsRestRequestHandler } from "@ts-rest/express";
import { AppRoute, AppRouter } from "@ts-rest/core";
import { RequestAuthenticationOptions } from "shared/schemas/api";
type RequestAuthenticationOptions = {
isPublic?: boolean;
acceptApeKeys?: boolean;
requireFreshToken?: boolean;
noCache?: boolean;
};
const DEFAULT_OPTIONS: RequestAuthenticationOptions = {
isPublic: false,
@ -22,28 +27,7 @@ const DEFAULT_OPTIONS: RequestAuthenticationOptions = {
requireFreshToken: false,
};
/**
* Authenticate request based on the auth settings of the route.
* By default a Bearer token with user authentication is required.
* @returns
*/
export function authenticateTsRestRequest<
T extends AppRouter | AppRoute
>(): TsRestRequestHandler<T> {
return async (
req: MonkeyTypes.RequestTsRest,
_res: Response,
next: NextFunction
): Promise<void> => {
const options = {
...DEFAULT_OPTIONS,
...(req.tsRestRoute["metadata"]?.["authenticationOptions"] ?? {}),
};
return _authenticateRequestInternal(req, _res, next, options);
};
}
export function authenticateRequest(authOptions = DEFAULT_OPTIONS): Handler {
function authenticateRequest(authOptions = DEFAULT_OPTIONS): Handler {
const options = {
...DEFAULT_OPTIONS,
...authOptions,
@ -54,78 +38,69 @@ export function authenticateRequest(authOptions = DEFAULT_OPTIONS): Handler {
_res: Response,
next: NextFunction
): Promise<void> => {
return _authenticateRequestInternal(req, _res, next, options);
};
}
const startTime = performance.now();
let token: MonkeyTypes.DecodedToken;
let authType = "None";
async function _authenticateRequestInternal(
req: MonkeyTypes.Request | MonkeyTypes.RequestTsRest,
_res: Response,
next: NextFunction,
options: RequestAuthenticationOptions
): Promise<void> {
const startTime = performance.now();
let token: MonkeyTypes.DecodedToken;
let authType = "None";
const { authorization: authHeader } = req.headers;
const { authorization: authHeader } = req.headers;
try {
if (authHeader !== undefined && authHeader !== "") {
token = await authenticateWithAuthHeader(
authHeader,
req.ctx.configuration,
options
);
} else if (options.isPublic === true) {
token = {
type: "None",
uid: "",
email: "",
};
} else {
throw new MonkeyError(
401,
"Unauthorized",
`endpoint: ${req.baseUrl} no authorization header found`
);
}
try {
if (authHeader !== undefined && authHeader !== "") {
token = await authenticateWithAuthHeader(
authHeader,
req.ctx.configuration,
options
);
} else if (options.isPublic === true) {
token = {
type: "None",
uid: "",
email: "",
incrementAuth(token.type);
req.ctx = {
...req.ctx,
decodedToken: token,
};
} else {
throw new MonkeyError(
401,
"Unauthorized",
`endpoint: ${req.baseUrl} no authorization header found`
} catch (error) {
authType = authHeader?.split(" ")[0] ?? "None";
recordAuthTime(
authType,
"failure",
Math.round(performance.now() - startTime),
req
);
return next(error);
}
incrementAuth(token.type);
req.ctx = {
...req.ctx,
decodedToken: token,
};
} catch (error) {
authType = authHeader?.split(" ")[0] ?? "None";
recordAuthTime(
authType,
"failure",
token.type,
"success",
Math.round(performance.now() - startTime),
req
);
return next(error);
}
recordAuthTime(
token.type,
"success",
Math.round(performance.now() - startTime),
req
);
const country = req.headers["cf-ipcountry"] as string;
if (country) {
recordRequestCountry(country, req as MonkeyTypes.Request);
}
const country = req.headers["cf-ipcountry"] as string;
if (country) {
recordRequestCountry(country, req);
}
// if (req.method !== "OPTIONS" && req?.ctx?.decodedToken?.uid) {
// recordRequestForUid(req.ctx.decodedToken.uid);
// }
// if (req.method !== "OPTIONS" && req?.ctx?.decodedToken?.uid) {
// recordRequestForUid(req.ctx.decodedToken.uid);
// }
next();
next();
};
}
async function authenticateWithAuthHeader(
@ -133,8 +108,24 @@ async function authenticateWithAuthHeader(
configuration: SharedTypes.Configuration,
options: RequestAuthenticationOptions
): Promise<MonkeyTypes.DecodedToken> {
if (authHeader === undefined || authHeader === "") {
throw new MonkeyError(
401,
"Missing authentication header",
"authenticateWithAuthHeader"
);
}
const [authScheme, token] = authHeader.split(" ");
if (authScheme === undefined) {
throw new MonkeyError(
401,
"Missing authentication scheme",
"authenticateWithAuthHeader"
);
}
if (token === undefined) {
throw new MonkeyError(
401,
@ -143,7 +134,7 @@ async function authenticateWithAuthHeader(
);
}
const normalizedAuthScheme = authScheme?.trim();
const normalizedAuthScheme = authScheme.trim();
switch (normalizedAuthScheme) {
case "Bearer":
@ -306,7 +297,7 @@ async function authenticateWithUid(
};
}
export function authenticateGithubWebhook(): Handler {
function authenticateGithubWebhook(): Handler {
return async (
req: MonkeyTypes.Request,
_res: Response,
@ -346,3 +337,5 @@ export function authenticateGithubWebhook(): Handler {
next();
};
}
export { authenticateRequest, authenticateGithubWebhook };

View file

@ -120,12 +120,6 @@ export const configGet = rateLimit({
keyGenerator: getKeyWithUid,
handler: customHandler,
});
export const configDelete = rateLimit({
windowMs: ONE_HOUR_MS,
max: 120 * REQUEST_MULTIPLIER,
keyGenerator: getKeyWithUid,
handler: customHandler,
});
// Leaderboards Routing
export const leaderboardsGet = rateLimit({

View file

@ -1,19 +1,15 @@
type ObjectId = import("mongodb").ObjectId;
type ExpressRequest = import("express").Request;
/* eslint-disable @typescript-eslint/no-explicit-any */
type TsRestRequest = import("@ts-rest/express").TsRestRequest<any>;
/* eslint-enable @typescript-eslint/no-explicit-any */
type AppRoute = import("@ts-rest/core").AppRoute;
type AppRouter = import("@ts-rest/core").AppRouter;
declare namespace MonkeyTypes {
export type DecodedToken = {
type DecodedToken = {
type: "Bearer" | "ApeKey" | "None";
uid: string;
email: string;
};
export type Context = {
type Context = {
configuration: SharedTypes.Configuration;
decodedToken: DecodedToken;
};
@ -22,18 +18,6 @@ declare namespace MonkeyTypes {
ctx: Readonly<Context>;
} & ExpressRequest;
type Request2<TQuery = undefined, TBody = undefined, TParams = undefined> = {
query: Readonly<TQuery>;
body: Readonly<TBody>;
params: Readonly<TParams>;
ctx: Readonly<Context>;
raw: Readonly<TsRestRequest>;
};
type RequestTsRest = {
ctx: Readonly<Context>;
} & TsRestRequest;
type DBUser = Omit<
SharedTypes.User,
| "resultFilterPresets"

View file

@ -176,6 +176,7 @@ export class DailyLeaderboard {
const { leaderboardScoresKey, leaderboardResultsKey } =
this.getTodaysLeaderboardKeys();
// @ts-expect-error
const [[, rank], [, count], [, result], [, minScore]] = await connection
.multi()

View file

@ -1,8 +1,7 @@
import { v4 as uuidv4 } from "uuid";
import { isDevEnvironment } from "./misc";
import { MonkeyServerErrorType } from "shared/schemas/api";
class MonkeyError extends Error implements MonkeyServerErrorType {
class MonkeyError extends Error {
status: number;
errorId: string;
uid?: string;

View file

@ -1,4 +1,4 @@
import _, { omit } from "lodash";
import _ from "lodash";
import uaparser from "ua-parser-js";
//todo split this file into smaller util files (grouped by functionality)
@ -306,13 +306,3 @@ export function stringToNumberOrDefault(
export function isDevEnvironment(): boolean {
return process.env["MODE"] === "dev";
}
export function replaceObjectId<T extends { _id: ObjectId }>(
data: T
): T & { _id: string } {
const result = {
_id: data._id.toString(),
...omit(data, "_id"),
} as T & { _id: string };
return result;
}

View file

@ -1,10 +1,6 @@
import { Response } from "express";
import { MonkeyResponseType } from "shared/schemas/api";
import { isCustomCode } from "../constants/monkey-status-codes";
export type MonkeyDataAware<T> = {
data: T | null;
};
//TODO FIX ANYS
export class MonkeyResponse {
@ -40,15 +36,3 @@ export function handleMonkeyResponse(
res.json({ message, data });
}
export class MonkeyResponse2<T = null>
implements MonkeyResponseType, MonkeyDataAware<T>
{
public message: string;
public data: T | null;
constructor(message: string, data: T | null = null) {
this.message = message;
this.data = data;
}
}

View file

@ -212,7 +212,7 @@ export function recordAuthTime(
type: string,
status: "success" | "failure",
time: number,
req: MonkeyTypes.Request | MonkeyTypes.RequestTsRest
req: MonkeyTypes.Request
): void {
const reqPath = req.baseUrl + req.route.path;
@ -234,7 +234,7 @@ const requestCountry = new Counter({
export function recordRequestCountry(
country: string,
req: MonkeyTypes.Request | MonkeyTypes.RequestTsRest
req: MonkeyTypes.Request
): void {
const reqPath = req.baseUrl + req.route.path;

View file

@ -17,13 +17,20 @@
"noImplicitReturns": true,
"noPropertyAccessFromIndexSignature": true,
"noFallthroughCasesInSwitch": true,
"forceConsistentCasingInFileNames": true
"forceConsistentCasingInFileNames": true,
"paths": {
"@shared/*": ["../shared-types/*"]
}
},
"ts-node": {
"files": true
},
"files": ["./src/types/types.d.ts"],
"include": ["./src/**/*", "../shared/**/*.d.ts"],
"files": [
"./src/types/types.d.ts",
"../shared-types/types.d.ts",
"../shared-types/config.d.ts"
],
"include": ["./src/**/*", "../shared-types/**/*.d.ts"],
"exclude": [
"node_modules",
"build",

View file

@ -3,7 +3,5 @@ cd .\frontend
call npm ci
cd ..\backend
call npm ci
cd ..\shared
call npm ci
cd ..\
PAUSE

View file

@ -1,5 +1,4 @@
npm ci &
cd ./frontend && npm ci &
cd ./backend && npm ci &
cd ./shared && npm ci &
wait

View file

@ -1,544 +0,0 @@
import * as Config from "../../src/ts/config";
import * as ConfigValidation from "../../src/ts/config-validation";
import { CustomThemeColors } from "shared/schemas/config";
import { randomBytes } from "crypto";
describe("Config", () => {
const asyncValidationMock = vi.spyOn(
ConfigValidation,
"isConfigValueValidAsync"
);
beforeEach(() => {
asyncValidationMock.mockResolvedValue(true);
});
afterEach(() => {
asyncValidationMock.mockReset();
});
it("setMode", () => {
expect(Config.setMode("zen")).toBe(true);
expect(Config.setMode("invalid" as any)).toBe(false);
});
it("setPlaySoundOnError", () => {
expect(Config.setPlaySoundOnError("off")).toBe(true);
expect(Config.setPlaySoundOnError("1")).toBe(true);
expect(Config.setPlaySoundOnError("invalid" as any)).toBe(false);
});
it("setPlaySoundOnClick", () => {
expect(Config.setPlaySoundOnClick("off")).toBe(true);
expect(Config.setPlaySoundOnClick("15")).toBe(true);
expect(Config.setPlaySoundOnClick("invalid" as any)).toBe(false);
});
it("setSoundVolume", () => {
expect(Config.setSoundVolume("0.1")).toBe(true);
expect(Config.setSoundVolume("1.0")).toBe(true);
expect(Config.setSoundVolume("invalid" as any)).toBe(false);
});
it("setDifficulty", () => {
expect(Config.setDifficulty("expert")).toBe(true);
expect(Config.setDifficulty("invalid" as any)).toBe(false);
});
it("setAccountChart", () => {
expect(Config.setAccountChart(["on", "off", "off", "on"])).toBe(true);
//arrays not having 4 values will get [on, on, on, on] as default
expect(Config.setAccountChart(["on", "off"] as any)).toBe(true);
expect(Config.setAccountChart(["on", "off", "on", "true"] as any)).toBe(
false
);
});
it("setAccountChartResults", () => {
expect(Config.setAccountChartResults(true)).toBe(true);
expect(Config.setAccountChartResults("on" as any)).toBe(false);
});
it("setStopOnError", () => {
expect(Config.setStopOnError("off")).toBe(true);
expect(Config.setStopOnError("word")).toBe(true);
expect(Config.setStopOnError("invalid" as any)).toBe(false);
});
it("setTypingSpeedUnit", () => {
expect(Config.setTypingSpeedUnit("wpm")).toBe(true);
expect(Config.setTypingSpeedUnit("cpm")).toBe(true);
expect(Config.setTypingSpeedUnit("invalid" as any)).toBe(false);
});
it("setPaceCaret", () => {
expect(Config.setPaceCaret("average")).toBe(true);
expect(Config.setPaceCaret("last")).toBe(true);
expect(Config.setPaceCaret("invalid" as any)).toBe(false);
});
it("setMinWpm", () => {
expect(Config.setMinWpm("custom")).toBe(true);
expect(Config.setMinWpm("off")).toBe(true);
expect(Config.setMinWpm("invalid" as any)).toBe(false);
});
it("setMinAcc", () => {
expect(Config.setMinAcc("custom")).toBe(true);
expect(Config.setMinAcc("off")).toBe(true);
expect(Config.setMinAcc("invalid" as any)).toBe(false);
});
it("setMinBurst", () => {
expect(Config.setMinBurst("fixed")).toBe(true);
expect(Config.setMinBurst("off")).toBe(true);
expect(Config.setMinBurst("invalid" as any)).toBe(false);
});
it("setSingleListCommandLine", () => {
expect(Config.setSingleListCommandLine("on")).toBe(true);
expect(Config.setSingleListCommandLine("manual")).toBe(true);
expect(Config.setSingleListCommandLine("invalid" as any)).toBe(false);
});
it("setAds", () => {
expect(Config.setAds("on")).toBe(true);
expect(Config.setAds("sellout")).toBe(true);
expect(Config.setAds("invalid" as any)).toBe(false);
});
it("setRepeatQuotes", () => {
expect(Config.setRepeatQuotes("off")).toBe(true);
expect(Config.setRepeatQuotes("typing")).toBe(true);
expect(Config.setRepeatQuotes("invalid" as any)).toBe(false);
});
it("setOppositeShiftMode", () => {
expect(Config.setOppositeShiftMode("on")).toBe(true);
expect(Config.setOppositeShiftMode("keymap")).toBe(true);
expect(Config.setOppositeShiftMode("invalid" as any)).toBe(false);
});
it("setCaretStyle", () => {
expect(Config.setCaretStyle("banana")).toBe(true);
expect(Config.setCaretStyle("block")).toBe(true);
expect(Config.setCaretStyle("invalid" as any)).toBe(false);
});
it("setPaceCaretStyle", () => {
expect(Config.setPaceCaretStyle("carrot")).toBe(true);
expect(Config.setPaceCaretStyle("outline")).toBe(true);
expect(Config.setPaceCaretStyle("invalid" as any)).toBe(false);
});
it("setShowAverage", () => {
expect(Config.setShowAverage("acc")).toBe(true);
expect(Config.setShowAverage("both")).toBe(true);
expect(Config.setShowAverage("invalid" as any)).toBe(false);
});
it("setHighlightMode", () => {
expect(Config.setHighlightMode("letter")).toBe(true);
expect(Config.setHighlightMode("next_three_words")).toBe(true);
expect(Config.setHighlightMode("invalid" as any)).toBe(false);
});
it("setTapeMode", () => {
expect(Config.setTapeMode("letter")).toBe(true);
expect(Config.setTapeMode("off")).toBe(true);
expect(Config.setTapeMode("invalid" as any)).toBe(false);
});
it("setTimerStyle", () => {
expect(Config.setTimerStyle("bar")).toBe(true);
expect(Config.setTimerStyle("mini")).toBe(true);
expect(Config.setTimerStyle("invalid" as any)).toBe(false);
});
it("setLiveSpeedStyle", () => {
expect(Config.setLiveSpeedStyle("text")).toBe(true);
expect(Config.setLiveSpeedStyle("mini")).toBe(true);
expect(Config.setLiveSpeedStyle("invalid" as any)).toBe(false);
});
it("setLiveAccStyle", () => {
expect(Config.setLiveAccStyle("text")).toBe(true);
expect(Config.setLiveAccStyle("mini")).toBe(true);
expect(Config.setLiveAccStyle("invalid" as any)).toBe(false);
});
it("setLiveBurstStyle", () => {
expect(Config.setLiveBurstStyle("text")).toBe(true);
expect(Config.setLiveBurstStyle("mini")).toBe(true);
expect(Config.setLiveBurstStyle("invalid" as any)).toBe(false);
});
it("setTimerColor", () => {
expect(Config.setTimerColor("text")).toBe(true);
expect(Config.setTimerColor("sub")).toBe(true);
expect(Config.setTimerColor("invalid" as any)).toBe(false);
});
it("setTimerOpacity", () => {
expect(Config.setTimerOpacity("1")).toBe(true);
expect(Config.setTimerOpacity("0.5")).toBe(true);
expect(Config.setTimerOpacity("invalid" as any)).toBe(false);
});
it("setSmoothCaret", () => {
expect(Config.setSmoothCaret("fast")).toBe(true);
expect(Config.setSmoothCaret("medium")).toBe(true);
expect(Config.setSmoothCaret("invalid" as any)).toBe(false);
});
it("setQuickRestartMode", () => {
expect(Config.setQuickRestartMode("off")).toBe(true);
expect(Config.setQuickRestartMode("tab")).toBe(true);
expect(Config.setQuickRestartMode("invalid" as any)).toBe(false);
});
it("setConfidenceMode", () => {
expect(Config.setConfidenceMode("max")).toBe(true);
expect(Config.setConfidenceMode("on")).toBe(true);
expect(Config.setConfidenceMode("invalid" as any)).toBe(false);
});
it("setIndicateTypos", () => {
expect(Config.setIndicateTypos("below")).toBe(true);
expect(Config.setIndicateTypos("off")).toBe(true);
expect(Config.setIndicateTypos("invalid" as any)).toBe(false);
});
it("setRandomTheme", () => {
expect(Config.setRandomTheme("fav")).toBe(true);
expect(Config.setRandomTheme("off")).toBe(true);
expect(Config.setRandomTheme("invalid" as any)).toBe(false);
});
it("setKeymapMode", () => {
expect(Config.setKeymapMode("next")).toBe(true);
expect(Config.setKeymapMode("react")).toBe(true);
expect(Config.setKeymapMode("invalid" as any)).toBe(false);
});
it("setKeymapLegendStyle", () => {
expect(Config.setKeymapLegendStyle("blank")).toBe(true);
expect(Config.setKeymapLegendStyle("lowercase")).toBe(true);
expect(Config.setKeymapLegendStyle("invalid" as any)).toBe(false);
});
it("setKeymapStyle", () => {
expect(Config.setKeymapStyle("matrix")).toBe(true);
expect(Config.setKeymapStyle("split")).toBe(true);
expect(Config.setKeymapStyle("invalid" as any)).toBe(false);
});
it("setKeymapShowTopRow", () => {
expect(Config.setKeymapShowTopRow("always")).toBe(true);
expect(Config.setKeymapShowTopRow("never")).toBe(true);
expect(Config.setKeymapShowTopRow("invalid" as any)).toBe(false);
});
it("setCustomBackgroundSize", () => {
expect(Config.setCustomBackgroundSize("contain")).toBe(true);
expect(Config.setCustomBackgroundSize("cover")).toBe(true);
expect(Config.setCustomBackgroundSize("invalid" as any)).toBe(false);
});
it("setCustomBackgroundFilter", () => {
expect(Config.setCustomBackgroundFilter([0, 1, 2, 3])).toBe(true);
//gets converted
expect(Config.setCustomBackgroundFilter([0, 1, 2, 3, 4] as any)).toBe(true);
expect(Config.setCustomBackgroundFilter([] as any)).toBe(false);
expect(Config.setCustomBackgroundFilter(["invalid"] as any)).toBe(false);
expect(Config.setCustomBackgroundFilter([1, 2, 3, 4, 5, 6] as any)).toBe(
false
);
});
it("setMonkeyPowerLevel", () => {
expect(Config.setMonkeyPowerLevel("2")).toBe(true);
expect(Config.setMonkeyPowerLevel("off")).toBe(true);
expect(Config.setMonkeyPowerLevel("invalid" as any)).toBe(false);
});
it("setCustomThemeColors", () => {
expect(Config.setCustomThemeColors(customThemeColors(10))).toBe(true);
//gets converted
expect(Config.setCustomThemeColors(customThemeColors(9))).toBe(true);
expect(Config.setCustomThemeColors([] as any)).toBe(false);
expect(Config.setCustomThemeColors(["invalid"] as any)).toBe(false);
expect(Config.setCustomThemeColors(customThemeColors(5))).toBe(false);
expect(Config.setCustomThemeColors(customThemeColors(11))).toBe(false);
const tenColors = customThemeColors(10);
tenColors[0] = "black";
expect(Config.setCustomThemeColors(tenColors)).toBe(false);
tenColors[0] = "#123456";
expect(Config.setCustomThemeColors(tenColors)).toBe(true);
tenColors[0] = "#1234";
expect(Config.setCustomThemeColors(tenColors)).toBe(false);
});
it("setNumbers", () => {
testBoolean(Config.setNumbers);
});
it("setPunctuation", () => {
testBoolean(Config.setPunctuation);
});
it("setBlindMode", () => {
testBoolean(Config.setBlindMode);
});
it("setAccountChartResults", () => {
testBoolean(Config.setAccountChartResults);
});
it("setAccountChartAccuracy", () => {
testBoolean(Config.setAccountChartAccuracy);
});
it("setAccountChartAvg10", () => {
testBoolean(Config.setAccountChartAvg10);
});
it("setAccountChartAvg100", () => {
testBoolean(Config.setAccountChartAvg100);
});
it("setAlwaysShowDecimalPlaces", () => {
testBoolean(Config.setAlwaysShowDecimalPlaces);
});
it("setShowOutOfFocusWarning", () => {
testBoolean(Config.setShowOutOfFocusWarning);
});
it("setAlwaysShowWordsHistory", () => {
testBoolean(Config.setAlwaysShowWordsHistory);
});
it("setCapsLockWarning", () => {
testBoolean(Config.setCapsLockWarning);
});
it("setShowAllLines", () => {
testBoolean(Config.setShowAllLines);
});
it("setQuickEnd", () => {
testBoolean(Config.setQuickEnd);
});
it("setFlipTestColors", () => {
testBoolean(Config.setFlipTestColors);
});
it("setColorfulMode", () => {
testBoolean(Config.setColorfulMode);
});
it("setStrictSpace", () => {
testBoolean(Config.setStrictSpace);
});
it("setHideExtraLetters", () => {
testBoolean(Config.setHideExtraLetters);
});
it("setKeyTips", () => {
testBoolean(Config.setKeyTips);
});
it("setStartGraphsAtZero", () => {
testBoolean(Config.setStartGraphsAtZero);
});
it("setSmoothLineScroll", () => {
testBoolean(Config.setSmoothLineScroll);
});
it("setFreedomMode", () => {
testBoolean(Config.setFreedomMode);
});
it("setAutoSwitchTheme", () => {
testBoolean(Config.setAutoSwitchTheme);
});
it("setCustomTheme", () => {
testBoolean(Config.setCustomTheme);
});
it("setBritishEnglish", () => {
testBoolean(Config.setBritishEnglish);
});
it("setLazyMode", () => {
testBoolean(Config.setLazyMode);
});
it("setMonkey", () => {
testBoolean(Config.setMonkey);
});
it("setBurstHeatmap", () => {
testBoolean(Config.setBurstHeatmap);
});
it("setRepeatedPace", () => {
testBoolean(Config.setRepeatedPace);
});
it("setFavThemes", () => {
expect(Config.setFavThemes([])).toBe(true);
expect(Config.setFavThemes(["test"])).toBe(true);
expect(Config.setFavThemes([stringOfLength(50)])).toBe(true);
expect(Config.setFavThemes("invalid" as any)).toBe(false);
expect(Config.setFavThemes([stringOfLength(51)])).toBe(false);
});
it("setFunbox", () => {
expect(Config.setFunbox("one")).toBe(true);
expect(Config.setFunbox("one#two")).toBe(true);
expect(Config.setFunbox("one#two#")).toBe(true);
expect(Config.setFunbox(stringOfLength(100))).toBe(true);
expect(Config.setFunbox(stringOfLength(101))).toBe(false);
});
it("setPaceCaretCustomSpeed", () => {
expect(Config.setPaceCaretCustomSpeed(0)).toBe(true);
expect(Config.setPaceCaretCustomSpeed(1)).toBe(true);
expect(Config.setPaceCaretCustomSpeed(11.11)).toBe(true);
expect(Config.setPaceCaretCustomSpeed("invalid" as any)).toBe(false);
expect(Config.setPaceCaretCustomSpeed(-1)).toBe(false);
});
it("setMinWpmCustomSpeed", () => {
expect(Config.setMinWpmCustomSpeed(0)).toBe(true);
expect(Config.setMinWpmCustomSpeed(1)).toBe(true);
expect(Config.setMinWpmCustomSpeed(11.11)).toBe(true);
expect(Config.setMinWpmCustomSpeed("invalid" as any)).toBe(false);
expect(Config.setMinWpmCustomSpeed(-1)).toBe(false);
});
it("setMinAccCustom", () => {
expect(Config.setMinAccCustom(0)).toBe(true);
expect(Config.setMinAccCustom(1)).toBe(true);
expect(Config.setMinAccCustom(11.11)).toBe(true);
//gets converted
expect(Config.setMinAccCustom(120)).toBe(true);
expect(Config.setMinAccCustom("invalid" as any)).toBe(false);
expect(Config.setMinAccCustom(-1)).toBe(false);
});
it("setMinBurstCustomSpeed", () => {
expect(Config.setMinBurstCustomSpeed(0)).toBe(true);
expect(Config.setMinBurstCustomSpeed(1)).toBe(true);
expect(Config.setMinBurstCustomSpeed(11.11)).toBe(true);
expect(Config.setMinBurstCustomSpeed("invalid" as any)).toBe(false);
expect(Config.setMinBurstCustomSpeed(-1)).toBe(false);
});
it("setTimeConfig", () => {
expect(Config.setTimeConfig(0)).toBe(true);
expect(Config.setTimeConfig(1)).toBe(true);
//gets converted
expect(Config.setTimeConfig("invalid" as any)).toBe(true);
expect(Config.setTimeConfig(-1)).toBe(true);
expect(Config.setTimeConfig(11.11)).toBe(false);
});
it("setWordCount", () => {
expect(Config.setWordCount(0)).toBe(true);
expect(Config.setWordCount(1)).toBe(true);
//gets converted
expect(Config.setWordCount(-1)).toBe(true);
expect(Config.setWordCount("invalid" as any)).toBe(false);
expect(Config.setWordCount(11.11)).toBe(false);
});
it("setFontFamily", () => {
expect(Config.setFontFamily("Arial")).toBe(true);
expect(Config.setFontFamily("roboto_mono")).toBe(true);
expect(Config.setFontFamily("test_font")).toBe(true);
expect(Config.setFontFamily(stringOfLength(50))).toBe(true);
expect(Config.setFontFamily(stringOfLength(51))).toBe(false);
expect(Config.setFontFamily("test font")).toBe(false);
expect(Config.setFontFamily("test!font")).toBe(false);
});
it("setTheme", () => {
expect(Config.setTheme("serika")).toBe(true);
expect(Config.setTheme("serika_dark")).toBe(true);
expect(Config.setTheme(stringOfLength(50))).toBe(true);
expect(Config.setTheme("serika dark")).toBe(false);
expect(Config.setTheme("serika-dark")).toBe(false);
expect(Config.setTheme(stringOfLength(51))).toBe(false);
});
it("setThemeLight", () => {
expect(Config.setThemeLight("serika")).toBe(true);
expect(Config.setThemeLight("serika_dark")).toBe(true);
expect(Config.setThemeLight(stringOfLength(50))).toBe(true);
expect(Config.setThemeLight("serika dark")).toBe(false);
expect(Config.setThemeLight("serika-dark")).toBe(false);
expect(Config.setThemeLight(stringOfLength(51))).toBe(false);
});
it("setThemeDark", () => {
expect(Config.setThemeDark("serika")).toBe(true);
expect(Config.setThemeDark("serika_dark")).toBe(true);
expect(Config.setThemeDark(stringOfLength(50))).toBe(true);
expect(Config.setThemeDark("serika dark")).toBe(false);
expect(Config.setThemeDark("serika-dark")).toBe(false);
expect(Config.setThemeDark(stringOfLength(51))).toBe(false);
});
it("setLanguage", () => {
expect(Config.setLanguage("english")).toBe(true);
expect(Config.setLanguage("english_1k")).toBe(true);
expect(Config.setLanguage(stringOfLength(50))).toBe(true);
expect(Config.setLanguage("english 1k")).toBe(false);
expect(Config.setLanguage("english-1k")).toBe(false);
expect(Config.setLanguage(stringOfLength(51))).toBe(false);
});
it("setKeymapLayout", () => {
expect(Config.setKeymapLayout("overrideSync")).toBe(true);
expect(Config.setKeymapLayout("override_sync")).toBe(true);
expect(Config.setKeymapLayout("override sync")).toBe(true);
expect(Config.setKeymapLayout("override-sync!")).toBe(true);
expect(Config.setKeymapLayout(stringOfLength(50))).toBe(true);
expect(Config.setKeymapLayout(stringOfLength(51))).toBe(false);
});
it("setLayout", () => {
expect(Config.setLayout("semimak")).toBe(true);
expect(Config.setLayout("semi_mak")).toBe(true);
expect(Config.setLayout(stringOfLength(50))).toBe(true);
expect(Config.setLayout("semi mak")).toBe(false);
expect(Config.setLayout("semi-mak")).toBe(false);
expect(Config.setLayout(stringOfLength(51))).toBe(false);
});
it("setFontSize", () => {
expect(Config.setFontSize(1)).toBe(true);
//gets converted
expect(Config.setFontSize(-1)).toBe(true);
expect(Config.setFontSize("1" as any)).toBe(true);
expect(Config.setFontSize("125" as any)).toBe(true);
expect(Config.setFontSize("15" as any)).toBe(true);
expect(Config.setFontSize("2" as any)).toBe(true);
expect(Config.setFontSize("3" as any)).toBe(true);
expect(Config.setFontSize("4" as any)).toBe(true);
expect(Config.setFontSize(0)).toBe(false);
expect(Config.setFontSize("5" as any)).toBe(false);
expect(Config.setFontSize("invalid" as any)).toBe(false);
});
it("setMaxLineWidth", () => {
expect(Config.setMaxLineWidth(0)).toBe(true);
expect(Config.setMaxLineWidth(50)).toBe(true);
expect(Config.setMaxLineWidth(50.5)).toBe(true);
//gets converted
expect(Config.setMaxLineWidth(10)).toBe(true);
expect(Config.setMaxLineWidth(10_000)).toBe(true);
expect(Config.setMaxLineWidth("invalid" as any)).toBe(false);
});
it("setCustomBackground", () => {
expect(Config.setCustomBackground("http://example.com/test.png")).toBe(
true
);
expect(Config.setCustomBackground("https://www.example.com/test.gif")).toBe(
true
);
expect(Config.setCustomBackground("https://example.com/test.jpg")).toBe(
true
);
expect(Config.setCustomBackground("http://www.example.com/test.jpeg")).toBe(
true
);
//gets converted
expect(
Config.setCustomBackground(" http://example.com/test.png ")
).toBe(true);
expect(Config.setCustomBackground("http://www.example.com/test.webp")).toBe(
false
);
expect(
Config.setCustomBackground("http://www.example.com/test?test=foo&bar=baz")
).toBe(false);
expect(Config.setCustomBackground("invalid")).toBe(false);
});
it("setQuoteLength", () => {
expect(Config.setQuoteLength(0)).toBe(true);
expect(Config.setQuoteLength(-3)).toBe(true);
expect(Config.setQuoteLength(3)).toBe(true);
expect(Config.setQuoteLength(-4 as any)).toBe(false);
expect(Config.setQuoteLength(4 as any)).toBe(false);
expect(Config.setQuoteLength([0, -3, 2])).toBe(true);
expect(Config.setQuoteLength([-4 as any, 5 as any])).toBe(false);
});
});
function customThemeColors(n: number): CustomThemeColors {
return new Array(n).fill("#000") as CustomThemeColors;
}
function testBoolean(fn: (val: boolean) => boolean): void {
expect(fn(true)).toBe(true);
expect(fn(false)).toBe(true);
expect(fn("true" as any)).toBe(false);
expect(fn("0" as any)).toBe(false);
expect(fn("invalid" as any)).toBe(false);
}
function stringOfLength(length: number): string {
return randomBytes(Math.ceil(length / 2))
.toString("hex")
.slice(0, length);
}

View file

@ -20,5 +20,9 @@
"files": true
},
"files": ["../src/ts/types/types.d.ts", "vitest.d.ts"],
"include": ["./**/*.spec.ts", "./setup-tests.ts", "../../shared/**/*.d.ts"]
"include": [
"./**/*.spec.ts",
"./setup-tests.ts",
"../../shared-types/**/*.d.ts"
]
}

File diff suppressed because it is too large Load diff

View file

@ -64,7 +64,6 @@
},
"dependencies": {
"@date-fns/utc": "1.2.0",
"@ts-rest/core": "3.45.2",
"axios": "1.6.4",
"canvas-confetti": "1.5.1",
"chart.js": "3.7.1",
@ -84,11 +83,9 @@
"konami": "1.6.3",
"lz-ts": "1.1.2",
"object-hash": "3.0.0",
"shared": "file:../shared",
"slim-select": "2.8.1",
"stemmer": "2.0.0",
"throttle-debounce": "3.0.1",
"zod": "3.23.8"
"throttle-debounce": "3.0.1"
},
"overrides": {
"madge": {

View file

@ -1,74 +0,0 @@
import { AppRouter, initClient, type ApiFetcherArgs } from "@ts-rest/core";
import { Method } from "axios";
import { getIdToken } from "firebase/auth";
import { envConfig } from "../../constants/env-config";
import { getAuthenticatedUser, isAuthenticated } from "../../firebase";
import type { EndpointMetadata } from "shared/schemas/api";
function buildApi(timeout: number): (args: ApiFetcherArgs) => Promise<{
status: number;
body: unknown;
headers: Headers;
}> {
return async (request: ApiFetcherArgs) => {
const isPublicEndpoint =
(request.route.metadata as EndpointMetadata | undefined)
?.authenticationOptions?.isPublic ?? false;
try {
const headers: HeadersInit = {
...request.headers,
"X-Client-Version": envConfig.clientVersion,
};
if (!isPublicEndpoint) {
const token = isAuthenticated()
? await getIdToken(getAuthenticatedUser())
: "";
headers["Authorization"] = `Bearer ${token}`;
}
const response = await fetch(request.path, {
signal: AbortSignal.timeout(timeout),
method: request.method as Method,
headers,
body: request.body,
});
const body = await response.json();
if (response.status >= 400) {
console.error(`${request.method} ${request.path} failed`, {
status: response.status,
...body,
});
}
return {
status: response.status,
body,
headers: response.headers ?? new Headers(),
};
} catch (e: Error | unknown) {
return {
status: 500,
body: { message: e },
headers: new Headers(),
};
}
};
}
/* eslint-disable @typescript-eslint/explicit-function-return-type */
export function buildClient<T extends AppRouter>(
contract: T,
baseUrl: string,
timeout: number = 10_000
) {
return initClient(contract, {
baseUrl: baseUrl,
jsonQuery: true,
api: buildApi(timeout),
baseHeaders: {
Accept: "application/json",
},
});
}
/* eslint-enable @typescript-eslint/explicit-function-return-type */

View file

@ -0,0 +1,17 @@
const BASE_PATH = "/configs";
export default class Configs {
constructor(private httpClient: Ape.HttpClient) {
this.httpClient = httpClient;
}
async get(): Ape.EndpointResponse<Ape.Configs.GetConfig> {
return await this.httpClient.get(BASE_PATH);
}
async save(
config: SharedTypes.Config
): Ape.EndpointResponse<Ape.Configs.PostConfig> {
return await this.httpClient.patch(BASE_PATH, { payload: { config } });
}
}

View file

@ -1,15 +1,17 @@
import ApeKeys from "./ape-keys";
import Configuration from "./configuration";
import Configs from "./configs";
import Leaderboards from "./leaderboards";
import Presets from "./presets";
import Psas from "./psas";
import Public from "./public";
import Quotes from "./quotes";
import Results from "./results";
import Users from "./users";
import ApeKeys from "./ape-keys";
import Public from "./public";
import Configuration from "./configuration";
import Dev from "./dev";
export default {
Configs,
Leaderboards,
Presets,
Psas,

View file

@ -1,19 +1,17 @@
import endpoints from "./endpoints";
import { buildHttpClient } from "./adapters/axios-adapter";
import { envConfig } from "../constants/env-config";
import { buildClient } from "./adapters/ts-rest-adapter";
import { configsContract } from "shared/contracts/configs";
const API_PATH = "";
const BASE_URL = envConfig.backendUrl;
const API_URL = `${BASE_URL}${API_PATH}`;
const httpClient = buildHttpClient(API_URL, 10_000);
const httpClient = buildHttpClient(API_URL, 10000);
// API Endpoints
const Ape = {
users: new endpoints.Users(httpClient),
configs: buildClient(configsContract, BASE_URL, 10_000),
configs: new endpoints.Configs(httpClient),
results: new endpoints.Results(httpClient),
psas: new endpoints.Psas(httpClient),
quotes: new endpoints.Quotes(httpClient),

10
frontend/src/ts/ape/types/configs.d.ts vendored Normal file
View file

@ -0,0 +1,10 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
// for some reason when using the dot notaion, the types are not being recognized as used
declare namespace Ape.Configs {
type GetConfig = {
_id: string;
uid: string;
config: Partial<SharedTypes.Config>;
};
type PostConfig = null;
}

View file

@ -1,7 +1,18 @@
import * as Misc from "./utils/misc";
import * as JSONData from "./utils/json-data";
import * as Notifications from "./elements/notifications";
import { ZodSchema, z } from "zod";
type PossibleType =
| "string"
| "number"
| "numberArray"
| "boolean"
| "undefined"
| "null"
| "stringArray"
| "layoutfluid"
| string[]
| number[];
type PossibleTypeAsync = "layoutfluid";
@ -27,19 +38,70 @@ function invalid(key: string, val: unknown, customMessage?: string): void {
console.error(`Invalid value key ${key} value ${val} type ${typeof val}`);
}
export function isConfigValueValid<T>(
function isArray(val: unknown): val is unknown[] {
return val instanceof Array;
}
export function isConfigValueValid(
key: string,
val: T,
schema: ZodSchema<T>
val: unknown,
possibleTypes: PossibleType[]
): boolean {
const isValid = schema.safeParse(val).success;
if (!isValid) invalid(key, val, undefined);
let isValid = false;
// might be used in the future
// eslint-disable-next-line
let customMessage: string | undefined = undefined;
for (const possibleType of possibleTypes) {
switch (possibleType) {
case "boolean":
if (typeof val === "boolean") isValid = true;
break;
case "null":
if (val === null) isValid = true;
break;
case "number":
if (typeof val === "number" && !isNaN(val)) isValid = true;
break;
case "numberArray":
if (
isArray(val) &&
val.every((v) => typeof v === "number" && !isNaN(v))
) {
isValid = true;
}
break;
case "string":
if (typeof val === "string") isValid = true;
break;
case "stringArray":
if (isArray(val) && val.every((v) => typeof v === "string")) {
isValid = true;
}
break;
case "undefined":
if (typeof val === "undefined" || val === undefined) isValid = true;
break;
default:
if (isArray(possibleType)) {
if (possibleType.includes(val as never)) isValid = true;
}
break;
}
}
if (!isValid) invalid(key, val, customMessage);
return isValid;
}
export function isConfigValueValidBoolean(key: string, val: boolean): boolean {
return isConfigValueValid(key, val, z.boolean());
}
export async function isConfigValueValidAsync(
key: string,

File diff suppressed because it is too large Load diff

View file

@ -1,5 +1,3 @@
import { Config } from "shared/schemas/config";
export default {
theme: "serika_dark",
themeLight: "serika",
@ -85,7 +83,7 @@ export default {
oppositeShiftMode: "off",
customBackground: "",
customBackgroundSize: "cover",
customBackgroundFilter: [0, 1, 1, 1],
customBackgroundFilter: [0, 1, 1, 1, 1],
customLayoutfluid: "qwerty#dvorak#colemak",
monkeyPowerLevel: "off",
minBurst: "off",
@ -96,4 +94,4 @@ export default {
showAverage: "off",
tapeMode: "off",
maxLineWidth: 0,
} as Config;
} as SharedTypes.Config;

View file

@ -77,7 +77,7 @@ export async function initSnapshot(): Promise<
if (configResponse.status !== 200) {
// eslint-disable-next-line @typescript-eslint/no-throw-literal
throw {
message: `${configResponse.body.message} (config)`,
message: `${configResponse.message} (config)`,
responseCode: configResponse.status,
};
}
@ -90,7 +90,7 @@ export async function initSnapshot(): Promise<
}
const userData = userResponse.data;
const configData = configResponse.body.data;
const configData = configResponse.data;
const presetsData = presetsResponse.data;
if (userData === null) {
@ -165,7 +165,7 @@ export async function initSnapshot(): Promise<
...DefaultConfig,
};
} else {
snap.config = mergeWithDefaultConfig(configData);
snap.config = mergeWithDefaultConfig(configData.config);
}
// if (ActivePage.get() === "loading") {
// LoadingPage.updateBar(67.5);
@ -892,18 +892,9 @@ export async function updateLbMemory<M extends SharedTypes.Config.Mode>(
export async function saveConfig(config: SharedTypes.Config): Promise<void> {
if (isAuthenticated()) {
const response = await Ape.configs.save({ body: config });
const response = await Ape.configs.save(config);
if (response.status !== 200) {
Notifications.add("Failed to save config: " + response.body.message, -1);
}
}
}
export async function resetConfig(): Promise<void> {
if (isAuthenticated()) {
const response = await Ape.configs.delete();
if (response.status !== 200) {
Notifications.add("Failed to reset config: " + response.body.message, -1);
Notifications.add("Failed to save config: " + response.message, -1);
}
}
}

View file

@ -145,7 +145,7 @@ function buildPbHtml(
if (pbData === undefined) throw new Error("No PB data found");
const date = new Date(pbData.timestamp);
if (pbData.timestamp !== undefined && pbData.timestamp > 0) {
if (pbData.timestamp) {
dateText = dateFormat(date, "dd MMM yyyy");
}

View file

@ -11,7 +11,6 @@ import * as DB from "../../db";
import * as ConfigEvent from "../../observables/config-event";
import { isAuthenticated } from "../../firebase";
import * as ActivePage from "../../states/active-page";
import { CustomThemeColors } from "shared/schemas/config";
function updateActiveButton(): void {
let activeThemeName = Config.theme;
@ -137,7 +136,6 @@ export async function refreshButtons(): Promise<void> {
customThemes.forEach((customTheme) => {
// const activeTheme =
// const activeTheme =git st
// Config.customThemeId === customTheme._id ? "active" : "";
const bgColor = customTheme.colors[0];
const mainColor = customTheme.colors[1];
@ -283,7 +281,7 @@ function saveCustomThemeColors(): void {
).attr("value") as string
);
}
UpdateConfig.setCustomThemeColors(newColors as CustomThemeColors);
UpdateConfig.setCustomThemeColors(newColors);
Notifications.add("Custom theme saved", 1);
}

View file

@ -33,7 +33,6 @@ import AnimatedModal, {
} from "../utils/animated-modal";
import { format as dateFormat } from "date-fns/format";
import { Attributes, buildTag } from "../utils/tag-builder";
import { CustomThemeColors } from "shared/schemas/config";
type CommonInput<TType, TValue> = {
type: TType;
@ -1706,7 +1705,7 @@ list.updateCustomTheme = new SimpleModal({
const newTheme = {
name: name.replaceAll(" ", "_"),
colors: newColors as CustomThemeColors,
colors: newColors,
};
const validation = await DB.editCustomTheme(customTheme._id, newTheme);
if (!validation) {
@ -1715,7 +1714,7 @@ list.updateCustomTheme = new SimpleModal({
message: "Failed to update custom theme",
};
}
UpdateConfig.setCustomThemeColors(newColors as CustomThemeColors);
UpdateConfig.setCustomThemeColors(newColors);
void ThemePicker.refreshButtons();
return {

View file

@ -544,7 +544,7 @@ async function fillContent(): Promise<void> {
histogramChartData.push(0);
}
}
(histogramChartData[bucket] as number)++;
histogramChartData[bucket]++;
let tt = 0;
if (

View file

@ -179,7 +179,7 @@ declare namespace MonkeyTypes {
type RawCustomTheme = {
name: string;
colors: SharedTypes.Config.CustomThemeColors;
colors: string[];
};
type CustomTheme = {

View file

@ -1,8 +1,9 @@
import { Config, PartialConfig } from "shared/schemas/config";
import DefaultConfig from "../constants/default-config";
import { typedKeys } from "./misc";
export function mergeWithDefaultConfig(config: PartialConfig): Config {
export function mergeWithDefaultConfig(
config: Partial<SharedTypes.Config>
): SharedTypes.Config {
const mergedConfig = {} as SharedTypes.Config;
for (const key of typedKeys(DefaultConfig)) {
const newValue =

View file

@ -31,8 +31,11 @@
"noPropertyAccessFromIndexSignature": true,
"allowUnusedLabels": false,
"allowUnreachableCode": false,
"skipLibCheck": false
"skipLibCheck": false,
"paths": {
"@shared/*": ["../shared-types/*"]
}
},
"include": ["./src/**/*.ts", "../shared/**/*.d.ts"],
"include": ["./src/**/*.ts", "../shared-types/**/*.d.ts"],
"exclude": ["node_modules", "build", "setup-tests.ts", "**/*.spec.ts"]
}

View file

@ -9,19 +9,19 @@
"path": "frontend"
},
{
"name": "shared",
"path": "shared"
"name": "shared-types",
"path": "shared-types"
},
{
"name": "root",
"path": "."
"path": "./"
}
],
"settings": {
"files.exclude": {
"frontend": true,
"backend": true,
"shared": true
"shared-types": true
},
"search.exclude": {
//defaults

80
shared-types/config.d.ts vendored Normal file
View file

@ -0,0 +1,80 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
// for some reason when using the dot notaion, the types are not being recognized as used
declare namespace SharedTypes.Config {
type SmoothCaret = "off" | "slow" | "medium" | "fast";
type QuickRestart = "off" | "esc" | "tab" | "enter";
type QuoteLength = -3 | -2 | -1 | 0 | 1 | 2 | 3;
type CaretStyle =
| "off"
| "default"
| "block"
| "outline"
| "underline"
| "carrot"
| "banana";
type Difficulty = "normal" | "expert" | "master";
type Mode = keyof PersonalBests;
type Mode2<M extends Mode> = M extends M ? keyof PersonalBests[M] : never;
type Mode2Custom<M extends Mode> = Mode2<M> | "custom";
type ConfidenceMode = "off" | "on" | "max";
type IndicateTypos = "off" | "below" | "replace";
type TimerStyle = "off" | "bar" | "text" | "mini";
type LiveSpeedAccBurstStyle = "off" | "text" | "mini";
type RandomTheme = "off" | "on" | "fav" | "light" | "dark" | "custom";
type TimerColor = "black" | "sub" | "text" | "main";
type TimerOpacity = "0.25" | "0.5" | "0.75" | "1";
type StopOnError = "off" | "word" | "letter";
type KeymapMode = "off" | "static" | "react" | "next";
type KeymapStyle =
| "staggered"
| "alice"
| "matrix"
| "split"
| "split_matrix"
| "steno"
| "steno_matrix";
type KeymapLegendStyle = "lowercase" | "uppercase" | "blank" | "dynamic";
type KeymapShowTopRow = "always" | "layout" | "never";
type SingleListCommandLine = "manual" | "on";
type PlaySoundOnClick =
| "off"
| "1"
| "2"
| "3"
| "4"
| "5"
| "6"
| "7"
| "8"
| "9"
| "10"
| "11"
| "12"
| "13"
| "14"
| "15";
type PlaySoundOnError = "off" | "1" | "2" | "3" | "4";
type SoundVolume = "0.1" | "0.5" | "1.0";
type PaceCaret = "off" | "average" | "pb" | "last" | "custom" | "daily";
type AccountChart = ["off" | "on", "off" | "on", "off" | "on", "off" | "on"];
type MinimumWordsPerMinute = "off" | "custom";
type HighlightMode =
| "off"
| "letter"
| "word"
| "next_word"
| "next_two_words"
| "next_three_words";
type TypingSpeedUnit = "wpm" | "cpm" | "wps" | "cps" | "wph";
type Ads = "off" | "result" | "on" | "sellout";
type MinimumAccuracy = "off" | "custom";
type RepeatQuotes = "off" | "typing";
type OppositeShiftMode = "off" | "on" | "keymap";
type CustomBackgroundSize = "cover" | "contain" | "max";
type CustomBackgroundFilter = [number, number, number, number, number];
type CustomLayoutFluid = `${string}#${string}#${string}`;
type MonkeyPowerLevel = "off" | "1" | "2" | "3" | "4";
type MinimumBurst = "off" | "fixed" | "flex";
type ShowAverage = "off" | "speed" | "acc" | "both";
type TapeMode = "off" | "letter" | "word";
}

View file

@ -111,11 +111,28 @@ declare namespace SharedTypes {
};
}
type StringNumber = import("../schemas/util").StringNumber;
type StringNumber = `${number}`;
type PersonalBest = import("../schemas/users").PersonalBest;
interface PersonalBest {
acc: number;
consistency?: number;
difficulty: SharedTypes.Config.Difficulty;
lazyMode?: boolean;
language: string;
punctuation?: boolean;
numbers?: boolean;
raw: number;
wpm: number;
timestamp: number;
}
type PersonalBests = import("../schemas/users").PersonalBests;
interface PersonalBests {
time: Record<StringNumber, PersonalBest[]>;
words: Record<StringNumber, PersonalBest[]>;
quote: Record<StringNumber, PersonalBest[]>;
custom: Partial<Record<"custom", PersonalBest[]>>;
zen: Partial<Record<"zen", PersonalBest[]>>;
}
interface IncompleteTest {
acc: number;
@ -329,7 +346,92 @@ declare namespace SharedTypes {
lastUsedOn: number;
}
type Config = import("../schemas/config").Config;
interface Config {
theme: string;
themeLight: string;
themeDark: string;
autoSwitchTheme: boolean;
customTheme: boolean;
customThemeColors: string[];
favThemes: string[];
showKeyTips: boolean;
smoothCaret: SharedTypes.Config.SmoothCaret;
quickRestart: SharedTypes.Config.QuickRestart;
punctuation: boolean;
numbers: boolean;
words: number;
time: number;
mode: SharedTypes.Config.Mode;
quoteLength: SharedTypes.Config.QuoteLength[];
language: string;
fontSize: number;
freedomMode: boolean;
difficulty: SharedTypes.Config.Difficulty;
blindMode: boolean;
quickEnd: boolean;
caretStyle: SharedTypes.Config.CaretStyle;
paceCaretStyle: SharedTypes.Config.CaretStyle;
flipTestColors: boolean;
layout: string;
funbox: string;
confidenceMode: SharedTypes.Config.ConfidenceMode;
indicateTypos: SharedTypes.Config.IndicateTypos;
timerStyle: SharedTypes.Config.TimerStyle;
liveSpeedStyle: SharedTypes.Config.LiveSpeedAccBurstStyle;
liveAccStyle: SharedTypes.Config.LiveSpeedAccBurstStyle;
liveBurstStyle: SharedTypes.Config.LiveSpeedAccBurstStyle;
colorfulMode: boolean;
randomTheme: SharedTypes.Config.RandomTheme;
timerColor: SharedTypes.Config.TimerColor;
timerOpacity: SharedTypes.Config.TimerOpacity;
stopOnError: SharedTypes.Config.StopOnError;
showAllLines: boolean;
keymapMode: SharedTypes.Config.KeymapMode;
keymapStyle: SharedTypes.Config.KeymapStyle;
keymapLegendStyle: SharedTypes.Config.KeymapLegendStyle;
keymapLayout: string;
keymapShowTopRow: SharedTypes.Config.KeymapShowTopRow;
fontFamily: string;
smoothLineScroll: boolean;
alwaysShowDecimalPlaces: boolean;
alwaysShowWordsHistory: boolean;
singleListCommandLine: SharedTypes.Config.SingleListCommandLine;
capsLockWarning: boolean;
playSoundOnError: SharedTypes.Config.PlaySoundOnError;
playSoundOnClick: SharedTypes.Config.PlaySoundOnClick;
soundVolume: SharedTypes.Config.SoundVolume;
startGraphsAtZero: boolean;
showOutOfFocusWarning: boolean;
paceCaret: SharedTypes.Config.PaceCaret;
paceCaretCustomSpeed: number;
repeatedPace: boolean;
accountChart: SharedTypes.Config.AccountChart;
minWpm: SharedTypes.Config.MinimumWordsPerMinute;
minWpmCustomSpeed: number;
highlightMode: SharedTypes.Config.HighlightMode;
typingSpeedUnit: SharedTypes.Config.TypingSpeedUnit;
ads: SharedTypes.Config.Ads;
hideExtraLetters: boolean;
strictSpace: boolean;
minAcc: SharedTypes.Config.MinimumAccuracy;
minAccCustom: number;
monkey: boolean;
repeatQuotes: SharedTypes.Config.RepeatQuotes;
oppositeShiftMode: SharedTypes.Config.OppositeShiftMode;
customBackground: string;
customBackgroundSize: SharedTypes.Config.CustomBackgroundSize;
customBackgroundFilter: SharedTypes.Config.CustomBackgroundFilter;
customLayoutfluid: SharedTypes.Config.CustomLayoutFluid;
monkeyPowerLevel: SharedTypes.Config.MonkeyPowerLevel;
minBurst: SharedTypes.Config.MinimumBurst;
minBurstCustomSpeed: number;
burstHeatmap: boolean;
britishEnglish: boolean;
lazyMode: boolean;
showAverage: SharedTypes.Config.ShowAverage;
tapeMode: SharedTypes.Config.TapeMode;
maxLineWidth: number;
}
type ConfigValue = Config[keyof Config];
@ -398,7 +500,7 @@ declare namespace SharedTypes {
type CustomTheme = {
_id: string;
name: string;
colors: Config.CustomThemeColors;
colors: string[];
};
type PremiumInfo = {

View file

@ -1,67 +0,0 @@
import { initContract } from "@ts-rest/core";
import { z } from "zod";
import {
EndpointMetadata,
MonkeyClientError,
MonkeyResponseSchema,
MonkeyServerError,
MonkeyValidationErrorSchema,
responseWithNullableData,
} from "../schemas/api";
import { PartialConfigSchema } from "../schemas/config";
export const GetConfigResponseSchema =
responseWithNullableData(PartialConfigSchema);
export type GetConfigResponse = z.infer<typeof GetConfigResponseSchema>;
const c = initContract();
export const configsContract = c.router(
{
get: {
summary: "get config",
description: "Get config of the current user.",
method: "GET",
path: "/",
responses: {
200: GetConfigResponseSchema,
},
},
save: {
method: "PATCH",
path: "/",
body: PartialConfigSchema.strict(),
responses: {
200: MonkeyResponseSchema,
},
summary: "update config",
description:
"Update the config of the current user. Only provided values will be updated while the missing values will be unchanged.",
},
delete: {
method: "DELETE",
path: "/",
body: c.noBody(),
responses: {
200: MonkeyResponseSchema,
},
summary: "delete config",
description: "Delete/reset the config for the current user.",
},
},
{
pathPrefix: "/configs",
strictStatusCodes: true,
metadata: {
openApiTags: "configs",
} as EndpointMetadata,
commonResponses: {
400: MonkeyClientError,
422: MonkeyValidationErrorSchema,
500: MonkeyServerError,
},
}
);

View file

@ -1,8 +0,0 @@
import { initContract } from "@ts-rest/core";
import { configsContract } from "./configs";
const c = initContract();
export const contract = c.router({
configs: configsContract,
});

View file

@ -1,35 +0,0 @@
{
"name": "shared",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "shared",
"dependencies": {
"@ts-rest/core": "3.45.2",
"zod": "3.23.8"
}
},
"node_modules/@ts-rest/core": {
"version": "3.45.2",
"resolved": "https://registry.npmjs.org/@ts-rest/core/-/core-3.45.2.tgz",
"integrity": "sha512-Eiv+Sa23MbsAd1Gx9vNJ+IFCDyLZNdJ+UuGMKbFvb+/NmgcBR1VL1UIVtEkd5DJxpYMMd8SLvW91RgB2TS8iPw==",
"peerDependencies": {
"zod": "^3.22.3"
},
"peerDependenciesMeta": {
"zod": {
"optional": true
}
}
},
"node_modules/zod": {
"version": "3.23.8",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.23.8.tgz",
"integrity": "sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
}
}
}

View file

@ -1,12 +0,0 @@
{
"name": "shared",
"types": "./dist/index.d.ts",
"dependencies": {
"@ts-rest/core": "3.45.2",
"zod": "3.23.8"
},
"scripts": {
"build": "tsc",
"dev": "tsc --watch"
}
}

View file

@ -1,66 +0,0 @@
import { z, ZodSchema } from "zod";
export type OperationTag = "configs";
export type EndpointMetadata = {
/** Authentication options, by default a bearer token is required. */
authenticationOptions?: RequestAuthenticationOptions;
openApiTags?: OperationTag | OperationTag[];
};
export type RequestAuthenticationOptions = {
/** Endpoint is accessible without any authentication. If `false` bearer authentication is required. */
isPublic?: boolean;
/** Endpoint is accessible with ape key authentication in _addition_ to the bearer authentication. */
acceptApeKeys?: boolean;
/** Endpoint requires an authentication token which is younger than one minute. */
requireFreshToken?: boolean;
noCache?: boolean;
};
export const MonkeyResponseSchema = z.object({
message: z.string(),
});
export type MonkeyResponseType = z.infer<typeof MonkeyResponseSchema>;
export const MonkeyValidationErrorSchema = MonkeyResponseSchema.extend({
validationErrors: z.array(z.string()).nonempty(),
});
export type MonkeyValidationError = z.infer<typeof MonkeyValidationErrorSchema>;
export const MonkeyClientError = MonkeyResponseSchema;
export const MonkeyServerError = MonkeyClientError.extend({
errorId: z.string(),
uid: z.string().optional(),
});
export type MonkeyServerErrorType = z.infer<typeof MonkeyServerError>;
export function responseWithNullableData<T extends ZodSchema>(
dataSchema: T
): z.ZodObject<
z.objectUtil.extendShape<
typeof MonkeyResponseSchema.shape,
{
data: z.ZodNullable<T>;
}
>
> {
return MonkeyResponseSchema.extend({
data: dataSchema.nullable(),
});
}
export function responseWithData<T extends ZodSchema>(
dataSchema: T
): z.ZodObject<
z.objectUtil.extendShape<
typeof MonkeyResponseSchema.shape,
{
data: T;
}
>
> {
return MonkeyResponseSchema.extend({
data: dataSchema,
});
}

View file

@ -1,390 +0,0 @@
import { z } from "zod";
import { token } from "./util";
export const SmoothCaretSchema = z.enum(["off", "slow", "medium", "fast"]);
export type SmoothCaret = z.infer<typeof SmoothCaretSchema>;
export const QuickRestartSchema = z.enum(["off", "esc", "tab", "enter"]);
export type QuickRestart = z.infer<typeof QuickRestartSchema>;
export const QuoteLengthSchema = z.union([
z.literal(-3),
z.literal(-2),
z.literal(-1),
z.literal(0),
z.literal(1),
z.literal(2),
z.literal(3),
]);
export type QuoteLength = z.infer<typeof QuoteLengthSchema>;
export const QuoteLengthConfigSchema = z.array(QuoteLengthSchema);
export type QuoteLengthConfig = z.infer<typeof QuoteLengthConfigSchema>;
export const CaretStyleSchema = z.enum([
"off",
"default",
"block",
"outline",
"underline",
"carrot",
"banana",
]);
export type CaretStyle = z.infer<typeof CaretStyleSchema>;
export const ConfidenceModeSchema = z.enum(["off", "on", "max"]);
export type ConfidenceMode = z.infer<typeof ConfidenceModeSchema>;
export const IndicateTyposSchema = z.enum(["off", "below", "replace"]);
export type IndicateTypos = z.infer<typeof IndicateTyposSchema>;
export const TimerStyleSchema = z.enum(["off", "bar", "text", "mini"]);
export type TimerStyle = z.infer<typeof TimerStyleSchema>;
export const LiveSpeedAccBurstStyleSchema = z.enum(["off", "text", "mini"]);
export type LiveSpeedAccBurstStyle = z.infer<
typeof LiveSpeedAccBurstStyleSchema
>;
export const RandomThemeSchema = z.enum([
"off",
"on",
"fav",
"light",
"dark",
"custom",
]);
export type RandomTheme = z.infer<typeof RandomThemeSchema>;
export const TimerColorSchema = z.enum(["black", "sub", "text", "main"]);
export type TimerColor = z.infer<typeof TimerColorSchema>;
export const TimerOpacitySchema = z.enum(["0.25", "0.5", "0.75", "1"]);
export type TimerOpacity = z.infer<typeof TimerOpacitySchema>;
export const StopOnErrorSchema = z.enum(["off", "word", "letter"]);
export type StopOnError = z.infer<typeof StopOnErrorSchema>;
export const KeymapModeSchema = z.enum(["off", "static", "react", "next"]);
export type KeymapMode = z.infer<typeof KeymapModeSchema>;
export const KeymapStyleSchema = z.enum([
"staggered",
"alice",
"matrix",
"split",
"split_matrix",
"steno",
"steno_matrix",
]);
export type KeymapStyle = z.infer<typeof KeymapStyleSchema>;
export const KeymapLegendStyleSchema = z.enum([
"lowercase",
"uppercase",
"blank",
"dynamic",
]);
export type KeymapLegendStyle = z.infer<typeof KeymapLegendStyleSchema>;
export const KeymapShowTopRowSchema = z.enum(["always", "layout", "never"]);
export type KeymapShowTopRow = z.infer<typeof KeymapShowTopRowSchema>;
export const SingleListCommandLineSchema = z.enum(["manual", "on"]);
export type SingleListCommandLine = z.infer<typeof SingleListCommandLineSchema>;
export const PlaySoundOnErrorSchema = z.enum(["off", "1", "2", "3", "4"]);
export type PlaySoundOnError = z.infer<typeof PlaySoundOnErrorSchema>;
export const PlaySoundOnClickSchema = z.enum([
"off",
"1",
"2",
"3",
"4",
"5",
"6",
"7",
"8",
"9",
"10",
"11",
"12",
"13",
"14",
"15",
]);
export type PlaySoundOnClick = z.infer<typeof PlaySoundOnClickSchema>;
export const SoundVolumeSchema = z.enum(["0.1", "0.5", "1.0"]);
export type SoundVolume = z.infer<typeof SoundVolumeSchema>;
export const PaceCaretSchema = z.enum([
"off",
"average",
"pb",
"last",
"custom",
"daily",
]);
export type PaceCaret = z.infer<typeof PaceCaretSchema>;
export const AccountChartSchema = z.tuple([
z.enum(["on", "off"]),
z.enum(["on", "off"]),
z.enum(["on", "off"]),
z.enum(["on", "off"]),
]);
export type AccountChart = z.infer<typeof AccountChartSchema>;
export const MinimumWordsPerMinuteSchema = z.enum(["off", "custom"]);
export type MinimumWordsPerMinute = z.infer<typeof MinimumWordsPerMinuteSchema>;
export const HighlightModeSchema = z.enum([
"off",
"letter",
"word",
"next_word",
"next_two_words",
"next_three_words",
]);
export type HighlightMode = z.infer<typeof HighlightModeSchema>;
export const TapeModeSchema = z.enum(["off", "letter", "word"]);
export type TapeMode = z.infer<typeof TapeModeSchema>;
export const TypingSpeedUnitSchema = z.enum([
"wpm",
"cpm",
"wps",
"cps",
"wph",
]);
export type TypingSpeedUnit = z.infer<typeof TypingSpeedUnitSchema>;
export const AdsSchema = z.enum(["off", "result", "on", "sellout"]);
export type Ads = z.infer<typeof AdsSchema>;
export const MinimumAccuracySchema = z.enum(["off", "custom"]);
export type MinimumAccuracy = z.infer<typeof MinimumAccuracySchema>;
export const RepeatQuotesSchema = z.enum(["off", "typing"]);
export type RepeatQuotes = z.infer<typeof RepeatQuotesSchema>;
export const OppositeShiftModeSchema = z.enum(["off", "on", "keymap"]);
export type OppositeShiftMode = z.infer<typeof OppositeShiftModeSchema>;
export const CustomBackgroundSizeSchema = z.enum(["cover", "contain", "max"]);
export type CustomBackgroundSize = z.infer<typeof CustomBackgroundSizeSchema>;
export const CustomBackgroundFilterSchema = z.tuple([
z.number(),
z.number(),
z.number(),
z.number(),
]);
export type CustomBackgroundFilter = z.infer<
typeof CustomBackgroundFilterSchema
>;
export const CustomLayoutFluidSchema = z.string().regex(/^[0-9a-zA-Z_#]+$/); //TODO better regex
export type CustomLayoutFluid = z.infer<typeof CustomLayoutFluidSchema>;
export const MonkeyPowerLevelSchema = z.enum(["off", "1", "2", "3", "4"]);
export type MonkeyPowerLevel = z.infer<typeof MonkeyPowerLevelSchema>;
export const MinimumBurstSchema = z.enum(["off", "fixed", "flex"]);
export type MinimumBurst = z.infer<typeof MinimumBurstSchema>;
export const ShowAverageSchema = z.enum(["off", "speed", "acc", "both"]);
export type ShowAverage = z.infer<typeof ShowAverageSchema>;
export const ColorHexValueSchema = z.string().regex(/^#([\da-f]{3}){1,2}$/i);
export type ColorHexValue = z.infer<typeof ColorHexValueSchema>;
export const DifficultySchema = z.enum(["normal", "expert", "master"]);
export type Difficulty = z.infer<typeof DifficultySchema>;
export const NumberModeSchema = z.enum(["time", "words", "quote"]);
export const CustomModeSchema = z.enum(["custom"]);
export const ZenModeSchema = z.enum(["zen"]);
export const ModeSchema = z.union([
NumberModeSchema,
CustomModeSchema,
ZenModeSchema,
]);
export type Mode = z.infer<typeof ModeSchema>;
export const CustomThemeColorsSchema = z.tuple([
ColorHexValueSchema,
ColorHexValueSchema,
ColorHexValueSchema,
ColorHexValueSchema,
ColorHexValueSchema,
ColorHexValueSchema,
ColorHexValueSchema,
ColorHexValueSchema,
ColorHexValueSchema,
ColorHexValueSchema,
]);
export type CustomThemeColors = z.infer<typeof CustomThemeColorsSchema>;
export const FavThemesSchema = z.array(token().max(50));
export type FavThemes = z.infer<typeof FavThemesSchema>;
export const FunboxSchema = z
.string()
.max(100)
.regex(/[\w#]+/);
export type Funbox = z.infer<typeof FunboxSchema>;
export const PaceCaretCustomSpeedSchema = z.number().nonnegative();
export type PaceCaretCustomSpeed = z.infer<typeof PaceCaretCustomSpeedSchema>;
export const MinWpmCustomSpeedSchema = z.number().nonnegative();
export type MinWpmCustomSpeed = z.infer<typeof MinWpmCustomSpeedSchema>;
export const MinimumAccuracyCustomSchema = z.number().nonnegative().max(100);
export type MinimumAccuracyCustom = z.infer<typeof MinimumAccuracyCustomSchema>;
export const MinimumBurstCustomSpeedSchema = z.number().nonnegative();
export type MinimumBurstCustomSpeed = z.infer<
typeof MinimumBurstCustomSpeedSchema
>;
export const TimeConfigSchema = z.number().int().nonnegative();
export type TimeConfig = z.infer<typeof TimeConfigSchema>;
export const WordCountSchema = z.number().int().nonnegative();
export type WordCount = z.infer<typeof WordCountSchema>;
export const FontFamilySchema = z
.string()
.max(50)
.regex(/^[a-zA-Z0-9_\-+.]+$/);
export type FontFamily = z.infer<typeof FontFamilySchema>;
export const ThemeNameSchema = token().max(50);
export type ThemeName = z.infer<typeof ThemeNameSchema>;
export const LanguageSchema = z
.string()
.max(50)
.regex(/^[a-zA-Z0-9_+]+$/);
export type Language = z.infer<typeof LanguageSchema>;
export const KeymapLayoutSchema = z
.string()
.max(50)
.regex(/[\w\-_]+/);
export type KeymapLayout = z.infer<typeof KeymapLayoutSchema>;
export const LayoutSchema = token().max(50);
export type Layout = z.infer<typeof LayoutSchema>;
export const FontSizeSchema = z.number().positive();
export type FontSize = z.infer<typeof FontSizeSchema>;
export const MaxLineWidthSchema = z.number().min(20).max(1000).or(z.literal(0));
export type MaxLineWidth = z.infer<typeof MaxLineWidthSchema>;
export const CustomBackgroundSchema = z
.string()
.regex(/(https|http):\/\/(www\.|).+\..+\/.+(\.png|\.gif|\.jpeg|\.jpg)/gi)
.or(z.literal(""));
export type CustomBackground = z.infer<typeof CustomBackgroundSchema>;
export const ConfigSchema = z
.object({
theme: ThemeNameSchema,
themeLight: ThemeNameSchema,
themeDark: ThemeNameSchema,
autoSwitchTheme: z.boolean(),
customTheme: z.boolean(),
//customThemeId: token().nonnegative().max(24),
customThemeColors: CustomThemeColorsSchema,
favThemes: FavThemesSchema,
showKeyTips: z.boolean(),
smoothCaret: SmoothCaretSchema,
quickRestart: QuickRestartSchema,
punctuation: z.boolean(),
numbers: z.boolean(),
words: WordCountSchema,
time: TimeConfigSchema,
mode: ModeSchema,
quoteLength: QuoteLengthConfigSchema,
language: LanguageSchema,
fontSize: FontSizeSchema,
freedomMode: z.boolean(),
difficulty: DifficultySchema,
blindMode: z.boolean(),
quickEnd: z.boolean(),
caretStyle: CaretStyleSchema,
paceCaretStyle: CaretStyleSchema,
flipTestColors: z.boolean(),
layout: LayoutSchema,
funbox: FunboxSchema,
confidenceMode: ConfidenceModeSchema,
indicateTypos: IndicateTyposSchema,
timerStyle: TimerStyleSchema,
liveSpeedStyle: LiveSpeedAccBurstStyleSchema,
liveAccStyle: LiveSpeedAccBurstStyleSchema,
liveBurstStyle: LiveSpeedAccBurstStyleSchema,
colorfulMode: z.boolean(),
randomTheme: RandomThemeSchema,
timerColor: TimerColorSchema,
timerOpacity: TimerOpacitySchema,
stopOnError: StopOnErrorSchema,
showAllLines: z.boolean(),
keymapMode: KeymapModeSchema,
keymapStyle: KeymapStyleSchema,
keymapLegendStyle: KeymapLegendStyleSchema,
keymapLayout: KeymapLayoutSchema,
keymapShowTopRow: KeymapShowTopRowSchema,
fontFamily: FontFamilySchema,
smoothLineScroll: z.boolean(),
alwaysShowDecimalPlaces: z.boolean(),
alwaysShowWordsHistory: z.boolean(),
singleListCommandLine: SingleListCommandLineSchema,
capsLockWarning: z.boolean(),
playSoundOnError: PlaySoundOnErrorSchema,
playSoundOnClick: PlaySoundOnClickSchema,
soundVolume: SoundVolumeSchema,
startGraphsAtZero: z.boolean(),
showOutOfFocusWarning: z.boolean(),
paceCaret: PaceCaretSchema,
paceCaretCustomSpeed: PaceCaretCustomSpeedSchema,
repeatedPace: z.boolean(),
accountChart: AccountChartSchema,
minWpm: MinimumWordsPerMinuteSchema,
minWpmCustomSpeed: MinWpmCustomSpeedSchema,
highlightMode: HighlightModeSchema,
tapeMode: TapeModeSchema,
typingSpeedUnit: TypingSpeedUnitSchema,
ads: AdsSchema,
hideExtraLetters: z.boolean(),
strictSpace: z.boolean(),
minAcc: MinimumAccuracySchema,
minAccCustom: MinimumAccuracyCustomSchema,
monkey: z.boolean(),
repeatQuotes: RepeatQuotesSchema,
oppositeShiftMode: OppositeShiftModeSchema,
customBackground: CustomBackgroundSchema,
customBackgroundSize: CustomBackgroundSizeSchema,
customBackgroundFilter: CustomBackgroundFilterSchema,
customLayoutfluid: CustomLayoutFluidSchema,
monkeyPowerLevel: MonkeyPowerLevelSchema,
minBurst: MinimumBurstSchema,
minBurstCustomSpeed: MinimumBurstCustomSpeedSchema,
burstHeatmap: z.boolean(),
britishEnglish: z.boolean(),
lazyMode: z.boolean(),
showAverage: ShowAverageSchema,
maxLineWidth: MaxLineWidthSchema,
})
.strict();
export type Config = z.infer<typeof ConfigSchema>;
export const PartialConfigSchema = ConfigSchema.partial();
export type PartialConfig = z.infer<typeof PartialConfigSchema>;

View file

@ -1,29 +0,0 @@
import { z } from "zod";
import { DifficultySchema } from "./config";
import { StringNumberSchema } from "./util";
export const PersonalBestSchema = z.object({
acc: z.number().nonnegative().max(100),
consistency: z.number().nonnegative().max(100),
difficulty: DifficultySchema,
lazyMode: z.boolean().optional(),
language: z
.string()
.max(100)
.regex(/[\w+]+/),
punctuation: z.boolean().optional(),
numbers: z.boolean().optional(),
raw: z.number().nonnegative(),
wpm: z.number().nonnegative(),
timestamp: z.number().nonnegative(),
});
export type PersonalBest = z.infer<typeof PersonalBestSchema>;
export const PersonalBestsSchema = z.object({
time: z.record(StringNumberSchema, z.array(PersonalBestSchema)),
words: z.record(StringNumberSchema, z.array(PersonalBestSchema)),
quote: z.record(StringNumberSchema, z.array(PersonalBestSchema)),
custom: z.record(z.literal("custom"), z.array(PersonalBestSchema)),
zen: z.record(z.literal("zen"), z.array(PersonalBestSchema)),
});
export type PersonalBests = z.infer<typeof PersonalBestsSchema>;

View file

@ -1,9 +0,0 @@
import { z, ZodString } from "zod";
export const StringNumberSchema = z.custom<`${number}`>((val) => {
return typeof val === "string" ? /^\d+$/.test(val) : false;
});
export type StringNumber = z.infer<typeof StringNumberSchema>;
export const token = (): ZodString => z.string().regex(/^[a-zA-Z0-9_]+$/);

View file

@ -1,17 +0,0 @@
{
"compilerOptions": {
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"isolatedModules": true,
"module": "ESNext", //TODO CommonJS
"moduleResolution": "Bundler", //TODO remove?
"preserveWatchOutput": true,
"skipLibCheck": true,
"strict": true,
"rootDir": ".",
"outDir": "dist",
"types": ["node"],
"declaration": true
},
"exclude": ["node_modules", "./dist/**/*"]
}

View file

@ -1,47 +0,0 @@
declare namespace SharedTypes.Config {
type SmoothCaret = import("../schemas/config").SmoothCaret;
type QuickRestart = import("../schemas/config").QuickRestart;
type QuoteLength = import("../schemas/config").QuoteLength;
type CaretStyle = import("../schemas/config").CaretStyle;
type Difficulty = import("../schemas/config").Difficulty;
type Mode = import("../schemas/config").Mode;
type Mode2<M extends Mode> = M extends M ? keyof PersonalBests[M] : never;
type Mode2Custom<M extends Mode> = Mode2<M> | "custom";
type ConfidenceMode = import("../schemas/config").ConfidenceMode;
type IndicateTypos = import("../schemas/config").IndicateTypos;
type TimerStyle = import("../schemas/config").TimerStyle;
type LiveSpeedAccBurstStyle =
import("../schemas/config").LiveSpeedAccBurstStyle;
type RandomTheme = import("../schemas/config").RandomTheme;
type TimerColor = import("../schemas/config").TimerColor;
type TimerOpacity = import("../schemas/config").TimerOpacity;
type StopOnError = import("../schemas/config").StopOnError;
type KeymapMode = import("../schemas/config").KeymapMode;
type KeymapStyle = import("../schemas/config").KeymapStyle;
type KeymapLegendStyle = import("../schemas/config").KeymapLegendStyle;
type KeymapShowTopRow = import("../schemas/config").KeymapShowTopRow;
type SingleListCommandLine =
import("../schemas/config").SingleListCommandLine;
type PlaySoundOnClick = import("../schemas/config").PlaySoundOnClick;
type PlaySoundOnError = import("../schemas/config").PlaySoundOnError;
type SoundVolume = import("../schemas/config").SoundVolume;
type PaceCaret = import("../schemas/config").PaceCaret;
type AccountChart = import("../schemas/config").AccountChart;
type MinimumWordsPerMinute =
import("../schemas/config").MinimumWordsPerMinute;
type HighlightMode = import("../schemas/config").HighlightMode;
type TypingSpeedUnit = import("../schemas/config").TypingSpeedUnit;
type Ads = import("../schemas/config").Ads;
type MinimumAccuracy = import("../schemas/config").MinimumAccuracy;
type RepeatQuotes = import("../schemas/config").RepeatQuotes;
type OppositeShiftMode = import("../schemas/config").OppositeShiftMode;
type CustomBackgroundSize = import("../schemas/config").CustomBackgroundSize;
type CustomBackgroundFilter =
import("../schemas/config").CustomBackgroundFilter;
type CustomLayoutFluid = import("../schemas/config").CustomLayoutFluid;
type MonkeyPowerLevel = import("../schemas/config").MonkeyPowerLevel;
type MinimumBurst = import("../schemas/config").MinimumBurst;
type ShowAverage = import("../schemas/config").ShowAverage;
type TapeMode = import("../schemas/config").TapeMode;
type CustomThemeColors = import("../schemas/config").CustomThemeColors;
}