Merge branch 'master' into feature/update-lables-on-ci-failure

This commit is contained in:
Christian Fehmer 2024-09-13 14:20:35 +02:00 committed by GitHub
commit 227a76e4f1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
216 changed files with 9728 additions and 5503 deletions

View file

@ -134,7 +134,7 @@ jobs:
version: ${{ env.PNPM_VERSION }}
- name: Install prettier
run: pnpm add -g prettier@2.5.1
run: pnpm add -g prettier@2.8.8
- name: Get changed files
if: github.event_name == 'pull_request'

View file

@ -0,0 +1,29 @@
import { REQUEST_MULTIPLIER } from "../../src/middlewares/rate-limit";
import { MatcherResult, ExpectedRateLimit } from "../vitest";
import { Test as SuperTest } from "supertest";
export function enableRateLimitExpects(): void {
expect.extend({
toBeRateLimited: async (
received: SuperTest,
expected: ExpectedRateLimit
): Promise<MatcherResult> => {
const now = Date.now();
const { headers } = await received.expect(200);
const max =
parseInt(headers["x-ratelimit-limit"] as string) / REQUEST_MULTIPLIER;
const windowMs =
parseInt(headers["x-ratelimit-reset"] as string) * 1000 - now;
return {
pass:
max === expected.max && Math.abs(expected.windowMs - windowMs) < 2500,
message: () =>
"Rate limit max not matching or windowMs is off by more then 2500ms",
actual: { max, windowMs },
expected: expected,
};
},
});
}

View file

@ -8,12 +8,14 @@ import * as ReportDal from "../../../src/dal/report";
import GeorgeQueue from "../../../src/queues/george-queue";
import * as AuthUtil from "../../../src/utils/auth";
import _ from "lodash";
import { enableRateLimitExpects } from "../../__testData__/rate-limit";
const mockApp = request(app);
const configuration = Configuration.getCachedConfiguration();
const uid = new ObjectId().toHexString();
enableRateLimitExpects();
describe("ApeKeyController", () => {
describe("AdminController", () => {
const isAdminMock = vi.spyOn(AdminUuidDal, "isAdmin");
beforeEach(async () => {
@ -50,6 +52,11 @@ describe("ApeKeyController", () => {
mockApp.get("/admin").set("authorization", `Uid ${uid}`)
);
});
it("should be rate limited", async () => {
await expect(
mockApp.get("/admin").set("authorization", `Uid ${uid}`)
).toBeRateLimited({ max: 1, windowMs: 5000 });
});
});
describe("toggle ban", () => {
@ -167,6 +174,22 @@ describe("ApeKeyController", () => {
.set("authorization", `Uid ${uid}`)
);
});
it("should be rate limited", async () => {
//GIVEN
const victimUid = new ObjectId().toHexString();
getUserMock.mockResolvedValue({
banned: false,
discordId: "discordId",
} as any);
//WHEN
await expect(
mockApp
.post("/admin/toggleBan")
.send({ uid: victimUid })
.set("authorization", `Uid ${uid}`)
).toBeRateLimited({ max: 1, windowMs: 5000 });
});
});
describe("accept reports", () => {
const getReportsMock = vi.spyOn(ReportDal, "getReports");
@ -269,6 +292,18 @@ describe("ApeKeyController", () => {
.set("authorization", `Uid ${uid}`)
);
});
it("should be rate limited", async () => {
//GIVEN
getReportsMock.mockResolvedValue([{ id: "1", reason: "one" } as any]);
//WHEN
await expect(
mockApp
.post("/admin/report/accept")
.send({ reports: [{ reportId: "1" }] })
.set("authorization", `Uid ${uid}`)
).toBeRateLimited({ max: 1, windowMs: 5000 });
});
});
describe("reject reports", () => {
const getReportsMock = vi.spyOn(ReportDal, "getReports");
@ -374,6 +409,18 @@ describe("ApeKeyController", () => {
.set("authorization", `Uid ${uid}`)
);
});
it("should be rate limited", async () => {
//GIVEN
getReportsMock.mockResolvedValue([{ id: "1", reason: "one" } as any]);
//WHEN
await expect(
mockApp
.post("/admin/report/reject")
.send({ reports: [{ reportId: "1" }] })
.set("authorization", `Uid ${uid}`)
).toBeRateLimited({ max: 1, windowMs: 5000 });
});
});
describe("send forgot password email", () => {
const sendForgotPasswordEmailMock = vi.spyOn(
@ -405,6 +452,15 @@ describe("ApeKeyController", () => {
"meowdec@example.com"
);
});
it("should be rate limited", async () => {
//WHEN
await expect(
mockApp
.post("/admin/sendForgotPasswordEmail")
.send({ email: "meowdec@example.com" })
.set("authorization", `Uid ${uid}`)
).toBeRateLimited({ max: 1, windowMs: 5000 });
});
});
async function expectFailForNonAdmin(call: SuperTest): Promise<void> {

View file

@ -1,13 +1,24 @@
import request from "supertest";
import app from "../../../src/app";
import * as AuthUtils from "../../../src/utils/auth";
import { ObjectId } from "mongodb";
import * as Misc from "../../../src/utils/misc";
import { DecodedIdToken } from "firebase-admin/auth";
const uid = new ObjectId().toHexString();
const mockDecodedToken = {
uid,
email: "newuser@mail.com",
iat: 0,
} as DecodedIdToken;
const mockApp = request(app);
describe("DevController", () => {
const verifyIdTokenMock = vi.spyOn(AuthUtils, "verifyIdToken");
beforeEach(() => {
verifyIdTokenMock.mockReset().mockResolvedValue(mockDecodedToken);
});
describe("generate testData", () => {
const isDevEnvironmentMock = vi.spyOn(Misc, "isDevEnvironment");
@ -22,6 +33,7 @@ describe("DevController", () => {
//WHEN
const { body } = await mockApp
.post("/dev/generateData")
.set("Authorization", "Bearer 123456789")
.send({ username: "test" })
.expect(503);
//THEN

View file

@ -155,7 +155,7 @@ describe("Loaderboard Controller", () => {
validationErrors: [
'"language" Required',
'"mode" Required',
'"mode2" Needs to be either a number, "zen" or "custom."',
'"mode2" Needs to be either a number, "zen" or "custom".',
],
});
});
@ -320,7 +320,7 @@ describe("Loaderboard Controller", () => {
validationErrors: [
'"language" Required',
'"mode" Required',
'"mode2" Needs to be either a number, "zen" or "custom."',
'"mode2" Needs to be either a number, "zen" or "custom".',
],
});
});
@ -591,7 +591,7 @@ describe("Loaderboard Controller", () => {
validationErrors: [
'"language" Required',
'"mode" Required',
'"mode2" Needs to be either a number, "zen" or "custom."',
'"mode2" Needs to be either a number, "zen" or "custom".',
],
});
});
@ -768,7 +768,7 @@ describe("Loaderboard Controller", () => {
validationErrors: [
'"language" Required',
'"mode" Required',
'"mode2" Needs to be either a number, "zen" or "custom."',
'"mode2" Needs to be either a number, "zen" or "custom".',
],
});
});

View file

