docs: audit and update all documentation to reflect current codebase
ARCHITECTURE.md: - Node version: 18+ → 22 (Alpine) - Tech stack: add helmet, express-rate-limit, cookie-parser, testing tools - Directory structure: add server/app.js, verifyCsrf.js, tokenStore.js, sanitizeError.js, tests/, docs/, .gitea/workflows/, vitest.config.js - §4.1: document app.js factory (createApp) vs index.js entry point; CSP nonce, rate limiters, CSRF middleware, trust proxy - §4.2: add CSRF Required column; document verifyCsrf; fix auth note - §4.3: add tokenStore.js and sanitizeError.js descriptions - §6 Auth flow: add rememberMe, rate limiter, stable DeviceId, server-side token store, CSRF token issuance, correct cookie TTL (session/30d not 24h) - §9 API: add csrfToken to login response, rememberMe field, 400/429 codes; add GET /api/auth/csrf endpoint; fix /me response; fix /logout CSRF note - §11 Config: add DATA_DIR, COOKIE_SECRET, TRUST_PROXY, NODE_ENV; split into Core / Emby / Service Instances / Tuning sections - §12 Deployment: update Dockerfile description to multi-stage node:22-alpine; add COOKIE_SECRET, TRUST_PROXY, named volume to compose example; add security hardening checklist; add CI/CD table diagrams/seq-auth.puml: - Add TokenStore participant - Add rememberMe, CSRF token issuance, stable DeviceId note - Add login rate limiter note - Add GET /csrf refresh flow - Add server-side token revocation on logout diagrams/class-server.puml: - Add app.js createApp() factory class - Add verifyCsrf middleware class - Add TokenStore and SanitizeError utility classes - Update auth.js routes (add GET /csrf) - Fix relationships: entry → appfn → routes diagrams/component.puml: - Add app.js factory component - Add helmet, express-rate-limit components - Add verifyCsrf middleware component - Add tokenStore.js and sanitizeError.js utility components - Fix wiring: entry → createApp() → mounts routes Dockerfile: - Fix stale comments referencing better-sqlite3 and SQLite server/routes/auth.js: - Fix stale comment: SQLite-backed → JSON file-backed
This commit is contained in:
@@ -80,18 +80,42 @@ Admin users can view all users' downloads, see server status, cache statistics,
|
||||
|
||||
## 2. Technology Stack
|
||||
|
||||
### Runtime & Framework
|
||||
|
||||
| Layer | Technology | Purpose |
|
||||
|-------|-----------|---------|
|
||||
| **Runtime** | Node.js 18+ | Server runtime |
|
||||
|-------|-----------|------|
|
||||
| **Runtime** | Node.js 22 (Alpine) | Server runtime |
|
||||
| **Framework** | Express 4.x | HTTP server, routing, middleware |
|
||||
| **HTTP Client** | axios 1.x | External API communication |
|
||||
| **Frontend** | Vanilla JS + CSS | Single-page app, no build step |
|
||||
| **Auth** | Emby API + httpOnly cookies | Session management |
|
||||
| **Caching** | In-memory Map with TTL | Reduce external API load |
|
||||
| **Scheduling** | `setInterval` | Background polling |
|
||||
| **Containerisation** | Docker (Alpine) | Production deployment |
|
||||
| **Containerisation** | Docker multi-stage (Alpine) | Production deployment |
|
||||
| **Logging** | Custom logger + `console.*` | File + stdout logging with levels |
|
||||
|
||||
### Security Middleware
|
||||
|
||||
| Package | Purpose |
|
||||
|---------|--------|
|
||||
| `helmet` 7.x | HTTP security headers (CSP with per-request nonce, HSTS, referrer policy) |
|
||||
| `express-rate-limit` 7.x | 300 req/15 min general limiter; 10 failed attempts/15 min login limiter |
|
||||
| `cookie-parser` 1.x | Signed cookie support (HMAC via `COOKIE_SECRET`) |
|
||||
|
||||
### Auth & Session
|
||||
|
||||
| Component | Technology | Details |
|
||||
|-----------|-----------|--------|
|
||||
| **Identity** | Emby API | `POST /Users/authenticatebyname` |
|
||||
| **Session cookie** | `httpOnly` + `sameSite: strict` | Signed when `COOKIE_SECRET` set |
|
||||
| **CSRF protection** | Double-submit cookie pattern | `csrf_token` cookie + `X-CSRF-Token` header |
|
||||
| **Token store** | JSON file (`DATA_DIR/tokens.json`) | Atomic writes, 31-day TTL, hourly pruning |
|
||||
|
||||
### Testing
|
||||
|
||||
| Tool | Purpose |
|
||||
|------|---------|
|
||||
| `vitest` 4.x | Test runner (V8 coverage built-in) |
|
||||
| `supertest` 7.x | HTTP integration testing |
|
||||
| `nock` 14.x | HTTP interception at Node layer (works with CJS `require('axios')`) |
|
||||
|
||||
---
|
||||
|
||||
## 3. Directory Structure
|
||||
@@ -99,22 +123,26 @@ Admin users can view all users' downloads, see server status, cache statistics,
|
||||
```
|
||||
sofarr/
|
||||
├── server/ # Backend application
|
||||
│ ├── index.js # Entry point: Express setup, middleware, startup
|
||||
│ ├── index.js # Entry point: logging setup, server listen, poller start
|
||||
│ ├── app.js # Express app factory (imported by index.js and tests)
|
||||
│ ├── routes/
|
||||
│ │ ├── auth.js # POST /login, GET /me, POST /logout
|
||||
│ │ ├── dashboard.js # GET /user-downloads, /user-summary, /status
|
||||
│ │ ├── auth.js # POST /login, GET /me, GET /csrf, POST /logout
|
||||
│ │ ├── dashboard.js # GET /user-downloads, /user-summary, /status, /cover-art
|
||||
│ │ ├── emby.js # Proxy routes to Emby API
|
||||
│ │ ├── sabnzbd.js # Proxy routes to SABnzbd API
|
||||
│ │ ├── sonarr.js # Proxy routes to Sonarr API
|
||||
│ │ └── radarr.js # Proxy routes to Radarr API
|
||||
│ ├── middleware/
|
||||
│ │ └── requireAuth.js # httpOnly cookie auth middleware
|
||||
│ │ ├── requireAuth.js # httpOnly cookie auth enforcement
|
||||
│ │ └── verifyCsrf.js # CSRF double-submit cookie validation
|
||||
│ └── utils/
|
||||
│ ├── cache.js # MemoryCache class (Map + TTL + stats)
|
||||
│ ├── config.js # Multi-instance service configuration parser
|
||||
│ ├── logger.js # File logger (server.log)
|
||||
│ ├── logger.js # File logger (DATA_DIR/server.log)
|
||||
│ ├── poller.js # Background polling engine + timing
|
||||
│ └── qbittorrent.js # qBittorrent client with auth + torrent mapping
|
||||
│ ├── qbittorrent.js # qBittorrent client with auth + torrent mapping
|
||||
│ ├── sanitizeError.js # Redacts secrets from error messages before logging
|
||||
│ └── tokenStore.js # JSON file-backed Emby token store (atomic, TTL)
|
||||
├── public/ # Static frontend (served by Express)
|
||||
│ ├── index.html # HTML shell: splash, login, dashboard
|
||||
│ ├── app.js # All frontend logic (auth, rendering, status)
|
||||
@@ -123,8 +151,21 @@ sofarr/
|
||||
│ ├── favicon-32.png # 32px PNG favicon
|
||||
│ ├── favicon-192.png # 192px PNG (apple-touch-icon / PWA)
|
||||
│ └── images/ # Logo / splash screen assets
|
||||
├── Dockerfile # Production container image
|
||||
├── tests/
|
||||
│ ├── README.md # Testing approach, design decisions, coverage targets
|
||||
│ ├── setup.js # Global setup: isolated DATA_DIR, rate-limit bypass
|
||||
│ ├── unit/ # Pure unit tests (no HTTP)
|
||||
│ └── integration/ # Supertest integration tests (nock for external HTTP)
|
||||
├── docs/
|
||||
│ ├── ARCHITECTURE.md # This document
|
||||
│ └── diagrams/ # PlantUML source files
|
||||
├── .gitea/workflows/
|
||||
│ ├── ci.yml # Security audit + test/coverage CI jobs
|
||||
│ ├── build-image.yml # Docker image build and push
|
||||
│ └── create-release.yml # Release tagging workflow
|
||||
├── Dockerfile # Multi-stage production container image (node:22-alpine)
|
||||
├── docker-compose.yaml # Example compose deployment
|
||||
├── vitest.config.js # Test runner configuration with per-file coverage thresholds
|
||||
├── package.json # Dependencies and scripts
|
||||
├── .env.sample # Annotated environment variable template
|
||||
└── README.md # User-facing documentation
|
||||
@@ -134,30 +175,45 @@ sofarr/
|
||||
|
||||
## 4. Component Architecture
|
||||
|
||||
### 4.1 Server Entry Point (`server/index.js`)
|
||||
### 4.1 Application Factory (`server/app.js`) and Entry Point (`server/index.js`)
|
||||
|
||||
Responsibilities:
|
||||
- Load environment variables via `dotenv`
|
||||
- Configure structured logging with level filtering (`LOG_LEVEL`)
|
||||
- Redirect `console.*` to both stdout and `server.log`
|
||||
- Mount Express middleware (cookie-parser, JSON, static files)
|
||||
- Mount route modules under `/api/*`
|
||||
**`server/app.js`** is a `createApp(options?)` factory that builds and returns the configured Express `app` instance. It is imported by both `server/index.js` (production) and the test suite. Keeping app creation separate from `app.listen()` means tests get a clean instance without triggering log-file setup, `process.exit()` calls, or the background poller.
|
||||
|
||||
`createApp` responsibilities:
|
||||
- Configure `trust proxy` from `TRUST_PROXY` env var
|
||||
- Apply `helmet` with a per-request CSP nonce (`crypto.randomBytes(16)`) for inline styles/scripts
|
||||
- Add `Permissions-Policy` header
|
||||
- Apply the general API rate limiter (300 req / 15 min per IP)
|
||||
- Mount `cookie-parser` (signed when `COOKIE_SECRET` is set)
|
||||
- Mount `express.json` (64 KB body limit)
|
||||
- Expose `/health` and `/ready` endpoints (no auth, no rate limit)
|
||||
- Mount `/api/auth` routes **before** CSRF middleware (login/logout are exempt)
|
||||
- Mount `verifyCsrf` for all subsequent `/api` routes
|
||||
- Mount remaining route modules under `/api/*`
|
||||
- Register global error handler (500 with sanitized message)
|
||||
|
||||
**`server/index.js`** entry point responsibilities:
|
||||
- Load `.env` via `dotenv`
|
||||
- Configure structured logging (`LOG_LEVEL`) with `console.*` redirection to both stdout and `DATA_DIR/server.log`
|
||||
- Call `createApp()`, serve `public/` as static files, start `app.listen()`
|
||||
- Start the background poller
|
||||
|
||||
### 4.2 Route Modules
|
||||
|
||||
| Module | Mount Point | Auth Required | Purpose |
|
||||
|--------|------------|---------------|---------|
|
||||
| `auth.js` | `/api/auth` | No (public) | Login, session check, logout |
|
||||
| `dashboard.js` | `/api/dashboard` | Yes (`requireAuth`) | Aggregated download data, status |
|
||||
| `emby.js` | `/api/emby` | Yes (`requireAuth`) | Proxy to Emby API |
|
||||
| `sabnzbd.js` | `/api/sabnzbd` | Yes (`requireAuth`) | Proxy to SABnzbd API |
|
||||
| `sonarr.js` | `/api/sonarr` | Yes (`requireAuth`) | Proxy to Sonarr API |
|
||||
| `radarr.js` | `/api/radarr` | Yes (`requireAuth`) | Proxy to Radarr API |
|
||||
| Module | Mount Point | Auth Required | CSRF Required | Purpose |
|
||||
|--------|------------|:-------------:|:-------------:|--------|
|
||||
| `auth.js` | `/api/auth` | No | No | Login, session check, CSRF token, logout |
|
||||
| `dashboard.js` | `/api/dashboard` | Yes (`requireAuth`) | Yes | Aggregated download data, status, cover-art proxy |
|
||||
| `emby.js` | `/api/emby` | Yes (`requireAuth`) | Yes | Proxy to Emby API |
|
||||
| `sabnzbd.js` | `/api/sabnzbd` | Yes (`requireAuth`) | Yes | Proxy to SABnzbd API |
|
||||
| `sonarr.js` | `/api/sonarr` | Yes (`requireAuth`) | Yes | Proxy to Sonarr API |
|
||||
| `radarr.js` | `/api/radarr` | Yes (`requireAuth`) | Yes | Proxy to Radarr API |
|
||||
|
||||
`requireAuth` (`server/middleware/requireAuth.js`) reads the `emby_user` httpOnly cookie and attaches the parsed user to `req.user`. Returns `401` if the cookie is absent or malformed.
|
||||
**`requireAuth`** (`server/middleware/requireAuth.js`) reads the `emby_user` cookie (signed if `COOKIE_SECRET` is set) and attaches the parsed `{ id, name, isAdmin }` user to `req.user`. Returns `401` if the cookie is absent, tampered, or schema-invalid.
|
||||
|
||||
> **Note:** The proxy routes (`emby`, `sabnzbd`, `sonarr`, `radarr`) use legacy single-instance env vars and are largely unused by the dashboard — they exist for direct API access. The dashboard reads from the poller cache.
|
||||
**`verifyCsrf`** (`server/middleware/verifyCsrf.js`) implements the double-submit cookie pattern for all state-changing requests (`POST`, `PUT`, `PATCH`, `DELETE`). Compares the `csrf_token` cookie against the `X-CSRF-Token` request header using `crypto.timingSafeEqual`. Safe methods (`GET`, `HEAD`, `OPTIONS`) are exempt. Auth routes are mounted **before** this middleware (login/logout are exempt by design — `sameSite: strict` provides equivalent protection).
|
||||
|
||||
> **Note:** The proxy routes (`emby`, `sabnzbd`, `sonarr`, `radarr`) exist for direct API access. The dashboard itself reads from the poller cache — it does not proxy through these routes.
|
||||
|
||||
### 4.3 Utility Modules
|
||||
|
||||
@@ -167,9 +223,13 @@ Responsibilities:
|
||||
|
||||
**`poller.js`** — Background polling engine. Fetches data from all configured service instances in parallel with per-task timing. Stores results in the cache with a configurable TTL. Can be disabled entirely (`POLL_INTERVAL=0`), in which case data is fetched on-demand per dashboard request.
|
||||
|
||||
**`qbittorrent.js`** — `QBittorrentClient` class with cookie-based authentication, automatic re-auth on 403, and persistent client instances. Includes torrent-to-download mapping (`mapTorrentToDownload`) and formatting utilities.
|
||||
**`qbittorrent.js`** — `QBittorrentClient` class with cookie-based authentication, automatic re-auth on 403, and persistent client instances. Includes torrent-to-download mapping (`mapTorrentToDownload`) and formatting utilities (`formatBytes`, `formatSpeed`, `formatEta`).
|
||||
|
||||
**`logger.js`** — Simple file appender writing timestamped messages to `server.log`.
|
||||
**`tokenStore.js`** — JSON file-backed store (`DATA_DIR/tokens.json`) for Emby `AccessToken`s. Tokens are stored server-side and **never sent to the client**. Writes are atomic (write to `.tmp` then rename). Entries expire after 31 days (slightly longer than the maximum 30-day cookie). Pruning runs on startup and hourly.
|
||||
|
||||
**`sanitizeError.js`** — Redacts secrets from error message strings before they are logged or returned in API responses. Patterns: URL query-param secrets (`apikey=`, `token=`, etc.), HTTP auth headers (`Authorization:`, `X-Emby-Authorization:`, etc.), Bearer tokens, and basic-auth credentials in URLs.
|
||||
|
||||
**`logger.js`** — Simple file appender writing timestamped messages to `DATA_DIR/server.log`.
|
||||
|
||||
---
|
||||
|
||||
@@ -212,12 +272,19 @@ When a user requests `/api/dashboard/user-downloads`:
|
||||
|
||||
### Flow
|
||||
|
||||
1. User submits credentials via the login form
|
||||
2. Backend calls Emby `POST /Users/authenticatebyname`
|
||||
3. On success, fetches full user profile to determine admin status
|
||||
4. Sets an `httpOnly` cookie (`emby_user`) containing: `{ id, name, isAdmin }` — the Emby `AccessToken` is intentionally **not** stored in the cookie
|
||||
5. Cookie expires after 24 hours
|
||||
6. All subsequent dashboard requests read this cookie for identity
|
||||
1. User submits credentials (+ optional `rememberMe`) via the login form
|
||||
2. Backend applies the **login rate limiter** (max 10 failed attempts per IP per 15-minute window; successful requests don't count)
|
||||
3. Backend calls Emby `POST /Users/authenticatebyname` using a deterministic `DeviceId` derived from the username — this causes Emby to reuse the existing session rather than creating a new one on every login
|
||||
4. On success, fetches full user profile (`GET /Users/{id}`) to determine admin status
|
||||
5. Stores the Emby `AccessToken` in **`tokenStore`** (server-side, never sent to the client)
|
||||
6. Sets an `httpOnly` `emby_user` cookie containing only `{ id, name, isAdmin }`:
|
||||
- **`rememberMe: true`** → persistent cookie, `Max-Age` 30 days
|
||||
- **`rememberMe: false`** → session cookie (no `Max-Age`; expires when browser closes)
|
||||
- `secure` flag enabled when `NODE_ENV=production`
|
||||
- Signed with HMAC when `COOKIE_SECRET` is set
|
||||
7. Issues a `csrf_token` cookie (`httpOnly: false` so JS can read it) containing a 32-byte random hex token
|
||||
8. Returns `{ success: true, user, csrfToken }` — the SPA stores `csrfToken` in memory and sends it as `X-CSRF-Token` on all subsequent state-changing requests
|
||||
9. All subsequent dashboard requests read the `emby_user` cookie for identity via `requireAuth`
|
||||
|
||||
### Authorisation Matrix
|
||||
|
||||
@@ -336,47 +403,76 @@ Each matched download produces an object with:
|
||||
|
||||
### `POST /api/auth/login`
|
||||
|
||||
Authenticate a user via Emby.
|
||||
Authenticate a user via Emby. Rate-limited to **10 failed attempts per IP per 15 minutes**.
|
||||
|
||||
**Request Body:**
|
||||
```json
|
||||
{ "username": "string", "password": "string" }
|
||||
{ "username": "string", "password": "string", "rememberMe": false }
|
||||
```
|
||||
|
||||
| Field | Required | Description |
|
||||
|-------|:--------:|-----------|
|
||||
| `username` | Yes | Max 128 chars, must be a non-empty string |
|
||||
| `password` | Yes | Max 256 chars |
|
||||
| `rememberMe` | No | `true` = 30-day persistent cookie; `false`/omitted = session cookie |
|
||||
|
||||
**Response (200):**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"user": { "id": "string", "name": "string", "isAdmin": true }
|
||||
"user": { "id": "string", "name": "string", "isAdmin": false },
|
||||
"csrfToken": "64-char hex string"
|
||||
}
|
||||
```
|
||||
|
||||
**Response (400):** Invalid input (empty/overlong username or password).
|
||||
|
||||
**Response (401):**
|
||||
```json
|
||||
{ "success": false, "error": "Invalid username or password" }
|
||||
```
|
||||
|
||||
**Side Effect:** Sets `emby_user` httpOnly cookie (24h TTL, `httpOnly`, `secure` in production, `sameSite: strict`). Cookie payload: `{ id, name, isAdmin }` — Emby `AccessToken` is not stored.
|
||||
**Response (429):** Too many failed attempts from this IP.
|
||||
|
||||
**Side Effects:**
|
||||
- Sets `emby_user` cookie: `httpOnly`, `sameSite: strict`, `secure` in production, signed if `COOKIE_SECRET` is set. Payload: `{ id, name, isAdmin }`. The Emby `AccessToken` is **never** included.
|
||||
- Sets `csrf_token` cookie: `httpOnly: false` (JS-readable), same security flags. 64-char hex.
|
||||
- Stores the Emby `AccessToken` server-side in `tokenStore` (used only for server-side Emby logout).
|
||||
|
||||
---
|
||||
|
||||
### `GET /api/auth/me`
|
||||
|
||||
Check current session.
|
||||
Check current session (no auth required — returns unauthenticated state rather than 401).
|
||||
|
||||
**Response:**
|
||||
**Response (authenticated):**
|
||||
```json
|
||||
{
|
||||
"authenticated": true,
|
||||
"user": { "id": "string", "name": "string", "isAdmin": false }
|
||||
}
|
||||
{ "authenticated": true, "user": { "id": "string", "name": "string", "isAdmin": false } }
|
||||
```
|
||||
|
||||
**Response (not authenticated):**
|
||||
```json
|
||||
{ "authenticated": false }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `GET /api/auth/csrf`
|
||||
|
||||
Issue a fresh CSRF token. Used by the SPA after a page reload when the in-memory `csrfToken` variable is lost.
|
||||
|
||||
**Response (200):**
|
||||
```json
|
||||
{ "csrfToken": "64-char hex string" }
|
||||
```
|
||||
|
||||
**Side Effect:** Sets a new `csrf_token` cookie.
|
||||
|
||||
---
|
||||
|
||||
### `POST /api/auth/logout`
|
||||
|
||||
Clear session cookie.
|
||||
Clear session and revoke the Emby token server-side. Does **not** require a CSRF token (auth routes are mounted before `verifyCsrf`; `sameSite: strict` provides equivalent protection).
|
||||
|
||||
---
|
||||
|
||||
@@ -518,26 +614,47 @@ The dashboard polls the server at the user-selected interval (1s, 5s, 10s, or Of
|
||||
|
||||
### Environment Variables
|
||||
|
||||
#### Core
|
||||
|
||||
| Variable | Required | Default | Description |
|
||||
|----------|:--------:|---------|-------------|
|
||||
| `PORT` | No | `3001` | Server listen port |
|
||||
| `EMBY_URL` | Yes | — | Emby/Jellyfin server URL |
|
||||
| `EMBY_API_KEY` | Yes | — | Emby API key |
|
||||
| `NODE_ENV` | No | — | Set to `production` to enable `secure` cookies and HTTPS upgrades |
|
||||
| `DATA_DIR` | No | `./data` | Directory for `tokens.json` and `server.log`. Must be writable by the server process. In Docker: `/app/data` (named volume). |
|
||||
| `COOKIE_SECRET` | No | — | If set, signs all session cookies with HMAC-SHA256. Strongly recommended in production. |
|
||||
| `TRUST_PROXY` | No | — | Express `trust proxy` setting. Set to `1` (or a hop count) when behind a reverse proxy (nginx, Traefik, etc.) so `req.ip` and `req.secure` are correct. |
|
||||
|
||||
#### Emby
|
||||
|
||||
| Variable | Required | Default | Description |
|
||||
|----------|:--------:|---------|-------------|
|
||||
| `EMBY_URL` | Yes | — | Emby/Jellyfin base URL (e.g. `https://emby.example.com`) |
|
||||
| `EMBY_API_KEY` | Yes | — | Emby API key (used by the poller to list users for tag badge classification) |
|
||||
|
||||
#### Service Instances
|
||||
|
||||
| Variable | Required | Default | Description |
|
||||
|----------|:--------:|---------|-------------|
|
||||
| `SONARR_INSTANCES` | Yes* | — | JSON array of Sonarr instances |
|
||||
| `SONARR_URL` | Yes* | — | Legacy single Sonarr URL |
|
||||
| `SONARR_API_KEY` | Yes* | — | Legacy single Sonarr API key |
|
||||
| `SONARR_URL` | Yes* | — | Legacy: single Sonarr URL |
|
||||
| `SONARR_API_KEY` | Yes* | — | Legacy: single Sonarr API key |
|
||||
| `RADARR_INSTANCES` | Yes* | — | JSON array of Radarr instances |
|
||||
| `RADARR_URL` | Yes* | — | Legacy single Radarr URL |
|
||||
| `RADARR_API_KEY` | Yes* | — | Legacy single Radarr API key |
|
||||
| `RADARR_URL` | Yes* | — | Legacy: single Radarr URL |
|
||||
| `RADARR_API_KEY` | Yes* | — | Legacy: single Radarr API key |
|
||||
| `SABNZBD_INSTANCES` | Yes* | — | JSON array of SABnzbd instances |
|
||||
| `SABNZBD_URL` | Yes* | — | Legacy single SABnzbd URL |
|
||||
| `SABNZBD_API_KEY` | Yes* | — | Legacy single SABnzbd API key |
|
||||
| `SABNZBD_URL` | Yes* | — | Legacy: single SABnzbd URL |
|
||||
| `SABNZBD_API_KEY` | Yes* | — | Legacy: single SABnzbd API key |
|
||||
| `QBITTORRENT_INSTANCES` | No | — | JSON array of qBittorrent instances |
|
||||
| `POLL_INTERVAL` | No | `5000` | Poll interval in ms, or `off`/`0` to disable |
|
||||
| `LOG_LEVEL` | No | `info` | `debug`, `info`, `warn`, `error`, `silent` |
|
||||
|
||||
\* Either `*_INSTANCES` (JSON array) or legacy `*_URL` + `*_API_KEY` format is required.
|
||||
|
||||
#### Tuning
|
||||
|
||||
| Variable | Required | Default | Description |
|
||||
|----------|:--------:|---------|-------------|
|
||||
| `POLL_INTERVAL` | No | `5000` | Poll interval in ms. Set to `0`, `off`, or `false` to disable background polling (on-demand mode). |
|
||||
| `LOG_LEVEL` | No | `info` | `debug`, `info`, `warn`, `error`, `silent` |
|
||||
|
||||
### Instance JSON Format
|
||||
|
||||
```json
|
||||
@@ -561,24 +678,20 @@ qBittorrent instances use `username` and `password` instead of `apiKey`.
|
||||
|
||||
## 12. Deployment
|
||||
|
||||
### Docker
|
||||
### Docker image
|
||||
|
||||
```dockerfile
|
||||
FROM node:18-alpine
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci --omit=dev
|
||||
COPY server/ ./server/
|
||||
COPY public/ ./public/
|
||||
EXPOSE 3001
|
||||
ENV NODE_ENV=production
|
||||
CMD ["node", "server/index.js"]
|
||||
```
|
||||
The production image uses a two-stage build on `node:22-alpine`:
|
||||
|
||||
1. **`deps` stage** — runs `npm ci --omit=dev` to install only production dependencies.
|
||||
2. **`runtime` stage** — copies `node_modules`, `server/`, `public/`, and `package.json`. Runs as the built-in non-root `node` user (UID 1000). `/app/data` is owned by `node` for writable token store and logs.
|
||||
|
||||
Key environment variables set in the image:
|
||||
- `NODE_ENV=production` — enables secure cookies and HTTPS upgrade CSP directive
|
||||
- `DATA_DIR=/app/data` — token store and log file location
|
||||
|
||||
### Docker Compose
|
||||
|
||||
```yaml
|
||||
version: "3"
|
||||
services:
|
||||
sofarr:
|
||||
image: docker.i3omb.com/sofarr:latest
|
||||
@@ -587,6 +700,10 @@ services:
|
||||
ports:
|
||||
- "3001:3001"
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- DATA_DIR=/app/data
|
||||
- COOKIE_SECRET=change-me-to-a-long-random-string
|
||||
- TRUST_PROXY=1 # set if behind nginx/Traefik
|
||||
- EMBY_URL=https://emby.example.com
|
||||
- EMBY_API_KEY=your-emby-api-key
|
||||
- SONARR_INSTANCES=[{"name":"main","url":"...","apiKey":"..."}]
|
||||
@@ -595,8 +712,31 @@ services:
|
||||
- QBITTORRENT_INSTANCES=[{"name":"main","url":"...","username":"...","password":"..."}]
|
||||
- POLL_INTERVAL=5000
|
||||
- LOG_LEVEL=info
|
||||
volumes:
|
||||
- sofarr-data:/app/data # persists tokens.json and server.log
|
||||
|
||||
volumes:
|
||||
sofarr-data:
|
||||
```
|
||||
|
||||
### Security hardening checklist
|
||||
|
||||
- **Set `COOKIE_SECRET`** — enables HMAC-signed cookies, preventing client-side forgery.
|
||||
- **Set `TRUST_PROXY=1`** when behind a reverse proxy — ensures `req.secure` is `true` so the `secure` cookie flag is enforced and HTTPS-upgrade CSP fires.
|
||||
- **Mount a named volume** for `DATA_DIR` — token store and log file survive container recreates.
|
||||
- **Use HTTPS** — the CSP includes `upgrade-insecure-requests` in production and the HSTS header is set with a 1-year `maxAge`.
|
||||
- **Rate limiting** — the login endpoint is limited to 10 failed attempts per IP per 15 minutes; all API endpoints share a 300 req/15 min window. Set `TRUST_PROXY` correctly so the client IP is not the proxy's IP.
|
||||
|
||||
### CI / CD
|
||||
|
||||
The `.gitea/workflows/` directory contains three pipeline definitions:
|
||||
|
||||
| File | Trigger | Purpose |
|
||||
|------|---------|--------|
|
||||
| `ci.yml` | Every push / PR | Security audit (`npm audit --audit-level=high`) + tests with V8 coverage |
|
||||
| `build-image.yml` | Push to `main` / `develop` | Build and push Docker image to `docker.i3omb.com` |
|
||||
| `create-release.yml` | Tag push (`v*`) | Create a Gitea release |
|
||||
|
||||
---
|
||||
|
||||
## 13. UML Diagrams (PlantUML)
|
||||
|
||||
Reference in New Issue
Block a user