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>
This commit is contained in:
co-authored by
Claude Opus 5
parent
81c8e17a8e
commit
1597fccd75
@@ -0,0 +1,122 @@
|
||||
#!/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`,
|
||||
);
|
||||
Reference in New Issue
Block a user