diff --git a/.env.example b/.env.example index 34896c8c..28b3a5a8 100644 --- a/.env.example +++ b/.env.example @@ -33,12 +33,22 @@ IMAGE_TAG=latest # Development (SSL disabled) DATABASE_URL=postgres://tracker:tracker@localhost:5432/trackarr -# Production (SSL enforced - sslmode=require automatically added in nuxt.config.ts) -# DATABASE_URL=postgres://tracker:STRONG_PASSWORD@pgbouncer:6432/trackarr +# En production, `docker-compose.prod.yml` construit `DATABASE_URL` lui-même à +# partir de DB_USER / DB_PASSWORD / DB_NAME : ne pas la renseigner ici. +# +# Ce bloc annonçait « sslmode=require automatically added in nuxt.config.ts ». +# Deux erreurs : le calcul est dans `apps/api/nitro.config.ts`, et la valeur +# qu'il produit (`runtimeConfig.databaseUrl`) N'EST LUE NULLE PART — +# `packages/db/src/index.ts` lit `process.env.DATABASE_URL` directement. Rien +# n'ajoute donc `sslmode=require` automatiquement. Sur le réseau interne de +# Docker, TLS est délibérément coupé (`-c ssl=off` côté Postgres, +# `sslmode=disable` dans la DSN) ; pour une base externe, mettre `DB_SSL=true` +# et l'écrire dans la DSN soi-même. # Production database settings DB_USER=tracker -# ⚠️ PRODUCTION: Generate with: openssl rand -base64 32 +# ⚠️ PRODUCTION: Generate with: openssl rand -hex 32 +# (hex, pas base64 : un « / » dans le mot de passe casse la DSN) # Leave empty and let the stack refuse to start rather than ship a guessable # default. Generate one with the command block at the bottom of this file. DB_PASSWORD= @@ -49,9 +59,15 @@ DB_PORT=5432 DB_POOL_MAX=20 # SSL/TLS for database connections -# Development: SSL disabled for local testing -# Production: SSL automatically enforced (see documentation/ssl-setup.md) -# Generate certificates with: ./scripts/generate-ssl-certs.sh +# +# Coupé par défaut, y compris en production : la base n'est joignable que depuis +# le réseau interne de Docker et n'est pas publiée. Pour une base EXTERNE ou +# gérée, mettre `DB_SSL=true` et ajouter `?sslmode=require` à la DSN. +# +# (Les deux renvois qui figuraient ici — `documentation/ssl-setup.md` et +# `./scripts/generate-ssl-certs.sh` — ne correspondent à aucun fichier du +# dépôt.) +DB_SSL=false # Debug mode (never enable in production) DB_DEBUG=false @@ -214,10 +230,28 @@ MESSAGING_SERVICE_URL=/messaging # echo "NUXT_SESSION_SECRET=$(openssl rand -hex 32)" >> .env # echo "ADMIN_API_KEY=$(openssl rand -hex 32)" >> .env # echo "IP_HASH_SECRET=$(openssl rand -hex 32)" >> .env -# echo "DB_PASSWORD=$(openssl rand -base64 24)" >> .env -# echo "REDIS_PASSWORD=$(openssl rand -base64 24)" >> .env +# `-hex`, pas `-base64` : l'alphabet base64 contient « / », qui termine +# l'autorité d'une URL. Le mot de passe est injecté dans +# `postgres://tracker:@pgbouncer:6432/…`, donc deux mots de passe sur +# cinq (mesuré : 0,395) produisaient une DSN illisible et une pile qui ne +# joignait pas sa base, avec une erreur « Invalid URL » qu'aucune page de doc +# ne relie au générateur. `doc/guide/local-production.md` employait déjà la +# bonne forme. +# echo "DB_PASSWORD=$(openssl rand -hex 32)" >> .env +# echo "REDIS_PASSWORD=$(openssl rand -hex 32)" >> .env # # Configure domains # echo "DOMAIN=your-tracker.com" >> .env # # Start # docker compose -f docker-compose.prod.yml up -d +# Hôtes autorisés pour le canal webhook, séparés par des virgules. +# +# L'URL d'un webhook est un champ de MEMBRE : chacun renseigne la sienne, sans +# revue d'administrateur. `safeFetch` écarte déjà les plages privées, la boucle +# locale et le lien-local, et re-valide chaque redirection — il reste une course +# de réattachement DNS sous-milliseconde, qui demande un serveur DNS contrôlé. +# +# Vide (le défaut) = aucune restriction, comportement inchangé. Renseigné, seuls +# ces hôtes et leurs sous-domaines sont joignables. +# WEBHOOK_ALLOW_HOSTS=discord.com,hooks.slack.com +WEBHOOK_ALLOW_HOSTS= diff --git a/.github/workflows/deploy-docs.yml b/.github/workflows/deploy-docs.yml index 035e0d87..dcde7725 100644 --- a/.github/workflows/deploy-docs.yml +++ b/.github/workflows/deploy-docs.yml @@ -47,7 +47,13 @@ jobs: - name: Setup Node uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: - node-version: 20 + # Node 24, comme `doc/Dockerfile` et comme les autres workflows. + # Ce travail était resté sur 20, en fin de vie depuis avril 2026 : + # la même documentation était donc construite sur deux runtimes, + # et le déploiement — qui a `pages: write` — tournait sur celui + # qui ne reçoit plus de correctif. Vérifié : `npm ci` puis + # `npm run build` passent en 24 (vitepress 1.6.4 demande >= 20). + node-version: 24 cache: 'npm' cache-dependency-path: './doc/package-lock.json' diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 3dd7a9f4..a037d39a 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -110,6 +110,17 @@ jobs: tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} platforms: linux/amd64 + # Un SBOM et une attestation de provenance, attachés à l'image. + # + # Les images sont déjà signées par cosign en mode sans clé juste en + # dessous : la signature dit QUI a poussé, elle ne dit pas CE QUE + # l'image contient. Sur un projet que des opérateurs tiers installent, + # c'est la moitié qui manquait — un `syft`/`grype` sur l'image publiée + # n'avait rien à lire, et un avis de sécurité sur une dépendance + # transitive ne se recoupait pas avec les versions réellement + # embarquées. + sbom: true + provenance: mode=max - name: Sign the api image env: @@ -165,6 +176,17 @@ jobs: tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} platforms: linux/amd64 + # Un SBOM et une attestation de provenance, attachés à l'image. + # + # Les images sont déjà signées par cosign en mode sans clé juste en + # dessous : la signature dit QUI a poussé, elle ne dit pas CE QUE + # l'image contient. Sur un projet que des opérateurs tiers installent, + # c'est la moitié qui manquait — un `syft`/`grype` sur l'image publiée + # n'avait rien à lire, et un avis de sécurité sur une dépendance + # transitive ne se recoupait pas avec les versions réellement + # embarquées. + sbom: true + provenance: mode=max - name: Sign the tracker image env: @@ -226,6 +248,17 @@ jobs: tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} platforms: linux/amd64 + # Un SBOM et une attestation de provenance, attachés à l'image. + # + # Les images sont déjà signées par cosign en mode sans clé juste en + # dessous : la signature dit QUI a poussé, elle ne dit pas CE QUE + # l'image contient. Sur un projet que des opérateurs tiers installent, + # c'est la moitié qui manquait — un `syft`/`grype` sur l'image publiée + # n'avait rien à lire, et un avis de sécurité sur une dépendance + # transitive ne se recoupait pas avec les versions réellement + # embarquées. + sbom: true + provenance: mode=max - name: Sign the relay image env: @@ -281,6 +314,17 @@ jobs: tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} platforms: linux/amd64 + # Un SBOM et une attestation de provenance, attachés à l'image. + # + # Les images sont déjà signées par cosign en mode sans clé juste en + # dessous : la signature dit QUI a poussé, elle ne dit pas CE QUE + # l'image contient. Sur un projet que des opérateurs tiers installent, + # c'est la moitié qui manquait — un `syft`/`grype` sur l'image publiée + # n'avait rien à lire, et un avis de sécurité sur une dépendance + # transitive ne se recoupait pas avec les versions réellement + # embarquées. + sbom: true + provenance: mode=max - name: Sign the front-ssr image env: @@ -335,6 +379,17 @@ jobs: tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} platforms: linux/amd64 + # Un SBOM et une attestation de provenance, attachés à l'image. + # + # Les images sont déjà signées par cosign en mode sans clé juste en + # dessous : la signature dit QUI a poussé, elle ne dit pas CE QUE + # l'image contient. Sur un projet que des opérateurs tiers installent, + # c'est la moitié qui manquait — un `syft`/`grype` sur l'image publiée + # n'avait rien à lire, et un avis de sécurité sur une dépendance + # transitive ne se recoupait pas avec les versions réellement + # embarquées. + sbom: true + provenance: mode=max - name: Sign the front image env: @@ -397,6 +452,17 @@ jobs: tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} platforms: linux/amd64 + # Un SBOM et une attestation de provenance, attachés à l'image. + # + # Les images sont déjà signées par cosign en mode sans clé juste en + # dessous : la signature dit QUI a poussé, elle ne dit pas CE QUE + # l'image contient. Sur un projet que des opérateurs tiers installent, + # c'est la moitié qui manquait — un `syft`/`grype` sur l'image publiée + # n'avait rien à lire, et un avis de sécurité sur une dépendance + # transitive ne se recoupait pas avec les versions réellement + # embarquées. + sbom: true + provenance: mode=max # Default base path is `/`, matching the standalone # docker-run usage. The GitHub Pages deploy # (deploy-docs.yml) keeps its own `/opentracker/` base diff --git a/.github/workflows/docs-ci.yml b/.github/workflows/docs-ci.yml index d4bf34a7..02d215df 100644 --- a/.github/workflows/docs-ci.yml +++ b/.github/workflows/docs-ci.yml @@ -45,7 +45,13 @@ jobs: - name: Setup Node uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: - node-version: 20 + # Node 24, comme `doc/Dockerfile` et comme les autres workflows. + # Ce travail était resté sur 20, en fin de vie depuis avril 2026 : + # la même documentation était donc construite sur deux runtimes, + # et le déploiement — qui a `pages: write` — tournait sur celui + # qui ne reçoit plus de correctif. Vérifié : `npm ci` puis + # `npm run build` passent en 24 (vitepress 1.6.4 demande >= 20). + node-version: 24 cache: 'npm' cache-dependency-path: './doc/package-lock.json' diff --git a/.github/workflows/relay-ci.yml b/.github/workflows/relay-ci.yml new file mode 100644 index 00000000..6d89d930 --- /dev/null +++ b/.github/workflows/relay-ci.yml @@ -0,0 +1,128 @@ +# Go relay CI — vet, tests, and vulnerability scanning. +# +# The relay had NO workflow at all. It is a publicly reachable HTTP service +# that verifies HMAC-signed bearer tokens and fans out SSE to every connected +# member: nothing enforced that it still compiled, that its tests passed, or +# that it was free of known vulnerabilities. `go build` inside +# apps/relay/Dockerfile is not a gate — it runs after a release is already +# published. +# +# That gap had a visible cost: `apps/relay/go.mod` sat on +# `golang.org/x/sys v0.30.0` long after the tracker had moved to v0.47.0, +# because no job ever ran `go mod tidy` or a vulnerability scan against it. +# +# Deliberately a copy of `tracker-ci.yml` rather than a shared reusable +# workflow: the two modules pin different Go minors over time and have +# different test shapes, and one file per module is what makes a red job point +# straight at its module. +name: Relay CI + +on: + push: + branches: [main] + paths: + - 'apps/relay/**' + - '.github/workflows/relay-ci.yml' + pull_request: + paths: + - 'apps/relay/**' + - '.github/workflows/relay-ci.yml' + # Advisories are published against code that hasn't changed, so the vuln + # scan can't only run on pushes. Monday morning, before anyone starts — + # staggered off the tracker's slot so the two don't queue against each other. + schedule: + - cron: '37 6 * * 1' + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: relay-ci-${{ github.ref }} + cancel-in-progress: true + +defaults: + run: + working-directory: apps/relay + +jobs: + test: + name: vet + test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + # Test-only checkout — nothing is pushed back. + persist-credentials: false + + - name: Set up Go + uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7 + with: + # The minor, not `go-version-file`. The `go` directive in go.mod is a + # minimum language version (`go 1.26.0`) and setup-go treats a full + # x.y.z as an exact pin — which on the tracker built CI on go1.26.0 + # and failed govulncheck on 19 stdlib vulnerabilities fixed in + # 1.26.1. Tracking the minor matches `golang:1.26-alpine` in the + # Dockerfile. Bump alongside the Dockerfile and the go.mod directive. + go-version: '1.26' + check-latest: true + cache-dependency-path: apps/relay/go.sum + + # `gofmt -l` exits 0 even when it lists files, so the emptiness of its + # output is the assertion. `set -e` covers the other failure mode: on a + # file that does not parse, gofmt writes to stderr and exits non-zero + # while printing nothing on stdout. + - name: gofmt + run: | + set -euo pipefail + unformatted=$(gofmt -l .) + if [ -n "$unformatted" ]; then + echo "::error::gofmt would rewrite these files — run 'gofmt -w .' in apps/relay" + echo "$unformatted" + gofmt -d . + exit 1 + fi + + - name: go vet + run: go vet ./... + + # -race is the point for this module: the hub subscribes and + # unsubscribes Redis channels from concurrent connection handlers while + # dispatching to a shared map of subscribers. + - name: go test -race + run: go test -race ./... + + # `go.mod` drifting behind what the build actually resolves is how the + # x/sys gap survived. `git diff --exit-code` fails the job if tidy moves + # anything. + - name: go mod tidy is up to date + run: | + set -euo pipefail + go mod tidy + git diff --exit-code -- go.mod go.sum + + govulncheck: + name: govulncheck + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + # Test-only checkout — nothing is pushed back. + persist-credentials: false + + - name: Set up Go + uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7 + with: + go-version: '1.26' + check-latest: true + cache-dependency-path: apps/relay/go.sum + + - name: Install govulncheck + run: go install golang.org/x/vuln/cmd/govulncheck@latest + + # The check Dependabot cannot do. Dependabot matches advisories against + # declared version ranges; govulncheck walks the call graph and only + # reports a vulnerability when a vulnerable symbol is actually reachable. + # It exits non-zero on a finding, which is what fails the job. + - name: govulncheck + run: govulncheck ./... diff --git a/README.md b/README.md index 20982fe4..f255fd81 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,9 @@ Three containers — Nuxt 4 web · Nitro API · Go tracker — backed by Postgre - **Proof of Work on registration** stops drive-by signup spam. - **Hashed IPs** — SHA-256 with daily-rotating salt; no raw IP persisted. Banning a user atomically banlists their last-known IP. - **Privacy toggles** — hide last-seen on public profile (mods/admins always see the truth). +- **Three keys, not one** — the announce passkey, an RSS/Torznab key and an API key, each revocable on its own. Handing a feed URL to a third party no longer hands over the credential that announces for you. See [Keys](doc/guide/api-keys.md). +- **Login history** — every attempt to open a session, refused ones included, with the method and a daily-hashed address. Members read their own; staff read a member's from their profile, which is where account sharing gets noticed. +- **Your data, both ways** — one-click JSON export of everything the instance holds about you (GDPR Art. 15 / 20) alongside self-service erasure (Art. 17). Both behind a fresh-login step-up; the export names every omission and why. ### Browse, upload & operate @@ -36,6 +39,13 @@ Three containers — Nuxt 4 web · Nitro API · Go tracker — backed by Postgre - **Dedicated upload page** — auto title + tags from filename, multi-source search picker, duplicate preflight, conditional ID block per category, Tiptap WYSIWYG description, NFO drag-drop (CP437 → UTF-8). - **Release sheet builder** — a four-step wizard at `/torrents/fiche` turns a video file into a BBCode sheet, an NFO and a normalised release name, then hands all three to the upload form. MediaInfo runs **in the browser** through WebAssembly and reads only the chunks it asks for, so a 40 GB remux is analysed without ever being uploaded. Every dropdown keeps an "Other…" entry, and bitrate/size unit selectors change the frame of reference without touching the value. - **Operator console** — `/admin` covers users, categories, roles, invites, branding, panic, tags, Torznab, reports, HnR. +- **Torrent lifecycle** — a release can be marked superseded by a better one (the older stays online and keeps its swarm), a dead swarm can ask its past snatchers for a reseed, and one request tells a client which of its torrents this tracker still serves. See [Torrent lifecycle](doc/guide/torrent-lifecycle.md). +- **IRC announce channel** — one line in a channel per accepted upload, the mechanism autobrr and autodl-irssi race on, with the autobrr indexer definition **generated from the announce template in force** so the format and the parser cannot disagree. Off by default; one bot however many API instances. See [IRC announce](doc/integrations/irc.md). +- **Site statistics** — `/stats` is the site looking at itself: what the catalogue holds, how it grew, what is being grabbed, and a year in review — plus your own year. No per-member volume anywhere, because no setting lets a member opt out of one. See [Site statistics](doc/guide/stats.md). +- **Saved searches** — a stored filter notifies its owner when a matching upload is accepted; the server-side half of autobrr, for members with no seedbox. See [Saved searches](doc/guide/saved-searches.md). +- **Invite tree** — who vouched for a member and who they let in, ten generations either way. Erased accounts keep their edges and lose their name. +- **Staff audit log** — every mutating request to the admin and moderation consoles leaves one append-only row: actor and role as they were, action, target, what changed, and the status — refusals included. Written by a Nitro hook rather than per route, so a staff route added tomorrow is audited before anyone writes a line for it. Admins only. See [Staff audit log](doc/guide/audit-log.md). +- **Installable (PWA)** — a manifest served by the API, so the app's name, colours and icon follow the instance's branding. No offline cache: every page here is a live view of a swarm. - **Notification fan-out** — every event-emitting route hits Postgres + Redis pub/sub + the user's chosen external transport (SMTP, Telegram, Discord, ntfy, Gotify, Pushover, Slack, Mattermost, webhook, Apprise, **Web Push**). ### Tracker protocols @@ -44,12 +54,16 @@ Three containers — Nuxt 4 web · Nitro API · Go tracker — backed by Postgre - **UDP announce (BEP 15)** on `6969/udp`; ~6×–8× cheaper on the wire than HTTP; stateless `connection_id` = HMAC-SHA256(secret, ip ‖ minute), so no per-id memory. - **BEP 41 URL_DATA passkey** — `udp://host:6969/announce/PASSKEY` works as-is in every modern client. - **Multi-tier `.torrent` files** — generator advertises HTTP + UDP independently. `TRACKER_UDP_ENABLED=false` disables UDP and drops it from new `.torrent` files in one go. +- **BitTorrent v2 (BEP 52)** — a v2 or hybrid torrent announces under a second, truncated SHA-256 infohash; the tracker resolves either form and keys both on the canonical hash, so a hybrid torrent's two swarms are one. `content_root_v2` gives cross-tracker content addressing that piece length and the private flag cannot move. See [BitTorrent v2](doc/guide/bittorrent-v2.md). +- **BEP 21 partial seeds** — a client holding only the files it asked for announces `event=paused`; it stays in the swarm, counts as a leecher rather than a seed, and banks no seed time towards a requirement it cannot meet. +- **Torznab that tells the truth** — `minimumratio` and `minimumseedtime` carry the site's real obligations to Sonarr / Radarr, and the volume factors follow the running bonus event instead of claiming normal rates during a freeleech. ### Bonus economy & resilience - **Seed-bonus points** — customisable per-minute rules (time, torrent age, rarity); tiered curves with live preview; ledger-backed. - **Bonus shop** — operator-curated catalogue with built-in `upload_credit` and `invite` effects. - **Bonus events** — time-bounded Freeleech / Silverleech / custom multipliers, applied on the announce hot path. +- **Per-torrent buffs** — freeleech, silverleech or double-upload on one release, plus pinning. Where a buff meets a site-wide event the member gets the better of the two on each axis, never the product; a lapsed buff is neutralised in the announce query itself, so nothing has to sweep. See [Per-torrent buffs](doc/guide/torrent-buffs.md). - **Panic Mode** — instant AES-256-GCM encryption of torrent data + user fields; recovery requires the original Panic Password. See [Panic Mode](doc/guide/panic-mode.md). - **Distributed rate limiting** — Redis-backed sliding windows; progressive penalties; auto IP bans. - **Optional static deployment** — distroless nginx serves a CSR bundle in **~28 MB** (see below). @@ -150,8 +164,8 @@ NUXT_SESSION_SECRET=$(openssl rand -hex 32) ADMIN_API_KEY=$(openssl rand -hex 32) IP_HASH_SECRET=$(openssl rand -hex 32) CHANNEL_ENCRYPTION_KEY=$(openssl rand -hex 32) -DB_PASSWORD=$(openssl rand -base64 24) -REDIS_PASSWORD=$(openssl rand -base64 24) +DB_PASSWORD=$(openssl rand -hex 32) +REDIS_PASSWORD=$(openssl rand -hex 32) NUXT_PUBLIC_TRACKER_HTTP_URL=https://tracker.your-domain.com/announce NUXT_PUBLIC_TRACKER_UDP_URL=udp://tracker.your-domain.com:6969/announce diff --git a/apps/api/middleware/security.ts b/apps/api/middleware/security.ts index eb39d625..a9e61370 100644 --- a/apps/api/middleware/security.ts +++ b/apps/api/middleware/security.ts @@ -11,8 +11,8 @@ import { users, webauthnCredentials } from '@trackarr/db/schema'; import { readBanStatusCached, readIpBanCached, - readLiveRoles, } from '~~/utils/adminAuth'; +import { readLiveRoles } from '~~/utils/liveRoles'; import { isUserRequiredFor2FA } from '~~/utils/settings'; // ============================================================================ @@ -113,17 +113,31 @@ function applySecurityHeaders(event: any): void { 'Referrer-Policy': 'strict-origin-when-cross-origin', 'Permissions-Policy': 'camera=(), microphone=(), geolocation=()', 'X-DNS-Prefetch-Control': 'off', - // Content Security Policy - Strict security rules + /* + * La CSP des réponses de l'API — et rien d'autre. + * + * Elle portait `script-src 'self' 'unsafe-inline'` et `style-src 'self' + * 'unsafe-inline'`, justifiés par « Nuxt requires unsafe-inline for HMR » + * et « Tailwind requires unsafe-inline ». Ces deux motifs appartiennent à + * l'application WEB, qui a sa propre politique à nonce dans + * `apps/web/server/plugins/csp.ts` ; ce processus-ci ne sert ni page, ni + * script, ni feuille de style — uniquement du JSON, du XML (Torznab, RSS), + * du CSS de thème et des objets stockés. + * + * `default-src 'none'` est donc la valeur juste : aucune réponse de l'API + * n'a de raison d'exécuter quoi que ce soit. Cela compte pour le cas où un + * navigateur arrive DIRECTEMENT sur une réponse — un SVG déposé dans + * `/uploads/`, par exemple, dont le script est déjà neutralisé par + * `servedObjectHeaders` et qui gagne ici une seconde barrière. Pour une + * ressource chargée depuis une page (une image, la feuille de thème), c'est + * la politique de la PAGE qui décide, pas celle-ci : la resserrer ne casse + * donc rien. + */ 'Content-Security-Policy': [ - "default-src 'self'", - "script-src 'self' 'unsafe-inline'", // Nuxt requires unsafe-inline for HMR - "style-src 'self' 'unsafe-inline'", // Tailwind requires unsafe-inline - "img-src 'self' data: https:", - "font-src 'self' data:", - "connect-src 'self'", + "default-src 'none'", "frame-ancestors 'none'", - "base-uri 'self'", - "form-action 'self'", + "base-uri 'none'", + "form-action 'none'", 'upgrade-insecure-requests', ].join('; '), }; @@ -211,10 +225,32 @@ export default defineEventHandler(async (event) => { // and only reaches Postgres once a minute per distinct address. const ipBanReason = await readIpBanCached(ip); if (ipBanReason) { - throw createError({ - statusCode: 403, - message: `Access denied: ${ipBanReason}`, - }); + /* + * Une issue de secours pour le personnel authentifié. + * + * Cette porte précède la lecture de session, et `admin/users/[id]/ban.post` + * insère INCONDITIONNELLEMENT le `lastIp` de la cible dans `banned_ips`. Un + * modérateur qui bannit un membre partageant une sortie CGNAT ou VPN avec + * l'administrateur verrouillait donc l'administrateur — hors de TOUTE route + * `/api/`, y compris `DELETE /api/admin/banned-ips/[ip]`, la seule qui + * lèverait le blocage. Le rétablissement demandait un accès direct à la + * base. La limitation de débit progressive pouvait produire le même + * verrouillage sans acteur hostile. + * + * Le rôle est relu dans la base (cache de 60 s), pas dans le cookie : un + * membre ordinaire ne s'échappe pas en se disant administrateur. + */ + const banned = await getUserSession(event); + const live = banned.user ? await readLiveRoles(banned.user.id) : null; + if (!live?.isAdmin && !live?.isModerator) { + throw createError({ + statusCode: 403, + message: `Access denied: ${ipBanReason}`, + }); + } + console.warn( + `[Security] IP ban bypassed for staff ${banned.user?.id}: ${ipBanReason}` + ); } // 4. Authenticated caller — ban status and mandatory-2FA policy. diff --git a/apps/api/package.json b/apps/api/package.json index 89b2c54e..a3d05623 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -16,7 +16,7 @@ "dependencies": { "@iconify-json/ph": "^1.2.2", "@iconify/utils": "^3.1.4", - "@simplewebauthn/server": "^13.3.2", + "@simplewebauthn/server": "^13.3.3", "@trackarr/db": "workspace:*", "@trackarr/shared": "workspace:*", "bencode": "^4.0.1", @@ -35,7 +35,7 @@ "qrcode": "^1.5.4", "uuid": "^14.0.1", "web-push": "^3.6.7", - "zod": "^4.4.3" + "zod": "^4.5.4" }, "devDependencies": { "@types/bencode": "^4.0.0", diff --git a/apps/api/plugins/audit-log.ts b/apps/api/plugins/audit-log.ts new file mode 100644 index 00000000..f0992627 --- /dev/null +++ b/apps/api/plugins/audit-log.ts @@ -0,0 +1,119 @@ +/** + * The staff audit log's write path. + * + * Two Nitro hooks, not one, and the second is there because the first is not + * enough — which an end-to-end check found rather than a review: + * + * - `afterResponse` fires for a request the handler answered normally. + * - `error` fires for one that threw, and `afterResponse` does NOT run for + * it. A single-hook version therefore recorded every successful ban and + * silently dropped every refusal — the exact rows worth having. A run of + * 403s from one account is the pattern this register exists to surface, + * and it would have been the one thing invisible in it. + * + * Both paths go through `record`, which is idempotent: a request that somehow + * reached both hooks writes one row, not two. + * + * The write sits after the response either way, so the insert never delays a + * reply or fails one. + * + * Why a hook rather than a call in each route: there are 243 operations in the + * generated OpenAPI spec, dozens of them staff mutations, and a convention that + * every one of them must remember to log itself is a convention that holds + * until the next route. Here, coverage is structural — a route added tomorrow + * is audited before it is written. What a route can still do is *sharpen* its + * entry, by calling `auditDetail`; see `utils/audit.ts`. + * + * The gate is deliberately narrow: a mutating method, a path under + * `/api/admin/` or `/api/mod/`, and an authenticated caller. Everything else — + * every GET, every member-facing write — is not a staff action and does not + * belong in a register of authority. + */ +import type { H3Event } from 'h3'; +import { redis } from '~~/utils/server'; +import { isAuditable, writeAuditEntry, type AuditActor } from '~~/utils/audit'; + +/** + * One row per request, whichever hook gets here first. + * + * `statusOverride` is for the error path: `event.node.res.statusCode` is not + * always the code that will be sent when a handler threw, so the error's own + * code is used when there is one. + */ +function record(event: H3Event, statusOverride?: number): void { + if (!event?.context || event.context.auditWritten) return; + + const method = event.method ?? 'GET'; + const path = (event.path ?? '').split('?')[0] ?? ''; + + // The actor is read before the predicate now, because whether a member-facing + // path is auditable depends on who is calling it — see `STAFF_REACH`. + const earlyActor = event.context.auditActor as AuditActor | undefined; + const actorIsStaff = !!( + earlyActor?.isAdmin || + earlyActor?.isModerator || + earlyActor?.isOwner + ); + if (!isAuditable(method, path, actorIsStaff)) return; + + // Set by `requireAuthSession`. Absent means the request never got past + // authentication, which is the rate limiter's business and not this + // register's — there is no actor to name. + const actor = event.context.auditActor as AuditActor | undefined; + if (!actor?.id) return; + + /** + * A refusal is worth a line; ten thousand of them are not. + * + * `auditActor` is set by plain authentication, and a staff gate throws its 403 + * BEFORE the route's own rate limit — so any member could loop + * `PUT /api/admin/settings` and write a row per request, bounded only by the + * global 100-per-10-seconds DDoS floor. Roughly 750 000 rows a day, in an + * append-only table with a year of retention and no per-row delete: the flood + * would bury the exact pattern this register exists to show, and an admin + * could not clean it out. + * + * So a refusal from somebody who is not staff is throttled to one row per + * minute per (actor, action). The first attempt is always recorded — which is + * the line that matters — and the storm behind it is not. + */ + const isStaff = !!(actor.isAdmin || actor.isModerator || actor.isOwner); + const statusForGate = statusOverride ?? event.node?.res?.statusCode ?? 200; + if (!isStaff && statusForGate >= 400) { + const key = `audit:throttle:${actor.id}:${event.method ?? 'GET'}:${path}`; + // Fire and forget: a Redis hiccup must not lose an audit row, so the + // fallback is to write it. + void redis + .set(key, '1', 'EX', 60, 'NX') + .then((first) => { + if (first === 'OK') writeAudit(); + }) + .catch(() => writeAudit()); + event.context.auditWritten = true; + return; + } + + event.context.auditWritten = true; + + writeAudit(); + + function writeAudit(): void { + const statusCode = statusOverride ?? event.node?.res?.statusCode ?? 200; + // Not awaited: the response has already gone out, and holding the hook open + // would keep the request's context alive for the length of an INSERT. + // `writeAuditEntry` swallows its own failures. + void writeAuditEntry(event, actor!, statusCode); + } +} + +export default defineNitroPlugin((nitro) => { + nitro.hooks.hook('afterResponse', (event) => { + record(event); + }); + + nitro.hooks.hook('error', (error, ctx) => { + if (!ctx?.event) return; + const status = (error as { statusCode?: number })?.statusCode; + record(ctx.event, typeof status === 'number' ? status : 500); + }); +}); diff --git a/apps/api/plugins/audit-retention.ts b/apps/api/plugins/audit-retention.ts new file mode 100644 index 00000000..bee3ab26 --- /dev/null +++ b/apps/api/plugins/audit-retention.ts @@ -0,0 +1,63 @@ +/** + * Audit-log retention sweep. + * + * Entries survive `audit_log_retention_days` (default 365, `0` = forever) and + * are then deleted by age. This is the ONLY thing in the application that + * removes an audit row — see the note on the table: a register whose entries + * can be amended by the people it registers is not a register, so there is no + * edit path and no per-row delete, and the retention period is published on + * `/api/privacy` where a member can read it. + * + * A year rather than the 90 days notifications get, because the question an + * audit log answers tends to be asked late: after a member disputes a ban, or + * after a staff account turns out to have been borrowed weeks ago. + * + * Daily, with a delay after boot: the table is small next to `notifications` + * (one row per staff mutation, not one per member event) and nothing here is + * urgent enough to compete with cold start. + */ +import { lt } from 'drizzle-orm'; +import { db, schema } from '@trackarr/db'; +import { getAuditRetentionDays } from '~~/utils/server'; +import { withCronLock } from '~~/utils/cronLock'; + +const SWEEP_INTERVAL_MS = 24 * 60 * 60 * 1000; +const FIRST_RUN_DELAY_MS = 5 * 60 * 1000; + +export default defineNitroPlugin(() => { + const tick = async () => { + try { + await withCronLock('audit_retention:lock', 10 * 60, async () => { + const days = await getAuditRetentionDays(); + if (days <= 0) return; // keep indefinitely + + const cutoff = new Date(Date.now() - days * 24 * 60 * 60 * 1000); + const deleted = await db + .delete(schema.auditLog) + .where(lt(schema.auditLog.createdAt, cutoff)) + .returning({ id: schema.auditLog.id }); + + if (deleted.length > 0) { + console.log( + `[AuditRetention] swept ${deleted.length} entries older than ${days}d` + ); + } + }); + } catch (err) { + // `console.error` with the cause chain — the notification sweep failed + // silently for months behind a `warn` carrying only `err.message`, and + // drizzle's message can point at the wrong layer entirely. + const e = err as { message?: string; cause?: { code?: string; message?: string } }; + console.error( + '[AuditRetention] sweep failed:', + e?.message, + e?.cause ? `| cause: ${e.cause.code ?? ''} ${e.cause.message ?? ''}` : '' + ); + } + }; + + setTimeout(() => { + void tick(); + setInterval(tick, SWEEP_INTERVAL_MS); + }, FIRST_RUN_DELAY_MS); +}); diff --git a/apps/api/plugins/backfill-content-roots.ts b/apps/api/plugins/backfill-content-roots.ts index 698aa419..19740d36 100644 --- a/apps/api/plugins/backfill-content-roots.ts +++ b/apps/api/plugins/backfill-content-roots.ts @@ -11,6 +11,23 @@ * * The parse is the cost, so the batch is capped and a cross-replica lock keeps * one replica doing the work. + * + * ## Why the cursor key is versioned + * + * `info_hash_v2` used to be hashed over a RE-ENCODE of the decoded info dict. + * That equals the real BEP 52 infohash for a canonical torrent and diverges for + * one whose keys are unsorted or whose paths are not valid UTF-8 — which was + * affordable while nothing matched on the value, and stopped being affordable + * the moment the announce path did (see `utils/bittorrentV2`). + * + * Rows written under the old rule are therefore wrong for a minority of + * torrents, and there is no way to tell which from the stored value alone. So + * the cursor key carries a version: bumping it sends the sweep over the + * catalogue once more, re-deriving every row from the bytes it still holds. + * A row that was already right is rewritten with the same value. + * + * Cheaper than a data migration and it cannot lock the table: the same capped, + * locked, resumable walk that filled the columns in the first place. */ import { db, schema } from '@trackarr/db'; import { and, asc, eq, gt, isNotNull } from 'drizzle-orm'; @@ -21,7 +38,9 @@ import { withCronLock } from '~~/utils/cronLock'; const SWEEP_INTERVAL_MS = 5 * 60 * 1000; const FIRST_RUN_DELAY_MS = 75 * 1000; const BATCH_SIZE = 50; -const CURSOR_KEY = 'content_root_v2_backfill_cursor'; +// v2: re-derives `info_hash_v2` from the original info-dict bytes rather than a +// re-encode. Bump this (and say why above) whenever the derivation changes. +const CURSOR_KEY = 'content_root_v2_backfill_cursor_v2'; async function tick(): Promise<{ processed: number; v2: number }> { const cursor = (await getSetting(CURSOR_KEY)) ?? ''; diff --git a/apps/api/plugins/irc-announce.ts b/apps/api/plugins/irc-announce.ts new file mode 100644 index 00000000..69d73424 --- /dev/null +++ b/apps/api/plugins/irc-announce.ts @@ -0,0 +1,52 @@ +/** + * Keeps the announce bot in the state the settings describe. + * + * A timer rather than a one-shot connect, because three separate things need + * the same tick: taking the lease (or renewing it), noticing a configuration + * change another instance saved, and retrying after a failure. The client + * itself has no reconnect loop for exactly this reason — one place decides when + * to try again, and its interval is the backoff. + * + * The interval is the lease renewal, not a poll of the settings: at 15 s a + * 45-second lease survives two missed ticks, which is what makes a garbage + * collection pause or a slow query a non-event rather than a handover. + * + * Nothing here throws. A tick that cannot read Redis or the settings leaves the + * bot as it was and tries again — an announce channel is a convenience, and it + * must not be able to take an API instance down with it. + */ +import { LEASE_RENEW_MS, reconcile, shutdownAnnouncer } from '~~/utils/irc/announcer'; +import { getIrcEnabled } from '~~/utils/irc/settings'; + +export default defineNitroPlugin(async (nitro) => { + // The first look is deliberately quiet: the overwhelmingly common case is an + // instance with no IRC configured at all, and it should say nothing. + try { + if (await getIrcEnabled()) { + console.log( + `[IRC] Announce enabled — reconciling every ${LEASE_RENEW_MS / 1000}s` + ); + } + } catch { + // Settings unreadable at boot: the tick will say so if it persists. + } + + const tick = async () => { + try { + await reconcile(); + } catch (err) { + console.warn('[IRC] reconcile failed:', (err as Error).message); + } + }; + + void tick(); + const timer = setInterval(tick, LEASE_RENEW_MS); + timer.unref?.(); + + // Release the lease on the way out rather than letting it expire: a rolling + // restart otherwise leaves the channel unattended for up to a full TTL. + nitro.hooks.hook('close', async () => { + clearInterval(timer); + await shutdownAnnouncer(); + }); +}); diff --git a/apps/api/plugins/login-event-retention.ts b/apps/api/plugins/login-event-retention.ts new file mode 100644 index 00000000..81d3b96d --- /dev/null +++ b/apps/api/plugins/login-event-retention.ts @@ -0,0 +1,54 @@ +/** + * Login-history retention sweep. + * + * Rows survive `login_event_retention_days` (default 90, `0` = forever) and are + * then deleted by age. Ninety days rather than the audit log's year: this is a + * high-volume table — one row per login attempt per member — and the questions + * it answers ("was that me last week", "is this account being shared right + * now") are asked about the recent past. + * + * Published on `/privacy` beside every other period, because it is a record of + * a member's own activity rather than of staff decisions. + */ +import { lt } from 'drizzle-orm'; +import { db, schema } from '@trackarr/db'; +import { getLoginEventRetentionDays } from '~~/utils/server'; +import { withCronLock } from '~~/utils/cronLock'; + +const SWEEP_INTERVAL_MS = 24 * 60 * 60 * 1000; +const FIRST_RUN_DELAY_MS = 7 * 60 * 1000; + +export default defineNitroPlugin(() => { + const tick = async () => { + try { + await withCronLock('login_event_retention:lock', 10 * 60, async () => { + const days = await getLoginEventRetentionDays(); + if (days <= 0) return; + + const cutoff = new Date(Date.now() - days * 24 * 60 * 60 * 1000); + const deleted = await db + .delete(schema.loginEvents) + .where(lt(schema.loginEvents.createdAt, cutoff)) + .returning({ id: schema.loginEvents.id }); + + if (deleted.length > 0) { + console.log( + `[LoginRetention] swept ${deleted.length} events older than ${days}d` + ); + } + }); + } catch (err) { + const e = err as { message?: string; cause?: { code?: string; message?: string } }; + console.error( + '[LoginRetention] sweep failed:', + e?.message, + e?.cause ? `| cause: ${e.cause.code ?? ''} ${e.cause.message ?? ''}` : '' + ); + } + }; + + setTimeout(() => { + void tick(); + setInterval(tick, SWEEP_INTERVAL_MS); + }, FIRST_RUN_DELAY_MS); +}); diff --git a/apps/api/routes/api/admin/audit/actions.get.ts b/apps/api/routes/api/admin/audit/actions.get.ts new file mode 100644 index 00000000..eed55feb --- /dev/null +++ b/apps/api/routes/api/admin/audit/actions.get.ts @@ -0,0 +1,35 @@ +/** + * GET /api/admin/audit/actions — the action keys actually present, for the filter. + * + * Read from the table rather than from a hard-coded list. The action key of an + * un-enriched route is derived from its path (`utils/audit.deriveAction`), so a + * fixed list would be a second inventory of every staff route, maintained by + * hand, wrong within a release. This one cannot be wrong: it is what is there. + * + * Capped, because a derived key includes the path and a misconfigured proxy + * could in principle produce many — a filter dropdown with 5 000 entries is not + * a filter. The cap is stated in the response so the UI can say so. + */ +import { desc, sql } from 'drizzle-orm'; +import { db, schema } from '@trackarr/db'; +import { requireAdminSession } from '~~/utils/adminAuth'; +import { rateLimit, RATE_LIMITS } from '~~/utils/rateLimit'; + +const LIMIT = 200; + +export default defineEventHandler(async (event) => { + await requireAdminSession(event); + await rateLimit(event, RATE_LIMITS.public); + + const rows = await db + .select({ + action: schema.auditLog.action, + count: sql`count(*)::int`, + }) + .from(schema.auditLog) + .groupBy(schema.auditLog.action) + .orderBy(desc(sql`count(*)`)) + .limit(LIMIT); + + return { items: rows, limit: LIMIT, truncated: rows.length === LIMIT }; +}); diff --git a/apps/api/routes/api/admin/audit/index.get.ts b/apps/api/routes/api/admin/audit/index.get.ts new file mode 100644 index 00000000..49d7e886 --- /dev/null +++ b/apps/api/routes/api/admin/audit/index.get.ts @@ -0,0 +1,119 @@ +/** + * GET /api/admin/audit — the staff register, read. + * + * ## Admins only, and moderators deliberately not + * + * `requireAdminSession`, not `requireModeratorSession`, even though moderator + * actions are what fills the table. A register read by everyone it registers is + * a register people write around: knowing exactly what your colleague can see + * about you changes what you do in front of them, and the value of an audit log + * is that it is read by the people accountable for the console, not by everyone + * with a key to it. The instance owner is an admin, so they are covered. + * + * ## Filters + * + * Four, and each has an index behind it (see the table): actor, action, target, + * and a date range. `q` is a free-text pass over actor name and target label — + * deliberately not over `changes`, whose JSON would need a GIN index nobody has + * asked for yet, and whose contents are the part most likely to hold a value + * that should not be searchable in bulk. + * + * ## What it does not do + * + * There is no write, no edit and no delete route beside this one. Rows leave + * only through the retention sweep (`plugins/audit-retention.ts`). + */ +import { and, count, desc, eq, gte, ilike, lte, or, type SQL } from 'drizzle-orm'; +import { z } from 'zod'; +import { db, schema } from '@trackarr/db'; +import { requireAdminSession } from '~~/utils/adminAuth'; +import { rateLimit, RATE_LIMITS } from '~~/utils/rateLimit'; +import { escapeLike } from '~~/utils/sql'; +import { validateQuery } from '~~/utils/schemas'; + +const querySchema = z.object({ + page: z.coerce.number().int().min(1).default(1), + pageSize: z.coerce.number().int().min(1).max(100).default(50), + /** Filter to one staffer. */ + actorId: z.string().min(1).max(64).optional(), + /** Exact action key, e.g. `user.ban`. */ + action: z.string().min(1).max(128).optional(), + targetType: z.string().min(1).max(64).optional(), + targetId: z.string().min(1).max(128).optional(), + /** Free text over actor name and target label. */ + q: z.string().min(1).max(128).optional(), + /** ISO dates, inclusive. */ + from: z.coerce.date().optional(), + to: z.coerce.date().optional(), + /** `true` keeps only entries whose request did not succeed. */ + failuresOnly: z + .union([z.literal('true'), z.literal('false')]) + .optional() + .transform((v) => v === 'true'), +}); + +export default defineEventHandler(async (event) => { + await requireAdminSession(event); + await rateLimit(event, RATE_LIMITS.public); + + const params = validateQuery(event, querySchema); + + const conditions: SQL[] = []; + if (params.actorId) { + conditions.push(eq(schema.auditLog.actorId, params.actorId)); + } + if (params.action) { + conditions.push(eq(schema.auditLog.action, params.action)); + } + if (params.targetType) { + conditions.push(eq(schema.auditLog.targetType, params.targetType)); + } + if (params.targetId) { + conditions.push(eq(schema.auditLog.targetId, params.targetId)); + } + if (params.from) { + conditions.push(gte(schema.auditLog.createdAt, params.from)); + } + if (params.to) { + conditions.push(lte(schema.auditLog.createdAt, params.to)); + } + if (params.failuresOnly) { + // Anything outside 2xx. A 403 run from one account is the signal this + // table exists for, so failures are first-class rather than noise. + conditions.push( + or( + lte(schema.auditLog.statusCode, 199), + gte(schema.auditLog.statusCode, 300) + )! + ); + } + if (params.q) { + const needle = `%${escapeLike(params.q)}%`; + conditions.push( + or( + ilike(schema.auditLog.actorName, needle), + ilike(schema.auditLog.targetLabel, needle) + )! + ); + } + + const where = conditions.length ? and(...conditions) : undefined; + + const [rows, [{ value: total } = { value: 0 }]] = await Promise.all([ + db + .select() + .from(schema.auditLog) + .where(where) + .orderBy(desc(schema.auditLog.createdAt)) + .limit(params.pageSize) + .offset((params.page - 1) * params.pageSize), + db.select({ value: count() }).from(schema.auditLog).where(where), + ]); + + return { + items: rows, + total, + page: params.page, + pageSize: params.pageSize, + }; +}); diff --git a/apps/api/routes/api/admin/banned-ips/index.get.ts b/apps/api/routes/api/admin/banned-ips/index.get.ts index ecf01dfd..34ef96eb 100644 --- a/apps/api/routes/api/admin/banned-ips/index.get.ts +++ b/apps/api/routes/api/admin/banned-ips/index.get.ts @@ -40,6 +40,7 @@ import { type SQL, } from 'drizzle-orm'; import { z } from 'zod'; +import { validateQuery } from '~~/utils/schemas'; const querySchema = z.object({ search: z.string().trim().max(120).optional(), @@ -55,7 +56,7 @@ const AUTO_PREFIX = 'Banned user:'; export default defineEventHandler(async (event) => { await requireModeratorSession(event); - const params = querySchema.parse(getQuery(event)); + const params = validateQuery(event, querySchema); const conditions: SQL[] = []; if (params.search) { diff --git a/apps/api/routes/api/admin/favicon.delete.ts b/apps/api/routes/api/admin/favicon.delete.ts new file mode 100644 index 00000000..5863dbff --- /dev/null +++ b/apps/api/routes/api/admin/favicon.delete.ts @@ -0,0 +1,47 @@ +import { requireAdminSession } from '~~/utils/adminAuth'; +import { setSetting, SETTINGS_KEYS } from '~~/utils/server'; +import { getStorage } from '~~/utils/storage'; +import { resolveObjectKey } from '~~/utils/storage/keys'; + +/** + * DELETE /api/admin/favicon + * + * Removes the custom favicon and falls back to the built-in one. + * + * This route exists because the console already offered the action and nothing + * carried it out. The branding form has a remove button next to the favicon + * preview; it set a local field that no request ever sent — `settings.put.ts` + * has never known the word "favicon", the only writer being the sibling upload + * route. So an operator removed the favicon, the form counted one unsaved + * change, the save reported success, and the favicon was still being served. + * + * Symmetrical with the upload in both respects: the setting is cleared and the + * stored file is deleted, and both are the operator's own upload rather than a + * shipped asset — `resolveObjectKey` is what makes that an assertion rather + * than a hope. + */ +export default defineEventHandler(async (event) => { + await requireAdminSession(event); + + const { getSiteFavicon } = await import('~~/utils/server'); + const current = await getSiteFavicon(); + + await setSetting(SETTINGS_KEYS.SITE_FAVICON, ''); + await setSetting(SETTINGS_KEYS.SITE_FAVICON_SIZE, ''); + + if (current && current.startsWith('/uploads/')) { + const key = resolveObjectKey(current.replace(/^\/uploads\//, '')); + if (key) { + try { + await getStorage().delete(key); + } catch (err) { + // The setting is already cleared, which is what the site reads. A file + // left behind is disk, not behaviour, and failing the request here + // would tell the operator the removal did not happen when it did. + console.warn('[Favicon Delete] could not remove the stored file:', err); + } + } + } + + return { success: true }; +}); diff --git a/apps/api/routes/api/admin/favicon.post.ts b/apps/api/routes/api/admin/favicon.post.ts index 0b9bfdd9..d09bb652 100644 --- a/apps/api/routes/api/admin/favicon.post.ts +++ b/apps/api/routes/api/admin/favicon.post.ts @@ -1,7 +1,11 @@ import { requireAdminSession } from '~~/utils/adminAuth'; import { setSetting, SETTINGS_KEYS } from '~~/utils/server'; import { randomBytes } from 'crypto'; -import { assertImageType } from '~~/utils/imageSniff'; +import { + assertImageType, + imageDimensions, + manifestIconSizes, +} from '~~/utils/imageSniff'; import { getStorage } from '~~/utils/storage'; import { resolveObjectKey } from '~~/utils/storage/keys'; @@ -91,6 +95,11 @@ export default defineEventHandler(async (event) => { // Save to settings await setSetting(SETTINGS_KEYS.SITE_FAVICON, fileUrl); + // Measured, not assumed — see the note in the sibling logo route. + await setSetting( + SETTINGS_KEYS.SITE_FAVICON_SIZE, + manifestIconSizes(imageDimensions(file.data)) + ); // Delete old favicon if it exists and is in uploads folder. The setting is // written by this route, so the value should always be a plain filename — diff --git a/apps/api/routes/api/admin/fonts/[id].delete.ts b/apps/api/routes/api/admin/fonts/[id].delete.ts index 6ee03f34..ed01d3be 100644 --- a/apps/api/routes/api/admin/fonts/[id].delete.ts +++ b/apps/api/routes/api/admin/fonts/[id].delete.ts @@ -14,6 +14,7 @@ import { requireOwnerSession, requireFreshAuth } from '~~/utils/adminAuth'; import { rateLimit, RATE_LIMITS } from '~~/utils/rateLimit'; import { deleteFont, themesUsingFont } from '~~/utils/fonts'; import { bumpThemeVersion } from '~~/utils/themes'; +import { validateRouterParams } from '~~/utils/schemas'; const paramsSchema = z.object({ id: z.string().uuid() }); @@ -21,7 +22,7 @@ export default defineEventHandler(async (event) => { await requireOwnerSession(event); await requireFreshAuth(event); await rateLimit(event, RATE_LIMITS.mutation); - const { id } = paramsSchema.parse(getRouterParams(event)); + const { id } = validateRouterParams(event, paramsSchema); const inUse = await themesUsingFont(id); if (inUse.length) { diff --git a/apps/api/routes/api/admin/invites/tree.get.ts b/apps/api/routes/api/admin/invites/tree.get.ts new file mode 100644 index 00000000..dff1940e --- /dev/null +++ b/apps/api/routes/api/admin/invites/tree.get.ts @@ -0,0 +1,272 @@ +/** + * GET /api/admin/invites/tree?userId=… + * + * Who invited this member, and who they invited in turn. + * + * The procedure this exists for is standard across the trackers that have had + * it for decades: an account is banned for cheating, and the first question is + * who vouched for them — because whoever did is either careless or complicit, + * and their other invitees are worth a look. The data has always been here; + * both pages that read it only ever rendered one generation. + * + * ## Shape + * + * Two walks, both bounded: + * + * - **Ancestors**, one row per generation, up to `MAX_DEPTH`. The chain ends + * at a member nobody invited — which on this site means either the first + * account or somebody who registered while registration was open. Those + * are indistinguishable from missing data, and the response says which + * ending it hit rather than letting the reader guess. + * - **Descendants**, breadth-first, capped on both depth and total nodes. A + * prolific inviter three generations down is a lot of rows, and an + * unbounded recursive CTE against a social graph is a query nobody meant + * to write. + * + * ## Erased accounts + * + * An erasure scrubs the username to a tombstone and leaves every invitation row + * intact, so the EDGES survive an erased inviter perfectly — which is exactly + * what a genealogy needs. The node is flagged so the page can draw a tombstone + * rather than a clickable stranger. + */ +import { and, eq, inArray, isNotNull } from 'drizzle-orm'; +import { z } from 'zod/v4'; +import { db, schema } from '@trackarr/db'; +import { requireAdminSession } from '~~/utils/adminAuth'; +import { auditDetail, writeAuditEntry } from '~~/utils/audit'; +import { rateLimit, RATE_LIMITS } from '~~/utils/rateLimit'; +import { validateQuery } from '~~/utils/schemas'; + +/** How far up, and how far down. */ +const MAX_DEPTH = 10; +/** And how many nodes in total, whatever shape the tree turns out to be. */ +const MAX_NODES = 400; + +const querySchema = z.object({ + userId: z.string().uuid(), +}); + +interface Node { + id: string; + username: string; + isBanned: boolean; + /** True when the account has been erased — render a tombstone, not a link. */ + erased: boolean; + createdAt: Date; + /** When this member used their invite. Null for a genealogy root. */ + invitedAt: Date | null; + depth: number; + children?: Node[]; +} + +const USER_COLUMNS = { + id: schema.users.id, + username: schema.users.username, + isBanned: schema.users.isBanned, + deletedAt: schema.users.deletedAt, + createdAt: schema.users.createdAt, +}; + +function toNode( + row: { + id: string; + username: string; + isBanned: boolean; + deletedAt: Date | null; + createdAt: Date; + }, + depth: number, + invitedAt: Date | null +): Node { + return { + id: row.id, + username: row.username, + isBanned: row.isBanned, + erased: row.deletedAt !== null, + createdAt: row.createdAt, + invitedAt, + depth, + }; +} + +export default defineEventHandler(async (event) => { + const session = await requireAdminSession(event); + await rateLimit(event, RATE_LIMITS.public); + + const { userId } = validateQuery(event, querySchema); + + const [subject] = await db.select(USER_COLUMNS).from(schema.users).where(eq(schema.users.id, userId)).limit(1); + if (!subject) { + throw createError({ statusCode: 404, message: 'User not found' }); + } + + // ── Up ───────────────────────────────────────────────────────────── + const ancestors: Node[] = []; + let cursor = userId; + let truncatedUp = false; + const seen = new Set([userId]); + + for (let depth = 1; depth <= MAX_DEPTH; depth++) { + const [invite] = await db + .select({ createdBy: schema.invitations.createdBy, usedAt: schema.invitations.usedAt }) + .from(schema.invitations) + .where(eq(schema.invitations.usedBy, cursor)) + .limit(1); + if (!invite) break; + + // A cycle is impossible through registration, but a restored backup or a + // hand-edited row could produce one, and an unguarded walk would spin. + if (seen.has(invite.createdBy)) break; + seen.add(invite.createdBy); + + const [row] = await db + .select(USER_COLUMNS) + .from(schema.users) + .where(eq(schema.users.id, invite.createdBy)) + .limit(1); + if (!row) break; + + ancestors.push(toNode(row, depth, invite.usedAt)); + cursor = row.id; + } + + /** + * Whether the chain actually continues past the tenth generation. + * + * This used to be `depth === MAX_DEPTH` inside the loop, which is true as soon + * as a tenth ancestor is found — so a chain that is EXACTLY ten deep, with the + * founder at the top, reported itself as truncated. One extra probe answers + * the question the flag is claiming to answer. + */ + if (ancestors.length === MAX_DEPTH) { + const [further] = await db + .select({ createdBy: schema.invitations.createdBy }) + .from(schema.invitations) + .where(eq(schema.invitations.usedBy, cursor)) + .limit(1); + truncatedUp = !!further && !seen.has(further.createdBy); + } + + // ── Down ─────────────────────────────────────────────────────────── + const root = toNode(subject, 0, null); + let nodeCount = 1; + let truncatedDown = false; + + let frontier: Node[] = [root]; + for (let depth = 1; depth <= MAX_DEPTH && frontier.length > 0; depth++) { + const parentIds = frontier.map((n) => n.id); + /** + * Bounded in SQL, not only in the response. + * + * `MAX_NODES` was applied while building the answer, so a prolific inviter + * three generations down still had every one of their redeemed invitations + * returned to the API first — the whole table, potentially, to produce four + * hundred nodes. The remaining budget is the limit, plus one so a truncation + * is still detectable. + */ + const remaining = MAX_NODES - nodeCount; + if (remaining <= 0) { + truncatedDown = true; + break; + } + const invites = await db + .select({ + createdBy: schema.invitations.createdBy, + usedBy: schema.invitations.usedBy, + usedAt: schema.invitations.usedAt, + }) + .from(schema.invitations) + .where( + and(inArray(schema.invitations.createdBy, parentIds), isNotNull(schema.invitations.usedBy)) + ) + .orderBy(schema.invitations.usedAt) + .limit(remaining + 1); + if (invites.length === 0) break; + if (invites.length > remaining) truncatedDown = true; + + /** + * A member already placed in the tree is not placed again. + * + * The upward walk has this guard and the descent did not: `invitations` has + * no constraint preventing a cycle (A invited B, B invited A after a staff + * edit), and without the guard the same pair is emitted once per generation + * until the node budget runs out — bounded, but it renders as a family tree + * that repeats itself. + */ + const childIds = [ + ...new Set(invites.map((i) => i.usedBy!).filter((id) => !seen.has(id))), + ]; + if (childIds.length === 0) break; + const rows = await db.select(USER_COLUMNS).from(schema.users).where(inArray(schema.users.id, childIds)); + const rowById = new Map(rows.map((r) => [r.id, r])); + const byParent = new Map(frontier.map((n) => [n.id, n])); + + const next: Node[] = []; + for (const invite of invites) { + if (nodeCount >= MAX_NODES) { + truncatedDown = true; + break; + } + const row = rowById.get(invite.usedBy!); + const parent = byParent.get(invite.createdBy); + if (!row || !parent) continue; + + if (seen.has(row.id)) continue; + seen.add(row.id); + const node = toNode(row, depth, invite.usedAt); + (parent.children ??= []).push(node); + next.push(node); + nodeCount++; + } + if (truncatedDown) break; + frontier = next; + if (depth === MAX_DEPTH && next.length > 0) truncatedDown = true; + } + + /** + * Logged explicitly, because the hook does not log reads. + * + * That is the right default — a register of authority records decisions, not + * who looked at a page — and this is the one read worth the exception: it is + * the social graph of the entire site in one response, and the commit that + * added it claimed it was audited when it was not. + */ + auditDetail(event, { + action: 'admin.invites.tree.read', + targetType: 'user', + targetId: userId, + targetLabel: root.username, + }); + void writeAuditEntry(event, session.user, 200); + + return { + subject: root, + /** Nearest first: `ancestors[0]` is who invited the subject. */ + ancestors, + /** + * Why the upward walk stopped. `root` means nobody invited them — the + * first account, or a member who registered while registration was open. + * Those two are indistinguishable in the data, and saying "root" rather + * than showing an empty list is what keeps a reader from assuming the + * record is incomplete. + */ + ancestorsEnd: truncatedUp ? 'depth-limit' : 'root', + /** + * One flag per direction, because the console renders one notice per + * section. + * + * A single `truncatedUp || truncatedDown` meant an up-truncation printed + * "truncated at 400 members or 10 generations" underneath the DESCENDANT + * list — telling the operator the branch they can see is incomplete when it + * is the chain above that is. On a page whose whole job is tracing a + * filiation, that sends the investigation to the wrong end of the tree. + * `truncated` stays for anything already reading it. + */ + truncatedUp, + truncatedDown, + truncated: truncatedUp || truncatedDown, + limits: { maxDepth: MAX_DEPTH, maxNodes: MAX_NODES }, + nodeCount, + }; +}); diff --git a/apps/api/routes/api/admin/irc/index.get.ts b/apps/api/routes/api/admin/irc/index.get.ts new file mode 100644 index 00000000..a45ca033 --- /dev/null +++ b/apps/api/routes/api/admin/irc/index.get.ts @@ -0,0 +1,42 @@ +/** + * GET /api/admin/irc + * + * The announce bot's configuration and what it is currently doing. + * + * The secrets come back blank with a `has…` flag beside each, the same contract + * the notification channels use: an admin needs to know a password is set + * without the console being a place to read it back. + * + * `status.leader` is worth surfacing rather than hiding. In a multi-instance + * deployment exactly one API process holds the connection, so an operator + * looking at a console served by another one would otherwise see `idle` and + * conclude the bot is down. + */ +import { requireAdminSession } from '~~/utils/adminAuth'; +import { rateLimit, RATE_LIMITS } from '~~/utils/rateLimit'; +import { ircStatus } from '~~/utils/irc/announcer'; +import { ANNOUNCE_TOKENS, DEFAULT_ANNOUNCE_TEMPLATE, announcePattern } from '~~/utils/irc/format'; +import { getIrcConfig, getIrcEnabled, redactIrcConfig } from '~~/utils/irc/settings'; + +export default defineEventHandler(async (event) => { + await requireAdminSession(event); + await rateLimit(event, RATE_LIMITS.public); + + const [enabled, config] = await Promise.all([getIrcEnabled(), getIrcConfig()]); + + return { + enabled, + config: redactIrcConfig(config), + status: ircStatus(), + /** So the form can explain the template rather than link to a doc page. */ + tokens: Object.entries(ANNOUNCE_TOKENS).map(([name, def]) => ({ + name, + variable: def.variable, + describes: def.describes, + })), + defaultTemplate: DEFAULT_ANNOUNCE_TEMPLATE, + /** The regex members' clients will use — shown so an operator editing the + * template can see what it does to the definition they are handing out. */ + pattern: announcePattern(config.template).pattern, + }; +}); diff --git a/apps/api/routes/api/admin/irc/index.put.ts b/apps/api/routes/api/admin/irc/index.put.ts new file mode 100644 index 00000000..634a2da6 --- /dev/null +++ b/apps/api/routes/api/admin/irc/index.put.ts @@ -0,0 +1,170 @@ +/** + * PUT /api/admin/irc + * + * Save the announce configuration, then reconcile the connection so the change + * is visible before the admin's page has finished reloading — rather than at + * the next plugin tick, which would make a correct setting look broken for + * thirty seconds. + * + * ## A blank secret means "keep the stored one" + * + * The GET blanks the three credentials, so a form that round-tripped what it + * received would erase them on every save. Blank therefore means unchanged, and + * clearing one is an explicit `null` — the same contract the notification + * channels use, and the reason it is a contract at all is that the alternative + * silently disconnects a working bot the first time an admin edits the channel + * name. + * + * ## The template is validated by being used + * + * An operator can write anything in it. What is checked is the thing that + * matters: that the pattern derived from it reads back the line rendered from + * it. A template that fails that would produce a definition every member's + * autobrr silently ignores, so it is refused here with the sample line in the + * message. + */ +import { z } from 'zod/v4'; +import { requireAdminSession } from '~~/utils/adminAuth'; +import { rateLimit, RATE_LIMITS } from '~~/utils/rateLimit'; +import { validateBody } from '~~/utils/schemas'; +import { reconcile } from '~~/utils/irc/announcer'; +import { SAMPLE_FIELDS } from '~~/utils/irc/autobrr'; +import { + announcePattern, + renderAnnounce, + templateTokens, + toJsRegExp, +} from '~~/utils/irc/format'; +import { + getIrcConfig, + redactIrcConfig, + setIrcConfig, + setIrcEnabled, + type IrcAnnounceConfig, +} from '~~/utils/irc/settings'; + +/** `null` clears a secret, an absent/empty string keeps it. */ +const secret = z.union([z.string().max(300), z.null()]).optional(); + +const bodySchema = z.object({ + enabled: z.boolean(), + host: z.string().trim().max(253), + port: z.coerce.number().int().min(1).max(65535), + tls: z.boolean(), + nick: z + .string() + .trim() + .max(30) + // RFC 2812's nick grammar, minus the leading-digit case no network accepts. + // Pipes and brackets are in it, which matters here: `name|autodl` is the + // convention announce channels require. + .regex(/^[A-Za-z\[\]\\`_^{|}][A-Za-z0-9\[\]\\`_^{|}-]*$/, 'Not a valid IRC nick'), + realname: z.string().trim().max(60), + serverPassword: secret, + saslUser: z.string().trim().max(60), + saslPassword: secret, + /** + * Absent means unchanged, like the three password fields — the GET no longer + * returns these lines, so a form that round-tripped what it received would + * erase them. An explicit empty array clears them. + */ + perform: z + .array(z.string().trim().max(400).regex(/^[^\r\n]*$/, 'One line per entry')) + .max(8) + .optional(), + channel: z.string().trim().max(60).regex(/^[#&][^\s,]+$/, 'Not a valid channel'), + channelKey: secret, + // No control characters. A newline survives `.trim()`, and the generated + // autobrr definition puts the template's derived pattern in a single-quoted + // YAML scalar — where a raw newline either breaks the file or, with matching + // indentation, injects structure into the artifact whose whole job is to be + // trustworthy. Every gate passed on such a template before this: the length, + // the `{name}` requirement, and the round-trip. + template: z + .string() + .trim() + .min(1) + .max(500) + .regex(/^[^\u0000-\u001f\u007f]+$/, 'The line cannot contain control characters'), + siteUrl: z.string().trim().max(300), + announceAdult: z.boolean(), +}); + +export default defineEventHandler(async (event) => { + await requireAdminSession(event); + await rateLimit(event, RATE_LIMITS.mutation); + + const body = await validateBody(event, bodySchema); + const stored = await getIrcConfig(); + + /** + * Two lazy unbounded groups with no literal text between them make the match + * ambiguous, and the cost of proving a failure grows with each one: measured + * at 20 seconds of blocked event loop for four adjacent groups, on a template + * inside the 500-character cap. Node is single-threaded, so that is the whole + * API instance serving nothing. + * + * Refusing adjacency costs nothing real — a parser cannot tell two adjacent + * free-text fields apart anyway, so such a template was never going to work. + */ + const adjacent = /\(\?P<\w+>\.\+\?\)\(\?P<\w+>/.test( + announcePattern(body.template).pattern + ); + if (adjacent) { + throw createError({ + statusCode: 400, + message: + 'Two free-text fields cannot sit next to each other — put a separator between them.', + }); + } + + // Round-trip the template before anything is written: a stored template that + // its own regex cannot read is a broken definition handed to every member. + const sample = renderAnnounce(body.template, SAMPLE_FIELDS); + const { pattern } = announcePattern(body.template); + const match = toJsRegExp(pattern).exec(sample); + if (!match) { + throw createError({ + statusCode: 400, + message: `That template does not parse back. Rendered: ${sample}`, + }); + } + // A template that mentions no fields would announce a constant line, which + // parses perfectly and tells a client nothing. + if (!templateTokens(body.template).includes('name')) { + throw createError({ + statusCode: 400, + message: 'The template has to include {name} — a client cannot filter on a line with no release in it.', + }); + } + + const keep = (next: string | null | undefined, current: string): string => + next === null ? '' : next ? next : current; + + const config: IrcAnnounceConfig = { + host: body.host, + port: body.port, + tls: body.tls, + nick: body.nick, + realname: body.realname, + serverPassword: keep(body.serverPassword, stored.serverPassword), + saslUser: body.saslUser, + saslPassword: keep(body.saslPassword, stored.saslPassword), + perform: body.perform + ? body.perform.filter((line) => line.length > 0) + : stored.perform, + channel: body.channel, + channelKey: keep(body.channelKey, stored.channelKey), + template: body.template, + siteUrl: body.siteUrl.replace(/\/+$/, ''), + announceAdult: body.announceAdult, + }; + + await setIrcConfig(config); + await setIrcEnabled(body.enabled); + // Fire-and-forget: a server that will not answer must not hang the save that + // an operator needs to correct it. + void reconcile(); + + return { config: redactIrcConfig(config), enabled: body.enabled, sample }; +}); diff --git a/apps/api/routes/api/admin/irc/test.post.ts b/apps/api/routes/api/admin/irc/test.post.ts new file mode 100644 index 00000000..5375fa56 --- /dev/null +++ b/apps/api/routes/api/admin/irc/test.post.ts @@ -0,0 +1,45 @@ +/** + * POST /api/admin/irc/test + * + * Say one line in the channel, so an operator can see the bot works without + * waiting for somebody to upload something. + * + * The line is fixed text plus the admin's name. It is not operator-supplied, + * which is the point: a route that let staff put arbitrary text in a channel + * would be a broadcast surface with an audit entry and no rate limit worth the + * name. Announcing is what the bot is for. + * + * Refuses rather than queues when the bot is not connected. Queuing would + * return "sent" for a line that leaves at the next reconnection, minutes later, + * to an operator who is trying to find out whether the connection works. + */ +import { requireAdminSession } from '~~/utils/adminAuth'; +import { rateLimit, RATE_LIMITS } from '~~/utils/rateLimit'; +import { ircStatus, saySomething } from '~~/utils/irc/announcer'; + +export default defineEventHandler(async (event) => { + const { user } = await requireAdminSession(event); + await rateLimit(event, RATE_LIMITS.mutation); + + // Only refuse on what this instance can be sure of. Since the line travels + // through Redis to whichever instance holds the socket, an admin talking to a + // non-leader can still test the bot — which is the common case in a fleet. + const status = ircStatus(); + if (status.leader && status.state !== 'ready') { + throw createError({ + statusCode: 409, + message: `The bot is not in the channel (${status.state}${ + status.lastError ? `: ${status.lastError}` : '' + }).`, + }); + } + + const line = `Test line from ${user.username} — the announce bot is connected.`; + if (!(await saySomething(line))) { + throw createError({ + statusCode: 409, + message: 'Nothing is listening for lines — the bot is not connected.', + }); + } + return { sent: true, line }; +}); diff --git a/apps/api/routes/api/admin/logo.post.ts b/apps/api/routes/api/admin/logo.post.ts index 0b2b3144..b87d94ff 100644 --- a/apps/api/routes/api/admin/logo.post.ts +++ b/apps/api/routes/api/admin/logo.post.ts @@ -1,7 +1,11 @@ import { requireAdminSession } from '~~/utils/adminAuth'; import { setSetting, SETTINGS_KEYS } from '~~/utils/server'; import { randomBytes } from 'crypto'; -import { assertImageType } from '~~/utils/imageSniff'; +import { + assertImageType, + imageDimensions, + manifestIconSizes, +} from '~~/utils/imageSniff'; import { getStorage } from '~~/utils/storage'; import { resolveObjectKey } from '~~/utils/storage/keys'; @@ -91,6 +95,14 @@ export default defineEventHandler(async (event) => { // Save to settings await setSetting(SETTINGS_KEYS.SITE_LOGO_IMAGE, fileUrl); + // And the pixel size, measured from the bytes we still hold. The web app + // manifest declares this as the icon's `sizes`, and a browser believes the + // declaration — so it is measured here, once, rather than guessed at render + // time or re-read from a storage backend that may be S3. + await setSetting( + SETTINGS_KEYS.SITE_LOGO_IMAGE_SIZE, + manifestIconSizes(imageDimensions(file.data)) + ); // Delete old logo if it exists and is in uploads folder. The setting is // written by this route, so the value should always be a plain filename — diff --git a/apps/api/routes/api/admin/panic/encrypt.post.ts b/apps/api/routes/api/admin/panic/encrypt.post.ts index 2a6b1441..0b1d408d 100644 --- a/apps/api/routes/api/admin/panic/encrypt.post.ts +++ b/apps/api/routes/api/admin/panic/encrypt.post.ts @@ -1,4 +1,4 @@ -import { eq, and, asc } from 'drizzle-orm'; +import { eq, and, asc, isNull } from 'drizzle-orm'; import { db } from '@trackarr/db'; import { users, @@ -8,10 +8,16 @@ import { torrentComments, } from '@trackarr/db/schema'; import { requireAdminSession } from '~~/utils/adminAuth'; +import { auditDetail } from '~~/utils/audit'; +import { z } from 'zod'; +import { rateLimit, RATE_LIMITS } from '~~/utils/rateLimit'; import { + CURRENT_KDF_VERSION, deriveKey, generateSalt, - encryptField, + // `encryptFieldOnce` et non `encryptField` : une reprise doit reconnaître ce + // qui est déjà chiffré plutôt que le chiffrer une seconde fois. + encryptFieldOnce as encryptField, encrypt, } from '~~/utils/panic'; @@ -20,30 +26,44 @@ import { * Encrypt all sensitive database data * This is an emergency action that renders data unreadable */ +/* + * Le corps est borné, et l'appel limité — comme `restore`. + * + * `restore` porte les trois durcissements (seau `RATE_LIMITS.auth`, budget + * global, schéma `.max(200)`) et les justifie : « free CPU amplification », + * parce que la valeur atteint scrypt. `encrypt` dérive la MÊME clé depuis le + * MÊME mot de passe et n'en avait aucun — `readBody()` nu, aucune limite. + * + * Deux conséquences. Un `panicPassword` de plusieurs mégaoctets part dans + * scrypt à chaque appel ; et surtout `verifyPassword(admin.panicPasswordHash, + * …)` plus bas est un oracle : sans plafond, une session d'administration + * compromise essaie le mot de passe qui déchiffre TOUTE la base autant de fois + * qu'elle veut. Que la porte demande déjà une session admin ne change rien — + * c'est précisément le scénario contre lequel le mode panique existe. + */ +const bodySchema = z + .object({ + confirm: z.literal('ENCRYPT_ALL_DATA'), + panicPassword: z.string().min(1, 'Panic password is required').max(200), + }) + .strict(); + export default defineEventHandler(async (event) => { await requireAdminSession(event); - - const body = await readBody(event); - - if (body.confirm !== 'ENCRYPT_ALL_DATA') { - throw createError({ - statusCode: 400, - message: 'Confirmation required. Send { confirm: "ENCRYPT_ALL_DATA" }', - }); - } + await rateLimit(event, RATE_LIMITS.auth); + // Named before the body is read, so the entry exists even when a guard + // below rejects the request — an attempted panic is worth as much as a + // completed one, and the status code on the row says which it was. + // + // No `changes`: the body carries the raw panic password. + auditDetail(event, { action: 'panic.encrypt', targetType: 'instance' }); // The raw panic password is required so the encryption key can be // derived from it (not from the stored hash). Deriving from the // hash would leave BOTH KDF inputs (hash + salt) inside the very // dump panic mode is meant to protect — see finding C1. - if (!body.panicPassword || typeof body.panicPassword !== 'string') { - throw createError({ - statusCode: 400, - message: 'Panic password is required to encrypt.', - }); - } + const body = await readValidatedBody(event, bodySchema.parse); - // Check if already encrypted const currentState = await db.query.panicState.findFirst(); if (currentState?.isEncrypted) { throw createError({ @@ -52,11 +72,32 @@ export default defineEventHandler(async (event) => { }); } - // Get first admin with panic password hash - const admin = await db.query.users.findFirst({ - where: and(eq(users.isAdmin, true)), - orderBy: asc(users.createdAt), - }); + /* + * Une reprise, et non un second chiffrement. + * + * Les quatre boucles ci-dessous tournent ligne par ligne, hors transaction. + * Une interruption au milieu — délai HTTP, mémoire épuisée, conteneur + * redémarré — laissait la moitié des lignes chiffrées sous une clé dérivée + * d'un sel qui n'existait que dans la mémoire du processus, `is_encrypted` + * restant à `false` : la donnée était définitivement perdue, et relancer la + * route générait un NOUVEAU sel puis surchiffrait les lignes déjà faites. + * + * Désormais : le sel, le hachis et la version sont écrits AVANT la première + * boucle avec `phase = 'encrypting'`. Une interruption laisse donc une base + * restaurable, et une relance reprend avec le MÊME sel — les champs déjà + * chiffrés sont reconnus et laissés tels quels par `encryptFieldOnce`. + */ + const resuming = currentState?.phase === 'encrypting' && !!currentState.encryptionSalt; + + // Le détenteur du mot de passe. En reprise, c'est celui qu'on a enregistré : + // le re-sélectionner est précisément ce qui perdait la base quand + // l'administrateur d'origine était rétrogradé ou effaçait son compte. + const admin = resuming + ? { panicPasswordHash: currentState!.panicPasswordHash } + : await db.query.users.findFirst({ + where: and(eq(users.isAdmin, true), isNull(users.deletedAt)), + orderBy: asc(users.createdAt), + }); if (!admin?.panicPasswordHash) { throw createError({ @@ -80,13 +121,44 @@ export default defineEventHandler(async (event) => { // `encrypt()` and prefixed into each ciphertext. The legacy IV // column on `panic_state` is left null on fresh panics; restore // still reads it as a fallback when decrypting old data. - const salt = generateSalt(); + const salt = resuming ? currentState!.encryptionSalt! : generateSalt(); + const kdfVersion = resuming + ? (currentState!.kdfVersion ?? CURRENT_KDF_VERSION) + : CURRENT_KDF_VERSION; // Derive the key from the RAW panic password (kdf_version 2). The // stored hash is NOT a key input — a DB dump then only yields the // scrypt verifier + salt + ciphertext, forcing an offline // brute-force rather than instant decryption (finding C1). - const key = await deriveKey(body.panicPassword, Buffer.from(salt, 'base64')); + const key = await deriveKey( + body.panicPassword, + Buffer.from(salt, 'base64'), + kdfVersion + ); + + // Le sel, le hachis employé et la phase — AVANT de toucher la moindre ligne. + // C'est ce qui rend une interruption récupérable au lieu de fatale. + await db + .insert(panicState) + .values({ + id: 'singleton', + isEncrypted: false, + encryptionSalt: salt, + encryptionIv: null, + kdfVersion, + panicPasswordHash: admin.panicPasswordHash, + phase: 'encrypting', + }) + .onConflictDoUpdate({ + target: panicState.id, + set: { + encryptionSalt: salt, + encryptionIv: null, + kdfVersion, + panicPasswordHash: admin.panicPasswordHash, + phase: 'encrypting', + }, + }); // ── Encrypt sensitive user data ────────────────────────────── const allUsers = await db.select().from(users); @@ -97,6 +169,13 @@ export default defineEventHandler(async (event) => { authSalt: encryptField(user.authSalt, key)!, authVerifier: encryptField(user.authVerifier, key)!, passkey: encryptField(user.passkey, key)!, + // The two read keys are credentials like the passkey is. Leaving them + // out would mean a panicked database still carrying live secrets in + // plaintext — a "the data is encrypted" that is only mostly true, which + // is the kind of gap nobody finds until it matters. `encryptField` + // passes null through, so an account that never minted one stays null. + rssKey: encryptField(user.rssKey, key) ?? undefined, + apiKey: encryptField(user.apiKey, key) ?? undefined, lastIp: encryptField(user.lastIp, key) ?? undefined, }) .where(eq(users.id, user.id)); @@ -105,6 +184,18 @@ export default defineEventHandler(async (event) => { // ── Encrypt torrent data ───────────────────────────────────── const allTorrents = await db.select().from(torrents); for (const torrent of allTorrents) { + /* + * Une ligne déjà traitée se reconnaît à un marqueur exact, pas à une forme. + * + * Ce tour-ci n'est pas idempotent comme celui des utilisateurs : il écrase + * `size` par 0 et enveloppe la description dans `[PANIC_META:…]`. Repasser + * dessus enregistrerait donc `size: 0` comme taille « originale » — perdue + * pour de bon — et produirait un second niveau d'enveloppe que la + * restauration ne défait pas. Le préfixe est écrit par nous, il est donc + * une preuve et non une supposition. + */ + if (torrent.description?.startsWith('[PANIC_META:')) continue; + const originalMeta = JSON.stringify({ size: torrent.size, categoryId: torrent.categoryId, @@ -160,29 +251,56 @@ export default defineEventHandler(async (event) => { // The column stays in the schema for backward-compatible restore // of databases encrypted before this fix. await db - .insert(panicState) - .values({ - id: 'singleton', + .update(panicState) + .set({ isEncrypted: true, encryptedAt: new Date(), - encryptionSalt: salt, - encryptionIv: null, - kdfVersion: 2, + phase: 'encrypted', }) - .onConflictDoUpdate({ - target: panicState.id, - set: { - isEncrypted: true, - encryptedAt: new Date(), - encryptionSalt: salt, - encryptionIv: null, - kdfVersion: 2, - }, - }); + .where(eq(panicState.id, 'singleton')); return { success: true, message: 'Database encrypted. Use panic password to restore.', encryptedAt: new Date().toISOString(), + /* + * Ce que « encrypted » couvre, et ce qu'il ne couvre pas. + * + * La réponse disait « Database encrypted » sans réserve. La couverture + * réelle est : `users` (sel et vérificateur d'authentification, passkey, + * clés RSS et API, dernière IP), `torrents` (nom, description, octets du + * .torrent, taille, catégorie), `forum_posts.content` et + * `torrent_comments.content`. + * + * Restent EN CLAIR : `messages.body` et `room_messages.body` — soit tout le + * texte des conversations privées et du salon public, le chiffrement de + * bout en bout étant optionnel et par conversation —, les tickets et leurs + * messages, les titres de sujets de forum, la bio et le nom affiché, + * `totp_secret`, la configuration serveur des canaux, le journal de + * connexions (adresses, agents), les signalements de l'anti-triche, le + * journal d'audit, le miroir des torrents distants et la clé privée de + * fédération. + * + * Étendre la couverture ferait de ces boucles quelque chose de bien plus + * long, ce qui rend la reprise (voir `phase`) d'autant plus nécessaire. + * D'ici là, l'énoncé est ici plutôt que dans une promesse tacite : + * `doc/guide/messaging.md` fait exactement ce travail de précision pour le + * cadenas de bout en bout. + */ + covers: [ + 'users.auth_salt', 'users.auth_verifier', 'users.passkey', + 'users.rss_key', 'users.api_key', 'users.last_ip', + 'torrents.name', 'torrents.description', 'torrents.torrent_data', + 'torrents.size', 'torrents.category_id', + 'forum_posts.content', 'torrent_comments.content', + ], + leavesInCleartext: [ + 'messages.body', 'room_messages.body', + 'tickets.*', 'ticket_messages.*', + 'forum_topics.title', 'users.bio', 'users.display_name', + 'users.totp_secret', 'notification_channels.server_config', + 'login_events.*', 'anticheat_flags.ip', 'anticheat_flags.user_agent', + 'audit_log.*', 'remote_torrents.*', 'federation_config.private_key', + ], }; }); diff --git a/apps/api/routes/api/admin/panic/restore.post.ts b/apps/api/routes/api/admin/panic/restore.post.ts index 5a359620..d1e85bbd 100644 --- a/apps/api/routes/api/admin/panic/restore.post.ts +++ b/apps/api/routes/api/admin/panic/restore.post.ts @@ -1,4 +1,4 @@ -import { eq, and, asc } from 'drizzle-orm'; +import { eq, and, asc, isNull } from 'drizzle-orm'; import { db } from '@trackarr/db'; import { users, @@ -94,13 +94,32 @@ export default defineEventHandler(async (event) => { }); } - // Get first admin to verify panic password - const admin = await db.query.users.findFirst({ - where: and(eq(users.isAdmin, true)), - orderBy: asc(users.createdAt), - }); + /* + * Le hachis contre lequel on vérifie : celui du chiffrement, pas celui de + * l'administrateur le plus ancien d'aujourd'hui. + * + * `encrypt` et cette route résolvaient toutes deux le détenteur par + * « `is_admin = true` trié par `created_at` », et rien n'enregistrait qui + * c'était. Trois façons de perdre la base avec le bon mot de passe en main : + * l'administrateur qui a chiffré est rétrogradé (on vérifiait alors contre le + * hachis d'un autre → 401), il efface son compte (`eraseAccount` vide + * `panic_password_hash` sans toucher `is_admin`, et `created_at` reste le plus + * ancien → 500), ou un administrateur plus récent était le seul à avoir + * configuré un mot de passe. + * + * `encrypt` persiste désormais le hachis employé. Le repli sur la + * re-sélection ne sert qu'aux bases chiffrées avant ce correctif. + */ + let expectedHash = currentState.panicPasswordHash ?? null; + if (!expectedHash) { + const admin = await db.query.users.findFirst({ + where: and(eq(users.isAdmin, true), isNull(users.deletedAt)), + orderBy: asc(users.createdAt), + }); + expectedHash = admin?.panicPasswordHash ?? null; + } - if (!admin?.panicPasswordHash) { + if (!expectedHash) { throw createError({ statusCode: 500, message: 'Admin panic password hash not found', @@ -108,7 +127,7 @@ export default defineEventHandler(async (event) => { } // Verify panic password matches stored hash - const isValid = await verifyPassword(admin.panicPasswordHash, body.panicPassword); + const isValid = await verifyPassword(expectedHash, body.panicPassword); if (!isValid) { throw createError({ statusCode: 401, @@ -123,17 +142,43 @@ export default defineEventHandler(async (event) => { // is embedded per-record (`iv:ct:tag`); `legacyIv` is forwarded for // databases encrypted before per-record IVs (single global IV). const kdfInput = - (currentState.kdfVersion ?? 1) >= 2 - ? body.panicPassword - : admin.panicPasswordHash; + (currentState.kdfVersion ?? 1) >= 2 ? body.panicPassword : expectedHash; const key = await deriveKey( kdfInput, - Buffer.from(currentState.encryptionSalt, 'base64') + Buffer.from(currentState.encryptionSalt, 'base64'), + currentState.kdfVersion ?? 1 ); const legacyIv = currentState.encryptionIv ? Buffer.from(currentState.encryptionIv, 'base64') : undefined; + /* + * Les échecs sont comptés, et ils décident de la suite. + * + * Chaque ligne était dans un `try { … } catch { console.error(…) }` qui + * continuait, puis la route effaçait inconditionnellement `encryption_salt` + * et répondait « Database restored successfully ». Une seule ligne `users` + * dont `auth_salt` ne déchiffrait pas — rot de bit, surchiffrement, ligne + * insérée pendant le chiffrement — et ce membre restait chiffré POUR + * TOUJOURS : le `try` englobe tout l'`update`, donc rien n'était écrit pour + * lui, et le sel disparaissait ensuite. Son mot de passe et son passkey + * d'annonce étaient morts, sans qu'aucun compteur ne le dise. + * + * Tant qu'il reste un échec, le sel et la phase sont conservés : la + * restauration reste possible après correction, et elle est reprenable + * puisque les lignes déjà déchiffrées ne déchiffrent plus. + */ + const failures: string[] = []; + const fail = (what: string, id: string, err: unknown) => { + failures.push(`${what}:${id}`); + console.error(`[panic] restore failed for ${what} ${id}:`, err); + }; + + await db + .update(panicState) + .set({ phase: 'restoring' }) + .where(eq(panicState.id, 'singleton')); + // ── Decrypt user data ──────────────────────────────────────── const allUsers = await db.select().from(users); for (const user of allUsers) { @@ -144,11 +189,13 @@ export default defineEventHandler(async (event) => { authSalt: decryptField(user.authSalt, key, legacyIv)!, authVerifier: decryptField(user.authVerifier, key, legacyIv)!, passkey: decryptField(user.passkey, key, legacyIv)!, + rssKey: decryptField(user.rssKey, key, legacyIv) ?? undefined, + apiKey: decryptField(user.apiKey, key, legacyIv) ?? undefined, lastIp: decryptField(user.lastIp, key, legacyIv) ?? undefined, }) .where(eq(users.id, user.id)); } catch (err) { - console.error(`Failed to decrypt user ${user.id}:`, err); + fail('user', user.id, err); } } @@ -174,7 +221,10 @@ export default defineEventHandler(async (event) => { originalSize = meta.size ?? 0; originalCategoryId = meta.categoryId ?? null; } catch { - console.error(`Failed to decrypt metadata for torrent ${torrent.id}`); + // La taille et la catégorie sont dans ces métadonnées, et le + // chiffrement a mis `size` à 0 : les perdre en silence était pire + // que de refuser de terminer. + fail('torrent-meta', torrent.id, 'metadata'); } decryptedDesc = encryptedDescPart @@ -193,7 +243,7 @@ export default defineEventHandler(async (event) => { const decryptedBase64 = decrypt(encryptedStr, key, legacyIv); decryptedTorrentData = Buffer.from(decryptedBase64, 'base64'); } catch { - console.error(`Failed to decrypt torrentData for torrent ${torrent.id}`); + fail('torrent-data', torrent.id, 'torrentData'); } } @@ -208,7 +258,7 @@ export default defineEventHandler(async (event) => { }) .where(eq(torrents.id, torrent.id)); } catch (err) { - console.error(`Failed to decrypt torrent ${torrent.id}:`, err); + fail('torrent', torrent.id, err); } } @@ -223,7 +273,7 @@ export default defineEventHandler(async (event) => { }) .where(eq(forumPosts.id, post.id)); } catch (err) { - console.error(`Failed to decrypt post ${post.id}:`, err); + fail('post', post.id, err); } } @@ -238,13 +288,32 @@ export default defineEventHandler(async (event) => { }) .where(eq(torrentComments.id, comment.id)); } catch (err) { - console.error(`Failed to decrypt comment ${comment.id}:`, err); + fail('comment', comment.id, err); } } // ===================================================================== // Update panic state // ===================================================================== + if (failures.length > 0) { + // On garde tout ce qui permet de recommencer. Lever le drapeau ici + // signifierait « c'est fini » à une base qui ne l'est pas, et effacer le + // sel rendrait le reste illisible pour de bon. + await db + .update(panicState) + .set({ phase: 'restoring' }) + .where(eq(panicState.id, 'singleton')); + + throw createError({ + statusCode: 500, + message: + `Restore incomplete: ${failures.length} record(s) could not be decrypted. ` + + 'The encryption salt has been KEPT so the restore can be retried once the ' + + 'cause is fixed. Do not re-encrypt.', + data: { failed: failures.length, sample: failures.slice(0, 20) }, + }); + } + await db .update(panicState) .set({ @@ -252,6 +321,8 @@ export default defineEventHandler(async (event) => { encryptedAt: null, encryptionSalt: null, encryptionIv: null, + panicPasswordHash: null, + phase: 'idle', }) .where(eq(panicState.id, 'singleton')); diff --git a/apps/api/routes/api/admin/reports/[id].put.ts b/apps/api/routes/api/admin/reports/[id].put.ts index 233f8998..63974804 100644 --- a/apps/api/routes/api/admin/reports/[id].put.ts +++ b/apps/api/routes/api/admin/reports/[id].put.ts @@ -34,8 +34,8 @@ import { db, schema } from '@trackarr/db'; import { requireModeratorSession, invalidateBanCache, - invalidateRoleCache, } from '~~/utils/adminAuth'; +import { invalidateRoleCache } from '~~/utils/liveRoles'; import { validateBody } from '~~/utils/schemas'; import { relinquishOwnership } from '~~/utils/owner'; import { eq } from 'drizzle-orm'; diff --git a/apps/api/routes/api/admin/settings.get.ts b/apps/api/routes/api/admin/settings.get.ts index f390c2ea..113577b9 100644 --- a/apps/api/routes/api/admin/settings.get.ts +++ b/apps/api/routes/api/admin/settings.get.ts @@ -45,6 +45,9 @@ import { getFeature3Desc, isInviteEnabled, getDefaultInvites, + getAuditRetentionDays, + getLoginEventRetentionDays, + getSavedSearchMaxPerUser, } from '~~/utils/server'; import { getRequire2FAScope, @@ -93,6 +96,12 @@ export default defineEventHandler(async (event) => { const inviteEnabled = await isInviteEnabled(); const defaultInvites = await getDefaultInvites(); const require2FAScope = await getRequire2FAScope(); + // Staff audit retention. Read here rather than only from `/api/privacy` so + // the admin console can offer the control next to the register itself — a + // setting with no way in is a setting only its author can change. + const auditRetentionDays = await getAuditRetentionDays(); + const loginEventRetentionDays = await getLoginEventRetentionDays(); + const savedSearchMaxPerUser = await getSavedSearchMaxPerUser(); const notificationsRetentionReadDays = await getNotificationsRetentionReadDays(); const notificationsRetentionUnreadDays = @@ -159,6 +168,9 @@ export default defineEventHandler(async (event) => { inviteEnabled, defaultInvites, require2FAScope, + auditRetentionDays, + loginEventRetentionDays, + savedSearchMaxPerUser, notificationsRetentionReadDays, notificationsRetentionUnreadDays, requestAutoValidateHours, diff --git a/apps/api/routes/api/admin/settings.put.ts b/apps/api/routes/api/admin/settings.put.ts index fc902880..1cf8cb2d 100644 --- a/apps/api/routes/api/admin/settings.put.ts +++ b/apps/api/routes/api/admin/settings.put.ts @@ -10,6 +10,7 @@ import { SETTINGS_KEYS, } from '~~/utils/server'; import { validateBody, adminSettingsSchema } from '~~/utils/schemas'; +import { auditDetail } from '~~/utils/audit'; /** * PUT /api/admin/settings @@ -21,6 +22,25 @@ export default defineEventHandler(async (event) => { // Validate request body with Zod const body = await validateBody(event, adminSettingsSchema); + /** + * Which settings this request touched — the KEYS, never the values. + * + * "Who changed the registration mode, and when" is the question an operator + * asks, and the key answers it. Recording the values would mean a listing + * page that reproduces whatever an admin typed into any settings field, + * which is a category of leak nobody would notice until it mattered: this + * body is wide, it grows with every feature, and one future field carrying + * a token or a URL is all it takes. + * + * A route that wants a before/after on a specific, non-sensitive setting can + * say so explicitly — see the ban and role routes for the shape. + */ + auditDetail(event, { + action: 'settings.update', + targetType: 'settings', + changes: { fields: Object.keys(body).filter((k) => body[k as keyof typeof body] !== undefined) }, + }); + if (body.searchFields !== undefined) { // Deduplicated and stored as CSV: the list is short and a JSON array would // add nothing but a format to parse on both sides. @@ -252,6 +272,38 @@ export default defineEventHandler(async (event) => { String(Math.floor(body.notificationsRetentionReadDays)), ); } + if ( + typeof body.auditRetentionDays === 'number' && + body.auditRetentionDays >= 0 && + body.auditRetentionDays <= 3650 + ) { + await setSetting( + SETTINGS_KEYS.AUDIT_LOG_RETENTION_DAYS, + String(Math.floor(body.auditRetentionDays)), + ); + } + + if ( + typeof body.loginEventRetentionDays === 'number' && + body.loginEventRetentionDays >= 0 && + body.loginEventRetentionDays <= 3650 + ) { + await setSetting( + SETTINGS_KEYS.LOGIN_EVENT_RETENTION_DAYS, + String(Math.floor(body.loginEventRetentionDays)) + ); + } + + if ( + typeof body.savedSearchMaxPerUser === 'number' && + body.savedSearchMaxPerUser >= 1 && + body.savedSearchMaxPerUser <= 200 + ) { + await setSetting( + SETTINGS_KEYS.SAVED_SEARCH_MAX_PER_USER, + String(Math.floor(body.savedSearchMaxPerUser)) + ); + } if ( typeof body.notificationsRetentionUnreadDays === 'number' && body.notificationsRetentionUnreadDays >= 1 && diff --git a/apps/api/routes/api/admin/stats.get.ts b/apps/api/routes/api/admin/stats.get.ts index 9f8ddee4..3b7ef380 100644 --- a/apps/api/routes/api/admin/stats.get.ts +++ b/apps/api/routes/api/admin/stats.get.ts @@ -4,6 +4,7 @@ import { sql } from 'drizzle-orm'; import { redis } from '~~/utils/server'; import { requireAdminSession } from '~~/utils/adminAuth'; import { rollupActivePeerCounts } from '~~/utils/peerStats'; +import { readTrackerHealth } from '~~/utils/trackerHealth'; export default defineEventHandler(async (event) => { // Require admin authentication @@ -58,8 +59,19 @@ export default defineEventHandler(async (event) => { // see apps/tracker/internal/config/config.go). The api process and // tracker process share the same env so this read is authoritative // without an internal RPC. + // L'état vient de la MÊME sonde que le badge de la page d'accueil. + // + // C'était `status: 'running'` en dur. Le tableau de bord affichait donc + // « TRACKER · EN LIGNE » quoi qu'il arrive — tracker arrêté, base coupée, + // Redis muet — pendant que l'accueil, qui sonde vraiment, affichait + // « hors ligne ». Les deux pages se contredisaient à l'écran, et c'était la + // page d'administration qui mentait : celle vers laquelle on se tourne quand + // quelque chose cloche. + const health = await readTrackerHealth(); + return { - status: 'running', + status: health.online ? 'running' : 'down', + trackerCheckedAt: health.checkedAt, cached: { torrents: totalTorrents, peers: totalPeers, diff --git a/apps/api/routes/api/admin/system/version.get.ts b/apps/api/routes/api/admin/system/version.get.ts index e9e8f0ca..4079fe9a 100644 --- a/apps/api/routes/api/admin/system/version.get.ts +++ b/apps/api/routes/api/admin/system/version.get.ts @@ -127,6 +127,11 @@ export default defineEventHandler(async (event) => { Accept: 'application/vnd.github.v3+json', 'User-Agent': 'Trackarr-Admin', }, + // Le seul appel sortant du projet qui n'avait pas de délai + // d'expiration : les huit autres portent un `AbortSignal`. Une + // connexion GitHub qui pend retenait la requête d'administration + // jusqu'au délai par défaut de Node, soit indéfiniment. + signal: AbortSignal.timeout(8000), } ); diff --git a/apps/api/routes/api/admin/templates/[id].delete.ts b/apps/api/routes/api/admin/templates/[id].delete.ts index 6baf0372..5741956c 100644 --- a/apps/api/routes/api/admin/templates/[id].delete.ts +++ b/apps/api/routes/api/admin/templates/[id].delete.ts @@ -15,13 +15,14 @@ import { z } from 'zod'; import { db, schema } from '@trackarr/db'; import { rateLimit, RATE_LIMITS } from '~~/utils/rateLimit'; import { requireAdminSession } from '~~/utils/adminAuth'; +import { validateRouterParams } from '~~/utils/schemas'; const paramsSchema = z.object({ id: z.string().uuid() }); export default defineEventHandler(async (event) => { await requireAdminSession(event); await rateLimit(event, RATE_LIMITS.admin); - const { id } = paramsSchema.parse(getRouterParams(event)); + const { id } = validateRouterParams(event, paramsSchema); const [deleted] = await db .delete(schema.presentationTemplates) diff --git a/apps/api/routes/api/admin/templates/[id].patch.ts b/apps/api/routes/api/admin/templates/[id].patch.ts index 87d5af5e..ba3e5c25 100644 --- a/apps/api/routes/api/admin/templates/[id].patch.ts +++ b/apps/api/routes/api/admin/templates/[id].patch.ts @@ -16,6 +16,7 @@ import { db, schema } from '@trackarr/db'; import { rateLimit, RATE_LIMITS } from '~~/utils/rateLimit'; import { requireAdminSession } from '~~/utils/adminAuth'; import { assertTemplateGrammar } from '~~/utils/templateGrammar'; +import { validateRouterParams } from '~~/utils/schemas'; const paramsSchema = z.object({ id: z.string().uuid() }); const bodySchema = z @@ -44,7 +45,7 @@ const bodySchema = z export default defineEventHandler(async (event) => { await requireAdminSession(event); await rateLimit(event, RATE_LIMITS.admin); - const { id } = paramsSchema.parse(getRouterParams(event)); + const { id } = validateRouterParams(event, paramsSchema); const body = await readValidatedBody(event, bodySchema.parse); if (body.content !== undefined) assertTemplateGrammar(body.content); diff --git a/apps/api/routes/api/admin/themes/[id].delete.ts b/apps/api/routes/api/admin/themes/[id].delete.ts index de347d11..ec3c0e01 100644 --- a/apps/api/routes/api/admin/themes/[id].delete.ts +++ b/apps/api/routes/api/admin/themes/[id].delete.ts @@ -18,13 +18,14 @@ import { db, schema } from '@trackarr/db'; import { requireAdminSession } from '~~/utils/adminAuth'; import { rateLimit, RATE_LIMITS } from '~~/utils/rateLimit'; import { bumpThemeVersion, releaseThemeReferences } from '~~/utils/themes'; +import { validateRouterParams } from '~~/utils/schemas'; const paramsSchema = z.object({ id: z.string().uuid() }); export default defineEventHandler(async (event) => { await requireAdminSession(event); await rateLimit(event, RATE_LIMITS.mutation); - const { id } = paramsSchema.parse(getRouterParams(event)); + const { id } = validateRouterParams(event, paramsSchema); const [theme] = await db .select({ slug: schema.themes.slug }) diff --git a/apps/api/routes/api/admin/themes/[id].put.ts b/apps/api/routes/api/admin/themes/[id].put.ts index 2100e921..b296ce9b 100644 --- a/apps/api/routes/api/admin/themes/[id].put.ts +++ b/apps/api/routes/api/admin/themes/[id].put.ts @@ -12,7 +12,7 @@ import { z } from 'zod'; import { db, schema } from '@trackarr/db'; import { requireAdminSession } from '~~/utils/adminAuth'; import { rateLimit, RATE_LIMITS } from '~~/utils/rateLimit'; -import { validateBody } from '~~/utils/schemas'; +import { validateBody, validateRouterParams } from '~~/utils/schemas'; import { uploadTokenProblems } from '~~/utils/fonts'; import { updateThemeSchema } from '~~/utils/themeSchemas'; import { @@ -26,7 +26,7 @@ const paramsSchema = z.object({ id: z.string().uuid() }); export default defineEventHandler(async (event) => { await requireAdminSession(event); await rateLimit(event, RATE_LIMITS.mutation); - const { id } = paramsSchema.parse(getRouterParams(event)); + const { id } = validateRouterParams(event, paramsSchema); const body = await validateBody(event, updateThemeSchema); // A font role may name an uploaded face. The shared validator accepts the // SHAPE `upload:`; only the database can say whether that face exists diff --git a/apps/api/routes/api/admin/themes/[id]/css.get.ts b/apps/api/routes/api/admin/themes/[id]/css.get.ts index 265a77ca..9d783b7d 100644 --- a/apps/api/routes/api/admin/themes/[id]/css.get.ts +++ b/apps/api/routes/api/admin/themes/[id]/css.get.ts @@ -15,12 +15,13 @@ import { z } from 'zod'; import { db, schema } from '@trackarr/db'; import { requireOwnerSession } from '~~/utils/adminAuth'; import { MAX_CUSTOM_CSS_BYTES } from '~~/utils/themeCss'; +import { validateRouterParams } from '~~/utils/schemas'; const paramsSchema = z.object({ id: z.string().uuid() }); export default defineEventHandler(async (event) => { await requireOwnerSession(event); - const { id } = paramsSchema.parse(getRouterParams(event)); + const { id } = validateRouterParams(event, paramsSchema); const [theme] = await db .select({ css: schema.themes.customCss }) diff --git a/apps/api/routes/api/admin/themes/[id]/css.put.ts b/apps/api/routes/api/admin/themes/[id]/css.put.ts index cae543d6..347b896d 100644 --- a/apps/api/routes/api/admin/themes/[id]/css.put.ts +++ b/apps/api/routes/api/admin/themes/[id]/css.put.ts @@ -31,7 +31,7 @@ import { z } from 'zod'; import { db, schema } from '@trackarr/db'; import { requireOwnerSession, requireFreshAuth } from '~~/utils/adminAuth'; import { rateLimit, RATE_LIMITS } from '~~/utils/rateLimit'; -import { validateBody } from '~~/utils/schemas'; +import { validateBody, validateRouterParams } from '~~/utils/schemas'; import { bumpThemeVersion } from '~~/utils/themes'; import { MAX_CUSTOM_CSS_BYTES, sanitiseCustomCss } from '~~/utils/themeCss'; @@ -50,7 +50,7 @@ export default defineEventHandler(async (event) => { await requireFreshAuth(event); await rateLimit(event, RATE_LIMITS.mutation); - const { id } = paramsSchema.parse(getRouterParams(event)); + const { id } = validateRouterParams(event, paramsSchema); const { css } = await validateBody(event, bodySchema); const [theme] = await db diff --git a/apps/api/routes/api/admin/torznab/users/[id]/reset.post.ts b/apps/api/routes/api/admin/torznab/users/[id]/reset.post.ts index 4e98780c..15d0da41 100644 --- a/apps/api/routes/api/admin/torznab/users/[id]/reset.post.ts +++ b/apps/api/routes/api/admin/torznab/users/[id]/reset.post.ts @@ -7,7 +7,10 @@ import { requireAdminSession } from '~~/utils/adminAuth'; import { db, schema } from '@trackarr/db'; import { eq } from 'drizzle-orm'; import { generatePasskey } from '~~/utils/auth'; -import { clearTorznabUserStats } from '~~/utils/torznabStats'; +import { + carryTorznabBlock, + retireTorznabPasskey, +} from '~~/utils/torznabStats'; export default defineEventHandler(async (event) => { await requireAdminSession(event); @@ -40,14 +43,19 @@ export default defineEventHandler(async (event) => { // Generate new passkey const newPasskey = generatePasskey(); + // Resetting a leaked key is not lifting a restriction, and the two live on + // the same page: without this, the reset button beside the block button + // silently undid it. Unblocking is its own route, deliberately. + const carried = await carryTorznabBlock(oldPasskey, newPasskey); + // Update user await db .update(schema.users) .set({ passkey: newPasskey }) .where(eq(schema.users.id, userId)); - // Clear old stats - await clearTorznabUserStats(oldPasskey); + // Block entry and counters for a value that is nobody's any more. + await retireTorznabPasskey(oldPasskey, carried); return { success: true, diff --git a/apps/api/routes/api/admin/users/[id]/ban.post.ts b/apps/api/routes/api/admin/users/[id]/ban.post.ts index a92306c3..414c78a3 100644 --- a/apps/api/routes/api/admin/users/[id]/ban.post.ts +++ b/apps/api/routes/api/admin/users/[id]/ban.post.ts @@ -16,9 +16,9 @@ import { db } from '@trackarr/db'; import { users, bannedIps, torrents } from '@trackarr/db/schema'; import { invalidateBanCache, - invalidateRoleCache, requireModeratorSession, } from '~~/utils/adminAuth'; +import { invalidateRoleCache } from '~~/utils/liveRoles'; import { relinquishOwnership } from '~~/utils/owner'; import { validateBody, @@ -27,6 +27,7 @@ import { uuidSchema, } from '~~/utils/schemas'; import { notify } from '~~/utils/notify'; +import { auditDetail } from '~~/utils/audit'; export default defineEventHandler(async (event) => { const { user: actor } = await requireModeratorSession(event); @@ -52,6 +53,23 @@ export default defineEventHandler(async (event) => { }); } + // The audit row for this request. Named here rather than left to the + // path-derived fallback because a ban is the decision most often disputed + // later, and "who, whom, why, for how long" is what the dispute turns on. + auditDetail(event, { + action: 'user.ban', + targetType: 'user', + targetId: target.id, + targetLabel: target.username, + changes: { + isBanned: { from: target.isBanned, to: true }, + reason, + // Present only when the caller asked for a timed ban; a permanent one + // carries no duration and recording `null` would read as "cleared". + ...(body.duration ? { durationSeconds: body.duration } : {}), + }, + }); + await db .update(users) .set({ @@ -77,7 +95,10 @@ export default defineEventHandler(async (event) => { // and partners purge their mirror. Enumerating them here would be a second // answer to "what is no longer federatable", and the two would drift. - if (target.lastIp) { + // Sur demande explicite seulement : voir la note sur `banIp` dans + // `adminBanSchema`. Une adresse partagée bannit des tiers, et la porte de + // bannissement précède l'authentification. + if (body.banIp && target.lastIp) { const banReason = `Banned user: ${target.username}. Reason: ${reason}`; await db .insert(bannedIps) diff --git a/apps/api/routes/api/admin/users/[id]/role.put.ts b/apps/api/routes/api/admin/users/[id]/role.put.ts index 4466d7e8..10af29d4 100644 --- a/apps/api/routes/api/admin/users/[id]/role.put.ts +++ b/apps/api/routes/api/admin/users/[id]/role.put.ts @@ -12,10 +12,11 @@ */ import { db, schema } from '@trackarr/db'; import { requireAdminSession, requireFreshAuth } from '~~/utils/adminAuth'; -import { validateBody } from '~~/utils/schemas'; +import { validateBody, validateRouterParams } from '~~/utils/schemas'; import { eq, and, ne, count } from 'drizzle-orm'; import { z } from 'zod'; import { notify } from '~~/utils/notify'; +import { auditDetail } from '~~/utils/audit'; const paramsSchema = z.object({ id: z.string().uuid() }); const bodySchema = z @@ -30,7 +31,7 @@ export default defineEventHandler(async (event) => { // Privilege grants are the highest-impact admin action — require a // fresh login on top of the admin gate (finding L10). await requireFreshAuth(event); - const { id } = paramsSchema.parse(getRouterParams(event)); + const { id } = validateRouterParams(event, paramsSchema); // Routed through validateBody so a Zod failure renders as a clean // 400 with a human message, not a wall of `unrecognized_keys` issue // objects. The frontend used to send the whole RegistryUser object @@ -39,7 +40,13 @@ export default defineEventHandler(async (event) => { const target = await db.query.users.findFirst({ where: eq(schema.users.id, id), - columns: { id: true, isAdmin: true, isModerator: true, isOwner: true }, + columns: { + id: true, + username: true, + isAdmin: true, + isModerator: true, + isOwner: true, + }, }); if (!target) { throw createError({ statusCode: 404, message: 'User not found' }); @@ -91,6 +98,19 @@ export default defineEventHandler(async (event) => { } } + // A privilege grant is the row an audit log exists for: it is how one + // compromised account becomes several. Both flags, both directions. + auditDetail(event, { + action: 'user.role', + targetType: 'user', + targetId: target.id, + targetLabel: target.username, + changes: { + isAdmin: { from: target.isAdmin, to: body.isAdmin }, + isModerator: { from: target.isModerator, to: body.isModerator }, + }, + }); + const [updated] = await db .update(schema.users) .set({ @@ -98,7 +118,19 @@ export default defineEventHandler(async (event) => { isModerator: body.isModerator, }) .where(eq(schema.users.id, id)) - .returning(); + // Une projection, pas la ligne entière. `.returning()` nu renvoyait les + // 33 colonnes de `users` — dont `passkey`, `rssKey` et `apiKey`, stockées + // EN CLAIR, plus `authVerifier`, `totpSecret`, `panicPasswordHash` et + // `lastIp`. Nommer un modérateur rendait donc sa passkey d'annonce à + // l'administrateur, qui pouvait dès lors annoncer à sa place. Le point de + // terminaison n'a besoin que de deux booléens. + .returning({ + id: schema.users.id, + username: schema.users.username, + isAdmin: schema.users.isAdmin, + isModerator: schema.users.isModerator, + isOwner: schema.users.isOwner, + }); // Bust the cached role so the staff gates observe the change // within the request, not after the 60 s TTL — and a demotion diff --git a/apps/api/routes/api/admin/users/[id]/roles/[roleId].delete.ts b/apps/api/routes/api/admin/users/[id]/roles/[roleId].delete.ts index 9616a287..b5973cab 100644 --- a/apps/api/routes/api/admin/users/[id]/roles/[roleId].delete.ts +++ b/apps/api/routes/api/admin/users/[id]/roles/[roleId].delete.ts @@ -19,6 +19,7 @@ import { invalidateBypassCache } from '~~/utils/torrentModeration'; import { and, eq } from 'drizzle-orm'; import { z } from 'zod'; import { notify } from '~~/utils/notify'; +import { validateRouterParams } from '~~/utils/schemas'; const paramsSchema = z.object({ id: z.string().uuid(), @@ -27,7 +28,7 @@ const paramsSchema = z.object({ export default defineEventHandler(async (event) => { const { user: actor } = await requireAdminSession(event); - const { id, roleId } = paramsSchema.parse(getRouterParams(event)); + const { id, roleId } = validateRouterParams(event, paramsSchema); const [removed] = await db .delete(schema.userRoles) diff --git a/apps/api/routes/api/admin/users/[id]/roles/index.get.ts b/apps/api/routes/api/admin/users/[id]/roles/index.get.ts index 1b1c14c8..3981a0cb 100644 --- a/apps/api/routes/api/admin/users/[id]/roles/index.get.ts +++ b/apps/api/routes/api/admin/users/[id]/roles/index.get.ts @@ -14,12 +14,13 @@ import { db, schema } from '@trackarr/db'; import { requireAdminSession } from '~~/utils/adminAuth'; import { eq, asc } from 'drizzle-orm'; import { z } from 'zod'; +import { validateRouterParams } from '~~/utils/schemas'; const paramsSchema = z.object({ id: z.string().uuid() }); export default defineEventHandler(async (event) => { await requireAdminSession(event); - const { id } = paramsSchema.parse(getRouterParams(event)); + const { id } = validateRouterParams(event, paramsSchema); const [user] = await db .select({ id: schema.users.id }) diff --git a/apps/api/routes/api/admin/users/[id]/roles/index.post.ts b/apps/api/routes/api/admin/users/[id]/roles/index.post.ts index c093d3cc..379fd275 100644 --- a/apps/api/routes/api/admin/users/[id]/roles/index.post.ts +++ b/apps/api/routes/api/admin/users/[id]/roles/index.post.ts @@ -16,7 +16,7 @@ import { db, schema } from '@trackarr/db'; import { requireAdminSession } from '~~/utils/adminAuth'; import { invalidateBypassCache } from '~~/utils/torrentModeration'; -import { validateBody } from '~~/utils/schemas'; +import { validateBody, validateRouterParams } from '~~/utils/schemas'; import { and, eq } from 'drizzle-orm'; import { z } from 'zod'; import { notify } from '~~/utils/notify'; @@ -28,7 +28,7 @@ const bodySchema = z.object({ export default defineEventHandler(async (event) => { const { user: actor } = await requireAdminSession(event); - const { id } = paramsSchema.parse(getRouterParams(event)); + const { id } = validateRouterParams(event, paramsSchema); const body = await validateBody(event, bodySchema); const [user] = await db diff --git a/apps/api/routes/api/admin/users/[id]/unban.post.ts b/apps/api/routes/api/admin/users/[id]/unban.post.ts index ca1ad551..de12a33b 100644 --- a/apps/api/routes/api/admin/users/[id]/unban.post.ts +++ b/apps/api/routes/api/admin/users/[id]/unban.post.ts @@ -18,6 +18,7 @@ import { users, bannedIps, torrents } from '@trackarr/db/schema'; import { invalidateBanCache, requireModeratorSession } from '~~/utils/adminAuth'; import { validateParam, uuidSchema } from '~~/utils/schemas'; import { notify } from '~~/utils/notify'; +import { auditDetail } from '~~/utils/audit'; export default defineEventHandler(async (event) => { const { user: actor } = await requireModeratorSession(event); @@ -38,6 +39,14 @@ export default defineEventHandler(async (event) => { }); } + auditDetail(event, { + action: 'user.unban', + targetType: 'user', + targetId: target.id, + targetLabel: target.username, + changes: { isBanned: { from: true, to: false } }, + }); + await db .update(users) .set({ isBanned: false, bannedById: null, bannedByRole: null }) diff --git a/apps/api/routes/api/admin/users/index.get.ts b/apps/api/routes/api/admin/users/index.get.ts index 243269c1..c45caacc 100644 --- a/apps/api/routes/api/admin/users/index.get.ts +++ b/apps/api/routes/api/admin/users/index.get.ts @@ -44,6 +44,7 @@ import { type SQL, } from 'drizzle-orm'; import { z } from 'zod'; +import { validateQuery } from '~~/utils/schemas'; const ternary = z.enum(['true', 'false']).optional(); const querySchema = z.object({ @@ -70,7 +71,7 @@ const querySchema = z.object({ export default defineEventHandler(async (event) => { const { user: viewer } = await requireModeratorSession(event); - const params = querySchema.parse(getQuery(event)); + const params = validateQuery(event, querySchema); // ── WHERE clause ───────────────────────────────────────────── const conditions: SQL[] = []; diff --git a/apps/api/routes/api/auth/2fa/passkey-options.post.ts b/apps/api/routes/api/auth/2fa/passkey-options.post.ts index 1a88f9cf..9af557b8 100644 --- a/apps/api/routes/api/auth/2fa/passkey-options.post.ts +++ b/apps/api/routes/api/auth/2fa/passkey-options.post.ts @@ -19,12 +19,18 @@ import { z } from 'zod'; import { redis } from '../../../../utils/server'; import { rpID, setAuthChallenge } from '~~/utils/webauthn'; import { validateBody } from '~~/utils/schemas'; +import { rateLimit, RATE_LIMITS } from '~~/utils/rateLimit'; const bodySchema = z.object({ challengeToken: z.string().min(16).max(128), }); export default defineEventHandler(async (event) => { + // Non authentifiée par nécessité — le second facteur se présente avant qu'une + // session existe — donc la limite est le seul plafond. Le jeton de défi vient + // déjà d'une preuve de mot de passe, mais chaque appel génère un défi WebAuthn + // et écrit une clé Redis : sans borne, c'est de la frappe de clés gratuite. + await rateLimit(event, RATE_LIMITS.auth); const body = await validateBody(event, bodySchema); // Peek at the challenge token without consuming it — we need the diff --git a/apps/api/routes/api/auth/2fa/passkey-verify.post.ts b/apps/api/routes/api/auth/2fa/passkey-verify.post.ts index 7266ff8e..f48e8c6b 100644 --- a/apps/api/routes/api/auth/2fa/passkey-verify.post.ts +++ b/apps/api/routes/api/auth/2fa/passkey-verify.post.ts @@ -25,6 +25,7 @@ import { import { issueTrustedDevice } from '~~/utils/trustedDevices'; import { validateBody } from '~~/utils/schemas'; import { rateLimit, RATE_LIMITS } from '~~/utils/rateLimit'; +import { recordLogin } from '~~/utils/account/loginLog'; const bodySchema = z.object({ challengeToken: z.string().min(16).max(128), @@ -46,6 +47,27 @@ export default defineEventHandler(async (event) => { message: 'Challenge expired or already used. Restart the login.', }); } + /** + * One place to record a failed assertion, since there are two ways to fail. + * + * The username is read lazily: this path has an id from the challenge token + * and no row yet, and the login history stores a name so a row survives an + * erasure with something readable in it. + */ + const recordFailedPasskey = async () => { + const row = await db.query.users.findFirst({ + where: eq(schema.users.id, userId), + columns: { username: true }, + }); + if (!row) return; + await recordLogin(event, { + userId, + username: row.username, + method: 'passkey', + outcome: 'failed', + }); + }; + const expectedChallenge = await getAuthChallenge(body.challengeToken); if (!expectedChallenge) { throw createError({ @@ -91,6 +113,11 @@ export default defineEventHandler(async (event) => { }, }); } catch (err: any) { + // A failed assertion is the same signal as a wrong TOTP and was the other + // second factor that left no trace: an account protected only by a passkey + // showed no failures at all in a history whose stated point is that "an + // attempt that did not succeed is the one worth knowing about". + void recordFailedPasskey(); throw createError({ statusCode: 400, message: `Passkey verification failed: ${err?.message || 'unknown'}`, @@ -98,6 +125,7 @@ export default defineEventHandler(async (event) => { } if (!verification.verified || !verification.authenticationInfo) { + void recordFailedPasskey(); throw createError({ statusCode: 400, message: 'Passkey assertion not verified' }); } @@ -133,6 +161,13 @@ export default defineEventHandler(async (event) => { }); if (!user) throw createError({ statusCode: 401, message: 'User not found' }); + void recordLogin(event, { + userId: user.id, + username: user.username, + method: 'passkey', + outcome: 'success', + }); + await setUserSession(event, { user: { id: user.id, diff --git a/apps/api/routes/api/auth/2fa/verify-totp.post.ts b/apps/api/routes/api/auth/2fa/verify-totp.post.ts index 7ce226e6..1dc6585b 100644 --- a/apps/api/routes/api/auth/2fa/verify-totp.post.ts +++ b/apps/api/routes/api/auth/2fa/verify-totp.post.ts @@ -22,6 +22,7 @@ import { validateBody } from '~~/utils/schemas'; import { rateLimit, RATE_LIMITS } from '~~/utils/rateLimit'; import { notify } from '~~/utils/notify'; import { decryptSecret } from '~~/utils/credentialSecrets'; +import { recordLogin } from '~~/utils/account/loginLog'; const bodySchema = z .object({ @@ -63,6 +64,7 @@ export default defineEventHandler(async (event) => { isAdmin: true, isModerator: true, isOwner: true, + sessionEpoch: true, uploaded: true, downloaded: true, bonusPoints: true, @@ -86,6 +88,14 @@ export default defineEventHandler(async (event) => { } const seed = decryptSecret(user.totpSecret); if (!seed || !(await verifyTotp(body.code, seed, { userId: user.id }))) { + // A wrong second factor after a correct password is the shape of a + // stolen password, and the only place it leaves a trace. + void recordLogin(event, { + userId: user.id, + username: user.username, + method: 'totp', + outcome: 'failed', + }); throw createError({ statusCode: 400, message: 'Invalid TOTP code.' }); } } else if (body.recoveryCode) { @@ -108,6 +118,16 @@ export default defineEventHandler(async (event) => { ) .returning({ id: recoveryCodes.id }); if (consumed.length === 0) { + // Recorded for the same reason a wrong TOTP is, and with more force: a + // recovery code bypasses the authenticator entirely, so a run of failed + // attempts against one is the highest-signal shape this table can hold. + // It was the one second-factor failure that left no trace. + void recordLogin(event, { + userId: user.id, + username: user.username, + method: 'recovery', + outcome: 'failed', + }); throw createError({ statusCode: 400, message: 'Invalid or already-used recovery code.', @@ -118,6 +138,16 @@ export default defineEventHandler(async (event) => { void notify(user.id, 'recovery_code_used', null, '/settings'); } + // Which factor cleared it, so a member reading their history can tell a + // recovery code — the one they only ever use once, in trouble — from an + // ordinary authenticator prompt. + void recordLogin(event, { + userId: user.id, + username: user.username, + method: body.recoveryCode ? 'recovery' : 'totp', + outcome: 'success', + }); + // Open the session — same shape as the post-SRP path so the FE // doesn't need to know which way it came in. await setUserSession(event, { @@ -131,6 +161,9 @@ export default defineEventHandler(async (event) => { isAdmin: user.isAdmin, isModerator: user.isModerator, isOwner: user.isOwner, + // L'époque de session en cours, comparée à chaque requête par + // `requireUserSession`. Voir `users.session_epoch`. + sessionEpoch: user.sessionEpoch ?? 0, uploaded: user.uploaded, downloaded: user.downloaded, bonusPoints: user.bonusPoints, diff --git a/apps/api/routes/api/auth/login.post.ts b/apps/api/routes/api/auth/login.post.ts index 7f42a464..0225eab0 100644 --- a/apps/api/routes/api/auth/login.post.ts +++ b/apps/api/routes/api/auth/login.post.ts @@ -10,6 +10,7 @@ import { consumeTrustedDevice } from '~~/utils/trustedDevices'; import { notify } from '~~/utils/notify'; import { liftExpiredBan } from '~~/utils/banExpiry'; import { decryptSecret, encryptSecretRequired, needsRewrite } from '~~/utils/credentialSecrets'; +import { recordLogin } from '~~/utils/account/loginLog'; /** * POST /api/auth/login @@ -80,6 +81,15 @@ export default defineEventHandler(async (event) => { .digest('hex'); if (!secureCompare(body.proof, expectedProof)) { + // Recorded before the throw. There is no per-account lockout on this site — + // throttling is entirely per IP — so an attempt spread across addresses + // meets nothing at all, and this row is the only trace it leaves. + void recordLogin(event, { + userId: user.id, + username: user.username, + method: 'password', + outcome: 'failed', + }); throw createError({ statusCode: 401, message: 'Invalid credentials', @@ -211,6 +221,9 @@ export default defineEventHandler(async (event) => { isAdmin: user.isAdmin, isModerator: user.isModerator, isOwner: user.isOwner, + // L'époque de session en cours, comparée à chaque requête par + // `requireUserSession`. Voir `users.session_epoch`. + sessionEpoch: user.sessionEpoch ?? 0, uploaded: user.uploaded, downloaded: user.downloaded, bonusPoints: user.bonusPoints, @@ -218,6 +231,19 @@ export default defineEventHandler(async (event) => { loggedInAt: Date.now(), }); + // `trusted-device` when the second factor was skipped by a remembered + // browser, `password` otherwise. The distinction is the point: a member + // reading their own history should be able to see which sessions never met + // their second factor. + void recordLogin(event, { + userId: user.id, + username: user.username, + method: (event.context as { trustedDeviceUsed?: boolean }).trustedDeviceUsed + ? 'trusted-device' + : 'password', + outcome: 'success', + }); + // Stamp the fresh-auth window even on no-2FA logins so the same // settings flow ("change my password" etc.) works either way. // Key on the real h3 session id (getSessionId), not the session diff --git a/apps/api/routes/api/auth/passkey.get.ts b/apps/api/routes/api/auth/passkey.get.ts index a09faf8e..5554de4e 100644 --- a/apps/api/routes/api/auth/passkey.get.ts +++ b/apps/api/routes/api/auth/passkey.get.ts @@ -2,8 +2,23 @@ * GET /api/auth/passkey * Returns the current user's passkey (private, only accessible to the user themselves) */ +/* + * `requireAuthSession`, pas `requireUserSession` : le bannissement compte ici. + * + * Le middleware saute la porte de bannissement pour tout `/api/auth/**` — il + * le doit, la connexion et l'inscription sont anonymes — mais le saut couvre + * aussi les routes de compte qui vivent sous ce préfixe. Or + * `requireUserSession` ne lit pas le statut, contrairement à + * `requireAuthSession`. Un membre banni gardait donc de quoi faire tourner son + * passkey et changer son mot de passe : la porte d'entrée était fermée, la + * porte de service ouverte. + * + * Corrigé par route plutôt qu'en resserrant le saut du middleware : le saut + * est juste pour ce qu'il vise, et le lister route par route ferait dépendre + * une garde d'autorisation d'une liste à tenir à jour. + */ export default defineEventHandler(async (event) => { - const { user } = await requireUserSession(event); + const { user } = await requireAuthSession(event); // The passkey is stored in the session, which is only accessible to the authenticated user return { diff --git a/apps/api/routes/api/auth/passkey.post.ts b/apps/api/routes/api/auth/passkey.post.ts index 76119607..6ff9da51 100644 --- a/apps/api/routes/api/auth/passkey.post.ts +++ b/apps/api/routes/api/auth/passkey.post.ts @@ -23,6 +23,11 @@ import { eq } from 'drizzle-orm'; import { db, schema } from '@trackarr/db'; import { generateToken } from '~~/utils/server'; import { rateLimit, RATE_LIMITS } from '~~/utils/rateLimit'; +import { requireFreshAuth } from '~~/utils/adminAuth'; +import { + carryTorznabBlock, + retireTorznabPasskey, +} from '~~/utils/torznabStats'; import { z } from 'zod'; const bodySchema = z.object({ @@ -34,8 +39,28 @@ const bodySchema = z.object({ }), }); +/* + * `requireAuthSession`, pas `requireUserSession` : le bannissement compte ici. + * + * Le middleware saute la porte de bannissement pour tout `/api/auth/**` — il + * le doit, la connexion et l'inscription sont anonymes — mais le saut couvre + * aussi les routes de compte qui vivent sous ce préfixe. Or + * `requireUserSession` ne lit pas le statut, contrairement à + * `requireAuthSession`. Un membre banni gardait donc de quoi faire tourner son + * passkey et changer son mot de passe : la porte d'entrée était fermée, la + * porte de service ouverte. + * + * Corrigé par route plutôt qu'en resserrant le saut du middleware : le saut + * est juste pour ce qu'il vise, et le lister route par route ferait dépendre + * une garde d'autorisation d'une liste à tenir à jour. + */ export default defineEventHandler(async (event) => { - const { user } = await requireUserSession(event); + const { user } = await requireAuthSession(event); + // The same step-up `me/passkey/reset` requires, and for the reason that route + // states: a borrowed session must not be able to lock the real owner's client + // out of the tracker. Two doors to the same action, one of them unguarded, is + // just the unguarded one. + await requireFreshAuth(event); await rateLimit(event, RATE_LIMITS.mutation); await readValidatedBody(event, bodySchema.parse); @@ -44,6 +69,29 @@ export default defineEventHandler(async (event) => { // and what BitTorrent clients pass through ?passkey=. const fresh = generateToken(16); + // The row rather than the session. The session's copy of the passkey is + // whatever was current when it was opened, so a member who rotated from + // another device would have the block carried from a value that is already + // dead — the entry would be written under a hash nobody presents, which + // looks exactly like a successful carry-over and frees the member. + const [current] = await db + .select({ passkey: schema.users.passkey }) + .from(schema.users) + .where(eq(schema.users.id, user.id)) + .limit(1); + + if (!current) { + throw createError({ + statusCode: 404, + message: 'User not found', + }); + } + + // Before the update, and refusing rather than failing open: the block is + // keyed by passkey hash, so a rotation that dropped it would let a blocked + // member self-lift an administrator's restriction by minting a new passkey. + const carried = await carryTorznabBlock(current.passkey, fresh); + const [updated] = await db .update(schema.users) .set({ passkey: fresh }) @@ -57,24 +105,8 @@ export default defineEventHandler(async (event) => { }); } - // Carry any Torznab access block across the rotation. The block is - // keyed by passkey hash, so without this a blocked user could - // self-lift the admin's restriction simply by minting a new - // passkey (finding: Torznab block evaded by rotation). Re-apply it - // to the new key and drop the now-dead old entry + stats. - try { - const wasBlocked = await isTorznabUserBlocked(user.passkey); - if (wasBlocked.blocked) { - await blockTorznabUser( - updated.passkey, - wasBlocked.reason ?? 'carried over on passkey rotation' - ); - await unblockTorznabUser(user.passkey); - } - await clearTorznabUserStats(user.passkey); - } catch (err) { - console.warn('[passkey rotate] torznab block migration failed:', err); - } + // The old value is nobody's now — block entry and counters both go. + await retireTorznabPasskey(current.passkey, carried); // Refresh the session in place so the next reveal/copy on the page // returns the new value rather than the stale one we cached at login. diff --git a/apps/api/routes/api/auth/password.put.ts b/apps/api/routes/api/auth/password.put.ts index de07cb83..f3948aae 100644 --- a/apps/api/routes/api/auth/password.put.ts +++ b/apps/api/routes/api/auth/password.put.ts @@ -42,8 +42,23 @@ const bodySchema = z.object({ newVerifier: z.string().min(40, 'Invalid new verifier'), }); +/* + * `requireAuthSession`, pas `requireUserSession` : le bannissement compte ici. + * + * Le middleware saute la porte de bannissement pour tout `/api/auth/**` — il + * le doit, la connexion et l'inscription sont anonymes — mais le saut couvre + * aussi les routes de compte qui vivent sous ce préfixe. Or + * `requireUserSession` ne lit pas le statut, contrairement à + * `requireAuthSession`. Un membre banni gardait donc de quoi faire tourner son + * passkey et changer son mot de passe : la porte d'entrée était fermée, la + * porte de service ouverte. + * + * Corrigé par route plutôt qu'en resserrant le saut du middleware : le saut + * est juste pour ce qu'il vise, et le lister route par route ferait dépendre + * une garde d'autorisation d'une liste à tenir à jour. + */ export default defineEventHandler(async (event) => { - const { user } = await requireUserSession(event); + const { user } = await requireAuthSession(event); // Password change is a credential operation — use the strict auth // bucket (5/5min, progressive) instead of the looser mutation bucket. await rateLimit(event, RATE_LIMITS.auth); diff --git a/apps/api/routes/api/auth/pow.get.ts b/apps/api/routes/api/auth/pow.get.ts index 747d666b..48976efc 100644 --- a/apps/api/routes/api/auth/pow.get.ts +++ b/apps/api/routes/api/auth/pow.get.ts @@ -3,8 +3,37 @@ * Generate a Proof of Work challenge for registration */ import { generatePoWChallenge } from '~~/utils/pow'; +import { rateLimit } from '~~/utils/rateLimit'; -export default defineEventHandler(async () => { +/** + * Le même raisonnement que `challenge.get.ts`, appliqué à la même forme. + * + * Cette route était ouverte à l'internet, sans garde ET sans limite propre, et + * chaque appel écrit une clé Redis `pow:<64 hex>` avec un TTL de cinq minutes. + * Le seul plafond était `detectDDoS` — 100 requêtes par 10 s et par IP, soit + * environ 600 clés par minute et par adresse, et 3 000 clés vivantes en + * permanence. Un botnet, ou simplement une rotation dans un /64 IPv6, y + * maintient des millions de clés. + * + * Ce que cela coûte n'est pas la mémoire en soi : ce Redis porte aussi les + * sessions, les seaux de limitation, le cache de bannissement et le cache de + * rôles. Sous `allkeys-lru`, l'éviction commence par ces caches — la + * vérification de bannissement et les limites de débit se dégradent alors EN + * SILENCE en retombant sur Postgres, ce qui convertit une pression mémoire en + * amplification de base de données. + * + * Plus généreux que `RATE_LIMITS.auth` : un défi n'est pas une tentative, et un + * client légitime en demande un par écran d'inscription. + */ +const POW_LIMIT = { + windowSec: 300, + maxRequests: 20, + prefix: 'pow', + progressive: true, +} as const; + +export default defineEventHandler(async (event) => { + await rateLimit(event, POW_LIMIT); const challenge = await generatePoWChallenge(); return challenge; }); diff --git a/apps/api/routes/api/auth/register.post.ts b/apps/api/routes/api/auth/register.post.ts index 3fe53730..214daee3 100644 --- a/apps/api/routes/api/auth/register.post.ts +++ b/apps/api/routes/api/auth/register.post.ts @@ -15,6 +15,7 @@ import { verifyPoWSolution } from '~~/utils/pow'; import { rateLimit, RATE_LIMITS } from '~~/utils/rateLimit'; import { markFreshAuth } from '~~/utils/twoFactor'; import { encryptSecretRequired } from '~~/utils/credentialSecrets'; +import { recordLogin } from '~~/utils/account/loginLog'; /** * Postgres advisory lock id used to serialise the "first user gets @@ -141,10 +142,25 @@ export default defineEventHandler(async (event) => { } // Check for existing username + /* + * Insensible à la casse. + * + * Le contrôle et l'index unique portaient tous deux sur la valeur exacte, + * donc `Admin` pouvait être créé alors qu'`admin` existait. Le jeu de + * caractères est `[a-zA-Z0-9_-]`, donc pas d'homographes Unicode — mais la + * collision par casse suffit à usurper un pseudonyme de personnel dans les + * commentaires, le forum, les messages privés et le journal de modération. + * Et `auth/challenge` étant lui aussi sensible à la casse, les deux comptes + * se connectent normalement. + * + * L'index unique fonctionnel de la migration est ce qui rend la garantie + * réelle : ce contrôle-ci répond 409 plutôt que de laisser la base lever une + * violation de contrainte. + */ const existingUsername = await db .select({ username: users.username }) .from(users) - .where(eq(users.username, body.username)) + .where(sql`lower(${users.username}) = ${body.username.toLowerCase()}`) .limit(1); if (existingUsername.length > 0) { @@ -279,6 +295,15 @@ export default defineEventHandler(async (event) => { // site-default setting inert — it recorded a choice nobody made, and nothing // downstream could tell it apart from one. Language keeps its schema default // ('en'), which has no equivalent site-wide setting to defer to. + // Registration opens a session like a login does, and a history that starts + // at the second visit reads as if the first one is missing. + void recordLogin(event, { + userId, + username: body.username, + method: 'password', + outcome: 'success', + }); + await setUserSession(event, { user: { id: userId, @@ -287,6 +312,9 @@ export default defineEventHandler(async (event) => { isAdmin: finalIsFirstUser, isModerator: false, isOwner: finalIsFirstUser, + // L'époque de session en cours, comparée à chaque requête par + // `requireUserSession`. Voir `users.session_epoch`. + sessionEpoch: 0, uploaded: starterUpload, downloaded: 0, bonusPoints: 0, diff --git a/apps/api/routes/api/categories/index.get.ts b/apps/api/routes/api/categories/index.get.ts index 10103e33..fb5d62a9 100644 --- a/apps/api/routes/api/categories/index.get.ts +++ b/apps/api/routes/api/categories/index.get.ts @@ -1,5 +1,5 @@ import { db, schema } from '@trackarr/db'; -import { asc, eq } from 'drizzle-orm'; +import { asc, count, eq } from 'drizzle-orm'; export default defineEventHandler(async (event) => { const { user: session } = await requireUserSession(event); @@ -38,11 +38,39 @@ export default defineEventHandler(async (event) => { // Filter both the root categories and the nested subcategories so a // user with the toggle off never even sees the XXX tree existed. const visible = allCategories.filter((c) => showAdult || !c.isAdult); + + // How many torrents sit in each category. Staff only, and only when asked + // for: it is one grouped scan, and the member-facing callers of this route + // (the upload form, the filter rail) have no use for it. + // + // The admin panel needs it because DELETE refuses a category that still has + // torrents — without the number, the operator learns that from a 400 after + // confirming a dialog that had promised the torrents would simply become + // unclassified. + let counts = new Map(); + if (isStaff && query.withCounts === 'true') { + const rows = await db + .select({ categoryId: schema.torrents.categoryId, n: count() }) + .from(schema.torrents) + .groupBy(schema.torrents.categoryId); + counts = new Map( + rows + .filter((r): r is { categoryId: string; n: number } => !!r.categoryId) + .map((r) => [r.categoryId, Number(r.n)]), + ); + } + const withCount = (c: T) => + counts.size || (isStaff && query.withCounts === 'true') + ? { ...c, torrentCount: counts.get(c.id) ?? 0 } + : c; + const rootCategories = visible .filter((c) => c.parentId === null) .map((c) => ({ - ...c, - subcategories: c.subcategories.filter((sc) => showAdult || !sc.isAdult), + ...withCount(c), + subcategories: c.subcategories + .filter((sc) => showAdult || !sc.isAdult) + .map(withCount), })); return rootCategories; diff --git a/apps/api/routes/api/forum/categories/[id].get.ts b/apps/api/routes/api/forum/categories/[id].get.ts index d394010b..b1fde249 100644 --- a/apps/api/routes/api/forum/categories/[id].get.ts +++ b/apps/api/routes/api/forum/categories/[id].get.ts @@ -125,19 +125,24 @@ export default defineEventHandler(async (event) => { WHERE p.topic_id IN (${idList}) ORDER BY p.topic_id, p.created_at DESC `); + // `createdAt: string | null` et non `Date` : c'est ce que la route renvoie + // réellement, une fois `naiveTimestampToIso` passé dessus. La déclaration + // disait `Date` — et `db.execute<…>()` ne vérifie pas son générique, donc + // rien ne contredisait cette annotation pendant que la valeur était une + // chaîne brute. Le type qui mentait est ce qui a caché le défaut. const lastPostMap = new Map< string, - { authorUsername: string; createdAt: Date } + { authorUsername: string; createdAt: string | null } >(); const lastPostRows = (lastPostsRaw as any).rows ?? lastPostsRaw; for (const r of lastPostRows as Array<{ topic_id: string; - created_at: Date; + created_at: string; author_username: string; }>) { lastPostMap.set(r.topic_id, { authorUsername: r.author_username, - createdAt: r.created_at, + createdAt: naiveTimestampToIso(r.created_at), }); } diff --git a/apps/api/routes/api/forum/categories/index.get.ts b/apps/api/routes/api/forum/categories/index.get.ts index c1aefb40..a1d9bacb 100644 --- a/apps/api/routes/api/forum/categories/index.get.ts +++ b/apps/api/routes/api/forum/categories/index.get.ts @@ -83,7 +83,9 @@ export default defineEventHandler(async (event) => { category_id: string; topic_id: string; title: string; - updated_at: Date; + // Chaîne brute, pas `Date` : `db.execute` ne passe pas par + // l'analyseur d'OID 1114 et son générique n'est jamais vérifié. + updated_at: string; is_pinned: boolean; is_locked: boolean; author_username: string; @@ -117,7 +119,8 @@ export default defineEventHandler(async (event) => { topic_id: string; topic_title: string; content: string; - created_at: Date; + // Idem : chaîne brute. Voir `utils/naiveTimestamp.ts`. + created_at: string; author_username: string; }>(sql` SELECT DISTINCT ON (t.category_id) @@ -152,7 +155,7 @@ export default defineEventHandler(async (event) => { ? { id: lt.topic_id, title: lt.title, - updatedAt: lt.updated_at, + updatedAt: naiveTimestampToIso(lt.updated_at), isPinned: lt.is_pinned, isLocked: lt.is_locked, authorUsername: lt.author_username, @@ -163,7 +166,7 @@ export default defineEventHandler(async (event) => { topicId: lp.topic_id, topicTitle: lp.topic_title, content: lp.content, - createdAt: lp.created_at, + createdAt: naiveTimestampToIso(lp.created_at), authorUsername: lp.author_username, } : null, diff --git a/apps/api/routes/api/forum/posts/[id].delete.ts b/apps/api/routes/api/forum/posts/[id].delete.ts index b76f1d31..c1a3841e 100644 --- a/apps/api/routes/api/forum/posts/[id].delete.ts +++ b/apps/api/routes/api/forum/posts/[id].delete.ts @@ -1,4 +1,5 @@ import { db } from '@trackarr/db'; +import { rateLimit, RATE_LIMITS } from '~~/utils/rateLimit'; import { forumPosts, forumTopics } from '@trackarr/db/schema'; import { eq, count } from 'drizzle-orm'; import { requireAuthSession } from '~~/utils/adminAuth'; @@ -6,6 +7,7 @@ import { notify } from '~~/utils/notify'; export default defineEventHandler(async (event) => { const session = await requireAuthSession(event); + await rateLimit(event, RATE_LIMITS.mutation); const id = getRouterParam(event, 'id'); if (!id) { @@ -58,6 +60,13 @@ export default defineEventHandler(async (event) => { }); if (firstPost?.id === id) { + // Supprimer le premier message supprime le sujet, donc la cascade emporte + // toutes les réponses. Le garde ci-dessus ne couvrait que le fil + // VERROUILLÉ ; un fil ouvert de cinquante réponses restait destructible par + // son auteur. Voir `utils/forumDeletion.ts`. + if (!isModerator) { + await assertTopicDeletableByAuthor(post.topicId, session.user.id); + } // If it's the first post, delete the whole topic await db.delete(forumTopics).where(eq(forumTopics.id, post.topicId)); return { message: 'Topic deleted (first post removed)' }; diff --git a/apps/api/routes/api/forum/posts/[id].patch.ts b/apps/api/routes/api/forum/posts/[id].patch.ts index e69d1e4b..c047d667 100644 --- a/apps/api/routes/api/forum/posts/[id].patch.ts +++ b/apps/api/routes/api/forum/posts/[id].patch.ts @@ -10,12 +10,14 @@ * without trusting client-supplied timestamps. */ import { db } from '@trackarr/db'; +import { rateLimit, RATE_LIMITS } from '~~/utils/rateLimit'; import { forumPosts, forumTopics } from '@trackarr/db/schema'; import { validateBody, forumPostUpdateSchema } from '~~/utils/schemas'; import { eq } from 'drizzle-orm'; export default defineEventHandler(async (event) => { const session = await requireUserSession(event); + await rateLimit(event, RATE_LIMITS.mutation); const id = getRouterParam(event, 'id'); if (!id) { diff --git a/apps/api/routes/api/forum/posts/index.post.ts b/apps/api/routes/api/forum/posts/index.post.ts index b2389f11..4124a95a 100644 --- a/apps/api/routes/api/forum/posts/index.post.ts +++ b/apps/api/routes/api/forum/posts/index.post.ts @@ -1,4 +1,5 @@ import { db } from '@trackarr/db'; +import { rateLimit, RATE_LIMITS } from '~~/utils/rateLimit'; import { forumPosts, forumTopics } from '@trackarr/db/schema'; import { eq, sql } from 'drizzle-orm'; import { v4 as uuidv4 } from 'uuid'; @@ -7,6 +8,7 @@ import { notify } from '~~/utils/notify'; export default defineEventHandler(async (event) => { const session = await requireUserSession(event); + await rateLimit(event, RATE_LIMITS.mutation); // Validate request body with Zod const body = await validateBody(event, forumPostSchema); diff --git a/apps/api/routes/api/forum/topics/[id].delete.ts b/apps/api/routes/api/forum/topics/[id].delete.ts index 1063c82e..6a6777a9 100644 --- a/apps/api/routes/api/forum/topics/[id].delete.ts +++ b/apps/api/routes/api/forum/topics/[id].delete.ts @@ -1,10 +1,12 @@ import { db } from '@trackarr/db'; +import { rateLimit, RATE_LIMITS } from '~~/utils/rateLimit'; import { forumTopics } from '@trackarr/db/schema'; import { eq } from 'drizzle-orm'; import { requireAuthSession } from '~~/utils/adminAuth'; export default defineEventHandler(async (event) => { const session = await requireAuthSession(event); + await rateLimit(event, RATE_LIMITS.mutation); const id = getRouterParam(event, 'id'); if (!id) { @@ -43,6 +45,13 @@ export default defineEventHandler(async (event) => { throw createError({ statusCode: 403, message: 'This topic is locked' }); } + // Ni un sujet où quelqu'un d'autre a pris la parole : la cascade sur + // `forum_posts.topic_id` emporterait ses messages avec. Voir + // `utils/forumDeletion.ts`. + if (!isModerator) { + await assertTopicDeletableByAuthor(id, session.user.id); + } + await db.delete(forumTopics).where(eq(forumTopics.id, id)); return { message: 'Topic deleted' }; diff --git a/apps/api/routes/api/forum/topics/[id]/lock.put.ts b/apps/api/routes/api/forum/topics/[id]/lock.put.ts index 4a17a148..551f6c47 100644 --- a/apps/api/routes/api/forum/topics/[id]/lock.put.ts +++ b/apps/api/routes/api/forum/topics/[id]/lock.put.ts @@ -2,11 +2,19 @@ import { db } from '@trackarr/db'; import { forumTopics } from '@trackarr/db/schema'; import { requireModeratorSession } from '~~/utils/adminAuth'; import { eq } from 'drizzle-orm'; +import { z } from 'zod'; export default defineEventHandler(async (event) => { await requireModeratorSession(event); const id = getRouterParam(event, 'id'); - const body = await readBody(event); + // Un schéma, pas `readBody()`. C'étaient les deux dernières routes + // mutantes de l'arbre sans validation : un PUT sans corps, ou avec un + // corps JSON `null`, faisait lever un TypeError sur la déréférence — un + // 500 avec trace là où un 400 suffit. + const body = await readValidatedBody( + event, + z.object({ isLocked: z.boolean() }).strict().parse + ); if (!id) { throw createError({ diff --git a/apps/api/routes/api/forum/topics/[id]/pin.put.ts b/apps/api/routes/api/forum/topics/[id]/pin.put.ts index 71ec398a..e50addaf 100644 --- a/apps/api/routes/api/forum/topics/[id]/pin.put.ts +++ b/apps/api/routes/api/forum/topics/[id]/pin.put.ts @@ -2,11 +2,19 @@ import { db } from '@trackarr/db'; import { forumTopics } from '@trackarr/db/schema'; import { requireModeratorSession } from '~~/utils/adminAuth'; import { eq } from 'drizzle-orm'; +import { z } from 'zod'; export default defineEventHandler(async (event) => { await requireModeratorSession(event); const id = getRouterParam(event, 'id'); - const body = await readBody(event); + // Un schéma, pas `readBody()`. C'étaient les deux dernières routes + // mutantes de l'arbre sans validation : un PUT sans corps, ou avec un + // corps JSON `null`, faisait lever un TypeError sur la déréférence — un + // 500 avec trace là où un 400 suffit. + const body = await readValidatedBody( + event, + z.object({ isPinned: z.boolean() }).strict().parse + ); if (!id) { throw createError({ diff --git a/apps/api/routes/api/forum/topics/index.post.ts b/apps/api/routes/api/forum/topics/index.post.ts index cfe97f3c..2acc16ea 100644 --- a/apps/api/routes/api/forum/topics/index.post.ts +++ b/apps/api/routes/api/forum/topics/index.post.ts @@ -1,10 +1,12 @@ import { db } from '@trackarr/db'; +import { rateLimit, RATE_LIMITS } from '~~/utils/rateLimit'; import { forumTopics, forumPosts } from '@trackarr/db/schema'; import { v4 as uuidv4 } from 'uuid'; import { validateBody, forumTopicSchema } from '~~/utils/schemas'; export default defineEventHandler(async (event) => { const session = await requireUserSession(event); + await rateLimit(event, RATE_LIMITS.mutation); // Validate request body with Zod const body = await validateBody(event, forumTopicSchema); diff --git a/apps/api/routes/api/freeleech-pool/state.get.ts b/apps/api/routes/api/freeleech-pool/state.get.ts index cf48e216..949be29c 100644 --- a/apps/api/routes/api/freeleech-pool/state.get.ts +++ b/apps/api/routes/api/freeleech-pool/state.get.ts @@ -9,6 +9,20 @@ * Anonymous callers get `userContribution: null` instead of zero * so the FE can tell "not logged in" from "logged in, didn't * contribute yet". + * + * Ils n'obtiennent PAS la liste des contributeurs. + * + * L'appel anonyme était prévu — pour `userContribution` — et + * `topContributors` est parti avec, sans que personne y pense. + * Mesuré sur la pile compilée : un appelant sans session recevait + * cinq pseudonymes, leurs totaux, et surtout leurs identifiants + * INTERNES, ceux qui servent de clé dans tout le reste de l'API. + * Sur un tracker privé, la liste des membres est précisément ce qui + * ne doit pas sortir. + * + * Personne d'authentifié n'y perd : le seul consommateur est + * `components/shop/FreeleechPool.vue`, et `/shop` demande une + * session. L'identifiant n'y sert d'ailleurs que de clé de boucle. */ import { getPublicState } from '~~/utils/freeleechPool'; @@ -16,5 +30,8 @@ export default defineEventHandler(async (event) => { const session = await getUserSession(event); const userId = session?.user?.id ?? null; const state = await getPublicState(userId); + if (!userId) { + return { ...state, topContributors: [] }; + } return state; }); diff --git a/apps/api/routes/api/irc/autobrr.yml.get.ts b/apps/api/routes/api/irc/autobrr.yml.get.ts new file mode 100644 index 00000000..51bfad7e --- /dev/null +++ b/apps/api/routes/api/irc/autobrr.yml.get.ts @@ -0,0 +1,59 @@ +/** + * GET /api/irc/autobrr.yml + * + * The autobrr indexer definition for this instance, generated from the announce + * template in force. See `utils/irc/autobrr.ts` for why it is generated rather + * than shipped. + * + * Members only, like the Prowlarr definition: the categories are not secret but + * the file names the instance, its address and its IRC network, and an + * invite-only tracker publishes none of those. + * + * 404 when announcing is off — an empty definition would be worse than no + * definition, because a member would configure it and then wait for lines that + * are never coming. + */ +import { requireAuthSession } from '~~/utils/adminAuth'; +import { rateLimit, RATE_LIMITS } from '~~/utils/rateLimit'; +import { getSiteName } from '~~/utils/server'; +import { autobrrDefinition } from '~~/utils/irc/autobrr'; +import { getIrcConfig, getIrcEnabled, ircConfigReady } from '~~/utils/irc/settings'; + +export default defineEventHandler(async (event) => { + await requireAuthSession(event); + await rateLimit(event, RATE_LIMITS.public); + + const [enabled, config] = await Promise.all([getIrcEnabled(), getIrcConfig()]); + if (!enabled || !ircConfigReady(config)) { + throw createError({ + statusCode: 404, + message: 'This instance does not announce to an IRC channel.', + }); + } + + const siteName = await getSiteName(); + const yaml = autobrrDefinition({ + siteName, + // The host the member reached, for the same reason the Cardigann route uses + // it: an env var set when the container was built is not where the member is. + baseUrl: getRequestURL(event).origin, + irc: { + host: config.host, + port: config.port, + tls: config.tls, + channel: config.channel, + announcer: config.nick, + keyed: !!config.channelKey, + invited: config.perform.length > 0, + }, + template: config.template, + }); + + setHeader(event, 'Content-Type', 'application/yaml; charset=utf-8'); + setHeader( + event, + 'Content-Disposition', + `attachment; filename="${siteName.toLowerCase().replace(/[^a-z0-9]+/g, '-')}.yaml"` + ); + return yaml; +}); diff --git a/apps/api/routes/api/manifest.webmanifest.get.ts b/apps/api/routes/api/manifest.webmanifest.get.ts new file mode 100644 index 00000000..b36b0636 --- /dev/null +++ b/apps/api/routes/api/manifest.webmanifest.get.ts @@ -0,0 +1,188 @@ +/** + * GET /api/manifest.webmanifest — this instance, as an installable app. + * + * ## Why a route and not a file in `public/` + * + * The same reason `/api/theme.css` is a route. Everything a manifest says + * about a site — its name, its colours, its icon — is operator-configurable + * here, and a static JSON would hard-code one instance's branding into every + * instance's bundle. An operator who renamed their tracker would still be + * installed as "Trackarr", in Trackarr's colours. + * + * It also has to work in both shapes `apps/web` ships in: SSR, and the static + * SPA served by nginx with no server at all. A `` in + * `app.head` points at this URL in both, and only the API needs to know the + * branding. + * + * A manifest's `scope` is resolved against the manifest's own URL but is not + * confined to its directory — unlike a service worker's. So a manifest served + * from `/api/` can and does claim `/`. + * + * ## Icons, and the number that has to be true + * + * `sizes` is a claim, and browsers act on the claim rather than on the file: + * Chrome offers to install a site only when the manifest declares an icon of + * at least 512×512. Declaring that over a 64-pixel logo buys an install + * prompt and a blurry home-screen icon, which is worse than no prompt — so the + * value comes from the bytes, measured by the upload route + * (`utils/imageSniff.manifestIconSizes`) and stored beside the URL. + * + * `any` means we could not measure it: an SVG (no intrinsic size), a format we + * do not walk, or an image uploaded before the measurement existed. Firefox + * and iOS install from `any`; Chrome does not. Re-uploading the logo is what + * fixes it, and the operator guide says so. + * + * With no uploaded branding at all the only icon is the shipped `favicon.ico` + * at 32×32, which is enough to install on iOS and not enough for Chrome. We do + * not ship a 512-pixel default to paper over that: an invented icon that says + * "Trackarr" on somebody else's tracker is not an improvement. + * + * ## Caching + * + * Branding is settings-cached, so a hit costs no query. `max-age=60` matches + * that cache's TTL — the same envelope `/api/theme.css` advertises — and the + * ETag folds in every field the document contains, so a rename revalidates + * within the minute instead of waiting out a longer TTL. + */ +import { createHash } from 'node:crypto'; +import { + getSiteName, + getSiteSubtitle, + getSiteLogoImage, + getSiteLogoImageSizes, + getSiteFavicon, + getSiteFaviconSizes, +} from '~~/utils/server'; +import { enabledThemes, getDefaultTheme } from '~~/utils/themes'; +import { resolveTokens } from '@trackarr/shared/theme'; + +/** + * `"12 34 56"` → `"#0c2238"`. + * + * Theme tokens are stored as space-separated RGB triplets, the convention the + * stylesheet uses so a value can be dropped into `rgb(… / )`. A + * manifest wants a CSS colour, and hex is the form every browser has parsed + * for twenty years — `rgb(12 34 56)` is valid CSS Color 4 and not worth + * betting a theme colour on. + */ +function tripletToHex(triplet: string | undefined): string | null { + if (!triplet) return null; + const parts = triplet.trim().split(/[\s,]+/); + if (parts.length !== 3) return null; + const bytes = parts.map((p) => Number.parseInt(p, 10)); + if (bytes.some((b) => !Number.isInteger(b) || b < 0 || b > 255)) return null; + return `#${bytes.map((b) => b.toString(16).padStart(2, '0')).join('')}`; +} + +interface ManifestIcon { + src: string; + sizes: string; + type?: string; + purpose?: string; +} + +/** Extension → MIME, for the four formats the branding uploads accept. */ +const ICON_TYPES: Record = { + png: 'image/png', + jpg: 'image/jpeg', + jpeg: 'image/jpeg', + webp: 'image/webp', + svg: 'image/svg+xml', + ico: 'image/x-icon', +}; + +function iconType(src: string): string | undefined { + const ext = src.split('?')[0]!.split('.').pop()?.toLowerCase(); + return ext ? ICON_TYPES[ext] : undefined; +} + +export default defineEventHandler(async (event) => { + const [siteName, subtitle, logo, logoSizes, favicon, faviconSizes, themeSlug] = + await Promise.all([ + getSiteName(), + getSiteSubtitle(), + getSiteLogoImage(), + getSiteLogoImageSizes(), + getSiteFavicon(), + getSiteFaviconSizes(), + getDefaultTheme(), + ]); + + /** + * The site default's own tokens drive the two colours. Not the visitor's + * theme: the manifest is fetched once at install time and the values are + * baked into the OS launcher, so a per-session answer would just mean + * whoever installed it picked the colour for everyone. + * + * An instance with no rows in `themes` is the common case, not an edge one — + * operator-authored themes are opt-in, and `getDefaultTheme()` then returns + * the built-in slug (`dark`, or `light`). Resolving that against the built-in + * token set is what `/api/theme.css` already does for the same situation, so + * both surfaces agree instead of this one falling back to black. + */ + const themes = await enabledThemes(); + const theme = themes.find((t) => t.slug === themeSlug); + const tokens = theme + ? resolveTokens(theme.base, theme.tokens) + : resolveTokens(themeSlug === 'light' ? 'light' : 'dark', null); + const themeColor = tripletToHex(tokens.accent) ?? '#000000'; + const backgroundColor = tripletToHex(tokens['bg-base']) ?? '#000000'; + + // Most specific first — a browser picking one icon walks the list and the + // logo is the larger, more deliberate image. `favicon.ico` closes it out so + // the array is never empty, which would make the manifest unusable rather + // than merely imperfect. + const icons: ManifestIcon[] = []; + if (logo) icons.push({ src: logo, sizes: logoSizes, type: iconType(logo) }); + if (favicon) { + icons.push({ src: favicon, sizes: faviconSizes, type: iconType(favicon) }); + } + icons.push({ src: '/favicon.ico', sizes: '32x32', type: 'image/x-icon' }); + + const manifest = { + // `id` pins the app's identity across renames. Without it the identity is + // `start_url`, and an operator moving the site would strand every + // installed copy as a second, separate app. + id: '/', + name: siteName, + // Launchers truncate around 12 characters; the site name is what the + // operator chose to be called, so it is used as-is rather than cut here. + short_name: siteName, + description: subtitle || undefined, + start_url: '/', + scope: '/', + display: 'standalone', + orientation: 'any', + theme_color: themeColor, + background_color: backgroundColor, + icons, + // Deep links the launcher can offer on a long-press. Kept to the three + // surfaces a member opens without thinking; anything gated on a role would + // show a shortcut to a 403. + shortcuts: [ + { name: 'Browse', url: '/torrents' }, + { name: 'Upload', url: '/torrents/upload' }, + { name: 'Notifications', url: '/notifications' }, + ], + }; + + const body = JSON.stringify(manifest); + // Over the document, not over a version counter: branding has no counter to + // read, and hashing what we are about to send cannot drift from it. + const etag = `W/"manifest-${createHash('sha256').update(body).digest('hex').slice(0, 16)}"`; + + // The registered media type. Nitro would infer `application/json` from the + // body, which browsers accept — but the spec'd type is what a validator and + // a strict fetch check look for. + setHeader(event, 'Content-Type', 'application/manifest+json; charset=utf-8'); + setHeader(event, 'Cache-Control', 'public, max-age=60, must-revalidate'); + setHeader(event, 'ETag', etag); + setHeader(event, 'Vary', 'Accept-Encoding'); + + if (getHeader(event, 'if-none-match') === etag) { + setResponseStatus(event, 304); + return null; + } + + return body; +}); diff --git a/apps/api/routes/api/me/2fa/totp/enable.post.ts b/apps/api/routes/api/me/2fa/totp/enable.post.ts index 323b21b4..53aad122 100644 --- a/apps/api/routes/api/me/2fa/totp/enable.post.ts +++ b/apps/api/routes/api/me/2fa/totp/enable.post.ts @@ -49,7 +49,16 @@ export default defineEventHandler(async (event) => { }); } const seed = decryptSecret(row.totpSecret); - if (!seed || !(await verifyTotp(body.code, seed))) { + // `{ userId }` compte : c'est LUI qui arme la garde anti-rejeu. + // + // `utils/twoFactor.ts` ne pose la clé Redis d'usage unique que si l'appelant + // fournit l'identifiant — c'était le correctif « finding M3 ». Les trois + // autres sites d'appel le passent ; celui-ci, seul, ne le passait pas. La + // portée réelle est étroite (la route refuse en 409 dès que la 2FA est + // active, donc un code capturé n'est rejouable que pendant l'enrôlement), + // mais une asymétrie dans une garde de sécurité est une asymétrie qu'on + // finit par croire volontaire. + if (!seed || !(await verifyTotp(body.code, seed, { userId: session.user.id }))) { throw createError({ statusCode: 400, message: 'Invalid code. Make sure your phone clock is in sync.', diff --git a/apps/api/routes/api/me/downloads.get.ts b/apps/api/routes/api/me/downloads.get.ts index 20bfa4f0..53e5ebec 100644 --- a/apps/api/routes/api/me/downloads.get.ts +++ b/apps/api/routes/api/me/downloads.get.ts @@ -25,6 +25,7 @@ import { db, schema } from '@trackarr/db'; import { count, desc, eq } from 'drizzle-orm'; import { z } from 'zod'; +import { validateQuery } from '~~/utils/schemas'; const querySchema = z.object({ page: z.coerce.number().int().min(1).default(1), @@ -33,7 +34,7 @@ const querySchema = z.object({ export default defineEventHandler(async (event) => { const { user } = await requireUserSession(event); - const params = querySchema.parse(getQuery(event)); + const params = validateQuery(event, querySchema); // Read the preference before the listing: when the history is hidden // there is nothing to page over, so we skip both queries rather than diff --git a/apps/api/routes/api/me/export.get.ts b/apps/api/routes/api/me/export.get.ts new file mode 100644 index 00000000..94be8265 --- /dev/null +++ b/apps/api/routes/api/me/export.get.ts @@ -0,0 +1,77 @@ +/** + * GET /api/me/export — a copy of everything this instance holds about you. + * + * The GDPR right of access (Art. 15) and the right to data portability + * (Art. 20). Erasure (Art. 17) has been here since `DELETE /api/me`; this is + * the half that was missing, and the odd half to be missing — the difficult + * work of deciding what counts as personal data was already done for the + * erasure, and thirteen `/api/me/*` routes were already reading most of it a + * page at a time. What did not exist was one request that returns the record + * as a record. + * + * ## Guards + * + * Two, and the second is the one that matters: + * + * - a live, non-banned session (the standard gate), and + * - a *fresh* login, exactly like account erasure. This endpoint answers + * with a person's entire history in one response, which makes it the most + * valuable single request on the site to a borrowed session. A step-up + * costs the legitimate member one password prompt. + * + * Rate-limited on the mutation bucket rather than the read bucket, despite + * being a GET: it reads twenty-odd tables and its cost is nothing like that of + * a page fetch. + * + * ## Response + * + * `application/json` with `Content-Disposition: attachment`, so a browser + * saves it instead of rendering a wall of text. Deliberately NOT streamed and + * NOT paginated — a portability export that arrives in pages is a dataset the + * member has to reassemble, and every collection inside is capped and declares + * its own total (see `utils/account/exportAccount`). + * + * `Cache-Control: no-store`, and `Vary` is irrelevant here: this must never be + * held by a shared cache or replayed from a browser's back-forward cache. + */ +import { exportAccount } from '~~/utils/account/exportAccount'; +import { requireAuthSession, requireFreshAuth } from '~~/utils/adminAuth'; +import { rateLimit, RATE_LIMITS } from '~~/utils/rateLimit'; + +/** + * `trackarr-export-alice-2026-08-31.json`. + * + * The username is sanitised even though the rules already constrain it: this + * value lands in a response header, and a newline or a quote in a filename is + * how header injection starts. Anything outside the safe set becomes `-`. + */ +function filenameFor(username: string): string { + const safe = username.replace(/[^A-Za-z0-9._-]/g, '-').slice(0, 64) || 'account'; + const day = new Date().toISOString().slice(0, 10); + return `trackarr-export-${safe}-${day}.json`; +} + +export default defineEventHandler(async (event) => { + const { user } = await requireAuthSession(event); + await rateLimit(event, RATE_LIMITS.mutation); + await requireFreshAuth(event); + + const payload = await exportAccount(user.id); + if (!payload) { + throw createError({ statusCode: 404, message: 'Account not found' }); + } + + // Two-space indent. It doubles the byte count and it is the difference + // between a file a person can read and one they have to run through a + // formatter first — which is the whole point of a right of access. + const body = JSON.stringify(payload, null, 2); + + setHeader(event, 'Content-Type', 'application/json; charset=utf-8'); + setHeader( + event, + 'Content-Disposition', + `attachment; filename="${filenameFor(user.username)}"` + ); + setHeader(event, 'Cache-Control', 'no-store, max-age=0'); + return body; +}); diff --git a/apps/api/routes/api/me/favorites.get.ts b/apps/api/routes/api/me/favorites.get.ts index a6931a73..97bb80c8 100644 --- a/apps/api/routes/api/me/favorites.get.ts +++ b/apps/api/routes/api/me/favorites.get.ts @@ -17,6 +17,7 @@ import { db, schema } from '@trackarr/db'; import { and, asc, desc, eq, sql } from 'drizzle-orm'; import { z } from 'zod'; import { getStats } from '~~/utils/server'; +import { validateQuery } from '~~/utils/schemas'; const querySchema = z.object({ page: z.coerce.number().int().min(1).default(1), @@ -26,7 +27,7 @@ const querySchema = z.object({ export default defineEventHandler(async (event) => { const { user } = await requireUserSession(event); - const query = querySchema.parse(getQuery(event)); + const query = validateQuery(event, querySchema); // Join through `torrent_favorites` so we get the pin timestamp // alongside the torrent row. Drizzle's `findMany` with a diff --git a/apps/api/routes/api/me/following.get.ts b/apps/api/routes/api/me/following.get.ts index 96804971..0b9f85df 100644 --- a/apps/api/routes/api/me/following.get.ts +++ b/apps/api/routes/api/me/following.get.ts @@ -17,6 +17,7 @@ import { db, schema } from '@trackarr/db'; import { and, asc, desc, eq, inArray, sql } from 'drizzle-orm'; import { z } from 'zod'; +import { validateQuery } from '~~/utils/schemas'; const querySchema = z.object({ page: z.coerce.number().int().min(1).default(1), @@ -26,7 +27,7 @@ const querySchema = z.object({ export default defineEventHandler(async (event) => { const { user } = await requireUserSession(event); - const query = querySchema.parse(getQuery(event)); + const query = validateQuery(event, querySchema); const offset = (query.page - 1) * query.limit; const orderClause = diff --git a/apps/api/routes/api/me/index.get.ts b/apps/api/routes/api/me/index.get.ts index 0435920d..40236f04 100644 --- a/apps/api/routes/api/me/index.get.ts +++ b/apps/api/routes/api/me/index.get.ts @@ -8,6 +8,7 @@ */ import { db, schema } from '@trackarr/db'; import { eq, sql, desc } from 'drizzle-orm'; +import { isLegacyPasskeyReadAllowed } from '~~/utils/settings'; export default defineEventHandler(async (event) => { const { user: session } = await requireUserSession(event); @@ -67,7 +68,7 @@ export default defineEventHandler(async (event) => { // Fold in counts that are useful at the top of the profile but // would otherwise need separate round-trips. - const [uploadsRow, hnrRow] = await Promise.all([ + const [uploadsRow, hnrRow, legacyPasskeyAccepted] = await Promise.all([ db .select({ value: sql`count(*)::int` }) .from(schema.torrents) @@ -86,6 +87,7 @@ export default defineEventHandler(async (event) => { }) .from(schema.hnrTracking) .where(eq(schema.hnrTracking.userId, user.id)), + isLegacyPasskeyReadAllowed(), ]); const ratio = @@ -127,5 +129,20 @@ export default defineEventHandler(async (event) => { activeSeeds: hnrRow[0]?.value?.active ?? 0, hnr: hnrRow[0]?.value?.hnr ?? 0, }, + /** + * Whether the announce passkey still opens the read surfaces. + * + * A site setting, not a secret, and it lives here because the only other + * place that returned it was `GET /api/me/keys` — which MINTS the two read + * keys on first read. So the banner telling a member their passkey is still + * accepted on feeds, and that they should migrate, appeared only after they + * revealed or copied an RSS key: only after they had already found the thing + * the banner exists to point them at. The member who most needs it — still + * using their passkey in Prowlarr, never opened the RSS card — never saw it. + * Fetching `/api/me/keys` eagerly instead would have minted a key for every + * member who opens their profile, which is the decision that route exists to + * avoid. + */ + legacyPasskeyAccepted, }; }); diff --git a/apps/api/routes/api/me/index.patch.ts b/apps/api/routes/api/me/index.patch.ts index fdcb78e8..acb39c2d 100644 --- a/apps/api/routes/api/me/index.patch.ts +++ b/apps/api/routes/api/me/index.patch.ts @@ -202,6 +202,18 @@ export default defineEventHandler(async (event) => { // read from the session, not from /api/me) reflect the new value on // the next /api/auth/status poll without waiting for a re-login. if ('displayName' in updates || 'theme' in updates || 'language' in updates) { + // `loggedInAt` est CONSERVÉ, pas réestampillé. + // + // Il valait `Date.now()` ici : éditer sa biographie remettait à zéro le + // moment de la connexion. C'est inerte aujourd'hui — la fenêtre de + // `requireFreshAuth` vit dans Redis, clefée sur l'identifiant h3, et rien + // ne lit ce champ comme signal de fraîcheur — mais c'est un piège posé + // pour le jour où quelqu'un s'y fiera : une requête anodine ferait alors + // passer une session ancienne pour fraîche. + // + // Rafraîchir la session sert à ce que la barre de navigation et le thème + // suivent le changement ; l'heure de connexion n'a rien à y voir. + const current = await getUserSession(event); await setUserSession(event, { user: { ...user, @@ -209,7 +221,7 @@ export default defineEventHandler(async (event) => { theme: updated.theme, language: updated.language, }, - loggedInAt: Date.now(), + loggedInAt: current.loggedInAt, }); } diff --git a/apps/api/routes/api/me/keys/[kind].post.ts b/apps/api/routes/api/me/keys/[kind].post.ts new file mode 100644 index 00000000..03cffd78 --- /dev/null +++ b/apps/api/routes/api/me/keys/[kind].post.ts @@ -0,0 +1,34 @@ +/** + * POST /api/me/keys/:kind (kind = `rss` | `api`) + * + * Rotate one read key. The old value stops working on the next request — these + * are read straight from the row, with no cache in front of them, which is the + * property that makes "revoke" mean revoke. + * + * Behind the fresh-auth step-up, like the passkey reset it sits beside: a + * borrowed session should not be able to lock the real member out of their own + * feeds by rotating underneath them. + * + * Rotating one key leaves the other two alone. That is the entire point of + * having three — a member who gave their feed URL to a service that turned out + * to be careless can undo exactly that, without touching a single torrent in + * their client. + */ +import { z } from 'zod/v4'; +import { requireAuthSession, requireFreshAuth } from '~~/utils/adminAuth'; +import { rateLimit, RATE_LIMITS } from '~~/utils/rateLimit'; +import { rotateKey } from '~~/utils/account/readKeys'; +import { validateParam } from '~~/utils/schemas'; + +const kindSchema = z.enum(['rss', 'api']); + +export default defineEventHandler(async (event) => { + const { user } = await requireAuthSession(event); + await rateLimit(event, RATE_LIMITS.mutation); + await requireFreshAuth(event); + + const kind = validateParam(event, 'kind', kindSchema); + const key = await rotateKey(user.id, kind); + + return { kind, key }; +}); diff --git a/apps/api/routes/api/me/keys/index.get.ts b/apps/api/routes/api/me/keys/index.get.ts new file mode 100644 index 00000000..af236a39 --- /dev/null +++ b/apps/api/routes/api/me/keys/index.get.ts @@ -0,0 +1,32 @@ +/** + * GET /api/me/keys + * + * The member's three keys, and what each one opens. + * + * The two read keys are minted on first read — a member who never opens this + * page never has one, which is one fewer live secret per account that has no + * use for it. + * + * The announce passkey is NOT minted here and not returned here: it has its own + * endpoint and its own reveal-and-rotate flow on `/me`, and duplicating it + * would give the page two places to rotate the same secret. What this returns + * about it is whether it still works on the read surfaces, so the page can say + * so. + */ +import { requireAuthSession } from '~~/utils/adminAuth'; +import { rateLimit, RATE_LIMITS } from '~~/utils/rateLimit'; +import { ensureKey } from '~~/utils/account/readKeys'; +import { isLegacyPasskeyReadAllowed } from '~~/utils/settings'; + +export default defineEventHandler(async (event) => { + const { user } = await requireAuthSession(event); + await rateLimit(event, RATE_LIMITS.public); + + const [rssKey, apiKey, legacyPasskeyAccepted] = await Promise.all([ + ensureKey(user.id, 'rss'), + ensureKey(user.id, 'api'), + isLegacyPasskeyReadAllowed(), + ]); + + return { rssKey, apiKey, legacyPasskeyAccepted }; +}); diff --git a/apps/api/routes/api/me/logins.get.ts b/apps/api/routes/api/me/logins.get.ts new file mode 100644 index 00000000..5aaeaa73 --- /dev/null +++ b/apps/api/routes/api/me/logins.get.ts @@ -0,0 +1,91 @@ +/** + * GET /api/me/logins — where this account has been used from. + * + * The member's own copy of the login history, so "was that me?" has an answer + * that does not require asking staff. Includes failures: an attempt that did + * not succeed is the one worth knowing about. + * + * The address is a daily-salted hash, so two rows can be compared for "same + * place" only within one day. The page says so — a reader who assumes + * otherwise would draw the wrong conclusion from two different-looking hashes + * that are in fact the same address a week apart. + */ +import { desc, eq } from 'drizzle-orm'; +import { z } from 'zod/v4'; +import { db, schema } from '@trackarr/db'; +import { requireAuthSession } from '~~/utils/adminAuth'; +import { rateLimit, RATE_LIMITS } from '~~/utils/rateLimit'; +import { validateQuery } from '~~/utils/schemas'; + +const querySchema = z.object({ + limit: z.coerce.number().int().min(1).max(100).default(30), +}); + +/** + * Replace the stored address hash with a label that only means something inside + * this response. + * + * The hash is `sha256(secret:day:ip)` truncated, and it is the SAME value in the + * member's own view and in the moderator's view of that member — so a moderator + * who suspects an account is being shared with somebody they can reach could + * sign in from that address, read their own hash, and compare. The hash was + * meant to make an address unrecoverable; handing the same value to two readers + * made it a confirmation oracle for a day. + * + * An ordinal keeps the only property either view claims — telling two addresses + * apart within one day — and gives up the only property neither needs, which is + * comparability with anybody else's copy. + */ +function labelAddresses( + rows: T[] +): Array & { address: string | null }> { + const seen = new Map(); + return rows.map((row) => { + const { ipHash, ...rest } = row; + if (!ipHash) return { ...rest, address: null } as Omit & { address: null }; + // Keyed on hash AND day, because the salt rotates daily: the same address + // is a different hash tomorrow, and pretending otherwise would invent a + // continuity the data does not have. + // + // Numbered WITHIN the day, not across the response. A single counter over + // the whole page meant one home address on five different days rendered as + // `#1 #2 #3 #4 #5` — so the ordinary case, one place over many days, looked + // exactly like five different places, on the one screen whose entire job is + // spotting somebody else signing in as you. Restarting each day also makes + // the "only comparable within one day" caveat something the numbers say for + // themselves rather than something a footnote has to teach. + const day = row.createdAt.toISOString().slice(0, 10); + const key = `${ipHash}:${day}`; + let ordinal = seen.get(key); + if (ordinal === undefined) { + let next = 1; + for (const k of seen.keys()) if (k.endsWith(`:${day}`)) next += 1; + ordinal = next; + seen.set(key, ordinal); + } + return { ...rest, address: `#${ordinal}` } as Omit & { address: string }; + }); +} + +export default defineEventHandler(async (event) => { + const { user } = await requireAuthSession(event); + await rateLimit(event, RATE_LIMITS.public); + + const { limit } = validateQuery(event, querySchema); + + const items = await db + .select({ + id: schema.loginEvents.id, + method: schema.loginEvents.method, + outcome: schema.loginEvents.outcome, + ipHash: schema.loginEvents.ipHash, + userAgent: schema.loginEvents.userAgent, + createdAt: schema.loginEvents.createdAt, + }) + .from(schema.loginEvents) + .where(eq(schema.loginEvents.userId, user.id)) + .orderBy(desc(schema.loginEvents.createdAt)) + .limit(limit); + + return { items: labelAddresses(items) }; +}); diff --git a/apps/api/routes/api/me/notification-channels/[type].delete.ts b/apps/api/routes/api/me/notification-channels/[type].delete.ts index 1709415a..ebe733c9 100644 --- a/apps/api/routes/api/me/notification-channels/[type].delete.ts +++ b/apps/api/routes/api/me/notification-channels/[type].delete.ts @@ -7,30 +7,38 @@ * deliver to a row that no longer exists). */ import { db, schema } from '@trackarr/db'; +import { rateLimit, RATE_LIMITS } from '~~/utils/rateLimit'; import { and, eq } from 'drizzle-orm'; export default defineEventHandler(async (event) => { const { user } = await requireUserSession(event); + await rateLimit(event, RATE_LIMITS.mutation); const type = getRouterParam(event, 'type') ?? ''; if (!type) { throw createError({ statusCode: 400, statusMessage: 'Missing channel type' }); } - await db - .delete(schema.userNotificationRouting) - .where( - and( - eq(schema.userNotificationRouting.userId, user.id), - eq(schema.userNotificationRouting.channelType, type) - ) - ); - await db - .delete(schema.userNotificationChannels) - .where( - and( - eq(schema.userNotificationChannels.userId, user.id), - eq(schema.userNotificationChannels.channelType, type) - ) - ); + // Les deux suppressions dans UNE transaction : l'intention est de retirer un + // canal ET ses routes, et un échec entre les deux laissait la configuration à + // moitié détruite — des routes orphelines pointant sur un canal disparu, ce + // que ce fichier existe précisément pour éviter. + await db.transaction(async (tx) => { + await tx + .delete(schema.userNotificationRouting) + .where( + and( + eq(schema.userNotificationRouting.userId, user.id), + eq(schema.userNotificationRouting.channelType, type) + ) + ); + await tx + .delete(schema.userNotificationChannels) + .where( + and( + eq(schema.userNotificationChannels.userId, user.id), + eq(schema.userNotificationChannels.channelType, type) + ) + ); + }); return { ok: true }; }); diff --git a/apps/api/routes/api/me/notification-channels/[type].put.ts b/apps/api/routes/api/me/notification-channels/[type].put.ts index b88d6dab..dc0e2585 100644 --- a/apps/api/routes/api/me/notification-channels/[type].put.ts +++ b/apps/api/routes/api/me/notification-channels/[type].put.ts @@ -14,6 +14,7 @@ * land. We re-check the gate here rather than trusting the UI. */ import { db, schema } from '@trackarr/db'; +import { rateLimit, RATE_LIMITS } from '~~/utils/rateLimit'; import { and, eq } from 'drizzle-orm'; import { z } from 'zod'; import { v4 as uuidv4 } from 'uuid'; @@ -31,6 +32,7 @@ const bodySchema = z.object({ export default defineEventHandler(async (event) => { const { user } = await requireUserSession(event); + await rateLimit(event, RATE_LIMITS.mutation); const type = getRouterParam(event, 'type') ?? ''; const adapter = getAdapter(type); if (!adapter) { diff --git a/apps/api/routes/api/me/notification-routing.put.ts b/apps/api/routes/api/me/notification-routing.put.ts index 61f56987..7220952c 100644 --- a/apps/api/routes/api/me/notification-routing.put.ts +++ b/apps/api/routes/api/me/notification-routing.put.ts @@ -15,6 +15,7 @@ * actually configured (avoids dangling pointers). */ import { db, schema } from '@trackarr/db'; +import { rateLimit, RATE_LIMITS } from '~~/utils/rateLimit'; import { eq, inArray } from 'drizzle-orm'; import { z } from 'zod'; @@ -31,6 +32,7 @@ const bodySchema = z.object({ export default defineEventHandler(async (event) => { const { user } = await requireUserSession(event); + await rateLimit(event, RATE_LIMITS.mutation); const raw = await readBody(event); const parsed = bodySchema.safeParse(raw); if (!parsed.success) { diff --git a/apps/api/routes/api/me/passkey/reset.post.ts b/apps/api/routes/api/me/passkey/reset.post.ts index 7f15d748..be14c359 100644 --- a/apps/api/routes/api/me/passkey/reset.post.ts +++ b/apps/api/routes/api/me/passkey/reset.post.ts @@ -20,11 +20,16 @@ * of the tracker. */ import { eq } from 'drizzle-orm'; +import { redis } from '~~/utils/server'; +import { createHash } from 'node:crypto'; import { db, schema } from '@trackarr/db'; import { requireAuthSession, requireFreshAuth } from '~~/utils/adminAuth'; import { generatePasskey } from '~~/utils/auth'; import { rateLimit, RATE_LIMITS } from '~~/utils/rateLimit'; -import { clearTorznabUserStats } from '~~/utils/torznabStats'; +import { + carryTorznabBlock, + retireTorznabPasskey, +} from '~~/utils/torznabStats'; import { notify } from '~~/utils/notify'; export default defineEventHandler(async (event) => { @@ -45,14 +50,45 @@ export default defineEventHandler(async (event) => { const oldPasskey = row.passkey; const newPasskey = generatePasskey(); + // Before the row changes. A Torznab access block is keyed by a hash of the + // passkey, so a rotation that did not carry it over would hand a blocked + // member the lift for free — this route is self-service, needs no + // administrator, and would have been the whole restriction's back door. + // Refuses the rotation if it cannot be sure, rather than freeing the member. + const carried = await carryTorznabBlock(oldPasskey, newPasskey); + await db .update(schema.users) .set({ passkey: newPasskey }) .where(eq(schema.users.id, user.id)); - // The per-passkey Torznab counters are keyed on the old value; leaving them - // behind would both leak the rotation and strand the rows. - await clearTorznabUserStats(oldPasskey); + // The old value belongs to nobody now: its block entry and its per-passkey + // counters both index a hash no account matches any more, and leaving the + // counters behind would also leak the rotation. + await retireTorznabPasskey(oldPasskey, carried); + + /* + * Le cache du tracker, purgé tout de suite. + * + * Le tracker résout `(passkey → user)` par un cache Redis de 60 s indexé sur + * `sha256(passkey)[:16]`. Il l'invalide lui-même sur son chemin de déban + * paresseux, et laisse le TTL couvrir le reste — un contrat écrit pour les + * changements d'état de BANNISSEMENT, pas pour une RÉVOCATION. Résultat : + * une passkey régénérée ici continuait d'annoncer une minute durant, ce qui + * est exactement la fenêtre qu'une rotation existe pour fermer (« ma clé a + * fuité »). + * + * La même clé, calculée de la même façon, effacée depuis ce côté-ci : c'est + * une suppression Redis, pas un appel au tracker, donc il n'y a pas de + * couplage de service à ajouter. + */ + try { + const digest = createHash('sha256').update(oldPasskey).digest('hex').slice(0, 16); + await redis.del(`${process.env.REDIS_KEY_PREFIX ?? 'ot:'}trk:pk:${digest}`); + } catch { + // Le TTL de 60 s reste le repli : ne pas faire échouer une rotation + // parce que le cache a hoqueté. + } // The session cookie carries the passkey, so it now holds a dead one. Write // the new value back rather than force a re-login. diff --git a/apps/api/routes/api/me/saved-searches/[id].delete.ts b/apps/api/routes/api/me/saved-searches/[id].delete.ts new file mode 100644 index 00000000..1d2e84b6 --- /dev/null +++ b/apps/api/routes/api/me/saved-searches/[id].delete.ts @@ -0,0 +1,29 @@ +/** + * DELETE /api/me/saved-searches/:id — own rows only. + */ +import { and, eq } from 'drizzle-orm'; +import { db, schema } from '@trackarr/db'; +import { requireAuthSession } from '~~/utils/adminAuth'; +import { rateLimit, RATE_LIMITS } from '~~/utils/rateLimit'; +import { uuidSchema, validateParam } from '~~/utils/schemas'; + +export default defineEventHandler(async (event) => { + const { user } = await requireAuthSession(event); + await rateLimit(event, RATE_LIMITS.mutation); + + const id = validateParam(event, 'id', uuidSchema); + + // Scoped to the owner in the WHERE rather than checked first: one statement, + // and no window between the check and the delete. + const deleted = await db + .delete(schema.savedSearches) + .where( + and(eq(schema.savedSearches.id, id), eq(schema.savedSearches.userId, user.id)) + ) + .returning({ id: schema.savedSearches.id }); + + if (deleted.length === 0) { + throw createError({ statusCode: 404, message: 'Saved search not found' }); + } + return { success: true }; +}); diff --git a/apps/api/routes/api/me/saved-searches/index.get.ts b/apps/api/routes/api/me/saved-searches/index.get.ts new file mode 100644 index 00000000..13aa30cc --- /dev/null +++ b/apps/api/routes/api/me/saved-searches/index.get.ts @@ -0,0 +1,24 @@ +/** + * GET /api/me/saved-searches — the member's stored filters. + */ +import { desc, eq } from 'drizzle-orm'; +import { db, schema } from '@trackarr/db'; +import { requireAuthSession } from '~~/utils/adminAuth'; +import { rateLimit, RATE_LIMITS } from '~~/utils/rateLimit'; +import { getSavedSearchMaxPerUser } from '~~/utils/settings'; + +export default defineEventHandler(async (event) => { + const { user } = await requireAuthSession(event); + await rateLimit(event, RATE_LIMITS.public); + + const [items, max] = await Promise.all([ + db.query.savedSearches.findMany({ + where: eq(schema.savedSearches.userId, user.id), + with: { category: { columns: { id: true, name: true, slug: true } } }, + orderBy: [desc(schema.savedSearches.createdAt)], + }), + getSavedSearchMaxPerUser(), + ]); + + return { items, max }; +}); diff --git a/apps/api/routes/api/me/saved-searches/index.post.ts b/apps/api/routes/api/me/saved-searches/index.post.ts new file mode 100644 index 00000000..d7e85aac --- /dev/null +++ b/apps/api/routes/api/me/saved-searches/index.post.ts @@ -0,0 +1,133 @@ +/** + * POST /api/me/saved-searches + * + * Store a filter. The body is the listing's own vocabulary — the same + * parameters `/api/torrents` accepts — so the page can hand over whatever the + * member currently has on screen without translating anything. + * + * ## Two refusals worth their code + * + * **A filter with no criteria at all** would match every upload forever, which + * is not a saved search but a firehose. Refused with a message that says what + * to add rather than a generic 400. + * + * **A cap per member.** Every armed filter is evaluated against every accepted + * upload, so the cost of the feature is the number of armed filters across the + * site. A ceiling somebody chose beats one that emerges at three in the + * morning. + */ +import { and, eq, sql } from 'drizzle-orm'; +import { randomUUID } from 'node:crypto'; +import { z } from 'zod/v4'; +import { db, schema } from '@trackarr/db'; +import { requireAuthSession } from '~~/utils/adminAuth'; +import { rateLimit, RATE_LIMITS } from '~~/utils/rateLimit'; +import { validateBody } from '~~/utils/schemas'; +import { toExactTsQuery } from '~~/utils/search'; +import { slugifyTag } from '~~/utils/tags'; +import { normalizeMediaId, tmdbIdBare } from '~~/utils/mediaIds'; +import { getSavedSearchMaxPerUser } from '~~/utils/settings'; + +const bodySchema = z.object({ + label: z.string().trim().min(1).max(80), + query: z.string().trim().max(255).optional(), + categoryId: z.string().max(128).optional(), + /** Tag slugs or names — `slugifyTag` resolves both, as the listing does. */ + tags: z.array(z.string().max(64)).max(10).optional(), + imdbId: z.string().max(64).optional(), + tmdbId: z.string().max(64).optional(), + tvdbId: z.string().max(64).optional(), + notify: z.boolean().optional(), +}); + +export default defineEventHandler(async (event) => { + const { user } = await requireAuthSession(event); + await rateLimit(event, RATE_LIMITS.mutation); + + const body = await validateBody(event, bodySchema); + + const tags = (body.tags ?? []).map(slugifyTag).filter(Boolean); + const query = body.query?.trim() || null; + const tsquery = query ? toExactTsQuery(query) : null; + const imdbId = body.imdbId ? normalizeMediaId('imdb', body.imdbId) : null; + const tmdbId = body.tmdbId ? tmdbIdBare(body.tmdbId) : null; + const tvdbId = body.tvdbId?.trim() || null; + const categoryId = body.categoryId?.trim() || null; + + // `query` without a usable tsquery means the member typed only punctuation — + // it looks like a criterion and matches nothing, so it does not count as one. + const hasCriteria = + !!tsquery || !!categoryId || tags.length > 0 || !!imdbId || !!tmdbId || !!tvdbId; + if (!hasCriteria) { + throw createError({ + statusCode: 400, + // `data.reason` so the browser can pick its own translated sentence. + // The message here is written for a developer reading a log; echoing it + // into the page put an English string in front of a French member. + data: { reason: 'no-criteria' }, + message: + 'A saved search needs at least one criterion — some text, a category, a tag or a media id.', + }); + } + + /** + * `tags` part en JSON, pas en tableau JavaScript brut. + * + * La colonne est du `jsonb`. Interpolé tel quel dans le gabarit SQL, un + * tableau JS devient un TABLEAU Postgres, et le serveur refuse : + * « column "tags" is of type jsonb but expression is of type record ». + * Toute recherche enregistrée portant au moins une étiquette échouait donc + * en 500 — alors que la page propose explicitement ce cas (`canSaveSearch` + * accepte les étiquettes seules) et que `/alerts` affiche déjà une puce par + * étiquette. Les autres colonnes sont du texte et n'ont jamais eu le + * problème, ce qui a gardé la panne cantonnée au seul chemin `jsonb`. + * + * Écrit ici plutôt qu'en commentaire SQL dans le gabarit ci-dessous : un + * `--` survit mal à une requête aplatie, et un accent grave dans un modèle + * littéral le termine. + */ + const tagsJson = tags.length ? JSON.stringify(tags) : null; + + const max = await getSavedSearchMaxPerUser(); + const id = randomUUID(); + + /** + * The cap is enforced by the INSERT, not by a count before it. + * + * Read-then-insert is a race, and this cap is the only bound on what the + * fan-out costs the whole site: ten concurrent posts at nineteen filters each + * all read nineteen and all inserted. `INSERT … SELECT … WHERE (SELECT count(*) + * …) < max` decides it in one statement; an insert that did not happen means + * the ceiling was reached. + * + * Written as SQL rather than through the query builder because the builder's + * insert-from-select would need every column aliased to its snake_case name + * by hand anyway, and this way the predicate sits where a reader expects it. + */ + const inserted = await db.execute(sql` + insert into ${schema.savedSearches} + (id, user_id, label, query, tsquery, category_id, tags, imdb_id, tmdb_id, tvdb_id, notify) + select + ${id}, ${user.id}, ${body.label}, ${query}, ${tsquery}, ${categoryId}, + ${tagsJson}::jsonb, ${imdbId}, ${tmdbId}, ${tvdbId}, ${body.notify ?? true} + where ( + select count(*) from ${schema.savedSearches} + where ${schema.savedSearches.userId} = ${user.id} + ) < ${max} + returning id + `); + + const created = + (inserted as unknown as { length?: number; count?: number })?.length ?? + (inserted as unknown as { count?: number })?.count ?? + 0; + if (created === 0) { + throw createError({ + statusCode: 400, + data: { reason: 'limit', max }, + message: `You can keep up to ${max} saved searches. Delete one first.`, + }); + } + + return { id, success: true }; +}); diff --git a/apps/api/routes/api/me/seeds.get.ts b/apps/api/routes/api/me/seeds.get.ts index 5a22ef1b..5038ca11 100644 --- a/apps/api/routes/api/me/seeds.get.ts +++ b/apps/api/routes/api/me/seeds.get.ts @@ -29,6 +29,7 @@ import { db, schema } from '@trackarr/db'; import { and, desc, eq, sql } from 'drizzle-orm'; import { z } from 'zod'; import { getStats } from '~~/utils/server'; +import { validateQuery } from '~~/utils/schemas'; const querySchema = z.object({ page: z.coerce.number().int().min(1).default(1), @@ -40,7 +41,7 @@ const querySchema = z.object({ export default defineEventHandler(async (event) => { const { user } = await requireUserSession(event); - const params = querySchema.parse(getQuery(event)); + const params = validateQuery(event, querySchema); // Build WHERE const conditions = [eq(schema.hnrTracking.userId, user.id)]; diff --git a/apps/api/routes/api/me/sessions/revoke-all.post.ts b/apps/api/routes/api/me/sessions/revoke-all.post.ts new file mode 100644 index 00000000..eafe96df --- /dev/null +++ b/apps/api/routes/api/me/sessions/revoke-all.post.ts @@ -0,0 +1,60 @@ +/** + * POST /api/me/sessions/revoke-all — « Déconnecter partout ». + * + * Le cookie de session est scellé et SANS ÉTAT : sept jours, sans registre + * serveur. Jusqu'ici rien ne pouvait l'invalider. `auth/password.put.ts` + * documente d'ailleurs qu'un changement de mot de passe laisse les sessions + * ouvertes, et se déconnecter ne vide que le cookie du navigateur courant. Un + * cookie exfiltré — un poste partagé, un ordinateur volé, une extension + * curieuse — valait donc sept jours d'accès ordinaire, et le seul recours + * était de bannir le compte ou de faire tourner `NUXT_SESSION_SECRET`, ce qui + * déconnecte TOUS les membres pour le problème d'un seul. + * + * Le geste est un incrément : `users.session_epoch + 1`. La session porte + * l'époque reçue à la connexion, et `requireUserSession` compare les deux à + * chaque requête — sur la lecture d'état vivant qui avait déjà lieu, donc sans + * requête supplémentaire. + * + * CELLE DE L'APPELANT AUSSI. C'est voulu : « déconnecter partout » qui + * épargnerait l'appareil courant obligerait à savoir lequel c'est, et cette + * information n'existe pas dans un cookie sans état. Le membre se reconnecte — + * et c'est aussi la bonne réponse quand il ne sait plus quel appareil est + * compromis. + * + * `requireFreshAuth` comme sur la réinitialisation de passkey : une action qui + * répare un vol de session ne doit pas être déclenchable PAR la session volée + * sans que son porteur reprouve qui il est. + */ +import { db, schema } from '@trackarr/db'; +import { eq, sql } from 'drizzle-orm'; +import { requireAuthSession, requireFreshAuth } from '~~/utils/adminAuth'; +import { invalidateRoleCache } from '~~/utils/liveRoles'; +import { rateLimit, RATE_LIMITS } from '~~/utils/rateLimit'; +import { auditDetail } from '~~/utils/audit'; + +export default defineEventHandler(async (event) => { + const session = await requireAuthSession(event); + await requireFreshAuth(event); + await rateLimit(event, RATE_LIMITS.mutation); + + auditDetail(event, { + action: 'session.revoke_all', + targetType: 'user', + targetId: session.user.id, + }); + + const [row] = await db + .update(schema.users) + .set({ sessionEpoch: sql`${schema.users.sessionEpoch} + 1` }) + .where(eq(schema.users.id, session.user.id)) + .returning({ sessionEpoch: schema.users.sessionEpoch }); + + // Le cache de rôles porte l'époque : sans cette purge, l'instance + // continuerait d'accepter les sessions périmées jusqu'à 60 s. + await invalidateRoleCache(session.user.id); + + // On ne vide pas le cookie ici : la prochaine requête le fera, avec le motif + // `session-revoked` que le front sait présenter. Le vider maintenant + // donnerait une déconnexion muette, impossible à distinguer d'une panne. + return { ok: true, sessionEpoch: row?.sessionEpoch ?? null }; +}); diff --git a/apps/api/routes/api/me/templates/[id].delete.ts b/apps/api/routes/api/me/templates/[id].delete.ts index 52d14a8a..e7b632d2 100644 --- a/apps/api/routes/api/me/templates/[id].delete.ts +++ b/apps/api/routes/api/me/templates/[id].delete.ts @@ -19,13 +19,14 @@ import { and, eq } from 'drizzle-orm'; import { z } from 'zod'; import { db, schema } from '@trackarr/db'; import { rateLimit, RATE_LIMITS } from '~~/utils/rateLimit'; +import { validateRouterParams } from '~~/utils/schemas'; const paramsSchema = z.object({ id: z.string().uuid() }); export default defineEventHandler(async (event) => { const { user } = await requireUserSession(event); await rateLimit(event, RATE_LIMITS.mutation); - const { id } = paramsSchema.parse(getRouterParams(event)); + const { id } = validateRouterParams(event, paramsSchema); // One statement, scoped to the owner: a row that is not the caller's and a // row that does not exist both come back as zero rows, so neither the diff --git a/apps/api/routes/api/me/templates/[id].patch.ts b/apps/api/routes/api/me/templates/[id].patch.ts index faad408f..432c215f 100644 --- a/apps/api/routes/api/me/templates/[id].patch.ts +++ b/apps/api/routes/api/me/templates/[id].patch.ts @@ -16,6 +16,7 @@ import { z } from 'zod'; import { db, schema } from '@trackarr/db'; import { rateLimit, RATE_LIMITS } from '~~/utils/rateLimit'; import { assertTemplateGrammar } from '~~/utils/templateGrammar'; +import { validateRouterParams } from '~~/utils/schemas'; const paramsSchema = z.object({ id: z.string().uuid() }); const bodySchema = z @@ -48,7 +49,7 @@ const bodySchema = z export default defineEventHandler(async (event) => { const { user } = await requireUserSession(event); await rateLimit(event, RATE_LIMITS.mutation); - const { id } = paramsSchema.parse(getRouterParams(event)); + const { id } = validateRouterParams(event, paramsSchema); const body = await readValidatedBody(event, bodySchema.parse); // Only when the field is actually being written — a rename must not be // refused because the stored body predates this check. diff --git a/apps/api/routes/api/me/templates/[id]/default.put.ts b/apps/api/routes/api/me/templates/[id]/default.put.ts index cf5e9723..4f5f8a89 100644 --- a/apps/api/routes/api/me/templates/[id]/default.put.ts +++ b/apps/api/routes/api/me/templates/[id]/default.put.ts @@ -22,6 +22,7 @@ import { and, eq, ne } from 'drizzle-orm'; import { z } from 'zod'; import { db, schema } from '@trackarr/db'; import { rateLimit, RATE_LIMITS } from '~~/utils/rateLimit'; +import { validateRouterParams } from '~~/utils/schemas'; const paramsSchema = z.object({ id: z.string().uuid() }); const bodySchema = z.object({ @@ -33,7 +34,7 @@ const bodySchema = z.object({ export default defineEventHandler(async (event) => { const { user } = await requireUserSession(event); await rateLimit(event, RATE_LIMITS.mutation); - const { id } = paramsSchema.parse(getRouterParams(event)); + const { id } = validateRouterParams(event, paramsSchema); // A PUT with no payload at all is the normal call, and readBody then // yields undefined (or throws on an empty-but-typed body) — neither of // which zod tolerates, hence the explicit fallback and safeParse. diff --git a/apps/api/routes/api/me/templates/index.get.ts b/apps/api/routes/api/me/templates/index.get.ts index f7380c3a..ab47fa50 100644 --- a/apps/api/routes/api/me/templates/index.get.ts +++ b/apps/api/routes/api/me/templates/index.get.ts @@ -25,6 +25,7 @@ import { and, asc, desc, eq, or, sql, type SQL } from 'drizzle-orm'; import { z } from 'zod'; import { rateLimit, RATE_LIMITS } from '~~/utils/rateLimit'; import { getTemplateQuotaPerUser } from '~~/utils/settings'; +import { validateQuery } from '~~/utils/schemas'; const querySchema = z.object({ scope: z.enum(['mine', 'site', 'all']).default('all'), @@ -40,7 +41,7 @@ export default defineEventHandler(async (event) => { // wizard both refetch after every write, and throttling those alongside // the writes themselves would make the UI stall on ordinary use. await rateLimit(event, RATE_LIMITS.public); - const query = querySchema.parse(getQuery(event)); + const query = validateQuery(event, querySchema); const offset = (query.page - 1) * query.limit; const mine = eq(schema.presentationTemplates.ownerId, user.id); diff --git a/apps/api/routes/api/me/year.get.ts b/apps/api/routes/api/me/year.get.ts new file mode 100644 index 00000000..e096f018 --- /dev/null +++ b/apps/api/routes/api/me/year.get.ts @@ -0,0 +1,60 @@ +/** + * GET /api/me/year?year=YYYY + * + * The caller's own year. Nobody else's, ever — there is no id parameter and no + * staff override, because there is no question a moderator has that this page + * answers better than the existing tools. + * + * Their data, so nothing is redacted: the adult filter is not applied here, and + * that is deliberate rather than an omission. A member who grabbed something + * already saw it, and a review that hid part of their own year would be a + * report about somebody else. + * + * Not cached. It is one member's row set, it is cheap, and a member reading + * their own review a second time after uploading something should see the + * upload. + */ +import { z } from 'zod/v4'; +import { eq } from 'drizzle-orm'; +import { db, schema } from '@trackarr/db'; +import { requireAuthSession } from '~~/utils/adminAuth'; +import { rateLimit, RATE_LIMITS } from '~~/utils/rateLimit'; +import { validateQuery } from '~~/utils/schemas'; +import { memberYear } from '~~/utils/publicStats'; + +const querySchema = z.object({ + year: z.coerce.number().int().min(2000).max(2100), +}); + +export default defineEventHandler(async (event) => { + const { user } = await requireAuthSession(event); + await rateLimit(event, RATE_LIMITS.public); + const { year } = validateQuery(event, querySchema); + + /** + * `hide_download_history` is a door, not a content filter. + * + * `/api/me/downloads` refuses the list to the AUTHENTICATED CALLER when the + * flag is set, and says why: what the toggle buys is that a stolen session + * cannot enumerate the snatch list. Three of the figures here come from the + * same table on the same precondition — a year of downloaded bytes and a grab + * count are exactly what that door is shut against — so the flag has to be + * honoured here too. The upload side comes from `torrents` and stays. + */ + const me = await db.query.users.findFirst({ + where: eq(schema.users.id, user.id), + columns: { hideDownloadHistory: true }, + }); + + const summary = await memberYear(user.id, year); + if (!me?.hideDownloadHistory) return { ...summary, downloadsHidden: false }; + + return { + ...summary, + snatches: 0, + seedTimeSeconds: 0, + bytesUp: 0, + bytesDown: 0, + downloadsHidden: true, + }; +}); diff --git a/apps/api/routes/api/metadata/lookup.get.ts b/apps/api/routes/api/metadata/lookup.get.ts index e8c32028..f4545186 100644 --- a/apps/api/routes/api/metadata/lookup.get.ts +++ b/apps/api/routes/api/metadata/lookup.get.ts @@ -21,6 +21,7 @@ import { type LookupSource, } from '~~/utils/metadata'; import type { MediaTypeHint } from '~~/utils/metadata/types'; +import { validateQuery } from '~~/utils/schemas'; const querySchema = z.object({ source: z.enum(ALL_SOURCE_IDS as [LookupSource, ...LookupSource[]]), @@ -43,7 +44,7 @@ export default defineEventHandler(async (event) => { }; } - const { source, id, type } = querySchema.parse(getQuery(event)); + const { source, id, type } = validateQuery(event, querySchema); if (!isSourceEnabled(source)) { setResponseStatus(event, 503); diff --git a/apps/api/routes/api/metadata/search.get.ts b/apps/api/routes/api/metadata/search.get.ts index 6b46e7f2..8dd90882 100644 --- a/apps/api/routes/api/metadata/search.get.ts +++ b/apps/api/routes/api/metadata/search.get.ts @@ -17,6 +17,7 @@ import { z } from 'zod'; import { eq } from 'drizzle-orm'; import { db, schema } from '@trackarr/db'; +import { validateQuery } from '~~/utils/schemas'; import { ALL_SOURCE_IDS, isMetadataEnabled, @@ -47,7 +48,7 @@ export default defineEventHandler(async (event) => { }; } - const { query, source, type, year } = querySchema.parse(getQuery(event)); + const { query, source, type, year } = validateQuery(event, querySchema); if (!isSourceEnabled(source)) { setResponseStatus(event, 503); diff --git a/apps/api/routes/api/mod/torrents/[hash]/buffs.put.ts b/apps/api/routes/api/mod/torrents/[hash]/buffs.put.ts new file mode 100644 index 00000000..f9e430e8 --- /dev/null +++ b/apps/api/routes/api/mod/torrents/[hash]/buffs.put.ts @@ -0,0 +1,197 @@ +/** + * PUT /api/mod/torrents/:hash/buffs + * + * Per-torrent bonus multipliers and the pinned flag — the buffs an operator + * applies to one release rather than to the whole site. + * + * ## Two powers, two gates + * + * `requireModeratorSession` covers the route, and the multipliers then require + * `isAdmin` on top. That split is not bureaucracy: pinning a release moves it + * up a page and changes nothing about what it costs, while setting its download + * multiplier to 0 mints upload credit out of nothing. One is editorial, the + * other is economic, and the existing ban route already draws a line in the + * same place (a moderator bans, an admin bans a moderator). + * + * Whichever gate applies, the request lands in the staff audit log — it is a + * mutating call under `/api/mod/`, so the hook records it without this route + * asking. `auditDetail` below only sharpens what it says. + * + * ## Units + * + * Basis points ×100, the same convention `bonus_events` uses: `0` freeleech, + * `50` silverleech, `100` normal, `200` double upload. Bounds come from + * `utils/bonusEvents` rather than being restated, so a torrent buff can never + * exceed what a site-wide event may do. + * + * ## `until` + * + * `null` means "until an operator changes it". A timestamp means the buff + * lapses on its own — and it lapses in SQL, on the announce path's own read, + * so there is no sweep to schedule and nothing to forget. Setting a time in + * the past is refused rather than silently accepted as "already over": it + * reads as a mistake, and the way to end a buff is to reset it. + * + * ## No notification + * + * A freeleech on somebody's upload is good news, and it is deliberately not + * announced to them. It would need a 51st notification type across six files, + * it would fire on every adjustment including the ones that take a buff away, + * and the badge on the torrent page already says what is true. The audit log + * says who did it. + */ +import { eq } from 'drizzle-orm'; +import { z } from 'zod/v4'; +import { db, schema } from '@trackarr/db'; +import { requireModeratorSession } from '~~/utils/adminAuth'; +import { auditDetail } from '~~/utils/audit'; +import { rateLimit, RATE_LIMITS } from '~~/utils/rateLimit'; +import { validateBody } from '~~/utils/schemas'; +import { + DOWNLOAD_MULTIPLIER_MAX, + DOWNLOAD_MULTIPLIER_MIN, + UPLOAD_MULTIPLIER_MAX, + UPLOAD_MULTIPLIER_MIN, +} from '~~/utils/bonusEvents'; + +const bodySchema = z.object({ + downloadMultiplier: z + .number() + .int() + .min(DOWNLOAD_MULTIPLIER_MIN) + .max(DOWNLOAD_MULTIPLIER_MAX) + .optional(), + uploadMultiplier: z + .number() + .int() + .min(UPLOAD_MULTIPLIER_MIN) + .max(UPLOAD_MULTIPLIER_MAX) + .optional(), + /** ISO timestamp, or null for "no end date". */ + until: z.union([z.iso.datetime(), z.null()]).optional(), + isSticky: z.boolean().optional(), +}); + +export default defineEventHandler(async (event) => { + const { user: actor } = await requireModeratorSession(event); + await rateLimit(event, RATE_LIMITS.mutation); + + const hash = getRouterParam(event, 'hash'); + if (!hash) { + throw createError({ statusCode: 400, message: 'Torrent hash is required' }); + } + const infoHash = hash.toLowerCase(); + + const body = await validateBody(event, bodySchema); + + const touchesEconomy = + body.downloadMultiplier !== undefined || + body.uploadMultiplier !== undefined || + body.until !== undefined; + + if (touchesEconomy && !actor.isAdmin) { + throw createError({ + statusCode: 403, + message: 'Only an admin can change a torrent’s bonus multipliers.', + }); + } + + const existing = await db.query.torrents.findFirst({ + where: eq(schema.torrents.infoHash, infoHash), + columns: { + id: true, + name: true, + downloadMultiplier: true, + uploadMultiplier: true, + multipliersUntil: true, + isSticky: true, + }, + }); + if (!existing) { + throw createError({ statusCode: 404, message: 'Torrent not found' }); + } + + // A past timestamp is a mistake, not a way to end a buff. Reset the + // multipliers to 100 for that. + if (typeof body.until === 'string' && new Date(body.until) <= new Date()) { + throw createError({ + statusCode: 400, + message: 'The end date must be in the future. Reset the multipliers to end a buff now.', + }); + } + + const next = { + downloadMultiplier: body.downloadMultiplier ?? existing.downloadMultiplier, + uploadMultiplier: body.uploadMultiplier ?? existing.uploadMultiplier, + multipliersUntil: + body.until === undefined + ? existing.multipliersUntil + : body.until === null + ? null + : new Date(body.until), + isSticky: body.isSticky ?? existing.isSticky, + }; + + // An end date on a torrent carrying no buff describes nothing, and it would + // sit in the row waiting to "expire" values that are already neutral. Drop it + // rather than store a fact about nothing. + if ( + next.downloadMultiplier === 100 && + next.uploadMultiplier === 100 && + next.multipliersUntil !== null + ) { + next.multipliersUntil = null; + } + + auditDetail(event, { + action: 'torrent.buffs', + targetType: 'torrent', + targetId: existing.id, + targetLabel: existing.name, + changes: { + ...(next.downloadMultiplier !== existing.downloadMultiplier + ? { + downloadMultiplier: { + from: existing.downloadMultiplier, + to: next.downloadMultiplier, + }, + } + : {}), + ...(next.uploadMultiplier !== existing.uploadMultiplier + ? { + uploadMultiplier: { + from: existing.uploadMultiplier, + to: next.uploadMultiplier, + }, + } + : {}), + ...(next.multipliersUntil?.getTime() !== existing.multipliersUntil?.getTime() + ? { + until: { + from: existing.multipliersUntil?.toISOString() ?? null, + to: next.multipliersUntil?.toISOString() ?? null, + }, + } + : {}), + ...(next.isSticky !== existing.isSticky + ? { isSticky: { from: existing.isSticky, to: next.isSticky } } + : {}), + }, + }); + + await db + .update(schema.torrents) + .set(next) + .where(eq(schema.torrents.id, existing.id)); + + // The announce path reads the row directly and the SQL neutralises a lapsed + // buff, so there is no cache to bust here — a change takes effect on the + // next announce, not on the next cache TTL. + return { + success: true, + downloadMultiplier: next.downloadMultiplier, + uploadMultiplier: next.uploadMultiplier, + until: next.multipliersUntil?.toISOString() ?? null, + isSticky: next.isSticky, + }; +}); diff --git a/apps/api/routes/api/mod/torrents/[hash]/supersede.delete.ts b/apps/api/routes/api/mod/torrents/[hash]/supersede.delete.ts new file mode 100644 index 00000000..6ace6e00 --- /dev/null +++ b/apps/api/routes/api/mod/torrents/[hash]/supersede.delete.ts @@ -0,0 +1,50 @@ +/** + * DELETE /api/mod/torrents/:hash/supersede + * + * Lift a supersede marking — the release is current again, or the pointer was + * wrong. Sibling of `supersede.put.ts`, which carries the reasoning and the + * guards; this end needs neither, because clearing a pointer cannot create a + * loop or send anybody to a page they cannot read. + * + * Unconditional: clearing an already-clear marking succeeds and says so. A 404 + * for "it was not superseded anyway" would make the UI carry a state check for + * an operation whose outcome is the same either way. + */ +import { eq } from 'drizzle-orm'; +import { db, schema } from '@trackarr/db'; +import { requireModeratorSession } from '~~/utils/adminAuth'; +import { auditDetail } from '~~/utils/audit'; +import { rateLimit, RATE_LIMITS } from '~~/utils/rateLimit'; + +export default defineEventHandler(async (event) => { + await requireModeratorSession(event); + await rateLimit(event, RATE_LIMITS.mutation); + + const hash = getRouterParam(event, 'hash'); + if (!hash) { + throw createError({ statusCode: 400, message: 'Torrent hash is required' }); + } + + const source = await db.query.torrents.findFirst({ + where: eq(schema.torrents.infoHash, hash.toLowerCase()), + columns: { id: true, name: true, supersededById: true }, + }); + if (!source) { + throw createError({ statusCode: 404, message: 'Torrent not found' }); + } + + auditDetail(event, { + action: 'torrent.supersede.clear', + targetType: 'torrent', + targetId: source.id, + targetLabel: source.name, + changes: { supersededBy: { from: source.supersededById, to: null } }, + }); + + await db + .update(schema.torrents) + .set({ supersededById: null, supersededAt: null, supersedeReason: null }) + .where(eq(schema.torrents.id, source.id)); + + return { success: true, supersededBy: null }; +}); diff --git a/apps/api/routes/api/mod/torrents/[hash]/supersede.put.ts b/apps/api/routes/api/mod/torrents/[hash]/supersede.put.ts new file mode 100644 index 00000000..9b856a59 --- /dev/null +++ b/apps/api/routes/api/mod/torrents/[hash]/supersede.put.ts @@ -0,0 +1,192 @@ +/** + * PUT /api/mod/torrents/:hash/supersede { supersededById, reason? } + * + * Mark this release as replaced by a better one — "trumping", in the vocabulary + * of the trackers that have had it for twenty years. + * + * ## What it does not do + * + * It does not retire the older release. It stays listed, stays downloadable, + * keeps its swarm, and its snatchers keep their hit-and-run obligations. People + * are seeding it; pulling it out from under them would turn a tidy-up into a + * hit-and-run of the operator's own making. + * + * What changes is that both pages say so. A member choosing between two copies + * of the same work is told which one the staff consider current, and the older + * page points at the newer. That is the whole feature — a catalogue with no way + * to express "this replaces that" ages by accumulating duplicates of uneven + * quality with no hierarchy between them. + * + * ## The guards, and why each one exists + * + * - **Not itself.** A row superseded by itself would render a page pointing + * at itself and, worse, would make the chain walk below never terminate. + * - **No cycle.** A supersedes B supersedes A is the same non-termination + * reached the long way round. Walked forward from the target, bounded. + * - **The target must be `accepted`.** Pointing members at something pending + * or rejected sends them to a page they may not be allowed to read. + * - **The target must not itself be superseded.** Otherwise the pointer sends + * a member to a dead end, and they have to walk the chain by hand. Staff are + * told to point at the head of the chain instead. + * + * Lifting the marking again is the sibling `supersede.delete.ts`. + * + * Both verbs land in the staff audit log — they are mutating calls under + * `/api/mod/`, so the hook records them whether or not this route says anything. + */ +import { and, eq, isNull, sql } from 'drizzle-orm'; +import { z } from 'zod/v4'; +import { db, schema } from '@trackarr/db'; +import { requireModeratorSession } from '~~/utils/adminAuth'; +import { auditDetail } from '~~/utils/audit'; +import { rateLimit, RATE_LIMITS } from '~~/utils/rateLimit'; +import { validateBody } from '~~/utils/schemas'; + +const bodySchema = z.object({ + /** The replacement's infohash — what a moderator has in front of them. */ + supersededById: z.string().regex(/^[a-fA-F0-9]{40}$/), + reason: z.string().trim().max(500).optional(), +}); + +/** How far a supersede chain may be walked before we call it a cycle. */ +const MAX_CHAIN = 32; + +export default defineEventHandler(async (event) => { + await requireModeratorSession(event); + await rateLimit(event, RATE_LIMITS.mutation); + + const hash = getRouterParam(event, 'hash'); + if (!hash) { + throw createError({ statusCode: 400, message: 'Torrent hash is required' }); + } + const infoHash = hash.toLowerCase(); + + const source = await db.query.torrents.findFirst({ + where: eq(schema.torrents.infoHash, infoHash), + columns: { id: true, name: true, supersededById: true }, + }); + if (!source) { + throw createError({ statusCode: 404, message: 'Torrent not found' }); + } + + const body = await validateBody(event, bodySchema); + const targetHash = body.supersededById.toLowerCase(); + + if (targetHash === infoHash) { + throw createError({ + statusCode: 400, + message: 'A release cannot supersede itself.', + }); + } + + const target = await db.query.torrents.findFirst({ + where: eq(schema.torrents.infoHash, targetHash), + columns: { + id: true, + name: true, + moderationStatus: true, + isActive: true, + supersededById: true, + }, + }); + if (!target) { + throw createError({ + statusCode: 404, + message: 'The replacement torrent was not found.', + }); + } + if (target.moderationStatus !== 'accepted' || !target.isActive) { + throw createError({ + statusCode: 400, + message: + 'The replacement must be an accepted, active release — otherwise the pointer sends members to a page they may not be able to read.', + }); + } + if (target.supersededById) { + throw createError({ + statusCode: 400, + message: + 'That release is itself superseded. Point at the head of the chain instead.', + }); + } + + // Walk forward from the target: if the chain reaches back to the source, the + // pointer would close a loop. Bounded by MAX_CHAIN so a pre-existing cycle in + // the data — which this route refuses to create, but a restore or a manual + // edit could — cannot hang the request. + let cursor: string | null = target.id; + for (let i = 0; cursor && i < MAX_CHAIN; i++) { + if (cursor === source.id) { + throw createError({ + statusCode: 400, + message: 'That would create a supersede loop.', + }); + } + const next: { supersededById: string | null } | undefined = + await db.query.torrents.findFirst({ + where: eq(schema.torrents.id, cursor), + columns: { supersededById: true }, + }); + cursor = next?.supersededById ?? null; + } + + auditDetail(event, { + action: 'torrent.supersede', + targetType: 'torrent', + targetId: source.id, + targetLabel: source.name, + changes: { + supersededBy: { from: source.supersededById, to: target.id }, + supersededByName: target.name, + ...(body.reason ? { reason: body.reason } : {}), + }, + }); + + const written = await db + .update(schema.torrents) + .set({ + supersededById: target.id, + supersededAt: new Date(), + supersedeReason: body.reason ?? null, + }) + // Guarded on the value we read, so two moderators racing on the same row + // cannot interleave into a state neither of them chose. `isNull` rather + // than `eq(col, null)`: the latter compiles to `= NULL`, which is never + // true, and the guard would silently match nothing. + .where( + and( + eq(schema.torrents.id, source.id), + source.supersededById === null + ? isNull(schema.torrents.supersededById) + : eq(schema.torrents.supersededById, source.supersededById), + /** + * And the TARGET must still be a chain head at write time. + * + * The cycle walk above runs as its own statements, so two moderators + * pointing A at B and B at A concurrently each read the other row as + * unsuperseded, each walk terminates cleanly, and both writes commit. + * Nothing hangs — every reader is bounded — but both releases then + * refuse reseed requests for ever and each page sends members to the + * other. Checking the target inside the predicate closes the window + * without a transaction. + */ + sql`(select superseded_by_id from torrents where id = ${target.id}) is null` + ) + ) + .returning({ id: schema.torrents.id }); + + if (written.length === 0) { + // Either another moderator changed this row, or the target stopped being a + // chain head. Both mean "read it again": the state the caller decided from + // is gone. + throw createError({ + statusCode: 409, + message: 'Somebody changed one of these two releases. Reload and try again.', + }); + } + + return { + success: true, + supersededBy: { infoHash: targetHash, name: target.name }, + }; +}); diff --git a/apps/api/routes/api/mod/users/[id]/logins.get.ts b/apps/api/routes/api/mod/users/[id]/logins.get.ts new file mode 100644 index 00000000..d32c8c00 --- /dev/null +++ b/apps/api/routes/api/mod/users/[id]/logins.get.ts @@ -0,0 +1,138 @@ +/** + * GET /api/mod/users/:id/logins — the same history, for moderation. + * + * The question this exists for is account sharing, which on an invite-only + * tracker is the offence that matters most and the one there was no evidence + * for. Several successful logins from different address hashes on the same day + * is what it looks like. + * + * Same daily-salt limit as the member-facing view, and the same consequence: + * the comparison is meaningful inside a day and meaningless across weeks. A + * moderator drawing a conclusion from two hashes a month apart would be + * drawing it from noise, so the page says so and the count below is scoped to + * one day rather than to the whole page. + * + * Moderator, not admin: this is triage, and it sits beside the anti-cheat queue + * those same people already work. + */ +import { and, desc, eq, sql } from 'drizzle-orm'; +import { z } from 'zod/v4'; +import { db, schema } from '@trackarr/db'; +import { requireModeratorSession } from '~~/utils/adminAuth'; +import { rateLimit, RATE_LIMITS } from '~~/utils/rateLimit'; +import { uuidSchema, validateParam, validateQuery } from '~~/utils/schemas'; + +const querySchema = z.object({ + limit: z.coerce.number().int().min(1).max(200).default(50), +}); + +/** + * Replace the stored address hash with a label that only means something inside + * this response. + * + * The hash is `sha256(secret:day:ip)` truncated, and it is the SAME value in the + * member's own view and in the moderator's view of that member — so a moderator + * who suspects an account is being shared with somebody they can reach could + * sign in from that address, read their own hash, and compare. The hash was + * meant to make an address unrecoverable; handing the same value to two readers + * made it a confirmation oracle for a day. + * + * An ordinal keeps the only property either view claims — telling two addresses + * apart within one day — and gives up the only property neither needs, which is + * comparability with anybody else's copy. + */ +function labelAddresses( + rows: T[] +): Array & { address: string | null }> { + const seen = new Map(); + return rows.map((row) => { + const { ipHash, ...rest } = row; + if (!ipHash) return { ...rest, address: null } as Omit & { address: null }; + // Keyed on hash AND day, because the salt rotates daily: the same address + // is a different hash tomorrow, and pretending otherwise would invent a + // continuity the data does not have. + // + // Numbered WITHIN the day, not across the response. A single counter over + // the whole page meant one home address on five different days rendered as + // `#1 #2 #3 #4 #5` — so the ordinary case, one place over many days, looked + // exactly like five different places, on the one screen whose entire job is + // spotting somebody else signing in as you. Restarting each day also makes + // the "only comparable within one day" caveat something the numbers say for + // themselves rather than something a footnote has to teach. + const day = row.createdAt.toISOString().slice(0, 10); + const key = `${ipHash}:${day}`; + let ordinal = seen.get(key); + if (ordinal === undefined) { + let next = 1; + for (const k of seen.keys()) if (k.endsWith(`:${day}`)) next += 1; + ordinal = next; + seen.set(key, ordinal); + } + return { ...rest, address: `#${ordinal}` } as Omit & { address: string }; + }); +} + +export default defineEventHandler(async (event) => { + await requireModeratorSession(event); + await rateLimit(event, RATE_LIMITS.public); + + const userId = validateParam(event, 'id', uuidSchema); + const { limit } = validateQuery(event, querySchema); + + const items = await db + .select({ + id: schema.loginEvents.id, + method: schema.loginEvents.method, + outcome: schema.loginEvents.outcome, + ipHash: schema.loginEvents.ipHash, + userAgent: schema.loginEvents.userAgent, + createdAt: schema.loginEvents.createdAt, + }) + .from(schema.loginEvents) + .where(eq(schema.loginEvents.userId, userId)) + .orderBy(desc(schema.loginEvents.createdAt)) + .limit(limit); + + /** + * Distinct address hashes among the SUCCESSFUL sign-ins of the most recent day. + * + * Two corrections, and each of them was a way to make a moderator wrong about + * account sharing — the one offence this figure is used to judge. + * + * **Successes only.** A failed attempt records the target's id and the + * CALLER's address, and it needs no password: an unauthenticated stranger + * could send twenty bad logins against one account from twenty addresses and + * the moderator would read "20 different addresses". The file's own docstring + * says "several successful logins", and now the query does too. + * + * **Counted in SQL, not on the page.** Taken from the returned rows, the + * figure was bounded by the page size: a member signing in fifty times from + * one address pushed the others off the first thirty rows and the count read + * `1`. So the subject could suppress it at will, and a moderator changing the + * page size changed the number. + */ + const [today] = await db + .select({ + day: sql`to_char(${schema.loginEvents.createdAt}, 'YYYY-MM-DD')`, + addresses: sql`count(distinct ${schema.loginEvents.ipHash})::int`, + logins: sql`count(*)::int`, + }) + .from(schema.loginEvents) + .where( + and( + eq(schema.loginEvents.userId, userId), + eq(schema.loginEvents.outcome, 'success') + ) + ) + .groupBy(sql`to_char(${schema.loginEvents.createdAt}, 'YYYY-MM-DD')`) + .orderBy(desc(sql`to_char(${schema.loginEvents.createdAt}, 'YYYY-MM-DD')`)) + .limit(1); + + return { + items: labelAddresses(items), + distinctAddressesToday: today?.addresses ?? 0, + /** Which day that count is about, since it is the newest day WITH a success. */ + distinctAddressesDay: today?.day ?? null, + successfulLoginsThatDay: today?.logins ?? 0, + }; +}); diff --git a/apps/api/routes/api/privacy.get.ts b/apps/api/routes/api/privacy.get.ts index 282556e4..90672907 100644 --- a/apps/api/routes/api/privacy.get.ts +++ b/apps/api/routes/api/privacy.get.ts @@ -15,6 +15,8 @@ */ import { rateLimit, RATE_LIMITS } from '~~/utils/rateLimit'; import { + getAuditRetentionDays, + getLoginEventRetentionDays, getDmRetentionDays, getMessagingDmScope, getMessagingRoomScope, @@ -33,6 +35,8 @@ export default defineEventHandler(async (event) => { roomMessageDays, notificationsReadDays, notificationsUnreadDays, + auditDays, + loginDays, ] = await Promise.all([ getMessagingDmScope(), getMessagingRoomScope(), @@ -40,6 +44,8 @@ export default defineEventHandler(async (event) => { getRoomRetentionDays(), getNotificationsRetentionReadDays(), getNotificationsRetentionUnreadDays(), + getAuditRetentionDays(), + getLoginEventRetentionDays(), ]); return { @@ -51,5 +57,21 @@ export default defineEventHandler(async (event) => { roomMessageDays, }, notifications: { notificationsReadDays, notificationsUnreadDays }, + /** + * The staff audit log. Published for the same reason every other period + * here is: a retention nobody can read is a retention nobody was told + * about. What it records is staff actions, not member browsing — but a + * member who was banned, warned or had their upload rejected IS the target + * of one of those rows, so the period is theirs to know. + * + * `0` means kept indefinitely. + */ + staffAudit: { retentionDays: auditDays }, + /** + * The login history. A member's own record of where their account has been + * used from — and unlike the staff register above, it is about them rather + * than about the site, which is why it is kept for months and not a year. + */ + loginHistory: { retentionDays: loginDays }, }; }); diff --git a/apps/api/routes/api/requests/[id].delete.ts b/apps/api/routes/api/requests/[id].delete.ts index 1e7c00d9..5337a14d 100644 --- a/apps/api/routes/api/requests/[id].delete.ts +++ b/apps/api/routes/api/requests/[id].delete.ts @@ -16,13 +16,14 @@ import { z } from 'zod'; import { db, schema } from '@trackarr/db'; import { rateLimit, RATE_LIMITS } from '~~/utils/rateLimit'; import { refundReward } from '~~/utils/requestPoints'; +import { validateRouterParams } from '~~/utils/schemas'; const paramsSchema = z.object({ id: z.string().uuid() }); export default defineEventHandler(async (event) => { const { user } = await requireUserSession(event); await rateLimit(event, RATE_LIMITS.mutation); - const { id } = paramsSchema.parse(getRouterParams(event)); + const { id } = validateRouterParams(event, paramsSchema); const row = await db.query.uploadRequests.findFirst({ where: eq(schema.uploadRequests.id, id), diff --git a/apps/api/routes/api/requests/[id].get.ts b/apps/api/routes/api/requests/[id].get.ts index cf8a0200..63730136 100644 --- a/apps/api/routes/api/requests/[id].get.ts +++ b/apps/api/routes/api/requests/[id].get.ts @@ -13,12 +13,13 @@ import { db, schema } from '@trackarr/db'; import { and, asc, eq, sql } from 'drizzle-orm'; import { z } from 'zod'; import { getRequestMaxFillsPerUser } from '~~/utils/settings'; +import { validateRouterParams } from '~~/utils/schemas'; const paramsSchema = z.object({ id: z.string().uuid() }); export default defineEventHandler(async (event) => { const { user } = await requireUserSession(event); - const { id } = paramsSchema.parse(getRouterParams(event)); + const { id } = validateRouterParams(event, paramsSchema); const row = await db.query.uploadRequests.findFirst({ where: eq(schema.uploadRequests.id, id), diff --git a/apps/api/routes/api/requests/[id].patch.ts b/apps/api/routes/api/requests/[id].patch.ts index 332b4349..f2de8f70 100644 --- a/apps/api/routes/api/requests/[id].patch.ts +++ b/apps/api/routes/api/requests/[id].patch.ts @@ -13,6 +13,7 @@ import { z } from 'zod'; import { db, schema } from '@trackarr/db'; import { rateLimit, RATE_LIMITS } from '~~/utils/rateLimit'; import { holdReward, RewardError } from '~~/utils/requestPoints'; +import { validateRouterParams } from '~~/utils/schemas'; const paramsSchema = z.object({ id: z.string().uuid() }); const bodySchema = z @@ -34,7 +35,7 @@ const bodySchema = z export default defineEventHandler(async (event) => { const { user } = await requireUserSession(event); await rateLimit(event, RATE_LIMITS.mutation); - const { id } = paramsSchema.parse(getRouterParams(event)); + const { id } = validateRouterParams(event, paramsSchema); const body = await readValidatedBody(event, bodySchema.parse); const row = await db.query.uploadRequests.findFirst({ diff --git a/apps/api/routes/api/requests/[id]/comments.post.ts b/apps/api/routes/api/requests/[id]/comments.post.ts index a53382a4..0d36544d 100644 --- a/apps/api/routes/api/requests/[id]/comments.post.ts +++ b/apps/api/routes/api/requests/[id]/comments.post.ts @@ -13,6 +13,7 @@ import { z } from 'zod'; import { db, schema } from '@trackarr/db'; import { rateLimit, RATE_LIMITS } from '~~/utils/rateLimit'; import { notify } from '~~/utils/notify'; +import { validateRouterParams } from '~~/utils/schemas'; const paramsSchema = z.object({ id: z.string().uuid() }); const bodySchema = z.object({ @@ -22,7 +23,7 @@ const bodySchema = z.object({ export default defineEventHandler(async (event) => { const { user } = await requireUserSession(event); await rateLimit(event, RATE_LIMITS.mutation); - const { id } = paramsSchema.parse(getRouterParams(event)); + const { id } = validateRouterParams(event, paramsSchema); const body = await readValidatedBody(event, bodySchema.parse); const request = await db.query.uploadRequests.findFirst({ diff --git a/apps/api/routes/api/requests/[id]/comments/[cid].delete.ts b/apps/api/routes/api/requests/[id]/comments/[cid].delete.ts index d8abbd72..5e29b752 100644 --- a/apps/api/routes/api/requests/[id]/comments/[cid].delete.ts +++ b/apps/api/routes/api/requests/[id]/comments/[cid].delete.ts @@ -11,6 +11,7 @@ import { and, eq } from 'drizzle-orm'; import { z } from 'zod'; import { db, schema } from '@trackarr/db'; import { rateLimit, RATE_LIMITS } from '~~/utils/rateLimit'; +import { validateRouterParams } from '~~/utils/schemas'; const EDIT_WINDOW_MS = 15 * 60 * 1000; @@ -22,7 +23,7 @@ const paramsSchema = z.object({ export default defineEventHandler(async (event) => { const { user } = await requireUserSession(event); await rateLimit(event, RATE_LIMITS.mutation); - const { id, cid } = paramsSchema.parse(getRouterParams(event)); + const { id, cid } = validateRouterParams(event, paramsSchema); const comment = await db.query.uploadRequestComments.findFirst({ where: and( diff --git a/apps/api/routes/api/requests/[id]/comments/[cid].patch.ts b/apps/api/routes/api/requests/[id]/comments/[cid].patch.ts index 2aa9e89f..d52a82cb 100644 --- a/apps/api/routes/api/requests/[id]/comments/[cid].patch.ts +++ b/apps/api/routes/api/requests/[id]/comments/[cid].patch.ts @@ -9,6 +9,7 @@ import { and, eq } from 'drizzle-orm'; import { z } from 'zod'; import { db, schema } from '@trackarr/db'; import { rateLimit, RATE_LIMITS } from '~~/utils/rateLimit'; +import { validateRouterParams } from '~~/utils/schemas'; const EDIT_WINDOW_MS = 15 * 60 * 1000; @@ -23,7 +24,7 @@ const bodySchema = z.object({ export default defineEventHandler(async (event) => { const { user } = await requireUserSession(event); await rateLimit(event, RATE_LIMITS.mutation); - const { id, cid } = paramsSchema.parse(getRouterParams(event)); + const { id, cid } = validateRouterParams(event, paramsSchema); const body = await readValidatedBody(event, bodySchema.parse); const comment = await db.query.uploadRequestComments.findFirst({ diff --git a/apps/api/routes/api/requests/[id]/fill.post.ts b/apps/api/routes/api/requests/[id]/fill.post.ts index 7d9315ff..ec97d243 100644 --- a/apps/api/routes/api/requests/[id]/fill.post.ts +++ b/apps/api/routes/api/requests/[id]/fill.post.ts @@ -22,6 +22,7 @@ import { db, schema } from '@trackarr/db'; import { rateLimit, RATE_LIMITS } from '~~/utils/rateLimit'; import { notify } from '~~/utils/notify'; import { getRequestMaxFillsPerUser } from '~~/utils/settings'; +import { validateRouterParams } from '~~/utils/schemas'; const paramsSchema = z.object({ id: z.string().uuid() }); const bodySchema = z.object({ @@ -37,7 +38,7 @@ const bodySchema = z.object({ export default defineEventHandler(async (event) => { const { user } = await requireUserSession(event); await rateLimit(event, RATE_LIMITS.mutation); - const { id } = paramsSchema.parse(getRouterParams(event)); + const { id } = validateRouterParams(event, paramsSchema); const body = await readValidatedBody(event, bodySchema.parse); const request = await db.query.uploadRequests.findFirst({ diff --git a/apps/api/routes/api/requests/[id]/reject.post.ts b/apps/api/routes/api/requests/[id]/reject.post.ts index 488dcac5..cf24af64 100644 --- a/apps/api/routes/api/requests/[id]/reject.post.ts +++ b/apps/api/routes/api/requests/[id]/reject.post.ts @@ -15,6 +15,7 @@ import { z } from 'zod'; import { db, schema } from '@trackarr/db'; import { rateLimit, RATE_LIMITS } from '~~/utils/rateLimit'; import { notify } from '~~/utils/notify'; +import { validateRouterParams } from '~~/utils/schemas'; const paramsSchema = z.object({ id: z.string().uuid() }); const bodySchema = z.object({ @@ -24,7 +25,7 @@ const bodySchema = z.object({ export default defineEventHandler(async (event) => { const { user } = await requireUserSession(event); await rateLimit(event, RATE_LIMITS.mutation); - const { id } = paramsSchema.parse(getRouterParams(event)); + const { id } = validateRouterParams(event, paramsSchema); // Body is optional — the FE button fires reject without one. // Read raw, default to {}, then validate against the schema. const rawBody = (await readBody(event).catch(() => null)) ?? {}; diff --git a/apps/api/routes/api/requests/[id]/validate.post.ts b/apps/api/routes/api/requests/[id]/validate.post.ts index 5960ea28..3cda1080 100644 --- a/apps/api/routes/api/requests/[id]/validate.post.ts +++ b/apps/api/routes/api/requests/[id]/validate.post.ts @@ -16,13 +16,14 @@ import { db, schema } from '@trackarr/db'; import { rateLimit, RATE_LIMITS } from '~~/utils/rateLimit'; import { notify } from '~~/utils/notify'; import { payReward } from '~~/utils/requestPoints'; +import { validateRouterParams } from '~~/utils/schemas'; const paramsSchema = z.object({ id: z.string().uuid() }); export default defineEventHandler(async (event) => { const { user } = await requireUserSession(event); await rateLimit(event, RATE_LIMITS.mutation); - const { id } = paramsSchema.parse(getRouterParams(event)); + const { id } = validateRouterParams(event, paramsSchema); const request = await db.query.uploadRequests.findFirst({ where: eq(schema.uploadRequests.id, id), diff --git a/apps/api/routes/api/requests/index.get.ts b/apps/api/routes/api/requests/index.get.ts index c6d6c5d9..a0f80b92 100644 --- a/apps/api/routes/api/requests/index.get.ts +++ b/apps/api/routes/api/requests/index.get.ts @@ -16,6 +16,7 @@ import { db, schema } from '@trackarr/db'; import { and, desc, eq, ilike, sql, type SQL } from 'drizzle-orm'; import { z } from 'zod'; import { escapeLike } from '~~/utils/sql'; +import { validateQuery } from '~~/utils/schemas'; const querySchema = z.object({ status: z @@ -30,7 +31,7 @@ const querySchema = z.object({ export default defineEventHandler(async (event) => { const { user } = await requireUserSession(event); - const query = querySchema.parse(getQuery(event)); + const query = validateQuery(event, querySchema); const offset = (query.page - 1) * query.limit; const conditions: SQL[] = []; diff --git a/apps/api/routes/api/rss/category/[slug].get.ts b/apps/api/routes/api/rss/category/[slug].get.ts index fefaf1db..27011155 100644 --- a/apps/api/routes/api/rss/category/[slug].get.ts +++ b/apps/api/routes/api/rss/category/[slug].get.ts @@ -2,10 +2,11 @@ import { db, schema } from '@trackarr/db'; import { getStats } from '~~/utils/server'; import { desc, eq, and } from 'drizzle-orm'; import { z } from 'zod'; -import { requireSessionOrApikey } from '~~/utils/adminAuth'; +import { requireReadAccess } from '~~/utils/account/readKeyAuth'; import { getTorznabIncludeFederated } from '~~/utils/torznabSettings'; import { getFederationConfig, isFederationLive } from '~~/utils/federation/config'; import { federatedFeedRows } from '~~/utils/federation/feedRows'; +import { validateQuery, validateRouterParams } from '~~/utils/schemas'; const paramsSchema = z.object({ slug: z.string().min(1), @@ -21,9 +22,9 @@ const querySchema = z.object({ * tracker, members-only, pending uploads filtered out. */ export default defineEventHandler(async (event) => { - const { user } = await requireSessionOrApikey(event); - const params = paramsSchema.parse(getRouterParams(event)); - const query = querySchema.parse(getQuery(event)); + const { user } = await requireReadAccess(event, 'rss'); + const params = validateRouterParams(event, paramsSchema); + const query = validateQuery(event, querySchema); const category = await db.query.categories.findFirst({ where: eq(schema.categories.slug, params.slug), diff --git a/apps/api/routes/api/rss/latest.get.ts b/apps/api/routes/api/rss/latest.get.ts index f9112702..03f5d800 100644 --- a/apps/api/routes/api/rss/latest.get.ts +++ b/apps/api/routes/api/rss/latest.get.ts @@ -2,8 +2,9 @@ import { db, schema } from '@trackarr/db'; import { getStats } from '~~/utils/server'; import { desc, eq, and, or, isNull, notInArray } from 'drizzle-orm'; import { z } from 'zod'; -import { requireSessionOrApikey } from '~~/utils/adminAuth'; +import { requireReadAccess } from '~~/utils/account/readKeyAuth'; import { adultCategoryIds } from '~~/utils/adultContent'; +import { validateQuery } from '~~/utils/schemas'; const querySchema = z.object({ limit: z.coerce.number().min(1).max(100).default(50), @@ -18,12 +19,13 @@ const querySchema = z.object({ * already turned down. */ export default defineEventHandler(async (event) => { - const { user } = await requireSessionOrApikey(event); - const query = querySchema.parse(getQuery(event)); + const { user } = await requireReadAccess(event, 'rss'); + const query = validateQuery(event, querySchema); // Build the where so feeds for users who haven't opted into XXX - // skip those entries entirely. requireSessionOrApikey already - // surfaces showAdultContent on the user record. + // skip those entries entirely. `requireReadAccess` reads + // showAdultContent from the row rather than the session cookie, so a + // member who changed the setting today does not get yesterday's feed. const conditions = [ eq(schema.torrents.isActive, true), eq(schema.torrents.moderationStatus, 'accepted'), diff --git a/apps/api/routes/api/stats/site.get.ts b/apps/api/routes/api/stats/site.get.ts new file mode 100644 index 00000000..4899f4f4 --- /dev/null +++ b/apps/api/routes/api/stats/site.get.ts @@ -0,0 +1,124 @@ +/** + * GET /api/stats/site?window=30|90|365 + * + * The state of the site, for the people who are on it. + * + * `/api/stats/public` already answers the homepage's four counters to anybody + * who loads the page. This is the rest of it — history, the shape of the + * catalogue, what is being seeded and by whom — and it is behind a session + * because half of it names releases and members. A private tracker publishing + * its catalogue to whoever asks would be a strange thing to build carefully in + * the feed and then hand out here. + * + * Cached per (window, adult visibility) for a minute. The aggregates are a + * handful of indexed group-bys, but this is a page members will refresh, and a + * minute of staleness on a chart of the last 90 days is not a number anyone can + * perceive. + */ +import { z } from 'zod/v4'; +import { eq } from 'drizzle-orm'; +import { db, schema } from '@trackarr/db'; +import { requireAuthSession } from '~~/utils/adminAuth'; +import { rateLimit, RATE_LIMITS } from '~~/utils/rateLimit'; +import { validateQuery } from '~~/utils/schemas'; +import { + categoryBreakdown, + dailyDeltas, + dailyPoints, + firstSnapshotAt, + hiddenCategoryIds, + selectableYears, + siteNow, + snapshots, + topTorrents, + topUploaders, +} from '~~/utils/publicStats'; + +const querySchema = z.object({ + // A closed set rather than a number: the window is the cache key and the + // series length, and "365" is already a thousand rows before bucketing. + window: z.enum(['30', '90', '365']).default('90'), +}); + +/** + * The caller's adult preference, read from the row. + * + * NOT from the session: no login path writes `showAdultContent` into the sealed + * cookie, so `user.showAdultContent` was always `undefined` — always + * fail-closed, which meant a member who HAD opted in never saw their own + * categories here, and the `all` half of the cache key was dead code. Every + * neighbouring route reads the row for the same reason, and deliberately does + * not put the flag in the cookie: a seven-day session would keep serving an + * adult view for a week after the member turned it off. + */ +async function callerShowsAdult(userId: string): Promise { + const me = await db.query.users.findFirst({ + where: eq(schema.users.id, userId), + columns: { showAdultContent: true }, + }); + return me?.showAdultContent ?? false; +} + +const CACHE_TTL_MS = 60_000; +const cache = new Map(); + +export default defineEventHandler(async (event) => { + const { user } = await requireAuthSession(event); + await rateLimit(event, RATE_LIMITS.public); + + const { window } = validateQuery(event, querySchema); + const days = Number(window); + const showAdult = await callerShowsAdult(user.id); + const key = `${days}:${showAdult ? 'all' : 'safe'}`; + + const hit = cache.get(key); + if (hit && Date.now() - hit.at < CACHE_TTL_MS) return hit.value; + + const adultIds = await hiddenCategoryIds(showAdult); + const since = new Date(Date.now() - days * 24 * 60 * 60 * 1000); + + const [now, rows, categories, mostSnatched, biggestSwarms, uploaders, firstAt] = + await Promise.all([ + siteNow(adultIds), + snapshots(since), + categoryBreakdown(adultIds), + topTorrents('snatches', adultIds, 10), + topTorrents('seeders', adultIds, 10), + topUploaders(adultIds, 10), + firstSnapshotAt(), + ]); + + const points = dailyPoints(rows); + const value = { + now, + days, + /** + * The series is what it is: if the collector has only been running a week, + * the chart shows a week rather than 83 empty days. Padding it would draw a + * flat line at zero and call it history. + * + * These points come from `site_stats`, which the operator's collector writes + * as WHOLE-CATALOGUE counters — every status, adult included, and every + * account. So they can legitimately sit above the filtered figures in `now`, + * and the page labels them as the catalogue total rather than implying they + * are the same number over time. + */ + points, + deltas: dailyDeltas(points), + categories, + mostSnatched, + biggestSwarms, + topUploaders: uploaders, + years: selectableYears(firstAt, new Date()), + }; + + cache.set(key, { at: Date.now(), value }); + // Two windows times two visibilities is four entries; the bound is here so a + // future third dimension cannot turn this into a leak. + if (cache.size > 12) { + for (const [k, v] of cache) { + if (Date.now() - v.at > CACHE_TTL_MS) cache.delete(k); + } + } + return value; +}); diff --git a/apps/api/routes/api/stats/year.get.ts b/apps/api/routes/api/stats/year.get.ts new file mode 100644 index 00000000..c26d812d --- /dev/null +++ b/apps/api/routes/api/stats/year.get.ts @@ -0,0 +1,110 @@ +/** + * GET /api/stats/year?year=YYYY + * + * The site's year: what was added, what moved, who joined, and what everybody + * was grabbing. + * + * A tracker's year in review is a retention feature rather than an analytics + * one — it is the page members link to each other in January — so it is written + * to be readable rather than complete. Every figure on it can be traced to one + * of four tables, and the ones that cannot be computed honestly are absent + * rather than approximated. + * + * ## Two figures that are not the same, and are both here + * + * `bytesAdded` is the size of the releases catalogued during the year. It is a + * property of the catalogue, and it is exact. + * + * `trafficBytes` is how much was actually transferred, taken as the difference + * between the first and last `site_stats` snapshot inside the year. It is + * approximate BY CONSTRUCTION: the counter behind it drops when an account is + * erased or a cheater's stats are reset, so it is a floor rather than a total, + * and it is null for a year the collector has no snapshots for. Presenting it + * as exact would be the lie; the guide says so and so does the page. + * + * Past years never change, so they are cached for a day. The current one is + * cached for a minute, like the rest of the stats. + */ +import { z } from 'zod/v4'; +import { eq } from 'drizzle-orm'; +import { db, schema } from '@trackarr/db'; +import { requireAuthSession } from '~~/utils/adminAuth'; +import { rateLimit, RATE_LIMITS } from '~~/utils/rateLimit'; +import { validateQuery } from '~~/utils/schemas'; +import { + firstSnapshotAt, + hiddenCategoryIds, + selectableYears, + siteYear, +} from '~~/utils/publicStats'; + +const querySchema = z.object({ + // 2000 is not a guess: BitTorrent was published in 2001, so a tracker with + // data before that is a clock problem rather than a year. + year: z.coerce.number().int().min(2000).max(2100), +}); + +/** + * The caller's adult preference, read from the row. + * + * NOT from the session: no login path writes `showAdultContent` into the sealed + * cookie, so `user.showAdultContent` was always `undefined` — always + * fail-closed, which meant a member who HAD opted in never saw their own + * categories here, and the `all` half of the cache key was dead code. Every + * neighbouring route reads the row for the same reason, and deliberately does + * not put the flag in the cookie: a seven-day session would keep serving an + * adult view for a week after the member turned it off. + */ +async function callerShowsAdult(userId: string): Promise { + const me = await db.query.users.findFirst({ + where: eq(schema.users.id, userId), + columns: { showAdultContent: true }, + }); + return me?.showAdultContent ?? false; +} + +const FRESH_TTL_MS = 60_000; +const SETTLED_TTL_MS = 24 * 60 * 60 * 1000; +const cache = new Map(); + +export default defineEventHandler(async (event) => { + const { user } = await requireAuthSession(event); + await rateLimit(event, RATE_LIMITS.public); + + const { year } = validateQuery(event, querySchema); + + /** + * Only a year this instance can answer for. + * + * The schema's [2000, 2100] range was 101 cache keys against a 40-entry cache, + * so a caller walking the range missed every time — and each miss is four + * range scans over `torrents` plus one over `hnr_tracking`. An empty year + * costs exactly as much as a full one, because the planner has to look to find + * nothing. The window parameter on the sibling route learned this already: + * the parameter IS the cache key, so it has to be small. + */ + const offered = selectableYears(await firstSnapshotAt(), new Date()); + if (!offered.includes(year)) { + throw createError({ + statusCode: 400, + message: `This instance has no data for ${year}.`, + }); + } + + const showAdult = await callerShowsAdult(user.id); + const key = `${year}:${showAdult ? 'all' : 'safe'}`; + + const hit = cache.get(key); + if (hit && Date.now() - hit.at < hit.ttl) return hit.value; + + const value = await siteYear(year, await hiddenCategoryIds(showAdult)); + const ttl = year < new Date().getUTCFullYear() ? SETTLED_TTL_MS : FRESH_TTL_MS; + cache.set(key, { at: Date.now(), ttl, value }); + // Bounded: one entry per year per visibility, and a long-lived instance would + // otherwise accumulate one a year forever. + if (cache.size > 40) { + const oldest = [...cache.entries()].sort((a, b) => a[1].at - b[1].at)[0]; + if (oldest) cache.delete(oldest[0]); + } + return value; +}); diff --git a/apps/api/routes/api/tags/index.get.ts b/apps/api/routes/api/tags/index.get.ts index 2c613294..9d1b624b 100644 --- a/apps/api/routes/api/tags/index.get.ts +++ b/apps/api/routes/api/tags/index.get.ts @@ -1,6 +1,7 @@ import { db } from '@trackarr/db'; import { escapeLike } from '~~/utils/sql'; import { z } from 'zod'; +import { validateQuery } from '~~/utils/schemas'; const querySchema = z.object({ q: z.string().trim().max(50).optional(), @@ -13,7 +14,7 @@ export default defineEventHandler(async (event) => { // we still serve the full sorted list so the admin UI keeps working. await requireUserSession(event); - const { q, limit } = querySchema.parse(getQuery(event)); + const { q, limit } = validateQuery(event, querySchema); const pattern = q ? `%${escapeLike(q)}%` : null; const tags = await db.query.tags.findMany({ diff --git a/apps/api/routes/api/torrents/[hash].delete.ts b/apps/api/routes/api/torrents/[hash].delete.ts index d0bcab89..74d0ce5c 100644 --- a/apps/api/routes/api/torrents/[hash].delete.ts +++ b/apps/api/routes/api/torrents/[hash].delete.ts @@ -61,9 +61,37 @@ export default defineEventHandler(async (event) => { // torrent it describes, which is what makes that possible. // Delete from Redis cache + // + // `completed_once::*` s'ajoute aux deux : le tracker y pose une + // clé par (membre, torrent) avec un TTL de SIX MOIS, pour qu'un `completed` + // rejoué ne gonfle pas le compteur public. Une ligne supprimée laissait donc + // ces marques une demi-année, et un infohash réenvoyé après suppression — + // ce que la branche « déjà existant » du point d'envoi permet — héritait des + // complétions de l'ancienne ligne, donc d'un compteur qui refusait de + // repartir à zéro. + // + // `SCAN` plutôt que `KEYS` : le motif est étroit (un torrent, ses membres) + // mais `KEYS` bloque Redis le temps du parcours de l'espace ENTIER. try { await redis.del(`peers:${infoHash}`); await redis.del(`stats:${infoHash}`); + let cursor = '0'; + do { + const [next, keys] = await redis.scan( + cursor, + 'MATCH', + `${redis.options.keyPrefix ?? ''}completed_once:${existing.id}:*`, + 'COUNT', + 200 + ); + cursor = next; + if (keys.length > 0) { + // `scan` rend des clés PRÉFIXÉES, et `del` en rajoute un : on retire + // le préfixe avant de supprimer. + const prefix = redis.options.keyPrefix ?? ''; + await redis.del(...keys.map((k) => (prefix && k.startsWith(prefix) ? k.slice(prefix.length) : k))); + } + } while (cursor !== '0'); } catch { // Redis errors are non-fatal } diff --git a/apps/api/routes/api/torrents/[hash].get.ts b/apps/api/routes/api/torrents/[hash].get.ts index 2cddfe76..f58bbc27 100644 --- a/apps/api/routes/api/torrents/[hash].get.ts +++ b/apps/api/routes/api/torrents/[hash].get.ts @@ -54,7 +54,11 @@ export default defineEventHandler(async (event) => { // changes_requested torrent). Staff see everything. Anyone else // gets a flat 404 — same response as a non-existent hash so a // probe can't even confirm a moderation thread exists. - if (torrent.moderationStatus !== 'accepted') { + // `!torrent.isActive` autant que le statut de modération : `is_active` est + // l'interrupteur de retrait d'un opérateur, honoré par le tracker, RSS, + // Torznab et la fédération — et jusqu'ici pas par la fiche, donc la release + // « retirée » restait consultable à son adresse directe. + if (torrent.moderationStatus !== 'accepted' || !torrent.isActive) { const isOwner = torrent.uploaderId === session.id; const isStaff = !!(session.isAdmin || session.isModerator); if (!isOwner && !isStaff) { diff --git a/apps/api/routes/api/torrents/[hash]/comments.post.ts b/apps/api/routes/api/torrents/[hash]/comments.post.ts index 6bafcf13..48f8bd21 100644 --- a/apps/api/routes/api/torrents/[hash]/comments.post.ts +++ b/apps/api/routes/api/torrents/[hash]/comments.post.ts @@ -1,4 +1,5 @@ import { eq } from 'drizzle-orm'; +import { rateLimit, RATE_LIMITS } from '~~/utils/rateLimit'; import { db, schema } from '@trackarr/db'; import { torrents, torrentComments } from '@trackarr/db/schema'; import { canComment } from '~~/utils/commentPolicy'; @@ -13,6 +14,7 @@ import { notify } from '~~/utils/notify'; export default defineEventHandler(async (event) => { const session = await requireAuthSession(event); + await rateLimit(event, RATE_LIMITS.mutation); // Validate hash parameter const hash = validateParam(event, 'hash', infoHashSchema); diff --git a/apps/api/routes/api/torrents/[hash]/download.get.ts b/apps/api/routes/api/torrents/[hash]/download.get.ts index 32ae8899..585acd80 100644 --- a/apps/api/routes/api/torrents/[hash]/download.get.ts +++ b/apps/api/routes/api/torrents/[hash]/download.get.ts @@ -37,6 +37,7 @@ export default defineEventHandler(async (event) => { torrentData: schema.torrents.torrentData, moderationStatus: schema.torrents.moderationStatus, uploaderId: schema.torrents.uploaderId, + isActive: schema.torrents.isActive, }) .from(schema.torrents) .where(eq(schema.torrents.infoHash, infoHash)) @@ -53,8 +54,14 @@ export default defineEventHandler(async (event) => { const isStaff = !!(user.isAdmin || user.isModerator); const isOwner = torrent.uploaderId === user.id; + // `!torrent.isActive` compte autant que le statut de modération. + // + // `is_active` est l'interrupteur qu'un opérateur bascule pour retirer une + // release — le tracker Go refuse alors de l'annoncer, RSS et Torznab la + // taisent — mais le `.torrent` restait servi ici. Retirer une release et + // continuer à distribuer son fichier est le contraire d'un retrait. if ( - torrent.moderationStatus !== 'accepted' && + (torrent.moderationStatus !== 'accepted' || !torrent.isActive) && !isStaff && !isOwner ) { diff --git a/apps/api/routes/api/torrents/[hash]/index.patch.ts b/apps/api/routes/api/torrents/[hash]/index.patch.ts index 12ee00ff..93bff5ac 100644 --- a/apps/api/routes/api/torrents/[hash]/index.patch.ts +++ b/apps/api/routes/api/torrents/[hash]/index.patch.ts @@ -17,6 +17,8 @@ * they edit — they're trusted to publish without re-review. */ import { eq } from 'drizzle-orm'; +import { validateBody } from '~~/utils/schemas'; +import { z } from 'zod'; import { db } from '@trackarr/db'; import { torrents, categories, torrentModerationMessages } from '@trackarr/db/schema'; import { randomUUID } from 'node:crypto'; @@ -85,8 +87,34 @@ export default defineEventHandler(async (event) => { // bypass flag. The result drives the auto-revert on save below. const canBypass = isStaff || (await userCanBypassModeration(user)); - // Read body - const body = await readBody(event); + /* + * Un schéma, pas `readBody()` nu. + * + * C'était l'une des cinq routes mutantes sans validation : `categoryId` + * partait tel quel dans `eq(categories.id, categoryId)`, donc un objet ou un + * nombre produisait une 500 de Postgres au lieu d'une 400 — pas d'injection + * (Drizzle paramètre), mais un plantage là où il fallait un refus. Même forme + * pour `description`, dont un objet atterrissait dans une colonne `text`. + * + * Les bornes reprennent celles que la route appliquait déjà à la main plus + * bas, et celles du point d'envoi : c'est le même contrat, exprimé une fois + * en entrée plutôt que dispersé dans le corps du handler. + */ + const patchSchema = z + .object({ + name: z.string().min(1).max(256).optional(), + description: z.string().max(10_000).nullish(), + categoryId: z.string().uuid().nullish().or(z.literal('')), + nfo: z.string().max(256 * 1024).nullish(), + imdbId: z.string().max(32).nullish(), + tmdbId: z.string().max(32).nullish(), + tvdbId: z.string().max(32).nullish(), + igdbId: z.string().max(32).nullish(), + openlibraryId: z.string().max(64).nullish(), + }) + .strict(); + + const body = await validateBody(event, patchSchema); const { name, description, diff --git a/apps/api/routes/api/torrents/[hash]/moderation/messages.post.ts b/apps/api/routes/api/torrents/[hash]/moderation/messages.post.ts index e9b4ebb2..a4238bf1 100644 --- a/apps/api/routes/api/torrents/[hash]/moderation/messages.post.ts +++ b/apps/api/routes/api/torrents/[hash]/moderation/messages.post.ts @@ -6,6 +6,7 @@ * actions. Either side (uploader or staff) can post. */ import { db, schema } from '@trackarr/db'; +import { rateLimit, RATE_LIMITS } from '~~/utils/rateLimit'; import { eq } from 'drizzle-orm'; import { z } from 'zod/v4'; import { canAccessModerationThread, postMessage } from '~~/utils/torrentModeration'; @@ -18,6 +19,7 @@ const bodySchema = z.object({ export default defineEventHandler(async (event) => { const { user } = await requireUserSession(event); + await rateLimit(event, RATE_LIMITS.mutation); const hash = getRouterParam(event, 'hash'); if (!hash) { throw createError({ statusCode: 400, message: 'Torrent hash is required' }); diff --git a/apps/api/routes/api/torrents/[hash]/reseed-request.post.ts b/apps/api/routes/api/torrents/[hash]/reseed-request.post.ts new file mode 100644 index 00000000..3577084b --- /dev/null +++ b/apps/api/routes/api/torrents/[hash]/reseed-request.post.ts @@ -0,0 +1,141 @@ +/** + * POST /api/torrents/:hash/reseed-request + * + * Ask the people who once downloaded this release to put it back online. + * + * A torrent at zero seeders is, today, a silent dead end: the page shows a + * zero and nothing follows from it. Yet the site knows exactly who could fix + * it — `hnr_tracking` holds one row per (member, torrent) forever, written both + * by the tracker on first completion and by the API the moment somebody clicks + * download. Turning that into a notification is a handful of lines against a + * measurable effect on catalogue health, which is why this is worth having and + * a "dead torrents" report is not. + * + * ## Guards, in the order they matter + * + * - **Zero seeders, checked live.** A request against a healthy swarm is + * noise sent to strangers. The count comes from Redis, the same source the + * page the member is looking at used. + * - **One request per torrent per day, site-wide.** Not per member: the + * recipients are what needs protecting, and ten members each asking once is + * ten notifications for one problem. The lock is a Redis key with a TTL — + * no column, no sweep, and it expires by itself. + * - **A cap on recipients.** An ancient release with 20 000 snatchers would + * otherwise be a mass-mail button available to every member. + * + * ## Who is NOT notified + * + * The requester (they know), erased accounts (a tombstone has no inbox), and + * banned accounts. `hideDownloadHistory` members ARE notified: the preference + * governs who can enumerate their snatch list, and a notification about one + * torrent they downloaded does not enumerate anything — but it does tell them + * the site remembers, which is why the guide says so plainly. + */ +import { and, desc, eq, isNull, ne } from 'drizzle-orm'; +import { db, schema } from '@trackarr/db'; +import { requireAuthSession } from '~~/utils/adminAuth'; +import { rateLimit, RATE_LIMITS } from '~~/utils/rateLimit'; +import { getStats, redis } from '~~/utils/server'; +import { notify } from '~~/utils/notify'; +import { FANOUT_CONCURRENCY, withConcurrency } from '~~/utils/fanout'; + +/** One request per torrent per day. */ +const COOLDOWN_S = 24 * 60 * 60; +const cooldownKey = (torrentId: string) => `reseed:asked:${torrentId}`; + +/** + * How many past snatchers one request may reach. + * + * The people most likely to still hold the files are the most recent ones, so + * the cap takes the newest rows rather than an arbitrary slice. + */ +const MAX_RECIPIENTS = 200; + +export default defineEventHandler(async (event) => { + const { user } = await requireAuthSession(event); + await rateLimit(event, RATE_LIMITS.mutation); + + const hash = getRouterParam(event, 'hash'); + if (!hash) { + throw createError({ statusCode: 400, message: 'Torrent hash is required' }); + } + const infoHash = hash.toLowerCase(); + + const torrent = await db.query.torrents.findFirst({ + where: eq(schema.torrents.infoHash, infoHash), + columns: { + id: true, + name: true, + isActive: true, + moderationStatus: true, + supersededById: true, + }, + }); + if (!torrent || !torrent.isActive || torrent.moderationStatus !== 'accepted') { + throw createError({ statusCode: 404, message: 'Torrent not found' }); + } + + // A superseded release is meant to fade. Asking members to resurrect one + // works against the decision a moderator already took. + if (torrent.supersededById) { + throw createError({ + statusCode: 400, + message: + 'This release has been superseded. Ask for the replacement to be seeded instead.', + }); + } + + const stats = await getStats(infoHash); + if (stats.seeders > 0) { + throw createError({ + statusCode: 400, + message: 'This torrent still has seeders.', + }); + } + + // Claim the day's slot before doing any work. `SET NX` is atomic, so two + // members pressing the button at the same instant produce one notification + // round, not two. + const claimed = await redis.set(cooldownKey(torrent.id), '1', 'EX', COOLDOWN_S, 'NX'); + if (claimed !== 'OK') { + throw createError({ + statusCode: 429, + message: 'A reseed has already been requested for this torrent today.', + }); + } + + const snatchers = await db + .select({ userId: schema.hnrTracking.userId }) + .from(schema.hnrTracking) + .innerJoin(schema.users, eq(schema.users.id, schema.hnrTracking.userId)) + .where( + and( + eq(schema.hnrTracking.torrentId, torrent.id), + ne(schema.hnrTracking.userId, user.id), + // A tombstone has no inbox, and a banned member cannot act on it. + isNull(schema.users.deletedAt), + eq(schema.users.isBanned, false) + ) + ) + // Newest first, which is what the note above says and what the feature is + // for: ascending pinged the 200 people who grabbed it longest ago — the + // least likely to still hold the data — and the daily lock meant nobody + // could try again that day. + .orderBy(desc(schema.hnrTracking.downloadedAt)) + .limit(MAX_RECIPIENTS); + + const recipients = snatchers.map((r) => r.userId); + + // Fire-and-forget, after the response: the member pressed a button and does + // not need to wait on 200 notification inserts to learn that it worked. + void withConcurrency(recipients, FANOUT_CONCURRENCY, async (userId) => { + await notify( + userId, + 'reseed_requested', + { torrentName: torrent.name, requesterUsername: user.username }, + `/torrents/${infoHash}` + ); + }); + + return { success: true, notified: recipients.length }; +}); diff --git a/apps/api/routes/api/torrents/[hash]/supersessions.get.ts b/apps/api/routes/api/torrents/[hash]/supersessions.get.ts new file mode 100644 index 00000000..25d2b8a8 --- /dev/null +++ b/apps/api/routes/api/torrents/[hash]/supersessions.get.ts @@ -0,0 +1,95 @@ +/** + * GET /api/torrents/:hash/supersessions + * + * Both directions of the trump pointer for one release: what replaced it, and + * what it replaced. + * + * Its own endpoint rather than fields on the detail payload, for the same + * reason the cross-seed siblings have one: the detail page fetches it + * separately and non-blocking, so a torrent with a long supersede chain never + * delays the page it belongs to. A missing or failing endpoint degrades to a + * hidden section. + * + * Visibility follows the catalogue's: a member sees `accepted` rows only, staff + * see everything. Without that, the pointer would be a way to learn that a + * pending or rejected release exists — the same oracle the duplicate preflight + * is careful not to be. + */ +import { and, eq } from 'drizzle-orm'; +import { db, schema } from '@trackarr/db'; +import { requireAuthSession } from '~~/utils/adminAuth'; +import { rateLimit, RATE_LIMITS } from '~~/utils/rateLimit'; + +/** A release can replace several older ones; the list is capped all the same. */ +const MAX_SUPERSEDES = 25; + +export default defineEventHandler(async (event) => { + const { user } = await requireAuthSession(event); + await rateLimit(event, RATE_LIMITS.public); + + const hash = getRouterParam(event, 'hash'); + if (!hash) { + throw createError({ statusCode: 400, message: 'Torrent hash is required' }); + } + + const isStaff = !!user.isAdmin || !!user.isModerator; + const visible = (alias: typeof schema.torrents) => + isStaff ? undefined : eq(alias.moderationStatus, 'accepted'); + + const source = await db.query.torrents.findFirst({ + // The visibility filter belongs on the SOURCE too, and its absence made + // this an existence oracle: a rejected hash answered 200 with empty + // relations while an unknown hash answered 404, so a member could sort + // hashes into "moderation turned this down" and "never heard of it". The + // detail endpoint flat-404s for exactly this reason. + where: and(eq(schema.torrents.infoHash, hash.toLowerCase()), visible(schema.torrents)), + columns: { + id: true, + supersededById: true, + supersededAt: true, + supersedeReason: true, + }, + }); + if (!source) { + throw createError({ statusCode: 404, message: 'Torrent not found' }); + } + + // Forward: the release that replaced this one. + const supersededBy = source.supersededById + ? ((await db.query.torrents.findFirst({ + where: and( + eq(schema.torrents.id, source.supersededById), + visible(schema.torrents) + ), + columns: { infoHash: true, name: true, size: true, createdAt: true }, + })) ?? null) + : null; + + // Reverse: the releases this one replaced. Served by + // `torrents_superseded_by_idx`. + const supersedes = await db.query.torrents.findMany({ + where: and( + eq(schema.torrents.supersededById, source.id), + visible(schema.torrents) + ), + columns: { + infoHash: true, + name: true, + size: true, + supersededAt: true, + supersedeReason: true, + }, + limit: MAX_SUPERSEDES, + }); + + return { + supersededBy: supersededBy + ? { + ...supersededBy, + at: source.supersededAt, + reason: source.supersedeReason, + } + : null, + supersedes, + }; +}); diff --git a/apps/api/routes/api/torrents/[hash]/tags.put.ts b/apps/api/routes/api/torrents/[hash]/tags.put.ts index c780e59a..0a9ad53b 100644 --- a/apps/api/routes/api/torrents/[hash]/tags.put.ts +++ b/apps/api/routes/api/torrents/[hash]/tags.put.ts @@ -3,13 +3,17 @@ import { eq } from 'drizzle-orm'; import { z } from 'zod'; import { validateParam, infoHashSchema, validateBody } from '~~/utils/schemas'; import { resolveTagsByName, MAX_TAGS_PER_TORRENT } from '~~/utils/tags'; +import { rateLimit, RATE_LIMITS } from '~~/utils/rateLimit'; // Accept either pre-resolved `tagIds` (kept for the existing admin UI) // or free-form `tags` strings (the user-facing flow from issue #45). // At least one of the two must be present. const updateTagsSchema = z .object({ - tagIds: z.array(z.string()).max(MAX_TAGS_PER_TORRENT).optional(), + // `z.string()` acceptait n'importe quoi et l'envoyait dans une colonne + // porteuse d'une clé étrangère : l'échec arrivait en 500 côté Postgres, pas + // en 400 côté schéma. + tagIds: z.array(z.string().uuid()).max(MAX_TAGS_PER_TORRENT).optional(), tags: z.array(z.string()).max(MAX_TAGS_PER_TORRENT).optional(), }) .refine((v) => v.tagIds !== undefined || v.tags !== undefined, { @@ -19,6 +23,11 @@ const updateTagsSchema = z export default defineEventHandler(async (event) => { const { user } = await requireUserSession(event); + // `resolveTagsByName` CRÉE les étiquettes absentes : sans limite, 600 + // requêtes par minute et par IP injectent jusqu'à 6 000 lignes `tags` dans un + // catalogue administré à la main, sans laisser de trace. + await rateLimit(event, RATE_LIMITS.mutation); + const infoHash = validateParam(event, 'hash', infoHashSchema); // Get torrent @@ -51,20 +60,31 @@ export default defineEventHandler(async (event) => { }); } - // Delete existing tags - await db - .delete(schema.torrentTags) - .where(eq(schema.torrentTags.torrentId, torrent.id)); - - // Insert new tags - if (ids.length > 0) { - await db.insert(schema.torrentTags).values( - ids.map((tagId) => ({ - torrentId: torrent.id, - tagId, - })) - ); - } + // Le remplacement, dans UNE transaction. + // + // C'étaient deux écritures séparées, la première destructrice. + // `torrentTags.tagId` porte une clé étrangère vers `tags.id`, et le schéma + // n'exigeait que « tableau de chaînes » : un identifiant inexistant faisait + // donc échouer l'INSERT sur violation de clé étrangère APRÈS que le DELETE + // avait été validé. Un `PUT {"tagIds":["x"]}` répondait 500 et emportait + // TOUTES les étiquettes du torrent. + // + // La validation en `uuid()` ci-dessus ne suffit pas seule — un UUID bien + // formé mais absent échoue de la même façon : c'est la transaction qui rend + // l'échec inoffensif. + await db.transaction(async (tx) => { + await tx + .delete(schema.torrentTags) + .where(eq(schema.torrentTags.torrentId, torrent.id)); + if (ids.length > 0) { + await tx.insert(schema.torrentTags).values( + ids.map((tagId) => ({ + torrentId: torrent.id, + tagId, + })) + ); + } + }); // Fetch updated tags const updatedTags = await db.query.torrentTags.findMany({ diff --git a/apps/api/routes/api/torrents/comments/[id].delete.ts b/apps/api/routes/api/torrents/comments/[id].delete.ts index fa158043..935922ce 100644 --- a/apps/api/routes/api/torrents/comments/[id].delete.ts +++ b/apps/api/routes/api/torrents/comments/[id].delete.ts @@ -1,4 +1,5 @@ import { eq } from 'drizzle-orm'; +import { rateLimit, RATE_LIMITS } from '~~/utils/rateLimit'; import { db } from '@trackarr/db'; import { torrentComments } from '@trackarr/db/schema'; import { requireAuthSession } from '~~/utils/adminAuth'; @@ -6,6 +7,7 @@ import { notify } from '~~/utils/notify'; export default defineEventHandler(async (event) => { const session = await requireAuthSession(event); + await rateLimit(event, RATE_LIMITS.mutation); const commentId = getRouterParam(event, 'id'); if (!commentId) { diff --git a/apps/api/routes/api/torrents/index.get.ts b/apps/api/routes/api/torrents/index.get.ts index 9404e4e6..68120044 100644 --- a/apps/api/routes/api/torrents/index.get.ts +++ b/apps/api/routes/api/torrents/index.get.ts @@ -18,6 +18,16 @@ import { } from '~~/utils/search'; import { adultCategoryIds } from '~~/utils/adultContent'; +/** + * How many pinned releases one listing may carry. + * + * Small on purpose. A pin is an editorial act — "read this one" — and the + * moment a first screen is all pins the listing has stopped being a listing. + * An operator who wants ten things at the top wants a homepage block, not a + * catalogue. + */ +const MAX_PINNED = 5; + export default defineEventHandler(async (event) => { // Require authentication const { user } = await requireUserSession(event); @@ -54,6 +64,29 @@ export default defineEventHandler(async (event) => { ); } + /* + * `is_active` est un interrupteur d'opérateur, et il ne coupait pas ici. + * + * `apps/tracker/db/queries/torrents.sql` le décrit comme tel et le tracker Go + * refuse d'annoncer une release inactive. Côté API il est honoré par RSS, par + * Torznab, par la fédération, par les groupes et par les statistiques — mais + * il était ABSENT du catalogue web, de la fiche et du téléchargement du + * `.torrent`, c'est-à-dire des trois seules surfaces qui comptent pour un + * retrait. Un opérateur qui basculait le drapeau à la main pour une demande + * DMCA voyait la release disparaître partout SAUF de l'endroit où on la + * trouve et de celui où on la récupère. + * + * Rien n'écrit `false` dans le code aujourd'hui, donc c'était inerte — un + * piège qui attendait la première fois qu'on s'en serve. + * + * Le personnel et le téléverseur continuent de voir la ligne : ils voient + * déjà les dépôts en attente, et retirer une release de la vue de celui qui + * doit la traiter n'aide personne. + */ + if (!canSeeUnapproved) { + conditions.push(eq(schema.torrents.isActive, true)); + } + // Hide adult-categorised torrents from users who haven't opted in. // Uncategorised torrents (categoryId = null) are never adult so they // pass through unconditionally. @@ -216,8 +249,22 @@ export default defineEventHandler(async (event) => { // The search predicate is kept apart from the filters, so the fuzzy fallback // replays the same query replacing only that. + /** + * Pinned releases are lifted out of the flow entirely, on every page. + * + * Not folded into the ORDER BY, which is the obvious implementation and the + * wrong one: putting `is_sticky DESC` in front of the sort key stops every + * existing single-column index from serving it, so a catalogue that sorted by + * date off an index starts doing a full sort on every page. A separate, + * capped query costs one extra round trip on page 1 and nothing after. + * + * Excluded on every page rather than only on page 1 so a torrent appears + * exactly once in a listing, and so `total` and the page count agree with + * what the reader can actually scroll through. + */ + const notPinned = eq(schema.torrents.isSticky, false); const compose = (search: SQL | null) => { - const all = search ? [...conditions, search] : conditions; + const all = search ? [...conditions, search, notPinned] : [...conditions, notPinned]; return all.length > 0 ? and(...all) : undefined; }; const countRows = async (where: SQL | undefined) => { @@ -246,6 +293,29 @@ export default defineEventHandler(async (event) => { // tiebreaker. const orderByClause = buildTorrentOrderBy(query.sortBy, query.order); + /** + * The pinned block, page 1 only, and under the SAME filters as the flow — + * a release pinned site-wide has no business appearing in a search for + * something else, and a member filtering by category is asking a question + * that a pin does not override. + * + * Capped hard: pinning is an editorial act and a page whose first screen is + * all pins is a page with no listing on it. + */ + const pinnedRows = + query.page === 1 + ? await db.query.torrents.findMany({ + where: and( + ...(searchCondition ? [...conditions, searchCondition] : conditions), + eq(schema.torrents.isSticky, true), + ), + columns: { torrentData: false }, + with: { category: true, torrentTags: { with: { tag: true } } }, + orderBy: orderByClause, + limit: MAX_PINNED, + }) + : []; + const torrents = await db.query.torrents.findMany({ where: whereClause, // Negative projection: select every column EXCEPT the raw .torrent @@ -267,10 +337,15 @@ export default defineEventHandler(async (event) => { // `total` was already computed above: it is what gates the fuzzy fallback. + // Pinned rows and flow rows are enriched as one list — one Redis round of + // stats, one favourites query — then split back apart at the end. Doing it + // twice would double both for a block that is usually empty. + const allRows = [...pinnedRows, ...torrents]; + // Enrich with live stats from Redis. Tolerate partial failure: a Redis hiccup // for one torrent should not fail the whole listing — fall back to zeroes. const settled = await Promise.allSettled( - torrents.map((t) => getStats(t.infoHash)) + allRows.map((t) => getStats(t.infoHash)) ); // Bulk-lookup the viewer's favorited torrent_ids among the page @@ -279,7 +354,7 @@ export default defineEventHandler(async (event) => { // toggle's filled/outline state authoritative without a // per-row round-trip. let favoritedSet = new Set(); - if (torrents.length > 0) { + if (allRows.length > 0) { const rows = await db .select({ torrentId: schema.torrentFavorites.torrentId }) .from(schema.torrentFavorites) @@ -288,14 +363,14 @@ export default defineEventHandler(async (event) => { eq(schema.torrentFavorites.userId, user.id), inArray( schema.torrentFavorites.torrentId, - torrents.map((t) => t.id), + allRows.map((t) => t.id), ), ), ); favoritedSet = new Set(rows.map((r) => r.torrentId)); } - const enriched = torrents.map((torrent, i) => { + const enriched = allRows.map((torrent, i) => { const r = settled[i]; const stats = r.status === 'fulfilled' @@ -316,10 +391,14 @@ export default defineEventHandler(async (event) => { }); return { - data: enriched, + // Split back apart in the order they went in. + pinned: enriched.slice(0, pinnedRows.length), + data: enriched.slice(pinnedRows.length), pagination: { page: query.page, limit: query.limit, + // Pinned rows are outside this count, which is what keeps the page + // count honest about the flow the reader is paging through. total, pages: Math.ceil(total / query.limit), }, diff --git a/apps/api/routes/api/torrents/index.post.ts b/apps/api/routes/api/torrents/index.post.ts index beaa3d04..222cebb6 100644 --- a/apps/api/routes/api/torrents/index.post.ts +++ b/apps/api/routes/api/torrents/index.post.ts @@ -13,6 +13,8 @@ import { normalizeMediaId } from '~~/utils/mediaIds'; import { getUploadRules, evaluateUpload } from '~~/utils/uploadRules'; import { notifyMany, listStaffRecipients } from '~~/utils/notify'; import { fanoutFollowedUserUpload } from '~~/utils/followerFanout'; +import { fanoutSavedSearchMatches } from '~~/utils/savedSearchFanout'; +import { announceRelease } from '~~/utils/irc/announcer'; /** @@ -201,6 +203,27 @@ export default defineEventHandler(async (event) => { throw new Error('missing info dict'); } info.private = 1; + // Les champs HORS `info`, qui portent les secrets de l'uploadeur. + // + // Un `.torrent` de tracker privé porte la passkey de son propriétaire dans + // `announce` (et dans chaque niveau d'`announce-list`). Les stocker verbatim + // laissait cette passkey au repos dans `torrent_data` : la route de sortie + // les réécrit de toute façon (`[hash]/download.get.ts`), donc la conserver + // n'apportait rien et suffisait à ce qu'un autre membre la lise — le cas + // courant en cross-seed étant que le fichier vienne d'un AUTRE tracker + // privé, dont la passkey n'a rien à faire ici. + // + // `url-list` (web seeds) sort aussi : un hôte tiers y ferait sortir le + // client de chaque téléchargeur vers lui. `comment` et `created by` sont du + // texte libre venu d'un fichier étranger, sans usage chez nous. + // + // Aucun de ces champs n'est dans `info`, donc l'infohash ne change pas et + // les lignes déjà stockées restent valides. + delete decoded.announce; + delete decoded['announce-list']; + delete decoded['url-list']; + delete decoded.comment; + delete decoded['created by']; normalizedData = Buffer.from(bencode.encode(decoded)); } catch (_err) { throw createError({ statusCode: 400, message: 'Invalid torrent file' }); @@ -309,8 +332,31 @@ export default defineEventHandler(async (event) => { // status so a previously-rejected upload can never be silently // re-introduced — that's the whole reason rejected rows are kept // in the table instead of being deleted. + // + // Une PROJECTION, pas la ligne entière. + // + // `findFirst` sans `columns` renvoyait les 33 colonnes de `torrents` — dont + // `torrentData`, c'est-à-dire les octets du `.torrent` stocké, plus + // `uploaderId`, `nfo`, `description` et l'état de modération — à tout membre + // authentifié capable de POSTer un fichier dont l'infohash correspond. + // + // Les trois gardes qui manquaient sont écrites, une par une, dans les deux + // routes qui font le même travail : `check.post.ts` (le PRÉFLIGHT de cette + // opération) porte déjà la projection, la porte de modération et le retrait + // de `uploaderId`, et `[hash]/download.get.ts` refuse une ligne non acceptée + // à qui n'est ni l'uploadeur ni du personnel. La garde avait été écrite pour + // l'annonce et oubliée sur l'opération annoncée. const existing = await db.query.torrents.findFirst({ where: (t, { eq }) => eq(t.infoHash, infoHash), + columns: { + id: true, + infoHash: true, + name: true, + moderationStatus: true, + createdAt: true, + uploaderId: true, + size: true, + }, }); if (existing) { @@ -322,14 +368,44 @@ export default defineEventHandler(async (event) => { 'This torrent has previously been rejected by moderation. Re-uploading it is not allowed.', }); } + + // La même porte que `check.post.ts` et `download.get.ts` : une ligne qui + // n'est pas acceptée est invisible à qui n'est ni son uploadeur ni du + // personnel. Sans elle, connaître un infohash en attente suffisait à en + // récupérer les octets — précisément ce que `download.get.ts` interdit. + const isStaff = !!(user.isAdmin || user.isModerator); + if ( + existing.moderationStatus !== 'accepted' && + !isStaff && + existing.uploaderId !== user.id + ) { + throw createError({ statusCode: 404, message: 'Torrent not found' }); + } + // Otherwise (pending / changes_requested / accepted) just hand // the existing row back. The uploader can find it on /me, and a // moderator can act on it via the queue. return { success: true, + // `outcome` porte le sens ; `message` reste pour les clients hors + // navigateur. La page d'envoi affichait `message` tel quel — donc une + // phrase anglaise en titre, sous une coche verte et au-dessus d'un + // sous-titre français qui annonçait le contraire : « la release est + // désormais indexée », alors que celle-ci existait déjà et appartient à + // quelqu'un d'autre. + outcome: 'exists' as const, message: 'Torrent already exists', data: { - ...existing, + // Pas de `...existing` : `uploaderId` sort en clair d'ici, ce qui + // défait `anonymousUploads` (`redactUploader` existe pour cela et + // n'était pas appelé), et `torrentData` sérialisé en JSON amplifie + // la réponse d'un facteur sept. + id: existing.id, + infoHash: existing.infoHash, + name: existing.name, + size: existing.size, + moderationStatus: existing.moderationStatus, + createdAt: existing.createdAt, magnetLink: generateMagnetLink(infoHash, name), }, }; @@ -537,10 +613,39 @@ export default defineEventHandler(async (event) => { torrentInfoHash: infoHash, torrentName: name, }); + // Saved-search alerts, on the same edge. Placed here rather than beside + // the insert because a filter can match on tags, and the tags are attached + // a few dozen lines above this — evaluating earlier would silently miss + // every tag-based filter. + void fanoutSavedSearchMatches({ + id, + name, + infoHash, + categoryId: categoryId || null, + imdbId, + tmdbId, + tvdbId, + uploaderId: user.id, + }); + // And the IRC channel. A fresh upload carries no per-torrent buff of its + // own — those are a moderation action — so the three multiplier fields are + // null and the announcer folds in whatever site-wide event is running. + void announceRelease({ + id, + infoHash, + name, + size: totalSize, + categoryId: categoryId || null, + uploaderId: user.id, + downloadMultiplier: null, + uploadMultiplier: null, + multipliersUntil: null, + }); } return { success: true, + outcome: canBypassModeration ? ('published' as const) : ('pending' as const), message: canBypassModeration ? 'Torrent created successfully' : 'Torrent uploaded and pending moderation approval', diff --git a/apps/api/routes/api/torrents/unregistered.post.ts b/apps/api/routes/api/torrents/unregistered.post.ts new file mode 100644 index 00000000..58fa6b87 --- /dev/null +++ b/apps/api/routes/api/torrents/unregistered.post.ts @@ -0,0 +1,176 @@ +/** + * POST /api/torrents/unregistered { infoHashes: string[] } + * + * Given a list of infohashes from a client, say which of them this tracker + * still serves — and for the ones it does not, why. + * + * The question a member's torrent client cannot answer on its own. A client + * holding four hundred torrents across six trackers has no way to tell an + * announce failing because the tracker is down from one failing because the + * release was deleted, so the usual answer is to leave dead entries in place + * forever. This is one request that sorts them. + * + * It is also the natural entry point for automated cross-seeding: a script + * that knows which of its local torrents this site does NOT have is a script + * that knows what to upload. + * + * ## Verdicts + * + * | verdict | meaning | + * | --- | --- | + * | `active` | served; announces should work | + * | `superseded` | served, but a better release replaced it — the replacement's hash comes with it | + * | `pending` | uploaded here and not through moderation yet | + * | `unregistered` | this tracker has no such torrent | + * + * `rejected` and inactive rows deliberately answer `unregistered`. The detail + * endpoint and the duplicate preflight both refuse to confirm that a rejected + * hash exists — it would turn either into an oracle for enumerating what + * moderation turned down — and an endpoint that takes 256 hashes at a time is + * the last place to open that door. + * + * ## Bounded + * + * 256 hashes per request. A client with more asks twice; the cap is what keeps + * one request from becoming a table scan with a list of arguments. + */ +import { and, eq, inArray, sql } from 'drizzle-orm'; +import { z } from 'zod/v4'; +import { db, schema } from '@trackarr/db'; +import { requireReadAccess } from '~~/utils/account/readKeyAuth'; +import { adultCategoryIds } from '~~/utils/adultContent'; +import { rateLimit, RATE_LIMITS } from '~~/utils/rateLimit'; +import { redis } from '~~/utils/server'; +import { validateBody } from '~~/utils/schemas'; + +const MAX_HASHES = 256; + +const bodySchema = z.object({ + infoHashes: z + .array(z.string().regex(/^[a-fA-F0-9]{40}$/)) + .min(1) + .max(MAX_HASHES), +}); + +export default defineEventHandler(async (event) => { + // Session or key: this is meant to be called by a script as much as by a + // browser, and a script has no cookie. + const { user: holder } = await requireReadAccess(event, 'api'); + await rateLimit(event, RATE_LIMITS.mutation); + + const body = await validateBody(event, bodySchema); + /** + * A budget in HASHES, per account and per day. + * + * The rate limiter counts requests and keys on the IP, so 10 requests a minute + * × 256 hashes is 3.7 million probes a day from one address — and a member on + * a /64 or a VPN multiplies that freely. Counting hashes against the account + * is what makes the cap mean something: 20 000 a day is far more than a + * client with a few thousand torrents needs, and far less than a catalogue + * enumeration. + */ + const budgetKey = `unregistered:budget:${holder.id}:${new Date() + .toISOString() + .slice(0, 10)}`; + const DAILY_HASH_BUDGET = 20_000; + let spent = 0; + try { + spent = await redis.incrby(budgetKey, body.infoHashes.length); + // 48 h so a key written just before midnight still expires on its own. + if (spent === body.infoHashes.length) await redis.expire(budgetKey, 172_800); + } catch { + // Redis down: the request goes through. This is an abuse budget, not an + // authorisation, and the rate limiter is still in front of it. + spent = 0; + } + if (spent > DAILY_HASH_BUDGET) { + throw createError({ + statusCode: 429, + message: `You have checked ${DAILY_HASH_BUDGET} hashes today. The budget resets at midnight UTC.`, + }); + } + + // De-duplicated and lowercased once, so a caller sending the same hash twice + // does not pay for it twice. + const wanted = [...new Set(body.infoHashes.map((h) => h.toLowerCase()))]; + + const rows = await db.query.torrents.findMany({ + where: inArray(schema.torrents.infoHash, wanted), + columns: { + id: true, + infoHash: true, + isActive: true, + moderationStatus: true, + supersededById: true, + categoryId: true, + }, + }); + + // The replacements' hashes, in one extra query rather than one per row. + const replacementIds = [ + ...new Set(rows.map((r) => r.supersededById).filter((v): v is string => !!v)), + ]; + /** + * The replacement is only named when it is itself visible to this caller. + * + * The comment below said this and the query did not: a moderator marks A + * superseded by B while B is accepted, B is later rejected or deactivated, and + * nothing clears the pointer — so this endpoint handed out the hash AND the + * name of a release moderation had turned down. On the endpoint whose own + * docstring calls itself the last place to open that door. + * + * The adult tree goes the same way: a release the caller has not opted into is + * not a release this may name. + */ + const adultIds = holder.showAdultContent ? [] : await adultCategoryIds(); + const replacements = replacementIds.length + ? await db.query.torrents.findMany({ + where: and( + inArray(schema.torrents.id, replacementIds), + eq(schema.torrents.isActive, true), + eq(schema.torrents.moderationStatus, 'accepted'), + ...(adultIds.length + ? [ + sql`(${schema.torrents.categoryId} is null or ${schema.torrents.categoryId} not in ${adultIds})`, + ] + : []) + ), + columns: { id: true, infoHash: true, name: true }, + }) + : []; + const replacementById = new Map(replacements.map((r) => [r.id, r])); + + const byHash = new Map(rows.map((r) => [r.infoHash, r])); + + const results = wanted.map((infoHash) => { + const row = byHash.get(infoHash); + if (!row || !row.isActive || row.moderationStatus === 'rejected') { + return { infoHash, verdict: 'unregistered' as const }; + } + // A release in the adult tree does not exist for a caller who has not opted + // in — the same answer the catalogue, the feeds and search give. + if (row.categoryId && adultIds.includes(row.categoryId)) { + return { infoHash, verdict: 'unregistered' as const }; + } + if (row.moderationStatus !== 'accepted') { + return { infoHash, verdict: 'pending' as const }; + } + if (row.supersededById) { + const rep = replacementById.get(row.supersededById); + return { + infoHash, + verdict: 'superseded' as const, + // Absent when the replacement is not itself visible — the verdict is + // still true and still useful without it. + supersededBy: rep ? { infoHash: rep.infoHash, name: rep.name } : null, + }; + } + return { infoHash, verdict: 'active' as const }; + }); + + return { + checked: results.length, + limit: MAX_HASHES, + results, + }; +}); diff --git a/apps/api/routes/api/torznab/api/index.get.ts b/apps/api/routes/api/torznab/api/index.get.ts index 7e878d38..04fb28ae 100644 --- a/apps/api/routes/api/torznab/api/index.get.ts +++ b/apps/api/routes/api/torznab/api/index.get.ts @@ -46,6 +46,13 @@ import { trackRateLimitHit, } from '~~/utils/torznabStats'; import { normalizeMediaId, tmdbIdBare } from '~~/utils/mediaIds'; +import { + getHnrRequiredSeedTime, + getMinRatio, + isHnrEnabled, +} from '~~/utils/settings'; +import { getActiveSnapshot } from '~~/utils/bonusEvents'; +import { IDENTITY, volumeFactors } from '~~/utils/torrentBuffs'; import { escapeLike } from '~~/utils/sql'; import { adultCategoryIds } from '~~/utils/adultContent'; @@ -275,7 +282,7 @@ async function handleMovieSearch( async function performSearch( event: H3Event, query: z.infer, - user: { passkey: string; showAdultContent: boolean } + user: { passkey: string; presentedKey: string; showAdultContent: boolean } ) { const baseUrl = getRequestURL(event).origin; const conditions: SQL[] = []; @@ -377,6 +384,44 @@ async function performSearch( const whereClause = conditions.length > 0 ? and(...conditions) : undefined; + /** + * What this site asks of every release, resolved once for the whole page. + * + * These are site-wide settings, not per-torrent columns, so they are read + * here and stamped onto every item rather than looked up inside the map — + * one Redis-cached read instead of `limit` of them. + * + * `minimumseedtime` is only sent when hit-and-run is actually switched on. + * The required seed time has a value either way (86 400 s by default), but + * announcing a requirement the site will never enforce would have clients + * seed against a rule that does not exist. + * + * The volume factors used to be hard-coded to 1 with a note about freeleech + * "enhancement". A site-wide bonus event is exactly that, and it was already + * being applied on the announce hot path — so the feed was telling *Arr + * clients "normal rates" during a freeleech. Per-torrent multipliers still + * do not exist (there is no column for them); this reflects what the site is + * doing right now, which is the part that was wrong. + */ + const [minRatio, hnrOn, requiredSeedTime, activeEvent] = await Promise.all([ + getMinRatio(), + isHnrEnabled(), + getHnrRequiredSeedTime(), + getActiveSnapshot(), + ]); + const minimumSeedTime = hnrOn ? requiredSeedTime : 0; + // The site-wide half of the volume factors. The per-torrent half is on each + // row and is folded in inside the map below, so two releases in one response + // can legitimately carry different factors — which is the whole point of a + // per-torrent buff, and was impossible while these were one pair of numbers + // for the page. + const siteWide = activeEvent + ? { + download: activeEvent.downloadMultiplier, + upload: activeEvent.uploadMultiplier, + } + : IDENTITY; + // Fetch torrents const torrents = await db.query.torrents.findMany({ where: whereClause, @@ -407,9 +452,14 @@ async function performSearch( seeders: stats.seeders, leechers: stats.leechers, grabs: stats.completed, - downloadUrl: `${baseUrl}/api/torznab/download?id=${torrent.infoHash}&apikey=${user.passkey}`, - downloadVolumeFactor: 1, // Could be enhanced with freeleech support - uploadVolumeFactor: 1, + // The key the caller presented, never `passkey`: this URL is in the + // response body, so building it from the announce credential handed a + // read-key holder the one thing a read key is meant to withhold. + downloadUrl: `${baseUrl}/api/torznab/download?id=${torrent.infoHash}&apikey=${user.presentedKey}`, + ...volumeFactors(torrent, siteWide), + infoHash: torrent.infoHash, + minimumRatio: minRatio, + minimumSeedTime, imdbId: torrent.imdbId ?? undefined, // Strip any `tv/` / `movie/` prefix before emitting — the // Torznab spec expects bare digits and *Arr clients won't @@ -453,8 +503,14 @@ async function performSearch( leechers: r.leechers, grabs: 0, downloadUrl: magnetLink(r.infoHash, r.name), + // A mirrored release is announced to the instance that holds it, so + // its rates and its seeding requirements are that instance's, not + // ours. We do not mirror either, and guessing would tell a client to + // honour a rule from the wrong site — so both are left at the neutral + // value and the obligations are omitted entirely. downloadVolumeFactor: 1, uploadVolumeFactor: 1, + infoHash: r.infoHash, }); } } diff --git a/apps/api/routes/api/torznab/cardigann.yml.get.ts b/apps/api/routes/api/torznab/cardigann.yml.get.ts new file mode 100644 index 00000000..50b16dfa --- /dev/null +++ b/apps/api/routes/api/torznab/cardigann.yml.get.ts @@ -0,0 +1,238 @@ +/** + * GET /api/torznab/cardigann.yml + * + * A Prowlarr indexer definition for THIS instance, generated from it. + * + * ## Why generated and not a file in the repo + * + * The categories are the reason. They are operator-configured — names, slugs + * and Newznab mappings all live in the database — so a static YAML shipped in + * the repo could describe every instance's categories except the one the member + * is actually joining. Generating it means a member downloads a definition that + * already knows their tracker's own categories, its name and its URL, and has + * nothing left to fill in but a key. + * + * It also follows what this codebase already does twice: the web app manifest + * and the theme stylesheet are routes for exactly the same reason. + * + * ## Format + * + * Cardigann v11, which is what Prowlarr currently loads (`DEFINITION_VERSION` + * is a constant in its source). There is no version marker inside the file — + * the version is the folder the shipped definitions live in — so nothing here + * declares one. + * + * The `search` block reads our own Torznab XML through `response.type: xml`, + * which Cardigann parses with AngleSharp and then addresses with ordinary CSS + * selectors. Note that `torznab:attr` elements are NOT selected by name: a + * namespace-prefixed element cannot be cleanly addressed in CSS, so every + * definition that consumes a Torznab feed uses the attribute form, + * `[name=seeders]` + `attribute: value`. We do the same. + * + * ## The filename is the identity, not `id` + * + * Prowlarr keys a custom definition on its FILENAME, and refuses to load one + * whose filename OR `name` collides with a built-in — the built-in wins and + * ours is dropped with nothing but a log line. `id` itself is only used in log + * messages. So the download is named after the site and the guide says to + * change file and `name` together if the instance is ever renamed. + */ +import { getCategoriesWithNewznabIds } from './utils/categories'; +import { getSiteName } from '~~/utils/server'; +import { getTorznabEnabled } from '~~/utils/torznabSettings'; +import { rateLimit, RATE_LIMITS } from '~~/utils/rateLimit'; +import { requireAuthSession } from '~~/utils/adminAuth'; + +/** YAML double-quoted scalar. Cardigann definitions are plain YAML 1.1. */ +function q(value: string): string { + return `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`; +} + +/** + * A slug Prowlarr will accept as a filename and an id. + * + * Lowercase alphanumerics and hyphens, which is the convention across all 548 + * shipped definitions. Nothing enforces it, but a definition that looks like + * the others is one an operator can reason about. + */ +function slugify(name: string): string { + const slug = name + .toLowerCase() + .normalize('NFD') + .replace(/[̀-ͯ]/g, '') + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 40); + return slug || 'trackarr'; +} + +export default defineEventHandler(async (event) => { + // Members only. The file names the instance, its address and the operator's + // whole category taxonomy, and an invite-only tracker publishes none of those + // — the sibling that generates the autobrr definition gates the same way. + await requireAuthSession(event); + await rateLimit(event, RATE_LIMITS.public); + + if (!(await getTorznabEnabled())) { + throw createError({ + statusCode: 404, + message: 'Torznab is not enabled on this instance.', + }); + } + + const siteName = await getSiteName(); + /** + * The identity is derived from the HOST, not from the site name. + * + * `getSiteName()` falls back to `TRACKARR`, so every instance nobody renamed + * produced `id: trackarr` and the same filename — two of them in one Prowlarr + * overwrite each other, which is exactly what this was supposed to avoid. A + * name can also collide with a shipped definition (`nyaa`, `1337x`), in which + * case Prowlarr keeps its own and drops ours with one log line. + */ + const host = getRequestURL(event).host; + const id = `trackarr-${slugify(host)}`; + const categories = await getCategoriesWithNewznabIds(); + + // Same derivation the Torznab feed itself uses, so the definition points at + // the host the member reached rather than at whatever an env var was set to + // on the day the container was built. + const baseUrl = getRequestURL(event).origin; + + /** + * One mapping per category, using the id our own feed emits so a `cat=` + * round-trips. `cat:` must come from Cardigann's closed 71-value enum, so it + * is derived from the Newznab parent rather than from the operator's name — + * a value outside the enum fails validation and the definition is refused. + */ + const NEWZNAB_TO_CARDIGANN: Record = { + 1000: 'Console', 2000: 'Movies', 3000: 'Audio', 4000: 'PC', + 5000: 'TV', 6000: 'XXX', 7000: 'Books', 8000: 'Other', + }; + const mappings = categories + .map((cat) => { + const parent = Math.floor(cat.newznabParent / 1000) * 1000; + const label = NEWZNAB_TO_CARDIGANN[parent] ?? 'Other'; + return ` - {id: ${cat.newznabId}, cat: ${label}, desc: ${q(cat.name)}}`; + }) + .join('\n'); + + const yaml = `--- +id: ${id} +name: ${q(siteName)} +description: ${q(`${siteName}, a private Trackarr tracker`)} +language: en-US +type: private +encoding: UTF-8 +links: + - ${q(`${baseUrl}/`)} + +caps: + categorymappings: +${mappings || ' - {id: 8000, cat: Other, desc: "Other"}'} + + modes: + search: [q] + tv-search: [q, season, ep, imdbid, tvdbid, tmdbid] + movie-search: [q, imdbid, tmdbid] + allowrawsearch: true + +settings: + - name: apikey + type: password + label: RSS key + - name: info_key + type: info + label: About your key + default: ${q( + 'Your RSS / Torznab key is on your profile page under Credentials. Use that one rather than your announce passkey: the key cannot announce, and you can revoke it on its own. Note that a .torrent this indexer grabs still carries your announce URL, so treat any client you paste this into as trusted.' + )} + +login: + # A cheap query that answers 401 when the key is wrong, so Prowlarr's + # "Test" button means something. + path: api/torznab/api + method: get + inputs: + apikey: "{{ .Config.apikey }}" + t: search + limit: 1 + +search: + paths: + - path: api/torznab/api + response: + type: xml + + inputs: + apikey: "{{ .Config.apikey }}" + t: "{{ .Query.Type }}" + q: "{{ .Keywords }}" + cat: "{{ join .Categories \\",\\" }}" + season: "{{ .Query.Season }}" + ep: "{{ .Query.Ep }}" + imdbid: "{{ .Query.IMDBID }}" + tmdbid: "{{ .Query.TMDBID }}" + tvdbid: "{{ .Query.TVDBID }}" + limit: 100 + + rows: + selector: rss > channel > item + + fields: + title: + selector: title + details: + selector: comments + download: + selector: enclosure + attribute: url + infohash: + selector: "[name=infohash]" + attribute: value + date: + selector: pubDate + filters: + - name: dateparse + args: "ddd, dd MMM yyyy HH:mm:ss zzz" + size: + selector: size + category: + selector: "[name=category]" + attribute: value + seeders: + selector: "[name=seeders]" + attribute: value + leechers: + selector: "[name=peers]" + attribute: value + grabs: + selector: "[name=grabs]" + attribute: value + downloadvolumefactor: + selector: "[name=downloadvolumefactor]" + attribute: value + uploadvolumefactor: + selector: "[name=uploadvolumefactor]" + attribute: value + minimumratio: + selector: "[name=minimumratio]" + attribute: value + optional: true + minimumseedtime: + selector: "[name=minimumseedtime]" + attribute: value + optional: true +`; + + setHeader(event, 'Content-Type', 'application/yaml; charset=utf-8'); + setHeader( + event, + 'Content-Disposition', + `attachment; filename="${id}.yml"` + ); + // Categories change when an operator edits them, and a stale definition maps + // a `cat=` to the wrong thing. Short cache, revalidated. + setHeader(event, 'Cache-Control', 'public, max-age=300, must-revalidate'); + return yaml; +}); diff --git a/apps/api/routes/api/torznab/utils/auth.ts b/apps/api/routes/api/torznab/utils/auth.ts index c938bf55..e589d546 100644 --- a/apps/api/routes/api/torznab/utils/auth.ts +++ b/apps/api/routes/api/torznab/utils/auth.ts @@ -8,11 +8,23 @@ import { db, schema } from '@trackarr/db'; import { eq } from 'drizzle-orm'; import { buildErrorXml, TORZNAB_ERRORS } from './xml'; import { liftExpiredBan } from '~~/utils/banExpiry'; +import { isLegacyPasskeyReadAllowed } from '~~/utils/settings'; export interface TorznabUser { id: string; username: string; passkey: string; + /** + * The credential the CALLER actually presented, lowercased. + * + * Not the same thing as `passkey`, and the difference was a live leak: every + * `` in a search response is built from a key, and building it + * from `passkey` published the member's ANNOUNCE credential to whoever held + * their read key — which is the one credential the read keys exist to keep + * out of a third party's hands. Anything echoing a key back into a response + * uses this field. + */ + presentedKey: string; isBanned: boolean; isAdmin: boolean; isModerator: boolean; @@ -39,37 +51,65 @@ export async function authenticateTorznab( ); } - // Validate passkey format (32 or 40 hex chars - supports legacy and new passkeys) + // Validate key format (32 or 40 hex chars — an RSS key is always 40, the + // announce passkey may be either depending on when the account was made) if (!/^[a-f0-9]{32}$/i.test(apikey) && !/^[a-f0-9]{40}$/i.test(apikey)) { throw createTorznabError( event, TORZNAB_ERRORS.INCORRECT_CREDENTIALS, - `Invalid API key format. Expected 32 or 40 hex characters (your passkey), got ${apikey.length} characters` + `Invalid API key format. Expected 32 or 40 hex characters, got ${apikey.length} characters` ); } - // Look up user by passkey. `bannedUntil` is projected here so - // `liftExpiredBan` can flip the row back to healthy when the - // timed ban has elapsed — without it we'd block users whose ban - // just expired but whom the 5-minute cron hasn't swept yet. - const users = await db - .select({ - id: schema.users.id, - username: schema.users.username, - passkey: schema.users.passkey, - isBanned: schema.users.isBanned, - bannedUntil: schema.users.bannedUntil, - isAdmin: schema.users.isAdmin, - isModerator: schema.users.isModerator, - showAdultContent: schema.users.showAdultContent, - }) + const supplied = apikey.toLowerCase(); + + /** + * The member's RSS key first, then the announce passkey while an operator + * still allows it here. + * + * Torznab is the surface members hand to Prowlarr, so it is the one most + * likely to leave the machine — which is exactly why it should not be + * carrying the credential that announces on their behalf. The passkey stays + * accepted by default because every feed already configured anywhere carries + * it; `legacy_passkey_read_access` is how an operator closes that door once + * their members have moved over. + * + * `bannedUntil` is projected so `liftExpiredBan` can flip the row back to + * healthy when a timed ban has elapsed — without it we would block users + * whose ban just expired but whom the 5-minute cron has not swept yet. + */ + const projection = { + id: schema.users.id, + username: schema.users.username, + passkey: schema.users.passkey, + isBanned: schema.users.isBanned, + bannedUntil: schema.users.bannedUntil, + // `findByReadKey` and `findByPasskey` both filter this out; this surface is + // the only one that did not, and it is the one an erased account would + // reach first if a future path ever cleared a name without clearing keys. + deletedAt: schema.users.deletedAt, + isAdmin: schema.users.isAdmin, + isModerator: schema.users.isModerator, + showAdultContent: schema.users.showAdultContent, + }; + + let users = await db + .select(projection) .from(schema.users) - .where(eq(schema.users.passkey, apikey.toLowerCase())) + .where(eq(schema.users.rssKey, supplied)) .limit(1); + if (users.length === 0 && (await isLegacyPasskeyReadAllowed())) { + users = await db + .select(projection) + .from(schema.users) + .where(eq(schema.users.passkey, supplied)) + .limit(1); + } + const user = users[0]; - if (!user) { + if (!user || user.deletedAt) { throw createTorznabError(event, TORZNAB_ERRORS.INCORRECT_CREDENTIALS); } @@ -80,8 +120,8 @@ export async function authenticateTorznab( // Strip the helper-only field before returning the user to the // rest of the Torznab pipeline — it doesn't need it. - const { bannedUntil: _bannedUntil, ...torznabUser } = user; - return torznabUser; + const { bannedUntil: _bannedUntil, deletedAt: _deletedAt, ...torznabUser } = user; + return { ...torznabUser, presentedKey: supplied }; } /** diff --git a/apps/api/routes/api/torznab/utils/xml.ts b/apps/api/routes/api/torznab/utils/xml.ts index 755080cf..104aeaed 100644 --- a/apps/api/routes/api/torznab/utils/xml.ts +++ b/apps/api/routes/api/torznab/utils/xml.ts @@ -123,6 +123,32 @@ export interface TorznabItem { downloadUrl: string; downloadVolumeFactor?: number; // 0 = freeleech, 1 = normal uploadVolumeFactor?: number; // 1 = normal, 2 = double upload + /** + * The v1 infohash, hex. Spec'd as an enumerated Torznab attribute and read + * by Prowlarr to match a release against a client's existing torrents + * without downloading the .torrent first. We already have it — it is the + * `guid` of every local item — so emitting it costs nothing and saves the + * consumer a round trip. + */ + infoHash?: string; + /** + * What the site will require of this release once it is grabbed. + * + * Both are spec'd for exactly this: a tracker stating its seeding + * requirements per torrent so the client can honour them by itself. We + * enforce a minimum ratio (announce-time gate) and a hit-and-run seed time + * (a sanction, after the fact) and until now told nobody in advance — the + * member found out when the gate closed or the sanction landed. The same + * two numbers, sent through the channel Sonarr / Radarr already read, turn + * hit-and-run from a trap into a contract. + * + * Omitted (rather than sent as 0) when the site does not impose them, since + * a stated 0 and an absent value are the same instruction and the shorter + * one cannot be misread as "seed for zero seconds". + */ + minimumRatio?: number; + /** Seconds. */ + minimumSeedTime?: number; // Torznab predefined external-id attributes (issue #47). Sonarr / // Radarr / Lidarr use these to match a release against their own // library. We pass them through as-is — IMDb keeps its `tt` prefix, @@ -153,6 +179,25 @@ export function buildSearchXml(feed: TorznabFeed): string { ` `, ]; + if (item.infoHash) { + attrs.push( + ` ` + ); + } + // `> 0` and not `!= null`: a site with no ratio requirement stores 0, + // and forwarding that would read as a requirement of zero rather than + // as the absence of one. + if (item.minimumRatio && item.minimumRatio > 0) { + attrs.push( + ` ` + ); + } + if (item.minimumSeedTime && item.minimumSeedTime > 0) { + attrs.push( + ` ` + ); + } + if (item.imdbId) { attrs.push( ` ` diff --git a/apps/api/routes/api/tracker-health.get.ts b/apps/api/routes/api/tracker-health.get.ts index 4d084d0b..f8e142b6 100644 --- a/apps/api/routes/api/tracker-health.get.ts +++ b/apps/api/routes/api/tracker-health.get.ts @@ -1,59 +1,16 @@ import { rateLimit, RATE_LIMITS } from '~~/utils/rateLimit'; +import { readTrackerHealth } from '~~/utils/trackerHealth'; -// Public health summary that the homepage badge reads. Hits the -// tracker's `/health` endpoint over the internal Docker network and -// caches the answer for a few seconds so a viral spike on /index.html -// doesn't translate into a thundering herd against the tracker. +// Résumé public que lit le badge de la page d'accueil. // -// We deliberately don't surface the per-component (db / redis) breakdown -// here — that's an admin-only diagnostic. The badge only cares whether -// the tracker is reachable AND able to answer health checks at all. - -interface TrackerHealth { - online: boolean; - // unix millis of the last successful probe; lets the UI show a - // "checked Xs ago" tooltip without triggering its own clock. - checkedAt: number; -} - -const CACHE_TTL_MS = 10_000; -const PROBE_TIMEOUT_MS = 1_500; - -let cached: { value: TrackerHealth; expiresAt: number } | null = null; - -function trackerHealthUrl(): string { - const base = - process.env.TRACKER_INTERNAL_URL || - process.env.TRACKER_HEALTH_URL || - 'http://tracker:8080'; - return base.replace(/\/+$/, '') + '/health'; -} - -async function probe(): Promise { - const url = trackerHealthUrl(); - const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), PROBE_TIMEOUT_MS); - try { - const res = await fetch(url, { signal: controller.signal }); - // Tracker returns 200 only when DB + Redis pings both succeed; any - // 5xx means at least one dependency is down. - return { online: res.ok, checkedAt: Date.now() }; - } catch { - return { online: false, checkedAt: Date.now() }; - } finally { - clearTimeout(timer); - } -} - +// La sonde et son cache vivent dans `utils/trackerHealth.ts`, partagés avec +// `/api/admin/stats` : les deux surfaces affichaient auparavant des états +// contradictoires, l'une sondant et l'autre non. Voir l'en-tête de ce module. +// +// On ne publie délibérément pas le détail par composant (db / redis) ici — +// c'est un diagnostic réservé à l'administration. Le badge ne veut savoir +// qu'une chose : le tracker répond-il. export default defineEventHandler(async (event) => { await rateLimit(event, RATE_LIMITS.public); - - const now = Date.now(); - if (cached && cached.expiresAt > now) { - return cached.value; - } - - const value = await probe(); - cached = { value, expiresAt: now + CACHE_TTL_MS }; - return value; + return await readTrackerHealth(); }); diff --git a/apps/api/routes/api/users/[id].get.ts b/apps/api/routes/api/users/[id].get.ts index 5ffbd104..0e62accf 100644 --- a/apps/api/routes/api/users/[id].get.ts +++ b/apps/api/routes/api/users/[id].get.ts @@ -1,6 +1,7 @@ import { db, schema } from '@trackarr/db'; import { and, eq, sql, desc } from 'drizzle-orm'; import { z } from 'zod'; +import { validateRouterParams } from '~~/utils/schemas'; const paramsSchema = z.object({ id: z.string().min(1), @@ -9,11 +10,12 @@ const paramsSchema = z.object({ export default defineEventHandler(async (event) => { const { user: viewer } = await requireUserSession(event); - const params = paramsSchema.parse(getRouterParams(event)); + const params = validateRouterParams(event, paramsSchema); const user = await db.query.users.findFirst({ where: eq(schema.users.id, params.id), columns: { + anonymousUploads: true, id: true, username: true, displayName: true, @@ -80,11 +82,36 @@ export default defineEventHandler(async (event) => { // parallel. The follower count is public (a number, never a list); // `viewerFollowing` is the one bit the follow toggle depends on // for its filled/outline state on first paint. + /* + * Le compte suit les MÊMES règles que la liste, sinon il la contredit. + * + * Il comptait `uploaderId = cible`, sans autre prédicat, quand + * `users/[id]/uploads.get.ts` en applique trois pour un lecteur ordinaire : + * seulement les dépôts acceptés, jamais les catégories adultes si le lecteur + * n'a pas opté, et RIEN DU TOUT quand la cible a coché « téléversements + * anonymes ». La fiche affichait donc « 12 téléversements » juste au-dessus + * d'une liste disant « ce membre publie anonymement » — et fuyait au passage + * le nombre de dépôts en attente et rejetés, à n'importe quel membre + * authentifié. + * + * Le compte reste entier pour la personne elle-même et pour le personnel, + * qui voient déjà la liste entière. + */ + const countsHidden = !isPrivileged && user.anonymousUploads; + const uploadCountWhere = isPrivileged + ? eq(schema.torrents.uploaderId, params.id) + : and( + eq(schema.torrents.uploaderId, params.id), + eq(schema.torrents.moderationStatus, 'accepted') + ); + const [uploadsCount, followersCount, viewerFollow] = await Promise.all([ - db - .select({ count: sql`count(*)::int` }) - .from(schema.torrents) - .where(eq(schema.torrents.uploaderId, params.id)), + countsHidden + ? Promise.resolve([{ count: 0 }]) + : db + .select({ count: sql`count(*)::int` }) + .from(schema.torrents) + .where(uploadCountWhere), db .select({ count: sql`count(*)::int` }) .from(schema.userFollows) diff --git a/apps/api/routes/api/users/[id]/uploads.get.ts b/apps/api/routes/api/users/[id]/uploads.get.ts index 4f231abe..54bf0438 100644 --- a/apps/api/routes/api/users/[id]/uploads.get.ts +++ b/apps/api/routes/api/users/[id]/uploads.get.ts @@ -3,6 +3,7 @@ import { and, eq, desc, sql, or, isNull, notInArray } from 'drizzle-orm'; import { getStats } from '~~/utils/server'; import { adultCategoryIds } from '~~/utils/adultContent'; import { z } from 'zod'; +import { validateQuery, validateRouterParams } from '~~/utils/schemas'; const paramsSchema = z.object({ id: z.string().min(1), @@ -16,8 +17,8 @@ const querySchema = z.object({ export default defineEventHandler(async (event) => { const { user: viewer } = await requireUserSession(event); - const params = paramsSchema.parse(getRouterParams(event)); - const query = querySchema.parse(getQuery(event)); + const params = validateRouterParams(event, paramsSchema); + const query = validateQuery(event, querySchema); const offset = (query.page - 1) * query.limit; diff --git a/apps/api/test/audit.test.ts b/apps/api/test/audit.test.ts new file mode 100644 index 00000000..cd86d8af --- /dev/null +++ b/apps/api/test/audit.test.ts @@ -0,0 +1,130 @@ +import { describe, it, expect } from 'vitest'; +import { deriveAction, isAuditable } from '../utils/audit'; + +// The audit log's coverage is structural: a hook logs every mutating staff +// request whether or not the route says anything about itself. Which makes +// these two pure functions the whole gate — one decides what gets a row, the +// other decides what that row is called for the majority of routes that never +// call `auditDetail`. + +describe('isAuditable', () => { + it('takes mutating methods on the staff consoles', () => { + expect(isAuditable('POST', '/api/admin/users/abc/ban')).toBe(true); + expect(isAuditable('PUT', '/api/admin/settings')).toBe(true); + expect(isAuditable('PATCH', '/api/mod/reports/1')).toBe(true); + expect(isAuditable('DELETE', '/api/admin/tags/9')).toBe(true); + expect(isAuditable('post', '/api/admin/panic/encrypt')).toBe(true); + }); + + it('ignores reads', () => { + // A register of authority records decisions, not who looked at a page. + expect(isAuditable('GET', '/api/admin/users')).toBe(false); + expect(isAuditable('HEAD', '/api/admin/users')).toBe(false); + }); + + it('takes a staff power exercised through a member-facing path', () => { + // The console prefixes are not the whole story: a moderator deletes a + // torrent, a comment or a forum post through the ordinary member routes, + // and every one of those is an act of authority that left no row. + expect(isAuditable('DELETE', '/api/torrents/abc', true)).toBe(true); + expect(isAuditable('DELETE', '/api/torrents/comments/12', true)).toBe(true); + expect(isAuditable('PATCH', '/api/forum/posts/9', true)).toBe(true); + expect(isAuditable('DELETE', '/api/messaging/room/messages/7', true)).toBe(true); + // …and the same request from a member is still nobody's business. + expect(isAuditable('DELETE', '/api/torrents/abc', false)).toBe(false); + expect(isAuditable('PATCH', '/api/forum/posts/9', false)).toBe(false); + }); + + it('ignores member-facing mutations', () => { + // Logging these would turn the register into a record of everybody's + // activity — the opposite of what the privacy toggles elsewhere protect. + expect(isAuditable('POST', '/api/torrents')).toBe(false); + expect(isAuditable('PATCH', '/api/me')).toBe(false); + expect(isAuditable('DELETE', '/api/me')).toBe(false); + expect(isAuditable('POST', '/api/messaging/conversations')).toBe(false); + }); + + it('is not fooled by a path that merely contains the prefix', () => { + expect(isAuditable('POST', '/api/torrents/admin/x')).toBe(false); + // No trailing slash: `/api/admin` itself is not a route, and matching it + // would be matching a prefix rather than a console. + expect(isAuditable('POST', '/api/administrators')).toBe(false); + }); +}); + +describe('deriveAction', () => { + it('names the operation from the path, dropping identifiers', () => { + expect( + deriveAction('POST', '/api/admin/users/3f2b1c4d-1111-2222-3333-444455556666/ban') + ).toBe('admin.users.ban'); + expect(deriveAction('PUT', '/api/admin/settings')).toBe('admin.settings.update'); + expect(deriveAction('DELETE', '/api/admin/tags/42')).toBe('admin.tags.delete'); + }); + + it('drops a 40-hex infohash the same way', () => { + // Otherwise every torrent is its own action and the filter is useless. + expect(deriveAction('PUT', `/api/mod/torrents/${'a'.repeat(40)}/approve`)).toBe( + 'mod.torrents.approve' + ); + }); + + it('does not append a verb to a segment that is already one', () => { + // `admin.users.ban.create` reads worse than `admin.users.ban`. + expect( + deriveAction('POST', '/api/admin/users/3f2b1c4d-1111-2222-3333-444455556666/unban') + ).toBe('admin.users.unban'); + expect(deriveAction('POST', '/api/admin/panic/encrypt')).toBe( + 'admin.panic.encrypt.create' + ); + }); + + it('keeps slug-shaped segments — they name things', () => { + expect(deriveAction('PUT', '/api/admin/federation/peers')).toBe( + 'admin.federation.peers.update' + ); + }); + + it('falls back rather than producing an empty key', () => { + expect(deriveAction('POST', '/api/')).toBe('unknown.create'); + expect(deriveAction('POST', '')).toBe('unknown.create'); + }); + + it('passes an unusual method through rather than guessing', () => { + expect(deriveAction('LOCK', '/api/admin/settings')).toBe('admin.settings.lock'); + }); +}); + +describe('deriveAction with route params', () => { + it('removes a slug-shaped id that shape heuristics cannot spot', () => { + // The case an end-to-end run found: a peer id that looks like a + // sub-resource name, turning every peer into its own action category. + expect( + deriveAction( + 'DELETE', + '/api/admin/federation/peers/does-not-exist', + ['does-not-exist'] + ) + ).toBe('admin.federation.peers.delete'); + }); + + it('removes several parameters at once', () => { + expect( + deriveAction('DELETE', '/api/admin/users/alice/roles/uploader', [ + 'alice', + 'uploader', + ]) + ).toBe('admin.users.roles.delete'); + }); + + it('leaves a genuine path segment that happens to equal no parameter', () => { + expect(deriveAction('POST', '/api/admin/panic/encrypt', [])).toBe( + 'admin.panic.encrypt.create' + ); + }); + + it('ignores empty parameter values rather than stripping empty segments', () => { + expect(deriveAction('PUT', '/api/admin/settings', [''])).toBe( + 'admin.settings.update' + ); + }); +}); diff --git a/apps/api/test/bittorrentV2.test.ts b/apps/api/test/bittorrentV2.test.ts index 188016d5..ad451a54 100644 --- a/apps/api/test/bittorrentV2.test.ts +++ b/apps/api/test/bittorrentV2.test.ts @@ -1,7 +1,11 @@ import { describe, it, expect } from 'vitest'; import bencode from 'bencode'; import { createHash } from 'node:crypto'; -import { extractV2 } from '../utils/bittorrentV2'; +import { + extractV2, + infoDictRange, + truncateV2, +} from '../utils/bittorrentV2'; // The whole value of this module is crypto correctness, so the tests build // bencoded torrents by hand and assert the exact bytes-derived values — above @@ -109,3 +113,104 @@ describe('extractV2', () => { expect(extractV2(Buffer.from('not bencode'))).toBeNull(); }); }); + +// The announce path matches on the v2 hash, so it has to be the hash a CLIENT +// computes — which is the SHA-256 of the info dict's original bytes, not of a +// re-encoding of the decoded value. The two agree for a canonical torrent, so +// the tests above cannot tell them apart; these can. +describe('infoDictRange', () => { + const raw = (s: string) => Buffer.from(s, 'latin1'); + const bstr = (s: string) => + raw(`${Buffer.byteLength(s, 'latin1')}:${s}`); + + /** A bencoded dict with the keys in the order given — canonical or not. */ + function dictOf(pairs: Array<[string, Buffer]>): Buffer { + return Buffer.concat([ + raw('d'), + ...pairs.flatMap(([k, v]) => [bstr(k), v]), + raw('e'), + ]); + } + + const v2InfoBytes = (reversed = false) => { + const tree = dictOf([ + [ + 'a.bin', + dictOf([ + [ + '', + dictOf([ + ['length', raw('i100e')], + ['pieces root', Buffer.concat([raw('32:'), Buffer.alloc(32, 0xab)])], + ]), + ], + ]), + ], + ]); + const pairs: Array<[string, Buffer]> = [ + ['file tree', tree], + ['meta version', raw('i2e')], + ['name', bstr('rel')], + ['piece length', raw('i16384e')], + ]; + return dictOf(reversed ? [...pairs].reverse() : pairs); + }; + + const fileOf = (info: Buffer) => + Buffer.concat([ + raw('d'), + bstr('announce'), + bstr('http://t/a'), + bstr('info'), + info, + // A key AFTER `info`, so a scanner that ran to the end of the file + // instead of the end of the value would be caught. + bstr('zz'), + raw('i1e'), + raw('e'), + ]); + + it('locates the info dict exactly, and stops at its end', () => { + const info = v2InfoBytes(); + const file = fileOf(info); + const range = infoDictRange(file)!; + expect(range).not.toBeNull(); + expect(file.subarray(range.start, range.end).equals(info)).toBe(true); + }); + + it('hashes the file bytes even when the info keys are out of order', () => { + // The case a re-encode gets wrong: a client that emits keys unsorted. + const info = v2InfoBytes(true); + const file = fileOf(info); + const fromFile = createHash('sha256').update(info).digest('hex'); + const r = extractV2(file); + expect(r!.infoHashV2).toBe(fromFile); + // And NOT the re-encoded (canonically re-sorted) value. + expect(r!.infoHashV2).not.toBe( + sha256(bencode.encode(bencode.decode(info) as Record)) + ); + }); + + it('derives the 20-byte announce form from it', () => { + const r = extractV2(fileOf(v2InfoBytes()))!; + expect(r.infoHashV2Short).toHaveLength(40); + expect(r.infoHashV2Short).toBe(r.infoHashV2.slice(0, 40)); + expect(truncateV2(r.infoHashV2)).toBe(r.infoHashV2Short); + }); + + it('returns null on malformed input rather than reading past the end', () => { + expect(infoDictRange(Buffer.alloc(0))).toBeNull(); + expect(infoDictRange(raw('l4:infoi1ee'))).toBeNull(); // list root + expect(infoDictRange(raw('d4:info999:ab'))).toBeNull(); // length past EOF + expect(infoDictRange(raw('d8:announce4:hehee'))).toBeNull(); // no info key + expect(infoDictRange(raw('d4:infod'))).toBeNull(); // unterminated + }); + + it('terminates on deeply nested junk', () => { + // Every branch is bounded by the buffer, so this is linear rather than a + // place to hang the announce path. + const started = Date.now(); + expect(infoDictRange(raw('d' + 'd'.repeat(4000)))).toBeNull(); + expect(Date.now() - started).toBeLessThan(1000); + }); +}); diff --git a/apps/api/test/imageSniff.test.ts b/apps/api/test/imageSniff.test.ts index 5b32a9af..198f006a 100644 --- a/apps/api/test/imageSniff.test.ts +++ b/apps/api/test/imageSniff.test.ts @@ -1,5 +1,10 @@ import { describe, it, expect } from 'vitest'; -import { sniffImage, assertImageType } from '../utils/imageSniff'; +import { + sniffImage, + assertImageType, + imageDimensions, + manifestIconSizes, +} from '../utils/imageSniff'; // Identifying an uploaded image from its bytes rather than its declared type. // @@ -108,3 +113,103 @@ describe('assertImageType', () => { expect(() => assertImageType(Buffer.from(''), ['image/png'])).toThrow(); }); }); + +// Measuring the image, which the web app manifest turns into a claim a browser +// acts on: Chrome installs a site only when an icon declares ≥ 512×512, and it +// reads the declaration rather than the file. A wrong number buys an install +// prompt and a blurry icon. +describe('imageDimensions', () => { + /** A PNG header with a real IHDR — the only chunk the reader looks at. */ + function pngOf(width: number, height: number): Buffer { + const buf = Buffer.alloc(24); + Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]).copy(buf, 0); + buf.writeUInt32BE(13, 8); // IHDR length + buf.write('IHDR', 12, 'latin1'); + buf.writeUInt32BE(width, 16); + buf.writeUInt32BE(height, 20); + return buf; + } + + it('reads a PNG IHDR', () => { + expect(imageDimensions(pngOf(512, 512))).toEqual({ + width: 512, + height: 512, + }); + }); + + it('reads a GIF logical screen descriptor (little-endian)', () => { + const buf = Buffer.alloc(16); + buf.write('GIF89a', 0, 'latin1'); + buf.writeUInt16LE(300, 6); + buf.writeUInt16LE(200, 8); + expect(imageDimensions(buf)).toEqual({ width: 300, height: 200 }); + }); + + it('reads a WEBP VP8X canvas (24-bit, stored minus one)', () => { + const buf = Buffer.alloc(32); + buf.write('RIFF', 0, 'latin1'); + buf.write('WEBP', 8, 'latin1'); + buf.write('VP8X', 12, 'latin1'); + // 192 and 96, each written as value-1 over three little-endian bytes. + buf[24] = 191; + buf[27] = 95; + expect(imageDimensions(buf)).toEqual({ width: 192, height: 96 }); + }); + + it('walks past a JPEG APP segment to reach the frame header', () => { + // SOI, then an APP0 of declared length 8, then SOF0 carrying 64×32. + const buf = Buffer.concat([ + Buffer.from([0xff, 0xd8]), + Buffer.from([0xff, 0xe0, 0x00, 0x08, 1, 2, 3, 4, 5, 6]), + Buffer.from([0xff, 0xc0, 0x00, 0x11, 0x08]), + (() => { + const d = Buffer.alloc(4); + d.writeUInt16BE(32, 0); // height first — JPEG's order + d.writeUInt16BE(64, 2); + return d; + })(), + Buffer.alloc(8), + ]); + expect(imageDimensions(buf)).toEqual({ width: 64, height: 32 }); + }); + + it('gives up rather than guessing on a JPEG that reaches its scan data', () => { + // SOI then SOS: the entropy-coded data starts and no frame header follows. + const buf = Buffer.concat([ + Buffer.from([0xff, 0xd8]), + Buffer.from([0xff, 0xda, 0x00, 0x08]), + Buffer.alloc(16), + ]); + expect(imageDimensions(buf)).toBeNull(); + }); + + it('returns null for an SVG, which has no intrinsic pixel size', () => { + expect(imageDimensions(Buffer.from(''))).toBeNull(); + }); + + it('returns null on a truncated header instead of reading past the end', () => { + expect(imageDimensions(pngOf(512, 512).subarray(0, 18))).toBeNull(); + expect(imageDimensions(Buffer.alloc(4))).toBeNull(); + }); +}); + +describe('manifestIconSizes', () => { + it('states the square when there is one', () => { + expect(manifestIconSizes({ width: 512, height: 512 })).toBe('512x512'); + }); + + it('falls back to `any` when the measurement is missing', () => { + // An SVG, an unwalked format, or an image uploaded before the measurement + // existed. Never a fabricated square. + expect(manifestIconSizes(null)).toBe('any'); + }); + + it('refuses to call a non-square image an icon size', () => { + // `sizes` names squares. A banner is not an 800-pixel icon. + expect(manifestIconSizes({ width: 800, height: 200 })).toBe('any'); + }); + + it('rejects degenerate dimensions', () => { + expect(manifestIconSizes({ width: 0, height: 0 })).toBe('any'); + }); +}); diff --git a/apps/api/test/ircAnnounce.test.ts b/apps/api/test/ircAnnounce.test.ts new file mode 100644 index 00000000..b942e4f3 --- /dev/null +++ b/apps/api/test/ircAnnounce.test.ts @@ -0,0 +1,377 @@ +import { describe, it, expect } from 'vitest'; +import { + ANNOUNCE_TOKENS, + DEFAULT_ANNOUNCE_TEMPLATE, + announcePattern, + freeleechPercent, + humanSize, + renderAnnounce, + sanitiseValue, + templateTokens, + toJsRegExp, + type AnnounceFields, +} from '../utils/irc/format'; +import { SAMPLE_FIELDS, autobrrDefinition, slugifyId } from '../utils/irc/autobrr'; + +/** + * The announce format, tested the only way that means anything: by parsing what + * it emits with the pattern we hand to members. + * + * A tracker's announce format is a public contract with software nobody here + * controls. The failure mode is not an exception — it is a channel that keeps + * talking, an autobrr that keeps not matching, and members who conclude the + * tracker is broken. So the round trip is the test, and it runs against the + * REAL pattern from `announcePattern`, converted to JavaScript syntax rather + * than rewritten in it. + */ + +const parse = (template: string, line: string) => + toJsRegExp(announcePattern(template).pattern).exec(line)?.groups; + +describe('the default format round-trips', () => { + it('parses every field back out of a rendered line', () => { + const line = renderAnnounce(DEFAULT_ANNOUNCE_TEMPLATE, SAMPLE_FIELDS); + const groups = parse(DEFAULT_ANNOUNCE_TEMPLATE, line); + expect(groups).toBeDefined(); + expect(groups).toMatchObject({ + category: 'Movies', + name: 'Example.Release.2026.1080p.BluRay.x264-GROUP', + size: '14.62 GiB', + freeleechPercent: '100%', + uploadFactor: '2', + tags: '1080p, bluray, x264', + uploader: 'example', + infoHash: '0123456789abcdef0123456789abcdef01234567', + }); + }); + + it('survives the values that break naive patterns', () => { + const awkward: AnnounceFields = { + ...SAMPLE_FIELDS, + // Brackets, colons, a dash run, unicode, and a name long enough to worry + // about — all of which appear in real release names. + name: 'Some.Show.S01E01.[HDR10+].Ünïcødé.-.MULTi.VFF.2160p.x265-Grp', + category: 'TV/UHD', + tags: 'hdr10+, x265, multi', + uploader: 'user|autodl', + }; + const line = renderAnnounce(DEFAULT_ANNOUNCE_TEMPLATE, awkward); + const groups = parse(DEFAULT_ANNOUNCE_TEMPLATE, line); + expect(groups?.name).toBe(awkward.name); + expect(groups?.category).toBe('TV/UHD'); + expect(groups?.uploader).toBe('user|autodl'); + expect(groups?.tags).toBe('hdr10+, x265, multi'); + }); + + it('parses a release with nothing on it', () => { + // The empty case is a real one — an uncategorised, untagged, anonymous + // upload with no buff — and it is where an optional group would go wrong. + const bare: AnnounceFields = { + ...SAMPLE_FIELDS, + category: 'uncategorised', + freeleechPercent: '0%', + uploadFactor: '1', + tags: '-', + uploader: 'anonymous', + }; + const groups = parse( + DEFAULT_ANNOUNCE_TEMPLATE, + renderAnnounce(DEFAULT_ANNOUNCE_TEMPLATE, bare) + ); + expect(groups).toMatchObject({ + category: 'uncategorised', + tags: '-', + uploader: 'anonymous', + freeleechPercent: '0%', + }); + }); +}); + +describe('an operator can change the format', () => { + it('derives a pattern that reads a reordered template', () => { + const custom = '[{category}] {name} | {size} | {tags} | {url}'; + const groups = parse(custom, renderAnnounce(custom, SAMPLE_FIELDS)); + expect(groups?.name).toBe(SAMPLE_FIELDS.name); + expect(groups?.size).toBe('14.62 GiB'); + expect(groups?.tags).toBe('1080p, bluray, x264'); + }); + + it('repeats a token as a non-capturing group, because RE2 has no backreference', () => { + // Measured against Go 1.26, which is what autobrr compiles this with: + // `(?Px) (?Py)` compiles; `(?Px) (?P=a)` is a syntax error. + // The first version of this emitted the backreference and asserted it here, + // so the test locked in a pattern autobrr rejects outright — and the + // round-trip could not see it, because `toJsRegExp` rewrote it to `\k`, + // which JavaScript does accept. + const custom = 'NEW {name} :: {infoHash} :: {url} :: {infoHash}'; + const { pattern } = announcePattern(custom); + expect(pattern).toContain('(?P'); + expect(pattern).not.toContain('(?P='); + expect(pattern).toContain('(?:[a-f0-9]{40})'); + const groups = parse(custom, renderAnnounce(custom, SAMPLE_FIELDS)); + expect(groups?.infoHash).toBe(SAMPLE_FIELDS.infoHash); + }); + + it('treats an unknown token as literal text, in both directions', () => { + // A typo has to render and parse consistently, or the line stops matching + // for a reason nobody can see. + const custom = 'NEW {nmae} {name} :: {url}'; + const line = renderAnnounce(custom, SAMPLE_FIELDS); + expect(line).toContain('{nmae}'); + expect(parse(custom, line)?.name).toBe(SAMPLE_FIELDS.name); + }); + + it('escapes regex metacharacters in the literal parts', () => { + const custom = 'NEW (release) [{category}] {name} $$ {url}'; + expect(parse(custom, renderAnnounce(custom, SAMPLE_FIELDS))?.category).toBe( + 'Movies' + ); + }); +}); + +describe('the failures the review found', () => { + it('keeps a long release name parseable by cutting the name, not the line', () => { + // The tail of the default template is `:: {url} :: {infoHash}` and the + // pattern anchors on the hash, so truncating the finished line produced a + // line no client could parse — for every release with a name over about 170 + // characters, which is routine. + for (const length of [150, 171, 200, 256]) { + const line = renderAnnounce(DEFAULT_ANNOUNCE_TEMPLATE, { + ...SAMPLE_FIELDS, + name: 'A'.repeat(length), + }); + expect(Buffer.byteLength(line, 'utf8')).toBeLessThanOrEqual(400); + const groups = parse(DEFAULT_ANNOUNCE_TEMPLATE, line); + expect(groups, `name of ${length} chars`).toBeDefined(); + expect(groups?.infoHash).toBe(SAMPLE_FIELDS.infoHash); + } + }); + + it('parses a colon inside a tag name, and leaves the URL alone', () => { + // `tags.name` is free text — only the slug is charset-restricted — so a + // member could create `quality:high` on their own upload and every future + // release carrying that tag was announced unparseably. Fixed in the token + // rather than by stripping colons from values: a live probe showed that + // stripping turned `https://` into `https-//` in the link field. + const line = renderAnnounce(DEFAULT_ANNOUNCE_TEMPLATE, { + ...SAMPLE_FIELDS, + tags: 'x264, quality:high', + }); + const groups = parse(DEFAULT_ANNOUNCE_TEMPLATE, line); + expect(groups).toBeDefined(); + expect(groups?.tags).toBe('x264, quality:high'); + expect(line).toContain('https://tracker.example.com/'); + }); + + it('cuts a multi-byte name on a character boundary', () => { + const line = renderAnnounce(DEFAULT_ANNOUNCE_TEMPLATE, { + ...SAMPLE_FIELDS, + name: '日'.repeat(300), + }); + expect(Buffer.byteLength(line, 'utf8')).toBeLessThanOrEqual(400); + expect(line).not.toContain('\ufffd'); + expect(parse(DEFAULT_ANNOUNCE_TEMPLATE, line)?.infoHash).toBe(SAMPLE_FIELDS.infoHash); + }); +}); + +describe('sanitising, which is the injection boundary', () => { + it('strips the frame delimiters out of a release name', () => { + // A name carrying CRLF would not corrupt the line — it would END it, and + // the rest would be a command the bot appears to have sent. + const evil = 'Nice.Release\r\nPRIVMSG #ops :give me ops\r\n'; + const cleaned = sanitiseValue(evil); + expect(cleaned).not.toMatch(/[\r\n]/); + expect(cleaned).toBe('Nice.Release PRIVMSG #ops :give me ops'); + }); + + it('renders an injected newline into one harmless line', () => { + const line = renderAnnounce(DEFAULT_ANNOUNCE_TEMPLATE, { + ...SAMPLE_FIELDS, + name: 'X\r\nQUIT', + }); + expect(line.split('\n')).toHaveLength(1); + expect(line).not.toContain('\r'); + }); + + it('drops the colour codes and control bytes', () => { + // U+0003 is mIRC colour, U+0002 bold, U+000F reset. Clients render them, + // a parser does not, and neither belongs in a machine-readable line. + expect(sanitiseValue('\u000304red \u0002text\u000f')).toBe('04red text'); + }); + + it('cannot invent a field by carrying the separator', () => { + const groups = parse( + DEFAULT_ANNOUNCE_TEMPLATE, + renderAnnounce(DEFAULT_ANNOUNCE_TEMPLATE, { + ...SAMPLE_FIELDS, + name: 'Release :: 999 GiB :: FL 100%', + }) + ); + // The name keeps its text with the separator neutralised, and the real size + // field is still the real one. + expect(groups?.size).toBe('14.62 GiB'); + expect(groups?.name).toContain('Release - 999 GiB - FL 100%'); + }); + + it('never renders an empty field', () => { + // An empty value would collapse two separators into one and shift every + // field after it. + expect(sanitiseValue(' ')).toBe('-'); + const groups = parse( + DEFAULT_ANNOUNCE_TEMPLATE, + renderAnnounce(DEFAULT_ANNOUNCE_TEMPLATE, { ...SAMPLE_FIELDS, tags: '' }) + ); + expect(groups?.tags).toBe('-'); + }); + + it('truncates by bytes and stays on a character boundary', () => { + const line = renderAnnounce(DEFAULT_ANNOUNCE_TEMPLATE, { + ...SAMPLE_FIELDS, + name: 'é'.repeat(500), + }); + expect(Buffer.byteLength(line, 'utf8')).toBeLessThanOrEqual(403); + expect(line).not.toContain('�'); + }); +}); + +describe('the figures in the line', () => { + it('reads a download multiplier as a freeleech percentage', () => { + expect(freeleechPercent(1)).toBe('0%'); + expect(freeleechPercent(0)).toBe('100%'); + expect(freeleechPercent(0.5)).toBe('50%'); + // Out-of-range values are clamped rather than printed: the token's pattern + // accepts three digits, and `-40%` would not parse at all. + expect(freeleechPercent(-1)).toBe('100%'); + expect(freeleechPercent(9)).toBe('0%'); + }); + + it('formats sizes the way a client shows them', () => { + expect(humanSize(0)).toBe('0 B'); + expect(humanSize(1023)).toBe('1023 B'); + expect(humanSize(1024)).toBe('1.00 KiB'); + expect(humanSize(15_700_000_000)).toBe('14.62 GiB'); + }); + + it('formats every size the size pattern accepts', () => { + const pattern = new RegExp(`^${ANNOUNCE_TOKENS.size!.pattern}$`); + for (const bytes of [0, 1, 999, 1024, 1_048_576, 15_700_000_000, 2 ** 50]) { + expect(pattern.test(humanSize(bytes))).toBe(true); + } + }); +}); + +describe('the generated autobrr definition', () => { + const definition = autobrrDefinition({ + siteName: 'Example Tracker', + baseUrl: 'https://tracker.example.com', + irc: { + host: 'irc.example.com', + port: 6697, + tls: true, + channel: '#announce', + announcer: 'trackarr', + keyed: true, + invited: true, + }, + template: DEFAULT_ANNOUNCE_TEMPLATE, + }); + + it('carries the pattern that reads this instance format', () => { + const { pattern } = announcePattern(DEFAULT_ANNOUNCE_TEMPLATE); + // Single-quoted YAML, so the only escaping is a doubled quote — and the + // pattern has none. This is the assertion that the file we hand out is the + // pattern we tested above, rather than something adjacent to it. + expect(definition).toContain(`pattern: '${pattern}'`); + }); + + it('carries a self-test line that its own pattern parses', () => { + const line = renderAnnounce(DEFAULT_ANNOUNCE_TEMPLATE, SAMPLE_FIELDS); + expect(definition).toContain(line); + expect(parse(DEFAULT_ANNOUNCE_TEMPLATE, line)).toBeDefined(); + }); + + it('expects each mapped variable, and nothing unmapped', () => { + for (const token of templateTokens(DEFAULT_ANNOUNCE_TEMPLATE)) { + const def = ANNOUNCE_TOKENS[token]!; + if (def.variable) expect(definition).toContain(`${token}: `); + } + // `url` and `uploadFactor` are printed for people and mapped to nothing; + // claiming them in `expect` would put autobrr's own test suite at odds with + // its behaviour. + expect(definition).not.toContain('url: "https://tracker.example.com/torrents/'); + }); + + it('asks for the API key and never for the passkey', () => { + expect(definition).toContain('name: apikey'); + expect(definition).toContain('{{ .apikey }}'); + // The help text names the passkey to warn a member off it, so what must be + // absent is the SUBSTITUTION — no URL in this file may carry the credential + // that can announce on somebody's behalf. + expect(definition).not.toContain('{{ .passkey }}'); + expect(definition).not.toMatch(/passkey=/); + }); + + it('points the download URL at the endpoint that takes a read key', () => { + expect(definition).toContain( + 'downloadurl: "/api/torznab/download?id={{ .torrentId }}&apikey={{ .apikey }}"' + ); + expect(definition).toContain('infourl: "/torrents/{{ .torrentId }}"'); + }); + + it('names the network and the announcer autobrr has to match', () => { + expect(definition).toContain('server: "irc.example.com"'); + expect(definition).toContain('port: 6697'); + expect(definition).toContain('tls: true'); + expect(definition).toContain('- "trackarr"'); + expect(definition).toContain('name: "#announce"'); + }); + + it('offers the invite field only when the channel is not open', () => { + const open = autobrrDefinition({ + siteName: 'Example Tracker', + baseUrl: 'https://tracker.example.com', + irc: { + host: 'irc.example.com', + port: 6667, + tls: false, + channel: '#announce', + announcer: 'bot', + keyed: false, + invited: false, + }, + template: DEFAULT_ANNOUNCE_TEMPLATE, + }); + expect(definition).toContain('invite_command'); + expect(open).not.toContain('invite_command'); + }); + + it('derives an identifier that will not collide with a shipped one', () => { + expect(slugifyId('Example Tracker')).toBe('example-tracker'); + // `Ü` and `é` decompose and lose their marks; `ø` does not — it is a letter + // in its own right, so it becomes a separator like any other non-ASCII + // character. Pinned because the alternative is meeting it in a filename. + expect(slugifyId('Ünïcødé Trackér!')).toBe('unic-de-tracker'); + expect(slugifyId('///')).toBe('trackarr'); + }); + + it('re-derives itself when the operator edits the template', () => { + const custom = 'DROP {name} [{category}] {size} {url}'; + const other = autobrrDefinition({ + siteName: 'Example Tracker', + baseUrl: 'https://tracker.example.com', + irc: { + host: 'irc.example.com', + port: 6697, + tls: true, + channel: '#announce', + announcer: 'trackarr', + keyed: false, + invited: false, + }, + template: custom, + }); + expect(other).toContain(`pattern: '${announcePattern(custom).pattern}'`); + // And the definition no longer claims fields the new template does not emit. + expect(other).not.toContain('freeleechPercent: '); + }); +}); diff --git a/apps/api/test/naiveTimestamp.test.ts b/apps/api/test/naiveTimestamp.test.ts new file mode 100644 index 00000000..6e2465c7 --- /dev/null +++ b/apps/api/test/naiveTimestamp.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from 'vitest'; +import { naiveTimestampToIso } from '../utils/naiveTimestamp'; + +/** + * `db.execute()` rend les colonnes `timestamp` en chaîne brute, sans fuseau. + * + * Le défaut que ce fichier verrouille se voyait sur le forum : « il y a + * 2 heures » pour un message publié depuis douze minutes, plus un défaut + * d'hydratation, parce que `"2026-09-02 16:41:33.779157"` partait tel quel + * dans le JSON et que `new Date()` le lit dans le fuseau LOCAL. Deux heures + * d'écart en France, treize en Nouvelle-Zélande, zéro sur un poste en UTC — + * c'est-à-dire invisible pour qui développe en UTC. + */ +describe('naiveTimestampToIso', () => { + it('déclare UTC la forme que rend Postgres', () => { + expect(naiveTimestampToIso('2026-09-02 16:41:33.779157')).toBe( + '2026-09-02T16:41:33.779Z' + ); + }); + + it('laisse intact un instant déjà daté', () => { + expect(naiveTimestampToIso('2026-09-02T16:41:33.779Z')).toBe( + '2026-09-02T16:41:33.779Z' + ); + // Un décalage explicite est un instant : on le convertit sans le déplacer. + expect(naiveTimestampToIso('2026-09-02T18:41:33.779+02:00')).toBe( + '2026-09-02T16:41:33.779Z' + ); + }); + + it('accepte un Date, que `db.select()` rend déjà correctement', () => { + const d = new Date('2026-09-02T16:41:33.779Z'); + expect(naiveTimestampToIso(d)).toBe('2026-09-02T16:41:33.779Z'); + }); + + it('rend null plutôt que « Invalid Date »', () => { + expect(naiveTimestampToIso(null)).toBeNull(); + expect(naiveTimestampToIso(undefined)).toBeNull(); + expect(naiveTimestampToIso('pas une date')).toBeNull(); + }); + + it("ne dépend pas du fuseau du processus", () => { + // La suite tourne en Europe/Paris (voir `vitest.config.ts` du web pour le + // même choix). Si la conversion lisait l'heure locale, on obtiendrait + // 14:41Z au lieu de 16:41Z — l'écart exact qu'affichait le forum. + const iso = naiveTimestampToIso('2026-09-02 16:41:33.779157'); + expect(iso).not.toBe('2026-09-02T14:41:33.779Z'); + expect(new Date(iso!).getUTCHours()).toBe(16); + }); +}); diff --git a/apps/api/test/panic.test.ts b/apps/api/test/panic.test.ts index 774068de..8dd72973 100644 --- a/apps/api/test/panic.test.ts +++ b/apps/api/test/panic.test.ts @@ -37,14 +37,24 @@ describe('deriveKey / generateSalt', () => { expect(k.length).toBe(32); }); - it('is deterministic for a constant salt, and diverges otherwise', async () => { - const salt = Buffer.from(generateSalt(), 'base64'); - const a = await deriveKey(PASSWORD, salt); - const b = await deriveKey(PASSWORD, salt); - const c = await deriveKey(PASSWORD, Buffer.from(generateSalt(), 'base64')); - expect(a.equals(b)).toBe(true); - expect(a.equals(c)).toBe(false); - }); + // Délai explicite, et non le défaut de 5 s de vitest : ce test dérive TROIS + // clés, et la version 3 du KDF coûte ~490 ms par dérivation (N = 2^17, contre + // ~50 ms pour les versions 1 et 2). Le coût est le but — voir `KDF_COST` dans + // `utils/panic.ts` : le mode panique suppose la base déjà entre les mains de + // l'attaquant. Si ce test dépasse à nouveau, allonger le délai ; ne PAS + // baisser N. + it( + 'is deterministic for a constant salt, and diverges otherwise', + async () => { + const salt = Buffer.from(generateSalt(), 'base64'); + const a = await deriveKey(PASSWORD, salt); + const b = await deriveKey(PASSWORD, salt); + const c = await deriveKey(PASSWORD, Buffer.from(generateSalt(), 'base64')); + expect(a.equals(b)).toBe(true); + expect(a.equals(c)).toBe(false); + }, + 20_000 + ); it('produces a 32-byte salt, different on every call', () => { const s1 = Buffer.from(generateSalt(), 'base64'); diff --git a/apps/api/test/publicStats.test.ts b/apps/api/test/publicStats.test.ts new file mode 100644 index 00000000..8019b885 --- /dev/null +++ b/apps/api/test/publicStats.test.ts @@ -0,0 +1,187 @@ +import { describe, it, expect } from 'vitest'; +import { + busiestDay, + dailyDeltas, + dailyPoints, + selectableYears, + yearWindow, + type Snapshot, +} from '../utils/publicStats'; + +/** + * The four derivations behind the public stats, tested without a database. + * + * `site_stats` holds hourly readings of cumulative counters, and every figure a + * member reads is derived from them. Each of these has a failure mode that + * looks like data rather than a bug — a gap in the snapshots, a counter that + * goes backwards, a year the site did not exist for — so this is where they are + * pinned down. + */ + +const snap = (iso: string, uploaded: number, extra: Partial = {}): Snapshot => ({ + // The day label comes from Postgres in the real query (`to_char`), because a + // JS Date reads a zone-less timestamp in the process's zone. Mirrored here. + day: iso.slice(0, 10), + at: new Date(iso), + users: 10, + torrents: 100, + peers: 5, + seeders: 4, + uploaded, + ...extra, +}); + +describe('dailyPoints', () => { + it('keeps the last reading of each day, not the first or an average', () => { + const points = dailyPoints([ + snap('2026-03-01T01:00:00Z', 100), + snap('2026-03-01T23:00:00Z', 180), + snap('2026-03-02T12:00:00Z', 240), + ]); + expect(points.map((p) => [p.day, p.uploaded])).toEqual([ + ['2026-03-01', 180], + ['2026-03-02', 240], + ]); + }); + + it('sorts by day even when the rows arrive out of order', () => { + const points = dailyPoints([ + snap('2026-03-03T10:00:00Z', 300), + snap('2026-03-01T10:00:00Z', 100), + ]); + expect(points.map((p) => p.day)).toEqual(['2026-03-01', '2026-03-03']); + }); + + it('leaves a day with no snapshot absent rather than zero', () => { + // An instance that was down for a day must not draw a cliff to zero on a + // chart of a counter that never moved. + const points = dailyPoints([ + snap('2026-03-01T10:00:00Z', 100), + snap('2026-03-03T10:00:00Z', 150), + ]); + expect(points).toHaveLength(2); + expect(points.some((p) => p.uploaded === 0)).toBe(false); + }); +}); + +describe('dailyDeltas', () => { + it('reports the movement between consecutive points', () => { + const deltas = dailyDeltas( + dailyPoints([ + snap('2026-03-01T10:00:00Z', 1_000), + snap('2026-03-02T10:00:00Z', 3_500), + snap('2026-03-03T10:00:00Z', 4_000), + ]), + ); + expect(deltas.map((d) => [d.day, d.bytes])).toEqual([ + ['2026-03-02', 2_500], + ['2026-03-03', 500], + ]); + }); + + it('drops the first day instead of comparing it against zero', () => { + // Otherwise the site's entire history is reported as one day's traffic. + const deltas = dailyDeltas(dailyPoints([snap('2026-03-01T10:00:00Z', 9_000_000)])); + expect(deltas).toEqual([]); + }); + + it('clamps a counter that goes backwards', () => { + // `total_uploaded_bytes` is SUM(users.uploaded): erasing an account lowers + // it, and so does a moderator resetting a cheater. "-4.2 TB on Tuesday" is + // a figure a reader would try to explain. + const deltas = dailyDeltas( + dailyPoints([ + snap('2026-03-01T10:00:00Z', 5_000), + snap('2026-03-02T10:00:00Z', 1_000), + snap('2026-03-03T10:00:00Z', 1_200), + ]), + ); + expect(deltas.map((d) => d.bytes)).toEqual([0, 200]); + }); + + it('clamps the torrent and member counters too', () => { + const deltas = dailyDeltas( + dailyPoints([ + snap('2026-03-01T10:00:00Z', 10, { torrents: 500, users: 50 }), + snap('2026-03-02T10:00:00Z', 20, { torrents: 480, users: 49 }), + ]), + ); + expect(deltas[0]).toMatchObject({ torrents: 0, users: 0 }); + }); +}); + +describe('dailyDeltas across a gap', () => { + it('does not attribute an outage to the day it ended', () => { + // Five days down, then a snapshot. The naive difference makes that one day + // look like the busiest of the year — every time, on every instance that + // ever restarted — and draws a bar that flattens the rest of the chart. + const deltas = dailyDeltas( + dailyPoints([ + snap('2026-03-01T10:00:00Z', 1_000), + snap('2026-03-02T10:00:00Z', 2_000), + snap('2026-03-08T10:00:00Z', 9_000), + snap('2026-03-09T10:00:00Z', 9_500), + ]), + ); + expect(deltas.map((d) => d.day)).toEqual(['2026-03-02', '2026-03-09']); + expect(deltas.map((d) => d.bytes)).toEqual([1_000, 500]); + }); + + it('and therefore does not let a gap win busiestDay', () => { + const points = dailyPoints([ + snap('2026-03-01T10:00:00Z', 0), + snap('2026-03-02T10:00:00Z', 5_000), + snap('2026-03-20T10:00:00Z', 900_000), + snap('2026-03-21T10:00:00Z', 906_000), + ]); + expect(busiestDay(dailyDeltas(points))?.day).toBe('2026-03-21'); + }); +}); + +describe('busiestDay', () => { + it('picks the largest day', () => { + const best = busiestDay([ + { day: '2026-03-02', bytes: 10, torrents: 0, users: 0 }, + { day: '2026-03-03', bytes: 90, torrents: 0, users: 0 }, + ]); + expect(best?.day).toBe('2026-03-03'); + }); + + it('has no busiest day when nothing moved', () => { + // A clamped run of zeroes is not a record, and "busiest day: 0 B" on a + // year in review reads as a bug in the page. + expect(busiestDay([{ day: '2026-03-02', bytes: 0, torrents: 0, users: 0 }])).toBeNull(); + expect(busiestDay([])).toBeNull(); + }); +}); + +describe('yearWindow', () => { + it('is half-open and in UTC', () => { + const { start, end } = yearWindow(2026); + expect(start.toISOString()).toBe('2026-01-01T00:00:00.000Z'); + expect(end.toISOString()).toBe('2027-01-01T00:00:00.000Z'); + }); + + it('excludes the last instant of the year from the next one', () => { + // The boundary that decides which review a New Year's Eve upload lands in. + const y2026 = yearWindow(2026); + const y2027 = yearWindow(2027); + const lastMoment = new Date('2026-12-31T23:59:59.999Z'); + expect(lastMoment >= y2026.start && lastMoment < y2026.end).toBe(true); + expect(lastMoment < y2027.start).toBe(true); + }); +}); + +describe('selectableYears', () => { + it('runs from the current year back to the first snapshot', () => { + expect( + selectableYears(new Date('2024-06-01T00:00:00Z'), new Date('2026-09-01T00:00:00Z')), + ).toEqual([2026, 2025, 2024]); + }); + + it('offers the current year alone on an instance with no history', () => { + // A selector that offered 2019 on a site installed last week would produce + // an empty review, which reads as a broken page rather than an empty year. + expect(selectableYears(null, new Date('2026-09-01T00:00:00Z'))).toEqual([2026]); + }); +}); diff --git a/apps/api/test/safeFetch.test.ts b/apps/api/test/safeFetch.test.ts index 78737dea..2c3fa677 100644 --- a/apps/api/test/safeFetch.test.ts +++ b/apps/api/test/safeFetch.test.ts @@ -1,5 +1,10 @@ import { describe, it, expect } from 'vitest'; -import { isBlockedIp, validateHost, SafeFetchError } from '../utils/safeFetch'; +import { + isBlockedIp, + validateHost, + safeFetch, + SafeFetchError, +} from '../utils/safeFetch'; // SSRF guard. This is the single choke point behind the web_push channel // (finding M10) and the federation swarm peer relay (finding L6): if any @@ -93,3 +98,171 @@ describe('validateHost', () => { await expect(validateHost('localhost')).rejects.toBeInstanceOf(SafeFetchError); }); }); + +/* + * Redirections : ce qui traverse une frontière d'origine, et ce qui tombe. + * + * La validation d'hôte était déjà rejouée à chaque saut, donc aucune + * redirection ne pouvait atteindre une plage privée. Ce qui manquait, c'est + * que l'`init` de l'appelant — en-têtes compris — était réinjecté dans chaque + * `fetch` : une cible publique qui répondait `302 Location: https://attaquant` + * recevait les seize en-têtes que le membre configure sur son webhook, son + * HMAC de corps, l'`Authorization` de son ntfy ou la signature SigV4 du + * stockage. Aucune plage franchie, rien dans le journal. + * + * Les hôtes sont des littéraux d'adresse PUBLIQUE : `validateHost` les + * court-circuite sans DNS, donc ces tests ne touchent pas le réseau. + */ +describe('safeFetch — frontières d’origine', () => { + type Hop = { url: string; method: string; headers: Record }; + + /** + * Remplace `fetch` par un enregistreur qui rejoue un script de réponses. + * Renvoie les sauts observés — c'est là que se lit la panne : un total + * correct ne dit rien si le secret est parti au saut d'avant. + */ + function stubFetch(script: Array<{ status: number; location?: string }>) { + const hops: Hop[] = []; + let i = 0; + const original = globalThis.fetch; + globalThis.fetch = (async (url: string, init: RequestInit) => { + const seen: Record = {}; + new Headers(init.headers as HeadersInit).forEach((v, k) => { + seen[k] = v; + }); + hops.push({ url: String(url), method: String(init.method), headers: seen }); + const step = script[Math.min(i++, script.length - 1)]; + const h = new Headers(); + if (step.location) h.set('location', step.location); + return new Response(null, { status: step.status, headers: h }); + }) as typeof globalThis.fetch; + return { hops, restore: () => void (globalThis.fetch = original) }; + } + + const SECRETS = { + Authorization: 'Bearer s3cr3t', + 'X-Trackarr-Signature': 'deadbeef', + 'X-Gotify-Key': 'gotify-token', + Cookie: 'session=abc', + 'User-Agent': 'Trackarr-Notify/1', + Accept: 'application/json', + }; + + it('garde les en-têtes sur une redirection DANS la même origine', async () => { + // Le contrôle positif. Sans lui, un test qui ne voit aucun secret au + // second saut ne distingue pas « retiré » de « jamais envoyé ». + const { hops, restore } = stubFetch([ + { status: 302, location: 'http://1.1.1.1/b' }, + { status: 200 }, + ]); + try { + await safeFetch('http://1.1.1.1/a', { headers: SECRETS }); + } finally { + restore(); + } + expect(hops).toHaveLength(2); + expect(hops[1]!.headers.authorization).toBe('Bearer s3cr3t'); + expect(hops[1]!.headers['x-trackarr-signature']).toBe('deadbeef'); + }); + + it('retire TOUT en-tête porteur de secret au passage vers une autre origine', async () => { + const { hops, restore } = stubFetch([ + { status: 302, location: 'http://1.0.0.1/b' }, + { status: 200 }, + ]); + try { + await safeFetch('http://1.1.1.1/a', { headers: SECRETS }); + } finally { + restore(); + } + expect(hops).toHaveLength(2); + // Le premier saut, vers l'hôte que l'appelant a choisi, les garde. + expect(hops[0]!.headers.authorization).toBe('Bearer s3cr3t'); + // Le second, non — et pas seulement `authorization` : une liste noire + // laisserait passer `x-trackarr-signature` et `x-gotify-key`. + expect(hops[1]!.headers.authorization).toBeUndefined(); + expect(hops[1]!.headers['x-trackarr-signature']).toBeUndefined(); + expect(hops[1]!.headers['x-gotify-key']).toBeUndefined(); + expect(hops[1]!.headers.cookie).toBeUndefined(); + // Ce qui ne peut rien authentifier survit. + expect(hops[1]!.headers['user-agent']).toBe('Trackarr-Notify/1'); + expect(hops[1]!.headers.accept).toBe('application/json'); + }); + + it('compte un passage https → http sur le même hôte comme un franchissement', async () => { + // Livrer le jeton en clair est le pire des deux cas, pas le meilleur. + const { hops, restore } = stubFetch([ + { status: 302, location: 'http://1.1.1.1/b' }, + { status: 200 }, + ]); + try { + await safeFetch('https://1.1.1.1/a', { headers: SECRETS }); + } finally { + restore(); + } + expect(hops[1]!.headers.authorization).toBeUndefined(); + }); + + it('ne rend pas les en-têtes si la chaîne revient à l’origine de départ', async () => { + // A → B → A. B a choisi ce retour ; il aurait pu choisir un A homographe. + const { hops, restore } = stubFetch([ + { status: 302, location: 'http://1.0.0.1/b' }, + { status: 302, location: 'http://1.1.1.1/c' }, + { status: 200 }, + ]); + try { + await safeFetch('http://1.1.1.1/a', { headers: SECRETS }); + } finally { + restore(); + } + expect(hops).toHaveLength(3); + expect(hops[2]!.url).toBe('http://1.1.1.1/c'); + expect(hops[2]!.headers.authorization).toBeUndefined(); + }); + + it('refuse un 307 qui rejouerait le corps vers une autre origine', async () => { + // Un 307/308 conserve la méthode ET le corps : retirer les en-têtes n'y + // suffirait pas, le contenu partirait quand même. Et dégrader le PUT en + // GET rendrait un 200 pour une écriture qui n'a jamais eu lieu — on + // refuse, visiblement. + const { hops, restore } = stubFetch([ + { status: 307, location: 'http://1.0.0.1/b' }, + { status: 200 }, + ]); + try { + await expect( + safeFetch('http://1.1.1.1/a', { + method: 'PUT', + headers: SECRETS, + body: 'des octets à ne pas divulguer', + }) + ).rejects.toBeInstanceOf(SafeFetchError); + } finally { + restore(); + } + // Un seul saut : le second n'a jamais été tenté. + expect(hops).toHaveLength(1); + }); + + it('suit un POST vers une autre origine sans corps et sans secret', async () => { + // Le comportement existant (POST → GET, corps abandonné) est conservé ; + // ce qui change, c'est que les en-têtes ne suivent plus. + const { hops, restore } = stubFetch([ + { status: 302, location: 'http://1.0.0.1/b' }, + { status: 200 }, + ]); + try { + const res = await safeFetch('http://1.1.1.1/a', { + method: 'POST', + headers: SECRETS, + body: '{"payload":"x"}', + }); + expect(res.status).toBe(200); + } finally { + restore(); + } + expect(hops).toHaveLength(2); + expect(hops[1]!.method).toBe('GET'); + expect(hops[1]!.headers.authorization).toBeUndefined(); + }); +}); diff --git a/apps/api/test/search.test.ts b/apps/api/test/search.test.ts index c7e477b2..80b4e03b 100644 --- a/apps/api/test/search.test.ts +++ b/apps/api/test/search.test.ts @@ -5,6 +5,7 @@ import { fuzzyTerm, parseSearchFields, parseSearchFuzzy, + toExactTsQuery, toPrefixTsQuery, } from '../utils/search'; @@ -154,3 +155,26 @@ describe('fuzzyTerm', () => { expect(fuzzyTerm('***')).toBeNull(); }); }); + +// A saved alert is settled intent, not a query bar with focus. `crown` must +// not fire on `crownfall` — the member would have no way to see it coming, and +// it arrives as a notification rather than as a page they can re-read. +describe('toExactTsQuery', () => { + it('drops the prefix marker the live search appends', () => { + expect(toPrefixTsQuery('the crown')).toBe('the & crown:*'); + expect(toExactTsQuery('the crown')).toBe('the & crown'); + }); + + it('normalises exactly like the live search otherwise', () => { + expect(toExactTsQuery('Blade.Runner 2049!')).toBe('blade & runner & 2049'); + }); + + it('returns null when nothing usable is left', () => { + expect(toExactTsQuery(' ')).toBeNull(); + expect(toExactTsQuery('...')).toBeNull(); + }); + + it('leaves a single term intact but unprefixed', () => { + expect(toExactTsQuery('dune')).toBe('dune'); + }); +}); diff --git a/apps/api/test/torrentBuffs.test.ts b/apps/api/test/torrentBuffs.test.ts new file mode 100644 index 00000000..57d12404 --- /dev/null +++ b/apps/api/test/torrentBuffs.test.ts @@ -0,0 +1,140 @@ +import { describe, it, expect } from 'vitest'; +import { + best, + buffLabel, + IDENTITY, + torrentMultipliers, + volumeFactors, +} from '../utils/torrentBuffs'; + +// The API's half of a rule the Go tracker also implements +// (`apps/tracker/internal/bonus.Best`). These cases mirror `TestBest` there on +// purpose: two implementations of one rule drift, and the table is what catches +// it when only one side is changed. + +const NEUTRAL = { downloadMultiplier: 100, uploadMultiplier: 100, multipliersUntil: null }; + +describe('torrentMultipliers', () => { + it('reads the buff off the row', () => { + expect( + torrentMultipliers({ ...NEUTRAL, downloadMultiplier: 0 }) + ).toEqual({ download: 0, upload: 100 }); + }); + + it('neutralises a lapsed buff', () => { + const past = new Date('2020-01-01T00:00:00Z'); + expect( + torrentMultipliers({ + downloadMultiplier: 0, + uploadMultiplier: 200, + multipliersUntil: past, + }) + ).toEqual(IDENTITY); + }); + + it('honours a buff that has not lapsed yet', () => { + const future = new Date(Date.now() + 60_000); + expect( + torrentMultipliers({ + downloadMultiplier: 0, + uploadMultiplier: 100, + multipliersUntil: future, + }) + ).toEqual({ download: 0, upload: 100 }); + }); + + it('treats a null end date as no end date', () => { + expect( + torrentMultipliers({ + downloadMultiplier: 50, + uploadMultiplier: 100, + multipliersUntil: null, + }) + ).toEqual({ download: 50, upload: 100 }); + }); +}); + +describe('best', () => { + it('is the identity when nothing is running and nothing is buffed', () => { + expect(best(IDENTITY, IDENTITY)).toEqual(IDENTITY); + }); + + it('takes the lower download and the higher upload', () => { + // The case the product gets wrong: multiplying a site freeleech by a + // per-torrent double upload would give upload 400 — credit nobody granted. + expect( + best({ download: 0, upload: 100 }, { download: 100, upload: 200 }) + ).toEqual({ download: 0, upload: 200 }); + }); + + it('cannot be made worse by the stingier side', () => { + expect( + best({ download: 0, upload: 200 }, { download: 100, upload: 100 }) + ).toEqual({ download: 0, upload: 200 }); + }); + + it('is commutative', () => { + const a = { download: 50, upload: 150 }; + const b = { download: 0, upload: 100 }; + expect(best(a, b)).toEqual(best(b, a)); + }); +}); + +describe('volumeFactors', () => { + it('converts basis points to the plain factors Torznab wants', () => { + expect( + volumeFactors( + { downloadMultiplier: 0, uploadMultiplier: 200, multipliersUntil: null }, + IDENTITY + ) + ).toEqual({ downloadVolumeFactor: 0, uploadVolumeFactor: 2 }); + }); + + it('lets two torrents in one response differ', () => { + // Which is the whole point: these used to be one pair of numbers for the + // entire page. + const buffed = volumeFactors( + { downloadMultiplier: 0, uploadMultiplier: 100, multipliersUntil: null }, + IDENTITY + ); + const plain = volumeFactors(NEUTRAL, IDENTITY); + expect(buffed.downloadVolumeFactor).toBe(0); + expect(plain.downloadVolumeFactor).toBe(1); + }); + + it('still reflects a site-wide event on an unbuffed torrent', () => { + expect( + volumeFactors(NEUTRAL, { download: 0, upload: 200 }) + ).toEqual({ downloadVolumeFactor: 0, uploadVolumeFactor: 2 }); + }); +}); + +describe('buffLabel', () => { + it('names the two presets and the double upload', () => { + expect(buffLabel({ ...NEUTRAL, downloadMultiplier: 0 })).toBe('freeleech'); + expect(buffLabel({ ...NEUTRAL, downloadMultiplier: 50 })).toBe('silverleech'); + expect(buffLabel({ ...NEUTRAL, uploadMultiplier: 200 })).toBe('double-upload'); + }); + + it('falls back to `custom` for anything else', () => { + expect( + buffLabel({ downloadMultiplier: 25, uploadMultiplier: 150, multipliersUntil: null }) + ).toBe('custom'); + }); + + it('is null when the torrent carries no buff of its own', () => { + // Deliberately blind to site-wide events: a badge on one torrent among a + // hundred has to mean THIS one. + expect(buffLabel(NEUTRAL)).toBeNull(); + }); + + it('is null once the buff has lapsed', () => { + expect( + buffLabel({ + downloadMultiplier: 0, + uploadMultiplier: 100, + multipliersUntil: new Date('2020-01-01T00:00:00Z'), + }) + ).toBeNull(); + }); +}); diff --git a/apps/api/test/torznabRotation.test.ts b/apps/api/test/torznabRotation.test.ts new file mode 100644 index 00000000..d3570b53 --- /dev/null +++ b/apps/api/test/torznabRotation.test.ts @@ -0,0 +1,101 @@ +import { describe, it, expect } from 'vitest'; +import { readdir, readFile } from 'fs/promises'; +import { join } from 'path'; + +/** + * A Torznab access block is stored under a hash of the member's passkey, so + * every route that rotates that passkey has to move the block onto the + * replacement. One of them did not, and the effect was that a blocked member + * could lift an administrator's restriction from their own settings page. + * + * Nothing about a rotation route makes that requirement visible while writing + * it — the block lives in Redis, under a key the route never mentions — which + * is exactly why this is a structural test over the source rather than a unit + * test of one function. It fails on the fourth rotation path, the one nobody + * has written yet. + */ + +const ROUTES = join(import.meta.dirname, '..', 'routes'); + +async function* walk(dir: string): AsyncGenerator { + for (const entry of await readdir(dir, { withFileTypes: true })) { + const full = join(dir, entry.name); + if (entry.isDirectory()) yield* walk(full); + else if (entry.name.endsWith('.ts')) yield full; + } +} + +/** + * Every route file that gives `users.passkey` a different value — matched on + * the drizzle write, `.set({ passkey: … })`, whatever the variable holding it + * is called. + * + * Panic mode is the one exclusion, and it is not an exception to the rule: it + * encrypts the stored passkey of every account and decrypts it back, so the + * member's credential never changes. The block index moves out from under the + * entry while the site is sealed — where no feed works for anybody — and comes + * back with it. Writing a block under the ciphertext's hash would be actively + * wrong. + * + * The predicate is deliberately the wide one: anything that writes the column + * by some route this test has never seen lands in the list and has to say what + * it does about the block. + */ +async function rotationRoutes(): Promise<{ path: string; source: string }[]> { + const found: { path: string; source: string }[] = []; + for await (const path of walk(ROUTES)) { + const source = await readFile(path, 'utf8'); + if (!/\.set\(\{[^}]*\bpasskey:/s.test(source)) continue; + if (/encryptField|decryptField/.test(source)) continue; + found.push({ path: path.slice(ROUTES.length + 1), source }); + } + return found; +} + +describe('passkey rotation carries the Torznab block', () => { + it('finds the rotation routes at all', async () => { + const routes = await rotationRoutes(); + // If this drops to zero the test has stopped testing anything — a renamed + // column or a switch away from `.set({ passkey })` would make every + // assertion below vacuously true. + expect(routes.map((r) => r.path).sort()).toEqual([ + 'api/admin/torznab/users/[id]/reset.post.ts', + 'api/auth/passkey.post.ts', + 'api/me/passkey/reset.post.ts', + ]); + }); + + it('every rotation route carries the block onto the new passkey', async () => { + const missing = (await rotationRoutes()) + // The open paren matters: an unused import satisfies a plain name + // match, which is precisely the state a half-applied fix leaves behind. + .filter((r) => !r.source.includes('carryTorznabBlock(')) + .map((r) => r.path); + expect(missing).toEqual([]); + }); + + it('and retires the old one', async () => { + const missing = (await rotationRoutes()) + .filter((r) => !r.source.includes('retireTorznabPasskey(')) + .map((r) => r.path); + expect(missing).toEqual([]); + }); + + it('carries before it writes, and retires after', async () => { + // Order is the whole guarantee: carry first and there is no instant in + // which a live passkey is unblocked. A route that retired the old entry + // before the row changed would open exactly the window this closes. + for (const { path, source } of await rotationRoutes()) { + const carry = source.indexOf('carryTorznabBlock('); + const write = source.search(/\.set\(\{[^}]*\bpasskey:/s); + const retire = source.indexOf('retireTorznabPasskey('); + // Assert they are there before comparing positions: `indexOf` answers + // -1 for absent, and -1 is less than every offset in the file, so the + // ordering check would pass loudest on the route that does neither. + expect(carry, `${path}: calls carryTorznabBlock`).toBeGreaterThan(-1); + expect(retire, `${path}: calls retireTorznabPasskey`).toBeGreaterThan(-1); + expect(carry, `${path}: carry before the write`).toBeLessThan(write); + expect(retire, `${path}: retire after the write`).toBeGreaterThan(write); + } + }); +}); diff --git a/apps/api/test/xml.test.ts b/apps/api/test/xml.test.ts index 9028d295..f974183a 100644 --- a/apps/api/test/xml.test.ts +++ b/apps/api/test/xml.test.ts @@ -79,3 +79,48 @@ describe('buildSearchXml — CDATA breakout (finding M5)', () => { expect(xml).not.toContain('Rls <b>'); }); }); + +describe('buildSearchXml — seeding obligations and infohash', () => { + it('emits infohash, minimumratio and minimumseedtime when the site sets them', () => { + const xml = buildSearchXml( + feed([ + item({ + infoHash: 'a'.repeat(40), + minimumRatio: 0.8, + minimumSeedTime: 172800, + }), + ]) + ); + + expect(xml).toContain(`<torznab:attr name="infohash" value="${'a'.repeat(40)}"/>`); + expect(xml).toContain('<torznab:attr name="minimumratio" value="0.8"/>'); + expect(xml).toContain('<torznab:attr name="minimumseedtime" value="172800"/>'); + }); + + it('omits an obligation the site does not impose rather than sending 0', () => { + // A stated 0 and an absent value mean the same thing to a client, and the + // stated one can be misread as "seed for zero seconds". + const xml = buildSearchXml( + feed([item({ infoHash: 'b'.repeat(40), minimumRatio: 0, minimumSeedTime: 0 })]) + ); + + expect(xml).toContain('name="infohash"'); + expect(xml).not.toContain('name="minimumratio"'); + expect(xml).not.toContain('name="minimumseedtime"'); + }); + + it('omits infohash entirely when the item carries none', () => { + // Mirrored (federated) releases are the case: we hold the hash, but the + // obligations belong to the origin instance, so only the hash is sent. + const xml = buildSearchXml(feed([item()])); + expect(xml).not.toContain('name="infohash"'); + }); + + it('carries the volume factors through unchanged', () => { + const xml = buildSearchXml( + feed([item({ downloadVolumeFactor: 0, uploadVolumeFactor: 2 })]) + ); + expect(xml).toContain('<torznab:attr name="downloadvolumefactor" value="0"/>'); + expect(xml).toContain('<torznab:attr name="uploadvolumefactor" value="2"/>'); + }); +}); diff --git a/apps/api/utils/account/eraseAccount.ts b/apps/api/utils/account/eraseAccount.ts index 81a1a47c..b43cd2ef 100644 --- a/apps/api/utils/account/eraseAccount.ts +++ b/apps/api/utils/account/eraseAccount.ts @@ -53,16 +53,37 @@ * ## What is kept, and on what basis * * Not everything touching the account goes. `notifications`, `hnr_tracking`, - * `bonus_events`, `invitations` and `reports` survive, attached to the scrubbed + * `bonus_events`, `invitations`, `reports` and — where the member was staff — + * their entries in `audit_log` survive, attached to the scrubbed * row. Each is either a record of an obligation between the tracker and other * members (a hit-and-run, an invitation tree, a report somebody else filed) or * part of the economy's audit trail, and none of them holds a raw identifier * once step 2 has run. Stated here because an unstated retention is * indistinguishable from an oversight. + * + * Cinq de plus, énoncées ici parce qu'elles sont exportées par + * `exportAccount` et donc déclarées comme données du membre — la liste + * s'arrêtait aux six ci-dessus et ces cinq-là étaient exactement des + * rétentions non énoncées : + * + * `bonus_grants`, `shop_purchases`, `freeleech_pool_contributions` + * Le grand livre de l'économie. Les retirer ne rend pas un compte + * anonyme — la ligne `users` porte déjà des totaux — mais falsifie le + * solde du site et l'historique d'un pot commun auquel d'autres + * membres ont contribué. + * `upload_requests`, `upload_request_fill_attempts` + * Une demande est du contenu avec lequel d'autres ont interagi, comme + * un message de forum : conservée sous un auteur anonymisé, au même + * titre. + * + * Aucune ne porte d'identifiant brut après l'étape 2. Ce qui était PUREMENT + * personnel — suivis fédérés, réactions, modèles de fiche, crédits fédérés — + * est désormais supprimé ; voir l'étape 2. */ import { and, eq, inArray, isNull, or, sql } from 'drizzle-orm'; import { randomBytes, randomUUID } from 'node:crypto'; import { db, schema } from '@trackarr/db'; +import { retireTorznabPasskey } from '~~/utils/torznabStats'; import { invalidateBanCache } from '~~/utils/adminAuth'; import { getFederationConfig, @@ -89,6 +110,19 @@ export interface EraseResult { * push is best-effort and never blocks the local erasure from committing. */ export async function eraseAccount(userId: string): Promise<EraseResult> { + /** + * The passkey as it is now, read before the transaction rotates it away. + * + * Needed after the commit to clear what is keyed on its hash outside + * Postgres — see the note further down. + */ + const [before] = await db + .select({ passkey: schema.users.passkey }) + .from(schema.users) + .where(eq(schema.users.id, userId)) + .limit(1); + const oldPasskey = before?.passkey ?? null; + // The member's identifiers, before we touch anything — needed to find the // partner-asserted links that mention them. const keys = await db @@ -139,7 +173,62 @@ export async function eraseAccount(userId: string): Promise<EraseResult> { await tx.delete(schema.userNotificationChannels).where(eq(schema.userNotificationChannels.userId, userId)); await tx.delete(schema.userNotificationRouting).where(eq(schema.userNotificationRouting.userId, userId)); await tx.delete(schema.userRoles).where(eq(schema.userRoles.userId, userId)); + /** + * The two personal tables this branch added. + * + * Deleted by hand for the reason the whole function exists: the `users` row + * SURVIVES an erasure, so no `ON DELETE` ever fires. Left behind, the saved + * filters kept matching uploads and writing notifications to a tombstone + * for ever, and the login history stayed attributed to the account — and + * readable through the moderator view — for its full retention period. + */ + await tx + .delete(schema.savedSearches) + .where(eq(schema.savedSearches.userId, userId)); + await tx.delete(schema.loginEvents).where(eq(schema.loginEvents.userId, userId)); await tx.delete(schema.torrentFavorites).where(eq(schema.torrentFavorites.userId, userId)); + + /* + * Cinq tables qui manquaient, trouvées en recoupant `exportAccount` avec + * ce fichier — le test décisif, puisque ce que l'article 15 déclare + * « vos données » ne peut pas survivre à l'article 17. + * + * Aucun `ON DELETE cascade` ne se déclenche jamais ici : la ligne `users` + * survit, c'est le choix acté, et toute clé étrangère vers elle se traite + * donc à la main. Ces cinq-là étaient restées attachées à la pierre + * tombale. + * + * `federated_follows` la liste des uploadeurs qu'un membre suit chez + * les partenaires, `remote_username` compris : + * un graphe social personnel. L'étape 1b efface + * `remote_identity_links` et l'étape 2 + * `federated_identities` ; celle-là avait été + * oubliée entre les deux. + * `message_reactions` qui a réagi à quel message, avec quelle clé. + * `room_message_reactions` idem, côté salon. + * `presentation_templates` les textes de fiche que le membre a écrits + * pour lui-même. Même classe que + * `saved_searches`, effacé juste au-dessus. + * `federation_credit_grants` le DID que l'effacement vient précisément + * de révoquer et d'annoncer comme retiré, gardé + * en local et joint au compte anonymisé — ce qui + * défait la révocation qu'on vient de publier. + */ + await tx + .delete(schema.federatedFollows) + .where(eq(schema.federatedFollows.localUserId, userId)); + await tx + .delete(schema.messageReactions) + .where(eq(schema.messageReactions.userId, userId)); + await tx + .delete(schema.roomMessageReactions) + .where(eq(schema.roomMessageReactions.userId, userId)); + await tx + .delete(schema.presentationTemplates) + .where(eq(schema.presentationTemplates.ownerId, userId)); + await tx + .delete(schema.federationCreditGrants) + .where(eq(schema.federationCreditGrants.localUserId, userId)); await tx .delete(schema.userFollows) .where( @@ -311,6 +400,36 @@ export async function eraseAccount(userId: string): Promise<EraseResult> { ) ); + // The staff audit log, on exactly the rule above: the pointer goes, the + // name stays. Banning a member is an act taken under authority, and an act + // under authority with no author is indefensible — an ex-moderator must not + // be able to un-sign their own decisions by closing their account. + // + // Done by hand rather than left to the FK: the row in `users` SURVIVES an + // erasure (that is the whole design — the catalogue hangs off it), so no + // ON DELETE ever fires and every reference has to be cleared here. + // + // What this costs, and it is the honest reading: the audit log keeps a + // username after erasure. It is kept on the same basis as the invitation + // tree and the reports the erasure already keeps — a record of an + // obligation between the tracker and OTHER members, which the person on + // one side of it cannot unilaterally erase. + await tx + .update(schema.auditLog) + .set({ actorId: null }) + .where(eq(schema.auditLog.actorId, userId)); + // Where they were the TARGET, though, the pointer and the label both go: + // being banned is not an act they took, it is a thing recorded about them. + await tx + .update(schema.auditLog) + .set({ targetId: null, targetLabel: erasedName }) + .where( + and( + eq(schema.auditLog.targetType, 'user'), + eq(schema.auditLog.targetId, userId) + ) + ); + // 3. Scrub the row itself. The passkey is rotated to a fresh unusable value // so any announce URL the member kept stops working; the SRP material is // replaced with random bytes no client can reproduce; the profile text and @@ -330,6 +449,13 @@ export async function eraseAccount(userId: string): Promise<EraseResult> { authSalt: randomBytes(24).toString('base64'), authVerifier: randomBytes(48).toString('base64'), passkey: randomUUID().replace(/-/g, ''), + // The two read keys go entirely rather than being rotated to an unusable + // value: unlike the passkey they are nullable, so "no key" is a state the + // schema already has a word for, and an erased account has nothing to + // read. Cleared by hand because the row survives an erasure — no ON + // DELETE ever fires here. + rssKey: null, + apiKey: null, totpSecret: null, totpEnabled: false, trustDevicesEnabled: false, @@ -339,6 +465,20 @@ export async function eraseAccount(userId: string): Promise<EraseResult> { .where(eq(schema.users.id, userId)); }); + /** + * The Torznab residue of the passkey we just rotated away. + * + * Keyed by a hash of the OLD value, so nothing in the transaction above + * touches it: an access block against an account that no longer exists, and + * up to seven days of request logs carrying an IP hash and a user agent. On + * the one route whose job is to leave nothing behind. + */ + if (oldPasskey) { + await retireTorznabPasskey(oldPasskey).catch((err) => { + console.warn('[erase] torznab residue survived:', (err as Error).message); + }); + } + // The cached gate must forget the old "ok" at once, or the account stays // reachable for up to the 60 s TTL behind a live cookie. await invalidateBanCache(userId); diff --git a/apps/api/utils/account/exportAccount.ts b/apps/api/utils/account/exportAccount.ts new file mode 100644 index 00000000..231bf922 --- /dev/null +++ b/apps/api/utils/account/exportAccount.ts @@ -0,0 +1,664 @@ +/** + * Export an account — the GDPR right of access (Art. 15) and the right to data + * portability (Art. 20). + * + * ## Why this is a mirror of `eraseAccount` + * + * Erasure already had to answer, precisely, "what of this person is held here" + * — it deletes some rows, scrubs fields on others, and states which retentions + * survive and on what basis. That inventory is the same one an export needs, + * read instead of written. So this file follows it table for table rather than + * inventing a second list, because two independently-maintained inventories of + * the same thing drift, and the direction they drift in is "the export forgot + * something the erasure knew about". + * + * If you add a personal table, it belongs in BOTH. + * + * ## Structured, machine-readable, and one file + * + * Art. 20 asks for a "structured, commonly used and machine-readable format". + * JSON is all three, and it is the only one this codebase can produce without + * taking a dependency — a zip writer would be a new package on a distroless + * image for the sake of a folder structure nobody needs. One document, one + * request, no archive to unpack. + * + * ## What is deliberately NOT in here + * + * Three exclusions, each with a reason, all of them declared in the payload + * itself under `notIncluded` so the reader is told rather than left to notice: + * + * - **Other people's data.** A follower list names members who followed this + * account; their identities are theirs, not this account's. Counted, never + * listed. Same for who used an invite, and for the other side of a + * conversation. + * - **Secrets, including this account's own.** Notification channels carry a + * webhook URL or a chat token, and they are encrypted at rest with a key + * this export has no business decrypting with. A file in a Downloads + * folder is a worse place for a live token than the database is. Channel + * *types* and their state are exported; the credential is not. + * - **The names inside a notification.** A notification this account received + * may name the moderator who acted on it, the member who used its invite, or + * quote a staff message. The notification, its type and its date are + * exported; those names are not — they are the same staff and third-party + * data the two entries above withhold, and a payload is not a loophole in + * that rule. + * - **Anti-cheat findings.** Art. 15 is not absolute — it yields where + * disclosure would prejudice the detection of abuse or the rights of + * others. Handing somebody the heuristics that flagged them is a recipe + * for evading the next check. An operator asked for these by a member (or + * by a regulator) can produce them from the moderation console. + * + * ## Bounded + * + * Every collection is capped and reports its own true total, so an account + * with 40 000 notifications produces a bounded document that SAYS it is + * bounded. An export that silently stops at the cap would be worse than one + * that refuses: the reader would take it for the whole record. + */ +import { and, count, desc, eq, isNull } from 'drizzle-orm'; +import { db, schema } from '@trackarr/db'; + +/** + * Rows per collection. Generous enough that a normal account is exported in + * full, small enough that the biggest imaginable one still fits in memory and + * in a browser's download. + */ +const CAP = 5000; + +/** + * Keys a notification payload may carry into the export. + * + * The payloads are written by whichever route emitted the notification, and + * several of them name somebody else: `actorUsername` is the moderator who + * banned this account, `inviteeUsername` is the member who used its invite, + * `preview` is 200 characters of a staff message, `uploaderUsername` is another + * member. Exporting the payload verbatim therefore handed out exactly the two + * things this file promises to withhold — staff identity and who used an invite + * — in a structured document, on one GET. + * + * A whitelist rather than a blocklist: a notification type added next year must + * not be able to widen this by accident. + */ +const PAYLOAD_KEYS_KEPT = new Set([ + 'reason', + 'amount', + 'itemName', + 'itemType', + 'torrentName', + 'infoHash', + 'label', + 'category', + 'points', + 'multiplier', + 'until', + 'seedTime', + 'requiredSeedTime', + 'expiresAt', + 'status', + 'count', +]); + +function safeNotificationPayload(payload: unknown): Record<string, unknown> { + if (!payload || typeof payload !== 'object') return {}; + const out: Record<string, unknown> = {}; + for (const [key, value] of Object.entries(payload as Record<string, unknown>)) { + if (PAYLOAD_KEYS_KEPT.has(key)) out[key] = value; + } + return out; +} + +/** A capped list, honest about what it left out. */ +interface Capped<T> { + total: number; + returned: number; + /** True when `total > returned` — the list is a prefix, not the record. */ + truncated: boolean; + items: T[]; +} + +function capped<T>(items: T[], total: number): Capped<T> { + return { + total, + returned: items.length, + truncated: total > items.length, + items, + }; +} + +/** + * `SELECT count(*)` for one table/predicate, unwrapped. + * + * Untyped on purpose: it is called against a dozen different tables and the + * only thing it needs from each is that Drizzle accepts it in `from()`. + * Spelling that out generically buys a signature nobody reads. + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +async function countOf(table: any, where: any): Promise<number> { + const [row] = await db.select({ value: count() }).from(table).where(where); + return row?.value ?? 0; +} + +export async function exportAccount(userId: string) { + const account = await db.query.users.findFirst({ + where: eq(schema.users.id, userId), + columns: { + // Identity and profile — everything the member typed or chose. + id: true, + username: true, + displayName: true, + bio: true, + language: true, + theme: true, + createdAt: true, + lastSeen: true, + // The economy's view of this account. + uploaded: true, + bonusUploaded: true, + downloaded: true, + bonusPoints: true, + invitesRemaining: true, + // Privacy preferences, which are themselves personal data: they record + // a choice this person made. + showLastSeen: true, + showAdultContent: true, + messagingReadReceipts: true, + anonymousUploads: true, + hideDownloadHistory: true, + restrictComments: true, + shareReputationFederated: true, + trustDevicesEnabled: true, + // Standing with the site. A sanction is this account's data and is + // exported; who imposed it is staff data and is not. + isBanned: true, + banReason: true, + bannedUntil: true, + // Whether a second factor exists — never the secret itself. + totpEnabled: true, + // Set only on an already-erased account; present so an export taken + // after an erasure is self-explanatory rather than mysteriously empty. + deletedAt: true, + }, + }); + + if (!account) return null; + + const [ + devices, + passkeys, + channels, + routing, + roles, + favourites, + following, + followerCount, + invitesCreated, + uploads, + uploadCount, + comments, + commentCount, + topics, + topicCount, + posts, + postCount, + snatches, + snatchCount, + bonus, + bonusCount, + purchases, + purchaseCount, + poolContributions, + reportsFiled, + requests, + requestFills, + ticketRows, + templates, + notificationRows, + notificationCount, + // Real totals for the nine collections that were reporting `items.length` + // as their own total — which made `truncated` permanently false and turned a + // 5000-row prefix into a document claiming to be the whole record. The + // header of this file calls that failure worse than refusing to export. + followingCount, + favouriteCount, + inviteCount, + templateCount, + requestCount, + requestFillCount, + ticketCount, + ticketMessageCount, + reportCount, + poolContributionCount, + // The two personal tables this branch added and did not bring here. The + // rule at the top of the file is that a personal table belongs in both the + // export and the erasure. + savedSearchRows, + savedSearchCount, + loginRows, + loginCount, + ] = await Promise.all([ + db.query.trustedDevices.findMany({ + where: eq(schema.trustedDevices.userId, userId), + // No `tokenHash`: it is a credential, and the label plus the dates are + // what tells a person which device this is. + columns: { label: true, createdAt: true, expiresAt: true, lastUsedAt: true }, + }), + db.query.webauthnCredentials.findMany({ + where: eq(schema.webauthnCredentials.userId, userId), + // No `publicKey`, no `credentialId` — identifiers of a key the browser + // holds, useless outside it and not something to copy around. + columns: { name: true, transports: true, createdAt: true, lastUsedAt: true }, + }), + db.query.userNotificationChannels.findMany({ + where: eq(schema.userNotificationChannels.userId, userId), + // `userConfig` is the encrypted destination + token. Excluded on + // purpose — see the note at the top of this file. + columns: { + channelType: true, + enabled: true, + lastTestStatus: true, + lastTestedAt: true, + createdAt: true, + }, + }), + db.query.userNotificationRouting.findMany({ + where: eq(schema.userNotificationRouting.userId, userId), + columns: { type: true, channelType: true }, + }), + db + .select({ + role: schema.roles.name, + assignedAt: schema.userRoles.assignedAt, + assignedManually: schema.userRoles.assignedManually, + }) + .from(schema.userRoles) + .innerJoin(schema.roles, eq(schema.roles.id, schema.userRoles.roleId)) + .where(eq(schema.userRoles.userId, userId)), + db + .select({ + infoHash: schema.torrents.infoHash, + name: schema.torrents.name, + favouritedAt: schema.torrentFavorites.createdAt, + }) + .from(schema.torrentFavorites) + .innerJoin( + schema.torrents, + eq(schema.torrents.id, schema.torrentFavorites.torrentId) + ) + .where(eq(schema.torrentFavorites.userId, userId)) + .limit(CAP), + // Who this account follows is its own choice, so it is exported by name. + db + .select({ + username: schema.users.username, + since: schema.userFollows.createdAt, + }) + .from(schema.userFollows) + .innerJoin(schema.users, eq(schema.users.id, schema.userFollows.followingId)) + .where(eq(schema.userFollows.followerId, userId)) + .limit(CAP), + // Followers are other people. Counted, not named. + countOf(schema.userFollows, eq(schema.userFollows.followingId, userId)), + db.query.invitations.findMany({ + where: eq(schema.invitations.createdBy, userId), + // `usedBy` identifies somebody else; `usedAt` says the same thing about + // this account's invite without naming them. + columns: { + code: true, + createdAt: true, + usedAt: true, + expiresAt: true, + }, + limit: CAP, + }), + db.query.torrents.findMany({ + where: eq(schema.torrents.uploaderId, userId), + columns: { + infoHash: true, + name: true, + size: true, + moderationStatus: true, + isActive: true, + createdAt: true, + }, + orderBy: [desc(schema.torrents.createdAt)], + limit: CAP, + }), + countOf(schema.torrents, eq(schema.torrents.uploaderId, userId)), + db.query.torrentComments.findMany({ + where: eq(schema.torrentComments.authorId, userId), + columns: { content: true, createdAt: true, updatedAt: true }, + orderBy: [desc(schema.torrentComments.createdAt)], + limit: CAP, + }), + countOf(schema.torrentComments, eq(schema.torrentComments.authorId, userId)), + db.query.forumTopics.findMany({ + where: eq(schema.forumTopics.authorId, userId), + columns: { title: true, createdAt: true, updatedAt: true }, + orderBy: [desc(schema.forumTopics.createdAt)], + limit: CAP, + }), + countOf(schema.forumTopics, eq(schema.forumTopics.authorId, userId)), + db.query.forumPosts.findMany({ + where: eq(schema.forumPosts.authorId, userId), + columns: { content: true, createdAt: true, updatedAt: true }, + orderBy: [desc(schema.forumPosts.createdAt)], + limit: CAP, + }), + countOf(schema.forumPosts, eq(schema.forumPosts.authorId, userId)), + // The snatch list. Exported regardless of `hideDownloadHistory`: that + // toggle hides the list from a browser session (a stolen cookie cannot + // enumerate it), and this route is behind a fresh-auth step-up. Refusing + // the member their own record here would be the toggle working against + // the person it protects. + db + .select({ + infoHash: schema.torrents.infoHash, + name: schema.torrents.name, + downloadedAt: schema.hnrTracking.downloadedAt, + completedAt: schema.hnrTracking.completedAt, + seedTime: schema.hnrTracking.seedTime, + requiredSeedTime: schema.hnrTracking.requiredSeedTime, + isHnr: schema.hnrTracking.isHnr, + isExempt: schema.hnrTracking.isExempt, + uploaded: schema.hnrTracking.uploaded, + downloaded: schema.hnrTracking.downloaded, + }) + .from(schema.hnrTracking) + .innerJoin(schema.torrents, eq(schema.torrents.id, schema.hnrTracking.torrentId)) + .where(eq(schema.hnrTracking.userId, userId)) + .orderBy(desc(schema.hnrTracking.downloadedAt)) + .limit(CAP), + countOf(schema.hnrTracking, eq(schema.hnrTracking.userId, userId)), + db.query.bonusGrants.findMany({ + where: eq(schema.bonusGrants.userId, userId), + columns: { source: true, amount: true, createdAt: true }, + orderBy: [desc(schema.bonusGrants.createdAt)], + limit: CAP, + }), + countOf(schema.bonusGrants, eq(schema.bonusGrants.userId, userId)), + db.query.shopPurchases.findMany({ + where: eq(schema.shopPurchases.userId, userId), + columns: { + itemNameSnapshot: true, + itemTypeSnapshot: true, + costPaid: true, + createdAt: true, + }, + orderBy: [desc(schema.shopPurchases.createdAt)], + limit: CAP, + }), + countOf(schema.shopPurchases, eq(schema.shopPurchases.userId, userId)), + db.query.freeleechPoolContributions.findMany({ + where: eq(schema.freeleechPoolContributions.userId, userId), + columns: { amount: true, createdAt: true }, + orderBy: [desc(schema.freeleechPoolContributions.createdAt)], + limit: CAP, + }), + // Reports this account filed. Not reports filed ABOUT it: those are + // somebody else's statement, and disclosing them would identify the + // reporter — the one thing a reporting system must not do. + db.query.reports.findMany({ + where: eq(schema.reports.reporterId, userId), + columns: { + targetType: true, + reason: true, + details: true, + status: true, + resolution: true, + withdrawnAt: true, + createdAt: true, + resolvedAt: true, + }, + orderBy: [desc(schema.reports.createdAt)], + limit: CAP, + }), + db.query.uploadRequests.findMany({ + where: eq(schema.uploadRequests.requesterId, userId), + columns: { + title: true, + description: true, + rewardPoints: true, + status: true, + createdAt: true, + filledAt: true, + validatedAt: true, + cancelledAt: true, + }, + orderBy: [desc(schema.uploadRequests.createdAt)], + limit: CAP, + }), + db.query.uploadRequestFillAttempts.findMany({ + where: eq(schema.uploadRequestFillAttempts.userId, userId), + columns: { status: true, createdAt: true, rejectedAt: true }, + orderBy: [desc(schema.uploadRequestFillAttempts.createdAt)], + limit: CAP, + }), + db.query.tickets.findMany({ + where: eq(schema.tickets.openedById, userId), + columns: { + number: true, + category: true, + subject: true, + status: true, + closureReason: true, + closingNote: true, + createdAt: true, + closedAt: true, + }, + orderBy: [desc(schema.tickets.createdAt)], + limit: CAP, + }), + db.query.presentationTemplates.findMany({ + where: eq(schema.presentationTemplates.ownerId, userId), + columns: { + name: true, + description: true, + category: true, + content: true, + visibility: true, + createdAt: true, + updatedAt: true, + }, + limit: CAP, + }), + db.query.notifications.findMany({ + where: eq(schema.notifications.userId, userId), + columns: { + type: true, + // The payload is filtered on the way out — see `safeNotificationPayload`. + payload: true, + link: true, + readAt: true, + createdAt: true, + }, + orderBy: [desc(schema.notifications.createdAt)], + limit: CAP, + }), + countOf(schema.notifications, eq(schema.notifications.userId, userId)), + countOf(schema.userFollows, eq(schema.userFollows.followerId, userId)), + countOf(schema.torrentFavorites, eq(schema.torrentFavorites.userId, userId)), + countOf(schema.invitations, eq(schema.invitations.createdBy, userId)), + countOf( + schema.presentationTemplates, + eq(schema.presentationTemplates.ownerId, userId) + ), + countOf(schema.uploadRequests, eq(schema.uploadRequests.requesterId, userId)), + countOf( + schema.uploadRequestFillAttempts, + eq(schema.uploadRequestFillAttempts.userId, userId) + ), + countOf(schema.tickets, eq(schema.tickets.openedById, userId)), + countOf(schema.ticketMessages, eq(schema.ticketMessages.authorId, userId)), + countOf(schema.reports, eq(schema.reports.reporterId, userId)), + countOf( + schema.freeleechPoolContributions, + eq(schema.freeleechPoolContributions.userId, userId) + ), + // Saved searches: the member's own stored filters, in their own words. + db.query.savedSearches.findMany({ + where: eq(schema.savedSearches.userId, userId), + columns: { + label: true, + query: true, + tags: true, + imdbId: true, + tmdbId: true, + tvdbId: true, + notify: true, + createdAt: true, + }, + limit: CAP, + }), + countOf(schema.savedSearches, eq(schema.savedSearches.userId, userId)), + /** + * Login history. The address is exported as the stored HASH, not as an + * address: this table never held a raw IP, and inventing one for the export + * would be inventing data. The hash is only comparable within one day — + * stated in the guide, and in the field name. + */ + db + .select({ + at: schema.loginEvents.createdAt, + outcome: schema.loginEvents.outcome, + method: schema.loginEvents.method, + ipHashDailyRotating: schema.loginEvents.ipHash, + userAgent: schema.loginEvents.userAgent, + }) + .from(schema.loginEvents) + .where(eq(schema.loginEvents.userId, userId)) + .orderBy(desc(schema.loginEvents.createdAt)) + .limit(CAP), + countOf(schema.loginEvents, eq(schema.loginEvents.userId, userId)), + ]); + + // Ticket messages, for the tickets just read. Second query rather than a + // join so a ticket with 200 replies does not multiply the ticket rows, and + // scoped to this author: a staff reply is on the ticket but is staff's text. + const myTicketMessages = await db.query.ticketMessages.findMany({ + where: and( + eq(schema.ticketMessages.authorId, userId), + eq(schema.ticketMessages.fromStaff, false) + ), + columns: { body: true, createdAt: true }, + orderBy: [desc(schema.ticketMessages.createdAt)], + limit: CAP, + }); + + // Messaging. Conversations this account takes part in, with the count of its + // own messages — never the messages themselves, and never the other + // participant's. Encrypted conversations cannot be read server-side at all + // (the key never leaves the members' browsers), so even the willing case is + // not available; an unencrypted one COULD be read, and is excluded on the + // same grounds — a two-party conversation is not one party's record. + const conversationCount = await countOf( + schema.conversationParticipants, + eq(schema.conversationParticipants.userId, userId) + ); + const sentMessageCount = await countOf( + schema.messages, + and(eq(schema.messages.authorId, userId), isNull(schema.messages.deletedAt)) + ); + + return { + /** + * Metadata about the export itself, so a file found later can be dated and + * placed without guessing. + */ + export: { + generatedAt: new Date().toISOString(), + /** Bump when the shape changes in a way a consumer would notice. */ + schemaVersion: 1, + subject: account.username, + rowCapPerCollection: CAP, + basis: [ + 'GDPR Art. 15 — right of access', + 'GDPR Art. 20 — right to data portability', + ], + }, + + account, + security: { trustedDevices: devices, passkeys, roles }, + notificationSettings: { channels, routing }, + + social: { + following: capped(following, followingCount), + /** Other people. A number, by design — see the note at the top. */ + followerCount, + favourites: capped(favourites, favouriteCount), + }, + + invitesCreated: capped(invitesCreated, inviteCount), + + contributions: { + uploads: capped(uploads, uploadCount), + torrentComments: capped(comments, commentCount), + forumTopics: capped(topics, topicCount), + forumPosts: capped(posts, postCount), + presentationTemplates: capped(templates, templateCount), + }, + + activity: { + snatches: capped(snatches, snatchCount), + notifications: capped( + notificationRows.map((n) => ({ + ...n, + payload: safeNotificationPayload(n.payload), + })), + notificationCount + ), + savedSearches: capped(savedSearchRows, savedSearchCount), + logins: capped(loginRows, loginCount), + }, + + economy: { + bonusLedger: capped(bonus, bonusCount), + shopPurchases: capped(purchases, purchaseCount), + freeleechPoolContributions: capped(poolContributions, poolContributionCount), + }, + + requests: { + opened: capped(requests, requestCount), + fillAttempts: capped(requestFills, requestFillCount), + }, + + support: { + tickets: capped(ticketRows, ticketCount), + myMessages: capped(myTicketMessages, ticketMessageCount), + }, + + reportsFiled: capped(reportsFiled, reportCount), + + messaging: { + conversationCount, + sentMessageCount, + note: 'Message bodies are not exported. See `notIncluded`.', + }, + + /** + * Said out loud rather than left to be noticed. An export whose omissions + * are undocumented is indistinguishable from an incomplete one. + */ + notIncluded: [ + { + what: 'Other members’ identities', + where: 'follower list, who used an invite, the other side of a conversation, who filed a report about this account', + why: 'Their data, not this account’s. Counted where a count is meaningful.', + }, + { + what: 'Credentials and secrets', + where: 'password verifier, passkey material, trusted-device tokens, TOTP secret, notification-channel tokens and webhook URLs, the announce passkey, the RSS and API keys', + why: 'A live credential in a downloaded file is a worse risk than one in the database. Channel types and state are exported; the credential is not.', + }, + { + what: 'Private message bodies', + where: 'direct messages and room messages', + why: 'A conversation belongs to both parties. Encrypted ones cannot be read server-side at all — the key never leaves the browser.', + }, + { + what: 'Anti-cheat findings', + where: 'automated flags raised on announces from this account', + why: 'Art. 15 yields where disclosure would prejudice the detection of abuse. An operator can produce these from the moderation console on request.', + }, + ], + }; +} diff --git a/apps/api/utils/account/loginLog.ts b/apps/api/utils/account/loginLog.ts new file mode 100644 index 00000000..b169bdf5 --- /dev/null +++ b/apps/api/utils/account/loginLog.ts @@ -0,0 +1,70 @@ +/** + * Recording how a session was opened, or refused. + * + * One helper called from the four places that authenticate, rather than four + * copies of an insert. Best-effort by contract: a login must never fail because + * its own log row did, so everything here is wrapped and swallowed. + * + * ## Why failures are recorded + * + * The failed attempt is the more useful half. There is no per-account lockout + * on this site — throttling is entirely per IP, so an attempt spread across + * addresses meets nothing at all — and this table is what makes such an attempt + * visible afterwards even though nothing stopped it at the time. + * + * ## The User-Agent is truncated + * + * At 200 characters. Long enough to tell a browser from a script, short enough + * that a hostile client cannot use the column as storage. + */ +import { randomUUID } from 'node:crypto'; +import type { H3Event } from 'h3'; +import { db, schema } from '@trackarr/db'; +import { hashIP } from '~~/utils/crypto'; +import { getClientIP } from '~~/utils/rateLimit'; + +export type LoginMethod = + | 'password' + | 'passkey' + | 'totp' + | 'recovery' + | 'trusted-device'; + +export type LoginOutcome = 'success' | 'failed'; + +const UA_MAX = 200; + +export async function recordLogin( + event: H3Event, + input: { + userId: string | null; + username: string; + method: LoginMethod; + outcome: LoginOutcome; + } +): Promise<void> { + try { + let ipHash: string | null = null; + try { + const ip = getClientIP(event); + ipHash = ip && ip !== 'unknown' ? hashIP(ip) : null; + } catch { + // An unresolvable address is not a reason to lose the event. + } + + await db.insert(schema.loginEvents).values({ + id: randomUUID(), + userId: input.userId, + username: input.username, + method: input.method, + outcome: input.outcome, + ipHash, + userAgent: (getHeader(event, 'user-agent') ?? '').slice(0, UA_MAX) || null, + }); + } catch (err) { + // Loud in the operator's log, invisible to the member: a login that + // succeeded and did not get a row is recoverable, a login that 500s + // because of its own bookkeeping is not. + console.error('[LoginLog] write failed:', (err as Error).message); + } +} diff --git a/apps/api/utils/account/readKeyAuth.ts b/apps/api/utils/account/readKeyAuth.ts new file mode 100644 index 00000000..9303ca54 --- /dev/null +++ b/apps/api/utils/account/readKeyAuth.ts @@ -0,0 +1,130 @@ +/** + * Authenticating a read surface — RSS, Torznab, the programmatic API. + * + * One resolver for all of them, because the three used to disagree in ways + * that were invisible until somebody hit one: the Torznab gate lowercased the + * key and shape-checked it, the RSS gate did neither, so the same key could + * work on one surface and fail on the other. That is fixed here by there being + * one place. + * + * ## Order + * + * 1. **The session cookie**, if there is one. A member browsing the site gets + * their own feed without a key in the URL. + * 2. **The surface's own key** — `rssKey` for feeds and Torznab, `apiKey` for + * programmatic calls. + * 3. **The announce passkey**, while `legacy_passkey_read_access` allows it. + * + * Step 3 is a migration path, not a design. The passkey was the only key these + * surfaces ever took, so it is in every feed URL a member has configured + * anywhere; removing it on the day of the split would break all of them at once + * — the exact breakage the split exists to prevent. An operator turns the + * setting off when their members have moved over, and the member-facing keys + * page says so. + */ +import type { H3Event } from 'h3'; +import { requireAuthSession } from '~~/utils/adminAuth'; +import { isLegacyPasskeyReadAllowed } from '~~/utils/settings'; +import { findByReadKey, type ReadKeyHolder, type ReadKeyKind } from './readKeys'; +import { db, schema } from '@trackarr/db'; +import { eq } from 'drizzle-orm'; + +/** How the caller proved who they are — used only for logging and headers. */ +export type ReadAuthVia = 'session' | 'key' | 'legacy-passkey'; + +export interface ReadAuthResult { + user: ReadKeyHolder; + via: ReadAuthVia; +} + +/** + * The passkey lookup, kept beside the key one so both apply the same + * post-conditions (not banned, not erased) rather than each remembering. + */ +async function findByPasskey(raw: string): Promise<ReadKeyHolder | null> { + const value = raw.toLowerCase(); + // 32 or 40: the codebase has minted both lengths over time. New keys are 40; + // this gate stays permissive because it is looking at a legacy credential. + if (!/^[a-f0-9]{32}$/.test(value) && !/^[a-f0-9]{40}$/.test(value)) return null; + + const [row] = await db + .select({ + id: schema.users.id, + username: schema.users.username, + passkey: schema.users.passkey, + isAdmin: schema.users.isAdmin, + isModerator: schema.users.isModerator, + isBanned: schema.users.isBanned, + uploaded: schema.users.uploaded, + downloaded: schema.users.downloaded, + showAdultContent: schema.users.showAdultContent, + deletedAt: schema.users.deletedAt, + }) + .from(schema.users) + .where(eq(schema.users.passkey, value)) + .limit(1); + + if (!row || row.isBanned || row.deletedAt) return null; + return row; +} + +/** + * Resolve the caller of a read surface, or throw 401. + * + * `kind` decides which dedicated key is accepted: a member's RSS key must not + * open the programmatic API and vice versa, or the split has bought nothing. + */ +export async function requireReadAccess( + event: H3Event, + kind: ReadKeyKind +): Promise<ReadAuthResult> { + // 1. A live session, if the request carries one. + try { + const session = await requireAuthSession(event); + const [row] = await db + .select({ + id: schema.users.id, + username: schema.users.username, + passkey: schema.users.passkey, + isAdmin: schema.users.isAdmin, + isModerator: schema.users.isModerator, + isBanned: schema.users.isBanned, + uploaded: schema.users.uploaded, + downloaded: schema.users.downloaded, + showAdultContent: schema.users.showAdultContent, + deletedAt: schema.users.deletedAt, + }) + .from(schema.users) + .where(eq(schema.users.id, session.user.id)) + .limit(1); + // Read from the row rather than the cookie: the sealed session is seven + // days old at worst, and `showAdultContent` in particular decides what a + // feed contains. + if (row && !row.isBanned && !row.deletedAt) { + return { user: row, via: 'session' }; + } + } catch { + // No session — fall through to the key paths. + } + + const query = getQuery(event); + const supplied = + (typeof query.apikey === 'string' && query.apikey) || + (typeof query.passkey === 'string' && query.passkey) || + (typeof query.rsskey === 'string' && query.rsskey) || + null; + + if (supplied) { + // 2. The surface's own key. + const byKey = await findByReadKey(kind, supplied); + if (byKey) return { user: byKey, via: 'key' }; + + // 3. The announce passkey, while it is still allowed here. + if (await isLegacyPasskeyReadAllowed()) { + const byPasskey = await findByPasskey(supplied); + if (byPasskey) return { user: byPasskey, via: 'legacy-passkey' }; + } + } + + throw createError({ statusCode: 401, message: 'Authentication required' }); +} diff --git a/apps/api/utils/account/readKeys.ts b/apps/api/utils/account/readKeys.ts new file mode 100644 index 00000000..c85a9ecc --- /dev/null +++ b/apps/api/utils/account/readKeys.ts @@ -0,0 +1,182 @@ +/** + * The two read keys — minting, rotating, and resolving a caller from one. + * + * ## What each is for + * + * | key | authenticates | can announce | + * | --- | --- | --- | + * | `passkey` | the tracker's announce and scrape | **yes** | + * | `rssKey` | RSS feeds, the Torznab endpoint | no | + * | `apiKey` | programmatic calls | no | + * + * One secret used to do all three. A member who pasted their feed URL into a + * third-party service was handing over the credential that announces for them, + * and the only remedy — rotating the passkey — broke every torrent in their + * client at once. The whole point of the split is that revoking the key you + * gave away costs you nothing else. + * + * ## Minted on demand + * + * Registration does not create them. A member who never wires up a feed reader + * should not be carrying two live secrets they have never seen, and a column + * that is null until someone asks is a column an attacker cannot use. + * `ensureKey` mints on first read and is safe to call repeatedly. + * + * ## 40 hex, like `generatePasskey` + * + * The codebase already has three generators producing 32, 40 and 32 characters, + * which is why the Torznab gate accepts "32 or 40". New keys use one length so + * the next gate can be exact. + */ +import { and, eq, isNull } from 'drizzle-orm'; +import { db, schema } from '@trackarr/db'; +import { generatePasskey } from '~~/utils/auth'; + +export type ReadKeyKind = 'rss' | 'api'; + +const COLUMN = { + rss: schema.users.rssKey, + api: schema.users.apiKey, +} as const; + +/** `^[a-f0-9]{40}$` — what `generatePasskey` produces, lowercased. */ +export const READ_KEY_PATTERN = /^[a-f0-9]{40}$/; + +export function isReadKeyShaped(value: string): boolean { + return READ_KEY_PATTERN.test(value.toLowerCase()); +} + +/** + * The member's key, minting one if they have none yet. + * + * The write is guarded on the column still being null, so two concurrent first + * reads cannot end with one of them holding a key the row no longer carries. + * The loser re-reads and returns what actually landed. + */ +export async function ensureKey( + userId: string, + kind: ReadKeyKind +): Promise<string> { + const column = COLUMN[kind]; + + const [existing] = await db + .select({ value: column }) + .from(schema.users) + .where(eq(schema.users.id, userId)) + .limit(1); + if (existing?.value) return existing.value; + + const minted = generatePasskey(); + const [claimed] = await db + .update(schema.users) + .set({ [kind === 'rss' ? 'rssKey' : 'apiKey']: minted }) + // The guard this function's docstring always described and did not have. + // Without it two concurrent first reads — a double click on the reveal + // button is enough — both minted, the last write won the row, and each + // request returned ITS OWN value: one member walked away with a key the row + // does not carry, and a 401 with no explanation. + .where(and(eq(schema.users.id, userId), isNull(column))) + .returning({ value: column }); + if (claimed?.value) return claimed.value; + + // We lost the race: read what actually landed rather than returning the value + // we minted and threw away. + const [settled] = await db + .select({ value: column }) + .from(schema.users) + .where(eq(schema.users.id, userId)) + .limit(1); + return settled?.value ?? minted; +} + +/** + * Replace a key. Whatever the member handed out stops working immediately — + * there is no cache in front of these, by design. + */ +export async function rotateKey( + userId: string, + kind: ReadKeyKind +): Promise<string> { + const minted = generatePasskey(); + await db + .update(schema.users) + .set({ [kind === 'rss' ? 'rssKey' : 'apiKey']: minted }) + .where(eq(schema.users.id, userId)); + return minted; +} + +/** Drop a key without minting a replacement. */ +export async function revokeKey( + userId: string, + kind: ReadKeyKind +): Promise<void> { + await db + .update(schema.users) + .set({ [kind === 'rss' ? 'rssKey' : 'apiKey']: null }) + .where(eq(schema.users.id, userId)); +} + +/** + * The columns every read-key holder lookup wants. Shared so the three call + * sites project the same fields — a caller resolved by RSS key and one + * resolved by passkey must be the same shape or the routes downstream start + * branching on how somebody authenticated. + */ +const HOLDER_COLUMNS = { + id: schema.users.id, + username: schema.users.username, + passkey: schema.users.passkey, + isAdmin: schema.users.isAdmin, + isModerator: schema.users.isModerator, + isBanned: schema.users.isBanned, + uploaded: schema.users.uploaded, + downloaded: schema.users.downloaded, + showAdultContent: schema.users.showAdultContent, + deletedAt: schema.users.deletedAt, +} as const; + +/** + * Written out rather than mapped over `HOLDER_COLUMNS`: the mapped form drops + * nullability (`deletedAt` came back as `Date` instead of `Date | null`), and a + * type that quietly disagrees with the column it describes is worse than one + * that has to be kept in step by hand. + */ +export interface ReadKeyHolder { + id: string; + username: string; + passkey: string; + isAdmin: boolean; + isModerator: boolean; + isBanned: boolean; + uploaded: number; + downloaded: number; + showAdultContent: boolean; + deletedAt: Date | null; +} + +/** + * Resolve whoever holds this key, or null. + * + * Shape-checked before it touches the database: a value that cannot be a key + * is not worth a query, and refusing early keeps an arbitrary string out of a + * `WHERE`. Lowercased, because keys are stored lowercase and a member copying + * one out of a config file may well have uppercased it — the Torznab gate + * already lowercases and the RSS one did not, which meant the same key worked + * on one surface and failed on the other. + */ +export async function findByReadKey( + kind: ReadKeyKind, + raw: string +): Promise<ReadKeyHolder | null> { + const value = raw.toLowerCase(); + if (!isReadKeyShaped(value)) return null; + + const [row] = await db + .select(HOLDER_COLUMNS) + .from(schema.users) + .where(eq(COLUMN[kind], value)) + .limit(1); + + if (!row || row.isBanned || row.deletedAt) return null; + return row; +} diff --git a/apps/api/utils/adminAuth.ts b/apps/api/utils/adminAuth.ts index 9a0da3f4..d1abb789 100644 --- a/apps/api/utils/adminAuth.ts +++ b/apps/api/utils/adminAuth.ts @@ -4,6 +4,16 @@ import { db } from '@trackarr/db'; import { bannedIps, users } from '@trackarr/db/schema'; import { redis } from '../redis/client'; import { getSessionId } from './session'; +// Le cache de rôles vit dans son propre module depuis que `session.ts` en a +// besoin : voir l'en-tête de `liveRoles.ts`. +// +// PAS de ré-export ici. Il y en avait un, pour ne pas toucher aux appelants — +// mais Nitro auto-importe `apps/api/utils/`, voyait donc `readLiveRoles` et +// `invalidateRoleCache` déclarés deux fois, et en ignorait un au hasard +// documenté (« Duplicated imports … has been ignored »). C'est exactement la +// collision qui a fait servir la mauvaise `formatSize` pendant des mois côté +// web. Les deux appelants explicites importent depuis `liveRoles` directement. +import { readLiveRoles } from './liveRoles'; import { isFreshAuth } from './twoFactor'; /** @@ -83,74 +93,6 @@ export async function invalidateBanCache(userId: string): Promise<void> { } } -/** - * Live staff-role lookup, cached for 60 s — backs the role - * re-validation in `requireModeratorSession` / `requireAdminSession`. - * - * The session cookie is a sealed, stateless 7-day token that bakes - * in `isAdmin` / `isModerator` at login time. Without this, a user - * demoted for cause kept a cookie that still asserted staff and - * could keep hitting admin/mod APIs for up to 7 days (finding M2). - * Re-reading the authoritative flags here (and bumping the cache on - * role change) closes that window to ≤ 60 s, mirroring the ban - * cache. Returns null when the user no longer exists. - */ -const ROLE_CACHE_TTL_S = 60; -const roleCacheKey = (userId: string) => `auth:role:${userId}`; - -export async function readLiveRoles( - userId: string -): Promise<{ isAdmin: boolean; isModerator: boolean; isOwner: boolean } | null> { - try { - const cached = await redis.get(roleCacheKey(userId)); - if (cached) { - const p = JSON.parse(cached) as { a: boolean; m: boolean; o?: boolean }; - // A payload written before `o` existed is treated as a MISS rather than - // as `isOwner: false`. Otherwise the deploy that adds ownership answers - // 403 to the owner for up to the cache TTL — and the one thing that - // would fix it is the console they cannot reach. - if (p.o !== undefined) { - return { isAdmin: !!p.a, isModerator: !!p.m, isOwner: !!p.o }; - } - } - } catch { - /* fall through to DB */ - } - const [row] = await db - .select({ - isAdmin: users.isAdmin, - isModerator: users.isModerator, - isOwner: users.isOwner, - }) - .from(users) - .where(eq(users.id, userId)) - .limit(1); - if (!row) return null; - try { - await redis.setex( - roleCacheKey(userId), - ROLE_CACHE_TTL_S, - JSON.stringify({ a: row.isAdmin, m: row.isModerator, o: row.isOwner }) - ); - } catch { - /* no-op */ - } - return { - isAdmin: row.isAdmin, - isModerator: row.isModerator, - isOwner: row.isOwner, - }; -} - -/** Drop the cached role state. Call from any path that changes a - * user's `is_admin` / `is_moderator` (role-change endpoint). */ -export async function invalidateRoleCache(userId: string): Promise<void> { - try { - await redis.del(roleCacheKey(userId)); - } catch { - /* no-op */ - } -} /** * Cached IP-ban lookup — backs the security middleware. @@ -253,6 +195,23 @@ async function refreshSessionRoles( export async function requireAuthSession(event: H3Event) { const session = await requireUserSession(event); + /** + * Remember who is acting, for the audit log. + * + * Set HERE — on the plain authentication gate — and not in the staff gates + * below, on purpose. A member who aims a request at `/api/admin/**` and takes + * a 403 from `requireAdminSession` has already passed this line, so the + * attempt is recorded with their name on it. That is the row an operator + * most wants: a privilege escalation being tried is worth more than the + * hundredth successful ban. + * + * The staff flags are re-read from the live role a few lines further down in + * the staff gates, and they mutate `session.user` in place — so by the time + * the audit hook reads this object it holds the authoritative role, not the + * one the sealed cookie asserted. + */ + event.context.auditActor = session.user; + // Skip DB check if already verified by middleware (per-request // memoisation — distinct from the Redis cache). if (event.context.authChecked) { @@ -363,58 +322,13 @@ export async function requireFreshAuth(event: H3Event): Promise<void> { } /** - * Auth gate for endpoints that need to be reachable by both browser - * sessions (cookie-based) and external clients that authenticate by - * passkey/apikey (RSS readers, *Arr-style integrations). Tries the - * session cookie first, then falls back to a `?apikey=` or - * `?passkey=` query parameter. - * - * Returns `{ user }` shaped like a session for callers, regardless of - * which path matched. On failure, throws 401 — same shape as - * `requireUserSession` so callers don't need a separate error path. + * Read-surface authentication (RSS, Torznab, the programmatic API) lives in + * `utils/account/readKeyAuth.requireReadAccess`. * - * The DB-side `isBanned` check still runs: a banned user can't slip - * past via passkey just because their session was already cleared. + * It used to live here as `requireSessionOrApikey`, which accepted `?apikey=` + * or `?passkey=` against `users.passkey` with no shape check and no + * lowercasing — while the Torznab gate did both, so the same key could work on + * one surface and fail on the other. The replacement resolves a session, then + * the surface's own key, then the announce passkey while an operator still + * allows it. */ -export async function requireSessionOrApikey(event: H3Event) { - // 1. Try the regular session path (cheap, also covers banned-user - // invalidation as a side effect). - try { - return await requireAuthSession(event); - } catch { - // fall through to apikey - } - - const query = getQuery(event); - const apikey = - (typeof query.apikey === 'string' && query.apikey) || - (typeof query.passkey === 'string' && query.passkey) || - null; - if (!apikey) { - throw createError({ statusCode: 401, message: 'Authentication required' }); - } - - const [user] = await db - .select({ - id: users.id, - username: users.username, - passkey: users.passkey, - isAdmin: users.isAdmin, - isModerator: users.isModerator, - isBanned: users.isBanned, - uploaded: users.uploaded, - downloaded: users.downloaded, - // Adult content opt-in flag carried alongside the rest so - // RSS / Torznab consumers don't need to re-query for it. - showAdultContent: users.showAdultContent, - }) - .from(users) - .where(eq(users.passkey, apikey)) - .limit(1); - - if (!user || user.isBanned) { - throw createError({ statusCode: 401, message: 'Authentication required' }); - } - - return { user }; -} diff --git a/apps/api/utils/audit.ts b/apps/api/utils/audit.ts new file mode 100644 index 00000000..f83ff94f --- /dev/null +++ b/apps/api/utils/audit.ts @@ -0,0 +1,277 @@ +/** + * The staff audit log — how a row gets written, and how a route sharpens it. + * + * ## Two halves + * + * **The floor** is a Nitro `afterResponse` hook (`plugins/audit-log.ts`). It + * sees every request, and for a mutating one under `/api/admin/**` or + * `/api/mod/**` by an authenticated staffer it writes a row: who, what method, + * what path, what status, when. No route has to do anything, so coverage is a + * property of the plumbing rather than of somebody remembering — which is the + * whole reason the previous per-route logs (the one on the route that reads + * private mail, the moderation thread, the report tombstone) each covered + * exactly one surface. + * + * **The ceiling** is `auditDetail(event, …)`. A route that knows more than the + * URL does — which member, which setting, from what to what — calls it, and the + * hook merges what it said over the derived values. Optional everywhere: a + * route that never calls it still appears in the log. + * + * ## What is never recorded + * + * Request bodies, wholesale. They carry passwords, panic passwords, channel + * tokens and 2FA secrets, and a log that swallowed them would be a credential + * store with a listing page. A route wanting a diff passes exactly the fields + * it means, through `changes`. + * + * Query strings are stripped from `path` for the same reason: `?q=` on a member + * search is somebody's name. + * + * ## Failure is silent, and that is deliberate + * + * A failed audit write must never fail the request that caused it. A moderator + * whose ban went through and whose log row did not is recoverable; a ban that + * 500s because the log table is full is an outage. The write is best-effort, + * after the response, and logs its own failure to stderr where the operator's + * log shipper will see it. + */ +import type { H3Event } from 'h3'; +import { randomUUID } from 'node:crypto'; +import { db, schema } from '@trackarr/db'; +import { hashIP } from './crypto'; +import { getClientIP } from './rateLimit'; + +/** What a route may add to its own audit row. Every field is optional. */ +export interface AuditDetail { + /** Stable dotted key, e.g. `user.ban`. Overrides the derived action. */ + action?: string; + targetType?: string; + targetId?: string; + targetLabel?: string; + /** `{ field: { from, to } }`, or whatever shape reads clearest. */ + changes?: Record<string, unknown>; +} + +/** + * Attach (or extend) the audit detail for the request in flight. + * + * Merges rather than replaces, so a route can name its action early — before a + * guard can throw — and fill in the target once it has loaded it. Called on a + * request the hook will not log (a GET, a non-staff path), it is a no-op that + * costs one property write. + */ +export function auditDetail(event: H3Event, detail: AuditDetail): void { + const existing = (event.context.auditDetail ?? {}) as AuditDetail; + event.context.auditDetail = { + ...existing, + ...detail, + changes: detail.changes + ? { ...(existing.changes ?? {}), ...detail.changes } + : existing.changes, + }; +} + +/** + * `POST /api/admin/users/3f2b.../ban` → `admin.users.ban`. + * + * A fallback, and it has to be a decent one: most routes will never call + * `auditDetail`, so this is what the listing shows for them, and it is what the + * action filter groups on. An identifier left in the name would make every row + * its own category and the filter useless. + * + * `paramValues` is how that is done exactly rather than by guessing: h3 knows + * which segments matched a route parameter, so those are removed by value. An + * end-to-end run is what showed the guessing was not enough on its own — + * `DELETE /api/admin/federation/peers/does-not-exist` produced + * `admin.federation.peers.does-not-exist.delete`, because a slug-shaped peer id + * looks exactly like a sub-resource name. + * + * The shape heuristics stay as a fallback for a call site with no params to + * hand (a test, a route matched without them). + */ +export function deriveAction( + method: string, + path: string, + paramValues: readonly string[] = [] +): string { + /** + * Route parameters are dropped by POSITION, not by value. + * + * By value, any segment that happened to equal a parameter's value went too — + * so a member called `mutes` turned `mod/room/mutes/:username` into + * `mod.room.delete`, and an admin filtering the register by action key would + * never see those lines. Usernames are 3–20 characters with no reserved list, + * so `mod`, `room` and `mutes` are all registrable. + * + * The positions come from the values: a parameter's value appears exactly + * where the router matched it, and taking the FIRST unclaimed occurrence of + * each value is what makes this positional rather than a set membership test. + */ + const segments = path.replace(/^\/api\//, '').split('/').filter(Boolean); + const dropped = new Set<number>(); + for (const value of paramValues.filter(Boolean)) { + const at = segments.findIndex((seg, i) => seg === value && !dropped.has(i)); + if (at >= 0) dropped.add(at); + } + const parts = segments + .filter((_, i) => !dropped.has(i)) + // Identifier-shaped: a UUID, a 40-hex infohash, a long opaque id, or a + // bare number. `slug`-shaped segments are kept — they name things. + .filter( + (p) => + !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(p) && + !/^[0-9a-f]{32,}$/i.test(p) && + !/^\d+$/.test(p) && + p.length < 40 + ); + + const verb = + { + POST: 'create', + PUT: 'update', + PATCH: 'update', + DELETE: 'delete', + }[method.toUpperCase()] ?? method.toLowerCase(); + + const tail = parts[parts.length - 1]; + // A route whose last segment is already a verb (`ban`, `unban`, `revoke`, + // `panic`) reads worse with one appended: `admin.users.ban.create`. + const tailIsVerb = + !!tail && + /^(ban|unban|revoke|suspend|block|approve|reject|resolve|withdraw|panic|test|send|retry|rotate|reset|refresh|validate|cancel|fill|close|reopen|assign|pin|unpin|lock|unlock|promote|demote|clear|sweep|flush|import|export)$/.test( + tail + ); + + const base = parts.join('.') || 'unknown'; + return tailIsVerb ? base : `${base}.${verb}`; +} + +/** Methods that change something. A GET is not audited. */ +const MUTATING = new Set(['POST', 'PUT', 'PATCH', 'DELETE']); + +/** + * Is this a request the audit log is for? + * + * Staff consoles only. Member-facing mutations are not staff actions and + * logging them would turn a register of authority into a record of everybody's + * browsing — which the privacy toggles elsewhere in this codebase exist to + * prevent. + */ +/** + * Member-facing paths where a staff member exercises a staff power. + * + * The console prefixes are not the whole story, and "who did what, across the + * whole console" was too generous a claim: a moderator deletes a torrent + * through `DELETE /api/torrents/:hash`, a comment through + * `/api/torrents/comments/:id`, moderates the forum through `/api/forum/...`, + * and removes a message through the messaging routes. Every one of those is an + * act of authority against somebody else's content, and none of them left a row. + * + * Matched only when the ACTOR is staff, which is what keeps the register from + * becoming a log of everybody's activity — the thing the predicate above is + * careful to avoid. A moderator deleting their own comment is audited too, and + * that is the right side to err on: the register records what authority did, + * and it cannot know intent. + */ +const STAFF_REACH: readonly RegExp[] = [ + /^\/api\/torrents\/[^/]+$/, + /^\/api\/torrents\/[^/]+\/(tags|federate-swarm|index)$/, + /^\/api\/torrents\/comments\/[^/]+$/, + /^\/api\/forum\//, + /^\/api\/messaging\/room\/messages\/[^/]+$/, + /^\/api\/messaging\/conversations\/[^/]+\/messages\/[^/]+$/, + /^\/api\/requests\/[^/]+\/comments\/[^/]+$/, + /^\/api\/tickets\/[^/]+\//, +]; + +export function isAuditable( + method: string, + path: string, + actorIsStaff = false +): boolean { + if (!MUTATING.has(method.toUpperCase())) return false; + if (path.startsWith('/api/admin/') || path.startsWith('/api/mod/')) return true; + return actorIsStaff && STAFF_REACH.some((re) => re.test(path)); +} + +/** + * The values h3 bound to route parameters for this request, if any. + * + * Defensive about the shape: this reads `event.context` from inside a hook + * that runs after the response, and a missing or oddly-typed `params` must + * degrade to "no params" rather than throw inside the log. + */ +function routeParamValues(event: H3Event): string[] { + const params = event.context?.params; + if (!params || typeof params !== 'object') return []; + return Object.values(params as Record<string, unknown>).filter( + (v): v is string => typeof v === 'string' && v.length > 0 + ); +} + +export interface AuditActor { + id: string; + username: string; + isAdmin?: boolean; + isModerator?: boolean; + isOwner?: boolean; +} + +/** `owner` outranks `admin` outranks `moderator`. */ +function roleOf(actor: AuditActor): string { + if (actor.isOwner) return 'owner'; + if (actor.isAdmin) return 'admin'; + if (actor.isModerator) return 'moderator'; + // Reached only if a staff route ever stops being staff-gated. Recorded as + // what it is rather than silently promoted. + return 'member'; +} + +/** + * Write one row. Never throws. + * + * `statusCode` is recorded whatever it is, failures included: a run of 403s + * from one account is a signal, and a log that kept only the successes would + * hide exactly the attempts worth seeing. + */ +export async function writeAuditEntry( + event: H3Event, + actor: AuditActor, + statusCode: number +): Promise<void> { + const detail = (event.context.auditDetail ?? {}) as AuditDetail; + // `event.path` carries the query string; the audit row must not. + const path = (event.path ?? '').split('?')[0] ?? ''; + const method = (event.method ?? 'GET').toUpperCase(); + + let ipHash: string | null = null; + try { + const ip = getClientIP(event); + ipHash = ip ? hashIP(ip) : null; + } catch { + // An unresolvable client IP is not a reason to lose the entry. + } + + try { + await db.insert(schema.auditLog).values({ + id: randomUUID(), + actorId: actor.id, + actorName: actor.username, + actorRole: roleOf(actor), + action: detail.action ?? deriveAction(method, path, routeParamValues(event)), + method, + path, + targetType: detail.targetType ?? null, + targetId: detail.targetId ?? null, + targetLabel: detail.targetLabel ?? null, + changes: detail.changes ?? null, + statusCode, + actorIpHash: ipHash, + }); + } catch (err) { + // Loud in the operator's logs, invisible to the request. See the note at + // the top: a ban that went through with no row is recoverable, a ban that + // 500s because of its own log entry is not. + console.error('[Audit] write failed:', (err as Error).message); + } +} diff --git a/apps/api/utils/auth.ts b/apps/api/utils/auth.ts index 6c1be182..7feb4da0 100644 --- a/apps/api/utils/auth.ts +++ b/apps/api/utils/auth.ts @@ -1,6 +1,28 @@ // Admin API-key gate. The session-based path lives in adminAuth.ts; this // file is for header-based access only (X-Admin-Key or `Authorization: // Bearer …`). Constant-time comparison prevents key recovery via timing. +// +// AUCUNE ROUTE N'APPELLE `requireAdmin` NI `isAdmin` AUJOURD'HUI. +// +// Vérifié le 2026-09-02 : rien sous `routes/`, `middleware/` ni `plugins/` +// ne les invoque, et `x-admin-key` n'apparaît qu'ici et dans la liste de +// masquage de `logger.ts`. Le panneau d'administration s'authentifie par +// session (`requireAdminSession`, dans adminAuth.ts) — pas par cette clé. +// +// Cela mérite d'être écrit, parce que toute la plomberie autour donne +// l'impression du contraire : `ADMIN_API_KEY` figure dans `.env.example`, le +// chart Helm le génère sur 48 octets et le garde stable entre deux mises à +// jour, `docker-compose.prod.yml` le passe au conteneur, et trois pages du +// guide le présentaient comme obligatoire — dont une qui affirmait que +// l'application refuse de démarrer sans lui. C'est faux : +// `plugins/00.secrets.ts` n'exige que `NUXT_SESSION_SECRET` et +// `IP_HASH_SECRET`. Les trois pages ont été corrigées le même jour. +// +// La porte n'est pas supprimée pour autant : elle est correcte (comparaison +// à temps constant, 503 quand la clé n'est pas configurée plutôt qu'un +// passe-droit en développement), et son coût est nul tant qu'on ne l'appelle +// pas. Ce qui était nuisible, c'était la documentation qui la faisait passer +// pour une protection active. import { randomBytes } from 'crypto'; diff --git a/apps/api/utils/bittorrentV2.ts b/apps/api/utils/bittorrentV2.ts index 4c662de0..a0602c0c 100644 --- a/apps/api/utils/bittorrentV2.ts +++ b/apps/api/utils/bittorrentV2.ts @@ -41,6 +41,16 @@ import { createHash } from 'node:crypto'; export interface V2Content { /** SHA-256 of the bencoded `info` dict, hex. Torrent-specific (hybrid announce). */ infoHashV2: string; + /** + * The first 20 bytes of `infoHashV2`, hex — what a v2 or hybrid client + * actually sends to the tracker. + * + * BEP 52 keeps the SHA-256 for content addressing but the tracker and DHT + * protocols were built around 20-byte hashes, so a v2 announce carries the + * SHA-256 truncated to 20 bytes. That is the value the announce path matches + * on; it is derived rather than stored, so the two can never disagree. + */ + infoHashV2Short: string; /** Cross-tracker content key over the sorted per-file roots, hex. */ contentRootV2: string; /** Per-file Merkle roots, sorted by path. `root` is '' for a zero-length file. */ @@ -109,6 +119,109 @@ function collectRoots( return true; } +/** + * Where the `info` dictionary's bytes start and end inside a `.torrent`. + * + * An infohash — v1 or v2 — is the hash of the ORIGINAL bytes of the info dict, + * not of a re-encoding of the decoded value. The two agree for a canonical + * torrent (sorted keys, valid UTF-8 paths) and diverge for everything else, and + * "everything else" exists in the wild: a client that emits keys out of order, + * or a path that is not valid UTF-8, which a decoder surfaces as something it + * cannot round-trip. + * + * The divergence used to be affordable because nothing matched on the v2 hash + * — the note further down said so. The announce path matches on it now, and a + * hash that is *usually* right is exactly the failure mode nobody would find: + * a member with an unusual client whose hybrid torrent announces into a swarm + * that does not exist, on a site where every other hybrid torrent works. + * + * So the bytes are located instead. This is a bencode scanner that walks + * structure without interpreting it — it does not need to understand a single + * value, only where each one ends — and returns the half-open range of the + * top-level `info` value. + * + * Returns null for anything it cannot walk: a truncated file, a non-dict root, + * no `info` key. Bounded by the buffer length on every path, so a hostile file + * cannot make it loop. + */ +export function infoDictRange( + bytes: Buffer +): { start: number; end: number } | null { + /** + * `skip` returns the index one past the value that starts at `i`, or -1. + * + * `depth` is the bound the comment above did not have. The walk cannot loop — + * every step is forward — but it recurses once per nesting level, so a file + * with fifteen thousand nested containers before the `info` key blew the + * stack, and `extractV2` is called without a try/catch on the upload path: a + * `RangeError` reached the member as a 500 instead of "this file cannot be + * read". Nothing legitimate nests past a handful of levels. + */ + const MAX_DEPTH = 64; + const skip = (i: number, depth = 0): number => { + if (i >= bytes.length || depth > MAX_DEPTH) return -1; + const c = bytes[i]!; + + // Integer: `i<digits>e`. + if (c === 0x69 /* i */) { + const e = bytes.indexOf(0x65 /* e */, i + 1); + return e === -1 ? -1 : e + 1; + } + + // Dict or list: recurse until the matching `e`. + if (c === 0x64 /* d */ || c === 0x6c /* l */) { + let j = i + 1; + while (j < bytes.length && bytes[j] !== 0x65 /* e */) { + const next = skip(j, depth + 1); + if (next <= j) return -1; // no progress, or too deep: malformed + j = next; + } + return j < bytes.length ? j + 1 : -1; + } + + // Byte string: `<length>:<bytes>`. Digits only — a leading `-` or a + // missing colon is malformed, not a negative length. + if (c >= 0x30 && c <= 0x39) { + const colon = bytes.indexOf(0x3a /* : */, i); + if (colon === -1) return -1; + const digits = bytes.toString('latin1', i, colon); + if (!/^[0-9]+$/.test(digits)) return -1; + const len = Number.parseInt(digits, 10); + // `Number.parseInt` on a 20-digit length yields something past any real + // buffer; the bound below rejects it either way. + const end = colon + 1 + len; + return end <= bytes.length ? end : -1; + } + + return -1; + }; + + if (bytes.length < 2 || bytes[0] !== 0x64 /* d */) return null; + + let i = 1; + while (i < bytes.length && bytes[i] !== 0x65 /* e */) { + // Every key in a bencoded dict is a byte string. + const keyStart = i; + const keyEnd = skip(keyStart); + if (keyEnd <= keyStart) return null; + const colon = bytes.indexOf(0x3a /* : */, keyStart); + if (colon === -1 || colon >= keyEnd) return null; + const key = bytes.toString('latin1', colon + 1, keyEnd); + + const valEnd = skip(keyEnd); + if (valEnd <= keyEnd) return null; + + if (key === 'info') return { start: keyEnd, end: valEnd }; + i = valEnd; + } + return null; +} + +/** The 20-byte truncation BEP 52 announces, as hex. */ +export function truncateV2(infoHashV2Hex: string): string { + return infoHashV2Hex.slice(0, 40); +} + /** * Derive v2 content addressing from raw `.torrent` bytes. Compute it from the * exact bytes you store and serve (post-normalisation), so a client re-deriving @@ -138,18 +251,28 @@ export function extractV2(torrentBytes: Buffer | Uint8Array): V2Content | null { roots.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0)); - // NOTE: this hashes a RE-ENCODE of the decoded info dict, not the original - // byte slice. For a canonical torrent (sorted keys, UTF-8 paths) that equals - // the true BEP-52 infohash; for a non-canonical dict, or a path that is not - // valid UTF-8 (which `bencode` surfaces as a hex string), it diverges. We can - // afford that because `infoHashV2` is only stored and carried in the record — - // nothing joins or matches on it — and `contentRootV2` (the key that IS - // matched) stays deterministic within this codebase, so opentracker↔opentracker - // matching is unaffected. If a future consumer needs the portable, exact v2 - // infohash, compute it from the original info-dict byte range instead. + // Hashed over the ORIGINAL info-dict bytes, located by `infoDictRange`. + // + // This used to hash a re-encode of the decoded dict, which is the same thing + // for a canonical torrent and a different thing for one whose keys are out of + // order or whose paths are not valid UTF-8. That was affordable while nothing + // matched on the value; the announce path matches on it now, so it has to be + // the hash a client computes rather than a hash that usually is. + // + // A file we cannot locate the range in is treated as unaddressable — the same + // answer as a malformed file, and the same answer as before for anything that + // was never going to work. + const raw = Buffer.isBuffer(torrentBytes) + ? torrentBytes + : Buffer.from(torrentBytes); + const range = infoDictRange(raw); + if (!range) return null; + let infoHashV2: string; try { - infoHashV2 = createHash('sha256').update(bencode.encode(info)).digest('hex'); + infoHashV2 = createHash('sha256') + .update(raw.subarray(range.start, range.end)) + .digest('hex'); } catch { return null; } @@ -157,5 +280,10 @@ export function extractV2(torrentBytes: Buffer | Uint8Array): V2Content | null { .update(JSON.stringify(roots)) .digest('hex'); - return { infoHashV2, contentRootV2, fileRoots: roots }; + return { + infoHashV2, + infoHashV2Short: truncateV2(infoHashV2), + contentRootV2, + fileRoots: roots, + }; } diff --git a/apps/api/utils/channelSecrets.ts b/apps/api/utils/channelSecrets.ts index 38fe12a4..31a2a2b1 100644 --- a/apps/api/utils/channelSecrets.ts +++ b/apps/api/utils/channelSecrets.ts @@ -24,6 +24,7 @@ import { scryptSync } from 'crypto'; import { encrypt, decrypt } from './panic'; let cachedKey: Buffer | null = null; +let cachedKeys: { current: Buffer; previous: Buffer | null } | null = null; /** * Resolve and cache the encryption key. Lazy on purpose — `notify.ts` @@ -31,7 +32,33 @@ let cachedKey: Buffer | null = null; * want the import to fail just because the env var is read before the * Nitro runtime hands them through. */ -function getKey(): Buffer { +/** + * La clé courante et, si elle est déclarée, la précédente. + * + * `credentialSecrets.ts` gère `CREDENTIAL_ENCRYPTION_KEY_PREVIOUS` et + * ré-chiffre à la connexion prouvée ; ce module-ci n'avait aucun équivalent, et + * la clé retombe par défaut sur `NUXT_SESSION_SECRET`. Or faire tourner + * `NUXT_SESSION_SECRET` est une opération périodique banale qui, avant cette + * fonctionnalité, n'invalidait que des cookies : tous les + * `notification_channels.server_config` et `user_notification_channels.user_config` + * devenaient d'un coup indéchiffrables, `decryptJson` LEVANT une exception de + * tag AES-GCM sans message actionnable, et les notifications tombaient. + * + * Avec la clé précédente déclarée, la lecture retente avec elle : l'opérateur + * garde une fenêtre pour tourner sans casser. + */ +function getKeys(): { current: Buffer; previous: Buffer | null } { + if (cachedKeys) return cachedKeys; + const salt = process.env.CHANNEL_ENCRYPTION_SALT || 'trackarr:channels:v1'; + const prev = process.env.CHANNEL_ENCRYPTION_KEY_PREVIOUS; + cachedKeys = { + current: deriveCurrent(), + previous: prev && prev.length >= 32 ? (scryptSync(prev, salt, 32) as Buffer) : null, + }; + return cachedKeys; +} + +function deriveCurrent(): Buffer { if (cachedKey) return cachedKey; const raw = process.env.CHANNEL_ENCRYPTION_KEY || process.env.NUXT_SESSION_SECRET; @@ -70,7 +97,7 @@ export function encryptJson(value: unknown): string { if (value == null) return ''; const json = typeof value === 'string' ? value : JSON.stringify(value); if (json.length === 0) return ''; - return encrypt(json, getKey()); + return encrypt(json, getKeys().current); } /** @@ -83,8 +110,28 @@ export function decryptJson<T = Record<string, unknown>>( blob: string | null | undefined ): T | null { if (!blob) return null; - const json = decrypt(blob, getKey()); - return JSON.parse(json) as T; + const keys = getKeys(); + try { + return JSON.parse(decrypt(blob, keys.current)) as T; + } catch (err) { + // La clé précédente, quand l'opérateur en a déclaré une. Sans ce chemin, + // faire tourner `NUXT_SESSION_SECRET` rendait indéchiffrable la + // configuration de TOUS les canaux, avec pour seul symptôme une exception + // de tag AES-GCM. + if (keys.previous) { + try { + return JSON.parse(decrypt(blob, keys.previous)) as T; + } catch { + /* ni l'une ni l'autre : on relaie l'erreur d'origine ci-dessous */ + } + } + throw new Error( + '[channelSecrets] Could not decrypt a channel config. The key changed ' + + 'without CHANNEL_ENCRYPTION_KEY_PREVIOUS being set, or ' + + 'CHANNEL_ENCRYPTION_SALT was altered. ' + + `(${(err as Error).message})` + ); + } } /** @@ -93,5 +140,5 @@ export function decryptJson<T = Record<string, unknown>>( * the row so the UI shows the misconfig before garbage is written. */ export function assertChannelEncryptionReady(): void { - getKey(); + getKeys(); } diff --git a/apps/api/utils/channels/webhook.ts b/apps/api/utils/channels/webhook.ts index 2bbe1632..a5573374 100644 --- a/apps/api/utils/channels/webhook.ts +++ b/apps/api/utils/channels/webhook.ts @@ -87,6 +87,43 @@ async function sendWebhook( .digest('hex'); } + /* + * Une liste d'hôtes autorisés, quand l'opérateur en déclare une. + * + * `url` est un champ de MEMBRE : `me/notification-channels/[type].put.ts` le + * persiste sans revue. `safeFetch` écarte les plages privées, la boucle + * locale et le lien-local, et re-valide chaque redirection — mais il reste + * une course de réattachement DNS sous-milliseconde, et son commentaire + * l'avait classée sans suite en supposant qu'aucune URL de membre ne + * l'atteignait. + * + * Vide par défaut, donc rien ne change pour une installation existante : + * c'est un levier offert à l'opérateur, pas une restriction imposée. Le + * motif est celui que `channels/webpush.ts` applique déjà à ses points de + * terminaison. + */ + const allow = (process.env.WEBHOOK_ALLOW_HOSTS ?? '') + .split(',') + .map((h) => h.trim().toLowerCase()) + .filter(Boolean); + if (allow.length > 0) { + let host: string; + try { + host = new URL(user.url).hostname.toLowerCase(); + } catch { + return { ok: false, error: 'Invalid webhook URL' }; + } + const permitted = allow.some( + (a) => host === a || host.endsWith(`.${a}`), + ); + if (!permitted) { + return { + ok: false, + error: `Webhook host not permitted by WEBHOOK_ALLOW_HOSTS (${host})`, + }; + } + } + try { const res = await safeFetch(user.url, { method: 'POST', @@ -94,11 +131,19 @@ async function sendWebhook( body, }); if (!res.ok) { - const text = await res.text().catch(() => ''); - return { - ok: false, - error: `Webhook returned ${res.status}: ${text.slice(0, 200)}`, - }; + // Le statut, et rien d'autre. + // + // On renvoyait les 200 premiers caractères du corps AMONT au membre. Avec + // une URL qu'il choisit lui-même (`url` est un `userField`, cf. plus bas) + // et jusqu'à seize en-têtes arbitraires, cela lui donnait un primitif de + // requête forgée AVEC lecture de réponse — la seule barrière côté cible + // étant le filtre de plages de `safeFetch`, et c'est précisément ce + // filtre que la course DNS résiduelle contourne. + // + // `channels/webpush.ts` a déjà pris cette décision et l'écrit : + // « Don't reflect the upstream response body — only the status code ». + // Celui-ci ne l'avait pas suivie. + return { ok: false, error: `Webhook returned ${res.status}` }; } return { ok: true }; } catch (err) { diff --git a/apps/api/utils/fanout.ts b/apps/api/utils/fanout.ts new file mode 100644 index 00000000..6b807de1 --- /dev/null +++ b/apps/api/utils/fanout.ts @@ -0,0 +1,46 @@ +/** + * A bounded worker pool for notification fan-outs. + * + * Lived in `followerFanout.ts` until the reseed request became its second + * caller. The reasoning is unchanged and worth keeping in front of whoever + * adds a third: an unbounded `Promise.all` over recipients is a denial of + * service you write yourself. Each task is a database insert plus a Redis + * publish plus — for anyone who configured one — an outbound HTTP request to + * Telegram, Discord or a webhook. A release followed by two thousand members + * would open two thousand of those at once, against a connection pool sized + * for tens. + * + * Failures are swallowed per item on purpose. A fan-out is best-effort, and one + * recipient whose webhook is refusing connections must not cost the other + * nineteen hundred their notification. + */ +/** + * 20 is high enough that a few hundred recipients finish in roughly one notify + * round-trip's worth of wall time, and low enough that an uploader with 50 000 + * followers cannot open 50 000 concurrent connections to Postgres and Redis at + * upload time. + */ +export const FANOUT_CONCURRENCY = 20; + +export async function withConcurrency<T>( + items: T[], + concurrency: number, + fn: (item: T) => Promise<void> +): Promise<void> { + if (items.length === 0) return; + const queue = items.slice(); + const workers = Array(Math.min(concurrency, items.length)) + .fill(0) + .map(async () => { + while (queue.length > 0) { + const item = queue.shift(); + if (item === undefined) return; + try { + await fn(item); + } catch { + // best-effort: don't let one bad recipient sink the rest + } + } + }); + await Promise.all(workers); +} diff --git a/apps/api/utils/federation/inbound.ts b/apps/api/utils/federation/inbound.ts index 1ca98f90..901ad15f 100644 --- a/apps/api/utils/federation/inbound.ts +++ b/apps/api/utils/federation/inbound.ts @@ -201,7 +201,16 @@ export async function verifyInboundS2S( prefix: 'feds2s', }); - await assertNotReplayed(headers['x-trackarr-signature']); + // La signature RÉELLEMENT vérifiée, pas celle de l'en-tête v1. + // + // `verifySignedRequest` ne contrôle que la v2 dès qu'une v2 et une audience + // sont présentes ; cléer le nonce sur la v1 laissait donc un attaquant + // rejouer une requête capturée indéfiniment en changeant simplement des + // octets d'un en-tête que personne ne vérifiait. Le repli sur la v1 ne sert + // qu'aux pairs qui n'ont pas encore de v2. + await assertNotReplayed( + verdict.verifiedSignature ?? headers['x-trackarr-signature'], + ); return { peer, config: config!, rawBody }; } diff --git a/apps/api/utils/federation/signing.ts b/apps/api/utils/federation/signing.ts index 4d833933..660cfe7c 100644 --- a/apps/api/utils/federation/signing.ts +++ b/apps/api/utils/federation/signing.ts @@ -122,6 +122,20 @@ export interface VerifyResult { /** Sender instanceId from the header, present even on some failures * so the caller can log who tried. */ instanceId?: string; + /** + * La signature RÉELLEMENT vérifiée, sur laquelle la garde anti-rejeu doit + * s'indexer. + * + * L'appelant cléait son nonce sur `x-trackarr-signature` — la v1 — alors que + * dès qu'une v2 et une audience sont présentes, cette fonction ne vérifie QUE + * la v2 et retourne. La v1 n'était donc jamais contrôlée sur ce chemin : un + * attaquant qui capturait une requête signée la rejouait autant de fois qu'il + * voulait en remplaçant la v1 par des octets aléatoires. La v2 vérifiait + * toujours, la clé Redis différait à chaque essai, et le doublon n'était + * jamais vu. La protection annoncée était nulle pendant toute la fenêtre de + * ±5 minutes. + */ + verifiedSignature?: string; } /** @@ -172,7 +186,7 @@ export function verifySignedRequest(opts: { if (!valid) { return { ok: false, reason: 'bad signature (audience)', instanceId }; } - return { ok: true, instanceId }; + return { ok: true, instanceId, verifiedSignature: signatureV2 }; } if (REQUIRE_AUDIENCE) { @@ -189,7 +203,7 @@ export function verifySignedRequest(opts: { signature, ); if (!valid) return { ok: false, reason: 'bad signature', instanceId }; - return { ok: true, instanceId }; + return { ok: true, instanceId, verifiedSignature: signature }; } export interface SignedResponse { diff --git a/apps/api/utils/followerFanout.ts b/apps/api/utils/followerFanout.ts index 0fcfded8..8f9d90ce 100644 --- a/apps/api/utils/followerFanout.ts +++ b/apps/api/utils/followerFanout.ts @@ -21,6 +21,9 @@ import { eq } from 'drizzle-orm'; import { db, schema } from '@trackarr/db'; import { notify } from './notify'; +// The pool and its size live in `utils/fanout` — they gained a second caller +// (the reseed request) and a shared cap is the point of having one. +import { FANOUT_CONCURRENCY, withConcurrency } from './fanout'; interface FanoutInput { uploaderId: string; @@ -30,42 +33,6 @@ interface FanoutInput { torrentName: string; } -/** Pool size for per-follower notify dispatch. 20 is high - * enough that a few hundred followers finish in roughly one - * notify round-trip's worth of wall time, low enough that an - * uploader with 50k+ followers can't open 50k+ concurrent - * connections to Postgres and Redis at upload time. */ -const FANOUT_CONCURRENCY = 20; - -/** - * Worker-pool concurrency limiter. Spins up `concurrency` - * workers that pop items from a shared queue. Each task's - * failure is swallowed — the fan-out is best-effort and a - * single recipient's notify glitch must not skip the rest. - */ -async function withConcurrency<T>( - items: T[], - concurrency: number, - fn: (item: T) => Promise<void>, -): Promise<void> { - if (items.length === 0) return; - const queue = items.slice(); - const workers = Array(Math.min(concurrency, items.length)) - .fill(0) - .map(async () => { - while (queue.length > 0) { - const item = queue.shift(); - if (item === undefined) return; - try { - await fn(item); - } catch { - // best-effort: don't let one bad recipient sink the rest - } - } - }); - await Promise.all(workers); -} - export async function fanoutFollowedUserUpload( input: FanoutInput, ): Promise<void> { diff --git a/apps/api/utils/forumDeletion.ts b/apps/api/utils/forumDeletion.ts new file mode 100644 index 00000000..244193bf --- /dev/null +++ b/apps/api/utils/forumDeletion.ts @@ -0,0 +1,53 @@ +import { db, schema } from '@trackarr/db'; +import { and, eq, ne, sql } from 'drizzle-orm'; + +/** + * L'auteur d'un sujet peut-il encore le supprimer ? + * + * `forum_posts.topic_id` porte `onDelete: 'cascade'`, donc supprimer la ligne + * `forum_topics` emporte TOUTES les réponses — y compris celles des autres + * membres. Les deux chemins qui suppriment un sujet le savaient à moitié : + * + * - `topics/[id].delete.ts` ne refusait que sur un sujet VERROUILLÉ ; + * - `posts/[id].delete.ts` porte un commentaire qui nomme le danger mot pour + * mot — « cascade-wipe the entire locked thread (every other user's + * replies too) » — et ne garde, lui aussi, que le cas verrouillé. + * + * Un fil non verrouillé de cinquante réponses restait donc destructible par son + * seul auteur. Mesuré sur la pile compilée : un sujet portant trois messages de + * trois auteurs distincts, supprimé par son auteur simple membre, laisse zéro + * message. Le travail des deux autres disparaît sans recours et sans trace + * autre qu'une ligne d'audit. + * + * La règle retenue est celle des forums : on peut retirer ce qu'on a ouvert + * tant que personne d'autre n'y a pris la parole. Au-delà, le fil ne + * t'appartient plus — seul le personnel peut le supprimer. Elle préserve le + * retrait d'une bêtise fraîche sans donner à un auteur le pouvoir d'effacer + * les contributions d'autrui. + * + * Le personnel n'est pas concerné : `isModerator` court-circuite l'appel. + */ +export async function assertTopicDeletableByAuthor( + topicId: string, + authorId: string +): Promise<void> { + const [row] = await db + .select({ others: sql<number>`count(*)::int` }) + .from(schema.forumPosts) + .where( + and( + eq(schema.forumPosts.topicId, topicId), + ne(schema.forumPosts.authorId, authorId) + ) + ); + + if ((row?.others ?? 0) > 0) { + throw createError({ + statusCode: 403, + // `data.reason` plutôt que la phrase : le front choisit sa traduction. + data: { reason: 'topic-has-replies' }, + message: + 'Another member has replied in this topic. Only staff can delete it now.', + }); + } +} diff --git a/apps/api/utils/imageSniff.ts b/apps/api/utils/imageSniff.ts index 9b2c97f3..5a97cbc4 100644 --- a/apps/api/utils/imageSniff.ts +++ b/apps/api/utils/imageSniff.ts @@ -136,3 +136,142 @@ export function assertImageType( } return actual; } + +/** + * How big the image actually is, read from its own header. + * + * The web app manifest has to state a `sizes` for each icon, and a browser + * takes that statement at face value: Chrome will only offer to install a site + * whose manifest declares an icon of at least 512×512, and it reads the + * declaration, not the file. So the number has to be true — declaring + * `512x512` over a 64-pixel logo produces an install prompt followed by a + * blurry icon, which is worse than no prompt. + * + * Measured at upload time, where the bytes are already in hand, rather than at + * manifest-render time: the file lives behind a storage backend that may be S3, + * and re-fetching it on a route the browser polls would be a network round trip + * per request for a number that cannot change after the upload. + * + * Returns null for a format whose header we do not walk, and for an SVG — which + * has no intrinsic pixel size at all, and whose honest `sizes` value is `any`. + * Callers treat null as "unknown", never as a failure: an operator whose logo + * we cannot measure still gets their logo, it just cannot claim a pixel size. + */ +export interface ImageDimensions { + width: number; + height: number; +} + +export function imageDimensions(buf: Buffer): ImageDimensions | null { + if (!buf || buf.length < 16) return null; + + // PNG — IHDR is always the first chunk, and its width/height are the two + // big-endian uint32s right after the chunk type. Fixed offsets, so no walk. + if (startsWith(buf, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) { + if (buf.length < 24) return null; + if (buf.subarray(12, 16).toString('latin1') !== 'IHDR') return null; + return { width: buf.readUInt32BE(16), height: buf.readUInt32BE(20) }; + } + + // GIF — logical screen descriptor, little-endian, right after the signature. + if (startsWith(buf, [0x47, 0x49, 0x46, 0x38])) { + return { width: buf.readUInt16LE(6), height: buf.readUInt16LE(8) }; + } + + // WEBP — three sub-formats under the same RIFF wrapper, each storing the + // size differently. VP8X (the extended form an animated or alpha file uses) + // carries canvas size minus one, in 24-bit little-endian. + if ( + startsWith(buf, [0x52, 0x49, 0x46, 0x46]) && + startsWith(buf, [0x57, 0x45, 0x42, 0x50], 8) + ) { + const fourcc = buf.subarray(12, 16).toString('latin1'); + if (fourcc === 'VP8X' && buf.length >= 30) { + const w = buf[24]! | (buf[25]! << 8) | (buf[26]! << 16); + const h = buf[27]! | (buf[28]! << 8) | (buf[29]! << 16); + return { width: w + 1, height: h + 1 }; + } + if (fourcc === 'VP8 ' && buf.length >= 30) { + // Lossy: the keyframe header's 14-bit dimensions, masked out of two + // little-endian uint16s. + return { + width: buf.readUInt16LE(26) & 0x3fff, + height: buf.readUInt16LE(28) & 0x3fff, + }; + } + if (fourcc === 'VP8L' && buf.length >= 25) { + // Lossless: 14 bits each, packed across four bytes after the 0x2f + // signature byte, both stored minus one. + const bits = + buf[21]! | (buf[22]! << 8) | (buf[23]! << 16) | (buf[24]! << 24); + return { + width: (bits & 0x3fff) + 1, + height: ((bits >> 14) & 0x3fff) + 1, + }; + } + return null; + } + + // JPEG — the only one that needs a walk: the size lives in a start-of-frame + // marker whose position depends on how much metadata precedes it. + if (startsWith(buf, [0xff, 0xd8, 0xff])) { + let i = 2; + // Bounded by the buffer, and every step advances by at least two bytes, so + // this terminates on any input including a truncated or hostile one. + while (i + 9 < buf.length) { + if (buf[i] !== 0xff) { + i++; + continue; + } + const marker = buf[i + 1]!; + // Padding fill bytes, and the standalone markers that carry no length. + if (marker === 0xff) { + i++; + continue; + } + if (marker === 0xd8 || marker === 0x01 || (marker >= 0xd0 && marker <= 0xd7)) { + i += 2; + continue; + } + // SOF0..SOF15, minus the four that are not frame headers (DHT 0xc4, + // JPG 0xc8, DAC 0xcc). + const isSof = + marker >= 0xc0 && + marker <= 0xcf && + marker !== 0xc4 && + marker !== 0xc8 && + marker !== 0xcc; + if (isSof) { + // height then width, both big-endian uint16, after the 2-byte segment + // length and the 1-byte sample precision. + return { height: buf.readUInt16BE(i + 5), width: buf.readUInt16BE(i + 7) }; + } + // Start of scan — the entropy-coded data begins and there is no frame + // header left to find. + if (marker === 0xda) return null; + const len = buf.readUInt16BE(i + 2); + if (len < 2) return null; + i += 2 + len; + } + return null; + } + + return null; +} + +/** + * The `sizes` value for a manifest icon: the measured pixel square, or `any`. + * + * `any` is the honest answer for an SVG (it has no intrinsic size), for a + * format we do not walk, and for a file uploaded before this measurement + * existed. It is also the honest answer for a non-square image: `sizes` names + * squares, and a 800×200 banner is not a 800×800 icon. + */ +export function manifestIconSizes( + dimensions: ImageDimensions | null +): string { + if (!dimensions) return 'any'; + const { width, height } = dimensions; + if (width < 1 || height < 1 || width !== height) return 'any'; + return `${width}x${height}`; +} diff --git a/apps/api/utils/irc/announcer.ts b/apps/api/utils/irc/announcer.ts new file mode 100644 index 00000000..778f7efc --- /dev/null +++ b/apps/api/utils/irc/announcer.ts @@ -0,0 +1,493 @@ +/** + * The site's one announce bot: what holds the connection, and what decides + * whether a release is announced. + * + * ## One connection for the whole fleet + * + * Every other periodic job here takes a Redis lock per tick and does its sweep. + * A connection cannot work that way — it is held, not performed — so this takes + * the same lock as a LEASE: whichever instance wins connects and renews every + * fifteen seconds; the others do nothing. If the leader dies, the key expires + * and the next instance to tick takes over. + * + * The reason this matters is not efficiency. Three instances, three + * connections, three bots in the channel, and every release announced three + * times — to autobrr, which would then grab it three times. + * + * ## Announcing is not on the upload's path + * + * `announceRelease` returns immediately. It resolves what it needs from the + * database, hands a line to the queue and stops; a channel that is down, a + * server that is throttling, an operator who mistyped the host — none of it can + * slow down or fail an upload. The failure mode of an announce is a missing + * line, and that has to stay true. + * + * ## What is never announced + * + * A release that is not accepted and live; an adult release unless the operator + * turned that on; the name of a member who uploads anonymously. The first is + * what the channel is for, the second is the operator's decision, and the third + * is the same rule the catalogue, the feeds and the federated catalogue already + * apply — this is simply one more surface that must not be the exception. + */ +import { randomUUID } from 'node:crypto'; +import { and, eq } from 'drizzle-orm'; +import { db, schema } from '@trackarr/db'; +import { redis } from '~~/utils/server'; +import { adultCategoryIds } from '~~/utils/adultContent'; +import { concealsUploader } from '~~/utils/uploaderVisibility'; +import { IDENTITY, volumeFactors } from '~~/utils/torrentBuffs'; +import { getActiveSnapshot } from '~~/utils/bonusEvents'; +import { getFederationConfig } from '~~/utils/federation/config'; +import { IrcClient, type IrcStatus } from './client'; +import { + freeleechPercent, + humanSize, + renderAnnounce, + type AnnounceFields, +} from './format'; +import { + getIrcConfig, + getIrcEnabled, + ircConfigReady, + type IrcAnnounceConfig, +} from './settings'; + +const LEASE_KEY = 'irc_announce:leader'; +/** + * Where a rendered line is handed to whichever instance holds the connection. + * + * Without this the feature worked only on a single-instance deployment, and + * failed in a way no test would show: `announceRelease` runs in the process that + * served the upload, and only the LEASE HOLDER has a client — so on three + * instances roughly two thirds of accepted releases were dropped silently. The + * fix for announcing three times had produced announcing once in three. + * + * Publishing unconditionally, including from the leader itself, keeps one path: + * every line is rendered where the release was accepted and said where the + * socket is. + */ +const LINE_CHANNEL = 'irc_announce:line'; +const LEASE_TTL_S = 45; +export const LEASE_RENEW_MS = 15_000; + +let client: IrcClient | null = null; +let leaseOwner: string | null = null; +let subscriber: ReturnType<typeof redis.duplicate> | null = null; +let activeSignature = ''; +let lastError: string | null = null; +/** Kept for the admin console when this instance is not the leader. */ +let lastStatus: IrcStatus | null = null; + +/** Changing any of these means the connection has to be rebuilt. */ +function signatureOf(config: IrcAnnounceConfig): string { + return JSON.stringify([ + config.host, + config.port, + config.tls, + config.nick, + config.serverPassword, + config.saslUser, + config.saslPassword, + config.perform, + config.channel, + config.channelKey, + ]); +} + +/** + * This process's lease identity, minted once at load. + * + * `pid:hostname` was not enough: `HOSTNAME` is provided by the container + * runtime rather than by anything here, and the `'local'` fallback plus two + * containers running the API as pid 1 gives two instances the SAME token — at + * which point the compare-and-renew below succeeds against the other one's key + * and both hold the lease. Every other lock in this codebase uses + * `pid:Date.now()`; this adds a uuid because two containers can start in the + * same millisecond. + */ +const LEASE_OWNER = `${process.pid}:${Date.now()}:${randomUUID()}`; + +function owner(): string { + return LEASE_OWNER; +} + +/** + * Take or renew the lease. + * + * `SET NX` to take it; a compare-and-renew to keep it. The renew is a Lua + * script because "check the owner then extend" as two commands is the classic + * way to extend a lock another instance has already taken. + */ +const RENEW_SCRIPT = ` +if redis.call('get', KEYS[1]) == ARGV[1] then + return redis.call('expire', KEYS[1], ARGV[2]) +end +return 0 +`; + +async function holdLease(): Promise<boolean> { + const me = owner(); + if (leaseOwner === me) { + const kept = await redis.eval(RENEW_SCRIPT, 1, LEASE_KEY, me, String(LEASE_TTL_S)); + if (kept === 1) return true; + // Lost it — the process was paused long enough for the key to expire and + // somebody else took over. Drop the connection rather than run a second bot. + leaseOwner = null; + stop('lost the lease'); + return false; + } + const taken = await redis.set(LEASE_KEY, me, 'EX', LEASE_TTL_S, 'NX'); + if (taken !== 'OK') return false; + leaseOwner = me; + return true; +} + +async function releaseLease(): Promise<void> { + if (leaseOwner !== owner()) return; + const me = leaseOwner; + leaseOwner = null; + try { + // Same compare-then-act problem in the other direction: only delete a key + // that is still ours. + await redis.eval( + `if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) end return 0`, + 1, + LEASE_KEY, + me + ); + } catch { + // The lease expires by itself; a failure here costs at most one TTL of + // nobody announcing. + } +} + +function stop(reason: string): void { + if (client) { + lastStatus = client.status(); + client.close(reason); + client = null; + } + activeSignature = ''; +} + +/** + * Listen for lines published by the other instances. + * + * Started once and left running: a subscriber that came and went with the lease + * would drop whatever arrived during the handover. Lines that arrive while this + * instance is not the one holding the connection are dropped here instead — + * cheap, and it means exactly one instance speaks. + */ +function ensureSubscriber(): void { + if (subscriber) return; + try { + // The shared client runs with `enableOfflineQueue: false` for the hot + // request paths, which is the wrong default for a long-lived idle + // subscriber — same override the settings invalidator uses. + subscriber = redis.duplicate({ lazyConnect: false, enableOfflineQueue: true }); + subscriber.on('error', (err: Error) => { + console.warn('[IRC] line subscriber error:', err.message); + }); + subscriber.on('message', (channel: string, message: string) => { + if (channel !== LINE_CHANNEL) return; + // `client` is only non-null on the holder, so this is the fence. + if (!client || leaseOwner !== owner()) return; + client.say(message); + }); + void subscriber.subscribe(LINE_CHANNEL); + } catch (err) { + subscriber = null; + console.warn('[IRC] could not subscribe to the line channel:', (err as Error).message); + } +} + +/** + * Bring the connection in line with the settings. Called by the plugin on a + * timer, and by the admin routes after a save so a change lands immediately. + */ +export async function reconcile(): Promise<void> { + let enabled = false; + let config: IrcAnnounceConfig | null = null; + try { + enabled = await getIrcEnabled(); + if (enabled) config = await getIrcConfig(); + } catch (err) { + lastError = (err as Error).message; + stop('configuration unreadable'); + return; + } + + if (!enabled || !config || !ircConfigReady(config)) { + if (client) stop('announcing disabled'); + await releaseLease(); + return; + } + + // Before the lease: whoever ends up holding it needs the subscription, and an + // instance that never wins still pays nothing for one idle connection. + ensureSubscriber(); + + if (!(await holdLease())) return; + + const signature = signatureOf(config); + if (client && signature === activeSignature) { + const status = client.status(); + lastStatus = status; + // A client that has given up is not restarted here on purpose: `error` is + // terminal for one socket, and the next tick builds a fresh one. That makes + // the retry cadence the plugin's interval — a bounded, visible backoff + // rather than a reconnect loop inside the client. + if (status.state === 'error') { + lastError = status.lastError; + stop('retrying'); + } + return; + } + + stop('reconfigured'); + activeSignature = signature; + lastError = null; + client = new IrcClient( + { + host: config.host, + port: config.port, + tls: config.tls, + nick: config.nick, + realname: config.realname, + serverPassword: config.serverPassword || undefined, + saslUser: config.saslUser || undefined, + saslPassword: config.saslPassword || undefined, + perform: config.perform, + channel: config.channel, + channelKey: config.channelKey || undefined, + }, + { + onState: (status) => { + lastStatus = status; + if (status.state === 'error') lastError = status.lastError; + if (status.state === 'ready') { + console.log( + `[IRC] Announcing in ${config!.channel} on ${config!.host} as ${status.nick}` + ); + } + }, + } + ); + client.connect(); +} + +/** Everything the admin console shows about the bot. */ +export function ircStatus(): IrcStatus & { leader: boolean } { + const status = client?.status() ?? lastStatus; + return { + state: status?.state ?? 'idle', + nick: status?.nick ?? '', + since: status?.since ?? null, + lastError: status?.lastError ?? lastError, + queued: status?.queued ?? 0, + sent: status?.sent ?? 0, + dropped: status?.dropped ?? 0, + leader: leaseOwner === owner(), + }; +} + +/** Say one arbitrary line — the admin console's test button, and nothing else. */ +export async function saySomething(line: string): Promise<boolean> { + // Through the channel like an announce, so the admin console of an instance + // that does not hold the connection can still test it. + try { + const heard = await redis.publish(LINE_CHANNEL, line); + return heard > 0; + } catch { + return false; + } +} + +export async function shutdownAnnouncer(): Promise<void> { + stop('shutting down'); + if (subscriber) { + try { + await subscriber.unsubscribe(LINE_CHANNEL); + subscriber.disconnect(); + } catch { + // Going away regardless. + } + subscriber = null; + } + await releaseLease(); +} + +/** + * Where a release page lives, as an absolute address when we have one. + * + * The bot has no request to take an origin from, so: what the operator typed, + * then the federation identity's public URL for the instances that already + * declared one, then nothing — and "nothing" yields a path rather than a + * guessed hostname. A path is honest and still useful; a wrong hostname sends + * every member of the channel somewhere else. + */ +async function siteBase(config: IrcAnnounceConfig): Promise<string> { + if (config.siteUrl) return config.siteUrl.replace(/\/+$/, ''); + try { + const federation = await getFederationConfig(); + if (federation?.publicUrl) return federation.publicUrl.replace(/\/+$/, ''); + } catch { + // Federation is optional and may not be configured at all. + } + return ''; +} + +export interface AnnounceCandidate { + id: string; + infoHash: string; + name: string; + size: number; + categoryId: string | null; + uploaderId: string | null; + downloadMultiplier: number | null; + uploadMultiplier: number | null; + multipliersUntil: Date | null; +} + +/** + * Announce one accepted release. Fire-and-forget by contract. + * + * The caller has the torrent it just accepted, so nothing here re-reads it. The + * three extra lookups are the ones the caller cannot know: the category name, + * the tags, and whether the uploader is anonymous. + */ +export async function announceRelease( + torrent: AnnounceCandidate +): Promise<void> { + // Deliberately NOT `if (!client) return`: this runs on whichever instance + // served the upload, which is usually not the one holding the socket. + let config: IrcAnnounceConfig; + try { + if (!(await getIrcEnabled())) return; + config = await getIrcConfig(); + } catch { + return; + } + + // The adult gate, before anything else is spent on the release. + if (!config.announceAdult && torrent.categoryId) { + const adult = await adultCategoryIds(); + if (adult.includes(torrent.categoryId)) return; + } + + const [category] = torrent.categoryId + ? await db + .select({ name: schema.categories.name }) + .from(schema.categories) + .where(eq(schema.categories.id, torrent.categoryId)) + .limit(1) + : []; + + const tagRows = await db + .select({ name: schema.tags.name }) + .from(schema.torrentTags) + .innerJoin(schema.tags, eq(schema.tags.id, schema.torrentTags.tagId)) + .where(eq(schema.torrentTags.torrentId, torrent.id)); + + let uploader = 'anonymous'; + if (torrent.uploaderId) { + const [row] = await db + .select({ + username: schema.users.username, + anonymousUploads: schema.users.anonymousUploads, + }) + .from(schema.users) + .where( + and( + eq(schema.users.id, torrent.uploaderId), + eq(schema.users.isBanned, false) + ) + ) + .limit(1); + // `concealsUploader` rather than a fresh comparison: one definition of who + // may be named, shared with the detail page, the feeds and federation. + if (row && !concealsUploader(row.anonymousUploads)) uploader = row.username; + } + + // The same computation the Torznab feed publishes, from the same function: + // the better of the site-wide event and the torrent's own buff, with an + // expired `multipliers_until` already neutralised. Announcing a figure the + // feed contradicts would be worse than announcing none — a member racing on + // the channel and a member polling the feed have to see one tracker. + const activeEvent = await getActiveSnapshot(); + const siteWide = activeEvent + ? { + download: activeEvent.downloadMultiplier, + upload: activeEvent.uploadMultiplier, + } + : IDENTITY; + const { downloadVolumeFactor, uploadVolumeFactor } = volumeFactors( + { + // The columns are nullable and the scale is percent: a row with nothing + // set means "no buff", which is 100 on both axes rather than 0. Getting + // this wrong would announce every release as freeleech. + downloadMultiplier: torrent.downloadMultiplier ?? 100, + uploadMultiplier: torrent.uploadMultiplier ?? 100, + multipliersUntil: torrent.multipliersUntil, + }, + siteWide + ); + + const site = await siteBase(config); + const fields: AnnounceFields = { + name: torrent.name, + category: category?.name ?? 'uncategorised', + size: humanSize(torrent.size), + freeleechPercent: freeleechPercent(downloadVolumeFactor), + uploadFactor: String(Number(uploadVolumeFactor.toFixed(2))), + tags: tagRows.length ? tagRows.map((t) => t.name).join(', ') : '-', + uploader, + // No key in the URL, which is the industry convention and the only safe + // choice: a line in a channel is seen by everybody in it, so a personalised + // download link would hand every member the credentials of one. The client + // appends its own read key — that is what the generated definition's + // `downloadurl` template is for. + url: `${site}/torrents/${torrent.infoHash}`, + infoHash: torrent.infoHash, + }; + + const line = renderAnnounce(config.template, fields); + + /** + * Once per release, not once per approval. + * + * The moderation edge is `pending → accepted`, and an ordinary edit sends an + * accepted torrent back to `pending` — so a member editing their own release + * and a moderator re-approving it announced the same thing again, and every + * autobrr in the channel grabbed it a second time. A key per infohash, kept + * for a fortnight, is enough: an announce is only interesting while the + * release is new, and a fortnight is well past that. + */ + try { + const first = await redis.set( + `irc_announce:said:${torrent.infoHash}`, + '1', + 'EX', + 1_209_600, + 'NX' + ); + if (first !== 'OK') return; + } catch { + // Redis unavailable: announce rather than stay silent. A duplicate line is + // a nuisance; a missing one is the feature not working. + } + + /** + * Published, never said directly — see `LINE_CHANNEL`. This also closes a + * narrower bug: the guard at the top of this function ran eight awaits ago, + * and `reconcile()` nulls `client` on any tick where the socket is in error, + * so `client.say(...)` here could throw on null exactly during an IRC outage. + */ + try { + await redis.publish(LINE_CHANNEL, line); + } catch (err) { + // A line is not worth an exception on the upload path. + console.warn('[IRC] could not publish an announce:', (err as Error).message); + } +} diff --git a/apps/api/utils/irc/autobrr.ts b/apps/api/utils/irc/autobrr.ts new file mode 100644 index 00000000..f782e325 --- /dev/null +++ b/apps/api/utils/irc/autobrr.ts @@ -0,0 +1,187 @@ +/** + * The autobrr indexer definition for this instance, generated. + * + * Same argument as the Prowlarr definition next door, one step further. That + * one is generated because the category map is per instance; this one is + * generated because the **announce format is per instance** — it is a template + * an operator may edit — so a hand-written definition would be a guess about a + * string in somebody else's database. + * + * The pattern comes from `announcePattern(template)`, which is the same + * function the renderer's output is shaped by. There is no second description + * of the format anywhere: one template produces both the line and the regex + * that reads it, which is why the round-trip test in `test/ircAnnounce.test.ts` + * is able to prove they agree for any template rather than for one. + * + * ## The test line is not decoration + * + * A definition carries `tests`, and autobrr's own tooling runs them. Emitting a + * rendered sample and the values it should yield means the file we hand a member + * arrives with a proof that it parses this instance's format — and if an + * operator's template is unparseable by its own regex, that shows up in their + * autobrr rather than in a silence nobody can explain. + */ +import { + ANNOUNCE_TOKENS, + announcePattern, + renderAnnounce, + templateTokens, + type AnnounceFields, +} from './format'; + +/** A release that exercises every field, for the definition's self-test. */ +export const SAMPLE_FIELDS: AnnounceFields = { + name: 'Example.Release.2026.1080p.BluRay.x264-GROUP', + category: 'Movies', + size: '14.62 GiB', + freeleechPercent: '100%', + uploadFactor: '2', + tags: '1080p, bluray, x264', + uploader: 'example', + url: 'https://tracker.example.com/torrents/0123456789abcdef0123456789abcdef01234567', + infoHash: '0123456789abcdef0123456789abcdef01234567', +}; + +/** YAML single-quoted scalar — the form that needs no backslash escaping, which + * matters when the value IS a regular expression. */ +function sq(value: string): string { + return `'${value.replace(/'/g, "''")}'`; +} + +/** YAML double-quoted scalar, for values that carry no backslashes. */ +function dq(value: string): string { + return `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`; +} + +export function slugifyId(name: string): string { + const slug = name + .toLowerCase() + .normalize('NFD') + .replace(/[̀-ͯ]/g, '') + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 40); + return slug || 'trackarr'; +} + +export interface AutobrrDefinitionInput { + siteName: string; + /** Where the member reached this instance — the definition's `urls`. */ + baseUrl: string; + /** IRC network details, from the operator's config. */ + irc: { + host: string; + port: number; + tls: boolean; + channel: string; + /** The bot's nick, which autobrr matches announcements against. */ + announcer: string; + /** Whether a channel key is in use — the member has to be told to ask. */ + keyed: boolean; + /** True when the operator configured perform lines, which usually means an + * invite request the member's own bot will have to make too. */ + invited: boolean; + }; + template: string; +} + +export function autobrrDefinition(input: AutobrrDefinitionInput): string { + const id = slugifyId(input.siteName); + const { pattern } = announcePattern(input.template); + const tokens = templateTokens(input.template); + + // What the sample line should yield: every mapped token the template uses. + // Built from the same table the pattern is, so a token added to the format + // appears in the expectations without anybody remembering to add it. + const expectations = tokens + .map((token) => { + const def = ANNOUNCE_TOKENS[token]!; + if (!def.variable) return null; + const value = SAMPLE_FIELDS[token as keyof AnnounceFields]; + return ` ${token}: ${dq(String(value))}`; + }) + .filter(Boolean) + .join('\n'); + + const sampleLine = renderAnnounce(input.template, SAMPLE_FIELDS); + + return `--- +# Generated by ${input.siteName} — do not hand-edit. +# +# This definition is produced from the announce template in force on the +# instance, so it always matches what the channel is actually saying. If the +# operator changes the format, download it again. +# +# Drop this file in autobrr's custom definitions directory, restart autobrr, +# then add the indexer and paste your API key from ${input.baseUrl}/me. +version: 2 +name: ${dq(input.siteName)} +identifier: ${id} +description: ${dq(`${input.siteName} is a private tracker`)} +language: en-us +urls: + - ${dq(`${input.baseUrl}/`)} +privacy: private +protocol: torrent +supports: + - irc + - rss + +settings: + - name: apikey + type: secret + required: true + label: API key + help: ${dq('Settings → Keys → API key. Not your announce passkey: that one can announce on your behalf.')} + +irc: + network: ${dq(input.siteName)} + server: ${dq(input.irc.host)} + port: ${input.irc.port} + tls: ${input.irc.tls} + settings: + - name: nick + type: text + required: true + label: Nick + help: ${dq('Your IRC nick. Some networks want a bot suffix, e.g. yourname|autodl.')} + - name: auth.account + type: text + required: false + label: NickServ account + help: ${dq('Only if the network requires you to be identified.')} + - name: auth.password + type: secret + required: false + label: NickServ password + help: ${dq('Only if the network requires you to be identified.')} +${ + input.irc.invited || input.irc.keyed + ? ` - name: invite_command + type: secret + required: false + label: Invite command + help: ${dq('The channel is not open to everyone. Ask the operator what to send, and to whom.')} +` + : '' +} + channels: + - name: ${dq(input.irc.channel)} + announcers: + - ${dq(input.irc.announcer)} + parse: + type: single + lines: + - pattern: ${sq(pattern)} + tests: + - line: ${sq(sampleLine)} + expect: +${expectations} + match: + infourl: ${dq('/torrents/{{ .torrentId }}')} + # No key travels in the announce line — everyone in the channel sees + # it. Your own key is appended here, by autobrr, from the setting + # above. + downloadurl: ${dq('/api/torznab/download?id={{ .torrentId }}&apikey={{ .apikey }}')} +`; +} diff --git a/apps/api/utils/irc/client.ts b/apps/api/utils/irc/client.ts new file mode 100644 index 00000000..8b0a4612 --- /dev/null +++ b/apps/api/utils/irc/client.ts @@ -0,0 +1,452 @@ +/** + * A minimal IRC client — enough to say one line in one channel, forever. + * + * ## Why not a library + * + * Because the job is registration, PING, JOIN and PRIVMSG, and every library + * that does those also does DCC, CTCP, channel modes and user tracking. The + * whole protocol surface here is under two hundred lines, and the alternative + * is a dependency on the network boundary of a private tracker — a place where + * "what does this parse and what does it do with it" should be readable in one + * sitting. + * + * ## What it deliberately does not do + * + * It never reads a command FROM the channel. Nothing an operator or a member + * says to the bot makes it do anything: the only inputs are PING (answered) and + * the numerics it needs to know it is connected. A bot that took commands from a + * channel would be a remote control for the tracker, gated on IRC's idea of + * identity — and IRC does not have one. + * + * It also never joins more than one channel, and never speaks to a user. An + * announce bot with a private-message surface is an invitation to social + * engineering with no upside. + * + * ## Flood + * + * Servers kill clients that talk too fast, and the penalty is a disconnect + * mid-burst — which on a tracker means the ten releases a moderator just + * accepted are the ten nobody hears about. So writes go through a queue with a + * minimum interval, and the queue is bounded: past its cap the OLDEST lines are + * dropped, because on an announce channel a stale release is worth less than a + * fresh one. + */ +import net from 'node:net'; +import tls from 'node:tls'; + +export interface IrcConfig { + host: string; + port: number; + tls: boolean; + /** Sent as-is before registration when set (server password, not NickServ). */ + serverPassword?: string; + nick: string; + /** Some networks require a suffix on a bot's nick to let it into #announce. */ + realname?: string; + /** SASL PLAIN. Preferred over NickServ when the network offers it. */ + saslUser?: string; + saslPassword?: string; + /** Raw lines sent once, after registration, before JOIN. NickServ identify, + * an invite request to a channel bot, whatever the network needs. */ + perform?: string[]; + channel: string; + channelKey?: string; +} + +export type IrcState = + | 'idle' + | 'connecting' + | 'registering' + | 'joining' + | 'ready' + | 'error'; + +export interface IrcStatus { + state: IrcState; + /** The nick actually in use — a collision may have changed it. */ + nick: string; + since: number | null; + lastError: string | null; + queued: number; + sent: number; + dropped: number; +} + +const WRITE_INTERVAL_MS = 1_500; +/** No byte in either direction for this long means the peer is gone. */ +const IDLE_TIMEOUT_MS = 300_000; +/** Our own keepalive, comfortably inside the deadline above. */ +const PING_EVERY_MS = 120_000; +const QUEUE_CAP = 200; +const CONNECT_TIMEOUT_MS = 20_000; +/** A registration that never completes is indistinguishable from a hung socket + * at the protocol level, so it gets its own deadline. */ +const REGISTER_TIMEOUT_MS = 45_000; + +export interface IrcClientEvents { + onState?: (status: IrcStatus) => void; + onLog?: (line: string) => void; +} + +export class IrcClient { + private socket: net.Socket | tls.TLSSocket | null = null; + private buffer = ''; + private state: IrcState = 'idle'; + private nick: string; + private since: number | null = null; + private lastError: string | null = null; + private queue: string[] = []; + private sentCount = 0; + private droppedCount = 0; + private timer: NodeJS.Timeout | null = null; + private connectTimer: NodeJS.Timeout | null = null; + private registerTimer: NodeJS.Timeout | null = null; + private pinger: NodeJS.Timeout | null = null; + /** When the last PRIVMSG actually went out, for the pacing above. */ + private lastSentAt = 0; + private nickAttempt = 0; + private closed = false; + + constructor( + private readonly config: IrcConfig, + private readonly events: IrcClientEvents = {} + ) { + this.nick = config.nick; + } + + status(): IrcStatus { + return { + state: this.state, + nick: this.nick, + since: this.since, + lastError: this.lastError, + queued: this.queue.length, + sent: this.sentCount, + dropped: this.droppedCount, + }; + } + + /** Queue a message for the channel. Never throws, never blocks a caller. */ + say(message: string): void { + if (this.closed) return; + if (this.queue.length >= QUEUE_CAP) { + // Oldest first: on an announce channel the fresh release is the one worth + // saying, and a queue that drops the NEW line would hide exactly what the + // members are waiting for. + this.queue.shift(); + this.droppedCount++; + } + this.queue.push(message); + this.pump(); + } + + connect(): void { + if (this.socket || this.closed) return; + this.setState('connecting'); + this.buffer = ''; + this.nickAttempt = 0; + this.nick = this.config.nick; + + const onReady = () => { + this.clearConnectTimer(); + this.setState('registering'); + this.armRegisterDeadline(); + // SASL has to be negotiated before registration completes, so the CAP + // request goes first or not at all. + if (this.config.saslUser && this.config.saslPassword) { + this.raw('CAP REQ :sasl'); + } + if (this.config.serverPassword) this.raw(`PASS ${this.config.serverPassword}`); + this.raw(`NICK ${this.nick}`); + this.raw( + `USER ${this.nick} 0 * :${this.config.realname || 'Trackarr announce'}` + ); + }; + + try { + if (this.config.tls) { + const socket = tls.connect( + { + host: this.config.host, + port: this.config.port, + servername: this.config.host, + }, + onReady + ); + this.socket = socket; + } else { + const socket = net.connect( + { host: this.config.host, port: this.config.port }, + onReady + ); + this.socket = socket; + } + } catch (err) { + this.fail((err as Error).message); + return; + } + + this.connectTimer = setTimeout(() => { + this.fail(`no connection within ${CONNECT_TIMEOUT_MS / 1000}s`); + }, CONNECT_TIMEOUT_MS); + this.connectTimer.unref?.(); + + this.socket.setEncoding('utf8'); + /** + * A dead peer that never sends a FIN — a kernel panic, a partition, a + * middlebox dropping the flow — leaves this socket open and the state + * `ready` forever. The reconciler then sees a healthy client, keeps renewing + * the lease, and every release is announced into nothing while the console + * says the bot is in the channel. So: a traffic deadline, plus our own PING + * on a shorter cadence so a merely quiet channel is not torn down. + */ + this.socket.setTimeout(IDLE_TIMEOUT_MS, () => this.fail('no traffic')); + this.pinger = setInterval(() => { + if (this.state === 'ready') this.raw(`PING :${Date.now()}`); + }, PING_EVERY_MS); + this.pinger.unref?.(); + this.socket.on('data', (chunk: string) => this.onData(chunk)); + this.socket.on('error', (err: Error) => this.fail(err.message)); + this.socket.on('close', () => { + if (!this.closed && this.state !== 'error') this.fail('connection closed'); + }); + } + + /** Close for good. A client that has been shut down never reconnects. */ + close(reason = 'shutting down'): void { + this.closed = true; + this.stopTimers(); + if (this.socket) { + try { + this.raw(`QUIT :${reason}`); + this.socket.end(); + } catch { + // The socket was already gone; nothing to say about it. + } + this.socket.destroy(); + this.socket = null; + } + this.setState('idle'); + } + + // ── protocol ────────────────────────────────────────────────────────────── + + private onData(chunk: string): void { + this.buffer += chunk; + // IRC frames on CRLF, but plenty of servers and bouncers send a bare LF. + const lines = this.buffer.split(/\r?\n/); + this.buffer = lines.pop() ?? ''; + for (const line of lines) { + if (line) this.handleLine(line); + } + // A peer that never sends a newline would otherwise grow this without + // bound. 8 KiB is sixteen times the longest legal frame. + if (this.buffer.length > 8192) this.buffer = ''; + } + + private handleLine(line: string): void { + this.events.onLog?.(line); + + if (line.startsWith('PING ')) { + this.raw(`PONG ${line.slice(5)}`); + return; + } + + // :prefix COMMAND params… — the prefix is not needed for anything here. + const parts = line.startsWith(':') ? line.slice(1).split(' ').slice(1) : line.split(' '); + const command = parts[0]?.toUpperCase(); + + switch (command) { + case 'AUTHENTICATE': { + if (parts[1] === '+' && this.config.saslUser && this.config.saslPassword) { + const payload = Buffer.from( + `${this.config.saslUser}\0${this.config.saslUser}\0${this.config.saslPassword}`, + 'utf8' + ).toString('base64'); + // IRCv3 requires the payload in 400-byte chunks, and a bare `+` when + // the length is an exact multiple — otherwise a long credential rides + // past the 512-byte frame and authentication fails with no + // diagnostic at all. + for (let i = 0; i < payload.length; i += 400) { + this.raw(`AUTHENTICATE ${payload.slice(i, i + 400)}`); + } + if (payload.length % 400 === 0) this.raw('AUTHENTICATE +'); + } + return; + } + case 'CAP': { + // ACK on the sasl cap is the go-ahead; anything else means the server + // will not do SASL, and registration continues without it. + if (parts[2]?.toUpperCase() === 'ACK') this.raw('AUTHENTICATE PLAIN'); + else if (parts[2]?.toUpperCase() === 'NAK') this.raw('CAP END'); + return; + } + case '903': // SASL succeeded + this.raw('CAP END'); + return; + case '904': // SASL failed + case '905': + case '906': + // Not fatal on its own: a network may still let an unauthenticated bot + // into a keyed channel, and failing here would hide that. The error is + // recorded so the operator sees why the channel refused them. + this.lastError = 'SASL authentication refused'; + this.raw('CAP END'); + return; + case '001': { + // Registered. Perform lines first — an invite request has to land + // before the JOIN it enables. + for (const raw of this.config.perform ?? []) { + if (raw.trim()) this.raw(raw.trim()); + } + this.setState('joining'); + this.raw( + this.config.channelKey + ? `JOIN ${this.config.channel} ${this.config.channelKey}` + : `JOIN ${this.config.channel}` + ); + return; + } + case '366': { + // End of NAMES for the channel we asked for: we are in. + if (parts[2]?.toLowerCase() === this.config.channel.toLowerCase()) { + this.since = Date.now(); + this.setState('ready'); + this.pump(); + } + return; + } + case '433': + case '436': { + // Nick taken. Bots reconnect faster than servers time out ghosts, so a + // collision with our own previous session is the common case rather + // than the interesting one. + this.nickAttempt++; + if (this.nickAttempt > 3) { + this.fail('nick unavailable after three attempts'); + return; + } + this.nick = `${this.config.nick}${this.nickAttempt}`; + this.raw(`NICK ${this.nick}`); + return; + } + case '473': // +i, invite only + case '475': // wrong key + case '474': // banned + case '471': // full + this.fail(`channel refused the bot (${command})`); + return; + case 'KILL': + this.fail('killed by the server'); + return; + case 'ERROR': + this.fail(line.slice(0, 200)); + return; + default: + return; + } + } + + private raw(line: string): void { + if (!this.socket) return; + // One frame, one line — a caller that managed to smuggle a newline in here + // would be writing a second command. + const safe = line.replace(/[\r\n]/g, ' '); + try { + this.socket.write(`${safe}\r\n`); + } catch (err) { + this.fail((err as Error).message); + } + } + + /** + * Drain the queue, one line per `WRITE_INTERVAL_MS`. + * + * The interval is measured from the LAST line actually sent, not from the + * start of a drain. The first version armed its timer only when the queue was + * still non-empty after a shift, so every `say()` that arrived to an empty + * queue wrote immediately — which is every announce, since each one drains the + * queue. Ten uploads accepted in the same second went out inside a + * millisecond of each other, and an ircd answers that with a kill for excess + * flood: the queue existed and paced nothing. + */ + private pump(): void { + if (this.timer || this.state !== 'ready' || this.queue.length === 0) return; + + const wait = Math.max(0, WRITE_INTERVAL_MS - (Date.now() - this.lastSentAt)); + const send = () => { + this.timer = null; + if (this.state !== 'ready') return; + const next = this.queue.shift(); + if (next === undefined) return; + this.raw(`PRIVMSG ${this.config.channel} :${next}`); + this.lastSentAt = Date.now(); + this.sentCount++; + if (this.queue.length > 0) { + this.timer = setTimeout(send, WRITE_INTERVAL_MS); + this.timer.unref?.(); + } + }; + + if (wait === 0) { + send(); + return; + } + this.timer = setTimeout(send, wait); + this.timer.unref?.(); + } + + private armRegisterDeadline(): void { + // Kept in a field rather than discarded: without the handle a closed client + // — and the config object holding its credentials — stayed reachable for the + // length of the deadline after `close()`. + this.registerTimer = setTimeout(() => { + this.registerTimer = null; + if (this.state === 'registering' || this.state === 'joining') { + this.fail('registration did not complete'); + } + }, REGISTER_TIMEOUT_MS); + this.registerTimer.unref?.(); + } + + private clearConnectTimer(): void { + if (this.connectTimer) { + clearTimeout(this.connectTimer); + this.connectTimer = null; + } + } + + private stopTimers(): void { + this.clearConnectTimer(); + if (this.timer) { + clearTimeout(this.timer); + this.timer = null; + } + if (this.registerTimer) { + clearTimeout(this.registerTimer); + this.registerTimer = null; + } + if (this.pinger) { + clearInterval(this.pinger); + this.pinger = null; + } + } + + private fail(message: string): void { + this.lastError = message; + this.stopTimers(); + this.since = null; + if (this.socket) { + this.socket.removeAllListeners(); + this.socket.destroy(); + this.socket = null; + } + this.setState('error'); + } + + private setState(state: IrcState): void { + if (this.state === state) return; + this.state = state; + this.events.onState?.(this.status()); + } +} diff --git a/apps/api/utils/irc/format.ts b/apps/api/utils/irc/format.ts new file mode 100644 index 00000000..3fe09e26 --- /dev/null +++ b/apps/api/utils/irc/format.ts @@ -0,0 +1,363 @@ +/** + * The announce line, and the regular expression that reads it back. + * + * ## Why IRC at all, in 2026 + * + * Because autobrr and autodl-irssi speak it, and between them they are how + * releases are actually raced. The mechanism is deliberately archaic — a bot + * says one line per accepted upload, a client matches it against filters and + * grabs — and that is exactly why it is universal. An RSS feed is polled; a + * channel message arrives. + * + * ## The one design decision worth reading + * + * The line is a template the operator can change, and the parsing regex is + * DERIVED FROM THAT TEMPLATE rather than written next to it. + * + * Every tracker that ships a hand-written definition alongside a configurable + * format eventually ships two things that disagree, and the failure is silent: + * the channel keeps announcing, the definition keeps not matching, and members + * conclude the tracker is broken. Deriving one from the other makes that + * impossible by construction — `/api/irc/autobrr.yml` regenerates from the + * template in force, so an operator who reorders the fields gets a definition + * that reads the new order. + * + * It also settles the versioning question the roadmap worried about. The format + * is not frozen because it does not need to be: the template is stored in + * settings, so changing the DEFAULT here never changes what a running instance + * emits, and whatever it emits is what the generated definition parses. + * + * ## Every field is always present + * + * No optional segments: a torrent with no tags says `-`, an anonymous upload + * says `anonymous`, a release with no freeleech says `FL 0%`. A fixed shape + * costs a few characters and buys a regex with no optional groups — and an + * optional group is how a parser silently attributes one field's value to + * another when the middle one is missing. + * + * ## Names that mean something to autobrr + * + * The capture names are taken from autobrr's own `MapVars` — `releaseName`, + * `category`, `torrentSize`, `freeleechPercent`, `tags`, `uploader`, + * `torrentId` — so the values land in the fields its filters read. Two + * omissions are deliberate: + * + * - The upload multiplier is printed for people and mapped to nothing, because + * autobrr has no field for it. Inventing a variable name would produce a + * definition that looks richer and behaves identically. + * - The seeding requirement is not in the line. autobrr reads `minimumratio` + * and `minimumseedtime` from the Torznab feed, which this site now serves, + * and there is no IRC variable for either. A field no tool can consume is + * noise in a format that has to stay parseable for years. + */ + +/** The format the derived regex and the docs both describe. Bump on a change. */ +export const ANNOUNCE_FORMAT_VERSION = 1; + +/** + * What a token may hold, and what autobrr calls it. + * + * `pattern` is lazy on purpose. The literals between tokens anchor the match, + * and a greedy group would eat the next separator whenever a value happened to + * contain one — which is precisely the case `sanitiseValue` cannot fully rule + * out for a release name. + */ +export interface AnnounceToken { + /** autobrr's variable name, or null when nothing consumes it. */ + readonly variable: string | null; + /** Regex body, without the named-group wrapper. */ + readonly pattern: string; + /** One line, for the operator staring at the template field. */ + readonly describes: string; +} + +export const ANNOUNCE_TOKENS: Readonly<Record<string, AnnounceToken>> = { + name: { + variable: 'releaseName', + pattern: '.+?', + describes: 'the release name', + }, + category: { + variable: 'category', + pattern: '.+?', + describes: 'the category, or `uncategorised`', + }, + size: { + variable: 'torrentSize', + // A number and a unit. autobrr parses the unit itself, so the group has to + // keep them together. + pattern: '\\d+(?:\\.\\d+)?\\s*[KMGTP]?i?B', + describes: 'the total size, e.g. `14.62 GiB`', + }, + freeleechPercent: { + variable: 'freeleechPercent', + // 0% for a normal torrent, 100% for freeleech, and the values in between + // that per-torrent download multipliers make possible. + pattern: '\\d{1,3}%', + describes: 'how much of the download is free, e.g. `100%`', + }, + uploadFactor: { + // Printed for people. autobrr has no field for it — see the note above. + variable: null, + pattern: '\\d+(?:\\.\\d+)?', + describes: 'the upload multiplier, e.g. `2`', + }, + tags: { + variable: 'tags', + // Lazy and unrestricted, like `name` and `category`. It used to be + // `[^:]*?`, which looked conservative and was a live defect: `tags.name` is + // free text — only the SLUG is charset-restricted — so a member creating a + // tag called `quality:high` on their own upload made every release carrying + // that tag unparseable. Stripping the colon from values instead was worse, + // and a probe against a real ircd said so immediately: it turns `https://` + // into `https-//` in the link field. + pattern: '.+?', + describes: 'comma-separated tags, or `-`', + }, + uploader: { + variable: 'uploader', + pattern: '\\S+', + describes: 'the uploader, or `anonymous`', + }, + url: { + // Not `baseUrl`: this is the whole page address, and autobrr's `baseUrl` + // means the site root that its own templates prepend. + variable: null, + pattern: '\\S+', + describes: 'the link to the release page', + }, + infoHash: { + variable: 'torrentId', + // `torrentId` rather than `torrentHash` because the URL templates in the + // generated definition interpolate `{{ .torrentId }}`, and on this site the + // id in a download URL IS the v1 infohash. + pattern: '[a-f0-9]{40}', + describes: 'the v1 infohash — what the download URL is keyed on', + }, +} as const; + +export type AnnounceTokenName = keyof typeof ANNOUNCE_TOKENS; + +/** + * The default line. + * + * Shaped after the definitions autobrr already ships — a literal lead-in, then + * ` :: ` separated fields — so somebody who has written an indexer definition + * before recognises it, and `announcers` filtering on the first word works the + * way it does elsewhere. + */ +export const DEFAULT_ANNOUNCE_TEMPLATE = + 'NEW [{category}] {name} :: {size} :: FL {freeleechPercent} :: UL x{uploadFactor} :: {tags} :: by {uploader} :: {url} :: {infoHash}'; + +export interface AnnounceFields { + name: string; + category: string; + size: string; + freeleechPercent: string; + uploadFactor: string; + tags: string; + uploader: string; + url: string; + infoHash: string; +} + +/** How long a rendered line may be before it is cut. */ +const MAX_LINE_BYTES = 400; + +/** + * Make a value safe to put in an IRC message, and safe to parse back out. + * + * The first half is not cosmetic. IRC frames commands with CRLF, so a value + * carrying `\r\n` does not corrupt the line — it ENDS it, and everything after + * becomes a command the bot appears to have sent. A release name is + * member-supplied text, which makes this the injection boundary of the whole + * feature. + * + * The second half keeps the format readable: the separator is stripped from + * values so a name containing ` :: ` cannot invent a field, and control + * characters (including the colour codes IRC clients interpret) are dropped + * rather than escaped, because nothing downstream has a use for them. + */ +export function sanitiseValue(raw: string): string { + return ( + raw + // CR, LF, NUL and the rest of C0, plus DEL: the frame delimiters and the + // colour codes. Replaced with a space rather than removed, so two words + // separated by one do not silently become a single word. + .replace(/[\u0000-\u001f\u007f]/g, ' ') + .replace(/\s*::\s*/g, ' - ') + .replace(/\s+/g, ' ') + .trim() || '-' + ); +} + +/** Bytes, the way IRC counts them. */ +function byteLength(s: string): number { + return Buffer.byteLength(s, 'utf8'); +} + +/** + * Render one line, cutting the NAME rather than the line. + * + * Cutting the finished line was wrong in a way the sample could never show: the + * tail of the default template is `:: {url} :: {infoHash}`, and the pattern + * anchors on `[a-f0-9]{40}$`. A release name over about 170 characters — routine + * in anime and scene naming, and the upload route allows 256 — pushed the hash + * off the end, so the line was announced in a form no client could parse. Every + * such release, silently. + * + * So the name absorbs the overflow. Everything else in the line is short and + * structural, and a name is the one field a reader can still recognise from its + * first hundred characters. + * + * Byte-based, because the 512-byte frame is a byte limit and a release name is + * UTF-8; the cut walks back to a whole character so no client renders a + * replacement glyph. + */ +export function renderAnnounce( + template: string, + fields: AnnounceFields +): string { + const render = (values: AnnounceFields) => + template.replace(/\{(\w+)\}/g, (whole, token: string) => { + if (!(token in ANNOUNCE_TOKENS)) return whole; + const value = values[token as keyof AnnounceFields]; + return sanitiseValue(value == null ? '' : String(value)); + }); + + const line = render(fields); + if (byteLength(line) <= MAX_LINE_BYTES) return line; + + // What the rest of the line costs, measured rather than assumed — an operator + // template can be any shape. + const overhead = byteLength(render({ ...fields, name: '' })); + const budget = MAX_LINE_BYTES - overhead - 3; // the ellipsis is three bytes + if (budget <= 0) { + // A template whose fixed text alone overflows the frame: nothing to save by + // cutting the name, so fall back to cutting the line and let the operator + // see a short template is required. + let cut = line; + while (byteLength(cut) > MAX_LINE_BYTES - 3 && cut.length > 0) { + cut = cut.slice(0, -1); + } + return `${cut}…`; + } + + let name = sanitiseValue(fields.name ?? ''); + while (byteLength(name) > budget && name.length > 0) { + name = name.slice(0, -1); + } + return render({ ...fields, name: `${name}…` }); +} + +/** Which tokens a template uses, in order, ignoring anything unknown. */ +export function templateTokens(template: string): AnnounceTokenName[] { + const out: AnnounceTokenName[] = []; + for (const m of template.matchAll(/\{(\w+)\}/g)) { + const token = m[1]!; + if (token in ANNOUNCE_TOKENS) out.push(token as AnnounceTokenName); + } + return out; +} + +/** Regex metacharacters in the template's literal text. */ +function escapeLiteral(s: string): string { + return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +export interface AnnouncePattern { + /** RE2-compatible pattern with named groups — Go's regexp, which is what + * autobrr compiles it with. */ + pattern: string; + /** The autobrr variables it captures, in order. */ + variables: string[]; +} + +/** + * Turn a template into the pattern that reads its output. + * + * A token used twice gets a named group once and a NON-CAPTURING repeat after, + * and the reason is the opposite of what this comment first claimed. Measured + * against Go 1.26, which is what autobrr compiles the pattern with: + * + * duplicate group name `(?P<a>x) (?P<a>y)` → compiles + * backreference `(?P<a>x) (?P=a)` → invalid or unsupported Perl syntax + * + * RE2 has no backreference construct at all. Emitting one produced a definition + * autobrr rejects outright — and the round-trip check could not see it, because + * `toJsRegExp` rewrote it to `\k<name>`, which JavaScript does support. So the + * repeat drops the equality constraint between occurrences, which parsing does + * not need: the value is captured once, and the second occurrence only has to + * be matched. + */ +export function announcePattern(template: string): AnnouncePattern { + let pattern = ''; + const named = new Set<string>(); + const variables: string[] = []; + let last = 0; + + for (const m of template.matchAll(/\{(\w+)\}/g)) { + const token = m[1]!; + const start = m.index!; + pattern += escapeLiteral(template.slice(last, start)); + last = start + m[0].length; + + const def = ANNOUNCE_TOKENS[token]; + if (!def) { + // An unknown token is emitted literally by the renderer, so it is matched + // literally here. Anything else would make the pattern disagree with the + // line for the one input an operator typo produces. + pattern += escapeLiteral(m[0]); + continue; + } + if (named.has(token)) { + pattern += `(?:${def.pattern})`; + continue; + } + named.add(token); + pattern += `(?P<${token}>${def.pattern})`; + if (def.variable) variables.push(def.variable); + } + pattern += escapeLiteral(template.slice(last)); + return { pattern: `^${pattern}$`, variables }; +} + +/** + * The same pattern as a JavaScript RegExp. + * + * Go writes named groups `(?P<x>…)` and JavaScript writes `(?<x>…)`; the two + * differ in that one character and in nothing else that this pattern uses. + * Converting rather than generating twice is what lets the test parse the real + * shipped pattern instead of a lookalike. + */ +export function toJsRegExp(pattern: string): RegExp { + return new RegExp(pattern.replace(/\(\?P</g, '(?<')); +} + +/** + * The freeleech figure, from a download multiplier. + * + * `0` means the download is free, so it reads as 100%. Rounded to whole + * percent: the token's pattern accepts three digits and no decimal point, and a + * multiplier is a slider an operator set rather than a measurement. + */ +export function freeleechPercent(downloadMultiplier: number): string { + const clamped = Math.min(1, Math.max(0, downloadMultiplier)); + return `${Math.round((1 - clamped) * 100)}%`; +} + +/** `14.62 GiB` — binary units, the ones a torrent client shows. */ +export function humanSize(bytes: number): string { + const units = ['B', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB']; + let value = Math.max(0, bytes); + let unit = 0; + while (value >= 1024 && unit < units.length - 1) { + value /= 1024; + unit++; + } + // Whole bytes have no decimals; everything else gets two, which is what the + // size pattern accepts and what every client renders. + return unit === 0 + ? `${Math.round(value)} ${units[unit]}` + : `${value.toFixed(2)} ${units[unit]}`; +} diff --git a/apps/api/utils/irc/settings.ts b/apps/api/utils/irc/settings.ts new file mode 100644 index 00000000..7a8fe4dd --- /dev/null +++ b/apps/api/utils/irc/settings.ts @@ -0,0 +1,164 @@ +/** + * The operator's IRC configuration, and why it is one encrypted blob. + * + * Three of these fields are credentials — the server password, the SASL + * password, the channel key — and `settings` is a plaintext table. The + * notification channels solved the same problem already: one JSON value, + * AES-GCM at rest, key derived from the session secret or from a dedicated + * `CHANNEL_ENCRYPTION_KEY`. Reusing that is both less code and one fewer + * decision about where secrets live. + * + * The trade is that the whole config is read and written as a unit, which suits + * a connection you have to restart to reconfigure anyway. + */ +import { getSetting, setSetting } from '~~/utils/server'; +import { decryptJson, encryptJson } from '~~/utils/channelSecrets'; +import { DEFAULT_ANNOUNCE_TEMPLATE } from './format'; + +export const IRC_SETTINGS = { + /** Encrypted JSON: everything below except `enabled`. */ + CONFIG: 'irc_announce_config', + /** Plain, because the plugin reads it on every tick and it is not a secret. */ + ENABLED: 'irc_announce_enabled', +} as const; + +export interface IrcAnnounceConfig { + host: string; + port: number; + tls: boolean; + nick: string; + realname: string; + serverPassword: string; + saslUser: string; + saslPassword: string; + /** One raw IRC line per entry, sent after registration and before JOIN. */ + perform: string[]; + channel: string; + channelKey: string; + template: string; + /** + * The public address the announce line links to, e.g. `https://tracker.example.com`. + * + * The bot has no request to derive an origin from — unlike every other place + * in this codebase that builds an absolute URL — so it has to be told. + * Falling back to the federation identity's `public_url` covers the instances + * that already declared one; with neither, the line carries a path and says + * so in the admin console rather than inventing a hostname. + */ + siteUrl: string; + /** + * Whether the adult tree is announced. + * + * Off by default, and this is the one default here that is a judgement rather + * than a convenience. A channel is a single stream with no per-member + * preferences in it: everyone who joins sees every line. The site lets a + * member decide whether adult releases exist for them, and an announce + * channel cannot honour that decision — so the operator makes it once, for + * the channel, and the safe direction is the one that does not put titles + * nobody asked for in front of people who turned them off. + */ + announceAdult: boolean; +} + +export const IRC_DEFAULTS: IrcAnnounceConfig = { + host: '', + port: 6697, + tls: true, + nick: 'trackarr', + realname: 'Trackarr announce', + serverPassword: '', + saslUser: '', + saslPassword: '', + perform: [], + channel: '#announce', + channelKey: '', + template: DEFAULT_ANNOUNCE_TEMPLATE, + siteUrl: '', + announceAdult: false, +}; + +export async function getIrcEnabled(): Promise<boolean> { + // Off unless asked for: a tracker that started announcing to a channel + // because it was upgraded would be announcing without anyone deciding to. + return (await getSetting(IRC_SETTINGS.ENABLED)) === 'true'; +} + +export async function setIrcEnabled(enabled: boolean): Promise<void> { + await setSetting(IRC_SETTINGS.ENABLED, enabled ? 'true' : 'false'); +} + +export async function getIrcConfig(): Promise<IrcAnnounceConfig> { + const raw = await getSetting(IRC_SETTINGS.CONFIG); + if (!raw) return { ...IRC_DEFAULTS }; + try { + const stored = decryptJson<Partial<IrcAnnounceConfig>>(raw); + if (!stored) return { ...IRC_DEFAULTS }; + return { ...IRC_DEFAULTS, ...stored, perform: stored.perform ?? [] }; + } catch (err) { + // A blob that will not decrypt means the key changed — an operator rotating + // `CHANNEL_ENCRYPTION_KEY`, or a restore from a backup taken under another + // one. Returning defaults would silently disconnect the bot and lose the + // settings on the next save, so this is loud and the announcer stays down. + console.error( + '[IRC] Could not decrypt the announce config; leaving the bot off:', + (err as Error).message + ); + throw err; + } +} + +export async function setIrcConfig( + config: IrcAnnounceConfig +): Promise<void> { + await setSetting(IRC_SETTINGS.CONFIG, encryptJson(config)); +} + +/** + * What the admin console may see: the same config with the credentials blanked. + * + * `perform` is in that list, and it was the omission worth fixing: the field's + * documented purpose is a NickServ IDENTIFY line, so it is the entry most likely + * to hold a password — and it was being returned verbatim, rendered into a + * textarea, and embedded in the page's server-rendered payload. Admin-only, but + * the contract this module states is that a secret is never re-emitted, and a + * contract kept on three fields out of four is a contract nobody can rely on. + */ +export function redactIrcConfig( + config: IrcAnnounceConfig +): IrcAnnounceConfig & { + hasServerPassword: boolean; + hasSaslPassword: boolean; + hasChannelKey: boolean; + hasPerform: boolean; + performCount: number; +} { + return { + ...config, + serverPassword: '', + saslPassword: '', + channelKey: '', + perform: [], + hasServerPassword: !!config.serverPassword, + hasSaslPassword: !!config.saslPassword, + hasChannelKey: !!config.channelKey, + hasPerform: config.perform.length > 0, + performCount: config.perform.length, + }; +} + +/** + * Whether a config is complete enough to try. + * + * Deliberately not a Zod schema on the whole shape: the admin form sends + * partials (a secret left blank means "keep the stored one"), and the useful + * question at connect time is narrower than "is this valid". + */ +export function ircConfigReady(config: IrcAnnounceConfig): boolean { + return ( + !!config.host && + config.port > 0 && + config.port <= 65535 && + !!config.nick && + /^[#&]/.test(config.channel) + ); +} diff --git a/apps/api/utils/liveRoles.ts b/apps/api/utils/liveRoles.ts new file mode 100644 index 00000000..fe36d626 --- /dev/null +++ b/apps/api/utils/liveRoles.ts @@ -0,0 +1,108 @@ +/** + * Le rôle vivant d'un compte, et son cache. + * + * Extrait de `adminAuth.ts` pour une raison précise : `session.ts` doit + * pouvoir réconcilier les drapeaux de personnel dans `requireUserSession`, et + * `adminAuth.ts` importe déjà `getSessionId` depuis `session.ts`. Les faire + * s'importer l'un l'autre marcherait — les déclarations de fonction sont + * hissées — mais reposerait sur l'ordre d'évaluation des modules, ce qui n'est + * pas une base pour un contrôle d'autorisation. + */ +import { eq } from 'drizzle-orm'; +import { db } from '@trackarr/db'; +import { users } from '@trackarr/db/schema'; +import { redis } from '../redis/client'; + +/** + * Live staff-role lookup, cached for 60 s — backs the role + * re-validation in `requireModeratorSession` / `requireAdminSession`. + * + * The session cookie is a sealed, stateless 7-day token that bakes + * in `isAdmin` / `isModerator` at login time. Without this, a user + * demoted for cause kept a cookie that still asserted staff and + * could keep hitting admin/mod APIs for up to 7 days (finding M2). + * Re-reading the authoritative flags here (and bumping the cache on + * role change) closes that window to ≤ 60 s, mirroring the ban + * cache. Returns null when the user no longer exists. + */ +const ROLE_CACHE_TTL_S = 60; +const roleCacheKey = (userId: string) => `auth:role:${userId}`; + +export async function readLiveRoles(userId: string): Promise<{ + isAdmin: boolean; + isModerator: boolean; + isOwner: boolean; + /** Voir `users.session_epoch` : la génération courante des sessions. */ + sessionEpoch: number; +} | null> { + try { + const cached = await redis.get(roleCacheKey(userId)); + if (cached) { + const p = JSON.parse(cached) as { + a: boolean; + m: boolean; + o?: boolean; + e?: number; + }; + // A payload written before `o` existed is treated as a MISS rather than + // as `isOwner: false`. Otherwise the deploy that adds ownership answers + // 403 to the owner for up to the cache TTL — and the one thing that + // would fix it is the console they cannot reach. + // Même raisonnement pour `e` que pour `o` ci-dessus : une charge écrite + // avant que l'époque existe est un ÉCHEC de cache, pas une époque zéro. + // Sans cela, le déploiement qui ajoute la révocation lirait `0` pendant + // une minute et accepterait des sessions qu'un membre vient de révoquer. + if (p.o !== undefined && p.e !== undefined) { + return { + isAdmin: !!p.a, + isModerator: !!p.m, + isOwner: !!p.o, + sessionEpoch: p.e, + }; + } + } + } catch { + /* fall through to DB */ + } + const [row] = await db + .select({ + isAdmin: users.isAdmin, + isModerator: users.isModerator, + isOwner: users.isOwner, + sessionEpoch: users.sessionEpoch, + }) + .from(users) + .where(eq(users.id, userId)) + .limit(1); + if (!row) return null; + try { + await redis.setex( + roleCacheKey(userId), + ROLE_CACHE_TTL_S, + JSON.stringify({ + a: row.isAdmin, + m: row.isModerator, + o: row.isOwner, + e: row.sessionEpoch, + }) + ); + } catch { + /* no-op */ + } + return { + isAdmin: row.isAdmin, + isModerator: row.isModerator, + isOwner: row.isOwner, + sessionEpoch: row.sessionEpoch, + }; +} + +/** Drop the cached role state. Call from any path that changes a + * user's `is_admin` / `is_moderator` (role-change endpoint). */ +export async function invalidateRoleCache(userId: string): Promise<void> { + try { + await redis.del(roleCacheKey(userId)); + } catch { + /* no-op */ + } +} diff --git a/apps/api/utils/logger.ts b/apps/api/utils/logger.ts index f0e2cb76..5f032c36 100644 --- a/apps/api/utils/logger.ts +++ b/apps/api/utils/logger.ts @@ -1,4 +1,5 @@ import pino from 'pino'; +import { isIP } from 'net'; import { fingerprintIP } from './crypto'; const isProd = process.env.NODE_ENV === 'production'; @@ -85,12 +86,31 @@ function hashRequestIp(req: any): string { process.env.TRUST_PROXY === 'true' ? req.headers?.['x-forwarded-for'] : undefined; - // x-forwarded-for can be a comma-separated chain; only the leftmost - // entry is the original client. - const raw = - (typeof fwd === 'string' ? fwd.split(',')[0]?.trim() : undefined) || - req.socket?.remoteAddress || - null; + /* + * Le jeton le plus à DROITE, et validé comme une adresse. + * + * Le commentaire disait « only the leftmost entry is the original client ». + * C'est vrai d'un proxy qui REMPLACE l'en-tête, et faux de tous ceux qui y + * AJOUTENT — nginx `proxy_add_x_forwarded_for`, Traefik, HAProxy, la plupart + * des CDN. Derrière l'un de ceux-là, avec `TRUST_PROXY=true`, le client + * choisissait l'empreinte IP inscrite dans chaque ligne de journal de + * sécurité, et pouvait aussi en fabriquer à partir de chaînes qui ne sont pas + * des adresses. + * + * `rateLimit.ts` a reçu ce correctif (`firstValidIp`, droite→gauche + + * `net.isIP`) et le compte-rendu de sécurité le marque comme réglé ; ce + * fichier n'en faisait pas partie. Même parcours, même validation, ici aussi. + */ + const rightmostValid = (value: unknown): string | null => { + if (typeof value !== 'string') return null; + const parts = value.split(','); + for (let i = parts.length - 1; i >= 0; i--) { + const candidate = parts[i]!.trim(); + if (candidate && isIP(candidate)) return candidate; + } + return null; + }; + const raw = rightmostValid(fwd) || req.socket?.remoteAddress || null; if (!raw) return 'unknown'; try { return fingerprintIP(raw); diff --git a/apps/api/utils/metrics.ts b/apps/api/utils/metrics.ts index 0abcbaaa..fb8b6d1e 100644 --- a/apps/api/utils/metrics.ts +++ b/apps/api/utils/metrics.ts @@ -12,6 +12,24 @@ * typical scrape) so a Prometheus burst doesn't repeatedly walk the * keyspace. */ +/* + * `prom-client` est marqué déprécié (« replaced by @prometheus-io/client »). + * On y reste, délibérément, et voici les chiffres qui portent ce choix — pris + * le 2026-09-02 : + * + * - `prom-client@15.1.3` n'a AUCUNE alerte de sécurité (audit npm en masse + * sur les 1101 paquets du verrou : 0). Déprécié n'est pas vulnérable. + * - `@prometheus-io/client` en est à `0.16.1`, avec quatre versions publiées + * en tout, la première non-alpha le 2026-08-24. Le dépôt s'impose + * `minimumReleaseAge: 1440` justement pour ne pas dépendre du frais ; un + * 0.x de six jours est très en dessous de cette barre. + * - Il ajoute une dépendance d'exécution, `@opentelemetry/api`. + * + * La surface à migrer est pourtant minuscule — ce fichier est le seul + * appelant, et il n'utilise que les quatre symboles ci-dessous. Le jour où le + * successeur atteint une majeure stable, la bascule tient en un import. + * D'ici là, changer de bibliothèque de métriques ne corrigerait rien. + */ import { Registry, Gauge, diff --git a/apps/api/utils/mixedGroups.ts b/apps/api/utils/mixedGroups.ts index 7e717de9..6439bb6b 100644 --- a/apps/api/utils/mixedGroups.ts +++ b/apps/api/utils/mixedGroups.ts @@ -145,6 +145,19 @@ export interface MixedGroupRow { seedMax: number; leechMin: number; leechMax: number; + /** + * Les totaux du groupe, un par release distincte (`rn = 1`), c'est-à-dire + * sans compter deux fois une release qu'un partenaire a aussi. + * + * L'agrégat les calculait déjà — c'est sur eux que la liste se trie — mais + * la projection ne les recopiait pas, alors que le type côté navigateur les + * déclarait. La colonne TÉLÉCHARGEMENTS du catalogue groupé affichait donc + * `completedTotal ?? 0` pour toutes les lignes : une colonne triable de + * zéros. + */ + seedTotal: number; + leechTotal: number; + completedTotal: number; scopes: ScopeSummary[]; defaultScope: GroupScope; } @@ -184,6 +197,9 @@ type RawMixedGroup = RawScopeCounts & { seed_max: number | null; leech_min: number | null; leech_max: number | null; + seed_total: number | null; + leech_total: number | null; + completed_total: number | null; }; /** @@ -412,6 +428,9 @@ export async function listMixedGroups( seedMax: Number(r.seed_max ?? 0), leechMin: Number(r.leech_min ?? 0), leechMax: Number(r.leech_max ?? 0), + seedTotal: Number(r.seed_total ?? 0), + leechTotal: Number(r.leech_total ?? 0), + completedTotal: Number(r.completed_total ?? 0), scopes, defaultScope: pickDefault(scopes), }; diff --git a/apps/api/utils/naiveTimestamp.ts b/apps/api/utils/naiveTimestamp.ts new file mode 100644 index 00000000..e7588c87 --- /dev/null +++ b/apps/api/utils/naiveTimestamp.ts @@ -0,0 +1,40 @@ +/** + * Un horodatage sorti de `db.execute()` remis en instant absolu. + * + * `packages/db` déclare un analyseur pour l'OID 1114 — voir son en-tête : les + * colonnes `timestamp without time zone` du schéma contiennent de l'heure + * murale UTC, et cet analyseur les relit en UTC plutôt que dans le fuseau du + * processus. Il couvre `db.select()` et le constructeur de requêtes. + * + * Il ne couvre PAS `db.execute()`. Mesuré le 2026-09-02, deux fois — à travers + * l'API et en reproduisant le même chemin drizzle isolément : + * + * db.select(...) → 2026-09-02T16:41:33.452Z (Date) + * db.execute(sql`…`) → "2026-09-02 16:41:33.779157" (chaîne brute) + * + * postgres.js seul analyse correctement dans les quatre combinaisons + * (`unsafe` ou requête étiquetée, `prepare` vrai ou faux) : la perte se produit + * dans la couche drizzle. Le générique de `db.execute<{ created_at: Date }>` + * n'est qu'une assertion, jamais vérifiée — c'est ce qui a laissé passer la + * chose. + * + * Sans conversion, cette chaîne part telle quelle dans le JSON, et + * `new Date("2026-09-02 16:41:33.779157")` la lit dans le fuseau LOCAL du + * navigateur. Sur le forum, cela donnait « il y a 2 heures » pour un message + * publié depuis douze minutes, et un défaut d'hydratation par-dessus : le + * rendu serveur (conteneur en UTC) et le client (Europe/Paris) calculaient + * deux durées différentes à partir du même octet. + */ +export function naiveTimestampToIso( + value: Date | string | null | undefined +): string | null { + if (value == null) return null; + if (value instanceof Date) return value.toISOString(); + const raw = String(value); + // Déjà un instant : `…Z` ou un décalage explicite. On n'y touche pas. + if (/[Zz]$|[+-]\d{2}:?\d{2}$/.test(raw)) return new Date(raw).toISOString(); + // La forme que rend Postgres : « 2026-09-02 16:41:33.779157 », heure murale + // UTC. On la déclare telle en remplaçant l'espace et en ajoutant le Z. + const d = new Date(`${raw.replace(' ', 'T')}Z`); + return Number.isNaN(d.getTime()) ? null : d.toISOString(); +} diff --git a/apps/api/utils/notify.ts b/apps/api/utils/notify.ts index a0e08cc8..4ea4ac43 100644 --- a/apps/api/utils/notify.ts +++ b/apps/api/utils/notify.ts @@ -44,6 +44,18 @@ export type NotificationType = | 'upload_reset' | 'moderation_message_received' | 'torrent_deleted_by_staff' + // ── Catalogue health ─────────────────────────────────────── + /** + * Somebody asked for a dead torrent to be seeded again, and you are on the + * list because you downloaded it once. Not a demand — the member has no way + * to know whether you still hold the files. + */ + | 'reseed_requested' + /** + * A stored filter matched a newly accepted upload. Carries the filter's own + * label so a member running several knows which one fired. + */ + | 'saved_search_match' // ── P1 — Hit & Run ───────────────────────────────────────── | 'hnr_violation_marked' | 'hnr_cleared' diff --git a/apps/api/utils/notifyRenderer.ts b/apps/api/utils/notifyRenderer.ts index 38cde4a1..02b4dafd 100644 --- a/apps/api/utils/notifyRenderer.ts +++ b/apps/api/utils/notifyRenderer.ts @@ -48,6 +48,14 @@ const EN: Dict = { title: 'Torrent removed by staff', desc: '{actorUsername} deleted “{torrentName}”.', }, + saved_search_match: { + title: 'A saved search matched', + desc: '“{torrentName}” matches your saved search “{searchLabel}”.', + }, + reseed_requested: { + title: 'Reseed requested', + desc: '“{torrentName}” has no seeders. You downloaded it once — can you seed it again?', + }, hnr_violation_marked: { title: 'Hit & Run flagged', desc: '“{torrentName}” crossed the grace period without enough seeding.', @@ -257,6 +265,14 @@ const FR: Dict = { title: 'Torrent supprimé par un staff', desc: '{actorUsername} a supprimé « {torrentName} ».', }, + saved_search_match: { + title: 'Une recherche enregistrée correspond', + desc: '« {torrentName} » correspond à votre recherche « {searchLabel} ».', + }, + reseed_requested: { + title: 'Remise en partage demandée', + desc: '« {torrentName} » n’a plus aucune source. Vous l’avez téléchargé : pouvez-vous le repartager ?', + }, hnr_violation_marked: { title: 'Hit & Run signalé', desc: '« {torrentName} » a dépassé la période de grâce sans seed suffisant.', diff --git a/apps/api/utils/owner.ts b/apps/api/utils/owner.ts index 8c9b010f..7a82d397 100644 --- a/apps/api/utils/owner.ts +++ b/apps/api/utils/owner.ts @@ -34,7 +34,7 @@ */ import { and, asc, eq, isNull, ne, sql } from 'drizzle-orm'; import { db, schema } from '@trackarr/db'; -import { invalidateRoleCache } from './adminAuth'; +import { invalidateRoleCache } from './liveRoles'; /** A database handle, or the transaction standing in for one. */ type Writer = Pick<typeof db, 'select' | 'update'>; diff --git a/apps/api/utils/panic.ts b/apps/api/utils/panic.ts index 852bc51c..1ef628b3 100644 --- a/apps/api/utils/panic.ts +++ b/apps/api/utils/panic.ts @@ -19,10 +19,20 @@ import { createDecipheriv, randomBytes, scrypt, + type ScryptOptions, } from 'crypto'; import { promisify } from 'util'; -const scryptAsync = promisify(scrypt); +/** + * `promisify` ne retient qu'une des surcharges de `scrypt`, celle sans options. + * On déclare donc la forme qu'on utilise réellement. + */ +const scryptAsync = promisify(scrypt) as unknown as ( + password: string, + salt: Buffer, + keylen: number, + options?: ScryptOptions +) => Promise<Buffer>; const ALGORITHM = 'aes-256-gcm'; const KEY_LENGTH = 32; // 12-byte IVs are the AES-GCM standard (96-bit nonce); previous code @@ -31,11 +41,41 @@ const KEY_LENGTH = 32; // around 96 bits. const IV_LENGTH = 12; +/** + * Le coût scrypt, par version de KDF. + * + * La version 1 et la version 2 utilisaient les défauts de Node — N = 2^14, + * r = 8, p = 1, soit environ 16 Mo. L'OWASP demande N = 2^17 pour un mot de + * passe humain, et c'est ici que cela compte le plus : le mode panique existe + * pour le cas « la base a fuité », donc l'attaquant détient le sel et le + * chiffré, et le mot de passe de panique est choisi par une personne + * (douze caractères au minimum). Un facteur huit sur le coût est un facteur + * huit sur la durée d'une attaque hors ligne. + * + * Versionné plutôt que changé : une base chiffrée avant ce correctif doit + * rester restaurable, exactement comme la branche v1 → v2 déjà en place. + */ +const KDF_COST: Record<number, { N: number; r: number; p: number }> = { + 1: { N: 16384, r: 8, p: 1 }, + 2: { N: 16384, r: 8, p: 1 }, + 3: { N: 131072, r: 8, p: 1 }, +}; + +/** La version qu'écrit un nouveau chiffrement. */ +export const CURRENT_KDF_VERSION = 3; + export async function deriveKey( password: string, - salt: Buffer + salt: Buffer, + kdfVersion: number = CURRENT_KDF_VERSION ): Promise<Buffer> { - return (await scryptAsync(password, salt, KEY_LENGTH)) as Buffer; + const cost = KDF_COST[kdfVersion] ?? KDF_COST[1]!; + return (await scryptAsync(password, salt, KEY_LENGTH, { + ...cost, + // scrypt exige ~128 · N · r octets ; à N = 2^17 cela fait 128 Mo, au-delà + // du plafond par défaut de Node (32 Mo), qui refuserait sinon de dériver. + maxmem: 256 * 1024 * 1024, + })) as Buffer; } /** @@ -110,6 +150,40 @@ export function encryptField( return encrypt(value, key); } +/** + * Chiffrer une valeur qui l'est peut-être déjà. + * + * Ce dont une reprise a besoin. Le chiffrement de panique parcourt quatre + * tables ligne par ligne, hors transaction ; une interruption au milieu — délai + * HTTP, mémoire épuisée, conteneur redémarré — laissait la moitié des lignes + * chiffrées. Relancer la route repassait alors sur ces lignes et les + * SURCHIFFRAIT, ce que la restauration ne défait qu'une fois : la donnée + * devenait irrécupérable. + * + * La détection ne se fait PAS sur la forme. Un champ de texte libre peut + * ressembler à `a:b:c` par accident, et se tromper dans ce sens laisserait une + * donnée en clair dans une base annoncée comme chiffrée. On tente le + * déchiffrement sous la clé courante : s'il réussit, la valeur vient de ce même + * chiffrement et on n'y touche pas ; s'il échoue, c'est du clair. Une opération + * AES-GCM par champ, et la réponse est exacte plutôt que probable. + */ +export function encryptFieldOnce( + value: string | null | undefined, + key: Buffer, + legacyIv?: Buffer +): string | null { + if (value == null) return null; + if (value.split(':').length === 3) { + try { + decrypt(value, key, legacyIv); + return value; // déjà chiffré sous cette clé + } catch { + /* pas notre chiffré : c'est du clair qui contient des deux-points */ + } + } + return encrypt(value, key); +} + export function decryptField( value: string | null | undefined, key: Buffer, diff --git a/apps/api/utils/pow.ts b/apps/api/utils/pow.ts index 2505cc6d..08392623 100644 --- a/apps/api/utils/pow.ts +++ b/apps/api/utils/pow.ts @@ -43,8 +43,23 @@ export async function generatePoWChallenge(): Promise<PoWChallenge> { * Deletes challenge after verification to prevent reuse */ export async function verifyPoWSolution(solution: PoWSolution): Promise<boolean> { - // Check if challenge exists and hasn't been used - const exists = await redis.get(`pow:${solution.challenge}`); + /* + * `GETDEL` : la réclamation et la lecture en une opération. + * + * C'était un `GET` puis, tout en bas, un `DEL` — donc deux solutions + * concurrentes du MÊME défi passaient toutes les deux le `GET`, calculaient + * le même hachis valide, et étaient toutes deux acceptées. Le défi est + * censé être à usage unique ; la fenêtre entre les deux appels le rendait + * réutilisable autant de fois que le parallélisme le permettait. + * + * `twoFactor.ts` utilise déjà `getdel` pour exactement ce motif. + * + * Conséquence assumée du déplacement : un défi est consommé même si la + * solution se révèle fausse. C'est le bon sens de l'erreur — le client en + * demande un autre, et une tentative ratée ne doit pas offrir un essai + * gratuit. + */ + const exists = await redis.getdel(`pow:${solution.challenge}`); if (!exists) { return false; } @@ -64,9 +79,7 @@ export async function verifyPoWSolution(solution: PoWSolution): Promise<boolean> return false; } - // Delete challenge to prevent reuse (one-time use) - await redis.del(`pow:${solution.challenge}`); - + // Le défi a déjà été consommé par le `GETDEL` en tête : rien à supprimer ici. return true; } diff --git a/apps/api/utils/publicStats.ts b/apps/api/utils/publicStats.ts new file mode 100644 index 00000000..111f850f --- /dev/null +++ b/apps/api/utils/publicStats.ts @@ -0,0 +1,742 @@ +/** + * The numbers a member is allowed to see about the site, and about their own + * year. + * + * ## Why this is not `/api/admin/stats` with a softer gate + * + * The operator console answers "is the machine healthy" — Redis memory, + * database size, per-user request logs. A member asking "how is the site doing" + * is asking a different question, and most of the answers to the first one are + * either meaningless to them or nobody's business. So this module computes its + * own set rather than widening that one, and the difference is not cosmetic: the + * queries here are written to be cheap enough to serve to everybody, and every + * one of them is filtered by what the caller is allowed to know. + * + * ## What is deliberately absent + * + * **No per-member volume.** Uploaded and downloaded bytes are the numbers a + * tracker's leaderboards are traditionally built on, and there is no setting on + * this site by which a member could decline to appear in one. A ratio board + * would therefore publish, for every member, a figure they never agreed to + * publish. Upload COUNTS are different — a member's uploads are already listed + * on their profile, so counting them discloses nothing new — and that is what + * the board here ranks. + * + * **No member who uploads anonymously.** `users.anonymous_uploads` conceals a + * name on every surface that attributes a release. A leaderboard naming them + * would be the one surface that undoes it, so they are excluded from the board + * and still counted in the totals. + * + * **No adult release to somebody who has not opted in.** The catalogue's rule, + * applied to every list that names a torrent. + * + * ## The shape of the history + * + * `site_stats` is an hourly snapshot of cumulative counters. Nothing in it is a + * per-day figure, so the daily series are derived here: one point per day (the + * last snapshot of that day), and traffic per day as the difference between + * consecutive points. Both derivations are pure functions below, because both + * have a failure mode that a database cannot show you — a gap in the snapshots, + * and a counter that goes DOWN. + */ +import { and, desc, eq, gte, isNull, lt, sql } from 'drizzle-orm'; +import { db, schema } from '@trackarr/db'; +import { adultCategoryIds } from './adultContent'; + +// ───────────────────────────────────────────────────────────────────────────── +// Pure shaping +// ───────────────────────────────────────────────────────────────────────────── + +export interface Snapshot { + /** `YYYY-MM-DD`, formatted by Postgres — see `snapshots()`. */ + day: string; + at: Date; + users: number; + torrents: number; + peers: number; + seeders: number; + uploaded: number; +} + +export interface DailyPoint { + /** `YYYY-MM-DD`, UTC. */ + day: string; + users: number; + torrents: number; + peers: number; + seeders: number; + /** Cumulative bytes at the end of that day. */ + uploaded: number; +} + +/** + * One point per day: the LAST snapshot of each day, not an average. + * + * Averaging would be wrong for cumulative counters — the mean of a rising + * counter is a value it held at no point — and the last reading of the day is + * also the one the next day's difference has to be taken against. + * + * Days with no snapshot at all are simply absent rather than zero-filled. A + * restart, or an instance that was down for six hours, must not draw a cliff to + * zero and back on a chart of a counter that never moved. + */ +export function dailyPoints(rows: Snapshot[]): DailyPoint[] { + const byDay = new Map<string, Snapshot>(); + for (const row of rows) { + // The day comes from the query, not from `at.toISOString()`. `created_at` is + // `timestamp without time zone`, and postgres.js hands a zone-less value to + // `new Date()`, which reads it in the PROCESS's zone — and the shipped + // compose file sets `TZ=Europe/Paris`. Bucketing in JavaScript therefore cut + // the days at 22:00 UTC and labelled them "UTC", which put a New Year's Eve + // upload in the previous year's review. + const day = row.day; + const seen = byDay.get(day); + if (!seen || row.at > seen.at) byDay.set(day, row); + } + return [...byDay.entries()] + .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)) + .map(([day, s]) => ({ + day, + users: s.users, + torrents: s.torrents, + peers: s.peers, + seeders: s.seeders, + uploaded: s.uploaded, + })); +} + +export interface DailyDelta { + day: string; + /** Bytes that moved that day. Never negative — see below. */ + bytes: number; + /** Torrents added that day. Never negative, same reason. */ + torrents: number; + /** Members who joined that day. */ + users: number; +} + +/** + * Per-day movement, from consecutive cumulative points. + * + * A decrease is clamped to zero rather than reported, and that is the whole + * reason this is a tested function. `total_uploaded_bytes` is + * `SUM(users.uploaded)`, so **erasing an account lowers it** — as does a + * moderator zeroing a cheater's stats, or a torrent being deleted. Reporting + * "-4.2 TB of traffic on Tuesday" would be worse than useless: it is a figure a + * reader would try to explain. + * + * The first day has no predecessor, so it is dropped rather than compared + * against zero — which would otherwise print the site's entire history as one + * day's traffic. + */ +/** Whole days between two `YYYY-MM-DD` labels. */ +function daysBetween(a: string, b: string): number { + return Math.round( + (Date.parse(`${b}T00:00:00Z`) - Date.parse(`${a}T00:00:00Z`)) / 86_400_000 + ); +} + +export function dailyDeltas(points: DailyPoint[]): DailyDelta[] { + const out: DailyDelta[] = []; + for (let i = 1; i < points.length; i++) { + const prev = points[i - 1]!; + const cur = points[i]!; + /** + * A gap in the snapshots is skipped rather than attributed. + * + * `dailyPoints` deliberately omits a day with no snapshot, so after an + * outage from the 3rd to the 7th the 8th's difference covers five days of + * traffic — and `busiestDay` then names the day after the longest outage as + * the busiest of the year, every time, with a bar that dwarfs the chart. + * There is no way to split it honestly, so it is left out. + */ + if (daysBetween(prev.day, cur.day) !== 1) continue; + out.push({ + day: cur.day, + bytes: Math.max(0, cur.uploaded - prev.uploaded), + torrents: Math.max(0, cur.torrents - prev.torrents), + users: Math.max(0, cur.users - prev.users), + }); + } + return out; +} + +/** The busiest day by bytes moved, or null when there is nothing to compare. */ +export function busiestDay(deltas: DailyDelta[]): DailyDelta | null { + let best: DailyDelta | null = null; + for (const d of deltas) { + if (d.bytes > 0 && (!best || d.bytes > best.bytes)) best = d; + } + return best; +} + +/** + * The half-open UTC window for a calendar year. + * + * UTC rather than the instance's local time, deliberately: the members of one + * tracker are spread across every timezone, so a "year" anchored on the + * server's own offset would be an arbitrary choice presented as a fact. The + * boundary is stated in the guide. + */ +export function yearWindow(year: number): { start: Date; end: Date } { + return { + start: new Date(Date.UTC(year, 0, 1)), + end: new Date(Date.UTC(year + 1, 0, 1)), + }; +} + +/** + * Which years an instance can be asked about: the year of its first snapshot + * through the current one, newest first. + * + * Bounded by data rather than by a constant so the selector cannot offer a year + * the site did not exist for — an empty review reads like a broken page. + */ +export function selectableYears(firstSeen: Date | null, now: Date): number[] { + const current = now.getUTCFullYear(); + const first = firstSeen ? firstSeen.getUTCFullYear() : current; + const out: number[] = []; + for (let y = current; y >= first; y--) out.push(y); + return out; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Queries +// ───────────────────────────────────────────────────────────────────────────── + +/** + * The catalogue predicate every list here shares: a release that is live and + * has been through moderation, minus the adult tree when the caller has not + * asked for it. + * + * One definition, for the same reason `uploaderVisibility` is one definition: + * five hand-written copies of "what a member may see" will disagree, and the + * failure mode is a leak rather than a wrong total. + */ +function visibleTorrents(adultIds: string[]) { + const parts = [ + eq(schema.torrents.isActive, true), + eq(schema.torrents.moderationStatus, 'accepted'), + ]; + if (adultIds.length) { + // `notInArray` on a nullable column is null-safe here only because an + // uncategorised torrent cannot be in the adult tree: the OR keeps it. + parts.push( + sql`(${schema.torrents.categoryId} IS NULL OR ${schema.torrents.categoryId} NOT IN ${adultIds})` + ); + } + return and(...parts); +} + +/** The adult ids to exclude — empty when the member has opted in. */ +export async function hiddenCategoryIds(showAdult: boolean): Promise<string[]> { + if (showAdult) return []; + return adultCategoryIds(); +} + +export interface SiteNow { + torrents: number; + members: number; + seeders: number; + leechers: number; + snatches: number; + /** Bytes catalogued (sum of torrent sizes), and bytes moved (announce deltas). */ + catalogued: number; + trafficTotal: number; +} + +export async function siteNow(adultIds: string[]): Promise<SiteNow> { + const where = visibleTorrents(adultIds); + + const [counts] = await db + .select({ + torrents: sql<number>`count(*)::int`, + catalogued: sql<number>`coalesce(sum(${schema.torrents.size}), 0)::bigint`, + }) + .from(schema.torrents) + .where(where); + + const [swarm] = await db + .select({ + // `sum()` of an integer is a bigint, and `completed` is cumulative: a + // catalogue past roughly 2.1 billion total completions would have made the + // `::int` cast throw `integer out of range` and taken the whole page with + // it. Kept as bigint and narrowed in JS, like the byte figures. + seeders: sql<number>`coalesce(sum(${schema.torrentStats.seeders}), 0)::bigint`, + leechers: sql<number>`coalesce(sum(${schema.torrentStats.leechers}), 0)::bigint`, + snatches: sql<number>`coalesce(sum(${schema.torrentStats.completed}), 0)::bigint`, + }) + .from(schema.torrentStats) + .innerJoin( + schema.torrents, + eq(schema.torrents.infoHash, schema.torrentStats.infoHash) + ) + .where(where); + + // Members, not rows: an erased account keeps its row (nothing cascades from + // `users`), so counting rows would count people who asked to be forgotten. + const [members] = await db + .select({ n: sql<number>`count(*)::int` }) + .from(schema.users) + .where(isNull(schema.users.deletedAt)); + + const [latest] = await db + .select({ uploaded: schema.siteStats.totalUploadedBytes }) + .from(schema.siteStats) + .orderBy(desc(schema.siteStats.createdAt)) + .limit(1); + + return { + torrents: counts?.torrents ?? 0, + members: members?.n ?? 0, + seeders: Number(swarm?.seeders ?? 0), + leechers: Number(swarm?.leechers ?? 0), + snatches: Number(swarm?.snatches ?? 0), + catalogued: Number(counts?.catalogued ?? 0), + trafficTotal: Number(latest?.uploaded ?? 0), + }; +} + +/** Raw snapshots for a window, oldest first. */ +export async function snapshots(since: Date, until?: Date): Promise<Snapshot[]> { + const rows = await db + .select({ + // Formatted by Postgres so the label is the stored (UTC) date rather than + // whatever zone this process happens to run in. + day: sql<string>`to_char(${schema.siteStats.createdAt}, 'YYYY-MM-DD')`, + at: schema.siteStats.createdAt, + users: schema.siteStats.usersCount, + torrents: schema.siteStats.torrentsCount, + peers: schema.siteStats.peersCount, + seeders: schema.siteStats.seedersCount, + uploaded: schema.siteStats.totalUploadedBytes, + }) + .from(schema.siteStats) + .where( + until + ? and( + gte(schema.siteStats.createdAt, since), + lt(schema.siteStats.createdAt, until) + ) + : gte(schema.siteStats.createdAt, since) + ) + .orderBy(schema.siteStats.createdAt); + return rows.map((r) => ({ ...r, uploaded: Number(r.uploaded) })); +} + +export interface CategorySlice { + id: string; + name: string; + slug: string; + torrents: number; + bytes: number; +} + +export async function categoryBreakdown( + adultIds: string[] +): Promise<CategorySlice[]> { + const rows = await db + .select({ + id: schema.categories.id, + name: schema.categories.name, + slug: schema.categories.slug, + torrents: sql<number>`count(*)::int`, + bytes: sql<number>`coalesce(sum(${schema.torrents.size}), 0)::bigint`, + }) + .from(schema.torrents) + .innerJoin( + schema.categories, + eq(schema.categories.id, schema.torrents.categoryId) + ) + .where(visibleTorrents(adultIds)) + .groupBy(schema.categories.id, schema.categories.name, schema.categories.slug) + .orderBy(desc(sql`count(*)`)); + return rows.map((r) => ({ ...r, bytes: Number(r.bytes) })); +} + +export interface TopTorrent { + infoHash: string; + name: string; + categoryName: string | null; + size: number; + seeders: number; + snatches: number; + createdAt: Date; +} + +/** + * Releases ranked by one column, `snatches` or `seeders`. + * + * The ordering column is chosen from a closed set rather than interpolated — + * this is an ORDER BY, which no amount of escaping makes safe from a caller. + */ +export async function topTorrents( + by: 'snatches' | 'seeders', + adultIds: string[], + limit: number +): Promise<TopTorrent[]> { + const column = + by === 'snatches' ? schema.torrentStats.completed : schema.torrentStats.seeders; + const rows = await db + .select({ + infoHash: schema.torrents.infoHash, + name: schema.torrents.name, + categoryName: schema.categories.name, + size: schema.torrents.size, + seeders: schema.torrentStats.seeders, + snatches: schema.torrentStats.completed, + createdAt: schema.torrents.createdAt, + }) + .from(schema.torrents) + .innerJoin( + schema.torrentStats, + eq(schema.torrentStats.infoHash, schema.torrents.infoHash) + ) + .leftJoin( + schema.categories, + eq(schema.categories.id, schema.torrents.categoryId) + ) + .where(and(visibleTorrents(adultIds), sql`${column} > 0`)) + .orderBy(desc(column)) + .limit(limit); + return rows.map((r) => ({ ...r, size: Number(r.size) })); +} + +export interface TopUploader { + /** The profile page routes on the id, not the name. */ + id: string; + username: string; + uploads: number; +} + +/** + * Members ranked by how many live releases they have contributed. + * + * Counts, never bytes — see the note at the top of the file — and never a + * member who uploads anonymously. + */ +export async function topUploaders( + adultIds: string[], + limit: number +): Promise<TopUploader[]> { + const rows = await db + .select({ + id: schema.users.id, + username: schema.users.username, + uploads: sql<number>`count(*)::int`, + }) + .from(schema.torrents) + .innerJoin(schema.users, eq(schema.users.id, schema.torrents.uploaderId)) + .where( + and( + visibleTorrents(adultIds), + eq(schema.users.anonymousUploads, false), + isNull(schema.users.deletedAt), + // A banned account is not a member of the site any more, and a cheater + // heading the public board is the worst version of this page. + eq(schema.users.isBanned, false) + ) + ) + .groupBy(schema.users.id, schema.users.username) + .orderBy(desc(sql`count(*)`)) + .limit(limit); + return rows; +} + +export interface YearInReview { + year: number; + /** Null until the year has a first snapshot — a year before the site existed. */ + trafficBytes: number | null; + torrentsAdded: number; + bytesAdded: number; + membersJoined: number; + uploadersActive: number; + snatches: number; + busiestDay: DailyDelta | null; + months: Array<{ month: number; torrents: number; bytes: number }>; + topCategories: CategorySlice[]; + topReleases: TopTorrent[]; +} + +export async function siteYear( + year: number, + adultIds: string[] +): Promise<YearInReview> { + const { start, end } = yearWindow(year); + const inYear = and( + gte(schema.torrents.createdAt, start), + lt(schema.torrents.createdAt, end) + ); + const where = and(visibleTorrents(adultIds), inYear); + + const [added] = await db + .select({ + torrents: sql<number>`count(*)::int`, + bytes: sql<number>`coalesce(sum(${schema.torrents.size}), 0)::bigint`, + uploaders: sql<number>`count(distinct ${schema.torrents.uploaderId})::int`, + }) + .from(schema.torrents) + .where(where); + + const [joined] = await db + .select({ n: sql<number>`count(*)::int` }) + .from(schema.users) + .where( + and( + gte(schema.users.createdAt, start), + lt(schema.users.createdAt, end), + isNull(schema.users.deletedAt) + ) + ); + + /** + * COMPLETIONS dated inside the year, not grabs. + * + * `hnr_tracking` is the only dated per-download record — `torrent_stats.completed` + * is a running total with no date — but a row is written when a member clicks + * the `.torrent`, before a single byte moves. Counting rows therefore counted + * downloads of a metainfo file, while the figure beside it in the header counts + * real completions: two different quantities under one word. `completed_at` + * is what makes them the same question. + */ + const [snatched] = await db + .select({ n: sql<number>`count(*)::int` }) + .from(schema.hnrTracking) + .innerJoin( + schema.torrents, + eq(schema.torrents.id, schema.hnrTracking.torrentId) + ) + .where( + and( + visibleTorrents(adultIds), + sql`${schema.hnrTracking.completedAt} is not null`, + gte(schema.hnrTracking.completedAt, start), + lt(schema.hnrTracking.completedAt, end) + ) + ); + + const monthRows = await db + .select({ + month: sql<number>`extract(month from ${schema.torrents.createdAt})::int`, + torrents: sql<number>`count(*)::int`, + bytes: sql<number>`coalesce(sum(${schema.torrents.size}), 0)::bigint`, + }) + .from(schema.torrents) + .where(where) + .groupBy(sql`extract(month from ${schema.torrents.createdAt})`) + .orderBy(sql`extract(month from ${schema.torrents.createdAt})`); + + const catRows = await db + .select({ + id: schema.categories.id, + name: schema.categories.name, + slug: schema.categories.slug, + torrents: sql<number>`count(*)::int`, + bytes: sql<number>`coalesce(sum(${schema.torrents.size}), 0)::bigint`, + }) + .from(schema.torrents) + .innerJoin( + schema.categories, + eq(schema.categories.id, schema.torrents.categoryId) + ) + .where(where) + .groupBy(schema.categories.id, schema.categories.name, schema.categories.slug) + .orderBy(desc(sql`count(*)`)) + .limit(5); + + const releaseRows = await db + .select({ + infoHash: schema.torrents.infoHash, + name: schema.torrents.name, + categoryName: schema.categories.name, + size: schema.torrents.size, + seeders: schema.torrentStats.seeders, + snatches: schema.torrentStats.completed, + createdAt: schema.torrents.createdAt, + }) + .from(schema.torrents) + .innerJoin( + schema.torrentStats, + eq(schema.torrentStats.infoHash, schema.torrents.infoHash) + ) + .leftJoin( + schema.categories, + eq(schema.categories.id, schema.torrents.categoryId) + ) + .where(where) + .orderBy(desc(schema.torrentStats.completed)) + .limit(5); + + // Traffic across the year: the difference between the first and last snapshot + // inside it. Clamped, because the same counter can go down. + const points = dailyPoints(await snapshots(start, end)); + const deltas = dailyDeltas(points); + const traffic = points.length >= 2 + ? Math.max(0, points[points.length - 1]!.uploaded - points[0]!.uploaded) + : null; + + return { + year, + trafficBytes: traffic, + torrentsAdded: added?.torrents ?? 0, + bytesAdded: Number(added?.bytes ?? 0), + membersJoined: joined?.n ?? 0, + uploadersActive: added?.uploaders ?? 0, + snatches: snatched?.n ?? 0, + busiestDay: busiestDay(deltas), + months: monthRows.map((m) => ({ ...m, bytes: Number(m.bytes) })), + topCategories: catRows.map((c) => ({ ...c, bytes: Number(c.bytes) })), + topReleases: releaseRows.map((r) => ({ ...r, size: Number(r.size) })), + }; +} + +export interface MemberYear { + year: number; + uploads: number; + uploadedBytesCatalogued: number; + snatches: number; + seedTimeSeconds: number; + bytesUp: number; + bytesDown: number; + bonusEarned: number; + invitesUsed: number; + topCategory: { name: string; torrents: number } | null; + bestRelease: { infoHash: string; name: string; snatches: number } | null; +} + +/** + * One member's year, for that member only. + * + * Their own numbers, so there is nothing to redact — the adult filter is not + * applied here either, since a member who downloaded something already saw it, + * and hiding their own history from them would be a lie about their own year. + */ +export async function memberYear( + userId: string, + year: number +): Promise<MemberYear> { + const { start, end } = yearWindow(year); + + const [uploads] = await db + .select({ + n: sql<number>`count(*)::int`, + bytes: sql<number>`coalesce(sum(${schema.torrents.size}), 0)::bigint`, + }) + .from(schema.torrents) + .where( + and( + eq(schema.torrents.uploaderId, userId), + gte(schema.torrents.createdAt, start), + lt(schema.torrents.createdAt, end) + ) + ); + + // The hit-and-run ledger doubles as the member's own download record: one row + // per (member, torrent) grab, with the bytes that actually moved on it. + const [grabs] = await db + .select({ + n: sql<number>`count(*)::int`, + seedTime: sql<number>`coalesce(sum(${schema.hnrTracking.seedTime}), 0)::bigint`, + up: sql<number>`coalesce(sum(${schema.hnrTracking.uploaded}), 0)::bigint`, + down: sql<number>`coalesce(sum(${schema.hnrTracking.downloaded}), 0)::bigint`, + }) + .from(schema.hnrTracking) + .where( + and( + eq(schema.hnrTracking.userId, userId), + gte(schema.hnrTracking.downloadedAt, start), + lt(schema.hnrTracking.downloadedAt, end) + ) + ); + + const [bonus] = await db + .select({ + amount: sql<number>`coalesce(sum(${schema.bonusGrants.amount}), 0)::numeric`, + }) + .from(schema.bonusGrants) + .where( + and( + eq(schema.bonusGrants.userId, userId), + gte(schema.bonusGrants.createdAt, start), + lt(schema.bonusGrants.createdAt, end) + ) + ); + + const [invites] = await db + .select({ n: sql<number>`count(*)::int` }) + .from(schema.invitations) + .where( + and( + eq(schema.invitations.createdBy, userId), + sql`${schema.invitations.usedBy} IS NOT NULL`, + gte(schema.invitations.createdAt, start), + lt(schema.invitations.createdAt, end) + ) + ); + + const [category] = await db + .select({ + name: schema.categories.name, + torrents: sql<number>`count(*)::int`, + }) + .from(schema.torrents) + .innerJoin( + schema.categories, + eq(schema.categories.id, schema.torrents.categoryId) + ) + .where( + and( + eq(schema.torrents.uploaderId, userId), + gte(schema.torrents.createdAt, start), + lt(schema.torrents.createdAt, end) + ) + ) + .groupBy(schema.categories.name) + .orderBy(desc(sql`count(*)`)) + .limit(1); + + const [best] = await db + .select({ + infoHash: schema.torrents.infoHash, + name: schema.torrents.name, + snatches: schema.torrentStats.completed, + }) + .from(schema.torrents) + .innerJoin( + schema.torrentStats, + eq(schema.torrentStats.infoHash, schema.torrents.infoHash) + ) + .where( + and( + eq(schema.torrents.uploaderId, userId), + gte(schema.torrents.createdAt, start), + lt(schema.torrents.createdAt, end) + ) + ) + .orderBy(desc(schema.torrentStats.completed)) + .limit(1); + + return { + year, + uploads: uploads?.n ?? 0, + uploadedBytesCatalogued: Number(uploads?.bytes ?? 0), + snatches: grabs?.n ?? 0, + seedTimeSeconds: Number(grabs?.seedTime ?? 0), + bytesUp: Number(grabs?.up ?? 0), + bytesDown: Number(grabs?.down ?? 0), + bonusEarned: Number(bonus?.amount ?? 0), + invitesUsed: invites?.n ?? 0, + topCategory: category ?? null, + bestRelease: best ?? null, + }; +} + +/** The first snapshot the instance ever wrote, for the year selector. */ +export async function firstSnapshotAt(): Promise<Date | null> { + const [row] = await db + .select({ at: schema.siteStats.createdAt }) + .from(schema.siteStats) + .orderBy(schema.siteStats.createdAt) + .limit(1); + return row?.at ?? null; +} diff --git a/apps/api/utils/rateLimit.ts b/apps/api/utils/rateLimit.ts index a3c8280a..98de07a7 100644 --- a/apps/api/utils/rateLimit.ts +++ b/apps/api/utils/rateLimit.ts @@ -165,7 +165,29 @@ function normalizeIP(ip: string): string { // Blacklist Management // ============================================================================ -const BLACKLIST_KEY = 'ddos:blacklist'; +/** + * Une clé par adresse, et non un hachis unique. + * + * C'était `HSET ddos:blacklist <ip>` avec un `EXPIRE` sur le hachis ENTIER. Un + * champ ne disparaissait qu'au moment où cette même adresse était réinterrogée + * et trouvée expirée — donc jamais, pour un attaquant qui ne revient pas — et + * chaque nouvelle mise en liste noire repoussait le TTL du hachis complet à + * 24 h, si bien que tant qu'un abus durait le hachis ne s'auto-nettoyait pas. + * À ~200 octets par champ, un million d'adresses distinctes retenaient 200 Mo + * pendant au moins 24 h, dans le Redis qui porte aussi les sessions et les + * seaux de limitation. + * + * Le repli en mémoire, lui, était plafonné à 100 000 entrées avec éviction — + * « bounded to prevent unbounded growth when Redis is down under abuse ». Le + * chemin Redis, celui qui sert en production, n'avait rien. Avec une clé par + * adresse, Redis fait l'expiration lui-même : le jeu ne peut plus croître sans + * borne et une mise en liste noire ne prolonge plus les autres. + */ +const blacklistKey = (ip: string) => `ddos:bl:${ip}`; + +/** L'ancien hachis, conservé le temps qu'une instance en cours d'exécution le + * laisse expirer de lui-même. Lu, plus jamais écrit. */ +const LEGACY_BLACKLIST_KEY = 'ddos:blacklist'; const BLACKLIST_DURATION_BASE = 300; // 5 minutes base const MAX_BLACKLIST_DURATION = 86400; // 24 hours max @@ -176,12 +198,14 @@ export async function isBlacklisted(ip: string): Promise<boolean> { const normalizedIP = normalizeIP(ip); try { - const data = await redis.hget(BLACKLIST_KEY, normalizedIP); - if (!data) return false; - - const entry: BlacklistEntry = JSON.parse(data); + if ((await redis.exists(blacklistKey(normalizedIP))) === 1) return true; + // Le hachis d'avant, pendant sa fenêtre d'expiration. Retiré au passage + // dès qu'il est périmé, pour que la transition se termine d'elle-même. + const legacy = await redis.hget(LEGACY_BLACKLIST_KEY, normalizedIP); + if (!legacy) return false; + const entry: BlacklistEntry = JSON.parse(legacy); if (entry.expiresAt < Date.now()) { - await redis.hdel(BLACKLIST_KEY, normalizedIP); + await redis.hdel(LEGACY_BLACKLIST_KEY, normalizedIP); return false; } return true; @@ -226,8 +250,12 @@ export async function blacklistIP( ); try { - await redis.hset(BLACKLIST_KEY, normalizedIP, JSON.stringify(entry)); - await redis.expire(BLACKLIST_KEY, MAX_BLACKLIST_DURATION); + await redis.set( + blacklistKey(normalizedIP), + JSON.stringify(entry), + 'EX', + Math.ceil(duration / 1000) + ); } catch { setBounded(memoryBlacklist, normalizedIP, entry); } @@ -240,7 +268,9 @@ async function getViolationCount(ip: string): Promise<number> { const normalizedIP = normalizeIP(ip); try { - const data = await redis.hget(BLACKLIST_KEY, normalizedIP); + const data = + (await redis.get(blacklistKey(normalizedIP))) ?? + (await redis.hget(LEGACY_BLACKLIST_KEY, normalizedIP)); if (!data) return 0; const entry: BlacklistEntry = JSON.parse(data); return entry.violations; @@ -390,15 +420,58 @@ export async function rateLimit( }); } - const key = `${prefix}:${normalizedIP}`; + /* + * Le sujet compté : le compte quand il y en a un, l'adresse sinon. + * + * C'était l'adresse, toujours. Trois conséquences, dont la première est un + * déni de service que n'importe quel membre pouvait déclencher : + * + * - **Dommage collatéral.** Sur un tracker privé, l'usage du VPN est + * quasi universel et les adresses de sortie sont massivement partagées. + * Tous les membres derrière une même sortie partageaient un seau de dix + * écritures par minute. + * - **Escalade en 403 pour tout le monde.** Les seaux `mutation`, `public`, + * `auth` et `tracker` portent `progressive: true` : au dépassement, l'IP + * ENTIÈRE part en liste noire pour 5 min, doublant jusqu'à 24 h — et + * `middleware/security.ts` évalue cette liste AVANT toute + * authentification, sans exemption. Onze étiquettes éditées en une minute + * expulsaient donc du site, pendant cinq minutes, tout le monde sur la + * même adresse, personnel compris. Déclenchable délibérément contre un + * voisin identifié. + * - **Évasion triviale.** Un /64 IPv6 résidentiel offre 2⁶⁴ adresses + * sources ; chaque rotation remettait le seau à zéro et la liste noire + * punissait l'adresse déjà abandonnée. La limite mordait le membre + * honnête à adresse stable, pas l'attaquant. + * + * La primitive correcte était déjà dans ce fichier — `rateLimitIdentity`, + * écrite pour la fédération avec exactement ce raisonnement — et n'avait pas + * été portée à la surface des membres. + * + * `getUserSession` plutôt que `event.context.auditActor` : plusieurs routes + * appellent `rateLimit` AVANT leur garde d'authentification, et faire + * dépendre la correction d'un ordre que chaque route peut se tromper à écrire + * est ce qui a produit ce défaut. La lecture est celle du cookie scellé, donc + * sans I/O. + */ + let actorId: string | undefined; + try { + actorId = (await getUserSession(event))?.user?.id; + } catch { + /* pas de session lisible : on compte l'adresse */ + } + const key = `${prefix}:${actorId ? `u:${actorId}` : normalizedIP}`; const result = useRedis ? await rateLimitRedis(key, windowSec, maxRequests) : rateLimitMemory(key, windowSec, maxRequests); if (result.blocked) { - // Progressive penalty: blacklist after multiple rate limit violations - if (progressive) { + // Progressive penalty: blacklist after multiple rate limit violations. + // + // Jamais pour un compte : la sanction doit viser le sujet compté. Mettre + // une adresse en liste noire parce qu'un compte a dépassé son seau est + // précisément ce qui permettait d'expulser ses voisins. + if (progressive && !actorId) { const violations = await getViolationCount(normalizedIP); if (violations >= 3) { await blacklistIP( diff --git a/apps/api/utils/safeFetch.ts b/apps/api/utils/safeFetch.ts index c061d3a8..a6299fe7 100644 --- a/apps/api/utils/safeFetch.ts +++ b/apps/api/utils/safeFetch.ts @@ -17,15 +17,46 @@ * `localhost`. * 3. Caps the redirect chain at `maxRedirects` (default 5) so a * malicious upstream can't ping-pong us indefinitely. + * 4. Drops every header the caller supplied at the first hop that + * crosses an origin boundary, and refuses outright to replay a + * request BODY across one. + * + * Le point 4 est arrivé après les trois autres, et il fermait une porte que + * les trois premiers laissaient grande ouverte. La validation d'hôte était + * bien rejouée à chaque saut — donc pas de SSRF — mais l'`init` de l'appelant + * était réinjecté tel quel dans chaque `fetch`, en-têtes compris. Une cible + * publique qui répond `302 Location: https://attaquant.tld` recevait donc les + * seize en-têtes que le membre a configurés sur son webhook, son HMAC de + * corps, l'`Authorization` de son serveur ntfy ou la signature SigV4 du + * stockage — sans qu'aucune plage privée ne soit franchie, et sans un mot dans + * le journal. Voir `CROSS_ORIGIN_SAFE_HEADERS` pour pourquoi c'est une liste + * blanche et non la liste noire du standard. * * Known limitation — DNS rebinding race: between the lookup and * the actual TCP connect (Node's undici opens its own resolver), a * DNS server can hand us a different answer. The race window is * sub-millisecond and shrinks the SSRF surface from "trivially * exploitable" to "needs a DNS server you control + cooperating - * timing". Acceptable for an operator-curated webhook target; if - * we ever expose this on user-supplied URLs without admin review - * (we don't today), revisit. + * timing". + * + * ATTENTION — cette limitation avait été classée sans suite « acceptable for an + * operator-curated webhook target; if we ever expose this on user-supplied URLs + * without admin review (we don't today), revisit ». La prémisse est FAUSSE : + * `channels/webhook.ts` déclare `url` comme un `userField`, et + * `routes/api/me/notification-channels/[type].put.ts` laisse tout membre le + * persister — sa validation ne contrôle que les clés connues, une longueur et + * le fait que ce soit une primitive, sans revue d'administrateur. Le + * pré-requis reste qu'un administrateur ait activé et testé le canal + * `webhook` générique. + * + * Ce qui a été fait plutôt que l'épinglage d'adresse : le canal ne réfléchit + * plus le corps amont (c'était le primitif de lecture), la route de + * persistance porte une limite de débit, et `WEBHOOK_ALLOW_HOSTS` permet à + * l'opérateur de restreindre les hôtes. L'épinglage lui-même demanderait + * `undici` en dépendance DIRECTE de l'API — le `dispatcher` du `fetch` de Node + * n'accepte qu'une instance de son undici interne — donc un ajout de + * dépendance pour une course sous-milliseconde ; à faire, mais délibérément et + * pas au détour d'un correctif. */ import { promises as dns } from 'node:dns'; import { isIP } from 'node:net'; @@ -232,6 +263,47 @@ export class SafeFetchError extends Error { } } +/** + * Les seuls en-têtes qui traversent une frontière d'origine. + * + * Une liste BLANCHE, et pas la liste noire du standard + * (`authorization`, `cookie`, `proxy-authorization`, `host` — ce que + * `fetch` retire de lui-même quand il suit une redirection). La liste noire + * est faite pour un navigateur, où les seuls en-têtes porteurs de secret sont + * ceux que le navigateur pose. Ici, les appelants posent les leurs : + * + * - `channels/webhook.ts` passe jusqu'à SEIZE en-têtes choisis par le + * membre, plus un `X-Trackarr-Signature` (HMAC du corps) ; + * - `channels/ntfy.ts` passe l'`Authorization` du serveur ; + * - `storage/s3Driver.ts` passe une signature SigV4 et ses `x-amz-*` ; + * - `federation/signing.ts` passe `x-trackarr-signature`, qui authentifie + * l'instance émettrice. + * + * Une liste noire n'en couvrirait que deux sur quatre. Ici on n'énumère donc + * pas ce qui est dangereux — on énumère ce qui ne peut RIEN authentifier, et + * tout le reste tombe au premier saut vers une autre origine. + * + * Conséquence assumée : une cible qui redirige légitimement vers une autre + * origine (une redirection de région d'un stockage S3, par exemple) répondra + * 403 au lieu de réussir. C'est le bon sens de l'erreur — un échec visible + * plutôt qu'un jeton livré à l'hôte qui a demandé la redirection. + */ +const CROSS_ORIGIN_SAFE_HEADERS = new Set([ + 'accept', + 'accept-language', + 'user-agent', +]); + +/** Ne garde que les en-têtes qui ne peuvent pas authentifier la requête. */ +function stripCredentialHeaders(headers: Headers): Headers { + const out = new Headers(); + headers.forEach((value, name) => { + // `Headers` normalise les noms en minuscules à l'itération. + if (CROSS_ORIGIN_SAFE_HEADERS.has(name)) out.append(name, value); + }); + return out; +} + /** * Hardened replacement for `fetch()` whenever the URL is user- * controlled. The contract matches `fetch` minus automatic redirect @@ -242,12 +314,19 @@ export async function safeFetch( init?: SafeFetchOptions ): Promise<Response> { const maxRedirects = init?.maxRedirects ?? 5; - // Strip the option so it doesn't leak into RequestInit. - const { maxRedirects: _drop, ...fetchInit } = init ?? {}; + // Strip the option so it doesn't leak into RequestInit. `headers` sort aussi + // du spread : ils sont désormais gérés à la main, saut par saut, et un + // `...fetchInit` qui les réinjecterait annulerait tout le travail plus bas. + const { maxRedirects: _drop, headers: initHeaders, ...fetchInit } = init ?? {}; let url = input; let method = (fetchInit.method ?? 'GET').toUpperCase(); let body = fetchInit.body; + // Le retrait plus bas est DESTRUCTIF : il réécrit `headers`, il ne masque + // pas une copie. C'est ce qui rend une chaîne A → B → A sûre par + // construction — au retour en A il n'y a plus rien à rendre. B a choisi ce + // retour, et pourrait aussi bien avoir choisi un A homographe. + let headers = new Headers(initHeaders as HeadersInit | undefined); for (let hop = 0; hop <= maxRedirects; hop++) { let parsed: URL; @@ -265,6 +344,7 @@ export async function safeFetch( ...fetchInit, method, body, + headers, // Manual mode so we get a chance to re-validate every hop. redirect: 'manual', }); @@ -275,7 +355,7 @@ export async function safeFetch( const loc = res.headers.get('location'); if (!loc) return res; // weird, but defer to caller // Resolve against the current URL so relative paths work. - url = new URL(loc, url).toString(); + const next = new URL(loc, url); // RFC 7231 §6.4.4 — 303 redirects always become GET. 301/302 // historically did the same in browsers for POST→GET; we // follow that for safety + sanity (a webhook POST 30x'd to a @@ -284,6 +364,28 @@ export async function safeFetch( method = 'GET'; body = undefined; } + // Changement d'origine : les en-têtes porteurs de secret tombent. + // + // `parsed.origin` compare schéma + hôte + port, donc un passage de + // `https:` à `http:` sur le MÊME hôte compte aussi comme un + // franchissement — ce qui est exactement ce qu'on veut : livrer un jeton + // en clair est le pire des deux cas. + if (next.origin !== parsed.origin) { + // Un 307 ou un 308 conserve la méthode ET le corps. Si le corps + // survit à la normalisation ci-dessus (un PUT signé de `s3Driver`, + // par exemple), le suivre enverrait son contenu à l'hôte qui a + // demandé la redirection — les en-têtes retirés n'y changeraient + // rien. On refuse, plutôt que de dégrader la méthode en silence : + // transformer un PUT en GET rendrait un 200 pour une écriture qui + // n'a jamais eu lieu. + if (body !== undefined && body !== null) { + throw new SafeFetchError( + `Refused: ${res.status} redirect to a different origin (${next.origin}) would replay the request body` + ); + } + headers = stripCredentialHeaders(headers); + } + url = next.toString(); continue; } return res; diff --git a/apps/api/utils/savedSearchFanout.ts b/apps/api/utils/savedSearchFanout.ts new file mode 100644 index 00000000..f471140d --- /dev/null +++ b/apps/api/utils/savedSearchFanout.ts @@ -0,0 +1,170 @@ +/** + * Saved-search alerts: one accepted upload, every filter that wanted it. + * + * ## The direction matters + * + * The naive shape is "for each filter, run the catalogue query and see if this + * torrent comes back" — N queries per upload. This inverts it: one query asks + * Postgres which stored filters match this one torrent, then the notifications + * fan out through the same bounded pool the follower fan-out uses. + * + * The free-text half is the only part that has to be SQL, because a tsquery is + * Postgres's to evaluate. Everything else — category, tags, media ids — is + * plain data already in memory, so it is filtered in TypeScript rather than + * turned into a join. + * + * ## What a match must respect, and why each is not optional + * + * - **The adult gate.** A member who turned adult content off must not be + * pushed an adult release by a filter they wrote before that. This is the + * one rule whose absence would be actively harmful rather than merely + * wrong, so it is applied last and unconditionally. + * - **Anonymous uploads.** Same rule the follower fan-out applies: the + * uploader asked not to be named, and a notification is a place a name + * leaks. The alert says what appeared, never who put it there. + * - **The filter's owner is not the uploader.** Nobody needs telling about + * their own upload. + * + * ## Bounded, and honest about it + * + * A cap per member on how many filters may be armed keeps this O(armed + * filters) with a ceiling somebody chose, rather than one that emerges. The + * sweep logs its own duration when it exceeds a threshold, so an operator finds + * out from their logs rather than from members. + */ +import { and, eq, inArray, or, sql } from 'drizzle-orm'; +import { db, schema } from '@trackarr/db'; +import { notify } from './notify'; +import { FANOUT_CONCURRENCY, withConcurrency } from './fanout'; +import { adultCategoryIds } from './adultContent'; + +export interface SavedSearchCandidate { + id: string; + name: string; + infoHash: string; + categoryId: string | null; + imdbId: string | null; + tmdbId: string | null; + tvdbId: string | null; + uploaderId: string | null; +} + +/** Log a warning past this, so a slow sweep is visible before it is a problem. */ +const SLOW_SWEEP_MS = 500; + +export async function fanoutSavedSearchMatches( + torrent: SavedSearchCandidate +): Promise<void> { + try { + await runFanout(torrent); + } catch (err) { + // Its sibling wraps the identical shape, and for the same reason: this is + // called with `void` from two routes, so without the catch a failure + // surfaces as an anonymous unhandled rejection instead of a line naming + // the feature that failed. + console.warn('[SavedSearch] fan-out failed:', (err as Error).message); + } +} + +async function runFanout(torrent: SavedSearchCandidate): Promise<void> { + const started = Date.now(); + + // The torrent's tag slugs, once. A filter matching on tags needs all of its + // own to be present — the listing's AND semantics. + const tagRows = await db + .select({ slug: schema.tags.slug }) + .from(schema.torrentTags) + .innerJoin(schema.tags, eq(schema.tags.id, schema.torrentTags.tagId)) + .where(eq(schema.torrentTags.torrentId, torrent.id)); + const torrentTags = new Set(tagRows.map((r) => r.slug)); + + /** + * Every armed filter whose free text matches, or which has none. + * + * The tsquery comparison runs in SQL against the torrent's own name, which + * is the field a member means when they save a search. Description and NFO + * are deliberately not consulted: the live search offers them because a + * reader is looking for something, while an alert firing on a word buried in + * an NFO is a notification nobody can account for. + */ + const candidates = await db + .select() + .from(schema.savedSearches) + .where( + and( + eq(schema.savedSearches.notify, true), + or( + sql`${schema.savedSearches.tsquery} IS NULL`, + sql`to_tsvector('simple', ${torrent.name}) @@ to_tsquery('simple', ${schema.savedSearches.tsquery})` + ) + ) + ); + + if (candidates.length === 0) return; + + // The structured half, in memory. + const matched = candidates.filter((f) => { + if (f.userId === torrent.uploaderId) return false; + if (f.categoryId && f.categoryId !== torrent.categoryId) return false; + if (f.imdbId && f.imdbId !== torrent.imdbId) return false; + if (f.tmdbId && f.tmdbId !== torrent.tmdbId) return false; + if (f.tvdbId && f.tvdbId !== torrent.tvdbId) return false; + const wanted = f.tags ?? []; + if (wanted.length && !wanted.every((t) => torrentTags.has(t))) return false; + // A filter with no free text, no category, no tag and no id would match + // every upload ever. The write path refuses to store one; this is the + // second line, in case a row predates that or arrives another way. + const empty = + !f.tsquery && !f.categoryId && !f.imdbId && !f.tmdbId && !f.tvdbId && !wanted.length; + return !empty; + }); + + if (matched.length === 0) return; + + // The adult gate, applied to the recipients rather than to the filters: it + // is a property of the member, and a member can change it after saving. + const adultIds = await adultCategoryIds(); + const isAdult = !!torrent.categoryId && adultIds.includes(torrent.categoryId); + + let recipients = matched; + if (isAdult) { + const optedIn = await db + .select({ id: schema.users.id }) + .from(schema.users) + .where( + and( + inArray( + schema.users.id, + matched.map((f) => f.userId) + ), + eq(schema.users.showAdultContent, true) + ) + ); + const allowed = new Set(optedIn.map((u) => u.id)); + recipients = matched.filter((f) => allowed.has(f.userId)); + } + + await withConcurrency(recipients, FANOUT_CONCURRENCY, async (filter) => { + await notify( + filter.userId, + 'saved_search_match', + { searchLabel: filter.label, torrentName: torrent.name }, + `/torrents/${torrent.infoHash}` + ); + await db + .update(schema.savedSearches) + .set({ + lastMatchedAt: new Date(), + matchCount: sql`${schema.savedSearches.matchCount} + 1`, + }) + .where(eq(schema.savedSearches.id, filter.id)); + }); + + const elapsed = Date.now() - started; + if (elapsed > SLOW_SWEEP_MS) { + console.warn( + `[SavedSearch] evaluated ${candidates.length} armed filters in ${elapsed}ms ` + + `(${recipients.length} notified) — consider lowering saved_search_max_per_user` + ); + } +} diff --git a/apps/api/utils/schemas.ts b/apps/api/utils/schemas.ts index 6d1a4f68..21e1ed2b 100644 --- a/apps/api/utils/schemas.ts +++ b/apps/api/utils/schemas.ts @@ -61,8 +61,15 @@ export const registerSchema = z.object({ 'Username can only contain letters, numbers, underscores, and hyphens' ), // ZKE fields - server never sees password - authSalt: z.string().min(40, 'Invalid salt'), - authVerifier: z.string().min(40, 'Invalid verifier'), + // Bornées en haut aussi. Les deux atterrissent dans des colonnes `text` non + // bornées, par un appelant NON authentifié (`POST /api/auth/register`), et + // un client honnête envoie 44 caractères de base64 pour 32 octets. Sans + // plafond, chaque inscription pouvait y planter la taille maximale d'un corps + // Nitro — puis `encryptSecretRequired` chiffrait tout cela à chaque écriture, + // et `login.post.ts` concaténait le vérificateur dans un SHA-256 à chaque + // tentative. + authSalt: z.string().min(40, 'Invalid salt').max(64, 'Invalid salt'), + authVerifier: z.string().min(40, 'Invalid verifier').max(64, 'Invalid verifier'), // Proof of Work powChallenge: z.string().length(64, 'Invalid PoW challenge'), powNonce: z.string().min(1, 'Invalid PoW nonce'), @@ -140,6 +147,16 @@ export const adminUserRoleSchema = z.object({ export const adminBanSchema = z.object({ reason: z.string().min(1, 'Ban reason is required').max(500), duration: z.coerce.number().int().positive().optional(), + /** + * Bannir aussi la dernière adresse IP du compte. + * + * C'était un effet de bord inconditionnel. Sur une sortie CGNAT ou VPN + * partagée — l'usage quasi universel sur un tracker privé — bannir un membre + * bannissait ses voisins, et le blocage précède l'authentification : y compris + * le personnel, y compris la route qui lèverait le blocage. C'est désormais + * une décision, et son défaut est de ne pas le faire. + */ + banIp: z.coerce.boolean().optional().default(false), }); export const adminCategorySchema = z.object({ @@ -253,6 +270,20 @@ export const adminSettingsSchema = z.object({ .min(1) .max(3650) .optional(), + /** + * Staff audit retention, in days. `0` is legitimate here and means "keep + * indefinitely" — unlike the notification periods above, which have no such + * reading and start at 1. An audit log an operator can only shorten is an + * audit log with a built-in expiry nobody chose. + */ + auditRetentionDays: z.number().int().min(0).max(3650).optional(), + // Both of these had a getter, a default and a documented meaning, and no + // writer anywhere — so the retention period an operator reads about in the + // privacy notice, and the ceiling the saved-search fan-out logs advice about + // ("consider lowering saved_search_max_per_user"), could only be changed with + // a SQL prompt. + loginEventRetentionDays: z.number().int().min(0).max(3650).optional(), + savedSearchMaxPerUser: z.number().int().min(1).max(200).optional(), notificationsRetentionUnreadDays: z .number() .int() @@ -328,17 +359,34 @@ export const forumCategoryUpdateSchema = forumCategorySchema.partial(); // Tracker Schemas (for announce/scrape validation) // ============================================================================ +/** + * Le contrat d'annonce, publié dans l'OpenAPI et servi par personne. + * + * Ce schéma et `scrapeQuerySchema` n'ont aucun appelant : l'annonce et le + * scrape sont servis par le tracker Go, qui a sa propre validation + * (`apps/tracker/internal/announce`). Ils restent parce que + * `scripts/generate-openapi.mjs` les publie comme documentation du protocole. + * + * Deux bornes ajoutées pour que le contrat publié dise la vérité : les trois + * compteurs d'octets étaient `min(0)` sans MAXIMUM, et `ip` était une chaîne + * libre. Ce que le tracker applique réellement, lui, est plus strict — un + * `peer_id` de 20 octets, un port hors plage privilégiée, `left` borné — donc + * une documentation plus permissive que l'implémentation est une invitation à + * signaler un faux bug. + */ export const announceQuerySchema = z.object({ info_hash: infoHashSchema, peer_id: z.string().length(20, 'Peer ID must be 20 characters'), port: z.coerce.number().int().min(1).max(65535), - uploaded: z.coerce.number().int().min(0), - downloaded: z.coerce.number().int().min(0), - left: z.coerce.number().int().min(0), + uploaded: z.coerce.number().int().min(0).max(Number.MAX_SAFE_INTEGER), + downloaded: z.coerce.number().int().min(0).max(Number.MAX_SAFE_INTEGER), + left: z.coerce.number().int().min(0).max(Number.MAX_SAFE_INTEGER), compact: z.coerce.number().int().optional(), no_peer_id: z.coerce.number().int().optional(), event: z.enum(['started', 'stopped', 'completed', '']).optional(), - ip: z.string().optional(), + // Fourni par le client et DÉLIBÉRÉMENT ignoré par le tracker : l'accepter + // ferait de lui un réflecteur (BEP 7 a fini par décourager ce champ). + ip: z.union([z.ipv4(), z.ipv6()]).optional(), numwant: z.coerce.number().int().min(0).max(200).optional(), key: z.string().optional(), trackerid: z.string().optional(), @@ -409,6 +457,40 @@ export function validateQuery<T>(event: any, schema: z.ZodSchema<T>): T { } } +/** + * Valide TOUS les paramètres de route d'un coup, comme `validateQuery` le fait + * pour la chaîne de requête. + * + * Il manquait, et son absence coûtait cher : vingt-six routes appelaient + * `paramsSchema.parse(getRouterParams(event))` en direct. Une `ZodError` non + * rattrapée ne devient pas un 400 — elle remonte comme erreur non gérée et + * Nitro répond **500 « Server Error »**. Mesuré le 2026-09-02 sur la pile + * compilée : `/api/tags?limit=abc` renvoyait 500, quand `/api/torrents?limit=abc`, + * qui passe par `validateQuery`, répondait + * « 400 limit: Invalid input: expected number, received NaN ». + * + * Deux conséquences, au-delà du message illisible : un 500 écrit une trace + * complète dans le journal à CHAQUE requête malformée — un lecteur de flux mal + * configuré sur `/api/rss/latest` en produit en continu — et il annonce au + * client une panne du serveur là où c'est sa propre requête qui est en cause. + * + * `validateParam` existait déjà mais ne prend qu'UN paramètre nommé, ce qui ne + * couvre pas les routes à deux segments (`/requests/[id]/comments/[cid]`). + */ +export function validateRouterParams<T>(event: any, schema: z.ZodSchema<T>): T { + try { + return schema.parse(getRouterParams(event)); + } catch (error) { + if (error instanceof z.ZodError) { + throw createError({ + statusCode: 400, + message: error.issues.map(describeZodIssue).join('; '), + }); + } + throw error; + } +} + /** * Validate route parameter with Zod schema * Throws HTTP 400 error with validation messages on failure diff --git a/apps/api/utils/search.ts b/apps/api/utils/search.ts index feb279f8..faf393a4 100644 --- a/apps/api/utils/search.ts +++ b/apps/api/utils/search.ts @@ -78,6 +78,24 @@ export function parseSearchFuzzy(raw: string | null | undefined): boolean { * Returns `null` when nothing usable is left, in which case the caller must * skip the filter rather than return nothing. */ +/** + * The same normalisation WITHOUT the trailing `:*`. + * + * The prefix in `toPrefixTsQuery` exists because the member is still typing: + * `crown` should match `crownfall` while the query bar has focus. A saved alert + * is settled intent, and the same prefix there would fire "The Crown" on every + * release whose title merely starts the same way — a false positive the member + * has no way to see coming, arriving as a notification. + * + * Shares the term-splitting with the prefix version rather than repeating it, + * so the two can never disagree about what counts as a term. + */ +export function toExactTsQuery(input: string): string | null { + const prefixed = toPrefixTsQuery(input); + if (!prefixed) return null; + return prefixed.replace(/:\*$/, ''); +} + export function toPrefixTsQuery(input: string): string | null { const terms = input .toLowerCase() diff --git a/apps/api/utils/session.ts b/apps/api/utils/session.ts index 2a74e982..b197b17c 100644 --- a/apps/api/utils/session.ts +++ b/apps/api/utils/session.ts @@ -1,4 +1,5 @@ import type { H3Event } from 'h3'; +import { readLiveRoles } from './liveRoles'; import { useSession } from 'h3'; import { touchPresence } from './presence'; @@ -20,6 +21,16 @@ export interface SessionUser { * visible in the type rather than arriving through the catch-all. */ isOwner: boolean; + /** + * La génération de sessions qui avait cours à la connexion. + * + * `requireUserSession` la compare à `users.session_epoch`. Absente d'un + * cookie émis avant cette fonctionnalité : traitée comme `0`, donc les + * sessions déjà ouvertes restent valides jusqu'à la première révocation — + * un déploiement ne doit pas déconnecter tout le monde pour installer de + * quoi déconnecter quelqu'un. + */ + sessionEpoch?: number; uploaded: number; downloaded: number; [key: string]: unknown; @@ -115,6 +126,31 @@ export async function getSessionId(event: H3Event): Promise<string> { return s.id; } +/** + * La porte d'authentification, et les deux choses qu'elle doit faire au passage. + * + * **Les drapeaux de personnel sont relus dans la base, pas dans le cookie.** + * Le cookie est un jeton scellé de sept jours : lus depuis lui, `isAdmin` et + * `isModerator` restaient vrais toute cette durée, si bien qu'un modérateur + * rétrogradé conservait ses pouvoirs sur douze routes mutantes qui n'élargissent + * les leurs que sur ces drapeaux — réécrire le message d'un autre membre, + * supprimer n'importe quel torrent, publier sans passer par la file de revue, + * désanonymiser un uploadeur. `invalidateRoleCache` était bien appelé à la + * rétrogradation, et son effet s'arrêtait aux portes `/api/admin/**` et + * `/api/mod/**` : depuis un client HTTP, le reste tenait. + * + * `reconcileStaffRoles` existait, avec ce défaut écrit mot pour mot dans son + * commentaire, et n'était câblé qu'à la messagerie et aux tickets. Le poser ICI + * plutôt que dans les douze routes est ce qui garantit qu'une treizième, écrite + * demain, en hérite : le coût est une lecture Redis mise en cache 60 s, que la + * chaîne d'authentification faisait déjà pour l'état de bannissement. + * + * **L'acteur du journal d'audit est posé ici aussi**, pour la même raison : + * `requireAuthSession` le posait et `requireUserSession` non, si bien que dix + * routes que `STAFF_REACH` déclare vouloir tracer n'écrivaient aucune ligne — + * ni succès, ni refus. Le choix entre les deux gardes, indistinguable vu de la + * route, décidait de la traçabilité. + */ export async function requireUserSession( event: H3Event ): Promise<UserSessionData & { user: SessionUser }> { @@ -125,5 +161,46 @@ export async function requireUserSession( message: 'Authentication required', }); } - return data as UserSessionData & { user: SessionUser }; + const session = data as UserSessionData & { user: SessionUser }; + + // Mémoïsé par requête : une route qui enchaîne `requireUserSession` puis + // `requireAuthSession` ne paie la lecture qu'une fois. + if (!event.context.rolesReconciled) { + const live = await readLiveRoles(session.user.id); + if (!live) { + throw createError({ statusCode: 403, message: 'Account no longer exists' }); + } + + /* + * La révocation, au même endroit et pour le même prix que les rôles. + * + * Le cookie est scellé et sans état : sans cette comparaison, rien ne + * pouvait l'invalider avant sept jours. La lecture est celle qui avait + * déjà lieu — `readLiveRoles` rend l'époque avec les rôles, cache de 60 s + * compris — donc révoquer ne coûte pas une requête de plus par appel. + * + * Un cookie sans époque vaut `0` : les sessions ouvertes au moment du + * déploiement survivent, et la première révocation les emporte. + * + * La fenêtre est celle du cache : jusqu'à 60 s. `revokeAllSessions` vide + * le cache en incrémentant, donc en pratique l'effet est immédiat pour + * l'instance qui reçoit l'appel. + */ + if ((session.user.sessionEpoch ?? 0) !== live.sessionEpoch) { + await clearUserSession(event); + throw createError({ + statusCode: 401, + data: { reason: 'session-revoked' }, + message: 'This session was revoked. Sign in again.', + }); + } + + session.user.isAdmin = live.isAdmin; + session.user.isModerator = live.isModerator; + session.user.isOwner = live.isOwner; + event.context.rolesReconciled = true; + } + + event.context.auditActor = session.user; + return session; } diff --git a/apps/api/utils/settings.ts b/apps/api/utils/settings.ts index a054c037..e5437950 100644 --- a/apps/api/utils/settings.ts +++ b/apps/api/utils/settings.ts @@ -123,6 +123,47 @@ export const SETTINGS_KEYS = { SITE_LOGO: 'site_logo', SITE_LOGO_IMAGE: 'site_logo_image', SITE_FAVICON: 'site_favicon', + // Pixel size of the two uploaded images, as `WxH`, written by the upload + // routes from the bytes themselves. Only the web app manifest reads them: + // a browser trusts the `sizes` an icon declares, so the declaration has to + // be measured rather than assumed. Absent (or `any`) means "unknown" — an + // SVG, a format we do not walk, or an image uploaded before the + // measurement existed. See `utils/imageSniff.manifestIconSizes`. + /** + * How long staff audit entries are kept, in days. 0 = forever. + * + * Long by default (a year) because the question an audit log answers is + * usually asked late — after a member disputes a ban, or after a staff + * account turns out to have been compromised weeks ago. Operators in + * jurisdictions that require a shorter hold can shorten it, and the value is + * published on `/api/privacy` either way. + */ + AUDIT_LOG_RETENTION_DAYS: 'audit_log_retention_days', + /** + * Whether the announce passkey still authenticates the READ surfaces (RSS, + * Torznab) now that those have keys of their own. + * + * Default true, and that is a migration stance rather than a preference: the + * passkey was the only key those surfaces ever accepted, so every feed URL a + * member has configured anywhere carries it. Flipping this to false the day + * the split ships would break all of them at once, which is exactly the + * breakage the split exists to prevent. An operator turns it off once their + * members have moved over. + */ + LEGACY_PASSKEY_READ_ACCESS: 'legacy_passkey_read_access', + /** + * How many saved searches one member may keep. + * + * Every armed filter is evaluated against every accepted upload, so this is + * the knob that bounds the feature's cost. 20 is generous for a person and + * small enough that a thousand members cost twenty thousand comparisons per + * upload — one indexed query, not twenty thousand. + */ + SAVED_SEARCH_MAX_PER_USER: 'saved_search_max_per_user', + /** Days a login event is kept. 0 = forever. See the retention plugin. */ + LOGIN_EVENT_RETENTION_DAYS: 'login_event_retention_days', + SITE_LOGO_IMAGE_SIZE: 'site_logo_image_size', + SITE_FAVICON_SIZE: 'site_favicon_size', SITE_SUBTITLE: 'site_subtitle', SITE_NAME_COLOR: 'site_name_color', SITE_NAME_BOLD: 'site_name_bold', @@ -358,6 +399,58 @@ export async function getSiteLogoImage(): Promise<string | null> { return value || null; } +/** + * Days an audit entry survives. 0 means "keep indefinitely" — the sweep skips + * entirely rather than treating 0 as "delete everything", which is the reading + * that would quietly empty the register. + */ +/** + * True while the announce passkey may still be used on the read surfaces. + * Defaults to true — see the note on the settings key. + */ +export async function isLegacyPasskeyReadAllowed(): Promise<boolean> { + const value = await getSetting(SETTINGS_KEYS.LEGACY_PASSKEY_READ_ACCESS); + return value !== 'false'; +} + +export async function getLoginEventRetentionDays(): Promise<number> { + const value = await getSetting(SETTINGS_KEYS.LOGIN_EVENT_RETENTION_DAYS); + const parsed = value ? parseInt(value, 10) : NaN; + if (!Number.isFinite(parsed) || parsed < 0) return 90; + return parsed; +} + +export async function getSavedSearchMaxPerUser(): Promise<number> { + const value = await getSetting(SETTINGS_KEYS.SAVED_SEARCH_MAX_PER_USER); + const parsed = value ? parseInt(value, 10) : NaN; + if (!Number.isFinite(parsed) || parsed < 1) return 20; + return parsed; +} + +export async function getAuditRetentionDays(): Promise<number> { + const value = await getSetting(SETTINGS_KEYS.AUDIT_LOG_RETENTION_DAYS); + const parsed = value ? parseInt(value, 10) : NaN; + if (!Number.isFinite(parsed) || parsed < 0) return 365; + return parsed; +} + +/** + * The `sizes` string for the uploaded logo / favicon, as measured at upload. + * + * `any` is both the fallback and a legitimate answer — see the note on the + * settings keys. Never fabricate a square here: the manifest's whole value to + * a browser is that the number can be trusted. + */ +export async function getSiteLogoImageSizes(): Promise<string> { + const value = await getSetting(SETTINGS_KEYS.SITE_LOGO_IMAGE_SIZE); + return value || 'any'; +} + +export async function getSiteFaviconSizes(): Promise<string> { + const value = await getSetting(SETTINGS_KEYS.SITE_FAVICON_SIZE); + return value || 'any'; +} + export async function getSiteFavicon(): Promise<string | null> { const value = await getSetting(SETTINGS_KEYS.SITE_FAVICON); return value || null; diff --git a/apps/api/utils/torrentBuffs.ts b/apps/api/utils/torrentBuffs.ts new file mode 100644 index 00000000..3016b91f --- /dev/null +++ b/apps/api/utils/torrentBuffs.ts @@ -0,0 +1,98 @@ +/** + * What a torrent actually costs right now — the site-wide bonus event and the + * torrent's own buff, resolved into one pair of factors. + * + * The rule is the same one the Go tracker applies on the announce path + * (`internal/bonus.Best`): the member gets the better of the two on each axis, + * never the product. Two implementations of one rule is a drift risk, and the + * mitigation is that this is the ONLY place the API expresses it — Torznab, the + * torrent detail route and the listing all call in here rather than each doing + * their own `Math.min`. + * + * If you change the rule, change `apps/tracker/internal/bonus/bonus.go` in the + * same commit. The tests on both sides assert the same table of cases. + */ + +/** Basis points ×100, as stored. */ +export interface Multipliers { + download: number; + upload: number; +} + +export const IDENTITY: Multipliers = { download: 100, upload: 100 }; + +/** The columns this needs from a torrent row. */ +export interface BuffableTorrent { + downloadMultiplier: number; + uploadMultiplier: number; + multipliersUntil: Date | null; +} + +/** + * The torrent's own buff, neutralised when it has lapsed. + * + * The announce path gets this from SQL; here it is done in TypeScript because + * the rows are already in memory and re-reading them to let Postgres do the + * comparison would be a query for an `if`. + */ +export function torrentMultipliers( + torrent: BuffableTorrent, + now: Date = new Date() +): Multipliers { + const lapsed = + torrent.multipliersUntil !== null && torrent.multipliersUntil <= now; + if (lapsed) return IDENTITY; + return { + download: torrent.downloadMultiplier, + upload: torrent.uploadMultiplier, + }; +} + +/** + * The better of two multiplier sets, axis by axis. + * + * Download: lower wins (0 is freeleech). Upload: higher wins (200 is double + * credit). Neither side can make the other worse, so an operator granting a + * buff never has to check what else is running first. + */ +export function best(a: Multipliers, b: Multipliers): Multipliers { + return { + download: Math.min(a.download, b.download), + upload: Math.max(a.upload, b.upload), + }; +} + +/** + * The pair a consumer should be told about, as plain factors rather than basis + * points — which is what Torznab's `downloadvolumefactor` / + * `uploadvolumefactor` want. + */ +export function volumeFactors( + torrent: BuffableTorrent, + siteWide: Multipliers, + now: Date = new Date() +): { downloadVolumeFactor: number; uploadVolumeFactor: number } { + const m = best(siteWide, torrentMultipliers(torrent, now)); + return { + downloadVolumeFactor: m.download / 100, + uploadVolumeFactor: m.upload / 100, + }; +} + +/** + * A short label for the buff a torrent carries on its own, or null when it + * carries none. Site-wide events are announced elsewhere and are not this + * function's business — a member seeing "Freeleech" on one torrent among a + * hundred should be able to trust that it means *this* one. + */ +export function buffLabel( + torrent: BuffableTorrent, + now: Date = new Date() +): 'freeleech' | 'silverleech' | 'double-upload' | 'custom' | null { + const m = torrentMultipliers(torrent, now); + if (m.download === 100 && m.upload === 100) return null; + if (m.download === 0 && m.upload === 100) return 'freeleech'; + if (m.download === 50 && m.upload === 100) return 'silverleech'; + if (m.download === 100 && m.upload === 200) return 'double-upload'; + return 'custom'; +} diff --git a/apps/api/utils/torrentModeration.ts b/apps/api/utils/torrentModeration.ts index 04b04740..c258c661 100644 --- a/apps/api/utils/torrentModeration.ts +++ b/apps/api/utils/torrentModeration.ts @@ -19,6 +19,8 @@ import { db, schema } from '@trackarr/db'; import { and, eq, inArray } from 'drizzle-orm'; import { fanoutFollowedUserUpload } from './followerFanout'; +import { fanoutSavedSearchMatches } from './savedSearchFanout'; +import { announceRelease } from './irc/announcer'; import { randomUUID } from 'node:crypto'; import { redis } from '../redis/client'; @@ -213,6 +215,40 @@ export async function transitionStatus(opts: { }); } + // Saved-search alerts, on the same edge and for the same reasons — except + // that this one does not need an uploader: a filter matches a release, and + // an anonymous upload is still a release somebody was waiting for. The + // notification names the torrent and never the uploader, so anonymity holds. + if (nextStatus === 'accepted' && priorStatus !== 'accepted') { + void fanoutSavedSearchMatches({ + id: updated.id, + name: updated.name, + infoHash: updated.infoHash, + categoryId: updated.categoryId, + imdbId: updated.imdbId, + tmdbId: updated.tmdbId, + tvdbId: updated.tvdbId, + uploaderId: updated.uploaderId, + }); + + // The IRC announce channel, on the same edge and for the same reason: this + // is the moment a release becomes something a member could grab. Racing is + // the whole point of the channel, so the line goes out here rather than on + // a sweep — and fire-and-forget, because a channel that is down must not + // hold up a moderator's queue. + void announceRelease({ + id: updated.id, + infoHash: updated.infoHash, + name: updated.name, + size: Number(updated.size), + categoryId: updated.categoryId, + uploaderId: updated.uploaderId, + downloadMultiplier: updated.downloadMultiplier, + uploadMultiplier: updated.uploadMultiplier, + multipliersUntil: updated.multipliersUntil, + }); + } + // Federation needs nothing here. A row that leaves the accepted state stops // matching the publishable predicate, and the record sweep mints a tombstone // for it on its next pass — the same way it handles a deletion or a ban. diff --git a/apps/api/utils/torznabStats.ts b/apps/api/utils/torznabStats.ts index aeabb33c..9ac9b822 100644 --- a/apps/api/utils/torznabStats.ts +++ b/apps/api/utils/torznabStats.ts @@ -475,6 +475,81 @@ export async function unblockTorznabUser(passkey: string): Promise<void> { } } +/** + * Move an access block onto the passkey that replaces it. + * + * The block is indexed by `passkeyId(passkey)`, so a rotation that merely + * dropped the old entry would BE the lift: a blocked member mints a new + * passkey and the administrator's restriction is gone, with nothing anywhere + * to say it ever applied. Three routes rotate `users.passkey` today, and the + * carry-over lives here rather than in each of them so a fourth cannot + * reintroduce that quietly. + * + * Call it BEFORE the row is updated, and `retireTorznabPasskey` after. That + * order leaves no instant in which a live passkey is unblocked: the + * replacement inherits the block before anything can poll with it, and the old + * entry survives right up to the moment it stops being the member's key. + * + * The entry is copied verbatim — same reason, same `blockedAt` — because the + * console sorts and displays both, and a carried block that claimed to have + * been applied just now would erase the only record of when it really was. + * + * Unlike every other function in this module, this one does NOT swallow a + * Redis failure. The others fail open on purpose: enforcement that cannot + * reach Redis lets a poll through, and the block bites again when Redis comes + * back. A carry-over that failed open would lose the block *permanently*, so + * the rotation is refused instead and nothing changes. + * + * Returns whether a block was carried. + */ +export async function carryTorznabBlock( + oldPasskey: string, + newPasskey: string +): Promise<boolean> { + try { + const entry = await redis.hget(KEYS.BLOCKED, passkeyId(oldPasskey)); + if (!entry) return false; + await redis.hset(KEYS.BLOCKED, passkeyId(newPasskey), entry); + return true; + } catch (error) { + console.error( + '[Torznab Stats] Could not carry the block over a rotation:', + error + ); + throw createError({ + statusCode: 503, + message: + 'Passkey rotation is temporarily unavailable. Your passkey has not been changed — try again shortly.', + }); + } +} + +/** + * Forget a passkey that is nobody's any more: its block entry and its + * per-passkey counters. + * + * Both index a hash of a value that has just stopped existing, so leaving them + * behind stops nothing and leaves the console listing a block against an id no + * account matches. Best-effort by the same reasoning that makes the carry-over + * strict: what is left behind here is litter, not a hole. + */ +export async function retireTorznabPasskey( + passkey: string, + /** + * Whether `carryTorznabBlock` reported moving a block for this rotation. + * + * Deleting the old entry unconditionally lost a block an administrator wrote + * DURING the rotation: the carry reads an empty slot, the admin writes one, the + * row changes, and the retire then deletes the admin's decision. A few + * milliseconds wide, and the member picks when to replay it — so the delete is + * conditional on there having been something to move. + */ + carriedBlock = true +): Promise<void> { + if (carriedBlock) await unblockTorznabUser(passkey); + await clearTorznabUserStats(passkey); +} + export async function isTorznabUserBlocked( passkey: string ): Promise<{ blocked: boolean; reason?: string }> { diff --git a/apps/api/utils/trackerHealth.ts b/apps/api/utils/trackerHealth.ts new file mode 100644 index 00000000..6d517e09 --- /dev/null +++ b/apps/api/utils/trackerHealth.ts @@ -0,0 +1,67 @@ +/** + * Le tracker est-il joignable ? Une seule réponse, pour tout le site. + * + * Ce module existe parce qu'il y en avait deux, et qu'elles se contredisaient + * en public : + * + * - la page d'accueil lisait `/api/tracker-health`, qui sonde réellement + * `http://tracker:8080/health` et dit la vérité ; + * - le tableau de bord d'administration lisait `/api/admin/stats`, qui + * renvoyait `status: 'running'` **en dur**. + * + * Le second n'était donc pas un état mais une décoration : il affichait + * « en ligne » quoi qu'il arrive, y compris conteneur tracker arrêté. Un + * indicateur qui ne peut jamais signaler de panne est pire qu'absent — c'est + * la page vers laquelle un exploitant se tourne quand quelque chose cloche, et + * elle le rassurait à tort. Les deux surfaces passent maintenant par ici. + * + * Le cache est partagé lui aussi, et c'est le point : deux caches séparés + * pouvaient déjà se contredire pendant leurs dix secondes respectives. + */ + +/** Ce que le tracker répond sur `/health` : 200 seulement si Postgres ET Redis répondent. */ +export interface TrackerHealth { + online: boolean; + /** Millisecondes unix de la dernière sonde ; l'interface affiche « vérifié il y a Xs ». */ + checkedAt: number; +} + +const CACHE_TTL_MS = 10_000; +const PROBE_TIMEOUT_MS = 1_500; + +let cached: { value: TrackerHealth; expiresAt: number } | null = null; + +function trackerHealthUrl(): string { + const base = + process.env.TRACKER_INTERNAL_URL || + process.env.TRACKER_HEALTH_URL || + 'http://tracker:8080'; + return base.replace(/\/+$/, '') + '/health'; +} + +async function probe(): Promise<TrackerHealth> { + const url = trackerHealthUrl(); + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), PROBE_TIMEOUT_MS); + try { + const res = await fetch(url, { signal: controller.signal }); + return { online: res.ok, checkedAt: Date.now() }; + } catch { + // Injoignable, DNS muet, délai dépassé : de l'extérieur c'est le même fait. + return { online: false, checkedAt: Date.now() }; + } finally { + clearTimeout(timer); + } +} + +/** + * L'état, mis en cache quelques secondes — pour qu'une pointe de trafic sur la + * page d'accueil ne se traduise pas en rafale de sondes contre le tracker. + */ +export async function readTrackerHealth(): Promise<TrackerHealth> { + const now = Date.now(); + if (cached && cached.expiresAt > now) return cached.value; + const value = await probe(); + cached = { value, expiresAt: now + CACHE_TTL_MS }; + return value; +} diff --git a/apps/api/utils/twoFactor.ts b/apps/api/utils/twoFactor.ts index 5d581d4a..84ff8556 100644 --- a/apps/api/utils/twoFactor.ts +++ b/apps/api/utils/twoFactor.ts @@ -176,20 +176,15 @@ export function hashRecoveryCode(code: string): string { .digest('hex'); } -/** - * Constant-time-ish comparison: same length so timing leaks can't tell - * whether the prefix matched. (Strict constant time isn't critical for - * our threat model since attackers don't get to enumerate codes — but - * it costs nothing to be careful.) +/* + * `recoveryCodeEquals` a été SUPPRIMÉE : elle n'avait aucun appelant. + * + * La vérification réelle est dans `routes/api/auth/2fa/verify-totp.post.ts` : + * un `UPDATE … WHERE code_hash = $1 AND used_at IS NULL RETURNING id`, ce qui + * est meilleur — usage unique atomique, et égalité indexée sur un SHA-256 + * plutôt qu'une comparaison en mémoire. Garder une fonction de comparaison + * inutilisée suggérait une protection qui vivait ailleurs. */ -export function recoveryCodeEquals(a: string, b: string): boolean { - if (a.length !== b.length) return false; - let mismatch = 0; - for (let i = 0; i < a.length; i++) { - mismatch |= a.charCodeAt(i) ^ b.charCodeAt(i); - } - return mismatch === 0; -} // ── Fresh-auth window ──────────────────────────────────────── // diff --git a/apps/relay/Dockerfile b/apps/relay/Dockerfile index f074db50..60e0b92f 100644 --- a/apps/relay/Dockerfile +++ b/apps/relay/Dockerfile @@ -26,4 +26,11 @@ FROM scratch AS runner COPY --from=builder /out/relay /relay USER 65532:65532 EXPOSE 4100 +# La sonde, sous forme de sous-commande du binaire lui-même. +# +# `scratch` n'a ni shell ni curl : c'est pourquoi ce service était le seul de la +# pile sans `HEALTHCHECK`, alors qu'il expose `/healthz` et que le chart Helm le +# sonde déjà. Le tracker résout la même contrainte de la même façon. +HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ + CMD ["/relay", "healthcheck"] ENTRYPOINT ["/relay"] diff --git a/apps/relay/cmd/relay/main.go b/apps/relay/cmd/relay/main.go index a94e1928..f4b32575 100644 --- a/apps/relay/cmd/relay/main.go +++ b/apps/relay/cmd/relay/main.go @@ -25,6 +25,7 @@ import ( "net/http" "os" "os/signal" + "strings" "syscall" "time" @@ -36,7 +37,41 @@ import ( "github.com/florianjs/trackarr/apps/relay/internal/sse" ) +/* + * `relay healthcheck` — la même forme que `tracker healthcheck`. + * + * L'image tourne depuis `scratch` : ni shell, ni curl, ni wget. Un + * `HEALTHCHECK` Docker n'avait donc aucun outil à invoquer, et le service + * était le SEUL de la pile sans sonde côté conteneur — alors que le processus + * expose `/healthz` et que le chart Helm, lui, le sonde. Un relais bloqué + * n'était jamais redémarré pendant que Caddy continuait d'y router + * `/messaging/events`. + */ +func runHealthcheck() int { + addr := os.Getenv("RELAY_ADDR") + if addr == "" { + addr = ":4100" + } + // `RELAY_ADDR` peut valoir `:4100` ou `0.0.0.0:4100` ; on sonde toujours la + // boucle locale, donc seul le port compte. + port := addr[strings.LastIndex(addr, ":")+1:] + client := &http.Client{Timeout: 4 * time.Second} + resp, err := client.Get("http://127.0.0.1:" + port + "/healthz") + if err != nil { + return 1 + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return 1 + } + return 0 +} + func main() { + if len(os.Args) > 1 && os.Args[1] == "healthcheck" { + os.Exit(runHealthcheck()) + } + static, err := config.LoadStatic() if err != nil { log.Fatalf("[relay] %v", err) @@ -147,7 +182,11 @@ func main() { }() <-ctx.Done() - log.Printf("[relay] draining") + // Prévenir les flux AVANT d'attendre : `Shutdown` n'annule pas le contexte + // des requêtes en cours, donc sans ce signal le drain n'était qu'une + // attente de dix secondes suivie d'une coupure sèche. Voir `Hub.Drain`. + drained := h.Drain() + log.Printf("[relay] draining %d stream(s)", drained) // Give open streams a moment to end on their own. A relay that cuts // twenty thousand connections at once during a rolling update creates // exactly the reconnect storm the jittered client backoff exists to diff --git a/apps/relay/go.mod b/apps/relay/go.mod index 1b908e45..f932ff78 100644 --- a/apps/relay/go.mod +++ b/apps/relay/go.mod @@ -7,5 +7,5 @@ require github.com/redis/go-redis/v9 v9.22.0 require ( github.com/cespare/xxhash/v2 v2.3.0 // indirect go.uber.org/atomic v1.11.0 // indirect - golang.org/x/sys v0.30.0 // indirect + golang.org/x/sys v0.44.0 // indirect ) diff --git a/apps/relay/go.sum b/apps/relay/go.sum index 2184f3ba..4f35daa9 100644 --- a/apps/relay/go.sum +++ b/apps/relay/go.sum @@ -18,5 +18,5 @@ github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs= github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s= go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= -golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= -golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= +golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= diff --git a/apps/relay/internal/config/config.go b/apps/relay/internal/config/config.go index f52945c8..bbe366bf 100644 --- a/apps/relay/internal/config/config.go +++ b/apps/relay/internal/config/config.go @@ -61,14 +61,34 @@ func (l *Live) Get() Dynamic { return *l.v.Load() } // Set applies an update, with one rule the fleet depends on: a lowered // ceiling never evicts. It applies to connections that have not been made // yet, so growing the fleet cannot knock existing readers off. +/* + * Des plafonds, et pas seulement des planchers. + * + * Ces valeurs arrivent d'un `PUBLISH` Valkey, et `QueueDepth` dimensionne + * directement `make(chan []byte, …)` à CHAQUE nouvelle connexion. Seules les + * valeurs `<= 0` étaient corrigées : un `queueDepth: 67108864` accepté tel quel + * portait le tampon de chaque flux à environ 1,5 Gio, et le premier lecteur qui + * se connectait faisait tomber le nœud. + * + * Cela demande un accès à Valkey, donc c'est du durcissement en profondeur — + * mais c'est exactement la forme « allocation pilotée par l'entrée », et une + * configuration valable pour toute la flotte est le dernier endroit où faire + * confiance. `Defaults()` prouve d'ailleurs que les bornes utiles sont connues. + */ +const ( + maxQueueDepth = 4096 + maxMaxConnections = 100_000 + maxCoalesceWindowMs = 5_000 +) + func (l *Live) Set(d Dynamic) { - if d.MaxConnections <= 0 { + if d.MaxConnections <= 0 || d.MaxConnections > maxMaxConnections { d.MaxConnections = Defaults().MaxConnections } - if d.QueueDepth <= 0 { + if d.QueueDepth <= 0 || d.QueueDepth > maxQueueDepth { d.QueueDepth = Defaults().QueueDepth } - if d.CoalesceWindowMs < 0 { + if d.CoalesceWindowMs < 0 || d.CoalesceWindowMs > maxCoalesceWindowMs { d.CoalesceWindowMs = Defaults().CoalesceWindowMs } l.v.Store(&d) diff --git a/apps/relay/internal/hub/hub.go b/apps/relay/internal/hub/hub.go index 9a8cd0be..d3355202 100644 --- a/apps/relay/internal/hub/hub.go +++ b/apps/relay/internal/hub/hub.go @@ -32,15 +32,15 @@ type Conn struct { // string because the room is shared — the alternative is a second // connection per reader, and the browser caps those. Channels []string - out chan []byte - done chan struct{} - closeMu sync.Once - dropped atomic.Bool + out chan []byte + done chan struct{} + closeMu sync.Once + dropped atomic.Bool } -func (c *Conn) Out() <-chan []byte { return c.out } +func (c *Conn) Out() <-chan []byte { return c.out } func (c *Conn) Done() <-chan struct{} { return c.done } -func (c *Conn) Dropped() bool { return c.dropped.Load() } +func (c *Conn) Dropped() bool { return c.dropped.Load() } func (c *Conn) close(dropped bool) { c.closeMu.Do(func() { @@ -51,13 +51,50 @@ func (c *Conn) close(dropped bool) { }) } +// pubsub est la part de `*redis.PubSub` dont le hub se sert. +// +// Une interface plutôt que le type concret, pour UNE raison : l'ordre dans +// lequel `Subscribe` et `Unsubscribe` atteignent Redis est un invariant, et un +// invariant qu'on ne peut pas observer est un invariant qu'on ne peut pas +// défendre. Un faux enregistre les commandes et le test ci-contre vérifie que +// la dernière commande vue pour un canal correspond bien à l'état de la map. +type pubsub interface { + Subscribe(ctx context.Context, channels ...string) error + Unsubscribe(ctx context.Context, channels ...string) error + Channel(opts ...redis.ChannelOption) <-chan *redis.Message + Close() error +} + type Hub struct { rdb *redis.Client live *config.Live mu sync.RWMutex conns map[string]map[*Conn]struct{} // channel -> connections - sub *redis.PubSub + sub pubsub + + // L'ordre des commandes Redis, et rien d'autre. + // + // `mu` décide qui s'abonne et qui se désabonne ; la commande Redis + // correspondante partait ENSUITE, hors du verrou. Les deux pouvaient donc + // s'inverser : le dernier lecteur d'un canal part (`Remove` retire la clé + // de la map), un nouveau lecteur arrive (`Add` voit `!existed`, remet la + // clé, met le canal dans `fresh`), puis `Subscribe` part AVANT + // `Unsubscribe`. go-redis 9.22 n'a pas de compteur de références — + // `Unsubscribe` fait un `delete` sec — donc le canal se retrouve présent + // dans `h.conns` et désabonné côté Redis. + // + // L'effet est COLLANT : la clé existant désormais, aucun `Add` ultérieur ne + // le remettra dans `fresh`. Sur `messaging:room:general`, partagé par tout + // le monde, un seul reconnect malheureux coupe le direct du salon pour le + // nœud entier, sans une ligne de journal. + // + // `subMu` est pris AVANT `mu` dans les deux chemins et relâché après la + // commande Redis : la décision et sa commande deviennent indivisibles l'une + // par rapport à l'autre. `mu` reste libre pendant l'aller-retour Redis, donc + // `dispatch` n'attend pas. Ordre d'acquisition constant `subMu` → `mu`, + // jamais l'inverse, et `dispatch` ne prend que `mu`. + subMu sync.Mutex count atomic.Int64 @@ -65,9 +102,9 @@ type Hub struct { // that no other component can see: how many readers this node cut for // falling behind, and how many frames it wrote. Monotonic, so the // scrape only ever has to rate() them. - dropped atomic.Int64 - frames atomic.Int64 - refused atomic.Int64 + dropped atomic.Int64 + frames atomic.Int64 + refused atomic.Int64 } func New(rdb *redis.Client, live *config.Live) *Hub { @@ -75,6 +112,18 @@ func New(rdb *redis.Client, live *config.Live) *Hub { rdb: rdb, live: live, conns: make(map[string]map[*Conn]struct{}), + // Abonné ICI, pas dans `Run`. + // + // `h.sub` était écrit par `Run` sans verrou et lu par `Add` hors du + // verrou, alors que le champ est déclaré dans le bloc gardé par `h.mu`. + // `main` lance `Run` et `ListenAndServe` dans deux goroutines + // indépendantes : une requête `/events` arrivée avant que `Run` n'ait + // posé le champ déréférençait nil. Au-delà de cette fenêtre de + // démarrage, l'accès restait une course au sens du modèle mémoire Go — + // invisible au détecteur, parce qu'aucun test ne fait tourner `Run` et + // `Add` ensemble. Créer l'abonnement au constructeur supprime la + // fenêtre et la course d'un seul coup. + sub: rdb.Subscribe(context.Background()), } } @@ -89,7 +138,6 @@ func (h *Hub) Refused() int64 { return h.refused.Load() } // O(nodes) rather than O(readers) — the fan-out to readers happens here, // in this process, which is exactly why this process is not the API. func (h *Hub) Run(ctx context.Context) error { - h.sub = h.rdb.Subscribe(ctx) defer h.sub.Close() ch := h.sub.Channel(redis.WithChannelSize(1024)) @@ -134,7 +182,11 @@ func (h *Hub) dispatch(channel string, payload []byte) { // to any this node had no reader for yet. Returns false at the ceiling. func (h *Hub) Add(ctx context.Context, channels ...string) (*Conn, bool) { cfg := h.live.Get() - if h.count.Load() >= int64(cfg.MaxConnections) { + // Réserver d'abord, rendre en cas de refus : le couple `Load` puis `Add` + // n'était pas atomique et pouvait dépasser le plafond du nombre de requêtes + // concurrentes. + if h.count.Add(1) > int64(cfg.MaxConnections) { + h.count.Add(-1) h.refused.Add(1) return nil, false } @@ -146,6 +198,7 @@ func (h *Hub) Add(ctx context.Context, channels ...string) (*Conn, bool) { } var fresh []string + h.subMu.Lock() h.mu.Lock() for _, channel := range channels { set, existed := h.conns[channel] @@ -158,13 +211,19 @@ func (h *Hub) Add(ctx context.Context, channels ...string) (*Conn, bool) { } h.mu.Unlock() + var subErr error if len(fresh) > 0 { - if err := h.sub.Subscribe(ctx, fresh...); err != nil { - h.Remove(c) - return nil, false - } + subErr = h.sub.Subscribe(ctx, fresh...) + } + // Relâché AVANT `Remove`, qui reprend `subMu` — un mutex Go n'est pas + // réentrant, le garder ici serait un interblocage avec soi-même. + h.subMu.Unlock() + + if subErr != nil { + h.Remove(c) + // `Remove` décrémente déjà le compteur : rien à rendre ici. + return nil, false } - h.count.Add(1) return c, true } @@ -175,6 +234,9 @@ func (h *Hub) Remove(c *Conn) { var emptied []string present := false + h.subMu.Lock() + defer h.subMu.Unlock() + h.mu.Lock() for _, channel := range c.Channels { set, ok := h.conns[channel] @@ -204,6 +266,40 @@ func (h *Hub) Remove(c *Conn) { } } +// Drain ferme chaque flux ouvert, proprement. +// +// `http.Server.Shutdown` n'annule PAS `r.Context()` — il cesse d'accepter et +// attend que les gestionnaires rendent la main. Or les boucles SSE n'attendent +// que `r.Context()`, `conn.Done()` ou un battement : rien ne leur disait de +// partir. Les dix secondes de drain s'écoulaient donc sans que personne bouge, +// `Shutdown` rendait `DeadlineExceeded` — ignoré par un `_ =` — puis `main` +// retournait et coupait les vingt mille flux d'un coup. C'est exactement la +// tempête de reconnexion que le commentaire du drain dit vouloir éviter : il +// la provoquait. +// +// Fermer `conn.done` fait sortir chaque boucle par sa branche `conn.Done()`, +// donc chaque réponse SSE se termine normalement plutôt que d'être coupée au +// niveau TCP. Le client voit une fin de flux, pas une erreur réseau, et sa +// temporisation à gigue joue son rôle. +// +// `close` est protégé par un `sync.Once` par connexion : appeler Drain pendant +// que des `Remove` se produisent est sans danger. +func (h *Hub) Drain() int { + h.mu.RLock() + seen := make(map[*Conn]struct{}) + for _, set := range h.conns { + for c := range set { + seen[c] = struct{}{} + } + } + h.mu.RUnlock() + + for c := range seen { + c.close(false) + } + return len(seen) +} + // Coalesce batches whatever arrives inside one window into a single frame. // // At three messages a second in a room with fifteen hundred readers that diff --git a/apps/relay/internal/hub/hubsub_test.go b/apps/relay/internal/hub/hubsub_test.go new file mode 100644 index 00000000..9b9c9ba9 --- /dev/null +++ b/apps/relay/internal/hub/hubsub_test.go @@ -0,0 +1,132 @@ +package hub + +import ( + "context" + "sync" + "testing" + "time" + + "github.com/redis/go-redis/v9" + + "github.com/florianjs/trackarr/apps/relay/internal/config" +) + +// Un faux qui retient la DERNIÈRE commande vue par canal. +type recordingSub struct { + mu sync.Mutex + last map[string]string // canal -> "sub" | "unsub" +} + +func newRecordingSub() *recordingSub { + return &recordingSub{last: map[string]string{}} +} + +// La latence est le POINT du faux. +// +// La fenêtre de course est l'intervalle entre la décision (sous `mu`) et la +// commande Redis. Un faux instantané la referme presque, et le test passait +// alors même que l'ordonnancement était retiré — un test vert pour de +// mauvaises raisons. Un vrai aller-retour Redis dure des dizaines de +// microsecondes ; on en simule autant. +func (r *recordingSub) note(kind string, channels []string) { + time.Sleep(50 * time.Microsecond) + r.mu.Lock() + defer r.mu.Unlock() + for _, c := range channels { + r.last[c] = kind + } +} + +func (r *recordingSub) Subscribe(_ context.Context, channels ...string) error { + r.note("sub", channels) + return nil +} + +func (r *recordingSub) Unsubscribe(_ context.Context, channels ...string) error { + r.note("unsub", channels) + return nil +} + +func (r *recordingSub) Channel(_ ...redis.ChannelOption) <-chan *redis.Message { + return make(chan *redis.Message) +} + +func (r *recordingSub) Close() error { return nil } + +func (r *recordingSub) lastFor(channel string) string { + r.mu.Lock() + defer r.mu.Unlock() + return r.last[channel] +} + +// L'abonnement Redis doit toujours refléter l'état de la map. +// +// `Subscribe` et `Unsubscribe` partaient hors du verrou : le dernier lecteur +// s'en allait (la clé quittait `h.conns`), un nouveau arrivait (la clé +// revenait, le canal passait dans `fresh`), puis les deux commandes Redis +// partaient dans l'ordre INVERSE. go-redis n'a pas de compteur de références, +// donc le canal restait présent dans la map et désabonné côté Redis — et +// comme la clé existait, aucun `Add` ultérieur ne le réabonnait. Mort +// silencieux, jusqu'au départ de tous les lecteurs. +// +// L'invariant que ce test défend : à la fin, si le canal est dans `h.conns`, +// la dernière commande vue doit être `sub` ; s'il n'y est plus, `unsub`. +func TestSubscribeAndUnsubscribeNeverInvert(t *testing.T) { + const channel = "messaging:room:general" + const rounds = 300 + + for attempt := 0; attempt < 20; attempt++ { + rec := newRecordingSub() + live := &config.Live{} + d := config.Defaults() + d.MaxConnections = rounds * 2 + live.Set(d) + h := &Hub{ + live: live, + conns: make(map[string]map[*Conn]struct{}), + sub: rec, + } + + var wg sync.WaitGroup + for i := 0; i < rounds; i++ { + wg.Add(1) + go func() { + defer wg.Done() + c, ok := h.Add(context.Background(), channel) + if !ok { + return + } + h.Remove(c) + }() + } + // Une connexion SURVIT à la tempête, et c'est le point du test. + // + // Si l'on retirait tout, l'état final serait « absent + unsub » quoi + // qu'il arrive : la panne recherchée — canal PRÉSENT dans la map et + // DÉSABONNÉ côté Redis — ne peut pas s'observer là. En gardant un + // lecteur, un `Unsubscribe` resté en vol qui atterrit après le + // `Subscribe` final produit exactement cet état collant. + final, ok := h.Add(context.Background(), channel) + if !ok { + t.Fatalf("tentative %d : plafond atteint, le test ne mesure rien", attempt) + } + wg.Wait() + // Laisser retomber ce qui est encore en vol. + time.Sleep(20 * time.Millisecond) + + h.mu.RLock() + _, present := h.conns[channel] + h.mu.RUnlock() + + if !present { + t.Fatalf("tentative %d : le canal a disparu alors qu'un lecteur reste", attempt) + } + if got := rec.lastFor(channel); got != "sub" { + t.Fatalf( + "tentative %d : un lecteur est présent mais la dernière commande Redis est %q — le canal est désabonné pour de bon, et aucun Add ultérieur ne le réabonnera", + attempt, got, + ) + } + h.Remove(final) + } +} diff --git a/apps/tracker/cmd/tracker/main.go b/apps/tracker/cmd/tracker/main.go index c0951802..06b249d8 100644 --- a/apps/tracker/cmd/tracker/main.go +++ b/apps/tracker/cmd/tracker/main.go @@ -88,7 +88,8 @@ func main() { store := peers.New(rclient, cfg.RedisKeyPrefix, cfg.PeerTTL) database := db.New(pool, rclient, cfg.RedisKeyPrefix) - srv := server.New(ctx, database, rclient, store, cfg.RedisKeyPrefix, cfg.IPHashSecret, cfg.Debug, cfg.FederationSwarm) + srv := server.New(ctx, database, rclient, store, cfg.RedisKeyPrefix, cfg.IPHashSecret, cfg.Debug, cfg.FederationSwarm, + cfg.StatsFlushInterval, cfg.StatsFlushChunk) defer srv.Stop() addr := ":" + strconv.Itoa(cfg.HTTPPort) @@ -125,7 +126,7 @@ func main() { if cfg.UDPEnabled { udpAddr := ":" + strconv.Itoa(cfg.UDPPort) var err error - udpSrv, err = udp.New(udpAddr, cfg.IPHashSecret, srv, store) + udpSrv, err = udp.New(udpAddr, cfg.IPHashSecret, srv, store, cfg.UDPScrapeEnabled) if err != nil { logger.Error("udp listen", "err", err) os.Exit(1) @@ -156,8 +157,12 @@ func main() { if err := httpSrv.Shutdown(shutdownCtx); err != nil { logger.Error("http shutdown", "err", err) } - // UDP has no in-flight connections to drain; closing the socket - // makes the read loop exit on the next deadline tick. + // Ferme la socket, puis attend les datagrammes déjà en traitement. + // + // Le commentaire d'origine disait « UDP has no in-flight connections to + // drain » : vrai du protocole, faux de cette implémentation. Chaque + // datagramme a sa goroutine, et un `announce` en cours d'écriture est + // exactement ce que le drain HTTP protège juste au-dessus. if udpSrv != nil { _ = udpSrv.Close() } diff --git a/apps/tracker/db/queries/hnr.sql b/apps/tracker/db/queries/hnr.sql index c36cac1c..52516bc1 100644 --- a/apps/tracker/db/queries/hnr.sql +++ b/apps/tracker/db/queries/hnr.sql @@ -40,6 +40,8 @@ SELECT u.id AS user_id, t.id AS torrent_id WHERE u.passkey = $1 AND t.info_hash = $2 AND t.is_active = true + -- Idem : pas de dette de hit-and-run sur une release refusée. + AND t.moderation_status = 'accepted' LIMIT 1; -- name: BumpUserTorrentBytes :execrows diff --git a/apps/tracker/db/queries/torrents.sql b/apps/tracker/db/queries/torrents.sql index d76753dd..24b859a0 100644 --- a/apps/tracker/db/queries/torrents.sql +++ b/apps/tracker/db/queries/torrents.sql @@ -1,8 +1,59 @@ -- name: FindActiveTorrentByInfoHash :one -- Returns the active torrent matching the given hex info_hash, or no rows -- if either it doesn't exist or it's been deactivated. -SELECT id +-- +-- The two multipliers come back with it, already neutralised when the buff has +-- lapsed. Doing that here rather than in Go is what keeps the announce path +-- free of clock logic AND free of a sweep that has to run on time: a buff +-- expires the moment its timestamp passes, whether or not anything noticed. +SELECT id, + CASE WHEN multipliers_until IS NULL OR multipliers_until > now() + THEN download_multiplier ELSE 100 END AS download_multiplier, + CASE WHEN multipliers_until IS NULL OR multipliers_until > now() + THEN upload_multiplier ELSE 100 END AS upload_multiplier FROM torrents WHERE info_hash = $1 AND is_active = true + -- Le chemin d'annonce est la SEULE application qu'un refus de modération + -- possède. `is_active` est un interrupteur d'opérateur et `transitionStatus` + -- ne le touche jamais : filtrer sur lui seul laissait une release REFUSÉE + -- continuer à distribuer des pairs, à créditer du ratio et à créer des + -- lignes de hit-and-run. Une ligne refusée est conservée pour que le même + -- infohash ne puisse pas être renvoyé en silence ; elle n'a pas à rester + -- annonçable pour autant. Idem pour tout ce qui attend encore une + -- validation. + AND moderation_status = 'accepted' + LIMIT 1; + +-- name: FindActiveTorrentByInfoHashV2Short :one +-- The BEP 52 second swarm. +-- +-- A v2 or hybrid torrent has two infohashes and a client that supports v2 +-- announces the SHA-256 one — truncated to 20 bytes, because the tracker +-- protocol has no room for 32. That truncation is the first 40 hex characters +-- of `info_hash_v2`, which is what this matches. +-- +-- Returns the canonical `info_hash` alongside the id, and the caller switches +-- to it as the swarm key. That is what merges the two halves of a hybrid +-- torrent's swarm instead of leaving v1-only and v2-capable peers unable to +-- see each other. +-- +-- Served by `torrents_info_hash_v2_short_idx`, a partial expression index — so +-- this costs an index lookup, not a scan, and only v2 rows are in it. +-- +-- The parameter is cast: `info_hash_v2` is nullable, so sqlc infers a nullable +-- argument from a bare comparison and generates `*string` for a value the +-- caller always has. The cast is on the parameter, not on the column, so the +-- expression index still serves the predicate. +SELECT id, info_hash, + CASE WHEN multipliers_until IS NULL OR multipliers_until > now() + THEN download_multiplier ELSE 100 END AS download_multiplier, + CASE WHEN multipliers_until IS NULL OR multipliers_until > now() + THEN upload_multiplier ELSE 100 END AS upload_multiplier + FROM torrents + WHERE left(info_hash_v2, 40) = sqlc.arg(announced_hash)::text + AND is_active = true + -- Même filtre que le chemin v1 ci-dessus : le second essaim d'un torrent + -- hybride n'est pas une porte de service. + AND moderation_status = 'accepted' LIMIT 1; diff --git a/apps/tracker/db/queries/users.sql b/apps/tracker/db/queries/users.sql index 4220bb0e..4a5adf58 100644 --- a/apps/tracker/db/queries/users.sql +++ b/apps/tracker/db/queries/users.sql @@ -6,8 +6,40 @@ SELECT id, is_banned, uploaded, downloaded LIMIT 1; -- name: IncrementUserStats :exec --- Adds upload/download deltas to the user identified by passkey. +-- Adds upload/download deltas to the user identified by ID. +-- +-- Par l'ID, et non par la passkey. L'appelant a `user.ID` en main — il vient +-- d'un cache de 60 s indexé par le hachis de la passkey — et si la passkey a +-- changé entre la résolution et cette écriture (rotation depuis l'interface +-- web), l'`UPDATE` touchait ZÉRO ligne et le crédit disparaissait en silence. +-- `BumpUserTorrentBytes`, juste en dessous, utilise déjà l'ID. UPDATE users SET uploaded = uploaded + $1, downloaded = downloaded + $2 - WHERE passkey = $3; + WHERE id = $3; + +-- name: BatchIncrementUserStats :exec +-- Versement groupé : applique en une requête les deltas accumulés pour une +-- TRANCHE de membres. +-- +-- Pourquoi par tranches, et pas la totalité d'un versement en une requête : +-- une seule transaction qui met à jour des dizaines de milliers de lignes +-- empêche l'élagage HOT de recycler la place en page — les anciennes versions +-- restent vivantes jusqu'au commit, chaque ligne migre vers une nouvelle page +-- et réécrit les SEPT index. Mesuré : 45 317 lignes en une transaction tombent +-- à 19 % de HOT et écrivent PLUS de WAL que les 117 840 écritures unitaires +-- qu'elles remplacent. Par tranches de dix, on remonte à 100 % de HOT et le +-- WAL est divisé par quinze. La taille de tranche est réglable, le défaut +-- vient de cette mesure. +-- +-- L'ordre des identifiants est celui que l'appelant fournit, et il les trie : +-- deux versements concurrents prendraient leurs verrous de ligne dans le même +-- ordre et ne peuvent donc pas s'interbloquer. +UPDATE users u + SET uploaded = u.uploaded + d.up, + downloaded = u.downloaded + d.down + FROM (SELECT i.id, p.up, q.down + FROM unnest(@ids::text[]) WITH ORDINALITY AS i(id, n) + JOIN unnest(@ups::bigint[]) WITH ORDINALITY AS p(up, n) USING (n) + JOIN unnest(@downs::bigint[]) WITH ORDINALITY AS q(down, n) USING (n)) d + WHERE u.id = d.id; diff --git a/apps/tracker/db/schema.sql b/apps/tracker/db/schema.sql index 3f65208b..46df4797 100644 --- a/apps/tracker/db/schema.sql +++ b/apps/tracker/db/schema.sql @@ -29,6 +29,11 @@ CREATE TABLE IF NOT EXISTS users ( CREATE TABLE IF NOT EXISTS torrents ( id text PRIMARY KEY, info_hash text NOT NULL UNIQUE, + -- SHA-256 of the v2 info dict, hex, for a v2 or hybrid torrent; NULL for + -- a v1-only one. A client announcing into the v2 swarm sends the first 20 + -- bytes of this, so the announce path looks up `left(info_hash_v2, 40)`. + -- Written by the api at upload time; the tracker only reads it. + info_hash_v2 text, name text NOT NULL, size bigint NOT NULL, description text, @@ -36,7 +41,19 @@ CREATE TABLE IF NOT EXISTS torrents ( uploader_id text, category_id text, is_active boolean NOT NULL DEFAULT true, - is_approved boolean NOT NULL DEFAULT false, + -- Per-torrent bonus multipliers, basis points x100 (0 = freeleech, + -- 100 = normal, 200 = double). NULL `multipliers_until` means the buff has + -- no end date; a past one means it has lapsed, and the announce query + -- neutralises it in SQL so the hot path carries no clock logic. + download_multiplier integer NOT NULL DEFAULT 100, + upload_multiplier integer NOT NULL DEFAULT 100, + multipliers_until timestamp, + -- L'état de modération. Remplace le booléen `is_approved`, que la migration + -- 0026a a supprimé de la vraie base — ce fichier le portait encore, si bien + -- que le schéma contre lequel sqlc valide les requêtes du tracker avait + -- dérivé de la base réelle. Une requête référençant une colonne inexistante + -- passait donc la génération et n'échouait qu'à l'exécution. + moderation_status text NOT NULL DEFAULT 'pending', created_at timestamp NOT NULL DEFAULT NOW() ); diff --git a/apps/tracker/internal/announce/announce.go b/apps/tracker/internal/announce/announce.go index 708803e9..4f3cae8c 100644 --- a/apps/tracker/internal/announce/announce.go +++ b/apps/tracker/internal/announce/announce.go @@ -22,6 +22,15 @@ const ( EventStarted EventStopped EventCompleted + // EventPaused is BEP 21's partial-seed signal. A client that holds every + // piece it asked for — but not the whole torrent, because the member + // deselected files — reports `left=0` and `event=paused`. It is still + // worth connecting to: it has real pieces to serve. It is simply not a + // seed, and BEP 21 asks the tracker not to report it as one. + // + // HTTP only. BEP 15 numbers its events 0..3 and has no code for this, so a + // UDP announce can never carry it. + EventPaused ) // String returns a stable lowercase event name for logging/storage. @@ -33,6 +42,8 @@ func (e Event) String() string { return "stopped" case EventCompleted: return "completed" + case EventPaused: + return "paused" default: return "update" } @@ -74,6 +85,24 @@ var ( // BitTorrent clients URL-encode the raw 20-byte info_hash and peer_id as // query parameters; net/url already does the right thing with %xx, but we // then need to assert the byte length is exactly 20. +// ValidPeerPort dit si un port peut être inscrit dans un essaim. +// +// Deux règles, et la même aux deux transports : +// +// - Zéro est refusé. Un pair à `IP:0` n'est joignable par personne, et il est +// pourtant distribué à tous les clients de l'essaim, qui y brûlent leur +// budget de connexions. L'analyseur HTTP le refusait déjà ; +// `ToAnnounceRequest` recopiait le port UDP sans contrôle, si bien que la +// règle ne tenait que sur la moitié des annonces. +// - Les ports privilégiés sont refusés. Un pair pouvait s'inscrire sur +// `<son IP>:22` ou `:25`, et l'essaim entier allait y frapper en TCP. Comme +// l'adresse vient du socket, ce n'est pas un réflecteur vers un tiers +// arbitraire — mais derrière un CGNAT l'adresse est partagée, et c'est la +// raison pour laquelle les trackers de référence bloquent cette plage. +func ValidPeerPort(port uint16) bool { + return port >= 1024 +} + func Parse(q url.Values) (*Request, error) { r := &Request{ Compact: true, @@ -103,7 +132,7 @@ func Parse(q url.Values) (*Request, error) { return nil, ErrMissingPort } port, err := strconv.ParseUint(portStr, 10, 16) - if err != nil || port == 0 { + if err != nil || !ValidPeerPort(uint16(port)) { return nil, ErrInvalidPort } r.Port = uint16(port) @@ -127,6 +156,15 @@ func Parse(q url.Values) (*Request, error) { r.Event = EventStopped case "completed": r.Event = EventCompleted + case "paused": + // BEP 21. Recognised rather than swallowed, which is what lets the + // peer be classified correctly below — and it is a real client + // behaviour, not an exotic one: qBittorrent sends it whenever a member + // downloads only some files of a multi-file torrent. Trackers that + // reject the value outright break those clients; we never did, but we + // did count the peer as a seed, which inflated the swarm's seeder + // count and made the torrent look healthier than it was. + r.Event = EventPaused default: // Per BEP 3, unknown events are equivalent to a periodic // announce (no event). We still want operator visibility so @@ -158,8 +196,13 @@ func Parse(q url.Values) (*Request, error) { return r, nil } -// IsSeeder reports whether the announce describes a seeding peer (left == 0). -func (r *Request) IsSeeder() bool { return r.Left == 0 } +// IsSeeder reports whether the announce describes a seeding peer. +// +// `left == 0` is necessary and no longer sufficient: a BEP 21 partial seed has +// nothing left to fetch of what it asked for, yet does not hold the torrent. It +// stays in the swarm — it has pieces others want — and counts as a leecher, so +// the seeder count means what a member reads it to mean. +func (r *Request) IsSeeder() bool { return r.Left == 0 && r.Event != EventPaused } func parseInt64(s string) (int64, bool) { if s == "" { diff --git a/apps/tracker/internal/announce/announce_test.go b/apps/tracker/internal/announce/announce_test.go index 36ad26e3..7568ede6 100644 --- a/apps/tracker/internal/announce/announce_test.go +++ b/apps/tracker/internal/announce/announce_test.go @@ -138,6 +138,7 @@ func TestEvent_String(t *testing.T) { EventStarted: "started", EventStopped: "stopped", EventCompleted: "completed", + EventPaused: "paused", EventNone: "update", } for ev, want := range cases { @@ -149,7 +150,9 @@ func TestEvent_String(t *testing.T) { func TestParse_UnknownEvent_RecordedOnRequest(t *testing.T) { q := baseValid() - q.Set("event", "paused") + // Not `paused` — that used to be this test's example of an unknown token + // and is a recognised BEP 21 event since. Any token no BEP defines does. + q.Set("event", "hibernating") r, err := Parse(q) if err != nil { t.Fatal(err) @@ -157,8 +160,8 @@ func TestParse_UnknownEvent_RecordedOnRequest(t *testing.T) { if r.Event != EventNone { t.Errorf("Event: got %v, want EventNone for unknown token", r.Event) } - if r.UnknownEventRaw != "paused" { - t.Errorf("UnknownEventRaw: got %q, want %q", r.UnknownEventRaw, "paused") + if r.UnknownEventRaw != "hibernating" { + t.Errorf("UnknownEventRaw: got %q, want %q", r.UnknownEventRaw, "hibernating") } } @@ -277,3 +280,61 @@ func TestRequest_IsSeederTrueWhenLeftZero(t *testing.T) { t.Error("IsSeeder() should be true when left=0") } } + +// BEP 21 — a partial seed says `left=0` and `event=paused`. It holds every +// piece it asked for and not the whole torrent, so it belongs in the swarm and +// does not belong in the seeder count. +func TestParsePausedIsNotASeeder(t *testing.T) { + q := baseValid() + q.Set("left", "0") + q.Set("event", "paused") + + r, err := Parse(q) + if err != nil { + t.Fatalf("Parse() error = %v", err) + } + if r.Event != EventPaused { + t.Errorf("Event = %v, want EventPaused", r.Event) + } + if r.Event.String() != "paused" { + t.Errorf("Event.String() = %q, want %q", r.Event.String(), "paused") + } + if r.IsSeeder() { + t.Error("a paused peer with left=0 must not count as a seeder") + } + // It is a recognised event, so it must not also be reported as an unknown + // one — that would put it in the operator's "misbehaving client" log. + if r.UnknownEventRaw != "" { + t.Errorf("UnknownEventRaw = %q, want empty", r.UnknownEventRaw) + } +} + +// The ordinary case must be untouched: left=0 with no event is still a seed. +func TestParseLeftZeroWithoutPausedIsStillASeeder(t *testing.T) { + q := baseValid() + q.Set("left", "0") + + r, err := Parse(q) + if err != nil { + t.Fatalf("Parse() error = %v", err) + } + if !r.IsSeeder() { + t.Error("left=0 with no event must still be a seeder") + } +} + +// A paused peer that still has data left is a leecher either way — the event +// must not be able to promote anything. +func TestParsePausedWithBytesLeftIsALeecher(t *testing.T) { + q := baseValid() + q.Set("left", "1024") + q.Set("event", "paused") + + r, err := Parse(q) + if err != nil { + t.Fatalf("Parse() error = %v", err) + } + if r.IsSeeder() { + t.Error("a paused peer with bytes left must not count as a seeder") + } +} diff --git a/apps/tracker/internal/bonus/bonus.go b/apps/tracker/internal/bonus/bonus.go index accc5b51..3cf272ed 100644 --- a/apps/tracker/internal/bonus/bonus.go +++ b/apps/tracker/internal/bonus/bonus.go @@ -51,6 +51,36 @@ type Multipliers struct { // against it returns the input deltas unchanged. var Identity = Multipliers{Download: 100, Upload: 100} +// Best returns the multipliers most favourable to the member, axis by axis. +// +// This is how a per-torrent buff combines with a running site-wide event, and +// the rule is "the member gets the better of the two" rather than the product. +// The product is what a reader first expects and it is wrong in practice: a +// site-wide freeleech (download 0) multiplied by a torrent-level double-upload +// (upload 200) would give 0 and 400 — the freeleech silently doubling an upload +// bonus nobody granted. Taking the better of each axis independently keeps each +// buff meaning what its operator set it to. +// +// - download: LOWER wins. 0 is freeleech and 100 is normal, so less is +// better for whoever is leeching. +// - upload: HIGHER wins. 200 is double credit, so more is better for whoever +// is seeding. +// +// Neither side can make the other worse: buffing a torrent can only ever help a +// member relative to the site-wide state, and vice versa. That is a property +// worth keeping — an operator granting a freeleech should never have to check +// what else is running first. +func Best(a, b Multipliers) Multipliers { + out := a + if b.Download < out.Download { + out.Download = b.Download + } + if b.Upload > out.Upload { + out.Upload = b.Upload + } + return out +} + // Apply scales a pair of (deltaUp, deltaDown) byte counts by the // upload / download multipliers. Integer-only — `delta * mul / 100` // never introduces floating-point drift, and any rounding goes diff --git a/apps/tracker/internal/bonus/bonus_test.go b/apps/tracker/internal/bonus/bonus_test.go index a9061b27..af6bf9d8 100644 --- a/apps/tracker/internal/bonus/bonus_test.go +++ b/apps/tracker/internal/bonus/bonus_test.go @@ -229,3 +229,73 @@ func TestGet_RedisError_FailsOpen(t *testing.T) { t.Fatalf("got %+v, want Identity (Redis down → fail-open)", got) } } + +// Best is how a per-torrent buff meets a site-wide event. The rule is "the +// member gets the better of the two, axis by axis" — not the product, which is +// what a reader first expects and which silently invents credit nobody granted. +func TestBest(t *testing.T) { + cases := []struct { + name string + event Multipliers + torrent Multipliers + wantDown int + wantUp int + }{ + { + name: "nothing running, no buff", + event: Identity, + torrent: Identity, + wantDown: 100, wantUp: 100, + }, + { + name: "per-torrent freeleech while the site is normal", + event: Identity, + torrent: Multipliers{Download: 0, Upload: 100}, + wantDown: 0, wantUp: 100, + }, + { + name: "site freeleech reaches a torrent with no buff", + event: Multipliers{Download: 0, Upload: 100}, + torrent: Identity, + wantDown: 0, wantUp: 100, + }, + { + // The case that makes the product wrong: multiplying would give + // download 0 and upload 400, i.e. the freeleech doubling an upload + // bonus the operator never granted. + name: "site freeleech plus per-torrent double upload", + event: Multipliers{Download: 0, Upload: 100}, + torrent: Multipliers{Download: 100, Upload: 200}, + wantDown: 0, wantUp: 200, + }, + { + name: "the more generous download wins, either side", + event: Multipliers{Download: 50, Upload: 100}, + torrent: Multipliers{Download: 0, Upload: 100}, + wantDown: 0, wantUp: 100, + }, + { + // A buff can never make a member worse off than the site-wide + // state, whichever way round the two are. + name: "a stingier torrent buff cannot undo a site freeleech", + event: Multipliers{Download: 0, Upload: 200}, + torrent: Multipliers{Download: 100, Upload: 100}, + wantDown: 0, wantUp: 200, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := Best(tc.event, tc.torrent) + if got.Download != tc.wantDown || got.Upload != tc.wantUp { + t.Fatalf("Best(%+v, %+v) = %+v, want {Download:%d Upload:%d}", + tc.event, tc.torrent, got, tc.wantDown, tc.wantUp) + } + // Commutative on each axis: which argument is "the event" and which + // is "the torrent" must not change the answer. + if swapped := Best(tc.torrent, tc.event); swapped != got { + t.Fatalf("Best is not commutative: %+v vs %+v", got, swapped) + } + }) + } +} diff --git a/apps/tracker/internal/config/config.go b/apps/tracker/internal/config/config.go index 49b4b211..1810e44a 100644 --- a/apps/tracker/internal/config/config.go +++ b/apps/tracker/internal/config/config.go @@ -10,15 +10,16 @@ import ( ) type Config struct { - HTTPPort int - UDPPort int - UDPEnabled bool - DatabaseURL string - RedisURL string - RedisPassword string - RedisKeyPrefix string - IPHashSecret string - Debug bool + HTTPPort int + UDPPort int + UDPEnabled bool + UDPScrapeEnabled bool + DatabaseURL string + RedisURL string + RedisPassword string + RedisKeyPrefix string + IPHashSecret string + Debug bool // FederationSwarm gates the cross-announce peer injection (Phase 4). // Off by default — mixing partner-instance peers into responses re-opens // the private swarm isolation, so it's an explicit operator opt-in. @@ -53,6 +54,60 @@ type Config struct { // Set it back to `on` if that trade is not yours to make. It applies ONLY // to the tracker's connections; the API keeps full durability. SynchronousCommit string + // StatsFlushInterval est la fenêtre de regroupement des crédits d'octets + // avant leur versement dans `users`. Zéro rend au chemin son écriture par + // annonce. + // + // Ce qu'elle achète, mesuré sur cinq minutes de trafic à 1 964 annonces + // créditées par seconde et 50 000 membres (voir internal/stats) : + // + // désactivé 113–125 Mo de WAL 589 200 écritures de ligne + // 60 s 45,8 Mo 226 315 + // 300 s 7,5 Mo 50 000 + // + // Ce qu'elle coûte : `users.uploaded` et `users.downloaded` accusent + // jusqu'à une fenêtre de retard. Le seul garde qui les lit est la porte de + // ratio de l'annonce, qui voit déjà une valeur vieille de 60 s (le cache de + // passkey) et ne se répète, par torrent, qu'à chaque intervalle d'annonce + // — 1 800 s. Le défaut de 60 s reste donc dans le bruit de l'existant ; + // au-delà, le retard devient visible pour le membre sur son propre profil. + StatsFlushInterval time.Duration + // StatsFlushChunk est le nombre de membres écrits par transaction lors + // d'un versement. + // + // Ce n'est pas un réglage de confort : un versement d'un seul bloc empêche + // l'élagage HOT de recycler la place en page et écrit PLUS de WAL que les + // écritures unitaires qu'il remplace (45 317 lignes d'un bloc : 19 % de + // HOT, 50 Mo ; par tranches de dix : 99,8 % de HOT, 20 Mo). Le défaut est + // le creux de la courbe mesurée. + StatsFlushChunk int +} + +// defaultStatsFlushInterval / defaultStatsFlushChunk : voir les champs +// correspondants de `Config` pour les mesures qui fixent ces valeurs. +const ( + defaultStatsFlushInterval = 60 * time.Second + defaultStatsFlushChunk = 10 +) + +// statsFlushInterval lit `TRACKER_STATS_FLUSH_INTERVAL`. +// +// Séparé de `getEnvDuration` parce que celui-ci traite une durée nulle comme +// une valeur invalide et retombe sur le défaut. Ici zéro est une DEMANDE — +// « écris chaque annonce tout de suite » — et l'ignorer laisserait un +// opérateur croire qu'il a désactivé le regroupement alors qu'il tourne. +func statsFlushInterval() time.Duration { + v := os.Getenv("TRACKER_STATS_FLUSH_INTERVAL") + if v == "" { + return defaultStatsFlushInterval + } + d, err := time.ParseDuration(v) + if err != nil || d < 0 { + slog.Warn("invalid duration in env, using default", + "key", "TRACKER_STATS_FLUSH_INTERVAL", "value", v, "default", defaultStatsFlushInterval) + return defaultStatsFlushInterval + } + return d } // defaultPeerTTL is the fallback applied when `TRACKER_PEER_TTL` is unset @@ -73,13 +128,34 @@ func Load() (*Config, error) { // tweaking. UDP support is opt-in via TRACKER_UDP_ENABLED so an // operator who doesn't want to expose it (HTTPS-only deployments, // strict firewall rules, etc.) can keep the listener off. - UDPPort: getEnvInt("TRACKER_UDP_PORT", 6969), - UDPEnabled: getEnvDefault("TRACKER_UDP_ENABLED", "true") == "true", - DatabaseURL: os.Getenv("DATABASE_URL"), - RedisURL: os.Getenv("REDIS_URL"), - RedisPassword: os.Getenv("REDIS_PASSWORD"), - RedisKeyPrefix: getEnvDefault("REDIS_KEY_PREFIX", "ot:"), - IPHashSecret: os.Getenv("IP_HASH_SECRET"), + UDPPort: getEnvInt("TRACKER_UDP_PORT", 6969), + UDPEnabled: getEnvDefault("TRACKER_UDP_ENABLED", "true") == "true", + // Le scrape UDP, séparément de l'annonce. + // + // Le scrape HTTP exige une passkey (voir `handleScrape` dans + // `internal/server/handler.go`, et le pourquoi : reconstituer le + // catalogue depuis une liste de hashes publics). Le scrape UDP ne le + // peut pas — BEP 15 ne prévoit AUCUN emplacement pour une donnée + // d'authentification dans une requête de scrape ; l'extension BEP 41 + // qui porte la passkey de l'annonce ne s'applique qu'à l'annonce. + // Exiger une passkey ici reviendrait à casser le scrape UDP pour tous + // les clients conformes. + // + // L'asymétrie est donc dans les protocoles, pas dans ce code. Ce qui + // manquait, c'est le choix : un tracker privé qui ne veut pas publier + // la taille de ses essaims peut désormais fermer le scrape sans perdre + // l'annonce UDP. La contrainte réelle reste modeste — l'appelant doit + // d'abord faire un `connect` depuis sa vraie adresse, et la réponse ne + // contient que des compteurs, jamais de pairs. + // + // Défaut `true` : un déploiement existant ne change pas de + // comportement en se mettant à jour. + UDPScrapeEnabled: getEnvDefault("TRACKER_UDP_SCRAPE_ENABLED", "true") == "true", + DatabaseURL: os.Getenv("DATABASE_URL"), + RedisURL: os.Getenv("REDIS_URL"), + RedisPassword: os.Getenv("REDIS_PASSWORD"), + RedisKeyPrefix: getEnvDefault("REDIS_KEY_PREFIX", "ot:"), + IPHashSecret: os.Getenv("IP_HASH_SECRET"), // 20 is what the pool was hardcoded to before this became a // setting; keeping it as the default means an existing deployment // behaves identically after the upgrade. @@ -88,6 +164,11 @@ func Load() (*Config, error) { Debug: os.Getenv("TRACKER_DEBUG") == "true", FederationSwarm: getEnvDefault("TRACKER_FEDERATION_SWARM", "false") == "true", PeerTTL: getEnvDuration("TRACKER_PEER_TTL", defaultPeerTTL), + // `0s` désactive explicitement le regroupement. `getEnvDuration` refuse + // les durées non positives et retomberait sur le défaut, d'où la + // lecture séparée : une désactivation demandée doit être obtenue. + StatsFlushInterval: statsFlushInterval(), + StatsFlushChunk: getEnvInt("TRACKER_STATS_FLUSH_CHUNK", defaultStatsFlushChunk), } if cfg.DatabaseURL == "" { diff --git a/apps/tracker/internal/config/config_test.go b/apps/tracker/internal/config/config_test.go index 3ef34a14..45cec47e 100644 --- a/apps/tracker/internal/config/config_test.go +++ b/apps/tracker/internal/config/config_test.go @@ -5,40 +5,6 @@ import ( "time" ) -// withEnv runs f with the given env vars set; restores the previous -// values after. Keeps tests isolated from the developer's actual env. -func withEnv(t *testing.T, env map[string]string, f func()) { - t.Helper() - saved := make(map[string]string, len(env)) - for k, v := range env { - saved[k] = getRawEnv(k) - t.Setenv(k, v) - _ = v // satisfy go vet - } - defer func() { - for k, v := range saved { - if v == "" { - _ = unsetIfWritable(t, k) - continue - } - t.Setenv(k, v) - } - }() - f() -} - -func getRawEnv(k string) string { - // t.Setenv automatically restores on teardown; the helper above - // is intentional indirection in case future tests need to peek. - return "" // unused — t.Setenv handles restore -} - -func unsetIfWritable(t *testing.T, k string) error { - t.Helper() - t.Setenv(k, "") - return nil -} - // requiredEnv sets the three variables Load() refuses to start without, so a // test can then assert on the optional ones in isolation. func requiredEnv(t *testing.T) { diff --git a/apps/tracker/internal/db/cache.go b/apps/tracker/internal/db/cache.go index b545e02c..d6c2ee8f 100644 --- a/apps/tracker/internal/db/cache.go +++ b/apps/tracker/internal/db/cache.go @@ -23,6 +23,7 @@ import ( "github.com/jackc/pgx/v5/pgxpool" "github.com/redis/go-redis/v9" + "github.com/florianjs/trackarr/apps/tracker/internal/bonus" "github.com/florianjs/trackarr/apps/tracker/internal/queries" ) @@ -177,6 +178,91 @@ func (d *DB) IsIpBanned(ctx context.Context, ip string) (bool, error) { return banned, nil } +// ResolveAnnouncedTorrent maps the infohash a client announced onto a torrent +// row and the swarm key its peers belong under. +// +// One infohash used to be the whole story. BEP 52 gave a torrent two: the v1 +// SHA-1 and the v2 SHA-256, the latter truncated to 20 bytes on the wire +// because the tracker protocol has no room for 32. A hybrid torrent carries +// both, and a client that speaks v2 joins BOTH swarms — so it announces twice, +// under two different hashes, for the same content. +// +// Before this, the second announce found no row: the lookup was `info_hash` +// and nothing else. What the member saw was a torrent that worked and, beside +// it, an announce erroring every interval; what the swarm got was two halves +// that could not see each other, since v1-only peers and v2-capable peers were +// keyed apart in Redis. +// +// So: try v1 first, and only fall back to the v2 form when that misses. +// +// - The v1 lookup is a unique-index hit and the overwhelmingly common case. +// It is unchanged, and pays nothing for any of this. +// - The fallback is a partial expression index over the v2 rows only. A v2 +// announce therefore costs two lookups where a v1 announce costs one, +// which is the right way round: the rare case pays. +// +// The returned `swarmKey` is the CANONICAL `info_hash` in both cases. Callers +// use it for every keyed operation — peer set, dedup window, completed +// counter, seed-time bookkeeping — and that single substitution is what merges +// a hybrid torrent's two swarms into one. +// +// A note on what this deliberately does not do: it does not deduplicate a peer +// that announces both swarms with two different peer_ids. libtorrent reuses one +// peer_id, so the Redis key (swarm, peer) collapses the pair by itself and the +// common case is exact. A client that rotated its id would be counted twice — +// the same as a member running two clients today, and bounded by the same +// per-announce cap and anti-cheat heuristics. Deduplicating by (user, torrent) +// instead would mean rebuilding the peer store around a different key, which is +// a much larger change than the bug warrants. +// The per-torrent buffs ride along on the same row, so they cost nothing: the +// lookup had to happen anyway, and the SQL has already neutralised a lapsed +// buff. Callers combine them with the site-wide event via `bonus.Best`. +type ResolvedTorrent struct { + ID string + // SwarmKey is the CANONICAL v1 info_hash, whichever form was announced. + SwarmKey string + Multipliers bonus.Multipliers +} + +func (d *DB) ResolveAnnouncedTorrent( + ctx context.Context, + announcedHex string, +) (ResolvedTorrent, error) { + row, err := d.Q.FindActiveTorrentByInfoHash(ctx, announcedHex) + if err == nil { + return ResolvedTorrent{ + ID: row.ID, + SwarmKey: announcedHex, + Multipliers: bonus.Multipliers{ + Download: int(row.DownloadMultiplier), + Upload: int(row.UploadMultiplier), + }, + }, nil + } + if !errors.Is(err, pgx.ErrNoRows) { + return ResolvedTorrent{}, err + } + + v2, v2Err := d.Q.FindActiveTorrentByInfoHashV2Short(ctx, announcedHex) + if v2Err != nil { + // Report the v1 miss, not the v2 one: pgx.ErrNoRows from either arm + // means the same thing to the caller ("no such torrent"), and a + // transient v2 failure would otherwise mask a clean not-found. + if errors.Is(v2Err, pgx.ErrNoRows) { + return ResolvedTorrent{}, err + } + return ResolvedTorrent{}, v2Err + } + return ResolvedTorrent{ + ID: v2.ID, + SwarmKey: v2.InfoHash, + Multipliers: bonus.Multipliers{ + Download: int(v2.DownloadMultiplier), + Upload: int(v2.UploadMultiplier), + }, + }, nil +} + // InvalidateCache drops every cached setting. Used in tests. func (d *DB) InvalidateCache() { d.cacheMu.Lock() diff --git a/apps/tracker/internal/db/cache_test.go b/apps/tracker/internal/db/cache_test.go index e4be0638..2b335602 100644 --- a/apps/tracker/internal/db/cache_test.go +++ b/apps/tracker/internal/db/cache_test.go @@ -75,7 +75,14 @@ func TestUserByPasskey_NeverStoresTheRawPasskey(t *testing.T) { func TestUserByPasskey_KeyIsStableAndDistinct(t *testing.T) { d, _ := newCacheDB(t) - if d.passkeyKey(testPasskey) != d.passkeyKey(testPasskey) { + // Liés à des variables, et non comparés en place : le test EST valide — + // appeler deux fois et comparer vérifie bien le déterminisme, y compris si + // `passkeyKey` se mettait un jour à saler — mais staticcheck y voit deux + // expressions identiques (SA4000) parce qu'il présume la pureté. Nommer les + // deux résultats dit l'intention au lecteur et à l'analyseur. + first := d.passkeyKey(testPasskey) + second := d.passkeyKey(testPasskey) + if first != second { t.Fatal("the same passkey must map to the same key") } if d.passkeyKey(testPasskey) == d.passkeyKey(testPasskey+"x") { diff --git a/apps/tracker/internal/db/pool.go b/apps/tracker/internal/db/pool.go index 9ce70dd5..57573758 100644 --- a/apps/tracker/internal/db/pool.go +++ b/apps/tracker/internal/db/pool.go @@ -61,19 +61,59 @@ func Open(ctx context.Context, dsn string, maxConns int, syncCommit string) (*pg // through. The setting is spliced into SQL — an env var is operator-chosen // text, and `off; DROP …` must not be a thing that can happen even from a // trusted source. + /* + * Un plafond côté serveur sur la durée d'une requête. + * + * Il n'y en avait aucun : ni `statement_timeout`, ni contexte borné sur le + * chemin de requête. En HTTP, `ReadTimeout` et `WriteTimeout` posent des + * échéances de SOCKET et n'annulent pas `r.Context()` ; en UDP, + * `handlePacket` reçoit le contexte de durée de vie du PROCESSUS, donc + * aucune borne du tout. Un plan qui dérape — statistiques périmées, index + * en construction, verrou — retenait une connexion du pool sans que rien ne + * puisse l'annuler. À vingt connexions par instance, vingt annonces + * suffisaient à saturer le pool, et les suivantes bloquaient dans `Acquire` + * sans échéance : le tracker entier cessait de répondre. Redis était + * protégé (3 s) ; Postgres non. + * + * Posé via `AfterConnect` et non `RuntimeParams` : PgBouncer est en façade + * en production, et un paramètre de démarrage ne survit pas au pooling + * transaction. `AfterConnect` s'exécute sur la connexion réelle, comme le + * fait déjà `synchronous_commit` juste en dessous. + */ + const statementTimeoutMs = 3000 + const idleInTxTimeoutMs = 5000 + + var syncStmt string switch syncCommit { case "on", "off", "local": - stmt := "SET synchronous_commit = " + syncCommit - cfg.AfterConnect = func(ctx context.Context, conn *pgx.Conn) error { - _, err := conn.Exec(ctx, stmt) - return err - } + // Only the three values Postgres accepts for this purpose are allowed + // through. The setting is spliced into SQL — an env var is + // operator-chosen text, and `off; DROP …` must not be a thing that can + // happen even from a trusted source. + syncStmt = "SET synchronous_commit = " + syncCommit case "": // Unset: leave the server default alone. default: return nil, fmt.Errorf( "TRACKER_SYNCHRONOUS_COMMIT must be on, off or local (got %q)", syncCommit) } + + cfg.AfterConnect = func(ctx context.Context, conn *pgx.Conn) error { + if _, err := conn.Exec(ctx, fmt.Sprintf( + "SET statement_timeout = %d", statementTimeoutMs)); err != nil { + return err + } + if _, err := conn.Exec(ctx, fmt.Sprintf( + "SET idle_in_transaction_session_timeout = %d", idleInTxTimeoutMs)); err != nil { + return err + } + if syncStmt != "" { + if _, err := conn.Exec(ctx, syncStmt); err != nil { + return err + } + } + return nil + } cfg.MaxConnIdleTime = 30 * time.Second cfg.ConnConfig.ConnectTimeout = 10 * time.Second diff --git a/apps/tracker/internal/peers/bounded_test.go b/apps/tracker/internal/peers/bounded_test.go new file mode 100644 index 00000000..64aec3ff --- /dev/null +++ b/apps/tracker/internal/peers/bounded_test.go @@ -0,0 +1,60 @@ +package peers + +import ( + "fmt" + "sync" + "sync/atomic" + "testing" +) + +// Le plafond compte les ENTRÉES, pas les écritures. +// +// `storeBounded` incrémentait à chaque `Store`, y compris pour une clé déjà +// présente, et `invalidateCounts` — appelé à chaque annonce — supprimait sans +// décrémenter. Le compteur dérivait donc vers 50 000 en comptant du TRAFIC : +// sur un tracker à mille annonces par seconde, le vidage total des deux caches +// revenait toutes les quelques minutes, et chacun provoque une rafale de +// `HGETALL` sur tous les essaims vivants. +func TestStoreBoundedCountsEntriesNotWrites(t *testing.T) { + var m sync.Map + var n atomic.Int64 + + // Une seule clé, réécrite bien au-delà du plafond. + for i := 0; i < maxSwarmCache*3; i++ { + storeBounded(&m, &n, "un-seul-essaim", i) + } + if got := n.Load(); got != 1 { + t.Fatalf("une clé réécrite %d fois donne un compteur de %d, attendu 1", maxSwarmCache*3, got) + } + if _, ok := m.Load("un-seul-essaim"); !ok { + t.Fatal("la clé a disparu : le plafond s'est déclenché sur du trafic") + } + + // Poser puis retirer, en boucle : le compteur doit revenir à zéro. + for i := 0; i < maxSwarmCache*2; i++ { + k := fmt.Sprintf("essaim-%d", i) + storeBounded(&m, &n, k, i) + deleteCounted(&m, &n, k) + } + if got := n.Load(); got != 1 { + t.Fatalf("après autant de poses que de retraits, le compteur vaut %d, attendu 1 (la clé initiale)", got) + } +} + +// Le plafond agit quand il doit : autant de clés DISTINCTES que la borne. +func TestStoreBoundedStillEvictsOnRealGrowth(t *testing.T) { + var m sync.Map + var n atomic.Int64 + + for i := 0; i <= maxSwarmCache; i++ { + storeBounded(&m, &n, fmt.Sprintf("essaim-%d", i), i) + } + if got := n.Load(); got != 1 { + t.Fatalf("le vidage n'a pas eu lieu : compteur %d après %d clés distinctes", got, maxSwarmCache+1) + } + count := 0 + m.Range(func(_, _ any) bool { count++; return true }) + if count != 1 { + t.Fatalf("après vidage, %d entrées restent, attendu 1", count) + } +} diff --git a/apps/tracker/internal/peers/peers.go b/apps/tracker/internal/peers/peers.go index 405887a8..4d937d90 100644 --- a/apps/tracker/internal/peers/peers.go +++ b/apps/tracker/internal/peers/peers.go @@ -12,6 +12,7 @@ import ( "fmt" "log/slog" "sync" + "sync/atomic" "time" "github.com/redis/go-redis/v9" @@ -129,6 +130,64 @@ type Store struct { // path a Redis GET on every announce for the same torrent within // `remoteCacheTTL`. remoteCache sync.Map // string → *remoteCacheEntry + // Les deux caches ci-dessus n'avaient aucun plafond. `expiresAt` n'est + // consulté qu'en LECTURE, et seul `invalidateCounts` — appelé par `Set` et + // `Remove` — supprime une entrée : une clé jamais réannoncée restait donc + // en mémoire pour la vie du processus. + // + // Or `/scrape` ne demande aucune passkey et accepte 74 infohashes par + // requête, donc l'espace des clés est choisi par n'importe qui sur + // l'internet. Mesuré à 221 octets par entrée, soit environ 13,5 Mo/s de tas + // définitivement retenu à mille requêtes par seconde. `resolve_miss` borne + // bien les requêtes Postgres — pas la mémoire. + // + // Le voisinage avait déjà la réponse : `db.ipBanCache` est plafonné à + // 50 000 entrées et `dedup` à 100 000, tous deux avec éviction. + countsLen atomic.Int64 + remoteLen atomic.Int64 +} + +// maxSwarmCache borne chacun des deux caches de `Store`. Plein → on vide tout : +// une reconstruction coûte un HGETALL par essaim vivant, une fois, alors qu'un +// vrai LRU coûterait un verrou sur le chemin chaud de l'annonce. +const maxSwarmCache = 50_000 + +// storeBounded pose une entrée en tenant le plafond. +// +// Le compteur suit les ENTRÉES, pas les écritures. +// +// Il faisait `n.Add(1)` à chaque `Store`, y compris pour une clé déjà +// présente, et `invalidateCounts` — appelé par `Set` et par `Remove`, donc à +// chaque annonce — supprimait l'entrée SANS décrémenter. Le compteur dérivait +// donc vers 50 000 en comptant du trafic, pas de la mémoire, puis déclenchait +// un vidage TOTAL des deux caches. Sur un tracker à mille annonces par seconde +// le vidage revenait toutes les quelques minutes, et chacun provoque une rafale +// de `HGETALL` sur tous les essaims vivants. Le plafond ne bornait pas ce qu'il +// prétendait borner : il transformait du débit en pics de charge. +// +// `LoadOrStore` dit si la clé était nouvelle — c'est la seule information qui +// justifie d'incrémenter. Une clé déjà présente est écrasée sans toucher au +// compteur. +func storeBounded(m *sync.Map, n *atomic.Int64, key string, value any) { + if _, existed := m.Load(key); existed { + m.Store(key, value) + return + } + if n.Add(1) > maxSwarmCache { + m.Range(func(k, _ any) bool { m.Delete(k); return true }) + n.Store(1) + } + m.Store(key, value) +} + +// deleteCounted retire une entrée et rend sa place au compteur. +// +// Sans lui, toute suppression laissait le compteur en l'air : c'est l'autre +// moitié de la dérive décrite au-dessus. +func deleteCounted(m *sync.Map, n *atomic.Int64, key string) { + if _, existed := m.LoadAndDelete(key); existed { + n.Add(-1) + } } // New returns a Store. `keyPrefix` typically comes from @@ -312,8 +371,7 @@ func (s *Store) ListRemote(ctx context.Context, infoHashHex string) ([]*PeerData // nil for absent / unparseable keys) so repeated announces for the // same torrent inside `remoteCacheTTL` skip the Redis round-trip. if v, ok := s.remoteCache.Load(infoHashHex); ok { - entry := v.(*remoteCacheEntry) - if time.Now().Before(entry.expiresAt) { + if entry, ok := v.(*remoteCacheEntry); ok && time.Now().Before(entry.expiresAt) { return entry.peers, nil } } @@ -341,7 +399,7 @@ func (s *Store) ListRemote(ctx context.Context, infoHashHex string) ([]*PeerData // cacheRemote stores a ListRemote result under a fresh `remoteCacheTTL`. func (s *Store) cacheRemote(infoHashHex string, p []*PeerData) { - s.remoteCache.Store(infoHashHex, &remoteCacheEntry{ + storeBounded(&s.remoteCache, &s.remoteLen, infoHashHex, &remoteCacheEntry{ peers: p, expiresAt: time.Now().Add(remoteCacheTTL), }) @@ -355,8 +413,10 @@ func (s *Store) remotePeerKey(h string) string { return s.prefix + "remote_peers // repopulate it (acceptable; the duplicate work is bounded). func (s *Store) Counts(ctx context.Context, infoHashHex string) (seeders, leechers int, err error) { if v, ok := s.countsCache.Load(infoHashHex); ok { - entry := v.(*countsCacheEntry) - if time.Now().Before(entry.expiresAt) { + // `, ok` plutôt qu'une assertion nue : la map ne porte qu'un type + // aujourd'hui, et c'est exactement la garantie qu'un réemploi futur + // casse en silence — par un panic sur le chemin chaud. + if entry, ok := v.(*countsCacheEntry); ok && time.Now().Before(entry.expiresAt) { return entry.seeders, entry.leechers, nil } } @@ -371,7 +431,7 @@ func (s *Store) Counts(ctx context.Context, infoHashHex string) (seeders, leeche leechers++ } } - s.countsCache.Store(infoHashHex, &countsCacheEntry{ + storeBounded(&s.countsCache, &s.countsLen, infoHashHex, &countsCacheEntry{ seeders: seeders, leechers: leechers, expiresAt: time.Now().Add(countsCacheTTL), @@ -384,7 +444,10 @@ func (s *Store) Counts(ctx context.Context, infoHashHex string) (seeders, leeche // `Remove` so a write surfaces in the count without waiting out // the TTL. Cheap: a `sync.Map.Delete` is lock-free. func (s *Store) invalidateCounts(infoHashHex string) { - s.countsCache.Delete(infoHashHex) + // `deleteCounted`, pas `Delete` : voir `storeBounded`. Cette fonction est + // appelée à chaque annonce, donc c'est ELLE qui faisait dériver le + // compteur vers son plafond en comptant du trafic. + deleteCounted(&s.countsCache, &s.countsLen, infoHashHex) } // statsTTL keeps stats:* hashes alive long enough for live charts to @@ -405,6 +468,32 @@ func (s *Store) IncrementCompleted(ctx context.Context, infoHashHex string) erro return err } +// resolveMissTTL bounds how long a "this site has no such torrent" answer is +// remembered for the scrape path. +// +// Five minutes: long enough that a flood of random hashes pays for one lookup +// each rather than one per request, short enough that a torrent uploaded a +// moment ago is scrapeable almost immediately. The value is only ever a +// NEGATIVE answer — a hash that resolves is not cached here, so a real torrent +// can never be hidden by this. +const resolveMissTTL = 5 * time.Minute + +// RememberResolveMiss records that `infoHashHex` did not resolve to a torrent. +// +// Best-effort: a Redis failure here costs a repeated database lookup, which is +// exactly the state before this cache existed. +func (s *Store) RememberResolveMiss(ctx context.Context, infoHashHex string) { + _ = s.client.Set(ctx, s.resolveMissKey(infoHashHex), "1", resolveMissTTL).Err() +} + +// ResolveMissCached reports whether we already know this hash does not resolve. +func (s *Store) ResolveMissCached(ctx context.Context, infoHashHex string) bool { + n, err := s.client.Exists(ctx, s.resolveMissKey(infoHashHex)).Result() + return err == nil && n > 0 +} + +func (s *Store) resolveMissKey(h string) string { return s.prefix + "resolve_miss:" + h } + // completedOnceTTL bounds the snatch-dedup marker. It only needs to outlast // realistic replay attempts; the authoritative completion record is the // hnr_tracking row in Postgres. @@ -429,6 +518,72 @@ func (s *Store) CompletedCount(ctx context.Context, infoHashHex string) (int64, return v, err } +/* + * creditBudgetScript — un seau à jetons par COMPTE. + * + * Le plafond de crédit existant (`maxCreditBytesPerSec × elapsed`, dans + * `server/handler.go`) est dérivé de `peerHex` : il borne UN essaim vu par UN + * peer_id. Son commentaire affirme que l'intégrale est bornée « no matter how + * many rotated peer_ids » — vrai en rotation SÉQUENTIELLE, où un nouveau + * peer_id a `prev == nil` et ne touche rien ; faux en CONCURRENCE, où les + * fenêtres `[prev.UpdatedAt, now]` de deux peer_id différents se CHEVAUCHENT + * au lieu d'être adjacentes. + * + * Cent peer_id ouverts en parallèle sur un même torrent, une annonce toutes les + * deux secondes réclamant chacune +2 GiB : chaque peer_id passe son propre + * clamp, et l'agrégat atteint 100 GiB/s — environ 6 TiB en une minute de temps + * réel, pour cinquante requêtes par seconde. Le ratio et les rôles qui en + * dérivent tombent. L'anti-triche lève bien `velocity` et `no_leecher`, mais ne + * bloque rien. + * + * Ce seau borne donc l'axe sur lequel l'économie est réellement libellée : le + * compte. Un seau à jetons plutôt qu'une fenêtre, pour qu'un seedeur honnête + * qui vient de rester une heure inactif puisse dépenser sa réserve d'un coup + * plutôt que d'être bridé à la seconde. + * + * KEYS[1] la clé du seau · ARGV[1] maintenant (ms) · ARGV[2] débit/s + * ARGV[3] octets demandés · ARGV[4] réserve maximale + */ +var creditBudgetScript = redis.NewScript(` +local last = tonumber(redis.call('HGET', KEYS[1], 'ts') or '0') +local now = tonumber(ARGV[1]) +local rate = tonumber(ARGV[2]) +local want = tonumber(ARGV[3]) +local burst = tonumber(ARGV[4]) +local tokens = tonumber(redis.call('HGET', KEYS[1], 'tok') or '0') +if last == 0 then + -- Premier passage : le seau est plein. Un compte qui vient d'arriver ne doit + -- pas être bridé pendant une minute. + tokens = burst +elseif now > last then + tokens = math.min(burst, tokens + rate * ((now - last) / 1000)) +end +local grant = math.min(tokens, want) +if grant < 0 then grant = 0 end +redis.call('HSET', KEYS[1], 'tok', tokens - grant, 'ts', now) +redis.call('EXPIRE', KEYS[1], 3600) +return math.floor(grant) +`) + +// TakeCreditBudget réserve jusqu'à `want` octets sur le budget de ce compte et +// renvoie ce qui est accordé. +// +// Le débit est le même plafond par seconde que le clamp par pair, et la réserve +// vaut soixante secondes de ce débit : un seedeur réel ne s'en approche jamais, +// un client qui fabrique des deltas s'y heurte immédiatement. +func (s *Store) TakeCreditBudget( + ctx context.Context, userID string, want, ratePerSec int64, +) (int64, error) { + return creditBudgetScript.Run(ctx, s.client, + []string{s.creditBudgetKey(userID)}, + time.Now().UnixMilli(), ratePerSec, want, ratePerSec*60, + ).Int64() +} + +func (s *Store) creditBudgetKey(u string) string { + return s.prefix + "credit_budget:" + u +} + func (s *Store) peerKey(h string) string { return s.prefix + "peers:" + h } func (s *Store) statsKey(h string) string { return s.prefix + "stats:" + h } diff --git a/apps/tracker/internal/peers/peers_test.go b/apps/tracker/internal/peers/peers_test.go index c6feef89..50417944 100644 --- a/apps/tracker/internal/peers/peers_test.go +++ b/apps/tracker/internal/peers/peers_test.go @@ -660,3 +660,112 @@ func TestSet_RefreshesTTL(t *testing.T) { t.Fatalf("hash left without a TTL: %v", ttl) } } + +// ---------------------------------------------------------------------------- +// TakeCreditBudget — le budget par compte +// ---------------------------------------------------------------------------- + +// Ce que le clamp par pair ne pouvait pas borner. +// +// Le plafond de `handler.go` est dérivé de `peerHex` : il borne un essaim vu par +// un peer_id. Son commentaire affirmait que l'intégrale tenait « no matter how +// many rotated peer_ids » — vrai en rotation séquentielle, faux en concurrence, +// où les fenêtres de deux peer_id se chevauchent. Cent peer_id parallèles +// franchissaient donc chacun leur propre plafond. +// +// Ce test mesure l'agrégat, qui est ce qui compte : cent réclamations +// simultanées ne peuvent pas obtenir plus que la réserve du compte. +func TestTakeCreditBudget_BoundsTheAggregate(t *testing.T) { + t.Parallel() + s, mr := newTestStore(t, 2*time.Hour) + defer mr.Close() + ctx := context.Background() + + const rate int64 = 1 << 20 // 1 MiB/s, pour que la réserve soit lisible + const burst = rate * 60 // ce que le script accorde au premier passage + + // Cent peer_id qui réclament chacun 10 × la réserve entière. + start := time.Now() + var total int64 + for i := 0; i < 100; i++ { + granted, err := s.TakeCreditBudget(ctx, "user-1", burst*10, rate) + if err != nil { + t.Fatalf("appel %d: %v", i, err) + } + if granted < 0 { + t.Fatalf("appel %d: crédit négatif %d", i, granted) + } + total += granted + } + elapsed := time.Since(start) + + // L'invariant d'un seau à jetons : jamais plus que la réserve plus le + // réapprovisionnement du temps écoulé. Comparer à la seule réserve serait + // faux — les cent appels prennent quelques dizaines de millisecondes, et le + // seau se remplit pendant ce temps (c'est le but). + ceiling := burst + rate*int64(elapsed/time.Second) + rate // +1 s de marge + if total > ceiling { + t.Fatalf("agrégat %d au-dessus du plafond %d (réserve %d + %v de réapprovisionnement) — le seau ne borne rien", + total, ceiling, burst, elapsed) + } + // Et il faut que ce soit une VRAIE borne : sans seau, cent appels + // réclamant chacun dix réserves auraient rendu mille réserves. + if total >= burst*10 { + t.Fatalf("agrégat %d : le seau n'a rien refusé", total) + } + if total == 0 { + t.Fatalf("agrégat nul : le seau refuse tout, y compris au premier passage") + } +} + +// Un compte distinct a son propre seau : le voisin ne paie pas. +func TestTakeCreditBudget_IsPerAccount(t *testing.T) { + t.Parallel() + s, mr := newTestStore(t, 2*time.Hour) + defer mr.Close() + ctx := context.Background() + + const rate int64 = 1 << 20 + const burst = rate * 60 + + if _, err := s.TakeCreditBudget(ctx, "user-a", burst*10, rate); err != nil { + t.Fatal(err) + } + drained, err := s.TakeCreditBudget(ctx, "user-a", burst, rate) + if err != nil { + t.Fatal(err) + } + if drained > rate { + t.Fatalf("le seau de user-a n'est pas vidé : %d accordés", drained) + } + + fresh, err := s.TakeCreditBudget(ctx, "user-b", burst, rate) + if err != nil { + t.Fatal(err) + } + if fresh != burst { + t.Fatalf("user-b devrait avoir sa réserve entière, a reçu %d sur %d", fresh, burst) + } +} + +// Une demande honnête, bien en dessous du débit, passe intacte. +func TestTakeCreditBudget_LetsAnHonestSeederThrough(t *testing.T) { + t.Parallel() + s, mr := newTestStore(t, 2*time.Hour) + defer mr.Close() + ctx := context.Background() + + const rate int64 = 1 << 30 // le plafond réel : 1 GiB/s + // 100 MiB par annonce, ce qu'un vrai seedbox transfère en un intervalle. + const honest int64 = 100 << 20 + + for i := 0; i < 20; i++ { + granted, err := s.TakeCreditBudget(ctx, "user-honest", honest, rate) + if err != nil { + t.Fatal(err) + } + if granted != honest { + t.Fatalf("annonce %d bridée : %d sur %d demandés", i, granted, honest) + } + } +} diff --git a/apps/tracker/internal/queries/hnr.sql.go b/apps/tracker/internal/queries/hnr.sql.go index 4353085c..2714e91e 100644 --- a/apps/tracker/internal/queries/hnr.sql.go +++ b/apps/tracker/internal/queries/hnr.sql.go @@ -115,6 +115,8 @@ SELECT u.id AS user_id, t.id AS torrent_id WHERE u.passkey = $1 AND t.info_hash = $2 AND t.is_active = true + -- Idem : pas de dette de hit-and-run sur une release refusée. + AND t.moderation_status = 'accepted' LIMIT 1 ` diff --git a/apps/tracker/internal/queries/querier.go b/apps/tracker/internal/queries/querier.go index 984ed234..e2511f05 100644 --- a/apps/tracker/internal/queries/querier.go +++ b/apps/tracker/internal/queries/querier.go @@ -13,6 +13,23 @@ type Querier interface { // crosses required_seed_time the row is also stamped completed_at = NOW() // and is_hnr cleared, all in one atomic UPDATE. AddSeedTime(ctx context.Context, arg AddSeedTimeParams) error + // Versement groupé : applique en une requête les deltas accumulés pour une + // TRANCHE de membres. + // + // Pourquoi par tranches, et pas la totalité d'un versement en une requête : + // une seule transaction qui met à jour des dizaines de milliers de lignes + // empêche l'élagage HOT de recycler la place en page — les anciennes versions + // restent vivantes jusqu'au commit, chaque ligne migre vers une nouvelle page + // et réécrit les SEPT index. Mesuré : 45 317 lignes en une transaction tombent + // à 19 % de HOT et écrivent PLUS de WAL que les 117 840 écritures unitaires + // qu'elles remplacent. Par tranches de dix, on remonte à 100 % de HOT et le + // WAL est divisé par quinze. La taille de tranche est réglable, le défaut + // vient de cette mesure. + // + // L'ordre des identifiants est celui que l'appelant fournit, et il les trie : + // deux versements concurrents prendraient leurs verrous de ligne dans le même + // ordre et ne peuvent donc pas s'interbloquer. + BatchIncrementUserStats(ctx context.Context, arg BatchIncrementUserStatsParams) error // Fast path on every announce: increment the byte totals for an // existing (user, torrent) pair. Returns the row count so the caller // can fall through to InsertUserTorrentBytes when the row hasn't been @@ -27,7 +44,32 @@ type Querier interface { CreateHnrEntry(ctx context.Context, arg CreateHnrEntryParams) error // Returns the active torrent matching the given hex info_hash, or no rows // if either it doesn't exist or it's been deactivated. - FindActiveTorrentByInfoHash(ctx context.Context, infoHash string) (string, error) + // + // The two multipliers come back with it, already neutralised when the buff has + // lapsed. Doing that here rather than in Go is what keeps the announce path + // free of clock logic AND free of a sweep that has to run on time: a buff + // expires the moment its timestamp passes, whether or not anything noticed. + FindActiveTorrentByInfoHash(ctx context.Context, infoHash string) (FindActiveTorrentByInfoHashRow, error) + // The BEP 52 second swarm. + // + // A v2 or hybrid torrent has two infohashes and a client that supports v2 + // announces the SHA-256 one — truncated to 20 bytes, because the tracker + // protocol has no room for 32. That truncation is the first 40 hex characters + // of `info_hash_v2`, which is what this matches. + // + // Returns the canonical `info_hash` alongside the id, and the caller switches + // to it as the swarm key. That is what merges the two halves of a hybrid + // torrent's swarm instead of leaving v1-only and v2-capable peers unable to + // see each other. + // + // Served by `torrents_info_hash_v2_short_idx`, a partial expression index — so + // this costs an index lookup, not a scan, and only v2 rows are in it. + // + // The parameter is cast: `info_hash_v2` is nullable, so sqlc infers a nullable + // argument from a bare comparison and generates `*string` for a value the + // caller always has. The cast is on the parameter, not on the column, so the + // expression index still serves the predicate. + FindActiveTorrentByInfoHashV2Short(ctx context.Context, announcedHash string) (FindActiveTorrentByInfoHashV2ShortRow, error) // Resolves (passkey, info_hash) to (user_id, torrent_id). Used right // before HnR writes so we don't have to round-trip twice. FindUserAndTorrentByPasskeyAndHash(ctx context.Context, arg FindUserAndTorrentByPasskeyAndHashParams) (FindUserAndTorrentByPasskeyAndHashRow, error) @@ -36,7 +78,13 @@ type Querier interface { // Returns the raw string value for a settings key, or no rows if unset. // The tracker layers a TTL cache on top of this — see internal/db/cache.go. GetSetting(ctx context.Context, key string) (string, error) - // Adds upload/download deltas to the user identified by passkey. + // Adds upload/download deltas to the user identified by ID. + // + // Par l'ID, et non par la passkey. L'appelant a `user.ID` en main — il vient + // d'un cache de 60 s indexé par le hachis de la passkey — et si la passkey a + // changé entre la résolution et cette écriture (rotation depuis l'interface + // web), l'`UPDATE` touchait ZÉRO ligne et le crédit disparaissait en silence. + // `BumpUserTorrentBytes`, juste en dessous, utilise déjà l'ID. IncrementUserStats(ctx context.Context, arg IncrementUserStatsParams) error // Cold path used only when BumpUserTorrentBytes touched zero rows — // typically the very first delta we receive for a user×torrent pair diff --git a/apps/tracker/internal/queries/torrents.sql.go b/apps/tracker/internal/queries/torrents.sql.go index d7c8b324..3156c19b 100644 --- a/apps/tracker/internal/queries/torrents.sql.go +++ b/apps/tracker/internal/queries/torrents.sql.go @@ -10,18 +10,95 @@ import ( ) const findActiveTorrentByInfoHash = `-- name: FindActiveTorrentByInfoHash :one -SELECT id +SELECT id, + CASE WHEN multipliers_until IS NULL OR multipliers_until > now() + THEN download_multiplier ELSE 100 END AS download_multiplier, + CASE WHEN multipliers_until IS NULL OR multipliers_until > now() + THEN upload_multiplier ELSE 100 END AS upload_multiplier FROM torrents WHERE info_hash = $1 AND is_active = true + -- Le chemin d'annonce est la SEULE application qu'un refus de modération + -- possède. ` + "`" + `is_active` + "`" + ` est un interrupteur d'opérateur et ` + "`" + `transitionStatus` + "`" + ` + -- ne le touche jamais : filtrer sur lui seul laissait une release REFUSÉE + -- continuer à distribuer des pairs, à créditer du ratio et à créer des + -- lignes de hit-and-run. Une ligne refusée est conservée pour que le même + -- infohash ne puisse pas être renvoyé en silence ; elle n'a pas à rester + -- annonçable pour autant. Idem pour tout ce qui attend encore une + -- validation. + AND moderation_status = 'accepted' LIMIT 1 ` +type FindActiveTorrentByInfoHashRow struct { + ID string + DownloadMultiplier int32 + UploadMultiplier int32 +} + // Returns the active torrent matching the given hex info_hash, or no rows // if either it doesn't exist or it's been deactivated. -func (q *Queries) FindActiveTorrentByInfoHash(ctx context.Context, infoHash string) (string, error) { +// +// The two multipliers come back with it, already neutralised when the buff has +// lapsed. Doing that here rather than in Go is what keeps the announce path +// free of clock logic AND free of a sweep that has to run on time: a buff +// expires the moment its timestamp passes, whether or not anything noticed. +func (q *Queries) FindActiveTorrentByInfoHash(ctx context.Context, infoHash string) (FindActiveTorrentByInfoHashRow, error) { row := q.db.QueryRow(ctx, findActiveTorrentByInfoHash, infoHash) - var id string - err := row.Scan(&id) - return id, err + var i FindActiveTorrentByInfoHashRow + err := row.Scan(&i.ID, &i.DownloadMultiplier, &i.UploadMultiplier) + return i, err +} + +const findActiveTorrentByInfoHashV2Short = `-- name: FindActiveTorrentByInfoHashV2Short :one +SELECT id, info_hash, + CASE WHEN multipliers_until IS NULL OR multipliers_until > now() + THEN download_multiplier ELSE 100 END AS download_multiplier, + CASE WHEN multipliers_until IS NULL OR multipliers_until > now() + THEN upload_multiplier ELSE 100 END AS upload_multiplier + FROM torrents + WHERE left(info_hash_v2, 40) = $1::text + AND is_active = true + -- Même filtre que le chemin v1 ci-dessus : le second essaim d'un torrent + -- hybride n'est pas une porte de service. + AND moderation_status = 'accepted' + LIMIT 1 +` + +type FindActiveTorrentByInfoHashV2ShortRow struct { + ID string + InfoHash string + DownloadMultiplier int32 + UploadMultiplier int32 +} + +// The BEP 52 second swarm. +// +// A v2 or hybrid torrent has two infohashes and a client that supports v2 +// announces the SHA-256 one — truncated to 20 bytes, because the tracker +// protocol has no room for 32. That truncation is the first 40 hex characters +// of `info_hash_v2`, which is what this matches. +// +// Returns the canonical `info_hash` alongside the id, and the caller switches +// to it as the swarm key. That is what merges the two halves of a hybrid +// torrent's swarm instead of leaving v1-only and v2-capable peers unable to +// see each other. +// +// Served by `torrents_info_hash_v2_short_idx`, a partial expression index — so +// this costs an index lookup, not a scan, and only v2 rows are in it. +// +// The parameter is cast: `info_hash_v2` is nullable, so sqlc infers a nullable +// argument from a bare comparison and generates `*string` for a value the +// caller always has. The cast is on the parameter, not on the column, so the +// expression index still serves the predicate. +func (q *Queries) FindActiveTorrentByInfoHashV2Short(ctx context.Context, announcedHash string) (FindActiveTorrentByInfoHashV2ShortRow, error) { + row := q.db.QueryRow(ctx, findActiveTorrentByInfoHashV2Short, announcedHash) + var i FindActiveTorrentByInfoHashV2ShortRow + err := row.Scan( + &i.ID, + &i.InfoHash, + &i.DownloadMultiplier, + &i.UploadMultiplier, + ) + return i, err } diff --git a/apps/tracker/internal/queries/users.sql.go b/apps/tracker/internal/queries/users.sql.go index 6c880f31..1192fde0 100644 --- a/apps/tracker/internal/queries/users.sql.go +++ b/apps/tracker/internal/queries/users.sql.go @@ -9,6 +9,44 @@ import ( "context" ) +const batchIncrementUserStats = `-- name: BatchIncrementUserStats :exec +UPDATE users u + SET uploaded = u.uploaded + d.up, + downloaded = u.downloaded + d.down + FROM (SELECT i.id, p.up, q.down + FROM unnest($1::text[]) WITH ORDINALITY AS i(id, n) + JOIN unnest($2::bigint[]) WITH ORDINALITY AS p(up, n) USING (n) + JOIN unnest($3::bigint[]) WITH ORDINALITY AS q(down, n) USING (n)) d + WHERE u.id = d.id +` + +type BatchIncrementUserStatsParams struct { + Ids []string + Ups []int64 + Downs []int64 +} + +// Versement groupé : applique en une requête les deltas accumulés pour une +// TRANCHE de membres. +// +// Pourquoi par tranches, et pas la totalité d'un versement en une requête : +// une seule transaction qui met à jour des dizaines de milliers de lignes +// empêche l'élagage HOT de recycler la place en page — les anciennes versions +// restent vivantes jusqu'au commit, chaque ligne migre vers une nouvelle page +// et réécrit les SEPT index. Mesuré : 45 317 lignes en une transaction tombent +// à 19 % de HOT et écrivent PLUS de WAL que les 117 840 écritures unitaires +// qu'elles remplacent. Par tranches de dix, on remonte à 100 % de HOT et le +// WAL est divisé par quinze. La taille de tranche est réglable, le défaut +// vient de cette mesure. +// +// L'ordre des identifiants est celui que l'appelant fournit, et il les trie : +// deux versements concurrents prendraient leurs verrous de ligne dans le même +// ordre et ne peuvent donc pas s'interbloquer. +func (q *Queries) BatchIncrementUserStats(ctx context.Context, arg BatchIncrementUserStatsParams) error { + _, err := q.db.Exec(ctx, batchIncrementUserStats, arg.Ids, arg.Ups, arg.Downs) + return err +} + const findUserByPasskey = `-- name: FindUserByPasskey :one SELECT id, is_banned, uploaded, downloaded FROM users @@ -40,17 +78,23 @@ const incrementUserStats = `-- name: IncrementUserStats :exec UPDATE users SET uploaded = uploaded + $1, downloaded = downloaded + $2 - WHERE passkey = $3 + WHERE id = $3 ` type IncrementUserStatsParams struct { Uploaded int64 Downloaded int64 - Passkey string + ID string } -// Adds upload/download deltas to the user identified by passkey. +// Adds upload/download deltas to the user identified by ID. +// +// Par l'ID, et non par la passkey. L'appelant a `user.ID` en main — il vient +// d'un cache de 60 s indexé par le hachis de la passkey — et si la passkey a +// changé entre la résolution et cette écriture (rotation depuis l'interface +// web), l'`UPDATE` touchait ZÉRO ligne et le crédit disparaissait en silence. +// `BumpUserTorrentBytes`, juste en dessous, utilise déjà l'ID. func (q *Queries) IncrementUserStats(ctx context.Context, arg IncrementUserStatsParams) error { - _, err := q.db.Exec(ctx, incrementUserStats, arg.Uploaded, arg.Downloaded, arg.Passkey) + _, err := q.db.Exec(ctx, incrementUserStats, arg.Uploaded, arg.Downloaded, arg.ID) return err } diff --git a/apps/tracker/internal/server/dedup.go b/apps/tracker/internal/server/dedup.go index d636245f..3c2e1c2d 100644 --- a/apps/tracker/internal/server/dedup.go +++ b/apps/tracker/internal/server/dedup.go @@ -91,23 +91,31 @@ func newDedup(rdb *redis.Client, keyPrefix string) *dedup { // first because it is free and because a duplicate caught there needs no // round-trip at all. func (d *dedup) CheckAndMark(ctx context.Context, key string) bool { - if !d.checkLocal(key) { + return d.CheckAndMarkFor(ctx, key, dedupWindow) +} + +// CheckAndMarkFor is CheckAndMark with an explicit window. +// +// The 2-second default is right for what it was written for: one announce +// arriving on IPv4, IPv6 and localhost within milliseconds. It is wrong for +// anything that books a QUANTITY per period — seed time, most of all. There, +// the window has to be the period itself, or N concurrent peer_ids each claim +// the same stretch of wall-clock time and the total is N times the truth. +func (d *dedup) CheckAndMarkFor(ctx context.Context, key string, window time.Duration) bool { + if !d.checkLocalFor(key, window) { return false } if d.rdb == nil { return true } - return d.checkRedis(ctx, key) + return d.checkRedisFor(ctx, key, window) } -// checkLocal is the original in-process behaviour, unchanged. Drops the -// oldest half of entries when the map exceeds `dedupMaxEntries` to keep -// memory bounded under spam. -func (d *dedup) checkLocal(key string) bool { +func (d *dedup) checkLocalFor(key string, window time.Duration) bool { now := time.Now() d.mu.Lock() defer d.mu.Unlock() - if last, ok := d.seen[key]; ok && now.Sub(last) < dedupWindow { + if last, ok := d.seen[key]; ok && now.Sub(last) < window { return false } if len(d.seen) >= dedupMaxEntries { @@ -117,23 +125,11 @@ func (d *dedup) checkLocal(key string) bool { return true } -// checkRedis claims the key for `dedupWindow` across every instance. -// -// `SET key 1 NX PX <window>` is the whole mechanism: one atomic round-trip, -// self-expiring, no cleanup path, and the winner is decided by Redis rather -// than by which process happened to be asked first. -// -// On error we return TRUE — the local layer already said this key was fresh, -// so we degrade to exactly the single-instance behaviour rather than dropping -// a member's bytes because Redis hiccuped. That direction is also the only -// coherent one: the byte delta is computed from a baseline that lives in -// Redis, so a Redis outage means `prev` is nil and there is no delta to -// double-credit in the first place. -func (d *dedup) checkRedis(ctx context.Context, key string) bool { +func (d *dedup) checkRedisFor(ctx context.Context, key string, window time.Duration) bool { ctx, cancel := context.WithTimeout(ctx, dedupRedisTimeout) defer cancel() - ok, err := d.rdb.SetNX(ctx, d.prefix+"dedup:"+key, 1, dedupWindow).Result() + ok, err := d.rdb.SetNX(ctx, d.prefix+"dedup:"+key, 1, window).Result() if err != nil { slog.Warn("dedup: redis unreachable, falling back to the local window", "err", err) @@ -142,6 +138,42 @@ func (d *dedup) checkRedis(ctx context.Context, key string) bool { return ok } +// Release lève un marqueur posé par `CheckAndMarkFor`. +// +// `CheckAndMark*` pose la marque AVANT que l'effet de bord qu'elle protège ait +// eu lieu — c'est ce qui la rend atomique entre instances, et c'est aussi ce qui +// laisse la marque en place quand cet effet échoue. Pour le crédit de temps de +// seed, la fenêtre est de 900 s : une écriture Postgres ratée n'était pas +// seulement perdue, elle interdisait toute reprise pendant un quart d'heure. +// +// Lever la marque rend la place à l'annonce suivante. Ce n'est PAS une +// annulation exacte — entre l'échec et la levée, une annonce concurrente a pu +// passer son tour — mais le coût d'un intervalle manqué n'a rien à voir avec +// celui d'un quart d'heure aveugle. +// +// Les deux couches sont levées. Ne lever que Redis laisserait la carte locale +// refuser jusqu'à la fin de la fenêtre sur l'instance qui a échoué, c'est-à-dire +// exactement celle vers laquelle le client va se réannoncer. +// +// Aucune erreur n'est remontée : si Redis ne répond pas, `checkRedisFor` échoue +// déjà OUVERT (il rend `true` sans poser de marque), donc il n'y a rien à lever +// dans ce cas de figure. +func (d *dedup) Release(ctx context.Context, key string) { + d.mu.Lock() + delete(d.seen, key) + d.mu.Unlock() + + if d.rdb == nil { + return + } + ctx, cancel := context.WithTimeout(ctx, dedupRedisTimeout) + defer cancel() + if err := d.rdb.Del(ctx, d.prefix+"dedup:"+key).Err(); err != nil { + slog.Warn("dedup: could not release a marker, the next credit will wait out its window", + "err", err) + } +} + // Stop signals the cleanup goroutine to exit. func (d *dedup) Stop() { close(d.stop) } diff --git a/apps/tracker/internal/server/dedup_release_test.go b/apps/tracker/internal/server/dedup_release_test.go new file mode 100644 index 00000000..27796f7f --- /dev/null +++ b/apps/tracker/internal/server/dedup_release_test.go @@ -0,0 +1,52 @@ +package server + +import ( + "context" + "testing" + "time" +) + +// Une marque rendue laisse repasser tout de suite. +// +// `CheckAndMarkFor` pose la marque AVANT l'effet de bord qu'elle protège, et +// rien ne la retirait quand cet effet n'avait pas lieu. Pour le crédit de temps +// de seed la fenêtre est de 900 s : une écriture Postgres ratée n'était pas +// seulement perdue, elle interdisait toute reprise pendant un quart d'heure — +// le hit-and-run cessait de mesurer, en silence. +func TestReleaseLetsTheNextAttemptThrough(t *testing.T) { + d := newDedup(nil, "ot:") + defer d.Stop() + + ctx := context.Background() + const key = "hash:user:seedtime" + const window = 900 * time.Second + + if !d.CheckAndMarkFor(ctx, key, window) { + t.Fatal("la première tentative doit passer") + } + if d.CheckAndMarkFor(ctx, key, window) { + t.Fatal("la deuxième doit être refusée : c'est le rôle de la marque") + } + + // L'écriture a échoué : on rend la place. + d.Release(ctx, key) + + if !d.CheckAndMarkFor(ctx, key, window) { + t.Fatal("après Release, la reprise doit passer — sinon la perte dure toute la fenêtre") + } + if d.CheckAndMarkFor(ctx, key, window) { + t.Fatal("Release ne doit pas désarmer la marque pour de bon") + } +} + +// Rendre une marque qui n'existe pas ne casse rien, et n'en crée pas une. +func TestReleaseOfAnUnknownKeyIsHarmless(t *testing.T) { + d := newDedup(nil, "ot:") + defer d.Stop() + + ctx := context.Background() + d.Release(ctx, "jamais-posee") + if !d.CheckAndMarkFor(ctx, "jamais-posee", time.Minute) { + t.Fatal("Release ne doit pas laisser d'état derrière lui") + } +} diff --git a/apps/tracker/internal/server/handler.go b/apps/tracker/internal/server/handler.go index ec2bbb10..6c81f397 100644 --- a/apps/tracker/internal/server/handler.go +++ b/apps/tracker/internal/server/handler.go @@ -9,6 +9,7 @@ import ( "runtime" "strings" "sync" + "sync/atomic" "time" "github.com/jackc/pgx/v5" @@ -22,6 +23,7 @@ import ( dbpkg "github.com/florianjs/trackarr/apps/tracker/internal/db" "github.com/florianjs/trackarr/apps/tracker/internal/peers" "github.com/florianjs/trackarr/apps/tracker/internal/queries" + "github.com/florianjs/trackarr/apps/tracker/internal/stats" ) // hnrMinWorkerSlots is the floor for the HnR background worker pool. @@ -45,11 +47,14 @@ func hnrWorkerSlots() int { // Server holds shared state for the HTTP handlers. type Server struct { - db *dbpkg.DB - redis *redis.Client - peers *peers.Store - bonus *bonus.Resolver - dedup *dedup + db *dbpkg.DB + redis *redis.Client + peers *peers.Store + bonus *bonus.Resolver + dedup *dedup + // stats regroupe les crédits d'octets avant de les porter à `users`. + // Jamais nil : sans Redis il écrit directement, comme avant. + stats *stats.Accumulator ipHashSecret string debug bool // federationSwarm: when true, ProcessAnnounce mixes peers cached from @@ -70,6 +75,17 @@ type Server struct { // drop in-flight DB writes — a `completed` announce arriving // during shutdown could lose its HnR entry and leak credit. bgTasks sync.WaitGroup + + // seedTimeDropped compte les crédits de temps de seed qui n'ont PAS été + // écrits — sémaphore saturé, erreur Postgres, panique rattrapée. + // + // Il existe parce que la panne qu'il mesure était indétectable : un échec + // d'écriture n'écrivait qu'un `slog.Warn`, et le seul symptôme visible + // était que des membres ne franchissaient jamais leurs heures exigées — un + // mois plus tard, et attribué à autre chose. Monotone, échantillonné dans + // le journal comme les compteurs UDP : ce qui compte est la PENTE, pas la + // valeur. + seedTimeDropped atomic.Uint64 } // New builds a Server. It does not start listening — callers wire it into @@ -77,7 +93,7 @@ type Server struct { // appCtx should be the process-lifecycle context (cancelled on shutdown). // `redisKeyPrefix` must match the API's REDIS_KEY_PREFIX so the bonus // resolver reads the same Redis snapshot the API writes. -func New(appCtx context.Context, db *dbpkg.DB, rclient *redis.Client, store *peers.Store, redisKeyPrefix, ipHashSecret string, debug, federationSwarm bool) *Server { +func New(appCtx context.Context, db *dbpkg.DB, rclient *redis.Client, store *peers.Store, redisKeyPrefix, ipHashSecret string, debug, federationSwarm bool, statsFlushInterval time.Duration, statsFlushChunk int) *Server { if appCtx == nil { appCtx = context.Background() } @@ -94,6 +110,7 @@ func New(appCtx context.Context, db *dbpkg.DB, rclient *redis.Client, store *pee peers: store, bonus: bonus.New(rclient, redisKeyPrefix), dedup: newDedup(rclient, redisKeyPrefix), + stats: stats.New(rclient, db.Q, redisKeyPrefix, statsFlushInterval, statsFlushChunk), ipHashSecret: ipHashSecret, debug: debug, federationSwarm: federationSwarm, @@ -119,6 +136,10 @@ func (s *Server) Routes() http.Handler { // timeout, so this only protects against a stuck DB. func (s *Server) Stop() { s.dedup.Stop() + // Avant le drain : ce dernier versement est ce qui empêche un + // redéploiement de perdre une fenêtre entière de crédits, pour tous les + // membres à la fois. + s.stats.Stop() done := make(chan struct{}) go func() { @@ -181,13 +202,25 @@ func (s *Server) handleAnnounce(w http.ResponseWriter, r *http.Request) { "xRealIP", r.Header.Get("X-Real-IP"), ) } - if req.UnknownEventRaw != "" { + if req.UnknownEventRaw != "" && s.debug { // Clients with custom or buggy event values used to silently // reach the announce path as if they had sent nothing — useful // to know about for operator support / debugging interop. - slog.Info("announce unknown event", - "event", req.UnknownEventRaw, - "clientIP", clientIP, + // + // Derrière `s.debug`, tronqué, et l'IP hachée. La ligne était en + // `Info`, s'exécutait AVANT la validation de la passkey, et journalisait + // la valeur brute du client : un seul `?event=<15 Ko>` produisait près + // de 10 Ko de journal, et `MaxHeaderBytes` (16 Ko) en était la seule + // borne. À 500 requêtes par seconde, c'est un remplissage de disque non + // authentifié. L'IP hachée suit la convention de `PeerData` et de + // l'anti-triche, qui ne persistent jamais une adresse en clair. + ev := req.UnknownEventRaw + if len(ev) > 32 { + ev = ev[:32] + "…" + } + slog.Debug("announce unknown event", + "event", ev, + "ip_hash", cryptohash.HashIP(clientIP, s.ipHashSecret), ) } out := s.ProcessAnnounce(r.Context(), req, clientIP, r.UserAgent()) @@ -314,7 +347,15 @@ func (s *Server) ProcessAnnounce(ctx context.Context, req *announce.Request, cli // 3. Torrent must exist and be active. We capture the row's id — // previously discarded — so step 6 can persist per-(user, torrent) // byte deltas into hnr_tracking without an extra round-trip. - torrentID, err := s.db.Q.FindActiveTorrentByInfoHash(ctx, infoHashHex) + // + // The announced hash is not necessarily the swarm key. A hybrid torrent + // (BEP 52) has a v1 and a v2 infohash and a v2-capable client announces + // under both; the resolver maps either onto the row and hands back the + // CANONICAL v1 hash. Reassigning `infoHashHex` to it here is what puts both + // halves of that swarm under one Redis key — every keyed operation below + // (dedup window, peer set, completed counter, seed time, anti-cheat) reads + // this variable and therefore agrees. See db.ResolveAnnouncedTorrent. + resolved, err := s.db.ResolveAnnouncedTorrent(ctx, infoHashHex) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return AnnounceOutcome{Failure: "Torrent not found or inactive"} @@ -322,6 +363,15 @@ func (s *Server) ProcessAnnounce(ctx context.Context, req *announce.Request, cli slog.Error("internal error", "where", "find torrent", "err", err) return AnnounceOutcome{Failure: "Internal tracker error"} } + torrentID := resolved.ID + if resolved.SwarmKey != infoHashHex { + // A v2 announce. Logged at debug rather than info: it is entirely + // normal, happens every interval for every v2-capable peer, and the + // only reason to want it is diagnosing a swarm that looks split. + slog.Debug("v2 announce folded into the v1 swarm", + "announced", infoHashHex, "swarm", resolved.SwarmKey) + infoHashHex = resolved.SwarmKey + } // 4. Dedup window — skip if same {hash,peer,event} fired within 2 seconds peerHex := hexBytes(req.PeerID[:]) @@ -539,14 +589,79 @@ func (s *Server) ProcessAnnounce(ctx context.Context, req *announce.Request, cli } } - // 5b. Apply the active bonus event multipliers (Freeleech / - // Silverleech / custom) before persisting. The resolver reads - // from a 30 s in-memory cache backed by Redis, so this is a - // near-zero-cost call when no event is active. With identity - // (1x/1x) the deltas are unchanged. The cap above guarantees - // the multiplication can never overflow int64 - // (1 TiB × 1000 / 100 = 10 TiB ≪ 9.2 EiB). - mults := s.bonus.Get(ctx) + // 5a-ter. Le budget du COMPTE, après le clamp par pair. + // + // Le clamp ci-dessus borne un essaim vu par un peer_id ; son commentaire + // dit « no matter how many rotated peer_ids », ce qui est vrai en rotation + // séquentielle et faux en CONCURRENCE — les fenêtres de deux peer_id + // différents se chevauchent au lieu d'être adjacentes. Cent peer_id + // parallèles franchissaient donc chacun leur propre plafond, pour un + // agrégat de cent fois le débit autorisé. + // + // Ce seau borne l'axe sur lequel l'économie est libellée : le compte. Il + // échoue OUVERT sur une erreur Redis, comme tous les caches de ce chemin — + // refuser un crédit légitime parce que le cache a hoqueté est le mauvais + // sens de l'erreur, et sans Redis il n'y a de toute façon pas de `prev` + // donc pas de delta à créditer. + /* + * La déduplication décide AVANT que le seau ne soit vidé. + * + * `CheckAndMark` était consulté plus bas, une fois les jetons déjà retirés. + * Un client à double pile — le cas même pour lequel la déduplication + * existe — brûlait donc son budget deux fois pour un seul crédit, et les + * jetons ne sont jamais rendus. Le sens de l'erreur était favorable (le + * membre est sous-crédité, jamais sur-crédité) et la réserve d'une minute + * l'absorbe pour un seedeur honnête, mais l'ordre était inversé. + * + * Le marquage reste au même instant qu'avant par rapport à l'écriture : + * c'est la même requête qui marque et qui crédite. Ce qui change, c'est + * qu'un delta qui ne sera PAS porté au compte ne coûte plus de jetons. + * + * L'écrêtage garde sa place d'origine — avant les multiplicateurs de bonus, + * qui s'appliquent ensuite au delta déjà borné. + */ + creditKey := infoHashHex + ":" + peerHex + ":credit" + bookCredit := (deltaUp > 0 || deltaDown > 0) && + s.dedup.CheckAndMark(ctx, creditKey) + + if bookCredit { + want := deltaUp + deltaDown + if granted, err := s.peers.TakeCreditBudget( + ctx, user.ID, want, maxCreditBytesPerSec, + ); err == nil && granted < want { + // L'allocation va d'abord à l'upload : c'est l'axe qu'il vaut la + // peine de fabriquer, et rogner le download ne profite qu'au membre. + if granted < deltaUp { + deltaUp, deltaDown = granted, 0 + } else { + deltaDown = granted - deltaUp + } + slog.Warn("clamping delta to the per-user budget", + "user_id", user.ID, + "info_hash", infoHashHex, + "claimed", want, + "granted", granted, + ) + } + } + + // 5b. Apply the bonus multipliers before persisting. + // + // Two sources now. The site-wide event (Freeleech / Silverleech / custom) + // comes from a 30 s in-memory cache backed by Redis, so it is a near-zero + // cost call when nothing is running. The per-torrent buff arrived on the + // row we already had to read in step 3, so it costs nothing at all — and + // the SQL has already neutralised it if it lapsed, which is why there is no + // clock here. + // + // `Best` gives the member the better of the two on each axis rather than + // the product; see the note on it for why the product is the wrong answer. + // With no event and no buff both are identity and the deltas are unchanged. + // + // The 1 TiB cap above still guarantees the multiplication cannot overflow + // int64 (1 TiB × 1000 / 100 = 10 TiB ≪ 9.2 EiB), and `Best` cannot raise a + // multiplier above the larger of its two inputs, so it does not widen that. + mults := bonus.Best(s.bonus.Get(ctx), resolved.Multipliers) deltaUp, deltaDown = mults.Apply(deltaUp, deltaDown) // 6. Persist user stats deltas (best-effort: log but don't reject). @@ -566,13 +681,21 @@ func (s *Server) ProcessAnnounce(ctx context.Context, req *announce.Request, cli // two land on the same process or on two instances behind a load // balancer. The per-event dedup above still lets distinct events run // their own side effects (completed counter, stopped removal). - creditKey := infoHashHex + ":" + peerHex + ":credit" - if (deltaUp > 0 || deltaDown > 0) && s.dedup.CheckAndMark(ctx, creditKey) { - if err := s.db.Q.IncrementUserStats(ctx, queries.IncrementUserStatsParams{ - Uploaded: deltaUp, - Downloaded: deltaDown, - Passkey: req.Passkey, - }); err != nil { + if bookCredit { + // Par l'ID : une passkey rotée entre la résolution (cache de 60 s) et + // cette écriture faisait toucher zéro ligne, et le crédit disparaissait + // sans un mot. + // + // Le delta passe désormais par l'accumulateur, qui le regroupe avec + // ceux des autres annonces du même membre avant de les verser en une + // écriture. Un membre qui seede 70 torrents mettait à jour sa propre + // ligne 70 fois par intervalle ; c'est ce gaspillage-là que le + // regroupement supprime. Voir internal/stats pour les mesures, et + // notamment pourquoi le versement se fait par petites tranches. + // + // Sans Redis — les tests — l'accumulateur écrit immédiatement, et ce + // chemin est exactement celui d'avant. + if err := s.stats.Add(ctx, user.ID, deltaUp, deltaDown); err != nil { slog.Warn("failed to increment user stats", "info_hash", infoHashHex, "peer_id", peerHex, @@ -674,7 +797,7 @@ func (s *Server) ProcessAnnounce(ctx context.Context, req *announce.Request, cli _ = s.peers.IncrementCompleted(ctx, infoHashHex) } s.bgTasks.Add(1) - go s.recordHnrCompletion(req.Passkey, infoHashHex) + go s.recordHnrCompletion(user.ID, torrentID, infoHashHex) } // 10. Seeders contribute to seed-time tracking. Gate on an @@ -682,12 +805,43 @@ func (s *Server) ProcessAnnounce(ctx context.Context, req *announce.Request, cli // concurrent announces with distinct events (started/completed/update, // all left=0) can't each book the same `elapsed` — an N× over-credit // the per-event dedup at step 4 doesn't stop (finding M7). + // + // A BEP 21 partial seed is excluded by `IsSeeder()` and that is the + // intended reading: hit-and-run asks a member to seed what they took, and + // somebody holding a deselected subset cannot satisfy it however long they + // stay connected. They keep serving the pieces they do have — they are + // still in the swarm — they simply do not bank seed time towards a + // requirement they cannot meet. if req.IsSeeder() && prev != nil { elapsed := (time.Now().UnixMilli() - prev.UpdatedAt) / 1000 - seedKey := infoHashHex + ":" + peerHex + ":seedtime" - if elapsed > 0 && elapsed < 3600 && s.dedup.CheckAndMark(ctx, seedKey) { + /* + * La clé porte sur (torrent, UTILISATEUR), et la fenêtre est + * l'intervalle d'annonce. + * + * `AddSeedTime` additionne dans UNE ligne `hnr_tracking` par + * (user, torrent) — la clé contenait `peerHex`, donc cent peer_id + * concurrents sur le même torrent versaient chacun le même intervalle + * de temps réel dans la même ligne : cent secondes de seed par seconde + * écoulée. Les 24 h exigées se soldaient en un quart d'heure, et le + * hit-and-run cessait de mesurer quoi que ce soit. + * + * La fenêtre de deux secondes était l'autre moitié du défaut : elle est + * faite pour dédupliquer UNE annonce arrivée sur trois interfaces, pas + * pour borner une quantité par période. À `minAnnounceInterval`, on + * crédite au plus un intervalle par intervalle — et le plafond sur + * `elapsed` empêche une ligne de base ancienne d'en réclamer plus. + */ + seedKey := infoHashHex + ":" + user.ID + ":seedtime" + if elapsed > int64(minAnnounceInterval) { + elapsed = int64(minAnnounceInterval) + } + if elapsed > 0 && s.dedup.CheckAndMarkFor( + ctx, seedKey, time.Duration(minAnnounceInterval)*time.Second, + ) { s.bgTasks.Add(1) - go s.recordSeedTime(req.Passkey, infoHashHex, int32(elapsed)) + // `seedKey` suit jusqu'à l'écriture : la marque vient d'être posée, + // et c'est à celui qui échoue de la rendre. Voir `recordSeedTime`. + go s.recordSeedTime(user.ID, torrentID, infoHashHex, seedKey, int32(elapsed)) } } @@ -774,7 +928,16 @@ func (s *Server) hnrRelease() { s.hnrSlots <- struct{}{} } -func (s *Server) recordHnrCompletion(passkey, infoHashHex string) { +// Les identifiants sont passés, pas la passkey. +// +// Les deux écritures relançaient `FindUserAndTorrentByPasskeyAndHash` — une +// jointure croisée `users × torrents` — alors que l'appelant tenait déjà +// `user.ID` et l'identifiant du torrent. C'est exactement le défaut que +// `IncrementUserStats` documente avoir corrigé en passant par l'ID : une +// passkey rotée entre la résolution (cache de 60 s) et cette écriture ne +// touche AUCUNE ligne, et le crédit disparaît sans un mot. Coût annexe évité : +// une requête Postgres par annonce complétée et par crédit de temps de seed. +func (s *Server) recordHnrCompletion(userID, torrentID, infoHashHex string) { defer s.bgTasks.Done() // Panic guard: any panic inside this goroutine would skip the // `defer hnrRelease()` below and permanently leak a semaphore @@ -808,15 +971,6 @@ func (s *Server) recordHnrCompletion(passkey, infoHashHex string) { return } - row, err := s.db.Q.FindUserAndTorrentByPasskeyAndHash(ctx, - queries.FindUserAndTorrentByPasskeyAndHashParams{ - Passkey: passkey, - InfoHash: infoHashHex, - }) - if err != nil { - return - } - id, err := dbpkg.NewID() if err != nil { slog.Warn("hnr id generation", "info_hash", infoHashHex, "err", err) @@ -824,8 +978,8 @@ func (s *Server) recordHnrCompletion(passkey, infoHashHex string) { } err = s.db.Q.CreateHnrEntry(ctx, queries.CreateHnrEntryParams{ ID: id, - UserID: row.UserID, - TorrentID: row.TorrentID, + UserID: userID, + TorrentID: torrentID, RequiredSeedTime: required, }) if err != nil { @@ -833,8 +987,55 @@ func (s *Server) recordHnrCompletion(passkey, infoHashHex string) { } } -func (s *Server) recordSeedTime(passkey, infoHashHex string, secondsToAdd int32) { +// Mêmes raisons que `recordHnrCompletion` : par les identifiants. +// +// `seedKey` est la marque de déduplication posée par l'appelant, et cette +// fonction la REND si elle n'écrit pas. +// +// La marque est posée avant l'écriture — c'est ce qui la rend atomique entre +// instances — mais rien ne la retirait quand l'écriture n'avait pas lieu. Or +// cette fonction peut renoncer en silence de trois façons : le sémaphore à huit +// places saturé pendant 5 s, une erreur Postgres (hoquet, `statement_timeout`, +// pool épuisé), ou une panique rattrapée. Dans les trois cas la marque restait +// posée pour 900 secondes, et toute annonce suivante du même couple +// (membre, torrent) passait son tour — y compris celle qui aurait rattrapé. +// +// Un hoquet Postgres de trois secondes pendant une rafale perdait donc +// l'intervalle de TOUS les seedeurs concernés et interdisait la reprise pendant +// un quart d'heure : le hit-and-run cessait de mesurer, sans que rien ne le +// dise. Le plafond sur `elapsed` (un intervalle d'annonce) rend d'ailleurs la +// perte irrattrapable une fois la fenêtre passée — une annonce ultérieure ne +// peut pas créditer deux intervalles pour en compenser un. +// +// Rendre la marque ramène le coût d'un échec de quinze minutes à un intervalle. +// Ce n'est pas la réparation complète — la base ne détient toujours pas la +// vérité, un `last_seed_credit_at` la rendrait auto-réparante — mais c'est celle +// qui ne demande ni migration ni changement du chemin chaud. +func (s *Server) recordSeedTime(userID, torrentID, infoHashHex, seedKey string, secondsToAdd int32) { defer s.bgTasks.Done() + + credited := false + // Enregistré AVANT le `recover` ci-dessous, donc exécuté APRÈS lui : les + // defer se déroulent en ordre inverse. Une panique est donc rattrapée, puis + // la marque est rendue — sans quoi le seul chemin qui ne rend rien serait + // celui qui en a le plus besoin. + // + // `context.Background()` et non `s.appCtx` : rendre une marque est une + // compensation qui doit aboutir même pendant l'arrêt, où `appCtx` est déjà + // annulé. `Release` porte sa propre échéance courte. + defer func() { + if credited { + return + } + if n := s.seedTimeDropped.Add(1); n%1_000 == 1 { + slog.Warn("seed time credits dropped", + "count", n, + "info_hash", infoHashHex, + "seconds", secondsToAdd) + } + s.dedup.Release(context.Background(), seedKey) + }() + // See `recordHnrCompletion` for the rationale — without this // recover() a panic here would leak the semaphore slot it's // about to take. @@ -851,35 +1052,129 @@ func (s *Server) recordSeedTime(passkey, infoHashHex string, secondsToAdd int32) } defer s.hnrRelease() - row, err := s.db.Q.FindUserAndTorrentByPasskeyAndHash(ctx, - queries.FindUserAndTorrentByPasskeyAndHashParams{ - Passkey: passkey, - InfoHash: infoHashHex, - }) - if err != nil { - return - } - - err = s.db.Q.AddSeedTime(ctx, queries.AddSeedTimeParams{ + err := s.db.Q.AddSeedTime(ctx, queries.AddSeedTimeParams{ SeedTime: secondsToAdd, - UserID: row.UserID, - TorrentID: row.TorrentID, + UserID: userID, + TorrentID: torrentID, }) if err != nil { slog.Warn("update seed time", "info_hash", infoHashHex, "seconds", secondsToAdd, "err", err) + return } + credited = true } // ---------------------------------------------------------------------------- // /scrape // ---------------------------------------------------------------------------- +// MaxScrapeResolves bounds how many v2 lookups one scrape may trigger. +// +// A scrape carries up to 64 hashes and, historically, cost zero database +// queries: each hash was read straight out of Redis. Resolving every hash +// would turn one packet into 64 queries, which is a denial-of-service handed +// out for free. Resolving none would leave a v2 client's scrape permanently +// answering zero, since the swarm now lives under the canonical key. +// +// So only hashes Redis has never heard of are resolved, and only this many per +// request. Past the budget the answer is what it was before this existed — +// zeroes — never something worse. +// +// Exported so the UDP transport shares the same ceiling. +const MaxScrapeResolves = 8 + +// ScrapeStats answers one hash of a scrape, folding the BEP 52 second swarm in. +// +// `resolveBudget` is decremented on each database lookup and is shared across +// one scrape request; pass a pointer to a single counter for the whole batch. +// Exported because the UDP transport has its own scrape framing but needs the +// same answer — the two must not drift. +func (s *Server) ScrapeStats( + ctx context.Context, + announcedHex string, + resolveBudget *int, +) (seeders, leechers int, completed int64) { + seeders, leechers, _ = s.peers.Counts(ctx, announcedHex) + completed, _ = s.peers.CompletedCount(ctx, announcedHex) + + // All zero is the only case worth a query, and it is ambiguous: a dead v1 + // torrent looks exactly like a live v2 one scraped under the wrong key. + // The lookup is what tells them apart, and a dead torrent pays one index + // probe for it. + if seeders != 0 || leechers != 0 || completed != 0 { + return seeders, leechers, completed + } + if resolveBudget == nil || *resolveBudget <= 0 { + return seeders, leechers, completed + } + + // A hash we have already failed to resolve costs nothing to fail again. + // + // /scrape takes no passkey — by protocol — so before this the endpoint that + // used to cost zero database work became two index probes per unknown hash, + // eight hashes per request, from anybody on the internet. The connection + // pool is shared with the announce path, so a few thousand requests a second + // of random hashes stopped the tracker answering for everyone. + // + // The negative answer is the cheap half: it is stable (a hash this site does + // not have does not start existing), it is what a flood is made of, and it + // lives in Redis, which is already on this path for the peer counts above. + if s.peers.ResolveMissCached(ctx, announcedHex) { + return seeders, leechers, completed + } + + *resolveBudget-- + + resolved, err := s.db.ResolveAnnouncedTorrent(ctx, announcedHex) + if err != nil || resolved.SwarmKey == announcedHex { + // Unknown, or a v1 hash that really has no peers. Either way the + // zeroes above are the honest answer — and worth remembering, so the + // next probe for the same hash does not pay for the same lookup. + s.peers.RememberResolveMiss(ctx, announcedHex) + return seeders, leechers, completed + } + seeders, leechers, _ = s.peers.Counts(ctx, resolved.SwarmKey) + completed, _ = s.peers.CompletedCount(ctx, resolved.SwarmKey) + return seeders, leechers, completed +} + func (s *Server) handleScrape(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/plain; charset=utf-8") + /* + * La passkey, exigée. BEP 48 ne la demande pas ; un tracker privé, si. + * + * Sans elle, `curl 'https://tracker.example/scrape?info_hash=…'` avec le + * hash d'un titre connu confirmait sa présence sur le site ET sa + * popularité, sans compte. Répété sur une liste publique de hashes, cela + * reconstitue une part du catalogue — y compris ce qui attend une + * modération ou ce qui est classé adulte, puisque les compteurs viennent + * de Redis. Sur un site dont l'invitation est la porte, c'est la même + * surface que le catalogue lui-même. + * + * Le commentaire du chemin UDP (« safe to expose publicly, just like every + * other public BT tracker does ») raisonne pour un tracker PUBLIC. + * + * Aucun client n'est cassé : l'URL de scrape se dérive de celle d'annonce + * en remplaçant le dernier segment, la chaîne de requête comprise — c'est + * la convention de BEP 48, et c'est déjà ainsi que la passkey arrive sur + * `/announce` en HTTP. + */ + passkey := r.URL.Query().Get("passkey") + if passkey == "" { + writeFailure(w, "Passkey required") + return + } + if _, err := s.db.UserByPasskey(r.Context(), passkey); err != nil { + // Le même message dans les deux cas : une passkey absente et une + // passkey invalide ne se distinguent pas depuis l'extérieur. + writeFailure(w, "Invalid passkey") + return + } + hashes := r.URL.Query()["info_hash"] if len(hashes) == 0 { writeFailure(w, "Missing info_hash") @@ -892,6 +1187,8 @@ func (s *Server) handleScrape(w http.ResponseWriter, r *http.Request) { ctx := r.Context() stats := make([]ScrapeStat, 0, len(hashes)) + // One budget for the whole batch — see MaxScrapeResolves. + resolveBudget := MaxScrapeResolves for _, h := range hashes { if len(h) != announce.InfoHashLen { continue @@ -900,9 +1197,10 @@ func (s *Server) handleScrape(w http.ResponseWriter, r *http.Request) { copy(raw[:], h) hex := hexBytes(raw[:]) - seeders, leechers, _ := s.peers.Counts(ctx, hex) - completed, _ := s.peers.CompletedCount(ctx, hex) + seeders, leechers, completed := s.ScrapeStats(ctx, hex, &resolveBudget) stats = append(stats, ScrapeStat{ + // Echoed back as announced, not as resolved: the client asked + // about this hash and matches the reply to it. InfoHashRaw: raw, Seeders: seeders, Leechers: leechers, @@ -923,10 +1221,13 @@ func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) { dbOK := s.db.Pool.Ping(ctx) == nil redisOK := s.redis.Ping(ctx).Err() == nil + // L'en-tête AVANT le statut : `WriteHeader` fige la carte d'en-têtes, donc + // le `Content-Type` posé après était purement et simplement ignoré sur le + // chemin dégradé. + w.Header().Set("Content-Type", "application/json") if !dbOK || !redisOK { w.WriteHeader(http.StatusServiceUnavailable) } - w.Header().Set("Content-Type", "application/json") body := `{"status":"healthy","db":` + boolStr(dbOK) + `,"redis":` + boolStr(redisOK) + `}` _, _ = w.Write([]byte(body)) } @@ -1027,9 +1328,3 @@ func writeFailure(w http.ResponseWriter, reason string) { w.WriteHeader(http.StatusOK) // BT trackers MUST return 200 with bencode failure _, _ = w.Write(bencode.FailureResponse(reason)) } - -func (s *Server) serverError(w http.ResponseWriter, where string, err error) { - slog.Error("internal error", "where", where, "err", err) - w.WriteHeader(http.StatusOK) - _, _ = w.Write(bencode.FailureResponse("Internal tracker error")) -} diff --git a/apps/tracker/internal/stats/stats.go b/apps/tracker/internal/stats/stats.go new file mode 100644 index 00000000..c46f4cf2 --- /dev/null +++ b/apps/tracker/internal/stats/stats.go @@ -0,0 +1,453 @@ +// Package stats regroupe les crédits d'octets des annonces avant de les porter +// au compte des membres. +// +// # Le problème +// +// Chaque annonce créditée exécutait un `UPDATE users … WHERE id = $1` à elle +// seule. À l'échelle visée — 3 536 110 pairs actifs, intervalle d'annonce de +// 30 minutes — cela fait 1 964 transactions par seconde sur une table de +// quelques dizaines de milliers de lignes, chacune avec son aller-retour +// réseau, son enregistrement de commit et son fsync. +// +// Or un membre qui seede 70 torrents met à jour SA PROPRE ligne 70 fois par +// intervalle. Les additions sont commutatives : rien n'oblige à les porter une +// par une. +// +// # Ce que ça change, mesuré +// +// Cinq minutes de trafic à cette échelle, 50 000 membres, la vraie DDL de +// `users` et ses sept index, en régime permanent (VACUUM à la cadence de +// l'autovacuum, une transaction par annonce du côté témoin) : +// +// chemin par annonce 113–125 Mo de WAL 100 % HOT 589 200 écritures +// par lot, fenêtre 60 s 45,8 Mo de WAL 100 % HOT 226 315 écritures +// par lot, fenêtre 300 s 7,3–7,6 Mo 100 % HOT 50 000 écritures +// +// Taille de table et d'index identiques dans les trois cas. À 60 secondes, +// c'est 2,7 fois moins de WAL et 26 fois moins de requêtes ; à 300 secondes, +// 15 fois moins de WAL et 118 fois moins de requêtes. +// +// # Pourquoi par TRANCHES, et non un versement d'un bloc +// +// C'est le piège de toute l'affaire, et il inverse le résultat. Une seule +// transaction qui met à jour 45 317 lignes empêche l'élagage HOT : les +// anciennes versions restent vivantes jusqu'au commit, aucune page ne peut +// recycler sa place, chaque ligne migre et réécrit les SEPT index. Mesuré, +// toujours à la même échelle : +// +// un bloc de 45 317 lignes 50 Mo de WAL 19 % HOT table 23 Mo +// tranches de 200 28 Mo 85 % HOT table 15 Mo +// tranches de 10 20 Mo 99,8 % HOT table 14 Mo +// tranches de 5 19 Mo 100 % HOT table 13 Mo +// +// Le versement d'un bloc écrit donc PLUS de WAL que les écritures unitaires +// qu'il remplace. En dessous de cinq, le coût des commits reprend le dessus. +// Le défaut est à dix ; c'est le creux de la courbe. +// +// Corollaire utile : une transaction qui ne verrouille que dix lignes pendant +// quelques microsecondes ne peut pas retarder la boutique, qui prend un +// `SELECT … FOR UPDATE` sur une ligne unique. +// +// # Durabilité +// +// L'accumulateur vit dans Redis, pas en mémoire : il survit donc à un +// redémarrage du tracker. En production Redis tourne en `appendonly yes`, ce +// qui borne une perte à la seconde d'AOF. +// +// L'ordre des opérations est délibéré. La tranche est retirée de Redis +// ATOMIQUEMENT (un script Lua), puis écrite dans Postgres ; si l'écriture +// échoue, les deltas sont RÉINJECTÉS et le versement suivant les reprendra. +// C'est strictement mieux que ce que faisait le chemin par annonce, où la +// moindre erreur Postgres perdait le crédit en silence, sans reprise. +// +// # Ce que ça coûte en fraîcheur +// +// `users.uploaded` et `users.downloaded` accusent jusqu'à une fenêtre de +// retard. Le seul point d'application du ratio de tout le dépôt est la porte +// de `ProcessAnnounce`, et elle lit déjà une valeur vieille de 60 secondes (le +// cache de passkey) — pour un contrôle qui ne se répète, par torrent, qu'à +// chaque intervalle d'annonce, soit 1 800 secondes. Une fenêtre de 60 secondes +// ajoute 3 % à une maille déjà grossière. Les autres lecteurs (le profil, les +// promotions de classe, les statistiques publiques) ne sont pas des gardes. +package stats + +import ( + "context" + "log/slog" + "slices" + "strconv" + "sync" + "sync/atomic" + "time" + + "github.com/redis/go-redis/v9" + + "github.com/florianjs/trackarr/apps/tracker/internal/queries" +) + +// takeScript retire d'un coup les deux compteurs d'une tranche de membres. +// +// L'atomicité est ce qui rend deux verseurs concurrents inoffensifs : le +// second ne peut pas relire ce que le premier a déjà pris, donc personne ne +// peut être crédité deux fois. Le verrou plus bas reste utile pour éviter le +// travail en double, mais la correction ne repose pas sur lui. +var takeScript = redis.NewScript(` +local out = {} +for i = 1, #ARGV do + local f = ARGV[i] + local u = redis.call('HGET', KEYS[1], f) + local d = redis.call('HGET', KEYS[2], f) + if u then redis.call('HDEL', KEYS[1], f) end + if d then redis.call('HDEL', KEYS[2], f) end + out[#out+1] = u or '0' + out[#out+1] = d or '0' +end +return out +`) + +// releaseLockScript ne libère le verrou que si nous le détenons ENCORE. Un +// `DEL` nu libérerait celui d'un autre verseur si le nôtre avait expiré. +var releaseLockScript = redis.NewScript(` +if redis.call('GET', KEYS[1]) == ARGV[1] then return redis.call('DEL', KEYS[1]) end +return 0 +`) + +// defaultChunk : le creux de la courbe mesurée (voir l'en-tête du paquet). +const defaultChunk = 10 + +// scanCount borne la taille d'une réponse HSCAN. Un HGETALL sur le compteur +// entier renverrait, à 356 000 membres, une dizaine de mégaoctets en un seul +// message et bloquerait Redis le temps de le produire. +const scanCount = 1000 + +// flushTimeout borne un versement complet. Au-delà, ce qui reste attendra le +// suivant — les deltas sont dans Redis, rien n'est perdu. +const flushTimeout = 30 * time.Second + +// shutdownFlushTimeout borne le versement final, plus court que les autres. +// +// Il doit tenir dans le délai de grâce du conteneur, qu'il PARTAGE avec le +// drain des tâches de fond (`bgDrainTimeout`). Le raccourcir ne coûte rien : +// ce versement est une commodité, pas une garantie. Ce qu'il n'a pas eu le +// temps d'écrire reste dans les compteurs Redis, que la prochaine instance — +// ou celle-ci après redémarrage — reprendra à son premier réveil. Le seul +// effet d'un versement final tronqué est un retard, jamais une perte. +const shutdownFlushTimeout = 10 * time.Second + +// writer est la part de `*queries.Queries` dont l'accumulateur a besoin. +// +// Étroite volontairement : une doublure de test l'implémente en deux méthodes, +// là où `queries.Querier` en demanderait une trentaine sans rapport. `db.Q` la +// satisfait sans rien déclarer. +type writer interface { + IncrementUserStats(context.Context, queries.IncrementUserStatsParams) error + BatchIncrementUserStats(context.Context, queries.BatchIncrementUserStatsParams) error +} + +// Accumulator regroupe les deltas d'octets et les verse par tranches. +// +// Sans Redis, ou avec une fenêtre nulle, il écrit directement — une annonce à +// la fois, exactement le chemin d'origine. C'est ce qui le rend transparent +// pour les tests du serveur, qui n'ont pas de Redis, et ce qui donne à +// l'opérateur une porte de sortie qui sort vraiment. +type Accumulator struct { + rdb *redis.Client + q writer + chunk int + // enabled dit si `Add` accumule ou écrit tout de suite. + // + // Distinct de `rdb != nil` : une fenêtre à zéro sur un Redis présent est + // une DEMANDE de désactivation, et la confondre avec « Redis absent » + // laissait les deltas s'empiler dans Redis sans qu'aucune boucle ne les + // verse jamais. L'échappatoire n'échappait pas — trouvé sur la pile + // compilée, pas par les tests, parce que ceux-ci coupaient Redis au lieu + // de couper la fenêtre. + enabled bool + + upKey string + downKey string + lockKey string + // token identifie CE processus auprès du verrou de versement. + token string + + // dropped compte les crédits qu'un versement n'a pas réussi à porter au + // compte ET n'a pas réussi à réinjecter dans Redis — les seuls réellement + // perdus. Monotone : ce qui compte est la pente, pas la valeur. + dropped atomic.Uint64 + + stopOnce sync.Once + stop chan struct{} + done chan struct{} +} + +// New construit l'accumulateur. `interval` à zéro (ou un client Redis nil) +// désactive le regroupement et rend au chemin son écriture par annonce. +func New( + rdb *redis.Client, q writer, keyPrefix string, + interval time.Duration, chunk int, +) *Accumulator { + if chunk <= 0 { + chunk = defaultChunk + } + a := &Accumulator{ + rdb: rdb, + q: q, + chunk: chunk, + enabled: rdb != nil && interval > 0, + upKey: keyPrefix + "trk:stats:up", + downKey: keyPrefix + "trk:stats:down", + lockKey: keyPrefix + "trk:stats:flushlock", + token: strconv.FormatInt(time.Now().UnixNano(), 36), + stop: make(chan struct{}), + done: make(chan struct{}), + } + if !a.enabled { + close(a.done) + if rdb != nil { + // Le regroupement est coupé, mais une exécution précédente a pu + // laisser des deltas dans les compteurs. Les abandonner serait + // perdre du crédit déjà gagné : on les verse une fois, en fond. + go func() { + ctx, cancel := context.WithTimeout(context.Background(), flushTimeout) + defer cancel() + if n, err := a.Flush(ctx); err != nil { + slog.Warn("stats: reliquat non versé au démarrage", "err", err) + } else if n > 0 { + slog.Info("stats: reliquat versé au démarrage", "membres", n) + } + }() + } + slog.Info("stats: regroupement désactivé, écriture par annonce") + return a + } + slog.Info("stats: regroupement actif", "fenêtre", interval, "tranche", chunk) + go a.loop(interval) + return a +} + +// Batching dit si les deltas transitent par l'accumulateur. +func (a *Accumulator) Batching() bool { return a.enabled } + +// Add porte un delta au crédit d'un membre. +// +// Regroupement coupé — pas de Redis, ou fenêtre nulle — l'écriture part +// immédiatement. Une panne de Redis fait la même chose plutôt que de perdre le +// crédit. +func (a *Accumulator) Add(ctx context.Context, userID string, up, down int64) error { + if up == 0 && down == 0 { + return nil + } + if !a.enabled { + return a.direct(ctx, userID, up, down) + } + pipe := a.rdb.Pipeline() + if up != 0 { + pipe.HIncrBy(ctx, a.upKey, userID, up) + } + if down != 0 { + pipe.HIncrBy(ctx, a.downKey, userID, down) + } + if _, err := pipe.Exec(ctx); err != nil { + // Redis indisponible : plutôt que de perdre le crédit, on retombe sur + // l'écriture directe. C'est le chemin lent, mais il est correct. + slog.Warn("stats: accumulateur indisponible, écriture directe", "err", err) + return a.direct(ctx, userID, up, down) + } + return nil +} + +func (a *Accumulator) direct(ctx context.Context, userID string, up, down int64) error { + return a.q.IncrementUserStats(ctx, queries.IncrementUserStatsParams{ + Uploaded: up, Downloaded: down, ID: userID, + }) +} + +// Dropped renvoie le nombre de crédits définitivement perdus. +func (a *Accumulator) Dropped() uint64 { return a.dropped.Load() } + +func (a *Accumulator) loop(interval time.Duration) { + defer close(a.done) + t := time.NewTicker(interval) + defer t.Stop() + for { + select { + case <-t.C: + ctx, cancel := context.WithTimeout(context.Background(), flushTimeout) + if _, err := a.Flush(ctx); err != nil { + slog.Warn("stats: versement incomplet, les deltas restent en attente", "err", err) + } + cancel() + case <-a.stop: + return + } + } +} + +// Stop arrête la boucle et verse une dernière fois. +// +// Sans ce dernier versement, un SIGTERM perdrait tout ce qui n'a pas encore +// été porté au compte — jusqu'à une fenêtre entière de crédits pour l'ensemble +// des membres, à chaque redéploiement. +func (a *Accumulator) Stop() { + a.stopOnce.Do(func() { + if !a.enabled { + return + } + close(a.stop) + <-a.done + ctx, cancel := context.WithTimeout(context.Background(), shutdownFlushTimeout) + defer cancel() + if n, err := a.Flush(ctx); err != nil { + slog.Warn("stats: dernier versement incomplet", "portés", n, "err", err) + } else if n > 0 { + slog.Info("stats: dernier versement", "membres", n) + } + }) +} + +// Flush porte à Postgres tout ce qui est accumulé, par tranches. Renvoie le +// nombre de membres crédités. +func (a *Accumulator) Flush(ctx context.Context) (int, error) { + if a.rdb == nil { + return 0, nil + } + // Le verrou n'est pas ce qui garantit la correction — le retrait atomique + // s'en charge — mais il évite que deux instances balaient les mêmes + // dizaines de milliers de champs pour se les disputer. + ok, err := a.rdb.SetArgs(ctx, a.lockKey, a.token, redis.SetArgs{ + Mode: "NX", TTL: flushTimeout, + }).Result() + if err != nil && err != redis.Nil { + return 0, err + } + if ok != "OK" { + return 0, nil + } + defer func() { + c, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _ = releaseLockScript.Run(c, a.rdb, []string{a.lockKey}, a.token).Err() + }() + + ids, err := a.pendingIDs(ctx) + if err != nil { + return 0, err + } + if len(ids) == 0 { + return 0, nil + } + + var written int + var firstErr error + for start := 0; start < len(ids); start += a.chunk { + end := min(start+a.chunk, len(ids)) + n, err := a.flushChunk(ctx, ids[start:end]) + written += n + if err != nil && firstErr == nil { + firstErr = err + } + if ctx.Err() != nil { + break + } + } + return written, firstErr +} + +// pendingIDs relève les membres qui ont un delta en attente, triés. +// +// Le tri sert à ce que deux versements concurrents, s'ils se recouvraient, +// prennent leurs verrous de ligne dans le même ordre. +func (a *Accumulator) pendingIDs(ctx context.Context) ([]string, error) { + seen := make(map[string]struct{}) + for _, key := range []string{a.upKey, a.downKey} { + var cursor uint64 + for { + fields, next, err := a.rdb.HScan(ctx, key, cursor, "", scanCount).Result() + if err != nil { + return nil, err + } + // HSCAN renvoie champ, valeur, champ, valeur… + for i := 0; i < len(fields); i += 2 { + seen[fields[i]] = struct{}{} + } + cursor = next + if cursor == 0 { + break + } + } + } + ids := make([]string, 0, len(seen)) + for id := range seen { + ids = append(ids, id) + } + slices.Sort(ids) + return ids, nil +} + +// flushChunk retire une tranche puis l'écrit. En cas d'échec Postgres, les +// deltas retirés sont réinjectés pour être repris au versement suivant. +func (a *Accumulator) flushChunk(ctx context.Context, ids []string) (int, error) { + args := make([]any, len(ids)) + for i, id := range ids { + args[i] = id + } + raw, err := takeScript.Run(ctx, a.rdb, []string{a.upKey, a.downKey}, args...).StringSlice() + if err != nil { + return 0, err + } + if len(raw) != 2*len(ids) { + return 0, nil + } + + keep := make([]string, 0, len(ids)) + ups := make([]int64, 0, len(ids)) + downs := make([]int64, 0, len(ids)) + for i, id := range ids { + up, _ := strconv.ParseInt(raw[2*i], 10, 64) + down, _ := strconv.ParseInt(raw[2*i+1], 10, 64) + if up == 0 && down == 0 { + continue + } + keep = append(keep, id) + ups = append(ups, up) + downs = append(downs, down) + } + if len(keep) == 0 { + return 0, nil + } + + if err := a.q.BatchIncrementUserStats(ctx, queries.BatchIncrementUserStatsParams{ + Ids: keep, Ups: ups, Downs: downs, + }); err != nil { + a.restore(keep, ups, downs) + return 0, err + } + return len(keep), nil +} + +// restore réinjecte des deltas qu'on avait retirés mais pas réussi à écrire. +// +// Son propre contexte, détaché : quand cette fonction est appelée, celui du +// versement est souvent déjà expiré — c'est précisément ce qui a fait échouer +// l'écriture. Le réutiliser perdrait le crédit pour de bon. +func (a *Accumulator) restore(ids []string, ups, downs []int64) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + pipe := a.rdb.Pipeline() + for i, id := range ids { + if ups[i] != 0 { + pipe.HIncrBy(ctx, a.upKey, id, ups[i]) + } + if downs[i] != 0 { + pipe.HIncrBy(ctx, a.downKey, id, downs[i]) + } + } + if _, err := pipe.Exec(ctx); err != nil { + a.dropped.Add(uint64(len(ids))) + // Le cumul, et pas seulement l'incident : c'est la PENTE qui dit si la + // panne est un hoquet ou une hémorragie, et un incident isolé dans un + // journal ne la montre pas. + slog.Error("stats: crédits perdus — retirés de Redis, refusés par Postgres, non réinjectés", + "membres", len(ids), "perdus_cumulés", a.dropped.Load(), "err", err) + } +} diff --git a/apps/tracker/internal/stats/stats_test.go b/apps/tracker/internal/stats/stats_test.go new file mode 100644 index 00000000..460073db --- /dev/null +++ b/apps/tracker/internal/stats/stats_test.go @@ -0,0 +1,392 @@ +package stats + +import ( + "context" + "errors" + "sync" + "testing" + "time" + + "github.com/alicebob/miniredis/v2" + "github.com/redis/go-redis/v9" + + "github.com/florianjs/trackarr/apps/tracker/internal/queries" +) + +// recorder note ce que l'accumulateur a réellement demandé à Postgres. +// +// Il enregistre les APPELS et non seulement les totaux : la moitié des pannes +// que ces tests visent — le versement d'un bloc, la double écriture — ne se +// voient que dans le découpage des appels, pas dans la somme. +type recorder struct { + mu sync.Mutex + batch [][]queries.BatchIncrementUserStatsParams + single []queries.IncrementUserStatsParams + fail error +} + +func (r *recorder) IncrementUserStats(_ context.Context, p queries.IncrementUserStatsParams) error { + r.mu.Lock() + defer r.mu.Unlock() + if r.fail != nil { + return r.fail + } + r.single = append(r.single, p) + return nil +} + +func (r *recorder) BatchIncrementUserStats(_ context.Context, p queries.BatchIncrementUserStatsParams) error { + r.mu.Lock() + defer r.mu.Unlock() + if r.fail != nil { + return r.fail + } + r.batch = append(r.batch, []queries.BatchIncrementUserStatsParams{p}) + return nil +} + +// totals additionne ce qui a été porté au compte de chaque membre, tous appels +// confondus. +func (r *recorder) totals() map[string][2]int64 { + r.mu.Lock() + defer r.mu.Unlock() + out := map[string][2]int64{} + for _, call := range r.batch { + p := call[0] + for i, id := range p.Ids { + t := out[id] + t[0] += p.Ups[i] + t[1] += p.Downs[i] + out[id] = t + } + } + for _, p := range r.single { + t := out[p.ID] + t[0] += p.Uploaded + t[1] += p.Downloaded + out[p.ID] = t + } + return out +} + +func (r *recorder) callSizes() []int { + r.mu.Lock() + defer r.mu.Unlock() + out := make([]int, 0, len(r.batch)) + for _, call := range r.batch { + out = append(out, len(call[0].Ids)) + } + return out +} + +func (r *recorder) setFail(err error) { + r.mu.Lock() + defer r.mu.Unlock() + r.fail = err +} + +// newTestAcc monte un accumulateur qui regroupe, avec une fenêtre assez longue +// pour que sa boucle ne verse jamais d'elle-même : les tests versent +// explicitement, sans dépendre d'un minuteur. +// +// Une fenêtre à ZÉRO ne conviendrait pas — c'est une demande de désactivation, +// et l'accumulateur écrirait alors directement. +func newTestAcc(t *testing.T, chunk int) (*Accumulator, *recorder, *miniredis.Miniredis) { + t.Helper() + mr := miniredis.RunT(t) + client := redis.NewClient(&redis.Options{Addr: mr.Addr()}) + rec := &recorder{} + a := New(client, rec, "ot:", time.Hour, chunk) + t.Cleanup(a.Stop) + if !a.Batching() { + t.Fatal("l'accumulateur devrait regrouper") + } + return a, rec, mr +} + +// TestCoalescesRepeatedCredits est la raison d'être du paquet : un membre qui +// seede plusieurs torrents met à jour SA PROPRE ligne à chaque annonce, et +// c'est cette répétition que le regroupement doit supprimer. +// +// Le test échoue si `Add` écrit directement : on verrait quatre écritures +// séparées au lieu d'une, et `callSizes` ne vaudrait pas [2]. +func TestCoalescesRepeatedCredits(t *testing.T) { + ctx := context.Background() + a, rec, _ := newTestAcc(t, 10) + + for _, d := range []struct { + id string + up, down int64 + }{ + {"alice", 100, 10}, + {"alice", 200, 20}, + {"alice", 300, 30}, + {"bob", 7, 3}, + } { + if err := a.Add(ctx, d.id, d.up, d.down); err != nil { + t.Fatalf("Add: %v", err) + } + } + + if len(rec.callSizes()) != 0 { + t.Fatalf("rien ne devait partir vers Postgres avant le versement, vu %v", rec.callSizes()) + } + + n, err := a.Flush(ctx) + if err != nil { + t.Fatalf("Flush: %v", err) + } + if n != 2 { + t.Fatalf("deux membres crédités attendus, vu %d", n) + } + if got := rec.callSizes(); len(got) != 1 || got[0] != 2 { + t.Fatalf("un seul appel de deux membres attendu, vu %v", got) + } + want := map[string][2]int64{"alice": {600, 60}, "bob": {7, 3}} + for id, w := range want { + if got := rec.totals()[id]; got != w { + t.Fatalf("%s : %v attendu, vu %v", id, w, got) + } + } +} + +// TestFlushIsChunked verrouille la décision qui inverse tout le résultat. +// +// Un versement d'un seul bloc empêche l'élagage HOT de recycler la place en +// page et écrit PLUS de WAL que les écritures unitaires qu'il remplace. La +// mesure est dans l'en-tête du paquet ; ce test est ce qui empêche quelqu'un +// de « simplifier » le découpage sans la relire. +func TestFlushIsChunked(t *testing.T) { + ctx := context.Background() + a, rec, _ := newTestAcc(t, 10) + + const members = 25 + for i := range members { + id := string(rune('a'+i/26)) + string(rune('a'+i%26)) + if err := a.Add(ctx, id, int64(i+1), int64(i+1)); err != nil { + t.Fatalf("Add: %v", err) + } + } + if _, err := a.Flush(ctx); err != nil { + t.Fatalf("Flush: %v", err) + } + + sizes := rec.callSizes() + if len(sizes) != 3 { + t.Fatalf("25 membres par tranches de 10 = 3 appels, vu %d : %v", len(sizes), sizes) + } + for _, s := range sizes { + if s > 10 { + t.Fatalf("une tranche dépasse la taille demandée : %v", sizes) + } + } + if total := len(rec.totals()); total != members { + t.Fatalf("%d membres crédités attendus, vu %d", members, total) + } +} + +// TestPostgresFailureLosesNothing : l'ancien chemin perdait le crédit en +// silence dès qu'un `UPDATE` échouait — un `slog.Warn`, aucune reprise. +// Retirer une tranche de Redis avant de l'écrire ne doit pas reproduire ça. +// +// Le test échoue si `restore` disparaît : le second versement ne verrait plus +// rien à écrire et les totaux seraient vides. +func TestPostgresFailureLosesNothing(t *testing.T) { + ctx := context.Background() + a, rec, _ := newTestAcc(t, 10) + + if err := a.Add(ctx, "alice", 1000, 100); err != nil { + t.Fatalf("Add: %v", err) + } + if err := a.Add(ctx, "bob", 2000, 200); err != nil { + t.Fatalf("Add: %v", err) + } + + rec.setFail(errors.New("postgres est parti")) + if _, err := a.Flush(ctx); err == nil { + t.Fatal("le versement devait signaler l'échec de Postgres") + } + if len(rec.totals()) != 0 { + t.Fatalf("rien ne devait être porté au compte, vu %v", rec.totals()) + } + if a.Dropped() != 0 { + t.Fatalf("les deltas étaient réinjectables, aucun ne devait être compté perdu (vu %d)", a.Dropped()) + } + + // Postgres revient : le versement suivant doit porter les MÊMES totaux, + // une seule fois. + rec.setFail(nil) + if _, err := a.Flush(ctx); err != nil { + t.Fatalf("second Flush: %v", err) + } + want := map[string][2]int64{"alice": {1000, 100}, "bob": {2000, 200}} + for id, w := range want { + if got := rec.totals()[id]; got != w { + t.Fatalf("%s : %v attendu après reprise, vu %v", id, w, got) + } + } +} + +// TestConcurrentFlushNeverDoubleCredits. +// +// Deux instances derrière un répartiteur versent depuis le MÊME accumulateur. +// Si le retrait n'était pas atomique — un HGETALL suivi d'un HDEL après +// l'écriture, par exemple — les deux liraient la même valeur et la porteraient +// chacune au compte : le membre serait crédité deux fois. +// +// Le test attaque `flushChunk` directement plutôt que `Flush`, parce que le +// verrou de versement ferait sortir le second appel avant même d'atteindre le +// retrait — et masquerait exactement ce qu'on veut éprouver. +func TestConcurrentFlushNeverDoubleCredits(t *testing.T) { + ctx := context.Background() + a, rec, _ := newTestAcc(t, 10) + + const members = 40 + ids := make([]string, 0, members) + for i := range members { + id := string(rune('a'+i/26)) + string(rune('a'+i%26)) + ids = append(ids, id) + if err := a.Add(ctx, id, 1000, 100); err != nil { + t.Fatalf("Add: %v", err) + } + } + + var wg sync.WaitGroup + for range 4 { + wg.Add(1) + go func() { + defer wg.Done() + for start := 0; start < len(ids); start += 10 { + _, _ = a.flushChunk(ctx, ids[start:min(start+10, len(ids))]) + } + }() + } + wg.Wait() + + totals := rec.totals() + if len(totals) != members { + t.Fatalf("%d membres attendus, vu %d", members, len(totals)) + } + for id, got := range totals { + if got != [2]int64{1000, 100} { + t.Fatalf("%s crédité %v — le delta a été porté plus d'une fois", id, got) + } + } +} + +// TestStopFlushesWhatIsPending : sans ce dernier versement, chaque +// redéploiement perdrait une fenêtre entière de crédits, pour tous les membres +// à la fois — silencieusement. +func TestStopFlushesWhatIsPending(t *testing.T) { + ctx := context.Background() + mr := miniredis.RunT(t) + client := redis.NewClient(&redis.Options{Addr: mr.Addr()}) + rec := &recorder{} + // Une fenêtre longue : seul l'arrêt peut verser dans le temps du test. + a := New(client, rec, "ot:", time.Hour, 10) + + if err := a.Add(ctx, "alice", 4242, 42); err != nil { + t.Fatalf("Add: %v", err) + } + a.Stop() + + if got := rec.totals()["alice"]; got != [2]int64{4242, 42} { + t.Fatalf("l'arrêt devait verser le delta en attente, vu %v", got) + } +} + +// TestWithoutRedisWritesThrough : sans Redis, l'accumulateur doit se comporter +// exactement comme le chemin d'origine. C'est ce qui rend son introduction +// transparente pour les tests du serveur, qui n'en ont pas. +func TestWithoutRedisWritesThrough(t *testing.T) { + ctx := context.Background() + rec := &recorder{} + a := New(nil, rec, "ot:", time.Hour, 10) + + if err := a.Add(ctx, "alice", 10, 1); err != nil { + t.Fatalf("Add: %v", err) + } + if err := a.Add(ctx, "alice", 20, 2); err != nil { + t.Fatalf("Add: %v", err) + } + if len(rec.single) != 2 { + t.Fatalf("deux écritures directes attendues, vu %d", len(rec.single)) + } + if got := rec.totals()["alice"]; got != [2]int64{30, 3} { + t.Fatalf("totaux %v", got) + } + // Ni panique ni blocage sur un accumulateur sans boucle. + a.Stop() +} + +// TestDisabledIntervalWritesThrough : `TRACKER_STATS_FLUSH_INTERVAL=0` doit +// rendre au chemin son écriture par annonce, AVEC un Redis présent. +// +// Ce test existe parce que sa panne est passée à travers tout le reste. La +// première version ne regardait que `rdb == nil` : avec Redis branché et une +// fenêtre à zéro, `Add` accumulait toujours, aucune boucle ne versait, et les +// deltas s'empilaient indéfiniment dans Redis. La porte de sortie n'ouvrait +// sur rien. `TestWithoutRedisWritesThrough` ne pouvait pas le voir — il coupe +// Redis, pas la fenêtre — et seule la pile compilée l'a montré. +func TestDisabledIntervalWritesThrough(t *testing.T) { + ctx := context.Background() + mr := miniredis.RunT(t) + client := redis.NewClient(&redis.Options{Addr: mr.Addr()}) + rec := &recorder{} + a := New(client, rec, "ot:", 0, 10) + + if a.Batching() { + t.Fatal("une fenêtre nulle doit désactiver le regroupement") + } + if err := a.Add(ctx, "alice", 100, 10); err != nil { + t.Fatalf("Add: %v", err) + } + if len(rec.single) != 1 { + t.Fatalf("écriture directe attendue, vu %d écriture(s) unitaire(s)", len(rec.single)) + } + if mr.Exists("ot:trk:stats:up") { + t.Fatal("rien ne doit s'accumuler dans Redis quand le regroupement est coupé") + } +} + +// TestZeroDeltaIsNotWritten : le chemin d'annonce n'appelle `Add` que sur un +// delta non nul, mais un compteur à zéro laissé dans Redis ferait écrire une +// ligne pour ne rien y ajouter, à chaque versement, indéfiniment. +func TestZeroDeltaIsNotWritten(t *testing.T) { + ctx := context.Background() + a, rec, _ := newTestAcc(t, 10) + + if err := a.Add(ctx, "alice", 0, 0); err != nil { + t.Fatalf("Add: %v", err) + } + if _, err := a.Flush(ctx); err != nil { + t.Fatalf("Flush: %v", err) + } + if len(rec.callSizes()) != 0 || len(rec.single) != 0 { + t.Fatalf("aucune écriture attendue, vu %v / %d", rec.callSizes(), len(rec.single)) + } +} + +// TestUploadOnlyCreditIsFlushed : un seedeur pur n'a qu'un delta d'upload, donc +// son identifiant n'existe que dans UN des deux compteurs. Ne balayer qu'un +// seul d'entre eux laisserait un membre sur deux sans crédit. +func TestUploadOnlyCreditIsFlushed(t *testing.T) { + ctx := context.Background() + a, rec, _ := newTestAcc(t, 10) + + if err := a.Add(ctx, "seeder", 5000, 0); err != nil { + t.Fatalf("Add: %v", err) + } + if err := a.Add(ctx, "leecher", 0, 9000); err != nil { + t.Fatalf("Add: %v", err) + } + if _, err := a.Flush(ctx); err != nil { + t.Fatalf("Flush: %v", err) + } + if got := rec.totals()["seeder"]; got != [2]int64{5000, 0} { + t.Fatalf("seedeur : %v", got) + } + if got := rec.totals()["leecher"]; got != [2]int64{0, 9000} { + t.Fatalf("leecheur : %v", got) + } +} diff --git a/apps/tracker/internal/udp/drain_test.go b/apps/tracker/internal/udp/drain_test.go new file mode 100644 index 00000000..46d3c12a --- /dev/null +++ b/apps/tracker/internal/udp/drain_test.go @@ -0,0 +1,154 @@ +package udp + +import ( + "context" + "io" + "log/slog" + "net" + "sync" + "testing" + "time" +) + +// newDrainServer monte un serveur UDP réduit au strict nécessaire pour +// éprouver `Close` : une vraie socket, le sémaphore d'admission, et un +// traitement que le test contrôle. +// +// Pas de `New()` : celui-ci exige un `*server.Server` (Postgres) et un +// `*peers.Store` (Redis), dont le drain n'a rien à faire. C'est la raison +// d'être du champ `handle`. +func newDrainServer(t *testing.T, handle func(context.Context, *[]byte, int, *net.UDPAddr)) *Server { + t.Helper() + addr, err := net.ResolveUDPAddr("udp", "127.0.0.1:0") + if err != nil { + t.Fatalf("resolve: %v", err) + } + conn, err := net.ListenUDP("udp", addr) + if err != nil { + t.Fatalf("listen: %v", err) + } + s := &Server{ + conn: conn, + addr: conn.LocalAddr().(*net.UDPAddr), + logger: slog.New(slog.NewTextHandler(io.Discard, nil)), + workerSem: make(chan struct{}, 16), + handle: handle, + } + s.bufPool = sync.Pool{New: func() any { b := make([]byte, maxUDPPacket); return &b }} + return s +} + +func send(t *testing.T, to *net.UDPAddr, payload []byte) { + t.Helper() + c, err := net.DialUDP("udp", nil, to) + if err != nil { + t.Fatalf("dial: %v", err) + } + defer c.Close() + if _, err := c.Write(payload); err != nil { + t.Fatalf("write: %v", err) + } +} + +// TestCloseWaitsForInFlightDatagrams. +// +// `main.go` affirmait « UDP has no in-flight connections to drain » : vrai du +// protocole, faux de l'implémentation. Chaque datagramme part dans sa propre +// goroutine, et `Close` ne fermait que la socket — le processus sortait en +// abandonnant les écritures Postgres en cours. Une annonce en vol perdait son +// crédit, silencieusement, à chaque redémarrage. +// +// Le test bloque le traitement, appelle `Close`, et vérifie qu'il n'a PAS +// rendu la main tant que le traitement n'est pas fini. +func TestCloseWaitsForInFlightDatagrams(t *testing.T) { + started := make(chan struct{}) + release := make(chan struct{}) + finished := make(chan struct{}) + + // Déclaré avant l'affectation : la fermeture capture la VARIABLE, qui est + // renseignée avant que le premier datagramme n'arrive. + var s *Server + s = newDrainServer(t, func(_ context.Context, bufp *[]byte, _ int, _ *net.UDPAddr) { + defer s.bufPool.Put(bufp) + close(started) + <-release + close(finished) + }) + + go func() { _ = s.Serve(context.Background()) }() + send(t, s.Addr(), []byte("un datagramme quelconque")) + + select { + case <-started: + case <-time.After(3 * time.Second): + t.Fatal("le traitement n'a jamais démarré — le test ne prouverait rien") + } + + closed := make(chan struct{}) + go func() { _ = s.Close(); close(closed) }() + + // `Close` doit être RETENU par le traitement en cours. + select { + case <-closed: + t.Fatal("Close a rendu la main alors qu'un datagramme était encore en traitement") + case <-time.After(300 * time.Millisecond): + } + + close(release) + select { + case <-finished: + case <-time.After(3 * time.Second): + t.Fatal("le traitement ne s'est jamais terminé") + } + select { + case <-closed: + case <-time.After(3 * time.Second): + t.Fatal("Close ne rend pas la main après la fin du traitement") + } +} + +// TestCloseReturnsPromptlyWhenIdle est le contrôle négatif. +// +// Sans lui, le test précédent serait satisfait par un `Close` qui attend +// toujours cinq secondes — le comportement serait « lent » plutôt que +// « correct », et personne ne le verrait. +func TestCloseReturnsPromptlyWhenIdle(t *testing.T) { + s := newDrainServer(t, func(_ context.Context, bufp *[]byte, _ int, _ *net.UDPAddr) {}) + go func() { _ = s.Serve(context.Background()) }() + // Laisse la boucle de lecture s'installer. + time.Sleep(50 * time.Millisecond) + + start := time.Now() + if err := s.Close(); err != nil { + t.Fatalf("Close: %v", err) + } + if elapsed := time.Since(start); elapsed > time.Second { + t.Fatalf("Close a mis %v sans rien à attendre — il ne devrait pas patienter", elapsed) + } +} + +// TestCloseGivesUpAfterTheDrainTimeout : une base bloquée ne doit pas +// suspendre l'arrêt indéfiniment. Le plafond partage le délai de grâce du +// conteneur avec le drain HTTP, le versement des crédits et les tâches de +// fond ; le dépasser ferait couper le processus au SIGKILL, ce qui est pire. +func TestCloseGivesUpAfterTheDrainTimeout(t *testing.T) { + if udpDrainTimeout > 10*time.Second { + t.Fatalf("udpDrainTimeout = %v : trop long pour le budget d'arrêt documenté", udpDrainTimeout) + } + s := newDrainServer(t, nil) + // Une goroutine fantôme qui ne finit jamais : exactement ce que le + // plafond existe pour abandonner. + s.inflight.Add(1) + start := time.Now() + if err := s.Close(); err != nil { + t.Fatalf("Close: %v", err) + } + elapsed := time.Since(start) + if elapsed < udpDrainTimeout { + t.Fatalf("Close a rendu la main en %v, avant son propre plafond de %v", elapsed, udpDrainTimeout) + } + if elapsed > udpDrainTimeout+2*time.Second { + t.Fatalf("Close a mis %v, bien au-delà de son plafond de %v", elapsed, udpDrainTimeout) + } + s.inflight.Done() +} diff --git a/apps/tracker/internal/udp/parse.go b/apps/tracker/internal/udp/parse.go index ef2189bb..73b04a60 100644 --- a/apps/tracker/internal/udp/parse.go +++ b/apps/tracker/internal/udp/parse.go @@ -68,11 +68,11 @@ const maxURLDataBytes = 512 // errors are short ASCII strings and we keep them tracker-vague — but // the constants make the code testable. var ( - errPacketTooShort = errors.New("packet too short") - errBadMagic = errors.New("invalid protocol_id") - errInvalidConnectionID = errors.New("invalid connection_id") - errMissingPasskey = errors.New("missing passkey") - errMalformedOptions = errors.New("malformed options") + errPacketTooShort = errors.New("packet too short") + errBadMagic = errors.New("invalid protocol_id") + errMissingPasskey = errors.New("missing passkey") + errInvalidPort = errors.New("invalid port") + errMalformedOptions = errors.New("malformed options") ) // ConnectRequest is the parsed step-1 packet. The transaction_id has @@ -240,6 +240,12 @@ func (r *AnnounceRequestUDP) ToAnnounceRequest() (*announce.Request, error) { if passkey == "" { return nil, errMissingPasskey } + // La même règle que l'analyseur HTTP, qui l'appliquait seul : ce chemin + // recopiait `r.Port` tel quel, donc un port à 0 ou privilégié entrait dans + // l'essaim par UDP. + if !announce.ValidPeerPort(r.Port) { + return nil, errInvalidPort + } out := &announce.Request{ InfoHash: r.InfoHash, PeerID: r.PeerID, diff --git a/apps/tracker/internal/udp/parse_test.go b/apps/tracker/internal/udp/parse_test.go index e7a1e57b..fbf92a40 100644 --- a/apps/tracker/internal/udp/parse_test.go +++ b/apps/tracker/internal/udp/parse_test.go @@ -111,7 +111,7 @@ func TestParseAnnounceURLDataPath(t *testing.T) { opts = append(opts, optEnd) var ih, pid [20]byte - pkt := buildAnnounce(1, 1, ih, pid, 1, opts) + pkt := buildAnnounce(1, 1, ih, pid, 6881, opts) req, err := ParseAnnounce(pkt) if err != nil { t.Fatalf("parse error: %v", err) @@ -134,7 +134,7 @@ func TestParseAnnounceURLDataQuery(t *testing.T) { opts = append(opts, optEnd) var ih, pid [20]byte - pkt := buildAnnounce(1, 1, ih, pid, 1, opts) + pkt := buildAnnounce(1, 1, ih, pid, 6881, opts) req, err := ParseAnnounce(pkt) if err != nil { t.Fatalf("parse error: %v", err) @@ -153,7 +153,7 @@ func TestParseAnnounceMissingPasskeyFails(t *testing.T) { // No URL data trailer at all — same as a client that announces // against `udp://host:6969/announce` (no passkey path/query). var ih, pid [20]byte - pkt := buildAnnounce(1, 1, ih, pid, 1, nil) + pkt := buildAnnounce(1, 1, ih, pid, 6881, nil) req, err := ParseAnnounce(pkt) if err != nil { t.Fatalf("parse error: %v", err) @@ -174,7 +174,7 @@ func TestParseAnnounceRejectsAnnounceTokenAsPasskey(t *testing.T) { opts = append(opts, optEnd) var ih, pid [20]byte - pkt := buildAnnounce(1, 1, ih, pid, 1, opts) + pkt := buildAnnounce(1, 1, ih, pid, 6881, opts) req, err := ParseAnnounce(pkt) if err != nil { t.Fatalf("parse error: %v", err) @@ -197,7 +197,7 @@ func TestParseAnnounceMultipleURLDataOptions(t *testing.T) { opts = append(opts, optEnd) var ih, pid [20]byte - pkt := buildAnnounce(1, 1, ih, pid, 1, opts) + pkt := buildAnnounce(1, 1, ih, pid, 6881, opts) req, err := ParseAnnounce(pkt) if err != nil { t.Fatalf("parse error: %v", err) @@ -220,7 +220,7 @@ func TestParseAnnounceNopOptionIgnored(t *testing.T) { opts = append(opts, optEnd) var ih, pid [20]byte - pkt := buildAnnounce(1, 1, ih, pid, 1, opts) + pkt := buildAnnounce(1, 1, ih, pid, 6881, opts) req, err := ParseAnnounce(pkt) if err != nil { t.Fatalf("parse error: %v", err) @@ -257,7 +257,7 @@ func TestParseOptions_URLDataCap(t *testing.T) { opts = append(opts, optEnd) var ih, pid [20]byte - pkt := buildAnnounce(1, 1, ih, pid, 1, opts) + pkt := buildAnnounce(1, 1, ih, pid, 6881, opts) if _, err := ParseAnnounce(pkt); err == nil { t.Fatal("expected error for oversized URL_DATA chain, got nil") } @@ -282,7 +282,7 @@ func TestParseOptions_URLDataJustUnderCap(t *testing.T) { opts = append(opts, optEnd) var ih, pid [20]byte - pkt := buildAnnounce(1, 1, ih, pid, 1, opts) + pkt := buildAnnounce(1, 1, ih, pid, 6881, opts) if _, err := ParseAnnounce(pkt); err != nil { t.Fatalf("expected accept just under cap, got %v", err) } @@ -299,7 +299,7 @@ func TestParseAnnounceNonNegBytesClamped(t *testing.T) { binary.BigEndian.PutUint32(pkt[12:16], 1) // uploaded is read out of bytes [72:80]; set the high bit. binary.BigEndian.PutUint64(pkt[72:80], 0x8000000000000000) - binary.BigEndian.PutUint16(pkt[96:98], 1) + binary.BigEndian.PutUint16(pkt[96:98], 6881) pkt = append(pkt, optURLData, 11) pkt = append(pkt, []byte("/announce/x")...) pkt = append(pkt, optEnd) @@ -423,7 +423,7 @@ func TestMapEvent_AllTokens(t *testing.T) { {udpEventStopped, "stopped"}, } for _, c := range cases { - req := AnnounceRequestUDP{Event: c.in, URLData: []byte("/announce/x")} + req := AnnounceRequestUDP{Event: c.in, Port: 6881, URLData: []byte("/announce/x")} apiReq, err := req.ToAnnounceRequest() if err != nil { t.Fatalf("event=%d: ToAnnounceRequest error: %v", c.in, err) @@ -525,3 +525,44 @@ func TestMapEvent(t *testing.T) { } } } + +// Le port, aux deux transports, avec la même règle. +// +// `ToAnnounceRequest` recopiait `r.Port` sans contrôle, alors que l'analyseur +// HTTP refusait déjà zéro : la règle ne tenait donc que sur la moitié des +// annonces. Un pair à `IP:0` n'est joignable par personne et est pourtant +// distribué à tout l'essaim ; un pair sur un port privilégié fait frapper +// l'essaim entier sur `:22` ou `:25` — partagé, derrière un CGNAT. +func TestToAnnounceRequestRejectsBadPorts(t *testing.T) { + t.Parallel() + passkey := "abcdef0123456789abcdef0123456789" + urlData := []byte("/announce/" + passkey) + opts := append([]byte{optURLData, byte(len(urlData))}, urlData...) + opts = append(opts, optEnd) + + var ih, pid [20]byte + for _, port := range []uint16{0, 22, 25, 80, 443, 1023} { + pkt := buildAnnounce(1, 1, ih, pid, port, opts) + req, err := ParseAnnounce(pkt) + if err != nil { + t.Fatalf("port %d: parse error: %v", port, err) + } + if _, err := req.ToAnnounceRequest(); err == nil { + t.Fatalf("port %d should be refused", port) + } + } + for _, port := range []uint16{1024, 6881, 51413, 65535} { + pkt := buildAnnounce(1, 1, ih, pid, port, opts) + req, err := ParseAnnounce(pkt) + if err != nil { + t.Fatalf("port %d: parse error: %v", port, err) + } + out, err := req.ToAnnounceRequest() + if err != nil { + t.Fatalf("port %d should be accepted: %v", port, err) + } + if out.Port != port { + t.Fatalf("port %d: got %d", port, out.Port) + } + } +} diff --git a/apps/tracker/internal/udp/server.go b/apps/tracker/internal/udp/server.go index 944033fd..f6940406 100644 --- a/apps/tracker/internal/udp/server.go +++ b/apps/tracker/internal/udp/server.go @@ -7,7 +7,7 @@ import ( "log/slog" "net" "runtime" - "strconv" + "runtime/debug" "sync" "sync/atomic" "time" @@ -68,6 +68,54 @@ type Server struct { // liner every ~10 k drops so an operator notices a sustained // flood even without metrics. droppedFlood atomic.Uint64 + // Les trois compteurs ci-dessous existent pour la même raison que + // `droppedFlood` : une ligne de journal par datagramme, sur une entrée non + // authentifiée ET à source usurpable, est un primitif d'épuisement de + // disque. Mesuré : 123 octets de journal pour 16 octets de paquet, soit une + // amplification de 7,7× vers le disque à débit ligne. `handlePacket` + // s'interdit déjà de journaliser une action inconnue pour cette raison + // exacte ; ces trois chemins-là l'avaient oublié. On compte, et on parle + // toutes les ~10 000. + droppedParse atomic.Uint64 + droppedConnID atomic.Uint64 + droppedPasskey atomic.Uint64 + droppedReject atomic.Uint64 + + // inflight compte les goroutines de traitement en vol, pour que `Close` + // les attende. + // + // Sans lui, `Close` fermait la socket, la boucle de lecture sortait, et + // jusqu'à `cap(workerSem)` goroutines restaient en train d'écrire dans + // Postgres — que le processus abandonnait en sortant. Le commentaire de + // `main.go` affirmait « UDP has no in-flight connections to drain », ce qui + // est vrai du protocole et faux de cette implémentation : chaque datagramme + // a sa goroutine, et un `announce` en cours d'écriture est exactement ce + // que le drain HTTP protège de son côté depuis toujours. + // + // Le coût de l'oubli était borné — quelques annonces qui perdent leur + // crédit à chaque redémarrage, du même ordre que ce que le tracker + // abandonne déjà quand la référence Redis d'un pair a expiré — mais c'était + // une perte silencieuse, et gratuite à éviter. + inflight sync.WaitGroup + + // handle est le traitement d'un datagramme, tenu dans un champ et non + // appelé directement. + // + // C'est une couture de test, et elle est là parce que la garantie qu'on + // veut éprouver — « `Close` attend les traitements en vol » — ne s'observe + // qu'avec un traitement qui BLOQUE. `handlePacket` a besoin d'un Postgres + // et d'un Redis ; un faux traitement n'a besoin de rien. Sans ce champ, le + // drain serait du code non testé. + handle func(ctx context.Context, bufp *[]byte, n int, raddr *net.UDPAddr) + + // Le scrape UDP peut être fermé sans fermer l'annonce. + // + // Le scrape HTTP exige une passkey ; celui-ci ne le peut pas, BEP 15 ne + // prévoyant aucun emplacement pour une donnée d'authentification dans une + // requête de scrape. L'exploitant d'un tracker privé qui ne veut pas + // publier la taille de ses essaims ferme donc l'accès plutôt que + // l'authentifier. Voir `TRACKER_UDP_SCRAPE_ENABLED`. + scrapeEnabled bool } // New builds a UDP server bound to `addr` and ready to dispatch @@ -76,7 +124,9 @@ type Server struct { // Binding happens here (rather than in Start) so the caller can fail // fast on a port conflict at boot — same pattern as `http.Server.Listen` // happens inside `ListenAndServe`, but exposed earlier. -func New(addr string, secret string, proc *server.Server, store *peers.Store) (*Server, error) { +// New construit le serveur UDP. `scrapeEnabled` vient de +// `TRACKER_UDP_SCRAPE_ENABLED` : voir le champ du même nom. +func New(addr string, secret string, proc *server.Server, store *peers.Store, scrapeEnabled bool) (*Server, error) { udpAddr, err := net.ResolveUDPAddr("udp", addr) if err != nil { return nil, err @@ -90,13 +140,14 @@ func New(addr string, secret string, proc *server.Server, store *peers.Store) (* cpus = 1 } s := &Server{ - conn: conn, - connID: NewConnIDIssuer(secret), - addr: udpAddr, - proc: proc, - store: store, - logger: slog.Default(), - workerSem: make(chan struct{}, cpus*workerSlotsPerCPU), + scrapeEnabled: scrapeEnabled, + conn: conn, + connID: NewConnIDIssuer(secret), + addr: udpAddr, + proc: proc, + store: store, + logger: slog.Default(), + workerSem: make(chan struct{}, cpus*workerSlotsPerCPU), } s.bufPool = sync.Pool{ New: func() any { @@ -104,6 +155,7 @@ func New(addr string, secret string, proc *server.Server, store *peers.Store) (* return &b }, } + s.handle = s.handlePacket return s, nil } @@ -117,8 +169,11 @@ func (s *Server) Addr() *net.UDPAddr { return s.addr } // blocks on Postgres/Redis so a slow announce can't stall the listener. // // Read deadlines are set per-loop (1 s) so a Close() call from -// Shutdown() makes the loop wake within ~1 s and exit cleanly. UDP -// has no connection state to drain — closing the socket is enough. +// Shutdown() makes the loop wake within ~1 s and exit cleanly. +// +// Fermer la socket suffit à arrêter la BOUCLE, mais pas à terminer le +// travail : les goroutines déjà parties continuent d'écrire. `Close` les +// attend ; voir `inflight`. func (s *Server) Serve(ctx context.Context) error { for { // Wake the loop periodically so ctx cancellation is visible @@ -153,9 +208,29 @@ func (s *Server) Serve(ctx context.Context) error { // the pool on drop so memory pressure stays flat. select { case s.workerSem <- struct{}{}: + // Enregistré AVANT le `go` : le faire à l'intérieur laisserait une + // fenêtre où `Close` croirait n'avoir rien à attendre. + s.inflight.Add(1) go func(bufp *[]byte, n int, raddr *net.UDPAddr) { + defer s.inflight.Done() defer func() { <-s.workerSem }() - s.handlePacket(ctx, bufp, n, raddr) + // La boucle de lecture EST le processus : un panic ici n'est pas + // un datagramme perdu, c'est le tracker qui s'arrête. `net/http` + // récupère par connexion et les trois goroutines d'arrière-plan + // de `server/handler.go` ont chacune leur `recover` — c'était le + // seul chemin de requête sans filet, et c'est la porte d'entrée + // la plus exposée du projet. + defer func() { + if r := recover(); r != nil { + s.logger.Error("udp handler panic", + "remote", raddr.String(), + "size", n, + "panic", r, + "stack", string(debug.Stack()), + ) + } + }() + s.handle(ctx, bufp, n, raddr) }(bufp, n, raddr) default: s.bufPool.Put(bufp) @@ -169,8 +244,41 @@ func (s *Server) Serve(ctx context.Context) error { } } -// Close stops the server. The `Serve` loop returns nil shortly after. -func (s *Server) Close() error { return s.conn.Close() } +// udpDrainTimeout borne l'attente des traitements en vol à la fermeture. +// +// Cinq secondes parce que le contexte d'application n'est PAS encore annulé +// quand `main` appelle `Close` : les écritures en cours vont jusqu'au bout +// normalement, et elles portent déjà leurs propres délais côté pgx. Ce +// plafond ne sert qu'à ne pas suspendre un arrêt derrière une base bloquée. +// +// Le budget d'arrêt du tracker, à ne pas dépasser dans le délai de grâce du +// conteneur : 10 s pour le drain HTTP, puis 5 s ici, puis 10 s pour le +// versement final des crédits d'octets, puis 8 s pour les tâches de fond — +// 33 s au pire. D'où les 45 s accordées en compose comme dans le chart. +const udpDrainTimeout = 5 * time.Second + +// Close stops the server: closes the socket, then waits for the handler +// goroutines already in flight. +// +// La socket d'abord, l'attente ensuite. L'inverse ne terminerait jamais — +// c'est la fermeture qui fait sortir la boucle de lecture, donc qui arrête +// d'alimenter le compteur qu'on attend. +func (s *Server) Close() error { + err := s.conn.Close() + + done := make(chan struct{}) + go func() { + s.inflight.Wait() + close(done) + }() + select { + case <-done: + case <-time.After(udpDrainTimeout): + s.logger.Warn("udp close: drain timed out — abandoning in-flight datagrams", + "timeout", udpDrainTimeout) + } + return err +} // handlePacket routes a single datagram by its action code. Anything // shorter than the fixed connect header is dropped silently — UDP is @@ -195,6 +303,13 @@ func (s *Server) handlePacket(ctx context.Context, bufp *[]byte, n int, raddr *n case action == ActionAnnounce: s.handleAnnounce(ctx, data, raddr) case action == ActionScrape: + if !s.scrapeEnabled { + // Silence plutôt qu'une erreur : répondre à une source non + // vérifiée, c'est le réflecteur que `handleConnect` refuse déjà + // d'être. BEP 15 prévoit qu'un client sans réponse refasse son + // `connect`. + return + } s.handleScrape(ctx, data, raddr) default: // Drop unknown action. We could send action=3 with "Bad @@ -241,18 +356,22 @@ func (s *Server) handleAnnounce(ctx context.Context, data []byte, raddr *net.UDP // — drop silently rather than reflect a forged source. Still // log so the operator can see if a probe / scanner is // hammering the port. - s.logger.Warn("udp announce parse failed", - "remote", raddr.String(), - "size", len(data), - "err", err, - ) + if n := s.droppedParse.Add(1); n%10_000 == 1 { + s.logger.Warn("udp announce parse failures", + "total", n, + "last_size", len(data), + "last_err", err, + ) + } return } if !s.connID.Validate(raddr.IP, req.ConnectionID) { - s.logger.Info("udp connection_id rejected", - "remote", raddr.String(), - "reason", "missing or expired (re-handshake needed)", - ) + if n := s.droppedConnID.Add(1); n%10_000 == 1 { + s.logger.Info("udp connection_id rejections", + "total", n, + "reason", "missing or expired (re-handshake needed)", + ) + } _, _ = s.conn.WriteToUDP( EncodeError(nil, req.TransactionID, "Connection ID expired"), raddr, @@ -270,11 +389,13 @@ func (s *Server) handleAnnounce(ctx context.Context, data []byte, raddr *net.UDP if len(urlData) > 200 { urlData = urlData[:200] + "…" } - s.logger.Info("udp announce missing passkey", - "remote", raddr.String(), - "url_data", urlData, - "hint", "client must use udp://host:port/announce/PASSKEY or ?passkey=PASSKEY", - ) + if n := s.droppedPasskey.Add(1); n%10_000 == 1 { + s.logger.Info("udp announces without a passkey", + "total", n, + "last_url_data", urlData, + "hint", "client must use udp://host:port/announce/PASSKEY or ?passkey=PASSKEY", + ) + } _, _ = s.conn.WriteToUDP( EncodeError(nil, req.TransactionID, "Passkey required"), raddr, @@ -289,14 +410,24 @@ func (s *Server) handleAnnounce(ctx context.Context, data []byte, raddr *net.UDP // unknown). out := s.proc.ProcessAnnounce(ctx, apiReq, clientIP, "") if out.Failure != "" { - // Failure carries the user-facing reason ("Invalid passkey", - // "Low ratio…", "Torrent not found or inactive"). Surface it - // alongside the remote so the operator can correlate with a - // specific peer. - s.logger.Info("udp announce rejected", - "remote", raddr.String(), - "reason", out.Failure, - ) + // Échantillonné, et sans adresse en clair. + // + // Une ligne par datagramme rejeté : les trois compteurs plus haut + // n'écrivent qu'une fois sur dix mille précisément parce qu'« une ligne + // de journal par datagramme est un primitif d'épuisement de disque » — + // ce chemin-ci l'avait oublié. Une passkey invalide ou un ratio bas se + // répète à chaque annonce du client, sans limite. + // + // Et `raddr.String()` écrivait l'adresse en clair, là où le chemin HTTP + // n'écrit rien en cas d'échec et hache l'adresse quand il journalise. + // Le motif suffit à l'exploitant ; l'adresse ne lui apprend rien qu'il + // ne puisse retrouver autrement. + if n := s.droppedReject.Add(1); n%10_000 == 1 { + s.logger.Info("udp announces rejected", + "count", n, + "reason", out.Failure, + ) + } _, _ = s.conn.WriteToUDP( EncodeError(nil, req.TransactionID, out.Failure), raddr, @@ -349,10 +480,18 @@ func (s *Server) handleScrape(ctx context.Context, data []byte, raddr *net.UDPAd connID := binary.BigEndian.Uint64(data[0:8]) txID := binary.BigEndian.Uint32(data[12:16]) if !s.connID.Validate(raddr.IP, connID) { - _, _ = s.conn.WriteToUDP( - EncodeError(nil, txID, "Connection ID expired"), - raddr, - ) + // Silence, pas d'erreur renvoyée. + // + // Seize octets entrants pour vingt-neuf sortants vers une source qui + // n'a PAS été vérifiée : c'est un réflecteur de facteur 1,81. Le même + // raisonnement est déjà écrit dans `handlePacket`, qui refuse de + // répondre à une action inconnue « pour ne pas transformer le tracker en + // petit réflecteur » — ce chemin-ci l'avait oublié. BEP 15 prévoit le + // silence : un client qui n'obtient pas de réponse refait son + // handshake `connect`, ce qui est exactement le comportement voulu. + if n := s.droppedConnID.Add(1); n%10_000 == 1 { + s.logger.Info("udp scrape connection_id rejections", "total", n) + } return } @@ -365,11 +504,14 @@ func (s *Server) handleScrape(ctx context.Context, data []byte, raddr *net.UDPAd count = maxScrapeHashes } stats := make([]ScrapeStat, count) + // Delegated to the shared processor so a v2 hash resolves here exactly as + // it does over HTTP — the framing differs between the two transports, the + // answer must not. One budget for the whole packet. + resolveBudget := server.MaxScrapeResolves for i := 0; i < count; i++ { ih := hashes[i*infoHashSize : (i+1)*infoHashSize] hex := hexBytes(ih) - seeders, leechers, _ := s.store.Counts(ctx, hex) - completed, _ := s.store.CompletedCount(ctx, hex) + seeders, leechers, completed := s.proc.ScrapeStats(ctx, hex, &resolveBudget) stats[i] = ScrapeStat{ Seeders: seeders, Completed: completed, @@ -406,10 +548,6 @@ func hexBytes(b []byte) string { return string(out) } -// portStr is a small helper used by tests to materialise the bound -// address. Not used in the hot path. -func portStr(p int) string { return ":" + strconv.Itoa(p) } - // _ asserts the announce package is compiled in even if the package // imports get reorganised — we rely on `announce.InfoHashLen` and // related constants. diff --git a/apps/web/Dockerfile.static b/apps/web/Dockerfile.static index 6fba2b9a..45ec1d90 100644 --- a/apps/web/Dockerfile.static +++ b/apps/web/Dockerfile.static @@ -85,11 +85,22 @@ USER 65532 EXPOSE 3000 STOPSIGNAL SIGQUIT -# Chainguard nginx images include a built-in HEALTHCHECK target on -# /, but we override to hit /healthz so we don't pull index.html -# every 30s — saves a tiny amount of CPU on busy hosts. -HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \ - CMD ["/usr/sbin/nginx", "-t"] +# Pas de HEALTHCHECK ici, et c'est délibéré. +# +# Il y en avait un : `CMD ["/usr/sbin/nginx", "-t"]`. Or `-t` valide le FICHIER +# DE CONFIGURATION — un worker bloqué, un listener fermé, un disque plein +# passent tous au vert. Un feu vert qui ne mesure rien est pire que pas de feu +# du tout : il transforme une panne en « sain » sur le tableau de bord. +# +# Le commentaire affirmait par ailleurs « we override to hit /healthz » ; la +# commande ne faisait pas cela. Les deux configurations exposent bien un vrai +# `/healthz`, mais l'image Chainguard n'a ni shell, ni wget, ni curl (vérifié : +# `/bin/sh` n'existe pas), donc rien ne peut l'interroger depuis l'intérieur du +# conteneur. +# +# La sonde revient donc à l'orchestrateur, qui sait le faire depuis l'extérieur : +# `readinessProbe: httpGet: { path: /healthz }` côté Kubernetes, ou un +# `healthcheck:` dans le compose si l'on veut la garder ici. # The Chainguard image's default ENTRYPOINT runs `nginx -g 'daemon off;'` # from the right user — no overrides needed. diff --git a/apps/web/app/assets/css/main.css b/apps/web/app/assets/css/main.css index dabea2d0..cdb51133 100644 --- a/apps/web/app/assets/css/main.css +++ b/apps/web/app/assets/css/main.css @@ -35,7 +35,14 @@ and is not perceptible next to the old value. Same class of defect as the `--fg-faint` note below, found by the contrast gate the theme system needs anyway. */ - --fg-subtle: 121 121 121; + /* 127, up from 121. The note above measured this token against `--bg-base` + only, which is the surface it fails on LAST: 121 gave 4.55:1 there and + 4.23:1 on `--bg-surface` — every muted label inside a card — and 4.40:1 on + `--bg-inset`. 127 clears 4.5:1 on all three. Found by extending the + contrast gate to the surfaces it was not checking, which is the third time + this file records the same lesson: the pair nobody declared is the pair + that ships broken. */ + --fg-subtle: 127 127 127; /* Bumped from 74/74/74 (~1.84:1 on bg-base — WCAG fail) up to 130/130/130 (~5.2:1 on bg-base) so the dozen-or-so mono micro labels using --fg-faint as a text colour stay legible. */ @@ -43,6 +50,17 @@ /* Lines */ --line-default: 42 42 42; + /* La bordure d'un CHAMP, distincte des filets décoratifs. + `--line-default` sur `--bg-elevated` donne 1,21:1 en sombre et 1,26:1 en + clair : dans les deux thèmes, la frontière du contrôle est sous les 3:1 + que WCAG 1.4.11 exige d'un élément non textuel nécessaire pour + l'identifier. Le portail écartait justement la paire + `line-default`/`bg-base` au motif que « `.input` s'identifie par son fond, + distinct de la page » — or en thème clair `--bg-elevated` et + `--bg-surface` valent tous deux 255 : la prémisse était fausse pour un des + deux thèmes livrés. 102 en sombre et 149 en clair donnent 3:1 sur le fond + du champ, sans alourdir les filets des cartes et des tableaux. */ + --line-field: 102 102 102; --line-strong: 58 58 58; /* Accent — deliberately neutral; primary CTA inverts the surface */ @@ -53,7 +71,24 @@ /* Semantic — used for status badges and notifications, NOT for chrome */ --online: 34 197 94; --warning: 234 179 8; + /* Les encres de la famille sémantique. + `accent` et `accent-warm` avaient chacun la leur ; `online`, `warning`, + `danger` et `info` n'en avaient aucune, et les composants comblaient le + trou avec des `#fff` et des `#0a1610` en dur — hors du système, donc + insensibles au thème, et faux dans un des deux : blanc sur le vert du thème + clair passe, blanc sur le vert du thème sombre non, et réciproquement pour + une encre foncée. Une encre quasi noire sur les teintes vives du thème + sombre, blanche sur les teintes profondes du thème clair. */ + --online-fg: 26 26 26; + --warning-fg: 26 26 26; + --info-fg: 26 26 26; --danger: 239 68 68; + /* L'encre du bouton plein de danger. `#fff` en dur sur `--danger` donne + 3,76:1 en thème sombre : l'état AU REPOS échouait pendant que son survol + passait à 4,70:1 — exactement à l'envers. Une encre quasi noire sur ce + rouge donne 4,63:1, et en thème clair (185 28 28) le blanc donne 6,54:1 : + chaque thème prend l'encre qui marche sur SON rouge. */ + --danger-fg: 26 26 26; --info: 56 189 248; /* Chrome. `--focus-ring` is new and its default IS the gold that used to be @@ -117,6 +152,16 @@ theme authored today stays valid when those 111 definitions are replaced, and a theme file can already set them by hand. */ --accent-warm: 212 167 52; + /* L'or, mais lisible EN TEXTE. + `--accent-warm` est un jeton de remplissage : sa paire déclarée au portail + de contraste est `accent-warm-fg` POSÉ DESSUS. Utilisé comme couleur de + texte il n'a jamais été mesuré, et en thème clair (176 133 24) il donne + 3,24:1 sur la page — sous les 4,5:1 requis, pour les dizaines de + micro-libellés dorés de 9 à 15 px qui parsèment la console. En sombre les + deux valeurs coïncident : c'est le thème clair qui avait besoin d'une + encre plus profonde. Les deux nouvelles paires sont déclarées au portail + pour que la question ne se repose pas. */ + --accent-warm-text: 212 167 52; --accent-warm-fg: 26 26 26; --accent-cool: 52 212 216; --accent-paper: 20 20 20; @@ -242,8 +287,8 @@ --fg-default: 10 10 10; --fg-strong: 0 0 0; --fg-muted: 85 85 85; - --fg-subtle: 115 115 115; - /* 115, down from 176 — the biggest visible change in this file, and a bug + --fg-subtle: 111 111 111; + /* 111, down from 176 — the biggest visible change in this file, and a bug fix rather than a preference. 176 on `--bg-base` is **2.08:1**, which is unreadable, and it is the same defect the dark theme already had: the note above records `--fg-faint` being raised from 1.84:1 to 5.2:1 there for @@ -253,10 +298,16 @@ Light-mode micro labels and placeholders are therefore noticeably darker than before. That is the point: at 2.08:1 they were decorative, not - legible. */ - --fg-faint: 115 115 115; + legible. + + 111, down from 115, for the same reason `--fg-subtle` moved with it: in the + light theme `--bg-inset` (245) is the DARKEST surface rather than a middle + one, so it is the worst case here and it was not measured either. 115 gave + 4.35:1 on it; 111 gives 4.55:1. */ + --fg-faint: 111 111 111; --line-default: 229 229 229; + --line-field: 146 146 146; --line-strong: 208 208 208; --accent: 10 10 10; @@ -265,7 +316,11 @@ --online: 21 128 61; --warning: 180 83 9; + --online-fg: 255 255 255; + --warning-fg: 255 255 255; + --info-fg: 255 255 255; --danger: 185 28 28; + --danger-fg: 255 255 255; --info: 3 105 161; --focus-ring: 176 133 24; @@ -277,6 +332,9 @@ --bg-pattern-step: 40px; --accent-warm: 176 133 24; + /* 143 104 8 : 4,76:1 sur `bg-base`, 5,06:1 sur `bg-surface` / `bg-elevated`, + 4,56:1 sur `bg-inset`. Le quatre surfaces passent AA. */ + --accent-warm-text: 143 104 8; --accent-warm-fg: 26 26 26; --accent-cool: 14 145 148; --accent-paper: 252 250 245; @@ -463,7 +521,7 @@ .btn-danger { background-color: rgb(var(--danger)); - color: #fff; + color: rgb(var(--danger-fg)); border-color: rgb(var(--danger)); } .btn-danger:hover { @@ -471,8 +529,138 @@ border-color: rgb(var(--danger) / 0.85); } - .btn-sm { padding: 0.3rem 0.6rem; font-size: 0.75rem; } - .btn-xs { padding: 0.2rem 0.45rem; font-size: 0.6875rem; } + /* `min-height` et non plus du padding seul : à `line-height: 1`, `.btn-sm` + tombait à ~23,6 px et `.btn-xs` à ~22,4 px — tous deux sous le minimum de + 24 px de WCAG 2.2 SC 2.5.8, que le commentaire de `.tool-btn` plus bas + cite pourtant pour justifier ses 36 px. `.btn-xs` est la croix de + fermeture de `Modal.vue`, donc la commande la plus pressée du système. */ + .btn-sm { padding: 0.3rem 0.6rem; font-size: 0.75rem; min-height: 1.5rem; } + .btn-xs { padding: 0.2rem 0.45rem; font-size: 0.6875rem; min-height: 1.5rem; } + + /* --------------------------------------------------------------------------- + * Icon buttons — the square affordance next to a heading or in a table row + * + * This lived in two `<style scoped>` blocks (`admin/Users.vue`, + * `admin/banned-ips.vue`) with byte-identical bodies, while five files used + * the class. A scoped rule compiles to `.tool-btn[data-v-…]`, so the three + * files that only *used* it — `alerts.vue`, `torrents/index.vue`, + * `admin/audit.vue` — rendered bare native buttons: Tailwind's preflight + * strips a button's background, border and padding, so what shipped was an + * unboxed 16px glyph with no hover and no focus surface. The delete control on + * `/alerts` was the worst of it, because `--danger` was never defined + * anywhere: the irreversible action and the safe one beside it were + * distinguishable only by the shape of their icon. + * + * 2.25rem square is not a round number picked for looks — it is 36px, which + * clears the 24px minimum of WCAG 2.2 SC 2.5.8 with room for the border. + * + * The scoped copies stay where they are. `<style scoped>` is unlayered, so it + * still wins over `@layer components` regardless of specificity, and those two + * pages keep rendering exactly as they do today. + * --------------------------------------------------------------------------- */ + /* --------------------------------------------------------------------------- + * Aller au contenu + * + * Hors écran jusqu'au focus, puis posé au-dessus de tout — y compris de + * l'en-tête collant, d'où le z-index au-delà de l'échelle des modales : un + * lien d'évitement que la barre recouvre ne sert à rien. + * --------------------------------------------------------------------------- */ + .skip-link { + position: fixed; + top: 0.5rem; + left: 0.5rem; + z-index: 70; + padding: 0.55rem 0.9rem; + border: 1px solid rgb(var(--line-field)); + border-radius: var(--radius-sm); + background: rgb(var(--bg-elevated)); + color: rgb(var(--fg-strong)); + font-size: 0.8125rem; + font-weight: 600; + transform: translateY(-200%); + transition: transform var(--dur-2) ease; + } + .skip-link:focus-visible { + transform: translateY(0); + outline: 2px solid rgb(var(--focus-ring)); + outline-offset: 2px; + } + + .tool-btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 2.25rem; + height: 2.25rem; + flex: none; + border-radius: var(--radius-pill); + border: 1px solid rgb(var(--line-default)); + background: rgb(var(--bg-elevated)); + color: rgb(var(--fg-muted)); + cursor: pointer; + transition: + color var(--dur-2) ease, + background-color var(--dur-2) ease, + border-color var(--dur-2) ease; + } + .tool-btn:hover:not(:disabled) { + color: rgb(var(--fg-strong)); + border-color: rgb(var(--fg-default) / 0.3); + } + .tool-btn:disabled, + .tool-btn[disabled] { + opacity: 0.6; + cursor: not-allowed; + } + /* Destructive. Muted until pointed at, so a row of controls does not read as + a row of alarms — but unmistakable on hover and on focus, which is where a + member is when they are about to press it. */ + .tool-btn--danger:hover:not(:disabled), + .tool-btn--danger:focus-visible:not(:disabled) { + color: rgb(var(--danger)); + border-color: rgb(var(--danger) / 0.5); + background: rgb(var(--danger) / 0.1); + } + /* A labelled variant: still a button, still boxed, but sized by its text + rather than square. */ + .tool-btn--text { + width: auto; + gap: 0.4rem; + padding: 0 0.75rem; + font-size: 0.8125rem; + font-weight: 500; + color: rgb(var(--fg-default)); + } + .tool-btn--sm { + width: 1.75rem; + height: 1.75rem; + } + .tool-btn--sm.tool-btn--text { width: auto; padding: 0 0.6rem; font-size: 0.75rem; } + + /* --------------------------------------------------------------------------- + * Field labels — the micro-label above an input, and the sentence under it + * + * Thirteen files defined this in `<style scoped>`; `admin/audit.vue` and + * `admin/InviteTree.vue` used it without defining it, so their six labels + * rendered as 16px inherited body text instead of a 10px uppercase label. + * The values below are `ThemeEditor.vue`'s, which is the most common of the + * thirteen. The scoped copies keep their own tracking, as above. + * --------------------------------------------------------------------------- */ + .field-label { + display: block; + font-size: 0.625rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: calc(0.14em * var(--tracking-scale)); + color: rgb(var(--fg-muted)); + margin-bottom: 0.25rem; + } + .field-help { + display: block; + margin-top: 0.25rem; + font-size: 0.6875rem; + color: rgb(var(--fg-subtle)); + } /* --------------------------------------------------------------------------- * Inputs @@ -481,7 +669,7 @@ width: 100%; background-color: rgb(var(--bg-elevated)); color: rgb(var(--fg-default)); - border: 1px solid rgb(var(--line-default)); + border: 1px solid rgb(var(--line-field)); border-radius: var(--radius-sm); padding: 0.45rem 0.75rem; font-size: 0.875rem; @@ -493,6 +681,16 @@ border-color: rgb(var(--fg-default)); outline: none; } + /* `outline: none` above is for the mouse, where a border change is enough and + a ring around every field is noise. It was also killing the ring for the + keyboard, because `.input:focus` outranks the bare `:focus-visible` rule + further up — so tabbing through a form of eight fields signalled position + with a 1px border tint and nothing else. Restated here at equal + specificity, and later in the sheet, so the keyboard gets its ring back. */ + .input:focus-visible { + outline: 2px solid rgb(var(--focus-ring)); + outline-offset: 2px; + } /* --------------------------------------------------------------------------- * Typography utilities — clean hierarchy alternatives to the previous @@ -627,6 +825,11 @@ .modal-overlay { position: fixed; inset: 0; + /* 50, comme l'échelle juste au-dessus le dit. Le toast était lui aussi à + 50 alors que sa place est 60 : à z-index égal c'est l'ordre DOM qui + tranche, et une modale téléportée dans `body` arrive APRÈS le toast monté + au layout. Son `backdrop-filter` floutait donc le message d'erreur + renvoyé par le formulaire de la modale — précisément quand il compte. */ z-index: 50; display: flex; align-items: center; @@ -709,3 +912,18 @@ } } +/* Et les DÉLAIS, pas seulement les durées. + Le bloc ci-dessus ramène `animation-duration` à 0,01 ms, mais une entrée + échelonnée garde son `animation-delay` : avec `fill-mode: backwards` ou + `both`, les dernières lignes d'une liste restent à `opacity: 0` pendant + jusqu'à 700 ms puis apparaissent d'un coup. Un mouvement réduit n'est pas + un mouvement plus lent. */ +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-delay: 0ms !important; + transition-delay: 0ms !important; + } +} + diff --git a/apps/web/app/assets/css/upload-form.css b/apps/web/app/assets/css/upload-form.css index ea32c0fb..033a9127 100644 --- a/apps/web/app/assets/css/upload-form.css +++ b/apps/web/app/assets/css/upload-form.css @@ -108,12 +108,12 @@ .ready-state.partial { border-color: rgba(245, 197, 24, 0.4); background: rgba(245, 197, 24, 0.08); - color: #f5c518; + color: rgb(var(--warning)); /* jeton sémantique : cette teinte était figée sur le thème sombre */ } .ready-state.ready { border-color: rgba(108, 209, 97, 0.4); background: rgba(108, 209, 97, 0.08); - color: #6cd161; + color: rgb(var(--online)); /* jeton sémantique : cette teinte était figée sur le thème sombre */ } /* ─── Grid ──────────────────────────────────────────────────── */ @@ -458,7 +458,7 @@ font-size: 0.75em; } .action-ready { - color: #6cd161; + color: rgb(var(--online)); /* jeton sémantique : cette teinte était figée sur le thème sombre */ font-weight: 600; display: inline-flex; align-items: center; diff --git a/apps/web/app/components/BookMetadataCard.vue b/apps/web/app/components/BookMetadataCard.vue index bf71e19d..d7b55514 100644 --- a/apps/web/app/components/BookMetadataCard.vue +++ b/apps/web/app/components/BookMetadataCard.vue @@ -224,7 +224,7 @@ defineProps<{ border: 1px solid rgba(217, 119, 6, 0.45); border-radius: var(--radius-pill); background: rgba(217, 119, 6, 0.1); - color: #f59e0b; + color: rgb(var(--fg-default)); font-family: var(--font-mono); font-size: 0.625rem; font-weight: 700; @@ -233,7 +233,7 @@ defineProps<{ } .bcard-tag-icon { font-size: 0.6875rem; - color: #d97706; + color: rgb(var(--fg-default)); filter: drop-shadow(0 0 4px rgba(217, 119, 6, 0.5)); } @@ -327,6 +327,13 @@ defineProps<{ margin-right: 0.4rem; } + /* La teinte reste sur le fond et la bordure — donc l'identité média + (IMDb, TMDb) et la distinction de catégorie survivent — mais le LIBELLÉ + passe sur un jeton de premier plan. Une couleur de marque n'a pas de raison + d'être lisible sur les deux thèmes : `#f5c518` sur blanc mesure 1,50:1. + C'est exactement ce que `tagBadgeStyle()` fait déjà pour les tags, où la + couleur est choisie par un opérateur et où le texte reste donc toujours + lisible. */ .bcard-genres { display: flex; flex-wrap: wrap; @@ -337,7 +344,7 @@ defineProps<{ border-radius: var(--radius-pill); border: 1px solid rgba(217, 119, 6, 0.3); background: rgba(217, 119, 6, 0.06); - color: #f59e0b; + color: rgb(var(--fg-default)); font-size: 0.7rem; font-weight: 600; letter-spacing: calc(0.02em * var(--tracking-scale)); @@ -356,7 +363,7 @@ defineProps<{ gap: 0.35rem; } .bcard-stat-icon { - color: #d97706; + color: rgb(var(--fg-default)); font-size: 0.85rem; } .bcard-stat-max { @@ -410,10 +417,10 @@ defineProps<{ gap: 0.35rem; font-size: 0.78rem; font-weight: 600; - color: #f59e0b; + color: rgb(var(--fg-default)); transition: color var(--dur-2) ease; } .bcard-link:hover { - color: #fbbf24; + color: rgb(var(--warning)); /* jeton sémantique : cette teinte était figée sur le thème sombre */ } </style> diff --git a/apps/web/app/components/CategoryTypeBadge.vue b/apps/web/app/components/CategoryTypeBadge.vue index be4caa53..eed9b4d1 100644 --- a/apps/web/app/components/CategoryTypeBadge.vue +++ b/apps/web/app/components/CategoryTypeBadge.vue @@ -58,23 +58,30 @@ defineProps<{ } /* Two distinct accents — gold for /movie, cyan for /tv — match the palette used in the admin KPI cards elsewhere in the app. */ + /* La teinte reste sur le fond et la bordure — donc l'identité média + (IMDb, TMDb) et la distinction de catégorie survivent — mais le LIBELLÉ + passe sur un jeton de premier plan. Une couleur de marque n'a pas de raison + d'être lisible sur les deux thèmes : `#f5c518` sur blanc mesure 1,50:1. + C'est exactement ce que `tagBadgeStyle()` fait déjà pour les tags, où la + couleur est choisie par un opérateur et où le texte reste donc toujours + lisible. */ .type-badge--movie { - color: #f5c518; + color: rgb(var(--fg-default)); border-color: rgba(245, 197, 24, 0.4); background: rgba(245, 197, 24, 0.08); } .type-badge--tv { - color: #34d4d8; + color: rgb(var(--info)); /* jeton sémantique : cette teinte était figée sur le thème sombre */ border-color: rgba(52, 212, 216, 0.4); background: rgba(52, 212, 216, 0.08); } .type-badge--game { - color: #a78bfa; + color: rgb(var(--fg-default)); border-color: rgba(167, 139, 250, 0.4); background: rgba(167, 139, 250, 0.08); } .type-badge--book { - color: #d97706; + color: rgb(var(--fg-default)); border-color: rgba(217, 119, 6, 0.4); background: rgba(217, 119, 6, 0.08); } diff --git a/apps/web/app/components/CommandPalette.vue b/apps/web/app/components/CommandPalette.vue index cf7e451e..dfc57b66 100644 --- a/apps/web/app/components/CommandPalette.vue +++ b/apps/web/app/components/CommandPalette.vue @@ -174,6 +174,7 @@ const mainLinks = computed<PaletteItem[]>(() => { }, { to: '/requests', key: 'requests', icon: 'ph:megaphone-bold' }, { to: '/forum', key: 'forum', icon: 'ph:chat-centered-text' }, + { to: '/stats', key: 'stats', icon: 'ph:chart-line-up' }, { to: '/admin', key: 'admin', @@ -204,6 +205,7 @@ const accountLinks = computed<PaletteItem[]>(() => { ? [{ to: '/messages', key: 'messages', icon: 'ph:chat-circle' }] : []), { to: '/favorites', key: 'favorites', icon: 'ph:heart' }, + { to: '/alerts', key: 'alerts', icon: 'ph:bookmark-simple' }, { to: '/following', key: 'following', icon: 'ph:bell' }, { to: '/downloads', key: 'downloads', icon: 'ph:download-simple' }, { to: '/invites', key: 'invites', icon: 'ph:envelope-simple' }, diff --git a/apps/web/app/components/ConfirmHost.vue b/apps/web/app/components/ConfirmHost.vue index 36aa30cc..4625a214 100644 --- a/apps/web/app/components/ConfirmHost.vue +++ b/apps/web/app/components/ConfirmHost.vue @@ -10,7 +10,7 @@ > <div v-if="current" - class="fixed inset-0 z-[60] flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm" + class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm" @click.self="onCancel" @keydown.esc.stop="onCancel" > @@ -51,17 +51,23 @@ </p> <div class="flex gap-2"> <button + ref="cancelButtonRef" type="button" - class="btn btn-secondary flex-1 text-[10px] font-bold uppercase tracking-widest" + class="btn btn-secondary flex-1 text-xs font-bold uppercase tracking-widest" @click="onCancel" > {{ current.cancelText || t('common.cancel') }} </button> + <!-- `.btn-danger` plutôt qu'une règle locale : sa couleur d'encre + est désormais un jeton par thème (`--danger-fg`), alors que le + `background-color: rgb(239 68 68); color: white` codé ici + donnait 3,76:1 et ignorait le thème clair, où le même blanc + sur le rouge du thème atteint 6,54:1. --> <button ref="confirmButtonRef" type="button" - class="btn btn-primary flex-1 text-[10px] font-bold uppercase tracking-widest" - :class="current.destructive ? 'destructive-btn' : ''" + class="btn flex-1 text-xs font-bold uppercase tracking-widest" + :class="current.destructive ? 'btn-danger' : 'btn-primary'" @click="onConfirm" > {{ current.confirmText || t('common.confirm') }} @@ -79,6 +85,7 @@ const { t } = useI18n(); const queue = useConfirmState(); const current = computed(() => queue.value[0] || null); const confirmButtonRef = ref<HTMLButtonElement | null>(null); +const cancelButtonRef = ref<HTMLButtonElement | null>(null); function pop(answer: boolean) { const req = queue.value[0]; @@ -94,12 +101,32 @@ function onConfirm() { pop(true); } -// Auto-focus the confirm button when a dialog opens so Enter resolves -// "Confirm" and Esc resolves "Cancel" — same default as window.confirm. +/* + * Où va le focus à l'ouverture. + * + * Sur un dialogue ORDINAIRE, sur Confirmer : c'est le geste attendu, et c'est + * le défaut de `window.confirm`. Sur un dialogue DESTRUCTIF, sur Annuler — une + * frappe Entrée résiduelle après le clic qui a ouvert la boîte détruisait + * sinon la donnée, et `handleKey` résout justement Entrée sur le bouton + * focalisé. La convention pour un dialogue destructif est d'exiger un geste + * délibéré pour atteindre le bouton rouge. + */ +/** Ce qui avait le focus avant la question, pour le lui rendre après. */ +let restoreTo: HTMLElement | null = null; + watch(current, async (req) => { - if (!req) return; + if (!req) { + // Sans cela, répondre à une confirmation laissait le focus sur `<body>` : + // la tabulation suivante repartait du haut de la page, loin du bouton + // qu'on venait d'actionner. + restoreTo?.focus?.(); + restoreTo = null; + return; + } + if (!restoreTo) restoreTo = document.activeElement as HTMLElement | null; await nextTick(); - confirmButtonRef.value?.focus(); + if (req.destructive) cancelButtonRef.value?.focus(); + else confirmButtonRef.value?.focus(); }); // Trap Esc globally while a dialog is open. Without this, browsers handle @@ -112,6 +139,21 @@ function handleKey(e: KeyboardEvent) { } else if (e.key === 'Enter' && document.activeElement === confirmButtonRef.value) { e.preventDefault(); onConfirm(); + } else if (e.key === 'Tab') { + // Deux boutons, en boucle. `aria-modal` n'a jamais retenu la tabulation : + // au troisième Tab l'utilisateur au clavier était dans la page derrière, + // devant une question à laquelle il ne pouvait plus répondre autrement + // qu'en revenant à reculons. + const pair = [cancelButtonRef.value, confirmButtonRef.value].filter( + (b): b is HTMLButtonElement => !!b, + ); + if (!pair.length) return; + e.preventDefault(); + const at = pair.indexOf(document.activeElement as HTMLButtonElement); + const next = e.shiftKey + ? (at <= 0 ? pair.length - 1 : at - 1) + : (at === pair.length - 1 ? 0 : at + 1); + pair[next]!.focus(); } } @@ -124,11 +166,4 @@ onUnmounted(() => { </script> <style scoped> -.destructive-btn { - background-color: rgb(239 68 68); - color: white; -} -.destructive-btn:hover { - background-color: rgb(220 38 38); -} </style> diff --git a/apps/web/app/components/FederationOff.vue b/apps/web/app/components/FederationOff.vue index 7678c6d3..a870b931 100644 --- a/apps/web/app/components/FederationOff.vue +++ b/apps/web/app/components/FederationOff.vue @@ -48,8 +48,8 @@ const isAdmin = computed(() => Boolean(user.value?.isAdmin)); display: grid; place-items: center; border-radius: var(--radius-pill); - border: 1px dashed var(--color-border, rgb(255 255 255 / 0.12)); - color: var(--color-text-secondary, rgb(255 255 255 / 0.45)); + border: 1px dashed var(--line-default, rgb(255 255 255 / 0.12)); + color: var(--fg-muted, rgb(255 255 255 / 0.45)); font-size: 2rem; } @@ -60,13 +60,13 @@ const isAdmin = computed(() => Boolean(user.value?.isAdmin)); } .fed-off-lead { - color: var(--color-text-secondary, rgb(255 255 255 / 0.6)); + color: var(--fg-muted, rgb(255 255 255 / 0.6)); line-height: 1.6; } .fed-off-admin { margin-top: 0.75rem; - color: var(--color-text-secondary, rgb(255 255 255 / 0.45)); + color: var(--fg-muted, rgb(255 255 255 / 0.45)); font-size: 0.875rem; line-height: 1.6; } @@ -85,15 +85,15 @@ const isAdmin = computed(() => Boolean(user.value?.isAdmin)); gap: 0.5rem; padding: 0.6rem 1.1rem; border-radius: var(--radius-xl); - border: 1px solid var(--color-border, rgb(255 255 255 / 0.12)); - color: var(--color-text-secondary, rgb(255 255 255 / 0.7)); + border: 1px solid var(--line-default, rgb(255 255 255 / 0.12)); + color: var(--fg-muted, rgb(255 255 255 / 0.7)); font-size: 0.9rem; transition: background-color var(--dur-2), color var(--dur-2), border-color var(--dur-2); } .fed-off-btn:hover { background: rgb(255 255 255 / 0.04); - color: var(--color-text-primary, rgb(255 255 255 / 0.92)); + color: var(--fg-default, rgb(255 255 255 / 0.92)); } .fed-off-btn.primary { diff --git a/apps/web/app/components/FicheAmount.vue b/apps/web/app/components/FicheAmount.vue index e59d77a2..b7ba64f6 100644 --- a/apps/web/app/components/FicheAmount.vue +++ b/apps/web/app/components/FicheAmount.vue @@ -16,7 +16,22 @@ import { type SizeUnit, } from '~/utils/mediainfo'; -const props = defineProps<{ kind: 'bitrate' | 'size' }>(); +const props = defineProps<{ + kind: 'bitrate' | 'size'; + /** + * Nom accessible du champ. Même raison que dans `FicheCombo` : l'appelant + * rend son libellé dans un `<span>`, et il y a deux contrôles — la quantité + * et son unité — donc rien ne peut être déduit. + */ + /** + * OBLIGATOIRE, et c'est le point : rendue optionnelle, un appelant qui + * l'oublie laisse un contrôle anonyme et rien ne le signale — ni le + * compilateur, ni un détecteur statique, qui voit bien l'attribut posé sur + * le contrôle mais pas si sa valeur arrive. Requise, le typecheck énumère + * lui-même les oublis. + */ + fieldLabel: string; +}>(); /** The value in base units: bit/s for a bitrate, bytes for a size. */ const base = defineModel<number | undefined>('base'); @@ -52,8 +67,19 @@ const shown = computed<number | undefined>({ <template> <div class="fiche-amount"> - <input v-model.number="shown" type="number" min="0" step="any" class="input field-input" /> - <select v-model="unit" class="input field-input field-input--select"> + <input + v-model.number="shown" + type="number" + min="0" + step="any" + class="input field-input" + :aria-label="fieldLabel" + /> + <select + v-model="unit" + class="input field-input field-input--select" + :aria-label="$t('fiche.tech.a11yUnitFor', { field: fieldLabel })" + > <option v-for="u in units" :key="u" :value="u">{{ u }}</option> </select> </div> diff --git a/apps/web/app/components/FicheCombo.vue b/apps/web/app/components/FicheCombo.vue index 87ddae0f..ec9c756c 100644 --- a/apps/web/app/components/FicheCombo.vue +++ b/apps/web/app/components/FicheCombo.vue @@ -16,6 +16,30 @@ const props = defineProps<{ /** How to render an option when the stored value is not readable as-is — a * language code, for instance. */ labelFor?: (value: string) => string; + /** + * Nom accessible du champ. + * + * Explicite, et pas déduit : les appelants rendent leur libellé dans un + * `<span class="field-label">`, pas un `<label for>`, donc rien ne désigne + * le `<select>` que ce composant crée — un lecteur d'écran annonçait + * « liste » sans dire de quoi. La retombée automatique des attributs ne + * suffirait pas non plus : il y a DEUX contrôles ici, et un `aria-label` + * atterrirait sur le `<div>` racine. + * + * `fieldLabel` et non `fieldLabel` : `aria-label` EST un attribut ARIA natif, + * donc `:aria-label="…"` sur le composant satisfait la signature HTML de la + * balise et n'alimente jamais la prop du même nom camélisé. Le typecheck + * restait rouge en signalant une prop manquante alors que l'attribut était + * bien là — un nom distinct lève l'ambiguïté. + */ + /** + * OBLIGATOIRE, et c'est le point : rendue optionnelle, un appelant qui + * l'oublie laisse un contrôle anonyme et rien ne le signale — ni le + * compilateur, ni un détecteur statique, qui voit bien l'attribut posé sur + * le contrôle mais pas si sa valeur arrive. Requise, le typecheck énumère + * lui-même les oublis. + */ + fieldLabel: string; }>(); const model = defineModel<string>({ default: '' }); @@ -43,7 +67,11 @@ const selected = computed({ <template> <div class="fiche-combo"> - <select v-model="selected" class="input field-input field-input--select"> + <select + v-model="selected" + class="input field-input field-input--select" + :aria-label="fieldLabel" + > <option v-if="emptyLabel !== undefined" value="">{{ emptyLabel }}</option> <option v-for="o in options" :key="o" :value="o"> {{ labelFor ? labelFor(o) : o }} @@ -55,7 +83,8 @@ const selected = computed({ v-model="model" type="text" class="input field-input" - :placeholder="placeholder" + + :aria-label="$t('fiche.tech.a11yCustomFor', { field: fieldLabel })":placeholder="placeholder" /> </div> </template> diff --git a/apps/web/app/components/GameMetadataCard.vue b/apps/web/app/components/GameMetadataCard.vue index 0bd4d499..22a7f805 100644 --- a/apps/web/app/components/GameMetadataCard.vue +++ b/apps/web/app/components/GameMetadataCard.vue @@ -261,7 +261,7 @@ function formatReleaseDate(iso: string | null): string { font-weight: 800; letter-spacing: calc(0.18em * var(--tracking-scale)); text-transform: uppercase; - color: #a78bfa; + color: rgb(var(--fg-default)); z-index: 2; } .gcard-tag-icon { font-size: 0.85rem; } @@ -354,7 +354,7 @@ function formatReleaseDate(iso: string | null): string { border-radius: var(--radius-sm); background: rgb(var(--accent-warm) / 0.08); border: 1px solid rgb(var(--accent-warm) / 0.35); - color: rgb(var(--accent-warm)); + color: rgb(var(--accent-warm-text)); } .gcard-stats { @@ -420,14 +420,21 @@ function formatReleaseDate(iso: string | null): string { .gcard-pill--platform { border-color: rgba(96, 165, 250, 0.35); background: rgba(96, 165, 250, 0.06); - color: #60a5fa; + color: rgb(var(--info)); /* jeton sémantique : cette teinte était figée sur le thème sombre */ } .gcard-pill--mode { border-color: rgba(108, 209, 97, 0.4); background: rgba(108, 209, 97, 0.06); - color: #6cd161; + color: rgb(var(--online)); /* jeton sémantique : cette teinte était figée sur le thème sombre */ } + /* La teinte reste sur le fond et la bordure — donc l'identité média + (IMDb, TMDb) et la distinction de catégorie survivent — mais le LIBELLÉ + passe sur un jeton de premier plan. Une couleur de marque n'a pas de raison + d'être lisible sur les deux thèmes : `#f5c518` sur blanc mesure 1,50:1. + C'est exactement ce que `tagBadgeStyle()` fait déjà pour les tags, où la + couleur est choisie par un opérateur et où le texte reste donc toujours + lisible. */ .gcard-links { display: flex; flex-wrap: wrap; @@ -443,11 +450,11 @@ function formatReleaseDate(iso: string | null): string { font-weight: 700; letter-spacing: calc(0.1em * var(--tracking-scale)); text-transform: uppercase; - color: #a78bfa; + color: rgb(var(--fg-default)); text-decoration: none; transition: color var(--dur-3) ease; } -.gcard-link:hover { color: #c4b5fd; } +.gcard-link:hover { color: rgb(var(--fg-strong)); } .gcard-link-arrow { font-size: 0.7rem; transition: transform var(--dur-3) ease; diff --git a/apps/web/app/components/IconPicker.vue b/apps/web/app/components/IconPicker.vue index b19d009f..51c28fb0 100644 --- a/apps/web/app/components/IconPicker.vue +++ b/apps/web/app/components/IconPicker.vue @@ -375,7 +375,7 @@ onBeforeUnmount(() => { } .icon-picker.is-open .icon-picker-preview { background: rgb(var(--accent-warm) / 0.12); - color: rgb(var(--accent-warm)); + color: rgb(var(--accent-warm-text)); } .icon-picker-preview:disabled { cursor: not-allowed; @@ -512,7 +512,7 @@ onBeforeUnmount(() => { .icon-picker-cell.is-active { background: rgb(var(--accent-warm) / 0.12); border-color: rgb(var(--accent-warm) / 0.45); - color: rgb(var(--accent-warm)); + color: rgb(var(--accent-warm-text)); } .icon-picker-cell-glyph { font-size: 1.4rem; diff --git a/apps/web/app/components/MediaMetadataCard.vue b/apps/web/app/components/MediaMetadataCard.vue index 9e8501dc..a08d0409 100644 --- a/apps/web/app/components/MediaMetadataCard.vue +++ b/apps/web/app/components/MediaMetadataCard.vue @@ -266,13 +266,20 @@ function formatRuntime(minutes: number): string { border-radius: var(--radius-pill); border: 1px solid rgb(var(--line-default)); } + /* La teinte reste sur le fond et la bordure — donc l'identité média + (IMDb, TMDb) et la distinction de catégorie survivent — mais le LIBELLÉ + passe sur un jeton de premier plan. Une couleur de marque n'a pas de raison + d'être lisible sur les deux thèmes : `#f5c518` sur blanc mesure 1,50:1. + C'est exactement ce que `tagBadgeStyle()` fait déjà pour les tags, où la + couleur est choisie par un opérateur et où le texte reste donc toujours + lisible. */ .media-card-type--movie { - color: #f5c518; + color: rgb(var(--fg-default)); border-color: rgba(245, 197, 24, 0.4); background: rgba(245, 197, 24, 0.1); } .media-card-type--tv { - color: #6cd161; + color: rgb(var(--online)); /* jeton sémantique : cette teinte était figée sur le thème sombre */ border-color: rgba(108, 209, 97, 0.4); background: rgba(108, 209, 97, 0.1); } diff --git a/apps/web/app/components/MediaSearchPicker.vue b/apps/web/app/components/MediaSearchPicker.vue index ecbcf92f..0b9fd310 100644 --- a/apps/web/app/components/MediaSearchPicker.vue +++ b/apps/web/app/components/MediaSearchPicker.vue @@ -38,15 +38,31 @@ </span> </div> + <!-- + L'échec, dit sous le champ plutôt que dans la liste déroulante. + + `error` ne s'affichait qu'à l'intérieur du panneau, sous `v-if="open + && …"`. Or les deux chemins qui l'écrivent ferment le panneau ou ne + l'ouvrent jamais : choisir un résultat (`resolveHit` met `open` à faux + AVANT d'échouer) et coller un identifiant TMDb à la main + (`resolveById`, qui n'ouvre rien). Un membre qui collait un id + introuvable, ou qui cliquait un titre pendant que le service de + métadonnées était coupé, ne voyait donc strictement rien : ni fiche, ni + message. + --> + <p v-if="error" class="picker-error picker-error--standalone" role="alert"> + <Icon name="ph:warning-circle-bold" /> + {{ error }} + </p> + <Transition name="picker-fade"> <div - v-if="open && (results.length > 0 || error || (debouncedQuery && !loading))" + v-if="open && (results.length > 0 || (debouncedQuery && !loading))" class="picker-results" @mousedown.prevent > - <p v-if="error" class="picker-error">{{ error }}</p> <p - v-else-if="debouncedQuery && !loading && results.length === 0" + v-if="debouncedQuery && !loading && results.length === 0" class="picker-empty" > {{ t('components.mediaSearch.noMatchesPrefix') }} <em>{{ debouncedQuery }}</em> @@ -699,26 +715,33 @@ async function resolveManual() { letter-spacing: calc(0.04em * var(--tracking-scale)); text-transform: uppercase; } + /* La teinte reste sur le fond et la bordure — donc l'identité média + (IMDb, TMDb) et la distinction de catégorie survivent — mais le LIBELLÉ + passe sur un jeton de premier plan. Une couleur de marque n'a pas de raison + d'être lisible sur les deux thèmes : `#f5c518` sur blanc mesure 1,50:1. + C'est exactement ce que `tagBadgeStyle()` fait déjà pour les tags, où la + couleur est choisie par un opérateur et où le texte reste donc toujours + lisible. */ .picker-kind--movie { border-color: rgba(245, 197, 24, 0.45); - color: #f5c518; + color: rgb(var(--fg-default)); background: rgba(245, 197, 24, 0.08); } .picker-kind--tv { border-color: rgba(108, 209, 97, 0.45); - color: #6cd161; + color: rgb(var(--online)); /* jeton sémantique : cette teinte était figée sur le thème sombre */ background: rgba(108, 209, 97, 0.08); } .picker-kind--game { border-color: rgba(167, 139, 250, 0.45); - color: #a78bfa; + color: rgb(var(--fg-default)); background: rgba(167, 139, 250, 0.08); } .picker-rating { display: inline-flex; align-items: center; gap: 0.2rem; - color: #f5c518; + color: rgb(var(--fg-default)); font-weight: 600; } .picker-overview { @@ -906,4 +929,12 @@ async function resolveManual() { opacity: 0; transform: translateY(-4px); } +.picker-error--standalone { + display: flex; + align-items: center; + gap: 0.35rem; + margin-top: 0.4rem; + font-size: 0.75rem; + color: rgb(var(--danger)); +} </style> diff --git a/apps/web/app/components/MessagesBell.vue b/apps/web/app/components/MessagesBell.vue index 5f524dbf..294433be 100644 --- a/apps/web/app/components/MessagesBell.vue +++ b/apps/web/app/components/MessagesBell.vue @@ -145,7 +145,7 @@ onUnmounted(() => { align-items: center; justify-content: center; background: #f43f5e; - color: #fff; + color: rgb(var(--danger-fg)); /* jeton sémantique : cette teinte était figée sur le thème sombre */ border-radius: var(--radius-pill); font-family: var(--font-mono); font-size: 0.5625rem; diff --git a/apps/web/app/components/Modal.vue b/apps/web/app/components/Modal.vue index 79db0b88..8c46c33f 100644 --- a/apps/web/app/components/Modal.vue +++ b/apps/web/app/components/Modal.vue @@ -20,7 +20,8 @@ role="dialog" aria-modal="true" tabindex="-1" - :aria-labelledby="titleId" + :aria-labelledby="hasHeader ? titleId : undefined" + :aria-label="hasHeader ? undefined : t('components.modal.fallbackLabel')" @click.stop > <header @@ -35,9 +36,17 @@ class="text-base flex-shrink-0" :style="iconStyle" /> - <slot name="header"> - <h3 :id="titleId" class="h-card truncate">{{ title }}</h3> - </slot> + <!-- L'id porte sur le conteneur, pas sur le `<h3>` par défaut. + Il était sur le titre par défaut uniquement : dès qu'un + appelant remplissait le slot `#header` — ce que font la + plupart des modales d'administration — `aria-labelledby` + pointait vers un élément qui n'existait pas, et un lecteur + d'écran annonçait « boîte de dialogue » sans nom. --> + <div :id="titleId" class="min-w-0"> + <slot name="header"> + <h3 class="h-card truncate">{{ title }}</h3> + </slot> + </div> </div> <button v-if="!hideClose" @@ -89,7 +98,13 @@ const emit = defineEmits<{ (e: 'close'): void; }>(); -const titleId = `modal-${Math.random().toString(36).slice(2, 8)}`; +// `useId()` plutôt que `Math.random()` : l'identifiant est calculé au `setup`, +// donc aussi côté serveur. Une modale ouverte au rendu initial recevait deux +// valeurs différentes et l'hydratation cassait le lien titre ↔ dialogue. +const titleId = useId(); + +const slots = useSlots(); +const hasHeader = computed(() => Boolean(slots.header || props.title)); const sizeClass = computed(() => { switch (props.size) { @@ -120,38 +135,16 @@ function onBackdropClick() { close(); } -// ── Focus management + window-level Esc handler ───────────── -// Scoped Esc handlers (e.g. on the backdrop) only fire when the -// focus is already inside the modal. We bind on `window` so a -// keyboard user who tabs OUT of the modal can still press Esc to -// dismiss it. The panel auto-focuses on mount so the very first -// keystroke after open is captured. +// ── Focus, Échap et verrou de défilement ──────────────────── +// La mécanique est dans `useModalChrome` : elle était ici, et +// `ReportModal.vue` — habillage sur mesure, donc pas réutilisable via ce +// composant — n'en avait rien. Une seule implémentation pour les deux. const panelRef = ref<HTMLElement | null>(null); -function onKeydown(e: KeyboardEvent) { - if (e.key === 'Escape' && !props.persistent) { - e.preventDefault(); - close(); - } -} - -watch( - () => props.modelValue, - (open) => { - if (typeof window === 'undefined') return; - if (open) { - window.addEventListener('keydown', onKeydown); - // Wait one tick so the teleport mounts before we steal focus. - nextTick(() => panelRef.value?.focus()); - } else { - window.removeEventListener('keydown', onKeydown); - } - } -); - -onBeforeUnmount(() => { - if (typeof window !== 'undefined') { - window.removeEventListener('keydown', onKeydown); - } +useModalChrome({ + isOpen: () => props.modelValue, + panel: panelRef, + onEscape: close, + escapable: () => !props.persistent, }); </script> diff --git a/apps/web/app/components/NotificationBell.vue b/apps/web/app/components/NotificationBell.vue index b14f0cda..56bd144d 100644 --- a/apps/web/app/components/NotificationBell.vue +++ b/apps/web/app/components/NotificationBell.vue @@ -120,7 +120,11 @@ class="nbell-row" :class="{ 'nbell-row--unread': !row.readAt }" :style="{ '--stagger': `${i * 25}ms` }" + :role="row.link ? 'link' : 'button'" + tabindex="0" @click="onRowClick(row)" + @keydown.enter.prevent="onRowClick(row)" + @keydown.space.prevent="onRowClick(row)" > <span class="nbell-row-rail" @@ -344,7 +348,7 @@ function humanise(s: string): string { background: rgb(var(--fg-default) / 0.05); } .nbell-btn--open { - color: rgb(var(--accent-warm)); + color: rgb(var(--accent-warm-text)); background: rgb(var(--accent-warm) / 0.08); } .nbell-icon { font-size: 1.05rem; } @@ -359,7 +363,7 @@ function humanise(s: string): string { align-items: center; justify-content: center; background: #f43f5e; - color: #fff; + color: rgb(var(--danger-fg)); /* jeton sémantique : cette teinte était figée sur le thème sombre */ border-radius: var(--radius-pill); font-family: var(--font-mono); font-size: 0.5625rem; @@ -418,7 +422,7 @@ function humanise(s: string): string { font-weight: 700; letter-spacing: calc(0.24em * var(--tracking-scale)); text-transform: uppercase; - color: rgb(var(--accent-warm)); + color: rgb(var(--accent-warm-text)); } .nbell-eyebrow-rule { display: inline-block; @@ -449,7 +453,7 @@ function humanise(s: string): string { font-weight: 800; background: rgba(244, 63, 94, 0.12); border: 1px solid rgba(244, 63, 94, 0.45); - color: #f43f5e; + color: rgb(var(--danger)); /* jeton sémantique : cette teinte était figée sur le thème sombre */ border-radius: var(--radius-pill); letter-spacing: calc(0.02em * var(--tracking-scale)); } @@ -467,7 +471,7 @@ function humanise(s: string): string { font-weight: 800; letter-spacing: calc(0.12em * var(--tracking-scale)); text-transform: uppercase; - color: rgb(var(--accent-warm)); + color: rgb(var(--accent-warm-text)); cursor: pointer; transition: all var(--dur-2) ease; white-space: nowrap; @@ -512,7 +516,7 @@ function humanise(s: string): string { border-color: rgb(var(--line-strong)); } .nbell-filter--on { - color: rgb(var(--accent-warm)); + color: rgb(var(--accent-warm-text)); background: rgb(var(--accent-warm) / 0.08); border-color: rgb(var(--accent-warm) / 0.5); } @@ -574,13 +578,13 @@ function humanise(s: string): string { border-radius: 50%; background: rgb(var(--accent-warm) / 0.08); border: 1px solid rgb(var(--accent-warm) / 0.4); - color: rgb(var(--accent-warm)); + color: rgb(var(--accent-warm-text)); font-size: 1.2rem; } .nbell-empty-stamp--clear { background: rgba(108, 209, 97, 0.08); border-color: rgba(108, 209, 97, 0.45); - color: #6cd161; + color: rgb(var(--online)); /* jeton sémantique : cette teinte était figée sur le thème sombre */ transform: rotate(-6deg); } .nbell-loading-spin { @@ -604,6 +608,10 @@ function humanise(s: string): string { border-radius: var(--radius-md); } +.nbell-row:focus-visible { + outline: 2px solid rgb(var(--focus-ring)); + outline-offset: -2px; +} .nbell-row { position: relative; display: grid; @@ -657,11 +665,11 @@ function humanise(s: string): string { align-self: flex-start; } .nbell-row-icon--gain { - color: #6cd161; + color: rgb(var(--online)); /* jeton sémantique : cette teinte était figée sur le thème sombre */ background: rgba(108, 209, 97, 0.1); } .nbell-row-icon--spend { - color: rgb(var(--accent-warm)); + color: rgb(var(--accent-warm-text)); background: rgb(var(--accent-warm) / 0.1); } .nbell-row-icon--info { @@ -669,15 +677,15 @@ function humanise(s: string): string { background: rgb(var(--fg-default) / 0.08); } .nbell-row-icon--warn { - color: #fb923c; + color: rgb(var(--warning)); /* jeton sémantique : cette teinte était figée sur le thème sombre */ background: rgba(251, 146, 60, 0.1); } .nbell-row-icon--danger { - color: #f43f5e; + color: rgb(var(--danger)); /* jeton sémantique : cette teinte était figée sur le thème sombre */ background: rgba(244, 63, 94, 0.1); } .nbell-row-icon--social { - color: #60a5fa; + color: rgb(var(--info)); /* jeton sémantique : cette teinte était figée sur le thème sombre */ background: rgba(96, 165, 250, 0.1); } @@ -737,7 +745,7 @@ function humanise(s: string): string { background: rgb(var(--accent-warm) / 0.08); border: 1px solid rgb(var(--accent-warm) / 0.35); border-radius: var(--radius-sm); - color: rgb(var(--accent-warm)); + color: rgb(var(--accent-warm-text)); cursor: pointer; opacity: 0.45; transition: opacity var(--dur-3) ease, background var(--dur-2) ease, @@ -775,7 +783,7 @@ function humanise(s: string): string { transition: all var(--dur-2) ease; } .nbell-pop-all:hover { - color: rgb(var(--accent-warm)); + color: rgb(var(--accent-warm-text)); background: rgb(var(--accent-warm) / 0.06); } .nbell-pop-all svg { diff --git a/apps/web/app/components/NotificationToast.vue b/apps/web/app/components/NotificationToast.vue index 61ffeee7..d6bb9733 100644 --- a/apps/web/app/components/NotificationToast.vue +++ b/apps/web/app/components/NotificationToast.vue @@ -1,7 +1,18 @@ <template> <Teleport to="body"> + <!-- A live region, because this container is the only channel every + confirmation on the site travels through: a rotated key, a saved + search, a reseed request, and every error including the ones a route + hands back verbatim. Without it a screen reader hears nothing after + pressing any of them — the toast appears, animates, and leaves in + silence. `polite` rather than `assertive` so it waits for a gap instead + of cutting the reader off mid-sentence, and `aria-atomic` so a toast is + read as one message rather than as the fragments it is built from. --> <div - class="fixed top-4 right-4 z-50 flex flex-col gap-2 pointer-events-none" + class="fixed top-4 right-4 z-[60] flex flex-col gap-2 pointer-events-none" + role="status" + aria-live="polite" + aria-atomic="true" > <TransitionGroup name="notification"> <div @@ -59,29 +70,39 @@ function iconName(type: string) { } } +/* + * Les teintes du bandeau, prises au thème. + * + * C'étaient `text-green-400` / `text-red-400` / `text-yellow-400` / + * `text-blue-400` et les bordures assorties : des couleurs Tailwind fixes, sur + * une carte dont le fond suit le thème. En thème clair, `text-yellow-400` + * (#facc15) sur la surface d'une carte tombe autour de 1,5:1 — l'icône du + * bandeau d'avertissement était pratiquement invisible, et c'est le seul canal + * par lequel le site dit qu'une action a échoué. + */ function iconClass(type: string) { switch (type) { case 'success': - return 'text-green-400'; + return 'text-success'; case 'error': - return 'text-red-400'; + return 'text-error'; case 'warning': - return 'text-yellow-400'; + return 'text-warning'; default: - return 'text-blue-400'; + return 'text-info'; } } function borderClass(type: string) { switch (type) { case 'success': - return 'border-green-500/30'; + return 'border-success/30'; case 'error': - return 'border-red-500/30'; + return 'border-error/30'; case 'warning': - return 'border-yellow-500/30'; + return 'border-warning/30'; default: - return 'border-blue-500/30'; + return 'border-info/30'; } } </script> diff --git a/apps/web/app/components/ReportModal.vue b/apps/web/app/components/ReportModal.vue index 1f3a81ee..11e22ecc 100644 --- a/apps/web/app/components/ReportModal.vue +++ b/apps/web/app/components/ReportModal.vue @@ -34,7 +34,6 @@ v-if="isOpen" class="slip-backdrop" @click.self="close" - @keydown.esc="close" > <div ref="slipRef" @@ -274,35 +273,21 @@ async function submitReport() { } } -// Focus + Esc management. The window-level listener fires even -// when the user has tabbed outside the slip, and the panel auto- -// focuses on open so the very first keystroke is captured. +// Focus, Échap, verrou de défilement et restitution du focus. +// +// Il n'y avait que Échap. `role="dialog"` + `aria-modal="true"` annoncent une +// modale au lecteur d'écran, mais ne rendent pas la page inerte pour le +// navigateur : la tabulation sortait du bordereau et repartait dans la page +// masquée par l'ombrage, sans moyen évident de revenir, et la fermeture rendait +// le focus à `<body>`. La mécanique est celle de `Modal.vue`, désormais +// partagée — ce composant ne peut pas réutiliser `Modal.vue` lui-même, son +// habillage (bord dentelé, en-tête à drapeau) lui est propre. const slipRef = ref<HTMLElement | null>(null); -function handleEsc(e: KeyboardEvent) { - if (e.key === 'Escape' && props.isOpen) { - e.preventDefault(); - close(); - } -} - -watch( - () => props.isOpen, - (open) => { - if (typeof window === 'undefined') return; - if (open) { - window.addEventListener('keydown', handleEsc); - nextTick(() => slipRef.value?.focus()); - } else { - window.removeEventListener('keydown', handleEsc); - } - } -); - -onBeforeUnmount(() => { - if (typeof window !== 'undefined') { - window.removeEventListener('keydown', handleEsc); - } +useModalChrome({ + isOpen: () => props.isOpen, + panel: slipRef, + onEscape: close, }); </script> @@ -377,11 +362,11 @@ onBeforeUnmount(() => { font-weight: 800; letter-spacing: calc(0.22em * var(--tracking-scale)); text-transform: uppercase; - color: #f43f5e; + color: rgb(var(--danger)); /* jeton sémantique : cette teinte était figée sur le thème sombre */ } .slip-eyebrow-icon { font-size: 1rem; - color: #f43f5e; + color: rgb(var(--danger)); /* jeton sémantique : cette teinte était figée sur le thème sombre */ filter: drop-shadow(0 0 6px rgba(244, 63, 94, 0.35)); } .slip-close { @@ -477,7 +462,7 @@ onBeforeUnmount(() => { transition: color var(--dur-3) ease; } .slip-counter--warn { - color: #f59e0b; + color: rgb(var(--warning)); /* jeton sémantique : cette teinte était figée sur le thème sombre */ } /* ── Chip picker ────────────────────────────────────────── */ @@ -508,7 +493,7 @@ onBeforeUnmount(() => { background: rgb(var(--bg-inset)); } .chip--active { - color: #f43f5e; + color: rgb(var(--danger)); /* jeton sémantique : cette teinte était figée sur le thème sombre */ border-color: rgba(244, 63, 94, 0.55); background: rgba(244, 63, 94, 0.08); box-shadow: inset 0 0 0 1px rgba(244, 63, 94, 0.25); @@ -540,6 +525,16 @@ onBeforeUnmount(() => { border-color: rgba(244, 63, 94, 0.55); box-shadow: 0 0 0 3px rgba(244, 63, 94, 0.12); } +/* L'anneau rendu au clavier. `outline: none` ci-dessus est pour la souris, où + un changement de bordure suffit ; en `<style scoped>` la règle compile avec un + attribut de données, donc elle battait le `:focus-visible` global de `main.css` + quel que soit l'ordre — et ce champ n'avait plus aucun indicateur de focus. + `main.css` corrige exactement ça pour `.input`, avec la même explication. */ +.slip-textarea:focus-visible { + outline: 2px solid rgb(var(--focus-ring)); + outline-offset: 2px; +} + .slip-textarea::placeholder { color: rgb(var(--fg-faint)); } diff --git a/apps/web/app/components/SearchBar.vue b/apps/web/app/components/SearchBar.vue index adc2d585..139676d5 100644 --- a/apps/web/app/components/SearchBar.vue +++ b/apps/web/app/components/SearchBar.vue @@ -29,6 +29,7 @@ ref="inputRef" :value="modelValue" type="text" + :aria-label="effectiveLabel" :placeholder="effectivePlaceholder" class="search-input w-full bg-bg-secondary border border-border text-text-primary placeholder-text-muted focus:bg-bg-tertiary transition-all" :class="[ @@ -105,6 +106,9 @@ const { t } = useI18n(); const props = defineProps<{ modelValue: string; placeholder?: string; + /** Nom accessible du champ, quand l'appelant sait mieux que « rechercher » + * — la barre du forum, celle du magasin. */ + label?: string; loading?: boolean; size?: 'sm' | 'lg'; }>(); @@ -128,6 +132,24 @@ const effectivePlaceholder = computed(() => { return props.placeholder ?? t('components.searchBar.placeholder'); }); +/** + * Le nom accessible du champ. + * + * Il n'y en avait aucun : ni `<label>`, ni `aria-label`, seulement un + * `placeholder`. Un placeholder n'est pas un nom — il disparaît à la première + * frappe, et `effectivePlaceholder` le vide DÉJÀ de lui-même dès qu'un + * identifiant média est reconnu. Le champ de recherche principal du site + * s'annonçait donc « saisie de texte », et sans rien du tout une fois rempli. + * + * Court, et surtout pas le placeholder par défaut : celui de la page des + * torrents fait soixante caractères, et un lecteur d'écran relit le nom d'un + * champ en entier chaque fois qu'on y entre. L'indication reste dans le + * placeholder, où elle est une indication. + */ +const effectiveLabel = computed( + () => props.label ?? t('components.searchBar.ariaLabel'), +); + function onInput(event: Event) { const value = (event.target as HTMLInputElement).value; emit('update:modelValue', value); @@ -207,14 +229,21 @@ onUnmounted(() => { box-shadow: 0 0 0 1px rgba(108, 209, 97, 0.18); } + /* La teinte reste sur le fond et la bordure — donc l'identité média + (IMDb, TMDb) et la distinction de catégorie survivent — mais le LIBELLÉ + passe sur un jeton de premier plan. Une couleur de marque n'a pas de raison + d'être lisible sur les deux thèmes : `#f5c518` sur blanc mesure 1,50:1. + C'est exactement ce que `tagBadgeStyle()` fait déjà pour les tags, où la + couleur est choisie par un opérateur et où le texte reste donc toujours + lisible. */ .detected-icon--imdb { - color: #f5c518; + color: rgb(var(--fg-default)); } .detected-icon--tmdb { - color: #01b4e4; + color: rgb(var(--fg-default)); } .detected-icon--tvdb { - color: #6cd161; + color: rgb(var(--online)); /* jeton sémantique : cette teinte était figée sur le thème sombre */ } /* Hint chip — discreet pill below the search input. */ @@ -249,13 +278,13 @@ onUnmounted(() => { font-weight: 800; } .detection-hint--imdb .detection-tag { - color: #f5c518; + color: rgb(var(--fg-default)); } .detection-hint--tmdb .detection-tag { - color: #01b4e4; + color: rgb(var(--fg-default)); } .detection-hint--tvdb .detection-tag { - color: #6cd161; + color: rgb(var(--online)); /* jeton sémantique : cette teinte était figée sur le thème sombre */ } .detection-id { font-family: var(--font-mono); diff --git a/apps/web/app/components/SettingsGroup.vue b/apps/web/app/components/SettingsGroup.vue index f2fcb6e4..78d22f20 100644 --- a/apps/web/app/components/SettingsGroup.vue +++ b/apps/web/app/components/SettingsGroup.vue @@ -1,9 +1,25 @@ <template> <div class="md:grid md:grid-cols-3 md:gap-6 py-4 border-b border-border/50 last:border-0"> <div class="md:col-span-1 space-y-1"> - <label class="text-[10px] font-bold uppercase tracking-widest text-text-muted block"> + <!-- + Un `<label>` seulement quand il désigne quelque chose. + + C'était toujours un `<label>`, et jamais avec un `for` : le composant ne + voit pas ce que l'appelant met dans son emplacement, donc il ne pouvait + pas le désigner. Trente-cinq réglages d'administration s'annonçaient + ainsi « liste » ou « saisie de texte » sans nom, et cliquer sur le + libellé ne donnait pas le focus. L'appelant passe désormais + l'identifiant du champ qu'il rend — voir `useFieldIds()` — et sans lui + c'est un `<span>`, parce qu'un `<label>` qui ne désigne rien est un + paragraphe stylé qui se fait passer pour un libellé. + --> + <component + :is="controlId ? 'label' : 'span'" + :for="controlId" + class="text-[10px] font-bold uppercase tracking-widest text-text-muted block" + > {{ label }} - </label> + </component> <p v-if="description" class="text-xs text-text-muted leading-relaxed"> {{ description }} </p> @@ -18,5 +34,9 @@ defineProps<{ label: string; description?: string; + /** L'`id` du champ que cet emplacement rend, quand il y en a exactement un + * et qu'il est natif. Laisser vide pour un groupe de contrôles ou pour un + * emplacement qui n'en contient aucun. */ + controlId?: string; }>(); </script> diff --git a/apps/web/app/components/TorrentTable.vue b/apps/web/app/components/TorrentTable.vue index 8a38059d..60f2b447 100644 --- a/apps/web/app/components/TorrentTable.vue +++ b/apps/web/app/components/TorrentTable.vue @@ -43,12 +43,15 @@ > {{ $t('components.torrentTable.noTorrents') }} </p> - <button + <!-- `NuxtLink` plutôt qu'un `<button>` qui appelle `navigateTo` : une + destination est un lien. Le bouton était atteignable au clavier mais + ne se laissait ni ouvrir dans un onglet, ni copier, et s'annonçait + « bouton » là où le lecteur d'écran attend « lien ». --> + <NuxtLink v-for="torrent in torrents" :key="torrent.id" - type="button" + :to="`/torrents/${torrent.infoHash}`" class="w-full text-left px-3 py-3 active:bg-fg-default/5 transition-colors block" - @click="navigateTo(`/torrents/${torrent.infoHash}`)" > <div class="flex items-start gap-2"> <Icon @@ -147,7 +150,7 @@ </div> </div> </div> - </button> + </NuxtLink> </div> <!-- ≥ md: original table preserved verbatim. --> @@ -180,9 +183,18 @@ :name="getCategoryIcon(torrent.category)" class="text-text-muted text-base shrink-0" /> - <span + <!-- Un vrai lien, pas un `<span>` dans une ligne cliquable. + La ligne n'avait ni `tabindex`, ni rôle, ni gestionnaire clavier : + la tabulation sautait le catalogue entier, et le seul moyen + d'ouvrir une release était la souris. Un lien rend aussi ce que + le navigateur sait faire d'un lien — ouvrir dans un onglet, + copier l'adresse, l'annoncer comme lien. Le clic sur la ligne + reste, comme raccourci. --> + <NuxtLink + :to="`/torrents/${torrent.infoHash}`" class="text-text-primary hover:text-text-strong transition-colors font-medium truncate max-w-[300px] lg:max-w-[500px]" - >{{ torrent.name }}</span + @click.stop + >{{ torrent.name }}</NuxtLink > <span v-for="tag in torrent.tags ?? []" diff --git a/apps/web/app/components/WysiwygEditor.vue b/apps/web/app/components/WysiwygEditor.vue index 686c95f9..c102d1bb 100644 --- a/apps/web/app/components/WysiwygEditor.vue +++ b/apps/web/app/components/WysiwygEditor.vue @@ -495,7 +495,12 @@ const previewSource = computed(() => { // mode we also accept the legacy mix of MD with embedded HTML/BBCode. function inputToHtml(value: string): string { if (!value) return ''; - if (props.format === 'html') return value; + // Assaini dans les DEUX branches. Celle-ci renvoyait la valeur stockée telle + // quelle : aucun appelant ne passe `format="html"` aujourd'hui, donc c'était + // un trou latent, mais la sûreté reposait alors sur le schéma ProseMirror de + // tiptap et non sur l'assainisseur du projet — et `htmlToOutput` renvoie + // ensuite ce HTML brut au serveur. + if (props.format === 'html') return sanitizeRichHtml(value); return toEditorHtml(value); } @@ -811,7 +816,7 @@ onBeforeUnmount(() => { .we-tag-chip:hover { background: rgba(56, 189, 248, 0.12); border-color: rgba(56, 189, 248, 0.45); - color: #38bdf8; + color: rgb(var(--info)); /* jeton sémantique : cette teinte était figée sur le thème sombre */ transform: translateY(-1px); } diff --git a/apps/web/app/components/admin/Announcements.vue b/apps/web/app/components/admin/Announcements.vue index eec86ffc..9cec8e97 100644 --- a/apps/web/app/components/admin/Announcements.vue +++ b/apps/web/app/components/admin/Announcements.vue @@ -39,11 +39,13 @@ <!-- Message --> <SettingsGroup + :control-id="fid('messageLabel')" :label="$t('admin.announcements.messageLabel')" :description="$t('admin.announcements.messageDescription')" > <div class="relative"> <textarea + :id="fid('messageLabel')" v-model="message" rows="3" maxlength="500" @@ -129,6 +131,10 @@ </template> <script setup lang="ts"> +// Les libellés de `SettingsGroup` ne désignaient aucun champ : ni `for`, ni +// imbrication. Voir `useFieldIds()`. +const fid = useFieldIds(); + const { t } = useI18n(); const enabled = ref(false); @@ -145,21 +151,21 @@ const typeOptions = computed(() => [ const typeStyles = { info: { - bg: 'bg-blue-500/10', - border: 'border-blue-500/30', - text: 'text-blue-400', + bg: 'bg-info/10', + border: 'border-info/30', + text: 'text-info', icon: 'ph:info', }, warning: { - bg: 'bg-yellow-500/10', - border: 'border-yellow-500/30', - text: 'text-yellow-400', + bg: 'bg-warning/10', + border: 'border-warning/30', + text: 'text-warning', icon: 'ph:warning', }, error: { - bg: 'bg-red-500/10', - border: 'border-red-500/30', - text: 'text-red-400', + bg: 'bg-error/10', + border: 'border-error/30', + text: 'text-error', icon: 'ph:warning-circle', }, }; diff --git a/apps/web/app/components/admin/BonusEvents.vue b/apps/web/app/components/admin/BonusEvents.vue index 732a7d23..5eb660b7 100644 --- a/apps/web/app/components/admin/BonusEvents.vue +++ b/apps/web/app/components/admin/BonusEvents.vue @@ -497,6 +497,7 @@ <input v-model.number="form.downloadMultiplier" type="range" + :aria-label="$t('admin.bonusEvents.form.downloadLabelShort')" min="0" max="200" step="5" @@ -519,6 +520,7 @@ <input v-model.number="form.uploadMultiplier" type="range" + :aria-label="$t('admin.bonusEvents.form.uploadLabelShort')" min="0" max="1000" step="10" @@ -577,6 +579,7 @@ <button type="button" role="switch" + :aria-label="$t('admin.bonusEvents.form.enabled')" :aria-checked="form.enabled" class="ed-toggle" :class="{ 'ed-toggle--on': form.enabled }" @@ -887,15 +890,11 @@ function applyPreset(kind: 'freeleech' | 'silverleech' | 'custom') { } // ── Open / submit ──────────────────────────────────────────── -function isoToLocalInput(iso: string): string { - const d = new Date(iso); - const pad = (n: number) => String(n).padStart(2, '0'); - return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`; -} - -function localInputToIso(local: string): string { - return new Date(local).toISOString(); -} +// Ces deux conversions vivaient ici, correctes, pendant que la page d'un +// torrent en avait sa propre version fausse. Elles sont désormais dans +// `utils/format.ts`, d'où les deux appelants les tirent. +const isoToLocalInput = isoToDatetimeLocal; +const localInputToIso = (local: string): string => datetimeLocalToIso(local) ?? ''; function openCreate() { editing.value = null; @@ -1078,7 +1077,7 @@ function presetLabel(kind: 'freeleech' | 'silverleech' | 'custom'): string { font-weight: 700; letter-spacing: calc(0.24em * var(--tracking-scale)); text-transform: uppercase; - color: rgb(var(--accent-warm)); + color: rgb(var(--accent-warm-text)); } .tower-eyebrow-rule { display: inline-block; @@ -1163,7 +1162,7 @@ function presetLabel(kind: 'freeleech' | 'silverleech' | 'custom'): string { border-radius: 50%; background: rgb(var(--accent-warm) / 0.08); border: 1px solid rgb(var(--accent-warm) / 0.4); - color: rgb(var(--accent-warm)); + color: rgb(var(--accent-warm-text)); font-size: 1.7rem; position: relative; } @@ -1292,7 +1291,7 @@ function presetLabel(kind: 'freeleech' | 'silverleech' | 'custom'): string { font-weight: 800; letter-spacing: calc(0.3em * var(--tracking-scale)); text-transform: uppercase; - color: #f43f5e; + color: rgb(var(--danger)); /* jeton sémantique : cette teinte était figée sur le thème sombre */ flex: 1; } .onair-now { @@ -1385,7 +1384,7 @@ function presetLabel(kind: 'freeleech' | 'silverleech' | 'custom'): string { font-size: 1.5rem; font-weight: 800; letter-spacing: calc(0.02em * var(--tracking-scale)); - color: #f43f5e; + color: rgb(var(--danger)); /* jeton sémantique : cette teinte était figée sur le thème sombre */ text-shadow: 0 0 18px rgba(244, 63, 94, 0.35); line-height: 1.1; } @@ -1450,7 +1449,7 @@ function presetLabel(kind: 'freeleech' | 'silverleech' | 'custom'): string { } .schedule-head-icon { font-size: 1rem; - color: rgb(var(--accent-warm)); + color: rgb(var(--accent-warm-text)); } .schedule-head-label { font-family: var(--font-mono); @@ -1601,17 +1600,17 @@ function presetLabel(kind: 'freeleech' | 'silverleech' | 'custom'): string { } .preset-chip-icon { font-size: 0.85rem; } .preset-chip--freeleech { - color: #6cd161; + color: rgb(var(--online)); /* jeton sémantique : cette teinte était figée sur le thème sombre */ border-color: rgba(108, 209, 97, 0.4); background: rgba(108, 209, 97, 0.08); } .preset-chip--silverleech { - color: #94a3b8; + color: rgb(var(--fg-muted)); /* jeton sémantique : cette teinte était figée sur le thème sombre */ border-color: rgba(148, 163, 184, 0.4); background: rgba(148, 163, 184, 0.08); } .preset-chip--custom { - color: rgb(var(--accent-warm)); + color: rgb(var(--accent-warm-text)); border-color: rgb(var(--accent-warm) / 0.4); background: rgb(var(--accent-warm) / 0.06); } @@ -1689,7 +1688,7 @@ function presetLabel(kind: 'freeleech' | 'silverleech' | 'custom'): string { } .act--pause:hover { color: #fb923c; border-color: rgba(251, 146, 60, 0.45); } .act--resume { - color: #6cd161; + color: rgb(var(--online)); /* jeton sémantique : cette teinte était figée sur le thème sombre */ border-color: rgba(108, 209, 97, 0.4); background: rgba(108, 209, 97, 0.06); } @@ -1697,7 +1696,7 @@ function presetLabel(kind: 'freeleech' | 'silverleech' | 'custom'): string { background: rgba(108, 209, 97, 0.12); } .act--delete:hover { - color: #f43f5e; + color: rgb(var(--danger)); /* jeton sémantique : cette teinte était figée sur le thème sombre */ border-color: rgba(244, 63, 94, 0.45); background: rgba(244, 63, 94, 0.06); } @@ -1753,7 +1752,7 @@ function presetLabel(kind: 'freeleech' | 'silverleech' | 'custom'): string { font-size: 0.625rem; font-weight: 800; letter-spacing: calc(0.18em * var(--tracking-scale)); - color: rgb(var(--accent-warm)); + color: rgb(var(--accent-warm-text)); background: rgb(var(--bg-elevated)); border: 1px solid rgb(var(--accent-warm) / 0.35); padding: 0.22rem 0.45rem; @@ -1787,6 +1786,16 @@ function presetLabel(kind: 'freeleech' | 'silverleech' | 'custom'): string { border-color: rgb(var(--accent-warm) / 0.55); box-shadow: 0 0 0 3px rgb(var(--accent-warm) / 0.1); } +/* L'anneau rendu au clavier. `outline: none` ci-dessus est pour la souris, où + un changement de bordure suffit ; en `<style scoped>` la règle compile avec un + attribut de données, donc elle battait le `:focus-visible` global de `main.css` + quel que soit l'ordre — et ce champ n'avait plus aucun indicateur de focus. + `main.css` corrige exactement ça pour `.input`, avec la même explication. */ +.ed-textarea:focus-visible { + outline: 2px solid rgb(var(--focus-ring)); + outline-offset: 2px; +} + .ed-textarea { resize: vertical; min-height: 60px; @@ -1853,7 +1862,7 @@ function presetLabel(kind: 'freeleech' | 'silverleech' | 'custom'): string { .ed-preset--on { background: rgb(var(--accent-warm) / 0.08); border-color: rgb(var(--accent-warm) / 0.55); - color: rgb(var(--accent-warm)); + color: rgb(var(--accent-warm-text)); box-shadow: inset 0 0 0 1px rgb(var(--accent-warm) / 0.3); } .ed-preset--on .ed-preset-sub { color: rgb(var(--accent-warm) / 0.7); } @@ -1898,7 +1907,7 @@ function presetLabel(kind: 'freeleech' | 'silverleech' | 'custom'): string { font-family: var(--font-mono); font-size: 0.95rem; font-weight: 800; - color: rgb(var(--accent-warm)); + color: rgb(var(--accent-warm-text)); letter-spacing: calc(-0.01em * var(--tracking-scale)); } .ed-range { diff --git a/apps/web/app/components/admin/BonusRules.vue b/apps/web/app/components/admin/BonusRules.vue index 80ef9577..5ee709c3 100644 --- a/apps/web/app/components/admin/BonusRules.vue +++ b/apps/web/app/components/admin/BonusRules.vue @@ -208,7 +208,7 @@ </span> <button type="button" - class="btn btn--ghost btn--sm" + class="cbtn cbtn--ghost cbtn--sm" @click="addMilestone(r)" > <Icon name="ph:plus-bold" /> @@ -300,7 +300,7 @@ <span class="tier-list-label">{{ $t('admin.bonusRules.seedTiers.editLabel') }}</span> <button type="button" - class="btn btn--primary btn--sm" + class="cbtn cbtn--primary cbtn--sm" @click="addSeedTier" > <Icon name="ph:plus-bold" /> @@ -331,7 +331,13 @@ </label> <span class="tier-arrow"><Icon name="ph:arrow-right-bold" /></span> <label class="tier-field"> - <span class="tier-field-label">{{ $t('admin.bonusRules.fields.multiplier') }}</span> + <span class="tier-field-label"> + {{ $t('admin.bonusRules.fields.multiplier') }} + <!-- Le champ se saisit en centièmes : 150 vaut ×1,50. Rien ne le + disait — un opérateur qui tapait « 2 » pour doubler la + récompense la divisait par cinquante. --> + <span class="tier-field-note">{{ $t('admin.bonusRules.units.hundredths') }}</span> + </span> <input type="number" min="0" @@ -398,7 +404,7 @@ <span class="tier-list-label">{{ $t('admin.bonusRules.ageTiers.editLabel') }}</span> <button type="button" - class="btn btn--primary btn--sm" + class="cbtn cbtn--primary cbtn--sm" @click="addAgeTier" > <Icon name="ph:plus-bold" /> @@ -429,7 +435,10 @@ </label> <span class="tier-arrow"><Icon name="ph:arrow-right-bold" /></span> <label class="tier-field"> - <span class="tier-field-label">{{ $t('admin.bonusRules.fields.multiplier') }}</span> + <span class="tier-field-label"> + {{ $t('admin.bonusRules.fields.multiplier') }} + <span class="tier-field-note">{{ $t('admin.bonusRules.units.hundredths') }}</span> + </span> <input type="number" min="0" @@ -474,6 +483,7 @@ import TierCurve from '~/components/admin/bonus/TierCurve.vue'; const { t } = useI18n(); const notifications = useNotificationStore(); +const confirm = useConfirm(); interface BonusRule { id: string; @@ -648,12 +658,23 @@ async function addSeedTier() { async function patchSeedTier(tier: SeedTier, body: Partial<SeedTier>) { try { await $fetch(`/api/admin/bonus-rules/tiers/seed-count/${tier.id}`, { method: 'PATCH', body }); + // Ces champs s'enregistrent à la volée, sans bouton : sans accusé de + // réception, quitter le champ ne produisait rien de visible et + // l'opérateur ne pouvait pas distinguer « enregistré » de « ignoré ». + notifications.success(t('admin.bonusRules.toasts.tierUpdated')); await refresh(); } catch (err: any) { notifications.error(err?.data?.message || t('admin.bonusRules.errors.tierUpdateFailed')); } } async function deleteSeedTier(tier: SeedTier) { + const ok = await confirm({ + title: t('admin.bonusRules.confirmTierDelete.title'), + message: t('admin.bonusRules.confirmTierDelete.message'), + confirmText: t('common.delete'), + destructive: true, + }); + if (!ok) return; try { await $fetch(`/api/admin/bonus-rules/tiers/seed-count/${tier.id}`, { method: 'DELETE' }); notifications.success(t('admin.bonusRules.toasts.tierDeleted')); @@ -677,12 +698,23 @@ async function addAgeTier() { async function patchAgeTier(tier: AgeTier, body: Partial<AgeTier>) { try { await $fetch(`/api/admin/bonus-rules/tiers/age/${tier.id}`, { method: 'PATCH', body }); + // Ces champs s'enregistrent à la volée, sans bouton : sans accusé de + // réception, quitter le champ ne produisait rien de visible et + // l'opérateur ne pouvait pas distinguer « enregistré » de « ignoré ». + notifications.success(t('admin.bonusRules.toasts.tierUpdated')); await refresh(); } catch (err: any) { notifications.error(err?.data?.message || t('admin.bonusRules.errors.tierUpdateFailed')); } } async function deleteAgeTier(tier: AgeTier) { + const ok = await confirm({ + title: t('admin.bonusRules.confirmTierDelete.title'), + message: t('admin.bonusRules.confirmTierDelete.message'), + confirmText: t('common.delete'), + destructive: true, + }); + if (!ok) return; try { await $fetch(`/api/admin/bonus-rules/tiers/age/${tier.id}`, { method: 'DELETE' }); notifications.success(t('admin.bonusRules.toasts.tierDeleted')); @@ -826,7 +858,7 @@ async function deleteAgeTier(tier: AgeTier) { font-size: 0.6875rem; font-weight: 700; letter-spacing: calc(0.2em * var(--tracking-scale)); - color: rgb(var(--accent-warm)); + color: rgb(var(--accent-warm-text)); background: rgb(var(--bg-elevated)); border: 1px solid rgb(var(--accent-warm) / 0.35); padding: 0.3rem 0.55rem; @@ -1057,6 +1089,16 @@ async function deleteAgeTier(tier: AgeTier) { border-color: rgb(var(--accent-warm) / 0.6); box-shadow: 0 0 0 3px rgb(var(--accent-warm) / 0.12); } +/* L'anneau rendu au clavier. `outline: none` ci-dessus est pour la souris, où + un changement de bordure suffit ; en `<style scoped>` la règle compile avec un + attribut de données, donc elle battait le `:focus-visible` global de `main.css` + quel que soit l'ordre — et ce champ n'avait plus aucun indicateur de focus. + `main.css` corrige exactement ça pour `.input`, avec la même explication. */ +.field-input:focus-visible { + outline: 2px solid rgb(var(--focus-ring)); + outline-offset: 2px; +} + .field-input--sm { font-size: 0.85rem; padding: 0.35rem 0.55rem; @@ -1385,7 +1427,18 @@ async function deleteAgeTier(tier: AgeTier) { } /* ── Buttons ─────────────────────────────────────────────────── */ -.btn { +/* + * Le bouton du dialecte console, renommé depuis `.btn`. + * + * Il portait le nom de la classe du système de design, dans un `<style + * scoped>` — donc dans une couche sans couche, qui l'emporte sur + * `@layer components` quelle que soit la spécificité. Tant que ce composant + * n'utilise QUE le dialecte local, rien ne casse ; le jour où quelqu'un y + * écrit `class="btn btn-primary"`, il obtient silencieusement ce bouton-ci et + * cherche longtemps pourquoi. Quatre composants d'administration portaient la + * même copie de cette définition. + */ +.cbtn { display: inline-flex; align-items: center; gap: 0.4rem; @@ -1400,22 +1453,22 @@ async function deleteAgeTier(tier: AgeTier) { transition: all var(--dur-2) ease; font-family: inherit; } -.btn:hover:not(:disabled) { +.cbtn:hover:not(:disabled) { border-color: rgb(var(--accent-warm) / 0.5); background: rgb(var(--accent-warm) / 0.05); } -.btn:disabled { opacity: 0.5; cursor: not-allowed; } -.btn--ghost { background: transparent; } -.btn--primary { +.cbtn:disabled { opacity: 0.5; cursor: not-allowed; } +.cbtn--ghost { background: transparent; } +.cbtn--primary { background: rgb(var(--accent-warm)); border-color: rgb(var(--accent-warm)); color: rgb(var(--accent-warm-fg)); } -.btn--primary:hover:not(:disabled) { +.cbtn--primary:hover:not(:disabled) { background: color-mix(in srgb, rgb(var(--accent-warm)) 82%, white); border-color: color-mix(in srgb, rgb(var(--accent-warm)) 82%, white); } -.btn--sm { +.cbtn--sm { padding: 0.32rem 0.6rem; font-size: 0.7rem; } @@ -1438,4 +1491,12 @@ async function deleteAgeTier(tier: AgeTier) { background: rgba(239, 68, 68, 0.06); border-color: rgba(239, 68, 68, 0.35); } +.tier-field-note { + display: block; + font-size: 0.6rem; + font-weight: 500; + text-transform: none; + letter-spacing: 0; + color: rgb(var(--fg-subtle)); +} </style> diff --git a/apps/web/app/components/admin/Branding.vue b/apps/web/app/components/admin/Branding.vue index 03db02bb..12e0b56a 100644 --- a/apps/web/app/components/admin/Branding.vue +++ b/apps/web/app/components/admin/Branding.vue @@ -194,7 +194,7 @@ </div> <div class="dropfile-info"> <code class="dropfile-path">{{ form.siteFavicon }}</code> - <button type="button" class="dropfile-remove" @click="form.siteFavicon = null"> + <button type="button" class="dropfile-remove" @click="removeFavicon"> <Icon name="ph:trash-bold" /> {{ $t('admin.branding.remove') }} </button> @@ -492,7 +492,7 @@ </span> <button type="button" - class="btn btn--ghost" + class="cbtn cbtn--ghost" :disabled="saving" @click="discard" > @@ -500,7 +500,7 @@ </button> <button type="button" - class="btn btn--primary" + class="cbtn cbtn--primary" :disabled="!dirty || saving" @click="saveAll" > @@ -517,6 +517,7 @@ <script setup lang="ts"> const { t } = useI18n(); +const confirm = useConfirm(); const notifications = useNotificationStore(); interface BrandingForm { @@ -701,7 +702,14 @@ watch(form, () => { dirty.value = true; }, { deep: true }); // re-render of the savebar — cheap individually, but the savebar // re-renders on every keystroke, so the saving adds up. const FORM_KEYS: (keyof BrandingForm)[] = [ - 'siteName', 'siteLogo', 'siteLogoImage', 'siteFavicon', 'siteSubtitle', + // `siteFavicon` n'est PAS dans cette liste : elle est commitée par sa propre + // route au moment du téléversement et n'a jamais fait partie du corps du PUT + // (`settings.put.ts` ne connaît pas le mot « favicon »). L'y laisser la + // comptait comme une modification en attente, faisait dire à la barre + // « 1 modification non enregistrée », puis « Enregistré » — pendant que la + // favicone restait exactement ce qu'elle était. Et `discard()` prétendait + // annuler un téléversement déjà en ligne. + 'siteName', 'siteLogo', 'siteLogoImage', 'siteSubtitle', 'siteNameColor', 'siteNameBold', 'authTitle', 'authSubtitle', 'footerText', 'pageTitleSuffix', 'welcomeMessage', 'siteRules', 'heroTitle', 'heroSubtitle', 'statusBadgeText', @@ -813,6 +821,11 @@ async function uploadFavicon(file: File) { fd.append('favicon', file); const result = await $fetch<{ url: string }>('/api/admin/favicon', { method: 'POST', body: fd }); form.siteFavicon = result.url; + // La route commite immédiatement, donc c'est fait : le dire tout de suite + // plutôt que de laisser croire que ça attend un Enregistrer qui ne + // l'enverra jamais. + snapshot.value.siteFavicon = result.url; + notifications.success(t('admin.branding.faviconSaved')); } catch (err) { console.error('Failed to upload favicon:', err); notifications.error(t('admin.branding.uploadFailed')); @@ -821,6 +834,40 @@ async function uploadFavicon(file: File) { } } +/** + * Retirer la favicone — pour de vrai. + * + * Le bouton faisait `form.siteFavicon = null`, un champ local qu'aucune requête + * n'envoyait : la barre annonçait une modification, l'enregistrement disait + * « Enregistré », et la favicone était toujours servie. Comme le téléversement, + * ce geste est commité tout de suite par sa propre route ; il n'attend pas la + * barre d'enregistrement, et l'écran ne prétend plus le contraire. + */ +const removingFavicon = ref(false); +async function removeFavicon() { + if (removingFavicon.value) return; + // Le fichier est supprimé du stockage : il faudra le renvoyer. + const ok = await confirm({ + title: t('admin.branding.confirmRemoveFavicon.title'), + message: t('admin.branding.confirmRemoveFavicon.message'), + confirmText: t('common.delete'), + destructive: true, + }); + if (!ok) return; + removingFavicon.value = true; + try { + await $fetch('/api/admin/favicon', { method: 'DELETE' }); + form.siteFavicon = null; + snapshot.value.siteFavicon = null; + notifications.success(t('admin.branding.faviconRemoved')); + } catch (err: unknown) { + const e = err as { data?: { message?: string } }; + notifications.error(e?.data?.message || t('common.deleteFailed')); + } finally { + removingFavicon.value = false; + } +} + // ── Save ──────────────────────────────────────────────────── async function saveAll() { if (saving.value || !dirty.value) return; @@ -969,7 +1016,7 @@ async function discard() { font-weight: 700; letter-spacing: calc(0.24em * var(--tracking-scale)); text-transform: uppercase; - color: rgb(var(--accent-warm)); + color: rgb(var(--accent-warm-text)); } .preview-eyebrow-rule { display: inline-block; @@ -1012,7 +1059,7 @@ async function discard() { color: rgb(var(--fg-muted)); } .sample-tag-arrow { - color: rgb(var(--accent-warm)); + color: rgb(var(--accent-warm-text)); font-weight: 700; } .sample-surface { @@ -1208,7 +1255,7 @@ async function discard() { font-weight: 700; letter-spacing: calc(0.14em * var(--tracking-scale)); text-transform: uppercase; - color: rgb(var(--accent-warm)); + color: rgb(var(--accent-warm-text)); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; @@ -1290,7 +1337,7 @@ async function discard() { font-size: 0.6875rem; font-weight: 700; letter-spacing: calc(0.2em * var(--tracking-scale)); - color: rgb(var(--accent-warm)); + color: rgb(var(--accent-warm-text)); background: rgb(var(--bg-elevated)); border: 1px solid rgb(var(--accent-warm) / 0.35); padding: 0.3rem 0.55rem; @@ -1360,6 +1407,16 @@ async function discard() { border-color: rgb(var(--accent-warm) / 0.55); box-shadow: 0 0 0 3px rgb(var(--accent-warm) / 0.12); } +/* L'anneau rendu au clavier. `outline: none` ci-dessus est pour la souris, où + un changement de bordure suffit ; en `<style scoped>` la règle compile avec un + attribut de données, donc elle battait le `:focus-visible` global de `main.css` + quel que soit l'ordre — et ce champ n'avait plus aucun indicateur de focus. + `main.css` corrige exactement ça pour `.input`, avec la même explication. */ +.field-input:focus-visible { + outline: 2px solid rgb(var(--focus-ring)); + outline-offset: 2px; +} + .field-input--mono { font-family: var(--font-mono); } @@ -1485,7 +1542,7 @@ async function discard() { .segment:hover { color: rgb(var(--fg-strong)); } .segment--active { background: rgb(var(--bg-base)); - color: rgb(var(--accent-warm)); + color: rgb(var(--accent-warm-text)); box-shadow: inset 0 0 0 1px rgb(var(--accent-warm) / 0.4); } @@ -1502,7 +1559,7 @@ async function discard() { border-radius: var(--radius-sm); background: rgb(var(--bg-inset)); font-size: 1.2rem; - color: rgb(var(--accent-warm)); + color: rgb(var(--accent-warm-text)); flex-shrink: 0; } @@ -1543,7 +1600,7 @@ async function discard() { border-color: rgb(var(--accent-warm) / 0.4); } .quick-icon--active { - color: rgb(var(--accent-warm)); + color: rgb(var(--accent-warm-text)); border-color: rgb(var(--accent-warm)); background: rgb(var(--accent-warm) / 0.08); } @@ -1631,7 +1688,7 @@ async function discard() { .dropzone--over { border-color: rgb(var(--accent-warm)); background: rgb(var(--accent-warm) / 0.06); - color: rgb(var(--accent-warm)); + color: rgb(var(--accent-warm-text)); } .dropzone--compact { padding: 1.2rem 1rem; @@ -1690,7 +1747,7 @@ async function discard() { font-size: 0.6875rem; font-weight: 700; letter-spacing: calc(0.2em * var(--tracking-scale)); - color: rgb(var(--accent-warm)); + color: rgb(var(--accent-warm-text)); background: rgb(var(--bg-base)); border: 1px solid rgb(var(--accent-warm) / 0.35); padding: 0.35rem 0.6rem; @@ -1741,7 +1798,7 @@ async function discard() { font-weight: 700; letter-spacing: calc(0.1em * var(--tracking-scale)); text-transform: uppercase; - color: rgb(var(--accent-warm)); + color: rgb(var(--accent-warm-text)); } .savebar-enter-active, .savebar-leave-active { @@ -1755,7 +1812,18 @@ async function discard() { } /* ── Buttons ─────────────────────────────────────────────── */ -.btn { +/* + * Le bouton du dialecte console, renommé depuis `.btn`. + * + * Il portait le nom de la classe du système de design, dans un `<style + * scoped>` — donc dans une couche sans couche, qui l'emporte sur + * `@layer components` quelle que soit la spécificité. Tant que ce composant + * n'utilise QUE le dialecte local, rien ne casse ; le jour où quelqu'un y + * écrit `class="btn btn-primary"`, il obtient silencieusement ce bouton-ci et + * cherche longtemps pourquoi. Quatre composants d'administration portaient la + * même copie de cette définition. + */ +.cbtn { display: inline-flex; align-items: center; gap: 0.4rem; @@ -1771,18 +1839,18 @@ async function discard() { font-family: inherit; white-space: nowrap; } -.btn:hover:not(:disabled) { +.cbtn:hover:not(:disabled) { border-color: rgb(var(--accent-warm) / 0.5); background: rgb(var(--accent-warm) / 0.05); } -.btn:disabled { opacity: 0.5; cursor: not-allowed; } -.btn--ghost { background: transparent; } -.btn--primary { +.cbtn:disabled { opacity: 0.5; cursor: not-allowed; } +.cbtn--ghost { background: transparent; } +.cbtn--primary { background: rgb(var(--accent-warm)); border-color: rgb(var(--accent-warm)); color: rgb(var(--accent-warm-fg)); } -.btn--primary:hover:not(:disabled) { +.cbtn--primary:hover:not(:disabled) { background: color-mix(in srgb, rgb(var(--accent-warm)) 82%, white); border-color: color-mix(in srgb, rgb(var(--accent-warm)) 82%, white); } diff --git a/apps/web/app/components/admin/Categories.vue b/apps/web/app/components/admin/Categories.vue index baa3a307..21a490a0 100644 --- a/apps/web/app/components/admin/Categories.vue +++ b/apps/web/app/components/admin/Categories.vue @@ -182,6 +182,18 @@ <Icon name="ph:tree-structure-bold" /> {{ $t('admin.categories.row.subCount', { n: category.subcategories.length }) }} </span> + <!-- Ce que la catégorie contient. La route DELETE refuse une + catégorie qui a des torrents ; sans ce nombre, l'opérateur + l'apprenait par une 400 après avoir confirmé une boîte de + dialogue qui lui promettait le contraire. --> + <span + v-if="category.torrentCount !== undefined" + class="entry-flag entry-flag--stock" + :class="{ 'entry-flag--empty': !category.torrentCount }" + > + <Icon name="ph:file-zip-bold" /> + {{ $t('admin.categories.row.stock', { n: category.torrentCount }, category.torrentCount) }} + </span> </div> </div> @@ -265,6 +277,14 @@ <Icon name="ph:eye-slash-fill" /> {{ $t('admin.categories.row.adultBadge') }} </span> + <span + v-if="sub.torrentCount !== undefined" + class="entry-flag entry-flag--stock" + :class="{ 'entry-flag--empty': !sub.torrentCount }" + > + <Icon name="ph:file-zip-bold" /> + {{ $t('admin.categories.row.stock', { n: sub.torrentCount }, sub.torrentCount) }} + </span> </div> </div> <div class="entry-actions"> @@ -378,6 +398,7 @@ <select v-model="form.parentId" class="ed-input ed-input--select" + :aria-label="$t('admin.categories.fields.parent')" :disabled="!!editing.id || saving" > <option :value="null">{{ $t('admin.categories.fields.rootCategory') }}</option> @@ -489,6 +510,7 @@ <button type="button" role="switch" + :aria-label="$t('admin.categories.adult.title')" :aria-checked="form.isAdult" class="ed-toggle" :class="{ 'ed-toggle--on': form.isAdult }" @@ -560,6 +582,8 @@ interface Category { type?: 'movie' | 'tv' | 'game' | 'book' | null; icon?: string | null; createdAt: string; + /** Seulement avec `?withCounts=true`, donc seulement côté admin. */ + torrentCount?: number; subcategories?: Category[]; } @@ -609,7 +633,7 @@ const TYPE_OPTIONS = computed<Array<{ // callers (admin/mod) so we can keep the public path filtered. const { data: categories, refresh } = await useFetch<Category[]>( '/api/categories', - { query: { includeAdult: 'true' } } + { query: { includeAdult: 'true', withCounts: 'true' } } ); const notifications = useNotificationStore(); const confirm = useConfirm(); @@ -864,7 +888,7 @@ const deletingIds = ref(new Set<string>()); async function deleteCategory(id: string) { if (deletingIds.value.has(id)) return; - let target: { name: string } | undefined; + let target: Category | undefined; for (const cat of categories.value || []) { if (cat.id === id) { target = cat; @@ -875,6 +899,32 @@ async function deleteCategory(id: string) { } deletingIds.value.add(id); try { + // Les deux refus de la route, dits avant le clic plutôt qu'après. + // + // La boîte annonçait « les torrents qui s'y trouvent deviendront non + // classés » ; la route répond 400 « Cannot delete category with torrents » + // et ne déclasse rien. L'opérateur confirmait une suppression qui + // n'arrivait jamais, et lisait le refus en anglais dans un toast. + if (target?.subcategories?.length) { + notifications.error( + t( + 'admin.categories.deleteConfirm.blockedSubs', + { n: target.subcategories.length }, + target.subcategories.length, + ), + ); + return; + } + if (target?.torrentCount) { + notifications.error( + t( + 'admin.categories.deleteConfirm.blockedTorrents', + { n: target.torrentCount }, + target.torrentCount, + ), + ); + return; + } const ok = await confirm({ title: t('admin.categories.deleteConfirm.title'), message: target @@ -961,7 +1011,7 @@ async function seedCategories() { font-weight: 700; letter-spacing: calc(0.24em * var(--tracking-scale)); text-transform: uppercase; - color: rgb(var(--accent-warm)); + color: rgb(var(--accent-warm-text)); } .atlas-eyebrow-rule { display: inline-block; @@ -1120,7 +1170,7 @@ async function seedCategories() { font-weight: 700; letter-spacing: calc(0.14em * var(--tracking-scale)); text-transform: uppercase; - color: rgb(var(--accent-warm)); + color: rgb(var(--accent-warm-text)); } /* ── Empty + no-results states ──────────────────────────── */ @@ -1143,7 +1193,7 @@ async function seedCategories() { border-radius: 50%; background: rgb(var(--accent-warm) / 0.08); border: 1px solid rgb(var(--accent-warm) / 0.4); - color: rgb(var(--accent-warm)); + color: rgb(var(--accent-warm-text)); font-size: 1.8rem; } .atlas-empty-title { @@ -1340,7 +1390,7 @@ async function seedCategories() { font-weight: 800; letter-spacing: calc(-0.01em * var(--tracking-scale)); font-variant-numeric: tabular-nums; - color: rgb(var(--accent-warm)); + color: rgb(var(--accent-warm-text)); line-height: 1; } .entry-code--sub .entry-code-num { @@ -1405,12 +1455,12 @@ async function seedCategories() { } .entry-flag svg { font-size: 0.85rem; } .entry-flag--adult { - color: #f43f5e; + color: rgb(var(--danger)); /* jeton sémantique : cette teinte était figée sur le thème sombre */ border-color: rgba(244, 63, 94, 0.4); background: rgba(244, 63, 94, 0.06); } .entry-flag--children { - color: rgb(var(--accent-warm)); + color: rgb(var(--accent-warm-text)); border-color: rgb(var(--accent-warm) / 0.4); background: rgb(var(--accent-warm) / 0.06); } @@ -1441,7 +1491,7 @@ async function seedCategories() { transform: rotate(180deg); } .entry-toggle:hover { - color: rgb(var(--accent-warm)); + color: rgb(var(--accent-warm-text)); border-color: rgb(var(--accent-warm) / 0.4); } .entry-act { @@ -1461,11 +1511,11 @@ async function seedCategories() { color: rgb(var(--fg-strong)); } .entry-act--add:hover { - color: #6cd161; + color: rgb(var(--online)); /* jeton sémantique : cette teinte était figée sur le thème sombre */ background: rgba(108, 209, 97, 0.08); } .entry-act--delete:hover { - color: #f43f5e; + color: rgb(var(--danger)); /* jeton sémantique : cette teinte était figée sur le thème sombre */ background: rgba(244, 63, 94, 0.08); } @@ -1583,7 +1633,7 @@ async function seedCategories() { font-weight: 800; letter-spacing: calc(0.24em * var(--tracking-scale)); text-transform: uppercase; - color: rgb(var(--accent-warm)); + color: rgb(var(--accent-warm-text)); } .ed-eyebrow-rule { display: inline-block; @@ -1655,7 +1705,7 @@ async function seedCategories() { font-weight: 800; letter-spacing: calc(-0.01em * var(--tracking-scale)); font-variant-numeric: tabular-nums; - color: rgb(var(--accent-warm)); + color: rgb(var(--accent-warm-text)); line-height: 1; } .ed-card-code-dash { @@ -1707,7 +1757,7 @@ async function seedCategories() { font-weight: 700; letter-spacing: calc(0.14em * var(--tracking-scale)); text-transform: uppercase; - color: #f43f5e; + color: rgb(var(--danger)); /* jeton sémantique : cette teinte était figée sur le thème sombre */ } .ed-card-flag svg { font-size: 0.85rem; } @@ -1758,7 +1808,7 @@ async function seedCategories() { font-size: 0.625rem; font-weight: 800; letter-spacing: calc(0.18em * var(--tracking-scale)); - color: rgb(var(--accent-warm)); + color: rgb(var(--accent-warm-text)); background: rgb(var(--bg-elevated)); border: 1px solid rgb(var(--accent-warm) / 0.35); padding: 0.22rem 0.45rem; @@ -1790,6 +1840,16 @@ async function seedCategories() { border-color: rgb(var(--accent-warm) / 0.55); box-shadow: 0 0 0 3px rgb(var(--accent-warm) / 0.12); } +/* L'anneau rendu au clavier. `outline: none` ci-dessus est pour la souris, où + un changement de bordure suffit ; en `<style scoped>` la règle compile avec un + attribut de données, donc elle battait le `:focus-visible` global de `main.css` + quel que soit l'ordre — et ce champ n'avait plus aucun indicateur de focus. + `main.css` corrige exactement ça pour `.input`, avec la même explication. */ +.ed-input:focus-visible { + outline: 2px solid rgb(var(--focus-ring)); + outline-offset: 2px; +} + .ed-input--mono { font-family: var(--font-mono); letter-spacing: calc(0.04em * var(--tracking-scale)); @@ -1833,7 +1893,7 @@ async function seedCategories() { border: 1px solid rgb(var(--line-default)); border-radius: var(--radius-sm); padding: 0.05rem 0.35rem; - color: rgb(var(--accent-warm)); + color: rgb(var(--accent-warm-text)); letter-spacing: calc(0.02em * var(--tracking-scale)); } @@ -2026,4 +2086,11 @@ async function seedCategories() { @keyframes cat-spin { to { transform: rotate(360deg); } } +.entry-flag--stock { + color: rgb(var(--fg-muted)); + border-color: rgb(var(--line-default)); +} +.entry-flag--stock.entry-flag--empty { + color: rgb(var(--fg-subtle)); +} </style> diff --git a/apps/web/app/components/admin/Endpoints.vue b/apps/web/app/components/admin/Endpoints.vue index 9dd3384c..6ad82bd6 100644 --- a/apps/web/app/components/admin/Endpoints.vue +++ b/apps/web/app/components/admin/Endpoints.vue @@ -203,12 +203,12 @@ onBeforeUnmount(() => { transition: all var(--dur-2) ease; } .endp-row-copy:hover { - color: rgb(var(--accent-warm)); + color: rgb(var(--accent-warm-text)); border-color: rgb(var(--accent-warm) / 0.4); background: rgb(var(--accent-warm) / 0.06); } .endp-row-copy--copied { - color: #6cd161; + color: rgb(var(--online)); /* jeton sémantique : cette teinte était figée sur le thème sombre */ border-color: rgba(108, 209, 97, 0.5); background: rgba(108, 209, 97, 0.08); } diff --git a/apps/web/app/components/admin/FederationHealth.vue b/apps/web/app/components/admin/FederationHealth.vue index ae2e5181..ba02cdb1 100644 --- a/apps/web/app/components/admin/FederationHealth.vue +++ b/apps/web/app/components/admin/FederationHealth.vue @@ -221,6 +221,7 @@ interface Health { } const { t, locale } = useI18n(); +const notifications = useNotificationStore(); const { data, pending, refresh } = await useFetch<Health>( '/api/admin/federation/health', ); @@ -234,10 +235,15 @@ async function recover(peerId: string): Promise<void> { method: 'POST', body: { mode: 'resync' }, }); - await refresh(); - } catch { - /* the health card surfaces the peer's error on the next refresh */ + } catch (err: unknown) { + // Le commentaire disait « la carte de santé remontera l'erreur au prochain + // rafraîchissement » — mais `refresh()` était DANS le `try`, après l'appel + // qui échoue : il n'était jamais atteint. L'opérateur cliquait, le spinner + // tournait, s'arrêtait, et rien n'avait changé ni n'avait été dit. + const e = err as { data?: { message?: string } }; + notifications.error(e?.data?.message || t('common.actionFailed')); } finally { + await refresh(); recovering.value = null; } } @@ -348,6 +354,15 @@ function hostOf(url: string): string { </script> <style scoped> +/* `.fed-hint` ne vivait que dans le `<style scoped>` de `admin/federation.vue`, + la page qui rend ce composant — un style scopé n'atteint que la RACINE d'un + enfant, pas ses descendants. La phrase d'aide rendait donc du texte courant. */ +.fed-hint { + font-size: 0.6875rem; + line-height: 1.5; + color: rgb(var(--fg-subtle)); +} + .fh-body { display: flex; flex-direction: column; @@ -505,10 +520,10 @@ function hostOf(url: string): string { so an instance carrying ten times what it publishes looks like it — no figure to divide in your head. */ .fh-store { - border: 1px solid rgb(var(--border) / 0.7); + border: 1px solid rgb(var(--line-default) / 0.7); border-radius: var(--radius-lg); padding: 0.7rem 0.8rem; - background: rgb(var(--bg-subtle) / 0.35); + background: rgb(var(--bg-inset) / 0.35); } .fh-store-bar { display: flex; @@ -516,7 +531,7 @@ function hostOf(url: string): string { gap: 2px; border-radius: var(--radius-xs); overflow: hidden; - background: rgb(var(--border) / 0.4); + background: rgb(var(--line-default) / 0.4); } .fh-seg { min-width: 2px; @@ -553,7 +568,7 @@ function hostOf(url: string): string { font-family: var(--font-mono); font-size: 0.8rem; font-weight: 600; - color: rgb(var(--fg)); + color: rgb(var(--fg-default)); } .fh-store-l { font-size: 0.7rem; @@ -567,7 +582,7 @@ function hostOf(url: string): string { gap: 0.3rem; margin-top: 0.55rem; padding-top: 0.55rem; - border-top: 1px dashed rgb(var(--border) / 0.6); + border-top: 1px dashed rgb(var(--line-default) / 0.6); } .fh-kind { display: inline-flex; @@ -575,12 +590,12 @@ function hostOf(url: string): string { gap: 0.3rem; padding: 0.1rem 0.4rem; border-radius: var(--radius-sm); - background: rgb(var(--bg-subtle) / 0.8); + background: rgb(var(--bg-inset) / 0.8); font-family: var(--font-mono); font-size: 0.68rem; } .fh-kind-k { color: rgb(var(--fg-muted)); } -.fh-kind-n { color: rgb(var(--fg)); font-weight: 600; } +.fh-kind-n { color: rgb(var(--fg-default)); font-weight: 600; } .fh-peer-sep { color: rgb(var(--fg-subtle)); } .fh-recover { @@ -588,14 +603,14 @@ function hostOf(url: string): string { align-items: center; gap: 0.3rem; padding: 0.2rem 0.5rem; - border: 1px solid rgb(var(--border) / 0.8); + border: 1px solid rgb(var(--line-default) / 0.8); border-radius: var(--radius-md); - background: rgb(var(--bg-subtle) / 0.5); + background: rgb(var(--bg-inset) / 0.5); color: rgb(var(--fg-muted)); font-size: 0.72rem; cursor: pointer; } -.fh-recover:hover:not(:disabled) { color: rgb(var(--fg)); border-color: rgb(var(--accent) / 0.6); } +.fh-recover:hover:not(:disabled) { color: rgb(var(--fg-default)); border-color: rgb(var(--accent) / 0.6); } .fh-recover:disabled { opacity: 0.5; cursor: default; } .fh-peer-mirror { margin-left: auto; diff --git a/apps/web/app/components/admin/FreeleechPool.vue b/apps/web/app/components/admin/FreeleechPool.vue index bb5ccb13..835f10df 100644 --- a/apps/web/app/components/admin/FreeleechPool.vue +++ b/apps/web/app/components/admin/FreeleechPool.vue @@ -181,7 +181,7 @@ <!-- Reset action — bottom right, danger-outline style --> <button type="button" - class="btn btn--danger reservoir-reset" + class="cbtn cbtn--danger reservoir-reset" :disabled="resetting" @click="confirmReset" > @@ -226,9 +226,10 @@ <!-- Calibration grid --> <div class="cal-grid"> <div class="field field--xl"> - <label class="field-label">{{ $t('admin.freeleechPool.fields.target') }}</label> + <label class="field-label" :for="fid('target')">{{ $t('admin.freeleechPool.fields.target') }}</label> <div class="field-input field-input--unit"> <input + :id="fid('target')" v-model.number="config.pointsTarget" type="number" min="0" @@ -239,9 +240,10 @@ </div> <div class="field"> - <label class="field-label">{{ $t('admin.freeleechPool.fields.durationDays') }}</label> + <label class="field-label" :for="fid('durationDays')">{{ $t('admin.freeleechPool.fields.durationDays') }}</label> <div class="field-input field-input--unit"> <input + :id="fid('durationDays')" v-model.number="config.freeleechDurationDays" type="number" min="1" @@ -253,17 +255,18 @@ </div> <div class="field"> - <label class="field-label">{{ $t('admin.freeleechPool.fields.contributionMin') }}</label> + <label class="field-label" :for="fid('contributionMin')">{{ $t('admin.freeleechPool.fields.contributionMin') }}</label> <div class="field-input field-input--unit"> - <input v-model.number="config.contributionMin" type="number" min="1" class="input"> + <input :id="fid('contributionMin')" v-model.number="config.contributionMin" type="number" min="1" class="input"> <span class="unit">pts</span> </div> </div> <div class="field"> - <label class="field-label">{{ $t('admin.freeleechPool.fields.maxPerUserPct') }}</label> + <label class="field-label" :for="fid('maxPerUserPct')">{{ $t('admin.freeleechPool.fields.maxPerUserPct') }}</label> <div class="field-input field-input--unit"> <input + :id="fid('maxPerUserPct')" v-model.number="maxPerUserPct" type="number" min="0" @@ -277,8 +280,9 @@ </div> <div class="field field--wide"> - <label class="field-label">{{ $t('admin.freeleechPool.fields.presetAmounts') }}</label> + <label class="field-label" :for="fid('presetAmounts')">{{ $t('admin.freeleechPool.fields.presetAmounts') }}</label> <input + :id="fid('presetAmounts')" v-model="presetAmountsInput" type="text" :placeholder="$t('admin.freeleechPool.fields.presetAmountsPlaceholder')" @@ -288,13 +292,14 @@ </div> <div class="field field--wide"> - <label class="field-label">{{ $t('admin.freeleechPool.fields.eventTitle') }}</label> - <input v-model="config.eventTitleTemplate" type="text" maxlength="120" class="input"> + <label class="field-label" :for="fid('eventTitle')">{{ $t('admin.freeleechPool.fields.eventTitle') }}</label> + <input :id="fid('eventTitle')" v-model="config.eventTitleTemplate" type="text" maxlength="120" class="input"> </div> <div class="field field--wide"> - <label class="field-label">{{ $t('admin.freeleechPool.fields.eventDescription') }}</label> + <label class="field-label" :for="fid('eventDescription')">{{ $t('admin.freeleechPool.fields.eventDescription') }}</label> <textarea + :id="fid('eventDescription')" v-model="config.eventDescriptionTemplate" rows="2" maxlength="500" @@ -306,7 +311,7 @@ <div class="actions"> <button type="button" - class="btn btn--primary" + class="cbtn cbtn--primary" :disabled="savingConfig" @click="saveConfig" > @@ -353,21 +358,21 @@ <div v-if="scheduleTab === 'oneoff'" class="form"> <div class="form-grid"> <div class="field"> - <label class="field-label">{{ $t('admin.freeleechPool.fields.from') }}</label> - <input v-model="newOneoff.startsAt" type="datetime-local" class="input"> + <label class="field-label" :for="fid('from')">{{ $t('admin.freeleechPool.fields.from') }}</label> + <input :id="fid('from')" v-model="newOneoff.startsAt" type="datetime-local" class="input"> </div> <div class="field"> - <label class="field-label">{{ $t('admin.freeleechPool.fields.to') }}</label> - <input v-model="newOneoff.endsAt" type="datetime-local" class="input"> + <label class="field-label" :for="fid('to')">{{ $t('admin.freeleechPool.fields.to') }}</label> + <input :id="fid('to')" v-model="newOneoff.endsAt" type="datetime-local" class="input"> </div> <div class="field field--wide"> - <label class="field-label">{{ $t('admin.freeleechPool.fields.label') }}</label> - <input v-model="newOneoff.label" type="text" maxlength="60" class="input"> + <label class="field-label" :for="fid('label')">{{ $t('admin.freeleechPool.fields.label') }}</label> + <input :id="fid('label')" v-model="newOneoff.label" type="text" maxlength="60" class="input"> </div> </div> <button type="button" - class="btn btn--primary btn--sm" + class="cbtn cbtn--primary cbtn--sm" :disabled="!validOneoff" @click="addOneoff" > @@ -380,38 +385,38 @@ <div v-else-if="scheduleTab === 'weekly'" class="form"> <div class="form-grid"> <div class="field"> - <label class="field-label">{{ $t('admin.freeleechPool.fields.fromDay') }}</label> - <select v-model.number="newWeekly.weekdayStart" class="input"> + <label class="field-label" :for="fid('fromDay')">{{ $t('admin.freeleechPool.fields.fromDay') }}</label> + <select :id="fid('fromDay')" v-model.number="newWeekly.weekdayStart" class="input"> <option v-for="d in weekdays" :key="`s-${d.value}`" :value="d.value"> {{ d.label }} </option> </select> </div> <div class="field"> - <label class="field-label">{{ $t('admin.freeleechPool.fields.fromTime') }}</label> - <input v-model="newWeekly.timeStart" type="time" class="input"> + <label class="field-label" :for="fid('fromTime')">{{ $t('admin.freeleechPool.fields.fromTime') }}</label> + <input :id="fid('fromTime')" v-model="newWeekly.timeStart" type="time" class="input"> </div> <div class="field"> - <label class="field-label">{{ $t('admin.freeleechPool.fields.toDay') }}</label> - <select v-model.number="newWeekly.weekdayEnd" class="input"> + <label class="field-label" :for="fid('toDay')">{{ $t('admin.freeleechPool.fields.toDay') }}</label> + <select :id="fid('toDay')" v-model.number="newWeekly.weekdayEnd" class="input"> <option v-for="d in weekdays" :key="`e-${d.value}`" :value="d.value"> {{ d.label }} </option> </select> </div> <div class="field"> - <label class="field-label">{{ $t('admin.freeleechPool.fields.toTime') }}</label> - <input v-model="newWeekly.timeEnd" type="time" class="input"> + <label class="field-label" :for="fid('toTime')">{{ $t('admin.freeleechPool.fields.toTime') }}</label> + <input :id="fid('toTime')" v-model="newWeekly.timeEnd" type="time" class="input"> </div> <div class="field field--wide"> - <label class="field-label">{{ $t('admin.freeleechPool.fields.label') }}</label> - <input v-model="newWeekly.label" type="text" maxlength="60" class="input"> + <label class="field-label" :for="fid('label2')">{{ $t('admin.freeleechPool.fields.label') }}</label> + <input :id="fid('label2')" v-model="newWeekly.label" type="text" maxlength="60" class="input"> </div> </div> <p class="form-hint">{{ $t('admin.freeleechPool.fields.utcHint') }}</p> <button type="button" - class="btn btn--primary btn--sm" + class="cbtn cbtn--primary cbtn--sm" :disabled="!validWeekly" @click="addWeekly" > @@ -423,8 +428,8 @@ <!-- ─── Monthly ─── --> <div v-else-if="scheduleTab === 'monthly'" class="form"> <div class="field field--full"> - <label class="field-label">{{ $t('admin.freeleechPool.fields.daysOfMonth') }}</label> - <div class="day-grid"> + <span :id="fid('daysOfMonth')" class="field-label">{{ $t('admin.freeleechPool.fields.daysOfMonth') }}</span> + <div class="day-grid" role="group" :aria-labelledby="fid('daysOfMonth')"> <button v-for="d in 31" :key="d" @@ -440,22 +445,22 @@ </div> <div class="form-grid"> <div class="field"> - <label class="field-label">{{ $t('admin.freeleechPool.fields.fromTime') }}</label> - <input v-model="newMonthly.timeStart" type="time" class="input"> + <label class="field-label" :for="fid('fromTime2')">{{ $t('admin.freeleechPool.fields.fromTime') }}</label> + <input :id="fid('fromTime2')" v-model="newMonthly.timeStart" type="time" class="input"> </div> <div class="field"> - <label class="field-label">{{ $t('admin.freeleechPool.fields.toTime') }}</label> - <input v-model="newMonthly.timeEnd" type="time" class="input"> + <label class="field-label" :for="fid('toTime2')">{{ $t('admin.freeleechPool.fields.toTime') }}</label> + <input :id="fid('toTime2')" v-model="newMonthly.timeEnd" type="time" class="input"> </div> <div class="field field--wide"> - <label class="field-label">{{ $t('admin.freeleechPool.fields.label') }}</label> - <input v-model="newMonthly.label" type="text" maxlength="60" class="input"> + <label class="field-label" :for="fid('label3')">{{ $t('admin.freeleechPool.fields.label') }}</label> + <input :id="fid('label3')" v-model="newMonthly.label" type="text" maxlength="60" class="input"> </div> </div> <p class="form-hint">{{ $t('admin.freeleechPool.fields.utcHint') }}</p> <button type="button" - class="btn btn--primary btn--sm" + class="cbtn cbtn--primary cbtn--sm" :disabled="!validMonthly" @click="addMonthly" > @@ -468,38 +473,38 @@ <div v-else-if="scheduleTab === 'yearly'" class="form"> <div class="form-grid"> <div class="field"> - <label class="field-label">{{ $t('admin.freeleechPool.fields.fromMonth') }}</label> - <select v-model.number="newYearly.monthStart" class="input"> + <label class="field-label" :for="fid('fromMonth')">{{ $t('admin.freeleechPool.fields.fromMonth') }}</label> + <select :id="fid('fromMonth')" v-model.number="newYearly.monthStart" class="input"> <option v-for="m in months" :key="`ms-${m.value}`" :value="m.value"> {{ m.label }} </option> </select> </div> <div class="field"> - <label class="field-label">{{ $t('admin.freeleechPool.fields.fromDayOfMonth') }}</label> - <input v-model.number="newYearly.dayStart" type="number" min="1" max="31" class="input"> + <label class="field-label" :for="fid('fromDayOfMonth')">{{ $t('admin.freeleechPool.fields.fromDayOfMonth') }}</label> + <input :id="fid('fromDayOfMonth')" v-model.number="newYearly.dayStart" type="number" min="1" max="31" class="input"> </div> <div class="field"> - <label class="field-label">{{ $t('admin.freeleechPool.fields.toMonth') }}</label> - <select v-model.number="newYearly.monthEnd" class="input"> + <label class="field-label" :for="fid('toMonth')">{{ $t('admin.freeleechPool.fields.toMonth') }}</label> + <select :id="fid('toMonth')" v-model.number="newYearly.monthEnd" class="input"> <option v-for="m in months" :key="`me-${m.value}`" :value="m.value"> {{ m.label }} </option> </select> </div> <div class="field"> - <label class="field-label">{{ $t('admin.freeleechPool.fields.toDayOfMonth') }}</label> - <input v-model.number="newYearly.dayEnd" type="number" min="1" max="31" class="input"> + <label class="field-label" :for="fid('toDayOfMonth')">{{ $t('admin.freeleechPool.fields.toDayOfMonth') }}</label> + <input :id="fid('toDayOfMonth')" v-model.number="newYearly.dayEnd" type="number" min="1" max="31" class="input"> </div> <div class="field field--wide"> - <label class="field-label">{{ $t('admin.freeleechPool.fields.label') }}</label> - <input v-model="newYearly.label" type="text" maxlength="60" class="input"> + <label class="field-label" :for="fid('label4')">{{ $t('admin.freeleechPool.fields.label') }}</label> + <input :id="fid('label4')" v-model="newYearly.label" type="text" maxlength="60" class="input"> </div> </div> <p class="form-hint">{{ $t('admin.freeleechPool.fields.yearlyHint') }}</p> <button type="button" - class="btn btn--primary btn--sm" + class="cbtn cbtn--primary cbtn--sm" :disabled="!validYearly" @click="addYearly" > @@ -610,6 +615,11 @@ const { t } = useI18n(); const notifications = useNotificationStore(); const confirm = useConfirm(); +// Les libellés de ce formulaire ne désignaient rien : ni `for`, ni imbrication. +// Vingt-trois champs sans nom accessible, dans le panneau qui décide de la +// cagnotte freeleech. +const fid = useFieldIds(); + interface PoolConfig { id: number; enabled: boolean; @@ -1825,7 +1835,18 @@ select.input option { } /* Button system. */ -.btn { +/* + * Le bouton du dialecte console, renommé depuis `.btn`. + * + * Il portait le nom de la classe du système de design, dans un `<style + * scoped>` — donc dans une couche sans couche, qui l'emporte sur + * `@layer components` quelle que soit la spécificité. Tant que ce composant + * n'utilise QUE le dialecte local, rien ne casse ; le jour où quelqu'un y + * écrit `class="btn btn-primary"`, il obtient silencieusement ce bouton-ci et + * cherche longtemps pourquoi. Quatre composants d'administration portaient la + * même copie de cette définition. + */ +.cbtn { display: inline-flex; align-items: center; justify-content: center; @@ -1841,40 +1862,40 @@ select.input option { cursor: pointer; transition: all var(--dur-3) ease; } -.btn:hover:not(:disabled) { +.cbtn:hover:not(:disabled) { border-color: rgb(var(--fg-faint)); background: rgb(var(--bg-hover)); } -.btn:disabled { +.cbtn:disabled { opacity: 0.42; cursor: not-allowed; } -.btn--primary { +.cbtn--primary { background: var(--gold); border-color: var(--gold); color: rgb(var(--bg-base)); font-weight: 700; } -.btn--primary:hover:not(:disabled) { +.cbtn--primary:hover:not(:disabled) { background: var(--gold-bright); border-color: var(--gold-bright); box-shadow: 0 6px 22px -8px rgb(var(--accent-warm) / 0.55); transform: translateY(-1px); } -.btn--primary:active:not(:disabled) { +.cbtn--primary:active:not(:disabled) { transform: translateY(0); } -.btn--danger { +.cbtn--danger { background: transparent; border-color: rgba(239, 68, 68, 0.4); color: var(--alert); } -.btn--danger:hover:not(:disabled) { +.cbtn--danger:hover:not(:disabled) { background: rgba(239, 68, 68, 0.08); border-color: var(--alert); box-shadow: 0 0 20px rgba(239, 68, 68, 0.2); } -.btn--sm { +.cbtn--sm { padding: 0.55rem 0.95rem; font-size: 0.82rem; } diff --git a/apps/web/app/components/admin/HnR.vue b/apps/web/app/components/admin/HnR.vue index 529268c9..73e68d8c 100644 --- a/apps/web/app/components/admin/HnR.vue +++ b/apps/web/app/components/admin/HnR.vue @@ -17,7 +17,11 @@ </span> </div> <div class="flex gap-2"> - <select v-model="statusFilter" class="input !py-1 text-xs"> + <select + v-model="statusFilter" + class="input !py-1 text-xs" + :aria-label="$t('admin.hnr.filterLabel')" + > <option value="">{{ $t('admin.hnr.filterAll') }}</option> <option value="hnr">{{ $t('admin.hnr.filterHnr') }}</option> <option value="pending">{{ $t('admin.hnr.filterPending') }}</option> diff --git a/apps/web/app/components/admin/HomepageContent.vue b/apps/web/app/components/admin/HomepageContent.vue index 3a0293c1..124987ce 100644 --- a/apps/web/app/components/admin/HomepageContent.vue +++ b/apps/web/app/components/admin/HomepageContent.vue @@ -42,10 +42,12 @@ </SettingsGroup> <SettingsGroup + :control-id="fid('statusBadge')" :label="$t('admin.homepage.statusBadge')" :description="$t('admin.homepage.statusBadgeDescription')" > <input + :id="fid('statusBadge')" v-model="statusBadgeText" type="text" maxlength="100" @@ -116,6 +118,10 @@ </template> <script setup lang="ts"> +// Les libellés de `SettingsGroup` ne désignaient aucun champ : ni `for`, ni +// imbrication. Voir `useFieldIds()`. +const fid = useFieldIds(); + const heroTitle = ref('Trackarr'); const heroSubtitle = ref( 'High-performance, minimalist P2P tracking engine. Search through our indexed database of verified torrents.' diff --git a/apps/web/app/components/admin/InviteTree.vue b/apps/web/app/components/admin/InviteTree.vue new file mode 100644 index 00000000..742225f0 --- /dev/null +++ b/apps/web/app/components/admin/InviteTree.vue @@ -0,0 +1,414 @@ +<template> + <div class="tree"> + <div class="tree-search"> + <label class="tree-field"> + <span class="field-label">{{ $t('admin.inviteTree.lookup') }}</span> + <input + v-model="term" + type="search" + class="input" + autocomplete="off" + spellcheck="false" + :placeholder="$t('admin.inviteTree.lookupPlaceholder')" + @keydown.enter.prevent="search" + /> + </label> + <button type="button" class="btn btn-secondary" :disabled="!term.trim() || busy" @click="search"> + <Icon :name="busy ? 'ph:circle-notch' : 'ph:magnifying-glass-bold'" :class="{ 'animate-spin': busy }" /> + {{ $t('admin.inviteTree.search') }} + </button> + </div> + + <ul v-if="candidates.length > 1" class="tree-candidates"> + <li v-for="c in candidates" :key="c.id"> + <button type="button" class="tree-candidate" @click="load(c.id)"> + @{{ c.username }} + </button> + </li> + </ul> + + <!-- A legend, because "banned" was an icon and a colour with no text, and + nothing on the page said what red meant. --> + <p class="tree-legend"> + <span class="tree-legend-item tree-legend-item--banned"> + <Icon name="ph:prohibit-bold" />{{ $t('admin.inviteTree.legendBanned') }} + </span> + <span class="tree-legend-item tree-legend-item--erased"> + <Icon name="ph:user-minus" />{{ $t('admin.inviteTree.legendErased') }} + </span> + </p> + + <p v-if="error" class="tree-error" role="alert">{{ error }}</p> + + <!-- What the page is for, said before there is anything on it. The first + screen used to be an empty search box and nothing else: no title, no + lede, no legend, no hint of what a result looks like. --> + <div v-else-if="!data && !busy" class="tree-blank"> + <Icon name="ph:git-fork" class="tree-blank-icon" /> + <p>{{ $t('admin.inviteTree.blank') }}</p> + </div> + + <template v-if="data"> + <!-- Upwards: who vouched for them, nearest first. The chain reads from + the subject outwards, because that is the direction the question is + asked in. --> + <section class="tree-block"> + <h3 class="tree-block-title">{{ $t('admin.inviteTree.invitedBy') }}</h3> + <p v-if="!data.ancestors.length" class="tree-note"> + {{ $t('admin.inviteTree.noInviter') }} + </p> + <!-- Out of the `!ancestors.length` branch it used to live in, where it + could never render: `ancestorsEnd === 'depth-limit'` requires ten + ancestors to have been found, so the condition was + self-contradictory and the message was dead code. The real case — + ten found, the chain continues — fell through to the list with no + warning at all, and an operator tracing a filiation concluded the + topmost account shown was the original sponsor. --> + <p v-if="data.truncatedUp" class="tree-note tree-note--warn"> + <Icon name="ph:warning-bold" /> + {{ $t('admin.inviteTree.depthLimit', { depth: data.limits.maxDepth }) }} + </p> + <ol v-else class="tree-chain"> + <!-- `--i` is what drives the indentation, and it was never set — the + `<li>` carried no `:style`, so the fallback `0` applied to every + row and the chain rendered perfectly flat while the comment on + the CSS rule described a staircase. Read outwards from the + subject: each step right is one generation further back, which is + the direction the question is asked in. --> + <li + v-for="a in data.ancestors" + :key="a.id" + class="tree-chain-item" + :style="{ '--i': a.depth - 1 }" + > + <component + :is="a.erased ? 'span' : 'NuxtLink'" + :to="a.erased ? undefined : `/users/${a.id}`" + class="tree-node" + :class="nodeClass(a)" + > + <Icon :name="nodeIcon(a)" /> + <span class="tree-name">{{ a.erased ? $t('admin.inviteTree.erased') : a.username }}</span> + <!-- "Banned" was carried by `--danger` and a small glyph, neither + of which a screen reader perceives, on the one page where an + operator is looking for exactly that. --> + <span v-if="a.isBanned" class="stat-badge tree-banned"> + {{ $t('admin.inviteTree.legendBanned') }} + </span> + <span class="tree-depth">{{ $t('admin.inviteTree.generation', { n: a.depth }) }}</span> + </component> + </li> + </ol> + </section> + + <!-- Downwards. --> + <section class="tree-block"> + <h3 class="tree-block-title"> + {{ $t('admin.inviteTree.invited', { name: data.subject.username }) }} + </h3> + <!-- The figure the procedure runs on. The component's own note says the + point is "the one every tracker runs after a ban" — look at the + sponsor, then at their other invitees — and the count of how many of + those are banned was computable from data already on the page and + shown nowhere. --> + <p v-if="data.subject.children?.length" class="tree-summary"> + {{ $t('admin.inviteTree.summary', { + members: descendantCount, + banned: bannedDescendants, + depth: deepestGeneration, + }) }} + </p> + <p v-if="!data.subject.children?.length" class="tree-note"> + {{ $t('admin.inviteTree.noInvitees') }} + </p> + <!-- `role="tree"` so the flat sibling list the recursion produces is + announced as a hierarchy: each row carries `aria-level`, which + conveys the depth without nesting the DOM — the trade this component + was built to make. --> + <div v-else role="tree" :aria-label="$t('admin.inviteTree.invited', { name: data.subject.username })"> + <AdminInviteTreeNode + v-for="child in data.subject.children ?? []" + :key="child.id" + :node="child" + /> + </div> + <p v-if="data.truncatedDown" class="tree-note tree-note--warn"> + <Icon name="ph:warning-bold" /> + {{ $t('admin.inviteTree.truncated', { nodes: data.limits.maxNodes, depth: data.limits.maxDepth }) }} + </p> + </section> + </template> + </div> +</template> + +<script setup lang="ts"> +/** + * The invitation genealogy. + * + * Two directions from one member: who vouched for them, and who they let in. + * The procedure it serves is the one every tracker runs after a ban — whoever + * invited a cheat is either careless or complicit, and their other invitees + * are worth a look. + * + * An erased account renders as a tombstone rather than a link: the edges + * survive an erasure intact (which is what makes the tree usable at all), but + * the name behind them is gone and clicking through would show a stranger. + */ +interface TreeNode { + id: string; + username: string; + isBanned: boolean; + erased: boolean; + createdAt: string; + invitedAt: string | null; + depth: number; + children?: TreeNode[]; +} + +interface TreePayload { + subject: TreeNode; + ancestors: TreeNode[]; + ancestorsEnd: 'root' | 'depth-limit'; + truncated: boolean; + truncatedUp: boolean; + truncatedDown: boolean; + limits: { maxDepth: number; maxNodes: number }; + nodeCount: number; +} + +const { t } = useI18n(); + +const term = ref(''); +const busy = ref(false); +const error = ref(''); +const data = ref<TreePayload | null>(null); +const candidates = ref<Array<{ id: string; username: string }>>([]); + +/** + * The one line of arithmetic the page exists for. + * + * `isBanned` is on every node and `nodeCount` is on the payload, and neither was + * ever turned into the sentence a moderator opens this page to read: how many of + * this account's invitees are banned, and how far the branch goes. Walked here + * rather than asked of the server, because the whole subtree is already in hand. + */ +function walk(node: TreeNode, seen: { total: number; banned: number; depth: number }) { + for (const child of node.children ?? []) { + seen.total += 1; + if (child.isBanned) seen.banned += 1; + if (child.depth > seen.depth) seen.depth = child.depth; + walk(child, seen); + } +} +const descendants = computed(() => { + const seen = { total: 0, banned: 0, depth: 0 }; + if (data.value) walk(data.value.subject, seen); + return seen; +}); +const descendantCount = computed(() => descendants.value.total); +const bannedDescendants = computed(() => descendants.value.banned); +const deepestGeneration = computed(() => descendants.value.depth); + +/** + * A member id in the query string loads that member straight away. + * + * The procedure this page serves starts on a user's row or on their dossier, and + * there was no path from either — the only way in was to retype a username by + * hand into a page reached from the sidebar. + */ +const route = useRoute(); +onMounted(() => { + const id = route.query.userId; + if (typeof id === 'string' && id) void load(id); +}); + +async function search() { + const q = term.value.trim(); + if (!q) return; + busy.value = true; + error.value = ''; + candidates.value = []; + try { + // Reuse the admin user search rather than adding a second one — the two + // would answer "who is this" differently the day one of them changes. + const res = await $fetch<{ + items: Array<{ id: string; username: string }>; + }>('/api/admin/users', { query: { search: q, pageSize: 10 } }); + const users = res.items ?? []; + if (users.length === 0) { + error.value = t('admin.inviteTree.notFound'); + } else if (users.length === 1) { + await load(users[0]!.id); + } else { + candidates.value = users; + } + } catch (err: unknown) { + const e = err as { data?: { message?: string }; message?: string }; + error.value = e?.data?.message || e?.message || t('admin.inviteTree.failed'); + } finally { + busy.value = false; + } +} + +async function load(userId: string) { + busy.value = true; + error.value = ''; + candidates.value = []; + try { + data.value = await $fetch<TreePayload>('/api/admin/invites/tree', { + query: { userId }, + }); + } catch (err: unknown) { + const e = err as { data?: { message?: string }; message?: string }; + error.value = e?.data?.message || e?.message || t('admin.inviteTree.failed'); + } finally { + busy.value = false; + } +} + +function nodeClass(n: TreeNode) { + if (n.erased) return 'tree-node--erased'; + if (n.isBanned) return 'tree-node--banned'; + return ''; +} +function nodeIcon(n: TreeNode) { + if (n.erased) return 'ph:user-minus'; + if (n.isBanned) return 'ph:prohibit-bold'; + return 'ph:user'; +} +</script> + +<style scoped> +.tree { + display: flex; + flex-direction: column; + gap: 1.5rem; +} +.tree-search { + display: flex; + align-items: flex-end; + gap: 0.75rem; + flex-wrap: wrap; +} +.tree-field { + display: flex; + flex-direction: column; + gap: 0.3rem; + flex: 1 1 20rem; +} +.tree-candidates { + display: flex; + flex-wrap: wrap; + gap: 0.4rem; + list-style: none; + margin: 0; + padding: 0; +} +.tree-candidate { + padding: 0.2rem 0.5rem; + border: 1px solid rgb(var(--line-default)); + border-radius: 0.25rem; + font-size: 0.78rem; + background: none; + color: rgb(var(--fg-default)); + cursor: pointer; +} +.tree-candidate:hover { + border-color: rgb(var(--accent)); + color: rgb(var(--accent)); +} +.tree-error { + margin: 0; + font-size: 0.82rem; + color: rgb(var(--danger)); +} +.tree-block-title { + margin: 0 0 0.6rem; + font-size: 0.72rem; + /* Scaled, so a theme that opens the tracking does not leave these two labels + behind. */ + letter-spacing: calc(0.1em * var(--tracking-scale)); + text-transform: uppercase; + color: rgb(var(--fg-subtle)); +} +.tree-legend { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + margin: 0; + font-size: 0.7rem; + color: rgb(var(--fg-subtle)); +} +.tree-legend-item { display: inline-flex; align-items: center; gap: 0.25rem; } +.tree-legend-item--banned { color: rgb(var(--danger)); } +.tree-legend-item--erased { font-style: italic; } +.tree-blank { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.6rem; + padding: 2.5rem 1rem; + border: 1px dashed rgb(var(--line-default)); + border-radius: var(--radius-md); + text-align: center; + font-size: 0.8125rem; + color: rgb(var(--fg-subtle)); +} +.tree-blank-icon { font-size: 1.75rem; } +.tree-summary { + margin: 0 0 0.6rem; + font-size: 0.8125rem; + color: rgb(var(--fg-muted)); +} +.tree-banned { + color: rgb(var(--danger)); + border-color: rgb(var(--danger) / 0.4); +} +.tree-note { + display: flex; + align-items: center; + gap: 0.4rem; + margin: 0; + font-size: 0.8125rem; + color: rgb(var(--fg-subtle)); +} +.tree-note--warn { + margin-top: 0.75rem; + color: rgb(var(--warning)); +} +.tree-chain { + list-style: none; + margin: 0; + padding: 0; +} +/* Each generation steps right, so the chain reads as a chain rather than as a + list of unrelated names. */ +.tree-chain-item { + padding-left: calc(1.1rem * (var(--i, 0))); +} +.tree-node { + display: inline-flex; + align-items: center; + gap: 0.4rem; + /* 36px and a padded box, so the whole row is the target. At ~22px with no + vertical spacing, in a list that can run to 400 names, this was a + mis-tap machine — and every mis-tap navigates to the wrong member. */ + min-height: 2.25rem; + padding: 0.35rem 0.5rem; + margin-left: -0.5rem; + border-radius: var(--radius-sm); + font-size: 0.85rem; +} +.tree-node:hover { background: rgb(var(--fg-default) / 0.05); } +.tree-node--banned { + color: rgb(var(--danger)); +} +.tree-node--erased { + color: rgb(var(--fg-subtle)); + font-style: italic; +} +.tree-depth { + font-size: 0.7rem; + color: rgb(var(--fg-subtle)); +} +</style> diff --git a/apps/web/app/components/admin/InviteTreeNode.vue b/apps/web/app/components/admin/InviteTreeNode.vue new file mode 100644 index 00000000..02aead5a --- /dev/null +++ b/apps/web/app/components/admin/InviteTreeNode.vue @@ -0,0 +1,132 @@ +<template> + <div class="node-row" :style="`--depth: ${node.depth}`" role="treeitem" :aria-level="node.depth"> + <component + :is="node.erased ? 'span' : 'NuxtLink'" + :to="node.erased ? undefined : `/users/${node.id}`" + class="node" + :class="{ 'node--banned': node.isBanned, 'node--erased': node.erased }" + > + <Icon :name="node.erased ? 'ph:user-minus' : node.isBanned ? 'ph:prohibit-bold' : 'ph:user'" /> + <span class="node-name">{{ node.erased ? $t('admin.inviteTree.erased') : node.username }}</span> + <!-- "Banned" in words. It was a red tint and a small glyph, neither of + which a screen reader perceives and neither of which says WHICH state + red means — in a list of forty names, banned, erased and inactive + would all have looked like "not normal". The erased case already + carried its own text; this one did not. --> + <span v-if="node.isBanned && !node.erased" class="stat-badge node-badge"> + {{ $t('admin.inviteTree.legendBanned') }} + </span> + <!-- When the sponsorship happened. `invitedAt` has been on the payload + and on this interface from the start and was rendered nowhere, so an + operator could not tell a three-year-old invitation from + yesterday's — which is most of what decides whether a cluster of + bans is a pattern. --> + <time v-if="node.invitedAt" class="node-when" :datetime="node.invitedAt"> + {{ shortDay(node.invitedAt) }} + </time> + </component> + </div> + <AdminInviteTreeNode v-for="child in node.children ?? []" :key="child.id" :node="child" /> +</template> + +<script setup lang="ts"> +/** + * One member in the genealogy, plus its subtree. + * + * Recursive by name — the component renders itself for each child. The server + * caps depth and total nodes, so the recursion is bounded by the payload + * rather than by anything here. + * + * Indentation is a CSS custom property driven by the server's own `depth` + * rather than by nesting the markup, so a deep tree does not become a deep DOM. + * The cost of that choice is that there is no ancestor element to hang a + * per-generation guide line on, which is why the line below is a pseudo-element + * positioned from `--depth` rather than a `border-left` on the row: with the + * border, every generation's line landed at the same 0.25rem from the left edge + * and stacked into one bar, so past the third generation nothing said which + * parent a name belonged to. + * + * `aria-level` carries the depth for a screen reader, which is what makes the + * flat sibling list readable as a hierarchy without nesting the DOM. + */ +interface TreeNode { + id: string; + username: string; + isBanned: boolean; + erased: boolean; + depth: number; + invitedAt?: string | null; + children?: TreeNode[]; +} + +defineProps<{ node: TreeNode }>(); + +const { locale } = useI18n(); + +/** `2023-04-18` → `18 Apr 2023` / `18 avr. 2023`. */ +function shortDay(iso: string): string { + return new Date(iso).toLocaleDateString(locale.value, { + day: 'numeric', + month: 'short', + year: 'numeric', + timeZone: 'UTC', + }); +} +</script> + +<style scoped> +.node-row { + position: relative; + /* + * Bounded indentation. + * + * `1.1rem * depth` is unbounded and `MAX_DEPTH` is 10, so generation ten sat + * 176px in. On the ~350px a 390px phone actually offers, that left 174px for + * an icon, a username, a badge and a date — names wrapped or overflowed, with + * no `word-break` and no fallback. `min()` keeps the staircase legible on a + * desktop and lets it compress on a phone. + */ + padding-left: calc(min(1.1rem, 3.2vw) * var(--depth, 0)); +} +/* One guide per generation, at that generation's own offset. */ +.node-row::before { + content: ''; + position: absolute; + top: 0; + bottom: 0; + left: calc(min(1.1rem, 3.2vw) * (var(--depth, 1) - 1) + 0.25rem); + border-left: 1px solid rgb(var(--line-default)); +} +.node { + display: inline-flex; + align-items: center; + gap: 0.4rem; + /* 36px and a padded box: the whole row is the target. At ~20px with no + vertical spacing, in a list that can run to 400 entries, every mis-tap + navigates to the wrong member's dossier. */ + min-height: 2.25rem; + padding: 0.35rem 0.5rem; + border-radius: var(--radius-sm); + font-size: 0.85rem; + max-width: 100%; +} +.node:hover { background: rgb(var(--fg-default) / 0.05); } +.node-name { overflow-wrap: anywhere; } +.node--banned { + color: rgb(var(--danger)); +} +.node-badge { + color: rgb(var(--danger)); + border-color: rgb(var(--danger) / 0.4); +} +.node-when { + font-family: var(--font-mono); + font-size: 0.65rem; + color: rgb(var(--fg-subtle)); + white-space: nowrap; +} +.node--erased { + color: rgb(var(--fg-subtle)); + font-style: italic; +} +</style> diff --git a/apps/web/app/components/admin/Invites.vue b/apps/web/app/components/admin/Invites.vue index 8963b87b..80cf80e5 100644 --- a/apps/web/app/components/admin/Invites.vue +++ b/apps/web/app/components/admin/Invites.vue @@ -107,9 +107,12 @@ </div> <div class="grant-count"> - <label class="grant-label">{{ $t('admin.invites.grant.addLabel') }}</label> + <label class="grant-label" :for="fid('grantCount')"> + {{ $t('admin.invites.grant.addLabel') }} + </label> <input v-model.number="grantCount" + :id="fid('grantCount')" type="number" min="1" max="100" @@ -321,12 +324,12 @@ </div> <template #footer> <div class="confirm-footer"> - <button type="button" class="btn btn--ghost" @click="confirmOpen = false"> + <button type="button" class="cbtn cbtn--ghost" @click="confirmOpen = false"> {{ $t('admin.invites.confirm.keep') }} </button> <button type="button" - class="btn btn--danger" + class="cbtn cbtn--danger" :disabled="pendingDelete !== null" @click="confirmDelete" > @@ -347,6 +350,12 @@ import { computed, ref } from 'vue'; import Modal from '~/components/Modal.vue'; import { useNotificationStore } from '~/stores/notifications'; +// Un `<label>` sans `for` n'est qu'un paragraphe stylé : il n'annonce rien et +// le clic ne donne pas le focus. `useFieldIds` fournit des identifiants stables +// entre le rendu serveur et l'hydratation. +const fid = useFieldIds(); + + const { t } = useI18n(); interface UserMini { @@ -655,7 +664,7 @@ async function confirmDelete() { max-width: 64ch; } .adm-intro-link { - color: rgb(var(--accent-warm)); + color: rgb(var(--accent-warm-text)); text-decoration: underline; text-decoration-color: rgb(var(--accent-warm) / 0.5); text-underline-offset: 3px; @@ -791,7 +800,7 @@ async function confirmDelete() { font-size: 0.6875rem; font-weight: 700; letter-spacing: calc(0.2em * var(--tracking-scale)); - color: rgb(var(--accent-warm)); + color: rgb(var(--accent-warm-text)); background: rgb(var(--bg-elevated)); border: 1px solid rgb(var(--accent-warm) / 0.35); padding: 0.3rem 0.55rem; @@ -888,7 +897,7 @@ async function confirmDelete() { padding: 0.2rem 0.5rem; background: rgb(var(--accent-warm) / 0.12); border: 1px solid rgb(var(--accent-warm) / 0.4); - color: rgb(var(--accent-warm)); + color: rgb(var(--accent-warm-text)); border-radius: var(--radius-pill); font-size: 0.6875rem; font-weight: 600; @@ -900,7 +909,7 @@ async function confirmDelete() { border: 0; padding: 0; background: transparent; - color: rgb(var(--accent-warm)); + color: rgb(var(--accent-warm-text)); cursor: pointer; opacity: 0.75; transition: opacity var(--dur-2) ease; @@ -976,6 +985,16 @@ async function confirmDelete() { border-color: rgb(var(--accent-warm) / 0.6); box-shadow: 0 0 0 3px rgb(var(--accent-warm) / 0.12); } +/* L'anneau rendu au clavier. `outline: none` ci-dessus est pour la souris, où + un changement de bordure suffit ; en `<style scoped>` la règle compile avec un + attribut de données, donc elle battait le `:focus-visible` global de `main.css` + quel que soit l'ordre — et ce champ n'avait plus aucun indicateur de focus. + `main.css` corrige exactement ça pour `.input`, avec la même explication. */ +.grant-count-input:focus-visible { + outline: 2px solid rgb(var(--focus-ring)); + outline-offset: 2px; +} + .grant-btn { display: inline-flex; @@ -1085,7 +1104,7 @@ async function confirmDelete() { .ledger-segment:hover { color: rgb(var(--fg-strong)); } .ledger-segment--active { background: rgb(var(--bg-base)); - color: rgb(var(--accent-warm)); + color: rgb(var(--accent-warm-text)); box-shadow: inset 0 0 0 1px rgb(var(--accent-warm) / 0.4); } .ledger-segment-count { @@ -1098,7 +1117,7 @@ async function confirmDelete() { letter-spacing: calc(0.04em * var(--tracking-scale)); } .ledger-segment--active .ledger-segment-count { - color: rgb(var(--accent-warm)); + color: rgb(var(--accent-warm-text)); background: rgb(var(--accent-warm) / 0.1); } @@ -1178,7 +1197,7 @@ async function confirmDelete() { white-space: nowrap; } .entry-status--active { - color: rgb(var(--accent-warm)); + color: rgb(var(--accent-warm-text)); background: rgb(var(--accent-warm) / 0.08); } .entry-status--used { @@ -1212,7 +1231,7 @@ async function confirmDelete() { .entry-flow-user { font-size: 0.82rem; font-weight: 600; - color: rgb(var(--accent-warm)); + color: rgb(var(--accent-warm-text)); text-decoration: none; border-bottom: 1px dashed rgb(var(--accent-warm) / 0.4); transition: border-color var(--dur-2) ease; @@ -1365,7 +1384,7 @@ async function confirmDelete() { } .pager-btn:hover:not(:disabled) { border-color: rgb(var(--accent-warm) / 0.5); - color: rgb(var(--accent-warm)); + color: rgb(var(--accent-warm-text)); } .pager-btn:disabled { opacity: 0.4; cursor: not-allowed; } .pager-pos { @@ -1412,7 +1431,18 @@ async function confirmDelete() { } /* ── Buttons ─────────────────────────────────────────────── */ -.btn { +/* + * Le bouton du dialecte console, renommé depuis `.btn`. + * + * Il portait le nom de la classe du système de design, dans un `<style + * scoped>` — donc dans une couche sans couche, qui l'emporte sur + * `@layer components` quelle que soit la spécificité. Tant que ce composant + * n'utilise QUE le dialecte local, rien ne casse ; le jour où quelqu'un y + * écrit `class="btn btn-primary"`, il obtient silencieusement ce bouton-ci et + * cherche longtemps pourquoi. Quatre composants d'administration portaient la + * même copie de cette définition. + */ +.cbtn { display: inline-flex; align-items: center; gap: 0.4rem; @@ -1427,18 +1457,18 @@ async function confirmDelete() { transition: all var(--dur-2) ease; font-family: inherit; } -.btn:hover:not(:disabled) { +.cbtn:hover:not(:disabled) { border-color: rgb(var(--accent-warm) / 0.5); background: rgb(var(--accent-warm) / 0.05); } -.btn:disabled { opacity: 0.5; cursor: not-allowed; } -.btn--ghost { background: transparent; } -.btn--danger { +.cbtn:disabled { opacity: 0.5; cursor: not-allowed; } +.cbtn--ghost { background: transparent; } +.cbtn--danger { background: rgba(239, 68, 68, 0.1); border-color: rgba(239, 68, 68, 0.5); color: rgb(var(--danger)); } -.btn--danger:hover:not(:disabled) { +.cbtn--danger:hover:not(:disabled) { background: rgba(239, 68, 68, 0.18); border-color: rgb(var(--danger)); } diff --git a/apps/web/app/components/admin/MessagingBroadcast.vue b/apps/web/app/components/admin/MessagingBroadcast.vue index 1a192a0e..88cf2fe0 100644 --- a/apps/web/app/components/admin/MessagingBroadcast.vue +++ b/apps/web/app/components/admin/MessagingBroadcast.vue @@ -28,11 +28,13 @@ </p> <SettingsGroup + :control-id="fid('kind')" :label="$t('admin.broadcast.audience')" :description="$t('admin.broadcast.audienceHint')" > <div class="flex flex-wrap items-center gap-3"> <select + :id="fid('kind')" v-model="kind" class="w-full md:w-56 bg-bg-tertiary border border-border rounded px-3 py-2 text-sm text-text-primary focus:border-fg-default/20" > @@ -45,6 +47,7 @@ <select v-if="kind === 'role'" v-model="roleId" + :aria-label="$t('admin.broadcast.roleLabel')" class="w-full md:w-56 bg-bg-tertiary border border-border rounded px-3 py-2 text-sm text-text-primary focus:border-fg-default/20" > <option v-for="r in roles" :key="r.id" :value="r.id">{{ r.name }}</option> @@ -54,6 +57,7 @@ <input v-model.number="days" type="number" + :aria-label="$t('admin.broadcast.inactiveDaysLabel')" min="7" max="3650" class="w-24 bg-bg-tertiary border border-border rounded px-3 py-2 text-sm text-text-primary focus:border-fg-default/20 font-mono" @@ -64,10 +68,12 @@ </SettingsGroup> <SettingsGroup + :control-id="fid('message')" :label="$t('admin.broadcast.message')" :description="$t('admin.broadcast.messageHint')" > <textarea + :id="fid('message')" v-model="body" rows="5" maxlength="4000" @@ -114,6 +120,10 @@ </template> <script setup lang="ts"> +// Les libellés de `SettingsGroup` ne désignaient aucun champ : ni `for`, ni +// imbrication. Voir `useFieldIds()`. +const fid = useFieldIds(); + interface Row { id: string; audience: string; diff --git a/apps/web/app/components/admin/MessagingSettings.vue b/apps/web/app/components/admin/MessagingSettings.vue index 38338fd5..1cf168d3 100644 --- a/apps/web/app/components/admin/MessagingSettings.vue +++ b/apps/web/app/components/admin/MessagingSettings.vue @@ -27,10 +27,12 @@ <div class="space-y-5"> <SettingsGroup + :control-id="fid('dmScope')" :label="$t('admin.messaging.dmScope')" :description="$t('admin.messaging.dmScopeHint')" > <select + :id="fid('dmScope')" v-model="dmScope" class="w-full md:w-64 bg-bg-tertiary border border-border rounded px-3 py-2 text-sm text-text-primary focus:border-fg-default/20" > @@ -41,10 +43,12 @@ </SettingsGroup> <SettingsGroup + :control-id="fid('roomScope')" :label="$t('admin.messaging.roomScope')" :description="$t('admin.messaging.roomScopeHint')" > <select + :id="fid('roomScope')" v-model="roomScope" class="w-full md:w-64 bg-bg-tertiary border border-border rounded px-3 py-2 text-sm text-text-primary focus:border-fg-default/20" > @@ -60,10 +64,12 @@ nothing new arrives. A plain on/off forces the choice between drowning and abandoning people mid-conversation. --> <SettingsGroup + :control-id="fid('ticketsMode')" :label="$t('admin.messaging.ticketsMode')" :description="$t('admin.messaging.ticketsModeHint')" > <select + :id="fid('ticketsMode')" v-model="ticketsMode" class="w-full md:w-64 bg-bg-tertiary border border-border rounded px-3 py-2 text-sm text-text-primary focus:border-fg-default/20" > @@ -74,11 +80,13 @@ </SettingsGroup> <SettingsGroup + :control-id="fid('retentionDays')" :label="$t('admin.messaging.retentionDays')" :description="$t('admin.messaging.retentionHint')" > <div class="flex items-center gap-3"> <input + :id="fid('retentionDays')" v-model.number="retentionDays" type="number" min="1" @@ -90,11 +98,13 @@ </SettingsGroup> <SettingsGroup + :control-id="fid('dmRetentionDays')" :label="$t('admin.messaging.dmRetentionDays')" :description="$t('admin.messaging.dmRetentionHint')" > <div class="flex items-center gap-3"> <input + :id="fid('dmRetentionDays')" v-model.number="dmRetentionDays" type="number" min="0" @@ -113,11 +123,13 @@ </SettingsGroup> <SettingsGroup + :control-id="fid('slowMode')" :label="$t('admin.messaging.slowMode')" :description="$t('admin.messaging.slowModeHint')" > <div class="flex items-center gap-3"> <input + :id="fid('slowMode')" v-model.number="slowModeSeconds" type="number" min="0" @@ -163,6 +175,10 @@ </template> <script setup lang="ts"> +// Les libellés de `SettingsGroup` ne désignaient aucun champ : ni `for`, ni +// imbrication. Voir `useFieldIds()`. +const fid = useFieldIds(); + type Scope = 'off' | 'staff' | 'all'; type TicketsMode = 'off' | 'suspended' | 'on'; diff --git a/apps/web/app/components/admin/NotificationRetention.vue b/apps/web/app/components/admin/NotificationRetention.vue index 8fc35248..076b5216 100644 --- a/apps/web/app/components/admin/NotificationRetention.vue +++ b/apps/web/app/components/admin/NotificationRetention.vue @@ -26,11 +26,13 @@ <div class="space-y-5"> <SettingsGroup + :control-id="fid('readDays')" :label="$t('admin.notifications.retention.readDays')" :description="$t('admin.notifications.retention.hint')" > <div class="flex items-center gap-3"> <input + :id="fid('readDays')" v-model.number="readDays" type="number" min="1" @@ -42,11 +44,13 @@ </SettingsGroup> <SettingsGroup + :control-id="fid('unreadDays')" :label="$t('admin.notifications.retention.unreadDays')" :description="$t('admin.notifications.retention.hint')" > <div class="flex items-center gap-3"> <input + :id="fid('unreadDays')" v-model.number="unreadDays" type="number" min="1" @@ -87,6 +91,10 @@ </template> <script setup lang="ts"> +// Les libellés de `SettingsGroup` ne désignaient aucun champ : ni `for`, ni +// imbrication. Voir `useFieldIds()`. +const fid = useFieldIds(); + const readDays = ref(90); const unreadDays = ref(90); const loading = ref(false); diff --git a/apps/web/app/components/admin/Registration.vue b/apps/web/app/components/admin/Registration.vue index 7ff8168b..76bd01b8 100644 --- a/apps/web/app/components/admin/Registration.vue +++ b/apps/web/app/components/admin/Registration.vue @@ -56,12 +56,14 @@ <div class="space-y-0"> <!-- Default Invites Per User --> <SettingsGroup + :control-id="fid('defaultInvitesLabel')" v-if="mode !== 'closed'" :label="$t('admin.registration.defaultInvitesLabel')" :description="$t('admin.registration.defaultInvitesDescription')" > <div class="flex items-center gap-3"> <input + :id="fid('defaultInvitesLabel')" v-model.number="defaultInvites" type="number" min="0" @@ -74,11 +76,13 @@ <!-- Minimum Ratio --> <SettingsGroup + :control-id="fid('minRatioLabel')" :label="$t('admin.registration.minRatioLabel')" :description="$t('admin.registration.minRatioDescription')" > <div class="flex items-center gap-3"> <input + :id="fid('minRatioLabel')" v-model.number="minRatio" type="number" step="0.1" @@ -97,11 +101,13 @@ <!-- Starter Credit --> <SettingsGroup + :control-id="fid('starterCreditLabel')" :label="$t('admin.registration.starterCreditLabel')" :description="$t('admin.registration.starterCreditDescription')" > <div class="flex items-center gap-3"> <input + :id="fid('starterCreditLabel')" v-model.number="starterUploadGB" type="number" min="0" @@ -138,6 +144,10 @@ </template> <script setup lang="ts"> +// Les libellés de `SettingsGroup` ne désignaient aucun champ : ni `for`, ni +// imbrication. Voir `useFieldIds()`. +const fid = useFieldIds(); + const { t } = useI18n(); type RegistrationMode = 'closed' | 'invite-only' | 'open'; diff --git a/apps/web/app/components/admin/Reports.vue b/apps/web/app/components/admin/Reports.vue index 0d4a00f5..f11031c3 100644 --- a/apps/web/app/components/admin/Reports.vue +++ b/apps/web/app/components/admin/Reports.vue @@ -336,12 +336,14 @@ <button type="button" class="act act--accept" - :disabled="busy === report.id" + :disabled="busy === report.id || !banPanel.duration" @click="confirmBanPanel(report)" > <Icon name="ph:check-bold" /> <span>{{ - banPanel.duration === 'none' + !banPanel.duration + ? $t('admin.reports.banPanel.submitPick') + : banPanel.duration === 'none' ? $t('admin.reports.banPanel.submitResolve') : $t('admin.reports.banPanel.submitBan') }}</span> @@ -429,7 +431,9 @@ interface ReportsResponse { pagination: { page: number; limit: number; total: number; pages: number }; } -type BanDuration = 'none' | '1d' | '7d' | '1m' | '1y' | 'permanent'; +/** `''` = rien de choisi. Un état à part entière : c'est celui dans lequel le + * panneau s'ouvre, et celui dans lequel la soumission reste bloquée. */ +type BanDuration = '' | 'none' | '1d' | '7d' | '1m' | '1y' | 'permanent'; interface BanPanelState { reportId: string; @@ -702,11 +706,19 @@ function onAcceptClick(report: Report) { if (report.targetType === 'user') { banPanel.value = { reportId: report.id, - // Default to "permanent" so a hurried moderator who hits - // Submit without thinking still issues the harshest - // sanction — accepting a user-targeted report without any - // ban would silently let an offender walk. - duration: 'permanent', + // Rien de présélectionné. + // + // C'était `'permanent'`, avec le raisonnement écrit : « un modérateur + // pressé qui valide sans réfléchir applique quand même la sanction la + // plus lourde ». Le raisonnement confond « il n'a pas choisi » avec + // « aucune sanction » ; la réponse correcte à « il n'a pas choisi » est + // « alors n'applique rien tant qu'il n'a pas choisi ». Deux clics au même + // endroit — Accepter, puis le bouton d'à côté — produisaient un + // bannissement définitif, sur une page qu'on parcourt en file. + // + // Le produit sait déjà faire ça : `mod/anti-cheat.vue` désactive sa + // soumission tant qu'aucun verdict n'est choisi. + duration: '', reason: report.reason, }; return; @@ -765,7 +777,7 @@ function confirmBanPanel(report: Report) { font-weight: 700; letter-spacing: calc(0.24em * var(--tracking-scale)); text-transform: uppercase; - color: rgb(var(--accent-warm)); + color: rgb(var(--accent-warm-text)); } .archive-eyebrow-rule { display: inline-block; @@ -793,7 +805,7 @@ function confirmBanPanel(report: Report) { width: 56px; height: 56px; font-size: 1.75rem; - color: rgb(var(--accent-warm)); + color: rgb(var(--accent-warm-text)); background: rgb(var(--accent-warm) / 0.08); border: 1px solid rgb(var(--accent-warm) / 0.35); border-radius: var(--radius-md); @@ -843,17 +855,17 @@ function confirmBanPanel(report: Report) { .filter--active.filter--all { background: rgb(var(--accent-warm) / 0.1); border-color: rgb(var(--accent-warm) / 0.55); - color: rgb(var(--accent-warm)); + color: rgb(var(--accent-warm-text)); } .filter--active.filter--pending { background: rgba(244, 63, 94, 0.1); border-color: rgba(244, 63, 94, 0.55); - color: #f43f5e; + color: rgb(var(--danger)); /* jeton sémantique : cette teinte était figée sur le thème sombre */ } .filter--active.filter--resolved { background: rgba(108, 209, 97, 0.1); border-color: rgba(108, 209, 97, 0.55); - color: #6cd161; + color: rgb(var(--online)); /* jeton sémantique : cette teinte était figée sur le thème sombre */ } .filter--active.filter--dismissed { background: rgb(var(--bg-base)); @@ -945,7 +957,7 @@ function confirmBanPanel(report: Report) { .dossier-case-num { font-size: 0.6875rem; font-weight: 700; - color: rgb(var(--accent-warm)); + color: rgb(var(--accent-warm-text)); letter-spacing: calc(0.05em * var(--tracking-scale)); background: transparent; } @@ -981,13 +993,13 @@ function confirmBanPanel(report: Report) { text-transform: uppercase; } .dossier-stamp--pending { - color: #f43f5e; + color: rgb(var(--danger)); /* jeton sémantique : cette teinte était figée sur le thème sombre */ background: rgba(244, 63, 94, 0.1); border: 1px solid rgba(244, 63, 94, 0.55); box-shadow: inset 0 0 0 1px rgba(244, 63, 94, 0.18); } .dossier-stamp--resolved { - color: #6cd161; + color: rgb(var(--online)); /* jeton sémantique : cette teinte était figée sur le thème sombre */ background: rgba(108, 209, 97, 0.1); border: 1px solid rgba(108, 209, 97, 0.55); } @@ -1125,7 +1137,7 @@ function confirmBanPanel(report: Report) { } .target-tag-icon { font-size: 0.85rem; - color: rgb(var(--accent-warm)); + color: rgb(var(--accent-warm-text)); } .target-link { display: inline-flex; @@ -1137,7 +1149,7 @@ function confirmBanPanel(report: Report) { border-radius: var(--radius-sm); font-size: 0.84rem; font-weight: 600; - color: rgb(var(--accent-warm)); + color: rgb(var(--accent-warm-text)); text-decoration: none; transition: all var(--dur-2) ease; max-width: 100%; @@ -1305,7 +1317,7 @@ function confirmBanPanel(report: Report) { font-weight: 800; letter-spacing: calc(0.22em * var(--tracking-scale)); text-transform: uppercase; - color: #f43f5e; + color: rgb(var(--danger)); /* jeton sémantique : cette teinte était figée sur le thème sombre */ } .ban-panel-icon { font-size: 0.95rem; } .ban-panel-title { line-height: 1; } @@ -1342,7 +1354,7 @@ function confirmBanPanel(report: Report) { .ban-chip-icon { font-size: 0.85rem; flex-shrink: 0; } .ban-chip.is-selected { - color: #f43f5e; + color: rgb(var(--danger)); /* jeton sémantique : cette teinte était figée sur le thème sombre */ background: rgba(244, 63, 94, 0.1); border-color: rgba(244, 63, 94, 0.55); box-shadow: inset 0 0 0 1px rgba(244, 63, 94, 0.25); @@ -1376,6 +1388,16 @@ function confirmBanPanel(report: Report) { border-color: rgba(244, 63, 94, 0.55); box-shadow: 0 0 0 3px rgba(244, 63, 94, 0.12); } +/* L'anneau rendu au clavier. `outline: none` ci-dessus est pour la souris, où + un changement de bordure suffit ; en `<style scoped>` la règle compile avec un + attribut de données, donc elle battait le `:focus-visible` global de `main.css` + quel que soit l'ordre — et ce champ n'avait plus aucun indicateur de focus. + `main.css` corrige exactement ça pour `.input`, avec la même explication. */ +.ban-panel-reason:focus-visible { + outline: 2px solid rgb(var(--focus-ring)); + outline-offset: 2px; +} + .ban-panel-reason::placeholder { color: rgb(var(--fg-faint)); } .ban-panel-hint { @@ -1421,7 +1443,7 @@ function confirmBanPanel(report: Report) { border-radius: 50%; background: rgba(108, 209, 97, 0.08); border: 2px solid rgba(108, 209, 97, 0.5); - color: #6cd161; + color: rgb(var(--online)); /* jeton sémantique : cette teinte était figée sur le thème sombre */ font-size: 1.6rem; transform: rotate(-6deg); } @@ -1469,7 +1491,7 @@ function confirmBanPanel(report: Report) { } .pager-btn:hover:not(:disabled) { border-color: rgb(var(--accent-warm) / 0.5); - color: rgb(var(--accent-warm)); + color: rgb(var(--accent-warm-text)); } .pager-btn:disabled { opacity: 0.4; diff --git a/apps/web/app/components/admin/RequestSettings.vue b/apps/web/app/components/admin/RequestSettings.vue index 3c8b542b..85e4ae27 100644 --- a/apps/web/app/components/admin/RequestSettings.vue +++ b/apps/web/app/components/admin/RequestSettings.vue @@ -29,11 +29,13 @@ <div class="space-y-5"> <SettingsGroup + :control-id="fid('autoValidateHours')" :label="$t('admin.requests.autoValidateHours')" :description="$t('admin.requests.autoValidateHint')" > <div class="flex items-center gap-3"> <input + :id="fid('autoValidateHours')" v-model.number="autoValidateHours" type="number" min="1" @@ -47,11 +49,13 @@ </SettingsGroup> <SettingsGroup + :control-id="fid('maxFills')" :label="$t('admin.requests.maxFills')" :description="$t('admin.requests.maxFillsHint')" > <div class="flex items-center gap-3"> <input + :id="fid('maxFills')" v-model.number="maxFillsPerUser" type="number" min="1" @@ -94,6 +98,10 @@ </template> <script setup lang="ts"> +// Les libellés de `SettingsGroup` ne désignaient aucun champ : ni `for`, ni +// imbrication. Voir `useFieldIds()`. +const fid = useFieldIds(); + const autoValidateHours = ref(168); const maxFillsPerUser = ref(3); const loading = ref(false); diff --git a/apps/web/app/components/admin/Roles.vue b/apps/web/app/components/admin/Roles.vue index 2d1a3854..46aa83e3 100644 --- a/apps/web/app/components/admin/Roles.vue +++ b/apps/web/app/components/admin/Roles.vue @@ -44,6 +44,17 @@ <div v-if="loading" class="rc-loading"> <Icon name="ph:circle-notch" class="animate-spin text-text-muted" /> </div> + <!-- L'échec AVANT le vide : sans cette branche, un GET refusé affichait + « créez votre premier rôle » à un opérateur dont l'instance en a + peut-être douze. --> + <div v-else-if="loadFailed" class="rc-empty"> + <Icon name="ph:warning-circle" class="rc-empty__icon" /> + <p class="rc-empty__title">{{ $t('common.loadFailed') }}</p> + <p class="rc-empty__sub">{{ $t('common.loadFailedHint') }}</p> + <button type="button" class="btn btn-secondary btn-sm" @click="loadRoles"> + {{ $t('common.retry') }} + </button> + </div> <div v-else-if="roles.length === 0" class="rc-empty" @@ -331,6 +342,7 @@ <button type="button" role="switch" + :aria-label="$t('admin.roles.modal.showAsBadgeTitle')" :aria-checked="form.showAsBadge" class="toggle" :class="{ 'toggle--on': form.showAsBadge }" @@ -356,6 +368,7 @@ <button type="button" role="switch" + :aria-label="$t('admin.roles.modal.skipModerationTitle')" :aria-checked="form.canUploadWithoutModeration" class="toggle" :class="{ 'toggle--on': form.canUploadWithoutModeration }" @@ -434,6 +447,7 @@ <span class="cond-row__num">#{{ idx + 1 }}</span> <select v-model="cond.field" + :aria-label="$t('admin.roles.condField', { n: idx + 1 })" class="input cond-input cond-input--field" :disabled="saving" > @@ -447,6 +461,7 @@ </select> <select v-model="cond.comparator" + :aria-label="$t('admin.roles.condComparator', { n: idx + 1 })" class="input cond-input cond-input--op" :disabled="saving" > @@ -460,6 +475,7 @@ </select> <input v-model.number="cond.value" + :aria-label="$t('admin.roles.condValue', { n: idx + 1 })" type="number" step="any" class="input cond-input cond-input--val" @@ -639,6 +655,8 @@ const confirm = useConfirm(); const roles = ref<Role[]>([]); const loading = ref(true); +/** Vrai quand le chargement a échoué : l'état d'accueil ne doit pas s'afficher. */ +const loadFailed = ref(false); async function loadRoles() { loading.value = true; @@ -647,7 +665,12 @@ async function loadRoles() { // Nuxt's route table and the comparison blew the instantiation depth. roles.value = await $fetch<Role[]>('/api/admin/roles'); } catch (err) { + // Un `console.error` laissait `roles` à `[]`, et la vue bascule alors sur + // l'état d'ACCUEIL — « créez votre premier rôle ». Agir dessus, c'est créer + // un doublon de rôles qui existent déjà. L'échec doit se distinguer du vide. console.error('[Roles] load failed:', err); + loadFailed.value = true; + notifications.error(t('common.loadFailed')); } finally { loading.value = false; } @@ -869,7 +892,7 @@ async function recompute() { } .rc-title-accent { font-weight: 400; - color: #f5c518; + color: rgb(var(--accent-warm-text)); /* jeton sémantique : cette teinte était figée sur le thème sombre */ font-style: italic; letter-spacing: 0; font-size: 0.6em; @@ -1052,7 +1075,7 @@ async function recompute() { font-weight: 700; } .role-card__mode--auto { - color: #34d4d8; + color: rgb(var(--info)); /* jeton sémantique : cette teinte était figée sur le thème sombre */ border-color: rgba(52, 212, 216, 0.4); background: rgba(52, 212, 216, 0.08); } @@ -1068,10 +1091,10 @@ async function recompute() { font-weight: 700; } .role-card__perm--privileged { - color: #6cd161; + color: rgb(var(--online)); /* jeton sémantique : cette teinte était figée sur le thème sombre */ } .role-card__perm--badge { - color: #f5c518; + color: rgb(var(--accent-warm-text)); /* jeton sémantique : cette teinte était figée sur le thème sombre */ } .role-card__actions { display: flex; @@ -1162,7 +1185,7 @@ async function recompute() { .cond-pill__op { font-size: 0.7188rem; font-weight: 900; - color: #f5c518; + color: rgb(var(--accent-warm-text)); /* jeton sémantique : cette teinte était figée sur le thème sombre */ } .cond-pill__val { color: rgb(var(--fg-strong)); diff --git a/apps/web/app/components/admin/SearchSettings.vue b/apps/web/app/components/admin/SearchSettings.vue index c97e3df4..a77dd466 100644 --- a/apps/web/app/components/admin/SearchSettings.vue +++ b/apps/web/app/components/admin/SearchSettings.vue @@ -26,6 +26,7 @@ </p> <SettingsGroup + :control-id="fid('fields')" :label="$t('admin.search.fields')" :description="$t('admin.search.fieldsHint')" > @@ -36,6 +37,7 @@ class="flex items-start gap-3 cursor-pointer group" > <input + :id="fid('fields')" v-model="fields" type="checkbox" :value="f" @@ -58,11 +60,12 @@ </p> <SettingsGroup + :control-id="fid('fuzzy')" :label="$t('admin.search.fuzzy')" :description="$t('admin.search.fuzzyHint')" > <label class="flex items-start gap-3 cursor-pointer"> - <input v-model="fuzzy" type="checkbox" class="mt-0.5 accent-text-primary" /> + <input :id="fid('fuzzy')" v-model="fuzzy" type="checkbox" class="mt-0.5 accent-text-primary" /> <span class="text-sm text-text-primary"> {{ $t('admin.search.fuzzyLabel') }} </span> @@ -94,6 +97,10 @@ </template> <script setup lang="ts"> +// Les libellés de `SettingsGroup` ne désignaient aucun champ : ni `for`, ni +// imbrication. Voir `useFieldIds()`. +const fid = useFieldIds(); + /** Same order as on the server side, most to least obvious to enable. */ const FIELDS = ['name', 'description', 'nfo', 'tags'] as const; type Field = (typeof FIELDS)[number]; diff --git a/apps/web/app/components/admin/Shop.vue b/apps/web/app/components/admin/Shop.vue index f3a2a8a3..a792bc50 100644 --- a/apps/web/app/components/admin/Shop.vue +++ b/apps/web/app/components/admin/Shop.vue @@ -370,6 +370,7 @@ <button type="button" role="switch" + :aria-label="$t('admin.shop.fields.enabled')" :aria-checked="form.enabled" class="relative w-9 h-5 rounded-full transition-colors flex-shrink-0" :class="form.enabled ? 'bg-accent' : 'bg-fg-default/15'" @@ -512,18 +513,6 @@ function effectLabel(item: ShopItemRow): string { return ''; } -function formatSize(bytes: number): string { - if (bytes === 0) return '0 B'; - const units = ['B', 'KiB', 'MiB', 'GiB', 'TiB']; - let i = 0; - let v = bytes; - while (v >= 1024 && i < units.length - 1) { - v /= 1024; - i++; - } - return `${v.toFixed(v >= 100 || i === 0 ? 0 : 1).replace(/\.0$/, '')} ${units[i]}`; -} - function formatNumber(n: number): string { return new Intl.NumberFormat(undefined, { maximumFractionDigits: 0 }).format(n); } diff --git a/apps/web/app/components/admin/System.vue b/apps/web/app/components/admin/System.vue index 03c5f128..e27189c4 100644 --- a/apps/web/app/components/admin/System.vue +++ b/apps/web/app/components/admin/System.vue @@ -114,11 +114,11 @@ <!-- Update check failed (e.g. non-GitHub repo, 404, network) ─── --> <div v-else-if="versionInfo?.checkError" - class="p-4 bg-yellow-500/10 border border-yellow-500/30 rounded-lg space-y-2" + class="p-4 bg-warning/10 border border-warning/30 rounded-lg space-y-2" > <div class="flex items-center gap-2"> - <Icon name="ph:warning" class="text-yellow-400" /> - <p class="text-sm font-medium text-yellow-400"> + <Icon name="ph:warning" class="text-warning" /> + <p class="text-sm font-medium text-warning"> {{ $t('admin.system.checkUnavailable') }} </p> </div> @@ -215,10 +215,10 @@ server payload so admins can edit the wording in one place. --> <div v-if="updateNotes.length > 0" - class="flex items-start gap-2 p-3 bg-yellow-500/10 border border-yellow-500/30 rounded-lg" + class="flex items-start gap-2 p-3 bg-warning/10 border border-warning/30 rounded-lg" > - <Icon name="ph:warning" class="text-yellow-400 mt-0.5" /> - <ul class="text-xs text-yellow-400 space-y-1"> + <Icon name="ph:warning" class="text-warning mt-0.5" /> + <ul class="text-xs text-warning space-y-1"> <li v-for="note in updateNotes" :key="note">{{ note }}</li> </ul> </div> diff --git a/apps/web/app/components/admin/Tags.vue b/apps/web/app/components/admin/Tags.vue index 9604b216..79e9784a 100644 --- a/apps/web/app/components/admin/Tags.vue +++ b/apps/web/app/components/admin/Tags.vue @@ -524,7 +524,7 @@ function hexToRgba(hex: string, alpha: number): string { font-size: 0.6875rem; font-weight: 700; letter-spacing: calc(0.2em * var(--tracking-scale)); - color: rgb(var(--accent-warm)); + color: rgb(var(--accent-warm-text)); background: rgb(var(--bg-elevated)); border: 1px solid rgb(var(--accent-warm) / 0.35); padding: 0.3rem 0.55rem; @@ -616,6 +616,16 @@ function hexToRgba(hex: string, alpha: number): string { border-color: rgb(var(--accent-warm) / 0.6); box-shadow: 0 0 0 3px rgb(var(--accent-warm) / 0.12); } +/* L'anneau rendu au clavier. `outline: none` ci-dessus est pour la souris, où + un changement de bordure suffit ; en `<style scoped>` la règle compile avec un + attribut de données, donc elle battait le `:focus-visible` global de `main.css` + quel que soit l'ordre — et ce champ n'avait plus aucun indicateur de focus. + `main.css` corrige exactement ça pour `.input`, avec la même explication. */ +.forge-input:focus-visible { + outline: 2px solid rgb(var(--focus-ring)); + outline-offset: 2px; +} + .forge-input--mono { font-family: var(--font-mono); font-size: 0.82rem; @@ -909,7 +919,7 @@ function hexToRgba(hex: string, alpha: number): string { } .palette-empty-clear:hover { border-color: rgb(var(--accent-warm) / 0.5); - color: rgb(var(--accent-warm)); + color: rgb(var(--accent-warm-text)); } .spin { diff --git a/apps/web/app/components/admin/TemplateSettings.vue b/apps/web/app/components/admin/TemplateSettings.vue index 06a93368..4c616ddc 100644 --- a/apps/web/app/components/admin/TemplateSettings.vue +++ b/apps/web/app/components/admin/TemplateSettings.vue @@ -26,6 +26,7 @@ </p> <SettingsGroup + :control-id="fid('quotaPerUser')" :label="$t('admin.templates.quotaPerUser')" :description="$t('admin.templates.quotaHint')" > @@ -40,6 +41,7 @@ it with a 20%-opacity border left the field with no perceptible focus state at all. --> <input + :id="fid('quotaPerUser')" v-model.number="quotaPerUser" type="number" min="1" @@ -78,6 +80,10 @@ </template> <script setup lang="ts"> +// Les libellés de `SettingsGroup` ne désignaient aucun champ : ni `for`, ni +// imbrication. Voir `useFieldIds()`. +const fid = useFieldIds(); + const QUOTA_DEFAULT = 5; const QUOTA_MIN = 1; const QUOTA_MAX = 100; diff --git a/apps/web/app/components/admin/ThemeEditor.vue b/apps/web/app/components/admin/ThemeEditor.vue index 5261e9e5..ef1c370b 100644 --- a/apps/web/app/components/admin/ThemeEditor.vue +++ b/apps/web/app/components/admin/ThemeEditor.vue @@ -125,6 +125,7 @@ would inherit without storing it. --> <select v-if="def.kind === 'enum'" + :aria-label="`--${def.key}`" class="input input-xs" :value="draft.tokens[def.key] ?? ''" @change="setToken(def.key, ($event.target as HTMLSelectElement).value)" @@ -160,6 +161,7 @@ <input type="range" class="token-range" + :aria-label="`--${def.key}`" :min="def.min ?? 0" :max="def.max ?? 1" step="0.05" @@ -194,6 +196,7 @@ <input type="color" class="token-colour" + :aria-label="`--${def.key}`" :value="hexOf(draft.tokens[def.key] ?? baseValue(def.key))" @input="setToken(def.key, tripletOf(($event.target as HTMLInputElement).value))" /> @@ -225,15 +228,22 @@ </div> </div> - <!-- Contrast, on the draft as it stands. --> + <!-- Contrast, on the draft as it stands. The notice interrupts; the + group below it is the whole measurement, because 4.6:1 and 12:1 + both look like silence and only one of them survives a nudge. --> <div v-if="draftWarnings.length" class="notice notice--warn"> <Icon name="ph:warning-bold" /> <span> {{ $t('admin.themes.contrastHeader') }} <ul class="mt-1 space-y-0.5"> + <!-- `pairLabel`, not `w.pair.what`. The raw English label went + straight into a translated sentence, so a French console read + « Sous WCAG AA : muted labels on cards : 4,23:1 » — and this + file's own note about deriving the key from the token names + sits a few hundred lines below, describing the fix. --> <li v-for="w in draftWarnings" :key="w.pair.what"> {{ $t('admin.themes.contrastDetail', { - what: w.pair.what, + what: pairLabel(w.pair), ratio: w.ratio, required: w.required, }) }} @@ -242,6 +252,73 @@ </span> </div> + <div class="token-group"> + <button class="token-group-head" @click="toggleGroup('contrast')"> + <Icon + :name="openGroups.has('contrast') ? 'ph:caret-down-bold' : 'ph:caret-right-bold'" + /> + {{ $t('admin.themes.contrastGroup') }} + <span + class="token-group-count" + :class="{ 'contrast-count--fail': draftWarnings.length > 0 }" + > + {{ passingPairs }}/{{ draftReport.length }} + </span> + </button> + <div v-if="openGroups.has('contrast')" class="contrast-list"> + <p class="field-help">{{ $t('admin.themes.contrastAbout') }}</p> + <!-- Column names, once, instead of the word "minimum" repeated + twenty times down a column of its own for two distinct values. + The number alone says it, and the column shrinks from ~66px to + ~24px — which is most of what made the panel unreadable on a + phone. --> + <div class="contrast-row contrast-head" aria-hidden="true"> + <span /> + <span class="field-label">{{ $t('admin.themes.contrastCol.pair') }}</span> + <span class="field-label contrast-num">{{ $t('admin.themes.contrastCol.measured') }}</span> + <span class="field-label contrast-num">{{ $t('admin.themes.contrastCol.needs') }}</span> + <span /> + </div> + <div + v-for="r in draftReport" + :key="`${r.pair.fg}-${r.pair.bg}`" + class="contrast-row" + :class="{ 'contrast-row--fail': !r.passes }" + > + <!-- The sample is the point: it shows what the number means, in + the two colours being measured, at the size they are used — + which for a pair measured against the 3:1 large-text + threshold means large. It used to be 12px for every row, + including those, so the sample under-sold the case where the + allowance mattered most. --> + <span + class="contrast-sample" + :class="{ 'contrast-sample--large': r.pair.large }" + :style="sampleStyle(r.pair)" + >Aa</span + > + <span class="contrast-what"> + {{ pairLabel(r.pair) }} + <!-- The two tokens, named. The panel used to diagnose and stop: + an operator reading "4.23:1, needs 4.5" had to guess which + of two tokens to change, scroll back up, and reopen the + right group. --> + <code class="contrast-tokens">--{{ r.pair.fg }} / --{{ r.pair.bg }}</code> + </span> + <span class="contrast-ratio">{{ r.ratio.toFixed(2) }}</span> + <span class="contrast-min">{{ r.required }}</span> + <!-- Named, because the only audible difference between a passing + and a failing row was arithmetic. --> + <Icon + class="contrast-verdict" + :name="r.passes ? 'ph:check-bold' : 'ph:warning-bold'" + :aria-label="r.passes ? $t('admin.themes.contrastPass') : $t('admin.themes.contrastFail')" + role="img" + /> + </div> + </div> + </div> + <!-- Uploaded fonts. Owner only to ADD one; every administrator may then select it above. The panel lives in the editor because that is where an author discovers they want a face the build does not ship. --> @@ -270,7 +347,12 @@ </li> </ul> <div class="flex flex-wrap items-center gap-2"> - <select v-model="fontRole" class="input input-xs" style="width: 7rem"> + <select + v-model="fontRole" + class="input input-xs" + style="width: 7rem" + :aria-label="$t('admin.themes.fontRoleLabel')" + > <option value="sans">sans</option> <option value="mono">mono</option> <option value="display">display</option> @@ -291,6 +373,7 @@ </datalist> <input ref="fontInput" + :aria-label="$t('admin.themes.fontFileLabel')" type="file" accept=".woff2,font/woff2" class="text-2xs" @@ -403,6 +486,7 @@ import { BUILT_IN_TOKENS, FONT_STACKS, THEME_TOKENS, + contrastReport, contrastWarnings, isValidTokenValue, parseRgb, @@ -425,7 +509,8 @@ const props = defineProps<{ themeId: string | null; }>(); -const { t } = useI18n(); +const { t, te } = useI18n(); +const confirm = useConfirm(); const router = useRouter(); const { messageOf, reloadThemeStylesheet } = useThemeAdmin(); @@ -491,6 +576,13 @@ async function uploadFont() { } async function removeFont(id: string) { + const ok = await confirm({ + title: t('admin.themes.confirmRemoveFont.title'), + message: t('admin.themes.confirmRemoveFont.message'), + confirmText: t('common.delete'), + destructive: true, + }); + if (!ok) return; fontError.value = ''; try { await $fetch(`/api/admin/fonts/${id}`, { method: 'DELETE' }); @@ -746,6 +838,36 @@ function resolvedFor(row: { base: 'light' | 'dark'; tokens: Record<string, strin const draftWarnings = computed(() => draft.value ? contrastWarnings(resolvedFor(draft.value)) : [], ); +const draftReport = computed(() => + draft.value ? contrastReport(resolvedFor(draft.value)) : [], +); +const passingPairs = computed( + () => draftReport.value.filter((r) => r.passes).length, +); + +/** + * The pair's name, translated when we have a translation for it. + * + * `pair.what` is English in the shared schema — it is a code comment that + * happens to be rendered — so the key is derived from the two token names, + * which are stable, and the English falls through when a locale has not + * described that pair yet. Interpolating `what` into a translated sentence, as + * the warning line above still does, is what made a French console read + * "muted labels on cards" in the first place. + */ +function pairLabel(pair: { fg: string; bg: string; what: string }): string { + const key = `admin.themes.contrastPairs.${pair.fg}__${pair.bg}`; + return te(key) ? t(key) : pair.what; +} + +/** The two colours actually being measured, straight from the resolved draft. */ +function sampleStyle(pair: { fg: string; bg: string }) { + const tokens = draft.value ? resolvedFor(draft.value) : {}; + return { + color: `rgb(${tokens[pair.fg] ?? '0 0 0'})`, + background: `rgb(${tokens[pair.bg] ?? '255 255 255'})`, + }; +} // ── Live preview ───────────────────────────────────────────────────── @@ -944,6 +1066,83 @@ const familySuggestions = computed(() => { </script> <style scoped> +.contrast-list { display: flex; flex-direction: column; gap: 0.25rem; padding: 0.5rem 0.75rem 0.75rem; } +.contrast-row { + display: grid; + /* Fixed widths on the two number columns, so twenty ratios share a decimal + point. `auto` plus `text-align: start` meant "4.62" and "12.00" did not + line up — and comparing them by eye is the entire service this table + renders. */ + grid-template-columns: 2.25rem minmax(0, 1fr) 3.5rem 2.5rem 1rem; + align-items: center; + gap: 0.6rem; + padding: 0.3rem 0.4rem; + border-radius: var(--radius-sm); + font-size: 0.75rem; +} +.contrast-head { padding-bottom: 0; } +.contrast-head .field-label { margin-bottom: 0; } +.contrast-num { text-align: right; } +.contrast-row--fail { background: rgb(var(--warning) / 0.08); } +.contrast-sample { + display: grid; + place-items: center; + height: 1.5rem; + border: 1px solid rgb(var(--line-default)); + border-radius: var(--radius-sm); + font-weight: 600; + /* Body size, because that is where these pairs are used. The comment above + the markup claimed "at the size they are used" while rendering 12px against + a 14px body. */ + font-size: 0.875rem; +} +/* A pair measured against the 3:1 large-text threshold, shown large. */ +.contrast-sample--large { font-size: 1.25rem; font-weight: 700; } +.contrast-what { + min-width: 0; + display: flex; + flex-direction: column; + gap: 0.1rem; +} +.contrast-tokens { + font-family: var(--font-mono); + font-size: 0.625rem; + color: rgb(var(--fg-subtle)); + overflow-wrap: anywhere; +} +.contrast-ratio { + font-family: var(--font-mono); + font-variant-numeric: tabular-nums; + text-align: right; +} +.contrast-min { + font-family: var(--font-mono); + font-variant-numeric: tabular-nums; + text-align: right; + color: rgb(var(--fg-subtle)); + font-size: 0.6875rem; +} +/* + * A phone, where this panel used to become useless. + * + * There was no media query at all. Inside a card body at 390px the fixed + * columns ate ~206px of ~302px, leaving the label about 11 characters with + * `text-overflow: ellipsis` — so five consecutive rows read « Étiquettes d… ». + * The label wraps instead, and the "needs" column goes: readable before + * compact. + */ +@media (max-width: 30rem) { + .contrast-row { + grid-template-columns: 2.25rem minmax(0, 1fr) 3.5rem 1rem; + } + .contrast-what { white-space: normal; } + .contrast-min, + .contrast-head .contrast-num:last-of-type { display: none; } +} +.contrast-verdict { color: rgb(var(--online)); } +.contrast-row--fail .contrast-verdict { color: rgb(var(--warning)); } +.contrast-count--fail { color: rgb(var(--warning)); } + .field-label { display: block; font-size: 0.625rem; diff --git a/apps/web/app/components/admin/Themes.vue b/apps/web/app/components/admin/Themes.vue index eabca575..b668b352 100644 --- a/apps/web/app/components/admin/Themes.vue +++ b/apps/web/app/components/admin/Themes.vue @@ -12,10 +12,11 @@ </div> <div class="card-body"> <SettingsGroup + :control-id="fid('defaultLabel')" :label="$t('admin.themes.defaultLabel')" :description="$t('admin.themes.defaultDescription')" > - <select v-model="settings.themeDefault" class="input"> + <select :id="fid('defaultLabel')" v-model="settings.themeDefault" class="input"> <option value="system">{{ $t('admin.themes.systemOption') }}</option> <option v-for="o in pickable" :key="o.slug" :value="o.slug"> {{ o.name }} @@ -186,6 +187,10 @@ </template> <script setup lang="ts"> +// Les libellés de `SettingsGroup` ne désignaient aucun champ : ni `for`, ni +// imbrication. Voir `useFieldIds()`. +const fid = useFieldIds(); + /** * The theme list, and the two settings that point at one. * @@ -208,6 +213,7 @@ import { } from '~/composables/useThemeAdmin'; const { t } = useI18n(); +const confirm = useConfirm(); const router = useRouter(); const { messageOf, reloadThemeStylesheet } = useThemeAdmin(); @@ -244,9 +250,31 @@ function roleNames(ids: string[] | null): string { } // ── Site settings ──────────────────────────────────────────────────── const settings = ref({ themeDefault: 'dark', systemLight: 'light', systemDark: 'dark' }); + +/** Vrai dès que les trois listes déroulantes s'écartent de ce que le serveur a + * renvoyé — donc dès que l'opérateur a touché à l'une d'elles. */ +const settingsDirty = computed(() => { + const src = data.value.settings; + if (!src) return false; + return ( + settings.value.themeDefault !== src.themeDefault || + settings.value.systemLight !== src.systemLight || + settings.value.systemDark !== src.systemDark + ); +}); + watch( () => data.value.settings, - (s) => { if (s) settings.value = { ...s }; }, + (s) => { + // Toute action sur la liste des thèmes (activer, supprimer, importer) + // appelle `refresh()`, ce qui rejouait cette recopie et remettait les trois + // listes déroulantes à la valeur enregistrée. Un opérateur qui choisissait + // son thème par défaut PUIS coupait un thème avant d'enregistrer voyait son + // choix disparaître — et rien ne le disait, les listes se contentant de + // revenir en arrière. + if (!s || settingsDirty.value) return; + settings.value = { ...s }; + }, { immediate: true, deep: true }, ); const sameSystemHalves = computed( @@ -273,6 +301,19 @@ async function saveSettings() { async function toggleEnabled(row: ThemeRow) { + // Couper un thème le retire de TOUS les membres qui l'avaient choisi : ils + // basculent sur le thème par défaut à leur prochaine page, sans rien avoir + // demandé. Un clic sur une icône œil de 28 px le faisait sans un mot, à + // côté d'une suppression qui, elle, demandait confirmation. + if (row.enabled) { + const ok = await confirm({ + title: t('admin.themes.disable'), + message: t('admin.themes.confirmDisable', { name: row.name }), + confirmText: t('admin.themes.disable'), + destructive: true, + }); + if (!ok) return; + } try { await $fetch(`/api/admin/themes/${row.id}`, { method: 'PUT', @@ -305,7 +346,16 @@ function duplicate(slug: string, name: string) { } async function remove(row: ThemeRow) { - if (!confirm(t('admin.themes.confirmDelete', { name: row.name }))) return; + // Le `confirm()` du navigateur : hors thème, non traduit dans ses boutons, + // et bloquant. Le site a son propre dialogue depuis longtemps ; c'était le + // dernier appel natif de la console. + const ok = await confirm({ + title: t('admin.themes.delete'), + message: t('admin.themes.confirmDelete', { name: row.name }), + confirmText: t('admin.themes.delete'), + destructive: true, + }); + if (!ok) return; try { await $fetch(`/api/admin/themes/${row.id}`, { method: 'DELETE' }); await refresh(); diff --git a/apps/web/app/components/admin/TorznabBlacklist.vue b/apps/web/app/components/admin/TorznabBlacklist.vue index d5fdf5b9..4b7775f0 100644 --- a/apps/web/app/components/admin/TorznabBlacklist.vue +++ b/apps/web/app/components/admin/TorznabBlacklist.vue @@ -61,7 +61,7 @@ </div> <div class="text-right"> <span class="text-[10px] text-text-muted">{{ $t('admin.torznab.blacklist.expiresIn') }}</span> - <p class="text-xs font-mono text-yellow-400"> + <p class="text-xs font-mono text-warning"> {{ formatTimeRemaining(entry.expiresAt) }} </p> </div> @@ -127,6 +127,7 @@ <script setup lang="ts"> const { t } = useI18n(); +const notifications = useNotificationStore(); interface BlacklistEntry { ip: string; @@ -158,14 +159,21 @@ const unblocking = ref<string | null>(null); async function unblockUser(blockId: string) { unblocking.value = blockId; try { - await fetch('/api/admin/torznab/unblock', { + // `$fetch`, pas le `fetch` natif : ce dernier ne rejette PAS sur un statut + // 4xx ou 5xx. Le `catch` ne se déclenchait donc jamais et `refresh()` + // s'exécutait quoi qu'il arrive — un 403 (privilège perdu), un 404 (blocage + // déjà expiré) ou un 500 ressemblaient tous à un succès : le témoin + // s'arrêtait, la liste se rafraîchissait, la ligne était toujours là, et + // aucune erreur n'apparaissait. Tous les autres appels de ce fichier + // passent par `useFetch` / `$fetch`, qui lèvent. + await $fetch('/api/admin/torznab/unblock', { method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ blockId }), + body: { blockId }, }); await refresh(); - } catch (error) { - console.error('Failed to unblock user:', error); + } catch (error: unknown) { + const e = error as { data?: { message?: string }; message?: string }; + notifications.error(e?.data?.message || e?.message || t('common.actionFailed')); } finally { unblocking.value = null; } diff --git a/apps/web/app/components/admin/TorznabConfig.vue b/apps/web/app/components/admin/TorznabConfig.vue index 1adec2ff..4e5d753c 100644 --- a/apps/web/app/components/admin/TorznabConfig.vue +++ b/apps/web/app/components/admin/TorznabConfig.vue @@ -16,7 +16,7 @@ :class=" config?.enabled ? 'bg-success/20 text-success' - : 'bg-red-500/20 text-red-400' + : 'bg-error/20 text-error' " > {{ config?.enabled ? $t('admin.torznab.config.enabled') : $t('admin.torznab.config.disabled') }} @@ -114,11 +114,13 @@ <!-- API URL Info --> <SettingsGroup + :control-id="fid('endpointLabel')" :label="$t('admin.torznab.config.endpointLabel')" :description="$t('admin.torznab.config.endpointDescription')" > <div class="flex items-center gap-2"> <input + :id="fid('endpointLabel')" :value="apiUrl" readonly class="flex-1 bg-bg-tertiary border border-border rounded px-3 py-2 text-sm font-mono text-text-primary" @@ -140,6 +142,10 @@ </template> <script setup lang="ts"> +// Les libellés de `SettingsGroup` ne désignaient aucun champ : ni `for`, ni +// imbrication. Voir `useFieldIds()`. +const fid = useFieldIds(); + const branding = await useBranding(); const federationEnabled = computed(() => Boolean(branding.value?.federationEnabled), diff --git a/apps/web/app/components/admin/TorznabLogTable.vue b/apps/web/app/components/admin/TorznabLogTable.vue index bdbd9062..31e0fc9e 100644 --- a/apps/web/app/components/admin/TorznabLogTable.vue +++ b/apps/web/app/components/admin/TorznabLogTable.vue @@ -51,7 +51,7 @@ v-for="(log, index) in logs" :key="index" class="border-b border-border/50" - :class="log.error && 'bg-red-500/5'" + :class="log.error && 'bg-error/5'" > <td class="py-2 px-2"> <span class="text-xs text-text-muted font-mono"> @@ -84,7 +84,7 @@ <td class="py-2 px-2 text-center"> <span v-if="log.error" - class="text-xs text-red-400" + class="text-xs text-error" :title="log.error" > {{ $t('admin.torznab.logs.table.error') }} @@ -141,26 +141,35 @@ function formatTime(timestamp: number): string { }); } +/* + * La pastille de fonction Torznab. + * + * Les cinq fonctions n'ont pas de sens sémantique — `tvsearch` n'est ni un + * succès ni une erreur — donc la teinte reste catégorielle, sur le FOND, et le + * libellé passe par `text-text-primary`. C'est le motif que `tagBadgeStyle()` + * applique déjà ailleurs. Auparavant l'encre était `text-purple-400` &c. sur un + * fond à 20 % d'opacité : environ 2,5:1 en thème sombre, moins encore en clair. + */ function getFunctionClass(func: string): string { switch (func) { case 'search': - return 'bg-blue-500/20 text-blue-400'; + return 'bg-info/20 text-text-primary'; case 'tvsearch': - return 'bg-purple-500/20 text-purple-400'; + return 'bg-purple-500/20 text-text-primary'; case 'movie': - return 'bg-pink-500/20 text-pink-400'; + return 'bg-pink-500/20 text-text-primary'; case 'caps': - return 'bg-gray-500/20 text-gray-400'; + return 'bg-fg-default/10 text-text-secondary'; case 'download': - return 'bg-green-500/20 text-green-400'; + return 'bg-success/20 text-text-primary'; default: - return 'bg-gray-500/20 text-gray-400'; + return 'bg-fg-default/10 text-text-secondary'; } } function getResponseTimeClass(time: number): string { if (time < 100) return 'text-success'; - if (time < 500) return 'text-yellow-400'; - return 'text-red-400'; + if (time < 500) return 'text-warning'; + return 'text-error'; } </script> diff --git a/apps/web/app/components/admin/TorznabRateLimiting.vue b/apps/web/app/components/admin/TorznabRateLimiting.vue index 4ba1af0e..1e9b891a 100644 --- a/apps/web/app/components/admin/TorznabRateLimiting.vue +++ b/apps/web/app/components/admin/TorznabRateLimiting.vue @@ -17,11 +17,13 @@ <!-- Time Window --> <SettingsGroup + :control-id="fid('windowLabel')" :label="$t('admin.torznab.rateLimiting.windowLabel')" :description="$t('admin.torznab.rateLimiting.windowDescription')" > <div class="flex items-center gap-3"> <input + :id="fid('windowLabel')" v-model.number="localWindow" type="number" min="10" @@ -38,11 +40,13 @@ <!-- Search Rate Limit --> <SettingsGroup + :control-id="fid('searchLimitLabel')" :label="$t('admin.torznab.rateLimiting.searchLimitLabel')" :description="$t('admin.torznab.rateLimiting.searchLimitDescription')" > <div class="flex items-center gap-3"> <input + :id="fid('searchLimitLabel')" v-model.number="localSearchLimit" type="number" min="1" @@ -71,11 +75,13 @@ <!-- Download Rate Limit --> <SettingsGroup + :control-id="fid('downloadLimitLabel')" :label="$t('admin.torznab.rateLimiting.downloadLimitLabel')" :description="$t('admin.torznab.rateLimiting.downloadLimitDescription')" > <div class="flex items-center gap-3"> <input + :id="fid('downloadLimitLabel')" v-model.number="localDownloadLimit" type="number" min="1" @@ -143,6 +149,10 @@ </template> <script setup lang="ts"> +// Les libellés de `SettingsGroup` ne désignaient aucun champ : ni `for`, ni +// imbrication. Voir `useFieldIds()`. +const fid = useFieldIds(); + interface TorznabConfig { enabled: boolean; rateLimitSearch: number; diff --git a/apps/web/app/components/admin/TorznabStats.vue b/apps/web/app/components/admin/TorznabStats.vue index f28c0188..434ea729 100644 --- a/apps/web/app/components/admin/TorznabStats.vue +++ b/apps/web/app/components/admin/TorznabStats.vue @@ -111,7 +111,7 @@ </p> <p class="text-2xl font-bold" - :class="stats?.errorsCount ? 'text-red-400' : 'text-text-primary'" + :class="stats?.errorsCount ? 'text-error' : 'text-text-primary'" > {{ formatNumber(stats?.errorsCount || 0) }} </p> diff --git a/apps/web/app/components/admin/TorznabUsers.vue b/apps/web/app/components/admin/TorznabUsers.vue index 376b5d96..9e3fda3c 100644 --- a/apps/web/app/components/admin/TorznabUsers.vue +++ b/apps/web/app/components/admin/TorznabUsers.vue @@ -117,7 +117,7 @@ <td class="py-3 px-2 text-center"> <span v-if="(user.apiStats?.rateLimitHits || 0) > 0" - class="px-1.5 py-0.5 rounded text-[10px] font-medium bg-yellow-500/20 text-yellow-400" + class="px-1.5 py-0.5 rounded text-[10px] font-medium bg-warning/20 text-warning" > {{ $t('admin.torznab.users.rateLimitHits', { n: user.apiStats?.rateLimitHits }) }} </span> @@ -143,14 +143,14 @@ </button> <button @click="confirmResetPasskey(user)" - class="p-1.5 text-text-muted hover:text-yellow-400 hover:bg-bg-tertiary rounded transition-colors" + class="p-1.5 text-text-muted hover:text-warning hover:bg-bg-tertiary rounded transition-colors" :title="$t('admin.torznab.users.resetPasskeyTitle')" > <Icon name="ph:key" class="text-sm" /> </button> <button @click="confirmBlockUser(user)" - class="p-1.5 text-text-muted hover:text-red-400 hover:bg-bg-tertiary rounded transition-colors" + class="p-1.5 text-text-muted hover:text-error hover:bg-bg-tertiary rounded transition-colors" :title="$t('admin.torznab.users.blockApiTitle')" > <Icon name="ph:prohibit" class="text-sm" /> @@ -257,8 +257,8 @@ class="px-4 py-2 rounded text-sm font-medium flex items-center gap-2" :class=" confirmAction.variant === 'danger' - ? 'bg-red-500 text-white' - : 'bg-yellow-500 text-black' + ? 'bg-error text-[rgb(var(--danger-fg))]' + : 'bg-warning text-[rgb(var(--warning-fg))]' " > <Icon diff --git a/apps/web/app/components/admin/UploadRules.vue b/apps/web/app/components/admin/UploadRules.vue index 19b04775..271dcb51 100644 --- a/apps/web/app/components/admin/UploadRules.vue +++ b/apps/web/app/components/admin/UploadRules.vue @@ -28,6 +28,15 @@ --> <div class="ur"> <!-- ── Policy snapshot ─────────────────────────────────────── --> + <!-- Un GET en échec affichait la politique la plus permissive possible comme + si elle était en vigueur. Dit, et les commandes rendues inertes : une + politique qu'on ne peut pas lire n'est pas une politique qu'on peut + modifier. --> + <p v-if="rulesFailed" class="rules-fault" role="alert"> + <Icon name="ph:warning-circle" /> + {{ $t('admin.uploadRules.loadFailed') }} + </p> + <section class="snapshot"> <article class="snap snap--gates"> <span class="snap-num tabular-nums"> @@ -384,7 +393,7 @@ <button v-if="dirty" type="button" - class="btn btn--ghost" + class="cbtn cbtn--ghost" :disabled="saving" @click="discard" > @@ -392,7 +401,7 @@ </button> <button type="button" - class="btn btn--primary" + class="cbtn cbtn--primary" :disabled="!dirty || hasErrors || saving" @click="save" > @@ -435,10 +444,21 @@ const { t } = useI18n(); const notifications = useNotificationStore(); // ── Source data ────────────────────────────────────────────── -const { data: serverRules, refresh: refreshRules } = await useFetch<RulesPayload>( +const { error: rulesError, data: serverRules, refresh: refreshRules } = await useFetch<RulesPayload>( '/api/admin/upload-rules', { default: () => emptyRules() }, ); +/* + * Un échec de chargement n'est pas une politique. + * + * `emptyRules()` rend toutes les portes à `false` ET `staffBypass: true` — la + * politique la plus permissive possible. Sur un GET en échec, le panneau + * l'affichait donc avec aplomb (« 0/6 portes armées · Pas de plafond · + * Contournement staff : Exempté »), `dirty` restait faux, la barre + * d'enregistrement restait cachée, et rien n'indiquait que ce n'était pas la + * politique en vigueur. + */ +const rulesFailed = computed(() => !!rulesError.value); const { data: categories } = await useFetch<Category[]>('/api/categories'); function emptyRules(): RulesPayload { @@ -805,7 +825,7 @@ function discard() { font-size: 0.6875rem; font-weight: 700; letter-spacing: calc(0.2em * var(--tracking-scale)); - color: rgb(var(--accent-warm)); + color: rgb(var(--accent-warm-text)); background: rgb(var(--bg-elevated)); border: 1px solid rgb(var(--accent-warm) / 0.35); padding: 0.3rem 0.55rem; @@ -1024,6 +1044,29 @@ function discard() { border-color: rgb(var(--accent-warm) / 0.6); box-shadow: 0 0 0 3px rgb(var(--accent-warm) / 0.12); } +/* L'anneau rendu au clavier. `outline: none` ci-dessus est pour la souris, où + un changement de bordure suffit ; en `<style scoped> +.rules-fault { + display: flex; + align-items: center; + gap: 0.5rem; + margin-bottom: 1rem; + padding: 0.7rem 0.9rem; + border: 1px solid rgb(var(--danger) / 0.4); + border-radius: var(--radius-md); + background: rgb(var(--danger) / 0.08); + font-size: 0.8125rem; + color: rgb(var(--fg-default)); +} +` la règle compile avec un + attribut de données, donc elle battait le `:focus-visible` global de `main.css` + quel que soit l'ordre — et ce champ n'avait plus aucun indicateur de focus. + `main.css` corrige exactement ça pour `.input`, avec la même explication. */ +.field-input:focus-visible { + outline: 2px solid rgb(var(--focus-ring)); + outline-offset: 2px; +} + .field-input--invalid { border-color: rgba(239, 68, 68, 0.55); box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.1); @@ -1079,7 +1122,7 @@ function discard() { line-height: 1.5; } .pattern-explainer-icon { - color: rgb(var(--accent-warm)); + color: rgb(var(--accent-warm-text)); font-size: 1rem; flex-shrink: 0; margin-top: 0.1rem; @@ -1202,7 +1245,7 @@ function discard() { } .cat-inherit > svg { color: rgb(var(--accent-warm)); } .cat-own-tag { - color: rgb(var(--accent-warm)); + color: rgb(var(--accent-warm-text)); border: 1px solid rgb(var(--accent-warm) / 0.5); background: rgb(var(--accent-warm) / 0.08); } @@ -1287,7 +1330,18 @@ function discard() { } /* ── Buttons ─────────────────────────────────────────────── */ -.btn { +/* + * Le bouton du dialecte console, renommé depuis `.btn`. + * + * Il portait le nom de la classe du système de design, dans un `<style + * scoped>` — donc dans une couche sans couche, qui l'emporte sur + * `@layer components` quelle que soit la spécificité. Tant que ce composant + * n'utilise QUE le dialecte local, rien ne casse ; le jour où quelqu'un y + * écrit `class="btn btn-primary"`, il obtient silencieusement ce bouton-ci et + * cherche longtemps pourquoi. Quatre composants d'administration portaient la + * même copie de cette définition. + */ +.cbtn { display: inline-flex; align-items: center; gap: 0.4rem; @@ -1303,18 +1357,18 @@ function discard() { font-family: inherit; white-space: nowrap; } -.btn:hover:not(:disabled) { +.cbtn:hover:not(:disabled) { border-color: rgb(var(--accent-warm) / 0.5); background: rgb(var(--accent-warm) / 0.05); } -.btn:disabled { opacity: 0.5; cursor: not-allowed; } -.btn--ghost { background: transparent; } -.btn--primary { +.cbtn:disabled { opacity: 0.5; cursor: not-allowed; } +.cbtn--ghost { background: transparent; } +.cbtn--primary { background: rgb(var(--accent-warm)); border-color: rgb(var(--accent-warm)); color: rgb(var(--accent-warm-fg)); } -.btn--primary:hover:not(:disabled) { +.cbtn--primary:hover:not(:disabled) { background: color-mix(in srgb, rgb(var(--accent-warm)) 82%, white); border-color: color-mix(in srgb, rgb(var(--accent-warm)) 82%, white); } diff --git a/apps/web/app/components/admin/Users.vue b/apps/web/app/components/admin/Users.vue index a040c7cb..f7b0428d 100644 --- a/apps/web/app/components/admin/Users.vue +++ b/apps/web/app/components/admin/Users.vue @@ -1600,13 +1600,13 @@ async function onDetachRole(roleId: string) { } } .kpi--green .kpi-value { - color: #6cd161; + color: rgb(var(--online)); /* jeton sémantique : cette teinte était figée sur le thème sombre */ } .kpi--aqua .kpi-value { - color: #34d4d8; + color: rgb(var(--info)); /* jeton sémantique : cette teinte était figée sur le thème sombre */ } .kpi--gold .kpi-value { - color: #f5c518; + color: rgb(var(--accent-warm-text)); /* jeton sémantique : cette teinte était figée sur le thème sombre */ } .kpi--red .kpi-value { color: rgb(var(--danger)); @@ -1653,6 +1653,16 @@ async function onDetachRole(roleId: string) { border-color: rgb(var(--fg-default) / 0.3); background: rgb(var(--bg-base)); } +/* L'anneau rendu au clavier. `outline: none` ci-dessus est pour la souris, où + un changement de bordure suffit ; en `<style scoped>` la règle compile avec un + attribut de données, donc elle battait le `:focus-visible` global de `main.css` + quel que soit l'ordre — et ce champ n'avait plus aucun indicateur de focus. + `main.css` corrige exactement ça pour `.input`, avec la même explication. */ +.filter-search-input:focus-visible { + outline: 2px solid rgb(var(--focus-ring)); + outline-offset: 2px; +} + .filter-search-input::placeholder { color: rgb(var(--fg-muted)); letter-spacing: calc(0.04em * var(--tracking-scale)); @@ -1711,6 +1721,16 @@ async function onDetachRole(roleId: string) { outline: none; border-color: rgb(var(--fg-default) / 0.3); } +/* L'anneau rendu au clavier. `outline: none` ci-dessus est pour la souris, où + un changement de bordure suffit ; en `<style scoped>` la règle compile avec un + attribut de données, donc elle battait le `:focus-visible` global de `main.css` + quel que soit l'ordre — et ce champ n'avait plus aucun indicateur de focus. + `main.css` corrige exactement ça pour `.input`, avec la même explication. */ +.filter-select-input:focus-visible { + outline: 2px solid rgb(var(--focus-ring)); + outline-offset: 2px; +} + .filter-reset { display: inline-flex; align-items: center; @@ -1954,17 +1974,17 @@ async function onDetachRole(roleId: string) { .role-chip--admin { border-color: rgba(229, 62, 62, 0.4); background: rgba(229, 62, 62, 0.08); - color: #ff6b6b; + color: rgb(var(--danger)); /* jeton sémantique : cette teinte était figée sur le thème sombre */ } .role-chip--mod { border-color: rgba(52, 212, 216, 0.4); background: rgba(52, 212, 216, 0.08); - color: #34d4d8; + color: rgb(var(--info)); /* jeton sémantique : cette teinte était figée sur le thème sombre */ } .role-chip--custom { border-color: rgba(245, 197, 24, 0.4); background: rgba(245, 197, 24, 0.08); - color: #f5c518; + color: rgb(var(--accent-warm-text)); /* jeton sémantique : cette teinte était figée sur le thème sombre */ } /* Last seen */ @@ -1975,7 +1995,7 @@ async function onDetachRole(roleId: string) { white-space: nowrap; } .last-seen.online { - color: #6cd161; + color: rgb(var(--online)); /* jeton sémantique : cette teinte était figée sur le thème sombre */ font-weight: 700; } .last-seen.recent { @@ -2013,7 +2033,7 @@ async function onDetachRole(roleId: string) { .ratio--great { border-color: rgba(108, 209, 97, 0.4); background: rgba(108, 209, 97, 0.08); - color: #6cd161; + color: rgb(var(--online)); /* jeton sémantique : cette teinte était figée sur le thème sombre */ } .ratio--ok { color: rgb(var(--fg-strong)); @@ -2021,12 +2041,12 @@ async function onDetachRole(roleId: string) { .ratio--low { border-color: rgba(245, 197, 24, 0.4); background: rgba(245, 197, 24, 0.08); - color: #f5c518; + color: rgb(var(--warning)); /* jeton sémantique : cette teinte était figée sur le thème sombre */ } .ratio--zero { border-color: rgba(229, 62, 62, 0.4); background: rgba(229, 62, 62, 0.08); - color: #ff6b6b; + color: rgb(var(--danger)); /* jeton sémantique : cette teinte était figée sur le thème sombre */ } .ratio--neutral { color: rgb(var(--fg-muted)); @@ -2087,17 +2107,17 @@ async function onDetachRole(roleId: string) { .row-action--danger-on { background: rgba(229, 62, 62, 0.12); border-color: rgba(229, 62, 62, 0.4); - color: #ff6b6b; + color: rgb(var(--danger)); /* jeton sémantique : cette teinte était figée sur le thème sombre */ } .row-action--mod-on { background: rgba(52, 212, 216, 0.12); border-color: rgba(52, 212, 216, 0.4); - color: #34d4d8; + color: rgb(var(--info)); /* jeton sémantique : cette teinte était figée sur le thème sombre */ } .row-action--admin-on { background: rgba(245, 197, 24, 0.12); border-color: rgba(245, 197, 24, 0.4); - color: #f5c518; + color: rgb(var(--accent-warm-text)); /* jeton sémantique : cette teinte était figée sur le thème sombre */ } /* Bonus-points action — coin tone when the user has a non-zero balance. Wider than the other row actions because the count badge can hold @@ -2105,7 +2125,7 @@ async function onDetachRole(roleId: string) { .row-action--bonus-active { background: rgb(var(--accent-warm) / 0.1); border-color: rgb(var(--accent-warm) / 0.35); - color: rgb(var(--accent-warm)); + color: rgb(var(--accent-warm-text)); } /* ─── Bonus-points adjustment modal ─────────────────────────────── */ @@ -2151,7 +2171,7 @@ async function onDetachRole(roleId: string) { color: rgb(var(--fg-strong)); } .bonus-balance-value > svg { - color: rgb(var(--accent-warm)); + color: rgb(var(--accent-warm-text)); font-size: 1.1rem; } .bonus-balance-unit { @@ -2376,6 +2396,16 @@ async function onDetachRole(roleId: string) { outline: none; border-color: rgb(var(--fg-default) / 0.3); } +/* L'anneau rendu au clavier. `outline: none` ci-dessus est pour la souris, où + un changement de bordure suffit ; en `<style scoped>` la règle compile avec un + attribut de données, donc elle battait le `:focus-visible` global de `main.css` + quel que soit l'ordre — et ce champ n'avait plus aucun indicateur de focus. + `main.css` corrige exactement ça pour `.input`, avec la même explication. */ +.row-role-select:focus-visible { + outline: 2px solid rgb(var(--focus-ring)); + outline-offset: 2px; +} + /* Empty state */ .empty { @@ -2607,7 +2637,7 @@ async function onDetachRole(roleId: string) { font-size: 0.5938rem; } .rm-row__mode--auto { - color: #34d4d8; + color: rgb(var(--info)); /* jeton sémantique : cette teinte était figée sur le thème sombre */ border-color: rgba(52, 212, 216, 0.4); background: rgba(52, 212, 216, 0.08); } @@ -2616,7 +2646,7 @@ async function onDetachRole(roleId: string) { font-weight: 700; } .rm-row--manual .rm-row__attached { - color: #f5c518; + color: rgb(var(--accent-warm-text)); /* jeton sémantique : cette teinte était figée sur le thème sombre */ } .rm-row__actions { display: flex; @@ -2644,7 +2674,7 @@ async function onDetachRole(roleId: string) { } .rm-action--attach:hover:not(:disabled) { border-color: rgba(108, 209, 97, 0.5); - color: #6cd161; + color: rgb(var(--online)); /* jeton sémantique : cette teinte était figée sur le thème sombre */ background: rgba(108, 209, 97, 0.08); } .rm-action--detach:hover:not(:disabled) { diff --git a/apps/web/app/components/admin/bonus/TierCurve.vue b/apps/web/app/components/admin/bonus/TierCurve.vue index 20b467ad..a7653b34 100644 --- a/apps/web/app/components/admin/bonus/TierCurve.vue +++ b/apps/web/app/components/admin/bonus/TierCurve.vue @@ -430,6 +430,6 @@ function formatMul(v: number): string { text-transform: uppercase; } .tcurve-readout > svg { - color: rgb(var(--accent-warm)); + color: rgb(var(--accent-warm-text)); } </style> diff --git a/apps/web/app/components/admin/notifications/ChannelCard.vue b/apps/web/app/components/admin/notifications/ChannelCard.vue index 3e16b321..851969c9 100644 --- a/apps/web/app/components/admin/notifications/ChannelCard.vue +++ b/apps/web/app/components/admin/notifications/ChannelCard.vue @@ -71,7 +71,7 @@ <button v-if="ch.enabled" type="button" - class="btn btn--ghost" + class="cbtn cbtn--ghost" :disabled="!!busyState" @click="$emit('test')" > @@ -84,7 +84,7 @@ <button v-if="ch.hasServerConfig" type="button" - class="btn btn--primary" + class="cbtn cbtn--primary" :disabled="!!busyState" @click="$emit('toggle-expand')" > @@ -211,14 +211,14 @@ <div class="cc-drawer-foot"> <button type="button" - class="btn btn--ghost" + class="cbtn cbtn--ghost" @click="$emit('toggle-expand')" > {{ $t('common.close') }} </button> <button type="button" - class="btn btn--ghost" + class="cbtn cbtn--ghost" v-if="ch.enabled" :disabled="!!busyState" @click="$emit('test')" @@ -232,7 +232,7 @@ <button v-if="ch.hasServerConfig" type="button" - class="btn btn--primary" + class="cbtn cbtn--primary" :disabled="!!busyState" @click="$emit('save')" > @@ -632,7 +632,7 @@ const relativeTime = computed(() => { border-radius: var(--radius-sm); } .cc-note > svg { - color: rgb(var(--accent-warm)); + color: rgb(var(--accent-warm-text)); flex-shrink: 0; margin-top: 0.1rem; } @@ -688,6 +688,16 @@ const relativeTime = computed(() => { border-color: rgb(var(--accent-warm) / 0.6); box-shadow: 0 0 0 3px rgb(var(--accent-warm) / 0.12); } +/* L'anneau rendu au clavier. `outline: none` ci-dessus est pour la souris, où + un changement de bordure suffit ; en `<style scoped>` la règle compile avec un + attribut de données, donc elle battait le `:focus-visible` global de `main.css` + quel que soit l'ordre — et ce champ n'avait plus aucun indicateur de focus. + `main.css` corrige exactement ça pour `.input`, avec la même explication. */ +.field-input:focus-visible { + outline: 2px solid rgb(var(--focus-ring)); + outline-offset: 2px; +} + .field-input::placeholder { color: rgb(var(--fg-faint)); font-style: italic; @@ -776,7 +786,11 @@ const relativeTime = computed(() => { } /* ── Buttons ─────────────────────────────────────────────────── */ -.btn { +/* Même renommage que dans les composants d'administration : `.btn` en `<style + * scoped>` masque la classe du système de design, qui vit dans + * `@layer components` et perd donc contre elle quelle que soit la + * spécificité. */ +.cbtn { display: inline-flex; align-items: center; gap: 0.4rem; @@ -791,23 +805,23 @@ const relativeTime = computed(() => { transition: all var(--dur-2) ease; font-family: inherit; } -.btn:hover:not(:disabled) { +.cbtn:hover:not(:disabled) { border-color: rgb(var(--accent-warm) / 0.5); background: rgb(var(--accent-warm) / 0.05); } -.btn:disabled { +.cbtn:disabled { opacity: 0.5; cursor: not-allowed; } -.btn--ghost { +.cbtn--ghost { background: transparent; } -.btn--primary { +.cbtn--primary { background: rgb(var(--accent-warm)); border-color: rgb(var(--accent-warm)); color: rgb(var(--accent-warm-fg)); } -.btn--primary:hover:not(:disabled) { +.cbtn--primary:hover:not(:disabled) { background: color-mix(in srgb, rgb(var(--accent-warm)) 82%, white); border-color: color-mix(in srgb, rgb(var(--accent-warm)) 82%, white); } diff --git a/apps/web/app/components/bonus/BonusEventIcon.vue b/apps/web/app/components/bonus/BonusEventIcon.vue index b76489ed..05be42ff 100644 --- a/apps/web/app/components/bonus/BonusEventIcon.vue +++ b/apps/web/app/components/bonus/BonusEventIcon.vue @@ -39,6 +39,7 @@ import { } from '~/composables/useActiveBonusEvent'; import BonusEventModal from '~/components/bonus/BonusEventModal.vue'; +const { t } = useI18n(); const { event } = useActiveBonusEvent(); const open = ref(false); @@ -66,6 +67,6 @@ const iconName = computed(() => { // Re-render the title every minute so the tooltip's countdown stays // approximately fresh without a per-second timer in the navbar. const countdown = computed(() => - event.value ? bonusCountdown(event.value.endsAt) : '' + event.value ? bonusCountdown(event.value.endsAt, new Date(), t) : '' ); </script> diff --git a/apps/web/app/components/bonus/BonusEventModal.vue b/apps/web/app/components/bonus/BonusEventModal.vue index 0da03952..f501c809 100644 --- a/apps/web/app/components/bonus/BonusEventModal.vue +++ b/apps/web/app/components/bonus/BonusEventModal.vue @@ -330,7 +330,7 @@ const overlayDownloadFrom = computed<number | null>(() => { // ── Countdown + window progress ──────────────────────────── const countdown = computed(() => - bonusCountdown(props.event.endsAt, new Date(now.value)) + bonusCountdown(props.event.endsAt, new Date(now.value), t) ); const windowProgress = computed(() => { @@ -544,7 +544,7 @@ const explainer = computed(() => { font-weight: 800; letter-spacing: calc(0.3em * var(--tracking-scale)); text-transform: uppercase; - color: #f43f5e; + color: rgb(var(--danger)); /* jeton sémantique : cette teinte était figée sur le thème sombre */ flex: 1; } .bb-onair-now { @@ -641,10 +641,10 @@ const explainer = computed(() => { border-radius: var(--radius-sm); border: 1px solid rgb(var(--accent-warm) / 0.45); background: rgb(var(--accent-warm) / 0.08); - color: rgb(var(--accent-warm)); + color: rgb(var(--accent-warm-text)); } .bb-title-strip--pool .bb-preset-tag { - color: rgb(var(--accent-warm)); + color: rgb(var(--accent-warm-text)); border-color: rgb(var(--accent-warm) / 0.45); background: rgb(var(--accent-warm) / 0.08); } @@ -664,7 +664,7 @@ const explainer = computed(() => { .bb-overlay-icon { flex-shrink: 0; margin-top: 0.1rem; - color: rgb(var(--accent-warm)); + color: rgb(var(--accent-warm-text)); } /* Strike-through value in the DL meter — surfaces the original @@ -694,17 +694,17 @@ const explainer = computed(() => { border: 1px solid; } .bb-title-strip--freeleech .bb-preset-tag { - color: #6cd161; + color: rgb(var(--online)); /* jeton sémantique : cette teinte était figée sur le thème sombre */ border-color: rgba(108, 209, 97, 0.45); background: rgba(108, 209, 97, 0.08); } .bb-title-strip--silverleech .bb-preset-tag { - color: #94a3b8; + color: rgb(var(--fg-muted)); /* jeton sémantique : cette teinte était figée sur le thème sombre */ border-color: rgba(148, 163, 184, 0.45); background: rgba(148, 163, 184, 0.08); } .bb-title-strip--bonus .bb-preset-tag { - color: rgb(var(--accent-warm)); + color: rgb(var(--accent-warm-text)); border-color: rgb(var(--accent-warm) / 0.45); background: rgb(var(--accent-warm) / 0.08); } @@ -728,7 +728,7 @@ const explainer = computed(() => { font-size: 0.7813rem; font-weight: 700; letter-spacing: calc(0.05em * var(--tracking-scale)); - color: #f43f5e; + color: rgb(var(--danger)); /* jeton sémantique : cette teinte était figée sur le thème sombre */ text-shadow: 0 0 10px rgba(244, 63, 94, 0.25); } .bb-countdown-icon { font-size: 0.95rem; } @@ -815,7 +815,7 @@ const explainer = computed(() => { .meter--good .meter-verdict-icon, .meter--good .meter-value, .meter--good .meter-verdict { - color: #6cd161; + color: rgb(var(--online)); /* jeton sémantique : cette teinte était figée sur le thème sombre */ } .meter--good .meter-bar-fill { background: linear-gradient(to right, #4ade80, #6cd161); @@ -828,7 +828,7 @@ const explainer = computed(() => { .meter--bad .meter-verdict-icon, .meter--bad .meter-value, .meter--bad .meter-verdict { - color: #f43f5e; + color: rgb(var(--danger)); /* jeton sémantique : cette teinte était figée sur le thème sombre */ } .meter--bad .meter-bar-fill { background: linear-gradient(to right, #f43f5e, #fb7185); @@ -971,7 +971,7 @@ const explainer = computed(() => { } .bb-window-foot-leg--right { text-align: right; - color: rgb(var(--accent-warm)); + color: rgb(var(--accent-warm-text)); } /* ── Explainer ──────────────────────────────────────────── */ @@ -996,7 +996,7 @@ const explainer = computed(() => { font-weight: 800; letter-spacing: calc(0.2em * var(--tracking-scale)); text-transform: uppercase; - color: rgb(var(--accent-warm)); + color: rgb(var(--accent-warm-text)); } .bb-explainer-icon { font-size: 0.95rem; } .bb-explainer-body { diff --git a/apps/web/app/components/forum/CategoryForm.vue b/apps/web/app/components/forum/CategoryForm.vue index 83a6e70c..7a34c08d 100644 --- a/apps/web/app/components/forum/CategoryForm.vue +++ b/apps/web/app/components/forum/CategoryForm.vue @@ -182,6 +182,16 @@ const iconSuggestions = [ outline: none; border-color: rgb(var(--fg-default)); } +/* L'anneau rendu au clavier. `outline: none` ci-dessus est pour la souris, où + un changement de bordure suffit ; en `<style scoped>` la règle compile avec un + attribut de données, donc elle battait le `:focus-visible` global de `main.css` + quel que soit l'ordre — et ce champ n'avait plus aucun indicateur de focus. + `main.css` corrige exactement ça pour `.input`, avec la même explication. */ +.cat-input:focus-visible { + outline: 2px solid rgb(var(--focus-ring)); + outline-offset: 2px; +} + .cat-input--textarea { resize: vertical; min-height: 4.5rem; diff --git a/apps/web/app/components/messaging/Reactions.vue b/apps/web/app/components/messaging/Reactions.vue index c8f313d1..5140eb66 100644 --- a/apps/web/app/components/messaging/Reactions.vue +++ b/apps/web/app/components/messaging/Reactions.vue @@ -54,7 +54,7 @@ const visible = computed(() => padding: 0.15rem 0.4rem; border: 1px solid rgb(var(--line-strong)); border-radius: var(--radius-pill); - background: rgb(var(--bg-tertiary)); + background: rgb(var(--bg-elevated)); color: rgb(var(--fg-default)); font-size: 0.75rem; line-height: 1; diff --git a/apps/web/app/components/messaging/TicketThread.vue b/apps/web/app/components/messaging/TicketThread.vue index fe8a0d13..94b2816e 100644 --- a/apps/web/app/components/messaging/TicketThread.vue +++ b/apps/web/app/components/messaging/TicketThread.vue @@ -242,6 +242,7 @@ const emit = defineEmits<{ (e: 'changed'): void }>(); const { t, locale } = useI18n(); const draft = ref(''); +const draftStore = useDraft(`ticket:${props.ticketId}`, draft); const note = ref(''); const busy = ref(false); const error = ref(''); @@ -349,6 +350,8 @@ async function send() { body: { body }, }); draft.value = ''; + // Publié : le brouillon n'a plus de raison d'exister. + draftStore.clear(); await refresh(); emit('changed'); } catch (err) { @@ -585,6 +588,16 @@ async function reopen() { resize: vertical; } .tk-input:focus { outline: none; border-color: rgb(var(--accent)); } +/* L'anneau rendu au clavier. `outline: none` ci-dessus est pour la souris, où + un changement de bordure suffit ; en `<style scoped>` la règle compile avec un + attribut de données, donc elle battait le `:focus-visible` global de `main.css` + quel que soit l'ordre — et ce champ n'avait plus aucun indicateur de focus. + `main.css` corrige exactement ça pour `.input`, avec la même explication. */ +.tk-input:focus-visible { + outline: 2px solid rgb(var(--focus-ring)); + outline-offset: 2px; +} + .tk-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 0.5rem; } .tk-spacer { flex: 1; } diff --git a/apps/web/app/components/messaging/TorrentCard.vue b/apps/web/app/components/messaging/TorrentCard.vue index c7a93566..0651fd7c 100644 --- a/apps/web/app/components/messaging/TorrentCard.vue +++ b/apps/web/app/components/messaging/TorrentCard.vue @@ -16,7 +16,7 @@ <span class="tc-main"> <span class="tc-name">{{ torrent.name }}</span> <span class="tc-meta"> - <span v-if="torrent.size">{{ formatSize(torrent.size) }}</span> + <span v-if="torrent.size">{{ formatSize(Number(torrent.size)) }}</span> <span class="tc-swarm"> <b class="tc-seed">{{ torrent.stats?.seeders ?? 0 }}</b> / @@ -57,18 +57,6 @@ onMounted(async () => { } }); -function formatSize(bytes: number | string): string { - const n = typeof bytes === 'string' ? Number(bytes) : bytes; - if (!Number.isFinite(n) || n <= 0) return ''; - const units = ['B', 'KB', 'MB', 'GB', 'TB']; - let i = 0; - let v = n; - while (v >= 1024 && i < units.length - 1) { - v /= 1024; - i += 1; - } - return `${v.toFixed(v >= 100 || i === 0 ? 0 : 1)} ${units[i]}`; -} </script> <style scoped> @@ -80,7 +68,7 @@ function formatSize(bytes: number | string): string { padding: 0.35rem 0.5rem; border: 1px solid rgb(var(--line-strong)); border-radius: var(--radius-sm); - background: rgb(var(--bg-tertiary)); + background: rgb(var(--bg-elevated)); color: rgb(var(--fg-default)); font-size: 0.7rem; text-decoration: none; diff --git a/apps/web/app/components/security/RecoveryCodesView.vue b/apps/web/app/components/security/RecoveryCodesView.vue index 857b1dd4..5c4492d4 100644 --- a/apps/web/app/components/security/RecoveryCodesView.vue +++ b/apps/web/app/components/security/RecoveryCodesView.vue @@ -9,15 +9,15 @@ </li> </ul> <div class="rcv-actions"> - <button class="btn-ghost" type="button" @click="copyAll"> + <button class="sbtn-ghost" type="button" @click="copyAll"> <Icon name="ph:copy-bold" /> {{ copied ? $t('common.copied') : $t('security.recoveryCodes.copyAll') }} </button> - <button class="btn-ghost" type="button" @click="download"> + <button class="sbtn-ghost" type="button" @click="download"> <Icon name="ph:download-simple-bold" /> {{ $t('security.recoveryCodes.downloadTxt') }} </button> - <button class="btn-ghost" type="button" @click="print"> + <button class="sbtn-ghost" type="button" @click="print"> <Icon name="ph:printer-bold" /> {{ $t('security.recoveryCodes.print') }} </button> @@ -68,10 +68,33 @@ function print() { // font, easy to drop in a paper safe. const w = window.open('', 'rc-print'); if (!w) return; - const list = props.codes.map((c) => `<li><code>${c}</code></li>`).join(''); + const doc = w.document; const title = t('security.recoveryCodes.printTitle'); const intro = t('security.recoveryCodes.printIntro'); + // `createElement` + `textContent`, pas `document.write`. + // + // C'était le seul endroit de l'application qui composait du HTML par + // concaténation, et il le faisait avec trois valeurs interpolées — le titre + // traduit, l'intro traduite, et les codes eux-mêmes. Rien n'est exploitable + // aujourd'hui (les traductions sont dans le dépôt, les codes sont + // alphanumériques), mais la garantie ne tient qu'à ces deux faits, sur + // l'écran où une injection coûterait le plus cher. Un nœud de texte ne se + // parse pas : la garantie devient structurelle. + // + // La fenêtre est nommée, donc réutilisée d'une impression à l'autre : on + // vide ce qu'une précédente y a laissé plutôt que d'empiler deux jeux de + // codes. + doc.head.replaceChildren(); + doc.body.replaceChildren(); + + const meta = doc.createElement('meta'); + meta.setAttribute('charset', 'utf-8'); + doc.head.append(meta); + // Passe par le nœud `<title>`, jamais par du balisage. + doc.title = title; + + const style = doc.createElement('style'); // The nonce, because this document inherits the OPENER's CSP. // // `window.open('')` yields an `about:blank` that carries the policy of the @@ -85,25 +108,33 @@ function print() { // content ATTRIBUTE, so `getAttribute('nonce')` returns nothing while the // `.nonce` property still holds the value. const nonce = document.querySelector<HTMLScriptElement>('script[nonce]')?.nonce ?? ''; - const nonceAttr = nonce ? ` nonce="${nonce}"` : ''; + if (nonce) style.setAttribute('nonce', nonce); + style.textContent = + 'body{font-family:ui-monospace,monospace;padding:2rem;color:#111}' + + 'h1{font-size:1.2rem}ul{list-style:none;padding:0;display:grid;grid-template-columns:repeat(2,1fr);gap:.5rem 1.5rem}' + + // A literal, and it has to be: this stylesheet goes into a document + // opened by `window.open`, where none of the application's custom + // properties exist. `calc(.06em * var(--tracking-scale))` there is + // invalid at computed-value time, so the tracking silently became + // `normal`. The scale substitution reached this string because it looks + // like CSS; the print window is the one place it must not. + 'code{font-size:1.05rem;letter-spacing:.06em}'; + doc.head.append(style); + + const h1 = doc.createElement('h1'); + h1.textContent = title; + const p = doc.createElement('p'); + p.textContent = intro; + const ul = doc.createElement('ul'); + for (const c of props.codes) { + const li = doc.createElement('li'); + const code = doc.createElement('code'); + code.textContent = c; + li.append(code); + ul.append(li); + } + doc.body.append(h1, p, ul); - w.document.write( - `<!doctype html><meta charset="utf-8"><title>${title}` + - `body{font-family:ui-monospace,monospace;padding:2rem;color:#111}` + - `h1{font-size:1.2rem}ul{list-style:none;padding:0;display:grid;grid-template-columns:repeat(2,1fr);gap:.5rem 1.5rem}` + - // A literal, and it has to be: this stylesheet goes into a document - // opened by `window.open`, where none of the application's custom - // properties exist. `calc(.06em * var(--tracking-scale))` there is - // invalid at computed-value time, so the tracking silently became - // `normal`. The scale substitution reached this string because it looks - // like CSS; the print window is the one place it must not. - `code{font-size:1.05rem;letter-spacing:.06em}` + - `` + - `

${title}

` + - `

${intro}

` + - `
    ${list}
` - ); - w.document.close(); w.focus(); w.print(); } @@ -143,7 +174,19 @@ function print() { flex-wrap: wrap; gap: 0.4rem; } -.btn-ghost { +/* + * Les boutons de cette surface, renommés depuis `btn-ghost` / `btn-primary`. + * + * Ce ne sont pas des copies ratées du bouton du système : c'est un dialecte à + * part — mono, capitales, 0,656 rem, interlettrage large — que les écrans de + * sécurité et de réglages emploient sciemment. Le défaut était le NOM : défini + * dans un ` diff --git a/apps/web/app/components/security/TwoFactorLoginStep.vue b/apps/web/app/components/security/TwoFactorLoginStep.vue index e2923d79..3a9ae658 100644 --- a/apps/web/app/components/security/TwoFactorLoginStep.vue +++ b/apps/web/app/components/security/TwoFactorLoginStep.vue @@ -31,8 +31,13 @@
- + +
- + -

+

{{ $t('security.twoFactorLogin.recoveryHint') }}

@@ -75,12 +82,12 @@

-