gadget/gadget-code/src/lib/controller.ts
Rob Colbert 42a47dbcb7 refactor: unify logging into @gadget/api as GadgetLog
Move the 6 duplicated logging modules (component, log, log-transport,
log-transport-console, log-transport-file, log-file) from both
gadget-code (Dtp* prefix) and gadget-drone (Gadget* prefix) into
@shad/api, using gadget-drone's GadgetLog as the canonical version.

GadgetLog now uses static configuration (consoleEnabled, defaultFile)
set by each consumer's env.ts at module scope, removing the env
dependency from the shared library. The addDefaultTransport/
removeDefaultTransport/getDefaultTransports static methods are
preserved for future real-time log transport injection.
2026-05-08 16:03:28 -04:00

355 lines
9.5 KiB
TypeScript

// src/lib/controller.js
// Copyright (C) 2026 Robert Colbert <rob.colbert@openplatform.us>
// 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<void>;
/**
* 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<void> => {
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<void> => {
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<DtpController> {
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<ICsrfToken> {
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<void> {
return new Promise((resolve, reject) => {
req.session.save((err) => {
if (err) {
return reject(err);
}
resolve();
});
});
}
}