underpost
Version:
Underpost Platform — end-to-end CI/CD and application-delivery toolchain CLI. Covers bare metal, Kubernetes, K3s, kubeadm, LXD, container/image orchestration, secrets, databases, cron jobs, monitoring, SSH, runners, PWA + Workbox delivery, and release orc
208 lines (194 loc) • 7.44 kB
JavaScript
/**
* Express middleware and controller/router helpers for engine APIs.
*
* @module src/server/middlewares.js
* @namespace Middlewares
*/
import { loggerFactory } from './logger.js';
import { moderatorGuard, adminGuard } from './auth.js';
const logger = loggerFactory(import.meta);
/**
* The public-read CORS policy: reflect the request origin (or allow any)
* and mark the resource embeddable cross-origin.
* @method setCrossOriginHeaders
* @param {import('express').Request} req
* @param {import('express').Response} res
* @returns {void}
* @memberof Middlewares
*/
const setCrossOriginHeaders = (req, res) => {
if (req && req.headers && req.headers.origin) res.set('Access-Control-Allow-Origin', req.headers.origin);
else res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin');
};
/**
* Express middleware form of {@link setCrossOriginHeaders}.
* @method crossOriginMiddleware
* @param {import('express').Request} req
* @param {import('express').Response} res
* @param {import('express').NextFunction} next
* @returns {void}
* @memberof Middlewares
*/
const crossOriginMiddleware = (req, res, next) => {
setCrossOriginHeaders(req, res);
next();
};
/**
* Shallow request copy with `page`/`limit` parsed to integers.
* `path` and `params` are copied explicitly because spreading an Express
* request drops prototype getters.
* @method withParsedPagination
* @param {import('express').Request} req
* @returns {import('express').Request} Request-like object with parsed pagination.
* @memberof Middlewares
*/
const withParsedPagination = (req) => {
const { page, limit } = req.query;
return {
...req,
path: req.path,
params: req.params,
query: { ...req.query, page: parseInt(page), limit: parseInt(limit) },
};
};
/**
* Sends the standard success response envelope.
* @method sendSuccess
* @param {import('express').Response} res
* @param {*} data - Response payload.
* @returns {import('express').Response} JSON response.
* @memberof Middlewares
*/
const sendSuccess = (res, data) => res.status(200).json({ status: 'success', data });
/**
* Sends the standard error response envelope.
* @method sendError
* @param {import('express').Response} res
* @param {Error} error - Error to expose.
* @param {number} [status=400] - HTTP status code.
* @returns {import('express').Response} JSON response.
* @memberof Middlewares
*/
const sendError = (res, error, status = 400) => res.status(status).json({ status: 'error', message: error.message });
/**
* Binary response with cross-origin and content headers.
* @method sendBlob
* @param {import('express').Request} req
* @param {import('express').Response} res
* @param {{ buffer: Buffer, mimetype: string, filename: string, disposition?: 'inline'|'attachment' }} blob
* @returns {import('express').Response} Completed binary response.
* @memberof Middlewares
*/
const sendBlob = (req, res, { buffer, mimetype, filename, disposition = 'inline' }) => {
setCrossOriginHeaders(req, res);
res.setHeader('Content-Type', mimetype);
res.setHeader('Content-Length', buffer.length);
res.setHeader('Content-Disposition', `${disposition}; filename="${filename}"`);
return res.status(200).end(buffer);
};
/**
* Wraps a controller body with error logging and the error response envelope.
* @method controllerHandler
* @param {(req, res, options) => Promise<any>} fn
* @param {{ errorStatus?: number }} [config]
* @returns {Function} Async Express-compatible controller handler.
* @memberof Middlewares
*/
const controllerHandler =
(fn, { errorStatus = 400 } = {}) =>
async (req, res, options) => {
try {
return await fn(req, res, options);
} catch (error) {
logger.error(error, error.stack);
return sendError(res, error, errorStatus);
}
};
/**
* Builds a controller method that delegates to a service method and wraps the
* result in the success envelope.
* @method serviceHandler
* @param {(req, res, options) => Promise<any>} serviceFn
* @param {{ errorStatus?: number, crossOrigin?: boolean, pagination?: boolean }} [config]
* @returns {Function} Async Express-compatible controller handler.
* @memberof Middlewares
*/
const serviceHandler = (serviceFn, { errorStatus = 400, crossOrigin = false, pagination = false } = {}) =>
controllerHandler(
async (req, res, options) => {
if (crossOrigin) setCrossOriginHeaders(req, res);
const result = await serviceFn(pagination ? withParsedPagination(req) : req, res, options);
return sendSuccess(res, result);
},
{ errorStatus },
);
/**
* Builds a standard CRUD controller class (static post/get/put/delete) from a
* service exposing the same methods. `get` parses pagination.
* @method buildCrudController
* @param {{ post, get, put, delete }} service
* @param {Object<string, Function>} [extend] - Extra or overriding static handlers.
* @returns {Function} CRUD controller class.
* @memberof Middlewares
*/
const buildCrudController = (service, extend = {}) => {
/**
* Generated controller namespace containing static CRUD handlers.
* @class CrudController
* @memberof Middlewares
*/
class CrudController {
/** @static @memberof Middlewares */
static post = serviceHandler(service.post);
/** @static @memberof Middlewares */
static get = serviceHandler(service.get, { pagination: true });
/** @static @memberof Middlewares */
static put = serviceHandler(service.put);
/** @static @memberof Middlewares */
static delete = serviceHandler(service.delete);
}
Object.assign(CrudController, extend);
return CrudController;
};
/**
* Registers the standard CRUD routes with the standard guard policy:
* public reads, moderator-guarded writes, admin-guarded collection delete.
* Custom routes must be registered before calling this (generic `/:id` routes
* capture everything).
* @method registerCrudRoutes
* @param {import('express').Router} router
* @param {{ post, get, put, delete }} Controller
* @param {import('../../api/types.js').RouterOptions} options
* @param {{ readGuards?: Function[], writeGuards?: Function[], deleteAllGuards?: Function[] }} [config]
* Pass empty arrays for unguarded endpoints (e.g. player-written progress)
* or explicit guard chains (e.g. admin-only reads).
* @returns {import('express').Router}
* @memberof Middlewares
*/
const registerCrudRoutes = (router, Controller, options, { readGuards = [], writeGuards, deleteAllGuards } = {}) => {
const write = writeGuards ?? [options.authMiddleware, moderatorGuard];
const deleteAll = deleteAllGuards ?? [options.authMiddleware, adminGuard];
const handle = (method) => async (req, res) => await Controller[method](req, res, options);
router.post(`/:id`, ...write, handle('post'));
router.post(`/`, ...write, handle('post'));
router.get(`/:id`, ...readGuards, handle('get'));
router.get(`/`, ...readGuards, handle('get'));
router.put(`/:id`, ...write, handle('put'));
router.put(`/`, ...write, handle('put'));
router.delete(`/:id`, ...write, handle('delete'));
router.delete(`/`, ...deleteAll, handle('delete'));
return router;
};
export {
setCrossOriginHeaders,
crossOriginMiddleware,
withParsedPagination,
sendSuccess,
sendError,
sendBlob,
controllerHandler,
serviceHandler,
buildCrudController,
registerCrudRoutes,
};