diff --git a/backend/src/middlewares/error.ts b/backend/src/middlewares/error.ts index 454105499..3cc572d04 100644 --- a/backend/src/middlewares/error.ts +++ b/backend/src/middlewares/error.ts @@ -46,9 +46,11 @@ async function errorHandlingMiddleware( uid: monkeyError.uid ?? req.ctx?.decodedToken?.uid, }; let message = "Unknown error"; + let isDbError = false; if (/ECONNREFUSED.*27017/i.test(error.message)) { message = "Could not connect to the database. It may be down."; + isDbError = true; } else if (error instanceof URIError || error instanceof SyntaxError) { status = 400; message = "Unprocessable request"; @@ -73,27 +75,30 @@ async function errorHandlingMiddleware( errorId: string; }; - try { - await addLog( - "system_error", - `${status} ${errorId} ${error.message} ${error.stack}`, - uid - ); - await db.collection("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); + if (!isDbError) { + try { + await addLog( + "system_error", + `${status} ${errorId} ${error.message} ${error.stack}`, + uid + ); + + await db.collection("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}`); diff --git a/backend/src/server.ts b/backend/src/server.ts index 7570078a0..0324fbda7 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -18,11 +18,24 @@ import { init as initFirebaseAdmin } from "./init/firebase-admin"; import { createIndicies as leaderboardDbSetup } from "./dal/leaderboards"; import { createIndicies as blocklistDbSetup } from "./dal/blocklist"; import { getErrorMessage } from "./utils/error"; +import { exit } from "process"; async function bootServer(port: number): Promise { try { Logger.info(`Starting server version ${version}`); Logger.info(`Starting server in ${process.env["MODE"]} mode`); + + process.on("unhandledRejection", (err) => { + const isDbError = + err instanceof Error && /ECONNREFUSED.*27017/i.test(err.message); + if (isDbError) { + Logger.error("Failed to connect to database, ignore error"); + } else { + Logger.error("Unhandled rejection: " + getErrorMessage(err)); + exit(-1); + } + }); + Logger.info(`Connecting to database ${process.env["DB_NAME"]}...`); await db.connect(); Logger.success("Connected to database"); diff --git a/frontend/src/ts/elements/input-validation.ts b/frontend/src/ts/elements/input-validation.ts index a1971bd62..0ad5554b2 100644 --- a/frontend/src/ts/elements/input-validation.ts +++ b/frontend/src/ts/elements/input-validation.ts @@ -14,6 +14,8 @@ export type ValidationResult = { errorMessage?: string; }; +export type IsValidResponse = true | string | { warning: string }; + export type Validation = { /** * Zod schema to validate the input value against. @@ -28,7 +30,7 @@ export type Validation = { * @param thisPopup the current modal * @returns true if the `value` is valid, an errorMessage as string if it is invalid. */ - isValid?: (value: T) => Promise; + isValid?: (value: T) => Promise; /** custom debounce delay for `isValid` call. defaults to 100 */ debounceDelay?: number; diff --git a/frontend/src/ts/modals/edit-tag.ts b/frontend/src/ts/modals/edit-tag.ts index 701169818..eacd882fd 100644 --- a/frontend/src/ts/modals/edit-tag.ts +++ b/frontend/src/ts/modals/edit-tag.ts @@ -1,12 +1,13 @@ import Ape from "../ape"; import * as DB from "../db"; +import { IsValidResponse } from "../elements/input-validation"; import * as Settings from "../pages/settings"; import AnimatedModal, { ShowOptions } from "../utils/animated-modal"; import { SimpleModal, TextInput } from "../utils/simple-modal"; import { TagNameSchema } from "@monkeytype/schemas/users"; const cleanTagName = (tagName: string): string => tagName.replaceAll(" ", "_"); -const tagNameValidation = async (tagName: string): Promise => { +const tagNameValidation = async (tagName: string): Promise => { const validationResult = TagNameSchema.safeParse(cleanTagName(tagName)); if (validationResult.success) return true; return validationResult.error.errors.map((err) => err.message).join(", "); diff --git a/frontend/src/ts/modals/google-sign-up.ts b/frontend/src/ts/modals/google-sign-up.ts index ff9d9481c..cb109ed14 100644 --- a/frontend/src/ts/modals/google-sign-up.ts +++ b/frontend/src/ts/modals/google-sign-up.ts @@ -16,6 +16,7 @@ import AnimatedModal from "../utils/animated-modal"; import { resetIgnoreAuthCallback } from "../firebase"; import { validateWithIndicator } from "../elements/input-validation"; import { UserNameSchema } from "@monkeytype/schemas/users"; +import { apeValidation } from "../utils/remote-validation"; let signedInUser: UserCredential | undefined = undefined; @@ -154,15 +155,10 @@ function disableInput(): void { validateWithIndicator(nameInputEl, { schema: UserNameSchema, - isValid: async (name: string) => { - const checkNameResponse = ( - await Ape.users.getNameAvailability({ - params: { name: name }, - }) - ).status; - - return checkNameResponse === 200 ? true : "Name not available"; - }, + isValid: apeValidation( + async (name) => Ape.users.getNameAvailability({ params: { name } }), + { errorMessage: "Name not available" } + ), debounceDelay: 1000, callback: (result) => { if (result.status === "success") { diff --git a/frontend/src/ts/modals/simple-modals.ts b/frontend/src/ts/modals/simple-modals.ts index f73c4fa77..0ae45bdba 100644 --- a/frontend/src/ts/modals/simple-modals.ts +++ b/frontend/src/ts/modals/simple-modals.ts @@ -45,6 +45,7 @@ import { import { goToPage } from "../pages/leaderboards"; import FileStorage from "../utils/file-storage"; import { z } from "zod"; +import { apeValidation } from "../utils/remote-validation"; type PopupKey = | "updateEmail" @@ -479,15 +480,10 @@ list.updateName = new SimpleModal({ initVal: "", validation: { schema: UserNameSchema, - isValid: async (newName: string) => { - const checkNameResponse = ( - await Ape.users.getNameAvailability({ - params: { name: newName }, - }) - ).status; - - return checkNameResponse === 200 ? true : "Name not available"; - }, + isValid: apeValidation( + async (name) => Ape.users.getNameAvailability({ params: { name } }), + { errorMessage: "Name not available" } + ), debounceDelay: 1000, }, }, diff --git a/frontend/src/ts/pages/login.ts b/frontend/src/ts/pages/login.ts index 715c9c4d4..576e29879 100644 --- a/frontend/src/ts/pages/login.ts +++ b/frontend/src/ts/pages/login.ts @@ -10,6 +10,7 @@ import { import { validateWithIndicator } from "../elements/input-validation"; import { isDevEnvironment } from "../utils/misc"; import { z } from "zod"; +import { apeValidation } from "../utils/remote-validation"; let registerForm: { name?: string; @@ -73,15 +74,10 @@ const nameInputEl = document.querySelector( ) as HTMLInputElement; validateWithIndicator(nameInputEl, { schema: UserNameSchema, - isValid: async (name: string) => { - const checkNameResponse = ( - await Ape.users.getNameAvailability({ - params: { name: name }, - }) - ).status; - - return checkNameResponse === 200 ? true : "Name not available"; - }, + isValid: apeValidation( + async (name) => Ape.users.getNameAvailability({ params: { name } }), + { errorMessage: "Name not available" } + ), debounceDelay: 1000, callback: (result) => { registerForm.name = diff --git a/frontend/src/ts/utils/remote-validation.ts b/frontend/src/ts/utils/remote-validation.ts new file mode 100644 index 000000000..1453cf3b6 --- /dev/null +++ b/frontend/src/ts/utils/remote-validation.ts @@ -0,0 +1,22 @@ +import { IsValidResponse } from "../elements/input-validation"; + +export function apeValidation( + call: ( + val: string + ) => Promise<{ status: number; body: { data?: T; message: string } }>, + options?: { + check?: (data: T) => IsValidResponse; + errorMessage?: string; + } +): (val: string) => Promise { + return async (val) => { + const result = await call(val); + if (result.status === 200) { + return options?.check?.(result.body.data as T) ?? true; + } else if (result.status >= 500) { + return result.body.message; + } else { + return options?.errorMessage ?? result.body.message; + } + }; +} diff --git a/frontend/src/ts/utils/simple-modal.ts b/frontend/src/ts/utils/simple-modal.ts index 2bc890d9d..9fb6864db 100644 --- a/frontend/src/ts/utils/simple-modal.ts +++ b/frontend/src/ts/utils/simple-modal.ts @@ -5,6 +5,7 @@ import * as Loader from "../elements/loader"; import * as Notifications from "../elements/notifications"; import * as ConnectionState from "../states/connection"; import { + IsValidResponse, Validation, ValidationOptions, ValidationResult, @@ -33,7 +34,10 @@ type CommonInput = { * @param thisPopup the current modal * @returns true if the `value` is valid, an errorMessage as string if it is invalid. */ - isValid?: (value: string, thisPopup: SimpleModal) => Promise; + isValid?: ( + value: string, + thisPopup: SimpleModal + ) => Promise; }; };