From 2764aea0ef06a7065ae2f52b2a031bd114aa3745 Mon Sep 17 00:00:00 2001 From: Florian <52180080+florianjs@users.noreply.github.com> Date: Tue, 20 Oct 2026 12:38:41 +0200 Subject: [PATCH 1/4] feat(torznab): add dedicated api key and integrations page Prowlarr, Sonarr and Radarr now get a tk_ API key instead of the announce passkey, so a leaked key is rotated without re-downloading every torrent. Passkeys still work for existing indexers. The new /integrations page lists URL, API path and key with copy buttons, per-app setup steps, a connection test and key rotation. An admin passkey reset also revokes the API key. Co-Authored-By: Claude Opus 5.5 --- app/components/shell/UserMenu.vue | 8 + app/pages/integrations.vue | 193 ++++++++++++++++++ doc/integrations/torznab.md | 57 ++++-- i18n/locales/de/integrations.json | 50 +++++ i18n/locales/en/integrations.json | 50 +++++ i18n/locales/es/integrations.json | 50 +++++ i18n/locales/fr/integrations.json | 50 +++++ i18n/locales/it/integrations.json | 50 +++++ i18n/locales/ja/integrations.json | 50 +++++ i18n/locales/ru/integrations.json | 50 +++++ i18n/locales/zh/integrations.json | 50 +++++ nuxt.config.ts | 2 +- .../admin/torznab/users/[id]/reset.post.ts | 6 +- server/api/auth/api-key.get.ts | 35 ++++ server/api/auth/api-key.post.ts | 20 ++ server/api/torznab/api/index.get.ts | 16 +- server/api/torznab/download.get.ts | 4 +- server/api/torznab/utils/auth.ts | 23 ++- server/db/migrations/0018_api_keys.sql | 6 + server/db/schema.ts | 2 + server/utils/apiKey.ts | 31 +++ shared/utils/torznabResponse.ts | 34 +++ tests/api-key.test.ts | 37 ++++ tests/torznab-response.test.ts | 31 +++ 24 files changed, 861 insertions(+), 44 deletions(-) create mode 100644 app/pages/integrations.vue create mode 100644 i18n/locales/de/integrations.json create mode 100644 i18n/locales/en/integrations.json create mode 100644 i18n/locales/es/integrations.json create mode 100644 i18n/locales/fr/integrations.json create mode 100644 i18n/locales/it/integrations.json create mode 100644 i18n/locales/ja/integrations.json create mode 100644 i18n/locales/ru/integrations.json create mode 100644 i18n/locales/zh/integrations.json create mode 100644 server/api/auth/api-key.get.ts create mode 100644 server/api/auth/api-key.post.ts create mode 100644 server/db/migrations/0018_api_keys.sql create mode 100644 server/utils/apiKey.ts create mode 100644 shared/utils/torznabResponse.ts create mode 100644 tests/api-key.test.ts create mode 100644 tests/torznab-response.test.ts diff --git a/app/components/shell/UserMenu.vue b/app/components/shell/UserMenu.vue index d87a10a1..d7ebe4ed 100644 --- a/app/components/shell/UserMenu.vue +++ b/app/components/shell/UserMenu.vue @@ -71,6 +71,14 @@ > {{ t('shell.nav.profile') }} + + {{ t('integrations.nav') }} +
diff --git a/app/pages/integrations.vue b/app/pages/integrations.vue new file mode 100644 index 00000000..a2c048c1 --- /dev/null +++ b/app/pages/integrations.vue @@ -0,0 +1,193 @@ + + + diff --git a/doc/integrations/torznab.md b/doc/integrations/torznab.md index 9ce52d08..47a0f7eb 100644 --- a/doc/integrations/torznab.md +++ b/doc/integrations/torznab.md @@ -11,16 +11,16 @@ Torznab is an extension of the Newznab API tailored for torrent indexers. It pro - 🔍 **Full-text search** with category filtering - 📺 **TV search** with season/episode parsing - 🎬 **Movie search** with IMDB support -- 🔐 **Passkey authentication** for secure API access +- 🔐 **Dedicated API key**, revocable without touching your passkey - 📦 **Personalized downloads** with announce URL injection ## Setup with Prowlarr -### Step 1: Get Your Passkey +### Step 1: Open the Integrations page 1. Log in to Trackarr -2. Click your username in the top-right → **Passkey** -3. Copy your **Passkey** (40-character hex string) +2. Click your username in the top-right → **Integrations** +3. The page lists every value to paste, with copy buttons, and a **Test connection** button ### Step 2: Add Indexer in Prowlarr @@ -28,11 +28,12 @@ Torznab is an extension of the Newznab API tailored for torrent indexers. It pro 2. Select **Generic Torznab** 3. Configure: -| Field | Value | -| ------- | -------------------------------------- | -| Name | `Trackarr` | -| URL | `https://your-tracker.com/api/torznab` | -| API Key | Your passkey from Step 1 | +| Field | Value | +| -------- | -------------------------------------- | +| Name | `Trackarr` | +| URL | `https://your-tracker.com/api/torznab` | +| API Path | `/api` (the default) | +| API Key | Your API key (`tk_…`) from Step 1 | 4. Click **Test** to verify the connection 5. Click **Save** @@ -41,12 +42,25 @@ Torznab is an extension of the Newznab API tailored for torrent indexers. It pro Prowlarr will automatically sync Trackarr as an indexer to your connected Sonarr, Radarr, and Lidarr instances. +::: tip Without Prowlarr +Sonarr, Radarr and Lidarr can also add Trackarr directly: **Settings → Indexers → + → Torznab**, with the same URL, API Path and API Key. +::: + +## API Key + +The API key (`tk_` followed by 40 hex characters) is only for Torznab apps. It is created the first time you open the Integrations page. + +- **Regenerate key** on the Integrations page rotates it: apps using the old key stop working, your passkey and downloaded `.torrent` files are untouched +- Downloaded `.torrent` files still carry your **passkey** in their announce URL, so nothing changes in your torrent client +- Indexers set up with the passkey as API key keep working, but switching to the API key is recommended: a leaked passkey means rotating it in every torrent +- An admin **passkey reset** also revokes the API key + ## API Endpoints -All endpoints are accessed via a single base URL with function parameters: +All functions are served by a single endpoint (URL + API Path): ``` -GET /api/torznab?t={function}&apikey={passkey}&... +GET /api/torznab/api?t={function}&apikey={apiKey}&... ``` ### Capabilities (`t=caps`) @@ -54,7 +68,7 @@ GET /api/torznab?t={function}&apikey={passkey}&... Returns XML describing indexer capabilities. No authentication required. ```bash -curl "https://your-tracker.com/api/torznab?t=caps" +curl "https://your-tracker.com/api/torznab/api?t=caps" ``` **Response:** XML with supported search types and categories. @@ -64,7 +78,7 @@ curl "https://your-tracker.com/api/torznab?t=caps" General torrent search. ```bash -curl "https://your-tracker.com/api/torznab?t=search&apikey=YOUR_PASSKEY&q=ubuntu" +curl "https://your-tracker.com/api/torznab/api?t=search&apikey=YOUR_API_KEY&q=ubuntu" ``` | Parameter | Type | Required | Description | @@ -79,7 +93,7 @@ curl "https://your-tracker.com/api/torznab?t=search&apikey=YOUR_PASSKEY&q=ubuntu Search with TV-specific parameters. ```bash -curl "https://your-tracker.com/api/torznab?t=tvsearch&apikey=YOUR_PASSKEY&q=Breaking+Bad&season=1&ep=1" +curl "https://your-tracker.com/api/torznab/api?t=tvsearch&apikey=YOUR_API_KEY&q=Breaking+Bad&season=1&ep=1" ``` | Parameter | Type | Description | @@ -92,7 +106,7 @@ curl "https://your-tracker.com/api/torznab?t=tvsearch&apikey=YOUR_PASSKEY&q=Brea Search with movie-specific parameters. ```bash -curl "https://your-tracker.com/api/torznab?t=movie&apikey=YOUR_PASSKEY&imdbid=tt0111161" +curl "https://your-tracker.com/api/torznab/api?t=movie&apikey=YOUR_API_KEY&imdbid=tt0111161" ``` | Parameter | Type | Description | @@ -101,10 +115,10 @@ curl "https://your-tracker.com/api/torznab?t=movie&apikey=YOUR_PASSKEY&imdbid=tt ### Download -Download a torrent file with your passkey embedded. +Download a torrent file with your passkey embedded in the announce URL. ```bash -curl -O "https://your-tracker.com/api/torznab/download?id=INFO_HASH&apikey=YOUR_PASSKEY" +curl -O "https://your-tracker.com/api/torznab/download?id=INFO_HASH&apikey=YOUR_API_KEY" ``` ## Category Mapping @@ -192,8 +206,8 @@ Errors are returned as XML: ## Security Notes -- **HTTPS Only**: Always use HTTPS to protect your passkey -- **Passkey = Identity**: Treat your passkey as a secret; anyone with it can download as you +- **HTTPS Only**: Always use HTTPS to protect your API key +- **API key = Identity**: anyone with it can search and download as you; regenerate it from the Integrations page if it leaks - **Rate Limits**: API is rate limited to prevent abuse (see below) ## Rate Limits @@ -220,7 +234,7 @@ Admins can also: - Enable/disable request logging - View real-time API statistics and usage - Block specific users from API access -- Reset user passkeys +- Reset user passkeys (also revokes their API key) - View blocked IPs and users ::: @@ -272,7 +286,8 @@ The Torznab API admin panel (`/admin/torznab`) provides comprehensive management ### "Incorrect user credentials" error -- Verify your passkey is correct (40 hex characters) +- Copy the API key again from the Integrations page (`tk_` + 40 hex characters), it may have been regenerated +- Use **Test connection** on the Integrations page to check the key from the tracker itself - Check that your account is not banned - Ensure you're using the correct tracker URL diff --git a/i18n/locales/de/integrations.json b/i18n/locales/de/integrations.json new file mode 100644 index 00000000..940784ce --- /dev/null +++ b/i18n/locales/de/integrations.json @@ -0,0 +1,50 @@ +{ + "integrations": { + "nav": "Integrationen", + "title": "Integrationen", + "subtitle": "Prowlarr, Sonarr, Radarr und andere Torznab-Apps verbinden", + "credentials": "Torznab-Einstellungen", + "credentialsHint": "Füge diese Werte in deine App ein. Der API-Schlüssel erlaubt nur Suche und Downloads: Du kannst ihn jederzeit neu erzeugen, ohne deinen Passkey zu ändern.", + "url": "URL", + "apiPath": "API-Pfad", + "apiKey": "API-Schlüssel", + "fullUrl": "Vollständiger Endpunkt", + "copy": "Kopieren", + "copied": "Kopiert", + "copyFailed": "Kopieren in die Zwischenablage fehlgeschlagen", + "show": "Schlüssel anzeigen", + "hide": "Schlüssel verbergen", + "test": "Verbindung testen", + "testing": "Wird getestet…", + "testOk": "Verbindung funktioniert", + "testFailed": "Verbindung fehlgeschlagen: {reason}", + "unreachable": "Tracker nicht erreichbar", + "rotate": "Schlüssel neu erzeugen", + "rotateConfirm": "API-Schlüssel neu erzeugen? Apps mit dem aktuellen Schlüssel funktionieren erst wieder, wenn du den neuen einfügst.", + "rotated": "Neuer API-Schlüssel erzeugt", + "rotateFailed": "Schlüssel konnte nicht neu erzeugt werden", + "apps": { + "prowlarr": { + "name": "Prowlarr", + "step1": "Öffne in Prowlarr Indexers und klicke auf Add Indexer.", + "step2": "Suche nach Generic Torznab und wähle es aus.", + "step3": "Fülle URL, API Path und API Key mit den Werten oben aus.", + "step4": "Klicke auf Test, dann auf Save. Prowlarr synchronisiert den Indexer mit deinen verbundenen Sonarr, Radarr und Lidarr." + }, + "arr": { + "name": "Sonarr / Radarr / Lidarr", + "step1": "Öffne Settings → Indexers und klicke auf +.", + "step2": "Wähle Torznab.", + "step3": "Fülle URL, API Path und API Key mit den Werten oben aus.", + "step4": "Klicke auf Test, dann auf Save. Mehrere Apps? Nutze lieber Prowlarr: Der Indexer wird nur einmal eingerichtet." + }, + "other": { + "name": "Andere Apps", + "step1": "Jeder Torznab-Client funktioniert: Nutze den vollständigen Endpunkt oben.", + "step2": "Die Fähigkeiten stehen am Endpunkt mit t=caps, ohne Schlüssel.", + "step3": "Suchergebnisse verlinken .torrent-Dateien über deinen API-Schlüssel.", + "step4": "Heruntergeladene .torrent-Dateien enthalten bereits deinen Passkey: Im Torrent-Client ist nichts einzurichten." + } + } + } +} diff --git a/i18n/locales/en/integrations.json b/i18n/locales/en/integrations.json new file mode 100644 index 00000000..52289ddf --- /dev/null +++ b/i18n/locales/en/integrations.json @@ -0,0 +1,50 @@ +{ + "integrations": { + "nav": "Integrations", + "title": "Integrations", + "subtitle": "Connect Prowlarr, Sonarr, Radarr and other Torznab apps", + "credentials": "Torznab settings", + "credentialsHint": "Paste these values into your app. The API key only gives access to search and downloads: you can regenerate it at any time without touching your passkey.", + "url": "URL", + "apiPath": "API Path", + "apiKey": "API Key", + "fullUrl": "Full endpoint", + "copy": "Copy", + "copied": "Copied", + "copyFailed": "Could not copy to the clipboard", + "show": "Show key", + "hide": "Hide key", + "test": "Test connection", + "testing": "Testing…", + "testOk": "Connection works", + "testFailed": "Connection failed: {reason}", + "unreachable": "tracker unreachable", + "rotate": "Regenerate key", + "rotateConfirm": "Regenerate the API key? Apps using the current key stop working until you paste the new one.", + "rotated": "New API key generated", + "rotateFailed": "Could not regenerate the key", + "apps": { + "prowlarr": { + "name": "Prowlarr", + "step1": "In Prowlarr, open Indexers and click Add Indexer.", + "step2": "Search for Generic Torznab and select it.", + "step3": "Fill URL, API Path and API Key with the values above.", + "step4": "Click Test, then Save. Prowlarr syncs the indexer to your connected Sonarr, Radarr and Lidarr." + }, + "arr": { + "name": "Sonarr / Radarr / Lidarr", + "step1": "Open Settings → Indexers and click +.", + "step2": "Pick Torznab.", + "step3": "Fill URL, API Path and API Key with the values above.", + "step4": "Click Test, then Save. Running several apps? Use Prowlarr instead: you set the indexer up once." + }, + "other": { + "name": "Other apps", + "step1": "Any Torznab client works: use the full endpoint above.", + "step2": "Capabilities are listed at the endpoint with t=caps, no key needed.", + "step3": "Search results link to .torrent files through your API key.", + "step4": "Downloaded .torrent files already carry your passkey: nothing to configure in your torrent client." + } + } + } +} diff --git a/i18n/locales/es/integrations.json b/i18n/locales/es/integrations.json new file mode 100644 index 00000000..606f5c41 --- /dev/null +++ b/i18n/locales/es/integrations.json @@ -0,0 +1,50 @@ +{ + "integrations": { + "nav": "Integraciones", + "title": "Integraciones", + "subtitle": "Conecta Prowlarr, Sonarr, Radarr y otras apps Torznab", + "credentials": "Ajustes de Torznab", + "credentialsHint": "Pega estos valores en tu app. La clave API solo da acceso a búsquedas y descargas: puedes regenerarla cuando quieras sin tocar tu passkey.", + "url": "URL", + "apiPath": "Ruta de la API", + "apiKey": "Clave API", + "fullUrl": "Endpoint completo", + "copy": "Copiar", + "copied": "Copiado", + "copyFailed": "No se pudo copiar al portapapeles", + "show": "Mostrar clave", + "hide": "Ocultar clave", + "test": "Probar conexión", + "testing": "Probando…", + "testOk": "La conexión funciona", + "testFailed": "Fallo de conexión: {reason}", + "unreachable": "tracker inaccesible", + "rotate": "Regenerar clave", + "rotateConfirm": "¿Regenerar la clave API? Las apps que usan la clave actual dejarán de funcionar hasta que pegues la nueva.", + "rotated": "Nueva clave API generada", + "rotateFailed": "No se pudo regenerar la clave", + "apps": { + "prowlarr": { + "name": "Prowlarr", + "step1": "En Prowlarr, abre Indexers y haz clic en Add Indexer.", + "step2": "Busca Generic Torznab y selecciónalo.", + "step3": "Rellena URL, API Path y API Key con los valores de arriba.", + "step4": "Haz clic en Test y luego en Save. Prowlarr sincroniza el indexer con tus Sonarr, Radarr y Lidarr conectados." + }, + "arr": { + "name": "Sonarr / Radarr / Lidarr", + "step1": "Abre Settings → Indexers y haz clic en +.", + "step2": "Elige Torznab.", + "step3": "Rellena URL, API Path y API Key con los valores de arriba.", + "step4": "Haz clic en Test y luego en Save. ¿Varias apps? Mejor usa Prowlarr: el indexer se configura una sola vez." + }, + "other": { + "name": "Otras apps", + "step1": "Cualquier cliente Torznab funciona: usa el endpoint completo de arriba.", + "step2": "Las capacidades se listan en el endpoint con t=caps, sin clave.", + "step3": "Los resultados de búsqueda enlazan los archivos .torrent mediante tu clave API.", + "step4": "Los archivos .torrent descargados ya llevan tu passkey: nada que configurar en tu cliente torrent." + } + } + } +} diff --git a/i18n/locales/fr/integrations.json b/i18n/locales/fr/integrations.json new file mode 100644 index 00000000..689dbbf1 --- /dev/null +++ b/i18n/locales/fr/integrations.json @@ -0,0 +1,50 @@ +{ + "integrations": { + "nav": "Intégrations", + "title": "Intégrations", + "subtitle": "Connectez Prowlarr, Sonarr, Radarr et les autres apps Torznab", + "credentials": "Paramètres Torznab", + "credentialsHint": "Collez ces valeurs dans votre app. La clé API ne donne accès qu'à la recherche et aux téléchargements : vous pouvez la régénérer à tout moment sans toucher à votre passkey.", + "url": "URL", + "apiPath": "Chemin de l'API", + "apiKey": "Clé API", + "fullUrl": "Endpoint complet", + "copy": "Copier", + "copied": "Copié", + "copyFailed": "Impossible de copier dans le presse-papiers", + "show": "Afficher la clé", + "hide": "Masquer la clé", + "test": "Tester la connexion", + "testing": "Test en cours…", + "testOk": "La connexion fonctionne", + "testFailed": "Échec de la connexion : {reason}", + "unreachable": "tracker injoignable", + "rotate": "Régénérer la clé", + "rotateConfirm": "Régénérer la clé API ? Les apps qui utilisent la clé actuelle cessent de fonctionner jusqu'à ce que vous colliez la nouvelle.", + "rotated": "Nouvelle clé API générée", + "rotateFailed": "Impossible de régénérer la clé", + "apps": { + "prowlarr": { + "name": "Prowlarr", + "step1": "Dans Prowlarr, ouvrez Indexers puis cliquez sur Add Indexer.", + "step2": "Cherchez Generic Torznab et sélectionnez-le.", + "step3": "Remplissez URL, API Path et API Key avec les valeurs ci-dessus.", + "step4": "Cliquez sur Test, puis Save. Prowlarr synchronise l'indexer avec vos Sonarr, Radarr et Lidarr connectés." + }, + "arr": { + "name": "Sonarr / Radarr / Lidarr", + "step1": "Ouvrez Settings → Indexers et cliquez sur +.", + "step2": "Choisissez Torznab.", + "step3": "Remplissez URL, API Path et API Key avec les valeurs ci-dessus.", + "step4": "Cliquez sur Test, puis Save. Plusieurs apps ? Passez plutôt par Prowlarr : l'indexer se configure une seule fois." + }, + "other": { + "name": "Autres apps", + "step1": "Tout client Torznab fonctionne : utilisez l'endpoint complet ci-dessus.", + "step2": "Les capacités sont listées sur l'endpoint avec t=caps, sans clé.", + "step3": "Les résultats de recherche pointent vers les fichiers .torrent via votre clé API.", + "step4": "Les fichiers .torrent téléchargés contiennent déjà votre passkey : rien à configurer dans votre client torrent." + } + } + } +} diff --git a/i18n/locales/it/integrations.json b/i18n/locales/it/integrations.json new file mode 100644 index 00000000..b0e6a7f9 --- /dev/null +++ b/i18n/locales/it/integrations.json @@ -0,0 +1,50 @@ +{ + "integrations": { + "nav": "Integrazioni", + "title": "Integrazioni", + "subtitle": "Collega Prowlarr, Sonarr, Radarr e altre app Torznab", + "credentials": "Impostazioni Torznab", + "credentialsHint": "Incolla questi valori nella tua app. La chiave API dà accesso solo a ricerche e download: puoi rigenerarla in qualsiasi momento senza toccare la tua passkey.", + "url": "URL", + "apiPath": "Percorso API", + "apiKey": "Chiave API", + "fullUrl": "Endpoint completo", + "copy": "Copia", + "copied": "Copiato", + "copyFailed": "Impossibile copiare negli appunti", + "show": "Mostra chiave", + "hide": "Nascondi chiave", + "test": "Prova connessione", + "testing": "Prova in corso…", + "testOk": "La connessione funziona", + "testFailed": "Connessione fallita: {reason}", + "unreachable": "tracker irraggiungibile", + "rotate": "Rigenera chiave", + "rotateConfirm": "Rigenerare la chiave API? Le app che usano la chiave attuale smettono di funzionare finché non incolli quella nuova.", + "rotated": "Nuova chiave API generata", + "rotateFailed": "Impossibile rigenerare la chiave", + "apps": { + "prowlarr": { + "name": "Prowlarr", + "step1": "In Prowlarr, apri Indexers e fai clic su Add Indexer.", + "step2": "Cerca Generic Torznab e selezionalo.", + "step3": "Compila URL, API Path e API Key con i valori qui sopra.", + "step4": "Fai clic su Test, poi su Save. Prowlarr sincronizza l'indexer con i tuoi Sonarr, Radarr e Lidarr collegati." + }, + "arr": { + "name": "Sonarr / Radarr / Lidarr", + "step1": "Apri Settings → Indexers e fai clic su +.", + "step2": "Scegli Torznab.", + "step3": "Compila URL, API Path e API Key con i valori qui sopra.", + "step4": "Fai clic su Test, poi su Save. Più app? Usa piuttosto Prowlarr: l'indexer si configura una volta sola." + }, + "other": { + "name": "Altre app", + "step1": "Qualsiasi client Torznab funziona: usa l'endpoint completo qui sopra.", + "step2": "Le capacità sono elencate all'endpoint con t=caps, senza chiave.", + "step3": "I risultati di ricerca puntano ai file .torrent tramite la tua chiave API.", + "step4": "I file .torrent scaricati contengono già la tua passkey: niente da configurare nel client torrent." + } + } + } +} diff --git a/i18n/locales/ja/integrations.json b/i18n/locales/ja/integrations.json new file mode 100644 index 00000000..15b61871 --- /dev/null +++ b/i18n/locales/ja/integrations.json @@ -0,0 +1,50 @@ +{ + "integrations": { + "nav": "連携", + "title": "連携", + "subtitle": "Prowlarr、Sonarr、Radarr などの Torznab アプリを接続します", + "credentials": "Torznab 設定", + "credentialsHint": "これらの値をアプリに貼り付けてください。API キーで可能なのは検索とダウンロードのみです。パスキーに触れずにいつでも再生成できます。", + "url": "URL", + "apiPath": "API パス", + "apiKey": "API キー", + "fullUrl": "完全なエンドポイント", + "copy": "コピー", + "copied": "コピーしました", + "copyFailed": "クリップボードにコピーできませんでした", + "show": "キーを表示", + "hide": "キーを隠す", + "test": "接続をテスト", + "testing": "テスト中…", + "testOk": "接続できました", + "testFailed": "接続に失敗しました: {reason}", + "unreachable": "トラッカーに接続できません", + "rotate": "キーを再生成", + "rotateConfirm": "API キーを再生成しますか?新しいキーを貼り付けるまで、現在のキーを使うアプリは動作しなくなります。", + "rotated": "新しい API キーを生成しました", + "rotateFailed": "キーを再生成できませんでした", + "apps": { + "prowlarr": { + "name": "Prowlarr", + "step1": "Prowlarr で Indexers を開き、Add Indexer をクリックします。", + "step2": "Generic Torznab を検索して選択します。", + "step3": "URL、API Path、API Key に上の値を入力します。", + "step4": "Test をクリックしてから Save をクリックします。Prowlarr が接続済みの Sonarr、Radarr、Lidarr にインデクサーを同期します。" + }, + "arr": { + "name": "Sonarr / Radarr / Lidarr", + "step1": "Settings → Indexers を開き、+ をクリックします。", + "step2": "Torznab を選びます。", + "step3": "URL、API Path、API Key に上の値を入力します。", + "step4": "Test をクリックしてから Save をクリックします。複数のアプリを使うなら Prowlarr を使えば設定は一度で済みます。" + }, + "other": { + "name": "その他のアプリ", + "step1": "どの Torznab クライアントでも使えます。上の完全なエンドポイントを使ってください。", + "step2": "機能一覧はキー不要で、エンドポイントに t=caps を付けると取得できます。", + "step3": "検索結果は API キー経由で .torrent ファイルにリンクします。", + "step4": "ダウンロードした .torrent ファイルにはすでにパスキーが含まれています。トレントクライアント側の設定は不要です。" + } + } + } +} diff --git a/i18n/locales/ru/integrations.json b/i18n/locales/ru/integrations.json new file mode 100644 index 00000000..c23125a0 --- /dev/null +++ b/i18n/locales/ru/integrations.json @@ -0,0 +1,50 @@ +{ + "integrations": { + "nav": "Интеграции", + "title": "Интеграции", + "subtitle": "Подключите Prowlarr, Sonarr, Radarr и другие приложения Torznab", + "credentials": "Настройки Torznab", + "credentialsHint": "Вставьте эти значения в приложение. API-ключ даёт доступ только к поиску и загрузкам: его можно перевыпустить в любой момент, не меняя passkey.", + "url": "URL", + "apiPath": "Путь API", + "apiKey": "API-ключ", + "fullUrl": "Полный адрес", + "copy": "Копировать", + "copied": "Скопировано", + "copyFailed": "Не удалось скопировать в буфер обмена", + "show": "Показать ключ", + "hide": "Скрыть ключ", + "test": "Проверить подключение", + "testing": "Проверка…", + "testOk": "Подключение работает", + "testFailed": "Ошибка подключения: {reason}", + "unreachable": "трекер недоступен", + "rotate": "Перевыпустить ключ", + "rotateConfirm": "Перевыпустить API-ключ? Приложения с текущим ключом перестанут работать, пока вы не вставите новый.", + "rotated": "Новый API-ключ создан", + "rotateFailed": "Не удалось перевыпустить ключ", + "apps": { + "prowlarr": { + "name": "Prowlarr", + "step1": "В Prowlarr откройте Indexers и нажмите Add Indexer.", + "step2": "Найдите Generic Torznab и выберите его.", + "step3": "Заполните URL, API Path и API Key значениями выше.", + "step4": "Нажмите Test, затем Save. Prowlarr синхронизирует индексатор с подключёнными Sonarr, Radarr и Lidarr." + }, + "arr": { + "name": "Sonarr / Radarr / Lidarr", + "step1": "Откройте Settings → Indexers и нажмите +.", + "step2": "Выберите Torznab.", + "step3": "Заполните URL, API Path и API Key значениями выше.", + "step4": "Нажмите Test, затем Save. Несколько приложений? Лучше используйте Prowlarr: индексатор настраивается один раз." + }, + "other": { + "name": "Другие приложения", + "step1": "Подойдёт любой клиент Torznab: используйте полный адрес выше.", + "step2": "Возможности доступны по адресу с t=caps, без ключа.", + "step3": "Результаты поиска ссылаются на .torrent-файлы через ваш API-ключ.", + "step4": "Загруженные .torrent-файлы уже содержат ваш passkey: в торрент-клиенте ничего настраивать не нужно." + } + } + } +} diff --git a/i18n/locales/zh/integrations.json b/i18n/locales/zh/integrations.json new file mode 100644 index 00000000..16d9d090 --- /dev/null +++ b/i18n/locales/zh/integrations.json @@ -0,0 +1,50 @@ +{ + "integrations": { + "nav": "集成", + "title": "集成", + "subtitle": "连接 Prowlarr、Sonarr、Radarr 及其他 Torznab 应用", + "credentials": "Torznab 设置", + "credentialsHint": "将这些值粘贴到你的应用中。API 密钥仅可用于搜索和下载:你可以随时重新生成,而不影响你的 passkey。", + "url": "URL", + "apiPath": "API 路径", + "apiKey": "API 密钥", + "fullUrl": "完整端点", + "copy": "复制", + "copied": "已复制", + "copyFailed": "无法复制到剪贴板", + "show": "显示密钥", + "hide": "隐藏密钥", + "test": "测试连接", + "testing": "测试中…", + "testOk": "连接正常", + "testFailed": "连接失败:{reason}", + "unreachable": "无法访问 tracker", + "rotate": "重新生成密钥", + "rotateConfirm": "重新生成 API 密钥?在你粘贴新密钥之前,使用当前密钥的应用将无法工作。", + "rotated": "已生成新的 API 密钥", + "rotateFailed": "无法重新生成密钥", + "apps": { + "prowlarr": { + "name": "Prowlarr", + "step1": "在 Prowlarr 中打开 Indexers,点击 Add Indexer。", + "step2": "搜索 Generic Torznab 并选择它。", + "step3": "用上面的值填写 URL、API Path 和 API Key。", + "step4": "点击 Test,然后点击 Save。Prowlarr 会将索引器同步到已连接的 Sonarr、Radarr 和 Lidarr。" + }, + "arr": { + "name": "Sonarr / Radarr / Lidarr", + "step1": "打开 Settings → Indexers,点击 +。", + "step2": "选择 Torznab。", + "step3": "用上面的值填写 URL、API Path 和 API Key。", + "step4": "点击 Test,然后点击 Save。使用多个应用?建议改用 Prowlarr:索引器只需配置一次。" + }, + "other": { + "name": "其他应用", + "step1": "任何 Torznab 客户端都可使用:请使用上面的完整端点。", + "step2": "在端点加上 t=caps 即可查看功能列表,无需密钥。", + "step3": "搜索结果通过你的 API 密钥链接到 .torrent 文件。", + "step4": "下载的 .torrent 文件已包含你的 passkey:无需在 torrent 客户端中配置。" + } + } + } +} diff --git a/nuxt.config.ts b/nuxt.config.ts index 670b6698..a528e7f9 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -1,7 +1,7 @@ import pkg from './package.json'; // One translation file per area, in i18n/locales// (issue #43) -const LOCALE_FILES = ['app.json', 'shop.json', 'messages.json', 'shoutbox.json']; +const LOCALE_FILES = ['app.json', 'shop.json', 'messages.json', 'shoutbox.json', 'integrations.json']; // https://nuxt.com/docs/api/configuration/nuxt-config export default defineNuxtConfig({ diff --git a/server/api/admin/torznab/users/[id]/reset.post.ts b/server/api/admin/torznab/users/[id]/reset.post.ts index f6edd55f..cd56255d 100644 --- a/server/api/admin/torznab/users/[id]/reset.post.ts +++ b/server/api/admin/torznab/users/[id]/reset.post.ts @@ -1,6 +1,6 @@ /** * POST /api/admin/torznab/users/:id/reset - * Reset a user's passkey (regenerate) (admin only) + * Reset a user's passkey and revoke their Torznab API key (admin only) */ import { requireAdminSession } from '../../../../../utils/adminAuth'; @@ -41,10 +41,10 @@ export default defineEventHandler(async (event) => { // Generate new passkey const newPasskey = generatePasskey(); - // Update user + // Revoke the API key too: indexers stop working until the user takes a new one await db .update(schema.users) - .set({ passkey: newPasskey }) + .set({ passkey: newPasskey, apiKey: null }) .where(eq(schema.users.id, userId)); // Clear old stats diff --git a/server/api/auth/api-key.get.ts b/server/api/auth/api-key.get.ts new file mode 100644 index 00000000..ab558f4e --- /dev/null +++ b/server/api/auth/api-key.get.ts @@ -0,0 +1,35 @@ +import { and, eq, isNull } from 'drizzle-orm'; +import { db } from '../../db'; +import { users } from '../../db/schema'; +import { generateApiKey } from '../../utils/apiKey'; + +/** + * GET /api/auth/api-key + * Returns the current user's Torznab API key, created on first request + */ +export default defineEventHandler(async (event) => { + const session = await requireAuthSession(event); + const userId = session.user.id; + + const [current] = await db + .select({ apiKey: users.apiKey }) + .from(users) + .where(eq(users.id, userId)) + .limit(1); + + if (current?.apiKey) return { apiKey: current.apiKey }; + + // Only fill an empty column: two tabs opening the page at once keep one key + await db + .update(users) + .set({ apiKey: generateApiKey() }) + .where(and(eq(users.id, userId), isNull(users.apiKey))); + + const [created] = await db + .select({ apiKey: users.apiKey }) + .from(users) + .where(eq(users.id, userId)) + .limit(1); + + return { apiKey: created?.apiKey ?? null }; +}); diff --git a/server/api/auth/api-key.post.ts b/server/api/auth/api-key.post.ts new file mode 100644 index 00000000..09f3fe75 --- /dev/null +++ b/server/api/auth/api-key.post.ts @@ -0,0 +1,20 @@ +import { eq } from 'drizzle-orm'; +import { db } from '../../db'; +import { users } from '../../db/schema'; +import { generateApiKey } from '../../utils/apiKey'; +import { protectEndpoint } from '../../utils/rateLimit'; + +/** + * POST /api/auth/api-key + * Rotate the current user's Torznab API key: indexers using the old one stop + * working, the passkey (announces, downloaded .torrent files) is untouched + */ +export default defineEventHandler(async (event) => { + await protectEndpoint(event, 'mutation'); + const session = await requireAuthSession(event); + + const apiKey = generateApiKey(); + await db.update(users).set({ apiKey }).where(eq(users.id, session.user.id)); + + return { apiKey }; +}); diff --git a/server/api/torznab/api/index.get.ts b/server/api/torznab/api/index.get.ts index 3427eb82..00480351 100644 --- a/server/api/torznab/api/index.get.ts +++ b/server/api/torznab/api/index.get.ts @@ -1,6 +1,6 @@ /** * Torznab API - Main Router - * GET /api/torznab?t={function}&apikey={passkey}&... + * GET /api/torznab?t={function}&apikey={apiKey}&... * * Handles all Torznab functions: * - caps: Capabilities @@ -17,7 +17,7 @@ import { desc, eq, ilike, and, inArray, sql } from 'drizzle-orm'; import { escapeLike } from '../../../utils/validation'; import { getFreeleechState } from '../../../utils/settings'; import { parseImdbId, parseTmdbId, parseTvdbId } from '../../../../shared/utils/mediaIds'; -import { authenticateTorznab, sendTorznabError } from '../utils/auth'; +import { authenticateTorznab, sendTorznabError, type TorznabUser } from '../utils/auth'; import { buildCapsXml, buildSearchXml, @@ -88,7 +88,7 @@ export default defineEventHandler(async (event) => { // All other functions require authentication // Wrap in try/catch to handle Torznab XML errors properly - let user: { passkey: string }; + let user: TorznabUser; try { user = await authenticateTorznab(event); } catch (error: any) { @@ -194,7 +194,7 @@ async function handleCaps(event: H3Event) { async function handleSearch( event: H3Event, query: z.infer, - user: { passkey: string } + user: TorznabUser ) { return performSearch(event, query, user); } @@ -206,7 +206,7 @@ async function handleSearch( async function handleTvSearch( event: H3Event, query: z.infer, - user: { passkey: string } + user: TorznabUser ) { // Build search query with season/episode. With a database ID the title // text is redundant (and often formatted differently): match the ID instead. @@ -246,7 +246,7 @@ async function handleTvSearch( async function handleMovieSearch( event: H3Event, query: z.infer, - user: { passkey: string } + user: TorznabUser ) { // With a database ID, match the ID column instead of the title text const searchQuery = query.imdbid || query.tmdbid ? '' : query.q || ''; @@ -265,7 +265,7 @@ async function handleMovieSearch( async function performSearch( event: H3Event, query: z.infer, - user: { passkey: string } + user: TorznabUser ) { const baseUrl = getRequestURL(event).origin; const conditions = []; @@ -352,7 +352,7 @@ async function performSearch( seeders: stats.seeders, leechers: stats.leechers, grabs: stats.completed, - downloadUrl: `${baseUrl}/api/torznab/download?id=${torrent.infoHash}&apikey=${user.passkey}`, + downloadUrl: `${baseUrl}/api/torznab/download?id=${torrent.infoHash}&apikey=${user.apikey}`, downloadVolumeFactor: freeleech ? 0 : 1, uploadVolumeFactor: 1, imdbId: torrent.imdbId ?? undefined, diff --git a/server/api/torznab/download.get.ts b/server/api/torznab/download.get.ts index 0a64a930..d4445d78 100644 --- a/server/api/torznab/download.get.ts +++ b/server/api/torznab/download.get.ts @@ -1,6 +1,6 @@ /** * Torznab API - Download Endpoint - * GET /api/torznab/download?id={infoHash}&apikey={passkey} + * GET /api/torznab/download?id={infoHash}&apikey={apiKey} * * Downloads a torrent file with personalized announce URL */ @@ -36,7 +36,7 @@ export default defineEventHandler(async (event) => { }); } - // Authenticate via passkey + // Authenticate via API key (or legacy passkey) let user: Awaited>; try { user = await authenticateTorznab(event); diff --git a/server/api/torznab/utils/auth.ts b/server/api/torznab/utils/auth.ts index 7f373f51..da0e15b7 100644 --- a/server/api/torznab/utils/auth.ts +++ b/server/api/torznab/utils/auth.ts @@ -7,6 +7,7 @@ import { H3Event, createError } from 'h3'; import { db, schema } from '../../../db'; import { eq } from 'drizzle-orm'; import { buildErrorXml, TORZNAB_ERRORS } from './xml'; +import { parseTorznabCredential } from '../../../utils/apiKey'; export interface TorznabUser { id: string; @@ -15,17 +16,18 @@ export interface TorznabUser { isBanned: boolean; isAdmin: boolean; isModerator: boolean; + /** Credential the request used, echoed in download links */ + apikey: string; } /** - * Authenticate user via Torznab API key (passkey) + * Authenticate user via Torznab API key (or legacy passkey) * Returns user if valid, throws error otherwise */ export async function authenticateTorznab( event: H3Event ): Promise { - const query = getQuery(event); - const apikey = query.apikey as string | undefined; + const apikey = getQuery(event).apikey; if (!apikey) { throw createTorznabError( @@ -35,16 +37,19 @@ export async function authenticateTorznab( ); } - // Validate passkey format (32 or 40 hex chars - supports legacy and new passkeys) - if (!/^[a-f0-9]{32}$/i.test(apikey) && !/^[a-f0-9]{40}$/i.test(apikey)) { + const credential = parseTorznabCredential(apikey); + if (!credential) { 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. Use the API key from your Integrations page' ); } - // Look up user by passkey + // API key, or the passkey for indexers set up before API keys existed + const column = + credential.kind === 'apiKey' ? schema.users.apiKey : schema.users.passkey; + const users = await db .select({ id: schema.users.id, @@ -55,7 +60,7 @@ export async function authenticateTorznab( isModerator: schema.users.isModerator, }) .from(schema.users) - .where(eq(schema.users.passkey, apikey.toLowerCase())) + .where(eq(column, credential.value)) .limit(1); const user = users[0]; @@ -68,7 +73,7 @@ export async function authenticateTorznab( throw createTorznabError(event, TORZNAB_ERRORS.ACCOUNT_SUSPENDED); } - return user; + return { ...user, apikey: credential.value }; } /** diff --git a/server/db/migrations/0018_api_keys.sql b/server/db/migrations/0018_api_keys.sql new file mode 100644 index 00000000..4c0dc55b --- /dev/null +++ b/server/db/migrations/0018_api_keys.sql @@ -0,0 +1,6 @@ +-- Torznab API key, separate from the announce passkey +ALTER TABLE "users" ADD COLUMN IF NOT EXISTS "api_key" text; +DO $$ BEGIN + ALTER TABLE "users" ADD CONSTRAINT "users_api_key_unique" UNIQUE ("api_key"); +EXCEPTION WHEN duplicate_object OR duplicate_table THEN NULL; +END $$; diff --git a/server/db/schema.ts b/server/db/schema.ts index 312f636d..dbd0c9f5 100644 --- a/server/db/schema.ts +++ b/server/db/schema.ts @@ -31,6 +31,8 @@ export const users = pgTable( authSalt: text('auth_salt'), // Client-generated salt (base64) - nullable for migration authVerifier: text('auth_verifier'), // Derived verifier (base64) - nullable for migration passkey: text('passkey').notNull().unique(), // For private tracker auth + // Torznab key for Prowlarr & co, rotated without touching the passkey + apiKey: text('api_key').unique(), isAdmin: boolean('is_admin').default(false).notNull(), isModerator: boolean('is_moderator').default(false).notNull(), isBanned: boolean('is_banned').default(false).notNull(), diff --git a/server/utils/apiKey.ts b/server/utils/apiKey.ts new file mode 100644 index 00000000..6bb840ff --- /dev/null +++ b/server/utils/apiKey.ts @@ -0,0 +1,31 @@ +import { randomBytes } from 'crypto'; + +/** + * Torznab API keys: given to Prowlarr, Sonarr and the like instead of the + * passkey, so a leaked key can be rotated without touching announces. + * The prefix tells them apart from passkeys (32 or 40 hex chars). + */ +export const API_KEY_PREFIX = 'tk_'; + +const API_KEY_RE = /^tk_[a-f0-9]{40}$/; +const PASSKEY_RE = /^[a-f0-9]{32}([a-f0-9]{8})?$/; + +export function generateApiKey(): string { + return API_KEY_PREFIX + randomBytes(20).toString('hex'); +} + +export type TorznabCredential = + | { kind: 'apiKey'; value: string } + | { kind: 'passkey'; value: string }; + +/** + * Classify the ?apikey= value of a Torznab request. Passkeys are still + * accepted for indexers configured before API keys existed. + */ +export function parseTorznabCredential(raw: unknown): TorznabCredential | null { + if (typeof raw !== 'string') return null; + const value = raw.trim().toLowerCase(); + if (API_KEY_RE.test(value)) return { kind: 'apiKey', value }; + if (PASSKEY_RE.test(value)) return { kind: 'passkey', value }; + return null; +} diff --git a/shared/utils/torznabResponse.ts b/shared/utils/torznabResponse.ts new file mode 100644 index 00000000..7deb3b54 --- /dev/null +++ b/shared/utils/torznabResponse.ts @@ -0,0 +1,34 @@ +/** + * Read a Torznab response well enough to tell a user whether their setup + * works: an document, or an RSS feed of items. + */ +export type TorznabCheck = + | { ok: true; items: number } + | { ok: false; code: number | null; description: string }; + +const decode = (text: string) => + text + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/&/g, '&'); + +export function checkTorznabResponse(xml: string): TorznabCheck { + const error = /]*)\/?>/i.exec(xml); + if (error) { + const code = /\bcode="(\d+)"/.exec(error[1]!); + const description = /\bdescription="([^"]*)"/.exec(error[1]!); + return { + ok: false, + code: code ? Number(code[1]) : null, + description: description ? decode(description[1]!) : 'Unknown error', + }; + } + + if (!/ { + it('is prefixed and parses back as an API key', () => { + const key = generateApiKey(); + expect(key).toMatch(/^tk_[a-f0-9]{40}$/); + expect(parseTorznabCredential(key)).toEqual({ kind: 'apiKey', value: key }); + }); + + it('is random', () => { + expect(generateApiKey()).not.toBe(generateApiKey()); + }); +}); + +describe('parseTorznabCredential', () => { + it('accepts 40 and legacy 32 char passkeys', () => { + const passkey = 'a'.repeat(40); + expect(parseTorznabCredential(passkey)).toEqual({ kind: 'passkey', value: passkey }); + expect(parseTorznabCredential('b'.repeat(32))?.kind).toBe('passkey'); + }); + + it('normalizes case and surrounding spaces', () => { + expect(parseTorznabCredential(` TK_${'AB'.repeat(20)} `)).toEqual({ + kind: 'apiKey', + value: `tk_${'ab'.repeat(20)}`, + }); + }); + + it('rejects anything else', () => { + expect(parseTorznabCredential(undefined)).toBeNull(); + expect(parseTorznabCredential(['a'.repeat(40)])).toBeNull(); + expect(parseTorznabCredential('a'.repeat(36))).toBeNull(); + expect(parseTorznabCredential(`tk_${'a'.repeat(32)}`)).toBeNull(); + expect(parseTorznabCredential(`tk_${'z'.repeat(40)}`)).toBeNull(); + }); +}); diff --git a/tests/torznab-response.test.ts b/tests/torznab-response.test.ts new file mode 100644 index 00000000..67a25de0 --- /dev/null +++ b/tests/torznab-response.test.ts @@ -0,0 +1,31 @@ +import { describe, it, expect } from 'vitest'; +import { checkTorznabResponse } from '../shared/utils/torznabResponse'; + +describe('checkTorznabResponse', () => { + it('reads a Torznab error', () => { + const xml = '\n'; + expect(checkTorznabResponse(xml)).toEqual({ + ok: false, + code: 100, + description: 'Incorrect user credentials', + }); + }); + + it('decodes entities in the description', () => { + const xml = ''; + expect(checkTorznabResponse(xml)).toMatchObject({ description: 'Torznab & "API" disabled' }); + }); + + it('counts items of a search feed', () => { + const xml = 'ab'; + expect(checkTorznabResponse(xml)).toEqual({ ok: true, items: 2 }); + }); + + it('accepts an empty feed', () => { + expect(checkTorznabResponse('')).toEqual({ ok: true, items: 0 }); + }); + + it('rejects anything else, like a login page', () => { + expect(checkTorznabResponse('')).toMatchObject({ ok: false, code: null }); + }); +}); From 6ef45cfb34c00a17ed74a1cd4a57669f9ee72b7f Mon Sep 17 00:00:00 2001 From: Florian <52180080+florianjs@users.noreply.github.com> Date: Tue, 20 Oct 2026 12:39:35 +0200 Subject: [PATCH 2/4] fix(panic): encrypt torznab api keys with other user secrets Co-Authored-By: Claude Opus 5.5 --- server/api/admin/panic/encrypt.post.ts | 1 + server/api/admin/panic/restore.post.ts | 1 + 2 files changed, 2 insertions(+) diff --git a/server/api/admin/panic/encrypt.post.ts b/server/api/admin/panic/encrypt.post.ts index adf40935..f319af61 100644 --- a/server/api/admin/panic/encrypt.post.ts +++ b/server/api/admin/panic/encrypt.post.ts @@ -100,6 +100,7 @@ export default defineEventHandler(async (event) => { authSalt: encryptField(user.authSalt, key), authVerifier: encryptField(user.authVerifier, key), passkey: encryptField(user.passkey, key)!, + apiKey: encryptField(user.apiKey, key), lastIp: encryptField(user.lastIp, key) ?? undefined, }) .where(eq(users.id, user.id)); diff --git a/server/api/admin/panic/restore.post.ts b/server/api/admin/panic/restore.post.ts index 6d8b14f4..101c3052 100644 --- a/server/api/admin/panic/restore.post.ts +++ b/server/api/admin/panic/restore.post.ts @@ -123,6 +123,7 @@ export default defineEventHandler(async (event) => { authSalt: decryptOrKeep(user.authSalt, key, legacyIv), authVerifier: decryptOrKeep(user.authVerifier, key, legacyIv), passkey: decryptOrKeep(user.passkey, key, legacyIv)!, + apiKey: decryptOrKeep(user.apiKey, key, legacyIv), lastIp: decryptOrKeep(user.lastIp, key, legacyIv) ?? undefined, }) .where(eq(users.id, user.id)); From e6260c3a259bbd8c90eec124b679f307f2bde121 Mon Sep 17 00:00:00 2001 From: Florian <52180080+florianjs@users.noreply.github.com> Date: Tue, 20 Oct 2026 13:09:21 +0200 Subject: [PATCH 3/4] docs(torznab): warn that a leaked api key exposes the passkey Torrents downloaded through the API key carry the passkey, so rotating the key alone does not contain a leak. Say so on the Integrations page and in the Torznab docs, and tell users to have their passkey reset. Co-Authored-By: Claude Opus 5.5 --- doc/integrations/torznab.md | 3 ++- i18n/locales/de/integrations.json | 2 +- i18n/locales/en/integrations.json | 2 +- i18n/locales/es/integrations.json | 2 +- i18n/locales/fr/integrations.json | 2 +- i18n/locales/it/integrations.json | 2 +- i18n/locales/ja/integrations.json | 2 +- i18n/locales/ru/integrations.json | 2 +- i18n/locales/zh/integrations.json | 2 +- 9 files changed, 10 insertions(+), 9 deletions(-) diff --git a/doc/integrations/torznab.md b/doc/integrations/torznab.md index 47a0f7eb..0606e140 100644 --- a/doc/integrations/torznab.md +++ b/doc/integrations/torznab.md @@ -51,6 +51,7 @@ Sonarr, Radarr and Lidarr can also add Trackarr directly: **Settings → Indexer The API key (`tk_` followed by 40 hex characters) is only for Torznab apps. It is created the first time you open the Integrations page. - **Regenerate key** on the Integrations page rotates it: apps using the old key stop working, your passkey and downloaded `.torrent` files are untouched +- The API key keeps the passkey out of app settings and logs, but it is **not a separate identity**: `.torrent` files downloaded with it carry the passkey. If the key leaks, regenerate it **and** ask an admin to reset your passkey (then re-download your active torrents) - Downloaded `.torrent` files still carry your **passkey** in their announce URL, so nothing changes in your torrent client - Indexers set up with the passkey as API key keep working, but switching to the API key is recommended: a leaked passkey means rotating it in every torrent - An admin **passkey reset** also revokes the API key @@ -207,7 +208,7 @@ Errors are returned as XML: ## Security Notes - **HTTPS Only**: Always use HTTPS to protect your API key -- **API key = Identity**: anyone with it can search and download as you; regenerate it from the Integrations page if it leaks +- **API key = Identity**: anyone with it can search and download as you, and read your passkey from the downloaded `.torrent` files. If it leaks, regenerate it and have an admin reset your passkey - **Rate Limits**: API is rate limited to prevent abuse (see below) ## Rate Limits diff --git a/i18n/locales/de/integrations.json b/i18n/locales/de/integrations.json index 940784ce..c92e6db8 100644 --- a/i18n/locales/de/integrations.json +++ b/i18n/locales/de/integrations.json @@ -4,7 +4,7 @@ "title": "Integrationen", "subtitle": "Prowlarr, Sonarr, Radarr und andere Torznab-Apps verbinden", "credentials": "Torznab-Einstellungen", - "credentialsHint": "Füge diese Werte in deine App ein. Der API-Schlüssel erlaubt nur Suche und Downloads: Du kannst ihn jederzeit neu erzeugen, ohne deinen Passkey zu ändern.", + "credentialsHint": "Füge diese Werte in deine App ein. Der API-Schlüssel hält deinen Passkey aus den Einstellungen deiner Apps heraus, und ein neuer Schlüssel sperrt Apps mit dem alten. Damit heruntergeladene Torrents enthalten trotzdem deinen Passkey: Wenn der Schlüssel durchsickert, erzeuge ihn neu und bitte das Team, auch deinen Passkey zurückzusetzen.", "url": "URL", "apiPath": "API-Pfad", "apiKey": "API-Schlüssel", diff --git a/i18n/locales/en/integrations.json b/i18n/locales/en/integrations.json index 52289ddf..f42fa71f 100644 --- a/i18n/locales/en/integrations.json +++ b/i18n/locales/en/integrations.json @@ -4,7 +4,7 @@ "title": "Integrations", "subtitle": "Connect Prowlarr, Sonarr, Radarr and other Torznab apps", "credentials": "Torznab settings", - "credentialsHint": "Paste these values into your app. The API key only gives access to search and downloads: you can regenerate it at any time without touching your passkey.", + "credentialsHint": "Paste these values into your app. The API key keeps your passkey out of your apps' settings, and regenerating it cuts off apps using the old one. Torrents downloaded with it still carry your passkey: if the key leaks, regenerate it and ask staff to reset your passkey too.", "url": "URL", "apiPath": "API Path", "apiKey": "API Key", diff --git a/i18n/locales/es/integrations.json b/i18n/locales/es/integrations.json index 606f5c41..3984d2f5 100644 --- a/i18n/locales/es/integrations.json +++ b/i18n/locales/es/integrations.json @@ -4,7 +4,7 @@ "title": "Integraciones", "subtitle": "Conecta Prowlarr, Sonarr, Radarr y otras apps Torznab", "credentials": "Ajustes de Torznab", - "credentialsHint": "Pega estos valores en tu app. La clave API solo da acceso a búsquedas y descargas: puedes regenerarla cuando quieras sin tocar tu passkey.", + "credentialsHint": "Pega estos valores en tu app. La clave API evita poner tu passkey en la configuración de tus apps, y regenerarla corta las apps que usan la anterior. Los torrents descargados con ella siguen llevando tu passkey: si la clave se filtra, regenérala y pide también al staff que restablezca tu passkey.", "url": "URL", "apiPath": "Ruta de la API", "apiKey": "Clave API", diff --git a/i18n/locales/fr/integrations.json b/i18n/locales/fr/integrations.json index 689dbbf1..6d2a496e 100644 --- a/i18n/locales/fr/integrations.json +++ b/i18n/locales/fr/integrations.json @@ -4,7 +4,7 @@ "title": "Intégrations", "subtitle": "Connectez Prowlarr, Sonarr, Radarr et les autres apps Torznab", "credentials": "Paramètres Torznab", - "credentialsHint": "Collez ces valeurs dans votre app. La clé API ne donne accès qu'à la recherche et aux téléchargements : vous pouvez la régénérer à tout moment sans toucher à votre passkey.", + "credentialsHint": "Collez ces valeurs dans votre app. La clé API évite de mettre votre passkey dans la configuration de vos apps, et la régénérer coupe les apps qui utilisent l'ancienne. Les torrents téléchargés avec elle contiennent quand même votre passkey : si la clé fuite, régénérez-la et demandez aussi au staff de réinitialiser votre passkey.", "url": "URL", "apiPath": "Chemin de l'API", "apiKey": "Clé API", diff --git a/i18n/locales/it/integrations.json b/i18n/locales/it/integrations.json index b0e6a7f9..20df45b8 100644 --- a/i18n/locales/it/integrations.json +++ b/i18n/locales/it/integrations.json @@ -4,7 +4,7 @@ "title": "Integrazioni", "subtitle": "Collega Prowlarr, Sonarr, Radarr e altre app Torznab", "credentials": "Impostazioni Torznab", - "credentialsHint": "Incolla questi valori nella tua app. La chiave API dà accesso solo a ricerche e download: puoi rigenerarla in qualsiasi momento senza toccare la tua passkey.", + "credentialsHint": "Incolla questi valori nella tua app. La chiave API evita di mettere la tua passkey nelle impostazioni delle app, e rigenerarla blocca le app che usano quella vecchia. I torrent scaricati con essa contengono comunque la tua passkey: se la chiave trapela, rigenerala e chiedi allo staff di reimpostare anche la tua passkey.", "url": "URL", "apiPath": "Percorso API", "apiKey": "Chiave API", diff --git a/i18n/locales/ja/integrations.json b/i18n/locales/ja/integrations.json index 15b61871..f6593fe2 100644 --- a/i18n/locales/ja/integrations.json +++ b/i18n/locales/ja/integrations.json @@ -4,7 +4,7 @@ "title": "連携", "subtitle": "Prowlarr、Sonarr、Radarr などの Torznab アプリを接続します", "credentials": "Torznab 設定", - "credentialsHint": "これらの値をアプリに貼り付けてください。API キーで可能なのは検索とダウンロードのみです。パスキーに触れずにいつでも再生成できます。", + "credentialsHint": "これらの値をアプリに貼り付けてください。API キーを使えばパスキーをアプリの設定に置かずに済み、再生成すると古いキーを使うアプリは使えなくなります。ただし、このキーでダウンロードしたトレントにはパスキーが含まれます。キーが漏れた場合は再生成し、スタッフにパスキーのリセットも依頼してください。", "url": "URL", "apiPath": "API パス", "apiKey": "API キー", diff --git a/i18n/locales/ru/integrations.json b/i18n/locales/ru/integrations.json index c23125a0..8fdbac43 100644 --- a/i18n/locales/ru/integrations.json +++ b/i18n/locales/ru/integrations.json @@ -4,7 +4,7 @@ "title": "Интеграции", "subtitle": "Подключите Prowlarr, Sonarr, Radarr и другие приложения Torznab", "credentials": "Настройки Torznab", - "credentialsHint": "Вставьте эти значения в приложение. API-ключ даёт доступ только к поиску и загрузкам: его можно перевыпустить в любой момент, не меняя passkey.", + "credentialsHint": "Вставьте эти значения в приложение. API-ключ избавляет от необходимости хранить passkey в настройках приложений, а его перевыпуск отключает приложения со старым ключом. Торренты, скачанные с ним, всё равно содержат ваш passkey: если ключ утёк, перевыпустите его и попросите администрацию сбросить и passkey.", "url": "URL", "apiPath": "Путь API", "apiKey": "API-ключ", diff --git a/i18n/locales/zh/integrations.json b/i18n/locales/zh/integrations.json index 16d9d090..01869d32 100644 --- a/i18n/locales/zh/integrations.json +++ b/i18n/locales/zh/integrations.json @@ -4,7 +4,7 @@ "title": "集成", "subtitle": "连接 Prowlarr、Sonarr、Radarr 及其他 Torznab 应用", "credentials": "Torznab 设置", - "credentialsHint": "将这些值粘贴到你的应用中。API 密钥仅可用于搜索和下载:你可以随时重新生成,而不影响你的 passkey。", + "credentialsHint": "将这些值粘贴到你的应用中。API 密钥让你的 passkey 不必出现在应用设置里,重新生成后使用旧密钥的应用将失效。但用它下载的种子仍包含你的 passkey:如果密钥泄露,请重新生成,并请管理员同时重置你的 passkey。", "url": "URL", "apiPath": "API 路径", "apiKey": "API 密钥", From 8cc94f6160344f8eff2145a30de0a554b987a4ce Mon Sep 17 00:00:00 2001 From: Florian <52180080+florianjs@users.noreply.github.com> Date: Tue, 20 Oct 2026 13:14:34 +0200 Subject: [PATCH 4/4] feat(auth): let members reset their own passkey A leaked API key or .torrent file exposes the passkey, and only admins could reset it. The Integrations page now has a reset with a warning that torrents downloaded before must be downloaded again. An admin's Torznab API block now follows the passkey on any reset (self-service or admin): it is copied before the database update, so a Redis failure aborts the reset instead of lifting the block. The user menu reloads the passkey on every opening. Co-Authored-By: Claude Opus 5.5 --- app/components/shell/UserMenu.vue | 9 ++-- app/pages/integrations.vue | 32 ++++++++++++++ doc/integrations/torznab.md | 6 +-- i18n/locales/de/integrations.json | 11 ++++- i18n/locales/en/integrations.json | 11 ++++- i18n/locales/es/integrations.json | 11 ++++- i18n/locales/fr/integrations.json | 11 ++++- i18n/locales/it/integrations.json | 11 ++++- i18n/locales/ja/integrations.json | 11 ++++- i18n/locales/ru/integrations.json | 11 ++++- i18n/locales/zh/integrations.json | 11 ++++- .../admin/torznab/users/[id]/reset.post.ts | 7 ++- server/api/auth/passkey.post.ts | 44 +++++++++++++++++++ server/utils/torznabStats.ts | 12 +++++ 14 files changed, 180 insertions(+), 18 deletions(-) create mode 100644 server/api/auth/passkey.post.ts diff --git a/app/components/shell/UserMenu.vue b/app/components/shell/UserMenu.vue index d7ebe4ed..239ca18b 100644 --- a/app/components/shell/UserMenu.vue +++ b/app/components/shell/UserMenu.vue @@ -154,11 +154,10 @@ async function loadAccount() { function toggle() { open.value = !open.value; if (!open.value) return; - if (!passkey.value) { - $fetch<{ passkey: string }>('/api/auth/passkey') - .then((res) => (passkey.value = res.passkey)) - .catch(() => {}); - } + // Every opening: the passkey can be reset from the Integrations page + $fetch<{ passkey: string }>('/api/auth/passkey') + .then((res) => (passkey.value = res.passkey)) + .catch(() => {}); loadAccount(); } diff --git a/app/pages/integrations.vue b/app/pages/integrations.vue index a2c048c1..4f619441 100644 --- a/app/pages/integrations.vue +++ b/app/pages/integrations.vue @@ -101,6 +101,23 @@
+ + +
+
+
+ +

