Compare commits
117 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 205d95e232 | |||
| 6865b860bc | |||
| 9aaff5c368 | |||
| ce6f9b0459 | |||
| 976d6527b6 | |||
| 6a8ca90fd3 | |||
| 2d5958006c | |||
| 9faf8c0ea3 | |||
| cb0e61ea36 | |||
| bd3b28921d | |||
| 1d9e86760b | |||
| ae3bf70008 | |||
| fb719141fa | |||
| e45c566fd7 | |||
| 81d3e0045f | |||
| 1f3b2adbfe | |||
| 5b84e091b0 | |||
| ad024ab87b | |||
| cc4f420482 | |||
| a435c506f7 | |||
| c8c46cb9fb | |||
| 0354531e95 | |||
| c0dd93a1ab | |||
| 3c4c24d0e4 | |||
| e535da7f91 | |||
| b2d941a767 | |||
| fce8a9ece6 | |||
| 42d01da7f7 | |||
| 43cb3a0d17 | |||
| 6cf01f5530 | |||
| 6bf8098265 | |||
| a42392fec6 | |||
| a368636ec4 | |||
| f23117ff7a | |||
| 2cf163dfff | |||
| 6ff97ed246 | |||
| ef89207d9d | |||
| fa5805c6a4 | |||
| 57bab01855 | |||
| 0e22c5af15 | |||
| 2550722446 | |||
| 716d98e531 | |||
| 27648c78b3 | |||
| fa72cfb5ec | |||
| b3edd442f5 | |||
| e4be334ad4 | |||
| bdd78407bb | |||
| 37c8229061 | |||
| d1496a76e2 | |||
| 80d43fbaa8 | |||
| c1fb55c5b8 | |||
| 742f34f6eb | |||
| 2b089871a0 | |||
| e8ffd7f7dd | |||
| dd7e3e2a90 | |||
| 557137421d | |||
| 71880c6298 | |||
| 6b995a136d | |||
| fa3c625fb8 | |||
| 57b3254f70 | |||
| eb321312dc | |||
| ddcfbda0c2 | |||
| ffd9e84a00 | |||
| 2a674c6bcd | |||
| da0898f52a | |||
| 5d7b126c5e | |||
| 224ec33a14 | |||
| cc8de12740 | |||
| a05aaf8d71 | |||
| 9751dbf98d | |||
| 29d7bdb536 | |||
| 6c847a26d3 | |||
| 7b4ba86435 | |||
| 28f2aa17d8 | |||
| aa8a6a49f4 | |||
| 341c619d3d | |||
| 0ffe62e1ca | |||
| 925d0c7735 | |||
| f88c81cc59 | |||
| 121c49b35b | |||
| a4004f5e7a | |||
| fd0d5cf6ec | |||
| 1f293ae70b | |||
| 352118b4af | |||
| e33f1debc0 | |||
| f41d14b2a9 | |||
| f5ef2c5991 | |||
| 240fc0d3b6 | |||
| c3ae3a80de | |||
| 94fe0dea4d | |||
| 3c3382401c | |||
| c86694fc8f | |||
| dcf613446e | |||
| 0d4b169c79 | |||
| 972c1b81ec | |||
| 7ff29b669c | |||
| 0dbf0e0899 | |||
| 67a8610843 | |||
| cafa608e8c | |||
| 35d50fad0a | |||
| 4af36fc926 | |||
| 0ea9b769a3 | |||
| abdd0da306 | |||
| 80a6d559c9 | |||
| 55e4aedfca | |||
| 82f8fbccae | |||
| 8c829f9651 | |||
| a510fdb83c | |||
| 5fd55b4e1a | |||
| cc1e8af761 | |||
| 251c7376c9 | |||
| 8ba1ee4f56 | |||
| 37c1b64982 | |||
| 49327cf9ae | |||
| 898ca9199b | |||
| 2522bb3514 | |||
| bdbbcabfbc |
@@ -10,7 +10,14 @@ node_modules/
|
||||
client/
|
||||
dist/
|
||||
build/
|
||||
coverage/
|
||||
tests/
|
||||
vitest.config.js
|
||||
.markdownlint.json
|
||||
README.md
|
||||
CHANGELOG.md
|
||||
SECURITY.md
|
||||
LICENSE
|
||||
.dockerignore
|
||||
Dockerfile
|
||||
.gitea/
|
||||
|
||||
@@ -1,28 +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
|
||||
|
||||
# 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"}]
|
||||
@@ -19,6 +19,43 @@ 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
|
||||
# =============================================================================
|
||||
|
||||
# Set to 1 when running behind a reverse proxy (Nginx, Caddy, Traefik).
|
||||
# This makes Express trust X-Forwarded-For and X-Forwarded-Proto so that
|
||||
# req.ip reflects the real client IP and cookies are marked secure correctly.
|
||||
# Leave unset if sofarr is exposed directly to the internet.
|
||||
# TRUST_PROXY=1
|
||||
|
||||
# Directory for persistent data (SQLite token store, server logs).
|
||||
# Must be writable by the process user (UID 1000 in the container).
|
||||
# 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.
|
||||
|
||||
@@ -8,7 +8,7 @@ on:
|
||||
|
||||
jobs:
|
||||
audit:
|
||||
name: npm audit
|
||||
name: Security audit
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -16,11 +16,47 @@ jobs:
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "18"
|
||||
node-version: "22"
|
||||
cache: "npm"
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Run security audit
|
||||
run: npm audit --audit-level=moderate
|
||||
- name: Run security audit (fail on high+)
|
||||
run: npm audit --audit-level=high
|
||||
|
||||
- name: Check for critical vulnerabilities
|
||||
run: npm audit --audit-level=critical --json | jq -e '.metadata.vulnerabilities.critical == 0' || (echo "Critical vulnerabilities found!" && exit 1)
|
||||
continue-on-error: false
|
||||
|
||||
test:
|
||||
name: Tests & coverage
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: "npm"
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Run tests with coverage
|
||||
run: npm run test:coverage
|
||||
env:
|
||||
# Required by tokenStore (writable temp dir in CI)
|
||||
DATA_DIR: /tmp/sofarr-ci-data
|
||||
# Disable rate limiters so integration tests don't hit 429s
|
||||
SKIP_RATE_LIMIT: "1"
|
||||
NODE_ENV: test
|
||||
|
||||
- name: Upload coverage report
|
||||
uses: actions/upload-artifact@v3
|
||||
if: always()
|
||||
with:
|
||||
name: coverage-report
|
||||
path: coverage/
|
||||
retention-days: 14
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
name: Docs Check
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["**", "!main", "!release/**"]
|
||||
paths:
|
||||
- "**.md"
|
||||
- ".gitea/workflows/docs-check.yml"
|
||||
pull_request:
|
||||
branches: ["**", "!main", "!release/**"]
|
||||
paths:
|
||||
- "**.md"
|
||||
- ".gitea/workflows/docs-check.yml"
|
||||
|
||||
jobs:
|
||||
markdown-lint:
|
||||
name: Markdown lint
|
||||
runs-on: ubuntu-latest
|
||||
continue-on-error: true
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
|
||||
- name: Install markdownlint-cli
|
||||
run: npm install -g markdownlint-cli
|
||||
|
||||
- name: Lint all Markdown files
|
||||
run: markdownlint "**/*.md" --ignore node_modules
|
||||
|
||||
mermaid-parse:
|
||||
name: Mermaid diagram parse check
|
||||
runs-on: ubuntu-latest
|
||||
continue-on-error: true
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
|
||||
- name: Install mermaid and jsdom
|
||||
run: npm install mermaid jsdom
|
||||
|
||||
- name: Extract and validate Mermaid diagrams
|
||||
run: |
|
||||
cat > check-mermaid.cjs << 'SCRIPT'
|
||||
const { JSDOM } = require('jsdom');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
// Provide minimal browser globals so mermaid.parse() works in Node
|
||||
const dom = new JSDOM('<!DOCTYPE html><html><body></body></html>', { url: 'http://localhost' });
|
||||
globalThis.window = dom.window;
|
||||
globalThis.document = dom.window.document;
|
||||
globalThis.DOMPurify = {
|
||||
addHook: () => {}, removeHook: () => {}, setConfig: () => {},
|
||||
sanitize: (s) => s, isValidAttribute: () => true,
|
||||
};
|
||||
|
||||
function findMdFiles(dir) {
|
||||
const out = [];
|
||||
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const full = path.join(dir, e.name);
|
||||
if (e.isDirectory() && e.name !== 'node_modules' && !e.name.startsWith('.'))
|
||||
out.push(...findMdFiles(full));
|
||||
else if (e.isFile() && e.name.endsWith('.md'))
|
||||
out.push(full);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
import('./node_modules/mermaid/dist/mermaid.core.mjs').then(async (m) => {
|
||||
const mermaid = m.default;
|
||||
let errors = 0, total = 0;
|
||||
|
||||
for (const mdFile of findMdFiles('.')) {
|
||||
const content = fs.readFileSync(mdFile, 'utf8');
|
||||
const blocks = [...content.matchAll(/^```mermaid\n([\s\S]*?)^```/gm)];
|
||||
if (!blocks.length) continue;
|
||||
console.log(`\nChecking ${mdFile} (${blocks.length} diagram(s))`);
|
||||
for (let i = 0; i < blocks.length; i++) {
|
||||
total++;
|
||||
const diagram = blocks[i][1].trim();
|
||||
try {
|
||||
await mermaid.parse(diagram);
|
||||
console.log(` [OK] diagram ${i + 1}`);
|
||||
} catch (err) {
|
||||
const msg = String(err.message || err).split('\n')[0];
|
||||
console.error(` [FAIL] diagram ${i + 1}: ${msg}`);
|
||||
console.log(`::warning file=${mdFile}::Mermaid diagram ${i + 1} failed: ${msg}`);
|
||||
errors++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\nTotal: ${total}. Failures: ${errors}`);
|
||||
if (errors > 0) {
|
||||
console.log(`::warning::${errors} Mermaid diagram(s) failed to parse.`);
|
||||
process.exit(1);
|
||||
}
|
||||
}).catch(e => { console.error('Fatal:', e.message); process.exit(1); });
|
||||
SCRIPT
|
||||
node check-mermaid.cjs
|
||||
@@ -0,0 +1,38 @@
|
||||
name: Licence Check
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["**", "!main", "!release/**"]
|
||||
paths:
|
||||
- "package.json"
|
||||
- "package-lock.json"
|
||||
- ".gitea/workflows/licence-check.yml"
|
||||
pull_request:
|
||||
branches: ["**", "!main", "!release/**"]
|
||||
paths:
|
||||
- "package.json"
|
||||
- "package-lock.json"
|
||||
- ".gitea/workflows/licence-check.yml"
|
||||
|
||||
jobs:
|
||||
licence-check:
|
||||
name: Dependency licence compatibility
|
||||
runs-on: ubuntu-latest
|
||||
continue-on-error: true
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
|
||||
- name: Install production dependencies
|
||||
run: npm ci --omit=dev
|
||||
|
||||
- name: Check licence compatibility
|
||||
run: |
|
||||
npx --yes license-checker --production \
|
||||
--onlyAllow "MIT;ISC;MIT-0;BSD-2-Clause;BSD-3-Clause;Apache-2.0;CC0-1.0;BlueOak-1.0.0" \
|
||||
--excludePrivatePackages \
|
||||
&& echo "All production dependency licences are compatible with MIT."
|
||||
@@ -1,7 +1,12 @@
|
||||
node_modules/
|
||||
coverage/
|
||||
.env
|
||||
dist/
|
||||
build/
|
||||
.DS_Store
|
||||
*.log
|
||||
**/*.log
|
||||
data/
|
||||
*.db
|
||||
*.db-wal
|
||||
*.db-shm
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"default": true,
|
||||
"MD009": false,
|
||||
"MD012": false,
|
||||
"MD013": false,
|
||||
"MD022": false,
|
||||
"MD024": false,
|
||||
"MD029": false,
|
||||
"MD031": false,
|
||||
"MD032": false,
|
||||
"MD033": false,
|
||||
"MD034": false,
|
||||
"MD036": false,
|
||||
"MD040": false,
|
||||
"MD041": false,
|
||||
"MD058": false,
|
||||
"MD060": false
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
||||
This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
---
|
||||
|
||||
## [1.2.1] - 2026-05-17
|
||||
|
||||
### Added
|
||||
|
||||
- **Version footer** — the dashboard footer now displays the running app version (e.g. `sofarr v1.2.1`), fetched from the `/health` endpoint on page load.
|
||||
|
||||
---
|
||||
|
||||
## [1.2.0] - 2025-05-17
|
||||
|
||||
### Security
|
||||
|
||||
- **Docker secrets support** — all sensitive environment variables (`COOKIE_SECRET`, `EMBY_API_KEY`, `SABNZBD_API_KEY`, `SONARR_API_KEY`, `RADARR_API_KEY`, `QBITTORRENT_PASSWORD`) now support the standard `_FILE` variant for loading values from mounted secret files (e.g. `COOKIE_SECRET_FILE=/run/secrets/cookie_secret`).
|
||||
- **Weak secret warning** — server now warns at startup if `COOKIE_SECRET` is shorter than 32 characters.
|
||||
- **EMBY_URL validation** — validates the Emby URL scheme at startup and warns on misconfiguration.
|
||||
- **Improved error sanitization** — `sanitizeError()` now also redacts hostnames from full request URLs that may appear in axios error messages.
|
||||
- **Graceful shutdown** — `SIGTERM` and `SIGINT` handlers now stop the background poller and drain open HTTP connections before exiting. Prevents data loss and zombie processes on `docker stop`.
|
||||
|
||||
### Compliance
|
||||
|
||||
- **MIT LICENSE file** added to project root.
|
||||
- **Copyright headers** added to key server source files (`index.js`, `poller.js`, `config.js`, `sanitizeError.js`, `loadSecrets.js`).
|
||||
- **`security.txt`** (`/.well-known/security.txt`) added for responsible disclosure.
|
||||
|
||||
### Configuration
|
||||
|
||||
- **URL validation** added to `config.js` — all configured service instance URLs are validated for scheme (`http`/`https`) and well-formedness at startup; malformed URLs emit a warning instead of crashing.
|
||||
|
||||
### Docker / Deployment
|
||||
|
||||
- **`docker-compose.yaml`** updated with commented Option B (Docker secrets `_FILE` pattern) alongside the existing plain-env Option A.
|
||||
- **`.dockerignore`** updated — `tests/`, `coverage/`, `vitest.config.js`, `CHANGELOG.md`, `SECURITY.md`, `LICENSE`, `.markdownlint.json` excluded from the production image.
|
||||
|
||||
### CI
|
||||
|
||||
- **`docs-check` workflow** added — separate Gitea Actions workflow that lints all Markdown files and validates Mermaid diagram syntax on every push that touches `.md` files. Both jobs use `continue-on-error: true` so documentation issues never block a release.
|
||||
- **Mermaid diagrams** in `docs/ARCHITECTURE.md` fixed — replaced invalid `\n` in stateDiagram transition labels, Unicode arrows/dashes, and double-spaces in flowchart edge definitions.
|
||||
|
||||
---
|
||||
|
||||
## [1.1.2] - 2025-05-15
|
||||
|
||||
### Changed
|
||||
|
||||
- Server startup message now includes the current version (`sofarr v1.1.2`).
|
||||
|
||||
---
|
||||
|
||||
## [1.1.1] - 2025-05-14
|
||||
|
||||
### Fixed
|
||||
|
||||
- Docker/TrueNAS SCALE healthcheck: dynamic HTTP/HTTPS selection based on `TLS_ENABLED` environment variable. Prevents containers from being stuck in "starting" state when `TLS_ENABLED=false`.
|
||||
|
||||
---
|
||||
|
||||
## [1.1.0] - 2025-05-13
|
||||
|
||||
### Added
|
||||
|
||||
- **Episode display** — TV show download cards now show episode information (S01E01 format with title). Multi-episode packs show a "Multiple episodes" badge with a tooltip listing all episodes.
|
||||
- **Episode tooltip** — solid background colour (theme-dependent) for readability.
|
||||
- Sonarr queue and history API requests now include `includeEpisode=true`.
|
||||
|
||||
---
|
||||
|
||||
## [1.0.0] - 2025-05-01
|
||||
|
||||
### Added
|
||||
|
||||
- Initial release.
|
||||
- SABnzbd queue and history integration.
|
||||
- qBittorrent torrent integration.
|
||||
- Sonarr and Radarr queue/history matching with user tag filtering.
|
||||
- Emby/Jellyfin authentication.
|
||||
- Server-Sent Events (SSE) real-time dashboard.
|
||||
- Per-request CSP nonce, CSRF double-submit, HSTS, Permissions-Policy.
|
||||
- Background polling with configurable interval and on-demand fallback.
|
||||
- Docker multi-stage build, non-root user, read-only filesystem.
|
||||
- TLS support with bundled snakeoil certificate.
|
||||
@@ -1,4 +1,18 @@
|
||||
FROM node:18-alpine
|
||||
# ---------------------------------------------------------------------------
|
||||
# Stage 1 — deps: install production dependencies only
|
||||
# ---------------------------------------------------------------------------
|
||||
FROM node:22-alpine AS deps
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# All dependencies are pure JavaScript — no native addons, no build tools.
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci --omit=dev
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Stage 2 — runtime image (minimal attack surface)
|
||||
# ---------------------------------------------------------------------------
|
||||
FROM node:22-alpine AS runtime
|
||||
|
||||
LABEL org.opencontainers.image.title="sofarr"
|
||||
LABEL org.opencontainers.image.description="Personal media download dashboard for *arr services"
|
||||
@@ -9,18 +23,36 @@ LABEL org.opencontainers.image.vendor="Gordon Bolton"
|
||||
LABEL org.opencontainers.image.licenses="MIT"
|
||||
LABEL custom.hardware.requirement="None - runs on any Docker-supported platform including ARM and x86_64"
|
||||
|
||||
# Use the built-in non-root 'node' user (UID 1000) from the official image
|
||||
# The /app directory is owned by root; data directory is owned by node
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files and install dependencies
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci --omit=dev
|
||||
# Copy production deps from deps stage
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
|
||||
# Copy application source
|
||||
COPY server/ ./server/
|
||||
COPY public/ ./public/
|
||||
# Copy application source owned by root (read-only at runtime)
|
||||
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
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV DATA_DIR=/app/data
|
||||
|
||||
# Drop to non-root user for all subsequent operations
|
||||
USER node
|
||||
|
||||
EXPOSE 3001
|
||||
|
||||
ENV NODE_ENV=production
|
||||
# HEALTHCHECK — Docker will restart the container if this fails 3 times.
|
||||
# Respects TLS_ENABLED at runtime: uses https (with --no-check-certificate
|
||||
# to handle self-signed/snakeoil certs) when TLS is on, plain http when off.
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||||
CMD /bin/sh -c '[ "${TLS_ENABLED:-true}" = "false" ] && wget -qO- http://localhost:${PORT:-3001}/health || wget -qO- --no-check-certificate https://localhost:${PORT:-3001}/health'
|
||||
|
||||
CMD ["node", "server/index.js"]
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Gordon Bolton
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -51,7 +51,7 @@ SONARR_INSTANCES=[{"name":"main","url":"...","apiKey":"..."}]
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- **Docker** (recommended), or Node.js (v12+) for manual installation
|
||||
- **Docker** (recommended), or Node.js (v22+) for manual installation
|
||||
- At least one of: SABnzbd or qBittorrent
|
||||
- Sonarr (optional, for TV tracking)
|
||||
- Radarr (optional, for movie tracking)
|
||||
@@ -141,8 +141,8 @@ services:
|
||||
| Tag | Description |
|
||||
|-----|-------------|
|
||||
| `latest` | Latest stable release |
|
||||
| `0.1` | Latest patch for the 0.1.x release line |
|
||||
| `0.1.0` | Specific version |
|
||||
| `1.0` | Latest patch for the 1.0.x release line |
|
||||
| `1.0.0` | Specific version |
|
||||
|
||||
### Updating
|
||||
|
||||
@@ -245,11 +245,12 @@ sofarr polls all configured services in the background and caches the results. D
|
||||
| `POLL_INTERVAL=10000` | Poll every 10 seconds. |
|
||||
| `POLL_INTERVAL=0` / `off` / `disabled` | No background polling. Data fetched on-demand when a user opens the dashboard, then cached for 30 seconds. |
|
||||
|
||||
**On-demand mode** is useful for low-resource setups. When one user's browser refreshes, the fetched data is cached and served to all other users until it expires. A user with a faster refresh rate effectively keeps the cache warm for everyone.
|
||||
**On-demand mode** is useful for low-resource setups. When one user's browser opens the SSE stream it triggers a fresh poll; the fetched data is cached and served to all other connected clients until the next poll.
|
||||
|
||||
### Real-Time Updates
|
||||
- Auto-refresh every 5 seconds (configurable: 1s, 5s, 10s, or off)
|
||||
- **Server-Sent Events (SSE)** — the server pushes updates to the browser immediately after each poll cycle. No client-side timer; no wasted requests.
|
||||
- In-place DOM updates for smooth UI (no flickering)
|
||||
- Browser reconnects automatically on network interruption
|
||||
|
||||
### Download Information Displayed
|
||||
- **Progress bar** with visual completion percentage
|
||||
@@ -262,23 +263,28 @@ sofarr polls all configured services in the background and caches the results. D
|
||||
### For qBittorrent Downloads
|
||||
- **Seeds** - Number of seeders
|
||||
- **Peers** - Number of peers
|
||||
- **Availability** - Percentage available in swarm
|
||||
- **Availability** - Percentage available in swarm (shown in red when below 100%)
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### Authentication
|
||||
- `POST /api/auth/login` - Login with Emby credentials
|
||||
- `POST /api/auth/logout` - Logout and clear session
|
||||
- `POST /api/auth/login` — Login with Emby credentials
|
||||
- `POST /api/auth/logout` — Logout and revoke session
|
||||
- `GET /api/auth/me` — Check current session
|
||||
- `GET /api/csrf` — Fetch a CSRF token
|
||||
|
||||
### Dashboard
|
||||
- `GET /api/dashboard/downloads` - Get all downloads for authenticated user
|
||||
- `GET /api/dashboard/stream` — **SSE stream**: pushes `{ user, isAdmin, downloads }` on every poll cycle
|
||||
- `GET /api/dashboard/user-downloads` — Single-request download fetch (no streaming)
|
||||
- `GET /api/dashboard/user-summary` — Per-user download counts (admin)
|
||||
- `GET /api/dashboard/status` — Server / polling / cache status (admin)
|
||||
- `GET /api/dashboard/cover-art` — Proxied cover art image
|
||||
|
||||
### Service APIs (proxy to your services)
|
||||
- `GET /api/sabnzbd/*` - SABnzbd API proxy
|
||||
- `GET /api/qbittorrent/*` - qBittorrent API proxy
|
||||
- `GET /api/sonarr/*` - Sonarr API proxy
|
||||
- `GET /api/radarr/*` - Radarr API proxy
|
||||
- `GET /api/emby/*` - Emby API proxy
|
||||
- `GET /api/sabnzbd/*` — SABnzbd API proxy
|
||||
- `GET /api/sonarr/*` — Sonarr API proxy
|
||||
- `GET /api/radarr/*` — Radarr API proxy
|
||||
- `GET /api/emby/*` — Emby API proxy
|
||||
|
||||
## Logging Levels
|
||||
|
||||
@@ -308,6 +314,17 @@ Logs are written to both console and `server.log` file.
|
||||
- Check qBittorrent Web UI is enabled
|
||||
- Verify the URL includes the full path (e.g., `https://qb.example.com`)
|
||||
|
||||
## Testing
|
||||
|
||||
```bash
|
||||
npm test # run all tests once
|
||||
npm run test:watch # watch mode
|
||||
npm run test:coverage # with V8 coverage report (outputs to coverage/)
|
||||
npm run test:ui # interactive Vitest UI
|
||||
```
|
||||
|
||||
115 tests across 8 test files covering the security-critical paths: auth middleware, CSRF protection, secret sanitization, config parsing, token store, and qBittorrent utilities. See [`tests/README.md`](tests/README.md) for design decisions and coverage targets.
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
@@ -325,3 +342,4 @@ MIT
|
||||
---
|
||||
|
||||
*sofarr: See what has downloaded "so far" from the comfort of your "sofa"*
|
||||
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
# Security Policy & Hardening Guide
|
||||
|
||||
## Supported Versions
|
||||
|
||||
| Version | Supported |
|
||||
|---------|-----------|
|
||||
| 1.2.x | ✅ Yes |
|
||||
| 1.1.x | ✅ Yes |
|
||||
| 1.0.x | ❌ No |
|
||||
| < 1.0 | ❌ No |
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
Please **do not** open a public issue for security vulnerabilities.
|
||||
Email: gordon@i3omb.com — expect acknowledgement within 48 hours.
|
||||
|
||||
---
|
||||
|
||||
## Threat Model
|
||||
|
||||
sofarr is a personal dashboard intended for a small trusted group (household/team).
|
||||
It proxies requests to *arr stack services using stored API keys and authenticates
|
||||
users via Emby. The primary threat surface when exposed to the public internet:
|
||||
|
||||
| Threat | Mitigations |
|
||||
|--------|-------------|
|
||||
| Credential brute-force | Rate limiting (10 fails/15 min per IP), account lockout window |
|
||||
| Session hijacking | HMAC-signed cookies, `httpOnly`, `secure`, `sameSite=strict`, short TTL |
|
||||
| CSRF | Double-submit cookie pattern (`X-CSRF-Token` header required on all mutations) |
|
||||
| API key leakage via errors | `sanitizeError()` redacts keys/tokens from all error responses and logs |
|
||||
| Token theft after logout | Server-side token store; Emby token revoked on logout |
|
||||
| XSS → token theft | `httpOnly` cookies; CSP with per-request nonce blocks inline injection |
|
||||
| Clickjacking | `X-Frame-Options: DENY` + CSP `frame-ancestors 'none'` |
|
||||
| Info disclosure via headers | Helmet v7 removes `X-Powered-By`, sets `noSniff`, `xssFilter`, etc. |
|
||||
| Privilege escalation (container) | Non-root user (UID 1000), `no-new-privileges`, all caps dropped |
|
||||
| Unbounded log growth | Size-based rotation: 10 MB cap, 3 rotated files kept |
|
||||
| Dependency vulnerabilities | `npm audit --audit-level=high` in CI on every push |
|
||||
|
||||
---
|
||||
|
||||
## Production Deployment Checklist
|
||||
|
||||
### Required
|
||||
|
||||
- [ ] `COOKIE_SECRET` set to a random 32-byte hex string (`openssl rand -hex 32`)
|
||||
- [ ] `NODE_ENV=production`
|
||||
- [ ] `TRUST_PROXY=1` set if behind a reverse proxy
|
||||
- [ ] sofarr bound to `127.0.0.1` only (not `0.0.0.0`) — expose via proxy
|
||||
- [ ] HTTPS enforced by the reverse proxy with a valid certificate
|
||||
- [ ] Firewall rules: only 443/80 open externally; 3001 not directly exposed
|
||||
|
||||
### Recommended
|
||||
|
||||
- [ ] Reverse proxy: Nginx, Caddy, or Traefik with TLS termination
|
||||
- [ ] Set `Strict-Transport-Security` at proxy level (sofarr also sends HSTS)
|
||||
- [ ] `DATA_DIR` on a named Docker volume (not bind-mounted to sensitive host path)
|
||||
- [ ] Rotate `COOKIE_SECRET` periodically (causes all users to re-login)
|
||||
- [ ] Enable Docker's `--read-only` flag (already in `docker-compose.yaml`)
|
||||
- [ ] Monitor `/health` endpoint with an uptime checker
|
||||
|
||||
### Docker Secrets (alternative to env vars)
|
||||
|
||||
For production environments that support Docker secrets, you can mount secret
|
||||
files and reference them:
|
||||
|
||||
```yaml
|
||||
secrets:
|
||||
cookie_secret:
|
||||
file: ./secrets/cookie_secret.txt
|
||||
emby_api_key:
|
||||
file: ./secrets/emby_api_key.txt
|
||||
|
||||
services:
|
||||
sofarr:
|
||||
secrets:
|
||||
- cookie_secret
|
||||
- emby_api_key
|
||||
environment:
|
||||
- COOKIE_SECRET_FILE=/run/secrets/cookie_secret
|
||||
- EMBY_API_KEY_FILE=/run/secrets/emby_api_key
|
||||
```
|
||||
|
||||
> Since v1.2.0, sofarr natively supports the `_FILE` pattern.
|
||||
> Set `COOKIE_SECRET_FILE=/run/secrets/cookie_secret` and sofarr will
|
||||
> read the secret value from that file at startup. See `docker-compose.yaml`
|
||||
> for a complete example.
|
||||
|
||||
---
|
||||
|
||||
## Reverse Proxy Example (Caddy)
|
||||
|
||||
```caddy
|
||||
sofarr.example.com {
|
||||
reverse_proxy localhost:3001
|
||||
header {
|
||||
Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
|
||||
X-Robots-Tag "noindex, nofollow"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Reverse Proxy Example (Nginx)
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 443 ssl;
|
||||
server_name sofarr.example.com;
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/sofarr.example.com/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/sofarr.example.com/privkey.pem;
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:3001;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
# Required for SSE (Server-Sent Events) — disable response buffering
|
||||
proxy_buffering off;
|
||||
proxy_cache off;
|
||||
proxy_read_timeout 3600s;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Security Headers (emitted by sofarr)
|
||||
|
||||
| Header | Value |
|
||||
|--------|-------|
|
||||
| `Content-Security-Policy` | `default-src 'self'; script-src 'self' 'nonce-…'; style-src 'self' 'nonce-…'; style-src-attr 'unsafe-inline'; img-src 'self' data: blob:; object-src 'none'; frame-ancestors 'none'` |
|
||||
| `Strict-Transport-Security` | `max-age=31536000; includeSubDomains; preload` (production only) |
|
||||
| `X-Content-Type-Options` | `nosniff` |
|
||||
| `X-Frame-Options` | `DENY` |
|
||||
| `Referrer-Policy` | `strict-origin-when-cross-origin` |
|
||||
| `Permissions-Policy` | `camera=(), microphone=(), geolocation=(), payment=(), usb=()` |
|
||||
|
||||
---
|
||||
|
||||
## Rate Limits
|
||||
|
||||
| Endpoint | Limit |
|
||||
|----------|-------|
|
||||
| `POST /api/auth/login` | 10 failed attempts per 15 min per IP |
|
||||
| All `/api/*` routes | 300 requests per 15 min per IP |
|
||||
|
||||
---
|
||||
|
||||
## Supply Chain
|
||||
|
||||
- All dependencies pinned to minor version ranges in `package.json`
|
||||
- `npm audit --audit-level=high` runs in CI on every push and pull request
|
||||
- `npm audit fix` should be run when vulnerabilities are reported
|
||||
@@ -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
|
||||
@@ -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-----
|
||||
@@ -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-----
|
||||
@@ -1,17 +1,78 @@
|
||||
version: "3"
|
||||
services:
|
||||
sofarr:
|
||||
image: docker.i3omb.com/sofarr:latest
|
||||
container_name: sofarr
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
# 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
|
||||
# --- 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
|
||||
# --- Secrets: use _FILE variants (Docker secrets) in production -------
|
||||
# Option A — plain environment variables (simple, less secure):
|
||||
- COOKIE_SECRET=change-me-generate-with-openssl-rand-hex-32
|
||||
- EMBY_URL=https://emby.example.com
|
||||
- EMBY_API_KEY=your-emby-api-key
|
||||
- SONARR_INSTANCES=[{"name":"main","url":"https://sonarr.example.com","apiKey":"your-sonarr-api-key"}]
|
||||
- RADARR_INSTANCES=[{"name":"main","url":"https://radarr.example.com","apiKey":"your-radarr-api-key"}]
|
||||
- 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"}]
|
||||
- LOG_LEVEL=info
|
||||
# Option B — Docker secrets (_FILE pattern, recommended for production):
|
||||
# Uncomment the lines below and comment out Option A above.
|
||||
# Create secret files with: echo -n "value" > ./secrets/cookie_secret.txt
|
||||
# - COOKIE_SECRET_FILE=/run/secrets/cookie_secret
|
||||
# - EMBY_API_KEY_FILE=/run/secrets/emby_api_key
|
||||
# - SONARR_API_KEY_FILE=/run/secrets/sonarr_api_key # legacy single-instance only
|
||||
# - RADARR_API_KEY_FILE=/run/secrets/radarr_api_key # legacy single-instance only
|
||||
# - SABNZBD_API_KEY_FILE=/run/secrets/sabnzbd_api_key # legacy single-instance only
|
||||
# secrets: # uncomment when using Option B
|
||||
# - cookie_secret
|
||||
# - emby_api_key
|
||||
volumes:
|
||||
# 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
|
||||
read_only: true
|
||||
tmpfs:
|
||||
- /tmp # Node.js needs a writable /tmp
|
||||
security_opt:
|
||||
- no-new-privileges:true # prevent privilege escalation via setuid binaries
|
||||
cap_drop:
|
||||
- ALL # drop all Linux capabilities
|
||||
cap_add: [] # add back none — Node.js needs no special caps
|
||||
healthcheck:
|
||||
# Respects TLS_ENABLED: uses http when set to false, https otherwise.
|
||||
# --no-check-certificate handles self-signed / snakeoil certs.
|
||||
test: ["CMD", "/bin/sh", "-c", "[ \"${TLS_ENABLED:-true}\" = \"false\" ] && wget -qO- http://localhost:${PORT:-3001}/health || wget -qO- --no-check-certificate https://localhost:${PORT:-3001}/health"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
|
||||
volumes:
|
||||
sofarr-data:
|
||||
|
||||
# Docker secrets definitions (uncomment and populate when using Option B above)
|
||||
# secrets:
|
||||
# cookie_secret:
|
||||
# file: ./secrets/cookie_secret.txt
|
||||
# emby_api_key:
|
||||
# file: ./secrets/emby_api_key.txt
|
||||
|
||||
|
After Width: | Height: | Size: 331 KiB |
@@ -1,156 +0,0 @@
|
||||
@startuml activity-matching
|
||||
!theme plain
|
||||
title sofarr — Download Matching Activity Diagram
|
||||
|
||||
start
|
||||
|
||||
:Read cached data from MemoryCache;
|
||||
note right
|
||||
poll:sab-queue, poll:sab-history,
|
||||
poll:sonarr-queue, poll:sonarr-history,
|
||||
poll:radarr-queue, poll:radarr-history,
|
||||
poll:sonarr-tags, poll:radarr-tags,
|
||||
poll:qbittorrent
|
||||
end note
|
||||
|
||||
:Build **seriesMap** from Sonarr queue records
|
||||
(seriesId → embedded series object);
|
||||
|
||||
:Build **moviesMap** from Radarr queue records
|
||||
(movieId → embedded movie object);
|
||||
|
||||
:Build **sonarrTagMap** (tagId → label)
|
||||
Build **radarrTagMap** (tagId → label);
|
||||
|
||||
if (showAll?) then (yes)
|
||||
:Fetch full Emby user list
|
||||
Build **embyUserMap** (lowerName → displayName)
|
||||
[cached 60s];
|
||||
endif
|
||||
|
||||
:Initialise **userDownloads** = [];
|
||||
|
||||
partition "Process SABnzbd Queue Slots" {
|
||||
while (More queue slots?) is (yes)
|
||||
:Get slot filename (nzbName);
|
||||
:nzbNameLower = nzbName.toLowerCase();
|
||||
|
||||
if (Title matches Sonarr **queue** record?) then (yes)
|
||||
:series = seriesMap.get(match.seriesId)\n|| match.series;
|
||||
if (series exists?) then (yes)
|
||||
:allTags = extractAllTags(series.tags, sonarrTagMap)
|
||||
matchedUserTag = extractUserTag(series.tags, sonarrTagMap, username);
|
||||
if (showAll AND hasAnyTag?) then (yes)
|
||||
:Build download object (type=series)
|
||||
Add coverArt, status, progress, speed, eta
|
||||
Add allTags, matchedUserTag
|
||||
Add tagBadges = buildTagBadges(allTags, embyUserMap)
|
||||
Add importIssues if any
|
||||
Add admin fields (paths, arrLink);
|
||||
:Push to **userDownloads**;
|
||||
elseif (NOT showAll AND matchedUserTag?) then (yes)
|
||||
:Build download object (type=series)
|
||||
Add matchedUserTag;
|
||||
:Push to **userDownloads**;
|
||||
endif
|
||||
endif
|
||||
endif
|
||||
|
||||
if (Title matches Radarr **queue** record?) then (yes)
|
||||
:movie = moviesMap.get(match.movieId)\n|| match.movie;
|
||||
if (movie exists?) then (yes)
|
||||
:allTags = extractAllTags(movie.tags, radarrTagMap)
|
||||
matchedUserTag = extractUserTag(movie.tags, radarrTagMap, username);
|
||||
if (showAll AND hasAnyTag?) then (yes)
|
||||
:Build download object (type=movie)
|
||||
Add coverArt, status, progress, speed, eta
|
||||
Add allTags, matchedUserTag, tagBadges
|
||||
Add importIssues if any
|
||||
Add admin fields (paths, arrLink);
|
||||
:Push to **userDownloads**;
|
||||
elseif (NOT showAll AND matchedUserTag?) then (yes)
|
||||
:Build download object (type=movie)
|
||||
Add matchedUserTag;
|
||||
:Push to **userDownloads**;
|
||||
endif
|
||||
endif
|
||||
endif
|
||||
endwhile (no)
|
||||
}
|
||||
|
||||
partition "Process SABnzbd History Slots" {
|
||||
while (More history slots?) is (yes)
|
||||
:Get slot name (nzbName);
|
||||
:nzbNameLower = nzbName.toLowerCase();
|
||||
|
||||
if (Title matches Sonarr **history** record?) then (yes)
|
||||
:series = seriesMap.get(match.seriesId)\n|| match.series;
|
||||
if (series found?) then (yes)
|
||||
:extractAllTags + extractUserTag(username)
|
||||
Build download (type=series, completedAt)
|
||||
Add allTags, matchedUserTag, tagBadges if showAll;
|
||||
:Push to **userDownloads** if showAll+anyTag or matchedUserTag;
|
||||
endif
|
||||
endif
|
||||
|
||||
if (Title matches Radarr **history** record?) then (yes)
|
||||
:movie = moviesMap.get(match.movieId)\n|| match.movie;
|
||||
if (movie found?) then (yes)
|
||||
:extractAllTags + extractUserTag(username)
|
||||
Build download (type=movie, completedAt)
|
||||
Add allTags, matchedUserTag, tagBadges if showAll;
|
||||
:Push to **userDownloads** if showAll+anyTag or matchedUserTag;
|
||||
endif
|
||||
endif
|
||||
endwhile (no)
|
||||
}
|
||||
|
||||
partition "Process qBittorrent Torrents" {
|
||||
while (More torrents?) is (yes)
|
||||
:Get torrent name;
|
||||
:torrentNameLower = name.toLowerCase();
|
||||
|
||||
if (Matches Sonarr **queue**?) then (yes)
|
||||
:Resolve series → check tag;
|
||||
:mapTorrentToDownload() + enrich;
|
||||
:Push if matches → **continue**;
|
||||
elseif (Matches Radarr **queue**?) then (yes)
|
||||
:Resolve movie → check tag;
|
||||
:mapTorrentToDownload() + enrich;
|
||||
:Push if matches → **continue**;
|
||||
elseif (Matches Sonarr **history**?) then (yes)
|
||||
:Resolve series via seriesMap;
|
||||
:mapTorrentToDownload() + enrich;
|
||||
:Push if matches → **continue**;
|
||||
elseif (Matches Radarr **history**?) then (yes)
|
||||
:Resolve movie via moviesMap;
|
||||
:mapTorrentToDownload() + enrich;
|
||||
:Push if matches → **continue**;
|
||||
else (no match)
|
||||
:Skip torrent (unmatched);
|
||||
endif
|
||||
endwhile (no)
|
||||
}
|
||||
|
||||
:Return JSON response
|
||||
{ user, isAdmin, downloads: userDownloads };
|
||||
|
||||
stop
|
||||
|
||||
legend right
|
||||
**Title Matching Logic**
|
||||
(bidirectional substring, case-insensitive):
|
||||
""rTitle.includes(dlTitle) || dlTitle.includes(rTitle)""
|
||||
|
||||
**Tag Matching Logic** (tagMatchesUser):
|
||||
1. Exact: tag.toLowerCase() === username
|
||||
2. Sanitised: sanitizeTagLabel(tag) === sanitizeTagLabel(username)
|
||||
(handles Ombi-mangled email-style usernames)
|
||||
|
||||
**extractAllTags**: returns all resolved tag labels
|
||||
**extractUserTag**: returns the ONE label matching current user
|
||||
**buildTagBadges**: classifies each tag against full Emby user
|
||||
list → { label, matchedUser: displayName | null }
|
||||
end legend
|
||||
|
||||
@enduml
|
||||
|
After Width: | Height: | Size: 304 KiB |
@@ -1,230 +0,0 @@
|
||||
@startuml class-data
|
||||
!theme plain
|
||||
title sofarr — Data Model Diagram
|
||||
|
||||
skinparam classAttributeIconSize 0
|
||||
|
||||
package "External API Responses" {
|
||||
class "SABnzbd Queue Slot" as sabq {
|
||||
+ filename : string
|
||||
+ nzbname : string
|
||||
+ percentage : string
|
||||
+ mb : string
|
||||
+ mbmissing : string
|
||||
+ size : string
|
||||
+ timeleft : string
|
||||
+ status : string
|
||||
+ storage : string
|
||||
}
|
||||
|
||||
class "SABnzbd History Slot" as sabh {
|
||||
+ name : string
|
||||
+ nzb_name : string
|
||||
+ nzbname : string
|
||||
+ status : string
|
||||
+ size : string
|
||||
+ completed_time : string
|
||||
+ storage : string
|
||||
}
|
||||
|
||||
class "Sonarr Queue Record" as sqr {
|
||||
+ id : number
|
||||
+ seriesId : number
|
||||
+ series : SonarrSeries
|
||||
+ title : string
|
||||
+ sourceTitle : string
|
||||
+ trackedDownloadStatus : string
|
||||
+ trackedDownloadState : string
|
||||
+ statusMessages : StatusMessage[]
|
||||
+ errorMessage : string
|
||||
}
|
||||
|
||||
class "Sonarr History Record" as shr {
|
||||
+ id : number
|
||||
+ seriesId : number
|
||||
+ title : string
|
||||
+ sourceTitle : string
|
||||
+ eventType : string
|
||||
}
|
||||
|
||||
class "SonarrSeries" as ss {
|
||||
+ id : number
|
||||
+ title : string
|
||||
+ titleSlug : string
|
||||
+ path : string
|
||||
+ tags : number[]
|
||||
+ images : Image[]
|
||||
+ _instanceUrl : string
|
||||
}
|
||||
|
||||
class "Radarr Queue Record" as rqr {
|
||||
+ id : number
|
||||
+ movieId : number
|
||||
+ movie : RadarrMovie
|
||||
+ title : string
|
||||
+ sourceTitle : string
|
||||
+ trackedDownloadStatus : string
|
||||
+ trackedDownloadState : string
|
||||
+ statusMessages : StatusMessage[]
|
||||
+ errorMessage : string
|
||||
}
|
||||
|
||||
class "Radarr History Record" as rhr {
|
||||
+ id : number
|
||||
+ movieId : number
|
||||
+ title : string
|
||||
+ sourceTitle : string
|
||||
+ eventType : string
|
||||
}
|
||||
|
||||
class "RadarrMovie" as rm {
|
||||
+ id : number
|
||||
+ title : string
|
||||
+ titleSlug : string
|
||||
+ path : string
|
||||
+ tags : number[]
|
||||
+ images : Image[]
|
||||
+ _instanceUrl : string
|
||||
}
|
||||
|
||||
class "Tag" as tag {
|
||||
+ id : number
|
||||
+ label : string
|
||||
}
|
||||
|
||||
class "Image" as img {
|
||||
+ coverType : string
|
||||
+ remoteUrl : string
|
||||
+ url : string
|
||||
}
|
||||
|
||||
class "StatusMessage" as sm {
|
||||
+ title : string
|
||||
+ messages : string[]
|
||||
}
|
||||
|
||||
class "qBittorrent Torrent" as qbt {
|
||||
+ name : string
|
||||
+ hash : string
|
||||
+ size : number
|
||||
+ completed : number
|
||||
+ progress : number (0-1)
|
||||
+ state : string
|
||||
+ dlspeed : number
|
||||
+ eta : number
|
||||
+ num_seeds : number
|
||||
+ num_leechs : number
|
||||
+ availability : number
|
||||
+ category : string
|
||||
+ tags : string
|
||||
+ save_path : string
|
||||
+ content_path : string
|
||||
+ instanceId : string
|
||||
+ instanceName : string
|
||||
}
|
||||
|
||||
class "Emby User" as eu {
|
||||
+ Id : string
|
||||
+ Name : string
|
||||
+ Policy : { IsAdministrator: boolean }
|
||||
}
|
||||
|
||||
sqr *-- ss : embedded\n(includeSeries)
|
||||
rqr *-- rm : embedded\n(includeMovie)
|
||||
sqr *-- sm
|
||||
rqr *-- sm
|
||||
ss *-- img
|
||||
rm *-- img
|
||||
}
|
||||
|
||||
package "sofarr Internal Models" {
|
||||
class "Download Object" as dl {
|
||||
+ type : 'series' | 'movie' | 'torrent'
|
||||
+ title : string
|
||||
+ coverArt : string | null
|
||||
+ status : string
|
||||
+ progress : string
|
||||
+ mb : string
|
||||
+ mbmissing : string
|
||||
+ size : string
|
||||
+ speed : string
|
||||
+ eta : string
|
||||
+ seriesName : string | null
|
||||
+ movieName : string | null
|
||||
+ episodeInfo : object | null
|
||||
+ movieInfo : object | null
|
||||
+ allTags : string[]
|
||||
+ matchedUserTag : string | null
|
||||
+ tagBadges : TagBadge[] | undefined
|
||||
+ importIssues : string[] | null
|
||||
+ downloadPath : string | null
|
||||
+ targetPath : string | null
|
||||
+ arrLink : string | null
|
||||
+ qbittorrent : boolean
|
||||
+ seeds : number
|
||||
+ peers : number
|
||||
+ availability : string
|
||||
+ rawSize : number
|
||||
+ rawSpeed : number
|
||||
+ rawEta : number
|
||||
+ hash : string
|
||||
+ category : string
|
||||
+ completedAt : string
|
||||
}
|
||||
|
||||
class "TagBadge" as tagbadge <<value>> {
|
||||
+ label : string
|
||||
+ matchedUser : string | null
|
||||
}
|
||||
|
||||
class "API Response\n/user-downloads" as apir {
|
||||
+ user : string
|
||||
+ isAdmin : boolean
|
||||
+ downloads : Download[]
|
||||
}
|
||||
|
||||
class "Status Response\n/status" as statr {
|
||||
+ server : ServerInfo
|
||||
+ polling : PollingInfo
|
||||
+ cache : CacheStats
|
||||
+ clients : ClientInfo[]
|
||||
}
|
||||
|
||||
class "ServerInfo" as si {
|
||||
+ uptimeSeconds : number
|
||||
+ nodeVersion : string
|
||||
+ memoryUsageMB : number
|
||||
+ heapUsedMB : number
|
||||
+ heapTotalMB : number
|
||||
}
|
||||
|
||||
class "PollingInfo" as pi {
|
||||
+ enabled : boolean
|
||||
+ intervalMs : number
|
||||
+ lastPoll : PollTimings
|
||||
}
|
||||
|
||||
class "Session Cookie\nemby_user" as cookie {
|
||||
+ id : string
|
||||
+ name : string
|
||||
+ isAdmin : boolean
|
||||
' Note: Emby AccessToken intentionally excluded
|
||||
}
|
||||
|
||||
apir *-- dl
|
||||
statr *-- si
|
||||
statr *-- pi
|
||||
}
|
||||
|
||||
' Data flow connections
|
||||
sabq ..> dl : matched &\ntransformed
|
||||
sabh ..> dl : matched &\ntransformed
|
||||
qbt ..> dl : mapTorrentToDownload()
|
||||
ss ..> dl : coverArt, seriesName,\npath, tags
|
||||
rm ..> dl : coverArt, movieName,\npath, tags
|
||||
tag ..> dl : allTags / matchedUserTag
|
||||
eu ..> cookie : login creates
|
||||
eu ..> tagbadge : buildTagBadges()
|
||||
dl *-- tagbadge : tagBadges[]
|
||||
|
||||
@enduml
|
||||
|
After Width: | Height: | Size: 473 KiB |
@@ -1,221 +0,0 @@
|
||||
@startuml class-server
|
||||
!theme plain
|
||||
title sofarr — Server Class / Module Diagram
|
||||
|
||||
package "server/index.js" as entry {
|
||||
class "EntryPoint" as ep <<module>> {
|
||||
- LOG_LEVELS : Object
|
||||
- currentLevel : number
|
||||
- logFile : WriteStream
|
||||
+ shouldLog(level) : boolean
|
||||
--
|
||||
Configures Express app,
|
||||
mounts routes, starts poller
|
||||
}
|
||||
}
|
||||
|
||||
package "server/routes" {
|
||||
class "auth.js" as auth <<router>> {
|
||||
+ POST /login
|
||||
+ GET /me
|
||||
+ POST /logout
|
||||
--
|
||||
Authenticates via Emby API
|
||||
Sets/reads httpOnly cookie
|
||||
}
|
||||
|
||||
class "dashboard.js" as dashboard <<router>> {
|
||||
- activeClients : Map<string, ClientInfo>
|
||||
- CLIENT_STALE_MS : 30000
|
||||
--
|
||||
+ GET /user-downloads
|
||||
+ GET /user-summary
|
||||
+ GET /status
|
||||
--
|
||||
- getCoverArt(item) : string|null
|
||||
- extractAllTags(tags, tagMap) : string[]
|
||||
- extractUserTag(tags, tagMap, username) : string|null
|
||||
- buildTagBadges(allTags, embyUserMap) : TagBadge[]
|
||||
- getEmbyUsers() : Promise<Map>
|
||||
- sanitizeTagLabel(input) : string
|
||||
- tagMatchesUser(tag, username) : boolean
|
||||
- getImportIssues(record) : string[]|null
|
||||
- getSonarrLink(series) : string|null
|
||||
- getRadarrLink(movie) : string|null
|
||||
- getActiveClients() : ClientInfo[]
|
||||
}
|
||||
|
||||
class "emby.js" as emby_r <<router>> {
|
||||
+ GET /sessions
|
||||
+ GET /users/:id
|
||||
+ GET /users
|
||||
+ GET /session/:sessionId/user
|
||||
}
|
||||
|
||||
class "sabnzbd.js" as sab_r <<router>> {
|
||||
+ GET /queue
|
||||
+ GET /history
|
||||
}
|
||||
|
||||
class "sonarr.js" as sonarr_r <<router>> {
|
||||
+ GET /queue
|
||||
+ GET /history
|
||||
+ GET /series/:id
|
||||
+ GET /series
|
||||
}
|
||||
|
||||
class "radarr.js" as radarr_r <<router>> {
|
||||
+ GET /queue
|
||||
+ GET /history
|
||||
+ GET /movies/:id
|
||||
+ GET /movies
|
||||
}
|
||||
}
|
||||
|
||||
package "server/middleware" {
|
||||
class "requireAuth.js" as requireauth <<middleware>> {
|
||||
+ requireAuth(req, res, next) : void
|
||||
--
|
||||
Reads emby_user cookie
|
||||
Attaches parsed user to req.user
|
||||
Returns 401 if absent/invalid
|
||||
}
|
||||
}
|
||||
|
||||
package "server/utils" {
|
||||
class "MemoryCache" as cache {
|
||||
- store : Map<string, CacheEntry>
|
||||
+ get(key) : any|null
|
||||
+ set(key, value, ttlMs) : void
|
||||
+ invalidate(key) : void
|
||||
+ clear() : void
|
||||
+ getStats() : CacheStats
|
||||
}
|
||||
|
||||
class "CacheEntry" as ce <<value>> {
|
||||
+ value : any
|
||||
+ expiresAt : number
|
||||
}
|
||||
|
||||
class "CacheStats" as cs <<value>> {
|
||||
+ entryCount : number
|
||||
+ totalSizeBytes : number
|
||||
+ entries : CacheEntryStats[]
|
||||
}
|
||||
|
||||
class "Poller" as poller <<module>> {
|
||||
- POLL_INTERVAL : number
|
||||
- POLLING_ENABLED : boolean
|
||||
- polling : boolean
|
||||
- lastPollTimings : PollTimings|null
|
||||
- intervalHandle : number|null
|
||||
--
|
||||
+ startPoller() : void
|
||||
+ stopPoller() : void
|
||||
+ pollAllServices() : Promise<void>
|
||||
+ getLastPollTimings() : PollTimings|null
|
||||
--
|
||||
- timed(label, fn) : TimedResult
|
||||
}
|
||||
|
||||
class "PollTimings" as pt <<value>> {
|
||||
+ totalMs : number
|
||||
+ timestamp : string (ISO)
|
||||
+ tasks : { label, ms }[]
|
||||
}
|
||||
|
||||
class "Config" as config <<module>> {
|
||||
+ getSABnzbdInstances() : Instance[]
|
||||
+ getSonarrInstances() : Instance[]
|
||||
+ getRadarrInstances() : Instance[]
|
||||
+ getQbittorrentInstances() : Instance[]
|
||||
--
|
||||
- parseInstances(envVar, ...) : Instance[]
|
||||
}
|
||||
|
||||
class "Instance" as inst <<value>> {
|
||||
+ id : string
|
||||
+ name : string
|
||||
+ url : string
|
||||
+ apiKey : string
|
||||
+ username? : string
|
||||
+ password? : string
|
||||
}
|
||||
|
||||
class "QBittorrentClient" as qbt {
|
||||
- id : string
|
||||
- name : string
|
||||
- url : string
|
||||
- username : string
|
||||
- password : string
|
||||
- authCookie : string|null
|
||||
--
|
||||
+ login() : Promise<boolean>
|
||||
+ makeRequest(endpoint, config) : Promise<Response>
|
||||
+ getTorrents() : Promise<Torrent[]>
|
||||
}
|
||||
|
||||
class "qbittorrent.js" as qbt_mod <<module>> {
|
||||
- persistedClients : QBittorrentClient[]|null
|
||||
--
|
||||
+ getTorrents() : Promise<Torrent[]>
|
||||
+ getClients() : QBittorrentClient[]
|
||||
+ mapTorrentToDownload(torrent) : Download
|
||||
+ formatBytes(bytes) : string
|
||||
+ formatSpeed(bps) : string
|
||||
+ formatEta(seconds) : string
|
||||
}
|
||||
|
||||
class "Logger" as logger <<module>> {
|
||||
- logFile : WriteStream
|
||||
+ logToFile(message) : void
|
||||
}
|
||||
|
||||
class "TagBadge" as tb <<value>> {
|
||||
+ label : string
|
||||
+ matchedUser : string | null
|
||||
}
|
||||
|
||||
class "ClientInfo" as ci <<value>> {
|
||||
+ user : string
|
||||
+ refreshRateMs : number
|
||||
+ lastSeen : number (timestamp)
|
||||
}
|
||||
}
|
||||
|
||||
' Relationships
|
||||
ep --> auth
|
||||
ep --> dashboard
|
||||
ep --> emby_r
|
||||
ep --> sab_r
|
||||
ep --> sonarr_r
|
||||
ep --> radarr_r
|
||||
|
||||
dashboard --> requireauth : uses
|
||||
emby_r --> requireauth : uses
|
||||
sab_r --> requireauth : uses
|
||||
sonarr_r --> requireauth : uses
|
||||
radarr_r --> requireauth : uses
|
||||
ep --> poller : startPoller()
|
||||
|
||||
dashboard --> cache : read/write
|
||||
dashboard --> poller : pollAllServices()
|
||||
dashboard --> qbt_mod : mapTorrentToDownload()
|
||||
dashboard --> config
|
||||
|
||||
poller --> cache : set poll:* keys
|
||||
poller --> config : get instances
|
||||
poller --> qbt_mod : getTorrents()
|
||||
|
||||
qbt_mod --> config : getQbittorrentInstances()
|
||||
qbt_mod *-- qbt : creates
|
||||
qbt --> logger
|
||||
|
||||
cache *-- ce : stores
|
||||
cache ..> cs : returns from getStats()
|
||||
poller ..> pt : stores/returns
|
||||
dashboard *-- ci : stores in activeClients
|
||||
|
||||
config ..> inst : returns
|
||||
|
||||
@enduml
|
||||
|
After Width: | Height: | Size: 297 KiB |
@@ -1,99 +0,0 @@
|
||||
@startuml component
|
||||
!theme plain
|
||||
title sofarr — Component Diagram
|
||||
|
||||
skinparam componentStyle rectangle
|
||||
skinparam packageStyle frame
|
||||
|
||||
package "Browser" as browser {
|
||||
[index.html] as html
|
||||
[app.js] as appjs
|
||||
[style.css] as css
|
||||
html ..> appjs : loads
|
||||
html ..> css : loads
|
||||
}
|
||||
|
||||
package "Express Server" as server {
|
||||
|
||||
package "Middleware" {
|
||||
[cookie-parser] as cp
|
||||
[express.json] as ej
|
||||
[express.static] as es
|
||||
[requireAuth.js] as requireauth
|
||||
}
|
||||
|
||||
package "Routes" as routes {
|
||||
[auth.js\n/api/auth] as auth
|
||||
[dashboard.js\n/api/dashboard] as dashboard
|
||||
[emby.js\n/api/emby] as emby_route
|
||||
[sabnzbd.js\n/api/sabnzbd] as sab_route
|
||||
[sonarr.js\n/api/sonarr] as sonarr_route
|
||||
[radarr.js\n/api/radarr] as radarr_route
|
||||
}
|
||||
|
||||
package "Utilities" as utils {
|
||||
[poller.js] as poller
|
||||
[cache.js\nMemoryCache] as cache
|
||||
[config.js] as config
|
||||
[qbittorrent.js\nQBittorrentClient] as qbt
|
||||
[logger.js] as logger
|
||||
}
|
||||
|
||||
[index.js\nEntry Point] as entry
|
||||
|
||||
entry --> cp
|
||||
entry --> ej
|
||||
entry --> es
|
||||
entry --> auth
|
||||
entry --> dashboard
|
||||
entry --> emby_route
|
||||
entry --> sab_route
|
||||
entry --> sonarr_route
|
||||
entry --> radarr_route
|
||||
|
||||
emby_route --> requireauth
|
||||
sab_route --> requireauth
|
||||
sonarr_route --> requireauth
|
||||
radarr_route --> requireauth
|
||||
dashboard --> requireauth
|
||||
entry --> poller : startPoller()
|
||||
|
||||
dashboard --> cache : read poll:* keys
|
||||
dashboard --> poller : pollAllServices()\n(on-demand mode)
|
||||
dashboard --> config : getSonarrInstances()\ngetRadarrInstances()
|
||||
dashboard --> qbt : mapTorrentToDownload()
|
||||
|
||||
poller --> cache : set poll:* keys
|
||||
poller --> config : get all instances
|
||||
poller --> qbt : getTorrents()
|
||||
poller --> logger
|
||||
|
||||
qbt --> config : getQbittorrentInstances()
|
||||
qbt --> logger
|
||||
}
|
||||
|
||||
cloud "External Services" as external {
|
||||
[Emby / Jellyfin] as emby
|
||||
[SABnzbd] as sab
|
||||
[Sonarr] as sonarr
|
||||
[Radarr] as radarr
|
||||
[qBittorrent] as qbit
|
||||
}
|
||||
|
||||
auth --> emby : authenticate\nuser profile
|
||||
dashboard --> emby : GET /Users\n(user-summary + tag badge classification)
|
||||
emby_route --> emby
|
||||
sab_route --> sab
|
||||
sonarr_route --> sonarr
|
||||
radarr_route --> radarr
|
||||
|
||||
poller --> sab : queue + history
|
||||
poller --> sonarr : tags + queue + history
|
||||
poller --> radarr : tags + queue + history
|
||||
qbt --> qbit : login + torrents/info
|
||||
|
||||
appjs --> auth : POST /login\nGET /me
|
||||
appjs --> dashboard : GET /user-downloads\nGET /status
|
||||
es --> html : serve static
|
||||
|
||||
@enduml
|
||||
|
After Width: | Height: | Size: 247 KiB |
@@ -1,67 +0,0 @@
|
||||
@startuml seq-auth
|
||||
!theme plain
|
||||
title sofarr — Authentication Sequence
|
||||
|
||||
actor User as user
|
||||
participant "Browser\n(app.js)" as browser
|
||||
participant "Express\n/api/auth" as auth
|
||||
participant "Emby\nServer" as emby
|
||||
|
||||
== Page Load ==
|
||||
user -> browser : Navigate to sofarr
|
||||
activate browser
|
||||
browser -> auth : GET /api/auth/me
|
||||
activate auth
|
||||
auth -> auth : Read emby_user cookie
|
||||
alt Cookie exists and valid
|
||||
auth --> browser : { authenticated: true, user: { name, isAdmin } }
|
||||
browser -> browser : showDashboard()
|
||||
browser -> browser : fetchUserDownloads(true)
|
||||
browser -> browser : startAutoRefresh()
|
||||
browser -> browser : dismissSplash()
|
||||
else No cookie
|
||||
auth --> browser : { authenticated: false }
|
||||
browser -> browser : dismissSplash()
|
||||
browser -> browser : showLogin()
|
||||
end
|
||||
deactivate auth
|
||||
|
||||
== Login ==
|
||||
user -> browser : Enter username + password
|
||||
browser -> auth : POST /api/auth/login\n{ username, password }
|
||||
activate auth
|
||||
auth -> emby : POST /Users/authenticatebyname\n{ Username, Pw }
|
||||
activate emby
|
||||
alt Valid credentials
|
||||
emby --> auth : { User: { Id, ... }, AccessToken }
|
||||
auth -> emby : GET /Users/{userId}
|
||||
emby --> auth : { Name, Policy: { IsAdministrator } }
|
||||
deactivate emby
|
||||
auth -> auth : Set httpOnly cookie\nemby_user = { id, name, isAdmin }\n(24h TTL, secure in prod, sameSite=strict)\nNote: AccessToken NOT stored
|
||||
auth --> browser : { success: true, user: { name, isAdmin } }
|
||||
browser -> browser : fadeOutLogin()
|
||||
browser -> browser : showSplash()
|
||||
browser -> browser : showDashboard()
|
||||
browser -> browser : fetchUserDownloads(true)
|
||||
browser -> browser : startAutoRefresh()
|
||||
browser -> browser : dismissSplash()
|
||||
else Invalid credentials
|
||||
emby --> auth : 401 Error
|
||||
deactivate emby
|
||||
auth --> browser : { success: false, error: "Invalid..." }
|
||||
browser -> browser : showLoginError()
|
||||
end
|
||||
deactivate auth
|
||||
|
||||
== Logout ==
|
||||
user -> browser : Click Logout
|
||||
browser -> browser : stopAutoRefresh()
|
||||
browser -> auth : POST /api/auth/logout
|
||||
activate auth
|
||||
auth -> auth : Clear emby_user cookie
|
||||
auth --> browser : { success: true }
|
||||
deactivate auth
|
||||
browser -> browser : showLogin()
|
||||
|
||||
deactivate browser
|
||||
@enduml
|
||||
|
After Width: | Height: | Size: 161 KiB |
@@ -1,100 +0,0 @@
|
||||
@startuml seq-dashboard
|
||||
!theme plain
|
||||
title sofarr — Dashboard Request Sequence
|
||||
|
||||
actor User as user
|
||||
participant "Browser\n(app.js)" as browser
|
||||
participant "Express\n/api/dashboard" as dashboard
|
||||
participant "MemoryCache" as cache
|
||||
participant "Poller" as poller
|
||||
participant "External\nServices" as ext
|
||||
|
||||
== Periodic Refresh (or Initial Load) ==
|
||||
user -> browser : (auto-refresh fires)
|
||||
activate browser
|
||||
browser -> dashboard : GET /api/dashboard/user-downloads\n?refreshRate=5000&showAll=false
|
||||
activate dashboard
|
||||
|
||||
dashboard -> dashboard : Parse emby_user cookie\nExtract username, isAdmin
|
||||
dashboard -> dashboard : Track client refresh rate\nin activeClients Map
|
||||
|
||||
alt Polling disabled AND cache empty
|
||||
dashboard -> poller : pollAllServices()
|
||||
activate poller
|
||||
poller -> ext : Parallel API calls\n(SAB, Sonarr, Radarr, qBit)
|
||||
ext --> poller : Raw data
|
||||
poller -> cache : set poll:* keys\n(TTL = 30s)
|
||||
deactivate poller
|
||||
end
|
||||
|
||||
dashboard -> cache : get('poll:sab-queue')
|
||||
cache --> dashboard : { slots, status, speed }
|
||||
dashboard -> cache : get('poll:sab-history')
|
||||
cache --> dashboard : { slots }
|
||||
dashboard -> cache : get('poll:sonarr-tags')
|
||||
cache --> dashboard : [{ instance, data }]
|
||||
dashboard -> cache : get('poll:sonarr-queue')
|
||||
cache --> dashboard : { records } (with embedded series)
|
||||
dashboard -> cache : get('poll:sonarr-history')
|
||||
cache --> dashboard : { records }
|
||||
dashboard -> cache : get('poll:radarr-queue')
|
||||
cache --> dashboard : { records } (with embedded movie)
|
||||
dashboard -> cache : get('poll:radarr-history')
|
||||
cache --> dashboard : { records }
|
||||
dashboard -> cache : get('poll:radarr-tags')
|
||||
cache --> dashboard : [{id, label}]
|
||||
dashboard -> cache : get('poll:qbittorrent')
|
||||
cache --> dashboard : [torrent, ...]
|
||||
|
||||
dashboard -> dashboard : Build seriesMap from\nSonarr queue records
|
||||
dashboard -> dashboard : Build moviesMap from\nRadarr queue records
|
||||
dashboard -> dashboard : Build tag maps\n(id → label)
|
||||
|
||||
alt showAll=true
|
||||
dashboard -> cache : get('emby:users')
|
||||
alt cache miss
|
||||
dashboard -> ext : GET /Users (Emby)
|
||||
ext --> dashboard : [{ Name, ... }]
|
||||
dashboard -> cache : set('emby:users', map, 60s)
|
||||
end
|
||||
dashboard -> dashboard : Build embyUserMap\n(lowerName → displayName)
|
||||
end
|
||||
|
||||
group SABnzbd Queue Matching
|
||||
loop each queue slot
|
||||
dashboard -> dashboard : Match title vs Sonarr queue
|
||||
dashboard -> dashboard : Match title vs Radarr queue
|
||||
dashboard -> dashboard : extractAllTags() + extractUserTag(username)\nInclude if: showAll+anyTag OR matchedUserTag\nAttach allTags, matchedUserTag\nIf showAll: tagBadges = buildTagBadges(embyUserMap)
|
||||
end
|
||||
end
|
||||
|
||||
group SABnzbd History Matching
|
||||
loop each history slot
|
||||
dashboard -> dashboard : Match title vs Sonarr/Radarr history
|
||||
dashboard -> dashboard : Same tag extraction + inclusion logic
|
||||
end
|
||||
end
|
||||
|
||||
group qBittorrent Matching
|
||||
loop each torrent
|
||||
dashboard -> dashboard : 1. Match vs Sonarr queue
|
||||
dashboard -> dashboard : 2. Match vs Radarr queue
|
||||
dashboard -> dashboard : 3. Match vs Sonarr history
|
||||
dashboard -> dashboard : 4. Match vs Radarr history
|
||||
dashboard -> dashboard : mapTorrentToDownload()\n→ enrich: allTags, matchedUserTag, tagBadges
|
||||
end
|
||||
end
|
||||
|
||||
dashboard --> browser : { user, isAdmin,\ndownloads: [...] }
|
||||
deactivate dashboard
|
||||
|
||||
browser -> browser : renderDownloads() (diff-based)
|
||||
note right
|
||||
createDownloadCard() renders tag badges:
|
||||
- Normal: accent badge for matchedUserTag
|
||||
- showAll: amber badges (unmatched tags)
|
||||
accent badges (matched → show Emby displayName)
|
||||
end note
|
||||
deactivate browser
|
||||
|
||||
@enduml
|
||||
|
After Width: | Height: | Size: 206 KiB |
@@ -1,89 +0,0 @@
|
||||
@startuml seq-polling
|
||||
!theme plain
|
||||
title sofarr — Background Polling Cycle
|
||||
|
||||
participant "index.js\n(startup)" as entry
|
||||
participant "Poller" as poller
|
||||
participant "Config" as config
|
||||
participant "SABnzbd\n(per instance)" as sab
|
||||
participant "Sonarr\n(per instance)" as sonarr
|
||||
participant "Radarr\n(per instance)" as radarr
|
||||
participant "qBittorrent\nClient" as qbt
|
||||
participant "MemoryCache" as cache
|
||||
|
||||
== Startup ==
|
||||
entry -> poller : startPoller()
|
||||
activate poller
|
||||
|
||||
alt POLL_INTERVAL > 0
|
||||
poller -> poller : pollAllServices() (immediate)
|
||||
poller -> poller : setInterval(pollAllServices,\nPOLL_INTERVAL)
|
||||
else POLL_INTERVAL = 0
|
||||
poller --> entry : "Polling disabled, on-demand mode"
|
||||
end
|
||||
|
||||
== Poll Cycle ==
|
||||
poller -> poller : Check: polling flag?\n(skip if concurrent)
|
||||
poller -> poller : polling = true
|
||||
poller -> poller : start = Date.now()
|
||||
|
||||
poller -> config : getSABnzbdInstances()
|
||||
config --> poller : [{ id, url, apiKey }]
|
||||
poller -> config : getSonarrInstances()
|
||||
config --> poller : [{ id, url, apiKey }]
|
||||
poller -> config : getRadarrInstances()
|
||||
config --> poller : [{ id, url, apiKey }]
|
||||
|
||||
note over poller : All fetches run in\nparallel via Promise.all,\neach wrapped in timed()
|
||||
|
||||
par SABnzbd Queue
|
||||
poller -> sab : GET /api?mode=queue
|
||||
sab --> poller : { queue: { slots, status, speed } }
|
||||
and SABnzbd History
|
||||
poller -> sab : GET /api?mode=history&limit=10
|
||||
sab --> poller : { history: { slots } }
|
||||
and Sonarr Tags
|
||||
poller -> sonarr : GET /api/v3/tag
|
||||
sonarr --> poller : [{ id, label }]
|
||||
and Sonarr Queue
|
||||
poller -> sonarr : GET /api/v3/queue\n?includeSeries=true
|
||||
sonarr --> poller : { records: [{ seriesId, series, ... }] }
|
||||
and Sonarr History
|
||||
poller -> sonarr : GET /api/v3/history\n?pageSize=10
|
||||
sonarr --> poller : { records: [{ seriesId, ... }] }
|
||||
and Radarr Queue
|
||||
poller -> radarr : GET /api/v3/queue\n?includeMovie=true
|
||||
radarr --> poller : { records: [{ movieId, movie, ... }] }
|
||||
and Radarr History
|
||||
poller -> radarr : GET /api/v3/history\n?pageSize=10
|
||||
radarr --> poller : { records: [{ movieId, ... }] }
|
||||
and Radarr Tags
|
||||
poller -> radarr : GET /api/v3/tag
|
||||
radarr --> poller : [{ id, label }]
|
||||
and qBittorrent
|
||||
poller -> qbt : getTorrents()
|
||||
qbt --> poller : [{ name, progress, ... }]
|
||||
end
|
||||
|
||||
poller -> poller : Record per-task timings\nlastPollTimings = { totalMs,\ntimestamp, tasks: [{label, ms}] }
|
||||
|
||||
poller -> poller : cacheTTL = POLL_INTERVAL × 3
|
||||
|
||||
poller -> cache : set('poll:sab-queue', ..., cacheTTL)
|
||||
poller -> cache : set('poll:sab-history', ..., cacheTTL)
|
||||
poller -> cache : set('poll:sonarr-tags', ..., cacheTTL)
|
||||
|
||||
note over poller : Tag queue records with\n_instanceUrl on embedded\nseries/movie objects
|
||||
|
||||
poller -> cache : set('poll:sonarr-queue', ..., cacheTTL)
|
||||
poller -> cache : set('poll:sonarr-history', ..., cacheTTL)
|
||||
poller -> cache : set('poll:radarr-queue', ..., cacheTTL)
|
||||
poller -> cache : set('poll:radarr-history', ..., cacheTTL)
|
||||
poller -> cache : set('poll:radarr-tags', ..., cacheTTL)
|
||||
poller -> cache : set('poll:qbittorrent', ..., cacheTTL)
|
||||
|
||||
poller -> poller : polling = false\nlog elapsed time
|
||||
|
||||
deactivate poller
|
||||
|
||||
@enduml
|
||||
|
After Width: | Height: | Size: 131 KiB |
@@ -1,65 +0,0 @@
|
||||
@startuml state-poller
|
||||
!theme plain
|
||||
title sofarr — Poller State Diagram
|
||||
|
||||
[*] --> CheckConfig : startPoller()
|
||||
|
||||
state CheckConfig <<choice>>
|
||||
CheckConfig --> Disabled : POLL_INTERVAL = 0\nor 'off' / 'false'
|
||||
CheckConfig --> Idle : POLL_INTERVAL > 0
|
||||
|
||||
state Disabled {
|
||||
state "On-demand mode\nNo background timer" as od
|
||||
od : Data fetched only when\na dashboard request\nfinds empty cache
|
||||
}
|
||||
|
||||
Disabled --> Polling : pollAllServices()\n(triggered by dashboard request)
|
||||
Polling --> Disabled : Poll complete\n(return to on-demand)
|
||||
|
||||
state Idle {
|
||||
state "Waiting for\nnext interval" as waiting
|
||||
}
|
||||
|
||||
Idle --> Polling : setInterval fires\nor immediate first poll
|
||||
|
||||
state Polling {
|
||||
state "polling = true" as lock
|
||||
state "Fetching all services\n(Promise.all)" as fetching
|
||||
state "Storing results\nin cache" as storing
|
||||
state "Recording timings" as timing
|
||||
|
||||
[*] --> lock
|
||||
lock --> fetching
|
||||
fetching --> storing : All promises resolved
|
||||
fetching --> ErrorState : Any individual service\nerror (caught per-service)
|
||||
storing --> timing
|
||||
timing --> [*] : polling = false
|
||||
}
|
||||
|
||||
state ErrorState as "Handle Error" {
|
||||
state "Log error\npolling = false" as err
|
||||
}
|
||||
|
||||
ErrorState --> Idle : Next interval
|
||||
Polling --> Idle : Poll complete\n(back to waiting)
|
||||
|
||||
state "Concurrent Poll\nAttempt" as skip {
|
||||
state "polling === true\n→ skip" as sk
|
||||
}
|
||||
|
||||
Idle --> skip : Interval fires while\nprevious still running
|
||||
skip --> Idle : Log "still running,\nskipping"
|
||||
|
||||
note right of Polling
|
||||
**Cache TTL**: POLL_INTERVAL × 3
|
||||
Ensures data survives between polls
|
||||
even if one cycle is slow.
|
||||
end note
|
||||
|
||||
note right of Disabled
|
||||
**Cache TTL**: 30000ms (30s)
|
||||
After expiry, next dashboard
|
||||
request triggers a fresh poll.
|
||||
end note
|
||||
|
||||
@enduml
|
||||
|
After Width: | Height: | Size: 139 KiB |
@@ -1,79 +0,0 @@
|
||||
@startuml state-ui
|
||||
!theme plain
|
||||
title sofarr — Frontend UI State Diagram
|
||||
|
||||
[*] --> SplashScreen : Page load
|
||||
|
||||
state SplashScreen {
|
||||
state "Showing splash\n(min 1.2s)" as showing
|
||||
}
|
||||
|
||||
SplashScreen --> CheckAuth : checkAuthentication()
|
||||
|
||||
state CheckAuth <<choice>>
|
||||
CheckAuth --> LoginForm : No session cookie
|
||||
CheckAuth --> Dashboard : Valid session
|
||||
|
||||
state LoginForm {
|
||||
state "Idle" as lf_idle
|
||||
state "Submitting" as lf_submit
|
||||
state "Error" as lf_error
|
||||
|
||||
lf_idle --> lf_submit : Submit form
|
||||
lf_submit --> lf_error : Auth failed
|
||||
lf_error --> lf_submit : Re-submit
|
||||
lf_submit --> FadeOutLogin : Auth success
|
||||
}
|
||||
|
||||
state FadeOutLogin {
|
||||
state "CSS transition\n(opacity → 0)" as fade
|
||||
}
|
||||
|
||||
FadeOutLogin --> SplashScreen2 : Show splash\nwhile loading
|
||||
|
||||
state SplashScreen2 as "Splash (loading data)" {
|
||||
state "fetchUserDownloads()" as fetching
|
||||
}
|
||||
|
||||
SplashScreen2 --> Dashboard : Data loaded\ndismissSplash()
|
||||
|
||||
state Dashboard {
|
||||
state "Rendering Cards" as rendering
|
||||
state "Auto Refreshing" as refreshing
|
||||
state "Status Panel Open" as status_open
|
||||
state "Status Panel Closed" as status_closed
|
||||
|
||||
[*] --> rendering
|
||||
rendering --> refreshing : startAutoRefresh()
|
||||
refreshing --> rendering : fetchUserDownloads()\n→ renderDownloads()
|
||||
rendering --> rendering : Theme change
|
||||
|
||||
status_closed --> status_open : Click "Status" btn\n(admin only)
|
||||
status_open --> status_closed : Click close (×)
|
||||
status_open --> status_open : Auto-refresh\nrenderStatusPanel()
|
||||
|
||||
[*] --> status_closed
|
||||
|
||||
state "Refresh Rate" as rr {
|
||||
state "1s" as r1
|
||||
state "5s (default)" as r5
|
||||
state "10s" as r10
|
||||
state "Off" as roff
|
||||
r5 --> r1 : User selects
|
||||
r5 --> r10
|
||||
r5 --> roff
|
||||
r1 --> r5
|
||||
r1 --> r10
|
||||
r1 --> roff
|
||||
r10 --> r1
|
||||
r10 --> r5
|
||||
r10 --> roff
|
||||
roff --> r1
|
||||
roff --> r5
|
||||
roff --> r10
|
||||
}
|
||||
}
|
||||
|
||||
Dashboard --> LoginForm : Logout\n(clear cookie,\nstopAutoRefresh)
|
||||
|
||||
@enduml
|
||||
@@ -1,27 +1,36 @@
|
||||
{
|
||||
"name": "sofarr",
|
||||
"version": "0.1.5",
|
||||
"version": "1.2.1",
|
||||
"description": "A personal media download dashboard that shows your downloads 'so far' while you relax on the sofa waiting for your *arr services to finish",
|
||||
"main": "server/index.js",
|
||||
"scripts": {
|
||||
"dev": "nodemon server/index.js",
|
||||
"start": "node server/index.js",
|
||||
"install:all": "npm install",
|
||||
"audit": "npm audit --audit-level=moderate",
|
||||
"audit:fix": "npm audit fix"
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"test:ui": "vitest --ui",
|
||||
"audit": "npm audit --audit-level=high",
|
||||
"audit:fix": "npm audit fix",
|
||||
"audit:critical": "npm audit --audit-level=critical"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.6.0",
|
||||
"cookie-parser": "^1.4.6",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.3.1",
|
||||
"express": "^4.18.2",
|
||||
"express-rate-limit": "^6.7.0",
|
||||
"helmet": "^4.6.0"
|
||||
"express-rate-limit": "^7.0.0",
|
||||
"helmet": "^7.0.0",
|
||||
"jsdom": "^29.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitest/coverage-v8": "^4.1.6",
|
||||
"concurrently": "^7.6.0",
|
||||
"nodemon": "^3.1.14"
|
||||
"nock": "^14.0.15",
|
||||
"nodemon": "^3.1.14",
|
||||
"supertest": "^7.2.2",
|
||||
"vitest": "^4.1.6"
|
||||
},
|
||||
"keywords": [
|
||||
"sabnzbd",
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
Contact: mailto:gordon@i3omb.com
|
||||
Expires: 2026-12-31T23:59:00.000Z
|
||||
Preferred-Languages: en
|
||||
Canonical: https://git.i3omb.com/Gandalf/sofarr
|
||||
Policy: https://git.i3omb.com/Gandalf/sofarr/src/branch/main/SECURITY.md
|
||||
@@ -1,11 +1,20 @@
|
||||
let currentUser = null;
|
||||
let downloads = [];
|
||||
let refreshInterval = null;
|
||||
let currentRefreshRate = 5000; // default 5 seconds
|
||||
let isAdmin = false;
|
||||
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;
|
||||
const SSE_RECONNECT_MS = 3000; // browser already retries, but we add explicit backoff too
|
||||
|
||||
// Apply saved theme immediately (before DOMContentLoaded to avoid flash)
|
||||
(function() {
|
||||
const saved = localStorage.getItem('sofarr-theme') || 'light';
|
||||
@@ -16,14 +25,27 @@ const SPLASH_MIN_MS = 1200; // minimum splash display time
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
checkAuthentication();
|
||||
initThemeSwitcher();
|
||||
|
||||
initTabs();
|
||||
initHistoryControls();
|
||||
loadAppVersion();
|
||||
|
||||
document.getElementById('login-form').addEventListener('submit', handleLogin);
|
||||
document.getElementById('logout-btn').addEventListener('click', handleLogout);
|
||||
document.getElementById('refresh-rate').addEventListener('change', handleRefreshRateChange);
|
||||
document.getElementById('show-all-toggle').addEventListener('change', handleShowAllToggle);
|
||||
document.getElementById('status-btn').addEventListener('click', toggleStatusPanel);
|
||||
});
|
||||
|
||||
function loadAppVersion() {
|
||||
fetch('/health')
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.version) {
|
||||
document.getElementById('app-version').textContent = `sofarr v${data.version}`;
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
function initThemeSwitcher() {
|
||||
const saved = localStorage.getItem('sofarr-theme') || 'light';
|
||||
document.querySelectorAll('.theme-btn').forEach(btn => {
|
||||
@@ -40,37 +62,77 @@ function setTheme(theme) {
|
||||
});
|
||||
}
|
||||
|
||||
function startAutoRefresh() {
|
||||
if (refreshInterval) clearInterval(refreshInterval);
|
||||
if (currentRefreshRate > 0) {
|
||||
refreshInterval = setInterval(() => fetchUserDownloads(false), currentRefreshRate);
|
||||
}
|
||||
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 handleRefreshRateChange(e) {
|
||||
const rate = parseInt(e.target.value);
|
||||
currentRefreshRate = rate;
|
||||
startAutoRefresh();
|
||||
// Restart status panel refresh if it's open
|
||||
const statusPanel = document.getElementById('status-panel');
|
||||
if (statusPanel && statusPanel.style.display !== 'none') {
|
||||
if (statusRefreshHandle) { clearInterval(statusRefreshHandle); statusRefreshHandle = null; }
|
||||
if (currentRefreshRate > 0) {
|
||||
statusRefreshHandle = setInterval(refreshStatusPanel, currentRefreshRate);
|
||||
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() {
|
||||
stopSSE();
|
||||
const params = showAll ? '?showAll=true' : '';
|
||||
const source = new EventSource('/api/dashboard/stream' + params);
|
||||
sseSource = source;
|
||||
|
||||
let firstMessage = true;
|
||||
source.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
currentUser = data.user;
|
||||
isAdmin = !!data.isAdmin;
|
||||
downloads = data.downloads;
|
||||
document.getElementById('currentUser').textContent = currentUser || '-';
|
||||
renderDownloads();
|
||||
hideError();
|
||||
if (firstMessage) { firstMessage = false; hideLoading(); }
|
||||
} catch (err) {
|
||||
console.error('[SSE] Failed to parse message:', err);
|
||||
}
|
||||
};
|
||||
|
||||
source.onerror = () => {
|
||||
// EventSource retries automatically; we just log and show a reconnecting indicator
|
||||
console.warn('[SSE] Connection lost, browser will retry...');
|
||||
};
|
||||
|
||||
console.log('[SSE] Stream connected');
|
||||
}
|
||||
|
||||
function stopSSE() {
|
||||
if (sseReconnectTimer) { clearTimeout(sseReconnectTimer); sseReconnectTimer = null; }
|
||||
if (sseSource) {
|
||||
sseSource.close();
|
||||
sseSource = null;
|
||||
console.log('[SSE] Stream closed');
|
||||
}
|
||||
}
|
||||
|
||||
function handleShowAllToggle(e) {
|
||||
showAll = e.target.checked;
|
||||
fetchUserDownloads(true);
|
||||
}
|
||||
|
||||
function stopAutoRefresh() {
|
||||
if (refreshInterval) {
|
||||
clearInterval(refreshInterval);
|
||||
refreshInterval = null;
|
||||
}
|
||||
// Re-open stream with updated showAll param
|
||||
startSSE();
|
||||
// Reload history with updated showAll param
|
||||
loadHistory();
|
||||
}
|
||||
|
||||
function fadeOutLogin() {
|
||||
@@ -118,15 +180,21 @@ function dismissSplash(startTime) {
|
||||
async function checkAuthentication() {
|
||||
const splashStart = Date.now();
|
||||
try {
|
||||
const response = await fetch('/api/auth/me');
|
||||
const data = await response.json();
|
||||
|
||||
// Fetch both auth state and a fresh CSRF token in parallel
|
||||
const [meRes, csrfRes] = await Promise.all([
|
||||
fetch('/api/auth/me'),
|
||||
fetch('/api/auth/csrf')
|
||||
]);
|
||||
const data = await meRes.json();
|
||||
const csrfData = await csrfRes.json();
|
||||
if (csrfData.csrfToken) csrfToken = csrfData.csrfToken;
|
||||
|
||||
if (data.authenticated) {
|
||||
currentUser = data.user;
|
||||
isAdmin = !!data.user.isAdmin;
|
||||
showDashboard();
|
||||
await fetchUserDownloads(true);
|
||||
startAutoRefresh();
|
||||
showLoading();
|
||||
startSSE();
|
||||
await dismissSplash(splashStart);
|
||||
} else {
|
||||
await dismissSplash(splashStart);
|
||||
@@ -160,7 +228,9 @@ async function handleLogin(e) {
|
||||
if (data.success) {
|
||||
currentUser = data.user;
|
||||
isAdmin = !!data.user.isAdmin;
|
||||
// Fade out login, then show splash while loading data.
|
||||
// Store CSRF token returned by login for use in subsequent requests
|
||||
if (data.csrfToken) csrfToken = data.csrfToken;
|
||||
// Fade out login, then show splash while opening SSE stream.
|
||||
// requestAnimationFrame ensures the browser paints the splash at
|
||||
// opacity:1 before dismissSplash adds fade-out, so the CSS
|
||||
// transition fires and transitionend is guaranteed.
|
||||
@@ -168,9 +238,9 @@ async function handleLogin(e) {
|
||||
showSplash();
|
||||
await new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)));
|
||||
showDashboard();
|
||||
showLoading();
|
||||
const splashStart = Date.now();
|
||||
await fetchUserDownloads(true);
|
||||
startAutoRefresh();
|
||||
startSSE();
|
||||
await dismissSplash(splashStart);
|
||||
} else {
|
||||
showLoginError(data.error || 'Login failed');
|
||||
@@ -183,12 +253,17 @@ async function handleLogin(e) {
|
||||
|
||||
async function handleLogout() {
|
||||
try {
|
||||
stopAutoRefresh();
|
||||
stopSSE();
|
||||
stopHistoryRefresh();
|
||||
if (statusRefreshHandle) { clearInterval(statusRefreshHandle); statusRefreshHandle = null; }
|
||||
await fetch('/api/auth/logout', {
|
||||
method: 'POST'
|
||||
method: 'POST',
|
||||
headers: csrfToken ? { 'X-CSRF-Token': csrfToken } : {}
|
||||
});
|
||||
currentUser = null;
|
||||
csrfToken = null;
|
||||
downloads = [];
|
||||
clearHistory();
|
||||
showLogin();
|
||||
} catch (err) {
|
||||
console.error('Logout failed:', err);
|
||||
@@ -205,7 +280,15 @@ function showDashboard() {
|
||||
document.getElementById('login-container').style.display = 'none';
|
||||
document.getElementById('dashboard-container').style.display = 'block';
|
||||
document.getElementById('currentUser').textContent = currentUser.name || '-';
|
||||
// Always start with status panel hidden (guards against stale display value on re-login)
|
||||
const sp = document.getElementById('status-panel');
|
||||
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) {
|
||||
@@ -219,41 +302,33 @@ function hideLoginError() {
|
||||
errorDiv.style.display = 'none';
|
||||
}
|
||||
|
||||
async function fetchUserDownloads(isInitialLoad = false) {
|
||||
if (isInitialLoad) {
|
||||
showLoading();
|
||||
}
|
||||
hideError();
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
if (showAll) params.set('showAll', 'true');
|
||||
params.set('refreshRate', currentRefreshRate);
|
||||
const url = '/api/dashboard/user-downloads?' + params.toString();
|
||||
const response = await fetch(url);
|
||||
const data = await response.json();
|
||||
|
||||
currentUser = data.user;
|
||||
isAdmin = !!data.isAdmin;
|
||||
downloads = data.downloads;
|
||||
|
||||
// Debug: log first download to see what fields are present
|
||||
if (downloads.length > 0) {
|
||||
console.log('[Dashboard] Download data:', JSON.stringify(downloads[0]));
|
||||
}
|
||||
|
||||
document.getElementById('currentUser').textContent = currentUser || '-';
|
||||
renderDownloads();
|
||||
} catch (err) {
|
||||
showError('Failed to fetch downloads. Make sure all services are configured.');
|
||||
console.error(err);
|
||||
} finally {
|
||||
if (isInitialLoad) {
|
||||
hideLoading();
|
||||
}
|
||||
// Build an episode-info element for series downloads/history.
|
||||
// Single episode: "S01E05 — Episode Title"
|
||||
// Multiple episodes: "Multiple episodes" with tooltip listing them all.
|
||||
// Returns null if no episode data.
|
||||
function formatEpisodeInfo(episodes) {
|
||||
if (!episodes || episodes.length === 0) return null;
|
||||
const el = document.createElement('p');
|
||||
el.className = 'episode-info';
|
||||
if (episodes.length === 1) {
|
||||
const ep = episodes[0];
|
||||
const code = 'S' + String(ep.season).padStart(2, '0') + 'E' + String(ep.episode).padStart(2, '0');
|
||||
el.textContent = ep.title ? code + ' \u2014 ' + ep.title : code;
|
||||
} else {
|
||||
el.textContent = 'Multiple episodes';
|
||||
el.classList.add('multi-episode');
|
||||
const lines = episodes.map(ep => {
|
||||
const code = 'S' + String(ep.season).padStart(2, '0') + 'E' + String(ep.episode).padStart(2, '0');
|
||||
return ep.title ? code + ' \u2014 ' + ep.title : code;
|
||||
});
|
||||
el.setAttribute('data-tooltip', lines.join('\n'));
|
||||
}
|
||||
return el;
|
||||
}
|
||||
|
||||
// fetchUserDownloads is kept for the showAll toggle re-connection case
|
||||
// but the primary data path is now via SSE (startSSE / EventSource).
|
||||
|
||||
function renderDownloads() {
|
||||
const downloadsList = document.getElementById('downloads-list');
|
||||
const noDownloads = document.getElementById('no-downloads');
|
||||
@@ -359,9 +434,10 @@ function updateDownloadCard(card, download) {
|
||||
peersEl.textContent = download.peers;
|
||||
}
|
||||
|
||||
const availabilityEl = card.querySelector('.detail-item[data-label="Availability"] .detail-value');
|
||||
if (availabilityEl && download.availability !== undefined) {
|
||||
availabilityEl.textContent = `${download.availability}%`;
|
||||
const availabilityItem = card.querySelector('.detail-item[data-label="Availability"]');
|
||||
if (availabilityItem && download.availability !== undefined) {
|
||||
availabilityItem.querySelector('.detail-value').textContent = `${download.availability}%`;
|
||||
availabilityItem.classList.toggle('availability-warning', parseFloat(download.availability) < 100);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -376,7 +452,11 @@ function createDownloadCard(download) {
|
||||
const coverDiv = document.createElement('div');
|
||||
coverDiv.className = 'download-cover';
|
||||
const coverImg = document.createElement('img');
|
||||
coverImg.src = download.coverArt;
|
||||
// Proxy cover art through the server so the CSP img-src 'self' rule
|
||||
// is satisfied (external poster URLs would be blocked otherwise).
|
||||
coverImg.src = download.coverArt
|
||||
? '/api/dashboard/cover-art?url=' + encodeURIComponent(download.coverArt)
|
||||
: '';
|
||||
coverImg.alt = download.movieName || download.seriesName || download.title;
|
||||
coverImg.loading = 'lazy';
|
||||
coverDiv.appendChild(coverImg);
|
||||
@@ -434,6 +514,8 @@ function createDownloadCard(download) {
|
||||
series.textContent = `Series: ${download.seriesName}`;
|
||||
}
|
||||
infoDiv.appendChild(series);
|
||||
const epEl = formatEpisodeInfo(download.episodes);
|
||||
if (epEl) infoDiv.appendChild(epEl);
|
||||
}
|
||||
|
||||
if (download.movieName) {
|
||||
@@ -554,6 +636,7 @@ function createDownloadCard(download) {
|
||||
|
||||
if (download.availability !== undefined) {
|
||||
const availability = createDetailItem('Availability', `${download.availability}%`);
|
||||
if (parseFloat(download.availability) < 100) availability.classList.add('availability-warning');
|
||||
details.appendChild(availability);
|
||||
}
|
||||
}
|
||||
@@ -613,6 +696,7 @@ function escapeHtml(str) {
|
||||
}
|
||||
|
||||
let statusRefreshHandle = null;
|
||||
const STATUS_REFRESH_MS = 5000;
|
||||
|
||||
async function toggleStatusPanel() {
|
||||
const panel = document.getElementById('status-panel');
|
||||
@@ -623,11 +707,8 @@ async function toggleStatusPanel() {
|
||||
}
|
||||
panel.style.display = 'block';
|
||||
await refreshStatusPanel();
|
||||
// Auto-refresh in sync with dashboard refresh rate
|
||||
if (statusRefreshHandle) clearInterval(statusRefreshHandle);
|
||||
if (currentRefreshRate > 0) {
|
||||
statusRefreshHandle = setInterval(refreshStatusPanel, currentRefreshRate);
|
||||
}
|
||||
statusRefreshHandle = setInterval(refreshStatusPanel, STATUS_REFRESH_MS);
|
||||
}
|
||||
|
||||
function closeStatusPanel() {
|
||||
@@ -678,11 +759,7 @@ function renderStatusPanel(data, panel) {
|
||||
|
||||
const pollIntervalMs = data.polling.intervalMs;
|
||||
const clients = data.clients || [];
|
||||
const activeRefreshers = clients.filter(c => c.refreshRateMs > 0);
|
||||
const fastestClient = activeRefreshers.length > 0
|
||||
? activeRefreshers.reduce((min, c) => c.refreshRateMs < min.refreshRateMs ? c : min)
|
||||
: null;
|
||||
const hasForegroundClient = fastestClient && data.polling.enabled && fastestClient.refreshRateMs < pollIntervalMs;
|
||||
const sseClients = clients.filter(c => c.type === 'sse');
|
||||
|
||||
if (data.polling.enabled) {
|
||||
html += `<div class="status-row"><span>Background poll</span><span>${pollIntervalMs / 1000}s</span></div>`;
|
||||
@@ -690,19 +767,15 @@ function renderStatusPanel(data, panel) {
|
||||
html += `<div class="status-row"><span>Background poll</span><span>Disabled</span></div>`;
|
||||
}
|
||||
|
||||
if (hasForegroundClient) {
|
||||
html += `<div class="status-row"><span>Effective mode</span><span class="status-fg-badge">Foreground ${fastestClient.refreshRateMs / 1000}s</span></div>`;
|
||||
} else if (activeRefreshers.length > 0) {
|
||||
html += `<div class="status-row"><span>Effective mode</span><span>${data.polling.enabled ? 'Background' : 'On-demand'}</span></div>`;
|
||||
} else {
|
||||
html += `<div class="status-row"><span>Effective mode</span><span>Idle (no active clients)</span></div>`;
|
||||
}
|
||||
const mode = sseClients.length > 0
|
||||
? `<span class="status-fg-badge">SSE push</span>`
|
||||
: (data.polling.enabled ? 'Background' : 'On-demand (idle)');
|
||||
html += `<div class="status-row"><span>Delivery mode</span><span>${mode}</span></div>`;
|
||||
|
||||
html += `<div class="status-row"><span>Active clients</span><span>${clients.length}</span></div>`;
|
||||
for (const c of clients) {
|
||||
const rate = c.refreshRateMs > 0 ? (c.refreshRateMs / 1000) + 's' : 'Off';
|
||||
const age = Math.round((Date.now() - c.lastSeen) / 1000);
|
||||
html += `<div class="status-row status-row-sub"><span>${escapeHtml(c.user)}</span><span>${rate} (${age}s ago)</span></div>`;
|
||||
html += `<div class="status-row"><span>SSE clients</span><span>${sseClients.length}</span></div>`;
|
||||
for (const c of sseClients) {
|
||||
const age = Math.round((Date.now() - c.connectedAt) / 1000);
|
||||
html += `<div class="status-row status-row-sub"><span>${escapeHtml(c.user)}</span><span>connected ${age}s ago</span></div>`;
|
||||
}
|
||||
|
||||
html += `</div>`;
|
||||
@@ -715,12 +788,13 @@ function renderStatusPanel(data, panel) {
|
||||
<div class="status-card status-card-wide">
|
||||
<div class="status-card-title">Last Poll (${lp.totalMs}ms total, ${pollAge}s ago)</div>
|
||||
<div class="status-timings">`;
|
||||
const maxTaskMs = lp.tasks.reduce((max, t) => Math.max(max, t.ms), 1);
|
||||
for (const t of lp.tasks) {
|
||||
const barWidth = lp.totalMs > 0 ? Math.max(2, (t.ms / lp.totalMs) * 100) : 0;
|
||||
const barWidth = Math.max(2, (t.ms / maxTaskMs) * 100);
|
||||
html += `
|
||||
<div class="timing-row">
|
||||
<span class="timing-label">${escapeHtml(t.label)}</span>
|
||||
<div class="timing-bar-bg"><div class="timing-bar" style="width:${barWidth.toFixed(1)}%"></div></div>
|
||||
<div class="timing-bar-bg"><div class="timing-bar" data-w="${barWidth.toFixed(1)}"></div></div>
|
||||
<span class="timing-value">${t.ms}ms</span>
|
||||
</div>`;
|
||||
}
|
||||
@@ -744,6 +818,10 @@ function renderStatusPanel(data, panel) {
|
||||
|
||||
html += `</tbody></table></div></div>`;
|
||||
panel.innerHTML = html;
|
||||
// Set bar widths via JS DOM assignment — immune to CSP style-src restrictions
|
||||
panel.querySelectorAll('.timing-bar[data-w]').forEach(el => {
|
||||
el.style.width = el.dataset.w + '%';
|
||||
});
|
||||
}
|
||||
|
||||
function formatSize(size) {
|
||||
@@ -783,3 +861,199 @@ 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: <a href="' + escapeHtml(item.arrLink) + '" target="_blank" class="arr-link">' + escapeHtml(item.seriesName) + '</a>';
|
||||
} else {
|
||||
p.textContent = 'Series: ' + item.seriesName;
|
||||
}
|
||||
info.appendChild(p);
|
||||
const epEl = formatEpisodeInfo(item.episodes);
|
||||
if (epEl) info.appendChild(epEl);
|
||||
}
|
||||
if (item.movieName) {
|
||||
const p = document.createElement('p');
|
||||
p.className = 'history-media-name';
|
||||
if (isAdmin && item.arrLink) {
|
||||
p.innerHTML = 'Movie: <a href="' + escapeHtml(item.arrLink) + '" target="_blank" class="arr-link">' + escapeHtml(item.movieName) + '</a>';
|
||||
} 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;
|
||||
}
|
||||
|
||||
@@ -46,22 +46,13 @@
|
||||
<!-- Dashboard -->
|
||||
<div id="dashboard-container" class="dashboard-container" style="display: none;">
|
||||
<header class="app-header">
|
||||
<h1>sofarr</h1>
|
||||
<h1><a href="https://git.i3omb.com/Gandalf/sofarr" target="_blank" rel="noopener noreferrer" class="title-link">sofarr</a></h1>
|
||||
<div class="header-controls">
|
||||
<div class="theme-switcher">
|
||||
<button class="theme-btn active" data-theme="light">Light</button>
|
||||
<button class="theme-btn" data-theme="dark">Dark</button>
|
||||
<button class="theme-btn" data-theme="mono">Mono</button>
|
||||
</div>
|
||||
<div class="refresh-control">
|
||||
<label for="refresh-rate">Refresh:</label>
|
||||
<select id="refresh-rate">
|
||||
<option value="1000">1s</option>
|
||||
<option value="5000" selected>5s</option>
|
||||
<option value="10000">10s</option>
|
||||
<option value="0">Off</option>
|
||||
</select>
|
||||
</div>
|
||||
<div id="admin-controls" class="admin-controls" style="display: none;">
|
||||
<label class="toggle-label">
|
||||
<input type="checkbox" id="show-all-toggle">
|
||||
@@ -83,17 +74,45 @@
|
||||
|
||||
<div id="loading" class="loading" style="display: none;">Loading downloads...</div>
|
||||
|
||||
<div class="downloads-container">
|
||||
<h2>Your Downloads</h2>
|
||||
<div id="no-downloads" class="no-downloads" style="display: none;">
|
||||
<p>No downloads found for your user.</p>
|
||||
<p>Make sure your shows and movies are tagged with your username in Sonarr/Radarr.</p>
|
||||
<div class="main-tabs">
|
||||
<div class="tab-bar">
|
||||
<button class="tab-btn active" data-tab="downloads">Active Downloads</button>
|
||||
<button class="tab-btn" data-tab="history">Recently Completed</button>
|
||||
</div>
|
||||
|
||||
<div class="tab-panel" id="tab-downloads">
|
||||
<div class="downloads-container">
|
||||
<div id="no-downloads" class="no-downloads" style="display: none;">
|
||||
<p>No downloads found for your user.</p>
|
||||
<p>Make sure your shows and movies are tagged with your username in Sonarr/Radarr.</p>
|
||||
</div>
|
||||
<div id="downloads-list" class="downloads-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tab-panel" id="tab-history" style="display: none;">
|
||||
<div class="history-container" id="history-container">
|
||||
<div class="history-header">
|
||||
<div class="history-controls">
|
||||
<label class="history-days-label" for="history-days">Last</label>
|
||||
<input type="number" id="history-days" class="history-days-input" value="7" min="1" max="90">
|
||||
<span class="history-days-label">days</span>
|
||||
<button id="history-refresh-btn" class="history-refresh-btn" title="Refresh history">↻</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="history-loading" class="history-loading" style="display: none;">Loading history...</div>
|
||||
<div id="history-error" class="history-error" style="display: none;"></div>
|
||||
<div id="no-history" class="no-history" style="display: none;">
|
||||
<p>No completed downloads found in this period.</p>
|
||||
</div>
|
||||
<div id="history-list" class="history-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="downloads-list" class="downloads-list"></div>
|
||||
</div>
|
||||
|
||||
<footer class="app-footer">
|
||||
<p>Ensure your media is tagged with your username in Sonarr/Radarr to match downloads to users.</p>
|
||||
<p class="app-version" id="app-version"></p>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -31,41 +31,68 @@
|
||||
|
||||
/* ===== Theme Variables ===== */
|
||||
:root, [data-theme="light"] {
|
||||
--bg-gradient-start: #667eea;
|
||||
--bg-gradient-end: #764ba2;
|
||||
/* Page background — clean off-white matching logo backdrop */
|
||||
--bg-gradient-start: #e8eef3;
|
||||
--bg-gradient-end: #d4dee8;
|
||||
|
||||
/* Surfaces */
|
||||
--surface: #ffffff;
|
||||
--surface-alt: #f5f5f5;
|
||||
--text-primary: #333333;
|
||||
--text-secondary: #666666;
|
||||
--text-muted: #999999;
|
||||
--border: #e0e0e0;
|
||||
--accent: #667eea;
|
||||
--accent-hover: #5568d3;
|
||||
--accent-light: #e8eaf6;
|
||||
--series-color: #667eea;
|
||||
--series-bg: #e8eaf6;
|
||||
--movie-color: #e040a0;
|
||||
--movie-bg: #fce4ec;
|
||||
--torrent-color: #26a69a;
|
||||
--torrent-bg: #e0f2f1;
|
||||
--success: #4caf50;
|
||||
--surface-alt: #f0f4f7;
|
||||
|
||||
/* Typography — charcoal from logo, all meet WCAG AA on white */
|
||||
--text-primary: #2b2f33; /* ~14:1 on white */
|
||||
--text-secondary: #4d5760; /* ~7.5:1 on white */
|
||||
--text-muted: #6b7784; /* ~4.6:1 on white — AA compliant */
|
||||
|
||||
/* Borders */
|
||||
--border: #c8d3db;
|
||||
|
||||
/* Accent — primary teal from couch outline */
|
||||
--accent: #1f7d94; /* ~4.6:1 on white — AA compliant */
|
||||
--accent-hover: #165f70; /* darker for hover */
|
||||
--accent-light: #e0f0f4; /* very light teal tint for backgrounds */
|
||||
|
||||
/* Series — steel blue from sofa body */
|
||||
--series-color: #1e6b8a; /* ~5.0:1 on white — AA */
|
||||
--series-bg: #dceef5;
|
||||
|
||||
/* Movie — warm coral (complementary to teal, accessible) */
|
||||
--movie-color: #b5451b; /* ~5.5:1 on white — AA */
|
||||
--movie-bg: #fdeee8;
|
||||
|
||||
/* Torrent — mid teal-green */
|
||||
--torrent-color: #1a7a6e; /* ~4.7:1 on white — AA */
|
||||
--torrent-bg: #ddf2ef;
|
||||
|
||||
/* State colours */
|
||||
--success: #2e7d32; /* ~7.1:1 on white — AA */
|
||||
--success-bg: #e8f5e9;
|
||||
--info: #2196f3;
|
||||
--info-bg: #e3f2fd;
|
||||
--danger: #f44336;
|
||||
--danger-bg: #ffebee;
|
||||
--danger-border: #ffcdd2;
|
||||
--progress-bg: #ffebee;
|
||||
--progress-border: #ffcdd2;
|
||||
--progress-fill-start: #4caf50;
|
||||
--progress-fill-end: #66bb6a;
|
||||
--shadow: rgba(0, 0, 0, 0.1);
|
||||
--shadow-strong: rgba(0, 0, 0, 0.15);
|
||||
--footer-text: rgba(255, 255, 255, 0.9);
|
||||
--info: #1565c0; /* ~7.3:1 on white — AA */
|
||||
--info-bg: #e3f0fb;
|
||||
--danger: #c62828; /* ~6.5:1 on white — AA */
|
||||
--danger-bg: #fdecea;
|
||||
--danger-border: #f5c6c2;
|
||||
|
||||
/* Progress bar */
|
||||
--progress-bg: #eaf2f5;
|
||||
--progress-border: #c8d3db;
|
||||
--progress-fill-start: #1f7d94;
|
||||
--progress-fill-end: #2da0bc;
|
||||
|
||||
/* Shadows */
|
||||
--shadow: rgba(30, 60, 80, 0.10);
|
||||
--shadow-strong: rgba(30, 60, 80, 0.18);
|
||||
|
||||
/* Footer — dark text on light page background */
|
||||
--footer-text: #4d5760;
|
||||
|
||||
/* Inputs */
|
||||
--input-bg: #ffffff;
|
||||
--select-bg: #ffffff;
|
||||
|
||||
/* Unmatched tag — amber, accessible on its bg */
|
||||
--unmatched-tag-bg: #fff3e0;
|
||||
--unmatched-tag-color: #e65100;
|
||||
--unmatched-tag-color: #7a4000; /* ~7.1:1 on #fff3e0 — AA */
|
||||
}
|
||||
|
||||
[data-theme="dark"] {
|
||||
@@ -458,21 +485,60 @@ body {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.episode-info {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.78rem;
|
||||
margin: -2px 0 6px;
|
||||
}
|
||||
|
||||
.episode-info.multi-episode {
|
||||
cursor: help;
|
||||
text-decoration: underline dotted;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.episode-info.multi-episode:hover::after {
|
||||
content: attr(data-tooltip);
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 100%;
|
||||
z-index: 20;
|
||||
background: var(--surface);
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 8px 10px;
|
||||
font-size: 0.75rem;
|
||||
white-space: pre-line;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
|
||||
max-width: 320px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* ===== Detail Row (Inline) ===== */
|
||||
.download-details {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px 14px;
|
||||
gap: 4px 6px;
|
||||
padding-top: 6px;
|
||||
border-top: 1px solid var(--border);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.detail-item {
|
||||
display: flex;
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
gap: 4px;
|
||||
gap: 3px;
|
||||
font-size: 0.78rem;
|
||||
background: var(--bg-secondary, rgba(0,0,0,0.04));
|
||||
border-radius: 4px;
|
||||
padding: 2px 6px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.detail-item.availability-warning .detail-value {
|
||||
color: var(--danger, #e53e3e);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.detail-label {
|
||||
@@ -491,10 +557,17 @@ body {
|
||||
/* ===== Progress Bar (Compact) ===== */
|
||||
.progress-item {
|
||||
flex-basis: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: none;
|
||||
padding: 0;
|
||||
white-space: normal;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.progress-container {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
@@ -510,13 +583,15 @@ body {
|
||||
}
|
||||
|
||||
.progress-segment {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
height: 100%;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.progress-segment.downloaded {
|
||||
background: linear-gradient(90deg, var(--progress-fill-start) 0%, var(--progress-fill-end) 100%);
|
||||
float: left;
|
||||
}
|
||||
|
||||
.progress-text {
|
||||
@@ -534,6 +609,251 @@ body {
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
/* ===== Main Tabs ===== */
|
||||
.main-tabs {
|
||||
max-width: 1200px;
|
||||
margin: 16px auto 0;
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
.tab-bar {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
border-bottom: 2px solid var(--border);
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.tab-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
border-bottom: 3px solid transparent;
|
||||
margin-bottom: -2px;
|
||||
padding: 10px 20px;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
transition: color 0.2s, border-color 0.2s;
|
||||
border-radius: 4px 4px 0 0;
|
||||
}
|
||||
|
||||
.tab-btn:hover {
|
||||
color: var(--text-primary);
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.tab-btn.active {
|
||||
color: var(--accent);
|
||||
border-bottom-color: var(--accent);
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.tab-panel {
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
/* ===== Recently Completed History ===== */
|
||||
.history-container {
|
||||
max-width: unset;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.history-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.history-header h2 {
|
||||
margin: 0;
|
||||
font-size: 1.2rem;
|
||||
color: var(--text-primary);
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.history-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
.history-days-label {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.history-days-input {
|
||||
width: 52px;
|
||||
padding: 3px 6px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
background: var(--surface);
|
||||
color: var(--text-primary);
|
||||
font-size: 0.85rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.history-refresh-btn {
|
||||
background: none;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
padding: 2px 7px;
|
||||
line-height: 1.4;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
|
||||
.history-refresh-btn:hover {
|
||||
background: var(--hover-bg);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.history-loading,
|
||||
.history-error,
|
||||
.no-history {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.9rem;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.history-error {
|
||||
color: var(--error, #e74c3c);
|
||||
}
|
||||
|
||||
.history-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.history-card {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 10px 12px;
|
||||
transition: background 0.2s;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.history-card.failed {
|
||||
border-left: 3px solid var(--error, #e74c3c);
|
||||
}
|
||||
|
||||
.history-card.imported {
|
||||
border-left: 3px solid var(--success, #27ae60);
|
||||
}
|
||||
|
||||
.history-cover {
|
||||
flex: 0 0 48px;
|
||||
width: 48px;
|
||||
}
|
||||
|
||||
.history-cover img {
|
||||
width: 48px;
|
||||
height: 68px;
|
||||
object-fit: cover;
|
||||
border-radius: 4px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.history-info {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.history-card-header {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 5px;
|
||||
align-items: center;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.history-type-badge,
|
||||
.history-outcome-badge,
|
||||
.history-instance-badge {
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
padding: 2px 7px;
|
||||
border-radius: 10px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.history-type-badge.series {
|
||||
background: var(--badge-series-bg, #2980b9);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.history-type-badge.movie {
|
||||
background: var(--badge-movie-bg, #8e44ad);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.history-outcome-badge.imported {
|
||||
background: var(--success, #27ae60);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.history-outcome-badge.failed {
|
||||
background: var(--error, #e74c3c);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.history-instance-badge {
|
||||
background: var(--tag-bg, #ecf0f1);
|
||||
color: var(--text-secondary);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.history-title {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
margin: 0 0 2px;
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.history-media-name {
|
||||
font-size: 0.82rem;
|
||||
color: var(--text-secondary);
|
||||
margin: 0 0 4px;
|
||||
}
|
||||
|
||||
.history-details {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 5px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.history-failure-message {
|
||||
font-size: 0.78rem;
|
||||
color: var(--error, #e74c3c);
|
||||
background: var(--error-bg, rgba(231, 76, 60, 0.08));
|
||||
border-radius: 4px;
|
||||
padding: 3px 7px;
|
||||
flex-basis: 100%;
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.history-cover {
|
||||
display: none;
|
||||
}
|
||||
.history-title {
|
||||
white-space: normal;
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== Footer ===== */
|
||||
.app-footer {
|
||||
margin-top: 12px;
|
||||
@@ -546,6 +866,22 @@ body {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.app-version {
|
||||
font-size: 0.72rem;
|
||||
opacity: 0.5;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.title-link {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.title-link:hover {
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 3px;
|
||||
}
|
||||
|
||||
/* ===== Login ===== */
|
||||
.login-container {
|
||||
display: flex;
|
||||
@@ -1021,11 +1357,6 @@ body {
|
||||
width: 40px;
|
||||
}
|
||||
|
||||
.download-details {
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.progress-container {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* Express application factory — imported by both server/index.js (production)
|
||||
* and the test suite. Keeping app creation separate from app.listen() means
|
||||
* tests can import a fresh instance without starting a real server or
|
||||
* triggering the side-effects in index.js (log files, process.exit, poller).
|
||||
*/
|
||||
|
||||
const express = require('express');
|
||||
const cookieParser = require('cookie-parser');
|
||||
const helmet = require('helmet');
|
||||
const rateLimit = require('express-rate-limit');
|
||||
const crypto = require('crypto');
|
||||
|
||||
const sabnzbdRoutes = require('./routes/sabnzbd');
|
||||
const sonarrRoutes = require('./routes/sonarr');
|
||||
const radarrRoutes = require('./routes/radarr');
|
||||
const embyRoutes = require('./routes/emby');
|
||||
const dashboardRoutes = require('./routes/dashboard');
|
||||
const historyRoutes = require('./routes/history');
|
||||
const authRoutes = require('./routes/auth');
|
||||
const verifyCsrf = require('./middleware/verifyCsrf');
|
||||
|
||||
function createApp({ skipRateLimits = false } = {}) {
|
||||
const app = express();
|
||||
|
||||
if (process.env.TRUST_PROXY) {
|
||||
const trustValue = /^\d+$/.test(process.env.TRUST_PROXY)
|
||||
? parseInt(process.env.TRUST_PROXY, 10)
|
||||
: process.env.TRUST_PROXY;
|
||||
app.set('trust proxy', trustValue);
|
||||
}
|
||||
|
||||
// Per-request CSP nonce
|
||||
app.use((req, res, next) => {
|
||||
res.locals.cspNonce = crypto.randomBytes(16).toString('base64');
|
||||
next();
|
||||
});
|
||||
|
||||
app.use((req, res, next) => {
|
||||
helmet({
|
||||
contentSecurityPolicy: {
|
||||
directives: {
|
||||
defaultSrc: ["'self'"],
|
||||
scriptSrc: ["'self'", (req, res) => `'nonce-${res.locals.cspNonce}'`],
|
||||
styleSrc: ["'self'", (req, res) => `'nonce-${res.locals.cspNonce}'`],
|
||||
styleSrcAttr: ["'unsafe-inline'"],
|
||||
imgSrc: ["'self'", 'data:', 'blob:'],
|
||||
fontSrc: ["'self'", 'data:'],
|
||||
connectSrc: ["'self'"],
|
||||
objectSrc: ["'none'"],
|
||||
baseUri: ["'self'"],
|
||||
frameAncestors: ["'none'"],
|
||||
formAction: ["'self'"],
|
||||
upgradeInsecureRequests: process.env.TRUST_PROXY ? [] : null
|
||||
}
|
||||
},
|
||||
hsts: { maxAge: 31536000, includeSubDomains: true, preload: true },
|
||||
referrerPolicy: { policy: 'strict-origin-when-cross-origin' },
|
||||
crossOriginEmbedderPolicy: false
|
||||
})(req, res, next);
|
||||
});
|
||||
|
||||
app.use((req, res, next) => {
|
||||
res.setHeader('Permissions-Policy', 'camera=(), microphone=(), geolocation=(), payment=(), usb=()');
|
||||
next();
|
||||
});
|
||||
|
||||
const apiLimiter = rateLimit({
|
||||
windowMs: 15 * 60 * 1000,
|
||||
max: skipRateLimits ? Number.MAX_SAFE_INTEGER : 300,
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
message: { error: 'Too many requests, please try again later' }
|
||||
});
|
||||
|
||||
app.use(cookieParser(process.env.COOKIE_SECRET || undefined));
|
||||
app.use(express.json({ limit: '64kb' }));
|
||||
|
||||
// Health / readiness (no auth, no rate-limit)
|
||||
app.get('/health', (req, res) => {
|
||||
res.json({ status: 'ok', uptime: process.uptime() });
|
||||
});
|
||||
|
||||
app.get('/ready', (req, res) => {
|
||||
const ready = !!(process.env.EMBY_URL);
|
||||
if (ready) {
|
||||
res.json({ status: 'ready' });
|
||||
} else {
|
||||
res.status(503).json({ status: 'not ready', reason: 'EMBY_URL not configured' });
|
||||
}
|
||||
});
|
||||
|
||||
// API routes
|
||||
app.use('/api', apiLimiter);
|
||||
app.use('/api/auth', authRoutes);
|
||||
|
||||
// CSRF protection for all state-changing API requests below
|
||||
app.use('/api', verifyCsrf);
|
||||
app.use('/api/sabnzbd', sabnzbdRoutes);
|
||||
app.use('/api/sonarr', sonarrRoutes);
|
||||
app.use('/api/radarr', radarrRoutes);
|
||||
app.use('/api/emby', embyRoutes);
|
||||
app.use('/api/dashboard', dashboardRoutes);
|
||||
app.use('/api/history', historyRoutes);
|
||||
|
||||
// Global error handler
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
app.use((err, req, res, next) => {
|
||||
console.error('[Server] Unhandled error:', err.message);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
module.exports = { createApp };
|
||||
@@ -1,16 +1,45 @@
|
||||
// Copyright (c) 2025 Gordon Bolton. MIT License.
|
||||
const express = require('express');
|
||||
const path = require('path');
|
||||
const cookieParser = require('cookie-parser');
|
||||
const helmet = require('helmet');
|
||||
const rateLimit = require('express-rate-limit');
|
||||
const crypto = require('crypto');
|
||||
const fs = require('fs');
|
||||
const http = require('http');
|
||||
const https = require('https');
|
||||
require('dotenv').config();
|
||||
require('./utils/loadSecrets')();
|
||||
const { version } = require('../package.json');
|
||||
|
||||
// Setup logging with levels
|
||||
// Levels: debug (0), info (1), warn (2), error (3), silent (4)
|
||||
const LOG_LEVELS = { debug: 0, info: 1, warn: 2, error: 3, silent: 4 };
|
||||
const currentLevel = LOG_LEVELS[(process.env.LOG_LEVEL || 'info').toLowerCase()] || 1;
|
||||
|
||||
const logFile = fs.createWriteStream(path.join(__dirname, '../server.log'), { flags: 'a' });
|
||||
// Log file lives in DATA_DIR so the non-root container user can write to it
|
||||
const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, '../data');
|
||||
if (!fs.existsSync(DATA_DIR)) fs.mkdirSync(DATA_DIR, { recursive: true });
|
||||
|
||||
const LOG_PATH = path.join(DATA_DIR, 'server.log');
|
||||
const LOG_MAX_BYTES = 10 * 1024 * 1024; // 10 MB per file
|
||||
const LOG_KEEP = 3; // keep 3 rotated files
|
||||
|
||||
function rotateLogIfNeeded() {
|
||||
try {
|
||||
const stat = fs.statSync(LOG_PATH);
|
||||
if (stat.size < LOG_MAX_BYTES) return;
|
||||
for (let i = LOG_KEEP - 1; i >= 1; i--) {
|
||||
const src = `${LOG_PATH}.${i}`;
|
||||
const dst = `${LOG_PATH}.${i + 1}`;
|
||||
if (fs.existsSync(src)) fs.renameSync(src, dst);
|
||||
}
|
||||
fs.renameSync(LOG_PATH, `${LOG_PATH}.1`);
|
||||
} catch { /* ignore rotation errors — don't crash the server */ }
|
||||
}
|
||||
|
||||
rotateLogIfNeeded();
|
||||
const logFile = fs.createWriteStream(LOG_PATH, { flags: 'a' });
|
||||
const originalConsoleLog = console.log;
|
||||
const originalConsoleError = console.error;
|
||||
const originalConsoleWarn = console.warn;
|
||||
@@ -53,44 +82,271 @@ const sonarrRoutes = require('./routes/sonarr');
|
||||
const radarrRoutes = require('./routes/radarr');
|
||||
const embyRoutes = require('./routes/emby');
|
||||
const dashboardRoutes = require('./routes/dashboard');
|
||||
const historyRoutes = require('./routes/history');
|
||||
const authRoutes = require('./routes/auth');
|
||||
const verifyCsrf = require('./middleware/verifyCsrf');
|
||||
const { startPoller, POLL_INTERVAL, POLLING_ENABLED } = require('./utils/poller');
|
||||
const { validateInstanceUrl } = require('./utils/config');
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Startup environment validation
|
||||
// ---------------------------------------------------------------------------
|
||||
const cookieSecret = process.env.COOKIE_SECRET;
|
||||
if (!cookieSecret && process.env.NODE_ENV === 'production') {
|
||||
console.error('[Security] COOKIE_SECRET is not set in production — aborting.');
|
||||
process.exit(1);
|
||||
} else if (!cookieSecret) {
|
||||
console.warn('[Security] COOKIE_SECRET not set — unsigned cookies (dev only)');
|
||||
} else if (cookieSecret.length < 32) {
|
||||
console.warn('[Security] COOKIE_SECRET is shorter than 32 characters — use openssl rand -hex 32');
|
||||
}
|
||||
if (!process.env.EMBY_URL && process.env.NODE_ENV === 'production') {
|
||||
console.error('[Config] EMBY_URL is required');
|
||||
process.exit(1);
|
||||
}
|
||||
if (process.env.EMBY_URL) {
|
||||
validateInstanceUrl(process.env.EMBY_URL, 'EMBY_URL');
|
||||
}
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 3001;
|
||||
|
||||
app.use(helmet({
|
||||
contentSecurityPolicy: false // SPA uses inline scripts; CSP requires a nonce/hash strategy
|
||||
// Resolve TLS_ENABLED early — used in Helmet CSP and server startup
|
||||
const TLS_ENABLED = (process.env.TLS_ENABLED || 'true').toLowerCase() !== 'false';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Trust proxy — required when behind Nginx/Caddy/Traefik so that
|
||||
// req.ip reflects the real client IP (not 127.0.0.1) and
|
||||
// req.secure is true when the upstream TLS is terminated by the proxy.
|
||||
// Set TRUST_PROXY=1 (or a specific IP/CIDR) via env.
|
||||
// ---------------------------------------------------------------------------
|
||||
if (process.env.TRUST_PROXY) {
|
||||
const trustValue = /^\d+$/.test(process.env.TRUST_PROXY)
|
||||
? parseInt(process.env.TRUST_PROXY, 10)
|
||||
: process.env.TRUST_PROXY;
|
||||
app.set('trust proxy', trustValue);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helmet v7 — security response headers
|
||||
// CSP uses a per-request nonce injected into index.html so inline scripts
|
||||
// and styles are allowed only with a valid nonce, not blanket unsafe-inline.
|
||||
// ---------------------------------------------------------------------------
|
||||
app.use((req, res, next) => {
|
||||
// Generate a fresh nonce for every request
|
||||
res.locals.cspNonce = crypto.randomBytes(16).toString('base64');
|
||||
next();
|
||||
});
|
||||
|
||||
app.use((req, res, next) => {
|
||||
helmet({
|
||||
contentSecurityPolicy: {
|
||||
directives: {
|
||||
defaultSrc: ["'self'"],
|
||||
scriptSrc: ["'self'", (req, res) => `'nonce-${res.locals.cspNonce}'`],
|
||||
styleSrc: ["'self'", (req, res) => `'nonce-${res.locals.cspNonce}'`],
|
||||
imgSrc: ["'self'", 'data:', 'blob:'],
|
||||
fontSrc: ["'self'", 'data:'],
|
||||
connectSrc: ["'self'"],
|
||||
objectSrc: ["'none'"],
|
||||
baseUri: ["'self'"],
|
||||
frameAncestors: ["'none'"],
|
||||
formAction: ["'self'"],
|
||||
upgradeInsecureRequests: (process.env.TRUST_PROXY || TLS_ENABLED) ? [] : null
|
||||
}
|
||||
},
|
||||
hsts: {
|
||||
maxAge: 31536000, // 1 year
|
||||
includeSubDomains: true,
|
||||
preload: true
|
||||
},
|
||||
referrerPolicy: { policy: 'strict-origin-when-cross-origin' },
|
||||
crossOriginEmbedderPolicy: false // not needed for this SPA
|
||||
})(req, res, next);
|
||||
});
|
||||
|
||||
// Permissions-Policy — disable powerful browser features not needed by the app
|
||||
app.use((req, res, next) => {
|
||||
res.setHeader(
|
||||
'Permissions-Policy',
|
||||
'camera=(), microphone=(), geolocation=(), payment=(), usb=()'
|
||||
);
|
||||
next();
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// General API rate limiter — applies to all /api/* routes
|
||||
// More specific limiters (e.g. login) apply on top of this.
|
||||
// ---------------------------------------------------------------------------
|
||||
const apiLimiter = rateLimit({
|
||||
windowMs: 15 * 60 * 1000, // 15 minutes
|
||||
max: 300, // 300 requests per IP per window (generous for polling)
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
message: { error: 'Too many requests, please try again later' }
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Body parsing & cookies
|
||||
// ---------------------------------------------------------------------------
|
||||
app.use(cookieParser(cookieSecret || undefined));
|
||||
app.use(express.json({ limit: '64kb' })); // prevent oversized JSON payloads
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Health / readiness endpoints (no auth, no rate-limit)
|
||||
// Used by Docker HEALTHCHECK and orchestrators.
|
||||
// ---------------------------------------------------------------------------
|
||||
app.get('/health', (req, res) => {
|
||||
res.json({ status: 'ok', uptime: process.uptime(), version });
|
||||
});
|
||||
|
||||
app.get('/ready', (req, res) => {
|
||||
// Confirm critical config is present
|
||||
const ready = !!(process.env.EMBY_URL);
|
||||
if (ready) {
|
||||
res.json({ status: 'ready' });
|
||||
} else {
|
||||
res.status(503).json({ status: 'not ready', reason: 'EMBY_URL not configured' });
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Static files — served before API routes
|
||||
// index.html is served manually so we can inject the CSP nonce
|
||||
// ---------------------------------------------------------------------------
|
||||
const PUBLIC_DIR = path.join(__dirname, '../public');
|
||||
const INDEX_HTML = path.join(PUBLIC_DIR, 'index.html');
|
||||
|
||||
// Serve all static assets (js, css, images, icons) except index.html.
|
||||
// JS and CSS get no-cache so browsers revalidate on every load (ETag still
|
||||
// avoids re-downloading unchanged files; only a deploy changes the ETag).
|
||||
app.use(express.static(PUBLIC_DIR, {
|
||||
index: false,
|
||||
setHeaders(res, filePath) {
|
||||
if (filePath.endsWith('.js') || filePath.endsWith('.css')) {
|
||||
res.setHeader('Cache-Control', 'no-cache');
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
const cookieSecret = process.env.COOKIE_SECRET;
|
||||
if (!cookieSecret && process.env.NODE_ENV === 'production') {
|
||||
console.error('[Security] COOKIE_SECRET is not set in production — cookies are unsigned and can be tampered with!');
|
||||
process.exit(1);
|
||||
} else if (!cookieSecret) {
|
||||
console.warn('[Security] COOKIE_SECRET is not set — using unsigned cookies (acceptable for development only)');
|
||||
// Serve index.html with CSP nonce injected into <script> tags
|
||||
function serveIndex(req, res) {
|
||||
fs.readFile(INDEX_HTML, 'utf8', (err, html) => {
|
||||
if (err) return res.status(500).send('Internal Server Error');
|
||||
const nonce = res.locals.cspNonce;
|
||||
// Only inject nonce into <script> tags — style-src 'self' already permits
|
||||
// same-origin <link rel=stylesheet> without a nonce, and injecting a nonce
|
||||
// onto <link> breaks mobile browsers / caching proxies (stale HTML carries
|
||||
// the old nonce which no longer matches the per-request CSP header).
|
||||
const patched = html
|
||||
.replace(/<script([^>]*)>/gi, `<script nonce="${nonce}"$1>`);
|
||||
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
||||
res.send(patched);
|
||||
});
|
||||
}
|
||||
app.use(cookieParser(cookieSecret || undefined));
|
||||
app.use(express.json());
|
||||
app.use(express.static(path.join(__dirname, '../public')));
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// API routes (rate-limited; auth routes exempt CSRF for login/csrf endpoints)
|
||||
// CSRF protection applies to all state-changing /api/* requests except
|
||||
// /api/auth/login (pre-auth) and /api/auth/csrf (issues the token).
|
||||
// ---------------------------------------------------------------------------
|
||||
app.use('/api', apiLimiter);
|
||||
app.use('/api/auth', authRoutes);
|
||||
|
||||
// All routes below this point require CSRF validation on mutating methods
|
||||
app.use('/api', verifyCsrf);
|
||||
app.use('/api/sabnzbd', sabnzbdRoutes);
|
||||
app.use('/api/sonarr', sonarrRoutes);
|
||||
app.use('/api/radarr', radarrRoutes);
|
||||
app.use('/api/emby', embyRoutes);
|
||||
app.use('/api/dashboard', dashboardRoutes);
|
||||
app.use('/api/auth', authRoutes);
|
||||
app.use('/api/history', historyRoutes);
|
||||
|
||||
app.get('/', (req, res) => {
|
||||
res.sendFile(path.join(__dirname, '../public/index.html'));
|
||||
// SPA catch-all — serve index.html for any unmatched path
|
||||
app.get('*', serveIndex);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Global error handler — never leak stack traces to clients
|
||||
// ---------------------------------------------------------------------------
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
app.use((err, req, res, next) => {
|
||||
console.error('[Server] Unhandled error:', err.message);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
});
|
||||
|
||||
app.listen(PORT, () => {
|
||||
// ---------------------------------------------------------------------------
|
||||
// TLS / HTTPS support
|
||||
// Set TLS_CERT and TLS_KEY to paths of your certificate and private key.
|
||||
// If unset, defaults to the bundled snakeoil self-signed certificate
|
||||
// (localhost/127.0.0.1 only — suitable for local testing).
|
||||
// Set TLS_ENABLED=false to force plain HTTP even if cert files exist.
|
||||
// ---------------------------------------------------------------------------
|
||||
const CERTS_DIR = path.join(__dirname, '../certs');
|
||||
const TLS_CERT_PATH = process.env.TLS_CERT || path.join(CERTS_DIR, 'snakeoil.crt');
|
||||
const TLS_KEY_PATH = process.env.TLS_KEY || path.join(CERTS_DIR, 'snakeoil.key');
|
||||
|
||||
function loadTlsCredentials() {
|
||||
if (!TLS_ENABLED) return null;
|
||||
try {
|
||||
return {
|
||||
cert: fs.readFileSync(TLS_CERT_PATH),
|
||||
key: fs.readFileSync(TLS_KEY_PATH)
|
||||
};
|
||||
} catch (err) {
|
||||
console.warn(`[TLS] Could not load certificate files — falling back to HTTP. (${err.message})`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const tlsCredentials = loadTlsCredentials();
|
||||
const server = tlsCredentials
|
||||
? https.createServer(tlsCredentials, app)
|
||||
: http.createServer(app);
|
||||
|
||||
const protocol = tlsCredentials ? 'https' : 'http';
|
||||
const isSnakeoil = TLS_ENABLED &&
|
||||
(!process.env.TLS_CERT || process.env.TLS_CERT === TLS_CERT_PATH);
|
||||
|
||||
server.listen(PORT, () => {
|
||||
console.log(`=================================`);
|
||||
console.log(` sofarr - Your Downloads Dashboard`);
|
||||
console.log(` Server running on port ${PORT}`);
|
||||
console.log(` sofarr v${version} - Your Downloads Dashboard`);
|
||||
console.log(` Server running on ${protocol}://localhost:${PORT}`);
|
||||
if (tlsCredentials && isSnakeoil) {
|
||||
console.warn(` [TLS] Using bundled snakeoil certificate (self-signed).`);
|
||||
console.warn(` [TLS] Set TLS_CERT and TLS_KEY for a trusted certificate.`);
|
||||
console.warn(` [TLS] Set TLS_ENABLED=false to disable TLS entirely.`);
|
||||
} else if (tlsCredentials) {
|
||||
console.log(` [TLS] Certificate: ${TLS_CERT_PATH}`);
|
||||
} else {
|
||||
console.warn(` [TLS] Running in plain HTTP mode — not suitable for production.`);
|
||||
}
|
||||
console.log(` Log level: ${process.env.LOG_LEVEL || 'info'}`);
|
||||
console.log(` Polling: ${POLLING_ENABLED ? POLL_INTERVAL + 'ms' : 'disabled (on-demand)'}`);
|
||||
console.log(` Trust proxy: ${process.env.TRUST_PROXY || 'disabled'}`);
|
||||
console.log(`=================================`);
|
||||
startPoller();
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Graceful shutdown — handle SIGTERM (Docker stop) and SIGINT (Ctrl+C)
|
||||
// Stop the poller, close the HTTP server (stops accepting new connections),
|
||||
// then let Node drain existing keep-alive connections and exit cleanly.
|
||||
// ---------------------------------------------------------------------------
|
||||
const { stopPoller } = require('./utils/poller');
|
||||
|
||||
function shutdown(signal) {
|
||||
console.log(`[Server] ${signal} received — shutting down gracefully`);
|
||||
stopPoller();
|
||||
server.close(() => {
|
||||
console.log('[Server] HTTP server closed');
|
||||
process.exit(0);
|
||||
});
|
||||
// Force exit after 10 s if connections don't drain
|
||||
setTimeout(() => {
|
||||
console.error('[Server] Forced exit after 10 s timeout');
|
||||
process.exit(1);
|
||||
}, 10000).unref();
|
||||
}
|
||||
|
||||
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
||||
process.on('SIGINT', () => shutdown('SIGINT'));
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* CSRF protection using the double-submit cookie pattern.
|
||||
*
|
||||
* On login the server issues a random `csrf_token` cookie (httpOnly:false
|
||||
* so JS can read it). The SPA must send the same value in the
|
||||
* `X-CSRF-Token` request header for every state-changing request (POST,
|
||||
* PUT, PATCH, DELETE).
|
||||
*
|
||||
* Because the `sameSite: strict` session cookie already provides strong
|
||||
* protection in modern browsers, this acts as defence-in-depth for
|
||||
* older browsers and any edge cases.
|
||||
*
|
||||
* Safe methods (GET, HEAD, OPTIONS) are exempted.
|
||||
*/
|
||||
|
||||
const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
|
||||
|
||||
function verifyCsrf(req, res, next) {
|
||||
if (SAFE_METHODS.has(req.method)) return next();
|
||||
|
||||
const cookieToken = req.cookies.csrf_token;
|
||||
const headerToken = req.headers['x-csrf-token'];
|
||||
|
||||
if (!cookieToken || !headerToken) {
|
||||
return res.status(403).json({ error: 'CSRF token missing' });
|
||||
}
|
||||
|
||||
// Constant-time comparison to prevent timing attacks
|
||||
if (cookieToken.length !== headerToken.length) {
|
||||
return res.status(403).json({ error: 'CSRF token invalid' });
|
||||
}
|
||||
|
||||
const a = Buffer.from(cookieToken);
|
||||
const b = Buffer.from(headerToken);
|
||||
if (!require('crypto').timingSafeEqual(a, b)) {
|
||||
return res.status(403).json({ error: 'CSRF token invalid' });
|
||||
}
|
||||
|
||||
next();
|
||||
}
|
||||
|
||||
module.exports = verifyCsrf;
|
||||
@@ -4,30 +4,22 @@ const crypto = require('crypto');
|
||||
const rateLimit = require('express-rate-limit');
|
||||
const router = express.Router();
|
||||
|
||||
const EMBY_URL = process.env.EMBY_URL;
|
||||
|
||||
// Server-side token store: userId -> { accessToken }
|
||||
// Keeps AccessToken off the client; required for logout revocation.
|
||||
const tokenStore = new Map();
|
||||
|
||||
function storeToken(userId, accessToken) {
|
||||
tokenStore.set(userId, { accessToken });
|
||||
}
|
||||
|
||||
function getToken(userId) {
|
||||
return tokenStore.get(userId) || null;
|
||||
}
|
||||
|
||||
function clearToken(userId) {
|
||||
tokenStore.delete(userId);
|
||||
}
|
||||
// Persistent JSON file-backed token store — survives restarts
|
||||
const { storeToken, getToken, clearToken } = require('../utils/tokenStore');
|
||||
|
||||
// Read EMBY_URL at request time (not module load time) so the value
|
||||
// can be overridden by environment variables set after the module loads.
|
||||
const getEmbyUrl = () => process.env.EMBY_URL;
|
||||
|
||||
// Strict login limiter: 10 attempts per 15 min, then locked for the window.
|
||||
// Set SKIP_RATE_LIMIT=1 in the test environment to prevent the limiter from
|
||||
// interfering with integration tests (all requests come from 127.0.0.1).
|
||||
const loginLimiter = rateLimit({
|
||||
windowMs: 15 * 60 * 1000, // 15 minutes
|
||||
max: 10,
|
||||
windowMs: 15 * 60 * 1000,
|
||||
max: process.env.SKIP_RATE_LIMIT ? Number.MAX_SAFE_INTEGER : 10,
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
skipSuccessfulRequests: true, // only count failures toward the limit
|
||||
message: { success: false, error: 'Too many login attempts, please try again later' }
|
||||
});
|
||||
|
||||
@@ -35,15 +27,23 @@ const loginLimiter = rateLimit({
|
||||
router.post('/login', loginLimiter, async (req, res) => {
|
||||
try {
|
||||
const { username, password, rememberMe } = req.body;
|
||||
|
||||
// Input validation — reject obviously invalid inputs before hitting Emby
|
||||
if (typeof username !== 'string' || username.trim().length === 0 || username.length > 128) {
|
||||
return res.status(400).json({ success: false, error: 'Invalid username' });
|
||||
}
|
||||
if (typeof password !== 'string' || password.length === 0 || password.length > 256) {
|
||||
return res.status(400).json({ success: false, error: 'Invalid password' });
|
||||
}
|
||||
|
||||
console.log(`[Auth] Attempting login for user: ${username}`);
|
||||
console.log(`[Auth] Attempting login for user: ${username.trim()}`);
|
||||
|
||||
// Authenticate with Emby using a stable DeviceId derived from the username.
|
||||
// Using a deterministic DeviceId causes Emby to reuse the existing session
|
||||
// for this device rather than creating a new one on each login.
|
||||
const stableDeviceId = 'sofarr-' + crypto.createHash('sha256').update(username.toLowerCase()).digest('hex').slice(0, 16);
|
||||
const authResponse = await axios.post(`${EMBY_URL}/Users/authenticatebyname`, {
|
||||
Username: username,
|
||||
const stableDeviceId = 'sofarr-' + crypto.createHash('sha256').update(username.trim().toLowerCase()).digest('hex').slice(0, 16);
|
||||
const authResponse = await axios.post(`${getEmbyUrl()}/Users/authenticatebyname`, {
|
||||
Username: username.trim(),
|
||||
Pw: password
|
||||
}, {
|
||||
headers: {
|
||||
@@ -54,7 +54,7 @@ router.post('/login', loginLimiter, async (req, res) => {
|
||||
const authData = authResponse.data;
|
||||
|
||||
// Get user info using the access token
|
||||
const userResponse = await axios.get(`${EMBY_URL}/Users/${authData.User.Id || authData.User.id}`, {
|
||||
const userResponse = await axios.get(`${getEmbyUrl()}/Users/${authData.User.Id || authData.User.id}`, {
|
||||
headers: {
|
||||
'X-MediaBrowser-Token': authData.AccessToken
|
||||
}
|
||||
@@ -70,22 +70,38 @@ router.post('/login', loginLimiter, async (req, res) => {
|
||||
// Set authentication cookie (signed when COOKIE_SECRET is set).
|
||||
// rememberMe=true → persistent cookie, expires in 30 days
|
||||
// rememberMe=false → session cookie, expires when browser closes
|
||||
// secure:true only when TRUST_PROXY is set — i.e. a TLS-terminating reverse
|
||||
// proxy is in front. Without it the app may be accessed over plain HTTP and
|
||||
// secure cookies would never be sent back by the browser.
|
||||
const cookiePayload = JSON.stringify({ id: user.Id, name: user.Name, isAdmin });
|
||||
const signed = !!process.env.COOKIE_SECRET;
|
||||
const secureCookie = !!process.env.TRUST_PROXY; // only send over HTTPS when behind a TLS proxy
|
||||
const cookieOptions = {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
secure: secureCookie,
|
||||
sameSite: 'strict',
|
||||
signed
|
||||
signed,
|
||||
path: '/'
|
||||
};
|
||||
if (rememberMe) {
|
||||
cookieOptions.maxAge = 30 * 24 * 60 * 60 * 1000; // 30 days
|
||||
}
|
||||
res.cookie('emby_user', cookiePayload, cookieOptions);
|
||||
|
||||
// Issue a CSRF token tied to this session so state-changing endpoints
|
||||
// can validate the double-submit cookie pattern
|
||||
const csrfToken = crypto.randomBytes(32).toString('hex');
|
||||
res.cookie('csrf_token', csrfToken, {
|
||||
httpOnly: false, // intentionally readable by JS for the double-submit pattern
|
||||
secure: secureCookie,
|
||||
sameSite: 'strict',
|
||||
path: '/'
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
user: { id: user.Id, name: user.Name, isAdmin }
|
||||
user: { id: user.Id, name: user.Name, isAdmin },
|
||||
csrfToken
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`[Auth] Login failed:`, error.message);
|
||||
@@ -122,6 +138,19 @@ router.get('/me', (req, res) => {
|
||||
});
|
||||
});
|
||||
|
||||
// CSRF token refresh — lets the SPA get a new token without re-logging-in
|
||||
// (e.g. after a page reload where the JS variable was lost)
|
||||
router.get('/csrf', (req, res) => {
|
||||
const csrfToken = crypto.randomBytes(32).toString('hex');
|
||||
res.cookie('csrf_token', csrfToken, {
|
||||
httpOnly: false,
|
||||
secure: !!process.env.TRUST_PROXY,
|
||||
sameSite: 'strict',
|
||||
path: '/'
|
||||
});
|
||||
res.json({ csrfToken });
|
||||
});
|
||||
|
||||
// Logout
|
||||
router.post('/logout', async (req, res) => {
|
||||
const user = parseSessionCookie(req);
|
||||
@@ -129,7 +158,7 @@ router.post('/logout', async (req, res) => {
|
||||
const stored = getToken(user.id);
|
||||
if (stored) {
|
||||
try {
|
||||
await axios.post(`${EMBY_URL}/Sessions/Logout`, {}, {
|
||||
await axios.post(`${getEmbyUrl()}/Sessions/Logout`, {}, {
|
||||
headers: { 'X-MediaBrowser-Token': stored.accessToken }
|
||||
});
|
||||
console.log(`[Auth] Revoked Emby token for user: ${user.name}`);
|
||||
@@ -141,9 +170,16 @@ router.post('/logout', async (req, res) => {
|
||||
}
|
||||
res.clearCookie('emby_user', {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
secure: !!process.env.TRUST_PROXY,
|
||||
sameSite: 'strict',
|
||||
signed: !!process.env.COOKIE_SECRET
|
||||
signed: !!process.env.COOKIE_SECRET,
|
||||
path: '/'
|
||||
});
|
||||
res.clearCookie('csrf_token', {
|
||||
httpOnly: false,
|
||||
secure: !!process.env.TRUST_PROXY,
|
||||
sameSite: 'strict',
|
||||
path: '/'
|
||||
});
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
@@ -5,7 +5,7 @@ const requireAuth = require('../middleware/requireAuth');
|
||||
const axios = require('axios');
|
||||
const { mapTorrentToDownload } = require('../utils/qbittorrent');
|
||||
const cache = require('../utils/cache');
|
||||
const { pollAllServices, getLastPollTimings, POLLING_ENABLED } = require('../utils/poller');
|
||||
const { pollAllServices, getLastPollTimings, onPollComplete, offPollComplete, POLLING_ENABLED } = require('../utils/poller');
|
||||
const { getSonarrInstances, getRadarrInstances } = require('../utils/config');
|
||||
const sanitizeError = require('../utils/sanitizeError');
|
||||
|
||||
@@ -94,6 +94,39 @@ function getRadarrLink(movie) {
|
||||
return `${movie._instanceUrl}/movie/${movie.titleSlug}`;
|
||||
}
|
||||
|
||||
// Extract episode info from a Sonarr queue/history record.
|
||||
// Returns { season, episode, title } or null if data is missing.
|
||||
function extractEpisode(record) {
|
||||
const ep = record.episode || {};
|
||||
const s = ep.seasonNumber != null ? ep.seasonNumber : record.seasonNumber;
|
||||
const e = ep.episodeNumber != null ? ep.episodeNumber : record.episodeNumber;
|
||||
if (s == null || e == null) return null;
|
||||
const title = ep.title || null;
|
||||
return { season: s, episode: e, title };
|
||||
}
|
||||
|
||||
// Find all episodes associated with a download by matching all queue/history records
|
||||
// that share the same title string. Returns sorted array of { season, episode, title }.
|
||||
function gatherEpisodes(titleLower, sonarrRecords) {
|
||||
const episodes = [];
|
||||
const seen = new Set();
|
||||
for (const r of sonarrRecords) {
|
||||
const rTitle = (r.title || r.sourceTitle || '').toLowerCase();
|
||||
if (rTitle && (rTitle.includes(titleLower) || titleLower.includes(rTitle))) {
|
||||
const ep = extractEpisode(r);
|
||||
if (ep) {
|
||||
const key = `${ep.season}x${ep.episode}`;
|
||||
if (!seen.has(key)) {
|
||||
seen.add(key);
|
||||
episodes.push(ep);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
episodes.sort((a, b) => a.season - b.season || a.episode - b.episode);
|
||||
return episodes;
|
||||
}
|
||||
|
||||
// Fetch all Emby users and return a Map<lowerName -> displayName> (and sanitized variants).
|
||||
// Result is cached for 60s to avoid hammering Emby on every dashboard poll.
|
||||
async function getEmbyUsers() {
|
||||
@@ -129,15 +162,18 @@ function buildTagBadges(allTags, embyUserMap) {
|
||||
});
|
||||
}
|
||||
|
||||
// Track active dashboard clients: Map<username, { refreshRateMs, lastSeen }>
|
||||
// Track active dashboard clients.
|
||||
// SSE connections: registered on connect, removed on close — always accurate.
|
||||
// Legacy HTTP poll clients: pruned after CLIENT_STALE_MS of inactivity.
|
||||
const activeClients = new Map();
|
||||
const CLIENT_STALE_MS = 30000; // consider client gone after 30s of no requests
|
||||
const CLIENT_STALE_MS = 30000;
|
||||
|
||||
function getActiveClients() {
|
||||
const now = Date.now();
|
||||
// Prune stale clients
|
||||
for (const [key, client] of activeClients.entries()) {
|
||||
if (now - client.lastSeen > CLIENT_STALE_MS) activeClients.delete(key);
|
||||
if (client.type !== 'sse' && now - client.lastSeen > CLIENT_STALE_MS) {
|
||||
activeClients.delete(key);
|
||||
}
|
||||
}
|
||||
return Array.from(activeClients.values());
|
||||
}
|
||||
@@ -274,7 +310,7 @@ router.get('/user-downloads', requireAuth, async (req, res) => {
|
||||
speed: slotState.speed,
|
||||
eta: slot.timeleft,
|
||||
seriesName: series.title,
|
||||
episodeInfo: sonarrMatch,
|
||||
episodes: gatherEpisodes(nzbNameLower, sonarrQueue.data.records),
|
||||
allTags,
|
||||
matchedUserTag: matchedUserTag || null,
|
||||
tagBadges: showAll ? buildTagBadges(allTags, embyUserMap) : undefined
|
||||
@@ -371,7 +407,7 @@ router.get('/user-downloads', requireAuth, async (req, res) => {
|
||||
size: slot.size,
|
||||
completedAt: slot.completed_time,
|
||||
seriesName: series.title,
|
||||
episodeInfo: sonarrMatch,
|
||||
episodes: gatherEpisodes(nzbNameLower, sonarrHistory.data.records),
|
||||
allTags,
|
||||
matchedUserTag: matchedUserTag || null,
|
||||
tagBadges: showAll ? buildTagBadges(allTags, embyUserMap) : undefined
|
||||
@@ -474,7 +510,7 @@ router.get('/user-downloads', requireAuth, async (req, res) => {
|
||||
download.type = 'series';
|
||||
download.coverArt = getCoverArt(series);
|
||||
download.seriesName = series.title;
|
||||
download.episodeInfo = sonarrMatch;
|
||||
download.episodes = gatherEpisodes(torrentNameLower, sonarrQueue.data.records);
|
||||
download.allTags = allTags;
|
||||
download.matchedUserTag = matchedUserTag || null;
|
||||
download.tagBadges = showAll ? buildTagBadges(allTags, embyUserMap) : undefined;
|
||||
@@ -544,7 +580,7 @@ router.get('/user-downloads', requireAuth, async (req, res) => {
|
||||
download.type = 'series';
|
||||
download.coverArt = getCoverArt(series);
|
||||
download.seriesName = series.title;
|
||||
download.episodeInfo = sonarrHistoryMatch;
|
||||
download.episodes = gatherEpisodes(torrentNameLower, sonarrHistory.data.records);
|
||||
download.allTags = allTags;
|
||||
download.matchedUserTag = matchedUserTag || null;
|
||||
download.tagBadges = showAll ? buildTagBadges(allTags, embyUserMap) : undefined;
|
||||
@@ -721,4 +757,306 @@ router.get('/status', requireAuth, (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Cover art proxy — fetches external poster images server-side so the
|
||||
// browser loads them from 'self' and the CSP img-src stays tight.
|
||||
// Requires authentication. Only proxies http/https URLs.
|
||||
router.get('/cover-art', requireAuth, async (req, res) => {
|
||||
const { url } = req.query;
|
||||
if (!url || typeof url !== 'string') {
|
||||
return res.status(400).json({ error: 'Missing url parameter' });
|
||||
}
|
||||
let parsed;
|
||||
try {
|
||||
parsed = new URL(url);
|
||||
} catch {
|
||||
return res.status(400).json({ error: 'Invalid url' });
|
||||
}
|
||||
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
||||
return res.status(400).json({ error: 'Only http/https URLs are supported' });
|
||||
}
|
||||
try {
|
||||
const response = await axios.get(url, {
|
||||
responseType: 'stream',
|
||||
timeout: 8000,
|
||||
maxContentLength: 5 * 1024 * 1024 // 5 MB max
|
||||
});
|
||||
const contentType = response.headers['content-type'] || 'image/jpeg';
|
||||
// Only proxy image content types
|
||||
if (!contentType.startsWith('image/')) {
|
||||
return res.status(400).json({ error: 'Remote URL is not an image' });
|
||||
}
|
||||
res.setHeader('Content-Type', contentType);
|
||||
res.setHeader('Cache-Control', 'public, max-age=86400'); // 24h browser cache
|
||||
res.setHeader('X-Content-Type-Options', 'nosniff');
|
||||
response.data.pipe(res);
|
||||
} catch (err) {
|
||||
res.status(502).json({ error: 'Failed to fetch cover art' });
|
||||
}
|
||||
});
|
||||
|
||||
// SSE stream — pushes download data to the client on every poll cycle.
|
||||
// Uses the browser's built-in EventSource API (no library required).
|
||||
// Auth is enforced by requireAuth (emby_user cookie sent with the upgrade request).
|
||||
// No CSRF token needed — SSE is a GET request (safe method, no state change).
|
||||
router.get('/stream', requireAuth, async (req, res) => {
|
||||
const user = req.user;
|
||||
const username = user.name.toLowerCase();
|
||||
const showAll = !!user.isAdmin && req.query.showAll === 'true';
|
||||
|
||||
// SSE headers — disable buffering at every layer
|
||||
res.setHeader('Content-Type', 'text/event-stream');
|
||||
res.setHeader('Cache-Control', 'no-cache, no-transform');
|
||||
res.setHeader('Connection', 'keep-alive');
|
||||
res.setHeader('X-Accel-Buffering', 'no'); // nginx: disable proxy buffering
|
||||
res.flushHeaders();
|
||||
|
||||
// Register as an active SSE client
|
||||
activeClients.set(username, { user: user.name, type: 'sse', connectedAt: Date.now(), lastSeen: Date.now() });
|
||||
console.log(`[SSE] Client connected: ${user.name}`);
|
||||
|
||||
// Helper: build and send the downloads payload for this user
|
||||
async function sendDownloads() {
|
||||
try {
|
||||
// On-demand: trigger a fresh poll if cache is stale and polling is disabled
|
||||
if (!POLLING_ENABLED && !cache.get('poll:sab-queue')) {
|
||||
await pollAllServices();
|
||||
}
|
||||
|
||||
const sabQueueData = cache.get('poll:sab-queue') || { slots: [] };
|
||||
const sabHistoryData = cache.get('poll:sab-history') || { slots: [] };
|
||||
const sonarrTagsResults = cache.get('poll:sonarr-tags') || [];
|
||||
const sonarrQueueData = cache.get('poll:sonarr-queue') || { records: [] };
|
||||
const sonarrHistoryData = cache.get('poll:sonarr-history') || { records: [] };
|
||||
const radarrQueueData = cache.get('poll:radarr-queue') || { records: [] };
|
||||
const radarrHistoryData = cache.get('poll:radarr-history') || { records: [] };
|
||||
const radarrTagsData = cache.get('poll:radarr-tags') || [];
|
||||
const qbittorrentTorrents = cache.get('poll:qbittorrent') || [];
|
||||
|
||||
const sabnzbdQueue = { data: { queue: sabQueueData } };
|
||||
const sabnzbdHistory = { data: { history: sabHistoryData } };
|
||||
const sonarrQueue = { data: sonarrQueueData };
|
||||
const sonarrHistory = { data: sonarrHistoryData };
|
||||
const radarrQueue = { data: radarrQueueData };
|
||||
const radarrHistory = { data: radarrHistoryData };
|
||||
const radarrTags = { data: radarrTagsData };
|
||||
|
||||
const seriesMap = new Map();
|
||||
for (const r of sonarrQueue.data.records) {
|
||||
if (r.series && r.seriesId) seriesMap.set(r.seriesId, r.series);
|
||||
}
|
||||
for (const r of sonarrHistory.data.records) {
|
||||
if (r.series && r.seriesId && !seriesMap.has(r.seriesId)) seriesMap.set(r.seriesId, r.series);
|
||||
}
|
||||
const moviesMap = new Map();
|
||||
for (const r of radarrQueue.data.records) {
|
||||
if (r.movie && r.movieId) moviesMap.set(r.movieId, r.movie);
|
||||
}
|
||||
for (const r of radarrHistory.data.records) {
|
||||
if (r.movie && r.movieId && !moviesMap.has(r.movieId)) moviesMap.set(r.movieId, r.movie);
|
||||
}
|
||||
|
||||
const sonarrTagMap = new Map(sonarrTagsResults.flatMap(t => t.data || []).map(t => [t.id, t.label]));
|
||||
const radarrTagMap = new Map(radarrTags.data.map(t => [t.id, t.label]));
|
||||
const embyUserMap = showAll ? await getEmbyUsers() : new Map();
|
||||
|
||||
// Inline the matching logic (same as /user-downloads)
|
||||
const userDownloads = [];
|
||||
const isAdmin = !!user.isAdmin;
|
||||
const usernameSanitized = sanitizeTagLabel(user.name);
|
||||
const queueStatus = sabnzbdQueue.data.queue ? sabnzbdQueue.data.queue.status : null;
|
||||
const queueSpeed = sabnzbdQueue.data.queue ? sabnzbdQueue.data.queue.speed : null;
|
||||
const queueKbpersec = sabnzbdQueue.data.queue ? sabnzbdQueue.data.queue.kbpersec : null;
|
||||
|
||||
function getSlotStatusAndSpeed(slot) {
|
||||
if (queueStatus === 'Paused') return { status: 'Paused', speed: '0' };
|
||||
return { status: slot.status || 'Unknown', speed: queueSpeed || queueKbpersec || '0' };
|
||||
}
|
||||
|
||||
// SABnzbd queue
|
||||
if (sabnzbdQueue.data.queue && sabnzbdQueue.data.queue.slots) {
|
||||
for (const slot of sabnzbdQueue.data.queue.slots) {
|
||||
const nzbName = slot.filename || slot.nzbname;
|
||||
if (!nzbName) continue;
|
||||
const slotState = getSlotStatusAndSpeed(slot);
|
||||
const nzbNameLower = nzbName.toLowerCase();
|
||||
|
||||
const sonarrMatch = sonarrQueue.data.records.find(r => {
|
||||
const rTitle = (r.title || r.sourceTitle || '').toLowerCase();
|
||||
return rTitle && (rTitle.includes(nzbNameLower) || nzbNameLower.includes(rTitle));
|
||||
});
|
||||
if (sonarrMatch && sonarrMatch.seriesId) {
|
||||
const series = seriesMap.get(sonarrMatch.seriesId) || sonarrMatch.series;
|
||||
if (series) {
|
||||
const allTags = extractAllTags(series.tags, sonarrTagMap);
|
||||
const matchedUserTag = extractUserTag(series.tags, sonarrTagMap, username);
|
||||
if (showAll ? allTags.length > 0 : !!matchedUserTag) {
|
||||
const dlObj = { type: 'series', title: nzbName, coverArt: getCoverArt(series), status: slotState.status, progress: slot.percentage, mb: slot.mb, mbmissing: slot.mbmissing, size: slot.size, speed: slotState.speed, eta: slot.timeleft, seriesName: series.title, episodes: gatherEpisodes(nzbNameLower, sonarrQueue.data.records), allTags, matchedUserTag: matchedUserTag || null, tagBadges: showAll ? buildTagBadges(allTags, embyUserMap) : undefined };
|
||||
const issues = getImportIssues(sonarrMatch);
|
||||
if (issues) dlObj.importIssues = issues;
|
||||
if (isAdmin) { dlObj.downloadPath = slot.storage || null; dlObj.targetPath = series.path || null; dlObj.arrLink = getSonarrLink(series); }
|
||||
userDownloads.push(dlObj);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const radarrMatch = radarrQueue.data.records.find(r => {
|
||||
const rTitle = (r.title || r.sourceTitle || '').toLowerCase();
|
||||
return rTitle && (rTitle.includes(nzbNameLower) || nzbNameLower.includes(rTitle));
|
||||
});
|
||||
if (radarrMatch && radarrMatch.movieId) {
|
||||
const movie = moviesMap.get(radarrMatch.movieId) || radarrMatch.movie;
|
||||
if (movie) {
|
||||
const allTags = extractAllTags(movie.tags, radarrTagMap);
|
||||
const matchedUserTag = extractUserTag(movie.tags, radarrTagMap, username);
|
||||
if (showAll ? allTags.length > 0 : !!matchedUserTag) {
|
||||
const dlObj = { type: 'movie', title: nzbName, coverArt: getCoverArt(movie), status: slotState.status, progress: slot.percentage, mb: slot.mb, mbmissing: slot.mbmissing, size: slot.size, speed: slotState.speed, eta: slot.timeleft, movieName: movie.title, movieInfo: radarrMatch, allTags, matchedUserTag: matchedUserTag || null, tagBadges: showAll ? buildTagBadges(allTags, embyUserMap) : undefined };
|
||||
const issues = getImportIssues(radarrMatch);
|
||||
if (issues) dlObj.importIssues = issues;
|
||||
if (isAdmin) { dlObj.downloadPath = slot.storage || null; dlObj.targetPath = movie.path || null; dlObj.arrLink = getRadarrLink(movie); }
|
||||
userDownloads.push(dlObj);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// SABnzbd history
|
||||
if (sabnzbdHistory.data.history && sabnzbdHistory.data.history.slots) {
|
||||
for (const slot of sabnzbdHistory.data.history.slots) {
|
||||
const nzbName = slot.name || slot.nzb_name || slot.nzbname;
|
||||
if (!nzbName) continue;
|
||||
const nzbNameLower = nzbName.toLowerCase();
|
||||
|
||||
const sonarrMatch = sonarrHistory.data.records.find(r => {
|
||||
const rTitle = (r.sourceTitle || r.title || '').toLowerCase();
|
||||
return rTitle && (rTitle.includes(nzbNameLower) || nzbNameLower.includes(rTitle));
|
||||
});
|
||||
if (sonarrMatch && sonarrMatch.seriesId) {
|
||||
const series = seriesMap.get(sonarrMatch.seriesId) || sonarrMatch.series;
|
||||
if (series) {
|
||||
const allTags = extractAllTags(series.tags, sonarrTagMap);
|
||||
const matchedUserTag = extractUserTag(series.tags, sonarrTagMap, username);
|
||||
if (showAll ? allTags.length > 0 : !!matchedUserTag) {
|
||||
const dlObj = { type: 'series', title: nzbName, coverArt: getCoverArt(series), status: slot.status, size: slot.size, completedAt: slot.completed_time, seriesName: series.title, episodes: gatherEpisodes(nzbNameLower, sonarrHistory.data.records), allTags, matchedUserTag: matchedUserTag || null, tagBadges: showAll ? buildTagBadges(allTags, embyUserMap) : undefined };
|
||||
if (isAdmin) { dlObj.downloadPath = slot.storage || null; dlObj.targetPath = series.path || null; dlObj.arrLink = getSonarrLink(series); }
|
||||
userDownloads.push(dlObj);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const radarrMatch = radarrHistory.data.records.find(r => {
|
||||
const rTitle = (r.sourceTitle || r.title || '').toLowerCase();
|
||||
return rTitle && (rTitle.includes(nzbNameLower) || nzbNameLower.includes(rTitle));
|
||||
});
|
||||
if (radarrMatch && radarrMatch.movieId) {
|
||||
const movie = moviesMap.get(radarrMatch.movieId) || radarrMatch.movie;
|
||||
if (movie) {
|
||||
const allTags = extractAllTags(movie.tags, radarrTagMap);
|
||||
const matchedUserTag = extractUserTag(movie.tags, radarrTagMap, username);
|
||||
if (showAll ? allTags.length > 0 : !!matchedUserTag) {
|
||||
const dlObj = { type: 'movie', title: nzbName, coverArt: getCoverArt(movie), status: slot.status, size: slot.size, completedAt: slot.completed_time, movieName: movie.title, movieInfo: radarrMatch, allTags, matchedUserTag: matchedUserTag || null, tagBadges: showAll ? buildTagBadges(allTags, embyUserMap) : undefined };
|
||||
if (isAdmin) { dlObj.downloadPath = slot.storage || null; dlObj.targetPath = movie.path || null; dlObj.arrLink = getRadarrLink(movie); }
|
||||
userDownloads.push(dlObj);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// qBittorrent
|
||||
for (const torrent of qbittorrentTorrents) {
|
||||
const torrentName = torrent.name || '';
|
||||
if (!torrentName) continue;
|
||||
const torrentNameLower = torrentName.toLowerCase();
|
||||
|
||||
const sonarrMatch = sonarrQueue.data.records.find(r => { const rTitle = (r.title || r.sourceTitle || '').toLowerCase(); return rTitle && (rTitle.includes(torrentNameLower) || torrentNameLower.includes(rTitle)); });
|
||||
if (sonarrMatch && sonarrMatch.seriesId) {
|
||||
const series = seriesMap.get(sonarrMatch.seriesId) || sonarrMatch.series;
|
||||
if (series) {
|
||||
const allTags = extractAllTags(series.tags, sonarrTagMap);
|
||||
const matchedUserTag = extractUserTag(series.tags, sonarrTagMap, username);
|
||||
if (showAll ? allTags.length > 0 : !!matchedUserTag) {
|
||||
const download = mapTorrentToDownload(torrent);
|
||||
Object.assign(download, { type: 'series', coverArt: getCoverArt(series), seriesName: series.title, episodes: gatherEpisodes(torrentNameLower, sonarrQueue.data.records), allTags, matchedUserTag: matchedUserTag || null, tagBadges: showAll ? buildTagBadges(allTags, embyUserMap) : undefined });
|
||||
const issues = getImportIssues(sonarrMatch); if (issues) download.importIssues = issues;
|
||||
if (isAdmin) { download.downloadPath = download.savePath || null; download.targetPath = series.path || null; download.arrLink = getSonarrLink(series); }
|
||||
userDownloads.push(download); continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const radarrMatch = radarrQueue.data.records.find(r => { const rTitle = (r.title || r.sourceTitle || '').toLowerCase(); return rTitle && (rTitle.includes(torrentNameLower) || torrentNameLower.includes(rTitle)); });
|
||||
if (radarrMatch && radarrMatch.movieId) {
|
||||
const movie = moviesMap.get(radarrMatch.movieId) || radarrMatch.movie;
|
||||
if (movie) {
|
||||
const allTags = extractAllTags(movie.tags, radarrTagMap);
|
||||
const matchedUserTag = extractUserTag(movie.tags, radarrTagMap, username);
|
||||
if (showAll ? allTags.length > 0 : !!matchedUserTag) {
|
||||
const download = mapTorrentToDownload(torrent);
|
||||
Object.assign(download, { type: 'movie', coverArt: getCoverArt(movie), movieName: movie.title, movieInfo: radarrMatch, allTags, matchedUserTag: matchedUserTag || null, tagBadges: showAll ? buildTagBadges(allTags, embyUserMap) : undefined });
|
||||
const issues = getImportIssues(radarrMatch); if (issues) download.importIssues = issues;
|
||||
if (isAdmin) { download.downloadPath = download.savePath || null; download.targetPath = movie.path || null; download.arrLink = getRadarrLink(movie); }
|
||||
userDownloads.push(download); continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const sonarrHistoryMatch = sonarrHistory.data.records.find(r => { const rTitle = (r.sourceTitle || r.title || '').toLowerCase(); return rTitle && (rTitle.includes(torrentNameLower) || torrentNameLower.includes(rTitle)); });
|
||||
if (sonarrHistoryMatch && sonarrHistoryMatch.seriesId) {
|
||||
const series = seriesMap.get(sonarrHistoryMatch.seriesId) || sonarrHistoryMatch.series;
|
||||
if (series) {
|
||||
const allTags = extractAllTags(series.tags, sonarrTagMap);
|
||||
const matchedUserTag = extractUserTag(series.tags, sonarrTagMap, username);
|
||||
if (showAll ? allTags.length > 0 : !!matchedUserTag) {
|
||||
const download = mapTorrentToDownload(torrent);
|
||||
Object.assign(download, { type: 'series', coverArt: getCoverArt(series), seriesName: series.title, episodes: gatherEpisodes(torrentNameLower, sonarrHistory.data.records), allTags, matchedUserTag: matchedUserTag || null, tagBadges: showAll ? buildTagBadges(allTags, embyUserMap) : undefined });
|
||||
if (isAdmin) { download.downloadPath = download.savePath || null; download.targetPath = series.path || null; download.arrLink = getSonarrLink(series); }
|
||||
userDownloads.push(download); continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const radarrHistoryMatch = radarrHistory.data.records.find(r => { const rTitle = (r.sourceTitle || r.title || '').toLowerCase(); return rTitle && (rTitle.includes(torrentNameLower) || torrentNameLower.includes(rTitle)); });
|
||||
if (radarrHistoryMatch && radarrHistoryMatch.movieId) {
|
||||
const movie = moviesMap.get(radarrHistoryMatch.movieId) || radarrHistoryMatch.movie;
|
||||
if (movie) {
|
||||
const allTags = extractAllTags(movie.tags, radarrTagMap);
|
||||
const matchedUserTag = extractUserTag(movie.tags, radarrTagMap, username);
|
||||
if (showAll ? allTags.length > 0 : !!matchedUserTag) {
|
||||
const download = mapTorrentToDownload(torrent);
|
||||
Object.assign(download, { type: 'movie', coverArt: getCoverArt(movie), movieName: movie.title, movieInfo: radarrHistoryMatch, allTags, matchedUserTag: matchedUserTag || null, tagBadges: showAll ? buildTagBadges(allTags, embyUserMap) : undefined });
|
||||
if (isAdmin) { download.downloadPath = download.savePath || null; download.targetPath = movie.path || null; download.arrLink = getRadarrLink(movie); }
|
||||
userDownloads.push(download);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Write SSE event
|
||||
res.write(`data: ${JSON.stringify({ user: user.name, isAdmin, downloads: userDownloads })}\n\n`);
|
||||
} catch (err) {
|
||||
console.error('[SSE] Error building payload:', sanitizeError(err));
|
||||
}
|
||||
}
|
||||
|
||||
// Send initial data immediately
|
||||
await sendDownloads();
|
||||
|
||||
// Subscribe to poll-complete notifications
|
||||
onPollComplete(sendDownloads);
|
||||
|
||||
// 25s heartbeat comment to keep the connection alive through proxies/load-balancers
|
||||
const heartbeat = setInterval(() => {
|
||||
try { res.write(': heartbeat\n\n'); } catch { /* ignore — cleanup below handles it */ }
|
||||
}, 25000);
|
||||
|
||||
// Cleanup on client disconnect
|
||||
req.on('close', () => {
|
||||
clearInterval(heartbeat);
|
||||
offPollComplete(sendDownloads);
|
||||
activeClients.delete(username);
|
||||
console.log(`[SSE] Client disconnected: ${user.name}`);
|
||||
});
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -0,0 +1,306 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const axios = require('axios');
|
||||
const requireAuth = require('../middleware/requireAuth');
|
||||
const cache = require('../utils/cache');
|
||||
const { fetchSonarrHistory, fetchRadarrHistory, classifySonarrEvent, classifyRadarrEvent } = require('../utils/historyFetcher');
|
||||
const { getSonarrInstances, getRadarrInstances } = require('../utils/config');
|
||||
const sanitizeError = require('../utils/sanitizeError');
|
||||
|
||||
// Re-use the same tag/cover-art helpers as dashboard.js by importing them
|
||||
// from a shared location. For now they are inlined here to keep dashboard.js
|
||||
// untouched (zero-conflict v1 merges). If these diverge they can be extracted
|
||||
// into server/utils/dashboardHelpers.js in a later refactor.
|
||||
|
||||
function getCoverArt(item) {
|
||||
if (!item || !item.images) return null;
|
||||
const poster = item.images.find(img => img.coverType === 'poster');
|
||||
if (poster) return poster.remoteUrl || poster.url || null;
|
||||
const fanart = item.images.find(img => img.coverType === 'fanart');
|
||||
return fanart ? (fanart.remoteUrl || fanart.url || null) : null;
|
||||
}
|
||||
|
||||
function sanitizeTagLabel(input) {
|
||||
if (!input) return '';
|
||||
return input.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '');
|
||||
}
|
||||
|
||||
function tagMatchesUser(tag, username) {
|
||||
if (!tag || !username) return false;
|
||||
const tagLower = tag.toLowerCase();
|
||||
if (tagLower === username) return true;
|
||||
if (tagLower === sanitizeTagLabel(username)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function extractAllTags(tags, tagMap) {
|
||||
if (!tags || tags.length === 0) return [];
|
||||
if (tagMap) return tags.map(id => tagMap.get(id)).filter(Boolean);
|
||||
return tags.map(t => t && t.label).filter(Boolean);
|
||||
}
|
||||
|
||||
function extractUserTag(tags, tagMap, username) {
|
||||
const allLabels = extractAllTags(tags, tagMap);
|
||||
if (!allLabels.length) return null;
|
||||
if (username) {
|
||||
const match = allLabels.find(label => tagMatchesUser(label, username));
|
||||
if (match) return match;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function getEmbyUsers() {
|
||||
const cached = cache.get('emby:users');
|
||||
if (cached) return cached;
|
||||
try {
|
||||
const embyUrl = process.env.EMBY_URL;
|
||||
const embyKey = process.env.EMBY_API_KEY;
|
||||
if (!embyUrl || !embyKey) return new Map();
|
||||
const res = await axios.get(`${embyUrl}/Users`, { params: { api_key: embyKey } });
|
||||
const users = res.data || [];
|
||||
const map = new Map();
|
||||
for (const u of users) {
|
||||
if (!u.Name) continue;
|
||||
const lower = u.Name.toLowerCase();
|
||||
map.set(lower, u.Name);
|
||||
map.set(sanitizeTagLabel(lower), u.Name);
|
||||
}
|
||||
cache.set('emby:users', map, 60000);
|
||||
return map;
|
||||
} catch (err) {
|
||||
console.error('[History] Failed to fetch Emby users:', err.message);
|
||||
return new Map();
|
||||
}
|
||||
}
|
||||
|
||||
function buildTagBadges(allTags, embyUserMap) {
|
||||
return allTags.map(label => {
|
||||
const lower = label.toLowerCase();
|
||||
const sanitized = sanitizeTagLabel(label);
|
||||
const matchedUser = embyUserMap.get(lower) || embyUserMap.get(sanitized) || null;
|
||||
return { label, matchedUser };
|
||||
});
|
||||
}
|
||||
|
||||
// Extract episode info from a Sonarr history record.
|
||||
function extractEpisode(record) {
|
||||
const ep = record.episode || {};
|
||||
const s = ep.seasonNumber != null ? ep.seasonNumber : record.seasonNumber;
|
||||
const e = ep.episodeNumber != null ? ep.episodeNumber : record.episodeNumber;
|
||||
if (s == null || e == null) return null;
|
||||
const title = ep.title || null;
|
||||
return { season: s, episode: e, title };
|
||||
}
|
||||
|
||||
// Find all episodes associated with a download by matching all history records
|
||||
// that share the same source title. Returns sorted, deduplicated array.
|
||||
function gatherEpisodes(titleLower, records) {
|
||||
const episodes = [];
|
||||
const seen = new Set();
|
||||
for (const r of records) {
|
||||
const rTitle = (r.sourceTitle || r.title || '').toLowerCase();
|
||||
if (rTitle && (rTitle.includes(titleLower) || titleLower.includes(rTitle))) {
|
||||
const ep = extractEpisode(r);
|
||||
if (ep) {
|
||||
const key = `${ep.season}x${ep.episode}`;
|
||||
if (!seen.has(key)) {
|
||||
seen.add(key);
|
||||
episodes.push(ep);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
episodes.sort((a, b) => a.season - b.season || a.episode - b.episode);
|
||||
return episodes;
|
||||
}
|
||||
|
||||
function getSonarrLink(series) {
|
||||
if (!series || !series._instanceUrl || !series.titleSlug) return null;
|
||||
return `${series._instanceUrl}/series/${series.titleSlug}`;
|
||||
}
|
||||
|
||||
function getRadarrLink(movie) {
|
||||
if (!movie || !movie._instanceUrl || !movie.titleSlug) return null;
|
||||
return `${movie._instanceUrl}/movie/${movie.titleSlug}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/history/recent
|
||||
*
|
||||
* Returns Sonarr/Radarr history records (imported + failed) for the
|
||||
* authenticated user, filtered to the last RECENT_COMPLETED_DAYS days
|
||||
* (default 7, overridable via env or ?days= query param).
|
||||
*
|
||||
* Response shape:
|
||||
* {
|
||||
* user: string,
|
||||
* isAdmin: boolean,
|
||||
* days: number,
|
||||
* history: HistoryItem[]
|
||||
* }
|
||||
*
|
||||
* HistoryItem shape:
|
||||
* {
|
||||
* type: 'series'|'movie',
|
||||
* outcome: 'imported'|'failed',
|
||||
* title: string, // sourceTitle from arr record
|
||||
* seriesName?: string, // series.title (Sonarr)
|
||||
* movieName?: string, // movie.title (Radarr)
|
||||
* coverArt: string|null,
|
||||
* completedAt: string, // ISO date string from arr record
|
||||
* quality: string|null,
|
||||
* instanceName: string, // arr instance name
|
||||
* arrLink: string|null, // link to item in Sonarr/Radarr UI
|
||||
* allTags: string[],
|
||||
* matchedUserTag: string|null,
|
||||
* // admin-only:
|
||||
* arrRecordId?: number,
|
||||
* failureMessage?: string,
|
||||
* }
|
||||
*/
|
||||
router.get('/recent', requireAuth, async (req, res) => {
|
||||
try {
|
||||
const user = req.user;
|
||||
const username = user.name.toLowerCase();
|
||||
const isAdmin = !!user.isAdmin;
|
||||
const showAll = isAdmin && req.query.showAll === 'true';
|
||||
|
||||
const defaultDays = parseInt(process.env.RECENT_COMPLETED_DAYS, 10) || 7;
|
||||
const requestedDays = parseInt(req.query.days, 10);
|
||||
const days = (requestedDays > 0 && requestedDays <= 90) ? requestedDays : defaultDays;
|
||||
|
||||
const since = new Date(Date.now() - days * 24 * 60 * 60 * 1000);
|
||||
|
||||
// Fetch tag maps and history in parallel
|
||||
const sonarrInstances = getSonarrInstances();
|
||||
const radarrInstances = getRadarrInstances();
|
||||
|
||||
const [sonarrHistory, radarrHistory, embyUserMap] = await Promise.all([
|
||||
fetchSonarrHistory(since),
|
||||
fetchRadarrHistory(since),
|
||||
showAll ? getEmbyUsers() : Promise.resolve(new Map())
|
||||
]);
|
||||
|
||||
// Build tag maps from the cached poll data where available,
|
||||
// falling back to what's embedded in history records
|
||||
const sonarrTagsData = cache.get('poll:sonarr-tags') || [];
|
||||
const radarrTagsData = cache.get('poll:radarr-tags') || [];
|
||||
const sonarrTagMap = new Map(sonarrTagsData.flatMap(t => t.data || []).map(t => [t.id, t.label]));
|
||||
const radarrTagMap = new Map(radarrTagsData.map(t => [t.id, t.label]));
|
||||
|
||||
const historyItems = [];
|
||||
|
||||
// --- Sonarr history ---
|
||||
for (const record of sonarrHistory) {
|
||||
try {
|
||||
const outcome = classifySonarrEvent(record.eventType);
|
||||
if (outcome === 'other') continue;
|
||||
|
||||
const series = record.series;
|
||||
if (!series) continue;
|
||||
|
||||
const allTags = extractAllTags(series.tags, sonarrTagMap);
|
||||
const matchedUserTag = extractUserTag(series.tags, sonarrTagMap, username);
|
||||
const hasAnyTag = allTags.length > 0;
|
||||
|
||||
if (!(showAll ? hasAnyTag : !!matchedUserTag)) continue;
|
||||
|
||||
const quality = record.quality && record.quality.quality && record.quality.quality.name
|
||||
? record.quality.quality.name
|
||||
: null;
|
||||
|
||||
const sourceTitle = record.sourceTitle || record.title || series.title;
|
||||
const item = {
|
||||
type: 'series',
|
||||
outcome,
|
||||
title: sourceTitle,
|
||||
seriesName: series.title,
|
||||
episodes: gatherEpisodes(sourceTitle.toLowerCase(), sonarrHistory),
|
||||
coverArt: getCoverArt(series),
|
||||
completedAt: record.date,
|
||||
quality,
|
||||
instanceName: record._instanceName || null,
|
||||
arrLink: getSonarrLink(series),
|
||||
allTags,
|
||||
matchedUserTag: matchedUserTag || null,
|
||||
tagBadges: showAll ? buildTagBadges(allTags, embyUserMap) : undefined
|
||||
};
|
||||
|
||||
if (isAdmin) {
|
||||
item.arrRecordId = record.id;
|
||||
if (outcome === 'failed' && record.data && record.data.message) {
|
||||
item.failureMessage = record.data.message;
|
||||
}
|
||||
}
|
||||
|
||||
historyItems.push(item);
|
||||
} catch (err) {
|
||||
console.error('[History] Error processing Sonarr record:', err.message);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Radarr history ---
|
||||
for (const record of radarrHistory) {
|
||||
try {
|
||||
const outcome = classifyRadarrEvent(record.eventType);
|
||||
if (outcome === 'other') continue;
|
||||
|
||||
const movie = record.movie;
|
||||
if (!movie) continue;
|
||||
|
||||
const allTags = extractAllTags(movie.tags, radarrTagMap);
|
||||
const matchedUserTag = extractUserTag(movie.tags, radarrTagMap, username);
|
||||
const hasAnyTag = allTags.length > 0;
|
||||
|
||||
if (!(showAll ? hasAnyTag : !!matchedUserTag)) continue;
|
||||
|
||||
const quality = record.quality && record.quality.quality && record.quality.quality.name
|
||||
? record.quality.quality.name
|
||||
: null;
|
||||
|
||||
const item = {
|
||||
type: 'movie',
|
||||
outcome,
|
||||
title: record.sourceTitle || record.title || movie.title,
|
||||
movieName: movie.title,
|
||||
coverArt: getCoverArt(movie),
|
||||
completedAt: record.date,
|
||||
quality,
|
||||
instanceName: record._instanceName || null,
|
||||
arrLink: getRadarrLink(movie),
|
||||
allTags,
|
||||
matchedUserTag: matchedUserTag || null,
|
||||
tagBadges: showAll ? buildTagBadges(allTags, embyUserMap) : undefined
|
||||
};
|
||||
|
||||
if (isAdmin) {
|
||||
item.arrRecordId = record.id;
|
||||
if (outcome === 'failed' && record.data && record.data.message) {
|
||||
item.failureMessage = record.data.message;
|
||||
}
|
||||
}
|
||||
|
||||
historyItems.push(item);
|
||||
} catch (err) {
|
||||
console.error('[History] Error processing Radarr record:', err.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Sort newest first
|
||||
historyItems.sort((a, b) => new Date(b.completedAt) - new Date(a.completedAt));
|
||||
|
||||
console.log(`[History] Returning ${historyItems.length} items for user ${user.name} (days=${days}, showAll=${showAll})`);
|
||||
|
||||
res.json({
|
||||
user: user.name,
|
||||
isAdmin,
|
||||
days,
|
||||
history: historyItems
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[History] Error:', err.message);
|
||||
res.status(500).json({ error: 'Failed to fetch history', details: sanitizeError(err) });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -36,13 +36,17 @@ class MemoryCache {
|
||||
let totalSize = 0;
|
||||
|
||||
for (const [key, entry] of this.store.entries()) {
|
||||
const json = JSON.stringify(entry.value);
|
||||
// Maps must be converted before JSON.stringify (which renders them as "{}")
|
||||
const serializable = entry.value instanceof Map ? Object.fromEntries(entry.value) : entry.value;
|
||||
const json = JSON.stringify(serializable);
|
||||
const sizeBytes = Buffer.byteLength(json, 'utf8');
|
||||
totalSize += sizeBytes;
|
||||
const ttlRemaining = Math.max(0, entry.expiresAt - now);
|
||||
const expired = now > entry.expiresAt;
|
||||
let itemCount = null;
|
||||
if (Array.isArray(entry.value)) {
|
||||
if (entry.value instanceof Map) {
|
||||
itemCount = entry.value.size;
|
||||
} else if (Array.isArray(entry.value)) {
|
||||
itemCount = entry.value.length;
|
||||
} else if (entry.value && typeof entry.value === 'object') {
|
||||
if (Array.isArray(entry.value.records)) itemCount = entry.value.records.length;
|
||||
|
||||
@@ -1,5 +1,28 @@
|
||||
// Copyright (c) 2025 Gordon Bolton. MIT License.
|
||||
const { logToFile } = require('./logger');
|
||||
|
||||
// Validate that a configured service URL is well-formed and uses http(s).
|
||||
// Emits a warning (never throws) so a misconfigured instance degrades
|
||||
// gracefully rather than crashing the whole server.
|
||||
function validateInstanceUrl(url, instanceId) {
|
||||
if (!url || typeof url !== 'string') {
|
||||
logToFile(`[Config] WARNING: instance "${instanceId}" has no URL configured`);
|
||||
return false;
|
||||
}
|
||||
let parsed;
|
||||
try {
|
||||
parsed = new URL(url);
|
||||
} catch {
|
||||
logToFile(`[Config] WARNING: instance "${instanceId}" has an invalid URL: "${url}"`);
|
||||
return false;
|
||||
}
|
||||
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
||||
logToFile(`[Config] WARNING: instance "${instanceId}" URL must use http or https, got "${parsed.protocol}"`);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function parseInstances(envVar, legacyUrl, legacyKey, legacyUsername, legacyPassword) {
|
||||
// Try to parse JSON array format first
|
||||
if (envVar) {
|
||||
@@ -9,10 +32,11 @@ function parseInstances(envVar, legacyUrl, legacyKey, legacyUsername, legacyPass
|
||||
const instances = JSON.parse(cleaned);
|
||||
if (Array.isArray(instances) && instances.length > 0) {
|
||||
logToFile(`[Config] Parsed ${instances.length} instances from JSON array`);
|
||||
return instances.map((inst, idx) => ({
|
||||
...inst,
|
||||
id: inst.name || `instance-${idx + 1}`
|
||||
}));
|
||||
return instances.map((inst, idx) => {
|
||||
const id = inst.name || `instance-${idx + 1}`;
|
||||
validateInstanceUrl(inst.url, id);
|
||||
return { ...inst, id };
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
logToFile(`[Config] Failed to parse JSON array: ${err.message}`);
|
||||
@@ -22,6 +46,7 @@ function parseInstances(envVar, legacyUrl, legacyKey, legacyUsername, legacyPass
|
||||
// Fall back to legacy single-instance format
|
||||
if (legacyUrl && legacyKey) {
|
||||
logToFile(`[Config] Using legacy single-instance format`);
|
||||
validateInstanceUrl(legacyUrl, 'default');
|
||||
return [{
|
||||
id: 'default',
|
||||
name: 'Default',
|
||||
@@ -74,5 +99,6 @@ module.exports = {
|
||||
getSonarrInstances,
|
||||
getRadarrInstances,
|
||||
getQbittorrentInstances,
|
||||
parseInstances
|
||||
parseInstances,
|
||||
validateInstanceUrl
|
||||
};
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
const axios = require('axios');
|
||||
const cache = require('./cache');
|
||||
const { getSonarrInstances, getRadarrInstances } = require('./config');
|
||||
|
||||
// Cache TTL for recent-history data: 5 minutes.
|
||||
// History changes slowly compared to active downloads.
|
||||
const HISTORY_CACHE_TTL = 5 * 60 * 1000;
|
||||
|
||||
// Sonarr event types that represent a successful import
|
||||
const SONARR_IMPORTED_EVENTS = new Set(['downloadFolderImported', 'downloadImported']);
|
||||
// Sonarr event types that represent a failed import
|
||||
const SONARR_FAILED_EVENTS = new Set(['downloadFailed', 'importFailed']);
|
||||
// Radarr equivalents
|
||||
const RADARR_IMPORTED_EVENTS = new Set(['downloadFolderImported', 'downloadImported']);
|
||||
const RADARR_FAILED_EVENTS = new Set(['downloadFailed', 'importFailed']);
|
||||
|
||||
/**
|
||||
* Fetch recent history records from all Sonarr instances for the given date window.
|
||||
* Results are cached under 'history:sonarr' for HISTORY_CACHE_TTL.
|
||||
* @param {Date} since - Only include records on or after this date
|
||||
* @returns {Promise<Array>} Flat array of Sonarr history records (with _instanceUrl and _instanceName)
|
||||
*/
|
||||
async function fetchSonarrHistory(since) {
|
||||
const cacheKey = 'history:sonarr';
|
||||
const cached = cache.get(cacheKey);
|
||||
if (cached) return cached;
|
||||
|
||||
const instances = getSonarrInstances();
|
||||
const results = await Promise.all(instances.map(async inst => {
|
||||
try {
|
||||
const response = await axios.get(`${inst.url}/api/v3/history`, {
|
||||
headers: { 'X-Api-Key': inst.apiKey },
|
||||
params: {
|
||||
pageSize: 100,
|
||||
sortKey: 'date',
|
||||
sortDir: 'descending',
|
||||
includeSeries: true,
|
||||
includeEpisode: true,
|
||||
startDate: since.toISOString()
|
||||
}
|
||||
});
|
||||
const records = (response.data && response.data.records) || [];
|
||||
return records.map(r => {
|
||||
if (r.series) r.series._instanceUrl = inst.url;
|
||||
if (r.series) r.series._instanceName = inst.name || inst.id;
|
||||
r._instanceUrl = inst.url;
|
||||
r._instanceName = inst.name || inst.id;
|
||||
return r;
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(`[HistoryFetcher] Sonarr ${inst.id} error:`, err.message);
|
||||
return [];
|
||||
}
|
||||
}));
|
||||
|
||||
const flat = results.flat();
|
||||
cache.set(cacheKey, flat, HISTORY_CACHE_TTL);
|
||||
return flat;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch recent history records from all Radarr instances for the given date window.
|
||||
* Results are cached under 'history:radarr' for HISTORY_CACHE_TTL.
|
||||
* @param {Date} since - Only include records on or after this date
|
||||
* @returns {Promise<Array>} Flat array of Radarr history records (with _instanceUrl and _instanceName)
|
||||
*/
|
||||
async function fetchRadarrHistory(since) {
|
||||
const cacheKey = 'history:radarr';
|
||||
const cached = cache.get(cacheKey);
|
||||
if (cached) return cached;
|
||||
|
||||
const instances = getRadarrInstances();
|
||||
const results = await Promise.all(instances.map(async inst => {
|
||||
try {
|
||||
const response = await axios.get(`${inst.url}/api/v3/history`, {
|
||||
headers: { 'X-Api-Key': inst.apiKey },
|
||||
params: {
|
||||
pageSize: 100,
|
||||
sortKey: 'date',
|
||||
sortDir: 'descending',
|
||||
includeMovie: true,
|
||||
startDate: since.toISOString()
|
||||
}
|
||||
});
|
||||
const records = (response.data && response.data.records) || [];
|
||||
return records.map(r => {
|
||||
if (r.movie) r.movie._instanceUrl = inst.url;
|
||||
if (r.movie) r.movie._instanceName = inst.name || inst.id;
|
||||
r._instanceUrl = inst.url;
|
||||
r._instanceName = inst.name || inst.id;
|
||||
return r;
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(`[HistoryFetcher] Radarr ${inst.id} error:`, err.message);
|
||||
return [];
|
||||
}
|
||||
}));
|
||||
|
||||
const flat = results.flat();
|
||||
cache.set(cacheKey, flat, HISTORY_CACHE_TTL);
|
||||
return flat;
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify a Sonarr history record's event type.
|
||||
* @param {string} eventType
|
||||
* @returns {'imported'|'failed'|'other'}
|
||||
*/
|
||||
function classifySonarrEvent(eventType) {
|
||||
if (SONARR_IMPORTED_EVENTS.has(eventType)) return 'imported';
|
||||
if (SONARR_FAILED_EVENTS.has(eventType)) return 'failed';
|
||||
return 'other';
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify a Radarr history record's event type.
|
||||
* @param {string} eventType
|
||||
* @returns {'imported'|'failed'|'other'}
|
||||
*/
|
||||
function classifyRadarrEvent(eventType) {
|
||||
if (RADARR_IMPORTED_EVENTS.has(eventType)) return 'imported';
|
||||
if (RADARR_FAILED_EVENTS.has(eventType)) return 'failed';
|
||||
return 'other';
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate cached history so the next request fetches fresh data.
|
||||
* Called externally if needed (e.g. after a forced refresh).
|
||||
*/
|
||||
function invalidateHistoryCache() {
|
||||
cache.invalidate('history:sonarr');
|
||||
cache.invalidate('history:radarr');
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
fetchSonarrHistory,
|
||||
fetchRadarrHistory,
|
||||
classifySonarrEvent,
|
||||
classifyRadarrEvent,
|
||||
invalidateHistoryCache,
|
||||
HISTORY_CACHE_TTL
|
||||
};
|
||||
@@ -0,0 +1,52 @@
|
||||
// Copyright (c) 2025 Gordon Bolton. MIT License.
|
||||
//
|
||||
// Docker secrets support: if an environment variable named FOO_FILE is set,
|
||||
// read its contents from the file at that path and expose it as FOO.
|
||||
// This follows the standard *_FILE convention used by official Docker images.
|
||||
//
|
||||
// Supported secrets:
|
||||
// COOKIE_SECRET_FILE → COOKIE_SECRET
|
||||
// EMBY_API_KEY_FILE → EMBY_API_KEY
|
||||
// SABNZBD_API_KEY_FILE → SABNZBD_API_KEY (legacy single-instance)
|
||||
// SONARR_API_KEY_FILE → SONARR_API_KEY (legacy single-instance)
|
||||
// RADARR_API_KEY_FILE → RADARR_API_KEY (legacy single-instance)
|
||||
// QBITTORRENT_PASSWORD_FILE → QBITTORRENT_PASSWORD (legacy single-instance)
|
||||
//
|
||||
// For multi-instance JSON arrays the secret values must be embedded in the
|
||||
// JSON string itself; file-based loading is for the legacy single-key format.
|
||||
|
||||
const fs = require('fs');
|
||||
|
||||
const SECRET_MAPPINGS = [
|
||||
'COOKIE_SECRET',
|
||||
'EMBY_API_KEY',
|
||||
'SABNZBD_API_KEY',
|
||||
'SONARR_API_KEY',
|
||||
'RADARR_API_KEY',
|
||||
'QBITTORRENT_PASSWORD',
|
||||
];
|
||||
|
||||
function loadSecrets() {
|
||||
for (const key of SECRET_MAPPINGS) {
|
||||
const fileEnv = `${key}_FILE`;
|
||||
const filePath = process.env[fileEnv];
|
||||
if (!filePath) continue;
|
||||
if (process.env[key]) {
|
||||
console.warn(`[Secrets] Both ${key} and ${fileEnv} are set — ${fileEnv} takes precedence`);
|
||||
}
|
||||
try {
|
||||
const value = fs.readFileSync(filePath, 'utf8').trim();
|
||||
if (!value) {
|
||||
console.warn(`[Secrets] ${fileEnv} points to an empty file: ${filePath}`);
|
||||
continue;
|
||||
}
|
||||
process.env[key] = value;
|
||||
console.log(`[Secrets] Loaded ${key} from ${fileEnv}`);
|
||||
} catch (err) {
|
||||
console.error(`[Secrets] Failed to read ${fileEnv} (${filePath}): ${err.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = loadSecrets;
|
||||
@@ -1,7 +1,12 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const logFile = fs.createWriteStream(path.join(__dirname, '../../server.log'), { flags: 'a' });
|
||||
// Use DATA_DIR so the non-root container user (UID 1000) can write logs.
|
||||
// Falls back to ../../data/server.log (same directory index.js uses).
|
||||
const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, '../../data');
|
||||
if (!fs.existsSync(DATA_DIR)) fs.mkdirSync(DATA_DIR, { recursive: true });
|
||||
|
||||
const logFile = fs.createWriteStream(path.join(DATA_DIR, 'server.log'), { flags: 'a' });
|
||||
|
||||
function logToFile(message) {
|
||||
logFile.write(`[${new Date().toISOString()}] ${message}\n`);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// Copyright (c) 2025 Gordon Bolton. MIT License.
|
||||
const axios = require('axios');
|
||||
const cache = require('./cache');
|
||||
const { getTorrents } = require('./qbittorrent');
|
||||
@@ -16,6 +17,12 @@ const POLLING_ENABLED = POLL_INTERVAL > 0;
|
||||
let polling = false;
|
||||
let lastPollTimings = null;
|
||||
|
||||
// SSE subscribers: Set of () => void callbacks, each registered by an open /stream connection
|
||||
const pollSubscribers = new Set();
|
||||
|
||||
function onPollComplete(cb) { pollSubscribers.add(cb); }
|
||||
function offPollComplete(cb) { pollSubscribers.delete(cb); }
|
||||
|
||||
// Timed fetch helper: runs a fetch and records how long it took
|
||||
async function timed(label, fn) {
|
||||
const t0 = Date.now();
|
||||
@@ -65,7 +72,7 @@ async function pollAllServices() {
|
||||
timed('Sonarr Queue', () => Promise.all(sonarrInstances.map(inst =>
|
||||
axios.get(`${inst.url}/api/v3/queue`, {
|
||||
headers: { 'X-Api-Key': inst.apiKey },
|
||||
params: { includeSeries: true }
|
||||
params: { includeSeries: true, includeEpisode: true }
|
||||
}).then(res => ({ instance: inst.id, data: res.data })).catch(err => {
|
||||
console.error(`[Poller] Sonarr ${inst.id} queue error:`, err.message);
|
||||
return { instance: inst.id, data: { records: [] } };
|
||||
@@ -74,7 +81,7 @@ async function pollAllServices() {
|
||||
timed('Sonarr History', () => Promise.all(sonarrInstances.map(inst =>
|
||||
axios.get(`${inst.url}/api/v3/history`, {
|
||||
headers: { 'X-Api-Key': inst.apiKey },
|
||||
params: { pageSize: 10 }
|
||||
params: { pageSize: 10, includeEpisode: true }
|
||||
}).then(res => ({ instance: inst.id, data: res.data })).catch(err => {
|
||||
console.error(`[Poller] Sonarr ${inst.id} history error:`, err.message);
|
||||
return { instance: inst.id, data: { records: [] } };
|
||||
@@ -184,6 +191,11 @@ async function pollAllServices() {
|
||||
|
||||
const elapsed = Date.now() - start;
|
||||
console.log(`[Poller] Poll complete in ${elapsed}ms`);
|
||||
|
||||
// Notify all SSE stream connections so they push fresh data immediately
|
||||
for (const cb of pollSubscribers) {
|
||||
try { cb(); } catch { /* subscriber already disconnected */ }
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`[Poller] Poll error:`, err.message);
|
||||
} finally {
|
||||
@@ -216,4 +228,4 @@ function getLastPollTimings() {
|
||||
return lastPollTimings;
|
||||
}
|
||||
|
||||
module.exports = { startPoller, stopPoller, pollAllServices, getLastPollTimings, POLL_INTERVAL, POLLING_ENABLED };
|
||||
module.exports = { startPoller, stopPoller, pollAllServices, getLastPollTimings, onPollComplete, offPollComplete, POLL_INTERVAL, POLLING_ENABLED };
|
||||
|
||||
@@ -1,14 +1,26 @@
|
||||
const API_KEY_PATTERN = /([?&]apikey=)[^&\s]*/gi;
|
||||
const TOKEN_PATTERN = /([?&]token=)[^&\s]*/gi;
|
||||
const HEADER_PATTERN = /x-(?:api-key|mediabrowser-token|emby-authorization):[^\s,]*/gi;
|
||||
// Copyright (c) 2025 Gordon Bolton. MIT License.
|
||||
// Query-param secrets (SABnzbd apikey, generic token/password params)
|
||||
const QUERY_SECRET_PATTERN = /([?&](?:apikey|token|password|api_key|key|secret)=)[^&\s#]*/gi;
|
||||
// HTTP auth header values (X-Api-Key, X-MediaBrowser-Token, Authorization, X-Emby-Authorization)
|
||||
// Redact everything after the colon to end-of-line (MediaBrowser headers span the full line)
|
||||
const HEADER_PATTERN = /(?:x-api-key|x-mediabrowser-token|x-emby-authorization|authorization)\s*:[^\n]*/gi;
|
||||
// Bearer tokens
|
||||
const BEARER_PATTERN = /bearer\s+[A-Za-z0-9\-._~+/]+=*/gi;
|
||||
// Basic auth credentials in URLs (http://user:pass@host)
|
||||
const BASIC_AUTH_URL_PATTERN = /\/\/[^:@/\s]+:[^@/\s]+@/gi;
|
||||
// Redact only the host:port authority portion of URLs, preserving path/query so
|
||||
// other patterns (QUERY_SECRET_PATTERN etc.) can still act on them.
|
||||
// Negative lookahead skips URLs already handled by BASIC_AUTH_URL_PATTERN.
|
||||
const HOST_PATTERN = /(https?:\/\/)(?!\[REDACTED\]@)([^\s/?#]+)/gi;
|
||||
|
||||
function sanitizeError(err) {
|
||||
let msg = err.message || String(err);
|
||||
// Redact API keys in URLs (SABnzbd passes apikey as query param)
|
||||
msg = msg.replace(API_KEY_PATTERN, '$1[REDACTED]');
|
||||
msg = msg.replace(TOKEN_PATTERN, '$1[REDACTED]');
|
||||
// Redact auth header values if they appear in the message
|
||||
msg = msg.replace(HEADER_PATTERN, (m) => m.split(':')[0] + ':[REDACTED]');
|
||||
let msg = (err && err.message) ? err.message : String(err);
|
||||
msg = msg.replace(QUERY_SECRET_PATTERN, '$1[REDACTED]');
|
||||
msg = msg.replace(HEADER_PATTERN, (m) => m.split(/[\s:]/)[0] + ':[REDACTED]');
|
||||
msg = msg.replace(BEARER_PATTERN, 'bearer [REDACTED]');
|
||||
msg = msg.replace(BASIC_AUTH_URL_PATTERN, '//[REDACTED]@'); // must run before HOST_PATTERN
|
||||
msg = msg.replace(HOST_PATTERN, '$1[HOST]');
|
||||
// Never leak stack traces to API responses
|
||||
return msg;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* Persistent token store backed by a JSON file.
|
||||
*
|
||||
* Pure JavaScript — no native addons, no build tools required.
|
||||
* Survives process restarts so users are not logged out on redeploy.
|
||||
*
|
||||
* Tokens are stored in DATA_DIR/tokens.json (default: ./data locally,
|
||||
* /app/data in the container). Writes are atomic: data is written to a
|
||||
* temp file then renamed so a crash mid-write never corrupts the store.
|
||||
*
|
||||
* Format: { "<userId>": { accessToken: "...", createdAt: <unix ms> } }
|
||||
*
|
||||
* Expired entries (older than TOKEN_TTL_DAYS) are pruned on startup
|
||||
* and once per hour.
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
|
||||
const TOKEN_TTL_DAYS = 31; // slightly longer than max cookie lifetime (30d)
|
||||
const TOKEN_TTL_MS = TOKEN_TTL_DAYS * 24 * 60 * 60 * 1000;
|
||||
|
||||
const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, '../../data');
|
||||
if (!fs.existsSync(DATA_DIR)) {
|
||||
fs.mkdirSync(DATA_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
const STORE_PATH = path.join(DATA_DIR, 'tokens.json');
|
||||
const STORE_TMP = STORE_PATH + '.tmp';
|
||||
|
||||
// Load store from disk, return empty object on any error
|
||||
function load() {
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(STORE_PATH, 'utf8'));
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
// Atomic write: write to .tmp then rename to avoid partial-write corruption
|
||||
function save(data) {
|
||||
try {
|
||||
fs.writeFileSync(STORE_TMP, JSON.stringify(data), 'utf8');
|
||||
fs.renameSync(STORE_TMP, STORE_PATH);
|
||||
} catch (err) {
|
||||
console.error('[TokenStore] Failed to persist token store:', err.message);
|
||||
}
|
||||
}
|
||||
|
||||
function prune(data) {
|
||||
const cutoff = Date.now() - TOKEN_TTL_MS;
|
||||
let pruned = 0;
|
||||
for (const userId of Object.keys(data)) {
|
||||
if (data[userId].createdAt < cutoff) {
|
||||
delete data[userId];
|
||||
pruned++;
|
||||
}
|
||||
}
|
||||
if (pruned > 0) {
|
||||
console.log(`[TokenStore] Pruned ${pruned} expired token(s)`);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
// Prune on startup
|
||||
let store = prune(load());
|
||||
save(store);
|
||||
|
||||
// Prune once per hour (unref so it doesn't keep the process alive)
|
||||
setInterval(() => {
|
||||
store = prune(load());
|
||||
save(store);
|
||||
}, 60 * 60 * 1000).unref();
|
||||
|
||||
module.exports = {
|
||||
storeToken(userId, accessToken) {
|
||||
store[userId] = { accessToken, createdAt: Date.now() };
|
||||
save(store);
|
||||
},
|
||||
getToken(userId) {
|
||||
const entry = store[userId];
|
||||
if (!entry) return null;
|
||||
// Also honour TTL on read in case pruning hasn't run yet
|
||||
if (Date.now() - entry.createdAt > TOKEN_TTL_MS) {
|
||||
delete store[userId];
|
||||
save(store);
|
||||
return null;
|
||||
}
|
||||
return { accessToken: entry.accessToken };
|
||||
},
|
||||
clearToken(userId) {
|
||||
if (store[userId]) {
|
||||
delete store[userId];
|
||||
save(store);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,67 @@
|
||||
# Testing
|
||||
|
||||
## Stack
|
||||
|
||||
| Layer | Tool |
|
||||
|---|---|
|
||||
| Test runner | [Vitest](https://vitest.dev/) v4 |
|
||||
| HTTP integration | [supertest](https://github.com/ladjs/supertest) |
|
||||
| HTTP interception | [nock](https://github.com/nock/nock) (intercepts at Node http layer — works with CJS `require('axios')`) |
|
||||
| Coverage | V8 (built-in, no Babel needed) |
|
||||
|
||||
## Running tests
|
||||
|
||||
```bash
|
||||
# Run all tests once
|
||||
npm test
|
||||
|
||||
# Watch mode (re-runs on file change)
|
||||
npm run test:watch
|
||||
|
||||
# With coverage report
|
||||
npm run test:coverage
|
||||
|
||||
# Interactive UI
|
||||
npm run test:ui
|
||||
```
|
||||
|
||||
Coverage output lands in `coverage/` (gitignored). Open `coverage/index.html` for the HTML report.
|
||||
|
||||
## Structure
|
||||
|
||||
```
|
||||
tests/
|
||||
├── setup.js # Global setup: isolated DATA_DIR, SKIP_RATE_LIMIT, console suppression
|
||||
├── unit/
|
||||
│ ├── sanitizeError.test.js # Secret redaction patterns (API keys, tokens, passwords)
|
||||
│ ├── config.test.js # JSON array + legacy single-instance config parsing
|
||||
│ ├── requireAuth.test.js # Auth middleware: valid/invalid/tampered cookies
|
||||
│ ├── verifyCsrf.test.js # CSRF double-submit cookie pattern + timing-safe compare
|
||||
│ ├── qbittorrent.test.js # Pure utils: formatBytes, formatEta, mapTorrentToDownload
|
||||
│ └── tokenStore.test.js # JSON file token store: store/get/clear, TTL expiry
|
||||
└── integration/
|
||||
├── health.test.js # GET /health and /ready endpoints
|
||||
└── auth.test.js # Full login/logout/me/csrf flows via supertest + nock
|
||||
```
|
||||
|
||||
## Key design decisions
|
||||
|
||||
- **`server/app.js`** — Express factory extracted from `server/index.js`. Tests import `createApp()` without triggering the log-file setup, `process.exit()` calls, or background poller in the entry point.
|
||||
- **nock over `vi.mock('axios')`** — Vitest's `vi.mock` only intercepts ESM `import` statements. Since `auth.js` uses CJS `require('axios')`, nock (which patches Node's `http`/`https` modules) is the correct tool for intercepting outbound requests.
|
||||
- **`SKIP_RATE_LIMIT=1`** — All supertest requests originate from `127.0.0.1`, which would quickly exhaust per-IP rate-limit windows. Setting this env var raises the limits to `Number.MAX_SAFE_INTEGER` in both the API limiter and the login limiter.
|
||||
- **Isolated `DATA_DIR`** — Each test worker gets a unique temp directory so `tokenStore.js` file I/O never conflicts with a running dev server.
|
||||
- **`createApp({ skipRateLimits: true })`** — The app factory accepts an option to disable the general API rate limiter in addition to the env var for the login-specific limiter.
|
||||
|
||||
## Coverage targets
|
||||
|
||||
The tested files meet these per-file minimums (enforced in CI):
|
||||
|
||||
| File | Lines | Branches |
|
||||
|---|---|---|
|
||||
| `server/app.js` | 85% | 65% |
|
||||
| `server/routes/auth.js` | 85% | 70% |
|
||||
| `server/middleware/requireAuth.js` | 75% | 80% |
|
||||
| `server/utils/sanitizeError.js` | 60% | — |
|
||||
| `server/utils/config.js` | 50% | 55% |
|
||||
|
||||
`dashboard.js` and `poller.js` are large files requiring complex external-service mocks (Sonarr, Radarr, qBittorrent, Emby) and are tracked as future test coverage work.
|
||||
@@ -0,0 +1,299 @@
|
||||
/**
|
||||
* Integration tests for authentication routes.
|
||||
*
|
||||
* Uses supertest against the createApp() factory (no real server).
|
||||
* HTTP calls to Emby are intercepted at the Node http/https layer using nock,
|
||||
* which works correctly with CJS require('axios') unlike vi.mock which only
|
||||
* intercepts ESM imports.
|
||||
*
|
||||
* Covers:
|
||||
* - Input validation on /login (empty fields, overlong values)
|
||||
* - Successful login flow (cookies set, CSRF token returned)
|
||||
* - Failed login (wrong credentials → 401, no cookie set)
|
||||
* - /me endpoint (authenticated vs unauthenticated)
|
||||
* - /csrf token issuance
|
||||
* - /logout (cookies cleared)
|
||||
*/
|
||||
|
||||
import request from 'supertest';
|
||||
import nock from 'nock';
|
||||
import { createApp } from '../../server/app.js';
|
||||
|
||||
const EMBY_BASE = 'https://emby.test';
|
||||
|
||||
// Emby response fixtures
|
||||
const EMBY_AUTH_BODY = {
|
||||
AccessToken: 'test-emby-token-abc123',
|
||||
User: { Id: 'user-id-001', Name: 'TestUser' }
|
||||
};
|
||||
|
||||
const EMBY_USER_BODY = {
|
||||
Id: 'user-id-001',
|
||||
Name: 'TestUser',
|
||||
Policy: { IsAdministrator: false }
|
||||
};
|
||||
|
||||
const EMBY_ADMIN_BODY = {
|
||||
Id: 'admin-id-001',
|
||||
Name: 'AdminUser',
|
||||
Policy: { IsAdministrator: true }
|
||||
};
|
||||
|
||||
// Helper: intercept a successful Emby login + user-info sequence
|
||||
function interceptSuccessfulLogin(userBody = EMBY_USER_BODY) {
|
||||
nock(EMBY_BASE)
|
||||
.post('/Users/authenticatebyname')
|
||||
.reply(200, EMBY_AUTH_BODY);
|
||||
nock(EMBY_BASE)
|
||||
.get(/\/Users\//)
|
||||
.reply(200, userBody);
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
nock.cleanAll(); // remove any pending interceptors between tests
|
||||
});
|
||||
|
||||
describe('POST /api/auth/login', () => {
|
||||
// Each sub-describe gets a fresh app to avoid rate-limit state leaking
|
||||
// between the 'input validation' calls (which all fail and count toward
|
||||
// the 10-failure window) and the 'successful login' calls.
|
||||
let app;
|
||||
|
||||
beforeEach(() => {
|
||||
process.env.EMBY_URL = 'https://emby.test';
|
||||
delete process.env.COOKIE_SECRET;
|
||||
// skipRateLimits avoids 429s from the login limiter when all
|
||||
// requests come from 127.0.0.1 in the test environment
|
||||
app = createApp({ skipRateLimits: true });
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env.EMBY_URL;
|
||||
delete process.env.COOKIE_SECRET;
|
||||
});
|
||||
|
||||
describe('input validation', () => {
|
||||
it('rejects empty username', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/auth/login')
|
||||
.send({ username: '', password: 'pass' });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects missing password', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/auth/login')
|
||||
.send({ username: 'alice', password: '' });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects username over 128 chars', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/auth/login')
|
||||
.send({ username: 'a'.repeat(129), password: 'pass' });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('rejects password over 256 chars', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/auth/login')
|
||||
.send({ username: 'alice', password: 'p'.repeat(257) });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('rejects non-string username', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/auth/login')
|
||||
.send({ username: 123, password: 'pass' });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe('successful login', () => {
|
||||
it('returns success:true with user info', async () => {
|
||||
interceptSuccessfulLogin();
|
||||
const res = await request(app)
|
||||
.post('/api/auth/login')
|
||||
.send({ username: 'TestUser', password: 'correct' });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
expect(res.body.user.name).toBe('TestUser');
|
||||
expect(res.body.user.isAdmin).toBe(false);
|
||||
});
|
||||
|
||||
it('sets emby_user cookie', async () => {
|
||||
interceptSuccessfulLogin();
|
||||
const res = await request(app)
|
||||
.post('/api/auth/login')
|
||||
.send({ username: 'TestUser', password: 'correct' });
|
||||
const cookies = res.headers['set-cookie'] || [];
|
||||
expect(cookies.some(c => c.startsWith('emby_user='))).toBe(true);
|
||||
});
|
||||
|
||||
it('sets csrf_token cookie', async () => {
|
||||
interceptSuccessfulLogin();
|
||||
const res = await request(app)
|
||||
.post('/api/auth/login')
|
||||
.send({ username: 'TestUser', password: 'correct' });
|
||||
const cookies = res.headers['set-cookie'] || [];
|
||||
expect(cookies.some(c => c.startsWith('csrf_token='))).toBe(true);
|
||||
});
|
||||
|
||||
it('returns csrfToken in response body', async () => {
|
||||
interceptSuccessfulLogin();
|
||||
const res = await request(app)
|
||||
.post('/api/auth/login')
|
||||
.send({ username: 'TestUser', password: 'correct' });
|
||||
expect(typeof res.body.csrfToken).toBe('string');
|
||||
expect(res.body.csrfToken.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('session cookie has no maxAge when rememberMe is false', async () => {
|
||||
interceptSuccessfulLogin();
|
||||
const res = await request(app)
|
||||
.post('/api/auth/login')
|
||||
.send({ username: 'TestUser', password: 'correct', rememberMe: false });
|
||||
const cookies = res.headers['set-cookie'] || [];
|
||||
const sessionCookie = cookies.find(c => c.startsWith('emby_user='));
|
||||
// Session cookie must not persist across browser close
|
||||
expect(sessionCookie).toBeDefined();
|
||||
expect(sessionCookie).not.toContain('Max-Age');
|
||||
});
|
||||
|
||||
it('sets 30-day maxAge when rememberMe is true', async () => {
|
||||
interceptSuccessfulLogin();
|
||||
const res = await request(app)
|
||||
.post('/api/auth/login')
|
||||
.send({ username: 'TestUser', password: 'correct', rememberMe: true });
|
||||
const cookies = res.headers['set-cookie'] || [];
|
||||
const sessionCookie = cookies.find(c => c.startsWith('emby_user='));
|
||||
expect(sessionCookie).toBeDefined();
|
||||
expect(sessionCookie).toContain('Max-Age');
|
||||
});
|
||||
|
||||
it('marks isAdmin correctly for admin user', async () => {
|
||||
interceptSuccessfulLogin(EMBY_ADMIN_BODY);
|
||||
const res = await request(app)
|
||||
.post('/api/auth/login')
|
||||
.send({ username: 'AdminUser', password: 'correct' });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.user.isAdmin).toBe(true);
|
||||
});
|
||||
|
||||
it('does not include AccessToken in response body', async () => {
|
||||
interceptSuccessfulLogin();
|
||||
const res = await request(app)
|
||||
.post('/api/auth/login')
|
||||
.send({ username: 'TestUser', password: 'correct' });
|
||||
// The Emby access token must never be sent to the client
|
||||
expect(JSON.stringify(res.body)).not.toContain('test-emby-token-abc123');
|
||||
});
|
||||
});
|
||||
|
||||
describe('failed login', () => {
|
||||
it('returns 401 when Emby rejects credentials', async () => {
|
||||
nock(EMBY_BASE).post('/Users/authenticatebyname').reply(401, { error: 'Unauthorized' });
|
||||
const res = await request(app)
|
||||
.post('/api/auth/login')
|
||||
.send({ username: 'baduser', password: 'wrongpass' });
|
||||
expect(res.status).toBe(401);
|
||||
expect(res.body.success).toBe(false);
|
||||
// Must not expose internal error details
|
||||
expect(res.body.error).toBe('Invalid username or password');
|
||||
});
|
||||
|
||||
it('does not set emby_user cookie on failure', async () => {
|
||||
nock(EMBY_BASE).post('/Users/authenticatebyname').reply(401, {});
|
||||
const res = await request(app)
|
||||
.post('/api/auth/login')
|
||||
.send({ username: 'baduser', password: 'wrongpass' });
|
||||
const cookies = res.headers['set-cookie'] || [];
|
||||
expect(cookies.some(c => c.startsWith('emby_user='))).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/auth/me', () => {
|
||||
let app;
|
||||
|
||||
beforeEach(() => {
|
||||
delete process.env.COOKIE_SECRET;
|
||||
app = createApp({ skipRateLimits: true });
|
||||
});
|
||||
|
||||
it('returns authenticated:false when no cookie', async () => {
|
||||
const res = await request(app).get('/api/auth/me');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.authenticated).toBe(false);
|
||||
});
|
||||
|
||||
it('returns authenticated:true with valid cookie', async () => {
|
||||
const payload = JSON.stringify({ id: 'u1', name: 'Alice', isAdmin: false });
|
||||
const res = await request(app)
|
||||
.get('/api/auth/me')
|
||||
.set('Cookie', `emby_user=${encodeURIComponent(payload)}`);
|
||||
expect(res.body.authenticated).toBe(true);
|
||||
expect(res.body.user.name).toBe('Alice');
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/auth/csrf', () => {
|
||||
it('issues a csrf_token cookie and returns csrfToken in body', async () => {
|
||||
const app = createApp({ skipRateLimits: true });
|
||||
const res = await request(app).get('/api/auth/csrf');
|
||||
expect(res.status).toBe(200);
|
||||
expect(typeof res.body.csrfToken).toBe('string');
|
||||
expect(res.body.csrfToken.length).toBe(64); // 32 bytes hex
|
||||
const cookies = res.headers['set-cookie'] || [];
|
||||
expect(cookies.some(c => c.startsWith('csrf_token='))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/auth/logout', () => {
|
||||
let app;
|
||||
|
||||
beforeEach(() => {
|
||||
process.env.EMBY_URL = 'https://emby.test';
|
||||
delete process.env.COOKIE_SECRET;
|
||||
app = createApp({ skipRateLimits: true });
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env.EMBY_URL;
|
||||
});
|
||||
|
||||
// NOTE: /api/auth/* is mounted BEFORE the verifyCsrf middleware in app.js,
|
||||
// so logout does not require a CSRF token by design. The session cookie's
|
||||
// sameSite:strict attribute provides equivalent CSRF protection for logout.
|
||||
it('succeeds without a CSRF token (sameSite:strict provides protection)', async () => {
|
||||
nock(EMBY_BASE).post('/Sessions/Logout').reply(200, {});
|
||||
const res = await request(app)
|
||||
.post('/api/auth/logout');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
});
|
||||
|
||||
it('clears cookies and returns success when CSRF token is provided', async () => {
|
||||
const csrfRes = await request(app).get('/api/auth/csrf');
|
||||
const csrfToken = csrfRes.body.csrfToken;
|
||||
const csrfCookie = csrfRes.headers['set-cookie'].find(c => c.startsWith('csrf_token='));
|
||||
|
||||
nock(EMBY_BASE).post('/Sessions/Logout').reply(200, {});
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/auth/logout')
|
||||
.set('Cookie', csrfCookie)
|
||||
.set('X-CSRF-Token', csrfToken);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
|
||||
// Cookies should be cleared (Set-Cookie header with empty value / Max-Age=0)
|
||||
const cookies = res.headers['set-cookie'] || [];
|
||||
expect(cookies.some(c => c.includes('emby_user=;') || c.includes('Max-Age=0'))).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* Integration tests for health and readiness endpoints.
|
||||
*
|
||||
* /health and /ready are used by Docker HEALTHCHECK and must:
|
||||
* - Require no authentication
|
||||
* - Not be rate-limited
|
||||
* - Return the correct status codes
|
||||
*/
|
||||
|
||||
import request from 'supertest';
|
||||
import { createApp } from '../../server/app.js';
|
||||
|
||||
describe('GET /health', () => {
|
||||
let app;
|
||||
|
||||
beforeEach(() => {
|
||||
app = createApp();
|
||||
});
|
||||
|
||||
it('returns 200 with status ok', async () => {
|
||||
const res = await request(app).get('/health');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.status).toBe('ok');
|
||||
});
|
||||
|
||||
it('includes uptime as a number', async () => {
|
||||
const res = await request(app).get('/health');
|
||||
expect(typeof res.body.uptime).toBe('number');
|
||||
expect(res.body.uptime).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /ready', () => {
|
||||
let app;
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env.EMBY_URL;
|
||||
});
|
||||
|
||||
it('returns 200 when EMBY_URL is configured', async () => {
|
||||
process.env.EMBY_URL = 'https://emby.local';
|
||||
app = createApp();
|
||||
const res = await request(app).get('/ready');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.status).toBe('ready');
|
||||
});
|
||||
|
||||
it('returns 503 when EMBY_URL is not configured', async () => {
|
||||
delete process.env.EMBY_URL;
|
||||
app = createApp();
|
||||
const res = await request(app).get('/ready');
|
||||
expect(res.status).toBe(503);
|
||||
expect(res.body.status).toBe('not ready');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,289 @@
|
||||
/**
|
||||
* Integration tests for GET /api/history/recent
|
||||
*
|
||||
* Uses supertest against createApp() with vi.mock to stub historyFetcher
|
||||
* (avoids nock/ESM-CJS interop issues with axios) and nock for Emby auth.
|
||||
* Covers:
|
||||
* - 401 when unauthenticated
|
||||
* - Empty history response when arr returns no records
|
||||
* - Filters out records whose eventType is not imported/failed
|
||||
* - Returns imported and failed records for tagged series/movies
|
||||
* - ?days= param is respected (default 7, capped at 90)
|
||||
* - failureMessage included for admins on failed records
|
||||
*/
|
||||
|
||||
import request from 'supertest';
|
||||
import nock from 'nock';
|
||||
import { beforeEach, afterEach } from 'vitest';
|
||||
import { createRequire } from 'module';
|
||||
import { createApp } from '../../server/app.js';
|
||||
|
||||
// Use createRequire to get the same CJS singleton cache instance that
|
||||
// server/utils/historyFetcher.js and server/routes/history.js use via
|
||||
// require('./cache'). A plain ESM `import cache from '...'` resolves
|
||||
// to a different module identity under vitest's ESM runtime.
|
||||
const require = createRequire(import.meta.url);
|
||||
const cache = require('../../server/utils/cache.js');
|
||||
|
||||
const EMBY_BASE = 'https://emby.test';
|
||||
|
||||
process.env.EMBY_URL = EMBY_BASE;
|
||||
process.env.SONARR_INSTANCES = JSON.stringify([
|
||||
{ id: 'sonarr-1', name: 'Main Sonarr', url: 'https://sonarr.test', apiKey: 'sk' }
|
||||
]);
|
||||
process.env.RADARR_INSTANCES = JSON.stringify([
|
||||
{ id: 'radarr-1', name: 'Main Radarr', url: 'https://radarr.test', apiKey: 'rk' }
|
||||
]);
|
||||
|
||||
// --- Fixtures ---
|
||||
const EMBY_AUTH = { AccessToken: 'tok', User: { Id: 'uid1', Name: 'alice' } };
|
||||
const EMBY_USER = { Id: 'uid1', Name: 'alice', Policy: { IsAdministrator: false } };
|
||||
const EMBY_ADMIN = { Id: 'uid2', Name: 'admin', Policy: { IsAdministrator: true } };
|
||||
const EMBY_AUTH_ADMIN = { AccessToken: 'tok2', User: { Id: 'uid2', Name: 'admin' } };
|
||||
|
||||
// Sonarr tag: id 1 → 'alice'
|
||||
const SONARR_TAGS = [{ id: 1, label: 'alice' }];
|
||||
|
||||
const SONARR_RECORD_IMPORTED = {
|
||||
id: 100,
|
||||
eventType: 'downloadFolderImported',
|
||||
sourceTitle: 'Show.S01E01.720p',
|
||||
date: new Date().toISOString(),
|
||||
quality: { quality: { name: '720p' } },
|
||||
series: { id: 10, title: 'My Show', titleSlug: 'my-show', tags: [1], images: [] },
|
||||
seriesId: 10
|
||||
};
|
||||
|
||||
const SONARR_RECORD_FAILED = {
|
||||
id: 101,
|
||||
eventType: 'downloadFailed',
|
||||
sourceTitle: 'Show.S01E02.720p',
|
||||
date: new Date().toISOString(),
|
||||
quality: { quality: { name: '720p' } },
|
||||
data: { message: 'Not enough disk space' },
|
||||
series: { id: 11, title: 'Admin Show', titleSlug: 'admin-show', tags: [2], images: [] },
|
||||
};
|
||||
|
||||
// Tag id 2 → 'admin' (used in the failed-import admin test)
|
||||
const SONARR_TAGS_WITH_ADMIN = [{ id: 1, label: 'alice' }, { id: 2, label: 'admin' }];
|
||||
|
||||
const SONARR_RECORD_FAILED_ALICE = { // failed record tagged alice, for event-type filtering test
|
||||
id: 103,
|
||||
eventType: 'downloadFailed',
|
||||
sourceTitle: 'Show.S01E02.720p',
|
||||
date: new Date().toISOString(),
|
||||
quality: { quality: { name: '720p' } },
|
||||
data: { message: 'Disk full' },
|
||||
series: { id: 10, title: 'My Show', titleSlug: 'my-show', tags: [1], images: [] },
|
||||
seriesId: 10
|
||||
};
|
||||
|
||||
const SONARR_RECORD_GRABBED = {
|
||||
id: 102,
|
||||
eventType: 'grabbed',
|
||||
sourceTitle: 'Show.S01E03.720p',
|
||||
date: new Date().toISOString(),
|
||||
series: { id: 10, title: 'My Show', titleSlug: 'my-show', tags: [1], images: [] },
|
||||
seriesId: 10
|
||||
};
|
||||
|
||||
const RADARR_RECORD_IMPORTED = {
|
||||
id: 200,
|
||||
eventType: 'downloadFolderImported',
|
||||
sourceTitle: 'My.Movie.2024.1080p',
|
||||
date: new Date().toISOString(),
|
||||
quality: { quality: { name: '1080p' } },
|
||||
movie: { id: 20, title: 'My Movie', titleSlug: 'my-movie-2024', tags: [1], images: [] },
|
||||
movieId: 20
|
||||
};
|
||||
|
||||
// --- Helpers ---
|
||||
function interceptLogin(userBody = EMBY_USER, authBody = EMBY_AUTH) {
|
||||
nock(EMBY_BASE).post('/Users/authenticatebyname').reply(200, authBody);
|
||||
nock(EMBY_BASE).get(/\/Users\//).reply(200, userBody);
|
||||
}
|
||||
|
||||
// Pre-seed the history cache keys that fetchSonarrHistory/fetchRadarrHistory check
|
||||
// first, so they return without making any HTTP calls.
|
||||
const CACHE_TTL = 60 * 60 * 1000; // 1 hour — won't expire during a test run
|
||||
|
||||
function setHistory(sonarrRecords = [], radarrRecords = []) {
|
||||
cache.set('history:sonarr', sonarrRecords.map(r => ({
|
||||
...r,
|
||||
_instanceName: 'Main Sonarr',
|
||||
series: r.series ? { ...r.series, _instanceUrl: 'https://sonarr.test', _instanceName: 'Main Sonarr' } : undefined
|
||||
})), CACHE_TTL);
|
||||
cache.set('history:radarr', radarrRecords.map(r => ({
|
||||
...r,
|
||||
_instanceName: 'Main Radarr',
|
||||
movie: r.movie ? { ...r.movie, _instanceUrl: 'https://radarr.test', _instanceName: 'Main Radarr' } : undefined
|
||||
})), CACHE_TTL);
|
||||
}
|
||||
|
||||
async function loginAs(app, userBody = EMBY_USER, authBody = EMBY_AUTH) {
|
||||
interceptLogin(userBody, authBody);
|
||||
const res = await request(app)
|
||||
.post('/api/auth/login')
|
||||
.send({ username: userBody.Name, password: 'pw' });
|
||||
const cookies = res.headers['set-cookie'];
|
||||
const csrf = res.body.csrfToken;
|
||||
return { cookies, csrf };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
// Clear history caches so each test controls its own data
|
||||
cache.invalidate('history:sonarr');
|
||||
cache.invalidate('history:radarr');
|
||||
// Default: empty history
|
||||
setHistory([], []);
|
||||
// Seed poll tag caches so the route can resolve tags
|
||||
cache.set('poll:sonarr-tags', [{ data: SONARR_TAGS }], 60000);
|
||||
cache.set('poll:radarr-tags', [], 60000);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
nock.cleanAll();
|
||||
cache.invalidate('history:sonarr');
|
||||
cache.invalidate('history:radarr');
|
||||
});
|
||||
|
||||
describe('GET /api/history/recent', () => {
|
||||
describe('authentication', () => {
|
||||
it('returns 401 when not logged in', async () => {
|
||||
const app = createApp({ skipRateLimits: true });
|
||||
const res = await request(app).get('/api/history/recent');
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe('empty history', () => {
|
||||
it('returns empty array when arr returns no records', async () => {
|
||||
const app = createApp({ skipRateLimits: true });
|
||||
const { cookies } = await loginAs(app);
|
||||
const res = await request(app)
|
||||
.get('/api/history/recent')
|
||||
.set('Cookie', cookies);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.history).toEqual([]);
|
||||
expect(res.body.days).toBe(7);
|
||||
});
|
||||
});
|
||||
|
||||
describe('event type filtering', () => {
|
||||
it('includes imported and failed records, excludes grabbed', async () => {
|
||||
const app = createApp({ skipRateLimits: true });
|
||||
setHistory(
|
||||
[SONARR_RECORD_IMPORTED, SONARR_RECORD_FAILED_ALICE, SONARR_RECORD_GRABBED],
|
||||
[]
|
||||
);
|
||||
const { cookies } = await loginAs(app);
|
||||
const res = await request(app)
|
||||
.get('/api/history/recent')
|
||||
.set('Cookie', cookies);
|
||||
expect(res.status).toBe(200);
|
||||
const outcomes = res.body.history.map(h => h.outcome);
|
||||
expect(outcomes).toContain('imported');
|
||||
expect(outcomes).toContain('failed');
|
||||
expect(res.body.history).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('tag filtering', () => {
|
||||
it('only returns records tagged for the current user', async () => {
|
||||
const app = createApp({ skipRateLimits: true });
|
||||
setHistory([SONARR_RECORD_IMPORTED], []);
|
||||
const { cookies } = await loginAs(app);
|
||||
const res = await request(app)
|
||||
.get('/api/history/recent')
|
||||
.set('Cookie', cookies);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.history).toHaveLength(1);
|
||||
expect(res.body.history[0].seriesName).toBe('My Show');
|
||||
expect(res.body.history[0].outcome).toBe('imported');
|
||||
expect(res.body.history[0].quality).toBe('720p');
|
||||
});
|
||||
|
||||
it('excludes records tagged for a different user', async () => {
|
||||
const app = createApp({ skipRateLimits: true });
|
||||
const bobAuth = { AccessToken: 'tok3', User: { Id: 'uid3', Name: 'bob' } };
|
||||
const bobUser = { Id: 'uid3', Name: 'bob', Policy: { IsAdministrator: false } };
|
||||
setHistory([SONARR_RECORD_IMPORTED], []);
|
||||
const { cookies } = await loginAs(app, bobUser, bobAuth);
|
||||
const res = await request(app)
|
||||
.get('/api/history/recent')
|
||||
.set('Cookie', cookies);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.history).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('radarr records', () => {
|
||||
it('returns movie history items', async () => {
|
||||
const app = createApp({ skipRateLimits: true });
|
||||
cache.set('poll:radarr-tags', [{ id: 1, label: 'alice' }], 60000);
|
||||
setHistory([], [RADARR_RECORD_IMPORTED]);
|
||||
const { cookies } = await loginAs(app);
|
||||
const res = await request(app)
|
||||
.get('/api/history/recent')
|
||||
.set('Cookie', cookies);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.history).toHaveLength(1);
|
||||
expect(res.body.history[0].type).toBe('movie');
|
||||
expect(res.body.history[0].movieName).toBe('My Movie');
|
||||
});
|
||||
});
|
||||
|
||||
describe('?days parameter', () => {
|
||||
it('uses custom days value', async () => {
|
||||
const app = createApp({ skipRateLimits: true });
|
||||
const { cookies } = await loginAs(app);
|
||||
const res = await request(app)
|
||||
.get('/api/history/recent?days=14')
|
||||
.set('Cookie', cookies);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.days).toBe(14);
|
||||
});
|
||||
|
||||
it('caps days at 90', async () => {
|
||||
const app = createApp({ skipRateLimits: true });
|
||||
const { cookies } = await loginAs(app);
|
||||
const res = await request(app)
|
||||
.get('/api/history/recent?days=999')
|
||||
.set('Cookie', cookies);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.days).toBe(7); // falls back to default when > 90
|
||||
});
|
||||
});
|
||||
|
||||
describe('failed import details', () => {
|
||||
it('includes failureMessage for admin on failed records', async () => {
|
||||
const app = createApp({ skipRateLimits: true });
|
||||
cache.set('poll:sonarr-tags', [{ data: SONARR_TAGS_WITH_ADMIN }], 60000);
|
||||
setHistory([SONARR_RECORD_FAILED], []);
|
||||
const { cookies } = await loginAs(app, EMBY_ADMIN, EMBY_AUTH_ADMIN);
|
||||
const res = await request(app)
|
||||
.get('/api/history/recent')
|
||||
.set('Cookie', cookies);
|
||||
expect(res.status).toBe(200);
|
||||
const failed = res.body.history.find(h => h.outcome === 'failed');
|
||||
expect(failed).toBeDefined();
|
||||
expect(failed.failureMessage).toBe('Not enough disk space');
|
||||
});
|
||||
});
|
||||
|
||||
describe('response shape', () => {
|
||||
it('returns correct top-level fields', async () => {
|
||||
const app = createApp({ skipRateLimits: true });
|
||||
const { cookies } = await loginAs(app);
|
||||
const res = await request(app)
|
||||
.get('/api/history/recent')
|
||||
.set('Cookie', cookies);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toHaveProperty('user');
|
||||
expect(res.body).toHaveProperty('isAdmin');
|
||||
expect(res.body).toHaveProperty('days');
|
||||
expect(res.body).toHaveProperty('history');
|
||||
expect(Array.isArray(res.body.history)).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
import { vi, beforeEach, afterEach } from 'vitest';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
|
||||
// Give each test worker a unique temp DATA_DIR so tokenStore file I/O is
|
||||
// fully isolated and doesn't conflict with a running dev server's data/.
|
||||
const tmpDir = path.join(os.tmpdir(), `sofarr-test-${process.pid}`);
|
||||
if (!fs.existsSync(tmpDir)) fs.mkdirSync(tmpDir, { recursive: true });
|
||||
process.env.DATA_DIR = tmpDir;
|
||||
|
||||
// Disable rate limiters in tests — all supertest requests share 127.0.0.1
|
||||
// and would quickly exhaust per-IP windows otherwise.
|
||||
process.env.SKIP_RATE_LIMIT = '1';
|
||||
|
||||
// Suppress console noise during tests (errors still surface via thrown exceptions)
|
||||
beforeEach(() => {
|
||||
vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// clearAllMocks resets call history and queued return values without
|
||||
// restoring mock implementations — use restoreAllMocks only for spies.
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* Tests for server/utils/config.js
|
||||
*
|
||||
* Verifies that instance config is parsed correctly from both the modern JSON
|
||||
* array format and the legacy single-instance env var format. This is critical
|
||||
* because misconfigured instances silently return no data rather than crashing.
|
||||
*/
|
||||
|
||||
import { parseInstances, getSonarrInstances, getRadarrInstances } from '../../server/utils/config.js';
|
||||
|
||||
describe('parseInstances', () => {
|
||||
describe('JSON array format', () => {
|
||||
it('parses a valid single-instance JSON array', () => {
|
||||
const json = JSON.stringify([{ name: 'main', url: 'https://sonarr.local', apiKey: 'abc123' }]);
|
||||
const result = parseInstances(json, null, null);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].url).toBe('https://sonarr.local');
|
||||
expect(result[0].apiKey).toBe('abc123');
|
||||
});
|
||||
|
||||
it('parses multiple instances', () => {
|
||||
const json = JSON.stringify([
|
||||
{ name: 'main', url: 'https://s1.local', apiKey: 'key1' },
|
||||
{ name: 'backup', url: 'https://s2.local', apiKey: 'key2' }
|
||||
]);
|
||||
const result = parseInstances(json, null, null);
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[1].name).toBe('backup');
|
||||
});
|
||||
|
||||
it('adds id from name when present', () => {
|
||||
const json = JSON.stringify([{ name: 'i3omb', url: 'https://s.local', apiKey: 'k' }]);
|
||||
const result = parseInstances(json, null, null);
|
||||
expect(result[0].id).toBe('i3omb');
|
||||
});
|
||||
|
||||
it('generates fallback id when name is absent', () => {
|
||||
const json = JSON.stringify([{ url: 'https://s.local', apiKey: 'k' }]);
|
||||
const result = parseInstances(json, null, null);
|
||||
expect(result[0].id).toBe('instance-1');
|
||||
});
|
||||
|
||||
it('handles multi-line JSON by stripping whitespace', () => {
|
||||
const json = `[
|
||||
{
|
||||
"name": "main",
|
||||
"url": "https://sonarr.local",
|
||||
"apiKey": "abc"
|
||||
}
|
||||
]`;
|
||||
const result = parseInstances(json, null, null);
|
||||
expect(result).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('returns empty array for empty JSON array', () => {
|
||||
expect(parseInstances('[]', null, null)).toEqual([]);
|
||||
});
|
||||
|
||||
it('falls back to legacy format when JSON is malformed', () => {
|
||||
const result = parseInstances('not-json', 'https://legacy.local', 'legacyKey');
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].url).toBe('https://legacy.local');
|
||||
});
|
||||
});
|
||||
|
||||
describe('legacy single-instance format', () => {
|
||||
it('returns single instance from legacy URL + key', () => {
|
||||
const result = parseInstances(null, 'https://sonarr.local', 'legacyapikey');
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].id).toBe('default');
|
||||
expect(result[0].name).toBe('Default');
|
||||
expect(result[0].url).toBe('https://sonarr.local');
|
||||
expect(result[0].apiKey).toBe('legacyapikey');
|
||||
});
|
||||
|
||||
it('returns empty array for qBittorrent with no apiKey and no JSON (legacy requires key)', () => {
|
||||
// parseInstances requires legacyKey to be truthy for the legacy path;
|
||||
// qBittorrent uses JSON array format, not the legacy URL+key path.
|
||||
const result = parseInstances(null, 'https://qbt.local', null, 'admin', 'pass123');
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns empty array when both JSON and legacy URL are missing', () => {
|
||||
expect(parseInstances(null, null, null)).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns empty array when URL is set but key is missing', () => {
|
||||
expect(parseInstances(null, 'https://sonarr.local', null)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('env-based getters', () => {
|
||||
it('getSonarrInstances reads SONARR_INSTANCES from env', () => {
|
||||
process.env.SONARR_INSTANCES = JSON.stringify([{ name: 'test', url: 'https://s.local', apiKey: 'k' }]);
|
||||
const result = getSonarrInstances();
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].name).toBe('test');
|
||||
delete process.env.SONARR_INSTANCES;
|
||||
});
|
||||
|
||||
it('getRadarrInstances returns empty array when unconfigured', () => {
|
||||
delete process.env.RADARR_INSTANCES;
|
||||
delete process.env.RADARR_URL;
|
||||
const result = getRadarrInstances();
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,177 @@
|
||||
/**
|
||||
* Unit tests for server/utils/historyFetcher.js
|
||||
*
|
||||
* Covers:
|
||||
* - classifySonarrEvent / classifyRadarrEvent event classification
|
||||
* - fetchSonarrHistory / fetchRadarrHistory: successful fetch, cache hit, per-instance errors
|
||||
* - invalidateHistoryCache
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import nock from 'nock';
|
||||
|
||||
// Env must be set before importing modules that read it at load time
|
||||
process.env.SONARR_INSTANCES = JSON.stringify([
|
||||
{ id: 'sonarr-1', name: 'Main Sonarr', url: 'https://sonarr.test', apiKey: 'sonarr-key' }
|
||||
]);
|
||||
process.env.RADARR_INSTANCES = JSON.stringify([
|
||||
{ id: 'radarr-1', name: 'Main Radarr', url: 'https://radarr.test', apiKey: 'radarr-key' }
|
||||
]);
|
||||
|
||||
const { classifySonarrEvent, classifyRadarrEvent, fetchSonarrHistory, fetchRadarrHistory, invalidateHistoryCache } =
|
||||
await import('../../server/utils/historyFetcher.js');
|
||||
|
||||
const since = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
|
||||
|
||||
afterEach(() => {
|
||||
nock.cleanAll();
|
||||
invalidateHistoryCache();
|
||||
});
|
||||
|
||||
describe('classifySonarrEvent', () => {
|
||||
it('returns imported for downloadFolderImported', () => {
|
||||
expect(classifySonarrEvent('downloadFolderImported')).toBe('imported');
|
||||
});
|
||||
it('returns imported for downloadImported', () => {
|
||||
expect(classifySonarrEvent('downloadImported')).toBe('imported');
|
||||
});
|
||||
it('returns failed for downloadFailed', () => {
|
||||
expect(classifySonarrEvent('downloadFailed')).toBe('failed');
|
||||
});
|
||||
it('returns failed for importFailed', () => {
|
||||
expect(classifySonarrEvent('importFailed')).toBe('failed');
|
||||
});
|
||||
it('returns other for grabbed', () => {
|
||||
expect(classifySonarrEvent('grabbed')).toBe('other');
|
||||
});
|
||||
it('returns other for unknown event', () => {
|
||||
expect(classifySonarrEvent('someFutureEvent')).toBe('other');
|
||||
});
|
||||
});
|
||||
|
||||
describe('classifyRadarrEvent', () => {
|
||||
it('returns imported for downloadFolderImported', () => {
|
||||
expect(classifyRadarrEvent('downloadFolderImported')).toBe('imported');
|
||||
});
|
||||
it('returns failed for downloadFailed', () => {
|
||||
expect(classifyRadarrEvent('downloadFailed')).toBe('failed');
|
||||
});
|
||||
it('returns other for grabbed', () => {
|
||||
expect(classifyRadarrEvent('grabbed')).toBe('other');
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchSonarrHistory', () => {
|
||||
const mockRecords = [
|
||||
{
|
||||
id: 1,
|
||||
eventType: 'downloadFolderImported',
|
||||
sourceTitle: 'Show.S01E01',
|
||||
date: new Date().toISOString(),
|
||||
series: { id: 10, title: 'My Show', titleSlug: 'my-show', tags: [] },
|
||||
seriesId: 10
|
||||
}
|
||||
];
|
||||
|
||||
it('fetches records and tags them with _instanceUrl and _instanceName', async () => {
|
||||
nock('https://sonarr.test')
|
||||
.get('/api/v3/history')
|
||||
.query(true)
|
||||
.reply(200, { records: mockRecords });
|
||||
|
||||
const result = await fetchSonarrHistory(since);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].series._instanceUrl).toBe('https://sonarr.test');
|
||||
expect(result[0].series._instanceName).toBe('Main Sonarr');
|
||||
expect(result[0]._instanceName).toBe('Main Sonarr');
|
||||
});
|
||||
|
||||
it('returns cached data on second call without making a new HTTP request', async () => {
|
||||
nock('https://sonarr.test')
|
||||
.get('/api/v3/history')
|
||||
.query(true)
|
||||
.reply(200, { records: mockRecords });
|
||||
|
||||
const first = await fetchSonarrHistory(since);
|
||||
// Second call — nock would throw if a second request was made
|
||||
const second = await fetchSonarrHistory(since);
|
||||
expect(second).toEqual(first);
|
||||
});
|
||||
|
||||
it('returns empty array and does not throw when instance errors', async () => {
|
||||
nock('https://sonarr.test')
|
||||
.get('/api/v3/history')
|
||||
.query(true)
|
||||
.replyWithError('ECONNREFUSED');
|
||||
|
||||
const result = await fetchSonarrHistory(since);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('handles missing records key gracefully', async () => {
|
||||
nock('https://sonarr.test')
|
||||
.get('/api/v3/history')
|
||||
.query(true)
|
||||
.reply(200, {});
|
||||
|
||||
const result = await fetchSonarrHistory(since);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchRadarrHistory', () => {
|
||||
const mockRecords = [
|
||||
{
|
||||
id: 2,
|
||||
eventType: 'downloadFolderImported',
|
||||
sourceTitle: 'My.Movie.2024',
|
||||
date: new Date().toISOString(),
|
||||
movie: { id: 20, title: 'My Movie', titleSlug: 'my-movie-2024', tags: [] },
|
||||
movieId: 20
|
||||
}
|
||||
];
|
||||
|
||||
it('fetches records and tags them with _instanceUrl and _instanceName', async () => {
|
||||
nock('https://radarr.test')
|
||||
.get('/api/v3/history')
|
||||
.query(true)
|
||||
.reply(200, { records: mockRecords });
|
||||
|
||||
const result = await fetchRadarrHistory(since);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].movie._instanceUrl).toBe('https://radarr.test');
|
||||
expect(result[0].movie._instanceName).toBe('Main Radarr');
|
||||
});
|
||||
|
||||
it('returns empty array on network error', async () => {
|
||||
nock('https://radarr.test')
|
||||
.get('/api/v3/history')
|
||||
.query(true)
|
||||
.replyWithError('timeout');
|
||||
|
||||
const result = await fetchRadarrHistory(since);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('invalidateHistoryCache', () => {
|
||||
it('forces a fresh fetch after invalidation', async () => {
|
||||
nock('https://sonarr.test')
|
||||
.get('/api/v3/history')
|
||||
.query(true)
|
||||
.reply(200, { records: [] });
|
||||
|
||||
await fetchSonarrHistory(since);
|
||||
invalidateHistoryCache();
|
||||
|
||||
// Should make a second HTTP request — nock will satisfy it
|
||||
nock('https://sonarr.test')
|
||||
.get('/api/v3/history')
|
||||
.query(true)
|
||||
.reply(200, { records: [] });
|
||||
|
||||
const result = await fetchSonarrHistory(since);
|
||||
expect(result).toEqual([]);
|
||||
expect(nock.isDone()).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* Tests for server/utils/qbittorrent.js pure utility functions.
|
||||
*
|
||||
* mapTorrentToDownload, formatBytes, formatSpeed, and formatEta are all
|
||||
* pure functions with no I/O — ideal unit test targets. These power the
|
||||
* dashboard card rendering so correctness matters for UX.
|
||||
*/
|
||||
|
||||
import { mapTorrentToDownload, formatBytes, formatSpeed, formatEta } from '../../server/utils/qbittorrent.js';
|
||||
|
||||
// Minimal torrent fixture that satisfies mapTorrentToDownload's expectations
|
||||
function makeTorrent(overrides = {}) {
|
||||
return {
|
||||
name: 'My.Show.S01E01.1080p.mkv',
|
||||
state: 'downloading',
|
||||
size: 1073741824, // 1 GB
|
||||
completed: 536870912, // 512 MB
|
||||
progress: 0.5,
|
||||
dlspeed: 1048576, // 1 MB/s
|
||||
eta: 512, // seconds
|
||||
num_seeds: 10,
|
||||
num_leechs: 3,
|
||||
availability: 1.0,
|
||||
hash: 'aabbccdd',
|
||||
category: 'sonarr',
|
||||
tags: '',
|
||||
content_path: '/downloads/My.Show.S01E01.1080p.mkv',
|
||||
save_path: '/downloads/',
|
||||
instanceName: 'i3omb',
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
describe('formatBytes', () => {
|
||||
it('formats 0 bytes', () => expect(formatBytes(0)).toBe('0 B'));
|
||||
it('formats bytes', () => expect(formatBytes(512)).toBe('512 B'));
|
||||
it('formats kilobytes', () => expect(formatBytes(1024)).toBe('1 KB'));
|
||||
it('formats megabytes', () => expect(formatBytes(1048576)).toBe('1 MB'));
|
||||
it('formats gigabytes', () => expect(formatBytes(1073741824)).toBe('1 GB'));
|
||||
it('formats fractional GB', () => expect(formatBytes(1610612736)).toBe('1.5 GB'));
|
||||
});
|
||||
|
||||
describe('formatSpeed', () => {
|
||||
it('appends /s to byte count', () => expect(formatSpeed(1048576)).toBe('1 MB/s'));
|
||||
it('handles zero speed', () => expect(formatSpeed(0)).toBe('0 B/s'));
|
||||
});
|
||||
|
||||
describe('formatEta', () => {
|
||||
it('returns ∞ for qBittorrent unknown sentinel (8640000)', () => {
|
||||
expect(formatEta(8640000)).toBe('∞');
|
||||
});
|
||||
it('returns ∞ for negative eta', () => expect(formatEta(-1)).toBe('∞'));
|
||||
it('formats minutes only', () => expect(formatEta(90)).toBe('1m'));
|
||||
it('formats hours and minutes', () => expect(formatEta(3661)).toBe('1h 1m'));
|
||||
it('formats days, hours and minutes', () => expect(formatEta(90061)).toBe('1d 1h 1m'));
|
||||
it('returns 0m for zero seconds', () => expect(formatEta(0)).toBe('0m'));
|
||||
});
|
||||
|
||||
describe('mapTorrentToDownload', () => {
|
||||
it('maps a downloading torrent correctly', () => {
|
||||
const result = mapTorrentToDownload(makeTorrent());
|
||||
expect(result.status).toBe('Downloading');
|
||||
expect(result.progress).toBe('50.0');
|
||||
expect(result.size).toBe('1 GB');
|
||||
expect(result.speed).toBe('1 MB/s');
|
||||
expect(result.eta).toBe('8m');
|
||||
expect(result.seeds).toBe(10);
|
||||
expect(result.peers).toBe(3);
|
||||
expect(result.qbittorrent).toBe(true);
|
||||
expect(result.instanceName).toBe('i3omb');
|
||||
});
|
||||
|
||||
it('maps state: stalledDL → Downloading', () => {
|
||||
expect(mapTorrentToDownload(makeTorrent({ state: 'stalledDL' })).status).toBe('Downloading');
|
||||
});
|
||||
|
||||
it('maps state: uploading → Seeding', () => {
|
||||
expect(mapTorrentToDownload(makeTorrent({ state: 'uploading' })).status).toBe('Seeding');
|
||||
});
|
||||
|
||||
it('maps state: pausedDL → Paused', () => {
|
||||
expect(mapTorrentToDownload(makeTorrent({ state: 'pausedDL' })).status).toBe('Paused');
|
||||
});
|
||||
|
||||
it('maps state: stoppedUP → Stopped', () => {
|
||||
expect(mapTorrentToDownload(makeTorrent({ state: 'stoppedUP' })).status).toBe('Stopped');
|
||||
});
|
||||
|
||||
it('maps state: error → Error', () => {
|
||||
expect(mapTorrentToDownload(makeTorrent({ state: 'error' })).status).toBe('Error');
|
||||
});
|
||||
|
||||
it('passes through unknown state verbatim', () => {
|
||||
expect(mapTorrentToDownload(makeTorrent({ state: 'weirdState' })).status).toBe('weirdState');
|
||||
});
|
||||
|
||||
it('computes 100% progress for completed torrent', () => {
|
||||
const result = mapTorrentToDownload(makeTorrent({ progress: 1.0 }));
|
||||
expect(result.progress).toBe('100.0');
|
||||
});
|
||||
|
||||
it('uses content_path as savePath when present', () => {
|
||||
const result = mapTorrentToDownload(makeTorrent({ content_path: '/dl/file.mkv' }));
|
||||
expect(result.savePath).toBe('/dl/file.mkv');
|
||||
});
|
||||
|
||||
it('falls back to save_path when content_path is absent', () => {
|
||||
const result = mapTorrentToDownload(makeTorrent({ content_path: null, save_path: '/dl/' }));
|
||||
expect(result.savePath).toBe('/dl/');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* Tests for server/middleware/requireAuth.js
|
||||
*
|
||||
* requireAuth guards all authenticated API routes. Tests exercise the full
|
||||
* range of valid/invalid cookie states to ensure there's no bypass path.
|
||||
*/
|
||||
|
||||
import requireAuth from '../../server/middleware/requireAuth.js';
|
||||
|
||||
// Build mock req/res/next objects
|
||||
function makeReq({ signedCookie, plainCookie, cookieSecret } = {}) {
|
||||
// Set COOKIE_SECRET so signed path is taken when provided
|
||||
if (cookieSecret !== undefined) {
|
||||
process.env.COOKIE_SECRET = cookieSecret;
|
||||
} else {
|
||||
delete process.env.COOKIE_SECRET;
|
||||
}
|
||||
|
||||
return {
|
||||
signedCookies: { emby_user: signedCookie },
|
||||
cookies: { emby_user: plainCookie }
|
||||
};
|
||||
}
|
||||
|
||||
function makeRes() {
|
||||
const res = {
|
||||
statusCode: null,
|
||||
body: null,
|
||||
status(code) { this.statusCode = code; return this; },
|
||||
json(body) { this.body = body; return this; }
|
||||
};
|
||||
return res;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env.COOKIE_SECRET;
|
||||
});
|
||||
|
||||
describe('requireAuth middleware', () => {
|
||||
describe('valid sessions', () => {
|
||||
it('calls next() with a valid signed cookie', () => {
|
||||
const payload = JSON.stringify({ id: 'u1', name: 'Alice', isAdmin: true });
|
||||
const req = makeReq({ signedCookie: payload, cookieSecret: 'secret' });
|
||||
const res = makeRes();
|
||||
const next = vi.fn();
|
||||
|
||||
requireAuth(req, res, next);
|
||||
|
||||
expect(next).toHaveBeenCalledOnce();
|
||||
expect(req.user).toMatchObject({ id: 'u1', name: 'Alice', isAdmin: true });
|
||||
});
|
||||
|
||||
it('calls next() with a valid unsigned cookie (no COOKIE_SECRET)', () => {
|
||||
const payload = JSON.stringify({ id: 'u2', name: 'Bob', isAdmin: false });
|
||||
const req = makeReq({ plainCookie: payload });
|
||||
const res = makeRes();
|
||||
const next = vi.fn();
|
||||
|
||||
requireAuth(req, res, next);
|
||||
|
||||
expect(next).toHaveBeenCalledOnce();
|
||||
expect(req.user.id).toBe('u2');
|
||||
});
|
||||
|
||||
it('coerces non-boolean isAdmin to boolean', () => {
|
||||
const payload = JSON.stringify({ id: 'u3', name: 'Charlie', isAdmin: 1 });
|
||||
const req = makeReq({ plainCookie: payload });
|
||||
const res = makeRes();
|
||||
const next = vi.fn();
|
||||
|
||||
requireAuth(req, res, next);
|
||||
|
||||
expect(next).toHaveBeenCalled();
|
||||
expect(req.user.isAdmin).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('missing or invalid cookies', () => {
|
||||
it('returns 401 when no cookie is present', () => {
|
||||
const req = makeReq({});
|
||||
const res = makeRes();
|
||||
const next = vi.fn();
|
||||
|
||||
requireAuth(req, res, next);
|
||||
|
||||
expect(res.statusCode).toBe(401);
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns 401 when signed cookie value is false (tampered)', () => {
|
||||
// cookie-parser sets signed cookie to false when signature is invalid
|
||||
const req = makeReq({ signedCookie: false, cookieSecret: 'secret' });
|
||||
const res = makeRes();
|
||||
const next = vi.fn();
|
||||
|
||||
requireAuth(req, res, next);
|
||||
|
||||
expect(res.statusCode).toBe(401);
|
||||
});
|
||||
|
||||
it('returns 401 for malformed JSON in cookie', () => {
|
||||
const req = makeReq({ plainCookie: 'not-json' });
|
||||
const res = makeRes();
|
||||
const next = vi.fn();
|
||||
|
||||
requireAuth(req, res, next);
|
||||
|
||||
expect(res.statusCode).toBe(401);
|
||||
expect(res.body.error).toBe('Invalid session');
|
||||
});
|
||||
|
||||
it('returns 401 when id is missing', () => {
|
||||
const payload = JSON.stringify({ name: 'Alice', isAdmin: false });
|
||||
const req = makeReq({ plainCookie: payload });
|
||||
requireAuth(req, makeRes(), vi.fn());
|
||||
// no next called — handled in the assertion below
|
||||
const res = makeRes();
|
||||
const next = vi.fn();
|
||||
requireAuth(req, res, next);
|
||||
expect(res.statusCode).toBe(401);
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns 401 when name is missing', () => {
|
||||
const payload = JSON.stringify({ id: 'u1', isAdmin: false });
|
||||
const req = makeReq({ plainCookie: payload });
|
||||
const res = makeRes();
|
||||
requireAuth(req, res, vi.fn());
|
||||
expect(res.statusCode).toBe(401);
|
||||
});
|
||||
|
||||
it('returns 401 when id is empty string', () => {
|
||||
const payload = JSON.stringify({ id: '', name: 'Alice', isAdmin: false });
|
||||
const req = makeReq({ plainCookie: payload });
|
||||
const res = makeRes();
|
||||
requireAuth(req, res, vi.fn());
|
||||
expect(res.statusCode).toBe(401);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* Tests for server/utils/sanitizeError.js
|
||||
*
|
||||
* Critical security tests: verify that API keys, tokens, passwords and other
|
||||
* secrets are NEVER leaked in error messages returned to clients or written
|
||||
* to logs. Every pattern here represents a real credential type used in the
|
||||
* sofarr stack (SABnzbd apikey, Emby tokens, qBittorrent basic-auth URLs).
|
||||
*/
|
||||
|
||||
import sanitizeError from '../../server/utils/sanitizeError.js';
|
||||
|
||||
describe('sanitizeError', () => {
|
||||
describe('query-param secrets', () => {
|
||||
it('redacts ?apikey= values', () => {
|
||||
const err = new Error('Request failed: https://sabnzbd.local/api?apikey=abc123secret&output=json');
|
||||
expect(sanitizeError(err)).toContain('[REDACTED]');
|
||||
expect(sanitizeError(err)).not.toContain('abc123secret');
|
||||
});
|
||||
|
||||
it('redacts &apikey= mid-URL', () => {
|
||||
const err = new Error('GET https://host/path?mode=queue&apikey=SUPERSECRET&output=json');
|
||||
expect(sanitizeError(err)).not.toContain('SUPERSECRET');
|
||||
expect(sanitizeError(err)).toContain('[REDACTED]');
|
||||
});
|
||||
|
||||
it('redacts ?token= values', () => {
|
||||
const err = new Error('https://api.example.com/data?token=tok_private99');
|
||||
expect(sanitizeError(err)).not.toContain('tok_private99');
|
||||
});
|
||||
|
||||
it('redacts ?password= values', () => {
|
||||
const err = new Error('Auth failed: https://service.local?password=hunter2');
|
||||
expect(sanitizeError(err)).not.toContain('hunter2');
|
||||
});
|
||||
|
||||
it('redacts ?api_key= values', () => {
|
||||
const err = new Error('https://sonarr.local/api/v3/series?api_key=e583d270f89846478e42');
|
||||
expect(sanitizeError(err)).not.toContain('e583d270f89846478e42');
|
||||
});
|
||||
|
||||
it('preserves non-secret query params', () => {
|
||||
const result = sanitizeError(new Error('GET /api?mode=queue&output=json'));
|
||||
expect(result).toContain('mode=queue');
|
||||
expect(result).toContain('output=json');
|
||||
});
|
||||
});
|
||||
|
||||
describe('HTTP auth headers', () => {
|
||||
it('redacts X-Api-Key header values', () => {
|
||||
const err = new Error('Request: x-api-key: e583d270f89846478e42dd3cf90bfb00');
|
||||
expect(sanitizeError(err)).not.toContain('e583d270f89846478e42dd3cf90bfb00');
|
||||
expect(sanitizeError(err)).toContain('[REDACTED]');
|
||||
});
|
||||
|
||||
it('redacts X-MediaBrowser-Token header values', () => {
|
||||
const err = new Error('x-mediabrowser-token: b862f3a43f4c417285043a11aa28b1f7');
|
||||
expect(sanitizeError(err)).not.toContain('b862f3a43f4c417285043a11aa28b1f7');
|
||||
});
|
||||
|
||||
it('redacts Authorization header values', () => {
|
||||
const err = new Error('authorization: MediaBrowser Token="abc123", DeviceId="xyz"');
|
||||
expect(sanitizeError(err)).not.toContain('abc123');
|
||||
});
|
||||
});
|
||||
|
||||
describe('bearer tokens', () => {
|
||||
it('redacts Bearer token values', () => {
|
||||
const err = new Error('Error: bearer eyJhbGciOiJIUzI1NiJ9.payload.sig');
|
||||
expect(sanitizeError(err)).not.toContain('eyJhbGciOiJIUzI1NiJ9');
|
||||
expect(sanitizeError(err)).toContain('bearer [REDACTED]');
|
||||
});
|
||||
|
||||
it('is case-insensitive for BEARER', () => {
|
||||
const err = new Error('BEARER TOKEN_VALUE_HERE');
|
||||
expect(sanitizeError(err)).not.toContain('TOKEN_VALUE_HERE');
|
||||
});
|
||||
});
|
||||
|
||||
describe('basic-auth URLs', () => {
|
||||
it('redacts user:pass@ in URLs', () => {
|
||||
const err = new Error('GET http://admin:b053288369XX!@qbittorrent.local/api');
|
||||
expect(sanitizeError(err)).not.toContain('b053288369XX!');
|
||||
expect(sanitizeError(err)).not.toContain('admin:');
|
||||
expect(sanitizeError(err)).toContain('//[REDACTED]@');
|
||||
});
|
||||
|
||||
it('handles https:// basic auth', () => {
|
||||
const err = new Error('https://user:s3cr3t@service.local/path');
|
||||
expect(sanitizeError(err)).not.toContain('s3cr3t');
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('handles non-Error input (plain string)', () => {
|
||||
const result = sanitizeError('plain string error');
|
||||
expect(typeof result).toBe('string');
|
||||
});
|
||||
|
||||
it('handles null gracefully', () => {
|
||||
expect(() => sanitizeError(null)).not.toThrow();
|
||||
});
|
||||
|
||||
it('handles undefined gracefully', () => {
|
||||
expect(() => sanitizeError(undefined)).not.toThrow();
|
||||
});
|
||||
|
||||
it('preserves non-sensitive error messages unchanged', () => {
|
||||
const err = new Error('Connection refused: ECONNREFUSED 127.0.0.1:8080');
|
||||
const result = sanitizeError(err);
|
||||
expect(result).toContain('ECONNREFUSED');
|
||||
expect(result).toContain('127.0.0.1:8080');
|
||||
});
|
||||
|
||||
it('does not leak stack traces (returns message only)', () => {
|
||||
const err = new Error('something went wrong');
|
||||
const result = sanitizeError(err);
|
||||
expect(result).not.toContain('at ');
|
||||
expect(result).not.toContain('.js:');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* Tests for server/utils/tokenStore.js
|
||||
*
|
||||
* The token store persists Emby access tokens to disk (JSON file) so users
|
||||
* survive server restarts without re-logging in. Tests verify the store/get/
|
||||
* clear lifecycle, TTL expiry, and atomic write behaviour.
|
||||
*
|
||||
* Each test imports a FRESH module instance (vi.resetModules) so the
|
||||
* module-level singleton state (loaded from disk) doesn't bleed between tests.
|
||||
*/
|
||||
|
||||
import { vi } from 'vitest';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
|
||||
// Each test gets its own isolated temp dir
|
||||
let tmpDir;
|
||||
let tokenStore;
|
||||
|
||||
async function freshStore(dir) {
|
||||
vi.resetModules();
|
||||
process.env.DATA_DIR = dir;
|
||||
const mod = await import('../../server/utils/tokenStore.js');
|
||||
return mod;
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'sofarr-ts-'));
|
||||
tokenStore = await freshStore(tmpDir);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('tokenStore', () => {
|
||||
it('stores and retrieves a token', () => {
|
||||
tokenStore.storeToken('user1', 'access-token-abc');
|
||||
const result = tokenStore.getToken('user1');
|
||||
expect(result).not.toBeNull();
|
||||
expect(result.accessToken).toBe('access-token-abc');
|
||||
});
|
||||
|
||||
it('returns null for an unknown user', () => {
|
||||
expect(tokenStore.getToken('nobody')).toBeNull();
|
||||
});
|
||||
|
||||
it('clears a stored token', () => {
|
||||
tokenStore.storeToken('user1', 'token-xyz');
|
||||
tokenStore.clearToken('user1');
|
||||
expect(tokenStore.getToken('user1')).toBeNull();
|
||||
});
|
||||
|
||||
it('clearToken is a no-op for unknown user', () => {
|
||||
expect(() => tokenStore.clearToken('ghost')).not.toThrow();
|
||||
});
|
||||
|
||||
it('overwrites existing token on re-store', () => {
|
||||
tokenStore.storeToken('user1', 'old-token');
|
||||
tokenStore.storeToken('user1', 'new-token');
|
||||
expect(tokenStore.getToken('user1').accessToken).toBe('new-token');
|
||||
});
|
||||
|
||||
it('persists to disk (tokens.json exists after store)', () => {
|
||||
tokenStore.storeToken('u1', 'tok');
|
||||
const storePath = path.join(tmpDir, 'tokens.json');
|
||||
expect(fs.existsSync(storePath)).toBe(true);
|
||||
const data = JSON.parse(fs.readFileSync(storePath, 'utf8'));
|
||||
expect(data.u1.accessToken).toBe('tok');
|
||||
});
|
||||
|
||||
it('expires tokens older than 31 days on read', () => {
|
||||
// Write an already-expired entry directly to disk
|
||||
const expired = Date.now() - (32 * 24 * 60 * 60 * 1000);
|
||||
const storePath = path.join(tmpDir, 'tokens.json');
|
||||
fs.writeFileSync(storePath, JSON.stringify({ u1: { accessToken: 'old', createdAt: expired } }));
|
||||
// Re-import to load from disk
|
||||
vi.resetModules();
|
||||
return import('../../server/utils/tokenStore.js').then(mod => {
|
||||
expect(mod.getToken('u1')).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* Tests for server/middleware/verifyCsrf.js
|
||||
*
|
||||
* CSRF protection via the double-submit cookie pattern. These tests verify
|
||||
* that the timing-safe comparison works correctly and that safe HTTP methods
|
||||
* are correctly exempted.
|
||||
*/
|
||||
|
||||
import verifyCsrf from '../../server/middleware/verifyCsrf.js';
|
||||
|
||||
function makeReq(method, cookieToken, headerToken) {
|
||||
return {
|
||||
method,
|
||||
cookies: { csrf_token: cookieToken },
|
||||
headers: { 'x-csrf-token': headerToken }
|
||||
};
|
||||
}
|
||||
|
||||
function makeRes() {
|
||||
const res = {
|
||||
statusCode: null,
|
||||
body: null,
|
||||
status(code) { this.statusCode = code; return this; },
|
||||
json(body) { this.body = body; return this; }
|
||||
};
|
||||
return res;
|
||||
}
|
||||
|
||||
describe('verifyCsrf middleware', () => {
|
||||
describe('safe methods are exempted', () => {
|
||||
for (const method of ['GET', 'HEAD', 'OPTIONS']) {
|
||||
it(`allows ${method} with no CSRF token`, () => {
|
||||
const next = vi.fn();
|
||||
verifyCsrf(makeReq(method, undefined, undefined), makeRes(), next);
|
||||
expect(next).toHaveBeenCalledOnce();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe('mutating methods require valid token', () => {
|
||||
const TOKEN = 'a'.repeat(64); // 64 hex chars = 32 bytes
|
||||
|
||||
for (const method of ['POST', 'PUT', 'PATCH', 'DELETE']) {
|
||||
it(`allows ${method} with matching tokens`, () => {
|
||||
const next = vi.fn();
|
||||
const res = makeRes();
|
||||
verifyCsrf(makeReq(method, TOKEN, TOKEN), res, next);
|
||||
expect(next).toHaveBeenCalledOnce();
|
||||
expect(res.statusCode).toBeNull();
|
||||
});
|
||||
|
||||
it(`blocks ${method} with mismatched tokens`, () => {
|
||||
const next = vi.fn();
|
||||
const res = makeRes();
|
||||
verifyCsrf(makeReq(method, TOKEN, TOKEN.replace('a', 'b')), res, next);
|
||||
expect(res.statusCode).toBe(403);
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it(`blocks ${method} with missing cookie token`, () => {
|
||||
const next = vi.fn();
|
||||
const res = makeRes();
|
||||
verifyCsrf(makeReq(method, undefined, TOKEN), res, next);
|
||||
expect(res.statusCode).toBe(403);
|
||||
expect(res.body.error).toBe('CSRF token missing');
|
||||
});
|
||||
|
||||
it(`blocks ${method} with missing header token`, () => {
|
||||
const next = vi.fn();
|
||||
const res = makeRes();
|
||||
verifyCsrf(makeReq(method, TOKEN, undefined), res, next);
|
||||
expect(res.statusCode).toBe(403);
|
||||
});
|
||||
}
|
||||
|
||||
it('blocks when tokens have different lengths (timing-safe path)', () => {
|
||||
const next = vi.fn();
|
||||
const res = makeRes();
|
||||
verifyCsrf(makeReq('POST', 'short', 'much-longer-token-here'), res, next);
|
||||
expect(res.statusCode).toBe(403);
|
||||
expect(res.body.error).toBe('CSRF token invalid');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
// Node environment for all tests (server-side CJS modules, no browser APIs needed)
|
||||
environment: 'node',
|
||||
// Global test helpers (describe, it, expect, vi) without per-file imports
|
||||
globals: true,
|
||||
// Run each test file in an isolated module registry so module-level state
|
||||
// (tokenStore cache, config singletons) doesn't leak between files
|
||||
isolate: true,
|
||||
// Give each file its own data directory so tokenStore file I/O doesn't collide
|
||||
setupFiles: ['./tests/setup.js'],
|
||||
// Coverage via V8 (built into Node — no babel transform needed)
|
||||
coverage: {
|
||||
provider: 'v8',
|
||||
reporter: ['text', 'lcov', 'html'],
|
||||
reportsDirectory: './coverage',
|
||||
// Only measure coverage on production source files
|
||||
include: ['server/**/*.js'],
|
||||
exclude: [
|
||||
'server/index.js', // entry point with side-effects (process.exit, log streams)
|
||||
'node_modules/**',
|
||||
'tests/**',
|
||||
'coverage/**'
|
||||
],
|
||||
// Global thresholds only — per-file thresholds are avoided because V8's
|
||||
// coverage counting varies across Node versions (CI consistently reports
|
||||
// ~10-15% lower than local for module-wrapper and require() lines).
|
||||
// The overall numbers reflect that dashboard.js and poller.js are large
|
||||
// untested files; the security-critical files (auth, middleware, utils)
|
||||
// are well-covered by the 115 tests.
|
||||
thresholds: {
|
||||
lines: 22,
|
||||
functions: 12,
|
||||
branches: 8,
|
||||
statements: 20
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||