docs: move the reference site to astro starlight (#2527)

This commit is contained in:
Hampus authored and GitHub committed 2026-09-06 15:40:22 +02:00
1 parent 7f8f09ee51
commit 5367c0ab42
198 files changed
+59927 -1216

No files matched your search

@@ -0,0 +1,194 @@
---
# SPDX-License-Identifier: AGPL-3.0-or-later
title: Media Proxy overview
description: Media Proxy surfaces, methods, byte ranges, response headers, and cache behaviour.
---
The Media Proxy serves Fluxer attachments, image assets, themes, entrance sound audio, and signed external media. It also accepts uploads authorised by a capability, a signed grant in the upload URL.
## Route families
| Family | Paths | Reference |
| --- | --- | --- |
| Attachment | `/attachments/{path}` | [Get attachment](/media-proxy/routes/#get-attachment) |
| Signed external | `/external/{signature}/{target}` | [Get signed external media](/media-proxy/routes/#get-signed-external-media) |
| Theme CSS | `/themes/{path}.css` | [Get theme CSS](/media-proxy/routes/#get-theme-css) |
| Entrance sound | `/entrance-sounds/{user_id}/{filename}` | [Get entrance sound](/media-proxy/routes/#get-entrance-sound) |
| Image asset<sup>1</sup> | `/avatars`, `/icons`, `/branding`, `/banners`, `/splashes`, `/embed-splashes`, `/emojis`, `/stickers` | [Image asset contract](/media-proxy/routes/#image-asset-contract) |
| Static object<sup>2</sup> | `/{key}` | [Get static object](/media-proxy/routes/#get-static-object) |
| Upload relay | `/v1/relay/{key}` | [Upload relay](/media-proxy/upload-relay/) |
| Operator and internal | `/_health`, `/_metrics`, `/_metadata`, `/_thumbnail`, `/_frames` | [Operator and internal endpoints](/media-proxy/routes/#operator-and-internal-endpoints) |
<sup>1</sup> Guild member avatars and banners are image assets too, at `/guilds/{guild_id}/users/{user_id}/avatars` and `/guilds/{guild_id}/users/{user_id}/banners`
<sup>2</sup> Served by a `static` endpoint alone
[Transformations](/media-proxy/transformations/) defines representation selection for every family that has one. [Responses and limits](/media-proxy/responses-and-limits/) consolidates statuses, size bounds, deadlines, and cache policies.
## Base URLs
Read paths have no `/v1` prefix. Take `endpoints.media` and `endpoints.static_cdn` from [instance discovery](/http-api/instance/#instance-endpoints-object).
The upload relay is the exception in both respects. It is served below `/v1/relay/`, and its base comes from a deployment setting. A client MUST take that base from the upload URL the HTTP API issued and MUST NOT rebuild one.
## Authorisation
No public Media Proxy route reads the HTTP API `Authorization` header. A stored object is authorised by its path, signed external media by its path signature, and an upload by the capability in its URL. Only the internal `POST` endpoints read `Authorization`, and they require the exact value `Bearer {secret}` built from the deployment secret.
:::caution[A media URL is a bearer capability]
An attachment path has no signature and no expiry, so anyone holding the URL reads the object for as long as it exists. Leaving the channel does not revoke a URL already issued.
:::
The Media Proxy has no request-count rate limit, so no response has the [rate limit headers](/topics/rate-limits/#rate-limit-headers) of the versioned HTTP API. A failure returns a short `text/plain` body, and [media error response](/media-proxy/responses-and-limits/#media-error-response) defines that shape.
## Deployment modes
One Media Proxy process serves exactly one mode. The mode is fixed at startup and defaults to `mp`.
| Mode | Serves |
| --- | --- |
| `mp` | Attachments, signed external media, themes, entrance sounds, and every image asset route |
| `static` | Every read path as a raw object read from the static bucket, with no transformation and no SVG rasterisation<sup>1</sup> |
| `upload` | The [upload relay](/media-proxy/upload-relay/), plus every `mp` read route from the same buckets |
<sup>1</sup> A `static` mode endpoint also strips `X-Robots-Tag` from every response and sends no `Content-Disposition`
The relay `PUT` is the only route with a mode gate, and it returns 404 outside `upload` mode. On a read that requests no transformation, only an `mp` endpoint rasterises SVG, so an `upload` endpoint returns the original SVG bytes. The [operator and internal endpoints](/media-proxy/routes/#operator-and-internal-endpoints) behave the same in every mode.
Which published base URL serves which mode is a deployment choice. The reference self-hosted deployment serves `endpoints.media` from an `upload` mode process.
## Methods
Every read route accepts `GET` and `HEAD`. HEAD returns the same status and representation headers as GET with an empty body, and a `Range` on HEAD still selects 206 or 416. Any other method on a read path returns 405 with the body `Method Not Allowed`.
The relay path accepts `PUT`. Any other method there returns 405 with an `Allow` header. An unknown path returns 404 with the body `Not Found`.
The [signed external route](/media-proxy/routes/#get-signed-external-media) is the one place a HEAD can answer differently from the matching GET. A HEAD with no range and no transformation is served from an origin HEAD when that origin returns 200, declares a length within the [500 MiB media bound](/media-proxy/responses-and-limits/#request-and-media-limits), and names a non-SVG media type.
:::note[An origin HEAD resolves the type without bytes]
That answer uses the declared type and the filename alone, so an origin that mislabels its bytes produces a different `Content-Type` and `Content-Disposition` than the GET of the same URL.
:::
## Request headers
Only `Range` affects the representation a public read returns. `X-Forwarded-For` decides which address the [media access allowlist](#access-restrictions) evaluates, so it can turn a 200 into a 403 without changing the representation. Every other request header, including `Authorization`, `Cookie`, `Accept`, `If-Range`, `If-None-Match`, and `Origin`, is ignored on a public read route.
### Common request headers
| Field | Type | Description |
| --- | --- | --- |
| Range?<sup>1</sup> | string | One byte range under the [byte-range contract](#byte-ranges) |
| X-Forwarded-For?<sup>2</sup> | string | Client address chain read by the [media access allowlist](#access-restrictions) |
<sup>1</sup> A value whose unit is not the exact lowercase `bytes`, or that names more than one range, is ignored, and the response has the complete representation
<sup>2</sup> Read only when the allowlist gate is enabled and the peer address is a configured trusted proxy. A value from any other peer is ignored
The Media Proxy reads no `If-None-Match`, `If-Modified-Since`, `If-Range`, `If-Match`, or `If-Unmodified-Since`, and it sends no `ETag` or `Last-Modified` on a read route, so a cache revalidates a representation by fetching it again.
## Access restrictions
An operator MAY gate public reads on a CDN edge address allowlist. The gate is disabled by default. When it is enabled, the process fetches the Bunny edge address list at startup and refreshes it every 3600 seconds by default, and a process whose first fetch fails does not start.
A request from an address outside the list returns 403 with the body `origin not in bunny allowlist`. `/_health`, `/_metrics`, `/_metadata`, `/_thumbnail`, `/_frames`, and every path below `/v1/relay/` are exempt.
When the peer address is a configured trusted proxy, the client address is the rightmost `X-Forwarded-For` hop that is not itself a trusted proxy. Otherwise the Media Proxy uses the peer address and ignores `X-Forwarded-For`. It does the same when there is no `X-Forwarded-For`, or when every hop in it is unparseable or is itself a trusted proxy.
## Selector parsing
A read route names its representation in its query string. The attachment, signed external, and image asset routes accept selectors. The theme and entrance sound routes accept none and ignore any query string.
Query names and values use URL form decoding, so `+` decodes to a space and a percent escape decodes to its byte. When a name occurs more than once, the final value wins. An unknown name is ignored.
The Media Proxy canonicalises nothing and issues no redirect for a noncanonical target. Two spellings of the same selection are two separate cache entries.
A Boolean is true only for case-insensitive `true` or the exact value `1`. Every other value, including `false` and `0`, is false. [Transformations](/media-proxy/transformations/) defines dimensions, formats, quality values, and animation flags.
## Byte ranges
This contract governs every range the Media Proxy resolves itself. A range is recognised only when its unit is the exact lowercase `bytes`, so `bytes=0-99` selects an interval and `BYTES=0-99` is ignored. Surrounding spaces and tabs are trimmed. A range containing a comma names multiple ranges and is ignored. A malformed range is ignored and produces the complete representation. `HEAD` applies the same contract as `GET`.
| Value | Description |
| --- | --- |
| `bytes={start}-{end}` | Selects the inclusive interval and clamps an end beyond the representation |
| `bytes={start}-` | Selects from start through the final byte |
| `bytes=-{length}` | Selects the final length bytes and selects the complete representation when length is larger |
A reversed range, a zero-length suffix, a start outside the representation, or any range over an empty representation returns 416 with `Content-Range: bytes */{size}` and `Accept-Ranges: bytes`. A satisfiable range returns 206, `Accept-Ranges: bytes`, and exact `Content-Range` and `Content-Length` values.
### Ranges on the signed external route
The route forwards a range to the origin only when no transformation is requested. It sends the range verbatim when the value after `bytes=` is non-empty and every byte of it is an ASCII graphic character, so a multiple range reaches the origin and the origin decides how to answer it. A value with a space anywhere is dropped, and no range is sent. The route relays the origin partial response with the origin `Content-Range` unchanged.
A transforming request forwards no range to the origin and applies the client range to the transformed bytes, so it still returns 206 or 416. An origin 200 on a non-transforming request is relayed as that 200 when its declared type is trustworthy and the response is not SVG by declared type, filename, or leading bytes. A relayed 200 does not reapply the client range. An SVG response is rasterised and the client range is applied to the rasterised bytes.
A trustworthy type is a normalised `image/`, `video/`, or `audio/` type other than `application/octet-stream`. An absent or empty `Content-Type`, `text/plain`, `application/pdf`, and `application/zip` are all untrustworthy. Fluxer buffers the body of a 200 under an untrustworthy type and applies the client range to those bytes, so that read returns 206.
The route fetches twice in exactly one case. When an origin answers a forwarded range with 206 under a declared SVG media type, Fluxer discards that partial response, fetches the whole object again without a range, rasterises it, and applies the client range to the rasterised bytes.
:::caution[A mislabelled SVG reaches the client as bytes]
The re-fetch tests the declared media type alone. An origin that answers a forwarded range with SVG bytes under another type produces a 206 of raw SVG under that type.
:::
Disposition follows that declared type, so SVG mislabelled as an image or video media type is served inline.
## Representation headers
| Field | Type | Description |
| --- | --- | --- |
| Content-Type<sup>1</sup> | string | The detected or selected media type |
| Content-Length?<sup>2</sup> | integer | The selected body length, including on HEAD |
| Content-Range? | string | Present on 206 as `bytes {start}-{end}/{size}` and on 416 as `bytes */{size}` |
| Content-Disposition?<sup>3</sup> | string | The route-selected inline or attachment disposition |
| Accept-Ranges | string | The literal `bytes` on every media representation |
| Access-Control-Allow-Origin | string | The literal `*` on media responses |
| Cache-Control<sup>4</sup> | string | The browser cache policy for the representation |
| CDN-Cache-Control | string | The corresponding shared cache policy |
| Vary | string | The literal `Accept-Encoding` |
| X-Content-Type-Options | string | The literal `nosniff` |
| X-Robots-Tag<sup>5</sup> | string | The indexing policy |
| X-Fluxer-Version<sup>6</sup> | string | The serving Fluxer build identifier, or `dev` when none is configured |
<sup>1</sup> Fixed at `text/css; charset=utf-8` on the theme route. A 416 response has none
<sup>2</sup> Counts the selected bytes, so a 206 advertises the range length. Omitted from a streamed [signed external](/media-proxy/routes/#get-signed-external-media) response whose origin declared no length
<sup>3</sup> Present on the attachment, image asset, and signed external routes, absent on the theme and entrance sound routes, absent on every `static` mode read, and omitted when an image asset falls back to its original bytes after a failed transcode
<sup>4</sup> An audio or video representation appends `no-transform`. A route-produced error uses `no-store` instead
<sup>5</sup> The literal `noindex, nofollow, nosnippet, noimageindex, notranslate, max-snippet:0, max-image-preview:none, max-video-preview:0`. Present in `mp` and `upload` mode. A `static` mode endpoint removes it
<sup>6</sup> Absent from a media access allowlist rejection
Every successful media representation uses `Cache-Control: public, max-age=31536000` and `CDN-Cache-Control: public, max-age=31536000`. An audio or video representation adds `no-transform` to the browser-facing policy only. No response repeats its policy in an `Expires` header. [Cache policies](/media-proxy/responses-and-limits/#cache-policies) lists the responses that have no policy at all.
No read route sends an `ETag`. The upload relay is the only route that returns one, and it relays the object storage value for the stored object.
:::note[External media is cached for a year too]
The signed path is derived from the target URL, so the bytes behind one unchanged target are cached for a year at both layers.
:::
A 416 response has `Accept-Ranges`, `Content-Range`, `Access-Control-Allow-Origin`, `Vary`, and `X-Robots-Tag`, with no `Content-Type` and no cache policy. Its body is empty.
## Content detection
For a streamed object, Fluxer first reads stored media type metadata that names an image, audio, or video. Otherwise it takes the filename extension, then any other non-empty declared type except `application/octet-stream`, and finally `application/octet-stream`.
When the complete input is available, detected SVG takes precedence. Another trustworthy declared image, audio, or video type remains authoritative. Without one, Fluxer checks the leading 8,192 bytes, then the filename extension, then the declared type, and finally uses `application/octet-stream`.
A filename extension that identifies MP4 audio overrides a declared `video/mp4` with `audio/mp4`.
## Content disposition
Disposition follows the resolved media type. An image other than SVG and a video are served inline. Every other type, including SVG and PDF, is served as an attachment. An explicit `download` request forces attachment disposition on every route that accepts the parameter.
The disposition filename comes from the route. An attachment or signed external read uses the filename in the path or target URL. An image asset uses the path hash with any `a_` prefix stripped, followed by the canonical name of the path extension, so `/avatars/1/a_abcd1234.jpg` is offered as `abcd1234.jpeg`.
When `download` resolves to true and the served media type has a canonical extension the filename does not already use, the filename keeps its stem and takes that extension, so a PNG transformation of `holiday.jpg` is offered as `holiday.png`. When a filename is not safe as a quoted ASCII value, Fluxer sends a sanitised quoted fallback and an RFC 5987 `filename*` parameter.
:::caution[A scriptable document is never inline]
The attachment, image asset, and signed external routes rasterise SVG to WebP, so a browser does not execute the document in the Media Proxy origin.
:::
On a non-transforming attachment read, only an `mp` endpoint rasterises SVG, and that path always uses lossless WebP. A transforming request uses the `format` and `quality` it was given, and an image asset path defaults to `high`. A `static` mode endpoint serves the original bytes.
@@ -0,0 +1,142 @@
---
# SPDX-License-Identifier: AGPL-3.0-or-later
title: Responses and limits
description: Media Proxy statuses, error bodies, size bounds, work admission, deadlines, and cache policies.
---
Almost every failed Media Proxy request answers with a short `text/plain` reason phrase and nothing else. Every bound below is a resource bound, because the Media Proxy has no request-count rate limit.
## Media error response
An unsuccessful response body is an English reason phrase under the content type `text/plain; charset=utf-8`. The JSON [HTTP API error response](/http-api/#error-response) never appears here. `HEAD` returns the same status and headers with an empty body. The method rejection of a registered path<sup>2</sup> and a locally unsatisfiable range have neither a body nor a `Content-Type`.
| Status | Body | Condition |
| --- | --- | --- |
| 400 | `Bad Request` | An invalid storage key, dimension, format, or external path, an unparsable relay `partNumber`, a failed attachment or external transformation, or a relay upload the endpoint could not take<sup>1</sup> |
| 401 | `Unauthorized` | An invalid signed external path signature, a missing, malformed, or expired relay capability, or a missing or invalid internal bearer token |
| 403 | `origin not in bunny allowlist` | The media access allowlist rejected the client address |
| 403 | `Forbidden` | A relay capability presented for another bucket, key, method, `uploadId`, or `partNumber` |
| 403 | `FORBIDDEN` | `/_metrics` requested from a non-loopback address |
| 404 | `Not Found` | A `GET` or `HEAD` of an unrouted path, an object that does not exist, or a relay `PUT` outside `upload` mode |
| 405 | `Method Not Allowed`<sup>2</sup> | A method other than `GET` or `HEAD` on a read route or on an unrouted path |
| 413 | `Payload Too Large` | A stored object, external body, upload body, or internal request body beyond its bound |
| 500 | `Transcode Failed` | An image asset transcode failed and the source is not directly displayable |
| 500 | `Internal Server Error` | A relay spool write to the endpoint's own disk failed |
| 502 | `Bad Gateway` | An object store read or write failed, an external origin could not be used, or an outbound socket deadline or a relay object storage write deadline expired |
| 503 | `Service Unavailable` | The upload relay spool budget is exhausted, an external buffer reservation or allocation failed, or an external origin answered `/_metadata` with 429<sup>3</sup> |
| 504 | `Gateway Timeout` | A transformation admission slot was unavailable or the transformation deadline expired |
<sup>1</sup> The relay also answers 400 when the client connection fails part way through the body
<sup>2</sup> The six registered paths, `/_health`, `/_metrics`, `/_metadata`, `/_thumbnail`, `/_frames`, and `/v1/relay/{key}`, answer an unaccepted method with an empty body, no `Content-Type`, and an `Allow` header
<sup>3</sup> `/_metadata` is the only endpoint that remaps an origin 429. The signed external read route retains 429 as 429
A retained external origin status uses the body `Upstream fetch failed` on the signed external read route and the canonical reason phrase of its status on `/_metadata`. An object store error that maps to no case above uses the canonical reason phrase of its status.
Every error a route produces uses `Cache-Control: no-store` and the standard [security headers](/media-proxy/overview/#representation-headers). Three failures have the security headers and set no cache policy, and [Cache policies](#cache-policies) names them. No plain-text error has CORS headers unless it came from the [upload relay](/media-proxy/upload-relay/), and no error has `Retry-After` or a request identifier.
### Handling contract
The set of reason phrases is open. A client MUST branch on the HTTP status and MUST NOT parse, compare, or pattern-match the body, because a phrase can be reworded and a new condition can introduce one without a version change. A client MUST NOT expect a JSON body on a failure, and MUST NOT expect a failure to name the key, parameter, or field that caused it.
## Status registry
Every `HEAD` response has an empty body, so the Body column describes `GET`, `PUT`, and `POST`.
| Status | Body | Condition |
| --- | --- | --- |
| 200<sup>1</sup> | Media bytes, or an empty `HEAD` or relay `PUT` body | A complete representation was served or a relay write succeeded |
| 206 | Selected media bytes for `GET` and empty for `HEAD` | One byte range is satisfiable |
| 400 | [Media error response](#media-error-response) | A path, query, target, transformation, or relay request is invalid |
| 401 | [Media error response](#media-error-response) | An external path signature, a relay capability, or an internal token is invalid |
| 403 | [Media error response](#media-error-response) | The media access policy denied the request, a relay capability does not match the request, or an external origin returned 403 |
| 404 | [Media error response](#media-error-response) | The route or object does not exist, the relay is not served here, or an external origin returned 404 |
| 405<sup>2</sup> | [Media error response](#media-error-response) | The route rejects the method, or an external origin returned 405 |
| 406, 408, 409, 410, 411, 412, 414, 415, 428, 429<sup>3</sup> | [Media error response](#media-error-response) | An external origin returned that status |
| 413 | [Media error response](#media-error-response) | A source, external response, or upload exceeds its applicable bound |
| 416<sup>4</sup> | empty locally, otherwise [Media error response](#media-error-response) | A local range is unsatisfiable, or an external origin returned 416 |
| 500 | [Media error response](#media-error-response) | An image asset transcode failed with no usable source, or a relay spool write failed |
| 502 | [Media error response](#media-error-response) | An object store, external origin, or upload destination returned an unusable response |
| 503 | [Media error response](#media-error-response) | Upload spool capacity is exhausted, an external buffer reservation or allocation failed, or an external origin answered `/_metadata` with 429<sup>5</sup> |
| 504 | [Media error response](#media-error-response) | Transformation capacity was unavailable or a transformation deadline expired |
<sup>1</sup> Four [internal endpoints](/media-proxy/routes/#operator-and-internal-endpoints) answer 200 with a non-media body. `/_metadata` and `/_frames` answer with JSON, `/_health` with the plain text `OK`, and `/_metrics` with the Prometheus text exposition
<sup>2</sup> A path served by the read fallback answers with the body `Method Not Allowed`. Each registered path answers with an empty body, no `Content-Type`, and an `Allow` header
<sup>3</sup> A retained external origin status is the only source of these statuses
<sup>4</sup> A locally unsatisfiable range answers with an empty body, `Content-Range: bytes */{size}`, and `Accept-Ranges: bytes`, and has no `Content-Type` and no cache policy
<sup>5</sup> The remap produces the body `Service Unavailable`, which is indistinguishable from a budget exhaustion 503
An external origin status of 400, 401, 403, 404, 405, 406, 408, 409, 410, 411, 412, 413, 414, 415, 416, 428, or 429 reaches the client unchanged, with the body `Upstream fetch failed`. The origin response body and headers are not forwarded. Any other unsuccessful origin status becomes 502.
The Media Proxy evaluates no conditional request header and never redirects a noncanonical target, so it returns no 304 and no 308.
:::note[A retained status describes the external URL]
The third-party origin chose it, and Fluxer passed the status through.
:::
## Request and media limits
Proxied or stored media is limited to 500 MiB, and exceeding that returns 413. The bound applies to a streamed object, a buffered object, an external response body, and any input selected for transformation. When a streamed external body passes the bound only after the response head is committed, the Media Proxy truncates it.
A decoded signed external target URL is limited to 8,192 bytes, and the route follows at most five redirects. A longer URL returns 400. A sixth redirect returns 502, and so does a redirect back to an already visited URL. A redirect target is subject to the same bound and the same address policy as the original URL. Content detection inspects the leading 8,192 bytes of a body.
Buffered external bodies share one endpoint budget of 500 MiB for every [work admission](#work-admission) slot plus 512 KiB. A body the budget cannot cover returns 503, and so does a failed buffer allocation.
Decoded images are limited to 16,384 pixels on either edge and 268,435,456 pixels in total. Animated input is limited to 20,000 frames and 1,073,741,824 decoded pixels across all frames. No configuration changes these bounds. [Transformations](/media-proxy/transformations/#transformation-limits) defines the resulting failure statuses.
The upload relay limits a body to the smaller of the capability's declared maximum and the endpoint's configured body limit, which defaults to the same 500 MiB ceiling and can be configured from 1 byte through 5 GiB. A request that declares no `Content-Length` is spooled to disk first, and spooled bodies share an 8 GiB endpoint budget by default.
An internal `/_metadata`, `/_thumbnail`, or `/_frames` request body is limited to the base64 expansion of the 500 MiB media bound plus 1 MiB. All three answer a larger body with 413 and the body `Payload Too Large`.
## Work admission
A transformation first takes an admission slot without waiting. The pool holds one slot for every concurrent native transform plus one for every queued transform. Native transform concurrency defaults to the process parallelism clamped to 2 through 8 and can be configured from 1 through 128. The queue defaults to eight times that concurrency and can be configured from 1 through 8192. When no slot is free the request returns 504 immediately.
Once admitted, the transformation waits for a native transform permit until the transformation deadline. A wait that outlives the deadline also returns 504.
The image branch of `/_thumbnail` follows the same admission and deadline rules. Its video branch takes no admission slot and no permit at all.
A successful transformation stays in the memory cache for 120,000 ms by default, within a 256 MiB total budget and a 64 MiB per-entry budget<sup>1</sup>. External content-type hints are cached for the same interval across at most 4,096 targets.
<sup>1</sup> The per-entry budget is clamped to the total budget, and setting the interval, the total budget, or the per-entry budget to zero disables the transform cache
## Deadlines
| Deadline | Default | Configurable range |
| --- | --- | --- |
| Outbound socket connect and total timeout<sup>1</sup> | 30,000 ms | 0 through 300,000 ms |
| Transformation | 15,000 ms | 1,000 through 120,000 ms |
| Upload relay object storage write<sup>2</sup> | 900,000 ms | 1,000 through 3,600,000 ms |
<sup>1</sup> One value sets both the connect timeout and the total request timeout, and it applies to external origin fetches, to object storage requests, and to the response body streamed back to the client
<sup>2</sup> A relay write with a declared `Content-Length` extends this deadline by one second for every 16 KiB of declared length
The same socket timeout bounds every streamed response body. A streamed stored object and a streamed signed external response terminate when the gap between two body chunks exceeds that timeout. The whole transfer has a second deadline of that timeout plus one second for every 16 KiB of expected length, which is a floor of 16 KiB per second. A body that ends before, or runs past, the advertised `Content-Length` also terminates with an error.
Animated encoding stops adding frames 3,000 ms before the transformation deadline, so the encoder has time to flush what it already holds. The request then succeeds with a shorter animation. The separate 30,000 ms animation bound caps the playback length of the encoded animation, and [Transformations](/media-proxy/transformations/#transformation-limits) defines it.
:::caution[A deadline after the head truncates the body]
A status and its headers are chosen before the body is sent. A streamed object store or external response that fails afterwards terminates the body, so the observed body can be shorter than the advertised `Content-Length`.
:::
## Cache policies
Every successful media representation uses `Cache-Control: public, max-age=31536000` and `CDN-Cache-Control: public, max-age=31536000`. An audio or video representation appends `no-transform` to `Cache-Control` only. Signed external media uses the same policy as stored media.
No route sets `immutable`, `Expires`, `ETag`, or `Last-Modified` on a read response, and no route selects a shorter cache policy. A cache revalidates a representation by fetching it again.
Every route-produced error response uses `Cache-Control: no-store`, and a successful `/_metrics` read uses it too. A 416 response, a successful upload relay response, a media access allowlist rejection, and the empty-body method rejection of a registered path set no cache policy at all.
## Range response headers
A complete media response has `Accept-Ranges: bytes`, the representation `Content-Type`, and an exact `Content-Length`<sup>1</sup>. A 206 additionally sets `Content-Range` to the complete size and `Content-Length` to the selected byte count. A 416 has `Content-Range: bytes */{size}` and `Accept-Ranges: bytes` with an empty body.
<sup>1</sup> A streamed signed external response omits `Content-Length` when the origin declared none
`HEAD` applies the same range contract as `GET` and returns 206 or 416 with an empty body. A forwarded external range is answered with the origin `Content-Range` and `Content-Length` unchanged. [Byte ranges](/media-proxy/overview/#byte-ranges) defines which request ranges are recognised.
@@ -0,0 +1,501 @@
---
# SPDX-License-Identifier: AGPL-3.0-or-later
title: Read and asset routes
description: Every Media Proxy read path with its inputs, representations, and statuses.
---
import RouteHeader from '@/components/RouteHeader.astro';
A Media Proxy read route resolves one path to one file and returns it. These paths are unversioned and sit under the base URL published as `endpoints.media` by [instance discovery](/http-api/instance/#instance-endpoints-object). The [static object read](#get-static-object) uses `endpoints.static_cdn`.
## Shared route contract
Every route here accepts `GET` and `HEAD`, reads no `Authorization` header, and has no request-count rate limit. HEAD returns the GET status and representation headers with an empty body, including 206 and 416.
[Selector parsing](/media-proxy/overview/#selector-parsing) defines query decoding. [Byte ranges](/media-proxy/overview/#byte-ranges) defines which ranges are recognised and which headers a 206 or 416 has.
## Common responses
Every media read route can produce these statuses in addition to the ones in its own table.
| Status | Body | Condition |
| --- | --- | --- |
| 403 | `origin not in bunny allowlist` | The request address is not permitted by the instance media access policy |
| 404 | `Not Found` | The path is not a media route or the object does not exist |
| 405 | `Method Not Allowed` | The method is neither `GET` nor `HEAD` |
| 413 | `Payload Too Large` | The stored object exceeds the 500 MiB media bound |
| 502 | `Bad Gateway` | The object store could not be read |
## Get attachment
<RouteHeader method="GET" path="/attachments/{path}" unauthenticated />
Returns the attachment identified by the path, optionally transformed. The path comes from the attachment URL the HTTP API returned.
:::caution[An attachment URL is a bearer capability]
The path has no signature and no expiry, so anyone holding the URL reads the object from any origin for as long as it exists. Leaving the guild does not revoke a URL a member already holds.
:::
### Path parameters
| Field | Type | Description |
| --- | --- | --- |
| path<sup>1</sup> | string | The attachment object path from a [message attachment object](/http-api/messages/#message-attachment-object) |
<sup>1</sup> Fluxer issues `{channel_id}/{attachment_id}/{filename}`. After percent-decoding, the storage key must be non-empty, must not begin with `/`, and must not contain an empty, `.`, or `..` segment, or the route returns 400
### Query parameters
| Field | Type | Description |
| --- | --- | --- |
| width? | integer | The output width in pixels (1 through the [decoded edge bound](/media-proxy/transformations/#allowed-dimensions), any other value returns 400) |
| height? | integer | The output height in pixels under the same rule |
| format? | string | The output format under the [attachment and external format contract](/media-proxy/transformations/#attachment-and-external-formats) (any other value returns 400) |
| quality? | string | The encoder profile under the [quality contract](/media-proxy/transformations/#quality) |
| animated? | boolean | Whether animated output is requested |
| effort?<sup>1</sup> | integer | The WebP encoder effort, clamped to 9 |
| download? | boolean | Whether attachment disposition is requested |
<sup>1</sup> Read only on this route, and an empty or unparsable value is ignored. Only lossless animated WebP output uses a value above 6
A transformation begins when `width`, `height`, `format`, or `quality` is present, or when `animated` resolves to true. `download` and `effort` select none on their own.
Without a transformation Fluxer streams the object from the store and forwards the range to it. With a transformation it reads the complete object into memory first, and the range then applies to the transformed bytes.
An SVG attachment with a transformation parameter is rasterised, and `format` and `quality` select the output as they do for any other source, defaulting to WebP. Without a transformation parameter, only an `mp` endpoint rasterises it, always as lossless WebP. An `upload` endpoint returns the stored SVG bytes.
A video source requires an explicit image `format`. Another transformation parameter without `format` returns 400. A source that is neither an image nor a video returns 400 when `format` is present. Without `format`, Fluxer returns it unchanged.
### Response
| Status | Body | Condition |
| --- | --- | --- |
| 200 | Complete media representation | The object and selected representation are available |
| 206 | Selected media bytes | One range is satisfiable |
| 400 | `Bad Request` | The decoded key, a dimension, the format, or the transformation request is invalid, or the transformation failed |
| 404 | `Not Found` | The attachment object does not exist |
| 416 | empty | The range is unsatisfiable |
| 504 | `Gateway Timeout` | Transformation capacity was unavailable or the transformation exceeded its deadline |
The [common responses](#common-responses) also apply.
## Get signed external media
<RouteHeader method="GET" path="/external/{signature}/{target}" />
Returns external media through Fluxer, optionally transformed. Fluxer verifies the signature before it decodes or fetches the target.
Fluxer constructs the signed path and exposes it on `proxy_url` of an [embed media object](/http-api/messages/#embed-media-object), `proxy_icon_url` of an [embed author object](/http-api/messages/#embed-author-object), `proxy_icon_url` of an [embed footer object](/http-api/messages/#embed-footer-object), `proxy_url` of a [resolved GIF entry](/http-api/memes/#resolved-gif-entry-object), and `proxy_src` of a [GIF media format object](/http-api/memes/#gif-media-format-object).
A field holds a signed path only when its source URL is external. A URL already under the Media Proxy endpoint, and any URL the deployment cannot sign, is returned verbatim, so a client MUST request the value exactly as issued and MUST NOT assume it has the signed shape.
### Path parameters
| Field | Type | Description |
| --- | --- | --- |
| signature | string | The unpadded base64url HMAC-SHA256 of the target component under the deployment secret |
| target<sup>1</sup> | string | The encoded target component, which can contain `/` separators |
<sup>1</sup> The opaque form is `v2/` followed by the unpadded base64url of the complete URL. The segmented form joins an optional percent-encoded `?query` segment, the scheme, the host and port, and the percent-encoded path segments with `/`
A client MUST preserve both components exactly as issued and MUST NOT decode or reconstruct either one. The signature covers the target component alone, so adding or changing a query parameter does not invalidate it. A path with no `/` after `/external/` returns 400.
:::caution[The signed path is the only authorisation]
Anyone holding a signed path fetches that external target through Fluxer for as long as the deployment secret is unchanged. The path has no expiry, user binding, or individual revocation.
:::
### Target validation
Fluxer validates the decoded target before every request and again for every redirect hop. It must be at most 8,192 bytes, must use `http` or `https`, must have a non-zero port when it names one, and must not contain credentials or a control character. A fragment is dropped before the fetch.
The target must resolve to a public address. A hostname must be at most 253 bytes and must contain at least one dot. Its labels use only ASCII letters, digits, and `-`, run to at most 63 bytes, and begin and end alphanumerically. The final label is not digits only. Fluxer rejects a literal address in an unspecified, loopback, private, link-local, carrier-grade NAT, benchmarking, documentation, 6to4 relay, multicast, reserved, broadcast, unique-local, IPv4-mapped, 6to4, or NAT64-embedded range. Fluxer validates every address DNS returns the same way before it opens the connection.
A hostname whose lookup fails or resolves to no address returns 400.
The route follows at most five redirects. A further redirect, a repeated URL, or a redirect without a `Location` header returns 502. A redirect to a rejected target returns 400.
### Query parameters
| Field | Type | Description |
| --- | --- | --- |
| width? | integer | The output width in pixels (1 through the [decoded edge bound](/media-proxy/transformations/#allowed-dimensions), any other value returns 400) |
| height? | integer | The output height in pixels under the same rule |
| format? | string | The output format under the [attachment and external format contract](/media-proxy/transformations/#attachment-and-external-formats) (any other value returns 400) |
| quality? | string | The encoder profile under the [quality contract](/media-proxy/transformations/#quality) |
| animated? | boolean | Whether animated output is requested |
| download? | boolean | Whether attachment disposition is requested |
`effort` is read only on [Get attachment](#get-attachment) and is ignored here.
A transformation begins when `width`, `height`, `format`, or `quality` is present, when `animated` resolves to true, when the target filename ends in `.svg`, or when the origin body is SVG by media type or by its first bytes. A transforming request forwards no `Range` to the origin and applies the client range to the transformed bytes, so it still selects 206 or 416.
A non-transforming request forwards the client range to the origin under the filter [byte ranges](/media-proxy/overview/#byte-ranges) defines, and relays an origin 206 with its `Content-Range` and `Content-Length` unchanged. An origin that ignores the range answers 200, and Fluxer streams that whole body through when the origin names an `image/`, `video/`, or `audio/` media type other than `image/svg+xml`. Fluxer buffers a transforming request, an SVG body, and any body whose declared media type is empty, `application/octet-stream`, or outside those three categories. A buffered response has the range applied to the bytes the route finally serves, so a client range over a buffered origin 200 produces 206.
Buffering reserves the whole body from a process-wide external buffer budget of 500 MiB for each configured transform slot and queue slot, plus 512 KiB. A reservation the budget cannot cover returns 503.
Fluxer answers a `HEAD` with no transformation and no range from an origin `HEAD` when that origin returns 200, declares a `Content-Length` of at most 500 MiB, and names a non-SVG media type. Any other `HEAD` runs the `GET` path and returns its headers with an empty body.
A video target requires an explicit image `format` to produce a thumbnail. Without one, Fluxer returns the original bytes unchanged, and it does the same for a target that is neither an image nor a video.
### Response
| Status | Body | Condition |
| --- | --- | --- |
| 200 | Complete external representation | The validated origin returned a complete representation |
| 206<sup>1</sup> | Selected external bytes | The origin answered a forwarded range, or a range over buffered or transformed bytes is satisfiable |
| 400 | `Bad Request` | The path shape, decoded URL, redirect target, transformation request, or transformation is invalid or prohibited |
| 401 | `Unauthorized` | The path signature is invalid |
| 413 | `Payload Too Large` | The origin declared or delivered more than 500 MiB |
| 416 | empty | A local range is unsatisfiable |
| 502 | `Bad Gateway` | The origin could not be reached, exceeded the redirect bound, or returned a status outside the retained set |
| 503 | `Service Unavailable` | The external buffer budget cannot cover the body, or the buffer allocation failed |
| 504 | `Gateway Timeout` | Transformation capacity was unavailable or the transformation exceeded its deadline |
<sup>1</sup> A forwarded range relays the origin `Content-Range` and `Content-Length` unchanged and omits either header the origin did not send, so a chunked origin 206 produces a 206 with no `Content-Length`. A local range over buffered or transformed bytes always has both
An origin status of 400, 401, 403, 404, 405, 406, 408, 409, 410, 411, 412, 413, 414, 415, 416, 428, or 429 reaches the client unchanged, with the body `Upstream fetch failed`. The origin body and headers are not returned. Any other unsuccessful origin status becomes 502. The [common responses](#common-responses) also apply.
### Side effects
The route requests the target from the validated third-party origin using the user agent `Mozilla/5.0 (compatible; Fluxerbot/1.0; +https://fluxer.app)`, which is visible to that origin.
## Get theme CSS
<RouteHeader method="GET" path="/themes/{path}" unauthenticated />
Returns a theme stylesheet with the fixed media type `text/css; charset=utf-8`. The [themes resource](/http-api/themes/) writes the object to `themes/{theme_id}.css`.
### Path parameters
| Field | Type | Description |
| --- | --- | --- |
| path<sup>1</sup> | string | The path below `/themes/`, whose raw route path must end in the exact lowercase suffix `.css` |
<sup>1</sup> After percent-decoding, the storage key must be non-empty, must not begin with `/`, and must not contain an empty, `.`, or `..` segment, or the route returns 400
The route selects no representation and accepts a `Range`.
### Response
| Status | Body | Condition |
| --- | --- | --- |
| 200 | Complete CSS representation | The theme object is available |
| 206 | Selected CSS bytes | One range is satisfiable |
| 400 | `Bad Request` | The decoded theme key is invalid |
| 416 | empty | The range is unsatisfiable |
The [common responses](#common-responses) also apply. A path that does not end in `.css` is not a theme path and falls through to 404.
## Get entrance sound
<RouteHeader method="GET" path="/entrance-sounds/{user_id}/{filename}" unauthenticated />
Returns the stored audio of a user entrance sound. The [entrance sounds resource](/http-api/entrance-sounds/) writes the object to `entrance-sounds/{user_id}/{hash}.{ext}`.
### Path parameters
| Field | Type | Description |
| --- | --- | --- |
| user_id<sup>1</sup> | string | The owning user [snowflake](/snowflakes/) |
| filename<sup>2</sup> | string | `{hash}.{ext}` for the stored sound |
<sup>1</sup> A non-empty run of ASCII digits. Any other value makes the path unroutable and returns 404
<sup>2</sup> The hash is non-empty and ASCII alphanumeric, and Fluxer issues the first 16 hexadecimal characters of the MD5 digest of the audio. The extension is the exact lowercase `mp3`, `ogg`, `m4a`, or `wav`
The route selects no representation, accepts a `Range`, and sends no `Content-Disposition`. A successful response streams the stored bytes with the detected audio media type and the one-year `no-transform` cache policy.
### Response
| Status | Body | Condition |
| --- | --- | --- |
| 200 | Complete audio representation | The sound object is available |
| 206 | Selected audio bytes | One range is satisfiable |
| 416 | empty | The range is unsatisfiable |
The [common responses](#common-responses) also apply.
## Image asset contract
An image asset is a stored picture Fluxer serves at a requested size and format, such as an avatar, a guild icon, or an emoji. A resource object exposes the asset hash, and the path combines the owning resource, that hash, and a file extension. An emoji or sticker path has its own identifier as the filename. The grammar, query parameters, and responses below govern every image asset route. [Asset size selection](/media-proxy/transformations/#asset-size-selection) defines every size class.
A client builds the complete URL from the Media Proxy [base URL](/media-proxy/overview/#base-urls), the path template for the asset class, the hash, and a file extension.
:::note[Some resources publish their URL in the response]
An attachment has its own `url` and `proxy_url`, a signed external media path has its own signature, and endpoint discovery publishes the instance branding images as complete URLs.
:::
Fluxer matches an asset path by its shape alone. A path with the wrong number of segments, an empty owner segment, or a filename without exactly one dot is not an asset path and returns 404. A hash containing anything but ASCII letters, digits, and `_` returns 404 as well, as does an extension outside the known image set. The known set is `png`, `jpg`, `jpeg`, `webp`, `gif`, `apng`, `avif`, `heic`, `heif`, `jxl`, and `svg`, matched case-insensitively.
Fluxer issues an eight-character lowercase hexadecimal hash, taken from the MD5 digest of the submitted image before metadata stripping. An owner segment is any non-empty segment, and Fluxer never checks it against a snowflake.
The storage key drops an `a_` prefix from the hash, except on an emoji or sticker path, whose key keeps the filename stem exactly as given. A path whose key is missing is also tried under the canonical name of its extension, so both `.jpg` and `.JPG` resolve an object stored as `.jpeg`.
An owner segment of the literal `.` or `..` parses as an asset path but produces an unsafe storage key and returns 400. So does a hash of the literal `a_` on a path that strips the prefix.
### Query parameters
| Field | Type | Description |
| --- | --- | --- |
| size?<sup>1</sup> | integer | The requested square edge in pixels, snapped to the [size ladder](/media-proxy/transformations/#allowed-dimensions) and clamped to the asset class |
| format?<sup>2</sup> | string | The requested output format, also accepted under the name `fmt` |
| quality? | string | The encoder profile under the [quality contract](/media-proxy/transformations/#quality), defaulting to `high` |
| animated?<sup>3</sup> | boolean | Whether animated output is requested |
| download? | boolean | Whether attachment disposition is requested |
<sup>1</sup> An absent or unparsable value selects 128 before clamping. No value is rejected
<sup>2</sup> Accepts case-insensitive `auto`, `png`, `jpg`, `jpeg`, `webp`, `gif`, `apng`, and `avif`, where `auto` keeps path-based selection and `avif` selects WebP. Any other value falls back to path-based selection, and a sticker path always selects WebP and ignores the field
<sup>3</sup> An absent value keeps the hash-derived default, which is animated only for an `a_` hash. PNG and JPEG output has no animation
`width`, `height`, and `effort` are not read on an asset path, and no asset query value produces a 400. The route accepts a `Range` over the selected representation.
<a id="image-asset-response"></a>
### Response
| Status | Body | Condition |
| --- | --- | --- |
| 200 | Complete asset representation | The original or transformed asset is available |
| 206 | Selected asset bytes | One range is satisfiable |
| 400 | `Bad Request` | An owner segment of `.` or `..`, or a hash of `a_` on a prefix-stripping path, makes the storage key unsafe |
| 404 | `Not Found` | The path is not a valid asset path or the asset does not exist |
| 416 | empty | The range is unsatisfiable |
| 500<sup>1</sup> | `Transcode Failed` | A required transcode failed and the source is not directly displayable |
| 504 | `Gateway Timeout` | Transformation capacity was unavailable or the transformation exceeded its deadline |
<sup>1</sup> A failed transcode whose stored media type begins with `image/` and is not `image/avif`, `image/heic`, `image/heif`, or SVG instead returns 200 with the original stored bytes and media type but without `Content-Disposition`
The [common responses](#common-responses) also apply. [Transformations](/media-proxy/transformations/) defines geometry, animation, output formats, and original representation selection.
## Get user avatar
<RouteHeader method="GET" path="/avatars/{user_id}/{hash}.{ext}" unauthenticated />
Returns the user avatar under the icon size class.
### Path parameters
| Field | Type | Description |
| --- | --- | --- |
| user_id | string | The owning user [snowflake](/snowflakes/) paired with the avatar hash |
| hash | string | The avatar hash, optionally prefixed with `a_` to default to animated output |
| ext | string | The path extension, which selects the output format |
### Response
The operation uses the shared [image asset response](#image-asset-response).
## Get guild icon
<RouteHeader method="GET" path="/icons/{guild_id}/{hash}.{ext}" unauthenticated />
Returns the guild icon under the icon size class.
### Path parameters
| Field | Type | Description |
| --- | --- | --- |
| guild_id | string | The owning guild [snowflake](/snowflakes/) paired with the icon hash |
| hash | string | The icon hash, optionally prefixed with `a_` to default to animated output |
| ext | string | The path extension, which selects the output format |
### Response
The operation uses the shared [image asset response](#image-asset-response).
## Get instance branding
<RouteHeader method="GET" path="/branding/{entity_id}/{hash}.{ext}" unauthenticated />
Returns the instance branding image written by [Create branding asset](/admin-api/instance/#create-branding-asset), under the icon size class.
### Path parameters
| Field | Type | Description |
| --- | --- | --- |
| entity_id | string | The instance branding entity key supplied by the branding URL |
| hash | string | The branding hash, optionally prefixed with `a_` to default to animated output |
| ext | string | The path extension, which selects the output format |
### Response
The operation uses the shared [image asset response](#image-asset-response).
## Get owner banner
<RouteHeader method="GET" path="/banners/{owner_id}/{hash}.{ext}" unauthenticated />
Returns the owner banner under the banner size class.
### Path parameters
| Field | Type | Description |
| --- | --- | --- |
| owner_id | string | The owning user or guild [snowflake](/snowflakes/) paired with the banner hash |
| hash | string | The banner hash, optionally prefixed with `a_` to default to animated output |
| ext | string | The path extension, which selects the output format |
### Response
The operation uses the shared [image asset response](#image-asset-response).
## Get guild splash
<RouteHeader method="GET" path="/splashes/{guild_id}/{hash}.{ext}" unauthenticated />
Returns the guild splash under the banner size class.
### Path parameters
| Field | Type | Description |
| --- | --- | --- |
| guild_id | string | The owning guild [snowflake](/snowflakes/) paired with the splash hash |
| hash | string | The splash hash, optionally prefixed with `a_` to default to animated output |
| ext | string | The path extension, which selects the output format |
### Response
The operation uses the shared [image asset response](#image-asset-response).
## Get guild embed splash
<RouteHeader method="GET" path="/embed-splashes/{guild_id}/{hash}.{ext}" unauthenticated />
Returns the guild embed splash under the banner size class.
### Path parameters
| Field | Type | Description |
| --- | --- | --- |
| guild_id | string | The owning guild [snowflake](/snowflakes/) paired with the embed splash hash |
| hash | string | The embed splash hash, optionally prefixed with `a_` to default to animated output |
| ext | string | The path extension, which selects the output format |
### Response
The operation uses the shared [image asset response](#image-asset-response).
## Get guild member avatar
<RouteHeader method="GET" path="/guilds/{guild_id}/users/{user_id}/avatars/{hash}.{ext}" unauthenticated />
Returns the guild member avatar under the icon size class.
### Path parameters
| Field | Type | Description |
| --- | --- | --- |
| guild_id | string | The guild [snowflake](/snowflakes/) owning the member asset |
| user_id | string | The member user [snowflake](/snowflakes/) paired with the member avatar hash |
| hash | string | The member avatar hash, optionally prefixed with `a_` to default to animated output |
| ext | string | The path extension, which selects the output format |
### Response
The operation uses the shared [image asset response](#image-asset-response).
## Get guild member banner
<RouteHeader method="GET" path="/guilds/{guild_id}/users/{user_id}/banners/{hash}.{ext}" unauthenticated />
Returns the guild member banner under the banner size class.
### Path parameters
| Field | Type | Description |
| --- | --- | --- |
| guild_id | string | The guild [snowflake](/snowflakes/) owning the member asset |
| user_id | string | The member user [snowflake](/snowflakes/) paired with the member banner hash |
| hash | string | The member banner hash, optionally prefixed with `a_` to default to animated output |
| ext | string | The path extension, which selects the output format |
### Response
The operation uses the shared [image asset response](#image-asset-response).
## Get emoji image
<RouteHeader method="GET" path="/emojis/{emoji_id}.{ext}" unauthenticated />
Returns the emoji image under the emoji size class with a square cover crop.
### Path parameters
| Field | Type | Description |
| --- | --- | --- |
| emoji_id<sup>1</sup> | string | The emoji [snowflake](/snowflakes/), which is also the storage key |
| ext<sup>2</sup> | string | The path extension, which selects the output format |
<sup>1</sup> Any non-empty run of ASCII letters, digits, and `_`. Fluxer clients request `{emoji_id}.webp`
<sup>2</sup> An extension whose encoder is not enabled for output selects WebP instead
Fluxer issues emoji paths without an `a_` prefix, so an emoji request defaults to static output and needs `animated=true` for animation. The parser still reads a leading `a_` as an animation request and keeps the stem verbatim in the storage key, so `/emojis/a_123.webp` names a different object than `/emojis/123.webp`.
### Response
The operation uses the shared [image asset response](#image-asset-response).
## Get sticker image
<RouteHeader method="GET" path="/stickers/{sticker_id}.{ext}" unauthenticated />
Returns the sticker image under the sticker size class with a square cover crop. Output is always WebP, except that an animated GIF source stays GIF when `animated` resolves to true.
### Path parameters
| Field | Type | Description |
| --- | --- | --- |
| sticker_id<sup>1</sup> | string | The sticker [snowflake](/snowflakes/), which is also the storage key |
| ext | string | The path extension, used only as the source hint |
<sup>1</sup> Any non-empty run of ASCII letters, digits, and `_`. Fluxer clients request `{sticker_id}.webp`
Fluxer issues sticker paths without an `a_` prefix, so a sticker request defaults to static output and needs `animated=true` for animation. The parser still reads a leading `a_` as an animation request and keeps the stem verbatim in the storage key.
### Response
The operation uses the shared [image asset response](#image-asset-response).
## Get static object
<RouteHeader method="GET" path="/{key}" unauthenticated />
Returns a raw object from the static bucket. The route exists only on the `static` deployment mode, published as `endpoints.static_cdn` by [instance discovery](/http-api/instance/#instance-endpoints-object). It serves default avatars, client bundles, and other published files.
The complete request path is the storage key after percent-decoding. The route applies no transformation and reads no query parameter. The response uses the detected media type and the one-year cache policy, and a `static` endpoint omits `X-Robots-Tag`.
### Path parameters
| Field | Type | Description |
| --- | --- | --- |
| key<sup>1</sup> | string | The object key in the static bucket, taken from the complete request path |
<sup>1</sup> After percent-decoding, the key must be non-empty, must not begin with `/`, and must not contain an empty, `.`, or `..` segment, or the route returns 400
### Response
| Status | Body | Condition |
| --- | --- | --- |
| 200 | Complete object | The object is available |
| 206 | Selected bytes | One range is satisfiable |
| 400 | `Bad Request` | The decoded key is invalid |
| 416 | empty | The range is unsatisfiable |
The [common responses](#common-responses) also apply.
## Operator and internal endpoints
These paths are not part of the public API surface. They are exempt from the media access allowlist and are not published by instance discovery. A client MUST NOT depend on them.
| Path | Method | Description |
| --- | --- | --- |
| `/_health` | GET | Returns 200 with the body `OK` |
| `/_metrics` | GET | Returns the Prometheus text exposition of the process. A request from a non-loopback address returns 403 with the body `FORBIDDEN` |
| `/_metadata` | POST | Extracts media metadata, a placeholder, and an optional NSFW verdict for the HTTP API |
| `/_thumbnail` | POST | Produces a WebP thumbnail of a staged upload for the HTTP API |
| `/_frames` | POST | Extracts one JPEG video frame for the HTTP API |
`/_metadata`, `/_thumbnail`, and `/_frames` require `Authorization: Bearer {deployment secret key}` and return 401 with the body `Unauthorized` without it. Their JSON request bodies are bounded, and a body beyond the bound returns 413 with the body `Payload Too Large` on all three.
@@ -0,0 +1,175 @@
---
# SPDX-License-Identifier: AGPL-3.0-or-later
title: Image and video transformations
description: Transformation parameters, geometry, formats, quality, animation, and limits.
---
The Media Proxy resizes and converts images, preserves or removes animation, and extracts a still image from a video. The [attachment](/media-proxy/routes/#get-attachment) and [signed external media](/media-proxy/routes/#get-signed-external-media) routes share one parameter set, and the [image asset contract](/media-proxy/routes/#image-asset-contract) has a smaller one of its own.
## When a transformation runs
The theme, entrance sound, and static object routes read no parameter and never transform.
An attachment or signed external request transforms when `width`, `height`, `format`, or `quality` is present, or when `animated` resolves to true. `download` and `effort` select no transformation.
A signed external request also transforms when the target filename ends in `.svg`, and when the origin body is SVG by media type or by its first bytes, whatever the filename. An SVG origin body is transformed unless the origin answered a forwarded range with a 206 under a non-SVG media type, which the proxy relays as raw bytes.
The Media Proxy still rasterises an SVG attachment that selects no transformation. That path always uses lossless WebP and runs only in [`mp` mode](/media-proxy/overview/#deployment-modes).
An image asset request always runs through the transformation engine. It returns the original bytes only when the resolved format already matches the source and the resolved size is at least the source dimensions.
The Media Proxy canonicalises nothing and issues no redirect, so two spellings of one selection are two separate HTTP cache entries.
## Representation parameters
| Field | Type | Description |
| --- | --- | --- |
| width?<sup>1</sup> | integer | The output width for an attachment or signed external request |
| height?<sup>1</sup> | integer | The output height for an attachment or signed external request |
| size?<sup>2</sup> | integer | The requested square edge for an image asset request |
| format?<sup>3</sup> | string | The requested output format under the rules for the route |
| quality? | string | The requested [quality profile](#quality) |
| animated?<sup>4</sup> | boolean | Whether animated output is requested |
| effort?<sup>5</sup> | integer | The WebP encoder effort for an attachment request |
| download? | boolean | Whether the response uses attachment disposition |
<sup>1</sup> An empty, unparsable, zero, or over-bound value returns 400. Any integer from 1 through 16,384 is accepted
<sup>2</sup> No value is rejected. An absent or unparsable value selects 128, a parsable value snaps to the ladder, and the result is then clamped by [asset size selection](#asset-size-selection)
<sup>3</sup> An image asset request accepts the same value under the name `fmt`, and reads `fmt` only when `format` is absent
<sup>4</sup> Overrides the route default in both directions
<sup>5</sup> An empty or unparsable value is ignored. A value from 10 through 255 is clamped to 9, and a value above 255 does not parse, so it too is ignored
A name that occurs more than once resolves to its final value, and an unknown name is ignored. `animated` and `download` are true only for case-insensitive `true` or the exact value `1`. Every other value, including `false` and `0`, is false.
## Allowed dimensions
`width` and `height` are free integers from 1 through 16,384. Any other value returns 400, and neither is clamped into range. The decoded source is bounded separately by [transformation limits](#transformation-limits).
`size` selects from a fixed ladder: 16, 20, 22, 24, 28, 32, 40, 44, 48, 56, 60, 64, 80, 96, 100, 128, 160, 240, 256, 300, 320, 480, 512, 600, 640, 1024, 1280, 1536, 2048, 3072, 4096, 8192, and 16,384. A requested value snaps up to the first rung that is at least as large, so 641 selects 1024. A request above 16,384 selects 16,384, and the class clamp in [asset size selection](#asset-size-selection) can reduce the result further.
## Geometry
Transformations never enlarge an image.
`width` alone scales proportionally to the requested width, and `height` alone scales proportionally to the requested height. A fit inside a rectangle uses the smaller of the two ratios, so a dimension requested larger than the source still shrinks when the other requested dimension is smaller than the source.
A cover crop scales a still image to cover the requested rectangle and crops it centrally. An attachment or signed external request asks for one by supplying both `width` and `height`. An emoji or sticker asset always uses one. Every other image asset fits inside the selected square and preserves its full aspect ratio.
:::note[An animated transformation fits the whole frame]
The Media Proxy downgrades a cover crop to a plain fit whenever it opens the decoder for every page. An animated emoji or sticker is fitted inside its square.
:::
## Asset size selection
An asset request snaps `size` to the ladder and then clamps the result into the range of its asset class. The resolved value is applied to both dimensions, and the [geometry rules](#geometry) then decide whether the image is cropped or fitted inside that square.
| Asset class | Routes | Range |
| --- | --- | --- |
| Icon | avatars, icons, branding, guild member avatars | 128 through 1024 |
| Banner<sup>1</sup> | banners, splashes, embed splashes, guild member banners | 480 through 2400 |
| Emoji | emojis | 32 through 512 |
| Sticker | stickers | 128 through 512 |
<sup>1</sup> The banner maximum of 2400 is not a ladder rung and is reachable only by clamping, so 3072 and every larger request collapse onto it
An absent `size` resolves to 128 before clamping, so an avatar defaults to 128 and a banner defaults to 480. An emoji and a sticker also default to 128. Every ladder value below a class minimum collapses onto that minimum, and every value above a class maximum collapses onto that maximum.
## Attachment and external formats
Format matching is case-insensitive. A value outside this table returns 400, and `auto` is not a value here.
| Value | Output format | Media type |
| --- | --- | --- |
| `png`<sup>1</sup> | PNG | `image/png` |
| `jpg`, `jpeg` | JPEG | `image/jpeg` |
| `webp`<sup>2</sup> | WebP | `image/webp` |
| `gif` | GIF | `image/gif` |
| `apng` | APNG | `image/apng` |
| `avif`, `heic`, `heif`, `jxl`, `svg` | WebP | `image/webp` |
<sup>1</sup> An APNG source requesting PNG output with `animated=true` keeps APNG and `image/apng`
<sup>2</sup> A GIF source requesting WebP output with `animated=true` keeps GIF and `image/gif`
AVIF, HEIC, HEIF, JXL, and SVG decode but have no output encoder, so they are returned as WebP. SVG is rasterised, and a transforming route never returns SVG bytes.
When `format` is omitted, an attachment uses the filename extension in its path. Signed external media uses the origin media type first and then the target filename. If neither identifies an image format, the output is WebP.
## Image asset formats
An image asset path can use PNG, JPEG, WebP, GIF, APNG, AVIF, HEIC, HEIF, JXL, or SVG. A path extension without an output encoder selects WebP.
The `format` query parameter, also accepted as `fmt`, overrides the path extension. It recognises case-insensitive `auto`, `png`, `jpg`, `jpeg`, `webp`, `gif`, `apng`, and `avif`. `auto` leaves the path extension to select the output, and `avif` selects WebP. Any other value, including `heic`, `heif`, `jxl`, `svg`, and an unknown name, falls back to path-based selection.
A sticker always selects WebP and ignores `format`. An animated GIF sticker stays GIF when `animated` resolves to true.
## Quality
Quality names are matched exactly and are case-sensitive. An unrecognised value selects `high`.
| Value | Description |
| --- | --- |
| `low`<sup>1</sup> | Encodes at quality 65 with WebP encoder effort 2 |
| `auto`<sup>2</sup> | Resolves to `lossless` or `high` |
| `high` | Encodes at quality 85 |
| `lossless`<sup>3</sup> | Selects lossless WebP encoding and JPEG quality 100 |
<sup>1</sup> PNG, APNG, and GIF have fixed encoder settings and ignore `quality`, so the quality number reaches WebP and JPEG output only
<sup>2</sup> Resolves to `lossless` only for animated WebP output whose source sniffs as GIF or APNG, is at most 4,194,304 bytes, and decodes to at most 16,777,216 pixels across all frames. It resolves to `high` otherwise, and an animated palette WebP that fails those tests uses WebP encoder effort 0 unless an attachment request supplies `effort`
<sup>3</sup> Lossless applies to WebP alone, and JPEG at quality 100 is still a lossy encode
An image asset defaults to `high`. An attachment or signed external image defaults to `lossless`, except that a JPEG, HEIC, or HEIF source defaults to `high`. Animated WebP output defaults to `auto` on every route that reads `quality`. A video thumbnail is extracted at `high`, and `quality` then applies only to the resize step that `width` or `height` requests. A non-transforming SVG rasterisation always uses `lossless`.
Encoder effort defaults to 2 for animated output or `low` quality and 4 otherwise, and it applies to WebP output only. JPEG and PNG have fixed encoder settings, and GIF always encodes at effort 7. The attachment-only `effort` parameter replaces the default and is clamped to 9. Still WebP output clamps it again to 6, and so does lossy animated WebP. Only lossless animated WebP uses 7 through 9.
## Animation
An owner-and-hash asset whose hash begins with `a_` requests animated output by default, and a bare hash requests static output by default. The prefix is stripped from the storage key, so both spellings read the same stored object.
The `animated` parameter overrides that default in both directions. It is read whenever the name is present and is true only for case-insensitive `true` or the exact value `1`, so `animated=false` forces static output even for an `a_` hash. Omitting the name keeps the route default.
Fluxer issues emoji and sticker paths without an `a_` prefix, so those requests default to static output and need `animated=true` for animation. An attachment or signed external request also defaults to static output.
When encoding occurs, animation survives only when `animated` resolves to true and the selected output is WebP, GIF, or APNG. The Media Proxy rejects the original bytes when the stored bytes sniff as animated and the request resolves to static, so that request is re-encoded to a single frame. Only a read that selects no transformation returns the source animation without `animated=true`.
:::caution[PNG and JPEG output stacks the frames]
PNG and JPEG have no animation, but `animated=true` still opens every page of the source. The encoder receives the frames stacked vertically and writes one tall image.
:::
An animated GIF request for GIF output is resized in its original container, and is returned untouched when the requested size would not change it. Requesting a cover crop routes it through the image pipeline, which still emits GIF.
## Video thumbnails
Fluxer extracts one thumbnail from a video only when the request supplies an explicit image `format`. The video itself is never transcoded. The extractor scans at most 512 packets to find a frame and encodes it as JPEG, PNG, WebP, GIF, or APNG. A `format` value with no output encoder has already been coerced to WebP, so no other target reaches the extractor. `width` and `height` fit the thumbnail inside the requested rectangle without cropping.
An attachment video request with another transformation parameter but no `format` returns 400. A signed external video request without `format` returns the original bytes instead.
An attachment source that is neither an image nor a video returns 400 when `format` is present and is otherwise returned unchanged. A signed external source that is neither an image nor a video is always returned unchanged.
## Original representations
The Media Proxy returns the original bytes when the source already has the selected format, no resize or crop is required, and no encoder option requires a new representation. A source whose bytes sniff as animated also requires a request that resolves to animated, and a static request against it is encoded.
An `effort` value forces encoding, and a `quality` value forces encoding for every source except GIF. An animated attachment or signed external request for the source's own GIF, WebP, or APNG format with neither `width` nor `height` bypasses both tests and can still reuse the original animation.
The Media Proxy derives the response `Content-Type` from the content when the stored media type is empty, is case-insensitively `application/octet-stream`, or is outside the set `image/jpeg`, `image/png`, `image/webp`, `image/gif`, `image/apng`, `image/avif`, `image/heic`, `image/heif`, `image/jxl`, and `image/svg+xml`. An original response can therefore use a different media type from the stored metadata. A stored media type from that set is trusted even when it disagrees with the bytes and is served unchanged.
## Transformation limits
Proxied or stored media is limited to 500 MiB. A stored object above that bound returns 413, and so does an external origin that declares or delivers more. The same bound applies to a transforming request and to every buffer it produces.
Decoded images are limited to 16,384 pixels on either edge and 268,435,456 pixels in total. Animated input is additionally limited to 20,000 decoded frames and 1,073,741,824 decoded pixels across all frames. These four bounds are fixed. Exceeding a decoded image or animation limit fails the transformation.
Animated WebP and animated APNG output is bounded again at encode time, and exceeding one of those bounds truncates the output. The encoder stops adding frames after 20,000 frames, or once the accumulated frame delays reach 30,000 ms of playback, and emits the frames it already has. An operator can configure the frame cap from 1 through 100,000 and the playback cap from 100 through 600,000 ms. Animated GIF output has neither cap on either of its paths, so the decode limits above are its only bound.
The overall transformation deadline defaults to 15,000 ms and can be configured from 1,000 through 120,000 ms. An animated WebP or APNG encode stops 3,000 ms before that deadline so it has time to flush what it has already encoded. Animated GIF output has no such headroom, and a GIF resize that reaches the deadline fails.
An attachment or signed external transformation failure returns 400 with the body `Bad Request`. An image asset transformation failure returns 500 with the body `Transcode Failed` when the source is not directly displayable, and otherwise returns 200 with the original stored bytes and no `Content-Disposition`.
A transformation refused admission returns 504, and so does one that exceeds its deadline. [Responses and limits](/media-proxy/responses-and-limits/) defines the admission capacity and the deadlines.
@@ -0,0 +1,167 @@
---
# SPDX-License-Identifier: AGPL-3.0-or-later
title: Upload relay
description: Capability-authorised uploads and the relay streaming limits.
---
import RouteHeader from '@/components/RouteHeader.astro';
The upload relay is where a client sends the bytes of a file. Each upload URL has a signed capability, and the relay stores the one object it authorises. [Request attachment upload URLs](/http-api/messages/#request-attachment-upload-urls) and [Create stream preview upload URL](/http-api/streams/#create-stream-preview-upload-url) issue them. [Attachment uploads](/topics/uploads/) states the flow the relay takes part in.
## Base URL and authorisation
Both paths here are relative to the relay base in the issued upload URL. That base is a deployment setting.
The capability is the complete authorisation. The relay reads no HTTP API `Authorization` header and has no request-count rate limit. The `PUT` has a [deployment mode](/media-proxy/overview/#deployment-modes) gate and returns 404 outside `upload` mode. Every path below `/v1/relay/` is exempt from the [media access policy](/media-proxy/overview/#access-restrictions).
:::note[Some upload URLs address object storage directly]
The HTTP API decides per request whether to relay, from the caller's country. A caller on the instance's direct-upload list receives a presigned object storage URL, and this contract does not describe it.
:::
:::caution[The complete URL is authenticated]
The path key and the `t` capability are bound together by the signature. A client MUST use the URL exactly as issued and MUST NOT rebuild, decode, reorder, or re-encode it. The URL is a bearer capability.
:::
## Relay capability object
A relay capability is a JSON payload signed with HMAC-SHA256 under the deployment relay secret. The signed value is the encoded payload text, and the capability is that text, a `.`, and the signature. Both halves use unpadded base64url.
The payload is opaque to a client.
### Structure
| Field | Type | Description |
| --- | --- | --- |
| b<sup>1</sup> | string | The destination bucket |
| k<sup>2</sup> | string | The destination object key |
| m<sup>3</sup> | string | The authorised method, always `put` |
| u?<sup>4</sup> | string | The multipart upload identifier |
| p?<sup>4</sup> | integer | The multipart part number |
| ct?<sup>5</sup> | string | The media type stored with the object |
| mb<sup>6</sup> | integer | The maximum body length in bytes |
| e<sup>7</sup> | integer | The expiry as a Unix timestamp in seconds |
<sup>1</sup> The uploads bucket configured on the endpoint. Any other value returns 403
<sup>2</sup> Equal to the decoded request key. Any other value returns 403
<sup>3</sup> A payload naming another method returns 401
<sup>4</sup> Present together on a multipart part capability and absent together on a single-object capability. The relay checks the query string values against these fields and then forwards both to the object store
<sup>5</sup> Takes precedence over the request `Content-Type`. When neither declares one, an S3 backend stores the object as `application/octet-stream`
<sup>6</sup> The HTTP API sets it to the exact length of the object or part for an attachment, and to 1,000,000 for a stream preview
<sup>7</sup> The bound is exclusive, so the relay rejects the capability once the clock reaches it. The HTTP API issues a 900 second lifetime by default
### Example
```json
{
"b": "fluxer-uploads",
"k": "a3f1c8de-4b21-4f0e-9c77-2d5b6e1a0f43",
"m": "put",
"ct": "image/png",
"mb": 184320,
"e": 1780000000
}
```
The relay answers 401 to a malformed capability, a signature that is not 32 bytes, an incorrect signature, a payload that is not valid unpadded base64url, a payload that is not valid JSON, and an expired capability.
## Put relay object
<RouteHeader method="PUT" path="/v1/relay/{key}" />
Stores the exact object the [relay capability](#relay-capability-object) authorises and returns 200 with an empty body. An `ETag` accompanies it when the object store supplied one. An S3 backend supplies one and a local filesystem backend does not.
### Path parameters
| Field | Type | Description |
| --- | --- | --- |
| key<sup>1</sup> | string | The object key of the issued URL |
<sup>1</sup> The key can contain `/` separators and the issuer percent-encodes each segment. The relay compares the decoded key against the capability `k` field, and any other value returns 403
Fluxer issues a UUID for an attachment and `stream_previews/{name}-{digest}.jpg` for a stream preview.
### Query parameters
| Field | Type | Description |
| --- | --- | --- |
| t<sup>1</sup> | string | The capability, as the unpadded base64url payload, a `.`, and the unpadded base64url signature |
| uploadId?<sup>2</sup> | string | The multipart upload identifier, present only when the capability has one |
| partNumber?<sup>3</sup> | integer | The multipart part number, present only when the capability has one |
<sup>1</sup> A missing `t` returns 401
<sup>2</sup> When the capability has `u`, the relay rejects an absent or differing value with 403. When it has no `u`, the relay rejects a non-empty value with 403 and accepts an absent or empty one
<sup>3</sup> An empty or unparsable value returns 400. When the capability has `p`, the relay rejects an absent or differing value with 403, and when it has no `p`, the relay rejects any value with 403
### Request headers
| Field | Type | Description |
| --- | --- | --- |
| Content-Length?<sup>1</sup> | integer | The declared length of the request body |
| Content-Type?<sup>2</sup> | string | The media type to store, read only when the capability declares none |
<sup>1</sup> A declared length above the capability `mb` field or above the endpoint body limit returns 413
<sup>2</sup> The capability `ct` field takes precedence over it
The relay ignores every other request header.
### Request body
The body is arbitrary bytes.
With `Content-Length`, the relay streams the body to the object store as it arrives. A body longer than the declared length returns 413 and a body shorter than it returns 400.
Without `Content-Length`, the relay spools the body to a temporary file first, bounded by the smaller of the capability `mb` field and the endpoint body limit. Exceeding that bound returns 413. A client transfer that fails part way returns 400. The relay returns 503 when the endpoint spool budget cannot cover the request.
### Response
The relay forwards no object storage response body and no arbitrary response headers.
| Status | Body | Condition |
| --- | --- | --- |
| 200 | empty | The object or part was stored |
| 400 | `Bad Request` | The `partNumber` value is unparsable or the client body ended early |
| 401 | `Unauthorized` | The capability is missing, malformed, incorrectly signed, or expired |
| 403 | `Forbidden` | The bucket, key, `uploadId`, or `partNumber` disagrees with the capability |
| 404 | `Not Found` | The endpoint does not serve the upload relay |
| 405<sup>1</sup> | empty | The method is not `PUT` |
| 413 | `Payload Too Large` | The declared or delivered body exceeds the capability `mb` field or the endpoint body limit |
| 500 | `Internal Server Error` | A spool write to the relay's own disk failed |
| 502 | `Bad Gateway` | The object store refused the write or could not be written to |
| 503 | `Service Unavailable` | The endpoint spool budget cannot cover the request |
<sup>1</sup> It has no relay CORS headers and no cache policy
:::caution[A message naming the key creates the attachment]
A successful relay response stores bytes only. [Create message](/http-api/messages/#create-message), [Modify message](/http-api/messages/#modify-message), and [Execute webhook](/http-api/webhooks/#execute-webhook) are the requests that can name the key.
:::
[Complete attachment upload](/http-api/messages/#complete-attachment-upload) assembles a multipart upload once every part is stored, and a singlepart upload needs no completion step. A stream preview becomes readable as soon as the stored object is in place, and the [Streams resource](/http-api/streams/) defines that flow.
The relay has no replay tracking. Repeating a request with the same capability writes the object again, and the last successful write wins. Retrying a multipart part after a 502 or 503 is safe, because a part is addressed by its number and the repeated write replaces it.
### Response headers
The 200, 400, 401, 403, 413, 500, 502, and 503 have the relay CORS headers. The 404 and the 405 have none of them.
Relay CORS means `Access-Control-Allow-Origin: *`, `Access-Control-Allow-Headers: Content-Type, Content-Length, Authorization, X-Fluxer-Features, X-Client-Context`, and `Access-Control-Expose-Headers: ETag, X-Fluxer-Version`.
### Side effects
A successful request writes the object the capability selects to the uploads bucket. It creates no attachment record and no message.
## Streaming limits
The endpoint body limit defaults to 500 MiB and an operator can configure it from 1 byte through 5 GiB. The effective limit for one request is the smaller of that value and the capability `mb` field.
A spooled body is written through a 1 MiB buffer by default, configurable from 64 KiB through 64 MiB. Spooled bodies share an 8 GiB endpoint budget by default, configurable up to 256 GiB. A request that arrives with no `Content-Length` reserves the complete effective limit from that budget before the relay reads a byte. The endpoint refuses to start when its body limit exceeds that budget, so one reservation always fits an idle endpoint.
The object storage write has a 900,000 ms deadline by default, configurable from 1,000 through 3,600,000 ms. For a streamed body the relay extends the deadline by one second for every 16 KiB of declared length.