Files
pastilhasandClaude Opus 5 07f050cb97 output takes a format NAME, and says so instead of ignoring you
`output: { webp: { quality: 82 } }` was accepted and did nothing. It reads
perfectly and mirrors how `pipeline` names an operation, so it is what a caller
writes from memory — and quietly it meant format fell through to the "webp"
default (right answer, wrong reason) while the options were never read, so the
quality asked for was not the quality encoded. Ask for `{ png: … }` the same way
and you got a WebP with a .png on the end of it.

Every other part of this API rejects a name it does not know. This one did not,
and what it let through was the kind of failure nobody finds by looking at the
output.

Also report `needsConversion` from `/v1/image`. Which formats have to go through
ImageMagick before sharp can read their pixels is this service's fact, and it is
expensive enough to plan around rather than merely absorb: 8.5 s against 1.1 s
for the resize and encode after it. A caller building a seven-rung ladder needs
to know so it can decode once itself, and a copy of the list kept on its side
would go on paying after this build stopped needing it.

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

364 lines
14 KiB
TypeScript

import { timingSafeEqual } from "node:crypto";
import { ASSETS_DIR, MAX_CONCURRENT_JOBS, MAX_UPLOAD_BYTES, PORT, requireToken } from "./config";
import { ApiError, invalid, notFound, toErrorResponse, unprocessable } from "./errors";
import { contentPath, deleteAsset, putAsset, readAsset } from "./assets/store";
import { ffprobe } from "./probe/ffprobe";
import { runSharp, type SharpRequest } from "./sharp/run";
import { runFfmpeg, type FfmpegRequest } from "./ffmpeg/run";
import { safeInputPath } from "./paths";
import { needsConversion } from "./sharp/decodable";
import sharp from "sharp";
import { createSidecarConnector } from "@@/sidecar/connect";
import { API_URL } from "@@/officer-url.mjs";
/**
* av-club — ffmpeg, sharp and ffprobe over HTTP.
*
* The security model is in docs/SECURITY.md and is not optional reading: the caller never names a
* path, and that single invariant is what makes it safe to expose the rest of these tools' surface.
*/
const encoder = new TextEncoder();
// Read once, at boot, so a missing token stops the process here rather than at the first request.
const TOKEN = requireToken();
/**
* Constant-time bearer check.
*
* `===` on secrets leaks their length and their common prefix through timing. The cost of doing it
* properly is three lines.
*/
function authorised(request: Request): boolean {
/*
The platform, having already authenticated somebody.
`createSidecarProxy` forwards `X-Officer-User` after the account gate and the `av-club` permission
gate have both passed, and this server binds LOOPBACK — so that header is only settable by something
already on this machine, which is the same trust boundary caldav's DAV door relies on. Requiring the
bearer token as well would mean shipping av-club's secret to every caller for no gain: the platform
is the thing deciding who may encode, and it has already decided.
*/
if (request.headers.get("x-officer-user")) return true;
const header = request.headers.get("authorization");
if (!header?.startsWith("Bearer ")) return false;
const provided = encoder.encode(header.slice(7));
const expected = encoder.encode(TOKEN);
if (provided.byteLength !== expected.byteLength) return false;
return timingSafeEqual(provided, expected);
}
/**
* Bound concurrent processing.
*
* sharp and ffmpeg will each take every core available. Without this, two large requests can make
* the box unresponsive to everything including the health check.
*/
let running = 0;
const queue: (() => void)[] = [];
async function withSlot<T>(work: () => Promise<T>): Promise<T> {
if (running >= MAX_CONCURRENT_JOBS) {
await new Promise<void>((resolve) => queue.push(resolve));
}
running++;
try {
return await work();
} finally {
running--;
queue.shift()?.();
}
}
/** Serve stored bytes, honouring Range — browsers will not scrub a <video> served without it. */
async function serveContent(id: string, request: Request): Promise<Response> {
const asset = await readAsset(id);
const file = Bun.file(contentPath(id));
const type =
asset.kind === "image"
? `image/${asset.format === "jpeg" ? "jpeg" : asset.format}`
: file.type || "application/octet-stream";
const range = request.headers.get("range");
const match = range?.match(/^bytes=(\d*)-(\d*)$/);
if (match) {
const size = asset.bytes;
const start = match[1] ? Number(match[1]) : 0;
const end = match[2] ? Math.min(Number(match[2]), size - 1) : size - 1;
if (!Number.isFinite(start) || start > end || start >= size) {
return new Response(null, { status: 416, headers: { "Content-Range": `bytes */${size}` } });
}
return new Response(file.slice(start, end + 1), {
status: 206,
headers: {
"Content-Type": type,
"Content-Range": `bytes ${start}-${end}/${size}`,
"Accept-Ranges": "bytes",
"Content-Length": String(end - start + 1),
},
});
}
return new Response(file, {
headers: {
"Content-Type": type,
"Accept-Ranges": "bytes",
// Every id is unique and content never changes under one, so this is honest.
"Cache-Control": "public, max-age=31536000, immutable",
"X-Content-Type-Options": "nosniff",
},
});
}
async function handleUpload(request: Request): Promise<Response> {
const form = await request.formData().catch(() => {
throw invalid("Expected a multipart/form-data body with a `file` field");
});
const file = form.get("file");
if (!(file instanceof File)) throw invalid("Expected a `file` field containing a file", "file");
if (file.size > MAX_UPLOAD_BYTES) {
throw new ApiError(
"payload_too_large",
`That file is ${(file.size / 1_048_576).toFixed(1)} MB. The limit is ${(MAX_UPLOAD_BYTES / 1_048_576).toFixed(0)} MB.`,
);
}
const asset = await withSlot(async () => putAsset(await file.arrayBuffer()));
return Response.json(asset, { status: 201 });
}
async function route(request: Request): Promise<Response> {
const url = new URL(request.url);
const path = url.pathname;
if (path === "/health") {
return Response.json({ ok: true, service: "av-club", running, queued: queue.length });
}
if (!authorised(request)) {
return new ApiError("unauthorized", "Missing or invalid bearer token").toResponse();
}
if (path === "/v1/assets" && request.method === "POST") return handleUpload(request);
/*
What is inside a file already on disk.
The same answer as `/v1/assets/:id/probe`, for the same reason the ffmpeg endpoint grew a path
mode: a caller deciding whether a conversion can be a lossless remux has to know the codecs, and
uploading a two-hour film to ask is not a way to find out. Confined by the same `safeInputPath`.
Read-only, so there is no output path to check — ffprobe is told a file and prints what it sees.
*/
/*
── The file browser's two questions, answered for formats the platform cannot read ──
The platform's thumbnailer uses sharp, whose bundled libheif has no HEVC decoder, so it does not
claim `.heic` at all. This plugin does — see `fileTypes` in the manifest — and these are the routes
the platform calls when it meets one.
`size` and `width` are CLAMPED here rather than trusted. The caller is the platform rather than a
browser, but a route that resizes to whatever number arrives is one bad query string from an
allocation the size of the machine, and "the only caller is trusted" is a property that stops being
true the first time somebody adds a second caller.
Both stream bytes rather than returning an asset id: the platform caches the result under its own
key, and an asset here would be a second copy of something already cached elsewhere, with no owner
and nothing to expire it.
*/
if ((path === "/v1/thumb" || path === "/v1/render") && request.method === "GET") {
const target = await safeInputPath(url.searchParams.get("path"), "path");
const thumb = path === "/v1/thumb";
const asked = Number(url.searchParams.get(thumb ? "size" : "width"));
const box = Number.isFinite(asked) && asked > 0 ? Math.min(Math.max(asked, 32), thumb ? 1024 : 4096) : thumb ? 256 : 2048;
const result = await withSlot(() =>
runSharp({
inputPath: target,
pipeline: [
// EXIF orientation applied and the tag dropped, or every photo taken sideways arrives sideways.
{ rotate: {} },
{ resize: { width: box, height: box, fit: "inside", withoutEnlargement: true } },
],
// JPEG for a thumbnail (small, universal); WebP for the viewer, which wants the quality back.
output: thumb ? { format: "jpeg", options: { quality: 80, mozjpeg: true } } : { format: "webp", options: { quality: 90 } },
}),
);
if (!("id" in result)) throw new Error("expected an asset");
/*
READ THE BYTES, THEN DELETE THE ASSET.
`runSharp` without an `outputPath` stores its result, which is right for the API and wrong here:
this is a derived view of a file that already exists, requested on every folder listing, and
keeping one would fill the asset store with thumbnails nothing owns and nothing expires.
Read fully rather than streamed from disk, because a streamed `Bun.file` would still be reading
when the delete lands. These are a few hundred kilobytes at most — the size is bounded by the box
clamped above.
*/
const bytes = await Bun.file(contentPath(result.id)).arrayBuffer();
await deleteAsset(result.id).catch(() => {});
return new Response(bytes, {
headers: {
"Content-Type": thumb ? "image/jpeg" : "image/webp",
// The platform caches under a key containing the source's mtime and size, so this answer cannot
// go stale for a given URL. Its own cache is what matters; this is just the producer.
"Cache-Control": "private, max-age=300",
},
});
}
if (path === "/v1/probe" && request.method === "GET") {
const target = await safeInputPath(url.searchParams.get("path"), "path");
return Response.json(await withSlot(() => ffprobe(target)));
}
/*
An image's header, by path.
Separate from `/v1/probe` because ffprobe is the wrong tool for a still and fails at it loudly: a
HEIC out of an iPhone is not a media container it understands, and it rejects the file outright
("moov atom not found") rather than reporting no dimensions. Anything that needs a photograph's
size — a caller building a width ladder, and therefore needing to know which rungs would be an
upscale — had nowhere to ask.
sharp reads it, including HEIC, because `metadata()` only parses the header. That is precisely the
part of a HEIC that works; the pixels are what its bundled libheif cannot reach, which is what
`decodable.ts` exists to route around. No decode happens here, so this is cheap and needs no job
slot: it is a file header read, not an image operation.
*/
if (path === "/v1/image" && request.method === "GET") {
const target = await safeInputPath(url.searchParams.get("path"), "path");
const meta = await sharp(target)
.metadata()
.catch(() => {
throw unprocessable(`Could not read that image: ${target}`);
});
return Response.json({
format: meta.format ?? null,
width: meta.width ?? null,
height: meta.height ?? null,
space: meta.space ?? null,
hasAlpha: meta.hasAlpha ?? false,
pages: meta.pages ?? 1,
orientation: meta.orientation ?? null,
/*
Reported, because it is a cost a caller can plan around rather than merely absorb.
True means every operation on this file starts with an ImageMagick conversion — 8.5 s against
1.1 s for the resize and encode after it. A caller making one derivative should ignore this. A
caller making seven should decode once itself and work from that, which is the difference
between 67 seconds and 16 for one phone photograph.
*/
needsConversion: needsConversion(meta.format ?? ""),
});
}
const asset = path.match(/^\/v1\/assets\/([^/]+)(\/content|\/probe)?$/);
if (asset) {
const [, id, suffix] = asset;
if (suffix === "/content" && request.method === "GET") return serveContent(id!, request);
if (suffix === "/probe" && request.method === "GET") {
const stored = await readAsset(id!);
if (stored.kind === "image") {
const metadata = await sharp(contentPath(id!)).metadata();
return Response.json({ kind: "image", metadata });
}
const probe = await withSlot(() => ffprobe(contentPath(id!)));
return Response.json({ kind: stored.kind, ...probe });
}
if (!suffix && request.method === "GET") return Response.json(await readAsset(id!));
if (!suffix && request.method === "DELETE") {
if (!(await readAsset(id!).then(() => true).catch(() => false))) throw notFound();
await deleteAsset(id!);
return new Response(null, { status: 204 });
}
}
if (path === "/v1/sharp" && request.method === "POST") {
const body = (await request.json().catch(() => {
throw invalid("Expected a JSON body");
})) as SharpRequest;
const result = await withSlot(() => runSharp(body));
if (!("id" in result)) return Response.json(result, { status: 200 });
return Response.json(result, { status: 201 });
}
if (path === "/v1/ffmpeg" && request.method === "POST") {
const body = (await request.json().catch(() => {
throw invalid("Expected a JSON body");
})) as FfmpegRequest;
const result = await withSlot(() => runFfmpeg(body));
// A path result is not an asset and has no id, so it cannot carry a Location header.
if (!("id" in result)) return Response.json(result, { status: 200 });
return Response.json(result, { status: 201 });
}
throw notFound(`No route for ${request.method} ${path}`);
}
const server = Bun.serve({
port: PORT,
// Loopback, because `X-Officer-User` above is trusted and a header is only as good as who can set it.
hostname: "127.0.0.1",
// Bun's default is 10 seconds, which silently kills a long encode mid-flight.
idleTimeout: 255,
maxRequestBodySize: MAX_UPLOAD_BYTES,
fetch: (request) => route(request).catch(toErrorResponse),
});
/*
Register with the platform, and report the port it can be reached on.
The port is still `AV_CLUB_PORT` rather than an ephemeral one: this service is also usable directly —
by a script on the box, or by fofos/platform, which is its stated first consumer and is not Officer.
Being a plugin is an additional way in, not a replacement for the one it already had.
*/
const connection = createSidecarConnector({
apiUrl: `${API_URL}/api/sidecar/register`,
name: "av-club",
handles: ["av-club"],
onCommand(_cmd, reply) {
reply({ type: "error", id: (_cmd as { id: string }).id, error: "av-club takes no commands; use its HTTP API" });
},
onConnected() {
connection.send({ type: "av-club:server", port: server.port! });
console.log(`[av-club] reported port ${server.port} to the API`);
},
});
function shutdown(signal: string) {
console.log(`[av-club] ${signal} received, shutting down...`);
try {
server.stop(true);
} catch {
/* already stopped */
}
connection.destroy();
process.exit(0);
}
process.on("SIGTERM", () => shutdown("SIGTERM"));
process.on("SIGINT", () => shutdown("SIGINT"));
console.log(`av-club on ${server.url}`);
console.log(` assets ${ASSETS_DIR}`);
console.log(` max upload ${(MAX_UPLOAD_BYTES / 1_048_576).toFixed(0)} MB`);
console.log(` concurrency ${MAX_CONCURRENT_JOBS}`);