mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-19 05:55:34 +09:00
docs: move the reference site to astro starlight (#2527)
This commit is contained in:
1 parent
7f8f09ee51
commit
5367c0ab42
198 files changed
+59927
-1216
No files matched your search
@@ -0,0 +1,87 @@
|
||||
---
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
title: CAPTCHA handling
|
||||
description: How a client discovers the CAPTCHA provider and answers a challenge.
|
||||
---
|
||||
|
||||
An instance can require a CAPTCHA solution on a small set of abuse-sensitive operations. A client reads the selected provider and its site key from [instance discovery](/http-api/instance/#instance-discovery-object), renders that provider's widget, and sends the solution on the gated request.
|
||||
|
||||
## Discovering the provider
|
||||
|
||||
`GET /.well-known/fluxer` publishes the [CAPTCHA configuration object](/http-api/instance/#captcha-configuration-object) inside the [instance discovery object](/http-api/instance/#instance-discovery-object). Its `provider` field names the selected [CAPTCHA provider](/http-api/instance/#captcha-providers), and `hcaptcha_site_key` or `turnstile_site_key` is that provider's site key. The other key is null, and both are null when `provider` is `none`.
|
||||
|
||||
The value `none` means the instance challenges no operation. A gated operation then proceeds with no CAPTCHA header. The values `hcaptcha` and `turnstile` name the provider whose widget a client renders.
|
||||
|
||||
Fluxer names a provider only while that provider holds both a site key and a secret key. An incomplete pair reports `none`, so a named provider means the instance enforces verification.
|
||||
|
||||
## Gated operations
|
||||
|
||||
The following operations verify a CAPTCHA while the instance enforces verification.
|
||||
|
||||
| Method | Route | Operation |
|
||||
| --- | --- | --- |
|
||||
| POST | /v1/auth/register | [Register an account](/http-api/authentication/#register-an-account) |
|
||||
| POST | /v1/auth/login | [Log in with a password](/http-api/authentication/#log-in-with-a-password) |
|
||||
| POST | /v1/auth/forgot | [Request password recovery](/http-api/authentication/#request-password-recovery) |
|
||||
| POST | /v1/oauth2/applications | [Create application](/http-api/applications/#create-application) |
|
||||
| POST | /v1/gifts/{code}/redeem | [Redeem gift](/http-api/gifts/#redeem-gift) |
|
||||
| POST | /v1/users/@me/channels | [Create private channel](/http-api/users/private-channels/#create-private-channel) |
|
||||
| PUT | /v1/channels/{channel_id}/recipients/{user_id} | [Add group direct message recipient](/http-api/channels/#add-group-direct-message-recipient) |
|
||||
|
||||
Create private channel is gated only on the group direct message path, where the request body has a `recipients` member. A one-to-one direct message request omits the field and is never gated.
|
||||
|
||||
Create application, redeem gift, create private channel, and add group direct message recipient reject an unauthenticated request before Fluxer reads the CAPTCHA. The three authentication operations accept a request with no credential, and each of them returns 403 `SSO_REQUIRED` before Fluxer reads the CAPTCHA when the instance enforces single sign-on.
|
||||
|
||||
## Exemption
|
||||
|
||||
Two exemptions skip the challenge. Fluxer tests both of them before it reads the token. A request that passes either one proceeds as though the instance had no provider configured.
|
||||
|
||||
The first exemption is the `captcha_exempt` capability the instance account policy grants to a contact address. A policy rule matches the address itself or the domain it belongs to, so one grant can cover a whole domain. Fluxer tests it against the resolved account's email address, then against an `email` member of the request body.
|
||||
|
||||
The second exemption is the `APP_STORE_REVIEWER` user flag. Fluxer tests it against the resolved account, then against the account an `email` member of the request body resolves to.
|
||||
|
||||
The body checks parse the request body as JSON and read a string `email` member, and a body that is absent, is not JSON, or is not a JSON object yields no address. Those checks exempt a login or a registration attempt before any account is resolved.
|
||||
|
||||
The exemptions run before request validation on the three authentication operations, on create application, and on redeem gift. Create private channel and add group direct message recipient validate the request first, so an invalid request is rejected before any exemption is tested.
|
||||
|
||||
No exemption is visible in an API response. A client cannot predict one and handles a challenge on every gated operation.
|
||||
|
||||
## Request headers
|
||||
|
||||
| Field | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| X-Captcha-Token?<sup>1</sup> | string | The solution issued by the provider widget |
|
||||
| X-Captcha-Type?<sup>2</sup> | string | The provider that produced the solution, accepting `hcaptcha` or `turnstile` |
|
||||
|
||||
<sup>1</sup> An absent or empty value on a gated operation returns 400 `CAPTCHA_REQUIRED`
|
||||
|
||||
<sup>2</sup> An absent value selects the instance's configured provider, and so does any value other than `hcaptcha` or `turnstile`. Naming a provider the instance holds no secret key for returns 400 `INVALID_CAPTCHA`.
|
||||
|
||||
## The retry handshake
|
||||
|
||||
A client that has never been challenged sends the gated request without any CAPTCHA header. When the instance enforces verification and no exemption applies, that request fails with 400 `CAPTCHA_REQUIRED`.
|
||||
|
||||
The client then renders the selected provider's widget with its advertised site key, obtains a solution, and repeats the identical request with `X-Captcha-Token` set to the solution. It can send `X-Captcha-Type` to state which provider produced the solution.
|
||||
|
||||
A retry succeeds when the provider accepts the solution and fails with 400 `INVALID_CAPTCHA` when it does not.
|
||||
|
||||
:::caution[A solution is single-use]
|
||||
The provider treats an already redeemed solution as invalid. A client obtains a new solution before retrying after `INVALID_CAPTCHA` and MUST NOT replay the previous `X-Captcha-Token` value.
|
||||
:::
|
||||
|
||||
## Provider verification
|
||||
|
||||
Fluxer submits the solution to the selected provider's verification endpoint over HTTPS with a 10-second deadline. It sends the caller's client IP address alongside the solution and omits it when the request resolves none.
|
||||
|
||||
Any outcome other than a successful provider verdict answers 400 `INVALID_CAPTCHA`. That covers an unsuccessful verdict, a non-2xx provider status, an unparseable provider payload, the 10-second timeout, and any transport failure. A rejected solution is therefore never distinguishable from an unreachable provider.
|
||||
|
||||
## Error codes
|
||||
|
||||
| Code | Status | Description |
|
||||
| --- | --- | --- |
|
||||
| CAPTCHA_REQUIRED<sup>1</sup> | 400 | The operation is gated and the request has no solution |
|
||||
| INVALID_CAPTCHA | 400 | The provider rejected the solution, or verification could not be completed |
|
||||
|
||||
<sup>1</sup> [Send phone verification](/http-api/users/phone-verification/#send-phone-verification) also answers this code when its phone attempt risk controls return a captcha decision. That operation is not gated and accepts no solution, so retrying it with `X-Captcha-Token` never helps
|
||||
|
||||
Both codes are defined in the [API error code registry](/http-api/errors/#api-error-code-registry), and the body of each is the ordinary [error response](/http-api/#error-response) envelope.
|
||||
@@ -0,0 +1,74 @@
|
||||
---
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
title: Locales
|
||||
description: The supported locale registry and how Fluxer resolves a response locale.
|
||||
---
|
||||
|
||||
A locale tells Fluxer which language to write human-readable text in. Each one is a BCP 47 tag from the registry below, either a bare language subtag such as `de`, or one joined to a region or numeric subtag by a hyphen, such as `en-GB`, `pt-BR`, and `es-419`.
|
||||
|
||||
## Supported locales
|
||||
|
||||
This registry is the complete set for every Fluxer surface. Wherever Fluxer stores a locale, such as in [user settings](/http-api/users/settings/), it requires an exact value from this table.
|
||||
|
||||
| Value | Description |
|
||||
| --- | --- |
|
||||
| ar | Arabic |
|
||||
| bg | Bulgarian |
|
||||
| cs | Czech |
|
||||
| da | Danish |
|
||||
| de | German |
|
||||
| el | Greek |
|
||||
| en-GB | English (United Kingdom) |
|
||||
| en-US<sup>1</sup> | English (United States) |
|
||||
| es-ES | Spanish (Spain) |
|
||||
| es-419 | Spanish (Latin America) |
|
||||
| fi | Finnish |
|
||||
| fr | French |
|
||||
| he | Hebrew |
|
||||
| hi | Hindi |
|
||||
| hr | Croatian |
|
||||
| hu | Hungarian |
|
||||
| id | Indonesian |
|
||||
| it | Italian |
|
||||
| ja | Japanese |
|
||||
| ko | Korean |
|
||||
| lt | Lithuanian |
|
||||
| nl | Dutch |
|
||||
| no | Norwegian |
|
||||
| pl | Polish |
|
||||
| pt-BR | Portuguese (Brazil) |
|
||||
| ro | Romanian |
|
||||
| ru | Russian |
|
||||
| sv-SE<sup>2</sup> | Swedish |
|
||||
| th | Thai |
|
||||
| tr | Turkish |
|
||||
| uk | Ukrainian |
|
||||
| vi | Vietnamese |
|
||||
| zh-CN | Chinese (Simplified) |
|
||||
| zh-TW | Chinese (Traditional) |
|
||||
|
||||
<sup>1</sup> [Negotiation](#negotiation) resolves to this value whenever it selects no other registry value. The bare tag `en` is a registered alias for it during negotiation and is not itself a storable value
|
||||
|
||||
<sup>2</sup> The bare tag `sv` is a registered alias for it during negotiation and is not itself a storable value
|
||||
|
||||
## Negotiation
|
||||
|
||||
Fluxer resolves the response locale once for each request. When a request resolves an authenticated user with a stored locale, Fluxer takes that locale. Every other request negotiates the `Accept-Language` header against the [supported locale registry](#supported-locales), and `en-US` is the result whenever negotiation selects no registry value.
|
||||
|
||||
An account created by password registration stores the locale negotiated from its own registration request, so the `Accept-Language` header on that request sets the stored value. An account provisioned through single sign-on stores no account locale. Its [user settings](/http-api/users/settings/) locale reads `en-US`, and its requests negotiate `Accept-Language` until the locale setting is changed.
|
||||
|
||||
Fluxer splits the header on commas. It trims each member and then splits it on semicolons. The text before the first semicolon is the language range, and Fluxer reads only the first parameter after it, looking for a `q=` weight. A member with no readable `q=` value has weight 1. Fluxer orders the members by descending weight, and members of equal weight keep their header order.
|
||||
|
||||
Fluxer then runs two passes over that ordered list.
|
||||
|
||||
The first pass takes the earliest member whose range names a registry value exactly. Fluxer trims the range, replaces every underscore with a hyphen, and lowercases it before comparing, so `EN-GB` and `en_gb` both name `en-GB`. The bare tags `en` and `sv` are registered aliases for `en-US` and `sv-SE` and match in this pass.
|
||||
|
||||
The second pass runs only when the first selects nothing. It reduces each member in the same order to its language subtag. A language subtag with a declared preference selects that value. The declared preferences are `en` to `en-US`, `es` to `es-ES`, `pt` to `pt-BR`, `zh` to `zh-CN`, and `sv` to `sv-SE`, so `en-AU` selects `en-US` and `pt-PT` selects `pt-BR`. A language subtag without a declared preference selects the first registry value whose tag begins with that subtag and a hyphen.
|
||||
|
||||
No registry tag begins with a subtag outside those five followed by a hyphen, so `de-AT` and `xx-YY` both select nothing. A weight of 0 orders a member last, and that member can still be selected. A range of `*` matches no registry value.
|
||||
|
||||
The resolved locale selects the localised `message` in an [error response](/http-api/#error-response) and in each element of a validation `errors` array. The `code` field is never localised.
|
||||
|
||||
:::note[Locale selection changes human-readable text only]
|
||||
A field name, an enumeration value, an error `code`, a [snowflake](/snowflakes/), and a timestamp representation are identical under every locale.
|
||||
:::
|
||||
@@ -0,0 +1,212 @@
|
||||
---
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
title: Rate limits
|
||||
description: The route and global buckets, the denial body, and the rate limit headers.
|
||||
---
|
||||
|
||||
Fluxer bounds HTTP API traffic with two allowances, a per-route bucket and one global bucket. A bucket is one allowance counted over one window. Fluxer denies an over-allowance request with 429, `code` set to `RATE_LIMITED`, a [rate limit response object](#rate-limit-response-object), and the [rate limit headers](#rate-limit-headers).
|
||||
|
||||
## Buckets and scope
|
||||
|
||||
Nearly every route declares its own bucket. A bucket name can have a path parameter placeholder written as a colon followed by the parameter name, as in `guild:emojis:list::guild_id`. Fluxer substitutes the request value before keying the bucket, so one route holds a separate allowance for each path resource.
|
||||
|
||||
Every bucket is also keyed by the caller's identity. An authenticated request is keyed by the account and its credential kind, and an OAuth2 bearer credential is keyed by the owning application as well. A session, a bot token, an Admin API key, and each bearer application therefore draw on separate allowances for the same account.
|
||||
|
||||
A request that resolves no account is keyed by the client IP address, exactly for IPv4 and by the `/64` for IPv6, so clients in the same `/64` share an allowance. Where the deployment is configured to read the address from a header the request does not have, it is refused with 403 `FORBIDDEN` before any bucket is evaluated.
|
||||
|
||||
Fluxer also evaluates a route bucket against the global bucket unless the route declares that bucket exempt. The global bucket is keyed by the same identity, so a request that resolves no account consumes the global allowance of its client IP address.
|
||||
|
||||
Seven buckets are exempt, and each is the only bucket its route declares. Those routes draw on no global allowance: `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`. The `user:group_dm:create` and `user:group_dm:recipient:add` buckets are exempt as well. Each sits on a route that already consumed a non-exempt bucket, so both routes still draw on the global allowance.
|
||||
|
||||
Every HTTP API and Admin API operation declares a bucket, apart from the seven [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.
|
||||
|
||||
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. An account holding the [`RATE_LIMIT_BYPASS`](/admin-api/users/#account-flags) flag is evaluated against neither the global bucket nor any route bucket, and receives no rate limit header on a successful response.
|
||||
|
||||
Some operations enforce a further limit inside the handler. `RATE_LIMIT_BYPASS` exempts an account from none of them. [Limits enforced inside a handler](#limits-enforced-inside-a-handler) has the complete set.
|
||||
|
||||
The global bucket is evaluated first. A route bucket is consumed only after the global check admits the request.
|
||||
|
||||
:::note[An allowance drains continuously]
|
||||
Every bucket is a leaky bucket. It admits at most the declared limit at once and refills continuously at that limit for each declared window, so a client that exhausts an allowance can send again as soon as enough of it has drained.
|
||||
:::
|
||||
|
||||
:::note[The headers and the body report different instants]
|
||||
`X-RateLimit-Reset` and `X-RateLimit-Reset-After` report when the bucket has drained completely. `retry_after` in the response body reports the shorter delay after which one further request is admitted.
|
||||
:::
|
||||
|
||||
:::note[A 429 can hide a 401 or 403]
|
||||
A route's rate limit middleware normally runs before its authentication policy, so an over-allowance request returns 429 `RATE_LIMITED` where the same request inside its allowance would return 401 or 403. Fluxer resolves the credential before either check, and both buckets are keyed by the authenticated account whenever one resolves.
|
||||
:::
|
||||
|
||||
Four routes charge a second bucket. [Create private channel](/http-api/users/private-channels/#create-private-channel) and [Add group direct message recipient](/http-api/channels/#add-group-direct-message-recipient) evaluate that second bucket after the authentication policy and the request validation, so an unauthenticated or malformed request is refused before it is consumed. Create private channel consumes `user:group_dm:create` only when the validated body supplies `recipients`. A one-to-one direct message create reaches no second bucket.
|
||||
|
||||
[Delete guild emoji](/http-api/guild-emojis/#delete-guild-emoji) and [Delete guild sticker](/http-api/guild-stickers/#delete-guild-sticker) declare both of their buckets ahead of the authentication policy and the request validation, so an unauthenticated or malformed request consumes the second bucket too. The `guild:emoji:delete:daily::guild_id` and `guild:sticker:delete:daily::guild_id` buckets draw on the global allowance, and one delete request evaluates it twice.
|
||||
|
||||
:::caution[A global denial revokes a user session]
|
||||
When the global bucket denies a request authenticated by a user session token belonging to a non-bot account, Fluxer revokes that session token before writing the 429 and the client must authenticate again. A bot token, an OAuth2 access token, and an Admin API key are never revoked this way, and a route bucket denial never revokes a credential.
|
||||
:::
|
||||
|
||||
A deployment can disable both buckets through instance configuration. While they are disabled, no response has an `X-RateLimit-*` header and no request is refused with 429 `RATE_LIMITED`. That switch also turns off the two login allowances. Every other [limit enforced inside a handler](#limits-enforced-inside-a-handler) stays in force.
|
||||
|
||||
## Rate limit response object
|
||||
|
||||
The denial body has the two members of the ordinary [error response](/http-api/#error-response) and two further members.
|
||||
|
||||
### Structure
|
||||
|
||||
| Field | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| code<sup>1</sup> | string | `RATE_LIMITED` on a bucket denial |
|
||||
| message<sup>2</sup> | string | The localised rate limit message |
|
||||
| global | boolean | Whether the global bucket produced the denial, present and false on a route denial |
|
||||
| retry_after<sup>3</sup> | number | The delay in fractional seconds before another request is admitted |
|
||||
|
||||
<sup>1</sup> A limit enforced outside the route bucket middleware can reuse this body with its own code. [Send phone verification](/http-api/users/phone-verification/#send-phone-verification) is the only live one, reporting `PHONE_RATE_LIMIT_EXCEEDED`
|
||||
|
||||
<sup>2</sup> The locale is the one resolved for the request, which the account setting selects ahead of [Accept-Language](/http-api/#standard-request-headers)
|
||||
|
||||
<sup>3</sup> Never below 0.001, falling back to the whole-second `Retry-After` value when no fractional delay was computed
|
||||
|
||||
The [Resend IP authorisation](/http-api/authentication/#resend-ip-authorisation) cooldown answers 429 with its own code and a different body. [Allowances answering 429](#allowances-answering-429) states what that body has.
|
||||
|
||||
A [slowmode](#slowmode) denial answers 400 `SLOWMODE_RATE_LIMITED`.
|
||||
|
||||
### Example
|
||||
|
||||
```json
|
||||
{
|
||||
"code": "RATE_LIMITED",
|
||||
"message": "You're being rate limited.",
|
||||
"global": false,
|
||||
"retry_after": 0.428
|
||||
}
|
||||
```
|
||||
|
||||
## Rate limit scopes
|
||||
|
||||
The `X-RateLimit-Scope` header is the scope that produced a denial.
|
||||
|
||||
| Value | Description |
|
||||
| --- | --- |
|
||||
| user | The denial came from an allowance keyed by the caller alone |
|
||||
| global | The denial came from the global bucket |
|
||||
| shared<sup>1</sup> | The denial came from an allowance that several accounts can exhaust for each other |
|
||||
|
||||
<sup>1</sup> No route bucket declares a scope of its own, so every route bucket denial reports `user`. [Send phone verification](/http-api/users/phone-verification/#send-phone-verification) is the only live source of `shared`
|
||||
|
||||
Phone verification reports `shared` when the per-number send allowance or a number-scoped provider cooldown produced the denial. Both are keyed by the submitted number, so two accounts sending to one number share the allowance.
|
||||
|
||||
## Rate limit headers
|
||||
|
||||
These headers describe a rate limit decision. An operation that answers 429 returns this set.
|
||||
|
||||
| Field | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| Retry-After?<sup>1</sup> | string | The delay in whole seconds before another request is admitted, rounded up and never below 1 |
|
||||
| X-RateLimit-Scope?<sup>1</sup> | string | The [rate limit scope](#rate-limit-scopes) that produced the denial |
|
||||
| X-RateLimit-Global?<sup>2</sup> | string | The literal value `true` on a global HTTP 429 |
|
||||
| X-RateLimit-Limit?<sup>3</sup> | string | The maximum requests the route allowance admits at once |
|
||||
| X-RateLimit-Remaining?<sup>3</sup> | string | The whole requests the route allowance still admits, always 0 on a denial |
|
||||
| X-RateLimit-Reset?<sup>3</sup> <sup>4</sup> | string | The Unix timestamp in seconds at which the whole route allowance is available again |
|
||||
| X-RateLimit-Reset-After?<sup>3</sup> <sup>5</sup> | string | The seconds until the whole route allowance is available again |
|
||||
| X-RateLimit-Bucket?<sup>3</sup> <sup>6</sup> | string | The stable 16-character hexadecimal identifier of the route bucket |
|
||||
|
||||
<sup>1</sup> Sent on every rate limit denial and on no successful response
|
||||
|
||||
<sup>2</sup> Sent only when the global bucket produced the denial, in which case none of the route bucket headers is sent
|
||||
|
||||
<sup>3</sup> Sent on a route HTTP 429, subject to footnote 6 for `X-RateLimit-Bucket`. On a successful response they are sent only when the credential resolves to a bot account, or when the request resolves no account at all on a route with both a `webhook_id` and a `token` path parameter
|
||||
|
||||
<sup>4</sup> A value at or before the current second is replaced with the next second
|
||||
|
||||
<sup>5</sup> Rounded to millisecond precision with trailing zeros removed on a successful response, and emitted as the exact computed decimal on a denial
|
||||
|
||||
<sup>6</sup> The leading 16 hexadecimal characters of the SHA-256 digest of the route's declared bucket name before any path parameter is substituted, so it identifies the route and never the caller
|
||||
|
||||
A 429 from a limit enforced inside a handler has the other four route headers and no `X-RateLimit-Bucket`.
|
||||
|
||||
:::note[A browser client reads none of these headers]
|
||||
The [cross-origin policy](/http-api/#cross-origin-requests) exposes only `X-Fluxer-Version`, so a script running on an allowed origin observes the 429 status and the response body, and no header.
|
||||
:::
|
||||
|
||||
## Limits enforced inside a handler
|
||||
|
||||
Some operations bound a further allowance inside the handler. Each one is keyed independently of the route bucket and of the global bucket, so exhausting it denies the request while both buckets still have room. The set below is complete.
|
||||
|
||||
Two deployment switches disable part of this set. `disable_rate_limits` turns off the two login allowances along with both buckets. `relax_registration_rate_limits` turns off the three registration allowances. Every other allowance below is enforced on every deployment.
|
||||
|
||||
A denial takes one of two shapes. A send or submission allowance answers 429 with the [rate limit response object](#rate-limit-response-object) and the [rate limit headers](#rate-limit-headers) minus `X-RateLimit-Bucket`. A change allowance answers 400 `INVALID_FORM_BODY` with one [validation error](/http-api/#validation-error-object) entry whose `code` names the exhausted allowance.
|
||||
|
||||
The 400 shape has no `retry_after` member, no `X-RateLimit-*` header, and no `Retry-After` header. The remaining delay appears only in the entry's localised `message`.
|
||||
|
||||
### Allowances answering 429
|
||||
|
||||
| Operation | Allowance | Code |
|
||||
| --- | --- | --- |
|
||||
| [Log in with a password](/http-api/authentication/#log-in-with-a-password) | 5 per 15 minutes, keyed by the submitted email address | `RATE_LIMITED` |
|
||||
| [Log in with a password](/http-api/authentication/#log-in-with-a-password) | 10 per 30 minutes, keyed by the client IP address, exactly for IPv4 and by the `/64` for IPv6 | `RATE_LIMITED` |
|
||||
| [Register an account](/http-api/authentication/#register-an-account) | 3 per 15 minutes, keyed by the submitted email address | `RATE_LIMITED` |
|
||||
| [Register an account](/http-api/authentication/#register-an-account) | 3 per hour, keyed by the client IP address | `RATE_LIMITED` |
|
||||
| [Register an account](/http-api/authentication/#register-an-account) | 15 per hour, keyed by the client subnet, the IPv4 `/24` or the IPv6 `/48` | `RATE_LIMITED` |
|
||||
| [Resend email verification](/http-api/authentication/#resend-email-verification) | 3 per 15 minutes, keyed by the account's stored email address | `RATE_LIMITED` |
|
||||
| [Request password recovery](/http-api/authentication/#request-password-recovery) | 20 per 30 minutes, keyed by the client IP address | `RATE_LIMITED` |
|
||||
| [Request password recovery](/http-api/authentication/#request-password-recovery) | 5 per 30 minutes, keyed by the submitted email address | `RATE_LIMITED` |
|
||||
| [Start email change](/http-api/users/email-and-password/#start-email-change) and [Resend original email code](/http-api/users/email-and-password/#resend-original-email-code) | 3 sends per 15 minutes, keyed by the authenticated account | `RATE_LIMITED` |
|
||||
| [Request new email](/http-api/users/email-and-password/#request-new-email), [Resend new email code](/http-api/users/email-and-password/#resend-new-email-code), and both bounced recovery sends | 5 sends per 15 minutes, keyed by the authenticated account | `RATE_LIMITED` |
|
||||
| [Start password change](/http-api/users/email-and-password/#start-password-change) | 3 sends per 15 minutes, keyed by the authenticated account | `RATE_LIMITED` |
|
||||
| [Resend password change code](/http-api/users/email-and-password/#resend-password-change-code) | 3 sends per 15 minutes, keyed by the authenticated account | `RATE_LIMITED` |
|
||||
| Every code resend and every new-address request on an email or password change ticket | 1 send per 30 seconds, keyed by the previous send recorded on that ticket | `RATE_LIMITED` |
|
||||
| [Report message](/http-api/reports/#report-message), [Report user](/http-api/reports/#report-user), [Report guild](/http-api/reports/#report-guild), and [Create DSA report](/http-api/reports/#create-dsa-report) | 5 per hour, keyed by the reporter, an account or a verified email address | `RATE_LIMITED` |
|
||||
| [Report message](/http-api/reports/#report-message) | 3 per hour, keyed by the reporter and the channel together | `RATE_LIMITED` |
|
||||
| [Report message](/http-api/reports/#report-message) | 20 per hour, keyed by the reported message, across all reporters | `RATE_LIMITED` |
|
||||
| [Report message](/http-api/reports/#report-message) | 4 per hour, keyed by the reporter and the guild together, for a guild message | `RATE_LIMITED` |
|
||||
| [Send phone verification](/http-api/users/phone-verification/#send-phone-verification) | 3 per 6 hours, keyed by the authenticated account | `PHONE_RATE_LIMIT_EXCEEDED` |
|
||||
| [Send phone verification](/http-api/users/phone-verification/#send-phone-verification) | 3 per 5 days, keyed by the submitted number | `PHONE_RATE_LIMIT_EXCEEDED` |
|
||||
| [Resend IP authorisation](/http-api/authentication/#resend-ip-authorisation) | Nothing in the first 30 seconds after the ticket was issued, keyed by the authorisation ticket | `IP_AUTHORIZATION_RESEND_COOLDOWN` |
|
||||
|
||||
Fluxer stores a further cooldown when the SMS provider itself throttles a send. The provider names the account, the number, or both, and a send inside that cooldown reports `PHONE_RATE_LIMIT_EXCEEDED` with the ordinary denial body and the remaining delay.
|
||||
|
||||
The Resend IP authorisation cooldown has no `X-RateLimit-*` header. It has a `Retry-After` header in whole seconds, and the body reports that delay again as a top-level `resend_available_in` and `retry_after`. A second resend on one ticket returns 400 `IP_AUTHORIZATION_RESEND_LIMIT_EXCEEDED`. The allowance never refills, and the ticket expires 15 minutes after it was issued.
|
||||
|
||||
### Allowances answering 400
|
||||
|
||||
| Operation | Allowance | Validation code |
|
||||
| --- | --- | --- |
|
||||
| [Modify current user](/http-api/users/current-user/#modify-current-user) | 5 per 3 hours on the resulting username or discriminator | `USERNAME_CHANGED_TOO_MANY_TIMES` |
|
||||
| [Modify current user](/http-api/users/current-user/#modify-current-user) | 25 per 30 minutes on the biography, when the submitted value differs | `BIO_CHANGED_TOO_MANY_TIMES` |
|
||||
| [Modify current user](/http-api/users/current-user/#modify-current-user) | 25 per 30 minutes on the pronouns, when the submitted value differs | `PRONOUNS_CHANGED_TOO_MANY_TIMES` |
|
||||
| [Modify current user](/http-api/users/current-user/#modify-current-user) | 25 per 30 minutes on the accent colour, when the submitted value differs | `ACCENT_COLOR_CHANGED_TOO_MANY_TIMES` |
|
||||
| [Modify current user](/http-api/users/current-user/#modify-current-user) | 25 per 30 minutes on any non-null avatar | `AVATAR_CHANGED_TOO_MANY_TIMES` |
|
||||
| [Modify current user](/http-api/users/current-user/#modify-current-user) | 25 per 30 minutes on any banner value past the entitlement check | `BANNER_CHANGED_TOO_MANY_TIMES` |
|
||||
| [Update bot profile](/http-api/applications/#update-bot-profile) | 5 per 3 hours on the bot's resulting username or discriminator | `USERNAME_CHANGED_TOO_MANY_TIMES` |
|
||||
| [Modify current guild member](/http-api/guild-members/#modify-current-guild-member) | 25 per 30 minutes on the guild avatar, whenever supplied | `AVATAR_CHANGED_TOO_MANY_TIMES` |
|
||||
| [Modify current guild member](/http-api/guild-members/#modify-current-guild-member) | 25 per 30 minutes on the guild banner, whenever supplied | `BANNER_CHANGED_TOO_MANY_TIMES` |
|
||||
| [Modify current guild member](/http-api/guild-members/#modify-current-guild-member) | 25 per 30 minutes on the guild biography, when the submitted value differs | `BIO_CHANGED_TOO_MANY_TIMES` |
|
||||
| [Modify current guild member](/http-api/guild-members/#modify-current-guild-member) | 25 per 30 minutes on the guild pronouns, when the submitted value differs | `PRONOUNS_CHANGED_TOO_MANY_TIMES` |
|
||||
| [Modify current guild member](/http-api/guild-members/#modify-current-guild-member) | 25 per 30 minutes on the guild accent colour, when the submitted value differs | `ACCENT_COLOR_CHANGED_TOO_MANY_TIMES` |
|
||||
| [Modify voice activity sharing](/http-api/users/settings/#modify-voice-activity-sharing) | 1 per 24 hours on the sharing default | `VOICE_ACTIVITY_SHARING_ON_COOLDOWN` |
|
||||
| [Complete login with TOTP](/http-api/authentication/#complete-login-with-totp) and [Complete login with WebAuthn MFA](/http-api/authentication/#complete-login-with-webauthn-mfa) | 10 per 15 minutes on one multi-factor attempt | `INVALID_CODE` |
|
||||
| [Complete login with TOTP](/http-api/authentication/#complete-login-with-totp) and [Complete login with WebAuthn MFA](/http-api/authentication/#complete-login-with-webauthn-mfa) | 5 per 5 minutes on one multi-factor attempt against one MFA ticket | `INVALID_CODE` |
|
||||
| [Sudo mode](/http-api/users/mfa/#sudo-mode) with the `totp` method | 10 per 15 minutes on one multi-factor attempt | `INVALID_MFA_CODE` |
|
||||
|
||||
Every Modify current user allowance is keyed by the authenticated account, and the bot tag allowance by the bot account, so an owner changing a bot's tag draws on the bot's allowance. The five guild member allowances are keyed by the guild and the member together, and one account holds a separate allowance in each guild. The login allowances are keyed by the account and by the MFA ticket respectively, and the sudo allowance by the account.
|
||||
|
||||
Fluxer consumes every multi-factor allowance before it checks the code, so a correct code drawn against an exhausted allowance is reported exactly like a wrong one. A correct code clears the counter. The ticket allowance also destroys the MFA ticket as it denies, and the client restarts from [Log in with a password](/http-api/authentication/#log-in-with-a-password).
|
||||
|
||||
### Allowances answering neither shape
|
||||
|
||||
[Get desktop handoff information](/http-api/authentication/#get-desktop-handoff-information) and [Complete desktop handoff](/http-api/authentication/#complete-desktop-handoff) share one failed-attempt counter keyed by the client IP address. Five failures block both operations for 15 minutes from the most recent failure, and a blocked request returns 400 `INVALID_HANDOFF_CODE` as a top-level code rather than as a validation entry. Get desktop handoff information separately permits three successful lookups for each handoff code and reports a fourth with the same top-level code.
|
||||
|
||||
[Refund latest purchase](/http-api/billing/#refund-latest-purchase) permits one self-serve refund every 30 days for each account and reports a request inside that window as 403 `STRIPE_REFUND_COOLDOWN_ACTIVE`.
|
||||
|
||||
[Slowmode](#slowmode) is enforced inside the handler as well.
|
||||
|
||||
## Slowmode
|
||||
|
||||
Slowmode limits how often one account sends a message in one channel. Fluxer reports a denial as an ordinary request failure. A denied send returns 400 `SLOWMODE_RATE_LIMITED` with a top-level `retry_after` in fractional seconds and a `Retry-After` header in whole seconds. The response has no `X-RateLimit-*` header, so a client tells it apart from a bucket denial by the status and the code.
|
||||
|
||||
The allowance is one message for each interval the channel configures in `rate_limit_per_user`, counted separately for each account and channel pair. Fluxer counts it only for a non-bot account sending in a guild channel whose configured interval is above zero. A caller holding [BYPASS_SLOWMODE](/http-api/permissions/) is exempt. [Get channel slowmode state](/http-api/channels/#get-channel-slowmode-state) reports the caller's remaining delay before a send is attempted.
|
||||
|
||||
## Other surfaces
|
||||
|
||||
Each protocol surface documents its own rate limit contract. The [main Gateway](/gateway/overview/) states its session, command, replay, backpressure, and admission limits in [Gateway limits and rate limits](/gateway/limits-and-rate-limits/). The [Media Proxy API](/media-proxy/overview/) has no request-count rate limit and bounds work through concurrency, payload, and deadline limits. The [upload relay](/media-proxy/upload-relay/) authorises each transfer with a bounded capability.
|
||||
@@ -0,0 +1,123 @@
|
||||
---
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
title: Attachment uploads
|
||||
description: The pre-upload plan, its two modes, and how a client claims the result.
|
||||
---
|
||||
|
||||
Fluxer accepts an attachment inline as a `files[n]` part of a [multipart request](/http-api/#request-body-formats), or pre-uploaded before the message exists. The pre-upload operations and objects live on the [Messages resource](/http-api/messages/).
|
||||
|
||||
The flow has two modes and the server chooses between them from the declared byte count. A file of 10485760 bytes or less, the 10 MiB threshold, is planned as a singlepart upload and is finished as soon as its bytes are stored. A larger file is planned as a multipart upload and needs an explicit completion call. Both modes end the same way, by naming the resulting `upload_filename` in an ordinary message operation.
|
||||
|
||||
:::caution[Pre-uploads can be switched off]
|
||||
When a deployment disables them, the plan request and the completion request both answer 403 `FEATURE_TEMPORARILY_DISABLED` ahead of the channel, permission, and size checks, and the [instance features object](/http-api/instance/#instance-features-object) reports `presigned_attachment_uploads` false. A client falls back to the inline multipart path.
|
||||
:::
|
||||
|
||||
## Requesting an upload plan
|
||||
|
||||
[Request attachment upload URLs](/http-api/messages/#request-attachment-upload-urls) takes from 1 through 10 attachment declarations. Each is a client-chosen `id`, a `filename`, an exact `file_size` in bytes, and a `content_type`. A user session credential and a bot token are accepted, and an OAuth2 bearer credential is rejected with 403 `ACCESS_DENIED`.
|
||||
|
||||
The channel must support messages, and any other channel type returns 400 `CANNOT_SEND_MESSAGES_IN_NON_TEXT_CHANNEL`. A guild channel additionally requires [SEND_MESSAGES](/http-api/permissions/) and [ATTACH_FILES](/http-api/permissions/), returning 403 `MISSING_PERMISSIONS` otherwise, and a caller under a communication timeout receives 403 `COMMUNICATION_DISABLED`.
|
||||
|
||||
Fluxer then checks every declared size on its own against the `max_attachment_file_size` limit resolved for the caller and the guild. A size above it returns 400 `FILE_SIZE_TOO_LARGE` with the resolved ceiling before anything is planned. That limit defaults to 26214400 bytes, the 25 MiB non-premium allowance, and to 524288000 bytes, the 500 MiB premium allowance. A bot credential is clamped to 52428800 bytes, the 50 MiB bot ceiling, even when the resolved limit is higher.
|
||||
|
||||
The response returns one entry for each declaration, in request order, discriminated by `upload_mode`. Every entry has the `id` from the request, the `filename`, the opaque `upload_filename` to claim later, the `file_size`, and the `content_type` the server derived from the filename. The issued capability authorises that derived media type, and the stored object has it.
|
||||
|
||||
### Upload modes
|
||||
|
||||
| Value | Selected for | Added fields |
|
||||
| --- | --- | --- |
|
||||
| singlepart<sup>1</sup> | A declared `file_size` of at most 10485760 bytes | `upload_url` |
|
||||
| multipart<sup>2</sup> | A declared `file_size` above 10485760 bytes | `upload_id`, `part_size`, and `parts` |
|
||||
|
||||
<sup>1</sup> Stored as soon as its `PUT` succeeds, so it is never sent to [Complete attachment upload](/http-api/messages/#complete-attachment-upload)
|
||||
|
||||
<sup>2</sup> Each `parts` entry has a one-based `part_number` and its own `upload_url`, and the array is ordered by ascending part number
|
||||
|
||||
## Part geometry
|
||||
|
||||
Fluxer derives multipart geometry from the file size. The part size is the declared size divided by 20, rounded up to a whole mebibyte, and never below 10485760 bytes. Every part is exactly `part_size` bytes except the last, which is the remainder.
|
||||
|
||||
A plan is bounded at 10,000 parts. One that would need more returns 400 `FILE_SIZE_TOO_LARGE` before the storage multipart upload is opened. The resolved file size limit is the only binding constraint.
|
||||
|
||||
## Transferring the bytes
|
||||
|
||||
Each `upload_url` is a `PUT` target with its own authorisation in its query string. A direct storage URL has the object store's own signature and a relay URL has the signed relay capability in its `t` parameter, so neither shape reads an `Authorization` header.
|
||||
|
||||
The direct storage capability signs the exact byte count, so a `PUT` of any other length is rejected. A relay capability bounds the length at the same value and answers 413 above it, and the relay applies its own body ceiling, 500 MiB by default, on top of that. A transfer that declares no `Content-Length` is spooled to the smaller of the two bounds and answers 413 past it. A relay request whose capability is missing, malformed, or expired answers 401. A client sends exactly the authorised byte count either way.
|
||||
|
||||
A singlepart transfer sends the whole file and must send the entry's `content_type` as its `Content-Type` header. A relay capability takes the media type from the capability and ignores the header. A multipart part transfer sends only that part's bytes and has no signed media type.
|
||||
|
||||
The instance decides per request whether to relay. It resolves the caller's country from the client IP address and issues a direct storage URL only when that country is on the deployment's direct-upload list. Every other caller, including one whose geolocation lookup fails, receives a URL on the [upload relay](/media-proxy/upload-relay/). A client treats both shapes the same way and MUST NOT parse, rewrite, or reorder the query string of either.
|
||||
|
||||
### Capability lifetimes
|
||||
|
||||
| Capability | Lifetime |
|
||||
| --- | --- |
|
||||
| Direct singlepart upload URL | 5 minutes |
|
||||
| Direct multipart part URL | 1 hour |
|
||||
| Relay URL of either kind<sup>1</sup> | The relay token lifetime the deployment configures, 900 seconds by default |
|
||||
|
||||
<sup>1</sup> A relay expiry is exclusive, so the capability is already rejected at its expiry second
|
||||
|
||||
:::caution[The complete URL is the credential]
|
||||
An issued upload URL authorises writing one object or one part. A client treats the whole URL, query string included, as secret until it expires, and keeps it out of logs, referrers, and redirect targets.
|
||||
:::
|
||||
|
||||
## Completing a multipart upload
|
||||
|
||||
[Complete attachment upload](/http-api/messages/#complete-attachment-upload) finalises from 1 through 10 multipart uploads. Each entry names the `upload_filename` and the `upload_id` from the plan. The caller sends no part list and no entity tags. The server lists the parts the storage backend has already accepted, sorts them by part number, and assembles them in that order.
|
||||
|
||||
The channel, permission, and communication checks of the plan request run again, and the operation answers 403 `FEATURE_TEMPORARILY_DISABLED` when pre-uploads are switched off.
|
||||
|
||||
Two failures are reported as [validation error object](/http-api/#validation-error-object) entries on a 400 `INVALID_FORM_BODY` response.
|
||||
|
||||
| Code | Path | Condition |
|
||||
| --- | --- | --- |
|
||||
| UPLOADED_ATTACHMENT_NOT_FOUND<sup>1</sup> | `uploads.{index}.upload_filename` | The key is not a pending multipart upload of the authenticated identity in this channel |
|
||||
| NO_UPLOADED_PARTS_TO_FINALIZE<sup>2</sup> | `parts` | The storage backend holds no part for the upload |
|
||||
|
||||
<sup>1</sup> An upload another identity planned, one planned for another channel, one planned as singlepart, and one a message has already consumed are all reported this way, so a caller cannot probe another identity's upload state
|
||||
|
||||
<sup>2</sup> The multipart upload is aborted before the error is returned
|
||||
|
||||
Fluxer then sums the listed part sizes. A total above the resolved file size limit aborts the upload and returns 400 `FILE_SIZE_TOO_LARGE`. A storage failure during assembly aborts it as well. An aborted upload discards its parts, and its `upload_filename` can never be claimed. A client requests a new plan for the file.
|
||||
|
||||
A client never sends a singlepart upload to this operation.
|
||||
|
||||
## Claiming the upload
|
||||
|
||||
Nothing before this step creates a message, changes a channel, or emits a Gateway Dispatch. The attachment exists only once a [Create message](/http-api/messages/#create-message) or [Modify message](/http-api/messages/#modify-message) request has the `upload_filename` in a [pre-uploaded attachment](/http-api/messages/#pre-uploaded-attachment-object) entry.
|
||||
|
||||
An upload is bound to the identity and the channel that planned it. A key the authenticated identity does not own, a key planned for another channel, and a key an attachment has already consumed each return 400 `INVALID_FORM_BODY` with `UPLOADED_ATTACHMENT_NOT_FOUND` on `attachments.{index}.upload_filename`. A key whose object is absent from storage, which is what an untransferred plan leaves behind, returns the same status with `FILE_NOT_FOUND` on the same path.
|
||||
|
||||
A key is single use, and a second claim of the same key returns the same `UPLOADED_ATTACHMENT_NOT_FOUND` entry.
|
||||
|
||||
:::caution[There is no resume operation]
|
||||
A capability that expires cannot be refreshed and a plan cannot be re-read. A client that loses its plan, or whose part capabilities expire mid transfer, requests a new plan for the file and starts again.
|
||||
:::
|
||||
|
||||
## Stream previews
|
||||
|
||||
A stream preview is a still image attached to one voice connection. It does not use the attachment flow. A preview belongs to the voice connection that publishes the stream. The [stream key](/http-api/streams/#stream-key) names the scope, the channel, and that connection ID. Every preview operation is user-only.
|
||||
|
||||
Uploading the image, issuing an upload capability, and deleting the preview each require the caller to hold a voice state in that channel whose connection ID matches the key. In a guild channel they require the [STREAM](/http-api/permissions/) permission as well. Reading the preview requires only access to the channel, and [CONNECT](/http-api/permissions/) in a guild channel, so any member who can join can read it.
|
||||
|
||||
:::note[Treat a stored preview as publisher-asserted]
|
||||
A voice state has no flag the check reads, so a caller can upload and read a preview for a connection that is publishing nothing.
|
||||
:::
|
||||
|
||||
[Upload stream preview](/http-api/streams/#upload-stream-preview) posts the image inline. Its JSON body has the `channel_id` the connection is in, the base64-encoded image in `thumbnail` of 1 through 2000000 characters, and an optional `content_type` of 1 through 64 characters. A `thumbnail` that is not canonical base64 returns 400 `INVALID_STREAM_THUMBNAIL_PAYLOAD`. Canonical base64 here means the standard alphabet, a length that is a multiple of four, at most two trailing `=`, and decoded bytes that re-encode to the string the request sent. A `content_type` containing `jpeg` or `jpg` in any case is accepted without inspecting the bytes. Every other value, an absent field included, is accepted only when the decoded bytes begin with `FF D8` and end with `FF D9`. A failure returns 400 `PREVIEW_MUST_BE_JPEG`. Decoded bytes above 1000000 return 400 `FILE_SIZE_TOO_LARGE`.
|
||||
|
||||
The operation answers 204 once the image is accepted. Fluxer absorbs a transient storage failure, so a 204 confirms acceptance alone.
|
||||
|
||||
[Create stream preview upload URL](/http-api/streams/#create-stream-preview-upload-url) instead issues a reusable `PUT` capability for the same purpose. Its `content_type` must contain `jpeg` or `jpg` in any case, and every other value returns 400 `PREVIEW_MUST_BE_JPEG`. It answers with `upload_url`, `method` fixed to `PUT`, the `content_type` the client sends, `expires_at`, `expires_in`, and `max_bytes`, which is always 1000000. A direct storage capability lasts one day and a relay capability lasts the relay token lifetime, so `expires_in` differs between the two shapes. The capability writes the same object every time it is used, and a publisher refreshes the thumbnail without asking for a new URL.
|
||||
|
||||
Nothing inspects the bytes written through that capability. A relay capability still refuses a declared length above `max_bytes` with 413, and a direct storage capability enforces nothing beyond its signed media type. A publisher encodes a valid JPEG of at most 1000000 bytes itself.
|
||||
|
||||
[Get stream preview](/http-api/streams/#get-stream-preview) returns the current image bytes with `Cache-Control: no-store, private`, and [Delete stream preview](/http-api/streams/#delete-stream-preview) removes it. The stored preview record expires one day after the inline upload that wrote it, or one day after the call that issued the capability, and using a capability again does not extend it. A read past that point answers an empty 404 even when the object is still in storage.
|
||||
|
||||
## Failures
|
||||
|
||||
Every HTTP API operation in this flow returns the ordinary [error response](/http-api/#error-response) envelope. A relay request answers with the plain-text [media error response](/media-proxy/responses-and-limits/#media-error-response) that the [Media Proxy API](/media-proxy/overview/) defines. A direct storage URL returns whatever the storage backend returns, in that backend's own format.
|
||||
|
||||
The relay has no request-count rate limit. [Request attachment upload URLs](/http-api/messages/#request-attachment-upload-urls) and [Complete attachment upload](/http-api/messages/#complete-attachment-upload) share the `channel:attachment:upload::channel_id` bucket, which permits 10 requests per 10 seconds for each authenticated identity and channel ID. Each stream preview operation declares its own bucket. [Rate limits](/topics/rate-limits/) defines both.
|
||||
Reference in new issue
Block a user