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>
This commit is contained in:
Pastilhas
2026-08-20 02:37:57 +01:00
co-authored by Claude Opus 5
parent 81c8e17a8e
commit 1597fccd75
15 changed files with 2153 additions and 8 deletions
+17
View File
@@ -0,0 +1,17 @@
# Copy to .env and fill in. The service refuses to start without AV_CLUB_TOKEN.
# The bearer token every request must carry. Generate with:
# openssl rand -hex 32
AV_CLUB_TOKEN=
AV_CLUB_PORT=3020
# Where stored assets live. Deliberately NOT inside the repo: a deploy that replaces the working
# tree would otherwise delete every original ever uploaded, silently.
# AV_CLUB_ASSETS_DIR=/var/lib/av-club/assets
# Limits. Each exists because its absence is a denial of service — see docs/SECURITY.md.
# AV_CLUB_MAX_UPLOAD_BYTES=536870912
# AV_CLUB_JOB_TIMEOUT_MS=120000
# AV_CLUB_MAX_OUTPUT_DIMENSION=16383
# AV_CLUB_MAX_CONCURRENT_JOBS=2
+30 -7
View File
@@ -6,27 +6,50 @@ People upload photos and videos straight off a phone or a camera: a 12 MB HEIC,
Safari refuses to play, a JPEG carrying the GPS coordinates of the room it was shot in. None of that Safari refuses to play, a JPEG carrying the GPS coordinates of the room it was shot in. None of that
belongs on a page as-is. This is the thing in between. belongs on a page as-is. This is the thing in between.
It is an HTTP service that exposes as much of **sharp**, **ffmpeg** and **ffprobe** as can be exposed
safely, keeping each tool's own vocabulary — sharp operations are named what sharp names them,
ffprobe fields come back spelled the way ffprobe spells them. If you know the tool, you know the API.
What it will not let you do is name a file. That single restriction is what makes the rest safe to
open up; see [`docs/SECURITY.md`](docs/SECURITY.md).
## Status ## Status
**Early.** Right now this repo contains the tooling requirements, an installer, and the Assets, probing and the full sharp surface work. **ffmpeg is designed but not built** — that is the
documentation of what the pipeline is meant to become. The processing code is not written yet. next piece.
| | | | | |
|---|---| |---|---|
| `scripts/install.sh` | Brings a machine to the state this needs, then verifies it | | [`docs/API.md`](docs/API.md) | The HTTP surface — start here if you are calling it |
| `scripts/verify.sh` | Checks the tooling by running it — usable standalone | | [`docs/SHARP.md`](docs/SHARP.md) | Every sharp operation and its bounds. Generated from the validator, so it cannot drift |
| `docs/TOOLING.md` | What each tool is for, and the two things that bite | | [`docs/SECURITY.md`](docs/SECURITY.md) | The threat model and the invariants. Read before adding an endpoint |
| `docs/PIPELINE.md` | What the pipeline is supposed to do, and what is still undecided | | [`docs/TOOLING.md`](docs/TOOLING.md) | What is installed, and the two things that bite |
| [`docs/PIPELINE.md`](docs/PIPELINE.md) | What this is for, and what is still undecided |
## Quick start ## Quick start
```bash ```bash
./scripts/install.sh # system packages + bun deps, then verifies ./scripts/install.sh # system packages + bun deps, then verifies
./scripts/verify.sh # verification on its own cp .env.example .env # then set AV_CLUB_TOKEN
bun dev
``` ```
Verified on Ubuntu 24.04. The installer says so rather than guessing if it finds something else. Verified on Ubuntu 24.04. The installer says so rather than guessing if it finds something else.
```bash
# upload something, then resize it
curl -X POST localhost:3020/v1/assets -H "Authorization: Bearer $AV_CLUB_TOKEN" -F file=@photo.jpg
curl -X POST localhost:3020/v1/sharp -H "Authorization: Bearer $AV_CLUB_TOKEN" \
-H 'Content-Type: application/json' -d '{
"input": "as_…",
"pipeline": [{ "rotate": {} }, { "resize": { "width": 1200, "withoutEnlargement": true } }],
"output": { "format": "avif", "options": { "quality": 55 } }
}'
```
`bun run verify` checks the tooling; `bun run docs` regenerates `docs/SHARP.md` from the validator.
## Why this is its own repo ## Why this is its own repo
**It has system dependencies a web app should not have to assume.** ffmpeg, ImageMagick, libvips and **It has system dependencies a web app should not have to assume.** ffmpeg, ImageMagick, libvips and
+208
View File
@@ -0,0 +1,208 @@
# 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.
+144
View File
@@ -0,0 +1,144 @@
# Security model
av-club exposes ffmpeg, sharp and exiftool over HTTP. Done naively that is a remote shell. This
document is the set of invariants that make it not one, and the reasoning for each — so that a future
change can be checked against the reasoning rather than against a vibe.
**Read this before adding an endpoint, an option, or a filter.**
## The one invariant everything else rests on
> **The caller never supplies a path, a URL, or a protocol. Not for input, not for output, not
> inside a filter argument, not anywhere.**
Every dangerous thing these tools can do is reached through a path or a URL. Remove the caller's
ability to name one and the entire class disappears, which is what makes it safe to expose almost
every *other* option these tools have.
Concretely: input is an **opaque asset id** issued by av-club for a file av-club received. Output is
a path av-club chooses. Both are resolved server-side into paths under a job directory. Anything that
looks like a path in a caller-supplied value is rejected, not sanitised.
### What that invariant is defending against
These all work by default, which is why "just pass the flags through" is not an option:
| Attack | How |
|---|---|
| Read any file the process can | `-i /etc/passwd`, then download the "converted" output |
| Read a file the input filter never sees | `-f lavfi -i "movie=/home/user/.ssh/id_ed25519"` |
| Read arbitrary paths via a demuxer | `-f concat -i list.txt` with a caller-controlled list |
| SSRF into the private network | ffmpeg speaks `http:` — cloud metadata endpoints, internal services |
| Write anywhere the process can | the output path is just an argument: `~/.ssh/authorized_keys` |
| Load arbitrary native code | `frei0r`, `ladspa`, `lv2` filters load plugins by name from disk |
Note the second row especially. A guard that only inspected `-i` would pass it, because the read
happens inside the filter graph. **This is why the rule is about values, not about which flag they
arrived in.**
## Layer 1 — no shell, ever
Processes are spawned with an **argv array**, never a command string. There is no shell to quote for,
so shell metacharacters are inert data rather than a parsing problem.
Anything that builds a command by string concatenation is a bug regardless of how well it escapes.
## Layer 2 — the caller names nothing
| | |
|---|---|
| **Input** | An asset id (opaque, server-issued) or a multipart upload. Never a path or URL. |
| **Output** | av-club picks the filename and directory. The caller picks the *format*, not where it lands. |
| **Working dir** | One temp directory per job, removed when the job ends. |
Asset ids are validated against a strict pattern before they are used to build a path, and the
resolved path is checked to still be inside the store afterwards. Two checks, because the first is
about intent and the second is about what actually happened.
## Layer 3 — ffmpeg runs with protocols disabled
```
-protocol_whitelist file (nothing else — no http, no concat, no subfile, no data)
-f <explicit> inputs are demuxed as what we probed them to be, not auto-detected
-nostdin ffmpeg never waits on stdin
```
`-protocol_whitelist file` alone kills SSRF and the `concat:`/`subfile:` tricks outright. It is not
a mitigation for a path leak — Layer 2 is — it is the second lock.
## Layer 4 — filters are allowlisted by name, and their arguments are checked
ffmpeg's filter graph is a language. Most of it is arithmetic on pixels and samples and is safe.
A small, specific part of it touches the filesystem or loads code.
**Never allowed** — these read files or load native code, by design:
`movie`, `amovie`, `subtitles`, `ass`, `frei0r`, `frei0r_src`, `ladspa`, `lv2`, `sendcmd`,
`asendcmd`, `vidstabtransform`, `libvmaf`, `openclsrc`
**Allowed, but with file-bearing parameters stripped**: `drawtext` is genuinely useful and carries
`textfile=` and `fontfile=`. The filter is permitted; those two parameters are rejected. Text comes
as a literal, fonts come from a fixed server-side set referenced by name.
The list is a **default-deny allowlist**, not a blocklist. A filter nobody has classified is
refused. A blocklist would silently admit whatever the next ffmpeg release adds.
## Layer 5 — bounded work
Unbounded is a denial of service even when it is not a breach.
| Bound | Why |
|---|---|
| Upload size cap | Multipart bodies are buffered; an unbounded one is an OOM |
| Wall-clock timeout per job, process killed on expiry | A crafted file can make an encoder run effectively forever |
| Output dimension cap | `resize(50000, 50000)` is an allocation, not an image |
| `limitInputPixels` on sharp | Decompression bombs: a 1 KB PNG that expands to 40 GB |
| Concurrency cap | ffmpeg will use every core it is given; two callers should not be able to take the box down |
| Job directory removed on completion **and** on failure | Otherwise disk fills with the failures |
## Layer 6 — the untrusted-parser problem, stated honestly
Everything above constrains what a caller may *ask for*. None of it helps with the fact that
**ffmpeg and libvips are large C/C++ codebases parsing hostile binary input**, which is a
memory-safety attack surface with a long CVE history — ImageTragick (CVE-2016-3714) and exiftool's
DjVu RCE (CVE-2021-22204) are the famous ones, and they are not the last.
The honest mitigation is process isolation, and it is a *deployment* property rather than a code one:
- run the service as a dedicated unprivileged user that owns nothing else
- no network egress from the process (with protocols disabled it has no reason to make one)
- read-only filesystem apart from the job directory
- ideally a container or `bubblewrap`/`nsjail` sandbox per job
**Status: not implemented yet.** It is written down here because it is the difference between "a
bug in ffmpeg is a crash" and "a bug in ffmpeg is our whole machine", and because the thing most
likely to happen otherwise is that it is never considered at all.
## exiftool is treated differently
exiftool is a Perl program with a plugin architecture and a history of code execution from
**parsing** — not from its arguments. It is therefore used narrowly:
- **reading only**, with a fixed argument list, never caller-supplied options
- never `-@` (argfile), never `-config`, never a user-supplied `-if` expression
- stripping metadata is done by **sharp's re-encode**, not by exiftool
Sharp drops EXIF by default on re-encode, which is verified on every `scripts/verify.sh` run. That
is a smaller, better-understood surface than asking exiftool to rewrite a file in place.
## Authentication
The service is not public and has no anonymous surface. Every endpoint requires a bearer token
issued out of band. This is deliberately dull — it is an internal service between two things we
operate, not a product.
**Even so, it must not be reachable from the internet.** Auth is the second lock; the first is not
listening on a public interface.
## Reviewing a change against this document
- Does the caller name a path, a URL, or a protocol anywhere in the new surface? → **no**
- Is a new ffmpeg filter classified in the allowlist, with a reason? → **required**
- Is the new option's value constrained by type and range, or is it a free string? → **constrain it**
- Can it produce unbounded work — time, memory, pixels, files? → **bound it**
- Is a command being built as a string? → **it must not be**
+491
View File
@@ -0,0 +1,491 @@
# sharp — the operation vocabulary
**Generated from `src/sharp/vocabulary.ts` by `scripts/gen-docs.ts`. Do not edit by hand —
run `bun run docs`.**
Every operation is named what sharp names it and takes sharp's own arguments, so
[sharp's documentation](https://sharp.pixelplumbing.com/api-operation) describes what each one
*does*. This page describes what av-club *accepts*: the bounds, which exist because an unbounded
number here is a denial of service rather than a picture.
Requests go to `POST /v1/sharp` — see [API.md](API.md) for the envelope.
```json
{
"input": "as_9f86d081884c7d659a2feaa0c55ad015",
"options": { "failOn": "warning" },
"pipeline": [
{ "rotate": {} },
{ "resize": { "width": 1200, "fit": "inside", "withoutEnlargement": true } }
],
"output": { "format": "avif", "options": { "quality": 55 } }
}
```
## Three things that catch people
**`rotate` with no angle applies the EXIF orientation.** Output is re-encoded and metadata is
dropped, so a photo that looked upright in a phone's gallery arrives sideways on the web unless you
include `{ "rotate": {} }`. This is the most commonly forgotten operation.
**`resize` enlarges by default.** A 300px source asked for 800px comes back 800px and soft. Pass
`withoutEnlargement: true` unless you actually want that.
**Metadata is always stripped and there is no way to keep it.** `withMetadata` is deliberately not
in this vocabulary — it is how a client's GPS coordinates end up published. Dimensions come back in
the response instead.
## Input options
sharp's constructor options, passed as `options`.
| Parameter | Accepts |
|---|---|
| `failOn` | `none` · `truncated` · `error` · `warning`. How strict to be about a damaged file. sharp's default is 'warning'. |
| `density` | number, 1–2400. DPI for vector input. |
| `pages` | integer, -1–1000. Pages to read from a multi-page input. -1 for all. |
| `page` | integer, 0–100000. First page to read. |
| `animated` | boolean. Read all frames of an animated GIF/WebP. Equivalent to pages: -1. |
| `ignoreIcc` | boolean. Ignore any embedded ICC profile. |
| `autoOrient` | boolean. Apply EXIF orientation on load. See the note about rotate() in docs/SHARP.md. |
| `sequentialRead` | boolean |
| `level` | integer, 0–256. Level to extract from a multi-level input. |
| `subifd` | integer, -1–100000. Subldeaf IFD to extract from an OME-TIFF. |
`limitInputPixels` is **not** accepted: it is the decompression-bomb guard and is set by the
server. A 1 KB PNG can expand to tens of gigabytes.
## Operations
32 operations, run in the order given.
### `resize`
Resize to given dimensions.
| Parameter | Accepts |
|---|---|
| `width` | integer, 1–16383 |
| `height` | integer, 1–16383 |
| `fit` | `cover` · `contain` · `fill` · `inside` · `outside`. How the image fits the box. Default 'cover'. |
| `position` | `centre` · `center` · `top` · `right top` · `right` · `right bottom` · `bottom` · `left bottom` · `left` · `left top` · `north` · `northeast` · `east` · `southeast` · `south` · `southwest` · `west` · `northwest` · `entropy` · `attention`. Which part survives when fit is 'cover'. 'attention' picks the busiest region. |
| `background` | colour — a CSS string like `#ffffff`, or `{r, g, b, alpha}`. Fill colour when fit is 'contain'. |
| `kernel` | `nearest` · `linear` · `cubic` · `mitchell` · `lanczos2` · `lanczos3` |
| `withoutEnlargement` | boolean. Never scale UP. Almost always what you want — see docs/SHARP.md. |
| `withoutReduction` | boolean. Never scale DOWN. |
| `fastShrinkOnLoad` | boolean |
### `extend`
Add pixels to the edges.
| Parameter | Accepts |
|---|---|
| `top` | integer, 0–16383 |
| `bottom` | integer, 0–16383 |
| `left` | integer, 0–16383 |
| `right` | integer, 0–16383 |
| `extendWith` | `background` · `copy` · `repeat` · `mirror` |
| `background` | colour — a CSS string like `#ffffff`, or `{r, g, b, alpha}` |
### `extract`
Crop to a region.
| Parameter | Accepts |
|---|---|
| `left` | integer, 0–16383 |
| `top` | integer, 0–16383 |
| `width` | integer, 1–16383 |
| `height` | integer, 1–16383 |
### `trim`
Trim uniform borders.
| Parameter | Accepts |
|---|---|
| `background` | colour — a CSS string like `#ffffff`, or `{r, g, b, alpha}` |
| `threshold` | number, 0–255 |
| `lineArt` | boolean |
### `rotate`
Rotate. With no angle, applies the EXIF orientation — see docs/SHARP.md.
| Parameter | Accepts |
|---|---|
| `angle` | number, -3600–3600. Multiples of 90 are lossless; anything else needs a background. |
| `background` | colour — a CSS string like `#ffffff`, or `{r, g, b, alpha}` |
### `flip`
Mirror vertically.
Takes `flip` directly — `{ "flip": { "flip": … } }`.
| Parameter | Accepts |
|---|---|
| `flip` | boolean |
### `flop`
Mirror horizontally.
Takes `flop` directly — `{ "flop": { "flop": … } }`.
| Parameter | Accepts |
|---|---|
| `flop` | boolean |
### `sharpen`
Sharpen.
| Parameter | Accepts |
|---|---|
| `sigma` | number, 0.000001–10 |
| `m1` | number, 0–1000000 |
| `m2` | number, 0–1000000 |
| `x1` | number, 0–1000000 |
| `y2` | number, 0–1000000 |
| `y3` | number, 0–1000000 |
### `blur`
Gaussian blur.
Takes `sigma` directly — `{ "blur": { "sigma": … } }`.
| Parameter | Accepts |
|---|---|
| `sigma` | number, 0.3–1000 |
### `median`
Median filter — good for noise.
Takes `size` directly — `{ "median": { "size": … } }`.
| Parameter | Accepts |
|---|---|
| `size` | integer, 1–1000 |
### `dilate`
Morphological dilate.
Takes `width` directly — `{ "dilate": { "width": … } }`.
| Parameter | Accepts |
|---|---|
| `width` | integer, 1–1000 |
### `erode`
Morphological erode.
Takes `width` directly — `{ "erode": { "width": … } }`.
| Parameter | Accepts |
|---|---|
| `width` | integer, 1–1000 |
### `flatten`
Composite transparency onto a background.
| Parameter | Accepts |
|---|---|
| `background` | colour — a CSS string like `#ffffff`, or `{r, g, b, alpha}` |
### `unflatten`
Make pure white pixels transparent.
_Takes no options._
### `gamma`
Apply gamma correction.
| Parameter | Accepts |
|---|---|
| `gamma` | number, 1–3 |
| `gammaOut` | number, 1–3 |
### `negate`
Invert.
| Parameter | Accepts |
|---|---|
| `alpha` | boolean. Also invert the alpha channel. |
### `normalise`
Stretch the histogram to full range.
| Parameter | Accepts |
|---|---|
| `lower` | number, 0–99 |
| `upper` | number, 1–100 |
### `clahe`
Contrast-limited adaptive histogram equalisation.
| Parameter | Accepts |
|---|---|
| `width` | integer, 1–16383 |
| `height` | integer, 1–16383 |
| `maxSlope` | integer, 0–100 |
### `convolve`
Apply a convolution kernel.
| Parameter | Accepts |
|---|---|
| `width` | integer, 1–100 |
| `height` | integer, 1–100 |
| `kernel` | array of 1–10000 numbers, each -1000–1000 |
| `scale` | number, -10000–10000 |
| `offset` | number, -10000–10000 |
### `threshold`
Reduce to black and white at a cutoff.
| Parameter | Accepts |
|---|---|
| `threshold` | integer, 0–255 |
| `greyscale` | boolean |
### `linear`
Multiply and add: out = in * a + b.
| Parameter | Accepts |
|---|---|
| `a` | array of 1–4 numbers, each -1000–1000 |
| `b` | array of 1–4 numbers, each -1000–1000 |
### `recomb`
Recombine channels with a 3x3 matrix.
Takes `matrix` directly — `{ "recomb": { "matrix": … } }`.
| Parameter | Accepts |
|---|---|
| `matrix` | array of 9 numbers, each -100–100 |
### `modulate`
Adjust brightness, saturation, hue and lightness.
| Parameter | Accepts |
|---|---|
| `brightness` | number, 0–100 |
| `saturation` | number, 0–100 |
| `hue` | number, -3600–3600. Degrees. |
| `lightness` | number, -100–100 |
### `tint`
Tint with a colour.
Takes `color` directly — `{ "tint": { "color": … } }`.
| Parameter | Accepts |
|---|---|
| `color` | colour — a CSS string like `#ffffff`, or `{r, g, b, alpha}` |
### `greyscale`
Convert to greyscale.
Takes `greyscale` directly — `{ "greyscale": { "greyscale": … } }`.
| Parameter | Accepts |
|---|---|
| `greyscale` | boolean |
### `toColourspace`
Convert to a colourspace.
Takes `colourspace` directly — `{ "toColourspace": { "colourspace": … } }`.
| Parameter | Accepts |
|---|---|
| `colourspace` | `srgb` · `rgb` · `cmyk` · `lab` · `b-w` · `hsv` · `scrgb` · `lch` · `grey16` · `rgb16` |
### `pipelineColourspace`
Set the colourspace operations are performed in.
Takes `colourspace` directly — `{ "pipelineColourspace": { "colourspace": … } }`.
| Parameter | Accepts |
|---|---|
| `colourspace` | `srgb` · `rgb` · `cmyk` · `lab` · `b-w` · `hsv` · `scrgb` · `lch` · `grey16` · `rgb16` |
### `removeAlpha`
Drop the alpha channel.
_Takes no options._
### `ensureAlpha`
Add an alpha channel if absent.
Takes `alpha` directly — `{ "ensureAlpha": { "alpha": … } }`.
| Parameter | Accepts |
|---|---|
| `alpha` | number, 0–1 |
### `extractChannel`
Keep one channel.
Takes `channel` directly — `{ "extractChannel": { "channel": … } }`.
| Parameter | Accepts |
|---|---|
| `channel` | `red` · `green` · `blue` · `alpha` · `0` · `1` · `2` · `3` |
### `bandbool`
Boolean-combine all channels into one.
Takes `operator` directly — `{ "bandbool": { "operator": … } }`.
| Parameter | Accepts |
|---|---|
| `operator` | `and` · `or` · `eor` |
### `composite`
Overlay another asset. `input` is an asset id, never a path.
| Parameter | Accepts |
|---|---|
| `input` | **asset id** (never a path). The asset to overlay. Must already be uploaded. |
| `blend` | `clear` · `source` · `over` · `in` · `out` · `atop` · `dest` · `dest-over` · `dest-in` · `dest-out` · `dest-atop` · `xor` · `add` · `saturate` · `multiply` · `screen` · `overlay` · `darken` · `lighten` · `colour-dodge` · `color-dodge` · `colour-burn` · `color-burn` · `hard-light` · `soft-light` · `difference` · `exclusion` |
| `gravity` | `centre` · `center` · `north` · `northeast` · `east` · `southeast` · `south` · `southwest` · `west` · `northwest` |
| `top` | integer, 0–16383 |
| `left` | integer, 0–16383 |
| `tile` | boolean |
| `premultiplied` | boolean |
## Output formats
Given as `output.format`, with `output.options` carrying the encoder settings. Defaults to `webp`.
### `jpeg`
JPEG.
| Parameter | Accepts |
|---|---|
| `quality` | integer, 1–100 |
| `progressive` | boolean |
| `chromaSubsampling` | `4:4:4` · `4:2:2` · `4:2:0` |
| `optimiseCoding` | boolean |
| `mozjpeg` | boolean. Turns on a bundle of mozjpeg settings at once. |
| `trellisQuantisation` | boolean |
| `overshootDeringing` | boolean |
| `optimiseScans` | boolean |
### `png`
PNG.
| Parameter | Accepts |
|---|---|
| `progressive` | boolean |
| `compressionLevel` | integer, 0–9 |
| `adaptiveFiltering` | boolean |
| `palette` | boolean. Quantise to a palette — much smaller for flat graphics. |
| `quality` | integer, 0–100 |
| `effort` | integer, 1–10 |
| `colours` | integer, 2–256 |
| `dither` | number, 0–1 |
### `webp`
WebP.
| Parameter | Accepts |
|---|---|
| `quality` | integer, 1–100 |
| `alphaQuality` | integer, 0–100 |
| `lossless` | boolean |
| `nearLossless` | boolean |
| `smartSubsample` | boolean |
| `preset` | `default` · `photo` · `picture` · `drawing` · `icon` · `text` |
| `effort` | integer, 0–6 |
| `loop` | integer, 0–100000 |
| `minSize` | boolean |
| `mixed` | boolean |
### `avif`
AVIF. Smaller than WebP at the same quality, slower to encode.
| Parameter | Accepts |
|---|---|
| `quality` | integer, 1–100 |
| `lossless` | boolean |
| `effort` | integer, 0–9. Higher is smaller and much slower. |
| `chromaSubsampling` | `4:4:4` · `4:2:0` |
| `bitdepth` | `8` · `10` · `12` |
### `tiff`
TIFF.
| Parameter | Accepts |
|---|---|
| `quality` | integer, 1–100 |
| `compression` | `none` · `jpeg` · `deflate` · `packbits` · `ccittfax4` · `lzw` · `webp` · `zstd` · `jp2k` |
| `predictor` | `none` · `horizontal` · `float` |
| `pyramid` | boolean |
| `tile` | boolean |
| `tileWidth` | integer, 1–16383 |
| `tileHeight` | integer, 1–16383 |
| `xres` | number, 0.01–100000 |
| `yres` | number, 0.01–100000 |
| `resolutionUnit` | `inch` · `cm` |
| `bitdepth` | `1` · `2` · `4` · `8` |
### `gif`
GIF.
| Parameter | Accepts |
|---|---|
| `reuse` | boolean |
| `progressive` | boolean |
| `colours` | integer, 2–256 |
| `effort` | integer, 1–10 |
| `dither` | number, 0–1 |
| `interFrameMaxError` | number, 0–32 |
| `interPaletteMaxError` | number, 0–256 |
| `loop` | integer, 0–100000 |
### `jp2`
JPEG 2000.
| Parameter | Accepts |
|---|---|
| `quality` | integer, 1–100 |
| `lossless` | boolean |
| `tileWidth` | integer, 1–16383 |
| `tileHeight` | integer, 1–16383 |
| `chromaSubsampling` | `4:4:4` · `4:2:2` · `4:2:0` |
+3
View File
@@ -5,6 +5,9 @@
"type": "module", "type": "module",
"description": "Turning whatever someone uploads into assets a website can actually serve", "description": "Turning whatever someone uploads into assets a website can actually serve",
"scripts": { "scripts": {
"dev": "bun --hot src/server.ts",
"start": "bun src/server.ts",
"docs": "bun run ./scripts/gen-docs.ts",
"install:tools": "./scripts/install.sh", "install:tools": "./scripts/install.sh",
"verify": "./scripts/verify.sh" "verify": "./scripts/verify.sh"
}, },
+122
View File
@@ -0,0 +1,122 @@
#!/usr/bin/env bun
// Generate docs/SHARP.md from src/sharp/vocabulary.ts.
//
// The vocabulary table is what the validator enforces. Writing the reference by hand would mean two
// descriptions of one thing, and the doc would quietly stop being true the first time a bound
// changed. This makes the drift structurally impossible instead of a discipline problem.
//
// Run: bun run docs
import { INPUT_OPTIONS, OPERATIONS, OUTPUT_FORMATS, type Param } from "../src/sharp/vocabulary";
import { resolve, dirname } from "node:path";
import { fileURLToPath } from "node:url";
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
function describeParam(spec: Param): string {
const base = (() => {
switch (spec.kind) {
case "int": return `integer, ${spec.min}–${spec.max}`;
case "number": return `number, ${spec.min}–${spec.max}`;
case "bool": return "boolean";
case "enum": return spec.values.map((v) => `\`${v}\``).join(" · ");
case "color": return "colour — a CSS string like `#ffffff`, or `{r, g, b, alpha}`";
case "numberArray": return `array of ${spec.minLength === spec.maxLength ? spec.minLength : `${spec.minLength}–${spec.maxLength}`} numbers, each ${spec.min}–${spec.max}`;
case "assetId": return "**asset id** (never a path)";
case "string": return `string, up to ${spec.maxLength} chars`;
}
})();
return spec.note ? `${base}. ${spec.note}` : base;
}
function paramTable(params: Record<string, Param>): string {
const keys = Object.keys(params);
if (!keys.length) return "_Takes no options._\n";
return [
"| Parameter | Accepts |",
"|---|---|",
...keys.map((key) => `| \`${key}\` | ${describeParam(params[key]!)} |`),
"",
].join("\n");
}
const lines: string[] = [
"# sharp — the operation vocabulary",
"",
"**Generated from `src/sharp/vocabulary.ts` by `scripts/gen-docs.ts`. Do not edit by hand —",
"run `bun run docs`.**",
"",
"Every operation is named what sharp names it and takes sharp's own arguments, so",
"[sharp's documentation](https://sharp.pixelplumbing.com/api-operation) describes what each one",
"*does*. This page describes what av-club *accepts*: the bounds, which exist because an unbounded",
"number here is a denial of service rather than a picture.",
"",
"Requests go to `POST /v1/sharp` — see [API.md](API.md) for the envelope.",
"",
"```json",
"{",
' "input": "as_9f86d081884c7d659a2feaa0c55ad015",',
' "options": { "failOn": "warning" },',
' "pipeline": [',
' { "rotate": {} },',
' { "resize": { "width": 1200, "fit": "inside", "withoutEnlargement": true } }',
" ],",
' "output": { "format": "avif", "options": { "quality": 55 } }',
"}",
"```",
"",
"## Three things that catch people",
"",
"**`rotate` with no angle applies the EXIF orientation.** Output is re-encoded and metadata is",
"dropped, so a photo that looked upright in a phone's gallery arrives sideways on the web unless you",
"include `{ \"rotate\": {} }`. This is the most commonly forgotten operation.",
"",
"**`resize` enlarges by default.** A 300px source asked for 800px comes back 800px and soft. Pass",
"`withoutEnlargement: true` unless you actually want that.",
"",
"**Metadata is always stripped and there is no way to keep it.** `withMetadata` is deliberately not",
"in this vocabulary — it is how a client's GPS coordinates end up published. Dimensions come back in",
"the response instead.",
"",
"## Input options",
"",
"sharp's constructor options, passed as `options`.",
"",
paramTable(INPUT_OPTIONS),
"`limitInputPixels` is **not** accepted: it is the decompression-bomb guard and is set by the",
"server. A 1 KB PNG can expand to tens of gigabytes.",
"",
"## Operations",
"",
`${Object.keys(OPERATIONS).length} operations, run in the order given.`,
"",
];
for (const [name, spec] of Object.entries(OPERATIONS)) {
lines.push(`### \`${name}\``, "", spec.describe, "");
if (spec.positional) {
lines.push(
`Takes \`${spec.positional}\` directly — \`{ "${name}": { "${spec.positional}": … } }\`.`,
"",
);
}
lines.push(paramTable(spec.params));
}
lines.push(
"## Output formats",
"",
"Given as `output.format`, with `output.options` carrying the encoder settings. Defaults to `webp`.",
"",
);
for (const [name, spec] of Object.entries(OUTPUT_FORMATS)) {
lines.push(`### \`${name}\``, "", spec.describe, "", paramTable(spec.params));
}
const path = resolve(ROOT, "docs/SHARP.md");
await Bun.write(path, lines.join("\n"));
console.log(
`[gen-docs] docs/SHARP.md — ${Object.keys(OPERATIONS).length} operations, ${Object.keys(OUTPUT_FORMATS).length} output formats`,
);
+149
View File
@@ -0,0 +1,149 @@
import { rm } from "node:fs/promises";
import { join, resolve, sep } from "node:path";
import sharp from "sharp";
import { ASSETS_DIR, MAX_INPUT_PIXELS } from "../config";
import { invalid, notFound, unprocessable } from "../errors";
import { ffprobe } from "../probe/ffprobe";
/**
* The asset store — and the place docs/SECURITY.md's central invariant is enforced.
*
* A caller never supplies a path. They supply an ID we issued, and this module is the only thing
* that turns an ID into a path. Everything downstream receives a path this module produced.
*/
export type AssetKind = "image" | "video" | "audio";
export type Asset = {
id: string;
bytes: number;
sha256: string;
kind: AssetKind;
format: string;
width?: number;
height?: number;
durationSeconds?: number;
createdAt: string;
};
/** `as_` plus 32 lowercase hex. Anchored, fixed length, no separators — nothing here can traverse. */
const ID_PATTERN = /^as_[0-9a-f]{32}$/;
export function newAssetId(): string {
return `as_${crypto.randomUUID().replaceAll("-", "")}`;
}
/**
* Resolve an ID to its directory, or throw.
*
* Two checks, deliberately, because they answer different questions:
*
* 1. Does the ID match the pattern we issue? — about the caller's INTENT
* 2. Is the resolved path still inside the store? — about what actually HAPPENED
*
* The second is not redundant. It is the one that still holds if the pattern is ever loosened, if
* ASSETS_DIR contains a symlink, or if some future refactor builds the path differently. Cheap, and
* the failure it prevents is "read any file on the machine".
*/
function assetDir(id: string): string {
if (!ID_PATTERN.test(id)) {
throw invalid(`Not a valid asset id: ${JSON.stringify(id.slice(0, 64))}`, "id");
}
const dir = resolve(join(ASSETS_DIR, id));
if (dir !== join(ASSETS_DIR, id) || !dir.startsWith(ASSETS_DIR + sep)) {
throw invalid("Not a valid asset id", "id");
}
return dir;
}
export const contentPath = (id: string): string => join(assetDir(id), "content");
const metaPath = (id: string): string => join(assetDir(id), "meta.json");
export async function readAsset(id: string): Promise<Asset> {
const file = Bun.file(metaPath(id));
if (!(await file.exists())) throw notFound();
return (await file.json()) as Asset;
}
export async function assetExists(id: string): Promise<boolean> {
return Bun.file(metaPath(id)).exists();
}
export async function deleteAsset(id: string): Promise<void> {
await rm(assetDir(id), { recursive: true, force: true });
}
/**
* Identify a file by INSPECTING IT, never from its name or the Content-Type a browser attached.
*
* Order matters: ffprobe will happily report a PNG as a video stream in an `image2` container, so
* sharp is asked first and images are settled before ffprobe is consulted at all.
*/
async function identify(path: string): Promise<Pick<Asset, "kind" | "format" | "width" | "height" | "durationSeconds">> {
try {
const meta = await sharp(path, { limitInputPixels: MAX_INPUT_PIXELS }).metadata();
if (meta.format) {
return { kind: "image", format: meta.format, width: meta.width, height: meta.height };
}
} catch {
// Not an image sharp can read. Fall through — it may well be a video.
}
const probe = await ffprobe(path).catch(() => null);
const streams = probe?.streams ?? [];
const video = streams.find((s) => s.codec_type === "video");
const audio = streams.find((s) => s.codec_type === "audio");
const duration = probe?.format?.duration ? Number(probe.format.duration) : undefined;
const format = probe?.format?.format_name?.split(",")[0] ?? "unknown";
if (video) {
return {
kind: "video",
format,
width: typeof video.width === "number" ? video.width : undefined,
height: typeof video.height === "number" ? video.height : undefined,
durationSeconds: duration,
};
}
if (audio) return { kind: "audio", format, durationSeconds: duration };
throw unprocessable(
"That file could not be read as an image, video or audio file. It may be corrupt, or it may not be media at all.",
);
}
/**
* Store bytes and return the asset.
*
* The ID is issued here, so a caller can never influence where a file lands. Identification runs
* AFTER the write because both sharp and ffprobe want a path — a file that turns out not to be
* media is written and then removed rather than being buffered twice.
*/
export async function putAsset(bytes: ArrayBuffer | Uint8Array): Promise<Asset> {
const id = newAssetId();
const path = contentPath(id);
await Bun.write(path, bytes);
try {
const identified = await identify(path);
const buffer = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);
const asset: Asset = {
id,
bytes: buffer.byteLength,
sha256: new Bun.CryptoHasher("sha256").update(buffer).digest("hex"),
createdAt: new Date().toISOString(),
...identified,
};
await Bun.write(metaPath(id), JSON.stringify(asset, null, 2));
return asset;
} catch (error) {
// Do not leave the bytes of something we refused sitting in the store.
await deleteAsset(id).catch(() => {});
throw error;
}
}
+71
View File
@@ -0,0 +1,71 @@
import { resolve } from "node:path";
/**
* Configuration, read once at boot.
*
* Every limit here exists because its absence is a denial of service — see docs/SECURITY.md,
* "Layer 5 — bounded work". They are configurable so a deployment can tighten them, not so a
* deployment can remove them: each has a floor enforced below.
*/
function num(key: string, fallback: number, min: number): number {
const raw = process.env[key];
if (raw === undefined) return fallback;
const value = Number(raw);
if (!Number.isFinite(value)) {
throw new Error(`${key} must be a number, got ${JSON.stringify(raw)}`);
}
if (value < min) {
throw new Error(`${key} must be at least ${min}, got ${value}`);
}
return value;
}
export const PORT = num("AV_CLUB_PORT", 3020, 1);
/**
* Where assets live. Outside the repo by default, and deliberately not under any directory a deploy
* would replace — losing every stored asset because the working tree was refreshed is the kind of
* failure that is only noticed later, by which point the originals are gone.
*/
export const ASSETS_DIR = resolve(process.env.AV_CLUB_ASSETS_DIR ?? `${process.env.HOME}/.local/share/av-club/assets`);
/** Multipart bodies are buffered whole. Without a cap, one request is an out-of-memory. */
export const MAX_UPLOAD_BYTES = num("AV_CLUB_MAX_UPLOAD_BYTES", 512 * 1024 * 1024, 1024);
/** A crafted file can make an encoder run effectively forever. The process is killed at this point. */
export const JOB_TIMEOUT_MS = num("AV_CLUB_JOB_TIMEOUT_MS", 120_000, 1_000);
/**
* Decompression bombs: a 1 KB PNG can expand to tens of gigabytes. This is sharp's own
* `limitInputPixels`, defaulted to its documented default (0x3FFF × 0x3FFF) rather than to `false`.
*/
export const MAX_INPUT_PIXELS = num("AV_CLUB_MAX_INPUT_PIXELS", 0x3fff * 0x3fff, 1);
/** `resize(50000, 50000)` is an allocation request, not an image. */
export const MAX_OUTPUT_DIMENSION = num("AV_CLUB_MAX_OUTPUT_DIMENSION", 16_383, 1);
/** ffmpeg will take every core it is given. Two callers should not be able to take the box down. */
export const MAX_CONCURRENT_JOBS = num("AV_CLUB_MAX_CONCURRENT_JOBS", 2, 1);
/**
* The bearer token every request must carry.
*
* A FUNCTION, not a constant evaluated at import. The limits above are plain data that anything may
* read — the doc generator imports the sharp vocabulary, which imports MAX_OUTPUT_DIMENSION from
* here — and a module-scope throw meant that generating documentation required a production secret.
*
* The check itself still happens at boot: server.ts calls this before it listens, so a missing token
* is a refusal to start rather than a service that quietly accepts everyone. There is no
* "unauthenticated by default" mode; this process runs decoders over untrusted bytes.
*/
export function requireToken(): string {
const value = process.env.AV_CLUB_TOKEN;
if (!value || value.length < 16) {
throw new Error(
"AV_CLUB_TOKEN must be set and at least 16 characters. Generate one with: openssl rand -hex 32",
);
}
return value;
}
+56
View File
@@ -0,0 +1,56 @@
/**
* One error envelope for every failure, because a client parses failures the same way it parses
* successes. A route that answers with a bare string does not return the wrong shape — it breaks
* the parse. `error` is always an object.
*/
export const ERROR_STATUS = {
invalid_request: 400,
unauthorized: 401,
not_found: 404,
payload_too_large: 413,
unprocessable: 422,
timeout: 504,
server_error: 500,
} as const;
export type ErrorType = keyof typeof ERROR_STATUS;
export class ApiError extends Error {
constructor(
readonly type: ErrorType,
message: string,
/** Dotted path to the offending field, e.g. `pipeline[1].resize.width`. */
readonly param?: string,
) {
super(message);
this.name = "ApiError";
}
toResponse(): Response {
return Response.json(
{ error: { message: this.message, type: this.type, param: this.param ?? null } },
{ status: ERROR_STATUS[this.type] },
);
}
}
export const invalid = (message: string, param?: string) => new ApiError("invalid_request", message, param);
export const notFound = (message = "No such asset") => new ApiError("not_found", message);
export const unprocessable = (message: string, param?: string) => new ApiError("unprocessable", message, param);
/**
* Turn anything thrown into a response.
*
* An unrecognised error is logged and answered with a generic 500 — the message may name a path or
* quote file contents, and neither belongs in a response body.
*/
export function toErrorResponse(error: unknown): Response {
if (error instanceof ApiError) return error.toResponse();
console.error("[av-club] unhandled:", error);
return Response.json(
{ error: { message: "Internal Server Error", type: "server_error", param: null } },
{ status: 500 },
);
}
+62
View File
@@ -0,0 +1,62 @@
import { JOB_TIMEOUT_MS } from "../config";
import { unprocessable } from "../errors";
/**
* ffprobe, with its own field names passed through untouched.
*
* `codec_name`, `r_frame_rate`, `nb_frames` and the rest reach the caller exactly as ffprobe emits
* them. Renaming them into some house style would mean maintaining a translation table and would
* make ffprobe's own documentation stop applying to this API, which is the opposite of the point.
*/
export type FfprobeStream = {
index: number;
codec_type?: string;
codec_name?: string;
width?: number;
height?: number;
[key: string]: unknown;
};
export type FfprobeResult = {
format?: { format_name?: string; duration?: string; bit_rate?: string; [key: string]: unknown };
streams?: FfprobeStream[];
};
export async function ffprobe(path: string): Promise<FfprobeResult> {
// Spawned as an argv ARRAY, never a command string — there is no shell here to quote for, so a
// filename containing shell metacharacters is inert data. See docs/SECURITY.md, Layer 1.
//
// `-protocol_whitelist file` is the second lock: `path` is always one this process constructed,
// but if that ever stops being true, ffprobe still cannot be talked into speaking http.
const proc = Bun.spawn(
[
"ffprobe",
"-v", "error",
"-protocol_whitelist", "file",
"-print_format", "json",
"-show_format",
"-show_streams",
path,
],
{ stdout: "pipe", stderr: "pipe", stdin: "ignore" },
);
const timer = setTimeout(() => proc.kill(), JOB_TIMEOUT_MS);
try {
const [stdout, stderr, code] = await Promise.all([
new Response(proc.stdout).text(),
new Response(proc.stderr).text(),
proc.exited,
]);
if (code !== 0) {
throw unprocessable(`Could not read that file: ${stderr.trim().split("\n").at(-1) ?? "ffprobe failed"}`);
}
return JSON.parse(stdout) as FfprobeResult;
} finally {
clearTimeout(timer);
}
}
+188
View File
@@ -0,0 +1,188 @@
import { timingSafeEqual } from "node:crypto";
import { ASSETS_DIR, MAX_CONCURRENT_JOBS, MAX_UPLOAD_BYTES, PORT, requireToken } from "./config";
import { ApiError, invalid, notFound, toErrorResponse } from "./errors";
import { contentPath, deleteAsset, putAsset, readAsset } from "./assets/store";
import { ffprobe } from "./probe/ffprobe";
import { runSharp, type SharpRequest } from "./sharp/run";
import sharp from "sharp";
/**
* av-club — ffmpeg, sharp and ffprobe over HTTP.
*
* The security model is in docs/SECURITY.md and is not optional reading: the caller never names a
* path, and that single invariant is what makes it safe to expose the rest of these tools' surface.
*/
const encoder = new TextEncoder();
// Read once, at boot, so a missing token stops the process here rather than at the first request.
const TOKEN = requireToken();
/**
* Constant-time bearer check.
*
* `===` on secrets leaks their length and their common prefix through timing. The cost of doing it
* properly is three lines.
*/
function authorised(request: Request): boolean {
const header = request.headers.get("authorization");
if (!header?.startsWith("Bearer ")) return false;
const provided = encoder.encode(header.slice(7));
const expected = encoder.encode(TOKEN);
if (provided.byteLength !== expected.byteLength) return false;
return timingSafeEqual(provided, expected);
}
/**
* Bound concurrent processing.
*
* sharp and ffmpeg will each take every core available. Without this, two large requests can make
* the box unresponsive to everything including the health check.
*/
let running = 0;
const queue: (() => void)[] = [];
async function withSlot<T>(work: () => Promise<T>): Promise<T> {
if (running >= MAX_CONCURRENT_JOBS) {
await new Promise<void>((resolve) => queue.push(resolve));
}
running++;
try {
return await work();
} finally {
running--;
queue.shift()?.();
}
}
/** Serve stored bytes, honouring Range — browsers will not scrub a <video> served without it. */
async function serveContent(id: string, request: Request): Promise<Response> {
const asset = await readAsset(id);
const file = Bun.file(contentPath(id));
const type =
asset.kind === "image"
? `image/${asset.format === "jpeg" ? "jpeg" : asset.format}`
: file.type || "application/octet-stream";
const range = request.headers.get("range");
const match = range?.match(/^bytes=(\d*)-(\d*)$/);
if (match) {
const size = asset.bytes;
const start = match[1] ? Number(match[1]) : 0;
const end = match[2] ? Math.min(Number(match[2]), size - 1) : size - 1;
if (!Number.isFinite(start) || start > end || start >= size) {
return new Response(null, { status: 416, headers: { "Content-Range": `bytes */${size}` } });
}
return new Response(file.slice(start, end + 1), {
status: 206,
headers: {
"Content-Type": type,
"Content-Range": `bytes ${start}-${end}/${size}`,
"Accept-Ranges": "bytes",
"Content-Length": String(end - start + 1),
},
});
}
return new Response(file, {
headers: {
"Content-Type": type,
"Accept-Ranges": "bytes",
// Every id is unique and content never changes under one, so this is honest.
"Cache-Control": "public, max-age=31536000, immutable",
"X-Content-Type-Options": "nosniff",
},
});
}
async function handleUpload(request: Request): Promise<Response> {
const form = await request.formData().catch(() => {
throw invalid("Expected a multipart/form-data body with a `file` field");
});
const file = form.get("file");
if (!(file instanceof File)) throw invalid("Expected a `file` field containing a file", "file");
if (file.size > MAX_UPLOAD_BYTES) {
throw new ApiError(
"payload_too_large",
`That file is ${(file.size / 1_048_576).toFixed(1)} MB. The limit is ${(MAX_UPLOAD_BYTES / 1_048_576).toFixed(0)} MB.`,
);
}
const asset = await withSlot(async () => putAsset(await file.arrayBuffer()));
return Response.json(asset, { status: 201 });
}
async function route(request: Request): Promise<Response> {
const url = new URL(request.url);
const path = url.pathname;
if (path === "/health") {
return Response.json({ ok: true, service: "av-club", running, queued: queue.length });
}
if (!authorised(request)) {
return new ApiError("unauthorized", "Missing or invalid bearer token").toResponse();
}
if (path === "/v1/assets" && request.method === "POST") return handleUpload(request);
const asset = path.match(/^\/v1\/assets\/([^/]+)(\/content|\/probe)?$/);
if (asset) {
const [, id, suffix] = asset;
if (suffix === "/content" && request.method === "GET") return serveContent(id!, request);
if (suffix === "/probe" && request.method === "GET") {
const stored = await readAsset(id!);
if (stored.kind === "image") {
const metadata = await sharp(contentPath(id!)).metadata();
return Response.json({ kind: "image", metadata });
}
const probe = await withSlot(() => ffprobe(contentPath(id!)));
return Response.json({ kind: stored.kind, ...probe });
}
if (!suffix && request.method === "GET") return Response.json(await readAsset(id!));
if (!suffix && request.method === "DELETE") {
if (!(await readAsset(id!).then(() => true).catch(() => false))) throw notFound();
await deleteAsset(id!);
return new Response(null, { status: 204 });
}
}
if (path === "/v1/sharp" && request.method === "POST") {
const body = (await request.json().catch(() => {
throw invalid("Expected a JSON body");
})) as SharpRequest;
const result = await withSlot(() => runSharp(body));
return Response.json(result, { status: 201 });
}
if (path === "/v1/ffmpeg") {
throw new ApiError("invalid_request", "POST /v1/ffmpeg is not implemented yet. See docs/API.md.");
}
throw notFound(`No route for ${request.method} ${path}`);
}
const server = Bun.serve({
port: PORT,
// Bun's default is 10 seconds, which silently kills a long encode mid-flight.
idleTimeout: 255,
maxRequestBodySize: MAX_UPLOAD_BYTES,
fetch: (request) => route(request).catch(toErrorResponse),
});
console.log(`av-club on ${server.url}`);
console.log(` assets ${ASSETS_DIR}`);
console.log(` max upload ${(MAX_UPLOAD_BYTES / 1_048_576).toFixed(0)} MB`);
console.log(` concurrency ${MAX_CONCURRENT_JOBS}`);
+134
View File
@@ -0,0 +1,134 @@
import sharp from "sharp";
import { MAX_INPUT_PIXELS } from "../config";
import { invalid, unprocessable } from "../errors";
import { contentPath, putAsset, readAsset, type Asset } from "../assets/store";
import { INPUT_OPTIONS, OPERATIONS, OUTPUT_FORMATS } from "./vocabulary";
import { validateObject } from "./validate";
export type SharpRequest = {
input?: unknown;
options?: unknown;
pipeline?: unknown;
output?: unknown;
};
/**
* Run a validated sharp pipeline and store the result as a new asset.
*
* The input asset is never modified — every operation produces a new asset. That is what makes it
* safe to re-run a pipeline with different settings six months from now, which is the whole reason
* originals are kept (docs/PIPELINE.md: "The original is never destroyed").
*/
export async function runSharp(body: SharpRequest): Promise<Asset> {
if (typeof body.input !== "string") {
throw invalid("input must be an asset id", "input");
}
const source = await readAsset(body.input);
if (source.kind !== "image") {
throw invalid(`Asset ${source.id} is ${source.kind}, not an image. Use /v1/ffmpeg for video and audio.`, "input");
}
const inputOptions = validateObject(INPUT_OPTIONS, body.options, "options");
// limitInputPixels is set HERE and cannot be supplied by the caller: it is the decompression-bomb
// guard, and a caller who could raise it could turn a 1 KB PNG into a 40 GB allocation.
let image = sharp(contentPath(source.id), {
...inputOptions,
limitInputPixels: MAX_INPUT_PIXELS,
});
// ── The pipeline ─────────────────────────────────────────────────────────────────────────────
const steps = body.pipeline ?? [];
if (!Array.isArray(steps)) throw invalid("pipeline must be an array of operations", "pipeline");
for (const [i, step] of steps.entries()) {
const at = `pipeline[${i}]`;
if (!step || typeof step !== "object" || Array.isArray(step)) {
throw invalid(`${at} must be an object like { "resize": { "width": 800 } }`, at);
}
const entries = Object.entries(step as Record<string, unknown>);
if (entries.length !== 1) {
throw invalid(`${at} must name exactly one operation, got ${entries.length}`, at);
}
const [name, rawArgs] = entries[0]!;
const spec = OPERATIONS[name];
if (!spec) {
throw invalid(
`Unknown operation ${JSON.stringify(name)}. See docs/SHARP.md for the full list.`,
`${at}.${name}`,
);
}
const args = validateObject(spec.params, rawArgs, `${at}.${name}`);
const method = (image as unknown as Record<string, (...a: unknown[]) => typeof image>)[name];
if (typeof method !== "function") {
// The vocabulary named something sharp does not have — our bug, not the caller's.
throw new Error(`vocabulary lists ${name}, but sharp has no such method`);
}
try {
// Operations sharp declares positionally — median(size), blur(sigma) — are called that way.
// Everything else takes an options object.
if (spec.positional && spec.positional in args) {
const positional = args[spec.positional];
const rest = { ...args };
delete rest[spec.positional];
image = Object.keys(rest).length
? method.call(image, positional, rest)
: method.call(image, positional);
} else if (Object.keys(args).length === 0) {
image = method.call(image);
} else if (name === "composite") {
// composite() takes an ARRAY, and its `input` is an asset id that only the store may turn
// into a path. This is the one operation that reads a second file, and it is the reason
// `assetId` exists as a parameter kind.
const overlay = await readAsset(args.input as string);
if (overlay.kind !== "image") {
throw invalid(`composite input ${overlay.id} is ${overlay.kind}, not an image`, `${at}.composite.input`);
}
const { input: _dropped, ...placement } = args;
image = image.composite([{ input: contentPath(overlay.id), ...placement } as never]);
} else {
image = method.call(image, args);
}
} catch (error) {
if (error instanceof Error && !("type" in error)) {
throw invalid(`${name} failed: ${error.message}`, `${at}.${name}`);
}
throw error;
}
}
// ── Output ───────────────────────────────────────────────────────────────────────────────────
const output = (body.output ?? {}) as Record<string, unknown>;
if (typeof output !== "object" || Array.isArray(output)) {
throw invalid("output must be an object", "output");
}
const format = output.format ?? "webp";
if (typeof format !== "string" || !OUTPUT_FORMATS[format]) {
throw invalid(
`output.format must be one of: ${Object.keys(OUTPUT_FORMATS).join(", ")}`,
"output.format",
);
}
const encoderOptions = validateObject(OUTPUT_FORMATS[format]!.params, output.options, "output.options");
const encode = (image as unknown as Record<string, (o: unknown) => typeof image>)[format]!;
let buffer: Buffer;
try {
buffer = await encode.call(image, encoderOptions).toBuffer();
} catch (error) {
// A failure here is usually the source file, not the request — a truncated JPEG, a PNG whose
// declared dimensions disagree with its data.
throw unprocessable(
`Could not process that image: ${error instanceof Error ? error.message : String(error)}`,
);
}
return putAsset(buffer);
}
+137
View File
@@ -0,0 +1,137 @@
import { invalid } from "../errors";
import type { Param } from "./vocabulary";
/**
* Validate caller-supplied values against the vocabulary table.
*
* Default-deny throughout: an unknown operation, an unknown parameter, or a value of the wrong shape
* is refused. Nothing is coerced and nothing is silently dropped — a typo'd parameter that was
* quietly ignored would look exactly like an option that does not work.
*
* Error messages name the exact path (`pipeline[1].resize.width`) and say what was expected, because
* the thing on the other end is usually relaying them to a person.
*/
const ID_PATTERN = /^as_[0-9a-f]{32}$/;
/** sharp accepts a CSS colour string or {r,g,b,alpha}. Both are data — neither can reference a file. */
function validateColor(value: unknown, path: string): unknown {
if (typeof value === "string") {
if (value.length > 64) throw invalid("Colour string is too long", path);
return value;
}
if (value && typeof value === "object" && !Array.isArray(value)) {
const obj = value as Record<string, unknown>;
for (const key of Object.keys(obj)) {
if (!["r", "g", "b", "alpha"].includes(key)) {
throw invalid(`Unknown colour component ${JSON.stringify(key)}. Expected r, g, b or alpha.`, path);
}
const component = obj[key];
if (typeof component !== "number" || !Number.isFinite(component)) {
throw invalid(`Colour component ${key} must be a number`, `${path}.${key}`);
}
}
return obj;
}
throw invalid("Expected a colour: a CSS string like '#ffffff', or {r, g, b, alpha}", path);
}
export function validateValue(spec: Param, value: unknown, path: string): unknown {
switch (spec.kind) {
case "int": {
if (typeof value !== "number" || !Number.isInteger(value)) {
throw invalid(`${path} must be an integer`, path);
}
if (value < spec.min || value > spec.max) {
throw invalid(`${path} must be between ${spec.min} and ${spec.max}, got ${value}`, path);
}
return value;
}
case "number": {
if (typeof value !== "number" || !Number.isFinite(value)) {
throw invalid(`${path} must be a number`, path);
}
if (value < spec.min || value > spec.max) {
throw invalid(`${path} must be between ${spec.min} and ${spec.max}, got ${value}`, path);
}
return value;
}
case "bool": {
if (typeof value !== "boolean") throw invalid(`${path} must be true or false`, path);
return value;
}
case "enum": {
if (typeof value !== "string" || !spec.values.includes(value)) {
throw invalid(`${path} must be one of: ${spec.values.join(", ")}`, path);
}
return value;
}
case "color":
return validateColor(value, path);
case "numberArray": {
if (!Array.isArray(value)) throw invalid(`${path} must be an array of numbers`, path);
if (value.length < spec.minLength || value.length > spec.maxLength) {
throw invalid(
`${path} must have between ${spec.minLength} and ${spec.maxLength} entries, got ${value.length}`,
path,
);
}
value.forEach((entry, i) => {
if (typeof entry !== "number" || !Number.isFinite(entry)) {
throw invalid(`${path}[${i}] must be a number`, `${path}[${i}]`);
}
if (entry < spec.min || entry > spec.max) {
throw invalid(`${path}[${i}] must be between ${spec.min} and ${spec.max}`, `${path}[${i}]`);
}
});
return value;
}
case "assetId": {
// The ONLY way a caller may reference another file. Checked here against the same pattern the
// store issues, then resolved to a path by the store and nowhere else. See docs/SECURITY.md.
if (typeof value !== "string" || !ID_PATTERN.test(value)) {
throw invalid(`${path} must be an asset id (as_ followed by 32 hex characters)`, path);
}
return value;
}
case "string": {
if (typeof value !== "string") throw invalid(`${path} must be a string`, path);
if (value.length > spec.maxLength) {
throw invalid(`${path} must be at most ${spec.maxLength} characters`, path);
}
return value;
}
}
}
/** Validate an options object against a table of parameter specs. Unknown keys are refused. */
export function validateObject(
params: Record<string, Param>,
value: unknown,
path: string,
): Record<string, unknown> {
if (value === undefined || value === null) return {};
if (typeof value !== "object" || Array.isArray(value)) {
throw invalid(`${path} must be an object`, path);
}
const input = value as Record<string, unknown>;
const out: Record<string, unknown> = {};
for (const [key, raw] of Object.entries(input)) {
const spec = params[key];
if (!spec) {
const known = Object.keys(params);
throw invalid(
known.length
? `Unknown option ${JSON.stringify(key)}. Accepted here: ${known.join(", ")}`
: `${path} takes no options, got ${JSON.stringify(key)}`,
`${path}.${key}`,
);
}
if (raw === undefined) continue;
out[key] = validateValue(spec, raw, `${path}.${key}`);
}
return out;
}
+340
View File
@@ -0,0 +1,340 @@
import { MAX_OUTPUT_DIMENSION } from "../config";
/**
* The sharp surface av-club exposes, as data.
*
* Every operation is named what SHARP names it and takes SHARP's arguments, so sharp's own
* documentation is the reference. This table is the single source of truth for three things that
* would otherwise drift apart:
*
* 1. what the validator accepts
* 2. what docs/SHARP.md says is accepted
* 3. what is actually called
*
* Adding an operation is one entry here.
*
* ── Why a table rather than "pass the options through" ──
*
* sharp is memory-safe in the ways ffmpeg is not — it takes buffers and returns buffers, and has no
* protocol handlers, no plugin loading and no filesystem syntax. Its danger is RESOURCE
* EXHAUSTION rather than code execution: `resize(50000, 50000)` is an allocation request, `blur(10000)`
* is a very long time. So every numeric parameter is bounded, and the bound is written next to the
* parameter rather than checked somewhere else.
*/
export type Param =
| { kind: "int"; min: number; max: number; note?: string }
| { kind: "number"; min: number; max: number; note?: string }
| { kind: "bool"; note?: string }
| { kind: "enum"; values: readonly string[]; note?: string }
| { kind: "color"; note?: string }
| { kind: "numberArray"; minLength: number; maxLength: number; min: number; max: number; note?: string }
| { kind: "assetId"; note?: string }
| { kind: "string"; maxLength: number; note?: string };
export type OpSpec = {
/** One line, used in docs/SHARP.md. */
describe: string;
params: Record<string, Param>;
/** Operations that take a bare value rather than an options object, e.g. `median(size)`. */
positional?: string;
};
const DIM = MAX_OUTPUT_DIMENSION;
/** sharp's constructor options. */
export const INPUT_OPTIONS: Record<string, Param> = {
failOn: { kind: "enum", values: ["none", "truncated", "error", "warning"], note: "How strict to be about a damaged file. sharp's default is 'warning'." },
density: { kind: "number", min: 1, max: 2400, note: "DPI for vector input." },
pages: { kind: "int", min: -1, max: 1000, note: "Pages to read from a multi-page input. -1 for all." },
page: { kind: "int", min: 0, max: 100_000, note: "First page to read." },
animated: { kind: "bool", note: "Read all frames of an animated GIF/WebP. Equivalent to pages: -1." },
ignoreIcc: { kind: "bool", note: "Ignore any embedded ICC profile." },
autoOrient: { kind: "bool", note: "Apply EXIF orientation on load. See the note about rotate() in docs/SHARP.md." },
sequentialRead: { kind: "bool" },
level: { kind: "int", min: 0, max: 256, note: "Level to extract from a multi-level input." },
subifd: { kind: "int", min: -1, max: 100_000, note: "Subldeaf IFD to extract from an OME-TIFF." },
};
/**
* The pipeline operations.
*
* `limitInputPixels` and `withMetadata` are DELIBERATELY ABSENT and must stay that way:
* - limitInputPixels is the decompression-bomb guard and is set by the server, not the caller.
* - withMetadata re-attaches EXIF, which is how a client's GPS coordinates get published.
* See docs/SECURITY.md.
*/
export const OPERATIONS: Record<string, OpSpec> = {
// ── Resizing ───────────────────────────────────────────────────────────────────────────────
resize: {
describe: "Resize to given dimensions.",
params: {
width: { kind: "int", min: 1, max: DIM },
height: { kind: "int", min: 1, max: DIM },
fit: { kind: "enum", values: ["cover", "contain", "fill", "inside", "outside"], note: "How the image fits the box. Default 'cover'." },
position: { kind: "enum", values: ["centre", "center", "top", "right top", "right", "right bottom", "bottom", "left bottom", "left", "left top", "north", "northeast", "east", "southeast", "south", "southwest", "west", "northwest", "entropy", "attention"], note: "Which part survives when fit is 'cover'. 'attention' picks the busiest region." },
background: { kind: "color", note: "Fill colour when fit is 'contain'." },
kernel: { kind: "enum", values: ["nearest", "linear", "cubic", "mitchell", "lanczos2", "lanczos3"] },
withoutEnlargement: { kind: "bool", note: "Never scale UP. Almost always what you want — see docs/SHARP.md." },
withoutReduction: { kind: "bool", note: "Never scale DOWN." },
fastShrinkOnLoad: { kind: "bool" },
},
},
extend: {
describe: "Add pixels to the edges.",
params: {
top: { kind: "int", min: 0, max: DIM },
bottom: { kind: "int", min: 0, max: DIM },
left: { kind: "int", min: 0, max: DIM },
right: { kind: "int", min: 0, max: DIM },
extendWith: { kind: "enum", values: ["background", "copy", "repeat", "mirror"] },
background: { kind: "color" },
},
},
extract: {
describe: "Crop to a region.",
params: {
left: { kind: "int", min: 0, max: DIM },
top: { kind: "int", min: 0, max: DIM },
width: { kind: "int", min: 1, max: DIM },
height: { kind: "int", min: 1, max: DIM },
},
},
trim: {
describe: "Trim uniform borders.",
params: {
background: { kind: "color" },
threshold: { kind: "number", min: 0, max: 255 },
lineArt: { kind: "bool" },
},
},
// ── Orientation ────────────────────────────────────────────────────────────────────────────
rotate: {
describe: "Rotate. With no angle, applies the EXIF orientation — see docs/SHARP.md.",
params: {
angle: { kind: "number", min: -3600, max: 3600, note: "Multiples of 90 are lossless; anything else needs a background." },
background: { kind: "color" },
},
},
flip: { describe: "Mirror vertically.", params: { flip: { kind: "bool" } }, positional: "flip" },
flop: { describe: "Mirror horizontally.", params: { flop: { kind: "bool" } }, positional: "flop" },
// ── Pixel operations ───────────────────────────────────────────────────────────────────────
sharpen: {
describe: "Sharpen.",
params: {
sigma: { kind: "number", min: 0.000001, max: 10 },
m1: { kind: "number", min: 0, max: 1_000_000 },
m2: { kind: "number", min: 0, max: 1_000_000 },
x1: { kind: "number", min: 0, max: 1_000_000 },
y2: { kind: "number", min: 0, max: 1_000_000 },
y3: { kind: "number", min: 0, max: 1_000_000 },
},
},
blur: {
describe: "Gaussian blur.",
params: { sigma: { kind: "number", min: 0.3, max: 1000 } },
positional: "sigma",
},
median: {
describe: "Median filter — good for noise.",
params: { size: { kind: "int", min: 1, max: 1000 } },
positional: "size",
},
dilate: { describe: "Morphological dilate.", params: { width: { kind: "int", min: 1, max: 1000 } }, positional: "width" },
erode: { describe: "Morphological erode.", params: { width: { kind: "int", min: 1, max: 1000 } }, positional: "width" },
flatten: { describe: "Composite transparency onto a background.", params: { background: { kind: "color" } } },
unflatten: { describe: "Make pure white pixels transparent.", params: {} },
gamma: {
describe: "Apply gamma correction.",
params: { gamma: { kind: "number", min: 1, max: 3 }, gammaOut: { kind: "number", min: 1, max: 3 } },
},
negate: { describe: "Invert.", params: { alpha: { kind: "bool", note: "Also invert the alpha channel." } } },
normalise: {
describe: "Stretch the histogram to full range.",
params: { lower: { kind: "number", min: 0, max: 99 }, upper: { kind: "number", min: 1, max: 100 } },
},
clahe: {
describe: "Contrast-limited adaptive histogram equalisation.",
params: {
width: { kind: "int", min: 1, max: DIM },
height: { kind: "int", min: 1, max: DIM },
maxSlope: { kind: "int", min: 0, max: 100 },
},
},
convolve: {
describe: "Apply a convolution kernel.",
params: {
width: { kind: "int", min: 1, max: 100 },
height: { kind: "int", min: 1, max: 100 },
kernel: { kind: "numberArray", minLength: 1, maxLength: 10_000, min: -1000, max: 1000 },
scale: { kind: "number", min: -10_000, max: 10_000 },
offset: { kind: "number", min: -10_000, max: 10_000 },
},
},
threshold: {
describe: "Reduce to black and white at a cutoff.",
params: { threshold: { kind: "int", min: 0, max: 255 }, greyscale: { kind: "bool" } },
},
linear: {
describe: "Multiply and add: out = in * a + b.",
params: {
a: { kind: "numberArray", minLength: 1, maxLength: 4, min: -1000, max: 1000 },
b: { kind: "numberArray", minLength: 1, maxLength: 4, min: -1000, max: 1000 },
},
},
recomb: {
describe: "Recombine channels with a 3x3 matrix.",
params: { matrix: { kind: "numberArray", minLength: 9, maxLength: 9, min: -100, max: 100 } },
positional: "matrix",
},
modulate: {
describe: "Adjust brightness, saturation, hue and lightness.",
params: {
brightness: { kind: "number", min: 0, max: 100 },
saturation: { kind: "number", min: 0, max: 100 },
hue: { kind: "number", min: -3600, max: 3600, note: "Degrees." },
lightness: { kind: "number", min: -100, max: 100 },
},
},
// ── Colour ─────────────────────────────────────────────────────────────────────────────────
tint: { describe: "Tint with a colour.", params: { color: { kind: "color" } }, positional: "color" },
greyscale: { describe: "Convert to greyscale.", params: { greyscale: { kind: "bool" } }, positional: "greyscale" },
toColourspace: {
describe: "Convert to a colourspace.",
params: { colourspace: { kind: "enum", values: ["srgb", "rgb", "cmyk", "lab", "b-w", "hsv", "scrgb", "lch", "grey16", "rgb16"] } },
positional: "colourspace",
},
pipelineColourspace: {
describe: "Set the colourspace operations are performed in.",
params: { colourspace: { kind: "enum", values: ["srgb", "rgb", "cmyk", "lab", "b-w", "hsv", "scrgb", "lch", "grey16", "rgb16"] } },
positional: "colourspace",
},
// ── Channels ───────────────────────────────────────────────────────────────────────────────
removeAlpha: { describe: "Drop the alpha channel.", params: {} },
ensureAlpha: { describe: "Add an alpha channel if absent.", params: { alpha: { kind: "number", min: 0, max: 1 } }, positional: "alpha" },
extractChannel: {
describe: "Keep one channel.",
params: { channel: { kind: "enum", values: ["red", "green", "blue", "alpha", "0", "1", "2", "3"] } },
positional: "channel",
},
bandbool: {
describe: "Boolean-combine all channels into one.",
params: { operator: { kind: "enum", values: ["and", "or", "eor"] } },
positional: "operator",
},
// ── Composite ──────────────────────────────────────────────────────────────────────────────
//
// `input` is an ASSET ID, not a path. That is what makes compositing safe to expose at all:
// sharp's own composite() takes a filename, which would hand the caller arbitrary file reads.
composite: {
describe: "Overlay another asset. `input` is an asset id, never a path.",
params: {
input: { kind: "assetId", note: "The asset to overlay. Must already be uploaded." },
blend: { kind: "enum", values: ["clear", "source", "over", "in", "out", "atop", "dest", "dest-over", "dest-in", "dest-out", "dest-atop", "xor", "add", "saturate", "multiply", "screen", "overlay", "darken", "lighten", "colour-dodge", "color-dodge", "colour-burn", "color-burn", "hard-light", "soft-light", "difference", "exclusion"] },
gravity: { kind: "enum", values: ["centre", "center", "north", "northeast", "east", "southeast", "south", "southwest", "west", "northwest"] },
top: { kind: "int", min: 0, max: DIM },
left: { kind: "int", min: 0, max: DIM },
tile: { kind: "bool" },
premultiplied: { kind: "bool" },
},
},
};
/** Output encoders, named as sharp names them, with sharp's own option names. */
export const OUTPUT_FORMATS: Record<string, { describe: string; params: Record<string, Param> }> = {
jpeg: {
describe: "JPEG.",
params: {
quality: { kind: "int", min: 1, max: 100 },
progressive: { kind: "bool" },
chromaSubsampling: { kind: "enum", values: ["4:4:4", "4:2:2", "4:2:0"] },
optimiseCoding: { kind: "bool" },
mozjpeg: { kind: "bool", note: "Turns on a bundle of mozjpeg settings at once." },
trellisQuantisation: { kind: "bool" },
overshootDeringing: { kind: "bool" },
optimiseScans: { kind: "bool" },
},
},
png: {
describe: "PNG.",
params: {
progressive: { kind: "bool" },
compressionLevel: { kind: "int", min: 0, max: 9 },
adaptiveFiltering: { kind: "bool" },
palette: { kind: "bool", note: "Quantise to a palette — much smaller for flat graphics." },
quality: { kind: "int", min: 0, max: 100 },
effort: { kind: "int", min: 1, max: 10 },
colours: { kind: "int", min: 2, max: 256 },
dither: { kind: "number", min: 0, max: 1 },
},
},
webp: {
describe: "WebP.",
params: {
quality: { kind: "int", min: 1, max: 100 },
alphaQuality: { kind: "int", min: 0, max: 100 },
lossless: { kind: "bool" },
nearLossless: { kind: "bool" },
smartSubsample: { kind: "bool" },
preset: { kind: "enum", values: ["default", "photo", "picture", "drawing", "icon", "text"] },
effort: { kind: "int", min: 0, max: 6 },
loop: { kind: "int", min: 0, max: 100_000 },
minSize: { kind: "bool" },
mixed: { kind: "bool" },
},
},
avif: {
describe: "AVIF. Smaller than WebP at the same quality, slower to encode.",
params: {
quality: { kind: "int", min: 1, max: 100 },
lossless: { kind: "bool" },
effort: { kind: "int", min: 0, max: 9, note: "Higher is smaller and much slower." },
chromaSubsampling: { kind: "enum", values: ["4:4:4", "4:2:0"] },
bitdepth: { kind: "enum", values: ["8", "10", "12"] },
},
},
tiff: {
describe: "TIFF.",
params: {
quality: { kind: "int", min: 1, max: 100 },
compression: { kind: "enum", values: ["none", "jpeg", "deflate", "packbits", "ccittfax4", "lzw", "webp", "zstd", "jp2k"] },
predictor: { kind: "enum", values: ["none", "horizontal", "float"] },
pyramid: { kind: "bool" },
tile: { kind: "bool" },
tileWidth: { kind: "int", min: 1, max: DIM },
tileHeight: { kind: "int", min: 1, max: DIM },
xres: { kind: "number", min: 0.01, max: 100_000 },
yres: { kind: "number", min: 0.01, max: 100_000 },
resolutionUnit: { kind: "enum", values: ["inch", "cm"] },
bitdepth: { kind: "enum", values: ["1", "2", "4", "8"] },
},
},
gif: {
describe: "GIF.",
params: {
reuse: { kind: "bool" },
progressive: { kind: "bool" },
colours: { kind: "int", min: 2, max: 256 },
effort: { kind: "int", min: 1, max: 10 },
dither: { kind: "number", min: 0, max: 1 },
interFrameMaxError: { kind: "number", min: 0, max: 32 },
interPaletteMaxError: { kind: "number", min: 0, max: 256 },
loop: { kind: "int", min: 0, max: 100_000 },
},
},
jp2: {
describe: "JPEG 2000.",
params: {
quality: { kind: "int", min: 1, max: 100 },
lossless: { kind: "bool" },
tileWidth: { kind: "int", min: 1, max: DIM },
tileHeight: { kind: "int", min: 1, max: DIM },
chromaSubsampling: { kind: "enum", values: ["4:4:4", "4:2:2", "4:2:0"] },
},
},
};