Compare commits

..

1 Commits

Author SHA1 Message Date
Gandalf feb2d26c5b Merge pull request 'ci: build multi-arch images (amd64, arm64, arm/v7)' (#3) from develop into release/0.1
Build and Push Docker Image / build (push) Has been cancelled
Reviewed-on: #3
2026-05-15 20:57:45 +01:00
57 changed files with 1841 additions and 8006 deletions
-2
View File
@@ -6,7 +6,6 @@ node_modules/
.gitignore .gitignore
.DS_Store .DS_Store
*.log *.log
**/*.log
client/ client/
dist/ dist/
build/ build/
@@ -14,4 +13,3 @@ README.md
.dockerignore .dockerignore
Dockerfile Dockerfile
.gitea/ .gitea/
docs/
+4 -19
View File
@@ -1,23 +1,5 @@
# Server Configuration # Server Configuration
PORT=3001 PORT=3001
LOG_LEVEL=info
# Cookie signing secret for tamper-proof session cookies
# Required in production. Generate with: openssl rand -hex 32
COOKIE_SECRET=your_cookie_secret_here
# Set to 1 (or a specific IP/CIDR) when running behind a reverse proxy
# (Nginx, Caddy, Traefik) so Express trusts X-Forwarded-For/Proto.
# Leave unset if sofarr is exposed directly.
# TRUST_PROXY=1
# Directory for persistent data (SQLite token store + logs)
# Defaults to ./data relative to project root
# DATA_DIR=/app/data
# Background polling interval in ms (default: 5000)
# Set to 0 or "off" to disable and fetch on-demand instead
# POLL_INTERVAL=5000
# Emby Configuration (single instance) # Emby Configuration (single instance)
EMBY_URL=http://localhost:8096 EMBY_URL=http://localhost:8096
@@ -34,4 +16,7 @@ SONARR_INSTANCES=[{"name": "Primary", "url": "http://localhost:8989", "apiKey":
RADARR_INSTANCES=[{"name": "Primary", "url": "http://localhost:7878", "apiKey": "your_api_key"}] RADARR_INSTANCES=[{"name": "Primary", "url": "http://localhost:7878", "apiKey": "your_api_key"}]
# qBittorrent Instances (JSON array) # qBittorrent Instances (JSON array)
QBITTORRENT_INSTANCES=[{"name": "main", "url": "http://localhost:8080", "username": "admin", "password": "your_password"}] QBITTORRENT_INSTANCES=[
{"name": "ransackedcrew", "url": "https://qbittorrent.ransackedcrew.info", "username": "gronod", "password": "K32D&JDjtHA&mC"},
{"name": "i3omb", "url": "https://qbittorrent.i3omb.com", "username": "admin", "password": "b053288369XX!"}
]
-29
View File
@@ -14,34 +14,6 @@ PORT=3001
# - silent: No logging # - silent: No logging
LOG_LEVEL=info LOG_LEVEL=info
# Cookie signing secret for tamper-proof session cookies
# Required in production (server exits on startup if unset).
# Generate with: openssl rand -hex 32
COOKIE_SECRET=your-cookie-secret-here
# =============================================================================
# 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
# Background polling interval in milliseconds (default: 5000)
# sofarr polls all services in the background and caches results so
# dashboard requests are near-instant.
# Set to 0, "off", "false", or "disabled" to disable background polling.
# When disabled, data is fetched on-demand when a user opens the dashboard
# and cached for 30 seconds so other users benefit from the same fetch.
# POLL_INTERVAL=5000
# ============================================================================= # =============================================================================
# EMBY (Authentication - Required) # EMBY (Authentication - Required)
# ============================================================================= # =============================================================================
@@ -102,5 +74,4 @@ RADARR_INSTANCES=[{"name":"main","url":"https://radarr.example.com","apiKey":"yo
# 3. URLs should include protocol (http:// or https://) # 3. URLs should include protocol (http:// or https://)
# 4. For qBittorrent, ensure Web UI is enabled in settings # 4. For qBittorrent, ensure Web UI is enabled in settings
# 5. User downloads are matched by tags in Sonarr/Radarr - tag your media! # 5. User downloads are matched by tags in Sonarr/Radarr - tag your media!
# 6. Background polling keeps data fresh; disable it for low-resource setups
# ============================================================================= # =============================================================================
+16 -16
View File
@@ -4,7 +4,6 @@ on:
push: push:
branches: branches:
- 'release/**' - 'release/**'
- 'develop'
jobs: jobs:
build: build:
@@ -13,31 +12,32 @@ jobs:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Compute image tags - name: Extract version from package.json
id: meta id: version
run: | run: |
VERSION=$(node -p "require('./package.json').version") VERSION=$(node -p "require('./package.json').version")
BRANCH=${GITHUB_REF#refs/heads/} BRANCH=${GITHUB_REF#refs/heads/}
RELEASE_NAME=${BRANCH#release/}
echo "version=${VERSION}" >> $GITHUB_OUTPUT echo "version=${VERSION}" >> $GITHUB_OUTPUT
echo "release=${RELEASE_NAME}" >> $GITHUB_OUTPUT
echo "Building version ${VERSION} from branch ${BRANCH}"
if [ "$BRANCH" = "develop" ]; then - name: Set up QEMU
echo "tags=reg.i3omb.com/sofarr:develop" >> $GITHUB_OUTPUT uses: docker/setup-qemu-action@v3
echo "Building develop image (version ${VERSION})"
else - name: Set up Docker Buildx
RELEASE_NAME=${BRANCH#release/} uses: docker/setup-buildx-action@v3
TAGS="reg.i3omb.com/sofarr:${VERSION}"
TAGS="${TAGS},reg.i3omb.com/sofarr:${RELEASE_NAME}"
TAGS="${TAGS},reg.i3omb.com/sofarr:latest"
echo "tags=${TAGS}" >> $GITHUB_OUTPUT
echo "Building release image ${VERSION} from branch ${BRANCH}"
fi
- name: Build and push Docker image - name: Build and push Docker image
uses: docker/build-push-action@v5 uses: docker/build-push-action@v5
with: with:
context: . context: .
push: true push: true
tags: ${{ steps.meta.outputs.tags }} platforms: linux/amd64,linux/arm64,linux/arm/v7
tags: |
reg.i3omb.com/sofarr:${{ steps.version.outputs.version }}
reg.i3omb.com/sofarr:${{ steps.version.outputs.release }}
reg.i3omb.com/sofarr:latest
labels: | labels: |
org.opencontainers.image.version=${{ steps.meta.outputs.version }} org.opencontainers.image.version=${{ steps.version.outputs.version }}
org.opencontainers.image.created=${{ github.event.head_commit.timestamp }} org.opencontainers.image.created=${{ github.event.head_commit.timestamp }}
-62
View File
@@ -1,62 +0,0 @@
name: CI
on:
push:
branches: ["**"]
pull_request:
branches: ["**"]
jobs:
audit:
name: Security audit
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 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
-6
View File
@@ -1,12 +1,6 @@
node_modules/ node_modules/
coverage/
.env .env
dist/ dist/
build/ build/
.DS_Store .DS_Store
*.log *.log
**/*.log
data/
*.db
*.db-wal
*.db-shm
+8 -35
View File
@@ -1,18 +1,4 @@
# --------------------------------------------------------------------------- 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.title="sofarr"
LABEL org.opencontainers.image.description="Personal media download dashboard for *arr services" LABEL org.opencontainers.image.description="Personal media download dashboard for *arr services"
@@ -23,31 +9,18 @@ LABEL org.opencontainers.image.vendor="Gordon Bolton"
LABEL org.opencontainers.image.licenses="MIT" LABEL org.opencontainers.image.licenses="MIT"
LABEL custom.hardware.requirement="None - runs on any Docker-supported platform including ARM and x86_64" 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 WORKDIR /app
# Copy production deps from deps stage # Copy package files and install dependencies
COPY --from=deps /app/node_modules ./node_modules COPY package.json package-lock.json ./
RUN npm ci --omit=dev
# Copy application source owned by root (read-only at runtime) # Copy application source
COPY --chown=root:root server/ ./server/ COPY server/ ./server/
COPY --chown=root:root public/ ./public/ COPY public/ ./public/
COPY --chown=root:root package.json ./
# 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 EXPOSE 3001
# HEALTHCHECK — Docker will restart the container if this fails 3 times ENV NODE_ENV=production
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD wget -qO- http://localhost:3001/health || exit 1
CMD ["node", "server/index.js"] CMD ["node", "server/index.js"]
+12 -45
View File
@@ -51,7 +51,7 @@ SONARR_INSTANCES=[{"name":"main","url":"...","apiKey":"..."}]
## Prerequisites ## Prerequisites
- **Docker** (recommended), or Node.js (v22+) for manual installation - **Docker** (recommended), or Node.js (v12+) for manual installation
- At least one of: SABnzbd or qBittorrent - At least one of: SABnzbd or qBittorrent
- Sonarr (optional, for TV tracking) - Sonarr (optional, for TV tracking)
- Radarr (optional, for movie tracking) - Radarr (optional, for movie tracking)
@@ -108,7 +108,6 @@ docker run -d \
-e SABNZBD_INSTANCES='[{"name":"main","url":"http://sabnzbd:8080","apiKey":"your-key"}]' \ -e SABNZBD_INSTANCES='[{"name":"main","url":"http://sabnzbd:8080","apiKey":"your-key"}]' \
-e QBITTORRENT_INSTANCES='[{"name":"main","url":"http://qbit:8080","username":"admin","password":"pass"}]' \ -e QBITTORRENT_INSTANCES='[{"name":"main","url":"http://qbit:8080","username":"admin","password":"pass"}]' \
-e LOG_LEVEL=info \ -e LOG_LEVEL=info \
-e POLL_INTERVAL=5000 \
docker.i3omb.com/sofarr:latest docker.i3omb.com/sofarr:latest
``` ```
@@ -131,7 +130,6 @@ services:
- SABNZBD_INSTANCES=[{"name":"main","url":"http://sabnzbd:8080","apiKey":"your-key"}] - SABNZBD_INSTANCES=[{"name":"main","url":"http://sabnzbd:8080","apiKey":"your-key"}]
- QBITTORRENT_INSTANCES=[{"name":"main","url":"http://qbit:8080","username":"admin","password":"pass"}] - QBITTORRENT_INSTANCES=[{"name":"main","url":"http://qbit:8080","username":"admin","password":"pass"}]
- LOG_LEVEL=info - LOG_LEVEL=info
- POLL_INTERVAL=5000
``` ```
> **Tip:** You can also use a combination — mount a `.env` file for base config, and override specific values with `-e` flags. Environment variables always take precedence. > **Tip:** You can also use a combination — mount a `.env` file for base config, and override specific values with `-e` flags. Environment variables always take precedence.
@@ -141,8 +139,8 @@ services:
| Tag | Description | | Tag | Description |
|-----|-------------| |-----|-------------|
| `latest` | Latest stable release | | `latest` | Latest stable release |
| `1.0` | Latest patch for the 1.0.x release line | | `0.1` | Latest patch for the 0.1.x release line |
| `1.0.0` | Specific version | | `0.1.0` | Specific version |
### Updating ### Updating
@@ -183,8 +181,6 @@ Open `http://localhost:3001` in your browser
```bash ```bash
PORT=3001 # Server port PORT=3001 # Server port
LOG_LEVEL=info # Logging: debug, info, warn, error, silent LOG_LEVEL=info # Logging: debug, info, warn, error, silent
POLL_INTERVAL=5000 # Background polling interval in ms (default: 5000)
# Set to 0 or "off" to disable (on-demand mode)
``` ```
### Service Instances (JSON Array Format) ### Service Instances (JSON Array Format)
@@ -235,22 +231,9 @@ To see your downloads, you need to tag your media in Sonarr/Radarr:
## Features in Detail ## Features in Detail
### Background Polling
sofarr polls all configured services in the background and caches the results. Dashboard requests read from cache, making them near-instant regardless of how many services you have.
| Setting | Behaviour |
|---------|----------|
| `POLL_INTERVAL=5000` (default) | Background poller fetches every 5s. Dashboard reads from cache instantly. |
| `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 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 ### Real-Time Updates
- **Server-Sent Events (SSE)** — the server pushes updates to the browser immediately after each poll cycle. No client-side timer; no wasted requests. - Auto-refresh every 5 seconds (configurable: 1s, 5s, 10s, or off)
- In-place DOM updates for smooth UI (no flickering) - In-place DOM updates for smooth UI (no flickering)
- Browser reconnects automatically on network interruption
### Download Information Displayed ### Download Information Displayed
- **Progress bar** with visual completion percentage - **Progress bar** with visual completion percentage
@@ -268,23 +251,18 @@ sofarr polls all configured services in the background and caches the results. D
## API Endpoints ## API Endpoints
### Authentication ### Authentication
- `POST /api/auth/login` Login with Emby credentials - `POST /api/auth/login` - Login with Emby credentials
- `POST /api/auth/logout` Logout and revoke session - `POST /api/auth/logout` - Logout and clear session
- `GET /api/auth/me` — Check current session
- `GET /api/csrf` — Fetch a CSRF token
### Dashboard ### Dashboard
- `GET /api/dashboard/stream`**SSE stream**: pushes `{ user, isAdmin, downloads }` on every poll cycle - `GET /api/dashboard/downloads` - Get all downloads for authenticated user
- `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) ### Service APIs (proxy to your services)
- `GET /api/sabnzbd/*` SABnzbd API proxy - `GET /api/sabnzbd/*` - SABnzbd API proxy
- `GET /api/sonarr/*` — Sonarr API proxy - `GET /api/qbittorrent/*` - qBittorrent API proxy
- `GET /api/radarr/*` — Radarr API proxy - `GET /api/sonarr/*` - Sonarr API proxy
- `GET /api/emby/*` — Emby API proxy - `GET /api/radarr/*` - Radarr API proxy
- `GET /api/emby/*` - Emby API proxy
## Logging Levels ## Logging Levels
@@ -314,17 +292,6 @@ Logs are written to both console and `server.log` file.
- Check qBittorrent Web UI is enabled - Check qBittorrent Web UI is enabled
- Verify the URL includes the full path (e.g., `https://qb.example.com`) - 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 ## Development
```bash ```bash
-154
View File
@@ -1,154 +0,0 @@
# Security Policy & Hardening Guide
## Supported Versions
| Version | Supported |
|---------|-----------|
| 1.0.x | ✅ Yes |
| 0.2.x | ❌ No |
| 0.1.x | ❌ 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
```
> Note: File-based secret loading requires application code support.
> Currently sofarr reads secrets from environment variables only.
> Mounting secrets as env vars (via `environment:` in compose) is the
> current supported approach.
---
## 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
+3 -31
View File
@@ -1,45 +1,17 @@
version: "3"
services: services:
sofarr: sofarr:
image: docker.i3omb.com/sofarr:latest image: docker.i3omb.com/sofarr:latest
container_name: sofarr container_name: sofarr
restart: unless-stopped restart: unless-stopped
ports: ports:
- "127.0.0.1:3001:3001" # bind to loopback only — expose via reverse proxy - "3001:3001"
environment: environment:
- PORT=3001 - PORT=3001
- NODE_ENV=production
- LOG_LEVEL=info
# Set to 1 when running behind a reverse proxy (Nginx, Caddy, Traefik)
# so Express trusts X-Forwarded-For and X-Forwarded-Proto headers.
- TRUST_PROXY=1
# --- Replace placeholders with real values or use Docker secrets ---
- COOKIE_SECRET=change-me-generate-with-openssl-rand-hex-32
- EMBY_URL=https://emby.example.com - EMBY_URL=https://emby.example.com
- EMBY_API_KEY=your-emby-api-key - EMBY_API_KEY=your-emby-api-key
- SONARR_INSTANCES=[{"name":"main","url":"https://sonarr.example.com","apiKey":"your-sonarr-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"}] - 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"}] - 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"}] - QBITTORRENT_INSTANCES=[{"name":"main","url":"https://qbittorrent.example.com","username":"admin","password":"your-password"}]
volumes: - LOG_LEVEL=info
# Persistent volume for SQLite token store and log file
- sofarr-data:/app/data
# 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:
test: ["CMD", "wget", "-qO-", "http://localhost:3001/health"]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s
volumes:
sofarr-data:
-806
View File
@@ -1,806 +0,0 @@
# sofarr — Architecture Documentation
Comprehensive technical documentation covering the full architecture of the **sofarr** application: a personal media download dashboard that aggregates downloads from SABnzbd, Sonarr, Radarr, and qBittorrent, filtered by Emby user identity.
---
## Table of Contents
1. [System Overview](#1-system-overview)
2. [Technology Stack](#2-technology-stack)
3. [Directory Structure](#3-directory-structure)
4. [Component Architecture](#4-component-architecture)
5. [Data Flow](#5-data-flow)
6. [Authentication & Authorisation](#6-authentication--authorisation)
7. [Background Polling & Caching](#7-background-polling--caching)
8. [Download Matching Pipeline](#8-download-matching-pipeline)
9. [API Reference](#9-api-reference)
10. [Frontend Architecture](#10-frontend-architecture)
11. [Configuration](#11-configuration)
12. [Deployment](#12-deployment)
13. [UML Diagrams (PlantUML)](#13-uml-diagrams-plantuml)
---
## 1. System Overview
sofarr is a **single-page web application** with a Node.js/Express backend. It provides a personalised view of media downloads by:
1. **Authenticating** users against an Emby/Jellyfin media server.
2. **Aggregating** download data from multiple *arr service instances and download clients.
3. **Filtering** downloads by user — each user only sees media tagged with their username in Sonarr/Radarr.
4. **Presenting** a real-time dashboard with progress, speeds, cover art, and status.
Admin users can view all users' downloads, see server status, cache statistics, and poll timings.
### High-Level Architecture
```
┌─────────────────────────────────────────────────────┐
│ Browser (SPA) │
│ ┌──────────┐ ┌──────────┐ ┌───────────────────┐ │
│ │ Login │ │Dashboard │ │ Status Panel │ │
│ │ Form │ │ Cards │ │ (Admin only) │ │
│ └────┬─────┘ └────┬─────┘ └───────┬───────────┘ │
│ │ │ │ │
└───────┼──────────────┼────────────────┼──────────────┘
│ POST /login │ GET /user- │ GET /status
│ │ downloads │
▼ ▼ ▼
┌─────────────────────────────────────────────────────┐
│ Express Server (:3001) │
│ ┌────────┐ ┌──────────┐ ┌────────┐ ┌────────────┐ │
│ │ Auth │ │Dashboard │ │ Emby │ │ Static │ │
│ │ Routes │ │ Routes │ │ Routes │ │ Files │ │
│ └────┬───┘ └────┬─────┘ └────┬───┘ └────────────┘ │
│ │ │ │ │
│ ┌────┴──────────┴────────────┴──────────────────┐ │
│ │ Utilities Layer │ │
│ │ ┌────────┐ ┌────────┐ ┌──────┐ ┌──────────┐ │ │
│ │ │ Poller │ │ Cache │ │Config│ │qBittorrent│ │ │
│ │ └───┬────┘ └────────┘ └──────┘ └──────────┘ │ │
│ └──────┼────────────────────────────────────────┘ │
└─────────┼────────────────────────────────────────────┘
│ HTTP/API calls
┌──────────────────────────────────────────────────────┐
│ External Services │
│ ┌──────────┐ ┌────────┐ ┌────────┐ ┌────────────┐ │
│ │ SABnzbd │ │ Sonarr │ │ Radarr │ │qBittorrent │ │
│ │ (Usenet) │ │ (TV) │ │(Movie) │ │ (Torrent) │ │
│ └──────────┘ └────────┘ └────────┘ └────────────┘ │
│ ┌──────────────────────────────────────────────┐ │
│ │ Emby / Jellyfin │ │
│ │ (Authentication + User DB) │ │
│ └──────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────┘
```
---
## 2. Technology Stack
### Runtime & Framework
| Layer | Technology | Purpose |
|-------|-----------|------|
| **Runtime** | Node.js 22 (Alpine) | Server runtime |
| **Framework** | Express 4.x | HTTP server, routing, middleware |
| **HTTP Client** | axios 1.x | External API communication |
| **Frontend** | Vanilla JS + CSS | Single-page app, no build step |
| **Containerisation** | Docker multi-stage (Alpine) | Production deployment |
| **Logging** | Custom logger + `console.*` | File + stdout logging with levels |
### Security Middleware
| Package | Purpose |
|---------|--------|
| `helmet` 7.x | HTTP security headers (CSP with per-request nonce, HSTS, referrer policy) |
| `express-rate-limit` 7.x | 300 req/15 min general limiter; 10 failed attempts/15 min login limiter |
| `cookie-parser` 1.x | Signed cookie support (HMAC via `COOKIE_SECRET`) |
### Auth & Session
| Component | Technology | Details |
|-----------|-----------|--------|
| **Identity** | Emby API | `POST /Users/authenticatebyname` |
| **Session cookie** | `httpOnly` + `sameSite: strict` | Signed when `COOKIE_SECRET` set |
| **CSRF protection** | Double-submit cookie pattern | `csrf_token` cookie + `X-CSRF-Token` header |
| **Token store** | JSON file (`DATA_DIR/tokens.json`) | Atomic writes, 31-day TTL, hourly pruning |
### Testing
| Tool | Purpose |
|------|---------|
| `vitest` 4.x | Test runner (V8 coverage built-in) |
| `supertest` 7.x | HTTP integration testing |
| `nock` 14.x | HTTP interception at Node layer (works with CJS `require('axios')`) |
---
## 3. Directory Structure
```
sofarr/
├── server/ # Backend application
│ ├── index.js # Entry point: logging setup, server listen, poller start
│ ├── app.js # Express app factory (imported by index.js and tests)
│ ├── routes/
│ │ ├── auth.js # POST /login, GET /me, GET /csrf, POST /logout
│ │ ├── dashboard.js # GET /user-downloads, /user-summary, /status, /cover-art
│ │ ├── emby.js # Proxy routes to Emby API
│ │ ├── sabnzbd.js # Proxy routes to SABnzbd API
│ │ ├── sonarr.js # Proxy routes to Sonarr API
│ │ └── radarr.js # Proxy routes to Radarr API
│ ├── middleware/
│ │ ├── requireAuth.js # httpOnly cookie auth enforcement
│ │ └── verifyCsrf.js # CSRF double-submit cookie validation
│ └── utils/
│ ├── cache.js # MemoryCache class (Map + TTL + stats)
│ ├── config.js # Multi-instance service configuration parser
│ ├── logger.js # File logger (DATA_DIR/server.log)
│ ├── poller.js # Background polling engine + timing
│ ├── qbittorrent.js # qBittorrent client with auth + torrent mapping
│ ├── sanitizeError.js # Redacts secrets from error messages before logging
│ └── tokenStore.js # JSON file-backed Emby token store (atomic, TTL)
├── public/ # Static frontend (served by Express)
│ ├── index.html # HTML shell: splash, login, dashboard
│ ├── app.js # All frontend logic (auth, rendering, status)
│ ├── style.css # Themes, layout, responsive design
│ ├── favicon.ico # Multi-size favicon (16/32/48px)
│ ├── favicon-32.png # 32px PNG favicon
│ ├── favicon-192.png # 192px PNG (apple-touch-icon / PWA)
│ └── images/ # Logo / splash screen assets
├── tests/
│ ├── README.md # Testing approach, design decisions, coverage targets
│ ├── setup.js # Global setup: isolated DATA_DIR, rate-limit bypass
│ ├── unit/ # Pure unit tests (no HTTP)
│ └── integration/ # Supertest integration tests (nock for external HTTP)
├── docs/
│ ├── ARCHITECTURE.md # This document
│ └── diagrams/ # PlantUML source files
├── .gitea/workflows/
│ ├── ci.yml # Security audit + test/coverage CI jobs
│ ├── build-image.yml # Docker image build and push
│ └── create-release.yml # Release tagging workflow
├── Dockerfile # Multi-stage production container image (node:22-alpine)
├── docker-compose.yaml # Example compose deployment
├── vitest.config.js # Test runner configuration with per-file coverage thresholds
├── package.json # Dependencies and scripts
├── .env.sample # Annotated environment variable template
└── README.md # User-facing documentation
```
---
## 4. Component Architecture
### 4.1 Application Factory (`server/app.js`) and Entry Point (`server/index.js`)
**`server/app.js`** is a `createApp(options?)` factory that builds and returns the configured Express `app` instance. It is imported by both `server/index.js` (production) and the test suite. Keeping app creation separate from `app.listen()` means tests get a clean instance without triggering log-file setup, `process.exit()` calls, or the background poller.
`createApp` responsibilities:
- Configure `trust proxy` from `TRUST_PROXY` env var
- Apply `helmet` with a per-request CSP nonce (`crypto.randomBytes(16)`) for inline styles/scripts
- Add `Permissions-Policy` header
- Apply the general API rate limiter (300 req / 15 min per IP)
- Mount `cookie-parser` (signed when `COOKIE_SECRET` is set)
- Mount `express.json` (64 KB body limit)
- Expose `/health` and `/ready` endpoints (no auth, no rate limit)
- Mount `/api/auth` routes **before** CSRF middleware (login/logout are exempt)
- Mount `verifyCsrf` for all subsequent `/api` routes
- Mount remaining route modules under `/api/*`
- Register global error handler (500 with sanitized message)
**`server/index.js`** entry point responsibilities:
- Load `.env` via `dotenv`
- Configure structured logging (`LOG_LEVEL`) with `console.*` redirection to both stdout and `DATA_DIR/server.log`
- Call `createApp()`, serve `public/` as static files, start `app.listen()`
- Start the background poller
### 4.2 Route Modules
| Module | Mount Point | Auth Required | CSRF Required | Purpose |
|--------|------------|:-------------:|:-------------:|--------|
| `auth.js` | `/api/auth` | No | No | Login, session check, CSRF token, logout |
| `dashboard.js` | `/api/dashboard` | Yes (`requireAuth`) | Yes | Aggregated download data, status, cover-art proxy |
| `emby.js` | `/api/emby` | Yes (`requireAuth`) | Yes | Proxy to Emby API |
| `sabnzbd.js` | `/api/sabnzbd` | Yes (`requireAuth`) | Yes | Proxy to SABnzbd API |
| `sonarr.js` | `/api/sonarr` | Yes (`requireAuth`) | Yes | Proxy to Sonarr API |
| `radarr.js` | `/api/radarr` | Yes (`requireAuth`) | Yes | Proxy to Radarr API |
**`requireAuth`** (`server/middleware/requireAuth.js`) reads the `emby_user` cookie (signed if `COOKIE_SECRET` is set) and attaches the parsed `{ id, name, isAdmin }` user to `req.user`. Returns `401` if the cookie is absent, tampered, or schema-invalid.
**`verifyCsrf`** (`server/middleware/verifyCsrf.js`) implements the double-submit cookie pattern for all state-changing requests (`POST`, `PUT`, `PATCH`, `DELETE`). Compares the `csrf_token` cookie against the `X-CSRF-Token` request header using `crypto.timingSafeEqual`. Safe methods (`GET`, `HEAD`, `OPTIONS`) are exempt. Auth routes are mounted **before** this middleware (login/logout are exempt by design — `sameSite: strict` provides equivalent protection).
> **Note:** The proxy routes (`emby`, `sabnzbd`, `sonarr`, `radarr`) exist for direct API access. The dashboard itself reads from the poller cache — it does not proxy through these routes.
### 4.3 Utility Modules
**`config.js`** — Parses service instances from environment variables. Supports both JSON array format (`SONARR_INSTANCES=[{...}]`) and legacy single-instance format (`SONARR_URL` + `SONARR_API_KEY`). Each instance gets an `id` derived from `name` or index.
**`cache.js`** — Singleton `MemoryCache` backed by a `Map`. Each entry has an expiration timestamp. Provides `get`, `set`, `invalidate`, `clear`, and `getStats` (returns per-key size, item count, TTL remaining).
**`poller.js`** — Background polling engine. Fetches data from all configured service instances in parallel with per-task timing. Stores results in the cache with a configurable TTL. Can be disabled entirely (`POLL_INTERVAL=0`), in which case data is fetched on-demand. After each successful poll it notifies all registered SSE subscriber callbacks so connected clients receive data immediately.
**`qbittorrent.js`** — `QBittorrentClient` class with cookie-based authentication, automatic re-auth on 403, and persistent client instances. Includes torrent-to-download mapping (`mapTorrentToDownload`) and formatting utilities (`formatBytes`, `formatSpeed`, `formatEta`).
**`tokenStore.js`** — JSON file-backed store (`DATA_DIR/tokens.json`) for Emby `AccessToken`s. Tokens are stored server-side and **never sent to the client**. Writes are atomic (write to `.tmp` then rename). Entries expire after 31 days (slightly longer than the maximum 30-day cookie). Pruning runs on startup and hourly.
**`sanitizeError.js`** — Redacts secrets from error message strings before they are logged or returned in API responses. Patterns: URL query-param secrets (`apikey=`, `token=`, etc.), HTTP auth headers (`Authorization:`, `X-Emby-Authorization:`, etc.), Bearer tokens, and basic-auth credentials in URLs.
**`logger.js`** — Simple file appender writing timestamped messages to `DATA_DIR/server.log`.
---
## 5. Data Flow
### 5.1 Polling Cycle
Every `POLL_INTERVAL` ms (default 5000), the poller fetches from all services in parallel:
| Task | API Call | Params |
|------|----------|--------|
| SABnzbd Queue | `GET /api?mode=queue` | `output=json` |
| SABnzbd History | `GET /api?mode=history` | `limit=10` |
| Sonarr Tags | `GET /api/v3/tag` | — |
| Sonarr Queue | `GET /api/v3/queue` | `includeSeries=true` |
| Sonarr History | `GET /api/v3/history` | `pageSize=10` |
| Radarr Queue | `GET /api/v3/queue` | `includeMovie=true` |
| Radarr History | `GET /api/v3/history` | `pageSize=10` |
| Radarr Tags | `GET /api/v3/tag` | — |
| qBittorrent | `GET /api/v2/torrents/info` | — |
Results are stored in the cache under `poll:*` keys with a TTL of `POLL_INTERVAL × 3`.
### 5.2 SSE Stream
When a browser opens `GET /api/dashboard/stream` (after authentication):
1. Server sets `Content-Type: text/event-stream`, disables buffering (`X-Accel-Buffering: no`)
2. Immediately builds and sends the first payload (same matching logic as below)
3. Registers a callback with the poller's `onPollComplete` subscriber set
4. After every subsequent poll cycle completes the callback fires, rebuilds the payload, and writes a `data:` SSE frame
5. A 25-second heartbeat comment (`: heartbeat`) keeps the connection alive through proxies
6. On client disconnect: deregisters callback, stops heartbeat, removes from active-client map
The browser's native `EventSource` API handles reconnection automatically on network interruption.
### 5.3 Download Matching
For each connected user the server:
1. Reads all `poll:*` keys from cache
2. Builds `seriesMap` and `moviesMap` from embedded objects in queue records
3. Builds `sonarrTagMap` and `radarrTagMap` from tag data
4. For each SABnzbd queue slot → tries to match against Sonarr/Radarr queue records by title
5. For each SABnzbd history slot → tries to match against Sonarr/Radarr history records
6. For each qBittorrent torrent → tries to match against Sonarr/Radarr queue, then history
7. For each match, resolves the series/movie, extracts the user tag, checks if it belongs to the requesting user
8. Returns only the user's downloads (or all, if admin with `showAll=true`)
---
## 6. Authentication & Authorisation
### Flow
1. User submits credentials (+ optional `rememberMe`) via the login form
2. Backend applies the **login rate limiter** (max 10 failed attempts per IP per 15-minute window; successful requests don't count)
3. Backend calls Emby `POST /Users/authenticatebyname` using a deterministic `DeviceId` derived from the username — this causes Emby to reuse the existing session rather than creating a new one on every login
4. On success, fetches full user profile (`GET /Users/{id}`) to determine admin status
5. Stores the Emby `AccessToken` in **`tokenStore`** (server-side, never sent to the client)
6. Sets an `httpOnly` `emby_user` cookie containing only `{ id, name, isAdmin }`:
- **`rememberMe: true`** → persistent cookie, `Max-Age` 30 days
- **`rememberMe: false`** → session cookie (no `Max-Age`; expires when browser closes)
- `secure` flag enabled when `NODE_ENV=production`
- Signed with HMAC when `COOKIE_SECRET` is set
7. Issues a `csrf_token` cookie (`httpOnly: false` so JS can read it) containing a 32-byte random hex token
8. Returns `{ success: true, user, csrfToken }` — the SPA stores `csrfToken` in memory and sends it as `X-CSRF-Token` on all subsequent state-changing requests
9. All subsequent dashboard requests read the `emby_user` cookie for identity via `requireAuth`
### Authorisation Matrix
| Feature | Regular User | Admin |
|---------|:----------:|:-----:|
| View own downloads | ✓ | ✓ |
| View all users' downloads | ✗ | ✓ (`showAll`) |
| See download/target paths | ✗ | ✓ |
| See Sonarr/Radarr links | ✗ | ✓ |
| View status panel | ✗ | ✓ |
### Tag Matching
Users are matched to downloads via tags in Sonarr/Radarr:
1. **Exact match**: tag label (lowercased) === username (lowercased)
2. **Sanitised match**: handles Ombi's tag mangling — `sanitizeTagLabel()` converts to lowercase, replaces non-alphanumeric with hyphens, collapses, trims
---
## 7. Background Polling & Caching
### Polling Modes
| Mode | `POLL_INTERVAL` | Behaviour |
|------|----------------|-----------|
| **Background** | `> 0` (e.g. `5000`) | Periodic fetch every N ms |
| **On-demand** | `0` / `off` / `false` | Fetch triggered by first user request when cache is empty |
### Cache Keys
| Key | Content | Source |
|-----|---------|--------|
| `poll:sab-queue` | `{ slots, status, speed, kbpersec }` | SABnzbd queue |
| `poll:sab-history` | `{ slots }` | SABnzbd history |
| `poll:sonarr-tags` | `[{ instance, data: [{id, label}] }]` | Sonarr tag API |
| `poll:sonarr-queue` | `{ records }` — includes embedded `series` objects | Sonarr queue (includeSeries) |
| `poll:sonarr-history` | `{ records }` — lightweight, no embedded objects | Sonarr history |
| `poll:radarr-queue` | `{ records }` — includes embedded `movie` objects | Radarr queue (includeMovie) |
| `poll:radarr-history` | `{ records }` — lightweight, no embedded objects | Radarr history |
| `poll:radarr-tags` | `[{id, label}]` | Radarr tag API |
| `poll:qbittorrent` | `[torrent, ...]` | qBittorrent all torrents |
| `emby:users` | `Map<lowerName, displayName>` | Full Emby user list (60s TTL) |
### TTL Strategy
- **Polling enabled**: `POLL_INTERVAL × 3` — ensures data survives between polls even if one poll is slow
- **Polling disabled**: `30000` ms — stale after 30s, next request triggers a fresh fetch
### Active Client Tracking
SSE connections are tracked precisely: registered on connect, removed on disconnect. The admin status panel shows each connected user and how long they have been connected. The `type: 'sse'` field distinguishes SSE clients from any legacy HTTP clients.
---
## 8. Download Matching Pipeline
The core logic in `dashboard.js` matches raw download client data to *arr service metadata to determine which user each download belongs to.
### Matching Strategy
For each download item (SABnzbd slot or qBittorrent torrent):
```
1. Try Sonarr QUEUE match (by title substring)
→ resolve series via seriesMap (embedded in queue record)
→ extract user tag → check tag matches requesting user
2. Try Radarr QUEUE match (by title substring)
→ resolve movie via moviesMap (embedded in queue record)
→ extract user tag → check tag matches requesting user
3. Try Sonarr HISTORY match (by title substring)
→ resolve series via seriesMap (from queue) using seriesId
→ extract user tag → check tag matches requesting user
4. Try Radarr HISTORY match (by title substring)
→ resolve movie via moviesMap (from queue) using movieId
→ extract user tag → check tag matches requesting user
```
### Title Matching
Matches are **bidirectional substring matches** (case-insensitive):
```javascript
rTitle.includes(downloadTitle) || downloadTitle.includes(rTitle)
```
### Download Object Structure
Each matched download produces an object with:
| Field | Type | Description |
|-------|------|-------------|
| `type` | `'series'` / `'movie'` / `'torrent'` | Media type |
| `title` | string | Raw download title |
| `coverArt` | string / null | Poster URL from *arr |
| `status` | string | Download status |
| `progress` | string | Percentage complete |
| `size` / `mb` / `mbmissing` | string / number | Size info |
| `speed` | string | Current download speed |
| `eta` | string | Estimated time remaining |
| `seriesName` / `movieName` | string | Friendly media title |
| `episodeInfo` / `movieInfo` | object | Full *arr queue/history record |
| `allTags` | string[] | All resolved tag labels on the series/movie |
| `matchedUserTag` | string / null | Tag label matching the requesting user, or `null` |
| `tagBadges` | `{label, matchedUser}[]` / undefined | (Admin `showAll` only) Each tag classified against full Emby user list |
| `importIssues` | string[] / null | Import warning/error messages |
| `downloadPath` | string / null | (Admin) Download client path |
| `targetPath` | string / null | (Admin) *arr target path |
| `arrLink` | string / null | (Admin) Link to *arr web UI |
---
## 9. API Reference
### `POST /api/auth/login`
Authenticate a user via Emby. Rate-limited to **10 failed attempts per IP per 15 minutes**.
**Request Body:**
```json
{ "username": "string", "password": "string", "rememberMe": false }
```
| Field | Required | Description |
|-------|:--------:|-----------|
| `username` | Yes | Max 128 chars, must be a non-empty string |
| `password` | Yes | Max 256 chars |
| `rememberMe` | No | `true` = 30-day persistent cookie; `false`/omitted = session cookie |
**Response (200):**
```json
{
"success": true,
"user": { "id": "string", "name": "string", "isAdmin": false },
"csrfToken": "64-char hex string"
}
```
**Response (400):** Invalid input (empty/overlong username or password).
**Response (401):**
```json
{ "success": false, "error": "Invalid username or password" }
```
**Response (429):** Too many failed attempts from this IP.
**Side Effects:**
- Sets `emby_user` cookie: `httpOnly`, `sameSite: strict`, `secure` in production, signed if `COOKIE_SECRET` is set. Payload: `{ id, name, isAdmin }`. The Emby `AccessToken` is **never** included.
- Sets `csrf_token` cookie: `httpOnly: false` (JS-readable), same security flags. 64-char hex.
- Stores the Emby `AccessToken` server-side in `tokenStore` (used only for server-side Emby logout).
---
### `GET /api/auth/me`
Check current session (no auth required — returns unauthenticated state rather than 401).
**Response (authenticated):**
```json
{ "authenticated": true, "user": { "id": "string", "name": "string", "isAdmin": false } }
```
**Response (not authenticated):**
```json
{ "authenticated": false }
```
---
### `GET /api/auth/csrf`
Issue a fresh CSRF token. Used by the SPA after a page reload when the in-memory `csrfToken` variable is lost.
**Response (200):**
```json
{ "csrfToken": "64-char hex string" }
```
**Side Effect:** Sets a new `csrf_token` cookie.
---
### `POST /api/auth/logout`
Clear session and revoke the Emby token server-side. Does **not** require a CSRF token (auth routes are mounted before `verifyCsrf`; `sameSite: strict` provides equivalent protection).
---
### `GET /api/dashboard/stream`
Server-Sent Events stream. Opens a persistent connection that pushes download data on every poll cycle.
**Query Parameters:**
| Param | Type | Description |
|-------|------|-------------|
| `showAll` | `"true"` | (Admin) Include all users' downloads |
**Response:** `Content-Type: text/event-stream`
Each event is a `data:` frame containing JSON:
```json
{
"user": "Alice",
"isAdmin": false,
"downloads": [ /* download objects same shape as /user-downloads */ ]
}
```
The connection is kept alive with `: heartbeat` comments every 25 seconds. The browser's `EventSource` reconnects automatically on failure.
---
### `GET /api/dashboard/user-downloads`
Fetch downloads for the authenticated user (single HTTP request, no streaming).
**Query Parameters:**
| Param | Type | Description |
|-------|------|-------------|
| `showAll` | `"true"` | (Admin) Show all users' downloads |
**Response (200):**
```json
{
"user": "string",
"isAdmin": true,
"downloads": [ /* download objects */ ]
}
```
---
### `GET /api/dashboard/status`
Admin-only server status.
**Response (200):**
```json
{
"server": {
"uptimeSeconds": 3600,
"nodeVersion": "v18.19.0",
"memoryUsageMB": 45.2,
"heapUsedMB": 28.1,
"heapTotalMB": 35.0
},
"polling": {
"enabled": true,
"intervalMs": 5000,
"lastPoll": {
"totalMs": 1234,
"timestamp": "2026-05-16T00:00:00.000Z",
"tasks": [
{ "label": "SABnzbd Queue", "ms": 120 },
{ "label": "Sonarr Queue", "ms": 890 }
]
}
},
"cache": {
"entryCount": 9,
"totalSizeBytes": 51200,
"entries": [
{ "key": "poll:sab-queue", "sizeBytes": 1024, "itemCount": 3, "ttlRemainingMs": 12000, "expired": false }
]
},
"clients": [
{ "user": "Alice", "type": "sse", "connectedAt": 1715817600000, "lastSeen": 1715817600000 }
]
}
```
---
### `GET /api/dashboard/user-summary`
Admin-only per-user download counts (fetches live from APIs, not cached).
**Response (200):**
```json
[
{ "username": "Alice", "seriesCount": 12, "movieCount": 5 }
]
```
---
## 10. Frontend Architecture
The frontend is a **vanilla JavaScript SPA** with no build step. All logic resides in `app.js`, styled by `style.css`, and structured by `index.html`.
### UI States
```
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ Splash Screen│────▶│ Login Form │────▶│ Dashboard │
│ (on load) │ │ (if no │ │ (after auth) │
│ │ │ session) │ │ │
└──────────────┘ └──────────────┘ └──────────────┘
┌─────┴─────┐
│ Status │
│ Panel │
│ (admin) │
└───────────┘
```
### Key Frontend Functions
| Function | Purpose |
|----------|---------|
| `checkAuthentication()` | On load: check session → show dashboard or login |
| `handleLogin()` | Authenticate, fade login → splash → dashboard |
| `startSSE()` | Open `EventSource` to `/stream`; handles incoming data + first-message loading hide |
| `stopSSE()` | Close `EventSource` and cancel reconnect timer |
| `renderDownloads()` | Diff-based card rendering (create/update/remove) |
| `createDownloadCard()` | Build DOM for a single download card; renders tag badges |
| `updateDownloadCard()` | Update existing card in-place (progress, speed, etc.) |
| `toggleStatusPanel()` | Show/hide admin status panel |
| `renderStatusPanel()` | Build status HTML (server, polling, SSE clients, cache) |
| `initThemeSwitcher()` | Light / Dark / Mono theme support |
### Themes
Three CSS themes via `data-theme` attribute on `<html>`:
- **Light** — Purple gradient header, white cards
- **Dark** — Dark surfaces, muted accents
- **Mono** — Monochrome, minimal colour
Theme selection persists in `localStorage`.
### Tag Badge Rendering
Download cards render tag badges in the card header:
- **Normal user view**: a single accent-coloured badge showing the tag label that matched the current user's username (via `matchedUserTag`).
- **Admin `showAll` view**: all tags on the download are rendered using `tagBadges[]`:
- Tags with **no matching Emby user** → amber badge showing the raw tag label (leftmost)
- Tags **matched to a known Emby user** → accent badge showing the Emby display name (rightmost)
### Live Push via SSE
The dashboard receives updates via a persistent `EventSource` connection to `GET /api/dashboard/stream`. The server pushes a new `data:` event immediately after every poll cycle completes — there is no client-side timer. The browser's `EventSource` implementation handles reconnection automatically on network interruption.
The status panel refreshes on a fixed 5-second timer and shows each SSE client with its connect duration.
---
## 11. Configuration
### Environment Variables
#### Core
| Variable | Required | Default | Description |
|----------|:--------:|---------|-------------|
| `PORT` | No | `3001` | Server listen port |
| `NODE_ENV` | No | — | Set to `production` to enable `secure` cookies and HTTPS upgrades |
| `DATA_DIR` | No | `./data` | Directory for `tokens.json` and `server.log`. Must be writable by the server process. In Docker: `/app/data` (named volume). |
| `COOKIE_SECRET` | No | — | If set, signs all session cookies with HMAC-SHA256. Strongly recommended in production. |
| `TRUST_PROXY` | No | — | Express `trust proxy` setting. Set to `1` (or a hop count) when behind a reverse proxy (nginx, Traefik, etc.) so `req.ip` and `req.secure` are correct. |
#### Emby
| Variable | Required | Default | Description |
|----------|:--------:|---------|-------------|
| `EMBY_URL` | Yes | — | Emby/Jellyfin base URL (e.g. `https://emby.example.com`) |
| `EMBY_API_KEY` | Yes | — | Emby API key (used by the poller to list users for tag badge classification) |
#### Service Instances
| Variable | Required | Default | Description |
|----------|:--------:|---------|-------------|
| `SONARR_INSTANCES` | Yes* | — | JSON array of Sonarr instances |
| `SONARR_URL` | Yes* | — | Legacy: single Sonarr URL |
| `SONARR_API_KEY` | Yes* | — | Legacy: single Sonarr API key |
| `RADARR_INSTANCES` | Yes* | — | JSON array of Radarr instances |
| `RADARR_URL` | Yes* | — | Legacy: single Radarr URL |
| `RADARR_API_KEY` | Yes* | — | Legacy: single Radarr API key |
| `SABNZBD_INSTANCES` | Yes* | — | JSON array of SABnzbd instances |
| `SABNZBD_URL` | Yes* | — | Legacy: single SABnzbd URL |
| `SABNZBD_API_KEY` | Yes* | — | Legacy: single SABnzbd API key |
| `QBITTORRENT_INSTANCES` | No | — | JSON array of qBittorrent instances |
\* Either `*_INSTANCES` (JSON array) or legacy `*_URL` + `*_API_KEY` format is required.
#### Tuning
| Variable | Required | Default | Description |
|----------|:--------:|---------|-------------|
| `POLL_INTERVAL` | No | `5000` | Poll interval in ms. Set to `0`, `off`, or `false` to disable background polling (on-demand mode). |
| `LOG_LEVEL` | No | `info` | `debug`, `info`, `warn`, `error`, `silent` |
### Instance JSON Format
```json
[
{
"name": "main",
"url": "https://sonarr.example.com",
"apiKey": "your-api-key"
},
{
"name": "4k",
"url": "https://sonarr4k.example.com",
"apiKey": "your-4k-api-key"
}
]
```
qBittorrent instances use `username` and `password` instead of `apiKey`.
---
## 12. Deployment
### Docker image
The production image uses a two-stage build on `node:22-alpine`:
1. **`deps` stage** — runs `npm ci --omit=dev` to install only production dependencies.
2. **`runtime` stage** — copies `node_modules`, `server/`, `public/`, and `package.json`. Runs as the built-in non-root `node` user (UID 1000). `/app/data` is owned by `node` for writable token store and logs.
Key environment variables set in the image:
- `NODE_ENV=production` — enables secure cookies and HTTPS upgrade CSP directive
- `DATA_DIR=/app/data` — token store and log file location
### Docker Compose
```yaml
services:
sofarr:
image: docker.i3omb.com/sofarr:latest
container_name: sofarr
restart: unless-stopped
ports:
- "3001:3001"
environment:
- NODE_ENV=production
- DATA_DIR=/app/data
- COOKIE_SECRET=change-me-to-a-long-random-string
- TRUST_PROXY=1 # set if behind nginx/Traefik
- EMBY_URL=https://emby.example.com
- EMBY_API_KEY=your-emby-api-key
- SONARR_INSTANCES=[{"name":"main","url":"...","apiKey":"..."}]
- RADARR_INSTANCES=[{"name":"main","url":"...","apiKey":"..."}]
- SABNZBD_INSTANCES=[{"name":"main","url":"...","apiKey":"..."}]
- QBITTORRENT_INSTANCES=[{"name":"main","url":"...","username":"...","password":"..."}]
- POLL_INTERVAL=5000
- LOG_LEVEL=info
volumes:
- sofarr-data:/app/data # persists tokens.json and server.log
volumes:
sofarr-data:
```
### Security hardening checklist
- **Set `COOKIE_SECRET`** — enables HMAC-signed cookies, preventing client-side forgery.
- **Set `TRUST_PROXY=1`** when behind a reverse proxy — ensures `req.secure` is `true` so the `secure` cookie flag is enforced and HTTPS-upgrade CSP fires.
- **Mount a named volume** for `DATA_DIR` — token store and log file survive container recreates.
- **Use HTTPS** — the CSP includes `upgrade-insecure-requests` in production and the HSTS header is set with a 1-year `maxAge`.
- **Rate limiting** — the login endpoint is limited to 10 failed attempts per IP per 15 minutes; all API endpoints share a 300 req/15 min window. Set `TRUST_PROXY` correctly so the client IP is not the proxy's IP.
### CI / CD
The `.gitea/workflows/` directory contains three pipeline definitions:
| File | Trigger | Purpose |
|------|---------|--------|
| `ci.yml` | Every push / PR | Security audit (`npm audit --audit-level=high`) + tests with V8 coverage |
| `build-image.yml` | Push to `main` / `develop` | Build and push Docker image to `docker.i3omb.com` |
| `create-release.yml` | Tag push (`v*`) | Create a Gitea release |
---
## 13. UML Diagrams (PlantUML)
All diagrams below are in PlantUML syntax. Render with any PlantUML tool or VS Code extension.
### 13.1 Component Diagram
See [`diagrams/component.puml`](diagrams/component.puml)
### 13.2 Sequence Diagrams
- **Authentication**: [`diagrams/seq-auth.puml`](diagrams/seq-auth.puml)
- **Dashboard Request**: [`diagrams/seq-dashboard.puml`](diagrams/seq-dashboard.puml)
- **Polling Cycle**: [`diagrams/seq-polling.puml`](diagrams/seq-polling.puml)
### 13.3 Class / Entity Diagrams
- **Server Classes**: [`diagrams/class-server.puml`](diagrams/class-server.puml)
- **Data Model**: [`diagrams/class-data.puml`](diagrams/class-data.puml)
### 13.4 State Diagrams
- **Frontend UI States**: [`diagrams/state-ui.puml`](diagrams/state-ui.puml)
- **Poller States**: [`diagrams/state-poller.puml`](diagrams/state-poller.puml)
### 13.5 Activity Diagram
- **Download Matching**: [`diagrams/activity-matching.puml`](diagrams/activity-matching.puml)
-156
View File
@@ -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
-230
View File
@@ -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
-278
View File
@@ -1,278 +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
--
Logging setup, app.listen(),
static files, startPoller()
}
}
package "server/app.js" as appfactory {
class "createApp(options?)" as appfn <<factory>> {
+ createApp(skipRateLimits?) : Express
--
Mounts helmet (CSP nonce),
rate limiters, cookie-parser,
auth routes (pre-CSRF),
verifyCsrf, all other routes,
/health, /ready, error handler
}
}
package "server/routes" {
class "auth.js" as auth <<router>> {
+ POST /login (rate-limited)
+ GET /me
+ GET /csrf
+ POST /logout
--
Authenticates via Emby API
Issues emby_user + csrf_token cookies
Stores/revokes Emby tokens server-side
}
class "dashboard.js" as dashboard <<router>> {
- activeClients : Map<string, ClientInfo>
- CLIENT_STALE_MS : 30000
--
+ GET /stream (SSE, text/event-stream)
+ GET /user-downloads
+ GET /user-summary
+ GET /status
+ GET /cover-art
--
- 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 (signed if COOKIE_SECRET)
Validates schema: id, name, isAdmin
Attaches user to req.user
Returns 401 if absent/tampered/invalid
}
class "verifyCsrf.js" as verifycsrf <<middleware>> {
+ verifyCsrf(req, res, next) : void
--
Exempt: GET, HEAD, OPTIONS
Compares csrf_token cookie
vs X-CSRF-Token header
using crypto.timingSafeEqual
Returns 403 on mismatch/missing
}
}
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 "TokenStore" as tokenstore <<module>> {
- store : Object (in-memory)
- STORE_PATH : string (DATA_DIR/tokens.json)
- TOKEN_TTL_MS : 31 days
--
+ storeToken(userId, accessToken) : void
+ getToken(userId) : {accessToken}|null
+ clearToken(userId) : void
--
Atomic write (.tmp → rename)
Pruned on startup + hourly
}
class "SanitizeError" as sanitize <<module>> {
+ sanitizeError(err) : string
--
Redacts: query-param secrets,
auth headers, bearer tokens,
basic-auth URLs
}
class "TagBadge" as tb <<value>> {
+ label : string
+ matchedUser : string | null
}
class "ClientInfo" as ci <<value>> {
+ user : string
+ type : 'sse'
+ connectedAt : number (timestamp)
+ lastSeen : number (timestamp)
}
}
' Relationships
ep --> appfn : createApp()
ep --> poller : startPoller()
appfn --> auth : /api/auth (pre-CSRF)
appfn --> verifycsrf : /api (all routes below)
appfn --> dashboard
appfn --> emby_r
appfn --> sab_r
appfn --> sonarr_r
appfn --> radarr_r
dashboard --> requireauth : uses
emby_r --> requireauth : uses
sab_r --> requireauth : uses
sonarr_r --> requireauth : uses
radarr_r --> requireauth : uses
auth --> tokenstore : storeToken / getToken / clearToken
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
auth ..> sanitize : sanitizeError on catch
dashboard ..> sanitize : sanitizeError on catch
@enduml
-118
View File
@@ -1,118 +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 {
[index.js\nEntry Point] as entry
[app.js\ncreatApp() factory] as appfactory
package "Middleware" {
[helmet\n(CSP nonce, HSTS)] as hm
[express-rate-limit\n(API + login)] as rl
[cookie-parser\n(signed cookies)] as cp
[express.json\n(64kb limit)] as ej
[express.static] as es
[requireAuth.js] as requireauth
[verifyCsrf.js\n(double-submit)] as verifycsrf
}
package "Routes" as routes {
[auth.js\n/api/auth\n(pre-CSRF)] as auth
[dashboard.js\n/api/dashboard\n(+SSE /stream)] 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
[tokenStore.js\n(tokens.json)] as tokenstore
[sanitizeError.js] as sanitize
[logger.js] as logger
}
entry --> appfactory : createApp()
entry --> es : serve public/
entry --> poller : startPoller()
appfactory --> hm
appfactory --> rl
appfactory --> cp
appfactory --> ej
appfactory --> auth : mount before verifyCsrf
appfactory --> verifycsrf : applied to all /api below
appfactory --> dashboard
appfactory --> emby_route
appfactory --> sab_route
appfactory --> sonarr_route
appfactory --> radarr_route
emby_route --> requireauth
sab_route --> requireauth
sonarr_route --> requireauth
radarr_route --> requireauth
dashboard --> requireauth
auth --> tokenstore : storeToken / getToken / clearToken
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
auth ..> sanitize
dashboard ..> sanitize
note "Browser uses EventSource\n(SSE) to /stream.\nServer pushes on each\npoll cycle — no client timer." as sseNote
sseNote .. dashboard
}
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
-104
View File
@@ -1,104 +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 "TokenStore\n(tokens.json)" as tokens
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\n(signed if COOKIE_SECRET set)
alt Cookie exists and valid
auth --> browser : { authenticated: true, user: { name, isAdmin } }
browser -> auth : GET /api/auth/csrf
activate auth
auth -> auth : Generate 32-byte hex csrfToken
auth --> browser : { csrfToken } + Set csrf_token cookie
deactivate auth
browser -> browser : store csrfToken in memory
browser -> browser : showDashboard()
browser -> browser : startAutoRefresh()
browser -> browser : dismissSplash()
else No cookie / tampered
auth --> browser : { authenticated: false }
browser -> browser : dismissSplash()
browser -> browser : showLogin()
end
deactivate auth
== Login ==
user -> browser : Enter username + password\n(+ optional rememberMe checkbox)
browser -> auth : POST /api/auth/login\n{ username, password, rememberMe }
activate auth
note right of auth
Rate limiter: max 10 failed
attempts per IP / 15 min
(successful requests excluded)
end note
auth -> emby : POST /Users/authenticatebyname\n{ Username, Pw }\nX-Emby-Authorization: MediaBrowser\nDeviceId = sha256(username)[0:16]
activate emby
alt Valid credentials
emby --> auth : { User: { Id }, AccessToken }
auth -> emby : GET /Users/{userId}\nX-MediaBrowser-Token: AccessToken
emby --> auth : { Name, Policy: { IsAdministrator } }
deactivate emby
auth -> tokens : storeToken(userId, AccessToken)
note right of tokens
Stored server-side only.
Never sent to the client.
31-day TTL, atomic JSON write.
end note
auth -> auth : Set emby_user cookie\n{ id, name, isAdmin }\nhttpOnly, sameSite=strict\nsecure (prod), signed (COOKIE_SECRET)\nrememberMe=true → Max-Age 30d\nrememberMe=false → session cookie
auth -> auth : Generate csrfToken\n(32-byte random hex)
auth -> auth : Set csrf_token cookie\nhttpOnly=false (JS-readable)\nsameSite=strict, secure (prod)
auth --> browser : { success: true, user, csrfToken }
browser -> browser : store csrfToken in memory
browser -> browser : fadeOutLogin()
browser -> browser : showDashboard()
browser -> browser : startAutoRefresh()
browser -> browser : dismissSplash()
else Invalid credentials
emby --> auth : 401 Error
deactivate emby
auth --> browser : { success: false, error: "Invalid username or password" }
browser -> browser : showLoginError()
end
deactivate auth
== CSRF Token Refresh (after page reload) ==
note over browser : csrfToken lost from memory\non hard page reload
browser -> auth : GET /api/auth/csrf
activate auth
auth -> auth : Generate new csrfToken
auth --> browser : { csrfToken } + new csrf_token cookie
deactivate auth
browser -> browser : store new csrfToken in memory
== Logout ==
user -> browser : Click Logout
browser -> browser : stopAutoRefresh()
browser -> auth : POST /api/auth/logout\n(no CSRF required — auth routes\nexempt; sameSite:strict protects)
activate auth
auth -> auth : Parse emby_user cookie → user
auth -> tokens : getToken(user.id)
activate tokens
tokens --> auth : { accessToken }
deactivate tokens
auth -> emby : POST /Sessions/Logout\nX-MediaBrowser-Token: accessToken
activate emby
emby --> auth : 204 / error (ignored)
deactivate emby
auth -> tokens : clearToken(user.id)
auth -> auth : clearCookie(emby_user)\nclearCookie(csrf_token)
auth --> browser : { success: true }
deactivate auth
browser -> browser : showLogin()
deactivate browser
@enduml
-67
View File
@@ -1,67 +0,0 @@
@startuml seq-dashboard
!theme plain
title sofarr — Dashboard SSE Stream 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
== SSE Connection (on login / page load) ==
user -> browser : Login success\nor valid session
activate browser
browser -> dashboard : GET /api/dashboard/stream\n(EventSource, Cookie: emby_user)
activate dashboard
dashboard -> dashboard : requireAuth: parse cookie\nextract username, isAdmin
dashboard -> dashboard : Set headers:\nContent-Type: text/event-stream\nX-Accel-Buffering: no
dashboard -> dashboard : Register in activeClients Map\n{ user, type:'sse', connectedAt }
alt Polling disabled AND cache empty
dashboard -> poller : pollAllServices()
activate poller
poller -> ext : Parallel API calls
ext --> poller : Raw data
poller -> cache : set poll:* keys (TTL=30s)
deactivate poller
end
== Initial Payload (sent immediately on connect) ==
dashboard -> cache : get all poll:* keys
dashboard -> dashboard : Build seriesMap, moviesMap,\nsonarrTagMap, radarrTagMap
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
end
dashboard -> dashboard : Match SABnzbd/qBit downloads\nvs Sonarr/Radarr records\nextractUserTag / buildTagBadges
dashboard --> browser : data: { user, isAdmin, downloads }
browser -> browser : hideLoading()\nrenderDownloads()
== Pushed Updates (on every poll cycle) ==
loop Each poll cycle completes
poller -> poller : pollAllServices() complete
poller -> dashboard : onPollComplete callback fires
dashboard -> cache : get all poll:* keys
dashboard -> dashboard : Rebuild download payload
dashboard --> browser : data: { user, isAdmin, downloads }
browser -> browser : renderDownloads() (diff-based)
end
== Heartbeat (every 25s) ==
dashboard --> browser : : heartbeat
note right : Keeps connection alive\nthrough idle-timeout proxies
== Client Disconnects ==
user -> browser : Close tab / logout
browser -> dashboard : TCP close (req 'close' event)
dashboard -> dashboard : offPollComplete(callback)\nclearInterval(heartbeat)\ndelete activeClients[key]
deactivate dashboard
deactivate browser
@enduml
-93
View File
@@ -1,93 +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 : Notify SSE subscribers\npollSubscribers.forEach(cb => cb())
note over poller : Each registered SSE client\ncallback rebuilds its payload\nand writes a data: frame
poller -> poller : polling = false\nlog elapsed time
deactivate poller
@enduml
-67
View File
@@ -1,67 +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 --> notifying : Cache updated
state "Notifying SSE\nsubscribers" as notifying
notifying --> 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
-73
View File
@@ -1,73 +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 "startSSE() — awaiting\nfirst SSE message" as fetching
}
SplashScreen2 --> Dashboard : Data loaded\ndismissSplash()
state Dashboard {
state "Rendering Cards" as rendering
state "Status Panel Open" as status_open
state "Status Panel Closed" as status_closed
[*] --> rendering
rendering --> rendering : SSE message received
→ renderDownloads()
rendering --> rendering : Theme change
status_closed --> status_open : Click "Status" btn
(admin only)
status_open --> status_closed : Click close (×)
status_open --> status_open : 5s timer
→ renderStatusPanel()
[*] --> status_closed
state "SSE Connection" as sse {
state "Connecting" as sc
state "Connected" as scon
state "Reconnecting" as srec
sc --> scon : First message received
scon --> srec : Connection lost
srec --> scon : Browser auto-reconnects
scon --> sc : showAll toggle changed
}
}
Dashboard --> LoginForm : Logout
(stopSSE,
clear state)
@enduml
+1301 -1995
View File
File diff suppressed because it is too large Load Diff
+9 -20
View File
@@ -1,35 +1,24 @@
{ {
"name": "sofarr", "name": "sofarr",
"version": "1.0.0", "version": "0.1.2",
"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", "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", "main": "server/index.js",
"scripts": { "scripts": {
"dev": "nodemon server/index.js", "dev": "nodemon server/index.js",
"start": "node server/index.js", "start": "node server/index.js",
"install:all": "npm install", "install:all": "npm install"
"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": { "dependencies": {
"axios": "^1.6.0",
"cookie-parser": "^1.4.6",
"dotenv": "^16.3.1",
"express": "^4.18.2", "express": "^4.18.2",
"express-rate-limit": "^7.0.0", "cors": "^2.8.5",
"helmet": "^7.0.0" "dotenv": "^16.3.1",
"axios": "^1.6.0",
"node-cron": "^3.0.3",
"cookie-parser": "^1.4.6"
}, },
"devDependencies": { "devDependencies": {
"@vitest/coverage-v8": "^4.1.6", "nodemon": "^2.0.22",
"concurrently": "^7.6.0", "concurrently": "^7.6.0"
"nock": "^14.0.15",
"nodemon": "^3.1.14",
"supertest": "^7.2.2",
"vitest": "^4.1.6"
}, },
"keywords": [ "keywords": [
"sabnzbd", "sabnzbd",
+67 -328
View File
@@ -1,14 +1,9 @@
let currentUser = null; let currentUser = null;
let downloads = []; let downloads = [];
let refreshInterval = null;
let currentRefreshRate = 5000; // default 5 seconds
let isAdmin = false; let isAdmin = false;
let showAll = 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
// 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) // Apply saved theme immediately (before DOMContentLoaded to avoid flash)
(function() { (function() {
@@ -23,8 +18,8 @@ document.addEventListener('DOMContentLoaded', () => {
document.getElementById('login-form').addEventListener('submit', handleLogin); document.getElementById('login-form').addEventListener('submit', handleLogin);
document.getElementById('logout-btn').addEventListener('click', handleLogout); document.getElementById('logout-btn').addEventListener('click', handleLogout);
document.getElementById('refresh-rate').addEventListener('change', handleRefreshRateChange);
document.getElementById('show-all-toggle').addEventListener('change', handleShowAllToggle); document.getElementById('show-all-toggle').addEventListener('change', handleShowAllToggle);
document.getElementById('status-btn').addEventListener('click', toggleStatusPanel);
}); });
function initThemeSwitcher() { function initThemeSwitcher() {
@@ -43,121 +38,47 @@ function setTheme(theme) {
}); });
} }
// --- SSE connection management --- function startAutoRefresh() {
if (refreshInterval) clearInterval(refreshInterval);
function startSSE() { if (currentRefreshRate > 0) {
stopSSE(); refreshInterval = setInterval(() => fetchUserDownloads(false), currentRefreshRate);
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() { function handleRefreshRateChange(e) {
if (sseReconnectTimer) { clearTimeout(sseReconnectTimer); sseReconnectTimer = null; } const rate = parseInt(e.target.value);
if (sseSource) { currentRefreshRate = rate;
sseSource.close(); startAutoRefresh();
sseSource = null;
console.log('[SSE] Stream closed');
}
} }
function handleShowAllToggle(e) { function handleShowAllToggle(e) {
showAll = e.target.checked; showAll = e.target.checked;
// Re-open stream with updated showAll param fetchUserDownloads(true);
startSSE();
} }
function fadeOutLogin() { function stopAutoRefresh() {
return new Promise(resolve => { if (refreshInterval) {
const login = document.getElementById('login-container'); clearInterval(refreshInterval);
login.classList.add('fade-out'); refreshInterval = null;
login.addEventListener('transitionend', () => { }
login.style.display = 'none';
login.classList.remove('fade-out');
resolve();
}, { once: true });
});
}
function showSplash() {
const splash = document.getElementById('splash-screen');
splash.style.display = 'flex';
splash.style.opacity = '1';
splash.classList.remove('fade-out');
}
function dismissSplash(startTime) {
return new Promise(resolve => {
const elapsed = Date.now() - (startTime || 0);
const remaining = Math.max(0, SPLASH_MIN_MS - elapsed);
setTimeout(() => {
const splash = document.getElementById('splash-screen');
splash.classList.add('fade-out');
// Fallback: resolve after transition duration + buffer in case
// transitionend never fires (e.g. display was toggled in same frame)
const TRANSITION_MS = 400;
const fallback = setTimeout(() => {
splash.style.display = 'none';
resolve();
}, TRANSITION_MS + 100);
splash.addEventListener('transitionend', () => {
clearTimeout(fallback);
splash.style.display = 'none';
resolve();
}, { once: true });
}, remaining);
});
} }
async function checkAuthentication() { async function checkAuthentication() {
const splashStart = Date.now();
try { try {
// Fetch both auth state and a fresh CSRF token in parallel const response = await fetch('/api/auth/me');
const [meRes, csrfRes] = await Promise.all([ const data = await response.json();
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) { if (data.authenticated) {
currentUser = data.user; currentUser = data.user;
isAdmin = !!data.user.isAdmin; isAdmin = !!data.user.isAdmin;
showDashboard(); showDashboard();
showLoading(); fetchUserDownloads(true);
startSSE(); startAutoRefresh();
await dismissSplash(splashStart);
} else { } else {
await dismissSplash(splashStart);
showLogin(); showLogin();
} }
} catch (err) { } catch (err) {
console.error('Authentication check failed:', err); console.error('Authentication check failed:', err);
await dismissSplash(splashStart);
showLogin(); showLogin();
} }
} }
@@ -167,7 +88,6 @@ async function handleLogin(e) {
const username = document.getElementById('username').value; const username = document.getElementById('username').value;
const password = document.getElementById('password').value; const password = document.getElementById('password').value;
const rememberMe = document.getElementById('remember-me').checked;
try { try {
const response = await fetch('/api/auth/login', { const response = await fetch('/api/auth/login', {
@@ -175,7 +95,7 @@ async function handleLogin(e) {
headers: { headers: {
'Content-Type': 'application/json' 'Content-Type': 'application/json'
}, },
body: JSON.stringify({ username, password, rememberMe }) body: JSON.stringify({ username, password })
}); });
const data = await response.json(); const data = await response.json();
@@ -183,20 +103,9 @@ async function handleLogin(e) {
if (data.success) { if (data.success) {
currentUser = data.user; currentUser = data.user;
isAdmin = !!data.user.isAdmin; isAdmin = !!data.user.isAdmin;
// 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.
await fadeOutLogin();
showSplash();
await new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)));
showDashboard(); showDashboard();
showLoading(); fetchUserDownloads(true);
const splashStart = Date.now(); startAutoRefresh();
startSSE();
await dismissSplash(splashStart);
} else { } else {
showLoginError(data.error || 'Login failed'); showLoginError(data.error || 'Login failed');
} }
@@ -208,14 +117,11 @@ async function handleLogin(e) {
async function handleLogout() { async function handleLogout() {
try { try {
stopSSE(); stopAutoRefresh();
if (statusRefreshHandle) { clearInterval(statusRefreshHandle); statusRefreshHandle = null; }
await fetch('/api/auth/logout', { await fetch('/api/auth/logout', {
method: 'POST', method: 'POST'
headers: csrfToken ? { 'X-CSRF-Token': csrfToken } : {}
}); });
currentUser = null; currentUser = null;
csrfToken = null;
downloads = []; downloads = [];
showLogin(); showLogin();
} catch (err) { } catch (err) {
@@ -233,10 +139,6 @@ function showDashboard() {
document.getElementById('login-container').style.display = 'none'; document.getElementById('login-container').style.display = 'none';
document.getElementById('dashboard-container').style.display = 'block'; document.getElementById('dashboard-container').style.display = 'block';
document.getElementById('currentUser').textContent = currentUser.name || '-'; 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'; document.getElementById('admin-controls').style.display = isAdmin ? 'flex' : 'none';
} }
@@ -251,8 +153,37 @@ function hideLoginError() {
errorDiv.style.display = 'none'; errorDiv.style.display = 'none';
} }
// fetchUserDownloads is kept for the showAll toggle re-connection case async function fetchUserDownloads(isInitialLoad = false) {
// but the primary data path is now via SSE (startSSE / EventSource). if (isInitialLoad) {
showLoading();
}
hideError();
try {
const url = showAll ? '/api/dashboard/user-downloads?showAll=true' : '/api/dashboard/user-downloads';
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();
}
}
}
function renderDownloads() { function renderDownloads() {
const downloadsList = document.getElementById('downloads-list'); const downloadsList = document.getElementById('downloads-list');
@@ -376,11 +307,7 @@ function createDownloadCard(download) {
const coverDiv = document.createElement('div'); const coverDiv = document.createElement('div');
coverDiv.className = 'download-cover'; coverDiv.className = 'download-cover';
const coverImg = document.createElement('img'); const coverImg = document.createElement('img');
// Proxy cover art through the server so the CSP img-src 'self' rule coverImg.src = download.coverArt;
// 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.alt = download.movieName || download.seriesName || download.title;
coverImg.loading = 'lazy'; coverImg.loading = 'lazy';
coverDiv.appendChild(coverImg); coverDiv.appendChild(coverImg);
@@ -413,14 +340,6 @@ function createDownloadCard(download) {
header.appendChild(type); header.appendChild(type);
header.appendChild(status); header.appendChild(status);
if (download.importIssues && download.importIssues.length > 0) {
const issueBadge = document.createElement('span');
issueBadge.className = 'import-issue-badge';
issueBadge.textContent = 'Import Pending';
issueBadge.setAttribute('data-tooltip', download.importIssues.join('\n'));
header.appendChild(issueBadge);
}
const title = document.createElement('h3'); const title = document.createElement('h3');
title.className = 'download-title'; title.className = 'download-title';
@@ -432,49 +351,22 @@ function createDownloadCard(download) {
if (download.seriesName) { if (download.seriesName) {
const series = document.createElement('p'); const series = document.createElement('p');
series.className = 'download-series'; series.className = 'download-series';
if (isAdmin && download.arrLink) { series.textContent = `Series: ${download.seriesName}`;
series.innerHTML = 'Series: <a href="' + escapeHtml(download.arrLink) + '" target="_blank" class="arr-link">' + escapeHtml(download.seriesName) + '</a>';
} else {
series.textContent = `Series: ${download.seriesName}`;
}
infoDiv.appendChild(series); infoDiv.appendChild(series);
} }
if (download.movieName) { if (download.movieName) {
const movie = document.createElement('p'); const movie = document.createElement('p');
movie.className = 'download-movie'; movie.className = 'download-movie';
if (isAdmin && download.arrLink) { movie.textContent = `Movie: ${download.movieName}`;
movie.innerHTML = 'Movie: <a href="' + escapeHtml(download.arrLink) + '" target="_blank" class="arr-link">' + escapeHtml(download.movieName) + '</a>';
} else {
movie.textContent = `Movie: ${download.movieName}`;
}
infoDiv.appendChild(movie); infoDiv.appendChild(movie);
} }
if (showAll && download.tagBadges && download.tagBadges.length > 0) { if (showAll && download.userTag) {
// In showAll mode: render all tags classified by whether they match an Emby user. const userBadge = document.createElement('span');
// Unmatched (no known Emby user) → amber, leftmost. userBadge.className = 'download-user-badge';
// Matched → show Emby display name in accent colour, rightmost. userBadge.textContent = download.userTag;
const unmatched = download.tagBadges.filter(b => !b.matchedUser); header.appendChild(userBadge);
const matched = download.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 (download.matchedUserTag) {
// Normal (non-showAll) view: show only the current user's matched tag
const matchedBadge = document.createElement('span');
matchedBadge.className = 'download-user-badge';
matchedBadge.textContent = download.matchedUserTag;
header.appendChild(matchedBadge);
} }
const details = document.createElement('div'); const details = document.createElement('div');
@@ -566,24 +458,6 @@ function createDownloadCard(download) {
const completed = createDetailItem('Completed', formatDate(download.completedAt)); const completed = createDetailItem('Completed', formatDate(download.completedAt));
details.appendChild(completed); details.appendChild(completed);
} }
if (isAdmin && (download.downloadPath || download.targetPath)) {
const pathsDiv = document.createElement('div');
pathsDiv.className = 'download-paths';
if (download.downloadPath) {
const dlPath = document.createElement('div');
dlPath.className = 'path-item';
dlPath.innerHTML = '<span class="path-label">Download:</span> <span class="path-value">' + escapeHtml(download.downloadPath) + '</span>';
pathsDiv.appendChild(dlPath);
}
if (download.targetPath) {
const tgtPath = document.createElement('div');
tgtPath.className = 'path-item';
tgtPath.innerHTML = '<span class="path-label">Target:</span> <span class="path-value">' + escapeHtml(download.targetPath) + '</span>';
pathsDiv.appendChild(tgtPath);
}
details.appendChild(pathsDiv);
}
infoDiv.appendChild(details); infoDiv.appendChild(details);
card.appendChild(infoDiv); card.appendChild(infoDiv);
@@ -610,141 +484,6 @@ function createDetailItem(label, value) {
return item; return item;
} }
function escapeHtml(str) {
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
let statusRefreshHandle = null;
const STATUS_REFRESH_MS = 5000;
async function toggleStatusPanel() {
const panel = document.getElementById('status-panel');
if (panel.style.display !== 'none') {
panel.style.display = 'none';
if (statusRefreshHandle) { clearInterval(statusRefreshHandle); statusRefreshHandle = null; }
return;
}
panel.style.display = 'block';
await refreshStatusPanel();
if (statusRefreshHandle) clearInterval(statusRefreshHandle);
statusRefreshHandle = setInterval(refreshStatusPanel, STATUS_REFRESH_MS);
}
function closeStatusPanel() {
document.getElementById('status-panel').style.display = 'none';
if (statusRefreshHandle) { clearInterval(statusRefreshHandle); statusRefreshHandle = null; }
}
async function refreshStatusPanel() {
const panel = document.getElementById('status-panel');
if (!panel || panel.style.display === 'none') return;
try {
const res = await fetch('/api/dashboard/status');
if (!res.ok) throw new Error('Failed to fetch status');
const data = await res.json();
renderStatusPanel(data, panel);
} catch (err) {
// Don't overwrite panel on transient error during auto-refresh
if (!panel.innerHTML || panel.innerHTML.includes('status-loading')) {
panel.innerHTML = '<p class="status-error">Failed to load status.</p>';
}
}
}
function renderStatusPanel(data, panel) {
const s = data.server;
const hrs = Math.floor(s.uptimeSeconds / 3600);
const mins = Math.floor((s.uptimeSeconds % 3600) / 60);
const secs = s.uptimeSeconds % 60;
const uptime = `${hrs}h ${mins}m ${secs}s`;
const totalKB = (data.cache.totalSizeBytes / 1024).toFixed(1);
let html = `
<div class="status-header">
<h3>Server Status</h3>
<button class="status-close" onclick="closeStatusPanel()">&times;</button>
</div>
<div class="status-grid">
<div class="status-card">
<div class="status-card-title">Server</div>
<div class="status-row"><span>Uptime</span><span>${uptime}</span></div>
<div class="status-row"><span>Node</span><span>${escapeHtml(s.nodeVersion)}</span></div>
<div class="status-row"><span>Memory (RSS)</span><span>${s.memoryUsageMB} MB</span></div>
<div class="status-row"><span>Heap</span><span>${s.heapUsedMB} / ${s.heapTotalMB} MB</span></div>
</div>
<div class="status-card">
<div class="status-card-title">Data Refresh</div>`;
const pollIntervalMs = data.polling.intervalMs;
const clients = data.clients || [];
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>`;
} else {
html += `<div class="status-row"><span>Background poll</span><span>Disabled</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>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>`;
// Poll timings card
const lp = data.polling.lastPoll;
if (lp) {
const pollAge = Math.round((Date.now() - new Date(lp.timestamp).getTime()) / 1000);
html += `
<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 = 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" data-w="${barWidth.toFixed(1)}"></div></div>
<span class="timing-value">${t.ms}ms</span>
</div>`;
}
html += `</div></div>`;
}
// Cache table
html += `
<div class="status-card status-card-wide">
<div class="status-card-title">Cache (${data.cache.entryCount} entries, ${totalKB} KB)</div>
<table class="status-table">
<thead><tr><th>Key</th><th>Items</th><th>Size</th><th>TTL</th></tr></thead>
<tbody>`;
for (const e of data.cache.entries) {
const sizeStr = e.sizeBytes > 1024 ? (e.sizeBytes / 1024).toFixed(1) + ' KB' : e.sizeBytes + ' B';
const ttlStr = e.expired ? '<span class="status-expired">expired</span>' : (e.ttlRemainingMs / 1000).toFixed(0) + 's';
const items = e.itemCount !== null ? e.itemCount : '—';
html += `<tr><td><code>${escapeHtml(e.key)}</code></td><td>${items}</td><td>${sizeStr}</td><td>${ttlStr}</td></tr>`;
}
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) { function formatSize(size) {
if (!size) return 'N/A'; if (!size) return 'N/A';
// If already a formatted string (e.g., "21.5 GB"), return as-is // If already a formatted string (e.g., "21.5 GB"), return as-is
Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 543 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

+10 -20
View File
@@ -4,24 +4,14 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>sofarr - Your Downloads Dashboard</title> <title>sofarr - Your Downloads Dashboard</title>
<link rel="icon" type="image/x-icon" href="favicon.ico">
<link rel="icon" type="image/png" sizes="32x32" href="favicon-32.png">
<link rel="apple-touch-icon" sizes="192x192" href="favicon-192.png">
<meta name="theme-color" content="#1a1a2e">
<link rel="stylesheet" href="style.css"> <link rel="stylesheet" href="style.css">
</head> </head>
<body> <body>
<!-- Splash Screen -->
<div id="splash-screen" class="splash-screen">
<img src="images/sofarr-flashscreen.png" alt="sofarr" class="splash-logo">
</div>
<div class="app"> <div class="app">
<!-- Login Form --> <!-- Login Form -->
<div id="login-container" class="login-container" style="display: none;"> <div id="login-container" class="login-container" style="display: none;">
<div class="login-box"> <div class="login-box">
<img src="images/sofarr-flashscreen.png" alt="sofarr" class="login-logo"> <h2>Login to Emby</h2>
<p class="login-subtitle">Login with your Emby credentials</p>
<form id="login-form"> <form id="login-form">
<div class="form-group"> <div class="form-group">
<label for="username">Username:</label> <label for="username">Username:</label>
@@ -31,12 +21,6 @@
<label for="password">Password:</label> <label for="password">Password:</label>
<input type="password" id="password" name="password" required> <input type="password" id="password" name="password" required>
</div> </div>
<div class="form-group form-group--checkbox">
<label class="checkbox-label">
<input type="checkbox" id="remember-me" name="rememberMe">
<span>Keep me logged in</span>
</label>
</div>
<button type="submit" class="login-btn">Login</button> <button type="submit" class="login-btn">Login</button>
</form> </form>
<div id="login-error" class="error-message" style="display: none;"></div> <div id="login-error" class="error-message" style="display: none;"></div>
@@ -53,12 +37,20 @@
<button class="theme-btn" data-theme="dark">Dark</button> <button class="theme-btn" data-theme="dark">Dark</button>
<button class="theme-btn" data-theme="mono">Mono</button> <button class="theme-btn" data-theme="mono">Mono</button>
</div> </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;"> <div id="admin-controls" class="admin-controls" style="display: none;">
<label class="toggle-label"> <label class="toggle-label">
<input type="checkbox" id="show-all-toggle"> <input type="checkbox" id="show-all-toggle">
<span>Show all users</span> <span>Show all users</span>
</label> </label>
<button id="status-btn" class="status-btn">Status</button>
</div> </div>
<div class="user-info"> <div class="user-info">
<span class="user-label">Current User:</span> <span class="user-label">Current User:</span>
@@ -68,8 +60,6 @@
</div> </div>
</header> </header>
<div id="status-panel" class="status-panel" style="display: none;"></div>
<div id="error-message" class="error-message" style="display: none;"></div> <div id="error-message" class="error-message" style="display: none;"></div>
<div id="loading" class="loading" style="display: none;">Loading downloads...</div> <div id="loading" class="loading" style="display: none;">Loading downloads...</div>
+9 -456
View File
@@ -1,34 +1,3 @@
/* ===== Splash Screen ===== */
.splash-screen {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background: #f8f9fa;
z-index: 9999;
opacity: 1;
transition: opacity 0.4s ease-out;
}
.splash-screen.fade-out {
opacity: 0;
}
.splash-logo {
max-width: 280px;
width: 60%;
animation: splashPulse 1.8s ease-in-out infinite;
}
@keyframes splashPulse {
0%, 100% { transform: scale(1); opacity: 1; }
50% { transform: scale(1.03); opacity: 0.85; }
}
/* ===== Theme Variables ===== */ /* ===== Theme Variables ===== */
:root, [data-theme="light"] { :root, [data-theme="light"] {
--bg-gradient-start: #667eea; --bg-gradient-start: #667eea;
@@ -64,8 +33,6 @@
--footer-text: rgba(255, 255, 255, 0.9); --footer-text: rgba(255, 255, 255, 0.9);
--input-bg: #ffffff; --input-bg: #ffffff;
--select-bg: #ffffff; --select-bg: #ffffff;
--unmatched-tag-bg: #fff3e0;
--unmatched-tag-color: #e65100;
} }
[data-theme="dark"] { [data-theme="dark"] {
@@ -102,8 +69,6 @@
--footer-text: rgba(200, 200, 220, 0.8); --footer-text: rgba(200, 200, 220, 0.8);
--input-bg: #2a2a3d; --input-bg: #2a2a3d;
--select-bg: #2a2a3d; --select-bg: #2a2a3d;
--unmatched-tag-bg: #3d2a00;
--unmatched-tag-color: #ffb74d;
} }
[data-theme="mono"] { [data-theme="mono"] {
@@ -140,8 +105,6 @@
--footer-text: rgba(180, 180, 180, 0.7); --footer-text: rgba(180, 180, 180, 0.7);
--input-bg: #252525; --input-bg: #252525;
--select-bg: #252525; --select-bg: #252525;
--unmatched-tag-bg: #2a2a2a;
--unmatched-tag-color: #a0a0a0;
} }
/* ===== Base ===== */ /* ===== Base ===== */
@@ -384,9 +347,8 @@ body {
.download-header { .download-header {
display: flex; display: flex;
flex-wrap: wrap; justify-content: space-between;
align-items: center; align-items: center;
gap: 4px;
margin-bottom: 4px; margin-bottom: 4px;
} }
@@ -445,9 +407,9 @@ body {
margin-bottom: 2px; margin-bottom: 2px;
font-size: 0.9rem; font-size: 0.9rem;
font-weight: 600; font-weight: 600;
overflow-wrap: break-word; white-space: nowrap;
word-break: break-word;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis;
} }
.download-series, .download-series,
@@ -530,8 +492,7 @@ body {
color: var(--danger); color: var(--danger);
font-size: 0.72rem; font-size: 0.72rem;
font-weight: 500; font-weight: 500;
overflow-wrap: break-word; white-space: nowrap;
word-break: break-word;
} }
/* ===== Footer ===== */ /* ===== Footer ===== */
@@ -553,12 +514,6 @@ body {
align-items: center; align-items: center;
min-height: 100vh; min-height: 100vh;
padding: 20px; padding: 20px;
opacity: 1;
transition: opacity 0.3s ease-out;
}
.login-container.fade-out {
opacity: 0;
} }
.login-box { .login-box {
@@ -569,24 +524,17 @@ body {
width: 100%; width: 100%;
max-width: 380px; max-width: 380px;
transition: background 0.3s; transition: background 0.3s;
text-align: center;
} }
.login-logo { .login-box h2 {
max-width: 180px; color: var(--text-primary);
width: 60%;
margin-bottom: 12px;
}
.login-subtitle {
color: var(--text-secondary);
margin-bottom: 24px; margin-bottom: 24px;
font-size: 0.85rem; text-align: center;
font-size: 1.5rem;
} }
.form-group { .form-group {
margin-bottom: 16px; margin-bottom: 16px;
text-align: left;
} }
.form-group label { .form-group label {
@@ -612,32 +560,6 @@ body {
border-color: var(--accent); border-color: var(--accent);
} }
.form-group--checkbox {
margin-bottom: 20px;
}
.checkbox-label {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
font-size: 0.9rem;
color: var(--text-secondary);
user-select: none;
}
.checkbox-label input[type="checkbox"] {
width: 16px;
height: 16px;
accent-color: var(--accent);
cursor: pointer;
flex-shrink: 0;
}
.checkbox-label span {
line-height: 1;
}
.login-btn { .login-btn {
width: 100%; width: 100%;
padding: 10px; padding: 10px;
@@ -680,80 +602,6 @@ body {
accent-color: var(--accent); accent-color: var(--accent);
} }
/* ===== Arr Links (Admin) ===== */
.arr-link {
color: var(--accent);
text-decoration: none;
border-bottom: 1px dotted var(--accent);
}
.arr-link:hover {
opacity: 0.8;
border-bottom-style: solid;
}
/* ===== Download Paths (Admin) ===== */
.download-paths {
flex-basis: 100%;
display: flex;
flex-direction: column;
gap: 2px;
margin-top: 2px;
}
.path-item {
font-size: 0.7rem;
font-family: 'SF Mono', 'Fira Code', 'Fira Mono', Menlo, Consolas, monospace;
color: var(--text-muted);
overflow-wrap: break-word;
word-break: break-all;
overflow: hidden;
}
.path-label {
font-weight: 600;
color: var(--text-secondary);
font-size: 0.65rem;
text-transform: uppercase;
letter-spacing: 0.3px;
}
.path-value {
color: var(--text-muted);
}
/* ===== Import Issue Badge ===== */
.import-issue-badge {
padding: 2px 8px;
border-radius: 10px;
font-size: 0.65rem;
font-weight: 600;
background: #ffebee;
color: #c62828;
cursor: help;
position: relative;
white-space: nowrap;
}
.import-issue-badge:hover::after {
content: attr(data-tooltip);
position: absolute;
top: calc(100% + 6px);
left: 0;
background: #424242;
color: #fff;
padding: 8px 12px;
border-radius: 6px;
font-size: 0.7rem;
font-weight: 400;
white-space: pre-line;
max-width: 320px;
z-index: 100;
box-shadow: 0 2px 8px rgba(0,0,0,0.25);
line-height: 1.4;
pointer-events: none;
}
.download-user-badge { .download-user-badge {
padding: 2px 8px; padding: 2px 8px;
border-radius: 10px; border-radius: 10px;
@@ -763,256 +611,21 @@ body {
background: var(--accent-light); background: var(--accent-light);
color: var(--accent); color: var(--accent);
margin-left: auto; margin-left: auto;
white-space: nowrap;
}
.download-user-badge.unmatched {
background: var(--unmatched-tag-bg);
color: var(--unmatched-tag-color);
margin-left: 0;
}
/* ===== Status Button ===== */
.status-btn {
padding: 4px 12px;
border: 1px solid var(--border);
border-radius: 5px;
background: var(--surface);
color: var(--text-secondary);
cursor: pointer;
font-size: 0.8rem;
font-weight: 500;
transition: background 0.2s, color 0.2s;
}
.status-btn:hover {
background: var(--accent);
color: white;
border-color: var(--accent);
}
/* ===== Status Panel ===== */
.status-panel {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 10px;
padding: 20px;
margin-bottom: 16px;
box-shadow: 0 2px 4px var(--shadow);
}
.status-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.status-header h3 {
margin: 0;
color: var(--text-primary);
font-size: 1.1rem;
}
.status-close {
background: none;
border: none;
font-size: 1.4rem;
color: var(--text-secondary);
cursor: pointer;
padding: 0 4px;
line-height: 1;
}
.status-close:hover {
color: var(--text-primary);
}
.status-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
}
.status-card {
background: var(--background);
border: 1px solid var(--border);
border-radius: 8px;
padding: 14px;
}
.status-card-wide {
grid-column: 1 / -1;
}
.status-card-title {
font-weight: 600;
font-size: 0.85rem;
color: var(--text-primary);
margin-bottom: 10px;
padding-bottom: 6px;
border-bottom: 1px solid var(--border);
}
.status-row {
display: flex;
justify-content: space-between;
padding: 3px 0;
font-size: 0.8rem;
color: var(--text-secondary);
}
.status-row span:last-child {
font-weight: 500;
color: var(--text-primary);
}
.status-table {
width: 100%;
border-collapse: collapse;
font-size: 0.78rem;
}
.status-table th {
text-align: left;
padding: 4px 8px;
color: var(--text-secondary);
font-weight: 600;
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.3px;
border-bottom: 1px solid var(--border);
}
.status-table td {
padding: 5px 8px;
color: var(--text-primary);
border-bottom: 1px solid var(--border);
}
.status-table code {
font-size: 0.75rem;
background: var(--surface);
padding: 1px 4px;
border-radius: 3px;
}
.status-expired {
color: #c62828;
font-weight: 600;
font-size: 0.7rem;
}
.status-fg-badge {
background: #fff3e0;
color: #e65100;
padding: 1px 8px;
border-radius: 8px;
font-size: 0.75rem;
font-weight: 600;
}
.status-row-sub {
padding-left: 12px;
font-size: 0.75rem;
opacity: 0.8;
}
.status-row-sub span:first-child {
font-style: italic;
}
.status-timings {
display: flex;
flex-direction: column;
gap: 6px;
}
.timing-row {
display: flex;
align-items: center;
gap: 8px;
font-size: 0.78rem;
}
.timing-label {
width: 110px;
flex-shrink: 0;
color: var(--text-secondary);
white-space: nowrap;
}
.timing-bar-bg {
flex: 1;
height: 8px;
background: var(--border);
border-radius: 4px;
overflow: hidden;
}
.timing-bar {
height: 100%;
background: var(--accent);
border-radius: 4px;
min-width: 2px;
transition: width 0.3s ease;
}
.timing-value {
width: 50px;
flex-shrink: 0;
text-align: right;
color: var(--text-primary);
font-weight: 500;
font-size: 0.75rem;
}
.status-loading, .status-error {
text-align: center;
padding: 20px;
color: var(--text-secondary);
font-size: 0.9rem;
}
.status-error {
color: #c62828;
} }
/* ===== Mobile ===== */ /* ===== Mobile ===== */
@media (max-width: 768px) { @media (max-width: 768px) {
.app {
padding: 10px;
}
.app-header { .app-header {
flex-direction: column; flex-direction: column;
align-items: flex-start; align-items: flex-start;
padding: 10px 12px; padding: 12px 14px;
gap: 8px;
} }
.header-controls { .header-controls {
width: 100%;
flex-wrap: wrap; flex-wrap: wrap;
gap: 8px; gap: 8px;
} }
.user-info {
width: 100%;
justify-content: space-between;
box-sizing: border-box;
}
.admin-controls {
width: 100%;
flex-wrap: wrap;
gap: 6px;
}
.downloads-container {
padding: 12px;
}
.download-card { .download-card {
padding: 8px 10px; padding: 8px 10px;
} }
@@ -1029,64 +642,4 @@ body {
.progress-container { .progress-container {
flex-wrap: wrap; flex-wrap: wrap;
} }
.status-grid {
grid-template-columns: 1fr;
}
.status-table {
font-size: 0.72rem;
}
.status-table th,
.status-table td {
padding: 4px 4px;
}
.status-table td code {
word-break: break-all;
}
.timing-label {
width: 90px;
}
.timing-value {
width: 40px;
}
.import-issue-badge:hover::after {
left: auto;
right: 0;
max-width: calc(100vw - 24px);
}
}
/* ===== Very small screens (≤ 400px) ===== */
@media (max-width: 400px) {
.app {
padding: 6px;
}
.download-cover {
display: none;
}
.theme-switcher {
flex-shrink: 0;
}
.user-info {
font-size: 0.78rem;
padding: 5px 10px;
}
.download-card {
padding: 8px;
}
.timing-label {
width: 75px;
font-size: 0.7rem;
}
} }
-114
View File
@@ -1,114 +0,0 @@
/**
* 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 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.NODE_ENV === 'production' ? [] : 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);
// 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 };
+9 -194
View File
@@ -1,9 +1,7 @@
const express = require('express'); const express = require('express');
const cors = require('cors');
const path = require('path'); const path = require('path');
const cookieParser = require('cookie-parser'); const cookieParser = require('cookie-parser');
const helmet = require('helmet');
const rateLimit = require('express-rate-limit');
const crypto = require('crypto');
const fs = require('fs'); const fs = require('fs');
require('dotenv').config(); require('dotenv').config();
@@ -12,29 +10,7 @@ require('dotenv').config();
const LOG_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 currentLevel = LOG_LEVELS[(process.env.LOG_LEVEL || 'info').toLowerCase()] || 1;
// Log file lives in DATA_DIR so the non-root container user can write to it const logFile = fs.createWriteStream(path.join(__dirname, '../server.log'), { flags: 'a' });
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 originalConsoleLog = console.log;
const originalConsoleError = console.error; const originalConsoleError = console.error;
const originalConsoleWarn = console.warn; const originalConsoleWarn = console.warn;
@@ -78,182 +54,24 @@ const radarrRoutes = require('./routes/radarr');
const embyRoutes = require('./routes/emby'); const embyRoutes = require('./routes/emby');
const dashboardRoutes = require('./routes/dashboard'); const dashboardRoutes = require('./routes/dashboard');
const authRoutes = require('./routes/auth'); const authRoutes = require('./routes/auth');
const verifyCsrf = require('./middleware/verifyCsrf');
const { startPoller, POLL_INTERVAL, POLLING_ENABLED } = require('./utils/poller');
// ---------------------------------------------------------------------------
// 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)');
}
if (!process.env.EMBY_URL && process.env.NODE_ENV === 'production') {
console.error('[Config] EMBY_URL is required');
process.exit(1);
}
const app = express(); const app = express();
const PORT = process.env.PORT || 3001; const PORT = process.env.PORT || 3001;
// --------------------------------------------------------------------------- app.use(cors());
// Trust proxy — required when behind Nginx/Caddy/Traefik so that app.use(cookieParser());
// req.ip reflects the real client IP (not 127.0.0.1) and app.use(express.json());
// req.secure is true when the upstream TLS is terminated by the proxy. app.use(express.static(path.join(__dirname, '../public')));
// 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.NODE_ENV === 'production' ? [] : 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() });
});
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');
}
}
}));
// Serve index.html with nonce injected into the <script> and <link> 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;
// Inject nonce into <script> and <link rel="stylesheet"> tags
const patched = html
.replace(/<script([^>]*)>/gi, `<script nonce="${nonce}"$1>`)
.replace(/<link([^>]*rel=["']stylesheet["'][^>]*)>/gi, `<link nonce="${nonce}"$1>`);
res.setHeader('Content-Type', 'text/html; charset=utf-8');
res.send(patched);
});
}
// ---------------------------------------------------------------------------
// 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/sabnzbd', sabnzbdRoutes);
app.use('/api/sonarr', sonarrRoutes); app.use('/api/sonarr', sonarrRoutes);
app.use('/api/radarr', radarrRoutes); app.use('/api/radarr', radarrRoutes);
app.use('/api/emby', embyRoutes); app.use('/api/emby', embyRoutes);
app.use('/api/dashboard', dashboardRoutes); app.use('/api/dashboard', dashboardRoutes);
app.use('/api/auth', authRoutes);
// SPA catch-all — serve index.html for any unmatched path app.get('/', (req, res) => {
app.get('*', serveIndex); res.sendFile(path.join(__dirname, '../public/index.html'));
// ---------------------------------------------------------------------------
// 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, () => { app.listen(PORT, () => {
@@ -261,8 +79,5 @@ app.listen(PORT, () => {
console.log(` sofarr - Your Downloads Dashboard`); console.log(` sofarr - Your Downloads Dashboard`);
console.log(` Server running on port ${PORT}`); console.log(` Server running on port ${PORT}`);
console.log(` Log level: ${process.env.LOG_LEVEL || 'info'}`); 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(`=================================`); console.log(`=================================`);
startPoller();
}); });
-21
View File
@@ -1,21 +0,0 @@
function requireAuth(req, res, next) {
const signed = !!process.env.COOKIE_SECRET;
const raw = signed ? req.signedCookies.emby_user : req.cookies.emby_user;
if (!raw || raw === false) {
return res.status(401).json({ error: 'Not authenticated' });
}
let u;
try {
u = JSON.parse(raw);
} catch {
return res.status(401).json({ error: 'Invalid session' });
}
// Schema validation
if (typeof u.id !== 'string' || !u.id) return res.status(401).json({ error: 'Invalid session' });
if (typeof u.name !== 'string' || !u.name) return res.status(401).json({ error: 'Invalid session' });
if (typeof u.isAdmin !== 'boolean') u.isAdmin = !!u.isAdmin;
req.user = u;
next();
}
module.exports = requireAuth;
-42
View File
@@ -1,42 +0,0 @@
/**
* 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;
+49 -136
View File
@@ -1,105 +1,60 @@
const express = require('express'); const express = require('express');
const axios = require('axios'); const axios = require('axios');
const crypto = require('crypto');
const rateLimit = require('express-rate-limit');
const router = express.Router(); const router = express.Router();
// Persistent JSON file-backed token store — survives restarts const EMBY_URL = process.env.EMBY_URL;
const { storeToken, getToken, clearToken } = require('../utils/tokenStore'); const EMBY_API_KEY = process.env.EMBY_API_KEY;
// 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,
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' }
});
// Authenticate user with Emby // Authenticate user with Emby
router.post('/login', loginLimiter, async (req, res) => { router.post('/login', async (req, res) => {
try { try {
const { username, password, rememberMe } = req.body; const { username, password } = 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.trim()}`); console.log(`[Auth] Attempting login for user: ${username}`);
// Authenticate with Emby using a stable DeviceId derived from the username. // Authenticate with Emby
// Using a deterministic DeviceId causes Emby to reuse the existing session const authResponse = await axios.post(`${EMBY_URL}/Users/authenticatebyname`, {
// for this device rather than creating a new one on each login. 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 Pw: password
}, { }, {
headers: { headers: {
'X-Emby-Authorization': `MediaBrowser Client="sofarr", Device="sofarr", DeviceId="${stableDeviceId}", Version="1.0.0"` 'X-Emby-Authorization': `MediaBrowser Client="MediaDashboard", Device="Browser", DeviceId="dashboard-${Date.now()}", Version="1.0.0"`
} }
}); });
const authData = authResponse.data; const authData = authResponse.data;
console.log(`[Auth] Emby auth response:`, JSON.stringify(authData));
// Get user info using the access token // Get user info using the access token
const userResponse = await axios.get(`${getEmbyUrl()}/Users/${authData.User.Id || authData.User.id}`, { const userResponse = await axios.get(`${EMBY_URL}/Users/${authData.User.Id || authData.User.id}`, {
headers: { headers: {
'X-MediaBrowser-Token': authData.AccessToken 'X-MediaBrowser-Token': authData.AccessToken
} }
}); });
const user = userResponse.data; const user = userResponse.data;
const isAdmin = !!(user.Policy && user.Policy.IsAdministrator); console.log(`[Auth] User info:`, JSON.stringify(user));
console.log(`[Auth] Login successful for user: ${user.Name}, isAdmin: ${isAdmin}`); console.log(`[Auth] Login successful for user: ${user.Name}`);
// Store token server-side; it is never sent to the client.
storeToken(user.Id, authData.AccessToken);
// Set authentication cookie (signed when COOKIE_SECRET is set). // Set authentication cookie
// rememberMe=true → persistent cookie, expires in 30 days const isAdmin = !!(user.Policy && user.Policy.IsAdministrator);
// rememberMe=false → session cookie, expires when browser closes res.cookie('emby_user', JSON.stringify({
// secure is always true — the app should sit behind HTTPS in production; id: user.Id,
// behind a reverse proxy set TRUST_PROXY=1 so req.secure works correctly. name: user.Name,
const cookiePayload = JSON.stringify({ id: user.Id, name: user.Name, isAdmin }); isAdmin: isAdmin,
const signed = !!process.env.COOKIE_SECRET; token: authData.AccessToken
const cookieOptions = { }), {
httpOnly: true, httpOnly: true,
secure: process.env.NODE_ENV === 'production', maxAge: 24 * 60 * 60 * 1000 // 24 hours
sameSite: 'strict',
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: process.env.NODE_ENV === 'production',
sameSite: 'strict',
path: '/'
}); });
res.json({ res.json({
success: true, success: true,
user: { id: user.Id, name: user.Name, isAdmin }, user: {
csrfToken id: user.Id,
name: user.Name,
isAdmin: isAdmin
}
}); });
} catch (error) { } catch (error) {
console.error(`[Auth] Login failed:`, error.message); console.error(`[Auth] Login failed:`, error.message);
@@ -110,75 +65,33 @@ router.post('/login', loginLimiter, async (req, res) => {
} }
}); });
function parseSessionCookie(req) {
const signed = !!process.env.COOKIE_SECRET;
const raw = signed ? req.signedCookies.emby_user : req.cookies.emby_user;
if (!raw || raw === false) return null; // false = tampered signed cookie
try {
const u = JSON.parse(raw);
// Schema validation: require id (string), name (string), isAdmin (boolean)
if (typeof u.id !== 'string' || !u.id) return null;
if (typeof u.name !== 'string' || !u.name) return null;
if (typeof u.isAdmin !== 'boolean') u.isAdmin = !!u.isAdmin;
return u;
} catch {
return null;
}
}
// Get current authenticated user // Get current authenticated user
router.get('/me', (req, res) => { router.get('/me', (req, res) => {
const user = parseSessionCookie(req); try {
if (!user) return res.json({ authenticated: false }); const userCookie = req.cookies.emby_user;
res.json({
authenticated: true, if (!userCookie) {
user: { id: user.id, name: user.name, isAdmin: user.isAdmin } return res.json({ authenticated: false });
}); }
});
const user = JSON.parse(userCookie);
// CSRF token refresh — lets the SPA get a new token without re-logging-in res.json({
// (e.g. after a page reload where the JS variable was lost) authenticated: true,
router.get('/csrf', (req, res) => { user: {
const csrfToken = crypto.randomBytes(32).toString('hex'); id: user.id,
res.cookie('csrf_token', csrfToken, { name: user.name,
httpOnly: false, isAdmin: !!user.isAdmin
secure: process.env.NODE_ENV === 'production', }
sameSite: 'strict', });
path: '/' } catch (error) {
}); console.error(`[Auth] Error getting current user:`, error.message);
res.json({ csrfToken }); res.json({ authenticated: false });
}
}); });
// Logout // Logout
router.post('/logout', async (req, res) => { router.post('/logout', (req, res) => {
const user = parseSessionCookie(req); res.clearCookie('emby_user');
if (user) {
const stored = getToken(user.id);
if (stored) {
try {
await axios.post(`${getEmbyUrl()}/Sessions/Logout`, {}, {
headers: { 'X-MediaBrowser-Token': stored.accessToken }
});
console.log(`[Auth] Revoked Emby token for user: ${user.name}`);
} catch (err) {
console.warn(`[Auth] Failed to revoke Emby token for ${user.name}:`, err.message);
}
clearToken(user.id);
}
}
res.clearCookie('emby_user', {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
signed: !!process.env.COOKIE_SECRET,
path: '/'
});
res.clearCookie('csrf_token', {
httpOnly: false,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
path: '/'
});
res.json({ success: true }); res.json({ success: true });
}); });
+289 -660
View File
File diff suppressed because it is too large Load Diff
+16 -17
View File
@@ -1,52 +1,51 @@
const express = require('express'); const express = require('express');
const axios = require('axios'); const axios = require('axios');
const router = express.Router(); const router = express.Router();
const requireAuth = require('../middleware/requireAuth');
const sanitizeError = require('../utils/sanitizeError');
router.use(requireAuth); const EMBY_URL = process.env.EMBY_URL;
const EMBY_API_KEY = process.env.EMBY_API_KEY;
// Get active sessions // Get active sessions
router.get('/sessions', async (req, res) => { router.get('/sessions', async (req, res) => {
try { try {
const response = await axios.get(`${process.env.EMBY_URL}/Sessions`, { const response = await axios.get(`${EMBY_URL}/Sessions`, {
headers: { 'X-MediaBrowser-Token': process.env.EMBY_API_KEY } headers: { 'X-MediaBrowser-Token': EMBY_API_KEY }
}); });
res.json(response.data); res.json(response.data);
} catch (error) { } catch (error) {
res.status(500).json({ error: 'Failed to fetch Emby sessions', details: sanitizeError(error) }); res.status(500).json({ error: 'Failed to fetch Emby sessions', details: error.message });
} }
}); });
// Get user by ID // Get user by ID
router.get('/users/:id', async (req, res) => { router.get('/users/:id', async (req, res) => {
try { try {
const response = await axios.get(`${process.env.EMBY_URL}/Users/${req.params.id}`, { const response = await axios.get(`${EMBY_URL}/Users/${req.params.id}`, {
headers: { 'X-MediaBrowser-Token': process.env.EMBY_API_KEY } headers: { 'X-MediaBrowser-Token': EMBY_API_KEY }
}); });
res.json(response.data); res.json(response.data);
} catch (error) { } catch (error) {
res.status(500).json({ error: 'Failed to fetch user details', details: sanitizeError(error) }); res.status(500).json({ error: 'Failed to fetch user details', details: error.message });
} }
}); });
// Get all users // Get all users
router.get('/users', async (req, res) => { router.get('/users', async (req, res) => {
try { try {
const response = await axios.get(`${process.env.EMBY_URL}/Users`, { const response = await axios.get(`${EMBY_URL}/Users`, {
headers: { 'X-MediaBrowser-Token': process.env.EMBY_API_KEY } headers: { 'X-MediaBrowser-Token': EMBY_API_KEY }
}); });
res.json(response.data); res.json(response.data);
} catch (error) { } catch (error) {
res.status(500).json({ error: 'Failed to fetch users', details: sanitizeError(error) }); res.status(500).json({ error: 'Failed to fetch users', details: error.message });
} }
}); });
// Get current user by session ID // Get current user by session ID
router.get('/session/:sessionId/user', async (req, res) => { router.get('/session/:sessionId/user', async (req, res) => {
try { try {
const response = await axios.get(`${process.env.EMBY_URL}/Sessions`, { const response = await axios.get(`${EMBY_URL}/Sessions`, {
headers: { 'X-MediaBrowser-Token': process.env.EMBY_API_KEY } headers: { 'X-MediaBrowser-Token': EMBY_API_KEY }
}); });
const session = response.data.find(s => s.Id === req.params.sessionId); const session = response.data.find(s => s.Id === req.params.sessionId);
@@ -54,13 +53,13 @@ router.get('/session/:sessionId/user', async (req, res) => {
return res.status(404).json({ error: 'Session not found' }); return res.status(404).json({ error: 'Session not found' });
} }
const userResponse = await axios.get(`${process.env.EMBY_URL}/Users/${session.UserId}`, { const userResponse = await axios.get(`${EMBY_URL}/Users/${session.UserId}`, {
headers: { 'X-MediaBrowser-Token': process.env.EMBY_API_KEY } headers: { 'X-MediaBrowser-Token': EMBY_API_KEY }
}); });
res.json(userResponse.data); res.json(userResponse.data);
} catch (error) { } catch (error) {
res.status(500).json({ error: 'Failed to fetch user from session', details: sanitizeError(error) }); res.status(500).json({ error: 'Failed to fetch user from session', details: error.message });
} }
}); });
+14 -15
View File
@@ -1,57 +1,56 @@
const express = require('express'); const express = require('express');
const axios = require('axios'); const axios = require('axios');
const router = express.Router(); const router = express.Router();
const requireAuth = require('../middleware/requireAuth');
const sanitizeError = require('../utils/sanitizeError');
router.use(requireAuth); const RADARR_URL = process.env.RADARR_URL;
const RADARR_API_KEY = process.env.RADARR_API_KEY;
// Get queue // Get queue
router.get('/queue', async (req, res) => { router.get('/queue', async (req, res) => {
try { try {
const response = await axios.get(`${process.env.RADARR_URL}/api/v3/queue`, { const response = await axios.get(`${RADARR_URL}/api/v3/queue`, {
headers: { 'X-Api-Key': process.env.RADARR_API_KEY } headers: { 'X-Api-Key': RADARR_API_KEY }
}); });
res.json(response.data); res.json(response.data);
} catch (error) { } catch (error) {
res.status(500).json({ error: 'Failed to fetch Radarr queue', details: sanitizeError(error) }); res.status(500).json({ error: 'Failed to fetch Radarr queue', details: error.message });
} }
}); });
// Get history // Get history
router.get('/history', async (req, res) => { router.get('/history', async (req, res) => {
try { try {
const response = await axios.get(`${process.env.RADARR_URL}/api/v3/history`, { const response = await axios.get(`${RADARR_URL}/api/v3/history`, {
headers: { 'X-Api-Key': process.env.RADARR_API_KEY }, headers: { 'X-Api-Key': RADARR_API_KEY },
params: { pageSize: req.query.pageSize || 50 } params: { pageSize: req.query.pageSize || 50 }
}); });
res.json(response.data); res.json(response.data);
} catch (error) { } catch (error) {
res.status(500).json({ error: 'Failed to fetch Radarr history', details: sanitizeError(error) }); res.status(500).json({ error: 'Failed to fetch Radarr history', details: error.message });
} }
}); });
// Get movie details // Get movie details
router.get('/movies/:id', async (req, res) => { router.get('/movies/:id', async (req, res) => {
try { try {
const response = await axios.get(`${process.env.RADARR_URL}/api/v3/movie/${req.params.id}`, { const response = await axios.get(`${RADARR_URL}/api/v3/movie/${req.params.id}`, {
headers: { 'X-Api-Key': process.env.RADARR_API_KEY } headers: { 'X-Api-Key': RADARR_API_KEY }
}); });
res.json(response.data); res.json(response.data);
} catch (error) { } catch (error) {
res.status(500).json({ error: 'Failed to fetch movie details', details: sanitizeError(error) }); res.status(500).json({ error: 'Failed to fetch movie details', details: error.message });
} }
}); });
// Get all movies with tags // Get all movies with tags
router.get('/movies', async (req, res) => { router.get('/movies', async (req, res) => {
try { try {
const response = await axios.get(`${process.env.RADARR_URL}/api/v3/movie`, { const response = await axios.get(`${RADARR_URL}/api/v3/movie`, {
headers: { 'X-Api-Key': process.env.RADARR_API_KEY } headers: { 'X-Api-Key': RADARR_API_KEY }
}); });
res.json(response.data); res.json(response.data);
} catch (error) { } catch (error) {
res.status(500).json({ error: 'Failed to fetch movies', details: sanitizeError(error) }); res.status(500).json({ error: 'Failed to fetch movies', details: error.message });
} }
}); });
+8 -9
View File
@@ -1,41 +1,40 @@
const express = require('express'); const express = require('express');
const axios = require('axios'); const axios = require('axios');
const router = express.Router(); const router = express.Router();
const requireAuth = require('../middleware/requireAuth');
const sanitizeError = require('../utils/sanitizeError');
router.use(requireAuth); const SABNZBD_URL = process.env.SABNZBD_URL;
const SABNZBD_API_KEY = process.env.SABNZBD_API_KEY;
// Get current queue // Get current queue
router.get('/queue', async (req, res) => { router.get('/queue', async (req, res) => {
try { try {
const response = await axios.get(`${process.env.SABNZBD_URL}/api`, { const response = await axios.get(`${SABNZBD_URL}/api`, {
params: { params: {
mode: 'queue', mode: 'queue',
apikey: process.env.SABNZBD_API_KEY, apikey: SABNZBD_API_KEY,
output: 'json' output: 'json'
} }
}); });
res.json(response.data); res.json(response.data);
} catch (error) { } catch (error) {
res.status(500).json({ error: 'Failed to fetch SABnzbd queue', details: sanitizeError(error) }); res.status(500).json({ error: 'Failed to fetch SABnzbd queue', details: error.message });
} }
}); });
// Get history // Get history
router.get('/history', async (req, res) => { router.get('/history', async (req, res) => {
try { try {
const response = await axios.get(`${process.env.SABNZBD_URL}/api`, { const response = await axios.get(`${SABNZBD_URL}/api`, {
params: { params: {
mode: 'history', mode: 'history',
apikey: process.env.SABNZBD_API_KEY, apikey: SABNZBD_API_KEY,
output: 'json', output: 'json',
limit: req.query.limit || 50 limit: req.query.limit || 50
} }
}); });
res.json(response.data); res.json(response.data);
} catch (error) { } catch (error) {
res.status(500).json({ error: 'Failed to fetch SABnzbd history', details: sanitizeError(error) }); res.status(500).json({ error: 'Failed to fetch SABnzbd history', details: error.message });
} }
}); });
+14 -15
View File
@@ -1,57 +1,56 @@
const express = require('express'); const express = require('express');
const axios = require('axios'); const axios = require('axios');
const router = express.Router(); const router = express.Router();
const requireAuth = require('../middleware/requireAuth');
const sanitizeError = require('../utils/sanitizeError');
router.use(requireAuth); const SONARR_URL = process.env.SONARR_URL;
const SONARR_API_KEY = process.env.SONARR_API_KEY;
// Get queue // Get queue
router.get('/queue', async (req, res) => { router.get('/queue', async (req, res) => {
try { try {
const response = await axios.get(`${process.env.SONARR_URL}/api/v3/queue`, { const response = await axios.get(`${SONARR_URL}/api/v3/queue`, {
headers: { 'X-Api-Key': process.env.SONARR_API_KEY } headers: { 'X-Api-Key': SONARR_API_KEY }
}); });
res.json(response.data); res.json(response.data);
} catch (error) { } catch (error) {
res.status(500).json({ error: 'Failed to fetch Sonarr queue', details: sanitizeError(error) }); res.status(500).json({ error: 'Failed to fetch Sonarr queue', details: error.message });
} }
}); });
// Get history // Get history
router.get('/history', async (req, res) => { router.get('/history', async (req, res) => {
try { try {
const response = await axios.get(`${process.env.SONARR_URL}/api/v3/history`, { const response = await axios.get(`${SONARR_URL}/api/v3/history`, {
headers: { 'X-Api-Key': process.env.SONARR_API_KEY }, headers: { 'X-Api-Key': SONARR_API_KEY },
params: { pageSize: req.query.pageSize || 50 } params: { pageSize: req.query.pageSize || 50 }
}); });
res.json(response.data); res.json(response.data);
} catch (error) { } catch (error) {
res.status(500).json({ error: 'Failed to fetch Sonarr history', details: sanitizeError(error) }); res.status(500).json({ error: 'Failed to fetch Sonarr history', details: error.message });
} }
}); });
// Get series details // Get series details
router.get('/series/:id', async (req, res) => { router.get('/series/:id', async (req, res) => {
try { try {
const response = await axios.get(`${process.env.SONARR_URL}/api/v3/series/${req.params.id}`, { const response = await axios.get(`${SONARR_URL}/api/v3/series/${req.params.id}`, {
headers: { 'X-Api-Key': process.env.SONARR_API_KEY } headers: { 'X-Api-Key': SONARR_API_KEY }
}); });
res.json(response.data); res.json(response.data);
} catch (error) { } catch (error) {
res.status(500).json({ error: 'Failed to fetch series details', details: sanitizeError(error) }); res.status(500).json({ error: 'Failed to fetch series details', details: error.message });
} }
}); });
// Get all series with tags // Get all series with tags
router.get('/series', async (req, res) => { router.get('/series', async (req, res) => {
try { try {
const response = await axios.get(`${process.env.SONARR_URL}/api/v3/series`, { const response = await axios.get(`${SONARR_URL}/api/v3/series`, {
headers: { 'X-Api-Key': process.env.SONARR_API_KEY } headers: { 'X-Api-Key': SONARR_API_KEY }
}); });
res.json(response.data); res.json(response.data);
} catch (error) { } catch (error) {
res.status(500).json({ error: 'Failed to fetch series', details: sanitizeError(error) }); res.status(500).json({ error: 'Failed to fetch series', details: error.message });
} }
}); });
-74
View File
@@ -1,74 +0,0 @@
const { logToFile } = require('./logger');
class MemoryCache {
constructor() {
this.store = new Map();
}
get(key) {
const entry = this.store.get(key);
if (!entry) return null;
if (Date.now() > entry.expiresAt) {
this.store.delete(key);
return null;
}
return entry.value;
}
set(key, value, ttlMs) {
this.store.set(key, {
value,
expiresAt: Date.now() + ttlMs
});
}
invalidate(key) {
this.store.delete(key);
}
clear() {
this.store.clear();
}
getStats() {
const now = Date.now();
const entries = [];
let totalSize = 0;
for (const [key, entry] of this.store.entries()) {
// 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 (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;
else if (Array.isArray(entry.value.slots)) itemCount = entry.value.slots.length;
}
entries.push({
key,
sizeBytes,
itemCount,
ttlRemainingMs: ttlRemaining,
expired
});
}
return {
entryCount: this.store.size,
totalSizeBytes: totalSize,
entries
};
}
}
const cache = new MemoryCache();
module.exports = cache;
+1 -6
View File
@@ -1,12 +1,7 @@
const fs = require('fs'); const fs = require('fs');
const path = require('path'); const path = require('path');
// Use DATA_DIR so the non-root container user (UID 1000) can write logs. const logFile = fs.createWriteStream(path.join(__dirname, '../../server.log'), { flags: 'a' });
// 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) { function logToFile(message) {
logFile.write(`[${new Date().toISOString()}] ${message}\n`); logFile.write(`[${new Date().toISOString()}] ${message}\n`);
-230
View File
@@ -1,230 +0,0 @@
const axios = require('axios');
const cache = require('./cache');
const { getTorrents } = require('./qbittorrent');
const {
getSABnzbdInstances,
getSonarrInstances,
getRadarrInstances
} = require('./config');
const rawPollInterval = (process.env.POLL_INTERVAL || '').toLowerCase();
const POLL_INTERVAL = (rawPollInterval === 'off' || rawPollInterval === 'false' || rawPollInterval === 'disabled')
? 0
: (parseInt(process.env.POLL_INTERVAL, 10) || 5000);
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();
const result = await fn();
return { label, result, ms: Date.now() - t0 };
}
async function pollAllServices() {
if (polling) {
console.log('[Poller] Previous poll still running, skipping');
return;
}
polling = true;
const start = Date.now();
try {
const sabInstances = getSABnzbdInstances();
const sonarrInstances = getSonarrInstances();
const radarrInstances = getRadarrInstances();
// All fetches in parallel, each individually timed
const results = await Promise.all([
timed('SABnzbd Queue', () => Promise.all(sabInstances.map(inst =>
axios.get(`${inst.url}/api`, {
params: { mode: 'queue', apikey: inst.apiKey, output: 'json' }
}).then(res => ({ instance: inst.id, data: res.data })).catch(err => {
console.error(`[Poller] SABnzbd ${inst.id} queue error:`, err.message);
return { instance: inst.id, data: { queue: { slots: [] } } };
})
))),
timed('SABnzbd History', () => Promise.all(sabInstances.map(inst =>
axios.get(`${inst.url}/api`, {
params: { mode: 'history', apikey: inst.apiKey, output: 'json', limit: 10 }
}).then(res => ({ instance: inst.id, data: res.data })).catch(err => {
console.error(`[Poller] SABnzbd ${inst.id} history error:`, err.message);
return { instance: inst.id, data: { history: { slots: [] } } };
})
))),
timed('Sonarr Tags', () => Promise.all(sonarrInstances.map(inst =>
axios.get(`${inst.url}/api/v3/tag`, {
headers: { 'X-Api-Key': inst.apiKey }
}).then(res => ({ instance: inst.id, data: res.data })).catch(err => {
console.error(`[Poller] Sonarr ${inst.id} tags error:`, err.message);
return { instance: inst.id, data: [] };
})
))),
timed('Sonarr Queue', () => Promise.all(sonarrInstances.map(inst =>
axios.get(`${inst.url}/api/v3/queue`, {
headers: { 'X-Api-Key': inst.apiKey },
params: { includeSeries: 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: [] } };
})
))),
timed('Sonarr History', () => Promise.all(sonarrInstances.map(inst =>
axios.get(`${inst.url}/api/v3/history`, {
headers: { 'X-Api-Key': inst.apiKey },
params: { pageSize: 10 }
}).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: [] } };
})
))),
timed('Radarr Queue', () => Promise.all(radarrInstances.map(inst =>
axios.get(`${inst.url}/api/v3/queue`, {
headers: { 'X-Api-Key': inst.apiKey },
params: { includeMovie: true }
}).then(res => ({ instance: inst.id, data: res.data })).catch(err => {
console.error(`[Poller] Radarr ${inst.id} queue error:`, err.message);
return { instance: inst.id, data: { records: [] } };
})
))),
timed('Radarr History', () => Promise.all(radarrInstances.map(inst =>
axios.get(`${inst.url}/api/v3/history`, {
headers: { 'X-Api-Key': inst.apiKey },
params: { pageSize: 10 }
}).then(res => ({ instance: inst.id, data: res.data })).catch(err => {
console.error(`[Poller] Radarr ${inst.id} history error:`, err.message);
return { instance: inst.id, data: { records: [] } };
})
))),
timed('Radarr Tags', () => Promise.all(radarrInstances.map(inst =>
axios.get(`${inst.url}/api/v3/tag`, {
headers: { 'X-Api-Key': inst.apiKey }
}).then(res => ({ instance: inst.id, data: res.data })).catch(err => {
console.error(`[Poller] Radarr ${inst.id} tags error:`, err.message);
return { instance: inst.id, data: [] };
})
))),
timed('qBittorrent', () => getTorrents().catch(err => {
console.error(`[Poller] qBittorrent error:`, err.message);
return [];
}))
]);
const [
{ result: sabQueues }, { result: sabHistories },
{ result: sonarrTagsResults }, { result: sonarrQueues },
{ result: sonarrHistories },
{ result: radarrQueues }, { result: radarrHistories },
{ result: radarrTagsResults },
{ result: qbittorrentTorrents }
] = results;
// Store per-task timings
const totalMs = Date.now() - start;
lastPollTimings = {
totalMs,
timestamp: new Date().toISOString(),
tasks: results.map(r => ({ label: r.label, ms: r.ms }))
};
// When polling is active, TTL is 3x interval to avoid gaps between polls
// When polling is disabled (on-demand), use 30s so data refreshes on next request after expiry
const cacheTTL = POLLING_ENABLED ? POLL_INTERVAL * 3 : 30000;
// SABnzbd
const firstSabQueue = sabQueues[0] && sabQueues[0].data && sabQueues[0].data.queue;
cache.set('poll:sab-queue', {
slots: sabQueues.flatMap(q => (q.data.queue && q.data.queue.slots) || []),
status: firstSabQueue && firstSabQueue.status,
speed: firstSabQueue && firstSabQueue.speed,
kbpersec: firstSabQueue && firstSabQueue.kbpersec
}, cacheTTL);
cache.set('poll:sab-history', {
slots: sabHistories.flatMap(h => (h.data.history && h.data.history.slots) || [])
}, cacheTTL);
// Sonarr
cache.set('poll:sonarr-tags', sonarrTagsResults, cacheTTL);
// Tag queue/history records with _instanceUrl so embedded series/movie objects can build links
cache.set('poll:sonarr-queue', {
records: sonarrQueues.flatMap(q => {
const inst = sonarrInstances.find(i => i.id === q.instance);
const url = inst ? inst.url : null;
return (q.data.records || []).map(r => {
if (r.series) r.series._instanceUrl = url;
return r;
});
})
}, cacheTTL);
cache.set('poll:sonarr-history', {
records: sonarrHistories.flatMap(h => h.data.records || [])
}, cacheTTL);
// Radarr
cache.set('poll:radarr-queue', {
records: radarrQueues.flatMap(q => {
const inst = radarrInstances.find(i => i.id === q.instance);
const url = inst ? inst.url : null;
return (q.data.records || []).map(r => {
if (r.movie) r.movie._instanceUrl = url;
return r;
});
})
}, cacheTTL);
cache.set('poll:radarr-history', {
records: radarrHistories.flatMap(h => h.data.records || [])
}, cacheTTL);
cache.set('poll:radarr-tags', radarrTagsResults.flatMap(t => t.data || []), cacheTTL);
// qBittorrent
cache.set('poll:qbittorrent', qbittorrentTorrents, cacheTTL);
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 {
polling = false;
}
}
let intervalHandle = null;
function startPoller() {
if (!POLLING_ENABLED) {
console.log(`[Poller] Background polling disabled (POLL_INTERVAL=${process.env.POLL_INTERVAL || 'not set'}). Data will be fetched on-demand.`);
return;
}
console.log(`[Poller] Starting background poller (interval: ${POLL_INTERVAL}ms)`);
// Run immediately, then on interval
pollAllServices();
intervalHandle = setInterval(pollAllServices, POLL_INTERVAL);
}
function stopPoller() {
if (intervalHandle) {
clearInterval(intervalHandle);
intervalHandle = null;
console.log('[Poller] Stopped');
}
}
function getLastPollTimings() {
return lastPollTimings;
}
module.exports = { startPoller, stopPoller, pollAllServices, getLastPollTimings, onPollComplete, offPollComplete, POLL_INTERVAL, POLLING_ENABLED };
+2 -8
View File
@@ -96,19 +96,14 @@ class QBittorrentClient {
} }
} }
// Persist clients so auth cookies survive between requests
let persistedClients = null;
function getClients() { function getClients() {
if (persistedClients) return persistedClients;
const instances = getQbittorrentInstances(); const instances = getQbittorrentInstances();
if (instances.length === 0) { if (instances.length === 0) {
logToFile('[qBittorrent] No instances configured'); logToFile('[qBittorrent] No instances configured');
return []; return [];
} }
logToFile(`[qBittorrent] Created ${instances.length} persistent client(s)`); logToFile(`[qBittorrent] Created ${instances.length} client(s)`);
persistedClients = instances.map(inst => new QBittorrentClient(inst)); return instances.map(inst => new QBittorrentClient(inst));
return persistedClients;
} }
async function getAllTorrents() { async function getAllTorrents() {
@@ -203,7 +198,6 @@ function mapTorrentToDownload(torrent) {
hash: torrent.hash, hash: torrent.hash,
category: torrent.category, category: torrent.category,
tags: torrent.tags, tags: torrent.tags,
savePath: torrent.content_path || torrent.save_path || null,
qbittorrent: true qbittorrent: true
}; };
} }
-21
View File
@@ -1,21 +0,0 @@
// 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;
function sanitizeError(err) {
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]@');
// Never leak stack traces to API responses
return msg;
}
module.exports = sanitizeError;
-97
View File
@@ -1,97 +0,0 @@
/**
* 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);
}
}
};
-67
View File
@@ -1,67 +0,0 @@
# 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.
-299
View File
@@ -1,299 +0,0 @@
/**
* 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);
});
});
-55
View File
@@ -1,55 +0,0 @@
/**
* 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');
});
});
-27
View File
@@ -1,27 +0,0 @@
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();
});
-108
View File
@@ -1,108 +0,0 @@
/**
* 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([]);
});
});
});
-111
View File
@@ -1,111 +0,0 @@
/**
* 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/');
});
});
-140
View File
@@ -1,140 +0,0 @@
/**
* 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);
});
});
});
-121
View File
@@ -1,121 +0,0 @@
/**
* 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:');
});
});
});
-84
View File
@@ -1,84 +0,0 @@
/**
* 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();
});
});
});
-84
View File
@@ -1,84 +0,0 @@
/**
* 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');
});
});
});
-41
View File
@@ -1,41 +0,0 @@
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
}
}
}
});