adding monkeytype
Some checks failed
Mark Stale PRs / stale (push) Has been cancelled

This commit is contained in:
Benjamin Falch
2026-04-23 13:53:44 +02:00
parent e214a2fd35
commit 2bc741fb78
1930 changed files with 7590652 additions and 0 deletions

View 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;
}

View 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);
}

View 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);
}

View 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);
}

View 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);
}

View 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);
}

View 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();
}

View 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;
}

View 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);
}

View 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()) ?? []);
}

View 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);
}

View 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);
}

View 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,
};
}

File diff suppressed because it is too large Load Diff

View 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);
}

View 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),
},
});

View 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),
},
});

View 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),
},
});

View 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),
},
});

View 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),
},
});

View 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),
},
});

View 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",
);
}

View 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);
}
}

View 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),
},
});

View 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),
},
});

View 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),
},
});

View 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),
},
});

View 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),
},
});

View 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),
},
});

View 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;

View 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),
},
});

View 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),
},
});

View 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
View 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
View 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();

View 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",
];

View 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",
},
},
},
},
};

View 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;

View File

View 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
View 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 });
}

View 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
View 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,
};

View 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,
};

View 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
View 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 });
}

View 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
View 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
View 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
View 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;
}

View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

View 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
View 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();
}

View 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);
}

View 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
View 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;
}

View 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
View 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,
];

View 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);

View 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);

View 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);

View 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,
);
}
}

View 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();
}

View 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];
}

View 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;

View 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;

View 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,
};
}

View 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,
});

View 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();
}

View 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,
},
},
});

View 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,
},
},
});

View 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];

View 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,
},
});

View 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
View 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);

View 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
View 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;
}
}
}

View 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;
}
}

View 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,
};

View 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;
}

View 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
View 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)}`;
};
}

View 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
View 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
);
}

View 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 ?? [],
};
}

View 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
View 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;
}

View 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
View 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;
}

View 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;
};
}

View 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
View 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();

View 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;
};

View File

@@ -0,0 +1,4 @@
import LaterWorker from "./later-worker";
import EmailWorker from "./email-worker";
export default [LaterWorker, EmailWorker];

View 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,
};