// src/lib/controller.js // Copyright (C) 2026 Robert Colbert // All Rights Reserved import env from "../config/env.js"; import path from "node:path"; import { v4 as uuidv4 } from "uuid"; import { rateLimit } from "express-rate-limit"; import { Router, Request, Response, NextFunction, RequestHandler, } from "express"; import multer from "multer"; import dayjs from "dayjs"; export interface CsrfTokenOptions { name: string; expiresMinutes: number; allowReuse: boolean; } import { ApiClientStatus } from "../models/api-client.js"; import { CsrfToken, ICsrfToken } from "../models/csrf-token.js"; import { GadgetId, IUser } from "@gadget/api"; import { GadgetComponent, GadgetLog } from "@gadget/api"; import { DtpPaginationParameters } from "./pagination-parameters.js"; import ApiClientService from "../services/api-client.js"; import CryptoService from "../services/crypto.js"; /** * The base class for all Web application controllers. A controller binds to an * HTTP application REST route, and processes requests received on that route. * * This is usually accomplished by calling service methods congigured using * request parameters, headers, and body content, then rendering HTML page or * JSON object responses. */ export abstract class DtpController implements GadgetComponent { log: GadgetLog; router: Router; abstract get name(): string; abstract get slug(): string; abstract get route(): string; constructor() { this.log = new GadgetLog(this); this.router = Router(); this.router.use(this.middleware.bind(this)); } abstract start(): Promise; /** * Middleware common to all controllers that populates the view model with * some commonly expected values. * @param _req Request The request being processed. * @param res Response The response being generated. * @param next NextFunction The next function to call when done. */ middleware(req: Request, res: Response, next: NextFunction) { res.locals.request = req; res.locals.currentView = this.slug; next(); } hmacMiddleware() { return async (req: Request, res: Response, next: NextFunction) => { if (!req.rawBody) { this.log.error("No raw body found"); res.status(401).json({ success: false, message: "No raw body found", }); return; } const apiClientId = req.headers["x-dtp-client-id"]; if (!apiClientId) { this.log.error("API Client ID is required"); res.status(401).json({ success: false, message: "API Client ID is required", }); return; } const apiClient = await ApiClientService.getById(apiClientId as GadgetId); if (!apiClient) { this.log.error("API client not found", { _id: apiClientId }); res.status(404).json({ success: false, message: "API Client not found", }); return; } if (apiClient.status !== ApiClientStatus.Active) { this.log.error("inactive API client", { _id: apiClientId, status: apiClient.status, }); res.status(403).json({ success: false, message: "API client is not active", }); return; } const hmacHeader = req.headers["x-dtp-hmac"]; if (!hmacHeader) { this.log.error("No HMAC header found"); res.status(401).json({ success: false, message: "No HMAC header found", }); return; } const hmacBody = CryptoService.createHmac(apiClient.secret, req.rawBody); if (hmacHeader !== hmacBody) { this.log.error("HMAC header does not match body", { hmacHeader, hmacBody, }); res.status(401).json({ success: false, message: "HMAC header does not match body", }); return; } this.log.debug("HMAC header matches body"); next(); }; } requireUser(): RequestHandler { return async ( req: Request, res: Response, next: NextFunction, ): Promise => { if (!req.user) { res .status(403) .json({ success: false, message: "Authentication required" }); return; } next(); }; } requireAdmin(): RequestHandler { return async ( req: Request, res: Response, next: NextFunction, ): Promise => { const user: IUser | null | undefined = req.user; if (!user || !user.flags.isAdmin) { res .status(403) .json({ success: false, message: "Admin access required" }); return; } next(); }; } async loadChild(filename: string): Promise { const pathObj = path.parse(filename); this.log.info("loading child controller", { script: pathObj.name, path: filename, }); const ControllerClass = (await import(filename)).default; if (!ControllerClass) { this.log.error( "failed to receive a default export class from child controller", { script: pathObj.name, }, ); throw new Error("Child controller failed to provide a default export"); } const controller: DtpController = new ControllerClass(ControllerClass); this.log.info("starting child controller", { name: ControllerClass.name, }); await controller.start(); const childRoute = this.route + controller.route; this.log.info("mounting child controller", { name: ControllerClass.name, childRoute, }); this.router.use(controller.route, controller.router); return controller; } /** * Retrieves set pagination parameters from the request. * @param req Request The request being processed. * @param maxPerPage number The maximum number of records to display per page. * @param pageParamName string The name of the page index parameter. * @param cppParamName string The name of the count-per-page parameter. * @returns A new DtpPaginationParameters instance containing pagination * parameter values. */ getPaginationParameters( req: Request, maxPerPage: number, pageParamName: string = "p", cppParamName: string = "cpp", ): DtpPaginationParameters { const pageParam: string = req.query[pageParamName] ? (req.query[pageParamName] as string) : "1"; const cppParam: string = req.query[cppParamName] ? (req.query[cppParamName] as string) : maxPerPage.toString(); const pagination: DtpPaginationParameters = { p: parseInt(pageParam, 10), skip: 0, cpp: parseInt(cppParam, 10), }; if (pagination.p < 1) { pagination.p = 1; } if (pagination.cpp > maxPerPage) { pagination.cpp = maxPerPage; } pagination.skip = (pagination.p - 1) * pagination.cpp; return pagination; } createLimiter( seconds: number, limit: number, message: string, keyGenerator?: (req: Request, res: Response) => string, ): RequestHandler { return rateLimit({ windowMs: seconds * 1000, limit, message: message || "Too many requests. Please try again later.", standardHeaders: "draft-8", legacyHeaders: false, statusCode: 429, keyGenerator, }); } /** * Creates a `multipart/form-encoded` HTTP POST processor using the * [multer](https://www.npmjs.com/package/multer) estension. * @param slug string The path slug into which files will be stored. * @param options multer.Options Options for the form processor. * @returns An ExpressJS middleware that enables a route to receive files. */ createMulter(slug: string, options: multer.Options): multer.Multer { if (!!slug && typeof slug === "object") { options = slug; slug = this.slug; } else { slug = slug || this.slug; } options = Object.assign( { dest: path.join(env.https.uploadPath, slug), }, options || {}, ); return multer(options); } /** * Generates a CSRF token used in forms to help prevent XSS attacks. * @param req Request The request for which a CSRF token will be generated. * @param options CsrfTokenOptions The options to be used when creating the * token. * @returns The new CsrfToken for use. */ async createCsrfToken( req: Request, options: CsrfTokenOptions, ): Promise { const NOW = new Date(); options = Object.assign( { expiresMinutes: 30, }, options, ); if (options.expiresMinutes > 120) { const e = new Error("CSRF tokens have a max lifespan of 120 minutes"); e.statusCode = 400; throw e; } const token = new CsrfToken(); token.created = NOW; token.expires = dayjs(NOW).add(options.expiresMinutes, "minute").toDate(); if (req.user) { token.user = req.user._id; } if (req.ip) { token.ip = req.ip; } token.token = uuidv4(); await token.save(); const tokenObj = token.toObject(); tokenObj.name = `csrf-token-${options.name}`; return tokenObj; } /** * Force-save the current user session and ensure it's written before * proceeding. * @param req Express.Request The request being processed. * @returns A promise that resolves when the session is saved. */ async saveSession(req: Request): Promise { return new Promise((resolve, reject) => { req.session.save((err) => { if (err) { return reject(err); } resolve(); }); }); } }