feat(api): redirect desktop downloads to pkgs (#2853)

This commit is contained in:
Hampus authored and GitHub committed 2026-09-20 01:20:30 +02:00
1 parent 487febac8e
commit e2abfd476a
47 files changed
+679 -3940

No files matched your search

@@ -1,504 +0,0 @@
---
# SPDX-License-Identifier: AGPL-3.0-or-later
title: Desktop downloads
description: Desktop release metadata, artifact and checksum downloads, test builds, and release feeds.
---
import RouteHeader from '@/components/RouteHeader.astro';
The download resource serves the desktop application builds a deployment has stored. It also serves a SHA-256 checksum for each build and the release feed files beside them. Every route here is unauthenticated. A request that sends a credential anyway receives the same status and body. No route here declares a rate limit bucket, and none draws on the global allowance.
## Prefix and mounting
Fluxer registers the routes under `/dl`, and the desktop routes under `/dl/desktop`. A client builds the URL against `api_client` from the [instance endpoints object](/http-api/instance/#instance-endpoints-object).
Fluxer mounts the prefix at the root and at `/v1`. Every desktop route below also answers under `/v1/dl/desktop/...`.
:::caution[The catch-all has no `/v1` form]
[Download stored object](#download-stored-object) requires the `/dl` prefix. Its `/v1/dl/...` equivalent returns 404.
:::
Every other route on this reference takes the `/v1` form, and [HTTP API](/http-api/) states that a client MUST use it.
## Methods
Every route answers `GET`. The checksum routes, the artifact routes, and the catch-all also bind `HEAD`. [Get latest desktop version](#get-latest-desktop-version) and [List desktop versions](#list-desktop-versions) bind `GET` alone, and a `HEAD` still reaches them under the [shared rule for HEAD](/http-api/#request-format).
A `HEAD` on either JSON route runs the same resolution as the `GET` and returns its status and headers with no body. It can therefore answer 404.
A checksum route answers a `HEAD` with 200 and no body. The headers are `Content-Type`, `Content-Disposition`, `Cache-Control`, and the `Content-Length` of the checksum line.
Artifact and catch-all `HEAD` requests return 200 with `Content-Type`, `Content-Disposition`, `Accept-Ranges`, `Cache-Control` and `Content-Length`, plus `ETag` and `Last-Modified` when available. They ignore `Range` and never return 206, 416 or a [presigned URL redirect](#redirects). [Country redirects](#redirects) still return 302 for `HEAD`, as for `GET`.
## Release channels
| Value | Name | Description |
| --- | --- | --- |
| stable | Stable | The channel a deployment publishes for general use |
| canary | Canary | The channel that receives a build ahead of `stable` |
The channel selects the release series and product name. A `canary` file is named `Fluxer-Canary` or `Fluxer Canary`, and a `stable` file is named `Fluxer`.
## Platforms and architectures
| Value | Name | Description |
| --- | --- | --- |
| win32 | Windows | The Windows build target |
| darwin | macOS | The macOS build target |
| linux | Linux | The Linux build target |
The path segment with one of those values is named `plat`.
| Value | Name | Description |
| --- | --- | --- |
| x64 | x64 | The 64-bit x86 architecture |
| arm64 | ARM64 | The 64-bit ARM architecture |
A channel, a platform, and an architecture together name one coordinate.
## Package formats
| Value | Name | Description |
| --- | --- | --- |
| setup | Setup | The Windows installer executable |
| portable | Portable | The Windows archive that stores its data beside the executable |
| dmg | DMG | The macOS disk image |
| zip | ZIP | The macOS application archive |
| appimage | AppImage | The Linux portable application image |
| deb | DEB | The Debian package |
| rpm | RPM | The RPM package |
| tar_gz | TAR.GZ | The Linux compressed tarball |
The registry is closed, and a `format` outside it returns 400 `INVALID_FORM_BODY`.
Each format resolves on one platform. `setup` and `portable` resolve on `win32`, `dmg` and `zip` on `darwin`, and `appimage`, `deb`, `rpm`, and `tar_gz` on `linux`. A format paired with any other platform returns 404.
On `darwin` a `dmg` or `zip` request tries the `universal` file before the architecture-specific one, so `x64` and `arm64` can resolve to the same file.
## Checksum requests
A client appends `.sha256` to the format segment to read the checksum, and sends the bare format to read the file. `tar_gz.sha256` is the checksum of `tar_gz`.
Use the lowercase `.sha256` suffix with a valid [package format](#package-formats). An invalid format or suffix returns 400 `INVALID_FORM_BODY`.
## Version info object
A version info object describes one release at a coordinate and the file it has for each format.
### Structure
| Field | Type | Description |
| --- | --- | --- |
| version | string | The release version in `MAJOR.MINOR.PATCH` form |
| pub_date<sup>1</sup> | ISO8601 timestamp | The moment the release was published |
| minimum_system_version?<sup>2</sup> | ?string | The lowest operating system version the release supports |
| files<sup>3</sup> | map[string, [version file](#version-file-object) object] | The download entry for each [package format](#package-formats) the release has |
<sup>1</sup> Publication time reported for the release
<sup>2</sup> Present when the release specifies a minimum system version
<sup>3</sup> A format that resolves no file is absent from the map
### Example
```json
{
"version": "1.4.2",
"pub_date": "2026-08-19T11:04:00.000Z",
"minimum_system_version": "10.15.0",
"files": {
"dmg": {
"url": "https://api.example.com/dl/desktop/stable/darwin/arm64/1.4.2/dmg",
"sha256": "3b1f5c0d9e7a24486cf0b1d3a5e87209cc4d61fba0937e5528d1c4a67b0e93f2",
"checksum_url": "https://api.example.com/dl/desktop/stable/darwin/arm64/1.4.2/dmg.sha256"
}
}
}
```
## Version file object
A version file object is the download entry for one format of one release.
### Structure
| Field | Type | Description |
| --- | --- | --- |
| url<sup>1</sup> | string | The absolute URL of the versioned download for this format |
| sha256<sup>2</sup> | ?string | The hash of the file as 64 lowercase hexadecimal characters, or null where no checksum was found |
| checksum_url<sup>1</sup> | ?string | The absolute URL of the checksum file, or null where `sha256` is null |
<sup>1</sup> Built from `api_client` in the [instance endpoints object](/http-api/instance/#instance-endpoints-object), with no `/v1` segment
<sup>2</sup> A 64-character lowercase hexadecimal hash, or null when no valid checksum is available
## Checksum files
A checksum response is one `sha256sum` line: the hash, two spaces, the resolved filename, and a trailing newline.
```text
2f6d1a4b8c3e07f95ab61d4c2e8035971fd0ba46c7e213985cd0f74a6b1e82c3 Fluxer-1.4.2-linux-x86_64.AppImage
```
The response has `Content-Type: text/plain; charset=utf-8`, a `Content-Disposition` of `attachment` naming the resolved filename with `.sha256` appended, and a `Content-Length` counting the encoded line. It sets no `Accept-Ranges`, `ETag`, or `Last-Modified`, and it ignores a `Range` header.
A missing or invalid checksum returns 404.
## Artifact resolution
Use `latest` for the release currently offered at a channel, platform and architecture, or a versioned URL to request a specific release. A missing file returns 404 with the plain text body `Not Found`.
## Response headers and caching
Every artifact response has `Accept-Ranges: bytes` and a `Content-Disposition` of `attachment` naming the resolved file, unless the stored object has its own `Content-Disposition`. The filename is percent-encoded inside the header, so a name containing a space is written with `%20`.
| Response | Cache-Control |
| --- | --- |
| Latest version metadata and the version list | `public, max-age=300` |
| Latest artifact and latest checksum | `no-store` |
| Versioned artifact and versioned checksum under `desktop/` | `public, max-age=31536000` |
| A release feed filename, and anything under `desktop-test/` | `public, max-age=300` |
| Any `desktop/` artifact on a deployment that configures country redirects | `private, no-store` |
| A redirect to a presigned storage URL | `no-store` |
A release feed filename is any of these:
- `manifest.json`
- A name ending in `.yml` or `.yaml`
- A name beginning `RELEASES`
- A name beginning `releases` or `assets` and ending in `.json`
## Redirects
The deployment settings below answer a download with 302.
A hosted deployment can list countries whose downloads of a file under `desktop/` Fluxer can redirect to GitHub release assets. Once the list is set, every response for such a file has `Cache-Control: private, no-store`, including a response Fluxer serves from storage.
A deployment that issues presigned download URLs answers a `GET` with 302 to a storage URL valid for 900 seconds. That redirect has `Cache-Control: no-store` and `Accept-Ranges: bytes`. The setting is off by default.
:::caution[A 302 has no bytes and no range]
The client follows `Location` to read the file. Fluxer produces the redirect before it reads any `Range` header, so the server at `Location` answers any `Range` header on a redirected download.
:::
## Test builds
Every route accepts the `test` query parameter. `1` or `true`, matched without regard to case, selects test releases. Any other value is false.
On the desktop routes the flag makes Fluxer resolve every file under `desktop-test/`. On [Download stored object](#download-stored-object) it rewrites a key beginning `desktop/` and leaves any other key unchanged, and a path that already names `desktop-test/` resolves there with no flag at all.
A `url` and a `checksum_url` built for a request that sent the flag repeat `?test=1`, so a client following either one stays on the test prefix.
:::caution[`?test=` fails the schema with 400 `INVALID_FORM_BODY`]
The empty value normalises to null under [input normalisation](/http-api/#input-normalisation). `?limit=`, `?before=`, and `?after=` fail the same way.
:::
## Get latest desktop version
<RouteHeader method="GET" path="/v1/dl/desktop/{channel}/{plat}/{arch}/latest" unauthenticated />
Returns the [version info](#version-info-object) object for the newest release at the coordinate.
### Path parameters
| Field | Type | Description |
| --- | --- | --- |
| channel | string | The [release channel](#release-channels) to resolve |
| plat | string | The [platform](#platforms-and-architectures) to resolve |
| arch | string | The [architecture](#platforms-and-architectures) to resolve |
### Query parameters
| Field | Type | Description |
| --- | --- | --- |
| test? | string | The [test build](#test-builds) flag, `1` or `true` to resolve under `desktop-test/` |
### Response
| Status | Body | Condition |
| --- | --- | --- |
| 200 | [version info](#version-info-object) object | A release resolved at the coordinate |
| 400 | [error response](/http-api/#error-response) | A path or query value fails its schema and the request returns `INVALID_FORM_BODY` |
| 404 | `Not Found` | No release resolved at the coordinate |
A client reads `files` for the format it wants. A format whose file resolved no checksum reports `sha256` and `checksum_url` as null.
### Response headers
The 200 has `Cache-Control: public, max-age=300`. The 404 has `Content-Type: text/plain`.
## List desktop versions
<RouteHeader method="GET" path="/v1/dl/desktop/{channel}/{plat}/{arch}/versions" unauthenticated />
Returns the releases stored at the coordinate, newest first.
### Path parameters
| Field | Type | Description |
| --- | --- | --- |
| channel | string | The [release channel](#release-channels) to resolve |
| plat | string | The [platform](#platforms-and-architectures) to resolve |
| arch | string | The [architecture](#platforms-and-architectures) to resolve |
### Query parameters
| Field | Type | Description |
| --- | --- | --- |
| limit? | integer | The maximum number of releases to return (1 through 100, default 25) |
| before?<sup>1</sup> | string | The version to page below, exclusive |
| after?<sup>1</sup> | string | The version to page above, exclusive |
| test? | string | The [test build](#test-builds) flag, `1` or `true` to resolve under `desktop-test/` |
<sup>1</sup> Compared component by component as numbers, and both bounds can be sent together to select a range
### Response body
| Field | Type | Description |
| --- | --- | --- |
| versions | array[[version info](#version-info-object) object] | The releases on this page, ordered newest first |
| has_more | boolean | Whether the filtered set held more releases than `limit` |
### Response
| Status | Body | Condition |
| --- | --- | --- |
| 200 | response body | The listing completed, possibly with no release |
| 400 | [error response](/http-api/#error-response) | A path or query value fails its schema and the request returns `INVALID_FORM_BODY` |
This route never answers 404. A coordinate that holds no object returns 200 with an empty array.
Only available files are listed. This response omits `minimum_system_version` and reports `sha256` only when a valid checksum is available.
### Response headers
The 200 has `Cache-Control: public, max-age=300`.
## Download latest desktop artifact
<RouteHeader method="GET" path="/v1/dl/desktop/{channel}/{plat}/{arch}/latest/{format}" unauthenticated />
Streams the newest file at the coordinate for one package format.
### Path parameters
| Field | Type | Description |
| --- | --- | --- |
| channel | string | The [release channel](#release-channels) to resolve |
| plat | string | The [platform](#platforms-and-architectures) to resolve |
| arch | string | The [architecture](#platforms-and-architectures) to resolve |
| format | string | The [package format](#package-formats) to resolve |
### Query parameters
| Field | Type | Description |
| --- | --- | --- |
| test? | string | The [test build](#test-builds) flag, `1` or `true` to resolve under `desktop-test/` |
### Request headers
| Field | Type | Description |
| --- | --- | --- |
| Range? | string | The standard byte range, answered with 206 and a `Content-Range` header |
### Response
| Status | Body | Condition |
| --- | --- | --- |
| 200 | artifact bytes | The complete file was streamed |
| 206 | artifact bytes | The requested byte range was streamed |
| 302 | empty | The deployment [redirects](#redirects) this download |
| 400 | [error response](/http-api/#error-response) | A path or query value fails its schema and the request returns `INVALID_FORM_BODY` |
| 404 | `Not Found` | No file resolved for the format at the coordinate |
| 416 | empty | The requested range is unsatisfiable |
The stored file keeps its own name, so the resolved filename has the release version even though the request named `latest`. A client that follows this route on every check reads a different file once a newer release is stored at the coordinate.
### Response headers
The 200 has `Content-Type`<sup>1</sup>, `Content-Disposition`<sup>2</sup>, `Content-Length`, `Accept-Ranges: bytes`, `Cache-Control: no-store`, `ETag`<sup>3</sup>, and `Last-Modified`<sup>3</sup>. The 206 has the 200 headers plus `Content-Range`. The 302 has `Location`, `Accept-Ranges: bytes`, and `Cache-Control`. The 404 has `Content-Type: text/plain`. The 416 has `Accept-Ranges: bytes`, `Content-Range: bytes */{size}`, and `Cache-Control`.
<sup>1</sup> The stored media type, and `application/octet-stream` where storage reports none
<sup>2</sup> `attachment` naming the resolved filename, percent-encoded
<sup>3</sup> Sent where storage reports the value
## Download latest desktop checksum
<RouteHeader method="GET" path="/v1/dl/desktop/{channel}/{plat}/{arch}/latest/{format}.sha256" unauthenticated />
Returns the [checksum file](#checksum-files) for the newest file at the coordinate.
### Path parameters
| Field | Type | Description |
| --- | --- | --- |
| channel | string | The [release channel](#release-channels) to resolve |
| plat | string | The [platform](#platforms-and-architectures) to resolve |
| arch | string | The [architecture](#platforms-and-architectures) to resolve |
| format<sup>1</sup> | string | The [package format](#package-formats) to resolve, followed by `.sha256` |
<sup>1</sup> The complete segment matches `[a-z_]+\.sha256` for the route to claim it, and the part before the suffix names a format in the closed registry
### Query parameters
| Field | Type | Description |
| --- | --- | --- |
| test? | string | The [test build](#test-builds) flag, `1` or `true` to resolve under `desktop-test/` |
### Response
| Status | Body | Condition |
| --- | --- | --- |
| 200 | checksum line | The file and a valid hash both resolved |
| 400 | [error response](/http-api/#error-response) | A path or query value fails its schema and the request returns `INVALID_FORM_BODY` |
| 404 | `Not Found` | No file resolved for the format, or the resolved file has no valid hash |
This hash is the value [Get latest desktop version](#get-latest-desktop-version) reports as `sha256` for the same format. The line names the file this coordinate's `latest` download streams.
### Response headers
The 200 has `Content-Type: text/plain; charset=utf-8`, `Content-Disposition`, `Content-Length`, and `Cache-Control: no-store`. The 404 has `Content-Type: text/plain`.
## Download desktop artifact
<RouteHeader method="GET" path="/v1/dl/desktop/{channel}/{plat}/{arch}/{version}/{format}" unauthenticated />
Streams one released file by version and package format.
### Path parameters
| Field | Type | Description |
| --- | --- | --- |
| channel | string | The [release channel](#release-channels) to resolve |
| plat | string | The [platform](#platforms-and-architectures) to resolve |
| arch | string | The [architecture](#platforms-and-architectures) to resolve |
| version<sup>1</sup> | string | The release version to resolve |
| format | string | The [package format](#package-formats) to resolve |
<sup>1</sup> Three decimal components matching `^\d+\.\d+\.\d+$`, so a two-component or suffixed version returns 400 `INVALID_FORM_BODY`
### Query parameters
| Field | Type | Description |
| --- | --- | --- |
| test? | string | The [test build](#test-builds) flag, `1` or `true` to resolve under `desktop-test/` |
### Request headers
| Field | Type | Description |
| --- | --- | --- |
| Range? | string | The standard byte range, answered with 206 and a `Content-Range` header |
### Response
| Status | Body | Condition |
| --- | --- | --- |
| 200 | artifact bytes | The complete file was streamed |
| 206 | artifact bytes | The requested byte range was streamed |
| 302 | empty | The deployment [redirects](#redirects) this download |
| 400 | [error response](/http-api/#error-response) | A path or query value fails its schema and the request returns `INVALID_FORM_BODY` |
| 404 | `Not Found` | No file resolved for the version and format |
| 416 | empty | The requested range is unsatisfiable |
### Response headers
The 200 has `Content-Type`, `Content-Disposition`, `Content-Length`, `Accept-Ranges: bytes`, `Cache-Control`<sup>1</sup>, `ETag`<sup>2</sup>, and `Last-Modified`<sup>2</sup>. The 206 has the 200 headers plus `Content-Range`. The 302 has `Location`, `Accept-Ranges: bytes`, and `Cache-Control`. The 404 has `Content-Type: text/plain`. The 416 has `Accept-Ranges: bytes`, `Content-Range: bytes */{size}`, and `Cache-Control`.
<sup>1</sup> `public, max-age=31536000` for a released file, and `public, max-age=300` under `desktop-test/` or for a [release feed filename](#response-headers-and-caching)
<sup>2</sup> Sent where storage reports the value
## Download desktop checksum
<RouteHeader method="GET" path="/v1/dl/desktop/{channel}/{plat}/{arch}/{version}/{format}.sha256" unauthenticated />
Returns the [checksum file](#checksum-files) for one released file.
### Path parameters
| Field | Type | Description |
| --- | --- | --- |
| channel | string | The [release channel](#release-channels) to resolve |
| plat | string | The [platform](#platforms-and-architectures) to resolve |
| arch | string | The [architecture](#platforms-and-architectures) to resolve |
| version | string | The release version to resolve |
| format<sup>1</sup> | string | The [package format](#package-formats) to resolve, followed by `.sha256` |
<sup>1</sup> The complete segment matches `[a-z_]+\.sha256` for the route to claim it, and the part before the suffix names a format in the closed registry
### Query parameters
| Field | Type | Description |
| --- | --- | --- |
| test? | string | The [test build](#test-builds) flag, `1` or `true` to resolve under `desktop-test/` |
### Response
| Status | Body | Condition |
| --- | --- | --- |
| 200 | checksum line | The file and a valid hash both resolved |
| 400 | [error response](/http-api/#error-response) | A path or query value fails its schema and the request returns `INVALID_FORM_BODY` |
| 404 | `Not Found` | No file resolved for the version and format, or no valid hash was found for it |
Returns 404 when the requested file has no available checksum.
### Response headers
The 200 has `Content-Type: text/plain; charset=utf-8`, `Content-Disposition`, `Content-Length`, and `Cache-Control`<sup>1</sup>. The 404 has `Content-Type: text/plain`.
<sup>1</sup> `public, max-age=31536000` for a released file, and `public, max-age=300` under `desktop-test/`
## Download stored object
<RouteHeader method="GET" path="/dl/{path}" unauthenticated />
Downloads a file by path. Use this route for release feeds, which have no format or version segment.
### Path parameters
| Field | Type | Description |
| --- | --- | --- |
| path<sup>1</sup> | string | The download path, which can contain `/` separators |
<sup>1</sup> Taken from the request path with the `/dl` prefix removed, then normalised. It begins `desktop/` or `desktop-test/` after normalisation, and every other key returns 404
### Query parameters
| Field | Type | Description |
| --- | --- | --- |
| test? | string | The [test build](#test-builds) flag, `1` or `true` to rewrite a `desktop/` key to `desktop-test/` |
### Request headers
| Field | Type | Description |
| --- | --- | --- |
| Range? | string | The standard byte range, answered with 206 and a `Content-Range` header |
### Response
| Status | Body | Condition |
| --- | --- | --- |
| 200 | object bytes | The complete object was streamed |
| 206 | object bytes | The requested byte range was streamed |
| 302 | empty | The deployment [redirects](#redirects) this download |
| 400 | [error response](/http-api/#error-response) | The `test` value fails its schema and the request returns `INVALID_FORM_BODY` |
| 404 | `Not Found` | The key is outside the permitted prefixes, the request has the `/v1` prefix, or storage holds no such object |
| 416 | empty | The requested range is unsatisfiable |
:::caution[The catch-all reaches the permitted prefixes only]
A path outside `desktop/` or `desktop-test/` returns 404.
:::
Fluxer also rejects the key when it is empty, when normalisation leaves it beginning `..` or `/`, or when any segment is `.`, `..`, or contains a NUL character. Each of those returns the same 404, so a caller cannot tell a traversal attempt from a missing object.
A request can name `desktop/stable/linux-x64/manifest.json` or `desktop/stable/linux/x64/manifest.json`. For the hyphen form, Fluxer returns that object when storage holds it, and otherwise returns the object at the slash form.
### Response headers
The 200 has `Content-Type`, `Content-Disposition`, `Content-Length`, `Accept-Ranges: bytes`, `Cache-Control`<sup>1</sup>, `ETag`<sup>2</sup>, and `Last-Modified`<sup>2</sup>. The 206 has the 200 headers plus `Content-Range`. The 302 has `Location`, `Accept-Ranges: bytes`, and `Cache-Control`. The 404 has `Content-Type: text/plain`. The 416 has `Accept-Ranges: bytes`, `Content-Range: bytes */{size}`, and `Cache-Control`.
<sup>1</sup> `public, max-age=31536000` for a released artifact, and `public, max-age=300` for a [release feed filename](#response-headers-and-caching) or a key under `desktop-test/`
<sup>2</sup> Sent where storage reports the value
@@ -6,7 +6,7 @@ description: Request format, body representations, shared headers, cross-origin
Use the HTTP API to read and change resources. Discover the base URL through [`/.well-known/fluxer`](/http-api/instance/#get-instance-discovery). Third-party clients use `endpoints.api_public`, and the first-party web application uses `endpoints.api_client`.
Use `/v1`, the only API version. [Download stored object](/http-api/downloads/#download-stored-object) is unversioned and uses its documented root path.
Use `/v1`, the only API version.
Read resource limits from [instance discovery](/http-api/instance/#limit-keys). Attachment counts, expression counts and profile field lengths can differ between deployments.