mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-07 19:22:14 +09:00
feat(api): redirect desktop downloads to pkgs (#2853)
This commit is contained in:
@@ -240,7 +240,7 @@ export default defineConfig({
|
||||
},
|
||||
{
|
||||
label: 'Client surfaces',
|
||||
items: ['http-api/experiments', 'http-api/themes', 'http-api/downloads'],
|
||||
items: ['http-api/experiments', 'http-api/themes'],
|
||||
},
|
||||
{
|
||||
label: 'Safety',
|
||||
@@ -257,6 +257,10 @@ export default defineConfig({
|
||||
'gateway/opcodes-and-close-codes',
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Downloads',
|
||||
items: ['downloads/overview', 'downloads/desktop', 'downloads/linux-repositories'],
|
||||
},
|
||||
{
|
||||
label: 'Media proxy',
|
||||
items: [
|
||||
|
||||
@@ -74,6 +74,22 @@ const MAIN_SPEC_EXEMPT = new Map<string, {file: string; anchor: string; reason:
|
||||
reason: 'the path constrains :format by regex and has no OpenAPI path template',
|
||||
},
|
||||
],
|
||||
[
|
||||
'GET /dl/desktop/{}/{}/{}/latest/{}.zsync',
|
||||
{
|
||||
file: DOWNLOAD_CONTROLLER,
|
||||
anchor: '`${DESKTOP_REDIRECT_PREFIX}/:channel/:plat/:arch/latest/:format{[a-z_]+\\\\.zsync}`,',
|
||||
reason: 'the path constrains :format by regex and has no OpenAPI path template',
|
||||
},
|
||||
],
|
||||
[
|
||||
'GET /dl/desktop/{}/{}/{}/{}/{}.zsync',
|
||||
{
|
||||
file: DOWNLOAD_CONTROLLER,
|
||||
anchor: '`${DESKTOP_REDIRECT_PREFIX}/:channel/:plat/:arch/:version/:format{[a-z_]+\\\\.zsync}`,',
|
||||
reason: 'the path constrains :format by regex and has no OpenAPI path template',
|
||||
},
|
||||
],
|
||||
[
|
||||
'GET /dl/{}',
|
||||
{
|
||||
@@ -164,6 +180,18 @@ const EXEMPTION_RULES: ReadonlyArray<ExemptionRule> = [
|
||||
],
|
||||
covers: (_shape, routePath) => routePath.startsWith('/test/'),
|
||||
},
|
||||
{
|
||||
name: 'deprecated desktop download redirect',
|
||||
justification:
|
||||
'every /dl route is an undocumented deprecated redirect onto pkgs.fluxer.com, kept only for desktop clients already in the field. Nothing current calls one, so documenting them would advertise a path new callers must not use',
|
||||
anchors: [
|
||||
{
|
||||
file: 'fluxer_api/src/api/download/DownloadController.ts',
|
||||
anchor: 'function redirectToPackageOrigin',
|
||||
},
|
||||
],
|
||||
covers: (_shape, routePath) => routePath === '/dl' || routePath.startsWith('/dl/'),
|
||||
},
|
||||
{
|
||||
name: 'backported separately',
|
||||
justification:
|
||||
@@ -1637,27 +1665,15 @@ console.log('unthrottled routes and global bucket claims');
|
||||
const problems: Array<string> = [];
|
||||
const uniquePublic = [...new Set(publicUnthrottled)].sort();
|
||||
|
||||
const unthrottledByDesign = new Set([
|
||||
'GET /dl/desktop/{}/{}/{}/latest',
|
||||
'GET /dl/desktop/{}/{}/{}/latest/{}',
|
||||
'GET /dl/desktop/{}/{}/{}/versions',
|
||||
'GET /dl/desktop/{}/{}/{}/{}/{}',
|
||||
]);
|
||||
const unexpected = uniquePublic.filter((shape) => !unthrottledByDesign.has(shape));
|
||||
const nowThrottled = [...unthrottledByDesign].filter((shape) => !uniquePublic.includes(shape)).sort();
|
||||
const unexpected = uniquePublic;
|
||||
|
||||
if (unexpected.length > 0) {
|
||||
problems.push(
|
||||
`rate-limits.md says every HTTP API operation outside the desktop downloads declares a bucket, but ${unexpected.length.toString()} more declare none: ${unexpected.join(', ')}`,
|
||||
`rate-limits.md says every HTTP API operation declares a bucket, but ${unexpected.length.toString()} declare none: ${unexpected.join(', ')}`,
|
||||
);
|
||||
}
|
||||
if (nowThrottled.length > 0) {
|
||||
problems.push(
|
||||
`rate-limits.md names the desktop downloads as the only operations with no bucket, but ${nowThrottled.length.toString()} now declare one: ${nowThrottled.join(', ')}`,
|
||||
);
|
||||
}
|
||||
if (!page.includes('[desktop download](/http-api/downloads/)')) {
|
||||
problems.push('rate-limits.md no longer names the desktop downloads as the operations with no bucket');
|
||||
if (!page.includes('Every HTTP API and Admin API operation declares a bucket')) {
|
||||
problems.push('rate-limits.md no longer states that every operation declares a bucket');
|
||||
}
|
||||
if (adminUnthrottled.length > 0) {
|
||||
const named = adminUnthrottled.map((entry) => `${entry.method} ${entry.route}`).sort();
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
---
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
title: Desktop builds
|
||||
description: Paths for desktop installers, checksums and the update feeds each platform reads.
|
||||
---
|
||||
|
||||
Every desktop path below is served from `https://pkgs.fluxer.com` and is built from four coordinates.
|
||||
|
||||
| Coordinate | Values |
|
||||
| --- | --- |
|
||||
| `channel` | `stable`, `canary` |
|
||||
| `platform` | `darwin`, `linux`, `win32` |
|
||||
| `arch` | `x64`, `arm64` |
|
||||
| `format` | `dmg`, `zip`, `setup`, `portable`, `appimage`, `deb`, `rpm`, `tar_gz` |
|
||||
|
||||
A format is only published for the platform it belongs to. `dmg` and `zip` are macOS, `setup` and `portable` are Windows, and the other four are Linux.
|
||||
|
||||
## Release metadata
|
||||
|
||||
```
|
||||
GET /desktop/{channel}/{platform}/{arch}/latest
|
||||
```
|
||||
|
||||
`latest` returns a JSON document naming the current version, its publication date, and a per-format URL and SHA256.
|
||||
|
||||
## Artifacts
|
||||
|
||||
```
|
||||
GET /desktop/{channel}/{platform}/{arch}/{version}/{format}
|
||||
GET /desktop/{channel}/{platform}/{arch}/{version}/{format}.sha256
|
||||
```
|
||||
|
||||
`version` is either a published version or the literal `latest`, which resolves to the current one. The `.sha256` sibling holds the checksum for the artifact beside it.
|
||||
|
||||
Linux AppImage builds also publish a zsync control file, so `AppImageUpdate`, `AppImageLauncher` and Gear Lever can fetch only the blocks that changed:
|
||||
|
||||
```
|
||||
GET /desktop/{channel}/linux/{arch}/{version}/appimage.zsync
|
||||
```
|
||||
|
||||
## Update feeds
|
||||
|
||||
Each platform's updater reads the feed its own framework expects, beside the artifacts:
|
||||
|
||||
| Platform | Feed |
|
||||
| --- | --- |
|
||||
| macOS | `RELEASES.json`, `releases.json` |
|
||||
| Windows | `RELEASES`, `releases.win.json`, `assets.win.json` |
|
||||
| All | `manifest.json`, `version.json`, `latest.json` |
|
||||
@@ -0,0 +1,72 @@
|
||||
---
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
title: Linux repositories
|
||||
description: The apt, dnf, pacman and Flatpak repositories Fluxer publishes, and how a client adds them.
|
||||
---
|
||||
|
||||
Fluxer publishes four Linux repositories. The apt, dnf and pacman entrypoints each subscribe to one channel, so the file you install decides whether you track stable or canary. The package name follows from that, `fluxer` for stable and `fluxer-canary` for canary. Flatpak is the exception: one remote serves both, and the application id selects the channel.
|
||||
|
||||
## apt
|
||||
|
||||
One repository serves Debian and Ubuntu. It uses the standard `dists` and `pool` layout, publishes both SHA256 and SHA512 by-hash indexes, and is signed.
|
||||
|
||||
```
|
||||
sudo install -d -m 0755 /etc/apt/keyrings
|
||||
sudo curl -fsSL -o /etc/apt/keyrings/fluxer-archive-keyring.gpg \
|
||||
https://pkgs.fluxer.com/keys/fluxer-archive-keyring.gpg
|
||||
sudo curl -fsSL -o /etc/apt/sources.list.d/fluxer.sources \
|
||||
https://pkgs.fluxer.com/deb/fluxer.sources
|
||||
sudo apt update && sudo apt install fluxer
|
||||
```
|
||||
|
||||
The `.sources` entry uses `Signed-By` rather than `Trusted: yes`, so `apt update` verifies the repository and prints nothing.
|
||||
|
||||
## dnf
|
||||
|
||||
One repository serves Fedora and the RHEL family, split by channel and architecture. It is signed, with both `gpgcheck` and `repo_gpgcheck` enabled.
|
||||
|
||||
```
|
||||
sudo curl -fsSL -o /etc/yum.repos.d/fluxer.repo \
|
||||
https://pkgs.fluxer.com/rpm/fluxer.repo
|
||||
sudo dnf install fluxer
|
||||
```
|
||||
|
||||
Metadata expires after six hours, so a freshly published build becomes visible within that window, or immediately with `dnf --refresh upgrade`.
|
||||
|
||||
:::caution[RHEL, Rocky, Alma and CentOS Stream need EPEL]
|
||||
The package depends on `libXScrnSaver`, which the EL base repositories do not ship. Run `sudo dnf install epel-release` first. Fedora does not need this.
|
||||
:::
|
||||
|
||||
## pacman
|
||||
|
||||
```
|
||||
sudo tee -a /etc/pacman.conf >/dev/null <<'REPO'
|
||||
|
||||
[fluxer]
|
||||
SigLevel = Never
|
||||
Server = https://pkgs.fluxer.com/arch/$repo/os/$arch
|
||||
REPO
|
||||
sudo pacman -Syu --noconfirm fluxer
|
||||
```
|
||||
|
||||
Write `$repo` and `$arch` literally. Both are pacman variables, not shell ones, which is why the heredoc above is quoted. `$repo` expands to the section name, so the same line works for `fluxer` and `fluxer-canary`.
|
||||
|
||||
A pacman sync database records one version per package name, so only the current release is installable by name. An older build is still served, and `curl` followed by `pacman -U ./<file>` installs it.
|
||||
|
||||
## Flatpak
|
||||
|
||||
One remote named `fluxer` serves both application ids, `app.fluxer.Fluxer` and `app.fluxer.FluxerCanary`.
|
||||
|
||||
```
|
||||
flatpak install https://pkgs.fluxer.com/flatpak/fluxer.flatpakref
|
||||
```
|
||||
|
||||
Use `fluxer-canary.flatpakref` for the canary channel. The reference file names the remote and resolves the runtime the application builds against, so this works on a machine with no remotes configured.
|
||||
|
||||
Once the remote exists, either application installs by id:
|
||||
|
||||
```
|
||||
flatpak install fluxer app.fluxer.FluxerCanary
|
||||
```
|
||||
|
||||
No `--no-gpg-verify` flag is required. The repository is unsigned, and flatpak reads that from the repository metadata itself.
|
||||
@@ -0,0 +1,34 @@
|
||||
---
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
title: Package origin overview
|
||||
description: The public origin that serves Fluxer desktop builds and Linux package repositories.
|
||||
---
|
||||
|
||||
Fluxer publishes every desktop build and every Linux package repository to one public origin, `https://pkgs.fluxer.com`. It is a static file origin. It serves bytes and nothing else, it needs no credential, and it declares no rate limit bucket.
|
||||
|
||||
## Channels
|
||||
|
||||
Every path names a channel, either `stable` or `canary`. The two are published independently and never share a file. The package name follows the channel: `fluxer` on stable, `fluxer-canary` on canary. A machine may install both at once.
|
||||
|
||||
## What the origin serves
|
||||
|
||||
| Prefix | Contents |
|
||||
| --- | --- |
|
||||
| `/desktop/` | Desktop installers, checksums and update feeds |
|
||||
| `/flatpak/` | An ostree repository holding both application ids |
|
||||
| `/arch/` | A pacman repository, one directory per architecture |
|
||||
| `/deb/` | An apt repository in the `dists` and `pool` layout |
|
||||
| `/rpm/` | A dnf repository, one directory per channel and architecture |
|
||||
| `/keys/` | The public signing key for the apt and rpm repositories |
|
||||
|
||||
## What it guarantees
|
||||
|
||||
The origin answers `Range`, `If-Range` and `If-None-Match` on every artifact, so a client may resume an interrupted download and revalidate a cached one. It never lists a directory. A request for a path that does not exist returns 404 with no body.
|
||||
|
||||
Artifacts under a version directory never change once published, so they are cached for a year. The files that move when a release ships, the `latest` documents and every repository index, are cached for minutes.
|
||||
|
||||
## Signing
|
||||
|
||||
The apt and rpm repositories are signed. The public key lives at `/keys/fluxer-archive-keyring.asc`, and the entrypoint files reference it, so a client verifies every package it installs.
|
||||
|
||||
The flatpak and pacman repositories are unsigned and rely on HTTPS for transport integrity. pacman still verifies each package against the SHA256 its index records, and flatpak verifies each object against its content address.
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -497,10 +497,6 @@ Default `fluxer`. Processed assets. `media-proxy` defaults to `cdn`.
|
||||
|
||||
Default `fluxer-uploads`. Raw uploads. `media-proxy` defaults to `uploads`, `app-proxy` to `fluxer-uploads`.
|
||||
|
||||
#### `FLUXER_S3_BUCKET_DOWNLOADS`
|
||||
|
||||
Default `fluxer-downloads`. Desktop build artefacts. Read by `api` and `worker`.
|
||||
|
||||
#### `FLUXER_S3_BUCKET_REPORTS`
|
||||
|
||||
Default `fluxer-reports`. Abuse report evidence. Read by `api` and `worker`.
|
||||
@@ -513,8 +509,6 @@ Default `fluxer-harvests`. Data archives. Read by `api` and `worker`.
|
||||
|
||||
Default `static`. Static assets. Read by `media-proxy` only in `static` mode, which the stack does not use. `api` and `worker` never read it, and `seaweedfs-init` does not create this bucket.
|
||||
|
||||
A separate downloads provider is available. `FLUXER_S3_DOWNLOADS_ENDPOINT`, `FLUXER_S3_DOWNLOADS_PUBLIC_ENDPOINT`, `FLUXER_S3_DOWNLOADS_FORCE_PATH_STYLE`, `FLUXER_S3_DOWNLOADS_REGION`, `FLUXER_S3_DOWNLOADS_ACCESS_KEY_ID`, and `FLUXER_S3_DOWNLOADS_SECRET_ACCESS_KEY` take effect only when `FLUXER_S3_DOWNLOADS_ENDPOINT` is non-empty, and they then replace the primary configuration for the downloads bucket.
|
||||
|
||||
The Media Proxy read path has overrides of its own. All are optional.
|
||||
|
||||
#### `FLUXER_S3_READ_ENDPOINT`
|
||||
@@ -1137,10 +1131,6 @@ Default `336`. How long a deleted account is recoverable. Forced to 0.01 hours w
|
||||
|
||||
Default `false`. Presigned attachment uploads. Compose sets `true`.
|
||||
|
||||
#### `FLUXER_API_PRESIGNED_DOWNLOADS_ENABLED`
|
||||
|
||||
Default `false`. Presigned downloads. Applies to the downloads bucket.
|
||||
|
||||
#### `FLUXER_API_PRESIGNED_HARVEST_DOWNLOADS_ENABLED`
|
||||
|
||||
Default `true`. Presigned harvest downloads. Applies to the harvests bucket.
|
||||
@@ -1177,10 +1167,6 @@ Default `true`. Whether a remote cache header is honoured. Clamped by `FLUXER_AP
|
||||
|
||||
Default empty. Hosts never unfurled. Comma separated, unvalidated.
|
||||
|
||||
#### `FLUXER_API_DESKTOP_GITHUB_REDIRECT_COUNTRIES`
|
||||
|
||||
Default empty. Countries redirected to GitHub for desktop downloads. Each entry must be two uppercase letters or the API fails at boot.
|
||||
|
||||
#### `FLUXER_TEST_MODE_ENABLED`
|
||||
|
||||
Default `false`. Test mode. Collapses the deletion grace period. Reaches production containers if set.
|
||||
|
||||
@@ -217,7 +217,7 @@ FLUXER_S3_REGION=eu-central-1
|
||||
FLUXER_S3_FORCE_PATH_STYLE=false
|
||||
```
|
||||
|
||||
`FLUXER_S3_PUBLIC_ENDPOINT` follows `FLUXER_S3_ENDPOINT` when it is not set on its own, and the credentials stay `FLUXER_S3_ACCESS_KEY` and `FLUXER_S3_SECRET_KEY`. `FLUXER_S3_BUCKET_CDN`, `FLUXER_S3_BUCKET_UPLOADS`, `FLUXER_S3_BUCKET_DOWNLOADS`, `FLUXER_S3_BUCKET_REPORTS` and `FLUXER_S3_BUCKET_HARVESTS` name the buckets. The bundled object store creates whichever names they hold, and a store outside the stack needs those buckets to exist already.
|
||||
`FLUXER_S3_PUBLIC_ENDPOINT` follows `FLUXER_S3_ENDPOINT` when it is not set on its own, and the credentials stay `FLUXER_S3_ACCESS_KEY` and `FLUXER_S3_SECRET_KEY`. `FLUXER_S3_BUCKET_CDN`, `FLUXER_S3_BUCKET_UPLOADS`, `FLUXER_S3_BUCKET_REPORTS` and `FLUXER_S3_BUCKET_HARVESTS` name the buckets. The bundled object store creates whichever names they hold, and a store outside the stack needs those buckets to exist already.
|
||||
|
||||
`FLUXER_KV_URL`, `FLUXER_NATS_URL`, `FLUXER_SEARCH_URL` and `FLUXER_LIVEKIT_INTERNAL_URL` move the other bundled services the same way, and `FLUXER_NATS_JETSTREAM_URL` and `FLUXER_SVC_NATS_URL` follow `FLUXER_NATS_URL` when they are not set on their own.
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ A request also counts against the global bucket, unless its route bucket is decl
|
||||
|
||||
These buckets are exempt, and each is the only bucket its route declares: `webhook:execute::webhook_id`, `webhook:message_get::webhook_id`, `webhook:message_edit::webhook_id`, `webhook:message_delete::webhook_id`, `webhook:github::webhook_id`, `webhook:instatus::webhook_id`, and `stripe:webhook`. Those routes draw on no global allowance. The `user:group_dm:create` and `user:group_dm:recipient:add` buckets are exempt as well. Each is a second bucket on a route whose first bucket is not exempt, so both routes still draw on the global allowance.
|
||||
|
||||
Every HTTP API and Admin API operation declares a bucket, apart from the [desktop download](/http-api/downloads/) routes, which declare none. A caller that sends no credential on a [Bluesky client document](/http-api/connections/#get-bluesky-client-metadata) is keyed by the client IP address.
|
||||
Every HTTP API and Admin API operation declares a bucket. A caller that sends no credential on a [Bluesky client document](/http-api/connections/#get-bluesky-client-metadata) is keyed by the client IP address.
|
||||
|
||||
The global window is one second. The default allowance is 50 requests per second, and an account holding the [`HIGH_GLOBAL_RATE_LIMIT`](/admin-api/users/#account-flags) flag receives 1,200 requests per second instead. The [`RATE_LIMIT_BYPASS`](/admin-api/users/#account-flags) flag exempts an account from the global bucket and from every route bucket. A successful response to that account has no rate limit header.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user