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>
232 lines
8.0 KiB
Markdown
232 lines
8.0 KiB
Markdown
# 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:
|
|
|
|
```json
|
|
{ "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.
|
|
|
|
```bash
|
|
curl -X POST http://localhost:3020/v1/assets \
|
|
-H "Authorization: Bearer $AV_CLUB_TOKEN" \
|
|
-F file=@photo.heic
|
|
```
|
|
|
|
```json
|
|
{
|
|
"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.
|
|
|
|
```json
|
|
{
|
|
"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.
|
|
|
|
```json
|
|
{
|
|
"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:
|
|
|
|
```json
|
|
{ "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`](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
|
|
|
|
```json
|
|
{ "input": "as_9f86…", "format": "mp4" }
|
|
```
|
|
stores the result as a new asset and answers `201` with it.
|
|
|
|
```json
|
|
{ "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
|
|
|
|
```json
|
|
{
|
|
"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.
|