diff --git a/dev.sh b/dev.sh index 64d89c1..000ccf3 100755 --- a/dev.sh +++ b/dev.sh @@ -50,6 +50,7 @@ if [ "$RECREATE" = true ]; then --network detleph_server_default \ -e DATABASE_PASSWORD=server \ -e DATABASE_URL="postgresql://server:server@postgres:5432/management?schema=public" \ + -e NODE_ENV="development" \ --entrypoint "/app/scripts/docker-entrypoint.dev.sh" \ node fi diff --git a/package.json b/package.json index dc7fbad..a97963a 100644 --- a/package.json +++ b/package.json @@ -30,11 +30,13 @@ "dotenv": "^10.0.0", "express": "^4.17.1", "handlebars": "^4.7.7", + "express-async-errors": "^3.1.1", "jsonwebtoken": "^8.5.1", "mjml": "^4.11.0", "nanoid": "^3.3.3", "nodemailer": "^6.7.0", - "redis": "^3.1.2" + "redis": "^3.1.2", + "winston": "^3.7.2" }, "devDependencies": { "@types/chai": "^4.2.22", diff --git a/src/Controllers/event.controller.ts b/src/Controllers/event.controller.ts index ba88f68..0038a2b 100644 --- a/src/Controllers/event.controller.ts +++ b/src/Controllers/event.controller.ts @@ -19,13 +19,13 @@ export const getAllEvents = async (req: Request, res: Response) => { id: false, }, }); - if (events.length > 0) - res.status(200).json({ - type: "success", - payload: { - events, - }, - }); + + res.status(200).json({ + type: "success", + payload: { + events, + }, + }); }; export const getEvent = async (req: Request, res: Response) => { diff --git a/src/Middleware/debug/logger.ts b/src/Middleware/debug/logger.ts new file mode 100644 index 0000000..64310c1 --- /dev/null +++ b/src/Middleware/debug/logger.ts @@ -0,0 +1,11 @@ +import { NextFunction, Request, Response } from "express"; +import logger from "../error/logger"; + +export default function debugLogger(req: Request, res: Response, next: NextFunction) { + // Only log when in development mode + if (process.env.NODE_ENV === "development") { + logger.debug(`Request to: ${req.url}`); + } + + next(); +} diff --git a/src/Middleware/error/ForwardableError.ts b/src/Middleware/error/ForwardableError.ts new file mode 100644 index 0000000..04bc5e1 --- /dev/null +++ b/src/Middleware/error/ForwardableError.ts @@ -0,0 +1,16 @@ +export default class ForwardableError extends Error { + // If in different context + private readonly __id = "CUSTOM_ERROR"; + + public readonly status: number; + + constructor(status: number, message: string) { + super(message); + + this.status = status; + } + + static isForwardableError(error: any): error is ForwardableError { + return error.__id === "CUSTOM_ERROR"; + } +} diff --git a/src/Middleware/error/handler.ts b/src/Middleware/error/handler.ts new file mode 100644 index 0000000..fbfe846 --- /dev/null +++ b/src/Middleware/error/handler.ts @@ -0,0 +1,82 @@ +import { PrismaClientUnknownRequestError } from "@prisma/client/runtime"; +import { NextFunction, Request, Response } from "express"; +import ForwardableError from "./ForwardableError"; +import logger from "./logger"; + +const env = process.env.NODE_ENV || "production"; + +type ErrorHandler = (err: any, req: Request, res: Response) => boolean; + +const customHandlers: ErrorHandler[] = []; + +export function addCustomHandler(handler: ErrorHandler) { + customHandlers.push(handler); +} + +export function removeCustomHandler(handler: ErrorHandler): Boolean { + const index = customHandlers.findIndex((h) => h === handler); + + if (index < 0) { + return false; + } + + customHandlers.splice(index); + + return true; +} + +export default function defaultErrorHandler(err: any, req: Request, res: Response, next: NextFunction) { + for (const handler of customHandlers) { + // Run custom handler + if (handler(err, req, res)) { + return; + } + } + + if (ForwardableError.isForwardableError(err)) { + return res.status(err.status).json({ + type: "error", + payload: { + message: err.message, + ...(env === "development" + ? { + stack: err.stack, + } + : {}), + }, + }); + } + + if (err instanceof PrismaClientUnknownRequestError) { + logger.warning(err); + + return res.status(404).json({ + type: "error", + payload: { + message: "An unknown error occured. This could be due to malformed IDs", + ...(env === "development" + ? { + prisma: err.message, + stack: err.stack, + } + : {}), + }, + }); + } + + logger.error("--- Unhandled error ---"); + logger.error(err); + + return res.status(err.status).json({ + type: "error", + payload: { + message: err.message, + ...(env === "development" + ? { + notice: "This error was not caught by any handler, please add handling!", + stack: err.stack, + } + : {}), + }, + }); +} diff --git a/src/Middleware/error/logger.ts b/src/Middleware/error/logger.ts new file mode 100644 index 0000000..9674d1f --- /dev/null +++ b/src/Middleware/error/logger.ts @@ -0,0 +1,41 @@ +import winston, { createLogger } from "winston"; + +const { + format: { printf, colorize, combine, timestamp, json, errors, prettyPrint }, +} = winston; + +const defaultJsonFormat = combine(errors({ stack: true }), timestamp(), json({ space: 2 })); + +const customCLIFormat = printf(({ level, message, label, timestamp, stack }) => { + let output = `${level}${stack ? `(1/2:message)` : ""} at ${timestamp}${label ? ` (#${label})` : ""}: ${message}${ + stack ? "\n" : "" + }`; + + if (stack) { + output += `\n${level}(2/2:stack)${label ? ` (#${label})` : ""}: ${stack}\n\n`; + } + + return output; +}); + +export default createLogger({ + levels: winston.config.syslog.levels, + format: combine(errors({ stack: true }), timestamp()), + transports: [ + new winston.transports.Console({ + level: "debug", + format: combine(timestamp(), colorize(), customCLIFormat), + }), + new winston.transports.File({ + filename: "/app/error.log", + level: "error", + format: defaultJsonFormat, + }), + new winston.transports.File({ + level: "debug", + filename: "/app/debug.log", + format: defaultJsonFormat, + silent: !(process.env.NODE_ENV === "development"), // Silent when not in development + }), + ], +}); diff --git a/src/app.ts b/src/app.ts index 7cf06b8..d1864ed 100644 --- a/src/app.ts +++ b/src/app.ts @@ -11,11 +11,21 @@ import argon2 from "argon2"; import adminRouter from "./Routes/admin.routes"; import organisationRouter from "./Routes/organisation.routes"; import groupRouter from "./Routes/group.routes"; +import defaultErrorHandler from "./Middleware/error/handler"; +import logger from "./Middleware/error/logger"; +import debugLogger from "./Middleware/debug/logger"; + +// Set up async error handling +require("express-async-errors"); require("dotenv").config(); // Load dotenv config const app = express(); +if (process.env.NODE_ENV === "development") { + logger.info("Using development mode"); +} + async function main() { // Dev await prisma.admin.upsert({ @@ -34,16 +44,7 @@ async function main() { app.use(express.urlencoded({ extended: true })); app.use(express.json()); - app.use((err: any, req: Request, res: Response, next: NextFunction) => { - if (err) { - res.status(400).send({ - type: "error", - payload: "The body of your request did not contain valid data", - }); - } else { - next(); - } - }); + app.use(debugLogger); // Admin authentication endpoints app.use("/api/authentication", adminAuthRouter); @@ -56,13 +57,24 @@ async function main() { app.use("/api/organisations", organisationRouter); app.use("/api/groups", groupRouter); + + // Error handling + app.use(defaultErrorHandler); + app.listen(process.env.PORT, () => { - console.log(`Listening on Port: ${process.env.PORT}`); + logger.info(`Listening on port ${process.env.PORT}`); + }); + + logger.info("Server started"); + + process.on("exit", () => { + logger.info("Server stopping..."); }); } main() .catch((e) => { + logger.crit(e); throw e; }) .finally(async () => {