Files
HiveGrid-Wireframes/03 Quellen und Referenzen/hivegrid-cloud/server/request-body.ts
T

113 lines
4.5 KiB
TypeScript

import type { IncomingMessage } from "node:http";
import { Readable } from "node:stream";
export const MAX_WEB_REQUEST_BODY_BYTES = 100 * 1024 * 1024;
export const DEFAULT_WEB_REQUEST_BODY_BYTES = 2 * 1024 * 1024;
const FILE_CHUNK_REQUEST_BODY_BYTES = 5 * 1024 * 1024;
const PROFILE_IMAGE_REQUEST_BODY_BYTES = 8 * 1024 * 1024;
const HR_DOCUMENT_REQUEST_BODY_BYTES = 26 * 1024 * 1024;
export const PUBLIC_FORM_REQUEST_BODY_BYTES = 30 * 1024 * 1024;
export const OAUTH_REGISTRATION_REQUEST_BODY_BYTES = 64 * 1024;
export const EMAIL_WEBHOOK_REQUEST_BODY_BYTES = 256 * 1024;
export const PUBLIC_AUTH_FORM_REQUEST_BODY_BYTES = 64 * 1024;
export const PUBLIC_BOOKING_REQUEST_BODY_BYTES = 64 * 1024;
export const PORTAL_UPLOAD_MULTIPART_OVERHEAD_BYTES = 1024 * 1024;
export const PORTAL_UPLOAD_REQUEST_BODY_BYTES = MAX_WEB_REQUEST_BODY_BYTES + PORTAL_UPLOAD_MULTIPART_OVERHEAD_BYTES;
/**
* Keep ordinary mutations small while granting explicit, narrow exceptions to
* routes that intentionally receive binary data. The 100 MiB value remains an
* absolute ceiling, not the default for every form or JSON action.
*/
export function resolveWebRequestBodyLimit(requestUrl: string | undefined, runtimeSurface: "core" | "portal" = "core"): number {
let pathname: string;
try {
pathname = new URL(requestUrl ?? "/", "http://internal").pathname;
} catch {
return DEFAULT_WEB_REQUEST_BODY_BYTES;
}
if (/^\/[^/]+\/files\/upload\/[^/]+\/chunk\/\d+\/?$/.test(pathname)) {
return FILE_CHUNK_REQUEST_BODY_BYTES;
}
if (
runtimeSurface === "portal" &&
/^(?:\/[^/]+)?\/projects\/[A-Za-z0-9_-]{1,128}\/upload\/?$/u.test(pathname)
) {
// The portal runtime receives the standard-host path with a tenant slug
// and the custom-host path without one. One bounded MiB of multipart
// overhead keeps a legitimate 100 MiB file below the transport ceiling;
// application validation still enforces the exact per-file limit.
return PORTAL_UPLOAD_REQUEST_BODY_BYTES;
}
if (/^\/api\/embed\/forms\/[^/]+\/submit\/?$/.test(pathname)) {
// Keep the transport ceiling aligned with the route's multipart limit.
// A larger server allowance lets chunked requests consume memory in
// request.formData() before the route-level Content-Length check applies.
return PUBLIC_FORM_REQUEST_BODY_BYTES;
}
if (/^\/oauth\/(?:register|token|revoke)\/?$/.test(pathname)) {
return OAUTH_REGISTRATION_REQUEST_BODY_BYTES;
}
if (/^\/webhooks\/email\/(?:google|microsoft)\/?$/.test(pathname)) {
return EMAIL_WEBHOOK_REQUEST_BODY_BYTES;
}
if (/^\/(?:register|signup)\/?$/.test(pathname)) {
return PUBLIC_AUTH_FORM_REQUEST_BODY_BYTES;
}
if (/^\/b\/[^/]+\/[^/]+(?:\/embed)?\/?$/.test(pathname)) {
return PUBLIC_BOOKING_REQUEST_BODY_BYTES;
}
if (/^\/[^/]+\/emails\/new\/?$/.test(pathname)) {
return MAX_WEB_REQUEST_BODY_BYTES;
}
if (/^\/[^/]+\/hr\/my-documents\/?$/.test(pathname)) {
return HR_DOCUMENT_REQUEST_BODY_BYTES;
}
if (/^(?:\/admin)?\/settings\/general\/?$/.test(pathname)) {
return MAX_WEB_REQUEST_BODY_BYTES;
}
if (/^(?:\/admin)?\/settings\/profile\/?$/.test(pathname) || /^\/[^/]+\/settings\/(?:profile|account)\/?$/.test(pathname)) {
return PROFILE_IMAGE_REQUEST_BODY_BYTES;
}
return DEFAULT_WEB_REQUEST_BODY_BYTES;
}
export class RequestBodyTooLargeError extends Error {
readonly code = "WEB_REQUEST_BODY_TOO_LARGE";
constructor(readonly limitBytes: number) {
super(`Web request body exceeds the ${limitBytes}-byte limit.`);
this.name = "RequestBodyTooLargeError";
}
}
export function parseRequestContentLength(value: string | undefined): number | null {
if (value === undefined || !/^\d+$/.test(value.trim())) {
return null;
}
const parsed = Number(value);
return Number.isSafeInteger(parsed) ? parsed : null;
}
export function createLimitedRequestBody(request: IncomingMessage, limitBytes = MAX_WEB_REQUEST_BODY_BYTES): Readable {
return Readable.from(limitRequestBody(request, limitBytes));
}
async function* limitRequestBody(request: IncomingMessage, limitBytes: number): AsyncGenerator<Buffer | string> {
let receivedBytes = 0;
for await (const chunk of request) {
receivedBytes += Buffer.isBuffer(chunk) ? chunk.byteLength : Buffer.byteLength(String(chunk));
if (receivedBytes > limitBytes) {
throw new RequestBodyTooLargeError(limitBytes);
}
yield chunk as Buffer | string;
}
}
export function isRequestBodyTooLargeError(error: unknown): error is RequestBodyTooLargeError {
return error instanceof RequestBodyTooLargeError;
}