`/v1/probe` is ffprobe, which is the wrong tool for a still and fails at it loudly: a HEIC out of an iPhone is not a container it understands, so it rejects the file outright with "moov atom not found" rather than reporting no dimensions. So anything needing a photograph's size had nowhere to ask — and a caller building a width ladder needs exactly that, to know which rungs would be an upscale. sharp reads it, HEIC included, 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 routes around. No decode happens, so this needs no job slot. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
354 lines
14 KiB
TypeScript
354 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 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,
|
|
});
|
|
}
|
|
|
|
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}`);
|