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>
7.1 KiB
The HTTP API
av-club is a Bun HTTP service. It exposes as much of sharp, ffmpeg and ffprobe as can be exposed safely, and it keeps each tool's own vocabulary: sharp operations are named what sharp names them, ffmpeg options are spelled the way ffmpeg spells them. If you know the tool, you know this API.
What it does not let you do is name a file. See docs/SECURITY.md — that single restriction is
what makes the rest of the surface safe to open up.
Status
| Endpoint | |
|---|---|
POST /v1/assets, GET /v1/assets/:id, GET /v1/assets/:id/content, DELETE /v1/assets/:id |
implemented |
GET /v1/assets/:id/probe |
implemented |
POST /v1/sharp |
implemented |
POST /v1/ffmpeg |
not yet — designed at the end of this document |
GET /health |
implemented |
The shape of everything
Nothing takes a path or a URL. You upload bytes, you get an asset id, and every subsequent operation refers to that id. Operations produce new assets and return new ids; inputs are never modified.
upload ──> as_a1b2… ──sharp──> as_c3d4… ──download
Ids look like as_ followed by 32 hex characters.
Authentication
Every endpoint except /health requires:
Authorization: Bearer <token>
The token is AV_CLUB_TOKEN in the service's environment. There are no user accounts — this is an
internal service between things we run.
Errors
One envelope, always, so a client can parse a failure the same way it parses a success:
{ "error": { "message": "resize.width must be an integer between 1 and 16383", "type": "invalid_request", "param": "pipeline[1].resize.width" } }
type |
Status | Meaning |
|---|---|---|
invalid_request |
400 | The request is malformed or an argument is out of range. param says where. |
unauthorized |
401 | Missing or wrong bearer token. |
not_found |
404 | No such asset. |
payload_too_large |
413 | Upload exceeds the size cap. |
unprocessable |
422 | Valid request, but the file could not be processed — corrupt, or not the format it claimed. |
timeout |
504 | The job exceeded its wall clock limit and was killed. |
server_error |
500 | Our fault. |
Messages are written to be readable by a person, because the thing on the other end is often relaying them to one.
Assets
POST /v1/assets
multipart/form-data with a file field.
curl -X POST http://localhost:3020/v1/assets \
-H "Authorization: Bearer $AV_CLUB_TOKEN" \
-F file=@photo.heic
{
"id": "as_9f86d081884c7d659a2feaa0c55ad015",
"bytes": 2847293,
"sha256": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08",
"kind": "image",
"format": "heif",
"width": 4032,
"height": 3024,
"createdAt": "2026-08-20T01:40:00.000Z"
}
kind is one of image, video, audio. It is determined by inspecting the bytes, never from
the filename or the browser's Content-Type — see docs/SECURITY.md.
The upload is rejected with 422 if it is not media we can decode, and with 413 if it exceeds
AV_CLUB_MAX_UPLOAD_BYTES.
GET /v1/assets/:id
The same JSON as above.
GET /v1/assets/:id/content
The bytes, with the right Content-Type. Supports Range requests, which matters because a browser
will not scrub — often will not even play — a <video> served without it.
DELETE /v1/assets/:id
204. Deleting an input does not affect outputs already derived from it.
GET /v1/assets/:id/probe
Everything the tools can tell you about the file.
For images this is sharp's metadata() plus stats(). For video and audio it is ffprobe with
-show_format -show_streams, passed through with ffprobe's own field names — codec_name,
bit_rate, r_frame_rate, nb_frames and the rest, exactly as ffprobe emits them.
{
"kind": "video",
"format": { "format_name": "mov,mp4,m4a,3gp,3g2,mj2", "duration": "12.480000", "bit_rate": "2489331" },
"streams": [
{ "codec_type": "video", "codec_name": "h264", "width": 1920, "height": 1080, "r_frame_rate": "30/1", "pix_fmt": "yuv420p" },
{ "codec_type": "audio", "codec_name": "aac", "sample_rate": "48000", "channels": 2 }
]
}
sharp
POST /v1/sharp
A sharp pipeline, expressed as an ordered list of operations. Every operation is named exactly what sharp names it, and its arguments are sharp's arguments, so sharp's own documentation is the reference for what each one does.
{
"input": "as_9f86d081884c7d659a2feaa0c55ad015",
"options": { "failOn": "warning", "animated": false },
"pipeline": [
{ "rotate": {} },
{ "resize": { "width": 1200, "fit": "inside", "withoutEnlargement": true } },
{ "modulate": { "saturation": 1.05 } }
],
"output": { "format": "avif", "options": { "quality": 55, "effort": 4 } }
}
options are sharp's constructor options. pipeline runs in order. output is the format and
its encoder options.
Returns a new asset:
{ "id": "as_c3d4…", "bytes": 84213, "kind": "image", "format": "avif", "width": 1200, "height": 900 }
The full operation vocabulary, the arguments each takes, and the bounds enforced on them are in
docs/SHARP.md. It is generated from the same table the validator uses, so it cannot
drift from what the server actually accepts.
Things worth knowing
rotate with no angle reads the EXIF orientation and applies it. Since output is re-encoded and
metadata is dropped, an image that was upright in a phone's gallery arrives sideways on the web
unless you do this. It is the single most commonly forgotten operation.
Metadata is stripped. Sharp does this by default and av-club does not offer withMetadata().
That is a deliberate refusal, not an omission: it is how a client's GPS coordinates get published.
If you need dimensions, they are in the response.
resize does not enlarge unless you let it. Pass withoutEnlargement: true or a 300px source
asked for 800px comes back 800px and soft.
ffmpeg — designed, not yet built
POST /v1/ffmpeg will take ffmpeg's own option names:
{
"input": "as_9f86…",
"inputOptions": { "ss": 5, "t": 10 },
"filters": { "v": [ { "scale": { "w": 1280, "h": -2 } }, { "fps": { "fps": 30 } } ] },
"outputOptions": {
"c:v": "libx264", "crf": 23, "preset": "medium", "pix_fmt": "yuv420p",
"c:a": "aac", "b:a": "128k", "movflags": "+faststart"
},
"format": "mp4"
}
Two deliberate differences from the command line, both from docs/SECURITY.md:
Filters are structured, not a -vf string. ffmpeg's filter syntax is a small language with its
own escaping rules, and parsing it correctly enough to validate it is harder than accepting a list of
{ name: args } objects. The names and parameters are ffmpeg's; only the punctuation is ours.
There is no -i and no output path. Input is an asset id and the output filename is ours. This
is the invariant the whole security model rests on.
Filters are a default-deny allowlist. movie, subtitles, frei0r, ladspa and their relatives
are refused because they read files or load native code; drawtext is allowed with its textfile
and fontfile parameters rejected.