import { resolve } from "node:path"; /** * Configuration, read once at boot. * * Every limit here exists because its absence is a denial of service — see docs/SECURITY.md, * "Layer 5 — bounded work". They are configurable so a deployment can tighten them, not so a * deployment can remove them: each has a floor enforced below. */ function num(key: string, fallback: number, min: number): number { const raw = process.env[key]; if (raw === undefined) return fallback; const value = Number(raw); if (!Number.isFinite(value)) { throw new Error(`${key} must be a number, got ${JSON.stringify(raw)}`); } if (value < min) { throw new Error(`${key} must be at least ${min}, got ${value}`); } return value; } export const PORT = num("AV_CLUB_PORT", 3020, 1); /** * Where assets live. Outside the repo by default, and deliberately not under any directory a deploy * would replace — losing every stored asset because the working tree was refreshed is the kind of * failure that is only noticed later, by which point the originals are gone. */ export const ASSETS_DIR = resolve(process.env.AV_CLUB_ASSETS_DIR ?? `${process.env.HOME}/.local/share/av-club/assets`); /** Multipart bodies are buffered whole. Without a cap, one request is an out-of-memory. */ export const MAX_UPLOAD_BYTES = num("AV_CLUB_MAX_UPLOAD_BYTES", 512 * 1024 * 1024, 1024); /** A crafted file can make an encoder run effectively forever. The process is killed at this point. */ export const JOB_TIMEOUT_MS = num("AV_CLUB_JOB_TIMEOUT_MS", 120_000, 1_000); /** * Decompression bombs: a 1 KB PNG can expand to tens of gigabytes. This is sharp's own * `limitInputPixels`, defaulted to its documented default (0x3FFF × 0x3FFF) rather than to `false`. */ export const MAX_INPUT_PIXELS = num("AV_CLUB_MAX_INPUT_PIXELS", 0x3fff * 0x3fff, 1); /** `resize(50000, 50000)` is an allocation request, not an image. */ export const MAX_OUTPUT_DIMENSION = num("AV_CLUB_MAX_OUTPUT_DIMENSION", 16_383, 1); /** ffmpeg will take every core it is given. Two callers should not be able to take the box down. */ export const MAX_CONCURRENT_JOBS = num("AV_CLUB_MAX_CONCURRENT_JOBS", 2, 1); /** * The bearer token every request must carry. * * A FUNCTION, not a constant evaluated at import. The limits above are plain data that anything may * read — the doc generator imports the sharp vocabulary, which imports MAX_OUTPUT_DIMENSION from * here — and a module-scope throw meant that generating documentation required a production secret. * * The check itself still happens at boot: server.ts calls this before it listens, so a missing token * is a refusal to start rather than a service that quietly accepts everyone. There is no * "unauthenticated by default" mode; this process runs decoders over untrusted bytes. */ export function requireToken(): string { const value = process.env.AV_CLUB_TOKEN; if (!value || value.length < 16) { throw new Error( "AV_CLUB_TOKEN must be set and at least 16 characters. Generate one with: openssl rand -hex 32", ); } return value; }