Compare commits

..

1 Commits

Author SHA1 Message Date
Gandalf feb2d26c5b Merge pull request 'ci: build multi-arch images (amd64, arm64, arm/v7)' (#3) from develop into release/0.1
Build and Push Docker Image / build (push) Has been cancelled
Reviewed-on: #3
2026-05-15 20:57:45 +01:00
64 changed files with 1911 additions and 9223 deletions
-2
View File
@@ -6,7 +6,6 @@ node_modules/
.gitignore
.DS_Store
*.log
**/*.log
client/
dist/
build/
@@ -14,4 +13,3 @@ README.md
.dockerignore
Dockerfile
.gitea/
docs/
+22
View File
@@ -0,0 +1,22 @@
# Server Configuration
PORT=3001
# Emby Configuration (single instance)
EMBY_URL=http://localhost:8096
EMBY_API_KEY=your_emby_api_key
# SABnzbd Instances (JSON array)
# Format: [{"name": "Instance Name", "url": "http://...", "apiKey": "..."}]
SABNZBD_INSTANCES=[{"name": "Primary", "url": "http://localhost:8080", "apiKey": "your_api_key"}]
# Sonarr Instances (JSON array)
SONARR_INSTANCES=[{"name": "Primary", "url": "http://localhost:8989", "apiKey": "your_api_key"}]
# Radarr Instances (JSON array)
RADARR_INSTANCES=[{"name": "Primary", "url": "http://localhost:7878", "apiKey": "your_api_key"}]
# qBittorrent Instances (JSON array)
QBITTORRENT_INSTANCES=[
{"name": "ransackedcrew", "url": "https://qbittorrent.ransackedcrew.info", "username": "gronod", "password": "K32D&JDjtHA&mC"},
{"name": "i3omb", "url": "https://qbittorrent.i3omb.com", "username": "admin", "password": "b053288369XX!"}
]
-51
View File
@@ -14,56 +14,6 @@ PORT=3001
# - silent: No logging
LOG_LEVEL=info
# Cookie signing secret for tamper-proof session cookies
# Required in production (server exits on startup if unset).
# Generate with: openssl rand -hex 32
COOKIE_SECRET=your-cookie-secret-here
# =============================================================================
# TLS / HTTPS
# =============================================================================
# TLS is enabled by default using the bundled snakeoil self-signed certificate
# (valid for localhost/127.0.0.1, 10-year expiry).
# Set TLS_CERT and TLS_KEY to use your own certificate (recommended).
# Set TLS_ENABLED=false to run in plain HTTP mode (e.g. behind a TLS proxy).
#
# To generate a self-signed cert for your own hostname:
# openssl req -x509 -newkey rsa:2048 -keyout server.key -out server.crt \
# -days 365 -nodes -subj "/CN=yourhostname" \
# -addext "subjectAltName=DNS:yourhostname,IP:192.168.x.x"
#
# TLS_ENABLED=true
# TLS_CERT=/path/to/server.crt
# TLS_KEY=/path/to/server.key
# =============================================================================
# REVERSE PROXY & DEPLOYMENT
# =============================================================================
# Set to 1 when running behind a reverse proxy (Nginx, Caddy, Traefik).
# This makes Express trust X-Forwarded-For and X-Forwarded-Proto so that
# req.ip reflects the real client IP and cookies are marked secure correctly.
# Leave unset if sofarr is exposed directly to the internet.
# TRUST_PROXY=1
# Directory for persistent data (SQLite token store, server logs).
# Must be writable by the process user (UID 1000 in the container).
# Defaults to ./data relative to the project root.
# DATA_DIR=/app/data
# Number of days of completed download history to show in the Recently Completed section.
# Override per-request with ?days=N (capped at 90).
# RECENT_COMPLETED_DAYS=7
# Background polling interval in milliseconds (default: 5000)
# sofarr polls all services in the background and caches results so
# dashboard requests are near-instant.
# Set to 0, "off", "false", or "disabled" to disable background polling.
# When disabled, data is fetched on-demand when a user opens the dashboard
# and cached for 30 seconds so other users benefit from the same fetch.
# POLL_INTERVAL=5000
# =============================================================================
# EMBY (Authentication - Required)
# =============================================================================
@@ -124,5 +74,4 @@ RADARR_INSTANCES=[{"name":"main","url":"https://radarr.example.com","apiKey":"yo
# 3. URLs should include protocol (http:// or https://)
# 4. For qBittorrent, ensure Web UI is enabled in settings
# 5. User downloads are matched by tags in Sonarr/Radarr - tag your media!
# 6. Background polling keeps data fresh; disable it for low-resource setups
# =============================================================================
+16 -16
View File
@@ -4,7 +4,6 @@ on:
push:
branches:
- 'release/**'
- 'develop'
jobs:
build:
@@ -13,31 +12,32 @@ jobs:
- name: Checkout
uses: actions/checkout@v4
- name: Compute image tags
id: meta
- name: Extract version from package.json
id: version
run: |
VERSION=$(node -p "require('./package.json').version")
BRANCH=${GITHUB_REF#refs/heads/}
RELEASE_NAME=${BRANCH#release/}
echo "version=${VERSION}" >> $GITHUB_OUTPUT
echo "release=${RELEASE_NAME}" >> $GITHUB_OUTPUT
echo "Building version ${VERSION} from branch ${BRANCH}"
if [ "$BRANCH" = "develop" ]; then
echo "tags=reg.i3omb.com/sofarr:develop" >> $GITHUB_OUTPUT
echo "Building develop image (version ${VERSION})"
else
RELEASE_NAME=${BRANCH#release/}
TAGS="reg.i3omb.com/sofarr:${VERSION}"
TAGS="${TAGS},reg.i3omb.com/sofarr:${RELEASE_NAME}"
TAGS="${TAGS},reg.i3omb.com/sofarr:latest"
echo "tags=${TAGS}" >> $GITHUB_OUTPUT
echo "Building release image ${VERSION} from branch ${BRANCH}"
fi
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build and push Docker image
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
platforms: linux/amd64,linux/arm64,linux/arm/v7
tags: |
reg.i3omb.com/sofarr:${{ steps.version.outputs.version }}
reg.i3omb.com/sofarr:${{ steps.version.outputs.release }}
reg.i3omb.com/sofarr:latest
labels: |
org.opencontainers.image.version=${{ steps.meta.outputs.version }}
org.opencontainers.image.version=${{ steps.version.outputs.version }}
org.opencontainers.image.created=${{ github.event.head_commit.timestamp }}
-62
View File
@@ -1,62 +0,0 @@
name: CI
on:
push:
branches: ["**"]
pull_request:
branches: ["**"]
jobs:
audit:
name: Security audit
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: "22"
cache: "npm"
- name: Install dependencies
run: npm ci
- name: Run security audit (fail on high+)
run: npm audit --audit-level=high
- name: Check for critical vulnerabilities
run: npm audit --audit-level=critical --json | jq -e '.metadata.vulnerabilities.critical == 0' || (echo "Critical vulnerabilities found!" && exit 1)
continue-on-error: false
test:
name: Tests & coverage
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: "22"
cache: "npm"
- name: Install dependencies
run: npm ci
- name: Run tests with coverage
run: npm run test:coverage
env:
# Required by tokenStore (writable temp dir in CI)
DATA_DIR: /tmp/sofarr-ci-data
# Disable rate limiters so integration tests don't hit 429s
SKIP_RATE_LIMIT: "1"
NODE_ENV: test
- name: Upload coverage report
uses: actions/upload-artifact@v3
if: always()
with:
name: coverage-report
path: coverage/
retention-days: 14
-6
View File
@@ -1,12 +1,6 @@
node_modules/
coverage/
.env
dist/
build/
.DS_Store
*.log
**/*.log
data/
*.db
*.db-wal
*.db-shm
+8 -40
View File
@@ -1,18 +1,4 @@
# ---------------------------------------------------------------------------
# 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
FROM node:18-alpine
LABEL org.opencontainers.image.title="sofarr"
LABEL org.opencontainers.image.description="Personal media download dashboard for *arr services"
@@ -23,36 +9,18 @@ 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 production deps from deps stage
COPY --from=deps /app/node_modules ./node_modules
# Copy package files and install dependencies
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
# Copy application source owned by root (read-only at runtime)
COPY --chown=root:root server/ ./server/
COPY --chown=root:root public/ ./public/
COPY --chown=root:root package.json ./
# Bundled snakeoil certificate for out-of-the-box TLS (self-signed, localhost only).
# Mount your own cert/key over /app/certs/ or set TLS_CERT/TLS_KEY env vars.
COPY --chown=root:root certs/ ./certs/
# Persistent data directory owned by node user (token store, logs)
RUN mkdir -p /app/data && chown node:node /app/data
ENV NODE_ENV=production
ENV DATA_DIR=/app/data
# Drop to non-root user for all subsequent operations
USER node
# Copy application source
COPY server/ ./server/
COPY public/ ./public/
EXPOSE 3001
# HEALTHCHECK — Docker will restart the container if this fails 3 times.
# Respects TLS_ENABLED at runtime: uses https (with --no-check-certificate
# to handle self-signed/snakeoil certs) when TLS is on, plain http when off.
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD /bin/sh -c '[ "${TLS_ENABLED:-true}" = "false" ] && wget -qO- http://localhost:${PORT:-3001}/health || wget -qO- --no-check-certificate https://localhost:${PORT:-3001}/health'
ENV NODE_ENV=production
CMD ["node", "server/index.js"]
+13 -46
View File
@@ -51,7 +51,7 @@ SONARR_INSTANCES=[{"name":"main","url":"...","apiKey":"..."}]
## Prerequisites
- **Docker** (recommended), or Node.js (v22+) for manual installation
- **Docker** (recommended), or Node.js (v12+) for manual installation
- At least one of: SABnzbd or qBittorrent
- Sonarr (optional, for TV tracking)
- Radarr (optional, for movie tracking)
@@ -108,7 +108,6 @@ docker run -d \
-e SABNZBD_INSTANCES='[{"name":"main","url":"http://sabnzbd:8080","apiKey":"your-key"}]' \
-e QBITTORRENT_INSTANCES='[{"name":"main","url":"http://qbit:8080","username":"admin","password":"pass"}]' \
-e LOG_LEVEL=info \
-e POLL_INTERVAL=5000 \
docker.i3omb.com/sofarr:latest
```
@@ -131,7 +130,6 @@ services:
- SABNZBD_INSTANCES=[{"name":"main","url":"http://sabnzbd:8080","apiKey":"your-key"}]
- QBITTORRENT_INSTANCES=[{"name":"main","url":"http://qbit:8080","username":"admin","password":"pass"}]
- LOG_LEVEL=info
- POLL_INTERVAL=5000
```
> **Tip:** You can also use a combination — mount a `.env` file for base config, and override specific values with `-e` flags. Environment variables always take precedence.
@@ -141,8 +139,8 @@ services:
| Tag | Description |
|-----|-------------|
| `latest` | Latest stable release |
| `1.0` | Latest patch for the 1.0.x release line |
| `1.0.0` | Specific version |
| `0.1` | Latest patch for the 0.1.x release line |
| `0.1.0` | Specific version |
### Updating
@@ -183,8 +181,6 @@ Open `http://localhost:3001` in your browser
```bash
PORT=3001 # Server port
LOG_LEVEL=info # Logging: debug, info, warn, error, silent
POLL_INTERVAL=5000 # Background polling interval in ms (default: 5000)
# Set to 0 or "off" to disable (on-demand mode)
```
### Service Instances (JSON Array Format)
@@ -235,22 +231,9 @@ To see your downloads, you need to tag your media in Sonarr/Radarr:
## Features in Detail
### Background Polling
sofarr polls all configured services in the background and caches the results. Dashboard requests read from cache, making them near-instant regardless of how many services you have.
| Setting | Behaviour |
|---------|----------|
| `POLL_INTERVAL=5000` (default) | Background poller fetches every 5s. Dashboard reads from cache instantly. |
| `POLL_INTERVAL=10000` | Poll every 10 seconds. |
| `POLL_INTERVAL=0` / `off` / `disabled` | No background polling. Data fetched on-demand when a user opens the dashboard, then cached for 30 seconds. |
**On-demand mode** is useful for low-resource setups. When one user's browser opens the SSE stream it triggers a fresh poll; the fetched data is cached and served to all other connected clients until the next poll.
### Real-Time Updates
- **Server-Sent Events (SSE)** — the server pushes updates to the browser immediately after each poll cycle. No client-side timer; no wasted requests.
- Auto-refresh every 5 seconds (configurable: 1s, 5s, 10s, or off)
- In-place DOM updates for smooth UI (no flickering)
- Browser reconnects automatically on network interruption
### Download Information Displayed
- **Progress bar** with visual completion percentage
@@ -263,28 +246,23 @@ sofarr polls all configured services in the background and caches the results. D
### For qBittorrent Downloads
- **Seeds** - Number of seeders
- **Peers** - Number of peers
- **Availability** - Percentage available in swarm (shown in red when below 100%)
- **Availability** - Percentage available in swarm
## API Endpoints
### Authentication
- `POST /api/auth/login` Login with Emby credentials
- `POST /api/auth/logout` Logout and revoke session
- `GET /api/auth/me` — Check current session
- `GET /api/csrf` — Fetch a CSRF token
- `POST /api/auth/login` - Login with Emby credentials
- `POST /api/auth/logout` - Logout and clear session
### Dashboard
- `GET /api/dashboard/stream`**SSE stream**: pushes `{ user, isAdmin, downloads }` on every poll cycle
- `GET /api/dashboard/user-downloads` — Single-request download fetch (no streaming)
- `GET /api/dashboard/user-summary` — Per-user download counts (admin)
- `GET /api/dashboard/status` — Server / polling / cache status (admin)
- `GET /api/dashboard/cover-art` — Proxied cover art image
- `GET /api/dashboard/downloads` - Get all downloads for authenticated user
### Service APIs (proxy to your services)
- `GET /api/sabnzbd/*` SABnzbd API proxy
- `GET /api/sonarr/*` — Sonarr API proxy
- `GET /api/radarr/*` — Radarr API proxy
- `GET /api/emby/*` — Emby API proxy
- `GET /api/sabnzbd/*` - SABnzbd API proxy
- `GET /api/qbittorrent/*` - qBittorrent API proxy
- `GET /api/sonarr/*` - Sonarr API proxy
- `GET /api/radarr/*` - Radarr API proxy
- `GET /api/emby/*` - Emby API proxy
## Logging Levels
@@ -314,17 +292,6 @@ 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
-154
View File
@@ -1,154 +0,0 @@
# Security Policy & Hardening Guide
## Supported Versions
| Version | Supported |
|---------|-----------|
| 1.0.x | ✅ Yes |
| 0.2.x | ❌ No |
| 0.1.x | ❌ No |
## Reporting a Vulnerability
Please **do not** open a public issue for security vulnerabilities.
Email: gordon@i3omb.com — expect acknowledgement within 48 hours.
---
## Threat Model
sofarr is a personal dashboard intended for a small trusted group (household/team).
It proxies requests to *arr stack services using stored API keys and authenticates
users via Emby. The primary threat surface when exposed to the public internet:
| Threat | Mitigations |
|--------|-------------|
| Credential brute-force | Rate limiting (10 fails/15 min per IP), account lockout window |
| Session hijacking | HMAC-signed cookies, `httpOnly`, `secure`, `sameSite=strict`, short TTL |
| CSRF | Double-submit cookie pattern (`X-CSRF-Token` header required on all mutations) |
| API key leakage via errors | `sanitizeError()` redacts keys/tokens from all error responses and logs |
| Token theft after logout | Server-side token store; Emby token revoked on logout |
| XSS → token theft | `httpOnly` cookies; CSP with per-request nonce blocks inline injection |
| Clickjacking | `X-Frame-Options: DENY` + CSP `frame-ancestors 'none'` |
| Info disclosure via headers | Helmet v7 removes `X-Powered-By`, sets `noSniff`, `xssFilter`, etc. |
| Privilege escalation (container) | Non-root user (UID 1000), `no-new-privileges`, all caps dropped |
| Unbounded log growth | Size-based rotation: 10 MB cap, 3 rotated files kept |
| Dependency vulnerabilities | `npm audit --audit-level=high` in CI on every push |
---
## Production Deployment Checklist
### Required
- [ ] `COOKIE_SECRET` set to a random 32-byte hex string (`openssl rand -hex 32`)
- [ ] `NODE_ENV=production`
- [ ] `TRUST_PROXY=1` set if behind a reverse proxy
- [ ] sofarr bound to `127.0.0.1` only (not `0.0.0.0`) — expose via proxy
- [ ] HTTPS enforced by the reverse proxy with a valid certificate
- [ ] Firewall rules: only 443/80 open externally; 3001 not directly exposed
### Recommended
- [ ] Reverse proxy: Nginx, Caddy, or Traefik with TLS termination
- [ ] Set `Strict-Transport-Security` at proxy level (sofarr also sends HSTS)
- [ ] `DATA_DIR` on a named Docker volume (not bind-mounted to sensitive host path)
- [ ] Rotate `COOKIE_SECRET` periodically (causes all users to re-login)
- [ ] Enable Docker's `--read-only` flag (already in `docker-compose.yaml`)
- [ ] Monitor `/health` endpoint with an uptime checker
### Docker Secrets (alternative to env vars)
For production environments that support Docker secrets, you can mount secret
files and reference them:
```yaml
secrets:
cookie_secret:
file: ./secrets/cookie_secret.txt
emby_api_key:
file: ./secrets/emby_api_key.txt
services:
sofarr:
secrets:
- cookie_secret
- emby_api_key
environment:
- COOKIE_SECRET_FILE=/run/secrets/cookie_secret
- EMBY_API_KEY_FILE=/run/secrets/emby_api_key
```
> Note: File-based secret loading requires application code support.
> Currently sofarr reads secrets from environment variables only.
> Mounting secrets as env vars (via `environment:` in compose) is the
> current supported approach.
---
## Reverse Proxy Example (Caddy)
```caddy
sofarr.example.com {
reverse_proxy localhost:3001
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
X-Robots-Tag "noindex, nofollow"
}
}
```
## Reverse Proxy Example (Nginx)
```nginx
server {
listen 443 ssl;
server_name sofarr.example.com;
ssl_certificate /etc/letsencrypt/live/sofarr.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/sofarr.example.com/privkey.pem;
location / {
proxy_pass http://127.0.0.1:3001;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Required for SSE (Server-Sent Events) — disable response buffering
proxy_buffering off;
proxy_cache off;
proxy_read_timeout 3600s;
}
}
```
---
## Security Headers (emitted by sofarr)
| Header | Value |
|--------|-------|
| `Content-Security-Policy` | `default-src 'self'; script-src 'self' 'nonce-…'; style-src 'self' 'nonce-…'; style-src-attr 'unsafe-inline'; img-src 'self' data: blob:; object-src 'none'; frame-ancestors 'none'` |
| `Strict-Transport-Security` | `max-age=31536000; includeSubDomains; preload` (production only) |
| `X-Content-Type-Options` | `nosniff` |
| `X-Frame-Options` | `DENY` |
| `Referrer-Policy` | `strict-origin-when-cross-origin` |
| `Permissions-Policy` | `camera=(), microphone=(), geolocation=(), payment=(), usb=()` |
---
## Rate Limits
| Endpoint | Limit |
|----------|-------|
| `POST /api/auth/login` | 10 failed attempts per 15 min per IP |
| All `/api/*` routes | 300 requests per 15 min per IP |
---
## Supply Chain
- All dependencies pinned to minor version ranges in `package.json`
- `npm audit --audit-level=high` runs in CI on every push and pull request
- `npm audit fix` should be run when vulnerabilities are reported
-6
View File
@@ -1,6 +0,0 @@
# Ignore all cert/key files EXCEPT the bundled snakeoil development defaults.
# Never commit real TLS certificates or private keys to version control.
*
!.gitignore
!snakeoil.crt
!snakeoil.key
-22
View File
@@ -1,22 +0,0 @@
-----BEGIN CERTIFICATE-----
MIIDoTCCAomgAwIBAgIUPgupZzf+zgMp4ylvf1NBRIWNpeUwDQYJKoZIhvcNAQEL
BQAwUjELMAkGA1UEBhMCR0IxDjAMBgNVBAgMBUxvY2FsMQ4wDAYDVQQHDAVMb2Nh
bDEPMA0GA1UECgwGc29mYXJyMRIwEAYDVQQDDAlsb2NhbGhvc3QwHhcNMjYwNTE3
MDk0NDQ4WhcNMzYwNTE0MDk0NDQ4WjBSMQswCQYDVQQGEwJHQjEOMAwGA1UECAwF
TG9jYWwxDjAMBgNVBAcMBUxvY2FsMQ8wDQYDVQQKDAZzb2ZhcnIxEjAQBgNVBAMM
CWxvY2FsaG9zdDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAL5Y05DF
9U3k27SGByJqdbKThRRabX0hsK0zckkbXoaj0U4odZatUicAqkm6yWzpqQyZM6qH
XX68LXqJk8r2VIdTTtKe5QU1WUAsW6KD0c514YC1VZ3a9ivQ2/tfdQsm8YxM/ECq
e2XFklfvE72HkHF4fM9LCMS/LeazazF0ogNTkyE27g9Ry/ofR2P4MymLtyBbQ1NA
B1zYRIhJ5HqFRMszBKhWi1zRgUQQNBuYA5wtxnXA9QNSz6ObtdWStfJ/C1Kuitpe
OVrB/TKCp1OKBpZTd4mBKlvdJWos9eZPzP4vLnsReT4pHBx6J2Jd1dC97/MAXLYP
mIXP+04zK5sWRUsCAwEAAaNvMG0wHQYDVR0OBBYEFCpYKb3zMgv4qThe8ukFrVIl
lhD3MB8GA1UdIwQYMBaAFCpYKb3zMgv4qThe8ukFrVIllhD3MA8GA1UdEwEB/wQF
MAMBAf8wGgYDVR0RBBMwEYcEfwAAAYIJbG9jYWxob3N0MA0GCSqGSIb3DQEBCwUA
A4IBAQBQ5Jg0pn4XW/560laNl7XAoGuMwrIy5j6zDlIgFE8DWAb0NGNyu/FkCVIZ
ruLNktq+w+kTeneAuYW3CGyac2HZo9T60VtQNL/k17hrDw1F6kMpAAYm6aiyFtE9
Stw07PMvpvjxKvetPLOQsfk0/hh0Nh2PiVVdqvVE/gGoraboHEfH+eGf/Arzg7s4
CzZTEz0OJP5i7VkZAvFygShPx/gHY77ojeHPl2LN3KI7s43TrjYZjxbUDwWDpGp0
BIsDFP+9NAvA5I74biChfEEopmBczVbQRtqBxBX1JTa2aT5w/ifUNyKVKIGp49Q8
o59gDmbCXhypom7OsyxBLZgyVWU1
-----END CERTIFICATE-----
-28
View File
@@ -1,28 +0,0 @@
-----BEGIN PRIVATE KEY-----
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC+WNOQxfVN5Nu0
hgcianWyk4UUWm19IbCtM3JJG16Go9FOKHWWrVInAKpJusls6akMmTOqh11+vC16
iZPK9lSHU07SnuUFNVlALFuig9HOdeGAtVWd2vYr0Nv7X3ULJvGMTPxAqntlxZJX
7xO9h5BxeHzPSwjEvy3ms2sxdKIDU5MhNu4PUcv6H0dj+DMpi7cgW0NTQAdc2ESI
SeR6hUTLMwSoVotc0YFEEDQbmAOcLcZ1wPUDUs+jm7XVkrXyfwtSroraXjlawf0y
gqdTigaWU3eJgSpb3SVqLPXmT8z+Ly57EXk+KRwceidiXdXQve/zAFy2D5iFz/tO
MyubFkVLAgMBAAECggEAEk0RCyNCJBSdqSKWLtFq8o0rdBsDpugyOymuOQHOjOxu
oQo6NnWaNF5kgQVAyfd0EpGFfavyw2iNVaUPo1zoU9ogwuOWSnHOj64UIcp0+PN6
VHknohWqdukBLyiGfnJM/0ieaKTbi2i7dGsfDA+rxbUoO6s2GYPC6O9inlUqVJGU
fBXKTbCneW1+hJQFcOKPW2+qwiUxbudG9neNTYFOm9a7Bfa6MLJ22mkfYVrHYDzo
gESo8sHXbEtvemka9jN0N/GoXgUi7iFXbqslPUoakCF7sgG0cXuUM9duSt67ynLj
j7Ix+QiKAZgvSO/83b7vK74Xmd6XFUif41VMZ2iEGQKBgQDqp/ZSCAakarRpBRN4
psKuhaYiBKurb5GezTOSfEGWxmng2s0bHcx74alKKNN8Tu2mNx6yafQdRNQdM+fG
dn8JnQUGXYpGwQbLHZ/aO0M64WBxfQku4JNGh+VZ3ZyUC+So28vzCYqx8qwJi94L
2sF8787hzHO1INzVaeXzQ89pEwKBgQDPqRzsJsP+zZmA4x16CtoWY7Z1d4z0GTkA
erlXNinu76AIvra6aUld/65ymMyNIIpLZoci55ZGxBY7g8U29e10iT+xmxAgq3VT
Tn438MbDXEgA+HvdRXCFPvNQQk0ZDegazdhTdtuhj+IHcm4M94zfVM3MSJOr0hJf
JGaVIGEx6QKBgDZLftckPEU221+haQv1qf4vtm0Qn5gfTJZt7Izsa1CzwDPi7Kpl
jrbrU/xwzd5pdNuMzXGCypUrI9lN9Ucai/JxfoQmiKQubZ/5zs7z/25UT7hysflC
xVEAiLTubhhjWBkqIlqtzoW2HNBoqIwdpb9+zWO5ptw2KmLHCgnrmsY5AoGBAKOt
YCaix4lm9L8qRGmVdCCBp6ce+/LKjqtaEAw1nQe/yBwcdlqn8jQs+4tH9LKoG1kj
DxDsCP7uP7fZPPD9FpTsOU/8MNIPUwK+s63UElaZvgdF1BusR+w+mfmAyNQeqfu2
k/P1k1fc2QOVpjiCRn8hkLSb4AlmIyTqxBB23SVBAoGATMtuk1r+SS9SDJW73CI1
jLkJkPGrBvX0dFZGtedpwLVhUTDnqKIsy2F1iGDRGIAy5IAiLEFx44qQrhUau7zR
/2avY0Ua9To9LW0k/matzJUKvXxYm59jh/GDp6LFU89hsL2zSd6z0Aknvh1SryCb
OSbN8wfCz53+7qea4NQEB4E=
-----END PRIVATE KEY-----
+2 -44
View File
@@ -1,59 +1,17 @@
version: "3"
services:
sofarr:
image: docker.i3omb.com/sofarr:latest
container_name: sofarr
restart: unless-stopped
ports:
# Direct HTTPS (default — uses bundled snakeoil cert if TLS_CERT not set)
- "3001:3001"
# Uncomment the line below and comment out the above to bind to loopback
# only when using a reverse proxy (set TLS_ENABLED=false in that case):
# - "127.0.0.1:3001:3001"
environment:
- PORT=3001
- NODE_ENV=production
- LOG_LEVEL=info
# --- TLS ---
# Default: TLS enabled using bundled snakeoil cert (self-signed).
# Supply your own cert/key by mounting them and setting these paths:
# - TLS_CERT=/app/certs/server.crt
# - TLS_KEY=/app/certs/server.key
# Set TLS_ENABLED=false if terminating TLS at a reverse proxy instead.
# If using a reverse proxy, also set TRUST_PROXY=1 below.
# - TRUST_PROXY=1
# --- 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"}]
volumes:
# Persistent volume for token store and log file
- sofarr-data:/app/data
# Mount your own TLS certificate and key (optional — snakeoil used if omitted)
# - /path/to/your/server.crt:/app/certs/server.crt:ro
# - /path/to/your/server.key:/app/certs/server.key:ro
# Run as the built-in non-root 'node' user (UID/GID 1000)
user: "1000:1000"
# Read-only root filesystem; only the data volume is writable
read_only: true
tmpfs:
- /tmp # Node.js needs a writable /tmp
security_opt:
- no-new-privileges:true # prevent privilege escalation via setuid binaries
cap_drop:
- ALL # drop all Linux capabilities
cap_add: [] # add back none — Node.js needs no special caps
healthcheck:
# Respects TLS_ENABLED: uses http when set to false, https otherwise.
# --no-check-certificate handles self-signed / snakeoil certs.
test: ["CMD", "/bin/sh", "-c", "[ \"${TLS_ENABLED:-true}\" = \"false\" ] && wget -qO- http://localhost:${PORT:-3001}/health || wget -qO- --no-check-certificate https://localhost:${PORT:-3001}/health"]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s
volumes:
sofarr-data:
- LOG_LEVEL=info
-1471
View File
File diff suppressed because it is too large Load Diff
Binary file not shown.

Before

Width:  |  Height:  |  Size: 331 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 304 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 473 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 297 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 247 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 161 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 206 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 131 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 139 KiB

+1301 -1995
View File
File diff suppressed because it is too large Load Diff
+9 -20
View File
@@ -1,35 +1,24 @@
{
"name": "sofarr",
"version": "1.1.2",
"version": "0.1.2",
"description": "A personal media download dashboard that shows your downloads 'so far' while you relax on the sofa waiting for your *arr services to finish",
"main": "server/index.js",
"scripts": {
"dev": "nodemon server/index.js",
"start": "node server/index.js",
"install:all": "npm install",
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage",
"test:ui": "vitest --ui",
"audit": "npm audit --audit-level=high",
"audit:fix": "npm audit fix",
"audit:critical": "npm audit --audit-level=critical"
"install:all": "npm install"
},
"dependencies": {
"axios": "^1.6.0",
"cookie-parser": "^1.4.6",
"dotenv": "^16.3.1",
"express": "^4.18.2",
"express-rate-limit": "^7.0.0",
"helmet": "^7.0.0"
"cors": "^2.8.5",
"dotenv": "^16.3.1",
"axios": "^1.6.0",
"node-cron": "^3.0.3",
"cookie-parser": "^1.4.6"
},
"devDependencies": {
"@vitest/coverage-v8": "^4.1.6",
"concurrently": "^7.6.0",
"nock": "^14.0.15",
"nodemon": "^3.1.14",
"supertest": "^7.2.2",
"vitest": "^4.1.6"
"nodemon": "^2.0.22",
"concurrently": "^7.6.0"
},
"keywords": [
"sabnzbd",
+69 -593
View File
@@ -1,19 +1,9 @@
let currentUser = null;
let downloads = [];
let refreshInterval = null;
let currentRefreshRate = 5000; // default 5 seconds
let isAdmin = false;
let showAll = false;
let csrfToken = null; // double-submit CSRF token, sent as X-CSRF-Token on mutating requests
const SPLASH_MIN_MS = 1200; // minimum splash display time
// History section state
let historyDays = parseInt(localStorage.getItem('sofarr-history-days'), 10) || 7;
let historyRefreshHandle = null;
const HISTORY_REFRESH_MS = 5 * 60 * 1000; // auto-refresh history every 5 min
// SSE stream state
let sseSource = null;
let sseReconnectTimer = null;
const SSE_RECONNECT_MS = 3000; // browser already retries, but we add explicit backoff too
// Apply saved theme immediately (before DOMContentLoaded to avoid flash)
(function() {
@@ -25,13 +15,11 @@ const SSE_RECONNECT_MS = 3000; // browser already retries, but we add explicit b
document.addEventListener('DOMContentLoaded', () => {
checkAuthentication();
initThemeSwitcher();
initTabs();
initHistoryControls();
document.getElementById('login-form').addEventListener('submit', handleLogin);
document.getElementById('logout-btn').addEventListener('click', handleLogout);
document.getElementById('refresh-rate').addEventListener('change', handleRefreshRateChange);
document.getElementById('show-all-toggle').addEventListener('change', handleShowAllToggle);
document.getElementById('status-btn').addEventListener('click', toggleStatusPanel);
});
function initThemeSwitcher() {
@@ -50,147 +38,47 @@ function setTheme(theme) {
});
}
function initTabs() {
const savedTab = localStorage.getItem('sofarr-active-tab') || 'downloads';
activateTab(savedTab, false);
document.querySelectorAll('.tab-btn').forEach(btn => {
btn.addEventListener('click', () => {
const tab = btn.dataset.tab;
activateTab(tab, true);
});
});
}
function activateTab(tabName, save) {
document.querySelectorAll('.tab-btn').forEach(btn => {
btn.classList.toggle('active', btn.dataset.tab === tabName);
});
document.querySelectorAll('.tab-panel').forEach(panel => {
panel.style.display = panel.id === `tab-${tabName}` ? '' : 'none';
});
if (save) localStorage.setItem('sofarr-active-tab', tabName);
// Load history the first time the history tab is shown
if (tabName === 'history') loadHistory();
}
// --- SSE connection management ---
function startSSE() {
stopSSE();
const params = showAll ? '?showAll=true' : '';
const source = new EventSource('/api/dashboard/stream' + params);
sseSource = source;
let firstMessage = true;
source.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
currentUser = data.user;
isAdmin = !!data.isAdmin;
downloads = data.downloads;
document.getElementById('currentUser').textContent = currentUser || '-';
renderDownloads();
hideError();
if (firstMessage) { firstMessage = false; hideLoading(); }
} catch (err) {
console.error('[SSE] Failed to parse message:', err);
}
};
source.onerror = () => {
// EventSource retries automatically; we just log and show a reconnecting indicator
console.warn('[SSE] Connection lost, browser will retry...');
};
console.log('[SSE] Stream connected');
}
function stopSSE() {
if (sseReconnectTimer) { clearTimeout(sseReconnectTimer); sseReconnectTimer = null; }
if (sseSource) {
sseSource.close();
sseSource = null;
console.log('[SSE] Stream closed');
function startAutoRefresh() {
if (refreshInterval) clearInterval(refreshInterval);
if (currentRefreshRate > 0) {
refreshInterval = setInterval(() => fetchUserDownloads(false), currentRefreshRate);
}
}
function handleRefreshRateChange(e) {
const rate = parseInt(e.target.value);
currentRefreshRate = rate;
startAutoRefresh();
}
function handleShowAllToggle(e) {
showAll = e.target.checked;
// Re-open stream with updated showAll param
startSSE();
// Reload history with updated showAll param
loadHistory();
fetchUserDownloads(true);
}
function fadeOutLogin() {
return new Promise(resolve => {
const login = document.getElementById('login-container');
login.classList.add('fade-out');
login.addEventListener('transitionend', () => {
login.style.display = 'none';
login.classList.remove('fade-out');
resolve();
}, { once: true });
});
}
function showSplash() {
const splash = document.getElementById('splash-screen');
splash.style.display = 'flex';
splash.style.opacity = '1';
splash.classList.remove('fade-out');
}
function dismissSplash(startTime) {
return new Promise(resolve => {
const elapsed = Date.now() - (startTime || 0);
const remaining = Math.max(0, SPLASH_MIN_MS - elapsed);
setTimeout(() => {
const splash = document.getElementById('splash-screen');
splash.classList.add('fade-out');
// Fallback: resolve after transition duration + buffer in case
// transitionend never fires (e.g. display was toggled in same frame)
const TRANSITION_MS = 400;
const fallback = setTimeout(() => {
splash.style.display = 'none';
resolve();
}, TRANSITION_MS + 100);
splash.addEventListener('transitionend', () => {
clearTimeout(fallback);
splash.style.display = 'none';
resolve();
}, { once: true });
}, remaining);
});
function stopAutoRefresh() {
if (refreshInterval) {
clearInterval(refreshInterval);
refreshInterval = null;
}
}
async function checkAuthentication() {
const splashStart = Date.now();
try {
// 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;
const response = await fetch('/api/auth/me');
const data = await response.json();
if (data.authenticated) {
currentUser = data.user;
isAdmin = !!data.user.isAdmin;
showDashboard();
showLoading();
startSSE();
await dismissSplash(splashStart);
fetchUserDownloads(true);
startAutoRefresh();
} else {
await dismissSplash(splashStart);
showLogin();
}
} catch (err) {
console.error('Authentication check failed:', err);
await dismissSplash(splashStart);
showLogin();
}
}
@@ -200,7 +88,6 @@ async function handleLogin(e) {
const username = document.getElementById('username').value;
const password = document.getElementById('password').value;
const rememberMe = document.getElementById('remember-me').checked;
try {
const response = await fetch('/api/auth/login', {
@@ -208,7 +95,7 @@ async function handleLogin(e) {
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ username, password, rememberMe })
body: JSON.stringify({ username, password })
});
const data = await response.json();
@@ -216,20 +103,9 @@ 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 opening SSE stream.
// requestAnimationFrame ensures the browser paints the splash at
// opacity:1 before dismissSplash adds fade-out, so the CSS
// transition fires and transitionend is guaranteed.
await fadeOutLogin();
showSplash();
await new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)));
showDashboard();
showLoading();
const splashStart = Date.now();
startSSE();
await dismissSplash(splashStart);
fetchUserDownloads(true);
startAutoRefresh();
} else {
showLoginError(data.error || 'Login failed');
}
@@ -241,17 +117,12 @@ async function handleLogin(e) {
async function handleLogout() {
try {
stopSSE();
stopHistoryRefresh();
if (statusRefreshHandle) { clearInterval(statusRefreshHandle); statusRefreshHandle = null; }
stopAutoRefresh();
await fetch('/api/auth/logout', {
method: 'POST',
headers: csrfToken ? { 'X-CSRF-Token': csrfToken } : {}
method: 'POST'
});
currentUser = null;
csrfToken = null;
downloads = [];
clearHistory();
showLogin();
} catch (err) {
console.error('Logout failed:', err);
@@ -268,15 +139,7 @@ function showDashboard() {
document.getElementById('login-container').style.display = 'none';
document.getElementById('dashboard-container').style.display = 'block';
document.getElementById('currentUser').textContent = currentUser.name || '-';
// Always start with status panel hidden (guards against stale display value on re-login)
const sp = document.getElementById('status-panel');
sp.style.display = 'none';
sp.innerHTML = '';
document.getElementById('admin-controls').style.display = isAdmin ? 'flex' : 'none';
// Initialise days input from saved value
const daysInput = document.getElementById('history-days');
if (daysInput) daysInput.value = historyDays;
startHistoryRefresh();
}
function showLoginError(message) {
@@ -290,33 +153,38 @@ function hideLoginError() {
errorDiv.style.display = 'none';
}
// Build an episode-info element for series downloads/history.
// Single episode: "S01E05 — Episode Title"
// Multiple episodes: "Multiple episodes" with tooltip listing them all.
// Returns null if no episode data.
function formatEpisodeInfo(episodes) {
if (!episodes || episodes.length === 0) return null;
const el = document.createElement('p');
el.className = 'episode-info';
if (episodes.length === 1) {
const ep = episodes[0];
const code = 'S' + String(ep.season).padStart(2, '0') + 'E' + String(ep.episode).padStart(2, '0');
el.textContent = ep.title ? code + ' \u2014 ' + ep.title : code;
} else {
el.textContent = 'Multiple episodes';
el.classList.add('multi-episode');
const lines = episodes.map(ep => {
const code = 'S' + String(ep.season).padStart(2, '0') + 'E' + String(ep.episode).padStart(2, '0');
return ep.title ? code + ' \u2014 ' + ep.title : code;
});
el.setAttribute('data-tooltip', lines.join('\n'));
async function fetchUserDownloads(isInitialLoad = false) {
if (isInitialLoad) {
showLoading();
}
hideError();
try {
const url = showAll ? '/api/dashboard/user-downloads?showAll=true' : '/api/dashboard/user-downloads';
const response = await fetch(url);
const data = await response.json();
currentUser = data.user;
isAdmin = !!data.isAdmin;
downloads = data.downloads;
// Debug: log first download to see what fields are present
if (downloads.length > 0) {
console.log('[Dashboard] Download data:', JSON.stringify(downloads[0]));
}
document.getElementById('currentUser').textContent = currentUser || '-';
renderDownloads();
} catch (err) {
showError('Failed to fetch downloads. Make sure all services are configured.');
console.error(err);
} finally {
if (isInitialLoad) {
hideLoading();
}
}
return el;
}
// fetchUserDownloads is kept for the showAll toggle re-connection case
// but the primary data path is now via SSE (startSSE / EventSource).
function renderDownloads() {
const downloadsList = document.getElementById('downloads-list');
const noDownloads = document.getElementById('no-downloads');
@@ -422,10 +290,9 @@ function updateDownloadCard(card, download) {
peersEl.textContent = download.peers;
}
const availabilityItem = card.querySelector('.detail-item[data-label="Availability"]');
if (availabilityItem && download.availability !== undefined) {
availabilityItem.querySelector('.detail-value').textContent = `${download.availability}%`;
availabilityItem.classList.toggle('availability-warning', parseFloat(download.availability) < 100);
const availabilityEl = card.querySelector('.detail-item[data-label="Availability"] .detail-value');
if (availabilityEl && download.availability !== undefined) {
availabilityEl.textContent = `${download.availability}%`;
}
}
}
@@ -440,11 +307,7 @@ function createDownloadCard(download) {
const coverDiv = document.createElement('div');
coverDiv.className = 'download-cover';
const coverImg = document.createElement('img');
// 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.src = download.coverArt;
coverImg.alt = download.movieName || download.seriesName || download.title;
coverImg.loading = 'lazy';
coverDiv.appendChild(coverImg);
@@ -477,14 +340,6 @@ function createDownloadCard(download) {
header.appendChild(type);
header.appendChild(status);
if (download.importIssues && download.importIssues.length > 0) {
const issueBadge = document.createElement('span');
issueBadge.className = 'import-issue-badge';
issueBadge.textContent = 'Import Pending';
issueBadge.setAttribute('data-tooltip', download.importIssues.join('\n'));
header.appendChild(issueBadge);
}
const title = document.createElement('h3');
title.className = 'download-title';
@@ -496,51 +351,22 @@ function createDownloadCard(download) {
if (download.seriesName) {
const series = document.createElement('p');
series.className = 'download-series';
if (isAdmin && download.arrLink) {
series.innerHTML = 'Series: <a href="' + escapeHtml(download.arrLink) + '" target="_blank" class="arr-link">' + escapeHtml(download.seriesName) + '</a>';
} else {
series.textContent = `Series: ${download.seriesName}`;
}
series.textContent = `Series: ${download.seriesName}`;
infoDiv.appendChild(series);
const epEl = formatEpisodeInfo(download.episodes);
if (epEl) infoDiv.appendChild(epEl);
}
if (download.movieName) {
const movie = document.createElement('p');
movie.className = 'download-movie';
if (isAdmin && download.arrLink) {
movie.innerHTML = 'Movie: <a href="' + escapeHtml(download.arrLink) + '" target="_blank" class="arr-link">' + escapeHtml(download.movieName) + '</a>';
} else {
movie.textContent = `Movie: ${download.movieName}`;
}
movie.textContent = `Movie: ${download.movieName}`;
infoDiv.appendChild(movie);
}
if (showAll && download.tagBadges && download.tagBadges.length > 0) {
// In showAll mode: render all tags classified by whether they match an Emby user.
// Unmatched (no known Emby user) → amber, leftmost.
// Matched → show Emby display name in accent colour, rightmost.
const unmatched = download.tagBadges.filter(b => !b.matchedUser);
const matched = download.tagBadges.filter(b => b.matchedUser);
for (const b of unmatched) {
const badge = document.createElement('span');
badge.className = 'download-user-badge unmatched';
badge.textContent = b.label;
header.appendChild(badge);
}
for (const b of matched) {
const badge = document.createElement('span');
badge.className = 'download-user-badge';
badge.textContent = b.matchedUser;
header.appendChild(badge);
}
} else if (download.matchedUserTag) {
// Normal (non-showAll) view: show only the current user's matched tag
const matchedBadge = document.createElement('span');
matchedBadge.className = 'download-user-badge';
matchedBadge.textContent = download.matchedUserTag;
header.appendChild(matchedBadge);
if (showAll && download.userTag) {
const userBadge = document.createElement('span');
userBadge.className = 'download-user-badge';
userBadge.textContent = download.userTag;
header.appendChild(userBadge);
}
const details = document.createElement('div');
@@ -624,7 +450,6 @@ function createDownloadCard(download) {
if (download.availability !== undefined) {
const availability = createDetailItem('Availability', `${download.availability}%`);
if (parseFloat(download.availability) < 100) availability.classList.add('availability-warning');
details.appendChild(availability);
}
}
@@ -633,24 +458,6 @@ function createDownloadCard(download) {
const completed = createDetailItem('Completed', formatDate(download.completedAt));
details.appendChild(completed);
}
if (isAdmin && (download.downloadPath || download.targetPath)) {
const pathsDiv = document.createElement('div');
pathsDiv.className = 'download-paths';
if (download.downloadPath) {
const dlPath = document.createElement('div');
dlPath.className = 'path-item';
dlPath.innerHTML = '<span class="path-label">Download:</span> <span class="path-value">' + escapeHtml(download.downloadPath) + '</span>';
pathsDiv.appendChild(dlPath);
}
if (download.targetPath) {
const tgtPath = document.createElement('div');
tgtPath.className = 'path-item';
tgtPath.innerHTML = '<span class="path-label">Target:</span> <span class="path-value">' + escapeHtml(download.targetPath) + '</span>';
pathsDiv.appendChild(tgtPath);
}
details.appendChild(pathsDiv);
}
infoDiv.appendChild(details);
card.appendChild(infoDiv);
@@ -677,141 +484,6 @@ function createDetailItem(label, value) {
return item;
}
function escapeHtml(str) {
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
let statusRefreshHandle = null;
const STATUS_REFRESH_MS = 5000;
async function toggleStatusPanel() {
const panel = document.getElementById('status-panel');
if (panel.style.display !== 'none') {
panel.style.display = 'none';
if (statusRefreshHandle) { clearInterval(statusRefreshHandle); statusRefreshHandle = null; }
return;
}
panel.style.display = 'block';
await refreshStatusPanel();
if (statusRefreshHandle) clearInterval(statusRefreshHandle);
statusRefreshHandle = setInterval(refreshStatusPanel, STATUS_REFRESH_MS);
}
function closeStatusPanel() {
document.getElementById('status-panel').style.display = 'none';
if (statusRefreshHandle) { clearInterval(statusRefreshHandle); statusRefreshHandle = null; }
}
async function refreshStatusPanel() {
const panel = document.getElementById('status-panel');
if (!panel || panel.style.display === 'none') return;
try {
const res = await fetch('/api/dashboard/status');
if (!res.ok) throw new Error('Failed to fetch status');
const data = await res.json();
renderStatusPanel(data, panel);
} catch (err) {
// Don't overwrite panel on transient error during auto-refresh
if (!panel.innerHTML || panel.innerHTML.includes('status-loading')) {
panel.innerHTML = '<p class="status-error">Failed to load status.</p>';
}
}
}
function renderStatusPanel(data, panel) {
const s = data.server;
const hrs = Math.floor(s.uptimeSeconds / 3600);
const mins = Math.floor((s.uptimeSeconds % 3600) / 60);
const secs = s.uptimeSeconds % 60;
const uptime = `${hrs}h ${mins}m ${secs}s`;
const totalKB = (data.cache.totalSizeBytes / 1024).toFixed(1);
let html = `
<div class="status-header">
<h3>Server Status</h3>
<button class="status-close" onclick="closeStatusPanel()">&times;</button>
</div>
<div class="status-grid">
<div class="status-card">
<div class="status-card-title">Server</div>
<div class="status-row"><span>Uptime</span><span>${uptime}</span></div>
<div class="status-row"><span>Node</span><span>${escapeHtml(s.nodeVersion)}</span></div>
<div class="status-row"><span>Memory (RSS)</span><span>${s.memoryUsageMB} MB</span></div>
<div class="status-row"><span>Heap</span><span>${s.heapUsedMB} / ${s.heapTotalMB} MB</span></div>
</div>
<div class="status-card">
<div class="status-card-title">Data Refresh</div>`;
const pollIntervalMs = data.polling.intervalMs;
const clients = data.clients || [];
const sseClients = clients.filter(c => c.type === 'sse');
if (data.polling.enabled) {
html += `<div class="status-row"><span>Background poll</span><span>${pollIntervalMs / 1000}s</span></div>`;
} else {
html += `<div class="status-row"><span>Background poll</span><span>Disabled</span></div>`;
}
const mode = sseClients.length > 0
? `<span class="status-fg-badge">SSE push</span>`
: (data.polling.enabled ? 'Background' : 'On-demand (idle)');
html += `<div class="status-row"><span>Delivery mode</span><span>${mode}</span></div>`;
html += `<div class="status-row"><span>SSE clients</span><span>${sseClients.length}</span></div>`;
for (const c of sseClients) {
const age = Math.round((Date.now() - c.connectedAt) / 1000);
html += `<div class="status-row status-row-sub"><span>${escapeHtml(c.user)}</span><span>connected ${age}s ago</span></div>`;
}
html += `</div>`;
// Poll timings card
const lp = data.polling.lastPoll;
if (lp) {
const pollAge = Math.round((Date.now() - new Date(lp.timestamp).getTime()) / 1000);
html += `
<div class="status-card status-card-wide">
<div class="status-card-title">Last Poll (${lp.totalMs}ms total, ${pollAge}s ago)</div>
<div class="status-timings">`;
const maxTaskMs = lp.tasks.reduce((max, t) => Math.max(max, t.ms), 1);
for (const t of lp.tasks) {
const barWidth = Math.max(2, (t.ms / maxTaskMs) * 100);
html += `
<div class="timing-row">
<span class="timing-label">${escapeHtml(t.label)}</span>
<div class="timing-bar-bg"><div class="timing-bar" data-w="${barWidth.toFixed(1)}"></div></div>
<span class="timing-value">${t.ms}ms</span>
</div>`;
}
html += `</div></div>`;
}
// Cache table
html += `
<div class="status-card status-card-wide">
<div class="status-card-title">Cache (${data.cache.entryCount} entries, ${totalKB} KB)</div>
<table class="status-table">
<thead><tr><th>Key</th><th>Items</th><th>Size</th><th>TTL</th></tr></thead>
<tbody>`;
for (const e of data.cache.entries) {
const sizeStr = e.sizeBytes > 1024 ? (e.sizeBytes / 1024).toFixed(1) + ' KB' : e.sizeBytes + ' B';
const ttlStr = e.expired ? '<span class="status-expired">expired</span>' : (e.ttlRemainingMs / 1000).toFixed(0) + 's';
const items = e.itemCount !== null ? e.itemCount : '—';
html += `<tr><td><code>${escapeHtml(e.key)}</code></td><td>${items}</td><td>${sizeStr}</td><td>${ttlStr}</td></tr>`;
}
html += `</tbody></table></div></div>`;
panel.innerHTML = html;
// Set bar widths via JS DOM assignment — immune to CSP style-src restrictions
panel.querySelectorAll('.timing-bar[data-w]').forEach(el => {
el.style.width = el.dataset.w + '%';
});
}
function formatSize(size) {
if (!size) return 'N/A';
// If already a formatted string (e.g., "21.5 GB"), return as-is
@@ -849,199 +521,3 @@ function hideLoading() {
const loading = document.getElementById('loading');
loading.style.display = 'none';
}
// =============================================================================
// History section
// =============================================================================
function initHistoryControls() {
const daysInput = document.getElementById('history-days');
const refreshBtn = document.getElementById('history-refresh-btn');
if (daysInput) {
daysInput.addEventListener('change', () => {
const v = parseInt(daysInput.value, 10);
if (v > 0 && v <= 90) {
historyDays = v;
localStorage.setItem('sofarr-history-days', v);
loadHistory();
}
});
}
if (refreshBtn) {
refreshBtn.addEventListener('click', () => loadHistory(true));
}
}
function startHistoryRefresh() {
stopHistoryRefresh();
historyRefreshHandle = setInterval(() => loadHistory(), HISTORY_REFRESH_MS);
}
function stopHistoryRefresh() {
if (historyRefreshHandle) {
clearInterval(historyRefreshHandle);
historyRefreshHandle = null;
}
}
function clearHistory() {
document.getElementById('history-list').innerHTML = '';
document.getElementById('no-history').style.display = 'none';
document.getElementById('history-error').style.display = 'none';
}
async function loadHistory(forceRefresh = false) {
const listEl = document.getElementById('history-list');
const loadingEl = document.getElementById('history-loading');
const errorEl = document.getElementById('history-error');
const noHistoryEl = document.getElementById('no-history');
loadingEl.style.display = 'block';
errorEl.style.display = 'none';
noHistoryEl.style.display = 'none';
try {
const params = new URLSearchParams({ days: historyDays });
if (showAll) params.set('showAll', 'true');
if (forceRefresh) params.set('_t', Date.now());
const res = await fetch(`/api/history/recent?${params}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
loadingEl.style.display = 'none';
renderHistory(data.history || []);
} catch (err) {
loadingEl.style.display = 'none';
errorEl.textContent = 'Failed to load history.';
errorEl.style.display = 'block';
console.error('[History] Load error:', err);
}
}
function renderHistory(items) {
const listEl = document.getElementById('history-list');
const noHistoryEl = document.getElementById('no-history');
listEl.innerHTML = '';
if (!items.length) {
noHistoryEl.style.display = 'block';
return;
}
noHistoryEl.style.display = 'none';
items.forEach(item => listEl.appendChild(createHistoryCard(item)));
}
function createHistoryCard(item) {
const card = document.createElement('div');
card.className = `history-card ${item.type} ${item.outcome}`;
if (item.coverArt) {
const coverDiv = document.createElement('div');
coverDiv.className = 'history-cover';
const img = document.createElement('img');
img.src = '/api/dashboard/cover-art?url=' + encodeURIComponent(item.coverArt);
img.alt = item.movieName || item.seriesName || item.title;
img.loading = 'lazy';
coverDiv.appendChild(img);
card.appendChild(coverDiv);
}
const info = document.createElement('div');
info.className = 'history-info';
// Header row: type badge + outcome badge
const header = document.createElement('div');
header.className = 'history-card-header';
const typeBadge = document.createElement('span');
typeBadge.className = `history-type-badge ${item.type}`;
typeBadge.textContent = item.type === 'series' ? '📺 Series' : '🎬 Movie';
header.appendChild(typeBadge);
const outcomeBadge = document.createElement('span');
outcomeBadge.className = `history-outcome-badge ${item.outcome}`;
outcomeBadge.textContent = item.outcome === 'imported' ? '✓ Imported' : '✗ Failed';
header.appendChild(outcomeBadge);
if (item.instanceName) {
const instBadge = document.createElement('span');
instBadge.className = 'history-instance-badge';
instBadge.textContent = item.instanceName;
header.appendChild(instBadge);
}
if (showAll && item.tagBadges && item.tagBadges.length > 0) {
const unmatched = item.tagBadges.filter(b => !b.matchedUser);
const matched = item.tagBadges.filter(b => b.matchedUser);
for (const b of unmatched) {
const badge = document.createElement('span');
badge.className = 'download-user-badge unmatched';
badge.textContent = b.label;
header.appendChild(badge);
}
for (const b of matched) {
const badge = document.createElement('span');
badge.className = 'download-user-badge';
badge.textContent = b.matchedUser;
header.appendChild(badge);
}
} else if (item.matchedUserTag) {
const badge = document.createElement('span');
badge.className = 'download-user-badge';
badge.textContent = item.matchedUserTag;
header.appendChild(badge);
}
info.appendChild(header);
// Title
const title = document.createElement('h3');
title.className = 'history-title';
title.textContent = item.title;
info.appendChild(title);
// Series/movie name with optional arr link
if (item.seriesName) {
const p = document.createElement('p');
p.className = 'history-media-name';
if (isAdmin && item.arrLink) {
p.innerHTML = 'Series: <a href="' + escapeHtml(item.arrLink) + '" target="_blank" class="arr-link">' + escapeHtml(item.seriesName) + '</a>';
} else {
p.textContent = 'Series: ' + item.seriesName;
}
info.appendChild(p);
const epEl = formatEpisodeInfo(item.episodes);
if (epEl) info.appendChild(epEl);
}
if (item.movieName) {
const p = document.createElement('p');
p.className = 'history-media-name';
if (isAdmin && item.arrLink) {
p.innerHTML = 'Movie: <a href="' + escapeHtml(item.arrLink) + '" target="_blank" class="arr-link">' + escapeHtml(item.movieName) + '</a>';
} else {
p.textContent = 'Movie: ' + item.movieName;
}
info.appendChild(p);
}
// Detail pills
const details = document.createElement('div');
details.className = 'history-details';
if (item.completedAt) {
details.appendChild(createDetailItem('Completed', formatDate(item.completedAt)));
}
if (item.quality) {
details.appendChild(createDetailItem('Quality', item.quality));
}
// Failed imports: show failure message
if (item.outcome === 'failed' && item.failureMessage) {
const failItem = document.createElement('div');
failItem.className = 'history-failure-message';
failItem.textContent = item.failureMessage;
details.appendChild(failItem);
}
info.appendChild(details);
card.appendChild(info);
return card;
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 543 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

+16 -53
View File
@@ -4,24 +4,14 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>sofarr - Your Downloads Dashboard</title>
<link rel="icon" type="image/x-icon" href="favicon.ico">
<link rel="icon" type="image/png" sizes="32x32" href="favicon-32.png">
<link rel="apple-touch-icon" sizes="192x192" href="favicon-192.png">
<meta name="theme-color" content="#1a1a2e">
<link rel="stylesheet" href="style.css">
</head>
<body>
<!-- Splash Screen -->
<div id="splash-screen" class="splash-screen">
<img src="images/sofarr-flashscreen.png" alt="sofarr" class="splash-logo">
</div>
<div class="app">
<!-- Login Form -->
<div id="login-container" class="login-container" style="display: none;">
<div class="login-box">
<img src="images/sofarr-flashscreen.png" alt="sofarr" class="login-logo">
<p class="login-subtitle">Login with your Emby credentials</p>
<h2>Login to Emby</h2>
<form id="login-form">
<div class="form-group">
<label for="username">Username:</label>
@@ -31,12 +21,6 @@
<label for="password">Password:</label>
<input type="password" id="password" name="password" required>
</div>
<div class="form-group form-group--checkbox">
<label class="checkbox-label">
<input type="checkbox" id="remember-me" name="rememberMe">
<span>Keep me logged in</span>
</label>
</div>
<button type="submit" class="login-btn">Login</button>
</form>
<div id="login-error" class="error-message" style="display: none;"></div>
@@ -53,12 +37,20 @@
<button class="theme-btn" data-theme="dark">Dark</button>
<button class="theme-btn" data-theme="mono">Mono</button>
</div>
<div class="refresh-control">
<label for="refresh-rate">Refresh:</label>
<select id="refresh-rate">
<option value="1000">1s</option>
<option value="5000" selected>5s</option>
<option value="10000">10s</option>
<option value="0">Off</option>
</select>
</div>
<div id="admin-controls" class="admin-controls" style="display: none;">
<label class="toggle-label">
<input type="checkbox" id="show-all-toggle">
<span>Show all users</span>
</label>
<button id="status-btn" class="status-btn">Status</button>
</div>
<div class="user-info">
<span class="user-label">Current User:</span>
@@ -68,46 +60,17 @@
</div>
</header>
<div id="status-panel" class="status-panel" style="display: none;"></div>
<div id="error-message" class="error-message" style="display: none;"></div>
<div id="loading" class="loading" style="display: none;">Loading downloads...</div>
<div class="main-tabs">
<div class="tab-bar">
<button class="tab-btn active" data-tab="downloads">Active Downloads</button>
<button class="tab-btn" data-tab="history">Recently Completed</button>
</div>
<div class="tab-panel" id="tab-downloads">
<div class="downloads-container">
<div id="no-downloads" class="no-downloads" style="display: none;">
<p>No downloads found for your user.</p>
<p>Make sure your shows and movies are tagged with your username in Sonarr/Radarr.</p>
</div>
<div id="downloads-list" class="downloads-list"></div>
</div>
</div>
<div class="tab-panel" id="tab-history" style="display: none;">
<div class="history-container" id="history-container">
<div class="history-header">
<div class="history-controls">
<label class="history-days-label" for="history-days">Last</label>
<input type="number" id="history-days" class="history-days-input" value="7" min="1" max="90">
<span class="history-days-label">days</span>
<button id="history-refresh-btn" class="history-refresh-btn" title="Refresh history">&#8635;</button>
</div>
</div>
<div id="history-loading" class="history-loading" style="display: none;">Loading history...</div>
<div id="history-error" class="history-error" style="display: none;"></div>
<div id="no-history" class="no-history" style="display: none;">
<p>No completed downloads found in this period.</p>
</div>
<div id="history-list" class="history-list"></div>
</div>
<div class="downloads-container">
<h2>Your Downloads</h2>
<div id="no-downloads" class="no-downloads" style="display: none;">
<p>No downloads found for your user.</p>
<p>Make sure your shows and movies are tagged with your username in Sonarr/Radarr.</p>
</div>
<div id="downloads-list" class="downloads-list"></div>
</div>
<footer class="app-footer">
+46 -808
View File
@@ -1,98 +1,38 @@
/* ===== Splash Screen ===== */
.splash-screen {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background: #f8f9fa;
z-index: 9999;
opacity: 1;
transition: opacity 0.4s ease-out;
}
.splash-screen.fade-out {
opacity: 0;
}
.splash-logo {
max-width: 280px;
width: 60%;
animation: splashPulse 1.8s ease-in-out infinite;
}
@keyframes splashPulse {
0%, 100% { transform: scale(1); opacity: 1; }
50% { transform: scale(1.03); opacity: 0.85; }
}
/* ===== Theme Variables ===== */
:root, [data-theme="light"] {
/* Page background — clean off-white matching logo backdrop */
--bg-gradient-start: #e8eef3;
--bg-gradient-end: #d4dee8;
/* Surfaces */
--bg-gradient-start: #667eea;
--bg-gradient-end: #764ba2;
--surface: #ffffff;
--surface-alt: #f0f4f7;
/* Typography — charcoal from logo, all meet WCAG AA on white */
--text-primary: #2b2f33; /* ~14:1 on white */
--text-secondary: #4d5760; /* ~7.5:1 on white */
--text-muted: #6b7784; /* ~4.6:1 on white — AA compliant */
/* Borders */
--border: #c8d3db;
/* Accent — primary teal from couch outline */
--accent: #1f7d94; /* ~4.6:1 on white — AA compliant */
--accent-hover: #165f70; /* darker for hover */
--accent-light: #e0f0f4; /* very light teal tint for backgrounds */
/* Series — steel blue from sofa body */
--series-color: #1e6b8a; /* ~5.0:1 on white — AA */
--series-bg: #dceef5;
/* Movie — warm coral (complementary to teal, accessible) */
--movie-color: #b5451b; /* ~5.5:1 on white — AA */
--movie-bg: #fdeee8;
/* Torrent — mid teal-green */
--torrent-color: #1a7a6e; /* ~4.7:1 on white — AA */
--torrent-bg: #ddf2ef;
/* State colours */
--success: #2e7d32; /* ~7.1:1 on white — AA */
--surface-alt: #f5f5f5;
--text-primary: #333333;
--text-secondary: #666666;
--text-muted: #999999;
--border: #e0e0e0;
--accent: #667eea;
--accent-hover: #5568d3;
--accent-light: #e8eaf6;
--series-color: #667eea;
--series-bg: #e8eaf6;
--movie-color: #e040a0;
--movie-bg: #fce4ec;
--torrent-color: #26a69a;
--torrent-bg: #e0f2f1;
--success: #4caf50;
--success-bg: #e8f5e9;
--info: #1565c0; /* ~7.3:1 on white — AA */
--info-bg: #e3f0fb;
--danger: #c62828; /* ~6.5:1 on white — AA */
--danger-bg: #fdecea;
--danger-border: #f5c6c2;
/* Progress bar */
--progress-bg: #eaf2f5;
--progress-border: #c8d3db;
--progress-fill-start: #1f7d94;
--progress-fill-end: #2da0bc;
/* Shadows */
--shadow: rgba(30, 60, 80, 0.10);
--shadow-strong: rgba(30, 60, 80, 0.18);
/* Footer — dark text on light page background */
--footer-text: #4d5760;
/* Inputs */
--info: #2196f3;
--info-bg: #e3f2fd;
--danger: #f44336;
--danger-bg: #ffebee;
--danger-border: #ffcdd2;
--progress-bg: #ffebee;
--progress-border: #ffcdd2;
--progress-fill-start: #4caf50;
--progress-fill-end: #66bb6a;
--shadow: rgba(0, 0, 0, 0.1);
--shadow-strong: rgba(0, 0, 0, 0.15);
--footer-text: rgba(255, 255, 255, 0.9);
--input-bg: #ffffff;
--select-bg: #ffffff;
/* Unmatched tag — amber, accessible on its bg */
--unmatched-tag-bg: #fff3e0;
--unmatched-tag-color: #7a4000; /* ~7.1:1 on #fff3e0 — AA */
}
[data-theme="dark"] {
@@ -129,8 +69,6 @@
--footer-text: rgba(200, 200, 220, 0.8);
--input-bg: #2a2a3d;
--select-bg: #2a2a3d;
--unmatched-tag-bg: #3d2a00;
--unmatched-tag-color: #ffb74d;
}
[data-theme="mono"] {
@@ -167,8 +105,6 @@
--footer-text: rgba(180, 180, 180, 0.7);
--input-bg: #252525;
--select-bg: #252525;
--unmatched-tag-bg: #2a2a2a;
--unmatched-tag-color: #a0a0a0;
}
/* ===== Base ===== */
@@ -411,9 +347,8 @@ body {
.download-header {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
align-items: center;
gap: 4px;
margin-bottom: 4px;
}
@@ -472,9 +407,9 @@ body {
margin-bottom: 2px;
font-size: 0.9rem;
font-weight: 600;
overflow-wrap: break-word;
word-break: break-word;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.download-series,
@@ -485,60 +420,21 @@ body {
font-size: 0.8rem;
}
.episode-info {
color: var(--text-secondary);
font-size: 0.78rem;
margin: -2px 0 6px;
}
.episode-info.multi-episode {
cursor: help;
text-decoration: underline dotted;
position: relative;
}
.episode-info.multi-episode:hover::after {
content: attr(data-tooltip);
position: absolute;
left: 0;
top: 100%;
z-index: 20;
background: var(--surface);
color: var(--text-primary);
border: 1px solid var(--border);
border-radius: 6px;
padding: 8px 10px;
font-size: 0.75rem;
white-space: pre-line;
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
max-width: 320px;
pointer-events: none;
}
/* ===== Detail Row (Inline) ===== */
.download-details {
display: flex;
flex-wrap: wrap;
gap: 4px 6px;
gap: 4px 14px;
padding-top: 6px;
border-top: 1px solid var(--border);
align-items: center;
}
.detail-item {
display: inline-flex;
display: flex;
align-items: baseline;
gap: 3px;
gap: 4px;
font-size: 0.78rem;
background: var(--bg-secondary, rgba(0,0,0,0.04));
border-radius: 4px;
padding: 2px 6px;
white-space: nowrap;
}
.detail-item.availability-warning .detail-value {
color: var(--danger, #e53e3e);
font-weight: 700;
}
.detail-label {
@@ -557,17 +453,10 @@ body {
/* ===== Progress Bar (Compact) ===== */
.progress-item {
flex-basis: 100%;
display: flex;
align-items: center;
background: none;
padding: 0;
white-space: normal;
border-radius: 0;
}
.progress-container {
display: flex;
flex: 1;
align-items: center;
gap: 8px;
}
@@ -583,15 +472,13 @@ body {
}
.progress-segment {
position: absolute;
top: 0;
left: 0;
height: 100%;
transition: width 0.3s ease;
}
.progress-segment.downloaded {
background: linear-gradient(90deg, var(--progress-fill-start) 0%, var(--progress-fill-end) 100%);
float: left;
}
.progress-text {
@@ -605,255 +492,9 @@ body {
color: var(--danger);
font-size: 0.72rem;
font-weight: 500;
overflow-wrap: break-word;
word-break: break-word;
}
/* ===== Main Tabs ===== */
.main-tabs {
max-width: 1200px;
margin: 16px auto 0;
padding: 0 16px;
}
.tab-bar {
display: flex;
gap: 4px;
border-bottom: 2px solid var(--border);
margin-bottom: 0;
}
.tab-btn {
background: none;
border: none;
border-bottom: 3px solid transparent;
margin-bottom: -2px;
padding: 10px 20px;
font-size: 0.95rem;
font-weight: 600;
color: var(--text-secondary);
cursor: pointer;
transition: color 0.2s, border-color 0.2s;
border-radius: 4px 4px 0 0;
}
.tab-btn:hover {
color: var(--text-primary);
background: var(--surface);
}
.tab-btn.active {
color: var(--accent);
border-bottom-color: var(--accent);
background: var(--surface);
}
.tab-panel {
padding-top: 0;
}
/* ===== Recently Completed History ===== */
.history-container {
max-width: unset;
margin: 0;
padding: 0;
}
.history-header {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 12px;
flex-wrap: wrap;
}
.history-header h2 {
margin: 0;
font-size: 1.2rem;
color: var(--text-primary);
flex: 1 1 auto;
}
.history-controls {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: nowrap;
}
.history-days-label {
font-size: 0.85rem;
color: var(--text-secondary);
}
.history-days-input {
width: 52px;
padding: 3px 6px;
border: 1px solid var(--border);
border-radius: 4px;
background: var(--surface);
color: var(--text-primary);
font-size: 0.85rem;
text-align: center;
}
.history-refresh-btn {
background: none;
border: 1px solid var(--border);
border-radius: 4px;
color: var(--text-secondary);
cursor: pointer;
font-size: 1rem;
padding: 2px 7px;
line-height: 1.4;
transition: background 0.15s, color 0.15s;
}
.history-refresh-btn:hover {
background: var(--hover-bg);
color: var(--text-primary);
}
.history-loading,
.history-error,
.no-history {
color: var(--text-secondary);
font-size: 0.9rem;
padding: 8px 0;
}
.history-error {
color: var(--error, #e74c3c);
}
.history-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.history-card {
display: flex;
gap: 12px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 8px;
padding: 10px 12px;
transition: background 0.2s;
align-items: flex-start;
}
.history-card.failed {
border-left: 3px solid var(--error, #e74c3c);
}
.history-card.imported {
border-left: 3px solid var(--success, #27ae60);
}
.history-cover {
flex: 0 0 48px;
width: 48px;
}
.history-cover img {
width: 48px;
height: 68px;
object-fit: cover;
border-radius: 4px;
display: block;
}
.history-info {
flex: 1 1 auto;
min-width: 0;
}
.history-card-header {
display: flex;
flex-wrap: wrap;
gap: 5px;
align-items: center;
margin-bottom: 4px;
}
.history-type-badge,
.history-outcome-badge,
.history-instance-badge {
font-size: 0.72rem;
font-weight: 600;
padding: 2px 7px;
border-radius: 10px;
white-space: nowrap;
}
.history-type-badge.series {
background: var(--badge-series-bg, #2980b9);
color: #fff;
}
.history-type-badge.movie {
background: var(--badge-movie-bg, #8e44ad);
color: #fff;
}
.history-outcome-badge.imported {
background: var(--success, #27ae60);
color: #fff;
}
.history-outcome-badge.failed {
background: var(--error, #e74c3c);
color: #fff;
}
.history-instance-badge {
background: var(--tag-bg, #ecf0f1);
color: var(--text-secondary);
border: 1px solid var(--border);
}
.history-title {
font-size: 0.9rem;
font-weight: 600;
margin: 0 0 2px;
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.history-media-name {
font-size: 0.82rem;
color: var(--text-secondary);
margin: 0 0 4px;
}
.history-details {
display: flex;
flex-wrap: wrap;
gap: 5px;
margin-top: 4px;
}
.history-failure-message {
font-size: 0.78rem;
color: var(--error, #e74c3c);
background: var(--error-bg, rgba(231, 76, 60, 0.08));
border-radius: 4px;
padding: 3px 7px;
flex-basis: 100%;
}
@media (max-width: 480px) {
.history-cover {
display: none;
}
.history-title {
white-space: normal;
}
}
/* ===== Footer ===== */
.app-footer {
margin-top: 12px;
@@ -873,12 +514,6 @@ body {
align-items: center;
min-height: 100vh;
padding: 20px;
opacity: 1;
transition: opacity 0.3s ease-out;
}
.login-container.fade-out {
opacity: 0;
}
.login-box {
@@ -889,24 +524,17 @@ body {
width: 100%;
max-width: 380px;
transition: background 0.3s;
text-align: center;
}
.login-logo {
max-width: 180px;
width: 60%;
margin-bottom: 12px;
}
.login-subtitle {
color: var(--text-secondary);
.login-box h2 {
color: var(--text-primary);
margin-bottom: 24px;
font-size: 0.85rem;
text-align: center;
font-size: 1.5rem;
}
.form-group {
margin-bottom: 16px;
text-align: left;
}
.form-group label {
@@ -932,32 +560,6 @@ body {
border-color: var(--accent);
}
.form-group--checkbox {
margin-bottom: 20px;
}
.checkbox-label {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
font-size: 0.9rem;
color: var(--text-secondary);
user-select: none;
}
.checkbox-label input[type="checkbox"] {
width: 16px;
height: 16px;
accent-color: var(--accent);
cursor: pointer;
flex-shrink: 0;
}
.checkbox-label span {
line-height: 1;
}
.login-btn {
width: 100%;
padding: 10px;
@@ -1000,80 +602,6 @@ body {
accent-color: var(--accent);
}
/* ===== Arr Links (Admin) ===== */
.arr-link {
color: var(--accent);
text-decoration: none;
border-bottom: 1px dotted var(--accent);
}
.arr-link:hover {
opacity: 0.8;
border-bottom-style: solid;
}
/* ===== Download Paths (Admin) ===== */
.download-paths {
flex-basis: 100%;
display: flex;
flex-direction: column;
gap: 2px;
margin-top: 2px;
}
.path-item {
font-size: 0.7rem;
font-family: 'SF Mono', 'Fira Code', 'Fira Mono', Menlo, Consolas, monospace;
color: var(--text-muted);
overflow-wrap: break-word;
word-break: break-all;
overflow: hidden;
}
.path-label {
font-weight: 600;
color: var(--text-secondary);
font-size: 0.65rem;
text-transform: uppercase;
letter-spacing: 0.3px;
}
.path-value {
color: var(--text-muted);
}
/* ===== Import Issue Badge ===== */
.import-issue-badge {
padding: 2px 8px;
border-radius: 10px;
font-size: 0.65rem;
font-weight: 600;
background: #ffebee;
color: #c62828;
cursor: help;
position: relative;
white-space: nowrap;
}
.import-issue-badge:hover::after {
content: attr(data-tooltip);
position: absolute;
top: calc(100% + 6px);
left: 0;
background: #424242;
color: #fff;
padding: 8px 12px;
border-radius: 6px;
font-size: 0.7rem;
font-weight: 400;
white-space: pre-line;
max-width: 320px;
z-index: 100;
box-shadow: 0 2px 8px rgba(0,0,0,0.25);
line-height: 1.4;
pointer-events: none;
}
.download-user-badge {
padding: 2px 8px;
border-radius: 10px;
@@ -1083,256 +611,21 @@ body {
background: var(--accent-light);
color: var(--accent);
margin-left: auto;
white-space: nowrap;
}
.download-user-badge.unmatched {
background: var(--unmatched-tag-bg);
color: var(--unmatched-tag-color);
margin-left: 0;
}
/* ===== Status Button ===== */
.status-btn {
padding: 4px 12px;
border: 1px solid var(--border);
border-radius: 5px;
background: var(--surface);
color: var(--text-secondary);
cursor: pointer;
font-size: 0.8rem;
font-weight: 500;
transition: background 0.2s, color 0.2s;
}
.status-btn:hover {
background: var(--accent);
color: white;
border-color: var(--accent);
}
/* ===== Status Panel ===== */
.status-panel {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 10px;
padding: 20px;
margin-bottom: 16px;
box-shadow: 0 2px 4px var(--shadow);
}
.status-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.status-header h3 {
margin: 0;
color: var(--text-primary);
font-size: 1.1rem;
}
.status-close {
background: none;
border: none;
font-size: 1.4rem;
color: var(--text-secondary);
cursor: pointer;
padding: 0 4px;
line-height: 1;
}
.status-close:hover {
color: var(--text-primary);
}
.status-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
}
.status-card {
background: var(--background);
border: 1px solid var(--border);
border-radius: 8px;
padding: 14px;
}
.status-card-wide {
grid-column: 1 / -1;
}
.status-card-title {
font-weight: 600;
font-size: 0.85rem;
color: var(--text-primary);
margin-bottom: 10px;
padding-bottom: 6px;
border-bottom: 1px solid var(--border);
}
.status-row {
display: flex;
justify-content: space-between;
padding: 3px 0;
font-size: 0.8rem;
color: var(--text-secondary);
}
.status-row span:last-child {
font-weight: 500;
color: var(--text-primary);
}
.status-table {
width: 100%;
border-collapse: collapse;
font-size: 0.78rem;
}
.status-table th {
text-align: left;
padding: 4px 8px;
color: var(--text-secondary);
font-weight: 600;
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.3px;
border-bottom: 1px solid var(--border);
}
.status-table td {
padding: 5px 8px;
color: var(--text-primary);
border-bottom: 1px solid var(--border);
}
.status-table code {
font-size: 0.75rem;
background: var(--surface);
padding: 1px 4px;
border-radius: 3px;
}
.status-expired {
color: #c62828;
font-weight: 600;
font-size: 0.7rem;
}
.status-fg-badge {
background: #fff3e0;
color: #e65100;
padding: 1px 8px;
border-radius: 8px;
font-size: 0.75rem;
font-weight: 600;
}
.status-row-sub {
padding-left: 12px;
font-size: 0.75rem;
opacity: 0.8;
}
.status-row-sub span:first-child {
font-style: italic;
}
.status-timings {
display: flex;
flex-direction: column;
gap: 6px;
}
.timing-row {
display: flex;
align-items: center;
gap: 8px;
font-size: 0.78rem;
}
.timing-label {
width: 110px;
flex-shrink: 0;
color: var(--text-secondary);
white-space: nowrap;
}
.timing-bar-bg {
flex: 1;
height: 8px;
background: var(--border);
border-radius: 4px;
overflow: hidden;
}
.timing-bar {
height: 100%;
background: var(--accent);
border-radius: 4px;
min-width: 2px;
transition: width 0.3s ease;
}
.timing-value {
width: 50px;
flex-shrink: 0;
text-align: right;
color: var(--text-primary);
font-weight: 500;
font-size: 0.75rem;
}
.status-loading, .status-error {
text-align: center;
padding: 20px;
color: var(--text-secondary);
font-size: 0.9rem;
}
.status-error {
color: #c62828;
}
/* ===== Mobile ===== */
@media (max-width: 768px) {
.app {
padding: 10px;
}
.app-header {
flex-direction: column;
align-items: flex-start;
padding: 10px 12px;
gap: 8px;
padding: 12px 14px;
}
.header-controls {
width: 100%;
flex-wrap: wrap;
gap: 8px;
}
.user-info {
width: 100%;
justify-content: space-between;
box-sizing: border-box;
}
.admin-controls {
width: 100%;
flex-wrap: wrap;
gap: 6px;
}
.downloads-container {
padding: 12px;
}
.download-card {
padding: 8px 10px;
}
@@ -1341,67 +634,12 @@ body {
width: 40px;
}
.download-details {
flex-direction: column;
gap: 2px;
}
.progress-container {
flex-wrap: wrap;
}
.status-grid {
grid-template-columns: 1fr;
}
.status-table {
font-size: 0.72rem;
}
.status-table th,
.status-table td {
padding: 4px 4px;
}
.status-table td code {
word-break: break-all;
}
.timing-label {
width: 90px;
}
.timing-value {
width: 40px;
}
.import-issue-badge:hover::after {
left: auto;
right: 0;
max-width: calc(100vw - 24px);
}
}
/* ===== Very small screens (≤ 400px) ===== */
@media (max-width: 400px) {
.app {
padding: 6px;
}
.download-cover {
display: none;
}
.theme-switcher {
flex-shrink: 0;
}
.user-info {
font-size: 0.78rem;
padding: 5px 10px;
}
.download-card {
padding: 8px;
}
.timing-label {
width: 75px;
font-size: 0.7rem;
}
}
-116
View File
@@ -1,116 +0,0 @@
/**
* Express application factory — imported by both server/index.js (production)
* and the test suite. Keeping app creation separate from app.listen() means
* tests can import a fresh instance without starting a real server or
* triggering the side-effects in index.js (log files, process.exit, poller).
*/
const express = require('express');
const cookieParser = require('cookie-parser');
const helmet = require('helmet');
const rateLimit = require('express-rate-limit');
const crypto = require('crypto');
const sabnzbdRoutes = require('./routes/sabnzbd');
const sonarrRoutes = require('./routes/sonarr');
const radarrRoutes = require('./routes/radarr');
const embyRoutes = require('./routes/emby');
const dashboardRoutes = require('./routes/dashboard');
const historyRoutes = require('./routes/history');
const authRoutes = require('./routes/auth');
const verifyCsrf = require('./middleware/verifyCsrf');
function createApp({ skipRateLimits = false } = {}) {
const app = express();
if (process.env.TRUST_PROXY) {
const trustValue = /^\d+$/.test(process.env.TRUST_PROXY)
? parseInt(process.env.TRUST_PROXY, 10)
: process.env.TRUST_PROXY;
app.set('trust proxy', trustValue);
}
// Per-request CSP nonce
app.use((req, res, next) => {
res.locals.cspNonce = crypto.randomBytes(16).toString('base64');
next();
});
app.use((req, res, next) => {
helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'", (req, res) => `'nonce-${res.locals.cspNonce}'`],
styleSrc: ["'self'", (req, res) => `'nonce-${res.locals.cspNonce}'`],
styleSrcAttr: ["'unsafe-inline'"],
imgSrc: ["'self'", 'data:', 'blob:'],
fontSrc: ["'self'", 'data:'],
connectSrc: ["'self'"],
objectSrc: ["'none'"],
baseUri: ["'self'"],
frameAncestors: ["'none'"],
formAction: ["'self'"],
upgradeInsecureRequests: process.env.TRUST_PROXY ? [] : null
}
},
hsts: { maxAge: 31536000, includeSubDomains: true, preload: true },
referrerPolicy: { policy: 'strict-origin-when-cross-origin' },
crossOriginEmbedderPolicy: false
})(req, res, next);
});
app.use((req, res, next) => {
res.setHeader('Permissions-Policy', 'camera=(), microphone=(), geolocation=(), payment=(), usb=()');
next();
});
const apiLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: skipRateLimits ? Number.MAX_SAFE_INTEGER : 300,
standardHeaders: true,
legacyHeaders: false,
message: { error: 'Too many requests, please try again later' }
});
app.use(cookieParser(process.env.COOKIE_SECRET || undefined));
app.use(express.json({ limit: '64kb' }));
// Health / readiness (no auth, no rate-limit)
app.get('/health', (req, res) => {
res.json({ status: 'ok', uptime: process.uptime() });
});
app.get('/ready', (req, res) => {
const ready = !!(process.env.EMBY_URL);
if (ready) {
res.json({ status: 'ready' });
} else {
res.status(503).json({ status: 'not ready', reason: 'EMBY_URL not configured' });
}
});
// API routes
app.use('/api', apiLimiter);
app.use('/api/auth', authRoutes);
// CSRF protection for all state-changing API requests below
app.use('/api', verifyCsrf);
app.use('/api/sabnzbd', sabnzbdRoutes);
app.use('/api/sonarr', sonarrRoutes);
app.use('/api/radarr', radarrRoutes);
app.use('/api/emby', embyRoutes);
app.use('/api/dashboard', dashboardRoutes);
app.use('/api/history', historyRoutes);
// Global error handler
// eslint-disable-next-line no-unused-vars
app.use((err, req, res, next) => {
console.error('[Server] Unhandled error:', err.message);
res.status(500).json({ error: 'Internal server error' });
});
return app;
}
module.exports = { createApp };
+12 -249
View File
@@ -1,43 +1,16 @@
const express = require('express');
const cors = require('cors');
const path = require('path');
const cookieParser = require('cookie-parser');
const helmet = require('helmet');
const rateLimit = require('express-rate-limit');
const crypto = require('crypto');
const fs = require('fs');
const http = require('http');
const https = require('https');
require('dotenv').config();
const { version } = require('../package.json');
// Setup logging with levels
// Levels: debug (0), info (1), warn (2), error (3), silent (4)
const LOG_LEVELS = { debug: 0, info: 1, warn: 2, error: 3, silent: 4 };
const currentLevel = LOG_LEVELS[(process.env.LOG_LEVEL || 'info').toLowerCase()] || 1;
// 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 logFile = fs.createWriteStream(path.join(__dirname, '../server.log'), { flags: 'a' });
const originalConsoleLog = console.log;
const originalConsoleError = console.error;
const originalConsoleWarn = console.warn;
@@ -80,241 +53,31 @@ const sonarrRoutes = require('./routes/sonarr');
const radarrRoutes = require('./routes/radarr');
const embyRoutes = require('./routes/emby');
const dashboardRoutes = require('./routes/dashboard');
const historyRoutes = require('./routes/history');
const authRoutes = require('./routes/auth');
const verifyCsrf = require('./middleware/verifyCsrf');
const { startPoller, POLL_INTERVAL, POLLING_ENABLED } = require('./utils/poller');
// ---------------------------------------------------------------------------
// 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;
// Resolve TLS_ENABLED early — used in Helmet CSP and server startup
const TLS_ENABLED = (process.env.TLS_ENABLED || 'true').toLowerCase() !== 'false';
app.use(cors());
app.use(cookieParser());
app.use(express.json());
app.use(express.static(path.join(__dirname, '../public')));
// ---------------------------------------------------------------------------
// Trust proxy — required when behind Nginx/Caddy/Traefik so that
// req.ip reflects the real client IP (not 127.0.0.1) and
// req.secure is true when the upstream TLS is terminated by the proxy.
// Set TRUST_PROXY=1 (or a specific IP/CIDR) via env.
// ---------------------------------------------------------------------------
if (process.env.TRUST_PROXY) {
const trustValue = /^\d+$/.test(process.env.TRUST_PROXY)
? parseInt(process.env.TRUST_PROXY, 10)
: process.env.TRUST_PROXY;
app.set('trust proxy', trustValue);
}
// ---------------------------------------------------------------------------
// Helmet v7 — security response headers
// CSP uses a per-request nonce injected into index.html so inline scripts
// and styles are allowed only with a valid nonce, not blanket unsafe-inline.
// ---------------------------------------------------------------------------
app.use((req, res, next) => {
// Generate a fresh nonce for every request
res.locals.cspNonce = crypto.randomBytes(16).toString('base64');
next();
});
app.use((req, res, next) => {
helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'", (req, res) => `'nonce-${res.locals.cspNonce}'`],
styleSrc: ["'self'", (req, res) => `'nonce-${res.locals.cspNonce}'`],
imgSrc: ["'self'", 'data:', 'blob:'],
fontSrc: ["'self'", 'data:'],
connectSrc: ["'self'"],
objectSrc: ["'none'"],
baseUri: ["'self'"],
frameAncestors: ["'none'"],
formAction: ["'self'"],
upgradeInsecureRequests: (process.env.TRUST_PROXY || TLS_ENABLED) ? [] : null
}
},
hsts: {
maxAge: 31536000, // 1 year
includeSubDomains: true,
preload: true
},
referrerPolicy: { policy: 'strict-origin-when-cross-origin' },
crossOriginEmbedderPolicy: false // not needed for this SPA
})(req, res, next);
});
// Permissions-Policy — disable powerful browser features not needed by the app
app.use((req, res, next) => {
res.setHeader(
'Permissions-Policy',
'camera=(), microphone=(), geolocation=(), payment=(), usb=()'
);
next();
});
// ---------------------------------------------------------------------------
// General API rate limiter — applies to all /api/* routes
// More specific limiters (e.g. login) apply on top of this.
// ---------------------------------------------------------------------------
const apiLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 300, // 300 requests per IP per window (generous for polling)
standardHeaders: true,
legacyHeaders: false,
message: { error: 'Too many requests, please try again later' }
});
// ---------------------------------------------------------------------------
// Body parsing & cookies
// ---------------------------------------------------------------------------
app.use(cookieParser(cookieSecret || undefined));
app.use(express.json({ limit: '64kb' })); // prevent oversized JSON payloads
// ---------------------------------------------------------------------------
// Health / readiness endpoints (no auth, no rate-limit)
// Used by Docker HEALTHCHECK and orchestrators.
// ---------------------------------------------------------------------------
app.get('/health', (req, res) => {
res.json({ status: 'ok', uptime: process.uptime() });
});
app.get('/ready', (req, res) => {
// Confirm critical config is present
const ready = !!(process.env.EMBY_URL);
if (ready) {
res.json({ status: 'ready' });
} else {
res.status(503).json({ status: 'not ready', reason: 'EMBY_URL not configured' });
}
});
// ---------------------------------------------------------------------------
// Static files — served before API routes
// index.html is served manually so we can inject the CSP nonce
// ---------------------------------------------------------------------------
const PUBLIC_DIR = path.join(__dirname, '../public');
const INDEX_HTML = path.join(PUBLIC_DIR, 'index.html');
// Serve all static assets (js, css, images, icons) except index.html.
// JS and CSS get no-cache so browsers revalidate on every load (ETag still
// avoids re-downloading unchanged files; only a deploy changes the ETag).
app.use(express.static(PUBLIC_DIR, {
index: false,
setHeaders(res, filePath) {
if (filePath.endsWith('.js') || filePath.endsWith('.css')) {
res.setHeader('Cache-Control', 'no-cache');
}
}
}));
// Serve index.html with CSP nonce injected into <script> tags
function serveIndex(req, res) {
fs.readFile(INDEX_HTML, 'utf8', (err, html) => {
if (err) return res.status(500).send('Internal Server Error');
const nonce = res.locals.cspNonce;
// Only inject nonce into <script> tags — style-src 'self' already permits
// same-origin <link rel=stylesheet> without a nonce, and injecting a nonce
// onto <link> breaks mobile browsers / caching proxies (stale HTML carries
// the old nonce which no longer matches the per-request CSP header).
const patched = html
.replace(/<script([^>]*)>/gi, `<script nonce="${nonce}"$1>`);
res.setHeader('Content-Type', 'text/html; charset=utf-8');
res.send(patched);
});
}
// ---------------------------------------------------------------------------
// 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/history', historyRoutes);
app.use('/api/auth', authRoutes);
// 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.get('/', (req, res) => {
res.sendFile(path.join(__dirname, '../public/index.html'));
});
// ---------------------------------------------------------------------------
// TLS / HTTPS support
// Set TLS_CERT and TLS_KEY to paths of your certificate and private key.
// If unset, defaults to the bundled snakeoil self-signed certificate
// (localhost/127.0.0.1 only — suitable for local testing).
// Set TLS_ENABLED=false to force plain HTTP even if cert files exist.
// ---------------------------------------------------------------------------
const CERTS_DIR = path.join(__dirname, '../certs');
const TLS_CERT_PATH = process.env.TLS_CERT || path.join(CERTS_DIR, 'snakeoil.crt');
const TLS_KEY_PATH = process.env.TLS_KEY || path.join(CERTS_DIR, 'snakeoil.key');
function loadTlsCredentials() {
if (!TLS_ENABLED) return null;
try {
return {
cert: fs.readFileSync(TLS_CERT_PATH),
key: fs.readFileSync(TLS_KEY_PATH)
};
} catch (err) {
console.warn(`[TLS] Could not load certificate files — falling back to HTTP. (${err.message})`);
return null;
}
}
const tlsCredentials = loadTlsCredentials();
const server = tlsCredentials
? https.createServer(tlsCredentials, app)
: http.createServer(app);
const protocol = tlsCredentials ? 'https' : 'http';
const isSnakeoil = TLS_ENABLED &&
(!process.env.TLS_CERT || process.env.TLS_CERT === TLS_CERT_PATH);
server.listen(PORT, () => {
app.listen(PORT, () => {
console.log(`=================================`);
console.log(` sofarr v${version} - Your Downloads Dashboard`);
console.log(` Server running on ${protocol}://localhost:${PORT}`);
if (tlsCredentials && isSnakeoil) {
console.warn(` [TLS] Using bundled snakeoil certificate (self-signed).`);
console.warn(` [TLS] Set TLS_CERT and TLS_KEY for a trusted certificate.`);
console.warn(` [TLS] Set TLS_ENABLED=false to disable TLS entirely.`);
} else if (tlsCredentials) {
console.log(` [TLS] Certificate: ${TLS_CERT_PATH}`);
} else {
console.warn(` [TLS] Running in plain HTTP mode — not suitable for production.`);
}
console.log(` sofarr - Your Downloads Dashboard`);
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();
});
-21
View File
@@ -1,21 +0,0 @@
function requireAuth(req, res, next) {
const signed = !!process.env.COOKIE_SECRET;
const raw = signed ? req.signedCookies.emby_user : req.cookies.emby_user;
if (!raw || raw === false) {
return res.status(401).json({ error: 'Not authenticated' });
}
let u;
try {
u = JSON.parse(raw);
} catch {
return res.status(401).json({ error: 'Invalid session' });
}
// Schema validation
if (typeof u.id !== 'string' || !u.id) return res.status(401).json({ error: 'Invalid session' });
if (typeof u.name !== 'string' || !u.name) return res.status(401).json({ error: 'Invalid session' });
if (typeof u.isAdmin !== 'boolean') u.isAdmin = !!u.isAdmin;
req.user = u;
next();
}
module.exports = requireAuth;
-42
View File
@@ -1,42 +0,0 @@
/**
* CSRF protection using the double-submit cookie pattern.
*
* On login the server issues a random `csrf_token` cookie (httpOnly:false
* so JS can read it). The SPA must send the same value in the
* `X-CSRF-Token` request header for every state-changing request (POST,
* PUT, PATCH, DELETE).
*
* Because the `sameSite: strict` session cookie already provides strong
* protection in modern browsers, this acts as defence-in-depth for
* older browsers and any edge cases.
*
* Safe methods (GET, HEAD, OPTIONS) are exempted.
*/
const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
function verifyCsrf(req, res, next) {
if (SAFE_METHODS.has(req.method)) return next();
const cookieToken = req.cookies.csrf_token;
const headerToken = req.headers['x-csrf-token'];
if (!cookieToken || !headerToken) {
return res.status(403).json({ error: 'CSRF token missing' });
}
// Constant-time comparison to prevent timing attacks
if (cookieToken.length !== headerToken.length) {
return res.status(403).json({ error: 'CSRF token invalid' });
}
const a = Buffer.from(cookieToken);
const b = Buffer.from(headerToken);
if (!require('crypto').timingSafeEqual(a, b)) {
return res.status(403).json({ error: 'CSRF token invalid' });
}
next();
}
module.exports = verifyCsrf;
+49 -138
View File
@@ -1,107 +1,60 @@
const express = require('express');
const axios = require('axios');
const crypto = require('crypto');
const rateLimit = require('express-rate-limit');
const router = express.Router();
// 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,
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' }
});
const EMBY_URL = process.env.EMBY_URL;
const EMBY_API_KEY = process.env.EMBY_API_KEY;
// Authenticate user with Emby
router.post('/login', loginLimiter, async (req, res) => {
router.post('/login', 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' });
}
const { username, password } = req.body;
console.log(`[Auth] Attempting login for user: ${username.trim()}`);
console.log(`[Auth] Attempting login for user: ${username}`);
// Authenticate with Emby using a stable DeviceId derived from the username.
// 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.trim().toLowerCase()).digest('hex').slice(0, 16);
const authResponse = await axios.post(`${getEmbyUrl()}/Users/authenticatebyname`, {
Username: username.trim(),
// Authenticate with Emby
const authResponse = await axios.post(`${EMBY_URL}/Users/authenticatebyname`, {
Username: username,
Pw: password
}, {
headers: {
'X-Emby-Authorization': `MediaBrowser Client="sofarr", Device="sofarr", DeviceId="${stableDeviceId}", Version="1.0.0"`
'X-Emby-Authorization': `MediaBrowser Client="MediaDashboard", Device="Browser", DeviceId="dashboard-${Date.now()}", Version="1.0.0"`
}
});
const authData = authResponse.data;
console.log(`[Auth] Emby auth response:`, JSON.stringify(authData));
// Get user info using the access token
const userResponse = await axios.get(`${getEmbyUrl()}/Users/${authData.User.Id || authData.User.id}`, {
const userResponse = await axios.get(`${EMBY_URL}/Users/${authData.User.Id || authData.User.id}`, {
headers: {
'X-MediaBrowser-Token': authData.AccessToken
}
});
const user = userResponse.data;
const isAdmin = !!(user.Policy && user.Policy.IsAdministrator);
console.log(`[Auth] Login successful for user: ${user.Name}, isAdmin: ${isAdmin}`);
// Store token server-side; it is never sent to the client.
storeToken(user.Id, authData.AccessToken);
console.log(`[Auth] User info:`, JSON.stringify(user));
console.log(`[Auth] Login successful for user: ${user.Name}`);
// Set authentication cookie (signed when COOKIE_SECRET is set).
// rememberMe=true → persistent cookie, expires in 30 days
// rememberMe=false → session cookie, expires when browser closes
// secure:true only when TRUST_PROXY is set — i.e. a TLS-terminating reverse
// proxy is in front. Without it the app may be accessed over plain HTTP and
// secure cookies would never be sent back by the browser.
const cookiePayload = JSON.stringify({ id: user.Id, name: user.Name, isAdmin });
const signed = !!process.env.COOKIE_SECRET;
const secureCookie = !!process.env.TRUST_PROXY; // only send over HTTPS when behind a TLS proxy
const cookieOptions = {
// Set authentication cookie
const isAdmin = !!(user.Policy && user.Policy.IsAdministrator);
res.cookie('emby_user', JSON.stringify({
id: user.Id,
name: user.Name,
isAdmin: isAdmin,
token: authData.AccessToken
}), {
httpOnly: true,
secure: secureCookie,
sameSite: 'strict',
signed,
path: '/'
};
if (rememberMe) {
cookieOptions.maxAge = 30 * 24 * 60 * 60 * 1000; // 30 days
}
res.cookie('emby_user', cookiePayload, cookieOptions);
// Issue a CSRF token tied to this session so state-changing endpoints
// can validate the double-submit cookie pattern
const csrfToken = crypto.randomBytes(32).toString('hex');
res.cookie('csrf_token', csrfToken, {
httpOnly: false, // intentionally readable by JS for the double-submit pattern
secure: secureCookie,
sameSite: 'strict',
path: '/'
maxAge: 24 * 60 * 60 * 1000 // 24 hours
});
res.json({
success: true,
user: { id: user.Id, name: user.Name, isAdmin },
csrfToken
user: {
id: user.Id,
name: user.Name,
isAdmin: isAdmin
}
});
} catch (error) {
console.error(`[Auth] Login failed:`, error.message);
@@ -112,75 +65,33 @@ router.post('/login', loginLimiter, async (req, res) => {
}
});
function parseSessionCookie(req) {
const signed = !!process.env.COOKIE_SECRET;
const raw = signed ? req.signedCookies.emby_user : req.cookies.emby_user;
if (!raw || raw === false) return null; // false = tampered signed cookie
try {
const u = JSON.parse(raw);
// Schema validation: require id (string), name (string), isAdmin (boolean)
if (typeof u.id !== 'string' || !u.id) return null;
if (typeof u.name !== 'string' || !u.name) return null;
if (typeof u.isAdmin !== 'boolean') u.isAdmin = !!u.isAdmin;
return u;
} catch {
return null;
}
}
// Get current authenticated user
router.get('/me', (req, res) => {
const user = parseSessionCookie(req);
if (!user) return res.json({ authenticated: false });
res.json({
authenticated: true,
user: { id: user.id, name: user.name, isAdmin: user.isAdmin }
});
});
// CSRF token refresh — lets the SPA get a new token without re-logging-in
// (e.g. after a page reload where the JS variable was lost)
router.get('/csrf', (req, res) => {
const csrfToken = crypto.randomBytes(32).toString('hex');
res.cookie('csrf_token', csrfToken, {
httpOnly: false,
secure: !!process.env.TRUST_PROXY,
sameSite: 'strict',
path: '/'
});
res.json({ csrfToken });
try {
const userCookie = req.cookies.emby_user;
if (!userCookie) {
return res.json({ authenticated: false });
}
const user = JSON.parse(userCookie);
res.json({
authenticated: true,
user: {
id: user.id,
name: user.name,
isAdmin: !!user.isAdmin
}
});
} catch (error) {
console.error(`[Auth] Error getting current user:`, error.message);
res.json({ authenticated: false });
}
});
// Logout
router.post('/logout', async (req, res) => {
const user = parseSessionCookie(req);
if (user) {
const stored = getToken(user.id);
if (stored) {
try {
await axios.post(`${getEmbyUrl()}/Sessions/Logout`, {}, {
headers: { 'X-MediaBrowser-Token': stored.accessToken }
});
console.log(`[Auth] Revoked Emby token for user: ${user.name}`);
} catch (err) {
console.warn(`[Auth] Failed to revoke Emby token for ${user.name}:`, err.message);
}
clearToken(user.id);
}
}
res.clearCookie('emby_user', {
httpOnly: true,
secure: !!process.env.TRUST_PROXY,
sameSite: 'strict',
signed: !!process.env.COOKIE_SECRET,
path: '/'
});
res.clearCookie('csrf_token', {
httpOnly: false,
secure: !!process.env.TRUST_PROXY,
sameSite: 'strict',
path: '/'
});
router.post('/logout', (req, res) => {
res.clearCookie('emby_user');
res.json({ success: true });
});
+293 -697
View File
File diff suppressed because it is too large Load Diff
+16 -17
View File
@@ -1,52 +1,51 @@
const express = require('express');
const axios = require('axios');
const router = express.Router();
const requireAuth = require('../middleware/requireAuth');
const sanitizeError = require('../utils/sanitizeError');
router.use(requireAuth);
const EMBY_URL = process.env.EMBY_URL;
const EMBY_API_KEY = process.env.EMBY_API_KEY;
// Get active sessions
router.get('/sessions', async (req, res) => {
try {
const response = await axios.get(`${process.env.EMBY_URL}/Sessions`, {
headers: { 'X-MediaBrowser-Token': process.env.EMBY_API_KEY }
const response = await axios.get(`${EMBY_URL}/Sessions`, {
headers: { 'X-MediaBrowser-Token': EMBY_API_KEY }
});
res.json(response.data);
} catch (error) {
res.status(500).json({ error: 'Failed to fetch Emby sessions', details: sanitizeError(error) });
res.status(500).json({ error: 'Failed to fetch Emby sessions', details: error.message });
}
});
// Get user by ID
router.get('/users/:id', async (req, res) => {
try {
const response = await axios.get(`${process.env.EMBY_URL}/Users/${req.params.id}`, {
headers: { 'X-MediaBrowser-Token': process.env.EMBY_API_KEY }
const response = await axios.get(`${EMBY_URL}/Users/${req.params.id}`, {
headers: { 'X-MediaBrowser-Token': EMBY_API_KEY }
});
res.json(response.data);
} catch (error) {
res.status(500).json({ error: 'Failed to fetch user details', details: sanitizeError(error) });
res.status(500).json({ error: 'Failed to fetch user details', details: error.message });
}
});
// Get all users
router.get('/users', async (req, res) => {
try {
const response = await axios.get(`${process.env.EMBY_URL}/Users`, {
headers: { 'X-MediaBrowser-Token': process.env.EMBY_API_KEY }
const response = await axios.get(`${EMBY_URL}/Users`, {
headers: { 'X-MediaBrowser-Token': EMBY_API_KEY }
});
res.json(response.data);
} catch (error) {
res.status(500).json({ error: 'Failed to fetch users', details: sanitizeError(error) });
res.status(500).json({ error: 'Failed to fetch users', details: error.message });
}
});
// Get current user by session ID
router.get('/session/:sessionId/user', async (req, res) => {
try {
const response = await axios.get(`${process.env.EMBY_URL}/Sessions`, {
headers: { 'X-MediaBrowser-Token': process.env.EMBY_API_KEY }
const response = await axios.get(`${EMBY_URL}/Sessions`, {
headers: { 'X-MediaBrowser-Token': EMBY_API_KEY }
});
const session = response.data.find(s => s.Id === req.params.sessionId);
@@ -54,13 +53,13 @@ router.get('/session/:sessionId/user', async (req, res) => {
return res.status(404).json({ error: 'Session not found' });
}
const userResponse = await axios.get(`${process.env.EMBY_URL}/Users/${session.UserId}`, {
headers: { 'X-MediaBrowser-Token': process.env.EMBY_API_KEY }
const userResponse = await axios.get(`${EMBY_URL}/Users/${session.UserId}`, {
headers: { 'X-MediaBrowser-Token': EMBY_API_KEY }
});
res.json(userResponse.data);
} catch (error) {
res.status(500).json({ error: 'Failed to fetch user from session', details: sanitizeError(error) });
res.status(500).json({ error: 'Failed to fetch user from session', details: error.message });
}
});
-306
View File
@@ -1,306 +0,0 @@
const express = require('express');
const router = express.Router();
const axios = require('axios');
const requireAuth = require('../middleware/requireAuth');
const cache = require('../utils/cache');
const { fetchSonarrHistory, fetchRadarrHistory, classifySonarrEvent, classifyRadarrEvent } = require('../utils/historyFetcher');
const { getSonarrInstances, getRadarrInstances } = require('../utils/config');
const sanitizeError = require('../utils/sanitizeError');
// Re-use the same tag/cover-art helpers as dashboard.js by importing them
// from a shared location. For now they are inlined here to keep dashboard.js
// untouched (zero-conflict v1 merges). If these diverge they can be extracted
// into server/utils/dashboardHelpers.js in a later refactor.
function getCoverArt(item) {
if (!item || !item.images) return null;
const poster = item.images.find(img => img.coverType === 'poster');
if (poster) return poster.remoteUrl || poster.url || null;
const fanart = item.images.find(img => img.coverType === 'fanart');
return fanart ? (fanart.remoteUrl || fanart.url || null) : null;
}
function sanitizeTagLabel(input) {
if (!input) return '';
return input.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '');
}
function tagMatchesUser(tag, username) {
if (!tag || !username) return false;
const tagLower = tag.toLowerCase();
if (tagLower === username) return true;
if (tagLower === sanitizeTagLabel(username)) return true;
return false;
}
function extractAllTags(tags, tagMap) {
if (!tags || tags.length === 0) return [];
if (tagMap) return tags.map(id => tagMap.get(id)).filter(Boolean);
return tags.map(t => t && t.label).filter(Boolean);
}
function extractUserTag(tags, tagMap, username) {
const allLabels = extractAllTags(tags, tagMap);
if (!allLabels.length) return null;
if (username) {
const match = allLabels.find(label => tagMatchesUser(label, username));
if (match) return match;
}
return null;
}
async function getEmbyUsers() {
const cached = cache.get('emby:users');
if (cached) return cached;
try {
const embyUrl = process.env.EMBY_URL;
const embyKey = process.env.EMBY_API_KEY;
if (!embyUrl || !embyKey) return new Map();
const res = await axios.get(`${embyUrl}/Users`, { params: { api_key: embyKey } });
const users = res.data || [];
const map = new Map();
for (const u of users) {
if (!u.Name) continue;
const lower = u.Name.toLowerCase();
map.set(lower, u.Name);
map.set(sanitizeTagLabel(lower), u.Name);
}
cache.set('emby:users', map, 60000);
return map;
} catch (err) {
console.error('[History] Failed to fetch Emby users:', err.message);
return new Map();
}
}
function buildTagBadges(allTags, embyUserMap) {
return allTags.map(label => {
const lower = label.toLowerCase();
const sanitized = sanitizeTagLabel(label);
const matchedUser = embyUserMap.get(lower) || embyUserMap.get(sanitized) || null;
return { label, matchedUser };
});
}
// Extract episode info from a Sonarr history record.
function extractEpisode(record) {
const ep = record.episode || {};
const s = ep.seasonNumber != null ? ep.seasonNumber : record.seasonNumber;
const e = ep.episodeNumber != null ? ep.episodeNumber : record.episodeNumber;
if (s == null || e == null) return null;
const title = ep.title || null;
return { season: s, episode: e, title };
}
// Find all episodes associated with a download by matching all history records
// that share the same source title. Returns sorted, deduplicated array.
function gatherEpisodes(titleLower, records) {
const episodes = [];
const seen = new Set();
for (const r of records) {
const rTitle = (r.sourceTitle || r.title || '').toLowerCase();
if (rTitle && (rTitle.includes(titleLower) || titleLower.includes(rTitle))) {
const ep = extractEpisode(r);
if (ep) {
const key = `${ep.season}x${ep.episode}`;
if (!seen.has(key)) {
seen.add(key);
episodes.push(ep);
}
}
}
}
episodes.sort((a, b) => a.season - b.season || a.episode - b.episode);
return episodes;
}
function getSonarrLink(series) {
if (!series || !series._instanceUrl || !series.titleSlug) return null;
return `${series._instanceUrl}/series/${series.titleSlug}`;
}
function getRadarrLink(movie) {
if (!movie || !movie._instanceUrl || !movie.titleSlug) return null;
return `${movie._instanceUrl}/movie/${movie.titleSlug}`;
}
/**
* GET /api/history/recent
*
* Returns Sonarr/Radarr history records (imported + failed) for the
* authenticated user, filtered to the last RECENT_COMPLETED_DAYS days
* (default 7, overridable via env or ?days= query param).
*
* Response shape:
* {
* user: string,
* isAdmin: boolean,
* days: number,
* history: HistoryItem[]
* }
*
* HistoryItem shape:
* {
* type: 'series'|'movie',
* outcome: 'imported'|'failed',
* title: string, // sourceTitle from arr record
* seriesName?: string, // series.title (Sonarr)
* movieName?: string, // movie.title (Radarr)
* coverArt: string|null,
* completedAt: string, // ISO date string from arr record
* quality: string|null,
* instanceName: string, // arr instance name
* arrLink: string|null, // link to item in Sonarr/Radarr UI
* allTags: string[],
* matchedUserTag: string|null,
* // admin-only:
* arrRecordId?: number,
* failureMessage?: string,
* }
*/
router.get('/recent', requireAuth, async (req, res) => {
try {
const user = req.user;
const username = user.name.toLowerCase();
const isAdmin = !!user.isAdmin;
const showAll = isAdmin && req.query.showAll === 'true';
const defaultDays = parseInt(process.env.RECENT_COMPLETED_DAYS, 10) || 7;
const requestedDays = parseInt(req.query.days, 10);
const days = (requestedDays > 0 && requestedDays <= 90) ? requestedDays : defaultDays;
const since = new Date(Date.now() - days * 24 * 60 * 60 * 1000);
// Fetch tag maps and history in parallel
const sonarrInstances = getSonarrInstances();
const radarrInstances = getRadarrInstances();
const [sonarrHistory, radarrHistory, embyUserMap] = await Promise.all([
fetchSonarrHistory(since),
fetchRadarrHistory(since),
showAll ? getEmbyUsers() : Promise.resolve(new Map())
]);
// Build tag maps from the cached poll data where available,
// falling back to what's embedded in history records
const sonarrTagsData = cache.get('poll:sonarr-tags') || [];
const radarrTagsData = cache.get('poll:radarr-tags') || [];
const sonarrTagMap = new Map(sonarrTagsData.flatMap(t => t.data || []).map(t => [t.id, t.label]));
const radarrTagMap = new Map(radarrTagsData.map(t => [t.id, t.label]));
const historyItems = [];
// --- Sonarr history ---
for (const record of sonarrHistory) {
try {
const outcome = classifySonarrEvent(record.eventType);
if (outcome === 'other') continue;
const series = record.series;
if (!series) continue;
const allTags = extractAllTags(series.tags, sonarrTagMap);
const matchedUserTag = extractUserTag(series.tags, sonarrTagMap, username);
const hasAnyTag = allTags.length > 0;
if (!(showAll ? hasAnyTag : !!matchedUserTag)) continue;
const quality = record.quality && record.quality.quality && record.quality.quality.name
? record.quality.quality.name
: null;
const sourceTitle = record.sourceTitle || record.title || series.title;
const item = {
type: 'series',
outcome,
title: sourceTitle,
seriesName: series.title,
episodes: gatherEpisodes(sourceTitle.toLowerCase(), sonarrHistory),
coverArt: getCoverArt(series),
completedAt: record.date,
quality,
instanceName: record._instanceName || null,
arrLink: getSonarrLink(series),
allTags,
matchedUserTag: matchedUserTag || null,
tagBadges: showAll ? buildTagBadges(allTags, embyUserMap) : undefined
};
if (isAdmin) {
item.arrRecordId = record.id;
if (outcome === 'failed' && record.data && record.data.message) {
item.failureMessage = record.data.message;
}
}
historyItems.push(item);
} catch (err) {
console.error('[History] Error processing Sonarr record:', err.message);
}
}
// --- Radarr history ---
for (const record of radarrHistory) {
try {
const outcome = classifyRadarrEvent(record.eventType);
if (outcome === 'other') continue;
const movie = record.movie;
if (!movie) continue;
const allTags = extractAllTags(movie.tags, radarrTagMap);
const matchedUserTag = extractUserTag(movie.tags, radarrTagMap, username);
const hasAnyTag = allTags.length > 0;
if (!(showAll ? hasAnyTag : !!matchedUserTag)) continue;
const quality = record.quality && record.quality.quality && record.quality.quality.name
? record.quality.quality.name
: null;
const item = {
type: 'movie',
outcome,
title: record.sourceTitle || record.title || movie.title,
movieName: movie.title,
coverArt: getCoverArt(movie),
completedAt: record.date,
quality,
instanceName: record._instanceName || null,
arrLink: getRadarrLink(movie),
allTags,
matchedUserTag: matchedUserTag || null,
tagBadges: showAll ? buildTagBadges(allTags, embyUserMap) : undefined
};
if (isAdmin) {
item.arrRecordId = record.id;
if (outcome === 'failed' && record.data && record.data.message) {
item.failureMessage = record.data.message;
}
}
historyItems.push(item);
} catch (err) {
console.error('[History] Error processing Radarr record:', err.message);
}
}
// Sort newest first
historyItems.sort((a, b) => new Date(b.completedAt) - new Date(a.completedAt));
console.log(`[History] Returning ${historyItems.length} items for user ${user.name} (days=${days}, showAll=${showAll})`);
res.json({
user: user.name,
isAdmin,
days,
history: historyItems
});
} catch (err) {
console.error('[History] Error:', err.message);
res.status(500).json({ error: 'Failed to fetch history', details: sanitizeError(err) });
}
});
module.exports = router;
+14 -15
View File
@@ -1,57 +1,56 @@
const express = require('express');
const axios = require('axios');
const router = express.Router();
const requireAuth = require('../middleware/requireAuth');
const sanitizeError = require('../utils/sanitizeError');
router.use(requireAuth);
const RADARR_URL = process.env.RADARR_URL;
const RADARR_API_KEY = process.env.RADARR_API_KEY;
// Get queue
router.get('/queue', async (req, res) => {
try {
const response = await axios.get(`${process.env.RADARR_URL}/api/v3/queue`, {
headers: { 'X-Api-Key': process.env.RADARR_API_KEY }
const response = await axios.get(`${RADARR_URL}/api/v3/queue`, {
headers: { 'X-Api-Key': RADARR_API_KEY }
});
res.json(response.data);
} catch (error) {
res.status(500).json({ error: 'Failed to fetch Radarr queue', details: sanitizeError(error) });
res.status(500).json({ error: 'Failed to fetch Radarr queue', details: error.message });
}
});
// Get history
router.get('/history', async (req, res) => {
try {
const response = await axios.get(`${process.env.RADARR_URL}/api/v3/history`, {
headers: { 'X-Api-Key': process.env.RADARR_API_KEY },
const response = await axios.get(`${RADARR_URL}/api/v3/history`, {
headers: { 'X-Api-Key': RADARR_API_KEY },
params: { pageSize: req.query.pageSize || 50 }
});
res.json(response.data);
} catch (error) {
res.status(500).json({ error: 'Failed to fetch Radarr history', details: sanitizeError(error) });
res.status(500).json({ error: 'Failed to fetch Radarr history', details: error.message });
}
});
// Get movie details
router.get('/movies/:id', async (req, res) => {
try {
const response = await axios.get(`${process.env.RADARR_URL}/api/v3/movie/${req.params.id}`, {
headers: { 'X-Api-Key': process.env.RADARR_API_KEY }
const response = await axios.get(`${RADARR_URL}/api/v3/movie/${req.params.id}`, {
headers: { 'X-Api-Key': RADARR_API_KEY }
});
res.json(response.data);
} catch (error) {
res.status(500).json({ error: 'Failed to fetch movie details', details: sanitizeError(error) });
res.status(500).json({ error: 'Failed to fetch movie details', details: error.message });
}
});
// Get all movies with tags
router.get('/movies', async (req, res) => {
try {
const response = await axios.get(`${process.env.RADARR_URL}/api/v3/movie`, {
headers: { 'X-Api-Key': process.env.RADARR_API_KEY }
const response = await axios.get(`${RADARR_URL}/api/v3/movie`, {
headers: { 'X-Api-Key': RADARR_API_KEY }
});
res.json(response.data);
} catch (error) {
res.status(500).json({ error: 'Failed to fetch movies', details: sanitizeError(error) });
res.status(500).json({ error: 'Failed to fetch movies', details: error.message });
}
});
+8 -9
View File
@@ -1,41 +1,40 @@
const express = require('express');
const axios = require('axios');
const router = express.Router();
const requireAuth = require('../middleware/requireAuth');
const sanitizeError = require('../utils/sanitizeError');
router.use(requireAuth);
const SABNZBD_URL = process.env.SABNZBD_URL;
const SABNZBD_API_KEY = process.env.SABNZBD_API_KEY;
// Get current queue
router.get('/queue', async (req, res) => {
try {
const response = await axios.get(`${process.env.SABNZBD_URL}/api`, {
const response = await axios.get(`${SABNZBD_URL}/api`, {
params: {
mode: 'queue',
apikey: process.env.SABNZBD_API_KEY,
apikey: SABNZBD_API_KEY,
output: 'json'
}
});
res.json(response.data);
} catch (error) {
res.status(500).json({ error: 'Failed to fetch SABnzbd queue', details: sanitizeError(error) });
res.status(500).json({ error: 'Failed to fetch SABnzbd queue', details: error.message });
}
});
// Get history
router.get('/history', async (req, res) => {
try {
const response = await axios.get(`${process.env.SABNZBD_URL}/api`, {
const response = await axios.get(`${SABNZBD_URL}/api`, {
params: {
mode: 'history',
apikey: process.env.SABNZBD_API_KEY,
apikey: SABNZBD_API_KEY,
output: 'json',
limit: req.query.limit || 50
}
});
res.json(response.data);
} catch (error) {
res.status(500).json({ error: 'Failed to fetch SABnzbd history', details: sanitizeError(error) });
res.status(500).json({ error: 'Failed to fetch SABnzbd history', details: error.message });
}
});
+14 -15
View File
@@ -1,57 +1,56 @@
const express = require('express');
const axios = require('axios');
const router = express.Router();
const requireAuth = require('../middleware/requireAuth');
const sanitizeError = require('../utils/sanitizeError');
router.use(requireAuth);
const SONARR_URL = process.env.SONARR_URL;
const SONARR_API_KEY = process.env.SONARR_API_KEY;
// Get queue
router.get('/queue', async (req, res) => {
try {
const response = await axios.get(`${process.env.SONARR_URL}/api/v3/queue`, {
headers: { 'X-Api-Key': process.env.SONARR_API_KEY }
const response = await axios.get(`${SONARR_URL}/api/v3/queue`, {
headers: { 'X-Api-Key': SONARR_API_KEY }
});
res.json(response.data);
} catch (error) {
res.status(500).json({ error: 'Failed to fetch Sonarr queue', details: sanitizeError(error) });
res.status(500).json({ error: 'Failed to fetch Sonarr queue', details: error.message });
}
});
// Get history
router.get('/history', async (req, res) => {
try {
const response = await axios.get(`${process.env.SONARR_URL}/api/v3/history`, {
headers: { 'X-Api-Key': process.env.SONARR_API_KEY },
const response = await axios.get(`${SONARR_URL}/api/v3/history`, {
headers: { 'X-Api-Key': SONARR_API_KEY },
params: { pageSize: req.query.pageSize || 50 }
});
res.json(response.data);
} catch (error) {
res.status(500).json({ error: 'Failed to fetch Sonarr history', details: sanitizeError(error) });
res.status(500).json({ error: 'Failed to fetch Sonarr history', details: error.message });
}
});
// Get series details
router.get('/series/:id', async (req, res) => {
try {
const response = await axios.get(`${process.env.SONARR_URL}/api/v3/series/${req.params.id}`, {
headers: { 'X-Api-Key': process.env.SONARR_API_KEY }
const response = await axios.get(`${SONARR_URL}/api/v3/series/${req.params.id}`, {
headers: { 'X-Api-Key': SONARR_API_KEY }
});
res.json(response.data);
} catch (error) {
res.status(500).json({ error: 'Failed to fetch series details', details: sanitizeError(error) });
res.status(500).json({ error: 'Failed to fetch series details', details: error.message });
}
});
// Get all series with tags
router.get('/series', async (req, res) => {
try {
const response = await axios.get(`${process.env.SONARR_URL}/api/v3/series`, {
headers: { 'X-Api-Key': process.env.SONARR_API_KEY }
const response = await axios.get(`${SONARR_URL}/api/v3/series`, {
headers: { 'X-Api-Key': SONARR_API_KEY }
});
res.json(response.data);
} catch (error) {
res.status(500).json({ error: 'Failed to fetch series', details: sanitizeError(error) });
res.status(500).json({ error: 'Failed to fetch series', details: error.message });
}
});
-74
View File
@@ -1,74 +0,0 @@
const { logToFile } = require('./logger');
class MemoryCache {
constructor() {
this.store = new Map();
}
get(key) {
const entry = this.store.get(key);
if (!entry) return null;
if (Date.now() > entry.expiresAt) {
this.store.delete(key);
return null;
}
return entry.value;
}
set(key, value, ttlMs) {
this.store.set(key, {
value,
expiresAt: Date.now() + ttlMs
});
}
invalidate(key) {
this.store.delete(key);
}
clear() {
this.store.clear();
}
getStats() {
const now = Date.now();
const entries = [];
let totalSize = 0;
for (const [key, entry] of this.store.entries()) {
// Maps must be converted before JSON.stringify (which renders them as "{}")
const serializable = entry.value instanceof Map ? Object.fromEntries(entry.value) : entry.value;
const json = JSON.stringify(serializable);
const sizeBytes = Buffer.byteLength(json, 'utf8');
totalSize += sizeBytes;
const ttlRemaining = Math.max(0, entry.expiresAt - now);
const expired = now > entry.expiresAt;
let itemCount = null;
if (entry.value instanceof Map) {
itemCount = entry.value.size;
} else if (Array.isArray(entry.value)) {
itemCount = entry.value.length;
} else if (entry.value && typeof entry.value === 'object') {
if (Array.isArray(entry.value.records)) itemCount = entry.value.records.length;
else if (Array.isArray(entry.value.slots)) itemCount = entry.value.slots.length;
}
entries.push({
key,
sizeBytes,
itemCount,
ttlRemainingMs: ttlRemaining,
expired
});
}
return {
entryCount: this.store.size,
totalSizeBytes: totalSize,
entries
};
}
}
const cache = new MemoryCache();
module.exports = cache;
-142
View File
@@ -1,142 +0,0 @@
const axios = require('axios');
const cache = require('./cache');
const { getSonarrInstances, getRadarrInstances } = require('./config');
// Cache TTL for recent-history data: 5 minutes.
// History changes slowly compared to active downloads.
const HISTORY_CACHE_TTL = 5 * 60 * 1000;
// Sonarr event types that represent a successful import
const SONARR_IMPORTED_EVENTS = new Set(['downloadFolderImported', 'downloadImported']);
// Sonarr event types that represent a failed import
const SONARR_FAILED_EVENTS = new Set(['downloadFailed', 'importFailed']);
// Radarr equivalents
const RADARR_IMPORTED_EVENTS = new Set(['downloadFolderImported', 'downloadImported']);
const RADARR_FAILED_EVENTS = new Set(['downloadFailed', 'importFailed']);
/**
* Fetch recent history records from all Sonarr instances for the given date window.
* Results are cached under 'history:sonarr' for HISTORY_CACHE_TTL.
* @param {Date} since - Only include records on or after this date
* @returns {Promise<Array>} Flat array of Sonarr history records (with _instanceUrl and _instanceName)
*/
async function fetchSonarrHistory(since) {
const cacheKey = 'history:sonarr';
const cached = cache.get(cacheKey);
if (cached) return cached;
const instances = getSonarrInstances();
const results = await Promise.all(instances.map(async inst => {
try {
const response = await axios.get(`${inst.url}/api/v3/history`, {
headers: { 'X-Api-Key': inst.apiKey },
params: {
pageSize: 100,
sortKey: 'date',
sortDir: 'descending',
includeSeries: true,
includeEpisode: true,
startDate: since.toISOString()
}
});
const records = (response.data && response.data.records) || [];
return records.map(r => {
if (r.series) r.series._instanceUrl = inst.url;
if (r.series) r.series._instanceName = inst.name || inst.id;
r._instanceUrl = inst.url;
r._instanceName = inst.name || inst.id;
return r;
});
} catch (err) {
console.error(`[HistoryFetcher] Sonarr ${inst.id} error:`, err.message);
return [];
}
}));
const flat = results.flat();
cache.set(cacheKey, flat, HISTORY_CACHE_TTL);
return flat;
}
/**
* Fetch recent history records from all Radarr instances for the given date window.
* Results are cached under 'history:radarr' for HISTORY_CACHE_TTL.
* @param {Date} since - Only include records on or after this date
* @returns {Promise<Array>} Flat array of Radarr history records (with _instanceUrl and _instanceName)
*/
async function fetchRadarrHistory(since) {
const cacheKey = 'history:radarr';
const cached = cache.get(cacheKey);
if (cached) return cached;
const instances = getRadarrInstances();
const results = await Promise.all(instances.map(async inst => {
try {
const response = await axios.get(`${inst.url}/api/v3/history`, {
headers: { 'X-Api-Key': inst.apiKey },
params: {
pageSize: 100,
sortKey: 'date',
sortDir: 'descending',
includeMovie: true,
startDate: since.toISOString()
}
});
const records = (response.data && response.data.records) || [];
return records.map(r => {
if (r.movie) r.movie._instanceUrl = inst.url;
if (r.movie) r.movie._instanceName = inst.name || inst.id;
r._instanceUrl = inst.url;
r._instanceName = inst.name || inst.id;
return r;
});
} catch (err) {
console.error(`[HistoryFetcher] Radarr ${inst.id} error:`, err.message);
return [];
}
}));
const flat = results.flat();
cache.set(cacheKey, flat, HISTORY_CACHE_TTL);
return flat;
}
/**
* Classify a Sonarr history record's event type.
* @param {string} eventType
* @returns {'imported'|'failed'|'other'}
*/
function classifySonarrEvent(eventType) {
if (SONARR_IMPORTED_EVENTS.has(eventType)) return 'imported';
if (SONARR_FAILED_EVENTS.has(eventType)) return 'failed';
return 'other';
}
/**
* Classify a Radarr history record's event type.
* @param {string} eventType
* @returns {'imported'|'failed'|'other'}
*/
function classifyRadarrEvent(eventType) {
if (RADARR_IMPORTED_EVENTS.has(eventType)) return 'imported';
if (RADARR_FAILED_EVENTS.has(eventType)) return 'failed';
return 'other';
}
/**
* Invalidate cached history so the next request fetches fresh data.
* Called externally if needed (e.g. after a forced refresh).
*/
function invalidateHistoryCache() {
cache.invalidate('history:sonarr');
cache.invalidate('history:radarr');
}
module.exports = {
fetchSonarrHistory,
fetchRadarrHistory,
classifySonarrEvent,
classifyRadarrEvent,
invalidateHistoryCache,
HISTORY_CACHE_TTL
};
+1 -6
View File
@@ -1,12 +1,7 @@
const fs = require('fs');
const path = require('path');
// 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' });
const logFile = fs.createWriteStream(path.join(__dirname, '../../server.log'), { flags: 'a' });
function logToFile(message) {
logFile.write(`[${new Date().toISOString()}] ${message}\n`);
-230
View File
@@ -1,230 +0,0 @@
const axios = require('axios');
const cache = require('./cache');
const { getTorrents } = require('./qbittorrent');
const {
getSABnzbdInstances,
getSonarrInstances,
getRadarrInstances
} = require('./config');
const rawPollInterval = (process.env.POLL_INTERVAL || '').toLowerCase();
const POLL_INTERVAL = (rawPollInterval === 'off' || rawPollInterval === 'false' || rawPollInterval === 'disabled')
? 0
: (parseInt(process.env.POLL_INTERVAL, 10) || 5000);
const POLLING_ENABLED = POLL_INTERVAL > 0;
let polling = false;
let lastPollTimings = null;
// SSE subscribers: Set of () => void callbacks, each registered by an open /stream connection
const pollSubscribers = new Set();
function onPollComplete(cb) { pollSubscribers.add(cb); }
function offPollComplete(cb) { pollSubscribers.delete(cb); }
// Timed fetch helper: runs a fetch and records how long it took
async function timed(label, fn) {
const t0 = Date.now();
const result = await fn();
return { label, result, ms: Date.now() - t0 };
}
async function pollAllServices() {
if (polling) {
console.log('[Poller] Previous poll still running, skipping');
return;
}
polling = true;
const start = Date.now();
try {
const sabInstances = getSABnzbdInstances();
const sonarrInstances = getSonarrInstances();
const radarrInstances = getRadarrInstances();
// All fetches in parallel, each individually timed
const results = await Promise.all([
timed('SABnzbd Queue', () => Promise.all(sabInstances.map(inst =>
axios.get(`${inst.url}/api`, {
params: { mode: 'queue', apikey: inst.apiKey, output: 'json' }
}).then(res => ({ instance: inst.id, data: res.data })).catch(err => {
console.error(`[Poller] SABnzbd ${inst.id} queue error:`, err.message);
return { instance: inst.id, data: { queue: { slots: [] } } };
})
))),
timed('SABnzbd History', () => Promise.all(sabInstances.map(inst =>
axios.get(`${inst.url}/api`, {
params: { mode: 'history', apikey: inst.apiKey, output: 'json', limit: 10 }
}).then(res => ({ instance: inst.id, data: res.data })).catch(err => {
console.error(`[Poller] SABnzbd ${inst.id} history error:`, err.message);
return { instance: inst.id, data: { history: { slots: [] } } };
})
))),
timed('Sonarr Tags', () => Promise.all(sonarrInstances.map(inst =>
axios.get(`${inst.url}/api/v3/tag`, {
headers: { 'X-Api-Key': inst.apiKey }
}).then(res => ({ instance: inst.id, data: res.data })).catch(err => {
console.error(`[Poller] Sonarr ${inst.id} tags error:`, err.message);
return { instance: inst.id, data: [] };
})
))),
timed('Sonarr Queue', () => Promise.all(sonarrInstances.map(inst =>
axios.get(`${inst.url}/api/v3/queue`, {
headers: { 'X-Api-Key': inst.apiKey },
params: { includeSeries: true, includeEpisode: true }
}).then(res => ({ instance: inst.id, data: res.data })).catch(err => {
console.error(`[Poller] Sonarr ${inst.id} queue error:`, err.message);
return { instance: inst.id, data: { records: [] } };
})
))),
timed('Sonarr History', () => Promise.all(sonarrInstances.map(inst =>
axios.get(`${inst.url}/api/v3/history`, {
headers: { 'X-Api-Key': inst.apiKey },
params: { pageSize: 10, includeEpisode: true }
}).then(res => ({ instance: inst.id, data: res.data })).catch(err => {
console.error(`[Poller] Sonarr ${inst.id} history error:`, err.message);
return { instance: inst.id, data: { records: [] } };
})
))),
timed('Radarr Queue', () => Promise.all(radarrInstances.map(inst =>
axios.get(`${inst.url}/api/v3/queue`, {
headers: { 'X-Api-Key': inst.apiKey },
params: { includeMovie: true }
}).then(res => ({ instance: inst.id, data: res.data })).catch(err => {
console.error(`[Poller] Radarr ${inst.id} queue error:`, err.message);
return { instance: inst.id, data: { records: [] } };
})
))),
timed('Radarr History', () => Promise.all(radarrInstances.map(inst =>
axios.get(`${inst.url}/api/v3/history`, {
headers: { 'X-Api-Key': inst.apiKey },
params: { pageSize: 10 }
}).then(res => ({ instance: inst.id, data: res.data })).catch(err => {
console.error(`[Poller] Radarr ${inst.id} history error:`, err.message);
return { instance: inst.id, data: { records: [] } };
})
))),
timed('Radarr Tags', () => Promise.all(radarrInstances.map(inst =>
axios.get(`${inst.url}/api/v3/tag`, {
headers: { 'X-Api-Key': inst.apiKey }
}).then(res => ({ instance: inst.id, data: res.data })).catch(err => {
console.error(`[Poller] Radarr ${inst.id} tags error:`, err.message);
return { instance: inst.id, data: [] };
})
))),
timed('qBittorrent', () => getTorrents().catch(err => {
console.error(`[Poller] qBittorrent error:`, err.message);
return [];
}))
]);
const [
{ result: sabQueues }, { result: sabHistories },
{ result: sonarrTagsResults }, { result: sonarrQueues },
{ result: sonarrHistories },
{ result: radarrQueues }, { result: radarrHistories },
{ result: radarrTagsResults },
{ result: qbittorrentTorrents }
] = results;
// Store per-task timings
const totalMs = Date.now() - start;
lastPollTimings = {
totalMs,
timestamp: new Date().toISOString(),
tasks: results.map(r => ({ label: r.label, ms: r.ms }))
};
// When polling is active, TTL is 3x interval to avoid gaps between polls
// When polling is disabled (on-demand), use 30s so data refreshes on next request after expiry
const cacheTTL = POLLING_ENABLED ? POLL_INTERVAL * 3 : 30000;
// SABnzbd
const firstSabQueue = sabQueues[0] && sabQueues[0].data && sabQueues[0].data.queue;
cache.set('poll:sab-queue', {
slots: sabQueues.flatMap(q => (q.data.queue && q.data.queue.slots) || []),
status: firstSabQueue && firstSabQueue.status,
speed: firstSabQueue && firstSabQueue.speed,
kbpersec: firstSabQueue && firstSabQueue.kbpersec
}, cacheTTL);
cache.set('poll:sab-history', {
slots: sabHistories.flatMap(h => (h.data.history && h.data.history.slots) || [])
}, cacheTTL);
// Sonarr
cache.set('poll:sonarr-tags', sonarrTagsResults, cacheTTL);
// Tag queue/history records with _instanceUrl so embedded series/movie objects can build links
cache.set('poll:sonarr-queue', {
records: sonarrQueues.flatMap(q => {
const inst = sonarrInstances.find(i => i.id === q.instance);
const url = inst ? inst.url : null;
return (q.data.records || []).map(r => {
if (r.series) r.series._instanceUrl = url;
return r;
});
})
}, cacheTTL);
cache.set('poll:sonarr-history', {
records: sonarrHistories.flatMap(h => h.data.records || [])
}, cacheTTL);
// Radarr
cache.set('poll:radarr-queue', {
records: radarrQueues.flatMap(q => {
const inst = radarrInstances.find(i => i.id === q.instance);
const url = inst ? inst.url : null;
return (q.data.records || []).map(r => {
if (r.movie) r.movie._instanceUrl = url;
return r;
});
})
}, cacheTTL);
cache.set('poll:radarr-history', {
records: radarrHistories.flatMap(h => h.data.records || [])
}, cacheTTL);
cache.set('poll:radarr-tags', radarrTagsResults.flatMap(t => t.data || []), cacheTTL);
// qBittorrent
cache.set('poll:qbittorrent', qbittorrentTorrents, cacheTTL);
const elapsed = Date.now() - start;
console.log(`[Poller] Poll complete in ${elapsed}ms`);
// Notify all SSE stream connections so they push fresh data immediately
for (const cb of pollSubscribers) {
try { cb(); } catch { /* subscriber already disconnected */ }
}
} catch (err) {
console.error(`[Poller] Poll error:`, err.message);
} finally {
polling = false;
}
}
let intervalHandle = null;
function startPoller() {
if (!POLLING_ENABLED) {
console.log(`[Poller] Background polling disabled (POLL_INTERVAL=${process.env.POLL_INTERVAL || 'not set'}). Data will be fetched on-demand.`);
return;
}
console.log(`[Poller] Starting background poller (interval: ${POLL_INTERVAL}ms)`);
// Run immediately, then on interval
pollAllServices();
intervalHandle = setInterval(pollAllServices, POLL_INTERVAL);
}
function stopPoller() {
if (intervalHandle) {
clearInterval(intervalHandle);
intervalHandle = null;
console.log('[Poller] Stopped');
}
}
function getLastPollTimings() {
return lastPollTimings;
}
module.exports = { startPoller, stopPoller, pollAllServices, getLastPollTimings, onPollComplete, offPollComplete, POLL_INTERVAL, POLLING_ENABLED };
+2 -8
View File
@@ -96,19 +96,14 @@ class QBittorrentClient {
}
}
// Persist clients so auth cookies survive between requests
let persistedClients = null;
function getClients() {
if (persistedClients) return persistedClients;
const instances = getQbittorrentInstances();
if (instances.length === 0) {
logToFile('[qBittorrent] No instances configured');
return [];
}
logToFile(`[qBittorrent] Created ${instances.length} persistent client(s)`);
persistedClients = instances.map(inst => new QBittorrentClient(inst));
return persistedClients;
logToFile(`[qBittorrent] Created ${instances.length} client(s)`);
return instances.map(inst => new QBittorrentClient(inst));
}
async function getAllTorrents() {
@@ -203,7 +198,6 @@ function mapTorrentToDownload(torrent) {
hash: torrent.hash,
category: torrent.category,
tags: torrent.tags,
savePath: torrent.content_path || torrent.save_path || null,
qbittorrent: true
};
}
-21
View File
@@ -1,21 +0,0 @@
// Query-param secrets (SABnzbd apikey, generic token/password params)
const QUERY_SECRET_PATTERN = /([?&](?:apikey|token|password|api_key|key|secret)=)[^&\s#]*/gi;
// HTTP auth header values (X-Api-Key, X-MediaBrowser-Token, Authorization, X-Emby-Authorization)
// Redact everything after the colon to end-of-line (MediaBrowser headers span the full line)
const HEADER_PATTERN = /(?:x-api-key|x-mediabrowser-token|x-emby-authorization|authorization)\s*:[^\n]*/gi;
// Bearer tokens
const BEARER_PATTERN = /bearer\s+[A-Za-z0-9\-._~+/]+=*/gi;
// Basic auth credentials in URLs (http://user:pass@host)
const BASIC_AUTH_URL_PATTERN = /\/\/[^:@/\s]+:[^@/\s]+@/gi;
function sanitizeError(err) {
let msg = (err && err.message) ? err.message : String(err);
msg = msg.replace(QUERY_SECRET_PATTERN, '$1[REDACTED]');
msg = msg.replace(HEADER_PATTERN, (m) => m.split(/[\s:]/)[0] + ':[REDACTED]');
msg = msg.replace(BEARER_PATTERN, 'bearer [REDACTED]');
msg = msg.replace(BASIC_AUTH_URL_PATTERN, '//[REDACTED]@');
// Never leak stack traces to API responses
return msg;
}
module.exports = sanitizeError;
-97
View File
@@ -1,97 +0,0 @@
/**
* Persistent token store backed by a JSON file.
*
* Pure JavaScript — no native addons, no build tools required.
* Survives process restarts so users are not logged out on redeploy.
*
* Tokens are stored in DATA_DIR/tokens.json (default: ./data locally,
* /app/data in the container). Writes are atomic: data is written to a
* temp file then renamed so a crash mid-write never corrupts the store.
*
* Format: { "<userId>": { accessToken: "...", createdAt: <unix ms> } }
*
* Expired entries (older than TOKEN_TTL_DAYS) are pruned on startup
* and once per hour.
*/
const path = require('path');
const fs = require('fs');
const TOKEN_TTL_DAYS = 31; // slightly longer than max cookie lifetime (30d)
const TOKEN_TTL_MS = TOKEN_TTL_DAYS * 24 * 60 * 60 * 1000;
const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, '../../data');
if (!fs.existsSync(DATA_DIR)) {
fs.mkdirSync(DATA_DIR, { recursive: true });
}
const STORE_PATH = path.join(DATA_DIR, 'tokens.json');
const STORE_TMP = STORE_PATH + '.tmp';
// Load store from disk, return empty object on any error
function load() {
try {
return JSON.parse(fs.readFileSync(STORE_PATH, 'utf8'));
} catch {
return {};
}
}
// Atomic write: write to .tmp then rename to avoid partial-write corruption
function save(data) {
try {
fs.writeFileSync(STORE_TMP, JSON.stringify(data), 'utf8');
fs.renameSync(STORE_TMP, STORE_PATH);
} catch (err) {
console.error('[TokenStore] Failed to persist token store:', err.message);
}
}
function prune(data) {
const cutoff = Date.now() - TOKEN_TTL_MS;
let pruned = 0;
for (const userId of Object.keys(data)) {
if (data[userId].createdAt < cutoff) {
delete data[userId];
pruned++;
}
}
if (pruned > 0) {
console.log(`[TokenStore] Pruned ${pruned} expired token(s)`);
}
return data;
}
// Prune on startup
let store = prune(load());
save(store);
// Prune once per hour (unref so it doesn't keep the process alive)
setInterval(() => {
store = prune(load());
save(store);
}, 60 * 60 * 1000).unref();
module.exports = {
storeToken(userId, accessToken) {
store[userId] = { accessToken, createdAt: Date.now() };
save(store);
},
getToken(userId) {
const entry = store[userId];
if (!entry) return null;
// Also honour TTL on read in case pruning hasn't run yet
if (Date.now() - entry.createdAt > TOKEN_TTL_MS) {
delete store[userId];
save(store);
return null;
}
return { accessToken: entry.accessToken };
},
clearToken(userId) {
if (store[userId]) {
delete store[userId];
save(store);
}
}
};
-67
View File
@@ -1,67 +0,0 @@
# Testing
## Stack
| Layer | Tool |
|---|---|
| Test runner | [Vitest](https://vitest.dev/) v4 |
| HTTP integration | [supertest](https://github.com/ladjs/supertest) |
| HTTP interception | [nock](https://github.com/nock/nock) (intercepts at Node http layer — works with CJS `require('axios')`) |
| Coverage | V8 (built-in, no Babel needed) |
## Running tests
```bash
# Run all tests once
npm test
# Watch mode (re-runs on file change)
npm run test:watch
# With coverage report
npm run test:coverage
# Interactive UI
npm run test:ui
```
Coverage output lands in `coverage/` (gitignored). Open `coverage/index.html` for the HTML report.
## Structure
```
tests/
├── setup.js # Global setup: isolated DATA_DIR, SKIP_RATE_LIMIT, console suppression
├── unit/
│ ├── sanitizeError.test.js # Secret redaction patterns (API keys, tokens, passwords)
│ ├── config.test.js # JSON array + legacy single-instance config parsing
│ ├── requireAuth.test.js # Auth middleware: valid/invalid/tampered cookies
│ ├── verifyCsrf.test.js # CSRF double-submit cookie pattern + timing-safe compare
│ ├── qbittorrent.test.js # Pure utils: formatBytes, formatEta, mapTorrentToDownload
│ └── tokenStore.test.js # JSON file token store: store/get/clear, TTL expiry
└── integration/
├── health.test.js # GET /health and /ready endpoints
└── auth.test.js # Full login/logout/me/csrf flows via supertest + nock
```
## Key design decisions
- **`server/app.js`** — Express factory extracted from `server/index.js`. Tests import `createApp()` without triggering the log-file setup, `process.exit()` calls, or background poller in the entry point.
- **nock over `vi.mock('axios')`** — Vitest's `vi.mock` only intercepts ESM `import` statements. Since `auth.js` uses CJS `require('axios')`, nock (which patches Node's `http`/`https` modules) is the correct tool for intercepting outbound requests.
- **`SKIP_RATE_LIMIT=1`** — All supertest requests originate from `127.0.0.1`, which would quickly exhaust per-IP rate-limit windows. Setting this env var raises the limits to `Number.MAX_SAFE_INTEGER` in both the API limiter and the login limiter.
- **Isolated `DATA_DIR`** — Each test worker gets a unique temp directory so `tokenStore.js` file I/O never conflicts with a running dev server.
- **`createApp({ skipRateLimits: true })`** — The app factory accepts an option to disable the general API rate limiter in addition to the env var for the login-specific limiter.
## Coverage targets
The tested files meet these per-file minimums (enforced in CI):
| File | Lines | Branches |
|---|---|---|
| `server/app.js` | 85% | 65% |
| `server/routes/auth.js` | 85% | 70% |
| `server/middleware/requireAuth.js` | 75% | 80% |
| `server/utils/sanitizeError.js` | 60% | — |
| `server/utils/config.js` | 50% | 55% |
`dashboard.js` and `poller.js` are large files requiring complex external-service mocks (Sonarr, Radarr, qBittorrent, Emby) and are tracked as future test coverage work.
-299
View File
@@ -1,299 +0,0 @@
/**
* Integration tests for authentication routes.
*
* Uses supertest against the createApp() factory (no real server).
* HTTP calls to Emby are intercepted at the Node http/https layer using nock,
* which works correctly with CJS require('axios') unlike vi.mock which only
* intercepts ESM imports.
*
* Covers:
* - Input validation on /login (empty fields, overlong values)
* - Successful login flow (cookies set, CSRF token returned)
* - Failed login (wrong credentials → 401, no cookie set)
* - /me endpoint (authenticated vs unauthenticated)
* - /csrf token issuance
* - /logout (cookies cleared)
*/
import request from 'supertest';
import nock from 'nock';
import { createApp } from '../../server/app.js';
const EMBY_BASE = 'https://emby.test';
// Emby response fixtures
const EMBY_AUTH_BODY = {
AccessToken: 'test-emby-token-abc123',
User: { Id: 'user-id-001', Name: 'TestUser' }
};
const EMBY_USER_BODY = {
Id: 'user-id-001',
Name: 'TestUser',
Policy: { IsAdministrator: false }
};
const EMBY_ADMIN_BODY = {
Id: 'admin-id-001',
Name: 'AdminUser',
Policy: { IsAdministrator: true }
};
// Helper: intercept a successful Emby login + user-info sequence
function interceptSuccessfulLogin(userBody = EMBY_USER_BODY) {
nock(EMBY_BASE)
.post('/Users/authenticatebyname')
.reply(200, EMBY_AUTH_BODY);
nock(EMBY_BASE)
.get(/\/Users\//)
.reply(200, userBody);
}
afterEach(() => {
nock.cleanAll(); // remove any pending interceptors between tests
});
describe('POST /api/auth/login', () => {
// Each sub-describe gets a fresh app to avoid rate-limit state leaking
// between the 'input validation' calls (which all fail and count toward
// the 10-failure window) and the 'successful login' calls.
let app;
beforeEach(() => {
process.env.EMBY_URL = 'https://emby.test';
delete process.env.COOKIE_SECRET;
// skipRateLimits avoids 429s from the login limiter when all
// requests come from 127.0.0.1 in the test environment
app = createApp({ skipRateLimits: true });
vi.clearAllMocks();
});
afterEach(() => {
delete process.env.EMBY_URL;
delete process.env.COOKIE_SECRET;
});
describe('input validation', () => {
it('rejects empty username', async () => {
const res = await request(app)
.post('/api/auth/login')
.send({ username: '', password: 'pass' });
expect(res.status).toBe(400);
expect(res.body.success).toBe(false);
});
it('rejects missing password', async () => {
const res = await request(app)
.post('/api/auth/login')
.send({ username: 'alice', password: '' });
expect(res.status).toBe(400);
expect(res.body.success).toBe(false);
});
it('rejects username over 128 chars', async () => {
const res = await request(app)
.post('/api/auth/login')
.send({ username: 'a'.repeat(129), password: 'pass' });
expect(res.status).toBe(400);
});
it('rejects password over 256 chars', async () => {
const res = await request(app)
.post('/api/auth/login')
.send({ username: 'alice', password: 'p'.repeat(257) });
expect(res.status).toBe(400);
});
it('rejects non-string username', async () => {
const res = await request(app)
.post('/api/auth/login')
.send({ username: 123, password: 'pass' });
expect(res.status).toBe(400);
});
});
describe('successful login', () => {
it('returns success:true with user info', async () => {
interceptSuccessfulLogin();
const res = await request(app)
.post('/api/auth/login')
.send({ username: 'TestUser', password: 'correct' });
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
expect(res.body.user.name).toBe('TestUser');
expect(res.body.user.isAdmin).toBe(false);
});
it('sets emby_user cookie', async () => {
interceptSuccessfulLogin();
const res = await request(app)
.post('/api/auth/login')
.send({ username: 'TestUser', password: 'correct' });
const cookies = res.headers['set-cookie'] || [];
expect(cookies.some(c => c.startsWith('emby_user='))).toBe(true);
});
it('sets csrf_token cookie', async () => {
interceptSuccessfulLogin();
const res = await request(app)
.post('/api/auth/login')
.send({ username: 'TestUser', password: 'correct' });
const cookies = res.headers['set-cookie'] || [];
expect(cookies.some(c => c.startsWith('csrf_token='))).toBe(true);
});
it('returns csrfToken in response body', async () => {
interceptSuccessfulLogin();
const res = await request(app)
.post('/api/auth/login')
.send({ username: 'TestUser', password: 'correct' });
expect(typeof res.body.csrfToken).toBe('string');
expect(res.body.csrfToken.length).toBeGreaterThan(0);
});
it('session cookie has no maxAge when rememberMe is false', async () => {
interceptSuccessfulLogin();
const res = await request(app)
.post('/api/auth/login')
.send({ username: 'TestUser', password: 'correct', rememberMe: false });
const cookies = res.headers['set-cookie'] || [];
const sessionCookie = cookies.find(c => c.startsWith('emby_user='));
// Session cookie must not persist across browser close
expect(sessionCookie).toBeDefined();
expect(sessionCookie).not.toContain('Max-Age');
});
it('sets 30-day maxAge when rememberMe is true', async () => {
interceptSuccessfulLogin();
const res = await request(app)
.post('/api/auth/login')
.send({ username: 'TestUser', password: 'correct', rememberMe: true });
const cookies = res.headers['set-cookie'] || [];
const sessionCookie = cookies.find(c => c.startsWith('emby_user='));
expect(sessionCookie).toBeDefined();
expect(sessionCookie).toContain('Max-Age');
});
it('marks isAdmin correctly for admin user', async () => {
interceptSuccessfulLogin(EMBY_ADMIN_BODY);
const res = await request(app)
.post('/api/auth/login')
.send({ username: 'AdminUser', password: 'correct' });
expect(res.status).toBe(200);
expect(res.body.user.isAdmin).toBe(true);
});
it('does not include AccessToken in response body', async () => {
interceptSuccessfulLogin();
const res = await request(app)
.post('/api/auth/login')
.send({ username: 'TestUser', password: 'correct' });
// The Emby access token must never be sent to the client
expect(JSON.stringify(res.body)).not.toContain('test-emby-token-abc123');
});
});
describe('failed login', () => {
it('returns 401 when Emby rejects credentials', async () => {
nock(EMBY_BASE).post('/Users/authenticatebyname').reply(401, { error: 'Unauthorized' });
const res = await request(app)
.post('/api/auth/login')
.send({ username: 'baduser', password: 'wrongpass' });
expect(res.status).toBe(401);
expect(res.body.success).toBe(false);
// Must not expose internal error details
expect(res.body.error).toBe('Invalid username or password');
});
it('does not set emby_user cookie on failure', async () => {
nock(EMBY_BASE).post('/Users/authenticatebyname').reply(401, {});
const res = await request(app)
.post('/api/auth/login')
.send({ username: 'baduser', password: 'wrongpass' });
const cookies = res.headers['set-cookie'] || [];
expect(cookies.some(c => c.startsWith('emby_user='))).toBe(false);
});
});
});
describe('GET /api/auth/me', () => {
let app;
beforeEach(() => {
delete process.env.COOKIE_SECRET;
app = createApp({ skipRateLimits: true });
});
it('returns authenticated:false when no cookie', async () => {
const res = await request(app).get('/api/auth/me');
expect(res.status).toBe(200);
expect(res.body.authenticated).toBe(false);
});
it('returns authenticated:true with valid cookie', async () => {
const payload = JSON.stringify({ id: 'u1', name: 'Alice', isAdmin: false });
const res = await request(app)
.get('/api/auth/me')
.set('Cookie', `emby_user=${encodeURIComponent(payload)}`);
expect(res.body.authenticated).toBe(true);
expect(res.body.user.name).toBe('Alice');
});
});
describe('GET /api/auth/csrf', () => {
it('issues a csrf_token cookie and returns csrfToken in body', async () => {
const app = createApp({ skipRateLimits: true });
const res = await request(app).get('/api/auth/csrf');
expect(res.status).toBe(200);
expect(typeof res.body.csrfToken).toBe('string');
expect(res.body.csrfToken.length).toBe(64); // 32 bytes hex
const cookies = res.headers['set-cookie'] || [];
expect(cookies.some(c => c.startsWith('csrf_token='))).toBe(true);
});
});
describe('POST /api/auth/logout', () => {
let app;
beforeEach(() => {
process.env.EMBY_URL = 'https://emby.test';
delete process.env.COOKIE_SECRET;
app = createApp({ skipRateLimits: true });
vi.clearAllMocks();
});
afterEach(() => {
delete process.env.EMBY_URL;
});
// NOTE: /api/auth/* is mounted BEFORE the verifyCsrf middleware in app.js,
// so logout does not require a CSRF token by design. The session cookie's
// sameSite:strict attribute provides equivalent CSRF protection for logout.
it('succeeds without a CSRF token (sameSite:strict provides protection)', async () => {
nock(EMBY_BASE).post('/Sessions/Logout').reply(200, {});
const res = await request(app)
.post('/api/auth/logout');
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
});
it('clears cookies and returns success when CSRF token is provided', async () => {
const csrfRes = await request(app).get('/api/auth/csrf');
const csrfToken = csrfRes.body.csrfToken;
const csrfCookie = csrfRes.headers['set-cookie'].find(c => c.startsWith('csrf_token='));
nock(EMBY_BASE).post('/Sessions/Logout').reply(200, {});
const res = await request(app)
.post('/api/auth/logout')
.set('Cookie', csrfCookie)
.set('X-CSRF-Token', csrfToken);
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
// Cookies should be cleared (Set-Cookie header with empty value / Max-Age=0)
const cookies = res.headers['set-cookie'] || [];
expect(cookies.some(c => c.includes('emby_user=;') || c.includes('Max-Age=0'))).toBe(true);
});
});
-55
View File
@@ -1,55 +0,0 @@
/**
* Integration tests for health and readiness endpoints.
*
* /health and /ready are used by Docker HEALTHCHECK and must:
* - Require no authentication
* - Not be rate-limited
* - Return the correct status codes
*/
import request from 'supertest';
import { createApp } from '../../server/app.js';
describe('GET /health', () => {
let app;
beforeEach(() => {
app = createApp();
});
it('returns 200 with status ok', async () => {
const res = await request(app).get('/health');
expect(res.status).toBe(200);
expect(res.body.status).toBe('ok');
});
it('includes uptime as a number', async () => {
const res = await request(app).get('/health');
expect(typeof res.body.uptime).toBe('number');
expect(res.body.uptime).toBeGreaterThanOrEqual(0);
});
});
describe('GET /ready', () => {
let app;
afterEach(() => {
delete process.env.EMBY_URL;
});
it('returns 200 when EMBY_URL is configured', async () => {
process.env.EMBY_URL = 'https://emby.local';
app = createApp();
const res = await request(app).get('/ready');
expect(res.status).toBe(200);
expect(res.body.status).toBe('ready');
});
it('returns 503 when EMBY_URL is not configured', async () => {
delete process.env.EMBY_URL;
app = createApp();
const res = await request(app).get('/ready');
expect(res.status).toBe(503);
expect(res.body.status).toBe('not ready');
});
});
-289
View File
@@ -1,289 +0,0 @@
/**
* Integration tests for GET /api/history/recent
*
* Uses supertest against createApp() with vi.mock to stub historyFetcher
* (avoids nock/ESM-CJS interop issues with axios) and nock for Emby auth.
* Covers:
* - 401 when unauthenticated
* - Empty history response when arr returns no records
* - Filters out records whose eventType is not imported/failed
* - Returns imported and failed records for tagged series/movies
* - ?days= param is respected (default 7, capped at 90)
* - failureMessage included for admins on failed records
*/
import request from 'supertest';
import nock from 'nock';
import { beforeEach, afterEach } from 'vitest';
import { createRequire } from 'module';
import { createApp } from '../../server/app.js';
// Use createRequire to get the same CJS singleton cache instance that
// server/utils/historyFetcher.js and server/routes/history.js use via
// require('./cache'). A plain ESM `import cache from '...'` resolves
// to a different module identity under vitest's ESM runtime.
const require = createRequire(import.meta.url);
const cache = require('../../server/utils/cache.js');
const EMBY_BASE = 'https://emby.test';
process.env.EMBY_URL = EMBY_BASE;
process.env.SONARR_INSTANCES = JSON.stringify([
{ id: 'sonarr-1', name: 'Main Sonarr', url: 'https://sonarr.test', apiKey: 'sk' }
]);
process.env.RADARR_INSTANCES = JSON.stringify([
{ id: 'radarr-1', name: 'Main Radarr', url: 'https://radarr.test', apiKey: 'rk' }
]);
// --- Fixtures ---
const EMBY_AUTH = { AccessToken: 'tok', User: { Id: 'uid1', Name: 'alice' } };
const EMBY_USER = { Id: 'uid1', Name: 'alice', Policy: { IsAdministrator: false } };
const EMBY_ADMIN = { Id: 'uid2', Name: 'admin', Policy: { IsAdministrator: true } };
const EMBY_AUTH_ADMIN = { AccessToken: 'tok2', User: { Id: 'uid2', Name: 'admin' } };
// Sonarr tag: id 1 → 'alice'
const SONARR_TAGS = [{ id: 1, label: 'alice' }];
const SONARR_RECORD_IMPORTED = {
id: 100,
eventType: 'downloadFolderImported',
sourceTitle: 'Show.S01E01.720p',
date: new Date().toISOString(),
quality: { quality: { name: '720p' } },
series: { id: 10, title: 'My Show', titleSlug: 'my-show', tags: [1], images: [] },
seriesId: 10
};
const SONARR_RECORD_FAILED = {
id: 101,
eventType: 'downloadFailed',
sourceTitle: 'Show.S01E02.720p',
date: new Date().toISOString(),
quality: { quality: { name: '720p' } },
data: { message: 'Not enough disk space' },
series: { id: 11, title: 'Admin Show', titleSlug: 'admin-show', tags: [2], images: [] },
};
// Tag id 2 → 'admin' (used in the failed-import admin test)
const SONARR_TAGS_WITH_ADMIN = [{ id: 1, label: 'alice' }, { id: 2, label: 'admin' }];
const SONARR_RECORD_FAILED_ALICE = { // failed record tagged alice, for event-type filtering test
id: 103,
eventType: 'downloadFailed',
sourceTitle: 'Show.S01E02.720p',
date: new Date().toISOString(),
quality: { quality: { name: '720p' } },
data: { message: 'Disk full' },
series: { id: 10, title: 'My Show', titleSlug: 'my-show', tags: [1], images: [] },
seriesId: 10
};
const SONARR_RECORD_GRABBED = {
id: 102,
eventType: 'grabbed',
sourceTitle: 'Show.S01E03.720p',
date: new Date().toISOString(),
series: { id: 10, title: 'My Show', titleSlug: 'my-show', tags: [1], images: [] },
seriesId: 10
};
const RADARR_RECORD_IMPORTED = {
id: 200,
eventType: 'downloadFolderImported',
sourceTitle: 'My.Movie.2024.1080p',
date: new Date().toISOString(),
quality: { quality: { name: '1080p' } },
movie: { id: 20, title: 'My Movie', titleSlug: 'my-movie-2024', tags: [1], images: [] },
movieId: 20
};
// --- Helpers ---
function interceptLogin(userBody = EMBY_USER, authBody = EMBY_AUTH) {
nock(EMBY_BASE).post('/Users/authenticatebyname').reply(200, authBody);
nock(EMBY_BASE).get(/\/Users\//).reply(200, userBody);
}
// Pre-seed the history cache keys that fetchSonarrHistory/fetchRadarrHistory check
// first, so they return without making any HTTP calls.
const CACHE_TTL = 60 * 60 * 1000; // 1 hour — won't expire during a test run
function setHistory(sonarrRecords = [], radarrRecords = []) {
cache.set('history:sonarr', sonarrRecords.map(r => ({
...r,
_instanceName: 'Main Sonarr',
series: r.series ? { ...r.series, _instanceUrl: 'https://sonarr.test', _instanceName: 'Main Sonarr' } : undefined
})), CACHE_TTL);
cache.set('history:radarr', radarrRecords.map(r => ({
...r,
_instanceName: 'Main Radarr',
movie: r.movie ? { ...r.movie, _instanceUrl: 'https://radarr.test', _instanceName: 'Main Radarr' } : undefined
})), CACHE_TTL);
}
async function loginAs(app, userBody = EMBY_USER, authBody = EMBY_AUTH) {
interceptLogin(userBody, authBody);
const res = await request(app)
.post('/api/auth/login')
.send({ username: userBody.Name, password: 'pw' });
const cookies = res.headers['set-cookie'];
const csrf = res.body.csrfToken;
return { cookies, csrf };
}
beforeEach(() => {
// Clear history caches so each test controls its own data
cache.invalidate('history:sonarr');
cache.invalidate('history:radarr');
// Default: empty history
setHistory([], []);
// Seed poll tag caches so the route can resolve tags
cache.set('poll:sonarr-tags', [{ data: SONARR_TAGS }], 60000);
cache.set('poll:radarr-tags', [], 60000);
});
afterEach(() => {
nock.cleanAll();
cache.invalidate('history:sonarr');
cache.invalidate('history:radarr');
});
describe('GET /api/history/recent', () => {
describe('authentication', () => {
it('returns 401 when not logged in', async () => {
const app = createApp({ skipRateLimits: true });
const res = await request(app).get('/api/history/recent');
expect(res.status).toBe(401);
});
});
describe('empty history', () => {
it('returns empty array when arr returns no records', async () => {
const app = createApp({ skipRateLimits: true });
const { cookies } = await loginAs(app);
const res = await request(app)
.get('/api/history/recent')
.set('Cookie', cookies);
expect(res.status).toBe(200);
expect(res.body.history).toEqual([]);
expect(res.body.days).toBe(7);
});
});
describe('event type filtering', () => {
it('includes imported and failed records, excludes grabbed', async () => {
const app = createApp({ skipRateLimits: true });
setHistory(
[SONARR_RECORD_IMPORTED, SONARR_RECORD_FAILED_ALICE, SONARR_RECORD_GRABBED],
[]
);
const { cookies } = await loginAs(app);
const res = await request(app)
.get('/api/history/recent')
.set('Cookie', cookies);
expect(res.status).toBe(200);
const outcomes = res.body.history.map(h => h.outcome);
expect(outcomes).toContain('imported');
expect(outcomes).toContain('failed');
expect(res.body.history).toHaveLength(2);
});
});
describe('tag filtering', () => {
it('only returns records tagged for the current user', async () => {
const app = createApp({ skipRateLimits: true });
setHistory([SONARR_RECORD_IMPORTED], []);
const { cookies } = await loginAs(app);
const res = await request(app)
.get('/api/history/recent')
.set('Cookie', cookies);
expect(res.status).toBe(200);
expect(res.body.history).toHaveLength(1);
expect(res.body.history[0].seriesName).toBe('My Show');
expect(res.body.history[0].outcome).toBe('imported');
expect(res.body.history[0].quality).toBe('720p');
});
it('excludes records tagged for a different user', async () => {
const app = createApp({ skipRateLimits: true });
const bobAuth = { AccessToken: 'tok3', User: { Id: 'uid3', Name: 'bob' } };
const bobUser = { Id: 'uid3', Name: 'bob', Policy: { IsAdministrator: false } };
setHistory([SONARR_RECORD_IMPORTED], []);
const { cookies } = await loginAs(app, bobUser, bobAuth);
const res = await request(app)
.get('/api/history/recent')
.set('Cookie', cookies);
expect(res.status).toBe(200);
expect(res.body.history).toHaveLength(0);
});
});
describe('radarr records', () => {
it('returns movie history items', async () => {
const app = createApp({ skipRateLimits: true });
cache.set('poll:radarr-tags', [{ id: 1, label: 'alice' }], 60000);
setHistory([], [RADARR_RECORD_IMPORTED]);
const { cookies } = await loginAs(app);
const res = await request(app)
.get('/api/history/recent')
.set('Cookie', cookies);
expect(res.status).toBe(200);
expect(res.body.history).toHaveLength(1);
expect(res.body.history[0].type).toBe('movie');
expect(res.body.history[0].movieName).toBe('My Movie');
});
});
describe('?days parameter', () => {
it('uses custom days value', async () => {
const app = createApp({ skipRateLimits: true });
const { cookies } = await loginAs(app);
const res = await request(app)
.get('/api/history/recent?days=14')
.set('Cookie', cookies);
expect(res.status).toBe(200);
expect(res.body.days).toBe(14);
});
it('caps days at 90', async () => {
const app = createApp({ skipRateLimits: true });
const { cookies } = await loginAs(app);
const res = await request(app)
.get('/api/history/recent?days=999')
.set('Cookie', cookies);
expect(res.status).toBe(200);
expect(res.body.days).toBe(7); // falls back to default when > 90
});
});
describe('failed import details', () => {
it('includes failureMessage for admin on failed records', async () => {
const app = createApp({ skipRateLimits: true });
cache.set('poll:sonarr-tags', [{ data: SONARR_TAGS_WITH_ADMIN }], 60000);
setHistory([SONARR_RECORD_FAILED], []);
const { cookies } = await loginAs(app, EMBY_ADMIN, EMBY_AUTH_ADMIN);
const res = await request(app)
.get('/api/history/recent')
.set('Cookie', cookies);
expect(res.status).toBe(200);
const failed = res.body.history.find(h => h.outcome === 'failed');
expect(failed).toBeDefined();
expect(failed.failureMessage).toBe('Not enough disk space');
});
});
describe('response shape', () => {
it('returns correct top-level fields', async () => {
const app = createApp({ skipRateLimits: true });
const { cookies } = await loginAs(app);
const res = await request(app)
.get('/api/history/recent')
.set('Cookie', cookies);
expect(res.status).toBe(200);
expect(res.body).toHaveProperty('user');
expect(res.body).toHaveProperty('isAdmin');
expect(res.body).toHaveProperty('days');
expect(res.body).toHaveProperty('history');
expect(Array.isArray(res.body.history)).toBe(true);
});
});
});
-27
View File
@@ -1,27 +0,0 @@
import { vi, beforeEach, afterEach } from 'vitest';
import os from 'os';
import path from 'path';
import fs from 'fs';
// Give each test worker a unique temp DATA_DIR so tokenStore file I/O is
// fully isolated and doesn't conflict with a running dev server's data/.
const tmpDir = path.join(os.tmpdir(), `sofarr-test-${process.pid}`);
if (!fs.existsSync(tmpDir)) fs.mkdirSync(tmpDir, { recursive: true });
process.env.DATA_DIR = tmpDir;
// Disable rate limiters in tests — all supertest requests share 127.0.0.1
// and would quickly exhaust per-IP windows otherwise.
process.env.SKIP_RATE_LIMIT = '1';
// Suppress console noise during tests (errors still surface via thrown exceptions)
beforeEach(() => {
vi.spyOn(console, 'log').mockImplementation(() => {});
vi.spyOn(console, 'warn').mockImplementation(() => {});
vi.spyOn(console, 'error').mockImplementation(() => {});
});
afterEach(() => {
// clearAllMocks resets call history and queued return values without
// restoring mock implementations — use restoreAllMocks only for spies.
vi.clearAllMocks();
});
-108
View File
@@ -1,108 +0,0 @@
/**
* Tests for server/utils/config.js
*
* Verifies that instance config is parsed correctly from both the modern JSON
* array format and the legacy single-instance env var format. This is critical
* because misconfigured instances silently return no data rather than crashing.
*/
import { parseInstances, getSonarrInstances, getRadarrInstances } from '../../server/utils/config.js';
describe('parseInstances', () => {
describe('JSON array format', () => {
it('parses a valid single-instance JSON array', () => {
const json = JSON.stringify([{ name: 'main', url: 'https://sonarr.local', apiKey: 'abc123' }]);
const result = parseInstances(json, null, null);
expect(result).toHaveLength(1);
expect(result[0].url).toBe('https://sonarr.local');
expect(result[0].apiKey).toBe('abc123');
});
it('parses multiple instances', () => {
const json = JSON.stringify([
{ name: 'main', url: 'https://s1.local', apiKey: 'key1' },
{ name: 'backup', url: 'https://s2.local', apiKey: 'key2' }
]);
const result = parseInstances(json, null, null);
expect(result).toHaveLength(2);
expect(result[1].name).toBe('backup');
});
it('adds id from name when present', () => {
const json = JSON.stringify([{ name: 'i3omb', url: 'https://s.local', apiKey: 'k' }]);
const result = parseInstances(json, null, null);
expect(result[0].id).toBe('i3omb');
});
it('generates fallback id when name is absent', () => {
const json = JSON.stringify([{ url: 'https://s.local', apiKey: 'k' }]);
const result = parseInstances(json, null, null);
expect(result[0].id).toBe('instance-1');
});
it('handles multi-line JSON by stripping whitespace', () => {
const json = `[
{
"name": "main",
"url": "https://sonarr.local",
"apiKey": "abc"
}
]`;
const result = parseInstances(json, null, null);
expect(result).toHaveLength(1);
});
it('returns empty array for empty JSON array', () => {
expect(parseInstances('[]', null, null)).toEqual([]);
});
it('falls back to legacy format when JSON is malformed', () => {
const result = parseInstances('not-json', 'https://legacy.local', 'legacyKey');
expect(result).toHaveLength(1);
expect(result[0].url).toBe('https://legacy.local');
});
});
describe('legacy single-instance format', () => {
it('returns single instance from legacy URL + key', () => {
const result = parseInstances(null, 'https://sonarr.local', 'legacyapikey');
expect(result).toHaveLength(1);
expect(result[0].id).toBe('default');
expect(result[0].name).toBe('Default');
expect(result[0].url).toBe('https://sonarr.local');
expect(result[0].apiKey).toBe('legacyapikey');
});
it('returns empty array for qBittorrent with no apiKey and no JSON (legacy requires key)', () => {
// parseInstances requires legacyKey to be truthy for the legacy path;
// qBittorrent uses JSON array format, not the legacy URL+key path.
const result = parseInstances(null, 'https://qbt.local', null, 'admin', 'pass123');
expect(result).toEqual([]);
});
it('returns empty array when both JSON and legacy URL are missing', () => {
expect(parseInstances(null, null, null)).toEqual([]);
});
it('returns empty array when URL is set but key is missing', () => {
expect(parseInstances(null, 'https://sonarr.local', null)).toEqual([]);
});
});
describe('env-based getters', () => {
it('getSonarrInstances reads SONARR_INSTANCES from env', () => {
process.env.SONARR_INSTANCES = JSON.stringify([{ name: 'test', url: 'https://s.local', apiKey: 'k' }]);
const result = getSonarrInstances();
expect(result).toHaveLength(1);
expect(result[0].name).toBe('test');
delete process.env.SONARR_INSTANCES;
});
it('getRadarrInstances returns empty array when unconfigured', () => {
delete process.env.RADARR_INSTANCES;
delete process.env.RADARR_URL;
const result = getRadarrInstances();
expect(result).toEqual([]);
});
});
});
-177
View File
@@ -1,177 +0,0 @@
/**
* Unit tests for server/utils/historyFetcher.js
*
* Covers:
* - classifySonarrEvent / classifyRadarrEvent event classification
* - fetchSonarrHistory / fetchRadarrHistory: successful fetch, cache hit, per-instance errors
* - invalidateHistoryCache
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import nock from 'nock';
// Env must be set before importing modules that read it at load time
process.env.SONARR_INSTANCES = JSON.stringify([
{ id: 'sonarr-1', name: 'Main Sonarr', url: 'https://sonarr.test', apiKey: 'sonarr-key' }
]);
process.env.RADARR_INSTANCES = JSON.stringify([
{ id: 'radarr-1', name: 'Main Radarr', url: 'https://radarr.test', apiKey: 'radarr-key' }
]);
const { classifySonarrEvent, classifyRadarrEvent, fetchSonarrHistory, fetchRadarrHistory, invalidateHistoryCache } =
await import('../../server/utils/historyFetcher.js');
const since = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
afterEach(() => {
nock.cleanAll();
invalidateHistoryCache();
});
describe('classifySonarrEvent', () => {
it('returns imported for downloadFolderImported', () => {
expect(classifySonarrEvent('downloadFolderImported')).toBe('imported');
});
it('returns imported for downloadImported', () => {
expect(classifySonarrEvent('downloadImported')).toBe('imported');
});
it('returns failed for downloadFailed', () => {
expect(classifySonarrEvent('downloadFailed')).toBe('failed');
});
it('returns failed for importFailed', () => {
expect(classifySonarrEvent('importFailed')).toBe('failed');
});
it('returns other for grabbed', () => {
expect(classifySonarrEvent('grabbed')).toBe('other');
});
it('returns other for unknown event', () => {
expect(classifySonarrEvent('someFutureEvent')).toBe('other');
});
});
describe('classifyRadarrEvent', () => {
it('returns imported for downloadFolderImported', () => {
expect(classifyRadarrEvent('downloadFolderImported')).toBe('imported');
});
it('returns failed for downloadFailed', () => {
expect(classifyRadarrEvent('downloadFailed')).toBe('failed');
});
it('returns other for grabbed', () => {
expect(classifyRadarrEvent('grabbed')).toBe('other');
});
});
describe('fetchSonarrHistory', () => {
const mockRecords = [
{
id: 1,
eventType: 'downloadFolderImported',
sourceTitle: 'Show.S01E01',
date: new Date().toISOString(),
series: { id: 10, title: 'My Show', titleSlug: 'my-show', tags: [] },
seriesId: 10
}
];
it('fetches records and tags them with _instanceUrl and _instanceName', async () => {
nock('https://sonarr.test')
.get('/api/v3/history')
.query(true)
.reply(200, { records: mockRecords });
const result = await fetchSonarrHistory(since);
expect(result).toHaveLength(1);
expect(result[0].series._instanceUrl).toBe('https://sonarr.test');
expect(result[0].series._instanceName).toBe('Main Sonarr');
expect(result[0]._instanceName).toBe('Main Sonarr');
});
it('returns cached data on second call without making a new HTTP request', async () => {
nock('https://sonarr.test')
.get('/api/v3/history')
.query(true)
.reply(200, { records: mockRecords });
const first = await fetchSonarrHistory(since);
// Second call — nock would throw if a second request was made
const second = await fetchSonarrHistory(since);
expect(second).toEqual(first);
});
it('returns empty array and does not throw when instance errors', async () => {
nock('https://sonarr.test')
.get('/api/v3/history')
.query(true)
.replyWithError('ECONNREFUSED');
const result = await fetchSonarrHistory(since);
expect(result).toEqual([]);
});
it('handles missing records key gracefully', async () => {
nock('https://sonarr.test')
.get('/api/v3/history')
.query(true)
.reply(200, {});
const result = await fetchSonarrHistory(since);
expect(result).toEqual([]);
});
});
describe('fetchRadarrHistory', () => {
const mockRecords = [
{
id: 2,
eventType: 'downloadFolderImported',
sourceTitle: 'My.Movie.2024',
date: new Date().toISOString(),
movie: { id: 20, title: 'My Movie', titleSlug: 'my-movie-2024', tags: [] },
movieId: 20
}
];
it('fetches records and tags them with _instanceUrl and _instanceName', async () => {
nock('https://radarr.test')
.get('/api/v3/history')
.query(true)
.reply(200, { records: mockRecords });
const result = await fetchRadarrHistory(since);
expect(result).toHaveLength(1);
expect(result[0].movie._instanceUrl).toBe('https://radarr.test');
expect(result[0].movie._instanceName).toBe('Main Radarr');
});
it('returns empty array on network error', async () => {
nock('https://radarr.test')
.get('/api/v3/history')
.query(true)
.replyWithError('timeout');
const result = await fetchRadarrHistory(since);
expect(result).toEqual([]);
});
});
describe('invalidateHistoryCache', () => {
it('forces a fresh fetch after invalidation', async () => {
nock('https://sonarr.test')
.get('/api/v3/history')
.query(true)
.reply(200, { records: [] });
await fetchSonarrHistory(since);
invalidateHistoryCache();
// Should make a second HTTP request — nock will satisfy it
nock('https://sonarr.test')
.get('/api/v3/history')
.query(true)
.reply(200, { records: [] });
const result = await fetchSonarrHistory(since);
expect(result).toEqual([]);
expect(nock.isDone()).toBe(true);
});
});
-111
View File
@@ -1,111 +0,0 @@
/**
* Tests for server/utils/qbittorrent.js pure utility functions.
*
* mapTorrentToDownload, formatBytes, formatSpeed, and formatEta are all
* pure functions with no I/O — ideal unit test targets. These power the
* dashboard card rendering so correctness matters for UX.
*/
import { mapTorrentToDownload, formatBytes, formatSpeed, formatEta } from '../../server/utils/qbittorrent.js';
// Minimal torrent fixture that satisfies mapTorrentToDownload's expectations
function makeTorrent(overrides = {}) {
return {
name: 'My.Show.S01E01.1080p.mkv',
state: 'downloading',
size: 1073741824, // 1 GB
completed: 536870912, // 512 MB
progress: 0.5,
dlspeed: 1048576, // 1 MB/s
eta: 512, // seconds
num_seeds: 10,
num_leechs: 3,
availability: 1.0,
hash: 'aabbccdd',
category: 'sonarr',
tags: '',
content_path: '/downloads/My.Show.S01E01.1080p.mkv',
save_path: '/downloads/',
instanceName: 'i3omb',
...overrides
};
}
describe('formatBytes', () => {
it('formats 0 bytes', () => expect(formatBytes(0)).toBe('0 B'));
it('formats bytes', () => expect(formatBytes(512)).toBe('512 B'));
it('formats kilobytes', () => expect(formatBytes(1024)).toBe('1 KB'));
it('formats megabytes', () => expect(formatBytes(1048576)).toBe('1 MB'));
it('formats gigabytes', () => expect(formatBytes(1073741824)).toBe('1 GB'));
it('formats fractional GB', () => expect(formatBytes(1610612736)).toBe('1.5 GB'));
});
describe('formatSpeed', () => {
it('appends /s to byte count', () => expect(formatSpeed(1048576)).toBe('1 MB/s'));
it('handles zero speed', () => expect(formatSpeed(0)).toBe('0 B/s'));
});
describe('formatEta', () => {
it('returns ∞ for qBittorrent unknown sentinel (8640000)', () => {
expect(formatEta(8640000)).toBe('∞');
});
it('returns ∞ for negative eta', () => expect(formatEta(-1)).toBe('∞'));
it('formats minutes only', () => expect(formatEta(90)).toBe('1m'));
it('formats hours and minutes', () => expect(formatEta(3661)).toBe('1h 1m'));
it('formats days, hours and minutes', () => expect(formatEta(90061)).toBe('1d 1h 1m'));
it('returns 0m for zero seconds', () => expect(formatEta(0)).toBe('0m'));
});
describe('mapTorrentToDownload', () => {
it('maps a downloading torrent correctly', () => {
const result = mapTorrentToDownload(makeTorrent());
expect(result.status).toBe('Downloading');
expect(result.progress).toBe('50.0');
expect(result.size).toBe('1 GB');
expect(result.speed).toBe('1 MB/s');
expect(result.eta).toBe('8m');
expect(result.seeds).toBe(10);
expect(result.peers).toBe(3);
expect(result.qbittorrent).toBe(true);
expect(result.instanceName).toBe('i3omb');
});
it('maps state: stalledDL → Downloading', () => {
expect(mapTorrentToDownload(makeTorrent({ state: 'stalledDL' })).status).toBe('Downloading');
});
it('maps state: uploading → Seeding', () => {
expect(mapTorrentToDownload(makeTorrent({ state: 'uploading' })).status).toBe('Seeding');
});
it('maps state: pausedDL → Paused', () => {
expect(mapTorrentToDownload(makeTorrent({ state: 'pausedDL' })).status).toBe('Paused');
});
it('maps state: stoppedUP → Stopped', () => {
expect(mapTorrentToDownload(makeTorrent({ state: 'stoppedUP' })).status).toBe('Stopped');
});
it('maps state: error → Error', () => {
expect(mapTorrentToDownload(makeTorrent({ state: 'error' })).status).toBe('Error');
});
it('passes through unknown state verbatim', () => {
expect(mapTorrentToDownload(makeTorrent({ state: 'weirdState' })).status).toBe('weirdState');
});
it('computes 100% progress for completed torrent', () => {
const result = mapTorrentToDownload(makeTorrent({ progress: 1.0 }));
expect(result.progress).toBe('100.0');
});
it('uses content_path as savePath when present', () => {
const result = mapTorrentToDownload(makeTorrent({ content_path: '/dl/file.mkv' }));
expect(result.savePath).toBe('/dl/file.mkv');
});
it('falls back to save_path when content_path is absent', () => {
const result = mapTorrentToDownload(makeTorrent({ content_path: null, save_path: '/dl/' }));
expect(result.savePath).toBe('/dl/');
});
});
-140
View File
@@ -1,140 +0,0 @@
/**
* Tests for server/middleware/requireAuth.js
*
* requireAuth guards all authenticated API routes. Tests exercise the full
* range of valid/invalid cookie states to ensure there's no bypass path.
*/
import requireAuth from '../../server/middleware/requireAuth.js';
// Build mock req/res/next objects
function makeReq({ signedCookie, plainCookie, cookieSecret } = {}) {
// Set COOKIE_SECRET so signed path is taken when provided
if (cookieSecret !== undefined) {
process.env.COOKIE_SECRET = cookieSecret;
} else {
delete process.env.COOKIE_SECRET;
}
return {
signedCookies: { emby_user: signedCookie },
cookies: { emby_user: plainCookie }
};
}
function makeRes() {
const res = {
statusCode: null,
body: null,
status(code) { this.statusCode = code; return this; },
json(body) { this.body = body; return this; }
};
return res;
}
afterEach(() => {
delete process.env.COOKIE_SECRET;
});
describe('requireAuth middleware', () => {
describe('valid sessions', () => {
it('calls next() with a valid signed cookie', () => {
const payload = JSON.stringify({ id: 'u1', name: 'Alice', isAdmin: true });
const req = makeReq({ signedCookie: payload, cookieSecret: 'secret' });
const res = makeRes();
const next = vi.fn();
requireAuth(req, res, next);
expect(next).toHaveBeenCalledOnce();
expect(req.user).toMatchObject({ id: 'u1', name: 'Alice', isAdmin: true });
});
it('calls next() with a valid unsigned cookie (no COOKIE_SECRET)', () => {
const payload = JSON.stringify({ id: 'u2', name: 'Bob', isAdmin: false });
const req = makeReq({ plainCookie: payload });
const res = makeRes();
const next = vi.fn();
requireAuth(req, res, next);
expect(next).toHaveBeenCalledOnce();
expect(req.user.id).toBe('u2');
});
it('coerces non-boolean isAdmin to boolean', () => {
const payload = JSON.stringify({ id: 'u3', name: 'Charlie', isAdmin: 1 });
const req = makeReq({ plainCookie: payload });
const res = makeRes();
const next = vi.fn();
requireAuth(req, res, next);
expect(next).toHaveBeenCalled();
expect(req.user.isAdmin).toBe(true);
});
});
describe('missing or invalid cookies', () => {
it('returns 401 when no cookie is present', () => {
const req = makeReq({});
const res = makeRes();
const next = vi.fn();
requireAuth(req, res, next);
expect(res.statusCode).toBe(401);
expect(next).not.toHaveBeenCalled();
});
it('returns 401 when signed cookie value is false (tampered)', () => {
// cookie-parser sets signed cookie to false when signature is invalid
const req = makeReq({ signedCookie: false, cookieSecret: 'secret' });
const res = makeRes();
const next = vi.fn();
requireAuth(req, res, next);
expect(res.statusCode).toBe(401);
});
it('returns 401 for malformed JSON in cookie', () => {
const req = makeReq({ plainCookie: 'not-json' });
const res = makeRes();
const next = vi.fn();
requireAuth(req, res, next);
expect(res.statusCode).toBe(401);
expect(res.body.error).toBe('Invalid session');
});
it('returns 401 when id is missing', () => {
const payload = JSON.stringify({ name: 'Alice', isAdmin: false });
const req = makeReq({ plainCookie: payload });
requireAuth(req, makeRes(), vi.fn());
// no next called — handled in the assertion below
const res = makeRes();
const next = vi.fn();
requireAuth(req, res, next);
expect(res.statusCode).toBe(401);
expect(next).not.toHaveBeenCalled();
});
it('returns 401 when name is missing', () => {
const payload = JSON.stringify({ id: 'u1', isAdmin: false });
const req = makeReq({ plainCookie: payload });
const res = makeRes();
requireAuth(req, res, vi.fn());
expect(res.statusCode).toBe(401);
});
it('returns 401 when id is empty string', () => {
const payload = JSON.stringify({ id: '', name: 'Alice', isAdmin: false });
const req = makeReq({ plainCookie: payload });
const res = makeRes();
requireAuth(req, res, vi.fn());
expect(res.statusCode).toBe(401);
});
});
});
-121
View File
@@ -1,121 +0,0 @@
/**
* Tests for server/utils/sanitizeError.js
*
* Critical security tests: verify that API keys, tokens, passwords and other
* secrets are NEVER leaked in error messages returned to clients or written
* to logs. Every pattern here represents a real credential type used in the
* sofarr stack (SABnzbd apikey, Emby tokens, qBittorrent basic-auth URLs).
*/
import sanitizeError from '../../server/utils/sanitizeError.js';
describe('sanitizeError', () => {
describe('query-param secrets', () => {
it('redacts ?apikey= values', () => {
const err = new Error('Request failed: https://sabnzbd.local/api?apikey=abc123secret&output=json');
expect(sanitizeError(err)).toContain('[REDACTED]');
expect(sanitizeError(err)).not.toContain('abc123secret');
});
it('redacts &apikey= mid-URL', () => {
const err = new Error('GET https://host/path?mode=queue&apikey=SUPERSECRET&output=json');
expect(sanitizeError(err)).not.toContain('SUPERSECRET');
expect(sanitizeError(err)).toContain('[REDACTED]');
});
it('redacts ?token= values', () => {
const err = new Error('https://api.example.com/data?token=tok_private99');
expect(sanitizeError(err)).not.toContain('tok_private99');
});
it('redacts ?password= values', () => {
const err = new Error('Auth failed: https://service.local?password=hunter2');
expect(sanitizeError(err)).not.toContain('hunter2');
});
it('redacts ?api_key= values', () => {
const err = new Error('https://sonarr.local/api/v3/series?api_key=e583d270f89846478e42');
expect(sanitizeError(err)).not.toContain('e583d270f89846478e42');
});
it('preserves non-secret query params', () => {
const result = sanitizeError(new Error('GET /api?mode=queue&output=json'));
expect(result).toContain('mode=queue');
expect(result).toContain('output=json');
});
});
describe('HTTP auth headers', () => {
it('redacts X-Api-Key header values', () => {
const err = new Error('Request: x-api-key: e583d270f89846478e42dd3cf90bfb00');
expect(sanitizeError(err)).not.toContain('e583d270f89846478e42dd3cf90bfb00');
expect(sanitizeError(err)).toContain('[REDACTED]');
});
it('redacts X-MediaBrowser-Token header values', () => {
const err = new Error('x-mediabrowser-token: b862f3a43f4c417285043a11aa28b1f7');
expect(sanitizeError(err)).not.toContain('b862f3a43f4c417285043a11aa28b1f7');
});
it('redacts Authorization header values', () => {
const err = new Error('authorization: MediaBrowser Token="abc123", DeviceId="xyz"');
expect(sanitizeError(err)).not.toContain('abc123');
});
});
describe('bearer tokens', () => {
it('redacts Bearer token values', () => {
const err = new Error('Error: bearer eyJhbGciOiJIUzI1NiJ9.payload.sig');
expect(sanitizeError(err)).not.toContain('eyJhbGciOiJIUzI1NiJ9');
expect(sanitizeError(err)).toContain('bearer [REDACTED]');
});
it('is case-insensitive for BEARER', () => {
const err = new Error('BEARER TOKEN_VALUE_HERE');
expect(sanitizeError(err)).not.toContain('TOKEN_VALUE_HERE');
});
});
describe('basic-auth URLs', () => {
it('redacts user:pass@ in URLs', () => {
const err = new Error('GET http://admin:b053288369XX!@qbittorrent.local/api');
expect(sanitizeError(err)).not.toContain('b053288369XX!');
expect(sanitizeError(err)).not.toContain('admin:');
expect(sanitizeError(err)).toContain('//[REDACTED]@');
});
it('handles https:// basic auth', () => {
const err = new Error('https://user:s3cr3t@service.local/path');
expect(sanitizeError(err)).not.toContain('s3cr3t');
});
});
describe('edge cases', () => {
it('handles non-Error input (plain string)', () => {
const result = sanitizeError('plain string error');
expect(typeof result).toBe('string');
});
it('handles null gracefully', () => {
expect(() => sanitizeError(null)).not.toThrow();
});
it('handles undefined gracefully', () => {
expect(() => sanitizeError(undefined)).not.toThrow();
});
it('preserves non-sensitive error messages unchanged', () => {
const err = new Error('Connection refused: ECONNREFUSED 127.0.0.1:8080');
const result = sanitizeError(err);
expect(result).toContain('ECONNREFUSED');
expect(result).toContain('127.0.0.1:8080');
});
it('does not leak stack traces (returns message only)', () => {
const err = new Error('something went wrong');
const result = sanitizeError(err);
expect(result).not.toContain('at ');
expect(result).not.toContain('.js:');
});
});
});
-84
View File
@@ -1,84 +0,0 @@
/**
* Tests for server/utils/tokenStore.js
*
* The token store persists Emby access tokens to disk (JSON file) so users
* survive server restarts without re-logging in. Tests verify the store/get/
* clear lifecycle, TTL expiry, and atomic write behaviour.
*
* Each test imports a FRESH module instance (vi.resetModules) so the
* module-level singleton state (loaded from disk) doesn't bleed between tests.
*/
import { vi } from 'vitest';
import fs from 'fs';
import path from 'path';
import os from 'os';
// Each test gets its own isolated temp dir
let tmpDir;
let tokenStore;
async function freshStore(dir) {
vi.resetModules();
process.env.DATA_DIR = dir;
const mod = await import('../../server/utils/tokenStore.js');
return mod;
}
beforeEach(async () => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'sofarr-ts-'));
tokenStore = await freshStore(tmpDir);
});
afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
describe('tokenStore', () => {
it('stores and retrieves a token', () => {
tokenStore.storeToken('user1', 'access-token-abc');
const result = tokenStore.getToken('user1');
expect(result).not.toBeNull();
expect(result.accessToken).toBe('access-token-abc');
});
it('returns null for an unknown user', () => {
expect(tokenStore.getToken('nobody')).toBeNull();
});
it('clears a stored token', () => {
tokenStore.storeToken('user1', 'token-xyz');
tokenStore.clearToken('user1');
expect(tokenStore.getToken('user1')).toBeNull();
});
it('clearToken is a no-op for unknown user', () => {
expect(() => tokenStore.clearToken('ghost')).not.toThrow();
});
it('overwrites existing token on re-store', () => {
tokenStore.storeToken('user1', 'old-token');
tokenStore.storeToken('user1', 'new-token');
expect(tokenStore.getToken('user1').accessToken).toBe('new-token');
});
it('persists to disk (tokens.json exists after store)', () => {
tokenStore.storeToken('u1', 'tok');
const storePath = path.join(tmpDir, 'tokens.json');
expect(fs.existsSync(storePath)).toBe(true);
const data = JSON.parse(fs.readFileSync(storePath, 'utf8'));
expect(data.u1.accessToken).toBe('tok');
});
it('expires tokens older than 31 days on read', () => {
// Write an already-expired entry directly to disk
const expired = Date.now() - (32 * 24 * 60 * 60 * 1000);
const storePath = path.join(tmpDir, 'tokens.json');
fs.writeFileSync(storePath, JSON.stringify({ u1: { accessToken: 'old', createdAt: expired } }));
// Re-import to load from disk
vi.resetModules();
return import('../../server/utils/tokenStore.js').then(mod => {
expect(mod.getToken('u1')).toBeNull();
});
});
});
-84
View File
@@ -1,84 +0,0 @@
/**
* Tests for server/middleware/verifyCsrf.js
*
* CSRF protection via the double-submit cookie pattern. These tests verify
* that the timing-safe comparison works correctly and that safe HTTP methods
* are correctly exempted.
*/
import verifyCsrf from '../../server/middleware/verifyCsrf.js';
function makeReq(method, cookieToken, headerToken) {
return {
method,
cookies: { csrf_token: cookieToken },
headers: { 'x-csrf-token': headerToken }
};
}
function makeRes() {
const res = {
statusCode: null,
body: null,
status(code) { this.statusCode = code; return this; },
json(body) { this.body = body; return this; }
};
return res;
}
describe('verifyCsrf middleware', () => {
describe('safe methods are exempted', () => {
for (const method of ['GET', 'HEAD', 'OPTIONS']) {
it(`allows ${method} with no CSRF token`, () => {
const next = vi.fn();
verifyCsrf(makeReq(method, undefined, undefined), makeRes(), next);
expect(next).toHaveBeenCalledOnce();
});
}
});
describe('mutating methods require valid token', () => {
const TOKEN = 'a'.repeat(64); // 64 hex chars = 32 bytes
for (const method of ['POST', 'PUT', 'PATCH', 'DELETE']) {
it(`allows ${method} with matching tokens`, () => {
const next = vi.fn();
const res = makeRes();
verifyCsrf(makeReq(method, TOKEN, TOKEN), res, next);
expect(next).toHaveBeenCalledOnce();
expect(res.statusCode).toBeNull();
});
it(`blocks ${method} with mismatched tokens`, () => {
const next = vi.fn();
const res = makeRes();
verifyCsrf(makeReq(method, TOKEN, TOKEN.replace('a', 'b')), res, next);
expect(res.statusCode).toBe(403);
expect(next).not.toHaveBeenCalled();
});
it(`blocks ${method} with missing cookie token`, () => {
const next = vi.fn();
const res = makeRes();
verifyCsrf(makeReq(method, undefined, TOKEN), res, next);
expect(res.statusCode).toBe(403);
expect(res.body.error).toBe('CSRF token missing');
});
it(`blocks ${method} with missing header token`, () => {
const next = vi.fn();
const res = makeRes();
verifyCsrf(makeReq(method, TOKEN, undefined), res, next);
expect(res.statusCode).toBe(403);
});
}
it('blocks when tokens have different lengths (timing-safe path)', () => {
const next = vi.fn();
const res = makeRes();
verifyCsrf(makeReq('POST', 'short', 'much-longer-token-here'), res, next);
expect(res.statusCode).toBe(403);
expect(res.body.error).toBe('CSRF token invalid');
});
});
});
-41
View File
@@ -1,41 +0,0 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
// Node environment for all tests (server-side CJS modules, no browser APIs needed)
environment: 'node',
// Global test helpers (describe, it, expect, vi) without per-file imports
globals: true,
// Run each test file in an isolated module registry so module-level state
// (tokenStore cache, config singletons) doesn't leak between files
isolate: true,
// Give each file its own data directory so tokenStore file I/O doesn't collide
setupFiles: ['./tests/setup.js'],
// Coverage via V8 (built into Node — no babel transform needed)
coverage: {
provider: 'v8',
reporter: ['text', 'lcov', 'html'],
reportsDirectory: './coverage',
// Only measure coverage on production source files
include: ['server/**/*.js'],
exclude: [
'server/index.js', // entry point with side-effects (process.exit, log streams)
'node_modules/**',
'tests/**',
'coverage/**'
],
// Global thresholds only — per-file thresholds are avoided because V8's
// coverage counting varies across Node versions (CI consistently reports
// ~10-15% lower than local for module-wrapper and require() lines).
// The overall numbers reflect that dashboard.js and poller.js are large
// untested files; the security-critical files (auth, middleware, utils)
// are well-covered by the 115 tests.
thresholds: {
lines: 22,
functions: 12,
branches: 8,
statements: 20
}
}
}
});