diff --git a/.env.example b/.env.example deleted file mode 100644 index 1a0d502..0000000 --- a/.env.example +++ /dev/null @@ -1,37 +0,0 @@ -# Server Configuration -PORT=3001 -LOG_LEVEL=info - -# Cookie signing secret for tamper-proof session cookies -# Required in production. Generate with: openssl rand -hex 32 -COOKIE_SECRET=your_cookie_secret_here - -# Set to 1 (or a specific IP/CIDR) when running behind a reverse proxy -# (Nginx, Caddy, Traefik) so Express trusts X-Forwarded-For/Proto. -# Leave unset if sofarr is exposed directly. -# TRUST_PROXY=1 - -# Directory for persistent data (SQLite token store + logs) -# Defaults to ./data relative to project root -# DATA_DIR=/app/data - -# Background polling interval in ms (default: 5000) -# Set to 0 or "off" to disable and fetch on-demand instead -# POLL_INTERVAL=5000 - -# Emby Configuration (single instance) -EMBY_URL=http://localhost:8096 -EMBY_API_KEY=your_emby_api_key - -# SABnzbd Instances (JSON array) -# Format: [{"name": "Instance Name", "url": "http://...", "apiKey": "..."}] -SABNZBD_INSTANCES=[{"name": "Primary", "url": "http://localhost:8080", "apiKey": "your_api_key"}] - -# Sonarr Instances (JSON array) -SONARR_INSTANCES=[{"name": "Primary", "url": "http://localhost:8989", "apiKey": "your_api_key"}] - -# Radarr Instances (JSON array) -RADARR_INSTANCES=[{"name": "Primary", "url": "http://localhost:7878", "apiKey": "your_api_key"}] - -# qBittorrent Instances (JSON array) -QBITTORRENT_INSTANCES=[{"name": "main", "url": "http://localhost:8080", "username": "admin", "password": "your_password"}] diff --git a/.env.sample b/.env.sample index 4618eea..38abe82 100644 --- a/.env.sample +++ b/.env.sample @@ -19,6 +19,24 @@ LOG_LEVEL=info # Generate with: openssl rand -hex 32 COOKIE_SECRET=your-cookie-secret-here +# ============================================================================= +# TLS / HTTPS +# ============================================================================= + +# TLS is enabled by default using the bundled snakeoil self-signed certificate +# (valid for localhost/127.0.0.1, 10-year expiry). +# Set TLS_CERT and TLS_KEY to use your own certificate (recommended). +# Set TLS_ENABLED=false to run in plain HTTP mode (e.g. behind a TLS proxy). +# +# To generate a self-signed cert for your own hostname: +# openssl req -x509 -newkey rsa:2048 -keyout server.key -out server.crt \ +# -days 365 -nodes -subj "/CN=yourhostname" \ +# -addext "subjectAltName=DNS:yourhostname,IP:192.168.x.x" +# +# TLS_ENABLED=true +# TLS_CERT=/path/to/server.crt +# TLS_KEY=/path/to/server.key + # ============================================================================= # REVERSE PROXY & DEPLOYMENT # ============================================================================= @@ -34,6 +52,10 @@ COOKIE_SECRET=your-cookie-secret-here # Defaults to ./data relative to the project root. # DATA_DIR=/app/data +# Number of days of completed download history to show in the Recently Completed section. +# Override per-request with ?days=N (capped at 90). +# RECENT_COMPLETED_DAYS=7 + # Background polling interval in milliseconds (default: 5000) # sofarr polls all services in the background and caches results so # dashboard requests are near-instant. diff --git a/Dockerfile b/Dockerfile index 63f3dff..c5c8e09 100644 --- a/Dockerfile +++ b/Dockerfile @@ -34,6 +34,9 @@ COPY --from=deps /app/node_modules ./node_modules COPY --chown=root:root server/ ./server/ COPY --chown=root:root public/ ./public/ COPY --chown=root:root package.json ./ +# Bundled snakeoil certificate for out-of-the-box TLS (self-signed, localhost only). +# Mount your own cert/key over /app/certs/ or set TLS_CERT/TLS_KEY env vars. +COPY --chown=root:root certs/ ./certs/ # Persistent data directory owned by node user (token store, logs) RUN mkdir -p /app/data && chown node:node /app/data @@ -47,7 +50,9 @@ USER node EXPOSE 3001 # HEALTHCHECK — Docker will restart the container if this fails 3 times +# --no-check-certificate handles self-signed / snakeoil certs. +# Remove that flag when using a CA-signed certificate. HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ - CMD wget -qO- http://localhost:3001/health || exit 1 + CMD wget -qO- --no-check-certificate https://localhost:3001/health || exit 1 CMD ["node", "server/index.js"] diff --git a/certs/.gitignore b/certs/.gitignore new file mode 100644 index 0000000..fac9498 --- /dev/null +++ b/certs/.gitignore @@ -0,0 +1,6 @@ +# Ignore all cert/key files EXCEPT the bundled snakeoil development defaults. +# Never commit real TLS certificates or private keys to version control. +* +!.gitignore +!snakeoil.crt +!snakeoil.key diff --git a/certs/snakeoil.crt b/certs/snakeoil.crt new file mode 100644 index 0000000..9af3c74 --- /dev/null +++ b/certs/snakeoil.crt @@ -0,0 +1,22 @@ +-----BEGIN CERTIFICATE----- +MIIDoTCCAomgAwIBAgIUPgupZzf+zgMp4ylvf1NBRIWNpeUwDQYJKoZIhvcNAQEL +BQAwUjELMAkGA1UEBhMCR0IxDjAMBgNVBAgMBUxvY2FsMQ4wDAYDVQQHDAVMb2Nh +bDEPMA0GA1UECgwGc29mYXJyMRIwEAYDVQQDDAlsb2NhbGhvc3QwHhcNMjYwNTE3 +MDk0NDQ4WhcNMzYwNTE0MDk0NDQ4WjBSMQswCQYDVQQGEwJHQjEOMAwGA1UECAwF +TG9jYWwxDjAMBgNVBAcMBUxvY2FsMQ8wDQYDVQQKDAZzb2ZhcnIxEjAQBgNVBAMM +CWxvY2FsaG9zdDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAL5Y05DF +9U3k27SGByJqdbKThRRabX0hsK0zckkbXoaj0U4odZatUicAqkm6yWzpqQyZM6qH +XX68LXqJk8r2VIdTTtKe5QU1WUAsW6KD0c514YC1VZ3a9ivQ2/tfdQsm8YxM/ECq +e2XFklfvE72HkHF4fM9LCMS/LeazazF0ogNTkyE27g9Ry/ofR2P4MymLtyBbQ1NA +B1zYRIhJ5HqFRMszBKhWi1zRgUQQNBuYA5wtxnXA9QNSz6ObtdWStfJ/C1Kuitpe +OVrB/TKCp1OKBpZTd4mBKlvdJWos9eZPzP4vLnsReT4pHBx6J2Jd1dC97/MAXLYP +mIXP+04zK5sWRUsCAwEAAaNvMG0wHQYDVR0OBBYEFCpYKb3zMgv4qThe8ukFrVIl +lhD3MB8GA1UdIwQYMBaAFCpYKb3zMgv4qThe8ukFrVIllhD3MA8GA1UdEwEB/wQF +MAMBAf8wGgYDVR0RBBMwEYcEfwAAAYIJbG9jYWxob3N0MA0GCSqGSIb3DQEBCwUA +A4IBAQBQ5Jg0pn4XW/560laNl7XAoGuMwrIy5j6zDlIgFE8DWAb0NGNyu/FkCVIZ +ruLNktq+w+kTeneAuYW3CGyac2HZo9T60VtQNL/k17hrDw1F6kMpAAYm6aiyFtE9 +Stw07PMvpvjxKvetPLOQsfk0/hh0Nh2PiVVdqvVE/gGoraboHEfH+eGf/Arzg7s4 +CzZTEz0OJP5i7VkZAvFygShPx/gHY77ojeHPl2LN3KI7s43TrjYZjxbUDwWDpGp0 +BIsDFP+9NAvA5I74biChfEEopmBczVbQRtqBxBX1JTa2aT5w/ifUNyKVKIGp49Q8 +o59gDmbCXhypom7OsyxBLZgyVWU1 +-----END CERTIFICATE----- diff --git a/certs/snakeoil.key b/certs/snakeoil.key new file mode 100644 index 0000000..253cb09 --- /dev/null +++ b/certs/snakeoil.key @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC+WNOQxfVN5Nu0 +hgcianWyk4UUWm19IbCtM3JJG16Go9FOKHWWrVInAKpJusls6akMmTOqh11+vC16 +iZPK9lSHU07SnuUFNVlALFuig9HOdeGAtVWd2vYr0Nv7X3ULJvGMTPxAqntlxZJX +7xO9h5BxeHzPSwjEvy3ms2sxdKIDU5MhNu4PUcv6H0dj+DMpi7cgW0NTQAdc2ESI +SeR6hUTLMwSoVotc0YFEEDQbmAOcLcZ1wPUDUs+jm7XVkrXyfwtSroraXjlawf0y +gqdTigaWU3eJgSpb3SVqLPXmT8z+Ly57EXk+KRwceidiXdXQve/zAFy2D5iFz/tO +MyubFkVLAgMBAAECggEAEk0RCyNCJBSdqSKWLtFq8o0rdBsDpugyOymuOQHOjOxu +oQo6NnWaNF5kgQVAyfd0EpGFfavyw2iNVaUPo1zoU9ogwuOWSnHOj64UIcp0+PN6 +VHknohWqdukBLyiGfnJM/0ieaKTbi2i7dGsfDA+rxbUoO6s2GYPC6O9inlUqVJGU +fBXKTbCneW1+hJQFcOKPW2+qwiUxbudG9neNTYFOm9a7Bfa6MLJ22mkfYVrHYDzo +gESo8sHXbEtvemka9jN0N/GoXgUi7iFXbqslPUoakCF7sgG0cXuUM9duSt67ynLj +j7Ix+QiKAZgvSO/83b7vK74Xmd6XFUif41VMZ2iEGQKBgQDqp/ZSCAakarRpBRN4 +psKuhaYiBKurb5GezTOSfEGWxmng2s0bHcx74alKKNN8Tu2mNx6yafQdRNQdM+fG +dn8JnQUGXYpGwQbLHZ/aO0M64WBxfQku4JNGh+VZ3ZyUC+So28vzCYqx8qwJi94L +2sF8787hzHO1INzVaeXzQ89pEwKBgQDPqRzsJsP+zZmA4x16CtoWY7Z1d4z0GTkA +erlXNinu76AIvra6aUld/65ymMyNIIpLZoci55ZGxBY7g8U29e10iT+xmxAgq3VT +Tn438MbDXEgA+HvdRXCFPvNQQk0ZDegazdhTdtuhj+IHcm4M94zfVM3MSJOr0hJf +JGaVIGEx6QKBgDZLftckPEU221+haQv1qf4vtm0Qn5gfTJZt7Izsa1CzwDPi7Kpl +jrbrU/xwzd5pdNuMzXGCypUrI9lN9Ucai/JxfoQmiKQubZ/5zs7z/25UT7hysflC +xVEAiLTubhhjWBkqIlqtzoW2HNBoqIwdpb9+zWO5ptw2KmLHCgnrmsY5AoGBAKOt +YCaix4lm9L8qRGmVdCCBp6ce+/LKjqtaEAw1nQe/yBwcdlqn8jQs+4tH9LKoG1kj +DxDsCP7uP7fZPPD9FpTsOU/8MNIPUwK+s63UElaZvgdF1BusR+w+mfmAyNQeqfu2 +k/P1k1fc2QOVpjiCRn8hkLSb4AlmIyTqxBB23SVBAoGATMtuk1r+SS9SDJW73CI1 +jLkJkPGrBvX0dFZGtedpwLVhUTDnqKIsy2F1iGDRGIAy5IAiLEFx44qQrhUau7zR +/2avY0Ua9To9LW0k/matzJUKvXxYm59jh/GDp6LFU89hsL2zSd6z0Aknvh1SryCb +OSbN8wfCz53+7qea4NQEB4E= +-----END PRIVATE KEY----- diff --git a/docker-compose.yaml b/docker-compose.yaml index 145b885..5478d3c 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -4,14 +4,23 @@ services: container_name: sofarr restart: unless-stopped ports: - - "127.0.0.1:3001:3001" # bind to loopback only — expose via reverse proxy + # Direct HTTPS (default — uses bundled snakeoil cert if TLS_CERT not set) + - "3001:3001" + # Uncomment the line below and comment out the above to bind to loopback + # only when using a reverse proxy (set TLS_ENABLED=false in that case): + # - "127.0.0.1:3001:3001" environment: - PORT=3001 - NODE_ENV=production - LOG_LEVEL=info - # Set to 1 when running behind a reverse proxy (Nginx, Caddy, Traefik) - # so Express trusts X-Forwarded-For and X-Forwarded-Proto headers. - - TRUST_PROXY=1 + # --- TLS --- + # Default: TLS enabled using bundled snakeoil cert (self-signed). + # Supply your own cert/key by mounting them and setting these paths: + # - TLS_CERT=/app/certs/server.crt + # - TLS_KEY=/app/certs/server.key + # Set TLS_ENABLED=false if terminating TLS at a reverse proxy instead. + # If using a reverse proxy, also set TRUST_PROXY=1 below. + # - TRUST_PROXY=1 # --- Replace placeholders with real values or use Docker secrets --- - COOKIE_SECRET=change-me-generate-with-openssl-rand-hex-32 - EMBY_URL=https://emby.example.com @@ -21,8 +30,11 @@ services: - SABNZBD_INSTANCES=[{"name":"main","url":"https://sabnzbd.example.com","apiKey":"your-sabnzbd-api-key"}] - QBITTORRENT_INSTANCES=[{"name":"main","url":"https://qbittorrent.example.com","username":"admin","password":"your-password"}] volumes: - # Persistent volume for SQLite token store and log file + # Persistent volume for token store and log file - sofarr-data:/app/data + # Mount your own TLS certificate and key (optional — snakeoil used if omitted) + # - /path/to/your/server.crt:/app/certs/server.crt:ro + # - /path/to/your/server.key:/app/certs/server.key:ro # Run as the built-in non-root 'node' user (UID/GID 1000) user: "1000:1000" # Read-only root filesystem; only the data volume is writable @@ -35,7 +47,9 @@ services: - ALL # drop all Linux capabilities cap_add: [] # add back none — Node.js needs no special caps healthcheck: - test: ["CMD", "wget", "-qO-", "http://localhost:3001/health"] + # Uses --no-check-certificate for self-signed / snakeoil certs. + # Remove that flag if using a CA-signed certificate. + test: ["CMD", "wget", "-qO-", "--no-check-certificate", "https://localhost:3001/health"] interval: 30s timeout: 5s retries: 3 diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 70699ee..50d013f 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -35,45 +35,46 @@ Admin users can view all users' downloads, see server status, cache statistics, ### High-Level Architecture -``` -┌─────────────────────────────────────────────────────┐ -│ Browser (SPA) │ -│ ┌──────────┐ ┌──────────┐ ┌───────────────────┐ │ -│ │ Login │ │Dashboard │ │ Status Panel │ │ -│ │ Form │ │ Cards │ │ (Admin only) │ │ -│ └────┬─────┘ └────┬─────┘ └───────┬───────────┘ │ -│ │ │ │ │ -└───────┼──────────────┼────────────────┼──────────────┘ - │ POST /login │ GET /user- │ GET /status - │ │ downloads │ - ▼ ▼ ▼ -┌─────────────────────────────────────────────────────┐ -│ Express Server (:3001) │ -│ ┌────────┐ ┌──────────┐ ┌────────┐ ┌────────────┐ │ -│ │ Auth │ │Dashboard │ │ Emby │ │ Static │ │ -│ │ Routes │ │ Routes │ │ Routes │ │ Files │ │ -│ └────┬───┘ └────┬─────┘ └────┬───┘ └────────────┘ │ -│ │ │ │ │ -│ ┌────┴──────────┴────────────┴──────────────────┐ │ -│ │ Utilities Layer │ │ -│ │ ┌────────┐ ┌────────┐ ┌──────┐ ┌──────────┐ │ │ -│ │ │ Poller │ │ Cache │ │Config│ │qBittorrent│ │ │ -│ │ └───┬────┘ └────────┘ └──────┘ └──────────┘ │ │ -│ └──────┼────────────────────────────────────────┘ │ -└─────────┼────────────────────────────────────────────┘ - │ HTTP/API calls - ▼ -┌──────────────────────────────────────────────────────┐ -│ External Services │ -│ ┌──────────┐ ┌────────┐ ┌────────┐ ┌────────────┐ │ -│ │ SABnzbd │ │ Sonarr │ │ Radarr │ │qBittorrent │ │ -│ │ (Usenet) │ │ (TV) │ │(Movie) │ │ (Torrent) │ │ -│ └──────────┘ └────────┘ └────────┘ └────────────┘ │ -│ ┌──────────────────────────────────────────────┐ │ -│ │ Emby / Jellyfin │ │ -│ │ (Authentication + User DB) │ │ -│ └──────────────────────────────────────────────┘ │ -└──────────────────────────────────────────────────────┘ +```mermaid +flowchart TB + subgraph Browser["Browser (SPA)"] + login["Login Form"] + dash["Dashboard Cards"] + status["Status Panel\n(Admin only)"] + end + + subgraph Server["Express Server (:3001)"] + auth_r["Auth Routes\n/api/auth"] + dash_r["Dashboard Routes\n/api/dashboard"] + emby_r["Emby Routes\n/api/emby"] + static_f["Static Files\npublic/"] + + subgraph Utils["Utilities Layer"] + poller["Poller"] + cache["Cache"] + config["Config"] + qbt["qBittorrent"] + end + end + + subgraph Ext["External Services"] + sab["SABnzbd\n(Usenet)"] + sonarr["Sonarr\n(TV)"] + radarr["Radarr\n(Movies)"] + qbittorrent["qBittorrent\n(Torrent)"] + emby["Emby / Jellyfin\n(Auth + User DB)"] + end + + login -->|"POST /login"| auth_r + dash -->|"GET /stream SSE\nGET /user-downloads"| dash_r + status -->|"GET /status"| dash_r + + auth_r -->|"authenticate"| emby + emby_r -->|"proxy"| emby + dash_r --> Utils + poller -->|"HTTP/API calls"| sab & sonarr & radarr + qbt -->|"HTTP/API calls"| qbittorrent + static_f -.->|"serve"| Browser ``` --- @@ -131,13 +132,15 @@ sofarr/ │ │ ├── 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 +│ │ ├── radarr.js # Proxy routes to Radarr API +│ │ └── history.js # GET /api/history/recent — recently completed downloads │ ├── 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 +│ ├── historyFetcher.js # Fetch + cache Sonarr/Radarr history; event classification │ ├── logger.js # File logger (DATA_DIR/server.log) │ ├── poller.js # Background polling engine + timing │ ├── qbittorrent.js # qBittorrent client with auth + torrent mapping @@ -208,6 +211,7 @@ sofarr/ | `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 | +| `history.js` | `/api/history` | Yes (`requireAuth`) | No (GET only) | Recently completed downloads from Sonarr/Radarr history | **`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. @@ -229,6 +233,8 @@ sofarr/ **`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. +**`historyFetcher.js`** — Fetches history records from all Sonarr/Radarr instances for a configurable date window (`since`). Results are cached under `history:sonarr` / `history:radarr` for 5 minutes. Exports `classifySonarrEvent` / `classifyRadarrEvent` (returns `'imported'` | `'failed'` | `'other'`) and `invalidateHistoryCache`. + **`logger.js`** — Simple file appender writing timestamped messages to `DATA_DIR/server.log`. --- @@ -341,6 +347,8 @@ Users are matched to downloads via tags in Sonarr/Radarr: | `poll:radarr-tags` | `[{id, label}]` | Radarr tag API | | `poll:qbittorrent` | `[torrent, ...]` | qBittorrent all torrents | | `emby:users` | `Map` | Full Emby user list (60s TTL) | +| `history:sonarr` | `[record, ...]` — flat array with `_instanceUrl` / `_instanceName` | Sonarr history (5 min TTL, fetched on-demand by `/api/history/recent`) | +| `history:radarr` | `[record, ...]` — flat array with `_instanceUrl` / `_instanceName` | Radarr history (5 min TTL, fetched on-demand by `/api/history/recent`) | ### TTL Strategy @@ -361,22 +369,21 @@ The core logic in `dashboard.js` matches raw download client data to *arr servic For each download item (SABnzbd slot or qBittorrent torrent): -``` -1. Try Sonarr QUEUE match (by title substring) - → resolve series via seriesMap (embedded in queue record) - → extract user tag → check tag matches requesting user +```mermaid +flowchart TD + Start(["Download item"]) --> SQ{"Sonarr QUEUE\nmatch (title)"} + SQ -->|yes| SQR["Resolve series via seriesMap\nextract user tag → check match"] + SQ -->|no| RQ{"Radarr QUEUE\nmatch (title)"} + RQ -->|yes| RQR["Resolve movie via moviesMap\nextract user tag → check match"] + RQ -->|no| SH{"Sonarr HISTORY\nmatch (title)"} + SH -->|yes| SHR["Resolve series via seriesId\nextract user tag → check match"] + SH -->|no| RH{"Radarr HISTORY\nmatch (title)"} + RH -->|yes| RHR["Resolve movie via movieId\nextract user tag → check match"] + RH -->|no| Skip(["Skip — unmatched"]) -2. Try Radarr QUEUE match (by title substring) - → resolve movie via moviesMap (embedded in queue record) - → extract user tag → check tag matches requesting user - -3. Try Sonarr HISTORY match (by title substring) - → resolve series via seriesMap (from queue) using seriesId - → extract user tag → check tag matches requesting user - -4. Try Radarr HISTORY match (by title substring) - → resolve movie via moviesMap (from queue) using movieId - → extract user tag → check tag matches requesting user + SQR & RQR & SHR & RHR --> Tagged{"Tag matches\nrequesting user?"} + Tagged -->|yes| Include(["Include in response"]) + Tagged -->|no| Skip ``` ### Title Matching @@ -587,24 +594,75 @@ Admin-only per-user download counts (fetches live from APIs, not cached). --- +### `GET /api/history/recent` + +Returns recently completed (imported or failed) downloads from Sonarr/Radarr history for the authenticated user, filtered to the last `days` days. + +**Query Parameters:** +| Param | Type | Default | Description | +|-------|------|---------|-------------| +| `days` | integer | `RECENT_COMPLETED_DAYS` env (default `7`) | How many days back to search. Capped at 90. | +| `showAll` | `"true"` | — | (Admin) Return records for all tagged users, not just the current user | + +**Response (200):** +```json +{ + "user": "Alice", + "isAdmin": false, + "days": 7, + "history": [ + { + "type": "series", + "outcome": "imported", + "title": "Show.S01E01.720p", + "seriesName": "My Show", + "coverArt": "https://…/poster.jpg", + "completedAt": "2026-05-15T18:00:00.000Z", + "quality": "720p", + "instanceName": "Main Sonarr", + "arrLink": "https://sonarr.example.com/series/my-show", + "allTags": ["alice"], + "matchedUserTag": "alice", + "arrRecordId": 1234, + "failureMessage": null + } + ] +} +``` + +- `outcome` is `"imported"` or `"failed"`. Records with other event types (e.g. `grabbed`) are filtered out. +- `failureMessage` is only included when the authenticated user is an admin and `outcome` is `"failed"`. +- `arrRecordId` is only included for admin users. +- Results are sorted newest first. +- History data is cached server-side for 5 minutes (`history:sonarr` / `history:radarr` cache keys). + +--- + ## 10. Frontend Architecture The frontend is a **vanilla JavaScript SPA** with no build step. All logic resides in `app.js`, styled by `style.css`, and structured by `index.html`. ### UI States -``` -┌──────────────┐ ┌──────────────┐ ┌──────────────┐ -│ Splash Screen│────▶│ Login Form │────▶│ Dashboard │ -│ (on load) │ │ (if no │ │ (after auth) │ -│ │ │ session) │ │ │ -└──────────────┘ └──────────────┘ └──────────────┘ - │ - ┌─────┴─────┐ - │ Status │ - │ Panel │ - │ (admin) │ - └───────────┘ +```mermaid +stateDiagram-v2 + [*] --> SplashScreen : Page load + SplashScreen --> LoginForm : No session + SplashScreen --> Dashboard : Valid session + LoginForm --> Dashboard : Auth success + Dashboard --> LoginForm : Logout + + state Dashboard { + [*] --> ActiveDownloads + ActiveDownloads --> ActiveDownloads : SSE update + + state StatusPanel { + [*] --> Closed + Closed --> Open : Click Status (admin) + Open --> Closed : Click close + Open --> Open : 5s refresh + } + } ``` ### Key Frontend Functions @@ -661,6 +719,9 @@ The status panel refreshes on a fixed 5-second timer and shows each SSE client w | `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. | +| `TLS_ENABLED` | No | `true` | Set to `false` to disable HTTPS and run plain HTTP (e.g. when TLS is terminated by a reverse proxy). | +| `TLS_CERT` | No | `certs/snakeoil.crt` | Path to the TLS certificate file (PEM). Defaults to the bundled self-signed snakeoil certificate. | +| `TLS_KEY` | No | `certs/snakeoil.key` | Path to the TLS private key file (PEM). Defaults to the bundled snakeoil key. | #### Emby @@ -691,6 +752,7 @@ The status panel refreshes on a fixed 5-second timer and shows each SSE client w | 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). | +| `RECENT_COMPLETED_DAYS` | No | `7` | Default lookback window (days) for `GET /api/history/recent`. Overridable per-request via `?days=`. Max 90. | | `LOG_LEVEL` | No | `info` | `debug`, `info`, `warn`, `error`, `silent` | ### Instance JSON Format @@ -726,6 +788,7 @@ The production image uses a two-stage build on `node:22-alpine`: Key environment variables set in the image: - `NODE_ENV=production` — enables production startup validation and logging - `DATA_DIR=/app/data` — token store and log file location +- TLS is **enabled by default** using the bundled snakeoil self-signed certificate (`certs/snakeoil.crt`). Set `TLS_CERT`/`TLS_KEY` to your own certificate, or set `TLS_ENABLED=false` when terminating TLS at a reverse proxy. ### Docker Compose @@ -736,12 +799,17 @@ services: container_name: sofarr restart: unless-stopped ports: - - "3001:3001" + - "3001:3001" # HTTPS by default (snakeoil cert if no TLS_CERT set) 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 + # Option A: direct TLS (default). Supply your own cert/key: + # - TLS_CERT=/app/certs/server.crt + # - TLS_KEY=/app/certs/server.key + # Option B: behind a TLS-terminating reverse proxy: + # - TLS_ENABLED=false + # - TRUST_PROXY=1 - EMBY_URL=https://emby.example.com - EMBY_API_KEY=your-emby-api-key - SONARR_INSTANCES=[{"name":"main","url":"...","apiKey":"..."}] @@ -751,7 +819,10 @@ services: - POLL_INTERVAL=5000 - LOG_LEVEL=info volumes: - - sofarr-data:/app/data # persists tokens.json and server.log + - sofarr-data:/app/data + # Uncomment to supply your own certificate (Option A): + # - /path/to/server.crt:/app/certs/server.crt:ro + # - /path/to/server.key:/app/certs/server.key:ro volumes: sofarr-data: @@ -759,10 +830,10 @@ volumes: ### Security hardening checklist +- **Use HTTPS** — TLS is on by default (snakeoil cert). Supply `TLS_CERT`/`TLS_KEY` pointing to a CA-signed certificate for trusted HTTPS. Alternatively terminate TLS at a reverse proxy and set `TLS_ENABLED=false` + `TRUST_PROXY=1`. - **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. +- **Set `TRUST_PROXY=1`** only when a TLS-terminating reverse proxy sits in front — ensures `req.secure` is correct and the CSP `upgrade-insecure-requests` + `secure` cookie flag fire correctly. - **Mount a named volume** for `DATA_DIR` — token store and log file survive container recreates. -- **Use HTTPS** — set `TRUST_PROXY=1` to enable the CSP `upgrade-insecure-requests` directive, the `secure` cookie flag, and HSTS (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 @@ -816,6 +887,7 @@ graph TB sab_r[sabnzbd.js\n/api/sabnzbd] sonarr_r[sonarr.js\n/api/sonarr] radarr_r[radarr.js\n/api/radarr] + history_r[history.js\n/api/history] end subgraph Utilities @@ -826,6 +898,7 @@ graph TB tokenstore[tokenStore.js\ntokens.json] sanitize[sanitizeError.js] logger[logger.js] + historyfetcher[historyFetcher.js] end entry --> appfactory @@ -835,11 +908,13 @@ graph TB appfactory --> hm & rl & cp & ej appfactory -->|pre-CSRF| auth appfactory --> verifycsrf - appfactory --> dashboard & emby_r & sab_r & sonarr_r & radarr_r + appfactory --> dashboard & emby_r & sab_r & sonarr_r & radarr_r & history_r - dashboard & emby_r & sab_r & sonarr_r & radarr_r --> requireauth + dashboard & emby_r & sab_r & sonarr_r & radarr_r & history_r --> requireauth auth --> tokenstore dashboard --> cache & poller & config & qbt + history_r --> cache & config & historyfetcher + historyfetcher --> cache & config poller --> cache & config & qbt & logger qbt --> config & logger auth & dashboard -.-> sanitize diff --git a/public/app.js b/public/app.js index 8c12ad8..18f3740 100644 --- a/public/app.js +++ b/public/app.js @@ -5,6 +5,11 @@ let showAll = false; let csrfToken = null; // double-submit CSRF token, sent as X-CSRF-Token on mutating requests const SPLASH_MIN_MS = 1200; // minimum splash display time +// History section state +let historyDays = parseInt(localStorage.getItem('sofarr-history-days'), 10) || 7; +let historyRefreshHandle = null; +const HISTORY_REFRESH_MS = 5 * 60 * 1000; // auto-refresh history every 5 min + // SSE stream state let sseSource = null; let sseReconnectTimer = null; @@ -20,6 +25,8 @@ const SSE_RECONNECT_MS = 3000; // browser already retries, but we add explicit b document.addEventListener('DOMContentLoaded', () => { checkAuthentication(); initThemeSwitcher(); + initTabs(); + initHistoryControls(); document.getElementById('login-form').addEventListener('submit', handleLogin); document.getElementById('logout-btn').addEventListener('click', handleLogout); @@ -43,6 +50,30 @@ function setTheme(theme) { }); } +function initTabs() { + const savedTab = localStorage.getItem('sofarr-active-tab') || 'downloads'; + activateTab(savedTab, false); + + document.querySelectorAll('.tab-btn').forEach(btn => { + btn.addEventListener('click', () => { + const tab = btn.dataset.tab; + activateTab(tab, true); + }); + }); +} + +function activateTab(tabName, save) { + document.querySelectorAll('.tab-btn').forEach(btn => { + btn.classList.toggle('active', btn.dataset.tab === tabName); + }); + document.querySelectorAll('.tab-panel').forEach(panel => { + panel.style.display = panel.id === `tab-${tabName}` ? '' : 'none'; + }); + if (save) localStorage.setItem('sofarr-active-tab', tabName); + // Load history the first time the history tab is shown + if (tabName === 'history') loadHistory(); +} + // --- SSE connection management --- function startSSE() { @@ -88,6 +119,8 @@ function handleShowAllToggle(e) { showAll = e.target.checked; // Re-open stream with updated showAll param startSSE(); + // Reload history with updated showAll param + loadHistory(); } function fadeOutLogin() { @@ -209,6 +242,7 @@ async function handleLogin(e) { async function handleLogout() { try { stopSSE(); + stopHistoryRefresh(); if (statusRefreshHandle) { clearInterval(statusRefreshHandle); statusRefreshHandle = null; } await fetch('/api/auth/logout', { method: 'POST', @@ -217,6 +251,7 @@ async function handleLogout() { currentUser = null; csrfToken = null; downloads = []; + clearHistory(); showLogin(); } catch (err) { console.error('Logout failed:', err); @@ -238,6 +273,10 @@ function showDashboard() { sp.style.display = 'none'; sp.innerHTML = ''; document.getElementById('admin-controls').style.display = isAdmin ? 'flex' : 'none'; + // Initialise days input from saved value + const daysInput = document.getElementById('history-days'); + if (daysInput) daysInput.value = historyDays; + startHistoryRefresh(); } function showLoginError(message) { @@ -784,3 +823,197 @@ function hideLoading() { const loading = document.getElementById('loading'); loading.style.display = 'none'; } + +// ============================================================================= +// History section +// ============================================================================= + +function initHistoryControls() { + const daysInput = document.getElementById('history-days'); + const refreshBtn = document.getElementById('history-refresh-btn'); + if (daysInput) { + daysInput.addEventListener('change', () => { + const v = parseInt(daysInput.value, 10); + if (v > 0 && v <= 90) { + historyDays = v; + localStorage.setItem('sofarr-history-days', v); + loadHistory(); + } + }); + } + if (refreshBtn) { + refreshBtn.addEventListener('click', () => loadHistory(true)); + } +} + +function startHistoryRefresh() { + stopHistoryRefresh(); + historyRefreshHandle = setInterval(() => loadHistory(), HISTORY_REFRESH_MS); +} + +function stopHistoryRefresh() { + if (historyRefreshHandle) { + clearInterval(historyRefreshHandle); + historyRefreshHandle = null; + } +} + +function clearHistory() { + document.getElementById('history-list').innerHTML = ''; + document.getElementById('no-history').style.display = 'none'; + document.getElementById('history-error').style.display = 'none'; +} + +async function loadHistory(forceRefresh = false) { + const listEl = document.getElementById('history-list'); + const loadingEl = document.getElementById('history-loading'); + const errorEl = document.getElementById('history-error'); + const noHistoryEl = document.getElementById('no-history'); + + loadingEl.style.display = 'block'; + errorEl.style.display = 'none'; + noHistoryEl.style.display = 'none'; + + try { + const params = new URLSearchParams({ days: historyDays }); + if (showAll) params.set('showAll', 'true'); + if (forceRefresh) params.set('_t', Date.now()); + const res = await fetch(`/api/history/recent?${params}`); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const data = await res.json(); + loadingEl.style.display = 'none'; + renderHistory(data.history || []); + } catch (err) { + loadingEl.style.display = 'none'; + errorEl.textContent = 'Failed to load history.'; + errorEl.style.display = 'block'; + console.error('[History] Load error:', err); + } +} + +function renderHistory(items) { + const listEl = document.getElementById('history-list'); + const noHistoryEl = document.getElementById('no-history'); + listEl.innerHTML = ''; + if (!items.length) { + noHistoryEl.style.display = 'block'; + return; + } + noHistoryEl.style.display = 'none'; + items.forEach(item => listEl.appendChild(createHistoryCard(item))); +} + +function createHistoryCard(item) { + const card = document.createElement('div'); + card.className = `history-card ${item.type} ${item.outcome}`; + + if (item.coverArt) { + const coverDiv = document.createElement('div'); + coverDiv.className = 'history-cover'; + const img = document.createElement('img'); + img.src = '/api/dashboard/cover-art?url=' + encodeURIComponent(item.coverArt); + img.alt = item.movieName || item.seriesName || item.title; + img.loading = 'lazy'; + coverDiv.appendChild(img); + card.appendChild(coverDiv); + } + + const info = document.createElement('div'); + info.className = 'history-info'; + + // Header row: type badge + outcome badge + const header = document.createElement('div'); + header.className = 'history-card-header'; + + const typeBadge = document.createElement('span'); + typeBadge.className = `history-type-badge ${item.type}`; + typeBadge.textContent = item.type === 'series' ? '📺 Series' : '🎬 Movie'; + header.appendChild(typeBadge); + + const outcomeBadge = document.createElement('span'); + outcomeBadge.className = `history-outcome-badge ${item.outcome}`; + outcomeBadge.textContent = item.outcome === 'imported' ? '✓ Imported' : '✗ Failed'; + header.appendChild(outcomeBadge); + + if (item.instanceName) { + const instBadge = document.createElement('span'); + instBadge.className = 'history-instance-badge'; + instBadge.textContent = item.instanceName; + header.appendChild(instBadge); + } + + if (showAll && item.tagBadges && item.tagBadges.length > 0) { + const unmatched = item.tagBadges.filter(b => !b.matchedUser); + const matched = item.tagBadges.filter(b => b.matchedUser); + for (const b of unmatched) { + const badge = document.createElement('span'); + badge.className = 'download-user-badge unmatched'; + badge.textContent = b.label; + header.appendChild(badge); + } + for (const b of matched) { + const badge = document.createElement('span'); + badge.className = 'download-user-badge'; + badge.textContent = b.matchedUser; + header.appendChild(badge); + } + } else if (item.matchedUserTag) { + const badge = document.createElement('span'); + badge.className = 'download-user-badge'; + badge.textContent = item.matchedUserTag; + header.appendChild(badge); + } + + info.appendChild(header); + + // Title + const title = document.createElement('h3'); + title.className = 'history-title'; + title.textContent = item.title; + info.appendChild(title); + + // Series/movie name with optional arr link + if (item.seriesName) { + const p = document.createElement('p'); + p.className = 'history-media-name'; + if (isAdmin && item.arrLink) { + p.innerHTML = 'Series: ' + escapeHtml(item.seriesName) + ''; + } else { + p.textContent = 'Series: ' + item.seriesName; + } + info.appendChild(p); + } + if (item.movieName) { + const p = document.createElement('p'); + p.className = 'history-media-name'; + if (isAdmin && item.arrLink) { + p.innerHTML = 'Movie: ' + escapeHtml(item.movieName) + ''; + } else { + p.textContent = 'Movie: ' + item.movieName; + } + info.appendChild(p); + } + + // Detail pills + const details = document.createElement('div'); + details.className = 'history-details'; + + if (item.completedAt) { + details.appendChild(createDetailItem('Completed', formatDate(item.completedAt))); + } + if (item.quality) { + details.appendChild(createDetailItem('Quality', item.quality)); + } + + // Failed imports: show failure message + if (item.outcome === 'failed' && item.failureMessage) { + const failItem = document.createElement('div'); + failItem.className = 'history-failure-message'; + failItem.textContent = item.failureMessage; + details.appendChild(failItem); + } + + info.appendChild(details); + card.appendChild(info); + return card; +} diff --git a/public/index.html b/public/index.html index ae54cd0..75b9189 100644 --- a/public/index.html +++ b/public/index.html @@ -74,13 +74,40 @@ -
-

Your Downloads

-