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>
209 lines
7.1 KiB
Markdown
209 lines
7.1 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` | **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:
|
|
|
|
```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 — designed, not yet built
|
|
|
|
`POST /v1/ffmpeg` will take ffmpeg's own option names:
|
|
|
|
```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.
|