adds a path mode to /v1/ffmpeg:
{ input: "as_…" } → stored as a new asset, 201
{ inputPath: "/…", outputPath: "/…" } → written where you said, 200 { path, bytes }
THIS REVISES THE INVARIANT IN docs/SECURITY.md, deliberately, and that document now
says so at the top rather than being quietly contradicted by the code.
it used to read "the caller never supplies a path, for input or output". that was
right for an upload service and av-club is not one: both of its real consumers —
a right-click on ~/Movies/thing.mkv, and a commander watching an upload folder —
already have the file, and obeying the rule meant copying gigabytes in and back out
to convert something that never moved. a rule that expensive gets worked around,
and the workaround is where the hole would have been.
so it is narrower now and says what it was always for. argv never a shell,
-protocol_whitelist file, filters allowlisted, work bounded — all unchanged. what
is gone is only the claim that a path never appears, replaced by one small module
every path goes through:
absolute (a relative path resolves against a cwd the caller cannot see)
realpath FIRST, then check — checking the path as written and opening the
resolved one is the classic way this fails
inside as `root` or `root + sep`, never a bare startsWith, which accepts
/home/user-backup for a root of /home/user
for an output the PARENT is resolved, and must already exist
errors never say which of "outside" or "missing" it was, or this becomes an
oracle for what exists elsewhere on the disk
VERIFIED against the running service, not reasoned about: `..` traversal, an
absolute path outside the root, a write outside the root, a relative path, a
symlink pointing into /etc, and a write THROUGH a symlinked directory — all
refused, nothing written outside the root.
the output is moved into place at the END. ffmpeg encodes into a temp dir, so a job
killed by the timeout or a restart leaves the caller's directory as it was. pointing
ffmpeg at the destination would leave a truncated file where a good one might have
been — and a task that then deleted its source would have destroyed something.
rename first, copy on EXDEV, which is the normal case rather than the error one
since tmp is often tmpfs.
docs/API.md said this endpoint was "designed, not yet built" while it was routed and
working. that is fixed too: it was read as a gap and nearly rebuilt.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
8.0 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 |
implemented — both asset and path modes |
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
POST /v1/ffmpeg takes ffmpeg's own option names.
This section said "designed, not yet built" until 2026-08-29, and was wrong when it said it — the endpoint was routed and working. A stale status table is worse than none: it was read as a gap and nearly rebuilt.
Two modes
{ "input": "as_9f86…", "format": "mp4" }
stores the result as a new asset and answers 201 with it.
{ "inputPath": "/home/you/Movies/a.mkv", "outputPath": "/home/you/Movies/a.mp4", "format": "mp4" }
writes the result where you said and answers 200 with { path, bytes }. Both paths are confined to
WORK_ROOT — see docs/SECURITY.md, which this mode deliberately revised.
The input is never modified, in either mode. ffmpeg encodes into a temp directory and the result is moved into place at the end, so a job killed halfway leaves the original intact rather than a truncated file where a good one was. Deleting a source is the caller's business.
The options
{
"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.