From 24882ef269eff34038c1777045d0faae82b1d0bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=A1clav=20Brzezina?= Date: Tue, 13 Oct 2026 15:07:23 +0200 Subject: [PATCH 01/12] fix(ingestion): keep synthetic activity timestamps out of the sync cursor --- .../modules/ingestion/ingestion.service.ts | 27 +++++----- .../src/modules/ingestion/sync-cursor.spec.ts | 36 +++++++++++++ .../src/modules/ingestion/sync-cursor.ts | 20 ++++++++ packages/core/src/types.ts | 7 +++ .../provider-arr/src/arr.provider.spec.ts | 50 +++++++++++++++++++ packages/provider-arr/src/arr.provider.ts | 2 + 6 files changed, 130 insertions(+), 12 deletions(-) create mode 100644 apps/backend/src/modules/ingestion/sync-cursor.spec.ts create mode 100644 apps/backend/src/modules/ingestion/sync-cursor.ts create mode 100644 packages/provider-arr/src/arr.provider.spec.ts diff --git a/apps/backend/src/modules/ingestion/ingestion.service.ts b/apps/backend/src/modules/ingestion/ingestion.service.ts index 66dd43d..a2c5960 100644 --- a/apps/backend/src/modules/ingestion/ingestion.service.ts +++ b/apps/backend/src/modules/ingestion/ingestion.service.ts @@ -19,6 +19,8 @@ import { IssuesService } from '../issues/issues.service'; import { LogsGateway } from '../logs/logs.gateway'; import { SessionsGateway } from '../sessions/sessions.gateway'; +import { computeSyncCursor } from './sync-cursor'; + import type { MediaServerProvider, NormalizedSession, NormalizedActivity } from '@logarr/core'; import type { EmbySession, @@ -241,18 +243,19 @@ export class IngestionService implements OnModuleInit, OnModuleDestroy { // Update last sync timestamp in database (persistence!) if (insertedCount > 0) { - const maxTimestamp = activities.reduce( - (max, a) => (a.timestamp > max ? a.timestamp : max), - activities[0]!.timestamp - ); - - await this.db - .update(schema.servers) - .set({ - lastActivitySync: maxTimestamp, - updatedAt: new Date(), - }) - .where(eq(schema.servers.id, server.id)); + // Synthetic rows (detection-time stamps) must not move the cursor; + // if nothing qualifies, leave it unchanged. + const nextCursor = computeSyncCursor(activities); + + if (nextCursor) { + await this.db + .update(schema.servers) + .set({ + lastActivitySync: nextCursor, + updatedAt: new Date(), + }) + .where(eq(schema.servers.id, server.id)); + } } } diff --git a/apps/backend/src/modules/ingestion/sync-cursor.spec.ts b/apps/backend/src/modules/ingestion/sync-cursor.spec.ts new file mode 100644 index 0000000..2191cb1 --- /dev/null +++ b/apps/backend/src/modules/ingestion/sync-cursor.spec.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest'; + +import { computeSyncCursor } from './sync-cursor'; + +import type { NormalizedActivity } from '@logarr/core'; + +const act = (iso: string, advancesSyncCursor?: boolean): NormalizedActivity => ({ + id: iso, + type: 't', + name: 'n', + severity: 'info', + timestamp: new Date(iso), + ...(advancesSyncCursor === undefined ? {} : { advancesSyncCursor }), +}); + +describe('computeSyncCursor', () => { + it('uses the max timestamp of event-time rows, ignoring synthetic rows', () => { + const result = computeSyncCursor([ + act('2026-01-01T00:00:00Z'), + act('2026-01-03T00:00:00Z', true), + act('2026-01-02T00:00:00Z'), + act('2026-06-01T00:00:00Z', false), + ]); + expect(result).toEqual(new Date('2026-01-03T00:00:00Z')); + }); + + it('returns undefined when all rows are synthetic', () => { + expect( + computeSyncCursor([act('2026-06-01T00:00:00Z', false), act('2026-06-02T00:00:00Z', false)]) + ).toBeUndefined(); + }); + + it('returns undefined for an empty batch', () => { + expect(computeSyncCursor([])).toBeUndefined(); + }); +}); diff --git a/apps/backend/src/modules/ingestion/sync-cursor.ts b/apps/backend/src/modules/ingestion/sync-cursor.ts new file mode 100644 index 0000000..0352d87 --- /dev/null +++ b/apps/backend/src/modules/ingestion/sync-cursor.ts @@ -0,0 +1,20 @@ +import type { NormalizedActivity } from '@logarr/core'; + +/** + * Compute the next `lastActivitySync` value from a batch of activities. + * + * Only activities whose timestamp is an event time count (`advancesSyncCursor` + * !== false). Synthetic rows stamped with the detection time are ignored so they + * can't move the cursor to "now" and hide history not returned in this poll. + * + * @returns the max qualifying timestamp, or undefined if none qualify + * (caller should leave the stored cursor unchanged). + */ +export function computeSyncCursor(activities: readonly NormalizedActivity[]): Date | undefined { + let max: Date | undefined; + for (const activity of activities) { + if (activity.advancesSyncCursor === false) continue; + if (!max || activity.timestamp > max) max = activity.timestamp; + } + return max; +} diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index a55b7e3..1e1dd04 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -239,6 +239,13 @@ export interface NormalizedActivity { readonly itemId?: string | undefined; readonly timestamp: Date; readonly metadata?: Record | undefined; + /** + * Whether this activity's timestamp may move the provider's `since` sync cursor. + * Defaults to true. Set to false for synthetic rows whose timestamp is the + * detection time rather than the event time (health checks, connection status, + * queue warnings), so they don't skip history that wasn't returned this poll. + */ + readonly advancesSyncCursor?: boolean | undefined; } // ============================================================================= diff --git a/packages/provider-arr/src/arr.provider.spec.ts b/packages/provider-arr/src/arr.provider.spec.ts new file mode 100644 index 0000000..09a4aba --- /dev/null +++ b/packages/provider-arr/src/arr.provider.spec.ts @@ -0,0 +1,50 @@ +import { describe, it, expect } from 'vitest'; + +import { ArrBaseProvider } from './arr.provider.js'; + +import type { ArrHealthCheck, ArrHistoryRecordBase, ArrQueueItemBase } from './arr.types.js'; +import type { NormalizedActivity } from '@logarr/core'; + +class TestArrProvider extends ArrBaseProvider { + readonly id = 'test'; + readonly name = 'Test'; + protected getHistoryRecords(): Promise { + return Promise.resolve([]); + } + protected normalizeHistoryRecord(): NormalizedActivity { + throw new Error('not used'); + } + protected getMediaType(): 'series' { + return 'series'; + } + health(checks: readonly ArrHealthCheck[]): NormalizedActivity[] { + return this.normalizeHealthChecks(checks); + } + queue(items: readonly ArrQueueItemBase[]): NormalizedActivity[] { + return this.normalizeQueueItems(items); + } +} + +describe('ArrBaseProvider synthetic activities', () => { + it('flags health activities as not advancing the sync cursor', () => { + const result = new TestArrProvider().health([ + { source: 'IndexerCheck', type: 'warning', message: 'm', wikiUrl: '' }, + ] as unknown as ArrHealthCheck[]); + expect(result).toHaveLength(1); + expect(result[0]!.advancesSyncCursor).toBe(false); + }); + + it('flags queue activities as not advancing the sync cursor', () => { + const result = new TestArrProvider().queue([ + { + title: 'x', + downloadId: 'd1', + trackedDownloadStatus: 'warning', + errorMessage: 'bad', + statusMessages: [], + }, + ] as unknown as ArrQueueItemBase[]); + expect(result).toHaveLength(1); + expect(result[0]!.advancesSyncCursor).toBe(false); + }); +}); diff --git a/packages/provider-arr/src/arr.provider.ts b/packages/provider-arr/src/arr.provider.ts index 20b3961..8d405c1 100644 --- a/packages/provider-arr/src/arr.provider.ts +++ b/packages/provider-arr/src/arr.provider.ts @@ -278,6 +278,7 @@ export abstract class ArrBaseProvider implements MediaServerProvider { overview: check.message, severity: check.type === 'error' ? 'error' : ('warn' as const), timestamp: new Date(), + advancesSyncCursor: false, })); } @@ -295,6 +296,7 @@ export abstract class ArrBaseProvider implements MediaServerProvider { item.errorMessage ?? item.statusMessages.map((m) => m.messages.join(', ')).join('; '), severity: 'warn' as const, timestamp: new Date(), + advancesSyncCursor: false, })); } From 76697cc46261ec16142fa5067a8f1167988a44db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=A1clav=20Brzezina?= Date: Sat, 10 Oct 2026 03:06:38 +0200 Subject: [PATCH 02/12] feat(providers): add qBittorrent integration with username/password auth Add @logarr/provider-qbittorrent and optional username credentials for servers. Credentials (generic, reusable by other providers): - nullable servers.username column (migration 0010) plumbed through core types, DTOs, ServersService, env seeding and the add/edit forms - the qBittorrent password is stored in the existing secret field; an empty secret is allowed only for qBittorrent (auth bypass), other providers still require an API key Provider: - fetch-based client supporting cookie login, auth bypass and the 5.3+ API key; generic cookie jar, Referer/Origin for CSRF, single re-login on 401/403, no redirect following, clear ban/proxy/CSRF errors - escalating login cooldown (5/15/60 min) so polling cannot trigger qBittorrent's IP ban; session state keyed by a hash of URL+credentials - activity from warning/critical log/main entries (cursor with restart handling, skipped when a log path is configured), errored torrents and connection status, with stable day-bucketed ids - tracker passkeys redacted and tracker URLs reduced to host; secrets never appear in errors or logs - qbittorrent.log file tailing parser - backend registration, env seeding (QBITTORRENT_URL/USERNAME/PASSWORD, /qbittorrent-logs), proxy guard, Dockerfiles, compose, turbo env, README --- .env.example | 14 + .env.tpl | 5 + README.md | 108 +- apps/backend/Dockerfile | 2 + apps/backend/Dockerfile.dev | 2 +- .../drizzle/0010_add-server-username.sql | 1 + apps/backend/drizzle/meta/0010_snapshot.json | 3281 +++++++++++++++++ apps/backend/drizzle/meta/_journal.json | 7 + apps/backend/package.json | 1 + apps/backend/src/database/schema.ts | 3 + .../log-file-processor.qbittorrent.spec.ts | 39 + .../modules/ingestion/ingestion.service.ts | 5 + .../src/modules/proxy/proxy.service.spec.ts | 50 +- .../src/modules/proxy/proxy.service.ts | 20 +- .../servers/server-seed.service.spec.ts | 125 + .../modules/servers/server-seed.service.ts | 30 +- .../src/modules/servers/servers.dto.ts | 20 +- .../modules/servers/servers.service.spec.ts | 194 +- .../src/modules/servers/servers.service.ts | 39 +- .../src/components/add-source-modal.tsx | 9 +- .../components/edit-server-dialog.test.tsx | 182 + .../src/components/edit-server-dialog.tsx | 105 +- .../source-diagnostics-dialog.test.tsx | 1 + apps/frontend/src/lib/api.ts | 3 + apps/frontend/src/lib/integrations.test.ts | 33 + apps/frontend/src/lib/integrations.ts | 25 +- apps/frontend/src/lib/server-payload.test.ts | 75 + apps/frontend/src/lib/server-payload.ts | 42 + docker-compose.dev.yml | 4 + docker-compose.yml | 12 + packages/core/src/types.ts | 6 + packages/provider-qbittorrent/package.json | 36 + packages/provider-qbittorrent/src/index.ts | 14 + .../src/qbittorrent.activity.spec.ts | 498 +++ .../src/qbittorrent.client.spec.ts | 564 +++ .../src/qbittorrent.client.ts | 351 ++ .../src/qbittorrent.cookies.spec.ts | 205 + .../src/qbittorrent.hardening.spec.ts | 275 ++ .../src/qbittorrent.parser.spec.ts | 177 + .../src/qbittorrent.parser.ts | 95 + .../src/qbittorrent.provider.spec.ts | 445 +++ .../src/qbittorrent.provider.ts | 353 ++ .../src/qbittorrent.redact.ts | 22 + .../src/qbittorrent.types.ts | 45 + packages/provider-qbittorrent/tsconfig.json | 14 + packages/provider-qbittorrent/tsup.config.ts | 22 + .../provider-qbittorrent/vitest.config.ts | 8 + pnpm-lock.yaml | 22 + turbo.json | 6 +- 49 files changed, 7505 insertions(+), 90 deletions(-) create mode 100644 apps/backend/drizzle/0010_add-server-username.sql create mode 100644 apps/backend/drizzle/meta/0010_snapshot.json create mode 100644 apps/backend/src/modules/file-ingestion/log-file-processor.qbittorrent.spec.ts create mode 100644 apps/backend/src/modules/servers/server-seed.service.spec.ts create mode 100644 apps/frontend/src/components/edit-server-dialog.test.tsx create mode 100644 apps/frontend/src/lib/server-payload.test.ts create mode 100644 apps/frontend/src/lib/server-payload.ts create mode 100644 packages/provider-qbittorrent/package.json create mode 100644 packages/provider-qbittorrent/src/index.ts create mode 100644 packages/provider-qbittorrent/src/qbittorrent.activity.spec.ts create mode 100644 packages/provider-qbittorrent/src/qbittorrent.client.spec.ts create mode 100644 packages/provider-qbittorrent/src/qbittorrent.client.ts create mode 100644 packages/provider-qbittorrent/src/qbittorrent.cookies.spec.ts create mode 100644 packages/provider-qbittorrent/src/qbittorrent.hardening.spec.ts create mode 100644 packages/provider-qbittorrent/src/qbittorrent.parser.spec.ts create mode 100644 packages/provider-qbittorrent/src/qbittorrent.parser.ts create mode 100644 packages/provider-qbittorrent/src/qbittorrent.provider.spec.ts create mode 100644 packages/provider-qbittorrent/src/qbittorrent.provider.ts create mode 100644 packages/provider-qbittorrent/src/qbittorrent.redact.ts create mode 100644 packages/provider-qbittorrent/src/qbittorrent.types.ts create mode 100644 packages/provider-qbittorrent/tsconfig.json create mode 100644 packages/provider-qbittorrent/tsup.config.ts create mode 100644 packages/provider-qbittorrent/vitest.config.ts diff --git a/.env.example b/.env.example index 10a722f..f899a3c 100644 --- a/.env.example +++ b/.env.example @@ -77,6 +77,12 @@ WHISPARR_API_KEY= # Lidarr LIDARR_URL= LIDARR_API_KEY= +# qBittorrent (WebUI) +# QBITTORRENT_PASSWORD is the WebUI password, or an API key if QBITTORRENT_USERNAME is empty (qBittorrent 5.3+). +# Leave USERNAME and PASSWORD empty if qBittorrent bypasses authentication for Logarr's IP. +QBITTORRENT_URL= +QBITTORRENT_USERNAME= +QBITTORRENT_PASSWORD= # ============================================================================= # SECURITY & AUTHENTICATION @@ -156,6 +162,11 @@ WHISPARR_LOGS_PATH= # Linux: ~/.config/Lidarr/logs # Docker: /path/to/lidarr/config/logs LIDARR_LOGS_PATH= +# qBittorrent examples (container path: /qbittorrent-logs): +# Docker (linuxserver, unverified): /path/to/qbittorrent/config/qBittorrent/logs +# Linux: ~/.local/share/qBittorrent/logs +# Windows: %LOCALAPPDATA%/qBittorrent/logs +QBITTORRENT_LOGS_PATH= # ============================================================================= # MULTIPLE INSTANCES (optional - for multiple servers of the same type) @@ -205,3 +216,6 @@ WHISPARR_LOGS_PATH_3= LIDARR_LOGS_PATH_1= LIDARR_LOGS_PATH_2= LIDARR_LOGS_PATH_3= +QBITTORRENT_LOGS_PATH_1= +QBITTORRENT_LOGS_PATH_2= +QBITTORRENT_LOGS_PATH_3= diff --git a/.env.tpl b/.env.tpl index 3261f01..5b562f3 100644 --- a/.env.tpl +++ b/.env.tpl @@ -87,6 +87,10 @@ WHISPARR_API_KEY={{ op://Development/Logarr-Dev/WHISPARR_API_KEY }} # Lidarr LIDARR_URL={{ op://Development/Logarr-Dev/LIDARR_URL }} LIDARR_API_KEY={{ op://Development/Logarr-Dev/LIDARR_API_KEY }} +# qBittorrent (not in the 1Password vault yet: add the items, then uncomment) +# QBITTORRENT_URL={{ op://Development/Logarr-Dev/QBITTORRENT_URL }} +# QBITTORRENT_USERNAME={{ op://Development/Logarr-Dev/QBITTORRENT_USERNAME }} +# QBITTORRENT_PASSWORD={{ op://Development/Logarr-Dev/QBITTORRENT_PASSWORD }} # ============================================================================= # SECURITY & AUTHENTICATION @@ -111,3 +115,4 @@ RADARR_LOGS_PATH={{ op://Development/Logarr-Dev/RADARR_LOGS_PATH }} PROWLARR_LOGS_PATH={{ op://Development/Logarr-Dev/PROWLARR_LOGS_PATH }} WHISPARR_LOGS_PATH={{ op://Development/Logarr-Dev/WHISPARR_LOGS_PATH }} LIDARR_LOGS_PATH={{ op://Development/Logarr-Dev/LIDARR_LOGS_PATH }} +# QBITTORRENT_LOGS_PATH={{ op://Development/Logarr-Dev/QBITTORRENT_LOGS_PATH }} diff --git a/README.md b/README.md index 6c26f02..185d399 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ [![Docker](https://img.shields.io/badge/Docker-Ready-2496ED?logo=docker&logoColor=white)](https://www.docker.com/) [![License](https://img.shields.io/badge/License-MIT-green.svg)](LICENSE) -Unified logging, intelligent issue detection, and AI-powered analysis for Plex, Jellyfin, Emby, Sonarr, Radarr, Prowlarr, Whisparr, Lidarr, and more. +Unified logging, intelligent issue detection, and AI-powered analysis for Plex, Jellyfin, Emby, Sonarr, Radarr, Prowlarr, Whisparr, Lidarr, qBittorrent, and more. [Quick Start](#quick-start) · [Features](#features) · [Screenshots](#screenshots) · [Documentation](#documentation) @@ -95,13 +95,13 @@ _Configure multiple AI providers for issue analysis (Anthropic, OpenAI, Google, ## Supported Servers -| Server | Status | Server | Status | -| ------------ | ------------ | ------------ | ------------ | -| **Jellyfin** | ✅ Supported | **Plex** | ✅ Supported | -| **Sonarr** | ✅ Supported | **Emby** | ✅ Supported | -| **Radarr** | ✅ Supported | **Whisparr** | ✅ Supported | -| **Prowlarr** | ✅ Supported | **Lidarr** | ✅ Supported | -| **Kodi** | 🚧 Planned | | | +| Server | Status | Server | Status | +| ------------ | ------------ | --------------- | ------------ | +| **Jellyfin** | ✅ Supported | **Plex** | ✅ Supported | +| **Sonarr** | ✅ Supported | **Emby** | ✅ Supported | +| **Radarr** | ✅ Supported | **Whisparr** | ✅ Supported | +| **Prowlarr** | ✅ Supported | **Lidarr** | ✅ Supported | +| **Kodi** | 🚧 Planned | **qBittorrent** | ✅ Supported | > Logarr uses a provider architecture—adding support for new servers is straightforward. [Contributions welcome!](#contributing) @@ -179,6 +179,7 @@ flowchart TB PR[Prowlarr] WH[Whisparr] LI[Lidarr] + QB[qBittorrent] end FE <--> API @@ -186,6 +187,7 @@ flowchart TB API <--> PG API <--> RD API <--> PX & JF & EM & SO & RA & PR & WH & LI + API <--> PX & JF & EM & SO & RA & PR & WH & QB ``` ### How It Works @@ -274,22 +276,43 @@ RADARR_LOGS_PATH=/path/to/radarr/config/logs PROWLARR_LOGS_PATH=/path/to/prowlarr/config/logs WHISPARR_LOGS_PATH=/path/to/whisparr/config/logs LIDARR_LOGS_PATH=/path/to/lidarr/config/logs + +# Download clients +QBITTORRENT_LOGS_PATH=/path/to/qbittorrent/config/qBittorrent/logs ``` 2. **Restart Logarr** — The docker-compose mounts these paths automatically. 3. **Configure in UI** — Go to **Sources** → Edit your server → Enable file ingestion with the container path: -| Server | Container Path | -| -------- | ---------------- | -| Plex | `/plex-logs` | -| Jellyfin | `/jellyfin-logs` | -| Emby | `/emby-logs` | -| Sonarr | `/sonarr-logs` | -| Radarr | `/radarr-logs` | -| Prowlarr | `/prowlarr-logs` | -| Whisparr | `/whisp-logs` | -| Lidarr | `/lidarr-logs` | +| Server | Container Path | +| ----------- | ------------------- | +| Plex | `/plex-logs` | +| Jellyfin | `/jellyfin-logs` | +| Emby | `/emby-logs` | +| Sonarr | `/sonarr-logs` | +| Radarr | `/radarr-logs` | +| Prowlarr | `/prowlarr-logs` | +| Whisparr | `/whisp-logs` | +| Lidarr | `/lidarr-logs` | +| qBittorrent | `/qbittorrent-logs` | + +#### qBittorrent authentication + +qBittorrent is a standalone provider (WebUI API v2) and is detected from the stored credentials: + +| Setup | Username | Password / API key | Notes | +| ---------------------------- | -------- | ------------------ | -------------------------------------------------------- | +| Username + password | set | WebUI password | Cookie session, re-login on expiry | +| API key (qBittorrent >= 5.2) | empty | API key | Sent as `Authorization: Bearer` | +| No authentication | empty | empty | "Bypass authentication" for Logarr's IP / subnet enabled | + +Environment variables for auto-setup: `QBITTORRENT_URL`, `QBITTORRENT_USERNAME`, `QBITTORRENT_PASSWORD`. + +- qBittorrent bans an IP for 1 hour after 5 failed logins. After a failed login Logarr backs off (5, 15, then 60 minutes) instead of retrying every poll; fix the credentials and edit the source to retry immediately. +- Behind a reverse proxy or with Host header validation enabled, qBittorrent may answer 401; allow Logarr's host name in "Server domains". +- The API log feed only reports warnings and critical messages, and is skipped when a log path is configured (the `qbittorrent.log` file is tailed instead). Log file timestamps have no timezone: set the backend `TZ` to qBittorrent's timezone. +- Default log locations are best guesses: `/config/qBittorrent/logs` (linuxserver) and `/config/logs` (hotio) are unverified, so set the log path explicitly. #### Multiple Instances (e.g., Movies vs Shorts, 1080p vs 4K) @@ -325,16 +348,17 @@ Simply add multiple servers in the UI using the same server type but different U #### Common Log Locations -| Server | Docker | Linux | Windows | -| -------- | -------------------------- | ----------------------------------- | --------------------------------------- | -| Plex | `/config/Library/.../Logs` | `/var/lib/plexmediaserver/.../Logs` | `%LOCALAPPDATA%\Plex Media Server\Logs` | -| Jellyfin | `/config/log` | `/var/lib/jellyfin/log` | `C:\ProgramData\Jellyfin\Server\log` | -| Emby | `/config/logs` | `/var/lib/emby/logs` | `C:\ProgramData\Emby-Server\logs` | -| Sonarr | `/config/logs` | `~/.config/Sonarr/logs` | `C:\ProgramData\Sonarr\logs` | -| Radarr | `/config/logs` | `~/.config/Radarr/logs` | `C:\ProgramData\Radarr\logs` | -| Prowlarr | `/config/logs` | `~/.config/Prowlarr/logs` | `C:\ProgramData\Prowlarr\logs` | -| Whisparr | `/config/logs` | `~/.config/Whisparr/logs` | `C:\ProgramData\Whisparr\logs` | -| Lidarr | `/config/logs` | `~/.config/Lidarr/logs` | `C:\ProgramData\Lidarr\logs` | +| Server | Docker | Linux | Windows | +| ----------- | ---------------------------------------------------- | ----------------------------------- | --------------------------------------- | +| Plex | `/config/Library/.../Logs` | `/var/lib/plexmediaserver/.../Logs` | `%LOCALAPPDATA%\Plex Media Server\Logs` | +| Jellyfin | `/config/log` | `/var/lib/jellyfin/log` | `C:\ProgramData\Jellyfin\Server\log` | +| Emby | `/config/logs` | `/var/lib/emby/logs` | `C:\ProgramData\Emby-Server\logs` | +| Sonarr | `/config/logs` | `~/.config/Sonarr/logs` | `C:\ProgramData\Sonarr\logs` | +| Radarr | `/config/logs` | `~/.config/Radarr/logs` | `C:\ProgramData\Radarr\logs` | +| Prowlarr | `/config/logs` | `~/.config/Prowlarr/logs` | `C:\ProgramData\Prowlarr\logs` | +| Whisparr | `/config/logs` | `~/.config/Whisparr/logs` | `C:\ProgramData\Whisparr\logs` | +| Lidarr | `/config/logs` | `~/.config/Lidarr/logs` | `C:\ProgramData\Lidarr\logs` | +| qBittorrent | `/config/qBittorrent/logs` (linuxserver, unverified) | `~/.local/share/qBittorrent/logs` | `%LOCALAPPDATA%\qBittorrent\logs` | --- @@ -377,6 +401,7 @@ logarr/ │ ├── provider-prowlarr/ # Prowlarr integration │ ├── provider-whisparr/ # Whisparr integration │ ├── provider-lidarr/ # Lidarr integration +│ ├── provider-qbittorrent/ # qBittorrent integration │ └── provider-arr/ # Base *arr provider └── docker-compose.yml ``` @@ -481,15 +506,24 @@ pnpm test:e2e # Run Playwright E2E tests ### Test Packages -| Package | Description | -| --------------------------- | -------------------------------------- | -| `@logarr/backend` | Backend services and controllers | -| `frontend` | React components, hooks, and utilities | -| `@logarr/core` | Core HTTP utilities | -| `@logarr/provider-arr` | \*arr application log parser | -| `@logarr/provider-emby` | Emby log parser | -| `@logarr/provider-whisparr` | Whisparr provider | -| `@logarr/provider-lidarr` | Lidarr provider | +| Package | Description | +| ------------------------------ | -------------------------------------- | +| `@logarr/backend` | Backend services and controllers | +| `frontend` | React components, hooks, and utilities | +| `@logarr/core` | Core HTTP utilities | +| `@logarr/provider-arr` | \*arr application log parser | +| `@logarr/provider-emby` | Emby log parser | +| `@logarr/provider-whisparr` | Whisparr provider | +| `@logarr/provider-lidarr` | Lidarr provider | +| Package | Description | +| ------------------------------ | -------------------------------------- | +| `@logarr/backend` | Backend services and controllers | +| `frontend` | React components, hooks, and utilities | +| `@logarr/core` | Core HTTP utilities | +| `@logarr/provider-arr` | \*arr application log parser | +| `@logarr/provider-emby` | Emby log parser | +| `@logarr/provider-whisparr` | Whisparr provider | +| `@logarr/provider-qbittorrent` | qBittorrent provider | > Coverage thresholds are enforced at 80% for statements, branches, functions, and lines. diff --git a/apps/backend/Dockerfile b/apps/backend/Dockerfile index 4950ae6..a6f1301 100644 --- a/apps/backend/Dockerfile +++ b/apps/backend/Dockerfile @@ -60,6 +60,8 @@ COPY --from=builder --chown=nestjs:nodejs /app/packages/provider-whisparr/packag COPY --from=builder --chown=nestjs:nodejs /app/packages/provider-whisparr/dist ./packages/provider-whisparr/dist COPY --from=builder --chown=nestjs:nodejs /app/packages/provider-lidarr/package.json ./packages/provider-lidarr/ COPY --from=builder --chown=nestjs:nodejs /app/packages/provider-lidarr/dist ./packages/provider-lidarr/dist +COPY --from=builder --chown=nestjs:nodejs /app/packages/provider-qbittorrent/package.json ./packages/provider-qbittorrent/ +COPY --from=builder --chown=nestjs:nodejs /app/packages/provider-qbittorrent/dist ./packages/provider-qbittorrent/dist # Copy backend COPY --from=builder --chown=nestjs:nodejs /app/apps/backend/package.json ./apps/backend/ diff --git a/apps/backend/Dockerfile.dev b/apps/backend/Dockerfile.dev index 66d84b3..b690de9 100644 --- a/apps/backend/Dockerfile.dev +++ b/apps/backend/Dockerfile.dev @@ -16,7 +16,7 @@ COPY apps/backend/ ./apps/backend/ RUN pnpm install # Build only the packages (not backend itself - that will be done in watch mode) -RUN pnpm build --filter @logarr/core --filter @logarr/provider-arr --filter @logarr/provider-jellyfin --filter @logarr/provider-sonarr --filter @logarr/provider-radarr --filter @logarr/provider-prowlarr --filter @logarr/provider-plex --filter @logarr/provider-emby --filter @logarr/provider-whisparr --filter @logarr/provider-lidarr +RUN pnpm build --filter @logarr/core --filter @logarr/provider-arr --filter @logarr/provider-jellyfin --filter @logarr/provider-sonarr --filter @logarr/provider-radarr --filter @logarr/provider-prowlarr --filter @logarr/provider-plex --filter @logarr/provider-emby --filter @logarr/provider-whisparr --filter @logarr/provider-lidarr --filter @logarr/provider-qbittorrent EXPOSE 4002 diff --git a/apps/backend/drizzle/0010_add-server-username.sql b/apps/backend/drizzle/0010_add-server-username.sql new file mode 100644 index 0000000..fe4f0ab --- /dev/null +++ b/apps/backend/drizzle/0010_add-server-username.sql @@ -0,0 +1 @@ +ALTER TABLE "servers" ADD COLUMN "username" text; \ No newline at end of file diff --git a/apps/backend/drizzle/meta/0010_snapshot.json b/apps/backend/drizzle/meta/0010_snapshot.json new file mode 100644 index 0000000..84fab12 --- /dev/null +++ b/apps/backend/drizzle/meta/0010_snapshot.json @@ -0,0 +1,3281 @@ +{ + "id": "808acff7-519d-4a92-85ef-35c4b3ded8f7", + "prevId": "118f4cac-0a95-450d-ae09-ce6c9b721fd7", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.ai_analyses": { + "name": "ai_analyses", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "server_id": { + "name": "server_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "log_entry_id": { + "name": "log_entry_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "session_id": { + "name": "session_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "prompt": { + "name": "prompt", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "response": { + "name": "response", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tokens_used": { + "name": "tokens_used", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "ai_analyses_server_id_idx": { + "name": "ai_analyses_server_id_idx", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "ai_analyses_log_entry_id_idx": { + "name": "ai_analyses_log_entry_id_idx", + "columns": [ + { + "expression": "log_entry_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "ai_analyses_session_id_idx": { + "name": "ai_analyses_session_id_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "ai_analyses_server_id_servers_id_fk": { + "name": "ai_analyses_server_id_servers_id_fk", + "tableFrom": "ai_analyses", + "tableTo": "servers", + "columnsFrom": [ + "server_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "ai_analyses_log_entry_id_log_entries_id_fk": { + "name": "ai_analyses_log_entry_id_log_entries_id_fk", + "tableFrom": "ai_analyses", + "tableTo": "log_entries", + "columnsFrom": [ + "log_entry_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "ai_analyses_session_id_sessions_id_fk": { + "name": "ai_analyses_session_id_sessions_id_fk", + "tableFrom": "ai_analyses", + "tableTo": "sessions", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ai_provider_settings": { + "name": "ai_provider_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "api_key": { + "name": "api_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "base_url": { + "name": "base_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "max_tokens": { + "name": "max_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 1000 + }, + "temperature": { + "name": "temperature", + "type": "real", + "primaryKey": false, + "notNull": false, + "default": 0.7 + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "last_tested_at": { + "name": "last_tested_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_test_result": { + "name": "last_test_result", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "ai_provider_settings_provider_idx": { + "name": "ai_provider_settings_provider_idx", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "ai_provider_settings_is_default_idx": { + "name": "ai_provider_settings_is_default_idx", + "columns": [ + { + "expression": "is_default", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.analysis_conversations": { + "name": "analysis_conversations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "issue_id": { + "name": "issue_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "messages": { + "name": "messages", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "context_snapshot": { + "name": "context_snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "total_tokens": { + "name": "total_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "analysis_conversations_issue_id_idx": { + "name": "analysis_conversations_issue_id_idx", + "columns": [ + { + "expression": "issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "analysis_conversations_created_at_idx": { + "name": "analysis_conversations_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "analysis_conversations_issue_id_issues_id_fk": { + "name": "analysis_conversations_issue_id_issues_id_fk", + "tableFrom": "analysis_conversations", + "tableTo": "issues", + "columnsFrom": [ + "issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.api_key_usage_log": { + "name": "api_key_usage_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "key_id": { + "name": "key_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "endpoint": { + "name": "endpoint", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "method": { + "name": "method", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status_code": { + "name": "status_code", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "response_time": { + "name": "response_time", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "success": { + "name": "success", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "timestamp": { + "name": "timestamp", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "api_key_usage_log_key_id_idx": { + "name": "api_key_usage_log_key_id_idx", + "columns": [ + { + "expression": "key_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "api_key_usage_log_timestamp_idx": { + "name": "api_key_usage_log_timestamp_idx", + "columns": [ + { + "expression": "timestamp", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "api_key_usage_log_success_idx": { + "name": "api_key_usage_log_success_idx", + "columns": [ + { + "expression": "success", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "api_key_usage_log_key_id_api_keys_id_fk": { + "name": "api_key_usage_log_key_id_api_keys_id_fk", + "tableFrom": "api_key_usage_log", + "tableTo": "api_keys", + "columnsFrom": [ + "key_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.api_keys": { + "name": "api_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key_hash": { + "name": "key_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "device_info": { + "name": "device_info", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "rate_limit": { + "name": "rate_limit", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "rate_limit_ttl": { + "name": "rate_limit_ttl", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_used_ip": { + "name": "last_used_ip", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "request_count": { + "name": "request_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "api_keys_key_hash_idx": { + "name": "api_keys_key_hash_idx", + "columns": [ + { + "expression": "key_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "api_keys_is_enabled_idx": { + "name": "api_keys_is_enabled_idx", + "columns": [ + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "api_keys_last_used_at_idx": { + "name": "api_keys_last_used_at_idx", + "columns": [ + { + "expression": "last_used_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "api_keys_key_hash_unique": { + "name": "api_keys_key_hash_unique", + "nullsNotDistinct": false, + "columns": [ + "key_hash" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.app_settings": { + "name": "app_settings", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "value": { + "name": "value", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audit_log": { + "name": "audit_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "audit_log_action", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "category": { + "name": "category", + "type": "audit_log_category", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "endpoint": { + "name": "endpoint", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "method": { + "name": "method", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status_code": { + "name": "status_code", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "response_time": { + "name": "response_time", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "success": { + "name": "success", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "api_key_id": { + "name": "api_key_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "timestamp": { + "name": "timestamp", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "audit_log_user_id_idx": { + "name": "audit_log_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_session_id_idx": { + "name": "audit_log_session_id_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_action_idx": { + "name": "audit_log_action_idx", + "columns": [ + { + "expression": "action", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_category_idx": { + "name": "audit_log_category_idx", + "columns": [ + { + "expression": "category", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_entity_type_idx": { + "name": "audit_log_entity_type_idx", + "columns": [ + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_entity_id_idx": { + "name": "audit_log_entity_id_idx", + "columns": [ + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_timestamp_idx": { + "name": "audit_log_timestamp_idx", + "columns": [ + { + "expression": "timestamp", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_success_idx": { + "name": "audit_log_success_idx", + "columns": [ + { + "expression": "success", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_api_key_id_idx": { + "name": "audit_log_api_key_id_idx", + "columns": [ + { + "expression": "api_key_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_audit_log_timestamp_category": { + "name": "idx_audit_log_timestamp_category", + "columns": [ + { + "expression": "timestamp", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "category", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_audit_log_user_action": { + "name": "idx_audit_log_user_action", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "action", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "audit_log_api_key_id_api_keys_id_fk": { + "name": "audit_log_api_key_id_api_keys_id_fk", + "tableFrom": "audit_log", + "tableTo": "api_keys", + "columnsFrom": [ + "api_key_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.issue_occurrences": { + "name": "issue_occurrences", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "issue_id": { + "name": "issue_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "log_entry_id": { + "name": "log_entry_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "timestamp": { + "name": "timestamp", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "server_id": { + "name": "server_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "issue_occurrences_issue_id_idx": { + "name": "issue_occurrences_issue_id_idx", + "columns": [ + { + "expression": "issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_occurrences_log_entry_id_idx": { + "name": "issue_occurrences_log_entry_id_idx", + "columns": [ + { + "expression": "log_entry_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_occurrences_timestamp_idx": { + "name": "issue_occurrences_timestamp_idx", + "columns": [ + { + "expression": "timestamp", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_occurrences_issue_log_idx": { + "name": "issue_occurrences_issue_log_idx", + "columns": [ + { + "expression": "issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "log_entry_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_issue_occurrences_issue_timestamp": { + "name": "idx_issue_occurrences_issue_timestamp", + "columns": [ + { + "expression": "issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "timestamp", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "issue_occurrences_issue_id_issues_id_fk": { + "name": "issue_occurrences_issue_id_issues_id_fk", + "tableFrom": "issue_occurrences", + "tableTo": "issues", + "columnsFrom": [ + "issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "issue_occurrences_log_entry_id_log_entries_id_fk": { + "name": "issue_occurrences_log_entry_id_log_entries_id_fk", + "tableFrom": "issue_occurrences", + "tableTo": "log_entries", + "columnsFrom": [ + "log_entry_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "issue_occurrences_server_id_servers_id_fk": { + "name": "issue_occurrences_server_id_servers_id_fk", + "tableFrom": "issue_occurrences", + "tableTo": "servers", + "columnsFrom": [ + "server_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.issue_patterns": { + "name": "issue_patterns", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sources": { + "name": "sources", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "pattern": { + "name": "pattern", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_regex": { + "name": "is_regex", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "suggested_severity": { + "name": "suggested_severity", + "type": "issue_severity", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'medium'" + }, + "suggested_category": { + "name": "suggested_category", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_system": { + "name": "is_system", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "match_count": { + "name": "match_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "issue_patterns_is_enabled_idx": { + "name": "issue_patterns_is_enabled_idx", + "columns": [ + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_patterns_is_system_idx": { + "name": "issue_patterns_is_system_idx", + "columns": [ + { + "expression": "is_system", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.issues": { + "name": "issues", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "fingerprint": { + "name": "fingerprint", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "issue_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "severity": { + "name": "severity", + "type": "issue_severity", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'medium'" + }, + "status": { + "name": "status", + "type": "issue_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'open'" + }, + "server_id": { + "name": "server_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_pattern": { + "name": "error_pattern", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sample_message": { + "name": "sample_message", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "exception_type": { + "name": "exception_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "first_seen": { + "name": "first_seen", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "last_seen": { + "name": "last_seen", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "occurrence_count": { + "name": "occurrence_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "affected_users_count": { + "name": "affected_users_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "affected_sessions_count": { + "name": "affected_sessions_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "impact_score": { + "name": "impact_score", + "type": "real", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "ai_analysis": { + "name": "ai_analysis", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ai_analysis_at": { + "name": "ai_analysis_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "ai_suggested_fix": { + "name": "ai_suggested_fix", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "related_links": { + "name": "related_links", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resolved_by": { + "name": "resolved_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resolved_at": { + "name": "resolved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "issues_fingerprint_idx": { + "name": "issues_fingerprint_idx", + "columns": [ + { + "expression": "fingerprint", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issues_server_id_idx": { + "name": "issues_server_id_idx", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issues_source_idx": { + "name": "issues_source_idx", + "columns": [ + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issues_severity_idx": { + "name": "issues_severity_idx", + "columns": [ + { + "expression": "severity", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issues_status_idx": { + "name": "issues_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issues_category_idx": { + "name": "issues_category_idx", + "columns": [ + { + "expression": "category", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issues_last_seen_idx": { + "name": "issues_last_seen_idx", + "columns": [ + { + "expression": "last_seen", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issues_impact_score_idx": { + "name": "issues_impact_score_idx", + "columns": [ + { + "expression": "impact_score", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issues_occurrence_count_idx": { + "name": "issues_occurrence_count_idx", + "columns": [ + { + "expression": "occurrence_count", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_issues_status_severity": { + "name": "idx_issues_status_severity", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "severity", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_issues_server_status": { + "name": "idx_issues_server_status", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "issues_server_id_servers_id_fk": { + "name": "issues_server_id_servers_id_fk", + "tableFrom": "issues", + "tableTo": "servers", + "columnsFrom": [ + "server_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "issues_fingerprint_unique": { + "name": "issues_fingerprint_unique", + "nullsNotDistinct": false, + "columns": [ + "fingerprint" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.log_entries": { + "name": "log_entries", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "server_id": { + "name": "server_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "external_activity_id": { + "name": "external_activity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "timestamp": { + "name": "timestamp", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "level": { + "name": "level", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "raw": { + "name": "raw", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "device_id": { + "name": "device_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "item_id": { + "name": "item_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "play_session_id": { + "name": "play_session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "exception": { + "name": "exception", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stack_trace": { + "name": "stack_trace", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "search_vector": { + "name": "search_vector", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "log_source": { + "name": "log_source", + "type": "log_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'api'" + }, + "log_file_path": { + "name": "log_file_path", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "log_file_line": { + "name": "log_file_line", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "deduplication_key": { + "name": "deduplication_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "log_entries_server_id_idx": { + "name": "log_entries_server_id_idx", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "log_entries_timestamp_idx": { + "name": "log_entries_timestamp_idx", + "columns": [ + { + "expression": "timestamp", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "log_entries_level_idx": { + "name": "log_entries_level_idx", + "columns": [ + { + "expression": "level", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "log_entries_source_idx": { + "name": "log_entries_source_idx", + "columns": [ + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "log_entries_session_id_idx": { + "name": "log_entries_session_id_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "log_entries_user_id_idx": { + "name": "log_entries_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "log_entries_play_session_id_idx": { + "name": "log_entries_play_session_id_idx", + "columns": [ + { + "expression": "play_session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "log_entries_log_source_idx": { + "name": "log_entries_log_source_idx", + "columns": [ + { + "expression": "log_source", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "log_entries_server_external_id_idx": { + "name": "log_entries_server_external_id_idx", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_activity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "log_entries_server_dedup_key_idx": { + "name": "log_entries_server_dedup_key_idx", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deduplication_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_log_entries_server_timestamp": { + "name": "idx_log_entries_server_timestamp", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "timestamp", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_log_entries_server_level": { + "name": "idx_log_entries_server_level", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "level", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_log_entries_level_timestamp": { + "name": "idx_log_entries_level_timestamp", + "columns": [ + { + "expression": "level", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "timestamp", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "log_entries_server_id_servers_id_fk": { + "name": "log_entries_server_id_servers_id_fk", + "tableFrom": "log_entries", + "tableTo": "servers", + "columnsFrom": [ + "server_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.log_file_state": { + "name": "log_file_state", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "server_id": { + "name": "server_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "file_path": { + "name": "file_path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "absolute_path": { + "name": "absolute_path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_size": { + "name": "file_size", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": "0" + }, + "byte_offset": { + "name": "byte_offset", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": "0" + }, + "line_number": { + "name": "line_number", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "file_inode": { + "name": "file_inode", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "file_modified_at": { + "name": "file_modified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_read_at": { + "name": "last_read_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "log_file_state_server_file_idx": { + "name": "log_file_state_server_file_idx", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "file_path", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "log_file_state_server_id_idx": { + "name": "log_file_state_server_id_idx", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "log_file_state_is_active_idx": { + "name": "log_file_state_is_active_idx", + "columns": [ + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "log_file_state_server_id_servers_id_fk": { + "name": "log_file_state_server_id_servers_id_fk", + "tableFrom": "log_file_state", + "tableTo": "servers", + "columnsFrom": [ + "server_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.playback_events": { + "name": "playback_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "session_id": { + "name": "session_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "item_id": { + "name": "item_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "item_name": { + "name": "item_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "item_type": { + "name": "item_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "position_ticks": { + "name": "position_ticks", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "duration_ticks": { + "name": "duration_ticks", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "is_paused": { + "name": "is_paused", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_muted": { + "name": "is_muted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_transcoding": { + "name": "is_transcoding", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "transcode_reasons": { + "name": "transcode_reasons", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "video_codec": { + "name": "video_codec", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "audio_codec": { + "name": "audio_codec", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "container": { + "name": "container", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "thumbnail_url": { + "name": "thumbnail_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "series_name": { + "name": "series_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "season_name": { + "name": "season_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "timestamp": { + "name": "timestamp", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "playback_events_session_id_idx": { + "name": "playback_events_session_id_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "playback_events_timestamp_idx": { + "name": "playback_events_timestamp_idx", + "columns": [ + { + "expression": "timestamp", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "playback_events_event_type_idx": { + "name": "playback_events_event_type_idx", + "columns": [ + { + "expression": "event_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_playback_events_session_timestamp": { + "name": "idx_playback_events_session_timestamp", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "timestamp", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "playback_events_session_id_sessions_id_fk": { + "name": "playback_events_session_id_sessions_id_fk", + "tableFrom": "playback_events", + "tableTo": "sessions", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.retention_history": { + "name": "retention_history", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "info_deleted": { + "name": "info_deleted", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "debug_deleted": { + "name": "debug_deleted", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "warn_deleted": { + "name": "warn_deleted", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "error_deleted": { + "name": "error_deleted", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "orphaned_occurrences_deleted": { + "name": "orphaned_occurrences_deleted", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "retention_history_started_at_idx": { + "name": "retention_history_started_at_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "retention_history_status_idx": { + "name": "retention_history_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.servers": { + "name": "servers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "api_key": { + "name": "api_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "log_path": { + "name": "log_path", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "is_connected": { + "name": "is_connected", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "last_seen": { + "name": "last_seen", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "version": { + "name": "version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "server_name": { + "name": "server_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_activity_sync": { + "name": "last_activity_sync", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "file_ingestion_enabled": { + "name": "file_ingestion_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "file_ingestion_connected": { + "name": "file_ingestion_connected", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "file_ingestion_error": { + "name": "file_ingestion_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "log_paths": { + "name": "log_paths", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "log_file_patterns": { + "name": "log_file_patterns", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "last_file_sync": { + "name": "last_file_sync", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "sync_status": { + "name": "sync_status", + "type": "sync_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'idle'" + }, + "sync_progress": { + "name": "sync_progress", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "sync_total_files": { + "name": "sync_total_files", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "sync_processed_files": { + "name": "sync_processed_files", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "sync_started_at": { + "name": "sync_started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "sync_completed_at": { + "name": "sync_completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "initial_sync_completed": { + "name": "initial_sync_completed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "servers_provider_id_idx": { + "name": "servers_provider_id_idx", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "servers_is_enabled_idx": { + "name": "servers_is_enabled_idx", + "columns": [ + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "servers_sync_status_idx": { + "name": "servers_sync_status_idx", + "columns": [ + { + "expression": "sync_status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "server_id": { + "name": "server_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "play_session_id": { + "name": "play_session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_name": { + "name": "user_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "device_id": { + "name": "device_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "device_name": { + "name": "device_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "client_name": { + "name": "client_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "client_version": { + "name": "client_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "now_playing_item_id": { + "name": "now_playing_item_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "now_playing_item_name": { + "name": "now_playing_item_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "now_playing_item_type": { + "name": "now_playing_item_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_activity": { + "name": "last_activity", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "sessions_server_id_idx": { + "name": "sessions_server_id_idx", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_user_id_idx": { + "name": "sessions_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_device_id_idx": { + "name": "sessions_device_id_idx", + "columns": [ + { + "expression": "device_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_is_active_idx": { + "name": "sessions_is_active_idx", + "columns": [ + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_server_external_id_idx": { + "name": "sessions_server_external_id_idx", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_sessions_server_active": { + "name": "idx_sessions_server_active", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sessions_server_id_servers_id_fk": { + "name": "sessions_server_id_servers_id_fk", + "tableFrom": "sessions", + "tableTo": "servers", + "columnsFrom": [ + "server_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.audit_log_action": { + "name": "audit_log_action", + "schema": "public", + "values": [ + "create", + "update", + "delete", + "read", + "login", + "logout", + "error", + "export", + "import", + "sync", + "test", + "other" + ] + }, + "public.audit_log_category": { + "name": "audit_log_category", + "schema": "public", + "values": [ + "auth", + "server", + "log_entry", + "session", + "playback", + "issue", + "ai_analysis", + "api_key", + "settings", + "retention", + "proxy", + "other" + ] + }, + "public.issue_severity": { + "name": "issue_severity", + "schema": "public", + "values": [ + "critical", + "high", + "medium", + "low", + "info" + ] + }, + "public.issue_source": { + "name": "issue_source", + "schema": "public", + "values": [ + "jellyfin", + "sonarr", + "radarr", + "prowlarr", + "docker", + "system" + ] + }, + "public.issue_status": { + "name": "issue_status", + "schema": "public", + "values": [ + "open", + "acknowledged", + "in_progress", + "resolved", + "ignored" + ] + }, + "public.log_source": { + "name": "log_source", + "schema": "public", + "values": [ + "api", + "file" + ] + }, + "public.sync_status": { + "name": "sync_status", + "schema": "public", + "values": [ + "idle", + "pending", + "discovering", + "syncing", + "error" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/apps/backend/drizzle/meta/_journal.json b/apps/backend/drizzle/meta/_journal.json index 96a9cc3..970b75d 100644 --- a/apps/backend/drizzle/meta/_journal.json +++ b/apps/backend/drizzle/meta/_journal.json @@ -71,6 +71,13 @@ "when": 1768151860740, "tag": "0009_high_venus", "breakpoints": true + }, + { + "idx": 10, + "version": "7", + "when": 1791589999831, + "tag": "0010_add-server-username", + "breakpoints": true } ] } \ No newline at end of file diff --git a/apps/backend/package.json b/apps/backend/package.json index 535f58a..bc06661 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -30,6 +30,7 @@ "@logarr/provider-lidarr": "workspace:*", "@logarr/provider-plex": "workspace:*", "@logarr/provider-prowlarr": "workspace:*", + "@logarr/provider-qbittorrent": "workspace:*", "@logarr/provider-radarr": "workspace:*", "@logarr/provider-sonarr": "workspace:*", "@logarr/provider-whisparr": "workspace:*", diff --git a/apps/backend/src/database/schema.ts b/apps/backend/src/database/schema.ts index 2378b4c..af8b81d 100644 --- a/apps/backend/src/database/schema.ts +++ b/apps/backend/src/database/schema.ts @@ -60,7 +60,10 @@ export const servers = pgTable( name: text('name').notNull(), providerId: text('provider_id').notNull(), url: text('url').notNull(), + // API key / token / password. May be '' for providers without auth (e.g. qBittorrent bypass) apiKey: text('api_key').notNull(), + // Optional login username (e.g. qBittorrent). The password lives in api_key. + username: text('username'), logPath: text('log_path'), isEnabled: boolean('is_enabled').notNull().default(true), isConnected: boolean('is_connected').notNull().default(false), diff --git a/apps/backend/src/modules/file-ingestion/log-file-processor.qbittorrent.spec.ts b/apps/backend/src/modules/file-ingestion/log-file-processor.qbittorrent.spec.ts new file mode 100644 index 0000000..fec7f83 --- /dev/null +++ b/apps/backend/src/modules/file-ingestion/log-file-processor.qbittorrent.spec.ts @@ -0,0 +1,39 @@ +import { QBittorrentProvider } from '@logarr/provider-qbittorrent'; +import { describe, expect, it } from 'vitest'; + +import { LogFileProcessor } from './log-file-processor'; + +import type { ParsedLogEntry } from '@logarr/core'; + +const SAMPLE = [ + '(N) 2026-10-10T12:00:01 - qBittorrent v5.2.4 started', + '(I) 2026-10-10T12:00:02 - Trying to listen on the following list of IP addresses: "0.0.0.0:6881,[::]:6881"', + '(W) 2026-10-10T12:01:15 - File error alert. Torrent: "Some.Torrent.Name". File: "/downloads/x.mkv". Reason: "Permission denied"', + 'some unparseable garbage line', + '(C) 2026-10-10T12:02:30 - I/O error. Torrent: "Foo". Reason: "No space left on device"', + '(W) 2026-10-10T12:03:00 - WebUI login failure. IP: 172.18.0.5, username: admin', +]; + +describe('LogFileProcessor with the qBittorrent provider', () => { + it('emits every parseable qbittorrent.log line exactly once, with correct levels', () => { + const processor = new LogFileProcessor(new QBittorrentProvider()); + const entries: ParsedLogEntry[] = []; + + SAMPLE.forEach((line, index) => { + const entry = processor.processLine(line, index + 1); + if (entry) entries.push(entry); + }); + const last = processor.flush(); + if (last) entries.push(last); + + expect(entries.map((e) => e.level)).toEqual(['info', 'info', 'warn', 'error', 'warn']); + expect(entries.map((e) => e.message)).toEqual([ + 'qBittorrent v5.2.4 started', + 'Trying to listen on the following list of IP addresses: "0.0.0.0:6881,[::]:6881"', + 'File error alert. Torrent: "Some.Torrent.Name". File: "/downloads/x.mkv". Reason: "Permission denied"', + 'I/O error. Torrent: "Foo". Reason: "No space left on device"', + 'WebUI login failure. IP: 172.18.0.5, username: admin', + ]); + expect(entries.every((e) => e.stackTrace === undefined)).toBe(true); + }); +}); diff --git a/apps/backend/src/modules/ingestion/ingestion.service.ts b/apps/backend/src/modules/ingestion/ingestion.service.ts index a2c5960..863f93c 100644 --- a/apps/backend/src/modules/ingestion/ingestion.service.ts +++ b/apps/backend/src/modules/ingestion/ingestion.service.ts @@ -5,6 +5,7 @@ import { JellyfinProvider, JellyfinClient } from '@logarr/provider-jellyfin'; import { LidarrProvider } from '@logarr/provider-lidarr'; import { PlexProvider, PlexClient } from '@logarr/provider-plex'; import { ProwlarrProvider } from '@logarr/provider-prowlarr'; +import { QBittorrentProvider } from '@logarr/provider-qbittorrent'; import { RadarrProvider } from '@logarr/provider-radarr'; import { SonarrProvider } from '@logarr/provider-sonarr'; import { WhisparrProvider } from '@logarr/provider-whisparr'; @@ -87,6 +88,8 @@ export class IngestionService implements OnModuleInit, OnModuleDestroy { const lidarrProvider = new LidarrProvider(); this.providers.set(lidarrProvider.id, lidarrProvider); + const qbittorrentProvider = new QBittorrentProvider(); + this.providers.set(qbittorrentProvider.id, qbittorrentProvider); } async onModuleInit() { @@ -231,6 +234,7 @@ export class IngestionService implements OnModuleInit, OnModuleDestroy { await provider.connect({ url: server.url, apiKey: server.apiKey, + username: server.username ?? undefined, logPath: server.logPath ?? undefined, }); @@ -294,6 +298,7 @@ export class IngestionService implements OnModuleInit, OnModuleDestroy { await provider.connect({ url: server.url, apiKey: server.apiKey, + username: server.username ?? undefined, logPath: server.logPath ?? undefined, }); diff --git a/apps/backend/src/modules/proxy/proxy.service.spec.ts b/apps/backend/src/modules/proxy/proxy.service.spec.ts index 3f2e9bc..7653612 100644 --- a/apps/backend/src/modules/proxy/proxy.service.spec.ts +++ b/apps/backend/src/modules/proxy/proxy.service.spec.ts @@ -1,5 +1,5 @@ import { HttpService } from '@nestjs/axios'; -import { NotFoundException, ForbiddenException } from '@nestjs/common'; +import { NotFoundException, ForbiddenException, BadRequestException } from '@nestjs/common'; import { Test } from '@nestjs/testing'; import { of, throwError } from 'rxjs'; import { describe, it, expect, vi, beforeEach } from 'vitest'; @@ -421,6 +421,54 @@ describe('ProxyService', () => { ); }); + describe('qBittorrent authentication modes', () => { + const qbitServer = { + id: 'server-qbit', + name: 'My qBittorrent', + providerId: 'qbittorrent', + url: 'http://localhost:8080', + isEnabled: true, + }; + + it('should reject servers that use username/password login', async () => { + configureMockDb(mockDb, { + select: [{ ...qbitServer, apiKey: 'password', username: 'admin' }], + }); + + await expect( + service.proxyRequest('server-qbit', { endpoint: 'torrents/info', method: 'GET' }) + ).rejects.toThrow(BadRequestException); + expect(mockHttpService.request).not.toHaveBeenCalled(); + }); + + it('should send X-Api-Key for API key setups without username', async () => { + configureMockDb(mockDb, { + select: [{ ...qbitServer, apiKey: 'qbt-key', username: null }], + }); + vi.mocked(mockHttpService.request)!.mockReturnValue(of({ data: {}, status: 200 } as any)); + + await service.proxyRequest('server-qbit', { endpoint: 'torrents/info', method: 'GET' }); + + expect(mockHttpService.request).toHaveBeenCalledWith( + expect.objectContaining({ + headers: expect.objectContaining({ 'X-Api-Key': 'qbt-key' }), + }) + ); + }); + + it('should omit X-Api-Key when the key is empty (auth bypass)', async () => { + configureMockDb(mockDb, { select: [{ ...qbitServer, apiKey: '', username: null }] }); + vi.mocked(mockHttpService.request)!.mockReturnValue(of({ data: {}, status: 200 } as any)); + + await service.proxyRequest('server-qbit', { endpoint: 'torrents/info', method: 'GET' }); + + const call = vi.mocked(mockHttpService.request)!.mock.calls[0]![0] as { + headers: Record; + }; + expect(call.headers).not.toHaveProperty('X-Api-Key'); + }); + }); + it('should replace path parameters in URL', async () => { const mockServer = { id: 'server-sonarr', diff --git a/apps/backend/src/modules/proxy/proxy.service.ts b/apps/backend/src/modules/proxy/proxy.service.ts index 64cf4eb..cd36a83 100644 --- a/apps/backend/src/modules/proxy/proxy.service.ts +++ b/apps/backend/src/modules/proxy/proxy.service.ts @@ -1,5 +1,12 @@ import { HttpService } from '@nestjs/axios'; -import { Injectable, Inject, NotFoundException, ForbiddenException, Logger } from '@nestjs/common'; +import { + Injectable, + Inject, + NotFoundException, + ForbiddenException, + BadRequestException, + Logger, +} from '@nestjs/common'; import { eq } from 'drizzle-orm'; import { PostgresJsDatabase } from 'drizzle-orm/postgres-js'; import { firstValueFrom } from 'rxjs'; @@ -15,6 +22,7 @@ interface ServerWithConfig { providerId: string; url: string; apiKey: string; + username?: string | null; status?: string; lastChecked?: Date; } @@ -88,6 +96,13 @@ export class ProxyService { // 1. Get server configuration const server = await this.findUserServer(serverId); + // qBittorrent username/password login uses cookie sessions, which the proxy does not support + if (server.providerId === 'qbittorrent' && server.username) { + throw new BadRequestException( + 'The request proxy does not support qBittorrent username/password login. Use an API key (qBittorrent 5.3+) or authentication bypass.' + ); + } + // 2. Build target URL with path parameters const targetUrl = this.buildTargetUrl(server, dto.endpoint, dto.params); @@ -95,7 +110,8 @@ export class ProxyService { // 3. Merge headers const headers: Record = { - 'X-Api-Key': server.apiKey, + // Providers without credentials (e.g. qBittorrent auth bypass) store an empty key + ...(server.apiKey !== '' && { 'X-Api-Key': server.apiKey }), 'Content-Type': 'application/json', ...dto.headers, }; diff --git a/apps/backend/src/modules/servers/server-seed.service.spec.ts b/apps/backend/src/modules/servers/server-seed.service.spec.ts new file mode 100644 index 0000000..e02f760 --- /dev/null +++ b/apps/backend/src/modules/servers/server-seed.service.spec.ts @@ -0,0 +1,125 @@ +import { afterEach, beforeEach, describe, expect, it, type vi } from 'vitest'; + +import { createMockDb, configureMockDb, type MockDb } from '../../test/mock-db'; + +import { ServerSeedService } from './server-seed.service'; + +const SEEDED_ENV_KEYS = [ + 'JELLYFIN_URL', + 'JELLYFIN_API_KEY', + 'QBITTORRENT_URL', + 'QBITTORRENT_USERNAME', + 'QBITTORRENT_PASSWORD', + 'QBITTORRENT_LOGS_PATH', +] as const; + +describe('ServerSeedService - qBittorrent', () => { + let mockDb: MockDb; + let service: ServerSeedService; + const originalEnv: Record = {}; + + const insertedRows = (): Array> => + mockDb.insert.mock.results.map( + (r) => + (r.value as { values: ReturnType }).values.mock.calls[0]![0] as Record< + string, + unknown + > + ); + + beforeEach(() => { + for (const key of SEEDED_ENV_KEYS) { + originalEnv[key] = process.env[key]; + delete process.env[key]; + } + // Unrelated providers must not be seeded by the test environment + for (const key of Object.keys(process.env)) { + if (/_(URL|API_KEY)$/.test(key) && !key.startsWith('QBITTORRENT')) { + originalEnv[key] = process.env[key]; + delete process.env[key]; + } + } + mockDb = createMockDb(); + configureMockDb(mockDb, { select: [] }); + service = new ServerSeedService(mockDb as never); + }); + + afterEach(() => { + for (const [key, value] of Object.entries(originalEnv)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + }); + + it('seeds qBittorrent with only a URL (auth bypass)', async () => { + process.env['QBITTORRENT_URL'] = 'http://qbit:8080'; + + await service.onModuleInit(); + + expect(insertedRows()).toHaveLength(1); + expect(insertedRows()[0]).toMatchObject({ + providerId: 'qbittorrent', + url: 'http://qbit:8080', + apiKey: '', + username: null, + }); + }); + + it('seeds username and password', async () => { + process.env['QBITTORRENT_URL'] = 'http://qbit:8080'; + process.env['QBITTORRENT_USERNAME'] = ' admin '; + process.env['QBITTORRENT_PASSWORD'] = ' secret '; + + await service.onModuleInit(); + + // the password is stored verbatim (not trimmed), the username is trimmed + expect(insertedRows()[0]).toMatchObject({ username: 'admin', apiKey: ' secret ' }); + }); + + it('seeds an API key when only the password variable is set', async () => { + process.env['QBITTORRENT_URL'] = 'http://qbit:8080'; + process.env['QBITTORRENT_PASSWORD'] = 'qbt_key'; + + await service.onModuleInit(); + + expect(insertedRows()[0]).toMatchObject({ username: null, apiKey: 'qbt_key' }); + }); + + it('enables file ingestion with the container log path when QBITTORRENT_LOGS_PATH is set', async () => { + process.env['QBITTORRENT_URL'] = 'http://qbit:8080'; + process.env['QBITTORRENT_LOGS_PATH'] = '/host/logs'; + + await service.onModuleInit(); + + expect(insertedRows()[0]).toMatchObject({ + fileIngestionEnabled: true, + logPath: '/qbittorrent-logs', + logPaths: ['/qbittorrent-logs'], + }); + }); + + it('does not seed without a URL', async () => { + process.env['QBITTORRENT_PASSWORD'] = 'secret'; + + await service.onModuleInit(); + + expect(mockDb.insert).not.toHaveBeenCalled(); + }); + + it('does not seed when a qBittorrent server already exists', async () => { + process.env['QBITTORRENT_URL'] = 'http://qbit:8080'; + configureMockDb(mockDb, { select: [{ id: 'existing' }] }); + + await service.onModuleInit(); + + expect(mockDb.insert).not.toHaveBeenCalled(); + }); + + it('still requires an API key for other providers', async () => { + process.env['JELLYFIN_URL'] = 'http://jf:8096'; + + await service.onModuleInit(); + + expect(mockDb.insert).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/backend/src/modules/servers/server-seed.service.ts b/apps/backend/src/modules/servers/server-seed.service.ts index 1cc32ed..bdda652 100644 --- a/apps/backend/src/modules/servers/server-seed.service.ts +++ b/apps/backend/src/modules/servers/server-seed.service.ts @@ -13,7 +13,12 @@ import type { PostgresJsDatabase } from 'drizzle-orm/postgres-js'; */ interface EnvServerConfig { urlEnvKey: string; + /** Env var holding the API key (or the password when usernameEnvKey is used) */ apiKeyEnvKey: string; + /** Optional env var holding a login username */ + usernameEnvKey?: string; + /** When true only the URL is required (credentials may be empty, e.g. auth bypass) */ + credentialsOptional?: boolean; providerId: string; name: string; /** Container path for log files (used in Docker) */ @@ -77,6 +82,15 @@ const ENV_SERVER_CONFIGS: EnvServerConfig[] = [ name: 'Lidarr', containerLogPath: '/lidarr-logs', }, + { + urlEnvKey: 'QBITTORRENT_URL', + usernameEnvKey: 'QBITTORRENT_USERNAME', + apiKeyEnvKey: 'QBITTORRENT_PASSWORD', + credentialsOptional: true, + providerId: 'qbittorrent', + name: 'qBittorrent', + containerLogPath: '/qbittorrent-logs', + }, ]; @Injectable() @@ -108,12 +122,19 @@ export class ServerSeedService implements OnModuleInit { for (const config of ENV_SERVER_CONFIGS) { const url = process.env[config.urlEnvKey]; - const apiKey = process.env[config.apiKeyEnvKey]; + // Not trimmed for providers with a password: a leading/trailing space may be intentional, + // and the UI stores the value verbatim too. Only the emptiness check ignores whitespace. + const apiKey = config.usernameEnvKey + ? (process.env[config.apiKeyEnvKey] ?? '') + : (process.env[config.apiKeyEnvKey]?.trim() ?? ''); + const username = config.usernameEnvKey + ? process.env[config.usernameEnvKey]?.trim() || null + : null; this.logger.log(`Checking ${config.providerId}: URL=${!!url}, API key=${!!apiKey}`); - // Skip if URL or API key not set - if (!url || url.trim() === '' || !apiKey || apiKey.trim() === '') { + // Skip if URL (or, unless credentials are optional, the API key) is not set + if (!url || url.trim() === '' || (!config.credentialsOptional && apiKey.trim() === '')) { this.logger.log(`Skipping ${config.providerId}: missing URL or API key`); continue; } @@ -145,7 +166,8 @@ export class ServerSeedService implements OnModuleInit { name: config.name, providerId: config.providerId, url: url.trim(), - apiKey: apiKey.trim(), + apiKey, + username, // Enable file ingestion if log path is configured fileIngestionEnabled: hasLogPath, logPaths: effectiveLogPath ? [effectiveLogPath] : null, diff --git a/apps/backend/src/modules/servers/servers.dto.ts b/apps/backend/src/modules/servers/servers.dto.ts index aadef9c..320f20d 100644 --- a/apps/backend/src/modules/servers/servers.dto.ts +++ b/apps/backend/src/modules/servers/servers.dto.ts @@ -10,9 +10,17 @@ export class CreateServerDto { @ApiProperty({ description: 'Server URL' }) url: string; - @ApiProperty({ description: 'API key for authentication' }) + @ApiProperty({ + description: + 'API key / token / password for authentication (may be empty for qBittorrent with authentication bypass)', + }) apiKey: string; + @ApiPropertyOptional({ + description: 'Login username (qBittorrent). Leave empty for API-key or no-auth setups.', + }) + username?: string; + @ApiPropertyOptional({ description: 'Path to log files' }) logPath?: string; @@ -36,9 +44,14 @@ export class UpdateServerDto { @ApiPropertyOptional({ description: 'Server URL' }) url?: string; - @ApiPropertyOptional({ description: 'API key for authentication' }) + @ApiPropertyOptional({ description: 'API key / token / password for authentication' }) apiKey?: string; + @ApiPropertyOptional({ + description: 'Login username (qBittorrent). An empty string clears it.', + }) + username?: string; + @ApiPropertyOptional({ description: 'Path to log files' }) logPath?: string; @@ -71,6 +84,9 @@ export class ServerResponseDto { @ApiProperty() url: string; + @ApiPropertyOptional({ description: 'Login username (qBittorrent)' }) + username?: string | null; + @ApiPropertyOptional() logPath?: string | null; diff --git a/apps/backend/src/modules/servers/servers.service.spec.ts b/apps/backend/src/modules/servers/servers.service.spec.ts index b68d720..b731846 100644 --- a/apps/backend/src/modules/servers/servers.service.spec.ts +++ b/apps/backend/src/modules/servers/servers.service.spec.ts @@ -1,4 +1,4 @@ -import { NotFoundException } from '@nestjs/common'; +import { BadRequestException, NotFoundException } from '@nestjs/common'; import { Test } from '@nestjs/testing'; import { describe, it, expect, vi, beforeEach } from 'vitest'; @@ -1226,4 +1226,196 @@ describe('ServersService', () => { expect(result.message).toBe('Restart failed'); }); }); + + describe('qBittorrent credentials', () => { + const qbitServer = { + ...mockServer, + id: 'server-qbit', + providerId: 'qbittorrent', + url: 'http://localhost:8080', + apiKey: 'pw', + username: 'admin', + }; + const baseDto = { + name: 'qBittorrent', + providerId: 'qbittorrent', + url: 'http://localhost:8080', + }; + + const insertedValues = (): Record => { + const query = mockDb.insert.mock.results[0]!.value as { + values: ReturnType; + }; + return query.values.mock.calls[0]![0] as Record; + }; + + const updatedValues = (): Record => { + const query = mockDb.update.mock.results[0]!.value as { set: ReturnType }; + return query.set.mock.calls[0]![0] as Record; + }; + + it('registers the qbittorrent provider without session support', () => { + const qbit = service.getAvailableProviders().find((p) => p.id === 'qbittorrent'); + + expect(qbit).toBeDefined(); + expect(qbit?.capabilities.supportsSessions).toBe(false); + }); + + it('stores a trimmed username on create', async () => { + configureMockDb(mockDb, { insert: [qbitServer] }); + + await service.create({ ...baseDto, apiKey: 'pw', username: ' admin ' }); + + expect(insertedValues()).toMatchObject({ username: 'admin', apiKey: 'pw' }); + }); + + it.each([undefined, '', ' '])('stores NULL for username %j on create', async (username) => { + configureMockDb(mockDb, { insert: [qbitServer] }); + + await service.create({ + ...baseDto, + apiKey: 'key', + ...(username !== undefined && { username }), + }); + + expect(insertedValues()['username']).toBeNull(); + }); + + it('stores NULL when the username is sent as null on create', async () => { + configureMockDb(mockDb, { insert: [qbitServer] }); + + await service.create({ ...baseDto, apiKey: 'key', username: null } as any); + + expect(insertedValues()['username']).toBeNull(); + }); + + it('allows an omitted apiKey (auth bypass) and stores an empty string', async () => { + configureMockDb(mockDb, { insert: [qbitServer] }); + + await service.create({ ...baseDto } as any); + + expect(insertedValues()['apiKey']).toBe(''); + }); + + it('stores NULL for null or non-string usernames on create', async () => { + configureMockDb(mockDb, { insert: [qbitServer] }); + await service.create({ ...baseDto, apiKey: 'k', username: null } as any); + expect(insertedValues()['username']).toBeNull(); + }); + + it.each([undefined, ''])( + 'rejects a missing or empty apiKey (%j) for other providers', + async (apiKey) => { + configureMockDb(mockDb, { insert: [mockServer] }); + + await expect( + service.create({ + name: 'Sonarr', + providerId: 'sonarr', + url: 'http://localhost:8989', + ...(apiKey !== undefined && { apiKey }), + } as any) + ).rejects.toThrow(BadRequestException); + expect(mockDb.insert).not.toHaveBeenCalled(); + } + ); + + it('allows an empty apiKey on create', async () => { + configureMockDb(mockDb, { insert: [qbitServer] }); + + await service.create({ ...baseDto, apiKey: '' }); + + expect(insertedValues()['apiKey']).toBe(''); + }); + + describe('update', () => { + beforeEach(() => { + configureMockDb(mockDb, { select: [qbitServer], update: [qbitServer] }); + }); + + it('clears the username when an empty string is sent', async () => { + await service.update('server-qbit', { username: '' }); + + expect(updatedValues()['username']).toBeNull(); + }); + + it('trims and stores a new username', async () => { + await service.update('server-qbit', { username: ' bob ' }); + + expect(updatedValues()['username']).toBe('bob'); + }); + + it('does not touch the username when it is not part of the update', async () => { + await service.update('server-qbit', { name: 'Renamed' }); + + expect(updatedValues()).not.toHaveProperty('username'); + expect(updatedValues()['name']).toBe('Renamed'); + }); + + it('clears the username when only whitespace is sent', async () => { + await service.update('server-qbit', { username: ' ' }); + + expect(updatedValues()['username']).toBeNull(); + }); + + it('clears the username when null is sent explicitly', async () => { + await service.update('server-qbit', { username: null } as any); + + expect(updatedValues()['username']).toBeNull(); + }); + + it('ignores non-string usernames instead of crashing', async () => { + await service.update('server-qbit', { username: 42 } as any); + + expect(updatedValues()).not.toHaveProperty('username'); + }); + + it('keeps an empty apiKey as is', async () => { + await service.update('server-qbit', { apiKey: '' }); + + expect(updatedValues()['apiKey']).toBe(''); + }); + }); + + describe('testConnection', () => { + const mockProvider = { + id: 'qbittorrent', + name: 'qBittorrent', + connect: vi.fn().mockResolvedValue(undefined), + disconnect: vi.fn().mockResolvedValue(undefined), + testConnection: vi.fn().mockResolvedValue({ connected: true }), + }; + + beforeEach(() => { + mockProvider.connect.mockClear(); + service['providers'].set('qbittorrent', mockProvider as any); + }); + + it('passes the username to the provider', async () => { + configureMockDb(mockDb, { select: [qbitServer], update: [qbitServer] }); + + await service.testConnection('server-qbit'); + + expect(mockProvider.connect).toHaveBeenCalledWith({ + url: 'http://localhost:8080', + apiKey: 'pw', + username: 'admin', + logPath: '/var/log/jellyfin', + }); + }); + + it('passes undefined when the stored username is NULL', async () => { + configureMockDb(mockDb, { + select: [{ ...qbitServer, username: null }], + update: [qbitServer], + }); + + await service.testConnection('server-qbit'); + + expect(mockProvider.connect).toHaveBeenCalledWith( + expect.objectContaining({ username: undefined }) + ); + }); + }); + }); }); diff --git a/apps/backend/src/modules/servers/servers.service.ts b/apps/backend/src/modules/servers/servers.service.ts index a3616cd..2265ff1 100644 --- a/apps/backend/src/modules/servers/servers.service.ts +++ b/apps/backend/src/modules/servers/servers.service.ts @@ -3,10 +3,18 @@ import { JellyfinProvider } from '@logarr/provider-jellyfin'; import { LidarrProvider } from '@logarr/provider-lidarr'; import { PlexProvider } from '@logarr/provider-plex'; import { ProwlarrProvider } from '@logarr/provider-prowlarr'; +import { QBittorrentProvider } from '@logarr/provider-qbittorrent'; import { RadarrProvider } from '@logarr/provider-radarr'; import { SonarrProvider } from '@logarr/provider-sonarr'; import { WhisparrProvider } from '@logarr/provider-whisparr'; -import { Injectable, Inject, NotFoundException, forwardRef, Logger } from '@nestjs/common'; +import { + Injectable, + Inject, + NotFoundException, + BadRequestException, + forwardRef, + Logger, +} from '@nestjs/common'; import { eq } from 'drizzle-orm'; import { DATABASE_CONNECTION } from '../../database'; @@ -18,6 +26,17 @@ import type { CreateServerDto, UpdateServerDto } from './servers.dto'; import type { MediaServerProvider } from '@logarr/core'; import type { PostgresJsDatabase } from 'drizzle-orm/postgres-js'; +/** + * Normalise a username from an API client: strings are trimmed (empty -> null), an explicit + * null clears it, anything else (missing or a non-string value) is left untouched (undefined). + */ +function normalizeUsername(username: unknown): string | null | undefined { + if (typeof username === 'string') { + return username.trim() || null; + } + return username === null ? null : undefined; +} + @Injectable() export class ServersService { private readonly logger = new Logger(ServersService.name); @@ -55,6 +74,8 @@ export class ServersService { const lidarrProvider = new LidarrProvider(); this.providers.set(lidarrProvider.id, lidarrProvider); + const qbittorrentProvider = new QBittorrentProvider(); + this.providers.set(qbittorrentProvider.id, qbittorrentProvider); } async findAll() { @@ -82,13 +103,19 @@ export class ServersService { throw new Error(`Unknown provider: ${dto.providerId}`); } + if (dto.providerId !== 'qbittorrent' && (dto.apiKey ?? '') === '') { + throw new BadRequestException('API key is required'); + } + const result = await this.db .insert(schema.servers) .values({ name: dto.name, providerId: dto.providerId, url: dto.url, - apiKey: dto.apiKey, + // apiKey may be empty only for qBittorrent (authentication bypass); the column is NOT NULL + apiKey: dto.apiKey ?? '', + username: normalizeUsername(dto.username) ?? null, logPath: dto.logPath, fileIngestionEnabled: dto.fileIngestionEnabled ?? false, logPaths: dto.logPaths, @@ -112,10 +139,15 @@ export class ServersService { // Get the current server state to detect changes const currentServer = await this.findOne(id); + // An empty username clears it (stored as NULL) + const { username, ...rest } = dto; + const normalizedUsername = normalizeUsername(username); + const result = await this.db .update(schema.servers) .set({ - ...dto, + ...rest, + ...(normalizedUsername !== undefined && { username: normalizedUsername }), updatedAt: new Date(), }) .where(eq(schema.servers.id, id)) @@ -228,6 +260,7 @@ export class ServersService { await provider.connect({ url: server.url, apiKey: server.apiKey, + username: server.username ?? undefined, logPath: server.logPath ?? undefined, }); diff --git a/apps/frontend/src/components/add-source-modal.tsx b/apps/frontend/src/components/add-source-modal.tsx index 4904a63..63ad299 100644 --- a/apps/frontend/src/components/add-source-modal.tsx +++ b/apps/frontend/src/components/add-source-modal.tsx @@ -40,6 +40,7 @@ import { } from '@/components/ui/select'; import { useCreateServer } from '@/hooks/use-api'; import { type Integration, type ConfigField, getIntegrationById } from '@/lib/integrations'; +import { buildCreateServerPayload } from '@/lib/server-payload'; import { cn } from '@/lib/utils'; interface AddSourceModalProps { @@ -211,13 +212,7 @@ function ConfigurationStep({ async function onSubmit(data: Record) { try { - await createServer.mutateAsync({ - name: data.name as string, - providerId: integration.id, - url: data.url as string, - apiKey: (data.apiKey || data.token || data.password || '') as string, - logPath: data.logPath as string | undefined, - }); + await createServer.mutateAsync(buildCreateServerPayload(integration.id, data)); onSuccess(); } catch (error) { toast.error(error instanceof Error ? error.message : 'Failed to add source'); diff --git a/apps/frontend/src/components/edit-server-dialog.test.tsx b/apps/frontend/src/components/edit-server-dialog.test.tsx new file mode 100644 index 0000000..0d47239 --- /dev/null +++ b/apps/frontend/src/components/edit-server-dialog.test.tsx @@ -0,0 +1,182 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { EditServerDialog } from './edit-server-dialog'; +import type { Server } from '@/lib/api'; + +const mutateAsync = vi.fn(); + +vi.mock('@/hooks/use-api', () => ({ + useUpdateServer: () => ({ mutateAsync, isPending: false }), + useTestConnection: () => ({ mutateAsync: vi.fn(), isPending: false }), +})); + +vi.mock('sonner', () => ({ + toast: { success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() }, +})); + +function makeServer(overrides: Partial): Server { + return { + id: 'server-1', + name: 'My Server', + providerId: 'sonarr', + url: 'http://localhost:8989', + apiKey: 'secret-key', + username: null, + logPath: null, + isConnected: true, + lastSeen: null, + lastError: null, + version: null, + serverName: null, + fileIngestionEnabled: false, + fileIngestionConnected: false, + fileIngestionError: null, + logPaths: null, + logFilePatterns: null, + lastFileSync: null, + createdAt: '2026-01-01T00:00:00Z', + updatedAt: '2026-01-01T00:00:00Z', + ...overrides, + }; +} + +function renderDialog(server: Server) { + return render(); +} + +/** The "API key" / "Password / API key" input is the only password-type field */ +function secretInput(): HTMLInputElement { + const input = document.querySelector('input[type="password"]'); + if (!input) throw new Error('secret input not found'); + return input; +} + +describe('EditServerDialog credential validation', () => { + beforeEach(() => { + mutateAsync.mockReset(); + mutateAsync.mockResolvedValue({}); + }); + + describe('qbittorrent', () => { + const qbit = makeServer({ + providerId: 'qbittorrent', + url: 'http://localhost:8080', + apiKey: 'old-password', + username: 'admin', + }); + + it('renders a username field prefilled from the server', () => { + renderDialog(qbit); + + expect(screen.getByPlaceholderText('admin')).toHaveValue('admin'); + expect(screen.getByText('Password / API key')).toBeInTheDocument(); + }); + + it('allows saving with an empty password and sends the empty apiKey', async () => { + const user = userEvent.setup(); + renderDialog(qbit); + + await user.clear(secretInput()); + await user.click(screen.getByRole('button', { name: /save changes/i })); + + await waitFor(() => expect(mutateAsync).toHaveBeenCalledTimes(1)); + expect(screen.queryByText('API key is required')).not.toBeInTheDocument(); + expect(mutateAsync.mock.calls[0]![0]).toMatchObject({ + id: 'server-1', + data: { apiKey: '', username: 'admin' }, + }); + }); + + it('sends an empty username to clear it when the field is emptied', async () => { + const user = userEvent.setup(); + renderDialog(qbit); + + await user.clear(screen.getByPlaceholderText('admin')); + await user.click(screen.getByRole('button', { name: /save changes/i })); + + await waitFor(() => expect(mutateAsync).toHaveBeenCalledTimes(1)); + expect(mutateAsync.mock.calls[0]![0].data.username).toBe(''); + }); + + it('trims the username before sending it', async () => { + const user = userEvent.setup(); + renderDialog(qbit); + + await user.clear(screen.getByPlaceholderText('admin')); + await user.type(screen.getByPlaceholderText('admin'), ' bob '); + await user.click(screen.getByRole('button', { name: /save changes/i })); + + await waitFor(() => expect(mutateAsync).toHaveBeenCalledTimes(1)); + expect(mutateAsync.mock.calls[0]![0].data.username).toBe('bob'); + }); + + it('still validates the URL', async () => { + const user = userEvent.setup(); + renderDialog(qbit); + + const url = screen.getByDisplayValue('http://localhost:8080'); + await user.clear(url); + await user.type(url, 'not a url'); + await user.click(screen.getByRole('button', { name: /save changes/i })); + + expect(await screen.findByText('Must be a valid URL')).toBeInTheDocument(); + expect(mutateAsync).not.toHaveBeenCalled(); + }); + + it('prefills an empty username as an empty string for auth-bypass servers', () => { + renderDialog(makeServer({ providerId: 'qbittorrent', apiKey: '', username: null })); + + expect(screen.getByPlaceholderText('admin')).toHaveValue(''); + expect(secretInput()).toHaveValue(''); + }); + }); + + describe.each(['sonarr', 'radarr', 'prowlarr', 'whisparr', 'jellyfin', 'emby', 'plex'])( + '%s', + (providerId) => { + const server = makeServer({ providerId }); + + it('has no username field', () => { + renderDialog(server); + + expect(screen.queryByPlaceholderText('admin')).not.toBeInTheDocument(); + }); + + it('rejects an empty API key', async () => { + const user = userEvent.setup(); + renderDialog(server); + + await user.clear(secretInput()); + await user.click(screen.getByRole('button', { name: /save changes/i })); + + expect(await screen.findByText('API key is required')).toBeInTheDocument(); + expect(mutateAsync).not.toHaveBeenCalled(); + }); + + it('rejects a whitespace-only API key', async () => { + const user = userEvent.setup(); + renderDialog(server); + + await user.clear(secretInput()); + await user.type(secretInput(), ' '); + await user.click(screen.getByRole('button', { name: /save changes/i })); + + expect(await screen.findByText('API key is required')).toBeInTheDocument(); + expect(mutateAsync).not.toHaveBeenCalled(); + }); + } + ); + + it('saves a valid sonarr edit without sending a username', async () => { + const user = userEvent.setup(); + renderDialog(makeServer({ providerId: 'sonarr' })); + + await user.click(screen.getByRole('button', { name: /save changes/i })); + + await waitFor(() => expect(mutateAsync).toHaveBeenCalledTimes(1)); + const data = mutateAsync.mock.calls[0]![0].data; + expect(data.apiKey).toBe('secret-key'); + expect(data).not.toHaveProperty('username'); + }); +}); diff --git a/apps/frontend/src/components/edit-server-dialog.tsx b/apps/frontend/src/components/edit-server-dialog.tsx index c2922ba..eef004c 100644 --- a/apps/frontend/src/components/edit-server-dialog.tsx +++ b/apps/frontend/src/components/edit-server-dialog.tsx @@ -14,7 +14,7 @@ import { FolderOpen, FileSearch, } from 'lucide-react'; -import { useEffect, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { useForm } from 'react-hook-form'; import { toast } from 'sonner'; import { z } from 'zod/v3'; @@ -50,19 +50,29 @@ import { Input } from '@/components/ui/input'; import { Switch } from '@/components/ui/switch'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; import { useUpdateServer, useTestConnection } from '@/hooks/use-api'; +import { areCredentialsOptional, buildUpdateCredentials } from '@/lib/server-payload'; import { cn } from '@/lib/utils'; -const formSchema = z.object({ +// qBittorrent allows an empty password / API key (authentication bypass) +const buildFormSchema = (providerId: string) => + baseFormSchema.superRefine((data, ctx) => { + if (!areCredentialsOptional(providerId) && data.apiKey.trim() === '') { + ctx.addIssue({ code: 'custom', path: ['apiKey'], message: 'API key is required' }); + } + }); + +const baseFormSchema = z.object({ name: z.string().min(1, 'Name is required').max(100), url: z.string().url('Must be a valid URL'), - apiKey: z.string().min(1, 'API key is required'), + apiKey: z.string(), + username: z.string().optional(), logPath: z.string().optional(), fileIngestionEnabled: z.boolean(), logPaths: z.string().optional(), logFilePatterns: z.string().optional(), }); -type FormData = z.infer; +type FormData = z.infer; interface EditServerDialogProps { server: ServerType; @@ -75,12 +85,16 @@ export function EditServerDialog({ server, open, onOpenChange }: EditServerDialo const testConnection = useTestConnection(); const [isTesting, setIsTesting] = useState(false); + const formSchema = useMemo(() => buildFormSchema(server.providerId), [server.providerId]); + const hasUsername = areCredentialsOptional(server.providerId); + const form = useForm({ resolver: zodResolver(formSchema), defaultValues: { name: server.name, url: server.url, apiKey: server.apiKey, + username: server.username ?? '', logPath: server.logPath || '', fileIngestionEnabled: server.fileIngestionEnabled || false, logPaths: server.logPaths?.join(', ') || '', @@ -96,6 +110,7 @@ export function EditServerDialog({ server, open, onOpenChange }: EditServerDialo name: server.name, url: server.url, apiKey: server.apiKey, + username: server.username ?? '', logPath: server.logPath || '', fileIngestionEnabled: server.fileIngestionEnabled || false, logPaths: server.logPaths?.join(', ') || '', @@ -156,7 +171,7 @@ export function EditServerDialog({ server, open, onOpenChange }: EditServerDialo data: { name: data.name, url: data.url, - apiKey: data.apiKey, + ...buildUpdateCredentials(server.providerId, data), logPath: data.logPath || undefined, fileIngestionEnabled: data.fileIngestionEnabled, logPaths: logPaths?.length ? logPaths : undefined, @@ -288,24 +303,68 @@ export function EditServerDialog({ server, open, onOpenChange }: EditServerDialo /> - {/* API Key full width */} - ( - - - - API Key - - - - - Found in Dashboard → API Keys - - - )} - /> + {hasUsername ? ( +
+ ( + + + + Username + + + + + Leave empty for API-key or no-auth setups + + + )} + /> + ( + + + + Password / API key + + + + + + With a username: password. Without: API key. Empty if auth is bypassed + + + + )} + /> +
+ ) : ( + ( + + + + API Key + + + + + Found in Dashboard → API Keys + + + )} + /> + )} {/* File Ingestion Section */} diff --git a/apps/frontend/src/components/source-diagnostics-dialog.test.tsx b/apps/frontend/src/components/source-diagnostics-dialog.test.tsx index e10df06..ce05c69 100644 --- a/apps/frontend/src/components/source-diagnostics-dialog.test.tsx +++ b/apps/frontend/src/components/source-diagnostics-dialog.test.tsx @@ -18,6 +18,7 @@ describe('SourceDiagnosticsDialog', () => { url: 'http://192.168.1.100:32400', providerId: 'plex', apiKey: 'test-api-key', + username: null, logPath: null, isConnected: true, lastSeen: '2024-01-01T00:00:00Z', diff --git a/apps/frontend/src/lib/api.ts b/apps/frontend/src/lib/api.ts index ad7e0f9..d9554c3 100644 --- a/apps/frontend/src/lib/api.ts +++ b/apps/frontend/src/lib/api.ts @@ -9,6 +9,7 @@ export interface Server { providerId: string; url: string; apiKey: string; + username: string | null; logPath: string | null; isConnected: boolean; lastSeen: string | null; @@ -31,6 +32,7 @@ export interface CreateServerDto { providerId: string; url: string; apiKey: string; + username?: string; logPath?: string; fileIngestionEnabled?: boolean; logPaths?: string[]; @@ -41,6 +43,7 @@ export interface UpdateServerDto { name?: string; url?: string; apiKey?: string; + username?: string; logPath?: string; fileIngestionEnabled?: boolean; logPaths?: string[]; diff --git a/apps/frontend/src/lib/integrations.test.ts b/apps/frontend/src/lib/integrations.test.ts index 8660d7e..00590b6 100644 --- a/apps/frontend/src/lib/integrations.test.ts +++ b/apps/frontend/src/lib/integrations.test.ts @@ -10,6 +10,39 @@ import { } from './integrations'; describe('Integrations Registry', () => { + describe('qBittorrent integration', () => { + it('should be available in the download clients category', () => { + const qbit = getIntegrationById('qbittorrent'); + expect(qbit?.name).toBe('qBittorrent'); + expect(qbit?.status).toBe('available'); + expect(qbit?.category).toBe('download_clients'); + expect(qbit?.connectionType).toBe('api_basic'); + expect(getAvailableIntegrations().some((i) => i.id === 'qbittorrent')).toBe(true); + }); + + it('should have username and password optional and name/url required', () => { + const fields = getIntegrationById('qbittorrent')?.configFields ?? []; + expect(fields.map((f) => f.name)).toEqual(['name', 'url', 'username', 'password', 'logPath']); + const byName = Object.fromEntries(fields.map((f) => [f.name, f])); + expect(byName['name']?.required).toBe(true); + expect(byName['url']?.required).toBe(true); + expect(byName['username']?.required).toBe(false); + expect(byName['password']?.required).toBe(false); + expect(byName['password']?.type).toBe('password'); + }); + + it('should not claim sessions, real-time logs or metrics', () => { + const caps = getIntegrationById('qbittorrent')?.capabilities; + expect(caps?.sessions).toBe(false); + expect(caps?.realTimeLogs).toBe(false); + }); + + it('should be found by search', () => { + expect(searchIntegrations('qbittorrent').some((i) => i.id === 'qbittorrent')).toBe(true); + expect(searchIntegrations('torrent').some((i) => i.id === 'qbittorrent')).toBe(true); + }); + }); + describe('Whisparr integration', () => { it('should be included in integrations array', () => { const whisparr = integrations.find((i) => i.id === 'whisparr'); diff --git a/apps/frontend/src/lib/integrations.ts b/apps/frontend/src/lib/integrations.ts index f4d69d5..b869ca5 100644 --- a/apps/frontend/src/lib/integrations.ts +++ b/apps/frontend/src/lib/integrations.ts @@ -472,9 +472,10 @@ export const integrations: Integration[] = [ { id: 'qbittorrent', name: 'qBittorrent', - description: 'Free and reliable P2P BitTorrent client', + description: + 'Free and reliable P2P BitTorrent client (WebUI API v2). Supports username/password, authentication bypass and API keys (5.3+)', category: 'download_clients', - status: 'coming_soon', + status: 'available', connectionType: 'api_basic', icon: 'qbittorrent', iconType: 'dashboard-icons', @@ -490,22 +491,30 @@ export const integrations: Integration[] = [ label: 'Username', type: 'text', placeholder: 'admin', - required: true, + description: 'WebUI username. Leave empty for API-key or no-auth (bypass) setups', + required: false, }, { name: 'password', - label: 'Password', + label: 'Password / API key', type: 'password', - placeholder: 'Enter password', - required: true, + placeholder: 'WebUI password, or API key (qBittorrent 5.3+)', + description: + "With a username: the WebUI password. Without a username: an API key. Leave both empty if authentication is bypassed for Logarr's IP", + required: false, + }, + { + ...standardLogPathField, + placeholder: '/config/qBittorrent/logs', }, ], + docsUrl: 'https://github.com/qbittorrent/qBittorrent/wiki/WebUI-API-(qBittorrent-5.0)', capabilities: { - realTimeLogs: true, + realTimeLogs: false, activityLog: true, sessions: false, webhooks: false, - metrics: true, + metrics: false, }, tags: ['torrent', 'download', 'p2p', 'bittorrent', 'open-source'], }, diff --git a/apps/frontend/src/lib/server-payload.test.ts b/apps/frontend/src/lib/server-payload.test.ts new file mode 100644 index 0000000..6d611f8 --- /dev/null +++ b/apps/frontend/src/lib/server-payload.test.ts @@ -0,0 +1,75 @@ +import { describe, it, expect } from 'vitest'; + +import { + areCredentialsOptional, + buildCreateServerPayload, + buildUpdateCredentials, +} from './server-payload'; + +describe('buildCreateServerPayload', () => { + it('maps the password field to apiKey and trims the username', () => { + expect( + buildCreateServerPayload('qbittorrent', { + name: 'qBit', + url: 'http://qbit:8080', + username: ' admin ', + password: 'secret', + logPath: '/qbittorrent-logs', + }) + ).toEqual({ + name: 'qBit', + providerId: 'qbittorrent', + url: 'http://qbit:8080', + apiKey: 'secret', + username: 'admin', + logPath: '/qbittorrent-logs', + }); + }); + + it('omits a blank username and allows an empty apiKey', () => { + const payload = buildCreateServerPayload('qbittorrent', { + name: 'qBit', + url: 'http://qbit:8080', + username: ' ', + password: '', + }); + expect(payload.apiKey).toBe(''); + expect(payload).not.toHaveProperty('username'); + }); + + it('keeps apiKey / token / password precedence for other providers', () => { + expect(buildCreateServerPayload('sonarr', { name: 'n', url: 'u', apiKey: 'k' }).apiKey).toBe( + 'k' + ); + expect(buildCreateServerPayload('x', { name: 'n', url: 'u', token: 't' }).apiKey).toBe('t'); + expect( + buildCreateServerPayload('sonarr', { name: 'n', url: 'u', apiKey: 'k' }) + ).not.toHaveProperty('username'); + }); +}); + +describe('buildUpdateCredentials', () => { + it('sends the trimmed username for qbittorrent, empty string to clear', () => { + expect(buildUpdateCredentials('qbittorrent', { apiKey: 'pw', username: ' bob ' })).toEqual({ + apiKey: 'pw', + username: 'bob', + }); + expect(buildUpdateCredentials('qbittorrent', { apiKey: '', username: undefined })).toEqual({ + apiKey: '', + username: '', + }); + }); + + it('never sends a username for other providers', () => { + expect(buildUpdateCredentials('sonarr', { apiKey: 'k', username: 'x' })).toEqual({ + apiKey: 'k', + }); + }); +}); + +describe('areCredentialsOptional', () => { + it('is true only for qbittorrent', () => { + expect(areCredentialsOptional('qbittorrent')).toBe(true); + expect(areCredentialsOptional('sonarr')).toBe(false); + }); +}); diff --git a/apps/frontend/src/lib/server-payload.ts b/apps/frontend/src/lib/server-payload.ts new file mode 100644 index 0000000..fce71a3 --- /dev/null +++ b/apps/frontend/src/lib/server-payload.ts @@ -0,0 +1,42 @@ +import type { CreateServerDto } from '@/lib/api'; + +/** Providers whose credentials (username / password / API key) are all optional */ +const OPTIONAL_CREDENTIAL_PROVIDERS = new Set(['qbittorrent']); + +export function areCredentialsOptional(providerId: string): boolean { + return OPTIONAL_CREDENTIAL_PROVIDERS.has(providerId); +} + +/** + * Build the create-server payload from the dynamic "add source" form values. + * The password field is stored in apiKey (it may be empty for qBittorrent). + */ +export function buildCreateServerPayload( + providerId: string, + data: Record +): CreateServerDto { + const username = typeof data.username === 'string' ? data.username.trim() : ''; + + return { + name: data.name as string, + providerId, + url: data.url as string, + apiKey: (data.apiKey || data.token || data.password || '') as string, + ...(username !== '' && { username }), + logPath: data.logPath as string | undefined, + }; +} + +/** + * Credential fields for the update payload. For providers using usernames the username is + * always sent ('' clears it on the backend). + */ +export function buildUpdateCredentials( + providerId: string, + data: { apiKey: string; username?: string | undefined } +): { apiKey: string; username?: string } { + return { + apiKey: data.apiKey, + ...(areCredentialsOptional(providerId) && { username: data.username?.trim() ?? '' }), + }; +} diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 22b1c9b..80b6d26 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -73,6 +73,7 @@ services: - ./packages/provider-emby/src:/app/packages/provider-emby/src - ./packages/provider-whisparr/src:/app/packages/provider-whisparr/src - ./packages/provider-lidarr/src:/app/packages/provider-lidarr/src + - ./packages/provider-qbittorrent/src:/app/packages/provider-qbittorrent/src - ./apps/backend/src:/app/apps/backend/src # Mount config files for changes - ./apps/backend/nest-cli.json:/app/apps/backend/nest-cli.json @@ -92,6 +93,7 @@ services: - ${PROWLARR_LOGS_PATH:-./logs/prowlarr}:/prowlarr-logs:ro - ${WHISPARR_LOGS_PATH:-./logs/whisparr}:/whisparr-logs:ro - ${LIDARR_LOGS_PATH:-./logs/lidarr}:/lidarr-logs:ro + - ${QBITTORRENT_LOGS_PATH:-./logs/qbittorrent}:/qbittorrent-logs:ro # Multiple instance mounts for development - ${PLEX_LOGS_PATH_1:-./logs/plex-1}:/plex-logs-1:ro - ${JELLYFIN_LOGS_PATH_1:-./logs/jellyfin-1}:/jellyfin-logs-1:ro @@ -101,6 +103,7 @@ services: - ${PROWLARR_LOGS_PATH_1:-./logs/prowlarr-1}:/prowlarr-logs-1:ro - ${WHISPARR_LOGS_PATH_1:-./logs/whisparr-1}:/whisparr-logs-1:ro - ${LIDARR_LOGS_PATH_1:-./logs/lidarr-1}:/lidarr-logs-1:ro + - ${QBITTORRENT_LOGS_PATH_1:-./logs/qbittorrent-1}:/qbittorrent-logs-1:ro - ${PLEX_LOGS_PATH_2:-./logs/plex-2}:/plex-logs-2:ro - ${JELLYFIN_LOGS_PATH_2:-./logs/jellyfin-2}:/jellyfin-logs-2:ro - ${EMBY_LOGS_PATH_2:-./logs/emby-2}:/emby-logs-2:ro @@ -109,6 +112,7 @@ services: - ${PROWLARR_LOGS_PATH_2:-./logs/prowlarr-2}:/prowlarr-logs-2:ro - ${WHISPARR_LOGS_PATH_2:-./logs/whisparr-2}:/whisparr-logs-2:ro - ${LIDARR_LOGS_PATH_2:-./logs/lidarr-2}:/lidarr-logs-2:ro + - ${QBITTORRENT_LOGS_PATH_2:-./logs/qbittorrent-2}:/qbittorrent-logs-2:ro healthcheck: test: ['CMD', 'wget', '--no-verbose', '--tries=1', '--spider', 'http://localhost:4002/api'] interval: 30s diff --git a/docker-compose.yml b/docker-compose.yml index 220bb7d..4a09fa6 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -77,6 +77,10 @@ services: WHISPARR_API_KEY: ${WHISPARR_API_KEY:-} LIDARR_URL: ${LIDARR_URL:-} LIDARR_API_KEY: ${LIDARR_API_KEY:-} + # qBittorrent: username + password, or only a password/API key (5.3+), or neither (auth bypass) + QBITTORRENT_URL: ${QBITTORRENT_URL:-} + QBITTORRENT_USERNAME: ${QBITTORRENT_USERNAME:-} + QBITTORRENT_PASSWORD: ${QBITTORRENT_PASSWORD:-} # Log paths - set to non-empty to enable file ingestion for that server # The actual path inside container is fixed (e.g., /jellyfin-logs) JELLYFIN_LOGS_PATH: ${JELLYFIN_LOGS_PATH:-} @@ -87,6 +91,7 @@ services: PROWLARR_LOGS_PATH: ${PROWLARR_LOGS_PATH:-} WHISPARR_LOGS_PATH: ${WHISPARR_LOGS_PATH:-} LIDARR_LOGS_PATH: ${LIDARR_LOGS_PATH:-} + QBITTORRENT_LOGS_PATH: ${QBITTORRENT_LOGS_PATH:-} # Multiple instance support - add numbered suffixes for additional instances # Example: RADARR_LOGS_PATH_1=/path/to/movies, RADARR_LOGS_PATH_2=/path/to/shorts JELLYFIN_LOGS_PATH_1: ${JELLYFIN_LOGS_PATH_1:-} @@ -113,6 +118,9 @@ services: LIDARR_LOGS_PATH_1: ${LIDARR_LOGS_PATH_1:-} LIDARR_LOGS_PATH_2: ${LIDARR_LOGS_PATH_2:-} LIDARR_LOGS_PATH_3: ${LIDARR_LOGS_PATH_3:-} + QBITTORRENT_LOGS_PATH_1: ${QBITTORRENT_LOGS_PATH_1:-} + QBITTORRENT_LOGS_PATH_2: ${QBITTORRENT_LOGS_PATH_2:-} + QBITTORRENT_LOGS_PATH_3: ${QBITTORRENT_LOGS_PATH_3:-} # Admin password reset - set to 'true' to reset admin account on startup ADMIN_PASSWORD_RESET: ${ADMIN_PASSWORD_RESET:-} volumes: @@ -126,6 +134,7 @@ services: - ${PROWLARR_LOGS_PATH:-/dev/null}:/prowlarr-logs:ro - ${WHISPARR_LOGS_PATH:-/dev/null}:/whisp-logs:ro - ${LIDARR_LOGS_PATH:-/dev/null}:/lidarr-logs:ro + - ${QBITTORRENT_LOGS_PATH:-/dev/null}:/qbittorrent-logs:ro # Multiple instance mounts - numbered instances map to /-logs- - ${JELLYFIN_LOGS_PATH_1:-/dev/null}:/jellyfin-logs-1:ro - ${JELLYFIN_LOGS_PATH_2:-/dev/null}:/jellyfin-logs-2:ro @@ -151,6 +160,9 @@ services: - ${LIDARR_LOGS_PATH_1:-/dev/null}:/lidarr-logs-1:ro - ${LIDARR_LOGS_PATH_2:-/dev/null}:/lidarr-logs-2:ro - ${LIDARR_LOGS_PATH_3:-/dev/null}:/lidarr-logs-3:ro + - ${QBITTORRENT_LOGS_PATH_1:-/dev/null}:/qbittorrent-logs-1:ro + - ${QBITTORRENT_LOGS_PATH_2:-/dev/null}:/qbittorrent-logs-2:ro + - ${QBITTORRENT_LOGS_PATH_3:-/dev/null}:/qbittorrent-logs-3:ro healthcheck: test: ['CMD', 'wget', '--no-verbose', '--tries=1', '--spider', 'http://localhost:4000/api'] interval: 30s diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 1e1dd04..71bfc0b 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -33,7 +33,10 @@ export interface ProviderCapabilities { */ export interface ProviderConfig { readonly url: string; + /** API key, token, or password (may be empty for providers without authentication) */ readonly apiKey: string; + /** Optional login username (providers using username/password authentication) */ + readonly username?: string | undefined; readonly logPath?: string | undefined; } @@ -393,6 +396,7 @@ export interface Server { readonly providerId: string; readonly url: string; readonly apiKey: string; + readonly username: string | null; readonly logPath: string | null; readonly isEnabled: boolean; readonly isConnected: boolean; @@ -418,6 +422,7 @@ export interface CreateServerInput { readonly providerId: string; readonly url: string; readonly apiKey: string; + readonly username?: string; readonly logPath?: string; } @@ -428,6 +433,7 @@ export interface UpdateServerInput { readonly name?: string; readonly url?: string; readonly apiKey?: string; + readonly username?: string; readonly logPath?: string; readonly isEnabled?: boolean; // File-based log ingestion fields diff --git a/packages/provider-qbittorrent/package.json b/packages/provider-qbittorrent/package.json new file mode 100644 index 0000000..2dbca93 --- /dev/null +++ b/packages/provider-qbittorrent/package.json @@ -0,0 +1,36 @@ +{ + "name": "@logarr/provider-qbittorrent", + "version": "0.1.0", + "private": true, + "type": "module", + "description": "qBittorrent provider for Logarr", + "main": "./dist/index.cjs", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "require": "./dist/index.cjs" + } + }, + "scripts": { + "build": "tsup", + "dev": "tsup --watch", + "lint": "eslint src/", + "lint:fix": "eslint src/ --fix", + "typecheck": "tsc --noEmit", + "test": "vitest run", + "test:watch": "vitest", + "clean": "rm -rf dist .turbo" + }, + "dependencies": { + "@logarr/core": "workspace:*" + }, + "devDependencies": { + "@types/node": "^22.10.2", + "tsup": "^8.3.5", + "typescript": "^5.7.2", + "vitest": "^4.0.16" + } +} diff --git a/packages/provider-qbittorrent/src/index.ts b/packages/provider-qbittorrent/src/index.ts new file mode 100644 index 0000000..2685cc4 --- /dev/null +++ b/packages/provider-qbittorrent/src/index.ts @@ -0,0 +1,14 @@ +// qBittorrent provider for Logarr +export { QBittorrentProvider } from './qbittorrent.provider.js'; +export { QBittorrentClient } from './qbittorrent.client.js'; +export { QBITTORRENT_LOG_FILE_CONFIG, parseQBittorrentLogLine } from './qbittorrent.parser.js'; + +// Types +export type { + QBittorrentAuthMode, + QBittorrentLogEntry, + QBittorrentTorrent, + QBittorrentTransferInfo, +} from './qbittorrent.types.js'; + +export { QBITTORRENT_LOG_TYPE } from './qbittorrent.types.js'; diff --git a/packages/provider-qbittorrent/src/qbittorrent.activity.spec.ts b/packages/provider-qbittorrent/src/qbittorrent.activity.spec.ts new file mode 100644 index 0000000..a5023d3 --- /dev/null +++ b/packages/provider-qbittorrent/src/qbittorrent.activity.spec.ts @@ -0,0 +1,498 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { QBittorrentProvider } from './qbittorrent.provider.js'; + +const mockFetch = vi.fn(); +vi.stubGlobal('fetch', mockFetch); + +let counter = 0; +function uniqueUrl(): string { + counter += 1; + return `http://qba-${counter}.local:8080`; +} + +type Route = (url: URL) => Response; + +function routeFetch(routes: Record): void { + mockFetch.mockImplementation((input: unknown) => { + const url = new URL(String(input)); + const route = routes[url.pathname.replace('/api/v2', '')]; + return Promise.resolve(route ? route(url) : new Response('not found', { status: 404 })); + }); +} + +const text = + (body: string): Route => + () => + new Response(body); +const json = + (body: unknown, status = 200): Route => + () => + new Response(JSON.stringify(body), { status }); + +function logRequests(): string[] { + return mockFetch.mock.calls + .map(([url]) => String(url)) + .filter((url) => url.includes('/log/main')); +} + +const lastKnownIds = (): string[] => + logRequests().map((url) => new URL(url).searchParams.get('last_known_id') ?? ''); + +const SERVER = { + '/app/version': text('v5.2.4'), + '/app/webapiVersion': text('2.15.1'), + '/torrents/info': json([]), + '/transfer/info': json({ connection_status: 'connected' }), +}; + +const entry = ( + id: number, + timestamp: number, + type = 4, + message = `m${id}` +): { id: number; message: string; timestamp: number; type: number } => ({ + id, + message, + timestamp, + type, +}); + +describe('QBittorrentProvider activity edge cases', () => { + let provider: QBittorrentProvider; + let baseUrl: string; + + beforeEach(() => { + mockFetch.mockReset(); + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-10-10T12:00:00Z')); + vi.spyOn(console, 'error').mockImplementation(() => undefined); + provider = new QBittorrentProvider(); + baseUrl = uniqueUrl(); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + const connect = (logPath?: string): Promise => + provider.connect({ url: baseUrl, apiKey: '', logPath }); + + describe('log cursor after a qBittorrent restart (ids go backwards)', () => { + it('recovers when the restarted instance reuses lower ids', async () => { + // fake qBittorrent: honours last_known_id against its in-memory buffer + let buffer = [entry(998, 1_790_000_000), entry(999, 1_790_000_001, 8)]; + routeFetch({ + ...SERVER, + '/log/main': (url) => { + const last = Number(url.searchParams.get('last_known_id')); + return json(buffer.filter((e) => e.id > last))(url); + }, + }); + await connect(); + + const first = await provider.getActivity(); + expect(first.map((a) => a.id)).toEqual([ + 'qbittorrent-log-1790000000-998', + 'qbittorrent-log-1790000001-999', + ]); + + // qBittorrent restarts: empty buffer, ids start from 0 again + buffer = []; + vi.advanceTimersByTime(60_000); + expect(await provider.getActivity()).toEqual([]); + expect(lastKnownIds()).toEqual(['-1', '999']); + + // new warnings arrive after the restart but with ids far below the cursor + buffer = [entry(0, 1_790_001_000), entry(1, 1_790_001_001, 8)]; + vi.advanceTimersByTime(60_000); + expect(await provider.getActivity()).toEqual([]); + expect(lastKnownIds()).toEqual(['-1', '999', '999']); + + // 10+ minutes since the last full sync: empty answer triggers the resync + vi.advanceTimersByTime(10 * 60_000); + const afterRestart = await provider.getActivity(); + expect(lastKnownIds()).toEqual(['-1', '999', '999', '999', '-1']); + expect(afterRestart.map((a) => a.id)).toEqual([ + 'qbittorrent-log-1790001000-0', + 'qbittorrent-log-1790001001-1', + ]); + + // cursor now follows the new (lower) id space + await provider.getActivity(); + expect(lastKnownIds().at(-1)).toBe('1'); + }); + + it('does not re-ingest old buffer entries that were already seen before the restart', async () => { + const queue: unknown[][] = [ + [entry(5, 1_790_000_100)], + [], + // resync returns the same entry (no restart happened, id space unchanged) + [entry(5, 1_790_000_100)], + ]; + routeFetch({ ...SERVER, '/log/main': () => json(queue.shift() ?? [])(new URL(baseUrl)) }); + await connect(); + await provider.getActivity(); + + vi.advanceTimersByTime(11 * 60_000); + const again = await provider.getActivity(); + + expect(again).toEqual([]); + expect(lastKnownIds()).toEqual(['-1', '5', '-1']); + }); + + it('never resyncs while the first response was empty (cursor id -1)', async () => { + routeFetch({ ...SERVER, '/log/main': json([]) }); + await connect(); + + await provider.getActivity(); + vi.advanceTimersByTime(60 * 60_000); + await provider.getActivity(); + + expect(lastKnownIds()).toEqual(['-1', '-1']); + }); + }); + + describe('full-resync timer', () => { + const setup = async (): Promise => { + const queue: unknown[][] = [[entry(1, 1_790_000_000)], [], []]; + routeFetch({ ...SERVER, '/log/main': () => json(queue.shift() ?? [])(new URL(baseUrl)) }); + await connect(); + await provider.getActivity(); + }; + + it('does not resync at exactly 10 minutes', async () => { + await setup(); + vi.advanceTimersByTime(10 * 60_000); + await provider.getActivity(); + expect(lastKnownIds()).toEqual(['-1', '1']); + }); + + it('resyncs one millisecond after 10 minutes', async () => { + await setup(); + vi.advanceTimersByTime(10 * 60_000 + 1); + await provider.getActivity(); + expect(lastKnownIds()).toEqual(['-1', '1', '-1']); + }); + + it('is not postponed by regular non-empty polls', async () => { + const queue: unknown[][] = [[entry(1, 1_790_000_000)], [entry(2, 1_790_000_100)], [], []]; + routeFetch({ ...SERVER, '/log/main': () => json(queue.shift() ?? [])(new URL(baseUrl)) }); + await connect(); + await provider.getActivity(); + vi.advanceTimersByTime(9 * 60_000); + await provider.getActivity(); // non-empty poll at t=9min + vi.advanceTimersByTime(2 * 60_000); + await provider.getActivity(); // empty at t=11min since the FIRST full sync + + expect(lastKnownIds()).toEqual(['-1', '1', '2', '-1']); + }); + + it('keeps the previous cursor when the resync request itself fails', async () => { + const queue: Array = [ + json([entry(1, 1_790_000_000)]), + json([]), + () => new Response('boom', { status: 500 }), + json([]), + ]; + routeFetch({ + ...SERVER, + '/log/main': (url) => (queue.shift() ?? json([]))(url), + }); + await connect(); + await provider.getActivity(); + vi.advanceTimersByTime(11 * 60_000); + + await expect(provider.getActivity()).resolves.toEqual([]); + expect(console.error).toHaveBeenCalledWith( + '[qbittorrent] Failed to fetch log messages:', + expect.stringContaining('HTTP 500') + ); + + // next poll tries the plain cursor again and the resync again + await provider.getActivity(); + expect(lastKnownIds().slice(-2)).toEqual(['1', '-1']); + }); + }); + + describe('cursor isolation', () => { + it('keeps independent cursors per base URL', async () => { + const other = uniqueUrl(); + const second = new QBittorrentProvider(); + routeFetch({ + ...SERVER, + '/log/main': (url) => + json(url.origin === new URL(baseUrl).origin ? [entry(10, 1_790_000_000)] : [])(url), + }); + await connect(); + await second.connect({ url: other, apiKey: '' }); + + await provider.getActivity(); + await second.getActivity(); + await provider.getActivity(); + + const byHost = mockFetch.mock.calls + .map(([url]) => String(url)) + .filter((url) => url.includes('/log/main')) + .map((url) => [new URL(url).host, new URL(url).searchParams.get('last_known_id')]); + expect(byHost).toEqual([ + [new URL(baseUrl).host, '-1'], + [new URL(other).host, '-1'], + [new URL(baseUrl).host, '10'], + ]); + }); + + it('survives disconnect and reconnect (cursor is module-level)', async () => { + routeFetch({ ...SERVER, '/log/main': json([entry(7, 1_790_000_000)]) }); + await connect(); + await provider.getActivity(); + await provider.disconnect(); + await connect(); + await provider.getActivity(); + + expect(lastKnownIds()).toEqual(['-1', '7']); + }); + }); + + describe('log filtering', () => { + it('ignores normal and info entries even if the server returns them', async () => { + routeFetch({ + ...SERVER, + '/log/main': json([ + entry(1, 1_790_000_000, 1), + entry(2, 1_790_000_001, 2), + entry(3, 1_790_000_002, 4), + ]), + }); + await connect(); + + const activities = await provider.getActivity(); + + expect(activities.map((a) => a.metadata?.['qbId'])).toEqual([3]); + }); + + it('still advances the cursor past ignored entries', async () => { + routeFetch({ ...SERVER, '/log/main': json([entry(41, 1_790_000_000, 1)]) }); + await connect(); + await provider.getActivity(); + await provider.getActivity(); + + expect(lastKnownIds()).toEqual(['-1', '41']); + }); + + it('truncates long messages in the name but keeps the full overview', async () => { + const message = 'x'.repeat(100_000); + routeFetch({ ...SERVER, '/log/main': json([entry(1, 1_790_000_000, 8, message)]) }); + await connect(); + + const [activity] = await provider.getActivity(); + + expect(activity?.name).toHaveLength(120); + expect(activity?.name.endsWith('…')).toBe(true); + expect(activity?.overview).toBe(message); + }); + + it('applies the since filter to millisecond timestamps (qBittorrent < 4.5)', async () => { + routeFetch({ + ...SERVER, + '/log/main': json([entry(1, 1_790_000_000_000), entry(2, 1_790_000_100_000)]), + }); + await connect(); + + const activities = await provider.getActivity(new Date(1_790_000_050_000)); + + expect(activities.map((a) => a.metadata?.['qbId'])).toEqual([2]); + }); + + it('drops entries at exactly the since timestamp (strictly newer only)', async () => { + routeFetch({ + ...SERVER, + '/log/main': json([entry(1, 1_790_000_000), entry(2, 1_790_000_001)]), + }); + await connect(); + + const activities = await provider.getActivity(new Date(1_790_000_000_000)); + + expect(activities.map((a) => a.metadata?.['qbId'])).toEqual([2]); + }); + + it('keeps the newest 500 warnings ordered oldest to newest when the buffer is unsorted', async () => { + const entries = Array.from({ length: 700 }, (_, i) => entry(i, 1_790_000_000 + i)); + routeFetch({ ...SERVER, '/log/main': json([...entries].reverse()) }); + await connect(); + + const activities = await provider.getActivity(); + + expect(activities).toHaveLength(500); + expect(activities[0]?.metadata?.['qbId']).toBe(200); + expect(activities.at(-1)?.metadata?.['qbId']).toBe(699); + }); + + it('does not cap subsequent polls', async () => { + const first = [entry(0, 1_790_000_000)]; + const next = Array.from({ length: 800 }, (_, i) => entry(i + 1, 1_790_000_001 + i)); + const queue: unknown[][] = [first, next]; + routeFetch({ ...SERVER, '/log/main': () => json(queue.shift() ?? [])(new URL(baseUrl)) }); + await connect(); + await provider.getActivity(); + + expect(await provider.getActivity()).toHaveLength(800); + }); + + it('produces unique ids for same-second entries', async () => { + routeFetch({ + ...SERVER, + '/log/main': json([entry(1, 1_790_000_000), entry(2, 1_790_000_000)]), + }); + await connect(); + + const ids = (await provider.getActivity()).map((a) => a.id); + + expect(new Set(ids).size).toBe(2); + }); + }); + + describe('malformed payloads', () => { + it.each([ + ['an object instead of an array', { error: 'nope' }], + ['null', null], + ['a string', 'Ok.'], + ])('survives %s from /log/main and keeps the other sources', async (_label, payload) => { + routeFetch({ + ...SERVER, + '/log/main': json(payload), + '/torrents/info': json([{ hash: 'h', name: 'N', state: 'error' }]), + }); + await connect(); + + const activities = await provider.getActivity(); + + expect(activities.map((a) => a.type)).toEqual(['TorrentError']); + expect(console.error).toHaveBeenCalledWith( + '[qbittorrent] Failed to fetch log messages:', + expect.any(String) + ); + }); + + it('survives a non-JSON /transfer/info body', async () => { + routeFetch({ + ...SERVER, + '/log/main': json([entry(1, 1_790_000_000)]), + '/transfer/info': text('proxy error'), + }); + await connect(''); + + const activities = await provider.getActivity(); + + expect(activities.map((a) => a.type)).toEqual(['Log']); + expect(console.error).toHaveBeenCalledWith( + '[qbittorrent] Failed to fetch transfer info:', + 'Unexpected response from qBittorrent' + ); + }); + + it.each([{}, { connection_status: 'weird' }, { connection_status: null }])( + 'ignores unknown or missing connection_status %j', + async (info) => { + routeFetch({ ...SERVER, '/log/main': json([]), '/transfer/info': json(info) }); + await connect(); + + expect(await provider.getActivity()).toEqual([]); + } + ); + + it('tolerates a torrent without optional fields', async () => { + routeFetch({ + ...SERVER, + '/log/main': json([]), + '/torrents/info': json([{ hash: 'h', name: 'N', state: 'error', category: '' }]), + }); + await connect(); + + const [activity] = await provider.getActivity(); + + expect(activity?.overview).toBe('N is in state "error"'); + expect(activity?.metadata?.['savePath']).toBeUndefined(); + }); + }); + + describe('errored torrent id stability', () => { + const torrents = (state: string): Route => + json([{ hash: 'abc', name: 'Show.S01', state, category: 'tv' }]); + + it('keeps the id within a day but re-fires with a new id the next day', async () => { + routeFetch({ ...SERVER, '/log/main': json([]), '/torrents/info': torrents('error') }); + await connect(); + + const [first] = await provider.getActivity(); + vi.advanceTimersByTime(60 * 60_000); + const [sameDay] = await provider.getActivity(); + vi.advanceTimersByTime(24 * 60 * 60_000); + const [nextDay] = await provider.getActivity(); + + expect(sameDay?.id).toBe(first?.id); + expect(nextDay?.id).not.toBe(first?.id); + expect(nextDay?.timestamp.getTime()).toBeGreaterThan(first?.timestamp.getTime() ?? 0); + }); + + it('issues a new id when the same torrent changes error state', async () => { + routeFetch({ ...SERVER, '/log/main': json([]), '/torrents/info': torrents('error') }); + await connect(); + const [error] = await provider.getActivity(); + + routeFetch({ ...SERVER, '/log/main': json([]), '/torrents/info': torrents('missingFiles') }); + const [missing] = await provider.getActivity(); + + expect(error?.id).toBe('qbittorrent-torrent-error-abc-error-2026-10-10'); + expect(missing?.id).toBe('qbittorrent-torrent-error-abc-missingFiles-2026-10-10'); + }); + + it('emits nothing once the torrent is no longer errored', async () => { + routeFetch({ ...SERVER, '/log/main': json([]), '/torrents/info': torrents('error') }); + await connect(); + await provider.getActivity(); + + routeFetch({ ...SERVER, '/log/main': json([]), '/torrents/info': json([]) }); + + expect(await provider.getActivity()).toEqual([]); + }); + }); + + describe('connection status buckets', () => { + const status = (connection_status: string): Route => json({ connection_status, dht_nodes: 0 }); + + it('rolls over exactly at the day boundary', async () => { + vi.setSystemTime(new Date('2026-10-10T23:59:59.999Z')); + routeFetch({ ...SERVER, '/log/main': json([]), '/transfer/info': status('firewalled') }); + await connect(); + const [before] = await provider.getActivity(); + + vi.setSystemTime(new Date('2026-10-11T00:00:00.000Z')); + const [after] = await provider.getActivity(); + + expect(before?.id).toBe('qbittorrent-connection-firewalled-2026-10-10'); + expect(after?.id).toBe('qbittorrent-connection-firewalled-2026-10-11'); + }); + + it('does not collide across days with the same hour', async () => { + routeFetch({ ...SERVER, '/log/main': json([]), '/transfer/info': status('disconnected') }); + await connect(); + const [today] = await provider.getActivity(); + vi.advanceTimersByTime(24 * 60 * 60_000); + const [tomorrow] = await provider.getActivity(); + + expect(tomorrow?.id).not.toBe(today?.id); + }); + + it('uses different ids for firewalled and disconnected in the same hour', async () => { + routeFetch({ ...SERVER, '/log/main': json([]), '/transfer/info': status('firewalled') }); + await connect(); + const [firewalled] = await provider.getActivity(); + routeFetch({ ...SERVER, '/log/main': json([]), '/transfer/info': status('disconnected') }); + const [disconnected] = await provider.getActivity(); + + expect(firewalled?.id).not.toBe(disconnected?.id); + }); + }); +}); diff --git a/packages/provider-qbittorrent/src/qbittorrent.client.spec.ts b/packages/provider-qbittorrent/src/qbittorrent.client.spec.ts new file mode 100644 index 0000000..c3a8928 --- /dev/null +++ b/packages/provider-qbittorrent/src/qbittorrent.client.spec.ts @@ -0,0 +1,564 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { QBittorrentClient } from './qbittorrent.client.js'; + +const mockFetch = vi.fn(); +vi.stubGlobal('fetch', mockFetch); + +let counter = 0; +/** Unique base URL per test so module-level session/cooldown maps never leak between tests */ +function uniqueUrl(): string { + counter += 1; + return `http://qb-${counter}.local:8080`; +} + +function ok(body = 'Ok.', init: { status?: number; cookies?: string[] } = {}): Response { + const headers = new Headers(); + for (const cookie of init.cookies ?? []) { + headers.append('set-cookie', cookie); + } + return new Response(body, { status: init.status ?? 200, headers }); +} + +interface RecordedCall { + url: string; + method: string; + headers: Record; + body: string | undefined; +} + +function calls(): RecordedCall[] { + return mockFetch.mock.calls.map(([url, init]) => ({ + url: String(url), + method: (init as RequestInit | undefined)?.method ?? 'GET', + headers: ((init as RequestInit | undefined)?.headers ?? {}) as Record, + body: (init as RequestInit | undefined)?.body as string | undefined, + })); +} + +describe('QBittorrentClient', () => { + beforeEach(() => { + mockFetch.mockReset(); + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-10-10T12:00:00Z')); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + describe('auth mode detection', () => { + it('uses login mode when a username is given', () => { + expect(new QBittorrentClient(uniqueUrl(), { username: 'admin', secret: 'pw' }).authMode).toBe( + 'login' + ); + }); + + it('uses apiKey mode without username but with a secret', () => { + expect(new QBittorrentClient(uniqueUrl(), { secret: 'key' }).authMode).toBe('apiKey'); + }); + + it('uses none mode without username and secret', () => { + expect(new QBittorrentClient(uniqueUrl(), { secret: '' }).authMode).toBe('none'); + expect(new QBittorrentClient(uniqueUrl(), { username: ' ', secret: '' }).authMode).toBe( + 'none' + ); + }); + }); + + describe('login mode', () => { + it('logs in with form encoded body, Referer and Origin, then sends the session cookie', async () => { + const base = `${uniqueUrl()}/`; + mockFetch + .mockResolvedValueOnce(ok('Ok.', { cookies: ['QBT_SID_8080=abc123; HttpOnly; path=/'] })) + .mockResolvedValueOnce(ok('v5.2.4')); + const client = new QBittorrentClient(base, { username: 'admin', secret: 'p@ss&w=rd' }); + + const version = await client.getVersion(); + + expect(version).toBe('v5.2.4'); + const [login, get] = calls(); + const origin = new URL(base).origin; + expect(login?.url).toBe(`${origin}/api/v2/auth/login`); + expect(login?.method).toBe('POST'); + expect(login?.headers['Content-Type']).toBe('application/x-www-form-urlencoded'); + expect(login?.headers['Referer']).toBe(origin); + expect(login?.headers['Origin']).toBe(origin); + expect(login?.body).toBe('username=admin&password=p%40ss%26w%3Drd'); + expect(get?.url).toBe(`${origin}/api/v2/app/version`); + expect(get?.headers['Cookie']).toBe('QBT_SID_8080=abc123'); + expect(get?.headers['Referer']).toBe(origin); + }); + + it('merges multiple Set-Cookie headers and honours Max-Age=0 deletions', async () => { + const base = uniqueUrl(); + mockFetch + .mockResolvedValueOnce(ok('Ok.', { cookies: ['a=1; path=/', 'b=2; path=/'] })) + .mockResolvedValueOnce(ok('v5.2.4', { cookies: ['a=; Max-Age=0; path=/', 'c=3'] })) + .mockResolvedValueOnce(ok('2.15.1')); + const client = new QBittorrentClient(base, { username: 'admin', secret: 'pw' }); + + await client.getVersion(); + await client.getWebApiVersion(); + + expect(calls()[2]?.headers['Cookie']).toBe('b=2; c=3'); + }); + + it('does not log in again while the session is valid', async () => { + mockFetch + .mockResolvedValueOnce(ok('Ok.', { cookies: ['SID=x'] })) + .mockResolvedValueOnce(ok('v5.2.4')) + .mockResolvedValueOnce(ok('2.15.1')); + const client = new QBittorrentClient(uniqueUrl(), { username: 'admin', secret: 'pw' }); + + await client.getVersion(); + await client.getWebApiVersion(); + + expect(calls().filter((c) => c.url.endsWith('/auth/login'))).toHaveLength(1); + }); + + it('shares the session between client instances with identical credentials only', async () => { + const base = uniqueUrl(); + mockFetch + .mockResolvedValueOnce(ok('Ok.', { cookies: ['SID=first'] })) + .mockResolvedValueOnce(ok('v5.2.4')) + .mockResolvedValueOnce(ok('v5.2.4')) + .mockResolvedValueOnce(ok('Ok.', { cookies: ['SID=other'] })) + .mockResolvedValueOnce(ok('v5.2.4')); + + await new QBittorrentClient(base, { username: 'admin', secret: 'pw' }).getVersion(); + await new QBittorrentClient(base, { username: 'admin', secret: 'pw' }).getVersion(); + // different credentials against the same URL must not reuse the cookie + await new QBittorrentClient(base, { username: 'bob', secret: 'pw2' }).getVersion(); + + const recorded = calls(); + expect(recorded).toHaveLength(5); + expect(recorded[2]?.headers['Cookie']).toBe('SID=first'); + expect(recorded[3]?.url).toContain('/auth/login'); + expect(recorded[4]?.headers['Cookie']).toBe('SID=other'); + }); + + it('does not share sessions between different servers', async () => { + mockFetch + .mockResolvedValueOnce(ok('Ok.', { cookies: ['SID=one'] })) + .mockResolvedValueOnce(ok('v5.2.4')) + .mockResolvedValueOnce(ok('Ok.', { cookies: ['SID=two'] })) + .mockResolvedValueOnce(ok('v5.2.4')); + + await new QBittorrentClient(uniqueUrl(), { username: 'admin', secret: 'pw' }).getVersion(); + await new QBittorrentClient(uniqueUrl(), { username: 'admin', secret: 'pw' }).getVersion(); + + expect(calls()[3]?.headers['Cookie']).toBe('SID=two'); + }); + + it('treats an Ok. login without cookie as auth bypass and does not re-login on every call', async () => { + mockFetch + .mockResolvedValueOnce(ok('Ok.')) + .mockResolvedValueOnce(ok('v5.2.4')) + .mockResolvedValueOnce(ok('2.15.1')); + const client = new QBittorrentClient(uniqueUrl(), { username: 'admin', secret: 'pw' }); + + await client.getVersion(); + await client.getWebApiVersion(); + + expect(calls().filter((c) => c.url.endsWith('/auth/login'))).toHaveLength(1); + expect(calls()[1]?.headers['Cookie']).toBeUndefined(); + }); + + it('fails with a clear message on wrong credentials and stores no cookie', async () => { + mockFetch.mockResolvedValueOnce(ok('Fails.', { cookies: ['SID=bad'] })); + const client = new QBittorrentClient(uniqueUrl(), { username: 'admin', secret: 'wrong' }); + + await expect(client.getVersion()).rejects.toThrow('Invalid qBittorrent username or password'); + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + + it('reports an IP ban when login returns 403', async () => { + mockFetch.mockResolvedValueOnce(ok('Forbidden', { status: 403 })); + const client = new QBittorrentClient(uniqueUrl(), { username: 'admin', secret: 'pw' }); + + await expect(client.getVersion()).rejects.toThrow(/banned/); + }); + + it('reports a reverse proxy / CSRF hint when login returns 401', async () => { + mockFetch.mockResolvedValueOnce(ok('Unauthorized', { status: 401 })); + const client = new QBittorrentClient(uniqueUrl(), { username: 'admin', secret: 'pw' }); + + await expect(client.getVersion()).rejects.toThrow(/Host header validation/); + }); + + it('reports a non-qBittorrent endpoint when login returns 404', async () => { + mockFetch.mockResolvedValueOnce(ok('nope', { status: 404 })); + const base = uniqueUrl(); + const client = new QBittorrentClient(base, { username: 'admin', secret: 'pw' }); + + await expect(client.getVersion()).rejects.toThrow(`Not a qBittorrent WebUI at ${base}`); + }); + + it('does not count a 401 login response towards the cooldown', async () => { + const base = uniqueUrl(); + mockFetch + .mockResolvedValueOnce(ok('Unauthorized', { status: 401 })) + .mockResolvedValueOnce(ok('Ok.', { cookies: ['SID=x'] })) + .mockResolvedValueOnce(ok('v5.2.4')); + const creds = { username: 'admin', secret: 'pw' }; + + await expect(new QBittorrentClient(base, creds).getVersion()).rejects.toThrow(); + await expect(new QBittorrentClient(base, creds).getVersion()).resolves.toBe('v5.2.4'); + }); + }); + + describe('re-login', () => { + it('re-logs in once and retries when a request returns 403', async () => { + mockFetch + .mockResolvedValueOnce(ok('Ok.', { cookies: ['SID=old'] })) + .mockResolvedValueOnce(ok('Forbidden', { status: 403 })) + .mockResolvedValueOnce(ok('Ok.', { cookies: ['SID=new'] })) + .mockResolvedValueOnce(ok('v5.2.4')); + const client = new QBittorrentClient(uniqueUrl(), { username: 'admin', secret: 'pw' }); + + await expect(client.getVersion()).resolves.toBe('v5.2.4'); + + const recorded = calls(); + expect(recorded.map((c) => c.method)).toEqual(['POST', 'GET', 'POST', 'GET']); + expect(recorded[3]?.headers['Cookie']).toBe('SID=new'); + }); + + it('re-logs in on 401 as well', async () => { + mockFetch + .mockResolvedValueOnce(ok('Ok.', { cookies: ['SID=old'] })) + .mockResolvedValueOnce(ok('Unauthorized', { status: 401 })) + .mockResolvedValueOnce(ok('Ok.', { cookies: ['SID=new'] })) + .mockResolvedValueOnce(ok('v5.2.4')); + const client = new QBittorrentClient(uniqueUrl(), { username: 'admin', secret: 'pw' }); + + await expect(client.getVersion()).resolves.toBe('v5.2.4'); + }); + + it('gives up after one re-login when the request is still rejected', async () => { + mockFetch + .mockResolvedValueOnce(ok('Ok.', { cookies: ['SID=a'] })) + .mockResolvedValueOnce(ok('Forbidden', { status: 403 })) + .mockResolvedValueOnce(ok('Ok.', { cookies: ['SID=b'] })) + .mockResolvedValueOnce(ok('Forbidden', { status: 403 })); + const client = new QBittorrentClient(uniqueUrl(), { username: 'admin', secret: 'pw' }); + + await expect(client.getVersion()).rejects.toThrow( + 'qBittorrent authentication failed after re-login' + ); + expect(mockFetch).toHaveBeenCalledTimes(4); + }); + }); + + describe('failure cooldown', () => { + const failLogin = (): void => { + mockFetch.mockResolvedValueOnce(ok('Fails.')); + }; + + it('does not contact the server again during the cooldown, then retries after 5 minutes', async () => { + const base = uniqueUrl(); + const creds = { username: 'admin', secret: 'wrong' }; + failLogin(); + await expect(new QBittorrentClient(base, creds).getVersion()).rejects.toThrow( + 'Invalid qBittorrent username or password' + ); + expect(mockFetch).toHaveBeenCalledTimes(1); + + await expect(new QBittorrentClient(base, creds).getVersion()).rejects.toThrow( + /Invalid qBittorrent username or password.*retrying in \d+s/ + ); + expect(mockFetch).toHaveBeenCalledTimes(1); + + vi.advanceTimersByTime(5 * 60 * 1000 - 1000); + await expect(new QBittorrentClient(base, creds).getVersion()).rejects.toThrow(/retrying in/); + expect(mockFetch).toHaveBeenCalledTimes(1); + + vi.advanceTimersByTime(1000); + failLogin(); + await expect(new QBittorrentClient(base, creds).getVersion()).rejects.toThrow( + 'Invalid qBittorrent username or password' + ); + expect(mockFetch).toHaveBeenCalledTimes(2); + }); + + it('escalates to 15 minutes and then 60 minutes', async () => { + const base = uniqueUrl(); + const creds = { username: 'admin', secret: 'wrong' }; + failLogin(); + await expect(new QBittorrentClient(base, creds).getVersion()).rejects.toThrow(); + + vi.advanceTimersByTime(5 * 60 * 1000); + failLogin(); + await expect(new QBittorrentClient(base, creds).getVersion()).rejects.toThrow(); + expect(mockFetch).toHaveBeenCalledTimes(2); + + // second failure: 15 minute cooldown + vi.advanceTimersByTime(14 * 60 * 1000); + await expect(new QBittorrentClient(base, creds).getVersion()).rejects.toThrow(/retrying in/); + expect(mockFetch).toHaveBeenCalledTimes(2); + vi.advanceTimersByTime(60 * 1000); + failLogin(); + await expect(new QBittorrentClient(base, creds).getVersion()).rejects.toThrow(); + expect(mockFetch).toHaveBeenCalledTimes(3); + + // third failure: 60 minute cooldown + vi.advanceTimersByTime(59 * 60 * 1000); + await expect(new QBittorrentClient(base, creds).getVersion()).rejects.toThrow(/retrying in/); + expect(mockFetch).toHaveBeenCalledTimes(3); + vi.advanceTimersByTime(60 * 1000); + failLogin(); + await expect(new QBittorrentClient(base, creds).getVersion()).rejects.toThrow(); + expect(mockFetch).toHaveBeenCalledTimes(4); + + // fourth failure stays at 60 minutes + vi.advanceTimersByTime(59 * 60 * 1000); + await expect(new QBittorrentClient(base, creds).getVersion()).rejects.toThrow(/retrying in/); + expect(mockFetch).toHaveBeenCalledTimes(4); + }); + + it('applies the cooldown after an IP ban response too', async () => { + const base = uniqueUrl(); + const creds = { username: 'admin', secret: 'pw' }; + mockFetch.mockResolvedValueOnce(ok('Forbidden', { status: 403 })); + await expect(new QBittorrentClient(base, creds).getVersion()).rejects.toThrow(/banned/); + await expect(new QBittorrentClient(base, creds).getVersion()).rejects.toThrow(/banned/); + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + + it('is bypassed immediately when the credentials change', async () => { + const base = uniqueUrl(); + failLogin(); + await expect( + new QBittorrentClient(base, { username: 'admin', secret: 'wrong' }).getVersion() + ).rejects.toThrow(); + + mockFetch + .mockResolvedValueOnce(ok('Ok.', { cookies: ['SID=x'] })) + .mockResolvedValueOnce(ok('v5.2.4')); + await expect( + new QBittorrentClient(base, { username: 'admin', secret: 'right' }).getVersion() + ).resolves.toBe('v5.2.4'); + }); + + it('is cleared after a successful login', async () => { + const base = uniqueUrl(); + const creds = { username: 'admin', secret: 'pw' }; + failLogin(); + await expect(new QBittorrentClient(base, creds).getVersion()).rejects.toThrow(); + + vi.advanceTimersByTime(5 * 60 * 1000); + mockFetch + .mockResolvedValueOnce(ok('Ok.', { cookies: ['SID=x'] })) + .mockResolvedValueOnce(ok('v5.2.4')); + await expect(new QBittorrentClient(base, creds).getVersion()).resolves.toBe('v5.2.4'); + + // a new failure sequence starts again at the first (5 minute) step, not at 15 minutes + mockFetch + .mockResolvedValueOnce(ok('Forbidden', { status: 403 })) + .mockResolvedValueOnce(ok('Fails.')); + await expect(new QBittorrentClient(base, creds).getWebApiVersion()).rejects.toThrow(); + mockFetch.mockClear(); + vi.advanceTimersByTime(5 * 60 * 1000); + mockFetch.mockResolvedValueOnce(ok('Fails.')); + await expect(new QBittorrentClient(base, creds).getVersion()).rejects.toThrow(); + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + }); + + describe('auth bypass (none mode)', () => { + it('never logs in and sends no credentials', async () => { + mockFetch.mockResolvedValueOnce(ok('v5.2.4')); + const client = new QBittorrentClient(uniqueUrl(), { secret: '' }); + + await client.getVersion(); + + const [call] = calls(); + expect(call?.url).toContain('/api/v2/app/version'); + expect(call?.headers['Cookie']).toBeUndefined(); + expect(call?.headers['Authorization']).toBeUndefined(); + }); + + it('explains that authentication is required on 403', async () => { + mockFetch.mockResolvedValueOnce(ok('Forbidden', { status: 403 })); + const client = new QBittorrentClient(uniqueUrl(), { secret: '' }); + + await expect(client.getVersion()).rejects.toThrow( + 'qBittorrent requires authentication; configure username/password (or an API key for >= 5.3)' + ); + }); + }); + + describe('API key mode', () => { + it('sends a bearer token on every request and never logs in', async () => { + mockFetch.mockResolvedValueOnce(ok('v5.3.0')).mockResolvedValueOnce(ok('2.16.2')); + const client = new QBittorrentClient(uniqueUrl(), { secret: 'qbt_key' }); + + await client.getVersion(); + await client.getWebApiVersion(); + + for (const call of calls()) { + expect(call.headers['Authorization']).toBe('Bearer qbt_key'); + expect(call.url).not.toContain('/auth/login'); + } + }); + + it('reports a rejected key on 401 without echoing the key', async () => { + mockFetch.mockResolvedValueOnce(ok('Unauthorized', { status: 401 })); + const client = new QBittorrentClient(uniqueUrl(), { secret: 'qbt_supersecret' }); + + const error = await client.getVersion().catch((e: unknown) => e as Error); + expect((error as Error).message).toBe( + 'API key rejected (API keys require qBittorrent >= 5.3)' + ); + expect((error as Error).message).not.toContain('qbt_supersecret'); + }); + }); + + describe('network errors', () => { + it('maps AbortError to a timeout message', async () => { + mockFetch.mockRejectedValueOnce(Object.assign(new Error('aborted'), { name: 'AbortError' })); + const client = new QBittorrentClient(uniqueUrl(), { secret: '' }); + + await expect(client.getVersion()).rejects.toThrow('qBittorrent request timed out after 10s'); + }); + + it('aborts requests that never complete after 10 seconds', async () => { + mockFetch.mockImplementationOnce( + (_url: unknown, init: RequestInit) => + new Promise((_resolve, reject) => { + init.signal?.addEventListener('abort', () => + reject(Object.assign(new Error('aborted'), { name: 'AbortError' })) + ); + }) + ); + const client = new QBittorrentClient(uniqueUrl(), { secret: '' }); + + const pending = client.getVersion().catch((e: unknown) => e as Error); + await vi.advanceTimersByTimeAsync(10_000); + + expect(((await pending) as Error).message).toBe('qBittorrent request timed out after 10s'); + }); + + it('maps connection refused to a reachability message', async () => { + const base = uniqueUrl(); + mockFetch.mockRejectedValueOnce( + Object.assign(new TypeError('fetch failed'), { cause: { code: 'ECONNREFUSED' } }) + ); + const client = new QBittorrentClient(base, { secret: '' }); + + await expect(client.getVersion()).rejects.toThrow(`Cannot reach qBittorrent at ${base}`); + }); + + it('maps unknown errors to a generic message', async () => { + mockFetch.mockRejectedValueOnce(new Error('boom')); + const client = new QBittorrentClient(uniqueUrl(), { secret: '' }); + + await expect(client.getVersion()).rejects.toThrow('qBittorrent request failed: boom'); + }); + }); + + describe('API calls', () => { + it('requests the log with a cursor and severity filters', async () => { + mockFetch.mockResolvedValueOnce(ok('[]')); + const client = new QBittorrentClient(uniqueUrl(), { secret: '' }); + + await client.getLog(-1); + + const url = calls()[0]?.url ?? ''; + expect(url).toContain('/api/v2/log/main?'); + expect(url).toContain('last_known_id=-1'); + expect(url).toContain('warning=true'); + expect(url).toContain('critical=true'); + expect(url).toContain('normal=false'); + expect(url).toContain('info=false'); + }); + + it('parses errored torrents and transfer info', async () => { + mockFetch + .mockResolvedValueOnce(ok('[{"hash":"h","name":"n","state":"error"}]')) + .mockResolvedValueOnce(ok('{"connection_status":"firewalled","dht_nodes":3}')); + const client = new QBittorrentClient(uniqueUrl(), { secret: '' }); + + await expect(client.getErroredTorrents()).resolves.toEqual([ + { hash: 'h', name: 'n', state: 'error' }, + ]); + await expect(client.getTransferInfo()).resolves.toEqual({ + connection_status: 'firewalled', + dht_nodes: 3, + }); + expect(calls()[0]?.url).toContain('/api/v2/torrents/info?filter=errored'); + expect(calls()[1]?.url).toContain('/api/v2/transfer/info'); + }); + + it('wraps JSON parse errors', async () => { + mockFetch.mockResolvedValueOnce(ok('')); + const client = new QBittorrentClient(uniqueUrl(), { secret: '' }); + + await expect(client.getTransferInfo()).rejects.toThrow( + 'Unexpected response from qBittorrent' + ); + }); + + it('reports a non-qBittorrent endpoint on 404', async () => { + const base = uniqueUrl(); + mockFetch.mockResolvedValueOnce(ok('nope', { status: 404 })); + const client = new QBittorrentClient(base, { secret: '' }); + + await expect(client.getVersion()).rejects.toThrow(`Not a qBittorrent WebUI at ${base}`); + }); + + it('reports other HTTP errors with status and path', async () => { + mockFetch.mockResolvedValueOnce(ok('err', { status: 500 })); + const client = new QBittorrentClient(uniqueUrl(), { secret: '' }); + + await expect(client.getVersion()).rejects.toThrow( + 'qBittorrent API error: HTTP 500 /app/version' + ); + }); + }); + + describe('secret hygiene', () => { + it('never leaks passwords or cookies in error messages', async () => { + const password = 'hunter2-very-secret'; + const messages: string[] = []; + + mockFetch.mockResolvedValueOnce(ok('Fails.', { cookies: ['SID=cookievalue123'] })); + messages.push( + ( + (await new QBittorrentClient(uniqueUrl(), { username: 'admin', secret: password }) + .getVersion() + .catch((e: unknown) => e)) as Error + ).message + ); + + mockFetch + .mockResolvedValueOnce(ok('Ok.', { cookies: ['SID=cookievalue123'] })) + .mockResolvedValueOnce(ok('Forbidden', { status: 403 })) + .mockResolvedValueOnce(ok('Ok.', { cookies: ['SID=cookievalue456'] })) + .mockResolvedValueOnce(ok('Forbidden', { status: 403 })); + messages.push( + ( + (await new QBittorrentClient(uniqueUrl(), { username: 'admin', secret: password }) + .getVersion() + .catch((e: unknown) => e)) as Error + ).message + ); + + mockFetch.mockRejectedValueOnce( + Object.assign(new TypeError('fetch failed'), { cause: { code: 'ECONNREFUSED' } }) + ); + messages.push( + ( + (await new QBittorrentClient(uniqueUrl(), { username: 'admin', secret: password }) + .getVersion() + .catch((e: unknown) => e)) as Error + ).message + ); + + for (const message of messages) { + expect(message).not.toContain(password); + expect(message).not.toContain('cookievalue'); + } + }); + }); +}); diff --git a/packages/provider-qbittorrent/src/qbittorrent.client.ts b/packages/provider-qbittorrent/src/qbittorrent.client.ts new file mode 100644 index 0000000..0f1c2be --- /dev/null +++ b/packages/provider-qbittorrent/src/qbittorrent.client.ts @@ -0,0 +1,351 @@ +import { createHash } from 'node:crypto'; + +import type { + QBittorrentAuthMode, + QBittorrentLogEntry, + QBittorrentTorrent, + QBittorrentTransferInfo, +} from './qbittorrent.types.js'; + +const REQUEST_TIMEOUT_MS = 10_000; +const API_PREFIX = '/api/v2'; +/** Expired cooldown entries are dropped after this long (they only carry the failure count) */ +const COOLDOWN_RETENTION_MS = 24 * 60 * 60_000; + +/** Escalating cooldown after authentication failures (qBittorrent bans an IP for 1h after 5) */ +const COOLDOWN_STEPS_MS = [5 * 60_000, 15 * 60_000, 60 * 60_000] as const; + +type CookieJar = Map; + +interface Cooldown { + until: number; + message: string; + failures: number; +} + +interface RawResponse { + status: number; + text: string; +} + +interface QBittorrentCredentials { + readonly username?: string | undefined; + /** Password (with username) or API key (without username). May be empty. */ + readonly secret: string; +} + +/** + * Module-level state. Providers are singletons that are connected/disconnected on every poll, + * so sessions and cooldowns must outlive client instances. Keys are a SHA-256 of base URL, + * username and secret, so different servers or credentials never share cookies and no plaintext + * secret is used as a map key. Kept in memory only and never logged. + */ +const sessionCache = new Map(); +const cooldowns = new Map(); + +function hashKey(baseUrl: string, username: string, secret: string): string { + return createHash('sha256').update(`${baseUrl}\u0000${username}\u0000${secret}`).digest('hex'); +} + +/** True when a Set-Cookie attribute list marks the cookie as expired/deleted */ +function isExpired(attributes: string[]): boolean { + for (const attribute of attributes) { + const separator = attribute.indexOf('='); + if (separator < 0) { + continue; + } + const name = attribute.slice(0, separator).trim().toLowerCase(); + const value = attribute.slice(separator + 1).trim(); + if (name === 'max-age') { + const seconds = Number(value); + if (Number.isFinite(seconds) && seconds <= 0) { + return true; + } + } else if (name === 'expires') { + const time = Date.parse(value); + if (!Number.isNaN(time) && time <= Date.now()) { + return true; + } + } + } + return false; +} + +function splitSetCookie(header: string | null): string[] { + if (header === null || header === '') { + return []; + } + return header.split(/,(?=\s*[^;,=\s]+=)/); +} + +/** + * Minimal qBittorrent WebUI API v2 client (cookie session, API key or no authentication). + * Uses fetch directly because core httpRequest cannot send forms or manage cookies. + */ +export class QBittorrentClient { + readonly baseUrl: string; + readonly authMode: QBittorrentAuthMode; + + private readonly origin: string; + private readonly username: string; + private readonly secret: string; + private readonly key: string; + private jar: CookieJar; + private sessionReady: boolean; + + constructor(baseUrl: string, credentials: QBittorrentCredentials) { + this.baseUrl = baseUrl.trim().replace(/\/+$/, ''); + this.origin = new URL(this.baseUrl).origin; + this.username = credentials.username?.trim() ?? ''; + this.secret = credentials.secret; + + if (this.username !== '') { + this.authMode = 'login'; + } else if (this.secret !== '') { + this.authMode = 'apiKey'; + } else { + this.authMode = 'none'; + } + + this.key = hashKey(this.baseUrl, this.username, this.secret); + const cached = sessionCache.get(this.key); + this.sessionReady = cached !== undefined; + this.jar = new Map(cached ?? []); + } + + async getVersion(): Promise { + return this.getText('/app/version'); + } + + async getWebApiVersion(): Promise { + return this.getText('/app/webapiVersion'); + } + + async getLog(lastKnownId: number): Promise { + return this.getJson('/log/main', { + normal: 'false', + info: 'false', + warning: 'true', + critical: 'true', + last_known_id: String(lastKnownId), + }); + } + + async getErroredTorrents(): Promise { + return this.getJson('/torrents/info', { filter: 'errored' }); + } + + async getTransferInfo(): Promise { + return this.getJson('/transfer/info'); + } + + // --------------------------------------------------------------------------- + // Internals + // --------------------------------------------------------------------------- + + private async getJson(path: string, query?: Record): Promise { + const text = await this.getText(path, query); + try { + return JSON.parse(text) as T; + } catch { + throw new Error('Unexpected response from qBittorrent'); + } + } + + private async getText(path: string, query?: Record): Promise { + if (this.authMode === 'login' && !this.sessionReady) { + await this.login(); + } + + let response = await this.send('GET', path, { query }); + + if (response.status === 401 || response.status === 403) { + if (this.authMode === 'login') { + this.jar = new Map(); + this.sessionReady = false; + sessionCache.delete(this.key); + await this.login(); + response = await this.send('GET', path, { query }); + if (response.status === 401 || response.status === 403) { + throw new Error('qBittorrent authentication failed after re-login'); + } + } else if (this.authMode === 'apiKey') { + throw new Error('API key rejected (API keys require qBittorrent >= 5.3)'); + } else { + throw new Error( + 'qBittorrent requires authentication; configure username/password (or an API key for >= 5.3)' + ); + } + } + + if (response.status === 404) { + throw this.notQBittorrentError(); + } + if (response.status < 200 || response.status >= 300) { + throw new Error(`qBittorrent API error: HTTP ${response.status} ${path}`); + } + + return response.text; + } + + private notQBittorrentError(): Error { + return new Error(`Not a qBittorrent WebUI at ${this.baseUrl} (/api/v2 not found)`); + } + + private async login(): Promise { + const cooldown = cooldowns.get(this.key); + if (cooldown !== undefined && cooldown.until > Date.now()) { + const seconds = Math.ceil((cooldown.until - Date.now()) / 1000); + throw new Error(`${cooldown.message} (retrying in ${seconds}s)`); + } + + const response = await this.send('POST', '/auth/login', { + form: { username: this.username, password: this.secret }, + }); + + if (response.status === 200 && response.text.trim() === 'Ok.') { + cooldowns.delete(this.key); + this.sessionReady = true; + sessionCache.set(this.key, new Map(this.jar)); + return; + } + + if (response.status === 200 && response.text.trim() === 'Fails.') { + throw this.recordFailure('Invalid qBittorrent username or password'); + } + if (response.status === 200) { + // Not counted as a credential failure: probably not qBittorrent (e.g. an auth proxy page) + throw new Error( + 'Unexpected login response — is this a qBittorrent WebUI (or behind an auth proxy)?' + ); + } + if (response.status === 403) { + throw this.recordFailure( + 'qBittorrent refused the login (HTTP 403): this IP is banned after too many failed attempts. Wait (default 1 hour) or check the credentials' + ); + } + if (response.status === 401) { + throw new Error( + 'qBittorrent rejected the request (HTTP 401): check that the URL is correct and, behind a reverse proxy, that Host header validation / CSRF settings allow Logarr' + ); + } + if (response.status === 404) { + throw this.notQBittorrentError(); + } + throw new Error(`qBittorrent login failed: HTTP ${response.status}`); + } + + private recordFailure(message: string): Error { + this.jar = new Map(); + const previous = cooldowns.get(this.key); + const failures = (previous?.failures ?? 0) + 1; + const step = COOLDOWN_STEPS_MS[Math.min(failures, COOLDOWN_STEPS_MS.length) - 1] as number; + const now = Date.now(); + for (const [key, entry] of cooldowns) { + if (entry.until + COOLDOWN_RETENTION_MS < now) { + cooldowns.delete(key); + } + } + cooldowns.set(this.key, { until: now + step, message, failures }); + return new Error(message); + } + + private async send( + method: 'GET' | 'POST', + path: string, + options: { query?: Record | undefined; form?: Record } + ): Promise { + const query = options.query ? `?${new URLSearchParams(options.query).toString()}` : ''; + const url = `${this.baseUrl}${API_PREFIX}${path}${query}`; + + const headers: Record = { + Accept: '*/*', + Referer: this.origin, + Origin: this.origin, + }; + if (options.form) { + headers['Content-Type'] = 'application/x-www-form-urlencoded'; + } + if (this.authMode === 'apiKey') { + headers['Authorization'] = `Bearer ${this.secret}`; + } + if (this.authMode === 'login' && this.jar.size > 0) { + headers['Cookie'] = [...this.jar].map(([name, value]) => `${name}=${value}`).join('; '); + } + + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); + + try { + const response = await fetch(url, { + method, + headers, + signal: controller.signal, + redirect: 'manual', + ...(options.form && { body: new URLSearchParams(options.form).toString() }), + }); + if (response.status >= 300 && response.status < 400) { + throw new Error(this.redirectMessage(response, url)); + } + this.storeCookies(response.headers); + return { status: response.status, text: await response.text() }; + } catch (error) { + throw this.mapNetworkError(error); + } finally { + clearTimeout(timer); + } + } + + private redirectMessage(response: Response, requestUrl: string): string { + let target = 'another location'; + try { + const location = response.headers.get('location'); + if (location !== null) { + target = new URL(location, requestUrl).origin; + } + } catch { + // keep the generic wording + } + return `URL redirects to ${target} — configure the final URL`; + } + + private mapNetworkError(error: unknown): Error { + if (error instanceof Error && error.message.startsWith('URL redirects to')) { + return error; + } + const err = error as { name?: string; message?: string; cause?: { code?: string } }; + if (err.name === 'AbortError' || err.name === 'TimeoutError') { + return new Error(`qBittorrent request timed out after ${REQUEST_TIMEOUT_MS / 1000}s`); + } + const code = err.cause?.code; + if (code === 'ECONNREFUSED' || code === 'ENOTFOUND' || code === 'EHOSTUNREACH') { + return new Error(`Cannot reach qBittorrent at ${this.baseUrl}`); + } + return new Error(`qBittorrent request failed: ${err.message ?? 'unknown error'}`); + } + + private storeCookies(headers: Headers): void { + if (this.authMode !== 'login') { + return; + } + const raw = + typeof headers.getSetCookie === 'function' + ? headers.getSetCookie() + : splitSetCookie(headers.get('set-cookie')); + + for (const cookie of raw) { + const [pair = '', ...attributes] = cookie.split(';'); + const separator = pair.indexOf('='); + if (separator <= 0) { + continue; + } + const name = pair.slice(0, separator).trim(); + const value = pair.slice(separator + 1).trim(); + if (value === '' || isExpired(attributes)) { + this.jar.delete(name); + } else { + this.jar.set(name, value); + } + } + } +} diff --git a/packages/provider-qbittorrent/src/qbittorrent.cookies.spec.ts b/packages/provider-qbittorrent/src/qbittorrent.cookies.spec.ts new file mode 100644 index 0000000..73fa19f --- /dev/null +++ b/packages/provider-qbittorrent/src/qbittorrent.cookies.spec.ts @@ -0,0 +1,205 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { QBittorrentClient } from './qbittorrent.client.js'; + +const mockFetch = vi.fn(); +vi.stubGlobal('fetch', mockFetch); + +let counter = 0; +function uniqueUrl(): string { + counter += 1; + return `http://qb-cookie-${counter}.local:8080`; +} + +/** + * Response stand-in for runtimes without Headers.getSetCookie(): all Set-Cookie values are + * folded into one comma-joined header, exactly like Headers.get('set-cookie') does. + */ +function legacyResponse(body: string, setCookie: string | null, status = 200): Response { + const headers = { + get: (name: string) => (name.toLowerCase() === 'set-cookie' ? setCookie : null), + }; + return { + status, + headers, + text: () => Promise.resolve(body), + } as unknown as Response; +} + +function modernResponse(body: string, cookies: string[], status = 200): Response { + const headers = new Headers(); + for (const cookie of cookies) { + headers.append('set-cookie', cookie); + } + return new Response(body, { status, headers }); +} + +function cookieHeaderOfCall(index: number): string | undefined { + const init = mockFetch.mock.calls[index]?.[1] as RequestInit | undefined; + return (init?.headers as Record | undefined)?.['Cookie']; +} + +async function loginAndGetCookie( + loginResponse: Response, + baseUrl = uniqueUrl() +): Promise { + mockFetch.mockResolvedValueOnce(loginResponse).mockResolvedValueOnce(new Response('v5.2.4')); + const client = new QBittorrentClient(baseUrl, { username: 'admin', secret: 'pw' }); + await client.getVersion(); + return cookieHeaderOfCall(1); +} + +describe('QBittorrentClient cookie handling', () => { + beforeEach(() => { + mockFetch.mockReset(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + describe('Headers.getSetCookie fallback (splitSetCookie)', () => { + it('parses a single comma-free cookie', async () => { + const cookie = await loginAndGetCookie( + legacyResponse('Ok.', 'QBT_SID_8080=abc123; HttpOnly; SameSite=Strict; path=/') + ); + expect(cookie).toBe('QBT_SID_8080=abc123'); + }); + + it('splits several cookies folded into one comma-joined header', async () => { + const cookie = await loginAndGetCookie( + legacyResponse('Ok.', 'a=1; path=/, b=2; HttpOnly; path=/, c=3') + ); + expect(cookie).toBe('a=1; b=2; c=3'); + }); + + it('does not split on the comma inside an Expires date', async () => { + const cookie = await loginAndGetCookie( + legacyResponse( + 'Ok.', + 'a=1; Expires=Wed, 21 Oct 2099 07:28:00 GMT; path=/, b=2; Expires=Thu, 22 Oct 2099 07:28:00 GMT' + ) + ); + expect(cookie).toBe('a=1; b=2'); + }); + + it('does not split on the comma inside a dashed (Netscape) Expires date', async () => { + const cookie = await loginAndGetCookie( + legacyResponse('Ok.', 'a=1; Expires=Wed, 21-Oct-2099 07:28:00 GMT, b=2') + ); + expect(cookie).toBe('a=1; b=2'); + }); + + it('treats a missing Set-Cookie header (null) as no cookies', async () => { + mockFetch + .mockResolvedValueOnce(legacyResponse('Ok.', null)) + .mockResolvedValueOnce(legacyResponse('v5.2.4', null)) + .mockResolvedValueOnce(legacyResponse('2.15.1', null)); + const client = new QBittorrentClient(uniqueUrl(), { username: 'admin', secret: 'pw' }); + + await expect(client.getVersion()).resolves.toBe('v5.2.4'); + // bypass-style login: no cookie, no re-login on the next call + await expect(client.getWebApiVersion()).resolves.toBe('2.15.1'); + expect(mockFetch).toHaveBeenCalledTimes(3); + expect(cookieHeaderOfCall(1)).toBeUndefined(); + }); + + it('treats an empty Set-Cookie header as no cookies', async () => { + const cookie = await loginAndGetCookie(legacyResponse('Ok.', '')); + expect(cookie).toBeUndefined(); + }); + + it('honours Max-Age=0 deletions delivered through the fallback', async () => { + mockFetch + .mockResolvedValueOnce(legacyResponse('Ok.', 'a=1; path=/, b=2; path=/')) + .mockResolvedValueOnce(legacyResponse('v5.2.4', 'a=; Max-Age=0; path=/')) + .mockResolvedValueOnce(legacyResponse('2.15.1', null)); + const client = new QBittorrentClient(uniqueUrl(), { username: 'admin', secret: 'pw' }); + + await client.getVersion(); + await client.getWebApiVersion(); + + expect(cookieHeaderOfCall(2)).toBe('b=2'); + }); + + it('produces the same jar as the getSetCookie() path', async () => { + const raw = ['x=1; path=/', 'y=2; HttpOnly', 'z=3; Max-Age=3600']; + const modern = await loginAndGetCookie(modernResponse('Ok.', raw)); + mockFetch.mockReset(); + const legacy = await loginAndGetCookie(legacyResponse('Ok.', raw.join(', '))); + expect(legacy).toBe(modern); + }); + + it('does not parse cookies at all outside login mode', async () => { + mockFetch.mockResolvedValueOnce(legacyResponse('v5.2.4', 'SID=leak')); + const client = new QBittorrentClient(uniqueUrl(), { secret: 'key' }); + await client.getVersion(); + mockFetch.mockResolvedValueOnce(legacyResponse('2.15.1', null)); + await client.getWebApiVersion(); + expect(cookieHeaderOfCall(1)).toBeUndefined(); + }); + }); + + describe('cookie parsing edge cases', () => { + it('keeps "=" characters inside the cookie value (base64 padding)', async () => { + const cookie = await loginAndGetCookie(modernResponse('Ok.', ['SID=YWJj==; path=/'])); + expect(cookie).toBe('SID=YWJj=='); + }); + + it('ignores cookies without a name or without "="', async () => { + const cookie = await loginAndGetCookie( + modernResponse('Ok.', ['=novalue', 'garbage', 'ok=1; path=/']) + ); + expect(cookie).toBe('ok=1'); + }); + + it('trims whitespace around names and values', async () => { + const cookie = await loginAndGetCookie(modernResponse('Ok.', [' a = 1 ; path=/'])); + expect(cookie).toBe('a=1'); + }); + + it('deletes a cookie that is set with an empty value', async () => { + mockFetch + .mockResolvedValueOnce(modernResponse('Ok.', ['a=1', 'b=2'])) + .mockResolvedValueOnce(modernResponse('v5.2.4', ['a='])) + .mockResolvedValueOnce(new Response('2.15.1')); + const client = new QBittorrentClient(uniqueUrl(), { username: 'admin', secret: 'pw' }); + await client.getVersion(); + await client.getWebApiVersion(); + expect(cookieHeaderOfCall(2)).toBe('b=2'); + }); + + it('treats Max-Age=0 case-insensitively and with padding zeros', async () => { + mockFetch + .mockResolvedValueOnce(modernResponse('Ok.', ['a=1', 'b=2', 'c=3'])) + .mockResolvedValueOnce(modernResponse('v5.2.4', ['a=1; MAX-AGE=0', 'b=2; max-age = 00'])) + .mockResolvedValueOnce(new Response('2.15.1')); + const client = new QBittorrentClient(uniqueUrl(), { username: 'admin', secret: 'pw' }); + await client.getVersion(); + await client.getWebApiVersion(); + expect(cookieHeaderOfCall(2)).toBe('c=3'); + }); + + it('overwrites an existing cookie of the same name', async () => { + mockFetch + .mockResolvedValueOnce(modernResponse('Ok.', ['SID=old'])) + .mockResolvedValueOnce(modernResponse('v5.2.4', ['SID=new'])) + .mockResolvedValueOnce(new Response('2.15.1')); + const client = new QBittorrentClient(uniqueUrl(), { username: 'admin', secret: 'pw' }); + await client.getVersion(); + await client.getWebApiVersion(); + expect(cookieHeaderOfCall(2)).toBe('SID=new'); + }); + + it('deletes a cookie with a negative Max-Age', async () => { + mockFetch + .mockResolvedValueOnce(modernResponse('Ok.', ['a=1', 'b=2'])) + .mockResolvedValueOnce(modernResponse('v5.2.4', ['a=1; Max-Age=-1'])) + .mockResolvedValueOnce(new Response('2.15.1')); + const client = new QBittorrentClient(uniqueUrl(), { username: 'admin', secret: 'pw' }); + await client.getVersion(); + await client.getWebApiVersion(); + expect(cookieHeaderOfCall(2)).toBe('b=2'); + }); + }); +}); diff --git a/packages/provider-qbittorrent/src/qbittorrent.hardening.spec.ts b/packages/provider-qbittorrent/src/qbittorrent.hardening.spec.ts new file mode 100644 index 0000000..e68b4f2 --- /dev/null +++ b/packages/provider-qbittorrent/src/qbittorrent.hardening.spec.ts @@ -0,0 +1,275 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { QBittorrentClient } from './qbittorrent.client.js'; +import { QBittorrentProvider } from './qbittorrent.provider.js'; +import { redactSecrets, trackerHost } from './qbittorrent.redact.js'; + +const mockFetch = vi.fn(); +vi.stubGlobal('fetch', mockFetch); + +let counter = 0; +function uniqueUrl(): string { + counter += 1; + return `http://hard-${counter}.local:8080`; +} + +function withCookies(body: string, cookies: string[], status = 200): Response { + const headers = new Headers(); + for (const cookie of cookies) headers.append('set-cookie', cookie); + return new Response(body, { status, headers }); +} + +function cookieOfCall(index: number): string | undefined { + const init = mockFetch.mock.calls[index]?.[1] as RequestInit | undefined; + return (init?.headers as Record | undefined)?.['Cookie']; +} + +describe('redactSecrets / trackerHost', () => { + it.each([ + ['https://t.example/announce?passkey=abc123&x=1', 'https://t.example/announce?passkey=***&x=1'], + ['http://t/a?authkey=ZZ&torrent_pass=YY', 'http://t/a?authkey=***&torrent_pass=***'], + ['http://t/a?pk=secret', 'http://t/a?pk=***'], + [ + 'https://t.example/0123456789abcdef0123456789abcdef/announce', + 'https://t.example/***/announce', + ], + ['nothing secret here', 'nothing secret here'], + ])('masks %s', (input, expected) => { + expect(redactSecrets(input)).toBe(expected); + }); + + it('reduces tracker URLs to the host and tolerates invalid input', () => { + expect(trackerHost('https://tracker.example:2710/abc/announce?passkey=1')).toBe( + 'tracker.example:2710' + ); + expect(trackerHost('not a url')).toBeUndefined(); + expect(trackerHost('')).toBeUndefined(); + expect(trackerHost(undefined)).toBeUndefined(); + }); +}); + +describe('QBittorrentClient hardening', () => { + beforeEach(() => { + mockFetch.mockReset(); + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-10-10T12:00:00Z')); + }); + afterEach(() => { + vi.useRealTimers(); + }); + + it('does not treat an unexpected 200 login body as bad credentials and does not escalate', async () => { + const base = uniqueUrl(); + const creds = { username: 'admin', secret: 'pw' }; + mockFetch.mockResolvedValueOnce(new Response('proxy login')); + await expect(new QBittorrentClient(base, creds).getVersion()).rejects.toThrow( + 'Unexpected login response — is this a qBittorrent WebUI (or behind an auth proxy)?' + ); + + // no cooldown: the next attempt reaches the server again + mockFetch + .mockResolvedValueOnce(withCookies('Ok.', ['SID=x'])) + .mockResolvedValueOnce(new Response('v5.2.4')); + await expect(new QBittorrentClient(base, creds).getVersion()).resolves.toBe('v5.2.4'); + }); + + it('only counts a trimmed "Fails." body as a credential failure', async () => { + mockFetch.mockResolvedValueOnce(new Response('Fails.\n')); + await expect( + new QBittorrentClient(uniqueUrl(), { username: 'a', secret: 'b' }).getVersion() + ).rejects.toThrow('Invalid qBittorrent username or password'); + }); + + it('uses manual redirect handling and reports the redirect target origin', async () => { + mockFetch.mockResolvedValueOnce( + new Response(null, { status: 302, headers: { location: 'https://qb.example/login?next=/' } }) + ); + const client = new QBittorrentClient(uniqueUrl(), { secret: '' }); + + await expect(client.getVersion()).rejects.toThrow( + 'URL redirects to https://qb.example — configure the final URL' + ); + expect((mockFetch.mock.calls[0]?.[1] as RequestInit).redirect).toBe('manual'); + }); + + it('reports a redirect on login too', async () => { + mockFetch.mockResolvedValueOnce( + new Response(null, { status: 301, headers: { location: 'https://secure.example/' } }) + ); + await expect( + new QBittorrentClient(uniqueUrl(), { username: 'a', secret: 'b' }).getVersion() + ).rejects.toThrow('URL redirects to https://secure.example'); + }); + + it('deletes cookies with a past Expires but keeps future ones', async () => { + mockFetch + .mockResolvedValueOnce(withCookies('Ok.', ['a=1', 'b=2', 'c=3'])) + .mockResolvedValueOnce( + withCookies('v5.2.4', [ + 'a=9; Expires=Thu, 01 Jan 1970 00:00:00 GMT', + 'b=9; Expires=Wed, 11 Oct 2028 12:00:00 GMT', + 'c=9; Max-Age=-5', + ]) + ) + .mockResolvedValueOnce(new Response('2.15.1')); + const client = new QBittorrentClient(uniqueUrl(), { username: 'admin', secret: 'pw' }); + await client.getVersion(); + await client.getWebApiVersion(); + + expect(cookieOfCall(2)).toBe('b=9'); + }); + + it('treats Max-Age with spaces or a large positive value correctly', async () => { + mockFetch + .mockResolvedValueOnce(withCookies('Ok.', ['a=1; Max-Age = 3600'])) + .mockResolvedValueOnce(new Response('v5.2.4')); + await new QBittorrentClient(uniqueUrl(), { username: 'u', secret: 'p' }).getVersion(); + expect(cookieOfCall(1)).toBe('a=1'); + }); + + it('parses a pathological Max-Age attribute quickly', async () => { + const evil = `a=1; Max-Age=${'0 '.repeat(20000)}x`; + mockFetch + .mockResolvedValueOnce(withCookies('Ok.', [evil])) + .mockResolvedValueOnce(new Response('v5.2.4')); + const started = Date.now(); + await new QBittorrentClient(uniqueUrl(), { username: 'u', secret: 'p' }).getVersion(); + expect(Date.now() - started).toBeLessThan(500); + }); +}); + +describe('QBittorrentProvider hardening', () => { + let provider: QBittorrentProvider; + let baseUrl: string; + + beforeEach(() => { + mockFetch.mockReset(); + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-10-10T12:00:00Z')); + provider = new QBittorrentProvider(); + baseUrl = uniqueUrl(); + }); + afterEach(() => { + vi.useRealTimers(); + }); + + const respondMixed = (json: Record): void => { + mockFetch.mockImplementation((url: unknown) => { + const path = new URL(String(url)).pathname.replace('/api/v2', ''); + if (path === '/app/version') return Promise.resolve(new Response('v5.2.4')); + if (path === '/app/webapiVersion') return Promise.resolve(new Response('2.15.1')); + const value = json[path]; + return Promise.resolve( + value === undefined + ? new Response('nf', { status: 404 }) + : new Response(JSON.stringify(value)) + ); + }); + }; + + it('keeps only the tracker host and drops the save path from torrent metadata', async () => { + respondMixed({ + '/torrents/info': [ + { + hash: 'h', + name: 'N', + state: 'error', + save_path: '/secret/path', + tracker: 'https://priv.example/ann/0123456789abcdef0123456789abcdef?passkey=abc', + }, + ], + '/transfer/info': { connection_status: 'connected' }, + }); + await provider.connect({ url: baseUrl, apiKey: '', logPath: '/logs' }); + + const [activity] = await provider.getActivity(); + + expect(activity?.metadata?.['tracker']).toBe('priv.example'); + expect(activity?.metadata).not.toHaveProperty('savePath'); + expect(JSON.stringify(activity)).not.toContain('abc'); + expect(JSON.stringify(activity)).not.toContain('secret/path'); + }); + + it('redacts secrets in API log messages', async () => { + respondMixed({ + '/log/main': [ + { + id: 1, + message: 'Tracker error "https://t/announce?passkey=SECRETKEY&a=b"', + timestamp: 1_790_000_000, + type: 4, + }, + ], + '/torrents/info': [], + '/transfer/info': { connection_status: 'connected' }, + }); + await provider.connect({ url: baseUrl, apiKey: '' }); + + const [activity] = await provider.getActivity(); + + expect(activity?.overview).toContain('passkey=***'); + expect(JSON.stringify(activity)).not.toContain('SECRETKEY'); + }); + + it('emits connection activity on state change and once per day only', async () => { + let status = 'connected'; + mockFetch.mockImplementation((url: unknown) => { + const path = new URL(String(url)).pathname.replace('/api/v2', ''); + if (path === '/app/version') return Promise.resolve(new Response('v5.2.4')); + if (path === '/app/webapiVersion') return Promise.resolve(new Response('2.15.1')); + if (path === '/transfer/info') + return Promise.resolve(new Response(JSON.stringify({ connection_status: status }))); + return Promise.resolve(new Response('[]')); + }); + await provider.connect({ url: baseUrl, apiKey: '', logPath: '/logs' }); + + expect(await provider.getActivity()).toEqual([]); + status = 'firewalled'; + expect(await provider.getActivity()).toHaveLength(1); + expect(await provider.getActivity()).toHaveLength(0); + status = 'disconnected'; + expect(await provider.getActivity()).toHaveLength(1); + status = 'connected'; + expect(await provider.getActivity()).toHaveLength(0); + status = 'disconnected'; + // recovered and failed again the same day: same id, deduped downstream, but a state change + const again = await provider.getActivity(); + expect(again).toHaveLength(1); + expect(await provider.getActivity()).toHaveLength(0); + vi.advanceTimersByTime(25 * 60 * 60_000); + expect(await provider.getActivity()).toHaveLength(1); + }); + + it('treats ids at or below the cursor as an id regression and filters by time', async () => { + const responses: unknown[][] = [ + [{ id: 500, message: 'old', timestamp: 1_790_000_000, type: 4 }], + [ + { id: 3, message: 'stale buffer entry', timestamp: 1_789_999_000, type: 4 }, + { id: 4, message: 'fresh after restart', timestamp: 1_790_000_100, type: 8 }, + ], + [], + ]; + mockFetch.mockImplementation((url: unknown) => { + const path = new URL(String(url)).pathname.replace('/api/v2', ''); + if (path === '/app/version') return Promise.resolve(new Response('v5.2.4')); + if (path === '/app/webapiVersion') return Promise.resolve(new Response('2.15.1')); + if (path === '/log/main') + return Promise.resolve(new Response(JSON.stringify(responses.shift() ?? []))); + if (path === '/transfer/info') + return Promise.resolve(new Response('{"connection_status":"connected"}')); + return Promise.resolve(new Response('[]')); + }); + await provider.connect({ url: baseUrl, apiKey: '' }); + + await provider.getActivity(); + const activities = await provider.getActivity(); + expect(activities.map((a) => a.overview)).toEqual(['fresh after restart']); + + await provider.getActivity(); + const lastCall = mockFetch.mock.calls + .map(([url]) => String(url)) + .filter((url) => url.includes('/log/main')) + .at(-1); + expect(lastCall).toContain('last_known_id=4'); + }); +}); diff --git a/packages/provider-qbittorrent/src/qbittorrent.parser.spec.ts b/packages/provider-qbittorrent/src/qbittorrent.parser.spec.ts new file mode 100644 index 0000000..34c44f6 --- /dev/null +++ b/packages/provider-qbittorrent/src/qbittorrent.parser.spec.ts @@ -0,0 +1,177 @@ +import { performance } from 'node:perf_hooks'; + +import { describe, it, expect } from 'vitest'; + +import { + QBITTORRENT_LOG_FILE_CONFIG, + isQBittorrentLogContinuation, + parseQBittorrentLogLine, + parseQBittorrentLogLineWithContext, +} from './qbittorrent.parser.js'; + +describe('parseQBittorrentLogLine', () => { + it('parses a normal line as info', () => { + const entry = parseQBittorrentLogLine('(N) 2026-10-10T12:00:01 - qBittorrent v5.2.4 started'); + expect(entry).not.toBeNull(); + expect(entry?.level).toBe('info'); + expect(entry?.message).toBe('qBittorrent v5.2.4 started'); + expect(entry?.timestamp).toEqual(new Date('2026-10-10T12:00:01')); + expect(entry?.source).toBe('qBittorrent'); + expect(entry?.metadata?.['qbLevel']).toBe('N'); + }); + + it('parses an info line as info', () => { + const entry = parseQBittorrentLogLine( + '(I) 2026-10-10T12:00:02 - Trying to listen on the following list of IP addresses: "0.0.0.0:6881,[::]:6881"' + ); + expect(entry?.level).toBe('info'); + expect(entry?.message).toContain('0.0.0.0:6881'); + }); + + it('parses a warning line as warn and extracts the torrent name', () => { + const entry = parseQBittorrentLogLine( + '(W) 2026-10-10T12:01:15 - File error alert. Torrent: "Some.Torrent.Name". File: "/downloads/x.mkv". Reason: "x.mkv file_open (/downloads/x.mkv) error: Permission denied"' + ); + expect(entry?.level).toBe('warn'); + expect(entry?.metadata?.['torrent']).toBe('Some.Torrent.Name'); + }); + + it('parses a critical line as error', () => { + const entry = parseQBittorrentLogLine( + '(C) 2026-10-10T12:02:30 - I/O error. Torrent: "Foo". Reason: "No space left on device"' + ); + expect(entry?.level).toBe('error'); + expect(entry?.metadata?.['torrent']).toBe('Foo'); + }); + + it('parses WebUI login failures', () => { + const entry = parseQBittorrentLogLine( + '(W) 2026-10-10T12:03:00 - WebUI login failure. IP: 172.18.0.5, username: admin' + ); + expect(entry?.level).toBe('warn'); + expect(entry?.message).toBe('WebUI login failure. IP: 172.18.0.5, username: admin'); + expect(entry?.metadata).not.toHaveProperty('torrent'); + }); + + it('keeps separators inside the message', () => { + const entry = parseQBittorrentLogLine('(I) 2026-10-10T12:00:02 - a - b - c'); + expect(entry?.message).toBe('a - b - c'); + }); + + it('tolerates a trailing carriage return', () => { + const entry = parseQBittorrentLogLine('(I) 2026-10-10T12:00:02 - hello\r'); + expect(entry?.message).toBe('hello'); + expect(entry?.raw).toBe('(I) 2026-10-10T12:00:02 - hello'); + }); + + it.each(['', ' ', 'random text', '(X) 2026-10-10T12:00:02 - bad level', '(I) 2026-10-10 - x'])( + 'returns null for %j', + (line) => { + expect(parseQBittorrentLogLine(line)).toBeNull(); + } + ); +}); + +describe('parseQBittorrentLogLineWithContext', () => { + const context = { continuationLines: [], filePath: '/x/qbittorrent.log', lineNumber: 1 }; + + it('returns the entry without continuation flags', () => { + const result = parseQBittorrentLogLineWithContext('(C) 2026-10-10T12:02:30 - boom', context); + expect(result.entry?.level).toBe('error'); + expect(result.isContinuation).toBe(false); + expect(result.previousComplete).toBe(false); + }); + + it('returns a null entry for unparseable lines', () => { + const result = parseQBittorrentLogLineWithContext('garbage', context); + expect(result).toEqual({ entry: null, isContinuation: false, previousComplete: false }); + }); + + it('never reports continuation lines', () => { + expect(isQBittorrentLogContinuation(' at something')).toBe(false); + }); +}); + +describe('QBITTORRENT_LOG_FILE_CONFIG', () => { + it('has docker paths including /qbittorrent-logs', () => { + expect(QBITTORRENT_LOG_FILE_CONFIG.defaultPaths.docker).toContain('/qbittorrent-logs'); + }); + + it('matches only the live log file', () => { + expect(QBITTORRENT_LOG_FILE_CONFIG.filePatterns).toEqual(['qbittorrent.log']); + expect(QBITTORRENT_LOG_FILE_CONFIG.rotatesDaily).toBe(false); + expect(QBITTORRENT_LOG_FILE_CONFIG.encoding).toBe('utf-8'); + }); +}); + +describe('parseQBittorrentLogLine - adversarial input', () => { + const header = '(W) 2026-10-10T12:00:02 - '; + + it('handles a 100k character message quickly', () => { + const line = header + 'x'.repeat(100_000); + const start = performance.now(); + const entry = parseQBittorrentLogLine(line); + expect(performance.now() - start).toBeLessThan(50); + expect(entry?.message).toHaveLength(100_000); + }); + + it('rejects a 100k character garbage line quickly', () => { + const start = performance.now(); + expect(parseQBittorrentLogLine('(' + 'N'.repeat(100_000))).toBeNull(); + expect(parseQBittorrentLogLine(' '.repeat(100_000))).toBeNull(); + expect(parseQBittorrentLogLine('(W) 2026-10-10T12:00:02-' + ' - '.repeat(30_000))).toBeNull(); + expect(performance.now() - start).toBeLessThan(50); + }); + + it('does not backtrack catastrophically on an unterminated torrent name', () => { + const line = header + 'Torrent: "' + 'a'.repeat(100_000); + const start = performance.now(); + const entry = parseQBittorrentLogLine(line); + expect(performance.now() - start).toBeLessThan(50); + expect(entry?.metadata).not.toHaveProperty('torrent'); + }); + + it('handles many torrent markers without a closing quote quickly', () => { + const line = header + 'Torrent: "x '.repeat(8_000); + const start = performance.now(); + parseQBittorrentLogLine(line); + expect(performance.now() - start).toBeLessThan(50); + }); + + it('only extracts the first quoted torrent name', () => { + const entry = parseQBittorrentLogLine(header + 'Torrent: "A", Torrent: "B"'); + expect(entry?.metadata?.['torrent']).toBe('A'); + }); + + it('accepts an empty message', () => { + expect(parseQBittorrentLogLine('(I) 2026-10-10T12:00:02 - ')?.message).toBe(''); + }); + + it('rejects lines with embedded newlines in the middle (no multi-line matching)', () => { + expect(parseQBittorrentLogLine(header + 'first\n(I) 2026-10-10T12:00:03 - second')).toBeNull(); + }); + + it('treats a line with a leading BOM or whitespace as unparseable', () => { + expect(parseQBittorrentLogLine('' + header + 'x')).toBeNull(); + expect(parseQBittorrentLogLine(' ' + header + 'x')).toBeNull(); + }); + + it('keeps html/script and control characters verbatim in the message', () => { + const entry = parseQBittorrentLogLine(header + '\u0000\u001b[31m'); + expect(entry?.message).toBe('\u0000\u001b[31m'); + }); + + it('returns null (not an Invalid Date entry) for an impossible timestamp', () => { + expect(parseQBittorrentLogLine('(I) 2026-13-45T99:99:99 - x')).toBeNull(); + }); + + it('masks passkeys and authkeys in messages and the raw line', () => { + const entry = parseQBittorrentLogLine( + '(W) 2026-10-10T12:00:00 - Tracker error. URL: "https://t.example/announce?passkey=abc123&x=1" authkey=zzz' + ); + expect(entry?.message).not.toContain('abc123'); + expect(entry?.message).not.toContain('zzz'); + expect(entry?.message).toContain('passkey=***'); + expect(entry?.raw).not.toContain('abc123'); + }); +}); diff --git a/packages/provider-qbittorrent/src/qbittorrent.parser.ts b/packages/provider-qbittorrent/src/qbittorrent.parser.ts new file mode 100644 index 0000000..8ed2918 --- /dev/null +++ b/packages/provider-qbittorrent/src/qbittorrent.parser.ts @@ -0,0 +1,95 @@ +import { redactSecrets } from './qbittorrent.redact.js'; + +import type { + CorrelationPattern, + LogFileConfig, + LogLevel, + LogParseContext, + LogParseResult, + ParsedLogEntry, +} from '@logarr/core'; + +/** + * qBittorrent file log format (qbittorrent.log): + * (N) 2026-10-10T12:00:01 - message + * N = normal, I = info, W = warning, C = critical. Timestamps are local time without timezone. + */ +const LOG_LINE_REGEX = /^\((N|I|W|C)\) (\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}) - (.*)$/; + +const LEVEL_MAP: Record = { + N: 'info', + I: 'info', + W: 'warn', + C: 'error', +}; + +const TORRENT_REGEX = /Torrent: "(.+?)"/; + +export const QBITTORRENT_LOG_FILE_CONFIG: LogFileConfig = { + defaultPaths: { + docker: ['/qbittorrent-logs', '/config/qBittorrent/logs'], + linux: [ + '~/.local/share/qBittorrent/logs', + '/var/lib/qbittorrent/.local/share/qBittorrent/logs', + ], + windows: ['%LOCALAPPDATA%\\qBittorrent\\logs'], + macos: ['~/Library/Application Support/qBittorrent/logs'], + }, + // Rotated backups (qbittorrent.log.bak*) are intentionally not matched to avoid duplicates + filePatterns: ['qbittorrent.log'], + encoding: 'utf-8', + rotatesDaily: false, +}; + +export const QBITTORRENT_CORRELATION_PATTERNS: readonly CorrelationPattern[] = [ + { name: 'ip', pattern: /IP:\s*([0-9a-f:.]+)/i }, +]; + +/** + * Parse a single qbittorrent.log line. Returns null for non-matching lines. + */ +export function parseQBittorrentLogLine(line: string): ParsedLogEntry | null { + const trimmed = line.replace(/\r$/, ''); + const match = LOG_LINE_REGEX.exec(trimmed); + if (!match) { + return null; + } + + const [, letter = '', time = '', rawMessage = ''] = match; + const timestamp = new Date(time); + if (Number.isNaN(timestamp.getTime())) { + return null; + } + const message = redactSecrets(rawMessage); + const torrent = TORRENT_REGEX.exec(message)?.[1]; + + return { + timestamp, + level: LEVEL_MAP[letter] ?? 'info', + message, + source: 'qBittorrent', + raw: redactSecrets(trimmed), + metadata: { + qbLevel: letter, + ...(torrent !== undefined && { torrent }), + }, + }; +} + +/** + * qBittorrent log entries are single-line, so there is never a continuation. + */ +export function parseQBittorrentLogLineWithContext( + line: string, + _context: LogParseContext +): LogParseResult { + return { + entry: parseQBittorrentLogLine(line), + isContinuation: false, + previousComplete: false, + }; +} + +export function isQBittorrentLogContinuation(_line: string): boolean { + return false; +} diff --git a/packages/provider-qbittorrent/src/qbittorrent.provider.spec.ts b/packages/provider-qbittorrent/src/qbittorrent.provider.spec.ts new file mode 100644 index 0000000..b233473 --- /dev/null +++ b/packages/provider-qbittorrent/src/qbittorrent.provider.spec.ts @@ -0,0 +1,445 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { QBITTORRENT_LOG_FILE_CONFIG } from './qbittorrent.parser.js'; +import { QBittorrentProvider } from './qbittorrent.provider.js'; + +const mockFetch = vi.fn(); +vi.stubGlobal('fetch', mockFetch); + +let counter = 0; +function uniqueUrl(): string { + counter += 1; + return `http://qbp-${counter}.local:8080`; +} + +/** Routes unauthenticated (bypass mode) requests by path */ +function routeFetch(routes: Record Response>): void { + mockFetch.mockImplementation((url: unknown) => { + const path = new URL(String(url)).pathname.replace('/api/v2', ''); + const route = routes[path]; + return Promise.resolve(route ? route() : new Response('not found', { status: 404 })); + }); +} + +const text = + (body: string, status = 200) => + () => + new Response(body, { status }); +const json = + (body: unknown, status = 200) => + () => + new Response(JSON.stringify(body), { status }); + +function logCalls(): string[] { + return mockFetch.mock.calls + .map(([url]) => String(url)) + .filter((url) => url.includes('/log/main')); +} + +describe('QBittorrentProvider', () => { + let provider: QBittorrentProvider; + let baseUrl: string; + + beforeEach(() => { + mockFetch.mockReset(); + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-10-10T12:00:00Z')); + provider = new QBittorrentProvider(); + baseUrl = uniqueUrl(); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + describe('identity', () => { + it('has correct id and name', () => { + expect(provider.id).toBe('qbittorrent'); + expect(provider.name).toBe('qBittorrent'); + }); + + it('has the expected capabilities', () => { + expect(provider.capabilities).toEqual({ + supportsRealTimeLogs: false, + supportsActivityLog: true, + supportsSessions: false, + supportsWebhooks: false, + supportsPlaybackHistory: false, + }); + }); + + it('returns empty sessions and users', async () => { + await expect(provider.getSessions()).resolves.toEqual([]); + await expect(provider.getUsers()).resolves.toEqual([]); + }); + + it('returns the log file config and parses lines', () => { + expect(provider.getLogFileConfig()).toBe(QBITTORRENT_LOG_FILE_CONFIG); + expect(provider.parseLogLine('(C) 2026-10-10T12:02:30 - boom')?.level).toBe('error'); + expect(provider.isLogContinuation('x')).toBe(false); + expect( + provider.parseLogLineWithContext('(W) 2026-10-10T12:02:30 - hmm', { + continuationLines: [], + filePath: 'f', + lineNumber: 1, + }).entry?.level + ).toBe('warn'); + expect(provider.getCorrelationPatterns().length).toBeGreaterThan(0); + }); + }); + + describe('getLogPaths', () => { + it('returns defaults including /qbittorrent-logs', async () => { + const paths = await provider.getLogPaths(); + expect(paths).toContain('/qbittorrent-logs'); + expect(paths).toContain('/config/qBittorrent/logs'); + }); + + it('honours a configured logPath', async () => { + routeFetch({ '/app/version': text('v5.2.4'), '/app/webapiVersion': text('2.15.1') }); + await provider.connect({ url: baseUrl, apiKey: '', logPath: '/custom/logs' }); + await expect(provider.getLogPaths()).resolves.toEqual(['/custom/logs']); + }); + }); + + describe('connect / testConnection', () => { + it('connects and reports server info (5.x)', async () => { + routeFetch({ '/app/version': text('v5.2.4'), '/app/webapiVersion': text('2.15.1\n') }); + await provider.connect({ url: baseUrl, apiKey: '' }); + + await expect(provider.testConnection()).resolves.toEqual({ + connected: true, + serverInfo: { name: 'qBittorrent', version: '5.2.4', id: 'qbittorrent-webapi-2.15.1' }, + }); + }); + + it('supports 4.x version strings', async () => { + routeFetch({ '/app/version': text('v4.6.7'), '/app/webapiVersion': text('2.9.3') }); + await provider.connect({ url: baseUrl, apiKey: '' }); + const info = await provider.getServerInfo(); + expect(info.version).toBe('4.6.7'); + }); + + it('rejects connect with the failure message', async () => { + routeFetch({ '/app/version': text('Forbidden', 403) }); + await expect(provider.connect({ url: baseUrl, apiKey: '' })).rejects.toThrow( + 'qBittorrent requires authentication' + ); + }); + + it('passes the username through to login', async () => { + mockFetch.mockImplementation((url: unknown) => { + const path = new URL(String(url)).pathname; + if (path.endsWith('/auth/login')) { + return Promise.resolve( + new Response('Ok.', { headers: { 'set-cookie': 'QBT_SID_8080=zzz; path=/' } }) + ); + } + return Promise.resolve(new Response(path.endsWith('version') ? 'v5.2.4' : '2.15.1')); + }); + await provider.connect({ url: baseUrl, apiKey: 'pw', username: 'admin' }); + const login = mockFetch.mock.calls[0]; + expect((login?.[1] as RequestInit).body).toBe('username=admin&password=pw'); + }); + + it('throws when used before connect', async () => { + await expect(provider.getServerInfo()).rejects.toThrow('not connected'); + await provider.disconnect(); + }); + }); + + describe('getActivity - log messages', () => { + const connect = async (logPath?: string): Promise => { + await provider.connect({ url: baseUrl, apiKey: '', logPath }); + }; + const base = { + '/app/version': text('v5.2.4'), + '/app/webapiVersion': text('2.15.1'), + '/torrents/info': json([]), + '/transfer/info': json({ connection_status: 'connected' }), + }; + + it('ingests only warning and critical entries with stable ids and severities', async () => { + routeFetch({ + ...base, + '/log/main': json([ + { id: 1, message: 'normal', timestamp: 1_790_000_000, type: 1 }, + { id: 2, message: 'info', timestamp: 1_790_000_001, type: 2 }, + { id: 3, message: 'File error alert', timestamp: 1_790_000_002, type: 4 }, + { id: 4, message: 'I/O error', timestamp: 1_790_000_003, type: 8 }, + ]), + }); + await connect(); + + const activities = await provider.getActivity(); + + expect(activities.map((a) => a.id)).toEqual([ + 'qbittorrent-log-1790000002-3', + 'qbittorrent-log-1790000003-4', + ]); + expect(activities.map((a) => a.severity)).toEqual(['warn', 'error']); + expect(activities[0]?.timestamp).toEqual(new Date(1_790_000_002_000)); + expect(activities[0]?.type).toBe('Log'); + expect(activities[1]?.overview).toBe('I/O error'); + }); + + it('normalises millisecond timestamps from qBittorrent < 4.5', async () => { + routeFetch({ + ...base, + '/log/main': json([{ id: 7, message: 'old', timestamp: 1_790_000_002_000, type: 4 }]), + }); + await connect(); + + const [activity] = await provider.getActivity(); + + expect(activity?.timestamp).toEqual(new Date(1_790_000_002_000)); + expect(activity?.id).toBe('qbittorrent-log-1790000002-7'); + }); + + it('requests only warnings and criticals and then continues from the last id', async () => { + let call = 0; + routeFetch({ + ...base, + '/log/main': () => + json( + call++ === 0 + ? [ + { id: 5, message: 'a', timestamp: 1_790_000_000, type: 4 }, + { id: 9, message: 'b', timestamp: 1_790_000_001, type: 8 }, + ] + : [] + )(), + }); + await connect(); + + await provider.getActivity(); + await provider.getActivity(); + + const [first, second] = logCalls(); + expect(first).toContain('last_known_id=-1'); + expect(first).toContain('warning=true'); + expect(first).toContain('critical=true'); + expect(first).toContain('normal=false'); + expect(second).toContain('last_known_id=9'); + }); + + it('resyncs after 10 minutes of silence and detects an id reset after a restart', async () => { + const responses: unknown[][] = [ + [{ id: 500, message: 'before', timestamp: 1_790_000_000, type: 4 }], + [], + // empty answer to last_known_id=500 (ids restarted), then the full resync + [], + // full resync after restart: ids restarted at 0, newer timestamps + [ + { id: 0, message: 'old buffer entry', timestamp: 1_789_999_000, type: 4 }, + { id: 1, message: 'after restart', timestamp: 1_790_000_900, type: 8 }, + ], + // next regular poll uses the reset cursor + [], + ]; + routeFetch({ ...base, '/log/main': () => json(responses.shift() ?? [])() }); + await connect(); + + await provider.getActivity(); + vi.advanceTimersByTime(60_000); + expect(await provider.getActivity()).toEqual([]); + expect(logCalls()).toHaveLength(2); + + vi.advanceTimersByTime(11 * 60_000); + const activities = await provider.getActivity(); + expect(activities.map((a) => a.id)).toEqual(['qbittorrent-log-1790000900-1']); + expect(logCalls()[2]).toContain('last_known_id=500'); + expect(logCalls()[3]).toContain('last_known_id=-1'); + + await provider.getActivity(); + expect(logCalls()[4]).toContain('last_known_id=1'); + }); + + it('does not resync again within 10 minutes of the last full sync', async () => { + routeFetch({ + ...base, + '/log/main': json([{ id: 5, message: 'a', timestamp: 1_790_000_000, type: 4 }]), + }); + await connect(); + await provider.getActivity(); + mockFetch.mockClear(); + routeFetch({ ...base, '/log/main': json([]) }); + vi.advanceTimersByTime(5 * 60_000); + + await provider.getActivity(); + + expect(logCalls()).toHaveLength(1); + expect(logCalls()[0]).toContain('last_known_id=5'); + }); + + it('applies the since filter on a fresh start', async () => { + routeFetch({ + ...base, + '/log/main': json([ + { id: 1, message: 'old', timestamp: 1_790_000_000, type: 4 }, + { id: 2, message: 'new', timestamp: 1_790_000_100, type: 4 }, + ]), + }); + await connect(); + + const activities = await provider.getActivity(new Date(1_790_000_050_000)); + + expect(activities.map((a) => a.overview)).toEqual(['new']); + }); + + it('caps a fresh start without since to the newest 500 entries', async () => { + const entries = Array.from({ length: 700 }, (_, i) => ({ + id: i, + message: `m${i}`, + timestamp: 1_790_000_000 + i, + type: 4, + })); + routeFetch({ ...base, '/log/main': json(entries) }); + await connect(); + + const activities = await provider.getActivity(); + + expect(activities).toHaveLength(500); + expect(activities[0]?.overview).toBe('m200'); + expect(activities.at(-1)?.overview).toBe('m699'); + }); + + it('skips the API log feed entirely when a logPath is configured', async () => { + routeFetch({ + ...base, + '/log/main': json([{ id: 1, message: 'x', timestamp: 1_790_000_000, type: 8 }]), + }); + await connect('/qbittorrent-logs'); + + const activities = await provider.getActivity(); + + expect(activities).toEqual([]); + expect(logCalls()).toHaveLength(0); + }); + + it('keeps other sources when the log endpoint fails', async () => { + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined); + routeFetch({ + ...base, + '/log/main': text('boom', 500), + '/torrents/info': json([{ hash: 'h1', name: 'T', state: 'error' }]), + }); + await connect(); + + const activities = await provider.getActivity(); + + expect(activities.map((a) => a.type)).toEqual(['TorrentError']); + expect(errorSpy).toHaveBeenCalled(); + }); + }); + + describe('getActivity - torrents and connection', () => { + const connect = async (): Promise => { + await provider.connect({ url: baseUrl, apiKey: '', logPath: '/logs' }); + }; + const base = { + '/app/version': text('v5.2.4'), + '/app/webapiVersion': text('2.15.1'), + '/log/main': json([]), + }; + + it('maps errored torrents to stable error activities', async () => { + routeFetch({ + ...base, + '/torrents/info': json([ + { + hash: 'abc', + name: 'Some.Torrent', + state: 'missingFiles', + category: 'tv', + save_path: '/dl', + tracker: 'http://t/announce?passkey=abcdef', + progress: 0.5, + }, + { hash: 'def', name: 'Other', state: 'error' }, + ]), + '/transfer/info': json({ connection_status: 'connected' }), + }); + await connect(); + + const first = await provider.getActivity(); + const second = await provider.getActivity(); + + expect(first.map((a) => a.id)).toEqual([ + 'qbittorrent-torrent-error-abc-missingFiles-2026-10-10', + 'qbittorrent-torrent-error-def-error-2026-10-10', + ]); + expect(second.map((a) => a.id)).toEqual(first.map((a) => a.id)); + expect(first[0]).toMatchObject({ + type: 'TorrentError', + severity: 'error', + itemId: 'abc', + name: 'Torrent error: Some.Torrent', + overview: 'Some.Torrent is in state "missingFiles" (category: tv)', + metadata: { hash: 'abc', state: 'missingFiles', tracker: 't' }, + }); + expect(first[1]?.overview).toBe('Other is in state "error"'); + }); + + it.each([ + ['firewalled', 'warn'], + ['disconnected', 'error'], + ])('reports %s connection status as %s', async (status, severity) => { + routeFetch({ + ...base, + '/torrents/info': json([]), + '/transfer/info': json({ connection_status: status, dht_nodes: 12 }), + }); + await connect(); + + const [activity] = await provider.getActivity(); + + expect(activity).toMatchObject({ + id: `qbittorrent-connection-${status}-2026-10-10`, + type: 'ConnectionStatus', + severity, + metadata: { connectionStatus: status, dhtNodes: 12 }, + }); + }); + + it('does not report a healthy connection and reports an unhealthy one once per day', async () => { + routeFetch({ + ...base, + '/torrents/info': json([]), + '/transfer/info': json({ connection_status: 'connected' }), + }); + await connect(); + expect(await provider.getActivity()).toEqual([]); + + routeFetch({ + ...base, + '/torrents/info': json([]), + '/transfer/info': json({ connection_status: 'firewalled' }), + }); + const [first] = await provider.getActivity(); + vi.advanceTimersByTime(30 * 60_000); + const sameDay = await provider.getActivity(); + vi.advanceTimersByTime(24 * 60 * 60_000); + const [nextDay] = await provider.getActivity(); + + expect(first).toBeDefined(); + expect(sameDay).toEqual([]); + expect(nextDay?.id).not.toBe(first?.id); + }); + + it('sorts the combined result by timestamp', async () => { + routeFetch({ + ...base, + '/torrents/info': json([{ hash: 'h', name: 'N', state: 'error' }]), + '/transfer/info': json({ connection_status: 'disconnected' }), + }); + await connect(); + + const activities = await provider.getActivity(); + + const times = activities.map((a) => a.timestamp.getTime()); + expect(times).toEqual([...times].sort((a, b) => a - b)); + }); + }); +}); diff --git a/packages/provider-qbittorrent/src/qbittorrent.provider.ts b/packages/provider-qbittorrent/src/qbittorrent.provider.ts new file mode 100644 index 0000000..89d538d --- /dev/null +++ b/packages/provider-qbittorrent/src/qbittorrent.provider.ts @@ -0,0 +1,353 @@ +import { QBittorrentClient } from './qbittorrent.client.js'; +import { + QBITTORRENT_CORRELATION_PATTERNS, + QBITTORRENT_LOG_FILE_CONFIG, + isQBittorrentLogContinuation, + parseQBittorrentLogLine, + parseQBittorrentLogLineWithContext, +} from './qbittorrent.parser.js'; +import { redactSecrets, trackerHost } from './qbittorrent.redact.js'; +import { QBITTORRENT_LOG_TYPE } from './qbittorrent.types.js'; + +import type { QBittorrentLogEntry } from './qbittorrent.types.js'; +import type { + ConnectionStatus, + CorrelationPattern, + LogFileConfig, + LogLevel, + LogParseContext, + LogParseResult, + MediaServerProvider, + NormalizedActivity, + NormalizedSession, + NormalizedUser, + ParsedLogEntry, + ProviderCapabilities, + ProviderConfig, + ServerInfo, +} from '@logarr/core'; + +/** Newest log messages ingested on the very first poll when no "since" is known */ +const INITIAL_LOG_LIMIT = 500; +/** How often an empty log response triggers a full resync (detects qBittorrent restarts) */ +const LOG_RESYNC_INTERVAL_MS = 10 * 60_000; + +interface LogCursor { + lastId: number; + lastTs: number; + fullSyncAt: number; +} + +/** + * Log cursors per qBittorrent instance. Module-level because the provider is a singleton that + * is disconnected after every poll. + */ +const logCursors = new Map(); + +/** + * Last seen connection status and the day it was last reported, per qBittorrent instance. + * Used to only emit connection activities on change (plus once per day while unhealthy). + */ +const connectionStates = new Map(); + +function truncate(value: string, max: number): string { + return value.length > max ? `${value.slice(0, max - 1)}…` : value; +} + +/** qBittorrent < 4.5.0 reports milliseconds, newer versions seconds */ +function toMillis(timestamp: number): number { + return timestamp > 1e11 ? timestamp : timestamp * 1000; +} + +function severityOf(type: number): LogLevel { + return type === QBITTORRENT_LOG_TYPE.CRITICAL ? 'error' : 'warn'; +} + +/** + * qBittorrent download client provider. + * + * Supports username/password (cookie session), API keys (qBittorrent >= 5.3) and + * authentication bypass. Credentials: `apiKey` holds the password (or the API key when no + * username is configured). + */ +export class QBittorrentProvider implements MediaServerProvider { + readonly id = 'qbittorrent'; + readonly name = 'qBittorrent'; + + readonly capabilities: ProviderCapabilities = { + supportsRealTimeLogs: false, + supportsActivityLog: true, + supportsSessions: false, + supportsWebhooks: false, + supportsPlaybackHistory: false, + }; + + private config: ProviderConfig | null = null; + private client: QBittorrentClient | null = null; + + async connect(config: ProviderConfig): Promise { + this.config = config; + this.client = new QBittorrentClient(config.url, { + username: config.username, + secret: config.apiKey, + }); + + const status = await this.testConnection(); + if (!status.connected) { + throw new Error(status.error ?? 'Connection failed'); + } + } + + disconnect(): Promise { + // Sessions and cursors live in module-level maps on purpose + this.client = null; + this.config = null; + return Promise.resolve(); + } + + async testConnection(): Promise { + try { + return { connected: true, serverInfo: await this.getServerInfo() }; + } catch (error) { + return { + connected: false, + error: error instanceof Error ? error.message : 'Unknown error', + }; + } + } + + async getServerInfo(): Promise { + const client = this.requireClient(); + const version = (await client.getVersion()).trim(); + const webApi = (await client.getWebApiVersion()).trim(); + return { + name: 'qBittorrent', + version: version.replace(/^v/i, ''), + id: `qbittorrent-webapi-${webApi}`, + }; + } + + getLogPaths(): Promise { + const logPath = this.config?.logPath?.trim(); + if (logPath !== undefined && logPath !== '') { + return Promise.resolve([logPath]); + } + return Promise.resolve([ + '/qbittorrent-logs', + // linuxserver/qbittorrent (profile under /config) - best guess, verify in your container + '/config/qBittorrent/logs', + // hotio/qbittorrent - best guess, verify in your container + '/config/logs', + '~/.local/share/qBittorrent/logs', + 'C:\\Users\\\\AppData\\Local\\qBittorrent\\logs', + ]); + } + + parseLogLine(line: string): ParsedLogEntry | null { + return parseQBittorrentLogLine(line); + } + + getCorrelationPatterns(): readonly CorrelationPattern[] { + return QBITTORRENT_CORRELATION_PATTERNS; + } + + getSessions(): Promise { + return Promise.resolve([]); + } + + getUsers(): Promise { + return Promise.resolve([]); + } + + getLogFileConfig(): LogFileConfig { + return QBITTORRENT_LOG_FILE_CONFIG; + } + + parseLogLineWithContext(line: string, context: LogParseContext): LogParseResult { + return parseQBittorrentLogLineWithContext(line, context); + } + + isLogContinuation(line: string): boolean { + return isQBittorrentLogContinuation(line); + } + + async getActivity(since?: Date): Promise { + const client = this.requireClient(); + const activities: NormalizedActivity[] = []; + + // (a) Warning/critical log messages. Skipped when the log file is tailed instead. + if ((this.config?.logPath?.trim() ?? '') === '') { + try { + activities.push(...(await this.fetchLogActivities(client, since))); + } catch (error) { + console.error('[qbittorrent] Failed to fetch log messages:', this.describe(error)); + } + } + + // (b) Errored torrents + try { + activities.push(...(await this.fetchTorrentErrors(client))); + } catch (error) { + console.error('[qbittorrent] Failed to fetch errored torrents:', this.describe(error)); + } + + // (c) Connection status + try { + activities.push(...(await this.fetchConnectionStatus(client))); + } catch (error) { + console.error('[qbittorrent] Failed to fetch transfer info:', this.describe(error)); + } + + return activities.sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime()); + } + + // --------------------------------------------------------------------------- + // Activity sources + // --------------------------------------------------------------------------- + + private async fetchLogActivities( + client: QBittorrentClient, + since: Date | undefined + ): Promise { + const now = Date.now(); + const cursor = logCursors.get(client.baseUrl); + let entries = await client.getLog(cursor?.lastId ?? -1); + let keepAfter: number | undefined; + let capNewest = false; + let resynced = false; + + if (cursor === undefined) { + keepAfter = since?.getTime() ?? 0; + capNewest = since === undefined; + } else if (cursor.lastId >= 0 && entries.some((entry) => entry.id <= cursor.lastId)) { + // ids went backwards (qBittorrent restarted): everything returned is new, filter by time + keepAfter = cursor.lastTs; + resynced = true; + } else if ( + entries.length === 0 && + cursor.lastId >= 0 && + now - cursor.fullSyncAt > LOG_RESYNC_INTERVAL_MS + ) { + // ids restart at 0 after a qBittorrent restart; re-read the whole buffer + entries = await client.getLog(-1); + keepAfter = cursor.lastTs; + resynced = true; + } + + const relevant = entries.filter( + (entry) => + (entry.type === QBITTORRENT_LOG_TYPE.WARNING || + entry.type === QBITTORRENT_LOG_TYPE.CRITICAL) && + (keepAfter === undefined || toMillis(entry.timestamp) > keepAfter) + ); + + const maxId = entries.reduce((max, entry) => Math.max(max, entry.id), -1); + const maxTs = entries.reduce((max, entry) => Math.max(max, toMillis(entry.timestamp)), 0); + const previousId = cursor?.lastId ?? -1; + let lastId = Math.max(maxId, previousId); + if (resynced && entries.length > 0) { + // ids restarted: trust the new (possibly lower) maximum + lastId = maxId; + } + logCursors.set(client.baseUrl, { + lastId, + lastTs: Math.max(maxTs, cursor?.lastTs ?? 0), + fullSyncAt: cursor === undefined || resynced ? now : cursor.fullSyncAt, + }); + + const selected = capNewest + ? [...relevant].sort((a, b) => a.timestamp - b.timestamp).slice(-INITIAL_LOG_LIMIT) + : relevant; + + return selected.map((entry) => this.toLogActivity(entry)); + } + + private toLogActivity(entry: QBittorrentLogEntry): NormalizedActivity { + const timestampMs = toMillis(entry.timestamp); + const message = redactSecrets(entry.message); + return { + id: `qbittorrent-log-${Math.floor(timestampMs / 1000)}-${entry.id}`, + type: 'Log', + name: truncate(message, 120), + overview: message, + severity: severityOf(entry.type), + timestamp: new Date(timestampMs), + metadata: { qbId: entry.id, qbType: entry.type }, + }; + } + + private async fetchTorrentErrors(client: QBittorrentClient): Promise { + const torrents = await client.getErroredTorrents(); + // Synthetic detection time. Note: it also advances lastActivitySync (accepted trade-off: + // the log feed re-reads from its own id cursor, so no log messages are lost). + const timestamp = new Date(); + const day = timestamp.toISOString().slice(0, 10); + return torrents.map((torrent) => ({ + // Day bucket: a persisting/recurring error is re-announced at most once per day + id: `qbittorrent-torrent-error-${torrent.hash}-${torrent.state}-${day}`, + type: 'TorrentError', + name: `Torrent error: ${torrent.name}`, + overview: + `${torrent.name} is in state "${torrent.state}"` + + (torrent.category !== undefined && torrent.category !== '' + ? ` (category: ${torrent.category})` + : ''), + severity: 'error' as const, + itemId: torrent.hash, + timestamp, + metadata: { + hash: torrent.hash, + state: torrent.state, + category: torrent.category, + // Host only: private tracker URLs embed the account passkey + tracker: trackerHost(torrent.tracker), + progress: torrent.progress, + }, + })); + } + + private async fetchConnectionStatus(client: QBittorrentClient): Promise { + const info = await client.getTransferInfo(); + const status = info.connection_status; + const previous = connectionStates.get(client.baseUrl); + + if (status !== 'firewalled' && status !== 'disconnected') { + connectionStates.set(client.baseUrl, { status, reportedDay: null }); + return []; + } + + // Synthetic detection time (see fetchTorrentErrors) + const timestamp = new Date(); + const day = timestamp.toISOString().slice(0, 10); + if (previous?.status === status && previous.reportedDay === day) { + return []; + } + connectionStates.set(client.baseUrl, { status, reportedDay: day }); + + return [ + { + id: `qbittorrent-connection-${status}-${day}`, + type: 'ConnectionStatus', + name: `qBittorrent connection status: ${status}`, + overview: `qBittorrent reports it is ${status} (check port forwarding / network binding)`, + severity: status === 'disconnected' ? 'error' : 'warn', + timestamp, + metadata: { + connectionStatus: status, + dhtNodes: info.dht_nodes, + }, + }, + ]; + } + + private requireClient(): QBittorrentClient { + if (!this.client) { + throw new Error('qBittorrent provider is not connected'); + } + return this.client; + } + + private describe(error: unknown): string { + return error instanceof Error ? error.message : 'Unknown error'; + } +} diff --git a/packages/provider-qbittorrent/src/qbittorrent.redact.ts b/packages/provider-qbittorrent/src/qbittorrent.redact.ts new file mode 100644 index 0000000..44144b7 --- /dev/null +++ b/packages/provider-qbittorrent/src/qbittorrent.redact.ts @@ -0,0 +1,22 @@ +/** + * Private trackers embed account secrets (passkey / authkey) in announce URLs, and qBittorrent + * log messages and torrent objects can contain those URLs. Mask them before anything is stored. + */ +const SECRET_QUERY_PARAM = /\b(passkey|authkey|torrent_pass|pk)=[^&\s"'<>]+/gi; +const HEX_PATH_SEGMENT = /\/[0-9a-f]{32,}(?=[/?#&\s"'<>]|$)/gi; + +export function redactSecrets(text: string): string { + return text.replace(SECRET_QUERY_PARAM, '$1=***').replace(HEX_PATH_SEGMENT, '/***'); +} + +/** Reduce a tracker URL to its host (no path or query); undefined if it is not a valid URL */ +export function trackerHost(tracker: string | undefined): string | undefined { + if (tracker === undefined || tracker === '') { + return undefined; + } + try { + return new URL(tracker).host; + } catch { + return undefined; + } +} diff --git a/packages/provider-qbittorrent/src/qbittorrent.types.ts b/packages/provider-qbittorrent/src/qbittorrent.types.ts new file mode 100644 index 0000000..eebb4ad --- /dev/null +++ b/packages/provider-qbittorrent/src/qbittorrent.types.ts @@ -0,0 +1,45 @@ +/** + * qBittorrent WebUI API v2 types + */ + +/** Entry returned by GET /api/v2/log/main */ +export interface QBittorrentLogEntry { + readonly id: number; + readonly message: string; + /** Seconds since epoch (milliseconds before qBittorrent 4.5.0) */ + readonly timestamp: number; + readonly type: number; +} + +/** Subset of the torrent object returned by GET /api/v2/torrents/info */ +export interface QBittorrentTorrent { + readonly hash: string; + readonly name: string; + readonly state: string; + readonly category?: string; + readonly save_path?: string; + readonly tracker?: string; + readonly progress?: number; +} + +/** Subset of GET /api/v2/transfer/info */ +export interface QBittorrentTransferInfo { + readonly connection_status: 'connected' | 'firewalled' | 'disconnected'; + readonly dht_nodes?: number; +} + +/** Log message type bit flags */ +export const QBITTORRENT_LOG_TYPE = { + NORMAL: 1, + INFO: 2, + WARNING: 4, + CRITICAL: 8, +} as const; + +/** + * How the client authenticates, derived from the stored credentials: + * - login: username + password -> POST /auth/login and session cookie + * - apiKey: no username, non-empty secret -> Authorization: Bearer (qBittorrent >= 5.3) + * - none: no credentials (WebUI authentication bypass) + */ +export type QBittorrentAuthMode = 'login' | 'apiKey' | 'none'; diff --git a/packages/provider-qbittorrent/tsconfig.json b/packages/provider-qbittorrent/tsconfig.json new file mode 100644 index 0000000..f103ea7 --- /dev/null +++ b/packages/provider-qbittorrent/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src", + "declaration": true, + "declarationMap": true, + "composite": true, + "lib": ["ES2022", "DOM"] + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"], + "references": [{ "path": "../core" }] +} diff --git a/packages/provider-qbittorrent/tsup.config.ts b/packages/provider-qbittorrent/tsup.config.ts new file mode 100644 index 0000000..4a0ea44 --- /dev/null +++ b/packages/provider-qbittorrent/tsup.config.ts @@ -0,0 +1,22 @@ +import { defineConfig } from 'tsup'; + +export default defineConfig({ + entry: ['src/index.ts'], + format: ['cjs', 'esm'], + dts: { + compilerOptions: { + composite: false, + }, + }, + splitting: false, + sourcemap: true, + clean: true, + treeshake: true, + minify: false, + external: ['@logarr/core'], + outExtension({ format }) { + return { + js: format === 'cjs' ? '.cjs' : '.js', + }; + }, +}); diff --git a/packages/provider-qbittorrent/vitest.config.ts b/packages/provider-qbittorrent/vitest.config.ts new file mode 100644 index 0000000..a2f6052 --- /dev/null +++ b/packages/provider-qbittorrent/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + include: ['src/**/*.spec.ts'], + exclude: ['dist/**', 'node_modules/**'], + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9551804..aa3562e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -77,6 +77,9 @@ importers: '@logarr/provider-prowlarr': specifier: workspace:* version: link:../../packages/provider-prowlarr + '@logarr/provider-qbittorrent': + specifier: workspace:* + version: link:../../packages/provider-qbittorrent '@logarr/provider-radarr': specifier: workspace:* version: link:../../packages/provider-radarr @@ -536,6 +539,25 @@ importers: specifier: ^5.7.2 version: 5.9.3 + packages/provider-qbittorrent: + dependencies: + '@logarr/core': + specifier: workspace:* + version: link:../core + devDependencies: + '@types/node': + specifier: ^22.10.2 + version: 22.19.3 + tsup: + specifier: ^8.3.5 + version: 8.5.1(@swc/core@1.15.8)(jiti@2.6.1)(postcss@8.5.14)(typescript@5.9.3) + typescript: + specifier: ^5.7.2 + version: 5.9.3 + vitest: + specifier: ^4.0.16 + version: 4.0.16(@types/node@22.19.3)(@vitest/ui@4.0.16)(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.30.2)(msw@2.12.7(@types/node@22.19.3)(typescript@5.9.3))(terser@5.44.1) + packages/provider-radarr: dependencies: '@logarr/core': diff --git a/turbo.json b/turbo.json index 248fe4a..79c3e77 100644 --- a/turbo.json +++ b/turbo.json @@ -30,6 +30,9 @@ "WHISPARR_API_KEY", "LIDARR_URL", "LIDARR_API_KEY", + "QBITTORRENT_URL", + "QBITTORRENT_USERNAME", + "QBITTORRENT_PASSWORD", "JELLYFIN_LOGS_PATH", "PLEX_LOGS_PATH", "EMBY_LOGS_PATH", @@ -37,7 +40,8 @@ "RADARR_LOGS_PATH", "PROWLARR_LOGS_PATH", "WHISPARR_LOGS_PATH", - "LIDARR_LOGS_PATH" + "LIDARR_LOGS_PATH", + "QBITTORRENT_LOGS_PATH" ], "tasks": { "build": { From 167fb1f29f882b4fd938d5183aac6ef51bfa7879 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=A1clav=20Brzezina?= Date: Sat, 10 Oct 2026 20:20:56 +0200 Subject: [PATCH 03/12] refactor(provider-qbittorrent): align with shared provider conventions - credentials: supportsUsername/apiKeyOptional capability flags (core, provider, integration entry) replace hard-coded qbittorrent checks; proxy guard keys on stored username - proxy: qBittorrent >= 5.3 accepts X-API-Key (verified in webui source); message fixed - activity types snake_case (log_warning, log_critical, torrent_error, connection_status) - getActivity: 'Failed to get ' logging, rethrow when all sources fail, 'Not connected' - bounded session/cursor/status caches (LRU + TTL) with resetQBittorrentState - qbittorrent icon via dashboard-icons, realTimeLogs true, fetch stubbing per beforeEach/afterEach - add qBittorrent to maintainer agent provider list --- .github/agents/my-agent.agent.md | 2 +- .../src/modules/proxy/proxy.service.spec.ts | 10 + .../src/modules/proxy/proxy.service.ts | 8 +- .../modules/servers/servers.service.spec.ts | 15 ++ .../src/modules/servers/servers.service.ts | 2 +- .../src/components/edit-server-dialog.tsx | 8 +- .../src/components/provider-icon.test.tsx | 17 ++ .../frontend/src/components/provider-icon.tsx | 13 ++ apps/frontend/src/lib/integrations.test.ts | 7 +- apps/frontend/src/lib/integrations.ts | 8 +- apps/frontend/src/lib/server-payload.test.ts | 9 +- apps/frontend/src/lib/server-payload.ts | 15 +- packages/core/src/types.ts | 4 + packages/provider-qbittorrent/src/index.ts | 9 +- .../src/qbittorrent.activity.spec.ts | 19 +- .../src/qbittorrent.client.spec.ts | 3 +- .../src/qbittorrent.client.ts | 25 +-- .../src/qbittorrent.cookies.spec.ts | 3 +- .../src/qbittorrent.hardening.spec.ts | 5 +- .../src/qbittorrent.provider.spec.ts | 15 +- .../src/qbittorrent.provider.ts | 76 +++---- .../src/qbittorrent.state.spec.ts | 191 ++++++++++++++++++ .../src/qbittorrent.state.ts | 119 +++++++++++ 23 files changed, 482 insertions(+), 101 deletions(-) create mode 100644 packages/provider-qbittorrent/src/qbittorrent.state.spec.ts create mode 100644 packages/provider-qbittorrent/src/qbittorrent.state.ts diff --git a/.github/agents/my-agent.agent.md b/.github/agents/my-agent.agent.md index 159bab5..b00206d 100644 --- a/.github/agents/my-agent.agent.md +++ b/.github/agents/my-agent.agent.md @@ -36,7 +36,7 @@ This is a pnpm + Turborepo TypeScript monorepo. - `apps/backend`: NestJS 11 API with Drizzle/Postgres, Redis, Socket.IO, Swagger, auth/RBAC, audit logging, retention, ingestion, settings, and proxying. - `apps/frontend`: Next.js 16 App Router UI with React 19, Tailwind 4, Radix UI, React Query, and realtime socket hooks. - `packages/core`: shared provider interfaces and normalized cross-service types. -- `packages/provider-*`: provider adapters for Plex, Jellyfin, Emby, Sonarr, Radarr, Prowlarr, Whisparr, Lidarr, plus shared `provider-arr` base logic. +- `packages/provider-*`: provider adapters for Plex, Jellyfin, Emby, Sonarr, Radarr, Prowlarr, Whisparr, Lidarr, qBittorrent, plus shared `provider-arr` base logic. - `e2e`: Playwright coverage for key UI flows. - `.github/workflows/docker-publish.yml`: CI gates and container publishing. diff --git a/apps/backend/src/modules/proxy/proxy.service.spec.ts b/apps/backend/src/modules/proxy/proxy.service.spec.ts index 7653612..6e4c23a 100644 --- a/apps/backend/src/modules/proxy/proxy.service.spec.ts +++ b/apps/backend/src/modules/proxy/proxy.service.spec.ts @@ -441,6 +441,16 @@ describe('ProxyService', () => { expect(mockHttpService.request).not.toHaveBeenCalled(); }); + it('should reject any server with a stored username regardless of provider', async () => { + configureMockDb(mockDb, { + select: [{ ...qbitServer, providerId: 'other', apiKey: 'pw', username: 'bob' }], + }); + + await expect( + service.proxyRequest('server-qbit', { endpoint: 'x', method: 'GET' }) + ).rejects.toThrow(/cookie session/); + }); + it('should send X-Api-Key for API key setups without username', async () => { configureMockDb(mockDb, { select: [{ ...qbitServer, apiKey: 'qbt-key', username: null }], diff --git a/apps/backend/src/modules/proxy/proxy.service.ts b/apps/backend/src/modules/proxy/proxy.service.ts index cd36a83..5d962b4 100644 --- a/apps/backend/src/modules/proxy/proxy.service.ts +++ b/apps/backend/src/modules/proxy/proxy.service.ts @@ -96,10 +96,12 @@ export class ProxyService { // 1. Get server configuration const server = await this.findUserServer(serverId); - // qBittorrent username/password login uses cookie sessions, which the proxy does not support - if (server.providerId === 'qbittorrent' && server.username) { + // Servers with a stored username authenticate with a cookie session (username/password + // login), which the proxy cannot do. API-key servers work: qBittorrent >= 5.3 accepts the + // key in the X-API-Key header (and as Bearer token); auth-bypass servers need no header. + if (server.username) { throw new BadRequestException( - 'The request proxy does not support qBittorrent username/password login. Use an API key (qBittorrent 5.3+) or authentication bypass.' + 'The request proxy does not support username/password (cookie session) login. Remove the username and use an API key (qBittorrent 5.3+) or authentication bypass.' ); } diff --git a/apps/backend/src/modules/servers/servers.service.spec.ts b/apps/backend/src/modules/servers/servers.service.spec.ts index b731846..0148efb 100644 --- a/apps/backend/src/modules/servers/servers.service.spec.ts +++ b/apps/backend/src/modules/servers/servers.service.spec.ts @@ -1256,6 +1256,8 @@ describe('ServersService', () => { it('registers the qbittorrent provider without session support', () => { const qbit = service.getAvailableProviders().find((p) => p.id === 'qbittorrent'); + expect(qbit?.capabilities.supportsUsername).toBe(true); + expect(qbit?.capabilities.apiKeyOptional).toBe(true); expect(qbit).toBeDefined(); expect(qbit?.capabilities.supportsSessions).toBe(false); @@ -1303,6 +1305,19 @@ describe('ServersService', () => { expect(insertedValues()['username']).toBeNull(); }); + it('derives the optional apiKey rule from the provider capability, not the provider id', async () => { + configureMockDb(mockDb, { insert: [mockServer] }); + service['providers'].set('custom', { + id: 'custom', + name: 'Custom', + capabilities: { apiKeyOptional: true }, + } as any); + + await service.create({ name: 'c', providerId: 'custom', url: 'http://x' } as any); + + expect(insertedValues()['apiKey']).toBe(''); + }); + it.each([undefined, ''])( 'rejects a missing or empty apiKey (%j) for other providers', async (apiKey) => { diff --git a/apps/backend/src/modules/servers/servers.service.ts b/apps/backend/src/modules/servers/servers.service.ts index 2265ff1..a21b9c8 100644 --- a/apps/backend/src/modules/servers/servers.service.ts +++ b/apps/backend/src/modules/servers/servers.service.ts @@ -103,7 +103,7 @@ export class ServersService { throw new Error(`Unknown provider: ${dto.providerId}`); } - if (dto.providerId !== 'qbittorrent' && (dto.apiKey ?? '') === '') { + if (provider.capabilities.apiKeyOptional !== true && (dto.apiKey ?? '') === '') { throw new BadRequestException('API key is required'); } diff --git a/apps/frontend/src/components/edit-server-dialog.tsx b/apps/frontend/src/components/edit-server-dialog.tsx index eef004c..73ab35d 100644 --- a/apps/frontend/src/components/edit-server-dialog.tsx +++ b/apps/frontend/src/components/edit-server-dialog.tsx @@ -50,7 +50,11 @@ import { Input } from '@/components/ui/input'; import { Switch } from '@/components/ui/switch'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; import { useUpdateServer, useTestConnection } from '@/hooks/use-api'; -import { areCredentialsOptional, buildUpdateCredentials } from '@/lib/server-payload'; +import { + areCredentialsOptional, + buildUpdateCredentials, + supportsUsername, +} from '@/lib/server-payload'; import { cn } from '@/lib/utils'; // qBittorrent allows an empty password / API key (authentication bypass) @@ -86,7 +90,7 @@ export function EditServerDialog({ server, open, onOpenChange }: EditServerDialo const [isTesting, setIsTesting] = useState(false); const formSchema = useMemo(() => buildFormSchema(server.providerId), [server.providerId]); - const hasUsername = areCredentialsOptional(server.providerId); + const hasUsername = supportsUsername(server.providerId); const form = useForm({ resolver: zodResolver(formSchema), diff --git a/apps/frontend/src/components/provider-icon.test.tsx b/apps/frontend/src/components/provider-icon.test.tsx index 8089195..6155b69 100644 --- a/apps/frontend/src/components/provider-icon.test.tsx +++ b/apps/frontend/src/components/provider-icon.test.tsx @@ -42,6 +42,23 @@ describe('ProviderIcon', () => { }); }); + describe('qBittorrent provider', () => { + it('should render the dashboard-icons image instead of the default icon', () => { + const { container } = render(); + const img = container.querySelector('img'); + expect(img).not.toBeNull(); + expect(img?.getAttribute('src')).toBe( + 'https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/qbittorrent.svg' + ); + expect(img?.classList.contains('h-8')).toBe(true); + expect(container.querySelector('svg')).toBeNull(); + }); + + it('should have provider metadata', () => { + expect(providerMeta.qbittorrent?.name).toBe('qBittorrent'); + }); + }); + describe('Default icon fallback', () => { it('should render default icon for unknown provider', () => { const { container } = render(); diff --git a/apps/frontend/src/components/provider-icon.tsx b/apps/frontend/src/components/provider-icon.tsx index aec0f90..d4bd768 100644 --- a/apps/frontend/src/components/provider-icon.tsx +++ b/apps/frontend/src/components/provider-icon.tsx @@ -557,6 +557,17 @@ function PlexIcon({ className }: { className?: string }) { ); } +// qBittorrent logo - Uses external CDN icon (dashboard-icons) +function QBittorrentIcon({ className }: { className?: string }) { + return ( + qBittorrent + ); +} + // Emby logo - Uses external CDN icon function EmbyIcon({ className }: { className?: string }) { return ( @@ -618,6 +629,7 @@ const providerIcons: Record> whisparr: WhisparrIcon, lidarr: LidarrIcon, readarr: ReadarrIcon, + qbittorrent: QBittorrentIcon, sqlite: SQLiteIcon, openai: OpenAIIcon, anthropic: AnthropicIcon, @@ -645,6 +657,7 @@ export const providerMeta: Record { expect(byName['password']?.type).toBe('password'); }); - it('should not claim sessions, real-time logs or metrics', () => { + it('should declare credential capabilities and no sessions', () => { const caps = getIntegrationById('qbittorrent')?.capabilities; expect(caps?.sessions).toBe(false); - expect(caps?.realTimeLogs).toBe(false); + // log-file tailing, like the *arr entries + expect(caps?.realTimeLogs).toBe(true); + expect(caps?.supportsUsername).toBe(true); + expect(caps?.apiKeyOptional).toBe(true); }); it('should be found by search', () => { diff --git a/apps/frontend/src/lib/integrations.ts b/apps/frontend/src/lib/integrations.ts index b869ca5..d280509 100644 --- a/apps/frontend/src/lib/integrations.ts +++ b/apps/frontend/src/lib/integrations.ts @@ -69,6 +69,10 @@ export interface Integration { sessions: boolean; webhooks: boolean; metrics: boolean; + /** Authenticates with a username plus password (stored as username + apiKey) */ + supportsUsername?: boolean; + /** The API key / password may be empty (e.g. authentication bypass) */ + apiKeyOptional?: boolean; }; tags: string[]; // For search } @@ -510,11 +514,13 @@ export const integrations: Integration[] = [ ], docsUrl: 'https://github.com/qbittorrent/qBittorrent/wiki/WebUI-API-(qBittorrent-5.0)', capabilities: { - realTimeLogs: false, + realTimeLogs: true, activityLog: true, sessions: false, webhooks: false, metrics: false, + supportsUsername: true, + apiKeyOptional: true, }, tags: ['torrent', 'download', 'p2p', 'bittorrent', 'open-source'], }, diff --git a/apps/frontend/src/lib/server-payload.test.ts b/apps/frontend/src/lib/server-payload.test.ts index 6d611f8..78b416c 100644 --- a/apps/frontend/src/lib/server-payload.test.ts +++ b/apps/frontend/src/lib/server-payload.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect } from 'vitest'; import { areCredentialsOptional, + supportsUsername, buildCreateServerPayload, buildUpdateCredentials, } from './server-payload'; @@ -67,9 +68,13 @@ describe('buildUpdateCredentials', () => { }); }); -describe('areCredentialsOptional', () => { - it('is true only for qbittorrent', () => { +describe('credential capability flags', () => { + it('are read from the integration entry', () => { expect(areCredentialsOptional('qbittorrent')).toBe(true); + expect(supportsUsername('qbittorrent')).toBe(true); expect(areCredentialsOptional('sonarr')).toBe(false); + expect(supportsUsername('sonarr')).toBe(false); + expect(areCredentialsOptional('unknown-provider')).toBe(false); + expect(supportsUsername('unknown-provider')).toBe(false); }); }); diff --git a/apps/frontend/src/lib/server-payload.ts b/apps/frontend/src/lib/server-payload.ts index fce71a3..cfb35f0 100644 --- a/apps/frontend/src/lib/server-payload.ts +++ b/apps/frontend/src/lib/server-payload.ts @@ -1,10 +1,15 @@ -import type { CreateServerDto } from '@/lib/api'; +import { getIntegrationById } from '@/lib/integrations'; -/** Providers whose credentials (username / password / API key) are all optional */ -const OPTIONAL_CREDENTIAL_PROVIDERS = new Set(['qbittorrent']); +import type { CreateServerDto } from '@/lib/api'; +/** The API key / password may be left empty (capability flag from the integration entry) */ export function areCredentialsOptional(providerId: string): boolean { - return OPTIONAL_CREDENTIAL_PROVIDERS.has(providerId); + return getIntegrationById(providerId)?.capabilities.apiKeyOptional === true; +} + +/** The provider authenticates with a username plus password (capability flag) */ +export function supportsUsername(providerId: string): boolean { + return getIntegrationById(providerId)?.capabilities.supportsUsername === true; } /** @@ -37,6 +42,6 @@ export function buildUpdateCredentials( ): { apiKey: string; username?: string } { return { apiKey: data.apiKey, - ...(areCredentialsOptional(providerId) && { username: data.username?.trim() ?? '' }), + ...(supportsUsername(providerId) && { username: data.username?.trim() ?? '' }), }; } diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 71bfc0b..9589609 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -26,6 +26,10 @@ export interface ProviderCapabilities { readonly supportsSessions: boolean; readonly supportsWebhooks: boolean; readonly supportsPlaybackHistory: boolean; + /** Authenticates with a username (stored in the server's username field) plus a password */ + readonly supportsUsername?: boolean; + /** The API key / password may be empty (e.g. WebUI authentication bypass) */ + readonly apiKeyOptional?: boolean; } /** diff --git a/packages/provider-qbittorrent/src/index.ts b/packages/provider-qbittorrent/src/index.ts index 2685cc4..81231aa 100644 --- a/packages/provider-qbittorrent/src/index.ts +++ b/packages/provider-qbittorrent/src/index.ts @@ -1,7 +1,14 @@ // qBittorrent provider for Logarr export { QBittorrentProvider } from './qbittorrent.provider.js'; export { QBittorrentClient } from './qbittorrent.client.js'; -export { QBITTORRENT_LOG_FILE_CONFIG, parseQBittorrentLogLine } from './qbittorrent.parser.js'; +export { + QBITTORRENT_CORRELATION_PATTERNS, + QBITTORRENT_LOG_FILE_CONFIG, + isQBittorrentLogContinuation, + parseQBittorrentLogLine, + parseQBittorrentLogLineWithContext, +} from './qbittorrent.parser.js'; +export { resetQBittorrentState } from './qbittorrent.state.js'; // Types export type { diff --git a/packages/provider-qbittorrent/src/qbittorrent.activity.spec.ts b/packages/provider-qbittorrent/src/qbittorrent.activity.spec.ts index a5023d3..1010f32 100644 --- a/packages/provider-qbittorrent/src/qbittorrent.activity.spec.ts +++ b/packages/provider-qbittorrent/src/qbittorrent.activity.spec.ts @@ -3,7 +3,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { QBittorrentProvider } from './qbittorrent.provider.js'; const mockFetch = vi.fn(); -vi.stubGlobal('fetch', mockFetch); let counter = 0; function uniqueUrl(): string { @@ -63,6 +62,7 @@ describe('QBittorrentProvider activity edge cases', () => { let baseUrl: string; beforeEach(() => { + vi.stubGlobal('fetch', mockFetch); mockFetch.mockReset(); vi.useFakeTimers(); vi.setSystemTime(new Date('2026-10-10T12:00:00Z')); @@ -72,6 +72,7 @@ describe('QBittorrentProvider activity edge cases', () => { }); afterEach(() => { + vi.unstubAllGlobals(); vi.useRealTimers(); vi.restoreAllMocks(); }); @@ -206,8 +207,8 @@ describe('QBittorrentProvider activity edge cases', () => { await expect(provider.getActivity()).resolves.toEqual([]); expect(console.error).toHaveBeenCalledWith( - '[qbittorrent] Failed to fetch log messages:', - expect.stringContaining('HTTP 500') + '[qbittorrent] Failed to get log messages:', + expect.objectContaining({ message: expect.stringContaining('HTTP 500') }) ); // next poll tries the plain cursor again and the resync again @@ -368,10 +369,10 @@ describe('QBittorrentProvider activity edge cases', () => { const activities = await provider.getActivity(); - expect(activities.map((a) => a.type)).toEqual(['TorrentError']); + expect(activities.map((a) => a.type)).toEqual(['torrent_error']); expect(console.error).toHaveBeenCalledWith( - '[qbittorrent] Failed to fetch log messages:', - expect.any(String) + '[qbittorrent] Failed to get log messages:', + expect.any(Error) ); }); @@ -385,10 +386,10 @@ describe('QBittorrentProvider activity edge cases', () => { const activities = await provider.getActivity(); - expect(activities.map((a) => a.type)).toEqual(['Log']); + expect(activities.map((a) => a.type)).toEqual(['log_warning']); expect(console.error).toHaveBeenCalledWith( - '[qbittorrent] Failed to fetch transfer info:', - 'Unexpected response from qBittorrent' + '[qbittorrent] Failed to get connection status:', + expect.objectContaining({ message: 'Unexpected response from qBittorrent' }) ); }); diff --git a/packages/provider-qbittorrent/src/qbittorrent.client.spec.ts b/packages/provider-qbittorrent/src/qbittorrent.client.spec.ts index c3a8928..b093e1e 100644 --- a/packages/provider-qbittorrent/src/qbittorrent.client.spec.ts +++ b/packages/provider-qbittorrent/src/qbittorrent.client.spec.ts @@ -3,7 +3,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { QBittorrentClient } from './qbittorrent.client.js'; const mockFetch = vi.fn(); -vi.stubGlobal('fetch', mockFetch); let counter = 0; /** Unique base URL per test so module-level session/cooldown maps never leak between tests */ @@ -38,12 +37,14 @@ function calls(): RecordedCall[] { describe('QBittorrentClient', () => { beforeEach(() => { + vi.stubGlobal('fetch', mockFetch); mockFetch.mockReset(); vi.useFakeTimers(); vi.setSystemTime(new Date('2026-10-10T12:00:00Z')); }); afterEach(() => { + vi.unstubAllGlobals(); vi.useRealTimers(); }); diff --git a/packages/provider-qbittorrent/src/qbittorrent.client.ts b/packages/provider-qbittorrent/src/qbittorrent.client.ts index 0f1c2be..36a69d7 100644 --- a/packages/provider-qbittorrent/src/qbittorrent.client.ts +++ b/packages/provider-qbittorrent/src/qbittorrent.client.ts @@ -1,5 +1,7 @@ import { createHash } from 'node:crypto'; +import { cooldowns, sessionCache } from './qbittorrent.state.js'; + import type { QBittorrentAuthMode, QBittorrentLogEntry, @@ -9,20 +11,12 @@ import type { const REQUEST_TIMEOUT_MS = 10_000; const API_PREFIX = '/api/v2'; -/** Expired cooldown entries are dropped after this long (they only carry the failure count) */ -const COOLDOWN_RETENTION_MS = 24 * 60 * 60_000; /** Escalating cooldown after authentication failures (qBittorrent bans an IP for 1h after 5) */ const COOLDOWN_STEPS_MS = [5 * 60_000, 15 * 60_000, 60 * 60_000] as const; type CookieJar = Map; -interface Cooldown { - until: number; - message: string; - failures: number; -} - interface RawResponse { status: number; text: string; @@ -35,14 +29,10 @@ interface QBittorrentCredentials { } /** - * Module-level state. Providers are singletons that are connected/disconnected on every poll, - * so sessions and cooldowns must outlive client instances. Keys are a SHA-256 of base URL, - * username and secret, so different servers or credentials never share cookies and no plaintext - * secret is used as a map key. Kept in memory only and never logged. + * Cache keys are a SHA-256 of base URL, username and secret, so different servers or credentials + * never share cookies and no plaintext secret is used as a map key. The caches themselves are + * bounded process-wide state, see qbittorrent.state.ts. */ -const sessionCache = new Map(); -const cooldowns = new Map(); - function hashKey(baseUrl: string, username: string, secret: string): string { return createHash('sha256').update(`${baseUrl}\u0000${username}\u0000${secret}`).digest('hex'); } @@ -241,11 +231,6 @@ export class QBittorrentClient { const failures = (previous?.failures ?? 0) + 1; const step = COOLDOWN_STEPS_MS[Math.min(failures, COOLDOWN_STEPS_MS.length) - 1] as number; const now = Date.now(); - for (const [key, entry] of cooldowns) { - if (entry.until + COOLDOWN_RETENTION_MS < now) { - cooldowns.delete(key); - } - } cooldowns.set(this.key, { until: now + step, message, failures }); return new Error(message); } diff --git a/packages/provider-qbittorrent/src/qbittorrent.cookies.spec.ts b/packages/provider-qbittorrent/src/qbittorrent.cookies.spec.ts index 73fa19f..d97a75d 100644 --- a/packages/provider-qbittorrent/src/qbittorrent.cookies.spec.ts +++ b/packages/provider-qbittorrent/src/qbittorrent.cookies.spec.ts @@ -3,7 +3,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { QBittorrentClient } from './qbittorrent.client.js'; const mockFetch = vi.fn(); -vi.stubGlobal('fetch', mockFetch); let counter = 0; function uniqueUrl(): string { @@ -51,10 +50,12 @@ async function loginAndGetCookie( describe('QBittorrentClient cookie handling', () => { beforeEach(() => { + vi.stubGlobal('fetch', mockFetch); mockFetch.mockReset(); }); afterEach(() => { + vi.unstubAllGlobals(); vi.useRealTimers(); }); diff --git a/packages/provider-qbittorrent/src/qbittorrent.hardening.spec.ts b/packages/provider-qbittorrent/src/qbittorrent.hardening.spec.ts index e68b4f2..d49eb2c 100644 --- a/packages/provider-qbittorrent/src/qbittorrent.hardening.spec.ts +++ b/packages/provider-qbittorrent/src/qbittorrent.hardening.spec.ts @@ -5,7 +5,6 @@ import { QBittorrentProvider } from './qbittorrent.provider.js'; import { redactSecrets, trackerHost } from './qbittorrent.redact.js'; const mockFetch = vi.fn(); -vi.stubGlobal('fetch', mockFetch); let counter = 0; function uniqueUrl(): string { @@ -50,11 +49,13 @@ describe('redactSecrets / trackerHost', () => { describe('QBittorrentClient hardening', () => { beforeEach(() => { + vi.stubGlobal('fetch', mockFetch); mockFetch.mockReset(); vi.useFakeTimers(); vi.setSystemTime(new Date('2026-10-10T12:00:00Z')); }); afterEach(() => { + vi.unstubAllGlobals(); vi.useRealTimers(); }); @@ -143,6 +144,7 @@ describe('QBittorrentProvider hardening', () => { let baseUrl: string; beforeEach(() => { + vi.stubGlobal('fetch', mockFetch); mockFetch.mockReset(); vi.useFakeTimers(); vi.setSystemTime(new Date('2026-10-10T12:00:00Z')); @@ -150,6 +152,7 @@ describe('QBittorrentProvider hardening', () => { baseUrl = uniqueUrl(); }); afterEach(() => { + vi.unstubAllGlobals(); vi.useRealTimers(); }); diff --git a/packages/provider-qbittorrent/src/qbittorrent.provider.spec.ts b/packages/provider-qbittorrent/src/qbittorrent.provider.spec.ts index b233473..97d47a4 100644 --- a/packages/provider-qbittorrent/src/qbittorrent.provider.spec.ts +++ b/packages/provider-qbittorrent/src/qbittorrent.provider.spec.ts @@ -4,7 +4,6 @@ import { QBITTORRENT_LOG_FILE_CONFIG } from './qbittorrent.parser.js'; import { QBittorrentProvider } from './qbittorrent.provider.js'; const mockFetch = vi.fn(); -vi.stubGlobal('fetch', mockFetch); let counter = 0; function uniqueUrl(): string { @@ -41,6 +40,7 @@ describe('QBittorrentProvider', () => { let baseUrl: string; beforeEach(() => { + vi.stubGlobal('fetch', mockFetch); mockFetch.mockReset(); vi.useFakeTimers(); vi.setSystemTime(new Date('2026-10-10T12:00:00Z')); @@ -49,6 +49,7 @@ describe('QBittorrentProvider', () => { }); afterEach(() => { + vi.unstubAllGlobals(); vi.useRealTimers(); vi.restoreAllMocks(); }); @@ -66,6 +67,8 @@ describe('QBittorrentProvider', () => { supportsSessions: false, supportsWebhooks: false, supportsPlaybackHistory: false, + supportsUsername: true, + apiKeyOptional: true, }); }); @@ -144,7 +147,7 @@ describe('QBittorrentProvider', () => { }); it('throws when used before connect', async () => { - await expect(provider.getServerInfo()).rejects.toThrow('not connected'); + await expect(provider.getServerInfo()).rejects.toThrow('Not connected'); await provider.disconnect(); }); }); @@ -180,7 +183,7 @@ describe('QBittorrentProvider', () => { ]); expect(activities.map((a) => a.severity)).toEqual(['warn', 'error']); expect(activities[0]?.timestamp).toEqual(new Date(1_790_000_002_000)); - expect(activities[0]?.type).toBe('Log'); + expect(activities.map((a) => a.type)).toEqual(['log_warning', 'log_critical']); expect(activities[1]?.overview).toBe('I/O error'); }); @@ -329,7 +332,7 @@ describe('QBittorrentProvider', () => { const activities = await provider.getActivity(); - expect(activities.map((a) => a.type)).toEqual(['TorrentError']); + expect(activities.map((a) => a.type)).toEqual(['torrent_error']); expect(errorSpy).toHaveBeenCalled(); }); }); @@ -372,7 +375,7 @@ describe('QBittorrentProvider', () => { ]); expect(second.map((a) => a.id)).toEqual(first.map((a) => a.id)); expect(first[0]).toMatchObject({ - type: 'TorrentError', + type: 'torrent_error', severity: 'error', itemId: 'abc', name: 'Torrent error: Some.Torrent', @@ -397,7 +400,7 @@ describe('QBittorrentProvider', () => { expect(activity).toMatchObject({ id: `qbittorrent-connection-${status}-2026-10-10`, - type: 'ConnectionStatus', + type: 'connection_status', severity, metadata: { connectionStatus: status, dhtNodes: 12 }, }); diff --git a/packages/provider-qbittorrent/src/qbittorrent.provider.ts b/packages/provider-qbittorrent/src/qbittorrent.provider.ts index 89d538d..158df00 100644 --- a/packages/provider-qbittorrent/src/qbittorrent.provider.ts +++ b/packages/provider-qbittorrent/src/qbittorrent.provider.ts @@ -7,6 +7,7 @@ import { parseQBittorrentLogLineWithContext, } from './qbittorrent.parser.js'; import { redactSecrets, trackerHost } from './qbittorrent.redact.js'; +import { connectionStates, logCursors } from './qbittorrent.state.js'; import { QBITTORRENT_LOG_TYPE } from './qbittorrent.types.js'; import type { QBittorrentLogEntry } from './qbittorrent.types.js'; @@ -32,24 +33,6 @@ const INITIAL_LOG_LIMIT = 500; /** How often an empty log response triggers a full resync (detects qBittorrent restarts) */ const LOG_RESYNC_INTERVAL_MS = 10 * 60_000; -interface LogCursor { - lastId: number; - lastTs: number; - fullSyncAt: number; -} - -/** - * Log cursors per qBittorrent instance. Module-level because the provider is a singleton that - * is disconnected after every poll. - */ -const logCursors = new Map(); - -/** - * Last seen connection status and the day it was last reported, per qBittorrent instance. - * Used to only emit connection activities on change (plus once per day while unhealthy). - */ -const connectionStates = new Map(); - function truncate(value: string, max: number): string { return value.length > max ? `${value.slice(0, max - 1)}…` : value; } @@ -80,6 +63,8 @@ export class QBittorrentProvider implements MediaServerProvider { supportsSessions: false, supportsWebhooks: false, supportsPlaybackHistory: false, + supportsUsername: true, + apiKeyOptional: true, }; private config: ProviderConfig | null = null; @@ -117,7 +102,7 @@ export class QBittorrentProvider implements MediaServerProvider { } async getServerInfo(): Promise { - const client = this.requireClient(); + const client = this.getClient(); const version = (await client.getVersion()).trim(); const webApi = (await client.getWebApiVersion()).trim(); return { @@ -172,30 +157,35 @@ export class QBittorrentProvider implements MediaServerProvider { } async getActivity(since?: Date): Promise { - const client = this.requireClient(); + const client = this.getClient(); const activities: NormalizedActivity[] = []; - - // (a) Warning/critical log messages. Skipped when the log file is tailed instead. - if ((this.config?.logPath?.trim() ?? '') === '') { + const errors: unknown[] = []; + let attempted = 0; + + const collect = async ( + source: string, + load: () => Promise + ): Promise => { + attempted++; try { - activities.push(...(await this.fetchLogActivities(client, since))); + activities.push(...(await load())); } catch (error) { - console.error('[qbittorrent] Failed to fetch log messages:', this.describe(error)); + errors.push(error); + console.error(`[${this.id}] Failed to get ${source}:`, error); } - } + }; - // (b) Errored torrents - try { - activities.push(...(await this.fetchTorrentErrors(client))); - } catch (error) { - console.error('[qbittorrent] Failed to fetch errored torrents:', this.describe(error)); + // Warning/critical log messages. Skipped when the log file is tailed instead. + if ((this.config?.logPath?.trim() ?? '') === '') { + await collect('log messages', () => this.fetchLogActivities(client, since)); } + await collect('errored torrents', () => this.fetchTorrentErrors(client)); + await collect('connection status', () => this.fetchConnectionStatus(client)); - // (c) Connection status - try { - activities.push(...(await this.fetchConnectionStatus(client))); - } catch (error) { - console.error('[qbittorrent] Failed to fetch transfer info:', this.describe(error)); + // Partial failures are tolerated; if every source failed, surface it so the server is + // marked unhealthy by the poll + if (errors.length === attempted) { + throw errors[0]; } return activities.sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime()); @@ -267,7 +257,7 @@ export class QBittorrentProvider implements MediaServerProvider { const message = redactSecrets(entry.message); return { id: `qbittorrent-log-${Math.floor(timestampMs / 1000)}-${entry.id}`, - type: 'Log', + type: entry.type === QBITTORRENT_LOG_TYPE.CRITICAL ? 'log_critical' : 'log_warning', name: truncate(message, 120), overview: message, severity: severityOf(entry.type), @@ -285,7 +275,7 @@ export class QBittorrentProvider implements MediaServerProvider { return torrents.map((torrent) => ({ // Day bucket: a persisting/recurring error is re-announced at most once per day id: `qbittorrent-torrent-error-${torrent.hash}-${torrent.state}-${day}`, - type: 'TorrentError', + type: 'torrent_error', name: `Torrent error: ${torrent.name}`, overview: `${torrent.name} is in state "${torrent.state}"` + @@ -327,7 +317,7 @@ export class QBittorrentProvider implements MediaServerProvider { return [ { id: `qbittorrent-connection-${status}-${day}`, - type: 'ConnectionStatus', + type: 'connection_status', name: `qBittorrent connection status: ${status}`, overview: `qBittorrent reports it is ${status} (check port forwarding / network binding)`, severity: status === 'disconnected' ? 'error' : 'warn', @@ -340,14 +330,10 @@ export class QBittorrentProvider implements MediaServerProvider { ]; } - private requireClient(): QBittorrentClient { + private getClient(): QBittorrentClient { if (!this.client) { - throw new Error('qBittorrent provider is not connected'); + throw new Error('Not connected'); } return this.client; } - - private describe(error: unknown): string { - return error instanceof Error ? error.message : 'Unknown error'; - } } diff --git a/packages/provider-qbittorrent/src/qbittorrent.state.spec.ts b/packages/provider-qbittorrent/src/qbittorrent.state.spec.ts new file mode 100644 index 0000000..a9e13c6 --- /dev/null +++ b/packages/provider-qbittorrent/src/qbittorrent.state.spec.ts @@ -0,0 +1,191 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { QBittorrentClient } from './qbittorrent.client.js'; +import { QBittorrentProvider } from './qbittorrent.provider.js'; +import { + BoundedCache, + connectionStates, + logCursors, + resetQBittorrentState, + sessionCache, +} from './qbittorrent.state.js'; + +const mockFetch = vi.fn(); + +describe('BoundedCache', () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-10-10T12:00:00Z')); + }); + afterEach(() => { + vi.useRealTimers(); + }); + + it('stores and returns values', () => { + const cache = new BoundedCache(3, 1000); + cache.set('a', 1); + expect(cache.get('a')).toBe(1); + expect(cache.has('a')).toBe(true); + expect(cache.get('missing')).toBeUndefined(); + }); + + it('evicts the least recently used entry beyond the size cap', () => { + const cache = new BoundedCache(2, 60_000); + cache.set('a', 1); + cache.set('b', 2); + cache.get('a'); // a is now more recent than b + cache.set('c', 3); + + expect(cache.size).toBe(2); + expect(cache.get('b')).toBeUndefined(); + expect(cache.get('a')).toBe(1); + expect(cache.get('c')).toBe(3); + }); + + it('expires idle entries after the TTL and refreshes the TTL on access', () => { + const cache = new BoundedCache(10, 1000); + cache.set('a', 1); + cache.set('b', 2); + + vi.advanceTimersByTime(800); + expect(cache.get('a')).toBe(1); // refreshed + vi.advanceTimersByTime(800); + + expect(cache.get('a')).toBe(1); + expect(cache.get('b')).toBeUndefined(); + vi.advanceTimersByTime(1001); + expect(cache.size).toBe(0); + }); + + it('supports delete and clear', () => { + const cache = new BoundedCache(10, 1000); + cache.set('a', 1); + cache.set('b', 2); + cache.delete('a'); + expect(cache.has('a')).toBe(false); + cache.clear(); + expect(cache.size).toBe(0); + }); +}); + +describe('qBittorrent process-wide state', () => { + beforeEach(() => { + vi.stubGlobal('fetch', mockFetch); + mockFetch.mockReset(); + resetQBittorrentState(); + }); + afterEach(() => { + vi.unstubAllGlobals(); + resetQBittorrentState(); + }); + + it('bounds the session cache when many servers log in', async () => { + mockFetch.mockImplementation((url: unknown) => + Promise.resolve( + String(url).endsWith('/auth/login') + ? new Response('Ok.', { headers: { 'set-cookie': 'SID=x' } }) + : new Response('v5.2.4') + ) + ); + + for (let i = 0; i < 130; i++) { + await new QBittorrentClient(`http://bounded-${i}.local:8080`, { + username: 'u', + secret: 'p', + }).getVersion(); + } + + expect(sessionCache.size).toBe(100); + }); + + it('bounds log cursors and connection states', async () => { + mockFetch.mockImplementation((url: unknown) => { + const path = new URL(String(url)).pathname; + if (path.endsWith('/log/main')) return Promise.resolve(new Response('[]')); + if (path.endsWith('/transfer/info')) + return Promise.resolve(new Response('{"connection_status":"connected"}')); + if (path.endsWith('/torrents/info')) return Promise.resolve(new Response('[]')); + return Promise.resolve(new Response(path.endsWith('version') ? 'v5.2.4' : '2.15.1')); + }); + + for (let i = 0; i < 120; i++) { + const provider = new QBittorrentProvider(); + await provider.connect({ url: `http://cursor-${i}.local:8080`, apiKey: '' }); + await provider.getActivity(); + } + + expect(logCursors.size).toBe(100); + expect(connectionStates.size).toBe(100); + }); + + it('resetQBittorrentState clears everything', () => { + sessionCache.set('a', new Map()); + logCursors.set('a', { lastId: 1, lastTs: 1, fullSyncAt: 1 }); + resetQBittorrentState(); + expect(sessionCache.size).toBe(0); + expect(logCursors.size).toBe(0); + }); +}); + +describe('QBittorrentProvider.getActivity error policy', () => { + beforeEach(() => { + vi.stubGlobal('fetch', mockFetch); + mockFetch.mockReset(); + resetQBittorrentState(); + vi.spyOn(console, 'error').mockImplementation(() => undefined); + }); + afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it('throws "Not connected" before connect', async () => { + await expect(new QBittorrentProvider().getActivity()).rejects.toThrow('Not connected'); + }); + + it('rethrows the first error when every source fails', async () => { + const provider = new QBittorrentProvider(); + let healthy = true; + mockFetch.mockImplementation((url: unknown) => { + const path = new URL(String(url)).pathname; + if (healthy) { + return Promise.resolve(new Response(path.endsWith('version') ? 'v5.2.4' : '2.15.1')); + } + return Promise.resolve(new Response('boom', { status: 500 })); + }); + await provider.connect({ url: 'http://allfail.local:8080', apiKey: '' }); + healthy = false; + + await expect(provider.getActivity()).rejects.toThrow('qBittorrent API error: HTTP 500'); + expect(console.error).toHaveBeenCalledWith( + '[qbittorrent] Failed to get log messages:', + expect.any(Error) + ); + expect(console.error).toHaveBeenCalledWith( + '[qbittorrent] Failed to get errored torrents:', + expect.any(Error) + ); + expect(console.error).toHaveBeenCalledWith( + '[qbittorrent] Failed to get connection status:', + expect.any(Error) + ); + }); + + it('counts only attempted sources: with a logPath, two failures are a total failure', async () => { + const provider = new QBittorrentProvider(); + let healthy = true; + mockFetch.mockImplementation((url: unknown) => { + const path = new URL(String(url)).pathname; + return Promise.resolve( + healthy + ? new Response(path.endsWith('version') ? 'v5.2.4' : '2.15.1') + : new Response('boom', { status: 500 }) + ); + }); + await provider.connect({ url: 'http://allfail2.local:8080', apiKey: '', logPath: '/logs' }); + healthy = false; + + await expect(provider.getActivity()).rejects.toThrow('HTTP 500'); + expect(console.error).toHaveBeenCalledTimes(2); + }); +}); diff --git a/packages/provider-qbittorrent/src/qbittorrent.state.ts b/packages/provider-qbittorrent/src/qbittorrent.state.ts new file mode 100644 index 0000000..ef1bae9 --- /dev/null +++ b/packages/provider-qbittorrent/src/qbittorrent.state.ts @@ -0,0 +1,119 @@ +/** + * Process-wide state of the qBittorrent provider. + * + * Providers are singletons that connect/disconnect on every poll, so sessions, login cooldowns, + * log cursors and connection states must outlive client instances. All of it is kept in bounded + * caches (size cap with oldest-first eviction plus an idle TTL) so it cannot grow without limit + * when servers are added, changed or deleted. + */ + +/** Maximum number of entries per cache (far above any realistic number of qBittorrent servers) */ +const MAX_ENTRIES = 100; +const HOUR_MS = 60 * 60_000; + +interface Entry { + value: V; + expires: number; +} + +/** + * Small Map-like cache with a size cap (least recently used entry is evicted first) and a TTL + * that is refreshed on access. Uses Date.now() so it works with fake timers. + */ +export class BoundedCache { + private readonly entries = new Map>(); + + constructor( + private readonly maxSize: number, + private readonly ttlMs: number + ) {} + + get(key: string): V | undefined { + const entry = this.entries.get(key); + if (entry === undefined) { + return undefined; + } + if (entry.expires <= Date.now()) { + this.entries.delete(key); + return undefined; + } + // Refresh recency and TTL + this.entries.delete(key); + this.entries.set(key, { value: entry.value, expires: Date.now() + this.ttlMs }); + return entry.value; + } + + has(key: string): boolean { + return this.get(key) !== undefined; + } + + set(key: string, value: V): void { + this.entries.delete(key); + this.entries.set(key, { value, expires: Date.now() + this.ttlMs }); + this.prune(); + } + + delete(key: string): void { + this.entries.delete(key); + } + + clear(): void { + this.entries.clear(); + } + + /** Number of stored entries (expired ones are removed first) */ + get size(): number { + this.prune(); + return this.entries.size; + } + + private prune(): void { + const now = Date.now(); + for (const [key, entry] of this.entries) { + if (entry.expires <= now) { + this.entries.delete(key); + } + } + while (this.entries.size > this.maxSize) { + const oldest = this.entries.keys().next(); + if (oldest.done === true) { + break; + } + this.entries.delete(oldest.value); + } + } +} + +export interface Cooldown { + until: number; + message: string; + failures: number; +} + +export interface LogCursor { + lastId: number; + lastTs: number; + fullSyncAt: number; +} + +export interface ConnectionState { + status: string; + reportedDay: string | null; +} + +/** Session cookies per (url, username, secret) hash */ +export const sessionCache = new BoundedCache>(MAX_ENTRIES, 12 * HOUR_MS); +/** Login failure cooldowns per (url, username, secret) hash; they also carry the failure count */ +export const cooldowns = new BoundedCache(MAX_ENTRIES, 24 * HOUR_MS); +/** Log cursors per qBittorrent base URL */ +export const logCursors = new BoundedCache(MAX_ENTRIES, 7 * 24 * HOUR_MS); +/** Last reported connection status per qBittorrent base URL */ +export const connectionStates = new BoundedCache(MAX_ENTRIES, 7 * 24 * HOUR_MS); + +/** Clear all cached state (used by tests) */ +export function resetQBittorrentState(): void { + sessionCache.clear(); + cooldowns.clear(); + logCursors.clear(); + connectionStates.clear(); +} From 1d5f93b9d297edfa280f29d3bae7490d8bfa7843 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=A1clav=20Brzezina?= Date: Sat, 10 Oct 2026 20:41:42 +0200 Subject: [PATCH 04/12] fix(provider-qbittorrent): gate username by capability and normalise ids - servers.service only stores/updates username for providers with supportsUsername - seed derives credentials-optional from the registered provider's apiKeyOptional capability - torrent-error id uses kebab-case state (missing-files) - provider-neutral proxy guard message; fix stale server-payload comment --- .../src/modules/proxy/proxy.service.ts | 2 +- .../servers/server-seed.service.spec.ts | 21 ++++++++++++++- .../modules/servers/server-seed.service.ts | 15 +++++++---- .../modules/servers/servers.service.spec.ts | 26 +++++++++++++++++++ .../src/modules/servers/servers.service.ts | 15 ++++++++--- apps/frontend/src/lib/server-payload.ts | 2 +- .../src/qbittorrent.activity.spec.ts | 2 +- .../src/qbittorrent.provider.spec.ts | 2 +- .../src/qbittorrent.provider.ts | 7 ++++- 9 files changed, 78 insertions(+), 14 deletions(-) diff --git a/apps/backend/src/modules/proxy/proxy.service.ts b/apps/backend/src/modules/proxy/proxy.service.ts index 5d962b4..42dc095 100644 --- a/apps/backend/src/modules/proxy/proxy.service.ts +++ b/apps/backend/src/modules/proxy/proxy.service.ts @@ -101,7 +101,7 @@ export class ProxyService { // key in the X-API-Key header (and as Bearer token); auth-bypass servers need no header. if (server.username) { throw new BadRequestException( - 'The request proxy does not support username/password (cookie session) login. Remove the username and use an API key (qBittorrent 5.3+) or authentication bypass.' + 'The request proxy does not support servers that use username/password login (cookie session). Use an API key (qBittorrent 5.3+) or authentication bypass instead.' ); } diff --git a/apps/backend/src/modules/servers/server-seed.service.spec.ts b/apps/backend/src/modules/servers/server-seed.service.spec.ts index e02f760..dd8dfb9 100644 --- a/apps/backend/src/modules/servers/server-seed.service.spec.ts +++ b/apps/backend/src/modules/servers/server-seed.service.spec.ts @@ -17,6 +17,7 @@ describe('ServerSeedService - qBittorrent', () => { let mockDb: MockDb; let service: ServerSeedService; const originalEnv: Record = {}; + let providerList: Array<{ id: string; capabilities: Record }>; const insertedRows = (): Array> => mockDb.insert.mock.results.map( @@ -28,6 +29,10 @@ describe('ServerSeedService - qBittorrent', () => { ); beforeEach(() => { + providerList = [ + { id: 'qbittorrent', capabilities: { apiKeyOptional: true, supportsUsername: true } }, + { id: 'jellyfin', capabilities: {} }, + ]; for (const key of SEEDED_ENV_KEYS) { originalEnv[key] = process.env[key]; delete process.env[key]; @@ -41,7 +46,12 @@ describe('ServerSeedService - qBittorrent', () => { } mockDb = createMockDb(); configureMockDb(mockDb, { select: [] }); - service = new ServerSeedService(mockDb as never); + service = new ServerSeedService( + mockDb as never, + { + getAvailableProviders: () => providerList, + } as never + ); }); afterEach(() => { @@ -115,6 +125,15 @@ describe('ServerSeedService - qBittorrent', () => { expect(mockDb.insert).not.toHaveBeenCalled(); }); + it('requires an API key when the provider does not declare apiKeyOptional', async () => { + providerList = [{ id: 'qbittorrent', capabilities: {} }]; + process.env['QBITTORRENT_URL'] = 'http://qbit:8080'; + + await service.onModuleInit(); + + expect(mockDb.insert).not.toHaveBeenCalled(); + }); + it('still requires an API key for other providers', async () => { process.env['JELLYFIN_URL'] = 'http://jf:8096'; diff --git a/apps/backend/src/modules/servers/server-seed.service.ts b/apps/backend/src/modules/servers/server-seed.service.ts index bdda652..1bdf922 100644 --- a/apps/backend/src/modules/servers/server-seed.service.ts +++ b/apps/backend/src/modules/servers/server-seed.service.ts @@ -4,6 +4,8 @@ import { eq } from 'drizzle-orm'; import { DATABASE_CONNECTION } from '../../database'; import * as schema from '../../database/schema'; +import { ServersService } from './servers.service'; + import type { PostgresJsDatabase } from 'drizzle-orm/postgres-js'; /** @@ -17,8 +19,6 @@ interface EnvServerConfig { apiKeyEnvKey: string; /** Optional env var holding a login username */ usernameEnvKey?: string; - /** When true only the URL is required (credentials may be empty, e.g. auth bypass) */ - credentialsOptional?: boolean; providerId: string; name: string; /** Container path for log files (used in Docker) */ @@ -86,7 +86,6 @@ const ENV_SERVER_CONFIGS: EnvServerConfig[] = [ urlEnvKey: 'QBITTORRENT_URL', usernameEnvKey: 'QBITTORRENT_USERNAME', apiKeyEnvKey: 'QBITTORRENT_PASSWORD', - credentialsOptional: true, providerId: 'qbittorrent', name: 'qBittorrent', containerLogPath: '/qbittorrent-logs', @@ -99,7 +98,8 @@ export class ServerSeedService implements OnModuleInit { constructor( @Inject(DATABASE_CONNECTION) - private readonly db: PostgresJsDatabase + private readonly db: PostgresJsDatabase, + private readonly serversService: ServersService ) {} async onModuleInit() { @@ -121,6 +121,11 @@ export class ServerSeedService implements OnModuleInit { let seededCount = 0; for (const config of ENV_SERVER_CONFIGS) { + // Whether only the URL is required (credentials may be empty, e.g. auth bypass) comes from + // the provider's capabilities + const apiKeyOptional = + this.serversService.getAvailableProviders().find((p) => p.id === config.providerId) + ?.capabilities.apiKeyOptional === true; const url = process.env[config.urlEnvKey]; // Not trimmed for providers with a password: a leading/trailing space may be intentional, // and the UI stores the value verbatim too. Only the emptiness check ignores whitespace. @@ -134,7 +139,7 @@ export class ServerSeedService implements OnModuleInit { this.logger.log(`Checking ${config.providerId}: URL=${!!url}, API key=${!!apiKey}`); // Skip if URL (or, unless credentials are optional, the API key) is not set - if (!url || url.trim() === '' || (!config.credentialsOptional && apiKey.trim() === '')) { + if (!url || url.trim() === '' || (!apiKeyOptional && apiKey.trim() === '')) { this.logger.log(`Skipping ${config.providerId}: missing URL or API key`); continue; } diff --git a/apps/backend/src/modules/servers/servers.service.spec.ts b/apps/backend/src/modules/servers/servers.service.spec.ts index 0148efb..5a9f429 100644 --- a/apps/backend/src/modules/servers/servers.service.spec.ts +++ b/apps/backend/src/modules/servers/servers.service.spec.ts @@ -1254,6 +1254,32 @@ describe('ServersService', () => { return query.set.mock.calls[0]![0] as Record; }; + it('does not store a username for providers without supportsUsername (create)', async () => { + configureMockDb(mockDb, { insert: [mockServer] }); + + await service.create({ + name: 'Sonarr', + providerId: 'sonarr', + url: 'http://localhost:8989', + apiKey: 'key', + username: 'admin', + }); + + expect(insertedValues()['username']).toBeNull(); + }); + + it('ignores a username update for providers without supportsUsername', async () => { + configureMockDb(mockDb, { + select: [{ ...mockServer, providerId: 'sonarr' }], + update: [mockServer], + }); + + await service.update('server-1', { username: 'admin', name: 'Renamed' }); + + expect(updatedValues()).not.toHaveProperty('username'); + expect(updatedValues()['name']).toBe('Renamed'); + }); + it('registers the qbittorrent provider without session support', () => { const qbit = service.getAvailableProviders().find((p) => p.id === 'qbittorrent'); expect(qbit?.capabilities.supportsUsername).toBe(true); diff --git a/apps/backend/src/modules/servers/servers.service.ts b/apps/backend/src/modules/servers/servers.service.ts index a21b9c8..59937ea 100644 --- a/apps/backend/src/modules/servers/servers.service.ts +++ b/apps/backend/src/modules/servers/servers.service.ts @@ -113,9 +113,14 @@ export class ServersService { name: dto.name, providerId: dto.providerId, url: dto.url, - // apiKey may be empty only for qBittorrent (authentication bypass); the column is NOT NULL + // apiKey may be empty only for providers with apiKeyOptional (e.g. qBittorrent auth bypass); + // the column is NOT NULL apiKey: dto.apiKey ?? '', - username: normalizeUsername(dto.username) ?? null, + // Only providers that authenticate with a username may store one + username: + provider.capabilities.supportsUsername === true + ? (normalizeUsername(dto.username) ?? null) + : null, logPath: dto.logPath, fileIngestionEnabled: dto.fileIngestionEnabled ?? false, logPaths: dto.logPaths, @@ -141,7 +146,11 @@ export class ServersService { // An empty username clears it (stored as NULL) const { username, ...rest } = dto; - const normalizedUsername = normalizeUsername(username); + // Ignored for providers that do not authenticate with a username + const normalizedUsername = + this.providers.get(currentServer.providerId)?.capabilities.supportsUsername === true + ? normalizeUsername(username) + : undefined; const result = await this.db .update(schema.servers) diff --git a/apps/frontend/src/lib/server-payload.ts b/apps/frontend/src/lib/server-payload.ts index cfb35f0..96dc2d2 100644 --- a/apps/frontend/src/lib/server-payload.ts +++ b/apps/frontend/src/lib/server-payload.ts @@ -14,7 +14,7 @@ export function supportsUsername(providerId: string): boolean { /** * Build the create-server payload from the dynamic "add source" form values. - * The password field is stored in apiKey (it may be empty for qBittorrent). + * The password field is stored in apiKey (it may be empty for providers with the apiKeyOptional capability). */ export function buildCreateServerPayload( providerId: string, diff --git a/packages/provider-qbittorrent/src/qbittorrent.activity.spec.ts b/packages/provider-qbittorrent/src/qbittorrent.activity.spec.ts index 1010f32..d96c39a 100644 --- a/packages/provider-qbittorrent/src/qbittorrent.activity.spec.ts +++ b/packages/provider-qbittorrent/src/qbittorrent.activity.spec.ts @@ -446,7 +446,7 @@ describe('QBittorrentProvider activity edge cases', () => { const [missing] = await provider.getActivity(); expect(error?.id).toBe('qbittorrent-torrent-error-abc-error-2026-10-10'); - expect(missing?.id).toBe('qbittorrent-torrent-error-abc-missingFiles-2026-10-10'); + expect(missing?.id).toBe('qbittorrent-torrent-error-abc-missing-files-2026-10-10'); }); it('emits nothing once the torrent is no longer errored', async () => { diff --git a/packages/provider-qbittorrent/src/qbittorrent.provider.spec.ts b/packages/provider-qbittorrent/src/qbittorrent.provider.spec.ts index 97d47a4..eae26ee 100644 --- a/packages/provider-qbittorrent/src/qbittorrent.provider.spec.ts +++ b/packages/provider-qbittorrent/src/qbittorrent.provider.spec.ts @@ -370,7 +370,7 @@ describe('QBittorrentProvider', () => { const second = await provider.getActivity(); expect(first.map((a) => a.id)).toEqual([ - 'qbittorrent-torrent-error-abc-missingFiles-2026-10-10', + 'qbittorrent-torrent-error-abc-missing-files-2026-10-10', 'qbittorrent-torrent-error-def-error-2026-10-10', ]); expect(second.map((a) => a.id)).toEqual(first.map((a) => a.id)); diff --git a/packages/provider-qbittorrent/src/qbittorrent.provider.ts b/packages/provider-qbittorrent/src/qbittorrent.provider.ts index 158df00..4c9e4e7 100644 --- a/packages/provider-qbittorrent/src/qbittorrent.provider.ts +++ b/packages/provider-qbittorrent/src/qbittorrent.provider.ts @@ -33,6 +33,11 @@ const INITIAL_LOG_LIMIT = 500; /** How often an empty log response triggers a full resync (detects qBittorrent restarts) */ const LOG_RESYNC_INTERVAL_MS = 10 * 60_000; +/** missingFiles -> missing-files */ +function kebab(value: string): string { + return value.replace(/([a-z0-9])([A-Z])/g, '$1-$2').toLowerCase(); +} + function truncate(value: string, max: number): string { return value.length > max ? `${value.slice(0, max - 1)}…` : value; } @@ -274,7 +279,7 @@ export class QBittorrentProvider implements MediaServerProvider { const day = timestamp.toISOString().slice(0, 10); return torrents.map((torrent) => ({ // Day bucket: a persisting/recurring error is re-announced at most once per day - id: `qbittorrent-torrent-error-${torrent.hash}-${torrent.state}-${day}`, + id: `qbittorrent-torrent-error-${torrent.hash}-${kebab(torrent.state)}-${day}`, type: 'torrent_error', name: `Torrent error: ${torrent.name}`, overview: From eabdc9656e98fe1e42b8a39b0b2de2848d1360c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=A1clav=20Brzezina?= Date: Mon, 12 Oct 2026 18:53:22 +0200 Subject: [PATCH 05/12] fix(provider-qbittorrent): accept 204 No Content login response (qBittorrent 5.2) qBittorrent 5.2 answers a successful POST /auth/login with 204 and an empty body plus the session cookie. Success is now any 2xx with body 'Ok.' or an empty body with a cookie set; 'Fails.' still escalates the cooldown and other bodies stay an unexpected-response error. --- .../src/qbittorrent.client.ts | 11 +- .../src/qbittorrent.login-response.spec.ts | 145 ++++++++++++++++++ 2 files changed, 153 insertions(+), 3 deletions(-) create mode 100644 packages/provider-qbittorrent/src/qbittorrent.login-response.spec.ts diff --git a/packages/provider-qbittorrent/src/qbittorrent.client.ts b/packages/provider-qbittorrent/src/qbittorrent.client.ts index 36a69d7..8f1220f 100644 --- a/packages/provider-qbittorrent/src/qbittorrent.client.ts +++ b/packages/provider-qbittorrent/src/qbittorrent.client.ts @@ -193,17 +193,22 @@ export class QBittorrentClient { form: { username: this.username, password: this.secret }, }); - if (response.status === 200 && response.text.trim() === 'Ok.') { + const success = response.status >= 200 && response.status < 300; + const body = response.text.trim(); + + // qBittorrent < 5.2 answers 200 "Ok."; newer versions answer 204 No Content (empty body) with + // the session cookie. "Ok." without a cookie is auth bypass for our IP. + if (success && (body === 'Ok.' || (body === '' && this.jar.size > 0))) { cooldowns.delete(this.key); this.sessionReady = true; sessionCache.set(this.key, new Map(this.jar)); return; } - if (response.status === 200 && response.text.trim() === 'Fails.') { + if (response.status === 200 && body === 'Fails.') { throw this.recordFailure('Invalid qBittorrent username or password'); } - if (response.status === 200) { + if (success) { // Not counted as a credential failure: probably not qBittorrent (e.g. an auth proxy page) throw new Error( 'Unexpected login response — is this a qBittorrent WebUI (or behind an auth proxy)?' diff --git a/packages/provider-qbittorrent/src/qbittorrent.login-response.spec.ts b/packages/provider-qbittorrent/src/qbittorrent.login-response.spec.ts new file mode 100644 index 0000000..9377d30 --- /dev/null +++ b/packages/provider-qbittorrent/src/qbittorrent.login-response.spec.ts @@ -0,0 +1,145 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { QBittorrentClient } from './qbittorrent.client.js'; +import { QBittorrentProvider } from './qbittorrent.provider.js'; +import { resetQBittorrentState } from './qbittorrent.state.js'; + +const mockFetch = vi.fn(); + +let counter = 0; +function uniqueUrl(): string { + counter += 1; + return `http://login-resp-${counter}.local:8080`; +} + +function cookieOfCall(index: number): string | undefined { + const init = mockFetch.mock.calls[index]?.[1] as RequestInit | undefined; + return (init?.headers as Record | undefined)?.['Cookie']; +} + +/** qBittorrent 5.2.x: 204 No Content, empty body, session cookie */ +function login204(): Response { + return new Response(null, { + status: 204, + headers: { 'set-cookie': 'QBT_SID_8080=abc123; HttpOnly; SameSite=Strict; path=/' }, + }); +} + +describe('login response variants', () => { + beforeEach(() => { + vi.stubGlobal('fetch', mockFetch); + mockFetch.mockReset(); + resetQBittorrentState(); + }); + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('accepts 204 No Content with a session cookie (qBittorrent 5.2.3) and sends the cookie', async () => { + mockFetch.mockResolvedValueOnce(login204()).mockResolvedValueOnce(new Response('v5.2.3')); + const client = new QBittorrentClient(uniqueUrl(), { username: 'admin', secret: 'pw' }); + + await expect(client.getVersion()).resolves.toBe('v5.2.3'); + + expect(mockFetch).toHaveBeenCalledTimes(2); + expect(cookieOfCall(1)).toBe('QBT_SID_8080=abc123'); + }); + + it('reuses the 204 session without logging in again', async () => { + mockFetch + .mockResolvedValueOnce(login204()) + .mockResolvedValueOnce(new Response('v5.2.3')) + .mockResolvedValueOnce(new Response('2.15.1')); + const client = new QBittorrentClient(uniqueUrl(), { username: 'admin', secret: 'pw' }); + + await client.getVersion(); + await client.getWebApiVersion(); + + expect( + mockFetch.mock.calls.filter(([url]) => String(url).endsWith('/auth/login')) + ).toHaveLength(1); + expect(cookieOfCall(2)).toBe('QBT_SID_8080=abc123'); + }); + + it('accepts 200 with an empty body and a session cookie', async () => { + mockFetch + .mockResolvedValueOnce(new Response('', { status: 200, headers: { 'set-cookie': 'SID=x' } })) + .mockResolvedValueOnce(new Response('v5.2.4')); + const client = new QBittorrentClient(uniqueUrl(), { username: 'admin', secret: 'pw' }); + + await expect(client.getVersion()).resolves.toBe('v5.2.4'); + expect(cookieOfCall(1)).toBe('SID=x'); + }); + + it('still accepts 200 "Ok." with a cookie', async () => { + mockFetch + .mockResolvedValueOnce(new Response('Ok.', { headers: { 'set-cookie': 'SID=y' } })) + .mockResolvedValueOnce(new Response('v4.6.7')); + const client = new QBittorrentClient(uniqueUrl(), { username: 'admin', secret: 'pw' }); + + await expect(client.getVersion()).resolves.toBe('v4.6.7'); + }); + + it('rejects an empty 2xx login response without a cookie as unexpected (no escalation)', async () => { + const base = uniqueUrl(); + const creds = { username: 'admin', secret: 'pw' }; + mockFetch.mockResolvedValueOnce(new Response(null, { status: 204 })); + await expect(new QBittorrentClient(base, creds).getVersion()).rejects.toThrow( + 'Unexpected login response' + ); + + // no cooldown: the next attempt reaches the server + mockFetch.mockResolvedValueOnce(login204()).mockResolvedValueOnce(new Response('v5.2.3')); + await expect(new QBittorrentClient(base, creds).getVersion()).resolves.toBe('v5.2.3'); + }); + + it('still treats 200 "Fails." as bad credentials with cooldown escalation', async () => { + const base = uniqueUrl(); + const creds = { username: 'admin', secret: 'wrong' }; + mockFetch.mockResolvedValueOnce(new Response('Fails.')); + await expect(new QBittorrentClient(base, creds).getVersion()).rejects.toThrow( + 'Invalid qBittorrent username or password' + ); + await expect(new QBittorrentClient(base, creds).getVersion()).rejects.toThrow(/retrying in/); + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + + it('still rejects a non-empty unexpected 2xx body without escalation', async () => { + mockFetch.mockResolvedValueOnce(new Response('proxy', { status: 200 })); + await expect( + new QBittorrentClient(uniqueUrl(), { username: 'a', secret: 'b' }).getVersion() + ).rejects.toThrow('Unexpected login response'); + }); + + it('does not treat a 204 login with a body-less non-2xx as success', async () => { + mockFetch.mockResolvedValueOnce(new Response(null, { status: 304 })); + await expect( + new QBittorrentClient(uniqueUrl(), { username: 'a', secret: 'b' }).getVersion() + ).rejects.toThrow(); + }); + + it('API key and bypass paths accept any 2xx without requiring a body', async () => { + mockFetch.mockResolvedValueOnce(new Response(null, { status: 204 })); + await expect(new QBittorrentClient(uniqueUrl(), { secret: 'key' }).getVersion()).resolves.toBe( + '' + ); + mockFetch.mockResolvedValueOnce(new Response(null, { status: 204 })); + await expect(new QBittorrentClient(uniqueUrl(), { secret: '' }).getVersion()).resolves.toBe(''); + }); + + it('connects and tests the connection through the provider with a 204 login', async () => { + mockFetch.mockImplementation((url: unknown) => { + const path = new URL(String(url)).pathname; + if (path.endsWith('/auth/login')) return Promise.resolve(login204()); + return Promise.resolve(new Response(path.endsWith('version') ? 'v5.2.3' : '2.15.1')); + }); + const provider = new QBittorrentProvider(); + + await provider.connect({ url: uniqueUrl(), apiKey: 'pw', username: 'admin' }); + + await expect(provider.testConnection()).resolves.toEqual({ + connected: true, + serverInfo: { name: 'qBittorrent', version: '5.2.3', id: 'qbittorrent-webapi-2.15.1' }, + }); + }); +}); From f30dbf645813220eb6c7a2fb07186ae5c6d2b860 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=A1clav=20Brzezina?= Date: Mon, 12 Oct 2026 18:54:03 +0200 Subject: [PATCH 06/12] fix(provider-qbittorrent): require a fresh session cookie on login Clear the cookie jar before POST /auth/login so an empty 2xx response only counts as success when this response set a new session cookie. --- .../src/qbittorrent.client.ts | 4 ++++ .../src/qbittorrent.login-response.spec.ts | 17 +++++++++++++++++ 2 files changed, 21 insertions(+) diff --git a/packages/provider-qbittorrent/src/qbittorrent.client.ts b/packages/provider-qbittorrent/src/qbittorrent.client.ts index 8f1220f..b226450 100644 --- a/packages/provider-qbittorrent/src/qbittorrent.client.ts +++ b/packages/provider-qbittorrent/src/qbittorrent.client.ts @@ -189,6 +189,10 @@ export class QBittorrentClient { throw new Error(`${cooldown.message} (retrying in ${seconds}s)`); } + // Success requires a cookie set by THIS response, so drop any stale session first + this.jar = new Map(); + this.sessionReady = false; + const response = await this.send('POST', '/auth/login', { form: { username: this.username, password: this.secret }, }); diff --git a/packages/provider-qbittorrent/src/qbittorrent.login-response.spec.ts b/packages/provider-qbittorrent/src/qbittorrent.login-response.spec.ts index 9377d30..304ff26 100644 --- a/packages/provider-qbittorrent/src/qbittorrent.login-response.spec.ts +++ b/packages/provider-qbittorrent/src/qbittorrent.login-response.spec.ts @@ -93,6 +93,23 @@ describe('login response variants', () => { await expect(new QBittorrentClient(base, creds).getVersion()).resolves.toBe('v5.2.3'); }); + it('requires a fresh cookie: a stale jar entry plus an empty 204 is not a successful login', async () => { + const base = uniqueUrl(); + const creds = { username: 'admin', secret: 'pw' }; + const client = new QBittorrentClient(base, creds); + (client as unknown as { jar: Map }).jar.set('QBT_SID_8080', 'stale'); + mockFetch.mockResolvedValueOnce(new Response(null, { status: 204 })); + + await expect((client as unknown as { login: () => Promise }).login()).rejects.toThrow( + 'Unexpected login response' + ); + + // the stale cookie was not sent with the login request and no cooldown was recorded + expect(cookieOfCall(0)).toBeUndefined(); + mockFetch.mockResolvedValueOnce(login204()).mockResolvedValueOnce(new Response('v5.2.3')); + await expect(new QBittorrentClient(base, creds).getVersion()).resolves.toBe('v5.2.3'); + }); + it('still treats 200 "Fails." as bad credentials with cooldown escalation', async () => { const base = uniqueUrl(); const creds = { username: 'admin', secret: 'wrong' }; From df2a846d3e869501aa79b343a872c6560e623559 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=A1clav=20Brzezina?= Date: Tue, 13 Oct 2026 18:33:14 +0200 Subject: [PATCH 07/12] fix(proxy): send qBittorrent API keys as Authorization Bearer Released qBittorrent (5.2.x / WebAPI 2.14.1+) only accepts Authorization: Bearer ; X-API-Key exists on master only. Add a small per-provider auth header map in the proxy (Bearer plus X-Api-Key for qBittorrent, X-Api-Key for everything else) and point all user-facing copy at qBittorrent >= 5.2. --- .env.example | 2 +- .../src/modules/proxy/proxy.service.spec.ts | 8 +++++-- .../src/modules/proxy/proxy.service.ts | 23 +++++++++++++++---- apps/frontend/src/lib/integrations.ts | 4 ++-- docker-compose.yml | 2 +- .../src/qbittorrent.client.spec.ts | 4 ++-- .../src/qbittorrent.client.ts | 4 ++-- .../src/qbittorrent.types.ts | 2 +- 8 files changed, 34 insertions(+), 15 deletions(-) diff --git a/.env.example b/.env.example index f899a3c..654363f 100644 --- a/.env.example +++ b/.env.example @@ -78,7 +78,7 @@ WHISPARR_API_KEY= LIDARR_URL= LIDARR_API_KEY= # qBittorrent (WebUI) -# QBITTORRENT_PASSWORD is the WebUI password, or an API key if QBITTORRENT_USERNAME is empty (qBittorrent 5.3+). +# QBITTORRENT_PASSWORD is the WebUI password, or an API key if QBITTORRENT_USERNAME is empty (qBittorrent 5.2+). # Leave USERNAME and PASSWORD empty if qBittorrent bypasses authentication for Logarr's IP. QBITTORRENT_URL= QBITTORRENT_USERNAME= diff --git a/apps/backend/src/modules/proxy/proxy.service.spec.ts b/apps/backend/src/modules/proxy/proxy.service.spec.ts index 6e4c23a..de91727 100644 --- a/apps/backend/src/modules/proxy/proxy.service.spec.ts +++ b/apps/backend/src/modules/proxy/proxy.service.spec.ts @@ -451,7 +451,7 @@ describe('ProxyService', () => { ).rejects.toThrow(/cookie session/); }); - it('should send X-Api-Key for API key setups without username', async () => { + it('should send Authorization: Bearer (and X-Api-Key) for API key setups without username', async () => { configureMockDb(mockDb, { select: [{ ...qbitServer, apiKey: 'qbt-key', username: null }], }); @@ -461,7 +461,10 @@ describe('ProxyService', () => { expect(mockHttpService.request).toHaveBeenCalledWith( expect.objectContaining({ - headers: expect.objectContaining({ 'X-Api-Key': 'qbt-key' }), + headers: expect.objectContaining({ + Authorization: 'Bearer qbt-key', + 'X-Api-Key': 'qbt-key', + }), }) ); }); @@ -476,6 +479,7 @@ describe('ProxyService', () => { headers: Record; }; expect(call.headers).not.toHaveProperty('X-Api-Key'); + expect(call.headers).not.toHaveProperty('Authorization'); }); }); diff --git a/apps/backend/src/modules/proxy/proxy.service.ts b/apps/backend/src/modules/proxy/proxy.service.ts index 42dc095..ffc9adf 100644 --- a/apps/backend/src/modules/proxy/proxy.service.ts +++ b/apps/backend/src/modules/proxy/proxy.service.ts @@ -43,6 +43,12 @@ export class ProxyService { sabnzbd: '/api', }; + // Per-provider API key auth headers (default: X-Api-Key). Released qBittorrent (>= 5.2) only + // accepts `Authorization: Bearer `; X-API-Key exists on qBittorrent master only. + private readonly authHeaderMap: Record Record> = { + qbittorrent: (key) => ({ Authorization: `Bearer ${key}`, 'X-Api-Key': key }), + }; + constructor( @Inject(DATABASE_CONNECTION) private readonly db: PostgresJsDatabase, @@ -85,6 +91,15 @@ export class ProxyService { return server; } + private buildAuthHeaders(server: ServerWithConfig): Record { + if (server.apiKey === '') { + return {}; + } + return (this.authHeaderMap[server.providerId] ?? ((key) => ({ 'X-Api-Key': key })))( + server.apiKey + ); + } + /** * Proxy request to target service with enhanced features */ @@ -97,11 +112,11 @@ export class ProxyService { const server = await this.findUserServer(serverId); // Servers with a stored username authenticate with a cookie session (username/password - // login), which the proxy cannot do. API-key servers work: qBittorrent >= 5.3 accepts the - // key in the X-API-Key header (and as Bearer token); auth-bypass servers need no header. + // login), which the proxy cannot do. API-key servers work (qBittorrent >= 5.2 takes the key as + // a Bearer token, see authHeaderMap); auth-bypass servers need no header. if (server.username) { throw new BadRequestException( - 'The request proxy does not support servers that use username/password login (cookie session). Use an API key (qBittorrent 5.3+) or authentication bypass instead.' + 'The request proxy does not support servers that use username/password login (cookie session). Use an API key (qBittorrent 5.2+) or authentication bypass instead.' ); } @@ -113,7 +128,7 @@ export class ProxyService { // 3. Merge headers const headers: Record = { // Providers without credentials (e.g. qBittorrent auth bypass) store an empty key - ...(server.apiKey !== '' && { 'X-Api-Key': server.apiKey }), + ...this.buildAuthHeaders(server), 'Content-Type': 'application/json', ...dto.headers, }; diff --git a/apps/frontend/src/lib/integrations.ts b/apps/frontend/src/lib/integrations.ts index d280509..757d498 100644 --- a/apps/frontend/src/lib/integrations.ts +++ b/apps/frontend/src/lib/integrations.ts @@ -477,7 +477,7 @@ export const integrations: Integration[] = [ id: 'qbittorrent', name: 'qBittorrent', description: - 'Free and reliable P2P BitTorrent client (WebUI API v2). Supports username/password, authentication bypass and API keys (5.3+)', + 'Free and reliable P2P BitTorrent client (WebUI API v2). Supports username/password, authentication bypass and API keys (5.2+)', category: 'download_clients', status: 'available', connectionType: 'api_basic', @@ -502,7 +502,7 @@ export const integrations: Integration[] = [ name: 'password', label: 'Password / API key', type: 'password', - placeholder: 'WebUI password, or API key (qBittorrent 5.3+)', + placeholder: 'WebUI password, or API key (qBittorrent 5.2+)', description: "With a username: the WebUI password. Without a username: an API key. Leave both empty if authentication is bypassed for Logarr's IP", required: false, diff --git a/docker-compose.yml b/docker-compose.yml index 4a09fa6..d97b6ad 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -77,7 +77,7 @@ services: WHISPARR_API_KEY: ${WHISPARR_API_KEY:-} LIDARR_URL: ${LIDARR_URL:-} LIDARR_API_KEY: ${LIDARR_API_KEY:-} - # qBittorrent: username + password, or only a password/API key (5.3+), or neither (auth bypass) + # qBittorrent: username + password, or only a password/API key (5.2+), or neither (auth bypass) QBITTORRENT_URL: ${QBITTORRENT_URL:-} QBITTORRENT_USERNAME: ${QBITTORRENT_USERNAME:-} QBITTORRENT_PASSWORD: ${QBITTORRENT_PASSWORD:-} diff --git a/packages/provider-qbittorrent/src/qbittorrent.client.spec.ts b/packages/provider-qbittorrent/src/qbittorrent.client.spec.ts index b093e1e..49ae4bf 100644 --- a/packages/provider-qbittorrent/src/qbittorrent.client.spec.ts +++ b/packages/provider-qbittorrent/src/qbittorrent.client.spec.ts @@ -384,7 +384,7 @@ describe('QBittorrentClient', () => { const client = new QBittorrentClient(uniqueUrl(), { secret: '' }); await expect(client.getVersion()).rejects.toThrow( - 'qBittorrent requires authentication; configure username/password (or an API key for >= 5.3)' + 'qBittorrent requires authentication; configure username/password (or an API key for >= 5.2)' ); }); }); @@ -409,7 +409,7 @@ describe('QBittorrentClient', () => { const error = await client.getVersion().catch((e: unknown) => e as Error); expect((error as Error).message).toBe( - 'API key rejected (API keys require qBittorrent >= 5.3)' + 'API key rejected (API keys require qBittorrent >= 5.2)' ); expect((error as Error).message).not.toContain('qbt_supersecret'); }); diff --git a/packages/provider-qbittorrent/src/qbittorrent.client.ts b/packages/provider-qbittorrent/src/qbittorrent.client.ts index b226450..143f269 100644 --- a/packages/provider-qbittorrent/src/qbittorrent.client.ts +++ b/packages/provider-qbittorrent/src/qbittorrent.client.ts @@ -160,10 +160,10 @@ export class QBittorrentClient { throw new Error('qBittorrent authentication failed after re-login'); } } else if (this.authMode === 'apiKey') { - throw new Error('API key rejected (API keys require qBittorrent >= 5.3)'); + throw new Error('API key rejected (API keys require qBittorrent >= 5.2)'); } else { throw new Error( - 'qBittorrent requires authentication; configure username/password (or an API key for >= 5.3)' + 'qBittorrent requires authentication; configure username/password (or an API key for >= 5.2)' ); } } diff --git a/packages/provider-qbittorrent/src/qbittorrent.types.ts b/packages/provider-qbittorrent/src/qbittorrent.types.ts index eebb4ad..44d1666 100644 --- a/packages/provider-qbittorrent/src/qbittorrent.types.ts +++ b/packages/provider-qbittorrent/src/qbittorrent.types.ts @@ -39,7 +39,7 @@ export const QBITTORRENT_LOG_TYPE = { /** * How the client authenticates, derived from the stored credentials: * - login: username + password -> POST /auth/login and session cookie - * - apiKey: no username, non-empty secret -> Authorization: Bearer (qBittorrent >= 5.3) + * - apiKey: no username, non-empty secret -> Authorization: Bearer (qBittorrent >= 5.2) * - none: no credentials (WebUI authentication bypass) */ export type QBittorrentAuthMode = 'login' | 'apiKey' | 'none'; From b773c61af0b5866df289d76eec158fd8162e2e53 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=A1clav=20Brzezina?= Date: Tue, 13 Oct 2026 18:33:14 +0200 Subject: [PATCH 08/12] fix(servers): never return the stored API key or password GET/POST/PUT /servers now map rows through toServerResponse, which drops api_key and adds hasApiKey. An omitted or empty apiKey on update keeps the stored value. The edit dialog no longer prefills the secret and omits an empty field from the update payload; internal callers (test connection, ingestion, proxy) still read the key from the database. --- .../modules/servers/server-response.mapper.ts | 18 ++++ .../src/modules/servers/servers.dto.ts | 11 ++- .../modules/servers/servers.service.spec.ts | 91 +++++++++++++++++-- .../src/modules/servers/servers.service.ts | 32 +++++-- .../components/edit-server-dialog.test.tsx | 52 ++++++++--- .../src/components/edit-server-dialog.tsx | 39 ++++++-- .../source-diagnostics-dialog.test.tsx | 2 +- apps/frontend/src/lib/api.ts | 3 +- apps/frontend/src/lib/server-payload.test.ts | 3 +- apps/frontend/src/lib/server-payload.ts | 9 +- 10 files changed, 214 insertions(+), 46 deletions(-) create mode 100644 apps/backend/src/modules/servers/server-response.mapper.ts diff --git a/apps/backend/src/modules/servers/server-response.mapper.ts b/apps/backend/src/modules/servers/server-response.mapper.ts new file mode 100644 index 0000000..0718086 --- /dev/null +++ b/apps/backend/src/modules/servers/server-response.mapper.ts @@ -0,0 +1,18 @@ +import type * as schema from '../../database/schema'; + +export type ServerRow = typeof schema.servers.$inferSelect; + +/** A server as returned by the API: the stored secret is never included */ +export type ServerResponse = Omit & { + /** Whether a secret (API key / token / password) is stored. The value itself is never returned. */ + hasApiKey: boolean; +}; + +/** + * Map a database row to the API shape. Every server returned over HTTP must go through this + * function so the `api_key` column (API key, token or qBittorrent WebUI password) cannot leak. + */ +export function toServerResponse(row: ServerRow): ServerResponse { + const { apiKey, ...rest } = row; + return { ...rest, hasApiKey: apiKey !== '' }; +} diff --git a/apps/backend/src/modules/servers/servers.dto.ts b/apps/backend/src/modules/servers/servers.dto.ts index 320f20d..b0d221f 100644 --- a/apps/backend/src/modules/servers/servers.dto.ts +++ b/apps/backend/src/modules/servers/servers.dto.ts @@ -44,7 +44,10 @@ export class UpdateServerDto { @ApiPropertyOptional({ description: 'Server URL' }) url?: string; - @ApiPropertyOptional({ description: 'API key / token / password for authentication' }) + @ApiPropertyOptional({ + description: + 'API key / token / password for authentication. Omit or leave empty to keep the stored value.', + }) apiKey?: string; @ApiPropertyOptional({ @@ -87,6 +90,12 @@ export class ServerResponseDto { @ApiPropertyOptional({ description: 'Login username (qBittorrent)' }) username?: string | null; + @ApiProperty({ + description: + 'Whether a secret (API key, token or password) is stored. The secret is never returned.', + }) + hasApiKey: boolean; + @ApiPropertyOptional() logPath?: string | null; diff --git a/apps/backend/src/modules/servers/servers.service.spec.ts b/apps/backend/src/modules/servers/servers.service.spec.ts index 5a9f429..f5adaf8 100644 --- a/apps/backend/src/modules/servers/servers.service.spec.ts +++ b/apps/backend/src/modules/servers/servers.service.spec.ts @@ -11,6 +11,12 @@ import { ServersService } from './servers.service'; import type { TestingModule } from '@nestjs/testing'; +/** Expected API shape: the stored secret is replaced by hasApiKey */ +const withoutSecret = (row: T) => { + const { apiKey, ...rest } = row; + return { ...rest, hasApiKey: apiKey !== '' }; +}; + describe('ServersService', () => { let service: ServersService; let mockDb: MockDb; @@ -66,7 +72,7 @@ describe('ServersService', () => { const result = await service.findAll(); - expect(result).toEqual(servers); + expect(result).toEqual(servers.map(withoutSecret)); }); it('should return empty array when no servers', async () => { @@ -84,7 +90,7 @@ describe('ServersService', () => { const result = await service.findOne('server-1'); - expect(result).toEqual(mockServer); + expect(result).toEqual(withoutSecret(mockServer)); }); it('should throw NotFoundException when server not found', async () => { @@ -107,7 +113,7 @@ describe('ServersService', () => { const result = await service.create(dto); - expect(result).toEqual(mockServer); + expect(result).toEqual(withoutSecret(mockServer)); expect(mockDb.insert).toHaveBeenCalled(); }); @@ -139,7 +145,7 @@ describe('ServersService', () => { apiKey: 'test-key', }); - expect(result).toEqual(lidarrServer); + expect(result).toEqual(withoutSecret(lidarrServer)); }); it('should start file ingestion if enabled', async () => { @@ -194,7 +200,7 @@ describe('ServersService', () => { const result = await service.create(dto); // Should successfully create the second Radarr instance - expect(result).toEqual(newRadarr); + expect(result).toEqual(withoutSecret(newRadarr)); expect(mockDb.insert).toHaveBeenCalled(); }); @@ -1411,10 +1417,16 @@ describe('ServersService', () => { expect(updatedValues()).not.toHaveProperty('username'); }); - it('keeps an empty apiKey as is', async () => { + it('keeps the stored secret when apiKey is empty or omitted', async () => { await service.update('server-qbit', { apiKey: '' }); - expect(updatedValues()['apiKey']).toBe(''); + expect(updatedValues()).not.toHaveProperty('apiKey'); + }); + + it('replaces the stored secret when a new apiKey is sent', async () => { + await service.update('server-qbit', { apiKey: 'new-secret' }); + + expect(updatedValues()['apiKey']).toBe('new-secret'); }); }); @@ -1459,4 +1471,69 @@ describe('ServersService', () => { }); }); }); + + describe('secret is never returned', () => { + const secretServer = { ...mockServer, apiKey: 'super-secret-token' }; + + it('findAll / findOne strip apiKey and expose hasApiKey', async () => { + configureMockDb(mockDb, { select: [secretServer] }); + + const [listed] = await service.findAll(); + const single = await service.findOne('server-1'); + + for (const server of [listed, single]) { + expect(server).not.toHaveProperty('apiKey'); + expect(server?.hasApiKey).toBe(true); + expect(JSON.stringify(server)).not.toContain('super-secret-token'); + } + }); + + it('hasApiKey is false for an empty stored secret', async () => { + configureMockDb(mockDb, { select: [{ ...secretServer, apiKey: '' }] }); + + expect((await service.findOne('server-1')).hasApiKey).toBe(false); + }); + + it('create strips apiKey from the returned server', async () => { + configureMockDb(mockDb, { insert: [secretServer] }); + + const created = await service.create({ + name: 'n', + providerId: 'jellyfin', + url: 'http://x', + apiKey: 'super-secret-token', + }); + + expect(created).not.toHaveProperty('apiKey'); + expect(created?.hasApiKey).toBe(true); + }); + + it('update strips apiKey from the returned server', async () => { + configureMockDb(mockDb, { select: [secretServer], update: [secretServer] }); + + const updated = await service.update('server-1', { name: 'Renamed' }); + + expect(updated).not.toHaveProperty('apiKey'); + expect(JSON.stringify(updated)).not.toContain('super-secret-token'); + }); + + it('testConnection reads the secret from the database, not from the caller', async () => { + const connect = vi.fn().mockResolvedValue(undefined); + service['providers'].set('jellyfin', { + id: 'jellyfin', + name: 'Jellyfin', + connect, + disconnect: vi.fn().mockResolvedValue(undefined), + testConnection: vi.fn().mockResolvedValue({ connected: true }), + capabilities: {}, + } as any); + configureMockDb(mockDb, { select: [secretServer], update: [secretServer] }); + + await service.testConnection('server-1'); + + expect(connect).toHaveBeenCalledWith( + expect.objectContaining({ apiKey: 'super-secret-token' }) + ); + }); + }); }); diff --git a/apps/backend/src/modules/servers/servers.service.ts b/apps/backend/src/modules/servers/servers.service.ts index 59937ea..a9dbf99 100644 --- a/apps/backend/src/modules/servers/servers.service.ts +++ b/apps/backend/src/modules/servers/servers.service.ts @@ -22,6 +22,9 @@ import * as schema from '../../database/schema'; import { FileIngestionService } from '../file-ingestion/file-ingestion.service'; import { IngestionService } from '../ingestion/ingestion.service'; +import { toServerResponse } from './server-response.mapper'; + +import type { ServerResponse, ServerRow } from './server-response.mapper'; import type { CreateServerDto, UpdateServerDto } from './servers.dto'; import type { MediaServerProvider } from '@logarr/core'; import type { PostgresJsDatabase } from 'drizzle-orm/postgres-js'; @@ -78,11 +81,19 @@ export class ServersService { this.providers.set(qbittorrentProvider.id, qbittorrentProvider); } - async findAll() { - return this.db.select().from(schema.servers).orderBy(schema.servers.createdAt); + /** All servers as returned by the API (without the stored secret) */ + async findAll(): Promise { + const rows = await this.db.select().from(schema.servers).orderBy(schema.servers.createdAt); + return rows.map(toServerResponse); + } + + /** One server as returned by the API (without the stored secret) */ + async findOne(id: string): Promise { + return toServerResponse(await this.getServerRow(id)); } - async findOne(id: string) { + /** Database row including the secret: for internal use only, never return it from the API */ + private async getServerRow(id: string): Promise { const result = await this.db .select() .from(schema.servers) @@ -128,7 +139,8 @@ export class ServersService { }) .returning(); - const server = result[0]; + const created = result[0]; + const server = created ? toServerResponse(created) : undefined; // If file ingestion is enabled, start it immediately if (server && dto.fileIngestionEnabled && dto.logPaths?.length) { @@ -142,10 +154,10 @@ export class ServersService { async update(id: string, dto: UpdateServerDto) { // Get the current server state to detect changes - const currentServer = await this.findOne(id); + const currentServer = await this.getServerRow(id); // An empty username clears it (stored as NULL) - const { username, ...rest } = dto; + const { username, apiKey, ...rest } = dto; // Ignored for providers that do not authenticate with a username const normalizedUsername = this.providers.get(currentServer.providerId)?.capabilities.supportsUsername === true @@ -156,6 +168,8 @@ export class ServersService { .update(schema.servers) .set({ ...rest, + // An omitted or empty secret keeps the stored value (the API never returns it) + ...(typeof apiKey === 'string' && apiKey !== '' && { apiKey }), ...(normalizedUsername !== undefined && { username: normalizedUsername }), updatedAt: new Date(), }) @@ -249,7 +263,7 @@ export class ServersService { } async testConnection(id: string) { - const server = await this.findOne(id); + const server = await this.getServerRow(id); const provider = this.providers.get(server.providerId); if (!provider) { @@ -398,7 +412,7 @@ export class ServersService { * Start file ingestion for a specific server */ private async startFileIngestionForServer(serverId: string) { - const server = await this.findOne(serverId); + const server = await this.getServerRow(serverId); if (!server.fileIngestionEnabled || !server.logPaths?.length) { return; @@ -450,7 +464,7 @@ export class ServersService { * This clears all file states and restarts file ingestion */ async resetFileIngestionState(id: string) { - const server = await this.findOne(id); + const server = await this.getServerRow(id); if (!server.fileIngestionEnabled) { return { diff --git a/apps/frontend/src/components/edit-server-dialog.test.tsx b/apps/frontend/src/components/edit-server-dialog.test.tsx index 0d47239..33740cb 100644 --- a/apps/frontend/src/components/edit-server-dialog.test.tsx +++ b/apps/frontend/src/components/edit-server-dialog.test.tsx @@ -21,7 +21,7 @@ function makeServer(overrides: Partial): Server { name: 'My Server', providerId: 'sonarr', url: 'http://localhost:8989', - apiKey: 'secret-key', + hasApiKey: true, username: null, logPath: null, isConnected: true, @@ -62,7 +62,7 @@ describe('EditServerDialog credential validation', () => { const qbit = makeServer({ providerId: 'qbittorrent', url: 'http://localhost:8080', - apiKey: 'old-password', + hasApiKey: true, username: 'admin', }); @@ -73,19 +73,35 @@ describe('EditServerDialog credential validation', () => { expect(screen.getByText('Password / API key')).toBeInTheDocument(); }); - it('allows saving with an empty password and sends the empty apiKey', async () => { + it('never prefills the stored secret and shows that one is stored', () => { + renderDialog(qbit); + + expect(secretInput()).toHaveValue(''); + expect(secretInput().placeholder).toBe('Stored - leave empty to keep'); + }); + + it('keeps the stored password when the field is left empty (apiKey is not sent)', async () => { const user = userEvent.setup(); renderDialog(qbit); - await user.clear(secretInput()); await user.click(screen.getByRole('button', { name: /save changes/i })); await waitFor(() => expect(mutateAsync).toHaveBeenCalledTimes(1)); expect(screen.queryByText('API key is required')).not.toBeInTheDocument(); - expect(mutateAsync.mock.calls[0]![0]).toMatchObject({ - id: 'server-1', - data: { apiKey: '', username: 'admin' }, - }); + const payload = mutateAsync.mock.calls[0]![0]; + expect(payload).toMatchObject({ id: 'server-1', data: { username: 'admin' } }); + expect(payload.data).not.toHaveProperty('apiKey'); + }); + + it('sends a newly typed password', async () => { + const user = userEvent.setup(); + renderDialog(qbit); + + await user.type(secretInput(), 'new-password'); + await user.click(screen.getByRole('button', { name: /save changes/i })); + + await waitFor(() => expect(mutateAsync).toHaveBeenCalledTimes(1)); + expect(mutateAsync.mock.calls[0]![0].data.apiKey).toBe('new-password'); }); it('sends an empty username to clear it when the field is emptied', async () => { @@ -125,7 +141,7 @@ describe('EditServerDialog credential validation', () => { }); it('prefills an empty username as an empty string for auth-bypass servers', () => { - renderDialog(makeServer({ providerId: 'qbittorrent', apiKey: '', username: null })); + renderDialog(makeServer({ providerId: 'qbittorrent', hasApiKey: false, username: null })); expect(screen.getByPlaceholderText('admin')).toHaveValue(''); expect(secretInput()).toHaveValue(''); @@ -135,7 +151,7 @@ describe('EditServerDialog credential validation', () => { describe.each(['sonarr', 'radarr', 'prowlarr', 'whisparr', 'jellyfin', 'emby', 'plex'])( '%s', (providerId) => { - const server = makeServer({ providerId }); + const server = makeServer({ providerId, hasApiKey: false }); it('has no username field', () => { renderDialog(server); @@ -168,15 +184,27 @@ describe('EditServerDialog credential validation', () => { } ); - it('saves a valid sonarr edit without sending a username', async () => { + it('keeps the stored sonarr key when the field is empty and sends no username', async () => { const user = userEvent.setup(); renderDialog(makeServer({ providerId: 'sonarr' })); + expect(secretInput()).toHaveValue(''); await user.click(screen.getByRole('button', { name: /save changes/i })); await waitFor(() => expect(mutateAsync).toHaveBeenCalledTimes(1)); const data = mutateAsync.mock.calls[0]![0].data; - expect(data.apiKey).toBe('secret-key'); + expect(data).not.toHaveProperty('apiKey'); expect(data).not.toHaveProperty('username'); }); + + it('sends a replacement sonarr key when one is typed', async () => { + const user = userEvent.setup(); + renderDialog(makeServer({ providerId: 'sonarr' })); + + await user.type(secretInput(), 'new-key'); + await user.click(screen.getByRole('button', { name: /save changes/i })); + + await waitFor(() => expect(mutateAsync).toHaveBeenCalledTimes(1)); + expect(mutateAsync.mock.calls[0]![0].data.apiKey).toBe('new-key'); + }); }); diff --git a/apps/frontend/src/components/edit-server-dialog.tsx b/apps/frontend/src/components/edit-server-dialog.tsx index 73ab35d..78342cd 100644 --- a/apps/frontend/src/components/edit-server-dialog.tsx +++ b/apps/frontend/src/components/edit-server-dialog.tsx @@ -57,10 +57,11 @@ import { } from '@/lib/server-payload'; import { cn } from '@/lib/utils'; -// qBittorrent allows an empty password / API key (authentication bypass) -const buildFormSchema = (providerId: string) => +// The stored secret is never sent to the browser: an empty field keeps the stored value, so it is +// only required when nothing is stored yet (providers with optional credentials never need one). +const buildFormSchema = (providerId: string, hasStoredSecret: boolean) => baseFormSchema.superRefine((data, ctx) => { - if (!areCredentialsOptional(providerId) && data.apiKey.trim() === '') { + if (!areCredentialsOptional(providerId) && !hasStoredSecret && data.apiKey.trim() === '') { ctx.addIssue({ code: 'custom', path: ['apiKey'], message: 'API key is required' }); } }); @@ -89,7 +90,10 @@ export function EditServerDialog({ server, open, onOpenChange }: EditServerDialo const testConnection = useTestConnection(); const [isTesting, setIsTesting] = useState(false); - const formSchema = useMemo(() => buildFormSchema(server.providerId), [server.providerId]); + const formSchema = useMemo( + () => buildFormSchema(server.providerId, server.hasApiKey), + [server.providerId, server.hasApiKey] + ); const hasUsername = supportsUsername(server.providerId); const form = useForm({ @@ -97,7 +101,7 @@ export function EditServerDialog({ server, open, onOpenChange }: EditServerDialo defaultValues: { name: server.name, url: server.url, - apiKey: server.apiKey, + apiKey: '', username: server.username ?? '', logPath: server.logPath || '', fileIngestionEnabled: server.fileIngestionEnabled || false, @@ -113,7 +117,7 @@ export function EditServerDialog({ server, open, onOpenChange }: EditServerDialo form.reset({ name: server.name, url: server.url, - apiKey: server.apiKey, + apiKey: '', username: server.username ?? '', logPath: server.logPath || '', fileIngestionEnabled: server.fileIngestionEnabled || false, @@ -338,12 +342,18 @@ export function EditServerDialog({ server, open, onOpenChange }: EditServerDialo - With a username: password. Without: API key. Empty if auth is bypassed + With a username: password. Without: API key. Leave empty to keep the + stored value @@ -361,9 +371,18 @@ export function EditServerDialog({ server, open, onOpenChange }: EditServerDialo API Key - + - Found in Dashboard → API Keys + + Found in Dashboard → API Keys. Leave empty to keep the stored key. + )} diff --git a/apps/frontend/src/components/source-diagnostics-dialog.test.tsx b/apps/frontend/src/components/source-diagnostics-dialog.test.tsx index ce05c69..4b3e731 100644 --- a/apps/frontend/src/components/source-diagnostics-dialog.test.tsx +++ b/apps/frontend/src/components/source-diagnostics-dialog.test.tsx @@ -17,7 +17,7 @@ describe('SourceDiagnosticsDialog', () => { name: 'Plex Server', url: 'http://192.168.1.100:32400', providerId: 'plex', - apiKey: 'test-api-key', + hasApiKey: true, username: null, logPath: null, isConnected: true, diff --git a/apps/frontend/src/lib/api.ts b/apps/frontend/src/lib/api.ts index d9554c3..bbaa1a3 100644 --- a/apps/frontend/src/lib/api.ts +++ b/apps/frontend/src/lib/api.ts @@ -8,7 +8,8 @@ export interface Server { name: string; providerId: string; url: string; - apiKey: string; + /** Whether a secret (API key / token / password) is stored. The value is never returned. */ + hasApiKey: boolean; username: string | null; logPath: string | null; isConnected: boolean; diff --git a/apps/frontend/src/lib/server-payload.test.ts b/apps/frontend/src/lib/server-payload.test.ts index 78b416c..690d348 100644 --- a/apps/frontend/src/lib/server-payload.test.ts +++ b/apps/frontend/src/lib/server-payload.test.ts @@ -55,10 +55,11 @@ describe('buildUpdateCredentials', () => { apiKey: 'pw', username: 'bob', }); + // an empty secret means "keep the stored value" and is not sent expect(buildUpdateCredentials('qbittorrent', { apiKey: '', username: undefined })).toEqual({ - apiKey: '', username: '', }); + expect(buildUpdateCredentials('sonarr', { apiKey: ' ' })).toEqual({}); }); it('never sends a username for other providers', () => { diff --git a/apps/frontend/src/lib/server-payload.ts b/apps/frontend/src/lib/server-payload.ts index 96dc2d2..51f277f 100644 --- a/apps/frontend/src/lib/server-payload.ts +++ b/apps/frontend/src/lib/server-payload.ts @@ -33,15 +33,16 @@ export function buildCreateServerPayload( } /** - * Credential fields for the update payload. For providers using usernames the username is - * always sent ('' clears it on the backend). + * Credential fields for the update payload. The stored secret is never sent back to the client, + * so an empty apiKey field means "keep the stored value" and is omitted. For providers using + * usernames the username is always sent ('' clears it on the backend). */ export function buildUpdateCredentials( providerId: string, data: { apiKey: string; username?: string | undefined } -): { apiKey: string; username?: string } { +): { apiKey?: string; username?: string } { return { - apiKey: data.apiKey, + ...(data.apiKey.trim() !== '' && { apiKey: data.apiKey }), ...(supportsUsername(providerId) && { username: data.username?.trim() ?? '' }), }; } From 6a452bdbd0a820ed4a56ed6f623e738bacd2c2e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=A1clav=20Brzezina?= Date: Tue, 13 Oct 2026 18:33:14 +0200 Subject: [PATCH 09/12] fix(provider-qbittorrent): keep synthetic rows out of the sync cursor and redact more secrets - torrent_error and connection_status activities set advancesSyncCursor: false - redactSecrets handles percent-encoded keys (passkey%3D...) and opaque tokens in announce/scrape paths (base32/base64url/alphanumeric >= 20 chars) --- .../src/qbittorrent.hardening.spec.ts | 68 +++++++++++++++++++ .../src/qbittorrent.provider.ts | 11 +-- .../src/qbittorrent.redact.ts | 17 ++++- 3 files changed, 90 insertions(+), 6 deletions(-) diff --git a/packages/provider-qbittorrent/src/qbittorrent.hardening.spec.ts b/packages/provider-qbittorrent/src/qbittorrent.hardening.spec.ts index d49eb2c..71b3781 100644 --- a/packages/provider-qbittorrent/src/qbittorrent.hardening.spec.ts +++ b/packages/provider-qbittorrent/src/qbittorrent.hardening.spec.ts @@ -1,6 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { QBittorrentClient } from './qbittorrent.client.js'; +import { parseQBittorrentLogLine } from './qbittorrent.parser.js'; import { QBittorrentProvider } from './qbittorrent.provider.js'; import { redactSecrets, trackerHost } from './qbittorrent.redact.js'; @@ -276,3 +277,70 @@ describe('QBittorrentProvider hardening', () => { expect(lastCall).toContain('last_known_id=4'); }); }); + +describe('redactSecrets - encoded keys and opaque tokens', () => { + it.each([ + ['https://t/a?passkey%3Dsecret123&x=1', 'https://t/a?passkey%3D***&x=1'], + ['https://t/a%3Fpasskey%3Dsecret123%26x%3D1', 'https://t/a%3Fpasskey%3D***%26x%3D1'], + [ + 'url=https%3A%2F%2Ft%2Fa%3Fauthkey%3DSECRET%26z', + 'url=https%3A%2F%2Ft%2Fa%3Fauthkey%3D***%26z', + ], + ['https://t/a?PassKey=Secret&b=2', 'https://t/a?PassKey=***&b=2'], + ['https://t/announce/ABCDEFGHIJKLMNOPQRSTUV', 'https://t/announce/***'], + ['https://t/ABCDEFGHIJKLMNOPQRSTUV/announce', 'https://t/***/announce'], + ['https://t/a1b2c3d4e5f6g7h8i9j0k1l2/scrape', 'https://t/***/scrape'], + ['https%3A%2F%2Ft%2Fannounce%2Fabcdefghij0123456789xyz', 'https%3A%2F%2Ft%2Fannounce/***'], + ['https://t/announce', 'https://t/announce'], + ['https://t/announce/short', 'https://t/announce/short'], + ['kpk=notakey and npk=x', 'kpk=notakey and npk=x'], + ])('masks %s', (input, expected) => { + expect(redactSecrets(input)).toBe(expected); + }); + + it('is applied to API log messages and file-parsed lines', () => { + const entry = parseQBittorrentLogLine( + '(W) 2026-10-10T12:00:00 - Tracker "https://t/announce/ABCDEFGHIJKLMNOPQRSTUV?passkey%3Dzzz" failed' + ); + expect(entry?.message).not.toContain('ABCDEFGHIJ'); + expect(entry?.message).not.toContain('zzz'); + expect(entry?.raw).not.toContain('zzz'); + }); +}); + +describe('QBittorrentProvider sync cursor flags', () => { + beforeEach(() => { + vi.stubGlobal('fetch', mockFetch); + mockFetch.mockReset(); + }); + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('keeps synthetic rows out of the sync cursor but lets real log events advance it', async () => { + mockFetch.mockImplementation((url: unknown) => { + const path = new URL(String(url)).pathname.replace('/api/v2', ''); + const bodies: Record = { + '/app/version': 'v5.2.4', + '/app/webapiVersion': '2.15.1', + '/log/main': JSON.stringify([ + { id: 1, message: 'real event', timestamp: 1_790_000_000, type: 4 }, + ]), + '/torrents/info': JSON.stringify([{ hash: 'h', name: 'N', state: 'error' }]), + '/transfer/info': JSON.stringify({ connection_status: 'firewalled' }), + }; + return Promise.resolve( + new Response(bodies[path] ?? 'nf', { status: bodies[path] ? 200 : 404 }) + ); + }); + const provider = new QBittorrentProvider(); + await provider.connect({ url: uniqueUrl(), apiKey: '' }); + + const activities = await provider.getActivity(); + + const byType = Object.fromEntries(activities.map((a) => [a.type, a.advancesSyncCursor])); + expect(byType['torrent_error']).toBe(false); + expect(byType['connection_status']).toBe(false); + expect(byType['log_warning']).toBeUndefined(); + }); +}); diff --git a/packages/provider-qbittorrent/src/qbittorrent.provider.ts b/packages/provider-qbittorrent/src/qbittorrent.provider.ts index 4c9e4e7..754f30b 100644 --- a/packages/provider-qbittorrent/src/qbittorrent.provider.ts +++ b/packages/provider-qbittorrent/src/qbittorrent.provider.ts @@ -54,7 +54,7 @@ function severityOf(type: number): LogLevel { /** * qBittorrent download client provider. * - * Supports username/password (cookie session), API keys (qBittorrent >= 5.3) and + * Supports username/password (cookie session), API keys (qBittorrent >= 5.2) and * authentication bypass. Credentials: `apiKey` holds the password (or the API key when no * username is configured). */ @@ -273,8 +273,8 @@ export class QBittorrentProvider implements MediaServerProvider { private async fetchTorrentErrors(client: QBittorrentClient): Promise { const torrents = await client.getErroredTorrents(); - // Synthetic detection time. Note: it also advances lastActivitySync (accepted trade-off: - // the log feed re-reads from its own id cursor, so no log messages are lost). + // Synthetic detection time: these rows are flagged advancesSyncCursor: false so they never + // move lastActivitySync, which therefore only follows real log event times. const timestamp = new Date(); const day = timestamp.toISOString().slice(0, 10); return torrents.map((torrent) => ({ @@ -290,6 +290,8 @@ export class QBittorrentProvider implements MediaServerProvider { severity: 'error' as const, itemId: torrent.hash, timestamp, + // Detection time, not an event time: must not move the lastActivitySync cursor + advancesSyncCursor: false, metadata: { hash: torrent.hash, state: torrent.state, @@ -311,7 +313,7 @@ export class QBittorrentProvider implements MediaServerProvider { return []; } - // Synthetic detection time (see fetchTorrentErrors) + // Synthetic detection time (see fetchTorrentErrors), also advancesSyncCursor: false const timestamp = new Date(); const day = timestamp.toISOString().slice(0, 10); if (previous?.status === status && previous.reportedDay === day) { @@ -327,6 +329,7 @@ export class QBittorrentProvider implements MediaServerProvider { overview: `qBittorrent reports it is ${status} (check port forwarding / network binding)`, severity: status === 'disconnected' ? 'error' : 'warn', timestamp, + advancesSyncCursor: false, metadata: { connectionStatus: status, dhtNodes: info.dht_nodes, diff --git a/packages/provider-qbittorrent/src/qbittorrent.redact.ts b/packages/provider-qbittorrent/src/qbittorrent.redact.ts index 44144b7..1c6a0ce 100644 --- a/packages/provider-qbittorrent/src/qbittorrent.redact.ts +++ b/packages/provider-qbittorrent/src/qbittorrent.redact.ts @@ -2,11 +2,24 @@ * Private trackers embed account secrets (passkey / authkey) in announce URLs, and qBittorrent * log messages and torrent objects can contain those URLs. Mask them before anything is stored. */ -const SECRET_QUERY_PARAM = /\b(passkey|authkey|torrent_pass|pk)=[^&\s"'<>]+/gi; +// "=" may be percent-encoded (%3D) and the key may follow an encoded "?" / "&" (%3F / %26). The value +// ends at a literal "&", an encoded "&" (%26), whitespace or a quote. +const SECRET_QUERY_PARAM = + /(?<=^|[^a-z0-9]|%3f|%26)(passkey|authkey|torrent_pass|pk)(=|%3d)(?:(?!%26)[^&\s"'<>])+/gi; const HEX_PATH_SEGMENT = /\/[0-9a-f]{32,}(?=[/?#&\s"'<>]|$)/gi; +// Opaque tokens (base32 / base64url / alphanumeric, >= 20 chars) next to announce/scrape in a path: +// /announce/ or //announce ("/" may be percent-encoded as %2F) +const SLASH = '(?:/|%2f)'; +const TOKEN = '[a-z0-9_-]{20,}'; +const TOKEN_AFTER_ANNOUNCE = new RegExp(`(${SLASH}(?:announce|scrape))${SLASH}${TOKEN}`, 'gi'); +const TOKEN_BEFORE_ANNOUNCE = new RegExp(`${SLASH}${TOKEN}(?=${SLASH}(?:announce|scrape))`, 'gi'); export function redactSecrets(text: string): string { - return text.replace(SECRET_QUERY_PARAM, '$1=***').replace(HEX_PATH_SEGMENT, '/***'); + return text + .replace(SECRET_QUERY_PARAM, '$1$2***') + .replace(HEX_PATH_SEGMENT, '/***') + .replace(TOKEN_AFTER_ANNOUNCE, '$1/***') + .replace(TOKEN_BEFORE_ANNOUNCE, '/***'); } /** Reduce a tracker URL to its host (no path or query); undefined if it is not a valid URL */ From 0a7794c9d900a14d0a921dbb211abf3390f8689a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=A1clav=20Brzezina?= Date: Tue, 13 Oct 2026 18:33:14 +0200 Subject: [PATCH 10/12] fix(servers): sanitize env seed errors Seed failures log only the error name and code: Drizzle query errors embed the bound parameters, which include QBITTORRENT_PASSWORD. The startup failure is rethrown as a sanitized error. README documents that stored secrets are never returned. --- README.md | 1 + .../servers/server-seed.service.spec.ts | 56 ++++++++++++++++++- .../modules/servers/server-seed.service.ts | 26 ++++++++- 3 files changed, 78 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 185d399..60e4204 100644 --- a/README.md +++ b/README.md @@ -309,6 +309,7 @@ qBittorrent is a standalone provider (WebUI API v2) and is detected from the sto Environment variables for auto-setup: `QBITTORRENT_URL`, `QBITTORRENT_USERNAME`, `QBITTORRENT_PASSWORD`. +- Stored passwords and API keys are never returned by the API. When editing a source, leave the password / API key field empty to keep the stored value. The request proxy sends API keys to qBittorrent as `Authorization: Bearer` and does not support username/password sources. - qBittorrent bans an IP for 1 hour after 5 failed logins. After a failed login Logarr backs off (5, 15, then 60 minutes) instead of retrying every poll; fix the credentials and edit the source to retry immediately. - Behind a reverse proxy or with Host header validation enabled, qBittorrent may answer 401; allow Logarr's host name in "Server domains". - The API log feed only reports warnings and critical messages, and is skipped when a log path is configured (the `qbittorrent.log` file is tailed instead). Log file timestamps have no timezone: set the backend `TZ` to qBittorrent's timezone. diff --git a/apps/backend/src/modules/servers/server-seed.service.spec.ts b/apps/backend/src/modules/servers/server-seed.service.spec.ts index dd8dfb9..9a458cf 100644 --- a/apps/backend/src/modules/servers/server-seed.service.spec.ts +++ b/apps/backend/src/modules/servers/server-seed.service.spec.ts @@ -1,8 +1,9 @@ -import { afterEach, beforeEach, describe, expect, it, type vi } from 'vitest'; +import { Logger } from '@nestjs/common'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { createMockDb, configureMockDb, type MockDb } from '../../test/mock-db'; -import { ServerSeedService } from './server-seed.service'; +import { ServerSeedService, describeSeedError } from './server-seed.service'; const SEEDED_ENV_KEYS = [ 'JELLYFIN_URL', @@ -141,4 +142,55 @@ describe('ServerSeedService - qBittorrent', () => { expect(mockDb.insert).not.toHaveBeenCalled(); }); + + describe('failure logging', () => { + const failingInsert = (error: Error): void => { + mockDb.insert = vi.fn().mockImplementation(() => ({ + values: () => Promise.reject(error), + })); + }; + + it('does not log the database error message (it contains bound parameters)', async () => { + process.env['QBITTORRENT_URL'] = 'http://qbit:8080'; + process.env['QBITTORRENT_USERNAME'] = 'admin'; + process.env['QBITTORRENT_PASSWORD'] = 'super-secret-pw'; + failingInsert( + Object.assign( + new Error('Failed query: insert into "servers" ... params: admin,super-secret-pw'), + { name: 'DrizzleQueryError', cause: { code: '23505' } } + ) + ); + const warn = vi.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined); + + await service.onModuleInit(); + + const logged = JSON.stringify(warn.mock.calls); + expect(logged).not.toContain('super-secret-pw'); + expect(logged).toContain('DrizzleQueryError (code 23505)'); + warn.mockRestore(); + }); + + it('rethrows a sanitized error when seeding itself fails', async () => { + process.env['JELLYFIN_URL'] = 'http://jf'; + process.env['JELLYFIN_API_KEY'] = 'k'; + mockDb.select = vi.fn().mockImplementation(() => { + throw new Error('select ... params: super-secret-pw'); + }); + const error = vi.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined); + + const result = service.onModuleInit(); + + await expect(result).rejects.toThrow('ServerSeedService failed: Error'); + await expect(result).rejects.not.toThrow(/super-secret-pw/); + expect(JSON.stringify(error.mock.calls)).not.toContain('super-secret-pw'); + error.mockRestore(); + }); + + it('describes non-errors and coded errors safely', () => { + expect(describeSeedError('boom super-secret-pw')).toBe('Unknown error'); + expect(describeSeedError(Object.assign(new Error('x'), { code: 'ECONNREFUSED' }))).toBe( + 'Error (code ECONNREFUSED)' + ); + }); + }); }); diff --git a/apps/backend/src/modules/servers/server-seed.service.ts b/apps/backend/src/modules/servers/server-seed.service.ts index 1bdf922..6f66770 100644 --- a/apps/backend/src/modules/servers/server-seed.service.ts +++ b/apps/backend/src/modules/servers/server-seed.service.ts @@ -92,6 +92,21 @@ const ENV_SERVER_CONFIGS: EnvServerConfig[] = [ }, ]; +/** + * Describe a seeding error without its message: Drizzle query errors include the bound + * parameters, which would put credentials (e.g. QBITTORRENT_PASSWORD) into the logs. + */ +export function describeSeedError(error: unknown): string { + if (!(error instanceof Error)) { + return 'Unknown error'; + } + const withCode = error as Error & { code?: unknown; cause?: { code?: unknown } }; + const code = withCode.code ?? withCode.cause?.code; + return typeof code === 'string' || typeof code === 'number' + ? `${error.name} (code ${code})` + : error.name; +} + @Injectable() export class ServerSeedService implements OnModuleInit { private readonly logger = new Logger(ServerSeedService.name); @@ -107,8 +122,11 @@ export class ServerSeedService implements OnModuleInit { try { await this.seedServersFromEnv(); } catch (error) { - this.logger.error(`ServerSeedService failed: ${error}`); - throw error; + const description = describeSeedError(error); + this.logger.error(`ServerSeedService failed: ${description}`); + // Do not rethrow the original error: Drizzle errors embed the bound query parameters + // (including the stored credentials) in their message + throw new Error(`ServerSeedService failed: ${description}`); } } @@ -186,7 +204,9 @@ export class ServerSeedService implements OnModuleInit { `Auto-configured server from env: ${config.name} (${config.providerId})${logStatus}` ); } catch (error) { - this.logger.warn(`Failed to auto-configure server ${config.name}: ${error}`); + this.logger.warn( + `Failed to auto-configure server ${config.name}: ${describeSeedError(error)}` + ); } } From 9b78844890c42563feb5ab9147e9dd34d20bfc8d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=A1clav=20Brzezina?= Date: Tue, 13 Oct 2026 18:37:14 +0200 Subject: [PATCH 11/12] feat(servers): allow explicitly clearing a stored secret PUT /servers/:id accepts apiKey: null to clear the stored password/API key (only for providers whose credentials are optional). The edit dialog offers a clear action when a secret is stored and makes the user choose between keeping it as an API key and clearing it when the username is removed, so there is no silent mode change. --- .../src/modules/servers/servers.dto.ts | 6 +- .../modules/servers/servers.service.spec.ts | 18 ++++ .../src/modules/servers/servers.service.ts | 14 +++- .../components/edit-server-dialog.test.tsx | 84 +++++++++++++++++++ .../src/components/edit-server-dialog.tsx | 76 ++++++++++++++++- apps/frontend/src/lib/api.ts | 3 +- apps/frontend/src/lib/server-payload.test.ts | 7 ++ apps/frontend/src/lib/server-payload.ts | 11 ++- 8 files changed, 207 insertions(+), 12 deletions(-) diff --git a/apps/backend/src/modules/servers/servers.dto.ts b/apps/backend/src/modules/servers/servers.dto.ts index b0d221f..bcabf22 100644 --- a/apps/backend/src/modules/servers/servers.dto.ts +++ b/apps/backend/src/modules/servers/servers.dto.ts @@ -46,9 +46,11 @@ export class UpdateServerDto { @ApiPropertyOptional({ description: - 'API key / token / password for authentication. Omit or leave empty to keep the stored value.', + 'API key / token / password for authentication. Omit or send an empty string to keep the stored value. Send null to clear it (only for providers whose credentials are optional, e.g. qBittorrent with authentication bypass).', + type: String, + nullable: true, }) - apiKey?: string; + apiKey?: string | null; @ApiPropertyOptional({ description: 'Login username (qBittorrent). An empty string clears it.', diff --git a/apps/backend/src/modules/servers/servers.service.spec.ts b/apps/backend/src/modules/servers/servers.service.spec.ts index f5adaf8..2089277 100644 --- a/apps/backend/src/modules/servers/servers.service.spec.ts +++ b/apps/backend/src/modules/servers/servers.service.spec.ts @@ -1423,6 +1423,24 @@ describe('ServersService', () => { expect(updatedValues()).not.toHaveProperty('apiKey'); }); + it('clears the stored secret when apiKey is null (credentials optional)', async () => { + await service.update('server-qbit', { apiKey: null }); + + expect(updatedValues()['apiKey']).toBe(''); + }); + + it('rejects clearing the secret for providers that require one', async () => { + configureMockDb(mockDb, { + select: [{ ...mockServer, providerId: 'sonarr', apiKey: 'k' }], + update: [mockServer], + }); + + await expect(service.update('server-1', { apiKey: null })).rejects.toThrow( + BadRequestException + ); + expect(mockDb.update).not.toHaveBeenCalled(); + }); + it('replaces the stored secret when a new apiKey is sent', async () => { await service.update('server-qbit', { apiKey: 'new-secret' }); diff --git a/apps/backend/src/modules/servers/servers.service.ts b/apps/backend/src/modules/servers/servers.service.ts index a9dbf99..35f8152 100644 --- a/apps/backend/src/modules/servers/servers.service.ts +++ b/apps/backend/src/modules/servers/servers.service.ts @@ -158,18 +158,24 @@ export class ServersService { // An empty username clears it (stored as NULL) const { username, apiKey, ...rest } = dto; + const provider = this.providers.get(currentServer.providerId); + + // An explicit null clears the stored secret, but only where credentials are optional + if (apiKey === null && provider?.capabilities.apiKeyOptional !== true) { + throw new BadRequestException('API key is required and cannot be cleared'); + } // Ignored for providers that do not authenticate with a username const normalizedUsername = - this.providers.get(currentServer.providerId)?.capabilities.supportsUsername === true - ? normalizeUsername(username) - : undefined; + provider?.capabilities.supportsUsername === true ? normalizeUsername(username) : undefined; const result = await this.db .update(schema.servers) .set({ ...rest, - // An omitted or empty secret keeps the stored value (the API never returns it) + // An omitted or empty secret keeps the stored value (the API never returns it); + // null clears it ...(typeof apiKey === 'string' && apiKey !== '' && { apiKey }), + ...(apiKey === null && { apiKey: '' }), ...(normalizedUsername !== undefined && { username: normalizedUsername }), updatedAt: new Date(), }) diff --git a/apps/frontend/src/components/edit-server-dialog.test.tsx b/apps/frontend/src/components/edit-server-dialog.test.tsx index 33740cb..43f61c8 100644 --- a/apps/frontend/src/components/edit-server-dialog.test.tsx +++ b/apps/frontend/src/components/edit-server-dialog.test.tsx @@ -104,11 +104,89 @@ describe('EditServerDialog credential validation', () => { expect(mutateAsync.mock.calls[0]![0].data.apiKey).toBe('new-password'); }); + it('offers to clear the stored password and sends apiKey: null when chosen', async () => { + const user = userEvent.setup(); + renderDialog(qbit); + + await user.click(screen.getByRole('button', { name: /clear stored password/i })); + expect(screen.getByText(/will be removed when you save/i)).toBeInTheDocument(); + await user.click(screen.getByRole('button', { name: /save changes/i })); + + await waitFor(() => expect(mutateAsync).toHaveBeenCalledTimes(1)); + expect(mutateAsync.mock.calls[0]![0].data.apiKey).toBeNull(); + }); + + it('lets the user undo the clear action', async () => { + const user = userEvent.setup(); + renderDialog(qbit); + + await user.click(screen.getByRole('button', { name: /clear stored password/i })); + await user.click(screen.getByRole('button', { name: /keep stored password/i })); + await user.click(screen.getByRole('button', { name: /save changes/i })); + + await waitFor(() => expect(mutateAsync).toHaveBeenCalledTimes(1)); + expect(mutateAsync.mock.calls[0]![0].data).not.toHaveProperty('apiKey'); + }); + + it('does not offer clearing when no secret is stored', () => { + renderDialog(makeServer({ providerId: 'qbittorrent', hasApiKey: false, username: null })); + + expect(screen.queryByRole('button', { name: /clear stored password/i })).toBeNull(); + }); + + it('requires a choice when the username is removed while a secret is stored', async () => { + const user = userEvent.setup(); + renderDialog(qbit); + + await user.clear(screen.getByPlaceholderText('admin')); + expect(screen.getByText(/would be used as an API key/i)).toBeInTheDocument(); + await user.click(screen.getByRole('button', { name: /save changes/i })); + + expect(mutateAsync).not.toHaveBeenCalled(); + }); + + it('keeps the secret as an API key when that choice is made', async () => { + const user = userEvent.setup(); + renderDialog(qbit); + + await user.clear(screen.getByPlaceholderText('admin')); + await user.click(screen.getByRole('button', { name: /keep it as an api key/i })); + await user.click(screen.getByRole('button', { name: /save changes/i })); + + await waitFor(() => expect(mutateAsync).toHaveBeenCalledTimes(1)); + const data = mutateAsync.mock.calls[0]![0].data; + expect(data.username).toBe(''); + expect(data).not.toHaveProperty('apiKey'); + }); + + it('clears the secret when that choice is made', async () => { + const user = userEvent.setup(); + renderDialog(qbit); + + await user.clear(screen.getByPlaceholderText('admin')); + await user.click(screen.getByRole('button', { name: /clear it/i })); + await user.click(screen.getByRole('button', { name: /save changes/i })); + + await waitFor(() => expect(mutateAsync).toHaveBeenCalledTimes(1)); + expect(mutateAsync.mock.calls[0]![0].data).toMatchObject({ username: '', apiKey: null }); + }); + + it('does not ask when a new password is typed along with removing the username', async () => { + const user = userEvent.setup(); + renderDialog(qbit); + + await user.clear(screen.getByPlaceholderText('admin')); + await user.type(secretInput(), 'api-key-123'); + + expect(screen.queryByText(/would be used as an API key/i)).toBeNull(); + }); + it('sends an empty username to clear it when the field is emptied', async () => { const user = userEvent.setup(); renderDialog(qbit); await user.clear(screen.getByPlaceholderText('admin')); + await user.click(screen.getByRole('button', { name: /keep it as an api key/i })); await user.click(screen.getByRole('button', { name: /save changes/i })); await waitFor(() => expect(mutateAsync).toHaveBeenCalledTimes(1)); @@ -184,6 +262,12 @@ describe('EditServerDialog credential validation', () => { } ); + it('does not offer clearing the stored key for providers that require one', () => { + renderDialog(makeServer({ providerId: 'sonarr' })); + + expect(screen.queryByRole('button', { name: /clear stored/i })).toBeNull(); + }); + it('keeps the stored sonarr key when the field is empty and sends no username', async () => { const user = userEvent.setup(); renderDialog(makeServer({ providerId: 'sonarr' })); diff --git a/apps/frontend/src/components/edit-server-dialog.tsx b/apps/frontend/src/components/edit-server-dialog.tsx index 78342cd..3bab942 100644 --- a/apps/frontend/src/components/edit-server-dialog.tsx +++ b/apps/frontend/src/components/edit-server-dialog.tsx @@ -95,6 +95,11 @@ export function EditServerDialog({ server, open, onOpenChange }: EditServerDialo [server.providerId, server.hasApiKey] ); const hasUsername = supportsUsername(server.providerId); + // Deliberate removal of the stored secret (only offered where credentials are optional) + const canClearSecret = server.hasApiKey && areCredentialsOptional(server.providerId); + const [clearSecret, setClearSecret] = useState(false); + // What to do with the stored secret when the username is removed: no silent mode change + const [usernameChoice, setUsernameChoice] = useState<'keep' | 'clear' | null>(null); const form = useForm({ resolver: zodResolver(formSchema), @@ -111,9 +116,20 @@ export function EditServerDialog({ server, open, onOpenChange }: EditServerDialo }); const fileIngestionEnabled = form.watch('fileIngestionEnabled'); + const watchedUsername = form.watch('username') ?? ''; + const watchedSecret = form.watch('apiKey'); + const usernameRemoved = + hasUsername && + (server.username ?? '') !== '' && + watchedUsername.trim() === '' && + server.hasApiKey && + watchedSecret.trim() === '' && + !clearSecret; useEffect(() => { if (open) { + setClearSecret(false); + setUsernameChoice(null); form.reset({ name: server.name, url: server.url, @@ -155,6 +171,10 @@ export function EditServerDialog({ server, open, onOpenChange }: EditServerDialo } async function onSubmit(data: FormData) { + if (usernameRemoved && usernameChoice === null) { + toast.error('Choose what happens to the stored password / API key before saving'); + return; + } try { const logPaths = data.logPaths ? data.logPaths @@ -179,7 +199,11 @@ export function EditServerDialog({ server, open, onOpenChange }: EditServerDialo data: { name: data.name, url: data.url, - ...buildUpdateCredentials(server.providerId, data), + ...buildUpdateCredentials( + server.providerId, + data, + clearSecret || (usernameRemoved && usernameChoice === 'clear') + ), logPath: data.logPath || undefined, fileIngestionEnabled: data.fileIngestionEnabled, logPaths: logPaths?.length ? logPaths : undefined, @@ -388,6 +412,56 @@ export function EditServerDialog({ server, open, onOpenChange }: EditServerDialo )} /> )} + + {canClearSecret && ( +
+ + {clearSecret && ( + + The stored value will be removed when you save. + + )} +
+ )} + + {usernameRemoved && ( + + + +

+ Without a username, the stored password would be used as an API key. Choose + what to do with it: +

+
+ + +
+
+
+ )} {/* File Ingestion Section */} diff --git a/apps/frontend/src/lib/api.ts b/apps/frontend/src/lib/api.ts index bbaa1a3..def90f0 100644 --- a/apps/frontend/src/lib/api.ts +++ b/apps/frontend/src/lib/api.ts @@ -43,7 +43,8 @@ export interface CreateServerDto { export interface UpdateServerDto { name?: string; url?: string; - apiKey?: string; + /** Omit or send '' to keep the stored secret; null clears it (providers with optional credentials) */ + apiKey?: string | null; username?: string; logPath?: string; fileIngestionEnabled?: boolean; diff --git a/apps/frontend/src/lib/server-payload.test.ts b/apps/frontend/src/lib/server-payload.test.ts index 690d348..b6e7653 100644 --- a/apps/frontend/src/lib/server-payload.test.ts +++ b/apps/frontend/src/lib/server-payload.test.ts @@ -62,6 +62,13 @@ describe('buildUpdateCredentials', () => { expect(buildUpdateCredentials('sonarr', { apiKey: ' ' })).toEqual({}); }); + it('sends apiKey: null to clear the stored secret and ignores a typed value', () => { + expect(buildUpdateCredentials('qbittorrent', { apiKey: 'typed' }, true)).toEqual({ + apiKey: null, + username: '', + }); + }); + it('never sends a username for other providers', () => { expect(buildUpdateCredentials('sonarr', { apiKey: 'k', username: 'x' })).toEqual({ apiKey: 'k', diff --git a/apps/frontend/src/lib/server-payload.ts b/apps/frontend/src/lib/server-payload.ts index 51f277f..595c0bc 100644 --- a/apps/frontend/src/lib/server-payload.ts +++ b/apps/frontend/src/lib/server-payload.ts @@ -35,14 +35,17 @@ export function buildCreateServerPayload( /** * Credential fields for the update payload. The stored secret is never sent back to the client, * so an empty apiKey field means "keep the stored value" and is omitted. For providers using - * usernames the username is always sent ('' clears it on the backend). + * usernames the username is always sent ('' clears it on the backend). `clearApiKey` sends + * apiKey: null to deliberately remove the stored secret. */ export function buildUpdateCredentials( providerId: string, - data: { apiKey: string; username?: string | undefined } -): { apiKey?: string; username?: string } { + data: { apiKey: string; username?: string | undefined }, + clearApiKey = false +): { apiKey?: string | null; username?: string } { return { - ...(data.apiKey.trim() !== '' && { apiKey: data.apiKey }), + // null explicitly clears the stored secret + ...(clearApiKey ? { apiKey: null } : data.apiKey.trim() !== '' && { apiKey: data.apiKey }), ...(supportsUsername(providerId) && { username: data.username?.trim() ?? '' }), }; } From 411e5fe6b0c94f66fc62c1be54d85721d2f1efcc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=A1clav=20Brzezina?= Date: Tue, 13 Oct 2026 18:54:16 +0200 Subject: [PATCH 12/12] fix(security): trim API keys and never leak secrets in qBittorrent network errors --- .../modules/servers/servers.service.spec.ts | 43 +++++++++++++++++++ .../src/modules/servers/servers.service.ts | 10 +++-- .../src/qbittorrent.client.spec.ts | 32 ++++++++++++++ .../src/qbittorrent.client.ts | 11 ++++- 4 files changed, 92 insertions(+), 4 deletions(-) diff --git a/apps/backend/src/modules/servers/servers.service.spec.ts b/apps/backend/src/modules/servers/servers.service.spec.ts index 2089277..9baa5e3 100644 --- a/apps/backend/src/modules/servers/servers.service.spec.ts +++ b/apps/backend/src/modules/servers/servers.service.spec.ts @@ -1286,6 +1286,49 @@ describe('ServersService', () => { expect(updatedValues()['name']).toBe('Renamed'); }); + it('trims whitespace and newlines from the API key on create', async () => { + configureMockDb(mockDb, { insert: [qbitServer] }); + + await service.create({ ...baseDto, apiKey: ' secret-key\n' }); + + expect(insertedValues()['apiKey']).toBe('secret-key'); + }); + + it('rejects a whitespace-only API key on create when the provider requires one', async () => { + await expect( + service.create({ + name: 'Sonarr', + providerId: 'sonarr', + url: 'http://localhost:8989', + apiKey: ' \n', + }) + ).rejects.toThrow(BadRequestException); + }); + + it('stores an empty key for whitespace-only input when the API key is optional', async () => { + configureMockDb(mockDb, { insert: [qbitServer] }); + + await service.create({ ...baseDto, apiKey: ' \n' }); + + expect(insertedValues()['apiKey']).toBe(''); + }); + + it('trims the API key on update', async () => { + configureMockDb(mockDb, { select: [qbitServer], update: [qbitServer] }); + + await service.update('server-qbit', { apiKey: 'new-secret\r\n' }); + + expect(updatedValues()['apiKey']).toBe('new-secret'); + }); + + it('keeps the stored API key when the update value is whitespace-only', async () => { + configureMockDb(mockDb, { select: [qbitServer], update: [qbitServer] }); + + await service.update('server-qbit', { apiKey: ' \n' }); + + expect(updatedValues()).not.toHaveProperty('apiKey'); + }); + it('registers the qbittorrent provider without session support', () => { const qbit = service.getAvailableProviders().find((p) => p.id === 'qbittorrent'); expect(qbit?.capabilities.supportsUsername).toBe(true); diff --git a/apps/backend/src/modules/servers/servers.service.ts b/apps/backend/src/modules/servers/servers.service.ts index 35f8152..acdc730 100644 --- a/apps/backend/src/modules/servers/servers.service.ts +++ b/apps/backend/src/modules/servers/servers.service.ts @@ -114,7 +114,10 @@ export class ServersService { throw new Error(`Unknown provider: ${dto.providerId}`); } - if (provider.capabilities.apiKeyOptional !== true && (dto.apiKey ?? '') === '') { + // A pasted trailing newline/space would make the Authorization header invalid + const apiKey = dto.apiKey?.trim() ?? ''; + + if (provider.capabilities.apiKeyOptional !== true && apiKey === '') { throw new BadRequestException('API key is required'); } @@ -126,7 +129,7 @@ export class ServersService { url: dto.url, // apiKey may be empty only for providers with apiKeyOptional (e.g. qBittorrent auth bypass); // the column is NOT NULL - apiKey: dto.apiKey ?? '', + apiKey, // Only providers that authenticate with a username may store one username: provider.capabilities.supportsUsername === true @@ -157,7 +160,8 @@ export class ServersService { const currentServer = await this.getServerRow(id); // An empty username clears it (stored as NULL) - const { username, apiKey, ...rest } = dto; + const { username, apiKey: rawApiKey, ...rest } = dto; + const apiKey = typeof rawApiKey === 'string' ? rawApiKey.trim() : rawApiKey; const provider = this.providers.get(currentServer.providerId); // An explicit null clears the stored secret, but only where credentials are optional diff --git a/packages/provider-qbittorrent/src/qbittorrent.client.spec.ts b/packages/provider-qbittorrent/src/qbittorrent.client.spec.ts index 49ae4bf..6bdaf7b 100644 --- a/packages/provider-qbittorrent/src/qbittorrent.client.spec.ts +++ b/packages/provider-qbittorrent/src/qbittorrent.client.spec.ts @@ -562,4 +562,36 @@ describe('QBittorrentClient', () => { } }); }); + describe('network error redaction', () => { + it('never leaks the secret when fetch rejects an invalid header value', async () => { + const secret = 'super-secret-key\n'; + mockFetch.mockRejectedValue( + new TypeError(`Headers.append: "Bearer ${secret}" is an invalid header value.`) + ); + const client = new QBittorrentClient(uniqueUrl(), { secret }); + + const error = await client.getVersion().catch((e: Error) => e); + + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).not.toContain('super-secret-key'); + expect((error as Error).message).toBe( + 'qBittorrent request failed: invalid credentials format' + ); + }); + + it('hides ByteString errors and any message containing the secret', async () => { + mockFetch.mockRejectedValueOnce( + new TypeError('Cannot convert argument to a ByteString because of character 10') + ); + const client = new QBittorrentClient(uniqueUrl(), { secret: 'abc123' }); + const first = await client.getVersion().catch((e: Error) => e); + expect((first as Error).message).toBe( + 'qBittorrent request failed: invalid credentials format' + ); + + mockFetch.mockRejectedValueOnce(new Error('weird failure for Authorization: Bearer abc123')); + const second = await client.getVersion().catch((e: Error) => e); + expect((second as Error).message).not.toContain('abc123'); + }); + }); }); diff --git a/packages/provider-qbittorrent/src/qbittorrent.client.ts b/packages/provider-qbittorrent/src/qbittorrent.client.ts index 143f269..9608a48 100644 --- a/packages/provider-qbittorrent/src/qbittorrent.client.ts +++ b/packages/provider-qbittorrent/src/qbittorrent.client.ts @@ -1,5 +1,6 @@ import { createHash } from 'node:crypto'; +import { redactSecrets } from './qbittorrent.redact.js'; import { cooldowns, sessionCache } from './qbittorrent.state.js'; import type { @@ -315,7 +316,15 @@ export class QBittorrentClient { if (code === 'ECONNREFUSED' || code === 'ENOTFOUND' || code === 'EHOSTUNREACH') { return new Error(`Cannot reach qBittorrent at ${this.baseUrl}`); } - return new Error(`qBittorrent request failed: ${err.message ?? 'unknown error'}`); + const message = err.message ?? 'unknown error'; + // fetch embeds the offending header value (the Bearer secret) in invalid-header errors + if ( + /invalid header value|ByteString/i.test(message) || + (this.secret !== '' && message.includes(this.secret)) + ) { + return new Error('qBittorrent request failed: invalid credentials format'); + } + return new Error(`qBittorrent request failed: ${redactSecrets(message)}`); } private storeCookies(headers: Headers): void {