Files
av-club/docs/API.md
T
PastilhasandClaude Opus 5 1597fccd75 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>
2026-08-20 02:37:57 +01:00

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.