Files
av-club/sidecar/config.ts
T
pastilhasandClaude Opus 5 ec6e09014f a photograph may be slow, but it may not take the machine down
ImageMagick sizes its pixel cache against the machine and will hold an entire
decoded image in RAM. On a host with room that is right. On a small one it is
how resizing a photograph kills whatever else is running — and "whatever else"
includes the website this service exists to serve, which is not an acceptable
way for an image to fail.

So the decode runs under `-limit memory` / `-limit map` / `-limit disk`. Past
the caps ImageMagick spills to a temporary file and carries on, which turns an
out-of-memory kill of whatever the kernel picks into a conversion that is
slower. Nobody minds a slow photograph; everybody minds a site that went down
while one was being resized.

Measured on fofos. A 12 MP HEIC peaks at 145 MB, so the 256 MB default does not
bind and the output is byte-identical — it is a ceiling, not a tax. Forced to
bind, on a 36 MP image with a 32 MiB cap, peak RSS is 12 MB and it still exits 0
with the file written.

`-limit thread 1` for the same reason: one decode is one image, and each thread
carries its own share of the cache the caps exist to bound.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 11:32:25 +01:00

118 lines
5.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
/** 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;
}