This commit is contained in:
31
backend/src/anticheat/index.ts
Normal file
31
backend/src/anticheat/index.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
const hasAnticheatImplemented = process.env["BYPASS_ANTICHEAT"] === "true";
|
||||
|
||||
import { CompletedEvent, KeyStats } from "@monkeytype/schemas/results";
|
||||
import Logger from "../utils/logger";
|
||||
|
||||
export function implemented(): boolean {
|
||||
if (hasAnticheatImplemented) {
|
||||
Logger.warning("BYPASS_ANTICHEAT is enabled! Running without anti-cheat.");
|
||||
}
|
||||
return hasAnticheatImplemented;
|
||||
}
|
||||
|
||||
export function validateResult(
|
||||
_result: object,
|
||||
_version: string,
|
||||
_uaStringifiedObject: string,
|
||||
_lbOptOut: boolean,
|
||||
): boolean {
|
||||
Logger.warning("No anticheat module found, result will not be validated.");
|
||||
return true;
|
||||
}
|
||||
|
||||
export function validateKeys(
|
||||
_result: CompletedEvent,
|
||||
_keySpacingStats: KeyStats,
|
||||
_keyDurationStats: KeyStats,
|
||||
_uid: string,
|
||||
): boolean {
|
||||
Logger.warning("No anticheat module found, key data will not be validated.");
|
||||
return true;
|
||||
}
|
||||
148
backend/src/api/controllers/admin.ts
Normal file
148
backend/src/api/controllers/admin.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
import { MonkeyResponse } from "../../utils/monkey-response";
|
||||
import { buildMonkeyMail } from "../../utils/monkey-mail";
|
||||
import * as UserDAL from "../../dal/user";
|
||||
import * as ReportDAL from "../../dal/report";
|
||||
import GeorgeQueue from "../../queues/george-queue";
|
||||
import { sendForgotPasswordEmail as authSendForgotPasswordEmail } from "../../utils/auth";
|
||||
import {
|
||||
AcceptReportsRequest,
|
||||
ClearStreakHourOffsetRequest,
|
||||
RejectReportsRequest,
|
||||
SendForgotPasswordEmailRequest,
|
||||
ToggleBanRequest,
|
||||
ToggleBanResponse,
|
||||
} from "@monkeytype/contracts/admin";
|
||||
import MonkeyError, { getErrorMessage } from "../../utils/error";
|
||||
import { Configuration } from "@monkeytype/schemas/configuration";
|
||||
import { addImportantLog } from "../../dal/logs";
|
||||
import { MonkeyRequest } from "../types";
|
||||
|
||||
export async function test(_req: MonkeyRequest): Promise<MonkeyResponse> {
|
||||
return new MonkeyResponse("OK", null);
|
||||
}
|
||||
|
||||
export async function toggleBan(
|
||||
req: MonkeyRequest<undefined, ToggleBanRequest>,
|
||||
): Promise<ToggleBanResponse> {
|
||||
const { uid } = req.body;
|
||||
|
||||
const user = await UserDAL.getPartialUser(uid, "toggle ban", [
|
||||
"banned",
|
||||
"discordId",
|
||||
]);
|
||||
const discordId = user.discordId;
|
||||
const discordIdIsValid = discordId !== undefined && discordId !== "";
|
||||
|
||||
await UserDAL.setBanned(uid, !user.banned);
|
||||
if (discordIdIsValid) await GeorgeQueue.userBanned(discordId, !user.banned);
|
||||
|
||||
void addImportantLog("user_ban_toggled", { banned: !user.banned }, uid);
|
||||
|
||||
return new MonkeyResponse(`Ban toggled`, {
|
||||
banned: !user.banned,
|
||||
});
|
||||
}
|
||||
|
||||
export async function clearStreakHourOffset(
|
||||
req: MonkeyRequest<undefined, ClearStreakHourOffsetRequest>,
|
||||
): Promise<MonkeyResponse> {
|
||||
const { uid } = req.body;
|
||||
|
||||
await UserDAL.clearStreakHourOffset(uid);
|
||||
void addImportantLog("admin_streak_hour_offset_cleared_by", {}, uid);
|
||||
|
||||
return new MonkeyResponse("Streak hour offset cleared", null);
|
||||
}
|
||||
|
||||
export async function acceptReports(
|
||||
req: MonkeyRequest<undefined, AcceptReportsRequest>,
|
||||
): Promise<MonkeyResponse> {
|
||||
await handleReports(
|
||||
req.body.reports.map((it) => ({ ...it })),
|
||||
true,
|
||||
req.ctx.configuration.users.inbox,
|
||||
);
|
||||
return new MonkeyResponse("Reports removed and users notified.", null);
|
||||
}
|
||||
|
||||
export async function rejectReports(
|
||||
req: MonkeyRequest<undefined, RejectReportsRequest>,
|
||||
): Promise<MonkeyResponse> {
|
||||
await handleReports(
|
||||
req.body.reports.map((it) => ({ ...it })),
|
||||
false,
|
||||
req.ctx.configuration.users.inbox,
|
||||
);
|
||||
return new MonkeyResponse("Reports removed and users notified.", null);
|
||||
}
|
||||
|
||||
export async function handleReports(
|
||||
reports: { reportId: string; reason?: string }[],
|
||||
accept: boolean,
|
||||
inboxConfig: Configuration["users"]["inbox"],
|
||||
): Promise<void> {
|
||||
const reportIds = reports.map(({ reportId }) => reportId);
|
||||
|
||||
const reportsFromDb = await ReportDAL.getReports(reportIds);
|
||||
const reportById = new Map(reportsFromDb.map((it) => [it.id, it]));
|
||||
|
||||
const existingReportIds = new Set(reportsFromDb.map((report) => report.id));
|
||||
const missingReportIds = reportIds.filter(
|
||||
(reportId) => !existingReportIds.has(reportId),
|
||||
);
|
||||
|
||||
if (missingReportIds.length > 0) {
|
||||
throw new MonkeyError(
|
||||
404,
|
||||
`Reports not found for some IDs ${missingReportIds.join(",")}`,
|
||||
);
|
||||
}
|
||||
|
||||
await ReportDAL.deleteReports(reportIds);
|
||||
|
||||
for (const { reportId, reason } of reports) {
|
||||
try {
|
||||
const report = reportById.get(reportId);
|
||||
if (!report) {
|
||||
throw new MonkeyError(404, `Report not found for ID: ${reportId}`);
|
||||
}
|
||||
|
||||
let mailBody = "";
|
||||
if (accept) {
|
||||
mailBody = `Your report regarding ${report.type} ${
|
||||
report.contentId
|
||||
} (${report.reason.toLowerCase()}) has been approved. Thank you.`;
|
||||
} else {
|
||||
mailBody = `Sorry, but your report regarding ${report.type} ${
|
||||
report.contentId
|
||||
} (${report.reason.toLowerCase()}) has been denied. ${
|
||||
reason !== undefined ? `\nReason: ${reason}` : ""
|
||||
}`;
|
||||
}
|
||||
|
||||
const mailSubject = accept ? "Report approved" : "Report denied";
|
||||
const mail = buildMonkeyMail({
|
||||
subject: mailSubject,
|
||||
body: mailBody,
|
||||
});
|
||||
await UserDAL.addToInbox(report.uid, [mail], inboxConfig);
|
||||
} catch (e) {
|
||||
if (e instanceof MonkeyError) {
|
||||
throw new MonkeyError(e.status, e.message);
|
||||
} else {
|
||||
throw new MonkeyError(
|
||||
500,
|
||||
"Error handling reports: " + getErrorMessage(e),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function sendForgotPasswordEmail(
|
||||
req: MonkeyRequest<undefined, SendForgotPasswordEmailRequest>,
|
||||
): Promise<MonkeyResponse> {
|
||||
const { email } = req.body;
|
||||
await authSendForgotPasswordEmail(email);
|
||||
return new MonkeyResponse("Password reset request email sent.", null);
|
||||
}
|
||||
95
backend/src/api/controllers/ape-key.ts
Normal file
95
backend/src/api/controllers/ape-key.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import { randomBytes } from "crypto";
|
||||
import { hash } from "bcrypt";
|
||||
import * as ApeKeysDAL from "../../dal/ape-keys";
|
||||
import MonkeyError from "../../utils/error";
|
||||
import { MonkeyResponse } from "../../utils/monkey-response";
|
||||
import { base64UrlEncode, omit } from "../../utils/misc";
|
||||
import { ObjectId } from "mongodb";
|
||||
|
||||
import {
|
||||
AddApeKeyRequest,
|
||||
AddApeKeyResponse,
|
||||
ApeKeyParams,
|
||||
EditApeKeyRequest,
|
||||
GetApeKeyResponse,
|
||||
} from "@monkeytype/contracts/ape-keys";
|
||||
import { ApeKey } from "@monkeytype/schemas/ape-keys";
|
||||
import { MonkeyRequest } from "../types";
|
||||
|
||||
function cleanApeKey(apeKey: ApeKeysDAL.DBApeKey): ApeKey {
|
||||
return omit(apeKey, ["hash", "_id", "uid", "useCount"]);
|
||||
}
|
||||
|
||||
export async function getApeKeys(
|
||||
req: MonkeyRequest,
|
||||
): Promise<GetApeKeyResponse> {
|
||||
const { uid } = req.ctx.decodedToken;
|
||||
|
||||
const apeKeys = await ApeKeysDAL.getApeKeys(uid);
|
||||
const cleanedKeys: Record<string, ApeKey> = Object.fromEntries(
|
||||
apeKeys.map((item) => [item._id.toHexString(), cleanApeKey(item)]),
|
||||
);
|
||||
|
||||
return new MonkeyResponse("ApeKeys retrieved", cleanedKeys);
|
||||
}
|
||||
|
||||
export async function generateApeKey(
|
||||
req: MonkeyRequest<undefined, AddApeKeyRequest>,
|
||||
): Promise<AddApeKeyResponse> {
|
||||
const { name, enabled } = req.body;
|
||||
const { uid } = req.ctx.decodedToken;
|
||||
const { maxKeysPerUser, apeKeyBytes, apeKeySaltRounds } =
|
||||
req.ctx.configuration.apeKeys;
|
||||
|
||||
const currentNumberOfApeKeys = await ApeKeysDAL.countApeKeysForUser(uid);
|
||||
|
||||
if (currentNumberOfApeKeys >= maxKeysPerUser) {
|
||||
throw new MonkeyError(409, "Maximum number of ApeKeys have been generated");
|
||||
}
|
||||
|
||||
const apiKey = randomBytes(apeKeyBytes).toString("base64url");
|
||||
const saltyHash = await hash(apiKey, apeKeySaltRounds);
|
||||
|
||||
const apeKey: ApeKeysDAL.DBApeKey = {
|
||||
_id: new ObjectId(),
|
||||
name,
|
||||
enabled,
|
||||
uid,
|
||||
hash: saltyHash,
|
||||
createdOn: Date.now(),
|
||||
modifiedOn: Date.now(),
|
||||
lastUsedOn: -1,
|
||||
useCount: 0,
|
||||
};
|
||||
|
||||
const apeKeyId = await ApeKeysDAL.addApeKey(apeKey);
|
||||
|
||||
return new MonkeyResponse("ApeKey generated", {
|
||||
apeKey: base64UrlEncode(`${apeKeyId}.${apiKey}`),
|
||||
apeKeyId,
|
||||
apeKeyDetails: cleanApeKey(apeKey),
|
||||
});
|
||||
}
|
||||
|
||||
export async function editApeKey(
|
||||
req: MonkeyRequest<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 MonkeyResponse("ApeKey updated", null);
|
||||
}
|
||||
|
||||
export async function deleteApeKey(
|
||||
req: MonkeyRequest<undefined, undefined, ApeKeyParams>,
|
||||
): Promise<MonkeyResponse> {
|
||||
const { apeKeyId } = req.params;
|
||||
const { uid } = req.ctx.decodedToken;
|
||||
|
||||
await ApeKeysDAL.deleteApeKey(uid, apeKeyId);
|
||||
|
||||
return new MonkeyResponse("ApeKey deleted", null);
|
||||
}
|
||||
34
backend/src/api/controllers/config.ts
Normal file
34
backend/src/api/controllers/config.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { PartialConfig } from "@monkeytype/schemas/configs";
|
||||
import * as ConfigDAL from "../../dal/config";
|
||||
import { MonkeyResponse } from "../../utils/monkey-response";
|
||||
import { GetConfigResponse } from "@monkeytype/contracts/configs";
|
||||
import { MonkeyRequest } from "../types";
|
||||
|
||||
export async function getConfig(
|
||||
req: MonkeyRequest,
|
||||
): Promise<GetConfigResponse> {
|
||||
const { uid } = req.ctx.decodedToken;
|
||||
const data = (await ConfigDAL.getConfig(uid))?.config ?? null;
|
||||
|
||||
return new MonkeyResponse("Configuration retrieved", data);
|
||||
}
|
||||
|
||||
export async function saveConfig(
|
||||
req: MonkeyRequest<undefined, PartialConfig>,
|
||||
): Promise<MonkeyResponse> {
|
||||
const config = req.body;
|
||||
const { uid } = req.ctx.decodedToken;
|
||||
|
||||
await ConfigDAL.saveConfig(uid, config);
|
||||
|
||||
return new MonkeyResponse("Config updated", null);
|
||||
}
|
||||
|
||||
export async function deleteConfig(
|
||||
req: MonkeyRequest,
|
||||
): Promise<MonkeyResponse> {
|
||||
const { uid } = req.ctx.decodedToken;
|
||||
|
||||
await ConfigDAL.deleteConfig(uid);
|
||||
return new MonkeyResponse("Config deleted", null);
|
||||
}
|
||||
39
backend/src/api/controllers/configuration.ts
Normal file
39
backend/src/api/controllers/configuration.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import * as Configuration from "../../init/configuration";
|
||||
import { MonkeyResponse } from "../../utils/monkey-response";
|
||||
import { CONFIGURATION_FORM_SCHEMA } from "../../constants/base-configuration";
|
||||
import {
|
||||
ConfigurationSchemaResponse,
|
||||
GetConfigurationResponse,
|
||||
PatchConfigurationRequest,
|
||||
} from "@monkeytype/contracts/configuration";
|
||||
import MonkeyError from "../../utils/error";
|
||||
import { MonkeyRequest } from "../types";
|
||||
|
||||
export async function getConfiguration(
|
||||
_req: MonkeyRequest,
|
||||
): Promise<GetConfigurationResponse> {
|
||||
const currentConfiguration = await Configuration.getCachedConfiguration(true);
|
||||
return new MonkeyResponse("Configuration retrieved", currentConfiguration);
|
||||
}
|
||||
|
||||
export async function getSchema(
|
||||
_req: MonkeyRequest,
|
||||
): Promise<ConfigurationSchemaResponse> {
|
||||
return new MonkeyResponse(
|
||||
"Configuration schema retrieved",
|
||||
CONFIGURATION_FORM_SCHEMA,
|
||||
);
|
||||
}
|
||||
|
||||
export async function updateConfiguration(
|
||||
req: MonkeyRequest<undefined, PatchConfigurationRequest>,
|
||||
): Promise<MonkeyResponse> {
|
||||
const { configuration } = req.body;
|
||||
const success = await Configuration.patchConfiguration(configuration);
|
||||
|
||||
if (!success) {
|
||||
throw new MonkeyError(500, "Configuration update failed");
|
||||
}
|
||||
|
||||
return new MonkeyResponse("Configuration updated", null);
|
||||
}
|
||||
85
backend/src/api/controllers/connections.ts
Normal file
85
backend/src/api/controllers/connections.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import {
|
||||
CreateConnectionRequest,
|
||||
CreateConnectionResponse,
|
||||
GetConnectionsQuery,
|
||||
GetConnectionsResponse,
|
||||
IdPathParams,
|
||||
UpdateConnectionRequest,
|
||||
} from "@monkeytype/contracts/connections";
|
||||
import { MonkeyRequest } from "../types";
|
||||
import { MonkeyResponse } from "../../utils/monkey-response";
|
||||
import * as ConnectionsDal from "../../dal/connections";
|
||||
import * as UserDal from "../../dal/user";
|
||||
import { replaceObjectId, omit } from "../../utils/misc";
|
||||
import MonkeyError from "../../utils/error";
|
||||
|
||||
import { Connection } from "@monkeytype/schemas/connections";
|
||||
|
||||
function convert(db: ConnectionsDal.DBConnection): Connection {
|
||||
return replaceObjectId(omit(db, ["key"]));
|
||||
}
|
||||
export async function getConnections(
|
||||
req: MonkeyRequest<GetConnectionsQuery>,
|
||||
): Promise<GetConnectionsResponse> {
|
||||
const { uid } = req.ctx.decodedToken;
|
||||
const { status, type } = req.query;
|
||||
|
||||
const results = await ConnectionsDal.getConnections({
|
||||
initiatorUid:
|
||||
type === undefined || type.includes("outgoing") ? uid : undefined,
|
||||
receiverUid:
|
||||
type === undefined || type?.includes("incoming") ? uid : undefined,
|
||||
status: status,
|
||||
});
|
||||
|
||||
return new MonkeyResponse("Connections retrieved", results.map(convert));
|
||||
}
|
||||
|
||||
export async function createConnection(
|
||||
req: MonkeyRequest<undefined, CreateConnectionRequest>,
|
||||
): Promise<CreateConnectionResponse> {
|
||||
const { uid } = req.ctx.decodedToken;
|
||||
const { receiverName } = req.body;
|
||||
const { maxPerUser } = req.ctx.configuration.connections;
|
||||
|
||||
const receiver = await UserDal.getUserByName(
|
||||
receiverName,
|
||||
"create connection",
|
||||
);
|
||||
|
||||
if (uid === receiver.uid) {
|
||||
throw new MonkeyError(400, "You cannot be your own friend, sorry.");
|
||||
}
|
||||
|
||||
const initiator = await UserDal.getPartialUser(uid, "create connection", [
|
||||
"uid",
|
||||
"name",
|
||||
]);
|
||||
|
||||
const result = await ConnectionsDal.create(initiator, receiver, maxPerUser);
|
||||
|
||||
return new MonkeyResponse("Connection created", convert(result));
|
||||
}
|
||||
|
||||
export async function deleteConnection(
|
||||
req: MonkeyRequest<undefined, undefined, IdPathParams>,
|
||||
): Promise<MonkeyResponse> {
|
||||
const { uid } = req.ctx.decodedToken;
|
||||
const { id } = req.params;
|
||||
|
||||
await ConnectionsDal.deleteById(uid, id);
|
||||
|
||||
return new MonkeyResponse("Connection deleted", null);
|
||||
}
|
||||
|
||||
export async function updateConnection(
|
||||
req: MonkeyRequest<undefined, UpdateConnectionRequest, IdPathParams>,
|
||||
): Promise<MonkeyResponse> {
|
||||
const { uid } = req.ctx.decodedToken;
|
||||
const { id } = req.params;
|
||||
const { status } = req.body;
|
||||
|
||||
await ConnectionsDal.updateStatus(uid, id, status);
|
||||
|
||||
return new MonkeyResponse("Connection updated", null);
|
||||
}
|
||||
433
backend/src/api/controllers/dev.ts
Normal file
433
backend/src/api/controllers/dev.ts
Normal file
@@ -0,0 +1,433 @@
|
||||
import { MonkeyResponse } from "../../utils/monkey-response";
|
||||
import * as UserDal from "../../dal/user";
|
||||
import FirebaseAdmin from "../../init/firebase-admin";
|
||||
import Logger from "../../utils/logger";
|
||||
import * as DateUtils from "date-fns";
|
||||
import { UTCDate } from "@date-fns/utc";
|
||||
import * as ResultDal from "../../dal/result";
|
||||
import { ObjectId } from "mongodb";
|
||||
import * as LeaderboardDal from "../../dal/leaderboards";
|
||||
import MonkeyError from "../../utils/error";
|
||||
|
||||
import { Mode, PersonalBest, PersonalBests } from "@monkeytype/schemas/shared";
|
||||
import {
|
||||
AddDebugInboxItemRequest,
|
||||
GenerateDataRequest,
|
||||
GenerateDataResponse,
|
||||
} from "@monkeytype/contracts/dev";
|
||||
import { buildMonkeyMail } from "../../utils/monkey-mail";
|
||||
import { roundTo2 } from "@monkeytype/util/numbers";
|
||||
import { MonkeyRequest } from "../types";
|
||||
import { DBResult } from "../../utils/result";
|
||||
import { LbPersonalBests } from "../../utils/pb";
|
||||
import { Language } from "@monkeytype/schemas/languages";
|
||||
|
||||
const CREATE_RESULT_DEFAULT_OPTIONS = {
|
||||
firstTestTimestamp: DateUtils.startOfDay(new UTCDate(Date.now())).valueOf(),
|
||||
lastTestTimestamp: DateUtils.endOfDay(new UTCDate(Date.now())).valueOf(),
|
||||
minTestsPerDay: 0,
|
||||
maxTestsPerDay: 50,
|
||||
};
|
||||
|
||||
export async function createTestData(
|
||||
req: MonkeyRequest<undefined, GenerateDataRequest>,
|
||||
): Promise<GenerateDataResponse> {
|
||||
const { username, createUser } = req.body;
|
||||
const user = await getOrCreateUser(username, "password", createUser);
|
||||
|
||||
const { uid, email } = user;
|
||||
|
||||
await createTestResults(user, req.body);
|
||||
await updateUser(uid);
|
||||
await updateLeaderboard();
|
||||
|
||||
return new MonkeyResponse("test data created", { uid, email });
|
||||
}
|
||||
|
||||
export async function addDebugInboxItem(
|
||||
req: MonkeyRequest<undefined, AddDebugInboxItemRequest>,
|
||||
): Promise<MonkeyResponse> {
|
||||
const { uid } = req.ctx.decodedToken;
|
||||
const { rewardType } = req.body;
|
||||
const inboxConfig = req.ctx.configuration.users.inbox;
|
||||
|
||||
const rewards =
|
||||
rewardType === "xp"
|
||||
? [{ type: "xp" as const, item: 1000 }]
|
||||
: rewardType === "badge"
|
||||
? [{ type: "badge" as const, item: { id: 1 } }]
|
||||
: [];
|
||||
|
||||
const body =
|
||||
rewardType === "xp"
|
||||
? "Here is your 1000 XP reward for debugging."
|
||||
: rewardType === "badge"
|
||||
? "Here is your Developer badge reward."
|
||||
: "A debug inbox item with no reward.";
|
||||
|
||||
const mail = buildMonkeyMail({
|
||||
subject: "Debug Inbox Item",
|
||||
body,
|
||||
rewards,
|
||||
});
|
||||
|
||||
await UserDal.addToInbox(uid, [mail], inboxConfig);
|
||||
return new MonkeyResponse("Debug inbox item added", null);
|
||||
}
|
||||
|
||||
async function getOrCreateUser(
|
||||
username: string,
|
||||
password: string,
|
||||
createUser = false,
|
||||
): Promise<UserDal.DBUser> {
|
||||
const existingUser = await UserDal.findByName(username);
|
||||
|
||||
if (existingUser !== undefined && existingUser !== null) {
|
||||
return existingUser;
|
||||
} else if (!createUser) {
|
||||
throw new MonkeyError(404, `User ${username} does not exist.`);
|
||||
}
|
||||
|
||||
const email = username + "@example.com";
|
||||
Logger.success("create user " + username);
|
||||
const { uid } = await FirebaseAdmin().auth().createUser({
|
||||
displayName: username,
|
||||
password: password,
|
||||
email,
|
||||
emailVerified: true,
|
||||
});
|
||||
|
||||
await UserDal.addUser(username, email, uid);
|
||||
return UserDal.getUser(uid, "getOrCreateUser");
|
||||
}
|
||||
|
||||
async function createTestResults(
|
||||
user: UserDal.DBUser,
|
||||
configOptions: GenerateDataRequest,
|
||||
): Promise<void> {
|
||||
const config = {
|
||||
...CREATE_RESULT_DEFAULT_OPTIONS,
|
||||
...configOptions,
|
||||
};
|
||||
const start = toDate(config.firstTestTimestamp);
|
||||
const end = toDate(config.lastTestTimestamp);
|
||||
|
||||
const days = DateUtils.eachDayOfInterval({
|
||||
start,
|
||||
end,
|
||||
}).map((day) => ({
|
||||
timestamp: DateUtils.startOfDay(day),
|
||||
amount: Math.round(random(config.minTestsPerDay, config.maxTestsPerDay)),
|
||||
}));
|
||||
|
||||
for (const day of days) {
|
||||
Logger.success(
|
||||
`User ${user.name} insert ${day.amount} results on ${new Date(
|
||||
day.timestamp,
|
||||
)}`,
|
||||
);
|
||||
const results = createArray(day.amount, () =>
|
||||
createResult(user, day.timestamp),
|
||||
);
|
||||
if (results.length > 0) {
|
||||
await ResultDal.getResultCollection().insertMany(results);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function toDate(value: number): Date {
|
||||
return new UTCDate(value);
|
||||
}
|
||||
|
||||
function random(min: number, max: number): number {
|
||||
return roundTo2(Math.random() * (max - min) + min);
|
||||
}
|
||||
|
||||
function createResult(
|
||||
user: UserDal.DBUser,
|
||||
timestamp: Date, //evil, we modify this value
|
||||
): DBResult {
|
||||
const mode: Mode = randomValue(["time", "words"]);
|
||||
const mode2: number =
|
||||
mode === "time"
|
||||
? randomValue([15, 30, 60, 120])
|
||||
: randomValue([10, 25, 50, 100]);
|
||||
const testDuration = mode2;
|
||||
|
||||
timestamp = DateUtils.addSeconds(timestamp, testDuration);
|
||||
return {
|
||||
_id: new ObjectId(),
|
||||
uid: user.uid,
|
||||
wpm: random(80, 120),
|
||||
rawWpm: random(80, 120),
|
||||
charStats: [131, 0, 0, 0],
|
||||
acc: random(80, 100),
|
||||
language: "english",
|
||||
mode: mode as Mode,
|
||||
mode2: mode2 as unknown as never,
|
||||
timestamp: timestamp.valueOf(),
|
||||
testDuration: testDuration,
|
||||
consistency: random(80, 100),
|
||||
keyConsistency: 33.18,
|
||||
chartData: {
|
||||
wpm: createArray(testDuration, () => random(80, 120)),
|
||||
burst: createArray(testDuration, () => random(80, 120)),
|
||||
err: createArray(testDuration, () => (Math.random() < 0.1 ? 1 : 0)),
|
||||
},
|
||||
keySpacingStats: {
|
||||
average: 113.88,
|
||||
sd: 77.3,
|
||||
},
|
||||
keyDurationStats: {
|
||||
average: 107.13,
|
||||
sd: 39.86,
|
||||
},
|
||||
isPb: Math.random() < 0.1,
|
||||
name: user.name,
|
||||
};
|
||||
}
|
||||
|
||||
async function updateUser(uid: string): Promise<void> {
|
||||
//update timetyping and completedTests
|
||||
const stats = await ResultDal.getResultCollection()
|
||||
.aggregate([
|
||||
{
|
||||
$match: {
|
||||
uid,
|
||||
},
|
||||
},
|
||||
{
|
||||
$group: {
|
||||
_id: {
|
||||
language: "$language",
|
||||
mode: "$mode",
|
||||
mode2: "$mode2",
|
||||
},
|
||||
timeTyping: {
|
||||
$sum: "$testDuration",
|
||||
},
|
||||
completedTests: {
|
||||
$count: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
])
|
||||
.toArray();
|
||||
|
||||
const timeTyping = stats.reduce((a, c) => (a + c["timeTyping"]) as number, 0);
|
||||
const completedTests = stats.reduce(
|
||||
(a, c) => (a + c["completedTests"]) as number,
|
||||
0,
|
||||
);
|
||||
|
||||
//update PBs
|
||||
const lbPersonalBests: LbPersonalBests = {
|
||||
time: {
|
||||
15: {},
|
||||
60: {},
|
||||
},
|
||||
};
|
||||
|
||||
const personalBests: PersonalBests = {
|
||||
time: {},
|
||||
custom: {},
|
||||
words: {},
|
||||
zen: {},
|
||||
quote: {},
|
||||
};
|
||||
const modes = stats.map(
|
||||
(it) =>
|
||||
it["_id"] as {
|
||||
language: Language;
|
||||
mode: "time" | "custom" | "words" | "quote" | "zen";
|
||||
mode2: `${number}` | "custom" | "zen";
|
||||
},
|
||||
);
|
||||
|
||||
for (const mode of modes) {
|
||||
const best = (await ResultDal.getResultCollection().findOne(
|
||||
{
|
||||
uid,
|
||||
language: mode.language,
|
||||
mode: mode.mode,
|
||||
mode2: mode.mode2,
|
||||
},
|
||||
{ sort: { wpm: -1, timestamp: 1 } },
|
||||
)) as DBResult;
|
||||
|
||||
personalBests[mode.mode] ??= {};
|
||||
if (personalBests[mode.mode][mode.mode2] === undefined) {
|
||||
personalBests[mode.mode][mode.mode2] = [];
|
||||
}
|
||||
|
||||
const entry = {
|
||||
acc: best.acc,
|
||||
consistency: best.consistency,
|
||||
difficulty: best.difficulty ?? "normal",
|
||||
lazyMode: best.lazyMode,
|
||||
language: mode.language,
|
||||
punctuation: best.punctuation,
|
||||
raw: best.rawWpm,
|
||||
wpm: best.wpm,
|
||||
numbers: best.numbers,
|
||||
timestamp: best.timestamp,
|
||||
} as PersonalBest;
|
||||
|
||||
(personalBests[mode.mode][mode.mode2] as PersonalBest[]).push(entry);
|
||||
|
||||
if (mode.mode === "time") {
|
||||
if (lbPersonalBests[mode.mode][mode.mode2] === undefined) {
|
||||
lbPersonalBests[mode.mode][mode.mode2] = {};
|
||||
}
|
||||
|
||||
// oxlint-disable-next-line no-unsafe-member-access
|
||||
lbPersonalBests[mode.mode][mode.mode2][mode.language] = entry;
|
||||
}
|
||||
|
||||
//update testActivity
|
||||
await updateTestActicity(uid);
|
||||
}
|
||||
|
||||
//update the user
|
||||
await UserDal.getUsersCollection().updateOne(
|
||||
{ uid },
|
||||
{
|
||||
$set: {
|
||||
timeTyping: timeTyping,
|
||||
completedTests: completedTests,
|
||||
startedTests: Math.round(completedTests * 1.25),
|
||||
personalBests: personalBests,
|
||||
lbPersonalBests: lbPersonalBests,
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async function updateLeaderboard(): Promise<void> {
|
||||
await LeaderboardDal.update("time", "15", "english");
|
||||
await LeaderboardDal.update("time", "60", "english");
|
||||
}
|
||||
|
||||
function randomValue<T>(values: T[]): T {
|
||||
const rnd = Math.round(Math.random() * (values.length - 1));
|
||||
return values[rnd] as T;
|
||||
}
|
||||
|
||||
function createArray<T>(size: number, builder: () => T): T[] {
|
||||
return new Array(size).fill(0).map(() => builder());
|
||||
}
|
||||
|
||||
async function updateTestActicity(uid: string): Promise<void> {
|
||||
await ResultDal.getResultCollection()
|
||||
.aggregate(
|
||||
[
|
||||
{
|
||||
$match: {
|
||||
uid,
|
||||
},
|
||||
},
|
||||
{
|
||||
$project: {
|
||||
_id: 0,
|
||||
timestamp: -1,
|
||||
uid: 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
$addFields: {
|
||||
date: {
|
||||
$toDate: "$timestamp",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
$replaceWith: {
|
||||
uid: "$uid",
|
||||
year: {
|
||||
$year: "$date",
|
||||
},
|
||||
day: {
|
||||
$dayOfYear: "$date",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
$group: {
|
||||
_id: {
|
||||
uid: "$uid",
|
||||
year: "$year",
|
||||
day: "$day",
|
||||
},
|
||||
count: {
|
||||
$sum: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
$group: {
|
||||
_id: {
|
||||
uid: "$_id.uid",
|
||||
year: "$_id.year",
|
||||
},
|
||||
days: {
|
||||
$addToSet: {
|
||||
day: "$_id.day",
|
||||
tests: "$count",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
$replaceWith: {
|
||||
uid: "$_id.uid",
|
||||
days: {
|
||||
$function: {
|
||||
lang: "js",
|
||||
args: ["$days", "$_id.year"],
|
||||
body: `function (days, year) {
|
||||
var max = Math.max(
|
||||
...days.map((it) => it.day)
|
||||
)-1;
|
||||
var arr = new Array(max).fill(null);
|
||||
for (day of days) {
|
||||
arr[day.day-1] = day.tests;
|
||||
}
|
||||
let result = {};
|
||||
result[year] = arr;
|
||||
return result;
|
||||
}`,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
$group: {
|
||||
_id: "$uid",
|
||||
testActivity: {
|
||||
$mergeObjects: "$days",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
$addFields: {
|
||||
uid: "$_id",
|
||||
},
|
||||
},
|
||||
{
|
||||
$project: {
|
||||
_id: 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
$merge: {
|
||||
into: "users",
|
||||
on: "uid",
|
||||
whenMatched: "merge",
|
||||
whenNotMatched: "discard",
|
||||
},
|
||||
},
|
||||
],
|
||||
{ allowDiskUse: true },
|
||||
)
|
||||
.toArray();
|
||||
}
|
||||
294
backend/src/api/controllers/leaderboard.ts
Normal file
294
backend/src/api/controllers/leaderboard.ts
Normal file
@@ -0,0 +1,294 @@
|
||||
import { MonkeyResponse } from "../../utils/monkey-response";
|
||||
import * as LeaderboardsDAL from "../../dal/leaderboards";
|
||||
import * as ConnectionsDal from "../../dal/connections";
|
||||
import MonkeyError from "../../utils/error";
|
||||
import * as DailyLeaderboards from "../../utils/daily-leaderboards";
|
||||
import * as WeeklyXpLeaderboard from "../../services/weekly-xp-leaderboard";
|
||||
import {
|
||||
DailyLeaderboardQuery,
|
||||
GetDailyLeaderboardQuery,
|
||||
GetDailyLeaderboardRankQuery,
|
||||
GetDailyLeaderboardResponse,
|
||||
GetLeaderboardDailyRankResponse,
|
||||
GetLeaderboardQuery,
|
||||
GetLeaderboardRankQuery,
|
||||
GetLeaderboardRankResponse,
|
||||
GetLeaderboardResponse,
|
||||
GetWeeklyXpLeaderboardQuery,
|
||||
GetWeeklyXpLeaderboardRankQuery,
|
||||
GetWeeklyXpLeaderboardRankResponse,
|
||||
GetWeeklyXpLeaderboardResponse,
|
||||
} from "@monkeytype/contracts/leaderboards";
|
||||
import { Configuration } from "@monkeytype/schemas/configuration";
|
||||
import {
|
||||
getCurrentDayTimestamp,
|
||||
getCurrentWeekTimestamp,
|
||||
MILLISECONDS_IN_DAY,
|
||||
} from "@monkeytype/util/date-and-time";
|
||||
import { MonkeyRequest } from "../types";
|
||||
import { omit } from "../../utils/misc";
|
||||
|
||||
export async function getLeaderboard(
|
||||
req: MonkeyRequest<GetLeaderboardQuery>,
|
||||
): Promise<GetLeaderboardResponse> {
|
||||
const { language, mode, mode2, page, pageSize, friendsOnly } = req.query;
|
||||
const { uid } = req.ctx.decodedToken;
|
||||
const connectionsConfig = req.ctx.configuration.connections;
|
||||
|
||||
if (
|
||||
mode !== "time" ||
|
||||
(mode2 !== "15" && mode2 !== "60") ||
|
||||
language !== "english"
|
||||
) {
|
||||
throw new MonkeyError(404, "There is no leaderboard for this mode");
|
||||
}
|
||||
|
||||
const friendsOnlyUid = getFriendsOnlyUid(uid, friendsOnly, connectionsConfig);
|
||||
|
||||
const leaderboard = await LeaderboardsDAL.get(
|
||||
mode,
|
||||
mode2,
|
||||
language,
|
||||
page,
|
||||
pageSize,
|
||||
req.ctx.configuration.users.premium.enabled,
|
||||
friendsOnlyUid,
|
||||
);
|
||||
|
||||
if (leaderboard === false) {
|
||||
throw new MonkeyError(
|
||||
503,
|
||||
"Leaderboard is currently updating. Please try again in a few seconds.",
|
||||
);
|
||||
}
|
||||
|
||||
const count = await LeaderboardsDAL.getCount(
|
||||
mode,
|
||||
mode2,
|
||||
language,
|
||||
friendsOnlyUid,
|
||||
);
|
||||
const normalizedLeaderboard = leaderboard.map((it) => omit(it, ["_id"]));
|
||||
|
||||
return new MonkeyResponse("Leaderboard retrieved", {
|
||||
count,
|
||||
entries: normalizedLeaderboard,
|
||||
pageSize,
|
||||
});
|
||||
}
|
||||
|
||||
export async function getRankFromLeaderboard(
|
||||
req: MonkeyRequest<GetLeaderboardRankQuery>,
|
||||
): Promise<GetLeaderboardRankResponse> {
|
||||
const { language, mode, mode2, friendsOnly } = req.query;
|
||||
const { uid } = req.ctx.decodedToken;
|
||||
const connectionsConfig = req.ctx.configuration.connections;
|
||||
|
||||
const data = await LeaderboardsDAL.getRank(
|
||||
mode,
|
||||
mode2,
|
||||
language,
|
||||
uid,
|
||||
getFriendsOnlyUid(uid, friendsOnly, connectionsConfig) !== undefined,
|
||||
);
|
||||
if (data === false) {
|
||||
throw new MonkeyError(
|
||||
503,
|
||||
"Leaderboard is currently updating. Please try again in a few seconds.",
|
||||
);
|
||||
}
|
||||
|
||||
if (data === null) {
|
||||
return new MonkeyResponse("Rank retrieved", null);
|
||||
}
|
||||
|
||||
return new MonkeyResponse("Rank retrieved", omit(data, ["_id"]));
|
||||
}
|
||||
|
||||
function getDailyLeaderboardWithError(
|
||||
{ language, mode, mode2, daysBefore }: DailyLeaderboardQuery,
|
||||
config: Configuration["dailyLeaderboards"],
|
||||
): DailyLeaderboards.DailyLeaderboard {
|
||||
const customTimestamp =
|
||||
daysBefore === undefined
|
||||
? -1
|
||||
: getCurrentDayTimestamp() - daysBefore * MILLISECONDS_IN_DAY;
|
||||
|
||||
const dailyLeaderboard = DailyLeaderboards.getDailyLeaderboard(
|
||||
language,
|
||||
mode,
|
||||
mode2,
|
||||
config,
|
||||
customTimestamp,
|
||||
);
|
||||
if (!dailyLeaderboard) {
|
||||
throw new MonkeyError(404, "There is no daily leaderboard for this mode");
|
||||
}
|
||||
|
||||
return dailyLeaderboard;
|
||||
}
|
||||
|
||||
export async function getDailyLeaderboard(
|
||||
req: MonkeyRequest<GetDailyLeaderboardQuery>,
|
||||
): Promise<GetDailyLeaderboardResponse> {
|
||||
const { page, pageSize, friendsOnly } = req.query;
|
||||
const { uid } = req.ctx.decodedToken;
|
||||
const connectionsConfig = req.ctx.configuration.connections;
|
||||
|
||||
const friendUids = await getFriendsUids(
|
||||
uid,
|
||||
friendsOnly === true,
|
||||
connectionsConfig,
|
||||
);
|
||||
|
||||
const dailyLeaderboard = getDailyLeaderboardWithError(
|
||||
req.query,
|
||||
req.ctx.configuration.dailyLeaderboards,
|
||||
);
|
||||
|
||||
const results = await dailyLeaderboard.getResults(
|
||||
page,
|
||||
pageSize,
|
||||
req.ctx.configuration.dailyLeaderboards,
|
||||
req.ctx.configuration.users.premium.enabled,
|
||||
friendUids,
|
||||
);
|
||||
|
||||
return new MonkeyResponse("Daily leaderboard retrieved", {
|
||||
entries: results?.entries ?? [],
|
||||
count: results?.count ?? 0,
|
||||
minWpm: results?.minWpm ?? 0,
|
||||
pageSize,
|
||||
});
|
||||
}
|
||||
|
||||
export async function getDailyLeaderboardRank(
|
||||
req: MonkeyRequest<GetDailyLeaderboardRankQuery>,
|
||||
): Promise<GetLeaderboardDailyRankResponse> {
|
||||
const { friendsOnly } = req.query;
|
||||
const { uid } = req.ctx.decodedToken;
|
||||
const connectionsConfig = req.ctx.configuration.connections;
|
||||
|
||||
const friendUids = await getFriendsUids(
|
||||
uid,
|
||||
friendsOnly === true,
|
||||
connectionsConfig,
|
||||
);
|
||||
|
||||
const dailyLeaderboard = getDailyLeaderboardWithError(
|
||||
req.query,
|
||||
req.ctx.configuration.dailyLeaderboards,
|
||||
);
|
||||
|
||||
const rank = await dailyLeaderboard.getRank(
|
||||
uid,
|
||||
req.ctx.configuration.dailyLeaderboards,
|
||||
friendUids,
|
||||
);
|
||||
|
||||
return new MonkeyResponse("Daily leaderboard rank retrieved", rank);
|
||||
}
|
||||
|
||||
function getWeeklyXpLeaderboardWithError(
|
||||
config: Configuration["leaderboards"]["weeklyXp"],
|
||||
weeksBefore?: number,
|
||||
): WeeklyXpLeaderboard.WeeklyXpLeaderboard {
|
||||
const customTimestamp =
|
||||
weeksBefore === undefined
|
||||
? -1
|
||||
: getCurrentWeekTimestamp() - weeksBefore * MILLISECONDS_IN_DAY * 7;
|
||||
|
||||
const weeklyXpLeaderboard = WeeklyXpLeaderboard.get(config, customTimestamp);
|
||||
if (!weeklyXpLeaderboard) {
|
||||
throw new MonkeyError(404, "XP leaderboard for this week not found.");
|
||||
}
|
||||
|
||||
return weeklyXpLeaderboard;
|
||||
}
|
||||
|
||||
export async function getWeeklyXpLeaderboard(
|
||||
req: MonkeyRequest<GetWeeklyXpLeaderboardQuery>,
|
||||
): Promise<GetWeeklyXpLeaderboardResponse> {
|
||||
const { page, pageSize, weeksBefore, friendsOnly } = req.query;
|
||||
|
||||
const { uid } = req.ctx.decodedToken;
|
||||
const connectionsConfig = req.ctx.configuration.connections;
|
||||
|
||||
const friendUids = await getFriendsUids(
|
||||
uid,
|
||||
friendsOnly === true,
|
||||
connectionsConfig,
|
||||
);
|
||||
|
||||
const weeklyXpLeaderboard = getWeeklyXpLeaderboardWithError(
|
||||
req.ctx.configuration.leaderboards.weeklyXp,
|
||||
weeksBefore,
|
||||
);
|
||||
const results = await weeklyXpLeaderboard.getResults(
|
||||
page,
|
||||
pageSize,
|
||||
req.ctx.configuration.leaderboards.weeklyXp,
|
||||
req.ctx.configuration.users.premium.enabled,
|
||||
friendUids,
|
||||
);
|
||||
|
||||
return new MonkeyResponse("Weekly xp leaderboard retrieved", {
|
||||
entries: results?.entries ?? [],
|
||||
count: results?.count ?? 0,
|
||||
pageSize,
|
||||
});
|
||||
}
|
||||
|
||||
export async function getWeeklyXpLeaderboardRank(
|
||||
req: MonkeyRequest<GetWeeklyXpLeaderboardRankQuery>,
|
||||
): Promise<GetWeeklyXpLeaderboardRankResponse> {
|
||||
const { friendsOnly } = req.query;
|
||||
const { uid } = req.ctx.decodedToken;
|
||||
const connectionsConfig = req.ctx.configuration.connections;
|
||||
|
||||
const friendUids = await getFriendsUids(
|
||||
uid,
|
||||
friendsOnly === true,
|
||||
connectionsConfig,
|
||||
);
|
||||
|
||||
const weeklyXpLeaderboard = getWeeklyXpLeaderboardWithError(
|
||||
req.ctx.configuration.leaderboards.weeklyXp,
|
||||
req.query.weeksBefore,
|
||||
);
|
||||
const rankEntry = await weeklyXpLeaderboard.getRank(
|
||||
uid,
|
||||
req.ctx.configuration.leaderboards.weeklyXp,
|
||||
friendUids,
|
||||
);
|
||||
|
||||
return new MonkeyResponse("Weekly xp leaderboard rank retrieved", rankEntry);
|
||||
}
|
||||
|
||||
async function getFriendsUids(
|
||||
uid: string,
|
||||
friendsOnly: boolean,
|
||||
friendsConfig: Configuration["connections"],
|
||||
): Promise<string[] | undefined> {
|
||||
if (uid !== "" && friendsOnly) {
|
||||
if (!friendsConfig.enabled) {
|
||||
throw new MonkeyError(503, "This feature is currently unavailable.");
|
||||
}
|
||||
return await ConnectionsDal.getFriendsUids(uid);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function getFriendsOnlyUid(
|
||||
uid: string,
|
||||
friendsOnly: boolean | undefined,
|
||||
friendsConfig: Configuration["connections"],
|
||||
): string | undefined {
|
||||
if (uid !== "" && friendsOnly === true) {
|
||||
if (!friendsConfig.enabled) {
|
||||
throw new MonkeyError(503, "This feature is currently unavailable.");
|
||||
}
|
||||
return uid;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
57
backend/src/api/controllers/preset.ts
Normal file
57
backend/src/api/controllers/preset.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import {
|
||||
AddPresetRequest,
|
||||
AddPresetResponse,
|
||||
DeletePresetsParams,
|
||||
GetPresetResponse,
|
||||
} from "@monkeytype/contracts/presets";
|
||||
import * as PresetDAL from "../../dal/preset";
|
||||
import { MonkeyResponse } from "../../utils/monkey-response";
|
||||
import { replaceObjectId } from "../../utils/misc";
|
||||
import { EditPresetRequest } from "@monkeytype/schemas/presets";
|
||||
import { MonkeyRequest } from "../types";
|
||||
|
||||
export async function getPresets(
|
||||
req: MonkeyRequest,
|
||||
): Promise<GetPresetResponse> {
|
||||
const { uid } = req.ctx.decodedToken;
|
||||
|
||||
const data = (await PresetDAL.getPresets(uid))
|
||||
.map((preset) => ({
|
||||
...preset,
|
||||
uid: undefined,
|
||||
}))
|
||||
.map((it) => replaceObjectId(it));
|
||||
|
||||
return new MonkeyResponse("Presets retrieved", data);
|
||||
}
|
||||
|
||||
export async function addPreset(
|
||||
req: MonkeyRequest<undefined, AddPresetRequest>,
|
||||
): Promise<AddPresetResponse> {
|
||||
const { uid } = req.ctx.decodedToken;
|
||||
|
||||
const data = await PresetDAL.addPreset(uid, req.body);
|
||||
|
||||
return new MonkeyResponse("Preset created", data);
|
||||
}
|
||||
|
||||
export async function editPreset(
|
||||
req: MonkeyRequest<undefined, EditPresetRequest>,
|
||||
): Promise<MonkeyResponse> {
|
||||
const { uid } = req.ctx.decodedToken;
|
||||
|
||||
await PresetDAL.editPreset(uid, req.body);
|
||||
|
||||
return new MonkeyResponse("Preset updated", null);
|
||||
}
|
||||
|
||||
export async function removePreset(
|
||||
req: MonkeyRequest<undefined, undefined, DeletePresetsParams>,
|
||||
): Promise<MonkeyResponse> {
|
||||
const { presetId } = req.params;
|
||||
const { uid } = req.ctx.decodedToken;
|
||||
|
||||
await PresetDAL.removePreset(uid, presetId);
|
||||
|
||||
return new MonkeyResponse("Preset deleted", null);
|
||||
}
|
||||
16
backend/src/api/controllers/psa.ts
Normal file
16
backend/src/api/controllers/psa.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { GetPsaResponse } from "@monkeytype/contracts/psas";
|
||||
import * as PsaDAL from "../../dal/psa";
|
||||
import { MonkeyResponse } from "../../utils/monkey-response";
|
||||
import { replaceObjectIds } from "../../utils/misc";
|
||||
import { MonkeyRequest } from "../types";
|
||||
import { PSA } from "@monkeytype/schemas/psas";
|
||||
import { cacheWithTTL } from "../../utils/ttl-cache";
|
||||
|
||||
//cache for one minute
|
||||
const cache = cacheWithTTL<PSA[]>(1 * 60 * 1000, async () => {
|
||||
return replaceObjectIds(await PsaDAL.get());
|
||||
});
|
||||
|
||||
export async function getPsas(_req: MonkeyRequest): Promise<GetPsaResponse> {
|
||||
return new MonkeyResponse("PSAs retrieved", (await cache()) ?? []);
|
||||
}
|
||||
23
backend/src/api/controllers/public.ts
Normal file
23
backend/src/api/controllers/public.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import {
|
||||
GetSpeedHistogramQuery,
|
||||
GetSpeedHistogramResponse,
|
||||
GetTypingStatsResponse,
|
||||
} from "@monkeytype/contracts/public";
|
||||
import * as PublicDAL from "../../dal/public";
|
||||
import { MonkeyResponse } from "../../utils/monkey-response";
|
||||
import { MonkeyRequest } from "../types";
|
||||
|
||||
export async function getSpeedHistogram(
|
||||
req: MonkeyRequest<GetSpeedHistogramQuery>,
|
||||
): Promise<GetSpeedHistogramResponse> {
|
||||
const { language, mode, mode2 } = req.query;
|
||||
const data = await PublicDAL.getSpeedHistogram(language, mode, mode2);
|
||||
return new MonkeyResponse("Public speed histogram retrieved", data);
|
||||
}
|
||||
|
||||
export async function getTypingStats(
|
||||
_req: MonkeyRequest,
|
||||
): Promise<GetTypingStatsResponse> {
|
||||
const data = await PublicDAL.getTypingStats();
|
||||
return new MonkeyResponse("Public typing stats retrieved", data);
|
||||
}
|
||||
165
backend/src/api/controllers/quote.ts
Normal file
165
backend/src/api/controllers/quote.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { getPartialUser, updateQuoteRatings } from "../../dal/user";
|
||||
import * as ReportDAL from "../../dal/report";
|
||||
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 { MonkeyResponse } from "../../utils/monkey-response";
|
||||
import { ObjectId } from "mongodb";
|
||||
import { addLog } from "../../dal/logs";
|
||||
import {
|
||||
AddQuoteRatingRequest,
|
||||
AddQuoteRequest,
|
||||
ApproveQuoteRequest,
|
||||
ApproveQuoteResponse,
|
||||
GetQuoteRatingQuery,
|
||||
GetQuoteRatingResponse,
|
||||
GetQuotesResponse,
|
||||
IsSubmissionEnabledResponse,
|
||||
RejectQuoteRequest,
|
||||
ReportQuoteRequest,
|
||||
} from "@monkeytype/contracts/quotes";
|
||||
import { replaceObjectId, replaceObjectIds } from "../../utils/misc";
|
||||
import { MonkeyRequest } from "../types";
|
||||
import { Language } from "@monkeytype/schemas/languages";
|
||||
|
||||
async function verifyCaptcha(captcha: string): Promise<void> {
|
||||
if (!(await verify(captcha))) {
|
||||
throw new MonkeyError(422, "Captcha check failed");
|
||||
}
|
||||
}
|
||||
|
||||
export async function getQuotes(
|
||||
req: MonkeyRequest,
|
||||
): Promise<GetQuotesResponse> {
|
||||
const { uid } = req.ctx.decodedToken;
|
||||
const quoteMod = (await getPartialUser(uid, "get quotes", ["quoteMod"]))
|
||||
.quoteMod;
|
||||
const quoteModLanguage = quoteMod === true ? "all" : (quoteMod as Language);
|
||||
|
||||
const data = await NewQuotesDAL.get(quoteModLanguage);
|
||||
return new MonkeyResponse(
|
||||
"Quote submissions retrieved",
|
||||
replaceObjectIds(data),
|
||||
);
|
||||
}
|
||||
|
||||
export async function isSubmissionEnabled(
|
||||
req: MonkeyRequest,
|
||||
): Promise<IsSubmissionEnabledResponse> {
|
||||
const { submissionsEnabled } = req.ctx.configuration.quotes;
|
||||
return new MonkeyResponse(
|
||||
"Quote submission " + (submissionsEnabled ? "enabled" : "disabled"),
|
||||
{ isEnabled: submissionsEnabled },
|
||||
);
|
||||
}
|
||||
|
||||
export async function addQuote(
|
||||
req: MonkeyRequest<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 MonkeyResponse("Quote submission added", null);
|
||||
}
|
||||
|
||||
export async function approveQuote(
|
||||
req: MonkeyRequest<undefined, ApproveQuoteRequest>,
|
||||
): Promise<ApproveQuoteResponse> {
|
||||
const { uid } = req.ctx.decodedToken;
|
||||
const { quoteId, editText, editSource } = req.body;
|
||||
|
||||
const { name } = await getPartialUser(uid, "approve quote", ["name"]);
|
||||
|
||||
if (!name) {
|
||||
throw new MonkeyError(500, "Missing name field");
|
||||
}
|
||||
|
||||
const data = await NewQuotesDAL.approve(quoteId, editText, editSource, name);
|
||||
void addLog("system_quote_approved", data, uid);
|
||||
|
||||
return new MonkeyResponse(data.message, data.quote);
|
||||
}
|
||||
|
||||
export async function refuseQuote(
|
||||
req: MonkeyRequest<undefined, RejectQuoteRequest>,
|
||||
): Promise<MonkeyResponse> {
|
||||
const { quoteId } = req.body;
|
||||
|
||||
await NewQuotesDAL.refuse(quoteId);
|
||||
return new MonkeyResponse("Quote refused", null);
|
||||
}
|
||||
|
||||
export async function getRating(
|
||||
req: MonkeyRequest<GetQuoteRatingQuery>,
|
||||
): Promise<GetQuoteRatingResponse> {
|
||||
const { quoteId, language } = req.query;
|
||||
|
||||
const data = await QuoteRatingsDAL.get(quoteId, language);
|
||||
|
||||
return new MonkeyResponse("Rating retrieved", replaceObjectId(data));
|
||||
}
|
||||
|
||||
export async function submitRating(
|
||||
req: MonkeyRequest<undefined, AddQuoteRatingRequest>,
|
||||
): Promise<MonkeyResponse> {
|
||||
const { uid } = req.ctx.decodedToken;
|
||||
const { quoteId, rating, language } = req.body;
|
||||
|
||||
const user = await getPartialUser(uid, "submit rating", ["quoteRatings"]);
|
||||
|
||||
const userQuoteRatings = user.quoteRatings ?? {};
|
||||
const currentRating = userQuoteRatings[language]?.[quoteId] ?? 0;
|
||||
|
||||
const newRating = rating - currentRating;
|
||||
const shouldUpdateRating = currentRating !== 0;
|
||||
|
||||
await QuoteRatingsDAL.submit(
|
||||
quoteId,
|
||||
language,
|
||||
newRating,
|
||||
shouldUpdateRating,
|
||||
);
|
||||
|
||||
userQuoteRatings[language] ??= {};
|
||||
userQuoteRatings[language][quoteId] = rating;
|
||||
|
||||
await updateQuoteRatings(uid, userQuoteRatings);
|
||||
|
||||
const responseMessage = `Rating ${
|
||||
shouldUpdateRating ? "updated" : "submitted"
|
||||
}`;
|
||||
return new MonkeyResponse(responseMessage, null);
|
||||
}
|
||||
|
||||
export async function reportQuote(
|
||||
req: MonkeyRequest<undefined, ReportQuoteRequest>,
|
||||
): Promise<MonkeyResponse> {
|
||||
const { uid } = req.ctx.decodedToken;
|
||||
const {
|
||||
reporting: { maxReports, contentReportLimit },
|
||||
} = req.ctx.configuration.quotes;
|
||||
|
||||
const { quoteId, quoteLanguage, reason, comment, captcha } = req.body;
|
||||
|
||||
await verifyCaptcha(captcha);
|
||||
|
||||
const newReport: ReportDAL.DBReport = {
|
||||
_id: new ObjectId(),
|
||||
id: uuidv4(),
|
||||
type: "quote",
|
||||
timestamp: new Date().getTime(),
|
||||
uid,
|
||||
contentId: `${quoteLanguage}-${quoteId}`,
|
||||
reason,
|
||||
comment: comment ?? "",
|
||||
};
|
||||
|
||||
await ReportDAL.createReport(newReport, maxReports, contentReportLimit);
|
||||
|
||||
return new MonkeyResponse("Quote reported", null);
|
||||
}
|
||||
831
backend/src/api/controllers/result.ts
Normal file
831
backend/src/api/controllers/result.ts
Normal file
@@ -0,0 +1,831 @@
|
||||
import * as ResultDAL from "../../dal/result";
|
||||
import * as PublicDAL from "../../dal/public";
|
||||
import {
|
||||
isDevEnvironment,
|
||||
omit,
|
||||
replaceObjectId,
|
||||
replaceObjectIds,
|
||||
} from "../../utils/misc";
|
||||
import objectHash from "object-hash";
|
||||
import Logger from "../../utils/logger";
|
||||
import "dotenv/config";
|
||||
import { MonkeyResponse } from "../../utils/monkey-response";
|
||||
import MonkeyError from "../../utils/error";
|
||||
import { isTestTooShort } from "../../utils/validation";
|
||||
import {
|
||||
implemented as anticheatImplemented,
|
||||
validateResult,
|
||||
validateKeys,
|
||||
} from "../../anticheat/index";
|
||||
import MonkeyStatusCodes from "../../constants/monkey-status-codes";
|
||||
import {
|
||||
incrementResult,
|
||||
incrementDailyLeaderboard,
|
||||
} from "../../utils/prometheus";
|
||||
import GeorgeQueue from "../../queues/george-queue";
|
||||
import { getDailyLeaderboard } from "../../utils/daily-leaderboards";
|
||||
import AutoRoleList from "../../constants/auto-roles";
|
||||
import * as UserDAL from "../../dal/user";
|
||||
import { buildMonkeyMail } from "../../utils/monkey-mail";
|
||||
import * as WeeklyXpLeaderboard from "../../services/weekly-xp-leaderboard";
|
||||
import { UAParser } from "ua-parser-js";
|
||||
import { canFunboxGetPb } from "../../utils/pb";
|
||||
import { buildDbResult } from "../../utils/result";
|
||||
import { Configuration } from "@monkeytype/schemas/configuration";
|
||||
import { addImportantLog, addLog } from "../../dal/logs";
|
||||
import {
|
||||
AddResultRequest,
|
||||
AddResultResponse,
|
||||
GetLastResultResponse,
|
||||
GetResultByIdPath,
|
||||
GetResultByIdResponse,
|
||||
GetResultsQuery,
|
||||
GetResultsResponse,
|
||||
UpdateResultTagsRequest,
|
||||
UpdateResultTagsResponse,
|
||||
} from "@monkeytype/contracts/results";
|
||||
import {
|
||||
CompletedEvent,
|
||||
KeyStats,
|
||||
PostResultResponse,
|
||||
XpBreakdown,
|
||||
} from "@monkeytype/schemas/results";
|
||||
import {
|
||||
isSafeNumber,
|
||||
mapRange,
|
||||
roundTo2,
|
||||
stdDev,
|
||||
} from "@monkeytype/util/numbers";
|
||||
import {
|
||||
getCurrentDayTimestamp,
|
||||
getStartOfDayTimestamp,
|
||||
} from "@monkeytype/util/date-and-time";
|
||||
import { MonkeyRequest } from "../types";
|
||||
import { getFunbox, checkCompatibility } from "@monkeytype/funbox";
|
||||
import { tryCatch } from "@monkeytype/util/trycatch";
|
||||
import { getCachedConfiguration } from "../../init/configuration";
|
||||
|
||||
try {
|
||||
if (!anticheatImplemented()) throw new Error("undefined");
|
||||
Logger.success("Anticheat module loaded");
|
||||
} catch (e) {
|
||||
if (isDevEnvironment()) {
|
||||
Logger.warning(
|
||||
"No anticheat module found. Continuing in dev mode, results will not be validated.",
|
||||
);
|
||||
} else {
|
||||
Logger.error(
|
||||
"No anticheat module found. To continue in dev mode, add MODE=dev to your .env file in the backend directory",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getResults(
|
||||
req: MonkeyRequest<GetResultsQuery>,
|
||||
): Promise<GetResultsResponse> {
|
||||
const { uid } = req.ctx.decodedToken;
|
||||
const premiumFeaturesEnabled = req.ctx.configuration.users.premium.enabled;
|
||||
const { onOrAfterTimestamp = NaN, offset = 0 } = req.query;
|
||||
const userHasPremium = await UserDAL.checkIfUserIsPremium(uid);
|
||||
|
||||
const maxLimit =
|
||||
premiumFeaturesEnabled && userHasPremium
|
||||
? req.ctx.configuration.results.limits.premiumUser
|
||||
: req.ctx.configuration.results.limits.regularUser;
|
||||
|
||||
let limit =
|
||||
req.query.limit ??
|
||||
Math.min(req.ctx.configuration.results.maxBatchSize, maxLimit);
|
||||
|
||||
//check if premium features are disabled and current call exceeds the limit for regular users
|
||||
if (
|
||||
userHasPremium &&
|
||||
!premiumFeaturesEnabled &&
|
||||
limit + offset > req.ctx.configuration.results.limits.regularUser
|
||||
) {
|
||||
throw new MonkeyError(503, "Premium feature disabled.");
|
||||
}
|
||||
|
||||
if (limit + offset > maxLimit) {
|
||||
if (offset < maxLimit) {
|
||||
//batch is partly in the allowed ranged. Set the limit to the max allowed and return partly results.
|
||||
limit = maxLimit - offset;
|
||||
} else {
|
||||
throw new MonkeyError(422, `Max results limit of ${maxLimit} exceeded.`);
|
||||
}
|
||||
}
|
||||
|
||||
const results = await ResultDAL.getResults(uid, {
|
||||
onOrAfterTimestamp,
|
||||
limit,
|
||||
offset,
|
||||
});
|
||||
void addLog(
|
||||
"user_results_requested",
|
||||
{
|
||||
limit,
|
||||
offset,
|
||||
onOrAfterTimestamp,
|
||||
isPremium: userHasPremium,
|
||||
},
|
||||
uid,
|
||||
);
|
||||
|
||||
return new MonkeyResponse("Results retrieved", replaceObjectIds(results));
|
||||
}
|
||||
|
||||
export async function getResultById(
|
||||
req: MonkeyRequest<undefined, undefined, GetResultByIdPath>,
|
||||
): Promise<GetResultByIdResponse> {
|
||||
const { uid } = req.ctx.decodedToken;
|
||||
const { resultId } = req.params;
|
||||
|
||||
const result = await ResultDAL.getResult(uid, resultId);
|
||||
return new MonkeyResponse("Result retrieved", replaceObjectId(result));
|
||||
}
|
||||
|
||||
export async function getLastResult(
|
||||
req: MonkeyRequest,
|
||||
): Promise<GetLastResultResponse> {
|
||||
const { uid } = req.ctx.decodedToken;
|
||||
const result = await ResultDAL.getLastResult(uid);
|
||||
return new MonkeyResponse("Result retrieved", replaceObjectId(result));
|
||||
}
|
||||
|
||||
export async function deleteAll(req: MonkeyRequest): Promise<MonkeyResponse> {
|
||||
const { uid } = req.ctx.decodedToken;
|
||||
|
||||
await ResultDAL.deleteAll(uid);
|
||||
void addLog("user_results_deleted", "", uid);
|
||||
return new MonkeyResponse("All results deleted", null);
|
||||
}
|
||||
|
||||
export async function updateTags(
|
||||
req: MonkeyRequest<undefined, UpdateResultTagsRequest>,
|
||||
): Promise<UpdateResultTagsResponse> {
|
||||
const { uid } = req.ctx.decodedToken;
|
||||
const { tagIds, resultId } = req.body;
|
||||
|
||||
await ResultDAL.updateTags(uid, resultId, tagIds);
|
||||
const result = await ResultDAL.getResult(uid, resultId);
|
||||
|
||||
result.difficulty ??= "normal";
|
||||
result.language ??= "english";
|
||||
result.funbox ??= [];
|
||||
result.lazyMode ??= false;
|
||||
result.punctuation ??= false;
|
||||
result.numbers ??= false;
|
||||
|
||||
const user = await UserDAL.getPartialUser(uid, "update tags", ["tags"]);
|
||||
const tagPbs = await UserDAL.checkIfTagPb(uid, user, result);
|
||||
return new MonkeyResponse("Result tags updated", {
|
||||
tagPbs,
|
||||
});
|
||||
}
|
||||
|
||||
export async function addResult(
|
||||
req: MonkeyRequest<undefined, AddResultRequest>,
|
||||
): Promise<AddResultResponse> {
|
||||
const { uid } = req.ctx.decodedToken;
|
||||
|
||||
const user = await UserDAL.getUser(uid, "add result");
|
||||
|
||||
if (user.needsToChangeName) {
|
||||
throw new MonkeyError(
|
||||
403,
|
||||
"Please change your name before submitting a result",
|
||||
);
|
||||
}
|
||||
|
||||
const completedEvent = req.body.result;
|
||||
completedEvent.uid = uid;
|
||||
|
||||
if (isTestTooShort(completedEvent)) {
|
||||
const status = MonkeyStatusCodes.TEST_TOO_SHORT;
|
||||
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"]);
|
||||
const serverhash = objectHash(objectToHash);
|
||||
if (serverhash !== resulthash) {
|
||||
void addLog(
|
||||
"incorrect_result_hash",
|
||||
{
|
||||
serverhash,
|
||||
resulthash,
|
||||
result: completedEvent,
|
||||
},
|
||||
uid,
|
||||
);
|
||||
const status = MonkeyStatusCodes.RESULT_HASH_INVALID;
|
||||
throw new MonkeyError(status.code, "Incorrect result hash");
|
||||
}
|
||||
} else {
|
||||
Logger.warning("Object hash check is disabled, skipping hash check");
|
||||
}
|
||||
|
||||
if (completedEvent.funbox.length !== new Set(completedEvent.funbox).size) {
|
||||
throw new MonkeyError(400, "Duplicate funboxes");
|
||||
}
|
||||
|
||||
if (!checkCompatibility(completedEvent.funbox)) {
|
||||
throw new MonkeyError(400, "Impossible funbox combination");
|
||||
}
|
||||
|
||||
let keySpacingStats: KeyStats | undefined = undefined;
|
||||
if (
|
||||
completedEvent.keySpacing !== "toolong" &&
|
||||
completedEvent.keySpacing.length > 0
|
||||
) {
|
||||
keySpacingStats = {
|
||||
average:
|
||||
completedEvent.keySpacing.reduce(
|
||||
(previous, current) => (current += previous),
|
||||
) / completedEvent.keySpacing.length,
|
||||
sd: stdDev(completedEvent.keySpacing),
|
||||
};
|
||||
}
|
||||
|
||||
let keyDurationStats: KeyStats | undefined = undefined;
|
||||
if (
|
||||
completedEvent.keyDuration !== "toolong" &&
|
||||
completedEvent.keyDuration.length > 0
|
||||
) {
|
||||
keyDurationStats = {
|
||||
average:
|
||||
completedEvent.keyDuration.reduce(
|
||||
(previous, current) => (current += previous),
|
||||
) / completedEvent.keyDuration.length,
|
||||
sd: stdDev(completedEvent.keyDuration),
|
||||
};
|
||||
}
|
||||
|
||||
if (user.suspicious && completedEvent.testDuration <= 120) {
|
||||
await addImportantLog("suspicious_user_result", completedEvent, uid);
|
||||
}
|
||||
|
||||
if (
|
||||
completedEvent.mode === "time" &&
|
||||
(completedEvent.mode2 === "60" || completedEvent.mode2 === "15") &&
|
||||
completedEvent.wpm > 250 &&
|
||||
user.lbOptOut !== true
|
||||
) {
|
||||
await addImportantLog("highwpm_user_result", completedEvent, uid);
|
||||
}
|
||||
|
||||
if (anticheatImplemented()) {
|
||||
if (
|
||||
!validateResult(
|
||||
completedEvent,
|
||||
((req.raw.headers["x-client-version"] as string) ||
|
||||
req.raw.headers["client-version"]) as string,
|
||||
JSON.stringify(new UAParser(req.raw.headers["user-agent"]).getResult()),
|
||||
user.lbOptOut === true,
|
||||
)
|
||||
) {
|
||||
const status = MonkeyStatusCodes.RESULT_DATA_INVALID;
|
||||
throw new MonkeyError(status.code, "Result data doesn't make sense");
|
||||
} else if (isDevEnvironment()) {
|
||||
Logger.success("Result data validated");
|
||||
}
|
||||
} else {
|
||||
if (!isDevEnvironment()) {
|
||||
throw new Error("No anticheat module found");
|
||||
}
|
||||
Logger.warning(
|
||||
"No anticheat module found. Continuing in dev mode, results will not be validated.",
|
||||
);
|
||||
}
|
||||
|
||||
//dont use - result timestamp is unreliable, can be changed by system time and stuff
|
||||
// if (result.timestamp > Math.round(Date.now() / 1000) * 1000 + 10) {
|
||||
// log(
|
||||
// "time_traveler",
|
||||
// {
|
||||
// resultTimestamp: result.timestamp,
|
||||
// serverTimestamp: Math.round(Date.now() / 1000) * 1000 + 10,
|
||||
// },
|
||||
// uid
|
||||
// );
|
||||
// return res.status(400).json({ message: "Time traveler detected" });
|
||||
|
||||
const { data: lastResultTimestamp } = await tryCatch(
|
||||
ResultDAL.getLastResultTimestamp(uid),
|
||||
);
|
||||
|
||||
//convert result test duration to miliseconds
|
||||
completedEvent.timestamp = Math.floor(Date.now() / 1000) * 1000;
|
||||
|
||||
//check if now is earlier than last result plus duration (-1 second as a buffer)
|
||||
const testDurationMilis = completedEvent.testDuration * 1000;
|
||||
const incompleteTestsMilis = completedEvent.incompleteTestSeconds * 1000;
|
||||
const earliestPossible =
|
||||
(lastResultTimestamp ?? 0) + testDurationMilis + incompleteTestsMilis;
|
||||
const nowNoMilis = Math.floor(Date.now() / 1000) * 1000;
|
||||
if (
|
||||
isSafeNumber(lastResultTimestamp) &&
|
||||
nowNoMilis < earliestPossible - 1000
|
||||
) {
|
||||
void addLog(
|
||||
"invalid_result_spacing",
|
||||
{
|
||||
lastTimestamp: lastResultTimestamp,
|
||||
earliestPossible,
|
||||
now: nowNoMilis,
|
||||
testDuration: testDurationMilis,
|
||||
difference: nowNoMilis - earliestPossible,
|
||||
},
|
||||
uid,
|
||||
);
|
||||
const status = MonkeyStatusCodes.RESULT_SPACING_INVALID;
|
||||
throw new MonkeyError(status.code, "Invalid result spacing");
|
||||
}
|
||||
|
||||
//check keyspacing and duration here for bots
|
||||
if (
|
||||
completedEvent.mode === "time" &&
|
||||
completedEvent.wpm > 130 &&
|
||||
completedEvent.testDuration < 122 &&
|
||||
(user.verified === false || user.verified === undefined) &&
|
||||
user.lbOptOut !== true
|
||||
) {
|
||||
if (!keySpacingStats || !keyDurationStats) {
|
||||
const status = MonkeyStatusCodes.MISSING_KEY_DATA;
|
||||
throw new MonkeyError(status.code, "Missing key data");
|
||||
}
|
||||
if (completedEvent.keyOverlap === undefined) {
|
||||
throw new MonkeyError(400, "Old key data format");
|
||||
}
|
||||
if (anticheatImplemented()) {
|
||||
if (
|
||||
!validateKeys(completedEvent, keySpacingStats, keyDurationStats, uid)
|
||||
) {
|
||||
//autoban
|
||||
const autoBanConfig = req.ctx.configuration.users.autoBan;
|
||||
if (autoBanConfig.enabled) {
|
||||
const didUserGetBanned = await UserDAL.recordAutoBanEvent(
|
||||
uid,
|
||||
autoBanConfig.maxCount,
|
||||
autoBanConfig.maxHours,
|
||||
);
|
||||
if (didUserGetBanned) {
|
||||
const mail = buildMonkeyMail({
|
||||
subject: "Banned",
|
||||
body: "Your account has been automatically banned for triggering the anticheat system. If you believe this is a mistake, please contact support.",
|
||||
});
|
||||
await UserDAL.addToInbox(
|
||||
uid,
|
||||
[mail],
|
||||
req.ctx.configuration.users.inbox,
|
||||
);
|
||||
user.banned = true;
|
||||
}
|
||||
}
|
||||
const status = MonkeyStatusCodes.BOT_DETECTED;
|
||||
throw new MonkeyError(status.code, "Possible bot detected");
|
||||
}
|
||||
} else {
|
||||
if (!isDevEnvironment()) {
|
||||
throw new Error("No anticheat module found");
|
||||
}
|
||||
Logger.warning(
|
||||
"No anticheat module found. Continuing in dev mode, results will not be validated.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (req.ctx.configuration.users.lastHashesCheck.enabled) {
|
||||
let lastHashes = user.lastReultHashes ?? [];
|
||||
if (lastHashes.includes(resulthash)) {
|
||||
void addLog(
|
||||
"duplicate_result",
|
||||
{
|
||||
lastHashes,
|
||||
resulthash,
|
||||
result: completedEvent,
|
||||
},
|
||||
uid,
|
||||
);
|
||||
const status = MonkeyStatusCodes.DUPLICATE_RESULT;
|
||||
throw new MonkeyError(status.code, "Duplicate result");
|
||||
} else {
|
||||
lastHashes.unshift(resulthash);
|
||||
const maxHashes = req.ctx.configuration.users.lastHashesCheck.maxHashes;
|
||||
if (lastHashes.length > maxHashes) {
|
||||
lastHashes = lastHashes.slice(0, maxHashes);
|
||||
}
|
||||
await UserDAL.updateLastHashes(uid, lastHashes);
|
||||
}
|
||||
}
|
||||
|
||||
if (keyDurationStats) {
|
||||
keyDurationStats.average = roundTo2(keyDurationStats.average);
|
||||
keyDurationStats.sd = roundTo2(keyDurationStats.sd);
|
||||
}
|
||||
if (keySpacingStats) {
|
||||
keySpacingStats.average = roundTo2(keySpacingStats.average);
|
||||
keySpacingStats.sd = roundTo2(keySpacingStats.sd);
|
||||
}
|
||||
|
||||
let isPb = false;
|
||||
let tagPbs: string[] = [];
|
||||
|
||||
if (!completedEvent.bailedOut) {
|
||||
[isPb, tagPbs] = await Promise.all([
|
||||
UserDAL.checkIfPb(uid, user, completedEvent),
|
||||
UserDAL.checkIfTagPb(uid, user, completedEvent),
|
||||
]);
|
||||
}
|
||||
|
||||
if (completedEvent.mode === "time" && completedEvent.mode2 === "60") {
|
||||
void UserDAL.incrementBananas(uid, completedEvent.wpm);
|
||||
if (
|
||||
isPb &&
|
||||
user.discordId !== undefined &&
|
||||
user.discordId !== "" &&
|
||||
user.lbOptOut !== true
|
||||
) {
|
||||
void GeorgeQueue.updateDiscordRole(user.discordId, completedEvent.wpm);
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
completedEvent.challenge !== null &&
|
||||
completedEvent.challenge !== undefined &&
|
||||
AutoRoleList.includes(completedEvent.challenge) &&
|
||||
user.discordId !== undefined &&
|
||||
user.discordId !== ""
|
||||
) {
|
||||
void GeorgeQueue.awardChallenge(user.discordId, completedEvent.challenge);
|
||||
} else {
|
||||
delete completedEvent.challenge;
|
||||
}
|
||||
|
||||
const afk = completedEvent.afkDuration ?? 0;
|
||||
const totalDurationTypedSeconds =
|
||||
completedEvent.testDuration + completedEvent.incompleteTestSeconds - afk;
|
||||
void UserDAL.updateTypingStats(
|
||||
uid,
|
||||
completedEvent.restartCount,
|
||||
totalDurationTypedSeconds,
|
||||
);
|
||||
void PublicDAL.updateStats(
|
||||
completedEvent.restartCount,
|
||||
totalDurationTypedSeconds,
|
||||
);
|
||||
|
||||
const dailyLeaderboardsConfig = req.ctx.configuration.dailyLeaderboards;
|
||||
const dailyLeaderboard = getDailyLeaderboard(
|
||||
completedEvent.language,
|
||||
completedEvent.mode,
|
||||
completedEvent.mode2,
|
||||
dailyLeaderboardsConfig,
|
||||
);
|
||||
|
||||
let dailyLeaderboardRank = -1;
|
||||
|
||||
const stopOnLetterTriggered =
|
||||
completedEvent.stopOnLetter && completedEvent.acc < 100;
|
||||
|
||||
const minTimeTyping = (await getCachedConfiguration(true)).leaderboards
|
||||
.minTimeTyping;
|
||||
|
||||
const userEligibleForLeaderboard =
|
||||
user.banned !== true &&
|
||||
user.lbOptOut !== true &&
|
||||
(isDevEnvironment() || (user.timeTyping ?? 0) > minTimeTyping);
|
||||
|
||||
const validResultCriteria =
|
||||
canFunboxGetPb(completedEvent) &&
|
||||
!completedEvent.bailedOut &&
|
||||
userEligibleForLeaderboard &&
|
||||
!stopOnLetterTriggered;
|
||||
|
||||
const selectedBadgeId = user.inventory?.badges?.find((b) => b.selected)?.id;
|
||||
const isPremium =
|
||||
(await UserDAL.checkIfUserIsPremium(user.uid, user)) || undefined;
|
||||
|
||||
if (dailyLeaderboard && validResultCriteria) {
|
||||
incrementDailyLeaderboard(
|
||||
completedEvent.mode,
|
||||
completedEvent.mode2,
|
||||
completedEvent.language,
|
||||
);
|
||||
dailyLeaderboardRank = await dailyLeaderboard.addResult(
|
||||
{
|
||||
name: user.name,
|
||||
wpm: completedEvent.wpm,
|
||||
raw: completedEvent.rawWpm,
|
||||
acc: completedEvent.acc,
|
||||
consistency: completedEvent.consistency,
|
||||
timestamp: completedEvent.timestamp,
|
||||
uid,
|
||||
discordAvatar: user.discordAvatar,
|
||||
discordId: user.discordId,
|
||||
badgeId: selectedBadgeId,
|
||||
isPremium,
|
||||
},
|
||||
dailyLeaderboardsConfig,
|
||||
);
|
||||
if (
|
||||
dailyLeaderboardRank >= 1 &&
|
||||
dailyLeaderboardRank <= 10 &&
|
||||
completedEvent.testDuration <= 120
|
||||
) {
|
||||
const now = Date.now();
|
||||
const reset = getCurrentDayTimestamp();
|
||||
const limit = 6 * 60 * 60 * 1000;
|
||||
if (now - reset >= limit) {
|
||||
await addLog("daily_leaderboard_top_10_result", completedEvent, uid);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const streak = await UserDAL.updateStreak(uid, completedEvent.timestamp);
|
||||
const badgeWaitingInInbox = (
|
||||
user.inbox?.flatMap((i) =>
|
||||
(i.rewards ?? []).map((r) => (r.type === "badge" ? r.item.id : null)),
|
||||
) ?? []
|
||||
).includes(14);
|
||||
|
||||
const shouldGetBadge =
|
||||
streak >= 365 &&
|
||||
user.inventory?.badges?.find((b) => b.id === 14) === undefined &&
|
||||
!badgeWaitingInInbox;
|
||||
|
||||
if (shouldGetBadge) {
|
||||
const mail = buildMonkeyMail({
|
||||
subject: "Badge",
|
||||
body: "Congratulations for reaching a 365 day streak! You have been awarded a special badge. Now, go touch some grass.",
|
||||
rewards: [
|
||||
{
|
||||
type: "badge",
|
||||
item: {
|
||||
id: 14,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
await UserDAL.addToInbox(uid, [mail], req.ctx.configuration.users.inbox);
|
||||
}
|
||||
|
||||
const xpGained = await calculateXp(
|
||||
completedEvent,
|
||||
req.ctx.configuration.users.xp,
|
||||
lastResultTimestamp,
|
||||
user.xp ?? 0,
|
||||
streak,
|
||||
);
|
||||
|
||||
if (xpGained.xp < 0) {
|
||||
throw new MonkeyError(
|
||||
500,
|
||||
"Calculated XP is negative",
|
||||
JSON.stringify({
|
||||
xpGained,
|
||||
result: completedEvent,
|
||||
}),
|
||||
uid,
|
||||
);
|
||||
}
|
||||
|
||||
const weeklyXpLeaderboardConfig = req.ctx.configuration.leaderboards.weeklyXp;
|
||||
let weeklyXpLeaderboardRank = -1;
|
||||
|
||||
const weeklyXpLeaderboard = WeeklyXpLeaderboard.get(
|
||||
weeklyXpLeaderboardConfig,
|
||||
);
|
||||
if (userEligibleForLeaderboard && xpGained.xp > 0 && weeklyXpLeaderboard) {
|
||||
weeklyXpLeaderboardRank = await weeklyXpLeaderboard.addResult(
|
||||
weeklyXpLeaderboardConfig,
|
||||
{
|
||||
entry: {
|
||||
uid,
|
||||
name: user.name,
|
||||
discordAvatar: user.discordAvatar,
|
||||
discordId: user.discordId,
|
||||
badgeId: selectedBadgeId,
|
||||
lastActivityTimestamp: Date.now(),
|
||||
isPremium,
|
||||
timeTypedSeconds: totalDurationTypedSeconds,
|
||||
},
|
||||
xpGained: xpGained.xp,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const dbresult = buildDbResult(completedEvent, user.name, isPb);
|
||||
if (keySpacingStats !== undefined) {
|
||||
dbresult.keySpacingStats = keySpacingStats;
|
||||
}
|
||||
if (keyDurationStats !== undefined) {
|
||||
dbresult.keyDurationStats = keyDurationStats;
|
||||
}
|
||||
|
||||
const addedResult = await ResultDAL.addResult(uid, dbresult);
|
||||
|
||||
await UserDAL.incrementXp(uid, xpGained.xp);
|
||||
await UserDAL.incrementTestActivity(user, completedEvent.timestamp);
|
||||
|
||||
if (isPb) {
|
||||
void addLog(
|
||||
"user_new_pb",
|
||||
`${completedEvent.mode + " " + completedEvent.mode2} ${
|
||||
completedEvent.wpm
|
||||
} ${completedEvent.acc}% ${completedEvent.rawWpm} ${
|
||||
completedEvent.consistency
|
||||
}% (${addedResult.insertedId})`,
|
||||
uid,
|
||||
);
|
||||
}
|
||||
|
||||
const data: PostResultResponse = {
|
||||
isPb,
|
||||
tagPbs,
|
||||
insertedId: addedResult.insertedId.toHexString(),
|
||||
xp: xpGained.xp,
|
||||
dailyXpBonus: xpGained.dailyBonus ?? false,
|
||||
xpBreakdown: xpGained.breakdown ?? {},
|
||||
streak,
|
||||
};
|
||||
|
||||
if (dailyLeaderboardRank !== -1) {
|
||||
data.dailyLeaderboardRank = dailyLeaderboardRank;
|
||||
}
|
||||
|
||||
if (weeklyXpLeaderboardRank !== -1) {
|
||||
data.weeklyXpLeaderboardRank = weeklyXpLeaderboardRank;
|
||||
}
|
||||
|
||||
incrementResult(completedEvent, dbresult.isPb);
|
||||
|
||||
return new MonkeyResponse("Result saved", data);
|
||||
}
|
||||
|
||||
type XpResult = {
|
||||
xp: number;
|
||||
dailyBonus?: boolean;
|
||||
breakdown?: XpBreakdown;
|
||||
};
|
||||
|
||||
async function calculateXp(
|
||||
result: CompletedEvent,
|
||||
xpConfiguration: Configuration["users"]["xp"],
|
||||
lastResultTimestamp: number | null,
|
||||
currentTotalXp: number,
|
||||
streak: number,
|
||||
): Promise<XpResult> {
|
||||
const {
|
||||
mode,
|
||||
acc,
|
||||
testDuration,
|
||||
incompleteTestSeconds,
|
||||
incompleteTests,
|
||||
afkDuration,
|
||||
charStats,
|
||||
punctuation,
|
||||
numbers,
|
||||
funbox: resultFunboxes,
|
||||
} = result;
|
||||
|
||||
const {
|
||||
enabled,
|
||||
gainMultiplier,
|
||||
maxDailyBonus,
|
||||
minDailyBonus,
|
||||
funboxBonus: funboxBonusConfiguration,
|
||||
} = xpConfiguration;
|
||||
|
||||
if (mode === "zen" || !enabled) {
|
||||
return {
|
||||
xp: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const breakdown: XpBreakdown = {};
|
||||
|
||||
const baseXp = Math.round((testDuration - afkDuration) * 2);
|
||||
breakdown.base = baseXp;
|
||||
|
||||
let modifier = 1;
|
||||
|
||||
const correctedEverything = charStats
|
||||
.slice(1)
|
||||
.every((charStat: number) => charStat === 0);
|
||||
|
||||
if (acc === 100) {
|
||||
modifier += 0.5;
|
||||
breakdown.fullAccuracy = Math.round(baseXp * 0.5);
|
||||
} else if (correctedEverything) {
|
||||
// corrected everything bonus
|
||||
modifier += 0.25;
|
||||
breakdown.corrected = Math.round(baseXp * 0.25);
|
||||
}
|
||||
|
||||
if (mode === "quote") {
|
||||
// real sentences bonus
|
||||
modifier += 0.5;
|
||||
breakdown.quote = Math.round(baseXp * 0.5);
|
||||
} else {
|
||||
// punctuation bonus
|
||||
if (punctuation) {
|
||||
modifier += 0.4;
|
||||
breakdown.punctuation = Math.round(baseXp * 0.4);
|
||||
}
|
||||
if (numbers) {
|
||||
modifier += 0.1;
|
||||
breakdown.numbers = Math.round(baseXp * 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
if (funboxBonusConfiguration > 0 && resultFunboxes.length !== 0) {
|
||||
const funboxModifier = resultFunboxes.reduce((sum, funboxName) => {
|
||||
const funbox = getFunbox(funboxName);
|
||||
const difficultyLevel = funbox?.difficultyLevel ?? 0;
|
||||
return sum + Math.max(difficultyLevel * funboxBonusConfiguration, 0);
|
||||
}, 0);
|
||||
|
||||
if (funboxModifier > 0) {
|
||||
modifier += funboxModifier;
|
||||
breakdown.funbox = Math.round(baseXp * funboxModifier);
|
||||
}
|
||||
}
|
||||
|
||||
if (xpConfiguration.streak.enabled) {
|
||||
const streakModifier = parseFloat(
|
||||
mapRange(
|
||||
streak,
|
||||
0,
|
||||
xpConfiguration.streak.maxStreakDays,
|
||||
0,
|
||||
xpConfiguration.streak.maxStreakMultiplier,
|
||||
true,
|
||||
).toFixed(1),
|
||||
);
|
||||
|
||||
if (streakModifier > 0) {
|
||||
modifier += streakModifier;
|
||||
breakdown.streak = Math.round(baseXp * streakModifier);
|
||||
}
|
||||
}
|
||||
|
||||
let incompleteXp = 0;
|
||||
if (incompleteTests !== undefined && incompleteTests.length > 0) {
|
||||
incompleteTests.forEach((it: { acc: number; seconds: number }) => {
|
||||
let mod = (it.acc - 50) / 50;
|
||||
if (mod < 0) mod = 0;
|
||||
incompleteXp += Math.round(it.seconds * mod);
|
||||
});
|
||||
breakdown.incomplete = incompleteXp;
|
||||
} else if (incompleteTestSeconds && incompleteTestSeconds > 0) {
|
||||
incompleteXp = Math.round(incompleteTestSeconds);
|
||||
breakdown.incomplete = incompleteXp;
|
||||
}
|
||||
|
||||
const accuracyModifier = (acc - 50) / 50;
|
||||
|
||||
let dailyBonus = 0;
|
||||
if (isSafeNumber(lastResultTimestamp)) {
|
||||
const lastResultDay = getStartOfDayTimestamp(lastResultTimestamp);
|
||||
const today = getCurrentDayTimestamp();
|
||||
if (lastResultDay !== today) {
|
||||
const proportionalXp = Math.round(currentTotalXp * 0.05);
|
||||
dailyBonus = Math.max(
|
||||
Math.min(maxDailyBonus, proportionalXp),
|
||||
minDailyBonus,
|
||||
);
|
||||
breakdown.daily = dailyBonus;
|
||||
}
|
||||
}
|
||||
|
||||
const xpWithModifiers = Math.round(baseXp * modifier);
|
||||
|
||||
const xpAfterAccuracy = Math.round(xpWithModifiers * accuracyModifier);
|
||||
breakdown.accPenalty = xpWithModifiers - xpAfterAccuracy;
|
||||
|
||||
const totalXp =
|
||||
Math.round((xpAfterAccuracy + incompleteXp) * gainMultiplier) + dailyBonus;
|
||||
|
||||
if (gainMultiplier > 1) {
|
||||
// breakdown.push([
|
||||
// "configMultiplier",
|
||||
// Math.round((xpAfterAccuracy + incompleteXp) * (gainMultiplier - 1)),
|
||||
// ]);
|
||||
breakdown.configMultiplier = gainMultiplier;
|
||||
}
|
||||
|
||||
const isAwardingDailyBonus = dailyBonus > 0;
|
||||
|
||||
return {
|
||||
xp: totalXp,
|
||||
dailyBonus: isAwardingDailyBonus,
|
||||
breakdown,
|
||||
};
|
||||
}
|
||||
1310
backend/src/api/controllers/user.ts
Normal file
1310
backend/src/api/controllers/user.ts
Normal file
File diff suppressed because it is too large
Load Diff
22
backend/src/api/controllers/webhooks.ts
Normal file
22
backend/src/api/controllers/webhooks.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { PostGithubReleaseRequest } from "@monkeytype/contracts/webhooks";
|
||||
import GeorgeQueue from "../../queues/george-queue";
|
||||
import { MonkeyResponse } from "../../utils/monkey-response";
|
||||
import MonkeyError from "../../utils/error";
|
||||
import { MonkeyRequest } from "../types";
|
||||
|
||||
export async function githubRelease(
|
||||
req: MonkeyRequest<undefined, PostGithubReleaseRequest>,
|
||||
): Promise<MonkeyResponse> {
|
||||
const action = req.body.action;
|
||||
|
||||
if (action === "published") {
|
||||
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", null);
|
||||
}
|
||||
return new MonkeyResponse("No action taken", null);
|
||||
}
|
||||
30
backend/src/api/routes/admin.ts
Normal file
30
backend/src/api/routes/admin.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
// import joi from "joi";
|
||||
|
||||
import * as AdminController from "../controllers/admin";
|
||||
import { adminContract } from "@monkeytype/contracts/admin";
|
||||
import { initServer } from "@ts-rest/express";
|
||||
import { callController } from "../ts-rest-adapter";
|
||||
|
||||
const s = initServer();
|
||||
export default s.router(adminContract, {
|
||||
test: {
|
||||
handler: async (r) => callController(AdminController.test)(r),
|
||||
},
|
||||
toggleBan: {
|
||||
handler: async (r) => callController(AdminController.toggleBan)(r),
|
||||
},
|
||||
clearStreakHourOffset: {
|
||||
handler: async (r) =>
|
||||
callController(AdminController.clearStreakHourOffset)(r),
|
||||
},
|
||||
acceptReports: {
|
||||
handler: async (r) => callController(AdminController.acceptReports)(r),
|
||||
},
|
||||
rejectReports: {
|
||||
handler: async (r) => callController(AdminController.rejectReports)(r),
|
||||
},
|
||||
sendForgotPasswordEmail: {
|
||||
handler: async (r) =>
|
||||
callController(AdminController.sendForgotPasswordEmail)(r),
|
||||
},
|
||||
});
|
||||
20
backend/src/api/routes/ape-keys.ts
Normal file
20
backend/src/api/routes/ape-keys.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { apeKeysContract } from "@monkeytype/contracts/ape-keys";
|
||||
import { initServer } from "@ts-rest/express";
|
||||
import * as ApeKeyController from "../controllers/ape-key";
|
||||
import { callController } from "../ts-rest-adapter";
|
||||
|
||||
const s = initServer();
|
||||
export default s.router(apeKeysContract, {
|
||||
get: {
|
||||
handler: async (r) => callController(ApeKeyController.getApeKeys)(r),
|
||||
},
|
||||
add: {
|
||||
handler: async (r) => callController(ApeKeyController.generateApeKey)(r),
|
||||
},
|
||||
save: {
|
||||
handler: async (r) => callController(ApeKeyController.editApeKey)(r),
|
||||
},
|
||||
delete: {
|
||||
handler: async (r) => callController(ApeKeyController.deleteApeKey)(r),
|
||||
},
|
||||
});
|
||||
18
backend/src/api/routes/configs.ts
Normal file
18
backend/src/api/routes/configs.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { configsContract } from "@monkeytype/contracts/configs";
|
||||
import { initServer } from "@ts-rest/express";
|
||||
import * as ConfigController from "../controllers/config";
|
||||
import { callController } from "../ts-rest-adapter";
|
||||
|
||||
const s = initServer();
|
||||
|
||||
export default s.router(configsContract, {
|
||||
get: {
|
||||
handler: async (r) => callController(ConfigController.getConfig)(r),
|
||||
},
|
||||
save: {
|
||||
handler: async (r) => callController(ConfigController.saveConfig)(r),
|
||||
},
|
||||
delete: {
|
||||
handler: async (r) => callController(ConfigController.deleteConfig)(r),
|
||||
},
|
||||
});
|
||||
20
backend/src/api/routes/configuration.ts
Normal file
20
backend/src/api/routes/configuration.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { configurationContract } from "@monkeytype/contracts/configuration";
|
||||
import { initServer } from "@ts-rest/express";
|
||||
import * as ConfigurationController from "../controllers/configuration";
|
||||
import { callController } from "../ts-rest-adapter";
|
||||
|
||||
const s = initServer();
|
||||
|
||||
export default s.router(configurationContract, {
|
||||
get: {
|
||||
handler: async (r) =>
|
||||
callController(ConfigurationController.getConfiguration)(r),
|
||||
},
|
||||
update: {
|
||||
handler: async (r) =>
|
||||
callController(ConfigurationController.updateConfiguration)(r),
|
||||
},
|
||||
getSchema: {
|
||||
handler: async (r) => callController(ConfigurationController.getSchema)(r),
|
||||
},
|
||||
});
|
||||
25
backend/src/api/routes/connections.ts
Normal file
25
backend/src/api/routes/connections.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { connectionsContract } from "@monkeytype/contracts/connections";
|
||||
import { initServer } from "@ts-rest/express";
|
||||
import { callController } from "../ts-rest-adapter";
|
||||
|
||||
import * as ConnectionsController from "../controllers/connections";
|
||||
|
||||
const s = initServer();
|
||||
export default s.router(connectionsContract, {
|
||||
get: {
|
||||
handler: async (r) =>
|
||||
callController(ConnectionsController.getConnections)(r),
|
||||
},
|
||||
create: {
|
||||
handler: async (r) =>
|
||||
callController(ConnectionsController.createConnection)(r),
|
||||
},
|
||||
delete: {
|
||||
handler: async (r) =>
|
||||
callController(ConnectionsController.deleteConnection)(r),
|
||||
},
|
||||
update: {
|
||||
handler: async (r) =>
|
||||
callController(ConnectionsController.updateConnection)(r),
|
||||
},
|
||||
});
|
||||
19
backend/src/api/routes/dev.ts
Normal file
19
backend/src/api/routes/dev.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { devContract } from "@monkeytype/contracts/dev";
|
||||
import { initServer } from "@ts-rest/express";
|
||||
|
||||
import * as DevController from "../controllers/dev";
|
||||
import { callController } from "../ts-rest-adapter";
|
||||
import { onlyAvailableOnDev } from "../../middlewares/utility";
|
||||
|
||||
const s = initServer();
|
||||
|
||||
export default s.router(devContract, {
|
||||
generateData: {
|
||||
middleware: [onlyAvailableOnDev()],
|
||||
handler: async (r) => callController(DevController.createTestData)(r),
|
||||
},
|
||||
addDebugInboxItem: {
|
||||
middleware: [onlyAvailableOnDev()],
|
||||
handler: async (r) => callController(DevController.addDebugInboxItem)(r),
|
||||
},
|
||||
});
|
||||
34
backend/src/api/routes/docs.ts
Normal file
34
backend/src/api/routes/docs.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { Response, Router } from "express";
|
||||
|
||||
const router = Router();
|
||||
|
||||
const root = __dirname + "/../../../dist/static";
|
||||
|
||||
router.use("/internal", (req, res) => {
|
||||
setCsp(res);
|
||||
res.sendFile("api/internal.html", { root });
|
||||
});
|
||||
|
||||
router.use("/internal.json", (req, res) => {
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
res.sendFile("api/openapi.json", { root });
|
||||
});
|
||||
|
||||
router.use(["/public", "/"], (req, res) => {
|
||||
setCsp(res);
|
||||
res.sendFile("api/public.html", { root });
|
||||
});
|
||||
|
||||
router.use("/public.json", (req, res) => {
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
res.sendFile("api/public.json", { root });
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
function setCsp(res: Response): void {
|
||||
res.setHeader(
|
||||
"Content-Security-Policy",
|
||||
"default-src 'self';base-uri 'self';block-all-mixed-content;font-src 'self' https: data:;frame-ancestors 'self';img-src 'self' monkeytype.com cdn.redocly.com data:;object-src 'none';script-src 'self' cdn.redocly.com 'unsafe-inline'; worker-src blob: data;script-src-attr 'none';style-src 'self' https: 'unsafe-inline';upgrade-insecure-requests",
|
||||
);
|
||||
}
|
||||
195
backend/src/api/routes/index.ts
Normal file
195
backend/src/api/routes/index.ts
Normal file
@@ -0,0 +1,195 @@
|
||||
import { contract } from "@monkeytype/contracts/index";
|
||||
import psas from "./psas";
|
||||
import publicStats from "./public";
|
||||
import users from "./users";
|
||||
import { join } from "path";
|
||||
import quotes from "./quotes";
|
||||
import results from "./results";
|
||||
import presets from "./presets";
|
||||
import apeKeys from "./ape-keys";
|
||||
import admin from "./admin";
|
||||
import docs from "./docs";
|
||||
import webhooks from "./webhooks";
|
||||
import dev from "./dev";
|
||||
import configs from "./configs";
|
||||
import configuration from "./configuration";
|
||||
import { version } from "../../version";
|
||||
import leaderboards from "./leaderboards";
|
||||
import connections from "./connections";
|
||||
import addSwaggerMiddlewares from "./swagger";
|
||||
import { MonkeyResponse } from "../../utils/monkey-response";
|
||||
import {
|
||||
Application,
|
||||
IRouter,
|
||||
NextFunction,
|
||||
Response,
|
||||
static as expressStatic,
|
||||
} from "express";
|
||||
import { isDevEnvironment } from "../../utils/misc";
|
||||
import { getLiveConfiguration } from "../../init/configuration";
|
||||
import Logger from "../../utils/logger";
|
||||
import { createExpressEndpoints, initServer } from "@ts-rest/express";
|
||||
import { ZodIssue } from "zod";
|
||||
import { MonkeyValidationError } from "@monkeytype/contracts/util/api";
|
||||
import { authenticateTsRestRequest } from "../../middlewares/auth";
|
||||
import { rateLimitRequest } from "../../middlewares/rate-limit";
|
||||
import { verifyPermissions } from "../../middlewares/permission";
|
||||
import { verifyRequiredConfiguration } from "../../middlewares/configuration";
|
||||
import { ExpressRequestWithContext } from "../types";
|
||||
|
||||
const pathOverride = process.env["API_PATH_OVERRIDE"];
|
||||
const BASE_ROUTE = pathOverride !== undefined ? `/${pathOverride}` : "";
|
||||
const APP_START_TIME = Date.now();
|
||||
|
||||
const API_ROUTE_MAP = {
|
||||
"/docs": docs,
|
||||
};
|
||||
|
||||
const s = initServer();
|
||||
const router = s.router(contract, {
|
||||
admin,
|
||||
apeKeys,
|
||||
configs,
|
||||
presets,
|
||||
psas,
|
||||
public: publicStats,
|
||||
leaderboards,
|
||||
results,
|
||||
configuration,
|
||||
dev,
|
||||
users,
|
||||
quotes,
|
||||
webhooks,
|
||||
connections,
|
||||
});
|
||||
|
||||
export function addApiRoutes(app: Application): void {
|
||||
applyDevApiRoutes(app);
|
||||
applyApiRoutes(app);
|
||||
applyTsRestApiRoutes(app);
|
||||
|
||||
app.use((req, res) => {
|
||||
res
|
||||
.status(404)
|
||||
.json(
|
||||
new MonkeyResponse(
|
||||
`Unknown request URL (${req.method}: ${req.path})`,
|
||||
null,
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function applyTsRestApiRoutes(app: IRouter): void {
|
||||
createExpressEndpoints(contract, router, app, {
|
||||
jsonQuery: true,
|
||||
requestValidationErrorHandler(err, req, res, _next) {
|
||||
let message: string | undefined = undefined;
|
||||
let validationErrors: string[] | undefined = undefined;
|
||||
|
||||
if (err.pathParams?.issues !== undefined) {
|
||||
message = "Invalid path parameter schema";
|
||||
validationErrors = err.pathParams.issues.map(prettyErrorMessage);
|
||||
} else if (err.query?.issues !== undefined) {
|
||||
message = "Invalid query schema";
|
||||
validationErrors = err.query.issues.map(prettyErrorMessage);
|
||||
} else if (err.body?.issues !== undefined) {
|
||||
message = "Invalid request data schema";
|
||||
validationErrors = err.body.issues.map(prettyErrorMessage);
|
||||
} else if (err.headers?.issues !== undefined) {
|
||||
message = "Invalid header schema";
|
||||
validationErrors = err.headers.issues.map(prettyErrorMessage);
|
||||
} else {
|
||||
Logger.error(
|
||||
`Unknown validation error for ${req.method} ${
|
||||
req.path
|
||||
}: ${JSON.stringify(err)}`,
|
||||
);
|
||||
res
|
||||
.status(500)
|
||||
.json({ message: "Unknown validation error. Contact support." });
|
||||
return;
|
||||
}
|
||||
|
||||
res
|
||||
.status(422)
|
||||
.json({ message, validationErrors } as MonkeyValidationError);
|
||||
},
|
||||
globalMiddleware: [
|
||||
authenticateTsRestRequest(),
|
||||
rateLimitRequest(),
|
||||
verifyRequiredConfiguration(),
|
||||
verifyPermissions(),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
function prettyErrorMessage(issue: ZodIssue | undefined): string {
|
||||
if (issue === undefined) return "";
|
||||
const path = issue.path.length > 0 ? `"${issue.path.join(".")}" ` : "";
|
||||
return `${path}${issue.message}`;
|
||||
}
|
||||
|
||||
function applyDevApiRoutes(app: Application): void {
|
||||
if (isDevEnvironment()) {
|
||||
//disable csp to allow assets to load from unsecured http
|
||||
app.use((req, res, next) => {
|
||||
res.setHeader("Content-Security-Policy", "");
|
||||
next();
|
||||
});
|
||||
app.use("/configure", expressStatic(join(__dirname, "../../../private")));
|
||||
|
||||
app.use(async (req, res, next) => {
|
||||
const slowdown = (await getLiveConfiguration()).dev.responseSlowdownMs;
|
||||
if (slowdown > 0) {
|
||||
Logger.info(
|
||||
`Simulating ${slowdown}ms delay for ${req.method} ${req.path}`,
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, slowdown));
|
||||
}
|
||||
next();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function applyApiRoutes(app: Application): void {
|
||||
addSwaggerMiddlewares(app);
|
||||
|
||||
app.use(
|
||||
(
|
||||
req: ExpressRequestWithContext,
|
||||
res: Response,
|
||||
next: NextFunction,
|
||||
): void => {
|
||||
if (req.path.startsWith("/configuration")) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
const inMaintenance =
|
||||
process.env["MAINTENANCE"] === "true" ||
|
||||
req.ctx.configuration.maintenance;
|
||||
|
||||
if (inMaintenance) {
|
||||
res.status(503).json({ message: "Server is down for maintenance" });
|
||||
return;
|
||||
}
|
||||
|
||||
next();
|
||||
},
|
||||
);
|
||||
|
||||
app.get("/", (_req, res) => {
|
||||
res.status(200).json(
|
||||
new MonkeyResponse("ok", {
|
||||
uptime: Date.now() - APP_START_TIME,
|
||||
version,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
for (const [route, mapRouter] of Object.entries(API_ROUTE_MAP)) {
|
||||
const apiRoute = `${BASE_ROUTE}${route}`;
|
||||
app.use(apiRoute, mapRouter);
|
||||
}
|
||||
}
|
||||
32
backend/src/api/routes/leaderboards.ts
Normal file
32
backend/src/api/routes/leaderboards.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { initServer } from "@ts-rest/express";
|
||||
import * as LeaderboardController from "../controllers/leaderboard";
|
||||
import { leaderboardsContract } from "@monkeytype/contracts/leaderboards";
|
||||
import { callController } from "../ts-rest-adapter";
|
||||
|
||||
const s = initServer();
|
||||
export default s.router(leaderboardsContract, {
|
||||
get: {
|
||||
handler: async (r) =>
|
||||
callController(LeaderboardController.getLeaderboard)(r),
|
||||
},
|
||||
getRank: {
|
||||
handler: async (r) =>
|
||||
callController(LeaderboardController.getRankFromLeaderboard)(r),
|
||||
},
|
||||
getDaily: {
|
||||
handler: async (r) =>
|
||||
callController(LeaderboardController.getDailyLeaderboard)(r),
|
||||
},
|
||||
getDailyRank: {
|
||||
handler: async (r) =>
|
||||
callController(LeaderboardController.getDailyLeaderboardRank)(r),
|
||||
},
|
||||
getWeeklyXp: {
|
||||
handler: async (r) =>
|
||||
callController(LeaderboardController.getWeeklyXpLeaderboard)(r),
|
||||
},
|
||||
getWeeklyXpRank: {
|
||||
handler: async (r) =>
|
||||
callController(LeaderboardController.getWeeklyXpLeaderboardRank)(r),
|
||||
},
|
||||
});
|
||||
20
backend/src/api/routes/presets.ts
Normal file
20
backend/src/api/routes/presets.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { presetsContract } from "@monkeytype/contracts/presets";
|
||||
import { initServer } from "@ts-rest/express";
|
||||
import * as PresetController from "../controllers/preset";
|
||||
import { callController } from "../ts-rest-adapter";
|
||||
|
||||
const s = initServer();
|
||||
export default s.router(presetsContract, {
|
||||
get: {
|
||||
handler: async (r) => callController(PresetController.getPresets)(r),
|
||||
},
|
||||
add: {
|
||||
handler: async (r) => callController(PresetController.addPreset)(r),
|
||||
},
|
||||
save: {
|
||||
handler: async (r) => callController(PresetController.editPreset)(r),
|
||||
},
|
||||
delete: {
|
||||
handler: async (r) => callController(PresetController.removePreset)(r),
|
||||
},
|
||||
});
|
||||
13
backend/src/api/routes/psas.ts
Normal file
13
backend/src/api/routes/psas.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { psasContract } from "@monkeytype/contracts/psas";
|
||||
import { initServer } from "@ts-rest/express";
|
||||
import * as PsaController from "../controllers/psa";
|
||||
import { callController } from "../ts-rest-adapter";
|
||||
import { recordClientVersion } from "../../middlewares/utility";
|
||||
|
||||
const s = initServer();
|
||||
export default s.router(psasContract, {
|
||||
get: {
|
||||
middleware: [recordClientVersion()],
|
||||
handler: async (r) => callController(PsaController.getPsas)(r),
|
||||
},
|
||||
});
|
||||
14
backend/src/api/routes/public.ts
Normal file
14
backend/src/api/routes/public.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { publicContract } from "@monkeytype/contracts/public";
|
||||
import { initServer } from "@ts-rest/express";
|
||||
import * as PublicController from "../controllers/public";
|
||||
import { callController } from "../ts-rest-adapter";
|
||||
|
||||
const s = initServer();
|
||||
export default s.router(publicContract, {
|
||||
getSpeedHistogram: {
|
||||
handler: async (r) => callController(PublicController.getSpeedHistogram)(r),
|
||||
},
|
||||
getTypingStats: {
|
||||
handler: async (r) => callController(PublicController.getTypingStats)(r),
|
||||
},
|
||||
});
|
||||
33
backend/src/api/routes/quotes.ts
Normal file
33
backend/src/api/routes/quotes.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { quotesContract } from "@monkeytype/contracts/quotes";
|
||||
import { initServer } from "@ts-rest/express";
|
||||
import * as QuoteController from "../controllers/quote";
|
||||
import { callController } from "../ts-rest-adapter";
|
||||
|
||||
const s = initServer();
|
||||
export default s.router(quotesContract, {
|
||||
get: {
|
||||
handler: async (r) => callController(QuoteController.getQuotes)(r),
|
||||
},
|
||||
isSubmissionEnabled: {
|
||||
handler: async (r) =>
|
||||
callController(QuoteController.isSubmissionEnabled)(r),
|
||||
},
|
||||
add: {
|
||||
handler: async (r) => callController(QuoteController.addQuote)(r),
|
||||
},
|
||||
approveSubmission: {
|
||||
handler: async (r) => callController(QuoteController.approveQuote)(r),
|
||||
},
|
||||
rejectSubmission: {
|
||||
handler: async (r) => callController(QuoteController.refuseQuote)(r),
|
||||
},
|
||||
getRating: {
|
||||
handler: async (r) => callController(QuoteController.getRating)(r),
|
||||
},
|
||||
addRating: {
|
||||
handler: async (r) => callController(QuoteController.submitRating)(r),
|
||||
},
|
||||
report: {
|
||||
handler: async (r) => callController(QuoteController.reportQuote)(r),
|
||||
},
|
||||
});
|
||||
26
backend/src/api/routes/results.ts
Normal file
26
backend/src/api/routes/results.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { resultsContract } from "@monkeytype/contracts/results";
|
||||
import { initServer } from "@ts-rest/express";
|
||||
import * as ResultController from "../controllers/result";
|
||||
import { callController } from "../ts-rest-adapter";
|
||||
|
||||
const s = initServer();
|
||||
export default s.router(resultsContract, {
|
||||
get: {
|
||||
handler: async (r) => callController(ResultController.getResults)(r),
|
||||
},
|
||||
getById: {
|
||||
handler: async (r) => callController(ResultController.getResultById)(r),
|
||||
},
|
||||
add: {
|
||||
handler: async (r) => callController(ResultController.addResult)(r),
|
||||
},
|
||||
updateTags: {
|
||||
handler: async (r) => callController(ResultController.updateTags)(r),
|
||||
},
|
||||
deleteAll: {
|
||||
handler: async (r) => callController(ResultController.deleteAll)(r),
|
||||
},
|
||||
getLast: {
|
||||
handler: async (r) => callController(ResultController.getLastResult)(r),
|
||||
},
|
||||
});
|
||||
39
backend/src/api/routes/swagger.ts
Normal file
39
backend/src/api/routes/swagger.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { Application } from "express";
|
||||
import { getMiddleware as getSwaggerMiddleware } from "swagger-stats";
|
||||
import { isDevEnvironment } from "../../utils/misc";
|
||||
import { readFileSync } from "fs";
|
||||
import Logger from "../../utils/logger";
|
||||
import { tryCatchSync } from "@monkeytype/util/trycatch";
|
||||
|
||||
function addSwaggerMiddlewares(app: Application): void {
|
||||
const openApiSpec = __dirname + "/../../../dist/static/api/openapi.json";
|
||||
|
||||
const { data: spec, error } = tryCatchSync(
|
||||
() =>
|
||||
JSON.parse(readFileSync(openApiSpec, "utf8")) as Record<string, unknown>,
|
||||
);
|
||||
|
||||
if (error) {
|
||||
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: spec ?? {},
|
||||
onAuthenticate: (_req, username, password) => {
|
||||
return (
|
||||
username === process.env["STATS_USERNAME"] &&
|
||||
password === process.env["STATS_PASSWORD"]
|
||||
);
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export default addSwaggerMiddlewares;
|
||||
143
backend/src/api/routes/users.ts
Normal file
143
backend/src/api/routes/users.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
import { usersContract } from "@monkeytype/contracts/users";
|
||||
import { initServer } from "@ts-rest/express";
|
||||
import * as UserController from "../controllers/user";
|
||||
import { callController } from "../ts-rest-adapter";
|
||||
|
||||
const s = initServer();
|
||||
export default s.router(usersContract, {
|
||||
get: {
|
||||
handler: async (r) => callController(UserController.getUser)(r),
|
||||
},
|
||||
create: {
|
||||
handler: async (r) => callController(UserController.createNewUser)(r),
|
||||
},
|
||||
getNameAvailability: {
|
||||
handler: async (r) => callController(UserController.checkName)(r),
|
||||
},
|
||||
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),
|
||||
},
|
||||
getFriends: {
|
||||
handler: async (r) => callController(UserController.getFriends)(r),
|
||||
},
|
||||
});
|
||||
12
backend/src/api/routes/webhooks.ts
Normal file
12
backend/src/api/routes/webhooks.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
// import joi from "joi";
|
||||
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 s = initServer();
|
||||
export default s.router(webhooksContract, {
|
||||
postGithubRelease: {
|
||||
handler: async (r) => callController(WebhooksController.githubRelease)(r),
|
||||
},
|
||||
});
|
||||
78
backend/src/api/ts-rest-adapter.ts
Normal file
78
backend/src/api/ts-rest-adapter.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { AppRoute, AppRouter } from "@ts-rest/core";
|
||||
import { TsRestRequest } from "@ts-rest/express";
|
||||
import { MonkeyResponse } from "../utils/monkey-response";
|
||||
import { Context } from "../middlewares/context";
|
||||
import { MonkeyRequest } from "./types";
|
||||
|
||||
export function callController<
|
||||
TRoute extends AppRoute | AppRouter,
|
||||
TQuery,
|
||||
TBody,
|
||||
TParams,
|
||||
TResponse,
|
||||
//ignoring as it might be used in the future
|
||||
// oxlint-disable-next-line no-unnecessary-type-parameters
|
||||
TStatus = 200,
|
||||
>(
|
||||
handler: MonkeyHandler<TQuery, TBody, TParams, TResponse>,
|
||||
): (all: TypeSafeTsRestRequest<TRoute, TQuery, TBody, TParams>) => Promise<{
|
||||
status: TStatus;
|
||||
body: MonkeyResponse<TResponse>;
|
||||
}> {
|
||||
return async (all) => {
|
||||
const req: MonkeyRequest<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"] as Context,
|
||||
};
|
||||
|
||||
const result = await handler(req);
|
||||
const response = {
|
||||
status: 200 as TStatus,
|
||||
body: {
|
||||
message: result.message,
|
||||
data: result.data,
|
||||
},
|
||||
};
|
||||
|
||||
return response;
|
||||
};
|
||||
}
|
||||
|
||||
type WithBody<T> = {
|
||||
body: T;
|
||||
};
|
||||
type WithQuery<T> = {
|
||||
query: T;
|
||||
};
|
||||
|
||||
type WithParams<T> = {
|
||||
params: T;
|
||||
};
|
||||
|
||||
type WithoutBody = {
|
||||
body?: never;
|
||||
};
|
||||
type WithoutQuery = {
|
||||
query?: never;
|
||||
};
|
||||
type WithoutParams = {
|
||||
params?: never;
|
||||
};
|
||||
|
||||
type MonkeyHandler<TQuery, TBody, TParams, TResponse> = (
|
||||
req: MonkeyRequest<TQuery, TBody, TParams>,
|
||||
) => Promise<MonkeyResponse<TResponse>>;
|
||||
|
||||
type TypeSafeTsRestRequest<
|
||||
TRoute extends AppRoute | AppRouter,
|
||||
TQuery,
|
||||
TBody,
|
||||
TParams,
|
||||
> = {
|
||||
req: TsRestRequest<TRoute>;
|
||||
} & (TQuery extends undefined ? WithoutQuery : WithQuery<TQuery>) &
|
||||
(TBody extends undefined ? WithoutBody : WithBody<TBody>) &
|
||||
(TParams extends undefined ? WithoutParams : WithParams<TParams>);
|
||||
27
backend/src/api/types.ts
Normal file
27
backend/src/api/types.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { TsRestRequest as TsRestRequestGeneric } from "@ts-rest/express";
|
||||
import { Request as ExpressRequest } from "express";
|
||||
import { Context } from "../middlewares/context";
|
||||
|
||||
// oxlint-disable-next-line no-explicit-any
|
||||
export type TsRestRequest = TsRestRequestGeneric<any>;
|
||||
|
||||
export type ExpressRequestWithContext = {
|
||||
ctx: Readonly<Context>;
|
||||
} & ExpressRequest;
|
||||
|
||||
export type TsRestRequestWithContext = {
|
||||
ctx: Readonly<Context>;
|
||||
} & TsRestRequest &
|
||||
ExpressRequest;
|
||||
|
||||
export type MonkeyRequest<
|
||||
TQuery = undefined,
|
||||
TBody = undefined,
|
||||
TParams = undefined,
|
||||
> = {
|
||||
query: Readonly<TQuery>;
|
||||
body: Readonly<TBody>;
|
||||
params: Readonly<TParams>;
|
||||
ctx: Readonly<Context>;
|
||||
raw: Readonly<TsRestRequest>;
|
||||
};
|
||||
44
backend/src/app.ts
Normal file
44
backend/src/app.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import cors from "cors";
|
||||
import helmet from "helmet";
|
||||
import { addApiRoutes } from "./api/routes";
|
||||
import express, { urlencoded, json } from "express";
|
||||
import contextMiddleware from "./middlewares/context";
|
||||
import errorHandlingMiddleware from "./middlewares/error";
|
||||
import {
|
||||
badAuthRateLimiterHandler,
|
||||
rootRateLimiter,
|
||||
} from "./middlewares/rate-limit";
|
||||
import { compatibilityCheckMiddleware } from "./middlewares/compatibilityCheck";
|
||||
import { COMPATIBILITY_CHECK_HEADER } from "@monkeytype/contracts";
|
||||
import { createETagGenerator } from "./utils/etag";
|
||||
import { v4RequestBody } from "./middlewares/utility";
|
||||
|
||||
const etagFn = createETagGenerator({ weak: true });
|
||||
|
||||
function buildApp(): express.Application {
|
||||
const app = express();
|
||||
|
||||
app.use(urlencoded({ extended: true }));
|
||||
app.use(json());
|
||||
app.use(cors({ exposedHeaders: [COMPATIBILITY_CHECK_HEADER] }));
|
||||
app.use(helmet());
|
||||
|
||||
app.set("trust proxy", 1);
|
||||
|
||||
app.use(compatibilityCheckMiddleware);
|
||||
app.use(contextMiddleware);
|
||||
|
||||
app.use(badAuthRateLimiterHandler);
|
||||
app.use(rootRateLimiter);
|
||||
app.use(v4RequestBody);
|
||||
|
||||
app.set("etag", etagFn);
|
||||
|
||||
addApiRoutes(app);
|
||||
|
||||
app.use(errorHandlingMiddleware);
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
export default buildApp();
|
||||
38
backend/src/constants/auto-roles.ts
Normal file
38
backend/src/constants/auto-roles.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
export default [
|
||||
"oneHourWarrior",
|
||||
"doubleDown",
|
||||
"tripleTrouble",
|
||||
"quad",
|
||||
"trueSimp",
|
||||
"bigramSalad",
|
||||
"simp",
|
||||
"antidiseWhat",
|
||||
"whatsThisWebsiteCalledAgain",
|
||||
"developd",
|
||||
"slowAndSteady",
|
||||
"speedSpacer",
|
||||
"iveGotThePower",
|
||||
"accuracyExpert",
|
||||
"accuracyMaster",
|
||||
"accuracyGod",
|
||||
"jolly",
|
||||
"gottaCatchEmAll",
|
||||
"rapGod",
|
||||
"navySeal",
|
||||
"rollercoaster",
|
||||
"oneHourMirror",
|
||||
"chooChoo",
|
||||
"earfquake",
|
||||
"simonSez",
|
||||
"accountant",
|
||||
"hidden",
|
||||
"iCanSeeTheFuture",
|
||||
"whatAreWordsAtThisPoint",
|
||||
"specials",
|
||||
"aeiou",
|
||||
"asciiWarrior",
|
||||
"iKiNdAlIkEhOwInEfFiCiEnTqWeRtYiS",
|
||||
"oneNauseousMonkey",
|
||||
"69",
|
||||
"englishMaster",
|
||||
];
|
||||
619
backend/src/constants/base-configuration.ts
Normal file
619
backend/src/constants/base-configuration.ts
Normal file
@@ -0,0 +1,619 @@
|
||||
import { Configuration } from "@monkeytype/schemas/configuration";
|
||||
|
||||
/**
|
||||
* This is the base schema for the configuration of the API backend.
|
||||
* To add a new configuration. Simply add it to this object.
|
||||
* When changing this template, please follow the principle of "Secure by default" (https://en.wikipedia.org/wiki/Secure_by_default).
|
||||
*/
|
||||
export const BASE_CONFIGURATION: Configuration = {
|
||||
maintenance: false,
|
||||
dev: {
|
||||
responseSlowdownMs: 0,
|
||||
},
|
||||
results: {
|
||||
savingEnabled: false,
|
||||
objectHashCheckEnabled: false,
|
||||
filterPresets: {
|
||||
enabled: false,
|
||||
maxPresetsPerUser: 0,
|
||||
},
|
||||
limits: {
|
||||
regularUser: 1000,
|
||||
premiumUser: 10000,
|
||||
},
|
||||
maxBatchSize: 1000,
|
||||
},
|
||||
quotes: {
|
||||
reporting: {
|
||||
enabled: false,
|
||||
maxReports: 0,
|
||||
contentReportLimit: 0,
|
||||
},
|
||||
submissionsEnabled: false,
|
||||
maxFavorites: 0,
|
||||
},
|
||||
admin: {
|
||||
endpointsEnabled: false,
|
||||
},
|
||||
apeKeys: {
|
||||
endpointsEnabled: false,
|
||||
acceptKeys: false,
|
||||
maxKeysPerUser: 0,
|
||||
apeKeyBytes: 24,
|
||||
apeKeySaltRounds: 5,
|
||||
},
|
||||
users: {
|
||||
signUp: false,
|
||||
lastHashesCheck: {
|
||||
enabled: false,
|
||||
maxHashes: 0,
|
||||
},
|
||||
discordIntegration: {
|
||||
enabled: false,
|
||||
},
|
||||
autoBan: {
|
||||
enabled: false,
|
||||
maxCount: 5,
|
||||
maxHours: 1,
|
||||
},
|
||||
profiles: {
|
||||
enabled: false,
|
||||
},
|
||||
xp: {
|
||||
enabled: false,
|
||||
funboxBonus: 0,
|
||||
gainMultiplier: 0,
|
||||
maxDailyBonus: 0,
|
||||
minDailyBonus: 0,
|
||||
streak: {
|
||||
enabled: false,
|
||||
maxStreakDays: 0,
|
||||
maxStreakMultiplier: 0,
|
||||
},
|
||||
},
|
||||
inbox: {
|
||||
enabled: false,
|
||||
maxMail: 0,
|
||||
},
|
||||
premium: {
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
rateLimiting: {
|
||||
badAuthentication: {
|
||||
enabled: false,
|
||||
penalty: 0,
|
||||
flaggedStatusCodes: [],
|
||||
},
|
||||
},
|
||||
dailyLeaderboards: {
|
||||
enabled: false,
|
||||
maxResults: 0,
|
||||
leaderboardExpirationTimeInDays: 0,
|
||||
validModeRules: [],
|
||||
scheduleRewardsModeRules: [],
|
||||
topResultsToAnnounce: 1, // This should never be 0. Setting to zero will announce all results.
|
||||
xpRewardBrackets: [],
|
||||
},
|
||||
leaderboards: {
|
||||
minTimeTyping: 2 * 60 * 60,
|
||||
weeklyXp: {
|
||||
enabled: false,
|
||||
expirationTimeInDays: 0, // This should atleast be 15
|
||||
xpRewardBrackets: [],
|
||||
},
|
||||
},
|
||||
connections: { enabled: false, maxPerUser: 100 },
|
||||
};
|
||||
|
||||
type BaseSchema = {
|
||||
type: string;
|
||||
label?: string;
|
||||
hint?: string;
|
||||
};
|
||||
|
||||
type NumberSchema = {
|
||||
type: "number";
|
||||
min?: number;
|
||||
} & BaseSchema;
|
||||
|
||||
type BooleanSchema = {
|
||||
type: "boolean";
|
||||
} & BaseSchema;
|
||||
|
||||
type StringSchema = {
|
||||
type: "string";
|
||||
} & BaseSchema;
|
||||
|
||||
type ArraySchema<T extends unknown[]> = {
|
||||
type: "array";
|
||||
items: Schema<T>[number];
|
||||
} & BaseSchema;
|
||||
|
||||
type ObjectSchema<T> = {
|
||||
type: "object";
|
||||
fields: Schema<T>;
|
||||
} & BaseSchema;
|
||||
|
||||
type Schema<T> = {
|
||||
[P in keyof T]: T[P] extends unknown[]
|
||||
? ArraySchema<T[P]>
|
||||
: T[P] extends number
|
||||
? NumberSchema
|
||||
: T[P] extends boolean
|
||||
? BooleanSchema
|
||||
: T[P] extends string
|
||||
? StringSchema
|
||||
: T[P] extends object
|
||||
? ObjectSchema<T[P]>
|
||||
: never;
|
||||
};
|
||||
|
||||
export const CONFIGURATION_FORM_SCHEMA: ObjectSchema<Configuration> = {
|
||||
type: "object",
|
||||
label: "Server Configuration",
|
||||
fields: {
|
||||
maintenance: {
|
||||
type: "boolean",
|
||||
label: "In Maintenance",
|
||||
},
|
||||
dev: {
|
||||
type: "object",
|
||||
label: "Development",
|
||||
fields: {
|
||||
responseSlowdownMs: {
|
||||
type: "number",
|
||||
label: "Response Slowdown (miliseconds)",
|
||||
min: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
results: {
|
||||
type: "object",
|
||||
label: "Results",
|
||||
fields: {
|
||||
savingEnabled: {
|
||||
type: "boolean",
|
||||
label: "Saving Results",
|
||||
},
|
||||
objectHashCheckEnabled: {
|
||||
type: "boolean",
|
||||
label: "Object Hash Check",
|
||||
},
|
||||
filterPresets: {
|
||||
type: "object",
|
||||
label: "Filter Presets",
|
||||
fields: {
|
||||
enabled: {
|
||||
type: "boolean",
|
||||
label: "Enabled",
|
||||
},
|
||||
maxPresetsPerUser: {
|
||||
type: "number",
|
||||
label: "Max Presets Per User",
|
||||
min: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
limits: {
|
||||
type: "object",
|
||||
label: "maximum results",
|
||||
fields: {
|
||||
regularUser: {
|
||||
type: "number",
|
||||
label: "for regular users",
|
||||
min: 0,
|
||||
},
|
||||
premiumUser: {
|
||||
type: "number",
|
||||
label: "for premium users",
|
||||
min: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
maxBatchSize: {
|
||||
type: "number",
|
||||
label: "results endpoint max batch size",
|
||||
min: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
quotes: {
|
||||
type: "object",
|
||||
label: "Quotes",
|
||||
fields: {
|
||||
reporting: {
|
||||
type: "object",
|
||||
label: "Reporting",
|
||||
fields: {
|
||||
enabled: {
|
||||
type: "boolean",
|
||||
label: "Enabled",
|
||||
},
|
||||
maxReports: {
|
||||
type: "number",
|
||||
label: "Max Reports",
|
||||
},
|
||||
contentReportLimit: {
|
||||
type: "number",
|
||||
label: "Content Report Limit",
|
||||
},
|
||||
},
|
||||
},
|
||||
submissionsEnabled: {
|
||||
type: "boolean",
|
||||
label: "Submissions Enabled",
|
||||
},
|
||||
maxFavorites: {
|
||||
type: "number",
|
||||
label: "Max Favorites",
|
||||
},
|
||||
},
|
||||
},
|
||||
admin: {
|
||||
type: "object",
|
||||
label: "Admin",
|
||||
fields: {
|
||||
endpointsEnabled: {
|
||||
type: "boolean",
|
||||
label: "Endpoints Enabled",
|
||||
},
|
||||
},
|
||||
},
|
||||
apeKeys: {
|
||||
type: "object",
|
||||
label: "Ape Keys",
|
||||
fields: {
|
||||
endpointsEnabled: {
|
||||
type: "boolean",
|
||||
label: "Endpoints Enabled",
|
||||
},
|
||||
acceptKeys: {
|
||||
type: "boolean",
|
||||
label: "Accept Keys",
|
||||
},
|
||||
maxKeysPerUser: {
|
||||
type: "number",
|
||||
label: "Max Keys Per User",
|
||||
min: 0,
|
||||
},
|
||||
apeKeyBytes: {
|
||||
type: "number",
|
||||
label: "Ape Key Bytes",
|
||||
min: 24,
|
||||
},
|
||||
apeKeySaltRounds: {
|
||||
type: "number",
|
||||
label: "Ape Key Salt Rounds",
|
||||
min: 5,
|
||||
},
|
||||
},
|
||||
},
|
||||
users: {
|
||||
type: "object",
|
||||
label: "Users",
|
||||
fields: {
|
||||
premium: {
|
||||
type: "object",
|
||||
label: "Premium",
|
||||
fields: {
|
||||
enabled: {
|
||||
type: "boolean",
|
||||
label: "Enabled",
|
||||
},
|
||||
},
|
||||
},
|
||||
signUp: {
|
||||
type: "boolean",
|
||||
label: "Sign Up Enabled",
|
||||
},
|
||||
lastHashesCheck: {
|
||||
type: "object",
|
||||
label: "Last Hashes Check",
|
||||
fields: {
|
||||
enabled: { type: "boolean", label: "Enabled" },
|
||||
maxHashes: { type: "number", label: "Hashes to store" },
|
||||
},
|
||||
},
|
||||
xp: {
|
||||
type: "object",
|
||||
label: "XP",
|
||||
fields: {
|
||||
enabled: {
|
||||
type: "boolean",
|
||||
label: "Enabled",
|
||||
},
|
||||
gainMultiplier: {
|
||||
type: "number",
|
||||
label: "Gain Multiplier",
|
||||
},
|
||||
funboxBonus: {
|
||||
type: "number",
|
||||
label: "Funbox Bonus",
|
||||
},
|
||||
maxDailyBonus: {
|
||||
type: "number",
|
||||
label: "Max Daily Bonus",
|
||||
},
|
||||
minDailyBonus: {
|
||||
type: "number",
|
||||
label: "Min Daily Bonus",
|
||||
},
|
||||
streak: {
|
||||
type: "object",
|
||||
label: "Streak",
|
||||
fields: {
|
||||
enabled: {
|
||||
type: "boolean",
|
||||
label: "Enabled",
|
||||
},
|
||||
maxStreakDays: {
|
||||
type: "number",
|
||||
label: "Max Streak Days",
|
||||
},
|
||||
maxStreakMultiplier: {
|
||||
type: "number",
|
||||
label: "Max Streak Multiplier",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
discordIntegration: {
|
||||
type: "object",
|
||||
label: "Discord Integration",
|
||||
fields: {
|
||||
enabled: {
|
||||
type: "boolean",
|
||||
label: "Enabled",
|
||||
},
|
||||
},
|
||||
},
|
||||
autoBan: {
|
||||
type: "object",
|
||||
label: "Auto Ban",
|
||||
fields: {
|
||||
enabled: {
|
||||
type: "boolean",
|
||||
label: "Enabled",
|
||||
},
|
||||
maxCount: {
|
||||
type: "number",
|
||||
label: "Max Count",
|
||||
min: 0,
|
||||
},
|
||||
maxHours: {
|
||||
type: "number",
|
||||
label: "Max Hours",
|
||||
min: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
inbox: {
|
||||
type: "object",
|
||||
label: "Inbox",
|
||||
fields: {
|
||||
enabled: {
|
||||
type: "boolean",
|
||||
label: "Enabled",
|
||||
},
|
||||
maxMail: {
|
||||
type: "number",
|
||||
label: "Max Messages",
|
||||
min: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
profiles: {
|
||||
type: "object",
|
||||
label: "User Profiles",
|
||||
fields: {
|
||||
enabled: {
|
||||
type: "boolean",
|
||||
label: "Enabled",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
rateLimiting: {
|
||||
type: "object",
|
||||
label: "Rate Limiting",
|
||||
fields: {
|
||||
badAuthentication: {
|
||||
type: "object",
|
||||
label: "Bad Authentication Rate Limiter",
|
||||
fields: {
|
||||
enabled: {
|
||||
type: "boolean",
|
||||
label: "Enabled",
|
||||
},
|
||||
penalty: {
|
||||
type: "number",
|
||||
label: "Penalty",
|
||||
min: 0,
|
||||
},
|
||||
flaggedStatusCodes: {
|
||||
type: "array",
|
||||
label: "Flagged Status Codes",
|
||||
items: {
|
||||
label: "Status Code",
|
||||
type: "number",
|
||||
min: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
dailyLeaderboards: {
|
||||
type: "object",
|
||||
label: "Daily Leaderboards",
|
||||
fields: {
|
||||
enabled: {
|
||||
type: "boolean",
|
||||
label: "Enabled",
|
||||
},
|
||||
maxResults: {
|
||||
type: "number",
|
||||
label: "Max Results",
|
||||
min: 0,
|
||||
},
|
||||
leaderboardExpirationTimeInDays: {
|
||||
type: "number",
|
||||
label: "Leaderboard Expiration Time In Days",
|
||||
min: 0,
|
||||
},
|
||||
validModeRules: {
|
||||
type: "array",
|
||||
label: "Valid Mode Rules",
|
||||
items: {
|
||||
type: "object",
|
||||
label: "Rule",
|
||||
fields: {
|
||||
language: {
|
||||
type: "string",
|
||||
label: "Language",
|
||||
},
|
||||
mode: {
|
||||
type: "string",
|
||||
label: "Mode",
|
||||
},
|
||||
mode2: {
|
||||
type: "string",
|
||||
label: "Secondary Mode",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
scheduleRewardsModeRules: {
|
||||
type: "array",
|
||||
label: "Schedule Rewards Mode Rules",
|
||||
items: {
|
||||
type: "object",
|
||||
label: "Rule",
|
||||
fields: {
|
||||
language: {
|
||||
type: "string",
|
||||
label: "Language",
|
||||
},
|
||||
mode: {
|
||||
type: "string",
|
||||
label: "Mode",
|
||||
},
|
||||
mode2: {
|
||||
type: "string",
|
||||
label: "Secondary Mode",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
topResultsToAnnounce: {
|
||||
type: "number",
|
||||
label: "Top Results To Announce",
|
||||
min: 1,
|
||||
hint: "This should atleast be 1. Setting to zero is very bad.",
|
||||
},
|
||||
xpRewardBrackets: {
|
||||
type: "array",
|
||||
label: "XP Reward Brackets",
|
||||
items: {
|
||||
type: "object",
|
||||
label: "Bracket",
|
||||
fields: {
|
||||
minRank: {
|
||||
type: "number",
|
||||
label: "Min Rank",
|
||||
min: 1,
|
||||
},
|
||||
maxRank: {
|
||||
type: "number",
|
||||
label: "Max Rank",
|
||||
min: 1,
|
||||
},
|
||||
minReward: {
|
||||
type: "number",
|
||||
label: "Min Reward",
|
||||
min: 0,
|
||||
},
|
||||
maxReward: {
|
||||
type: "number",
|
||||
label: "Max Reward",
|
||||
min: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
leaderboards: {
|
||||
type: "object",
|
||||
label: "Leaderboards",
|
||||
fields: {
|
||||
minTimeTyping: {
|
||||
type: "number",
|
||||
label: "Minimum typing time the user needs to get on a leaderboard",
|
||||
hint: "Typing time in seconds. Change is only applied after restarting the server.",
|
||||
min: 0,
|
||||
},
|
||||
weeklyXp: {
|
||||
type: "object",
|
||||
label: "Weekly XP",
|
||||
fields: {
|
||||
enabled: {
|
||||
type: "boolean",
|
||||
label: "Enabled",
|
||||
},
|
||||
expirationTimeInDays: {
|
||||
type: "number",
|
||||
label: "Expiration time in days",
|
||||
min: 0,
|
||||
hint: "This should atleast be 15, to allow for past week queries.",
|
||||
},
|
||||
xpRewardBrackets: {
|
||||
type: "array",
|
||||
label: "XP Reward Brackets",
|
||||
items: {
|
||||
type: "object",
|
||||
label: "Bracket",
|
||||
fields: {
|
||||
minRank: {
|
||||
type: "number",
|
||||
label: "Min Rank",
|
||||
min: 1,
|
||||
},
|
||||
maxRank: {
|
||||
type: "number",
|
||||
label: "Max Rank",
|
||||
min: 1,
|
||||
},
|
||||
minReward: {
|
||||
type: "number",
|
||||
label: "Min Reward",
|
||||
min: 0,
|
||||
},
|
||||
maxReward: {
|
||||
type: "number",
|
||||
label: "Max Reward",
|
||||
min: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
connections: {
|
||||
type: "object",
|
||||
label: "Connections",
|
||||
fields: {
|
||||
enabled: { type: "boolean", label: "Enabled" },
|
||||
maxPerUser: {
|
||||
type: "number",
|
||||
label: "Max Connections per user",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
80
backend/src/constants/monkey-status-codes.ts
Normal file
80
backend/src/constants/monkey-status-codes.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
type Status = {
|
||||
code: number;
|
||||
message: string;
|
||||
};
|
||||
|
||||
type Statuses = {
|
||||
TEST_TOO_SHORT: Status;
|
||||
RESULT_HASH_INVALID: Status;
|
||||
RESULT_DATA_INVALID: Status;
|
||||
RESULT_SPACING_INVALID: Status;
|
||||
MISSING_KEY_DATA: Status;
|
||||
BOT_DETECTED: Status;
|
||||
DUPLICATE_RESULT: Status;
|
||||
GIT_GUD: Status;
|
||||
APE_KEY_INVALID: Status;
|
||||
APE_KEY_INACTIVE: Status;
|
||||
APE_KEY_MALFORMED: Status;
|
||||
APE_KEY_RATE_LIMIT_EXCEEDED: Status;
|
||||
};
|
||||
|
||||
const statuses: Statuses = {
|
||||
TEST_TOO_SHORT: {
|
||||
code: 460,
|
||||
message: "Test too short",
|
||||
},
|
||||
RESULT_HASH_INVALID: {
|
||||
code: 461,
|
||||
message: "Result hash invalid",
|
||||
},
|
||||
RESULT_SPACING_INVALID: {
|
||||
code: 462,
|
||||
message: "Result spacing invalid",
|
||||
},
|
||||
RESULT_DATA_INVALID: {
|
||||
code: 463,
|
||||
message: "Result data invalid",
|
||||
},
|
||||
MISSING_KEY_DATA: {
|
||||
code: 464,
|
||||
message: "Missing key data",
|
||||
},
|
||||
BOT_DETECTED: {
|
||||
code: 465,
|
||||
message: "Bot detected",
|
||||
},
|
||||
DUPLICATE_RESULT: {
|
||||
code: 466,
|
||||
message: "Duplicate result",
|
||||
},
|
||||
GIT_GUD: {
|
||||
code: 469,
|
||||
message: "Git gud scrub",
|
||||
},
|
||||
APE_KEY_INVALID: {
|
||||
code: 470,
|
||||
message: "Invalid ApeKey",
|
||||
},
|
||||
APE_KEY_INACTIVE: {
|
||||
code: 471,
|
||||
message: "ApeKey is inactive",
|
||||
},
|
||||
APE_KEY_MALFORMED: {
|
||||
code: 472,
|
||||
message: "ApeKey is malformed",
|
||||
},
|
||||
APE_KEY_RATE_LIMIT_EXCEEDED: {
|
||||
code: 479,
|
||||
message: "ApeKey rate limit exceeded",
|
||||
},
|
||||
};
|
||||
|
||||
const CUSTOM_STATUS_CODES = new Set<number>(
|
||||
Object.values(statuses).map((status) => status.code),
|
||||
);
|
||||
|
||||
export function isCustomCode(code: number): boolean {
|
||||
return CUSTOM_STATUS_CODES.has(code);
|
||||
}
|
||||
|
||||
export default statuses;
|
||||
0
backend/src/credentials/.gitkeep
Normal file
0
backend/src/credentials/.gitkeep
Normal file
14
backend/src/dal/admin-uids.ts
Normal file
14
backend/src/dal/admin-uids.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { Collection, WithId } from "mongodb";
|
||||
import * as db from "../init/db";
|
||||
|
||||
export const getCollection = (): Collection<WithId<{ uid: string }>> =>
|
||||
db.collection("admin-uids");
|
||||
|
||||
export async function isAdmin(uid: string): Promise<boolean> {
|
||||
const doc = await getCollection().findOne({ uid });
|
||||
if (doc) {
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
108
backend/src/dal/ape-keys.ts
Normal file
108
backend/src/dal/ape-keys.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import * as db from "../init/db";
|
||||
import {
|
||||
type Filter,
|
||||
type MatchKeysAndValues,
|
||||
type WithId,
|
||||
ObjectId,
|
||||
Collection,
|
||||
} from "mongodb";
|
||||
import MonkeyError from "../utils/error";
|
||||
import { ApeKey } from "@monkeytype/schemas/ape-keys";
|
||||
|
||||
export type DBApeKey = ApeKey & {
|
||||
_id: ObjectId;
|
||||
uid: string;
|
||||
hash: string;
|
||||
useCount: number;
|
||||
};
|
||||
|
||||
export const getApeKeysCollection = (): Collection<WithId<DBApeKey>> =>
|
||||
db.collection<DBApeKey>("ape-keys");
|
||||
|
||||
function getApeKeyFilter(uid: string, keyId: string): Filter<DBApeKey> {
|
||||
return {
|
||||
_id: new ObjectId(keyId),
|
||||
uid,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getApeKeys(uid: string): Promise<DBApeKey[]> {
|
||||
return await getApeKeysCollection().find({ uid }).toArray();
|
||||
}
|
||||
|
||||
export async function getApeKey(keyId: string): Promise<DBApeKey | null> {
|
||||
return await getApeKeysCollection().findOne({ _id: new ObjectId(keyId) });
|
||||
}
|
||||
|
||||
export async function countApeKeysForUser(uid: string): Promise<number> {
|
||||
return getApeKeysCollection().countDocuments({ uid });
|
||||
}
|
||||
|
||||
export async function addApeKey(apeKey: DBApeKey): Promise<string> {
|
||||
const insertionResult = await getApeKeysCollection().insertOne(apeKey);
|
||||
return insertionResult.insertedId.toHexString();
|
||||
}
|
||||
|
||||
async function updateApeKey(
|
||||
uid: string,
|
||||
keyId: string,
|
||||
updates: MatchKeysAndValues<DBApeKey>,
|
||||
): Promise<void> {
|
||||
const updateResult = await getApeKeysCollection().updateOne(
|
||||
getApeKeyFilter(uid, keyId),
|
||||
{
|
||||
$inc: { useCount: "lastUsedOn" in updates ? 1 : 0 },
|
||||
$set: Object.fromEntries(
|
||||
Object.entries(updates).filter(
|
||||
([_, value]) => value !== null && value !== undefined,
|
||||
),
|
||||
),
|
||||
},
|
||||
);
|
||||
|
||||
if (updateResult.modifiedCount === 0) {
|
||||
throw new MonkeyError(404, "ApeKey not found");
|
||||
}
|
||||
}
|
||||
|
||||
export async function editApeKey(
|
||||
uid: string,
|
||||
keyId: string,
|
||||
name?: string,
|
||||
enabled?: boolean,
|
||||
): Promise<void> {
|
||||
//check if there is a change
|
||||
if (name === undefined && enabled === undefined) return;
|
||||
const apeKeyUpdates = {
|
||||
name,
|
||||
enabled,
|
||||
modifiedOn: Date.now(),
|
||||
};
|
||||
|
||||
await updateApeKey(uid, keyId, apeKeyUpdates);
|
||||
}
|
||||
|
||||
export async function updateLastUsedOn(
|
||||
uid: string,
|
||||
keyId: string,
|
||||
): Promise<void> {
|
||||
const apeKeyUpdates = {
|
||||
lastUsedOn: Date.now(),
|
||||
};
|
||||
|
||||
await updateApeKey(uid, keyId, apeKeyUpdates);
|
||||
}
|
||||
|
||||
export async function deleteApeKey(uid: string, keyId: string): Promise<void> {
|
||||
const deletionResult = await getApeKeysCollection().deleteOne(
|
||||
getApeKeyFilter(uid, keyId),
|
||||
);
|
||||
|
||||
if (deletionResult.deletedCount === 0) {
|
||||
throw new MonkeyError(404, "ApeKey not found");
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteAllApeKeys(uid: string): Promise<void> {
|
||||
await getApeKeysCollection().deleteMany({ uid });
|
||||
}
|
||||
126
backend/src/dal/blocklist.ts
Normal file
126
backend/src/dal/blocklist.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import { Collection } from "mongodb";
|
||||
import * as db from "../init/db";
|
||||
import { createHash } from "crypto";
|
||||
import { User } from "@monkeytype/schemas/users";
|
||||
import { WithObjectId } from "../utils/misc";
|
||||
|
||||
type BlocklistEntryProperties = Pick<User, "name" | "email" | "discordId">;
|
||||
|
||||
type BlocklistEntry = {
|
||||
_id: string;
|
||||
usernameHash?: string;
|
||||
emailHash?: string;
|
||||
discordIdHash?: string;
|
||||
timestamp: number;
|
||||
};
|
||||
|
||||
type DBBlocklistEntry = WithObjectId<BlocklistEntry>;
|
||||
|
||||
// Export for use in tests
|
||||
export const getCollection = (): Collection<DBBlocklistEntry> =>
|
||||
db.collection("blocklist");
|
||||
|
||||
export async function add(user: BlocklistEntryProperties): Promise<void> {
|
||||
const timestamp = Date.now();
|
||||
const inserts: Promise<unknown>[] = [];
|
||||
|
||||
const usernameHash = hash(user.name);
|
||||
const emailHash = hash(user.email);
|
||||
inserts.push(
|
||||
getCollection().replaceOne(
|
||||
{ usernameHash },
|
||||
{
|
||||
usernameHash,
|
||||
timestamp,
|
||||
},
|
||||
{ upsert: true },
|
||||
),
|
||||
getCollection().replaceOne(
|
||||
{ emailHash },
|
||||
{
|
||||
emailHash,
|
||||
timestamp,
|
||||
},
|
||||
{ upsert: true },
|
||||
),
|
||||
);
|
||||
|
||||
if (user.discordId !== undefined && user.discordId !== "") {
|
||||
const discordIdHash = hash(user.discordId);
|
||||
inserts.push(
|
||||
getCollection().replaceOne(
|
||||
{ discordIdHash },
|
||||
{
|
||||
discordIdHash,
|
||||
timestamp,
|
||||
},
|
||||
{ upsert: true },
|
||||
),
|
||||
);
|
||||
}
|
||||
await Promise.all(inserts);
|
||||
}
|
||||
|
||||
export async function remove(
|
||||
user: Partial<BlocklistEntryProperties>,
|
||||
): Promise<void> {
|
||||
const filter = getFilter(user);
|
||||
if (filter.length === 0) return;
|
||||
await getCollection().deleteMany({ $or: filter });
|
||||
}
|
||||
|
||||
export async function contains(
|
||||
user: Partial<BlocklistEntryProperties>,
|
||||
): Promise<boolean> {
|
||||
const filter = getFilter(user);
|
||||
if (filter.length === 0) return false;
|
||||
|
||||
return (
|
||||
(await getCollection().countDocuments({
|
||||
$or: filter,
|
||||
})) !== 0
|
||||
);
|
||||
}
|
||||
export function hash(value: string): string {
|
||||
return createHash("sha256").update(value.toLocaleLowerCase()).digest("hex");
|
||||
}
|
||||
|
||||
function getFilter(
|
||||
user: Partial<BlocklistEntryProperties>,
|
||||
): Partial<DBBlocklistEntry>[] {
|
||||
const filter: Partial<DBBlocklistEntry>[] = [];
|
||||
if (user.email !== undefined) {
|
||||
filter.push({ emailHash: hash(user.email) });
|
||||
}
|
||||
if (user.name !== undefined) {
|
||||
filter.push({ usernameHash: hash(user.name) });
|
||||
}
|
||||
if (user.discordId !== undefined) {
|
||||
filter.push({ discordIdHash: hash(user.discordId) });
|
||||
}
|
||||
return filter;
|
||||
}
|
||||
|
||||
export async function createIndicies(): Promise<void> {
|
||||
await getCollection().createIndex(
|
||||
{ usernameHash: 1 },
|
||||
{
|
||||
unique: true,
|
||||
partialFilterExpression: { usernameHash: { $exists: true } },
|
||||
},
|
||||
);
|
||||
await getCollection().createIndex(
|
||||
{ emailHash: 1 },
|
||||
{
|
||||
unique: true,
|
||||
partialFilterExpression: { emailHash: { $exists: true } },
|
||||
},
|
||||
);
|
||||
await getCollection().createIndex(
|
||||
{ discordIdHash: 1 },
|
||||
{
|
||||
unique: true,
|
||||
partialFilterExpression: { discordIdHash: { $exists: true } },
|
||||
},
|
||||
);
|
||||
}
|
||||
60
backend/src/dal/config.ts
Normal file
60
backend/src/dal/config.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { Collection, ObjectId, UpdateResult } from "mongodb";
|
||||
import * as db from "../init/db";
|
||||
import { Config, PartialConfig } from "@monkeytype/schemas/configs";
|
||||
|
||||
const configLegacyProperties: Record<string, ""> = {
|
||||
"config.swapEscAndTab": "",
|
||||
"config.quickTab": "",
|
||||
"config.chartStyle": "",
|
||||
"config.chartAverage10": "",
|
||||
"config.chartAverage100": "",
|
||||
"config.alwaysShowCPM": "",
|
||||
"config.resultFilters": "",
|
||||
"config.chartAccuracy": "",
|
||||
"config.liveSpeed": "",
|
||||
"config.extraTestColor": "",
|
||||
"config.savedLayout": "",
|
||||
"config.showTimerBar": "",
|
||||
"config.showDiscordDot": "",
|
||||
"config.maxConfidence": "",
|
||||
"config.capsLockBackspace": "",
|
||||
"config.showAvg": "",
|
||||
"config.enableAds": "",
|
||||
};
|
||||
|
||||
export type DBConfig = {
|
||||
_id: ObjectId;
|
||||
uid: string;
|
||||
config: PartialConfig;
|
||||
};
|
||||
|
||||
const getConfigCollection = (): Collection<DBConfig> =>
|
||||
db.collection<DBConfig>("configs");
|
||||
|
||||
export async function saveConfig(
|
||||
uid: string,
|
||||
config: Partial<Config>,
|
||||
): Promise<UpdateResult> {
|
||||
const configChanges = Object.fromEntries(
|
||||
Object.entries(config).map(([key, value]) => [`config.${key}`, value]),
|
||||
);
|
||||
|
||||
return await getConfigCollection().updateOne(
|
||||
{ uid },
|
||||
{ $set: configChanges, $unset: configLegacyProperties },
|
||||
{ upsert: true },
|
||||
);
|
||||
}
|
||||
|
||||
export async function getConfig(uid: string): Promise<DBConfig | null> {
|
||||
const config = await getConfigCollection().findOne({ uid });
|
||||
return config;
|
||||
}
|
||||
|
||||
export async function deleteConfig(uid: string): Promise<void> {
|
||||
await getConfigCollection().deleteOne({ uid });
|
||||
}
|
||||
|
||||
export const __testing = {
|
||||
getConfigCollection,
|
||||
};
|
||||
349
backend/src/dal/connections.ts
Normal file
349
backend/src/dal/connections.ts
Normal file
@@ -0,0 +1,349 @@
|
||||
import { Collection, Document, Filter, ObjectId } from "mongodb";
|
||||
import * as db from "../init/db";
|
||||
import { Connection, ConnectionStatus } from "@monkeytype/schemas/connections";
|
||||
import MonkeyError from "../utils/error";
|
||||
import { WithObjectId } from "../utils/misc";
|
||||
|
||||
export type DBConnection = WithObjectId<
|
||||
Connection & {
|
||||
key: string; //sorted uid
|
||||
}
|
||||
>;
|
||||
|
||||
const getCollection = (): Collection<DBConnection> =>
|
||||
db.collection("connections");
|
||||
|
||||
export async function getConnections(options: {
|
||||
initiatorUid?: string;
|
||||
receiverUid?: string;
|
||||
status?: ConnectionStatus[];
|
||||
}): Promise<DBConnection[]> {
|
||||
const { initiatorUid, receiverUid, status } = options;
|
||||
|
||||
if (initiatorUid === undefined && receiverUid === undefined) {
|
||||
throw new Error("Missing filter");
|
||||
}
|
||||
|
||||
let filter: Filter<DBConnection> = { $or: [] };
|
||||
|
||||
if (initiatorUid !== undefined) {
|
||||
filter.$or?.push({ initiatorUid });
|
||||
}
|
||||
|
||||
if (receiverUid !== undefined) {
|
||||
filter.$or?.push({ receiverUid });
|
||||
}
|
||||
|
||||
if (status !== undefined) {
|
||||
filter.status = { $in: status };
|
||||
}
|
||||
|
||||
return await getCollection().find(filter).toArray();
|
||||
}
|
||||
|
||||
export async function create(
|
||||
initiator: { uid: string; name: string },
|
||||
receiver: { uid: string; name: string },
|
||||
maxPerUser: number,
|
||||
): Promise<DBConnection> {
|
||||
const count = await getCollection().countDocuments({
|
||||
initiatorUid: initiator.uid,
|
||||
});
|
||||
|
||||
if (count >= maxPerUser) {
|
||||
throw new MonkeyError(
|
||||
409,
|
||||
"Maximum number of connections reached",
|
||||
"create connection request",
|
||||
);
|
||||
}
|
||||
const key = getKey(initiator.uid, receiver.uid);
|
||||
try {
|
||||
const created: DBConnection = {
|
||||
_id: new ObjectId(),
|
||||
key,
|
||||
initiatorUid: initiator.uid,
|
||||
initiatorName: initiator.name,
|
||||
receiverUid: receiver.uid,
|
||||
receiverName: receiver.name,
|
||||
lastModified: Date.now(),
|
||||
status: "pending",
|
||||
};
|
||||
|
||||
await getCollection().insertOne(created);
|
||||
|
||||
return created;
|
||||
} catch (e) {
|
||||
// oxlint-disable-next-line no-unsafe-member-access
|
||||
if (e.name === "MongoServerError" && e.code === 11000) {
|
||||
const existing = await getCollection().findOne(
|
||||
{ key },
|
||||
{ projection: { status: 1 } },
|
||||
);
|
||||
|
||||
let message = "";
|
||||
|
||||
if (existing?.status === "accepted") {
|
||||
message = "Connection already exists";
|
||||
} else if (existing?.status === "pending") {
|
||||
message = "Connection request already sent";
|
||||
} else if (existing?.status === "blocked") {
|
||||
if (existing.initiatorUid === initiator.uid) {
|
||||
message = "Connection blocked by initiator";
|
||||
} else {
|
||||
message = "Connection blocked by receiver";
|
||||
}
|
||||
} else {
|
||||
message = "Duplicate connection";
|
||||
}
|
||||
|
||||
throw new MonkeyError(409, message);
|
||||
}
|
||||
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
*Update the status of a connection by id
|
||||
* @param receiverUid
|
||||
* @param id
|
||||
* @param status
|
||||
* @throws MonkeyError if the connection id is unknown or the recieverUid does not match
|
||||
*/
|
||||
export async function updateStatus(
|
||||
receiverUid: string,
|
||||
id: string,
|
||||
status: ConnectionStatus,
|
||||
): Promise<void> {
|
||||
const updateResult = await getCollection().updateOne(
|
||||
{
|
||||
_id: new ObjectId(id),
|
||||
receiverUid,
|
||||
},
|
||||
{ $set: { status, lastModified: Date.now() } },
|
||||
);
|
||||
|
||||
if (updateResult.matchedCount === 0) {
|
||||
throw new MonkeyError(404, "No permission or connection not found");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* delete a connection by the id.
|
||||
* @param uid
|
||||
* @param id
|
||||
* @throws MonkeyError if the connection id is unknown or uid does not match
|
||||
*/
|
||||
export async function deleteById(uid: string, id: string): Promise<void> {
|
||||
const deletionResult = await getCollection().deleteOne({
|
||||
$and: [
|
||||
{
|
||||
_id: new ObjectId(id),
|
||||
},
|
||||
{
|
||||
$or: [
|
||||
{ receiverUid: uid },
|
||||
{ status: { $in: ["accepted", "pending"] }, initiatorUid: uid },
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
if (deletionResult.deletedCount === 0) {
|
||||
throw new MonkeyError(404, "No permission or connection not found");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update all connections for the uid (initiator or receiver) with the given name.
|
||||
* @param uid
|
||||
* @param newName
|
||||
*/
|
||||
export async function updateName(uid: string, newName: string): Promise<void> {
|
||||
await getCollection().bulkWrite([
|
||||
{
|
||||
updateMany: {
|
||||
filter: { initiatorUid: uid },
|
||||
update: { $set: { initiatorName: newName } },
|
||||
},
|
||||
},
|
||||
{
|
||||
updateMany: {
|
||||
filter: { receiverUid: uid },
|
||||
update: { $set: { receiverName: newName } },
|
||||
},
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove all connections containing the uid as initiatorUid or receiverUid
|
||||
* @param uid
|
||||
*/
|
||||
export async function deleteByUid(uid: string): Promise<void> {
|
||||
await getCollection().deleteMany({
|
||||
$or: [{ initiatorUid: uid }, { receiverUid: uid }],
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Return uids of all accepted connections for the given uid including the uid.
|
||||
* @param uid
|
||||
* @returns
|
||||
*/
|
||||
export async function getFriendsUids(uid: string): Promise<string[]> {
|
||||
return Array.from(
|
||||
new Set(
|
||||
(
|
||||
await getCollection()
|
||||
.find(
|
||||
{
|
||||
status: "accepted",
|
||||
$or: [{ initiatorUid: uid }, { receiverUid: uid }],
|
||||
},
|
||||
{ projection: { initiatorUid: true, receiverUid: true } },
|
||||
)
|
||||
.toArray()
|
||||
).flatMap((it) => [it.initiatorUid, it.receiverUid]),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* aggregate the given `pipeline` on the `collectionName` for each friendUid and the given `uid`.
|
||||
|
||||
* @param pipeline
|
||||
* @param options
|
||||
* @returns
|
||||
*/
|
||||
export async function aggregateWithAcceptedConnections<T>(
|
||||
options: {
|
||||
uid: string;
|
||||
/**
|
||||
* target collection
|
||||
*/
|
||||
collectionName: string;
|
||||
/**
|
||||
* uid field on the collection, defaults to `uid`
|
||||
*/
|
||||
uidField?: string;
|
||||
/**
|
||||
* add meta data `connectionMeta.lastModified` and *connectionMeta._id` to the document
|
||||
*/
|
||||
includeMetaData?: boolean;
|
||||
},
|
||||
pipeline: Document[],
|
||||
): Promise<T[]> {
|
||||
const metaData = options.includeMetaData
|
||||
? {
|
||||
let: {
|
||||
lastModified: "$lastModified",
|
||||
connectionId: "$connectionId",
|
||||
},
|
||||
pipeline: [
|
||||
{
|
||||
$addFields: {
|
||||
"connectionMeta.lastModified": "$$lastModified",
|
||||
"connectionMeta._id": "$$connectionId",
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
: {};
|
||||
const { uid, collectionName, uidField } = options;
|
||||
const fullPipeline = [
|
||||
{
|
||||
$match: {
|
||||
status: "accepted",
|
||||
//uid is friend or initiator
|
||||
$or: [{ initiatorUid: uid }, { receiverUid: uid }],
|
||||
},
|
||||
},
|
||||
{
|
||||
$project: {
|
||||
lastModified: true,
|
||||
uid: {
|
||||
//pick the other user, not uid
|
||||
$cond: {
|
||||
if: { $eq: ["$receiverUid", uid] },
|
||||
// oxlint-disable-next-line no-thenable
|
||||
then: "$initiatorUid",
|
||||
else: "$receiverUid",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
// we want to fetch the data for our uid as well, add it to the list of documents
|
||||
// workaround for missing unionWith + $documents in mongodb 5.0
|
||||
{
|
||||
$group: {
|
||||
_id: null,
|
||||
data: {
|
||||
$push: {
|
||||
uid: "$uid",
|
||||
lastModified: "$lastModified",
|
||||
connectionId: "$_id",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
$project: {
|
||||
data: {
|
||||
$concatArrays: ["$data", [{ uid }]],
|
||||
},
|
||||
},
|
||||
},
|
||||
{ $unwind: "$data" },
|
||||
{ $replaceRoot: { newRoot: "$data" } },
|
||||
|
||||
/* end of workaround, this is the replacement for >= 5.1
|
||||
|
||||
{ $addFields: { connectionId: "$_id" } },
|
||||
{ $project: { uid: true, lastModified: true, connectionId: true } },
|
||||
{
|
||||
$unionWith: {
|
||||
pipeline: [{ $documents: [{ uid }] }],
|
||||
},
|
||||
},
|
||||
*/
|
||||
|
||||
{
|
||||
//replace with $unionWith in MongoDB 6 or newer
|
||||
$lookup: {
|
||||
from: collectionName,
|
||||
localField: "uid",
|
||||
foreignField: uidField ?? "uid",
|
||||
as: "result",
|
||||
...metaData,
|
||||
},
|
||||
},
|
||||
|
||||
{ $match: { result: { $ne: [] } } },
|
||||
{ $replaceRoot: { newRoot: { $first: "$result" } } },
|
||||
...pipeline,
|
||||
];
|
||||
|
||||
//console.log(JSON.stringify(fullPipeline, null, 4));
|
||||
return (await getCollection().aggregate(fullPipeline).toArray()) as T[];
|
||||
}
|
||||
|
||||
function getKey(initiatorUid: string, receiverUid: string): string {
|
||||
const ids = [initiatorUid, receiverUid];
|
||||
ids.sort();
|
||||
return ids.join("/");
|
||||
}
|
||||
|
||||
export async function createIndicies(): Promise<void> {
|
||||
//index used for search
|
||||
await getCollection().createIndex({ initiatorUid: 1, status: 1 });
|
||||
await getCollection().createIndex({ receiverUid: 1, status: 1 });
|
||||
|
||||
//make sure there is only one connection for each initiatorr/receiver
|
||||
await getCollection().createIndex({ key: 1 }, { unique: true });
|
||||
}
|
||||
|
||||
export const __testing = {
|
||||
getCollection,
|
||||
};
|
||||
421
backend/src/dal/leaderboards.ts
Normal file
421
backend/src/dal/leaderboards.ts
Normal file
@@ -0,0 +1,421 @@
|
||||
import * as db from "../init/db";
|
||||
import Logger from "../utils/logger";
|
||||
import { performance } from "perf_hooks";
|
||||
import { setLeaderboard } from "../utils/prometheus";
|
||||
import { isDevEnvironment, omit } from "../utils/misc";
|
||||
import {
|
||||
getCachedConfiguration,
|
||||
getLiveConfiguration,
|
||||
} from "../init/configuration";
|
||||
|
||||
import { addLog } from "./logs";
|
||||
import { Collection, Document, ObjectId } from "mongodb";
|
||||
import { LeaderboardEntry } from "@monkeytype/schemas/leaderboards";
|
||||
import { DBUser, getUsersCollection } from "./user";
|
||||
import MonkeyError from "../utils/error";
|
||||
import { aggregateWithAcceptedConnections } from "./connections";
|
||||
|
||||
export type DBLeaderboardEntry = LeaderboardEntry & {
|
||||
_id: ObjectId;
|
||||
};
|
||||
|
||||
function getCollectionName(key: {
|
||||
language: string;
|
||||
mode: string;
|
||||
mode2: string;
|
||||
}): string {
|
||||
return `leaderboards.${key.language}.${key.mode}.${key.mode2}`;
|
||||
}
|
||||
export const getCollection = (key: {
|
||||
language: string;
|
||||
mode: string;
|
||||
mode2: string;
|
||||
}): Collection<DBLeaderboardEntry> =>
|
||||
db.collection<DBLeaderboardEntry>(getCollectionName(key));
|
||||
|
||||
export async function get(
|
||||
mode: string,
|
||||
mode2: string,
|
||||
language: string,
|
||||
page: number,
|
||||
pageSize: number,
|
||||
premiumFeaturesEnabled: boolean = false,
|
||||
uid?: string,
|
||||
): Promise<DBLeaderboardEntry[] | false> {
|
||||
if (page < 0 || pageSize < 0) {
|
||||
throw new MonkeyError(500, "Invalid page or pageSize");
|
||||
}
|
||||
|
||||
const skip = page * pageSize;
|
||||
const limit = pageSize;
|
||||
|
||||
let leaderboard: DBLeaderboardEntry[] | false = [];
|
||||
|
||||
const pipeline: Document[] = [
|
||||
{ $sort: { rank: 1 } },
|
||||
{ $skip: skip },
|
||||
{ $limit: limit },
|
||||
];
|
||||
|
||||
try {
|
||||
if (uid !== undefined) {
|
||||
leaderboard = await aggregateWithAcceptedConnections(
|
||||
{
|
||||
uid,
|
||||
collectionName: getCollectionName({ language, mode, mode2 }),
|
||||
},
|
||||
[
|
||||
{
|
||||
$setWindowFields: {
|
||||
sortBy: { rank: 1 },
|
||||
output: { friendsRank: { $documentNumber: {} } },
|
||||
},
|
||||
},
|
||||
...pipeline,
|
||||
],
|
||||
);
|
||||
} else {
|
||||
leaderboard = await getCollection({ language, mode, mode2 })
|
||||
.aggregate<DBLeaderboardEntry>(pipeline)
|
||||
.toArray();
|
||||
}
|
||||
if (!premiumFeaturesEnabled) {
|
||||
leaderboard = leaderboard.map((it) => omit(it, ["isPremium"]));
|
||||
}
|
||||
|
||||
return leaderboard;
|
||||
} catch (e) {
|
||||
// oxlint-disable-next-line no-unsafe-member-access
|
||||
if (e.error === 175) {
|
||||
//QueryPlanKilled, collection was removed during the query
|
||||
return false;
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
const cachedCounts = new Map<string, number>();
|
||||
|
||||
export async function getCount(
|
||||
mode: string,
|
||||
mode2: string,
|
||||
language: string,
|
||||
uid?: string,
|
||||
): Promise<number> {
|
||||
const key = `${language}_${mode}_${mode2}`;
|
||||
if (uid === undefined && cachedCounts.has(key)) {
|
||||
return cachedCounts.get(key) as number;
|
||||
} else {
|
||||
if (uid === undefined) {
|
||||
const count = await getCollection({
|
||||
language,
|
||||
mode,
|
||||
mode2,
|
||||
}).estimatedDocumentCount();
|
||||
cachedCounts.set(key, count);
|
||||
return count;
|
||||
} else {
|
||||
return (
|
||||
await aggregateWithAcceptedConnections(
|
||||
{
|
||||
collectionName: getCollectionName({ language, mode, mode2 }),
|
||||
uid,
|
||||
},
|
||||
[{ $project: { _id: true } }],
|
||||
)
|
||||
).length;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function getRank(
|
||||
mode: string,
|
||||
mode2: string,
|
||||
language: string,
|
||||
uid: string,
|
||||
friendsOnly: boolean = false,
|
||||
): Promise<DBLeaderboardEntry | null | false> {
|
||||
try {
|
||||
if (!friendsOnly) {
|
||||
const entry = await getCollection({ language, mode, mode2 }).findOne({
|
||||
uid,
|
||||
});
|
||||
|
||||
return entry;
|
||||
} else {
|
||||
const results =
|
||||
await aggregateWithAcceptedConnections<DBLeaderboardEntry>(
|
||||
{
|
||||
collectionName: getCollectionName({ language, mode, mode2 }),
|
||||
uid,
|
||||
},
|
||||
[
|
||||
{
|
||||
$setWindowFields: {
|
||||
sortBy: { rank: 1 },
|
||||
output: { friendsRank: { $documentNumber: {} } },
|
||||
},
|
||||
},
|
||||
{ $match: { uid } },
|
||||
],
|
||||
);
|
||||
return results[0] ?? null;
|
||||
}
|
||||
} catch (e) {
|
||||
// oxlint-disable-next-line no-unsafe-member-access
|
||||
if (e.error === 175) {
|
||||
//QueryPlanKilled, collection was removed during the query
|
||||
return false;
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
export async function update(
|
||||
mode: string,
|
||||
mode2: string,
|
||||
language: string,
|
||||
): Promise<{
|
||||
message: string;
|
||||
rank?: number;
|
||||
}> {
|
||||
const key = `lbPersonalBests.${mode}.${mode2}.${language}`;
|
||||
const lbCollectionName = getCollectionName({ language, mode, mode2 });
|
||||
const minTimeTyping = (await getCachedConfiguration(true)).leaderboards
|
||||
.minTimeTyping;
|
||||
const lb = db.collection<DBUser>("users").aggregate<LeaderboardEntry>(
|
||||
[
|
||||
{
|
||||
$match: {
|
||||
[`${key}.wpm`]: {
|
||||
$gt: 0,
|
||||
},
|
||||
[`${key}.acc`]: {
|
||||
$gt: 0,
|
||||
},
|
||||
[`${key}.timestamp`]: {
|
||||
$gt: 0,
|
||||
},
|
||||
banned: {
|
||||
$ne: true,
|
||||
},
|
||||
lbOptOut: {
|
||||
$ne: true,
|
||||
},
|
||||
needsToChangeName: {
|
||||
$ne: true,
|
||||
},
|
||||
timeTyping: {
|
||||
$gt: isDevEnvironment() ? 0 : minTimeTyping,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
$sort: {
|
||||
[`${key}.wpm`]: -1,
|
||||
[`${key}.acc`]: -1,
|
||||
[`${key}.timestamp`]: -1,
|
||||
},
|
||||
},
|
||||
{
|
||||
$project: {
|
||||
_id: 0,
|
||||
[`${key}.wpm`]: 1,
|
||||
[`${key}.acc`]: 1,
|
||||
[`${key}.raw`]: 1,
|
||||
[`${key}.consistency`]: 1,
|
||||
[`${key}.timestamp`]: 1,
|
||||
uid: 1,
|
||||
name: 1,
|
||||
discordId: 1,
|
||||
discordAvatar: 1,
|
||||
inventory: 1,
|
||||
premium: 1,
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
$addFields: {
|
||||
"user.uid": "$uid",
|
||||
"user.name": "$name",
|
||||
"user.discordId": { $ifNull: ["$discordId", "$$REMOVE"] },
|
||||
"user.discordAvatar": { $ifNull: ["$discordAvatar", "$$REMOVE"] },
|
||||
[`${key}.consistency`]: {
|
||||
$ifNull: [`$${key}.consistency`, "$$REMOVE"],
|
||||
},
|
||||
calculated: {
|
||||
$function: {
|
||||
lang: "js",
|
||||
args: [
|
||||
"$premium.expirationTimestamp",
|
||||
"$$NOW",
|
||||
"$inventory.badges",
|
||||
],
|
||||
body: `function(expiration, currentTime, badges) {
|
||||
try {row_number+= 1;} catch (e) {row_number= 1;}
|
||||
var badgeId = undefined;
|
||||
if(badges)for(let i=0; i<badges.length; i++){
|
||||
if(badges[i].selected){ badgeId = badges[i].id; break}
|
||||
}
|
||||
var isPremium = expiration !== undefined && (expiration === -1 || new Date(expiration)>currentTime) || undefined;
|
||||
return {rank:row_number,badgeId, isPremium};
|
||||
}`,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
$replaceWith: {
|
||||
$mergeObjects: [`$${key}`, "$user", "$calculated"],
|
||||
},
|
||||
},
|
||||
{ $out: lbCollectionName },
|
||||
],
|
||||
{ allowDiskUse: true },
|
||||
);
|
||||
|
||||
const start1 = performance.now();
|
||||
await lb.toArray();
|
||||
const end1 = performance.now();
|
||||
|
||||
const start2 = performance.now();
|
||||
await db.collection(lbCollectionName).createIndex({ uid: -1 });
|
||||
await db.collection(lbCollectionName).createIndex({ rank: 1 });
|
||||
const end2 = performance.now();
|
||||
|
||||
cachedCounts.delete(`${language}_${mode}_${mode2}`);
|
||||
|
||||
//update speedStats
|
||||
const boundaries = [...Array(32).keys()].map((it) => it * 10);
|
||||
const statsKey = `${language}_${mode}_${mode2}`;
|
||||
const src = db.collection(lbCollectionName);
|
||||
const histogram = src.aggregate(
|
||||
[
|
||||
{
|
||||
$bucket: {
|
||||
groupBy: "$wpm",
|
||||
boundaries: boundaries,
|
||||
default: "Other",
|
||||
},
|
||||
},
|
||||
{
|
||||
$replaceRoot: {
|
||||
newRoot: {
|
||||
$arrayToObject: [[{ k: { $toString: "$_id" }, v: "$count" }]],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
$group: {
|
||||
_id: "speedStatsHistogram", //we only expect one document with type=speedStats
|
||||
[`${statsKey}`]: {
|
||||
$mergeObjects: "$$ROOT",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
$merge: {
|
||||
into: "public",
|
||||
on: "_id",
|
||||
whenMatched: "merge",
|
||||
whenNotMatched: "insert",
|
||||
},
|
||||
},
|
||||
],
|
||||
{ allowDiskUse: true },
|
||||
);
|
||||
const start3 = performance.now();
|
||||
await histogram.toArray();
|
||||
const end3 = performance.now();
|
||||
|
||||
const timeToRunAggregate = (end1 - start1) / 1000;
|
||||
const timeToRunIndex = (end2 - start2) / 1000;
|
||||
const timeToSaveHistogram = (end3 - start3) / 1000; // not sent to prometheus yet
|
||||
|
||||
void addLog(
|
||||
`system_lb_update_${language}_${mode}_${mode2}`,
|
||||
`Aggregate ${timeToRunAggregate}s, loop 0s, insert 0s, index ${timeToRunIndex}s, histogram ${timeToSaveHistogram}`,
|
||||
);
|
||||
|
||||
setLeaderboard(language, mode, mode2, [
|
||||
timeToRunAggregate,
|
||||
0,
|
||||
0,
|
||||
timeToRunIndex,
|
||||
]);
|
||||
|
||||
return {
|
||||
message: "Successfully updated leaderboard",
|
||||
};
|
||||
}
|
||||
|
||||
async function createIndex(
|
||||
key: string,
|
||||
minTimeTyping: number,
|
||||
dropIfMismatch = true,
|
||||
): Promise<void> {
|
||||
const index = {
|
||||
[`${key}.wpm`]: -1,
|
||||
[`${key}.acc`]: -1,
|
||||
[`${key}.timestamp`]: -1,
|
||||
[`${key}.raw`]: -1,
|
||||
[`${key}.consistency`]: -1,
|
||||
banned: 1,
|
||||
lbOptOut: 1,
|
||||
needsToChangeName: 1,
|
||||
timeTyping: 1,
|
||||
uid: 1,
|
||||
name: 1,
|
||||
discordId: 1,
|
||||
discordAvatar: 1,
|
||||
inventory: 1,
|
||||
premium: 1,
|
||||
};
|
||||
const partial = {
|
||||
partialFilterExpression: {
|
||||
[`${key}.wpm`]: {
|
||||
$gt: 0,
|
||||
},
|
||||
timeTyping: {
|
||||
$gt: minTimeTyping,
|
||||
},
|
||||
},
|
||||
};
|
||||
try {
|
||||
await getUsersCollection().createIndex(index, partial);
|
||||
} catch (e) {
|
||||
if (!dropIfMismatch) throw e;
|
||||
if (
|
||||
(e as Error).message.startsWith(
|
||||
"An existing index has the same name as the requested index",
|
||||
)
|
||||
) {
|
||||
Logger.warning(`Index ${key} not matching, dropping and recreating...`);
|
||||
|
||||
const existingIndex = (await getUsersCollection().listIndexes().toArray())
|
||||
// oxlint-disable-next-line no-unsafe-member-access
|
||||
.map((it) => it.name as string)
|
||||
.find((it) => it.startsWith(key));
|
||||
|
||||
if (existingIndex !== undefined && existingIndex !== null) {
|
||||
await getUsersCollection().dropIndex(existingIndex);
|
||||
return createIndex(key, minTimeTyping, false);
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function createIndicies(): Promise<void> {
|
||||
const minTimeTyping = (await getLiveConfiguration()).leaderboards
|
||||
.minTimeTyping;
|
||||
await createIndex("lbPersonalBests.time.15.english", minTimeTyping);
|
||||
await createIndex("lbPersonalBests.time.60.english", minTimeTyping);
|
||||
|
||||
if (isDevEnvironment()) {
|
||||
Logger.info("Updating leaderboards in dev mode...");
|
||||
await update("time", "15", "english");
|
||||
await update("time", "60", "english");
|
||||
}
|
||||
}
|
||||
65
backend/src/dal/logs.ts
Normal file
65
backend/src/dal/logs.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { Collection, ObjectId } from "mongodb";
|
||||
import * as db from "../init/db";
|
||||
import Logger from "../utils/logger";
|
||||
|
||||
type DbLog = {
|
||||
_id: ObjectId;
|
||||
type?: string;
|
||||
timestamp: number;
|
||||
uid: string;
|
||||
important?: boolean;
|
||||
event: string;
|
||||
message: string | Record<string, unknown>;
|
||||
};
|
||||
|
||||
export const getLogsCollection = (): Collection<DbLog> =>
|
||||
db.collection<DbLog>("logs");
|
||||
|
||||
async function insertIntoDb(
|
||||
event: string,
|
||||
message: string | Record<string, unknown>,
|
||||
uid = "",
|
||||
important = false,
|
||||
): Promise<void> {
|
||||
const dbLog: DbLog = {
|
||||
_id: new ObjectId(),
|
||||
timestamp: Date.now(),
|
||||
uid: uid ?? "",
|
||||
event: event,
|
||||
message: message,
|
||||
important: important,
|
||||
};
|
||||
|
||||
if (!important) delete dbLog.important;
|
||||
|
||||
const stringified = JSON.stringify(message);
|
||||
|
||||
Logger.info(
|
||||
`${event}\t${uid}\t${
|
||||
stringified.length > 100 ? stringified.slice(0, 100) + "..." : stringified
|
||||
}`,
|
||||
);
|
||||
|
||||
await getLogsCollection().insertOne(dbLog);
|
||||
}
|
||||
|
||||
export async function addLog(
|
||||
event: string,
|
||||
message: string | Record<string, unknown>,
|
||||
uid = "",
|
||||
): Promise<void> {
|
||||
await insertIntoDb(event, message, uid);
|
||||
}
|
||||
|
||||
export async function addImportantLog(
|
||||
event: string,
|
||||
message: string | Record<string, unknown>,
|
||||
uid = "",
|
||||
): Promise<void> {
|
||||
console.log("log", event, message, uid);
|
||||
await insertIntoDb(event, message, uid, true);
|
||||
}
|
||||
|
||||
export async function deleteUserLogs(uid: string): Promise<void> {
|
||||
await getLogsCollection().deleteMany({ uid });
|
||||
}
|
||||
235
backend/src/dal/new-quotes.ts
Normal file
235
backend/src/dal/new-quotes.ts
Normal file
@@ -0,0 +1,235 @@
|
||||
import { simpleGit } from "simple-git";
|
||||
import { Collection, ObjectId } from "mongodb";
|
||||
import path from "path";
|
||||
import { existsSync, writeFileSync } from "fs";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import * as db from "../init/db";
|
||||
import MonkeyError from "../utils/error";
|
||||
import { compareTwoStrings } from "string-similarity";
|
||||
import { ApproveQuote, Quote } from "@monkeytype/schemas/quotes";
|
||||
import { WithObjectId } from "../utils/misc";
|
||||
import { parseWithSchema as parseJsonWithSchema } from "@monkeytype/util/json";
|
||||
import { z } from "zod";
|
||||
import { tryCatchSync } from "@monkeytype/util/trycatch";
|
||||
import { Language } from "@monkeytype/schemas/languages";
|
||||
|
||||
const JsonQuoteSchema = z.object({
|
||||
text: z.string(),
|
||||
britishText: z.string().optional(),
|
||||
approvedBy: z.string().optional(),
|
||||
source: z.string(),
|
||||
length: z.number(),
|
||||
id: z.number(),
|
||||
});
|
||||
|
||||
const QuoteDataSchema = z.object({
|
||||
language: z.string(),
|
||||
quotes: z.array(JsonQuoteSchema),
|
||||
groups: z.array(z.tuple([z.number(), z.number()])),
|
||||
});
|
||||
|
||||
const PATH_TO_REPO = "../../../../monkeytype-new-quotes";
|
||||
|
||||
const { data: git, error } = tryCatchSync(() =>
|
||||
simpleGit(path.join(__dirname, PATH_TO_REPO)),
|
||||
);
|
||||
|
||||
if (error) {
|
||||
console.error(`Failed to initialize git: ${error}`);
|
||||
}
|
||||
|
||||
type AddQuoteReturn = {
|
||||
languageError?: number;
|
||||
duplicateId?: number;
|
||||
similarityScore?: number;
|
||||
};
|
||||
|
||||
export type DBNewQuote = WithObjectId<Quote>;
|
||||
|
||||
// Export for use in tests
|
||||
export const getNewQuoteCollection = (): Collection<DBNewQuote> =>
|
||||
db.collection<DBNewQuote>("new-quotes");
|
||||
|
||||
export async function add(
|
||||
text: string,
|
||||
source: string,
|
||||
language: string,
|
||||
uid: string,
|
||||
): Promise<AddQuoteReturn | undefined> {
|
||||
if (git === undefined) throw new MonkeyError(500, "Git not available.");
|
||||
const quote = {
|
||||
_id: new ObjectId(),
|
||||
text: text,
|
||||
source: source,
|
||||
language: language.toLowerCase(),
|
||||
submittedBy: uid,
|
||||
timestamp: Date.now(),
|
||||
approved: false,
|
||||
};
|
||||
|
||||
if (!/^\w+$/.test(language)) {
|
||||
throw new MonkeyError(500, `Invalid language name`, language);
|
||||
}
|
||||
|
||||
const count = await getNewQuoteCollection().countDocuments({
|
||||
language: language,
|
||||
});
|
||||
|
||||
if (count >= 100) {
|
||||
throw new MonkeyError(
|
||||
409,
|
||||
"There are already 100 quotes in the queue for this language.",
|
||||
);
|
||||
}
|
||||
|
||||
//check for duplicate first
|
||||
const fileDir = path.join(
|
||||
__dirname,
|
||||
`${PATH_TO_REPO}/frontend/static/quotes/${language}.json`,
|
||||
);
|
||||
let duplicateId = -1;
|
||||
let similarityScore = -1;
|
||||
if (existsSync(fileDir)) {
|
||||
const quoteFile = await readFile(fileDir);
|
||||
const quoteFileJSON = parseJsonWithSchema(
|
||||
quoteFile.toString(),
|
||||
QuoteDataSchema,
|
||||
);
|
||||
quoteFileJSON.quotes.every((old) => {
|
||||
if (compareTwoStrings(old.text, quote.text) > 0.9) {
|
||||
duplicateId = old.id;
|
||||
similarityScore = compareTwoStrings(old.text, quote.text);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
} else {
|
||||
return { languageError: 1 };
|
||||
}
|
||||
if (duplicateId !== -1) {
|
||||
return { duplicateId, similarityScore };
|
||||
}
|
||||
await db.collection("new-quotes").insertOne(quote);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export async function get(language: Language | "all"): Promise<DBNewQuote[]> {
|
||||
if (git === undefined) throw new MonkeyError(500, "Git not available.");
|
||||
const where: {
|
||||
approved: boolean;
|
||||
language?: Language;
|
||||
} = {
|
||||
approved: false,
|
||||
};
|
||||
|
||||
if (!/^\w+$/.test(language)) {
|
||||
throw new MonkeyError(500, `Invalid language name`, language);
|
||||
}
|
||||
|
||||
if (language !== "all") {
|
||||
where.language = language;
|
||||
}
|
||||
return await getNewQuoteCollection()
|
||||
.find(where)
|
||||
.sort({ timestamp: 1 })
|
||||
.limit(10)
|
||||
.toArray();
|
||||
}
|
||||
|
||||
type ApproveReturn = {
|
||||
quote: ApproveQuote;
|
||||
message: string;
|
||||
};
|
||||
|
||||
export async function approve(
|
||||
quoteId: string,
|
||||
editQuote: string | undefined,
|
||||
editSource: string | undefined,
|
||||
name: string,
|
||||
): Promise<ApproveReturn> {
|
||||
if (git === null) throw new MonkeyError(500, "Git not available.");
|
||||
//check mod status
|
||||
const targetQuote = await getNewQuoteCollection().findOne({
|
||||
_id: new ObjectId(quoteId),
|
||||
});
|
||||
if (!targetQuote) {
|
||||
throw new MonkeyError(
|
||||
404,
|
||||
"Quote not found. It might have already been reviewed. Please refresh the list.",
|
||||
);
|
||||
}
|
||||
const language = targetQuote.language;
|
||||
const quote: ApproveQuote = {
|
||||
text: editQuote ?? targetQuote.text,
|
||||
source: editSource ?? targetQuote.source,
|
||||
length: targetQuote.text.length,
|
||||
approvedBy: name,
|
||||
id: -1,
|
||||
};
|
||||
let message = "";
|
||||
|
||||
if (!/^\w+$/.test(language)) {
|
||||
throw new MonkeyError(500, `Invalid language name`, language);
|
||||
}
|
||||
|
||||
const fileDir = path.join(
|
||||
__dirname,
|
||||
`${PATH_TO_REPO}/frontend/static/quotes/${language}.json`,
|
||||
);
|
||||
await git.pull("upstream", "master");
|
||||
if (existsSync(fileDir)) {
|
||||
const quoteFile = await readFile(fileDir);
|
||||
const quoteObject = parseJsonWithSchema(
|
||||
quoteFile.toString(),
|
||||
QuoteDataSchema,
|
||||
);
|
||||
quoteObject.quotes.every((old) => {
|
||||
if (compareTwoStrings(old.text, quote.text) > 0.8) {
|
||||
throw new MonkeyError(409, "Duplicate quote");
|
||||
}
|
||||
return true;
|
||||
});
|
||||
let maxid = 0;
|
||||
quoteObject.quotes.map(function (q) {
|
||||
if (q.id > maxid) {
|
||||
maxid = q.id;
|
||||
}
|
||||
});
|
||||
quote.id = maxid + 1;
|
||||
|
||||
if (quote.id === -1) {
|
||||
throw new MonkeyError(500, "Failed to get max id");
|
||||
}
|
||||
|
||||
quoteObject.quotes.push(quote);
|
||||
writeFileSync(fileDir, JSON.stringify(quoteObject, null, 2));
|
||||
message = `Added quote to ${language}.json.`;
|
||||
} else {
|
||||
//file doesnt exist, create it
|
||||
quote.id = 1;
|
||||
writeFileSync(
|
||||
fileDir,
|
||||
JSON.stringify({
|
||||
language: language,
|
||||
groups: [
|
||||
[0, 100],
|
||||
[101, 300],
|
||||
[301, 600],
|
||||
[601, 9999],
|
||||
],
|
||||
quotes: [quote],
|
||||
}),
|
||||
);
|
||||
message = `Created file ${language}.json and added quote.`;
|
||||
}
|
||||
await git.add([`frontend/static/quotes/${language}.json`]);
|
||||
await git.commit(`Added quote to ${language}.json`);
|
||||
await git.push("origin", "master");
|
||||
await getNewQuoteCollection().deleteOne({ _id: new ObjectId(quoteId) });
|
||||
return { quote, message };
|
||||
}
|
||||
|
||||
export async function refuse(quoteId: string): Promise<void> {
|
||||
if (git === undefined) throw new MonkeyError(500, "Git not available.");
|
||||
await getNewQuoteCollection().deleteOne({ _id: new ObjectId(quoteId) });
|
||||
}
|
||||
93
backend/src/dal/preset.ts
Normal file
93
backend/src/dal/preset.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import MonkeyError from "../utils/error";
|
||||
import * as db from "../init/db";
|
||||
import { ObjectId, type Filter, Collection, type WithId } from "mongodb";
|
||||
import { EditPresetRequest, Preset } from "@monkeytype/schemas/presets";
|
||||
import { WithObjectId, omit } from "../utils/misc";
|
||||
|
||||
const MAX_PRESETS = 10;
|
||||
|
||||
type DBConfigPreset = WithObjectId<
|
||||
Preset & {
|
||||
uid: string;
|
||||
}
|
||||
>;
|
||||
|
||||
function getPresetKeyFilter(
|
||||
uid: string,
|
||||
keyId: string,
|
||||
): Filter<DBConfigPreset> {
|
||||
return {
|
||||
_id: new ObjectId(keyId),
|
||||
uid,
|
||||
};
|
||||
}
|
||||
|
||||
type PresetCreationResult = {
|
||||
presetId: string;
|
||||
};
|
||||
|
||||
export const getPresetsCollection = (): Collection<WithId<DBConfigPreset>> =>
|
||||
db.collection<DBConfigPreset>("presets");
|
||||
|
||||
export async function getPresets(uid: string): Promise<DBConfigPreset[]> {
|
||||
const presets = await getPresetsCollection()
|
||||
.find({ uid })
|
||||
.sort({ timestamp: -1 })
|
||||
.toArray(); // this needs to be changed to later take patreon into consideration
|
||||
return presets;
|
||||
}
|
||||
|
||||
export async function addPreset(
|
||||
uid: string,
|
||||
preset: Omit<Preset, "_id">,
|
||||
): Promise<PresetCreationResult> {
|
||||
const presets = await getPresetsCollection().countDocuments({ uid });
|
||||
|
||||
if (presets >= MAX_PRESETS) {
|
||||
throw new MonkeyError(409, "Too many presets");
|
||||
}
|
||||
|
||||
const result = await getPresetsCollection().insertOne({
|
||||
...preset,
|
||||
_id: new ObjectId(),
|
||||
uid,
|
||||
});
|
||||
return {
|
||||
presetId: result.insertedId.toHexString(),
|
||||
};
|
||||
}
|
||||
|
||||
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: update,
|
||||
});
|
||||
}
|
||||
|
||||
export async function removePreset(
|
||||
uid: string,
|
||||
presetId: string,
|
||||
): Promise<void> {
|
||||
const deleteResult = await getPresetsCollection().deleteOne(
|
||||
getPresetKeyFilter(uid, presetId),
|
||||
);
|
||||
|
||||
if (deleteResult.deletedCount === 0) {
|
||||
throw new MonkeyError(404, "Preset not found");
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteAllPresets(uid: string): Promise<void> {
|
||||
await getPresetsCollection().deleteMany({ uid });
|
||||
}
|
||||
9
backend/src/dal/psa.ts
Normal file
9
backend/src/dal/psa.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { PSA } from "@monkeytype/schemas/psas";
|
||||
import * as db from "../init/db";
|
||||
import { WithObjectId } from "../utils/misc";
|
||||
|
||||
export type DBPSA = WithObjectId<PSA>;
|
||||
|
||||
export async function get(): Promise<DBPSA[]> {
|
||||
return await db.collection<DBPSA>("psa").find().toArray();
|
||||
}
|
||||
69
backend/src/dal/public.ts
Normal file
69
backend/src/dal/public.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { roundTo2 } from "@monkeytype/util/numbers";
|
||||
import * as db from "../init/db";
|
||||
import MonkeyError from "../utils/error";
|
||||
import { TypingStats, SpeedHistogram } from "@monkeytype/schemas/public";
|
||||
|
||||
export type PublicTypingStatsDB = TypingStats & { _id: "stats" };
|
||||
export type PublicSpeedStatsDB = {
|
||||
_id: "speedStatsHistogram";
|
||||
english_time_15: SpeedHistogram;
|
||||
english_time_60: SpeedHistogram;
|
||||
};
|
||||
|
||||
export async function updateStats(
|
||||
restartCount: number,
|
||||
time: number,
|
||||
): Promise<boolean> {
|
||||
await db.collection<PublicTypingStatsDB>("public").updateOne(
|
||||
{ _id: "stats" },
|
||||
{
|
||||
$inc: {
|
||||
testsCompleted: 1,
|
||||
testsStarted: restartCount + 1,
|
||||
timeTyping: roundTo2(time),
|
||||
},
|
||||
},
|
||||
{ upsert: true },
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Get the histogram stats of speed buckets for all users.
|
||||
* @returns an object mapping wpm => count, eg { '80': 4388, '90': 2149}
|
||||
*/
|
||||
export async function getSpeedHistogram(
|
||||
language: string,
|
||||
mode: string,
|
||||
mode2: string,
|
||||
): Promise<SpeedHistogram> {
|
||||
const key = `${language}_${mode}_${mode2}` as keyof PublicSpeedStatsDB;
|
||||
|
||||
if (key === "_id") {
|
||||
throw new MonkeyError(
|
||||
400,
|
||||
"Invalid speed histogram key",
|
||||
"get speed histogram",
|
||||
);
|
||||
}
|
||||
|
||||
const stats = await db
|
||||
.collection<PublicSpeedStatsDB>("public")
|
||||
.findOne({ _id: "speedStatsHistogram" }, { projection: { [key]: 1 } });
|
||||
|
||||
return stats?.[key] ?? {};
|
||||
}
|
||||
|
||||
/** Get typing stats such as total number of tests completed on site */
|
||||
export async function getTypingStats(): Promise<PublicTypingStatsDB> {
|
||||
const stats = await db
|
||||
.collection<PublicTypingStatsDB>("public")
|
||||
.findOne({ _id: "stats" }, { projection: { _id: 0 } });
|
||||
if (!stats) {
|
||||
throw new MonkeyError(
|
||||
404,
|
||||
"Public typing stats not found",
|
||||
"get typing stats",
|
||||
);
|
||||
}
|
||||
return stats;
|
||||
}
|
||||
54
backend/src/dal/quote-ratings.ts
Normal file
54
backend/src/dal/quote-ratings.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { QuoteRating } from "@monkeytype/schemas/quotes";
|
||||
import * as db from "../init/db";
|
||||
import { Collection } from "mongodb";
|
||||
import { WithObjectId } from "../utils/misc";
|
||||
import { Language } from "@monkeytype/schemas/languages";
|
||||
|
||||
type DBQuoteRating = WithObjectId<QuoteRating>;
|
||||
|
||||
// Export for use in tests
|
||||
export const getQuoteRatingCollection = (): Collection<DBQuoteRating> =>
|
||||
db.collection<DBQuoteRating>("quote-rating");
|
||||
|
||||
export async function submit(
|
||||
quoteId: number,
|
||||
language: Language,
|
||||
rating: number,
|
||||
update: boolean,
|
||||
): Promise<void> {
|
||||
if (update) {
|
||||
await getQuoteRatingCollection().updateOne(
|
||||
{ quoteId, language },
|
||||
{ $inc: { totalRating: rating } },
|
||||
{ upsert: true },
|
||||
);
|
||||
} else {
|
||||
await getQuoteRatingCollection().updateOne(
|
||||
{ quoteId, language },
|
||||
{ $inc: { ratings: 1, totalRating: rating } },
|
||||
{ upsert: true },
|
||||
);
|
||||
}
|
||||
|
||||
const quoteRating = await get(quoteId, language);
|
||||
if (quoteRating === null) {
|
||||
throw new Error("Quote rating is null after adding rating?");
|
||||
}
|
||||
const average = parseFloat(
|
||||
(
|
||||
Math.round((quoteRating.totalRating / quoteRating.ratings) * 10) / 10
|
||||
).toFixed(1),
|
||||
);
|
||||
|
||||
await getQuoteRatingCollection().updateOne(
|
||||
{ quoteId, language },
|
||||
{ $set: { average } },
|
||||
);
|
||||
}
|
||||
|
||||
export async function get(
|
||||
quoteId: number,
|
||||
language: Language,
|
||||
): Promise<DBQuoteRating | null> {
|
||||
return await getQuoteRatingCollection().findOne({ quoteId, language });
|
||||
}
|
||||
71
backend/src/dal/report.ts
Normal file
71
backend/src/dal/report.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import MonkeyError from "../utils/error";
|
||||
import * as db from "../init/db";
|
||||
import { ObjectId } from "mongodb";
|
||||
|
||||
type ReportTypes = "quote" | "user";
|
||||
|
||||
export type DBReport = {
|
||||
_id: ObjectId;
|
||||
id: string;
|
||||
type: ReportTypes;
|
||||
timestamp: number;
|
||||
uid: string;
|
||||
contentId: string;
|
||||
reason: string;
|
||||
comment: string;
|
||||
};
|
||||
|
||||
const COLLECTION_NAME = "reports";
|
||||
|
||||
export async function getReports(reportIds: string[]): Promise<DBReport[]> {
|
||||
const query = { id: { $in: reportIds } };
|
||||
return await db.collection<DBReport>(COLLECTION_NAME).find(query).toArray();
|
||||
}
|
||||
|
||||
export async function deleteReports(reportIds: string[]): Promise<void> {
|
||||
const query = { id: { $in: reportIds } };
|
||||
await db.collection(COLLECTION_NAME).deleteMany(query);
|
||||
}
|
||||
|
||||
export async function createReport(
|
||||
report: DBReport,
|
||||
maxReports: number,
|
||||
contentReportLimit: number,
|
||||
): Promise<void> {
|
||||
if (report.type === "user" && report.contentId === report.uid) {
|
||||
throw new MonkeyError(400, "You cannot report yourself.");
|
||||
}
|
||||
|
||||
const reportsCount = await db
|
||||
.collection<DBReport>(COLLECTION_NAME)
|
||||
.estimatedDocumentCount();
|
||||
|
||||
if (reportsCount >= maxReports) {
|
||||
throw new MonkeyError(
|
||||
503,
|
||||
"Reports are not being accepted at this time due to a large backlog of reports. Please try again later.",
|
||||
);
|
||||
}
|
||||
|
||||
const sameReports = await db
|
||||
.collection<DBReport>(COLLECTION_NAME)
|
||||
.find({ contentId: report.contentId })
|
||||
.toArray();
|
||||
|
||||
if (sameReports.length >= contentReportLimit) {
|
||||
throw new MonkeyError(
|
||||
409,
|
||||
"A report limit for this content has been reached.",
|
||||
);
|
||||
}
|
||||
|
||||
const countFromUserMakingReport = sameReports.filter((r) => {
|
||||
return r.uid === report.uid;
|
||||
}).length;
|
||||
|
||||
if (countFromUserMakingReport > 0) {
|
||||
throw new MonkeyError(409, "You have already reported this content.");
|
||||
}
|
||||
|
||||
await db.collection<DBReport>(COLLECTION_NAME).insertOne(report);
|
||||
}
|
||||
145
backend/src/dal/result.ts
Normal file
145
backend/src/dal/result.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
import {
|
||||
Collection,
|
||||
type DeleteResult,
|
||||
Filter,
|
||||
ObjectId,
|
||||
type UpdateResult,
|
||||
} from "mongodb";
|
||||
import MonkeyError from "../utils/error";
|
||||
import * as db from "../init/db";
|
||||
import { getUser, getTags } from "./user";
|
||||
import { DBResult, replaceLegacyValues } from "../utils/result";
|
||||
import { tryCatch } from "@monkeytype/util/trycatch";
|
||||
|
||||
export const getResultCollection = (): Collection<DBResult> =>
|
||||
db.collection<DBResult>("results");
|
||||
|
||||
export async function addResult(
|
||||
uid: string,
|
||||
result: DBResult,
|
||||
): Promise<{ insertedId: ObjectId }> {
|
||||
const { data: user } = await tryCatch(getUser(uid, "add result"));
|
||||
|
||||
if (!user) throw new MonkeyError(404, "User not found", "add result");
|
||||
result.uid ??= uid;
|
||||
// result.ir = true;
|
||||
const res = await getResultCollection().insertOne(result);
|
||||
return {
|
||||
insertedId: res.insertedId,
|
||||
};
|
||||
}
|
||||
|
||||
export async function deleteAll(uid: string): Promise<DeleteResult> {
|
||||
return await getResultCollection().deleteMany({ uid });
|
||||
}
|
||||
|
||||
export async function updateTags(
|
||||
uid: string,
|
||||
resultId: string,
|
||||
tags: string[],
|
||||
): Promise<UpdateResult> {
|
||||
const result = await getResultCollection().findOne({
|
||||
_id: new ObjectId(resultId),
|
||||
uid,
|
||||
});
|
||||
if (!result) throw new MonkeyError(404, "Result not found");
|
||||
const userTags = await getTags(uid);
|
||||
const userTagIds = new Set(userTags.map((tag) => tag._id.toString()));
|
||||
let validTags = true;
|
||||
tags.forEach((tagId) => {
|
||||
if (!userTagIds.has(tagId)) validTags = false;
|
||||
});
|
||||
if (!validTags) {
|
||||
throw new MonkeyError(422, "One of the tag id's is not valid");
|
||||
}
|
||||
return await getResultCollection().updateOne(
|
||||
{ _id: new ObjectId(resultId), uid },
|
||||
{ $set: { tags } },
|
||||
);
|
||||
}
|
||||
|
||||
export async function getResult(uid: string, id: string): Promise<DBResult> {
|
||||
const result = await getResultCollection().findOne({
|
||||
_id: new ObjectId(id),
|
||||
uid,
|
||||
});
|
||||
|
||||
if (!result) throw new MonkeyError(404, "Result not found");
|
||||
return replaceLegacyValues(result);
|
||||
}
|
||||
|
||||
export async function getLastResult(uid: string): Promise<DBResult> {
|
||||
const lastResult = await getResultCollection().findOne(
|
||||
{ uid },
|
||||
{ sort: { timestamp: -1 } },
|
||||
);
|
||||
|
||||
if (lastResult === null) throw new MonkeyError(404, "No last result found");
|
||||
return replaceLegacyValues(lastResult);
|
||||
}
|
||||
|
||||
export async function getLastResultTimestamp(uid: string): Promise<number> {
|
||||
const lastResult = await getResultCollection().findOne(
|
||||
{ uid },
|
||||
{
|
||||
projection: { timestamp: 1, _id: 0 },
|
||||
sort: { timestamp: -1 },
|
||||
},
|
||||
);
|
||||
|
||||
if (lastResult === null) throw new MonkeyError(404, "No last result found");
|
||||
return lastResult.timestamp;
|
||||
}
|
||||
|
||||
export async function getResultByTimestamp(
|
||||
uid: string,
|
||||
timestamp: number,
|
||||
): Promise<DBResult | null> {
|
||||
const result = await getResultCollection().findOne({ uid, timestamp });
|
||||
if (result === null) return null;
|
||||
return replaceLegacyValues(result);
|
||||
}
|
||||
|
||||
type GetResultsOpts = {
|
||||
onOrAfterTimestamp?: number;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
};
|
||||
|
||||
export async function getResults(
|
||||
uid: string,
|
||||
opts?: GetResultsOpts,
|
||||
): Promise<DBResult[]> {
|
||||
const { onOrAfterTimestamp, offset, limit } = opts ?? {};
|
||||
|
||||
const condition: Filter<DBResult> = { uid };
|
||||
if (
|
||||
onOrAfterTimestamp !== undefined &&
|
||||
onOrAfterTimestamp !== null &&
|
||||
!isNaN(onOrAfterTimestamp)
|
||||
) {
|
||||
condition.timestamp = { $gte: onOrAfterTimestamp };
|
||||
}
|
||||
|
||||
let query = getResultCollection()
|
||||
.find(condition, {
|
||||
projection: {
|
||||
chartData: 0,
|
||||
keySpacingStats: 0,
|
||||
keyDurationStats: 0,
|
||||
name: 0,
|
||||
},
|
||||
})
|
||||
.sort({ timestamp: -1 });
|
||||
|
||||
if (limit !== undefined) {
|
||||
query = query.limit(limit);
|
||||
}
|
||||
if (offset !== undefined) {
|
||||
query = query.skip(offset);
|
||||
}
|
||||
|
||||
const results = await query.toArray();
|
||||
if (results === undefined) throw new MonkeyError(404, "Result not found");
|
||||
return results.map(replaceLegacyValues);
|
||||
}
|
||||
1384
backend/src/dal/user.ts
Normal file
1384
backend/src/dal/user.ts
Normal file
File diff suppressed because it is too large
Load Diff
170
backend/src/init/configuration.ts
Normal file
170
backend/src/init/configuration.ts
Normal file
@@ -0,0 +1,170 @@
|
||||
import * as db from "./db";
|
||||
import { ObjectId } from "mongodb";
|
||||
import Logger from "../utils/logger";
|
||||
import { identity, isPlainObject, omit } from "../utils/misc";
|
||||
import { BASE_CONFIGURATION } from "../constants/base-configuration";
|
||||
import { Configuration } from "@monkeytype/schemas/configuration";
|
||||
import { addLog } from "../dal/logs";
|
||||
import {
|
||||
PartialConfiguration,
|
||||
PartialConfigurationSchema,
|
||||
} from "@monkeytype/contracts/configuration";
|
||||
import { getErrorMessage } from "../utils/error";
|
||||
import { join } from "path";
|
||||
import { existsSync, readFileSync } from "fs";
|
||||
import { parseWithSchema as parseJsonWithSchema } from "@monkeytype/util/json";
|
||||
import { z } from "zod";
|
||||
import { intersect } from "@monkeytype/util/arrays";
|
||||
|
||||
const CONFIG_UPDATE_INTERVAL = 10 * 60 * 1000; // 10 Minutes
|
||||
const SERVER_CONFIG_FILE_PATH = join(
|
||||
__dirname,
|
||||
"../backend-configuration.json",
|
||||
);
|
||||
|
||||
function mergeConfigurations(
|
||||
baseConfiguration: Configuration,
|
||||
liveConfiguration: PartialConfiguration,
|
||||
): void {
|
||||
if (!isPlainObject(baseConfiguration) || !isPlainObject(liveConfiguration)) {
|
||||
return;
|
||||
}
|
||||
|
||||
function merge(base: object, source: object): void {
|
||||
const commonKeys = intersect(Object.keys(base), Object.keys(source), true);
|
||||
|
||||
commonKeys.forEach((key) => {
|
||||
const baseValue = base[key] as object;
|
||||
const sourceValue = source[key] as object;
|
||||
|
||||
const isBaseValueObject = isPlainObject(baseValue);
|
||||
const isSourceValueObject = isPlainObject(sourceValue);
|
||||
|
||||
if (isBaseValueObject && isSourceValueObject) {
|
||||
merge(baseValue, sourceValue);
|
||||
} else if (identity(baseValue) === identity(sourceValue)) {
|
||||
base[key] = sourceValue;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
merge(baseConfiguration, liveConfiguration);
|
||||
}
|
||||
|
||||
let configuration = BASE_CONFIGURATION;
|
||||
let lastFetchTime = 0;
|
||||
let serverConfigurationUpdated = false;
|
||||
|
||||
export async function getCachedConfiguration(
|
||||
attemptCacheUpdate = false,
|
||||
): Promise<Configuration> {
|
||||
if (
|
||||
attemptCacheUpdate &&
|
||||
lastFetchTime < Date.now() - CONFIG_UPDATE_INTERVAL
|
||||
) {
|
||||
Logger.info("Cached configuration is stale.");
|
||||
return await getLiveConfiguration();
|
||||
}
|
||||
|
||||
return configuration;
|
||||
}
|
||||
|
||||
export async function getLiveConfiguration(): Promise<Configuration> {
|
||||
lastFetchTime = Date.now();
|
||||
|
||||
const configurationCollection = db.collection("configuration");
|
||||
|
||||
try {
|
||||
const liveConfiguration = await configurationCollection.findOne();
|
||||
|
||||
if (liveConfiguration) {
|
||||
const baseConfiguration = structuredClone(BASE_CONFIGURATION);
|
||||
|
||||
const liveConfigurationWithoutId = omit(liveConfiguration, [
|
||||
"_id",
|
||||
]) as Configuration;
|
||||
mergeConfigurations(baseConfiguration, liveConfigurationWithoutId);
|
||||
|
||||
await pushConfiguration(baseConfiguration);
|
||||
configuration = baseConfiguration;
|
||||
} else {
|
||||
await configurationCollection.insertOne({
|
||||
...BASE_CONFIGURATION,
|
||||
_id: new ObjectId(),
|
||||
}); // Seed the base configuration.
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = getErrorMessage(error) ?? "Unknown error";
|
||||
void addLog(
|
||||
"fetch_configuration_failure",
|
||||
`Could not fetch configuration: ${errorMessage}`,
|
||||
);
|
||||
}
|
||||
|
||||
return configuration;
|
||||
}
|
||||
|
||||
async function pushConfiguration(
|
||||
configurationToPush: Configuration,
|
||||
): Promise<void> {
|
||||
if (serverConfigurationUpdated) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await db.collection("configuration").replaceOne({}, configurationToPush);
|
||||
serverConfigurationUpdated = true;
|
||||
} catch (error) {
|
||||
const errorMessage = getErrorMessage(error) ?? "Unknown error";
|
||||
void addLog(
|
||||
"push_configuration_failure",
|
||||
`Could not push configuration: ${errorMessage}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function patchConfiguration(
|
||||
configurationUpdates: PartialConfiguration,
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const currentConfiguration = structuredClone(configuration);
|
||||
mergeConfigurations(currentConfiguration, configurationUpdates);
|
||||
|
||||
await db
|
||||
.collection("configuration")
|
||||
.updateOne({}, { $set: currentConfiguration }, { upsert: true });
|
||||
|
||||
await getLiveConfiguration();
|
||||
} catch (error) {
|
||||
const errorMessage = getErrorMessage(error) ?? "Unknown error";
|
||||
void addLog(
|
||||
"patch_configuration_failure",
|
||||
`Could not patch configuration: ${errorMessage}`,
|
||||
);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function updateFromConfigurationFile(): Promise<void> {
|
||||
if (existsSync(SERVER_CONFIG_FILE_PATH)) {
|
||||
Logger.info(
|
||||
`Reading server configuration from file ${SERVER_CONFIG_FILE_PATH}`,
|
||||
);
|
||||
const json = readFileSync(SERVER_CONFIG_FILE_PATH, "utf-8");
|
||||
const data = parseJsonWithSchema(
|
||||
json,
|
||||
z.object({
|
||||
configuration: PartialConfigurationSchema,
|
||||
}),
|
||||
);
|
||||
|
||||
await patchConfiguration(data.configuration);
|
||||
}
|
||||
}
|
||||
|
||||
export const __testing = {
|
||||
mergeConfigurations,
|
||||
};
|
||||
80
backend/src/init/db.ts
Normal file
80
backend/src/init/db.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import {
|
||||
AuthMechanism,
|
||||
Collection,
|
||||
Db,
|
||||
MongoClient,
|
||||
type MongoClientOptions,
|
||||
type WithId,
|
||||
} from "mongodb";
|
||||
import MonkeyError, { getErrorMessage } from "../utils/error";
|
||||
import Logger from "../utils/logger";
|
||||
|
||||
let db: Db;
|
||||
let mongoClient: MongoClient;
|
||||
|
||||
export async function connect(): Promise<void> {
|
||||
const {
|
||||
DB_USERNAME,
|
||||
DB_PASSWORD,
|
||||
DB_AUTH_MECHANISM,
|
||||
DB_AUTH_SOURCE,
|
||||
DB_URI,
|
||||
DB_NAME,
|
||||
} = process.env;
|
||||
|
||||
const authProvided =
|
||||
DB_USERNAME !== undefined &&
|
||||
DB_USERNAME !== "" &&
|
||||
DB_PASSWORD !== undefined &&
|
||||
DB_PASSWORD !== "";
|
||||
const uriProvided = DB_URI !== undefined && DB_URI !== "";
|
||||
const nameProvided = DB_NAME !== undefined && DB_NAME !== "";
|
||||
|
||||
if (!nameProvided || !uriProvided) {
|
||||
throw new Error("No database configuration provided");
|
||||
}
|
||||
|
||||
const auth = authProvided
|
||||
? {
|
||||
username: DB_USERNAME,
|
||||
password: DB_PASSWORD,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const connectionOptions: MongoClientOptions = {
|
||||
connectTimeoutMS: 2000,
|
||||
serverSelectionTimeoutMS: 2000,
|
||||
auth: auth,
|
||||
authMechanism: DB_AUTH_MECHANISM as AuthMechanism | undefined,
|
||||
authSource: DB_AUTH_SOURCE,
|
||||
};
|
||||
|
||||
mongoClient = new MongoClient(
|
||||
DB_URI ?? global.__MONGO_URI__, // Set in tests only
|
||||
connectionOptions,
|
||||
);
|
||||
|
||||
try {
|
||||
await mongoClient.connect();
|
||||
db = mongoClient.db(DB_NAME);
|
||||
} catch (error) {
|
||||
Logger.error(getErrorMessage(error) ?? "Unknown error");
|
||||
Logger.error(
|
||||
"Failed to connect to database. Exiting with exit status code 1.",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
export const getDb = (): Db | undefined => db;
|
||||
|
||||
export function collection<T>(collectionName: string): Collection<WithId<T>> {
|
||||
if (db === undefined) {
|
||||
throw new MonkeyError(500, "Database is not initialized.");
|
||||
}
|
||||
|
||||
return db.collection<WithId<T>>(collectionName);
|
||||
}
|
||||
export async function close(): Promise<void> {
|
||||
await mongoClient?.close();
|
||||
}
|
||||
167
backend/src/init/email-client.ts
Normal file
167
backend/src/init/email-client.ts
Normal file
@@ -0,0 +1,167 @@
|
||||
import * as nodemailer from "nodemailer";
|
||||
import Logger from "../utils/logger";
|
||||
import fs from "fs";
|
||||
import { join } from "path";
|
||||
import mjml2html from "mjml";
|
||||
import mustache from "mustache";
|
||||
import { recordEmail } from "../utils/prometheus";
|
||||
import type { EmailTaskContexts, EmailType } from "../queues/email-queue";
|
||||
import { isDevEnvironment } from "../utils/misc";
|
||||
import { getErrorMessage } from "../utils/error";
|
||||
import { tryCatch } from "@monkeytype/util/trycatch";
|
||||
|
||||
type EmailMetadata = {
|
||||
subject: string;
|
||||
templateName: string;
|
||||
};
|
||||
|
||||
const templates: Record<EmailType, EmailMetadata> = {
|
||||
verify: {
|
||||
subject: "Verify your Monkeytype account",
|
||||
templateName: "verification.html",
|
||||
},
|
||||
resetPassword: {
|
||||
subject: "Reset your Monkeytype password",
|
||||
templateName: "reset-password.html",
|
||||
},
|
||||
};
|
||||
|
||||
let transportInitialized = false;
|
||||
let transporter: nodemailer.Transporter;
|
||||
let emailFrom = "Monkeytype <noreply@monkeytype.com>";
|
||||
|
||||
export function isInitialized(): boolean {
|
||||
return transportInitialized;
|
||||
}
|
||||
|
||||
export async function init(): Promise<void> {
|
||||
if (isInitialized()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { EMAIL_HOST, EMAIL_USER, EMAIL_PASS, EMAIL_PORT, EMAIL_FROM } =
|
||||
process.env;
|
||||
|
||||
if (EMAIL_FROM !== undefined) {
|
||||
emailFrom = EMAIL_FROM;
|
||||
}
|
||||
|
||||
if (!(EMAIL_HOST ?? "") || !(EMAIL_USER ?? "") || !(EMAIL_PASS ?? "")) {
|
||||
if (isDevEnvironment()) {
|
||||
Logger.warning(
|
||||
"No email client configuration provided. Running without email.",
|
||||
);
|
||||
} else if (process.env["BYPASS_EMAILCLIENT"] === "true") {
|
||||
Logger.warning("BYPASS_EMAILCLIENT is enabled! Running without email.");
|
||||
} else {
|
||||
throw new Error("No email client configuration provided");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
transporter = nodemailer.createTransport({
|
||||
host: EMAIL_HOST,
|
||||
secure: EMAIL_PORT === "465",
|
||||
port: parseInt(EMAIL_PORT ?? "578", 10),
|
||||
auth: {
|
||||
user: EMAIL_USER,
|
||||
pass: EMAIL_PASS,
|
||||
},
|
||||
});
|
||||
transportInitialized = true;
|
||||
|
||||
Logger.info("Verifying email client configuration...");
|
||||
const result = await transporter.verify();
|
||||
|
||||
if (!result) {
|
||||
throw new Error(
|
||||
`Could not verify email client configuration: ` +
|
||||
JSON.stringify(result),
|
||||
);
|
||||
}
|
||||
|
||||
Logger.success("Email client configuration verified");
|
||||
} catch (error) {
|
||||
transportInitialized = false;
|
||||
Logger.error(getErrorMessage(error) ?? "Unknown error");
|
||||
Logger.error("Failed to verify email client configuration.");
|
||||
}
|
||||
}
|
||||
|
||||
type MailResult = {
|
||||
success: boolean;
|
||||
message: string;
|
||||
};
|
||||
|
||||
export async function sendEmail(
|
||||
templateName: EmailType,
|
||||
to: string,
|
||||
data: EmailTaskContexts[EmailType],
|
||||
): Promise<MailResult> {
|
||||
if (!isInitialized()) {
|
||||
return {
|
||||
success: false,
|
||||
message: "Email client transport not initialized",
|
||||
};
|
||||
}
|
||||
|
||||
const template = await fillTemplate<typeof templateName>(templateName, data);
|
||||
|
||||
const mailOptions = {
|
||||
from: emailFrom,
|
||||
to,
|
||||
subject: templates[templateName].subject,
|
||||
html: template,
|
||||
};
|
||||
|
||||
type Result = { response: string; accepted: string[] };
|
||||
|
||||
const { data: result, error } = await tryCatch(
|
||||
transporter.sendMail(mailOptions) as Promise<Result>,
|
||||
);
|
||||
|
||||
if (error) {
|
||||
recordEmail(templateName, "fail");
|
||||
return {
|
||||
success: false,
|
||||
message: getErrorMessage(error) ?? "Unknown error",
|
||||
};
|
||||
}
|
||||
|
||||
recordEmail(templateName, result.accepted.length === 0 ? "fail" : "success");
|
||||
|
||||
return {
|
||||
success: result.accepted.length !== 0,
|
||||
message: result.response,
|
||||
};
|
||||
}
|
||||
|
||||
const EMAIL_TEMPLATES_DIRECTORY = join(__dirname, "../../email-templates");
|
||||
|
||||
const cachedTemplates: Record<string, string> = {};
|
||||
|
||||
async function getTemplate(name: string): Promise<string> {
|
||||
const cachedTemp = cachedTemplates[name];
|
||||
if (cachedTemp !== undefined) {
|
||||
return cachedTemp;
|
||||
}
|
||||
|
||||
const template = await fs.promises.readFile(
|
||||
`${EMAIL_TEMPLATES_DIRECTORY}/${name}`,
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
const html = mjml2html(template).html;
|
||||
|
||||
cachedTemplates[name] = html;
|
||||
return html;
|
||||
}
|
||||
|
||||
async function fillTemplate<M extends EmailType>(
|
||||
type: M,
|
||||
data: EmailTaskContexts[M],
|
||||
): Promise<string> {
|
||||
const template = await getTemplate(templates[type].templateName);
|
||||
return mustache.render(template, data);
|
||||
}
|
||||
47
backend/src/init/firebase-admin.ts
Normal file
47
backend/src/init/firebase-admin.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import admin from "firebase-admin";
|
||||
import Logger from "../utils/logger";
|
||||
import { existsSync } from "fs";
|
||||
import MonkeyError from "../utils/error";
|
||||
import path from "path";
|
||||
import { isDevEnvironment } from "../utils/misc";
|
||||
|
||||
const SERVICE_ACCOUNT_PATH = path.join(
|
||||
__dirname,
|
||||
"../../src/credentials/serviceAccountKey.json",
|
||||
);
|
||||
|
||||
export function init(): void {
|
||||
if (!existsSync(SERVICE_ACCOUNT_PATH)) {
|
||||
if (isDevEnvironment()) {
|
||||
Logger.warning(
|
||||
"Firebase service account key not found! Continuing in dev mode, but authentication will throw errors.",
|
||||
);
|
||||
} else if (process.env["BYPASS_FIREBASE"] === "true") {
|
||||
Logger.warning("BYPASS_FIREBASE is enabled! Running without firebase.");
|
||||
} else {
|
||||
throw new MonkeyError(
|
||||
500,
|
||||
"Firebase service account key not found! Make sure generate a service account key and place it in credentials/serviceAccountKey.json.",
|
||||
"init() firebase-admin.ts",
|
||||
);
|
||||
}
|
||||
} else {
|
||||
admin.initializeApp({
|
||||
credential: admin.credential.cert(SERVICE_ACCOUNT_PATH),
|
||||
});
|
||||
Logger.success("Firebase app initialized");
|
||||
}
|
||||
}
|
||||
|
||||
function get(): typeof admin {
|
||||
if (admin.apps.length === 0) {
|
||||
throw new MonkeyError(
|
||||
500,
|
||||
"Firebase app not initialized! Make sure generate a service account key and place it in credentials/serviceAccountKey.json.",
|
||||
"get() firebase-admin.ts",
|
||||
);
|
||||
}
|
||||
return admin;
|
||||
}
|
||||
|
||||
export default get;
|
||||
130
backend/src/init/redis.ts
Normal file
130
backend/src/init/redis.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import fs from "fs";
|
||||
import { join } from "path";
|
||||
import IORedis, { Redis } from "ioredis";
|
||||
import Logger from "../utils/logger";
|
||||
import { isDevEnvironment } from "../utils/misc";
|
||||
import { getErrorMessage } from "../utils/error";
|
||||
import { kebabToCamelCase } from "@monkeytype/util/strings";
|
||||
|
||||
// Define Redis connection with custom methods for type safety
|
||||
export type RedisConnectionWithCustomMethods = Redis & {
|
||||
addResult: (
|
||||
keyCount: number,
|
||||
scoresKey: string,
|
||||
resultsKey: string,
|
||||
maxResults: number,
|
||||
expirationTime: number,
|
||||
uid: string,
|
||||
score: number,
|
||||
data: string,
|
||||
) => Promise<number>;
|
||||
addResultIncrement: (
|
||||
keyCount: number,
|
||||
scoresKey: string,
|
||||
resultsKey: string,
|
||||
expirationTime: number,
|
||||
uid: string,
|
||||
score: number,
|
||||
data: string,
|
||||
) => Promise<number>;
|
||||
getResults: (
|
||||
keyCount: number,
|
||||
scoresKey: string,
|
||||
resultsKey: string,
|
||||
minRank: number,
|
||||
maxRank: number,
|
||||
withScores: string,
|
||||
userIds: string,
|
||||
) => Promise<
|
||||
[string[], string[], string, [string, string | number], string[]]
|
||||
>; //entries, scores(optional), count, min_score(optiona)[uid, score], ranks(optional)
|
||||
getRank: (
|
||||
keyCount: number,
|
||||
scoresKey: string,
|
||||
resultsKey: string,
|
||||
uid: string,
|
||||
withScores: string,
|
||||
userIds: string,
|
||||
) => Promise<[number, string, string, number]>; //rank, score(optional), entry json, friendsRank(optional)
|
||||
purgeResults: (
|
||||
keyCount: number,
|
||||
uid: string,
|
||||
namespace: string,
|
||||
) => Promise<void>;
|
||||
};
|
||||
|
||||
let connection: IORedis.Redis;
|
||||
let connected = false;
|
||||
|
||||
const REDIS_SCRIPTS_DIRECTORY_PATH = join(__dirname, "../../redis-scripts");
|
||||
|
||||
function loadScripts(client: IORedis.Redis): void {
|
||||
const scriptFiles = fs.readdirSync(REDIS_SCRIPTS_DIRECTORY_PATH);
|
||||
|
||||
scriptFiles.forEach((scriptFile) => {
|
||||
const scriptPath = join(REDIS_SCRIPTS_DIRECTORY_PATH, scriptFile);
|
||||
const scriptSource = fs.readFileSync(scriptPath, "utf-8");
|
||||
const scriptName = kebabToCamelCase(scriptFile.split(".")[0] as string);
|
||||
|
||||
client.defineCommand(scriptName, {
|
||||
lua: scriptSource,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function connect(): Promise<void> {
|
||||
if (connected) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { REDIS_URI } = process.env;
|
||||
|
||||
if (!(REDIS_URI ?? "")) {
|
||||
if (isDevEnvironment()) {
|
||||
Logger.warning("No redis configuration provided. Running without redis.");
|
||||
return;
|
||||
}
|
||||
throw new Error("No redis configuration provided");
|
||||
}
|
||||
|
||||
connection = new IORedis(REDIS_URI, {
|
||||
maxRetriesPerRequest: null, // These options are required for BullMQ
|
||||
enableReadyCheck: false,
|
||||
lazyConnect: true,
|
||||
});
|
||||
|
||||
try {
|
||||
await connection.connect();
|
||||
|
||||
Logger.info("Loading custom redis scripts...");
|
||||
loadScripts(connection);
|
||||
|
||||
connected = true;
|
||||
} catch (error) {
|
||||
Logger.error(getErrorMessage(error) ?? "Unknown error");
|
||||
if (isDevEnvironment()) {
|
||||
await connection.quit();
|
||||
Logger.warning(
|
||||
`Failed to connect to redis. Continuing in dev mode, running without redis.`,
|
||||
);
|
||||
} else {
|
||||
Logger.error(
|
||||
"Failed to connect to redis. Exiting with exit status code 1.",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function isConnected(): boolean {
|
||||
return connected;
|
||||
}
|
||||
|
||||
export function getConnection(): RedisConnectionWithCustomMethods | null {
|
||||
const status = connection?.status;
|
||||
if (connection === undefined || status !== "ready") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return connection as RedisConnectionWithCustomMethods;
|
||||
}
|
||||
28
backend/src/jobs/delete-old-logs.ts
Normal file
28
backend/src/jobs/delete-old-logs.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { CronJob } from "cron";
|
||||
import * as db from "../init/db";
|
||||
import { getCachedConfiguration } from "../init/configuration";
|
||||
import { addLog } from "../dal/logs";
|
||||
|
||||
const CRON_SCHEDULE = "0 0 0 * * *";
|
||||
const LOG_MAX_AGE_DAYS = 30;
|
||||
const LOG_MAX_AGE_MILLISECONDS = LOG_MAX_AGE_DAYS * 24 * 60 * 60 * 1000;
|
||||
|
||||
async function deleteOldLogs(): Promise<void> {
|
||||
const { maintenance } = await getCachedConfiguration();
|
||||
if (maintenance) {
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await db.collection("logs").deleteMany({
|
||||
timestamp: { $lt: Date.now() - LOG_MAX_AGE_MILLISECONDS },
|
||||
$or: [{ important: false }, { important: { $exists: false } }],
|
||||
});
|
||||
|
||||
void addLog(
|
||||
"system_logs_deleted",
|
||||
`${data.deletedCount} logs deleted older than ${LOG_MAX_AGE_DAYS} day(s)`,
|
||||
undefined,
|
||||
);
|
||||
}
|
||||
|
||||
export default new CronJob(CRON_SCHEDULE, deleteOldLogs);
|
||||
11
backend/src/jobs/index.ts
Normal file
11
backend/src/jobs/index.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import updateLeaderboards from "./update-leaderboards";
|
||||
import deleteOldLogs from "./delete-old-logs";
|
||||
import logCollectionSizes from "./log-collection-sizes";
|
||||
import logQueueSizes from "./log-queue-sizes";
|
||||
|
||||
export default [
|
||||
updateLeaderboards,
|
||||
deleteOldLogs,
|
||||
logCollectionSizes,
|
||||
logQueueSizes,
|
||||
];
|
||||
27
backend/src/jobs/log-collection-sizes.ts
Normal file
27
backend/src/jobs/log-collection-sizes.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { CronJob } from "cron";
|
||||
import * as db from "../init/db";
|
||||
import * as Prometheus from "../utils/prometheus";
|
||||
|
||||
const CRON_SCHEDULE = "0 */5 * * * *";
|
||||
|
||||
const collectionsToLog = [
|
||||
"ape-keys",
|
||||
"configs",
|
||||
"errors",
|
||||
"logs",
|
||||
"presets",
|
||||
"reports",
|
||||
"results",
|
||||
"users",
|
||||
];
|
||||
|
||||
async function main(): Promise<void> {
|
||||
await Promise.all(collectionsToLog.map(set));
|
||||
}
|
||||
|
||||
async function set(collection: string): Promise<void> {
|
||||
const size = await db.collection(collection).estimatedDocumentCount();
|
||||
Prometheus.setCollectionSize(collection, size);
|
||||
}
|
||||
|
||||
export default new CronJob(CRON_SCHEDULE, main);
|
||||
31
backend/src/jobs/log-queue-sizes.ts
Normal file
31
backend/src/jobs/log-queue-sizes.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { CronJob } from "cron";
|
||||
import Queues from "../queues/index";
|
||||
import { setQueueLength } from "../utils/prometheus";
|
||||
|
||||
const CRON_SCHEDULE = "0 */5 * * * *";
|
||||
|
||||
async function main(): Promise<void> {
|
||||
await Promise.all(
|
||||
Queues.map(async (queue) => {
|
||||
const counts = await queue.getJobCounts();
|
||||
|
||||
const active = counts["active"] ?? 0;
|
||||
const completed = counts["completed"] ?? 0;
|
||||
const failed = counts["failed"] ?? 0;
|
||||
|
||||
const waiting = counts["waiting"] ?? 0;
|
||||
const paused = counts["paused"] ?? 0;
|
||||
const delayed = counts["delayed"] ?? 0;
|
||||
const waitingChildren = counts["waiting-children"] ?? 0;
|
||||
|
||||
const waitingTotal = waiting + paused + delayed + waitingChildren;
|
||||
|
||||
setQueueLength(queue.queueName, "completed", completed);
|
||||
setQueueLength(queue.queueName, "active", active);
|
||||
setQueueLength(queue.queueName, "failed", failed);
|
||||
setQueueLength(queue.queueName, "waiting", waitingTotal);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export default new CronJob(CRON_SCHEDULE, main);
|
||||
69
backend/src/jobs/update-leaderboards.ts
Normal file
69
backend/src/jobs/update-leaderboards.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { CronJob } from "cron";
|
||||
import GeorgeQueue from "../queues/george-queue";
|
||||
import * as LeaderboardsDAL from "../dal/leaderboards";
|
||||
import { getCachedConfiguration } from "../init/configuration";
|
||||
|
||||
const CRON_SCHEDULE = "30 14/15 * * * *";
|
||||
const RECENT_AGE_MINUTES = 10;
|
||||
const RECENT_AGE_MILLISECONDS = RECENT_AGE_MINUTES * 60 * 1000;
|
||||
|
||||
async function getTop10(
|
||||
leaderboardTime: string,
|
||||
): Promise<LeaderboardsDAL.DBLeaderboardEntry[]> {
|
||||
return (await LeaderboardsDAL.get(
|
||||
"time",
|
||||
leaderboardTime,
|
||||
"english",
|
||||
0,
|
||||
10,
|
||||
)) as LeaderboardsDAL.DBLeaderboardEntry[]; //can do that because gettop10 will not be called during an update
|
||||
}
|
||||
|
||||
async function updateLeaderboardAndNotifyChanges(
|
||||
leaderboardTime: string,
|
||||
): Promise<void> {
|
||||
const top10BeforeUpdate = await getTop10(leaderboardTime);
|
||||
|
||||
const previousRecordsMap = Object.fromEntries(
|
||||
top10BeforeUpdate.map((record) => {
|
||||
return [record.uid, record];
|
||||
}),
|
||||
);
|
||||
|
||||
await LeaderboardsDAL.update("time", leaderboardTime, "english");
|
||||
|
||||
const top10AfterUpdate = await getTop10(leaderboardTime);
|
||||
|
||||
const newRecords = top10AfterUpdate.filter((record) => {
|
||||
const userId = record.uid;
|
||||
|
||||
const previousMapUser = previousRecordsMap[userId];
|
||||
const userImprovedRank =
|
||||
previousMapUser && previousMapUser.rank > record.rank;
|
||||
|
||||
const newUserInTop10 = !(userId in previousRecordsMap);
|
||||
|
||||
const isRecentRecord =
|
||||
record.timestamp > Date.now() - RECENT_AGE_MILLISECONDS;
|
||||
|
||||
return (userImprovedRank === true || newUserInTop10) && isRecentRecord;
|
||||
});
|
||||
|
||||
if (newRecords.length > 0) {
|
||||
const leaderboardId = `time ${leaderboardTime} english`;
|
||||
|
||||
await GeorgeQueue.announceLeaderboardUpdate(newRecords, leaderboardId);
|
||||
}
|
||||
}
|
||||
|
||||
async function updateLeaderboards(): Promise<void> {
|
||||
const { maintenance } = await getCachedConfiguration();
|
||||
if (maintenance) {
|
||||
return;
|
||||
}
|
||||
|
||||
await updateLeaderboardAndNotifyChanges("60");
|
||||
await updateLeaderboardAndNotifyChanges("15");
|
||||
}
|
||||
|
||||
export default new CronJob(CRON_SCHEDULE, updateLeaderboards);
|
||||
364
backend/src/middlewares/auth.ts
Normal file
364
backend/src/middlewares/auth.ts
Normal file
@@ -0,0 +1,364 @@
|
||||
import { compare } from "bcrypt";
|
||||
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 } from "express";
|
||||
import statuses from "../constants/monkey-status-codes";
|
||||
import {
|
||||
incrementAuth,
|
||||
recordAuthTime,
|
||||
recordRequestCountry,
|
||||
} from "../utils/prometheus";
|
||||
import crypto from "crypto";
|
||||
import { performance } from "perf_hooks";
|
||||
import { TsRestRequestHandler } from "@ts-rest/express";
|
||||
import { AppRoute, AppRouter } from "@ts-rest/core";
|
||||
import {
|
||||
EndpointMetadata,
|
||||
RequestAuthenticationOptions,
|
||||
} from "@monkeytype/contracts/util/api";
|
||||
import { Configuration } from "@monkeytype/schemas/configuration";
|
||||
import { getMetadata } from "./utility";
|
||||
import { TsRestRequestWithContext } from "../api/types";
|
||||
|
||||
export type DecodedToken = {
|
||||
type: "Bearer" | "ApeKey" | "None" | "GithubWebhook";
|
||||
uid: string;
|
||||
email: string;
|
||||
};
|
||||
|
||||
const DEFAULT_OPTIONS: RequestAuthenticationOptions = {
|
||||
isGithubWebhook: false,
|
||||
isPublic: false,
|
||||
acceptApeKeys: false,
|
||||
requireFreshToken: false,
|
||||
isPublicOnDev: false,
|
||||
};
|
||||
|
||||
/**
|
||||
* Authenticate request based on the auth settings of the route.
|
||||
* By default a Bearer token with user authentication is required.
|
||||
* @returns
|
||||
*/
|
||||
export function authenticateTsRestRequest<
|
||||
T extends AppRouter | AppRoute,
|
||||
>(): TsRestRequestHandler<T> {
|
||||
return async (
|
||||
req: TsRestRequestWithContext,
|
||||
_res: Response,
|
||||
next: NextFunction,
|
||||
): Promise<void> => {
|
||||
const options = {
|
||||
...DEFAULT_OPTIONS,
|
||||
...((getMetadata(req).authenticationOptions ?? {}) as EndpointMetadata),
|
||||
};
|
||||
|
||||
const startTime = performance.now();
|
||||
let token: DecodedToken;
|
||||
let authType = "None";
|
||||
|
||||
const isPublic =
|
||||
options.isPublic === true ||
|
||||
(options.isPublicOnDev && isDevEnvironment());
|
||||
|
||||
const {
|
||||
authorization: authHeader,
|
||||
"x-hub-signature-256": githubWebhookHeader,
|
||||
} = req.headers;
|
||||
|
||||
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`,
|
||||
);
|
||||
}
|
||||
|
||||
incrementAuth(token.type);
|
||||
|
||||
req.ctx = {
|
||||
...req.ctx,
|
||||
decodedToken: token,
|
||||
};
|
||||
} catch (error) {
|
||||
authType = authHeader?.split(" ")[0] ?? "None";
|
||||
|
||||
recordAuthTime(
|
||||
authType,
|
||||
"failure",
|
||||
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);
|
||||
}
|
||||
|
||||
// if (req.method !== "OPTIONS" && req?.ctx?.decodedToken?.uid) {
|
||||
// recordRequestForUid(req.ctx.decodedToken.uid);
|
||||
// }
|
||||
|
||||
next();
|
||||
};
|
||||
}
|
||||
|
||||
async function authenticateWithAuthHeader(
|
||||
authHeader: string,
|
||||
configuration: Configuration,
|
||||
options: RequestAuthenticationOptions,
|
||||
): Promise<DecodedToken> {
|
||||
const [authScheme, token] = authHeader.split(" ");
|
||||
|
||||
if (token === undefined) {
|
||||
throw new MonkeyError(
|
||||
401,
|
||||
"Missing authentication token",
|
||||
"authenticateWithAuthHeader",
|
||||
);
|
||||
}
|
||||
|
||||
const normalizedAuthScheme = authScheme?.trim();
|
||||
|
||||
switch (normalizedAuthScheme) {
|
||||
case "Bearer":
|
||||
return await authenticateWithBearerToken(token, options);
|
||||
case "ApeKey":
|
||||
return await authenticateWithApeKey(token, configuration, options);
|
||||
case "Uid":
|
||||
return await authenticateWithUid(token);
|
||||
}
|
||||
|
||||
throw new MonkeyError(
|
||||
401,
|
||||
"Unknown authentication scheme",
|
||||
`The authentication scheme "${authScheme}" is not implemented`,
|
||||
);
|
||||
}
|
||||
|
||||
async function authenticateWithBearerToken(
|
||||
token: string,
|
||||
options: RequestAuthenticationOptions,
|
||||
): Promise<DecodedToken> {
|
||||
try {
|
||||
const decodedToken = await verifyIdToken(
|
||||
token,
|
||||
(options.requireFreshToken ?? false) || (options.noCache ?? false),
|
||||
);
|
||||
|
||||
if (options.requireFreshToken) {
|
||||
const now = Date.now();
|
||||
const tokenIssuedAt = new Date(decodedToken.iat * 1000).getTime();
|
||||
|
||||
if (now - tokenIssuedAt > 60 * 1000) {
|
||||
throw new MonkeyError(
|
||||
401,
|
||||
"Unauthorized",
|
||||
`This endpoint requires a fresh token`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
type: "Bearer",
|
||||
uid: decodedToken.uid,
|
||||
email: decodedToken.email ?? "",
|
||||
};
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof Error &&
|
||||
error.message.includes("An internal error has occurred")
|
||||
) {
|
||||
throw new MonkeyError(
|
||||
503,
|
||||
"Firebase returned an internal error when trying to verify the token.",
|
||||
"authenticateWithBearerToken",
|
||||
);
|
||||
}
|
||||
|
||||
// oxlint-disable-next-line no-unsafe-member-access
|
||||
const errorCode = error?.errorInfo?.code as string | 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")) {
|
||||
throw new MonkeyError(
|
||||
401,
|
||||
"Token revoked - please login again",
|
||||
"authenticateWithBearerToken",
|
||||
);
|
||||
} else if (errorCode?.includes("auth/user-not-found")) {
|
||||
throw new MonkeyError(
|
||||
404,
|
||||
"User not found",
|
||||
"authenticateWithBearerToken",
|
||||
);
|
||||
} else if (errorCode?.includes("auth/argument-error")) {
|
||||
throw new MonkeyError(
|
||||
400,
|
||||
"Incorrect Bearer token format",
|
||||
"authenticateWithBearerToken",
|
||||
);
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function authenticateWithApeKey(
|
||||
key: string,
|
||||
configuration: Configuration,
|
||||
options: RequestAuthenticationOptions,
|
||||
): Promise<DecodedToken> {
|
||||
const isPublic =
|
||||
options.isPublic === true || (options.isPublicOnDev && isDevEnvironment());
|
||||
|
||||
if (!isPublic) {
|
||||
if (!configuration.apeKeys.acceptKeys) {
|
||||
throw new MonkeyError(503, "ApeKeys are not being accepted at this time");
|
||||
}
|
||||
|
||||
if (!options.acceptApeKeys) {
|
||||
throw new MonkeyError(401, "This endpoint does not accept ApeKeys");
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const decodedKey = base64UrlDecode(key);
|
||||
const [keyId, apeKey] = decodedKey.split(".");
|
||||
|
||||
if (
|
||||
keyId === undefined ||
|
||||
keyId === "" ||
|
||||
apeKey === undefined ||
|
||||
apeKey === ""
|
||||
) {
|
||||
throw new MonkeyError(400, "Malformed ApeKey");
|
||||
}
|
||||
|
||||
const targetApeKey = await getApeKey(keyId);
|
||||
if (!targetApeKey) {
|
||||
throw new MonkeyError(404, "ApeKey not found");
|
||||
}
|
||||
|
||||
if (!targetApeKey.enabled) {
|
||||
const { code, message } = statuses.APE_KEY_INACTIVE;
|
||||
throw new MonkeyError(code, message);
|
||||
}
|
||||
|
||||
const isKeyValid = await compare(apeKey, targetApeKey.hash);
|
||||
if (!isKeyValid) {
|
||||
const { code, message } = statuses.APE_KEY_INVALID;
|
||||
throw new MonkeyError(code, message);
|
||||
}
|
||||
|
||||
await updateLastUsedOn(targetApeKey.uid, keyId);
|
||||
|
||||
return {
|
||||
type: "ApeKey",
|
||||
uid: targetApeKey.uid,
|
||||
email: "",
|
||||
};
|
||||
} catch (error) {
|
||||
if (!(error instanceof MonkeyError)) {
|
||||
const { code, message } = statuses.APE_KEY_MALFORMED;
|
||||
throw new MonkeyError(code, message);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function authenticateWithUid(token: string): Promise<DecodedToken> {
|
||||
if (!isDevEnvironment()) {
|
||||
throw new MonkeyError(401, "Bearer type uid is not supported");
|
||||
}
|
||||
const [uid, email] = token.split("|");
|
||||
|
||||
if (uid === undefined || uid === "") {
|
||||
throw new MonkeyError(401, "Missing uid");
|
||||
}
|
||||
|
||||
return {
|
||||
type: "Bearer",
|
||||
uid: uid,
|
||||
email: email ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
export function authenticateGithubWebhook(
|
||||
req: TsRestRequestWithContext,
|
||||
authHeader: string | string[] | undefined,
|
||||
): DecodedToken {
|
||||
try {
|
||||
const webhookSecret = process.env["GITHUB_WEBHOOK_SECRET"];
|
||||
|
||||
if (webhookSecret === undefined || webhookSecret === "") {
|
||||
throw new MonkeyError(500, "Missing Github Webhook Secret");
|
||||
}
|
||||
|
||||
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
20
backend/src/middlewares/compatibilityCheck.ts
Normal file
20
backend/src/middlewares/compatibilityCheck.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import {
|
||||
COMPATIBILITY_CHECK,
|
||||
COMPATIBILITY_CHECK_HEADER,
|
||||
} from "@monkeytype/contracts";
|
||||
import type { Response, NextFunction, Request } from "express";
|
||||
|
||||
/**
|
||||
* Add the COMPATIBILITY_CHECK_HEADER to each response
|
||||
* @param _req
|
||||
* @param res
|
||||
* @param next
|
||||
*/
|
||||
export async function compatibilityCheckMiddleware(
|
||||
_req: Request,
|
||||
res: Response,
|
||||
next: NextFunction,
|
||||
): Promise<void> {
|
||||
res.setHeader(COMPATIBILITY_CHECK_HEADER, COMPATIBILITY_CHECK);
|
||||
next();
|
||||
}
|
||||
91
backend/src/middlewares/configuration.ts
Normal file
91
backend/src/middlewares/configuration.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import type { Response, NextFunction } from "express";
|
||||
import { TsRestRequestHandler } from "@ts-rest/express";
|
||||
import { EndpointMetadata } from "@monkeytype/contracts/util/api";
|
||||
import MonkeyError from "../utils/error";
|
||||
import { Configuration } from "@monkeytype/schemas/configuration";
|
||||
import {
|
||||
ConfigurationPath,
|
||||
RequireConfiguration,
|
||||
} from "@monkeytype/contracts/require-configuration/index";
|
||||
import { getMetadata } from "./utility";
|
||||
import { TsRestRequestWithContext } from "../api/types";
|
||||
import { AppRoute, AppRouter } from "@ts-rest/core";
|
||||
|
||||
export function verifyRequiredConfiguration<
|
||||
T extends AppRouter | AppRoute,
|
||||
>(): TsRestRequestHandler<T> {
|
||||
return async (
|
||||
req: TsRestRequestWithContext,
|
||||
_res: Response,
|
||||
next: NextFunction,
|
||||
): Promise<void> => {
|
||||
const requiredConfigurations = getRequireConfigurations(getMetadata(req));
|
||||
|
||||
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;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
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];
|
||||
}
|
||||
41
backend/src/middlewares/context.ts
Normal file
41
backend/src/middlewares/context.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { getCachedConfiguration } from "../init/configuration";
|
||||
import type {
|
||||
Response,
|
||||
NextFunction,
|
||||
Request as ExpressRequest,
|
||||
} from "express";
|
||||
import { DecodedToken } from "./auth";
|
||||
import { Configuration } from "@monkeytype/schemas/configuration";
|
||||
import { ExpressRequestWithContext } from "../api/types";
|
||||
|
||||
export type Context = {
|
||||
configuration: Configuration;
|
||||
decodedToken: DecodedToken;
|
||||
};
|
||||
|
||||
/**
|
||||
* Add the context to the request
|
||||
* @param req
|
||||
* @param _res
|
||||
* @param next
|
||||
*/
|
||||
async function contextMiddleware(
|
||||
req: ExpressRequest,
|
||||
_res: Response,
|
||||
next: NextFunction,
|
||||
): Promise<void> {
|
||||
const configuration = await getCachedConfiguration(true);
|
||||
|
||||
(req as ExpressRequestWithContext).ctx = {
|
||||
configuration,
|
||||
decodedToken: {
|
||||
type: "None",
|
||||
uid: "",
|
||||
email: "",
|
||||
},
|
||||
};
|
||||
|
||||
next();
|
||||
}
|
||||
|
||||
export default contextMiddleware;
|
||||
138
backend/src/middlewares/error.ts
Normal file
138
backend/src/middlewares/error.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
import * as db from "../init/db";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import Logger from "../utils/logger";
|
||||
import MonkeyError, { getErrorMessage } from "../utils/error";
|
||||
import { incrementBadAuth } from "./rate-limit";
|
||||
import type { NextFunction, Response } from "express";
|
||||
import { isCustomCode } from "../constants/monkey-status-codes";
|
||||
|
||||
import {
|
||||
recordClientErrorByVersion,
|
||||
recordServerErrorByVersion,
|
||||
} from "../utils/prometheus";
|
||||
import { isDevEnvironment } from "../utils/misc";
|
||||
import { version } from "../version";
|
||||
import { addLog } from "../dal/logs";
|
||||
import { ExpressRequestWithContext } from "../api/types";
|
||||
|
||||
type DBError = {
|
||||
_id: string; //we are using uuid here, not objectIds
|
||||
timestamp: number;
|
||||
status: number;
|
||||
uid: string;
|
||||
message: string;
|
||||
stack?: string;
|
||||
endpoint: string;
|
||||
method: string;
|
||||
url: string;
|
||||
};
|
||||
|
||||
type ErrorData = {
|
||||
errorId?: string;
|
||||
uid: string;
|
||||
};
|
||||
|
||||
async function errorHandlingMiddleware(
|
||||
error: Error,
|
||||
req: ExpressRequestWithContext,
|
||||
res: Response,
|
||||
_next: NextFunction,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const monkeyError = error as MonkeyError;
|
||||
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)) {
|
||||
message = "Could not connect to the database. It may be down.";
|
||||
} else if (error instanceof URIError || error instanceof SyntaxError) {
|
||||
status = 400;
|
||||
message = "Unprocessable request";
|
||||
} else if (error instanceof MonkeyError) {
|
||||
message = error.message;
|
||||
status = error.status;
|
||||
} else {
|
||||
message = `Oops! Our monkeys dropped their bananas. Please try again later. - ${data.errorId}`;
|
||||
}
|
||||
|
||||
await incrementBadAuth(req, res, status);
|
||||
|
||||
if (status >= 400 && status < 500) {
|
||||
recordClientErrorByVersion(req.headers["x-client-version"] as string);
|
||||
}
|
||||
|
||||
if (!isDevEnvironment() && status >= 500 && status !== 503) {
|
||||
recordServerErrorByVersion(version);
|
||||
|
||||
const { uid, errorId } = data as {
|
||||
uid: string;
|
||||
errorId: string;
|
||||
};
|
||||
|
||||
try {
|
||||
await addLog(
|
||||
"system_error",
|
||||
`${status} ${errorId} ${error.message} ${error.stack}`,
|
||||
uid,
|
||||
);
|
||||
await db.collection<DBError>("errors").insertOne({
|
||||
_id: errorId,
|
||||
timestamp: Date.now(),
|
||||
status: status,
|
||||
uid,
|
||||
message: error.message,
|
||||
stack: error.stack,
|
||||
endpoint: req.originalUrl,
|
||||
method: req.method,
|
||||
url: req.url,
|
||||
});
|
||||
} catch (e) {
|
||||
Logger.error("Logging to db failed.");
|
||||
Logger.error(getErrorMessage(e) ?? "Unknown error");
|
||||
console.error(e);
|
||||
}
|
||||
} else {
|
||||
Logger.error(`Error: ${error.message} Stack: ${error.stack}`);
|
||||
}
|
||||
|
||||
if (status < 500) {
|
||||
delete data.errorId;
|
||||
}
|
||||
|
||||
handleErrorResponse(res, status, message, data);
|
||||
return;
|
||||
} catch (e) {
|
||||
Logger.error("Error handling middleware failed.");
|
||||
Logger.error(getErrorMessage(e) ?? "Unknown error");
|
||||
console.error(e);
|
||||
}
|
||||
|
||||
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;
|
||||
202
backend/src/middlewares/permission.ts
Normal file
202
backend/src/middlewares/permission.ts
Normal file
@@ -0,0 +1,202 @@
|
||||
import MonkeyError from "../utils/error";
|
||||
import type { Response, NextFunction } from "express";
|
||||
import { DBUser, getPartialUser } from "../dal/user";
|
||||
import { isAdmin } from "../dal/admin-uids";
|
||||
import { TsRestRequestHandler } from "@ts-rest/express";
|
||||
import {
|
||||
EndpointMetadata,
|
||||
RequestAuthenticationOptions,
|
||||
PermissionId,
|
||||
} from "@monkeytype/contracts/util/api";
|
||||
import { isDevEnvironment } from "../utils/misc";
|
||||
import { getMetadata } from "./utility";
|
||||
import { TsRestRequestWithContext } from "../api/types";
|
||||
import { DecodedToken } from "./auth";
|
||||
import { AppRoute, AppRouter } from "@ts-rest/core";
|
||||
|
||||
type RequestPermissionCheck = {
|
||||
type: "request";
|
||||
criteria: (
|
||||
req: TsRestRequestWithContext,
|
||||
metadata: EndpointMetadata | undefined,
|
||||
) => Promise<boolean>;
|
||||
invalidMessage?: string;
|
||||
};
|
||||
|
||||
type UserPermissionCheck = {
|
||||
type: "user";
|
||||
fields: (keyof DBUser)[];
|
||||
criteria: (user: DBUser) => boolean;
|
||||
invalidMessage?: string;
|
||||
};
|
||||
|
||||
type PermissionCheck = UserPermissionCheck | RequestPermissionCheck;
|
||||
|
||||
function buildUserPermission<K extends keyof DBUser>(
|
||||
fields: K[],
|
||||
criteria: (user: Pick<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 as string) !== ""),
|
||||
),
|
||||
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: TsRestRequestWithContext,
|
||||
_res: Response,
|
||||
next: NextFunction,
|
||||
): Promise<void> => {
|
||||
const metadata = getMetadata(req);
|
||||
const requiredPermissionIds = getRequiredPermissionIds(metadata);
|
||||
if (
|
||||
requiredPermissionIds === undefined ||
|
||||
requiredPermissionIds.length === 0
|
||||
) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
//handle user checks
|
||||
const userChecks = checks.filter((it) => it.type === "user");
|
||||
const checkResult = await checkUserPermissions(
|
||||
req.ctx.decodedToken,
|
||||
userChecks,
|
||||
);
|
||||
|
||||
if (!checkResult.passed) {
|
||||
next(
|
||||
new MonkeyError(
|
||||
403,
|
||||
checkResult.invalidMessage ?? "You don't have permission to do this.",
|
||||
),
|
||||
);
|
||||
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: 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: 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 DBUser;
|
||||
|
||||
for (const check of checks) {
|
||||
if (!check.criteria(user)) {
|
||||
return {
|
||||
passed: false,
|
||||
invalidMessage: check.invalidMessage,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
passed: true,
|
||||
};
|
||||
}
|
||||
210
backend/src/middlewares/rate-limit.ts
Normal file
210
backend/src/middlewares/rate-limit.ts
Normal file
@@ -0,0 +1,210 @@
|
||||
import MonkeyError from "../utils/error";
|
||||
import type { Response, NextFunction, Request } from "express";
|
||||
import { RateLimiterMemory } from "rate-limiter-flexible";
|
||||
import {
|
||||
rateLimit,
|
||||
RateLimitRequestHandler,
|
||||
type Options,
|
||||
} from "express-rate-limit";
|
||||
import { isDevEnvironment } from "../utils/misc";
|
||||
import { TsRestRequestHandler } from "@ts-rest/express";
|
||||
import {
|
||||
limits,
|
||||
RateLimiterId,
|
||||
RateLimitOptions,
|
||||
Window,
|
||||
} from "@monkeytype/contracts/rate-limit/index";
|
||||
import statuses from "../constants/monkey-status-codes";
|
||||
import { getMetadata } from "./utility";
|
||||
import {
|
||||
ExpressRequestWithContext,
|
||||
TsRestRequestWithContext,
|
||||
} from "../api/types";
|
||||
import { AppRoute, AppRouter } from "@ts-rest/core";
|
||||
|
||||
export const REQUEST_MULTIPLIER = isDevEnvironment() ? 100 : 1;
|
||||
|
||||
export const customHandler = (
|
||||
req: 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) ||
|
||||
(req.ip as string) ||
|
||||
"255.255.255.255"
|
||||
);
|
||||
};
|
||||
|
||||
const getKeyWithUid = (
|
||||
req: ExpressRequestWithContext,
|
||||
_res: Response,
|
||||
): string => {
|
||||
const uid = req?.ctx?.decodedToken?.uid;
|
||||
const useUid = uid !== undefined && uid !== "";
|
||||
|
||||
return useUid ? uid : getKey(req, _res);
|
||||
};
|
||||
|
||||
function initialiseLimiters(): Record<RateLimiterId, RateLimitRequestHandler> {
|
||||
const keys = Object.keys(limits) as RateLimiterId[];
|
||||
|
||||
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]);
|
||||
return output;
|
||||
}, {}) 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: TsRestRequestWithContext,
|
||||
res: Response,
|
||||
next: NextFunction,
|
||||
): Promise<void> => {
|
||||
const metadataRateLimit = getMetadata(req).rateLimit;
|
||||
if (metadataRateLimit === undefined) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
const hasApeKeyLimiterId = typeof metadataRateLimit === "object";
|
||||
let rateLimiterId: RateLimiterId;
|
||||
|
||||
if (req.ctx.decodedToken.type === "ApeKey") {
|
||||
rateLimiterId = hasApeKeyLimiterId
|
||||
? metadataRateLimit.apeKey
|
||||
: "defaultApeRateLimit";
|
||||
} else {
|
||||
rateLimiterId = hasApeKeyLimiterId
|
||||
? metadataRateLimit.normal
|
||||
: metadataRateLimit;
|
||||
}
|
||||
|
||||
const rateLimiter = requestLimiters[rateLimiterId];
|
||||
if (rateLimiter === undefined) {
|
||||
next(
|
||||
new MonkeyError(
|
||||
500,
|
||||
`Unknown rateLimiterId '${rateLimiterId}', how did you manage to do this?`,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
await rateLimiter(req, res, next);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Root Rate Limit
|
||||
export const rootRateLimiter = rateLimit({
|
||||
windowMs: 60 * 1000 * 60,
|
||||
max: 1000 * REQUEST_MULTIPLIER,
|
||||
keyGenerator: getKey,
|
||||
handler: (_req, _res, _next, _options): void => {
|
||||
throw new MonkeyError(
|
||||
429,
|
||||
"Maximum API request (root) limit reached. Please try again later.",
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
// Bad Authentication Rate Limiter
|
||||
const badAuthRateLimiter = new RateLimiterMemory({
|
||||
points: 30 * REQUEST_MULTIPLIER,
|
||||
duration: 60 * 60, //one hour seconds
|
||||
});
|
||||
|
||||
export async function badAuthRateLimiterHandler(
|
||||
req: ExpressRequestWithContext,
|
||||
res: Response,
|
||||
next: NextFunction,
|
||||
): Promise<void> {
|
||||
const badAuthEnabled =
|
||||
req?.ctx?.configuration?.rateLimiting?.badAuthentication?.enabled;
|
||||
if (!badAuthEnabled) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const key = getKey(req, res);
|
||||
const rateLimitStatus = await badAuthRateLimiter.get(key);
|
||||
|
||||
if (rateLimitStatus !== null && rateLimitStatus?.remainingPoints <= 0) {
|
||||
throw new MonkeyError(
|
||||
429,
|
||||
"Too many bad authentication attempts, please try again later.",
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
next(error);
|
||||
return;
|
||||
}
|
||||
|
||||
next();
|
||||
}
|
||||
|
||||
export async function incrementBadAuth(
|
||||
req: ExpressRequestWithContext,
|
||||
res: Response,
|
||||
status: number,
|
||||
): Promise<void> {
|
||||
const { enabled, penalty, flaggedStatusCodes } =
|
||||
req?.ctx?.configuration?.rateLimiting?.badAuthentication ?? {};
|
||||
|
||||
if (!enabled || !flaggedStatusCodes.includes(status)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const key = getKey(req, res);
|
||||
await badAuthRateLimiter.penalty(key, penalty);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
export const webhookLimit = rateLimit({
|
||||
windowMs: 1000,
|
||||
max: 1 * REQUEST_MULTIPLIER,
|
||||
keyGenerator: getKeyWithUid,
|
||||
handler: customHandler,
|
||||
});
|
||||
65
backend/src/middlewares/utility.ts
Normal file
65
backend/src/middlewares/utility.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import type { Request, Response, NextFunction, RequestHandler } from "express";
|
||||
import { recordClientVersion as prometheusRecordClientVersion } from "../utils/prometheus";
|
||||
import { isDevEnvironment } from "../utils/misc";
|
||||
import MonkeyError from "../utils/error";
|
||||
import { EndpointMetadata } from "@monkeytype/contracts/util/api";
|
||||
import { TsRestRequestWithContext } from "../api/types";
|
||||
|
||||
/**
|
||||
* record the client version from the `x-client-version` or ` client-version` header to prometheus
|
||||
*/
|
||||
export function recordClientVersion(): RequestHandler {
|
||||
return (req: Request, _res: Response, next: NextFunction) => {
|
||||
const clientVersion =
|
||||
(req.headers["x-client-version"] as string) ||
|
||||
req.headers["client-version"];
|
||||
|
||||
prometheusRecordClientVersion(clientVersion?.toString() ?? "unknown");
|
||||
|
||||
next();
|
||||
};
|
||||
}
|
||||
|
||||
/** Endpoint is only available in dev environment, else return 503. */
|
||||
export function onlyAvailableOnDev(): RequestHandler {
|
||||
return (
|
||||
_req: TsRestRequestWithContext,
|
||||
_res: Response,
|
||||
next: NextFunction,
|
||||
) => {
|
||||
if (!isDevEnvironment()) {
|
||||
next(
|
||||
new MonkeyError(
|
||||
503,
|
||||
"Development endpoints are only available in DEV mode.",
|
||||
),
|
||||
);
|
||||
} else {
|
||||
next();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function getMetadata(req: TsRestRequestWithContext): EndpointMetadata {
|
||||
// oxlint-disable-next-line no-unsafe-member-access
|
||||
return (req.tsRestRoute["metadata"] ?? {}) as EndpointMetadata;
|
||||
}
|
||||
|
||||
/**
|
||||
* The req.body property returns undefined when the body has not been parsed. In Express 4, it returns {} by default.
|
||||
* Restore the v4 behavior
|
||||
* @param req
|
||||
* @param _res
|
||||
* @param next
|
||||
*/
|
||||
export async function v4RequestBody(
|
||||
req: Request,
|
||||
_res: Response,
|
||||
next: NextFunction,
|
||||
): Promise<void> {
|
||||
if (req.body === undefined) {
|
||||
req.body = {};
|
||||
}
|
||||
|
||||
next();
|
||||
}
|
||||
68
backend/src/queues/email-queue.ts
Normal file
68
backend/src/queues/email-queue.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { MonkeyQueue } from "./monkey-queue";
|
||||
|
||||
const QUEUE_NAME = "email-tasks";
|
||||
|
||||
export type EmailType = "verify" | "resetPassword";
|
||||
|
||||
export type EmailTask<M extends EmailType> = {
|
||||
type: M;
|
||||
email: string;
|
||||
ctx: EmailTaskContexts[M];
|
||||
};
|
||||
|
||||
export type EmailTaskContexts = {
|
||||
verify: {
|
||||
name: string;
|
||||
verificationLink: string;
|
||||
};
|
||||
resetPassword: {
|
||||
name: string;
|
||||
passwordResetLink: string;
|
||||
};
|
||||
};
|
||||
|
||||
function buildTask(
|
||||
taskName: EmailType,
|
||||
email: string,
|
||||
taskContext: EmailTaskContexts[EmailType],
|
||||
): EmailTask<EmailType> {
|
||||
return {
|
||||
type: taskName,
|
||||
email: email,
|
||||
ctx: taskContext,
|
||||
};
|
||||
}
|
||||
|
||||
class EmailQueue extends MonkeyQueue<EmailTask<EmailType>> {
|
||||
async sendVerificationEmail(
|
||||
email: string,
|
||||
name: string,
|
||||
verificationLink: string,
|
||||
): Promise<void> {
|
||||
const taskName = "verify";
|
||||
const task = buildTask(taskName, email, { name, verificationLink });
|
||||
await this.add(taskName, task);
|
||||
}
|
||||
|
||||
async sendForgotPasswordEmail(
|
||||
email: string,
|
||||
name: string,
|
||||
passwordResetLink: string,
|
||||
): Promise<void> {
|
||||
const taskName = "resetPassword";
|
||||
const task = buildTask(taskName, email, { name, passwordResetLink });
|
||||
await this.add(taskName, task);
|
||||
}
|
||||
}
|
||||
|
||||
export default new EmailQueue(QUEUE_NAME, {
|
||||
defaultJobOptions: {
|
||||
removeOnComplete: true,
|
||||
removeOnFail: true,
|
||||
attempts: 1,
|
||||
backoff: {
|
||||
type: "exponential",
|
||||
delay: 2000,
|
||||
},
|
||||
},
|
||||
});
|
||||
124
backend/src/queues/george-queue.ts
Normal file
124
backend/src/queues/george-queue.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
import { LeaderboardEntry } from "@monkeytype/schemas/leaderboards";
|
||||
import { MonkeyQueue } from "./monkey-queue";
|
||||
|
||||
const QUEUE_NAME = "george-tasks";
|
||||
|
||||
type GeorgeTask = {
|
||||
name: string;
|
||||
args: unknown[];
|
||||
};
|
||||
|
||||
function buildGeorgeTask(taskName: string, taskArgs: unknown[]): GeorgeTask {
|
||||
return {
|
||||
name: taskName,
|
||||
args: taskArgs,
|
||||
};
|
||||
}
|
||||
|
||||
class GeorgeQueue extends MonkeyQueue<GeorgeTask> {
|
||||
async sendReleaseAnnouncement(releaseName: string): Promise<void> {
|
||||
const taskName = "sendReleaseAnnouncement";
|
||||
const sendReleaseAnnouncementTask = buildGeorgeTask(taskName, [
|
||||
releaseName,
|
||||
]);
|
||||
await this.add(taskName, sendReleaseAnnouncementTask);
|
||||
}
|
||||
|
||||
async updateDiscordRole(discordId: string, wpm: number): Promise<void> {
|
||||
const taskName = "updateRole";
|
||||
const updateDiscordRoleTask = buildGeorgeTask(taskName, [discordId, wpm]);
|
||||
await this.add(taskName, updateDiscordRoleTask);
|
||||
}
|
||||
|
||||
async linkDiscord(
|
||||
discordId: string,
|
||||
uid: string,
|
||||
lbOptOut: boolean,
|
||||
): Promise<void> {
|
||||
const taskName = "linkDiscord";
|
||||
const linkDiscordTask = buildGeorgeTask(taskName, [
|
||||
discordId,
|
||||
uid,
|
||||
lbOptOut,
|
||||
]);
|
||||
await this.add(taskName, linkDiscordTask);
|
||||
}
|
||||
|
||||
async unlinkDiscord(discordId: string, uid: string): Promise<void> {
|
||||
const taskName = "unlinkDiscord";
|
||||
const unlinkDiscordTask = buildGeorgeTask(taskName, [discordId, uid]);
|
||||
await this.add(taskName, unlinkDiscordTask);
|
||||
}
|
||||
|
||||
async awardChallenge(
|
||||
discordId: string,
|
||||
challengeName: string,
|
||||
): Promise<void> {
|
||||
const taskName = "awardChallenge";
|
||||
const awardChallengeTask = buildGeorgeTask(taskName, [
|
||||
discordId,
|
||||
challengeName,
|
||||
]);
|
||||
await this.add(taskName, awardChallengeTask);
|
||||
}
|
||||
|
||||
async userBanned(discordId: string, banned: boolean): Promise<void> {
|
||||
const taskName = "userBanned";
|
||||
const userBannedTask = buildGeorgeTask(taskName, [discordId, banned]);
|
||||
await this.add(taskName, userBannedTask);
|
||||
}
|
||||
|
||||
async announceLeaderboardUpdate(
|
||||
newRecords: Omit<LeaderboardEntry, "_id">[],
|
||||
leaderboardId: string,
|
||||
): Promise<void> {
|
||||
const taskName = "announceLeaderboardUpdate";
|
||||
|
||||
const leaderboardUpdateTasks = newRecords.map((record) => {
|
||||
const taskData = buildGeorgeTask(taskName, [
|
||||
record.discordId ?? record.name,
|
||||
record.rank,
|
||||
leaderboardId,
|
||||
record.wpm,
|
||||
record.raw,
|
||||
record.acc,
|
||||
record.consistency,
|
||||
]);
|
||||
|
||||
return {
|
||||
name: taskName,
|
||||
data: taskData,
|
||||
};
|
||||
});
|
||||
|
||||
await this.addBulk(leaderboardUpdateTasks);
|
||||
}
|
||||
|
||||
async announceDailyLeaderboardTopResults(
|
||||
leaderboardId: string,
|
||||
leaderboardTimestamp: number,
|
||||
topResults: LeaderboardEntry[],
|
||||
): Promise<void> {
|
||||
const taskName = "announceDailyLeaderboardTopResults";
|
||||
|
||||
const dailyLeaderboardTopResultsTask = buildGeorgeTask(taskName, [
|
||||
leaderboardId,
|
||||
leaderboardTimestamp,
|
||||
topResults,
|
||||
]);
|
||||
|
||||
await this.add(taskName, dailyLeaderboardTopResultsTask);
|
||||
}
|
||||
}
|
||||
|
||||
export default new GeorgeQueue(QUEUE_NAME, {
|
||||
defaultJobOptions: {
|
||||
removeOnComplete: true,
|
||||
removeOnFail: true,
|
||||
attempts: 3,
|
||||
backoff: {
|
||||
type: "exponential",
|
||||
delay: 2000,
|
||||
},
|
||||
},
|
||||
});
|
||||
5
backend/src/queues/index.ts
Normal file
5
backend/src/queues/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import LaterQueue from "./later-queue";
|
||||
import GeorgeQueue from "./george-queue";
|
||||
import EmailQueue from "./email-queue";
|
||||
|
||||
export default [GeorgeQueue, LaterQueue, EmailQueue];
|
||||
121
backend/src/queues/later-queue.ts
Normal file
121
backend/src/queues/later-queue.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import LRUCache from "lru-cache";
|
||||
import Logger from "../utils/logger";
|
||||
import { MonkeyQueue } from "./monkey-queue";
|
||||
import { ValidModeRule } from "@monkeytype/schemas/configuration";
|
||||
import {
|
||||
getCurrentDayTimestamp,
|
||||
getCurrentWeekTimestamp,
|
||||
} from "@monkeytype/util/date-and-time";
|
||||
|
||||
const QUEUE_NAME = "later";
|
||||
|
||||
export type LaterTaskType =
|
||||
| "daily-leaderboard-results"
|
||||
| "weekly-xp-leaderboard-results";
|
||||
|
||||
export type LaterTask<T extends LaterTaskType> = {
|
||||
taskName: LaterTaskType;
|
||||
ctx: LaterTaskContexts[T];
|
||||
};
|
||||
|
||||
export type LaterTaskContexts = {
|
||||
"daily-leaderboard-results": {
|
||||
yesterdayTimestamp: number;
|
||||
modeRule: ValidModeRule;
|
||||
};
|
||||
"weekly-xp-leaderboard-results": {
|
||||
lastWeekTimestamp: number;
|
||||
};
|
||||
};
|
||||
|
||||
const ONE_MINUTE_IN_MILLISECONDS = 1000 * 60;
|
||||
const ONE_DAY_IN_MILLISECONDS = 1000 * 60 * 60 * 24;
|
||||
|
||||
class LaterQueue extends MonkeyQueue<LaterTask<LaterTaskType>> {
|
||||
private scheduledJobCache = new LRUCache<string, boolean>({
|
||||
max: 100,
|
||||
});
|
||||
|
||||
private async scheduleTask(
|
||||
taskName: string,
|
||||
task: LaterTask<LaterTaskType>,
|
||||
jobId: string,
|
||||
delay: number,
|
||||
): Promise<void> {
|
||||
await this.add(taskName, task, {
|
||||
delay,
|
||||
jobId, // Prevent duplicate jobs
|
||||
backoff: 60 * ONE_MINUTE_IN_MILLISECONDS, // Try again every hour on failure
|
||||
attempts: 23,
|
||||
});
|
||||
|
||||
this.scheduledJobCache.set(jobId, true);
|
||||
|
||||
Logger.info(
|
||||
`Scheduled ${task.taskName} for ${new Date(Date.now() + delay)}`,
|
||||
);
|
||||
}
|
||||
|
||||
async scheduleForNextWeek(
|
||||
taskName: LaterTaskType,
|
||||
taskId: string,
|
||||
): Promise<void> {
|
||||
const currentWeekTimestamp = getCurrentWeekTimestamp();
|
||||
const jobId = `${taskName}:${currentWeekTimestamp}:${taskId}`;
|
||||
|
||||
if (this.scheduledJobCache.has(jobId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const task: LaterTask<LaterTaskType> = {
|
||||
taskName,
|
||||
ctx: {
|
||||
lastWeekTimestamp: currentWeekTimestamp,
|
||||
},
|
||||
};
|
||||
|
||||
const delay =
|
||||
currentWeekTimestamp +
|
||||
7 * ONE_DAY_IN_MILLISECONDS -
|
||||
Date.now() +
|
||||
ONE_MINUTE_IN_MILLISECONDS;
|
||||
|
||||
await this.scheduleTask("todo-next-week", task, jobId, delay);
|
||||
}
|
||||
|
||||
async scheduleForTomorrow(
|
||||
taskName: LaterTaskType,
|
||||
taskId: string,
|
||||
modeRule: ValidModeRule,
|
||||
): Promise<void> {
|
||||
const currentDayTimestamp = getCurrentDayTimestamp();
|
||||
const jobId = `${taskName}:${currentDayTimestamp}:${taskId}`;
|
||||
|
||||
if (this.scheduledJobCache.has(jobId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const task: LaterTask<LaterTaskType> = {
|
||||
taskName,
|
||||
ctx: {
|
||||
modeRule,
|
||||
yesterdayTimestamp: currentDayTimestamp,
|
||||
},
|
||||
};
|
||||
|
||||
const delay =
|
||||
currentDayTimestamp +
|
||||
ONE_DAY_IN_MILLISECONDS -
|
||||
Date.now() +
|
||||
ONE_MINUTE_IN_MILLISECONDS;
|
||||
|
||||
await this.scheduleTask("todo-tomorrow", task, jobId, delay);
|
||||
}
|
||||
}
|
||||
|
||||
export default new LaterQueue(QUEUE_NAME, {
|
||||
defaultJobOptions: {
|
||||
removeOnComplete: true,
|
||||
removeOnFail: true,
|
||||
},
|
||||
});
|
||||
62
backend/src/queues/monkey-queue.ts
Normal file
62
backend/src/queues/monkey-queue.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import IORedis from "ioredis";
|
||||
import {
|
||||
type BulkJobOptions,
|
||||
type ConnectionOptions,
|
||||
type JobsOptions,
|
||||
Queue,
|
||||
type QueueOptions,
|
||||
QueueScheduler,
|
||||
} from "bullmq";
|
||||
|
||||
export class MonkeyQueue<T> {
|
||||
private jobQueue: Queue | undefined;
|
||||
private _queueScheduler: QueueScheduler;
|
||||
public readonly queueName: string;
|
||||
private queueOpts: QueueOptions;
|
||||
|
||||
constructor(queueName: string, queueOpts: QueueOptions) {
|
||||
this.queueName = queueName;
|
||||
this.queueOpts = queueOpts;
|
||||
}
|
||||
|
||||
init(redisConnection?: IORedis.Redis): void {
|
||||
if (this.jobQueue !== undefined || !redisConnection) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.jobQueue = new Queue(this.queueName, {
|
||||
...this.queueOpts,
|
||||
connection: redisConnection as ConnectionOptions,
|
||||
});
|
||||
|
||||
this._queueScheduler = new QueueScheduler(this.queueName, {
|
||||
connection: redisConnection as ConnectionOptions,
|
||||
});
|
||||
}
|
||||
|
||||
async add(taskName: string, task: T, jobOpts?: JobsOptions): Promise<void> {
|
||||
if (this.jobQueue === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.jobQueue.add(taskName, task, jobOpts);
|
||||
}
|
||||
|
||||
async getJobCounts(): Promise<Record<string, number>> {
|
||||
if (this.jobQueue === undefined) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return await this.jobQueue.getJobCounts();
|
||||
}
|
||||
|
||||
async addBulk(
|
||||
tasks: { name: string; data: T; opts?: BulkJobOptions }[],
|
||||
): Promise<void> {
|
||||
if (this.jobQueue === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.jobQueue.addBulk(tasks);
|
||||
}
|
||||
}
|
||||
99
backend/src/server.ts
Normal file
99
backend/src/server.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import "dotenv/config";
|
||||
import * as db from "./init/db";
|
||||
import jobs from "./jobs";
|
||||
import {
|
||||
getLiveConfiguration,
|
||||
updateFromConfigurationFile,
|
||||
} from "./init/configuration";
|
||||
import app from "./app";
|
||||
import { Server } from "http";
|
||||
import { version } from "./version";
|
||||
import { recordServerVersion } from "./utils/prometheus";
|
||||
import * as RedisClient from "./init/redis";
|
||||
import queues from "./queues";
|
||||
import workers from "./workers";
|
||||
import Logger from "./utils/logger";
|
||||
import * as EmailClient from "./init/email-client";
|
||||
import { init as initFirebaseAdmin } from "./init/firebase-admin";
|
||||
import { createIndicies as leaderboardDbSetup } from "./dal/leaderboards";
|
||||
import { createIndicies as blocklistDbSetup } from "./dal/blocklist";
|
||||
import { createIndicies as connectionsDbSetup } from "./dal/connections";
|
||||
import { getErrorMessage } from "./utils/error";
|
||||
|
||||
async function bootServer(port: number): Promise<Server> {
|
||||
try {
|
||||
Logger.info(`Starting server version ${version}`);
|
||||
Logger.info(`Starting server in ${process.env["MODE"]} mode`);
|
||||
Logger.info(`Connecting to database ${process.env["DB_NAME"]}...`);
|
||||
await db.connect();
|
||||
Logger.success("Connected to database");
|
||||
|
||||
Logger.info("Initializing Firebase app instance...");
|
||||
initFirebaseAdmin();
|
||||
|
||||
Logger.info("Fetching live configuration...");
|
||||
await getLiveConfiguration();
|
||||
Logger.success("Live configuration fetched");
|
||||
await updateFromConfigurationFile();
|
||||
|
||||
Logger.info("Initializing email client...");
|
||||
await EmailClient.init();
|
||||
|
||||
Logger.info("Connecting to redis...");
|
||||
await RedisClient.connect();
|
||||
|
||||
if (RedisClient.isConnected()) {
|
||||
Logger.success("Connected to redis");
|
||||
const connection = RedisClient.getConnection();
|
||||
|
||||
Logger.info("Initializing queues...");
|
||||
queues.forEach((queue) => {
|
||||
queue.init(connection ?? undefined);
|
||||
});
|
||||
Logger.success(
|
||||
`Queues initialized: ${queues
|
||||
.map((queue) => queue.queueName)
|
||||
.join(", ")}`,
|
||||
);
|
||||
|
||||
Logger.info("Initializing workers...");
|
||||
workers.forEach(async (worker) => {
|
||||
await worker(connection ?? undefined).run();
|
||||
});
|
||||
Logger.success(
|
||||
`Workers initialized: ${workers
|
||||
.map((worker) => worker(connection ?? undefined).name)
|
||||
.join(", ")}`,
|
||||
);
|
||||
}
|
||||
|
||||
Logger.info("Starting cron jobs...");
|
||||
jobs.forEach((job) => job.start());
|
||||
Logger.success("Cron jobs started");
|
||||
|
||||
Logger.info("Setting up leaderboard indicies...");
|
||||
await leaderboardDbSetup();
|
||||
|
||||
Logger.info("Setting up blocklist indicies...");
|
||||
await blocklistDbSetup();
|
||||
|
||||
Logger.info("Setting up connections indicies...");
|
||||
await connectionsDbSetup();
|
||||
|
||||
recordServerVersion(version);
|
||||
} catch (error) {
|
||||
Logger.error("Failed to boot server");
|
||||
const message = getErrorMessage(error);
|
||||
Logger.error(message ?? "Unknown error");
|
||||
console.error(error);
|
||||
return process.exit(1);
|
||||
}
|
||||
|
||||
return app.listen(port, () => {
|
||||
Logger.success(`API server listening on port ${port}`);
|
||||
});
|
||||
}
|
||||
|
||||
const PORT = parseInt(process.env["PORT"] ?? "5005", 10);
|
||||
|
||||
void bootServer(PORT);
|
||||
288
backend/src/services/weekly-xp-leaderboard.ts
Normal file
288
backend/src/services/weekly-xp-leaderboard.ts
Normal file
@@ -0,0 +1,288 @@
|
||||
import { Configuration } from "@monkeytype/schemas/configuration";
|
||||
import * as RedisClient from "../init/redis";
|
||||
import LaterQueue from "../queues/later-queue";
|
||||
import {
|
||||
RedisXpLeaderboardEntry,
|
||||
RedisXpLeaderboardEntrySchema,
|
||||
RedisXpLeaderboardScore,
|
||||
XpLeaderboardEntry,
|
||||
} from "@monkeytype/schemas/leaderboards";
|
||||
import { getCurrentWeekTimestamp } from "@monkeytype/util/date-and-time";
|
||||
import MonkeyError from "../utils/error";
|
||||
import { parseWithSchema as parseJsonWithSchema } from "@monkeytype/util/json";
|
||||
import { omit } from "../utils/misc";
|
||||
|
||||
export type AddResultOpts = {
|
||||
entry: RedisXpLeaderboardEntry;
|
||||
xpGained: RedisXpLeaderboardScore;
|
||||
};
|
||||
|
||||
const weeklyXpLeaderboardLeaderboardNamespace =
|
||||
"monkeytype:weekly-xp-leaderboard";
|
||||
const scoresNamespace = `${weeklyXpLeaderboardLeaderboardNamespace}:scores`;
|
||||
const resultsNamespace = `${weeklyXpLeaderboardLeaderboardNamespace}:results`;
|
||||
|
||||
export class WeeklyXpLeaderboard {
|
||||
private weeklyXpLeaderboardResultsKeyName: string;
|
||||
private weeklyXpLeaderboardScoresKeyName: string;
|
||||
private customTime: number;
|
||||
|
||||
constructor(customTime = -1) {
|
||||
this.weeklyXpLeaderboardResultsKeyName = resultsNamespace;
|
||||
this.weeklyXpLeaderboardScoresKeyName = scoresNamespace;
|
||||
this.customTime = customTime;
|
||||
}
|
||||
|
||||
private getThisWeeksXpLeaderboardKeys(): {
|
||||
currentWeekTimestamp: number;
|
||||
weeklyXpLeaderboardScoresKey: string;
|
||||
weeklyXpLeaderboardResultsKey: string;
|
||||
} {
|
||||
const currentWeekTimestamp =
|
||||
this.customTime === -1 ? getCurrentWeekTimestamp() : this.customTime;
|
||||
|
||||
const weeklyXpLeaderboardScoresKey = `${this.weeklyXpLeaderboardScoresKeyName}:${currentWeekTimestamp}`;
|
||||
const weeklyXpLeaderboardResultsKey = `${this.weeklyXpLeaderboardResultsKeyName}:${currentWeekTimestamp}`;
|
||||
|
||||
return {
|
||||
currentWeekTimestamp,
|
||||
weeklyXpLeaderboardScoresKey,
|
||||
weeklyXpLeaderboardResultsKey,
|
||||
};
|
||||
}
|
||||
|
||||
public async addResult(
|
||||
weeklyXpLeaderboardConfig: Configuration["leaderboards"]["weeklyXp"],
|
||||
opts: AddResultOpts,
|
||||
): Promise<number> {
|
||||
const { entry, xpGained } = opts;
|
||||
|
||||
const connection = RedisClient.getConnection();
|
||||
if (!connection || !weeklyXpLeaderboardConfig.enabled) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
const {
|
||||
currentWeekTimestamp,
|
||||
weeklyXpLeaderboardScoresKey,
|
||||
weeklyXpLeaderboardResultsKey,
|
||||
} = this.getThisWeeksXpLeaderboardKeys();
|
||||
|
||||
const { expirationTimeInDays } = weeklyXpLeaderboardConfig;
|
||||
const weeklyXpLeaderboardExpirationDurationInMilliseconds =
|
||||
expirationTimeInDays * 24 * 60 * 60 * 1000;
|
||||
|
||||
const weeklyXpLeaderboardExpirationTimeInSeconds = Math.floor(
|
||||
(currentWeekTimestamp +
|
||||
weeklyXpLeaderboardExpirationDurationInMilliseconds) /
|
||||
1000,
|
||||
);
|
||||
|
||||
const currentEntry = await connection.hget(
|
||||
weeklyXpLeaderboardResultsKey,
|
||||
entry.uid,
|
||||
);
|
||||
|
||||
const currentEntryTimeTypedSeconds =
|
||||
currentEntry !== null
|
||||
? parseJsonWithSchema(currentEntry, RedisXpLeaderboardEntrySchema)
|
||||
?.timeTypedSeconds
|
||||
: undefined;
|
||||
|
||||
const totalTimeTypedSeconds =
|
||||
entry.timeTypedSeconds + (currentEntryTimeTypedSeconds ?? 0);
|
||||
|
||||
const [rank] = await Promise.all([
|
||||
connection.addResultIncrement(
|
||||
2,
|
||||
weeklyXpLeaderboardScoresKey,
|
||||
weeklyXpLeaderboardResultsKey,
|
||||
weeklyXpLeaderboardExpirationTimeInSeconds,
|
||||
entry.uid,
|
||||
xpGained,
|
||||
JSON.stringify(
|
||||
RedisXpLeaderboardEntrySchema.parse({
|
||||
...entry,
|
||||
timeTypedSeconds: totalTimeTypedSeconds,
|
||||
}),
|
||||
),
|
||||
),
|
||||
LaterQueue.scheduleForNextWeek(
|
||||
"weekly-xp-leaderboard-results",
|
||||
"weekly-xp",
|
||||
),
|
||||
]);
|
||||
|
||||
return rank + 1;
|
||||
}
|
||||
|
||||
public async getResults(
|
||||
page: number,
|
||||
pageSize: number,
|
||||
weeklyXpLeaderboardConfig: Configuration["leaderboards"]["weeklyXp"],
|
||||
premiumFeaturesEnabled: boolean,
|
||||
userIds?: string[],
|
||||
): Promise<{
|
||||
entries: XpLeaderboardEntry[];
|
||||
count: number;
|
||||
} | null> {
|
||||
const connection = RedisClient.getConnection();
|
||||
if (!connection || !weeklyXpLeaderboardConfig.enabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (page < 0 || pageSize < 0) {
|
||||
throw new MonkeyError(500, "Invalid page or pageSize");
|
||||
}
|
||||
|
||||
if (userIds?.length === 0) {
|
||||
return { entries: [], count: 0 };
|
||||
}
|
||||
|
||||
const isFriends = userIds !== undefined;
|
||||
const minRank = page * pageSize;
|
||||
const maxRank = minRank + pageSize - 1;
|
||||
|
||||
const { weeklyXpLeaderboardScoresKey, weeklyXpLeaderboardResultsKey } =
|
||||
this.getThisWeeksXpLeaderboardKeys();
|
||||
|
||||
const [results, scores, count, _, ranks] = await connection.getResults(
|
||||
2,
|
||||
weeklyXpLeaderboardScoresKey,
|
||||
weeklyXpLeaderboardResultsKey,
|
||||
minRank,
|
||||
maxRank,
|
||||
"true",
|
||||
userIds?.join(",") ?? "",
|
||||
);
|
||||
|
||||
if (results === undefined) {
|
||||
throw new Error(
|
||||
"Redis returned undefined when getting weekly leaderboard results",
|
||||
);
|
||||
}
|
||||
|
||||
if (scores === undefined) {
|
||||
throw new Error(
|
||||
"Redis returned undefined when getting weekly leaderboard scores",
|
||||
);
|
||||
}
|
||||
|
||||
let resultsWithRanks: XpLeaderboardEntry[] = results.map(
|
||||
(resultJSON: string, index: number) => {
|
||||
try {
|
||||
const parsed = parseJsonWithSchema(
|
||||
resultJSON,
|
||||
RedisXpLeaderboardEntrySchema,
|
||||
);
|
||||
const scoreValue = scores[index];
|
||||
|
||||
if (typeof scoreValue !== "string") {
|
||||
throw new Error(
|
||||
`Invalid score value at index ${index}: ${scoreValue}`,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
...parsed,
|
||||
rank: isFriends
|
||||
? new Number(ranks[index]).valueOf() + 1
|
||||
: minRank + index + 1,
|
||||
friendsRank: isFriends ? minRank + index + 1 : undefined,
|
||||
totalXp: parseInt(scoreValue, 10),
|
||||
};
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Failed to parse leaderboard entry at index ${index}: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
if (!premiumFeaturesEnabled) {
|
||||
resultsWithRanks = resultsWithRanks.map((it) => omit(it, ["isPremium"]));
|
||||
}
|
||||
|
||||
return { entries: resultsWithRanks, count: parseInt(count) };
|
||||
}
|
||||
|
||||
public async getRank(
|
||||
uid: string,
|
||||
weeklyXpLeaderboardConfig: Configuration["leaderboards"]["weeklyXp"],
|
||||
userIds?: string[],
|
||||
): Promise<XpLeaderboardEntry | null> {
|
||||
const connection = RedisClient.getConnection();
|
||||
if (!connection || !weeklyXpLeaderboardConfig.enabled) {
|
||||
throw new Error("Redis connection is unavailable");
|
||||
}
|
||||
if (userIds?.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { weeklyXpLeaderboardScoresKey, weeklyXpLeaderboardResultsKey } =
|
||||
this.getThisWeeksXpLeaderboardKeys();
|
||||
|
||||
const [rank, score, result, friendsRank] = await connection.getRank(
|
||||
2,
|
||||
weeklyXpLeaderboardScoresKey,
|
||||
weeklyXpLeaderboardResultsKey,
|
||||
uid,
|
||||
"true",
|
||||
userIds?.join(",") ?? "",
|
||||
);
|
||||
|
||||
if (rank === null || result === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return {
|
||||
...parseJsonWithSchema(result ?? "null", RedisXpLeaderboardEntrySchema),
|
||||
rank: rank + 1,
|
||||
friendsRank: friendsRank !== undefined ? friendsRank + 1 : undefined,
|
||||
totalXp: parseInt(score, 10),
|
||||
};
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Failed to parse leaderboard entry: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function get(
|
||||
weeklyXpLeaderboardConfig: Configuration["leaderboards"]["weeklyXp"],
|
||||
customTimestamp?: number,
|
||||
): WeeklyXpLeaderboard | null {
|
||||
const { enabled } = weeklyXpLeaderboardConfig;
|
||||
|
||||
if (!enabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new WeeklyXpLeaderboard(customTimestamp);
|
||||
}
|
||||
|
||||
export async function purgeUserFromXpLeaderboards(
|
||||
uid: string,
|
||||
weeklyXpLeaderboardConfig: Configuration["leaderboards"]["weeklyXp"],
|
||||
): Promise<void> {
|
||||
const connection = RedisClient.getConnection();
|
||||
if (!connection || !weeklyXpLeaderboardConfig.enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
await connection.purgeResults(
|
||||
0,
|
||||
uid,
|
||||
weeklyXpLeaderboardLeaderboardNamespace,
|
||||
);
|
||||
}
|
||||
|
||||
export const __testing = {
|
||||
namespace: weeklyXpLeaderboardLeaderboardNamespace,
|
||||
};
|
||||
110
backend/src/utils/auth.ts
Normal file
110
backend/src/utils/auth.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import FirebaseAdmin from "./../init/firebase-admin";
|
||||
import LRUCache from "lru-cache";
|
||||
import {
|
||||
recordTokenCacheAccess,
|
||||
setTokenCacheLength,
|
||||
setTokenCacheSize,
|
||||
} from "./prometheus";
|
||||
import { type DecodedIdToken, UserRecord } from "firebase-admin/auth";
|
||||
import { getFrontendUrl } from "./misc";
|
||||
import emailQueue from "../queues/email-queue";
|
||||
import * as UserDAL from "../dal/user";
|
||||
import { isFirebaseError } from "./error";
|
||||
|
||||
const tokenCache = new LRUCache<string, DecodedIdToken>({
|
||||
max: 20000,
|
||||
maxSize: 50000000, // 50MB
|
||||
sizeCalculation: (token, key): number =>
|
||||
JSON.stringify(token).length + key.length, //sizeInBytes
|
||||
});
|
||||
|
||||
const TOKEN_CACHE_BUFFER = 1000 * 60 * 5; // 5 minutes
|
||||
|
||||
export async function verifyIdToken(
|
||||
idToken: string,
|
||||
noCache = false,
|
||||
): Promise<DecodedIdToken> {
|
||||
if (noCache) {
|
||||
return await FirebaseAdmin().auth().verifyIdToken(idToken, true);
|
||||
}
|
||||
|
||||
setTokenCacheLength(tokenCache.size);
|
||||
setTokenCacheSize(tokenCache.calculatedSize ?? 0);
|
||||
|
||||
const cached = tokenCache.get(idToken);
|
||||
|
||||
if (cached) {
|
||||
const expirationDate = cached.exp * 1000 - TOKEN_CACHE_BUFFER;
|
||||
|
||||
if (expirationDate < Date.now()) {
|
||||
recordTokenCacheAccess("hit_expired");
|
||||
tokenCache.delete(idToken);
|
||||
} else {
|
||||
recordTokenCacheAccess("hit");
|
||||
return cached;
|
||||
}
|
||||
} else {
|
||||
recordTokenCacheAccess("miss");
|
||||
}
|
||||
|
||||
const decoded = await FirebaseAdmin().auth().verifyIdToken(idToken, true);
|
||||
tokenCache.set(idToken, decoded);
|
||||
return decoded;
|
||||
}
|
||||
|
||||
export async function updateUserEmail(
|
||||
uid: string,
|
||||
email: string,
|
||||
): Promise<UserRecord> {
|
||||
await revokeTokensByUid(uid);
|
||||
return await FirebaseAdmin().auth().updateUser(uid, {
|
||||
email,
|
||||
emailVerified: false,
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateUserPassword(
|
||||
uid: string,
|
||||
password: string,
|
||||
): Promise<UserRecord> {
|
||||
await revokeTokensByUid(uid);
|
||||
return await FirebaseAdmin().auth().updateUser(uid, {
|
||||
password,
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteUser(uid: string): Promise<void> {
|
||||
await revokeTokensByUid(uid);
|
||||
await FirebaseAdmin().auth().deleteUser(uid);
|
||||
}
|
||||
|
||||
export async function revokeTokensByUid(uid: string): Promise<void> {
|
||||
await FirebaseAdmin().auth().revokeRefreshTokens(uid);
|
||||
for (const entry of tokenCache.entries()) {
|
||||
if (entry[1].uid === uid) {
|
||||
tokenCache.delete(entry[0]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function sendForgotPasswordEmail(email: string): Promise<void> {
|
||||
try {
|
||||
const uid = (await FirebaseAdmin().auth().getUserByEmail(email)).uid;
|
||||
const { name } = await UserDAL.getPartialUser(
|
||||
uid,
|
||||
"request forgot password email",
|
||||
["name"],
|
||||
);
|
||||
|
||||
const link = await FirebaseAdmin()
|
||||
.auth()
|
||||
.generatePasswordResetLink(email, { url: getFrontendUrl() });
|
||||
|
||||
await emailQueue.sendForgotPasswordEmail(email, name, link);
|
||||
} catch (err) {
|
||||
if (isFirebaseError(err) && err.errorInfo.code !== "auth/user-not-found") {
|
||||
// oxlint-disable-next-line only-throw-error
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
36
backend/src/utils/captcha.ts
Normal file
36
backend/src/utils/captcha.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { isDevEnvironment } from "./misc";
|
||||
|
||||
type CaptchaData = {
|
||||
success: boolean;
|
||||
challenge_ts?: number;
|
||||
hostname: string;
|
||||
"error-codes"?: string[];
|
||||
};
|
||||
|
||||
const recaptchaSecret = process.env["RECAPTCHA_SECRET"] ?? null;
|
||||
|
||||
export async function verify(captcha: string): Promise<boolean> {
|
||||
if (isDevEnvironment()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (recaptchaSecret === null) {
|
||||
throw new Error("RECAPTCHA_SECRET is not defined");
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
`https://www.google.com/recaptcha/api/siteverify`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: `secret=${recaptchaSecret}&response=${captcha}`,
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
return false;
|
||||
} else {
|
||||
const captchaData = (await response.json()) as CaptchaData;
|
||||
return captchaData.success;
|
||||
}
|
||||
}
|
||||
291
backend/src/utils/daily-leaderboards.ts
Normal file
291
backend/src/utils/daily-leaderboards.ts
Normal file
@@ -0,0 +1,291 @@
|
||||
import * as RedisClient from "../init/redis";
|
||||
import LaterQueue from "../queues/later-queue";
|
||||
import { matchesAPattern, kogascore, omit } from "./misc";
|
||||
import { parseWithSchema as parseJsonWithSchema } from "@monkeytype/util/json";
|
||||
import {
|
||||
Configuration,
|
||||
ValidModeRule,
|
||||
} from "@monkeytype/schemas/configuration";
|
||||
import {
|
||||
LeaderboardEntry,
|
||||
RedisDailyLeaderboardEntry,
|
||||
RedisDailyLeaderboardEntrySchema,
|
||||
} from "@monkeytype/schemas/leaderboards";
|
||||
import MonkeyError from "./error";
|
||||
import { Mode, Mode2 } from "@monkeytype/schemas/shared";
|
||||
import { getCurrentDayTimestamp } from "@monkeytype/util/date-and-time";
|
||||
|
||||
const dailyLeaderboardNamespace = "monkeytype:dailyleaderboard";
|
||||
const scoresNamespace = `${dailyLeaderboardNamespace}:scores`;
|
||||
const resultsNamespace = `${dailyLeaderboardNamespace}:results`;
|
||||
|
||||
export class DailyLeaderboard {
|
||||
private leaderboardResultsKeyName: string;
|
||||
private leaderboardScoresKeyName: string;
|
||||
private leaderboardModeKey: string;
|
||||
private customTime: number;
|
||||
private modeRule: ValidModeRule;
|
||||
|
||||
constructor(modeRule: ValidModeRule, customTime = -1) {
|
||||
const { language, mode, mode2 } = modeRule;
|
||||
|
||||
this.leaderboardModeKey = `${language}:${mode}:${mode2}`;
|
||||
this.leaderboardResultsKeyName = `${resultsNamespace}:${this.leaderboardModeKey}`;
|
||||
this.leaderboardScoresKeyName = `${scoresNamespace}:${this.leaderboardModeKey}`;
|
||||
this.customTime = customTime;
|
||||
this.modeRule = modeRule;
|
||||
}
|
||||
|
||||
private getTodaysLeaderboardKeys(): {
|
||||
currentDayTimestamp: number;
|
||||
leaderboardScoresKey: string;
|
||||
leaderboardResultsKey: string;
|
||||
} {
|
||||
const currentDayTimestamp =
|
||||
this.customTime === -1 ? getCurrentDayTimestamp() : this.customTime;
|
||||
const leaderboardScoresKey = `${this.leaderboardScoresKeyName}:${currentDayTimestamp}`;
|
||||
const leaderboardResultsKey = `${this.leaderboardResultsKeyName}:${currentDayTimestamp}`;
|
||||
|
||||
return {
|
||||
currentDayTimestamp,
|
||||
leaderboardScoresKey,
|
||||
leaderboardResultsKey,
|
||||
};
|
||||
}
|
||||
|
||||
public async addResult(
|
||||
entry: RedisDailyLeaderboardEntry,
|
||||
dailyLeaderboardsConfig: Configuration["dailyLeaderboards"],
|
||||
): Promise<number> {
|
||||
const connection = RedisClient.getConnection();
|
||||
if (!connection || !dailyLeaderboardsConfig.enabled) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
const { currentDayTimestamp, leaderboardScoresKey, leaderboardResultsKey } =
|
||||
this.getTodaysLeaderboardKeys();
|
||||
|
||||
const { maxResults, leaderboardExpirationTimeInDays } =
|
||||
dailyLeaderboardsConfig;
|
||||
const leaderboardExpirationDurationInMilliseconds =
|
||||
leaderboardExpirationTimeInDays * 24 * 60 * 60 * 1000;
|
||||
|
||||
const leaderboardExpirationTimeInSeconds = Math.floor(
|
||||
(currentDayTimestamp + leaderboardExpirationDurationInMilliseconds) /
|
||||
1000,
|
||||
);
|
||||
|
||||
const resultScore = kogascore(entry.wpm, entry.acc, entry.timestamp);
|
||||
|
||||
const rank = await connection.addResult(
|
||||
2,
|
||||
leaderboardScoresKey,
|
||||
leaderboardResultsKey,
|
||||
maxResults,
|
||||
leaderboardExpirationTimeInSeconds,
|
||||
entry.uid,
|
||||
resultScore,
|
||||
JSON.stringify(entry),
|
||||
);
|
||||
|
||||
if (
|
||||
isValidModeRule(
|
||||
this.modeRule,
|
||||
dailyLeaderboardsConfig.scheduleRewardsModeRules,
|
||||
)
|
||||
) {
|
||||
await LaterQueue.scheduleForTomorrow(
|
||||
"daily-leaderboard-results",
|
||||
this.leaderboardModeKey,
|
||||
this.modeRule,
|
||||
);
|
||||
}
|
||||
|
||||
if (rank === null) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
return rank + 1;
|
||||
}
|
||||
|
||||
public async getResults(
|
||||
page: number,
|
||||
pageSize: number,
|
||||
dailyLeaderboardsConfig: Configuration["dailyLeaderboards"],
|
||||
premiumFeaturesEnabled: boolean,
|
||||
userIds?: string[],
|
||||
): Promise<{
|
||||
entries: LeaderboardEntry[];
|
||||
count: number;
|
||||
minWpm: number;
|
||||
} | null> {
|
||||
const connection = RedisClient.getConnection();
|
||||
if (!connection || !dailyLeaderboardsConfig.enabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (page < 0 || pageSize < 0) {
|
||||
throw new MonkeyError(500, "Invalid page or pageSize");
|
||||
}
|
||||
|
||||
if (userIds?.length === 0) {
|
||||
return { entries: [], count: 0, minWpm: 0 };
|
||||
}
|
||||
|
||||
const isFriends = userIds !== undefined;
|
||||
const minRank = page * pageSize;
|
||||
const maxRank = minRank + pageSize - 1;
|
||||
|
||||
const { leaderboardScoresKey, leaderboardResultsKey } =
|
||||
this.getTodaysLeaderboardKeys();
|
||||
|
||||
const [results, _, count, [_uid, minScore], ranks] =
|
||||
await connection.getResults(
|
||||
2,
|
||||
leaderboardScoresKey,
|
||||
leaderboardResultsKey,
|
||||
minRank,
|
||||
maxRank,
|
||||
"false",
|
||||
userIds?.join(",") ?? "",
|
||||
);
|
||||
|
||||
const minWpm =
|
||||
minScore !== undefined
|
||||
? parseInt(minScore.toString().slice(1, 6)) / 100
|
||||
: 0;
|
||||
|
||||
if (results === undefined) {
|
||||
throw new Error(
|
||||
"Redis returned undefined when getting daily leaderboard results",
|
||||
);
|
||||
}
|
||||
|
||||
let resultsWithRanks: LeaderboardEntry[] = results.map(
|
||||
(resultJSON, index) => {
|
||||
try {
|
||||
const parsed = parseJsonWithSchema(
|
||||
resultJSON,
|
||||
RedisDailyLeaderboardEntrySchema,
|
||||
);
|
||||
|
||||
return {
|
||||
...parsed,
|
||||
rank: isFriends
|
||||
? new Number(ranks[index]).valueOf() + 1
|
||||
: minRank + index + 1,
|
||||
friendsRank: isFriends ? minRank + index + 1 : undefined,
|
||||
};
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Failed to parse leaderboard entry at index ${index}: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
if (!premiumFeaturesEnabled) {
|
||||
resultsWithRanks = resultsWithRanks.map((it) => omit(it, ["isPremium"]));
|
||||
}
|
||||
|
||||
return { entries: resultsWithRanks, count: parseInt(count), minWpm };
|
||||
}
|
||||
|
||||
public async getRank(
|
||||
uid: string,
|
||||
dailyLeaderboardsConfig: Configuration["dailyLeaderboards"],
|
||||
userIds?: string[],
|
||||
): Promise<LeaderboardEntry | null> {
|
||||
const connection = RedisClient.getConnection();
|
||||
if (!connection || !dailyLeaderboardsConfig.enabled) {
|
||||
throw new Error("Redis connection is unavailable");
|
||||
}
|
||||
if (userIds?.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { leaderboardScoresKey, leaderboardResultsKey } =
|
||||
this.getTodaysLeaderboardKeys();
|
||||
|
||||
const [rank, _score, result, friendsRank] = await connection.getRank(
|
||||
2,
|
||||
leaderboardScoresKey,
|
||||
leaderboardResultsKey,
|
||||
uid,
|
||||
"false",
|
||||
userIds?.join(",") ?? "",
|
||||
);
|
||||
|
||||
if (rank === null || rank === undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return {
|
||||
...parseJsonWithSchema(
|
||||
result ?? "null",
|
||||
RedisDailyLeaderboardEntrySchema,
|
||||
),
|
||||
rank: rank + 1,
|
||||
friendsRank: friendsRank !== undefined ? friendsRank + 1 : undefined,
|
||||
};
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Failed to parse leaderboard entry: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function purgeUserFromDailyLeaderboards(
|
||||
uid: string,
|
||||
configuration: Configuration["dailyLeaderboards"],
|
||||
): Promise<void> {
|
||||
const connection = RedisClient.getConnection();
|
||||
if (!connection || !configuration.enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
await connection.purgeResults(0, uid, dailyLeaderboardNamespace);
|
||||
}
|
||||
|
||||
function isValidModeRule(
|
||||
modeRule: ValidModeRule,
|
||||
modeRules: ValidModeRule[],
|
||||
): boolean {
|
||||
const { language, mode, mode2 } = modeRule;
|
||||
|
||||
return modeRules.some((rule) => {
|
||||
const matchesLanguage = matchesAPattern(language, rule.language);
|
||||
const matchesMode = matchesAPattern(mode, rule.mode);
|
||||
const matchesMode2 = matchesAPattern(mode2, rule.mode2);
|
||||
return matchesLanguage && matchesMode && matchesMode2;
|
||||
});
|
||||
}
|
||||
|
||||
export function getDailyLeaderboard(
|
||||
language: string,
|
||||
mode: Mode,
|
||||
mode2: Mode2<Mode>,
|
||||
dailyLeaderboardsConfig: Configuration["dailyLeaderboards"],
|
||||
customTimestamp = -1,
|
||||
): DailyLeaderboard | null {
|
||||
const { validModeRules, enabled } = dailyLeaderboardsConfig;
|
||||
|
||||
const modeRule: ValidModeRule = { language, mode, mode2 };
|
||||
const isValidMode = isValidModeRule(modeRule, validModeRules);
|
||||
|
||||
if (!enabled || !isValidMode) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new DailyLeaderboard(modeRule, customTimestamp);
|
||||
}
|
||||
|
||||
export const __testing = {
|
||||
namespace: dailyLeaderboardNamespace,
|
||||
};
|
||||
65
backend/src/utils/discord.ts
Normal file
65
backend/src/utils/discord.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { isDevEnvironment } from "./misc";
|
||||
import * as RedisClient from "../init/redis";
|
||||
import { randomBytes } from "crypto";
|
||||
import MonkeyError from "./error";
|
||||
import { z } from "zod";
|
||||
import { parseWithSchema as parseJsonWithSchema } from "@monkeytype/util/json";
|
||||
|
||||
const BASE_URL = "https://discord.com/api";
|
||||
|
||||
const DiscordIdAndAvatarSchema = z.object({
|
||||
id: z.string(),
|
||||
avatar: z
|
||||
.string()
|
||||
.optional()
|
||||
.or(z.null().transform(() => undefined)),
|
||||
});
|
||||
type DiscordIdAndAvatar = z.infer<typeof DiscordIdAndAvatarSchema>;
|
||||
|
||||
export async function getDiscordUser(
|
||||
tokenType: string,
|
||||
accessToken: string,
|
||||
): Promise<DiscordIdAndAvatar> {
|
||||
const response = await fetch(`${BASE_URL}/users/@me`, {
|
||||
headers: {
|
||||
authorization: `${tokenType} ${accessToken}`,
|
||||
},
|
||||
});
|
||||
|
||||
const parsed = parseJsonWithSchema(
|
||||
await response.text(),
|
||||
DiscordIdAndAvatarSchema,
|
||||
);
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
export async function getOauthLink(uid: string): Promise<string> {
|
||||
const connection = RedisClient.getConnection();
|
||||
if (!connection) {
|
||||
throw new MonkeyError(500, "Redis connection not found");
|
||||
}
|
||||
const token = randomBytes(10).toString("hex");
|
||||
|
||||
//add the token uid pair to reids
|
||||
await connection.setex(`discordoauth:${uid}`, 60, token);
|
||||
|
||||
return `${BASE_URL}/oauth2/authorize?client_id=798272335035498557&redirect_uri=${
|
||||
isDevEnvironment()
|
||||
? `http%3A%2F%2Flocalhost%3A3000%2Fverify`
|
||||
: `https%3A%2F%2Fmonkeytype.com%2Fverify`
|
||||
}&response_type=token&scope=identify&state=${token}`;
|
||||
}
|
||||
|
||||
export async function iStateValidForUser(
|
||||
state: string,
|
||||
uid: string,
|
||||
): Promise<boolean> {
|
||||
const connection = RedisClient.getConnection();
|
||||
if (!connection) {
|
||||
throw new MonkeyError(500, "Redis connection not found");
|
||||
}
|
||||
const redisToken = await connection.getdel(`discordoauth:${uid}`);
|
||||
|
||||
return redisToken === state;
|
||||
}
|
||||
78
backend/src/utils/error.ts
Normal file
78
backend/src/utils/error.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { isDevEnvironment } from "./misc";
|
||||
import { MonkeyServerErrorType } from "@monkeytype/contracts/util/api";
|
||||
import { FirebaseError } from "firebase-admin";
|
||||
|
||||
type FirebaseErrorParent = {
|
||||
code: string;
|
||||
errorInfo: FirebaseError;
|
||||
};
|
||||
|
||||
export function isFirebaseError(err: unknown): err is FirebaseErrorParent {
|
||||
return (
|
||||
err !== null &&
|
||||
typeof err === "object" &&
|
||||
"code" in err &&
|
||||
"errorInfo" in err &&
|
||||
"codePrefix" in err &&
|
||||
typeof err.errorInfo === "object" &&
|
||||
err.errorInfo !== null &&
|
||||
"code" in err.errorInfo &&
|
||||
"message" in err.errorInfo
|
||||
);
|
||||
}
|
||||
|
||||
export function getErrorMessage(error: unknown): string | undefined {
|
||||
let message = "";
|
||||
|
||||
if (error instanceof Error) {
|
||||
message = error.message;
|
||||
} else if (
|
||||
error !== null &&
|
||||
typeof error === "object" &&
|
||||
"message" in error &&
|
||||
(typeof error.message === "string" || typeof error.message === "number")
|
||||
) {
|
||||
message = `${error.message}`;
|
||||
} else if (typeof error === "string") {
|
||||
message = error;
|
||||
} else if (typeof error === "number") {
|
||||
message = `${error}`;
|
||||
}
|
||||
|
||||
if (message === "") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return message;
|
||||
}
|
||||
|
||||
class MonkeyError extends Error implements MonkeyServerErrorType {
|
||||
status: number;
|
||||
errorId: string;
|
||||
uid?: string;
|
||||
|
||||
constructor(status: number, message?: string, stack?: string, uid?: string) {
|
||||
super(message);
|
||||
this.status = status ?? 500;
|
||||
this.errorId = uuidv4();
|
||||
this.stack = stack;
|
||||
this.uid = uid;
|
||||
|
||||
if (isDevEnvironment()) {
|
||||
this.message =
|
||||
(stack ?? "")
|
||||
? String(message) + "\nStack: " + String(stack)
|
||||
: String(message);
|
||||
} else {
|
||||
if ((this.stack ?? "") && this.status >= 500) {
|
||||
this.stack = this.message + "\n" + this.stack;
|
||||
this.message = "Internal Server Error " + this.errorId;
|
||||
} else {
|
||||
this.message = String(message);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default MonkeyError;
|
||||
25
backend/src/utils/etag.ts
Normal file
25
backend/src/utils/etag.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { COMPATIBILITY_CHECK } from "@monkeytype/contracts";
|
||||
import { default as etag } from "etag";
|
||||
|
||||
/**
|
||||
* create etag generator, based on the express implementation https://github.com/expressjs/express/blob/9f4dbe3a1332cd883069ba9b73a9eed99234cfc7/lib/utils.js#L247
|
||||
* Adds the api COMPATIBILITY_CHECK version in front of the etag.
|
||||
* @param options
|
||||
* @returns
|
||||
*/
|
||||
export function createETagGenerator(options: {
|
||||
weak: boolean;
|
||||
}): (body: Buffer | string, encoding: BufferEncoding | undefined) => string {
|
||||
return function generateETag(body, encoding) {
|
||||
const buf = !Buffer.isBuffer(body) ? Buffer.from(body, encoding) : body;
|
||||
|
||||
// oxlint-disable-next-line no-unsafe-assignment, no-unsafe-call
|
||||
const generatedTag: string = etag(buf, options);
|
||||
|
||||
//custom code to add the version number
|
||||
if (generatedTag.startsWith("W/")) {
|
||||
return `W/"V${COMPATIBILITY_CHECK}-${generatedTag.slice(3)}`;
|
||||
}
|
||||
return `"V${COMPATIBILITY_CHECK}-${generatedTag.slice(1)}`;
|
||||
};
|
||||
}
|
||||
91
backend/src/utils/logger.ts
Normal file
91
backend/src/utils/logger.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import chalk from "chalk";
|
||||
import {
|
||||
format,
|
||||
createLogger,
|
||||
transports,
|
||||
type Logger as LoggerType,
|
||||
} from "winston";
|
||||
import { resolve } from "path";
|
||||
|
||||
const errorColor = chalk.red.bold;
|
||||
const warningColor = chalk.yellow.bold;
|
||||
const successColor = chalk.green.bold;
|
||||
const infoColor = chalk.white;
|
||||
|
||||
const logFolderPath = process.env["LOG_FOLDER_PATH"] ?? "./logs";
|
||||
const maxLogSize = parseInt(process.env["LOG_FILE_MAX_SIZE"] ?? "10485760");
|
||||
|
||||
const customLevels = {
|
||||
error: 0,
|
||||
warning: 1,
|
||||
info: 2,
|
||||
success: 3,
|
||||
};
|
||||
|
||||
const timestampFormat = format.timestamp({
|
||||
format: "DD-MMM-YYYY HH:mm:ss.SSS",
|
||||
});
|
||||
|
||||
const simpleOutputFormat = format.printf((log) => {
|
||||
return `${log["timestamp"]}\t${log.level}: ${log.message}`;
|
||||
});
|
||||
|
||||
const coloredOutputFormat = format.printf((log) => {
|
||||
let color = infoColor;
|
||||
|
||||
switch (log.level) {
|
||||
case "error":
|
||||
color = errorColor;
|
||||
break;
|
||||
case "warning":
|
||||
color = warningColor;
|
||||
break;
|
||||
case "success":
|
||||
color = successColor;
|
||||
break;
|
||||
}
|
||||
|
||||
return `${log["timestamp"]}\t${color(log.message)}`;
|
||||
});
|
||||
|
||||
const fileFormat = format.combine(timestampFormat, simpleOutputFormat);
|
||||
|
||||
const consoleFormat = format.combine(timestampFormat, coloredOutputFormat);
|
||||
|
||||
const logger = createLogger({
|
||||
levels: customLevels,
|
||||
transports: [
|
||||
new transports.File({
|
||||
level: "error",
|
||||
filename: resolve(logFolderPath, "error.log"),
|
||||
maxsize: maxLogSize,
|
||||
format: fileFormat,
|
||||
}),
|
||||
new transports.File({
|
||||
level: "success",
|
||||
filename: resolve(logFolderPath, "combined.log"),
|
||||
maxsize: maxLogSize,
|
||||
format: fileFormat,
|
||||
}),
|
||||
new transports.Console({
|
||||
level: "success",
|
||||
format: consoleFormat,
|
||||
handleExceptions: true,
|
||||
}),
|
||||
],
|
||||
exceptionHandlers: [
|
||||
new transports.File({
|
||||
filename: resolve(logFolderPath, "exceptions.log"),
|
||||
format: fileFormat,
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const Logger = {
|
||||
error: (message: string): LoggerType => logger.error(message),
|
||||
warning: (message: string): LoggerType => logger.warning(message),
|
||||
info: (message: string): LoggerType => logger.info(message),
|
||||
success: (message: string): LoggerType => logger.log("success", message),
|
||||
};
|
||||
|
||||
export default Logger;
|
||||
259
backend/src/utils/misc.ts
Normal file
259
backend/src/utils/misc.ts
Normal file
@@ -0,0 +1,259 @@
|
||||
import { MILLISECONDS_IN_DAY } from "@monkeytype/util/date-and-time";
|
||||
import { roundTo2 } from "@monkeytype/util/numbers";
|
||||
import uaparser from "ua-parser-js";
|
||||
import { MonkeyRequest } from "../api/types";
|
||||
import { ObjectId } from "mongodb";
|
||||
|
||||
//todo split this file into smaller util files (grouped by functionality)
|
||||
|
||||
export function identity(value: unknown): string {
|
||||
return Object.prototype.toString
|
||||
.call(value)
|
||||
.replace(/^\[object\s+([a-z]+)\]$/i, "$1")
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
export function base64UrlEncode(data: string): string {
|
||||
return Buffer.from(data).toString("base64url");
|
||||
}
|
||||
|
||||
export function base64UrlDecode(data: string): string {
|
||||
return Buffer.from(data, "base64url").toString();
|
||||
}
|
||||
|
||||
type AgentLog = {
|
||||
ip: string;
|
||||
agent: string;
|
||||
device?: string;
|
||||
};
|
||||
|
||||
export function buildAgentLog(req: MonkeyRequest): AgentLog {
|
||||
const agent = uaparser(req.raw.headers["user-agent"]);
|
||||
|
||||
const agentLog: AgentLog = {
|
||||
ip:
|
||||
(req.raw.headers["cf-connecting-ip"] as string) ||
|
||||
(req.raw.headers["x-forwarded-for"] as string) ||
|
||||
(req.raw.ip as string) ||
|
||||
"255.255.255.255",
|
||||
agent: `${agent.os.name} ${agent.os.version} ${agent.browser.name} ${agent.browser.version}`,
|
||||
};
|
||||
|
||||
const {
|
||||
device: { vendor, model, type },
|
||||
} = agent;
|
||||
|
||||
agentLog.device = `${vendor ?? "unknown vendor"} ${
|
||||
model ?? "unknown model"
|
||||
} ${type ?? "unknown type"}`;
|
||||
|
||||
return agentLog;
|
||||
}
|
||||
|
||||
export function padNumbers(
|
||||
numbers: number[],
|
||||
maxLength: number,
|
||||
fillString: string,
|
||||
): string[] {
|
||||
return numbers.map((number) =>
|
||||
number.toString().padStart(maxLength, fillString),
|
||||
);
|
||||
}
|
||||
|
||||
export function matchesAPattern(text: string, pattern: string): boolean {
|
||||
const regex = new RegExp(`^${pattern}$`);
|
||||
return regex.test(text);
|
||||
}
|
||||
|
||||
export function kogascore(wpm: number, acc: number, timestamp: number): number {
|
||||
// its safe to round after multiplying by 100 (99.99 * 100 rounded will be 9999 not 100)
|
||||
// rounding is necessary to protect against floating point errors
|
||||
const normalizedWpm = Math.round(wpm * 100);
|
||||
const normalizedAcc = Math.round(acc * 100);
|
||||
|
||||
const padAmount = 100000;
|
||||
const firstPart = (padAmount + normalizedWpm) * padAmount;
|
||||
const secondPart = (firstPart + normalizedAcc) * padAmount;
|
||||
|
||||
const currentDayTimeMilliseconds =
|
||||
timestamp - (timestamp % MILLISECONDS_IN_DAY);
|
||||
const todayMilliseconds = timestamp - currentDayTimeMilliseconds;
|
||||
|
||||
return (
|
||||
secondPart + Math.floor((MILLISECONDS_IN_DAY - todayMilliseconds) / 1000)
|
||||
);
|
||||
}
|
||||
|
||||
export function flattenObjectDeep(
|
||||
obj: Record<string, unknown>,
|
||||
prefix = "",
|
||||
): Record<string, unknown> {
|
||||
const result: Record<string, unknown> = {};
|
||||
const keys = Object.keys(obj);
|
||||
|
||||
keys.forEach((key) => {
|
||||
const value = obj[key];
|
||||
|
||||
const newPrefix = prefix.length > 0 ? `${prefix}.${key}` : key;
|
||||
|
||||
if (isPlainObject(value)) {
|
||||
const flattened = flattenObjectDeep(value as Record<string, unknown>);
|
||||
const flattenedKeys = Object.keys(flattened);
|
||||
|
||||
if (flattenedKeys.length === 0) {
|
||||
result[newPrefix] = value;
|
||||
}
|
||||
|
||||
flattenedKeys.forEach((flattenedKey) => {
|
||||
result[`${newPrefix}.${flattenedKey}`] = flattened[flattenedKey];
|
||||
});
|
||||
} else {
|
||||
result[newPrefix] = value;
|
||||
}
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export function sanitizeString(str: string | undefined): string | undefined {
|
||||
if (str === undefined || str === "") {
|
||||
return str;
|
||||
}
|
||||
|
||||
return str
|
||||
.replace(/[\u0300-\u036F]/g, "")
|
||||
.trim()
|
||||
.replace(/\n{3,}/g, "\n\n")
|
||||
.replace(/\s{3,}/g, " ");
|
||||
}
|
||||
|
||||
const suffixes = ["th", "st", "nd", "rd"];
|
||||
|
||||
export function getOrdinalNumberString(number: number): string {
|
||||
const lastTwo = number % 100;
|
||||
const suffix =
|
||||
suffixes[(lastTwo - 20) % 10] ?? suffixes[lastTwo] ?? suffixes[0];
|
||||
return `${number}${suffix}`;
|
||||
}
|
||||
|
||||
type TimeUnit =
|
||||
| "second"
|
||||
| "minute"
|
||||
| "hour"
|
||||
| "day"
|
||||
| "week"
|
||||
| "month"
|
||||
| "year";
|
||||
|
||||
export const MINUTE_IN_SECONDS = 1 * 60;
|
||||
export const HOUR_IN_SECONDS = 1 * 60 * MINUTE_IN_SECONDS;
|
||||
export const DAY_IN_SECONDS = 1 * 24 * HOUR_IN_SECONDS;
|
||||
export const WEEK_IN_SECONDS = 1 * 7 * DAY_IN_SECONDS;
|
||||
export const MONTH_IN_SECONDS = 1 * 30.4167 * DAY_IN_SECONDS;
|
||||
export const YEAR_IN_SECONDS = 1 * 12 * MONTH_IN_SECONDS;
|
||||
|
||||
export function formatSeconds(
|
||||
seconds: number,
|
||||
): `${number} ${TimeUnit}${"s" | ""}` {
|
||||
let unit: TimeUnit;
|
||||
let secondsInUnit: number;
|
||||
|
||||
if (seconds < MINUTE_IN_SECONDS) {
|
||||
unit = "second";
|
||||
secondsInUnit = 1;
|
||||
} else if (seconds < HOUR_IN_SECONDS) {
|
||||
unit = "minute";
|
||||
secondsInUnit = MINUTE_IN_SECONDS;
|
||||
} else if (seconds < DAY_IN_SECONDS) {
|
||||
unit = "hour";
|
||||
secondsInUnit = HOUR_IN_SECONDS;
|
||||
} else if (seconds < WEEK_IN_SECONDS) {
|
||||
unit = "day";
|
||||
secondsInUnit = DAY_IN_SECONDS;
|
||||
} else if (seconds < YEAR_IN_SECONDS) {
|
||||
if (seconds < WEEK_IN_SECONDS * 4) {
|
||||
unit = "week";
|
||||
secondsInUnit = WEEK_IN_SECONDS;
|
||||
} else {
|
||||
unit = "month";
|
||||
secondsInUnit = MONTH_IN_SECONDS;
|
||||
}
|
||||
} else {
|
||||
unit = "year";
|
||||
secondsInUnit = YEAR_IN_SECONDS;
|
||||
}
|
||||
|
||||
const normalized = roundTo2(seconds / secondsInUnit);
|
||||
|
||||
return `${normalized} ${unit}${normalized > 1 ? "s" : ""}`;
|
||||
}
|
||||
|
||||
export function isDevEnvironment(): boolean {
|
||||
return process.env["MODE"] === "dev";
|
||||
}
|
||||
|
||||
export function getFrontendUrl(): string {
|
||||
return isDevEnvironment()
|
||||
? "http://localhost:3000"
|
||||
: (process.env["FRONTEND_URL"] ?? "https://monkeytype.com");
|
||||
}
|
||||
|
||||
/**
|
||||
* convert database object into api object
|
||||
* @param data database object with `_id: ObjectId`
|
||||
* @returns api object with `id: string`
|
||||
*/
|
||||
|
||||
export function replaceObjectId<T extends { _id: ObjectId }>(
|
||||
data: T,
|
||||
): T & { _id: string };
|
||||
export function replaceObjectId<T extends { _id: ObjectId }>(
|
||||
data: T | null,
|
||||
): (T & { _id: string }) | null;
|
||||
export function replaceObjectId<T extends { _id: ObjectId }>(
|
||||
data: T | null,
|
||||
): (T & { _id: string }) | null {
|
||||
if (data === null) {
|
||||
return null;
|
||||
}
|
||||
const result = {
|
||||
...data,
|
||||
_id: data._id.toString(),
|
||||
} as T & { _id: string };
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* convert database objects into api objects
|
||||
* @param data database objects with `_id: ObjectId`
|
||||
* @returns api objects with `id: string`
|
||||
*/
|
||||
export function replaceObjectIds<T extends { _id: ObjectId }>(
|
||||
data: T[],
|
||||
): (T & { _id: string })[] {
|
||||
if (data === undefined) return data;
|
||||
return data.map((it) => replaceObjectId(it));
|
||||
}
|
||||
export type WithObjectId<T extends { _id: string }> = Omit<T, "_id"> & {
|
||||
_id: ObjectId;
|
||||
};
|
||||
|
||||
export function omit<T extends object, K extends keyof T>(
|
||||
obj: T,
|
||||
keys: K[],
|
||||
): Omit<T, K> {
|
||||
const result = { ...obj };
|
||||
for (const key of keys) {
|
||||
// oxlint-disable-next-line no-dynamic-delete
|
||||
delete result[key];
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function isPlainObject(value: unknown): boolean {
|
||||
return (
|
||||
value !== null &&
|
||||
typeof value === "object" &&
|
||||
Object.getPrototypeOf(value) === Object.prototype
|
||||
);
|
||||
}
|
||||
15
backend/src/utils/monkey-mail.ts
Normal file
15
backend/src/utils/monkey-mail.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { MonkeyMail } from "@monkeytype/schemas/users";
|
||||
import { v4 } from "uuid";
|
||||
|
||||
type MonkeyMailOptions = Partial<Omit<MonkeyMail, "id" | "read">>;
|
||||
|
||||
export function buildMonkeyMail(options: MonkeyMailOptions): MonkeyMail {
|
||||
return {
|
||||
id: v4(),
|
||||
subject: options.subject ?? "",
|
||||
body: options.body ?? "",
|
||||
timestamp: options.timestamp ?? Date.now(),
|
||||
read: false,
|
||||
rewards: options.rewards ?? [],
|
||||
};
|
||||
}
|
||||
17
backend/src/utils/monkey-response.ts
Normal file
17
backend/src/utils/monkey-response.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { MonkeyResponseType } from "@monkeytype/contracts/util/api";
|
||||
|
||||
export type MonkeyDataAware<T> = {
|
||||
data: T | null;
|
||||
};
|
||||
|
||||
export class MonkeyResponse<T = null>
|
||||
implements MonkeyResponseType, MonkeyDataAware<T>
|
||||
{
|
||||
public message: string;
|
||||
public data: T;
|
||||
|
||||
constructor(message: string, data: T) {
|
||||
this.message = message;
|
||||
this.data = data;
|
||||
}
|
||||
}
|
||||
210
backend/src/utils/pb.ts
Normal file
210
backend/src/utils/pb.ts
Normal file
@@ -0,0 +1,210 @@
|
||||
import { Mode, PersonalBest, PersonalBests } from "@monkeytype/schemas/shared";
|
||||
import { Result as ResultType } from "@monkeytype/schemas/results";
|
||||
import { getFunbox } from "@monkeytype/funbox";
|
||||
|
||||
export type LbPersonalBests = {
|
||||
time: Record<number, Record<string, PersonalBest>>;
|
||||
};
|
||||
|
||||
type CheckAndUpdatePbResult = {
|
||||
isPb: boolean;
|
||||
personalBests: PersonalBests;
|
||||
lbPersonalBests?: LbPersonalBests;
|
||||
};
|
||||
|
||||
type Result = Omit<ResultType<Mode>, "_id" | "name">;
|
||||
|
||||
export function canFunboxGetPb(result: Result): boolean {
|
||||
if (result.funbox === undefined || result.funbox.length === 0) return true;
|
||||
|
||||
return getFunbox(result.funbox).every((f) => f.canGetPb);
|
||||
}
|
||||
|
||||
export function checkAndUpdatePb(
|
||||
userPersonalBests: PersonalBests,
|
||||
lbPersonalBests: LbPersonalBests | undefined,
|
||||
result: Result,
|
||||
): CheckAndUpdatePbResult {
|
||||
const mode = result.mode;
|
||||
const mode2 = result.mode2;
|
||||
|
||||
const userPb = userPersonalBests ?? {};
|
||||
userPb[mode] ??= {};
|
||||
userPb[mode][mode2] ??= [];
|
||||
|
||||
const personalBestMatch = (userPb[mode][mode2] as PersonalBest[]).find((pb) =>
|
||||
matchesPersonalBest(result, pb),
|
||||
);
|
||||
|
||||
let isPb = true;
|
||||
|
||||
if (personalBestMatch) {
|
||||
const didUpdate = updatePersonalBest(personalBestMatch, result);
|
||||
isPb = didUpdate;
|
||||
} else {
|
||||
(userPb[mode][mode2] as PersonalBest[]).push(buildPersonalBest(result));
|
||||
}
|
||||
|
||||
if (lbPersonalBests !== undefined && lbPersonalBests !== null) {
|
||||
const newLbPb = updateLeaderboardPersonalBests(
|
||||
userPb,
|
||||
lbPersonalBests,
|
||||
result,
|
||||
);
|
||||
if (newLbPb !== null) {
|
||||
lbPersonalBests = newLbPb;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
isPb,
|
||||
personalBests: userPb,
|
||||
lbPersonalBests: lbPersonalBests,
|
||||
};
|
||||
}
|
||||
|
||||
function matchesPersonalBest(
|
||||
result: Result,
|
||||
personalBest: PersonalBest,
|
||||
): boolean {
|
||||
if (
|
||||
result.difficulty === undefined ||
|
||||
result.language === undefined ||
|
||||
result.punctuation === undefined ||
|
||||
result.lazyMode === undefined ||
|
||||
result.numbers === undefined
|
||||
) {
|
||||
throw new Error("Missing result data (matchesPersonalBest)");
|
||||
}
|
||||
|
||||
const sameLazyMode =
|
||||
(result.lazyMode ?? false) === (personalBest.lazyMode ?? false);
|
||||
const samePunctuation =
|
||||
(result.punctuation ?? false) === (personalBest.punctuation ?? false);
|
||||
const sameDifficulty = result.difficulty === personalBest.difficulty;
|
||||
const sameLanguage = result.language === personalBest.language;
|
||||
const sameNumbers =
|
||||
(result.numbers ?? false) === (personalBest.numbers ?? false);
|
||||
|
||||
return (
|
||||
sameLazyMode &&
|
||||
samePunctuation &&
|
||||
sameDifficulty &&
|
||||
sameLanguage &&
|
||||
sameNumbers
|
||||
);
|
||||
}
|
||||
|
||||
function updatePersonalBest(
|
||||
personalBest: PersonalBest,
|
||||
result: Result,
|
||||
): boolean {
|
||||
if (personalBest.wpm >= result.wpm) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
result.difficulty === undefined ||
|
||||
result.language === undefined ||
|
||||
result.punctuation === undefined ||
|
||||
result.lazyMode === undefined ||
|
||||
result.acc === undefined ||
|
||||
result.consistency === undefined ||
|
||||
result.rawWpm === undefined ||
|
||||
result.wpm === undefined ||
|
||||
result.numbers === undefined
|
||||
) {
|
||||
throw new Error("Missing result data (updatePersonalBest)");
|
||||
}
|
||||
|
||||
personalBest.difficulty = result.difficulty;
|
||||
personalBest.language = result.language;
|
||||
personalBest.punctuation = result.punctuation;
|
||||
personalBest.lazyMode = result.lazyMode;
|
||||
personalBest.acc = result.acc;
|
||||
personalBest.consistency = result.consistency;
|
||||
personalBest.raw = result.rawWpm;
|
||||
personalBest.wpm = result.wpm;
|
||||
personalBest.numbers = result.numbers;
|
||||
personalBest.timestamp = Date.now();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function buildPersonalBest(result: Result): PersonalBest {
|
||||
if (
|
||||
result.difficulty === undefined ||
|
||||
result.language === undefined ||
|
||||
result.punctuation === undefined ||
|
||||
result.lazyMode === undefined ||
|
||||
result.acc === undefined ||
|
||||
result.consistency === undefined ||
|
||||
result.rawWpm === undefined ||
|
||||
result.wpm === undefined ||
|
||||
result.numbers === undefined
|
||||
) {
|
||||
throw new Error("Missing result data (buildPersonalBest)");
|
||||
}
|
||||
return {
|
||||
acc: result.acc,
|
||||
consistency: result.consistency,
|
||||
difficulty: result.difficulty,
|
||||
lazyMode: result.lazyMode,
|
||||
language: result.language,
|
||||
punctuation: result.punctuation,
|
||||
raw: result.rawWpm,
|
||||
wpm: result.wpm,
|
||||
numbers: result.numbers,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
export function updateLeaderboardPersonalBests(
|
||||
userPersonalBests: PersonalBests,
|
||||
lbPersonalBests: LbPersonalBests,
|
||||
result: Result,
|
||||
): LbPersonalBests | null {
|
||||
if (!shouldUpdateLeaderboardPersonalBests(result)) {
|
||||
return null;
|
||||
}
|
||||
const lbPb = lbPersonalBests ?? {};
|
||||
const mode = result.mode as keyof typeof lbPb;
|
||||
const mode2 = result.mode2 as unknown as keyof (typeof lbPb)[typeof mode];
|
||||
lbPb[mode] ??= {};
|
||||
lbPb[mode][mode2] ??= {};
|
||||
|
||||
const bestForEveryLanguage: Record<string, PersonalBest> = {};
|
||||
(userPersonalBests[mode][mode2] as PersonalBest[]).forEach(
|
||||
(pb: PersonalBest) => {
|
||||
const language = pb.language;
|
||||
if (
|
||||
bestForEveryLanguage[language] === undefined ||
|
||||
bestForEveryLanguage[language].wpm < pb.wpm
|
||||
) {
|
||||
bestForEveryLanguage[language] = pb;
|
||||
}
|
||||
},
|
||||
);
|
||||
Object.entries(bestForEveryLanguage).forEach(([language, pb]) => {
|
||||
const languageDoesNotExist = lbPb[mode][mode2]?.[language] === undefined;
|
||||
const languageIsEmpty =
|
||||
lbPb[mode][mode2]?.[language] &&
|
||||
Object.keys(lbPb[mode][mode2][language]).length === 0;
|
||||
|
||||
if (
|
||||
(languageDoesNotExist ||
|
||||
languageIsEmpty ||
|
||||
(lbPb[mode][mode2]?.[language]?.wpm ?? 0) < pb.wpm) &&
|
||||
lbPb[mode][mode2] !== undefined
|
||||
) {
|
||||
lbPb[mode][mode2][language] = pb;
|
||||
}
|
||||
});
|
||||
return lbPb;
|
||||
}
|
||||
|
||||
function shouldUpdateLeaderboardPersonalBests(result: Result): boolean {
|
||||
const isValidTimeMode =
|
||||
result.mode === "time" && (result.mode2 === "15" || result.mode2 === "60");
|
||||
return isValidTimeMode && !result.lazyMode;
|
||||
}
|
||||
346
backend/src/utils/prometheus.ts
Normal file
346
backend/src/utils/prometheus.ts
Normal file
@@ -0,0 +1,346 @@
|
||||
import "dotenv/config";
|
||||
import { Counter, Histogram, Gauge } from "prom-client";
|
||||
import { CompletedEvent } from "@monkeytype/schemas/results";
|
||||
import { Request } from "express";
|
||||
|
||||
const auth = new Counter({
|
||||
name: "api_request_auth_total",
|
||||
help: "Counts authentication events",
|
||||
labelNames: ["type"],
|
||||
});
|
||||
|
||||
const result = new Counter({
|
||||
name: "result_saved_total",
|
||||
help: "Counts result saves",
|
||||
labelNames: [
|
||||
"mode",
|
||||
"mode2",
|
||||
"isPb",
|
||||
"blindMode",
|
||||
"lazyMode",
|
||||
"difficulty",
|
||||
"numbers",
|
||||
"punctuation",
|
||||
],
|
||||
});
|
||||
|
||||
const dailyLb = new Counter({
|
||||
name: "daily_leaderboard_update_total",
|
||||
help: "Counts daily leaderboard updates",
|
||||
labelNames: ["mode", "mode2", "language"],
|
||||
});
|
||||
|
||||
const resultLanguage = new Counter({
|
||||
name: "result_language_total",
|
||||
help: "Counts result langauge",
|
||||
labelNames: ["language"],
|
||||
});
|
||||
|
||||
const resultFunbox = new Counter({
|
||||
name: "result_funbox_total",
|
||||
help: "Counts result funbox",
|
||||
labelNames: ["funbox"],
|
||||
});
|
||||
|
||||
const resultWpm = new Histogram({
|
||||
name: "result_wpm",
|
||||
help: "Result wpm",
|
||||
labelNames: ["mode", "mode2"],
|
||||
buckets: [
|
||||
10, 20, 30, 40, 50, 60, 70, 80, 90, 100, 110, 120, 130, 140, 150, 160, 170,
|
||||
180, 190, 200, 210, 220, 230, 240, 250, 260, 270, 280, 290,
|
||||
],
|
||||
});
|
||||
|
||||
const resultAcc = new Histogram({
|
||||
name: "result_acc",
|
||||
help: "Result accuracy",
|
||||
labelNames: ["mode", "mode2"],
|
||||
buckets: [10, 20, 30, 40, 50, 60, 70, 80, 90, 100],
|
||||
});
|
||||
|
||||
const resultDuration = new Histogram({
|
||||
name: "result_duration",
|
||||
help: "Result duration",
|
||||
buckets: [
|
||||
5, 10, 15, 30, 45, 60, 90, 120, 250, 500, 750, 1000, 1250, 1500, 1750, 2000,
|
||||
2500, 3000,
|
||||
],
|
||||
});
|
||||
|
||||
const leaderboardUpdate = new Gauge({
|
||||
name: "leaderboard_update_seconds",
|
||||
help: "Leaderboard update time",
|
||||
labelNames: ["language", "mode", "mode2", "step"],
|
||||
});
|
||||
|
||||
export function incrementAuth(
|
||||
type: "Bearer" | "ApeKey" | "None" | "GithubWebhook",
|
||||
): void {
|
||||
auth.inc({ type });
|
||||
}
|
||||
|
||||
export function setLeaderboard(
|
||||
language: string,
|
||||
mode: string,
|
||||
mode2: string,
|
||||
times: [number, number, number, number],
|
||||
): void {
|
||||
leaderboardUpdate.set({ language, mode, mode2, step: "aggregate" }, times[0]);
|
||||
leaderboardUpdate.set({ language, mode, mode2, step: "loop" }, times[1]);
|
||||
leaderboardUpdate.set({ language, mode, mode2, step: "insert" }, times[2]);
|
||||
leaderboardUpdate.set({ language, mode, mode2, step: "index" }, times[3]);
|
||||
}
|
||||
|
||||
export function incrementResult(res: CompletedEvent, isPb?: boolean): void {
|
||||
const {
|
||||
mode,
|
||||
mode2,
|
||||
blindMode,
|
||||
lazyMode,
|
||||
difficulty,
|
||||
funbox,
|
||||
language,
|
||||
numbers,
|
||||
punctuation,
|
||||
} = res;
|
||||
|
||||
let m2 = mode2;
|
||||
if (mode === "time" && !["15", "30", "60", "120"].includes(mode2)) {
|
||||
m2 = "custom";
|
||||
}
|
||||
if (mode === "words" && !["10", "25", "50", "100"].includes(mode2)) {
|
||||
m2 = "custom";
|
||||
}
|
||||
if (mode === "quote" || mode === "zen" || mode === "custom") m2 = mode;
|
||||
|
||||
result.inc({
|
||||
mode,
|
||||
mode2: m2,
|
||||
isPb: isPb ? "true" : "false",
|
||||
blindMode: blindMode ? "true" : "false",
|
||||
lazyMode: lazyMode ? "true" : "false",
|
||||
difficulty: difficulty || "normal",
|
||||
numbers: numbers ? "true" : "false",
|
||||
punctuation: punctuation ? "true" : "false",
|
||||
});
|
||||
|
||||
resultLanguage.inc({
|
||||
language: language || "english",
|
||||
});
|
||||
|
||||
resultFunbox.inc({
|
||||
funbox: (funbox ?? ["none"]).join("#"),
|
||||
});
|
||||
|
||||
resultWpm.observe(
|
||||
{
|
||||
mode,
|
||||
mode2: m2,
|
||||
},
|
||||
res.wpm,
|
||||
);
|
||||
|
||||
resultAcc.observe(
|
||||
{
|
||||
mode,
|
||||
mode2: m2,
|
||||
},
|
||||
res.acc,
|
||||
);
|
||||
|
||||
resultDuration.observe(res.testDuration);
|
||||
}
|
||||
|
||||
export function incrementDailyLeaderboard(
|
||||
mode: string,
|
||||
mode2: string,
|
||||
language: string,
|
||||
): void {
|
||||
dailyLb.inc({ mode, mode2, language });
|
||||
}
|
||||
|
||||
const clientVersionsCounter = new Counter({
|
||||
name: "api_client_versions",
|
||||
help: "Records frequency of client versions",
|
||||
labelNames: ["version"],
|
||||
});
|
||||
|
||||
export function recordClientVersion(version: string): void {
|
||||
clientVersionsCounter.inc({ version });
|
||||
}
|
||||
|
||||
const serverVersionCounter = new Counter({
|
||||
name: "api_server_version",
|
||||
help: "The server's current version",
|
||||
labelNames: ["version"],
|
||||
});
|
||||
|
||||
export function recordServerVersion(serverVersion: string): void {
|
||||
serverVersionCounter.inc({ version: serverVersion });
|
||||
}
|
||||
|
||||
const clientErrorByVersion = new Counter({
|
||||
name: "api_client_error_by_version",
|
||||
help: "Client versions which are experiencing 400 errors",
|
||||
labelNames: ["version"],
|
||||
});
|
||||
|
||||
export function recordClientErrorByVersion(version: string): void {
|
||||
clientErrorByVersion.inc({ version });
|
||||
}
|
||||
|
||||
const serverErrorByVersion = new Counter({
|
||||
name: "api_server_error_by_version",
|
||||
help: "Server versions which are generating 500 errors",
|
||||
labelNames: ["version"],
|
||||
});
|
||||
|
||||
export function recordServerErrorByVersion(version: string): void {
|
||||
serverErrorByVersion.inc({ version });
|
||||
}
|
||||
|
||||
const authTime = new Histogram({
|
||||
name: "api_request_auth_time",
|
||||
help: "Time spent authenticating",
|
||||
labelNames: ["type", "status", "path"],
|
||||
buckets: [
|
||||
100, 200, 300, 400, 500, 600, 700, 800, 900, 1000, 1500, 2000, 2500, 3000,
|
||||
],
|
||||
});
|
||||
|
||||
export function recordAuthTime(
|
||||
type: string,
|
||||
status: "success" | "failure",
|
||||
time: number,
|
||||
req: Request,
|
||||
): void {
|
||||
// for some reason route is not in the types
|
||||
// oxlint-disable-next-line no-unsafe-member-access
|
||||
const reqPath = req.baseUrl + req.route.path;
|
||||
|
||||
let normalizedPath = "/";
|
||||
if (reqPath !== "/") {
|
||||
normalizedPath = reqPath.endsWith("/") ? reqPath.slice(0, -1) : reqPath;
|
||||
}
|
||||
|
||||
const pathNoGet = normalizedPath.replace(/\?.*/, "");
|
||||
|
||||
authTime.observe({ type, status, path: pathNoGet }, time);
|
||||
}
|
||||
|
||||
const requestCountry = new Counter({
|
||||
name: "api_request_country",
|
||||
help: "Country of request",
|
||||
labelNames: ["path", "country"],
|
||||
});
|
||||
|
||||
export function recordRequestCountry(country: string, req: Request): void {
|
||||
// for some reason route is not in the types
|
||||
// oxlint-disable-next-line no-unsafe-member-access
|
||||
const reqPath = req.baseUrl + req.route.path;
|
||||
|
||||
let normalizedPath = "/";
|
||||
if (reqPath !== "/") {
|
||||
normalizedPath = reqPath.endsWith("/") ? reqPath.slice(0, -1) : reqPath;
|
||||
}
|
||||
|
||||
const pathNoGet = normalizedPath.replace(/\?.*/, "");
|
||||
|
||||
requestCountry.inc({ path: pathNoGet, country });
|
||||
}
|
||||
|
||||
const tokenCacheAccess = new Counter({
|
||||
name: "api_token_cache_access",
|
||||
help: "Token cache access",
|
||||
labelNames: ["status"],
|
||||
});
|
||||
|
||||
export function recordTokenCacheAccess(
|
||||
status: "hit" | "miss" | "hit_expired",
|
||||
): void {
|
||||
tokenCacheAccess.inc({ status });
|
||||
}
|
||||
|
||||
const tokenCacheSize = new Gauge({
|
||||
name: "api_token_cache_size",
|
||||
help: "Token cache size",
|
||||
});
|
||||
|
||||
export function setTokenCacheSize(size: number): void {
|
||||
tokenCacheSize.set(size);
|
||||
}
|
||||
|
||||
const tokenCacheLength = new Gauge({
|
||||
name: "api_token_cache_length",
|
||||
help: "Token cache length",
|
||||
});
|
||||
|
||||
export function setTokenCacheLength(length: number): void {
|
||||
tokenCacheLength.set(length);
|
||||
}
|
||||
|
||||
const uidRequestCount = new Counter({
|
||||
name: "user_request_count",
|
||||
help: "Request count per uid",
|
||||
labelNames: ["uid"],
|
||||
});
|
||||
|
||||
export function recordRequestForUid(uid: string): void {
|
||||
uidRequestCount.inc({ uid });
|
||||
}
|
||||
|
||||
const collectionSize = new Gauge({
|
||||
name: "db_collection_size",
|
||||
help: "Size of a collection",
|
||||
labelNames: ["collection"],
|
||||
});
|
||||
|
||||
export function setCollectionSize(collection: string, size: number): void {
|
||||
collectionSize.set({ collection }, size);
|
||||
}
|
||||
|
||||
const queueLength = new Gauge({
|
||||
name: "queue_length",
|
||||
help: "Length of the queues",
|
||||
labelNames: ["queueName", "countType"],
|
||||
});
|
||||
|
||||
export function setQueueLength(
|
||||
queueName: string,
|
||||
countType: string,
|
||||
length: number,
|
||||
): void {
|
||||
queueLength.set({ queueName, countType }, length);
|
||||
}
|
||||
|
||||
const emailCount = new Counter({
|
||||
name: "email_count",
|
||||
help: "Emails sent by the server",
|
||||
labelNames: ["type", "status"],
|
||||
});
|
||||
|
||||
export function recordEmail(type: string, status: string): void {
|
||||
emailCount.inc({ type, status });
|
||||
}
|
||||
|
||||
const timeToCompleteJobTotal = new Counter({
|
||||
name: "time_to_complete_job_total",
|
||||
help: "Time to complete a job total",
|
||||
labelNames: ["queueName", "jobName"],
|
||||
});
|
||||
|
||||
const timeToCompleteJobCount = new Counter({
|
||||
name: "time_to_complete_job_count",
|
||||
help: "Time to complete a job count",
|
||||
labelNames: ["queueName", "jobName"],
|
||||
});
|
||||
|
||||
export function recordTimeToCompleteJob(
|
||||
queueName: string,
|
||||
jobName: string,
|
||||
time: number,
|
||||
): void {
|
||||
timeToCompleteJobTotal.inc({ queueName, jobName }, time);
|
||||
timeToCompleteJobCount.inc({ queueName, jobName });
|
||||
}
|
||||
126
backend/src/utils/result.ts
Normal file
126
backend/src/utils/result.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import {
|
||||
ChartData,
|
||||
CompletedEvent,
|
||||
OldChartData,
|
||||
Result,
|
||||
} from "@monkeytype/schemas/results";
|
||||
import { Mode } from "@monkeytype/schemas/shared";
|
||||
import { ObjectId } from "mongodb";
|
||||
import { WithObjectId } from "./misc";
|
||||
import { FunboxName } from "@monkeytype/schemas/configs";
|
||||
|
||||
export type DBResult = WithObjectId<Result<Mode>> & {
|
||||
//legacy values
|
||||
correctChars?: number;
|
||||
incorrectChars?: number;
|
||||
chartData: ChartData | OldChartData | "toolong";
|
||||
};
|
||||
|
||||
export function buildDbResult(
|
||||
completedEvent: CompletedEvent,
|
||||
userName: string,
|
||||
isPb: boolean,
|
||||
): DBResult {
|
||||
const ce = completedEvent;
|
||||
const res: DBResult = {
|
||||
_id: new ObjectId(),
|
||||
uid: ce.uid,
|
||||
wpm: ce.wpm,
|
||||
rawWpm: ce.rawWpm,
|
||||
charStats: ce.charStats,
|
||||
acc: ce.acc,
|
||||
mode: ce.mode,
|
||||
mode2: ce.mode2,
|
||||
quoteLength: ce.quoteLength,
|
||||
timestamp: ce.timestamp,
|
||||
restartCount: ce.restartCount,
|
||||
incompleteTestSeconds: ce.incompleteTestSeconds,
|
||||
testDuration: ce.testDuration,
|
||||
afkDuration: ce.afkDuration,
|
||||
tags: ce.tags,
|
||||
consistency: ce.consistency,
|
||||
keyConsistency: ce.keyConsistency,
|
||||
chartData: ce.chartData,
|
||||
language: ce.language,
|
||||
lazyMode: ce.lazyMode,
|
||||
difficulty: ce.difficulty,
|
||||
funbox: ce.funbox,
|
||||
numbers: ce.numbers,
|
||||
punctuation: ce.punctuation,
|
||||
isPb: isPb,
|
||||
bailedOut: ce.bailedOut,
|
||||
blindMode: ce.blindMode,
|
||||
name: userName,
|
||||
};
|
||||
|
||||
//compress object by omitting default values. Frontend will add them back after reading
|
||||
//reduces object size on the database and on the rest api
|
||||
if (!ce.bailedOut) delete res.bailedOut;
|
||||
if (!ce.blindMode) delete res.blindMode;
|
||||
if (!ce.lazyMode) delete res.lazyMode;
|
||||
if (ce.difficulty === "normal") delete res.difficulty;
|
||||
if (ce.funbox.length === 0) delete res.funbox;
|
||||
if (ce.language === "english") delete res.language;
|
||||
if (!ce.numbers) delete res.numbers;
|
||||
if (!ce.punctuation) delete res.punctuation;
|
||||
if (ce.mode !== "quote") delete res.quoteLength;
|
||||
if (ce.restartCount === 0) delete res.restartCount;
|
||||
if (ce.incompleteTestSeconds === 0) delete res.incompleteTestSeconds;
|
||||
if (ce.afkDuration === 0) delete res.afkDuration;
|
||||
if (ce.tags.length === 0) delete res.tags;
|
||||
if (res.isPb === false) delete res.isPb;
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert legacy values
|
||||
* @param result
|
||||
* @returns
|
||||
*/
|
||||
export function replaceLegacyValues(result: DBResult): DBResult {
|
||||
//convert legacy values
|
||||
if (
|
||||
result.correctChars !== undefined &&
|
||||
result.incorrectChars !== undefined
|
||||
) {
|
||||
//super edge case but just in case
|
||||
if (result.charStats !== undefined) {
|
||||
result.charStats = [
|
||||
result.charStats[0],
|
||||
result.charStats[1],
|
||||
result.charStats[2],
|
||||
result.charStats[3],
|
||||
];
|
||||
delete result.correctChars;
|
||||
delete result.incorrectChars;
|
||||
} else {
|
||||
result.charStats = [result.correctChars, result.incorrectChars, 0, 0];
|
||||
delete result.correctChars;
|
||||
delete result.incorrectChars;
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof result.funbox === "string") {
|
||||
if (result.funbox === "none") {
|
||||
result.funbox = [];
|
||||
} else {
|
||||
result.funbox = (result.funbox as string).split("#") as FunboxName[];
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
result.chartData !== undefined &&
|
||||
result.chartData !== "toolong" &&
|
||||
"raw" in result.chartData
|
||||
) {
|
||||
const temp = result.chartData;
|
||||
result.chartData = {
|
||||
wpm: temp.wpm,
|
||||
burst: temp.raw,
|
||||
err: temp.err,
|
||||
};
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
27
backend/src/utils/ttl-cache.ts
Normal file
27
backend/src/utils/ttl-cache.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* Creates a caching function that loads data with a specified TTL (Time-to-Live).
|
||||
* If the cache has expired (based on TTL), it will re-fetch the data by calling the provided function.
|
||||
* Otherwise, it returns the cached value.
|
||||
*
|
||||
* @template T - The type of the value being cached.
|
||||
*
|
||||
* @param {number} ttlMs - The Time-to-Live (TTL) in milliseconds. The cache will refetch on call after this duration.
|
||||
* @param {() => Promise<T>} fn - A function that returns a promise resolving to the data to cache.
|
||||
*
|
||||
* @returns {() => Promise<T | undefined>}
|
||||
*/
|
||||
export function cacheWithTTL<T>(
|
||||
ttlMs: number,
|
||||
fn: () => Promise<T>,
|
||||
): () => Promise<T | undefined> {
|
||||
let lastFetchTime = 0;
|
||||
let cache: T | undefined;
|
||||
|
||||
return async () => {
|
||||
if (lastFetchTime < Date.now() - ttlMs) {
|
||||
lastFetchTime = Date.now();
|
||||
cache = await fn();
|
||||
}
|
||||
return cache;
|
||||
};
|
||||
}
|
||||
47
backend/src/utils/validation.ts
Normal file
47
backend/src/utils/validation.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { CompletedEvent } from "@monkeytype/schemas/results";
|
||||
|
||||
export function isTestTooShort(result: CompletedEvent): boolean {
|
||||
const { mode, mode2, customText, testDuration, bailedOut } = result;
|
||||
|
||||
if (mode === "time") {
|
||||
const seconds = parseInt(mode2);
|
||||
|
||||
const setTimeTooShort = seconds > 0 && seconds < 15;
|
||||
const infiniteTimeTooShort = seconds === 0 && testDuration < 15;
|
||||
const bailedOutTooShort = bailedOut
|
||||
? bailedOut && testDuration < 15
|
||||
: false;
|
||||
return setTimeTooShort || infiniteTimeTooShort || bailedOutTooShort;
|
||||
}
|
||||
|
||||
if (mode === "words") {
|
||||
const wordCount = parseInt(mode2);
|
||||
|
||||
const setWordTooShort = wordCount > 0 && wordCount < 10;
|
||||
const infiniteWordTooShort = wordCount === 0 && testDuration < 15;
|
||||
const bailedOutTooShort = bailedOut
|
||||
? bailedOut && testDuration < 15
|
||||
: false;
|
||||
return setWordTooShort || infiniteWordTooShort || bailedOutTooShort;
|
||||
}
|
||||
|
||||
if (mode === "custom") {
|
||||
if (!customText) return true;
|
||||
const wordLimitTooShort =
|
||||
(customText.limit.mode === "word" ||
|
||||
customText.limit.mode === "section") &&
|
||||
customText.limit.value < 10;
|
||||
const timeLimitTooShort =
|
||||
customText.limit.mode === "time" && customText.limit.value < 15;
|
||||
const bailedOutTooShort = bailedOut
|
||||
? bailedOut && testDuration < 15
|
||||
: false;
|
||||
return wordLimitTooShort || timeLimitTooShort || bailedOutTooShort;
|
||||
}
|
||||
|
||||
if (mode === "zen") {
|
||||
return testDuration < 15;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
38
backend/src/version.ts
Normal file
38
backend/src/version.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { join } from "path";
|
||||
import { isDevEnvironment, padNumbers } from "./utils/misc";
|
||||
import { readFileSync, writeFileSync, existsSync } from "fs";
|
||||
|
||||
const SERVER_VERSION_FILE_PATH = join(__dirname, "./server.version");
|
||||
const { COMMIT_HASH = "NO_HASH" } = process.env;
|
||||
|
||||
function getDateVersion(): string {
|
||||
const date = new Date();
|
||||
|
||||
const versionPrefix = [
|
||||
date.getFullYear(),
|
||||
date.getMonth() + 1,
|
||||
date.getDate(),
|
||||
];
|
||||
const versionSuffix = [date.getHours(), date.getMinutes()];
|
||||
|
||||
return [versionPrefix, versionSuffix]
|
||||
.map((versionPart) => padNumbers(versionPart, 2, "0").join("."))
|
||||
.join("_");
|
||||
}
|
||||
|
||||
function getVersion(): string {
|
||||
if (isDevEnvironment()) {
|
||||
return "DEVELOPMENT-VERSION";
|
||||
}
|
||||
|
||||
if (existsSync(SERVER_VERSION_FILE_PATH)) {
|
||||
return readFileSync(SERVER_VERSION_FILE_PATH, "utf-8");
|
||||
}
|
||||
|
||||
const serverVersion = `${getDateVersion()}_${COMMIT_HASH}`;
|
||||
writeFileSync(SERVER_VERSION_FILE_PATH, serverVersion);
|
||||
|
||||
return serverVersion;
|
||||
}
|
||||
|
||||
export const version = getVersion();
|
||||
46
backend/src/workers/email-worker.ts
Normal file
46
backend/src/workers/email-worker.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import IORedis from "ioredis";
|
||||
import { Worker, Job, type ConnectionOptions } from "bullmq";
|
||||
import Logger from "../utils/logger";
|
||||
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<EmailTask<EmailType>>): Promise<void> {
|
||||
const type = job.data.type;
|
||||
const email = job.data.email;
|
||||
const ctx = job.data.ctx;
|
||||
|
||||
Logger.info(`Starting job: ${type}`);
|
||||
|
||||
const start = performance.now();
|
||||
|
||||
const result = await sendEmail(type, email, ctx);
|
||||
|
||||
if (!result.success) {
|
||||
void addLog("error_sending_email", {
|
||||
type,
|
||||
email,
|
||||
ctx: JSON.stringify(ctx),
|
||||
error: result.message,
|
||||
});
|
||||
throw new Error(result.message);
|
||||
}
|
||||
|
||||
const elapsed = performance.now() - start;
|
||||
recordTimeToCompleteJob(EmailQueue.queueName, type, elapsed);
|
||||
Logger.success(`Job: ${type} - completed in ${elapsed}ms`);
|
||||
}
|
||||
|
||||
export default (redisConnection?: IORedis.Redis): Worker => {
|
||||
const worker = new Worker(EmailQueue.queueName, jobHandler, {
|
||||
autorun: false,
|
||||
connection: redisConnection as ConnectionOptions,
|
||||
});
|
||||
worker.on("failed", (job, error) => {
|
||||
Logger.error(
|
||||
`Job: ${job.data.type} - failed with error "${error.message}"`,
|
||||
);
|
||||
});
|
||||
return worker;
|
||||
};
|
||||
4
backend/src/workers/index.ts
Normal file
4
backend/src/workers/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import LaterWorker from "./later-worker";
|
||||
import EmailWorker from "./email-worker";
|
||||
|
||||
export default [LaterWorker, EmailWorker];
|
||||
224
backend/src/workers/later-worker.ts
Normal file
224
backend/src/workers/later-worker.ts
Normal file
@@ -0,0 +1,224 @@
|
||||
import IORedis from "ioredis";
|
||||
import { Worker, Job, type ConnectionOptions } from "bullmq";
|
||||
import Logger from "../utils/logger";
|
||||
import { addToInboxBulk } from "../dal/user";
|
||||
import GeorgeQueue from "../queues/george-queue";
|
||||
import { buildMonkeyMail } from "../utils/monkey-mail";
|
||||
import { DailyLeaderboard } from "../utils/daily-leaderboards";
|
||||
import { getCachedConfiguration } from "../init/configuration";
|
||||
import { formatSeconds, getOrdinalNumberString } from "../utils/misc";
|
||||
import LaterQueue, {
|
||||
type LaterTask,
|
||||
type LaterTaskContexts,
|
||||
type LaterTaskType,
|
||||
} from "../queues/later-queue";
|
||||
import { recordTimeToCompleteJob } from "../utils/prometheus";
|
||||
import { WeeklyXpLeaderboard } from "../services/weekly-xp-leaderboard";
|
||||
import { MonkeyMail } from "@monkeytype/schemas/users";
|
||||
import { isSafeNumber, mapRange } from "@monkeytype/util/numbers";
|
||||
import { RewardBracket } from "@monkeytype/schemas/configuration";
|
||||
|
||||
async function handleDailyLeaderboardResults(
|
||||
ctx: LaterTaskContexts["daily-leaderboard-results"],
|
||||
): Promise<void> {
|
||||
const { yesterdayTimestamp, modeRule } = ctx;
|
||||
const { language, mode, mode2 } = modeRule;
|
||||
const {
|
||||
dailyLeaderboards: dailyLeaderboardsConfig,
|
||||
users: { inbox: inboxConfig },
|
||||
} = await getCachedConfiguration(false);
|
||||
|
||||
const { maxResults, xpRewardBrackets, topResultsToAnnounce } =
|
||||
dailyLeaderboardsConfig;
|
||||
|
||||
const maxRankToGet = Math.max(
|
||||
topResultsToAnnounce,
|
||||
...xpRewardBrackets.map((bracket) => bracket.maxRank),
|
||||
);
|
||||
|
||||
const dailyLeaderboard = new DailyLeaderboard(modeRule, yesterdayTimestamp);
|
||||
|
||||
const results = await dailyLeaderboard.getResults(
|
||||
0,
|
||||
maxRankToGet,
|
||||
dailyLeaderboardsConfig,
|
||||
false,
|
||||
);
|
||||
|
||||
if (results === null || results.entries.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (inboxConfig.enabled && xpRewardBrackets.length > 0) {
|
||||
const mailEntries: {
|
||||
uid: string;
|
||||
mail: MonkeyMail[];
|
||||
}[] = [];
|
||||
|
||||
results.entries.forEach((entry) => {
|
||||
const rank = entry.rank ?? maxResults;
|
||||
const wpm = Math.round(entry.wpm);
|
||||
|
||||
const placementString = getOrdinalNumberString(rank);
|
||||
|
||||
const xpReward = calculateXpReward(xpRewardBrackets, rank);
|
||||
|
||||
if (!isSafeNumber(xpReward)) return;
|
||||
|
||||
const rewardMail = buildMonkeyMail({
|
||||
subject: "Daily leaderboard placement",
|
||||
body: `Congratulations ${entry.name} on placing ${placementString} with ${wpm} wpm in the ${language} ${mode} ${mode2} daily leaderboard!`,
|
||||
rewards: [
|
||||
{
|
||||
type: "xp",
|
||||
item: Math.round(xpReward),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
mailEntries.push({
|
||||
uid: entry.uid,
|
||||
mail: [rewardMail],
|
||||
});
|
||||
});
|
||||
|
||||
await addToInboxBulk(mailEntries, inboxConfig);
|
||||
}
|
||||
|
||||
const topResults = results.entries.slice(
|
||||
0,
|
||||
dailyLeaderboardsConfig.topResultsToAnnounce,
|
||||
);
|
||||
|
||||
const leaderboardId = `${mode} ${mode2} ${language}`;
|
||||
await GeorgeQueue.announceDailyLeaderboardTopResults(
|
||||
leaderboardId,
|
||||
yesterdayTimestamp,
|
||||
topResults,
|
||||
);
|
||||
}
|
||||
|
||||
async function handleWeeklyXpLeaderboardResults(
|
||||
ctx: LaterTaskContexts["weekly-xp-leaderboard-results"],
|
||||
): Promise<void> {
|
||||
const {
|
||||
leaderboards: { weeklyXp: weeklyXpConfig },
|
||||
users: { inbox: inboxConfig },
|
||||
} = await getCachedConfiguration(false);
|
||||
|
||||
const { enabled, xpRewardBrackets } = weeklyXpConfig;
|
||||
if (!enabled || xpRewardBrackets.length < 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { lastWeekTimestamp } = ctx;
|
||||
const weeklyXpLeaderboard = new WeeklyXpLeaderboard(lastWeekTimestamp);
|
||||
|
||||
const maxRankToGet = Math.max(
|
||||
...xpRewardBrackets.map((bracket) => bracket.maxRank),
|
||||
);
|
||||
|
||||
const allResults = await weeklyXpLeaderboard.getResults(
|
||||
0,
|
||||
maxRankToGet,
|
||||
weeklyXpConfig,
|
||||
false,
|
||||
);
|
||||
|
||||
if (allResults === null || allResults.entries.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const mailEntries: {
|
||||
uid: string;
|
||||
mail: MonkeyMail[];
|
||||
}[] = [];
|
||||
|
||||
allResults?.entries.forEach((entry) => {
|
||||
// just in case, gonna ignore this error
|
||||
// oxlint-disable-next-line typescript/no-useless-default-assignment
|
||||
const { uid, name, rank = maxRankToGet, totalXp, timeTypedSeconds } = entry;
|
||||
|
||||
const xp = Math.round(totalXp);
|
||||
const placementString = getOrdinalNumberString(rank);
|
||||
|
||||
const xpReward = calculateXpReward(xpRewardBrackets, rank);
|
||||
|
||||
if (!isSafeNumber(xpReward)) return;
|
||||
|
||||
const rewardMail = buildMonkeyMail({
|
||||
subject: "Weekly XP Leaderboard placement",
|
||||
body: `Congratulations ${name} on placing ${placementString} with ${xp} xp! Last week, you typed for a total of ${formatSeconds(
|
||||
timeTypedSeconds,
|
||||
)}! Keep up the good work :)`,
|
||||
rewards: [
|
||||
{
|
||||
type: "xp",
|
||||
item: Math.round(xpReward),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
mailEntries.push({
|
||||
uid: uid,
|
||||
mail: [rewardMail],
|
||||
});
|
||||
});
|
||||
|
||||
await addToInboxBulk(mailEntries, inboxConfig);
|
||||
}
|
||||
|
||||
async function jobHandler(job: Job<LaterTask<LaterTaskType>>): Promise<void> {
|
||||
const { taskName, ctx } = job.data;
|
||||
|
||||
Logger.info(`Starting job: ${taskName}`);
|
||||
|
||||
const start = performance.now();
|
||||
|
||||
if (taskName === "daily-leaderboard-results") {
|
||||
const taskCtx = ctx as LaterTaskContexts["daily-leaderboard-results"];
|
||||
await handleDailyLeaderboardResults(taskCtx);
|
||||
} else if (taskName === "weekly-xp-leaderboard-results") {
|
||||
const taskCtx = ctx as LaterTaskContexts["weekly-xp-leaderboard-results"];
|
||||
await handleWeeklyXpLeaderboardResults(taskCtx);
|
||||
}
|
||||
|
||||
const elapsed = performance.now() - start;
|
||||
recordTimeToCompleteJob(LaterQueue.queueName, taskName, elapsed);
|
||||
Logger.success(`Job: ${taskName} - completed in ${elapsed}ms`);
|
||||
}
|
||||
|
||||
function calculateXpReward(
|
||||
xpRewardBrackets: RewardBracket[],
|
||||
rank: number,
|
||||
): number | undefined {
|
||||
const rewards = xpRewardBrackets
|
||||
.filter((bracket) => rank >= bracket.minRank && rank <= bracket.maxRank)
|
||||
.map((bracket) =>
|
||||
mapRange(
|
||||
rank,
|
||||
bracket.minRank,
|
||||
bracket.maxRank,
|
||||
bracket.maxReward,
|
||||
bracket.minReward,
|
||||
),
|
||||
);
|
||||
return rewards.length ? Math.max(...rewards) : undefined;
|
||||
}
|
||||
|
||||
export default (redisConnection?: IORedis.Redis): Worker => {
|
||||
const worker = new Worker(LaterQueue.queueName, jobHandler, {
|
||||
autorun: false,
|
||||
connection: redisConnection as ConnectionOptions,
|
||||
});
|
||||
worker.on("failed", (job, error) => {
|
||||
Logger.error(
|
||||
`Job: ${job.data.taskName} - failed with error "${error.message}"`,
|
||||
);
|
||||
});
|
||||
return worker;
|
||||
};
|
||||
|
||||
export const __testing = {
|
||||
calculateXpReward,
|
||||
};
|
||||
Reference in New Issue
Block a user