chore: merge develop into main for v0.2.0 release
This commit is contained in:
@@ -6,6 +6,15 @@ LOG_LEVEL=info
|
||||
# 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
|
||||
|
||||
15
.env.sample
15
.env.sample
@@ -19,6 +19,21 @@ LOG_LEVEL=info
|
||||
# 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.
|
||||
|
||||
@@ -8,7 +8,7 @@ on:
|
||||
|
||||
jobs:
|
||||
audit:
|
||||
name: npm audit
|
||||
name: Security audit
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -16,11 +16,47 @@ jobs:
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "18"
|
||||
node-version: "22"
|
||||
cache: "npm"
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Run security audit
|
||||
run: npm audit --audit-level=moderate
|
||||
- name: Run security audit (fail on high+)
|
||||
run: npm audit --audit-level=high
|
||||
|
||||
- name: Check for critical vulnerabilities
|
||||
run: npm audit --audit-level=critical --json | jq -e '.metadata.vulnerabilities.critical == 0' || (echo "Critical vulnerabilities found!" && exit 1)
|
||||
continue-on-error: false
|
||||
|
||||
test:
|
||||
name: Tests & coverage
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: "npm"
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Run tests with coverage
|
||||
run: npm run test:coverage
|
||||
env:
|
||||
# Required by tokenStore (writable temp dir in CI)
|
||||
DATA_DIR: /tmp/sofarr-ci-data
|
||||
# Disable rate limiters so integration tests don't hit 429s
|
||||
SKIP_RATE_LIMIT: "1"
|
||||
NODE_ENV: test
|
||||
|
||||
- name: Upload coverage report
|
||||
uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
with:
|
||||
name: coverage-report
|
||||
path: coverage/
|
||||
retention-days: 14
|
||||
|
||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -1,7 +1,12 @@
|
||||
node_modules/
|
||||
coverage/
|
||||
.env
|
||||
dist/
|
||||
build/
|
||||
.DS_Store
|
||||
*.log
|
||||
**/*.log
|
||||
data/
|
||||
*.db
|
||||
*.db-wal
|
||||
*.db-shm
|
||||
|
||||
43
Dockerfile
43
Dockerfile
@@ -1,4 +1,18 @@
|
||||
FROM node:18-alpine
|
||||
# ---------------------------------------------------------------------------
|
||||
# Stage 1 — deps: install production dependencies only
|
||||
# ---------------------------------------------------------------------------
|
||||
FROM node:22-alpine AS deps
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# All dependencies are pure JavaScript — no native addons, no build tools.
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci --omit=dev
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Stage 2 — runtime image (minimal attack surface)
|
||||
# ---------------------------------------------------------------------------
|
||||
FROM node:22-alpine AS runtime
|
||||
|
||||
LABEL org.opencontainers.image.title="sofarr"
|
||||
LABEL org.opencontainers.image.description="Personal media download dashboard for *arr services"
|
||||
@@ -9,18 +23,31 @@ LABEL org.opencontainers.image.vendor="Gordon Bolton"
|
||||
LABEL org.opencontainers.image.licenses="MIT"
|
||||
LABEL custom.hardware.requirement="None - runs on any Docker-supported platform including ARM and x86_64"
|
||||
|
||||
# Use the built-in non-root 'node' user (UID 1000) from the official image
|
||||
# The /app directory is owned by root; data directory is owned by node
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files and install dependencies
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci --omit=dev
|
||||
# Copy production deps from deps stage
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
|
||||
# Copy application source
|
||||
COPY server/ ./server/
|
||||
COPY public/ ./public/
|
||||
# Copy application source owned by root (read-only at runtime)
|
||||
COPY --chown=root:root server/ ./server/
|
||||
COPY --chown=root:root public/ ./public/
|
||||
COPY --chown=root:root package.json ./
|
||||
|
||||
# Persistent data directory owned by node user (token store, logs)
|
||||
RUN mkdir -p /app/data && chown node:node /app/data
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV DATA_DIR=/app/data
|
||||
|
||||
# Drop to non-root user for all subsequent operations
|
||||
USER node
|
||||
|
||||
EXPOSE 3001
|
||||
|
||||
ENV NODE_ENV=production
|
||||
# HEALTHCHECK — Docker will restart the container if this fails 3 times
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||||
CMD wget -qO- http://localhost:3001/health || exit 1
|
||||
|
||||
CMD ["node", "server/index.js"]
|
||||
|
||||
11
README.md
11
README.md
@@ -308,6 +308,17 @@ Logs are written to both console and `server.log` file.
|
||||
- Check qBittorrent Web UI is enabled
|
||||
- Verify the URL includes the full path (e.g., `https://qb.example.com`)
|
||||
|
||||
## Testing
|
||||
|
||||
```bash
|
||||
npm test # run all tests once
|
||||
npm run test:watch # watch mode
|
||||
npm run test:coverage # with V8 coverage report (outputs to coverage/)
|
||||
npm run test:ui # interactive Vitest UI
|
||||
```
|
||||
|
||||
115 tests across 8 test files covering the security-critical paths: auth middleware, CSRF protection, secret sanitization, config parsing, token store, and qBittorrent utilities. See [`tests/README.md`](tests/README.md) for design decisions and coverage targets.
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
|
||||
148
SECURITY.md
Normal file
148
SECURITY.md
Normal file
@@ -0,0 +1,148 @@
|
||||
# Security Policy & Hardening Guide
|
||||
|
||||
## Supported Versions
|
||||
|
||||
| Version | Supported |
|
||||
|---------|-----------|
|
||||
| 0.2.x | ✅ Yes |
|
||||
| 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;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Security Headers (emitted by sofarr)
|
||||
|
||||
| Header | Value |
|
||||
|--------|-------|
|
||||
| `Content-Security-Policy` | `default-src 'self'; script-src 'self' 'nonce-…'; style-src 'self' 'nonce-…'; 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
|
||||
@@ -1,17 +1,45 @@
|
||||
version: "3"
|
||||
services:
|
||||
sofarr:
|
||||
image: docker.i3omb.com/sofarr:latest
|
||||
container_name: sofarr
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "3001:3001"
|
||||
- "127.0.0.1:3001:3001" # bind to loopback only — expose via reverse proxy
|
||||
environment:
|
||||
- PORT=3001
|
||||
- NODE_ENV=production
|
||||
- LOG_LEVEL=info
|
||||
# Set to 1 when running behind a reverse proxy (Nginx, Caddy, Traefik)
|
||||
# so Express trusts X-Forwarded-For and X-Forwarded-Proto headers.
|
||||
- TRUST_PROXY=1
|
||||
# --- 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_API_KEY=your-emby-api-key
|
||||
- SONARR_INSTANCES=[{"name":"main","url":"https://sonarr.example.com","apiKey":"your-sonarr-api-key"}]
|
||||
- RADARR_INSTANCES=[{"name":"main","url":"https://radarr.example.com","apiKey":"your-radarr-api-key"}]
|
||||
- SABNZBD_INSTANCES=[{"name":"main","url":"https://sabnzbd.example.com","apiKey":"your-sabnzbd-api-key"}]
|
||||
- QBITTORRENT_INSTANCES=[{"name":"main","url":"https://qbittorrent.example.com","username":"admin","password":"your-password"}]
|
||||
- LOG_LEVEL=info
|
||||
volumes:
|
||||
# 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:
|
||||
|
||||
@@ -80,18 +80,42 @@ Admin users can view all users' downloads, see server status, cache statistics,
|
||||
|
||||
## 2. Technology Stack
|
||||
|
||||
### Runtime & Framework
|
||||
|
||||
| Layer | Technology | Purpose |
|
||||
|-------|-----------|---------|
|
||||
| **Runtime** | Node.js 18+ | Server runtime |
|
||||
|-------|-----------|------|
|
||||
| **Runtime** | Node.js 22 (Alpine) | Server runtime |
|
||||
| **Framework** | Express 4.x | HTTP server, routing, middleware |
|
||||
| **HTTP Client** | axios 1.x | External API communication |
|
||||
| **Frontend** | Vanilla JS + CSS | Single-page app, no build step |
|
||||
| **Auth** | Emby API + httpOnly cookies | Session management |
|
||||
| **Caching** | In-memory Map with TTL | Reduce external API load |
|
||||
| **Scheduling** | `setInterval` | Background polling |
|
||||
| **Containerisation** | Docker (Alpine) | Production deployment |
|
||||
| **Containerisation** | Docker multi-stage (Alpine) | Production deployment |
|
||||
| **Logging** | Custom logger + `console.*` | File + stdout logging with levels |
|
||||
|
||||
### Security Middleware
|
||||
|
||||
| Package | Purpose |
|
||||
|---------|--------|
|
||||
| `helmet` 7.x | HTTP security headers (CSP with per-request nonce, HSTS, referrer policy) |
|
||||
| `express-rate-limit` 7.x | 300 req/15 min general limiter; 10 failed attempts/15 min login limiter |
|
||||
| `cookie-parser` 1.x | Signed cookie support (HMAC via `COOKIE_SECRET`) |
|
||||
|
||||
### Auth & Session
|
||||
|
||||
| Component | Technology | Details |
|
||||
|-----------|-----------|--------|
|
||||
| **Identity** | Emby API | `POST /Users/authenticatebyname` |
|
||||
| **Session cookie** | `httpOnly` + `sameSite: strict` | Signed when `COOKIE_SECRET` set |
|
||||
| **CSRF protection** | Double-submit cookie pattern | `csrf_token` cookie + `X-CSRF-Token` header |
|
||||
| **Token store** | JSON file (`DATA_DIR/tokens.json`) | Atomic writes, 31-day TTL, hourly pruning |
|
||||
|
||||
### Testing
|
||||
|
||||
| Tool | Purpose |
|
||||
|------|---------|
|
||||
| `vitest` 4.x | Test runner (V8 coverage built-in) |
|
||||
| `supertest` 7.x | HTTP integration testing |
|
||||
| `nock` 14.x | HTTP interception at Node layer (works with CJS `require('axios')`) |
|
||||
|
||||
---
|
||||
|
||||
## 3. Directory Structure
|
||||
@@ -99,22 +123,26 @@ Admin users can view all users' downloads, see server status, cache statistics,
|
||||
```
|
||||
sofarr/
|
||||
├── server/ # Backend application
|
||||
│ ├── index.js # Entry point: Express setup, middleware, startup
|
||||
│ ├── index.js # Entry point: logging setup, server listen, poller start
|
||||
│ ├── app.js # Express app factory (imported by index.js and tests)
|
||||
│ ├── routes/
|
||||
│ │ ├── auth.js # POST /login, GET /me, POST /logout
|
||||
│ │ ├── dashboard.js # GET /user-downloads, /user-summary, /status
|
||||
│ │ ├── auth.js # POST /login, GET /me, GET /csrf, POST /logout
|
||||
│ │ ├── dashboard.js # GET /user-downloads, /user-summary, /status, /cover-art
|
||||
│ │ ├── emby.js # Proxy routes to Emby API
|
||||
│ │ ├── sabnzbd.js # Proxy routes to SABnzbd API
|
||||
│ │ ├── sonarr.js # Proxy routes to Sonarr API
|
||||
│ │ └── radarr.js # Proxy routes to Radarr API
|
||||
│ ├── middleware/
|
||||
│ │ └── requireAuth.js # httpOnly cookie auth middleware
|
||||
│ │ ├── requireAuth.js # httpOnly cookie auth enforcement
|
||||
│ │ └── verifyCsrf.js # CSRF double-submit cookie validation
|
||||
│ └── utils/
|
||||
│ ├── cache.js # MemoryCache class (Map + TTL + stats)
|
||||
│ ├── config.js # Multi-instance service configuration parser
|
||||
│ ├── logger.js # File logger (server.log)
|
||||
│ ├── logger.js # File logger (DATA_DIR/server.log)
|
||||
│ ├── poller.js # Background polling engine + timing
|
||||
│ └── qbittorrent.js # qBittorrent client with auth + torrent mapping
|
||||
│ ├── qbittorrent.js # qBittorrent client with auth + torrent mapping
|
||||
│ ├── sanitizeError.js # Redacts secrets from error messages before logging
|
||||
│ └── tokenStore.js # JSON file-backed Emby token store (atomic, TTL)
|
||||
├── public/ # Static frontend (served by Express)
|
||||
│ ├── index.html # HTML shell: splash, login, dashboard
|
||||
│ ├── app.js # All frontend logic (auth, rendering, status)
|
||||
@@ -123,8 +151,21 @@ sofarr/
|
||||
│ ├── favicon-32.png # 32px PNG favicon
|
||||
│ ├── favicon-192.png # 192px PNG (apple-touch-icon / PWA)
|
||||
│ └── images/ # Logo / splash screen assets
|
||||
├── Dockerfile # Production container image
|
||||
├── tests/
|
||||
│ ├── README.md # Testing approach, design decisions, coverage targets
|
||||
│ ├── setup.js # Global setup: isolated DATA_DIR, rate-limit bypass
|
||||
│ ├── unit/ # Pure unit tests (no HTTP)
|
||||
│ └── integration/ # Supertest integration tests (nock for external HTTP)
|
||||
├── docs/
|
||||
│ ├── ARCHITECTURE.md # This document
|
||||
│ └── diagrams/ # PlantUML source files
|
||||
├── .gitea/workflows/
|
||||
│ ├── ci.yml # Security audit + test/coverage CI jobs
|
||||
│ ├── build-image.yml # Docker image build and push
|
||||
│ └── create-release.yml # Release tagging workflow
|
||||
├── Dockerfile # Multi-stage production container image (node:22-alpine)
|
||||
├── docker-compose.yaml # Example compose deployment
|
||||
├── vitest.config.js # Test runner configuration with per-file coverage thresholds
|
||||
├── package.json # Dependencies and scripts
|
||||
├── .env.sample # Annotated environment variable template
|
||||
└── README.md # User-facing documentation
|
||||
@@ -134,30 +175,45 @@ sofarr/
|
||||
|
||||
## 4. Component Architecture
|
||||
|
||||
### 4.1 Server Entry Point (`server/index.js`)
|
||||
### 4.1 Application Factory (`server/app.js`) and Entry Point (`server/index.js`)
|
||||
|
||||
Responsibilities:
|
||||
- Load environment variables via `dotenv`
|
||||
- Configure structured logging with level filtering (`LOG_LEVEL`)
|
||||
- Redirect `console.*` to both stdout and `server.log`
|
||||
- Mount Express middleware (cookie-parser, JSON, static files)
|
||||
- Mount route modules under `/api/*`
|
||||
**`server/app.js`** is a `createApp(options?)` factory that builds and returns the configured Express `app` instance. It is imported by both `server/index.js` (production) and the test suite. Keeping app creation separate from `app.listen()` means tests get a clean instance without triggering log-file setup, `process.exit()` calls, or the background poller.
|
||||
|
||||
`createApp` responsibilities:
|
||||
- Configure `trust proxy` from `TRUST_PROXY` env var
|
||||
- Apply `helmet` with a per-request CSP nonce (`crypto.randomBytes(16)`) for inline styles/scripts
|
||||
- Add `Permissions-Policy` header
|
||||
- Apply the general API rate limiter (300 req / 15 min per IP)
|
||||
- Mount `cookie-parser` (signed when `COOKIE_SECRET` is set)
|
||||
- Mount `express.json` (64 KB body limit)
|
||||
- Expose `/health` and `/ready` endpoints (no auth, no rate limit)
|
||||
- Mount `/api/auth` routes **before** CSRF middleware (login/logout are exempt)
|
||||
- Mount `verifyCsrf` for all subsequent `/api` routes
|
||||
- Mount remaining route modules under `/api/*`
|
||||
- Register global error handler (500 with sanitized message)
|
||||
|
||||
**`server/index.js`** entry point responsibilities:
|
||||
- Load `.env` via `dotenv`
|
||||
- Configure structured logging (`LOG_LEVEL`) with `console.*` redirection to both stdout and `DATA_DIR/server.log`
|
||||
- Call `createApp()`, serve `public/` as static files, start `app.listen()`
|
||||
- Start the background poller
|
||||
|
||||
### 4.2 Route Modules
|
||||
|
||||
| Module | Mount Point | Auth Required | Purpose |
|
||||
|--------|------------|---------------|---------|
|
||||
| `auth.js` | `/api/auth` | No (public) | Login, session check, logout |
|
||||
| `dashboard.js` | `/api/dashboard` | Yes (`requireAuth`) | Aggregated download data, status |
|
||||
| `emby.js` | `/api/emby` | Yes (`requireAuth`) | Proxy to Emby API |
|
||||
| `sabnzbd.js` | `/api/sabnzbd` | Yes (`requireAuth`) | Proxy to SABnzbd API |
|
||||
| `sonarr.js` | `/api/sonarr` | Yes (`requireAuth`) | Proxy to Sonarr API |
|
||||
| `radarr.js` | `/api/radarr` | Yes (`requireAuth`) | Proxy to Radarr API |
|
||||
| Module | Mount Point | Auth Required | CSRF Required | Purpose |
|
||||
|--------|------------|:-------------:|:-------------:|--------|
|
||||
| `auth.js` | `/api/auth` | No | No | Login, session check, CSRF token, logout |
|
||||
| `dashboard.js` | `/api/dashboard` | Yes (`requireAuth`) | Yes | Aggregated download data, status, cover-art proxy |
|
||||
| `emby.js` | `/api/emby` | Yes (`requireAuth`) | Yes | Proxy to Emby API |
|
||||
| `sabnzbd.js` | `/api/sabnzbd` | Yes (`requireAuth`) | Yes | Proxy to SABnzbd API |
|
||||
| `sonarr.js` | `/api/sonarr` | Yes (`requireAuth`) | Yes | Proxy to Sonarr API |
|
||||
| `radarr.js` | `/api/radarr` | Yes (`requireAuth`) | Yes | Proxy to Radarr API |
|
||||
|
||||
`requireAuth` (`server/middleware/requireAuth.js`) reads the `emby_user` httpOnly cookie and attaches the parsed user to `req.user`. Returns `401` if the cookie is absent or malformed.
|
||||
**`requireAuth`** (`server/middleware/requireAuth.js`) reads the `emby_user` cookie (signed if `COOKIE_SECRET` is set) and attaches the parsed `{ id, name, isAdmin }` user to `req.user`. Returns `401` if the cookie is absent, tampered, or schema-invalid.
|
||||
|
||||
> **Note:** The proxy routes (`emby`, `sabnzbd`, `sonarr`, `radarr`) use legacy single-instance env vars and are largely unused by the dashboard — they exist for direct API access. The dashboard reads from the poller cache.
|
||||
**`verifyCsrf`** (`server/middleware/verifyCsrf.js`) implements the double-submit cookie pattern for all state-changing requests (`POST`, `PUT`, `PATCH`, `DELETE`). Compares the `csrf_token` cookie against the `X-CSRF-Token` request header using `crypto.timingSafeEqual`. Safe methods (`GET`, `HEAD`, `OPTIONS`) are exempt. Auth routes are mounted **before** this middleware (login/logout are exempt by design — `sameSite: strict` provides equivalent protection).
|
||||
|
||||
> **Note:** The proxy routes (`emby`, `sabnzbd`, `sonarr`, `radarr`) exist for direct API access. The dashboard itself reads from the poller cache — it does not proxy through these routes.
|
||||
|
||||
### 4.3 Utility Modules
|
||||
|
||||
@@ -167,9 +223,13 @@ Responsibilities:
|
||||
|
||||
**`poller.js`** — Background polling engine. Fetches data from all configured service instances in parallel with per-task timing. Stores results in the cache with a configurable TTL. Can be disabled entirely (`POLL_INTERVAL=0`), in which case data is fetched on-demand per dashboard request.
|
||||
|
||||
**`qbittorrent.js`** — `QBittorrentClient` class with cookie-based authentication, automatic re-auth on 403, and persistent client instances. Includes torrent-to-download mapping (`mapTorrentToDownload`) and formatting utilities.
|
||||
**`qbittorrent.js`** — `QBittorrentClient` class with cookie-based authentication, automatic re-auth on 403, and persistent client instances. Includes torrent-to-download mapping (`mapTorrentToDownload`) and formatting utilities (`formatBytes`, `formatSpeed`, `formatEta`).
|
||||
|
||||
**`logger.js`** — Simple file appender writing timestamped messages to `server.log`.
|
||||
**`tokenStore.js`** — JSON file-backed store (`DATA_DIR/tokens.json`) for Emby `AccessToken`s. Tokens are stored server-side and **never sent to the client**. Writes are atomic (write to `.tmp` then rename). Entries expire after 31 days (slightly longer than the maximum 30-day cookie). Pruning runs on startup and hourly.
|
||||
|
||||
**`sanitizeError.js`** — Redacts secrets from error message strings before they are logged or returned in API responses. Patterns: URL query-param secrets (`apikey=`, `token=`, etc.), HTTP auth headers (`Authorization:`, `X-Emby-Authorization:`, etc.), Bearer tokens, and basic-auth credentials in URLs.
|
||||
|
||||
**`logger.js`** — Simple file appender writing timestamped messages to `DATA_DIR/server.log`.
|
||||
|
||||
---
|
||||
|
||||
@@ -212,12 +272,19 @@ When a user requests `/api/dashboard/user-downloads`:
|
||||
|
||||
### Flow
|
||||
|
||||
1. User submits credentials via the login form
|
||||
2. Backend calls Emby `POST /Users/authenticatebyname`
|
||||
3. On success, fetches full user profile to determine admin status
|
||||
4. Sets an `httpOnly` cookie (`emby_user`) containing: `{ id, name, isAdmin }` — the Emby `AccessToken` is intentionally **not** stored in the cookie
|
||||
5. Cookie expires after 24 hours
|
||||
6. All subsequent dashboard requests read this cookie for identity
|
||||
1. User submits credentials (+ optional `rememberMe`) via the login form
|
||||
2. Backend applies the **login rate limiter** (max 10 failed attempts per IP per 15-minute window; successful requests don't count)
|
||||
3. Backend calls Emby `POST /Users/authenticatebyname` using a deterministic `DeviceId` derived from the username — this causes Emby to reuse the existing session rather than creating a new one on every login
|
||||
4. On success, fetches full user profile (`GET /Users/{id}`) to determine admin status
|
||||
5. Stores the Emby `AccessToken` in **`tokenStore`** (server-side, never sent to the client)
|
||||
6. Sets an `httpOnly` `emby_user` cookie containing only `{ id, name, isAdmin }`:
|
||||
- **`rememberMe: true`** → persistent cookie, `Max-Age` 30 days
|
||||
- **`rememberMe: false`** → session cookie (no `Max-Age`; expires when browser closes)
|
||||
- `secure` flag enabled when `NODE_ENV=production`
|
||||
- Signed with HMAC when `COOKIE_SECRET` is set
|
||||
7. Issues a `csrf_token` cookie (`httpOnly: false` so JS can read it) containing a 32-byte random hex token
|
||||
8. Returns `{ success: true, user, csrfToken }` — the SPA stores `csrfToken` in memory and sends it as `X-CSRF-Token` on all subsequent state-changing requests
|
||||
9. All subsequent dashboard requests read the `emby_user` cookie for identity via `requireAuth`
|
||||
|
||||
### Authorisation Matrix
|
||||
|
||||
@@ -336,47 +403,76 @@ Each matched download produces an object with:
|
||||
|
||||
### `POST /api/auth/login`
|
||||
|
||||
Authenticate a user via Emby.
|
||||
Authenticate a user via Emby. Rate-limited to **10 failed attempts per IP per 15 minutes**.
|
||||
|
||||
**Request Body:**
|
||||
```json
|
||||
{ "username": "string", "password": "string" }
|
||||
{ "username": "string", "password": "string", "rememberMe": false }
|
||||
```
|
||||
|
||||
| Field | Required | Description |
|
||||
|-------|:--------:|-----------|
|
||||
| `username` | Yes | Max 128 chars, must be a non-empty string |
|
||||
| `password` | Yes | Max 256 chars |
|
||||
| `rememberMe` | No | `true` = 30-day persistent cookie; `false`/omitted = session cookie |
|
||||
|
||||
**Response (200):**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"user": { "id": "string", "name": "string", "isAdmin": true }
|
||||
"user": { "id": "string", "name": "string", "isAdmin": false },
|
||||
"csrfToken": "64-char hex string"
|
||||
}
|
||||
```
|
||||
|
||||
**Response (400):** Invalid input (empty/overlong username or password).
|
||||
|
||||
**Response (401):**
|
||||
```json
|
||||
{ "success": false, "error": "Invalid username or password" }
|
||||
```
|
||||
|
||||
**Side Effect:** Sets `emby_user` httpOnly cookie (24h TTL, `httpOnly`, `secure` in production, `sameSite: strict`). Cookie payload: `{ id, name, isAdmin }` — Emby `AccessToken` is not stored.
|
||||
**Response (429):** Too many failed attempts from this IP.
|
||||
|
||||
**Side Effects:**
|
||||
- Sets `emby_user` cookie: `httpOnly`, `sameSite: strict`, `secure` in production, signed if `COOKIE_SECRET` is set. Payload: `{ id, name, isAdmin }`. The Emby `AccessToken` is **never** included.
|
||||
- Sets `csrf_token` cookie: `httpOnly: false` (JS-readable), same security flags. 64-char hex.
|
||||
- Stores the Emby `AccessToken` server-side in `tokenStore` (used only for server-side Emby logout).
|
||||
|
||||
---
|
||||
|
||||
### `GET /api/auth/me`
|
||||
|
||||
Check current session.
|
||||
Check current session (no auth required — returns unauthenticated state rather than 401).
|
||||
|
||||
**Response:**
|
||||
**Response (authenticated):**
|
||||
```json
|
||||
{
|
||||
"authenticated": true,
|
||||
"user": { "id": "string", "name": "string", "isAdmin": false }
|
||||
}
|
||||
{ "authenticated": true, "user": { "id": "string", "name": "string", "isAdmin": false } }
|
||||
```
|
||||
|
||||
**Response (not authenticated):**
|
||||
```json
|
||||
{ "authenticated": false }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `GET /api/auth/csrf`
|
||||
|
||||
Issue a fresh CSRF token. Used by the SPA after a page reload when the in-memory `csrfToken` variable is lost.
|
||||
|
||||
**Response (200):**
|
||||
```json
|
||||
{ "csrfToken": "64-char hex string" }
|
||||
```
|
||||
|
||||
**Side Effect:** Sets a new `csrf_token` cookie.
|
||||
|
||||
---
|
||||
|
||||
### `POST /api/auth/logout`
|
||||
|
||||
Clear session cookie.
|
||||
Clear session and revoke the Emby token server-side. Does **not** require a CSRF token (auth routes are mounted before `verifyCsrf`; `sameSite: strict` provides equivalent protection).
|
||||
|
||||
---
|
||||
|
||||
@@ -518,26 +614,47 @@ The dashboard polls the server at the user-selected interval (1s, 5s, 10s, or Of
|
||||
|
||||
### Environment Variables
|
||||
|
||||
#### Core
|
||||
|
||||
| Variable | Required | Default | Description |
|
||||
|----------|:--------:|---------|-------------|
|
||||
| `PORT` | No | `3001` | Server listen port |
|
||||
| `EMBY_URL` | Yes | — | Emby/Jellyfin server URL |
|
||||
| `EMBY_API_KEY` | Yes | — | Emby API key |
|
||||
| `NODE_ENV` | No | — | Set to `production` to enable `secure` cookies and HTTPS upgrades |
|
||||
| `DATA_DIR` | No | `./data` | Directory for `tokens.json` and `server.log`. Must be writable by the server process. In Docker: `/app/data` (named volume). |
|
||||
| `COOKIE_SECRET` | No | — | If set, signs all session cookies with HMAC-SHA256. Strongly recommended in production. |
|
||||
| `TRUST_PROXY` | No | — | Express `trust proxy` setting. Set to `1` (or a hop count) when behind a reverse proxy (nginx, Traefik, etc.) so `req.ip` and `req.secure` are correct. |
|
||||
|
||||
#### Emby
|
||||
|
||||
| Variable | Required | Default | Description |
|
||||
|----------|:--------:|---------|-------------|
|
||||
| `EMBY_URL` | Yes | — | Emby/Jellyfin base URL (e.g. `https://emby.example.com`) |
|
||||
| `EMBY_API_KEY` | Yes | — | Emby API key (used by the poller to list users for tag badge classification) |
|
||||
|
||||
#### Service Instances
|
||||
|
||||
| Variable | Required | Default | Description |
|
||||
|----------|:--------:|---------|-------------|
|
||||
| `SONARR_INSTANCES` | Yes* | — | JSON array of Sonarr instances |
|
||||
| `SONARR_URL` | Yes* | — | Legacy single Sonarr URL |
|
||||
| `SONARR_API_KEY` | Yes* | — | Legacy single Sonarr API key |
|
||||
| `SONARR_URL` | Yes* | — | Legacy: single Sonarr URL |
|
||||
| `SONARR_API_KEY` | Yes* | — | Legacy: single Sonarr API key |
|
||||
| `RADARR_INSTANCES` | Yes* | — | JSON array of Radarr instances |
|
||||
| `RADARR_URL` | Yes* | — | Legacy single Radarr URL |
|
||||
| `RADARR_API_KEY` | Yes* | — | Legacy single Radarr API key |
|
||||
| `RADARR_URL` | Yes* | — | Legacy: single Radarr URL |
|
||||
| `RADARR_API_KEY` | Yes* | — | Legacy: single Radarr API key |
|
||||
| `SABNZBD_INSTANCES` | Yes* | — | JSON array of SABnzbd instances |
|
||||
| `SABNZBD_URL` | Yes* | — | Legacy single SABnzbd URL |
|
||||
| `SABNZBD_API_KEY` | Yes* | — | Legacy single SABnzbd API key |
|
||||
| `SABNZBD_URL` | Yes* | — | Legacy: single SABnzbd URL |
|
||||
| `SABNZBD_API_KEY` | Yes* | — | Legacy: single SABnzbd API key |
|
||||
| `QBITTORRENT_INSTANCES` | No | — | JSON array of qBittorrent instances |
|
||||
| `POLL_INTERVAL` | No | `5000` | Poll interval in ms, or `off`/`0` to disable |
|
||||
| `LOG_LEVEL` | No | `info` | `debug`, `info`, `warn`, `error`, `silent` |
|
||||
|
||||
\* Either `*_INSTANCES` (JSON array) or legacy `*_URL` + `*_API_KEY` format is required.
|
||||
|
||||
#### Tuning
|
||||
|
||||
| Variable | Required | Default | Description |
|
||||
|----------|:--------:|---------|-------------|
|
||||
| `POLL_INTERVAL` | No | `5000` | Poll interval in ms. Set to `0`, `off`, or `false` to disable background polling (on-demand mode). |
|
||||
| `LOG_LEVEL` | No | `info` | `debug`, `info`, `warn`, `error`, `silent` |
|
||||
|
||||
### Instance JSON Format
|
||||
|
||||
```json
|
||||
@@ -561,24 +678,20 @@ qBittorrent instances use `username` and `password` instead of `apiKey`.
|
||||
|
||||
## 12. Deployment
|
||||
|
||||
### Docker
|
||||
### Docker image
|
||||
|
||||
```dockerfile
|
||||
FROM node:18-alpine
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci --omit=dev
|
||||
COPY server/ ./server/
|
||||
COPY public/ ./public/
|
||||
EXPOSE 3001
|
||||
ENV NODE_ENV=production
|
||||
CMD ["node", "server/index.js"]
|
||||
```
|
||||
The production image uses a two-stage build on `node:22-alpine`:
|
||||
|
||||
1. **`deps` stage** — runs `npm ci --omit=dev` to install only production dependencies.
|
||||
2. **`runtime` stage** — copies `node_modules`, `server/`, `public/`, and `package.json`. Runs as the built-in non-root `node` user (UID 1000). `/app/data` is owned by `node` for writable token store and logs.
|
||||
|
||||
Key environment variables set in the image:
|
||||
- `NODE_ENV=production` — enables secure cookies and HTTPS upgrade CSP directive
|
||||
- `DATA_DIR=/app/data` — token store and log file location
|
||||
|
||||
### Docker Compose
|
||||
|
||||
```yaml
|
||||
version: "3"
|
||||
services:
|
||||
sofarr:
|
||||
image: docker.i3omb.com/sofarr:latest
|
||||
@@ -587,6 +700,10 @@ services:
|
||||
ports:
|
||||
- "3001:3001"
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- DATA_DIR=/app/data
|
||||
- COOKIE_SECRET=change-me-to-a-long-random-string
|
||||
- TRUST_PROXY=1 # set if behind nginx/Traefik
|
||||
- EMBY_URL=https://emby.example.com
|
||||
- EMBY_API_KEY=your-emby-api-key
|
||||
- SONARR_INSTANCES=[{"name":"main","url":"...","apiKey":"..."}]
|
||||
@@ -595,8 +712,31 @@ services:
|
||||
- QBITTORRENT_INSTANCES=[{"name":"main","url":"...","username":"...","password":"..."}]
|
||||
- POLL_INTERVAL=5000
|
||||
- LOG_LEVEL=info
|
||||
volumes:
|
||||
- sofarr-data:/app/data # persists tokens.json and server.log
|
||||
|
||||
volumes:
|
||||
sofarr-data:
|
||||
```
|
||||
|
||||
### Security hardening checklist
|
||||
|
||||
- **Set `COOKIE_SECRET`** — enables HMAC-signed cookies, preventing client-side forgery.
|
||||
- **Set `TRUST_PROXY=1`** when behind a reverse proxy — ensures `req.secure` is `true` so the `secure` cookie flag is enforced and HTTPS-upgrade CSP fires.
|
||||
- **Mount a named volume** for `DATA_DIR` — token store and log file survive container recreates.
|
||||
- **Use HTTPS** — the CSP includes `upgrade-insecure-requests` in production and the HSTS header is set with a 1-year `maxAge`.
|
||||
- **Rate limiting** — the login endpoint is limited to 10 failed attempts per IP per 15 minutes; all API endpoints share a 300 req/15 min window. Set `TRUST_PROXY` correctly so the client IP is not the proxy's IP.
|
||||
|
||||
### CI / CD
|
||||
|
||||
The `.gitea/workflows/` directory contains three pipeline definitions:
|
||||
|
||||
| File | Trigger | Purpose |
|
||||
|------|---------|--------|
|
||||
| `ci.yml` | Every push / PR | Security audit (`npm audit --audit-level=high`) + tests with V8 coverage |
|
||||
| `build-image.yml` | Push to `main` / `develop` | Build and push Docker image to `docker.i3omb.com` |
|
||||
| `create-release.yml` | Tag push (`v*`) | Create a Gitea release |
|
||||
|
||||
---
|
||||
|
||||
## 13. UML Diagrams (PlantUML)
|
||||
|
||||
@@ -9,19 +9,33 @@ package "server/index.js" as entry {
|
||||
- logFile : WriteStream
|
||||
+ shouldLog(level) : boolean
|
||||
--
|
||||
Configures Express app,
|
||||
mounts routes, starts poller
|
||||
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
|
||||
+ POST /login (rate-limited)
|
||||
+ GET /me
|
||||
+ GET /csrf
|
||||
+ POST /logout
|
||||
--
|
||||
Authenticates via Emby API
|
||||
Sets/reads httpOnly cookie
|
||||
Issues emby_user + csrf_token cookies
|
||||
Stores/revokes Emby tokens server-side
|
||||
}
|
||||
|
||||
class "dashboard.js" as dashboard <<router>> {
|
||||
@@ -76,9 +90,20 @@ package "server/middleware" {
|
||||
class "requireAuth.js" as requireauth <<middleware>> {
|
||||
+ requireAuth(req, res, next) : void
|
||||
--
|
||||
Reads emby_user cookie
|
||||
Attaches parsed user to req.user
|
||||
Returns 401 if absent/invalid
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -171,6 +196,27 @@ package "server/utils" {
|
||||
+ 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
|
||||
@@ -184,19 +230,24 @@ package "server/utils" {
|
||||
}
|
||||
|
||||
' Relationships
|
||||
ep --> auth
|
||||
ep --> dashboard
|
||||
ep --> emby_r
|
||||
ep --> sab_r
|
||||
ep --> sonarr_r
|
||||
ep --> radarr_r
|
||||
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
|
||||
ep --> poller : startPoller()
|
||||
|
||||
auth --> tokenstore : storeToken / getToken / clearToken
|
||||
|
||||
dashboard --> cache : read/write
|
||||
dashboard --> poller : pollAllServices()
|
||||
@@ -218,4 +269,7 @@ dashboard *-- ci : stores in activeClients
|
||||
|
||||
config ..> inst : returns
|
||||
|
||||
auth ..> sanitize : sanitizeError on catch
|
||||
dashboard ..> sanitize : sanitizeError on catch
|
||||
|
||||
@enduml
|
||||
|
||||
@@ -15,15 +15,21 @@ package "Browser" as browser {
|
||||
|
||||
package "Express Server" as server {
|
||||
|
||||
[index.js\nEntry Point] as entry
|
||||
[app.js\ncreatApp() factory] as appfactory
|
||||
|
||||
package "Middleware" {
|
||||
[cookie-parser] as cp
|
||||
[express.json] as ej
|
||||
[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] as auth
|
||||
[auth.js\n/api/auth\n(pre-CSRF)] as auth
|
||||
[dashboard.js\n/api/dashboard] as dashboard
|
||||
[emby.js\n/api/emby] as emby_route
|
||||
[sabnzbd.js\n/api/sabnzbd] as sab_route
|
||||
@@ -36,27 +42,34 @@ package "Express Server" as server {
|
||||
[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
|
||||
}
|
||||
|
||||
[index.js\nEntry Point] as entry
|
||||
entry --> appfactory : createApp()
|
||||
entry --> es : serve public/
|
||||
entry --> poller : startPoller()
|
||||
|
||||
entry --> cp
|
||||
entry --> ej
|
||||
entry --> es
|
||||
entry --> auth
|
||||
entry --> dashboard
|
||||
entry --> emby_route
|
||||
entry --> sab_route
|
||||
entry --> sonarr_route
|
||||
entry --> radarr_route
|
||||
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
|
||||
entry --> poller : startPoller()
|
||||
|
||||
auth --> tokenstore : storeToken / getToken / clearToken
|
||||
|
||||
dashboard --> cache : read poll:* keys
|
||||
dashboard --> poller : pollAllServices()\n(on-demand mode)
|
||||
@@ -70,6 +83,9 @@ package "Express Server" as server {
|
||||
|
||||
qbt --> config : getQbittorrentInstances()
|
||||
qbt --> logger
|
||||
|
||||
auth ..> sanitize
|
||||
dashboard ..> sanitize
|
||||
}
|
||||
|
||||
cloud "External Services" as external {
|
||||
|
||||
@@ -5,6 +5,7 @@ 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 ==
|
||||
@@ -12,14 +13,19 @@ user -> browser : Navigate to sofarr
|
||||
activate browser
|
||||
browser -> auth : GET /api/auth/me
|
||||
activate auth
|
||||
auth -> auth : Read emby_user cookie
|
||||
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 : fetchUserDownloads(true)
|
||||
browser -> browser : startAutoRefresh()
|
||||
browser -> browser : dismissSplash()
|
||||
else No cookie
|
||||
else No cookie / tampered
|
||||
auth --> browser : { authenticated: false }
|
||||
browser -> browser : dismissSplash()
|
||||
browser -> browser : showLogin()
|
||||
@@ -27,38 +33,69 @@ end
|
||||
deactivate auth
|
||||
|
||||
== Login ==
|
||||
user -> browser : Enter username + password
|
||||
browser -> auth : POST /api/auth/login\n{ username, password }
|
||||
user -> browser : Enter username + password\n(+ optional rememberMe checkbox)
|
||||
browser -> auth : POST /api/auth/login\n{ username, password, rememberMe }
|
||||
activate auth
|
||||
auth -> emby : POST /Users/authenticatebyname\n{ Username, Pw }
|
||||
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}
|
||||
emby --> auth : { User: { Id }, AccessToken }
|
||||
auth -> emby : GET /Users/{userId}\nX-MediaBrowser-Token: AccessToken
|
||||
emby --> auth : { Name, Policy: { IsAdministrator } }
|
||||
deactivate emby
|
||||
auth -> auth : Set httpOnly cookie\nemby_user = { id, name, isAdmin }\n(24h TTL, secure in prod, sameSite=strict)\nNote: AccessToken NOT stored
|
||||
auth --> browser : { success: true, user: { name, isAdmin } }
|
||||
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 : showSplash()
|
||||
browser -> browser : showDashboard()
|
||||
browser -> browser : fetchUserDownloads(true)
|
||||
browser -> browser : startAutoRefresh()
|
||||
browser -> browser : dismissSplash()
|
||||
else Invalid credentials
|
||||
emby --> auth : 401 Error
|
||||
deactivate emby
|
||||
auth --> browser : { success: false, error: "Invalid..." }
|
||||
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
|
||||
browser -> auth : POST /api/auth/logout\n(no CSRF required — auth routes\nexempt; sameSite:strict protects)
|
||||
activate auth
|
||||
auth -> auth : Clear emby_user cookie
|
||||
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()
|
||||
|
||||
3169
package-lock.json
generated
3169
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
22
package.json
22
package.json
@@ -1,27 +1,35 @@
|
||||
{
|
||||
"name": "sofarr",
|
||||
"version": "0.1.5",
|
||||
"version": "0.2.0",
|
||||
"description": "A personal media download dashboard that shows your downloads 'so far' while you relax on the sofa waiting for your *arr services to finish",
|
||||
"main": "server/index.js",
|
||||
"scripts": {
|
||||
"dev": "nodemon server/index.js",
|
||||
"start": "node server/index.js",
|
||||
"install:all": "npm install",
|
||||
"audit": "npm audit --audit-level=moderate",
|
||||
"audit:fix": "npm audit fix"
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"test:ui": "vitest --ui",
|
||||
"audit": "npm audit --audit-level=high",
|
||||
"audit:fix": "npm audit fix",
|
||||
"audit:critical": "npm audit --audit-level=critical"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.6.0",
|
||||
"cookie-parser": "^1.4.6",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.3.1",
|
||||
"express": "^4.18.2",
|
||||
"express-rate-limit": "^6.7.0",
|
||||
"helmet": "^4.6.0"
|
||||
"express-rate-limit": "^7.0.0",
|
||||
"helmet": "^7.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitest/coverage-v8": "^4.1.6",
|
||||
"concurrently": "^7.6.0",
|
||||
"nodemon": "^3.1.14"
|
||||
"nock": "^14.0.15",
|
||||
"nodemon": "^3.1.14",
|
||||
"supertest": "^7.2.2",
|
||||
"vitest": "^4.1.6"
|
||||
},
|
||||
"keywords": [
|
||||
"sabnzbd",
|
||||
|
||||
@@ -4,6 +4,7 @@ let refreshInterval = null;
|
||||
let currentRefreshRate = 5000; // default 5 seconds
|
||||
let isAdmin = false;
|
||||
let showAll = false;
|
||||
let csrfToken = null; // double-submit CSRF token, sent as X-CSRF-Token on mutating requests
|
||||
const SPLASH_MIN_MS = 1200; // minimum splash display time
|
||||
|
||||
// Apply saved theme immediately (before DOMContentLoaded to avoid flash)
|
||||
@@ -118,9 +119,15 @@ function dismissSplash(startTime) {
|
||||
async function checkAuthentication() {
|
||||
const splashStart = Date.now();
|
||||
try {
|
||||
const response = await fetch('/api/auth/me');
|
||||
const data = await response.json();
|
||||
|
||||
// Fetch both auth state and a fresh CSRF token in parallel
|
||||
const [meRes, csrfRes] = await Promise.all([
|
||||
fetch('/api/auth/me'),
|
||||
fetch('/api/auth/csrf')
|
||||
]);
|
||||
const data = await meRes.json();
|
||||
const csrfData = await csrfRes.json();
|
||||
if (csrfData.csrfToken) csrfToken = csrfData.csrfToken;
|
||||
|
||||
if (data.authenticated) {
|
||||
currentUser = data.user;
|
||||
isAdmin = !!data.user.isAdmin;
|
||||
@@ -160,6 +167,8 @@ async function handleLogin(e) {
|
||||
if (data.success) {
|
||||
currentUser = data.user;
|
||||
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 loading data.
|
||||
// requestAnimationFrame ensures the browser paints the splash at
|
||||
// opacity:1 before dismissSplash adds fade-out, so the CSS
|
||||
@@ -185,9 +194,11 @@ async function handleLogout() {
|
||||
try {
|
||||
stopAutoRefresh();
|
||||
await fetch('/api/auth/logout', {
|
||||
method: 'POST'
|
||||
method: 'POST',
|
||||
headers: csrfToken ? { 'X-CSRF-Token': csrfToken } : {}
|
||||
});
|
||||
currentUser = null;
|
||||
csrfToken = null;
|
||||
downloads = [];
|
||||
showLogin();
|
||||
} catch (err) {
|
||||
@@ -376,7 +387,11 @@ function createDownloadCard(download) {
|
||||
const coverDiv = document.createElement('div');
|
||||
coverDiv.className = 'download-cover';
|
||||
const coverImg = document.createElement('img');
|
||||
coverImg.src = download.coverArt;
|
||||
// Proxy cover art through the server so the CSP img-src 'self' rule
|
||||
// is satisfied (external poster URLs would be blocked otherwise).
|
||||
coverImg.src = download.coverArt
|
||||
? '/api/dashboard/cover-art?url=' + encodeURIComponent(download.coverArt)
|
||||
: '';
|
||||
coverImg.alt = download.movieName || download.seriesName || download.title;
|
||||
coverImg.loading = 'lazy';
|
||||
coverDiv.appendChild(coverImg);
|
||||
|
||||
113
server/app.js
Normal file
113
server/app.js
Normal file
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* 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}'`],
|
||||
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 };
|
||||
197
server/index.js
197
server/index.js
@@ -2,6 +2,8 @@ const express = require('express');
|
||||
const path = require('path');
|
||||
const cookieParser = require('cookie-parser');
|
||||
const helmet = require('helmet');
|
||||
const rateLimit = require('express-rate-limit');
|
||||
const crypto = require('crypto');
|
||||
const fs = require('fs');
|
||||
require('dotenv').config();
|
||||
|
||||
@@ -10,7 +12,29 @@ require('dotenv').config();
|
||||
const LOG_LEVELS = { debug: 0, info: 1, warn: 2, error: 3, silent: 4 };
|
||||
const currentLevel = LOG_LEVELS[(process.env.LOG_LEVEL || 'info').toLowerCase()] || 1;
|
||||
|
||||
const logFile = fs.createWriteStream(path.join(__dirname, '../server.log'), { flags: 'a' });
|
||||
// Log file lives in DATA_DIR so the non-root container user can write to it
|
||||
const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, '../data');
|
||||
if (!fs.existsSync(DATA_DIR)) fs.mkdirSync(DATA_DIR, { recursive: true });
|
||||
|
||||
const LOG_PATH = path.join(DATA_DIR, 'server.log');
|
||||
const LOG_MAX_BYTES = 10 * 1024 * 1024; // 10 MB per file
|
||||
const LOG_KEEP = 3; // keep 3 rotated files
|
||||
|
||||
function rotateLogIfNeeded() {
|
||||
try {
|
||||
const stat = fs.statSync(LOG_PATH);
|
||||
if (stat.size < LOG_MAX_BYTES) return;
|
||||
for (let i = LOG_KEEP - 1; i >= 1; i--) {
|
||||
const src = `${LOG_PATH}.${i}`;
|
||||
const dst = `${LOG_PATH}.${i + 1}`;
|
||||
if (fs.existsSync(src)) fs.renameSync(src, dst);
|
||||
}
|
||||
fs.renameSync(LOG_PATH, `${LOG_PATH}.1`);
|
||||
} catch { /* ignore rotation errors — don't crash the server */ }
|
||||
}
|
||||
|
||||
rotateLogIfNeeded();
|
||||
const logFile = fs.createWriteStream(LOG_PATH, { flags: 'a' });
|
||||
const originalConsoleLog = console.log;
|
||||
const originalConsoleError = console.error;
|
||||
const originalConsoleWarn = console.warn;
|
||||
@@ -54,35 +78,173 @@ 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');
|
||||
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 PORT = process.env.PORT || 3001;
|
||||
|
||||
app.use(helmet({
|
||||
contentSecurityPolicy: false // SPA uses inline scripts; CSP requires a nonce/hash strategy
|
||||
}));
|
||||
|
||||
const cookieSecret = process.env.COOKIE_SECRET;
|
||||
if (!cookieSecret && process.env.NODE_ENV === 'production') {
|
||||
console.error('[Security] COOKIE_SECRET is not set in production — cookies are unsigned and can be tampered with!');
|
||||
process.exit(1);
|
||||
} else if (!cookieSecret) {
|
||||
console.warn('[Security] COOKIE_SECRET is not set — using unsigned cookies (acceptable for development only)');
|
||||
// ---------------------------------------------------------------------------
|
||||
// Trust proxy — required when behind Nginx/Caddy/Traefik so that
|
||||
// req.ip reflects the real client IP (not 127.0.0.1) and
|
||||
// req.secure is true when the upstream TLS is terminated by the proxy.
|
||||
// Set TRUST_PROXY=1 (or a specific IP/CIDR) via env.
|
||||
// ---------------------------------------------------------------------------
|
||||
if (process.env.TRUST_PROXY) {
|
||||
const trustValue = /^\d+$/.test(process.env.TRUST_PROXY)
|
||||
? parseInt(process.env.TRUST_PROXY, 10)
|
||||
: process.env.TRUST_PROXY;
|
||||
app.set('trust proxy', trustValue);
|
||||
}
|
||||
app.use(cookieParser(cookieSecret || undefined));
|
||||
app.use(express.json());
|
||||
app.use(express.static(path.join(__dirname, '../public')));
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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
|
||||
app.use(express.static(PUBLIC_DIR, { index: false }));
|
||||
|
||||
// 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/sonarr', sonarrRoutes);
|
||||
app.use('/api/radarr', radarrRoutes);
|
||||
app.use('/api/emby', embyRoutes);
|
||||
app.use('/api/dashboard', dashboardRoutes);
|
||||
app.use('/api/auth', authRoutes);
|
||||
|
||||
app.get('/', (req, res) => {
|
||||
res.sendFile(path.join(__dirname, '../public/index.html'));
|
||||
// SPA catch-all — serve index.html for any unmatched path
|
||||
app.get('*', serveIndex);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Global error handler — never leak stack traces to clients
|
||||
// ---------------------------------------------------------------------------
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
app.use((err, req, res, next) => {
|
||||
console.error('[Server] Unhandled error:', err.message);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
});
|
||||
|
||||
app.listen(PORT, () => {
|
||||
@@ -91,6 +253,7 @@ app.listen(PORT, () => {
|
||||
console.log(` Server running on port ${PORT}`);
|
||||
console.log(` Log level: ${process.env.LOG_LEVEL || 'info'}`);
|
||||
console.log(` Polling: ${POLLING_ENABLED ? POLL_INTERVAL + 'ms' : 'disabled (on-demand)'}`);
|
||||
console.log(` Trust proxy: ${process.env.TRUST_PROXY || 'disabled'}`);
|
||||
console.log(`=================================`);
|
||||
startPoller();
|
||||
});
|
||||
|
||||
42
server/middleware/verifyCsrf.js
Normal file
42
server/middleware/verifyCsrf.js
Normal file
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* CSRF protection using the double-submit cookie pattern.
|
||||
*
|
||||
* On login the server issues a random `csrf_token` cookie (httpOnly:false
|
||||
* so JS can read it). The SPA must send the same value in the
|
||||
* `X-CSRF-Token` request header for every state-changing request (POST,
|
||||
* PUT, PATCH, DELETE).
|
||||
*
|
||||
* Because the `sameSite: strict` session cookie already provides strong
|
||||
* protection in modern browsers, this acts as defence-in-depth for
|
||||
* older browsers and any edge cases.
|
||||
*
|
||||
* Safe methods (GET, HEAD, OPTIONS) are exempted.
|
||||
*/
|
||||
|
||||
const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
|
||||
|
||||
function verifyCsrf(req, res, next) {
|
||||
if (SAFE_METHODS.has(req.method)) return next();
|
||||
|
||||
const cookieToken = req.cookies.csrf_token;
|
||||
const headerToken = req.headers['x-csrf-token'];
|
||||
|
||||
if (!cookieToken || !headerToken) {
|
||||
return res.status(403).json({ error: 'CSRF token missing' });
|
||||
}
|
||||
|
||||
// Constant-time comparison to prevent timing attacks
|
||||
if (cookieToken.length !== headerToken.length) {
|
||||
return res.status(403).json({ error: 'CSRF token invalid' });
|
||||
}
|
||||
|
||||
const a = Buffer.from(cookieToken);
|
||||
const b = Buffer.from(headerToken);
|
||||
if (!require('crypto').timingSafeEqual(a, b)) {
|
||||
return res.status(403).json({ error: 'CSRF token invalid' });
|
||||
}
|
||||
|
||||
next();
|
||||
}
|
||||
|
||||
module.exports = verifyCsrf;
|
||||
@@ -4,30 +4,22 @@ const crypto = require('crypto');
|
||||
const rateLimit = require('express-rate-limit');
|
||||
const router = express.Router();
|
||||
|
||||
const EMBY_URL = process.env.EMBY_URL;
|
||||
|
||||
// Server-side token store: userId -> { accessToken }
|
||||
// Keeps AccessToken off the client; required for logout revocation.
|
||||
const tokenStore = new Map();
|
||||
|
||||
function storeToken(userId, accessToken) {
|
||||
tokenStore.set(userId, { accessToken });
|
||||
}
|
||||
|
||||
function getToken(userId) {
|
||||
return tokenStore.get(userId) || null;
|
||||
}
|
||||
|
||||
function clearToken(userId) {
|
||||
tokenStore.delete(userId);
|
||||
}
|
||||
// Persistent JSON file-backed token store — survives restarts
|
||||
const { storeToken, getToken, clearToken } = require('../utils/tokenStore');
|
||||
|
||||
// Read EMBY_URL at request time (not module load time) so the value
|
||||
// can be overridden by environment variables set after the module loads.
|
||||
const getEmbyUrl = () => process.env.EMBY_URL;
|
||||
|
||||
// Strict login limiter: 10 attempts per 15 min, then locked for the window.
|
||||
// Set SKIP_RATE_LIMIT=1 in the test environment to prevent the limiter from
|
||||
// interfering with integration tests (all requests come from 127.0.0.1).
|
||||
const loginLimiter = rateLimit({
|
||||
windowMs: 15 * 60 * 1000, // 15 minutes
|
||||
max: 10,
|
||||
windowMs: 15 * 60 * 1000,
|
||||
max: process.env.SKIP_RATE_LIMIT ? Number.MAX_SAFE_INTEGER : 10,
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
skipSuccessfulRequests: true, // only count failures toward the limit
|
||||
message: { success: false, error: 'Too many login attempts, please try again later' }
|
||||
});
|
||||
|
||||
@@ -35,15 +27,23 @@ const loginLimiter = rateLimit({
|
||||
router.post('/login', loginLimiter, async (req, res) => {
|
||||
try {
|
||||
const { username, password, rememberMe } = req.body;
|
||||
|
||||
// Input validation — reject obviously invalid inputs before hitting Emby
|
||||
if (typeof username !== 'string' || username.trim().length === 0 || username.length > 128) {
|
||||
return res.status(400).json({ success: false, error: 'Invalid username' });
|
||||
}
|
||||
if (typeof password !== 'string' || password.length === 0 || password.length > 256) {
|
||||
return res.status(400).json({ success: false, error: 'Invalid password' });
|
||||
}
|
||||
|
||||
console.log(`[Auth] Attempting login for user: ${username}`);
|
||||
console.log(`[Auth] Attempting login for user: ${username.trim()}`);
|
||||
|
||||
// Authenticate with Emby using a stable DeviceId derived from the username.
|
||||
// Using a deterministic DeviceId causes Emby to reuse the existing session
|
||||
// for this device rather than creating a new one on each login.
|
||||
const stableDeviceId = 'sofarr-' + crypto.createHash('sha256').update(username.toLowerCase()).digest('hex').slice(0, 16);
|
||||
const authResponse = await axios.post(`${EMBY_URL}/Users/authenticatebyname`, {
|
||||
Username: username,
|
||||
const stableDeviceId = 'sofarr-' + crypto.createHash('sha256').update(username.trim().toLowerCase()).digest('hex').slice(0, 16);
|
||||
const authResponse = await axios.post(`${getEmbyUrl()}/Users/authenticatebyname`, {
|
||||
Username: username.trim(),
|
||||
Pw: password
|
||||
}, {
|
||||
headers: {
|
||||
@@ -54,7 +54,7 @@ router.post('/login', loginLimiter, async (req, res) => {
|
||||
const authData = authResponse.data;
|
||||
|
||||
// Get user info using the access token
|
||||
const userResponse = await axios.get(`${EMBY_URL}/Users/${authData.User.Id || authData.User.id}`, {
|
||||
const userResponse = await axios.get(`${getEmbyUrl()}/Users/${authData.User.Id || authData.User.id}`, {
|
||||
headers: {
|
||||
'X-MediaBrowser-Token': authData.AccessToken
|
||||
}
|
||||
@@ -70,22 +70,36 @@ router.post('/login', loginLimiter, async (req, res) => {
|
||||
// Set authentication cookie (signed when COOKIE_SECRET is set).
|
||||
// rememberMe=true → persistent cookie, expires in 30 days
|
||||
// rememberMe=false → session cookie, expires when browser closes
|
||||
// secure is always true — the app should sit behind HTTPS in production;
|
||||
// behind a reverse proxy set TRUST_PROXY=1 so req.secure works correctly.
|
||||
const cookiePayload = JSON.stringify({ id: user.Id, name: user.Name, isAdmin });
|
||||
const signed = !!process.env.COOKIE_SECRET;
|
||||
const cookieOptions = {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'strict',
|
||||
signed
|
||||
signed,
|
||||
path: '/'
|
||||
};
|
||||
if (rememberMe) {
|
||||
cookieOptions.maxAge = 30 * 24 * 60 * 60 * 1000; // 30 days
|
||||
}
|
||||
res.cookie('emby_user', cookiePayload, cookieOptions);
|
||||
|
||||
// Issue a CSRF token tied to this session so state-changing endpoints
|
||||
// can validate the double-submit cookie pattern
|
||||
const csrfToken = crypto.randomBytes(32).toString('hex');
|
||||
res.cookie('csrf_token', csrfToken, {
|
||||
httpOnly: false, // intentionally readable by JS for the double-submit pattern
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'strict',
|
||||
path: '/'
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
user: { id: user.Id, name: user.Name, isAdmin }
|
||||
user: { id: user.Id, name: user.Name, isAdmin },
|
||||
csrfToken
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`[Auth] Login failed:`, error.message);
|
||||
@@ -122,6 +136,19 @@ router.get('/me', (req, res) => {
|
||||
});
|
||||
});
|
||||
|
||||
// CSRF token refresh — lets the SPA get a new token without re-logging-in
|
||||
// (e.g. after a page reload where the JS variable was lost)
|
||||
router.get('/csrf', (req, res) => {
|
||||
const csrfToken = crypto.randomBytes(32).toString('hex');
|
||||
res.cookie('csrf_token', csrfToken, {
|
||||
httpOnly: false,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'strict',
|
||||
path: '/'
|
||||
});
|
||||
res.json({ csrfToken });
|
||||
});
|
||||
|
||||
// Logout
|
||||
router.post('/logout', async (req, res) => {
|
||||
const user = parseSessionCookie(req);
|
||||
@@ -129,7 +156,7 @@ router.post('/logout', async (req, res) => {
|
||||
const stored = getToken(user.id);
|
||||
if (stored) {
|
||||
try {
|
||||
await axios.post(`${EMBY_URL}/Sessions/Logout`, {}, {
|
||||
await axios.post(`${getEmbyUrl()}/Sessions/Logout`, {}, {
|
||||
headers: { 'X-MediaBrowser-Token': stored.accessToken }
|
||||
});
|
||||
console.log(`[Auth] Revoked Emby token for user: ${user.name}`);
|
||||
@@ -143,7 +170,14 @@ router.post('/logout', async (req, res) => {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'strict',
|
||||
signed: !!process.env.COOKIE_SECRET
|
||||
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 });
|
||||
});
|
||||
|
||||
@@ -721,4 +721,41 @@ router.get('/status', requireAuth, (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Cover art proxy — fetches external poster images server-side so the
|
||||
// browser loads them from 'self' and the CSP img-src stays tight.
|
||||
// Requires authentication. Only proxies http/https URLs.
|
||||
router.get('/cover-art', requireAuth, async (req, res) => {
|
||||
const { url } = req.query;
|
||||
if (!url || typeof url !== 'string') {
|
||||
return res.status(400).json({ error: 'Missing url parameter' });
|
||||
}
|
||||
let parsed;
|
||||
try {
|
||||
parsed = new URL(url);
|
||||
} catch {
|
||||
return res.status(400).json({ error: 'Invalid url' });
|
||||
}
|
||||
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
||||
return res.status(400).json({ error: 'Only http/https URLs are supported' });
|
||||
}
|
||||
try {
|
||||
const response = await axios.get(url, {
|
||||
responseType: 'stream',
|
||||
timeout: 8000,
|
||||
maxContentLength: 5 * 1024 * 1024 // 5 MB max
|
||||
});
|
||||
const contentType = response.headers['content-type'] || 'image/jpeg';
|
||||
// Only proxy image content types
|
||||
if (!contentType.startsWith('image/')) {
|
||||
return res.status(400).json({ error: 'Remote URL is not an image' });
|
||||
}
|
||||
res.setHeader('Content-Type', contentType);
|
||||
res.setHeader('Cache-Control', 'public, max-age=86400'); // 24h browser cache
|
||||
res.setHeader('X-Content-Type-Options', 'nosniff');
|
||||
response.data.pipe(res);
|
||||
} catch (err) {
|
||||
res.status(502).json({ error: 'Failed to fetch cover art' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const logFile = fs.createWriteStream(path.join(__dirname, '../../server.log'), { flags: 'a' });
|
||||
// Use DATA_DIR so the non-root container user (UID 1000) can write logs.
|
||||
// Falls back to ../../data/server.log (same directory index.js uses).
|
||||
const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, '../../data');
|
||||
if (!fs.existsSync(DATA_DIR)) fs.mkdirSync(DATA_DIR, { recursive: true });
|
||||
|
||||
const logFile = fs.createWriteStream(path.join(DATA_DIR, 'server.log'), { flags: 'a' });
|
||||
|
||||
function logToFile(message) {
|
||||
logFile.write(`[${new Date().toISOString()}] ${message}\n`);
|
||||
|
||||
@@ -1,14 +1,20 @@
|
||||
const API_KEY_PATTERN = /([?&]apikey=)[^&\s]*/gi;
|
||||
const TOKEN_PATTERN = /([?&]token=)[^&\s]*/gi;
|
||||
const HEADER_PATTERN = /x-(?:api-key|mediabrowser-token|emby-authorization):[^\s,]*/gi;
|
||||
// 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.message || String(err);
|
||||
// Redact API keys in URLs (SABnzbd passes apikey as query param)
|
||||
msg = msg.replace(API_KEY_PATTERN, '$1[REDACTED]');
|
||||
msg = msg.replace(TOKEN_PATTERN, '$1[REDACTED]');
|
||||
// Redact auth header values if they appear in the message
|
||||
msg = msg.replace(HEADER_PATTERN, (m) => m.split(':')[0] + ':[REDACTED]');
|
||||
let msg = (err && err.message) ? err.message : String(err);
|
||||
msg = msg.replace(QUERY_SECRET_PATTERN, '$1[REDACTED]');
|
||||
msg = msg.replace(HEADER_PATTERN, (m) => m.split(/[\s:]/)[0] + ':[REDACTED]');
|
||||
msg = msg.replace(BEARER_PATTERN, 'bearer [REDACTED]');
|
||||
msg = msg.replace(BASIC_AUTH_URL_PATTERN, '//[REDACTED]@');
|
||||
// Never leak stack traces to API responses
|
||||
return msg;
|
||||
}
|
||||
|
||||
|
||||
97
server/utils/tokenStore.js
Normal file
97
server/utils/tokenStore.js
Normal file
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* Persistent token store backed by a JSON file.
|
||||
*
|
||||
* Pure JavaScript — no native addons, no build tools required.
|
||||
* Survives process restarts so users are not logged out on redeploy.
|
||||
*
|
||||
* Tokens are stored in DATA_DIR/tokens.json (default: ./data locally,
|
||||
* /app/data in the container). Writes are atomic: data is written to a
|
||||
* temp file then renamed so a crash mid-write never corrupts the store.
|
||||
*
|
||||
* Format: { "<userId>": { accessToken: "...", createdAt: <unix ms> } }
|
||||
*
|
||||
* Expired entries (older than TOKEN_TTL_DAYS) are pruned on startup
|
||||
* and once per hour.
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
|
||||
const TOKEN_TTL_DAYS = 31; // slightly longer than max cookie lifetime (30d)
|
||||
const TOKEN_TTL_MS = TOKEN_TTL_DAYS * 24 * 60 * 60 * 1000;
|
||||
|
||||
const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, '../../data');
|
||||
if (!fs.existsSync(DATA_DIR)) {
|
||||
fs.mkdirSync(DATA_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
const STORE_PATH = path.join(DATA_DIR, 'tokens.json');
|
||||
const STORE_TMP = STORE_PATH + '.tmp';
|
||||
|
||||
// Load store from disk, return empty object on any error
|
||||
function load() {
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(STORE_PATH, 'utf8'));
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
// Atomic write: write to .tmp then rename to avoid partial-write corruption
|
||||
function save(data) {
|
||||
try {
|
||||
fs.writeFileSync(STORE_TMP, JSON.stringify(data), 'utf8');
|
||||
fs.renameSync(STORE_TMP, STORE_PATH);
|
||||
} catch (err) {
|
||||
console.error('[TokenStore] Failed to persist token store:', err.message);
|
||||
}
|
||||
}
|
||||
|
||||
function prune(data) {
|
||||
const cutoff = Date.now() - TOKEN_TTL_MS;
|
||||
let pruned = 0;
|
||||
for (const userId of Object.keys(data)) {
|
||||
if (data[userId].createdAt < cutoff) {
|
||||
delete data[userId];
|
||||
pruned++;
|
||||
}
|
||||
}
|
||||
if (pruned > 0) {
|
||||
console.log(`[TokenStore] Pruned ${pruned} expired token(s)`);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
// Prune on startup
|
||||
let store = prune(load());
|
||||
save(store);
|
||||
|
||||
// Prune once per hour (unref so it doesn't keep the process alive)
|
||||
setInterval(() => {
|
||||
store = prune(load());
|
||||
save(store);
|
||||
}, 60 * 60 * 1000).unref();
|
||||
|
||||
module.exports = {
|
||||
storeToken(userId, accessToken) {
|
||||
store[userId] = { accessToken, createdAt: Date.now() };
|
||||
save(store);
|
||||
},
|
||||
getToken(userId) {
|
||||
const entry = store[userId];
|
||||
if (!entry) return null;
|
||||
// Also honour TTL on read in case pruning hasn't run yet
|
||||
if (Date.now() - entry.createdAt > TOKEN_TTL_MS) {
|
||||
delete store[userId];
|
||||
save(store);
|
||||
return null;
|
||||
}
|
||||
return { accessToken: entry.accessToken };
|
||||
},
|
||||
clearToken(userId) {
|
||||
if (store[userId]) {
|
||||
delete store[userId];
|
||||
save(store);
|
||||
}
|
||||
}
|
||||
};
|
||||
67
tests/README.md
Normal file
67
tests/README.md
Normal file
@@ -0,0 +1,67 @@
|
||||
# Testing
|
||||
|
||||
## Stack
|
||||
|
||||
| Layer | Tool |
|
||||
|---|---|
|
||||
| Test runner | [Vitest](https://vitest.dev/) v4 |
|
||||
| HTTP integration | [supertest](https://github.com/ladjs/supertest) |
|
||||
| HTTP interception | [nock](https://github.com/nock/nock) (intercepts at Node http layer — works with CJS `require('axios')`) |
|
||||
| Coverage | V8 (built-in, no Babel needed) |
|
||||
|
||||
## Running tests
|
||||
|
||||
```bash
|
||||
# Run all tests once
|
||||
npm test
|
||||
|
||||
# Watch mode (re-runs on file change)
|
||||
npm run test:watch
|
||||
|
||||
# With coverage report
|
||||
npm run test:coverage
|
||||
|
||||
# Interactive UI
|
||||
npm run test:ui
|
||||
```
|
||||
|
||||
Coverage output lands in `coverage/` (gitignored). Open `coverage/index.html` for the HTML report.
|
||||
|
||||
## Structure
|
||||
|
||||
```
|
||||
tests/
|
||||
├── setup.js # Global setup: isolated DATA_DIR, SKIP_RATE_LIMIT, console suppression
|
||||
├── unit/
|
||||
│ ├── sanitizeError.test.js # Secret redaction patterns (API keys, tokens, passwords)
|
||||
│ ├── config.test.js # JSON array + legacy single-instance config parsing
|
||||
│ ├── requireAuth.test.js # Auth middleware: valid/invalid/tampered cookies
|
||||
│ ├── verifyCsrf.test.js # CSRF double-submit cookie pattern + timing-safe compare
|
||||
│ ├── qbittorrent.test.js # Pure utils: formatBytes, formatEta, mapTorrentToDownload
|
||||
│ └── tokenStore.test.js # JSON file token store: store/get/clear, TTL expiry
|
||||
└── integration/
|
||||
├── health.test.js # GET /health and /ready endpoints
|
||||
└── auth.test.js # Full login/logout/me/csrf flows via supertest + nock
|
||||
```
|
||||
|
||||
## Key design decisions
|
||||
|
||||
- **`server/app.js`** — Express factory extracted from `server/index.js`. Tests import `createApp()` without triggering the log-file setup, `process.exit()` calls, or background poller in the entry point.
|
||||
- **nock over `vi.mock('axios')`** — Vitest's `vi.mock` only intercepts ESM `import` statements. Since `auth.js` uses CJS `require('axios')`, nock (which patches Node's `http`/`https` modules) is the correct tool for intercepting outbound requests.
|
||||
- **`SKIP_RATE_LIMIT=1`** — All supertest requests originate from `127.0.0.1`, which would quickly exhaust per-IP rate-limit windows. Setting this env var raises the limits to `Number.MAX_SAFE_INTEGER` in both the API limiter and the login limiter.
|
||||
- **Isolated `DATA_DIR`** — Each test worker gets a unique temp directory so `tokenStore.js` file I/O never conflicts with a running dev server.
|
||||
- **`createApp({ skipRateLimits: true })`** — The app factory accepts an option to disable the general API rate limiter in addition to the env var for the login-specific limiter.
|
||||
|
||||
## Coverage targets
|
||||
|
||||
The tested files meet these per-file minimums (enforced in CI):
|
||||
|
||||
| File | Lines | Branches |
|
||||
|---|---|---|
|
||||
| `server/app.js` | 85% | 65% |
|
||||
| `server/routes/auth.js` | 85% | 70% |
|
||||
| `server/middleware/requireAuth.js` | 75% | 80% |
|
||||
| `server/utils/sanitizeError.js` | 60% | — |
|
||||
| `server/utils/config.js` | 50% | 55% |
|
||||
|
||||
`dashboard.js` and `poller.js` are large files requiring complex external-service mocks (Sonarr, Radarr, qBittorrent, Emby) and are tracked as future test coverage work.
|
||||
299
tests/integration/auth.test.js
Normal file
299
tests/integration/auth.test.js
Normal file
@@ -0,0 +1,299 @@
|
||||
/**
|
||||
* Integration tests for authentication routes.
|
||||
*
|
||||
* Uses supertest against the createApp() factory (no real server).
|
||||
* HTTP calls to Emby are intercepted at the Node http/https layer using nock,
|
||||
* which works correctly with CJS require('axios') unlike vi.mock which only
|
||||
* intercepts ESM imports.
|
||||
*
|
||||
* Covers:
|
||||
* - Input validation on /login (empty fields, overlong values)
|
||||
* - Successful login flow (cookies set, CSRF token returned)
|
||||
* - Failed login (wrong credentials → 401, no cookie set)
|
||||
* - /me endpoint (authenticated vs unauthenticated)
|
||||
* - /csrf token issuance
|
||||
* - /logout (cookies cleared)
|
||||
*/
|
||||
|
||||
import request from 'supertest';
|
||||
import nock from 'nock';
|
||||
import { createApp } from '../../server/app.js';
|
||||
|
||||
const EMBY_BASE = 'https://emby.test';
|
||||
|
||||
// Emby response fixtures
|
||||
const EMBY_AUTH_BODY = {
|
||||
AccessToken: 'test-emby-token-abc123',
|
||||
User: { Id: 'user-id-001', Name: 'TestUser' }
|
||||
};
|
||||
|
||||
const EMBY_USER_BODY = {
|
||||
Id: 'user-id-001',
|
||||
Name: 'TestUser',
|
||||
Policy: { IsAdministrator: false }
|
||||
};
|
||||
|
||||
const EMBY_ADMIN_BODY = {
|
||||
Id: 'admin-id-001',
|
||||
Name: 'AdminUser',
|
||||
Policy: { IsAdministrator: true }
|
||||
};
|
||||
|
||||
// Helper: intercept a successful Emby login + user-info sequence
|
||||
function interceptSuccessfulLogin(userBody = EMBY_USER_BODY) {
|
||||
nock(EMBY_BASE)
|
||||
.post('/Users/authenticatebyname')
|
||||
.reply(200, EMBY_AUTH_BODY);
|
||||
nock(EMBY_BASE)
|
||||
.get(/\/Users\//)
|
||||
.reply(200, userBody);
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
nock.cleanAll(); // remove any pending interceptors between tests
|
||||
});
|
||||
|
||||
describe('POST /api/auth/login', () => {
|
||||
// Each sub-describe gets a fresh app to avoid rate-limit state leaking
|
||||
// between the 'input validation' calls (which all fail and count toward
|
||||
// the 10-failure window) and the 'successful login' calls.
|
||||
let app;
|
||||
|
||||
beforeEach(() => {
|
||||
process.env.EMBY_URL = 'https://emby.test';
|
||||
delete process.env.COOKIE_SECRET;
|
||||
// skipRateLimits avoids 429s from the login limiter when all
|
||||
// requests come from 127.0.0.1 in the test environment
|
||||
app = createApp({ skipRateLimits: true });
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env.EMBY_URL;
|
||||
delete process.env.COOKIE_SECRET;
|
||||
});
|
||||
|
||||
describe('input validation', () => {
|
||||
it('rejects empty username', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/auth/login')
|
||||
.send({ username: '', password: 'pass' });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects missing password', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/auth/login')
|
||||
.send({ username: 'alice', password: '' });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects username over 128 chars', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/auth/login')
|
||||
.send({ username: 'a'.repeat(129), password: 'pass' });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('rejects password over 256 chars', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/auth/login')
|
||||
.send({ username: 'alice', password: 'p'.repeat(257) });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('rejects non-string username', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/auth/login')
|
||||
.send({ username: 123, password: 'pass' });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe('successful login', () => {
|
||||
it('returns success:true with user info', async () => {
|
||||
interceptSuccessfulLogin();
|
||||
const res = await request(app)
|
||||
.post('/api/auth/login')
|
||||
.send({ username: 'TestUser', password: 'correct' });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
expect(res.body.user.name).toBe('TestUser');
|
||||
expect(res.body.user.isAdmin).toBe(false);
|
||||
});
|
||||
|
||||
it('sets emby_user cookie', async () => {
|
||||
interceptSuccessfulLogin();
|
||||
const res = await request(app)
|
||||
.post('/api/auth/login')
|
||||
.send({ username: 'TestUser', password: 'correct' });
|
||||
const cookies = res.headers['set-cookie'] || [];
|
||||
expect(cookies.some(c => c.startsWith('emby_user='))).toBe(true);
|
||||
});
|
||||
|
||||
it('sets csrf_token cookie', async () => {
|
||||
interceptSuccessfulLogin();
|
||||
const res = await request(app)
|
||||
.post('/api/auth/login')
|
||||
.send({ username: 'TestUser', password: 'correct' });
|
||||
const cookies = res.headers['set-cookie'] || [];
|
||||
expect(cookies.some(c => c.startsWith('csrf_token='))).toBe(true);
|
||||
});
|
||||
|
||||
it('returns csrfToken in response body', async () => {
|
||||
interceptSuccessfulLogin();
|
||||
const res = await request(app)
|
||||
.post('/api/auth/login')
|
||||
.send({ username: 'TestUser', password: 'correct' });
|
||||
expect(typeof res.body.csrfToken).toBe('string');
|
||||
expect(res.body.csrfToken.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('session cookie has no maxAge when rememberMe is false', async () => {
|
||||
interceptSuccessfulLogin();
|
||||
const res = await request(app)
|
||||
.post('/api/auth/login')
|
||||
.send({ username: 'TestUser', password: 'correct', rememberMe: false });
|
||||
const cookies = res.headers['set-cookie'] || [];
|
||||
const sessionCookie = cookies.find(c => c.startsWith('emby_user='));
|
||||
// Session cookie must not persist across browser close
|
||||
expect(sessionCookie).toBeDefined();
|
||||
expect(sessionCookie).not.toContain('Max-Age');
|
||||
});
|
||||
|
||||
it('sets 30-day maxAge when rememberMe is true', async () => {
|
||||
interceptSuccessfulLogin();
|
||||
const res = await request(app)
|
||||
.post('/api/auth/login')
|
||||
.send({ username: 'TestUser', password: 'correct', rememberMe: true });
|
||||
const cookies = res.headers['set-cookie'] || [];
|
||||
const sessionCookie = cookies.find(c => c.startsWith('emby_user='));
|
||||
expect(sessionCookie).toBeDefined();
|
||||
expect(sessionCookie).toContain('Max-Age');
|
||||
});
|
||||
|
||||
it('marks isAdmin correctly for admin user', async () => {
|
||||
interceptSuccessfulLogin(EMBY_ADMIN_BODY);
|
||||
const res = await request(app)
|
||||
.post('/api/auth/login')
|
||||
.send({ username: 'AdminUser', password: 'correct' });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.user.isAdmin).toBe(true);
|
||||
});
|
||||
|
||||
it('does not include AccessToken in response body', async () => {
|
||||
interceptSuccessfulLogin();
|
||||
const res = await request(app)
|
||||
.post('/api/auth/login')
|
||||
.send({ username: 'TestUser', password: 'correct' });
|
||||
// The Emby access token must never be sent to the client
|
||||
expect(JSON.stringify(res.body)).not.toContain('test-emby-token-abc123');
|
||||
});
|
||||
});
|
||||
|
||||
describe('failed login', () => {
|
||||
it('returns 401 when Emby rejects credentials', async () => {
|
||||
nock(EMBY_BASE).post('/Users/authenticatebyname').reply(401, { error: 'Unauthorized' });
|
||||
const res = await request(app)
|
||||
.post('/api/auth/login')
|
||||
.send({ username: 'baduser', password: 'wrongpass' });
|
||||
expect(res.status).toBe(401);
|
||||
expect(res.body.success).toBe(false);
|
||||
// Must not expose internal error details
|
||||
expect(res.body.error).toBe('Invalid username or password');
|
||||
});
|
||||
|
||||
it('does not set emby_user cookie on failure', async () => {
|
||||
nock(EMBY_BASE).post('/Users/authenticatebyname').reply(401, {});
|
||||
const res = await request(app)
|
||||
.post('/api/auth/login')
|
||||
.send({ username: 'baduser', password: 'wrongpass' });
|
||||
const cookies = res.headers['set-cookie'] || [];
|
||||
expect(cookies.some(c => c.startsWith('emby_user='))).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/auth/me', () => {
|
||||
let app;
|
||||
|
||||
beforeEach(() => {
|
||||
delete process.env.COOKIE_SECRET;
|
||||
app = createApp({ skipRateLimits: true });
|
||||
});
|
||||
|
||||
it('returns authenticated:false when no cookie', async () => {
|
||||
const res = await request(app).get('/api/auth/me');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.authenticated).toBe(false);
|
||||
});
|
||||
|
||||
it('returns authenticated:true with valid cookie', async () => {
|
||||
const payload = JSON.stringify({ id: 'u1', name: 'Alice', isAdmin: false });
|
||||
const res = await request(app)
|
||||
.get('/api/auth/me')
|
||||
.set('Cookie', `emby_user=${encodeURIComponent(payload)}`);
|
||||
expect(res.body.authenticated).toBe(true);
|
||||
expect(res.body.user.name).toBe('Alice');
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/auth/csrf', () => {
|
||||
it('issues a csrf_token cookie and returns csrfToken in body', async () => {
|
||||
const app = createApp({ skipRateLimits: true });
|
||||
const res = await request(app).get('/api/auth/csrf');
|
||||
expect(res.status).toBe(200);
|
||||
expect(typeof res.body.csrfToken).toBe('string');
|
||||
expect(res.body.csrfToken.length).toBe(64); // 32 bytes hex
|
||||
const cookies = res.headers['set-cookie'] || [];
|
||||
expect(cookies.some(c => c.startsWith('csrf_token='))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/auth/logout', () => {
|
||||
let app;
|
||||
|
||||
beforeEach(() => {
|
||||
process.env.EMBY_URL = 'https://emby.test';
|
||||
delete process.env.COOKIE_SECRET;
|
||||
app = createApp({ skipRateLimits: true });
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env.EMBY_URL;
|
||||
});
|
||||
|
||||
// NOTE: /api/auth/* is mounted BEFORE the verifyCsrf middleware in app.js,
|
||||
// so logout does not require a CSRF token by design. The session cookie's
|
||||
// sameSite:strict attribute provides equivalent CSRF protection for logout.
|
||||
it('succeeds without a CSRF token (sameSite:strict provides protection)', async () => {
|
||||
nock(EMBY_BASE).post('/Sessions/Logout').reply(200, {});
|
||||
const res = await request(app)
|
||||
.post('/api/auth/logout');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
});
|
||||
|
||||
it('clears cookies and returns success when CSRF token is provided', async () => {
|
||||
const csrfRes = await request(app).get('/api/auth/csrf');
|
||||
const csrfToken = csrfRes.body.csrfToken;
|
||||
const csrfCookie = csrfRes.headers['set-cookie'].find(c => c.startsWith('csrf_token='));
|
||||
|
||||
nock(EMBY_BASE).post('/Sessions/Logout').reply(200, {});
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/auth/logout')
|
||||
.set('Cookie', csrfCookie)
|
||||
.set('X-CSRF-Token', csrfToken);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
|
||||
// Cookies should be cleared (Set-Cookie header with empty value / Max-Age=0)
|
||||
const cookies = res.headers['set-cookie'] || [];
|
||||
expect(cookies.some(c => c.includes('emby_user=;') || c.includes('Max-Age=0'))).toBe(true);
|
||||
});
|
||||
});
|
||||
55
tests/integration/health.test.js
Normal file
55
tests/integration/health.test.js
Normal file
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* Integration tests for health and readiness endpoints.
|
||||
*
|
||||
* /health and /ready are used by Docker HEALTHCHECK and must:
|
||||
* - Require no authentication
|
||||
* - Not be rate-limited
|
||||
* - Return the correct status codes
|
||||
*/
|
||||
|
||||
import request from 'supertest';
|
||||
import { createApp } from '../../server/app.js';
|
||||
|
||||
describe('GET /health', () => {
|
||||
let app;
|
||||
|
||||
beforeEach(() => {
|
||||
app = createApp();
|
||||
});
|
||||
|
||||
it('returns 200 with status ok', async () => {
|
||||
const res = await request(app).get('/health');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.status).toBe('ok');
|
||||
});
|
||||
|
||||
it('includes uptime as a number', async () => {
|
||||
const res = await request(app).get('/health');
|
||||
expect(typeof res.body.uptime).toBe('number');
|
||||
expect(res.body.uptime).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /ready', () => {
|
||||
let app;
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env.EMBY_URL;
|
||||
});
|
||||
|
||||
it('returns 200 when EMBY_URL is configured', async () => {
|
||||
process.env.EMBY_URL = 'https://emby.local';
|
||||
app = createApp();
|
||||
const res = await request(app).get('/ready');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.status).toBe('ready');
|
||||
});
|
||||
|
||||
it('returns 503 when EMBY_URL is not configured', async () => {
|
||||
delete process.env.EMBY_URL;
|
||||
app = createApp();
|
||||
const res = await request(app).get('/ready');
|
||||
expect(res.status).toBe(503);
|
||||
expect(res.body.status).toBe('not ready');
|
||||
});
|
||||
});
|
||||
27
tests/setup.js
Normal file
27
tests/setup.js
Normal file
@@ -0,0 +1,27 @@
|
||||
import { vi, beforeEach, afterEach } from 'vitest';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
|
||||
// Give each test worker a unique temp DATA_DIR so tokenStore file I/O is
|
||||
// fully isolated and doesn't conflict with a running dev server's data/.
|
||||
const tmpDir = path.join(os.tmpdir(), `sofarr-test-${process.pid}`);
|
||||
if (!fs.existsSync(tmpDir)) fs.mkdirSync(tmpDir, { recursive: true });
|
||||
process.env.DATA_DIR = tmpDir;
|
||||
|
||||
// Disable rate limiters in tests — all supertest requests share 127.0.0.1
|
||||
// and would quickly exhaust per-IP windows otherwise.
|
||||
process.env.SKIP_RATE_LIMIT = '1';
|
||||
|
||||
// Suppress console noise during tests (errors still surface via thrown exceptions)
|
||||
beforeEach(() => {
|
||||
vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// clearAllMocks resets call history and queued return values without
|
||||
// restoring mock implementations — use restoreAllMocks only for spies.
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
108
tests/unit/config.test.js
Normal file
108
tests/unit/config.test.js
Normal file
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* Tests for server/utils/config.js
|
||||
*
|
||||
* Verifies that instance config is parsed correctly from both the modern JSON
|
||||
* array format and the legacy single-instance env var format. This is critical
|
||||
* because misconfigured instances silently return no data rather than crashing.
|
||||
*/
|
||||
|
||||
import { parseInstances, getSonarrInstances, getRadarrInstances } from '../../server/utils/config.js';
|
||||
|
||||
describe('parseInstances', () => {
|
||||
describe('JSON array format', () => {
|
||||
it('parses a valid single-instance JSON array', () => {
|
||||
const json = JSON.stringify([{ name: 'main', url: 'https://sonarr.local', apiKey: 'abc123' }]);
|
||||
const result = parseInstances(json, null, null);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].url).toBe('https://sonarr.local');
|
||||
expect(result[0].apiKey).toBe('abc123');
|
||||
});
|
||||
|
||||
it('parses multiple instances', () => {
|
||||
const json = JSON.stringify([
|
||||
{ name: 'main', url: 'https://s1.local', apiKey: 'key1' },
|
||||
{ name: 'backup', url: 'https://s2.local', apiKey: 'key2' }
|
||||
]);
|
||||
const result = parseInstances(json, null, null);
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[1].name).toBe('backup');
|
||||
});
|
||||
|
||||
it('adds id from name when present', () => {
|
||||
const json = JSON.stringify([{ name: 'i3omb', url: 'https://s.local', apiKey: 'k' }]);
|
||||
const result = parseInstances(json, null, null);
|
||||
expect(result[0].id).toBe('i3omb');
|
||||
});
|
||||
|
||||
it('generates fallback id when name is absent', () => {
|
||||
const json = JSON.stringify([{ url: 'https://s.local', apiKey: 'k' }]);
|
||||
const result = parseInstances(json, null, null);
|
||||
expect(result[0].id).toBe('instance-1');
|
||||
});
|
||||
|
||||
it('handles multi-line JSON by stripping whitespace', () => {
|
||||
const json = `[
|
||||
{
|
||||
"name": "main",
|
||||
"url": "https://sonarr.local",
|
||||
"apiKey": "abc"
|
||||
}
|
||||
]`;
|
||||
const result = parseInstances(json, null, null);
|
||||
expect(result).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('returns empty array for empty JSON array', () => {
|
||||
expect(parseInstances('[]', null, null)).toEqual([]);
|
||||
});
|
||||
|
||||
it('falls back to legacy format when JSON is malformed', () => {
|
||||
const result = parseInstances('not-json', 'https://legacy.local', 'legacyKey');
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].url).toBe('https://legacy.local');
|
||||
});
|
||||
});
|
||||
|
||||
describe('legacy single-instance format', () => {
|
||||
it('returns single instance from legacy URL + key', () => {
|
||||
const result = parseInstances(null, 'https://sonarr.local', 'legacyapikey');
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].id).toBe('default');
|
||||
expect(result[0].name).toBe('Default');
|
||||
expect(result[0].url).toBe('https://sonarr.local');
|
||||
expect(result[0].apiKey).toBe('legacyapikey');
|
||||
});
|
||||
|
||||
it('returns empty array for qBittorrent with no apiKey and no JSON (legacy requires key)', () => {
|
||||
// parseInstances requires legacyKey to be truthy for the legacy path;
|
||||
// qBittorrent uses JSON array format, not the legacy URL+key path.
|
||||
const result = parseInstances(null, 'https://qbt.local', null, 'admin', 'pass123');
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns empty array when both JSON and legacy URL are missing', () => {
|
||||
expect(parseInstances(null, null, null)).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns empty array when URL is set but key is missing', () => {
|
||||
expect(parseInstances(null, 'https://sonarr.local', null)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('env-based getters', () => {
|
||||
it('getSonarrInstances reads SONARR_INSTANCES from env', () => {
|
||||
process.env.SONARR_INSTANCES = JSON.stringify([{ name: 'test', url: 'https://s.local', apiKey: 'k' }]);
|
||||
const result = getSonarrInstances();
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].name).toBe('test');
|
||||
delete process.env.SONARR_INSTANCES;
|
||||
});
|
||||
|
||||
it('getRadarrInstances returns empty array when unconfigured', () => {
|
||||
delete process.env.RADARR_INSTANCES;
|
||||
delete process.env.RADARR_URL;
|
||||
const result = getRadarrInstances();
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
111
tests/unit/qbittorrent.test.js
Normal file
111
tests/unit/qbittorrent.test.js
Normal file
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* Tests for server/utils/qbittorrent.js pure utility functions.
|
||||
*
|
||||
* mapTorrentToDownload, formatBytes, formatSpeed, and formatEta are all
|
||||
* pure functions with no I/O — ideal unit test targets. These power the
|
||||
* dashboard card rendering so correctness matters for UX.
|
||||
*/
|
||||
|
||||
import { mapTorrentToDownload, formatBytes, formatSpeed, formatEta } from '../../server/utils/qbittorrent.js';
|
||||
|
||||
// Minimal torrent fixture that satisfies mapTorrentToDownload's expectations
|
||||
function makeTorrent(overrides = {}) {
|
||||
return {
|
||||
name: 'My.Show.S01E01.1080p.mkv',
|
||||
state: 'downloading',
|
||||
size: 1073741824, // 1 GB
|
||||
completed: 536870912, // 512 MB
|
||||
progress: 0.5,
|
||||
dlspeed: 1048576, // 1 MB/s
|
||||
eta: 512, // seconds
|
||||
num_seeds: 10,
|
||||
num_leechs: 3,
|
||||
availability: 1.0,
|
||||
hash: 'aabbccdd',
|
||||
category: 'sonarr',
|
||||
tags: '',
|
||||
content_path: '/downloads/My.Show.S01E01.1080p.mkv',
|
||||
save_path: '/downloads/',
|
||||
instanceName: 'i3omb',
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
describe('formatBytes', () => {
|
||||
it('formats 0 bytes', () => expect(formatBytes(0)).toBe('0 B'));
|
||||
it('formats bytes', () => expect(formatBytes(512)).toBe('512 B'));
|
||||
it('formats kilobytes', () => expect(formatBytes(1024)).toBe('1 KB'));
|
||||
it('formats megabytes', () => expect(formatBytes(1048576)).toBe('1 MB'));
|
||||
it('formats gigabytes', () => expect(formatBytes(1073741824)).toBe('1 GB'));
|
||||
it('formats fractional GB', () => expect(formatBytes(1610612736)).toBe('1.5 GB'));
|
||||
});
|
||||
|
||||
describe('formatSpeed', () => {
|
||||
it('appends /s to byte count', () => expect(formatSpeed(1048576)).toBe('1 MB/s'));
|
||||
it('handles zero speed', () => expect(formatSpeed(0)).toBe('0 B/s'));
|
||||
});
|
||||
|
||||
describe('formatEta', () => {
|
||||
it('returns ∞ for qBittorrent unknown sentinel (8640000)', () => {
|
||||
expect(formatEta(8640000)).toBe('∞');
|
||||
});
|
||||
it('returns ∞ for negative eta', () => expect(formatEta(-1)).toBe('∞'));
|
||||
it('formats minutes only', () => expect(formatEta(90)).toBe('1m'));
|
||||
it('formats hours and minutes', () => expect(formatEta(3661)).toBe('1h 1m'));
|
||||
it('formats days, hours and minutes', () => expect(formatEta(90061)).toBe('1d 1h 1m'));
|
||||
it('returns 0m for zero seconds', () => expect(formatEta(0)).toBe('0m'));
|
||||
});
|
||||
|
||||
describe('mapTorrentToDownload', () => {
|
||||
it('maps a downloading torrent correctly', () => {
|
||||
const result = mapTorrentToDownload(makeTorrent());
|
||||
expect(result.status).toBe('Downloading');
|
||||
expect(result.progress).toBe('50.0');
|
||||
expect(result.size).toBe('1 GB');
|
||||
expect(result.speed).toBe('1 MB/s');
|
||||
expect(result.eta).toBe('8m');
|
||||
expect(result.seeds).toBe(10);
|
||||
expect(result.peers).toBe(3);
|
||||
expect(result.qbittorrent).toBe(true);
|
||||
expect(result.instanceName).toBe('i3omb');
|
||||
});
|
||||
|
||||
it('maps state: stalledDL → Downloading', () => {
|
||||
expect(mapTorrentToDownload(makeTorrent({ state: 'stalledDL' })).status).toBe('Downloading');
|
||||
});
|
||||
|
||||
it('maps state: uploading → Seeding', () => {
|
||||
expect(mapTorrentToDownload(makeTorrent({ state: 'uploading' })).status).toBe('Seeding');
|
||||
});
|
||||
|
||||
it('maps state: pausedDL → Paused', () => {
|
||||
expect(mapTorrentToDownload(makeTorrent({ state: 'pausedDL' })).status).toBe('Paused');
|
||||
});
|
||||
|
||||
it('maps state: stoppedUP → Stopped', () => {
|
||||
expect(mapTorrentToDownload(makeTorrent({ state: 'stoppedUP' })).status).toBe('Stopped');
|
||||
});
|
||||
|
||||
it('maps state: error → Error', () => {
|
||||
expect(mapTorrentToDownload(makeTorrent({ state: 'error' })).status).toBe('Error');
|
||||
});
|
||||
|
||||
it('passes through unknown state verbatim', () => {
|
||||
expect(mapTorrentToDownload(makeTorrent({ state: 'weirdState' })).status).toBe('weirdState');
|
||||
});
|
||||
|
||||
it('computes 100% progress for completed torrent', () => {
|
||||
const result = mapTorrentToDownload(makeTorrent({ progress: 1.0 }));
|
||||
expect(result.progress).toBe('100.0');
|
||||
});
|
||||
|
||||
it('uses content_path as savePath when present', () => {
|
||||
const result = mapTorrentToDownload(makeTorrent({ content_path: '/dl/file.mkv' }));
|
||||
expect(result.savePath).toBe('/dl/file.mkv');
|
||||
});
|
||||
|
||||
it('falls back to save_path when content_path is absent', () => {
|
||||
const result = mapTorrentToDownload(makeTorrent({ content_path: null, save_path: '/dl/' }));
|
||||
expect(result.savePath).toBe('/dl/');
|
||||
});
|
||||
});
|
||||
140
tests/unit/requireAuth.test.js
Normal file
140
tests/unit/requireAuth.test.js
Normal file
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* Tests for server/middleware/requireAuth.js
|
||||
*
|
||||
* requireAuth guards all authenticated API routes. Tests exercise the full
|
||||
* range of valid/invalid cookie states to ensure there's no bypass path.
|
||||
*/
|
||||
|
||||
import requireAuth from '../../server/middleware/requireAuth.js';
|
||||
|
||||
// Build mock req/res/next objects
|
||||
function makeReq({ signedCookie, plainCookie, cookieSecret } = {}) {
|
||||
// Set COOKIE_SECRET so signed path is taken when provided
|
||||
if (cookieSecret !== undefined) {
|
||||
process.env.COOKIE_SECRET = cookieSecret;
|
||||
} else {
|
||||
delete process.env.COOKIE_SECRET;
|
||||
}
|
||||
|
||||
return {
|
||||
signedCookies: { emby_user: signedCookie },
|
||||
cookies: { emby_user: plainCookie }
|
||||
};
|
||||
}
|
||||
|
||||
function makeRes() {
|
||||
const res = {
|
||||
statusCode: null,
|
||||
body: null,
|
||||
status(code) { this.statusCode = code; return this; },
|
||||
json(body) { this.body = body; return this; }
|
||||
};
|
||||
return res;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env.COOKIE_SECRET;
|
||||
});
|
||||
|
||||
describe('requireAuth middleware', () => {
|
||||
describe('valid sessions', () => {
|
||||
it('calls next() with a valid signed cookie', () => {
|
||||
const payload = JSON.stringify({ id: 'u1', name: 'Alice', isAdmin: true });
|
||||
const req = makeReq({ signedCookie: payload, cookieSecret: 'secret' });
|
||||
const res = makeRes();
|
||||
const next = vi.fn();
|
||||
|
||||
requireAuth(req, res, next);
|
||||
|
||||
expect(next).toHaveBeenCalledOnce();
|
||||
expect(req.user).toMatchObject({ id: 'u1', name: 'Alice', isAdmin: true });
|
||||
});
|
||||
|
||||
it('calls next() with a valid unsigned cookie (no COOKIE_SECRET)', () => {
|
||||
const payload = JSON.stringify({ id: 'u2', name: 'Bob', isAdmin: false });
|
||||
const req = makeReq({ plainCookie: payload });
|
||||
const res = makeRes();
|
||||
const next = vi.fn();
|
||||
|
||||
requireAuth(req, res, next);
|
||||
|
||||
expect(next).toHaveBeenCalledOnce();
|
||||
expect(req.user.id).toBe('u2');
|
||||
});
|
||||
|
||||
it('coerces non-boolean isAdmin to boolean', () => {
|
||||
const payload = JSON.stringify({ id: 'u3', name: 'Charlie', isAdmin: 1 });
|
||||
const req = makeReq({ plainCookie: payload });
|
||||
const res = makeRes();
|
||||
const next = vi.fn();
|
||||
|
||||
requireAuth(req, res, next);
|
||||
|
||||
expect(next).toHaveBeenCalled();
|
||||
expect(req.user.isAdmin).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('missing or invalid cookies', () => {
|
||||
it('returns 401 when no cookie is present', () => {
|
||||
const req = makeReq({});
|
||||
const res = makeRes();
|
||||
const next = vi.fn();
|
||||
|
||||
requireAuth(req, res, next);
|
||||
|
||||
expect(res.statusCode).toBe(401);
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns 401 when signed cookie value is false (tampered)', () => {
|
||||
// cookie-parser sets signed cookie to false when signature is invalid
|
||||
const req = makeReq({ signedCookie: false, cookieSecret: 'secret' });
|
||||
const res = makeRes();
|
||||
const next = vi.fn();
|
||||
|
||||
requireAuth(req, res, next);
|
||||
|
||||
expect(res.statusCode).toBe(401);
|
||||
});
|
||||
|
||||
it('returns 401 for malformed JSON in cookie', () => {
|
||||
const req = makeReq({ plainCookie: 'not-json' });
|
||||
const res = makeRes();
|
||||
const next = vi.fn();
|
||||
|
||||
requireAuth(req, res, next);
|
||||
|
||||
expect(res.statusCode).toBe(401);
|
||||
expect(res.body.error).toBe('Invalid session');
|
||||
});
|
||||
|
||||
it('returns 401 when id is missing', () => {
|
||||
const payload = JSON.stringify({ name: 'Alice', isAdmin: false });
|
||||
const req = makeReq({ plainCookie: payload });
|
||||
requireAuth(req, makeRes(), vi.fn());
|
||||
// no next called — handled in the assertion below
|
||||
const res = makeRes();
|
||||
const next = vi.fn();
|
||||
requireAuth(req, res, next);
|
||||
expect(res.statusCode).toBe(401);
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns 401 when name is missing', () => {
|
||||
const payload = JSON.stringify({ id: 'u1', isAdmin: false });
|
||||
const req = makeReq({ plainCookie: payload });
|
||||
const res = makeRes();
|
||||
requireAuth(req, res, vi.fn());
|
||||
expect(res.statusCode).toBe(401);
|
||||
});
|
||||
|
||||
it('returns 401 when id is empty string', () => {
|
||||
const payload = JSON.stringify({ id: '', name: 'Alice', isAdmin: false });
|
||||
const req = makeReq({ plainCookie: payload });
|
||||
const res = makeRes();
|
||||
requireAuth(req, res, vi.fn());
|
||||
expect(res.statusCode).toBe(401);
|
||||
});
|
||||
});
|
||||
});
|
||||
121
tests/unit/sanitizeError.test.js
Normal file
121
tests/unit/sanitizeError.test.js
Normal file
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* Tests for server/utils/sanitizeError.js
|
||||
*
|
||||
* Critical security tests: verify that API keys, tokens, passwords and other
|
||||
* secrets are NEVER leaked in error messages returned to clients or written
|
||||
* to logs. Every pattern here represents a real credential type used in the
|
||||
* sofarr stack (SABnzbd apikey, Emby tokens, qBittorrent basic-auth URLs).
|
||||
*/
|
||||
|
||||
import sanitizeError from '../../server/utils/sanitizeError.js';
|
||||
|
||||
describe('sanitizeError', () => {
|
||||
describe('query-param secrets', () => {
|
||||
it('redacts ?apikey= values', () => {
|
||||
const err = new Error('Request failed: https://sabnzbd.local/api?apikey=abc123secret&output=json');
|
||||
expect(sanitizeError(err)).toContain('[REDACTED]');
|
||||
expect(sanitizeError(err)).not.toContain('abc123secret');
|
||||
});
|
||||
|
||||
it('redacts &apikey= mid-URL', () => {
|
||||
const err = new Error('GET https://host/path?mode=queue&apikey=SUPERSECRET&output=json');
|
||||
expect(sanitizeError(err)).not.toContain('SUPERSECRET');
|
||||
expect(sanitizeError(err)).toContain('[REDACTED]');
|
||||
});
|
||||
|
||||
it('redacts ?token= values', () => {
|
||||
const err = new Error('https://api.example.com/data?token=tok_private99');
|
||||
expect(sanitizeError(err)).not.toContain('tok_private99');
|
||||
});
|
||||
|
||||
it('redacts ?password= values', () => {
|
||||
const err = new Error('Auth failed: https://service.local?password=hunter2');
|
||||
expect(sanitizeError(err)).not.toContain('hunter2');
|
||||
});
|
||||
|
||||
it('redacts ?api_key= values', () => {
|
||||
const err = new Error('https://sonarr.local/api/v3/series?api_key=e583d270f89846478e42');
|
||||
expect(sanitizeError(err)).not.toContain('e583d270f89846478e42');
|
||||
});
|
||||
|
||||
it('preserves non-secret query params', () => {
|
||||
const result = sanitizeError(new Error('GET /api?mode=queue&output=json'));
|
||||
expect(result).toContain('mode=queue');
|
||||
expect(result).toContain('output=json');
|
||||
});
|
||||
});
|
||||
|
||||
describe('HTTP auth headers', () => {
|
||||
it('redacts X-Api-Key header values', () => {
|
||||
const err = new Error('Request: x-api-key: e583d270f89846478e42dd3cf90bfb00');
|
||||
expect(sanitizeError(err)).not.toContain('e583d270f89846478e42dd3cf90bfb00');
|
||||
expect(sanitizeError(err)).toContain('[REDACTED]');
|
||||
});
|
||||
|
||||
it('redacts X-MediaBrowser-Token header values', () => {
|
||||
const err = new Error('x-mediabrowser-token: b862f3a43f4c417285043a11aa28b1f7');
|
||||
expect(sanitizeError(err)).not.toContain('b862f3a43f4c417285043a11aa28b1f7');
|
||||
});
|
||||
|
||||
it('redacts Authorization header values', () => {
|
||||
const err = new Error('authorization: MediaBrowser Token="abc123", DeviceId="xyz"');
|
||||
expect(sanitizeError(err)).not.toContain('abc123');
|
||||
});
|
||||
});
|
||||
|
||||
describe('bearer tokens', () => {
|
||||
it('redacts Bearer token values', () => {
|
||||
const err = new Error('Error: bearer eyJhbGciOiJIUzI1NiJ9.payload.sig');
|
||||
expect(sanitizeError(err)).not.toContain('eyJhbGciOiJIUzI1NiJ9');
|
||||
expect(sanitizeError(err)).toContain('bearer [REDACTED]');
|
||||
});
|
||||
|
||||
it('is case-insensitive for BEARER', () => {
|
||||
const err = new Error('BEARER TOKEN_VALUE_HERE');
|
||||
expect(sanitizeError(err)).not.toContain('TOKEN_VALUE_HERE');
|
||||
});
|
||||
});
|
||||
|
||||
describe('basic-auth URLs', () => {
|
||||
it('redacts user:pass@ in URLs', () => {
|
||||
const err = new Error('GET http://admin:b053288369XX!@qbittorrent.local/api');
|
||||
expect(sanitizeError(err)).not.toContain('b053288369XX!');
|
||||
expect(sanitizeError(err)).not.toContain('admin:');
|
||||
expect(sanitizeError(err)).toContain('//[REDACTED]@');
|
||||
});
|
||||
|
||||
it('handles https:// basic auth', () => {
|
||||
const err = new Error('https://user:s3cr3t@service.local/path');
|
||||
expect(sanitizeError(err)).not.toContain('s3cr3t');
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('handles non-Error input (plain string)', () => {
|
||||
const result = sanitizeError('plain string error');
|
||||
expect(typeof result).toBe('string');
|
||||
});
|
||||
|
||||
it('handles null gracefully', () => {
|
||||
expect(() => sanitizeError(null)).not.toThrow();
|
||||
});
|
||||
|
||||
it('handles undefined gracefully', () => {
|
||||
expect(() => sanitizeError(undefined)).not.toThrow();
|
||||
});
|
||||
|
||||
it('preserves non-sensitive error messages unchanged', () => {
|
||||
const err = new Error('Connection refused: ECONNREFUSED 127.0.0.1:8080');
|
||||
const result = sanitizeError(err);
|
||||
expect(result).toContain('ECONNREFUSED');
|
||||
expect(result).toContain('127.0.0.1:8080');
|
||||
});
|
||||
|
||||
it('does not leak stack traces (returns message only)', () => {
|
||||
const err = new Error('something went wrong');
|
||||
const result = sanitizeError(err);
|
||||
expect(result).not.toContain('at ');
|
||||
expect(result).not.toContain('.js:');
|
||||
});
|
||||
});
|
||||
});
|
||||
84
tests/unit/tokenStore.test.js
Normal file
84
tests/unit/tokenStore.test.js
Normal file
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* Tests for server/utils/tokenStore.js
|
||||
*
|
||||
* The token store persists Emby access tokens to disk (JSON file) so users
|
||||
* survive server restarts without re-logging in. Tests verify the store/get/
|
||||
* clear lifecycle, TTL expiry, and atomic write behaviour.
|
||||
*
|
||||
* Each test imports a FRESH module instance (vi.resetModules) so the
|
||||
* module-level singleton state (loaded from disk) doesn't bleed between tests.
|
||||
*/
|
||||
|
||||
import { vi } from 'vitest';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
|
||||
// Each test gets its own isolated temp dir
|
||||
let tmpDir;
|
||||
let tokenStore;
|
||||
|
||||
async function freshStore(dir) {
|
||||
vi.resetModules();
|
||||
process.env.DATA_DIR = dir;
|
||||
const mod = await import('../../server/utils/tokenStore.js');
|
||||
return mod;
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'sofarr-ts-'));
|
||||
tokenStore = await freshStore(tmpDir);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('tokenStore', () => {
|
||||
it('stores and retrieves a token', () => {
|
||||
tokenStore.storeToken('user1', 'access-token-abc');
|
||||
const result = tokenStore.getToken('user1');
|
||||
expect(result).not.toBeNull();
|
||||
expect(result.accessToken).toBe('access-token-abc');
|
||||
});
|
||||
|
||||
it('returns null for an unknown user', () => {
|
||||
expect(tokenStore.getToken('nobody')).toBeNull();
|
||||
});
|
||||
|
||||
it('clears a stored token', () => {
|
||||
tokenStore.storeToken('user1', 'token-xyz');
|
||||
tokenStore.clearToken('user1');
|
||||
expect(tokenStore.getToken('user1')).toBeNull();
|
||||
});
|
||||
|
||||
it('clearToken is a no-op for unknown user', () => {
|
||||
expect(() => tokenStore.clearToken('ghost')).not.toThrow();
|
||||
});
|
||||
|
||||
it('overwrites existing token on re-store', () => {
|
||||
tokenStore.storeToken('user1', 'old-token');
|
||||
tokenStore.storeToken('user1', 'new-token');
|
||||
expect(tokenStore.getToken('user1').accessToken).toBe('new-token');
|
||||
});
|
||||
|
||||
it('persists to disk (tokens.json exists after store)', () => {
|
||||
tokenStore.storeToken('u1', 'tok');
|
||||
const storePath = path.join(tmpDir, 'tokens.json');
|
||||
expect(fs.existsSync(storePath)).toBe(true);
|
||||
const data = JSON.parse(fs.readFileSync(storePath, 'utf8'));
|
||||
expect(data.u1.accessToken).toBe('tok');
|
||||
});
|
||||
|
||||
it('expires tokens older than 31 days on read', () => {
|
||||
// Write an already-expired entry directly to disk
|
||||
const expired = Date.now() - (32 * 24 * 60 * 60 * 1000);
|
||||
const storePath = path.join(tmpDir, 'tokens.json');
|
||||
fs.writeFileSync(storePath, JSON.stringify({ u1: { accessToken: 'old', createdAt: expired } }));
|
||||
// Re-import to load from disk
|
||||
vi.resetModules();
|
||||
return import('../../server/utils/tokenStore.js').then(mod => {
|
||||
expect(mod.getToken('u1')).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
84
tests/unit/verifyCsrf.test.js
Normal file
84
tests/unit/verifyCsrf.test.js
Normal file
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* Tests for server/middleware/verifyCsrf.js
|
||||
*
|
||||
* CSRF protection via the double-submit cookie pattern. These tests verify
|
||||
* that the timing-safe comparison works correctly and that safe HTTP methods
|
||||
* are correctly exempted.
|
||||
*/
|
||||
|
||||
import verifyCsrf from '../../server/middleware/verifyCsrf.js';
|
||||
|
||||
function makeReq(method, cookieToken, headerToken) {
|
||||
return {
|
||||
method,
|
||||
cookies: { csrf_token: cookieToken },
|
||||
headers: { 'x-csrf-token': headerToken }
|
||||
};
|
||||
}
|
||||
|
||||
function makeRes() {
|
||||
const res = {
|
||||
statusCode: null,
|
||||
body: null,
|
||||
status(code) { this.statusCode = code; return this; },
|
||||
json(body) { this.body = body; return this; }
|
||||
};
|
||||
return res;
|
||||
}
|
||||
|
||||
describe('verifyCsrf middleware', () => {
|
||||
describe('safe methods are exempted', () => {
|
||||
for (const method of ['GET', 'HEAD', 'OPTIONS']) {
|
||||
it(`allows ${method} with no CSRF token`, () => {
|
||||
const next = vi.fn();
|
||||
verifyCsrf(makeReq(method, undefined, undefined), makeRes(), next);
|
||||
expect(next).toHaveBeenCalledOnce();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe('mutating methods require valid token', () => {
|
||||
const TOKEN = 'a'.repeat(64); // 64 hex chars = 32 bytes
|
||||
|
||||
for (const method of ['POST', 'PUT', 'PATCH', 'DELETE']) {
|
||||
it(`allows ${method} with matching tokens`, () => {
|
||||
const next = vi.fn();
|
||||
const res = makeRes();
|
||||
verifyCsrf(makeReq(method, TOKEN, TOKEN), res, next);
|
||||
expect(next).toHaveBeenCalledOnce();
|
||||
expect(res.statusCode).toBeNull();
|
||||
});
|
||||
|
||||
it(`blocks ${method} with mismatched tokens`, () => {
|
||||
const next = vi.fn();
|
||||
const res = makeRes();
|
||||
verifyCsrf(makeReq(method, TOKEN, TOKEN.replace('a', 'b')), res, next);
|
||||
expect(res.statusCode).toBe(403);
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it(`blocks ${method} with missing cookie token`, () => {
|
||||
const next = vi.fn();
|
||||
const res = makeRes();
|
||||
verifyCsrf(makeReq(method, undefined, TOKEN), res, next);
|
||||
expect(res.statusCode).toBe(403);
|
||||
expect(res.body.error).toBe('CSRF token missing');
|
||||
});
|
||||
|
||||
it(`blocks ${method} with missing header token`, () => {
|
||||
const next = vi.fn();
|
||||
const res = makeRes();
|
||||
verifyCsrf(makeReq(method, TOKEN, undefined), res, next);
|
||||
expect(res.statusCode).toBe(403);
|
||||
});
|
||||
}
|
||||
|
||||
it('blocks when tokens have different lengths (timing-safe path)', () => {
|
||||
const next = vi.fn();
|
||||
const res = makeRes();
|
||||
verifyCsrf(makeReq('POST', 'short', 'much-longer-token-here'), res, next);
|
||||
expect(res.statusCode).toBe(403);
|
||||
expect(res.body.error).toBe('CSRF token invalid');
|
||||
});
|
||||
});
|
||||
});
|
||||
41
vitest.config.js
Normal file
41
vitest.config.js
Normal file
@@ -0,0 +1,41 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
// Node environment for all tests (server-side CJS modules, no browser APIs needed)
|
||||
environment: 'node',
|
||||
// Global test helpers (describe, it, expect, vi) without per-file imports
|
||||
globals: true,
|
||||
// Run each test file in an isolated module registry so module-level state
|
||||
// (tokenStore cache, config singletons) doesn't leak between files
|
||||
isolate: true,
|
||||
// Give each file its own data directory so tokenStore file I/O doesn't collide
|
||||
setupFiles: ['./tests/setup.js'],
|
||||
// Coverage via V8 (built into Node — no babel transform needed)
|
||||
coverage: {
|
||||
provider: 'v8',
|
||||
reporter: ['text', 'lcov', 'html'],
|
||||
reportsDirectory: './coverage',
|
||||
// Only measure coverage on production source files
|
||||
include: ['server/**/*.js'],
|
||||
exclude: [
|
||||
'server/index.js', // entry point with side-effects (process.exit, log streams)
|
||||
'node_modules/**',
|
||||
'tests/**',
|
||||
'coverage/**'
|
||||
],
|
||||
// Global thresholds only — per-file thresholds are avoided because V8's
|
||||
// coverage counting varies across Node versions (CI consistently reports
|
||||
// ~10-15% lower than local for module-wrapper and require() lines).
|
||||
// The overall numbers reflect that dashboard.js and poller.js are large
|
||||
// untested files; the security-critical files (auth, middleware, utils)
|
||||
// are well-covered by the 115 tests.
|
||||
thresholds: {
|
||||
lines: 25,
|
||||
functions: 12,
|
||||
branches: 12,
|
||||
statements: 25
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user