diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 34a3562..bb5a04c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -369,6 +369,11 @@ jobs: if: needs.gate.outputs.full == 'true' runs-on: ubuntu-latest timeout-minutes: 45 + # The providers' keys are the secrets of this environment, given to this + # job and to the parity check alone rather than to every job of the + # repository. Forgejo, which runs this workflow too, has no environments + # and reads its own secrets. + environment: provider-keys services: # The cache's second tier, which the cache spec reads back; the server is diff --git a/.github/workflows/parity.yml b/.github/workflows/parity.yml index 1c0696c..59e6f83 100644 --- a/.github/workflows/parity.yml +++ b/.github/workflows/parity.yml @@ -36,14 +36,18 @@ jobs: name: Skyhook v2 parity runs-on: ubuntu-latest timeout-minutes: 20 + # The providers' keys are the secrets of this environment, given to this + # job and to the end-to-end tests alone. Forgejo, which runs this workflow + # too, has no environments and reads its own secrets. + environment: provider-keys steps: # The server answers the series from TheTVDB and TMDB, so the run needs - # their keys, as the end-to-end tests do: the repository secrets - # TMDB_API_KEY and TVDB_API_KEY. Said before anything is fetched or - # built, so that a run without them costs a few seconds. A secret - # cannot be tested in an `if`, which is why it is an environment - # variable here, and why the answer is an output. + # their keys, as the end-to-end tests do: the secrets TMDB_API_KEY and + # TVDB_API_KEY of the environment provider-keys. Said before anything + # is fetched or built, so that a run without them costs a few seconds. + # A secret cannot be tested in an `if`, which is why it is an + # environment variable here, and why the answer is an output. - name: Look for the provider keys id: keys env: diff --git a/.gitleaks.toml b/.gitleaks.toml index 6d1ae60..5943f11 100644 --- a/.gitleaks.toml +++ b/.gitleaks.toml @@ -14,8 +14,9 @@ # (`shift+mod+enter`, the editor's *Save and open the next*), the fake # `api_key=abcdef123456` the log's tests write on purpose, to see it come # out masked, and the fake `api_key=0123456789abcdef` the TMDB relay's tests -# write, to see it kept out of what a client is handed. None is a value that -# could open anything. +# write, to see it kept out of what a client is handed. And the interface's +# list of the services' host names, which the generic-key rule reads as a key +# behind the word `api`. None is a value that could open anything. [extend] useDefault = true @@ -28,6 +29,7 @@ regexes = [ '''keys[=:]\s*["']shift\+mod\+enter["']''', '''api_key=abcdef123456''', '''api_key=0123456789abcdef''', + '''skyhook\.sonarr\.tv, api\.radarr\.video, api\.themoviedb\.org, api4\.thetvdb\.com''', ] [[rules]] diff --git a/.gitleaksignore b/.gitleaksignore new file mode 100644 index 0000000..938e6ad --- /dev/null +++ b/.gitleaksignore @@ -0,0 +1,17 @@ +# Findings of the history that are not secrets, by fingerprint — the commit, +# the file, the rule and the line, which `gitleaks git` prints for each. A +# fingerprint names one line of one commit and nothing else: it loosens no +# rule, and a line like it written today would be found again. What is +# allowed on every commit, whatever its line, is in .gitleaks.toml. + +# A test's fake key — `ams_` and the base64 of a made-up phrase, not written +# here so that this file is no finding of its own — the key a Plex address +# carries in the test that checks it is masked in a logged path. It opens +# nothing; a later commit wrote one that says so on its face. +cf133cdde69916eb02aea11e847b383d37112bb1:src/telemetry.rs:generic-api-key:316 + +# The host name api.radarr.video, in the help text that lists the addresses +# the clients' door answers for (the French text, then the English): Radarr's +# own public API address, which the generic-key rule reads as a key. +c2f01484fd17d70fc97bc9af2d762cc60640e8a0:frontend/src/lib/lang/fr.ts:generic-api-key:867 +c2f01484fd17d70fc97bc9af2d762cc60640e8a0:frontend/src/lib/lang/en.ts:generic-api-key:860 diff --git a/CHANGELOG.md b/CHANGELOG.md index 998e54f..b21a7bf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,106 @@ may change what the API or the configuration means, and says so here. ## [Unreleased] +### Security + +A review of the whole server, area by area, and of what semgrep, CodeQL, +gitleaks, trivy and zizmor find in it. Nothing let a caller past a guard; what +follows let one cost the server too much, see what was not theirs, or keep +what they had lost. + +- **Signing in.** Failed sign-ins locked an account for everyone, so a guesser + could shut the administrator out of their own server; they are counted per + account and address now (an IPv6 /64 as one), each before its password is + checked and with one atomic count on the cache server, and guessing from + many addresses meets a bar five times higher that never applies to an + address the account signed in from in the last thirty days. Concurrent + guesses no longer slip past the count while it is read, and a full count + forgets its oldest rather than stop counting. A run of failed sign-ins, or of + returns from the identity provider, from one address is one audit row with + its number; a return nobody started here is not written at all. +- **Accounts and keys.** A member could get round an administrator's pause of + their key by deleting it and making another; the pause stands on the member + now, until an administrator enables one of their keys or lifts it + (`DELETE /api/v1/users/{id}/keys-pause`). Tying an identity-provider login to + an account asks for the account's password — or has the provider sign the + person in again, for an account with none — where a session alone did; the + owner can untie it from their account page, an administrator's password + reset unties it, and the journal says who tied and who untied. +- **The identity provider's client** refuses link-local and metadata addresses + at connect time (loopback excepted), judges the provider's endpoints before + it asks any of them, and sends no Referer. +- **A trusted proxy** that forwarded a request without an `X-Forwarded-For` + hop made every client look like the proxy, whose address the allowlist + admits; the client is unknown now, refused by every allowlist, and the log + says so. +- **Load.** One address could hold every connection of a door — idle HTTP/2 + connections that answer pings, slow readers: no network holds more than 64 + now (a trusted proxy's and this machine's excepted), a connection with no + request in flight for two minutes is closed, and a response its peer does + not read is given up on after a minute. Searches, facets, lists and figures + no cache held are read three at a time, a caller waiting more than three + seconds answered `503` with `Retry-After`. Link previews, a network's page + and `/ready` asked the database afresh for every request: previews count + against the caller's allowance and are kept five seconds, the catalogue's + mark and `/ready`'s answer are asked once in five seconds, and a network's + slug is matched in memory. `/tmdb-images/` has a quota of its own, ten times + the requests', and hands on 16 pictures at a time. Search terms are cut at + 200 characters at every door, as the catalogue's own search always did. +- **Pictures.** One picture could take some 700 MB to decode, as only the + decoder's own buffer was bounded: decoding is counted by the byte a pixel + from the picture's header before anything is decoded, a picture that would + hold more than 256 MB is served whole, and the pictures decoding together + share those 256 MB. A copy made on demand waits for its turn before reading + the original, at most 32 are made at once, and each counts against the + caller's rate limit. An upload is cut off as it is read, at the weight its + first bytes say it may have, and a writer who is not an administrator keeps + 1 GiB of uploads all told. A writer is told only that a picture could not be + fetched, no longer how or after how many tries — which let a typed-in + address map the private network. +- **Relays.** TheTVDB's relay read every season, episode and character kept + without a TheTVDB id on each request for a negative number: a number no id + made here can be is answered at once, the others are found in maps read once + while the catalogue stays as it was, and seasons and orders' episodes are + indexed by TheTVDB's id (migration 0053). A reference list asked with a + made-up parameter or header filed a new database row each time: lists are + filed under their path and the language and region asked, 512 at most per + relay. TheTVDB's and AniList's relays key their answers on the Accept, + Accept-Language and Content-Type they ask with, as TMDB's does. +- **Outbound requests** send no Referer, which handed the next host of a + redirect the address it came from, key included; TMDB, Fanart.tv, TheTVDB + and MyAnimeList follow a redirect only within the origin first asked. A + series' IMDb id went into TMDB's `/find` path unchecked; it is checked as a + film's is and written as one segment. +- **Logs.** A sign-in's `code`, `state` and `session_state`, TMDB's + `session_id`, `guest_session_id` and `request_token`, and an `auth` + parameter are masked as the other keys are; an import rule that could not + fetch its list wrote reqwest's error, address and key included, into its + history and the log; a database address whose password holds an `@`, or + that carries it as `?password=`, was written in part. +- **Backups.** Archives, and the copy of the database one is made from, were + readable by every user of the machine: they are written for the server's + user alone (0600, new directories 0700), `config check` warns when archives + are not encrypted, and downloading one is written to the audit trail. A + restore wrote the archive's column names into SQL as they stood, so a crafted + archive could run statements of its own: names are quoted, and such an + archive is refused. +- **Elsewhere.** The trailer's frame is sandboxed. `trust-ca.sh` checked a + pinned fingerprint against a decoding of the whole file while installing + every certificate in it: a fetched or pinned file must be exactly one + certificate. History lines gitleaks takes for secrets are named, each with + why it is none, in `.gitleaksignore`. + +### Changed + +- `transfer` refuses a database URL with a password among its arguments: such + URLs are read from `AMS_TRANSFER_FROM` and `AMS_TRANSFER_TO`, which a + process list and a shell's history do not keep. +- A provider's upstream that redirects `http://` to `https://` is no longer + followed there: give it as `https://`. +- Tying an account to the identity provider is a button on the account page + that asks for the password (`POST /api/v1/account/provider`); untying is + beside it (`DELETE /api/v1/account/provider`). + ## [0.14.0] — 2026-11-03 ### Added diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index cbd73b1..0c887a2 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -97,7 +97,12 @@ why. Say in the pull request why a new dependency is worth carrying. **Secrets.** None, in the code, the tests or the fixtures. `gitleaks detect --source . --redact` reads the history with -[`.gitleaks.toml`](.gitleaks.toml). +[`.gitleaks.toml`](.gitleaks.toml); the few lines of past commits that are no +secret are named, each with its reason, in [`.gitleaksignore`](.gitleaksignore). +CI's own keys — `TMDB_API_KEY`, `TVDB_API_KEY` and `FANARTTV_API_KEY`, which the +end-to-end tests and the parity check run against the real services with — are +the secrets of the GitHub environment `provider-keys`, given to those two jobs +alone. Without them, both say so and stop within seconds. ## The interface's design system diff --git a/docker/trust-ca.sh b/docker/trust-ca.sh index b7a2b14..51ce809 100755 --- a/docker/trust-ca.sh +++ b/docker/trust-ca.sh @@ -18,6 +18,10 @@ # shows — or the risk is accepted, on a network you control, with # AMS_CA_INSECURE=1. # +# What is fetched, or pinned, is one certificate and nothing else: a file +# with a second PEM block is refused, since update-ca-certificates would +# install every block of it. +# # The same script is served by the server at /trust-ca.sh, and its # authority at /ca.crt, so a deployment from the image alone has both. # Mounted on purpose and finding nothing to install, it fails loudly: a @@ -29,6 +33,9 @@ CA_NAME="arr-metadata.crt" CA_PATH="/usr/local/share/ca-certificates/${CA_NAME}" CA_FILE="${AMS_CA_FILE:-/arr-metadata/tls/ca.crt}" +# Set once the authority is fetched from AMS_CA_URL rather than found here. +FETCHED="" + if [ -s "${CA_PATH}" ]; then echo "[trust-ca] using the authority mounted at ${CA_PATH}" elif [ -s "${CA_FILE}" ]; then @@ -55,6 +62,7 @@ elif [ -n "${AMS_CA_URL:-}" ]; then ;; esac echo "[trust-ca] fetching the authority from ${AMS_CA_URL}" + FETCHED=1 mkdir -p "$(dirname "${CA_PATH}")" if command -v curl >/dev/null 2>&1; then curl -fsS --retry 10 --retry-delay 3 --retry-connrefused -o "${CA_PATH}" "${AMS_CA_URL}" @@ -74,6 +82,22 @@ if ! grep -q "BEGIN CERTIFICATE" "${CA_PATH}"; then exit 1 fi +# A file that was fetched, or that a fingerprint is given for, is one +# certificate. The fingerprint below is taken of the file's base64 text as one +# stream, and a decoder is more lenient than OpenSSL: GNU's stops at a tab that +# OpenSSL skips, so a second certificate behind one goes unhashed, and +# update-ca-certificates installs every block of the file all the same. Any +# kind of PEM marker is counted — a TRUSTED CERTIFICATE block is a root to +# OpenSSL too — and the one let through is a CERTIFICATE. +if [ -n "${FETCHED}" ] || [ -n "${AMS_CA_FINGERPRINT:-}" ]; then + BLOCKS="$(grep -o -e '-----BEGIN' "${CA_PATH}" | wc -l | tr -d ' ')" + if [ "${BLOCKS}" != 1 ] || ! grep -q -e '^-----BEGIN CERTIFICATE-----' "${CA_PATH}"; then + echo "[trust-ca] ERROR: ${CA_PATH} holds ${BLOCKS} PEM blocks, or its one block is not a CERTIFICATE: an authority that is fetched or pinned is exactly one certificate, since a second block would be installed without being pinned; not installed" >&2 + rm -f "${CA_PATH}" 2>/dev/null || true + exit 1 + fi +fi + # Pinned, when a fingerprint was given: the SHA-256 of the certificate's DER, # as the administration page shows it, colons or not, in either case. if [ -n "${AMS_CA_FINGERPRINT:-}" ]; then diff --git a/docs/access.md b/docs/access.md index 8edea04..dddb21b 100644 --- a/docs/access.md +++ b/docs/access.md @@ -65,6 +65,28 @@ account, and no sign-up ever makes an administrator. Uninvited sign-ups are limited to `AMS_SIGNUPS_PER_HOUR` (5) per address — an IPv6 address counts by its /64 — and `AMS_SIGNUPS_PER_HOUR_TOTAL` (100) for the whole server. +**Failed sign-ins** are counted for each account and address. After +`AMS_SIGNIN_FAILURES_PER_ACCOUNT` attempts (10) on an account from one address — +an IPv6 address counts by its /64 — in fifteen minutes, that address is refused +without its password being looked at, until the window closes; the attempt is +counted before the password is checked, so a burst sent together is held to the +same ten. Somebody guessing at an account from their address shuts out that +address, and not the account's owner at theirs. Guessing from many addresses is +held by a bar for the account as a whole, five times as high; it does not apply to +an address the account has signed in from in the last thirty days, so its owner's +usual places get through with the right password. Failures of somebody with no +credential — a refused password, a refused return from the identity provider — +are written to the journal as one row for each address and kind in a minute: +written at once, and amended when the minute is over to say how many there were +and what the last of them typed. A return from the provider that was not started +here, or with no provider set up, is not written at all. + +A key a person holds is theirs to pause, and an administrator's to pause for +them from **Keys & network**: the pause then stands on the person, and until an +administrator enables one of their keys from there, or lifts it on their page +(`DELETE /users/{id}/keys-pause`), they can neither make a key nor resume one — +deleting the paused key and making another is no way round it. + The same page switches each API off — Sonarr's, Radarr's, the Plex provider, the TMDB, TheTVDB and AniList relays, and the native API's keys (the interface's own session is never cut). A switched-off API answers `503`, which Sonarr and Radarr @@ -118,12 +140,27 @@ The flow is the authorization code with PKCE, a state and a nonce, carried in a cookie sealed with a key only the running server holds (`__Host-` over HTTPS); the ID token's signature — asymmetric only — issuer, audience, expiry and `at_hash` are checked. The issuer, and every endpoint it publishes, must be -HTTPS unless it is a local address. A returning person is found by the -provider's own identifier. Someone who already has an account here ties it to -theirs at the provider from their account page, signed in, which proves both -sides; nothing is ever tied by e-mail, since an address typed into a profile -proves nothing. Otherwise, if allowed, an account is opened — a member's, or -the role a claim gives (`groups`, a path such as `realm_access.roles`, or a +HTTPS unless it is a local address, and none may be at a link-local, metadata, +multicast or unspecified address — written as one, or a name that resolves to +one; this machine's own loopback is the one such address a provider may have. +The provider is reached directly, whatever `HTTP_PROXY` says, and every address +it publishes is judged before it is asked for, its keys' included. A returning +person is found by the provider's own identifier. Someone who already has an +account here ties it to theirs at the provider from their account page: they give +their current password, are sent to sign in at the provider, and the tie is made +when they come back — which proves both halves are theirs. An account with no +password of its own has the provider sign its person in again (`prompt=login`, +`max_age=0`), and the provider's answer has to say when (`auth_time`). A session +alone ties nothing: one left open, or taken, would tie whoever holds it, and the +tie would outlive the password the account's owner then resets — nor does a +sign-in begun before the account was changed, a password reset above all, finish +its tie after it. Nothing is ever tied by e-mail, since an address typed into a +profile proves nothing. The owner can untie the account from the same page while +it has a password to sign in with — their other sessions are closed with it — +and an administrator can from the member's page; an administrator's reset of a +password unties the account too. The journal says who tied (`user.linked`) and +who untied (`user.unlinked`). Otherwise, if allowed, an account is opened — a +member's, or the role a claim gives (`groups`, a path such as `realm_access.roles`, or a namespaced `https://…/roles`, with the values that make an administrator or an editor). With a claim named, the provider decides the role at every sign-in, but never takes away the last administrator, and a sign-in whose claims did diff --git a/docs/cli.md b/docs/cli.md index 1996b90..666ba24 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -24,7 +24,9 @@ docker run --rm -it -v arr-metadata-server_metadata-data:/data --env-file .env \ `-it` is for a command that asks for a password at the terminal. One read from standard input — `--password-stdin`, `--passphrase-stdin` — needs `-i` alone, and nothing is ever taken as an -argument, which a process list and a shell's history would keep: +argument, which a process list and a shell's history would keep — the password of a database +address, which `transfer` takes, is read from the environment instead +([below](#the-server)): ```bash printf '%s\n' "$NEW_PASSWORD" | \ @@ -41,7 +43,7 @@ not exactly this binary's, rather than migrate it under a server that runs anoth | --- | --- | | *(none)*, `serve` | Runs the server. | | `healthcheck` | Asks the running server's `/health`, over loopback; exits non-zero if it is unwell. The image's health check. | -| `transfer FROM TO [--force]` | Copies a database into another, whatever their engines. | +| `transfer [FROM] [TO] [--force]` | Copies a database into another, whatever their engines. A URL with a password is read from `AMS_TRANSFER_FROM` or `AMS_TRANSFER_TO`, never an argument. | | `backup [--out PATH] [--include-media] [--passphrase-stdin]` | Makes an archive. | | `restore ARCHIVE [--replace] [--passphrase-stdin]` | Restores one. Refused while a server runs on the database. | | `user list` | Lists the accounts. | @@ -65,9 +67,26 @@ one moment, so a server may go on writing to it meanwhile; the copy is one trans failure leaves the target as it was: ```bash -arr-metadata-server transfer 'sqlite://data/ams.db' 'postgres://ams:secret@localhost/ams' +arr-metadata-server transfer 'sqlite://data/ams.db' 'postgres://ams@localhost/ams' ``` +A password is never an argument, which a process list and a shell's history would keep. A URL +that carries one — after the user's name, or as `?password=` — is refused as an argument, with a +message that says where to put it: `AMS_TRANSFER_FROM` names the source and `AMS_TRANSFER_TO` the +target, each standing in for the argument left out, so the source may be an argument and the +target a variable. An argument that is given wins over its variable, and a `.env` is read as for +every other variable. Typed with nothing shown, the variable is not in the shell's history +either: + +```bash +read -rs AMS_TRANSFER_TO # the target's URL, password and all +export AMS_TRANSFER_TO +arr-metadata-server transfer 'sqlite://data/ams.db' +``` + +In a container, hand it the variable by name — `docker compose run --rm -e AMS_TRANSFER_TO metadata +transfer 'sqlite:///data/ams.db'` — and Docker takes its value from the shell. + ## Backups `backup` makes an archive where the server keeps them — `AMS_BACKUP_DIR`, or the bucket among @@ -76,7 +95,9 @@ several instances — listed on **Administration › Backups** like any other, a puts the media kept on disk in it; `--out PATH` writes it to a file instead, or into a directory under its usual name, and does not list it; `--passphrase-stdin` encrypts it with a passphrase read from standard input — asked for, unseen, at a terminal — rather than `AMS_BACKUP_PASSPHRASE`. -It says where it wrote the archive, its size and its SHA-256. +It says where it wrote the archive, its size and its SHA-256. The archive is written readable by +the user who runs the command alone (`0600`), wherever it goes, the directories made for it +`0700`; [Installing and running](install.md#backups) says who else can be given it. `restore` puts an archive back: a file, or the name of one the server keeps. It has the database to itself, or it does not start: a server holding it — the server's own lock beside an SQLite @@ -134,10 +155,12 @@ TheTVDB or IMDb id: the document travels from one catalogue to another. `config check` prints the configuration as the server reads it — every secret masked, as the log masks it — and then probes what it names: the database, and where its migrations stand; the media -store, a directory or a bucket; where the archives are kept, written to and listed; the public -address's `/health`. A configuration the server would refuse to start with is said as the start -would say it. It exits non-zero when the database, the store or the archives' place does not -answer; the public address is only reported, since a proxy may route it from outside alone. +store, a directory or a bucket; where the archives are kept, written to and listed — with a +warning when `AMS_BACKUP_PASSPHRASE` is unset, since an archive then goes unencrypted with every +account's password hash in it; the public address's `/health`. A configuration the server would +refuse to start with is said as the start would say it. It exits non-zero when the database, the +store or the archives' place does not answer; the public address is only reported, since a proxy +may route it from outside alone. `migrate status` lists this binary's migrations — applied, pending, or failed — and any the database has that it does not know, which a later version applied. `migrate run` applies those diff --git a/docs/configuration.md b/docs/configuration.md index 034578a..d3423ff 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -73,8 +73,8 @@ naming the variable. Flags take `true`, `false`, `1`, `0`, `yes`, `no`, `on` and | `AMS_ALLOWED_HOSTS` | unset, any name | The host names the interface's door answers to, separated by commas; another is answered `421`, but for `/health`, `/ready`, `/ca.crt` and `/trust-ca.sh`. | | `AMS_CORS_ORIGINS` | unset, none | Origins whose pages may call the native API from a browser, separated by commas. `*` is ignored, with a warning. | | `AMS_REQUEST_TIMEOUT` | `60` | Seconds a request may take before it is answered `504`; 1 to 86400. | -| `AMS_MAX_CONNECTIONS` | `1024` | Connections each door holds at once; past it, a new one waits to be accepted. | -| `AMS_HEADER_READ_TIMEOUT` | `30` | Seconds a connection has to send a request's head, and may sit idle between two requests; 1 to 600. | +| `AMS_MAX_CONNECTIONS` | `1024` | Connections each door holds at once; past it, a new one waits to be accepted. No one address — an IPv6 /64 as one — holds more than 64 of them, a trusted proxy's and this machine's excepted. | +| `AMS_HEADER_READ_TIMEOUT` | `30` | Seconds a connection has to send a request's head, and may sit idle between two requests; 1 to 600. A connection with no request in flight for two minutes — an HTTP/2 one too — is closed. | | `AMS_LOG` (`RUST_LOG`) | `info` | What is logged, as a `tracing` filter: `debug`, `info,arr_metadata_server=debug`. The administration's **Server log** keeps the same lines. | | `AMS_LOG_FORMAT` | unset | `json` writes one JSON object per line, for a log shipper, each with the `trace_id` of the request it was written in. | @@ -125,8 +125,8 @@ OpenTelemetry collector. These are OpenTelemetry's own variables; see | `AMS_AUTH_DISABLED` | `false` | Opens every surface. For closed networks and a first setup only. | | `AMS_ALLOWLIST` (`AMS_ARR_ALLOWLIST`) | loopback and the private ranges | The first value of the list of allowed addresses, separated by commas; read against an empty list only. The default is `127.0.0.0/8`, `::1/128`, `10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16` and `fc00::/7`. | | `AMS_PUBLIC_BROWSE` | unset | `true` gives `site.access` the first value `public`; `false` keeps the site private whatever the setting says. | -| `AMS_RATE_LIMIT_PER_MINUTE` | `600` | Requests a minute, per address — an IPv6 address by its /64. TMDB's pictures under `/tmdb-images/` are not counted for an address their guard lets in. | -| `AMS_SIGNIN_FAILURES_PER_ACCOUNT` | `10` | Failed sign-ins an account takes in fifteen minutes before it is refused unchecked until the window closes. | +| `AMS_RATE_LIMIT_PER_MINUTE` | `600` | Requests a minute, per address — an IPv6 address by its /64. TMDB's pictures under `/tmdb-images/` are counted apart, against ten times as many, for an address their guard lets in. A copy of a picture made on demand under `/media/` counts for one each time it is asked for before it is kept; copies kept and pictures count for none. | +| `AMS_SIGNIN_FAILURES_PER_ACCOUNT` | `10` | Failed sign-ins an account takes from one address in fifteen minutes (an IPv6 /64 counts as one) before that address is refused unchecked until the window closes; five times as many from every address together, which does not apply to an address the account signed in from in the last thirty days. | | `AMS_SIGNUPS_PER_HOUR` | `5` | Sign-ups without an invitation, per address and per hour. | | `AMS_SIGNUPS_PER_HOUR_TOTAL` | `100` | The same, for the whole server. | | `AMS_OIDC_CLIENT_SECRET` | unset | The identity provider's client secret, which wins over the one stored in the settings. | @@ -163,7 +163,9 @@ These give a setting its first value, and are not read again once it has one — Each provider is asked at its own address unless one is given. An address given may be anywhere — a mirror on the same machine included; where it redirects is judged as any other -address the server fetches. See +address the server fetches, and TMDB, Fanart.tv, TheTVDB and MyAnimeList, which a key or a +client id travels to, are followed only within the origin first asked: an upstream that +redirects `http://` to `https://` must be given as `https://`. See [If you redirect at the resolver](integration.md#if-you-redirect-at-the-resolver). | Variable | Default | diff --git a/docs/install.md b/docs/install.md index 89b82ed..c25da85 100644 --- a/docs/install.md +++ b/docs/install.md @@ -110,7 +110,7 @@ Changing `AMS_DATABASE_URL` points the server at a different database; it does not carry your catalogue across. To move it: ```bash -arr-metadata-server transfer 'sqlite://data/ams.db' 'postgres://ams:secret@localhost/ams' +arr-metadata-server transfer 'sqlite://data/ams.db' 'postgres://ams@localhost/ams' ``` The target is migrated first and must be empty, unless you pass `--force` to add @@ -118,6 +118,11 @@ to what is already there. Everything goes across, the lists read whole — TVmaz index, Wikidata's links — included. An archive does the same: one made on SQLite restores on PostgreSQL, and the other way round (see [Backups](#backups)). +A URL that carries a password is not an argument, which a process list and the shell's +history would keep: the command refuses one, and takes the source from `AMS_TRANSFER_FROM` +and the target from `AMS_TRANSFER_TO` instead, each standing in for an argument left out +([the command line](cli.md#the-server)). + ## Backups The server makes archives of itself: its database — every work, lock, account, key, setting, @@ -142,6 +147,13 @@ downloads, deletes and restores them; [the command line](cli.md) does the same f put together in `AMS_BACKUP_DIR` all the same: that is where the room for a copy of the database is wanted, not in the system's temporary directory, often a small tmpfs in a container. +- **Who can read them.** The user who runs the server, alone: each archive is written `0600`, + and the copy of the database it is put together from, and the directories the server makes + for them — `AMS_BACKUP_DIR` when it is not there yet, the `.work` folder in it — `0700`, + whatever the umask says. A directory that exists already keeps the permissions it has, and so + do the archives an earlier version wrote: `chmod 600` those. A job that copies the directory as + another user has to run as root, or be given the right to read each archive, which nothing + gives it by default. - **Before an upgrade.** A start that finds migrations to apply first makes an archive of the database as the version before left it, without the media: `ams-20261019-020000-upgrade-m0037.tar.zst`, made before the migrations that bring it to 0037, diff --git a/docs/integration.md b/docs/integration.md index cad685e..edeeec1 100644 --- a/docs/integration.md +++ b/docs/integration.md @@ -174,6 +174,8 @@ That fetch is plain HTTP on the stack's own network, and what it installs is a root the container then trusts for everything — so over `http://` the script refuses to install anything it cannot check: set `AMS_CA_FINGERPRINT` to the SHA-256 shown on the Opening & APIs page, and anything else is refused. A +fetched or pinned authority must be exactly one certificate: a file with a +second PEM block is refused. A stack's very first start has no fingerprint to copy yet; `AMS_CA_INSECURE=1` accepts the fetch unpinned, with a warning in the container's log, on a network you control — `compose.integration.yaml` sets it until a fingerprint @@ -262,7 +264,8 @@ that is not in that list — it carries `X-Forwarded-For`, `Forwarded` or `X-Real-IP` — is therefore refused on these surfaces, with a warning naming the proxy; Sonarr and Radarr calling directly never send one. From a trusted proxy every `X-Forwarded-For` line is read, in order, right to left past the other -trusted hops; a hop that is no address leaves the client unknown, and an unknown +trusted hops; a hop that is no address, or a trusted proxy that sends only +`Forwarded` or `X-Real-IP`, leaves the client unknown, and an unknown client is refused by every allowlist rather than taken for the proxy. ### Changes need this server's own pages @@ -744,9 +747,10 @@ An upstream named this way may be anywhere — a mirror on the same machine or the same network included: it is yours. Where it *redirects* is not: a hop to an address that only means something from where this server stands (loopback, link-local, a cloud's metadata service), written as an address -or as a name that resolves there, is refused, and MyAnimeList's client id is -never carried to another origin — scheme, host and port — than the one first -asked. +or as a name that resolves there, is refused, and the key or client id TMDB, +Fanart.tv, TheTVDB and MyAnimeList are asked with is never carried to another +origin — scheme, host and port — than the one first asked: an upstream that +redirects `http://` to `https://` must be given as `https://`. ## When a provider does not answer diff --git a/docs/media.md b/docs/media.md index 33d904d..15066cd 100644 --- a/docs/media.md +++ b/docs/media.md @@ -29,7 +29,12 @@ default: nothing is kept until it is asked for. and whatever it redirects to. A picture mirror on a private network is reached all the same; `AMS_MEDIA_PRIVATE_NETWORKS=false` keeps the store and the NFO export out of `10/8`, `172.16/12`, `192.168/16`, `100.64/10` - and IPv6's unique-local `fc00::/7` too (`true` by default). + and IPv6's unique-local `fc00::/7` too (`true` by default). A writer who is + not an administrator is told of a picture that failed only that it "could + not be fetched", and not how many tries it took: what the address answered, + or that it did not, is the administrator's to read on **Administration › + Media** — an address is whatever a writer typed in, and what came back for + each would be a map of the network the server is on. - **How they are served.** Every copy is addressed by the hash of its bytes under `/media/`, and served immutable. The rows keep the providers' addresses: a work is rewritten as it is read, never as it is written, and @@ -51,7 +56,12 @@ default: nothing is kept until it is asked for. read for what it is, never for what it is called; SVG is refused, since a document that can carry a script must not be served from here — and a copy taken away. An upload's file goes with it; a provider's copy is - fetched again at the next refresh. + fetched again at the next refresh. A picture may weigh 25 MiB and a theme + 60, a writer who is not an administrator keeps 1 GiB of uploads all told, + and an upload is cut off at the weight of what it is as it is read — + told by its first bytes, or by the `kind` of the form when that comes + before the file —, not once all of it is held in memory; one whose first + bytes are neither a picture nor a sound is refused with them. - **The sweep.** Daily, and on request: what no work points at any more — a work removed, a picture a provider dropped, an upload taken off — is forgotten and its file deleted, and a file no row names is removed once it @@ -116,8 +126,16 @@ anything — and asks for the right files from its first picture. poster's 480, a background's 1280. Any other is made the first time a page asks for it — a few hundred milliseconds for a large background — and served from the store after. A width no page asks for is refused, so - a picture has a dozen copies at the most. The thumbnails earlier versions - made are still served; none is made any more. + a picture has a dozen copies at the most, a picture's own width being one + only where it is no wider than 1920. The thumbnails earlier versions + made are still served; none is made any more. Making one is work, and is + held to what the rest of the server's is: the address that asks is counted + against `AMS_RATE_LIMIT_PER_MINUTE` each time it asks for a copy that is not + kept yet — a copy already kept is served, and a picture, for nothing —; at + most thirty-two are made at once, and a page that asks for one when there + is no room is sent the picture itself, for that time, and asks again; and + a copy waits for its turn among the pictures being decoded before its + picture is read from the store, not after. - **Turned on.** The pages ask for the copies at once, and the pictures kept already have theirs made as they are asked for: a catalogue's first pages after the switch are a little slower to fill. @@ -129,9 +147,22 @@ anything — and asks for the right files from its first picture. [Deleting the copies of a format](#deleting-the-copies-of-a-format). - **Read safely.** A picture is decoded in Rust, never by libwebp: a picture past 16,384 pixels a side or 100 million pixels is not decoded - at all, nor one whose pixels would take more than 256 MB, and a decoder - that panics on what it is given is a copy refused, not a server brought - down. Such a picture is served whole. + at all, nor one whose decoding would hold more than 256 MB at its worst, + and a decoder that panics on what it is given is a copy refused, not a + server brought down. Such a picture is served whole. What decoding holds + is counted from the picture's header, before a byte of it is decoded, by + the byte a pixel at the worst moment of the way from the file to the copy: + the decoder's buffer and what it keeps beside it — a progressive JPEG's + coefficients, six bytes a pixel more than its three —, the copy a quarter + turn makes, the copy that makes the pixels eight bits an RGB or RGBA + channel, what the resizer holds beside them, and what is held at the end: + 3 to 9 bytes a pixel for a picture in colour, 16 for one with transparency. + A grey picture of 10,000 by 10,000 pixels is 100 MB for its decoder, and + took some 700 with the copies made of it; it is counted at 400 MB, or 900 + where a copy is made of it, and refused. The pictures being decoded hold + 256 MB between them, whatever their sizes: a large one waits for those + before it, and small ones go side by side. What an encoder holds to write + a copy is its own matter, and a copy is never wider than 1920 pixels. - **The square for Plex.** A background kept is also cut into a square from its middle, 1080 pixels a side, as a JPEG — Plex's `backgroundSquare`: see [Plex](plex.md#what-is-served). diff --git a/docs/perf/README.md b/docs/perf/README.md index ab08d35..b8b4c14 100644 --- a/docs/perf/README.md +++ b/docs/perf/README.md @@ -174,3 +174,16 @@ background: whole after 255 s (258 080 names of works, 39 976 people, 345 214 episodes), the search answering from what is written meanwhile. The file grows from 336 MB to 596 MB: the index's table, its FTS5 trigrams and its three indexes weigh 283 MB, less the 0022 index the migration drops. + +### The reads no cache answers go through three places + +The figures above were measured before the server took the reads no cache +answers — a search nobody made lately, the facets of a new filter, a page of +the catalogue in an order nobody asked for, the figures — three at a time +(`src/cache/gate.rs`). Thirty-two connections asking for new ones now queue +for those three places: the database's own limit is still what the rate is, +but a request that waits more than three seconds is answered `503` with +`Retry-After: 5`, which a load tool reports as an error. A scenario that +repeats a request is not touched, since what the cache holds takes no place; +a cold one is compared by the rate of its answers and the figures of those +that were answered, not by its failures. diff --git a/docs/relays.md b/docs/relays.md index 9ab8f1e..b033c1a 100644 --- a/docs/relays.md +++ b/docs/relays.md @@ -270,6 +270,15 @@ service fails and the memory holds nothing — after a restart, for instance. On older than 180 days is neither served nor kept, and the server deletes it as it goes; a database transfer carries the others. +A reference list is filed in the database under its path and the language and +region the request names (`language`, `region`, `watch_region`), and under nothing +else: not the rest of the query, not the caller's headers. A request that adds +parameters of its own finds the row the others wrote, rather than making one. A +language or a region that is not written as one — empty, longer than sixteen +characters, anything but letters, digits, `-` and `_` — or that a request gives +twice, is still answered, and kept in the database under none. A relay keeps at +most 512 of them; past that, the oldest go first. + `relays.staleIfError`, on unless an administrator turns it off, decides all of this: off, the service's failure is handed on as it came, and the reference lists, still written, are not read. It is set on **Opening & APIs**, under *When a service @@ -288,6 +297,12 @@ answers with what it kept, or `429` with the seconds left in `Retry-After`. A client's AniList query is never spaced out as the server's own fetches from AniList are. +What a relay keeps in memory is filed under the path, the query and the headers it +hands on that may change the answer: `Accept` and `Accept-Language` for TMDB, and +for TheTVDB and AniList `Content-Type` too, which says how AniList reads a body. +Two callers who ask with other headers are two entries; none is answered what the +service said to the other. + A client's `If-None-Match` and `If-Modified-Since` reach the service only with a request answered for that client alone, and handed back as the service wrote it: a request kept or shared with others is asked without them, since a "not modified" is diff --git a/docs/reverse-proxy.md b/docs/reverse-proxy.md index 12ca120..076b6c1 100644 --- a/docs/reverse-proxy.md +++ b/docs/reverse-proxy.md @@ -86,14 +86,17 @@ From a trusted proxy, and from nobody else: | Header | Read as | | --- | --- | -| `X-Forwarded-For` | The client's address. Its lines are read as one list, in the order they arrived, and walked from the right: the first address that is not itself a trusted proxy is the client. A hop that is no address leaves the client unknown: refused by every list of allowed addresses, and counted with the other unknowns. | +| `X-Forwarded-For` | The client's address. Its lines are read as one list, in the order they arrived, and walked from the right: the first address that is not itself a trusted proxy is the client. A hop that is no address leaves the client unknown: refused by every list of allowed addresses, and counted with the other unknowns. So does a request forwarded with no hop to read: a line left empty, or only a `Forwarded` or an `X-Real-IP`. | | `X-Forwarded-Proto` | The scheme the request reached the proxy by, `https` or `http`: its first value. | | `X-Forwarded-Host` | The name the request reached the proxy by: its first value. It counts as this server's own origin when a change is asked, and names the links of a feed when `AMS_PUBLIC_URL` is unset. | `Forwarded` and `X-Real-IP` are only recognised, never read: a proxy that sends either without -`X-Forwarded-For` leaves every client looking like the proxy. With several proxies in a row, -every one between the client and the server has to be in `AMS_TRUSTED_PROXIES`. A request from -an address that is not trusted keeps its own address, whatever it claims in these headers. +an `X-Forwarded-For` that has an address in it leaves its client unknown — refused by every +list of allowed addresses, counted with the other unknowns, and warned of in the log with the +proxy's address — rather than looking like the proxy. Only a request that carries none of the +three is taken to be the proxy's own. With several proxies in a row, every one between the +client and the server has to be in `AMS_TRUSTED_PROXIES`. A request from an address that is not +trusted keeps its own address, whatever it claims in these headers. The `Host` header is read as it arrives. With `AMS_ALLOWED_HOSTS` set, it has to be one of those names, or the request is answered `421` — so the proxy passes the client's `Host` on, and @@ -118,11 +121,16 @@ name, and the paths they call are best not forwarded by it at all: | `/v1/…`, `/v2/…` | Sonarr and Radarr | | `/3/…`, `/4/…` | TMDB clients | | `/v4/…` | TheTVDB clients | +| `/tmdb-images/…` | TMDB clients, for TMDB's pictures when `tmdb.images` names this server | They are guarded either way — by the list of allowed addresses, or by a key — but Plex sends a provider no credential, so the Plex provider is guarded by address alone unless its address -carries a key ([Keeping it on your network](plex.md#keeping-it-on-your-network)). The -interface asks for none of these paths. A Plex that is elsewhere and reaches this server +carries a key ([Keeping it on your network](plex.md#keeping-it-on-your-network)). So are +TMDB's pictures, since a picture's address carries no key. An address the list lets in is held +to a quota of its own, ten times the requests' — a library's scan asks for pictures in bursts — +and pictures are handed on from TMDB sixteen at a time; a proxy that forwards `/tmdb-images/…` +from the internet, to a TMDB policy that is `open`, still gives anyone a way to TMDB's picture +server through this one. The interface asks for none of these paths. A Plex that is elsewhere and reaches this server through the proxy is given a key in its address instead (`AMS_PLEX_AUTH=apikey`, [A key in the address](plex.md#a-key-in-the-address)), and `/plex` is then forwarded. @@ -182,7 +190,7 @@ server { client_max_body_size 61m; # The surfaces the clients call stay on the local network. - location ~ ^/(plex|v1|v2|v4|3|4)(/|$) { + location ~ ^/(plex|v1|v2|v4|3|4|tmdb-images)(/|$) { return 404; } @@ -210,6 +218,7 @@ certificate resolver `letsencrypt` in Traefik's own configuration: Host(`metadata.example.com`) && !PathPrefix(`/plex`) && !PathPrefix(`/v1/`) && !PathPrefix(`/v2/`) && !PathPrefix(`/v4/`) && !PathPrefix(`/3/`) && !PathPrefix(`/4/`) + && !PathPrefix(`/tmdb-images/`) traefik.http.routers.metadata.entrypoints: websecure traefik.http.routers.metadata.tls.certresolver: letsencrypt traefik.http.services.metadata.loadbalancer.server.port: "8080" @@ -224,7 +233,7 @@ Caddy obtains the certificate for the name, passes the `Host` on and sets the ``` metadata.example.com { - @clients path /plex /plex/* /v1/* /v2/* /v4/* /3/* /4/* + @clients path /plex /plex/* /v1/* /v2/* /v4/* /3/* /4/* /tmdb-images/* respond @clients 404 reverse_proxy metadata:8080 diff --git a/docs/security.md b/docs/security.md index f8e3847..517a511 100644 --- a/docs/security.md +++ b/docs/security.md @@ -31,37 +31,68 @@ is in [Accounts and access](access.md), and publishing the interface in browser's `Origin` is not this server's (`AMS_PUBLIC_URL` and `AMS_CORS_ORIGINS` count as this server's). Keys, Sonarr, Radarr and scripts are not concerned. -- **Every response carries a content security policy** with `frame-ancestors - 'none'`, so no page elsewhere can frame this one and borrow an administrator's - clicks, and `Referrer-Policy: no-referrer`, so the ids of what you are looking - at do not travel to the artwork hosts; a `Permissions-Policy` that denies the - camera, the microphone, the location and payments; on pages, a +- **Every response of the interface carries a content security policy** with + `frame-ancestors 'self'`, and `X-Frame-Options: SAMEORIGIN` beside it for an + engine older than the directive: no page elsewhere can frame this one and + borrow an administrator's clicks. This server's own pages may, on purpose — + the editor shows a work's public page in a frame, as a preview of it. The + clients' door, which serves no page, sends `default-src 'none'; + frame-ancestors 'none'; sandbox`. Both send `Referrer-Policy: no-referrer`, + so the ids of what you are looking at do not travel to the artwork hosts; the + interface also sends a `Permissions-Policy` that denies the camera, the + microphone, the location and payments; on pages, a `Cross-Origin-Opener-Policy`; and, over TLS — this server's own or a trusted proxy's — `Strict-Transport-Security` for a year. -- **Every surface is rate limited**, the arr and TMDB ones included +- **Every API surface is rate limited**, the arr and TMDB ones included (`AMS_RATE_LIMIT_PER_MINUTE`, 600 by default, per address — an IPv6 address - by its /64), save TMDB's pictures under `/tmdb-images/` for an address their - guard lets in, which a library's scan asks for in bursts; a refusal says - when to come back in `Retry-After`, and the per-address limit its quota in - `RateLimit` and `RateLimit-Policy`. Signing - in has two brakes of its own: an account that failed - `AMS_SIGNIN_FAILURES_PER_ACCOUNT` times (10) in fifteen minutes is refused - without being checked until the window closes, from wherever the attempts - come; and passwords are checked a few at a time — as many as the machine has + by its /64). TMDB's pictures under `/tmdb-images/` are counted apart for an + address their guard lets in, against a quota ten times as high, since a + library's scan asks for them in bursts; and they are handed on from TMDB + sixteen at a time, the others waiting their turn for five seconds before + they are answered `503`. A refusal says when to come back in `Retry-After`, + and the per-address limit its quota in `RateLimit` and `RateLimit-Policy`. + Signing in has two brakes of its own: an address — an IPv6 /64 as one — that + failed `AMS_SIGNIN_FAILURES_PER_ACCOUNT` times (10) in fifteen minutes for an + account is refused for it without being checked until the window closes, each + attempt counted before its password is; five times as many from every address + together refuse the account to the rest, never to an address it signed in from + in the last thirty days, so a guesser cannot shut its owner out; and passwords + are checked a few at a time — as many as the machine has cores, two to eight — so a burst of sign-ins waits, or is told to come back, rather than taking the memory argon2 needs for each. With passwords off, the name `AMS_ADMIN_USERNAME` keeps its door, and takes as long to refuse as any other name. +- **What costs the database more than it takes to ask for is done a few at a + time.** A search nobody made lately, the facets of a filter nobody chose, a + page of the catalogue, its figures: read three at a time, the others waiting + their turn for three seconds before they are answered `503` with + `Retry-After`. What the cache holds is answered whatever. A link's preview — + a work's, a list's, a genre's — is looked up on the caller's account, counted + against the allowance above as the API's requests are, and kept five seconds; + a caller who has spent its allowance is given the page without one. + `/ready` asks the database once in five seconds, however often it is asked. - **Each door holds `AMS_MAX_CONNECTIONS` connections at once** (1024); past - that, a new one waits to be accepted. A connection has + that, a new one waits to be accepted. No one network holds more than 64 of + them — an address, or an IPv6 /64 — and a connection past that is closed as + it is accepted; a trusted proxy's addresses and this machine's own are not + counted, since everybody behind a proxy has the proxy's. A connection has `AMS_HEADER_READ_TIMEOUT` seconds (30) to send a request's head, and may sit - idle between two requests no longer. + idle between two requests no longer. One with no request in flight for two + minutes is closed — an HTTP/2 one too, which is pinged and answers the + pings — and a response its peer does not read is given up on once a write + has stood still for a minute. - **What is logged has its secrets masked:** the values of `api_key`, `apikey`, - `token` and their like in any address, what follows `Bearer` or `Basic`, the + `token`, `auth`, a sign-in's `code`, `state` and `session_state`, TMDB's + `session_id`, `guest_session_id` and `request_token`, and their like in any + address, what follows `Bearer` or `Basic`, the password of an address, the configured keys and passwords, the keys kept for your own Sonarr and Radarr, and the addresses, tokens and signing secrets of notification destinations. A failed sign-in's username is logged quoted, its line breaks escaped. +- **Requests this server makes send no `Referer`,** which would hand the next + host of a redirect the address it came from, key included; and the providers + a key or a client id travels to — TMDB, Fanart.tv, TheTVDB, MyAnimeList — + are followed only within the origin first asked. - **An image URL may not name an address only this server can reach** — loopback, link-local, `169.254.169.254`. The NFO export downloads stored artwork, which makes an image URL a request this server makes on somebody else's say-so. diff --git a/frontend/e2e/oidc.serial.spec.ts b/frontend/e2e/oidc.serial.spec.ts index a330d3a..36b5d5c 100644 --- a/frontend/e2e/oidc.serial.spec.ts +++ b/frontend/e2e/oidc.serial.spec.ts @@ -302,7 +302,11 @@ test.describe('signing in through an identity provider', () => { provider.signInAs({ sub: `sub-${username}`, preferred_username: `${username}-there`, groups: [] }) await them.goto('/account') - await them.getByRole('link', { name: /tie my account|lier mon compte/i }).click() + // Tying asks for the password first: a session alone ties nothing. + await them.getByRole('button', { name: /tie my account|lier mon compte/i }).click() + const asked = them.getByRole('dialog') + await asked.getByLabel(/^(current password|mot de passe actuel)$/i).fill(password) + await asked.getByRole('button', { name: /tie my account|lier mon compte/i }).click() await them.waitForURL('**/account') await expect(them.getByText(/tied to the identity provider|lié au fournisseur d’identité/i)).toBeVisible() diff --git a/frontend/openapi.json b/frontend/openapi.json index 4a975b6..346faa0 100644 --- a/frontend/openapi.json +++ b/frontend/openapi.json @@ -217,7 +217,7 @@ "description": "A field was rejected" }, "403": { - "description": "The key limit is reached, or a key asked" + "description": "The key limit is reached, a key asked, or an administrator paused your keys" }, "409": { "description": "You already have a key by that name" @@ -288,6 +288,9 @@ "400": { "description": "A field was rejected" }, + "403": { + "description": "An administrator paused your keys, and only one can resume them" + }, "404": { "description": "Not one of your keys" }, @@ -423,6 +426,74 @@ } } }, + "/api/v1/account/provider": { + "post": { + "tags": [ + "Account" + ], + "summary": "Tie your account to the person the identity provider knows, so that you\ncan sign in through it as well.", + "description": "A session is not proof enough: one left open, or taken, would tie whoever\nholds it, and the tie would outlive the password the account's owner then\nresets. So the account's current password is asked for — or, for an account\nwith none, the provider is asked to have you sign in again, and its answer\nmust say you did. The answer is the provider's address to send the browser\nto; the tie is made when the browser comes back from it.", + "operationId": "oidc_tie", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TieRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Where to send the browser; the cookie of the flow is set", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TieStarted" + } + } + } + }, + "400": { + "description": "The password was left out, or is too long" + }, + "401": { + "description": "The password was wrong" + }, + "403": { + "description": "A key cannot manage an account" + }, + "409": { + "description": "The account is tied already, or no provider is set up" + }, + "429": { + "description": "Too many wrong passwords lately" + }, + "502": { + "description": "The provider did not answer" + } + } + }, + "delete": { + "tags": [ + "Account" + ], + "summary": "Untie your account from the identity provider. Not one that would be left\nwith no way in: a password of its own, which this server asks for, comes\nfirst. Your other sessions are closed — one of them may have been opened\nthrough the tie.", + "operationId": "oidc_untie", + "responses": { + "204": { + "description": "Untied; the other sessions are closed" + }, + "403": { + "description": "A key cannot manage an account" + }, + "409": { + "description": "The account is not tied, or would be left with no way in" + } + } + } + }, "/api/v1/account/sessions": { "get": { "tags": [ @@ -1977,7 +2048,7 @@ "tags": [ "Session" ], - "summary": "Go to the identity provider to sign in — or, with `link`, to tie the\nsigned-in account to the person the provider knows.", + "summary": "Go to the identity provider to sign in. Tying an account to the person the\nprovider knows is asked for with `POST /account/provider`.", "operationId": "oidc_start", "parameters": [ { @@ -1992,7 +2063,7 @@ { "name": "link", "in": "query", - "description": "Set by a signed-in person tying their account to the provider.", + "description": "Refused: a tie is asked for from the account page, with\n`POST /account/provider`, which proves it by the account's password.", "required": false, "schema": { "type": "string" @@ -2425,6 +2496,7 @@ "Clients" ], "summary": "Enable or disable a key without revoking it.", + "description": "A person's key paused from here pauses its owner's keys: its owner can\nneither resume it nor make another, or resume any other of theirs, until an\nadministrator enables a key of theirs from here, or lifts the pause\n(`DELETE /users/{id}/keys-pause`).", "operationId": "clients_update", "parameters": [ { @@ -3486,6 +3558,9 @@ }, "400": { "description": "A filter was not one the list understands" + }, + "503": { + "description": "As many such reads as the server answers at once are under way; `Retry-After` says when to ask again" } } } @@ -3577,6 +3652,9 @@ } } } + }, + "503": { + "description": "As many such reads as the server answers at once are under way; `Retry-After` says when to ask again" } } } @@ -4254,6 +4332,9 @@ }, "401": { "description": "No valid credential was presented" + }, + "503": { + "description": "As many such reads as the server answers at once are under way; `Retry-After` says when to ask again" } } }, @@ -8161,6 +8242,9 @@ }, "400": { "description": "The term is too long, a kind is not one of work, person and episode, or the language is not a language tag" + }, + "503": { + "description": "As many such reads as the server answers at once are under way; `Retry-After` says when to ask again" } } } @@ -9043,6 +9127,34 @@ } } }, + "/api/v1/users/{id}/keys-pause": { + "delete": { + "tags": [ + "Users" + ], + "summary": "Lift the pause an administrator put on a person's keys — by disabling one\nof them from **Keys & network** — so that they can make keys and resume\nthem again. Enabling a key of theirs from there lifts it too; this is for\na person who has deleted theirs, with none left to enable.", + "operationId": "users_lift_keys_pause", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "Lifted, or there was none" + }, + "403": { + "description": "The caller is not an administrator" + }, + "404": {} + } + } + }, "/api/v1/users/{id}/oidc": { "delete": { "tags": [ @@ -9077,7 +9189,7 @@ "tags": [ "Users" ], - "summary": "Give an account a new password; its sessions are closed.", + "summary": "Give an account a new password; its sessions are closed, and so is its tie\nto the identity provider, if it has one: whoever tied it — the person, or\nsomebody holding a session of theirs — signed in as them through the\nprovider, which no password reset would otherwise end.", "operationId": "users_reset_password", "parameters": [ { @@ -9742,11 +9854,14 @@ "404": { "description": "TMDB has no such picture at that size" }, + "429": { + "description": "The caller's quota of pictures is spent for now; `Retry-After` says when to ask again" + }, "502": { "description": "TMDB's pictures could not be reached" }, "503": { - "description": "The TMDB relay is switched off" + "description": "The TMDB relay is switched off, or as many pictures as are handed on from TMDB at once are under way (`Retry-After` says when to ask again)" } }, "security": [] @@ -24654,6 +24769,37 @@ } } }, + "TieRequest": { + "type": "object", + "properties": { + "currentPassword": { + "type": [ + "string", + "null" + ], + "description": "The account's current password: the proof, from an account that has\none, that its owner is asking and not a session left open. An account\nwith none leaves it out, and is sent to sign in at the provider again\ninstead." + }, + "next": { + "type": [ + "string", + "null" + ], + "description": "The page of this site to come back to." + } + } + }, + "TieStarted": { + "type": "object", + "required": [ + "url" + ], + "properties": { + "url": { + "type": "string", + "description": "Where to send the browser: the identity provider's sign-in. The\ncookie that carries the flow back is set with this answer." + } + } + }, "TimeOfDay": { "type": "object", "required": [ @@ -25117,6 +25263,13 @@ "items": { "$ref": "#/components/schemas/Session" } + }, + "keysPausedBy": { + "type": [ + "string", + "null" + ], + "description": "Who paused this person's keys, while the pause stands: until an\nadministrator lifts it ([`lift_keys_pause`]), they can neither make a\nkey nor resume one." } } }, diff --git a/frontend/src/components/Theatre.tsx b/frontend/src/components/Theatre.tsx index b61194a..883b2cc 100644 --- a/frontend/src/components/Theatre.tsx +++ b/frontend/src/components/Theatre.tsx @@ -171,6 +171,10 @@ export function Lightbox({ * no-cookie domain is the one this server's page policy lets frame, and the * frame sends YouTube this server's origin — which its player now refuses to * play without — and nothing more. + * + * The frame is sandboxed: YouTube's player runs, keeps its own origin's + * storage and goes full screen, and its links open in a window of their own; + * it can neither navigate this page, open a form, nor download anything. */ export function Trailer({ youtubeId, @@ -207,6 +211,7 @@ export function Trailer({ src={trailerEmbed(youtubeId)} title={heading} className="size-full" + sandbox="allow-scripts allow-same-origin allow-presentation allow-popups allow-popups-to-escape-sandbox" allow="autoplay; encrypted-media; picture-in-picture; fullscreen" allowFullScreen referrerPolicy="strict-origin" diff --git a/frontend/src/lib/api.gen.ts b/frontend/src/lib/api.gen.ts index 642d2c1..7832d71 100644 --- a/frontend/src/lib/api.gen.ts +++ b/frontend/src/lib/api.gen.ts @@ -203,6 +203,38 @@ export interface paths { patch?: never; trace?: never; }; + "/api/v1/account/provider": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Tie your account to the person the identity provider knows, so that you + * can sign in through it as well. + * @description A session is not proof enough: one left open, or taken, would tie whoever + * holds it, and the tie would outlive the password the account's owner then + * resets. So the account's current password is asked for — or, for an account + * with none, the provider is asked to have you sign in again, and its answer + * must say you did. The answer is the provider's address to send the browser + * to; the tie is made when the browser comes back from it. + */ + post: operations["oidc_tie"]; + /** + * Untie your account from the identity provider. Not one that would be left + * with no way in: a password of its own, which this server asks for, comes + * first. Your other sessions are closed — one of them may have been opened + * through the tie. + */ + delete: operations["oidc_untie"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v1/account/sessions": { parameters: { query?: never; @@ -1024,8 +1056,8 @@ export interface paths { cookie?: never; }; /** - * Go to the identity provider to sign in — or, with `link`, to tie the - * signed-in account to the person the provider knows. + * Go to the identity provider to sign in. Tying an account to the person the + * provider knows is asked for with `POST /account/provider`. */ get: operations["oidc_start"]; put?: never; @@ -1224,7 +1256,13 @@ export interface paths { delete: operations["clients_remove"]; options?: never; head?: never; - /** Enable or disable a key without revoking it. */ + /** + * Enable or disable a key without revoking it. + * @description A person's key paused from here pauses its owner's keys: its owner can + * neither resume it nor make another, or resume any other of theirs, until an + * administrator enables a key of theirs from here, or lifts the pause + * (`DELETE /users/{id}/keys-pause`). + */ patch: operations["clients_update"]; trace?: never; }; @@ -3672,6 +3710,28 @@ export interface paths { patch: operations["users_update"]; trace?: never; }; + "/api/v1/users/{id}/keys-pause": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + /** + * Lift the pause an administrator put on a person's keys — by disabling one + * of them from **Keys & network** — so that they can make keys and resume + * them again. Enabling a key of theirs from there lifts it too; this is for + * a person who has deleted theirs, with none left to enable. + */ + delete: operations["users_lift_keys_pause"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v1/users/{id}/oidc": { parameters: { query?: never; @@ -3702,7 +3762,12 @@ export interface paths { }; get?: never; put?: never; - /** Give an account a new password; its sessions are closed. */ + /** + * Give an account a new password; its sessions are closed, and so is its tie + * to the identity provider, if it has one: whoever tied it — the person, or + * somebody holding a session of theirs — signed in as them through the + * provider, which no password reset would otherwise end. + */ post: operations["users_reset_password"]; delete?: never; options?: never; @@ -9734,6 +9799,24 @@ export interface components { /** @description The issuer to try; the stored one when left out. */ issuer?: string | null; }; + TieRequest: { + /** + * @description The account's current password: the proof, from an account that has + * one, that its owner is asking and not a session left open. An account + * with none leaves it out, and is sent to sign in at the provider again + * instead. + */ + currentPassword?: string | null; + /** @description The page of this site to come back to. */ + next?: string | null; + }; + TieStarted: { + /** + * @description Where to send the browser: the identity provider's sign-in. The + * cookie that carries the flow back is set with this answer. + */ + url: string; + }; TimeOfDay: { /** Format: int32 */ hours: number; @@ -9925,6 +10008,12 @@ export interface components { user: components["schemas"]["User"]; keys: components["schemas"]["ApiClient"][]; sessions: components["schemas"]["Session"][]; + /** + * @description Who paused this person's keys, while the pause stands: until an + * administrator lifts it ([`lift_keys_pause`]), they can neither make a + * key nor resume one. + */ + keysPausedBy?: string | null; }; UsersPage: { users: components["schemas"]["Listed"][]; @@ -10623,7 +10712,7 @@ export interface operations { }; content?: never; }; - /** @description The key limit is reached, or a key asked */ + /** @description The key limit is reached, a key asked, or an administrator paused your keys */ 403: { headers: { [name: string]: unknown; @@ -10695,6 +10784,13 @@ export interface operations { }; content?: never; }; + /** @description An administrator paused your keys, and only one can resume them */ + 403: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; /** @description Not one of your keys */ 404: { headers: { @@ -10839,6 +10935,104 @@ export interface operations { }; }; }; + oidc_tie: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["TieRequest"]; + }; + }; + responses: { + /** @description Where to send the browser; the cookie of the flow is set */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["TieStarted"]; + }; + }; + /** @description The password was left out, or is too long */ + 400: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description The password was wrong */ + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description A key cannot manage an account */ + 403: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description The account is tied already, or no provider is set up */ + 409: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Too many wrong passwords lately */ + 429: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description The provider did not answer */ + 502: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + oidc_untie: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Untied; the other sessions are closed */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description A key cannot manage an account */ + 403: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description The account is not tied, or would be left with no way in */ + 409: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; account_sessions: { parameters: { query?: never; @@ -12393,7 +12587,10 @@ export interface operations { query?: { /** @description The page of this site to come back to. */ next?: string; - /** @description Set by a signed-in person tying their account to the provider. */ + /** + * @description Refused: a tie is asked for from the account page, with + * `POST /account/provider`, which proves it by the account's password. + */ link?: string; }; header?: never; @@ -13611,6 +13808,13 @@ export interface operations { }; content?: never; }; + /** @description As many such reads as the server answers at once are under way; `Retry-After` says when to ask again */ + 503: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; }; }; feeds_added_atom: { @@ -13697,6 +13901,13 @@ export interface operations { "application/json": components["schemas"]["Figures"]; }; }; + /** @description As many such reads as the server answers at once are under way; `Retry-After` says when to ask again */ + 503: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; }; }; browse_genre: { @@ -14222,6 +14433,13 @@ export interface operations { }; content?: never; }; + /** @description As many such reads as the server answers at once are under way; `Retry-After` says when to ask again */ + 503: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; }; }; items_create: { @@ -17819,6 +18037,13 @@ export interface operations { }; content?: never; }; + /** @description As many such reads as the server answers at once are under way; `Retry-After` says when to ask again */ + 503: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; }; }; seasons_chart: { @@ -18625,6 +18850,39 @@ export interface operations { }; }; }; + users_lift_keys_pause: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Lifted, or there was none */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description The caller is not an administrator */ + 403: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; oidc_unlink: { parameters: { query?: never; @@ -19176,6 +19434,13 @@ export interface operations { }; content?: never; }; + /** @description The caller's quota of pictures is spent for now; `Retry-After` says when to ask again */ + 429: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; /** @description TMDB's pictures could not be reached */ 502: { headers: { @@ -19183,7 +19448,7 @@ export interface operations { }; content?: never; }; - /** @description The TMDB relay is switched off */ + /** @description The TMDB relay is switched off, or as many pictures as are handed on from TMDB at once are under way (`Retry-After` says when to ask again) */ 503: { headers: { [name: string]: unknown; diff --git a/frontend/src/lib/lang/en.ts b/frontend/src/lib/lang/en.ts index fdfffd2..8e42393 100644 --- a/frontend/src/lib/lang/en.ts +++ b/frontend/src/lib/lang/en.ts @@ -1000,9 +1000,18 @@ export const en = { key: 'Your API key', keysLead: 'For a script, an app or a TMDB or TheTVDB client. A key acts in your name, with your rights and no more.', provider: 'Identity provider', - providerLead: 'Sign in through the identity provider as well as with your password: tie this account to the one you have there. You sign in there once to prove it is yours.', + providerLead: 'Sign in through the identity provider as well as with your password: tie this account to the one you have there. You give your password here, then sign in there, which shows both halves are yours.', + providerLeadNoPassword: 'Tie this account to the one you have at the identity provider. You sign in there again, whatever session it holds for you, which shows the account is yours.', providerTie: 'Tie my account', + providerTieTitle: 'Tie your account to the identity provider', + providerTieBody: 'Give your password to show that it is you asking, and not a session left open on this device. You are then sent to the identity provider to sign in.', + providerTieBodyNoPassword: 'This account has no password: the identity provider is asked to sign you in again, and the tie is made once it has.', providerTied: 'Tied to the identity provider: you can sign in through it.', + providerUntie: 'Untie…', + providerUntieTitle: 'Untie your account from the identity provider?', + providerUntieBody: 'You will sign in with your password only. Your other sessions are closed; this one stays open.', + providerUntieConfirm: 'Untie', + providerUntieNeedsPassword: 'This account has no password of its own, so it cannot be untied: it would have no way back in.', relayYes: 'Your keys read this catalogue’s API (/api/v1) and open the TMDB (/3) and TheTVDB (/v4) relays, for Jellyseerr, Yamtrack or Kodi.', relayNo: 'Your keys read this catalogue’s API (/api/v1). The relays are kept for the catalogue’s editors here.', keyCount: (used: number, limit: number) => `${used} of ${limit}`, @@ -4022,6 +4031,7 @@ export const en = { 'dataset.imported': 'Dataset downloaded', 'backup.deleted': 'Archive deleted', 'backup.restored': 'Archive restored', + 'backup.downloaded': 'Archive downloaded', 'item.numbering_fallback': 'Numbered by TMDB in TheTVDB’s place', 'item.numbering_restored': 'Numbering given back to TheTVDB', 'order.created': 'Order made', diff --git a/frontend/src/lib/lang/fr.ts b/frontend/src/lib/lang/fr.ts index 0c430bd..3c8b29f 100644 --- a/frontend/src/lib/lang/fr.ts +++ b/frontend/src/lib/lang/fr.ts @@ -993,9 +993,18 @@ export const fr: Dict = { key: 'Votre clé API', keysLead: 'Pour un script, une application ou un client TMDB ou TheTVDB. Une clé agit en votre nom, avec vos droits et pas davantage.', provider: 'Fournisseur d’identité', - providerLead: 'Se connecter par le fournisseur d’identité en plus du mot de passe : liez ce compte à celui que vous avez là-bas. Vous vous y connectez une fois pour prouver qu’il est à vous.', + providerLead: 'Se connecter par le fournisseur d’identité en plus du mot de passe : liez ce compte à celui que vous avez là-bas. Vous donnez votre mot de passe ici, puis vous vous connectez là-bas : les deux moitiés sont à vous.', + providerLeadNoPassword: 'Liez ce compte à celui que vous avez chez le fournisseur d’identité. Vous vous y reconnectez, quelle que soit la session qu’il garde pour vous : c’est ce qui prouve que le compte est à vous.', providerTie: 'Lier mon compte', + providerTieTitle: 'Lier votre compte au fournisseur d’identité', + providerTieBody: 'Donnez votre mot de passe pour montrer que c’est bien vous, et non une session restée ouverte sur cet appareil. Vous êtes ensuite envoyé chez le fournisseur d’identité pour vous y connecter.', + providerTieBodyNoPassword: 'Ce compte n’a pas de mot de passe : le fournisseur d’identité est prié de vous reconnecter, et le lien n’est fait qu’une fois que c’est fait.', providerTied: 'Lié au fournisseur d’identité : vous pouvez vous connecter par lui.', + providerUntie: 'Délier…', + providerUntieTitle: 'Délier votre compte du fournisseur d’identité ?', + providerUntieBody: 'Vous ne vous connecterez plus qu’avec votre mot de passe. Vos autres sessions sont fermées ; celle-ci reste ouverte.', + providerUntieConfirm: 'Délier', + providerUntieNeedsPassword: 'Ce compte n’a pas de mot de passe à lui : il ne peut pas être délié, il n’aurait plus aucun moyen de se reconnecter.', relayYes: 'Vos clés lisent l’API de ce catalogue (/api/v1) et ouvrent les relais TMDB (/3) et TheTVDB (/v4), pour Jellyseerr, Yamtrack ou Kodi.', relayNo: 'Vos clés lisent l’API de ce catalogue (/api/v1). Les relais sont réservés ici aux éditeurs du catalogue.', keyCount: (used: number, limit: number) => `${used} sur ${limit}`, @@ -4023,6 +4032,7 @@ export const fr: Dict = { 'dataset.imported': 'Jeu de données téléchargé', 'backup.deleted': 'Archive supprimée', 'backup.restored': 'Archive restaurée', + 'backup.downloaded': 'Archive téléchargée', 'item.numbering_fallback': 'Numérotée par TMDB à la place de TheTVDB', 'item.numbering_restored': 'Numérotation rendue à TheTVDB', 'order.created': 'Ordre fait', diff --git a/frontend/src/routes/Account.tsx b/frontend/src/routes/Account.tsx index 38e950e..dfb3653 100644 --- a/frontend/src/routes/Account.tsx +++ b/frontend/src/routes/Account.tsx @@ -21,7 +21,6 @@ import { RoleChip } from '../components/account/people' import { SecretReveal } from '../components/account/SecretReveal' import { Button, - ButtonLink, Chip, Dialog, EmptyState, @@ -110,11 +109,16 @@ export function Account() { * Tying this account to the one its owner has at the identity provider: a * sign-in there, started from here while signed in here, so both sides are * proved by the same person. Offered only when a provider is set up. + * + * A session alone is not asked to tie: one left open, or taken, would tie + * whoever holds it. The password is given first — an account without one is + * sent to sign in at the provider again instead — and the tie is made when + * the browser comes back. Untied by its owner, who keeps their password. */ function Provider({ me }: { me: User }) { const { t } = useI18n() const options = useAuthOptions() - const { pathname } = useLocation() + const [asking, setAsking] = useState<'tie' | 'untie' | null>(null) if (!options.data?.oidc && !me.oidcLinked) return null @@ -123,24 +127,145 @@ function Provider({ me }: { me: User }) {
{me.oidcLinked ? ( -

- - {t.account.providerTied} -

+ <> +

+ + {t.account.providerTied} +

+ {me.hasPassword ? ( + + ) : ( +

{t.account.providerUntieNeedsPassword}

+ )} + ) : ( <> -

{t.account.providerLead}

- +

+ {me.hasPassword ? t.account.providerLead : t.account.providerLeadNoPassword} +

+ )}
+ {asking === 'tie' ? setAsking(null)} /> : null} + {asking === 'untie' ? setAsking(null)} /> : null} ) } +/** The password, and then the provider's own sign-in. */ +function TieProvider({ me, onClose }: { me: User; onClose: () => void }) { + const { t } = useI18n() + const { pathname } = useLocation() + const [password, setPassword] = useState('') + + const tie = useMutation({ + mutationFn: () => + api.post<{ url: string }>('/account/provider', { + ...(me.hasPassword ? { currentPassword: password } : {}), + next: pathname, + }), + // To the provider, and back here once it has signed them in. + onSuccess: ({ url }) => window.location.assign(url), + }) + + const missing = me.hasPassword && password === '' + const wrong = tie.error instanceof ApiError && tie.error.isUnauthorized + const waiting = tie.isPending || tie.isSuccess + const refusal = tie.isError ? (wrong ? t.admin.config.passwordWrong : tie.error.message) : undefined + + return ( + + + + + } + > +
{ + event.preventDefault() + if (!missing && !waiting) tie.mutate() + }} + > +

+ {me.hasPassword ? t.account.providerTieBody : t.account.providerTieBodyNoPassword} +

+ {me.hasPassword ? ( + + setPassword(event.target.value)} + /> + + ) : refusal ? ( +

+ {refusal} +

+ ) : null} +
+
+ ) +} + +/** Asked first: what is untied is not tied again without the password. */ +function UntieProvider({ onClose }: { onClose: () => void }) { + const { t } = useI18n() + const queryClient = useQueryClient() + + const untie = useMutation({ + mutationFn: () => api.delete('/account/provider'), + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: ['account'] }) + void queryClient.invalidateQueries({ queryKey: ['me'] }) + onClose() + }, + }) + + return ( + + + + + } + > +