{{ t('integrations.passkey.title') }}

+
+
+
+

{{ t('integrations.passkey.hint') }}

+ +
+
@@ -189,5 +206,20 @@ async function rotateKey() { } } +const resettingPasskey = ref(false); +async function resetPasskey() { + if (!confirm(t('integrations.passkey.confirm'))) return; + resettingPasskey.value = true; + try { + await $fetch('/api/auth/passkey', { method: 'POST' }); + notifications.success(t('integrations.passkey.done'), t('integrations.passkey.doneHint')); + } catch (error) { + const message = (error as { data?: { message?: string } }).data?.message; + notifications.error(t('integrations.passkey.failed'), message); + } finally { + resettingPasskey.value = false; + } +} + useHead({ title: () => t('integrations.title') }); diff --git a/doc/integrations/torznab.md b/doc/integrations/torznab.md index 0606e140..39fbe7dd 100644 --- a/doc/integrations/torznab.md +++ b/doc/integrations/torznab.md @@ -51,10 +51,10 @@ Sonarr, Radarr and Lidarr can also add Trackarr directly: **Settings → Indexer The API key (`tk_` followed by 40 hex characters) is only for Torznab apps. It is created the first time you open the Integrations page. - **Regenerate key** on the Integrations page rotates it: apps using the old key stop working, your passkey and downloaded `.torrent` files are untouched -- The API key keeps the passkey out of app settings and logs, but it is **not a separate identity**: `.torrent` files downloaded with it carry the passkey. If the key leaks, regenerate it **and** ask an admin to reset your passkey (then re-download your active torrents) +- The API key keeps the passkey out of app settings and logs, but it is **not a separate identity**: `.torrent` files downloaded with it carry the passkey. If the key leaks, regenerate it **and** reset your passkey at the bottom of the Integrations page (then re-download your active torrents) - Downloaded `.torrent` files still carry your **passkey** in their announce URL, so nothing changes in your torrent client - Indexers set up with the passkey as API key keep working, but switching to the API key is recommended: a leaked passkey means rotating it in every torrent -- An admin **passkey reset** also revokes the API key +- An admin **passkey reset** also revokes the API key. A Torznab API block set by an admin survives any passkey reset ## API Endpoints @@ -208,7 +208,7 @@ Errors are returned as XML: ## Security Notes - **HTTPS Only**: Always use HTTPS to protect your API key -- **API key = Identity**: anyone with it can search and download as you, and read your passkey from the downloaded `.torrent` files. If it leaks, regenerate it and have an admin reset your passkey +- **API key = Identity**: anyone with it can search and download as you, and read your passkey from the downloaded `.torrent` files. If it leaks, regenerate it and reset your passkey from the Integrations page - **Rate Limits**: API is rate limited to prevent abuse (see below) ## Rate Limits diff --git a/i18n/locales/de/integrations.json b/i18n/locales/de/integrations.json index c92e6db8..1e9a773a 100644 --- a/i18n/locales/de/integrations.json +++ b/i18n/locales/de/integrations.json @@ -4,7 +4,7 @@ "title": "Integrationen", "subtitle": "Prowlarr, Sonarr, Radarr und andere Torznab-Apps verbinden", "credentials": "Torznab-Einstellungen", - "credentialsHint": "Füge diese Werte in deine App ein. Der API-Schlüssel hält deinen Passkey aus den Einstellungen deiner Apps heraus, und ein neuer Schlüssel sperrt Apps mit dem alten. Damit heruntergeladene Torrents enthalten trotzdem deinen Passkey: Wenn der Schlüssel durchsickert, erzeuge ihn neu und bitte das Team, auch deinen Passkey zurückzusetzen.", + "credentialsHint": "Füge diese Werte in deine App ein. Der API-Schlüssel hält deinen Passkey aus den Einstellungen deiner Apps heraus, und ein neuer Schlüssel sperrt Apps mit dem alten. Damit heruntergeladene Torrents enthalten trotzdem deinen Passkey: Wenn der Schlüssel durchsickert, erzeuge ihn neu und setze unten deinen Passkey zurück.", "url": "URL", "apiPath": "API-Pfad", "apiKey": "API-Schlüssel", @@ -45,6 +45,15 @@ "step3": "Suchergebnisse verlinken .torrent-Dateien über deinen API-Schlüssel.", "step4": "Heruntergeladene .torrent-Dateien enthalten bereits deinen Passkey: Im Torrent-Client ist nichts einzurichten." } + }, + "passkey": { + "title": "Passkey", + "hint": "Dein Passkey steckt in jeder heruntergeladenen .torrent-Datei und identifiziert dich beim Tracker. Setze ihn zurück, wenn eine .torrent-Datei oder dein API-Schlüssel durchgesickert ist. Vorher heruntergeladene Torrents funktionieren dann nicht mehr: Lade sie erneut herunter (oder ändere ihre Tracker-URL), um weiter zu seeden.", + "reset": "Passkey zurücksetzen", + "confirm": "Passkey zurücksetzen? Alle vorher heruntergeladenen Torrents müssen erneut heruntergeladen werden, um weiter zu seeden.", + "done": "Passkey zurückgesetzt", + "doneHint": "Lade deine aktiven Torrents erneut herunter, damit sie mit dem neuen Passkey melden.", + "failed": "Passkey konnte nicht zurückgesetzt werden" } } } diff --git a/i18n/locales/en/integrations.json b/i18n/locales/en/integrations.json index f42fa71f..ca98e413 100644 --- a/i18n/locales/en/integrations.json +++ b/i18n/locales/en/integrations.json @@ -4,7 +4,7 @@ "title": "Integrations", "subtitle": "Connect Prowlarr, Sonarr, Radarr and other Torznab apps", "credentials": "Torznab settings", - "credentialsHint": "Paste these values into your app. The API key keeps your passkey out of your apps' settings, and regenerating it cuts off apps using the old one. Torrents downloaded with it still carry your passkey: if the key leaks, regenerate it and ask staff to reset your passkey too.", + "credentialsHint": "Paste these values into your app. The API key keeps your passkey out of your apps' settings, and regenerating it cuts off apps using the old one. Torrents downloaded with it still carry your passkey: if the key leaks, regenerate it and reset your passkey below.", "url": "URL", "apiPath": "API Path", "apiKey": "API Key", @@ -45,6 +45,15 @@ "step3": "Search results link to .torrent files through your API key.", "step4": "Downloaded .torrent files already carry your passkey: nothing to configure in your torrent client." } + }, + "passkey": { + "title": "Passkey", + "hint": "Your passkey is in every .torrent file you download and identifies you to the tracker. Reset it if a .torrent file or your API key leaked. Torrents downloaded before stop working: download them again (or update their tracker URL) to keep seeding.", + "reset": "Reset passkey", + "confirm": "Reset your passkey? Every torrent downloaded before must be downloaded again to keep seeding.", + "done": "Passkey reset", + "doneHint": "Download your active torrents again so they announce with the new passkey.", + "failed": "Could not reset the passkey" } } } diff --git a/i18n/locales/es/integrations.json b/i18n/locales/es/integrations.json index 3984d2f5..43086d91 100644 --- a/i18n/locales/es/integrations.json +++ b/i18n/locales/es/integrations.json @@ -4,7 +4,7 @@ "title": "Integraciones", "subtitle": "Conecta Prowlarr, Sonarr, Radarr y otras apps Torznab", "credentials": "Ajustes de Torznab", - "credentialsHint": "Pega estos valores en tu app. La clave API evita poner tu passkey en la configuración de tus apps, y regenerarla corta las apps que usan la anterior. Los torrents descargados con ella siguen llevando tu passkey: si la clave se filtra, regenérala y pide también al staff que restablezca tu passkey.", + "credentialsHint": "Pega estos valores en tu app. La clave API evita poner tu passkey en la configuración de tus apps, y regenerarla corta las apps que usan la anterior. Los torrents descargados con ella siguen llevando tu passkey: si la clave se filtra, regénérala y restablece tu passkey más abajo.", "url": "URL", "apiPath": "Ruta de la API", "apiKey": "Clave API", @@ -45,6 +45,15 @@ "step3": "Los resultados de búsqueda enlazan los archivos .torrent mediante tu clave API.", "step4": "Los archivos .torrent descargados ya llevan tu passkey: nada que configurar en tu cliente torrent." } + }, + "passkey": { + "title": "Passkey", + "hint": "Tu passkey está en cada archivo .torrent que descargas y te identifica ante el tracker. Restablécelo si un archivo .torrent o tu clave API se filtró. Los torrents descargados antes dejan de funcionar: vuelve a descargarlos (o actualiza su URL de tracker) para seguir compartiendo.", + "reset": "Restablecer passkey", + "confirm": "¿Restablecer tu passkey? Todos los torrents descargados antes deberán descargarse de nuevo para seguir compartiendo.", + "done": "Passkey restablecido", + "doneHint": "Vuelve a descargar tus torrents activos para que anuncien con el nuevo passkey.", + "failed": "No se pudo restablecer el passkey" } } } diff --git a/i18n/locales/fr/integrations.json b/i18n/locales/fr/integrations.json index 6d2a496e..79e9fae3 100644 --- a/i18n/locales/fr/integrations.json +++ b/i18n/locales/fr/integrations.json @@ -4,7 +4,7 @@ "title": "Intégrations", "subtitle": "Connectez Prowlarr, Sonarr, Radarr et les autres apps Torznab", "credentials": "Paramètres Torznab", - "credentialsHint": "Collez ces valeurs dans votre app. La clé API évite de mettre votre passkey dans la configuration de vos apps, et la régénérer coupe les apps qui utilisent l'ancienne. Les torrents téléchargés avec elle contiennent quand même votre passkey : si la clé fuite, régénérez-la et demandez aussi au staff de réinitialiser votre passkey.", + "credentialsHint": "Collez ces valeurs dans votre app. La clé API évite de mettre votre passkey dans la configuration de vos apps, et la régénérer coupe les apps qui utilisent l'ancienne. Les torrents téléchargés avec elle contiennent quand même votre passkey : si la clé fuite, régénérez-la et réinitialisez votre passkey ci-dessous.", "url": "URL", "apiPath": "Chemin de l'API", "apiKey": "Clé API", @@ -45,6 +45,15 @@ "step3": "Les résultats de recherche pointent vers les fichiers .torrent via votre clé API.", "step4": "Les fichiers .torrent téléchargés contiennent déjà votre passkey : rien à configurer dans votre client torrent." } + }, + "passkey": { + "title": "Passkey", + "hint": "Votre passkey figure dans chaque fichier .torrent téléchargé et vous identifie auprès du tracker. Réinitialisez-le si un fichier .torrent ou votre clé API a fuité. Les torrents téléchargés avant cessent de fonctionner : téléchargez-les à nouveau (ou mettez à jour leur URL de tracker) pour continuer à seeder.", + "reset": "Réinitialiser le passkey", + "confirm": "Réinitialiser votre passkey ? Chaque torrent téléchargé avant devra être téléchargé à nouveau pour continuer à seeder.", + "done": "Passkey réinitialisé", + "doneHint": "Téléchargez à nouveau vos torrents actifs pour qu'ils annoncent avec le nouveau passkey.", + "failed": "Impossible de réinitialiser le passkey" } } } diff --git a/i18n/locales/it/integrations.json b/i18n/locales/it/integrations.json index 20df45b8..e83f73ca 100644 --- a/i18n/locales/it/integrations.json +++ b/i18n/locales/it/integrations.json @@ -4,7 +4,7 @@ "title": "Integrazioni", "subtitle": "Collega Prowlarr, Sonarr, Radarr e altre app Torznab", "credentials": "Impostazioni Torznab", - "credentialsHint": "Incolla questi valori nella tua app. La chiave API evita di mettere la tua passkey nelle impostazioni delle app, e rigenerarla blocca le app che usano quella vecchia. I torrent scaricati con essa contengono comunque la tua passkey: se la chiave trapela, rigenerala e chiedi allo staff di reimpostare anche la tua passkey.", + "credentialsHint": "Incolla questi valori nella tua app. La chiave API evita di mettere la tua passkey nelle impostazioni delle app, e rigenerarla blocca le app che usano quella vecchia. I torrent scaricati con essa contengono comunque la tua passkey: se la chiave trapela, rigenerala e reimposta la passkey qui sotto.", "url": "URL", "apiPath": "Percorso API", "apiKey": "Chiave API", @@ -45,6 +45,15 @@ "step3": "I risultati di ricerca puntano ai file .torrent tramite la tua chiave API.", "step4": "I file .torrent scaricati contengono già la tua passkey: niente da configurare nel client torrent." } + }, + "passkey": { + "title": "Passkey", + "hint": "La tua passkey è in ogni file .torrent scaricato e ti identifica presso il tracker. Reimpostala se un file .torrent o la tua chiave API sono trapelati. I torrent scaricati prima smettono di funzionare: scaricali di nuovo (o aggiorna il loro URL del tracker) per continuare a fare seed.", + "reset": "Reimposta passkey", + "confirm": "Reimpostare la passkey? Ogni torrent scaricato prima dovrà essere scaricato di nuovo per continuare a fare seed.", + "done": "Passkey reimpostata", + "doneHint": "Scarica di nuovo i tuoi torrent attivi perché annuncino con la nuova passkey.", + "failed": "Impossibile reimpostare la passkey" } } } diff --git a/i18n/locales/ja/integrations.json b/i18n/locales/ja/integrations.json index f6593fe2..6333ce7f 100644 --- a/i18n/locales/ja/integrations.json +++ b/i18n/locales/ja/integrations.json @@ -4,7 +4,7 @@ "title": "連携", "subtitle": "Prowlarr、Sonarr、Radarr などの Torznab アプリを接続します", "credentials": "Torznab 設定", - "credentialsHint": "これらの値をアプリに貼り付けてください。API キーを使えばパスキーをアプリの設定に置かずに済み、再生成すると古いキーを使うアプリは使えなくなります。ただし、このキーでダウンロードしたトレントにはパスキーが含まれます。キーが漏れた場合は再生成し、スタッフにパスキーのリセットも依頼してください。", + "credentialsHint": "これらの値をアプリに貼り付けてください。API キーを使えばパスキーをアプリの設定に置かずに済み、再生成すると古いキーを使うアプリは使えなくなります。ただし、このキーでダウンロードしたトレントにはパスキーが含まれます。キーが漏れた場合は再生成し、下でパスキーもリセットしてください。", "url": "URL", "apiPath": "API パス", "apiKey": "API キー", @@ -45,6 +45,15 @@ "step3": "検索結果は API キー経由で .torrent ファイルにリンクします。", "step4": "ダウンロードした .torrent ファイルにはすでにパスキーが含まれています。トレントクライアント側の設定は不要です。" } + }, + "passkey": { + "title": "パスキー", + "hint": "パスキーはダウンロードしたすべての .torrent ファイルに含まれ、トラッカーであなたを識別します。.torrent ファイルや API キーが漏れた場合はリセットしてください。以前にダウンロードしたトレントは動作しなくなるため、シードを続けるには再ダウンロード(またはトラッカー URL を更新)してください。", + "reset": "パスキーをリセット", + "confirm": "パスキーをリセットしますか?以前にダウンロードしたトレントでシードを続けるには、すべて再ダウンロードする必要があります。", + "done": "パスキーをリセットしました", + "doneHint": "新しいパスキーでアナウンスするよう、アクティブなトレントを再ダウンロードしてください。", + "failed": "パスキーをリセットできませんでした" } } } diff --git a/i18n/locales/ru/integrations.json b/i18n/locales/ru/integrations.json index 8fdbac43..a02687a5 100644 --- a/i18n/locales/ru/integrations.json +++ b/i18n/locales/ru/integrations.json @@ -4,7 +4,7 @@ "title": "Интеграции", "subtitle": "Подключите Prowlarr, Sonarr, Radarr и другие приложения Torznab", "credentials": "Настройки Torznab", - "credentialsHint": "Вставьте эти значения в приложение. API-ключ избавляет от необходимости хранить passkey в настройках приложений, а его перевыпуск отключает приложения со старым ключом. Торренты, скачанные с ним, всё равно содержат ваш passkey: если ключ утёк, перевыпустите его и попросите администрацию сбросить и passkey.", + "credentialsHint": "Вставьте эти значения в приложение. API-ключ избавляет от необходимости хранить passkey в настройках приложений, а его перевыпуск отключает приложения со старым ключом. Торренты, скачанные с ним, всё равно содержат ваш passkey: если ключ утёк, перевыпустите его и сбросьте passkey ниже.", "url": "URL", "apiPath": "Путь API", "apiKey": "API-ключ", @@ -45,6 +45,15 @@ "step3": "Результаты поиска ссылаются на .torrent-файлы через ваш API-ключ.", "step4": "Загруженные .torrent-файлы уже содержат ваш passkey: в торрент-клиенте ничего настраивать не нужно." } + }, + "passkey": { + "title": "Passkey", + "hint": "Ваш passkey есть в каждом скачанном .torrent-файле и идентифицирует вас на трекере. Сбросьте его, если утёк .torrent-файл или API-ключ. Скачанные ранее торренты перестанут работать: скачайте их заново (или обновите URL трекера), чтобы продолжить раздачу.", + "reset": "Сбросить passkey", + "confirm": "Сбросить passkey? Все скачанные ранее торренты придётся скачать заново, чтобы продолжить раздачу.", + "done": "Passkey сброшен", + "doneHint": "Скачайте активные торренты заново, чтобы они анонсировались с новым passkey.", + "failed": "Не удалось сбросить passkey" } } } diff --git a/i18n/locales/zh/integrations.json b/i18n/locales/zh/integrations.json index 01869d32..57e4ffc5 100644 --- a/i18n/locales/zh/integrations.json +++ b/i18n/locales/zh/integrations.json @@ -4,7 +4,7 @@ "title": "集成", "subtitle": "连接 Prowlarr、Sonarr、Radarr 及其他 Torznab 应用", "credentials": "Torznab 设置", - "credentialsHint": "将这些值粘贴到你的应用中。API 密钥让你的 passkey 不必出现在应用设置里,重新生成后使用旧密钥的应用将失效。但用它下载的种子仍包含你的 passkey:如果密钥泄露,请重新生成,并请管理员同时重置你的 passkey。", + "credentialsHint": "将这些值粘贴到你的应用中。API 密钥让你的 passkey 不必出现在应用设置里,重新生成后使用旧密钥的应用将失效。但用它下载的种子仍包含你的 passkey:如果密钥泄露,请重新生成,并在下方重置你的 passkey。", "url": "URL", "apiPath": "API 路径", "apiKey": "API 密钥", @@ -45,6 +45,15 @@ "step3": "搜索结果通过你的 API 密钥链接到 .torrent 文件。", "step4": "下载的 .torrent 文件已包含你的 passkey:无需在 torrent 客户端中配置。" } + }, + "passkey": { + "title": "Passkey", + "hint": "你的 passkey 包含在每个下载的 .torrent 文件中,用于在 tracker 上识别你。如果 .torrent 文件或 API 密钥泄露,请重置它。之前下载的种子将失效:请重新下载(或更新其 tracker 地址)以继续做种。", + "reset": "重置 passkey", + "confirm": "重置 passkey?之前下载的所有种子都需要重新下载才能继续做种。", + "done": "passkey 已重置", + "doneHint": "请重新下载正在做种的种子,使其使用新的 passkey 汇报。", + "failed": "无法重置 passkey" } } } diff --git a/server/api/admin/torznab/users/[id]/reset.post.ts b/server/api/admin/torznab/users/[id]/reset.post.ts index cd56255d..7c4f028f 100644 --- a/server/api/admin/torznab/users/[id]/reset.post.ts +++ b/server/api/admin/torznab/users/[id]/reset.post.ts @@ -7,7 +7,7 @@ import { requireAdminSession } from '../../../../../utils/adminAuth'; import { db, schema } from '../../../../../db'; import { eq } from 'drizzle-orm'; import { generatePasskey } from '../../../../../utils/auth'; -import { clearTorznabUserStats } from '../../../../../utils/torznabStats'; +import { clearTorznabUserStats, copyTorznabBlock, unblockTorznabUser } from '../../../../../utils/torznabStats'; import { invalidateTrackerUsers } from '~~/server/tracker/client'; export default defineEventHandler(async (event) => { @@ -38,8 +38,10 @@ export default defineEventHandler(async (event) => { const oldPasskey = existingUsers[0].passkey; - // Generate new passkey + // Generate new passkey; an API block follows it (copied first, see + // copyTorznabBlock) const newPasskey = generatePasskey(); + const wasBlocked = await copyTorznabBlock(oldPasskey, newPasskey); // Revoke the API key too: indexers stop working until the user takes a new one await db @@ -48,6 +50,7 @@ export default defineEventHandler(async (event) => { .where(eq(schema.users.id, userId)); // Clear old stats + if (wasBlocked) await unblockTorznabUser(oldPasskey); await clearTorznabUserStats(oldPasskey); // Tracker caches the row for a few seconds diff --git a/server/api/auth/passkey.post.ts b/server/api/auth/passkey.post.ts new file mode 100644 index 00000000..7b74b0b7 --- /dev/null +++ b/server/api/auth/passkey.post.ts @@ -0,0 +1,44 @@ +import { and, eq } from 'drizzle-orm'; +import { db } from '../../db'; +import { users } from '../../db/schema'; +import { generatePasskey } from '../../utils/auth'; +import { protectEndpoint } from '../../utils/rateLimit'; +import { clearTorznabUserStats, copyTorznabBlock, unblockTorznabUser } from '../../utils/torznabStats'; +import { invalidateTrackerUsers } from '../../tracker/client'; + +/** + * POST /api/auth/passkey + * Reset the current user's passkey, e.g. after a leak. Torrents downloaded + * before keep the old one in their announce URL and stop working until they + * are downloaded again. + */ +export default defineEventHandler(async (event) => { + await protectEndpoint(event, 'mutation'); + const session = await requireAuthSession(event); + const oldPasskey = await requireUserPasskey(event); + + const passkey = generatePasskey(); + // An admin's API block must survive the reset: copied first, so a Redis + // failure aborts the reset instead of lifting the block + const wasBlocked = await copyTorznabBlock(oldPasskey, passkey); + + // Only from the passkey this request was checked against: two resets at + // once must not both report success with different keys + const [updated] = await db + .update(users) + .set({ passkey }) + .where(and(eq(users.id, session.user.id), eq(users.passkey, oldPasskey))) + .returning({ passkey: users.passkey }); + if (!updated) { + throw createError({ statusCode: 409, message: 'Passkey changed meanwhile, reload the page' }); + } + + // Request stats start over; the old passkey is gone from the block list + if (wasBlocked) await unblockTorznabUser(oldPasskey); + await clearTorznabUserStats(oldPasskey); + + // The tracker caches user rows by passkey for a few seconds + invalidateTrackerUsers(); + + return { passkey }; +}); diff --git a/server/utils/torznabStats.ts b/server/utils/torznabStats.ts index e57eb468..e0a4c6de 100644 --- a/server/utils/torznabStats.ts +++ b/server/utils/torznabStats.ts @@ -448,6 +448,18 @@ export async function unblockTorznabUser(passkey: string): Promise { } } +/** + * Copy an API block to a new passkey before a reset, so the reset does not + * lift it. Throws on Redis errors: better no reset than a lost block. + * Returns whether there was a block. + */ +export async function copyTorznabBlock(oldPasskey: string, newPasskey: string): Promise { + const data = await redis.hget(KEYS.BLOCKED, oldPasskey); + if (!data) return false; + await redis.hset(KEYS.BLOCKED, newPasskey, data); + return true; +} + /** * Check if a user is blocked */