@ -24,9 +24,15 @@ describe("PresetController", () => {
_id: new ObjectId(),
uid: "123456789",
name: "test2",
config: { language: "polish" },
settingGroups: ["hideElements"],
config: {
showKeyTips: true,
capsLockWarning: true,
showOutOfFocusWarning: true,
showAverage: "off",
},
};
//@ts-expect-error
getPresetsMock.mockResolvedValue([presetOne, presetTwo]);
//WHEN
@ -47,7 +53,13 @@ describe("PresetController", () => {
{
_id: presetTwo._id.toHexString(),
name: "test2",
config: { language: "polish" },
settingGroups: ["hideElements"],
config: {
showKeyTips: true,
capsLockWarning: true,
showOutOfFocusWarning: true,
showAverage: "off",
},
},
],
});
@ -81,7 +93,7 @@ describe("PresetController", () => {
addPresetMock.mockReset();
});
it("should add the users preset", async () => {
it("should add the users full preset", async () => {
//GIVEN
addPresetMock.mockResolvedValue({ presetId: "1" });
@ -110,6 +122,65 @@ describe("PresetController", () => {
config: { language: "english", tags: ["one", "two"] },
});
});
it("should add the users partial preset", async () => {
//GIVEN
addPresetMock.mockResolvedValue({ presetId: "1" });
//WHEN
const { body } = await mockApp
.post("/presets")
.set("authorization", "Uid 123456789")
.accept("application/json")
.send({
name: "new",
settingGroups: ["hideElements"],
config: {
showKeyTips: true,
capsLockWarning: true,
showOutOfFocusWarning: true,
showAverage: "off",
},
})
.expect(200);
//THEN
expect(body).toStrictEqual({
message: "Preset created",
data: { presetId: "1" },
});
expect(addPresetMock).toHaveBeenCalledWith("123456789", {
name: "new",
settingGroups: ["hideElements"],
config: {
showKeyTips: true,
capsLockWarning: true,
showOutOfFocusWarning: true,
showAverage: "off",
},
});
});
it("should fail for no setting groups in partial presets", async () => {
//WHEN
const { body } = await mockApp
.post("/presets")
.set("authorization", "Uid 123456789")
.accept("application/json")
.send({
name: "update",
settingGroups: [],
config: {},
})
.expect(422);
expect(body).toStrictEqual({
message: "Invalid request data schema",
validationErrors: [
`"settingGroups" Array must contain at least 1 element(s)`,
],
});
expect(addPresetMock).not.toHaveBeenCalled();
});
it("should not fail with emtpy config", async () => {
//GIVEN
@ -149,7 +220,7 @@ describe("PresetController", () => {
});
expect(addPresetMock).not.toHaveBeenCalled();
});
it("should not fail with invalid preset", async () => {
it("should fail with invalid preset", async () => {
//WHEN
const { body } = await mockApp
.post("/presets")
@ -178,6 +249,32 @@ describe("PresetController", () => {
],
});
expect(addPresetMock).not.toHaveBeenCalled();
});
it("should fail with duplicate group settings in partial preset", async () => {
//WHEN
const { body } = await mockApp
.post("/presets")
.set("authorization", "Uid 123456789")
.accept("application/json")
.send({
name: "new",
settingGroups: ["hideElements", "hideElements"],
config: {
showKeyTips: true,
capsLockWarning: true,
showOutOfFocusWarning: true,
showAverage: "off",
},
})
.expect(422);
//THEN
expect(body).toStrictEqual({
message: "Invalid request data schema",
validationErrors: [`"settingGroups" No duplicates allowed.`],
});
expect(addPresetMock).not.toHaveBeenCalled();
});
});
@ -220,6 +317,46 @@ describe("PresetController", () => {
config: { language: "english", tags: ["one", "two"] },
});
});
it("should update the users partial preset", async () => {
//GIVEN
editPresetMock.mockResolvedValue({} as any);
//WHEN
const { body } = await mockApp
.patch("/presets")
.set("authorization", "Uid 123456789")
.accept("application/json")
.send({
_id: "1",
name: "new",
settingGroups: ["hideElements"],
config: {
showKeyTips: true,
capsLockWarning: true,
showOutOfFocusWarning: true,
showAverage: "off",
},
})
.expect(200);
//THEN
expect(body).toStrictEqual({
message: "Preset updated",
data: null,
});
expect(editPresetMock).toHaveBeenCalledWith("123456789", {
_id: "1",
name: "new",
settingGroups: ["hideElements"],
config: {
showKeyTips: true,
capsLockWarning: true,
showOutOfFocusWarning: true,
showAverage: "off",
},
});
});
it("should not fail with emtpy config", async () => {
//GIVEN
@ -256,15 +393,11 @@ describe("PresetController", () => {
expect(body).toStrictEqual({
message: "Invalid request data schema",
validationErrors: [
`"_id" Required`,
`"name" Required`,
`"config" Required`,
],
validationErrors: [`"_id" Required`, `"name" Required`],
});
expect(editPresetMock).not.toHaveBeenCalled();
});
it("should not fail with invalid preset", async () => {
it("should fail with invalid preset", async () => {
//WHEN
const { body } = await mockApp
.patch("/presets")
@ -274,6 +407,7 @@ describe("PresetController", () => {
_id: "1",
name: "update",
extra: "extra",
settingGroups: ["mappers"],
config: {
extra: "extra",
autoSwitchTheme: "yes",
@ -286,6 +420,7 @@ describe("PresetController", () => {
expect(body).toStrictEqual({
message: "Invalid request data schema",
validationErrors: [
`"settingGroups.0" Invalid enum value. Expected 'test' | 'behavior' | 'input' | 'sound' | 'caret' | 'appearance' | 'theme' | 'hideElements' | 'ads' | 'hidden', received 'mappers'`,
`"config.autoSwitchTheme" Expected boolean, received string`,
`"config.confidenceMode" Invalid enum value. Expected 'off' | 'on' | 'max', received 'pretty'`,
`"config" Unrecognized key(s) in object: 'extra'`,
@ -293,6 +428,33 @@ describe("PresetController", () => {
],
});
expect(editPresetMock).not.toHaveBeenCalled();
});
it("should fail with duplicate group settings in partial preset", async () => {
//WHEN
const { body } = await mockApp
.patch("/presets")
.set("authorization", "Uid 123456789")
.accept("application/json")
.send({
_id: "1",
name: "new",
settingGroups: ["hideElements", "hideElements"],
config: {
showKeyTips: true,
capsLockWarning: true,
showOutOfFocusWarning: true,
showAverage: "off",
},
})
.expect(422);
//THEN
expect(body).toStrictEqual({
message: "Invalid request data schema",
validationErrors: [`"settingGroups" No duplicates allowed.`],
});
expect(editPresetMock).not.toHaveBeenCalled();
});
});

View file

@ -18,9 +18,8 @@ describe("PublicController", () => {
//WHEN
const { body } = await mockApp
.get("/public/speedHistogram")
.query({ language: "english", mode: "time", mode2: "60" });
//.expect(200);
console.log(body);
.query({ language: "english", mode: "time", mode2: "60" })
.expect(200);
//THEN
expect(body).toEqual({
@ -72,7 +71,7 @@ describe("PublicController", () => {
validationErrors: [
'"language" Required',
'"mode" Required',
'"mode2" Needs to be either a number, "zen" or "custom."',
'"mode2" Needs to be either a number, "zen" or "custom".',
],
});
});

View file

@ -9,6 +9,7 @@ import * as AuthUtils from "../../../src/utils/auth";
import { DecodedIdToken } from "firebase-admin/lib/auth/token-verifier";
import { ObjectId } from "mongodb";
import { mockAuthenticateWithApeKey } from "../../__testData__/auth";
import { enableRateLimitExpects } from "../../__testData__/rate-limit";
const uid = "123456";
const mockDecodedToken: DecodedIdToken = {
@ -20,6 +21,7 @@ const mockDecodedToken: DecodedIdToken = {
const mockApp = request(app);
const configuration = Configuration.getCachedConfiguration();
enableRateLimitExpects();
describe("result controller test", () => {
const verifyIdTokenMock = vi.spyOn(AuthUtils, "verifyIdToken");
@ -322,6 +324,21 @@ describe("result controller test", () => {
expect(body.data[1]).not.toHaveProperty("correctChars");
expect(body.data[1]).not.toHaveProperty("incorrectChars");
});
it("should be rate limited", async () => {
await expect(
mockApp.get("/results").set("Authorization", `Bearer ${uid}`)
).toBeRateLimited({ max: 60, windowMs: 60 * 60 * 1000 });
});
it("should be rate limited for ape keys", async () => {
//GIVEN
await acceptApeKeys(true);
const apeKey = await mockAuthenticateWithApeKey(uid, await configuration);
//WHEN
await expect(
mockApp.get("/results").set("Authorization", `ApeKey ${apeKey}`)
).toBeRateLimited({ max: 30, windowMs: 24 * 60 * 60 * 1000 });
});
});
describe("getLastResult", () => {
const getLastResultMock = vi.spyOn(ResultDal, "getLastResult");
@ -385,6 +402,22 @@ describe("result controller test", () => {
expect(body.data).not.toHaveProperty("correctChars");
expect(body.data).not.toHaveProperty("incorrectChars");
});
it("should rate limit get last result with ape key", async () => {
//GIVEN
const result = givenDbResult(uid, {
charStats: undefined,
incorrectChars: 5,
correctChars: 12,
});
getLastResultMock.mockResolvedValue(result);
await acceptApeKeys(true);
const apeKey = await mockAuthenticateWithApeKey(uid, await configuration);
//WHEN
await expect(
mockApp.get("/results/last").set("Authorization", `ApeKey ${apeKey}`)
).toBeRateLimited({ max: 30, windowMs: 60 * 1000 }); //should use defaultApeRateLimit
});
});
describe("deleteAll", () => {
const deleteAllMock = vi.spyOn(ResultDal, "deleteAll");

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,81 @@
import GeorgeQueue from "../../../src/queues/george-queue";
import crypto from "crypto";
import request from "supertest";
import app from "../../../src/app";
const mockApp = request(app);
describe("WebhooksController", () => {
describe("githubRelease", () => {
const georgeSendReleaseAnnouncementMock = vi.spyOn(
GeorgeQueue,
"sendReleaseAnnouncement"
);
const timingSafeEqualMock = vi.spyOn(crypto, "timingSafeEqual");
beforeEach(() => {
vi.stubEnv("GITHUB_WEBHOOK_SECRET", "GITHUB_WEBHOOK_SECRET");
georgeSendReleaseAnnouncementMock.mockReset();
timingSafeEqualMock.mockReset().mockReturnValue(true);
});
it("should announce release", async () => {
//WHEN
const { body } = await mockApp
.post("/webhooks/githubRelease")
.set("x-hub-signature-256", "the-signature")
.send({ action: "published", release: { id: 1 } })
.expect(200);
//THEN
expect(body).toEqual({
message: "Added release announcement task to queue",
data: null,
});
expect(georgeSendReleaseAnnouncementMock).toHaveBeenCalledWith("1");
expect(timingSafeEqualMock).toHaveBeenCalledWith(
Buffer.from(
"sha256=ff0f3080539e9df19153f6b5b5780f66e558d61038e6cf5ecf4efdc7266a7751"
),
Buffer.from("the-signature")
);
});
it("should ignore non-published actions", async () => {
//WHEN
const { body } = await mockApp
.post("/webhooks/githubRelease")
.set("x-hub-signature-256", "the-signature")
.send({ action: "created" })
.expect(200);
//THEN
expect(body.message).toEqual("No action taken");
expect(georgeSendReleaseAnnouncementMock).not.toHaveBeenCalled();
});
it("should ignore additional properties", async () => {
//WHEN
await mockApp
.post("/webhooks/githubRelease")
.set("x-hub-signature-256", "the-signature")
.send({
action: "published",
extra: "value",
release: { id: 1, extra2: "value" },
})
.expect(200);
});
it("should fail with missing releaseId", async () => {
//WHEN
const { body } = await mockApp
.post("/webhooks/githubRelease")
.set("x-hub-signature-256", "the-signature")
.send({ action: "published" })
.expect(422);
//THEN
expect(body.message).toEqual('Missing property "release.id".');
});
});
});

View file

@ -323,7 +323,7 @@ function lbBests(
return result;
}
function pb(
export function pb(
wpm: number,
acc: number = 90,
timestamp: number = 1

View file

@ -13,8 +13,12 @@ describe("PresetDal", () => {
});
const second = await PresetDal.addPreset(uid, {
name: "second",
settingGroups: ["hideElements"],
config: {
ads: "result",
showKeyTips: true,
capsLockWarning: true,
showOutOfFocusWarning: true,
showAverage: "off",
},
});
await PresetDal.addPreset("unknown", { name: "unknown", config: {} });
@ -36,7 +40,13 @@ describe("PresetDal", () => {
_id: new ObjectId(second.presetId),
uid: uid,
name: "second",
config: { ads: "result" },
settingGroups: ["hideElements"],
config: {
showKeyTips: true,
capsLockWarning: true,
showOutOfFocusWarning: true,
showAverage: "off",
},
}),
])
);
@ -160,7 +170,7 @@ describe("PresetDal", () => {
);
});
it("should edit with name only", async () => {
it("should edit with name only - full preset", async () => {
//GIVEN
const uid = new ObjectId().toHexString();
const first = (
@ -174,7 +184,6 @@ describe("PresetDal", () => {
await PresetDal.editPreset(uid, {
_id: first,
name: "newName",
config: {},
});
expect(await PresetDal.getPresets(uid)).toEqual(
expect.arrayContaining([
@ -187,6 +196,44 @@ describe("PresetDal", () => {
])
);
});
it("should edit with name only - partial preset", async () => {
//GIVEN
const uid = new ObjectId().toHexString();
const first = (
await PresetDal.addPreset(uid, {
name: "first",
settingGroups: ["hideElements"],
config: {
showKeyTips: true,
capsLockWarning: true,
showOutOfFocusWarning: true,
showAverage: "off",
},
})
).presetId;
//WHEN empty
await PresetDal.editPreset(uid, {
_id: first,
name: "newName",
});
expect(await PresetDal.getPresets(uid)).toEqual(
expect.arrayContaining([
expect.objectContaining({
_id: new ObjectId(first),
uid: uid,
name: "newName",
settingGroups: ["hideElements"],
config: {
showKeyTips: true,
capsLockWarning: true,
showOutOfFocusWarning: true,
showAverage: "off",
},
}),
])
);
});
it("should not edit present not matching uid", async () => {
//GIVEN
const uid = new ObjectId().toHexString();
@ -219,6 +266,85 @@ describe("PresetDal", () => {
])
);
});
it("should edit when partial is edited to full", async () => {
//GIVEN
const uid = new ObjectId().toHexString();
const first = (
await PresetDal.addPreset(uid, {
name: "first",
settingGroups: ["hideElements"],
config: {
showKeyTips: true,
capsLockWarning: true,
showOutOfFocusWarning: true,
showAverage: "off",
},
})
).presetId;
//WHEN
await PresetDal.editPreset(uid, {
_id: first,
name: "newName",
settingGroups: null,
config: { ads: "off" },
});
//THEN
expect(await PresetDal.getPresets(uid)).toEqual(
expect.arrayContaining([
expect.objectContaining({
_id: new ObjectId(first),
uid: uid,
name: "newName",
config: { ads: "off" },
settingGroups: null,
}),
])
);
});
it("should edit when full is edited to partial", async () => {
//GIVEN
const uid = new ObjectId().toHexString();
const first = (
await PresetDal.addPreset(uid, {
name: "first",
config: {
ads: "off",
},
})
).presetId;
//WHEN
await PresetDal.editPreset(uid, {
_id: first,
name: "newName",
settingGroups: ["hideElements"],
config: {
showKeyTips: true,
capsLockWarning: true,
showOutOfFocusWarning: true,
showAverage: "off",
},
});
//THEN
expect(await PresetDal.getPresets(uid)).toEqual(
expect.arrayContaining([
expect.objectContaining({
_id: new ObjectId(first),
uid: uid,
name: "newName",
settingGroups: ["hideElements"],
config: {
showKeyTips: true,
capsLockWarning: true,
showOutOfFocusWarning: true,
showAverage: "off",
},
}),
])
);
});
});
describe("removePreset", () => {

View file

@ -2,10 +2,6 @@ import * as ResultDal from "../../src/dal/result";
import { ObjectId } from "mongodb";
import * as UserDal from "../../src/dal/user";
type MonkeyTypesResult = MonkeyTypes.WithObjectId<
SharedTypes.DBResult<SharedTypes.Config.Mode>
>;
let uid: string = "";
const timestamp = Date.now() - 60000;
@ -60,7 +56,7 @@ async function createDummyData(
language: "english",
isPb: false,
name: "Test",
} as MonkeyTypesResult);
} as MonkeyTypes.DBResult);
}
}
describe("ResultDal", () => {

View file

@ -2,6 +2,12 @@ import _ from "lodash";
import * as UserDAL from "../../src/dal/user";
import * as UserTestData from "../__testData__/users";
import { ObjectId } from "mongodb";
import { MonkeyMail, ResultFilters } from "@monkeytype/contracts/schemas/users";
import {
PersonalBest,
PersonalBests,
} from "@monkeytype/contracts/schemas/shared";
import { CustomThemeColors } from "@monkeytype/contracts/schemas/configs";
const mockPersonalBest = {
acc: 1,
@ -15,7 +21,7 @@ const mockPersonalBest = {
timestamp: 13123123,
};
const mockResultFilter: SharedTypes.ResultFilters = {
const mockResultFilter: ResultFilters = {
_id: "id",
name: "sfdkjhgdf",
pb: {
@ -193,38 +199,6 @@ describe("UserDal", () => {
).rejects.toThrow("Username already taken");
});
it("updatename should not allow invalid usernames", async () => {
// given
const testUser = {
name: "Test",
email: "mockemail@email.com",
uid: "userId",
};
await UserDAL.addUser(testUser.name, testUser.email, testUser.uid);
const invalidNames = [
null, // falsy
undefined, // falsy
"", // empty
" ".repeat(16), // too long
".testName", // cant begin with period
"asdasdAS$", // invalid characters
];
// when, then
invalidNames.forEach(
async (invalidName) =>
await expect(
UserDAL.updateName(
testUser.uid,
invalidName as unknown as string,
testUser.name
)
).rejects.toThrow("Invalid username")
);
});
it("UserDAL.updateName should change the name of a user", async () => {
// given
const testUser = {
@ -474,7 +448,7 @@ describe("UserDal", () => {
it("addTag success", async () => {
// given
const emptyPb: SharedTypes.PersonalBests = {
const emptyPb: PersonalBests = {
time: {},
words: {},
quote: {},
@ -641,21 +615,21 @@ describe("UserDal", () => {
name: "tagOne",
personalBests: {
custom: { custom: [mockPersonalBest] },
} as SharedTypes.PersonalBests,
} as PersonalBests,
};
const tagTwo = {
_id: new ObjectId(),
name: "tagTwo",
personalBests: {
custom: { custom: [mockPersonalBest] },
} as SharedTypes.PersonalBests,
} as PersonalBests,
};
const tagThree = {
_id: new ObjectId(),
name: "tagThree",
personalBests: {
custom: { custom: [mockPersonalBest] },
} as SharedTypes.PersonalBests,
} as PersonalBests,
};
const { uid } = await UserTestData.createUser({
@ -1112,7 +1086,7 @@ describe("UserDal", () => {
//then
const read = (await UserDAL.getUser(user.uid, "")).testActivity || {};
expect(read).toHaveProperty("2024");
const year2024 = read["2024"];
const year2024 = read["2024"] as any;
expect(year2024).toHaveLength(94);
//fill previous days with null
expect(year2024.slice(0, 93)).toEqual(new Array(93).fill(null));
@ -1130,7 +1104,7 @@ describe("UserDal", () => {
//then
const read = (await UserDAL.getUser(user.uid, "")).testActivity || {};
expect(read).toHaveProperty("2024");
const year2024 = read["2024"];
const year2024 = read["2024"] as any;
expect(year2024).toHaveLength(94);
expect(year2024[0]).toBeNull();
@ -1149,7 +1123,7 @@ describe("UserDal", () => {
//then
const read = (await UserDAL.getUser(user.uid, "")).testActivity || {};
const year2024 = read["2024"];
const year2024 = read["2024"] as any;
expect(year2024[93]).toEqual(2);
});
});
@ -1279,7 +1253,7 @@ describe("UserDal", () => {
describe("updateInbox", () => {
it("claims rewards on read", async () => {
//GIVEN
const rewardOne: SharedTypes.MonkeyMail = {
const rewardOne: MonkeyMail = {
id: "b5866d4c-0749-41b6-b101-3656249d39b9",
body: "test",
subject: "reward one",
@ -1291,7 +1265,7 @@ describe("UserDal", () => {
{ type: "badge", item: { id: 4 } },
],
};
const rewardTwo: SharedTypes.MonkeyMail = {
const rewardTwo: MonkeyMail = {
id: "3692b9f5-84fb-4d9b-bd39-9a3217b3a33a",
body: "test",
subject: "reward two",
@ -1299,7 +1273,7 @@ describe("UserDal", () => {
read: false,
rewards: [{ type: "xp", item: 2000 }],
};
const rewardThree: SharedTypes.MonkeyMail = {
const rewardThree: MonkeyMail = {
id: "0d73b3e0-dc79-4abb-bcaf-66fa6b09a58a",
body: "test",
subject: "reward three",
@ -1307,7 +1281,7 @@ describe("UserDal", () => {
read: true,
rewards: [{ type: "xp", item: 3000 }],
};
const rewardFour: SharedTypes.MonkeyMail = {
const rewardFour: MonkeyMail = {
id: "d852d2cf-1802-4cd0-9fb4-336650fc470a",
body: "test",
subject: "reward four",
@ -1349,7 +1323,7 @@ describe("UserDal", () => {
it("claims rewards on delete", async () => {
//GIVEN
//GIVEN
const rewardOne: SharedTypes.MonkeyMail = {
const rewardOne: MonkeyMail = {
id: "b5866d4c-0749-41b6-b101-3656249d39b9",
body: "test",
subject: "reward one",
@ -1361,7 +1335,7 @@ describe("UserDal", () => {
{ type: "badge", item: { id: 4 } },
],
};
const rewardTwo: SharedTypes.MonkeyMail = {
const rewardTwo: MonkeyMail = {
id: "3692b9f5-84fb-4d9b-bd39-9a3217b3a33a",
body: "test",
subject: "reward two",
@ -1370,7 +1344,7 @@ describe("UserDal", () => {
rewards: [{ type: "xp", item: 2000 }],
};
const rewardThree: SharedTypes.MonkeyMail = {
const rewardThree: MonkeyMail = {
id: "0d73b3e0-dc79-4abb-bcaf-66fa6b09a58a",
body: "test",
subject: "reward three",
@ -1395,7 +1369,7 @@ describe("UserDal", () => {
it("updates badge", async () => {
//GIVEN
const rewardOne: SharedTypes.MonkeyMail = {
const rewardOne: MonkeyMail = {
id: "b5866d4c-0749-41b6-b101-3656249d39b9",
body: "test",
subject: "reward one",
@ -1406,7 +1380,7 @@ describe("UserDal", () => {
{ type: "badge", item: { id: 4 } },
],
};
const rewardTwo: SharedTypes.MonkeyMail = {
const rewardTwo: MonkeyMail = {
id: "3692b9f5-84fb-4d9b-bd39-9a3217b3a33a",
body: "test",
subject: "reward two",
@ -1418,7 +1392,7 @@ describe("UserDal", () => {
{ type: "badge", item: { id: 5 } },
],
};
const rewardThree: SharedTypes.MonkeyMail = {
const rewardThree: MonkeyMail = {
id: "0d73b3e0-dc79-4abb-bcaf-66fa6b09a58a",
body: "test",
subject: "reward three",
@ -1460,7 +1434,7 @@ describe("UserDal", () => {
});
it("read and delete the same message does not claim reward twice", async () => {
//GIVEN
const rewardOne: SharedTypes.MonkeyMail = {
const rewardOne: MonkeyMail = {
id: "b5866d4c-0749-41b6-b101-3656249d39b9",
body: "test",
subject: "reward one",
@ -1468,7 +1442,7 @@ describe("UserDal", () => {
read: false,
rewards: [{ type: "xp", item: 1000 }],
};
const rewardTwo: SharedTypes.MonkeyMail = {
const rewardTwo: MonkeyMail = {
id: "3692b9f5-84fb-4d9b-bd39-9a3217b3a33a",
body: "test",
subject: "reward two",
@ -1495,7 +1469,7 @@ describe("UserDal", () => {
it("concurrent calls dont claim a reward multiple times", async () => {
//GIVEN
const rewardOne: SharedTypes.MonkeyMail = {
const rewardOne: MonkeyMail = {
id: "b5866d4c-0749-41b6-b101-3656249d39b9",
body: "test",
subject: "reward one",
@ -1507,7 +1481,7 @@ describe("UserDal", () => {
{ type: "badge", item: { id: 4 } },
],
};
const rewardTwo: SharedTypes.MonkeyMail = {
const rewardTwo: MonkeyMail = {
id: "3692b9f5-84fb-4d9b-bd39-9a3217b3a33a",
body: "test",
subject: "reward two",
@ -1515,7 +1489,7 @@ describe("UserDal", () => {
read: false,
rewards: [{ type: "xp", item: 2000 }],
};
const rewardThree: SharedTypes.MonkeyMail = {
const rewardThree: MonkeyMail = {
id: "0d73b3e0-dc79-4abb-bcaf-66fa6b09a58a",
body: "test",
subject: "reward three",
@ -1649,8 +1623,8 @@ describe("UserDal", () => {
personalBests: {
time: {
"60": [
{ wpm: 100 } as SharedTypes.PersonalBest,
{ wpm: 30 } as SharedTypes.PersonalBest, //highest PB should be used
{ wpm: 100 } as PersonalBest,
{ wpm: 30 } as PersonalBest, //highest PB should be used
],
},
} as any,
@ -1743,7 +1717,10 @@ describe("UserDal", () => {
it("should return error if uid not found", async () => {
// when, then
await expect(
UserDAL.addTheme("non existing uid", { name: "new", colors: [] })
UserDAL.addTheme("non existing uid", {
name: "new",
colors: [] as any,
})
).rejects.toThrow(
"Maximum number of custom themes reached\nStack: add theme"
);
@ -1755,13 +1732,13 @@ describe("UserDal", () => {
customThemes: new Array(10).fill(0).map(() => ({
_id: new ObjectId(),
name: "any",
colors: [],
colors: [] as any,
})),
});
// when, then
await expect(
UserDAL.addTheme(uid, { name: "new", colors: [] })
UserDAL.addTheme(uid, { name: "new", colors: [] as any })
).rejects.toThrow(
"Maximum number of custom themes reached\nStack: add theme"
);
@ -1772,17 +1749,18 @@ describe("UserDal", () => {
const themeOne = {
_id: new ObjectId(),
name: "first",
colors: ["green", "white", "red"],
colors: new Array(10).fill("#123456") as CustomThemeColors,
};
const { uid } = await UserTestData.createUser({
customThemes: [themeOne],
});
// when
await UserDAL.addTheme(uid, {
const newTheme = {
name: "newTheme",
colors: ["red", "white", "blue"],
});
colors: new Array(10).fill("#000000") as CustomThemeColors,
};
// when
await UserDAL.addTheme(uid, { ...newTheme });
// then
const read = await UserDAL.getUser(uid, "read");
@ -1790,11 +1768,11 @@ describe("UserDal", () => {
expect.arrayContaining([
expect.objectContaining({
name: "first",
colors: ["green", "white", "red"],
colors: themeOne.colors,
}),
expect.objectContaining({
name: "newTheme",
colors: ["red", "white", "blue"],
colors: newTheme.colors,
}),
])
);
@ -1807,7 +1785,7 @@ describe("UserDal", () => {
await expect(
UserDAL.editTheme("non existing uid", new ObjectId().toHexString(), {
name: "newName",
colors: [],
colors: [] as any,
})
).rejects.toThrow("Custom theme not found\nStack: edit theme");
});
@ -1817,7 +1795,7 @@ describe("UserDal", () => {
const themeOne = {
_id: new ObjectId(),
name: "first",
colors: ["green", "white", "red"],
colors: ["green", "white", "red"] as any,
};
const { uid } = await UserTestData.createUser({
customThemes: [themeOne],
@ -1827,7 +1805,7 @@ describe("UserDal", () => {
await expect(
UserDAL.editTheme(uid, new ObjectId().toHexString(), {
name: "newName",
colors: [],
colors: [] as any,
})
).rejects.toThrow("Custom theme not found\nStack: edit theme");
});
@ -1837,7 +1815,7 @@ describe("UserDal", () => {
const themeOne = {
_id: new ObjectId(),
name: "first",
colors: ["green", "white", "red"],
colors: ["green", "white", "red"] as any,
};
const { uid } = await UserTestData.createUser({
customThemes: [themeOne],
@ -1845,7 +1823,7 @@ describe("UserDal", () => {
// when
await UserDAL.editTheme(uid, themeOne._id.toHexString(), {
name: "newThemeName",
colors: ["red", "white", "blue"],
colors: ["red", "white", "blue"] as any,
});
// then
@ -1869,7 +1847,7 @@ describe("UserDal", () => {
const themeOne = {
_id: new ObjectId(),
name: "first",
colors: ["green", "white", "red"],
colors: ["green", "white", "red"] as any,
};
const { uid } = await UserTestData.createUser({
customThemes: [themeOne],
@ -1885,18 +1863,18 @@ describe("UserDal", () => {
const themeOne = {
_id: new ObjectId(),
name: "first",
colors: [],
colors: [] as any,
};
const themeTwo = {
_id: new ObjectId(),
name: "second",
colors: [],
colors: [] as any,
};
const themeThree = {
_id: new ObjectId(),
name: "third",
colors: [],
colors: [] as any,
};
const { uid } = await UserTestData.createUser({

View file

@ -8,6 +8,7 @@ import { ObjectId } from "mongodb";
import { hashSync } from "bcrypt";
import MonkeyError from "../../src/utils/error";
import * as Misc from "../../src/utils/misc";
import crypto from "crypto";
import {
EndpointMetadata,
RequestAuthenticationOptions,
@ -36,7 +37,7 @@ 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 mockRequest: Partial<Auth.TsRestRequestWithCtx>;
let mockResponse: Partial<Response>;
let nextFunction: NextFunction;
@ -78,193 +79,17 @@ describe("middlewares/auth", () => {
isDevModeMock.mockReset();
});
describe("authenticateRequest", () => {
it("should fail if token is not fresh", async () => {
Date.now = vi.fn(() => 60001);
const authenticateRequest = Auth.authenticateRequest({
requireFreshToken: true,
});
expect(() =>
authenticateRequest(
mockRequest as Request,
mockResponse as Response,
nextFunction
)
).rejects.toThrowError(
"Unauthorized\nStack: This endpoint requires a fresh token"
);
});
it("should allow the request if token is fresh", async () => {
Date.now = vi.fn(() => 10000);
const authenticateRequest = Auth.authenticateRequest({
requireFreshToken: true,
});
await authenticateRequest(
mockRequest as Request,
mockResponse as Response,
nextFunction
);
const decodedToken = mockRequest?.ctx?.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 if apeKey is supported", async () => {
mockRequest.headers = {
authorization: "ApeKey aWQua2V5",
};
const authenticateRequest = Auth.authenticateRequest({
acceptApeKeys: true,
});
await authenticateRequest(
mockRequest as Request,
mockResponse as Response,
nextFunction
);
const decodedToken = mockRequest?.ctx?.decodedToken;
expect(decodedToken?.type).toBe("ApeKey");
expect(decodedToken?.email).toBe("");
expect(decodedToken?.uid).toBe("123");
expect(nextFunction).toHaveBeenCalledTimes(1);
});
it("should allow the request with authentation on public endpoint", async () => {
const authenticateRequest = Auth.authenticateRequest({
isPublic: true,
});
await authenticateRequest(
mockRequest as Request,
mockResponse as Response,
nextFunction
);
const decodedToken = mockRequest?.ctx?.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 () => {
mockRequest.headers = {};
const authenticateRequest = Auth.authenticateRequest({
isPublic: true,
});
await authenticateRequest(
mockRequest as Request,
mockResponse as Response,
nextFunction
);
const decodedToken = mockRequest?.ctx?.decodedToken;
expect(decodedToken?.type).toBe("None");
expect(decodedToken?.email).toBe("");
expect(decodedToken?.uid).toBe("");
expect(nextFunction).toHaveBeenCalledTimes(1);
});
it("should allow the request with apeKey on public endpoint", async () => {
mockRequest.headers = {
authorization: "ApeKey aWQua2V5",
};
const authenticateRequest = Auth.authenticateRequest({
isPublic: true,
});
await authenticateRequest(
mockRequest as Request,
mockResponse as Response,
nextFunction
);
const decodedToken = mockRequest?.ctx?.decodedToken;
expect(decodedToken?.type).toBe("ApeKey");
expect(decodedToken?.email).toBe("");
expect(decodedToken?.uid).toBe("123");
expect(nextFunction).toHaveBeenCalledTimes(1);
});
it("should allow request with Uid on dev", async () => {
mockRequest.headers = {
authorization: "Uid 123",
};
const authenticateRequest = Auth.authenticateRequest({});
await authenticateRequest(
mockRequest as Request,
mockResponse as Response,
nextFunction
);
const decodedToken = mockRequest?.ctx?.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 () => {
mockRequest.headers = {
authorization: "Uid 123|test@example.com",
};
const authenticateRequest = Auth.authenticateRequest({});
await authenticateRequest(
mockRequest as Request,
mockResponse as Response,
nextFunction
);
const decodedToken = mockRequest?.ctx?.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 () => {
isDevModeMock.mockReturnValue(false);
mockRequest.headers = {
authorization: "Uid 123",
};
const authenticateRequest = Auth.authenticateRequest({});
await expect(() =>
authenticateRequest(
mockRequest as Request,
mockResponse as Response,
nextFunction
)
).rejects.toThrow(
new MonkeyError(401, "Baerer type uid is not supported")
);
});
});
describe("authenticateTsRestRequest", () => {
const prometheusRecordAuthTimeMock = vi.spyOn(Prometheus, "recordAuthTime");
const prometheusIncrementAuthMock = vi.spyOn(Prometheus, "incrementAuth");
const timingSafeEqualMock = vi.spyOn(crypto, "timingSafeEqual");
beforeEach(() =>
beforeEach(() => {
timingSafeEqualMock.mockReset().mockReturnValue(true);
[prometheusIncrementAuthMock, prometheusRecordAuthTimeMock].forEach(
(it) => it.mockReset()
)
);
);
});
it("should fail if token is not fresh", async () => {
//GIVEN
@ -604,6 +429,124 @@ describe("middlewares/auth", () => {
expect(prometheusIncrementAuthMock).toHaveBeenCalledWith("ApeKey");
expect(prometheusRecordAuthTimeMock).toHaveBeenCalledOnce();
});
it("should allow githubwebhook with header", async () => {
vi.stubEnv("GITHUB_WEBHOOK_SECRET", "GITHUB_WEBHOOK_SECRET");
//WHEN
const result = await authenticate(
{
headers: { "x-hub-signature-256": "the-signature" },
body: { action: "published", release: { id: 1 } },
},
{ isGithubWebhook: true }
);
//THEN
const decodedToken = result.decodedToken;
expect(decodedToken?.type).toBe("GithubWebhook");
expect(decodedToken?.email).toBe("");
expect(decodedToken?.uid).toBe("");
expect(nextFunction).toHaveBeenCalledTimes(1);
expect(prometheusIncrementAuthMock).toHaveBeenCalledWith("GithubWebhook");
expect(prometheusRecordAuthTimeMock).toHaveBeenCalledOnce();
expect(timingSafeEqualMock).toHaveBeenCalledWith(
Buffer.from(
"sha256=ff0f3080539e9df19153f6b5b5780f66e558d61038e6cf5ecf4efdc7266a7751"
),
Buffer.from("the-signature")
);
});
it("should fail githubwebhook with mismatched signature", async () => {
vi.stubEnv("GITHUB_WEBHOOK_SECRET", "GITHUB_WEBHOOK_SECRET");
timingSafeEqualMock.mockReturnValue(false);
await expect(() =>
authenticate(
{
headers: { "x-hub-signature-256": "the-signature" },
body: { action: "published", release: { id: 1 } },
},
{ isGithubWebhook: true }
)
).rejects.toThrowError("Github webhook signature invalid");
//THEH
expect(prometheusIncrementAuthMock).not.toHaveBeenCalled();
expect(prometheusRecordAuthTimeMock).toHaveBeenCalledWith(
"None",
"failure",
expect.anything(),
expect.anything()
);
});
it("should fail without header when endpoint is using githubwebhook", async () => {
vi.stubEnv("GITHUB_WEBHOOK_SECRET", "GITHUB_WEBHOOK_SECRET");
await expect(() =>
authenticate(
{
headers: {},
body: { action: "published", release: { id: 1 } },
},
{ isGithubWebhook: true }
)
).rejects.toThrowError("Missing Github signature header");
//THEH
expect(prometheusIncrementAuthMock).not.toHaveBeenCalled();
expect(prometheusRecordAuthTimeMock).toHaveBeenCalledWith(
"None",
"failure",
expect.anything(),
expect.anything()
);
});
it("should fail with missing GITHUB_WEBHOOK_SECRET when endpoint is using githubwebhook", async () => {
vi.stubEnv("GITHUB_WEBHOOK_SECRET", "");
await expect(() =>
authenticate(
{
headers: { "x-hub-signature-256": "the-signature" },
body: { action: "published", release: { id: 1 } },
},
{ isGithubWebhook: true }
)
).rejects.toThrowError("Missing Github Webhook Secret");
//THEH
expect(prometheusIncrementAuthMock).not.toHaveBeenCalled();
expect(prometheusRecordAuthTimeMock).toHaveBeenCalledWith(
"None",
"failure",
expect.anything(),
expect.anything()
);
});
it("should throw 500 if something went wrong when validating the signature when endpoint is using githubwebhook", async () => {
vi.stubEnv("GITHUB_WEBHOOK_SECRET", "GITHUB_WEBHOOK_SECRET");
timingSafeEqualMock.mockImplementation(() => {
throw new Error("could not validate");
});
await expect(() =>
authenticate(
{
headers: { "x-hub-signature-256": "the-signature" },
body: { action: "published", release: { id: 1 } },
},
{ isGithubWebhook: true }
)
).rejects.toThrowError(
"Failed to authenticate Github webhook: could not validate"
);
//THEH
expect(prometheusIncrementAuthMock).not.toHaveBeenCalled();
expect(prometheusRecordAuthTimeMock).toHaveBeenCalledWith(
"None",
"failure",
expect.anything(),
expect.anything()
);
});
});
});

View file

@ -0,0 +1,185 @@
import { RequireConfiguration } from "@monkeytype/contracts/require-configuration/index";
import { verifyRequiredConfiguration } from "../../src/middlewares/configuration";
import { Configuration } from "@monkeytype/contracts/schemas/configuration";
import { Response } from "express";
import MonkeyError from "../../src/utils/error";
describe("configuration middleware", () => {
const handler = verifyRequiredConfiguration();
const res: Response = {} as any;
const next = vi.fn();
beforeEach(() => {
next.mockReset();
});
afterEach(() => {
//next function must only be called once
expect(next).toHaveBeenCalledOnce();
});
it("should pass without requireConfiguration", async () => {
//GIVEN
const req = { tsRestRoute: { metadata: {} } } as any;
//WHEN
await handler(req, res, next);
//THEN
expect(next).toHaveBeenCalledWith();
});
it("should pass for enabled configuration", async () => {
//GIVEN
const req = givenRequest({ path: "maintenance" }, { maintenance: true });
//WHEN
await handler(req, res, next);
//THEN
expect(next).toHaveBeenCalledWith();
});
it("should pass for enabled configuration with complex path", async () => {
//GIVEN
const req = givenRequest(
{ path: "users.xp.streak.enabled" },
{ users: { xp: { streak: { enabled: true } as any } as any } as any }
);
//WHEN
await handler(req, res, next);
//THEN
expect(next).toHaveBeenCalledWith();
});
it("should fail for disabled configuration", async () => {
//GIVEN
const req = givenRequest({ path: "maintenance" }, { maintenance: false });
//WHEN
await handler(req, res, next);
//THEN
expect(next).toHaveBeenCalledWith(
new MonkeyError(503, "This endpoint is currently unavailable.")
);
});
it("should fail for disabled configuration and custom message", async () => {
//GIVEN
const req = givenRequest(
{ path: "maintenance", invalidMessage: "Feature not enabled." },
{ maintenance: false }
);
//WHEN
await handler(req, res, next);
//THEN
expect(next).toHaveBeenCalledWith(
new MonkeyError(503, "Feature not enabled.")
);
});
it("should fail for invalid path", async () => {
//GIVEN
const req = givenRequest({ path: "invalid.path" as any }, {});
//WHEN
await handler(req, res, next);
//THEN
expect(next).toHaveBeenCalledWith(
new MonkeyError(503, 'Invalid configuration path: "invalid.path"')
);
});
it("should fail for undefined value", async () => {
//GIVEN
const req = givenRequest(
{ path: "admin.endpointsEnabled" },
{ admin: {} as any }
);
//WHEN
await handler(req, res, next);
//THEN
expect(next).toHaveBeenCalledWith(
new MonkeyError(
500,
'Required configuration doesnt exist: "admin.endpointsEnabled"'
)
);
});
it("should fail for null value", async () => {
//GIVEN
const req = givenRequest(
{ path: "admin.endpointsEnabled" },
{ admin: { endpointsEnabled: null as any } }
);
//WHEN
await handler(req, res, next);
//THEN
expect(next).toHaveBeenCalledWith(
new MonkeyError(
500,
'Required configuration doesnt exist: "admin.endpointsEnabled"'
)
);
});
it("should fail for non booean value", async () => {
//GIVEN
const req = givenRequest(
{ path: "admin.endpointsEnabled" },
{ admin: { endpointsEnabled: "disabled" as any } }
);
//WHEN
await handler(req, res, next);
//THEN
expect(next).toHaveBeenCalledWith(
new MonkeyError(
500,
'Required configuration is not a boolean: "admin.endpointsEnabled"'
)
);
});
it("should pass for multiple configurations", async () => {
//GIVEN
const req = givenRequest(
[{ path: "maintenance" }, { path: "admin.endpointsEnabled" }],
{ maintenance: true, admin: { endpointsEnabled: true } }
);
//WHEN
await handler(req, res, next);
//THEN
expect(next).toHaveBeenCalledWith();
});
it("should fail for multiple configurations", async () => {
//GIVEN
const req = givenRequest(
[
{ path: "maintenance", invalidMessage: "maintenance mode" },
{ path: "admin.endpointsEnabled", invalidMessage: "admin disabled" },
],
{ maintenance: true, admin: { endpointsEnabled: false } }
);
//WHEN
await handler(req, res, next);
//THEN
expect(next).toHaveBeenCalledWith(new MonkeyError(503, "admin disabled"));
});
});
function givenRequest(
requireConfiguration: RequireConfiguration | RequireConfiguration[],
configuration: Partial<Configuration>
): TsRestRequest {
return {
tsRestRoute: { metadata: { requireConfiguration } },
ctx: { configuration },
} as any;
}

View file

@ -0,0 +1,317 @@
import { Response } from "express";
import { verifyPermissions } from "../../src/middlewares/permission";
import { EndpointMetadata } from "@monkeytype/contracts/schemas/api";
import * as Misc from "../../src/utils/misc";
import * as AdminUids from "../../src/dal/admin-uids";
import * as UserDal from "../../src/dal/user";
import MonkeyError from "../../src/utils/error";
const uid = "123456789";
describe("permission middleware", () => {
const handler = verifyPermissions();
const res: Response = {} as any;
const next = vi.fn();
const getPartialUserMock = vi.spyOn(UserDal, "getPartialUser");
const isAdminMock = vi.spyOn(AdminUids, "isAdmin");
const isDevMock = vi.spyOn(Misc, "isDevEnvironment");
beforeEach(() => {
next.mockReset();
getPartialUserMock.mockReset().mockResolvedValue({} as any);
isDevMock.mockReset().mockReturnValue(false);
isAdminMock.mockReset().mockResolvedValue(false);
});
afterEach(() => {
//next function must only be called once
expect(next).toHaveBeenCalledOnce();
});
it("should bypass without requiredPermission", async () => {
//GIVEN
const req = givenRequest({});
//WHEN
await handler(req, res, next);
//THEN
expect(next).toHaveBeenCalledWith();
});
it("should bypass with empty requiredPermission", async () => {
//GIVEN
const req = givenRequest({ requirePermission: [] });
//WHEN
await handler(req, res, next);
//THE
expect(next).toHaveBeenCalledWith();
});
describe("admin check", () => {
const requireAdminPermission: EndpointMetadata = {
requirePermission: "admin",
};
it("should fail without authentication", async () => {
//GIVEN
const req = givenRequest(requireAdminPermission);
//WHEN
await handler(req, res, next);
//THEN
expect(next).toHaveBeenCalledWith(
new MonkeyError(403, "You don't have permission to do this.")
);
});
it("should pass without authentication if publicOnDev on dev", async () => {
//GIVEN
isDevMock.mockReturnValue(true);
const req = givenRequest(
{
...requireAdminPermission,
authenticationOptions: { isPublicOnDev: true },
},
{ uid }
);
//WHEN
await handler(req, res, next);
//THEN
expect(next).toHaveBeenCalledWith();
});
it("should fail without authentication if publicOnDev on prod ", async () => {
//GIVEN
const req = givenRequest(
{
...requireAdminPermission,
authenticationOptions: { isPublicOnDev: true },
},
{ uid }
);
//WHEN
await handler(req, res, next);
//THEN
expect(next).toHaveBeenCalledWith(
new MonkeyError(403, "You don't have permission to do this.")
);
});
it("should fail without admin permissions", async () => {
//GIVEN
const req = givenRequest(requireAdminPermission, { uid });
//WHEN
await handler(req, res, next);
//THEN
expect(next).toHaveBeenCalledWith(
new MonkeyError(403, "You don't have permission to do this.")
);
expect(isAdminMock).toHaveBeenCalledWith(uid);
});
});
describe("user checks", () => {
it("should fetch user only once", async () => {
//GIVEN
const req = givenRequest(
{
requirePermission: ["canReport", "canManageApeKeys"],
},
{ uid }
);
//WHEN
await handler(req, res, next);
//THEN
expect(getPartialUserMock).toHaveBeenCalledOnce();
expect(getPartialUserMock).toHaveBeenCalledWith(
uid,
"check user permissions",
["canReport", "canManageApeKeys"]
);
});
it("should fail if authentication is missing", async () => {
//GIVEN
const req = givenRequest({
requirePermission: ["canReport", "canManageApeKeys"],
});
//WHEN
await handler(req, res, next);
//THEN
expect(next).toHaveBeenCalledWith(
new MonkeyError(
403,
"Failed to check permissions, authentication required."
)
);
});
});
describe("quoteMod check", () => {
const requireQuoteMod: EndpointMetadata = {
requirePermission: "quoteMod",
};
it("should pass for quoteAdmin", async () => {
//GIVEN
getPartialUserMock.mockResolvedValue({ quoteMod: true } as any);
const req = givenRequest(requireQuoteMod, { uid });
//WHEN
await handler(req, res, next);
//THEN
expect(next).toHaveBeenCalledWith();
expect(getPartialUserMock).toHaveBeenCalledWith(
uid,
"check user permissions",
["quoteMod"]
);
});
it("should pass for specific language", async () => {
//GIVEN
getPartialUserMock.mockResolvedValue({ quoteMod: "english" } as any);
const req = givenRequest(requireQuoteMod, { uid });
//WHEN
await handler(req, res, next);
//THEN
expect(next).toHaveBeenCalledWith();
expect(getPartialUserMock).toHaveBeenCalledWith(
uid,
"check user permissions",
["quoteMod"]
);
});
it("should fail for empty string", async () => {
//GIVEN
getPartialUserMock.mockResolvedValue({ quoteMod: "" } as any);
const req = givenRequest(requireQuoteMod, { uid });
//WHEN
await handler(req, res, next);
//THEN
expect(next).toHaveBeenCalledWith(
new MonkeyError(403, "You don't have permission to do this.")
);
});
it("should fail for missing quoteMod", async () => {
//GIVEN
getPartialUserMock.mockResolvedValue({} as any);
const req = givenRequest(requireQuoteMod, { uid });
//WHEN
await handler(req, res, next);
//THEN
expect(next).toHaveBeenCalledWith(
new MonkeyError(403, "You don't have permission to do this.")
);
});
});
describe("canReport check", () => {
const requireCanReport: EndpointMetadata = {
requirePermission: "canReport",
};
it("should fail if user cannot report", async () => {
//GIVEN
getPartialUserMock.mockResolvedValue({ canReport: false } as any);
const req = givenRequest(requireCanReport, { uid });
//WHEN
await handler(req, res, next);
//THEN
expect(next).toHaveBeenCalledWith(
new MonkeyError(403, "You don't have permission to do this.")
);
expect(getPartialUserMock).toHaveBeenCalledWith(
uid,
"check user permissions",
["canReport"]
);
});
it("should pass if user can report", async () => {
//GIVEN
getPartialUserMock.mockResolvedValue({ canReport: true } as any);
const req = givenRequest(requireCanReport, { uid });
//WHEN
await handler(req, res, next);
//THEN
expect(next).toHaveBeenCalledWith();
});
it("should pass if canReport is not set", async () => {
//GIVEN
getPartialUserMock.mockResolvedValue({} as any);
const req = givenRequest(requireCanReport, { uid });
//WHEN
await handler(req, res, next);
//THEN
expect(next).toHaveBeenCalledWith();
});
});
describe("canManageApeKeys check", () => {
const requireCanReport: EndpointMetadata = {
requirePermission: "canManageApeKeys",
};
it("should fail if user cannot report", async () => {
//GIVEN
getPartialUserMock.mockResolvedValue({ canManageApeKeys: false } as any);
const req = givenRequest(requireCanReport, { uid });
//WHEN
await handler(req, res, next);
//THEN
expect(next).toHaveBeenCalledWith(
new MonkeyError(
403,
"You have lost access to ape keys, please contact support"
)
);
expect(getPartialUserMock).toHaveBeenCalledWith(
uid,
"check user permissions",
["canManageApeKeys"]
);
});
it("should pass if user can report", async () => {
//GIVEN
getPartialUserMock.mockResolvedValue({ canManageApeKeys: true } as any);
const req = givenRequest(requireCanReport, { uid });
//WHEN
await handler(req, res, next);
//THEN
expect(next).toHaveBeenCalledWith();
});
it("should pass if canManageApeKeys is not set", async () => {
//GIVEN
getPartialUserMock.mockResolvedValue({} as any);
const req = givenRequest(requireCanReport, { uid });
//WHEN
await handler(req, res, next);
//THEN
expect(next).toHaveBeenCalledWith();
});
});
});
function givenRequest(
metadata: EndpointMetadata,
decodedToken?: Partial<MonkeyTypes.DecodedToken>
): TsRestRequest {
return { tsRestRoute: { metadata }, ctx: { decodedToken } } as any;
}

View file

@ -2,6 +2,7 @@ import { Collection, Db, MongoClient, WithId } from "mongodb";
import { afterAll, beforeAll, afterEach } from "vitest";
import * as MongoDbMock from "vitest-mongodb";
import { MongoDbMockConfig } from "./global-setup";
import { enableRateLimitExpects } from "./__testData__/rate-limit";
process.env["MODE"] = "dev";
//process.env["MONGOMS_DISTRO"] = "ubuntu-22.04";

View file

@ -7,11 +7,6 @@
"ts-node": {
"files": true
},
"files": ["../src/types/types.d.ts"],
"include": [
"./**/*.ts",
"./**/*.spec.ts",
"./setup-tests.ts",
"../../shared-types/**/*.d.ts"
]
"files": ["../src/types/types.d.ts", "vitest.d.ts"],
"include": ["./**/*.ts", "./**/*.spec.ts", "./setup-tests.ts"]
}

View file

@ -1,5 +1,7 @@
import _ from "lodash";
import * as pb from "../../src/utils/pb";
import { Mode, PersonalBests } from "@monkeytype/contracts/schemas/shared";
import { Result } from "@monkeytype/contracts/schemas/results";
describe("Pb Utils", () => {
it("funboxCatGetPb", () => {
@ -34,7 +36,7 @@ describe("Pb Utils", () => {
});
describe("checkAndUpdatePb", () => {
it("should update personal best", () => {
const userPbs: SharedTypes.PersonalBests = {
const userPbs: PersonalBests = {
time: {},
words: {},
custom: {},
@ -53,15 +55,20 @@ describe("Pb Utils", () => {
numbers: false,
mode: "time",
mode2: "15",
} as unknown as SharedTypes.Result<SharedTypes.Config.Mode>;
} as unknown as Result<Mode>;
const run = pb.checkAndUpdatePb(userPbs, undefined, result);
const run = pb.checkAndUpdatePb(
userPbs,
{} as MonkeyTypes.LbPersonalBests,
result
);
expect(run.isPb).toBe(true);
expect(run.personalBests?.["time"]?.["15"]?.[0]).not.toBe(undefined);
expect(run.lbPersonalBests).not.toBe({});
});
it("should not override default pb when saving numbers test", () => {
const userPbs: SharedTypes.PersonalBests = {
const userPbs: PersonalBests = {
time: {
"15": [
{
@ -95,7 +102,7 @@ describe("Pb Utils", () => {
numbers: true,
mode: "time",
mode2: "15",
} as unknown as SharedTypes.Result<SharedTypes.Config.Mode>;
} as unknown as Result<Mode>;
const run = pb.checkAndUpdatePb(userPbs, undefined, result);
@ -109,4 +116,93 @@ describe("Pb Utils", () => {
);
});
});
describe("updateLeaderboardPersonalBests", () => {
const userPbs: PersonalBests = {
time: {
"15": [
{
acc: 100,
consistency: 100,
difficulty: "normal",
lazyMode: false,
language: "english",
numbers: false,
punctuation: false,
raw: 100,
timestamp: 0,
wpm: 100,
},
{
acc: 100,
consistency: 100,
difficulty: "normal",
lazyMode: false,
language: "spanish",
numbers: false,
punctuation: false,
raw: 100,
timestamp: 0,
wpm: 100,
},
],
},
words: {},
custom: {},
quote: {},
zen: {},
};
it("should update leaderboard personal bests if they dont exist or the structure is incomplete", () => {
const lbpbstartingvalues = [
undefined,
{},
{ time: {} },
{ time: { "15": {} } },
{ time: { "15": { english: {} } } },
];
const result15 = {
mode: "time",
mode2: "15",
} as unknown as Result<Mode>;
for (const lbPb of lbpbstartingvalues) {
const lbPbPb = pb.updateLeaderboardPersonalBests(
userPbs,
_.cloneDeep(lbPb) as MonkeyTypes.LbPersonalBests,
result15
);
expect(lbPbPb).toEqual({
time: {
"15": {
english: {
acc: 100,
consistency: 100,
difficulty: "normal",
lazyMode: false,
language: "english",
numbers: false,
punctuation: false,
raw: 100,
timestamp: 0,
wpm: 100,
},
spanish: {
acc: 100,
consistency: 100,
difficulty: "normal",
lazyMode: false,
language: "spanish",
numbers: false,
punctuation: false,
raw: 100,
timestamp: 0,
wpm: 100,
},
},
},
});
}
});
});
});

View file

@ -1,162 +1,6 @@
import * as Validation from "../../src/utils/validation";
import { isTagPresetNameValid } from "../../src/utils/validation";
describe("Validation", () => {
it("isTagPresetNameValid", () => {
const testCases = [
{
name: "valid_name",
expected: true,
},
{
name: "validname",
expected: true,
},
{
name: "valid-name",
expected: true,
},
{
name: "thistagnameistoolong",
expected: false,
},
{
name: "",
expected: false,
},
{
name: "invalid name",
expected: false,
},
{
name: "invalid=name",
expected: false,
},
];
testCases.forEach((testCase) => {
expect(Validation.isTagPresetNameValid(testCase.name)).toBe(
testCase.expected
);
});
});
it("inRange", () => {
const testCases = [
{
value: 1,
min: 1,
max: 2,
expected: true,
},
{
value: 1,
min: 2,
max: 2,
expected: false,
},
{
value: 1,
min: 1,
max: 1,
expected: true,
},
{
value: 53,
min: -100,
max: 100,
expected: true,
},
{
value: 153,
min: -100,
max: 100,
expected: false,
},
];
testCases.forEach((testCase) => {
expect(
Validation.inRange(testCase.value, testCase.min, testCase.max)
).toBe(testCase.expected);
});
});
it("isUsernameValid", () => {
const testCases = [
{
name: "Bruce",
expected: true,
},
{
name: "Rizwan_123",
expected: true,
},
{
name: "Fe-rotiq_123_",
expected: true,
},
{
name: " ",
expected: false,
},
{
name: "",
expected: false,
},
{
name: "superduperlongnamethatshouldbeinvalid",
expected: false,
},
{
name: ".period",
expected: false,
},
];
testCases.forEach((testCase) => {
expect(Validation.isUsernameValid(testCase.name)).toBe(testCase.expected);
});
});
it("containsProfanity", () => {
const testCases = [
{
text: "https://www.fuckyou.com",
expected: true,
},
{
text: "fucking_profane",
expected: true,
},
{
text: "fucker",
expected: true,
},
{
text: "Hello world!",
expected: false,
},
{
text: "I fucking hate you",
expected: true,
},
{
text: "I love you",
expected: false,
},
{
text: "\n.fuck!",
expected: true,
},
];
testCases.forEach((testCase) => {
expect(Validation.containsProfanity(testCase.text, "substring")).toBe(
testCase.expected
);
});
});
it("isTestTooShort", () => {
const testCases = [
{
@ -202,8 +46,7 @@ describe("Validation", () => {
];
testCases.forEach((testCase) => {
//@ts-ignore
expect(Validation.isTestTooShort(testCase.result)).toBe(
expect(Validation.isTestTooShort(testCase.result as any)).toBe(
testCase.expected
);
});

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

@ -0,0 +1,24 @@
import type { Assertion, AsymmetricMatchersContaining } from "vitest";
import type { Test as SuperTest } from "supertest";
type ExpectedRateLimit = {
/** max calls */
max: number;
/** window in milliseconds. Needs to be within 2500ms */
windowMs: number;
};
interface RestRequestMatcher<R = Supertest> {
toBeRateLimited: (expected: ExpectedRateLimit) => RestRequestMatcher<R>;
}
declare module "vitest" {
interface Assertion<T = any> extends RestRequestMatcher<T> {}
interface AsymmetricMatchersContaining extends RestRequestMatcher {}
}
interface MatcherResult {
pass: boolean;
message: () => string;
actual?: unknown;
expected?: unknown;
}

View file

@ -24,9 +24,9 @@
"dependencies": {
"@date-fns/utc": "1.2.0",
"@monkeytype/contracts": "workspace:*",
"@ts-rest/core": "3.49.3",
"@ts-rest/express": "3.49.3",
"@ts-rest/open-api": "3.49.3",
"@ts-rest/core": "3.51.0",
"@ts-rest/express": "3.51.0",
"@ts-rest/open-api": "3.51.0",
"bcrypt": "5.1.1",
"bullmq": "1.91.1",
"chalk": "4.1.2",
@ -34,12 +34,11 @@
"cron": "2.3.0",
"date-fns": "3.6.0",
"dotenv": "16.4.5",
"express": "4.19.2",
"express-rate-limit": "6.2.1",
"express": "4.20.0",
"express-rate-limit": "7.4.0",
"firebase-admin": "12.0.0",
"helmet": "4.6.0",
"ioredis": "4.28.5",
"joi": "17.6.0",
"lodash": "4.17.21",
"lru-cache": "7.10.1",
"mjml": "4.15.0",
@ -55,7 +54,6 @@
"simple-git": "3.16.0",
"string-similarity": "4.0.4",
"swagger-stats": "0.99.7",
"swagger-ui-express": "4.3.0",
"ua-parser-js": "0.7.33",
"uuid": "10.0.0",
"winston": "3.6.0",
@ -63,9 +61,8 @@
},
"devDependencies": {
"@monkeytype/eslint-config": "workspace:*",
"@monkeytype/shared-types": "workspace:*",
"@monkeytype/typescript-config": "workspace:*",
"@redocly/cli": "1.19.0",
"@redocly/cli": "1.22.0",
"@types/bcrypt": "5.0.2",
"@types/cors": "2.8.12",
"@types/cron": "1.7.3",
@ -82,7 +79,6 @@
"@types/string-similarity": "4.0.2",
"@types/supertest": "2.0.12",
"@types/swagger-stats": "0.95.11",
"@types/swagger-ui-express": "4.1.3",
"@types/ua-parser-js": "0.7.36",
"@types/uuid": "10.0.0",
"@vitest/coverage-v8": "2.0.5",
@ -90,6 +86,7 @@
"eslint": "8.57.0",
"eslint-watch": "8.0.0",
"ioredis-mock": "7.4.0",
"openapi3-ts": "2.0.2",
"readline-sync": "1.4.10",
"supertest": "6.2.3",
"tsx": "4.16.2",

View file

@ -35,7 +35,7 @@ features.openapi:
http:
delete: "#da3333"
post: "#004D94"
patch: "#e2b714"
patch: "#af8d0f"
get: "#009400"
sidebar:
backgroundColor: "#323437"

View file

@ -1,8 +1,18 @@
import { generateOpenApi } from "@ts-rest/open-api";
import { contract } from "@monkeytype/contracts/index";
import { writeFileSync, mkdirSync } from "fs";
import { EndpointMetadata } from "@monkeytype/contracts/schemas/api";
import type { OpenAPIObject } from "openapi3-ts";
import {
EndpointMetadata,
PermissionId,
} from "@monkeytype/contracts/schemas/api";
import type { OpenAPIObject, OperationObject } from "openapi3-ts";
import {
RateLimitIds,
getLimits,
RateLimiterId,
Window,
} from "@monkeytype/contracts/rate-limit/index";
import { formatDuration } from "date-fns";
type SecurityRequirementObject = {
[name: string]: string[];
@ -16,7 +26,7 @@ export function getOpenApi(): OpenAPIObject {
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.",
"Documentation for the 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: {
@ -50,6 +60,12 @@ export function getOpenApi(): OpenAPIObject {
},
},
tags: [
{
name: "users",
description: "User account data.",
"x-displayName": "Users",
"x-public": "yes",
},
{
name: "configs",
description:
@ -112,53 +128,154 @@ export function getOpenApi(): OpenAPIObject {
"x-public": "yes",
},
{
name: "dev",
name: "development",
description:
"Development related endpoints. Only available on dev environment",
"x-displayName": "Development",
"x-public": "no",
},
{
name: "webhooks",
description: "Endpoints for incoming webhooks.",
"x-displayName": "Webhooks",
"x-public": "yes",
},
],
},
{
jsonQuery: true,
setOperationId: "concatenated-path",
operationMapper: (operation, route) => ({
...operation,
...addAuth(route.metadata as EndpointMetadata),
...addTags(route.metadata as EndpointMetadata),
}),
operationMapper: (operation, route) => {
const metadata = route.metadata as EndpointMetadata;
if (!operation.description?.trim()?.endsWith(".")) {
operation.description += ".";
}
operation.description += "\n\n";
addAuth(operation, metadata);
addRateLimit(operation, metadata);
addRequiredConfiguration(operation, metadata);
addTags(operation, metadata);
return operation;
},
}
);
return openApiDocument;
}
function addAuth(metadata: EndpointMetadata | undefined): object {
const auth = metadata?.["authenticationOptions"] ?? {};
function addAuth(
operation: OperationObject,
metadata: EndpointMetadata | undefined
): void {
const auth = metadata?.authenticationOptions ?? {};
const permissions = getRequiredPermissions(metadata) ?? [];
const security: SecurityRequirementObject[] = [];
if (!auth.isPublic === true) {
security.push({ BearerAuth: [] });
if (!auth.isPublic && !auth.isPublicOnDev) {
security.push({ BearerAuth: permissions });
if (auth.acceptApeKeys === true) {
security.push({ ApeKey: [] });
security.push({ ApeKey: permissions });
}
}
const includeInPublic = auth.isPublic === true || auth.acceptApeKeys === true;
return {
"x-public": includeInPublic ? "yes" : "no",
security,
operation["x-public"] = includeInPublic ? "yes" : "no";
operation.security = security;
if (permissions.length !== 0) {
operation.description += `**Required permissions:** ${permissions.join(
", "
)}\n\n`;
}
}
function getRequiredPermissions(
metadata: EndpointMetadata | undefined
): Permission[] | undefined {
if (metadata === undefined || metadata.requirePermission === undefined)
return undefined;
if (Array.isArray(metadata.requirePermission))
return metadata.requirePermission;
return [metadata.requirePermission];
}
function addTags(
operation: OperationObject,
metadata: EndpointMetadata | undefined
): void {
if (metadata === undefined || metadata.openApiTags === undefined) return;
operation.tags = Array.isArray(metadata.openApiTags)
? metadata.openApiTags
: [metadata.openApiTags];
}
function addRateLimit(
operation: OperationObject,
metadata: EndpointMetadata | undefined
): void {
if (metadata === undefined || metadata.rateLimit === undefined) return;
const okResponse = operation.responses["200"];
if (okResponse === undefined) return;
operation.description += getRateLimitDescription(metadata.rateLimit);
okResponse["headers"] = {
...okResponse["headers"],
"x-ratelimit-limit": {
schema: { type: "integer" },
description: "The number of allowed requests in the current period",
},
"x-ratelimit-remaining": {
schema: { type: "integer" },
description: "The number of remaining requests in the current period",
},
"x-ratelimit-reset": {
schema: { type: "integer" },
description: "The timestamp of the start of the next period",
},
};
}
function addTags(metadata: EndpointMetadata | undefined): object {
if (metadata === undefined || metadata.openApiTags === undefined) return {};
return {
tags: Array.isArray(metadata.openApiTags)
? metadata.openApiTags
: [metadata.openApiTags],
};
function getRateLimitDescription(limit: RateLimiterId | RateLimitIds): string {
const limits = getLimits(limit);
let result = `**Rate limit:** This operation can be called up to ${
limits.limiter.max
} times ${formatWindow(limits.limiter.window)} for regular users`;
if (limits.apeKeyLimiter !== undefined) {
result += ` and up to ${limits.apeKeyLimiter.max} times ${formatWindow(
limits.apeKeyLimiter.window
)} with ApeKeys`;
}
return result + ".\n\n";
}
function formatWindow(window: Window): string {
if (typeof window === "number") {
const seconds = Math.floor(window / 1000);
const duration = formatDuration({
hours: Math.floor(seconds / 3600),
minutes: Math.floor(seconds / 60) % 60,
seconds: seconds % 60,
});
return `every ${duration}`;
}
return "per " + window;
}
function addRequiredConfiguration(
operation: OperationObject,
metadata: EndpointMetadata | undefined
): void {
if (metadata === undefined || metadata.requireConfiguration === undefined)
return;
operation.description += `**Required configuration:** This operation can only be called if the [configuration](#tag/configuration/operation/configuration.get) for \`${metadata.requireConfiguration.path}\` is \`true\`.\n\n`;
}
//detect if we run this as a main

View file

@ -0,0 +1,11 @@
{
"extends": "@monkeytype/typescript-config/base.json",
"compilerOptions": {
"target": "ES6"
},
"ts-node": {
"files": true
},
"files": ["../src/types/types.d.ts"],
"include": ["./**/*"]
}

View file

@ -1,4 +1,4 @@
import { MonkeyResponse2 } from "../../utils/monkey-response";
import { MonkeyResponse } from "../../utils/monkey-response";
import { buildMonkeyMail } from "../../utils/monkey-mail";
import * as UserDAL from "../../dal/user";
import * as ReportDAL from "../../dal/report";
@ -15,14 +15,12 @@ import MonkeyError from "../../utils/error";
import { Configuration } from "@monkeytype/contracts/schemas/configuration";
import { addImportantLog } from "../../dal/logs";
export async function test(
_req: MonkeyTypes.Request2
): Promise<MonkeyResponse2> {
return new MonkeyResponse2("OK", null);
export async function test(_req: MonkeyTypes.Request): Promise<MonkeyResponse> {
return new MonkeyResponse("OK", null);
}
export async function toggleBan(
req: MonkeyTypes.Request2<undefined, ToggleBanRequest>
req: MonkeyTypes.Request<undefined, ToggleBanRequest>
): Promise<ToggleBanResponse> {
const { uid } = req.body;
@ -38,31 +36,31 @@ export async function toggleBan(
void addImportantLog("user_ban_toggled", { banned: !user.banned }, uid);
return new MonkeyResponse2(`Ban toggled`, {
return new MonkeyResponse(`Ban toggled`, {
banned: !user.banned,
});
}
export async function acceptReports(
req: MonkeyTypes.Request2<undefined, AcceptReportsRequest>
): Promise<MonkeyResponse2> {
req: MonkeyTypes.Request<undefined, AcceptReportsRequest>
): Promise<MonkeyResponse> {
await handleReports(
req.body.reports.map((it) => ({ ...it })),
true,
req.ctx.configuration.users.inbox
);
return new MonkeyResponse2("Reports removed and users notified.", null);
return new MonkeyResponse("Reports removed and users notified.", null);
}
export async function rejectReports(
req: MonkeyTypes.Request2<undefined, RejectReportsRequest>
): Promise<MonkeyResponse2> {
req: MonkeyTypes.Request<undefined, RejectReportsRequest>
): Promise<MonkeyResponse> {
await handleReports(
req.body.reports.map((it) => ({ ...it })),
false,
req.ctx.configuration.users.inbox
);
return new MonkeyResponse2("Reports removed and users notified.", null);
return new MonkeyResponse("Reports removed and users notified.", null);
}
export async function handleReports(
@ -116,15 +114,19 @@ export async function handleReports(
});
await UserDAL.addToInbox(report.uid, [mail], inboxConfig);
} catch (e) {
throw new MonkeyError(e.status, e.message);
if (e instanceof MonkeyError) {
throw new MonkeyError(e.status, e.message);
} else {
throw new MonkeyError(500, "Error handling reports: " + e.message);
}
}
}
}
export async function sendForgotPasswordEmail(
req: MonkeyTypes.Request2<undefined, SendForgotPasswordEmailRequest>
): Promise<MonkeyResponse2> {
req: MonkeyTypes.Request<undefined, SendForgotPasswordEmailRequest>
): Promise<MonkeyResponse> {
const { email } = req.body;
await authSendForgotPasswordEmail(email);
return new MonkeyResponse2("Password reset request email sent.", null);
return new MonkeyResponse("Password reset request email sent.", null);
}

View file

@ -3,7 +3,7 @@ import { randomBytes } from "crypto";
import { hash } from "bcrypt";
import * as ApeKeysDAL from "../../dal/ape-keys";
import MonkeyError from "../../utils/error";
import { MonkeyResponse2 } from "../../utils/monkey-response";
import { MonkeyResponse } from "../../utils/monkey-response";
import { base64UrlEncode } from "../../utils/misc";
import { ObjectId } from "mongodb";
@ -21,18 +21,18 @@ function cleanApeKey(apeKey: MonkeyTypes.ApeKeyDB): ApeKey {
}
export async function getApeKeys(
req: MonkeyTypes.Request2
req: MonkeyTypes.Request
): Promise<GetApeKeyResponse> {
const { uid } = req.ctx.decodedToken;
const apeKeys = await ApeKeysDAL.getApeKeys(uid);
const cleanedKeys = _(apeKeys).keyBy("_id").mapValues(cleanApeKey).value();
return new MonkeyResponse2("ApeKeys retrieved", cleanedKeys);
return new MonkeyResponse("ApeKeys retrieved", cleanedKeys);
}
export async function generateApeKey(
req: MonkeyTypes.Request2<undefined, AddApeKeyRequest>
req: MonkeyTypes.Request<undefined, AddApeKeyRequest>
): Promise<AddApeKeyResponse> {
const { name, enabled } = req.body;
const { uid } = req.ctx.decodedToken;
@ -62,7 +62,7 @@ export async function generateApeKey(
const apeKeyId = await ApeKeysDAL.addApeKey(apeKey);
return new MonkeyResponse2("ApeKey generated", {
return new MonkeyResponse("ApeKey generated", {
apeKey: base64UrlEncode(`${apeKeyId}.${apiKey}`),
apeKeyId,
apeKeyDetails: cleanApeKey(apeKey),
@ -70,24 +70,24 @@ export async function generateApeKey(
}
export async function editApeKey(
req: MonkeyTypes.Request2<undefined, EditApeKeyRequest, ApeKeyParams>
): Promise<MonkeyResponse2> {
req: MonkeyTypes.Request<undefined, EditApeKeyRequest, ApeKeyParams>
): Promise<MonkeyResponse> {
const { apeKeyId } = req.params;
const { name, enabled } = req.body;
const { uid } = req.ctx.decodedToken;
await ApeKeysDAL.editApeKey(uid, apeKeyId, name, enabled);
return new MonkeyResponse2("ApeKey updated", null);
return new MonkeyResponse("ApeKey updated", null);
}
export async function deleteApeKey(
req: MonkeyTypes.Request2<undefined, undefined, ApeKeyParams>
): Promise<MonkeyResponse2> {
req: MonkeyTypes.Request<undefined, undefined, ApeKeyParams>
): Promise<MonkeyResponse> {
const { apeKeyId } = req.params;
const { uid } = req.ctx.decodedToken;
await ApeKeysDAL.deleteApeKey(uid, apeKeyId);
return new MonkeyResponse2("ApeKey deleted", null);
return new MonkeyResponse("ApeKey deleted", null);
}

View file

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

View file

@ -1,5 +1,5 @@
import * as Configuration from "../../init/configuration";
import { MonkeyResponse2 } from "../../utils/monkey-response";
import { MonkeyResponse } from "../../utils/monkey-response";
import { CONFIGURATION_FORM_SCHEMA } from "../../constants/base-configuration";
import {
ConfigurationSchemaResponse,
@ -9,24 +9,24 @@ import {
import MonkeyError from "../../utils/error";
export async function getConfiguration(
_req: MonkeyTypes.Request2
_req: MonkeyTypes.Request
): Promise<GetConfigurationResponse> {
const currentConfiguration = await Configuration.getLiveConfiguration();
return new MonkeyResponse2("Configuration retrieved", currentConfiguration);
return new MonkeyResponse("Configuration retrieved", currentConfiguration);
}
export async function getSchema(
_req: MonkeyTypes.Request2
_req: MonkeyTypes.Request
): Promise<ConfigurationSchemaResponse> {
return new MonkeyResponse2(
return new MonkeyResponse(
"Configuration schema retrieved",
CONFIGURATION_FORM_SCHEMA
);
}
export async function updateConfiguration(
req: MonkeyTypes.Request2<undefined, PatchConfigurationRequest>
): Promise<MonkeyResponse2> {
req: MonkeyTypes.Request<undefined, PatchConfigurationRequest>
): Promise<MonkeyResponse> {
const { configuration } = req.body;
const success = await Configuration.patchConfiguration(configuration);
@ -34,5 +34,5 @@ export async function updateConfiguration(
throw new MonkeyError(500, "Configuration update failed");
}
return new MonkeyResponse2("Configuration updated", null);
return new MonkeyResponse("Configuration updated", null);
}

View file

@ -1,4 +1,4 @@
import { MonkeyResponse2 } from "../../utils/monkey-response";
import { MonkeyResponse } from "../../utils/monkey-response";
import * as UserDal from "../../dal/user";
import FirebaseAdmin from "../../init/firebase-admin";
import Logger from "../../utils/logger";
@ -28,7 +28,7 @@ const CREATE_RESULT_DEFAULT_OPTIONS = {
};
export async function createTestData(
req: MonkeyTypes.Request2<undefined, GenerateDataRequest>
req: MonkeyTypes.Request<undefined, GenerateDataRequest>
): Promise<GenerateDataResponse> {
const { username, createUser } = req.body;
const user = await getOrCreateUser(username, "password", createUser);
@ -39,7 +39,7 @@ export async function createTestData(
await updateUser(uid);
await updateLeaderboard();
return new MonkeyResponse2("test data created", { uid, email });
return new MonkeyResponse("test data created", { uid, email });
}
async function getOrCreateUser(

View file

@ -4,7 +4,7 @@ import {
MILLISECONDS_IN_DAY,
getCurrentWeekTimestamp,
} from "../../utils/misc";
import { MonkeyResponse2 } from "../../utils/monkey-response";
import { MonkeyResponse } from "../../utils/monkey-response";
import * as LeaderboardsDAL from "../../dal/leaderboards";
import MonkeyError from "../../utils/error";
import * as DailyLeaderboards from "../../utils/daily-leaderboards";
@ -24,7 +24,7 @@ import {
import { Configuration } from "@monkeytype/contracts/schemas/configuration";
export async function getLeaderboard(
req: MonkeyTypes.Request2<GetLeaderboardQuery>
req: MonkeyTypes.Request<GetLeaderboardQuery>
): Promise<GetLeaderboardResponse> {
const { language, mode, mode2, skip = 0, limit = 50 } = req.query;
@ -45,11 +45,11 @@ export async function getLeaderboard(
const normalizedLeaderboard = leaderboard.map((it) => _.omit(it, ["_id"]));
return new MonkeyResponse2("Leaderboard retrieved", normalizedLeaderboard);
return new MonkeyResponse("Leaderboard retrieved", normalizedLeaderboard);
}
export async function getRankFromLeaderboard(
req: MonkeyTypes.Request2<LanguageAndModeQuery>
req: MonkeyTypes.Request<LanguageAndModeQuery>
): Promise<GetLeaderboardRankResponse> {
const { language, mode, mode2 } = req.query;
const { uid } = req.ctx.decodedToken;
@ -62,7 +62,7 @@ export async function getRankFromLeaderboard(
);
}
return new MonkeyResponse2("Rank retrieved", data);
return new MonkeyResponse("Rank retrieved", data);
}
function getDailyLeaderboardWithError(
@ -89,7 +89,7 @@ function getDailyLeaderboardWithError(
}
export async function getDailyLeaderboard(
req: MonkeyTypes.Request2<GetDailyLeaderboardQuery>
req: MonkeyTypes.Request<GetDailyLeaderboardQuery>
): Promise<GetLeaderboardResponse> {
const { skip = 0, limit = 50 } = req.query;
@ -108,11 +108,11 @@ export async function getDailyLeaderboard(
req.ctx.configuration.users.premium.enabled
);
return new MonkeyResponse2("Daily leaderboard retrieved", topResults);
return new MonkeyResponse("Daily leaderboard retrieved", topResults);
}
export async function getDailyLeaderboardRank(
req: MonkeyTypes.Request2<GetDailyLeaderboardRankQuery>
req: MonkeyTypes.Request<GetDailyLeaderboardRankQuery>
): Promise<GetLeaderboardDailyRankResponse> {
const { uid } = req.ctx.decodedToken;
@ -126,7 +126,7 @@ export async function getDailyLeaderboardRank(
req.ctx.configuration.dailyLeaderboards
);
return new MonkeyResponse2("Daily leaderboard rank retrieved", rank);
return new MonkeyResponse("Daily leaderboard rank retrieved", rank);
}
function getWeeklyXpLeaderboardWithError(
@ -147,7 +147,7 @@ function getWeeklyXpLeaderboardWithError(
}
export async function getWeeklyXpLeaderboardResults(
req: MonkeyTypes.Request2<GetWeeklyXpLeaderboardQuery>
req: MonkeyTypes.Request<GetWeeklyXpLeaderboardQuery>
): Promise<GetWeeklyXpLeaderboardResponse> {
const { skip = 0, limit = 50 } = req.query;
@ -164,11 +164,11 @@ export async function getWeeklyXpLeaderboardResults(
req.ctx.configuration.leaderboards.weeklyXp
);
return new MonkeyResponse2("Weekly xp leaderboard retrieved", results);
return new MonkeyResponse("Weekly xp leaderboard retrieved", results);
}
export async function getWeeklyXpLeaderboardRank(
req: MonkeyTypes.Request2
req: MonkeyTypes.Request
): Promise<GetWeeklyXpLeaderboardRankResponse> {
const { uid } = req.ctx.decodedToken;
@ -181,5 +181,5 @@ export async function getWeeklyXpLeaderboardRank(
req.ctx.configuration.leaderboards.weeklyXp
);
return new MonkeyResponse2("Weekly xp leaderboard rank retrieved", rankEntry);
return new MonkeyResponse("Weekly xp leaderboard rank retrieved", rankEntry);
}

View file

@ -5,12 +5,12 @@ import {
GetPresetResponse,
} from "@monkeytype/contracts/presets";
import * as PresetDAL from "../../dal/preset";
import { MonkeyResponse2 } from "../../utils/monkey-response";
import { MonkeyResponse } from "../../utils/monkey-response";
import { replaceObjectId } from "../../utils/misc";
import { Preset } from "@monkeytype/contracts/schemas/presets";
import { EditPresetRequest } from "@monkeytype/contracts/schemas/presets";
export async function getPresets(
req: MonkeyTypes.Request2
req: MonkeyTypes.Request
): Promise<GetPresetResponse> {
const { uid } = req.ctx.decodedToken;
@ -21,36 +21,36 @@ export async function getPresets(
}))
.map((it) => replaceObjectId(it));
return new MonkeyResponse2("Presets retrieved", data);
return new MonkeyResponse("Presets retrieved", data);
}
export async function addPreset(
req: MonkeyTypes.Request2<undefined, AddPresetRequest>
req: MonkeyTypes.Request<undefined, AddPresetRequest>
): Promise<AddPresetResponse> {
const { uid } = req.ctx.decodedToken;
const data = await PresetDAL.addPreset(uid, req.body);
return new MonkeyResponse2("Preset created", data);
return new MonkeyResponse("Preset created", data);
}
export async function editPreset(
req: MonkeyTypes.Request2<undefined, Preset>
): Promise<MonkeyResponse2> {
req: MonkeyTypes.Request<undefined, EditPresetRequest>
): Promise<MonkeyResponse> {
const { uid } = req.ctx.decodedToken;
await PresetDAL.editPreset(uid, req.body);
return new MonkeyResponse2("Preset updated", null);
return new MonkeyResponse("Preset updated", null);
}
export async function removePreset(
req: MonkeyTypes.Request2<undefined, undefined, DeletePresetsParams>
): Promise<MonkeyResponse2> {
req: MonkeyTypes.Request<undefined, undefined, DeletePresetsParams>
): Promise<MonkeyResponse> {
const { presetId } = req.params;
const { uid } = req.ctx.decodedToken;
await PresetDAL.removePreset(uid, presetId);
return new MonkeyResponse2("Preset deleted", null);
return new MonkeyResponse("Preset deleted", null);
}

View file

@ -1,11 +1,11 @@
import { GetPsaResponse } from "@monkeytype/contracts/psas";
import * as PsaDAL from "../../dal/psa";
import { MonkeyResponse2 } from "../../utils/monkey-response";
import { MonkeyResponse } from "../../utils/monkey-response";
import { replaceObjectIds } from "../../utils/misc";
export async function getPsas(
_req: MonkeyTypes.Request2
_req: MonkeyTypes.Request
): Promise<GetPsaResponse> {
const data = await PsaDAL.get();
return new MonkeyResponse2("PSAs retrieved", replaceObjectIds(data));
return new MonkeyResponse("PSAs retrieved", replaceObjectIds(data));
}

View file

@ -4,19 +4,19 @@ import {
GetTypingStatsResponse,
} from "@monkeytype/contracts/public";
import * as PublicDAL from "../../dal/public";
import { MonkeyResponse2 } from "../../utils/monkey-response";
import { MonkeyResponse } from "../../utils/monkey-response";
export async function getSpeedHistogram(
req: MonkeyTypes.Request2<GetSpeedHistogramQuery>
req: MonkeyTypes.Request<GetSpeedHistogramQuery>
): Promise<GetSpeedHistogramResponse> {
const { language, mode, mode2 } = req.query;
const data = await PublicDAL.getSpeedHistogram(language, mode, mode2);
return new MonkeyResponse2("Public speed histogram retrieved", data);
return new MonkeyResponse("Public speed histogram retrieved", data);
}
export async function getTypingStats(
_req: MonkeyTypes.Request2
_req: MonkeyTypes.Request
): Promise<GetTypingStatsResponse> {
const data = await PublicDAL.getTypingStats();
return new MonkeyResponse2("Public typing stats retrieved", data);
return new MonkeyResponse("Public typing stats retrieved", data);
}

View file

@ -6,7 +6,7 @@ import * as NewQuotesDAL from "../../dal/new-quotes";
import * as QuoteRatingsDAL from "../../dal/quote-ratings";
import MonkeyError from "../../utils/error";
import { verify } from "../../utils/captcha";
import { MonkeyResponse2 } from "../../utils/monkey-response";
import { MonkeyResponse } from "../../utils/monkey-response";
import { ObjectId } from "mongodb";
import { addLog } from "../../dal/logs";
import {
@ -30,7 +30,7 @@ async function verifyCaptcha(captcha: string): Promise<void> {
}
export async function getQuotes(
req: MonkeyTypes.Request2
req: MonkeyTypes.Request
): Promise<GetQuotesResponse> {
const { uid } = req.ctx.decodedToken;
const quoteMod = (await getPartialUser(uid, "get quotes", ["quoteMod"]))
@ -38,36 +38,36 @@ export async function getQuotes(
const quoteModString = quoteMod === true ? "all" : (quoteMod as string);
const data = await NewQuotesDAL.get(quoteModString);
return new MonkeyResponse2(
return new MonkeyResponse(
"Quote submissions retrieved",
replaceObjectIds(data)
);
}
export async function isSubmissionEnabled(
req: MonkeyTypes.Request2
req: MonkeyTypes.Request
): Promise<IsSubmissionEnabledResponse> {
const { submissionsEnabled } = req.ctx.configuration.quotes;
return new MonkeyResponse2(
return new MonkeyResponse(
"Quote submission " + (submissionsEnabled ? "enabled" : "disabled"),
{ isEnabled: submissionsEnabled }
);
}
export async function addQuote(
req: MonkeyTypes.Request2<undefined, AddQuoteRequest>
): Promise<MonkeyResponse2> {
req: MonkeyTypes.Request<undefined, AddQuoteRequest>
): Promise<MonkeyResponse> {
const { uid } = req.ctx.decodedToken;
const { text, source, language, captcha } = req.body;
await verifyCaptcha(captcha);
await NewQuotesDAL.add(text, source, language, uid);
return new MonkeyResponse2("Quote submission added", null);
return new MonkeyResponse("Quote submission added", null);
}
export async function approveQuote(
req: MonkeyTypes.Request2<undefined, ApproveQuoteRequest>
req: MonkeyTypes.Request<undefined, ApproveQuoteRequest>
): Promise<ApproveQuoteResponse> {
const { uid } = req.ctx.decodedToken;
const { quoteId, editText, editSource } = req.body;
@ -81,31 +81,31 @@ export async function approveQuote(
const data = await NewQuotesDAL.approve(quoteId, editText, editSource, name);
void addLog("system_quote_approved", data, uid);
return new MonkeyResponse2(data.message, data.quote);
return new MonkeyResponse(data.message, data.quote);
}
export async function refuseQuote(
req: MonkeyTypes.Request2<undefined, RejectQuoteRequest>
): Promise<MonkeyResponse2> {
req: MonkeyTypes.Request<undefined, RejectQuoteRequest>
): Promise<MonkeyResponse> {
const { quoteId } = req.body;
await NewQuotesDAL.refuse(quoteId);
return new MonkeyResponse2("Quote refused", null);
return new MonkeyResponse("Quote refused", null);
}
export async function getRating(
req: MonkeyTypes.Request2<GetQuoteRatingQuery>
req: MonkeyTypes.Request<GetQuoteRatingQuery>
): Promise<GetQuoteRatingResponse> {
const { quoteId, language } = req.query;
const data = await QuoteRatingsDAL.get(quoteId, language);
return new MonkeyResponse2("Rating retrieved", replaceObjectId(data));
return new MonkeyResponse("Rating retrieved", replaceObjectId(data));
}
export async function submitRating(
req: MonkeyTypes.Request2<undefined, AddQuoteRatingRequest>
): Promise<MonkeyResponse2> {
req: MonkeyTypes.Request<undefined, AddQuoteRatingRequest>
): Promise<MonkeyResponse> {
const { uid } = req.ctx.decodedToken;
const { quoteId, rating, language } = req.body;
@ -131,12 +131,12 @@ export async function submitRating(
const responseMessage = `Rating ${
shouldUpdateRating ? "updated" : "submitted"
}`;
return new MonkeyResponse2(responseMessage, null);
return new MonkeyResponse(responseMessage, null);
}
export async function reportQuote(
req: MonkeyTypes.Request2<undefined, ReportQuoteRequest>
): Promise<MonkeyResponse2> {
req: MonkeyTypes.Request<undefined, ReportQuoteRequest>
): Promise<MonkeyResponse> {
const { uid } = req.ctx.decodedToken;
const {
reporting: { maxReports, contentReportLimit },
@ -159,5 +159,5 @@ export async function reportQuote(
await ReportDAL.createReport(newReport, maxReports, contentReportLimit);
return new MonkeyResponse2("Quote reported", null);
return new MonkeyResponse("Quote reported", null);
}

View file

@ -12,7 +12,7 @@ import {
import objectHash from "object-hash";
import Logger from "../../utils/logger";
import "dotenv/config";
import { MonkeyResponse2 } from "../../utils/monkey-response";
import { MonkeyResponse } from "../../utils/monkey-response";
import MonkeyError from "../../utils/error";
import { areFunboxesCompatible, isTestTooShort } from "../../utils/validation";
import {
@ -73,7 +73,7 @@ try {
}
export async function getResults(
req: MonkeyTypes.Request2<GetResultsQuery>
req: MonkeyTypes.Request<GetResultsQuery>
): Promise<GetResultsResponse> {
const { uid } = req.ctx.decodedToken;
const premiumFeaturesEnabled = req.ctx.configuration.users.premium.enabled;
@ -122,29 +122,29 @@ export async function getResults(
},
uid
);
return new MonkeyResponse2("Results retrieved", results.map(convertResult));
return new MonkeyResponse("Results retrieved", results.map(convertResult));
}
export async function getLastResult(
req: MonkeyTypes.Request2
req: MonkeyTypes.Request
): Promise<GetLastResultResponse> {
const { uid } = req.ctx.decodedToken;
const results = await ResultDAL.getLastResult(uid);
return new MonkeyResponse2("Result retrieved", convertResult(results));
return new MonkeyResponse("Result retrieved", convertResult(results));
}
export async function deleteAll(
req: MonkeyTypes.Request2
): Promise<MonkeyResponse2> {
req: MonkeyTypes.Request
): Promise<MonkeyResponse> {
const { uid } = req.ctx.decodedToken;
await ResultDAL.deleteAll(uid);
void addLog("user_results_deleted", "", uid);
return new MonkeyResponse2("All results deleted", null);
return new MonkeyResponse("All results deleted", null);
}
export async function updateTags(
req: MonkeyTypes.Request2<undefined, UpdateResultTagsRequest>
req: MonkeyTypes.Request<undefined, UpdateResultTagsRequest>
): Promise<UpdateResultTagsResponse> {
const { uid } = req.ctx.decodedToken;
const { tagIds, resultId } = req.body;
@ -173,13 +173,13 @@ export async function updateTags(
const user = await UserDAL.getPartialUser(uid, "update tags", ["tags"]);
const tagPbs = await UserDAL.checkIfTagPb(uid, user, result);
return new MonkeyResponse2("Result tags updated", {
return new MonkeyResponse("Result tags updated", {
tagPbs,
});
}
export async function addResult(
req: MonkeyTypes.Request2<undefined, AddResultRequest>
req: MonkeyTypes.Request<undefined, AddResultRequest>
): Promise<AddResultResponse> {
const { uid } = req.ctx.decodedToken;
@ -200,6 +200,10 @@ export async function addResult(
throw new MonkeyError(status.code, status.message);
}
if (user.lbOptOut !== true && completedEvent.acc < 75) {
throw new MonkeyError(400, "Accuracy too low");
}
const resulthash = completedEvent.hash;
if (req.ctx.configuration.results.objectHashCheckEnabled) {
const objectToHash = omit(completedEvent, "hash");
@ -626,7 +630,7 @@ export async function addResult(
incrementResult(completedEvent, dbresult.isPb);
return new MonkeyResponse2("Result saved", data);
return new MonkeyResponse("Result saved", data);
}
type XpResult = {

View file

@ -7,10 +7,12 @@ import {
MILLISECONDS_IN_DAY,
buildAgentLog,
isDevEnvironment,
replaceObjectId,
replaceObjectIds,
sanitizeString,
} from "../../utils/misc";
import GeorgeQueue from "../../queues/george-queue";
import admin, { type FirebaseError } from "firebase-admin";
import { type FirebaseError } from "firebase-admin";
import { deleteAllApeKeys } from "../../dal/ape-keys";
import { deleteAllPresets } from "../../dal/preset";
import { deleteAll as deleteAllResults } from "../../dal/result";
@ -27,17 +29,61 @@ import * as AuthUtil from "../../utils/auth";
import * as Dates from "date-fns";
import { UTCDateMini } from "@date-fns/utc";
import * as BlocklistDal from "../../dal/blocklist";
import { Mode, Mode2 } from "@monkeytype/contracts/schemas/shared";
import {
AllTimeLbs,
CountByYearAndDay,
RankAndCount,
TestActivity,
ResultFilters,
User,
UserProfile,
CountByYearAndDay,
TestActivity,
UserProfileDetails,
} from "@monkeytype/shared-types";
} from "@monkeytype/contracts/schemas/users";
import { addImportantLog, addLog, deleteUserLogs } from "../../dal/logs";
import { sendForgotPasswordEmail as authSendForgotPasswordEmail } from "../../utils/auth";
import {
AddCustomThemeRequest,
AddCustomThemeResponse,
AddFavoriteQuoteRequest,
AddResultFilterPresetRequest,
AddResultFilterPresetResponse,
AddTagRequest,
AddTagResponse,
CheckNamePathParameters,
CreateUserRequest,
DeleteCustomThemeRequest,
EditCustomThemeRequst,
EditTagRequest,
ForgotPasswordEmailRequest,
GetCurrentTestActivityResponse,
GetCustomThemesResponse,
GetDiscordOauthLinkResponse,
GetFavoriteQuotesResponse,
GetPersonalBestsQuery,
GetPersonalBestsResponse,
GetProfilePathParams,
GetProfileQuery,
GetProfileResponse,
GetStatsResponse,
GetStreakResponseSchema,
GetTagsResponse,
GetTestActivityResponse,
GetUserInboxResponse,
GetUserResponse,
LinkDiscordRequest,
LinkDiscordResponse,
RemoveFavoriteQuoteRequest,
RemoveResultFilterPresetPathParams,
ReportUserRequest,
SetStreakHourOffsetRequest,
TagIdPathParams,
UpdateEmailRequestSchema,
UpdateLeaderboardMemoryRequest,
UpdatePasswordRequest,
UpdateUserInboxRequest,
UpdateUserNameRequest,
UpdateUserProfileRequest,
UpdateUserProfileResponse,
} from "@monkeytype/contracts/users";
async function verifyCaptcha(captcha: string): Promise<void> {
let verified = false;
@ -56,7 +102,7 @@ async function verifyCaptcha(captcha: string): Promise<void> {
}
export async function createNewUser(
req: MonkeyTypes.Request
req: MonkeyTypes.Request<undefined, CreateUserRequest>
): Promise<MonkeyResponse> {
const { name, captcha } = req.body;
const { email, uid } = req.ctx.decodedToken;
@ -81,7 +127,7 @@ export async function createNewUser(
await UserDAL.addUser(name, email, uid);
void addImportantLog("user_created", `${name} ${email}`, uid);
return new MonkeyResponse("User created");
return new MonkeyResponse("User created", null);
} catch (e) {
//user was created in firebase from the frontend, remove it
await firebaseDeleteUserIgnoreError(uid);
@ -94,7 +140,7 @@ export async function sendVerificationEmail(
): Promise<MonkeyResponse> {
const { email, uid } = req.ctx.decodedToken;
const isVerified = (
await admin
await FirebaseAdmin()
.auth()
.getUser(uid)
.catch((e: unknown) => {
@ -152,7 +198,7 @@ export async function sendVerificationEmail(
JSON.stringify({
decodedTokenEmail: email,
userInfoEmail: userInfo.email,
stack: e.stack,
stack: e.stack as unknown,
}),
userInfo.uid
);
@ -164,19 +210,19 @@ export async function sendVerificationEmail(
);
}
}
await emailQueue.sendVerificationEmail(email, userInfo.name, link);
return new MonkeyResponse("Email sent");
return new MonkeyResponse("Email sent", null);
}
export async function sendForgotPasswordEmail(
req: MonkeyTypes.Request
req: MonkeyTypes.Request<undefined, ForgotPasswordEmailRequest>
): Promise<MonkeyResponse> {
const { email } = req.body;
await authSendForgotPasswordEmail(email);
return new MonkeyResponse(
"Password reset request received. If the email is valid, you will receive an email shortly."
"Password reset request received. If the email is valid, you will receive an email shortly.",
null
);
}
@ -219,7 +265,7 @@ export async function deleteUser(
uid
);
return new MonkeyResponse("User deleted");
return new MonkeyResponse("User deleted", null);
}
export async function resetUser(
@ -255,11 +301,11 @@ export async function resetUser(
await Promise.all(promises);
void addImportantLog("user_reset", `${userInfo.email} ${userInfo.name}`, uid);
return new MonkeyResponse("User reset");
return new MonkeyResponse("User reset", null);
}
export async function updateName(
req: MonkeyTypes.Request
req: MonkeyTypes.Request<undefined, UpdateUserNameRequest>
): Promise<MonkeyResponse> {
const { uid } = req.ctx.decodedToken;
const { name } = req.body;
@ -289,7 +335,7 @@ export async function updateName(
uid
);
return new MonkeyResponse("User's name updated");
return new MonkeyResponse("User's name updated", null);
}
export async function clearPb(
@ -304,7 +350,7 @@ export async function clearPb(
);
void addImportantLog("user_cleared_pbs", "", uid);
return new MonkeyResponse("User's PB cleared");
return new MonkeyResponse("User's PB cleared", null);
}
export async function optOutOfLeaderboards(
@ -319,25 +365,25 @@ export async function optOutOfLeaderboards(
);
void addImportantLog("user_opted_out_of_leaderboards", "", uid);
return new MonkeyResponse("User opted out of leaderboards");
return new MonkeyResponse("User opted out of leaderboards", null);
}
export async function checkName(
req: MonkeyTypes.Request
req: MonkeyTypes.Request<undefined, undefined, CheckNamePathParameters>
): Promise<MonkeyResponse> {
const { name } = req.params;
const { uid } = req.ctx.decodedToken;
const available = await UserDAL.isNameAvailable(name as string, uid);
const available = await UserDAL.isNameAvailable(name, uid);
if (!available) {
throw new MonkeyError(409, "Username unavailable");
}
return new MonkeyResponse("Username available");
return new MonkeyResponse("Username available", null);
}
export async function updateEmail(
req: MonkeyTypes.Request
req: MonkeyTypes.Request<undefined, UpdateEmailRequestSchema>
): Promise<MonkeyResponse> {
const { uid } = req.ctx.decodedToken;
let { newEmail } = req.body;
@ -377,23 +423,35 @@ export async function updateEmail(
uid
);
return new MonkeyResponse("Email updated");
return new MonkeyResponse("Email updated", null);
}
export async function updatePassword(
req: MonkeyTypes.Request
req: MonkeyTypes.Request<undefined, UpdatePasswordRequest>
): Promise<MonkeyResponse> {
const { uid } = req.ctx.decodedToken;
const { newPassword } = req.body;
await AuthUtil.updateUserPassword(uid, newPassword);
return new MonkeyResponse("Password updated");
return new MonkeyResponse("Password updated", null);
}
function getRelevantUserInfo(
user: MonkeyTypes.DBUser
): Partial<MonkeyTypes.DBUser> {
type RelevantUserInfo = Omit<
MonkeyTypes.DBUser,
| "bananas"
| "lbPersonalBests"
| "inbox"
| "nameHistory"
| "lastNameChange"
| "_id"
| "lastReultHashes" //TODO fix typo
| "note"
| "ips"
| "testActivity"
>;
function getRelevantUserInfo(user: MonkeyTypes.DBUser): RelevantUserInfo {
return _.omit(user, [
"bananas",
"lbPersonalBests",
@ -401,16 +459,16 @@ function getRelevantUserInfo(
"nameHistory",
"lastNameChange",
"_id",
"lastResultHashes",
"lastReultHashes", //TODO fix typo
"note",
"ips",
"testActivity",
]);
]) as RelevantUserInfo;
}
export async function getUser(
req: MonkeyTypes.Request
): Promise<MonkeyResponse> {
): Promise<GetUserResponse> {
const { uid } = req.ctx.decodedToken;
let userInfo: MonkeyTypes.DBUser;
@ -454,7 +512,7 @@ export async function getUser(
custom: {},
};
const agentLog = buildAgentLog(req);
const agentLog = buildAgentLog(req.raw);
void addLog("user_data_requested", agentLog, uid);
void UserDAL.logIpAddress(uid, agentLog.ip, userInfo);
@ -472,21 +530,40 @@ export async function getUser(
const allTimeLbs = await getAllTimeLbs(uid);
const testActivity = generateCurrentTestActivity(userInfo.testActivity);
const relevantUserInfo = getRelevantUserInfo(userInfo);
const userData = {
...getRelevantUserInfo(userInfo),
inboxUnreadSize: inboxUnreadSize,
const resultFilterPresets: ResultFilters[] = (
relevantUserInfo.resultFilterPresets ?? []
).map((it) => replaceObjectId(it));
delete relevantUserInfo.resultFilterPresets;
const tags = (relevantUserInfo.tags ?? []).map((it) => replaceObjectId(it));
delete relevantUserInfo.tags;
const customThemes = (relevantUserInfo.customThemes ?? []).map((it) =>
replaceObjectId(it)
);
delete relevantUserInfo.customThemes;
const userData: User = {
...relevantUserInfo,
resultFilterPresets,
tags,
customThemes,
isPremium,
allTimeLbs,
testActivity,
};
return new MonkeyResponse("User data retrieved", userData);
return new MonkeyResponse("User data retrieved", {
...userData,
inboxUnreadSize: inboxUnreadSize,
});
}
export async function getOauthLink(
req: MonkeyTypes.Request
): Promise<MonkeyResponse> {
): Promise<GetDiscordOauthLinkResponse> {
const { uid } = req.ctx.decodedToken;
//build the url
@ -499,8 +576,8 @@ export async function getOauthLink(
}
export async function linkDiscord(
req: MonkeyTypes.Request
): Promise<MonkeyResponse> {
req: MonkeyTypes.Request<undefined, LinkDiscordRequest>
): Promise<LinkDiscordResponse> {
const { uid } = req.ctx.decodedToken;
const { tokenType, accessToken, state } = req.body;
@ -581,12 +658,12 @@ export async function unlinkDiscord(
await UserDAL.unlinkDiscord(uid);
void addImportantLog("user_discord_unlinked", discordId, uid);
return new MonkeyResponse("Discord account unlinked");
return new MonkeyResponse("Discord account unlinked", null);
}
export async function addResultFilterPreset(
req: MonkeyTypes.Request
): Promise<MonkeyResponse> {
req: MonkeyTypes.Request<undefined, AddResultFilterPresetRequest>
): Promise<AddResultFilterPresetResponse> {
const { uid } = req.ctx.decodedToken;
const filter = req.body;
const { maxPresetsPerUser } = req.ctx.configuration.results.filterPresets;
@ -596,134 +673,139 @@ export async function addResultFilterPreset(
filter,
maxPresetsPerUser
);
return new MonkeyResponse("Result filter preset created", createdId);
return new MonkeyResponse(
"Result filter preset created",
createdId.toHexString()
);
}
export async function removeResultFilterPreset(
req: MonkeyTypes.Request
req: MonkeyTypes.Request<
undefined,
undefined,
RemoveResultFilterPresetPathParams
>
): Promise<MonkeyResponse> {
const { uid } = req.ctx.decodedToken;
const { presetId } = req.params;
await UserDAL.removeResultFilterPreset(uid, presetId as string);
return new MonkeyResponse("Result filter preset deleted");
await UserDAL.removeResultFilterPreset(uid, presetId);
return new MonkeyResponse("Result filter preset deleted", null);
}
export async function addTag(
req: MonkeyTypes.Request
): Promise<MonkeyResponse> {
req: MonkeyTypes.Request<undefined, AddTagRequest>
): Promise<AddTagResponse> {
const { uid } = req.ctx.decodedToken;
const { tagName } = req.body;
const tag = await UserDAL.addTag(uid, tagName);
return new MonkeyResponse("Tag updated", tag);
return new MonkeyResponse("Tag updated", replaceObjectId(tag));
}
export async function clearTagPb(
req: MonkeyTypes.Request
req: MonkeyTypes.Request<undefined, undefined, TagIdPathParams>
): Promise<MonkeyResponse> {
const { uid } = req.ctx.decodedToken;
const { tagId } = req.params;
await UserDAL.removeTagPb(uid, tagId as string);
return new MonkeyResponse("Tag PB cleared");
await UserDAL.removeTagPb(uid, tagId);
return new MonkeyResponse("Tag PB cleared", null);
}
export async function editTag(
req: MonkeyTypes.Request
req: MonkeyTypes.Request<undefined, EditTagRequest>
): Promise<MonkeyResponse> {
const { uid } = req.ctx.decodedToken;
const { tagId, newName } = req.body;
await UserDAL.editTag(uid, tagId, newName);
return new MonkeyResponse("Tag updated");
return new MonkeyResponse("Tag updated", null);
}
export async function removeTag(
req: MonkeyTypes.Request
req: MonkeyTypes.Request<undefined, undefined, TagIdPathParams>
): Promise<MonkeyResponse> {
const { uid } = req.ctx.decodedToken;
const { tagId } = req.params;
await UserDAL.removeTag(uid, tagId as string);
return new MonkeyResponse("Tag deleted");
await UserDAL.removeTag(uid, tagId);
return new MonkeyResponse("Tag deleted", null);
}
export async function getTags(
req: MonkeyTypes.Request
): Promise<MonkeyResponse> {
): Promise<GetTagsResponse> {
const { uid } = req.ctx.decodedToken;
const tags = await UserDAL.getTags(uid);
return new MonkeyResponse("Tags retrieved", tags ?? []);
return new MonkeyResponse("Tags retrieved", replaceObjectIds(tags));
}
export async function updateLbMemory(
req: MonkeyTypes.Request
req: MonkeyTypes.Request<undefined, UpdateLeaderboardMemoryRequest>
): Promise<MonkeyResponse> {
const { uid } = req.ctx.decodedToken;
const { mode, language, rank } = req.body;
const mode2 = req.body.mode2 as Mode2<Mode>;
const mode2 = req.body.mode2;
await UserDAL.updateLbMemory(uid, mode, mode2, language, rank);
return new MonkeyResponse("Leaderboard memory updated");
return new MonkeyResponse("Leaderboard memory updated", null);
}
export async function getCustomThemes(
req: MonkeyTypes.Request
): Promise<MonkeyResponse> {
): Promise<GetCustomThemesResponse> {
const { uid } = req.ctx.decodedToken;
const customThemes = await UserDAL.getThemes(uid);
return new MonkeyResponse("Custom themes retrieved", customThemes);
return new MonkeyResponse(
"Custom themes retrieved",
replaceObjectIds(customThemes)
);
}
export async function addCustomTheme(
req: MonkeyTypes.Request
): Promise<MonkeyResponse> {
req: MonkeyTypes.Request<undefined, AddCustomThemeRequest>
): Promise<AddCustomThemeResponse> {
const { uid } = req.ctx.decodedToken;
const { name, colors } = req.body;
const addedTheme = await UserDAL.addTheme(uid, { name, colors });
return new MonkeyResponse("Custom theme added", addedTheme);
return new MonkeyResponse("Custom theme added", replaceObjectId(addedTheme));
}
export async function removeCustomTheme(
req: MonkeyTypes.Request
req: MonkeyTypes.Request<undefined, DeleteCustomThemeRequest>
): Promise<MonkeyResponse> {
const { uid } = req.ctx.decodedToken;
const { themeId } = req.body;
await UserDAL.removeTheme(uid, themeId);
return new MonkeyResponse("Custom theme removed");
return new MonkeyResponse("Custom theme removed", null);
}
export async function editCustomTheme(
req: MonkeyTypes.Request
req: MonkeyTypes.Request<undefined, EditCustomThemeRequst>
): Promise<MonkeyResponse> {
const { uid } = req.ctx.decodedToken;
const { themeId, theme } = req.body;
await UserDAL.editTheme(uid, themeId, theme);
return new MonkeyResponse("Custom theme updated");
return new MonkeyResponse("Custom theme updated", null);
}
export async function getPersonalBests(
req: MonkeyTypes.Request
): Promise<MonkeyResponse> {
req: MonkeyTypes.Request<GetPersonalBestsQuery>
): Promise<GetPersonalBestsResponse> {
const { uid } = req.ctx.decodedToken;
const { mode, mode2 } = req.query;
const data =
(await UserDAL.getPersonalBests(
uid,
mode as string,
mode2 as string | undefined
)) ?? null;
const data = (await UserDAL.getPersonalBests(uid, mode, mode2)) ?? null;
return new MonkeyResponse("Personal bests retrieved", data);
}
export async function getStats(
req: MonkeyTypes.Request
): Promise<MonkeyResponse> {
): Promise<GetStatsResponse> {
const { uid } = req.ctx.decodedToken;
const data = (await UserDAL.getStats(uid)) ?? null;
@ -732,7 +814,7 @@ export async function getStats(
export async function getFavoriteQuotes(
req: MonkeyTypes.Request
): Promise<MonkeyResponse> {
): Promise<GetFavoriteQuotesResponse> {
const { uid } = req.ctx.decodedToken;
const quotes = await UserDAL.getFavoriteQuotes(uid);
@ -741,7 +823,7 @@ export async function getFavoriteQuotes(
}
export async function addFavoriteQuote(
req: MonkeyTypes.Request
req: MonkeyTypes.Request<undefined, AddFavoriteQuoteRequest>
): Promise<MonkeyResponse> {
const { uid } = req.ctx.decodedToken;
@ -754,31 +836,28 @@ export async function addFavoriteQuote(
req.ctx.configuration.quotes.maxFavorites
);
return new MonkeyResponse("Quote added to favorites");
return new MonkeyResponse("Quote added to favorites", null);
}
export async function removeFavoriteQuote(
req: MonkeyTypes.Request
req: MonkeyTypes.Request<undefined, RemoveFavoriteQuoteRequest>
): Promise<MonkeyResponse> {
const { uid } = req.ctx.decodedToken;
const { quoteId, language } = req.body;
await UserDAL.removeFavoriteQuote(uid, language, quoteId);
return new MonkeyResponse("Quote removed from favorites");
return new MonkeyResponse("Quote removed from favorites", null);
}
export async function getProfile(
req: MonkeyTypes.Request
): Promise<MonkeyResponse> {
req: MonkeyTypes.Request<GetProfileQuery, undefined, GetProfilePathParams>
): Promise<GetProfileResponse> {
const { uidOrName } = req.params;
const { isUid } = req.query;
const user =
isUid !== undefined
? await UserDAL.getUser(uidOrName as string, "get user profile")
: await UserDAL.getUserByName(uidOrName as string, "get user profile");
const user = req.query.isUid
? await UserDAL.getUser(uidOrName, "get user profile")
: await UserDAL.getUserByName(uidOrName, "get user profile");
const {
name,
@ -844,8 +923,8 @@ export async function getProfile(
}
export async function updateProfile(
req: MonkeyTypes.Request
): Promise<MonkeyResponse> {
req: MonkeyTypes.Request<undefined, UpdateUserProfileRequest>
): Promise<UpdateUserProfileResponse> {
const { uid } = req.ctx.decodedToken;
const { bio, keyboard, socialProfiles, selectedBadgeId } = req.body;
@ -882,7 +961,7 @@ export async function updateProfile(
export async function getInbox(
req: MonkeyTypes.Request
): Promise<MonkeyResponse> {
): Promise<GetUserInboxResponse> {
const { uid } = req.ctx.decodedToken;
const inbox = await UserDAL.getInbox(uid);
@ -894,18 +973,22 @@ export async function getInbox(
}
export async function updateInbox(
req: MonkeyTypes.Request
req: MonkeyTypes.Request<undefined, UpdateUserInboxRequest>
): Promise<MonkeyResponse> {
const { uid } = req.ctx.decodedToken;
const { mailIdsToMarkRead, mailIdsToDelete } = req.body;
await UserDAL.updateInbox(uid, mailIdsToMarkRead, mailIdsToDelete);
await UserDAL.updateInbox(
uid,
mailIdsToMarkRead ?? [],
mailIdsToDelete ?? []
);
return new MonkeyResponse("Inbox updated");
return new MonkeyResponse("Inbox updated", null);
}
export async function reportUser(
req: MonkeyTypes.Request
req: MonkeyTypes.Request<undefined, ReportUserRequest>
): Promise<MonkeyResponse> {
const { uid } = req.ctx.decodedToken;
const {
@ -924,16 +1007,16 @@ export async function reportUser(
uid,
contentId: `${uidToReport}`,
reason,
comment,
comment: comment ?? "",
};
await ReportDAL.createReport(newReport, maxReports, contentReportLimit);
return new MonkeyResponse("User reported");
return new MonkeyResponse("User reported", null);
}
export async function setStreakHourOffset(
req: MonkeyTypes.Request
req: MonkeyTypes.Request<undefined, SetStreakHourOffsetRequest>
): Promise<MonkeyResponse> {
const { uid } = req.ctx.decodedToken;
const { hourOffset } = req.body;
@ -953,34 +1036,7 @@ export async function setStreakHourOffset(
void addImportantLog("user_streak_hour_offset_set", { hourOffset }, uid);
return new MonkeyResponse("Streak hour offset set");
}
export async function toggleBan(
req: MonkeyTypes.Request
): Promise<MonkeyResponse> {
const { uid } = req.body;
const user = await UserDAL.getPartialUser(uid, "toggle ban", [
"banned",
"discordId",
]);
const discordId = user.discordId;
const discordIdIsValid = discordId !== undefined && discordId !== "";
if (user.banned) {
await UserDAL.setBanned(uid, false);
if (discordIdIsValid) await GeorgeQueue.userBanned(discordId, false);
} else {
await UserDAL.setBanned(uid, true);
if (discordIdIsValid) await GeorgeQueue.userBanned(discordId, true);
}
void addImportantLog("user_ban_toggled", { banned: !user.banned }, uid);
return new MonkeyResponse(`Ban toggled`, {
banned: !user.banned,
});
return new MonkeyResponse("Streak hour offset set", null);
}
export async function revokeAllTokens(
@ -989,7 +1045,7 @@ export async function revokeAllTokens(
const { uid } = req.ctx.decodedToken;
await AuthUtil.revokeTokensByUid(uid);
void addImportantLog("user_tokens_revoked", "", uid);
return new MonkeyResponse("All tokens revoked");
return new MonkeyResponse("All tokens revoked", null);
}
async function getAllTimeLbs(uid: string): Promise<AllTimeLbs> {
@ -1010,18 +1066,18 @@ async function getAllTimeLbs(uid: string): Promise<AllTimeLbs> {
const english15 =
allTime15English === false
? undefined
: ({
: {
rank: allTime15English.rank,
count: allTime15English.count,
} as RankAndCount);
};
const english60 =
allTime60English === false
? undefined
: ({
: {
rank: allTime60English.rank,
count: allTime60English.count,
} as RankAndCount);
};
return {
time: {
@ -1053,9 +1109,9 @@ export function generateCurrentTestActivity(
//make sure lastYearData covers the full year
if (lastYearData.length < Dates.getDaysInYear(lastYear)) {
lastYearData.push(
...new Array(Dates.getDaysInYear(lastYear) - lastYearData.length).fill(
...(new Array(Dates.getDaysInYear(lastYear) - lastYearData.length).fill(
undefined
)
) as (number | null)[])
);
}
//use enough days of the last year to have 372 days in total to always fill the first week of the graph
@ -1073,7 +1129,7 @@ export function generateCurrentTestActivity(
export async function getTestActivity(
req: MonkeyTypes.Request
): Promise<MonkeyResponse> {
): Promise<GetTestActivityResponse> {
const { uid } = req.ctx.decodedToken;
const premiumFeaturesEnabled = req.ctx.configuration.users.premium.enabled;
const user = await UserDAL.getPartialUser(uid, "testActivity", [
@ -1090,7 +1146,10 @@ export async function getTestActivity(
throw new MonkeyError(503, "User does not have premium");
}
return new MonkeyResponse("Test activity data retrieved", user.testActivity);
return new MonkeyResponse(
"Test activity data retrieved",
user.testActivity ?? null
);
}
async function firebaseDeleteUserIgnoreError(uid: string): Promise<void> {
@ -1103,22 +1162,25 @@ async function firebaseDeleteUserIgnoreError(uid: string): Promise<void> {
export async function getCurrentTestActivity(
req: MonkeyTypes.Request
): Promise<MonkeyResponse> {
): Promise<GetCurrentTestActivityResponse> {
const { uid } = req.ctx.decodedToken;
const user = await UserDAL.getPartialUser(uid, "current test activity", [
"testActivity",
]);
const data = generateCurrentTestActivity(user.testActivity);
return new MonkeyResponse("Current test activity data retrieved", data);
return new MonkeyResponse(
"Current test activity data retrieved",
data ?? null
);
}
export async function getStreak(
req: MonkeyTypes.Request
): Promise<MonkeyResponse> {
): Promise<GetStreakResponseSchema> {
const { uid } = req.ctx.decodedToken;
const user = await UserDAL.getPartialUser(uid, "streak", ["streak"]);
return new MonkeyResponse("Streak data retrieved", user.streak);
return new MonkeyResponse("Streak data retrieved", user.streak ?? null);
}

View file

@ -1,15 +1,20 @@
import { MonkeyResponse } from "../../utils/monkey-response";
import { PostGithubReleaseRequest } from "@monkeytype/contracts/webhooks";
import GeorgeQueue from "../../queues/george-queue";
import { MonkeyResponse } from "../../utils/monkey-response";
import MonkeyError from "../../utils/error";
export async function githubRelease(
req: MonkeyTypes.Request
req: MonkeyTypes.Request<undefined, PostGithubReleaseRequest>
): Promise<MonkeyResponse> {
const action = req.body.action;
if (action === "published") {
const releaseId = req.body.release.id;
const releaseId = req.body.release?.id;
if (releaseId === undefined)
throw new MonkeyError(422, 'Missing property "release.id".');
await GeorgeQueue.sendReleaseAnnouncement(releaseId);
return new MonkeyResponse("Added release announcement task to queue");
return new MonkeyResponse("Added release announcement task to queue", null);
}
return new MonkeyResponse("No action taken");
return new MonkeyResponse("No action taken", null);
}

View file

@ -1,45 +1,25 @@
// import joi from "joi";
import { adminLimit } from "../../middlewares/rate-limit";
import * as AdminController from "../controllers/admin";
import * as AdminController from "../controllers/admin";
import { adminContract } from "@monkeytype/contracts/admin";
import { initServer } from "@ts-rest/express";
import { validate } from "../../middlewares/configuration";
import { checkIfUserIsAdmin } from "../../middlewares/permission";
import { callController } from "../ts-rest-adapter";
const commonMiddleware = [
adminLimit,
validate({
criteria: (configuration) => {
return configuration.admin.endpointsEnabled;
},
invalidMessage: "Admin endpoints are currently disabled.",
}),
checkIfUserIsAdmin(),
];
const s = initServer();
export default s.router(adminContract, {
test: {
middleware: commonMiddleware,
handler: async (r) => callController(AdminController.test)(r),
},
toggleBan: {
middleware: commonMiddleware,
handler: async (r) => callController(AdminController.toggleBan)(r),
},
acceptReports: {
middleware: commonMiddleware,
handler: async (r) => callController(AdminController.acceptReports)(r),
},
rejectReports: {
middleware: commonMiddleware,
handler: async (r) => callController(AdminController.rejectReports)(r),
},
sendForgotPasswordEmail: {
middleware: commonMiddleware,
handler: async (r) =>
callController(AdminController.sendForgotPasswordEmail)(r),
},

View file

@ -1,42 +1,20 @@
import { apeKeysContract } from "@monkeytype/contracts/ape-keys";
import { initServer } from "@ts-rest/express";
import * as RateLimit from "../../middlewares/rate-limit";
import * as ApeKeyController from "../controllers/ape-key";
import { callController } from "../ts-rest-adapter";
import { checkUserPermissions } from "../../middlewares/permission";
import { validate } from "../../middlewares/configuration";
const commonMiddleware = [
validate({
criteria: (configuration) => {
return configuration.apeKeys.endpointsEnabled;
},
invalidMessage: "ApeKeys are currently disabled.",
}),
checkUserPermissions(["canManageApeKeys"], {
criteria: (user) => {
return user.canManageApeKeys ?? true;
},
invalidMessage: "You have lost access to ape keys, please contact support",
}),
];
const s = initServer();
export default s.router(apeKeysContract, {
get: {
middleware: [...commonMiddleware, RateLimit.apeKeysGet],
handler: async (r) => callController(ApeKeyController.getApeKeys)(r),
},
add: {
middleware: [...commonMiddleware, RateLimit.apeKeysGenerate],
handler: async (r) => callController(ApeKeyController.generateApeKey)(r),
},
save: {
middleware: [...commonMiddleware, RateLimit.apeKeysUpdate],
handler: async (r) => callController(ApeKeyController.editApeKey)(r),
},
delete: {
middleware: [...commonMiddleware, RateLimit.apeKeysDelete],
handler: async (r) => callController(ApeKeyController.deleteApeKey)(r),
},
});

View file

@ -1,6 +1,5 @@
import { configsContract } from "@monkeytype/contracts/configs";
import { initServer } from "@ts-rest/express";
import * as RateLimit from "../../middlewares/rate-limit";
import * as ConfigController from "../controllers/config";
import { callController } from "../ts-rest-adapter";
@ -8,16 +7,12 @@ const s = initServer();
export default s.router(configsContract, {
get: {
middleware: [RateLimit.configGet],
handler: async (r) => callController(ConfigController.getConfig)(r),
},
save: {
middleware: [RateLimit.configUpdate],
handler: async (r) => callController(ConfigController.saveConfig)(r),
},
delete: {
middleware: [RateLimit.configDelete],
handler: async (r) => callController(ConfigController.deleteConfig)(r),
},
});

View file

@ -1,7 +1,5 @@
import { configurationContract } from "@monkeytype/contracts/configuration";
import { initServer } from "@ts-rest/express";
import { checkIfUserIsAdmin } from "../../middlewares/permission";
import * as RateLimit from "../../middlewares/rate-limit";
import * as ConfigurationController from "../controllers/configuration";
import { callController } from "../ts-rest-adapter";
@ -12,14 +10,11 @@ export default s.router(configurationContract, {
handler: async (r) =>
callController(ConfigurationController.getConfiguration)(r),
},
update: {
middleware: [checkIfUserIsAdmin(), RateLimit.adminLimit],
handler: async (r) =>
callController(ConfigurationController.updateConfiguration)(r),
},
getSchema: {
middleware: [checkIfUserIsAdmin(), RateLimit.adminLimit],
handler: async (r) => callController(ConfigurationController.getSchema)(r),
},
});

View file

@ -1,44 +1,29 @@
import { Response, Router } from "express";
import * as swaggerUi from "swagger-ui-express";
import publicSwaggerSpec from "../../documentation/public-swagger.json";
const SWAGGER_UI_OPTIONS = {
customCss: ".swagger-ui .topbar { display: none } .try-out { display: none }",
customSiteTitle: "Monkeytype API Documentation",
};
const router = Router();
const root = __dirname + "../../../static";
router.use("/v2/internal", (req, res) => {
router.use("/internal", (req, res) => {
setCsp(res);
res.sendFile("api/internal.html", { root });
});
router.use("/v2/internal.json", (req, res) => {
router.use("/internal.json", (req, res) => {
res.setHeader("Content-Type", "application/json");
res.sendFile("api/openapi.json", { root });
});
router.use(["/v2/public", "/v2/"], (req, res) => {
router.use(["/public", "/"], (req, res) => {
setCsp(res);
res.sendFile("api/public.html", { root });
});
router.use("/v2/public.json", (req, res) => {
router.use("/public.json", (req, res) => {
res.setHeader("Content-Type", "application/json");
res.sendFile("api/public.json", { root });
});
const options = {};
router.use(
"/",
swaggerUi.serveFiles(publicSwaggerSpec, options),
swaggerUi.setup(publicSwaggerSpec, SWAGGER_UI_OPTIONS)
);
export default router;
function setCsp(res: Response): void {

View file

@ -17,7 +17,6 @@ import configuration from "./configuration";
import { version } from "../../version";
import leaderboards from "./leaderboards";
import addSwaggerMiddlewares from "./swagger";
import { asyncHandler } from "../../middlewares/utility";
import { MonkeyResponse } from "../../utils/monkey-response";
import {
Application,
@ -34,14 +33,15 @@ import { createExpressEndpoints, initServer } from "@ts-rest/express";
import { ZodIssue } from "zod";
import { MonkeyValidationError } from "@monkeytype/contracts/schemas/api";
import { authenticateTsRestRequest } from "../../middlewares/auth";
import { rateLimitRequest } from "../../middlewares/rate-limit";
import { verifyPermissions } from "../../middlewares/permission";
import { verifyRequiredConfiguration } from "../../middlewares/configuration";
const pathOverride = process.env["API_PATH_OVERRIDE"];
const BASE_ROUTE = pathOverride !== undefined ? `/${pathOverride}` : "";
const APP_START_TIME = Date.now();
const API_ROUTE_MAP = {
"/users": users,
"/webhooks": webhooks,
"/docs": docs,
};
@ -57,7 +57,9 @@ const router = s.router(contract, {
results,
configuration,
dev,
users,
quotes,
webhooks,
});
export function addApiRoutes(app: Application): void {
@ -65,15 +67,16 @@ export function addApiRoutes(app: Application): void {
applyApiRoutes(app);
applyTsRestApiRoutes(app);
app.use(
asyncHandler(async (req, _res) => {
return new MonkeyResponse(
`Unknown request URL (${req.method}: ${req.path})`,
null,
404
app.use((req, res) => {
res
.status(404)
.json(
new MonkeyResponse(
`Unknown request URL (${req.method}: ${req.path})`,
null
)
);
})
);
});
}
function applyTsRestApiRoutes(app: IRouter): void {
@ -111,7 +114,12 @@ function applyTsRestApiRoutes(app: IRouter): void {
.status(422)
.json({ message, validationErrors } as MonkeyValidationError);
},
globalMiddleware: [authenticateTsRestRequest()],
globalMiddleware: [
authenticateTsRestRequest(),
rateLimitRequest(),
verifyRequiredConfiguration(),
verifyPermissions(),
],
});
}
@ -146,9 +154,12 @@ function applyDevApiRoutes(app: Application): void {
function applyApiRoutes(app: Application): void {
addSwaggerMiddlewares(app);
//TODO move to globalMiddleware when all endpoints use tsrest
app.use(
(req: MonkeyTypes.Request, res: Response, next: NextFunction): void => {
(
req: MonkeyTypes.ExpressRequestWithContext,
res: Response,
next: NextFunction
): void => {
if (req.path.startsWith("/configuration")) {
next();
return;
@ -167,25 +178,13 @@ function applyApiRoutes(app: Application): void {
}
);
app.get(
"/",
asyncHandler(async (_req, _res) => {
return new MonkeyResponse("ok", {
app.get("/", (_req, res) => {
res.status(200).json(
new MonkeyResponse("ok", {
uptime: Date.now() - APP_START_TIME,
version,
});
})
);
//legacy route
app.get("/psa", (_req, res) => {
res.json([
{
message:
"It seems like your client version is very out of date as you're requesting an API endpoint that no longer exists. This will likely cause most of the website to not function correctly. Please clear your cache, or contact support if this message persists.",
sticky: true,
},
]);
})
);
});
_.each(API_ROUTE_MAP, (router: Router, route) => {

View file

@ -1,55 +1,31 @@
import { initServer } from "@ts-rest/express";
import { withApeRateLimiter } from "../../middlewares/ape-rate-limit";
import { validate } from "../../middlewares/configuration";
import * as RateLimit from "../../middlewares/rate-limit";
import * as LeaderboardController from "../controllers/leaderboard";
import { leaderboardsContract } from "@monkeytype/contracts/leaderboards";
import { callController } from "../ts-rest-adapter";
const requireDailyLeaderboardsEnabled = validate({
criteria: (configuration) => {
return configuration.dailyLeaderboards.enabled;
},
invalidMessage: "Daily leaderboards are not available at this time.",
});
const requireWeeklyXpLeaderboardEnabled = validate({
criteria: (configuration) => {
return configuration.leaderboards.weeklyXp.enabled;
},
invalidMessage: "Weekly XP leaderboards are not available at this time.",
});
const s = initServer();
export default s.router(leaderboardsContract, {
get: {
middleware: [RateLimit.leaderboardsGet],
handler: async (r) =>
callController(LeaderboardController.getLeaderboard)(r),
},
getRank: {
middleware: [withApeRateLimiter(RateLimit.leaderboardsGet)],
handler: async (r) =>
callController(LeaderboardController.getRankFromLeaderboard)(r),
},
getDaily: {
middleware: [requireDailyLeaderboardsEnabled, RateLimit.leaderboardsGet],
handler: async (r) =>
callController(LeaderboardController.getDailyLeaderboard)(r),
},
getDailyRank: {
middleware: [requireDailyLeaderboardsEnabled, RateLimit.leaderboardsGet],
handler: async (r) =>
callController(LeaderboardController.getDailyLeaderboardRank)(r),
},
getWeeklyXp: {
middleware: [requireWeeklyXpLeaderboardEnabled, RateLimit.leaderboardsGet],
handler: async (r) =>
callController(LeaderboardController.getWeeklyXpLeaderboardResults)(r),
},
getWeeklyXpRank: {
middleware: [requireWeeklyXpLeaderboardEnabled, RateLimit.leaderboardsGet],
handler: async (r) =>
callController(LeaderboardController.getWeeklyXpLeaderboardRank)(r),
},

View file

@ -1,25 +1,20 @@
import { presetsContract } from "@monkeytype/contracts/presets";
import { initServer } from "@ts-rest/express";
import * as RateLimit from "../../middlewares/rate-limit";
import * as PresetController from "../controllers/preset";
import { callController } from "../ts-rest-adapter";
const s = initServer();
export default s.router(presetsContract, {
get: {
middleware: [RateLimit.presetsGet],
handler: async (r) => callController(PresetController.getPresets)(r),
},
add: {
middleware: [RateLimit.presetsAdd],
handler: async (r) => callController(PresetController.addPreset)(r),
},
save: {
middleware: [RateLimit.presetsEdit],
handler: async (r) => callController(PresetController.editPreset)(r),
},
delete: {
middleware: [RateLimit.presetsRemove],
handler: async (r) => callController(PresetController.removePreset)(r),
},
});

View file

@ -1,6 +1,5 @@
import { psasContract } from "@monkeytype/contracts/psas";
import { initServer } from "@ts-rest/express";
import * as RateLimit from "../../middlewares/rate-limit";
import * as PsaController from "../controllers/psa";
import { callController } from "../ts-rest-adapter";
import { recordClientVersion } from "../../middlewares/utility";
@ -8,7 +7,7 @@ import { recordClientVersion } from "../../middlewares/utility";
const s = initServer();
export default s.router(psasContract, {
get: {
middleware: [recordClientVersion(), RateLimit.psaGet],
middleware: [recordClientVersion()],
handler: async (r) => callController(PsaController.getPsas)(r),
},
});

View file

@ -1,17 +1,14 @@
import { publicContract } from "@monkeytype/contracts/public";
import { initServer } from "@ts-rest/express";
import * as RateLimit from "../../middlewares/rate-limit";
import * as PublicController from "../controllers/public";
import { callController } from "../ts-rest-adapter";
const s = initServer();
export default s.router(publicContract, {
getSpeedHistogram: {
middleware: [RateLimit.publicStatsGet],
handler: async (r) => callController(PublicController.getSpeedHistogram)(r),
},
getTypingStats: {
middleware: [RateLimit.publicStatsGet],
handler: async (r) => callController(PublicController.getTypingStats)(r),
},
});

View file

@ -1,75 +1,33 @@
import { quotesContract } from "@monkeytype/contracts/quotes";
import { initServer } from "@ts-rest/express";
import { validate } from "../../middlewares/configuration";
import { checkUserPermissions } from "../../middlewares/permission";
import * as RateLimit from "../../middlewares/rate-limit";
import * as QuoteController from "../controllers/quote";
import { callController } from "../ts-rest-adapter";
const checkIfUserIsQuoteMod = checkUserPermissions(["quoteMod"], {
criteria: (user) => {
return (
user.quoteMod === true ||
(typeof user.quoteMod === "string" && user.quoteMod !== "")
);
},
});
const s = initServer();
export default s.router(quotesContract, {
get: {
middleware: [checkIfUserIsQuoteMod, RateLimit.newQuotesGet],
handler: async (r) => callController(QuoteController.getQuotes)(r),
},
isSubmissionEnabled: {
middleware: [RateLimit.newQuotesIsSubmissionEnabled],
handler: async (r) =>
callController(QuoteController.isSubmissionEnabled)(r),
},
add: {
middleware: [
validate({
criteria: (configuration) => {
return configuration.quotes.submissionsEnabled;
},
invalidMessage:
"Quote submission is disabled temporarily. The queue is quite long and we need some time to catch up.",
}),
RateLimit.newQuotesAdd,
],
handler: async (r) => callController(QuoteController.addQuote)(r),
},
approveSubmission: {
middleware: [checkIfUserIsQuoteMod, RateLimit.newQuotesAction],
handler: async (r) => callController(QuoteController.approveQuote)(r),
},
rejectSubmission: {
middleware: [checkIfUserIsQuoteMod, RateLimit.newQuotesAction],
handler: async (r) => callController(QuoteController.refuseQuote)(r),
},
getRating: {
middleware: [RateLimit.quoteRatingsGet],
handler: async (r) => callController(QuoteController.getRating)(r),
},
addRating: {
middleware: [RateLimit.quoteRatingsSubmit],
handler: async (r) => callController(QuoteController.submitRating)(r),
},
report: {
middleware: [
validate({
criteria: (configuration) => {
return configuration.quotes.reporting.enabled;
},
invalidMessage: "Quote reporting is unavailable.",
}),
RateLimit.quoteReportSubmit,
checkUserPermissions(["canReport"], {
criteria: (user) => {
return user.canReport !== false;
},
}),
],
handler: async (r) => callController(QuoteController.reportQuote)(r),
},
});

View file

@ -1,40 +1,23 @@
import { resultsContract } from "@monkeytype/contracts/results";
import { initServer } from "@ts-rest/express";
import { withApeRateLimiter2 as withApeRateLimiter } from "../../middlewares/ape-rate-limit";
import { validate } from "../../middlewares/configuration";
import * as RateLimit from "../../middlewares/rate-limit";
import * as ResultController from "../controllers/result";
import { callController } from "../ts-rest-adapter";
const validateResultSavingEnabled = validate({
criteria: (configuration) => {
return configuration.results.savingEnabled;
},
invalidMessage: "Results are not being saved at this time.",
});
const s = initServer();
export default s.router(resultsContract, {
get: {
middleware: [
withApeRateLimiter(RateLimit.resultsGet, RateLimit.resultsGetApe),
],
handler: async (r) => callController(ResultController.getResults)(r),
},
add: {
middleware: [validateResultSavingEnabled, RateLimit.resultsAdd],
handler: async (r) => callController(ResultController.addResult)(r),
},
updateTags: {
middleware: [RateLimit.resultsTagsUpdate],
handler: async (r) => callController(ResultController.updateTags)(r),
},
deleteAll: {
middleware: [RateLimit.resultsDeleteAll],
handler: async (r) => callController(ResultController.deleteAll)(r),
},
getLast: {
middleware: [withApeRateLimiter(RateLimit.resultsGet)],
handler: async (r) => callController(ResultController.getLastResult)(r),
},
});

View file

@ -1,16 +1,27 @@
import { Application } from "express";
import { getMiddleware as getSwaggerMiddleware } from "swagger-stats";
import internalSwaggerSpec from "../../documentation/internal-swagger.json";
import { isDevEnvironment } from "../../utils/misc";
import { readFileSync } from "fs";
import Logger from "../../utils/logger";
function addSwaggerMiddlewares(app: Application): void {
const openApiSpec = __dirname + "/../../static/api/openapi.json";
let spec = {};
try {
spec = JSON.parse(readFileSync(openApiSpec, "utf8")) as string;
} catch (err) {
Logger.warning(
`Cannot read openApi specification from ${openApiSpec}. Swagger stats will not fully work.`
);
}
app.use(
getSwaggerMiddleware({
name: "Monkeytype API",
uriPath: "/stats",
authentication: !isDevEnvironment(),
apdexThreshold: 100,
swaggerSpec: internalSwaggerSpec,
swaggerSpec: spec,
onAuthenticate: (_req, username, password) => {
return (
username === process.env["STATS_USERNAME"] &&

View file

@ -1,703 +1,140 @@
import joi from "joi";
import { authenticateRequest } from "../../middlewares/auth";
import { Router } from "express";
import { usersContract } from "@monkeytype/contracts/users";
import { initServer } from "@ts-rest/express";
import * as UserController from "../controllers/user";
import * as RateLimit from "../../middlewares/rate-limit";
import { withApeRateLimiter } from "../../middlewares/ape-rate-limit";
import { containsProfanity, isUsernameValid } from "../../utils/validation";
import filterSchema from "../schemas/filter-schema";
import { asyncHandler } from "../../middlewares/utility";
import { validate } from "../../middlewares/configuration";
import { validateRequest } from "../../middlewares/validation";
import { checkUserPermissions } from "../../middlewares/permission";
import { callController } from "../ts-rest-adapter";
const router = Router();
const tagNameValidation = joi
.string()
.required()
.regex(/^[0-9a-zA-Z_-]+$/)
.max(16)
.messages({
"string.pattern.base":
"Tag name invalid. Name cannot contain special characters or more than 16 characters. Can include _ . and -",
"string.max": "Tag name exceeds maximum of 16 characters",
});
const customThemeNameValidation = joi
.string()
.max(16)
.regex(/^[0-9a-zA-Z_-]+$/)
.required()
.messages({
"string.max": "The name must not exceed 16 characters",
"string.pattern.base":
"Name cannot contain special characters. Can include _ . and -",
});
const customThemeColorsValidation = joi
.array()
.items(
joi
.string()
.length(7)
.regex(/^#[0-9a-fA-F]{6}$/)
.messages({
"string.pattern.base": "The colors must be valid hexadecimal",
"string.length": "The colors must be 7 characters long",
})
)
.length(10)
.required()
.messages({
"array.length": "The colors array must have 10 colors",
});
const customThemeIdValidation = joi
.string()
.length(24)
.regex(/^[0-9a-fA-F]+$/)
.required()
.messages({
"string.length": "The themeId must be 24 characters long",
"string.pattern.base": "The themeId must be valid hexadecimal string",
});
const usernameValidation = joi
.string()
.required()
.custom((value, helpers) => {
if (containsProfanity(value, "substring")) {
return helpers.error("string.profanity");
}
if (!isUsernameValid(value)) {
return helpers.error("string.pattern.base");
}
return value as string;
})
.messages({
"string.profanity":
"The username contains profanity. If you believe this is a mistake, please contact us ",
"string.pattern.base":
"Username invalid. Name cannot use special characters or contain more than 16 characters. Can include _ and - ",
});
const languageSchema = joi
.string()
.min(1)
.max(50)
.regex(/[\w+]+/)
.required();
const quoteIdSchema = joi.string().min(1).max(10).regex(/\d+/).required();
router.get(
"/",
authenticateRequest(),
RateLimit.userGet,
asyncHandler(UserController.getUser)
);
router.post(
"/signup",
validate({
criteria: (configuration) => {
return configuration.users.signUp;
},
invalidMessage: "Sign up is temporarily disabled",
}),
authenticateRequest(),
RateLimit.userSignup,
validateRequest({
body: {
email: joi.string().email(),
name: usernameValidation,
uid: joi.string().token(),
captcha: joi
.string()
.regex(/[\w-_]+/)
.required(),
},
}),
asyncHandler(UserController.createNewUser)
);
router.get(
"/checkName/:name",
authenticateRequest({
isPublic: true,
}),
RateLimit.userCheckName,
validateRequest({
params: {
name: usernameValidation,
},
}),
asyncHandler(UserController.checkName)
);
router.delete(
"/",
authenticateRequest({
requireFreshToken: true,
}),
RateLimit.userDelete,
asyncHandler(UserController.deleteUser)
);
router.patch(
"/reset",
authenticateRequest({
requireFreshToken: true,
}),
RateLimit.userReset,
asyncHandler(UserController.resetUser)
);
router.patch(
"/name",
authenticateRequest({
requireFreshToken: true,
}),
RateLimit.userUpdateName,
validateRequest({
body: {
name: usernameValidation,
},
}),
asyncHandler(UserController.updateName)
);
router.patch(
"/leaderboardMemory",
authenticateRequest(),
RateLimit.userUpdateLBMemory,
validateRequest({
body: {
mode: joi
.string()
.valid("time", "words", "quote", "zen", "custom")
.required(),
mode2: joi
.string()
.regex(/^(\d)+|custom|zen/)
.required(),
language: joi
.string()
.max(50)
.pattern(/^[a-zA-Z0-9_+]+$/)
.required(),
rank: joi.number().required(),
},
}),
asyncHandler(UserController.updateLbMemory)
);
router.patch(
"/email",
authenticateRequest({
requireFreshToken: true,
}),
RateLimit.userUpdateEmail,
validateRequest({
body: {
newEmail: joi.string().email().required(),
previousEmail: joi.string().email().required(),
},
}),
asyncHandler(UserController.updateEmail)
);
router.patch(
"/password",
authenticateRequest({
requireFreshToken: true,
}),
RateLimit.userUpdateEmail,
validateRequest({
body: {
newPassword: joi.string().min(6).required(),
},
}),
asyncHandler(UserController.updatePassword)
);
router.delete(
"/personalBests",
authenticateRequest({
requireFreshToken: true,
}),
RateLimit.userClearPB,
asyncHandler(UserController.clearPb)
);
router.post(
"/optOutOfLeaderboards",
authenticateRequest({
requireFreshToken: true,
}),
RateLimit.userOptOutOfLeaderboards,
asyncHandler(UserController.optOutOfLeaderboards)
);
const requireFilterPresetsEnabled = validate({
criteria: (configuration) => {
return configuration.results.filterPresets.enabled;
const s = initServer();
export default s.router(usersContract, {
get: {
handler: async (r) => callController(UserController.getUser)(r),
},
invalidMessage: "Result filter presets are not available at this time.",
});
router.post(
"/resultFilterPresets",
requireFilterPresetsEnabled,
authenticateRequest(),
RateLimit.userCustomFilterAdd,
validateRequest({
body: filterSchema,
}),
asyncHandler(UserController.addResultFilterPreset)
);
router.delete(
"/resultFilterPresets/:presetId",
requireFilterPresetsEnabled,
authenticateRequest(),
RateLimit.userCustomFilterRemove,
validateRequest({
params: {
presetId: joi.string().token().required(),
},
}),
asyncHandler(UserController.removeResultFilterPreset)
);
router.get(
"/tags",
authenticateRequest({
acceptApeKeys: true,
}),
withApeRateLimiter(RateLimit.userTagsGet),
asyncHandler(UserController.getTags)
);
router.post(
"/tags",
authenticateRequest(),
RateLimit.userTagsAdd,
validateRequest({
body: {
tagName: tagNameValidation,
},
}),
asyncHandler(UserController.addTag)
);
router.patch(
"/tags",
authenticateRequest(),
RateLimit.userTagsEdit,
validateRequest({
body: {
tagId: joi
.string()
.regex(/^[a-f\d]{24}$/i)
.required(),
newName: tagNameValidation,
},
}),
asyncHandler(UserController.editTag)
);
router.delete(
"/tags/:tagId",
authenticateRequest(),
RateLimit.userTagsRemove,
validateRequest({
params: {
tagId: joi
.string()
.regex(/^[a-f\d]{24}$/i)
.required(),
},
}),
asyncHandler(UserController.removeTag)
);
router.delete(
"/tags/:tagId/personalBest",
authenticateRequest(),
RateLimit.userTagsClearPB,
validateRequest({
params: {
tagId: joi
.string()
.regex(/^[a-f\d]{24}$/i)
.required(),
},
}),
asyncHandler(UserController.clearTagPb)
);
router.get(
"/customThemes",
authenticateRequest(),
RateLimit.userCustomThemeGet,
asyncHandler(UserController.getCustomThemes)
);
router.post(
"/customThemes",
authenticateRequest(),
RateLimit.userCustomThemeAdd,
validateRequest({
body: {
name: customThemeNameValidation,
colors: customThemeColorsValidation,
},
}),
asyncHandler(UserController.addCustomTheme)
);
router.delete(
"/customThemes",
authenticateRequest(),
RateLimit.userCustomThemeRemove,
validateRequest({
body: {
themeId: customThemeIdValidation,
},
}),
asyncHandler(UserController.removeCustomTheme)
);
router.patch(
"/customThemes",
authenticateRequest(),
RateLimit.userCustomThemeEdit,
validateRequest({
body: {
themeId: customThemeIdValidation,
theme: {
name: customThemeNameValidation,
colors: customThemeColorsValidation,
},
},
}),
asyncHandler(UserController.editCustomTheme)
);
const requireDiscordIntegrationEnabled = validate({
criteria: (configuration) => {
return configuration.users.discordIntegration.enabled;
create: {
handler: async (r) => callController(UserController.createNewUser)(r),
},
invalidMessage: "Discord integration is not available at this time",
});
router.get(
"/discord/oauth",
requireDiscordIntegrationEnabled,
authenticateRequest(),
RateLimit.userDiscordLink,
asyncHandler(UserController.getOauthLink)
);
router.post(
"/discord/link",
requireDiscordIntegrationEnabled,
authenticateRequest(),
RateLimit.userDiscordLink,
validateRequest({
body: {
tokenType: joi.string().token().required(),
accessToken: joi.string().token().required(),
state: joi.string().length(20).token().required(),
},
}),
asyncHandler(UserController.linkDiscord)
);
router.post(
"/discord/unlink",
authenticateRequest(),
RateLimit.userDiscordUnlink,
asyncHandler(UserController.unlinkDiscord)
);
router.get(
"/personalBests",
authenticateRequest({
acceptApeKeys: true,
}),
withApeRateLimiter(RateLimit.userGet),
validateRequest({
query: {
mode: joi
.string()
.valid("time", "words", "quote", "zen", "custom")
.required(),
mode2: joi.string().regex(/^(\d)+|custom|zen/),
},
}),
asyncHandler(UserController.getPersonalBests)
);
router.get(
"/stats",
authenticateRequest({
acceptApeKeys: true,
}),
withApeRateLimiter(RateLimit.userGet),
asyncHandler(UserController.getStats)
);
router.post(
"/setStreakHourOffset",
authenticateRequest(),
RateLimit.setStreakHourOffset,
validateRequest({
body: {
hourOffset: joi.number().min(-11).max(12).required(),
},
}),
asyncHandler(UserController.setStreakHourOffset)
);
router.get(
"/favoriteQuotes",
authenticateRequest(),
RateLimit.quoteFavoriteGet,
asyncHandler(UserController.getFavoriteQuotes)
);
router.post(
"/favoriteQuotes",
authenticateRequest(),
RateLimit.quoteFavoritePost,
validateRequest({
body: {
language: languageSchema,
quoteId: quoteIdSchema,
},
}),
asyncHandler(UserController.addFavoriteQuote)
);
router.delete(
"/favoriteQuotes",
authenticateRequest(),
RateLimit.quoteFavoriteDelete,
validateRequest({
body: {
language: languageSchema,
quoteId: quoteIdSchema,
},
}),
asyncHandler(UserController.removeFavoriteQuote)
);
const requireProfilesEnabled = validate({
criteria: (configuration) => {
return configuration.users.profiles.enabled;
getNameAvailability: {
handler: async (r) => callController(UserController.checkName)(r),
},
invalidMessage: "Profiles are not available at this time",
});
router.get(
"/:uidOrName/profile",
requireProfilesEnabled,
authenticateRequest({
isPublic: true,
}),
withApeRateLimiter(RateLimit.userProfileGet),
validateRequest({
params: {
uidOrName: joi.alternatives().try(
joi
.string()
.regex(/^[\da-zA-Z._-]+$/)
.max(16),
joi.string().token().max(50)
),
},
query: {
isUid: joi.string().valid("").messages({
"any.only": "isUid must be empty",
}),
},
}),
asyncHandler(UserController.getProfile)
);
const profileDetailsBase = joi
.string()
.allow("")
.custom((value, helpers) => {
if (containsProfanity(value, "word")) {
return helpers.error("string.profanity");
}
return value as string;
})
.messages({
"string.profanity":
"Profanity detected. Please remove it. (if you believe this is a mistake, please contact us)",
});
router.patch(
"/profile",
requireProfilesEnabled,
authenticateRequest(),
RateLimit.userProfileUpdate,
validateRequest({
body: {
bio: profileDetailsBase.max(250),
keyboard: profileDetailsBase.max(75),
selectedBadgeId: joi.number(),
socialProfiles: joi.object({
twitter: profileDetailsBase.regex(/^[0-9a-zA-Z_.-]+$/).max(20),
github: profileDetailsBase.regex(/^[0-9a-zA-Z_.-]+$/).max(39),
website: profileDetailsBase
.uri({
scheme: "https",
domain: {
tlds: {
allow: true,
},
},
})
.max(200),
}),
},
}),
asyncHandler(UserController.updateProfile)
);
const mailIdSchema = joi.array().items(joi.string().guid()).min(1).default([]);
const requireInboxEnabled = validate({
criteria: (configuration) => {
return configuration.users.inbox.enabled;
delete: {
handler: async (r) => callController(UserController.deleteUser)(r),
},
reset: {
handler: async (r) => callController(UserController.resetUser)(r),
},
updateName: {
handler: async (r) => callController(UserController.updateName)(r),
},
updateLeaderboardMemory: {
handler: async (r) => callController(UserController.updateLbMemory)(r),
},
updateEmail: {
handler: async (r) => callController(UserController.updateEmail)(r),
},
updatePassword: {
handler: async (r) => callController(UserController.updatePassword)(r),
},
getPersonalBests: {
handler: async (r) => callController(UserController.getPersonalBests)(r),
},
deletePersonalBests: {
handler: async (r) => callController(UserController.clearPb)(r),
},
optOutOfLeaderboards: {
handler: async (r) =>
callController(UserController.optOutOfLeaderboards)(r),
},
addResultFilterPreset: {
handler: async (r) =>
callController(UserController.addResultFilterPreset)(r),
},
removeResultFilterPreset: {
handler: async (r) =>
callController(UserController.removeResultFilterPreset)(r),
},
getTags: {
handler: async (r) => callController(UserController.getTags)(r),
},
createTag: {
handler: async (r) => callController(UserController.addTag)(r),
},
editTag: {
handler: async (r) => callController(UserController.editTag)(r),
},
deleteTag: {
handler: async (r) => callController(UserController.removeTag)(r),
},
deleteTagPersonalBest: {
handler: async (r) => callController(UserController.clearTagPb)(r),
},
getCustomThemes: {
handler: async (r) => callController(UserController.getCustomThemes)(r),
},
addCustomTheme: {
handler: async (r) => callController(UserController.addCustomTheme)(r),
},
deleteCustomTheme: {
handler: async (r) => callController(UserController.removeCustomTheme)(r),
},
editCustomTheme: {
handler: async (r) => callController(UserController.editCustomTheme)(r),
},
getDiscordOAuth: {
handler: async (r) => callController(UserController.getOauthLink)(r),
},
linkDiscord: {
handler: async (r) => callController(UserController.linkDiscord)(r),
},
unlinkDiscord: {
handler: async (r) => callController(UserController.unlinkDiscord)(r),
},
getStats: {
handler: async (r) => callController(UserController.getStats)(r),
},
setStreakHourOffset: {
handler: async (r) => callController(UserController.setStreakHourOffset)(r),
},
getFavoriteQuotes: {
handler: async (r) => callController(UserController.getFavoriteQuotes)(r),
},
addQuoteToFavorites: {
handler: async (r) => callController(UserController.addFavoriteQuote)(r),
},
removeQuoteFromFavorites: {
handler: async (r) => callController(UserController.removeFavoriteQuote)(r),
},
getProfile: {
handler: async (r) => callController(UserController.getProfile)(r),
},
updateProfile: {
handler: async (r) => callController(UserController.updateProfile)(r),
},
getInbox: {
handler: async (r) => callController(UserController.getInbox)(r),
},
updateInbox: {
handler: async (r) => callController(UserController.updateInbox)(r),
},
report: {
handler: async (r) => callController(UserController.reportUser)(r),
},
verificationEmail: {
handler: async (r) =>
callController(UserController.sendVerificationEmail)(r),
},
forgotPasswordEmail: {
handler: async (r) =>
callController(UserController.sendForgotPasswordEmail)(r),
},
revokeAllTokens: {
handler: async (r) => callController(UserController.revokeAllTokens)(r),
},
getTestActivity: {
handler: async (r) => callController(UserController.getTestActivity)(r),
},
getCurrentTestActivity: {
handler: async (r) =>
callController(UserController.getCurrentTestActivity)(r),
},
getStreak: {
handler: async (r) => callController(UserController.getStreak)(r),
},
invalidMessage: "Your inbox is not available at this time.",
});
router.get(
"/inbox",
requireInboxEnabled,
authenticateRequest(),
RateLimit.userMailGet,
asyncHandler(UserController.getInbox)
);
router.patch(
"/inbox",
requireInboxEnabled,
authenticateRequest(),
RateLimit.userMailUpdate,
validateRequest({
body: {
mailIdsToDelete: mailIdSchema,
mailIdsToMarkRead: mailIdSchema,
},
}),
asyncHandler(UserController.updateInbox)
);
const withCustomMessages = joi.string().messages({
"string.pattern.base": "Invalid parameter format",
});
router.post(
"/report",
validate({
criteria: (configuration) => {
return configuration.quotes.reporting.enabled;
},
invalidMessage: "User reporting is unavailable.",
}),
authenticateRequest(),
RateLimit.quoteReportSubmit,
validateRequest({
body: {
uid: withCustomMessages.token().max(50).required(),
reason: joi
.string()
.valid(
"Inappropriate name",
"Inappropriate bio",
"Inappropriate social links",
"Suspected cheating"
)
.required(),
comment: withCustomMessages
.allow("")
.regex(/^([.]|[^/<>])+$/)
.max(250)
.required(),
captcha: withCustomMessages.regex(/[\w-_]+/).required(),
},
}),
checkUserPermissions(["canReport"], {
criteria: (user) => {
return user.canReport !== false;
},
}),
asyncHandler(UserController.reportUser)
);
router.get(
"/verificationEmail",
authenticateRequest({
noCache: true,
}),
RateLimit.userRequestVerificationEmail,
asyncHandler(UserController.sendVerificationEmail)
);
router.post(
"/forgotPasswordEmail",
RateLimit.userForgotPasswordEmail,
validateRequest({
body: {
email: joi.string().email().required(),
},
}),
asyncHandler(UserController.sendForgotPasswordEmail)
);
router.post(
"/revokeAllTokens",
RateLimit.userRevokeAllTokens,
authenticateRequest({
requireFreshToken: true,
noCache: true,
}),
asyncHandler(UserController.revokeAllTokens)
);
router.get(
"/testActivity",
authenticateRequest(),
RateLimit.userTestActivity,
asyncHandler(UserController.getTestActivity)
);
router.get(
"/currentTestActivity",
authenticateRequest({
acceptApeKeys: true,
}),
withApeRateLimiter(RateLimit.userCurrentTestActivity),
asyncHandler(UserController.getCurrentTestActivity)
);
router.get(
"/streak",
authenticateRequest({
acceptApeKeys: true,
}),
withApeRateLimiter(RateLimit.userStreak),
asyncHandler(UserController.getStreak)
);
export default router;

View file

@ -1,17 +1,12 @@
// import joi from "joi";
import { Router } from "express";
import { authenticateGithubWebhook } from "../../middlewares/auth";
import { asyncHandler } from "../../middlewares/utility";
import { webhookLimit } from "../../middlewares/rate-limit";
import { githubRelease } from "../controllers/webhooks";
import { webhooksContract } from "@monkeytype/contracts/webhooks";
import { initServer } from "@ts-rest/express";
import * as WebhooksController from "../controllers/webhooks";
import { callController } from "../ts-rest-adapter";
const router = Router();
router.post(
"/githubRelease",
webhookLimit,
authenticateGithubWebhook(),
asyncHandler(githubRelease)
);
export default router;
const s = initServer();
export default s.router(webhooksContract, {
postGithubRelease: {
handler: async (r) => callController(WebhooksController.githubRelease)(r),
},
});

View file

@ -1,90 +0,0 @@
import joi from "joi";
const FILTER_SCHEMA = {
_id: joi.string().required(),
name: joi
.string()
.required()
.regex(/^[0-9a-zA-Z_.-]+$/)
.max(16)
.messages({
"string.pattern.base":
"Filter name invalid. Name cannot contain special characters or more than 16 characters. Can include _ . and -",
"string.max": "Filter name exceeds maximum of 16 characters",
}),
pb: joi.object({
no: joi.bool().required(),
yes: joi.bool().required(),
}),
difficulty: joi
.object({
normal: joi.bool().required(),
expert: joi.bool().required(),
master: joi.bool().required(),
})
.required(),
mode: joi
.object({
words: joi.bool().required(),
time: joi.bool().required(),
quote: joi.bool().required(),
zen: joi.bool().required(),
custom: joi.bool().required(),
})
.required(),
words: joi
.object({
10: joi.bool().required(),
25: joi.bool().required(),
50: joi.bool().required(),
100: joi.bool().required(),
custom: joi.bool().required(),
})
.required(),
time: joi
.object({
15: joi.bool().required(),
30: joi.bool().required(),
60: joi.bool().required(),
120: joi.bool().required(),
custom: joi.bool().required(),
})
.required(),
quoteLength: joi
.object({
short: joi.bool().required(),
medium: joi.bool().required(),
long: joi.bool().required(),
thicc: joi.bool().required(),
})
.required(),
punctuation: joi
.object({
on: joi.bool().required(),
off: joi.bool().required(),
})
.required(),
numbers: joi
.object({
on: joi.bool().required(),
off: joi.bool().required(),
})
.required(),
date: joi
.object({
last_day: joi.bool().required(),
last_week: joi.bool().required(),
last_month: joi.bool().required(),
last_3months: joi.bool().required(),
all: joi.bool().required(),
})
.required(),
tags: joi.object().pattern(joi.string().token(), joi.bool()).required(),
language: joi
.object()
.pattern(joi.string().pattern(/^[a-zA-Z0-9_+]+$/), joi.bool())
.required(),
funbox: joi.object().pattern(/\w+/, joi.bool()).required(),
};
export default FILTER_SCHEMA;

View file

@ -1,6 +1,6 @@
import { AppRoute, AppRouter } from "@ts-rest/core";
import { TsRestRequest } from "@ts-rest/express";
import { MonkeyResponse2 } from "../utils/monkey-response";
import { MonkeyResponse } from "../utils/monkey-response";
export function callController<
TRoute extends AppRoute | AppRouter,
TQuery,
@ -17,12 +17,12 @@ export function callController<
body: { message: string; data: TResponse };
}> {
return async (all) => {
const req: MonkeyTypes.Request2<TQuery, TBody, TParams> = {
const req: MonkeyTypes.Request<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"],
ctx: all.req["ctx"] as MonkeyTypes.Context,
};
const result = await handler(req);
@ -60,8 +60,8 @@ type WithoutParams = {
};
type Handler<TQuery, TBody, TParams, TResponse> = (
req: MonkeyTypes.Request2<TQuery, TBody, TParams>
) => Promise<MonkeyResponse2<TResponse>>;
req: MonkeyTypes.Request<TQuery, TBody, TParams>
) => Promise<MonkeyResponse<TResponse>>;
type RequestType2<
TRoute extends AppRoute | AppRouter,

View file

@ -354,6 +354,13 @@ const FunboxList: MonkeyTypes.FunboxMetadata[] = [
frontendFunctions: ["alterText"],
name: "ddoouubblleedd",
},
{
canGetPb: false,
difficultyLevel: 1,
properties: ["changesCapitalisation"],
frontendFunctions: ["alterText"],
name: "instant_messaging",
},
];
export default FunboxList;

View file

@ -1,7 +1,7 @@
import { Collection } from "mongodb";
import * as db from "../init/db";
import { createHash } from "crypto";
import { User } from "@monkeytype/shared-types";
import { User } from "@monkeytype/contracts/schemas/users";
type BlocklistEntryProperties = Pick<User, "name" | "email" | "discordId">;
// Export for use in tests

View file

@ -8,6 +8,20 @@ import MonkeyError from "../utils/error";
import { compareTwoStrings } from "string-similarity";
import { ApproveQuote, Quote } from "@monkeytype/contracts/schemas/quotes";
type JsonQuote = {
text: string;
britishText?: string;
source: string;
length: number;
id: number;
};
type QuoteData = {
language: string;
quotes: JsonQuote[];
groups: [number, number][];
};
const PATH_TO_REPO = "../../../../monkeytype-new-quotes";
let git;
@ -71,7 +85,7 @@ export async function add(
let similarityScore = -1;
if (existsSync(fileDir)) {
const quoteFile = await readFile(fileDir);
const quoteFileJSON = JSON.parse(quoteFile.toString());
const quoteFileJSON = JSON.parse(quoteFile.toString()) as QuoteData;
quoteFileJSON.quotes.every((old) => {
if (compareTwoStrings(old.text, quote.text) > 0.9) {
duplicateId = old.id;
@ -155,7 +169,7 @@ export async function approve(
await git.pull("upstream", "master");
if (existsSync(fileDir)) {
const quoteFile = await readFile(fileDir);
const quoteObject = JSON.parse(quoteFile.toString());
const quoteObject = JSON.parse(quoteFile.toString()) as QuoteData;
quoteObject.quotes.every((old) => {
if (compareTwoStrings(old.text, quote.text) > 0.8) {
throw new MonkeyError(409, "Duplicate quote");
@ -168,7 +182,7 @@ export async function approve(
}
});
quote.id = maxid + 1;
quoteObject.quotes.push(quote);
quoteObject.quotes.push(quote as JsonQuote);
writeFileSync(fileDir, JSON.stringify(quoteObject, null, 2));
message = `Added quote to ${language}.json.`;
} else {

View file

@ -1,7 +1,11 @@
import MonkeyError from "../utils/error";
import * as db from "../init/db";
import { ObjectId, type Filter, Collection, type WithId } from "mongodb";
import { Preset } from "@monkeytype/contracts/schemas/presets";
import {
EditPresetRequest,
Preset,
} from "@monkeytype/contracts/schemas/presets";
import { omit } from "lodash";
const MAX_PRESETS = 10;
@ -56,15 +60,21 @@ export async function addPreset(
};
}
export async function editPreset(uid: string, preset: Preset): Promise<void> {
const config = preset.config;
const presetUpdates =
config !== undefined && config !== null && Object.keys(config).length > 0
? { name: preset.name, config }
: { name: preset.name };
export async function editPreset(
uid: string,
preset: EditPresetRequest
): Promise<void> {
const update: Partial<Omit<Preset, "_id">> = omit(preset, "_id");
if (
preset.config === undefined ||
preset.config === null ||
Object.keys(preset.config).length === 0
) {
delete update.config;
}
await getPresetsCollection().updateOne(getPresetKeyFilter(uid, preset._id), {
$set: presetUpdates,
$set: update,
});
}

View file

@ -87,7 +87,7 @@ export async function getLastResult(
export async function getResultByTimestamp(
uid: string,
timestamp
timestamp: number
): Promise<MonkeyTypes.DBResult | null> {
return await getResultCollection().findOne({ uid, timestamp });
}

View file

@ -1,5 +1,4 @@
import _ from "lodash";
import { containsProfanity, isUsernameValid } from "../utils/validation";
import { canFunboxGetPb, checkAndUpdatePb } from "../utils/pb";
import * as db from "../init/db";
import MonkeyError from "../utils/error";
@ -23,14 +22,14 @@ import {
UserProfileDetails,
UserQuoteRatings,
UserStreak,
} from "@monkeytype/shared-types";
ResultFilters,
} from "@monkeytype/contracts/schemas/users";
import {
Mode,
Mode2,
PersonalBest,
} from "@monkeytype/contracts/schemas/shared";
import { addImportantLog } from "./logs";
import { ResultFilters } from "@monkeytype/contracts/schemas/users";
import { Result as ResultType } from "@monkeytype/contracts/schemas/results";
import { Configuration } from "@monkeytype/contracts/schemas/configuration";
@ -131,12 +130,6 @@ export async function updateName(
if (name === previousName) {
throw new MonkeyError(400, "New name is the same as the old name");
}
if (!isUsernameValid(name)) {
throw new MonkeyError(400, "Invalid username");
}
if (containsProfanity(name, "substring")) {
throw new MonkeyError(400, "Username contains profanity");
}
if (
name?.toLowerCase() !== previousName?.toLowerCase() &&
@ -549,7 +542,7 @@ export async function updateLastHashes(
{ uid },
{
$set: {
lastReultHashes: lastHashes,
lastReultHashes: lastHashes, //TODO fix typo
},
}
);
@ -763,8 +756,8 @@ export async function getStats(
}
export async function getFavoriteQuotes(
uid
): Promise<MonkeyTypes.DBUser["favoriteQuotes"]> {
uid: string
): Promise<NonNullable<MonkeyTypes.DBUser["favoriteQuotes"]>> {
const user = await getPartialUser(uid, "get favorite quotes", [
"favoriteQuotes",
]);
@ -896,7 +889,7 @@ export async function updateProfile(
export async function getInbox(
uid: string
): Promise<MonkeyTypes.DBUser["inbox"]> {
): Promise<NonNullable<MonkeyTypes.DBUser["inbox"]>> {
const user = await getPartialUser(uid, "get inbox", ["inbox"]);
return user.inbox ?? [];
}
@ -1079,7 +1072,7 @@ export async function updateStreak(
} else if (!isToday(streak.lastResultTimestamp, streak.hourOffset ?? 0)) {
void addImportantLog(
"streak_lost",
JSON.parse(JSON.stringify(streak)),
JSON.parse(JSON.stringify(streak)) as Record<string, unknown>,
uid
);
streak.length = 1;

View file

@ -1,418 +0,0 @@
{
"swagger": "2.0",
"info": {
"description": "These are the set of `internal` endpoints dedicated to the Monkeytype web client. Authentication for these endpoints requires a user account.\nNote: We are currently re-working our APIs. Some endpoints are documented at https://api.monkeytype.com/docs/v2/internal",
"version": "1.0.0",
"title": "Monkeytype",
"termsOfService": "https://monkeytype.com/terms-of-service",
"contact": {
"name": "Support",
"email": "support@monkeytype.com"
}
},
"host": "api.monkeytype.com",
"schemes": ["https"],
"consumes": ["application/json"],
"produces": ["application/json"],
"tags": [
{
"name": "index",
"description": "Server status information"
},
{
"name": "users",
"description": "User data and related operations"
}
],
"paths": {
"/": {
"get": {
"tags": ["index"],
"summary": "Gets the server's status data",
"responses": {
"default": {
"description": "",
"schema": {
"$ref": "#/definitions/Response"
}
}
}
}
},
"/users": {
"get": {
"tags": ["users"],
"summary": "Returns a user's data",
"responses": {
"default": {
"description": "",
"schema": {
"$ref": "#/definitions/Response"
}
}
}
},
"delete": {
"tags": ["users"],
"summary": "Deletes a user's account",
"responses": {
"default": {
"description": "",
"schema": {
"$ref": "#/definitions/Response"
}
}
}
}
},
"/users/name": {
"patch": {
"tags": ["users"],
"summary": "Updates a user's name",
"parameters": [
{
"in": "body",
"name": "body",
"required": true,
"schema": {
"type": "object",
"properties": {
"name": {
"type": "string"
}
}
}
}
],
"responses": {
"default": {
"description": "",
"schema": {
"$ref": "#/definitions/Response"
}
}
}
}
},
"/users/signup": {
"post": {
"tags": ["users"],
"summary": "Creates a new user",
"parameters": [
{
"in": "body",
"name": "body",
"required": true,
"schema": {
"type": "object",
"properties": {
"email": {
"type": "string"
},
"name": {
"type": "string"
},
"uid": {
"type": "string"
}
}
}
}
],
"responses": {
"default": {
"description": "",
"schema": {
"$ref": "#/definitions/Response"
}
}
}
}
},
"/users/checkName/{name}": {
"get": {
"tags": ["users"],
"summary": "Checks to see if a username is available",
"parameters": [
{
"name": "name",
"in": "path",
"description": "",
"required": true,
"type": "string"
}
],
"responses": {
"default": {
"description": "",
"schema": {
"$ref": "#/definitions/Response"
}
}
}
}
},
"/users/leaderboardMemory": {
"patch": {
"tags": ["users"],
"summary": "Updates a user's cached leaderboard state",
"parameters": [
{
"in": "body",
"name": "body",
"required": true,
"schema": {
"type": "object",
"properties": {
"mode": {
"type": "string"
},
"mode2": {
"type": "string"
},
"language": {
"type": "string"
},
"rank": {
"type": "number"
}
}
}
}
],
"responses": {
"default": {
"description": "",
"schema": {
"$ref": "#/definitions/Response"
}
}
}
}
},
"/users/discord/link": {
"post": {
"tags": ["users"],
"summary": "Links a user's account with a discord account",
"parameters": [
{
"in": "body",
"name": "body",
"required": true,
"schema": {
"type": "object",
"properties": {
"tokenType": {
"type": "string"
},
"accessToken": {
"type": "string"
},
"uid": {
"type": "string"
}
}
}
}
],
"responses": {
"default": {
"description": "",
"schema": {
"$ref": "#/definitions/Response"
}
}
}
}
},
"/users/discord/unlink": {
"post": {
"tags": ["users"],
"summary": "Unlinks a user's account with a discord account",
"responses": {
"default": {
"description": "",
"schema": {
"$ref": "#/definitions/Response"
}
}
}
}
},
"/users/email": {
"patch": {
"tags": ["users"],
"summary": "Updates a user's email",
"parameters": [
{
"in": "body",
"name": "body",
"required": true,
"schema": {
"type": "object",
"properties": {
"newEmail": {
"type": "string"
},
"previousEmail": {
"type": "string"
}
}
}
}
],
"responses": {
"default": {
"description": "",
"schema": {
"$ref": "#/definitions/Response"
}
}
}
}
},
"/users/personalBests": {
"delete": {
"tags": ["users"],
"summary": "Gets a user's personal bests",
"responses": {
"default": {
"description": "",
"schema": {
"$ref": "#/definitions/Response"
}
}
}
}
},
"/users/tags": {
"get": {
"tags": ["users"],
"summary": "Gets a user's tags",
"responses": {
"default": {
"description": "",
"schema": {
"$ref": "#/definitions/Response"
}
}
}
},
"post": {
"tags": ["users"],
"summary": "Creates a new tag",
"parameters": [
{
"in": "body",
"name": "body",
"required": true,
"schema": {
"type": "object",
"properties": {
"tagName": {
"type": "string"
}
}
}
}
],
"responses": {
"default": {
"description": "",
"schema": {
"$ref": "#/definitions/Response"
}
}
}
},
"patch": {
"tags": ["users"],
"summary": "Updates an existing tag",
"parameters": [
{
"in": "body",
"name": "body",
"required": true,
"schema": {
"type": "object",
"properties": {
"tagId": {
"type": "string"
},
"newName": {
"type": "string"
}
}
}
}
],
"responses": {
"default": {
"description": "",
"schema": {
"$ref": "#/definitions/Response"
}
}
}
}
},
"/users/tags/{tagId}": {
"delete": {
"tags": ["users"],
"summary": "Deletes a tag",
"parameters": [
{
"in": "path",
"name": "tagId",
"required": true,
"type": "string"
}
],
"responses": {
"default": {
"description": "",
"schema": {
"$ref": "#/definitions/Response"
}
}
}
}
},
"/users/tags/{tagId}/personalBest": {
"delete": {
"tags": ["users"],
"summary": "Removes personal bests associated with a tag",
"parameters": [
{
"in": "path",
"name": "tagId",
"required": true,
"type": "string"
}
],
"responses": {
"default": {
"description": "",
"schema": {
"$ref": "#/definitions/Response"
}
}
}
}
}
},
"definitions": {
"Response": {
"type": "object",
"required": ["message", "data"],
"properties": {
"message": {
"type": "string"
},
"data": {
"type": "object"
}
}
}
}
}

View file

@ -1,493 +0,0 @@
{
"swagger": "2.0",
"info": {
"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.\n\nNote: We are currently re-working our APIs. Some endpoints are documented at https://api.monkeytype.com/docs/v2/public",
"version": "1.0.0",
"title": "Monkeytype API",
"termsOfService": "https://monkeytype.com/terms-of-service",
"contact": {
"name": "Support",
"email": "support@monkeytype.com"
}
},
"host": "api.monkeytype.com",
"schemes": ["https"],
"basePath": "/",
"consumes": ["application/json"],
"produces": ["application/json"],
"tags": [
{
"name": "users",
"description": "User data and related operations"
}
],
"paths": {
"/users/personalBests": {
"get": {
"tags": ["users"],
"summary": "Gets a user's personal best data",
"parameters": [
{
"name": "mode",
"in": "query",
"description": "The primary mode (i.e., time)",
"required": true,
"type": "string"
},
{
"name": "mode2",
"in": "query",
"description": "The secondary mode (i.e., 60)",
"required": false,
"type": "string"
}
],
"responses": {
"200": {
"description": "",
"schema": {
"$ref": "#/definitions/PersonalBest"
}
}
}
}
},
"/users/stats": {
"get": {
"tags": ["users"],
"summary": "Gets a user's typing stats data",
"responses": {
"200": {
"description": "",
"schema": {
"$ref": "#/definitions/Stats"
}
}
}
}
},
"/users/tags": {
"get": {
"tags": ["users"],
"summary": "Gets a user's tags data",
"responses": {
"200": {
"description": "",
"schema": {
"$ref": "#/definitions/Tags"
}
}
}
}
},
"/users/{uidOrName}/profile": {
"get": {
"tags": ["users"],
"summary": "Gets a user's profile",
"parameters": [
{
"name": "uidOrName",
"in": "path",
"description": "The user uid or name. Defaults to the user name. To filter by uid set the parameter `isUid` to ``.",
"required": true,
"type": "string"
},
{
"name": "isUid",
"in": "query",
"description": "Indicates the parameter `uidOrName` is an uid.",
"required": false,
"type": "string",
"minLength": 0,
"maxLength": 0
}
],
"responses": {
"200": {
"description": "",
"schema": {
"$ref": "#/definitions/Profile"
}
}
}
}
},
"/users/currentTestActivity": {
"get": {
"tags": ["users"],
"summary": "Gets a user's test activity data for the last ~52 weeks",
"responses": {
"200": {
"description": "",
"schema": {
"$ref": "#/definitions/CurrentTestActivity"
}
}
}
}
},
"/users/streak": {
"get": {
"tags": ["users"],
"summary": "Gets a user's streak",
"responses": {
"200": {
"description": "",
"schema": {
"$ref": "#/definitions/UserStreak"
}
}
}
}
}
},
"definitions": {
"Response": {
"type": "object",
"required": ["message", "data"],
"properties": {
"message": {
"type": "string"
},
"data": {
"type": "object"
}
}
},
"PersonalBest": {
"type": "object",
"properties": {
"acc": {
"type": "number",
"format": "double",
"example": 94.44
},
"consistency": {
"type": "number",
"format": "double",
"example": 75.98
},
"difficulty": {
"type": "string",
"example": "normal"
},
"lazyMode": {
"type": "boolean",
"example": false
},
"language": {
"type": "string",
"example": "english"
},
"punctuation": {
"type": "boolean",
"example": false
},
"raw": {
"type": "number",
"format": "double",
"example": 116.6
},
"wpm": {
"type": "number",
"format": "double",
"example": 107.6
},
"timestamp": {
"type": "integer",
"example": 1644438189583
}
}
},
"Profile": {
"type": "object",
"properties": {
"name": {
"type": "string",
"example": "example_name"
},
"banned": {
"type": "boolean",
"example": true
},
"addedAt": {
"type": "integer",
"example": 1644438189583
},
"typingStats": {
"type": "object",
"properties": {
"startedTests": {
"type": "integer",
"example": 578
},
"completedTests": {
"type": "integer",
"example": 451
},
"timeTyping": {
"type": "number",
"format": "double",
"example": 3941.6
}
}
},
"personalBests": {
"type": "object",
"properties": {
"time": {
"type": "object",
"properties": {
"15": {
"type": "array",
"items": {
"$ref": "#/definitions/PersonalBest"
}
},
"30": {
"type": "array",
"items": {
"$ref": "#/definitions/PersonalBest"
}
},
"60": {
"type": "array",
"items": {
"$ref": "#/definitions/PersonalBest"
}
},
"120": {
"type": "array",
"items": {
"$ref": "#/definitions/PersonalBest"
}
}
}
},
"words": {
"type": "object",
"properties": {
"10": {
"type": "array",
"items": {
"$ref": "#/definitions/PersonalBest"
}
},
"25": {
"type": "array",
"items": {
"$ref": "#/definitions/PersonalBest"
}
},
"50": {
"type": "array",
"items": {
"$ref": "#/definitions/PersonalBest"
}
},
"100": {
"type": "array",
"items": {
"$ref": "#/definitions/PersonalBest"
}
}
}
}
}
},
"discordId": {
"type": "string",
"example": "974761412044437307"
},
"discordAvatar": {
"type": "string",
"example": "6226b17aebc27a4a8d1ce04b"
},
"inventory": {
"type": "object",
"properties": {
"badges": {
"type": "array",
"items": {
"type": "object",
"properties": {
"id": {
"type": "integer",
"example": 1
},
"selected": {
"type": "boolean",
"example": true
}
}
}
}
}
},
"details": {
"type": "object",
"properties": {
"bio": {
"type": "string",
"example": "I love MonkeyType!"
},
"keyboard": {
"type": "string",
"example": "Keychron V4"
},
"socialProfiles": {
"type": "object",
"properties": {
"twitter": {
"type": "string",
"example": "monkeytype"
},
"github": {
"type": "string",
"example": "monkeytype"
},
"website": {
"type": "string",
"example": "https://monkeytype.com/"
}
}
}
}
}
}
},
"Stats": {
"type": "object",
"properties": {
"startedTests": {
"type": "integer",
"example": 578
},
"completedTests": {
"type": "integer",
"example": 451
},
"timeTyping": {
"type": "number",
"format": "double",
"example": 3941.6
}
}
},
"Tags": {
"type": "array",
"items": {
"type": "object",
"properties": {
"_id": {
"type": "string",
"example": "63fde8d39312642481070f5d"
},
"name": {
"type": "string",
"example": "example_tag"
},
"personalBests": {
"type": "object",
"properties": {
"time": {
"type": "object",
"properties": {
"15": {
"type": "array",
"items": {
"$ref": "#/definitions/PersonalBest"
}
},
"30": {
"type": "array",
"items": {
"$ref": "#/definitions/PersonalBest"
}
},
"60": {
"type": "array",
"items": {
"$ref": "#/definitions/PersonalBest"
}
},
"120": {
"type": "array",
"items": {
"$ref": "#/definitions/PersonalBest"
}
}
}
},
"words": {
"type": "object",
"properties": {
"10": {
"type": "array",
"items": {
"$ref": "#/definitions/PersonalBest"
}
},
"25": {
"type": "array",
"items": {
"$ref": "#/definitions/PersonalBest"
}
},
"50": {
"type": "array",
"items": {
"$ref": "#/definitions/PersonalBest"
}
},
"100": {
"type": "array",
"items": {
"$ref": "#/definitions/PersonalBest"
}
}
}
}
}
}
}
}
},
"CurrentTestActivity": {
"type": "object",
"properties": {
"testByDays": {
"type": "array",
"items": {
"type": "number",
"nullable": true
},
"example": [null, null, null, 1, 2, 3, null, 4],
"description": "Test activity by day. Last element of the array are the tests on the date specified by the `lastDay` property. All dates are in UTC."
},
"lastDay": {
"type": "integer",
"example": 1712140496000
}
}
},
"UserStreak": {
"type": "object",
"properties": {
"lastResultTimestamp": {
"type": "integer"
},
"length": {
"type": "integer"
},
"maxLength": {
"type": "integer"
},
"hourOffset": {
"type": "integer",
"nullable": true
}
}
}
}
}

View file

@ -25,8 +25,8 @@ function mergeConfigurations(
const commonKeys = _.intersection(_.keys(base), _.keys(source));
commonKeys.forEach((key) => {
const baseValue = base[key];
const sourceValue = source[key];
const baseValue = base[key] as object;
const sourceValue = source[key] as object;
const isBaseValueObject = _.isPlainObject(baseValue);
const isSourceValueObject = _.isPlainObject(sourceValue);

View file

@ -58,7 +58,7 @@ export async function connect(): Promise<void> {
await mongoClient.connect();
db = mongoClient.db(DB_NAME);
} catch (error) {
Logger.error(error.message);
Logger.error(error.message as string);
Logger.error(
"Failed to connect to database. Exiting with exit status code 1."
);

View file

@ -72,7 +72,7 @@ export async function init(): Promise<void> {
Logger.success("Email client configuration verified");
} catch (error) {
transportInitialized = false;
Logger.error(error.message);
Logger.error(error.message as string);
Logger.error("Failed to verify email client configuration.");
}
}
@ -103,14 +103,16 @@ export async function sendEmail(
html: template,
};
let result;
type Result = { response: string; accepted: string[] };
let result: Result;
try {
result = await transporter.sendMail(mailOptions);
result = (await transporter.sendMail(mailOptions)) as Result;
} catch (e) {
recordEmail(templateName, "fail");
return {
success: false,
message: e.message,
message: e.message as string,
};
}

View file

@ -29,11 +29,9 @@ export function init(): void {
encoding: "utf8",
flag: "r",
})
);
) as ServiceAccount;
admin.initializeApp({
credential: admin.credential.cert(
serviceAccount as unknown as ServiceAccount
),
credential: admin.credential.cert(serviceAccount),
});
Logger.success("Firebase app initialized");
}

View file

@ -53,7 +53,7 @@ export async function connect(): Promise<void> {
connected = true;
} catch (error) {
Logger.error(error.message);
Logger.error(error.message as string);
if (isDevEnvironment()) {
await connection.quit();
Logger.warning(

View file

@ -1,72 +0,0 @@
import MonkeyError from "../utils/error";
import type { Response, NextFunction, RequestHandler } from "express";
import statuses from "../constants/monkey-status-codes";
import rateLimit, {
type RateLimitRequestHandler,
type Options,
} from "express-rate-limit";
import { isDevEnvironment } from "../utils/misc";
import { TsRestRequestHandler } from "@ts-rest/express";
import { TsRestRequestWithCtx } from "./auth";
const REQUEST_MULTIPLIER = isDevEnvironment() ? 1 : 1;
const getKey = (req: MonkeyTypes.Request, _res: Response): string => {
return req?.ctx?.decodedToken?.uid;
};
const ONE_MINUTE = 1000 * 60;
const {
APE_KEY_RATE_LIMIT_EXCEEDED: { message, code },
} = statuses;
export const customHandler = (
_req: MonkeyTypes.Request,
_res: Response,
_next: NextFunction,
_options: Options
): void => {
throw new MonkeyError(code, message);
};
const apeRateLimiter = rateLimit({
windowMs: ONE_MINUTE,
max: 30 * REQUEST_MULTIPLIER,
keyGenerator: getKey,
handler: customHandler,
});
export function withApeRateLimiter(
defaultRateLimiter: RateLimitRequestHandler,
apeRateLimiterOverride?: RateLimitRequestHandler
): RequestHandler {
return (req: MonkeyTypes.Request, res: Response, next: NextFunction) => {
if (req.ctx.decodedToken.type === "ApeKey") {
const rateLimiter = apeRateLimiterOverride ?? apeRateLimiter;
// TODO: bump version?
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return rateLimiter(req, res, next);
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return defaultRateLimiter(req, res, next);
};
}
export function withApeRateLimiter2<T extends AppRouter | AppRoute>(
defaultRateLimiter: RateLimitRequestHandler,
apeRateLimiterOverride?: RateLimitRequestHandler
): TsRestRequestHandler<T> {
return (req: TsRestRequestWithCtx, res: Response, next: NextFunction) => {
if (req.ctx.decodedToken.type === "ApeKey") {
const rateLimiter = apeRateLimiterOverride ?? apeRateLimiter;
// TODO: bump version?
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return rateLimiter(req, res, next);
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return defaultRateLimiter(req, res, next);
};
}

View file

@ -3,7 +3,7 @@ import { getApeKey, updateLastUsedOn } from "../dal/ape-keys";
import MonkeyError from "../utils/error";
import { verifyIdToken } from "../utils/auth";
import { base64UrlDecode, isDevEnvironment } from "../utils/misc";
import { NextFunction, Response, Handler } from "express";
import { NextFunction, Response } from "express";
import statuses from "../constants/monkey-status-codes";
import {
incrementAuth,
@ -14,10 +14,14 @@ 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 "@monkeytype/contracts/schemas/api";
import {
EndpointMetadata,
RequestAuthenticationOptions,
} from "@monkeytype/contracts/schemas/api";
import { Configuration } from "@monkeytype/contracts/schemas/configuration";
const DEFAULT_OPTIONS: RequestAuthenticationOptions = {
isGithubWebhook: false,
isPublic: false,
acceptApeKeys: false,
requireFreshToken: false,
@ -26,7 +30,8 @@ const DEFAULT_OPTIONS: RequestAuthenticationOptions = {
export type TsRestRequestWithCtx = {
ctx: Readonly<MonkeyTypes.Context>;
} & TsRestRequest;
} & TsRestRequest &
ExpressRequest;
/**
* Authenticate request based on the auth settings of the route.
@ -43,99 +48,82 @@ export function authenticateTsRestRequest<
): Promise<void> => {
const options = {
...DEFAULT_OPTIONS,
...(req.tsRestRoute["metadata"]?.["authenticationOptions"] ?? {}),
...((req.tsRestRoute["metadata"]?.["authenticationOptions"] ??
{}) as EndpointMetadata),
};
return _authenticateRequestInternal(req, _res, next, options);
};
}
export function authenticateRequest(authOptions = DEFAULT_OPTIONS): Handler {
const options = {
...DEFAULT_OPTIONS,
...authOptions,
};
const startTime = performance.now();
let token: MonkeyTypes.DecodedToken;
let authType = "None";
return async (
req: MonkeyTypes.Request,
_res: Response,
next: NextFunction
): Promise<void> => {
return _authenticateRequestInternal(req, _res, next, options);
};
}
const isPublic =
options.isPublic || (options.isPublicOnDev && isDevEnvironment());
async function _authenticateRequestInternal(
req: MonkeyTypes.Request | TsRestRequestWithCtx,
_res: Response,
next: NextFunction,
options: RequestAuthenticationOptions
): Promise<void> {
const startTime = performance.now();
let token: MonkeyTypes.DecodedToken;
let authType = "None";
const {
authorization: authHeader,
"x-hub-signature-256": githubWebhookHeader,
} = req.headers;
const isPublic =
options.isPublic || (options.isPublicOnDev && isDevEnvironment());
try {
if (options.isGithubWebhook) {
token = authenticateGithubWebhook(req, githubWebhookHeader);
} else if (authHeader !== undefined && authHeader !== "") {
token = await authenticateWithAuthHeader(
authHeader,
req.ctx.configuration,
options
);
} else if (isPublic === true) {
token = {
type: "None",
uid: "",
email: "",
};
} else {
throw new MonkeyError(
401,
"Unauthorized",
`endpoint: ${req.baseUrl} no authorization header found`
);
}
const { authorization: authHeader } = req.headers;
incrementAuth(token.type);
try {
if (authHeader !== undefined && authHeader !== "") {
token = await authenticateWithAuthHeader(
authHeader,
req.ctx.configuration,
options
);
} else if (isPublic === true) {
token = {
type: "None",
uid: "",
email: "",
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
);
next(error);
return;
}
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
);
next(error);
return;
}
recordAuthTime(
token.type,
"success",
Math.round(performance.now() - startTime),
req
);
const country = req.headers["cf-ipcountry"] as string;
if (country) {
recordRequestCountry(country, req);
}
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(
@ -200,33 +188,27 @@ async function authenticateWithBearerToken(
email: decodedToken.email ?? "",
};
} catch (error) {
const errorCode = error?.errorInfo?.code;
const errorCode = error?.errorInfo?.code as string | undefined;
if (errorCode?.includes("auth/id-token-expired") as boolean | undefined) {
if (errorCode?.includes("auth/id-token-expired")) {
throw new MonkeyError(
401,
"Token expired - please login again",
"authenticateWithBearerToken"
);
} else if (
errorCode?.includes("auth/id-token-revoked") as boolean | undefined
) {
} else if (errorCode?.includes("auth/id-token-revoked")) {
throw new MonkeyError(
401,
"Token revoked - please login again",
"authenticateWithBearerToken"
);
} else if (
errorCode?.includes("auth/user-not-found") as boolean | undefined
) {
} else if (errorCode?.includes("auth/user-not-found")) {
throw new MonkeyError(
404,
"User not found",
"authenticateWithBearerToken"
);
} else if (
errorCode?.includes("auth/argument-error") as boolean | undefined
) {
} else if (errorCode?.includes("auth/argument-error")) {
throw new MonkeyError(
400,
"Incorrect Bearer token format",
@ -321,44 +303,49 @@ async function authenticateWithUid(
};
}
export function authenticateGithubWebhook(): Handler {
return async (
req: MonkeyTypes.Request,
_res: Response,
next: NextFunction
): Promise<void> => {
//authorize github webhook
const { "x-hub-signature-256": authHeader } = req.headers;
export function authenticateGithubWebhook(
req: TsRestRequest,
authHeader: string | string[] | undefined
): MonkeyTypes.DecodedToken {
try {
const webhookSecret = process.env["GITHUB_WEBHOOK_SECRET"];
try {
if (webhookSecret === undefined || webhookSecret === "") {
throw new MonkeyError(500, "Missing Github Webhook Secret");
} else if (
authHeader === undefined ||
authHeader === "" ||
authHeader.length === 0
) {
throw new MonkeyError(401, "Missing Github signature header");
} else {
const signature = crypto
.createHmac("sha256", webhookSecret)
.update(JSON.stringify(req.body))
.digest("hex");
const trusted = Buffer.from(`sha256=${signature}`, "ascii");
const untrusted = Buffer.from(authHeader as string, "ascii");
const isSignatureValid = crypto.timingSafeEqual(trusted, untrusted);
if (!isSignatureValid) {
throw new MonkeyError(401, "Github webhook signature invalid");
}
}
} catch (e) {
next(e);
return;
if (webhookSecret === undefined || webhookSecret === "") {
throw new MonkeyError(500, "Missing Github Webhook Secret");
}
next();
};
if (
Array.isArray(authHeader) ||
authHeader === undefined ||
authHeader === ""
) {
throw new MonkeyError(401, "Missing Github signature header");
}
const signature = crypto
.createHmac("sha256", webhookSecret)
.update(JSON.stringify(req.body))
.digest("hex");
const trusted = Buffer.from(`sha256=${signature}`, "ascii");
const untrusted = Buffer.from(authHeader, "ascii");
const isSignatureValid = crypto.timingSafeEqual(trusted, untrusted);
if (!isSignatureValid) {
throw new MonkeyError(401, "Github webhook signature invalid");
}
return {
type: "GithubWebhook",
uid: "",
email: "",
};
} catch (error) {
if (error instanceof MonkeyError) {
throw error;
}
throw new MonkeyError(
500,
"Failed to authenticate Github webhook: " + (error as Error).message
);
}
}

View file

@ -1,32 +1,87 @@
import type { Response, NextFunction, RequestHandler } from "express";
import type { Response, NextFunction } from "express";
import { TsRestRequestWithCtx } from "./auth";
import { TsRestRequestHandler } from "@ts-rest/express";
import { EndpointMetadata } from "@monkeytype/contracts/schemas/api";
import MonkeyError from "../utils/error";
import { Configuration } from "@monkeytype/contracts/schemas/configuration";
import {
ConfigurationPath,
RequireConfiguration,
} from "@monkeytype/contracts/require-configuration/index";
export type ValidationOptions<T> = {
criteria: (data: T) => boolean;
invalidMessage?: string;
};
export function verifyRequiredConfiguration<
T extends AppRouter | AppRoute
>(): TsRestRequestHandler<T> {
return async (
req: TsRestRequestWithCtx,
_res: Response,
next: NextFunction
): Promise<void> => {
const requiredConfigurations = getRequireConfigurations(
req.tsRestRoute["metadata"] as EndpointMetadata | undefined
);
/**
* This utility checks that the server's configuration matches
* the criteria.
*/
export function validate(
options: ValidationOptions<Configuration>
): RequestHandler {
const {
criteria,
invalidMessage = "This service is currently unavailable.",
} = options;
return (req: MonkeyTypes.Request, _res: Response, next: NextFunction) => {
const configuration = req.ctx.configuration;
const validated = criteria(configuration);
if (!validated) {
throw new MonkeyError(503, invalidMessage);
if (requiredConfigurations === undefined) {
next();
return;
}
try {
for (const requireConfiguration of requiredConfigurations) {
const value = getValue(
req.ctx.configuration,
requireConfiguration.path
);
if (!value) {
throw new MonkeyError(
503,
requireConfiguration.invalidMessage ??
"This endpoint is currently unavailable."
);
}
}
next();
return;
} catch (e) {
next(e);
return;
}
next();
};
}
function getValue(
configuration: Configuration,
path: ConfigurationPath
): boolean {
const keys = (path as string).split(".");
let result: unknown = configuration;
for (const key of keys) {
if (result === undefined || result === null) {
throw new MonkeyError(500, `Invalid configuration path: "${path}"`);
}
result = result[key];
}
if (result === undefined || result === null)
throw new MonkeyError(
500,
`Required configuration doesnt exist: "${path}"`
);
if (typeof result !== "boolean")
throw new MonkeyError(
500,
`Required configuration is not a boolean: "${path}"`
);
return result;
}
function getRequireConfigurations(
metadata: EndpointMetadata | undefined
): RequireConfiguration[] | undefined {
if (metadata === undefined || metadata.requireConfiguration === undefined)
return undefined;
if (Array.isArray(metadata.requireConfiguration))
return metadata.requireConfiguration;
return [metadata.requireConfiguration];
}

View file

@ -1,8 +1,14 @@
import { getCachedConfiguration } from "../init/configuration";
import type { Response, NextFunction } from "express";
/**
* Add the context to the request
* @param req
* @param _res
* @param next
*/
async function contextMiddleware(
req: MonkeyTypes.Request,
req: MonkeyTypes.ExpressRequestWithContext,
_res: Response,
next: NextFunction
): Promise<void> {

View file

@ -4,7 +4,8 @@ import Logger from "../utils/logger";
import MonkeyError from "../utils/error";
import { incrementBadAuth } from "./rate-limit";
import type { NextFunction, Response } from "express";
import { MonkeyResponse, handleMonkeyResponse } from "../utils/monkey-response";
import { isCustomCode } from "../constants/monkey-status-codes";
import {
recordClientErrorByVersion,
recordServerErrorByVersion,
@ -26,60 +27,62 @@ type DBError = {
url: string;
};
type ErrorData = {
errorId?: string;
uid: string;
};
async function errorHandlingMiddleware(
error: Error,
req: MonkeyTypes.Request,
req: MonkeyTypes.ExpressRequestWithContext,
res: Response,
_next: NextFunction
): Promise<void> {
try {
const monkeyError = error as MonkeyError;
const monkeyResponse = new MonkeyResponse();
monkeyResponse.status = 500;
monkeyResponse.data = {
let status = 500;
const data: { errorId?: string; uid: string } = {
errorId: monkeyError.errorId ?? uuidv4(),
uid: monkeyError.uid ?? req.ctx?.decodedToken?.uid,
};
let message = "Unknown error";
if (/ECONNREFUSED.*27017/i.test(error.message)) {
monkeyResponse.message =
"Could not connect to the database. It may be down.";
message = "Could not connect to the database. It may be down.";
} else if (error instanceof URIError || error instanceof SyntaxError) {
monkeyResponse.status = 400;
monkeyResponse.message = "Unprocessable request";
status = 400;
message = "Unprocessable request";
} else if (error instanceof MonkeyError) {
monkeyResponse.message = error.message;
monkeyResponse.status = error.status;
message = error.message;
status = error.status;
} else {
monkeyResponse.message = `Oops! Our monkeys dropped their bananas. Please try again later. - ${monkeyResponse.data.errorId}`;
message = `Oops! Our monkeys dropped their bananas. Please try again later. - ${data.errorId}`;
}
await incrementBadAuth(req, res, monkeyResponse.status);
await incrementBadAuth(req, res, status);
if (monkeyResponse.status >= 400 && monkeyResponse.status < 500) {
if (status >= 400 && status < 500) {
recordClientErrorByVersion(req.headers["x-client-version"] as string);
}
if (
!isDevEnvironment() &&
monkeyResponse.status >= 500 &&
monkeyResponse.status !== 503
) {
if (!isDevEnvironment() && status >= 500 && status !== 503) {
recordServerErrorByVersion(version);
const { uid, errorId } = monkeyResponse.data;
const { uid, errorId } = data as {
uid: string;
errorId: string;
};
try {
await addLog(
"system_error",
`${monkeyResponse.status} ${errorId} ${error.message} ${error.stack}`,
`${status} ${errorId} ${error.message} ${error.stack}`,
uid
);
await db.collection<DBError>("errors").insertOne({
_id: errorId,
_id: new ObjectId(errorId),
timestamp: Date.now(),
status: monkeyResponse.status,
status: status,
uid,
message: error.message,
stack: error.stack,
@ -89,31 +92,47 @@ async function errorHandlingMiddleware(
});
} catch (e) {
Logger.error("Logging to db failed.");
Logger.error(e);
Logger.error(e.message as string);
console.error(e);
}
} else {
Logger.error(`Error: ${error.message} Stack: ${error.stack}`);
}
if (monkeyResponse.status < 500) {
delete monkeyResponse.data.errorId;
if (status < 500) {
delete data.errorId;
}
handleMonkeyResponse(monkeyResponse, res);
handleErrorResponse(res, status, message, data);
return;
} catch (e) {
Logger.error("Error handling middleware failed.");
Logger.error(e);
Logger.error(e.message as string);
console.error(e);
}
handleMonkeyResponse(
new MonkeyResponse(
"Something went really wrong, please contact support.",
undefined,
500
),
res
handleErrorResponse(
res,
500,
"Something went really wrong, please contact support."
);
}
function handleErrorResponse(
res: Response,
status: number,
message: string,
data?: ErrorData
): void {
res.status(status);
if (isCustomCode(status)) {
res.statusMessage = message;
}
//@ts-expect-error ignored so that we can see message in swagger stats
res.monkeyMessage = message;
res.json({ message, data: data ?? null });
}
export default errorHandlingMiddleware;

View file

@ -1,82 +1,199 @@
import _ from "lodash";
import MonkeyError from "../utils/error";
import type { Response, NextFunction, RequestHandler } from "express";
import type { Response, NextFunction } from "express";
import { getPartialUser } from "../dal/user";
import { isAdmin } from "../dal/admin-uids";
import type { ValidationOptions } from "./configuration";
import { TsRestRequestHandler } from "@ts-rest/express";
import { TsRestRequestWithCtx } from "./auth";
import { RequestAuthenticationOptions } from "@monkeytype/contracts/schemas/api";
import {
EndpointMetadata,
RequestAuthenticationOptions,
PermissionId,
} from "@monkeytype/contracts/schemas/api";
import { isDevEnvironment } from "../utils/misc";
/**
* Check if the user is an admin before handling request.
* Note that this middleware must be used after authentication in the middleware stack.
*/
export function checkIfUserIsAdmin<
type RequestPermissionCheck = {
type: "request";
criteria: (
req: TsRestRequestWithCtx,
metadata: EndpointMetadata | undefined
) => Promise<boolean>;
invalidMessage?: string;
};
type UserPermissionCheck = {
type: "user";
fields: (keyof MonkeyTypes.DBUser)[];
criteria: (user: MonkeyTypes.DBUser) => boolean;
invalidMessage?: string;
};
type PermissionCheck = UserPermissionCheck | RequestPermissionCheck;
function buildUserPermission<K extends keyof MonkeyTypes.DBUser>(
fields: K[],
criteria: (user: Pick<MonkeyTypes.DBUser, K>) => boolean,
invalidMessage?: string
): UserPermissionCheck {
return {
type: "user",
fields,
criteria,
invalidMessage: invalidMessage,
};
}
const permissionChecks: Record<PermissionId, PermissionCheck> = {
admin: {
type: "request",
criteria: async (req, metadata) =>
await checkIfUserIsAdmin(
req.ctx.decodedToken,
metadata?.authenticationOptions
),
},
quoteMod: buildUserPermission(
["quoteMod"],
(user) =>
user.quoteMod === true ||
(typeof user.quoteMod === "string" && user.quoteMod !== "")
),
canReport: buildUserPermission(
["canReport"],
(user) => user.canReport !== false
),
canManageApeKeys: buildUserPermission(
["canManageApeKeys"],
(user) => user.canManageApeKeys ?? true,
"You have lost access to ape keys, please contact support"
),
};
export function verifyPermissions<
T extends AppRouter | AppRoute
>(): TsRestRequestHandler<T> {
return async (
req: TsRestRequestWithCtx,
_res: Response,
next: NextFunction
) => {
try {
const options: RequestAuthenticationOptions =
req.tsRestRoute["metadata"]?.["authenticationOptions"] ?? {};
): Promise<void> => {
const metadata = req.tsRestRoute["metadata"] as
| EndpointMetadata
| undefined;
const requiredPermissionIds = getRequiredPermissionIds(metadata);
if (
requiredPermissionIds === undefined ||
requiredPermissionIds.length === 0
) {
next();
return;
}
if (options.isPublicOnDev && isDevEnvironment()) {
next();
const checks = requiredPermissionIds.map((id) => permissionChecks[id]);
if (checks.some((it) => it === undefined)) {
next(new MonkeyError(500, "Unknown permission id."));
return;
}
//handle request checks
const requestChecks = checks.filter((it) => it.type === "request");
for (const check of requestChecks) {
if (!(await check.criteria(req, metadata))) {
next(
new MonkeyError(
403,
check.invalidMessage ?? "You don't have permission to do this."
)
);
return;
}
const { uid } = req.ctx.decodedToken;
const admin = await isAdmin(uid);
if (!admin) {
throw new MonkeyError(403, "You don't have permission to do this.");
}
} catch (error) {
next(error);
}
next();
};
}
//handle user checks
const userChecks = checks.filter((it) => it.type === "user");
const checkResult = await checkUserPermissions(
req.ctx.decodedToken,
userChecks
);
/**
* Check user permissions before handling request.
* Note that this middleware must be used after authentication in the middleware stack.
*/
export function checkUserPermissions<K extends keyof MonkeyTypes.DBUser>(
fields: K[],
options: ValidationOptions<Pick<MonkeyTypes.DBUser, K>>
): RequestHandler {
const { criteria, invalidMessage = "You don't have permission to do this." } =
options;
return async (
req: MonkeyTypes.Request,
_res: Response,
next: NextFunction
) => {
try {
const { uid } = req.ctx.decodedToken;
const userData = await getPartialUser(
uid,
"check user permissions",
fields
if (!checkResult.passed) {
next(
new MonkeyError(
403,
checkResult.invalidMessage ?? "You don't have permission to do this."
)
);
const hasPermission = criteria(userData);
if (!hasPermission) {
throw new MonkeyError(403, invalidMessage);
}
} catch (error) {
next(error);
return;
}
//all checks passed
next();
return;
};
}
function getRequiredPermissionIds(
metadata: EndpointMetadata | undefined
): PermissionId[] | undefined {
if (metadata === undefined || metadata.requirePermission === undefined)
return undefined;
if (Array.isArray(metadata.requirePermission))
return metadata.requirePermission;
return [metadata.requirePermission];
}
async function checkIfUserIsAdmin(
decodedToken: MonkeyTypes.DecodedToken | undefined,
options: RequestAuthenticationOptions | undefined
): Promise<boolean> {
if (decodedToken === undefined) return false;
if (options?.isPublicOnDev && isDevEnvironment()) return true;
return await isAdmin(decodedToken.uid);
}
type CheckResult =
| {
passed: true;
}
| {
passed: false;
invalidMessage?: string;
};
async function checkUserPermissions(
decodedToken: MonkeyTypes.DecodedToken | undefined,
checks: UserPermissionCheck[]
): Promise<CheckResult> {
if (checks === undefined || checks.length === 0) {
return {
passed: true,
};
}
if (decodedToken === undefined) {
return {
passed: false,
invalidMessage: "Failed to check permissions, authentication required.",
};
}
const user = (await getPartialUser(
decodedToken.uid,
"check user permissions",
checks.flatMap((it) => it.fields)
)) as MonkeyTypes.DBUser;
for (const check of checks) {
if (!check.criteria(user))
return {
passed: false,
invalidMessage: check.invalidMessage,
};
}
return {
passed: true,
};
}

View file

@ -1,13 +1,42 @@
import _ from "lodash";
import MonkeyError from "../utils/error";
import type { Response, NextFunction } from "express";
import type { Response, NextFunction, Request } from "express";
import { RateLimiterMemory } from "rate-limiter-flexible";
import rateLimit, { type Options } from "express-rate-limit";
import {
rateLimit,
RateLimitRequestHandler,
type Options,
} from "express-rate-limit";
import { isDevEnvironment } from "../utils/misc";
import { EndpointMetadata } from "@monkeytype/contracts/schemas/api";
import { TsRestRequestWithCtx } from "./auth";
import { TsRestRequestHandler } from "@ts-rest/express";
import {
limits,
RateLimiterId,
RateLimitOptions,
Window,
} from "@monkeytype/contracts/rate-limit/index";
import statuses from "../constants/monkey-status-codes";
const REQUEST_MULTIPLIER = isDevEnvironment() ? 100 : 1;
export const REQUEST_MULTIPLIER = isDevEnvironment() ? 100 : 1;
const getKey = (req: MonkeyTypes.Request, _res: Response): string => {
export const customHandler = (
req: MonkeyTypes.ExpressRequestWithContext,
_res: Response,
_next: NextFunction,
_options: Options
): void => {
if (req.ctx.decodedToken.type === "ApeKey") {
throw new MonkeyError(
statuses.APE_KEY_RATE_LIMIT_EXCEEDED.code,
statuses.APE_KEY_RATE_LIMIT_EXCEEDED.message
);
}
throw new MonkeyError(429, "Request limit reached, please try again later.");
};
const getKey = (req: Request, _res: Response): string => {
return (
(req.headers["cf-connecting-ip"] as string) ||
(req.headers["x-forwarded-for"] as string) ||
@ -16,29 +45,95 @@ const getKey = (req: MonkeyTypes.Request, _res: Response): string => {
);
};
const getKeyWithUid = (req: MonkeyTypes.Request, _res: Response): string => {
const getKeyWithUid = (
req: MonkeyTypes.ExpressRequestWithContext,
_res: Response
): string => {
const uid = req?.ctx?.decodedToken?.uid;
const useUid = uid !== undefined && uid !== "";
return useUid ? uid : getKey(req, _res);
};
export const customHandler = (
_req: MonkeyTypes.Request,
_res: Response,
_next: NextFunction,
_options: Options
): void => {
throw new MonkeyError(429, "Request limit reached, please try again later.");
};
function initialiseLimiters(): Record<RateLimiterId, RateLimitRequestHandler> {
const keys = Object.keys(limits) as RateLimiterId[];
const ONE_HOUR_SECONDS = 60 * 60;
const ONE_HOUR_MS = 1000 * ONE_HOUR_SECONDS;
const ONE_DAY_MS = 24 * ONE_HOUR_MS;
const convert = (options: RateLimitOptions): RateLimitRequestHandler => {
return rateLimit({
windowMs: convertWindowToMs(options.window),
max: options.max * REQUEST_MULTIPLIER,
handler: customHandler,
keyGenerator: getKeyWithUid,
});
};
return keys.reduce(
(output, key) => ({ ...output, [key]: convert(limits[key]) }),
{}
) as Record<RateLimiterId, RateLimitRequestHandler>;
}
function convertWindowToMs(window: Window): number {
if (typeof window === "number") return window;
switch (window) {
case "second":
return 1000;
case "minute":
return 60 * 1000;
case "hour":
return 60 * 60 * 1000;
case "day":
return 24 * 60 * 60 * 1000;
}
}
//visible for testing
export const requestLimiters: Record<RateLimiterId, RateLimitRequestHandler> =
initialiseLimiters();
export function rateLimitRequest<
T extends AppRouter | AppRoute
>(): TsRestRequestHandler<T> {
return async (
req: TsRestRequestWithCtx,
res: Response,
next: NextFunction
): Promise<void> => {
const rateLimit = (req.tsRestRoute["metadata"] as EndpointMetadata)
?.rateLimit;
if (rateLimit === undefined) {
next();
return;
}
const hasApeKeyLimiterId = typeof rateLimit === "object";
let rateLimiterId: RateLimiterId;
if (req.ctx.decodedToken.type === "ApeKey") {
rateLimiterId = hasApeKeyLimiterId
? rateLimit.apeKey
: "defaultApeRateLimit";
} else {
rateLimiterId = hasApeKeyLimiterId ? rateLimit.normal : rateLimit;
}
const rateLimiter = requestLimiters[rateLimiterId];
if (rateLimiter === undefined) {
next(
new MonkeyError(
500,
`Unknown rateLimiterId '${rateLimiterId}', how did you manage to do this?`
)
);
} else {
rateLimiter(req, res, next);
}
};
}
// Root Rate Limit
export const rootRateLimiter = rateLimit({
windowMs: ONE_HOUR_MS,
windowMs: 60 * 1000 * 60,
max: 1000 * REQUEST_MULTIPLIER,
keyGenerator: getKey,
handler: (_req, _res, _next, _options): void => {
@ -52,11 +147,11 @@ export const rootRateLimiter = rateLimit({
// Bad Authentication Rate Limiter
const badAuthRateLimiter = new RateLimiterMemory({
points: 30 * REQUEST_MULTIPLIER,
duration: ONE_HOUR_SECONDS,
duration: 60 * 60, //one hour seconds
});
export async function badAuthRateLimiterHandler(
req: MonkeyTypes.Request,
req: MonkeyTypes.ExpressRequestWithContext,
res: Response,
next: NextFunction
): Promise<void> {
@ -86,7 +181,7 @@ export async function badAuthRateLimiterHandler(
}
export async function incrementBadAuth(
req: MonkeyTypes.Request,
req: MonkeyTypes.ExpressRequestWithContext,
res: Response,
status: number
): Promise<void> {
@ -103,476 +198,9 @@ export async function incrementBadAuth(
} catch (error) {}
}
export const adminLimit = rateLimit({
windowMs: 5000,
max: 1 * REQUEST_MULTIPLIER,
keyGenerator: getKeyWithUid,
handler: customHandler,
});
// Config Routing
export const configUpdate = rateLimit({
windowMs: ONE_HOUR_MS,
max: 500 * REQUEST_MULTIPLIER,
keyGenerator: getKeyWithUid,
handler: customHandler,
});
export const configGet = rateLimit({
windowMs: ONE_HOUR_MS,
max: 120 * REQUEST_MULTIPLIER,
keyGenerator: getKeyWithUid,
handler: customHandler,
});
export const configDelete = rateLimit({
windowMs: ONE_HOUR_MS,
max: 120 * REQUEST_MULTIPLIER,
keyGenerator: getKeyWithUid,
handler: customHandler,
});
// Leaderboards Routing
export const leaderboardsGet = rateLimit({
windowMs: ONE_HOUR_MS,
max: 500 * REQUEST_MULTIPLIER,
keyGenerator: getKeyWithUid,
handler: customHandler,
});
// New Quotes Routing
export const newQuotesGet = rateLimit({
windowMs: ONE_HOUR_MS,
max: 500 * REQUEST_MULTIPLIER,
keyGenerator: getKeyWithUid,
handler: customHandler,
});
export const newQuotesIsSubmissionEnabled = rateLimit({
windowMs: 60 * 1000,
max: 60 * REQUEST_MULTIPLIER,
keyGenerator: getKeyWithUid,
handler: customHandler,
});
export const newQuotesAdd = rateLimit({
windowMs: ONE_HOUR_MS,
max: 60 * REQUEST_MULTIPLIER,
keyGenerator: getKeyWithUid,
handler: customHandler,
});
export const newQuotesAction = rateLimit({
windowMs: ONE_HOUR_MS,
max: 500 * REQUEST_MULTIPLIER,
keyGenerator: getKeyWithUid,
handler: customHandler,
});
// Quote Ratings Routing
export const quoteRatingsGet = rateLimit({
windowMs: ONE_HOUR_MS,
max: 500 * REQUEST_MULTIPLIER,
keyGenerator: getKeyWithUid,
handler: customHandler,
});
export const quoteRatingsSubmit = rateLimit({
windowMs: ONE_HOUR_MS,
max: 500 * REQUEST_MULTIPLIER,
keyGenerator: getKeyWithUid,
handler: customHandler,
});
// Quote reporting
export const quoteReportSubmit = rateLimit({
windowMs: 30 * 60 * 1000, // 30 min
max: 50 * REQUEST_MULTIPLIER,
keyGenerator: getKeyWithUid,
handler: customHandler,
});
// Quote favorites
export const quoteFavoriteGet = rateLimit({
windowMs: 30 * 60 * 1000, // 30 min
max: 50 * REQUEST_MULTIPLIER,
keyGenerator: getKeyWithUid,
handler: customHandler,
});
export const quoteFavoritePost = rateLimit({
windowMs: 30 * 60 * 1000, // 30 min
max: 50 * REQUEST_MULTIPLIER,
keyGenerator: getKeyWithUid,
handler: customHandler,
});
export const quoteFavoriteDelete = rateLimit({
windowMs: 30 * 60 * 1000, // 30 min
max: 50 * REQUEST_MULTIPLIER,
keyGenerator: getKeyWithUid,
handler: customHandler,
});
// Presets Routing
export const presetsGet = rateLimit({
windowMs: ONE_HOUR_MS,
max: 60 * REQUEST_MULTIPLIER,
keyGenerator: getKeyWithUid,
handler: customHandler,
});
export const presetsAdd = rateLimit({
windowMs: ONE_HOUR_MS,
max: 60 * REQUEST_MULTIPLIER,
keyGenerator: getKeyWithUid,
handler: customHandler,
});
export const presetsRemove = rateLimit({
windowMs: ONE_HOUR_MS,
max: 60 * REQUEST_MULTIPLIER,
keyGenerator: getKeyWithUid,
handler: customHandler,
});
export const presetsEdit = rateLimit({
windowMs: ONE_HOUR_MS,
max: 60 * REQUEST_MULTIPLIER,
keyGenerator: getKeyWithUid,
handler: customHandler,
});
// PSA (Public Service Announcement) Routing
export const psaGet = rateLimit({
windowMs: 60 * 1000,
max: 60 * REQUEST_MULTIPLIER,
keyGenerator: getKeyWithUid,
handler: customHandler,
});
// get public speed stats
export const publicStatsGet = rateLimit({
windowMs: 60 * 1000,
max: 60 * REQUEST_MULTIPLIER,
keyGenerator: getKeyWithUid,
handler: customHandler,
});
// Results Routing
export const resultsGet = rateLimit({
windowMs: ONE_HOUR_MS,
max: 60 * REQUEST_MULTIPLIER,
keyGenerator: getKeyWithUid,
handler: customHandler,
});
// Results Routing
export const resultsGetApe = rateLimit({
windowMs: ONE_DAY_MS,
max: 30 * REQUEST_MULTIPLIER,
keyGenerator: getKeyWithUid,
handler: customHandler,
});
export const resultsAdd = rateLimit({
windowMs: ONE_HOUR_MS,
max: 300 * REQUEST_MULTIPLIER,
keyGenerator: getKeyWithUid,
handler: customHandler,
});
export const resultsTagsUpdate = rateLimit({
windowMs: ONE_HOUR_MS,
max: 100 * REQUEST_MULTIPLIER,
keyGenerator: getKeyWithUid,
handler: customHandler,
});
export const resultsDeleteAll = rateLimit({
windowMs: ONE_HOUR_MS,
max: 10 * REQUEST_MULTIPLIER,
keyGenerator: getKeyWithUid,
handler: customHandler,
});
export const resultsLeaderboardGet = rateLimit({
windowMs: ONE_HOUR_MS,
max: 60 * REQUEST_MULTIPLIER,
keyGenerator: getKeyWithUid,
handler: customHandler,
});
export const resultsLeaderboardQualificationGet = rateLimit({
windowMs: ONE_HOUR_MS,
max: 60 * REQUEST_MULTIPLIER,
keyGenerator: getKeyWithUid,
handler: customHandler,
});
// Users Routing
export const userGet = rateLimit({
windowMs: ONE_HOUR_MS,
max: 60 * REQUEST_MULTIPLIER,
keyGenerator: getKeyWithUid,
handler: customHandler,
});
export const setStreakHourOffset = rateLimit({
windowMs: ONE_HOUR_MS,
max: 5 * REQUEST_MULTIPLIER,
keyGenerator: getKeyWithUid,
handler: customHandler,
});
export const userSignup = rateLimit({
windowMs: 24 * ONE_HOUR_MS, // 1 day
max: 2 * REQUEST_MULTIPLIER,
keyGenerator: getKey,
handler: customHandler,
});
export const userDelete = rateLimit({
windowMs: 24 * ONE_HOUR_MS, // 1 day
max: 3 * REQUEST_MULTIPLIER,
keyGenerator: getKeyWithUid,
handler: customHandler,
});
export const userReset = rateLimit({
windowMs: 24 * ONE_HOUR_MS, // 1 day
max: 3 * REQUEST_MULTIPLIER,
keyGenerator: getKeyWithUid,
handler: customHandler,
});
export const userCheckName = rateLimit({
windowMs: 60 * 1000,
max: 60 * REQUEST_MULTIPLIER,
keyGenerator: getKeyWithUid,
handler: customHandler,
});
export const userUpdateName = rateLimit({
windowMs: 24 * ONE_HOUR_MS, // 1 day
max: 3 * REQUEST_MULTIPLIER,
keyGenerator: getKeyWithUid,
handler: customHandler,
});
export const userUpdateLBMemory = rateLimit({
windowMs: 60 * 1000,
max: 60 * REQUEST_MULTIPLIER,
keyGenerator: getKeyWithUid,
handler: customHandler,
});
export const userUpdateEmail = rateLimit({
windowMs: ONE_HOUR_MS,
max: 60 * REQUEST_MULTIPLIER,
keyGenerator: getKeyWithUid,
handler: customHandler,
});
export const userClearPB = rateLimit({
windowMs: ONE_HOUR_MS,
max: 60 * REQUEST_MULTIPLIER,
keyGenerator: getKeyWithUid,
handler: customHandler,
});
export const userOptOutOfLeaderboards = rateLimit({
windowMs: ONE_HOUR_MS,
max: 10 * REQUEST_MULTIPLIER,
keyGenerator: getKeyWithUid,
handler: customHandler,
});
export const userCustomFilterAdd = rateLimit({
windowMs: ONE_HOUR_MS,
max: 60 * REQUEST_MULTIPLIER,
keyGenerator: getKeyWithUid,
handler: customHandler,
});
export const userCustomFilterRemove = rateLimit({
windowMs: ONE_HOUR_MS,
max: 60 * REQUEST_MULTIPLIER,
keyGenerator: getKeyWithUid,
handler: customHandler,
});
export const userTagsGet = rateLimit({
windowMs: ONE_HOUR_MS,
max: 60 * REQUEST_MULTIPLIER,
keyGenerator: getKeyWithUid,
handler: customHandler,
});
export const userTagsRemove = rateLimit({
windowMs: ONE_HOUR_MS,
max: 30 * REQUEST_MULTIPLIER,
keyGenerator: getKeyWithUid,
handler: customHandler,
});
export const userTagsClearPB = rateLimit({
windowMs: ONE_HOUR_MS,
max: 60 * REQUEST_MULTIPLIER,
keyGenerator: getKeyWithUid,
handler: customHandler,
});
export const userTagsEdit = rateLimit({
windowMs: ONE_HOUR_MS,
max: 30 * REQUEST_MULTIPLIER,
keyGenerator: getKeyWithUid,
handler: customHandler,
});
export const userTagsAdd = rateLimit({
windowMs: ONE_HOUR_MS,
max: 30 * REQUEST_MULTIPLIER,
keyGenerator: getKeyWithUid,
handler: customHandler,
});
export const userCustomThemeGet = rateLimit({
windowMs: ONE_HOUR_MS,
max: 30 * REQUEST_MULTIPLIER,
keyGenerator: getKeyWithUid,
handler: customHandler,
});
export const userCustomThemeAdd = rateLimit({
windowMs: ONE_HOUR_MS,
max: 30 * REQUEST_MULTIPLIER,
keyGenerator: getKeyWithUid,
handler: customHandler,
});
export const userCustomThemeRemove = rateLimit({
windowMs: ONE_HOUR_MS,
max: 30 * REQUEST_MULTIPLIER,
keyGenerator: getKeyWithUid,
handler: customHandler,
});
export const userCustomThemeEdit = rateLimit({
windowMs: ONE_HOUR_MS,
max: 30 * REQUEST_MULTIPLIER,
keyGenerator: getKeyWithUid,
handler: customHandler,
});
export const userDiscordLink = rateLimit({
windowMs: ONE_HOUR_MS,
max: 15 * REQUEST_MULTIPLIER,
keyGenerator: getKeyWithUid,
handler: customHandler,
});
export const usersTagsEdit = userDiscordLink;
export const userDiscordUnlink = rateLimit({
windowMs: ONE_HOUR_MS,
max: 15 * REQUEST_MULTIPLIER,
keyGenerator: getKeyWithUid,
handler: customHandler,
});
export const userRequestVerificationEmail = rateLimit({
windowMs: ONE_HOUR_MS / 4,
max: 1 * REQUEST_MULTIPLIER,
keyGenerator: getKeyWithUid,
handler: customHandler,
});
export const userForgotPasswordEmail = rateLimit({
windowMs: ONE_HOUR_MS / 60,
max: 1 * REQUEST_MULTIPLIER,
keyGenerator: getKeyWithUid,
handler: customHandler,
});
export const userRevokeAllTokens = rateLimit({
windowMs: ONE_HOUR_MS,
max: 10 * REQUEST_MULTIPLIER,
keyGenerator: getKeyWithUid,
handler: customHandler,
});
export const userProfileGet = rateLimit({
windowMs: ONE_HOUR_MS,
max: 100 * REQUEST_MULTIPLIER,
keyGenerator: getKeyWithUid,
handler: customHandler,
});
export const userProfileUpdate = rateLimit({
windowMs: ONE_HOUR_MS,
max: 60 * REQUEST_MULTIPLIER,
keyGenerator: getKeyWithUid,
handler: customHandler,
});
export const userMailGet = rateLimit({
windowMs: ONE_HOUR_MS,
max: 60 * REQUEST_MULTIPLIER,
keyGenerator: getKeyWithUid,
handler: customHandler,
});
export const userMailUpdate = rateLimit({
windowMs: ONE_HOUR_MS,
max: 60 * REQUEST_MULTIPLIER,
keyGenerator: getKeyWithUid,
handler: customHandler,
});
export const userTestActivity = rateLimit({
windowMs: ONE_HOUR_MS,
max: 60 * REQUEST_MULTIPLIER,
keyGenerator: getKeyWithUid,
handler: customHandler,
});
export const userCurrentTestActivity = rateLimit({
windowMs: ONE_HOUR_MS,
max: 60 * REQUEST_MULTIPLIER,
keyGenerator: getKeyWithUid,
handler: customHandler,
});
export const userStreak = rateLimit({
windowMs: ONE_HOUR_MS,
max: 60 * REQUEST_MULTIPLIER,
keyGenerator: getKeyWithUid,
handler: customHandler,
});
// ApeKeys Routing
export const apeKeysGet = rateLimit({
windowMs: ONE_HOUR_MS,
max: 120 * REQUEST_MULTIPLIER,
keyGenerator: getKeyWithUid,
handler: customHandler,
});
export const apeKeysGenerate = rateLimit({
windowMs: ONE_HOUR_MS,
max: 15 * REQUEST_MULTIPLIER,
keyGenerator: getKeyWithUid,
handler: customHandler,
});
export const webhookLimit = rateLimit({
windowMs: 1000,
max: 1 * REQUEST_MULTIPLIER,
keyGenerator: getKeyWithUid,
handler: customHandler,
});
export const apeKeysUpdate = apeKeysGenerate;
export const apeKeysDelete = apeKeysGenerate;

View file

@ -1,41 +1,9 @@
import _ from "lodash";
import type { Request, Response, NextFunction, RequestHandler } from "express";
import { handleMonkeyResponse, MonkeyResponse } from "../utils/monkey-response";
import { recordClientVersion as prometheusRecordClientVersion } from "../utils/prometheus";
import { validate } from "./configuration";
import { isDevEnvironment } from "../utils/misc";
export const emptyMiddleware = (
_req: MonkeyTypes.Request,
_res: Response,
next: NextFunction
): void => next();
type AsyncHandler = (
req: MonkeyTypes.Request,
res?: Response
) => Promise<MonkeyResponse>;
/**
* This utility serves as an alternative to wrapping express handlers with try/catch statements.
* Any routes that use an async handler function should wrap the handler with this function.
* Without this, any errors thrown will not be caught by the error handling middleware, and
* the app will hang!
*/
export function asyncHandler(handler: AsyncHandler): RequestHandler {
return async (
req: MonkeyTypes.Request,
res: Response,
next: NextFunction
) => {
try {
const handlerData = await handler(req, res);
handleMonkeyResponse(handlerData, res);
} catch (error) {
next(error);
}
};
}
import MonkeyError from "../utils/error";
import { TsRestRequestWithCtx } from "./auth";
/**
* record the client version from the `x-client-version` or ` client-version` header to prometheus
@ -52,11 +20,18 @@ export function recordClientVersion(): RequestHandler {
};
}
export function onlyAvailableOnDev(): RequestHandler {
return validate({
criteria: () => {
return isDevEnvironment();
},
invalidMessage: "Development endpoints are only available in DEV mode.",
});
/** Endpoint is only available in dev environment, else return 503. */
export function onlyAvailableOnDev(): MonkeyTypes.RequestHandler {
return (_req: TsRestRequestWithCtx, _res: Response, next: NextFunction) => {
if (!isDevEnvironment()) {
next(
new MonkeyError(
503,
"Development endpoints are only available in DEV mode."
)
);
} else {
next();
}
};
}

View file

@ -1,69 +0,0 @@
import _ from "lodash";
import joi from "joi";
import MonkeyError from "../utils/error";
import type { Response, NextFunction, RequestHandler } from "express";
type ValidationSchema = {
body?: object;
query?: object;
params?: object;
headers?: object;
};
type ValidationSchemaOption = {
allowUnknown?: boolean;
};
type ValidationHandlingOptions = {
validationErrorMessage?: string;
};
type ValidationSchemaOptions = {
[_schema in keyof ValidationSchema]?: ValidationSchemaOption;
} & ValidationHandlingOptions;
const VALIDATION_SCHEMA_DEFAULT_OPTIONS: ValidationSchemaOptions = {
body: { allowUnknown: false },
headers: { allowUnknown: true },
params: { allowUnknown: false },
query: { allowUnknown: false },
};
export function validateRequest(
validationSchema: ValidationSchema,
validationOptions: ValidationSchemaOptions = VALIDATION_SCHEMA_DEFAULT_OPTIONS
): RequestHandler {
const options = {
...VALIDATION_SCHEMA_DEFAULT_OPTIONS,
...validationOptions,
};
const { validationErrorMessage } = options;
const normalizedValidationSchema: ValidationSchema = _.omit(
validationSchema,
"validationErrorMessage"
);
return (req: MonkeyTypes.Request, _res: Response, next: NextFunction) => {
_.each(
normalizedValidationSchema,
(schema: object, key: keyof ValidationSchema) => {
const joiSchema = joi
.object()
.keys(schema)
.unknown(options[key]?.allowUnknown);
const { error } = joiSchema.validate(req[key] ?? {});
if (error) {
const errorMessage = error.details[0]?.message;
throw new MonkeyError(
422,
validationErrorMessage ??
`${errorMessage} (${error.details[0]?.context?.value})`
);
}
}
);
next();
};
}

View file

@ -74,7 +74,7 @@ async function bootServer(port: number): Promise<Server> {
recordServerVersion(version);
} catch (error) {
Logger.error("Failed to boot server");
Logger.error(error.message);
Logger.error(error.message as string);
console.error(error);
return process.exit(1);
}

View file

@ -129,14 +129,14 @@ export class WeeklyXpLeaderboard {
this.getThisWeeksXpLeaderboardKeys();
// @ts-expect-error
const [results, scores]: string[][] = await connection.getResults(
const [results, scores] = (await connection.getResults(
2, // How many of the arguments are redis keys (https://redis.io/docs/manual/programmability/lua-api/)
weeklyXpLeaderboardScoresKey,
weeklyXpLeaderboardResultsKey,
minRank,
maxRank,
"true"
);
)) as string[][];
if (results === undefined) {
throw new Error(
@ -183,20 +183,24 @@ export class WeeklyXpLeaderboard {
// eslint-disable-next-line @typescript-eslint/prefer-ts-expect-error
// @ts-ignore
const [[, rank], [, totalXp], [, count], [, result]] = await connection
const [[, rank], [, totalXp], [, count], [, result]] = (await connection
.multi()
.zrevrank(weeklyXpLeaderboardScoresKey, uid)
.zscore(weeklyXpLeaderboardScoresKey, uid)
.zcard(weeklyXpLeaderboardScoresKey)
.hget(weeklyXpLeaderboardResultsKey, uid)
.exec();
.exec()) as [
[null, number | null],
[null, string | null],
[null, number | null]
];
if (rank === null) {
return null;
}
//TODO parse with zod?
const parsed = JSON.parse(result ?? "null") as Omit<
const parsed = JSON.parse((result as string) ?? "null") as Omit<
XpLeaderboardEntry,
"rank" | "count" | "totalXp"
>;
@ -204,7 +208,7 @@ export class WeeklyXpLeaderboard {
return {
rank: rank + 1,
count: count ?? 0,
totalXp: parseInt(totalXp, 10),
totalXp: parseInt(totalXp as string, 10),
...parsed,
};
}

View file

@ -8,7 +8,7 @@ type AppRoute = import("@ts-rest/core").AppRoute;
type AppRouter = import("@ts-rest/core").AppRouter;
declare namespace MonkeyTypes {
export type DecodedToken = {
type: "Bearer" | "ApeKey" | "None";
type: "Bearer" | "ApeKey" | "None" | "GithubWebhook";
uid: string;
email: string;
};
@ -18,11 +18,11 @@ declare namespace MonkeyTypes {
decodedToken: DecodedToken;
};
type Request = {
type ExpressRequestWithContext = {
ctx: Readonly<Context>;
} & ExpressRequest;
type Request2<TQuery = undefined, TBody = undefined, TParams = undefined> = {
type Request<TQuery = undefined, TBody = undefined, TParams = undefined> = {
query: Readonly<TQuery>;
body: Readonly<TBody>;
params: Readonly<TParams>;
@ -30,8 +30,11 @@ declare namespace MonkeyTypes {
raw: Readonly<TsRestRequest>;
};
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
type RequestHandler = import("@ts-rest/core").TsRestRequestHandler<any>;
type DBUser = Omit<
import("@monkeytype/shared-types").User,
import("@monkeytype/contracts/schemas/users").User,
| "resultFilterPresets"
| "tags"
| "customThemes"
@ -41,26 +44,28 @@ declare namespace MonkeyTypes {
> & {
_id: ObjectId;
resultFilterPresets?: WithObjectId<
import("@monkeytype/shared-types").ResultFilters
import("@monkeytype/contracts/schemas/users").ResultFilters
>[];
tags?: DBUserTag[];
lbPersonalBests?: LbPersonalBests;
customThemes?: DBCustomTheme[];
autoBanTimestamps?: number[];
inbox?: import("@monkeytype/shared-types").MonkeyMail[];
inbox?: import("@monkeytype/contracts/schemas/users").MonkeyMail[];
ips?: string[];
canReport?: boolean;
lastNameChange?: number;
canManageApeKeys?: boolean;
bananas?: number;
testActivity?: import("@monkeytype/shared-types").CountByYearAndDay;
testActivity?: import("@monkeytype/contracts/schemas/users").CountByYearAndDay;
};
type DBCustomTheme = WithObjectId<
import("@monkeytype/shared-types").CustomTheme
import("@monkeytype/contracts/schemas/users").CustomTheme
>;
type DBUserTag = WithObjectId<import("@monkeytype/shared-types").UserTag>;
type DBUserTag = WithObjectId<
import("@monkeytype/contracts/schemas/users").UserTag
>;
type LbPersonalBests = {
time: Record<

View file

@ -121,14 +121,14 @@ export class DailyLeaderboard {
this.getTodaysLeaderboardKeys();
// @ts-expect-error
const [results]: string[][] = await connection.getResults(
const [results] = (await connection.getResults(
2,
leaderboardScoresKey,
leaderboardResultsKey,
minRank,
maxRank,
"false"
);
)) as string[][];
if (results === undefined) {
throw new Error(
@ -167,17 +167,25 @@ export class DailyLeaderboard {
const { leaderboardScoresKey, leaderboardResultsKey } =
this.getTodaysLeaderboardKeys();
// @ts-expect-error
const [[, rank], [, count], [, result], [, minScore]] = await connection
const redisExecResult = (await connection
.multi()
.zrevrank(leaderboardScoresKey, uid)
.zcard(leaderboardScoresKey)
.hget(leaderboardResultsKey, uid)
.zrange(leaderboardScoresKey, 0, 0, "WITHSCORES")
.exec();
.exec()) as [
[null, number | null],
[null, number | null],
[null, string | null],
[null, [string, string] | null]
];
const [[, rank], [, count], [, result], [, minScore]] = redisExecResult;
const minWpm =
minScore.length > 0 ? parseInt(minScore[1]?.slice(1, 6)) / 100 : 0;
minScore !== null && minScore.length > 0
? parseInt(minScore[1]?.slice(1, 6)) / 100
: 0;
if (rank === null) {
return {
minWpm,
@ -190,7 +198,7 @@ export class DailyLeaderboard {
count: count ?? 0,
rank: rank + 1,
entry: {
...JSON.parse(result ?? "null"),
...(JSON.parse(result ?? "null") as LeaderboardEntry),
},
};
}

View file

@ -51,7 +51,7 @@ type AgentLog = {
device?: string;
};
export function buildAgentLog(req: MonkeyTypes.Request): AgentLog {
export function buildAgentLog(req: TsRestRequest): AgentLog {
const agent = uaparser(req.headers["user-agent"]);
const agentLog: AgentLog = {
@ -285,7 +285,7 @@ export function formatSeconds(
}
export function intersect<T>(a: T[], b: T[], removeDuplicates = false): T[] {
let t;
let t: T[];
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
if (b.length > a.length) (t = b), (b = a), (a = t); // indexOf to loop over shorter
const filtered = a.filter(function (e) {

View file

@ -1,4 +1,4 @@
import { MonkeyMail } from "@monkeytype/shared-types";
import { MonkeyMail } from "@monkeytype/contracts/schemas/users";
import { v4 } from "uuid";
type MonkeyMailOptions = Partial<Omit<MonkeyMail, "id" | "read">>;

View file

@ -1,48 +1,10 @@
import { type Response } from "express";
import { isCustomCode } from "../constants/monkey-status-codes";
import { MonkeyResponseType } from "@monkeytype/contracts/schemas/api";
export type MonkeyDataAware<T> = {
data: T | null;
};
//TODO FIX ANYS
export class MonkeyResponse {
message: string;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
data: any;
status: number;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
constructor(message?: string, data?: any, status = 200) {
this.message = message ?? "ok";
this.data = data ?? null;
this.status = status;
}
}
export function handleMonkeyResponse(
monkeyResponse: MonkeyResponse,
res: Response
): void {
const { message, data, status } = monkeyResponse;
res.status(status);
if (isCustomCode(status)) {
res.statusMessage = message;
}
//@ts-expect-error ignored so that we can see message in swagger stats
res.monkeyMessage = message;
if ([301, 302].includes(status)) {
res.redirect(data);
return;
}
res.json({ message, data });
}
export class MonkeyResponse2<T = null>
export class MonkeyResponse<T = null>
implements MonkeyResponseType, MonkeyDataAware<T>
{
public message: string;

View file

@ -59,7 +59,14 @@ export function checkAndUpdatePb(
}
if (!_.isNil(lbPersonalBests)) {
updateLeaderboardPersonalBests(userPb, lbPersonalBests, result);
const newLbPb = updateLeaderboardPersonalBests(
userPb,
lbPersonalBests,
result
);
if (newLbPb !== null) {
lbPersonalBests = newLbPb;
}
}
return {
@ -165,26 +172,20 @@ function buildPersonalBest(result: Result): PersonalBest {
};
}
function updateLeaderboardPersonalBests(
export function updateLeaderboardPersonalBests(
userPersonalBests: PersonalBests,
lbPersonalBests: MonkeyTypes.LbPersonalBests,
result: Result
): void {
): MonkeyTypes.LbPersonalBests | null {
if (!shouldUpdateLeaderboardPersonalBests(result)) {
return;
return null;
}
const mode = result.mode;
const mode2 = result.mode2;
lbPersonalBests[mode] = lbPersonalBests[mode] ?? {};
const lbMode2 = lbPersonalBests[mode][mode2] as MonkeyTypes.LbPersonalBests;
if (lbMode2 === undefined || Array.isArray(lbMode2)) {
lbPersonalBests[mode][mode2] = {};
}
const lbPb = lbPersonalBests ?? {};
lbPb[mode] ??= {};
lbPb[mode][mode2] ??= {};
const bestForEveryLanguage = {};
userPersonalBests[mode][mode2].forEach((pb: PersonalBest) => {
const language = pb.language;
if (
@ -194,18 +195,18 @@ function updateLeaderboardPersonalBests(
bestForEveryLanguage[language] = pb;
}
});
_.each(bestForEveryLanguage, (pb: PersonalBest, language: string) => {
const languageDoesNotExist =
lbPersonalBests[mode][mode2][language] === undefined;
const languageDoesNotExist = lbPb[mode][mode2][language] === undefined;
const languageIsEmpty = _.isEmpty(lbPb[mode][mode2][language]);
if (
languageDoesNotExist ||
lbPersonalBests[mode][mode2][language].wpm < pb.wpm
languageIsEmpty ||
lbPb[mode][mode2][language].wpm < pb.wpm
) {
lbPersonalBests[mode][mode2][language] = pb;
lbPb[mode][mode2][language] = pb;
}
});
return lbPb;
}
function shouldUpdateLeaderboardPersonalBests(result: Result): boolean {

View file

@ -1,7 +1,7 @@
import "dotenv/config";
import { Counter, Histogram, Gauge } from "prom-client";
import { TsRestRequestWithCtx } from "../middlewares/auth";
import { CompletedEvent } from "@monkeytype/contracts/schemas/results";
import { Request } from "express";
const auth = new Counter({
name: "api_request_auth_total",
@ -74,7 +74,9 @@ const leaderboardUpdate = new Gauge({
labelNames: ["language", "mode", "mode2", "step"],
});
export function incrementAuth(type: "Bearer" | "ApeKey" | "None"): void {
export function incrementAuth(
type: "Bearer" | "ApeKey" | "None" | "GithubWebhook"
): void {
auth.inc({ type });
}
@ -211,7 +213,7 @@ export function recordAuthTime(
type: string,
status: "success" | "failure",
time: number,
req: MonkeyTypes.Request | TsRestRequestWithCtx
req: Request
): void {
const reqPath = req.baseUrl + req.route.path;
@ -231,10 +233,7 @@ const requestCountry = new Counter({
labelNames: ["path", "country"],
});
export function recordRequestCountry(
country: string,
req: MonkeyTypes.Request | TsRestRequestWithCtx
): void {
export function recordRequestCountry(country: string, req: Request): void {
const reqPath = req.baseUrl + req.route.path;
let normalizedPath = "/";

View file

@ -1,54 +1,8 @@
import _ from "lodash";
import { replaceHomoglyphs } from "../constants/homoglyphs";
import { profanities } from "../constants/profanities";
import { intersect, sanitizeString } from "./misc";
import { intersect } from "./misc";
import { default as FunboxList } from "../constants/funbox-list";
import { CompletedEvent } from "@monkeytype/contracts/schemas/results";
export function inRange(value: number, min: number, max: number): boolean {
return value >= min && value <= max;
}
const VALID_NAME_PATTERN = /^[\da-zA-Z_-]+$/;
export function isUsernameValid(name: string): boolean {
if (_.isNil(name) || !inRange(name.length, 1, 16)) {
return false;
}
return VALID_NAME_PATTERN.test(name);
}
export function containsProfanity(
text: string,
mode: "word" | "substring"
): boolean {
const normalizedText = text
.toLowerCase()
.split(/[.,"/#!?$%^&*;:{}=\-_`~()\s\n]+/g)
.map((str) => {
return replaceHomoglyphs(sanitizeString(str) ?? "");
});
const hasProfanity = profanities.some((profanity) => {
return normalizedText.some((word) => {
return mode === "word"
? word.startsWith(profanity)
: word.includes(profanity);
});
});
return hasProfanity;
}
export function isTagPresetNameValid(name: string): boolean {
if (_.isNil(name) || !inRange(name.length, 1, 16)) {
return false;
}
return VALID_NAME_PATTERN.test(name);
}
export function isTestTooShort(result: CompletedEvent): boolean {
const { mode, mode2, customText, testDuration, bailedOut } = result;

View file

@ -2,18 +2,15 @@ import _ from "lodash";
import IORedis from "ioredis";
import { Worker, Job, type ConnectionOptions } from "bullmq";
import Logger from "../utils/logger";
import EmailQueue, {
type EmailTaskContexts,
type EmailType,
} from "../queues/email-queue";
import EmailQueue, { EmailTask, type EmailType } from "../queues/email-queue";
import { sendEmail } from "../init/email-client";
import { recordTimeToCompleteJob } from "../utils/prometheus";
import { addLog } from "../dal/logs";
async function jobHandler(job: Job): Promise<void> {
const type: EmailType = job.data.type;
const email: string = job.data.email;
const ctx: EmailTaskContexts[typeof type] = job.data.ctx;
async function jobHandler(job: Job<EmailTask<EmailType>>): Promise<void> {
const type = job.data.type;
const email = job.data.email;
const ctx = job.data.ctx;
Logger.info(`Starting job: ${type}`);

View file

@ -15,7 +15,7 @@ import LaterQueue, {
} from "../queues/later-queue";
import { recordTimeToCompleteJob } from "../utils/prometheus";
import { WeeklyXpLeaderboard } from "../services/weekly-xp-leaderboard";
import { MonkeyMail } from "@monkeytype/shared-types";
import { MonkeyMail } from "@monkeytype/contracts/schemas/users";
async function handleDailyLeaderboardResults(
ctx: LaterTaskContexts["daily-leaderboard-results"]
@ -180,8 +180,8 @@ async function handleWeeklyXpLeaderboardResults(
await addToInboxBulk(mailEntries, inboxConfig);
}
async function jobHandler(job: Job): Promise<void> {
const { taskName, ctx }: LaterTask<LaterTaskType> = job.data;
async function jobHandler(job: Job<LaterTask<LaterTaskType>>): Promise<void> {
const { taskName, ctx } = job.data;
Logger.info(`Starting job: ${taskName}`);

View file

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

View file

@ -1,6 +1,9 @@
import { migrateConfig } from "../../src/ts/utils/config";
import DefaultConfig from "../../src/ts/constants/default-config";
import { PartialConfig } from "@monkeytype/contracts/schemas/configs";
import {
PartialConfig,
ShowAverageSchema,
} from "@monkeytype/contracts/schemas/configs";
describe("config.ts", () => {
describe("migrateConfig", () => {

View file

@ -1,5 +1,6 @@
import { Formatting } from "../../src/ts/utils/format";
import DefaultConfig from "../../src/ts/constants/default-config";
import { Config } from "@monkeytype/contracts/schemas/configs";
describe("format.ts", () => {
describe("typingsSpeed", () => {
@ -272,7 +273,7 @@ describe("format.ts", () => {
});
});
function getInstance(config?: Partial<SharedTypes.Config>): Formatting {
const target: SharedTypes.Config = { ...DefaultConfig, ...config };
function getInstance(config?: Partial<Config>): Formatting {
const target: Config = { ...DefaultConfig, ...config };
return new Formatting(target);
}

View file

@ -1,4 +1,4 @@
import { isObject } from "../../src/ts/utils/misc";
import { deepClone, isObject } from "../../src/ts/utils/misc";
import {
getLanguageDisplayString,
removeLanguageSize,
@ -118,4 +118,55 @@ describe("misc.ts", () => {
});
});
});
describe("deepClone", () => {
it("should correctly clone objects", () => {
const tests = [
{
input: {},
expected: {},
},
{
input: { a: 1 },
expected: { a: 1 },
},
{
input: { a: { b: 2 } },
expected: { a: { b: 2 } },
},
{
input: { a: { b: 2 }, c: [1, 2, 3] },
expected: { a: { b: 2 }, c: [1, 2, 3] },
},
{
input: [],
expected: [],
},
{
input: [1, 2, 3],
expected: [1, 2, 3],
},
{
input: "string",
expected: "string",
},
{
input: 1,
expected: 1,
},
{
input: null,
expected: null,
},
{
input: undefined,
expected: undefined,
},
];
tests.forEach((test) => {
const result = deepClone(test.input);
expect(result).toStrictEqual(test.expected);
});
});
});
});

View file

@ -31,7 +31,6 @@
"devDependencies": {
"@fortawesome/fontawesome-free": "5.15.4",
"@monkeytype/eslint-config": "workspace:*",
"@monkeytype/shared-types": "workspace:*",
"@monkeytype/typescript-config": "workspace:*",
"@types/canvas-confetti": "1.4.3",
"@types/chartjs-plugin-trendline": "1.0.1",
@ -71,8 +70,7 @@
"dependencies": {
"@date-fns/utc": "1.2.0",
"@monkeytype/contracts": "workspace:*",
"@ts-rest/core": "3.49.3",
"axios": "1.7.4",
"@ts-rest/core": "3.51.0",
"canvas-confetti": "1.5.1",
"chart.js": "3.7.1",
"chartjs-adapter-date-fns": "3.0.0",

View file

@ -953,14 +953,30 @@
</dialog>
<dialog id="editPresetModal" class="modalWrapper hidden">
<form class="modal">
<div class="title"></div>
<input type="text" title="presets" />
<label class="checkbox">
<div class="title popupTitle"></div>
<div class="group">
<div class="presetNameTitle">name</div>
<input type="text" title="presets" />
</div>
<label class="changePresetToCurrentCheckbox checkbox">
<input type="checkbox" />
Change preset to current settings
</label>
<div class="text"></div>
<button type="submit">add</button>
<div class="inputs">
<div class="presetType group" data-id="presetType">
<div class="title">Preset Type</div>
<div class="presetTypeButtonGroup">
<button value="full" type="button">full</button>
<button value="partial" type="button">partial</button>
</div>
</div>
<div class="partialPresetGroups group">
<div class="title">partial groups</div>
<div class="checkboxList"></div>
</div>
</div>
<div class="text deletePrompt"></div>
<button class="submit" type="submit">add</button>
</form>
</dialog>
<dialog id="shareCustomThemeModal" class="modalWrapper hidden">

View file

@ -1478,6 +1478,62 @@ body.darkMode {
#editPresetModal {
.modal {
max-width: 450px;
gap: 1rem;
.presetNameTitle {
font-size: 0.75rem;
color: var(--sub-color);
text-transform: lowercase;
}
}
.group {
display: grid;
gap: 0.5rem;
align-items: center;
width: 100%;
}
input[type="text"] {
width: 100%;
}
.inputs {
display: grid;
grid-template-columns: 1fr;
gap: 1rem;
font-size: 0.75rem;
.title {
color: var(--sub-color);
text-transform: lowercase;
}
.partialPresetGroups {
.checkboxList {
display: grid;
gap: 1rem;
grid-template-columns: 1fr 1fr;
.title {
color: var(--text-color);
text-transform: lowercase;
}
.checkboxTitlePair {
display: flex;
gap: 0.5rem;
button {
flex-grow: 1;
}
}
}
}
.presetType {
.presetTypeButtonGroup {
display: flex;
width: 100%;
gap: 0.5rem;
button {
flex-grow: 1;
}
}
}
}
}

View file

@ -263,6 +263,7 @@
&.tape .word {
margin: 0.25em 0.6em 0.75em 0;
white-space: nowrap;
}
/* a little hack for right-to-left languages */
@ -529,7 +530,9 @@
}
#wordsInput {
font-size: 1rem;
width: 1ch;
font-size: 1em;
height: 1em;
opacity: 0;
padding: 0;
margin: 0 auto;
@ -634,9 +637,32 @@
color: var(--sub-color);
// grid-column: 1/3;
margin-bottom: 1rem;
--unreached-color: var(--sub-color);
--speed-0-color: var(--colorful-error-color);
--speed-1-color: color-mix(
in srgb,
var(--colorful-error-color),
var(--text-color)
);
--speed-2-color: var(--text-color);
--speed-3-color: color-mix(in srgb, var(--main-color), var(--text-color));
--speed-4-color: var(--main-color);
&.withSubColor {
--unreached-color: var(--sub-alt-color);
--speed-2-color: var(--sub-color);
--speed-3-color: color-mix(
in srgb,
var(--sub-color),
var(--text-color)
);
}
.textButton {
padding: 0 0.25em;
}
#copyWordsListButton,
#playpauseReplayButton {
margin-left: 0.5em;
@ -649,6 +675,7 @@
font-size: 0.75rem;
color: var(--sub-color);
width: min-content;
.boxes {
// display: flex;
display: grid;
@ -663,11 +690,22 @@
display: grid;
place-content: center center;
}
.box:nth-child(1) {
.box0 {
border-radius: var(--roundness) 0 0 var(--roundness);
background-color: var(--speed-0-color);
}
.box:nth-child(5) {
.box1 {
background-color: var(--speed-1-color);
}
.box2 {
background-color: var(--speed-2-color);
}
.box3 {
background-color: var(--speed-3-color);
}
.box4 {
border-radius: 0 var(--roundness) var(--roundness) 0;
background-color: var(--speed-4-color);
}
}
}
@ -682,9 +720,27 @@
flex-wrap: wrap;
width: 100%;
align-content: flex-start;
.unreached {
color: var(--unreached-color);
}
.word {
position: relative;
margin: 0.18rem 0.6rem 0.15rem 0;
&[speed="0"] {
color: var(--speed-0-color);
}
&[speed="1"] {
color: var(--speed-1-color);
}
&[speed="2"] {
color: var(--speed-2-color);
}
&[speed="3"] {
color: var(--speed-3-color);
}
&[speed="4"] {
color: var(--speed-4-color);
}
& letter.corrected {
color: var(--text-color);
border-bottom: 2px dotted var(--main-color);

View file

@ -1,138 +0,0 @@
import { getAuthenticatedUser, isAuthenticated } from "../../firebase";
import { getIdToken } from "firebase/auth";
import axios, { AxiosRequestConfig, AxiosResponse, isAxiosError } from "axios";
import { envConfig } from "../../constants/env-config";
import { createErrorMessage } from "../../utils/misc";
type AxiosClientMethod = (
endpoint: string,
config: AxiosRequestConfig
) => Promise<AxiosResponse>;
type AxiosClientDataMethod = (
endpoint: string,
data: unknown,
config: AxiosRequestConfig
) => Promise<AxiosResponse>;
async function adaptRequestOptions<TQuery, TPayload>(
options: Ape.RequestOptionsWithPayload<TQuery, TPayload>
): Promise<AxiosRequestConfig> {
const idToken = isAuthenticated()
? await getIdToken(getAuthenticatedUser())
: "";
return {
params: options.searchQuery,
data: options.payload,
headers: {
...options.headers,
Accept: "application/json",
"Content-Type": "application/json",
...(idToken && { Authorization: `Bearer ${idToken}` }),
"X-Client-Version": envConfig.clientVersion,
},
};
}
function apeifyClientMethod(
clientMethod: AxiosClientMethod | AxiosClientDataMethod,
methodType: Ape.HttpMethodTypes
): Ape.HttpClientMethod | Ape.HttpClientMethodWithPayload {
return async function <TQuery, TPayload, TData>(
endpoint: string,
options: Ape.RequestOptionsWithPayload<TQuery, TPayload> = {}
): Ape.EndpointResponse<TData> {
let errorMessage = "";
let requestOptions: AxiosRequestConfig;
try {
requestOptions = await adaptRequestOptions(options);
} catch (error) {
console.error("Failed to adapt request options");
console.error(error);
if ((error as Error).message.includes("auth/network-request-failed")) {
return {
status: 400,
message:
"Network error while trying to authenticate. Please try again.",
data: null,
};
}
const message = createErrorMessage(
error,
"Failed to adapt request options"
);
return {
status: 400,
message: message,
data: null,
};
}
try {
let response;
if (methodType === "get" || methodType === "delete") {
response = await (clientMethod as AxiosClientMethod)(
endpoint,
requestOptions
);
} else {
response = await (clientMethod as AxiosClientDataMethod)(
endpoint,
requestOptions.data,
requestOptions
);
}
const { message, data } = response.data;
return {
status: response.status,
message,
data,
};
} catch (error) {
console.error(error);
const typedError = error as Error;
errorMessage = typedError.message;
if (isAxiosError(typedError)) {
const data = typedError.response?.data as { data: TData };
return {
status: typedError.response?.status ?? 500,
message: typedError.message,
...data,
};
}
}
return {
status: 500,
message: errorMessage,
data: null,
};
};
}
export function buildHttpClient(
baseURL: string,
timeout: number
): Ape.HttpClient {
const axiosClient = axios.create({
baseURL,
timeout,
});
return {
get: apeifyClientMethod(axiosClient.get, "get"),
post: apeifyClientMethod(axiosClient.post, "post"),
put: apeifyClientMethod(axiosClient.put, "put"),
patch: apeifyClientMethod(axiosClient.patch, "patch"),
delete: apeifyClientMethod(axiosClient.delete, "delete"),
};
}

View file

@ -1,5 +1,4 @@
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";
@ -28,7 +27,7 @@ function buildApi(timeout: number): (args: ApiFetcherArgs) => Promise<{
}
const fetchOptions: RequestInit = {
method: request.method as Method,
method: request.method,
headers,
body: request.body,
};
@ -42,7 +41,7 @@ function buildApi(timeout: number): (args: ApiFetcherArgs) => Promise<{
: AbortSignal.timeout(timeout),
});
const body = await response.json();
const body = (await response.json()) as object;
if (response.status >= 400) {
console.error(`${request.method} ${request.path} failed`, {
status: response.status,

Some files were not shown because too many files have changed in this diff Show more