Files
av-club/scripts/gen-docs.ts
T
PastilhasandClaude Opus 5 1597fccd75 The HTTP service: assets, probe, and the full sharp surface
Exposes sharp and ffprobe with their own vocabulary — operations are named what
sharp names them, ffprobe fields come back spelled the way ffprobe spells them —
so the tools' own documentation applies and there is no house dialect to learn.

Safety comes from one architectural invariant rather than from filtering: the
caller never supplies a path, a URL or a protocol. Input is an id we issued, output
is a name we chose. That removes the entire class of attack that makes "just pass
the flags through" a remote shell, which is precisely what makes it safe to open up
everything else. docs/SECURITY.md has the reasoning and the specific attacks.

The vocabulary is a data table, so the validator and docs/SHARP.md are generated
from one source and cannot drift. Two things are deliberately absent from it:
withMetadata, because that is how a client's GPS coordinates get published, and
limitInputPixels, because a caller who could raise it could turn a 1 KB PNG into a
40 GB allocation.

Verified against a running server, not asserted: traversal ids rejected, unknown
operations and typo'd parameters rejected by name, out-of-range values rejected
with the bound, a GPS-tagged photo coming out clean end to end, video probe, Range
requests, and a rejected upload leaving nothing on disk.

Found while building: config threw at import if AV_CLUB_TOKEN was unset, so
generating documentation required a production secret. The token is read at boot in
server.ts now — still a refusal to start, no longer a module-scope landmine.

ffmpeg is designed in docs/API.md and not built yet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 02:37:57 +01:00

123 lines
4.6 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.
#!/usr/bin/env bun
// Generate docs/SHARP.md from src/sharp/vocabulary.ts.
//
// The vocabulary table is what the validator enforces. Writing the reference by hand would mean two
// descriptions of one thing, and the doc would quietly stop being true the first time a bound
// changed. This makes the drift structurally impossible instead of a discipline problem.
//
// Run: bun run docs
import { INPUT_OPTIONS, OPERATIONS, OUTPUT_FORMATS, type Param } from "../src/sharp/vocabulary";
import { resolve, dirname } from "node:path";
import { fileURLToPath } from "node:url";
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
function describeParam(spec: Param): string {
const base = (() => {
switch (spec.kind) {
case "int": return `integer, ${spec.min}–${spec.max}`;
case "number": return `number, ${spec.min}–${spec.max}`;
case "bool": return "boolean";
case "enum": return spec.values.map((v) => `\`${v}\``).join(" · ");
case "color": return "colour — a CSS string like `#ffffff`, or `{r, g, b, alpha}`";
case "numberArray": return `array of ${spec.minLength === spec.maxLength ? spec.minLength : `${spec.minLength}–${spec.maxLength}`} numbers, each ${spec.min}–${spec.max}`;
case "assetId": return "**asset id** (never a path)";
case "string": return `string, up to ${spec.maxLength} chars`;
}
})();
return spec.note ? `${base}. ${spec.note}` : base;
}
function paramTable(params: Record<string, Param>): string {
const keys = Object.keys(params);
if (!keys.length) return "_Takes no options._\n";
return [
"| Parameter | Accepts |",
"|---|---|",
...keys.map((key) => `| \`${key}\` | ${describeParam(params[key]!)} |`),
"",
].join("\n");
}
const lines: string[] = [
"# sharp — the operation vocabulary",
"",
"**Generated from `src/sharp/vocabulary.ts` by `scripts/gen-docs.ts`. Do not edit by hand —",
"run `bun run docs`.**",
"",
"Every operation is named what sharp names it and takes sharp's own arguments, so",
"[sharp's documentation](https://sharp.pixelplumbing.com/api-operation) describes what each one",
"*does*. This page describes what av-club *accepts*: the bounds, which exist because an unbounded",
"number here is a denial of service rather than a picture.",
"",
"Requests go to `POST /v1/sharp` — see [API.md](API.md) for the envelope.",
"",
"```json",
"{",
' "input": "as_9f86d081884c7d659a2feaa0c55ad015",',
' "options": { "failOn": "warning" },',
' "pipeline": [',
' { "rotate": {} },',
' { "resize": { "width": 1200, "fit": "inside", "withoutEnlargement": true } }',
" ],",
' "output": { "format": "avif", "options": { "quality": 55 } }',
"}",
"```",
"",
"## Three things that catch people",
"",
"**`rotate` with no angle applies the EXIF orientation.** Output is re-encoded and metadata is",
"dropped, so a photo that looked upright in a phone's gallery arrives sideways on the web unless you",
"include `{ \"rotate\": {} }`. This is the most commonly forgotten operation.",
"",
"**`resize` enlarges by default.** A 300px source asked for 800px comes back 800px and soft. Pass",
"`withoutEnlargement: true` unless you actually want that.",
"",
"**Metadata is always stripped and there is no way to keep it.** `withMetadata` is deliberately not",
"in this vocabulary — it is how a client's GPS coordinates end up published. Dimensions come back in",
"the response instead.",
"",
"## Input options",
"",
"sharp's constructor options, passed as `options`.",
"",
paramTable(INPUT_OPTIONS),
"`limitInputPixels` is **not** accepted: it is the decompression-bomb guard and is set by the",
"server. A 1 KB PNG can expand to tens of gigabytes.",
"",
"## Operations",
"",
`${Object.keys(OPERATIONS).length} operations, run in the order given.`,
"",
];
for (const [name, spec] of Object.entries(OPERATIONS)) {
lines.push(`### \`${name}\``, "", spec.describe, "");
if (spec.positional) {
lines.push(
`Takes \`${spec.positional}\` directly — \`{ "${name}": { "${spec.positional}": … } }\`.`,
"",
);
}
lines.push(paramTable(spec.params));
}
lines.push(
"## Output formats",
"",
"Given as `output.format`, with `output.options` carrying the encoder settings. Defaults to `webp`.",
"",
);
for (const [name, spec] of Object.entries(OUTPUT_FORMATS)) {
lines.push(`### \`${name}\``, "", spec.describe, "", paramTable(spec.params));
}
const path = resolve(ROOT, "docs/SHARP.md");
await Bun.write(path, lines.join("\n"));
console.log(
`[gen-docs] docs/SHARP.md — ${Object.keys(OPERATIONS).length} operations, ${Object.keys(OUTPUT_FORMATS).length} output formats`,
);