JOB_TIMEOUT_MS was 120 seconds, which is a budget rather than a ceiling: it fits a short clip and nothing else. Video encoding is measured against the length of the video — a 30-second hero at two renditions takes about 40 seconds — so a two-minute upload could not finish and failed with "Encoding took longer than 120s and was stopped". The file was fine, the encoder was working, and it was killed for being ordinary. Thirty minutes now. It is still a ceiling and still does the job it was written for: a crafted file cannot hold a worker for the life of the process. Nothing legitimate this service is asked for approaches half an hour, and a runaway is still stopped. The default has to fit the worst LEGITIMATE case, because guessing low costs a rejected upload and guessing high costs a runaway running a little longer before being killed anyway. A machine that only converts photographs can tighten it with AV_CLUB_JOB_TIMEOUT_MS. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
143 lines
6.7 KiB
TypeScript
143 lines
6.7 KiB
TypeScript
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;
|
||
}
|
||
|
||
/**
|
||
* The port to listen on. **0 means "any free one", and that is the default.**
|
||
*
|
||
* A plugin sidecar does not choose a number. It binds whatever is free and REPORTS it to the platform,
|
||
* which is how every other sidecar here works and why none of them can collide. av-club asserted 3020
|
||
* because it began life as a standalone service, and on 2026-08-28 that assumption cost a deploy: a
|
||
* stale copy of the pre-plugin code was still holding 3020 on fofos, so the plugin's sidecar died on
|
||
* EADDRINUSE in a restart loop.
|
||
*
|
||
* `AV_CLUB_PORT` still works and still pins the port. That is for running this outside Officer — the
|
||
* standalone mode fofos/platform was written against — where something on the other side has to know
|
||
* where to find it. Inside Officer nothing should set it.
|
||
*/
|
||
export const PORT = num("AV_CLUB_PORT", 0, 0);
|
||
|
||
/**
|
||
* 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. */
|
||
/**
|
||
* The only part of the filesystem this service will read from or write to when a caller names a path.
|
||
*
|
||
* Defaults to the owner's home, which is where every consumer's files already are — the file browser
|
||
* runs there, and so does the commander watching an upload folder. Narrow it with the env var on a
|
||
* machine where av-club should only see one project.
|
||
*
|
||
* `/` is refused outright rather than treated as "no limit": a root that permits everything is a
|
||
* configuration mistake that would look like it was working.
|
||
*/
|
||
export const WORK_ROOT = (() => {
|
||
const root = resolve(process.env.AV_CLUB_WORK_ROOT ?? process.env.HOME ?? "");
|
||
if (!root || root === "/") {
|
||
throw new Error("AV_CLUB_WORK_ROOT (or HOME) must be a real directory, and may not be /");
|
||
}
|
||
return root;
|
||
})();
|
||
|
||
export const MAX_UPLOAD_BYTES = num("AV_CLUB_MAX_UPLOAD_BYTES", 512 * 1024 * 1024, 1024);
|
||
|
||
/**
|
||
* How much RAM ImageMagick may hold while decoding, before it spills to a temporary file.
|
||
*
|
||
* Deliberately modest. This is the one operation here that can allocate against the size of the
|
||
* PICTURE rather than against anything this service chose, so it is the one that can take a small
|
||
* machine down — and taking a machine down is a worse outcome than being slow, every time, because
|
||
* the other things on it did not ask to be part of an image conversion.
|
||
*
|
||
* 256 MB comfortably holds the working set of a phone photograph. Past it nothing fails: ImageMagick
|
||
* memory-maps and then falls back to disk, so a genuinely enormous image is slower rather than fatal.
|
||
*
|
||
* Raise it on a machine with headroom. See sharp/decodable.ts for how it is applied.
|
||
*/
|
||
export const CONVERT_MEMORY_MB = num("AV_CLUB_CONVERT_MEMORY_MB", 256, 32);
|
||
|
||
/**
|
||
* How long one encode may run before it is killed.
|
||
*
|
||
* ── What it is for ──
|
||
*
|
||
* A crafted file can make an encoder run effectively forever. This is the ceiling that stops one
|
||
* request holding a worker for the life of the process. It is a guard against FOREVER, not a budget
|
||
* for how long work should take.
|
||
*
|
||
* ── Why it is not 120 seconds any more ──
|
||
*
|
||
* It was, and that is a budget rather than a ceiling — it fits a short clip and nothing else. Video
|
||
* encoding is measured against the length of the video: a 30-second hero at two renditions took about
|
||
* 40 seconds, so a two-minute upload could not finish and failed with "Encoding took longer than 120s
|
||
* and was stopped." The file was fine, the encoder was working, and it was killed for being ordinary.
|
||
*
|
||
* Thirty minutes is still a ceiling. Nothing legitimate this service is asked for approaches it, and a
|
||
* runaway is still stopped rather than left running — which is the whole property being defended.
|
||
*
|
||
* ── Tightening it ──
|
||
*
|
||
* `AV_CLUB_JOB_TIMEOUT_MS`, per machine. A host that only ever converts photographs can safely set this
|
||
* low; one that takes long video should leave it alone. The default has to fit the worst legitimate
|
||
* case, because the cost of guessing low is a rejected upload and the cost of guessing high is a
|
||
* runaway that runs a while longer before being killed anyway.
|
||
*/
|
||
export const JOB_TIMEOUT_MS = num("AV_CLUB_JOB_TIMEOUT_MS", 1_800_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;
|
||
}
|