- Implemented reasoning effort setting in SESSION panel of Chat Sessio View - Removed all ability to "sign up" for an account
356 lines
9.5 KiB
TypeScript
356 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 { DtpComponent } from "./component.js";
|
|
import { DtpPaginationParameters } from "./pagination-parameters.js";
|
|
import { DtpLog } from "./log.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 DtpComponent {
|
|
log: DtpLog;
|
|
router: Router;
|
|
|
|
abstract get name(): string;
|
|
abstract get slug(): string;
|
|
abstract get route(): string;
|
|
|
|
constructor() {
|
|
this.log = new DtpLog(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();
|
|
});
|
|
});
|
|
}
|
|
}
|