{t.account.providerUntieBody}

+ {untie.isError ? ( +

+ {untie.error.message} +

+ ) : null} +
+ ) +} + /* ── Profile ──────────────────────────────────────────────────────────────── */ function Profile({ me }: { me: User }) { @@ -686,9 +811,11 @@ function KeyList({ > {confirm?.what === 'rotate' ? t.account.regenerateBody : t.account.revokeBody} - {rotate.isError || revoke.isError ? ( + {rotate.isError || revoke.isError || toggle.isError ? (

- {(rotate.error ?? revoke.error) instanceof ApiError ? (rotate.error ?? revoke.error)?.message : null} + {(rotate.error ?? revoke.error ?? toggle.error) instanceof ApiError + ? (rotate.error ?? revoke.error ?? toggle.error)?.message + : null}

) : null} diff --git a/migrations/postgres/0053_season_order_tvdb_index.sql b/migrations/postgres/0053_season_order_tvdb_index.sql new file mode 100644 index 0000000..bf00d27 --- /dev/null +++ b/migrations/postgres/0053_season_order_tvdb_index.sql @@ -0,0 +1,6 @@ +-- The TheTVDB relay finds the work that holds a season, or an episode an +-- order keeps, by TheTVDB's id of it, as it finds an episode (0026): a number +-- nobody holds read every season and every episode of every order, on each +-- request. Partial, as there: what is made by hand has no such id. +CREATE INDEX ix_media_season_tvdb ON media_season (tvdb_id) WHERE tvdb_id IS NOT NULL; +CREATE INDEX ix_order_episode_tvdb ON media_order_episode (tvdb_episode_id) WHERE tvdb_episode_id IS NOT NULL; diff --git a/migrations/sqlite/0053_season_order_tvdb_index.sql b/migrations/sqlite/0053_season_order_tvdb_index.sql new file mode 100644 index 0000000..bf00d27 --- /dev/null +++ b/migrations/sqlite/0053_season_order_tvdb_index.sql @@ -0,0 +1,6 @@ +-- The TheTVDB relay finds the work that holds a season, or an episode an +-- order keeps, by TheTVDB's id of it, as it finds an episode (0026): a number +-- nobody holds read every season and every episode of every order, on each +-- request. Partial, as there: what is made by hand has no such id. +CREATE INDEX ix_media_season_tvdb ON media_season (tvdb_id) WHERE tvdb_id IS NOT NULL; +CREATE INDEX ix_order_episode_tvdb ON media_order_episode (tvdb_episode_id) WHERE tvdb_episode_id IS NOT NULL; diff --git a/scripts/watch/common.py b/scripts/watch/common.py index ecdfb47..6df1c11 100644 --- a/scripts/watch/common.py +++ b/scripts/watch/common.py @@ -101,6 +101,7 @@ def _closed(url, data, headers, method, timeout, limit): """One request on a connection of its own, as urllib makes them.""" request = urllib.request.Request(url, data=data, headers=headers, method=method) try: + # nosemgrep: python.lang.security.audit.dynamic-urllib-use-detected.dynamic-urllib-use-detected — the URLs are the checks' own https constants with urllib.request.urlopen(request, timeout=timeout) as response: body = response.read(limit) if limit else response.read() return response.status, response.headers, _decoded(body, response.headers, limit) @@ -145,8 +146,10 @@ def _kept(url, data, headers, method, timeout, limit): def _connect(host, port, timeout): proxy = urllib.request.getproxies().get("https") if not proxy or urllib.request.proxy_bypass(host): + # nosemgrep: python.lang.security.audit.httpsconnection-detected.httpsconnection-detected — http.client verifies the certificate and the host name return http.client.HTTPSConnection(host, port, timeout=timeout) via = urllib.parse.urlsplit(proxy if "://" in proxy else f"http://{proxy}") + # nosemgrep: python.lang.security.audit.httpsconnection-detected.httpsconnection-detected — http.client verifies the certificate and the host name connection = http.client.HTTPSConnection(via.hostname, via.port or 80, timeout=timeout) tunnel = {} if via.username: diff --git a/src/api/anilist.rs b/src/api/anilist.rs index 638dd77..d0e09dd 100644 --- a/src/api/anilist.rs +++ b/src/api/anilist.rs @@ -86,6 +86,12 @@ const COLLECTIONS: &[&str] = &["GenreCollection", "MediaTagCollection"]; /// What a relayed request keeps of the caller's headers: what describes it. const ASKED_WITH: &[&str] = &["user-agent", "accept", "accept-language", "content-type"]; +/// Of those, the headers that may change AniList's answer: what a kept answer +/// is told apart by besides the query — the type a body is posted as says how +/// AniList reads it — as it travels asked with them, an answer filed without +/// them would be handed to a caller that asked otherwise. +const VARIES_BY: &[&str] = &["accept", "accept-language", "content-type"]; + /// How many entries of one answer are looked up here, in the order the /// answer lists them. A page is fifty at most; a user's whole list, in one /// answer, is not patched beyond this. @@ -258,7 +264,7 @@ pub async fn relay(State(state): State, request: Request) -> AppResult .is_some_and(|query| !has_mutation(query) && !is_personal(query)); let ask = relay::Ask { service: relay::Service::Anilist, - key: anybodys.then(|| relay_key(&state, &parts.method, &parts.uri, &body)), + key: anybodys.then(|| relay_key(&state, &parts.method, &parts.uri, &parts.headers, &body)), path: if document.as_deref().is_some_and(asks_collections_alone) { cache::ANILIST_COLLECTIONS } else { @@ -535,13 +541,23 @@ fn is_personal(document: &str) -> bool { } /// What a relayed answer is filed under: the query asked — its text, its -/// variables, whatever else the body says — digested, under the generation. -fn relay_key(state: &AppState, method: &Method, uri: &Uri, body: &[u8]) -> String { +/// variables, whatever else the body says — and the caller's headers that +/// travel and may change AniList's answer ([`relay::varies_by`]), digested, +/// under the generation. +fn relay_key( + state: &AppState, + method: &Method, + uri: &Uri, + headers: &HeaderMap, + body: &[u8], +) -> String { let mut digest = Sha256::new(); digest.update(method.as_str().as_bytes()); digest.update(b"\n"); digest.update(relay::sorted_query(uri, relay::OUR_PARAMS).as_bytes()); digest.update(b"\n"); + digest.update(relay::varies_by(headers, VARIES_BY).as_bytes()); + digest.update(b"\n"); digest.update(body); let digest: String = digest .finalize() @@ -2169,6 +2185,17 @@ mod tests { /// What the relay answers `query`, asked with somebody's own AniList /// token when there is one. async fn relayed(state: &AppState, query: &str, token: Option<&str>) -> Response { + relayed_with(state, query, token, &[]).await + } + + /// The same, with headers of the caller's own, which take the place of + /// the ones a client posts a query with. + async fn relayed_with( + state: &AppState, + query: &str, + token: Option<&str>, + headers: &[(header::HeaderName, &str)], + ) -> Response { let mut request = axum::http::Request::builder() .method(Method::POST) .uri("/") @@ -2177,7 +2204,12 @@ mod tests { if let Some(token) = token { request = request.header(header::AUTHORIZATION, format!("Bearer {token}")); } - let request = request.body(Body::from(posted(query))).unwrap(); + let mut request = request.body(Body::from(posted(query))).unwrap(); + for (name, value) in headers { + request + .headers_mut() + .insert(name.clone(), HeaderValue::from_str(value).unwrap()); + } match relay(State(state.clone()), request).await { Ok(response) => response, Err(error) => error.into_response(), @@ -2195,22 +2227,91 @@ mod tests { response.headers().get(name)?.to_str().ok()?.parse().ok() } + /// The headers a client posts a query with, as [`relayed`] does. + fn posting() -> HeaderMap { + let mut headers = HeaderMap::new(); + headers.insert( + header::CONTENT_TYPE, + HeaderValue::from_static("application/json"), + ); + headers + } + /// What the relay space holds for `query`. async fn kept(state: &AppState, query: &str) -> Option { - let key = relay_key(state, &Method::POST, &Uri::from_static("/"), &posted(query)); + let key = relay_key( + state, + &Method::POST, + &Uri::from_static("/"), + &posting(), + &posted(query), + ); state.caches.relay.get(&key).await } /// What was kept for `query`, made `seconds` old — as if AniList had /// answered it then. async fn aged(state: &AppState, query: &str, seconds: u64) { - let key = relay_key(state, &Method::POST, &Uri::from_static("/"), &posted(query)); + let key = relay_key( + state, + &Method::POST, + &Uri::from_static("/"), + &posting(), + &posted(query), + ); let mut kept = state.caches.relay.get(&key).await.expect("kept"); kept.expires_at = cache::now_secs() + cache::relay_ttl(cache::ANILIST_QUERY).as_secs() - seconds; state.caches.relay.insert(key, kept).await; } + /// What AniList answered is kept for the callers who asked as its first + /// did — by the headers that travel to it and may change its answer: the + /// type a body is posted as says how AniList reads it — and not for + /// those who asked otherwise. + #[tokio::test] + async fn an_answer_is_kept_for_the_callers_who_asked_as_its_first_did() { + const FIRST: &str = r#"{"data":{"Media":{"id":9400001,"title":{"romaji":"First"}}}}"#; + const OTHER: &str = r#"{"data":{"Media":{"id":9400001,"title":{"romaji":"Other"}}}}"#; + let (at, asked) = stand_in(vec![reply("200 OK", FIRST), reply("200 OK", OTHER)]).await; + let state = server(&at).await; + let query = "{ Media(id: 9400001) { id title { romaji } } }"; + + assert_eq!(text(relayed(&state, query, None).await).await, FIRST); + assert_eq!(text(relayed(&state, query, None).await).await, FIRST); + assert_eq!(asked.load(Ordering::SeqCst), 1, "kept for the same caller"); + + // Another language: another entry, asked of AniList; and then kept. + let german = [(header::ACCEPT_LANGUAGE, "de")]; + assert_eq!( + text(relayed_with(&state, query, None, &german).await).await, + OTHER + ); + assert_eq!(asked.load(Ordering::SeqCst), 2); + assert_eq!( + text(relayed_with(&state, query, None, &german).await).await, + OTHER + ); + assert_eq!(asked.load(Ordering::SeqCst), 2); + // The first caller's is as it was. + assert_eq!(text(relayed(&state, query, None).await).await, FIRST); + assert_eq!(asked.load(Ordering::SeqCst), 2); + + // The same body posted as another type is another request to AniList, + // and another entry here. + let graphql = [(header::CONTENT_TYPE, "application/graphql")]; + let _ = relayed_with(&state, query, None, &graphql).await; + assert_eq!(asked.load(Ordering::SeqCst), 3); + let _ = relayed_with(&state, query, None, &graphql).await; + assert_eq!(asked.load(Ordering::SeqCst), 3, "and then kept"); + // And what does not change AniList's answer is not another entry. + let agent = [(header::USER_AGENT, "Yamtrack/1.0")]; + assert_eq!( + text(relayed_with(&state, query, None, &agent).await).await, + FIRST + ); + } + #[tokio::test] async fn when_anilist_fails_its_last_answer_stands_in_for_a_day() { const KEPT: &str = r#"{"data":{"Media":{"id":9300001,"title":{"romaji":"Kept"}}}}"#; diff --git a/src/api/audit.rs b/src/api/audit.rs index b849e43..812efb9 100644 --- a/src/api/audit.rs +++ b/src/api/audit.rs @@ -1,17 +1,25 @@ //! Recording and reading the audit trail. +use std::{ + collections::HashMap, + net::IpAddr, + sync::LazyLock, + time::{Duration, Instant}, +}; + use axum::{ Extension, Json, extract::{Query as AxumQuery, State}, response::{IntoResponse, Response}, }; +use parking_lot::Mutex; use serde::{Deserialize, Serialize}; use utoipa::{IntoParams, ToSchema}; use utoipa_axum::{router::OpenApiRouter, routes}; use crate::{ api::extract::ClientIp, - auth::Identity, + auth::{Identity, ratelimit::network_of}, db::repo::{ self, audit::{Action, Entry}, @@ -35,13 +43,18 @@ pub struct Event<'a> { /// refusing it after the fact because the trail could not be written would be /// worse than an incomplete trail. pub async fn record(state: &AppState, event: Event<'_>) { + record_row(state, event).await; +} + +/// [`record`], and the id of the row it wrote — nothing when it could not. +async fn record_row(state: &AppState, event: Event<'_>) -> Option { let actor = event.identity.map(Identity::label); let ip = event.ip.as_text(); let target = event.target.map(clip); let detail = event.detail.map(clip); - let result = repo::audit::record( + let result = repo::audit::record_returning( &state.db, repo::audit::Record { actor: actor.as_deref(), @@ -53,9 +66,13 @@ pub async fn record(state: &AppState, event: Event<'_>) { ) .await; - if let Err(e) = result { - tracing::warn!(action = %event.action, error = %e, "could not write the audit entry"); - } + let row = match result { + Ok(id) => Some(id), + Err(e) => { + tracing::warn!(action = %event.action, error = %e, "could not write the audit entry"); + None + } + }; // A lock, an edit, a sync: told to the operator's own Sonarr or Radarr // too, when that is asked for. @@ -73,6 +90,8 @@ pub async fn record(state: &AppState, event: Event<'_>) { }, ) .await; + + row } /// Who the trail says did what this server does of its own accord. @@ -113,6 +132,221 @@ pub async fn record_system(state: &AppState, action: Action, target: &str, detai .await; } +// ─── failures of somebody with no credential ───────────────────────────────── + +/// What kind of failure somebody with no credential made, for the trail to +/// gather them by. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum Failure { + /// A password that was refused at the sign-in form. + Password, + /// A return from the identity provider that was refused. + Provider, +} + +/// How long the failures of one kind from one network are gathered into one +/// row. +const GATHERED_FOR: Duration = Duration::from_secs(60); + +/// The most networks gathered at once. The addresses come from whoever can +/// reach the port; past this the rest are gathered with the one every address +/// that cannot be told shares, so that the rows written stay bounded however +/// many addresses there are. +const GATHERED_MOST: usize = 4096; + +/// What one network's failures of one kind have come to in the window open. +struct Gathered { + opened: Instant, + /// The row the first of them was recorded in, to say how many there were + /// when the window closes — none when it could not be written. + row: Option, + /// Failures since the first, which was recorded at once: counted here, + /// and told together when the window closes. + held: u32, + /// The name and the note of the latest of them, for the row to read by. + last: (Option, Option), + /// Whether a task is waiting for the window to close to tell them. + waiting: bool, +} + +static GATHERING: LazyLock>> = + LazyLock::new(|| Mutex::new(HashMap::new())); + +/// Record a failed sign-in of somebody who came with no credential — a +/// refused password, a refused return from the identity provider — and gather +/// the ones that follow from the same network ([`crate::auth::ip::bucket`]) +/// in the next minute into that one row, which says how many there were. +/// +/// What anybody can cause by sending requests has to be bounded by who sends +/// them, not by how many: the sign-in form is reachable with no credential, +/// and each failure was a row, a notification and a line of the journal's +/// CSV, six hundred a minute from one address. The first of a run is +/// recorded at once, as it always was — it is the one somebody is looking +/// for, and a slip of one's own fingers is then a row, as before — and when +/// the minute is over the row is amended to carry the number of the run and +/// what the last of them typed. A minute with only the one stays as it was. +pub async fn record_failure( + state: &AppState, + kind: Failure, + ip: &ClientIp, + target: Option<&str>, + detail: Option<&str>, +) { + gather(state, kind, ip, target, detail, GATHERED_FOR).await; +} + +async fn gather( + state: &AppState, + kind: Failure, + ip: &ClientIp, + target: Option<&str>, + detail: Option<&str>, + window: Duration, +) { + let (noted, key) = note( + &mut GATHERING.lock(), + (network_of(ip.0), kind), + Instant::now(), + window, + GATHERED_MOST, + (target, detail), + ); + + match noted { + Noted::First => { + let row = record_row( + state, + Event { + identity: None, + ip, + action: Action::SignInFailed, + target, + detail, + }, + ) + .await; + if let Some(gathered) = GATHERING.lock().get_mut(&key) { + gathered.row = row; + } + } + Noted::Held => {} + Noted::HeldUntil(wait) => { + let (state, ip) = (state.clone(), *ip); + tokio::spawn(async move { + tokio::time::sleep(wait).await; + let Some(closed) = GATHERING.lock().remove(&key) else { + return; + }; + if closed.held > 0 { + tell(&state, &ip, closed, window).await; + } + }); + } + } +} + +/// Say how many there were of a run that is over: in the row of the first, +/// which stood for one until now; in a row of its own, for the failures after +/// the first, when that row could not be written. +async fn tell(state: &AppState, ip: &ClientIp, run: Gathered, window: Duration) { + let (target, last) = run.last; + // What the last of them typed, or what the provider said of it. + let last = last.or(target.clone()); + let seconds = window.as_secs().max(1); + let after = |last: Option| { + last.map(|last| format!(" · the last: {last}")) + .unwrap_or_default() + }; + + if let Some(row) = &run.row { + let said = clip(&format!( + "{} in all within {seconds} s{}", + run.held.saturating_add(1), + after(last) + )); + match repo::audit::amend_detail(&state.db, row, &said).await { + // Taken by the retention meanwhile: nothing to say it in. + Ok(_) => {} + Err(e) => tracing::warn!(error = %e, "could not amend the audit entry"), + } + return; + } + + let said = format!("{} more within {seconds} s{}", run.held, after(last)); + record( + state, + Event { + identity: None, + ip, + action: Action::SignInFailed, + target: target.as_deref(), + detail: Some(&said), + }, + ) + .await; +} + +/// What a failure is to the trail. +#[derive(Debug, PartialEq, Eq)] +enum Noted { + /// The first of a run: recorded now. + First, + /// One more in a run, to be recorded with the rest. + Held, + /// The first one more of a run: held, and somebody is to wait this long + /// for the window to close and record what has been held. + HeldUntil(Duration), +} + +/// Note a failure under `key` — the network and the kind, or the shared +/// place for every network at once when `most` of them are already being +/// gathered, which a flood of addresses is the only way to reach. Returns +/// what it is, and the key it was noted under. +fn note( + gathering: &mut HashMap<(IpAddr, Failure), Gathered>, + mut key: (IpAddr, Failure), + now: Instant, + window: Duration, + most: usize, + (target, detail): (Option<&str>, Option<&str>), +) -> (Noted, (IpAddr, Failure)) { + if !gathering.contains_key(&key) && gathering.len() >= most { + gathering.retain(|_, g| g.waiting || now.duration_since(g.opened) < window); + if gathering.len() >= most { + key.0 = network_of(None); + } + } + + // An entry somebody waits to record is held to, a moment past its window + // too: replaced, what it held would be lost. + let noted = match gathering.get_mut(&key) { + Some(g) if g.waiting || now.duration_since(g.opened) < window => { + g.held = g.held.saturating_add(1); + g.last = (target.map(str::to_string), detail.map(str::to_string)); + if g.waiting { + Noted::Held + } else { + g.waiting = true; + Noted::HeldUntil(window.saturating_sub(now.duration_since(g.opened))) + } + } + _ => { + gathering.insert( + key, + Gathered { + opened: now, + row: None, + held: 0, + last: (None, None), + waiting: false, + }, + ); + Noted::First + } + }; + (noted, key) +} + /// The most of any one field this trail will hold. /// /// Some of what is recorded comes from whoever is calling — the username of a @@ -537,6 +771,7 @@ pub(crate) fn cell_of(value: Option<&serde_json::Value>) -> String { #[cfg(test)] mod tests { use super::*; + use crate::auth::testing::client_at; #[test] fn every_action_has_a_distinct_name() { @@ -783,6 +1018,243 @@ mod tests { } } + fn at(address: &str) -> (IpAddr, Failure) { + (address.parse().unwrap(), Failure::Password) + } + + #[test] + fn a_run_from_one_network_is_recorded_once_and_held_for_a_row_that_counts() { + let mut gathering = HashMap::new(); + let window = Duration::from_secs(60); + let start = Instant::now(); + let fail = |gathering: &mut _, key, after: u64, target: &str| { + note( + gathering, + key, + start + Duration::from_secs(after), + window, + 8, + (Some(target), None), + ) + }; + + let key = at("192.0.2.1"); + assert_eq!(fail(&mut gathering, key, 0, "a").0, Noted::First); + // The first held is told to be waited for; the rest only counted. + assert_eq!( + fail(&mut gathering, key, 10, "b").0, + Noted::HeldUntil(Duration::from_secs(50)) + ); + assert_eq!(fail(&mut gathering, key, 20, "c").0, Noted::Held); + let held = &gathering[&key]; + assert_eq!(held.held, 2); + assert_eq!(held.last.0.as_deref(), Some("c")); + + // Another kind from the same address, and another network, are runs of + // their own; an IPv6 network is one. + assert_eq!( + fail(&mut gathering, (key.0, Failure::Provider), 20, "x").0, + Noted::First + ); + assert_eq!( + fail(&mut gathering, at("192.0.2.2"), 20, "x").0, + Noted::First + ); + let v6 = |tail: &str| at(&format!("2001:db8:1:2::{tail}")); + let bucket = |(ip, kind): (IpAddr, Failure)| (crate::auth::ip::bucket(ip), kind); + assert_eq!( + fail(&mut gathering, bucket(v6("1")), 20, "x").0, + Noted::First + ); + assert!(matches!( + fail(&mut gathering, bucket(v6("2")), 21, "x").0, + Noted::HeldUntil(_) + )); + + // Once the flush has taken what was held, the window is another. + gathering.remove(&key); + assert_eq!(fail(&mut gathering, key, 70, "d").0, Noted::First); + // A window that closed and was not flushed yet still takes what comes. + assert_eq!( + fail(&mut gathering, key, 80, "e").0, + Noted::HeldUntil(Duration::from_secs(50)) + ); + assert_eq!(fail(&mut gathering, key, 200, "f").0, Noted::Held); + assert_eq!(gathering[&key].held, 2); + } + + /// Past what is gathered at once, the networks beyond are gathered as one. + #[test] + fn a_flood_of_addresses_is_gathered_under_one_key() { + let mut gathering = HashMap::new(); + let window = Duration::from_secs(60); + let now = Instant::now(); + + for n in 0..4 { + let key = at(&format!("192.0.2.{n}")); + let (noted, used) = note(&mut gathering, key, now, window, 4, (None, None)); + assert_eq!((noted, used), (Noted::First, key)); + } + // The fifth address is nobody's in particular, and so is the sixth: + // one run between them. + let (first, used) = note( + &mut gathering, + at("198.51.100.1"), + now, + window, + 4, + (None, None), + ); + assert_eq!(first, Noted::First); + assert_eq!(used, at("0.0.0.0")); + let (second, used) = note( + &mut gathering, + at("198.51.100.2"), + now, + window, + 4, + (None, None), + ); + assert!(matches!(second, Noted::HeldUntil(_))); + assert_eq!(used, at("0.0.0.0")); + assert_eq!(gathering.len(), 5); + + // Windows that closed make room again. + let later = now + window; + let (noted, used) = note( + &mut gathering, + at("198.51.100.3"), + later, + window, + 4, + (None, None), + ); + assert_eq!((noted, used), (Noted::First, at("198.51.100.3"))); + } + + /// The failed sign-ins of the trail, the oldest first. + async fn failed_sign_ins(state: &AppState) -> Vec { + let mut rows = repo::audit::list( + &state.db, + &repo::audit::Query { + action: Some(Action::SignInFailed.as_str().into()), + limit: 100, + ..Default::default() + }, + ) + .await + .unwrap(); + rows.reverse(); + rows + } + + /// What the sign-in form is flooded with is one row, written at once, and + /// said to be many when the minute is over — whatever the flood. + #[tokio::test] + async fn a_flood_of_failed_sign_ins_is_one_row_that_carries_the_count() { + let state = crate::api::native::testing::server().await; + let window = Duration::from_millis(300); + let ip = client_at(&state, "192.0.2.150").await; + + for n in 0..8 { + let typed = format!("guess-{n}"); + gather(&state, Failure::Password, &ip, Some(&typed), None, window).await; + } + let at_once = failed_sign_ins(&state).await; + assert_eq!(at_once.len(), 1); + assert_eq!(at_once[0].target.as_deref(), Some("guess-0")); + assert_eq!(at_once[0].ip.as_deref(), Some("192.0.2.150")); + assert_eq!(at_once[0].detail, None); + + // The window closes, and the one row says how many it stood for. + let mut rows = at_once; + for _ in 0..200 { + if rows[0].detail.is_some() { + break; + } + tokio::time::sleep(Duration::from_millis(50)).await; + rows = failed_sign_ins(&state).await; + } + assert_eq!(rows.len(), 1, "{rows:?}"); + assert_eq!(rows[0].target.as_deref(), Some("guess-0")); + let said = rows[0].detail.as_deref().unwrap_or_default(); + assert_eq!(said, "8 in all within 1 s · the last: guess-7"); + + // A failure after that is the first of another run, recorded at once. + gather(&state, Failure::Password, &ip, Some("again"), None, window).await; + assert_eq!(failed_sign_ins(&state).await.len(), 2); + } + + /// A run of one stays as it was: a row, and nothing said of it later. + #[tokio::test] + async fn a_single_failure_is_one_row_as_it_always_was() { + let state = crate::api::native::testing::server().await; + let window = Duration::from_millis(100); + let ip = client_at(&state, "192.0.2.170").await; + + gather(&state, Failure::Password, &ip, Some("slip"), None, window).await; + tokio::time::sleep(Duration::from_millis(400)).await; + + let rows = failed_sign_ins(&state).await; + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].target.as_deref(), Some("slip")); + assert_eq!(rows[0].detail, None); + } + + /// Where the first row could not be written there is none to amend: what + /// followed it is told in a row of its own. + #[tokio::test] + async fn a_run_whose_first_row_is_missing_is_told_in_a_row_of_its_own() { + let state = crate::api::native::testing::server().await; + let ip = client_at(&state, "192.0.2.180").await; + let run = Gathered { + opened: Instant::now(), + row: None, + held: 3, + last: (Some("guess-3".into()), None), + waiting: true, + }; + + tell(&state, &ip, run, Duration::from_secs(60)).await; + + let rows = failed_sign_ins(&state).await; + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].target.as_deref(), Some("guess-3")); + assert_eq!( + rows[0].detail.as_deref(), + Some("3 more within 60 s · the last: guess-3") + ); + } + + /// One address's failures do not hold another's back. + #[tokio::test] + async fn each_network_and_kind_is_recorded_at_its_own_first() { + let state = crate::api::native::testing::server().await; + let window = Duration::from_secs(60); + let one = client_at(&state, "192.0.2.160").await; + let other = client_at(&state, "192.0.2.161").await; + + gather(&state, Failure::Password, &one, Some("a"), None, window).await; + gather(&state, Failure::Password, &other, Some("b"), None, window).await; + gather( + &state, + Failure::Provider, + &one, + None, + Some("expired"), + window, + ) + .await; + gather(&state, Failure::Password, &one, Some("c"), None, window).await; + + let rows = failed_sign_ins(&state).await; + assert_eq!( + rows.iter().map(|r| r.target.as_deref()).collect::>(), + [Some("a"), Some("b"), None], + "the fourth is held for the row that counts" + ); + } + #[test] fn action_names_are_namespaced() { // A log shipper filters on the prefix, so every name must carry one. diff --git a/src/api/mod.rs b/src/api/mod.rs index 95ec515..4789f41 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -26,7 +26,7 @@ pub mod tmdb_images; use axum::{ Router, extract::Request, - http::StatusCode, + http::{HeaderValue, StatusCode, header}, middleware::from_fn_with_state, response::{IntoResponse, Response}, }; @@ -36,7 +36,29 @@ use utoipa::{ }; use utoipa_axum::router::OpenApiRouter; -use crate::{auth::middleware as guards, state::AppState}; +use crate::{auth::middleware as guards, error::AppError, state::AppState}; + +/// Seconds a caller turned away from work that takes a place +/// ([`crate::cache::Gate`]) is told to wait: past those it waited for one, +/// and long enough for what held them to finish. +const BUSY_RETRY: u64 = 5; + +/// What a caller is answered when every place for a kind of work stayed taken +/// as long as it waits — a read no cache answered, a picture handed on from +/// TMDB: a 503, in the API's own shape, and when to come back. +pub(crate) fn busy() -> Response { + let mut response = AppError::Disabled { + code: "busy", + message: "this server is answering as many such requests as it can; try again in a few \ + seconds" + .into(), + } + .into_response(); + response + .headers_mut() + .insert(header::RETRY_AFTER, HeaderValue::from(BUSY_RETRY)); + response +} /// Spec metadata. Paths and schemas are collected from the handlers themselves, /// so this cannot drift from what the server actually serves. @@ -284,10 +306,16 @@ fn tmdb_surface(state: AppState, tmdb: Router) -> Router { /// key policy read as the address list — a picture's address, built by the /// client from TMDB's configuration, carries no key. Held to the limit of /// requests an address may make only where its guard turns the caller -/// away: a library's scan asks for pictures in bursts no relay's caller -/// makes ([`guards::guard_tmdb_images`]). +/// away — a library's scan asks for pictures in bursts no relay's caller +/// makes ([`guards::guard_tmdb_images`]) — and, where it lets the caller in, +/// to a quota of pictures ten times as high ([`tmdb_images::Pace`]). fn tmdb_images_surface(state: AppState, images: Router) -> Router { images + // Inside the guard, which has let the caller in and left it uncounted. + .layer(from_fn_with_state( + tmdb_images::Pace::new(state.clone()), + tmdb_images::pace, + )) .layer(from_fn_with_state(state.clone(), guards::guard_tmdb_images)) .layer(from_fn_with_state(state, guards::reject_self_calls)) } @@ -989,6 +1017,21 @@ mod tests { ); } + /// Work turned away for want of a place is told so in the API's shape, + /// as a 503, with when to come back. + #[tokio::test] + async fn a_caller_turned_away_from_a_place_is_told_when_to_come_back() { + let response = busy(); + assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE); + assert_eq!(response.headers()[header::RETRY_AFTER], "5"); + let body = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(); + let said: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert_eq!(said["error"], "busy"); + assert!(said["message"].as_str().is_some_and(|m| !m.is_empty())); + } + /// The interface's types are generated from a copy of the spec kept in /// `frontend/openapi.json` (`npm run types` there writes /// `src/lib/api.gen.ts`), so a field added here reaches the pages' types diff --git a/src/api/native/account.rs b/src/api/native/account.rs index 57d5516..9e15ebd 100644 --- a/src/api/native/account.rs +++ b/src/api/native/account.rs @@ -339,7 +339,7 @@ fn clean_key_name(raw: &str) -> AppResult { responses( (status = 201, body = IssuedKey), (status = 400, description = "A field was rejected"), - (status = 403, description = "The key limit is reached, or a key asked"), + (status = 403, description = "The key limit is reached, a key asked, or an administrator paused your keys"), (status = 409, description = "You already have a key by that name"), ), )] @@ -357,6 +357,10 @@ async fn create_key( // would both find room for one more. let _held = state.accounts_lock().await; + if clients::paused_by_admin(&state, &me.id).await?.is_some() { + return Err(keys_paused()); + } + if let Some(limit) = limit_for(&state, me) && repo::client::count_owned(&state.db, &me.id).await? >= limit { @@ -412,6 +416,18 @@ async fn create_key( )) } +/// What a person is told while an administrator's pause on their keys stands: +/// the pause is on them, not on one key, so that deleting a paused key and +/// making another is no way round it. +fn keys_paused() -> AppError { + AppError::Refused { + code: "keys_paused_by_admin", + message: "an administrator paused your keys: until one lifts that, you can neither make a \ + key nor resume one" + .into(), + } +} + /// One of your keys, or nobody's business. async fn own_key(state: &AppState, me: &User, id: &str) -> AppResult { repo::client::get(&state.db, id) @@ -437,6 +453,7 @@ pub struct KeyUpdate { responses( (status = 200, body = ApiClient), (status = 400, description = "A field was rejected"), + (status = 403, description = "An administrator paused your keys, and only one can resume them"), (status = 404, description = "Not one of your keys"), (status = 409, description = "You already have a key by that name"), ), @@ -470,17 +487,14 @@ async fn update_key( ))); } - // Paused by an administrator, it stays paused until one resumes it: a - // member whose key was stopped for hammering the relays does not get to - // start it again. + // Paused by an administrator, the keys stay paused until one resumes + // them: a member whose key was stopped for hammering the relays does not + // get to start it again, nor another of theirs. if update.is_enabled == Some(true) && !key.is_enabled - && clients::paused_by_admin(&state, &id).await? + && clients::paused_by_admin(&state, &me.id).await?.is_some() { - return Err(AppError::Refused { - code: "key_paused_by_admin", - message: "an administrator paused this key; only an administrator can resume it".into(), - }); + return Err(keys_paused()); } if let Some(name) = &name { @@ -825,6 +839,124 @@ mod tests { } } + async fn opened(state: &AppState, name: &str, role: Role) -> User { + repo::user::create( + &state.db, + repo::user::NewUser { + username: name, + password_hash: repo::user::NO_PASSWORD, + role, + status: Status::Active, + display_name: None, + email: None, + invited_by: None, + oidc: None, + }, + ) + .await + .expect("an account") + } + + /// Whether an answer is the refusal a pause tells. + fn paused(answer: Result) -> bool { + matches!( + answer, + Err(AppError::Refused { + code: "keys_paused_by_admin", + .. + }) + ) + } + + /// A member whose key an administrator stopped could delete it and make + /// another, and the pause was gone with the key. It stands on the person: + /// until an administrator lifts it they make no key and resume none, and + /// deleting the one that was paused is no way round it. + #[tokio::test] + async fn an_administrators_pause_stands_on_the_person_and_outlives_the_key() { + use crate::settings::Scope; + + let state = crate::api::native::testing::server().await; + state + .settings + .set(Scope::Server, "", "keys.maxPerUser", "5", None) + .await + .expect("room for several keys"); + let admin = opened(&state, "root", Role::Admin).await; + let alice = opened(&state, "alice", Role::Member).await; + let as_alice = || Extension(Identity::User(Box::new(alice.clone()))); + let as_admin = || Extension(Identity::User(Box::new(admin.clone()))); + + let make = |name: &'static str| { + create_key( + State(state.clone()), + as_alice(), + ClientIp::NONE, + Json(NewKey { + name: name.into(), + scopes: vec![], + }), + ) + }; + let toggle = |id: &str, enabled: bool| { + update_key( + State(state.clone()), + as_alice(), + ClientIp::NONE, + Path(id.to_string()), + Json(KeyUpdate { + name: None, + is_enabled: Some(enabled), + scopes: None, + }), + ) + }; + let from_admin = |id: &str, enabled: bool| { + clients::update( + State(state.clone()), + as_admin(), + ClientIp::NONE, + Path(id.to_string()), + Json(clients::UpdateRequest { + is_enabled: Some(enabled), + }), + ) + }; + + let (_, Json(sonarr)) = make("Sonarr").await.expect("a key"); + let (_, Json(radarr)) = make("Radarr").await.expect("another"); + + // Hers to pause and resume, while nobody else has said anything. + let _ = toggle(&radarr.key.id, false).await.expect("paused by her"); + let _ = toggle(&radarr.key.id, true).await.expect("resumed by her"); + + // An administrator stops the first, which is hammering the relays. + let _ = from_admin(&sonarr.key.id, false).await.expect("paused"); + assert!(paused(toggle(&sonarr.key.id, true).await)); + // Nor does she resume another she pauses herself, or make a third. + let _ = toggle(&radarr.key.id, false).await.expect("paused by her"); + assert!(paused(toggle(&radarr.key.id, true).await)); + assert!(paused(make("Third").await)); + + // Deleting the key that was paused is no way round it. + delete_key( + State(state.clone()), + as_alice(), + ClientIp::NONE, + Path(sonarr.key.id.clone()), + ) + .await + .expect("deleted"); + assert!(paused(make("Sonarr again").await)); + assert!(paused(toggle(&radarr.key.id, true).await)); + + // The administrator resumes the key she has left, and the pause lifts. + let _ = from_admin(&radarr.key.id, true).await.expect("resumed"); + let _ = make("Sonarr again").await.expect("made again"); + let _ = toggle(&radarr.key.id, false).await.expect("hers again"); + let _ = toggle(&radarr.key.id, true).await.expect("hers again"); + } + #[tokio::test] async fn a_person_keeps_their_preferences_and_no_key_can() { let state = crate::api::native::testing::server().await; diff --git a/src/api/native/auth.rs b/src/api/native/auth.rs index de6a1ad..36254aa 100644 --- a/src/api/native/auth.rs +++ b/src/api/native/auth.rs @@ -20,10 +20,11 @@ use crate::{ auth::{ Identity, ip, middleware::{self, SESSION_COOKIE}, + ratelimit::{self, Reserved}, secrets, }, db::repo::{self, audit::Action}, - error::{AppError, AppResult}, + error::{AppError, AppResult, Throttle}, state::AppState, }; @@ -104,18 +105,12 @@ async fn login( return Err(AppError::Unauthorized); } - // An account that has failed too often lately is not checked at all, from - // wherever the attempt comes: guessing one person's password from many - // addresses runs out as surely as from one. Asked before anything is - // hashed, so a refused attempt costs nothing. Told when the account's - // window closes: the failures are no quota of requests, so that alone. - if let Err(wait) = state.limiter.sign_in_allowed(&request.username).await { - tracing::warn!( - username = ?request.username, - "sign-in refused: too many failed attempts on this account lately" - ); - return Err(AppError::RateLimited(crate::error::Throttle::after(wait))); - } + // The attempt is counted before anything is looked up or hashed, and + // refused there when the account has taken as many from this address as + // it takes lately — or, from addresses it has not been signed in from, + // as many from everywhere. Told when the window closes: the failures + // are no quota of requests, so that alone. + let attempt = begin_attempt(&state, &request.username, &ip, None).await?; // Passwords switched off: the identity provider signs people in, and only // the account the environment names keeps a door — the way back in when @@ -129,8 +124,13 @@ async fn login( message: "this server signs people in through its identity provider".into(), }; if passwords_off && !state.is_break_glass_name(&request.username) { - secrets::verify_password_async(request.password.clone(), DUMMY_HASH.to_string()).await?; - state.limiter.sign_in_failed(&request.username).await; + check_password( + &state, + attempt, + request.password.clone(), + DUMMY_HASH.to_string(), + ) + .await?; return Err(refused()); } @@ -144,40 +144,53 @@ async fn login( // An account an identity provider made has no password: checked // against the dummy all the same, so it takes as long as any other. Some(creds) if creds.password_hash == repo::user::NO_PASSWORD => { - secrets::verify_password_async(request.password.clone(), DUMMY_HASH.to_string()) - .await?; + check_password( + &state, + attempt, + request.password.clone(), + DUMMY_HASH.to_string(), + ) + .await?; (Some(creds.user), false) } Some(creds) => { - let ok = secrets::verify_password_async(request.password.clone(), creds.password_hash) - .await?; + let ok = check_password( + &state, + attempt, + request.password.clone(), + creds.password_hash, + ) + .await?; (Some(creds.user), ok) } None => { - secrets::verify_password_async(request.password.clone(), DUMMY_HASH.to_string()) - .await?; + check_password( + &state, + attempt, + request.password.clone(), + DUMMY_HASH.to_string(), + ) + .await?; (None, false) } }; let (Some(user), true) = (user, ok) else { - state.limiter.sign_in_failed(&request.username).await; - // Debug-formatted: quoted, its line breaks and control characters - // escaped, so a name typed into the form cannot write a line of its - // own into the log. + // Counted already, before the password was looked at. Debug-formatted: + // quoted, its line breaks and control characters escaped, so a name + // typed into the form cannot write a line of its own into the log. tracing::warn!(username = ?request.username, "failed sign-in attempt"); // Recorded deliberately: a run of these is the one thing in this log - // worth alerting on. The attempted username is kept; the password is not. - audit::record( + // worth alerting on. The attempted username is kept; the password is + // not. A run from one network is one row at once and one that counts + // the rest — anybody can send these with no credential at all. + audit::record_failure( &state, - Event { - identity: None, - ip: &ip, - action: Action::SignInFailed, - target: Some(request.username.trim()), - detail: None, - }, + audit::Failure::Password, + &ip, + Some(request.username.trim()), + None, ) .await; @@ -188,7 +201,7 @@ async fn login( }); }; - state.limiter.sign_in_succeeded(&request.username).await; + state.limiter.sign_in_succeeded(attempt).await; if passwords_off && !(user.role == repo::user::Role::Admin && user.status == repo::user::Status::Active) @@ -220,6 +233,97 @@ async fn login( Ok((StatusCode::OK, [(header::SET_COOKIE, cookie)], Json(answer))) } +/// Count an attempt at `username`'s password from this caller's network +/// before the password is looked at, and refuse it when it is not to be. +/// Refused: when the network has made as many attempts on the account as a +/// window allows, whatever it sends; and when the account has taken more +/// than its bar from every network together and this one has not been signed +/// in to it lately ([`ratelimit::address_known`]), so that guessing from +/// many addresses runs out while the account's owner, from theirs, still +/// gets in. A refused attempt costs nothing: no password is hashed for it. +/// +/// `user` is the account's id when it is known; otherwise the account is +/// looked up by its name, and only once the bar is passed. +pub(crate) async fn begin_attempt( + state: &AppState, + username: &str, + ip: &ClientIp, + user: Option<&str>, +) -> AppResult { + let network = ratelimit::network_of(ip.0); + let reserved = match state.limiter.sign_in_begin(username, network).await { + Ok(reserved) => reserved, + Err(wait) => { + tracing::warn!( + username = ?username, + "sign-in refused: too many failed attempts on this account from this address lately" + ); + return Err(AppError::RateLimited(Throttle::after(wait))); + } + }; + + let Some(wait) = reserved.over_account_bar() else { + return Ok(reserved); + }; + let known = match user { + Some(id) => ratelimit::address_known(&state.db, id, network).await, + None => match repo::user::find_by_username(&state.db, username.trim()).await? { + Some(found) => ratelimit::address_known(&state.db, &found.user.id, network).await, + None => false, + }, + }; + if known { + return Ok(reserved); + } + + state.limiter.sign_in_given_back(reserved).await; + tracing::warn!( + username = ?username, + "sign-in refused: too many failed attempts on this account lately, from addresses it has \ + not been signed in from" + ); + Err(AppError::RateLimited(Throttle::after(wait))) +} + +/// A password checked within an attempt counted by [`begin_attempt`]: whether +/// it is right. When every hashing slot stayed taken nothing was checked, and +/// the attempt is given back; the caller is told to come back. +pub(crate) async fn check_password( + state: &AppState, + attempt: Reserved, + password: String, + hash: String, +) -> AppResult { + match secrets::verify_password_async(password, hash).await { + Ok(ok) => Ok(ok), + Err(busy @ secrets::HashError::Busy) => { + state.limiter.sign_in_given_back(attempt).await; + Err(busy.into()) + } + Err(other) => Err(other.into()), + } +} + +/// A signed-in person's password, asked for again as proof that it is them +/// at the keyboard and not whoever holds a session left open: counted and +/// checked as a sign-in is, so that guessing it from a session that is not +/// theirs runs out as guessing it at the form does. A wrong one is +/// `Unauthorized`. +pub(crate) async fn prove_password( + state: &AppState, + user: &repo::user::User, + hash: String, + typed: &str, + ip: &ClientIp, +) -> AppResult<()> { + let attempt = begin_attempt(state, &user.username, ip, Some(&user.id)).await?; + if !check_password(state, attempt, typed.to_string(), hash).await? { + return Err(AppError::Unauthorized); + } + state.limiter.sign_in_succeeded(attempt).await; + Ok(()) +} + /// Open a session for someone who has shown who they are — by their password, /// by signing up, or through the identity provider — and say so in the /// journal. Returns the cookie to set and what the interface is told; @@ -247,6 +351,11 @@ pub(crate) async fn open_session( repo::user::mark_login(&state.db, &user.id).await?; + // However they came in: the address a person signs in from is one the + // account's own, which what is tried against it from elsewhere does not + // shut out ([`begin_attempt`]). + ratelimit::remember_address(&state.db, &user.id, ratelimit::network_of(ip.0)).await; + let identity = Identity::User(Box::new(user.clone())); audit::record( state, @@ -454,11 +563,16 @@ async fn change_password( return Err(AppError::BadRequest("that password is too long".into())); } - if !secrets::verify_password_async(request.current_password.clone(), creds.password_hash) - .await? - { - return Err(AppError::Unauthorized); - } + // Asked as a sign-in is: a session is not the password, and guessing the + // password through it is counted as guessing it at the form. + prove_password( + &state, + user, + creds.password_hash, + &request.current_password, + &ip, + ) + .await?; let hash = secrets::hash_password_async(request.new_password.clone()).await?; @@ -485,3 +599,161 @@ async fn change_password( Ok(StatusCode::NO_CONTENT) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::{ + auth::testing::{client_at, server}, + db::repo::user::{NewUser, Role, Status, User}, + }; + + /// A password, drawn at random: the tests need none in particular, and one + /// written in the code reads as a secret kept there to whoever scans it. + fn password() -> String { + secrets::generate_session_token().unwrap().0 + } + + async fn account(state: &AppState, name: &str, password: &str) -> User { + repo::user::create( + &state.db, + NewUser { + username: name, + password_hash: &secrets::hash_password(password).unwrap(), + role: Role::Member, + status: Status::Active, + display_name: None, + email: None, + invited_by: None, + oidc: None, + }, + ) + .await + .expect("an account") + } + + /// The sign-in form, filled in from an address. + async fn sign_in( + state: &AppState, + from: &str, + username: &str, + password: &str, + ) -> Result { + let ip = client_at(state, from).await; + login( + State(state.clone()), + ip, + None, + HeaderMap::new(), + Json(LoginRequest { + username: username.into(), + password: password.into(), + }), + ) + .await + .map(|answer| answer.into_response().status()) + } + + /// What the lock-out is for is that a burst is held to its limit. Every + /// password of a burst was checked, once, when the count was made after + /// the check: all of them found it at nothing, and went on to be checked. + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] + async fn a_burst_of_wrong_passwords_is_checked_no_more_than_the_limit_allows() { + let state = server(3).await; + account(&state, "alice", &password()).await; + let wrong = password(); + + let attempts: Vec<_> = (0..12) + .map(|_| { + let (state, wrong) = (state.clone(), wrong.clone()); + tokio::spawn(async move { sign_in(&state, "198.51.100.9", "alice", &wrong).await }) + }) + .collect(); + let (mut checked, mut turned_away) = (0, 0); + for attempt in attempts { + match attempt.await.unwrap() { + Err(AppError::Unauthorized) => checked += 1, + Err(AppError::RateLimited(_)) => turned_away += 1, + other => panic!("{other:?}"), + } + } + assert_eq!((checked, turned_away), (3, 9)); + } + + /// Somebody guessing at an account from their own addresses shuts those + /// addresses out, and — past a bar for the account as a whole — every + /// address that has not signed in to it. Its owner's usual address is not + /// one of them: with the right password it gets in, and the guessing + /// that was turned away cost nothing to check. + #[tokio::test] + async fn guessing_at_an_account_shuts_out_the_guessers_and_not_its_owner() { + let state = server(1).await; + let right = password(); + account(&state, "alice", &right).await; + let (home, away) = ("203.0.113.7", "203.0.113.99"); + + // Their usual address, signed in from once: known to the account. + assert_eq!( + sign_in(&state, home, "alice", &right).await.unwrap(), + StatusCode::OK + ); + + // Five addresses, each its one failure — the account's bar is five + // times an address's — and the sixth is turned away unchecked. + let wrong = password(); + for n in 1..=5 { + let from = format!("198.51.100.{n}"); + assert!(matches!( + sign_in(&state, &from, "alice", &wrong).await, + Err(AppError::Unauthorized) + )); + } + assert!(matches!( + sign_in(&state, "198.51.100.6", "alice", &wrong).await, + Err(AppError::RateLimited(_)) + )); + // One address, again: its own count is spent. + assert!(matches!( + sign_in(&state, "198.51.100.1", "alice", &wrong).await, + Err(AppError::RateLimited(_)) + )); + + // The owner, from where they have signed in, gets through; from + // somewhere they have not, they wait with the rest. + assert_eq!( + sign_in(&state, home, "alice", &right).await.unwrap(), + StatusCode::OK + ); + assert!(matches!( + sign_in(&state, away, "alice", &right).await, + Err(AppError::RateLimited(_)) + )); + } + + /// A session is not the password: asking for it again is counted, so that + /// guessing it through a session that is not its owner's runs out as at + /// the form. + #[tokio::test] + async fn the_current_password_asked_for_again_is_counted_as_at_the_form() { + let state = server(1).await; + let right = password(); + let alice = account(&state, "alice", &right).await; + let hash = repo::user::credentials(&state.db, &alice.id) + .await + .unwrap() + .unwrap() + .password_hash; + let ip = client_at(&state, "198.51.100.9").await; + + let wrong = password(); + assert!(matches!( + prove_password(&state, &alice, hash.clone(), &wrong, &ip).await, + Err(AppError::Unauthorized) + )); + // Spent: not even the right one is looked at now. + assert!(matches!( + prove_password(&state, &alice, hash, &right, &ip).await, + Err(AppError::RateLimited(_)) + )); + } +} diff --git a/src/api/native/backup.rs b/src/api/native/backup.rs index 5f159e7..a648cfe 100644 --- a/src/api/native/backup.rs +++ b/src/api/native/backup.rs @@ -185,6 +185,7 @@ async fn create( async fn download( State(state): State, Extension(identity): Extension, + ip: ClientIp, Path(name): Path, ) -> AppResult { identity.require_admin()?; @@ -194,6 +195,21 @@ async fn download( .await? .ok_or(AppError::NotFound)?; + // Written down as the archive starts to leave: it holds every account's + // password hash, and who took one is what the trail is read for after a + // leak. A name that is no archive's, or no longer kept, is not an entry. + audit::record( + &state, + Event { + identity: Some(&identity), + ip: &ip, + action: Action::BackupDownloaded, + target: Some(&name), + detail: None, + }, + ) + .await; + let mut response = Body::from_stream(found.stream).into_response(); let headers = response.headers_mut(); // Plain zstd says what it is; an encrypted one is bytes to whoever has @@ -340,6 +356,7 @@ mod tests { download( State(state.clone()), Extension(identity.clone()), + ClientIp::NONE, Path("ams-20261019-020000-manual.tar.zst".into()) ) .await, @@ -371,9 +388,14 @@ mod tests { assert_eq!(archive.size_bytes, made.size); assert!(!listed.overdue); - let response = download(State(state.clone()), admin(), Path(made.name.clone())) - .await - .unwrap(); + let response = download( + State(state.clone()), + admin(), + ClientIp::NONE, + Path(made.name.clone()), + ) + .await + .unwrap(); assert_eq!(response.status(), StatusCode::OK); assert_eq!( response.headers()[header::ETAG], @@ -394,11 +416,33 @@ mod tests { // A name that is not an archive's names nothing, wherever it points. for name in ["../ams.db", "ams.db", "ams-20261019-020000-manual.tar.zst"] { assert!(matches!( - download(State(state.clone()), admin(), Path(name.into())).await, + download( + State(state.clone()), + admin(), + ClientIp::NONE, + Path(name.into()) + ) + .await, Err(AppError::NotFound) )); } + // Written down: which archive was taken, and by whom — once, for the + // download that happened, none for the names that named nothing. + let trail = repo::audit::list( + &state.db, + &repo::audit::Query { + action: Some("backup.downloaded".into()), + limit: 5, + ..Default::default() + }, + ) + .await + .unwrap(); + assert_eq!(trail.len(), 1); + assert_eq!(trail[0].target.as_deref(), Some(made.name.as_str())); + assert!(trail[0].actor.is_some()); + let gone = remove( State(state.clone()), admin(), diff --git a/src/api/native/browse.rs b/src/api/native/browse.rs index e6f36d0..e15ff68 100644 --- a/src/api/native/browse.rs +++ b/src/api/native/browse.rs @@ -9,6 +9,7 @@ use std::collections::{BTreeMap, HashMap}; use axum::{ Extension, Json, extract::{Path, Query, State}, + response::{IntoResponse, Response}, }; use serde::{Deserialize, Serialize}; use utoipa::{IntoParams, ToSchema}; @@ -86,13 +87,14 @@ pub struct CatalogueFacets { responses( (status = 200, body = CatalogueFacets), (status = 400, description = "A filter was not one the list understands"), + (status = 503, description = "As many such reads as the server answers at once are under way; `Retry-After` says when to ask again"), ), )] async fn facets( State(state): State, Extension(identity): Extension, Query(query): Query, -) -> AppResult> { +) -> AppResult { let mut filters = super::items::to_query(&state, &identity, query)?; // The page and its order are the list's; the counts are the same for all. filters.sort = repo::item::Sort::default(); @@ -108,13 +110,22 @@ async fn facets( let key = format!( "facets:{filters:?}:{}:{}", state.caches.stamp(), - repo::item::catalogue_stamp(&state.db).await? + state.caches.catalogue_stamp(&state.db).await? ); - if let Some(cached) = state.caches.searches.get(&key).await - && let Ok(facets) = serde_json::from_str::(&cached) - { - return Ok(Json(facets)); + if let Some(kept) = kept_facets(&state, &key).await { + return Ok(Json(kept).into_response()); + } + + // Counted over every work the filters leave, for filters nobody has asked + // about yet: a few at a time, so that asking for many of them is no way to + // take the database from everybody else. + let Some(_place) = state.caches.cold.take().await else { + return Ok(crate::api::busy()); + }; + // Asked and answered meanwhile, perhaps, by a caller that waited too. + if let Some(kept) = kept_facets(&state, &key).await { + return Ok(Json(kept).into_response()); } // The works a misspelt term is taken for, as the list takes it: settled @@ -132,7 +143,13 @@ async fn facets( state.caches.searches.insert(key, encoded).await; } - Ok(Json(found)) + Ok(Json(found).into_response()) +} + +/// The facets kept under a key, when they are. +async fn kept_facets(state: &AppState, key: &str) -> Option { + let cached = state.caches.searches.get(key).await?; + serde_json::from_str(&cached).ok() } // ─── calendar ──────────────────────────────────────────────────────────────── @@ -735,33 +752,54 @@ async fn network( } /// The name of the network or the studio a slug stands for, as the works -/// name it most often, or none: kept a while under the catalogue's mark, -/// since every visit to the page asks. +/// name it most often, or none. +/// +/// Every visit to the page asks, and the slug is whatever the caller wrote: +/// so it is matched in memory against the names the catalogue holds, which +/// are kept — for each reading of adult titles and each mark of the +/// catalogue — in one place, and never becomes the key of anything. pub(crate) async fn network_named( state: &AppState, slug: &str, adult: bool, ) -> AppResult> { + Ok(network_names(state, adult).await?.get(slug).cloned()) +} + +/// Each slug the networks' and studios' names make, and the name the works +/// give it most often: of two spellings as common, the first in order. +async fn network_names( + state: &AppState, + adult: bool, +) -> AppResult>> { let key = format!( - "network-name:{slug}:{adult}:{}", - repo::item::catalogue_stamp(&state.db).await? + "{adult}:{}:{}", + state.caches.stamp(), + state.caches.catalogue_stamp(&state.db).await? ); - if let Some(cached) = state.caches.searches.get(&key).await { - return Ok(Some(cached).filter(|name| !name.is_empty())); - } - let named = repo::item::network_names(&state.db, adult) - .await? - .into_iter() - .filter(|(name, _)| slug::slugify(name) == slug) - // The most common spelling, and of two as common the first in order. - .max_by(|a, b| a.1.cmp(&b.1).then_with(|| b.0.cmp(&a.0))) - .map(|(name, _)| name); state .caches - .searches - .insert(key, named.clone().unwrap_or_default()) - .await; - Ok(named) + .network_names + .try_get_with(key, async { + let mut best: HashMap = HashMap::new(); + for (name, count) in repo::item::network_names(&state.db, adult).await? { + let slug = slug::slugify(&name); + let better = match best.get(&slug) { + None => true, + Some((held, kept)) => count > *held || (count == *held && name < *kept), + }; + if better { + best.insert(slug, (count, name)); + } + } + Ok::<_, anyhow::Error>(std::sync::Arc::new( + best.into_iter() + .map(|(slug, (_, name))| (slug, name)) + .collect(), + )) + }) + .await + .map_err(|e| AppError::Internal(anyhow::anyhow!("{e:#}"))) } /// The longest id an entity's address is looked up by: this server's ids @@ -1799,6 +1837,97 @@ mod tests { )); } + /// A slug is whatever the caller wrote: it is matched in memory, against + /// the names the catalogue holds, and one nobody holds costs no read and + /// is never the key of anything kept. + #[tokio::test] + async fn a_network_slug_nobody_holds_is_never_the_key_of_anything() { + let state = super::super::testing::server().await; + for (title, network, adult) in [ + ("On Canal", "Canal+", false), + ("After Dark", "Night Channel", true), + ] { + stored(&state, MediaKind::Series, title, |w| { + w.network = Some(network.into()); + w.is_adult = adult; + }) + .await; + } + crate::service::listing::relist_stale(&state.db, false, 100) + .await + .expect("listed"); + + let named = async |slug: &str, adult: bool| { + network_named(&state, slug, adult).await.expect("answered") + }; + assert_eq!(named("canal", false).await.as_deref(), Some("Canal+")); + // A title for adults only where adults are counted. + assert_eq!(named("night-channel", false).await, None); + assert_eq!( + named("night-channel", true).await.as_deref(), + Some("Night Channel") + ); + + state.caches.searches.settle().await; + let kept = state.caches.searches.l1_entries(); + let readings = state.caches.network_names.entries().await; + assert_eq!(readings, 2, "one for each reading of adult titles"); + for n in 0..200 { + assert_eq!(named(&format!("nobody-{n}"), false).await, None); + } + state.caches.searches.settle().await; + assert_eq!(state.caches.searches.l1_entries(), kept); + assert_eq!(state.caches.network_names.entries().await, readings); + } + + /// The facets of filters nobody asked about are counted over every work + /// they leave, a few at a time: with every place taken, the caller waits, + /// and is turned away — told when to come back — when none is free. Those + /// kept are answered whatever. + #[tokio::test] + async fn facets_nobody_asked_for_wait_for_a_place_and_are_turned_away_without_one() { + use axum::http::{StatusCode, Uri, header}; + + let state = super::super::testing::server().await; + stored(&state, MediaKind::Movie, "Heat", |w| { + w.genres = vec!["Crime".into()]; + }) + .await; + crate::service::listing::relist_stale(&state.db, false, 100) + .await + .expect("listed"); + state + .caches + .cold + .wait_at_most(std::time::Duration::from_millis(30)); + + let counted = async |filters: &str| { + let uri: Uri = format!("/facets?{filters}").parse().expect("an address"); + let Query(query) = + Query::::try_from_uri(&uri).expect("a query"); + facets( + State(state.clone()), + Extension(Identity::Visitor), + Query(query), + ) + .await + .expect("answered") + }; + assert_eq!(counted("genre=Crime").await.status(), StatusCode::OK); + + let mut held = Vec::new(); + while state.caches.cold.free() > 0 { + held.push(state.caches.cold.take().await.expect("a place")); + } + assert_eq!(counted("genre=Crime").await.status(), StatusCode::OK); + let turned_away = counted("genre=Drama").await; + assert_eq!(turned_away.status(), StatusCode::SERVICE_UNAVAILABLE); + assert!(turned_away.headers().get(header::RETRY_AFTER).is_some()); + + held.pop(); + assert_eq!(counted("genre=Drama").await.status(), StatusCode::OK); + } + #[tokio::test] async fn a_company_and_a_keyword_are_pages_by_this_servers_id_for_them() { use crate::domain::{Company, CompanyCredit, CompanyKind, CompanyRole, Keyword}; diff --git a/src/api/native/clients.rs b/src/api/native/clients.rs index 4aee8e0..ba3dc31 100644 --- a/src/api/native/clients.rs +++ b/src/api/native/clients.rs @@ -224,6 +224,11 @@ pub struct UpdateRequest { } /// Enable or disable a key without revoking it. +/// +/// A person's key paused from here pauses its owner's keys: its owner can +/// neither resume it nor make another, or resume any other of theirs, until an +/// administrator enables a key of theirs from here, or lifts the pause +/// (`DELETE /users/{id}/keys-pause`). #[utoipa::path( patch, path = "/clients/{id}", tag = TAG, operation_id = "clients_update", @@ -235,7 +240,7 @@ pub struct UpdateRequest { (status = 404, description = "No such client"), ), )] -async fn update( +pub(crate) async fn update( State(state): State, Extension(identity): Extension, ip: ClientIp, @@ -245,6 +250,11 @@ async fn update( identity.require_admin()?; if let Some(enabled) = request.is_enabled { + // Under the accounts lock, as its owner's own changes to their keys + // are: a key enabled between the pause being read and being written + // would be enabled still. + let _held = state.accounts_lock().await; + // Read first: the trail is read by humans, who know the name, and a // person's key paused from here is one its owner cannot resume. let key = repo::client::get(&state.db, &id).await?; @@ -256,11 +266,13 @@ async fn update( // A server key has no owner to keep it from; an administrator's own // key, paused from here, is theirs to resume from their account. + let mut said = String::from(if enabled { "enabled" } else { "disabled" }); if let Some(owner) = key.as_ref().and_then(|k| k.owner_id.as_deref()) { if !enabled && identity.person_id() != Some(owner) { - mark_paused(&state, &id, Some(&identity.label())).await?; - } else if enabled { - mark_paused(&state, &id, None).await?; + mark_paused(&state, owner, Some(&identity.label())).await?; + said.push_str(" · its owner can neither make nor resume keys until it is lifted"); + } else if enabled && lift_pause(&state, owner).await? { + said.push_str(" · the pause on its owner's keys lifted"); } } @@ -271,7 +283,7 @@ async fn update( ip: &ip, action: Action::ClientUpdated, target: name.as_deref(), - detail: Some(if enabled { "enabled" } else { "disabled" }), + detail: Some(&said), }, ) .await; @@ -356,32 +368,169 @@ fn expiry(text: &str) -> AppResult { Ok(crate::db::to_rfc3339(at)) } -/// Where the administrator who paused a person's key is written down. +/// Where the administrator who paused a person's keys is written down. /// -/// In the store every instance shares rather than on the key's row: what -/// matters is only that the owner's own "resume" is refused while it is -/// there, and that is the store that needs no change of schema to hold it. -fn pause_entry(id: &str) -> String { - format!("client.pausedBy:{id}") +/// On the person, not on a key: a key its owner deletes and makes again is +/// another key, and a pause that stood on the first went with it. In the store +/// every instance shares rather than on the account's row, which is the one +/// that needs no change of schema to hold it. +fn pause_entry(owner_id: &str) -> String { + format!("user.keysPausedBy:{owner_id}") +} + +/// What an older version wrote: the pause on one key, and nothing on its +/// owner. Still honoured, until an administrator lifts it. +fn key_pause_entry(key_id: &str) -> String { + format!("client.pausedBy:{key_id}") } -/// Write down who paused a person's key, or that nobody holds it paused. -async fn mark_paused(state: &AppState, id: &str, by: Option<&str>) -> AppResult<()> { - repo::keystore::put(&state.db, &pause_entry(id), by.unwrap_or_default()).await?; +/// Write down who paused a person's keys, or that nobody holds them paused. +async fn mark_paused(state: &AppState, owner_id: &str, by: Option<&str>) -> AppResult<()> { + repo::keystore::put(&state.db, &pause_entry(owner_id), by.unwrap_or_default()).await?; Ok(()) } -/// Whether an administrator paused this key, which its owner then may not -/// resume. -pub(crate) async fn paused_by_admin(state: &AppState, id: &str) -> AppResult { - Ok(repo::keystore::get(&state.db, &pause_entry(id)) +/// Who an entry says paused, when it says anybody. +async fn standing(state: &AppState, entry: &str) -> AppResult> { + Ok(repo::keystore::get(&state.db, entry) .await? - .is_some_and(|by| !by.is_empty())) + .filter(|by| !by.is_empty())) +} + +/// Who paused this person's keys, when an administrator did and none has +/// lifted it: until then their owner can neither make a key nor resume one. +pub(crate) async fn paused_by_admin(state: &AppState, owner_id: &str) -> AppResult> { + if let Some(by) = standing(state, &pause_entry(owner_id)).await? { + return Ok(Some(by)); + } + // A key paused before the pause stood on the person: the person's, too. + for key in repo::client::list(&state.db, repo::client::Owner::User(owner_id)).await? { + if let Some(by) = standing(state, &key_pause_entry(&key.id)).await? { + return Ok(Some(by)); + } + } + Ok(None) +} + +/// Lift the pause on a person's keys, the one on the person and any that an +/// older version left on a key: whether one stood. What an administrator does +/// by enabling one of their keys, or by saying so. +pub(crate) async fn lift_pause(state: &AppState, owner_id: &str) -> AppResult { + let mut lifted = false; + if standing(state, &pause_entry(owner_id)).await?.is_some() { + mark_paused(state, owner_id, None).await?; + lifted = true; + } + for key in repo::client::list(&state.db, repo::client::Owner::User(owner_id)).await? { + let entry = key_pause_entry(&key.id); + if standing(state, &entry).await?.is_some() { + repo::keystore::put(&state.db, &entry, "").await?; + lifted = true; + } + } + Ok(lifted) } #[cfg(test)] mod tests { use super::*; + use crate::db::repo::user::{NO_PASSWORD, NewUser, Role, Status, User}; + + async fn person(state: &AppState, name: &str, role: Role) -> User { + repo::user::create( + &state.db, + NewUser { + username: name, + password_hash: NO_PASSWORD, + role, + status: Status::Active, + display_name: None, + email: None, + invited_by: None, + oidc: None, + }, + ) + .await + .expect("an account") + } + + async fn key_of(state: &AppState, owner: &User, name: &str) -> ApiClient { + repo::client::create( + &state.db, + repo::client::NewClient { + name, + key_prefix: "ams_test", + key_hash: name, + scopes: &["read".to_string()], + expires_at: None, + note: None, + owner_id: Some(&owner.id), + }, + ) + .await + .expect("a key") + } + + /// What the version before this one wrote was a pause on a key, and + /// nothing on its owner: still a pause on the person, and lifted with it. + #[tokio::test] + async fn a_pause_put_on_a_key_by_an_older_version_is_still_the_persons() { + let state = crate::api::native::testing::server().await; + let alice = person(&state, "alice", Role::Member).await; + let key = key_of(&state, &alice, "Sonarr").await; + + assert_eq!(paused_by_admin(&state, &alice.id).await.unwrap(), None); + repo::keystore::put( + &state.db, + &format!("client.pausedBy:{}", key.id), + "admin:root", + ) + .await + .unwrap(); + assert_eq!( + paused_by_admin(&state, &alice.id).await.unwrap().as_deref(), + Some("admin:root") + ); + + assert!(lift_pause(&state, &alice.id).await.unwrap()); + assert_eq!(paused_by_admin(&state, &alice.id).await.unwrap(), None); + assert!(!lift_pause(&state, &alice.id).await.unwrap(), "none stood"); + } + + /// Pausing a person's key writes the pause on the person, naming who; an + /// administrator's own key, paused from here, is theirs to resume. + #[tokio::test] + async fn pausing_a_persons_key_pauses_the_person_and_naming_who() { + let state = crate::api::native::testing::server().await; + let admin = person(&state, "root", Role::Admin).await; + let alice = person(&state, "alice", Role::Member).await; + let hers = key_of(&state, &alice, "Sonarr").await; + let own = key_of(&state, &admin, "Mine").await; + let as_admin = || Extension(Identity::User(Box::new(admin.clone()))); + let pause = |id: &str, enabled: bool| { + update( + State(state.clone()), + as_admin(), + ClientIp::NONE, + Path(id.to_string()), + Json(UpdateRequest { + is_enabled: Some(enabled), + }), + ) + }; + + let _ = pause(&own.id, false).await.expect("an administrator's own"); + assert_eq!(paused_by_admin(&state, &admin.id).await.unwrap(), None); + + let _ = pause(&hers.id, false).await.expect("a member's"); + assert_eq!( + paused_by_admin(&state, &alice.id).await.unwrap().as_deref(), + Some("admin:root") + ); + // Enabling it from here lifts the pause on its owner. + let _ = pause(&hers.id, true).await.expect("resumed"); + assert_eq!(paused_by_admin(&state, &alice.id).await.unwrap(), None); + } #[test] fn an_expiry_is_a_time_still_to_come() { diff --git a/src/api/native/figures.rs b/src/api/native/figures.rs index c9d8792..9ef2251 100644 --- a/src/api/native/figures.rs +++ b/src/api/native/figures.rs @@ -6,7 +6,12 @@ use std::{ time::{Duration, Instant}, }; -use axum::{Extension, Json, extract::State, http::header}; +use axum::{ + Extension, Json, + extract::State, + http::header, + response::{IntoResponse, Response}, +}; use serde::{Deserialize, Serialize}; use utoipa::ToSchema; use utoipa_axum::{router::OpenApiRouter, routes}; @@ -74,12 +79,15 @@ pub struct Figures { #[utoipa::path( get, path = "/figures", tag = TAG, operation_id = "figures_figures", - responses((status = 200, body = Figures)), + responses( + (status = 200, body = Figures), + (status = 503, description = "As many such reads as the server answers at once are under way; `Retry-After` says when to ask again"), + ), )] async fn figures( State(state): State, Extension(identity): Extension, -) -> AppResult> { +) -> AppResult { let adult = state.adult_for(identity.client_id(), identity.peer_id(), None); // Read across the whole catalogue, by anyone the site is open to, so @@ -90,12 +98,19 @@ async fn figures( let key = format!( "figures:{adult}:{}:{}", state.caches.stamp(), - repo::item::catalogue_stamp(&state.db).await? + state.caches.catalogue_stamp(&state.db).await? ); - if let Some(cached) = state.caches.searches.get(&key).await - && let Ok(figures) = serde_json::from_str::(&cached) - { - return Ok(Json(figures)); + if let Some(kept) = kept_figures(&state, &key).await { + return Ok(Json(kept).into_response()); + } + + // A count of the whole catalogue, made afresh: a few at a time, as the + // facets it reads are. + let Some(_place) = state.caches.cold.take().await else { + return Ok(crate::api::busy()); + }; + if let Some(kept) = kept_figures(&state, &key).await { + return Ok(Json(kept).into_response()); } let query = repo::item::Query { @@ -165,7 +180,13 @@ async fn figures( { state.caches.searches.insert(key, encoded).await; } - Ok(Json(figures)) + Ok(Json(figures).into_response()) +} + +/// The figures kept under a key, when they are. +async fn kept_figures(state: &AppState, key: &str) -> Option { + let cached = state.caches.searches.get(key).await?; + serde_json::from_str(&cached).ok() } #[derive(Serialize, ToSchema)] @@ -630,6 +651,54 @@ mod tests { use super::*; use crate::{auth::testing::key, db::repo::user::Role, error::AppError}; + /// The catalogue's figures are counted over the whole of it, a few at a + /// time when none is kept: with every place taken, the caller waits, and is + /// turned away — told when to come back. Those kept are answered whatever. + #[tokio::test] + async fn figures_nobody_counted_lately_wait_for_a_place_and_are_turned_away_without_one() { + use axum::http::StatusCode; + + let state = super::super::testing::server().await; + let mut work = crate::domain::MediaItem::empty(MediaKind::Movie); + work.title = "Heat".into(); + work.slug = crate::domain::make_slug("Heat", None); + repo::item::upsert( + &state.db, + repo::item::ItemWrite { + item: &work, + replace_children: true, + }, + ) + .await + .expect("stored"); + state + .caches + .cold + .wait_at_most(std::time::Duration::from_millis(30)); + let counted = async || { + figures(State(state.clone()), Extension(Identity::Visitor)) + .await + .expect("answered") + }; + + assert_eq!(counted().await.status(), StatusCode::OK); + + let mut held = Vec::new(); + while state.caches.cold.free() > 0 { + held.push(state.caches.cold.take().await.expect("a place")); + } + assert_eq!(counted().await.status(), StatusCode::OK, "kept"); + + // The catalogue moved: what was kept is of the past. + state.caches.bump_epoch().await; + let turned_away = counted().await; + assert_eq!(turned_away.status(), StatusCode::SERVICE_UNAVAILABLE); + assert!(turned_away.headers().get(header::RETRY_AFTER).is_some()); + + held.pop(); + assert_eq!(counted().await.status(), StatusCode::OK); + } + /// The figures are read by whoever manages the server and by a key with /// the `metrics` scope — a scraper's — and by nobody else, a key that /// reads or edits the catalogue included. diff --git a/src/api/native/items.rs b/src/api/native/items.rs index be58f7f..cb82a32 100644 --- a/src/api/native/items.rs +++ b/src/api/native/items.rs @@ -478,6 +478,7 @@ fn country_code(raw: Option<&str>, what: &str) -> AppResult> { (status = 200, description = "Matching works, newest first", body = ListResponse), (status = 400, description = "A filter is not one the list takes, or a cursor is not one it gave, or was given beside an offset"), (status = 401, description = "No valid credential was presented"), + (status = 503, description = "As many such reads as the server answers at once are under way; `Retry-After` says when to ask again"), ), )] async fn list( @@ -518,13 +519,28 @@ async fn list( let cacheable = !query_for_count .missing .contains(&repo::item::Missing::MediaFailed); - if cacheable && let Some(cached) = state.caches.searches.get(&cache_key).await { - return Ok(( + let kept = |cached: String| { + ( StatusCode::OK, [(header::CONTENT_TYPE, "application/json")], cached, ) - .into_response()); + .into_response() + }; + if cacheable && let Some(cached) = state.caches.searches.get(&cache_key).await { + return Ok(kept(cached)); + } + + // A page nobody has asked for lately is read from the database — through + // the search index, or in an order no index keeps — and asking for one + // takes less than answering it: a few at a time, so that asking for many + // is no way to take the database from everybody else. + let Some(_place) = state.caches.cold.take().await else { + return Ok(crate::api::busy()); + }; + // Asked and answered meanwhile, perhaps, by a caller that waited too. + if cacheable && let Some(cached) = state.caches.searches.get(&cache_key).await { + return Ok(kept(cached)); } // The page and the count from one look in the search index: a misspelt @@ -2083,6 +2099,103 @@ mod tests { assert_eq!(total, found.len() as i64); } + /// The list as a caller is answered for `asked`, whatever the status. + async fn answered(state: &AppState, asked: &str) -> Response { + let uri: Uri = format!("/items?{asked}").parse().expect("an address"); + let Query(query) = Query::::try_from_uri(&uri).expect("a query"); + list(State(state.clone()), admin(), Query(query)) + .await + .expect("answered") + } + + /// Every place for the reads no cache answered, taken. + async fn all_places_taken(state: &AppState) -> Vec { + let mut held = Vec::new(); + while state.caches.cold.free() > 0 { + held.push(state.caches.cold.take().await.expect("a place")); + } + held + } + + /// A page nobody asked for is read from the database a few at a time: a + /// caller waits for its turn, and is turned away — told when to come + /// back — when none comes. A page the cache holds is answered whatever. + #[tokio::test] + async fn a_page_nobody_asked_for_waits_for_a_place_and_is_turned_away_without_one() { + use std::time::Duration; + + let state = crate::api::native::testing::server().await; + stored(&state, 1, "Heat", |_| {}).await; + state.caches.cold.wait_at_most(Duration::from_millis(30)); + + // A place free: read, and kept. + assert_eq!( + answered(&state, "limit=10&sort=title").await.status(), + StatusCode::OK + ); + + let mut held = all_places_taken(&state).await; + // The page kept is answered with no place at all… + assert_eq!( + answered(&state, "limit=10&sort=title").await.status(), + StatusCode::OK + ); + // …and one that nothing kept is not read: a 503, and when to ask again. + let turned_away = answered(&state, "limit=10&sort=release").await; + assert_eq!(turned_away.status(), StatusCode::SERVICE_UNAVAILABLE); + assert_eq!(turned_away.headers()[header::RETRY_AFTER], "5"); + let body = axum::body::to_bytes(turned_away.into_body(), usize::MAX) + .await + .expect("a body"); + let said: Value = serde_json::from_slice(&body).expect("JSON"); + assert_eq!(said["error"], "busy"); + + // A place given back is the next caller's. + held.pop(); + assert_eq!( + answered(&state, "limit=10&sort=release").await.status(), + StatusCode::OK + ); + } + + /// Two callers asking for the same page when no place is free: the one + /// that waits is answered from what the other made, and reads nothing. + #[tokio::test] + async fn a_caller_that_waited_for_a_place_is_answered_from_what_the_other_made() { + use std::time::Duration; + + let state = crate::api::native::testing::server().await; + stored(&state, 1, "Heat", |_| {}).await; + state.caches.cold.wait_at_most(Duration::from_secs(5)); + + // One place left, for one of them: the other waits for it, and finds + // the page made when it has it. + let mut held = all_places_taken(&state).await; + held.pop(); + let hits = state.caches.searches.l1_tally().hits; + + let (first, second) = tokio::join!( + answered(&state, "limit=10&sort=title"), + answered(&state, "limit=10&sort=title") + ); + assert_eq!(first.status(), StatusCode::OK); + assert_eq!(second.status(), StatusCode::OK); + let (first, second) = ( + axum::body::to_bytes(first.into_body(), usize::MAX) + .await + .expect("a body"), + axum::body::to_bytes(second.into_body(), usize::MAX) + .await + .expect("a body"), + ); + assert_eq!(first, second); + assert_eq!( + state.caches.searches.l1_tally().hits, + hits + 1, + "one made the page, the other found it made" + ); + } + #[tokio::test] async fn a_seasons_title_and_synopsis_are_read_in_the_language_asked_where_held() { let state = crate::api::native::testing::server().await; diff --git a/src/api/native/lists/import_rules.rs b/src/api/native/lists/import_rules.rs index 6adc119..5fbcf84 100644 --- a/src/api/native/lists/import_rules.rs +++ b/src/api/native/lists/import_rules.rs @@ -577,7 +577,7 @@ async fn preview( let checked = check(&state, request, current.as_ref()).await?; let wanted = rules::gather(&state, checked.source, &checked.params) .await - .map_err(|e| AppError::BadRequest(format!("{e:#}")))?; + .map_err(|e| AppError::BadRequest(rules::told(&e)))?; let lines: Vec = wanted .iter() .take(PREVIEW_MOST) diff --git a/src/api/native/media.rs b/src/api/native/media.rs index 0bfe07e..cdc3993 100644 --- a/src/api/native/media.rs +++ b/src/api/native/media.rs @@ -41,6 +41,20 @@ const TAG: &str = super::items::TAG; /// little for the form around it. const UPLOAD_BYTES: usize = file::MAX_AUDIO_BYTES as usize + 64 * 1024; +/// What a writer who is not an administrator may keep in uploads, all told: +/// room for every poster and theme a catalogue wants, short of filling the +/// store with whatever a key with the write scope sends. +const UPLOAD_QUOTA: u64 = 1024 * 1024 * 1024; + +/// Whether `adding` more bytes would take uploads that weigh `held` past +/// [`UPLOAD_QUOTA`]. +fn past_quota(held: i64, adding: usize) -> bool { + u64::try_from(held) + .unwrap_or(0) + .saturating_add(adding as u64) + > UPLOAD_QUOTA +} + pub fn router() -> OpenApiRouter { OpenApiRouter::new() .routes(routes!(status)) @@ -467,7 +481,10 @@ async fn work_media( .ok_or(AppError::NotFound)?; repo::item::load_children(&state.db, &mut item).await?; // What an address answered is an administrator's to read; a writer is - // told the kind of trouble, since any address can be typed in. + // told that the file could not be fetched and not why, nor how many + // tries it took: any address can be typed in, and what comes back for + // each — the kind of trouble among it — is a map of the network this + // server is on. let admin = identity.is_admin(); let origins = addresses(&state, &item).await?; @@ -492,12 +509,12 @@ async fn work_media( bytes: asset.bytes, width: asset.width, height: asset.height, - attempts: Some(asset.attempts), + attempts: admin.then_some(asset.attempts), error: asset.error.as_deref().map(|error| { if admin { error.to_string() } else { - crate::media::fetch::reason_for_writers(error).to_string() + crate::media::fetch::FOR_WRITERS.to_string() } }), uploaded_by: asset.uploaded_by.clone(), @@ -688,6 +705,57 @@ pub struct Uploaded { pub item: MediaItem, } +/// What a form calls a kind of upload: a picture, or a theme. +fn kind_named(name: &str) -> Option { + match name { + "image" => Some(Kind::Image), + "theme" | "audio" => Some(Kind::Audio), + _ => None, + } +} + +/// The file of an upload, read as it arrives and cut off where its first +/// bytes say it may end: a picture at [`file::MAX_IMAGE_BYTES`], a sound at +/// [`file::MAX_AUDIO_BYTES`], rather than at the most any upload may weigh, +/// once all of it has been held in memory. A file whose first bytes say it +/// is neither is refused as soon as they have come. `declared` is the kind +/// the form named before the file, when it did: the interface sends the file +/// first, so that what its first bytes say is what holds it. +async fn read_file( + mut field: axum::extract::multipart::Field<'_>, + declared: Option, +) -> AppResult { + let mut body: Vec = Vec::new(); + // What it may weigh until its first bytes have come: as a sound may, or + // as the form said. + let (mut kind, mut limit) = declared.map_or((Kind::Audio, file::MAX_AUDIO_BYTES), |kind| { + (kind, file::max_bytes(kind)) + }); + let mut known = false; + while let Some(chunk) = field.chunk().await.map_err(|e| { + if e.status() == StatusCode::PAYLOAD_TOO_LARGE { + AppError::PayloadTooLarge("the file is larger than an upload may be".into()) + } else { + AppError::BadRequest(format!("the file could not be read: {e}")) + } + })? { + body.extend_from_slice(&chunk); + if !known && body.len() >= file::HEAD_BYTES { + (kind, limit) = file::limit_for_head(&body, declared) + .map_err(|e| AppError::BadRequest(e.to_string()))?; + known = true; + } + if body.len() as u64 > limit { + return Err(AppError::PayloadTooLarge(format!( + "the file is larger than the {} MiB a {} may be", + limit / (1024 * 1024), + kind.as_str() + ))); + } + } + Ok(Bytes::from(body)) +} + /// Put a picture or a theme on a work, from a file. /// /// A form with `file`, and: `kind` (`image`, the default, or `theme`); @@ -738,14 +806,8 @@ async fn upload( { let name = field.name().unwrap_or_default().to_string(); if name == "file" { - let bytes = field.bytes().await.map_err(|e| { - if e.status() == StatusCode::PAYLOAD_TOO_LARGE { - AppError::PayloadTooLarge("the file is larger than an upload may be".into()) - } else { - AppError::BadRequest(format!("the file could not be read: {e}")) - } - })?; - file = Some(bytes); + let declared = fields.get("kind").and_then(|kind| kind_named(kind)); + file = Some(read_file(field, declared).await?); } else { let text = field .text() @@ -758,19 +820,10 @@ async fn upload( return Err(AppError::BadRequest("no file was sent".into())); }; - let kind = match fields.get("kind").map(String::as_str).unwrap_or("image") { - "image" => Kind::Image, - "theme" | "audio" => Kind::Audio, - other => { - return Err(AppError::BadRequest(format!( - "{other} is not a kind of upload" - ))); - } - }; - let limit = match kind { - Kind::Image => file::MAX_IMAGE_BYTES, - Kind::Audio => file::MAX_AUDIO_BYTES, - }; + let named = fields.get("kind").map_or("image", String::as_str); + let kind = kind_named(named) + .ok_or_else(|| AppError::BadRequest(format!("{named} is not a kind of upload")))?; + let limit = file::max_bytes(kind); if bytes.len() as u64 > limit { return Err(AppError::PayloadTooLarge(format!( "the file is larger than the {} MiB a {} may be", @@ -842,6 +895,20 @@ async fn upload( }, }; + // A writer's uploads are weighed together, under the label they are + // made as; an administrator's are not. + if !identity.is_admin() { + let by = identity.label(); + let held = repo::asset::uploaded_bytes(&state.db, &by).await?; + if past_quota(held, bytes.len()) { + return Err(AppError::PayloadTooLarge(format!( + "the uploads made as {by} already weigh {} MiB of the {} MiB a writer may keep", + held / (1024 * 1024), + UPLOAD_QUOTA / (1024 * 1024) + ))); + } + } + let asset_id = crate::db::new_id(); let origin = format!("upload:{asset_id}"); let (key, sha256) = file::key_for(&bytes, inspected.ext); @@ -1125,6 +1192,17 @@ mod tests { /// what it took. type Line = (Cause, String, String, Option, Option); + /// A writer's uploads stop at the quota, the last byte under it allowed. + #[test] + fn a_writer_keeps_no_more_than_the_quota_in_uploads() { + let quota = i64::try_from(UPLOAD_QUOTA).unwrap(); + assert!(!past_quota(0, 25 * 1024 * 1024)); + assert!(!past_quota(quota - 10, 10)); + assert!(past_quota(quota - 10, 11)); + assert!(past_quota(quota, 1)); + assert!(!past_quota(-1, 0), "a sum that is no weight weighs nothing"); + } + /// The whole server, on a database of its own, keeping its media in /// `dir`: what an upload needs somewhere to go. async fn server(dir: &std::path::Path) -> AppState { @@ -1447,6 +1525,207 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + + /// A film held here, with a picture of its own. + async fn a_film(state: &AppState, title: &str, url: &str) -> String { + let mut item = MediaItem::empty(MediaKind::Movie); + item.title = title.into(); + item.slug = crate::domain::make_slug(title, None); + item.images = vec![crate::domain::Image { + id: crate::db::new_id(), + season_number: None, + cover_type: CoverType::Poster, + url: url.into(), + language: None, + sort_order: 0, + source: None, + is_manual: false, + }]; + repo::item::upsert( + &state.db, + repo::item::ItemWrite { + item: &item, + replace_children: true, + }, + ) + .await + .expect("stored"); + item.id + } + + /// The form's words as the parts of a request. + fn words_as_parts(words: Words<'_>) -> Vec { + let mut out = Vec::new(); + for (name, value) in words { + out.extend_from_slice( + format!( + "--edge\r\nContent-Disposition: form-data; name=\"{name}\"\r\n\r\n{value}\r\n" + ) + .as_bytes(), + ); + } + out + } + + /// The form's words, each a name and a value. + type Words<'a> = &'a [(&'a str, &'a str)]; + + /// An upload whose file is `head`, then zeros to `total` bytes, sent in + /// chunks of 64 KiB, with the form's words before it and after it — the + /// interface sends the file first — through the route, with the limit the + /// route's body is held to. What it answered, and how many chunks of the + /// request were read by then, of how many there were. + async fn upload_streamed( + state: &AppState, + id: &str, + (before, after): (Words<'_>, Words<'_>), + head: &[u8], + total: usize, + ) -> (StatusCode, Value, usize, usize) { + use std::sync::{ + Arc, + atomic::{AtomicUsize, Ordering::SeqCst}, + }; + use tower::ServiceExt as _; + + let mut first = words_as_parts(before); + first.extend_from_slice( + b"--edge\r\nContent-Disposition: form-data; name=\"file\"; filename=\"upload\"\r\n\ + Content-Type: application/octet-stream\r\n\r\n", + ); + first.extend_from_slice(head); + let mut chunks = vec![Bytes::from(first)]; + let zeros = Bytes::from(vec![0u8; 64 * 1024]); + let mut sent = head.len(); + while sent < total { + let more = (total - sent).min(zeros.len()); + chunks.push(zeros.slice(..more)); + sent += more; + } + let mut last = b"\r\n".to_vec(); + last.extend_from_slice(&words_as_parts(after)); + last.extend_from_slice(b"--edge--\r\n"); + chunks.push(Bytes::from(last)); + + // One chunk for each time the body is asked for more, as a socket + // gives them: a stream that is always ready is read to its end by + // the form's parser in one go, whatever is done with what it gives. + let (all, pulled) = (chunks.len(), Arc::new(AtomicUsize::new(0))); + let counted = pulled.clone(); + let body = Body::from_stream(futures::stream::unfold( + chunks.into_iter(), + move |mut chunks| { + let counted = counted.clone(); + async move { + tokio::task::yield_now().await; + let chunk = chunks.next()?; + counted.fetch_add(1, SeqCst); + Some((Ok::<_, std::io::Error>(chunk), chunks)) + } + }, + )); + let request = Request::builder() + .method("POST") + .uri(format!("/items/{id}/media")) + .header("content-type", "multipart/form-data; boundary=edge") + .body(body) + .expect("a request"); + let response = axum::Router::new() + .route("/items/{id}/media", axum::routing::post(upload)) + .layer(axum::extract::DefaultBodyLimit::max(UPLOAD_BYTES)) + .layer(Extension(Identity::Anonymous)) + .with_state(state.clone()) + .oneshot(request) + .await + .expect("an answer"); + let status = response.status(); + let said = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .expect("a body"); + let said = serde_json::from_slice(&said).unwrap_or(Value::Null); + (status, said, pulled.load(SeqCst), all) + } + + /// A picture is cut off at the weight of a picture as it is read, not + /// once it has been held to the weight of a theme: the first bytes say + /// what it is, and the interface sends the file before it says so. A + /// file that is neither, or is not what the form says, is refused as + /// soon as they have come; a theme is let run to its own weight. + #[tokio::test] + async fn an_upload_is_cut_off_as_it_is_read_at_the_weight_of_what_it_is() { + let dir = crate::testing::scratch_path("media-streamed"); + let state = server(&dir).await; + let id = a_film(&state, "Streamed Upload Film", "https://p/streamed.jpg").await; + let image_cap = file::MAX_IMAGE_BYTES as usize; + let picture = png(7); + + // A picture of 26 MiB: stopped a chunk past the cap, the rest of the + // request never read. + let (status, _, pulled, all) = upload_streamed( + &state, + &id, + (&[("kind", "image")], &[]), + &picture, + image_cap + 1024 * 1024, + ) + .await; + assert_eq!(status, StatusCode::PAYLOAD_TOO_LARGE); + assert!( + pulled <= image_cap / (64 * 1024) + 8 && pulled < all - 8, + "{pulled} of {all} chunks read" + ); + + // The same with the kind sent after the file, as the interface does. + let (status, _, pulled, all) = upload_streamed( + &state, + &id, + (&[], &[("coverType", "poster"), ("kind", "image")]), + &picture, + image_cap + 1024 * 1024, + ) + .await; + assert_eq!(status, StatusCode::PAYLOAD_TOO_LARGE); + assert!(pulled < all - 8, "{pulled} of {all} chunks read"); + + // Neither a picture nor a sound: refused with the first bytes. + let (status, _, pulled, all) = + upload_streamed(&state, &id, (&[], &[]), b"", None).is_err()); + assert!(limit_for_head(b"", None).is_err()); + assert_eq!(max_bytes(Kind::Image), MAX_IMAGE_BYTES); + assert_eq!(max_bytes(Kind::Audio), MAX_AUDIO_BYTES); + } + fn jpeg(width: u32, height: u32) -> Vec { let mut out = Vec::new(); image::RgbImage::from_fn(width, height, |x, _| { diff --git a/src/media/mod.rs b/src/media/mod.rs index e575519..2fdd8b1 100644 --- a/src/media/mod.rs +++ b/src/media/mod.rs @@ -34,6 +34,13 @@ pub use file::valid_key; /// The path the media are served under, on this server. pub const ROUTE: &str = "/media/"; +/// How many copies of pictures are being made for the pages that ask for +/// them at once — those waiting for their turn to be made counted: a page's +/// first visit asks for a few dozen, and two are drawn at a time. A page +/// that asks while as many are being made is sent the picture itself, for +/// that time. +pub const COPIES_AT_ONCE: usize = 32; + /// The fields whose locked value is an address: a still put on an episode by /// hand, a theme put on a work, the poster and background a work or a season /// is to lead with. @@ -50,6 +57,9 @@ pub struct Media { pub guard: crate::outbound::Guard, /// Rung by whatever put an address in line. pub notify: tokio::sync::Notify, + /// The places for copies being made as pages ask for them, taken before + /// anything is read for one: see [`COPIES_AT_ONCE`] and [`serve`]. + pub copying: Arc, /// What the served addresses begin with: the public URL, so that a /// client elsewhere can follow them, or nothing, for the interface alone. base: String, @@ -155,6 +165,7 @@ impl Media { http: crate::outbound::guarded_client(guard)?, guard, notify: tokio::sync::Notify::new(), + copying: Arc::new(tokio::sync::Semaphore::new(COPIES_AT_ONCE)), base: public_url .map(|u| u.trim_end_matches('/').to_string()) .unwrap_or_default(), diff --git a/src/media/placeholder.rs b/src/media/placeholder.rs index 54a6b76..184e3d5 100644 --- a/src/media/placeholder.rs +++ b/src/media/placeholder.rs @@ -58,11 +58,21 @@ pub fn parse_thumbhash(text: &str) -> Option> { (5..=32).contains(&bytes.len()).then_some(bytes) } -/// The placeholder of a decoded picture. +/// The placeholder of a decoded picture: drawn from its pixels where they +/// lie, the sketch being the only copy made — a hundred pixels on a side at +/// the most, where a copy of the whole as RGBA was a third as much again as +/// the picture, on top of it. pub fn of(decoded: &Decoded) -> Result { - let rgba = decoded.rgba(); + let pixels = decoded.as_pixels(); let (width, height) = fit(decoded.width, decoded.height); - let sketch = area_average(&rgba, decoded.width, decoded.height, width, height)?; + let sketch = area_average( + pixels.data, + pixels.channels(), + decoded.width, + decoded.height, + width, + height, + )?; let thumbhash = thumbhash::rgba_to_thumb_hash(width as usize, height as usize, &sketch); let color = dominant(&sketch).unwrap_or([0, 0, 0]); Ok(Placeholder { color, thumbhash }) @@ -85,10 +95,22 @@ fn fit(width: u32, height: u32) -> (u32, u32) { /// A picture made smaller by averaging every pixel of each area into one — /// the colours weighed by their alpha, so that what is transparent lends -/// none of its colour — in whole numbers, the same everywhere. -fn area_average(rgba: &[u8], width: u32, height: u32, to_w: u32, to_h: u32) -> Result> { +/// none of its colour — in whole numbers, the same everywhere. `pixels` are +/// RGB or RGBA, `channels` bytes each (3 or 4), RGB being opaque; the +/// sketch is always RGBA. +fn area_average( + pixels: &[u8], + channels: usize, + width: u32, + height: u32, + to_w: u32, + to_h: u32, +) -> Result> { ensure!( - rgba.len() == width as usize * height as usize * 4 && to_w > 0 && to_h > 0, + (channels == 3 || channels == 4) + && pixels.len() == width as usize * height as usize * channels + && to_w > 0 + && to_h > 0, "the pixels are not the size the picture says" ); let (width, height, to_w, to_h) = ( @@ -111,11 +133,15 @@ fn area_average(rgba: &[u8], width: u32, height: u32, to_w: u32, to_h: u32) -> R let (mut r, mut g, mut b, mut a, mut n) = (0u64, 0u64, 0u64, 0u64, 0u64); for y in y0..y1.min(height) { for x in x0..x1.min(width) { - let at = (y * width + x) * 4; - let alpha = u64::from(rgba[at + 3]); - r += u64::from(rgba[at]) * alpha; - g += u64::from(rgba[at + 1]) * alpha; - b += u64::from(rgba[at + 2]) * alpha; + let at = (y * width + x) * channels; + let alpha = if channels == 4 { + u64::from(pixels[at + 3]) + } else { + 255 + }; + r += u64::from(pixels[at]) * alpha; + g += u64::from(pixels[at + 1]) * alpha; + b += u64::from(pixels[at + 2]) * alpha; a += alpha; n += 1; } diff --git a/src/media/serve.rs b/src/media/serve.rs index 858aedb..576ecb2 100644 --- a/src/media/serve.rs +++ b/src/media/serve.rs @@ -14,8 +14,21 @@ //! while `media.webp` is on: off, one kept already is served, and a page //! that asks for one that is not is sent to the picture itself — for this //! time only, as they may be turned on again. +//! +//! Making one is work, so it is held to what the rest of the server's is: +//! the caller who asks for one that is not kept is counted against the +//! requests an address may make ([`crate::auth::ratelimit::spend`]) — a +//! copy served is not counted, nor is a picture —, at most +//! [`super::COPIES_AT_ONCE`] are being made at once, and a copy waits for its +//! turn among the pictures decoded before its picture is read from the +//! store, not after. -use std::{ops::Range, sync::LazyLock, time::Duration}; +use std::{ + net::{IpAddr, Ipv4Addr}, + ops::Range, + sync::{Arc, LazyLock}, + time::Duration, +}; use anyhow::Context as _; use axum::{ @@ -27,7 +40,7 @@ use axum::{ use bytes::Bytes; use object_store::GetRange; -use crate::{db::repo, state::AppState}; +use crate::{api::extract::ClientIp, db::repo, error::AppError, state::AppState}; use super::{ file::{self, Made}, @@ -43,11 +56,47 @@ const PRESIGNED_FOR: Duration = Duration::from_secs(3600); /// names the bytes. const IMMUTABLE: &str = "public, max-age=31536000, immutable"; +/// What a request that is counted is called in the log: the route, never +/// the key, which is the one thing a reader needs to fetch what it names. +const LOGGED_AS: &str = "/media/{key}"; + +/// `GET /media/{key}`, for a caller whose address is known: what the route +/// answers. A copy this asks for that is not kept is made on its account, +/// counted against what its address may ask of the server. +/// +/// What the route in `web.rs` is mounted on: [`get`] cannot be, having no +/// caller to count — and is called as it is from inside this server. +pub async fn get_for_caller( + State(state): State, + method: Method, + Path(key): Path, + ip: ClientIp, + headers: HeaderMap, +) -> Response { + // As the rate limiter counts it: an address by its network, and a + // caller whose address is unknown with every other unknown one. + let caller = + ip.0.map_or(IpAddr::V4(Ipv4Addr::UNSPECIFIED), crate::auth::ip::bucket); + respond(&state, method, key, Some(caller), headers).await +} + +/// The same, for what asks of this server from inside it — the TMDB +/// pictures' gate, for the picture a key names: nobody to count. pub async fn get( State(state): State, method: Method, Path(key): Path, headers: HeaderMap, +) -> Response { + respond(&state, method, key, None, headers).await +} + +async fn respond( + state: &AppState, + method: Method, + key: String, + caller: Option, + headers: HeaderMap, ) -> Response { let Some(parsed) = file::parse_key(&key) else { return StatusCode::NOT_FOUND.into_response(); @@ -56,7 +105,7 @@ pub async fn get( return StatusCode::NOT_FOUND.into_response(); }; if matches!(parsed.made, Made::Width(_) | Made::Square) { - return copy(&state, store, &method, parsed, &headers).await; + return copy(state, store, &method, parsed, caller, &headers).await; } // A thumbnail is looked up by its picture's key. @@ -210,6 +259,7 @@ async fn copy( store: &Store, method: &Method, asked: file::Key<'_>, + caller: Option, headers: &HeaderMap, ) -> Response { let not_found = || StatusCode::NOT_FOUND.into_response(); @@ -254,7 +304,7 @@ async fn copy( if format == Format::Avif { return avif( - state, store, method, &original, asked.stem, made, &key, headers, + state, store, method, &original, asked.stem, made, &key, caller, headers, ) .await; } @@ -290,9 +340,21 @@ async fn copy( }; } - match kept_or_made(state, store, &original, asked.stem, made, format, &key).await { + match kept_or_made( + state, store, &original, asked.stem, made, format, &key, caller, + ) + .await + { Ok(bytes) => whole(method, format_type(format), &key, &etag, bytes, IMMUTABLE), - Err(e) => { + // Counted against what the address may ask, and spent: told so. + Err(Unmade::Spent(refusal)) => refusal.into_response(), + // As many are being made as there is room for: the picture itself, + // for now, and the copy asked for again. + Err(Unmade::Busy) => { + tracing::debug!(key, "too many copies are being made; the picture is sent"); + redirect(&state.media.url_for(&original), "no-store") + } + Err(Unmade::Failed(e)) => { tracing::warn!( key, error = format_args!("{e:#}"), @@ -318,6 +380,7 @@ async fn avif( stem: &str, made: Made, key: &str, + caller: Option, headers: &HeaderMap, ) -> Response { let etag = format!("\"{key}\""); @@ -348,7 +411,18 @@ async fn avif( return redirect(&state.media.url_for(original), "no-cache"); } let webp = file::made_key(stem, made, Format::Webp.ext()); - match kept_or_made(state, store, original, stem, made, Format::Webp, &webp).await { + match kept_or_made( + state, + store, + original, + stem, + made, + Format::Webp, + &webp, + caller, + ) + .await + { Ok(bytes) => whole( method, "image/webp", @@ -357,12 +431,61 @@ async fn avif( bytes, "no-cache", ), - Err(_) => StatusCode::NOT_FOUND.into_response(), + Err(Unmade::Spent(refusal)) => refusal.into_response(), + Err(Unmade::Busy) => redirect(&state.media.url_for(original), "no-store"), + Err(Unmade::Failed(_)) => StatusCode::NOT_FOUND.into_response(), + } +} + +/// Why a copy was not given. +enum Unmade { + /// The caller has spent what its address may ask of this server. + Spent(AppError), + /// As many copies are being made as there are places for. + Busy, + /// It could not be made. + Failed(anyhow::Error), +} + +impl From for Unmade { + fn from(e: anyhow::Error) -> Self { + Self::Failed(e) + } +} + +/// Why making a copy stopped — what the several who asked for the same one +/// at once are each told. +enum Skipped { + Busy, + Failed(anyhow::Error), +} + +impl From for Skipped { + fn from(e: anyhow::Error) -> Self { + Self::Failed(e) + } +} + +impl From> for Unmade { + fn from(skipped: Arc) -> Self { + match &*skipped { + Skipped::Busy => Self::Busy, + Skipped::Failed(e) => Self::Failed(anyhow::anyhow!("{e:#}")), + } } } /// A copy's bytes: the store's, or made now, put there and noted — once, /// however many ask for it at the same moment. +/// +/// One that is made is work, so what asks for it is held to what the server +/// holds the rest of its work to: the caller is counted against what its +/// address may ask, once for each request that finds the copy not kept; +/// no more than [`super::COPIES_AT_ONCE`] are made at a time; and the copy +/// waits for its turn among the pictures decoded *before* its picture is +/// read from the store, so that what waits is a request and not a picture of +/// up to 25 MiB for each. +#[allow(clippy::too_many_arguments)] async fn kept_or_made( state: &AppState, store: &Store, @@ -371,11 +494,28 @@ async fn kept_or_made( made: Made, format: Format, key: &str, -) -> anyhow::Result { + caller: Option, +) -> Result { if let Some(bytes) = store.read(key).await? { return Ok(bytes); } + // Not kept: this request is one that makes it, or waits for one that does. + if let Some(peer) = caller { + crate::auth::ratelimit::spend(state, peer, LOGGED_AS) + .await + .map_err(Unmade::Spent)?; + } MADE.try_get_with(key.to_string(), async { + // A place among the copies being made, those waiting for a turn + // counted: the copy's own work holds it until it is done. + let place = state + .media + .copying + .clone() + .try_acquire_owned() + .map_err(|_| Skipped::Busy)?; + // The turn first, then the picture. + let turn = variants::turn().await?; let bytes = store .read(original) .await? @@ -387,11 +527,13 @@ async fn kept_or_made( .unwrap_or(None), None => None, }; - let copy = variants::off_thread(move || { - let decoded = variants::Decoded::read(&bytes)?; - variants::make(&decoded, made, format, cover) - }) - .await?; + let copy = turn + .run(move || { + let _place = place; + let decoded = variants::Decoded::read(&bytes)?; + variants::make(&decoded, made, format, cover) + }) + .await?; // Filed under what was made: a width asked of a picture whose own // was not known may have come out narrower. let filed = copy.key(stem); @@ -413,10 +555,10 @@ async fn kept_or_made( { tracing::warn!(key = filed, error = %e, "could not note a copy made"); } - Ok::<_, anyhow::Error>(made_bytes) + Ok::<_, Skipped>(made_bytes) }) .await - .map_err(|e| anyhow::anyhow!("{e:#}")) + .map_err(Unmade::from) } fn format_type(format: Format) -> &'static str { @@ -755,7 +897,13 @@ mod tests { /// The whole server, on a database of its own, keeping its media in /// `dir`. async fn server(dir: &std::path::Path) -> AppState { + limited_server(dir, 600).await + } + + /// The same, an address of which may make `per_minute` requests a minute. + async fn limited_server(dir: &std::path::Path, per_minute: u32) -> AppState { let mut config = crate::config::Config::from_env().expect("a configuration"); + config.security.rate_limit_per_minute = per_minute; config.mode = crate::config::Mode::Single; config.database = crate::db::testing::database().await; config.security.bootstrap_admin = None; @@ -878,6 +1026,157 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + /// The route is mounted on the handler that knows its caller: on [`get`], + /// which is for what asks from inside the server, no copy made would be + /// counted against anybody. + #[test] + fn the_media_route_is_mounted_on_the_handler_that_counts_its_caller() { + let web = include_str!("../web.rs"); + let route = web + .lines() + .find(|line| line.contains(".route(\"/media/{key}\"")) + .expect("the route of the media kept"); + assert!( + route.contains("crate::media::serve::get_for_caller"), + "web.rs mounts {route:?}: no copy made would be counted against anybody — mount \ + `.route(\"/media/{{key}}\", get(crate::media::serve::get_for_caller))`" + ); + } + + /// A copy asked for by a caller whose address is known, as the route + /// asks it. + async fn asked_by_a_caller(state: &AppState, key: &str) -> Response { + get_for_caller( + State(state.clone()), + Method::GET, + Path(key.to_string()), + ClientIp::NONE, + HeaderMap::new(), + ) + .await + } + + /// A caller who asks for a copy that is not kept is counted against what + /// its address may ask — once, and for the copy that is made alone: the + /// copy kept is served, and so is the picture, whatever has been spent. + /// Spent, it is told so, and nothing is made. + #[tokio::test] + async fn a_caller_who_has_a_copy_made_is_counted_and_one_who_has_none_is_not() { + let dir = crate::testing::scratch_path("serve-counted"); + let state = limited_server(&dir, 2).await; + let store = state.media.store().unwrap().clone(); + turn(&state, "media.webp", true).await; + let mut pictures = Vec::new(); + for shade in [81, 82, 83] { + pictures.push(kept(&state, shade).await); + } + + // Two copies are made, and counted: the quota is two. + for (_, sha) in &pictures[..2] { + let answer = asked_by_a_caller(&state, &format!("{sha}-480.webp")).await; + assert_eq!(answer.status(), StatusCode::OK, "{sha}"); + } + // The third is not made: the address has spent what it may ask. + let third = format!("{}-480.webp", pictures[2].1); + let answer = asked_by_a_caller(&state, &third).await; + assert_eq!(answer.status(), StatusCode::TOO_MANY_REQUESTS); + assert!(!store.exists(&third).await.unwrap(), "nothing was made"); + assert!(webp_copies(&state, &pictures[2].1).await.is_empty()); + + // What is kept is served all the same, and so are the pictures + // themselves: neither is counted. + let first = format!("{}-480.webp", pictures[0].1); + for _ in 0..3 { + let answer = asked_by_a_caller(&state, &first).await; + assert_eq!(answer.status(), StatusCode::OK); + let answer = asked_by_a_caller(&state, &pictures[2].0).await; + assert_eq!(answer.status(), StatusCode::OK); + } + // What asks from inside the server has no caller to count. + let answer = asked(&state, &third).await; + assert_eq!(answer.status(), StatusCode::OK); + assert!(store.exists(&third).await.unwrap()); + + let _ = std::fs::remove_dir_all(&dir); + } + + /// As many copies are being made as there are places for: one more is + /// not made — the page is sent the picture itself, for now, and the copy + /// is made when it asks again. + #[tokio::test] + async fn no_more_copies_are_made_at_once_than_there_are_places_for() { + let dir = crate::testing::scratch_path("serve-places"); + let state = server(&dir).await; + let store = state.media.store().unwrap().clone(); + turn(&state, "media.webp", true).await; + let (key, sha) = kept(&state, 91).await; + let copy = format!("{sha}-480.webp"); + + let places = state + .media + .copying + .clone() + .acquire_many_owned(crate::media::COPIES_AT_ONCE as u32) + .await + .unwrap(); + let answer = asked(&state, ©).await; + assert_eq!(answer.status(), StatusCode::FOUND); + assert_eq!(answer.headers()[header::LOCATION], format!("/media/{key}")); + assert_eq!(answer.headers()[header::CACHE_CONTROL], "no-store"); + assert!(!store.exists(©).await.unwrap(), "nothing made"); + assert!(webp_copies(&state, &sha).await.is_empty(), "nothing noted"); + + drop(places); + let answer = asked(&state, ©).await; + assert_eq!(answer.status(), StatusCode::OK, "made once there is room"); + assert!(store.exists(©).await.unwrap()); + // The places are given back once a copy is made. + assert_eq!( + state.media.copying.available_permits(), + crate::media::COPIES_AT_ONCE + ); + + let _ = std::fs::remove_dir_all(&dir); + } + + /// A copy waits for its turn among the pictures decoded before its + /// picture is read from the store: while every turn is taken, the + /// request is a request waiting and not a picture of up to 25 MiB held + /// in memory with it. The picture is gone from the store here, so that + /// a read made too soon is told: it would answer at once. + #[tokio::test] + async fn a_copy_waits_for_its_turn_before_its_picture_is_read() { + let dir = crate::testing::scratch_path("serve-turn"); + let state = server(&dir).await; + let store = state.media.store().unwrap().clone(); + turn(&state, "media.webp", true).await; + let (key, sha) = kept(&state, 61).await; + store.delete(&key).await.unwrap(); + let copy = format!("{sha}-480.webp"); + + let turns = variants::every_turn().await; + let asking = tokio::spawn({ + let state = state.clone(); + let copy = copy.clone(); + async move { asked(&state, ©).await } + }); + tokio::time::sleep(Duration::from_millis(300)).await; + assert!( + !asking.is_finished(), + "the picture was read before the turn was had: it is not there, and the request is over" + ); + drop(turns); + let answer = tokio::time::timeout(Duration::from_secs(10), asking) + .await + .expect("answered once there is a turn") + .unwrap(); + // The picture is not in the store: the page is sent it all the same. + assert_eq!(answer.status(), StatusCode::FOUND); + assert_eq!(answer.headers()[header::CACHE_CONTROL], "no-store"); + + let _ = std::fs::remove_dir_all(&dir); + } + /// An AVIF of 480 of the picture of `sha`, noted and offered, whose /// file is not there: a store moved, say. Its key. async fn avif_without_its_file(state: &AppState, sha: &str) -> String { diff --git a/src/media/variants.rs b/src/media/variants.rs index 67e1421..d49522a 100644 --- a/src/media/variants.rs +++ b/src/media/variants.rs @@ -17,7 +17,7 @@ use std::{ panic::AssertUnwindSafe, - sync::{Arc, LazyLock}, + sync::{Arc, Condvar, LazyLock, Mutex, PoisonError}, }; use anyhow::{Context as _, Result, bail}; @@ -80,9 +80,14 @@ pub fn avif_widths(cover: Option) -> &'static [u32] { } /// The width a copy asked for is made at: the width asked, never past the -/// picture's own — `None` for a width the interface never asks for. +/// picture's own — `None` for a width the interface never asks for. The +/// picture's own width is one it asks for, and one made, only where the +/// picture is no wider than the widest copy: past that, "its own" is a +/// request for the whole of a picture of any size, drawn and written again +/// at full size. pub fn width_made(asked: u32, own: u32) -> Option { - (WIDTHS.contains(&asked) || asked == own).then(|| asked.min(own)) + let widest = WIDTHS[WIDTHS.len() - 1]; + (WIDTHS.contains(&asked) || (asked == own && own <= widest)).then(|| asked.min(own)) } /// What a copy is written as. @@ -127,45 +132,45 @@ pub struct Decoded { pub width: u32, pub height: u32, pub alpha: bool, + /// What reading the picture reserved of the memory the pictures decoding + /// may hold together, kept for as long as the picture is: given back + /// when it is dropped. Nothing for a copy of it made smaller, which is + /// within what was reserved for the whole. + _held: Option, } impl Decoded { - /// Read a picture's bytes — see [`file::decode`] for the bounds. + /// Read a picture's bytes — see [`file::decode_with`] for the bounds. + /// Waits, on the thread it is called from, for the memory its picture + /// needs to be there: see [`Budget`]. pub fn read(bytes: &[u8]) -> Result { - Ok(Self::of(file::decode(bytes)?)) + let (picture, held) = file::decode_with(bytes, true, |weight| BUDGET.take(weight))?; + Ok(Self::of(picture, Some(held))) } /// Read a picture's bytes `side` or more on one of its sides, smaller - /// than it is where its format allows: see [`file::decode_small`]. + /// than it is where its format allows: see [`file::decode_small_with`]. pub fn read_small(bytes: &[u8], side: u32) -> Result { - Ok(Self::of(file::decode_small(bytes, side)?)) + let (picture, held) = file::decode_small_with(bytes, side, |weight| BUDGET.take(weight))?; + Ok(Self::of(picture, Some(held))) } - fn of(picture: image::DynamicImage) -> Self { + fn of(picture: image::DynamicImage, held: Option) -> Self { let (width, height) = (picture.width(), picture.height()); // An alpha channel that is opaque everywhere is a photograph like // any other; only real transparency is kept. if picture.color().has_alpha() { - let rgba = picture.into_rgba8().into_raw(); - if rgba.as_chunks::<4>().0.iter().any(|p| p[3] < 255) { - return Self { - pixels: rgba, - width, - height, - alpha: true, - }; + let mut rgba = picture.into_rgba8().into_raw(); + let transparent = rgba.as_chunks::<4>().0.iter().any(|p| p[3] < 255); + if !transparent { + drop_alpha(&mut rgba); } - let rgb = rgba - .as_chunks::<4>() - .0 - .iter() - .flat_map(|p| [p[0], p[1], p[2]]) - .collect(); return Self { - pixels: rgb, + pixels: rgba, width, height, - alpha: false, + alpha: transparent, + _held: held, }; } Self { @@ -173,10 +178,11 @@ impl Decoded { width, height, alpha: false, + _held: held, } } - fn as_pixels(&self) -> Pixels<'_> { + pub fn as_pixels(&self) -> Pixels<'_> { Pixels { data: &self.pixels, width: self.width, @@ -195,6 +201,7 @@ impl Decoded { width, height, alpha: self.alpha, + _held: None, }); } let kind = if self.alpha { @@ -220,6 +227,7 @@ impl Decoded { width, height, alpha: self.alpha, + _held: None, }) } @@ -232,6 +240,7 @@ impl Decoded { width: self.width, height: self.height, alpha: false, + _held: None, }; } let pixels = self @@ -249,6 +258,7 @@ impl Decoded { width: self.width, height: self.height, alpha: false, + _held: None, } } @@ -257,19 +267,17 @@ impl Decoded { fn is_portrait(&self) -> bool { self.height > self.width } +} - /// RGBA, whatever it is: what the placeholder is worked out from. - pub fn rgba(&self) -> Vec { - if self.alpha { - return self.pixels.clone(); - } - self.pixels - .as_chunks::<3>() - .0 - .iter() - .flat_map(|p| [p[0], p[1], p[2], 255]) - .collect() +/// RGBA made RGB where it lies, each pixel's three colours moved down over +/// the alpha of those before it: a second buffer would be held beside the +/// first at the moment both are whole, a third as much again as the picture. +fn drop_alpha(pixels: &mut Vec) { + let count = pixels.len() / 4; + for at in 0..count { + pixels.copy_within(at * 4..at * 4 + 3, at * 3); } + pixels.truncate(count * 3); } /// A copy made. @@ -334,7 +342,8 @@ pub fn make( (Made::Width(asked), Format::Webp | Format::Avif) => { let width = asked.min(decoded.width); let height = height_at(decoded, width); - let small = decoded.resized(width, height)?; + let mut resized = None; + let small = drawn_at(decoded, width, height, &mut resized)?; let bytes = if format == Format::Avif { encode::avif(&small.as_pixels())? } else { @@ -367,6 +376,22 @@ pub fn make( } } +/// A picture at the size a copy is made at: itself where it is that size +/// already — a copy at a picture's own width copies none of its pixels, which +/// would be a second of them beside the first — and otherwise resized into +/// `resized`, whose picture is returned. +fn drawn_at<'a>( + decoded: &'a Decoded, + width: u32, + height: u32, + resized: &'a mut Option, +) -> Result<&'a Decoded> { + if (width, height) == (decoded.width, decoded.height) { + return Ok(decoded); + } + Ok(resized.insert(decoded.resized(width, height)?)) +} + /// What preparing a picture as it is stored came to: its placeholder, and /// the copy its kind is first asked for, when one was made. pub struct Prepared { @@ -402,7 +427,8 @@ pub fn prepare(bytes: &[u8], cover: Option, webp: bool) -> Result
> =
     LazyLock::new(|| Arc::new(tokio::sync::Semaphore::new(AT_ONCE)));
@@ -411,34 +437,156 @@ static DECODING: LazyLock> =
 static AVIF: LazyLock> =
     LazyLock::new(|| Arc::new(tokio::sync::Semaphore::new(1)));
 
-/// Run `work` — a decoding, a resizing, an encoding — off the server's
-/// threads, as many at once as [`AT_ONCE`] allows, and a decoder that
-/// panics on the bytes it was given as a picture refused rather than a
-/// thread brought down.
+/// What the pictures being decoded may hold in memory together
+/// ([`file::DECODE_BUDGET`]), however many the gate lets through and
+/// whatever they are: each takes the share its picture's worst case needs
+/// ([`file::decode_with`] counts it from the header) before a byte is
+/// allocated for it, and gives it back when what it decoded is dropped.
+/// Taken in turn, so that a large picture is not kept waiting for ever by a
+/// string of small ones; the thread that waits is the one the work runs on
+/// (see [`Turn::run`]), of which [`AT_ONCE`] and the night's one are all
+/// there are.
+struct Budget {
+    limit: u64,
+    shares: Mutex,
+    changed: Condvar,
+}
+
+#[derive(Default)]
+struct Shares {
+    /// What is held.
+    used: u64,
+    /// The next place in line to hand out, and the one whose turn it is.
+    next: u64,
+    serving: u64,
+}
+
+static BUDGET: LazyLock> = LazyLock::new(|| Arc::new(Budget::new(file::DECODE_BUDGET)));
+
+impl Budget {
+    fn new(limit: u64) -> Self {
+        Self {
+            limit,
+            shares: Mutex::new(Shares::default()),
+            changed: Condvar::new(),
+        }
+    }
+
+    /// A share of `bytes`, waited for until it fits beside those held and
+    /// every share asked for before it has been given. One larger than the
+    /// whole is the whole: the pictures that need more are refused before
+    /// they ask.
+    fn take(self: &Arc, bytes: u64) -> Held {
+        let bytes = bytes.min(self.limit);
+        let mut shares = self.shares.lock().unwrap_or_else(PoisonError::into_inner);
+        let place = shares.next;
+        shares.next += 1;
+        while shares.serving != place || shares.used + bytes > self.limit {
+            shares = self
+                .changed
+                .wait(shares)
+                .unwrap_or_else(PoisonError::into_inner);
+        }
+        shares.used += bytes;
+        shares.serving += 1;
+        // The one behind may fit beside this.
+        self.changed.notify_all();
+        Held {
+            budget: self.clone(),
+            bytes,
+        }
+    }
+}
+
+/// A share of the [`Budget`], given back when it is dropped.
+pub struct Held {
+    budget: Arc,
+    bytes: u64,
+}
+
+impl Drop for Held {
+    fn drop(&mut self) {
+        let mut shares = self
+            .budget
+            .shares
+            .lock()
+            .unwrap_or_else(PoisonError::into_inner);
+        shares.used = shares.used.saturating_sub(self.bytes);
+        self.budget.changed.notify_all();
+    }
+}
+
+/// A place among the pictures decoded at once ([`AT_ONCE`]), held until the
+/// work it was waited for is done. Taken before the picture is read from
+/// the store, so that no more pictures sit in memory waiting to be decoded
+/// than are being decoded.
+pub struct Turn(tokio::sync::OwnedSemaphorePermit);
+
+/// Wait for a place among the pictures decoded at once.
+pub async fn turn() -> Result {
+    let permit = DECODING
+        .clone()
+        .acquire_owned()
+        .await
+        .context("the pictures' gate is closed")?;
+    Ok(Turn(permit))
+}
+
+/// Every place at once, for a test that fills the gate: held by one test at a
+/// time, since two of them, each holding one of the two places, would wait
+/// for each other for ever.
+#[cfg(test)]
+pub async fn every_turn() -> (tokio::sync::MutexGuard<'static, ()>, [Turn; AT_ONCE]) {
+    static FILLING: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
+    let filling = FILLING.lock().await;
+    let mut turns = Vec::new();
+    for _ in 0..AT_ONCE {
+        turns.push(turn().await.expect("a place"));
+    }
+    let turns = turns.try_into().ok().expect("as many places as there are");
+    (filling, turns)
+}
+
+impl Turn {
+    /// Run `work` — a decoding, a resizing, an encoding — off the server's
+    /// threads, holding this place until it is done, and a decoder that
+    /// panics on the bytes it was given as a picture refused rather than a
+    /// thread brought down.
+    pub async fn run(
+        self,
+        work: impl FnOnce() -> Result + Send + 'static,
+    ) -> Result {
+        run_holding(self.0, work).await
+    }
+}
+
+/// Run `work` off the server's threads, as many at once as [`AT_ONCE`]
+/// allows, and a decoder that panics on the bytes it was given as a picture
+/// refused rather than a thread brought down.
 pub async fn off_thread(
     work: impl FnOnce() -> Result + Send + 'static,
 ) -> Result {
-    run_under(&DECODING, work).await
+    turn().await?.run(work).await
 }
 
 /// The same, for an AVIF copy: one at a time.
 pub async fn off_thread_avif(
     work: impl FnOnce() -> Result + Send + 'static,
 ) -> Result {
-    run_under(&AVIF, work).await
+    let permit = AVIF
+        .clone()
+        .acquire_owned()
+        .await
+        .context("the pictures' gate is closed")?;
+    run_holding(permit, work).await
 }
 
-async fn run_under(
-    gate: &Arc,
+async fn run_holding(
+    permit: tokio::sync::OwnedSemaphorePermit,
     work: impl FnOnce() -> Result + Send + 'static,
 ) -> Result {
     // Held by the work itself, so that a request given up on meanwhile does
     // not let another start beside it.
-    let permit = gate
-        .clone()
-        .acquire_owned()
-        .await
-        .context("the pictures' gate is closed")?;
     let joined = tokio::task::spawn_blocking(move || {
         let _permit = permit;
         std::panic::catch_unwind(AssertUnwindSafe(work))
@@ -506,6 +654,13 @@ mod tests {
             "no width the interface never asks for"
         );
         assert_eq!(width_made(10_000, 20_000), None);
+        // Its own width is a copy only where it is no wider than the widest:
+        // past that it would be a request for the whole of a picture of any
+        // size, drawn and written again.
+        assert_eq!(width_made(1920, 1920), Some(1920));
+        assert_eq!(width_made(2000, 2000), None);
+        assert_eq!(width_made(9_000, 9_000), None);
+        assert_eq!(width_made(1920, 3000), Some(1920));
     }
 
     #[test]
@@ -570,12 +725,49 @@ mod tests {
         assert_eq!(back.get_pixel(20, 70)[3], 0, "transparent where it was");
         assert_eq!(back.get_pixel(380, 70)[3], 255, "opaque where it was");
 
-        // An alpha channel opaque everywhere is no transparency.
+        // An alpha channel opaque everywhere is no transparency, and the
+        // colours are what they were, closed up where they lay.
         let mut opaque = Vec::new();
-        image::RgbaImage::from_pixel(50, 50, image::Rgba([1, 2, 3, 255]))
-            .write_to(&mut Cursor::new(&mut opaque), image::ImageFormat::Png)
-            .unwrap();
-        assert!(!Decoded::read(&opaque).unwrap().alpha);
+        image::RgbaImage::from_fn(50, 40, |x, y| {
+            image::Rgba([(x * 5) as u8, (y * 6) as u8, 3, 255])
+        })
+        .write_to(&mut Cursor::new(&mut opaque), image::ImageFormat::Png)
+        .unwrap();
+        let decoded = Decoded::read(&opaque).unwrap();
+        assert!(!decoded.alpha);
+        let pixels = decoded.as_pixels();
+        assert_eq!((pixels.data.len(), pixels.channels()), (50 * 40 * 3, 3));
+        assert_eq!(&pixels.data[(7 * 50 + 9) * 3..][..3], [45, 42, 3]);
+        assert_eq!(&pixels.data[(39 * 50 + 49) * 3..][..3], [245, 234, 3]);
+    }
+
+    /// RGBA made RGB where it lies keeps every pixel's colours, in order.
+    #[test]
+    fn the_alpha_is_dropped_where_the_pixels_lie() {
+        let mut pixels = vec![1, 2, 3, 255, 4, 5, 6, 255, 7, 8, 9, 255];
+        drop_alpha(&mut pixels);
+        assert_eq!(pixels, [1, 2, 3, 4, 5, 6, 7, 8, 9]);
+        let mut none: Vec = Vec::new();
+        drop_alpha(&mut none);
+        assert!(none.is_empty());
+    }
+
+    /// A copy at the picture's own size draws the picture itself: no second
+    /// buffer of its pixels beside the first; any other size is resized.
+    #[test]
+    fn a_copy_at_the_pictures_own_size_copies_none_of_its_pixels() {
+        let decoded = Decoded::read(&png(300, 200)).unwrap();
+        let mut resized = None;
+        let same = drawn_at(&decoded, 300, 200, &mut resized).unwrap();
+        assert!(std::ptr::eq(same, &decoded));
+        assert!(resized.is_none());
+        let smaller = drawn_at(&decoded, 150, 100, &mut resized).unwrap();
+        assert!(!std::ptr::eq(smaller, &decoded));
+        assert_eq!((smaller.width, smaller.height), (150, 100));
+        // And the copy made is the same as before it was spared.
+        let copy = make(&decoded, Made::Width(300), Format::Webp, None).unwrap();
+        assert_eq!((copy.width, copy.height), (300, 200));
+        assert_eq!(size_of(©.bytes), (300, 200));
     }
 
     #[test]
@@ -637,6 +829,110 @@ mod tests {
         assert!(prepare(b"not a picture", Some(CoverType::Poster), false).is_err());
     }
 
+    /// Pictures decoding together hold no more than the budget: one that
+    /// does not fit waits for those that do to be done, and those behind it
+    /// wait their turn, though they would fit — so that a large picture is
+    /// not kept waiting by a string of small ones. A share is given back
+    /// when it is dropped.
+    #[test]
+    fn pictures_decoding_together_hold_no_more_than_the_budget_and_wait_in_turn() {
+        use std::{sync::mpsc, thread, time::Duration};
+
+        let budget = Arc::new(Budget::new(100));
+        let first = budget.take(60);
+        let held = |budget: &Budget| budget.shares.lock().unwrap().used;
+        assert_eq!(held(&budget), 60);
+
+        let (told, heard) = mpsc::channel();
+        let ask = |name: &'static str, bytes: u64| {
+            let (budget, told) = (budget.clone(), told.clone());
+            thread::spawn(move || {
+                let share = budget.take(bytes);
+                told.send(name).unwrap();
+                // Held a moment, as a picture is while it is decoded.
+                thread::sleep(Duration::from_millis(50));
+                drop(share);
+            })
+        };
+        // 60 does not fit beside 60; 30 would, and is behind it.
+        let large = ask("large", 60);
+        thread::sleep(Duration::from_millis(100));
+        let small = ask("small", 30);
+        assert!(
+            heard.recv_timeout(Duration::from_millis(300)).is_err(),
+            "nobody goes while the first holds its share, and the small one is in turn"
+        );
+        assert_eq!(held(&budget), 60);
+
+        drop(first);
+        let mut told_in_the_end = vec![
+            heard.recv_timeout(Duration::from_secs(5)).unwrap(),
+            heard.recv_timeout(Duration::from_secs(5)).unwrap(),
+        ];
+        told_in_the_end.sort_unstable();
+        assert_eq!(told_in_the_end, ["large", "small"]);
+        large.join().unwrap();
+        small.join().unwrap();
+        assert_eq!(held(&budget), 0, "every share is given back");
+
+        // A share larger than the whole is the whole, and never waits for
+        // more than there is.
+        let all = budget.take(500);
+        assert_eq!(all.bytes, 100);
+        assert_eq!(held(&budget), 100);
+        drop(all);
+        assert_eq!(held(&budget), 0);
+    }
+
+    /// What a picture reserves is its worst case from the header, taken
+    /// before it is decoded, held for as long as the picture is and given
+    /// back with it.
+    #[test]
+    fn what_a_picture_reserves_is_given_back_once_it_is_dropped() {
+        let budget = Arc::new(Budget::new(file::DECODE_BUDGET));
+        let used = || budget.shares.lock().unwrap().used;
+        let bytes = png(64, 48);
+        let (picture, held) =
+            file::decode_with(&bytes, true, |weight| budget.take(weight)).unwrap();
+        assert_eq!((picture.width(), picture.height()), (64, 48));
+        // 3 072 pixels of RGB, 9 bytes each with the copy to be made of
+        // them, the file's own bytes and the fixed ones.
+        assert_eq!(
+            held.bytes,
+            64 * 48 * 9 + bytes.len() as u64 + 8 * 1024 * 1024
+        );
+        assert_eq!(used(), held.bytes);
+        drop(held);
+        assert_eq!(used(), 0);
+
+        // A picture read for the copies carries its share, a copy of it
+        // made smaller none, being within it.
+        let decoded = Decoded::read(&bytes).unwrap();
+        assert!(decoded._held.is_some());
+        assert!(decoded.resized(32, 24).unwrap()._held.is_none());
+    }
+
+    #[tokio::test]
+    async fn a_turn_is_waited_for_and_held_by_the_work() {
+        // Every place taken: another waits.
+        let (filling, [one, two]) = every_turn().await;
+        let mut third = Box::pin(turn());
+        assert!(
+            tokio::time::timeout(std::time::Duration::from_millis(100), &mut third)
+                .await
+                .is_err(),
+            "no more than {AT_ONCE} are decoded at once"
+        );
+        // The work holds its place until it is done, whoever stops waiting.
+        let done = one.run(|| Ok(1)).await.unwrap();
+        assert_eq!(done, 1);
+        let third = tokio::time::timeout(std::time::Duration::from_secs(5), third)
+            .await
+            .expect("a place freed by the work")
+            .unwrap();
+        drop((two, third, filling));
+    }
+
     #[tokio::test]
     async fn work_that_panics_is_a_picture_refused() {
         let refused = off_thread(|| -> Result<()> { panic!("a decoder's bug") }).await;
diff --git a/src/memory.rs b/src/memory.rs
index 05cfef6..719773b 100644
--- a/src/memory.rs
+++ b/src/memory.rs
@@ -63,6 +63,7 @@ fn count(delta: isize) {
 unsafe impl GlobalAlloc for Counting {
     #[inline]
     unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
+        // nosemgrep: rust.lang.security.unsafe-usage.unsafe-usage — a GlobalAlloc has to call its allocator's unsafe methods, held by the SAFETY note
         let at = unsafe { mimalloc::MiMalloc.alloc(layout) };
         if !at.is_null() {
             count(layout.size() as isize);
@@ -72,6 +73,7 @@ unsafe impl GlobalAlloc for Counting {
 
     #[inline]
     unsafe fn alloc_zeroed(&self, layout: Layout) -> *mut u8 {
+        // nosemgrep: rust.lang.security.unsafe-usage.unsafe-usage — a GlobalAlloc has to call its allocator's unsafe methods, held by the SAFETY note
         let at = unsafe { mimalloc::MiMalloc.alloc_zeroed(layout) };
         if !at.is_null() {
             count(layout.size() as isize);
@@ -81,12 +83,14 @@ unsafe impl GlobalAlloc for Counting {
 
     #[inline]
     unsafe fn dealloc(&self, at: *mut u8, layout: Layout) {
+        // nosemgrep: rust.lang.security.unsafe-usage.unsafe-usage — a GlobalAlloc has to call its allocator's unsafe methods, held by the SAFETY note
         unsafe { mimalloc::MiMalloc.dealloc(at, layout) };
         count(-(layout.size() as isize));
     }
 
     #[inline]
     unsafe fn realloc(&self, at: *mut u8, layout: Layout, new_size: usize) -> *mut u8 {
+        // nosemgrep: rust.lang.security.unsafe-usage.unsafe-usage — a GlobalAlloc has to call its allocator's unsafe methods, held by the SAFETY note
         let moved = unsafe { mimalloc::MiMalloc.realloc(at, layout, new_size) };
         // A failed realloc leaves the old block where it was, still held.
         if !moved.is_null() {
diff --git a/src/outbound.rs b/src/outbound.rs
index edfc77d..007b9ab 100644
--- a/src/outbound.rs
+++ b/src/outbound.rs
@@ -397,6 +397,9 @@ fn guarded_builder(guard: Guard) -> reqwest::ClientBuilder {
         .timeout(std::time::Duration::from_secs(60))
         .connect_timeout(std::time::Duration::from_secs(10))
         .redirect(guard.redirect_policy())
+        // A redirect is followed without telling the next host where it
+        // came from: the address left may carry a key in its query.
+        .referer(false)
 }
 
 #[cfg(test)]
diff --git a/src/providers/tmdb/mod.rs b/src/providers/tmdb/mod.rs
index 6f8a608..08de957 100644
--- a/src/providers/tmdb/mod.rs
+++ b/src/providers/tmdb/mod.rs
@@ -163,7 +163,13 @@ impl TmdbClient {
         external_source: &str,
         external_id: &str,
     ) -> Result {
-        let url = format!("{}/find/{external_id}", self.base);
+        // One segment of the path, whatever the id holds: it is read from a
+        // request, a list, a feed, and is no address of anybody's choosing.
+        let url = format!(
+            "{}/find/{}",
+            self.base,
+            crate::providers::segment(external_id)
+        );
         let params = [
             ("external_source", external_source.to_string()),
             ("language", self.language()),
@@ -754,6 +760,57 @@ mod tests {
         assert_eq!(image_languages("ja"), "ja,en,null");
     }
 
+    /// What an id written into the path of `/find` is asked as: the path
+    /// the stand-in was sent, as it was written on the wire.
+    async fn asked_to_find(id: &str) -> Vec {
+        use std::sync::{Arc, Mutex};
+
+        use axum::{Router, extract::Request};
+
+        let seen = Arc::new(Mutex::new(Vec::new()));
+        let kept = Arc::clone(&seen);
+        let app = Router::new().fallback(move |request: Request| {
+            let kept = Arc::clone(&kept);
+            async move {
+                kept.lock().unwrap().push(request.uri().path().to_string());
+                axum::Json(serde_json::json!({ "movie_results": [], "tv_results": [] }))
+            }
+        });
+        let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
+            .await
+            .expect("a port");
+        let at = listener.local_addr().expect("its address");
+        tokio::spawn(async move {
+            let _ = axum::serve(listener, app).await;
+        });
+
+        let client = TmdbClient::new(
+            reqwest::Client::new(),
+            &config::Tmdb {
+                api_key: Some("test".into()),
+                upstream: format!("http://{at}"),
+                language: "en-US".into(),
+                include_adult: false,
+                search_limit: 5,
+                passthrough: true,
+            },
+        );
+        client.find("imdb_id", id).await.expect("answered");
+        seen.lock().unwrap().clone()
+    }
+
+    #[tokio::test]
+    async fn an_id_asked_for_is_one_segment_of_the_path_whatever_it_holds() {
+        assert_eq!(asked_to_find("tt0903747").await, ["/3/find/tt0903747"]);
+        assert_eq!(asked_to_find("81189").await, ["/3/find/81189"]);
+        // Not a way to another path of TMDB's — nor to a query of its own.
+        assert_eq!(
+            asked_to_find("../account/1?session_id=s#x").await,
+            ["/3/find/..%2Faccount%2F1%3Fsession_id%3Ds%23x"]
+        );
+        assert_eq!(asked_to_find("tt1/../../3/account").await.len(), 1);
+    }
+
     #[test]
     fn backfill_ignores_episodes_absent_from_the_fallback() {
         let mut target = season(vec![(1, 9, Some(""), None)]);
diff --git a/src/search/rank.rs b/src/search/rank.rs
index 7f132e9..8308d98 100644
--- a/src/search/rank.rs
+++ b/src/search/rank.rs
@@ -44,9 +44,26 @@ impl Form {
 /// that it can only be a number in the title.
 const YEARS: std::ops::RangeInclusive = 1870..=2100;
 
+/// The most characters a term is looked for by: what the list and the search
+/// of the native API take, and what the searches TMDB's, TheTVDB's, Plex's
+/// and Sonarr's clients make are given too, however long the term they send.
+/// A term longer is read by its first characters, folded or not.
+pub const TERM_LENGTH: usize = 200;
+
+/// `text`'s first [`TERM_LENGTH`] characters.
+fn clip(text: &str) -> &str {
+    match text.char_indices().nth(TERM_LENGTH) {
+        Some((at, _)) => &text[..at],
+        None => text,
+    }
+}
+
 impl Term {
     pub fn parse(text: &str) -> Self {
-        let whole = norm(text);
+        // Cut before it is folded, which a text of megabytes is not to cost,
+        // and after, since a character may fold into a dozen.
+        let folded = norm(clip(text));
+        let whole = clip(&folded).trim_end().to_string();
         let dated = whole.rsplit_once(' ').and_then(|(before, last)| {
             let year: i32 = last.parse().ok().filter(|y| YEARS.contains(y))?;
             (last.len() == 4 && !before.is_empty()).then(|| (Form::of(before.to_string()), year))
@@ -282,6 +299,38 @@ mod tests {
         assert!(sequel > first, "{sequel} against {first}");
     }
 
+    /// Whoever sends a term — the interface, TMDB's search, TheTVDB's, Plex,
+    /// Sonarr — it is read by its first two hundred characters, and a text
+    /// of megabytes costs what a long term does.
+    #[test]
+    fn a_term_is_read_by_its_first_two_hundred_characters() {
+        // Under the cap nothing changes.
+        assert_eq!(Term::parse("Dune 2021").whole.norm, "dune 2021");
+        let exact = "a".repeat(TERM_LENGTH);
+        assert_eq!(Term::parse(&exact).whole.norm, exact);
+
+        // One word and a year past the cap: the year is cut with the rest,
+        // and no title is read before it.
+        let long = format!("{exact} 2021");
+        let term = Term::parse(&long);
+        assert_eq!(term.whole.norm, exact);
+        assert!(term.dated.is_none());
+
+        // The cut never leaves a space at the end.
+        let spaced = format!("{} b", "a".repeat(TERM_LENGTH - 1));
+        assert_eq!(Term::parse(&spaced).whole.norm, "a".repeat(TERM_LENGTH - 1));
+
+        // A character that folds into many is cut after it is folded: the
+        // Arabic ligature of a whole invocation is eighteen of them.
+        let ligatures = "\u{FDFA}".repeat(TERM_LENGTH);
+        assert!(Term::parse(&ligatures).whole.norm.chars().count() <= TERM_LENGTH);
+
+        let huge = "ab ".repeat(1_000_000);
+        let term = Term::parse(&huge);
+        assert!(term.whole.norm.chars().count() <= TERM_LENGTH);
+        assert!(!term.is_empty());
+    }
+
     #[test]
     fn a_term_of_punctuation_alone_is_nothing_to_look_for() {
         assert!(Term::parse("%").is_empty());
diff --git a/src/service/ids.rs b/src/service/ids.rs
index dfed97c..73989e3 100644
--- a/src/service/ids.rs
+++ b/src/service/ids.rs
@@ -308,6 +308,14 @@ pub fn own_episode(names: &str) -> i64 {
     -(i64::from(hash % (i32::MAX as u32)) + 1)
 }
 
+/// Whether `id` is a number [`own_episode`] can make: negative, and no
+/// further from nought than the 32-bit column a client keeps it in. Anything
+/// else — a positive number, nought, `-9999999999` — was never made here, so
+/// nothing is looked for under it.
+pub fn is_own_episode(id: i64) -> bool {
+    (-i64::from(i32::MAX)..0).contains(&id)
+}
+
 /// A search term of the form `prefix:value`, which Sonarr and Radarr both use
 /// to look a title up by a specific provider's id.
 #[derive(Debug, PartialEq, Eq)]
@@ -650,6 +658,31 @@ mod tests {
         assert!(own_episode("") < 0);
     }
 
+    #[test]
+    fn only_a_number_an_episode_can_be_given_is_one_made_here() {
+        for names in [
+            "tmdb:170135",
+            "place:abc",
+            "season:tmdb:1",
+            "character:x",
+            "",
+        ] {
+            assert!(is_own_episode(own_episode(names)), "{names}");
+        }
+        // The ends of what it makes, and one past each.
+        assert!(is_own_episode(-1) && is_own_episode(-i64::from(i32::MAX)));
+        for never in [
+            0,
+            1,
+            990_701,
+            -i64::from(i32::MAX) - 1,
+            -9_999_999_999,
+            i64::MIN,
+        ] {
+            assert!(!is_own_episode(never), "{never}");
+        }
+    }
+
     #[test]
     fn prefixed_terms_are_recognised() {
         assert_eq!(classify("tvdb:81189"), TermLookup::Tvdb(81189));
diff --git a/src/service/series.rs b/src/service/series.rs
index de9e836..fe83fc8 100644
--- a/src/service/series.rs
+++ b/src/service/series.rs
@@ -214,6 +214,13 @@ pub async fn by_tmdb_id(state: &AppState, tmdb_id: i64) -> Result Result> {
+    // As a film's: an id that is none is no series', and never goes into a
+    // provider's address — it is written there, a path of TMDB's — nor into
+    // what is looked for.
+    let Some(imdb_id) = crate::domain::ids::normalize_imdb_id(imdb_id) else {
+        return Ok(None);
+    };
+    let imdb_id = imdb_id.as_str();
     if let Some(item) = local(state, ExternalSource::Imdb, imdb_id).await?
         && item.kind == MediaKind::Series
     {
@@ -1031,6 +1038,31 @@ mod tests {
         item
     }
 
+    /// An IMDb id comes from a request's body, a query, a list somebody
+    /// else keeps: one that is none is no series, and asks nobody.
+    #[tokio::test]
+    async fn an_imdb_id_that_is_none_is_no_series_and_goes_into_no_address() {
+        let (state, providers) = crate::service::testing::server().await;
+        for none in [
+            "../account/1?x=1",
+            "tt12345?language=fr",
+            "tt12345/../../3/account/tt",
+            "not an id",
+            "ttabc",
+            "tt",
+            "",
+        ] {
+            let found = by_imdb_id(&state, none).await.expect("answered");
+            assert!(found.is_none(), "{none:?}");
+        }
+        assert_eq!(providers.asked(), 0, "none of them was asked of anybody");
+
+        // What makes it a test: one that is an id, spelt as TMDB's find
+        // takes it, is asked for.
+        let _ = by_imdb_id(&state, "9907240").await;
+        assert!(providers.asked() > 0);
+    }
+
     #[tokio::test]
     async fn sonarrs_imdb_and_tmdb_terms_find_a_series_held_under_another_id_of_its_item() {
         use crate::service::bridge::{Site, keep_for_tests};
diff --git a/src/service/webhook.rs b/src/service/webhook.rs
index b1b1ec1..2fcf4a8 100644
--- a/src/service/webhook.rs
+++ b/src/service/webhook.rs
@@ -229,6 +229,9 @@ impl Hooks {
             // Webhooks asks, rather than followed: a 3xx is a failure, and
             // the log says where to look.
             .redirect(reqwest::redirect::Policy::none())
+            // Nothing is followed, so no address is ever named to another;
+            // said all the same, as it is for every client this server makes.
+            .referer(false)
             .timeout(TIMEOUT)
             .connect_timeout(CONNECT_TIMEOUT)
             .build()
diff --git a/src/state.rs b/src/state.rs
index 7a79c89..e2a3d0f 100644
--- a/src/state.rs
+++ b/src/state.rs
@@ -175,6 +175,9 @@ pub struct Inner {
     accounts: tokio::sync::Mutex<()>,
     /// What each API answered since the start.
     pub calls: Calls,
+    /// The numbers the TheTVDB relay made for what TheTVDB gave none, led back
+    /// to what they name: read once while the catalogue stays as it was.
+    pub made_ids: crate::api::thetvdb::MadeIds,
     /// The media kept: the store, and the index of what is in it.
     pub media: crate::media::Media,
     /// The doors' certificates: the clients' listener and what it shows.
@@ -231,11 +234,13 @@ impl AppState {
 
         let upstreams = std::sync::Arc::new(configured_hosts(&config));
         let http = outbound_client(&upstreams, Redirects::Anywhere)?;
-        // MyAnimeList's client id travels in a header of its own, which a
-        // redirect to another origin — another host, but another port or
-        // scheme of the same one too — would carry along: reqwest strips only
-        // the ones it knows for credentials.
-        let mal_http = outbound_client(&upstreams, Redirects::SameOrigin)?;
+        // The providers a credential travels to: TMDB's and Fanart's key in
+        // the address, TheTVDB's in the body of its sign-in, which a 307
+        // sends on again, MyAnimeList's client id in a header of its own,
+        // which reqwest does not know for a credential and keeps on a
+        // redirect to another port or scheme of the same host too. Each is
+        // followed only where it was first asked.
+        let keyed_http = outbound_client(&upstreams, Redirects::SameOrigin)?;
 
         let slot = crate::cache::RedisSlot::default();
         let caches = Caches::new(&config.cache, slot.clone());
@@ -270,20 +275,20 @@ impl AppState {
             crate::config::Mode::Single => axum_extra::extract::cookie::Key::generate(),
             crate::config::Mode::Multi => shared_cookie_key(&db).await?,
         };
-        let tmdb = TmdbClient::new(http.clone(), &config.tmdb);
+        let tmdb = TmdbClient::new(keyed_http.clone(), &config.tmdb);
         let skyhook = SkyhookClient::new(http.clone(), &config.skyhook, instance.clone());
         let radarr_metadata =
             RadarrMetadataClient::new(http.clone(), &config.radarr_metadata, instance.clone());
-        let fanart = FanartClient::new(http.clone(), &config.fanart, &config.tmdb.language);
+        let fanart = FanartClient::new(keyed_http.clone(), &config.fanart, &config.tmdb.language);
         let tvdb = TvdbClient::new(
-            http.clone(),
+            keyed_http.clone(),
             &config.tvdb,
             &config.tmdb.language,
             instance.clone(),
         );
         let tvmaze = TvmazeClient::new(http.clone(), &config.tvmaze);
         let anilist = AnilistClient::new(http.clone(), &config.anilist, instance.clone());
-        let mal = MalClient::new(mal_http, &config.mal);
+        let mal = MalClient::new(keyed_http, &config.mal);
         let fankai = FankaiClient::new(http.clone(), &config.fankai, &config.tmdb.language);
         let fankai_wiki = FankaiWikiClient::new(http.clone(), &config.fankai_wiki);
 
@@ -346,6 +351,7 @@ impl AppState {
             instance,
             accounts: tokio::sync::Mutex::new(()),
             calls: Calls::default(),
+            made_ids: crate::api::thetvdb::MadeIds::default(),
             cookie_key,
             tls,
             coord,
@@ -1112,8 +1118,8 @@ enum Redirects {
     /// To any host the guard does not refuse.
     Anywhere,
     /// Only at the origin first asked — its scheme, host and port: a
-    /// credential in a header of the provider's own goes nowhere else, and
-    /// not to another service of the same host.
+    /// credential of the provider's own goes nowhere else, and not to
+    /// another service of the same host.
     SameOrigin,
 }
 
@@ -1124,6 +1130,26 @@ fn leaves_origin(first: Option<&url::Url>, next: &url::Url) -> bool {
     first.is_none_or(|first| !crate::outbound::same_origin(first.as_str(), next.as_str()))
 }
 
+/// What every client of the providers is made from: this server's name, how
+/// long it waits, and no `Referer`. reqwest writes the address a redirect
+/// came from into one, query and all, and for a provider asked with its key
+/// in the address — TMDB's, Fanart's — that address carries the key to
+/// whoever the redirect names.
+fn provider_builder() -> reqwest::ClientBuilder {
+    reqwest::Client::builder()
+        .user_agent(concat!(
+            "arr-metadata-server/",
+            env!("CARGO_PKG_VERSION"),
+            " (+",
+            env!("CARGO_PKG_REPOSITORY"),
+            ")"
+        ))
+        .timeout(std::time::Duration::from_secs(30))
+        .connect_timeout(std::time::Duration::from_secs(10))
+        .pool_max_idle_per_host(16)
+        .referer(false)
+}
+
 /// The client every provider, dataset, relay to TMDB and webhook is asked
 /// with.
 ///
@@ -1138,17 +1164,7 @@ fn outbound_client(
     redirects: Redirects,
 ) -> Result {
     let guard = crate::outbound::Guard::default();
-    reqwest::Client::builder()
-        .user_agent(concat!(
-            "arr-metadata-server/",
-            env!("CARGO_PKG_VERSION"),
-            " (+",
-            env!("CARGO_PKG_REPOSITORY"),
-            ")"
-        ))
-        .timeout(std::time::Duration::from_secs(30))
-        .connect_timeout(std::time::Duration::from_secs(10))
-        .pool_max_idle_per_host(16)
+    provider_builder()
         .dns_resolver(std::sync::Arc::new(UpstreamResolver {
             configured: std::sync::Arc::clone(upstreams),
         }))
@@ -1358,6 +1374,110 @@ mod redirect_tests {
         // With no first request on record, nothing is known to stay.
         assert!(leaves_origin(None, &first));
     }
+
+    /// A stand-in on the loopback: `/start` is sent on to `/next`, which says
+    /// the `Referer` it came with; `/away` is sent on to `elsewhere`.
+    async fn stand_in(elsewhere: &str) -> std::net::SocketAddr {
+        use axum::{Router, http::HeaderMap, response::Redirect, routing::get};
+
+        let away = elsewhere.to_string();
+        let app = Router::new()
+            .route("/start", get(|| async { Redirect::temporary("/next") }))
+            .route(
+                "/next",
+                get(|headers: HeaderMap| async move {
+                    headers
+                        .get("referer")
+                        .and_then(|value| value.to_str().ok())
+                        .unwrap_or("none")
+                        .to_string()
+                }),
+            )
+            .route(
+                "/away",
+                get(move || async move { Redirect::temporary(&away) }),
+            );
+        let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
+            .await
+            .expect("a port");
+        let at = listener.local_addr().expect("its address");
+        tokio::spawn(async move {
+            let _ = axum::serve(listener, app).await;
+        });
+        at
+    }
+
+    #[tokio::test]
+    async fn a_redirect_is_followed_without_telling_where_it_came_from() {
+        let at = stand_in("/").await;
+        let asked = format!("http://{at}/start?api_key=s3cret");
+        let said = |builder: reqwest::ClientBuilder| {
+            let asked = asked.clone();
+            async move {
+                builder
+                    .redirect(reqwest::redirect::Policy::limited(2))
+                    .build()
+                    .unwrap()
+                    .get(asked)
+                    .send()
+                    .await
+                    .expect("answered")
+                    .text()
+                    .await
+                    .expect("read")
+            }
+        };
+        assert_eq!(said(super::provider_builder()).await, "none");
+        // What makes it a test: a client as reqwest builds one says the
+        // address it was sent to, key and all.
+        assert!(said(reqwest::Client::builder()).await.contains("s3cret"));
+    }
+
+    /// The server's own clients, as they are built: TMDB's, which carries its
+    /// key in the address, is handed a redirect to another origin as it came;
+    /// the client of a provider with no credential is judged by the guard
+    /// alone, which refuses a hop to the loopback with an error.
+    #[tokio::test]
+    async fn the_client_of_tmdb_follows_no_redirect_off_its_origin() {
+        use crate::api::relay::testing::{StandIn, server};
+
+        let elsewhere = stand_in("/").await;
+        let at = stand_in(&format!("http://{elsewhere}/next")).await;
+        let upstream =
+            StandIn::start(|_| (axum::http::StatusCode::OK, serde_json::json!({}))).await;
+        let state = server(&upstream).await;
+        let asked = format!("http://{at}/away?api_key=s3cret");
+
+        let answer = state
+            .tmdb
+            .request(reqwest::Method::GET, &asked)
+            .send()
+            .await
+            .expect("answered");
+        assert_eq!(answer.status(), reqwest::StatusCode::TEMPORARY_REDIRECT);
+        assert!(state.http.get(&asked).send().await.is_err());
+    }
+
+    #[tokio::test]
+    async fn a_provider_with_a_credential_is_not_followed_to_another_origin() {
+        let elsewhere = stand_in("/").await;
+        let at = stand_in(&format!("http://{elsewhere}/next")).await;
+        let upstreams = std::sync::Arc::new(std::collections::HashSet::new());
+        let keyed = super::outbound_client(&upstreams, super::Redirects::SameOrigin).unwrap();
+
+        // Another port of the same host is another service: the redirect is
+        // handed back as it came, and nothing is sent to it.
+        let answer = keyed
+            .get(format!("http://{at}/away?api_key=s3cret"))
+            .send()
+            .await
+            .expect("answered");
+        assert_eq!(answer.status(), reqwest::StatusCode::TEMPORARY_REDIRECT);
+        assert_eq!(
+            answer.headers()["location"],
+            format!("http://{elsewhere}/next")
+        );
+    }
 }
 
 #[cfg(test)]
diff --git a/src/telemetry.rs b/src/telemetry.rs
index 21a10d9..fb81865 100644
--- a/src/telemetry.rs
+++ b/src/telemetry.rs
@@ -397,7 +397,19 @@ pub fn redact(text: &str) -> String {
 
 const MASK: &str = "***";
 
-/// The parameters whose values are secrets: keys, tokens, passwords.
+/// The parameters whose values are secrets: keys, tokens, passwords — and
+/// what an address carries a credential in besides, each by the name it
+/// is found under somewhere here: `auth`, where a list another server keeps
+/// private is read with a key (an import rule's address); `code`, `state`
+/// and `session_state`, what the identity provider sends a sign-in back with
+/// — a `code` is the one-time right to the person's tokens, and a `state`
+/// the proof the sign-in is the one begun; `session_id`, `guest_session_id`
+/// and `request_token`, the sessions TMDB's clients open, any of which acts
+/// as the account for as long as it lasts.
+///
+/// Every text that is logged, journaled, reported or traced is masked by
+/// this list: the request's span ([`redact_uri`]), the lines the journal
+/// keeps (`journal::masked`) and a server span's `url.query` ([`redact`]).
 const SECRET_PARAMETERS: &[&str] = &[
     "api_key",
     "apikey",
@@ -415,6 +427,13 @@ const SECRET_PARAMETERS: &[&str] = &[
     "signature",
     "x-amz-signature",
     "x-amz-credential",
+    "auth",
+    "code",
+    "state",
+    "session_state",
+    "session_id",
+    "guest_session_id",
+    "request_token",
 ];
 
 /// `api_key=abc&language=fr` is `api_key=***&language=fr`, wherever such a
@@ -637,6 +656,39 @@ mod tests {
         assert_eq!(mask_parameters("api_key="), "api_key=");
     }
 
+    #[test]
+    fn what_a_sign_in_a_session_or_a_list_carries_in_an_address_is_masked() {
+        // The identity provider's way back to the callback: the code is
+        // the right to the person's tokens, the state is the sign-in's own.
+        assert_eq!(
+            redact(
+                "GET /api/v1/auth/oidc/callback?code=4/0AbCdEf&state=Xy12Zq&session_state=ab.cd&iss=https://idp.example"
+            ),
+            "GET /api/v1/auth/oidc/callback?code=***&state=***&session_state=***&iss=https://idp.example"
+        );
+        // TMDB's sessions, in any case and wherever the pair stands.
+        assert_eq!(
+            mask_parameters(
+                "language=fr&Session_ID=s3ss10n&guest_session_id=gu3st&request_token=r3qt0k&page=2"
+            ),
+            "language=fr&Session_ID=***&guest_session_id=***&request_token=***&page=2"
+        );
+        // An import rule's address, with the key its list is read with.
+        assert_eq!(
+            redact(
+                "error sending request for url (https://other.example/lists/top/ids.txt?kind=movie&auth=t0pS3cret)"
+            ),
+            "error sending request for url (https://other.example/lists/top/ids.txt?kind=movie&auth=***)"
+        );
+        // A name that only contains one is left alone, and a value that is
+        // none stays none.
+        assert_eq!(
+            mask_parameters("barcode=1&mistate=2&authors=3&postcode=4"),
+            "barcode=1&mistate=2&authors=3&postcode=4"
+        );
+        assert_eq!(mask_parameters("code=&state="), "code=&state=");
+    }
+
     #[test]
     fn credentials_after_their_scheme_are_masked() {
         assert_eq!(
@@ -702,6 +754,26 @@ mod tests {
         assert_eq!(redact_uri(&bare), "/health");
     }
 
+    #[test]
+    fn the_request_span_never_holds_what_a_sign_in_or_a_session_carries() {
+        let callback: axum::http::Uri =
+            "/api/v1/auth/oidc/callback?code=authcode&state=csrf&session_state=sess"
+                .parse()
+                .unwrap();
+        assert_eq!(
+            redact_uri(&callback),
+            "/api/v1/auth/oidc/callback?code=***&state=***&session_state=***"
+        );
+        let tmdb: axum::http::Uri =
+            "/3/account/1/lists?session_id=s&guest_session_id=g&request_token=t&language=fr"
+                .parse()
+                .unwrap();
+        assert_eq!(
+            redact_uri(&tmdb),
+            "/3/account/1/lists?session_id=***&guest_session_id=***&request_token=***&language=fr"
+        );
+    }
+
     #[test]
     fn an_id_from_outside_is_taken_in_one_shape_only() {
         let ok = |text: &str| acceptable_id(&HeaderValue::from_str(text).unwrap());
diff --git a/src/telemetry/journal.rs b/src/telemetry/journal.rs
index 601e91e..073a68b 100644
--- a/src/telemetry/journal.rs
+++ b/src/telemetry/journal.rs
@@ -503,6 +503,41 @@ mod tests {
         assert!(text.contains("api_key=***"), "{text}");
     }
 
+    #[test]
+    fn what_a_sign_in_or_a_session_carries_in_an_address_is_not_kept() {
+        let subscriber = tracing_subscriber::registry().with(layer());
+        tracing::subscriber::with_default(subscriber, || {
+            tracing::warn!(
+                callback = %"/api/v1/auth/oidc/callback?code=authcode123456&state=csrfstate7890&session_state=sessstate4321",
+                tmdb = %"/3/account/1/lists?session_id=tmdbsession5555&guest_session_id=guestsess6666&request_token=reqtoken7777",
+                list = %"https://other.example/ids.txt?auth=listkey8888&kind=movie",
+                "signin-line-marker"
+            );
+        });
+        let lines = kept("signin-line-marker");
+        let text = lines.first().expect("the line").as_text();
+        for planted in [
+            "authcode123456",
+            "csrfstate7890",
+            "sessstate4321",
+            "tmdbsession5555",
+            "guestsess6666",
+            "reqtoken7777",
+            "listkey8888",
+        ] {
+            assert!(!text.contains(planted), "{planted} in {text}");
+        }
+        for masked in [
+            "code=***",
+            "state=***",
+            "session_id=***",
+            "auth=***",
+            "kind=movie",
+        ] {
+            assert!(text.contains(masked), "{masked} in {text}");
+        }
+    }
+
     #[test]
     fn the_journal_is_read_by_level_by_words_and_after_a_line() {
         let subscriber = tracing_subscriber::registry().with(layer());
diff --git a/src/telemetry/trace.rs b/src/telemetry/trace.rs
index 7ae00a6..ebd7e6f 100644
--- a/src/telemetry/trace.rs
+++ b/src/telemetry/trace.rs
@@ -744,6 +744,64 @@ pub(super) mod tests {
         ON_HERE.with(|on| on.set(false));
     }
 
+    /// The query a server span records is masked by the list the log is:
+    /// the key of a client, what a sign-in's address is sent back with, what
+    /// a TMDB session carries — and what is not a secret stays as it was.
+    #[tokio::test(flavor = "current_thread")]
+    async fn the_query_a_server_span_records_is_masked_as_the_log_masks_it() {
+        ON_HERE.with(|on| on.set(true));
+        let kept = Kept::default();
+        let provider_spans = SdkTracerProvider::builder()
+            .with_simple_exporter(kept.clone())
+            .with_id_generator(Chosen::default())
+            .build();
+        let subscriber = tracing_subscriber::registry().with(spans(provider_spans.tracer("test")));
+        let _default = tracing::subscriber::set_default(subscriber);
+
+        let (upstream, _) = provider().await;
+        let query = "code=authcode123456&state=csrfstate7890&session_state=sessstate4321\
+                     &session_id=tmdbsession5555&guest_session_id=guestsess6666\
+                     &request_token=reqtoken7777&auth=listkey8888&api_key=clientkey9999&language=fr";
+        let mut request = HttpRequest::get(format!("/v1/tvdb/shows/en/81189?{query}"))
+            .body(Body::empty())
+            .unwrap();
+        request.extensions_mut().insert(ConnectInfo::(
+            "203.0.113.5:4000".parse().unwrap(),
+        ));
+        let response = door(upstream, vec![]).oneshot(request).await.unwrap();
+        assert_eq!(response.status(), 200);
+        // The span ends with the answer's body, as the other test reads it.
+        let body = axum::body::to_bytes(response.into_body(), 1024)
+            .await
+            .unwrap();
+        assert_eq!(&body[..], b"answered");
+
+        let spans = std::mem::take(&mut *kept.0.lock().unwrap());
+        let server = spans
+            .iter()
+            .find(|s| s.span_kind == SpanKind::Server)
+            .expect("the server span");
+        let recorded = attribute(server, "url.query")
+            .expect("the query")
+            .to_string();
+        for planted in [
+            "authcode123456",
+            "csrfstate7890",
+            "sessstate4321",
+            "tmdbsession5555",
+            "guestsess6666",
+            "reqtoken7777",
+            "listkey8888",
+            "clientkey9999",
+        ] {
+            assert!(!recorded.contains(planted), "{planted} in {recorded}");
+        }
+        assert!(recorded.contains("language=fr"), "{recorded}");
+        assert!(recorded.contains("code=***&state=***"), "{recorded}");
+
+        ON_HERE.with(|on| on.set(false));
+    }
+
     /// A call made outside any request — a background task's — is sent as
     /// it is: no span, no `traceparent`.
     #[tokio::test(flavor = "current_thread")]
diff --git a/src/ui.rs b/src/ui.rs
index 9bee92b..e3d7119 100644
--- a/src/ui.rs
+++ b/src/ui.rs
@@ -13,6 +13,11 @@
 //! credential would be let in at all. A work's page carries what a search
 //! engine reads of it too, as schema.org describes a film or a series — only
 //! where the site is public, the one case a search engine is meant to read it.
+//!
+//! Finding what a preview says is the database's work, which anybody may ask
+//! for as often as they like: it is kept a few seconds, and what is not kept
+//! is counted against the caller's allowance as the API's requests are, a
+//! caller who has spent it getting the page without ([`shell`]).
 
 use axum::{
     Router,
@@ -84,13 +89,69 @@ async fn serve(State(state): State, request: Request) -> Response {
     let Some(file) = Assets::get("index.html") else {
         return not_found();
     };
-    let mut html = String::from_utf8_lossy(&file.data).into_owned();
-    if let Some(preview) = preview(&state, path).await {
-        html = preview.apply(&html, state.config.server.public_url.as_deref(), path);
-    } else if let Some(public) = state.config.server.public_url.as_deref() {
-        html = absolute_site_image(&html, public);
+    let html = String::from_utf8_lossy(&file.data).into_owned();
+    let peer = crate::auth::ratelimit::peer_of(&state, &request);
+    page(shell(&state, path, peer, &html).await)
+}
+
+/// The longest path a rendered preview is kept under: a path that may have
+/// one is a few dozen characters, and what follows a work's id is a stranger's
+/// to write.
+const KEPT_PATH: usize = 256;
+
+/// The page for a route: the app's own, with the preview of what the route
+/// shows in its head where there is one.
+///
+/// Looking a preview up is the database's work — a work, a list, a genre's
+/// count — which anybody may ask for as often as they like, one address after
+/// another. So a preview rendered lately is kept a few seconds, under the path
+/// and the catalogue's mark; one that is not kept is looked up on the
+/// caller's account, as the API's requests are counted
+/// ([`crate::auth::ratelimit::spend`]); and a caller whose allowance is spent
+/// is given the page without one, as a path that has none is. `peer` is the
+/// caller, as the limiter counts callers ([`crate::auth::ratelimit::peer_of`]).
+async fn shell(state: &AppState, path: &str, peer: std::net::IpAddr, html: &str) -> String {
+    let public = state.config.server.public_url.as_deref();
+    let plain = || match public {
+        Some(public) => absolute_site_image(html, public),
+        None => html.to_string(),
+    };
+
+    let Some(subject) = subject_of(path) else {
+        return plain();
+    };
+    if !open_to_readers(state) {
+        return plain();
+    }
+    let Ok(mark) = state.caches.catalogue_stamp(&state.db).await else {
+        return plain();
+    };
+    let key = format!("{}\n{mark}\n{path}", state.caches.stamp());
+    let kept = path.len() <= KEPT_PATH;
+    if kept && let Some(page) = state.caches.previews.get(&key).await {
+        return page.to_string();
+    }
+
+    if crate::auth::ratelimit::spend(state, peer, path)
+        .await
+        .is_err()
+    {
+        return plain();
     }
-    page(html)
+    let Some(preview) = preview(state, subject).await else {
+        return plain();
+    };
+    let page = preview.apply(html, public, path);
+    // Only what was found is kept: a path that names nothing is looked up
+    // again, on its caller's account, and is never the key of anything.
+    if kept {
+        state
+            .caches
+            .previews
+            .insert(key, std::sync::Arc::from(page.as_str()))
+            .await;
+    }
+    page
 }
 
 fn asset(path: &str) -> Option {
@@ -241,32 +302,48 @@ fn open_to_readers(state: &AppState) -> bool {
     }
 }
 
-/// The preview a path deserves: a work's, a list's, a page's, or none. The
-/// shape of the id or slug is checked before anything is looked up, so a
-/// stranger walking random addresses is answered from the page alone.
-async fn preview(state: &AppState, path: &str) -> Option {
-    if !open_to_readers(state) {
-        return None;
-    }
+/// What a path's preview would be of, once the shape of its id or slug says
+/// it can have one at all.
+#[derive(Debug, PartialEq, Eq)]
+enum Subject<'a> {
+    /// A work, by its id.
+    Work(&'a str),
+    /// A curated list, by its slug or its id.
+    List(&'a str),
+    /// A genre's, a network's, a year's or a decade's page, and the key it is
+    /// asked for by, as written.
+    Page(&'a str, String),
+    /// A company's or a keyword's page, by this server's id for it.
+    Entity(&'a str, &'a str),
+    /// A character, by this server's id for it.
+    Character(&'a str),
+    /// A collection, by TMDB's id for it.
+    Collection(i64),
+}
+
+/// What a path asks a preview of, or nothing: the shape of the id or slug is
+/// checked before anything is looked up, so a stranger walking random
+/// addresses is answered from the page alone.
+fn subject_of(path: &str) -> Option> {
     let mut parts = path.trim_start_matches('/').split('/');
     match (parts.next()?, parts.next()?) {
-        ("work", id) if uuid::Uuid::parse_str(id).is_ok() => work_preview(state, id).await,
-        ("lists", key) if is_key(key) && parts.next().is_none() => list_preview(state, key).await,
+        ("work", id) if uuid::Uuid::parse_str(id).is_ok() => Some(Subject::Work(id)),
+        ("lists", key) if is_key(key) && parts.next().is_none() => Some(Subject::List(key)),
         (page @ ("genre" | "network" | "year" | "decade"), key)
             if !key.is_empty() && key.len() <= 128 && parts.next().is_none() =>
         {
             let key = urlencoding::decode(key).ok()?;
-            page_preview(state, page, &key).await
+            Some(Subject::Page(page, key.into_owned()))
         }
         // A company's, a keyword's and a character's pages, by this
         // server's id for them.
         (page @ ("company" | "keyword"), id)
             if uuid::Uuid::parse_str(id).is_ok() && parts.next().is_none() =>
         {
-            entity_preview(state, page, id).await
+            Some(Subject::Entity(page, id))
         }
         ("characters", id) if uuid::Uuid::parse_str(id).is_ok() && parts.next().is_none() => {
-            character_preview(state, id).await
+            Some(Subject::Character(id))
         }
         // A collection's, by TMDB's id for it.
         ("collections", id)
@@ -275,12 +352,25 @@ async fn preview(state: &AppState, path: &str) -> Option {
                 && id.bytes().all(|b| b.is_ascii_digit())
                 && parts.next().is_none() =>
         {
-            collection_preview(state, id.parse().ok()?).await
+            Some(Subject::Collection(id.parse().ok()?))
         }
         _ => None,
     }
 }
 
+/// The preview of what a path asks one of: a work's, a list's, a page's, or
+/// none where the catalogue holds nothing by that name.
+async fn preview(state: &AppState, subject: Subject<'_>) -> Option {
+    match subject {
+        Subject::Work(id) => work_preview(state, id).await,
+        Subject::List(key) => list_preview(state, key).await,
+        Subject::Page(page, key) => page_preview(state, page, &key).await,
+        Subject::Entity(page, id) => entity_preview(state, page, id).await,
+        Subject::Character(id) => character_preview(state, id).await,
+        Subject::Collection(tmdb_id) => collection_preview(state, tmdb_id).await,
+    }
+}
+
 /// A list's slug or id: what `make_slug` produces, or a UUID.
 fn is_key(key: &str) -> bool {
     !key.is_empty()
@@ -1258,4 +1348,217 @@ mod tests {
         assert!(!is_key("a/b"));
         assert!(!is_key(&"a".repeat(129)));
     }
+
+    #[test]
+    fn only_a_path_that_can_have_a_preview_is_asked_for_one() {
+        let id = "01a0d073-6ce8-760c-a2cb-113d4cf78503";
+        assert_eq!(subject_of(&format!("/work/{id}")), Some(Subject::Work(id)));
+        // What follows a work's id changes nothing of whose page it is.
+        assert_eq!(
+            subject_of(&format!("/work/{id}/seasons/2")),
+            Some(Subject::Work(id))
+        );
+        assert_eq!(
+            subject_of("/lists/soirees-d-automne"),
+            Some(Subject::List("soirees-d-automne"))
+        );
+        assert_eq!(
+            subject_of("/genre/Dr%C3%A2me"),
+            Some(Subject::Page("genre", "Drâme".into()))
+        );
+        assert_eq!(
+            subject_of("/network/hbo"),
+            Some(Subject::Page("network", "hbo".into()))
+        );
+        assert_eq!(
+            subject_of(&format!("/company/{id}")),
+            Some(Subject::Entity("company", id))
+        );
+        assert_eq!(
+            subject_of(&format!("/keyword/{id}")),
+            Some(Subject::Entity("keyword", id))
+        );
+        assert_eq!(
+            subject_of(&format!("/characters/{id}")),
+            Some(Subject::Character(id))
+        );
+        assert_eq!(subject_of("/collections/10"), Some(Subject::Collection(10)));
+
+        let long = format!("/genre/{}", "a".repeat(129));
+        for path in [
+            "/",
+            "/catalogue",
+            "/work",
+            "/work/not-an-id",
+            "/lists/Soirées",
+            "/lists/a/b",
+            "/genre/",
+            long.as_str(),
+            "/company/nobody",
+            "/characters/1",
+            "/collections/abc",
+            "/collections/1234567890123456789",
+        ] {
+            assert_eq!(subject_of(path), None, "{path}");
+        }
+    }
+
+    /// A server whose pages are offered a link preview whoever reads them,
+    /// and that lets an address make `per_minute` requests a minute.
+    async fn open_server(per_minute: u32) -> AppState {
+        let mut config = crate::config::Config::from_env().expect("a configuration");
+        config.mode = crate::config::Mode::Single;
+        config.database = crate::db::testing::database().await;
+        config.security.bootstrap_admin = None;
+        config.security.native_policy = SurfacePolicy::Open;
+        config.security.rate_limit_per_minute = per_minute;
+        config.cache.redis_url = None;
+        config.media.storage = crate::config::MediaStorage::Off;
+        config.clients = None;
+        config.tmdb.api_key = None;
+        config.tmdb.include_adult = false;
+        config.tvdb.api_key = None;
+        config.tvdb.enabled = false;
+        AppState::bootstrap(config).await.expect("a server")
+    }
+
+    /// Two dramas in the catalogue: the drama page's preview says so.
+    async fn two_dramas(state: &AppState) -> Vec {
+        let mut works = Vec::new();
+        for title in ["One", "Two"] {
+            let mut work = MediaItem::empty(MediaKind::Movie);
+            work.title = title.into();
+            work.slug = crate::domain::make_slug(title, None);
+            work.genres = vec!["Drama".to_string()];
+            repo::item::upsert(
+                &state.db,
+                repo::item::ItemWrite {
+                    item: &work,
+                    replace_children: true,
+                },
+            )
+            .await
+            .unwrap();
+            works.push(work);
+        }
+        crate::service::listing::relist_stale(&state.db, false, 100)
+            .await
+            .unwrap();
+        works
+    }
+
+    /// The page for a path, as a caller at one address is given it.
+    async fn previewed(state: &AppState, path: &str) -> String {
+        shell(state, path, std::net::IpAddr::from([203, 0, 113, 9]), PAGE).await
+    }
+
+    const TWO: &str = "2 works in the catalogue.";
+
+    /// What a link preview costs the database is asked of whoever asks: kept
+    /// a few seconds, counted against the caller's allowance when it is not
+    /// kept, and not looked up at all once the allowance is spent.
+    #[tokio::test]
+    async fn a_preview_is_looked_up_on_the_callers_account_and_kept_a_few_seconds() {
+        let state = open_server(2).await;
+        two_dramas(&state).await;
+
+        // A page that can have no preview is answered as it always was, and
+        // costs the caller nothing.
+        for _ in 0..5 {
+            let page = previewed(&state, "/catalogue").await;
+            assert!(page.contains("A catalogue."), "{page}");
+        }
+
+        // The first look-up is counted; the same path asked again is answered
+        // from what was kept, however often.
+        for _ in 0..5 {
+            let page = previewed(&state, "/genre/drama").await;
+            assert!(page.contains(TWO), "{page}");
+        }
+        // Another spelling of it is another path: a look-up, the second of
+        // the two the allowance holds.
+        let page = previewed(&state, "/genre/Drama").await;
+        assert!(page.contains(TWO), "{page}");
+
+        // Spent: a path that is not kept is given the page without one…
+        let spent = previewed(&state, "/genre/DRAMA").await;
+        assert!(!spent.contains(TWO), "{spent}");
+        assert!(spent.contains("A catalogue."), "{spent}");
+        // … and what is kept is still served.
+        let page = previewed(&state, "/genre/drama").await;
+        assert!(page.contains(TWO), "{page}");
+    }
+
+    /// A page is kept under the catalogue's mark: a write made here, which
+    /// moves it, is seen at once; a note on the genre, which does not, within
+    /// the seconds a page is kept.
+    #[tokio::test]
+    async fn a_kept_preview_is_looked_up_again_once_the_catalogue_moves() {
+        let state = open_server(600).await;
+        two_dramas(&state).await;
+
+        let first = previewed(&state, "/genre/drama").await;
+        assert!(first.contains(TWO), "{first}");
+
+        repo::editorial::put(
+            &state.db,
+            Scope::Genre,
+            "drama",
+            "en",
+            "The *quiet* ones.",
+            None,
+        )
+        .await
+        .unwrap();
+        let kept = previewed(&state, "/genre/drama").await;
+        assert_eq!(kept, first, "kept: nothing was asked again");
+
+        state.caches.bump_epoch().await;
+        let fresh = previewed(&state, "/genre/drama").await;
+        assert!(fresh.contains("The quiet ones."), "{fresh}");
+        assert!(!fresh.contains(TWO), "{fresh}");
+    }
+
+    /// A path that names nothing is looked up each time, on its caller's
+    /// account, and is never the key of anything kept.
+    #[tokio::test]
+    async fn a_path_that_names_nothing_is_never_kept() {
+        let state = open_server(600).await;
+        let works = two_dramas(&state).await;
+
+        for path in [
+            "/genre/isekai",
+            "/network/nobody",
+            "/lists/no-such-list",
+            "/year/nineteen",
+            "/work/01a0d073-6ce8-760c-a2cb-113d4cf78503",
+        ] {
+            let page = previewed(&state, path).await;
+            assert!(page.contains("A catalogue."), "{path}: {page}");
+        }
+        assert_eq!(state.caches.previews.entries().await, 0);
+
+        // A work found is kept; the same work asked for under a path too long
+        // to be a page's is found, and is not kept under it.
+        let id = &works[0].id;
+        let page = previewed(&state, &format!("/work/{id}")).await;
+        assert!(page.contains("One · Cinémathèque"), "{page}");
+        assert_eq!(state.caches.previews.entries().await, 1);
+        let long = format!("/work/{id}/{}", "x".repeat(KEPT_PATH));
+        let page = previewed(&state, &long).await;
+        assert!(page.contains("One · Cinémathèque"), "{page}");
+        assert_eq!(state.caches.previews.entries().await, 1);
+    }
+
+    /// Where no reader is let in without a credential, there is no preview:
+    /// nothing is looked up, and nothing is counted.
+    #[tokio::test]
+    async fn no_preview_is_looked_up_where_a_reader_is_not_let_in() {
+        let state = crate::api::native::testing::server().await;
+        two_dramas(&state).await;
+        assert!(!open_to_readers(&state));
+        let page = previewed(&state, "/genre/drama").await;
+        assert!(!page.contains(TWO), "{page}");
+        assert_eq!(state.caches.previews.entries().await, 0);
+    }
 }
diff --git a/src/web.rs b/src/web.rs
index 36ca984..eeec052 100644
--- a/src/web.rs
+++ b/src/web.rs
@@ -38,10 +38,13 @@ const MAX_BODY_BYTES: usize = 1024 * 1024;
 
 pub async fn serve(state: AppState) -> Result<()> {
     let bind = state.config.server.bind;
-    let limits = listen::Limits {
-        connections: state.config.server.max_connections,
-        head: state.config.server.header_read_timeout,
-    };
+    let limits = listen::Limits::new(
+        state.config.server.max_connections,
+        state.config.server.header_read_timeout,
+    );
+    // How many connections each network holds at once, but for the proxies
+    // in front of the server, which hold those of everybody behind them.
+    let crowd = || listen::Crowd::new(&state.config.server.trusted_proxies);
 
     // The loops that run for as long as the process does, kept: one that
     // panics or returns when it should not is said so and started again.
@@ -138,7 +141,7 @@ pub async fn serve(state: AppState) -> Result<()> {
                 "listening for the clients (TLS)"
             );
             let served = listen::serve(
-                listen::Door::new(listener, limits.connections),
+                listen::Door::new(listener, limits.connections, crowd()),
                 Some(clients.rustls.clone()),
                 clients_router,
                 handle.clone(),
@@ -208,7 +211,7 @@ pub async fn serve(state: AppState) -> Result<()> {
     });
 
     listen::serve(
-        listen::Door::new(listener, limits.connections),
+        listen::Door::new(listener, limits.connections, crowd()),
         tls,
         router,
         handle,
@@ -399,8 +402,9 @@ fn build_router(state: AppState) -> Router {
         .route("/health", get(health))
         .route("/ready", get(ready))
         // The media kept, outside every guard: a key is a hash nobody
-        // guesses, and Sonarr fetches a poster with no credential.
-        .route("/media/{key}", get(crate::media::serve::get))
+        // guesses, and Sonarr fetches a poster with no credential. A copy
+        // made on demand counts against its caller, as the API does.
+        .route("/media/{key}", get(crate::media::serve::get_for_caller))
         // The authority the clients are to trust, and the script that makes
         // a container trust it: public, since a client fetches them before
         // it can trust anything.
@@ -800,9 +804,11 @@ struct Ready {
     tmdb_configured: bool,
 }
 
-/// Liveness is `/health`; this is readiness, and it touches the database.
+/// Liveness is `/health`; this is readiness, and it touches the database —
+/// once in a few seconds, however often it is asked, since anybody may ask
+/// under any name and with no credential.
 async fn ready(State(state): State) -> (StatusCode, Json) {
-    let database = state.db.health().await.is_ok();
+    let database = state.caches.database_answers(&state.db).await;
 
     let status = if database {
         StatusCode::OK
@@ -972,6 +978,54 @@ mod policy_tests {
     }
 }
 
+#[cfg(test)]
+mod ready_tests {
+    use axum::{body::Body, http::Request};
+    use tower::ServiceExt as _;
+
+    use super::*;
+
+    /// What `/ready` says, as the status and whether it says the database
+    /// answers.
+    async fn readiness(router: &Router) -> (StatusCode, bool) {
+        let answer = router
+            .clone()
+            .oneshot(Request::get("/ready").body(Body::empty()).unwrap())
+            .await
+            .unwrap();
+        let status = answer.status();
+        let body = axum::body::to_bytes(answer.into_body(), usize::MAX)
+            .await
+            .unwrap();
+        let said: serde_json::Value = serde_json::from_slice(&body).unwrap();
+        (status, said["database"] == true)
+    }
+
+    /// The database is asked once in a few seconds, however often `/ready` is:
+    /// what was said is said again while the database goes away, and a server
+    /// that has not been asked yet finds out.
+    #[tokio::test]
+    async fn readiness_asks_the_database_once_in_a_few_seconds() {
+        let state = crate::api::native::testing::server().await;
+        let router = build_router(state.clone());
+        assert_eq!(readiness(&router).await, (StatusCode::OK, true));
+
+        state.db.close().await;
+        assert_eq!(
+            readiness(&router).await,
+            (StatusCode::OK, true),
+            "kept: the database was not asked again"
+        );
+
+        let down = crate::api::native::testing::server().await;
+        down.db.close().await;
+        assert_eq!(
+            readiness(&build_router(down)).await,
+            (StatusCode::SERVICE_UNAVAILABLE, false)
+        );
+    }
+}
+
 #[cfg(test)]
 mod well_known_tests {
     use axum::{body::Body, http::Request};
diff --git a/src/web/listen.rs b/src/web/listen.rs
index 7d665d9..e39c7f3 100644
--- a/src/web/listen.rs
+++ b/src/web/listen.rs
@@ -11,37 +11,57 @@
 //! * a semaphore in the accept loop holds each door to `AMS_MAX_CONNECTIONS`:
 //!   past it, a connection waits in the system's queue, not accepted, until
 //!   another one closes;
+//! * no one network holds more than [`PER_PEER`] of them: a connection past
+//!   that is closed as it is accepted, so that one address cannot take every
+//!   place of the door — a trusted proxy's, and this machine's own, are not
+//!   counted, since everybody behind a proxy has its address;
 //! * hyper is given a clock, and with it `AMS_HEADER_READ_TIMEOUT` for a
 //!   request's head — the first, and each one a connection kept alive waits
 //!   for — and a head no larger than a head needs to be;
 //! * the bytes before that, which tell HTTP/2 from HTTP/1 and which hyper
 //!   does not time, are given the same;
-//! * an idle HTTP/2 connection is pinged, and closed when it stops answering.
+//! * an idle HTTP/2 connection is pinged, and closed when it stops answering —
+//!   and closed when it has had no request in flight for [`IDLE`] whatever it
+//!   answers, since a ping keeps a connection alive however little it is used;
+//! * a response that cannot be written because its peer does not read it is
+//!   given up on after [`WRITE_STALL`], and the connection with it.
 
 use std::{
+    collections::HashMap,
+    convert::Infallible,
     future::Future,
     io,
-    net::SocketAddr,
+    net::{IpAddr, SocketAddr},
     pin::Pin,
     sync::{
-        Arc, LazyLock,
-        atomic::{AtomicU64, Ordering},
+        Arc, LazyLock, Mutex, PoisonError,
+        atomic::{AtomicU64, AtomicUsize, Ordering},
     },
     task::{Context, Poll},
     time::{Duration, Instant},
 };
 
-use axum::{Router, extract::Request};
+use axum::{
+    Router,
+    extract::ConnectInfo,
+    http::{Request, Response},
+};
 use axum_server::{
     AddrListener, Address, Handle, Server,
     accept::{Accept, DefaultAcceptor},
     tls_rustls::{RustlsAcceptor, RustlsConfig},
 };
+use bytes::Bytes;
+use hyper::body::{Body as HttpBody, Frame, Incoming, SizeHint};
+use ipnet::IpNet;
 use tokio::{
     io::{AsyncRead, AsyncWrite, ReadBuf},
     net::{TcpListener, TcpStream},
     sync::{OwnedSemaphorePermit, Semaphore},
 };
+use tower::Service;
+
+use crate::auth::ip;
 
 /// What every door is held to.
 #[derive(Clone, Copy, Debug)]
@@ -50,12 +70,37 @@ pub struct Limits {
     pub connections: usize,
     /// How long a connection has to say what it wants.
     pub head: Duration,
+    /// How long a connection may have no request in flight.
+    pub idle: Duration,
+    /// How long a response may wait to be written to a peer that does not
+    /// read it.
+    pub write: Duration,
+}
+
+impl Limits {
+    /// The limits of a door holding `connections` at once, whose connections
+    /// have `head` to say what they want: idle no longer than that, nor than
+    /// [`IDLE`] — a head's time is what a connection kept alive was given to
+    /// sit idle in, and a longer one is the operator's word for it.
+    pub fn new(connections: usize, head: Duration) -> Self {
+        Self {
+            connections,
+            head,
+            idle: IDLE.max(head),
+            write: WRITE_STALL,
+        }
+    }
 }
 
 /// Connections a door bound by address alone holds; the configured doors
 /// are given theirs.
 const DEFAULT_CONNECTIONS: usize = 1024;
 
+/// The most connections one network holds at once — an address, or an IPv6
+/// /64, as the rate limit counts a caller. A browser holds a handful, a
+/// Sonarr or a Plex a few; a thousand and twenty-four is sixteen times this.
+const PER_PEER: usize = 64;
+
 /// The most a request's head may take in a connection's buffer. A head is
 /// a few kilobytes; hyper's default lets each connection hold four hundred
 /// before refusing one, which a door full of slow connections turns into
@@ -67,20 +112,162 @@ const HEAD_BUFFER: usize = 64 * 1024;
 const KEEP_ALIVE: Duration = Duration::from_secs(30);
 const KEEP_ALIVE_TIMEOUT: Duration = Duration::from_secs(20);
 
-/// The address a connection came from, as the doors hand it on.
-#[derive(Clone, Copy, Debug)]
-pub struct Peer(pub SocketAddr);
+/// How long a connection may have no request in flight before it is closed.
+/// Longer than the idle time most clients keep a connection for — a browser's
+/// minute or two, a proxy's — so that a client reuses a connection that is
+/// still open, not one being closed as it sends.
+const IDLE: Duration = Duration::from_secs(120);
+
+/// How long a response may wait to be written to a peer that does not read
+/// it — the time a send buffer may stay full, as a web server's own send
+/// timeout is — before it is given up on.
+const WRITE_STALL: Duration = Duration::from_secs(60);
+
+/// How long bytes must have stopped coming before an idle connection is
+/// closed, so that a request on its way is not met by the connection closing:
+/// the bytes of a request read are not yet a request in flight.
+const SETTLE: Duration = Duration::from_secs(1);
+
+/// The address a connection came from, as the doors hand it on, and what the
+/// connection is doing.
+#[derive(Clone, Debug)]
+pub struct Peer {
+    addr: SocketAddr,
+    life: Arc,
+}
 
 impl Address for Peer {
     type Stream = Held;
     type Listener = Door;
 }
 
-/// What the handlers read as `ConnectInfo`: the peer's address,
-/// as before.
-impl axum::extract::connect_info::Connected for SocketAddr {
-    fn connect_info(peer: Peer) -> Self {
-        peer.0
+/// How many connections each network holds at once, and the networks nobody
+/// counts.
+pub struct Crowd {
+    most: usize,
+    exempt: Vec,
+    holding: Mutex>,
+}
+
+impl Crowd {
+    /// Every network held to [`PER_PEER`] connections but the `proxies` —
+    /// which hold those of everybody behind them — and this machine.
+    pub fn new(proxies: &[IpNet]) -> Self {
+        let machine = ["127.0.0.0/8", "::1/128"]
+            .into_iter()
+            .filter_map(|network| network.parse::().ok());
+        Self::of(PER_PEER, proxies.iter().copied().chain(machine).collect())
+    }
+
+    fn of(most: usize, exempt: Vec) -> Self {
+        Self {
+            most: most.max(1),
+            exempt,
+            holding: Mutex::new(HashMap::new()),
+        }
+    }
+
+    /// A share for a connection from `peer` — or none, when its network
+    /// holds as many as it may.
+    fn admit(self: &Arc, peer: SocketAddr) -> Option {
+        if ip::is_trusted_peer(Some(peer), &self.exempt) {
+            return Some(Share {
+                crowd: self.clone(),
+                network: None,
+            });
+        }
+        let network = ip::bucket(peer.ip());
+        let mut holding = self.holding.lock().unwrap_or_else(PoisonError::into_inner);
+        let held = holding.entry(network).or_default();
+        if *held >= self.most {
+            return None;
+        }
+        *held += 1;
+        Some(Share {
+            crowd: self.clone(),
+            network: Some(network),
+        })
+    }
+
+    /// How many networks hold a connection now.
+    #[cfg(test)]
+    fn networks(&self) -> usize {
+        self.holding
+            .lock()
+            .unwrap_or_else(PoisonError::into_inner)
+            .len()
+    }
+}
+
+/// A connection's place in its network's count, given back when it closes.
+struct Share {
+    crowd: Arc,
+    /// The network it is counted under; none for one nobody counts.
+    network: Option,
+}
+
+impl Drop for Share {
+    fn drop(&mut self) {
+        let Some(network) = self.network else {
+            return;
+        };
+        let mut holding = self
+            .crowd
+            .holding
+            .lock()
+            .unwrap_or_else(PoisonError::into_inner);
+        if let Some(held) = holding.get_mut(&network) {
+            *held = held.saturating_sub(1);
+            if *held == 0 {
+                holding.remove(&network);
+            }
+        }
+    }
+}
+
+/// What a connection is doing: shared by the stream it is read from and
+/// written to, and the service its requests are given to.
+#[derive(Debug)]
+struct Life {
+    /// The requests begun and not yet done with: a response whose last byte
+    /// was written, or whose caller has gone.
+    open: AtomicUsize,
+    /// When the connection last had none, or began: milliseconds after
+    /// [`EPOCH`].
+    idle_since: AtomicU64,
+}
+
+impl Life {
+    fn new() -> Self {
+        Self {
+            open: AtomicUsize::new(0),
+            idle_since: AtomicU64::new(now_ms()),
+        }
+    }
+
+    /// A request begins.
+    fn begin(self: &Arc) -> Open {
+        self.open.fetch_add(1, Ordering::SeqCst);
+        Open(self.clone())
+    }
+
+    fn open(&self) -> usize {
+        self.open.load(Ordering::SeqCst)
+    }
+
+    fn idle_since(&self) -> u64 {
+        self.idle_since.load(Ordering::SeqCst)
+    }
+}
+
+/// A request in flight, until it is dropped.
+struct Open(Arc);
+
+impl Drop for Open {
+    fn drop(&mut self) {
+        if self.0.open.fetch_sub(1, Ordering::SeqCst) == 1 {
+            self.0.idle_since.store(now_ms(), Ordering::SeqCst);
+        }
     }
 }
 
@@ -89,34 +276,53 @@ pub struct Door {
     listener: TcpListener,
     connections: usize,
     places: Arc,
+    crowd: Arc,
     /// When the door last said it was full, in seconds after [`EPOCH`] plus
     /// one; nought for never.
     said_full: AtomicU64,
+    /// The same, for a network that holds as many as it may.
+    said_crowded: AtomicU64,
 }
 
 static EPOCH: LazyLock = LazyLock::new(Instant::now);
 
+/// Milliseconds since [`EPOCH`].
+fn now_ms() -> u64 {
+    u64::try_from(EPOCH.elapsed().as_millis()).unwrap_or(u64::MAX)
+}
+
+fn millis(time: Duration) -> u64 {
+    u64::try_from(time.as_millis()).unwrap_or(u64::MAX)
+}
+
 impl Door {
-    pub fn new(listener: TcpListener, connections: usize) -> Self {
+    pub fn new(listener: TcpListener, connections: usize, crowd: Crowd) -> Self {
         let connections = connections.max(1);
         Self {
             listener,
             connections,
             places: Arc::new(Semaphore::new(connections)),
+            crowd: Arc::new(crowd),
             said_full: AtomicU64::new(0),
+            said_crowded: AtomicU64::new(0),
         }
     }
+}
+
+/// Whether a thing that stays so a while — a door full, a network holding its
+/// share — is to be said now: once a minute at most.
+fn say(last: &AtomicU64) -> bool {
+    let now = EPOCH.elapsed().as_secs() + 1;
+    let before = last.load(Ordering::Relaxed);
+    (before == 0 || now.saturating_sub(before) >= 60)
+        && last
+            .compare_exchange(before, now, Ordering::Relaxed, Ordering::Relaxed)
+            .is_ok()
+}
 
-    /// Said once a minute at most: a door that is full stays full a while.
+impl Door {
     fn say_full(&self) {
-        let now = EPOCH.elapsed().as_secs() + 1;
-        let last = self.said_full.load(Ordering::Relaxed);
-        if (last == 0 || now.saturating_sub(last) >= 60)
-            && self
-                .said_full
-                .compare_exchange(last, now, Ordering::Relaxed, Ordering::Relaxed)
-                .is_ok()
-        {
+        if say(&self.said_full) {
             tracing::warn!(
                 connections = self.connections,
                 "a door holds as many connections as AMS_MAX_CONNECTIONS allows; new ones wait \
@@ -124,18 +330,31 @@ impl Door {
             );
         }
     }
+
+    fn say_crowded(&self, peer: SocketAddr) {
+        if say(&self.said_crowded) {
+            tracing::warn!(
+                %peer,
+                most = self.crowd.most,
+                "a network holds as many connections as one may; its new ones are closed"
+            );
+        }
+    }
 }
 
 impl AddrListener for Door {
     async fn bind_to(addr: Peer) -> io::Result {
         Ok(Self::new(
-            TcpListener::bind(addr.0).await?,
+            TcpListener::bind(addr.addr).await?,
             DEFAULT_CONNECTIONS,
+            Crowd::new(&[]),
         ))
     }
 
     /// A place first, then the connection: past the limit nothing more is
-    /// accepted, so nothing more holds a descriptor.
+    /// accepted, so nothing more holds a descriptor. A connection whose
+    /// network holds its share already is closed as it is accepted, and the
+    /// place goes to the next.
     async fn accept_stream(&self) -> io::Result<(Held, Peer)> {
         let place = match self.places.clone().try_acquire_owned() {
             Ok(place) => place,
@@ -148,25 +367,41 @@ impl AddrListener for Door {
                     .map_err(io::Error::other)?
             }
         };
-        let (stream, addr) = self.listener.accept().await?;
-        Ok((
-            Held {
-                stream,
-                _place: place,
-            },
-            Peer(addr),
-        ))
+        loop {
+            let (stream, addr) = self.listener.accept().await?;
+            let Some(share) = self.crowd.admit(addr) else {
+                self.say_crowded(addr);
+                drop(stream);
+                continue;
+            };
+            let life = Arc::new(Life::new());
+            return Ok((
+                Held {
+                    stream,
+                    life: life.clone(),
+                    _place: place,
+                    _share: share,
+                },
+                Peer { addr, life },
+            ));
+        }
     }
 
     fn get_local_addr(&self) -> io::Result {
-        self.listener.local_addr().map(Peer)
+        self.listener.local_addr().map(|addr| Peer {
+            addr,
+            life: Arc::new(Life::new()),
+        })
     }
 }
 
-/// An accepted connection, holding its place at the door until it closes.
+/// An accepted connection, holding its place at the door and in its network's
+/// count until it closes.
 pub struct Held {
     stream: TcpStream,
+    life: Arc,
     _place: OwnedSemaphorePermit,
+    _share: Share,
 }
 
 impl AsyncRead for Held {
@@ -209,31 +444,44 @@ impl AsyncWrite for Held {
     }
 }
 
+/// How long a connection may be idle, and how long a write may stall.
+#[derive(Clone, Copy, Debug)]
+struct Tempo {
+    idle: Duration,
+    write: Duration,
+}
+
 /// Gives a connection's first bytes — after TLS, where there is any — the
-/// time a request's head is given.
+/// time a request's head is given, and watches it from then on
+/// ([`Watched`]).
 #[derive(Clone)]
 pub struct Prompt {
     inner: A,
     within: Duration,
+    tempo: Tempo,
 }
 
-impl Accept for Prompt
+impl Accept for Prompt
 where
-    A: Accept,
+    A: Accept,
     A::Future: Send + 'static,
     A::Stream: Send + 'static,
     A::Service: Send + 'static,
 {
-    type Stream = Timed;
+    type Stream = Watched>;
     type Service = A::Service;
     type Future = Pin> + Send>>;
 
-    fn accept(&self, stream: I, service: S) -> Self::Future {
+    fn accept(&self, stream: Held, service: S) -> Self::Future {
+        let life = stream.life.clone();
         let accepted = self.inner.accept(stream, service);
-        let within = self.within;
+        let (within, tempo) = (self.within, self.tempo);
         Box::pin(async move {
             let (stream, service) = accepted.await?;
-            Ok((Timed::new(stream, within), service))
+            Ok((
+                Watched::new(Timed::new(stream, within), life, tempo),
+                service,
+            ))
         })
     }
 }
@@ -341,6 +589,280 @@ impl AsyncWrite for Timed {
     }
 }
 
+/// A connection watched for what hyper's own timers do not see.
+///
+/// *Idle.* An HTTP/2 connection is pinged while it has nothing to say, and a
+/// peer that answers the pings is a peer that is there: kept for ever,
+/// whatever it asks, and so are those of a client that asks for something
+/// once a minute. One with no request in flight for [`Tempo::idle`] is
+/// closed — read to its end, as if its peer had gone — unless bytes are still
+/// coming in, which it is given a moment to finish.
+///
+/// *Stalled writes.* A response a peer does not read fills the send buffer,
+/// and what hyper writes waits for room for ever. A write that makes no
+/// headway for [`Tempo::write`] fails, and the connection with it.
+pub struct Watched {
+    inner: S,
+    life: Arc,
+    tempo: Tempo,
+    /// Wakes a read that has nothing to read when the connection may be due
+    /// to close.
+    idle: Pin>,
+    /// The moment `idle` is set to wake, in milliseconds after [`EPOCH`].
+    armed_for: u64,
+    /// When bytes were last read, in milliseconds after [`EPOCH`].
+    read_at: u64,
+    /// Armed while a write cannot go through.
+    stalled: Option>>,
+}
+
+/// When an idle connection is to be closed, in milliseconds after
+/// [`EPOCH`]: once it has had no request in flight for `idle` — from `since`
+/// — and bytes have stopped coming for [`SETTLE`] (a quarter of `idle`, where
+/// that is less) since `read_at`; but not later than twice `idle` from
+/// `since`, whatever arrives, since a peer that sends something every second
+/// is no peer that wants an answer.
+fn closes_at(since: u64, read_at: u64, idle: Duration) -> u64 {
+    let idle = millis(idle);
+    let settle = millis(SETTLE).min(idle / 4);
+    let earliest = since.saturating_add(idle);
+    let latest = since.saturating_add(idle.saturating_mul(2));
+    earliest.max(read_at.saturating_add(settle).min(latest))
+}
+
+impl Watched {
+    fn new(inner: S, life: Arc, tempo: Tempo) -> Self {
+        Self {
+            inner,
+            life,
+            tempo,
+            idle: Box::pin(tokio::time::sleep(tempo.idle)),
+            armed_for: now_ms().saturating_add(millis(tempo.idle)),
+            read_at: now_ms(),
+            stalled: None,
+        }
+    }
+
+    /// Whether the connection is to be closed now; where it is not, the timer
+    /// that wakes the read when it may be is set, and has the task's waker.
+    fn closes_idle(&mut self, cx: &mut Context<'_>) -> bool {
+        loop {
+            let now = now_ms();
+            let busy = self.life.open() > 0;
+            let at = if busy {
+                // Looked at again after an idle time: what is asked of it
+                // ends, and the connection is idle from then.
+                now.saturating_add(millis(self.tempo.idle))
+            } else {
+                closes_at(self.life.idle_since(), self.read_at, self.tempo.idle)
+            };
+            if !busy && now >= at {
+                return true;
+            }
+            // Set again only where it would wake the read too late, or has
+            // gone off: a busy connection finds nothing to read after every
+            // batch of bytes, and a timer moved each time is work for nothing.
+            // One that wakes it too soon only has it look again.
+            if self.armed_for <= now || self.armed_for > at {
+                let wake = tokio::time::Instant::now() + Duration::from_millis(at - now);
+                self.idle.as_mut().reset(wake);
+                self.armed_for = at;
+            }
+            if self.idle.as_mut().poll(cx).is_pending() {
+                return false;
+            }
+            // Gone off: looked at again, and set for what is then due.
+            self.armed_for = 0;
+        }
+    }
+
+    /// A write's answer, given up on if it has been pending for as long as
+    /// writes may stall.
+    fn watch(
+        &mut self,
+        cx: &mut Context<'_>,
+        polled: Poll>,
+    ) -> Poll> {
+        match polled {
+            Poll::Pending => {
+                let within = self.tempo.write;
+                let stalled = self
+                    .stalled
+                    .get_or_insert_with(|| Box::pin(tokio::time::sleep(within)));
+                if stalled.as_mut().poll(cx).is_ready() {
+                    self.stalled = None;
+                    return Poll::Ready(Err(io::Error::new(
+                        io::ErrorKind::TimedOut,
+                        "the peer did not read what it was sent in time",
+                    )));
+                }
+                Poll::Pending
+            }
+            ready => {
+                self.stalled = None;
+                ready
+            }
+        }
+    }
+}
+
+impl AsyncRead for Watched {
+    fn poll_read(
+        self: Pin<&mut Self>,
+        cx: &mut Context<'_>,
+        buf: &mut ReadBuf<'_>,
+    ) -> Poll> {
+        let this = self.get_mut();
+        let before = buf.filled().len();
+        match Pin::new(&mut this.inner).poll_read(cx, buf) {
+            Poll::Ready(Ok(())) => {
+                if buf.filled().len() > before {
+                    this.read_at = now_ms();
+                }
+                Poll::Ready(Ok(()))
+            }
+            Poll::Pending => {
+                if this.closes_idle(cx) {
+                    // The end of what the peer sent: hyper takes the peer
+                    // for gone, and closes the connection.
+                    return Poll::Ready(Ok(()));
+                }
+                Poll::Pending
+            }
+            failed => failed,
+        }
+    }
+}
+
+impl AsyncWrite for Watched {
+    fn poll_write(
+        self: Pin<&mut Self>,
+        cx: &mut Context<'_>,
+        buf: &[u8],
+    ) -> Poll> {
+        let this = self.get_mut();
+        let polled = Pin::new(&mut this.inner).poll_write(cx, buf);
+        this.watch(cx, polled)
+    }
+
+    fn poll_write_vectored(
+        self: Pin<&mut Self>,
+        cx: &mut Context<'_>,
+        bufs: &[io::IoSlice<'_>],
+    ) -> Poll> {
+        let this = self.get_mut();
+        let polled = Pin::new(&mut this.inner).poll_write_vectored(cx, bufs);
+        this.watch(cx, polled)
+    }
+
+    fn is_write_vectored(&self) -> bool {
+        self.inner.is_write_vectored()
+    }
+
+    fn poll_flush(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> {
+        let this = self.get_mut();
+        let polled = Pin::new(&mut this.inner).poll_flush(cx);
+        this.watch(cx, polled)
+    }
+
+    fn poll_shutdown(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> {
+        let this = self.get_mut();
+        let polled = Pin::new(&mut this.inner).poll_shutdown(cx);
+        this.watch(cx, polled)
+    }
+}
+
+/// What the requests of the connections of a door are served by: made for
+/// each connection, with the address it came from and what it is doing.
+#[derive(Clone)]
+struct Connections {
+    router: Router,
+}
+
+impl Service for Connections {
+    type Response = Tracked;
+    type Error = Infallible;
+    type Future = std::future::Ready>;
+
+    fn poll_ready(&mut self, _: &mut Context<'_>) -> Poll> {
+        Poll::Ready(Ok(()))
+    }
+
+    fn call(&mut self, peer: Peer) -> Self::Future {
+        std::future::ready(Ok(Tracked {
+            router: self.router.clone(),
+            addr: peer.addr,
+            life: peer.life,
+        }))
+    }
+}
+
+/// The router, serving one connection's requests: each told the address the
+/// connection came from — as `ConnectInfo`, which is what the
+/// handlers read — and counted among those in flight until its response's
+/// last byte is written.
+#[derive(Clone)]
+struct Tracked {
+    router: Router,
+    addr: SocketAddr,
+    life: Arc,
+}
+
+impl Service> for Tracked {
+    type Response = Response;
+    type Error = Infallible;
+    type Future = Pin> + Send>>;
+
+    fn poll_ready(&mut self, _: &mut Context<'_>) -> Poll> {
+        Poll::Ready(Ok(()))
+    }
+
+    fn call(&mut self, mut request: Request) -> Self::Future {
+        request.extensions_mut().insert(ConnectInfo(self.addr));
+        // Counted from here, and given to the response's body below: a
+        // request whose future is dropped — its caller gone — is no longer
+        // in flight either.
+        let open = self.life.begin();
+        let mut router = self.router.clone();
+        Box::pin(async move {
+            let response = match router.call(request).await {
+                Ok(response) => response,
+                Err(never) => match never {},
+            };
+            Ok(response.map(|body| Counted { body, _open: open }))
+        })
+    }
+}
+
+/// A response's body, keeping its request among those in flight until it is
+/// dropped — written to its end, or its caller gone. Everything else of the
+/// body is the body's: its frames, whether it is at its end, how large it is
+/// (hyper writes a `Content-Length` of it).
+struct Counted {
+    body: axum::body::Body,
+    _open: Open,
+}
+
+impl HttpBody for Counted {
+    type Data = Bytes;
+    type Error = axum::Error;
+
+    fn poll_frame(
+        mut self: Pin<&mut Self>,
+        cx: &mut Context<'_>,
+    ) -> Poll, axum::Error>>> {
+        Pin::new(&mut self.body).poll_frame(cx)
+    }
+
+    fn is_end_stream(&self) -> bool {
+        self.body.is_end_stream()
+    }
+
+    fn size_hint(&self) -> SizeHint {
+        self.body.size_hint()
+    }
+}
+
 /// hyper's clock, kept by tokio: what lets hyper time a connection out.
 #[derive(Clone, Copy, Debug)]
 struct Clock;
@@ -376,14 +898,18 @@ pub async fn serve(
     handle: Handle,
     limits: Limits,
 ) -> io::Result<()> {
-    let service =
-        axum::ServiceExt::::into_make_service_with_connect_info::(router);
+    let service = Connections { router };
+    let tempo = Tempo {
+        idle: limits.idle,
+        write: limits.write,
+    };
     match tls {
         Some(config) => {
             let mut server = Server::::from_listener(door)
                 .acceptor(Prompt {
                     inner: RustlsAcceptor::new(config),
                     within: limits.head,
+                    tempo,
                 })
                 .handle(handle);
             tune(&mut server, limits);
@@ -394,6 +920,7 @@ pub async fn serve(
                 .acceptor(Prompt {
                     inner: DefaultAcceptor::new(),
                     within: limits.head,
+                    tempo,
                 })
                 .handle(handle);
             tune(&mut server, limits);
@@ -423,14 +950,51 @@ mod tests {
     use super::*;
     use tokio::io::{AsyncReadExt, AsyncWriteExt};
 
-    /// A door serving a single route, with the limits given; its address.
-    async fn door(limits: Limits) -> (SocketAddr, Handle) {
+    impl Crowd {
+        /// Every network counted, this machine's too: what a test of the
+        /// count runs on, over loopback.
+        fn counting_all(most: usize) -> Self {
+            Self::of(most, Vec::new())
+        }
+    }
+
+    /// The limits a door of these tests has: generous, but for what a test
+    /// narrows.
+    fn limits() -> Limits {
+        Limits {
+            connections: 8,
+            head: Duration::from_secs(30),
+            idle: Duration::from_secs(60),
+            write: Duration::from_secs(60),
+        }
+    }
+
+    /// A door serving a few routes, with the limits given, its network
+    /// counted as the crowd says; its address.
+    async fn door_with(limits: Limits, crowd: Crowd) -> (SocketAddr, Handle) {
         let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
         let addr = listener.local_addr().unwrap();
-        let router = Router::new().route("/", axum::routing::get(|| async { "here" }));
+        let router = Router::new()
+            .route("/", axum::routing::get(|| async { "here" }))
+            .route(
+                "/slow",
+                axum::routing::get(|| async {
+                    tokio::time::sleep(Duration::from_millis(900)).await;
+                    "slowly"
+                }),
+            )
+            .route(
+                "/endless",
+                axum::routing::get(|| async {
+                    let chunks = futures::stream::repeat_with(|| {
+                        Ok::<_, io::Error>(Bytes::from(vec![0u8; 64 * 1024]))
+                    });
+                    axum::body::Body::from_stream(chunks)
+                }),
+            );
         let handle = Handle::new();
         tokio::spawn(serve(
-            Door::new(listener, limits.connections),
+            Door::new(listener, limits.connections, crowd),
             None,
             router,
             handle.clone(),
@@ -439,6 +1003,10 @@ mod tests {
         (addr, handle)
     }
 
+    async fn door(limits: Limits) -> (SocketAddr, Handle) {
+        door_with(limits, Crowd::new(&[])).await
+    }
+
     async fn ask(addr: SocketAddr) -> String {
         let mut stream = TcpStream::connect(addr).await.unwrap();
         stream
@@ -456,11 +1024,31 @@ mod tests {
         (head.lines().next().unwrap_or_default(), body)
     }
 
+    /// Whether the door has closed a connection: its end is read within
+    /// `within`, and nothing else is.
+    async fn closed_within(stream: &mut TcpStream, within: Duration) -> bool {
+        let mut rest = Vec::new();
+        matches!(
+            tokio::time::timeout(within, stream.read_to_end(&mut rest)).await,
+            Ok(Ok(_) | Err(_))
+        )
+    }
+
+    /// Whether the door leaves a connection open for `for_`: nothing is read
+    /// of it, and it is not closed.
+    async fn open_for(stream: &mut TcpStream, for_: Duration) -> bool {
+        let mut byte = [0u8; 1];
+        tokio::time::timeout(for_, stream.read(&mut byte))
+            .await
+            .is_err()
+    }
+
     #[tokio::test]
     async fn a_door_answers_through_its_limits() {
         let (addr, handle) = door(Limits {
             connections: 4,
             head: Duration::from_secs(5),
+            ..limits()
         })
         .await;
         let answer = ask(addr).await;
@@ -473,6 +1061,7 @@ mod tests {
         let (addr, handle) = door(Limits {
             connections: 4,
             head: Duration::from_millis(200),
+            ..limits()
         })
         .await;
 
@@ -516,6 +1105,7 @@ mod tests {
         let (addr, handle) = door(Limits {
             connections: 1,
             head: Duration::from_secs(30),
+            ..limits()
         })
         .await;
 
@@ -557,4 +1147,366 @@ mod tests {
         nothing.saw(b"");
         assert!(nothing.deadline.is_some());
     }
+
+    // ── the count of a network's connections ────────────────────────────
+
+    fn peer(address: &str) -> SocketAddr {
+        format!("{address}:40000").parse().unwrap()
+    }
+
+    #[test]
+    fn a_network_holds_so_many_connections_and_gives_a_share_back() {
+        let crowd = Arc::new(Crowd::counting_all(2));
+        let first = crowd.admit(peer("203.0.113.5")).expect("a share");
+        let _second = crowd.admit(peer("203.0.113.5")).expect("a share");
+        assert!(
+            crowd.admit(peer("203.0.113.5")).is_none(),
+            "a third is more than one network may hold"
+        );
+        // Another address has its own.
+        let _other = crowd.admit(peer("203.0.113.6")).expect("a share");
+        assert_eq!(crowd.networks(), 2);
+
+        // A share given back is the next connection's.
+        drop(first);
+        let _third = crowd
+            .admit(peer("203.0.113.5"))
+            .expect("the share given back");
+        assert!(crowd.admit(peer("203.0.113.5")).is_none());
+    }
+
+    #[test]
+    fn a_count_does_not_outlive_its_connections() {
+        let crowd = Arc::new(Crowd::counting_all(3));
+        let shares: Vec<_> = (0..3)
+            .map(|_| crowd.admit(peer("198.51.100.7")).expect("a share"))
+            .collect();
+        assert_eq!(crowd.networks(), 1);
+        drop(shares);
+        assert_eq!(crowd.networks(), 0, "nothing is kept of a network gone");
+    }
+
+    /// One machine has a whole /64 to pick its addresses from: they are one
+    /// network, as the rate limit counts them.
+    #[test]
+    fn an_ipv6_network_holds_one_count_between_its_addresses() {
+        let crowd = Arc::new(Crowd::counting_all(2));
+        let _a = crowd.admit(peer("[2001:db8:1:2::1]")).expect("a share");
+        let _b = crowd
+            .admit(peer("[2001:db8:1:2:ffff::9]"))
+            .expect("a share");
+        assert!(crowd.admit(peer("[2001:db8:1:2::3]")).is_none());
+        assert!(crowd.admit(peer("[2001:db8:1:3::1]")).is_some());
+
+        // An IPv4 client of a dual-stack socket is its IPv4 address.
+        let _c = crowd.admit(peer("[::ffff:192.0.2.1]")).expect("a share");
+        let _d = crowd.admit(peer("192.0.2.1")).expect("a share");
+        assert!(crowd.admit(peer("[::ffff:192.0.2.1]")).is_none());
+    }
+
+    /// Everybody behind a proxy has its address, and this machine's own are
+    /// the operator's: neither is counted.
+    #[test]
+    fn a_trusted_proxy_and_this_machine_are_not_counted() {
+        let proxies: Vec = vec!["10.0.0.0/8".parse().unwrap(), "fd00::/16".parse().unwrap()];
+        let mut crowd = Crowd::new(&proxies);
+        crowd.most = 1;
+        let crowd = Arc::new(crowd);
+        let mut held = Vec::new();
+        for address in [
+            "10.1.2.3",
+            "[fd00::7]",
+            "127.0.0.1",
+            "[::1]",
+            "[::ffff:127.0.0.1]",
+        ] {
+            for _ in 0..5 {
+                held.push(
+                    crowd
+                        .admit(peer(address))
+                        .unwrap_or_else(|| panic!("{address} is counted")),
+                );
+            }
+        }
+        assert_eq!(crowd.networks(), 0);
+
+        // Any other is, and holds the one.
+        let _only = crowd.admit(peer("203.0.113.5")).expect("a share");
+        assert!(crowd.admit(peer("203.0.113.5")).is_none());
+        // The standard crowd leaves this machine out and counts the rest.
+        let standard = Arc::new(Crowd::new(&[]));
+        let counted: Vec<_> = (0..PER_PEER)
+            .map(|_| standard.admit(peer("203.0.113.5")).expect("a share"))
+            .collect();
+        assert!(standard.admit(peer("203.0.113.5")).is_none());
+        drop(counted);
+    }
+
+    #[tokio::test]
+    async fn a_network_past_its_share_is_closed_and_the_others_are_answered() {
+        let (addr, handle) = door_with(limits(), Crowd::counting_all(2)).await;
+
+        // Two connections, as many as this network may hold: idle, open.
+        let mut first = TcpStream::connect(addr).await.unwrap();
+        let mut second = TcpStream::connect(addr).await.unwrap();
+        tokio::time::sleep(Duration::from_millis(100)).await;
+
+        // The third is accepted, and closed with no word.
+        let mut third = TcpStream::connect(addr).await.unwrap();
+        assert!(
+            closed_within(&mut third, Duration::from_secs(5)).await,
+            "the door should have closed a third connection of one network"
+        );
+        // The two it holds are left as they were.
+        assert!(open_for(&mut first, Duration::from_millis(200)).await);
+        assert!(open_for(&mut second, Duration::from_millis(200)).await);
+
+        // One given back, the next is answered.
+        drop(first);
+        tokio::time::sleep(Duration::from_millis(100)).await;
+        let answer = tokio::time::timeout(Duration::from_secs(5), ask(addr))
+            .await
+            .expect("answered");
+        assert_eq!(parts(&answer).0, "HTTP/1.1 200 OK");
+        handle.shutdown();
+    }
+
+    // ── a connection with nothing in flight ─────────────────────────────
+
+    #[test]
+    fn an_idle_connection_is_closed_once_it_is_quiet_and_never_later_than_twice_its_time() {
+        let idle = Duration::from_secs(120);
+        // Quiet: closed when its idle time is up.
+        assert_eq!(closes_at(0, 0, idle), 120_000);
+        // Bytes read just before: a moment more, for what is on its way.
+        assert_eq!(closes_at(0, 119_900, idle), 120_900);
+        // Bytes read all the time: not later than twice the time.
+        assert_eq!(closes_at(0, 239_999, idle), 240_000);
+        assert_eq!(closes_at(1_000, 10_000_000, idle), 241_000);
+        // A short time settles in a quarter of it.
+        assert_eq!(closes_at(0, 100, Duration::from_millis(400)), 400);
+        assert_eq!(closes_at(0, 350, Duration::from_millis(400)), 450);
+    }
+
+    /// What HTTP/2 opens with and says next, as an idle client sends it: the
+    /// preface and an empty SETTINGS frame, and a PING.
+    fn h2_hello() -> Vec {
+        let mut bytes = PREFACE.to_vec();
+        bytes.extend_from_slice(&[0, 0, 0, 4, 0, 0, 0, 0, 0]);
+        bytes
+    }
+
+    fn h2_ping() -> [u8; 17] {
+        let mut ping = [0u8; 17];
+        ping[2] = 8;
+        ping[3] = 6;
+        ping
+    }
+
+    /// An idle HTTP/2 connection is closed whatever it answers — here, a
+    /// client that pings every fifty milliseconds — within twice the idle
+    /// time.
+    #[tokio::test]
+    async fn an_h2_connection_with_no_request_is_closed_though_it_pings() {
+        let (addr, handle) = door(Limits {
+            idle: Duration::from_millis(300),
+            ..limits()
+        })
+        .await;
+        let mut stream = TcpStream::connect(addr).await.unwrap();
+        stream.write_all(&h2_hello()).await.unwrap();
+
+        let started = Instant::now();
+        let mut buf = [0u8; 4096];
+        let mut closed_after = None;
+        while started.elapsed() < Duration::from_secs(10) {
+            if stream.write_all(&h2_ping()).await.is_err() {
+                closed_after = Some(started.elapsed());
+                break;
+            }
+            if let Ok(Ok(0) | Err(_)) =
+                tokio::time::timeout(Duration::from_millis(50), stream.read(&mut buf)).await
+            {
+                closed_after = Some(started.elapsed());
+                break;
+            }
+        }
+        let closed_after = closed_after.expect("the door should have closed the connection");
+        assert!(
+            closed_after >= Duration::from_millis(250),
+            "closed after {closed_after:?}, before its idle time"
+        );
+        assert!(
+            closed_after <= Duration::from_secs(5),
+            "closed after {closed_after:?}"
+        );
+        handle.shutdown();
+    }
+
+    /// An HTTP/1 connection kept alive between two requests is closed once
+    /// it has had none in flight for the idle time — here, shorter than the
+    /// head's.
+    #[tokio::test]
+    async fn an_h1_connection_with_no_request_is_closed_after_its_idle_time() {
+        let (addr, handle) = door(Limits {
+            idle: Duration::from_millis(800),
+            ..limits()
+        })
+        .await;
+        let mut stream = TcpStream::connect(addr).await.unwrap();
+        stream
+            .write_all(b"GET / HTTP/1.1\r\nHost: x\r\n\r\n")
+            .await
+            .unwrap();
+        let mut buf = [0u8; 1024];
+        let read = stream.read(&mut buf).await.unwrap();
+        assert!(String::from_utf8_lossy(&buf[..read]).starts_with("HTTP/1.1 200 OK"));
+
+        // Kept alive, and then closed.
+        assert!(open_for(&mut stream, Duration::from_millis(150)).await);
+        assert!(
+            closed_within(&mut stream, Duration::from_secs(3)).await,
+            "the door should have closed the idle connection"
+        );
+        handle.shutdown();
+    }
+
+    /// A connection with a request in flight is not idle, however long the
+    /// request takes: the answer comes, and the connection is closed after
+    /// it, once the idle time has passed.
+    #[tokio::test]
+    async fn a_request_in_flight_keeps_its_connection_open() {
+        let (addr, handle) = door(Limits {
+            idle: Duration::from_millis(300),
+            ..limits()
+        })
+        .await;
+        let mut stream = TcpStream::connect(addr).await.unwrap();
+        stream
+            .write_all(b"GET /slow HTTP/1.1\r\nHost: x\r\n\r\n")
+            .await
+            .unwrap();
+
+        // The handler takes three times the idle time to answer.
+        let mut answer = Vec::new();
+        let mut buf = [0u8; 1024];
+        while !String::from_utf8_lossy(&answer).contains("slowly") {
+            let read = tokio::time::timeout(Duration::from_secs(5), stream.read(&mut buf))
+                .await
+                .expect("an answer")
+                .expect("read");
+            assert!(read > 0, "closed before it was answered");
+            answer.extend_from_slice(&buf[..read]);
+        }
+        assert!(String::from_utf8_lossy(&answer).starts_with("HTTP/1.1 200 OK"));
+
+        // Nothing in flight now: closed once it has been idle.
+        assert!(
+            closed_within(&mut stream, Duration::from_secs(3)).await,
+            "the door should have closed the connection after its answer"
+        );
+        handle.shutdown();
+    }
+
+    // ── a response nobody reads ─────────────────────────────────────────
+
+    /// A response a peer does not read is given up on once a write has made
+    /// no headway for the write time, and the connection with it.
+    #[tokio::test]
+    async fn a_response_nobody_reads_is_given_up_on() {
+        let (addr, handle) = door(Limits {
+            write: Duration::from_millis(300),
+            ..limits()
+        })
+        .await;
+        let mut stream = TcpStream::connect(addr).await.unwrap();
+        stream
+            .write_all(b"GET /endless HTTP/1.1\r\nHost: x\r\n\r\n")
+            .await
+            .unwrap();
+
+        // Nothing is read while the send buffers fill and the write stalls.
+        tokio::time::sleep(Duration::from_millis(1200)).await;
+
+        // What was written before is there; and then the end, long before
+        // an endless body's.
+        let mut got = 0usize;
+        let mut buf = vec![0u8; 256 * 1024];
+        let ended = tokio::time::timeout(Duration::from_secs(10), async {
+            loop {
+                match stream.read(&mut buf).await {
+                    Ok(0) | Err(_) => return,
+                    Ok(read) => got += read,
+                }
+            }
+        })
+        .await;
+        assert!(
+            ended.is_ok(),
+            "the door should have given up on the response"
+        );
+        assert!(
+            got < 64 * 1024 * 1024,
+            "read {got} bytes of an endless body"
+        );
+        handle.shutdown();
+    }
+
+    /// A peer that reads what it is sent is not stalled, however slowly it
+    /// asks for the rest: the clock runs only while a write cannot go through.
+    #[tokio::test]
+    async fn a_response_that_is_read_is_not_given_up_on() {
+        let (addr, handle) = door(Limits {
+            write: Duration::from_secs(1),
+            ..limits()
+        })
+        .await;
+        let mut stream = TcpStream::connect(addr).await.unwrap();
+        stream
+            .write_all(b"GET /endless HTTP/1.1\r\nHost: x\r\n\r\n")
+            .await
+            .unwrap();
+        let mut buf = vec![0u8; 256 * 1024];
+        let mut got = 0usize;
+        let started = Instant::now();
+        while started.elapsed() < Duration::from_millis(2500) {
+            let read = tokio::time::timeout(Duration::from_secs(5), stream.read(&mut buf))
+                .await
+                .expect("bytes keep coming")
+                .expect("read");
+            assert!(read > 0, "the door closed a connection that was reading");
+            got += read;
+        }
+        assert!(got > 1024 * 1024, "read {got} bytes");
+        handle.shutdown();
+    }
+
+    /// The service's requests count as in flight until the response's last
+    /// byte, and the body is the router's own: its length is still told.
+    #[tokio::test]
+    async fn the_body_keeps_its_length_and_its_request_counts_until_it_is_dropped() {
+        let life = Arc::new(Life::new());
+        let open = life.begin();
+        assert_eq!(life.open(), 1);
+        let counted = Counted {
+            body: axum::body::Body::from("twelve bytes"),
+            _open: open,
+        };
+        assert_eq!(counted.size_hint().exact(), Some(12));
+        assert!(!counted.is_end_stream());
+        assert_eq!(life.open(), 1);
+        let before = life.idle_since();
+        tokio::time::sleep(Duration::from_millis(20)).await;
+        drop(counted);
+        assert_eq!(life.open(), 0);
+        assert!(life.idle_since() > before, "idle from the moment it ended");
+
+        let empty = Counted {
+            body: axum::body::Body::empty(),
+            _open: life.begin(),
+        };
+        assert!(empty.is_end_stream());
+        drop(empty);
+        assert_eq!(life.open(), 0);
+    }
 }