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

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.