import type { NextFunction, Request, Response } from "express"; import { v4 as uuid } from "uuid"; import { API_PREFIX, MAX_WHITEBOARD_BODY_BYTES } from "../constants.js"; /** * T-167 — route-scoped body parser for `PUT /pages/:id/whiteboard`. * * A canvas save is a single JSON document (the whole canvas), and a large * board (the 2,000-object render/perf gate) can exceed the global 256 KB JSON * ceiling. This mirrors `csv-import-body.ts`: it buffers ONLY the canvas-save * route (everything else falls through to the 256 KB parser), 413s past * `MAX_WHITEBOARD_BODY_BYTES`, and sets `req.body` + `req._body = true` so the * downstream global parser skips. It must be registered BEFORE the * `app.useBodyParser("json", …)` calls (main.ts + integration helper). */ const CANVAS_SAVE_PATH = new RegExp( `^/${API_PREFIX.replace(/\//g, "\\/")}/pages/[^/]+/whiteboard$`, ); function envelope(req: Request, res: Response, code: string, status: number, message: string): void { const requestId = (req.headers["x-request-id"] as string | undefined) ?? uuid(); req.id = requestId; res.setHeader("x-request-id", requestId); res.status(status).json({ error: { code, message, requestId } }); } export function whiteboardBodyParser(req: Request, res: Response, next: NextFunction): void { if (req.method !== "PUT" || !CANVAS_SAVE_PATH.test(req.path)) { next(); return; } // Fast path: a declared Content-Length over the cap 413s without buffering. const declared = Number(req.headers["content-length"] ?? 0); if (declared > MAX_WHITEBOARD_BODY_BYTES) { req.resume(); envelope(req, res, "PAYLOAD_TOO_LARGE", 413, `Whiteboard canvas exceeds the ${MAX_WHITEBOARD_BODY_BYTES} byte limit.`); return; } const chunks: Buffer[] = []; let size = 0; let done = false; const finish = (err?: unknown): void => { if (done) return; done = true; if (err) { next(err); return; } const raw = Buffer.concat(chunks); req.rawBody = raw; try { req.body = raw.length > 0 ? JSON.parse(raw.toString("utf8")) : null; } catch { envelope(req, res, "VALIDATION_ERROR", 400, "Request body must be valid JSON."); return; } (req as unknown as Record)._body = true; // stop the downstream 256 KB parser next(); }; req.on("data", (chunk: Buffer) => { size += chunk.length; if (size > MAX_WHITEBOARD_BODY_BYTES) { done = true; req.resume(); envelope(req, res, "PAYLOAD_TOO_LARGE", 413, `Whiteboard canvas exceeds the ${MAX_WHITEBOARD_BODY_BYTES} byte limit.`); return; } chunks.push(chunk); }); req.on("end", () => finish()); req.on("error", (err) => finish(err)); }