Compare commits
100 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 80a6d559c9 | |||
| 55e4aedfca | |||
| 82f8fbccae | |||
| 8c829f9651 | |||
| a510fdb83c | |||
| 5fd55b4e1a | |||
| cc1e8af761 | |||
| 251c7376c9 | |||
| 8ba1ee4f56 | |||
| 37c1b64982 | |||
| 49327cf9ae | |||
| 898ca9199b | |||
| 2522bb3514 | |||
| bdbbcabfbc | |||
| 8eb49f64b6 | |||
| 6b8c215497 | |||
| 11749a428c | |||
| e83afde5ef | |||
| 031877e6a0 | |||
| 663826e295 | |||
| 14de5e4644 | |||
| 44cff5bf41 | |||
| bdfb042527 | |||
| b608fa0337 | |||
| 1f41114482 | |||
| 8fa20c6990 | |||
| d8584d0511 | |||
| 1eadb30481 | |||
| 8f96a5f296 | |||
| 6675e5dcfe | |||
| 54647ab7cf | |||
| 8b81f16dac | |||
| 1f4aa19a72 | |||
| 43839fd8e3 | |||
| 24b7797b60 | |||
| de8563704a | |||
| 83049786eb | |||
| 2137f65766 | |||
| 0ddb7a407e | |||
| 5ed547579d | |||
| 2bef9f9dee | |||
| fdecdd979b | |||
| e97bd3c67b | |||
| 0c8d5d8a4a | |||
| 268238215e | |||
| 31ff973eff | |||
| 1327c8e466 | |||
| c10d20d9f5 | |||
| d50a6fe19c | |||
| 6e3a98ae75 | |||
| 57e1db18e2 | |||
| c03e4620ea | |||
| e5b2fc8ea4 | |||
| 85bac5994e | |||
| f28d94d9a3 | |||
| 1574cce788 | |||
| b48332f075 | |||
| 3edc98b8d6 | |||
| c6b5aaf3de | |||
| c0478ed1b2 | |||
| b146a180d0 | |||
| bafa03aac2 | |||
| 59b096a60a | |||
| d09b0ab40a | |||
| 137d40affe | |||
| 84e4201dc1 | |||
| b75cd18580 | |||
| 36d183cba9 | |||
| b1f81eff0f | |||
| 78a8737f29 | |||
| d5542abd27 | |||
| 980e20247c | |||
| ba43a3d6bd | |||
| b44d370b51 | |||
| 6e81180175 | |||
| 5ae6af114e | |||
| c97f232290 | |||
| 6d35ce06e0 | |||
| 6d6969190a | |||
| fe73589633 | |||
| 6baf643645 | |||
| 570eca0b82 | |||
| b04b52e3f1 | |||
| eda9770f49 | |||
| efa66b9fd6 | |||
| fd8335b683 | |||
| 4c1b11c3cc | |||
| c6d1a7ffed | |||
| 9b0e778392 | |||
| d31f108821 | |||
| b590513f94 | |||
| ebb73492c4 | |||
| 8b526aa13b | |||
| 67b816cd61 | |||
| faaca310e9 | |||
| 0957f83411 | |||
| 6463c6b3d1 | |||
| bd9868b4e1 | |||
| 07e5b185d7 | |||
| 6140808efb |
@@ -6,6 +6,7 @@ node_modules/
|
|||||||
.gitignore
|
.gitignore
|
||||||
.DS_Store
|
.DS_Store
|
||||||
*.log
|
*.log
|
||||||
|
**/*.log
|
||||||
client/
|
client/
|
||||||
dist/
|
dist/
|
||||||
build/
|
build/
|
||||||
@@ -13,3 +14,4 @@ README.md
|
|||||||
.dockerignore
|
.dockerignore
|
||||||
Dockerfile
|
Dockerfile
|
||||||
.gitea/
|
.gitea/
|
||||||
|
docs/
|
||||||
+19
-4
@@ -1,5 +1,23 @@
|
|||||||
# Server Configuration
|
# Server Configuration
|
||||||
PORT=3001
|
PORT=3001
|
||||||
|
LOG_LEVEL=info
|
||||||
|
|
||||||
|
# Cookie signing secret for tamper-proof session cookies
|
||||||
|
# Required in production. Generate with: openssl rand -hex 32
|
||||||
|
COOKIE_SECRET=your_cookie_secret_here
|
||||||
|
|
||||||
|
# Set to 1 (or a specific IP/CIDR) when running behind a reverse proxy
|
||||||
|
# (Nginx, Caddy, Traefik) so Express trusts X-Forwarded-For/Proto.
|
||||||
|
# Leave unset if sofarr is exposed directly.
|
||||||
|
# TRUST_PROXY=1
|
||||||
|
|
||||||
|
# Directory for persistent data (SQLite token store + logs)
|
||||||
|
# Defaults to ./data relative to project root
|
||||||
|
# DATA_DIR=/app/data
|
||||||
|
|
||||||
|
# Background polling interval in ms (default: 5000)
|
||||||
|
# Set to 0 or "off" to disable and fetch on-demand instead
|
||||||
|
# POLL_INTERVAL=5000
|
||||||
|
|
||||||
# Emby Configuration (single instance)
|
# Emby Configuration (single instance)
|
||||||
EMBY_URL=http://localhost:8096
|
EMBY_URL=http://localhost:8096
|
||||||
@@ -16,7 +34,4 @@ SONARR_INSTANCES=[{"name": "Primary", "url": "http://localhost:8989", "apiKey":
|
|||||||
RADARR_INSTANCES=[{"name": "Primary", "url": "http://localhost:7878", "apiKey": "your_api_key"}]
|
RADARR_INSTANCES=[{"name": "Primary", "url": "http://localhost:7878", "apiKey": "your_api_key"}]
|
||||||
|
|
||||||
# qBittorrent Instances (JSON array)
|
# qBittorrent Instances (JSON array)
|
||||||
QBITTORRENT_INSTANCES=[
|
QBITTORRENT_INSTANCES=[{"name": "main", "url": "http://localhost:8080", "username": "admin", "password": "your_password"}]
|
||||||
{"name": "ransackedcrew", "url": "https://qbittorrent.ransackedcrew.info", "username": "gronod", "password": "K32D&JDjtHA&mC"},
|
|
||||||
{"name": "i3omb", "url": "https://qbittorrent.i3omb.com", "username": "admin", "password": "b053288369XX!"}
|
|
||||||
]
|
|
||||||
|
|||||||
+29
@@ -14,6 +14,34 @@ PORT=3001
|
|||||||
# - silent: No logging
|
# - silent: No logging
|
||||||
LOG_LEVEL=info
|
LOG_LEVEL=info
|
||||||
|
|
||||||
|
# Cookie signing secret for tamper-proof session cookies
|
||||||
|
# Required in production (server exits on startup if unset).
|
||||||
|
# Generate with: openssl rand -hex 32
|
||||||
|
COOKIE_SECRET=your-cookie-secret-here
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# REVERSE PROXY & DEPLOYMENT
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# Set to 1 when running behind a reverse proxy (Nginx, Caddy, Traefik).
|
||||||
|
# This makes Express trust X-Forwarded-For and X-Forwarded-Proto so that
|
||||||
|
# req.ip reflects the real client IP and cookies are marked secure correctly.
|
||||||
|
# Leave unset if sofarr is exposed directly to the internet.
|
||||||
|
# TRUST_PROXY=1
|
||||||
|
|
||||||
|
# Directory for persistent data (SQLite token store, server logs).
|
||||||
|
# Must be writable by the process user (UID 1000 in the container).
|
||||||
|
# Defaults to ./data relative to the project root.
|
||||||
|
# DATA_DIR=/app/data
|
||||||
|
|
||||||
|
# Background polling interval in milliseconds (default: 5000)
|
||||||
|
# sofarr polls all services in the background and caches results so
|
||||||
|
# dashboard requests are near-instant.
|
||||||
|
# Set to 0, "off", "false", or "disabled" to disable background polling.
|
||||||
|
# When disabled, data is fetched on-demand when a user opens the dashboard
|
||||||
|
# and cached for 30 seconds so other users benefit from the same fetch.
|
||||||
|
# POLL_INTERVAL=5000
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# EMBY (Authentication - Required)
|
# EMBY (Authentication - Required)
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@@ -74,4 +102,5 @@ RADARR_INSTANCES=[{"name":"main","url":"https://radarr.example.com","apiKey":"yo
|
|||||||
# 3. URLs should include protocol (http:// or https://)
|
# 3. URLs should include protocol (http:// or https://)
|
||||||
# 4. For qBittorrent, ensure Web UI is enabled in settings
|
# 4. For qBittorrent, ensure Web UI is enabled in settings
|
||||||
# 5. User downloads are matched by tags in Sonarr/Radarr - tag your media!
|
# 5. User downloads are matched by tags in Sonarr/Radarr - tag your media!
|
||||||
|
# 6. Background polling keeps data fresh; disable it for low-resource setups
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ on:
|
|||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- 'release/**'
|
- 'release/**'
|
||||||
|
- 'develop'
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
@@ -12,25 +13,31 @@ jobs:
|
|||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Extract version from package.json
|
- name: Compute image tags
|
||||||
id: version
|
id: meta
|
||||||
run: |
|
run: |
|
||||||
VERSION=$(node -p "require('./package.json').version")
|
VERSION=$(node -p "require('./package.json').version")
|
||||||
BRANCH=${GITHUB_REF#refs/heads/}
|
BRANCH=${GITHUB_REF#refs/heads/}
|
||||||
RELEASE_NAME=${BRANCH#release/}
|
|
||||||
echo "version=${VERSION}" >> $GITHUB_OUTPUT
|
echo "version=${VERSION}" >> $GITHUB_OUTPUT
|
||||||
echo "release=${RELEASE_NAME}" >> $GITHUB_OUTPUT
|
|
||||||
echo "Building version ${VERSION} from branch ${BRANCH}"
|
if [ "$BRANCH" = "develop" ]; then
|
||||||
|
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: Build and push Docker image
|
- name: Build and push Docker image
|
||||||
uses: docker/build-push-action@v5
|
uses: docker/build-push-action@v5
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
push: true
|
push: true
|
||||||
tags: |
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
reg.i3omb.com/sofarr:${{ steps.version.outputs.version }}
|
|
||||||
reg.i3omb.com/sofarr:${{ steps.version.outputs.release }}
|
|
||||||
reg.i3omb.com/sofarr:latest
|
|
||||||
labels: |
|
labels: |
|
||||||
org.opencontainers.image.version=${{ steps.version.outputs.version }}
|
org.opencontainers.image.version=${{ steps.meta.outputs.version }}
|
||||||
org.opencontainers.image.created=${{ github.event.head_commit.timestamp }}
|
org.opencontainers.image.created=${{ github.event.head_commit.timestamp }}
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
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@v4
|
||||||
|
if: always()
|
||||||
|
with:
|
||||||
|
name: coverage-report
|
||||||
|
path: coverage/
|
||||||
|
retention-days: 14
|
||||||
@@ -1,6 +1,12 @@
|
|||||||
node_modules/
|
node_modules/
|
||||||
|
coverage/
|
||||||
.env
|
.env
|
||||||
dist/
|
dist/
|
||||||
build/
|
build/
|
||||||
.DS_Store
|
.DS_Store
|
||||||
*.log
|
*.log
|
||||||
|
**/*.log
|
||||||
|
data/
|
||||||
|
*.db
|
||||||
|
*.db-wal
|
||||||
|
*.db-shm
|
||||||
|
|||||||
+35
-8
@@ -1,4 +1,18 @@
|
|||||||
FROM node:18-alpine
|
# ---------------------------------------------------------------------------
|
||||||
|
# Stage 1 — deps: install production dependencies only
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
FROM node:22-alpine AS deps
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# All dependencies are pure JavaScript — no native addons, no build tools.
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
RUN npm ci --omit=dev
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Stage 2 — runtime image (minimal attack surface)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
FROM node:22-alpine AS runtime
|
||||||
|
|
||||||
LABEL org.opencontainers.image.title="sofarr"
|
LABEL org.opencontainers.image.title="sofarr"
|
||||||
LABEL org.opencontainers.image.description="Personal media download dashboard for *arr services"
|
LABEL org.opencontainers.image.description="Personal media download dashboard for *arr services"
|
||||||
@@ -9,18 +23,31 @@ LABEL org.opencontainers.image.vendor="Gordon Bolton"
|
|||||||
LABEL org.opencontainers.image.licenses="MIT"
|
LABEL org.opencontainers.image.licenses="MIT"
|
||||||
LABEL custom.hardware.requirement="None - runs on any Docker-supported platform including ARM and x86_64"
|
LABEL custom.hardware.requirement="None - runs on any Docker-supported platform including ARM and x86_64"
|
||||||
|
|
||||||
|
# Use the built-in non-root 'node' user (UID 1000) from the official image
|
||||||
|
# The /app directory is owned by root; data directory is owned by node
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Copy package files and install dependencies
|
# Copy production deps from deps stage
|
||||||
COPY package.json package-lock.json ./
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
RUN npm ci --omit=dev
|
|
||||||
|
|
||||||
# Copy application source
|
# Copy application source owned by root (read-only at runtime)
|
||||||
COPY server/ ./server/
|
COPY --chown=root:root server/ ./server/
|
||||||
COPY public/ ./public/
|
COPY --chown=root:root public/ ./public/
|
||||||
|
COPY --chown=root:root package.json ./
|
||||||
|
|
||||||
|
# Persistent data directory owned by node user (token store, logs)
|
||||||
|
RUN mkdir -p /app/data && chown node:node /app/data
|
||||||
|
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
ENV DATA_DIR=/app/data
|
||||||
|
|
||||||
|
# Drop to non-root user for all subsequent operations
|
||||||
|
USER node
|
||||||
|
|
||||||
EXPOSE 3001
|
EXPOSE 3001
|
||||||
|
|
||||||
ENV NODE_ENV=production
|
# HEALTHCHECK — Docker will restart the container if this fails 3 times
|
||||||
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||||||
|
CMD wget -qO- http://localhost:3001/health || exit 1
|
||||||
|
|
||||||
CMD ["node", "server/index.js"]
|
CMD ["node", "server/index.js"]
|
||||||
|
|||||||
@@ -92,6 +92,26 @@ docker run -d \
|
|||||||
|
|
||||||
3. **Access the dashboard** at `http://your-server:3001`
|
3. **Access the dashboard** at `http://your-server:3001`
|
||||||
|
|
||||||
|
### Using Environment Variables (Alternative to .env file)
|
||||||
|
|
||||||
|
All configuration can be passed directly as environment variables instead of mounting a `.env` file. This is the preferred approach for orchestrated deployments (Docker Compose, Kubernetes, Portainer, etc).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run -d \
|
||||||
|
--name sofarr \
|
||||||
|
--restart unless-stopped \
|
||||||
|
-p 3001:3001 \
|
||||||
|
-e EMBY_URL=http://emby.local:8096 \
|
||||||
|
-e EMBY_API_KEY=your-emby-api-key \
|
||||||
|
-e SONARR_INSTANCES='[{"name":"main","url":"http://sonarr:8989","apiKey":"your-key"}]' \
|
||||||
|
-e RADARR_INSTANCES='[{"name":"main","url":"http://radarr:7878","apiKey":"your-key"}]' \
|
||||||
|
-e SABNZBD_INSTANCES='[{"name":"main","url":"http://sabnzbd:8080","apiKey":"your-key"}]' \
|
||||||
|
-e QBITTORRENT_INSTANCES='[{"name":"main","url":"http://qbit:8080","username":"admin","password":"pass"}]' \
|
||||||
|
-e LOG_LEVEL=info \
|
||||||
|
-e POLL_INTERVAL=5000 \
|
||||||
|
docker.i3omb.com/sofarr:latest
|
||||||
|
```
|
||||||
|
|
||||||
### Docker Compose
|
### Docker Compose
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
@@ -103,10 +123,19 @@ services:
|
|||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- "3001:3001"
|
- "3001:3001"
|
||||||
volumes:
|
environment:
|
||||||
- /opt/sofarr/.env:/app/.env
|
- EMBY_URL=http://emby:8096
|
||||||
|
- EMBY_API_KEY=your-emby-api-key
|
||||||
|
- SONARR_INSTANCES=[{"name":"main","url":"http://sonarr:8989","apiKey":"your-key"}]
|
||||||
|
- RADARR_INSTANCES=[{"name":"main","url":"http://radarr:7878","apiKey":"your-key"}]
|
||||||
|
- SABNZBD_INSTANCES=[{"name":"main","url":"http://sabnzbd:8080","apiKey":"your-key"}]
|
||||||
|
- QBITTORRENT_INSTANCES=[{"name":"main","url":"http://qbit:8080","username":"admin","password":"pass"}]
|
||||||
|
- 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.
|
||||||
|
|
||||||
### Available Tags
|
### Available Tags
|
||||||
|
|
||||||
| Tag | Description |
|
| Tag | Description |
|
||||||
@@ -154,6 +183,8 @@ Open `http://localhost:3001` in your browser
|
|||||||
```bash
|
```bash
|
||||||
PORT=3001 # Server port
|
PORT=3001 # Server port
|
||||||
LOG_LEVEL=info # Logging: debug, info, warn, error, silent
|
LOG_LEVEL=info # Logging: debug, info, warn, error, silent
|
||||||
|
POLL_INTERVAL=5000 # Background polling interval in ms (default: 5000)
|
||||||
|
# Set to 0 or "off" to disable (on-demand mode)
|
||||||
```
|
```
|
||||||
|
|
||||||
### Service Instances (JSON Array Format)
|
### Service Instances (JSON Array Format)
|
||||||
@@ -204,6 +235,18 @@ To see your downloads, you need to tag your media in Sonarr/Radarr:
|
|||||||
|
|
||||||
## Features in Detail
|
## Features in Detail
|
||||||
|
|
||||||
|
### Background Polling
|
||||||
|
|
||||||
|
sofarr polls all configured services in the background and caches the results. Dashboard requests read from cache, making them near-instant regardless of how many services you have.
|
||||||
|
|
||||||
|
| Setting | Behaviour |
|
||||||
|
|---------|----------|
|
||||||
|
| `POLL_INTERVAL=5000` (default) | Background poller fetches every 5s. Dashboard reads from cache instantly. |
|
||||||
|
| `POLL_INTERVAL=10000` | Poll every 10 seconds. |
|
||||||
|
| `POLL_INTERVAL=0` / `off` / `disabled` | No background polling. Data fetched on-demand when a user opens the dashboard, then cached for 30 seconds. |
|
||||||
|
|
||||||
|
**On-demand mode** is useful for low-resource setups. When one user's browser refreshes, the fetched data is cached and served to all other users until it expires. A user with a faster refresh rate effectively keeps the cache warm for everyone.
|
||||||
|
|
||||||
### Real-Time Updates
|
### Real-Time Updates
|
||||||
- Auto-refresh every 5 seconds (configurable: 1s, 5s, 10s, or off)
|
- Auto-refresh every 5 seconds (configurable: 1s, 5s, 10s, or off)
|
||||||
- In-place DOM updates for smooth UI (no flickering)
|
- In-place DOM updates for smooth UI (no flickering)
|
||||||
@@ -265,6 +308,17 @@ Logs are written to both console and `server.log` file.
|
|||||||
- Check qBittorrent Web UI is enabled
|
- Check qBittorrent Web UI is enabled
|
||||||
- Verify the URL includes the full path (e.g., `https://qb.example.com`)
|
- Verify the URL includes the full path (e.g., `https://qb.example.com`)
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm test # run all tests once
|
||||||
|
npm run test:watch # watch mode
|
||||||
|
npm run test:coverage # with V8 coverage report (outputs to coverage/)
|
||||||
|
npm run test:ui # interactive Vitest UI
|
||||||
|
```
|
||||||
|
|
||||||
|
115 tests across 8 test files covering the security-critical paths: auth middleware, CSRF protection, secret sanitization, config parsing, token store, and qBittorrent utilities. See [`tests/README.md`](tests/README.md) for design decisions and coverage targets.
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
+148
@@ -0,0 +1,148 @@
|
|||||||
|
# Security Policy & Hardening Guide
|
||||||
|
|
||||||
|
## Supported Versions
|
||||||
|
|
||||||
|
| Version | Supported |
|
||||||
|
|---------|-----------|
|
||||||
|
| 0.2.x | ✅ Yes |
|
||||||
|
| 0.1.x | ❌ No |
|
||||||
|
|
||||||
|
## Reporting a Vulnerability
|
||||||
|
|
||||||
|
Please **do not** open a public issue for security vulnerabilities.
|
||||||
|
Email: gordon@i3omb.com — expect acknowledgement within 48 hours.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Threat Model
|
||||||
|
|
||||||
|
sofarr is a personal dashboard intended for a small trusted group (household/team).
|
||||||
|
It proxies requests to *arr stack services using stored API keys and authenticates
|
||||||
|
users via Emby. The primary threat surface when exposed to the public internet:
|
||||||
|
|
||||||
|
| Threat | Mitigations |
|
||||||
|
|--------|-------------|
|
||||||
|
| Credential brute-force | Rate limiting (10 fails/15 min per IP), account lockout window |
|
||||||
|
| Session hijacking | HMAC-signed cookies, `httpOnly`, `secure`, `sameSite=strict`, short TTL |
|
||||||
|
| CSRF | Double-submit cookie pattern (`X-CSRF-Token` header required on all mutations) |
|
||||||
|
| API key leakage via errors | `sanitizeError()` redacts keys/tokens from all error responses and logs |
|
||||||
|
| Token theft after logout | Server-side token store; Emby token revoked on logout |
|
||||||
|
| XSS → token theft | `httpOnly` cookies; CSP with per-request nonce blocks inline injection |
|
||||||
|
| Clickjacking | `X-Frame-Options: DENY` + CSP `frame-ancestors 'none'` |
|
||||||
|
| Info disclosure via headers | Helmet v7 removes `X-Powered-By`, sets `noSniff`, `xssFilter`, etc. |
|
||||||
|
| Privilege escalation (container) | Non-root user (UID 1000), `no-new-privileges`, all caps dropped |
|
||||||
|
| Unbounded log growth | Size-based rotation: 10 MB cap, 3 rotated files kept |
|
||||||
|
| Dependency vulnerabilities | `npm audit --audit-level=high` in CI on every push |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Production Deployment Checklist
|
||||||
|
|
||||||
|
### Required
|
||||||
|
|
||||||
|
- [ ] `COOKIE_SECRET` set to a random 32-byte hex string (`openssl rand -hex 32`)
|
||||||
|
- [ ] `NODE_ENV=production`
|
||||||
|
- [ ] `TRUST_PROXY=1` set if behind a reverse proxy
|
||||||
|
- [ ] sofarr bound to `127.0.0.1` only (not `0.0.0.0`) — expose via proxy
|
||||||
|
- [ ] HTTPS enforced by the reverse proxy with a valid certificate
|
||||||
|
- [ ] Firewall rules: only 443/80 open externally; 3001 not directly exposed
|
||||||
|
|
||||||
|
### Recommended
|
||||||
|
|
||||||
|
- [ ] Reverse proxy: Nginx, Caddy, or Traefik with TLS termination
|
||||||
|
- [ ] Set `Strict-Transport-Security` at proxy level (sofarr also sends HSTS)
|
||||||
|
- [ ] `DATA_DIR` on a named Docker volume (not bind-mounted to sensitive host path)
|
||||||
|
- [ ] Rotate `COOKIE_SECRET` periodically (causes all users to re-login)
|
||||||
|
- [ ] Enable Docker's `--read-only` flag (already in `docker-compose.yaml`)
|
||||||
|
- [ ] Monitor `/health` endpoint with an uptime checker
|
||||||
|
|
||||||
|
### Docker Secrets (alternative to env vars)
|
||||||
|
|
||||||
|
For production environments that support Docker secrets, you can mount secret
|
||||||
|
files and reference them:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
secrets:
|
||||||
|
cookie_secret:
|
||||||
|
file: ./secrets/cookie_secret.txt
|
||||||
|
emby_api_key:
|
||||||
|
file: ./secrets/emby_api_key.txt
|
||||||
|
|
||||||
|
services:
|
||||||
|
sofarr:
|
||||||
|
secrets:
|
||||||
|
- cookie_secret
|
||||||
|
- emby_api_key
|
||||||
|
environment:
|
||||||
|
- COOKIE_SECRET_FILE=/run/secrets/cookie_secret
|
||||||
|
- EMBY_API_KEY_FILE=/run/secrets/emby_api_key
|
||||||
|
```
|
||||||
|
|
||||||
|
> Note: File-based secret loading requires application code support.
|
||||||
|
> Currently sofarr reads secrets from environment variables only.
|
||||||
|
> Mounting secrets as env vars (via `environment:` in compose) is the
|
||||||
|
> current supported approach.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Reverse Proxy Example (Caddy)
|
||||||
|
|
||||||
|
```caddy
|
||||||
|
sofarr.example.com {
|
||||||
|
reverse_proxy localhost:3001
|
||||||
|
header {
|
||||||
|
Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
|
||||||
|
X-Robots-Tag "noindex, nofollow"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Reverse Proxy Example (Nginx)
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
server {
|
||||||
|
listen 443 ssl;
|
||||||
|
server_name sofarr.example.com;
|
||||||
|
|
||||||
|
ssl_certificate /etc/letsencrypt/live/sofarr.example.com/fullchain.pem;
|
||||||
|
ssl_certificate_key /etc/letsencrypt/live/sofarr.example.com/privkey.pem;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://127.0.0.1:3001;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Security Headers (emitted by sofarr)
|
||||||
|
|
||||||
|
| Header | Value |
|
||||||
|
|--------|-------|
|
||||||
|
| `Content-Security-Policy` | `default-src 'self'; script-src 'self' 'nonce-…'; style-src 'self' 'nonce-…'; img-src 'self' data: blob:; object-src 'none'; frame-ancestors 'none'` |
|
||||||
|
| `Strict-Transport-Security` | `max-age=31536000; includeSubDomains; preload` (production only) |
|
||||||
|
| `X-Content-Type-Options` | `nosniff` |
|
||||||
|
| `X-Frame-Options` | `DENY` |
|
||||||
|
| `Referrer-Policy` | `strict-origin-when-cross-origin` |
|
||||||
|
| `Permissions-Policy` | `camera=(), microphone=(), geolocation=(), payment=(), usb=()` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Rate Limits
|
||||||
|
|
||||||
|
| Endpoint | Limit |
|
||||||
|
|----------|-------|
|
||||||
|
| `POST /api/auth/login` | 10 failed attempts per 15 min per IP |
|
||||||
|
| All `/api/*` routes | 300 requests per 15 min per IP |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Supply Chain
|
||||||
|
|
||||||
|
- All dependencies pinned to minor version ranges in `package.json`
|
||||||
|
- `npm audit --audit-level=high` runs in CI on every push and pull request
|
||||||
|
- `npm audit fix` should be run when vulnerabilities are reported
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
services:
|
||||||
|
sofarr:
|
||||||
|
image: docker.i3omb.com/sofarr:latest
|
||||||
|
container_name: sofarr
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:3001:3001" # bind to loopback only — expose via reverse proxy
|
||||||
|
environment:
|
||||||
|
- PORT=3001
|
||||||
|
- NODE_ENV=production
|
||||||
|
- LOG_LEVEL=info
|
||||||
|
# Set to 1 when running behind a reverse proxy (Nginx, Caddy, Traefik)
|
||||||
|
# so Express trusts X-Forwarded-For and X-Forwarded-Proto headers.
|
||||||
|
- TRUST_PROXY=1
|
||||||
|
# --- Replace placeholders with real values or use Docker secrets ---
|
||||||
|
- COOKIE_SECRET=change-me-generate-with-openssl-rand-hex-32
|
||||||
|
- EMBY_URL=https://emby.example.com
|
||||||
|
- EMBY_API_KEY=your-emby-api-key
|
||||||
|
- SONARR_INSTANCES=[{"name":"main","url":"https://sonarr.example.com","apiKey":"your-sonarr-api-key"}]
|
||||||
|
- RADARR_INSTANCES=[{"name":"main","url":"https://radarr.example.com","apiKey":"your-radarr-api-key"}]
|
||||||
|
- SABNZBD_INSTANCES=[{"name":"main","url":"https://sabnzbd.example.com","apiKey":"your-sabnzbd-api-key"}]
|
||||||
|
- QBITTORRENT_INSTANCES=[{"name":"main","url":"https://qbittorrent.example.com","username":"admin","password":"your-password"}]
|
||||||
|
volumes:
|
||||||
|
# Persistent volume for SQLite token store and log file
|
||||||
|
- sofarr-data:/app/data
|
||||||
|
# Run as the built-in non-root 'node' user (UID/GID 1000)
|
||||||
|
user: "1000:1000"
|
||||||
|
# Read-only root filesystem; only the data volume is writable
|
||||||
|
read_only: true
|
||||||
|
tmpfs:
|
||||||
|
- /tmp # Node.js needs a writable /tmp
|
||||||
|
security_opt:
|
||||||
|
- no-new-privileges:true # prevent privilege escalation via setuid binaries
|
||||||
|
cap_drop:
|
||||||
|
- ALL # drop all Linux capabilities
|
||||||
|
cap_add: [] # add back none — Node.js needs no special caps
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "wget", "-qO-", "http://localhost:3001/health"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
start_period: 10s
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
sofarr-data:
|
||||||
@@ -0,0 +1,768 @@
|
|||||||
|
# sofarr — Architecture Documentation
|
||||||
|
|
||||||
|
Comprehensive technical documentation covering the full architecture of the **sofarr** application: a personal media download dashboard that aggregates downloads from SABnzbd, Sonarr, Radarr, and qBittorrent, filtered by Emby user identity.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Table of Contents
|
||||||
|
|
||||||
|
1. [System Overview](#1-system-overview)
|
||||||
|
2. [Technology Stack](#2-technology-stack)
|
||||||
|
3. [Directory Structure](#3-directory-structure)
|
||||||
|
4. [Component Architecture](#4-component-architecture)
|
||||||
|
5. [Data Flow](#5-data-flow)
|
||||||
|
6. [Authentication & Authorisation](#6-authentication--authorisation)
|
||||||
|
7. [Background Polling & Caching](#7-background-polling--caching)
|
||||||
|
8. [Download Matching Pipeline](#8-download-matching-pipeline)
|
||||||
|
9. [API Reference](#9-api-reference)
|
||||||
|
10. [Frontend Architecture](#10-frontend-architecture)
|
||||||
|
11. [Configuration](#11-configuration)
|
||||||
|
12. [Deployment](#12-deployment)
|
||||||
|
13. [UML Diagrams (PlantUML)](#13-uml-diagrams-plantuml)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. System Overview
|
||||||
|
|
||||||
|
sofarr is a **single-page web application** with a Node.js/Express backend. It provides a personalised view of media downloads by:
|
||||||
|
|
||||||
|
1. **Authenticating** users against an Emby/Jellyfin media server.
|
||||||
|
2. **Aggregating** download data from multiple *arr service instances and download clients.
|
||||||
|
3. **Filtering** downloads by user — each user only sees media tagged with their username in Sonarr/Radarr.
|
||||||
|
4. **Presenting** a real-time dashboard with progress, speeds, cover art, and status.
|
||||||
|
|
||||||
|
Admin users can view all users' downloads, see server status, cache statistics, and poll timings.
|
||||||
|
|
||||||
|
### High-Level Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────┐
|
||||||
|
│ Browser (SPA) │
|
||||||
|
│ ┌──────────┐ ┌──────────┐ ┌───────────────────┐ │
|
||||||
|
│ │ Login │ │Dashboard │ │ Status Panel │ │
|
||||||
|
│ │ Form │ │ Cards │ │ (Admin only) │ │
|
||||||
|
│ └────┬─────┘ └────┬─────┘ └───────┬───────────┘ │
|
||||||
|
│ │ │ │ │
|
||||||
|
└───────┼──────────────┼────────────────┼──────────────┘
|
||||||
|
│ POST /login │ GET /user- │ GET /status
|
||||||
|
│ │ downloads │
|
||||||
|
▼ ▼ ▼
|
||||||
|
┌─────────────────────────────────────────────────────┐
|
||||||
|
│ Express Server (:3001) │
|
||||||
|
│ ┌────────┐ ┌──────────┐ ┌────────┐ ┌────────────┐ │
|
||||||
|
│ │ Auth │ │Dashboard │ │ Emby │ │ Static │ │
|
||||||
|
│ │ Routes │ │ Routes │ │ Routes │ │ Files │ │
|
||||||
|
│ └────┬───┘ └────┬─────┘ └────┬───┘ └────────────┘ │
|
||||||
|
│ │ │ │ │
|
||||||
|
│ ┌────┴──────────┴────────────┴──────────────────┐ │
|
||||||
|
│ │ Utilities Layer │ │
|
||||||
|
│ │ ┌────────┐ ┌────────┐ ┌──────┐ ┌──────────┐ │ │
|
||||||
|
│ │ │ Poller │ │ Cache │ │Config│ │qBittorrent│ │ │
|
||||||
|
│ │ └───┬────┘ └────────┘ └──────┘ └──────────┘ │ │
|
||||||
|
│ └──────┼────────────────────────────────────────┘ │
|
||||||
|
└─────────┼────────────────────────────────────────────┘
|
||||||
|
│ HTTP/API calls
|
||||||
|
▼
|
||||||
|
┌──────────────────────────────────────────────────────┐
|
||||||
|
│ External Services │
|
||||||
|
│ ┌──────────┐ ┌────────┐ ┌────────┐ ┌────────────┐ │
|
||||||
|
│ │ SABnzbd │ │ Sonarr │ │ Radarr │ │qBittorrent │ │
|
||||||
|
│ │ (Usenet) │ │ (TV) │ │(Movie) │ │ (Torrent) │ │
|
||||||
|
│ └──────────┘ └────────┘ └────────┘ └────────────┘ │
|
||||||
|
│ ┌──────────────────────────────────────────────┐ │
|
||||||
|
│ │ Emby / Jellyfin │ │
|
||||||
|
│ │ (Authentication + User DB) │ │
|
||||||
|
│ └──────────────────────────────────────────────┘ │
|
||||||
|
└──────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Technology Stack
|
||||||
|
|
||||||
|
### Runtime & Framework
|
||||||
|
|
||||||
|
| Layer | Technology | Purpose |
|
||||||
|
|-------|-----------|------|
|
||||||
|
| **Runtime** | Node.js 22 (Alpine) | Server runtime |
|
||||||
|
| **Framework** | Express 4.x | HTTP server, routing, middleware |
|
||||||
|
| **HTTP Client** | axios 1.x | External API communication |
|
||||||
|
| **Frontend** | Vanilla JS + CSS | Single-page app, no build step |
|
||||||
|
| **Containerisation** | Docker multi-stage (Alpine) | Production deployment |
|
||||||
|
| **Logging** | Custom logger + `console.*` | File + stdout logging with levels |
|
||||||
|
|
||||||
|
### Security Middleware
|
||||||
|
|
||||||
|
| Package | Purpose |
|
||||||
|
|---------|--------|
|
||||||
|
| `helmet` 7.x | HTTP security headers (CSP with per-request nonce, HSTS, referrer policy) |
|
||||||
|
| `express-rate-limit` 7.x | 300 req/15 min general limiter; 10 failed attempts/15 min login limiter |
|
||||||
|
| `cookie-parser` 1.x | Signed cookie support (HMAC via `COOKIE_SECRET`) |
|
||||||
|
|
||||||
|
### Auth & Session
|
||||||
|
|
||||||
|
| Component | Technology | Details |
|
||||||
|
|-----------|-----------|--------|
|
||||||
|
| **Identity** | Emby API | `POST /Users/authenticatebyname` |
|
||||||
|
| **Session cookie** | `httpOnly` + `sameSite: strict` | Signed when `COOKIE_SECRET` set |
|
||||||
|
| **CSRF protection** | Double-submit cookie pattern | `csrf_token` cookie + `X-CSRF-Token` header |
|
||||||
|
| **Token store** | JSON file (`DATA_DIR/tokens.json`) | Atomic writes, 31-day TTL, hourly pruning |
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
|
||||||
|
| Tool | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `vitest` 4.x | Test runner (V8 coverage built-in) |
|
||||||
|
| `supertest` 7.x | HTTP integration testing |
|
||||||
|
| `nock` 14.x | HTTP interception at Node layer (works with CJS `require('axios')`) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Directory Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
sofarr/
|
||||||
|
├── server/ # Backend application
|
||||||
|
│ ├── index.js # Entry point: logging setup, server listen, poller start
|
||||||
|
│ ├── app.js # Express app factory (imported by index.js and tests)
|
||||||
|
│ ├── routes/
|
||||||
|
│ │ ├── auth.js # POST /login, GET /me, GET /csrf, POST /logout
|
||||||
|
│ │ ├── dashboard.js # GET /user-downloads, /user-summary, /status, /cover-art
|
||||||
|
│ │ ├── emby.js # Proxy routes to Emby API
|
||||||
|
│ │ ├── sabnzbd.js # Proxy routes to SABnzbd API
|
||||||
|
│ │ ├── sonarr.js # Proxy routes to Sonarr API
|
||||||
|
│ │ └── radarr.js # Proxy routes to Radarr API
|
||||||
|
│ ├── middleware/
|
||||||
|
│ │ ├── requireAuth.js # httpOnly cookie auth enforcement
|
||||||
|
│ │ └── verifyCsrf.js # CSRF double-submit cookie validation
|
||||||
|
│ └── utils/
|
||||||
|
│ ├── cache.js # MemoryCache class (Map + TTL + stats)
|
||||||
|
│ ├── config.js # Multi-instance service configuration parser
|
||||||
|
│ ├── logger.js # File logger (DATA_DIR/server.log)
|
||||||
|
│ ├── poller.js # Background polling engine + timing
|
||||||
|
│ ├── qbittorrent.js # qBittorrent client with auth + torrent mapping
|
||||||
|
│ ├── sanitizeError.js # Redacts secrets from error messages before logging
|
||||||
|
│ └── tokenStore.js # JSON file-backed Emby token store (atomic, TTL)
|
||||||
|
├── public/ # Static frontend (served by Express)
|
||||||
|
│ ├── index.html # HTML shell: splash, login, dashboard
|
||||||
|
│ ├── app.js # All frontend logic (auth, rendering, status)
|
||||||
|
│ ├── style.css # Themes, layout, responsive design
|
||||||
|
│ ├── favicon.ico # Multi-size favicon (16/32/48px)
|
||||||
|
│ ├── favicon-32.png # 32px PNG favicon
|
||||||
|
│ ├── favicon-192.png # 192px PNG (apple-touch-icon / PWA)
|
||||||
|
│ └── images/ # Logo / splash screen assets
|
||||||
|
├── tests/
|
||||||
|
│ ├── README.md # Testing approach, design decisions, coverage targets
|
||||||
|
│ ├── setup.js # Global setup: isolated DATA_DIR, rate-limit bypass
|
||||||
|
│ ├── unit/ # Pure unit tests (no HTTP)
|
||||||
|
│ └── integration/ # Supertest integration tests (nock for external HTTP)
|
||||||
|
├── docs/
|
||||||
|
│ ├── ARCHITECTURE.md # This document
|
||||||
|
│ └── diagrams/ # PlantUML source files
|
||||||
|
├── .gitea/workflows/
|
||||||
|
│ ├── ci.yml # Security audit + test/coverage CI jobs
|
||||||
|
│ ├── build-image.yml # Docker image build and push
|
||||||
|
│ └── create-release.yml # Release tagging workflow
|
||||||
|
├── Dockerfile # Multi-stage production container image (node:22-alpine)
|
||||||
|
├── docker-compose.yaml # Example compose deployment
|
||||||
|
├── vitest.config.js # Test runner configuration with per-file coverage thresholds
|
||||||
|
├── package.json # Dependencies and scripts
|
||||||
|
├── .env.sample # Annotated environment variable template
|
||||||
|
└── README.md # User-facing documentation
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Component Architecture
|
||||||
|
|
||||||
|
### 4.1 Application Factory (`server/app.js`) and Entry Point (`server/index.js`)
|
||||||
|
|
||||||
|
**`server/app.js`** is a `createApp(options?)` factory that builds and returns the configured Express `app` instance. It is imported by both `server/index.js` (production) and the test suite. Keeping app creation separate from `app.listen()` means tests get a clean instance without triggering log-file setup, `process.exit()` calls, or the background poller.
|
||||||
|
|
||||||
|
`createApp` responsibilities:
|
||||||
|
- Configure `trust proxy` from `TRUST_PROXY` env var
|
||||||
|
- Apply `helmet` with a per-request CSP nonce (`crypto.randomBytes(16)`) for inline styles/scripts
|
||||||
|
- Add `Permissions-Policy` header
|
||||||
|
- Apply the general API rate limiter (300 req / 15 min per IP)
|
||||||
|
- Mount `cookie-parser` (signed when `COOKIE_SECRET` is set)
|
||||||
|
- Mount `express.json` (64 KB body limit)
|
||||||
|
- Expose `/health` and `/ready` endpoints (no auth, no rate limit)
|
||||||
|
- Mount `/api/auth` routes **before** CSRF middleware (login/logout are exempt)
|
||||||
|
- Mount `verifyCsrf` for all subsequent `/api` routes
|
||||||
|
- Mount remaining route modules under `/api/*`
|
||||||
|
- Register global error handler (500 with sanitized message)
|
||||||
|
|
||||||
|
**`server/index.js`** entry point responsibilities:
|
||||||
|
- Load `.env` via `dotenv`
|
||||||
|
- Configure structured logging (`LOG_LEVEL`) with `console.*` redirection to both stdout and `DATA_DIR/server.log`
|
||||||
|
- Call `createApp()`, serve `public/` as static files, start `app.listen()`
|
||||||
|
- Start the background poller
|
||||||
|
|
||||||
|
### 4.2 Route Modules
|
||||||
|
|
||||||
|
| Module | Mount Point | Auth Required | CSRF Required | Purpose |
|
||||||
|
|--------|------------|:-------------:|:-------------:|--------|
|
||||||
|
| `auth.js` | `/api/auth` | No | No | Login, session check, CSRF token, logout |
|
||||||
|
| `dashboard.js` | `/api/dashboard` | Yes (`requireAuth`) | Yes | Aggregated download data, status, cover-art proxy |
|
||||||
|
| `emby.js` | `/api/emby` | Yes (`requireAuth`) | Yes | Proxy to Emby API |
|
||||||
|
| `sabnzbd.js` | `/api/sabnzbd` | Yes (`requireAuth`) | Yes | Proxy to SABnzbd API |
|
||||||
|
| `sonarr.js` | `/api/sonarr` | Yes (`requireAuth`) | Yes | Proxy to Sonarr API |
|
||||||
|
| `radarr.js` | `/api/radarr` | Yes (`requireAuth`) | Yes | Proxy to Radarr API |
|
||||||
|
|
||||||
|
**`requireAuth`** (`server/middleware/requireAuth.js`) reads the `emby_user` cookie (signed if `COOKIE_SECRET` is set) and attaches the parsed `{ id, name, isAdmin }` user to `req.user`. Returns `401` if the cookie is absent, tampered, or schema-invalid.
|
||||||
|
|
||||||
|
**`verifyCsrf`** (`server/middleware/verifyCsrf.js`) implements the double-submit cookie pattern for all state-changing requests (`POST`, `PUT`, `PATCH`, `DELETE`). Compares the `csrf_token` cookie against the `X-CSRF-Token` request header using `crypto.timingSafeEqual`. Safe methods (`GET`, `HEAD`, `OPTIONS`) are exempt. Auth routes are mounted **before** this middleware (login/logout are exempt by design — `sameSite: strict` provides equivalent protection).
|
||||||
|
|
||||||
|
> **Note:** The proxy routes (`emby`, `sabnzbd`, `sonarr`, `radarr`) exist for direct API access. The dashboard itself reads from the poller cache — it does not proxy through these routes.
|
||||||
|
|
||||||
|
### 4.3 Utility Modules
|
||||||
|
|
||||||
|
**`config.js`** — Parses service instances from environment variables. Supports both JSON array format (`SONARR_INSTANCES=[{...}]`) and legacy single-instance format (`SONARR_URL` + `SONARR_API_KEY`). Each instance gets an `id` derived from `name` or index.
|
||||||
|
|
||||||
|
**`cache.js`** — Singleton `MemoryCache` backed by a `Map`. Each entry has an expiration timestamp. Provides `get`, `set`, `invalidate`, `clear`, and `getStats` (returns per-key size, item count, TTL remaining).
|
||||||
|
|
||||||
|
**`poller.js`** — Background polling engine. Fetches data from all configured service instances in parallel with per-task timing. Stores results in the cache with a configurable TTL. Can be disabled entirely (`POLL_INTERVAL=0`), in which case data is fetched on-demand per dashboard request.
|
||||||
|
|
||||||
|
**`qbittorrent.js`** — `QBittorrentClient` class with cookie-based authentication, automatic re-auth on 403, and persistent client instances. Includes torrent-to-download mapping (`mapTorrentToDownload`) and formatting utilities (`formatBytes`, `formatSpeed`, `formatEta`).
|
||||||
|
|
||||||
|
**`tokenStore.js`** — JSON file-backed store (`DATA_DIR/tokens.json`) for Emby `AccessToken`s. Tokens are stored server-side and **never sent to the client**. Writes are atomic (write to `.tmp` then rename). Entries expire after 31 days (slightly longer than the maximum 30-day cookie). Pruning runs on startup and hourly.
|
||||||
|
|
||||||
|
**`sanitizeError.js`** — Redacts secrets from error message strings before they are logged or returned in API responses. Patterns: URL query-param secrets (`apikey=`, `token=`, etc.), HTTP auth headers (`Authorization:`, `X-Emby-Authorization:`, etc.), Bearer tokens, and basic-auth credentials in URLs.
|
||||||
|
|
||||||
|
**`logger.js`** — Simple file appender writing timestamped messages to `DATA_DIR/server.log`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Data Flow
|
||||||
|
|
||||||
|
### 5.1 Polling Cycle
|
||||||
|
|
||||||
|
Every `POLL_INTERVAL` ms (default 5000), the poller fetches from all services in parallel:
|
||||||
|
|
||||||
|
| Task | API Call | Params |
|
||||||
|
|------|----------|--------|
|
||||||
|
| SABnzbd Queue | `GET /api?mode=queue` | `output=json` |
|
||||||
|
| SABnzbd History | `GET /api?mode=history` | `limit=10` |
|
||||||
|
| Sonarr Tags | `GET /api/v3/tag` | — |
|
||||||
|
| Sonarr Queue | `GET /api/v3/queue` | `includeSeries=true` |
|
||||||
|
| Sonarr History | `GET /api/v3/history` | `pageSize=10` |
|
||||||
|
| Radarr Queue | `GET /api/v3/queue` | `includeMovie=true` |
|
||||||
|
| Radarr History | `GET /api/v3/history` | `pageSize=10` |
|
||||||
|
| Radarr Tags | `GET /api/v3/tag` | — |
|
||||||
|
| qBittorrent | `GET /api/v2/torrents/info` | — |
|
||||||
|
|
||||||
|
Results are stored in the cache under `poll:*` keys with a TTL of `POLL_INTERVAL × 3`.
|
||||||
|
|
||||||
|
### 5.2 Dashboard Request
|
||||||
|
|
||||||
|
When a user requests `/api/dashboard/user-downloads`:
|
||||||
|
|
||||||
|
1. Read all `poll:*` keys from cache
|
||||||
|
2. Build `seriesMap` and `moviesMap` from embedded objects in queue records
|
||||||
|
3. Build `sonarrTagMap` and `radarrTagMap` from tag data
|
||||||
|
4. For each SABnzbd queue slot → try to match against Sonarr/Radarr queue records by title
|
||||||
|
5. For each SABnzbd history slot → try to match against Sonarr/Radarr history records
|
||||||
|
6. For each qBittorrent torrent → try to match against Sonarr/Radarr queue, then history
|
||||||
|
7. For each match, resolve the series/movie, extract the user tag, check if it belongs to the requesting user
|
||||||
|
8. Return only the user's downloads (or all, if admin with `showAll=true`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Authentication & Authorisation
|
||||||
|
|
||||||
|
### Flow
|
||||||
|
|
||||||
|
1. User submits credentials (+ optional `rememberMe`) via the login form
|
||||||
|
2. Backend applies the **login rate limiter** (max 10 failed attempts per IP per 15-minute window; successful requests don't count)
|
||||||
|
3. Backend calls Emby `POST /Users/authenticatebyname` using a deterministic `DeviceId` derived from the username — this causes Emby to reuse the existing session rather than creating a new one on every login
|
||||||
|
4. On success, fetches full user profile (`GET /Users/{id}`) to determine admin status
|
||||||
|
5. Stores the Emby `AccessToken` in **`tokenStore`** (server-side, never sent to the client)
|
||||||
|
6. Sets an `httpOnly` `emby_user` cookie containing only `{ id, name, isAdmin }`:
|
||||||
|
- **`rememberMe: true`** → persistent cookie, `Max-Age` 30 days
|
||||||
|
- **`rememberMe: false`** → session cookie (no `Max-Age`; expires when browser closes)
|
||||||
|
- `secure` flag enabled when `NODE_ENV=production`
|
||||||
|
- Signed with HMAC when `COOKIE_SECRET` is set
|
||||||
|
7. Issues a `csrf_token` cookie (`httpOnly: false` so JS can read it) containing a 32-byte random hex token
|
||||||
|
8. Returns `{ success: true, user, csrfToken }` — the SPA stores `csrfToken` in memory and sends it as `X-CSRF-Token` on all subsequent state-changing requests
|
||||||
|
9. All subsequent dashboard requests read the `emby_user` cookie for identity via `requireAuth`
|
||||||
|
|
||||||
|
### Authorisation Matrix
|
||||||
|
|
||||||
|
| Feature | Regular User | Admin |
|
||||||
|
|---------|:----------:|:-----:|
|
||||||
|
| View own downloads | ✓ | ✓ |
|
||||||
|
| View all users' downloads | ✗ | ✓ (`showAll`) |
|
||||||
|
| See download/target paths | ✗ | ✓ |
|
||||||
|
| See Sonarr/Radarr links | ✗ | ✓ |
|
||||||
|
| View status panel | ✗ | ✓ |
|
||||||
|
|
||||||
|
### Tag Matching
|
||||||
|
|
||||||
|
Users are matched to downloads via tags in Sonarr/Radarr:
|
||||||
|
|
||||||
|
1. **Exact match**: tag label (lowercased) === username (lowercased)
|
||||||
|
2. **Sanitised match**: handles Ombi's tag mangling — `sanitizeTagLabel()` converts to lowercase, replaces non-alphanumeric with hyphens, collapses, trims
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Background Polling & Caching
|
||||||
|
|
||||||
|
### Polling Modes
|
||||||
|
|
||||||
|
| Mode | `POLL_INTERVAL` | Behaviour |
|
||||||
|
|------|----------------|-----------|
|
||||||
|
| **Background** | `> 0` (e.g. `5000`) | Periodic fetch every N ms |
|
||||||
|
| **On-demand** | `0` / `off` / `false` | Fetch triggered by first user request when cache is empty |
|
||||||
|
|
||||||
|
### Cache Keys
|
||||||
|
|
||||||
|
| Key | Content | Source |
|
||||||
|
|-----|---------|--------|
|
||||||
|
| `poll:sab-queue` | `{ slots, status, speed, kbpersec }` | SABnzbd queue |
|
||||||
|
| `poll:sab-history` | `{ slots }` | SABnzbd history |
|
||||||
|
| `poll:sonarr-tags` | `[{ instance, data: [{id, label}] }]` | Sonarr tag API |
|
||||||
|
| `poll:sonarr-queue` | `{ records }` — includes embedded `series` objects | Sonarr queue (includeSeries) |
|
||||||
|
| `poll:sonarr-history` | `{ records }` — lightweight, no embedded objects | Sonarr history |
|
||||||
|
| `poll:radarr-queue` | `{ records }` — includes embedded `movie` objects | Radarr queue (includeMovie) |
|
||||||
|
| `poll:radarr-history` | `{ records }` — lightweight, no embedded objects | Radarr history |
|
||||||
|
| `poll:radarr-tags` | `[{id, label}]` | Radarr tag API |
|
||||||
|
| `poll:qbittorrent` | `[torrent, ...]` | qBittorrent all torrents |
|
||||||
|
| `emby:users` | `Map<lowerName, displayName>` | Full Emby user list (60s TTL) |
|
||||||
|
|
||||||
|
### TTL Strategy
|
||||||
|
|
||||||
|
- **Polling enabled**: `POLL_INTERVAL × 3` — ensures data survives between polls even if one poll is slow
|
||||||
|
- **Polling disabled**: `30000` ms — stale after 30s, next request triggers a fresh fetch
|
||||||
|
|
||||||
|
### Active Client Tracking
|
||||||
|
|
||||||
|
Each dashboard request reports the client's refresh rate. The server tracks active clients in a `Map<username, { user, refreshRateMs, lastSeen }>`, pruning entries older than 30 seconds. This data powers the admin status panel's "effective refresh mode" display.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Download Matching Pipeline
|
||||||
|
|
||||||
|
The core logic in `dashboard.js` matches raw download client data to *arr service metadata to determine which user each download belongs to.
|
||||||
|
|
||||||
|
### Matching Strategy
|
||||||
|
|
||||||
|
For each download item (SABnzbd slot or qBittorrent torrent):
|
||||||
|
|
||||||
|
```
|
||||||
|
1. Try Sonarr QUEUE match (by title substring)
|
||||||
|
→ resolve series via seriesMap (embedded in queue record)
|
||||||
|
→ extract user tag → check tag matches requesting user
|
||||||
|
|
||||||
|
2. Try Radarr QUEUE match (by title substring)
|
||||||
|
→ resolve movie via moviesMap (embedded in queue record)
|
||||||
|
→ extract user tag → check tag matches requesting user
|
||||||
|
|
||||||
|
3. Try Sonarr HISTORY match (by title substring)
|
||||||
|
→ resolve series via seriesMap (from queue) using seriesId
|
||||||
|
→ extract user tag → check tag matches requesting user
|
||||||
|
|
||||||
|
4. Try Radarr HISTORY match (by title substring)
|
||||||
|
→ resolve movie via moviesMap (from queue) using movieId
|
||||||
|
→ extract user tag → check tag matches requesting user
|
||||||
|
```
|
||||||
|
|
||||||
|
### Title Matching
|
||||||
|
|
||||||
|
Matches are **bidirectional substring matches** (case-insensitive):
|
||||||
|
```javascript
|
||||||
|
rTitle.includes(downloadTitle) || downloadTitle.includes(rTitle)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Download Object Structure
|
||||||
|
|
||||||
|
Each matched download produces an object with:
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
|-------|------|-------------|
|
||||||
|
| `type` | `'series'` / `'movie'` / `'torrent'` | Media type |
|
||||||
|
| `title` | string | Raw download title |
|
||||||
|
| `coverArt` | string / null | Poster URL from *arr |
|
||||||
|
| `status` | string | Download status |
|
||||||
|
| `progress` | string | Percentage complete |
|
||||||
|
| `size` / `mb` / `mbmissing` | string / number | Size info |
|
||||||
|
| `speed` | string | Current download speed |
|
||||||
|
| `eta` | string | Estimated time remaining |
|
||||||
|
| `seriesName` / `movieName` | string | Friendly media title |
|
||||||
|
| `episodeInfo` / `movieInfo` | object | Full *arr queue/history record |
|
||||||
|
| `allTags` | string[] | All resolved tag labels on the series/movie |
|
||||||
|
| `matchedUserTag` | string / null | Tag label matching the requesting user, or `null` |
|
||||||
|
| `tagBadges` | `{label, matchedUser}[]` / undefined | (Admin `showAll` only) Each tag classified against full Emby user list |
|
||||||
|
| `importIssues` | string[] / null | Import warning/error messages |
|
||||||
|
| `downloadPath` | string / null | (Admin) Download client path |
|
||||||
|
| `targetPath` | string / null | (Admin) *arr target path |
|
||||||
|
| `arrLink` | string / null | (Admin) Link to *arr web UI |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. API Reference
|
||||||
|
|
||||||
|
### `POST /api/auth/login`
|
||||||
|
|
||||||
|
Authenticate a user via Emby. Rate-limited to **10 failed attempts per IP per 15 minutes**.
|
||||||
|
|
||||||
|
**Request Body:**
|
||||||
|
```json
|
||||||
|
{ "username": "string", "password": "string", "rememberMe": false }
|
||||||
|
```
|
||||||
|
|
||||||
|
| Field | Required | Description |
|
||||||
|
|-------|:--------:|-----------|
|
||||||
|
| `username` | Yes | Max 128 chars, must be a non-empty string |
|
||||||
|
| `password` | Yes | Max 256 chars |
|
||||||
|
| `rememberMe` | No | `true` = 30-day persistent cookie; `false`/omitted = session cookie |
|
||||||
|
|
||||||
|
**Response (200):**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"user": { "id": "string", "name": "string", "isAdmin": false },
|
||||||
|
"csrfToken": "64-char hex string"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response (400):** Invalid input (empty/overlong username or password).
|
||||||
|
|
||||||
|
**Response (401):**
|
||||||
|
```json
|
||||||
|
{ "success": false, "error": "Invalid username or password" }
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response (429):** Too many failed attempts from this IP.
|
||||||
|
|
||||||
|
**Side Effects:**
|
||||||
|
- Sets `emby_user` cookie: `httpOnly`, `sameSite: strict`, `secure` in production, signed if `COOKIE_SECRET` is set. Payload: `{ id, name, isAdmin }`. The Emby `AccessToken` is **never** included.
|
||||||
|
- Sets `csrf_token` cookie: `httpOnly: false` (JS-readable), same security flags. 64-char hex.
|
||||||
|
- Stores the Emby `AccessToken` server-side in `tokenStore` (used only for server-side Emby logout).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `GET /api/auth/me`
|
||||||
|
|
||||||
|
Check current session (no auth required — returns unauthenticated state rather than 401).
|
||||||
|
|
||||||
|
**Response (authenticated):**
|
||||||
|
```json
|
||||||
|
{ "authenticated": true, "user": { "id": "string", "name": "string", "isAdmin": false } }
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response (not authenticated):**
|
||||||
|
```json
|
||||||
|
{ "authenticated": false }
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `GET /api/auth/csrf`
|
||||||
|
|
||||||
|
Issue a fresh CSRF token. Used by the SPA after a page reload when the in-memory `csrfToken` variable is lost.
|
||||||
|
|
||||||
|
**Response (200):**
|
||||||
|
```json
|
||||||
|
{ "csrfToken": "64-char hex string" }
|
||||||
|
```
|
||||||
|
|
||||||
|
**Side Effect:** Sets a new `csrf_token` cookie.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `POST /api/auth/logout`
|
||||||
|
|
||||||
|
Clear session and revoke the Emby token server-side. Does **not** require a CSRF token (auth routes are mounted before `verifyCsrf`; `sameSite: strict` provides equivalent protection).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `GET /api/dashboard/user-downloads`
|
||||||
|
|
||||||
|
Fetch downloads for the authenticated user.
|
||||||
|
|
||||||
|
**Query Parameters:**
|
||||||
|
| Param | Type | Description |
|
||||||
|
|-------|------|-------------|
|
||||||
|
| `showAll` | `"true"` | (Admin) Show all users' downloads |
|
||||||
|
| `refreshRate` | number (ms) | Client's current refresh rate for tracking |
|
||||||
|
|
||||||
|
**Response (200):**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"user": "string",
|
||||||
|
"isAdmin": true,
|
||||||
|
"downloads": [ /* download objects */ ]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `GET /api/dashboard/status`
|
||||||
|
|
||||||
|
Admin-only server status.
|
||||||
|
|
||||||
|
**Response (200):**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"server": {
|
||||||
|
"uptimeSeconds": 3600,
|
||||||
|
"nodeVersion": "v18.19.0",
|
||||||
|
"memoryUsageMB": 45.2,
|
||||||
|
"heapUsedMB": 28.1,
|
||||||
|
"heapTotalMB": 35.0
|
||||||
|
},
|
||||||
|
"polling": {
|
||||||
|
"enabled": true,
|
||||||
|
"intervalMs": 5000,
|
||||||
|
"lastPoll": {
|
||||||
|
"totalMs": 1234,
|
||||||
|
"timestamp": "2026-05-16T00:00:00.000Z",
|
||||||
|
"tasks": [
|
||||||
|
{ "label": "SABnzbd Queue", "ms": 120 },
|
||||||
|
{ "label": "Sonarr Queue", "ms": 890 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"cache": {
|
||||||
|
"entryCount": 9,
|
||||||
|
"totalSizeBytes": 51200,
|
||||||
|
"entries": [
|
||||||
|
{ "key": "poll:sab-queue", "sizeBytes": 1024, "itemCount": 3, "ttlRemainingMs": 12000, "expired": false }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"clients": [
|
||||||
|
{ "user": "Alice", "refreshRateMs": 5000, "lastSeen": 1715817600000 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `GET /api/dashboard/user-summary`
|
||||||
|
|
||||||
|
Admin-only per-user download counts (fetches live from APIs, not cached).
|
||||||
|
|
||||||
|
**Response (200):**
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{ "username": "Alice", "seriesCount": 12, "movieCount": 5 }
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Frontend Architecture
|
||||||
|
|
||||||
|
The frontend is a **vanilla JavaScript SPA** with no build step. All logic resides in `app.js`, styled by `style.css`, and structured by `index.html`.
|
||||||
|
|
||||||
|
### UI States
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
|
||||||
|
│ Splash Screen│────▶│ Login Form │────▶│ Dashboard │
|
||||||
|
│ (on load) │ │ (if no │ │ (after auth) │
|
||||||
|
│ │ │ session) │ │ │
|
||||||
|
└──────────────┘ └──────────────┘ └──────────────┘
|
||||||
|
│
|
||||||
|
┌─────┴─────┐
|
||||||
|
│ Status │
|
||||||
|
│ Panel │
|
||||||
|
│ (admin) │
|
||||||
|
└───────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### Key Frontend Functions
|
||||||
|
|
||||||
|
| Function | Purpose |
|
||||||
|
|----------|---------|
|
||||||
|
| `checkAuthentication()` | On load: check session → show dashboard or login |
|
||||||
|
| `handleLogin()` | Authenticate, fade login → splash → dashboard |
|
||||||
|
| `fetchUserDownloads()` | GET `/user-downloads`, update state, re-render |
|
||||||
|
| `renderDownloads()` | Diff-based card rendering (create/update/remove) |
|
||||||
|
| `createDownloadCard()` | Build DOM for a single download card; renders tag badges |
|
||||||
|
| `updateDownloadCard()` | Update existing card in-place (progress, speed, etc.) |
|
||||||
|
| `toggleStatusPanel()` | Show/hide admin status panel |
|
||||||
|
| `renderStatusPanel()` | Build status HTML (server, polling, cache, clients) |
|
||||||
|
| `startAutoRefresh()` | Start periodic `fetchUserDownloads` |
|
||||||
|
| `initThemeSwitcher()` | Light / Dark / Mono theme support |
|
||||||
|
|
||||||
|
### Themes
|
||||||
|
|
||||||
|
Three CSS themes via `data-theme` attribute on `<html>`:
|
||||||
|
- **Light** — Purple gradient header, white cards
|
||||||
|
- **Dark** — Dark surfaces, muted accents
|
||||||
|
- **Mono** — Monochrome, minimal colour
|
||||||
|
|
||||||
|
Theme selection persists in `localStorage`.
|
||||||
|
|
||||||
|
### Tag Badge Rendering
|
||||||
|
|
||||||
|
Download cards render tag badges in the card header:
|
||||||
|
|
||||||
|
- **Normal user view**: a single accent-coloured badge showing the tag label that matched the current user's username (via `matchedUserTag`).
|
||||||
|
- **Admin `showAll` view**: all tags on the download are rendered using `tagBadges[]`:
|
||||||
|
- Tags with **no matching Emby user** → amber badge showing the raw tag label (leftmost)
|
||||||
|
- Tags **matched to a known Emby user** → accent badge showing the Emby display name (rightmost)
|
||||||
|
|
||||||
|
### Auto-Refresh
|
||||||
|
|
||||||
|
The dashboard polls the server at the user-selected interval (1s, 5s, 10s, or Off). The status panel auto-refreshes in sync. Client refresh rate is sent to the server on each request for active-client tracking.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Configuration
|
||||||
|
|
||||||
|
### Environment Variables
|
||||||
|
|
||||||
|
#### Core
|
||||||
|
|
||||||
|
| Variable | Required | Default | Description |
|
||||||
|
|----------|:--------:|---------|-------------|
|
||||||
|
| `PORT` | No | `3001` | Server listen port |
|
||||||
|
| `NODE_ENV` | No | — | Set to `production` to enable `secure` cookies and HTTPS upgrades |
|
||||||
|
| `DATA_DIR` | No | `./data` | Directory for `tokens.json` and `server.log`. Must be writable by the server process. In Docker: `/app/data` (named volume). |
|
||||||
|
| `COOKIE_SECRET` | No | — | If set, signs all session cookies with HMAC-SHA256. Strongly recommended in production. |
|
||||||
|
| `TRUST_PROXY` | No | — | Express `trust proxy` setting. Set to `1` (or a hop count) when behind a reverse proxy (nginx, Traefik, etc.) so `req.ip` and `req.secure` are correct. |
|
||||||
|
|
||||||
|
#### Emby
|
||||||
|
|
||||||
|
| Variable | Required | Default | Description |
|
||||||
|
|----------|:--------:|---------|-------------|
|
||||||
|
| `EMBY_URL` | Yes | — | Emby/Jellyfin base URL (e.g. `https://emby.example.com`) |
|
||||||
|
| `EMBY_API_KEY` | Yes | — | Emby API key (used by the poller to list users for tag badge classification) |
|
||||||
|
|
||||||
|
#### Service Instances
|
||||||
|
|
||||||
|
| Variable | Required | Default | Description |
|
||||||
|
|----------|:--------:|---------|-------------|
|
||||||
|
| `SONARR_INSTANCES` | Yes* | — | JSON array of Sonarr instances |
|
||||||
|
| `SONARR_URL` | Yes* | — | Legacy: single Sonarr URL |
|
||||||
|
| `SONARR_API_KEY` | Yes* | — | Legacy: single Sonarr API key |
|
||||||
|
| `RADARR_INSTANCES` | Yes* | — | JSON array of Radarr instances |
|
||||||
|
| `RADARR_URL` | Yes* | — | Legacy: single Radarr URL |
|
||||||
|
| `RADARR_API_KEY` | Yes* | — | Legacy: single Radarr API key |
|
||||||
|
| `SABNZBD_INSTANCES` | Yes* | — | JSON array of SABnzbd instances |
|
||||||
|
| `SABNZBD_URL` | Yes* | — | Legacy: single SABnzbd URL |
|
||||||
|
| `SABNZBD_API_KEY` | Yes* | — | Legacy: single SABnzbd API key |
|
||||||
|
| `QBITTORRENT_INSTANCES` | No | — | JSON array of qBittorrent instances |
|
||||||
|
|
||||||
|
\* Either `*_INSTANCES` (JSON array) or legacy `*_URL` + `*_API_KEY` format is required.
|
||||||
|
|
||||||
|
#### Tuning
|
||||||
|
|
||||||
|
| Variable | Required | Default | Description |
|
||||||
|
|----------|:--------:|---------|-------------|
|
||||||
|
| `POLL_INTERVAL` | No | `5000` | Poll interval in ms. Set to `0`, `off`, or `false` to disable background polling (on-demand mode). |
|
||||||
|
| `LOG_LEVEL` | No | `info` | `debug`, `info`, `warn`, `error`, `silent` |
|
||||||
|
|
||||||
|
### Instance JSON Format
|
||||||
|
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"name": "main",
|
||||||
|
"url": "https://sonarr.example.com",
|
||||||
|
"apiKey": "your-api-key"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "4k",
|
||||||
|
"url": "https://sonarr4k.example.com",
|
||||||
|
"apiKey": "your-4k-api-key"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
qBittorrent instances use `username` and `password` instead of `apiKey`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Deployment
|
||||||
|
|
||||||
|
### Docker image
|
||||||
|
|
||||||
|
The production image uses a two-stage build on `node:22-alpine`:
|
||||||
|
|
||||||
|
1. **`deps` stage** — runs `npm ci --omit=dev` to install only production dependencies.
|
||||||
|
2. **`runtime` stage** — copies `node_modules`, `server/`, `public/`, and `package.json`. Runs as the built-in non-root `node` user (UID 1000). `/app/data` is owned by `node` for writable token store and logs.
|
||||||
|
|
||||||
|
Key environment variables set in the image:
|
||||||
|
- `NODE_ENV=production` — enables secure cookies and HTTPS upgrade CSP directive
|
||||||
|
- `DATA_DIR=/app/data` — token store and log file location
|
||||||
|
|
||||||
|
### Docker Compose
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
sofarr:
|
||||||
|
image: docker.i3omb.com/sofarr:latest
|
||||||
|
container_name: sofarr
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "3001:3001"
|
||||||
|
environment:
|
||||||
|
- NODE_ENV=production
|
||||||
|
- DATA_DIR=/app/data
|
||||||
|
- COOKIE_SECRET=change-me-to-a-long-random-string
|
||||||
|
- TRUST_PROXY=1 # set if behind nginx/Traefik
|
||||||
|
- EMBY_URL=https://emby.example.com
|
||||||
|
- EMBY_API_KEY=your-emby-api-key
|
||||||
|
- SONARR_INSTANCES=[{"name":"main","url":"...","apiKey":"..."}]
|
||||||
|
- RADARR_INSTANCES=[{"name":"main","url":"...","apiKey":"..."}]
|
||||||
|
- SABNZBD_INSTANCES=[{"name":"main","url":"...","apiKey":"..."}]
|
||||||
|
- QBITTORRENT_INSTANCES=[{"name":"main","url":"...","username":"...","password":"..."}]
|
||||||
|
- POLL_INTERVAL=5000
|
||||||
|
- LOG_LEVEL=info
|
||||||
|
volumes:
|
||||||
|
- sofarr-data:/app/data # persists tokens.json and server.log
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
sofarr-data:
|
||||||
|
```
|
||||||
|
|
||||||
|
### Security hardening checklist
|
||||||
|
|
||||||
|
- **Set `COOKIE_SECRET`** — enables HMAC-signed cookies, preventing client-side forgery.
|
||||||
|
- **Set `TRUST_PROXY=1`** when behind a reverse proxy — ensures `req.secure` is `true` so the `secure` cookie flag is enforced and HTTPS-upgrade CSP fires.
|
||||||
|
- **Mount a named volume** for `DATA_DIR` — token store and log file survive container recreates.
|
||||||
|
- **Use HTTPS** — the CSP includes `upgrade-insecure-requests` in production and the HSTS header is set with a 1-year `maxAge`.
|
||||||
|
- **Rate limiting** — the login endpoint is limited to 10 failed attempts per IP per 15 minutes; all API endpoints share a 300 req/15 min window. Set `TRUST_PROXY` correctly so the client IP is not the proxy's IP.
|
||||||
|
|
||||||
|
### CI / CD
|
||||||
|
|
||||||
|
The `.gitea/workflows/` directory contains three pipeline definitions:
|
||||||
|
|
||||||
|
| File | Trigger | Purpose |
|
||||||
|
|------|---------|--------|
|
||||||
|
| `ci.yml` | Every push / PR | Security audit (`npm audit --audit-level=high`) + tests with V8 coverage |
|
||||||
|
| `build-image.yml` | Push to `main` / `develop` | Build and push Docker image to `docker.i3omb.com` |
|
||||||
|
| `create-release.yml` | Tag push (`v*`) | Create a Gitea release |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. UML Diagrams (PlantUML)
|
||||||
|
|
||||||
|
All diagrams below are in PlantUML syntax. Render with any PlantUML tool or VS Code extension.
|
||||||
|
|
||||||
|
### 13.1 Component Diagram
|
||||||
|
|
||||||
|
See [`diagrams/component.puml`](diagrams/component.puml)
|
||||||
|
|
||||||
|
### 13.2 Sequence Diagrams
|
||||||
|
|
||||||
|
- **Authentication**: [`diagrams/seq-auth.puml`](diagrams/seq-auth.puml)
|
||||||
|
- **Dashboard Request**: [`diagrams/seq-dashboard.puml`](diagrams/seq-dashboard.puml)
|
||||||
|
- **Polling Cycle**: [`diagrams/seq-polling.puml`](diagrams/seq-polling.puml)
|
||||||
|
|
||||||
|
### 13.3 Class / Entity Diagrams
|
||||||
|
|
||||||
|
- **Server Classes**: [`diagrams/class-server.puml`](diagrams/class-server.puml)
|
||||||
|
- **Data Model**: [`diagrams/class-data.puml`](diagrams/class-data.puml)
|
||||||
|
|
||||||
|
### 13.4 State Diagrams
|
||||||
|
|
||||||
|
- **Frontend UI States**: [`diagrams/state-ui.puml`](diagrams/state-ui.puml)
|
||||||
|
- **Poller States**: [`diagrams/state-poller.puml`](diagrams/state-poller.puml)
|
||||||
|
|
||||||
|
### 13.5 Activity Diagram
|
||||||
|
|
||||||
|
- **Download Matching**: [`diagrams/activity-matching.puml`](diagrams/activity-matching.puml)
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
@startuml activity-matching
|
||||||
|
!theme plain
|
||||||
|
title sofarr — Download Matching Activity Diagram
|
||||||
|
|
||||||
|
start
|
||||||
|
|
||||||
|
:Read cached data from MemoryCache;
|
||||||
|
note right
|
||||||
|
poll:sab-queue, poll:sab-history,
|
||||||
|
poll:sonarr-queue, poll:sonarr-history,
|
||||||
|
poll:radarr-queue, poll:radarr-history,
|
||||||
|
poll:sonarr-tags, poll:radarr-tags,
|
||||||
|
poll:qbittorrent
|
||||||
|
end note
|
||||||
|
|
||||||
|
:Build **seriesMap** from Sonarr queue records
|
||||||
|
(seriesId → embedded series object);
|
||||||
|
|
||||||
|
:Build **moviesMap** from Radarr queue records
|
||||||
|
(movieId → embedded movie object);
|
||||||
|
|
||||||
|
:Build **sonarrTagMap** (tagId → label)
|
||||||
|
Build **radarrTagMap** (tagId → label);
|
||||||
|
|
||||||
|
if (showAll?) then (yes)
|
||||||
|
:Fetch full Emby user list
|
||||||
|
Build **embyUserMap** (lowerName → displayName)
|
||||||
|
[cached 60s];
|
||||||
|
endif
|
||||||
|
|
||||||
|
:Initialise **userDownloads** = [];
|
||||||
|
|
||||||
|
partition "Process SABnzbd Queue Slots" {
|
||||||
|
while (More queue slots?) is (yes)
|
||||||
|
:Get slot filename (nzbName);
|
||||||
|
:nzbNameLower = nzbName.toLowerCase();
|
||||||
|
|
||||||
|
if (Title matches Sonarr **queue** record?) then (yes)
|
||||||
|
:series = seriesMap.get(match.seriesId)\n|| match.series;
|
||||||
|
if (series exists?) then (yes)
|
||||||
|
:allTags = extractAllTags(series.tags, sonarrTagMap)
|
||||||
|
matchedUserTag = extractUserTag(series.tags, sonarrTagMap, username);
|
||||||
|
if (showAll AND hasAnyTag?) then (yes)
|
||||||
|
:Build download object (type=series)
|
||||||
|
Add coverArt, status, progress, speed, eta
|
||||||
|
Add allTags, matchedUserTag
|
||||||
|
Add tagBadges = buildTagBadges(allTags, embyUserMap)
|
||||||
|
Add importIssues if any
|
||||||
|
Add admin fields (paths, arrLink);
|
||||||
|
:Push to **userDownloads**;
|
||||||
|
elseif (NOT showAll AND matchedUserTag?) then (yes)
|
||||||
|
:Build download object (type=series)
|
||||||
|
Add matchedUserTag;
|
||||||
|
:Push to **userDownloads**;
|
||||||
|
endif
|
||||||
|
endif
|
||||||
|
endif
|
||||||
|
|
||||||
|
if (Title matches Radarr **queue** record?) then (yes)
|
||||||
|
:movie = moviesMap.get(match.movieId)\n|| match.movie;
|
||||||
|
if (movie exists?) then (yes)
|
||||||
|
:allTags = extractAllTags(movie.tags, radarrTagMap)
|
||||||
|
matchedUserTag = extractUserTag(movie.tags, radarrTagMap, username);
|
||||||
|
if (showAll AND hasAnyTag?) then (yes)
|
||||||
|
:Build download object (type=movie)
|
||||||
|
Add coverArt, status, progress, speed, eta
|
||||||
|
Add allTags, matchedUserTag, tagBadges
|
||||||
|
Add importIssues if any
|
||||||
|
Add admin fields (paths, arrLink);
|
||||||
|
:Push to **userDownloads**;
|
||||||
|
elseif (NOT showAll AND matchedUserTag?) then (yes)
|
||||||
|
:Build download object (type=movie)
|
||||||
|
Add matchedUserTag;
|
||||||
|
:Push to **userDownloads**;
|
||||||
|
endif
|
||||||
|
endif
|
||||||
|
endif
|
||||||
|
endwhile (no)
|
||||||
|
}
|
||||||
|
|
||||||
|
partition "Process SABnzbd History Slots" {
|
||||||
|
while (More history slots?) is (yes)
|
||||||
|
:Get slot name (nzbName);
|
||||||
|
:nzbNameLower = nzbName.toLowerCase();
|
||||||
|
|
||||||
|
if (Title matches Sonarr **history** record?) then (yes)
|
||||||
|
:series = seriesMap.get(match.seriesId)\n|| match.series;
|
||||||
|
if (series found?) then (yes)
|
||||||
|
:extractAllTags + extractUserTag(username)
|
||||||
|
Build download (type=series, completedAt)
|
||||||
|
Add allTags, matchedUserTag, tagBadges if showAll;
|
||||||
|
:Push to **userDownloads** if showAll+anyTag or matchedUserTag;
|
||||||
|
endif
|
||||||
|
endif
|
||||||
|
|
||||||
|
if (Title matches Radarr **history** record?) then (yes)
|
||||||
|
:movie = moviesMap.get(match.movieId)\n|| match.movie;
|
||||||
|
if (movie found?) then (yes)
|
||||||
|
:extractAllTags + extractUserTag(username)
|
||||||
|
Build download (type=movie, completedAt)
|
||||||
|
Add allTags, matchedUserTag, tagBadges if showAll;
|
||||||
|
:Push to **userDownloads** if showAll+anyTag or matchedUserTag;
|
||||||
|
endif
|
||||||
|
endif
|
||||||
|
endwhile (no)
|
||||||
|
}
|
||||||
|
|
||||||
|
partition "Process qBittorrent Torrents" {
|
||||||
|
while (More torrents?) is (yes)
|
||||||
|
:Get torrent name;
|
||||||
|
:torrentNameLower = name.toLowerCase();
|
||||||
|
|
||||||
|
if (Matches Sonarr **queue**?) then (yes)
|
||||||
|
:Resolve series → check tag;
|
||||||
|
:mapTorrentToDownload() + enrich;
|
||||||
|
:Push if matches → **continue**;
|
||||||
|
elseif (Matches Radarr **queue**?) then (yes)
|
||||||
|
:Resolve movie → check tag;
|
||||||
|
:mapTorrentToDownload() + enrich;
|
||||||
|
:Push if matches → **continue**;
|
||||||
|
elseif (Matches Sonarr **history**?) then (yes)
|
||||||
|
:Resolve series via seriesMap;
|
||||||
|
:mapTorrentToDownload() + enrich;
|
||||||
|
:Push if matches → **continue**;
|
||||||
|
elseif (Matches Radarr **history**?) then (yes)
|
||||||
|
:Resolve movie via moviesMap;
|
||||||
|
:mapTorrentToDownload() + enrich;
|
||||||
|
:Push if matches → **continue**;
|
||||||
|
else (no match)
|
||||||
|
:Skip torrent (unmatched);
|
||||||
|
endif
|
||||||
|
endwhile (no)
|
||||||
|
}
|
||||||
|
|
||||||
|
:Return JSON response
|
||||||
|
{ user, isAdmin, downloads: userDownloads };
|
||||||
|
|
||||||
|
stop
|
||||||
|
|
||||||
|
legend right
|
||||||
|
**Title Matching Logic**
|
||||||
|
(bidirectional substring, case-insensitive):
|
||||||
|
""rTitle.includes(dlTitle) || dlTitle.includes(rTitle)""
|
||||||
|
|
||||||
|
**Tag Matching Logic** (tagMatchesUser):
|
||||||
|
1. Exact: tag.toLowerCase() === username
|
||||||
|
2. Sanitised: sanitizeTagLabel(tag) === sanitizeTagLabel(username)
|
||||||
|
(handles Ombi-mangled email-style usernames)
|
||||||
|
|
||||||
|
**extractAllTags**: returns all resolved tag labels
|
||||||
|
**extractUserTag**: returns the ONE label matching current user
|
||||||
|
**buildTagBadges**: classifies each tag against full Emby user
|
||||||
|
list → { label, matchedUser: displayName | null }
|
||||||
|
end legend
|
||||||
|
|
||||||
|
@enduml
|
||||||
@@ -0,0 +1,230 @@
|
|||||||
|
@startuml class-data
|
||||||
|
!theme plain
|
||||||
|
title sofarr — Data Model Diagram
|
||||||
|
|
||||||
|
skinparam classAttributeIconSize 0
|
||||||
|
|
||||||
|
package "External API Responses" {
|
||||||
|
class "SABnzbd Queue Slot" as sabq {
|
||||||
|
+ filename : string
|
||||||
|
+ nzbname : string
|
||||||
|
+ percentage : string
|
||||||
|
+ mb : string
|
||||||
|
+ mbmissing : string
|
||||||
|
+ size : string
|
||||||
|
+ timeleft : string
|
||||||
|
+ status : string
|
||||||
|
+ storage : string
|
||||||
|
}
|
||||||
|
|
||||||
|
class "SABnzbd History Slot" as sabh {
|
||||||
|
+ name : string
|
||||||
|
+ nzb_name : string
|
||||||
|
+ nzbname : string
|
||||||
|
+ status : string
|
||||||
|
+ size : string
|
||||||
|
+ completed_time : string
|
||||||
|
+ storage : string
|
||||||
|
}
|
||||||
|
|
||||||
|
class "Sonarr Queue Record" as sqr {
|
||||||
|
+ id : number
|
||||||
|
+ seriesId : number
|
||||||
|
+ series : SonarrSeries
|
||||||
|
+ title : string
|
||||||
|
+ sourceTitle : string
|
||||||
|
+ trackedDownloadStatus : string
|
||||||
|
+ trackedDownloadState : string
|
||||||
|
+ statusMessages : StatusMessage[]
|
||||||
|
+ errorMessage : string
|
||||||
|
}
|
||||||
|
|
||||||
|
class "Sonarr History Record" as shr {
|
||||||
|
+ id : number
|
||||||
|
+ seriesId : number
|
||||||
|
+ title : string
|
||||||
|
+ sourceTitle : string
|
||||||
|
+ eventType : string
|
||||||
|
}
|
||||||
|
|
||||||
|
class "SonarrSeries" as ss {
|
||||||
|
+ id : number
|
||||||
|
+ title : string
|
||||||
|
+ titleSlug : string
|
||||||
|
+ path : string
|
||||||
|
+ tags : number[]
|
||||||
|
+ images : Image[]
|
||||||
|
+ _instanceUrl : string
|
||||||
|
}
|
||||||
|
|
||||||
|
class "Radarr Queue Record" as rqr {
|
||||||
|
+ id : number
|
||||||
|
+ movieId : number
|
||||||
|
+ movie : RadarrMovie
|
||||||
|
+ title : string
|
||||||
|
+ sourceTitle : string
|
||||||
|
+ trackedDownloadStatus : string
|
||||||
|
+ trackedDownloadState : string
|
||||||
|
+ statusMessages : StatusMessage[]
|
||||||
|
+ errorMessage : string
|
||||||
|
}
|
||||||
|
|
||||||
|
class "Radarr History Record" as rhr {
|
||||||
|
+ id : number
|
||||||
|
+ movieId : number
|
||||||
|
+ title : string
|
||||||
|
+ sourceTitle : string
|
||||||
|
+ eventType : string
|
||||||
|
}
|
||||||
|
|
||||||
|
class "RadarrMovie" as rm {
|
||||||
|
+ id : number
|
||||||
|
+ title : string
|
||||||
|
+ titleSlug : string
|
||||||
|
+ path : string
|
||||||
|
+ tags : number[]
|
||||||
|
+ images : Image[]
|
||||||
|
+ _instanceUrl : string
|
||||||
|
}
|
||||||
|
|
||||||
|
class "Tag" as tag {
|
||||||
|
+ id : number
|
||||||
|
+ label : string
|
||||||
|
}
|
||||||
|
|
||||||
|
class "Image" as img {
|
||||||
|
+ coverType : string
|
||||||
|
+ remoteUrl : string
|
||||||
|
+ url : string
|
||||||
|
}
|
||||||
|
|
||||||
|
class "StatusMessage" as sm {
|
||||||
|
+ title : string
|
||||||
|
+ messages : string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
class "qBittorrent Torrent" as qbt {
|
||||||
|
+ name : string
|
||||||
|
+ hash : string
|
||||||
|
+ size : number
|
||||||
|
+ completed : number
|
||||||
|
+ progress : number (0-1)
|
||||||
|
+ state : string
|
||||||
|
+ dlspeed : number
|
||||||
|
+ eta : number
|
||||||
|
+ num_seeds : number
|
||||||
|
+ num_leechs : number
|
||||||
|
+ availability : number
|
||||||
|
+ category : string
|
||||||
|
+ tags : string
|
||||||
|
+ save_path : string
|
||||||
|
+ content_path : string
|
||||||
|
+ instanceId : string
|
||||||
|
+ instanceName : string
|
||||||
|
}
|
||||||
|
|
||||||
|
class "Emby User" as eu {
|
||||||
|
+ Id : string
|
||||||
|
+ Name : string
|
||||||
|
+ Policy : { IsAdministrator: boolean }
|
||||||
|
}
|
||||||
|
|
||||||
|
sqr *-- ss : embedded\n(includeSeries)
|
||||||
|
rqr *-- rm : embedded\n(includeMovie)
|
||||||
|
sqr *-- sm
|
||||||
|
rqr *-- sm
|
||||||
|
ss *-- img
|
||||||
|
rm *-- img
|
||||||
|
}
|
||||||
|
|
||||||
|
package "sofarr Internal Models" {
|
||||||
|
class "Download Object" as dl {
|
||||||
|
+ type : 'series' | 'movie' | 'torrent'
|
||||||
|
+ title : string
|
||||||
|
+ coverArt : string | null
|
||||||
|
+ status : string
|
||||||
|
+ progress : string
|
||||||
|
+ mb : string
|
||||||
|
+ mbmissing : string
|
||||||
|
+ size : string
|
||||||
|
+ speed : string
|
||||||
|
+ eta : string
|
||||||
|
+ seriesName : string | null
|
||||||
|
+ movieName : string | null
|
||||||
|
+ episodeInfo : object | null
|
||||||
|
+ movieInfo : object | null
|
||||||
|
+ allTags : string[]
|
||||||
|
+ matchedUserTag : string | null
|
||||||
|
+ tagBadges : TagBadge[] | undefined
|
||||||
|
+ importIssues : string[] | null
|
||||||
|
+ downloadPath : string | null
|
||||||
|
+ targetPath : string | null
|
||||||
|
+ arrLink : string | null
|
||||||
|
+ qbittorrent : boolean
|
||||||
|
+ seeds : number
|
||||||
|
+ peers : number
|
||||||
|
+ availability : string
|
||||||
|
+ rawSize : number
|
||||||
|
+ rawSpeed : number
|
||||||
|
+ rawEta : number
|
||||||
|
+ hash : string
|
||||||
|
+ category : string
|
||||||
|
+ completedAt : string
|
||||||
|
}
|
||||||
|
|
||||||
|
class "TagBadge" as tagbadge <<value>> {
|
||||||
|
+ label : string
|
||||||
|
+ matchedUser : string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
class "API Response\n/user-downloads" as apir {
|
||||||
|
+ user : string
|
||||||
|
+ isAdmin : boolean
|
||||||
|
+ downloads : Download[]
|
||||||
|
}
|
||||||
|
|
||||||
|
class "Status Response\n/status" as statr {
|
||||||
|
+ server : ServerInfo
|
||||||
|
+ polling : PollingInfo
|
||||||
|
+ cache : CacheStats
|
||||||
|
+ clients : ClientInfo[]
|
||||||
|
}
|
||||||
|
|
||||||
|
class "ServerInfo" as si {
|
||||||
|
+ uptimeSeconds : number
|
||||||
|
+ nodeVersion : string
|
||||||
|
+ memoryUsageMB : number
|
||||||
|
+ heapUsedMB : number
|
||||||
|
+ heapTotalMB : number
|
||||||
|
}
|
||||||
|
|
||||||
|
class "PollingInfo" as pi {
|
||||||
|
+ enabled : boolean
|
||||||
|
+ intervalMs : number
|
||||||
|
+ lastPoll : PollTimings
|
||||||
|
}
|
||||||
|
|
||||||
|
class "Session Cookie\nemby_user" as cookie {
|
||||||
|
+ id : string
|
||||||
|
+ name : string
|
||||||
|
+ isAdmin : boolean
|
||||||
|
' Note: Emby AccessToken intentionally excluded
|
||||||
|
}
|
||||||
|
|
||||||
|
apir *-- dl
|
||||||
|
statr *-- si
|
||||||
|
statr *-- pi
|
||||||
|
}
|
||||||
|
|
||||||
|
' Data flow connections
|
||||||
|
sabq ..> dl : matched &\ntransformed
|
||||||
|
sabh ..> dl : matched &\ntransformed
|
||||||
|
qbt ..> dl : mapTorrentToDownload()
|
||||||
|
ss ..> dl : coverArt, seriesName,\npath, tags
|
||||||
|
rm ..> dl : coverArt, movieName,\npath, tags
|
||||||
|
tag ..> dl : allTags / matchedUserTag
|
||||||
|
eu ..> cookie : login creates
|
||||||
|
eu ..> tagbadge : buildTagBadges()
|
||||||
|
dl *-- tagbadge : tagBadges[]
|
||||||
|
|
||||||
|
@enduml
|
||||||
@@ -0,0 +1,275 @@
|
|||||||
|
@startuml class-server
|
||||||
|
!theme plain
|
||||||
|
title sofarr — Server Class / Module Diagram
|
||||||
|
|
||||||
|
package "server/index.js" as entry {
|
||||||
|
class "EntryPoint" as ep <<module>> {
|
||||||
|
- LOG_LEVELS : Object
|
||||||
|
- currentLevel : number
|
||||||
|
- logFile : WriteStream
|
||||||
|
+ shouldLog(level) : boolean
|
||||||
|
--
|
||||||
|
Logging setup, app.listen(),
|
||||||
|
static files, startPoller()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
package "server/app.js" as appfactory {
|
||||||
|
class "createApp(options?)" as appfn <<factory>> {
|
||||||
|
+ createApp(skipRateLimits?) : Express
|
||||||
|
--
|
||||||
|
Mounts helmet (CSP nonce),
|
||||||
|
rate limiters, cookie-parser,
|
||||||
|
auth routes (pre-CSRF),
|
||||||
|
verifyCsrf, all other routes,
|
||||||
|
/health, /ready, error handler
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
package "server/routes" {
|
||||||
|
class "auth.js" as auth <<router>> {
|
||||||
|
+ POST /login (rate-limited)
|
||||||
|
+ GET /me
|
||||||
|
+ GET /csrf
|
||||||
|
+ POST /logout
|
||||||
|
--
|
||||||
|
Authenticates via Emby API
|
||||||
|
Issues emby_user + csrf_token cookies
|
||||||
|
Stores/revokes Emby tokens server-side
|
||||||
|
}
|
||||||
|
|
||||||
|
class "dashboard.js" as dashboard <<router>> {
|
||||||
|
- activeClients : Map<string, ClientInfo>
|
||||||
|
- CLIENT_STALE_MS : 30000
|
||||||
|
--
|
||||||
|
+ GET /user-downloads
|
||||||
|
+ GET /user-summary
|
||||||
|
+ GET /status
|
||||||
|
--
|
||||||
|
- getCoverArt(item) : string|null
|
||||||
|
- extractAllTags(tags, tagMap) : string[]
|
||||||
|
- extractUserTag(tags, tagMap, username) : string|null
|
||||||
|
- buildTagBadges(allTags, embyUserMap) : TagBadge[]
|
||||||
|
- getEmbyUsers() : Promise<Map>
|
||||||
|
- sanitizeTagLabel(input) : string
|
||||||
|
- tagMatchesUser(tag, username) : boolean
|
||||||
|
- getImportIssues(record) : string[]|null
|
||||||
|
- getSonarrLink(series) : string|null
|
||||||
|
- getRadarrLink(movie) : string|null
|
||||||
|
- getActiveClients() : ClientInfo[]
|
||||||
|
}
|
||||||
|
|
||||||
|
class "emby.js" as emby_r <<router>> {
|
||||||
|
+ GET /sessions
|
||||||
|
+ GET /users/:id
|
||||||
|
+ GET /users
|
||||||
|
+ GET /session/:sessionId/user
|
||||||
|
}
|
||||||
|
|
||||||
|
class "sabnzbd.js" as sab_r <<router>> {
|
||||||
|
+ GET /queue
|
||||||
|
+ GET /history
|
||||||
|
}
|
||||||
|
|
||||||
|
class "sonarr.js" as sonarr_r <<router>> {
|
||||||
|
+ GET /queue
|
||||||
|
+ GET /history
|
||||||
|
+ GET /series/:id
|
||||||
|
+ GET /series
|
||||||
|
}
|
||||||
|
|
||||||
|
class "radarr.js" as radarr_r <<router>> {
|
||||||
|
+ GET /queue
|
||||||
|
+ GET /history
|
||||||
|
+ GET /movies/:id
|
||||||
|
+ GET /movies
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
package "server/middleware" {
|
||||||
|
class "requireAuth.js" as requireauth <<middleware>> {
|
||||||
|
+ requireAuth(req, res, next) : void
|
||||||
|
--
|
||||||
|
Reads emby_user cookie (signed if COOKIE_SECRET)
|
||||||
|
Validates schema: id, name, isAdmin
|
||||||
|
Attaches user to req.user
|
||||||
|
Returns 401 if absent/tampered/invalid
|
||||||
|
}
|
||||||
|
|
||||||
|
class "verifyCsrf.js" as verifycsrf <<middleware>> {
|
||||||
|
+ verifyCsrf(req, res, next) : void
|
||||||
|
--
|
||||||
|
Exempt: GET, HEAD, OPTIONS
|
||||||
|
Compares csrf_token cookie
|
||||||
|
vs X-CSRF-Token header
|
||||||
|
using crypto.timingSafeEqual
|
||||||
|
Returns 403 on mismatch/missing
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
package "server/utils" {
|
||||||
|
class "MemoryCache" as cache {
|
||||||
|
- store : Map<string, CacheEntry>
|
||||||
|
+ get(key) : any|null
|
||||||
|
+ set(key, value, ttlMs) : void
|
||||||
|
+ invalidate(key) : void
|
||||||
|
+ clear() : void
|
||||||
|
+ getStats() : CacheStats
|
||||||
|
}
|
||||||
|
|
||||||
|
class "CacheEntry" as ce <<value>> {
|
||||||
|
+ value : any
|
||||||
|
+ expiresAt : number
|
||||||
|
}
|
||||||
|
|
||||||
|
class "CacheStats" as cs <<value>> {
|
||||||
|
+ entryCount : number
|
||||||
|
+ totalSizeBytes : number
|
||||||
|
+ entries : CacheEntryStats[]
|
||||||
|
}
|
||||||
|
|
||||||
|
class "Poller" as poller <<module>> {
|
||||||
|
- POLL_INTERVAL : number
|
||||||
|
- POLLING_ENABLED : boolean
|
||||||
|
- polling : boolean
|
||||||
|
- lastPollTimings : PollTimings|null
|
||||||
|
- intervalHandle : number|null
|
||||||
|
--
|
||||||
|
+ startPoller() : void
|
||||||
|
+ stopPoller() : void
|
||||||
|
+ pollAllServices() : Promise<void>
|
||||||
|
+ getLastPollTimings() : PollTimings|null
|
||||||
|
--
|
||||||
|
- timed(label, fn) : TimedResult
|
||||||
|
}
|
||||||
|
|
||||||
|
class "PollTimings" as pt <<value>> {
|
||||||
|
+ totalMs : number
|
||||||
|
+ timestamp : string (ISO)
|
||||||
|
+ tasks : { label, ms }[]
|
||||||
|
}
|
||||||
|
|
||||||
|
class "Config" as config <<module>> {
|
||||||
|
+ getSABnzbdInstances() : Instance[]
|
||||||
|
+ getSonarrInstances() : Instance[]
|
||||||
|
+ getRadarrInstances() : Instance[]
|
||||||
|
+ getQbittorrentInstances() : Instance[]
|
||||||
|
--
|
||||||
|
- parseInstances(envVar, ...) : Instance[]
|
||||||
|
}
|
||||||
|
|
||||||
|
class "Instance" as inst <<value>> {
|
||||||
|
+ id : string
|
||||||
|
+ name : string
|
||||||
|
+ url : string
|
||||||
|
+ apiKey : string
|
||||||
|
+ username? : string
|
||||||
|
+ password? : string
|
||||||
|
}
|
||||||
|
|
||||||
|
class "QBittorrentClient" as qbt {
|
||||||
|
- id : string
|
||||||
|
- name : string
|
||||||
|
- url : string
|
||||||
|
- username : string
|
||||||
|
- password : string
|
||||||
|
- authCookie : string|null
|
||||||
|
--
|
||||||
|
+ login() : Promise<boolean>
|
||||||
|
+ makeRequest(endpoint, config) : Promise<Response>
|
||||||
|
+ getTorrents() : Promise<Torrent[]>
|
||||||
|
}
|
||||||
|
|
||||||
|
class "qbittorrent.js" as qbt_mod <<module>> {
|
||||||
|
- persistedClients : QBittorrentClient[]|null
|
||||||
|
--
|
||||||
|
+ getTorrents() : Promise<Torrent[]>
|
||||||
|
+ getClients() : QBittorrentClient[]
|
||||||
|
+ mapTorrentToDownload(torrent) : Download
|
||||||
|
+ formatBytes(bytes) : string
|
||||||
|
+ formatSpeed(bps) : string
|
||||||
|
+ formatEta(seconds) : string
|
||||||
|
}
|
||||||
|
|
||||||
|
class "Logger" as logger <<module>> {
|
||||||
|
- logFile : WriteStream
|
||||||
|
+ logToFile(message) : void
|
||||||
|
}
|
||||||
|
|
||||||
|
class "TokenStore" as tokenstore <<module>> {
|
||||||
|
- store : Object (in-memory)
|
||||||
|
- STORE_PATH : string (DATA_DIR/tokens.json)
|
||||||
|
- TOKEN_TTL_MS : 31 days
|
||||||
|
--
|
||||||
|
+ storeToken(userId, accessToken) : void
|
||||||
|
+ getToken(userId) : {accessToken}|null
|
||||||
|
+ clearToken(userId) : void
|
||||||
|
--
|
||||||
|
Atomic write (.tmp → rename)
|
||||||
|
Pruned on startup + hourly
|
||||||
|
}
|
||||||
|
|
||||||
|
class "SanitizeError" as sanitize <<module>> {
|
||||||
|
+ sanitizeError(err) : string
|
||||||
|
--
|
||||||
|
Redacts: query-param secrets,
|
||||||
|
auth headers, bearer tokens,
|
||||||
|
basic-auth URLs
|
||||||
|
}
|
||||||
|
|
||||||
|
class "TagBadge" as tb <<value>> {
|
||||||
|
+ label : string
|
||||||
|
+ matchedUser : string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
class "ClientInfo" as ci <<value>> {
|
||||||
|
+ user : string
|
||||||
|
+ refreshRateMs : number
|
||||||
|
+ lastSeen : number (timestamp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
' Relationships
|
||||||
|
ep --> appfn : createApp()
|
||||||
|
ep --> poller : startPoller()
|
||||||
|
|
||||||
|
appfn --> auth : /api/auth (pre-CSRF)
|
||||||
|
appfn --> verifycsrf : /api (all routes below)
|
||||||
|
appfn --> dashboard
|
||||||
|
appfn --> emby_r
|
||||||
|
appfn --> sab_r
|
||||||
|
appfn --> sonarr_r
|
||||||
|
appfn --> radarr_r
|
||||||
|
|
||||||
|
dashboard --> requireauth : uses
|
||||||
|
emby_r --> requireauth : uses
|
||||||
|
sab_r --> requireauth : uses
|
||||||
|
sonarr_r --> requireauth : uses
|
||||||
|
radarr_r --> requireauth : uses
|
||||||
|
|
||||||
|
auth --> tokenstore : storeToken / getToken / clearToken
|
||||||
|
|
||||||
|
dashboard --> cache : read/write
|
||||||
|
dashboard --> poller : pollAllServices()
|
||||||
|
dashboard --> qbt_mod : mapTorrentToDownload()
|
||||||
|
dashboard --> config
|
||||||
|
|
||||||
|
poller --> cache : set poll:* keys
|
||||||
|
poller --> config : get instances
|
||||||
|
poller --> qbt_mod : getTorrents()
|
||||||
|
|
||||||
|
qbt_mod --> config : getQbittorrentInstances()
|
||||||
|
qbt_mod *-- qbt : creates
|
||||||
|
qbt --> logger
|
||||||
|
|
||||||
|
cache *-- ce : stores
|
||||||
|
cache ..> cs : returns from getStats()
|
||||||
|
poller ..> pt : stores/returns
|
||||||
|
dashboard *-- ci : stores in activeClients
|
||||||
|
|
||||||
|
config ..> inst : returns
|
||||||
|
|
||||||
|
auth ..> sanitize : sanitizeError on catch
|
||||||
|
dashboard ..> sanitize : sanitizeError on catch
|
||||||
|
|
||||||
|
@enduml
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
@startuml component
|
||||||
|
!theme plain
|
||||||
|
title sofarr — Component Diagram
|
||||||
|
|
||||||
|
skinparam componentStyle rectangle
|
||||||
|
skinparam packageStyle frame
|
||||||
|
|
||||||
|
package "Browser" as browser {
|
||||||
|
[index.html] as html
|
||||||
|
[app.js] as appjs
|
||||||
|
[style.css] as css
|
||||||
|
html ..> appjs : loads
|
||||||
|
html ..> css : loads
|
||||||
|
}
|
||||||
|
|
||||||
|
package "Express Server" as server {
|
||||||
|
|
||||||
|
[index.js\nEntry Point] as entry
|
||||||
|
[app.js\ncreatApp() factory] as appfactory
|
||||||
|
|
||||||
|
package "Middleware" {
|
||||||
|
[helmet\n(CSP nonce, HSTS)] as hm
|
||||||
|
[express-rate-limit\n(API + login)] as rl
|
||||||
|
[cookie-parser\n(signed cookies)] as cp
|
||||||
|
[express.json\n(64kb limit)] as ej
|
||||||
|
[express.static] as es
|
||||||
|
[requireAuth.js] as requireauth
|
||||||
|
[verifyCsrf.js\n(double-submit)] as verifycsrf
|
||||||
|
}
|
||||||
|
|
||||||
|
package "Routes" as routes {
|
||||||
|
[auth.js\n/api/auth\n(pre-CSRF)] as auth
|
||||||
|
[dashboard.js\n/api/dashboard] as dashboard
|
||||||
|
[emby.js\n/api/emby] as emby_route
|
||||||
|
[sabnzbd.js\n/api/sabnzbd] as sab_route
|
||||||
|
[sonarr.js\n/api/sonarr] as sonarr_route
|
||||||
|
[radarr.js\n/api/radarr] as radarr_route
|
||||||
|
}
|
||||||
|
|
||||||
|
package "Utilities" as utils {
|
||||||
|
[poller.js] as poller
|
||||||
|
[cache.js\nMemoryCache] as cache
|
||||||
|
[config.js] as config
|
||||||
|
[qbittorrent.js\nQBittorrentClient] as qbt
|
||||||
|
[tokenStore.js\n(tokens.json)] as tokenstore
|
||||||
|
[sanitizeError.js] as sanitize
|
||||||
|
[logger.js] as logger
|
||||||
|
}
|
||||||
|
|
||||||
|
entry --> appfactory : createApp()
|
||||||
|
entry --> es : serve public/
|
||||||
|
entry --> poller : startPoller()
|
||||||
|
|
||||||
|
appfactory --> hm
|
||||||
|
appfactory --> rl
|
||||||
|
appfactory --> cp
|
||||||
|
appfactory --> ej
|
||||||
|
appfactory --> auth : mount before verifyCsrf
|
||||||
|
appfactory --> verifycsrf : applied to all /api below
|
||||||
|
appfactory --> dashboard
|
||||||
|
appfactory --> emby_route
|
||||||
|
appfactory --> sab_route
|
||||||
|
appfactory --> sonarr_route
|
||||||
|
appfactory --> radarr_route
|
||||||
|
|
||||||
|
emby_route --> requireauth
|
||||||
|
sab_route --> requireauth
|
||||||
|
sonarr_route --> requireauth
|
||||||
|
radarr_route --> requireauth
|
||||||
|
dashboard --> requireauth
|
||||||
|
|
||||||
|
auth --> tokenstore : storeToken / getToken / clearToken
|
||||||
|
|
||||||
|
dashboard --> cache : read poll:* keys
|
||||||
|
dashboard --> poller : pollAllServices()\n(on-demand mode)
|
||||||
|
dashboard --> config : getSonarrInstances()\ngetRadarrInstances()
|
||||||
|
dashboard --> qbt : mapTorrentToDownload()
|
||||||
|
|
||||||
|
poller --> cache : set poll:* keys
|
||||||
|
poller --> config : get all instances
|
||||||
|
poller --> qbt : getTorrents()
|
||||||
|
poller --> logger
|
||||||
|
|
||||||
|
qbt --> config : getQbittorrentInstances()
|
||||||
|
qbt --> logger
|
||||||
|
|
||||||
|
auth ..> sanitize
|
||||||
|
dashboard ..> sanitize
|
||||||
|
}
|
||||||
|
|
||||||
|
cloud "External Services" as external {
|
||||||
|
[Emby / Jellyfin] as emby
|
||||||
|
[SABnzbd] as sab
|
||||||
|
[Sonarr] as sonarr
|
||||||
|
[Radarr] as radarr
|
||||||
|
[qBittorrent] as qbit
|
||||||
|
}
|
||||||
|
|
||||||
|
auth --> emby : authenticate\nuser profile
|
||||||
|
dashboard --> emby : GET /Users\n(user-summary + tag badge classification)
|
||||||
|
emby_route --> emby
|
||||||
|
sab_route --> sab
|
||||||
|
sonarr_route --> sonarr
|
||||||
|
radarr_route --> radarr
|
||||||
|
|
||||||
|
poller --> sab : queue + history
|
||||||
|
poller --> sonarr : tags + queue + history
|
||||||
|
poller --> radarr : tags + queue + history
|
||||||
|
qbt --> qbit : login + torrents/info
|
||||||
|
|
||||||
|
appjs --> auth : POST /login\nGET /me
|
||||||
|
appjs --> dashboard : GET /user-downloads\nGET /status
|
||||||
|
es --> html : serve static
|
||||||
|
|
||||||
|
@enduml
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
@startuml seq-auth
|
||||||
|
!theme plain
|
||||||
|
title sofarr — Authentication Sequence
|
||||||
|
|
||||||
|
actor User as user
|
||||||
|
participant "Browser\n(app.js)" as browser
|
||||||
|
participant "Express\n/api/auth" as auth
|
||||||
|
participant "TokenStore\n(tokens.json)" as tokens
|
||||||
|
participant "Emby\nServer" as emby
|
||||||
|
|
||||||
|
== Page Load ==
|
||||||
|
user -> browser : Navigate to sofarr
|
||||||
|
activate browser
|
||||||
|
browser -> auth : GET /api/auth/me
|
||||||
|
activate auth
|
||||||
|
auth -> auth : Read emby_user cookie\n(signed if COOKIE_SECRET set)
|
||||||
|
alt Cookie exists and valid
|
||||||
|
auth --> browser : { authenticated: true, user: { name, isAdmin } }
|
||||||
|
browser -> auth : GET /api/auth/csrf
|
||||||
|
activate auth
|
||||||
|
auth -> auth : Generate 32-byte hex csrfToken
|
||||||
|
auth --> browser : { csrfToken } + Set csrf_token cookie
|
||||||
|
deactivate auth
|
||||||
|
browser -> browser : store csrfToken in memory
|
||||||
|
browser -> browser : showDashboard()
|
||||||
|
browser -> browser : startAutoRefresh()
|
||||||
|
browser -> browser : dismissSplash()
|
||||||
|
else No cookie / tampered
|
||||||
|
auth --> browser : { authenticated: false }
|
||||||
|
browser -> browser : dismissSplash()
|
||||||
|
browser -> browser : showLogin()
|
||||||
|
end
|
||||||
|
deactivate auth
|
||||||
|
|
||||||
|
== Login ==
|
||||||
|
user -> browser : Enter username + password\n(+ optional rememberMe checkbox)
|
||||||
|
browser -> auth : POST /api/auth/login\n{ username, password, rememberMe }
|
||||||
|
activate auth
|
||||||
|
note right of auth
|
||||||
|
Rate limiter: max 10 failed
|
||||||
|
attempts per IP / 15 min
|
||||||
|
(successful requests excluded)
|
||||||
|
end note
|
||||||
|
auth -> emby : POST /Users/authenticatebyname\n{ Username, Pw }\nX-Emby-Authorization: MediaBrowser\nDeviceId = sha256(username)[0:16]
|
||||||
|
activate emby
|
||||||
|
alt Valid credentials
|
||||||
|
emby --> auth : { User: { Id }, AccessToken }
|
||||||
|
auth -> emby : GET /Users/{userId}\nX-MediaBrowser-Token: AccessToken
|
||||||
|
emby --> auth : { Name, Policy: { IsAdministrator } }
|
||||||
|
deactivate emby
|
||||||
|
auth -> tokens : storeToken(userId, AccessToken)
|
||||||
|
note right of tokens
|
||||||
|
Stored server-side only.
|
||||||
|
Never sent to the client.
|
||||||
|
31-day TTL, atomic JSON write.
|
||||||
|
end note
|
||||||
|
auth -> auth : Set emby_user cookie\n{ id, name, isAdmin }\nhttpOnly, sameSite=strict\nsecure (prod), signed (COOKIE_SECRET)\nrememberMe=true → Max-Age 30d\nrememberMe=false → session cookie
|
||||||
|
auth -> auth : Generate csrfToken\n(32-byte random hex)
|
||||||
|
auth -> auth : Set csrf_token cookie\nhttpOnly=false (JS-readable)\nsameSite=strict, secure (prod)
|
||||||
|
auth --> browser : { success: true, user, csrfToken }
|
||||||
|
browser -> browser : store csrfToken in memory
|
||||||
|
browser -> browser : fadeOutLogin()
|
||||||
|
browser -> browser : showDashboard()
|
||||||
|
browser -> browser : startAutoRefresh()
|
||||||
|
browser -> browser : dismissSplash()
|
||||||
|
else Invalid credentials
|
||||||
|
emby --> auth : 401 Error
|
||||||
|
deactivate emby
|
||||||
|
auth --> browser : { success: false, error: "Invalid username or password" }
|
||||||
|
browser -> browser : showLoginError()
|
||||||
|
end
|
||||||
|
deactivate auth
|
||||||
|
|
||||||
|
== CSRF Token Refresh (after page reload) ==
|
||||||
|
note over browser : csrfToken lost from memory\non hard page reload
|
||||||
|
browser -> auth : GET /api/auth/csrf
|
||||||
|
activate auth
|
||||||
|
auth -> auth : Generate new csrfToken
|
||||||
|
auth --> browser : { csrfToken } + new csrf_token cookie
|
||||||
|
deactivate auth
|
||||||
|
browser -> browser : store new csrfToken in memory
|
||||||
|
|
||||||
|
== Logout ==
|
||||||
|
user -> browser : Click Logout
|
||||||
|
browser -> browser : stopAutoRefresh()
|
||||||
|
browser -> auth : POST /api/auth/logout\n(no CSRF required — auth routes\nexempt; sameSite:strict protects)
|
||||||
|
activate auth
|
||||||
|
auth -> auth : Parse emby_user cookie → user
|
||||||
|
auth -> tokens : getToken(user.id)
|
||||||
|
activate tokens
|
||||||
|
tokens --> auth : { accessToken }
|
||||||
|
deactivate tokens
|
||||||
|
auth -> emby : POST /Sessions/Logout\nX-MediaBrowser-Token: accessToken
|
||||||
|
activate emby
|
||||||
|
emby --> auth : 204 / error (ignored)
|
||||||
|
deactivate emby
|
||||||
|
auth -> tokens : clearToken(user.id)
|
||||||
|
auth -> auth : clearCookie(emby_user)\nclearCookie(csrf_token)
|
||||||
|
auth --> browser : { success: true }
|
||||||
|
deactivate auth
|
||||||
|
browser -> browser : showLogin()
|
||||||
|
|
||||||
|
deactivate browser
|
||||||
|
@enduml
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
@startuml seq-dashboard
|
||||||
|
!theme plain
|
||||||
|
title sofarr — Dashboard Request Sequence
|
||||||
|
|
||||||
|
actor User as user
|
||||||
|
participant "Browser\n(app.js)" as browser
|
||||||
|
participant "Express\n/api/dashboard" as dashboard
|
||||||
|
participant "MemoryCache" as cache
|
||||||
|
participant "Poller" as poller
|
||||||
|
participant "External\nServices" as ext
|
||||||
|
|
||||||
|
== Periodic Refresh (or Initial Load) ==
|
||||||
|
user -> browser : (auto-refresh fires)
|
||||||
|
activate browser
|
||||||
|
browser -> dashboard : GET /api/dashboard/user-downloads\n?refreshRate=5000&showAll=false
|
||||||
|
activate dashboard
|
||||||
|
|
||||||
|
dashboard -> dashboard : Parse emby_user cookie\nExtract username, isAdmin
|
||||||
|
dashboard -> dashboard : Track client refresh rate\nin activeClients Map
|
||||||
|
|
||||||
|
alt Polling disabled AND cache empty
|
||||||
|
dashboard -> poller : pollAllServices()
|
||||||
|
activate poller
|
||||||
|
poller -> ext : Parallel API calls\n(SAB, Sonarr, Radarr, qBit)
|
||||||
|
ext --> poller : Raw data
|
||||||
|
poller -> cache : set poll:* keys\n(TTL = 30s)
|
||||||
|
deactivate poller
|
||||||
|
end
|
||||||
|
|
||||||
|
dashboard -> cache : get('poll:sab-queue')
|
||||||
|
cache --> dashboard : { slots, status, speed }
|
||||||
|
dashboard -> cache : get('poll:sab-history')
|
||||||
|
cache --> dashboard : { slots }
|
||||||
|
dashboard -> cache : get('poll:sonarr-tags')
|
||||||
|
cache --> dashboard : [{ instance, data }]
|
||||||
|
dashboard -> cache : get('poll:sonarr-queue')
|
||||||
|
cache --> dashboard : { records } (with embedded series)
|
||||||
|
dashboard -> cache : get('poll:sonarr-history')
|
||||||
|
cache --> dashboard : { records }
|
||||||
|
dashboard -> cache : get('poll:radarr-queue')
|
||||||
|
cache --> dashboard : { records } (with embedded movie)
|
||||||
|
dashboard -> cache : get('poll:radarr-history')
|
||||||
|
cache --> dashboard : { records }
|
||||||
|
dashboard -> cache : get('poll:radarr-tags')
|
||||||
|
cache --> dashboard : [{id, label}]
|
||||||
|
dashboard -> cache : get('poll:qbittorrent')
|
||||||
|
cache --> dashboard : [torrent, ...]
|
||||||
|
|
||||||
|
dashboard -> dashboard : Build seriesMap from\nSonarr queue records
|
||||||
|
dashboard -> dashboard : Build moviesMap from\nRadarr queue records
|
||||||
|
dashboard -> dashboard : Build tag maps\n(id → label)
|
||||||
|
|
||||||
|
alt showAll=true
|
||||||
|
dashboard -> cache : get('emby:users')
|
||||||
|
alt cache miss
|
||||||
|
dashboard -> ext : GET /Users (Emby)
|
||||||
|
ext --> dashboard : [{ Name, ... }]
|
||||||
|
dashboard -> cache : set('emby:users', map, 60s)
|
||||||
|
end
|
||||||
|
dashboard -> dashboard : Build embyUserMap\n(lowerName → displayName)
|
||||||
|
end
|
||||||
|
|
||||||
|
group SABnzbd Queue Matching
|
||||||
|
loop each queue slot
|
||||||
|
dashboard -> dashboard : Match title vs Sonarr queue
|
||||||
|
dashboard -> dashboard : Match title vs Radarr queue
|
||||||
|
dashboard -> dashboard : extractAllTags() + extractUserTag(username)\nInclude if: showAll+anyTag OR matchedUserTag\nAttach allTags, matchedUserTag\nIf showAll: tagBadges = buildTagBadges(embyUserMap)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
group SABnzbd History Matching
|
||||||
|
loop each history slot
|
||||||
|
dashboard -> dashboard : Match title vs Sonarr/Radarr history
|
||||||
|
dashboard -> dashboard : Same tag extraction + inclusion logic
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
group qBittorrent Matching
|
||||||
|
loop each torrent
|
||||||
|
dashboard -> dashboard : 1. Match vs Sonarr queue
|
||||||
|
dashboard -> dashboard : 2. Match vs Radarr queue
|
||||||
|
dashboard -> dashboard : 3. Match vs Sonarr history
|
||||||
|
dashboard -> dashboard : 4. Match vs Radarr history
|
||||||
|
dashboard -> dashboard : mapTorrentToDownload()\n→ enrich: allTags, matchedUserTag, tagBadges
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
dashboard --> browser : { user, isAdmin,\ndownloads: [...] }
|
||||||
|
deactivate dashboard
|
||||||
|
|
||||||
|
browser -> browser : renderDownloads() (diff-based)
|
||||||
|
note right
|
||||||
|
createDownloadCard() renders tag badges:
|
||||||
|
- Normal: accent badge for matchedUserTag
|
||||||
|
- showAll: amber badges (unmatched tags)
|
||||||
|
accent badges (matched → show Emby displayName)
|
||||||
|
end note
|
||||||
|
deactivate browser
|
||||||
|
|
||||||
|
@enduml
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
@startuml seq-polling
|
||||||
|
!theme plain
|
||||||
|
title sofarr — Background Polling Cycle
|
||||||
|
|
||||||
|
participant "index.js\n(startup)" as entry
|
||||||
|
participant "Poller" as poller
|
||||||
|
participant "Config" as config
|
||||||
|
participant "SABnzbd\n(per instance)" as sab
|
||||||
|
participant "Sonarr\n(per instance)" as sonarr
|
||||||
|
participant "Radarr\n(per instance)" as radarr
|
||||||
|
participant "qBittorrent\nClient" as qbt
|
||||||
|
participant "MemoryCache" as cache
|
||||||
|
|
||||||
|
== Startup ==
|
||||||
|
entry -> poller : startPoller()
|
||||||
|
activate poller
|
||||||
|
|
||||||
|
alt POLL_INTERVAL > 0
|
||||||
|
poller -> poller : pollAllServices() (immediate)
|
||||||
|
poller -> poller : setInterval(pollAllServices,\nPOLL_INTERVAL)
|
||||||
|
else POLL_INTERVAL = 0
|
||||||
|
poller --> entry : "Polling disabled, on-demand mode"
|
||||||
|
end
|
||||||
|
|
||||||
|
== Poll Cycle ==
|
||||||
|
poller -> poller : Check: polling flag?\n(skip if concurrent)
|
||||||
|
poller -> poller : polling = true
|
||||||
|
poller -> poller : start = Date.now()
|
||||||
|
|
||||||
|
poller -> config : getSABnzbdInstances()
|
||||||
|
config --> poller : [{ id, url, apiKey }]
|
||||||
|
poller -> config : getSonarrInstances()
|
||||||
|
config --> poller : [{ id, url, apiKey }]
|
||||||
|
poller -> config : getRadarrInstances()
|
||||||
|
config --> poller : [{ id, url, apiKey }]
|
||||||
|
|
||||||
|
note over poller : All fetches run in\nparallel via Promise.all,\neach wrapped in timed()
|
||||||
|
|
||||||
|
par SABnzbd Queue
|
||||||
|
poller -> sab : GET /api?mode=queue
|
||||||
|
sab --> poller : { queue: { slots, status, speed } }
|
||||||
|
and SABnzbd History
|
||||||
|
poller -> sab : GET /api?mode=history&limit=10
|
||||||
|
sab --> poller : { history: { slots } }
|
||||||
|
and Sonarr Tags
|
||||||
|
poller -> sonarr : GET /api/v3/tag
|
||||||
|
sonarr --> poller : [{ id, label }]
|
||||||
|
and Sonarr Queue
|
||||||
|
poller -> sonarr : GET /api/v3/queue\n?includeSeries=true
|
||||||
|
sonarr --> poller : { records: [{ seriesId, series, ... }] }
|
||||||
|
and Sonarr History
|
||||||
|
poller -> sonarr : GET /api/v3/history\n?pageSize=10
|
||||||
|
sonarr --> poller : { records: [{ seriesId, ... }] }
|
||||||
|
and Radarr Queue
|
||||||
|
poller -> radarr : GET /api/v3/queue\n?includeMovie=true
|
||||||
|
radarr --> poller : { records: [{ movieId, movie, ... }] }
|
||||||
|
and Radarr History
|
||||||
|
poller -> radarr : GET /api/v3/history\n?pageSize=10
|
||||||
|
radarr --> poller : { records: [{ movieId, ... }] }
|
||||||
|
and Radarr Tags
|
||||||
|
poller -> radarr : GET /api/v3/tag
|
||||||
|
radarr --> poller : [{ id, label }]
|
||||||
|
and qBittorrent
|
||||||
|
poller -> qbt : getTorrents()
|
||||||
|
qbt --> poller : [{ name, progress, ... }]
|
||||||
|
end
|
||||||
|
|
||||||
|
poller -> poller : Record per-task timings\nlastPollTimings = { totalMs,\ntimestamp, tasks: [{label, ms}] }
|
||||||
|
|
||||||
|
poller -> poller : cacheTTL = POLL_INTERVAL × 3
|
||||||
|
|
||||||
|
poller -> cache : set('poll:sab-queue', ..., cacheTTL)
|
||||||
|
poller -> cache : set('poll:sab-history', ..., cacheTTL)
|
||||||
|
poller -> cache : set('poll:sonarr-tags', ..., cacheTTL)
|
||||||
|
|
||||||
|
note over poller : Tag queue records with\n_instanceUrl on embedded\nseries/movie objects
|
||||||
|
|
||||||
|
poller -> cache : set('poll:sonarr-queue', ..., cacheTTL)
|
||||||
|
poller -> cache : set('poll:sonarr-history', ..., cacheTTL)
|
||||||
|
poller -> cache : set('poll:radarr-queue', ..., cacheTTL)
|
||||||
|
poller -> cache : set('poll:radarr-history', ..., cacheTTL)
|
||||||
|
poller -> cache : set('poll:radarr-tags', ..., cacheTTL)
|
||||||
|
poller -> cache : set('poll:qbittorrent', ..., cacheTTL)
|
||||||
|
|
||||||
|
poller -> poller : polling = false\nlog elapsed time
|
||||||
|
|
||||||
|
deactivate poller
|
||||||
|
|
||||||
|
@enduml
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
@startuml state-poller
|
||||||
|
!theme plain
|
||||||
|
title sofarr — Poller State Diagram
|
||||||
|
|
||||||
|
[*] --> CheckConfig : startPoller()
|
||||||
|
|
||||||
|
state CheckConfig <<choice>>
|
||||||
|
CheckConfig --> Disabled : POLL_INTERVAL = 0\nor 'off' / 'false'
|
||||||
|
CheckConfig --> Idle : POLL_INTERVAL > 0
|
||||||
|
|
||||||
|
state Disabled {
|
||||||
|
state "On-demand mode\nNo background timer" as od
|
||||||
|
od : Data fetched only when\na dashboard request\nfinds empty cache
|
||||||
|
}
|
||||||
|
|
||||||
|
Disabled --> Polling : pollAllServices()\n(triggered by dashboard request)
|
||||||
|
Polling --> Disabled : Poll complete\n(return to on-demand)
|
||||||
|
|
||||||
|
state Idle {
|
||||||
|
state "Waiting for\nnext interval" as waiting
|
||||||
|
}
|
||||||
|
|
||||||
|
Idle --> Polling : setInterval fires\nor immediate first poll
|
||||||
|
|
||||||
|
state Polling {
|
||||||
|
state "polling = true" as lock
|
||||||
|
state "Fetching all services\n(Promise.all)" as fetching
|
||||||
|
state "Storing results\nin cache" as storing
|
||||||
|
state "Recording timings" as timing
|
||||||
|
|
||||||
|
[*] --> lock
|
||||||
|
lock --> fetching
|
||||||
|
fetching --> storing : All promises resolved
|
||||||
|
fetching --> ErrorState : Any individual service\nerror (caught per-service)
|
||||||
|
storing --> timing
|
||||||
|
timing --> [*] : polling = false
|
||||||
|
}
|
||||||
|
|
||||||
|
state ErrorState as "Handle Error" {
|
||||||
|
state "Log error\npolling = false" as err
|
||||||
|
}
|
||||||
|
|
||||||
|
ErrorState --> Idle : Next interval
|
||||||
|
Polling --> Idle : Poll complete\n(back to waiting)
|
||||||
|
|
||||||
|
state "Concurrent Poll\nAttempt" as skip {
|
||||||
|
state "polling === true\n→ skip" as sk
|
||||||
|
}
|
||||||
|
|
||||||
|
Idle --> skip : Interval fires while\nprevious still running
|
||||||
|
skip --> Idle : Log "still running,\nskipping"
|
||||||
|
|
||||||
|
note right of Polling
|
||||||
|
**Cache TTL**: POLL_INTERVAL × 3
|
||||||
|
Ensures data survives between polls
|
||||||
|
even if one cycle is slow.
|
||||||
|
end note
|
||||||
|
|
||||||
|
note right of Disabled
|
||||||
|
**Cache TTL**: 30000ms (30s)
|
||||||
|
After expiry, next dashboard
|
||||||
|
request triggers a fresh poll.
|
||||||
|
end note
|
||||||
|
|
||||||
|
@enduml
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
@startuml state-ui
|
||||||
|
!theme plain
|
||||||
|
title sofarr — Frontend UI State Diagram
|
||||||
|
|
||||||
|
[*] --> SplashScreen : Page load
|
||||||
|
|
||||||
|
state SplashScreen {
|
||||||
|
state "Showing splash\n(min 1.2s)" as showing
|
||||||
|
}
|
||||||
|
|
||||||
|
SplashScreen --> CheckAuth : checkAuthentication()
|
||||||
|
|
||||||
|
state CheckAuth <<choice>>
|
||||||
|
CheckAuth --> LoginForm : No session cookie
|
||||||
|
CheckAuth --> Dashboard : Valid session
|
||||||
|
|
||||||
|
state LoginForm {
|
||||||
|
state "Idle" as lf_idle
|
||||||
|
state "Submitting" as lf_submit
|
||||||
|
state "Error" as lf_error
|
||||||
|
|
||||||
|
lf_idle --> lf_submit : Submit form
|
||||||
|
lf_submit --> lf_error : Auth failed
|
||||||
|
lf_error --> lf_submit : Re-submit
|
||||||
|
lf_submit --> FadeOutLogin : Auth success
|
||||||
|
}
|
||||||
|
|
||||||
|
state FadeOutLogin {
|
||||||
|
state "CSS transition\n(opacity → 0)" as fade
|
||||||
|
}
|
||||||
|
|
||||||
|
FadeOutLogin --> SplashScreen2 : Show splash\nwhile loading
|
||||||
|
|
||||||
|
state SplashScreen2 as "Splash (loading data)" {
|
||||||
|
state "fetchUserDownloads()" as fetching
|
||||||
|
}
|
||||||
|
|
||||||
|
SplashScreen2 --> Dashboard : Data loaded\ndismissSplash()
|
||||||
|
|
||||||
|
state Dashboard {
|
||||||
|
state "Rendering Cards" as rendering
|
||||||
|
state "Auto Refreshing" as refreshing
|
||||||
|
state "Status Panel Open" as status_open
|
||||||
|
state "Status Panel Closed" as status_closed
|
||||||
|
|
||||||
|
[*] --> rendering
|
||||||
|
rendering --> refreshing : startAutoRefresh()
|
||||||
|
refreshing --> rendering : fetchUserDownloads()\n→ renderDownloads()
|
||||||
|
rendering --> rendering : Theme change
|
||||||
|
|
||||||
|
status_closed --> status_open : Click "Status" btn\n(admin only)
|
||||||
|
status_open --> status_closed : Click close (×)
|
||||||
|
status_open --> status_open : Auto-refresh\nrenderStatusPanel()
|
||||||
|
|
||||||
|
[*] --> status_closed
|
||||||
|
|
||||||
|
state "Refresh Rate" as rr {
|
||||||
|
state "1s" as r1
|
||||||
|
state "5s (default)" as r5
|
||||||
|
state "10s" as r10
|
||||||
|
state "Off" as roff
|
||||||
|
r5 --> r1 : User selects
|
||||||
|
r5 --> r10
|
||||||
|
r5 --> roff
|
||||||
|
r1 --> r5
|
||||||
|
r1 --> r10
|
||||||
|
r1 --> roff
|
||||||
|
r10 --> r1
|
||||||
|
r10 --> r5
|
||||||
|
r10 --> roff
|
||||||
|
roff --> r1
|
||||||
|
roff --> r5
|
||||||
|
roff --> r10
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Dashboard --> LoginForm : Logout\n(clear cookie,\nstopAutoRefresh)
|
||||||
|
|
||||||
|
@enduml
|
||||||
Generated
+1996
-1302
File diff suppressed because it is too large
Load Diff
+20
-9
@@ -1,24 +1,35 @@
|
|||||||
{
|
{
|
||||||
"name": "sofarr",
|
"name": "sofarr",
|
||||||
"version": "0.1.0",
|
"version": "0.2.0",
|
||||||
"description": "A personal media download dashboard that shows your downloads 'so far' while you relax on the sofa waiting for your *arr services to finish",
|
"description": "A personal media download dashboard that shows your downloads 'so far' while you relax on the sofa waiting for your *arr services to finish",
|
||||||
"main": "server/index.js",
|
"main": "server/index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "nodemon server/index.js",
|
"dev": "nodemon server/index.js",
|
||||||
"start": "node server/index.js",
|
"start": "node server/index.js",
|
||||||
"install:all": "npm install"
|
"install:all": "npm install",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest",
|
||||||
|
"test:coverage": "vitest run --coverage",
|
||||||
|
"test:ui": "vitest --ui",
|
||||||
|
"audit": "npm audit --audit-level=high",
|
||||||
|
"audit:fix": "npm audit fix",
|
||||||
|
"audit:critical": "npm audit --audit-level=critical"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"express": "^4.18.2",
|
|
||||||
"cors": "^2.8.5",
|
|
||||||
"dotenv": "^16.3.1",
|
|
||||||
"axios": "^1.6.0",
|
"axios": "^1.6.0",
|
||||||
"node-cron": "^3.0.3",
|
"cookie-parser": "^1.4.6",
|
||||||
"cookie-parser": "^1.4.6"
|
"dotenv": "^16.3.1",
|
||||||
|
"express": "^4.18.2",
|
||||||
|
"express-rate-limit": "^7.0.0",
|
||||||
|
"helmet": "^7.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"nodemon": "^2.0.22",
|
"@vitest/coverage-v8": "^4.1.6",
|
||||||
"concurrently": "^7.6.0"
|
"concurrently": "^7.6.0",
|
||||||
|
"nock": "^14.0.15",
|
||||||
|
"nodemon": "^3.1.14",
|
||||||
|
"supertest": "^7.2.2",
|
||||||
|
"vitest": "^4.1.6"
|
||||||
},
|
},
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"sabnzbd",
|
"sabnzbd",
|
||||||
|
|||||||
+331
-11
@@ -2,16 +2,45 @@ let currentUser = null;
|
|||||||
let downloads = [];
|
let downloads = [];
|
||||||
let refreshInterval = null;
|
let refreshInterval = null;
|
||||||
let currentRefreshRate = 5000; // default 5 seconds
|
let currentRefreshRate = 5000; // default 5 seconds
|
||||||
|
let isAdmin = false;
|
||||||
|
let showAll = false;
|
||||||
|
let csrfToken = null; // double-submit CSRF token, sent as X-CSRF-Token on mutating requests
|
||||||
|
const SPLASH_MIN_MS = 1200; // minimum splash display time
|
||||||
|
|
||||||
|
// Apply saved theme immediately (before DOMContentLoaded to avoid flash)
|
||||||
|
(function() {
|
||||||
|
const saved = localStorage.getItem('sofarr-theme') || 'light';
|
||||||
|
document.documentElement.setAttribute('data-theme', saved);
|
||||||
|
})();
|
||||||
|
|
||||||
// Check authentication on load
|
// Check authentication on load
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
checkAuthentication();
|
checkAuthentication();
|
||||||
|
initThemeSwitcher();
|
||||||
|
|
||||||
document.getElementById('login-form').addEventListener('submit', handleLogin);
|
document.getElementById('login-form').addEventListener('submit', handleLogin);
|
||||||
document.getElementById('logout-btn').addEventListener('click', handleLogout);
|
document.getElementById('logout-btn').addEventListener('click', handleLogout);
|
||||||
document.getElementById('refresh-rate').addEventListener('change', handleRefreshRateChange);
|
document.getElementById('refresh-rate').addEventListener('change', handleRefreshRateChange);
|
||||||
|
document.getElementById('show-all-toggle').addEventListener('change', handleShowAllToggle);
|
||||||
|
document.getElementById('status-btn').addEventListener('click', toggleStatusPanel);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function initThemeSwitcher() {
|
||||||
|
const saved = localStorage.getItem('sofarr-theme') || 'light';
|
||||||
|
document.querySelectorAll('.theme-btn').forEach(btn => {
|
||||||
|
btn.classList.toggle('active', btn.dataset.theme === saved);
|
||||||
|
btn.addEventListener('click', () => setTheme(btn.dataset.theme));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function setTheme(theme) {
|
||||||
|
document.documentElement.setAttribute('data-theme', theme);
|
||||||
|
localStorage.setItem('sofarr-theme', theme);
|
||||||
|
document.querySelectorAll('.theme-btn').forEach(btn => {
|
||||||
|
btn.classList.toggle('active', btn.dataset.theme === theme);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function startAutoRefresh() {
|
function startAutoRefresh() {
|
||||||
if (refreshInterval) clearInterval(refreshInterval);
|
if (refreshInterval) clearInterval(refreshInterval);
|
||||||
if (currentRefreshRate > 0) {
|
if (currentRefreshRate > 0) {
|
||||||
@@ -23,6 +52,19 @@ function handleRefreshRateChange(e) {
|
|||||||
const rate = parseInt(e.target.value);
|
const rate = parseInt(e.target.value);
|
||||||
currentRefreshRate = rate;
|
currentRefreshRate = rate;
|
||||||
startAutoRefresh();
|
startAutoRefresh();
|
||||||
|
// Restart status panel refresh if it's open
|
||||||
|
const statusPanel = document.getElementById('status-panel');
|
||||||
|
if (statusPanel && statusPanel.style.display !== 'none') {
|
||||||
|
if (statusRefreshHandle) { clearInterval(statusRefreshHandle); statusRefreshHandle = null; }
|
||||||
|
if (currentRefreshRate > 0) {
|
||||||
|
statusRefreshHandle = setInterval(refreshStatusPanel, currentRefreshRate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleShowAllToggle(e) {
|
||||||
|
showAll = e.target.checked;
|
||||||
|
fetchUserDownloads(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
function stopAutoRefresh() {
|
function stopAutoRefresh() {
|
||||||
@@ -32,21 +74,74 @@ function stopAutoRefresh() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function checkAuthentication() {
|
async function checkAuthentication() {
|
||||||
|
const splashStart = Date.now();
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/auth/me');
|
// Fetch both auth state and a fresh CSRF token in parallel
|
||||||
const data = await response.json();
|
const [meRes, csrfRes] = await Promise.all([
|
||||||
|
fetch('/api/auth/me'),
|
||||||
|
fetch('/api/auth/csrf')
|
||||||
|
]);
|
||||||
|
const data = await meRes.json();
|
||||||
|
const csrfData = await csrfRes.json();
|
||||||
|
if (csrfData.csrfToken) csrfToken = csrfData.csrfToken;
|
||||||
|
|
||||||
if (data.authenticated) {
|
if (data.authenticated) {
|
||||||
currentUser = data.user;
|
currentUser = data.user;
|
||||||
|
isAdmin = !!data.user.isAdmin;
|
||||||
showDashboard();
|
showDashboard();
|
||||||
fetchUserDownloads(true);
|
await fetchUserDownloads(true);
|
||||||
startAutoRefresh();
|
startAutoRefresh();
|
||||||
|
await dismissSplash(splashStart);
|
||||||
} else {
|
} else {
|
||||||
|
await dismissSplash(splashStart);
|
||||||
showLogin();
|
showLogin();
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Authentication check failed:', err);
|
console.error('Authentication check failed:', err);
|
||||||
|
await dismissSplash(splashStart);
|
||||||
showLogin();
|
showLogin();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -56,6 +151,7 @@ async function handleLogin(e) {
|
|||||||
|
|
||||||
const username = document.getElementById('username').value;
|
const username = document.getElementById('username').value;
|
||||||
const password = document.getElementById('password').value;
|
const password = document.getElementById('password').value;
|
||||||
|
const rememberMe = document.getElementById('remember-me').checked;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/auth/login', {
|
const response = await fetch('/api/auth/login', {
|
||||||
@@ -63,16 +159,28 @@ async function handleLogin(e) {
|
|||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json'
|
'Content-Type': 'application/json'
|
||||||
},
|
},
|
||||||
body: JSON.stringify({ username, password })
|
body: JSON.stringify({ username, password, rememberMe })
|
||||||
});
|
});
|
||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
currentUser = data.user;
|
currentUser = data.user;
|
||||||
|
isAdmin = !!data.user.isAdmin;
|
||||||
|
// Store CSRF token returned by login for use in subsequent requests
|
||||||
|
if (data.csrfToken) csrfToken = data.csrfToken;
|
||||||
|
// Fade out login, then show splash while loading data.
|
||||||
|
// requestAnimationFrame ensures the browser paints the splash at
|
||||||
|
// opacity:1 before dismissSplash adds fade-out, so the CSS
|
||||||
|
// transition fires and transitionend is guaranteed.
|
||||||
|
await fadeOutLogin();
|
||||||
|
showSplash();
|
||||||
|
await new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)));
|
||||||
showDashboard();
|
showDashboard();
|
||||||
fetchUserDownloads(true);
|
const splashStart = Date.now();
|
||||||
|
await fetchUserDownloads(true);
|
||||||
startAutoRefresh();
|
startAutoRefresh();
|
||||||
|
await dismissSplash(splashStart);
|
||||||
} else {
|
} else {
|
||||||
showLoginError(data.error || 'Login failed');
|
showLoginError(data.error || 'Login failed');
|
||||||
}
|
}
|
||||||
@@ -86,9 +194,11 @@ async function handleLogout() {
|
|||||||
try {
|
try {
|
||||||
stopAutoRefresh();
|
stopAutoRefresh();
|
||||||
await fetch('/api/auth/logout', {
|
await fetch('/api/auth/logout', {
|
||||||
method: 'POST'
|
method: 'POST',
|
||||||
|
headers: csrfToken ? { 'X-CSRF-Token': csrfToken } : {}
|
||||||
});
|
});
|
||||||
currentUser = null;
|
currentUser = null;
|
||||||
|
csrfToken = null;
|
||||||
downloads = [];
|
downloads = [];
|
||||||
showLogin();
|
showLogin();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -106,6 +216,7 @@ function showDashboard() {
|
|||||||
document.getElementById('login-container').style.display = 'none';
|
document.getElementById('login-container').style.display = 'none';
|
||||||
document.getElementById('dashboard-container').style.display = 'block';
|
document.getElementById('dashboard-container').style.display = 'block';
|
||||||
document.getElementById('currentUser').textContent = currentUser.name || '-';
|
document.getElementById('currentUser').textContent = currentUser.name || '-';
|
||||||
|
document.getElementById('admin-controls').style.display = isAdmin ? 'flex' : 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
function showLoginError(message) {
|
function showLoginError(message) {
|
||||||
@@ -126,10 +237,15 @@ async function fetchUserDownloads(isInitialLoad = false) {
|
|||||||
hideError();
|
hideError();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/dashboard/user-downloads');
|
const params = new URLSearchParams();
|
||||||
|
if (showAll) params.set('showAll', 'true');
|
||||||
|
params.set('refreshRate', currentRefreshRate);
|
||||||
|
const url = '/api/dashboard/user-downloads?' + params.toString();
|
||||||
|
const response = await fetch(url);
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
|
||||||
currentUser = data.user;
|
currentUser = data.user;
|
||||||
|
isAdmin = !!data.isAdmin;
|
||||||
downloads = data.downloads;
|
downloads = data.downloads;
|
||||||
|
|
||||||
// Debug: log first download to see what fields are present
|
// Debug: log first download to see what fields are present
|
||||||
@@ -271,7 +387,11 @@ function createDownloadCard(download) {
|
|||||||
const coverDiv = document.createElement('div');
|
const coverDiv = document.createElement('div');
|
||||||
coverDiv.className = 'download-cover';
|
coverDiv.className = 'download-cover';
|
||||||
const coverImg = document.createElement('img');
|
const coverImg = document.createElement('img');
|
||||||
coverImg.src = download.coverArt;
|
// Proxy cover art through the server so the CSP img-src 'self' rule
|
||||||
|
// is satisfied (external poster URLs would be blocked otherwise).
|
||||||
|
coverImg.src = download.coverArt
|
||||||
|
? '/api/dashboard/cover-art?url=' + encodeURIComponent(download.coverArt)
|
||||||
|
: '';
|
||||||
coverImg.alt = download.movieName || download.seriesName || download.title;
|
coverImg.alt = download.movieName || download.seriesName || download.title;
|
||||||
coverImg.loading = 'lazy';
|
coverImg.loading = 'lazy';
|
||||||
coverDiv.appendChild(coverImg);
|
coverDiv.appendChild(coverImg);
|
||||||
@@ -304,6 +424,14 @@ function createDownloadCard(download) {
|
|||||||
|
|
||||||
header.appendChild(type);
|
header.appendChild(type);
|
||||||
header.appendChild(status);
|
header.appendChild(status);
|
||||||
|
|
||||||
|
if (download.importIssues && download.importIssues.length > 0) {
|
||||||
|
const issueBadge = document.createElement('span');
|
||||||
|
issueBadge.className = 'import-issue-badge';
|
||||||
|
issueBadge.textContent = 'Import Pending';
|
||||||
|
issueBadge.setAttribute('data-tooltip', download.importIssues.join('\n'));
|
||||||
|
header.appendChild(issueBadge);
|
||||||
|
}
|
||||||
|
|
||||||
const title = document.createElement('h3');
|
const title = document.createElement('h3');
|
||||||
title.className = 'download-title';
|
title.className = 'download-title';
|
||||||
@@ -315,16 +443,50 @@ function createDownloadCard(download) {
|
|||||||
if (download.seriesName) {
|
if (download.seriesName) {
|
||||||
const series = document.createElement('p');
|
const series = document.createElement('p');
|
||||||
series.className = 'download-series';
|
series.className = 'download-series';
|
||||||
series.textContent = `Series: ${download.seriesName}`;
|
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}`;
|
||||||
|
}
|
||||||
infoDiv.appendChild(series);
|
infoDiv.appendChild(series);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (download.movieName) {
|
if (download.movieName) {
|
||||||
const movie = document.createElement('p');
|
const movie = document.createElement('p');
|
||||||
movie.className = 'download-movie';
|
movie.className = 'download-movie';
|
||||||
movie.textContent = `Movie: ${download.movieName}`;
|
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}`;
|
||||||
|
}
|
||||||
infoDiv.appendChild(movie);
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
const details = document.createElement('div');
|
const details = document.createElement('div');
|
||||||
details.className = 'download-details';
|
details.className = 'download-details';
|
||||||
@@ -415,6 +577,24 @@ function createDownloadCard(download) {
|
|||||||
const completed = createDetailItem('Completed', formatDate(download.completedAt));
|
const completed = createDetailItem('Completed', formatDate(download.completedAt));
|
||||||
details.appendChild(completed);
|
details.appendChild(completed);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isAdmin && (download.downloadPath || download.targetPath)) {
|
||||||
|
const pathsDiv = document.createElement('div');
|
||||||
|
pathsDiv.className = 'download-paths';
|
||||||
|
if (download.downloadPath) {
|
||||||
|
const dlPath = document.createElement('div');
|
||||||
|
dlPath.className = 'path-item';
|
||||||
|
dlPath.innerHTML = '<span class="path-label">Download:</span> <span class="path-value">' + escapeHtml(download.downloadPath) + '</span>';
|
||||||
|
pathsDiv.appendChild(dlPath);
|
||||||
|
}
|
||||||
|
if (download.targetPath) {
|
||||||
|
const tgtPath = document.createElement('div');
|
||||||
|
tgtPath.className = 'path-item';
|
||||||
|
tgtPath.innerHTML = '<span class="path-label">Target:</span> <span class="path-value">' + escapeHtml(download.targetPath) + '</span>';
|
||||||
|
pathsDiv.appendChild(tgtPath);
|
||||||
|
}
|
||||||
|
details.appendChild(pathsDiv);
|
||||||
|
}
|
||||||
|
|
||||||
infoDiv.appendChild(details);
|
infoDiv.appendChild(details);
|
||||||
card.appendChild(infoDiv);
|
card.appendChild(infoDiv);
|
||||||
@@ -441,6 +621,146 @@ function createDetailItem(label, value) {
|
|||||||
return item;
|
return item;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function escapeHtml(str) {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.textContent = str;
|
||||||
|
return div.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
let statusRefreshHandle = null;
|
||||||
|
|
||||||
|
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();
|
||||||
|
// Auto-refresh in sync with dashboard refresh rate
|
||||||
|
if (statusRefreshHandle) clearInterval(statusRefreshHandle);
|
||||||
|
if (currentRefreshRate > 0) {
|
||||||
|
statusRefreshHandle = setInterval(refreshStatusPanel, currentRefreshRate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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()">×</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 activeRefreshers = clients.filter(c => c.refreshRateMs > 0);
|
||||||
|
const fastestClient = activeRefreshers.length > 0
|
||||||
|
? activeRefreshers.reduce((min, c) => c.refreshRateMs < min.refreshRateMs ? c : min)
|
||||||
|
: null;
|
||||||
|
const hasForegroundClient = fastestClient && data.polling.enabled && fastestClient.refreshRateMs < pollIntervalMs;
|
||||||
|
|
||||||
|
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>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasForegroundClient) {
|
||||||
|
html += `<div class="status-row"><span>Effective mode</span><span class="status-fg-badge">Foreground ${fastestClient.refreshRateMs / 1000}s</span></div>`;
|
||||||
|
} else if (activeRefreshers.length > 0) {
|
||||||
|
html += `<div class="status-row"><span>Effective mode</span><span>${data.polling.enabled ? 'Background' : 'On-demand'}</span></div>`;
|
||||||
|
} else {
|
||||||
|
html += `<div class="status-row"><span>Effective mode</span><span>Idle (no active clients)</span></div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
html += `<div class="status-row"><span>Active clients</span><span>${clients.length}</span></div>`;
|
||||||
|
for (const c of clients) {
|
||||||
|
const rate = c.refreshRateMs > 0 ? (c.refreshRateMs / 1000) + 's' : 'Off';
|
||||||
|
const age = Math.round((Date.now() - c.lastSeen) / 1000);
|
||||||
|
html += `<div class="status-row status-row-sub"><span>${escapeHtml(c.user)}</span><span>${rate} (${age}s ago)</span></div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
html += `</div>`;
|
||||||
|
|
||||||
|
// 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">`;
|
||||||
|
for (const t of lp.tasks) {
|
||||||
|
const barWidth = lp.totalMs > 0 ? Math.max(2, (t.ms / lp.totalMs) * 100) : 0;
|
||||||
|
html += `
|
||||||
|
<div class="timing-row">
|
||||||
|
<span class="timing-label">${escapeHtml(t.label)}</span>
|
||||||
|
<div class="timing-bar-bg"><div class="timing-bar" style="width:${barWidth.toFixed(1)}%"></div></div>
|
||||||
|
<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;
|
||||||
|
}
|
||||||
|
|
||||||
function formatSize(size) {
|
function formatSize(size) {
|
||||||
if (!size) return 'N/A';
|
if (!size) return 'N/A';
|
||||||
// If already a formatted string (e.g., "21.5 GB"), return as-is
|
// If already a formatted string (e.g., "21.5 GB"), return as-is
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.0 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 543 B |
Binary file not shown.
|
After Width: | Height: | Size: 85 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 34 KiB |
+31
-1
@@ -4,14 +4,24 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>sofarr - Your Downloads Dashboard</title>
|
<title>sofarr - Your Downloads Dashboard</title>
|
||||||
|
<link rel="icon" type="image/x-icon" href="favicon.ico">
|
||||||
|
<link rel="icon" type="image/png" sizes="32x32" href="favicon-32.png">
|
||||||
|
<link rel="apple-touch-icon" sizes="192x192" href="favicon-192.png">
|
||||||
|
<meta name="theme-color" content="#1a1a2e">
|
||||||
<link rel="stylesheet" href="style.css">
|
<link rel="stylesheet" href="style.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
<!-- Splash Screen -->
|
||||||
|
<div id="splash-screen" class="splash-screen">
|
||||||
|
<img src="images/sofarr-flashscreen.png" alt="sofarr" class="splash-logo">
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="app">
|
<div class="app">
|
||||||
<!-- Login Form -->
|
<!-- Login Form -->
|
||||||
<div id="login-container" class="login-container" style="display: none;">
|
<div id="login-container" class="login-container" style="display: none;">
|
||||||
<div class="login-box">
|
<div class="login-box">
|
||||||
<h2>Login to Emby</h2>
|
<img src="images/sofarr-flashscreen.png" alt="sofarr" class="login-logo">
|
||||||
|
<p class="login-subtitle">Login with your Emby credentials</p>
|
||||||
<form id="login-form">
|
<form id="login-form">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="username">Username:</label>
|
<label for="username">Username:</label>
|
||||||
@@ -21,6 +31,12 @@
|
|||||||
<label for="password">Password:</label>
|
<label for="password">Password:</label>
|
||||||
<input type="password" id="password" name="password" required>
|
<input type="password" id="password" name="password" required>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="form-group form-group--checkbox">
|
||||||
|
<label class="checkbox-label">
|
||||||
|
<input type="checkbox" id="remember-me" name="rememberMe">
|
||||||
|
<span>Keep me logged in</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
<button type="submit" class="login-btn">Login</button>
|
<button type="submit" class="login-btn">Login</button>
|
||||||
</form>
|
</form>
|
||||||
<div id="login-error" class="error-message" style="display: none;"></div>
|
<div id="login-error" class="error-message" style="display: none;"></div>
|
||||||
@@ -32,6 +48,11 @@
|
|||||||
<header class="app-header">
|
<header class="app-header">
|
||||||
<h1>sofarr</h1>
|
<h1>sofarr</h1>
|
||||||
<div class="header-controls">
|
<div class="header-controls">
|
||||||
|
<div class="theme-switcher">
|
||||||
|
<button class="theme-btn active" data-theme="light">Light</button>
|
||||||
|
<button class="theme-btn" data-theme="dark">Dark</button>
|
||||||
|
<button class="theme-btn" data-theme="mono">Mono</button>
|
||||||
|
</div>
|
||||||
<div class="refresh-control">
|
<div class="refresh-control">
|
||||||
<label for="refresh-rate">Refresh:</label>
|
<label for="refresh-rate">Refresh:</label>
|
||||||
<select id="refresh-rate">
|
<select id="refresh-rate">
|
||||||
@@ -41,6 +62,13 @@
|
|||||||
<option value="0">Off</option>
|
<option value="0">Off</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</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">
|
<div class="user-info">
|
||||||
<span class="user-label">Current User:</span>
|
<span class="user-label">Current User:</span>
|
||||||
<span class="user-name" id="currentUser">-</span>
|
<span class="user-name" id="currentUser">-</span>
|
||||||
@@ -49,6 +77,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
<div id="status-panel" class="status-panel" style="display: none;"></div>
|
||||||
|
|
||||||
<div id="error-message" class="error-message" style="display: none;"></div>
|
<div id="error-message" class="error-message" style="display: none;"></div>
|
||||||
|
|
||||||
<div id="loading" class="loading" style="display: none;">Loading downloads...</div>
|
<div id="loading" class="loading" style="display: none;">Loading downloads...</div>
|
||||||
|
|||||||
+904
-224
File diff suppressed because it is too large
Load Diff
+113
@@ -0,0 +1,113 @@
|
|||||||
|
/**
|
||||||
|
* Express application factory — imported by both server/index.js (production)
|
||||||
|
* and the test suite. Keeping app creation separate from app.listen() means
|
||||||
|
* tests can import a fresh instance without starting a real server or
|
||||||
|
* triggering the side-effects in index.js (log files, process.exit, poller).
|
||||||
|
*/
|
||||||
|
|
||||||
|
const express = require('express');
|
||||||
|
const cookieParser = require('cookie-parser');
|
||||||
|
const helmet = require('helmet');
|
||||||
|
const rateLimit = require('express-rate-limit');
|
||||||
|
const crypto = require('crypto');
|
||||||
|
|
||||||
|
const sabnzbdRoutes = require('./routes/sabnzbd');
|
||||||
|
const sonarrRoutes = require('./routes/sonarr');
|
||||||
|
const radarrRoutes = require('./routes/radarr');
|
||||||
|
const embyRoutes = require('./routes/emby');
|
||||||
|
const dashboardRoutes = require('./routes/dashboard');
|
||||||
|
const authRoutes = require('./routes/auth');
|
||||||
|
const verifyCsrf = require('./middleware/verifyCsrf');
|
||||||
|
|
||||||
|
function createApp({ skipRateLimits = false } = {}) {
|
||||||
|
const app = express();
|
||||||
|
|
||||||
|
if (process.env.TRUST_PROXY) {
|
||||||
|
const trustValue = /^\d+$/.test(process.env.TRUST_PROXY)
|
||||||
|
? parseInt(process.env.TRUST_PROXY, 10)
|
||||||
|
: process.env.TRUST_PROXY;
|
||||||
|
app.set('trust proxy', trustValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Per-request CSP nonce
|
||||||
|
app.use((req, res, next) => {
|
||||||
|
res.locals.cspNonce = crypto.randomBytes(16).toString('base64');
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
|
||||||
|
app.use((req, res, next) => {
|
||||||
|
helmet({
|
||||||
|
contentSecurityPolicy: {
|
||||||
|
directives: {
|
||||||
|
defaultSrc: ["'self'"],
|
||||||
|
scriptSrc: ["'self'", (req, res) => `'nonce-${res.locals.cspNonce}'`],
|
||||||
|
styleSrc: ["'self'", (req, res) => `'nonce-${res.locals.cspNonce}'`],
|
||||||
|
imgSrc: ["'self'", 'data:', 'blob:'],
|
||||||
|
fontSrc: ["'self'", 'data:'],
|
||||||
|
connectSrc: ["'self'"],
|
||||||
|
objectSrc: ["'none'"],
|
||||||
|
baseUri: ["'self'"],
|
||||||
|
frameAncestors: ["'none'"],
|
||||||
|
formAction: ["'self'"],
|
||||||
|
upgradeInsecureRequests: process.env.NODE_ENV === 'production' ? [] : null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
hsts: { maxAge: 31536000, includeSubDomains: true, preload: true },
|
||||||
|
referrerPolicy: { policy: 'strict-origin-when-cross-origin' },
|
||||||
|
crossOriginEmbedderPolicy: false
|
||||||
|
})(req, res, next);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.use((req, res, next) => {
|
||||||
|
res.setHeader('Permissions-Policy', 'camera=(), microphone=(), geolocation=(), payment=(), usb=()');
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
|
||||||
|
const apiLimiter = rateLimit({
|
||||||
|
windowMs: 15 * 60 * 1000,
|
||||||
|
max: skipRateLimits ? Number.MAX_SAFE_INTEGER : 300,
|
||||||
|
standardHeaders: true,
|
||||||
|
legacyHeaders: false,
|
||||||
|
message: { error: 'Too many requests, please try again later' }
|
||||||
|
});
|
||||||
|
|
||||||
|
app.use(cookieParser(process.env.COOKIE_SECRET || undefined));
|
||||||
|
app.use(express.json({ limit: '64kb' }));
|
||||||
|
|
||||||
|
// Health / readiness (no auth, no rate-limit)
|
||||||
|
app.get('/health', (req, res) => {
|
||||||
|
res.json({ status: 'ok', uptime: process.uptime() });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/ready', (req, res) => {
|
||||||
|
const ready = !!(process.env.EMBY_URL);
|
||||||
|
if (ready) {
|
||||||
|
res.json({ status: 'ready' });
|
||||||
|
} else {
|
||||||
|
res.status(503).json({ status: 'not ready', reason: 'EMBY_URL not configured' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// API routes
|
||||||
|
app.use('/api', apiLimiter);
|
||||||
|
app.use('/api/auth', authRoutes);
|
||||||
|
|
||||||
|
// CSRF protection for all state-changing API requests below
|
||||||
|
app.use('/api', verifyCsrf);
|
||||||
|
app.use('/api/sabnzbd', sabnzbdRoutes);
|
||||||
|
app.use('/api/sonarr', sonarrRoutes);
|
||||||
|
app.use('/api/radarr', radarrRoutes);
|
||||||
|
app.use('/api/emby', embyRoutes);
|
||||||
|
app.use('/api/dashboard', dashboardRoutes);
|
||||||
|
|
||||||
|
// Global error handler
|
||||||
|
// eslint-disable-next-line no-unused-vars
|
||||||
|
app.use((err, req, res, next) => {
|
||||||
|
console.error('[Server] Unhandled error:', err.message);
|
||||||
|
res.status(500).json({ error: 'Internal server error' });
|
||||||
|
});
|
||||||
|
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { createApp };
|
||||||
+185
-9
@@ -1,7 +1,9 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const cors = require('cors');
|
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const cookieParser = require('cookie-parser');
|
const cookieParser = require('cookie-parser');
|
||||||
|
const helmet = require('helmet');
|
||||||
|
const rateLimit = require('express-rate-limit');
|
||||||
|
const crypto = require('crypto');
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
require('dotenv').config();
|
require('dotenv').config();
|
||||||
|
|
||||||
@@ -10,7 +12,29 @@ require('dotenv').config();
|
|||||||
const LOG_LEVELS = { debug: 0, info: 1, warn: 2, error: 3, silent: 4 };
|
const LOG_LEVELS = { debug: 0, info: 1, warn: 2, error: 3, silent: 4 };
|
||||||
const currentLevel = LOG_LEVELS[(process.env.LOG_LEVEL || 'info').toLowerCase()] || 1;
|
const currentLevel = LOG_LEVELS[(process.env.LOG_LEVEL || 'info').toLowerCase()] || 1;
|
||||||
|
|
||||||
const logFile = fs.createWriteStream(path.join(__dirname, '../server.log'), { flags: 'a' });
|
// Log file lives in DATA_DIR so the non-root container user can write to it
|
||||||
|
const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, '../data');
|
||||||
|
if (!fs.existsSync(DATA_DIR)) fs.mkdirSync(DATA_DIR, { recursive: true });
|
||||||
|
|
||||||
|
const LOG_PATH = path.join(DATA_DIR, 'server.log');
|
||||||
|
const LOG_MAX_BYTES = 10 * 1024 * 1024; // 10 MB per file
|
||||||
|
const LOG_KEEP = 3; // keep 3 rotated files
|
||||||
|
|
||||||
|
function rotateLogIfNeeded() {
|
||||||
|
try {
|
||||||
|
const stat = fs.statSync(LOG_PATH);
|
||||||
|
if (stat.size < LOG_MAX_BYTES) return;
|
||||||
|
for (let i = LOG_KEEP - 1; i >= 1; i--) {
|
||||||
|
const src = `${LOG_PATH}.${i}`;
|
||||||
|
const dst = `${LOG_PATH}.${i + 1}`;
|
||||||
|
if (fs.existsSync(src)) fs.renameSync(src, dst);
|
||||||
|
}
|
||||||
|
fs.renameSync(LOG_PATH, `${LOG_PATH}.1`);
|
||||||
|
} catch { /* ignore rotation errors — don't crash the server */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
rotateLogIfNeeded();
|
||||||
|
const logFile = fs.createWriteStream(LOG_PATH, { flags: 'a' });
|
||||||
const originalConsoleLog = console.log;
|
const originalConsoleLog = console.log;
|
||||||
const originalConsoleError = console.error;
|
const originalConsoleError = console.error;
|
||||||
const originalConsoleWarn = console.warn;
|
const originalConsoleWarn = console.warn;
|
||||||
@@ -54,24 +78,173 @@ const radarrRoutes = require('./routes/radarr');
|
|||||||
const embyRoutes = require('./routes/emby');
|
const embyRoutes = require('./routes/emby');
|
||||||
const dashboardRoutes = require('./routes/dashboard');
|
const dashboardRoutes = require('./routes/dashboard');
|
||||||
const authRoutes = require('./routes/auth');
|
const authRoutes = require('./routes/auth');
|
||||||
|
const verifyCsrf = require('./middleware/verifyCsrf');
|
||||||
|
const { startPoller, POLL_INTERVAL, POLLING_ENABLED } = require('./utils/poller');
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Startup environment validation
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
const cookieSecret = process.env.COOKIE_SECRET;
|
||||||
|
if (!cookieSecret && process.env.NODE_ENV === 'production') {
|
||||||
|
console.error('[Security] COOKIE_SECRET is not set in production — aborting.');
|
||||||
|
process.exit(1);
|
||||||
|
} else if (!cookieSecret) {
|
||||||
|
console.warn('[Security] COOKIE_SECRET not set — unsigned cookies (dev only)');
|
||||||
|
}
|
||||||
|
if (!process.env.EMBY_URL && process.env.NODE_ENV === 'production') {
|
||||||
|
console.error('[Config] EMBY_URL is required');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
const PORT = process.env.PORT || 3001;
|
const PORT = process.env.PORT || 3001;
|
||||||
|
|
||||||
app.use(cors());
|
// ---------------------------------------------------------------------------
|
||||||
app.use(cookieParser());
|
// Trust proxy — required when behind Nginx/Caddy/Traefik so that
|
||||||
app.use(express.json());
|
// req.ip reflects the real client IP (not 127.0.0.1) and
|
||||||
app.use(express.static(path.join(__dirname, '../public')));
|
// 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.NODE_ENV === 'production' ? [] : null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
hsts: {
|
||||||
|
maxAge: 31536000, // 1 year
|
||||||
|
includeSubDomains: true,
|
||||||
|
preload: true
|
||||||
|
},
|
||||||
|
referrerPolicy: { policy: 'strict-origin-when-cross-origin' },
|
||||||
|
crossOriginEmbedderPolicy: false // not needed for this SPA
|
||||||
|
})(req, res, next);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Permissions-Policy — disable powerful browser features not needed by the app
|
||||||
|
app.use((req, res, next) => {
|
||||||
|
res.setHeader(
|
||||||
|
'Permissions-Policy',
|
||||||
|
'camera=(), microphone=(), geolocation=(), payment=(), usb=()'
|
||||||
|
);
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// General API rate limiter — applies to all /api/* routes
|
||||||
|
// More specific limiters (e.g. login) apply on top of this.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
const apiLimiter = rateLimit({
|
||||||
|
windowMs: 15 * 60 * 1000, // 15 minutes
|
||||||
|
max: 300, // 300 requests per IP per window (generous for polling)
|
||||||
|
standardHeaders: true,
|
||||||
|
legacyHeaders: false,
|
||||||
|
message: { error: 'Too many requests, please try again later' }
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Body parsing & cookies
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
app.use(cookieParser(cookieSecret || undefined));
|
||||||
|
app.use(express.json({ limit: '64kb' })); // prevent oversized JSON payloads
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Health / readiness endpoints (no auth, no rate-limit)
|
||||||
|
// Used by Docker HEALTHCHECK and orchestrators.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
app.get('/health', (req, res) => {
|
||||||
|
res.json({ status: 'ok', uptime: process.uptime() });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/ready', (req, res) => {
|
||||||
|
// Confirm critical config is present
|
||||||
|
const ready = !!(process.env.EMBY_URL);
|
||||||
|
if (ready) {
|
||||||
|
res.json({ status: 'ready' });
|
||||||
|
} else {
|
||||||
|
res.status(503).json({ status: 'not ready', reason: 'EMBY_URL not configured' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Static files — served before API routes
|
||||||
|
// index.html is served manually so we can inject the CSP nonce
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
const PUBLIC_DIR = path.join(__dirname, '../public');
|
||||||
|
const INDEX_HTML = path.join(PUBLIC_DIR, 'index.html');
|
||||||
|
|
||||||
|
// Serve all static assets (js, css, images, icons) except index.html
|
||||||
|
app.use(express.static(PUBLIC_DIR, { index: false }));
|
||||||
|
|
||||||
|
// Serve index.html with nonce injected into the <script> and <link> tags
|
||||||
|
function serveIndex(req, res) {
|
||||||
|
fs.readFile(INDEX_HTML, 'utf8', (err, html) => {
|
||||||
|
if (err) return res.status(500).send('Internal Server Error');
|
||||||
|
const nonce = res.locals.cspNonce;
|
||||||
|
// Inject nonce into <script> and <link rel="stylesheet"> tags
|
||||||
|
const patched = html
|
||||||
|
.replace(/<script([^>]*)>/gi, `<script nonce="${nonce}"$1>`)
|
||||||
|
.replace(/<link([^>]*rel=["']stylesheet["'][^>]*)>/gi, `<link nonce="${nonce}"$1>`);
|
||||||
|
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
||||||
|
res.send(patched);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// API routes (rate-limited; auth routes exempt CSRF for login/csrf endpoints)
|
||||||
|
// CSRF protection applies to all state-changing /api/* requests except
|
||||||
|
// /api/auth/login (pre-auth) and /api/auth/csrf (issues the token).
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
app.use('/api', apiLimiter);
|
||||||
|
app.use('/api/auth', authRoutes);
|
||||||
|
|
||||||
|
// All routes below this point require CSRF validation on mutating methods
|
||||||
|
app.use('/api', verifyCsrf);
|
||||||
app.use('/api/sabnzbd', sabnzbdRoutes);
|
app.use('/api/sabnzbd', sabnzbdRoutes);
|
||||||
app.use('/api/sonarr', sonarrRoutes);
|
app.use('/api/sonarr', sonarrRoutes);
|
||||||
app.use('/api/radarr', radarrRoutes);
|
app.use('/api/radarr', radarrRoutes);
|
||||||
app.use('/api/emby', embyRoutes);
|
app.use('/api/emby', embyRoutes);
|
||||||
app.use('/api/dashboard', dashboardRoutes);
|
app.use('/api/dashboard', dashboardRoutes);
|
||||||
app.use('/api/auth', authRoutes);
|
|
||||||
|
|
||||||
app.get('/', (req, res) => {
|
// SPA catch-all — serve index.html for any unmatched path
|
||||||
res.sendFile(path.join(__dirname, '../public/index.html'));
|
app.get('*', serveIndex);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Global error handler — never leak stack traces to clients
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// eslint-disable-next-line no-unused-vars
|
||||||
|
app.use((err, req, res, next) => {
|
||||||
|
console.error('[Server] Unhandled error:', err.message);
|
||||||
|
res.status(500).json({ error: 'Internal server error' });
|
||||||
});
|
});
|
||||||
|
|
||||||
app.listen(PORT, () => {
|
app.listen(PORT, () => {
|
||||||
@@ -79,5 +252,8 @@ app.listen(PORT, () => {
|
|||||||
console.log(` sofarr - Your Downloads Dashboard`);
|
console.log(` sofarr - Your Downloads Dashboard`);
|
||||||
console.log(` Server running on port ${PORT}`);
|
console.log(` Server running on port ${PORT}`);
|
||||||
console.log(` Log level: ${process.env.LOG_LEVEL || 'info'}`);
|
console.log(` Log level: ${process.env.LOG_LEVEL || 'info'}`);
|
||||||
|
console.log(` Polling: ${POLLING_ENABLED ? POLL_INTERVAL + 'ms' : 'disabled (on-demand)'}`);
|
||||||
|
console.log(` Trust proxy: ${process.env.TRUST_PROXY || 'disabled'}`);
|
||||||
console.log(`=================================`);
|
console.log(`=================================`);
|
||||||
|
startPoller();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
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;
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
/**
|
||||||
|
* CSRF protection using the double-submit cookie pattern.
|
||||||
|
*
|
||||||
|
* On login the server issues a random `csrf_token` cookie (httpOnly:false
|
||||||
|
* so JS can read it). The SPA must send the same value in the
|
||||||
|
* `X-CSRF-Token` request header for every state-changing request (POST,
|
||||||
|
* PUT, PATCH, DELETE).
|
||||||
|
*
|
||||||
|
* Because the `sameSite: strict` session cookie already provides strong
|
||||||
|
* protection in modern browsers, this acts as defence-in-depth for
|
||||||
|
* older browsers and any edge cases.
|
||||||
|
*
|
||||||
|
* Safe methods (GET, HEAD, OPTIONS) are exempted.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
|
||||||
|
|
||||||
|
function verifyCsrf(req, res, next) {
|
||||||
|
if (SAFE_METHODS.has(req.method)) return next();
|
||||||
|
|
||||||
|
const cookieToken = req.cookies.csrf_token;
|
||||||
|
const headerToken = req.headers['x-csrf-token'];
|
||||||
|
|
||||||
|
if (!cookieToken || !headerToken) {
|
||||||
|
return res.status(403).json({ error: 'CSRF token missing' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Constant-time comparison to prevent timing attacks
|
||||||
|
if (cookieToken.length !== headerToken.length) {
|
||||||
|
return res.status(403).json({ error: 'CSRF token invalid' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const a = Buffer.from(cookieToken);
|
||||||
|
const b = Buffer.from(headerToken);
|
||||||
|
if (!require('crypto').timingSafeEqual(a, b)) {
|
||||||
|
return res.status(403).json({ error: 'CSRF token invalid' });
|
||||||
|
}
|
||||||
|
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = verifyCsrf;
|
||||||
+136
-45
@@ -1,57 +1,105 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const axios = require('axios');
|
const axios = require('axios');
|
||||||
|
const crypto = require('crypto');
|
||||||
|
const rateLimit = require('express-rate-limit');
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
const EMBY_URL = process.env.EMBY_URL;
|
// Persistent JSON file-backed token store — survives restarts
|
||||||
const EMBY_API_KEY = process.env.EMBY_API_KEY;
|
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' }
|
||||||
|
});
|
||||||
|
|
||||||
// Authenticate user with Emby
|
// Authenticate user with Emby
|
||||||
router.post('/login', async (req, res) => {
|
router.post('/login', loginLimiter, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { username, password } = req.body;
|
const { username, password, rememberMe } = req.body;
|
||||||
|
|
||||||
|
// Input validation — reject obviously invalid inputs before hitting Emby
|
||||||
|
if (typeof username !== 'string' || username.trim().length === 0 || username.length > 128) {
|
||||||
|
return res.status(400).json({ success: false, error: 'Invalid username' });
|
||||||
|
}
|
||||||
|
if (typeof password !== 'string' || password.length === 0 || password.length > 256) {
|
||||||
|
return res.status(400).json({ success: false, error: 'Invalid password' });
|
||||||
|
}
|
||||||
|
|
||||||
console.log(`[Auth] Attempting login for user: ${username}`);
|
console.log(`[Auth] Attempting login for user: ${username.trim()}`);
|
||||||
|
|
||||||
// Authenticate with Emby
|
// Authenticate with Emby using a stable DeviceId derived from the username.
|
||||||
const authResponse = await axios.post(`${EMBY_URL}/Users/authenticatebyname`, {
|
// Using a deterministic DeviceId causes Emby to reuse the existing session
|
||||||
Username: username,
|
// 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(),
|
||||||
Pw: password
|
Pw: password
|
||||||
}, {
|
}, {
|
||||||
headers: {
|
headers: {
|
||||||
'X-Emby-Authorization': `MediaBrowser Client="MediaDashboard", Device="Browser", DeviceId="dashboard-${Date.now()}", Version="1.0.0"`
|
'X-Emby-Authorization': `MediaBrowser Client="sofarr", Device="sofarr", DeviceId="${stableDeviceId}", Version="1.0.0"`
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const authData = authResponse.data;
|
const authData = authResponse.data;
|
||||||
console.log(`[Auth] Emby auth response:`, JSON.stringify(authData));
|
|
||||||
|
|
||||||
// Get user info using the access token
|
// Get user info using the access token
|
||||||
const userResponse = await axios.get(`${EMBY_URL}/Users/${authData.User.Id || authData.User.id}`, {
|
const userResponse = await axios.get(`${getEmbyUrl()}/Users/${authData.User.Id || authData.User.id}`, {
|
||||||
headers: {
|
headers: {
|
||||||
'X-MediaBrowser-Token': authData.AccessToken
|
'X-MediaBrowser-Token': authData.AccessToken
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const user = userResponse.data;
|
const user = userResponse.data;
|
||||||
console.log(`[Auth] User info:`, JSON.stringify(user));
|
const isAdmin = !!(user.Policy && user.Policy.IsAdministrator);
|
||||||
console.log(`[Auth] Login successful for user: ${user.Name}`);
|
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);
|
||||||
|
|
||||||
// Set authentication cookie
|
// Set authentication cookie (signed when COOKIE_SECRET is set).
|
||||||
res.cookie('emby_user', JSON.stringify({
|
// rememberMe=true → persistent cookie, expires in 30 days
|
||||||
id: user.Id,
|
// rememberMe=false → session cookie, expires when browser closes
|
||||||
name: user.Name,
|
// secure is always true — the app should sit behind HTTPS in production;
|
||||||
token: authData.AccessToken
|
// behind a reverse proxy set TRUST_PROXY=1 so req.secure works correctly.
|
||||||
}), {
|
const cookiePayload = JSON.stringify({ id: user.Id, name: user.Name, isAdmin });
|
||||||
|
const signed = !!process.env.COOKIE_SECRET;
|
||||||
|
const cookieOptions = {
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
maxAge: 24 * 60 * 60 * 1000 // 24 hours
|
secure: process.env.NODE_ENV === 'production',
|
||||||
|
sameSite: 'strict',
|
||||||
|
signed,
|
||||||
|
path: '/'
|
||||||
|
};
|
||||||
|
if (rememberMe) {
|
||||||
|
cookieOptions.maxAge = 30 * 24 * 60 * 60 * 1000; // 30 days
|
||||||
|
}
|
||||||
|
res.cookie('emby_user', cookiePayload, cookieOptions);
|
||||||
|
|
||||||
|
// Issue a CSRF token tied to this session so state-changing endpoints
|
||||||
|
// can validate the double-submit cookie pattern
|
||||||
|
const csrfToken = crypto.randomBytes(32).toString('hex');
|
||||||
|
res.cookie('csrf_token', csrfToken, {
|
||||||
|
httpOnly: false, // intentionally readable by JS for the double-submit pattern
|
||||||
|
secure: process.env.NODE_ENV === 'production',
|
||||||
|
sameSite: 'strict',
|
||||||
|
path: '/'
|
||||||
});
|
});
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
user: {
|
user: { id: user.Id, name: user.Name, isAdmin },
|
||||||
id: user.Id,
|
csrfToken
|
||||||
name: user.Name
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`[Auth] Login failed:`, error.message);
|
console.error(`[Auth] Login failed:`, error.message);
|
||||||
@@ -62,32 +110,75 @@ router.post('/login', async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function parseSessionCookie(req) {
|
||||||
|
const signed = !!process.env.COOKIE_SECRET;
|
||||||
|
const raw = signed ? req.signedCookies.emby_user : req.cookies.emby_user;
|
||||||
|
if (!raw || raw === false) return null; // false = tampered signed cookie
|
||||||
|
try {
|
||||||
|
const u = JSON.parse(raw);
|
||||||
|
// Schema validation: require id (string), name (string), isAdmin (boolean)
|
||||||
|
if (typeof u.id !== 'string' || !u.id) return null;
|
||||||
|
if (typeof u.name !== 'string' || !u.name) return null;
|
||||||
|
if (typeof u.isAdmin !== 'boolean') u.isAdmin = !!u.isAdmin;
|
||||||
|
return u;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Get current authenticated user
|
// Get current authenticated user
|
||||||
router.get('/me', (req, res) => {
|
router.get('/me', (req, res) => {
|
||||||
try {
|
const user = parseSessionCookie(req);
|
||||||
const userCookie = req.cookies.emby_user;
|
if (!user) return res.json({ authenticated: false });
|
||||||
|
res.json({
|
||||||
if (!userCookie) {
|
authenticated: true,
|
||||||
return res.json({ authenticated: false });
|
user: { id: user.id, name: user.name, isAdmin: user.isAdmin }
|
||||||
}
|
});
|
||||||
|
});
|
||||||
const user = JSON.parse(userCookie);
|
|
||||||
res.json({
|
// CSRF token refresh — lets the SPA get a new token without re-logging-in
|
||||||
authenticated: true,
|
// (e.g. after a page reload where the JS variable was lost)
|
||||||
user: {
|
router.get('/csrf', (req, res) => {
|
||||||
id: user.id,
|
const csrfToken = crypto.randomBytes(32).toString('hex');
|
||||||
name: user.name
|
res.cookie('csrf_token', csrfToken, {
|
||||||
}
|
httpOnly: false,
|
||||||
});
|
secure: process.env.NODE_ENV === 'production',
|
||||||
} catch (error) {
|
sameSite: 'strict',
|
||||||
console.error(`[Auth] Error getting current user:`, error.message);
|
path: '/'
|
||||||
res.json({ authenticated: false });
|
});
|
||||||
}
|
res.json({ csrfToken });
|
||||||
});
|
});
|
||||||
|
|
||||||
// Logout
|
// Logout
|
||||||
router.post('/logout', (req, res) => {
|
router.post('/logout', async (req, res) => {
|
||||||
res.clearCookie('emby_user');
|
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.NODE_ENV === 'production',
|
||||||
|
sameSite: 'strict',
|
||||||
|
signed: !!process.env.COOKIE_SECRET,
|
||||||
|
path: '/'
|
||||||
|
});
|
||||||
|
res.clearCookie('csrf_token', {
|
||||||
|
httpOnly: false,
|
||||||
|
secure: process.env.NODE_ENV === 'production',
|
||||||
|
sameSite: 'strict',
|
||||||
|
path: '/'
|
||||||
|
});
|
||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+399
-289
@@ -1,16 +1,14 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const axios = require('axios');
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
const requireAuth = require('../middleware/requireAuth');
|
||||||
|
|
||||||
const { getTorrents, mapTorrentToDownload } = require('../utils/qbittorrent');
|
const axios = require('axios');
|
||||||
const {
|
const { mapTorrentToDownload } = require('../utils/qbittorrent');
|
||||||
getSABnzbdInstances,
|
const cache = require('../utils/cache');
|
||||||
getSonarrInstances,
|
const { pollAllServices, getLastPollTimings, POLLING_ENABLED } = require('../utils/poller');
|
||||||
getRadarrInstances
|
const { getSonarrInstances, getRadarrInstances } = require('../utils/config');
|
||||||
} = require('../utils/config');
|
const sanitizeError = require('../utils/sanitizeError');
|
||||||
|
|
||||||
const EMBY_URL = process.env.EMBY_URL;
|
|
||||||
const EMBY_API_KEY = process.env.EMBY_API_KEY;
|
|
||||||
|
|
||||||
// Helper function to extract poster/cover art URL from a movie or series object
|
// Helper function to extract poster/cover art URL from a movie or series object
|
||||||
function getCoverArt(item) {
|
function getCoverArt(item) {
|
||||||
@@ -22,240 +20,200 @@ function getCoverArt(item) {
|
|||||||
return fanart ? (fanart.remoteUrl || fanart.url || null) : null;
|
return fanart ? (fanart.remoteUrl || fanart.url || null) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper function to extract user tag from series/movie
|
// Return all resolved tag labels for a series/movie.
|
||||||
// For Radarr: tags is array of IDs, tagMap is id -> label mapping
|
// For Radarr: tags is array of IDs, tagMap is id -> label mapping.
|
||||||
// For Sonarr: tags is array of objects with label property
|
// For Sonarr: tags are objects with a label property.
|
||||||
function extractUserTag(tags, tagMap) {
|
function extractAllTags(tags, tagMap) {
|
||||||
if (!tags || tags.length === 0) return null;
|
if (!tags || tags.length === 0) return [];
|
||||||
|
|
||||||
// If tagMap provided (Radarr), look up label by ID
|
|
||||||
if (tagMap) {
|
if (tagMap) {
|
||||||
for (const tagId of tags) {
|
return tags.map(id => tagMap.get(id)).filter(Boolean);
|
||||||
const label = tagMap.get(tagId);
|
|
||||||
if (label) return label;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
return tags.map(t => t && t.label).filter(Boolean);
|
||||||
// Sonarr style - tags are objects with label
|
}
|
||||||
const userTag = tags.find(tag => tag && tag.label);
|
|
||||||
return userTag ? userTag.label : null;
|
// Return the tag label that matches the current username, or null.
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Replicate Ombi's StringHelper.SanitizeTagLabel: lowercase, replace non-alphanumeric with hyphen, collapse, trim
|
||||||
|
function sanitizeTagLabel(input) {
|
||||||
|
if (!input) return '';
|
||||||
|
return input.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if a tag matches the username: exact match first, then sanitized match
|
||||||
|
function tagMatchesUser(tag, username) {
|
||||||
|
if (!tag || !username) return false;
|
||||||
|
const tagLower = tag.toLowerCase();
|
||||||
|
// Exact match (handles users whose tags weren't mangled)
|
||||||
|
if (tagLower === username) return true;
|
||||||
|
// Sanitized match (handles Ombi-mangled tags for email-style usernames)
|
||||||
|
if (tagLower === sanitizeTagLabel(username)) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract import issues from a Sonarr/Radarr queue record
|
||||||
|
function getImportIssues(queueRecord) {
|
||||||
|
if (!queueRecord) return null;
|
||||||
|
const state = queueRecord.trackedDownloadState;
|
||||||
|
const status = queueRecord.trackedDownloadStatus;
|
||||||
|
if (state !== 'importPending' && status !== 'warning' && status !== 'error') return null;
|
||||||
|
const messages = [];
|
||||||
|
if (queueRecord.statusMessages && queueRecord.statusMessages.length > 0) {
|
||||||
|
for (const sm of queueRecord.statusMessages) {
|
||||||
|
if (sm.messages && sm.messages.length > 0) {
|
||||||
|
messages.push(...sm.messages);
|
||||||
|
} else if (sm.title) {
|
||||||
|
messages.push(sm.title);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (queueRecord.errorMessage) {
|
||||||
|
messages.push(queueRecord.errorMessage);
|
||||||
|
}
|
||||||
|
if (messages.length === 0) return null;
|
||||||
|
return messages;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to build Sonarr web UI link for a series
|
||||||
|
function getSonarrLink(series) {
|
||||||
|
if (!series || !series._instanceUrl || !series.titleSlug) return null;
|
||||||
|
return `${series._instanceUrl}/series/${series.titleSlug}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to build Radarr web UI link for a movie
|
||||||
|
function getRadarrLink(movie) {
|
||||||
|
if (!movie || !movie._instanceUrl || !movie.titleSlug) return null;
|
||||||
|
return `${movie._instanceUrl}/movie/${movie.titleSlug}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch all Emby users and return a Map<lowerName -> displayName> (and sanitized variants).
|
||||||
|
// Result is cached for 60s to avoid hammering Emby on every dashboard poll.
|
||||||
|
async function getEmbyUsers() {
|
||||||
|
const cached = cache.get('emby:users');
|
||||||
|
if (cached) return cached;
|
||||||
|
try {
|
||||||
|
const response = await axios.get(`${process.env.EMBY_URL}/Users`, {
|
||||||
|
headers: { 'X-MediaBrowser-Token': process.env.EMBY_API_KEY }
|
||||||
|
});
|
||||||
|
// Build map: both raw lowercase and sanitized form -> display name
|
||||||
|
const map = new Map();
|
||||||
|
for (const u of response.data) {
|
||||||
|
const name = u.Name || '';
|
||||||
|
map.set(name.toLowerCase(), name);
|
||||||
|
map.set(sanitizeTagLabel(name), name);
|
||||||
|
}
|
||||||
|
cache.set('emby:users', map, 60000);
|
||||||
|
return map;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Dashboard] Failed to fetch Emby users:', err.message);
|
||||||
|
return new Map();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Classify each tag label: matched to a known Emby user, or unmatched.
|
||||||
|
// Returns array of { label, matchedUser: string|null }
|
||||||
|
function buildTagBadges(allTags, embyUserMap) {
|
||||||
|
return allTags.map(label => {
|
||||||
|
const lower = label.toLowerCase();
|
||||||
|
const sanitized = sanitizeTagLabel(label);
|
||||||
|
const displayName = embyUserMap.get(lower) || embyUserMap.get(sanitized) || null;
|
||||||
|
return { label, matchedUser: displayName };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track active dashboard clients: Map<username, { refreshRateMs, lastSeen }>
|
||||||
|
const activeClients = new Map();
|
||||||
|
const CLIENT_STALE_MS = 30000; // consider client gone after 30s of no requests
|
||||||
|
|
||||||
|
function getActiveClients() {
|
||||||
|
const now = Date.now();
|
||||||
|
// Prune stale clients
|
||||||
|
for (const [key, client] of activeClients.entries()) {
|
||||||
|
if (now - client.lastSeen > CLIENT_STALE_MS) activeClients.delete(key);
|
||||||
|
}
|
||||||
|
return Array.from(activeClients.values());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get user downloads for authenticated user
|
// Get user downloads for authenticated user
|
||||||
router.get('/user-downloads', async (req, res) => {
|
router.get('/user-downloads', requireAuth, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
// Get authenticated user from cookie
|
const user = req.user;
|
||||||
const userCookie = req.cookies.emby_user;
|
|
||||||
if (!userCookie) {
|
|
||||||
return res.status(401).json({ error: 'Not authenticated' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const user = JSON.parse(userCookie);
|
|
||||||
const username = user.name.toLowerCase();
|
const username = user.name.toLowerCase();
|
||||||
console.log(`[Dashboard] Fetching downloads for authenticated user: ${user.name} (${username})`);
|
const usernameSanitized = sanitizeTagLabel(user.name);
|
||||||
|
const isAdmin = !!user.isAdmin;
|
||||||
|
const showAll = isAdmin && req.query.showAll === 'true';
|
||||||
|
console.log(`[Dashboard] Serving downloads for user: ${user.name} (${username}), isAdmin: ${isAdmin}, showAll: ${showAll}`);
|
||||||
|
|
||||||
// Get all service instances
|
// Track this client's refresh rate
|
||||||
const sabInstances = getSABnzbdInstances();
|
const clientRefreshRate = parseInt(req.query.refreshRate, 10);
|
||||||
const sonarrInstances = getSonarrInstances();
|
if (clientRefreshRate > 0) {
|
||||||
const radarrInstances = getRadarrInstances();
|
activeClients.set(username, { user: user.name, refreshRateMs: clientRefreshRate, lastSeen: Date.now() });
|
||||||
|
} else {
|
||||||
console.log(`[Dashboard] Fetching data from all services...`);
|
// Client has refresh off or didn't send — still mark as seen but with no rate
|
||||||
console.log(`[Dashboard] SABnzbd instances: ${sabInstances.length}`);
|
activeClients.set(username, { user: user.name, refreshRateMs: 0, lastSeen: Date.now() });
|
||||||
console.log(`[Dashboard] Sonarr instances: ${sonarrInstances.length}`);
|
}
|
||||||
console.log(`[Dashboard] Radarr instances: ${radarrInstances.length}`);
|
|
||||||
|
// When polling is disabled, fetch on-demand if cache has expired
|
||||||
// Fetch from all SABnzbd instances
|
// The fetched data is cached (30s TTL) so subsequent requests from any user reuse it
|
||||||
const sabQueuePromises = sabInstances.map(inst =>
|
if (!POLLING_ENABLED && !cache.get('poll:sab-queue')) {
|
||||||
axios.get(`${inst.url}/api`, {
|
console.log(`[Dashboard] Cache expired and polling disabled, fetching on-demand...`);
|
||||||
params: { mode: 'queue', apikey: inst.apiKey, output: 'json' }
|
await pollAllServices();
|
||||||
}).then(res => ({ instance: inst.id, data: res.data })).catch(err => {
|
}
|
||||||
console.error(`[Dashboard] SABnzbd ${inst.id} queue error:`, err.message);
|
|
||||||
return { instance: inst.id, data: { queue: { slots: [] } } };
|
// Read all data from cache
|
||||||
})
|
const sabQueueData = cache.get('poll:sab-queue') || { slots: [] };
|
||||||
);
|
const sabHistoryData = cache.get('poll:sab-history') || { slots: [] };
|
||||||
|
const sonarrTagsResults = cache.get('poll:sonarr-tags') || [];
|
||||||
const sabHistoryPromises = sabInstances.map(inst =>
|
const sonarrQueueData = cache.get('poll:sonarr-queue') || { records: [] };
|
||||||
axios.get(`${inst.url}/api`, {
|
const sonarrHistoryData = cache.get('poll:sonarr-history') || { records: [] };
|
||||||
params: { mode: 'history', apikey: inst.apiKey, output: 'json', limit: 100 }
|
const radarrQueueData = cache.get('poll:radarr-queue') || { records: [] };
|
||||||
}).then(res => ({ instance: inst.id, data: res.data })).catch(err => {
|
const radarrHistoryData = cache.get('poll:radarr-history') || { records: [] };
|
||||||
console.error(`[Dashboard] SABnzbd ${inst.id} history error:`, err.message);
|
const radarrTagsData = cache.get('poll:radarr-tags') || [];
|
||||||
return { instance: inst.id, data: { history: { slots: [] } } };
|
const qbittorrentTorrents = cache.get('poll:qbittorrent') || [];
|
||||||
})
|
|
||||||
);
|
// Wrap in the structure the rest of the code expects
|
||||||
|
const sabnzbdQueue = { data: { queue: sabQueueData } };
|
||||||
// Fetch from all Sonarr instances
|
const sabnzbdHistory = { data: { history: sabHistoryData } };
|
||||||
const sonarrTagsPromises = sonarrInstances.map(inst =>
|
const sonarrQueue = { data: sonarrQueueData };
|
||||||
axios.get(`${inst.url}/api/v3/tag`, {
|
const sonarrHistory = { data: sonarrHistoryData };
|
||||||
headers: { 'X-Api-Key': inst.apiKey }
|
const radarrQueue = { data: radarrQueueData };
|
||||||
}).then(res => ({ instance: inst.id, data: res.data })).catch(err => {
|
const radarrHistory = { data: radarrHistoryData };
|
||||||
console.error(`[Dashboard] Sonarr ${inst.id} tags error:`, err.message);
|
const radarrTags = { data: radarrTagsData };
|
||||||
return { instance: inst.id, data: [] };
|
|
||||||
})
|
// Build series/movie maps from embedded objects in queue records
|
||||||
);
|
// (history is fetched without includeSeries/includeMovie for speed;
|
||||||
|
// history matches fall back to the queue-built map via seriesId/movieId)
|
||||||
const sonarrQueuePromises = sonarrInstances.map(inst =>
|
const seriesMap = new Map();
|
||||||
axios.get(`${inst.url}/api/v3/queue`, {
|
for (const r of sonarrQueue.data.records) {
|
||||||
headers: { 'X-Api-Key': inst.apiKey },
|
if (r.series && r.seriesId) seriesMap.set(r.seriesId, r.series);
|
||||||
params: { includeSeries: true, includeEpisode: true }
|
}
|
||||||
}).then(res => ({ instance: inst.id, data: res.data })).catch(err => {
|
for (const r of sonarrHistory.data.records) {
|
||||||
console.error(`[Dashboard] Sonarr ${inst.id} queue error:`, err.message);
|
if (r.series && r.seriesId && !seriesMap.has(r.seriesId)) seriesMap.set(r.seriesId, r.series);
|
||||||
return { instance: inst.id, data: { records: [] } };
|
}
|
||||||
})
|
const moviesMap = new Map();
|
||||||
);
|
for (const r of radarrQueue.data.records) {
|
||||||
|
if (r.movie && r.movieId) moviesMap.set(r.movieId, r.movie);
|
||||||
const sonarrHistoryPromises = sonarrInstances.map(inst =>
|
}
|
||||||
axios.get(`${inst.url}/api/v3/history`, {
|
for (const r of radarrHistory.data.records) {
|
||||||
headers: { 'X-Api-Key': inst.apiKey },
|
if (r.movie && r.movieId && !moviesMap.has(r.movieId)) moviesMap.set(r.movieId, r.movie);
|
||||||
params: { pageSize: 100, includeSeries: true, includeEpisode: true }
|
}
|
||||||
}).then(res => ({ instance: inst.id, data: res.data })).catch(err => {
|
|
||||||
console.error(`[Dashboard] Sonarr ${inst.id} history error:`, err.message);
|
|
||||||
return { instance: inst.id, data: { records: [] } };
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
const sonarrSeriesPromises = sonarrInstances.map(inst =>
|
|
||||||
axios.get(`${inst.url}/api/v3/series`, {
|
|
||||||
headers: { 'X-Api-Key': inst.apiKey }
|
|
||||||
}).then(res => ({ instance: inst.id, data: res.data })).catch(err => {
|
|
||||||
console.error(`[Dashboard] Sonarr ${inst.id} series error:`, err.message);
|
|
||||||
return { instance: inst.id, data: [] };
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
// Fetch from all Radarr instances
|
|
||||||
const radarrQueuePromises = 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(`[Dashboard] Radarr ${inst.id} queue error:`, err.message);
|
|
||||||
return { instance: inst.id, data: { records: [] } };
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
const radarrHistoryPromises = radarrInstances.map(inst =>
|
|
||||||
axios.get(`${inst.url}/api/v3/history`, {
|
|
||||||
headers: { 'X-Api-Key': inst.apiKey },
|
|
||||||
params: { pageSize: 100, includeMovie: true }
|
|
||||||
}).then(res => ({ instance: inst.id, data: res.data })).catch(err => {
|
|
||||||
console.error(`[Dashboard] Radarr ${inst.id} history error:`, err.message);
|
|
||||||
return { instance: inst.id, data: { records: [] } };
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
const radarrMoviesPromises = radarrInstances.map(inst =>
|
|
||||||
axios.get(`${inst.url}/api/v3/movie`, {
|
|
||||||
headers: { 'X-Api-Key': inst.apiKey }
|
|
||||||
}).then(res => ({ instance: inst.id, data: res.data })).catch(err => {
|
|
||||||
console.error(`[Dashboard] Radarr ${inst.id} movies error:`, err.message);
|
|
||||||
return { instance: inst.id, data: [] };
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
const radarrTagsPromises = 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(`[Dashboard] Radarr ${inst.id} tags error:`, err.message);
|
|
||||||
return { instance: inst.id, data: [] };
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
// Execute all requests
|
|
||||||
const [
|
|
||||||
sabQueues, sabHistories, sonarrTagsResults, sonarrQueues, sonarrHistories, sonarrSeriesResults,
|
|
||||||
radarrQueues, radarrHistories, radarrMoviesResults, radarrTagsResults,
|
|
||||||
qbittorrentTorrents
|
|
||||||
] = await Promise.all([
|
|
||||||
Promise.all(sabQueuePromises),
|
|
||||||
Promise.all(sabHistoryPromises),
|
|
||||||
Promise.all(sonarrTagsPromises),
|
|
||||||
Promise.all(sonarrQueuePromises),
|
|
||||||
Promise.all(sonarrHistoryPromises),
|
|
||||||
Promise.all(sonarrSeriesPromises),
|
|
||||||
Promise.all(radarrQueuePromises),
|
|
||||||
Promise.all(radarrHistoryPromises),
|
|
||||||
Promise.all(radarrMoviesPromises),
|
|
||||||
Promise.all(radarrTagsPromises),
|
|
||||||
getTorrents().catch(err => {
|
|
||||||
console.error(`[Dashboard] qBittorrent error:`, err.message);
|
|
||||||
return [];
|
|
||||||
})
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Aggregate data from all instances
|
|
||||||
const firstSabQueue = sabQueues[0] && sabQueues[0].data && sabQueues[0].data.queue;
|
|
||||||
const sabnzbdQueue = {
|
|
||||||
data: {
|
|
||||||
queue: {
|
|
||||||
slots: sabQueues.flatMap(q => (q.data.queue && q.data.queue.slots) || []),
|
|
||||||
status: firstSabQueue && firstSabQueue.status,
|
|
||||||
speed: firstSabQueue && firstSabQueue.speed,
|
|
||||||
kbpersec: firstSabQueue && firstSabQueue.kbpersec
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const sabnzbdHistory = {
|
|
||||||
data: {
|
|
||||||
history: {
|
|
||||||
slots: sabHistories.flatMap(h => (h.data.history && h.data.history.slots) || [])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const sonarrQueue = {
|
|
||||||
data: {
|
|
||||||
records: sonarrQueues.flatMap(q => q.data.records || [])
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const sonarrHistory = {
|
|
||||||
data: {
|
|
||||||
records: sonarrHistories.flatMap(h => h.data.records || [])
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const sonarrSeries = {
|
|
||||||
data: sonarrSeriesResults.flatMap(s => s.data || [])
|
|
||||||
};
|
|
||||||
const radarrQueue = {
|
|
||||||
data: {
|
|
||||||
records: radarrQueues.flatMap(q => q.data.records || [])
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const radarrHistory = {
|
|
||||||
data: {
|
|
||||||
records: radarrHistories.flatMap(h => h.data.records || [])
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const radarrMovies = {
|
|
||||||
data: radarrMoviesResults.flatMap(m => m.data || [])
|
|
||||||
};
|
|
||||||
const radarrTags = {
|
|
||||||
data: radarrTagsResults.flatMap(t => t.data || [])
|
|
||||||
};
|
|
||||||
|
|
||||||
console.log(`[Dashboard] Data fetched successfully`);
|
|
||||||
console.log(`[Dashboard] Sonarr series: ${sonarrSeries.data.length}`);
|
|
||||||
console.log(`[Dashboard] Radarr movies: ${radarrMovies.data.length}`);
|
|
||||||
console.log(`[Dashboard] Radarr queue records: ${radarrQueue.data.records.length}`);
|
|
||||||
console.log(`[Dashboard] Radarr history records: ${radarrHistory.data.records.length}`);
|
|
||||||
console.log(`[Dashboard] SABnzbd queue slots: ${sabnzbdQueue.data.queue ? sabnzbdQueue.data.queue.slots.length : 0}`);
|
|
||||||
console.log(`[Dashboard] SABnzbd history slots: ${sabnzbdHistory.data.history ? sabnzbdHistory.data.history.slots.length : 0}`);
|
|
||||||
console.log(`[Dashboard] qBittorrent torrents: ${qbittorrentTorrents.length}`);
|
|
||||||
console.log(`[Dashboard] Radarr queue:`, JSON.stringify(radarrQueue.data.records));
|
|
||||||
console.log(`[Dashboard] Radarr history:`, JSON.stringify(radarrHistory.data.records));
|
|
||||||
|
|
||||||
// Create maps for quick lookup
|
|
||||||
const seriesMap = new Map(sonarrSeries.data.map(s => [s.id, s]));
|
|
||||||
const moviesMap = new Map(radarrMovies.data.map(m => [m.id, m]));
|
|
||||||
|
|
||||||
// Create tag maps (id -> label)
|
// Create tag maps (id -> label)
|
||||||
const sonarrTagMap = new Map(sonarrTagsResults.flatMap(t => t.data || []).map(t => [t.id, t.label]));
|
const sonarrTagMap = new Map(sonarrTagsResults.flatMap(t => t.data || []).map(t => [t.id, t.label]));
|
||||||
const radarrTagMap = new Map(radarrTags.data.map(t => [t.id, t.label]));
|
const radarrTagMap = new Map(radarrTags.data.map(t => [t.id, t.label]));
|
||||||
console.log(`[Dashboard] Radarr tags:`, JSON.stringify(radarrTags.data));
|
|
||||||
console.log(`[Dashboard] Movies map keys:`, Array.from(moviesMap.keys()).slice(0, 10));
|
// When showing all downloads, fetch full Emby user list to classify tags
|
||||||
console.log(`[Dashboard] Looking for movieId: 2962`);
|
const embyUserMap = showAll ? await getEmbyUsers() : new Map();
|
||||||
console.log(`[Dashboard] Movie 2962:`, JSON.stringify(moviesMap.get(2962)));
|
|
||||||
console.log(`[Dashboard] Sample movie structure:`, JSON.stringify(radarrMovies.data[0]));
|
console.log(`[Dashboard] Cache data - Series: ${seriesMap.size}, Movies: ${moviesMap.size}, qBit: ${qbittorrentTorrents.length}`);
|
||||||
|
|
||||||
// Match SABnzbd downloads to Sonarr/Radarr activity
|
// Match SABnzbd downloads to Sonarr/Radarr activity
|
||||||
const userDownloads = [];
|
const userDownloads = [];
|
||||||
@@ -300,9 +258,11 @@ router.get('/user-downloads', async (req, res) => {
|
|||||||
if (sonarrMatch && sonarrMatch.seriesId) {
|
if (sonarrMatch && sonarrMatch.seriesId) {
|
||||||
const series = seriesMap.get(sonarrMatch.seriesId) || sonarrMatch.series;
|
const series = seriesMap.get(sonarrMatch.seriesId) || sonarrMatch.series;
|
||||||
if (series) {
|
if (series) {
|
||||||
const userTag = extractUserTag(series.tags, sonarrTagMap);
|
const allTags = extractAllTags(series.tags, sonarrTagMap);
|
||||||
if (userTag && userTag.toLowerCase() === username) {
|
const matchedUserTag = extractUserTag(series.tags, sonarrTagMap, username);
|
||||||
userDownloads.push({
|
const hasAnyTag = allTags.length > 0;
|
||||||
|
if (showAll ? hasAnyTag : !!matchedUserTag) {
|
||||||
|
const dlObj = {
|
||||||
type: 'series',
|
type: 'series',
|
||||||
title: nzbName,
|
title: nzbName,
|
||||||
coverArt: getCoverArt(series),
|
coverArt: getCoverArt(series),
|
||||||
@@ -314,8 +274,19 @@ router.get('/user-downloads', async (req, res) => {
|
|||||||
speed: slotState.speed,
|
speed: slotState.speed,
|
||||||
eta: slot.timeleft,
|
eta: slot.timeleft,
|
||||||
seriesName: series.title,
|
seriesName: series.title,
|
||||||
episodeInfo: sonarrMatch
|
episodeInfo: sonarrMatch,
|
||||||
});
|
allTags,
|
||||||
|
matchedUserTag: matchedUserTag || null,
|
||||||
|
tagBadges: showAll ? buildTagBadges(allTags, embyUserMap) : undefined
|
||||||
|
};
|
||||||
|
const issues = getImportIssues(sonarrMatch);
|
||||||
|
if (issues) dlObj.importIssues = issues;
|
||||||
|
if (isAdmin) {
|
||||||
|
dlObj.downloadPath = slot.storage || null;
|
||||||
|
dlObj.targetPath = series.path || null;
|
||||||
|
dlObj.arrLink = getSonarrLink(series);
|
||||||
|
}
|
||||||
|
userDownloads.push(dlObj);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -329,9 +300,11 @@ router.get('/user-downloads', async (req, res) => {
|
|||||||
if (radarrMatch && radarrMatch.movieId) {
|
if (radarrMatch && radarrMatch.movieId) {
|
||||||
const movie = moviesMap.get(radarrMatch.movieId) || radarrMatch.movie;
|
const movie = moviesMap.get(radarrMatch.movieId) || radarrMatch.movie;
|
||||||
if (movie) {
|
if (movie) {
|
||||||
const userTag = extractUserTag(movie.tags, radarrTagMap);
|
const allTags = extractAllTags(movie.tags, radarrTagMap);
|
||||||
if (userTag && userTag.toLowerCase() === username) {
|
const matchedUserTag = extractUserTag(movie.tags, radarrTagMap, username);
|
||||||
userDownloads.push({
|
const hasAnyTag = allTags.length > 0;
|
||||||
|
if (showAll ? hasAnyTag : !!matchedUserTag) {
|
||||||
|
const dlObj = {
|
||||||
type: 'movie',
|
type: 'movie',
|
||||||
title: nzbName,
|
title: nzbName,
|
||||||
coverArt: getCoverArt(movie),
|
coverArt: getCoverArt(movie),
|
||||||
@@ -343,8 +316,19 @@ router.get('/user-downloads', async (req, res) => {
|
|||||||
speed: slotState.speed,
|
speed: slotState.speed,
|
||||||
eta: slot.timeleft,
|
eta: slot.timeleft,
|
||||||
movieName: movie.title,
|
movieName: movie.title,
|
||||||
movieInfo: radarrMatch
|
movieInfo: radarrMatch,
|
||||||
});
|
allTags,
|
||||||
|
matchedUserTag: matchedUserTag || null,
|
||||||
|
tagBadges: showAll ? buildTagBadges(allTags, embyUserMap) : undefined
|
||||||
|
};
|
||||||
|
const issues = getImportIssues(radarrMatch);
|
||||||
|
if (issues) dlObj.importIssues = issues;
|
||||||
|
if (isAdmin) {
|
||||||
|
dlObj.downloadPath = slot.storage || null;
|
||||||
|
dlObj.targetPath = movie.path || null;
|
||||||
|
dlObj.arrLink = getRadarrLink(movie);
|
||||||
|
}
|
||||||
|
userDownloads.push(dlObj);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -375,9 +359,11 @@ router.get('/user-downloads', async (req, res) => {
|
|||||||
if (sonarrMatch && sonarrMatch.seriesId) {
|
if (sonarrMatch && sonarrMatch.seriesId) {
|
||||||
const series = seriesMap.get(sonarrMatch.seriesId) || sonarrMatch.series;
|
const series = seriesMap.get(sonarrMatch.seriesId) || sonarrMatch.series;
|
||||||
if (series) {
|
if (series) {
|
||||||
const userTag = extractUserTag(series.tags, sonarrTagMap);
|
const allTags = extractAllTags(series.tags, sonarrTagMap);
|
||||||
if (userTag && userTag.toLowerCase() === username) {
|
const matchedUserTag = extractUserTag(series.tags, sonarrTagMap, username);
|
||||||
userDownloads.push({
|
const hasAnyTag = allTags.length > 0;
|
||||||
|
if (showAll ? hasAnyTag : !!matchedUserTag) {
|
||||||
|
const dlObj = {
|
||||||
type: 'series',
|
type: 'series',
|
||||||
title: nzbName,
|
title: nzbName,
|
||||||
coverArt: getCoverArt(series),
|
coverArt: getCoverArt(series),
|
||||||
@@ -385,8 +371,17 @@ router.get('/user-downloads', async (req, res) => {
|
|||||||
size: slot.size,
|
size: slot.size,
|
||||||
completedAt: slot.completed_time,
|
completedAt: slot.completed_time,
|
||||||
seriesName: series.title,
|
seriesName: series.title,
|
||||||
episodeInfo: sonarrMatch
|
episodeInfo: sonarrMatch,
|
||||||
});
|
allTags,
|
||||||
|
matchedUserTag: matchedUserTag || null,
|
||||||
|
tagBadges: showAll ? buildTagBadges(allTags, embyUserMap) : undefined
|
||||||
|
};
|
||||||
|
if (isAdmin) {
|
||||||
|
dlObj.downloadPath = slot.storage || null;
|
||||||
|
dlObj.targetPath = series.path || null;
|
||||||
|
dlObj.arrLink = getSonarrLink(series);
|
||||||
|
}
|
||||||
|
userDownloads.push(dlObj);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -400,9 +395,11 @@ router.get('/user-downloads', async (req, res) => {
|
|||||||
if (radarrMatch && radarrMatch.movieId) {
|
if (radarrMatch && radarrMatch.movieId) {
|
||||||
const movie = moviesMap.get(radarrMatch.movieId) || radarrMatch.movie;
|
const movie = moviesMap.get(radarrMatch.movieId) || radarrMatch.movie;
|
||||||
if (movie) {
|
if (movie) {
|
||||||
const userTag = extractUserTag(movie.tags, radarrTagMap);
|
const allTags = extractAllTags(movie.tags, radarrTagMap);
|
||||||
if (userTag && userTag.toLowerCase() === username) {
|
const matchedUserTag = extractUserTag(movie.tags, radarrTagMap, username);
|
||||||
userDownloads.push({
|
const hasAnyTag = allTags.length > 0;
|
||||||
|
if (showAll ? hasAnyTag : !!matchedUserTag) {
|
||||||
|
const dlObj = {
|
||||||
type: 'movie',
|
type: 'movie',
|
||||||
title: nzbName,
|
title: nzbName,
|
||||||
coverArt: getCoverArt(movie),
|
coverArt: getCoverArt(movie),
|
||||||
@@ -410,8 +407,17 @@ router.get('/user-downloads', async (req, res) => {
|
|||||||
size: slot.size,
|
size: slot.size,
|
||||||
completedAt: slot.completed_time,
|
completedAt: slot.completed_time,
|
||||||
movieName: movie.title,
|
movieName: movie.title,
|
||||||
movieInfo: radarrMatch
|
movieInfo: radarrMatch,
|
||||||
});
|
allTags,
|
||||||
|
matchedUserTag: matchedUserTag || null,
|
||||||
|
tagBadges: showAll ? buildTagBadges(allTags, embyUserMap) : undefined
|
||||||
|
};
|
||||||
|
if (isAdmin) {
|
||||||
|
dlObj.downloadPath = slot.storage || null;
|
||||||
|
dlObj.targetPath = movie.path || null;
|
||||||
|
dlObj.arrLink = getRadarrLink(movie);
|
||||||
|
}
|
||||||
|
userDownloads.push(dlObj);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -430,14 +436,12 @@ router.get('/user-downloads', async (req, res) => {
|
|||||||
console.log(`[Dashboard] Sonarr tag map:`, Array.from(sonarrTagMap.entries()));
|
console.log(`[Dashboard] Sonarr tag map:`, Array.from(sonarrTagMap.entries()));
|
||||||
console.log(`[Dashboard] Radarr tag map:`, Array.from(radarrTagMap.entries()));
|
console.log(`[Dashboard] Radarr tag map:`, Array.from(radarrTagMap.entries()));
|
||||||
|
|
||||||
// Show movies/series tagged for this user
|
// Show movies/series tagged for this user (from embedded objects in queue/history)
|
||||||
const userMovies = radarrMovies.data.filter(m => {
|
const userMovies = Array.from(moviesMap.values()).filter(m => {
|
||||||
const tag = extractUserTag(m.tags, radarrTagMap);
|
return !!extractUserTag(m.tags, radarrTagMap, username);
|
||||||
return tag && tag.toLowerCase() === username;
|
|
||||||
});
|
});
|
||||||
const userSeries = sonarrSeries.data.filter(s => {
|
const userSeries = Array.from(seriesMap.values()).filter(s => {
|
||||||
const tag = extractUserTag(s.tags, sonarrTagMap);
|
return !!extractUserTag(s.tags, sonarrTagMap, username);
|
||||||
return tag && tag.toLowerCase() === username;
|
|
||||||
});
|
});
|
||||||
console.log(`[Dashboard] Movies tagged for ${username}:`, userMovies.map(m => m.title));
|
console.log(`[Dashboard] Movies tagged for ${username}:`, userMovies.map(m => m.title));
|
||||||
console.log(`[Dashboard] Series tagged for ${username}:`, userSeries.map(s => s.title));
|
console.log(`[Dashboard] Series tagged for ${username}:`, userSeries.map(s => s.title));
|
||||||
@@ -461,15 +465,26 @@ router.get('/user-downloads', async (req, res) => {
|
|||||||
if (sonarrMatch && sonarrMatch.seriesId) {
|
if (sonarrMatch && sonarrMatch.seriesId) {
|
||||||
const series = seriesMap.get(sonarrMatch.seriesId) || sonarrMatch.series;
|
const series = seriesMap.get(sonarrMatch.seriesId) || sonarrMatch.series;
|
||||||
if (series) {
|
if (series) {
|
||||||
const userTag = extractUserTag(series.tags, sonarrTagMap);
|
const allTags = extractAllTags(series.tags, sonarrTagMap);
|
||||||
if (userTag && userTag.toLowerCase() === username) {
|
const matchedUserTag = extractUserTag(series.tags, sonarrTagMap, username);
|
||||||
|
const hasAnyTag = allTags.length > 0;
|
||||||
|
if (showAll ? hasAnyTag : !!matchedUserTag) {
|
||||||
console.log(`[Dashboard] Matched torrent "${torrentName}" to Sonarr series "${series.title}"`);
|
console.log(`[Dashboard] Matched torrent "${torrentName}" to Sonarr series "${series.title}"`);
|
||||||
const download = mapTorrentToDownload(torrent);
|
const download = mapTorrentToDownload(torrent);
|
||||||
download.type = 'series';
|
download.type = 'series';
|
||||||
download.coverArt = getCoverArt(series);
|
download.coverArt = getCoverArt(series);
|
||||||
download.seriesName = series.title;
|
download.seriesName = series.title;
|
||||||
download.episodeInfo = sonarrMatch;
|
download.episodeInfo = sonarrMatch;
|
||||||
download.userTag = userTag;
|
download.allTags = allTags;
|
||||||
|
download.matchedUserTag = matchedUserTag || null;
|
||||||
|
download.tagBadges = showAll ? buildTagBadges(allTags, embyUserMap) : undefined;
|
||||||
|
const sonarrIssues = getImportIssues(sonarrMatch);
|
||||||
|
if (sonarrIssues) download.importIssues = sonarrIssues;
|
||||||
|
if (isAdmin) {
|
||||||
|
download.downloadPath = download.savePath || null;
|
||||||
|
download.targetPath = series.path || null;
|
||||||
|
download.arrLink = getSonarrLink(series);
|
||||||
|
}
|
||||||
userDownloads.push(download);
|
userDownloads.push(download);
|
||||||
continue; // Skip to next torrent
|
continue; // Skip to next torrent
|
||||||
}
|
}
|
||||||
@@ -485,15 +500,26 @@ router.get('/user-downloads', async (req, res) => {
|
|||||||
if (radarrMatch && radarrMatch.movieId) {
|
if (radarrMatch && radarrMatch.movieId) {
|
||||||
const movie = moviesMap.get(radarrMatch.movieId) || radarrMatch.movie;
|
const movie = moviesMap.get(radarrMatch.movieId) || radarrMatch.movie;
|
||||||
if (movie) {
|
if (movie) {
|
||||||
const userTag = extractUserTag(movie.tags, radarrTagMap);
|
const allTags = extractAllTags(movie.tags, radarrTagMap);
|
||||||
if (userTag && userTag.toLowerCase() === username) {
|
const matchedUserTag = extractUserTag(movie.tags, radarrTagMap, username);
|
||||||
|
const hasAnyTag = allTags.length > 0;
|
||||||
|
if (showAll ? hasAnyTag : !!matchedUserTag) {
|
||||||
console.log(`[Dashboard] Matched torrent "${torrentName}" to Radarr movie "${movie.title}"`);
|
console.log(`[Dashboard] Matched torrent "${torrentName}" to Radarr movie "${movie.title}"`);
|
||||||
const download = mapTorrentToDownload(torrent);
|
const download = mapTorrentToDownload(torrent);
|
||||||
download.type = 'movie';
|
download.type = 'movie';
|
||||||
download.coverArt = getCoverArt(movie);
|
download.coverArt = getCoverArt(movie);
|
||||||
download.movieName = movie.title;
|
download.movieName = movie.title;
|
||||||
download.movieInfo = radarrMatch;
|
download.movieInfo = radarrMatch;
|
||||||
download.userTag = userTag;
|
download.allTags = allTags;
|
||||||
|
download.matchedUserTag = matchedUserTag || null;
|
||||||
|
download.tagBadges = showAll ? buildTagBadges(allTags, embyUserMap) : undefined;
|
||||||
|
const radarrIssues = getImportIssues(radarrMatch);
|
||||||
|
if (radarrIssues) download.importIssues = radarrIssues;
|
||||||
|
if (isAdmin) {
|
||||||
|
download.downloadPath = download.savePath || null;
|
||||||
|
download.targetPath = movie.path || null;
|
||||||
|
download.arrLink = getRadarrLink(movie);
|
||||||
|
}
|
||||||
userDownloads.push(download);
|
userDownloads.push(download);
|
||||||
continue; // Skip to next torrent
|
continue; // Skip to next torrent
|
||||||
}
|
}
|
||||||
@@ -509,15 +535,24 @@ router.get('/user-downloads', async (req, res) => {
|
|||||||
if (sonarrHistoryMatch && sonarrHistoryMatch.seriesId) {
|
if (sonarrHistoryMatch && sonarrHistoryMatch.seriesId) {
|
||||||
const series = seriesMap.get(sonarrHistoryMatch.seriesId) || sonarrHistoryMatch.series;
|
const series = seriesMap.get(sonarrHistoryMatch.seriesId) || sonarrHistoryMatch.series;
|
||||||
if (series) {
|
if (series) {
|
||||||
const userTag = extractUserTag(series.tags, sonarrTagMap);
|
const allTags = extractAllTags(series.tags, sonarrTagMap);
|
||||||
if (userTag && userTag.toLowerCase() === username) {
|
const matchedUserTag = extractUserTag(series.tags, sonarrTagMap, username);
|
||||||
|
const hasAnyTag = allTags.length > 0;
|
||||||
|
if (showAll ? hasAnyTag : !!matchedUserTag) {
|
||||||
console.log(`[Dashboard] Matched torrent "${torrentName}" to Sonarr history "${series.title}"`);
|
console.log(`[Dashboard] Matched torrent "${torrentName}" to Sonarr history "${series.title}"`);
|
||||||
const download = mapTorrentToDownload(torrent);
|
const download = mapTorrentToDownload(torrent);
|
||||||
download.type = 'series';
|
download.type = 'series';
|
||||||
download.coverArt = getCoverArt(series);
|
download.coverArt = getCoverArt(series);
|
||||||
download.seriesName = series.title;
|
download.seriesName = series.title;
|
||||||
download.episodeInfo = sonarrHistoryMatch;
|
download.episodeInfo = sonarrHistoryMatch;
|
||||||
download.userTag = userTag;
|
download.allTags = allTags;
|
||||||
|
download.matchedUserTag = matchedUserTag || null;
|
||||||
|
download.tagBadges = showAll ? buildTagBadges(allTags, embyUserMap) : undefined;
|
||||||
|
if (isAdmin) {
|
||||||
|
download.downloadPath = download.savePath || null;
|
||||||
|
download.targetPath = series.path || null;
|
||||||
|
download.arrLink = getSonarrLink(series);
|
||||||
|
}
|
||||||
userDownloads.push(download);
|
userDownloads.push(download);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -533,15 +568,24 @@ router.get('/user-downloads', async (req, res) => {
|
|||||||
if (radarrHistoryMatch && radarrHistoryMatch.movieId) {
|
if (radarrHistoryMatch && radarrHistoryMatch.movieId) {
|
||||||
const movie = moviesMap.get(radarrHistoryMatch.movieId) || radarrHistoryMatch.movie;
|
const movie = moviesMap.get(radarrHistoryMatch.movieId) || radarrHistoryMatch.movie;
|
||||||
if (movie) {
|
if (movie) {
|
||||||
const userTag = extractUserTag(movie.tags, radarrTagMap);
|
const allTags = extractAllTags(movie.tags, radarrTagMap);
|
||||||
if (userTag && userTag.toLowerCase() === username) {
|
const matchedUserTag = extractUserTag(movie.tags, radarrTagMap, username);
|
||||||
|
const hasAnyTag = allTags.length > 0;
|
||||||
|
if (showAll ? hasAnyTag : !!matchedUserTag) {
|
||||||
console.log(`[Dashboard] Matched torrent "${torrentName}" to Radarr history "${movie.title}"`);
|
console.log(`[Dashboard] Matched torrent "${torrentName}" to Radarr history "${movie.title}"`);
|
||||||
const download = mapTorrentToDownload(torrent);
|
const download = mapTorrentToDownload(torrent);
|
||||||
download.type = 'movie';
|
download.type = 'movie';
|
||||||
download.coverArt = getCoverArt(movie);
|
download.coverArt = getCoverArt(movie);
|
||||||
download.movieName = movie.title;
|
download.movieName = movie.title;
|
||||||
download.movieInfo = radarrHistoryMatch;
|
download.movieInfo = radarrHistoryMatch;
|
||||||
download.userTag = userTag;
|
download.allTags = allTags;
|
||||||
|
download.matchedUserTag = matchedUserTag || null;
|
||||||
|
download.tagBadges = showAll ? buildTagBadges(allTags, embyUserMap) : undefined;
|
||||||
|
if (isAdmin) {
|
||||||
|
download.downloadPath = download.savePath || null;
|
||||||
|
download.targetPath = movie.path || null;
|
||||||
|
download.arrLink = getRadarrLink(movie);
|
||||||
|
}
|
||||||
userDownloads.push(download);
|
userDownloads.push(download);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -561,24 +605,25 @@ router.get('/user-downloads', async (req, res) => {
|
|||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
user: user.name,
|
user: user.name,
|
||||||
|
isAdmin: isAdmin,
|
||||||
downloads: userDownloads
|
downloads: userDownloads
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`[Dashboard] Error fetching user downloads:`, error.message);
|
console.error(`[Dashboard] Error fetching user downloads:`, error.message);
|
||||||
console.error(`[Dashboard] Full error:`, error);
|
console.error(`[Dashboard] Full error:`, error);
|
||||||
res.status(500).json({ error: 'Failed to fetch user downloads', details: error.message });
|
res.status(500).json({ error: 'Failed to fetch user downloads', details: sanitizeError(error) });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Get all users with their download counts
|
// Get all users with their download counts
|
||||||
router.get('/user-summary', async (req, res) => {
|
router.get('/user-summary', requireAuth, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const sonarrInstances = getSonarrInstances();
|
const sonarrInstances = getSonarrInstances();
|
||||||
const radarrInstances = getRadarrInstances();
|
const radarrInstances = getRadarrInstances();
|
||||||
|
|
||||||
// Get all Emby users
|
// Get all Emby users
|
||||||
const usersResponse = await axios.get(`${EMBY_URL}/Users`, {
|
const usersResponse = await axios.get(`${process.env.EMBY_URL}/Users`, {
|
||||||
headers: { 'X-MediaBrowser-Token': EMBY_API_KEY }
|
headers: { 'X-MediaBrowser-Token': process.env.EMBY_API_KEY }
|
||||||
});
|
});
|
||||||
|
|
||||||
// Get all series, movies, and tags from all instances
|
// Get all series, movies, and tags from all instances
|
||||||
@@ -622,29 +667,94 @@ router.get('/user-summary', async (req, res) => {
|
|||||||
|
|
||||||
// Process series tags
|
// Process series tags
|
||||||
allSeries.forEach(series => {
|
allSeries.forEach(series => {
|
||||||
const userTag = extractUserTag(series.tags, sonarrTagMap);
|
const tags = extractAllTags(series.tags, sonarrTagMap);
|
||||||
if (userTag) {
|
tags.forEach(userTag => {
|
||||||
const username = userTag.toLowerCase();
|
const uname = userTag.toLowerCase();
|
||||||
if (userDownloads[username]) {
|
if (userDownloads[uname]) userDownloads[uname].seriesCount++;
|
||||||
userDownloads[username].seriesCount++;
|
});
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Process movie tags
|
// Process movie tags
|
||||||
allMovies.forEach(movie => {
|
allMovies.forEach(movie => {
|
||||||
const userTag = extractUserTag(movie.tags, radarrTagMap);
|
const tags = extractAllTags(movie.tags, radarrTagMap);
|
||||||
if (userTag) {
|
tags.forEach(userTag => {
|
||||||
const username = userTag.toLowerCase();
|
const uname = userTag.toLowerCase();
|
||||||
if (userDownloads[username]) {
|
if (userDownloads[uname]) userDownloads[uname].movieCount++;
|
||||||
userDownloads[username].movieCount++;
|
});
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
res.json(Object.values(userDownloads));
|
res.json(Object.values(userDownloads));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).json({ error: 'Failed to fetch user summary', details: error.message });
|
res.status(500).json({ error: 'Failed to fetch user summary', details: sanitizeError(error) });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Admin-only status page with cache stats
|
||||||
|
router.get('/status', requireAuth, (req, res) => {
|
||||||
|
try {
|
||||||
|
const user = req.user;
|
||||||
|
if (!user.isAdmin) {
|
||||||
|
return res.status(403).json({ error: 'Admin access required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const cacheStats = cache.getStats();
|
||||||
|
const uptime = process.uptime();
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
server: {
|
||||||
|
uptimeSeconds: Math.floor(uptime),
|
||||||
|
nodeVersion: process.version,
|
||||||
|
memoryUsageMB: Math.round(process.memoryUsage().rss / 1024 / 1024 * 10) / 10,
|
||||||
|
heapUsedMB: Math.round(process.memoryUsage().heapUsed / 1024 / 1024 * 10) / 10,
|
||||||
|
heapTotalMB: Math.round(process.memoryUsage().heapTotal / 1024 / 1024 * 10) / 10
|
||||||
|
},
|
||||||
|
polling: {
|
||||||
|
enabled: POLLING_ENABLED,
|
||||||
|
intervalMs: POLLING_ENABLED ? require('../utils/poller').POLL_INTERVAL : 0,
|
||||||
|
lastPoll: getLastPollTimings()
|
||||||
|
},
|
||||||
|
cache: cacheStats,
|
||||||
|
clients: getActiveClients()
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: 'Failed to get status', details: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Cover art proxy — fetches external poster images server-side so the
|
||||||
|
// browser loads them from 'self' and the CSP img-src stays tight.
|
||||||
|
// Requires authentication. Only proxies http/https URLs.
|
||||||
|
router.get('/cover-art', requireAuth, async (req, res) => {
|
||||||
|
const { url } = req.query;
|
||||||
|
if (!url || typeof url !== 'string') {
|
||||||
|
return res.status(400).json({ error: 'Missing url parameter' });
|
||||||
|
}
|
||||||
|
let parsed;
|
||||||
|
try {
|
||||||
|
parsed = new URL(url);
|
||||||
|
} catch {
|
||||||
|
return res.status(400).json({ error: 'Invalid url' });
|
||||||
|
}
|
||||||
|
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
||||||
|
return res.status(400).json({ error: 'Only http/https URLs are supported' });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const response = await axios.get(url, {
|
||||||
|
responseType: 'stream',
|
||||||
|
timeout: 8000,
|
||||||
|
maxContentLength: 5 * 1024 * 1024 // 5 MB max
|
||||||
|
});
|
||||||
|
const contentType = response.headers['content-type'] || 'image/jpeg';
|
||||||
|
// Only proxy image content types
|
||||||
|
if (!contentType.startsWith('image/')) {
|
||||||
|
return res.status(400).json({ error: 'Remote URL is not an image' });
|
||||||
|
}
|
||||||
|
res.setHeader('Content-Type', contentType);
|
||||||
|
res.setHeader('Cache-Control', 'public, max-age=86400'); // 24h browser cache
|
||||||
|
res.setHeader('X-Content-Type-Options', 'nosniff');
|
||||||
|
response.data.pipe(res);
|
||||||
|
} catch (err) {
|
||||||
|
res.status(502).json({ error: 'Failed to fetch cover art' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+17
-16
@@ -1,51 +1,52 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const axios = require('axios');
|
const axios = require('axios');
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
const requireAuth = require('../middleware/requireAuth');
|
||||||
|
const sanitizeError = require('../utils/sanitizeError');
|
||||||
|
|
||||||
const EMBY_URL = process.env.EMBY_URL;
|
router.use(requireAuth);
|
||||||
const EMBY_API_KEY = process.env.EMBY_API_KEY;
|
|
||||||
|
|
||||||
// Get active sessions
|
// Get active sessions
|
||||||
router.get('/sessions', async (req, res) => {
|
router.get('/sessions', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const response = await axios.get(`${EMBY_URL}/Sessions`, {
|
const response = await axios.get(`${process.env.EMBY_URL}/Sessions`, {
|
||||||
headers: { 'X-MediaBrowser-Token': EMBY_API_KEY }
|
headers: { 'X-MediaBrowser-Token': process.env.EMBY_API_KEY }
|
||||||
});
|
});
|
||||||
res.json(response.data);
|
res.json(response.data);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).json({ error: 'Failed to fetch Emby sessions', details: error.message });
|
res.status(500).json({ error: 'Failed to fetch Emby sessions', details: sanitizeError(error) });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Get user by ID
|
// Get user by ID
|
||||||
router.get('/users/:id', async (req, res) => {
|
router.get('/users/:id', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const response = await axios.get(`${EMBY_URL}/Users/${req.params.id}`, {
|
const response = await axios.get(`${process.env.EMBY_URL}/Users/${req.params.id}`, {
|
||||||
headers: { 'X-MediaBrowser-Token': EMBY_API_KEY }
|
headers: { 'X-MediaBrowser-Token': process.env.EMBY_API_KEY }
|
||||||
});
|
});
|
||||||
res.json(response.data);
|
res.json(response.data);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).json({ error: 'Failed to fetch user details', details: error.message });
|
res.status(500).json({ error: 'Failed to fetch user details', details: sanitizeError(error) });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Get all users
|
// Get all users
|
||||||
router.get('/users', async (req, res) => {
|
router.get('/users', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const response = await axios.get(`${EMBY_URL}/Users`, {
|
const response = await axios.get(`${process.env.EMBY_URL}/Users`, {
|
||||||
headers: { 'X-MediaBrowser-Token': EMBY_API_KEY }
|
headers: { 'X-MediaBrowser-Token': process.env.EMBY_API_KEY }
|
||||||
});
|
});
|
||||||
res.json(response.data);
|
res.json(response.data);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).json({ error: 'Failed to fetch users', details: error.message });
|
res.status(500).json({ error: 'Failed to fetch users', details: sanitizeError(error) });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Get current user by session ID
|
// Get current user by session ID
|
||||||
router.get('/session/:sessionId/user', async (req, res) => {
|
router.get('/session/:sessionId/user', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const response = await axios.get(`${EMBY_URL}/Sessions`, {
|
const response = await axios.get(`${process.env.EMBY_URL}/Sessions`, {
|
||||||
headers: { 'X-MediaBrowser-Token': EMBY_API_KEY }
|
headers: { 'X-MediaBrowser-Token': process.env.EMBY_API_KEY }
|
||||||
});
|
});
|
||||||
|
|
||||||
const session = response.data.find(s => s.Id === req.params.sessionId);
|
const session = response.data.find(s => s.Id === req.params.sessionId);
|
||||||
@@ -53,13 +54,13 @@ router.get('/session/:sessionId/user', async (req, res) => {
|
|||||||
return res.status(404).json({ error: 'Session not found' });
|
return res.status(404).json({ error: 'Session not found' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const userResponse = await axios.get(`${EMBY_URL}/Users/${session.UserId}`, {
|
const userResponse = await axios.get(`${process.env.EMBY_URL}/Users/${session.UserId}`, {
|
||||||
headers: { 'X-MediaBrowser-Token': EMBY_API_KEY }
|
headers: { 'X-MediaBrowser-Token': process.env.EMBY_API_KEY }
|
||||||
});
|
});
|
||||||
|
|
||||||
res.json(userResponse.data);
|
res.json(userResponse.data);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).json({ error: 'Failed to fetch user from session', details: error.message });
|
res.status(500).json({ error: 'Failed to fetch user from session', details: sanitizeError(error) });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+15
-14
@@ -1,56 +1,57 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const axios = require('axios');
|
const axios = require('axios');
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
const requireAuth = require('../middleware/requireAuth');
|
||||||
|
const sanitizeError = require('../utils/sanitizeError');
|
||||||
|
|
||||||
const RADARR_URL = process.env.RADARR_URL;
|
router.use(requireAuth);
|
||||||
const RADARR_API_KEY = process.env.RADARR_API_KEY;
|
|
||||||
|
|
||||||
// Get queue
|
// Get queue
|
||||||
router.get('/queue', async (req, res) => {
|
router.get('/queue', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const response = await axios.get(`${RADARR_URL}/api/v3/queue`, {
|
const response = await axios.get(`${process.env.RADARR_URL}/api/v3/queue`, {
|
||||||
headers: { 'X-Api-Key': RADARR_API_KEY }
|
headers: { 'X-Api-Key': process.env.RADARR_API_KEY }
|
||||||
});
|
});
|
||||||
res.json(response.data);
|
res.json(response.data);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).json({ error: 'Failed to fetch Radarr queue', details: error.message });
|
res.status(500).json({ error: 'Failed to fetch Radarr queue', details: sanitizeError(error) });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Get history
|
// Get history
|
||||||
router.get('/history', async (req, res) => {
|
router.get('/history', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const response = await axios.get(`${RADARR_URL}/api/v3/history`, {
|
const response = await axios.get(`${process.env.RADARR_URL}/api/v3/history`, {
|
||||||
headers: { 'X-Api-Key': RADARR_API_KEY },
|
headers: { 'X-Api-Key': process.env.RADARR_API_KEY },
|
||||||
params: { pageSize: req.query.pageSize || 50 }
|
params: { pageSize: req.query.pageSize || 50 }
|
||||||
});
|
});
|
||||||
res.json(response.data);
|
res.json(response.data);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).json({ error: 'Failed to fetch Radarr history', details: error.message });
|
res.status(500).json({ error: 'Failed to fetch Radarr history', details: sanitizeError(error) });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Get movie details
|
// Get movie details
|
||||||
router.get('/movies/:id', async (req, res) => {
|
router.get('/movies/:id', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const response = await axios.get(`${RADARR_URL}/api/v3/movie/${req.params.id}`, {
|
const response = await axios.get(`${process.env.RADARR_URL}/api/v3/movie/${req.params.id}`, {
|
||||||
headers: { 'X-Api-Key': RADARR_API_KEY }
|
headers: { 'X-Api-Key': process.env.RADARR_API_KEY }
|
||||||
});
|
});
|
||||||
res.json(response.data);
|
res.json(response.data);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).json({ error: 'Failed to fetch movie details', details: error.message });
|
res.status(500).json({ error: 'Failed to fetch movie details', details: sanitizeError(error) });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Get all movies with tags
|
// Get all movies with tags
|
||||||
router.get('/movies', async (req, res) => {
|
router.get('/movies', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const response = await axios.get(`${RADARR_URL}/api/v3/movie`, {
|
const response = await axios.get(`${process.env.RADARR_URL}/api/v3/movie`, {
|
||||||
headers: { 'X-Api-Key': RADARR_API_KEY }
|
headers: { 'X-Api-Key': process.env.RADARR_API_KEY }
|
||||||
});
|
});
|
||||||
res.json(response.data);
|
res.json(response.data);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).json({ error: 'Failed to fetch movies', details: error.message });
|
res.status(500).json({ error: 'Failed to fetch movies', details: sanitizeError(error) });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,40 +1,41 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const axios = require('axios');
|
const axios = require('axios');
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
const requireAuth = require('../middleware/requireAuth');
|
||||||
|
const sanitizeError = require('../utils/sanitizeError');
|
||||||
|
|
||||||
const SABNZBD_URL = process.env.SABNZBD_URL;
|
router.use(requireAuth);
|
||||||
const SABNZBD_API_KEY = process.env.SABNZBD_API_KEY;
|
|
||||||
|
|
||||||
// Get current queue
|
// Get current queue
|
||||||
router.get('/queue', async (req, res) => {
|
router.get('/queue', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const response = await axios.get(`${SABNZBD_URL}/api`, {
|
const response = await axios.get(`${process.env.SABNZBD_URL}/api`, {
|
||||||
params: {
|
params: {
|
||||||
mode: 'queue',
|
mode: 'queue',
|
||||||
apikey: SABNZBD_API_KEY,
|
apikey: process.env.SABNZBD_API_KEY,
|
||||||
output: 'json'
|
output: 'json'
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
res.json(response.data);
|
res.json(response.data);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).json({ error: 'Failed to fetch SABnzbd queue', details: error.message });
|
res.status(500).json({ error: 'Failed to fetch SABnzbd queue', details: sanitizeError(error) });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Get history
|
// Get history
|
||||||
router.get('/history', async (req, res) => {
|
router.get('/history', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const response = await axios.get(`${SABNZBD_URL}/api`, {
|
const response = await axios.get(`${process.env.SABNZBD_URL}/api`, {
|
||||||
params: {
|
params: {
|
||||||
mode: 'history',
|
mode: 'history',
|
||||||
apikey: SABNZBD_API_KEY,
|
apikey: process.env.SABNZBD_API_KEY,
|
||||||
output: 'json',
|
output: 'json',
|
||||||
limit: req.query.limit || 50
|
limit: req.query.limit || 50
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
res.json(response.data);
|
res.json(response.data);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).json({ error: 'Failed to fetch SABnzbd history', details: error.message });
|
res.status(500).json({ error: 'Failed to fetch SABnzbd history', details: sanitizeError(error) });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+15
-14
@@ -1,56 +1,57 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const axios = require('axios');
|
const axios = require('axios');
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
const requireAuth = require('../middleware/requireAuth');
|
||||||
|
const sanitizeError = require('../utils/sanitizeError');
|
||||||
|
|
||||||
const SONARR_URL = process.env.SONARR_URL;
|
router.use(requireAuth);
|
||||||
const SONARR_API_KEY = process.env.SONARR_API_KEY;
|
|
||||||
|
|
||||||
// Get queue
|
// Get queue
|
||||||
router.get('/queue', async (req, res) => {
|
router.get('/queue', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const response = await axios.get(`${SONARR_URL}/api/v3/queue`, {
|
const response = await axios.get(`${process.env.SONARR_URL}/api/v3/queue`, {
|
||||||
headers: { 'X-Api-Key': SONARR_API_KEY }
|
headers: { 'X-Api-Key': process.env.SONARR_API_KEY }
|
||||||
});
|
});
|
||||||
res.json(response.data);
|
res.json(response.data);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).json({ error: 'Failed to fetch Sonarr queue', details: error.message });
|
res.status(500).json({ error: 'Failed to fetch Sonarr queue', details: sanitizeError(error) });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Get history
|
// Get history
|
||||||
router.get('/history', async (req, res) => {
|
router.get('/history', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const response = await axios.get(`${SONARR_URL}/api/v3/history`, {
|
const response = await axios.get(`${process.env.SONARR_URL}/api/v3/history`, {
|
||||||
headers: { 'X-Api-Key': SONARR_API_KEY },
|
headers: { 'X-Api-Key': process.env.SONARR_API_KEY },
|
||||||
params: { pageSize: req.query.pageSize || 50 }
|
params: { pageSize: req.query.pageSize || 50 }
|
||||||
});
|
});
|
||||||
res.json(response.data);
|
res.json(response.data);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).json({ error: 'Failed to fetch Sonarr history', details: error.message });
|
res.status(500).json({ error: 'Failed to fetch Sonarr history', details: sanitizeError(error) });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Get series details
|
// Get series details
|
||||||
router.get('/series/:id', async (req, res) => {
|
router.get('/series/:id', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const response = await axios.get(`${SONARR_URL}/api/v3/series/${req.params.id}`, {
|
const response = await axios.get(`${process.env.SONARR_URL}/api/v3/series/${req.params.id}`, {
|
||||||
headers: { 'X-Api-Key': SONARR_API_KEY }
|
headers: { 'X-Api-Key': process.env.SONARR_API_KEY }
|
||||||
});
|
});
|
||||||
res.json(response.data);
|
res.json(response.data);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).json({ error: 'Failed to fetch series details', details: error.message });
|
res.status(500).json({ error: 'Failed to fetch series details', details: sanitizeError(error) });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Get all series with tags
|
// Get all series with tags
|
||||||
router.get('/series', async (req, res) => {
|
router.get('/series', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const response = await axios.get(`${SONARR_URL}/api/v3/series`, {
|
const response = await axios.get(`${process.env.SONARR_URL}/api/v3/series`, {
|
||||||
headers: { 'X-Api-Key': SONARR_API_KEY }
|
headers: { 'X-Api-Key': process.env.SONARR_API_KEY }
|
||||||
});
|
});
|
||||||
res.json(response.data);
|
res.json(response.data);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).json({ error: 'Failed to fetch series', details: error.message });
|
res.status(500).json({ error: 'Failed to fetch series', details: sanitizeError(error) });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
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()) {
|
||||||
|
const json = JSON.stringify(entry.value);
|
||||||
|
const sizeBytes = Buffer.byteLength(json, 'utf8');
|
||||||
|
totalSize += sizeBytes;
|
||||||
|
const ttlRemaining = Math.max(0, entry.expiresAt - now);
|
||||||
|
const expired = now > entry.expiresAt;
|
||||||
|
let itemCount = null;
|
||||||
|
if (Array.isArray(entry.value)) {
|
||||||
|
itemCount = entry.value.length;
|
||||||
|
} else if (entry.value && typeof entry.value === 'object') {
|
||||||
|
if (Array.isArray(entry.value.records)) itemCount = entry.value.records.length;
|
||||||
|
else if (Array.isArray(entry.value.slots)) itemCount = entry.value.slots.length;
|
||||||
|
}
|
||||||
|
entries.push({
|
||||||
|
key,
|
||||||
|
sizeBytes,
|
||||||
|
itemCount,
|
||||||
|
ttlRemainingMs: ttlRemaining,
|
||||||
|
expired
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
entryCount: this.store.size,
|
||||||
|
totalSizeBytes: totalSize,
|
||||||
|
entries
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const cache = new MemoryCache();
|
||||||
|
|
||||||
|
module.exports = cache;
|
||||||
@@ -1,7 +1,12 @@
|
|||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
|
|
||||||
const logFile = fs.createWriteStream(path.join(__dirname, '../../server.log'), { flags: 'a' });
|
// Use DATA_DIR so the non-root container user (UID 1000) can write logs.
|
||||||
|
// Falls back to ../../data/server.log (same directory index.js uses).
|
||||||
|
const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, '../../data');
|
||||||
|
if (!fs.existsSync(DATA_DIR)) fs.mkdirSync(DATA_DIR, { recursive: true });
|
||||||
|
|
||||||
|
const logFile = fs.createWriteStream(path.join(DATA_DIR, 'server.log'), { flags: 'a' });
|
||||||
|
|
||||||
function logToFile(message) {
|
function logToFile(message) {
|
||||||
logFile.write(`[${new Date().toISOString()}] ${message}\n`);
|
logFile.write(`[${new Date().toISOString()}] ${message}\n`);
|
||||||
|
|||||||
@@ -0,0 +1,219 @@
|
|||||||
|
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;
|
||||||
|
|
||||||
|
// Timed fetch helper: runs a fetch and records how long it took
|
||||||
|
async function timed(label, fn) {
|
||||||
|
const t0 = Date.now();
|
||||||
|
const result = await fn();
|
||||||
|
return { label, result, ms: Date.now() - t0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pollAllServices() {
|
||||||
|
if (polling) {
|
||||||
|
console.log('[Poller] Previous poll still running, skipping');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
polling = true;
|
||||||
|
const start = Date.now();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const sabInstances = getSABnzbdInstances();
|
||||||
|
const sonarrInstances = getSonarrInstances();
|
||||||
|
const radarrInstances = getRadarrInstances();
|
||||||
|
|
||||||
|
// All fetches in parallel, each individually timed
|
||||||
|
const results = await Promise.all([
|
||||||
|
timed('SABnzbd Queue', () => Promise.all(sabInstances.map(inst =>
|
||||||
|
axios.get(`${inst.url}/api`, {
|
||||||
|
params: { mode: 'queue', apikey: inst.apiKey, output: 'json' }
|
||||||
|
}).then(res => ({ instance: inst.id, data: res.data })).catch(err => {
|
||||||
|
console.error(`[Poller] SABnzbd ${inst.id} queue error:`, err.message);
|
||||||
|
return { instance: inst.id, data: { queue: { slots: [] } } };
|
||||||
|
})
|
||||||
|
))),
|
||||||
|
timed('SABnzbd History', () => Promise.all(sabInstances.map(inst =>
|
||||||
|
axios.get(`${inst.url}/api`, {
|
||||||
|
params: { mode: 'history', apikey: inst.apiKey, output: 'json', limit: 10 }
|
||||||
|
}).then(res => ({ instance: inst.id, data: res.data })).catch(err => {
|
||||||
|
console.error(`[Poller] SABnzbd ${inst.id} history error:`, err.message);
|
||||||
|
return { instance: inst.id, data: { history: { slots: [] } } };
|
||||||
|
})
|
||||||
|
))),
|
||||||
|
timed('Sonarr Tags', () => Promise.all(sonarrInstances.map(inst =>
|
||||||
|
axios.get(`${inst.url}/api/v3/tag`, {
|
||||||
|
headers: { 'X-Api-Key': inst.apiKey }
|
||||||
|
}).then(res => ({ instance: inst.id, data: res.data })).catch(err => {
|
||||||
|
console.error(`[Poller] Sonarr ${inst.id} tags error:`, err.message);
|
||||||
|
return { instance: inst.id, data: [] };
|
||||||
|
})
|
||||||
|
))),
|
||||||
|
timed('Sonarr Queue', () => Promise.all(sonarrInstances.map(inst =>
|
||||||
|
axios.get(`${inst.url}/api/v3/queue`, {
|
||||||
|
headers: { 'X-Api-Key': inst.apiKey },
|
||||||
|
params: { includeSeries: true }
|
||||||
|
}).then(res => ({ instance: inst.id, data: res.data })).catch(err => {
|
||||||
|
console.error(`[Poller] Sonarr ${inst.id} queue error:`, err.message);
|
||||||
|
return { instance: inst.id, data: { records: [] } };
|
||||||
|
})
|
||||||
|
))),
|
||||||
|
timed('Sonarr History', () => Promise.all(sonarrInstances.map(inst =>
|
||||||
|
axios.get(`${inst.url}/api/v3/history`, {
|
||||||
|
headers: { 'X-Api-Key': inst.apiKey },
|
||||||
|
params: { pageSize: 10 }
|
||||||
|
}).then(res => ({ instance: inst.id, data: res.data })).catch(err => {
|
||||||
|
console.error(`[Poller] Sonarr ${inst.id} history error:`, err.message);
|
||||||
|
return { instance: inst.id, data: { records: [] } };
|
||||||
|
})
|
||||||
|
))),
|
||||||
|
timed('Radarr Queue', () => Promise.all(radarrInstances.map(inst =>
|
||||||
|
axios.get(`${inst.url}/api/v3/queue`, {
|
||||||
|
headers: { 'X-Api-Key': inst.apiKey },
|
||||||
|
params: { includeMovie: true }
|
||||||
|
}).then(res => ({ instance: inst.id, data: res.data })).catch(err => {
|
||||||
|
console.error(`[Poller] Radarr ${inst.id} queue error:`, err.message);
|
||||||
|
return { instance: inst.id, data: { records: [] } };
|
||||||
|
})
|
||||||
|
))),
|
||||||
|
timed('Radarr History', () => Promise.all(radarrInstances.map(inst =>
|
||||||
|
axios.get(`${inst.url}/api/v3/history`, {
|
||||||
|
headers: { 'X-Api-Key': inst.apiKey },
|
||||||
|
params: { pageSize: 10 }
|
||||||
|
}).then(res => ({ instance: inst.id, data: res.data })).catch(err => {
|
||||||
|
console.error(`[Poller] Radarr ${inst.id} history error:`, err.message);
|
||||||
|
return { instance: inst.id, data: { records: [] } };
|
||||||
|
})
|
||||||
|
))),
|
||||||
|
timed('Radarr Tags', () => Promise.all(radarrInstances.map(inst =>
|
||||||
|
axios.get(`${inst.url}/api/v3/tag`, {
|
||||||
|
headers: { 'X-Api-Key': inst.apiKey }
|
||||||
|
}).then(res => ({ instance: inst.id, data: res.data })).catch(err => {
|
||||||
|
console.error(`[Poller] Radarr ${inst.id} tags error:`, err.message);
|
||||||
|
return { instance: inst.id, data: [] };
|
||||||
|
})
|
||||||
|
))),
|
||||||
|
timed('qBittorrent', () => getTorrents().catch(err => {
|
||||||
|
console.error(`[Poller] qBittorrent error:`, err.message);
|
||||||
|
return [];
|
||||||
|
}))
|
||||||
|
]);
|
||||||
|
|
||||||
|
const [
|
||||||
|
{ result: sabQueues }, { result: sabHistories },
|
||||||
|
{ result: sonarrTagsResults }, { result: sonarrQueues },
|
||||||
|
{ result: sonarrHistories },
|
||||||
|
{ result: radarrQueues }, { result: radarrHistories },
|
||||||
|
{ result: radarrTagsResults },
|
||||||
|
{ result: qbittorrentTorrents }
|
||||||
|
] = results;
|
||||||
|
|
||||||
|
// Store per-task timings
|
||||||
|
const totalMs = Date.now() - start;
|
||||||
|
lastPollTimings = {
|
||||||
|
totalMs,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
tasks: results.map(r => ({ label: r.label, ms: r.ms }))
|
||||||
|
};
|
||||||
|
|
||||||
|
// When polling is active, TTL is 3x interval to avoid gaps between polls
|
||||||
|
// When polling is disabled (on-demand), use 30s so data refreshes on next request after expiry
|
||||||
|
const cacheTTL = POLLING_ENABLED ? POLL_INTERVAL * 3 : 30000;
|
||||||
|
|
||||||
|
// SABnzbd
|
||||||
|
const firstSabQueue = sabQueues[0] && sabQueues[0].data && sabQueues[0].data.queue;
|
||||||
|
cache.set('poll:sab-queue', {
|
||||||
|
slots: sabQueues.flatMap(q => (q.data.queue && q.data.queue.slots) || []),
|
||||||
|
status: firstSabQueue && firstSabQueue.status,
|
||||||
|
speed: firstSabQueue && firstSabQueue.speed,
|
||||||
|
kbpersec: firstSabQueue && firstSabQueue.kbpersec
|
||||||
|
}, cacheTTL);
|
||||||
|
|
||||||
|
cache.set('poll:sab-history', {
|
||||||
|
slots: sabHistories.flatMap(h => (h.data.history && h.data.history.slots) || [])
|
||||||
|
}, cacheTTL);
|
||||||
|
|
||||||
|
// Sonarr
|
||||||
|
cache.set('poll:sonarr-tags', sonarrTagsResults, cacheTTL);
|
||||||
|
// Tag queue/history records with _instanceUrl so embedded series/movie objects can build links
|
||||||
|
cache.set('poll:sonarr-queue', {
|
||||||
|
records: sonarrQueues.flatMap(q => {
|
||||||
|
const inst = sonarrInstances.find(i => i.id === q.instance);
|
||||||
|
const url = inst ? inst.url : null;
|
||||||
|
return (q.data.records || []).map(r => {
|
||||||
|
if (r.series) r.series._instanceUrl = url;
|
||||||
|
return r;
|
||||||
|
});
|
||||||
|
})
|
||||||
|
}, cacheTTL);
|
||||||
|
cache.set('poll:sonarr-history', {
|
||||||
|
records: sonarrHistories.flatMap(h => h.data.records || [])
|
||||||
|
}, cacheTTL);
|
||||||
|
|
||||||
|
// Radarr
|
||||||
|
cache.set('poll:radarr-queue', {
|
||||||
|
records: radarrQueues.flatMap(q => {
|
||||||
|
const inst = radarrInstances.find(i => i.id === q.instance);
|
||||||
|
const url = inst ? inst.url : null;
|
||||||
|
return (q.data.records || []).map(r => {
|
||||||
|
if (r.movie) r.movie._instanceUrl = url;
|
||||||
|
return r;
|
||||||
|
});
|
||||||
|
})
|
||||||
|
}, cacheTTL);
|
||||||
|
cache.set('poll:radarr-history', {
|
||||||
|
records: radarrHistories.flatMap(h => h.data.records || [])
|
||||||
|
}, cacheTTL);
|
||||||
|
cache.set('poll:radarr-tags', radarrTagsResults.flatMap(t => t.data || []), cacheTTL);
|
||||||
|
|
||||||
|
// qBittorrent
|
||||||
|
cache.set('poll:qbittorrent', qbittorrentTorrents, cacheTTL);
|
||||||
|
|
||||||
|
const elapsed = Date.now() - start;
|
||||||
|
console.log(`[Poller] Poll complete in ${elapsed}ms`);
|
||||||
|
} 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, POLL_INTERVAL, POLLING_ENABLED };
|
||||||
@@ -96,14 +96,19 @@ class QBittorrentClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Persist clients so auth cookies survive between requests
|
||||||
|
let persistedClients = null;
|
||||||
|
|
||||||
function getClients() {
|
function getClients() {
|
||||||
|
if (persistedClients) return persistedClients;
|
||||||
const instances = getQbittorrentInstances();
|
const instances = getQbittorrentInstances();
|
||||||
if (instances.length === 0) {
|
if (instances.length === 0) {
|
||||||
logToFile('[qBittorrent] No instances configured');
|
logToFile('[qBittorrent] No instances configured');
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
logToFile(`[qBittorrent] Created ${instances.length} client(s)`);
|
logToFile(`[qBittorrent] Created ${instances.length} persistent client(s)`);
|
||||||
return instances.map(inst => new QBittorrentClient(inst));
|
persistedClients = instances.map(inst => new QBittorrentClient(inst));
|
||||||
|
return persistedClients;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getAllTorrents() {
|
async function getAllTorrents() {
|
||||||
@@ -198,6 +203,7 @@ function mapTorrentToDownload(torrent) {
|
|||||||
hash: torrent.hash,
|
hash: torrent.hash,
|
||||||
category: torrent.category,
|
category: torrent.category,
|
||||||
tags: torrent.tags,
|
tags: torrent.tags,
|
||||||
|
savePath: torrent.content_path || torrent.save_path || null,
|
||||||
qbittorrent: true
|
qbittorrent: true
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
// 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;
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
/**
|
||||||
|
* Persistent token store backed by a JSON file.
|
||||||
|
*
|
||||||
|
* Pure JavaScript — no native addons, no build tools required.
|
||||||
|
* Survives process restarts so users are not logged out on redeploy.
|
||||||
|
*
|
||||||
|
* Tokens are stored in DATA_DIR/tokens.json (default: ./data locally,
|
||||||
|
* /app/data in the container). Writes are atomic: data is written to a
|
||||||
|
* temp file then renamed so a crash mid-write never corrupts the store.
|
||||||
|
*
|
||||||
|
* Format: { "<userId>": { accessToken: "...", createdAt: <unix ms> } }
|
||||||
|
*
|
||||||
|
* Expired entries (older than TOKEN_TTL_DAYS) are pruned on startup
|
||||||
|
* and once per hour.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const path = require('path');
|
||||||
|
const fs = require('fs');
|
||||||
|
|
||||||
|
const TOKEN_TTL_DAYS = 31; // slightly longer than max cookie lifetime (30d)
|
||||||
|
const TOKEN_TTL_MS = TOKEN_TTL_DAYS * 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, '../../data');
|
||||||
|
if (!fs.existsSync(DATA_DIR)) {
|
||||||
|
fs.mkdirSync(DATA_DIR, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const STORE_PATH = path.join(DATA_DIR, 'tokens.json');
|
||||||
|
const STORE_TMP = STORE_PATH + '.tmp';
|
||||||
|
|
||||||
|
// Load store from disk, return empty object on any error
|
||||||
|
function load() {
|
||||||
|
try {
|
||||||
|
return JSON.parse(fs.readFileSync(STORE_PATH, 'utf8'));
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Atomic write: write to .tmp then rename to avoid partial-write corruption
|
||||||
|
function save(data) {
|
||||||
|
try {
|
||||||
|
fs.writeFileSync(STORE_TMP, JSON.stringify(data), 'utf8');
|
||||||
|
fs.renameSync(STORE_TMP, STORE_PATH);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[TokenStore] Failed to persist token store:', err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function prune(data) {
|
||||||
|
const cutoff = Date.now() - TOKEN_TTL_MS;
|
||||||
|
let pruned = 0;
|
||||||
|
for (const userId of Object.keys(data)) {
|
||||||
|
if (data[userId].createdAt < cutoff) {
|
||||||
|
delete data[userId];
|
||||||
|
pruned++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (pruned > 0) {
|
||||||
|
console.log(`[TokenStore] Pruned ${pruned} expired token(s)`);
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prune on startup
|
||||||
|
let store = prune(load());
|
||||||
|
save(store);
|
||||||
|
|
||||||
|
// Prune once per hour (unref so it doesn't keep the process alive)
|
||||||
|
setInterval(() => {
|
||||||
|
store = prune(load());
|
||||||
|
save(store);
|
||||||
|
}, 60 * 60 * 1000).unref();
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
storeToken(userId, accessToken) {
|
||||||
|
store[userId] = { accessToken, createdAt: Date.now() };
|
||||||
|
save(store);
|
||||||
|
},
|
||||||
|
getToken(userId) {
|
||||||
|
const entry = store[userId];
|
||||||
|
if (!entry) return null;
|
||||||
|
// Also honour TTL on read in case pruning hasn't run yet
|
||||||
|
if (Date.now() - entry.createdAt > TOKEN_TTL_MS) {
|
||||||
|
delete store[userId];
|
||||||
|
save(store);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return { accessToken: entry.accessToken };
|
||||||
|
},
|
||||||
|
clearToken(userId) {
|
||||||
|
if (store[userId]) {
|
||||||
|
delete store[userId];
|
||||||
|
save(store);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
# Testing
|
||||||
|
|
||||||
|
## Stack
|
||||||
|
|
||||||
|
| Layer | Tool |
|
||||||
|
|---|---|
|
||||||
|
| Test runner | [Vitest](https://vitest.dev/) v4 |
|
||||||
|
| HTTP integration | [supertest](https://github.com/ladjs/supertest) |
|
||||||
|
| HTTP interception | [nock](https://github.com/nock/nock) (intercepts at Node http layer — works with CJS `require('axios')`) |
|
||||||
|
| Coverage | V8 (built-in, no Babel needed) |
|
||||||
|
|
||||||
|
## Running tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run all tests once
|
||||||
|
npm test
|
||||||
|
|
||||||
|
# Watch mode (re-runs on file change)
|
||||||
|
npm run test:watch
|
||||||
|
|
||||||
|
# With coverage report
|
||||||
|
npm run test:coverage
|
||||||
|
|
||||||
|
# Interactive UI
|
||||||
|
npm run test:ui
|
||||||
|
```
|
||||||
|
|
||||||
|
Coverage output lands in `coverage/` (gitignored). Open `coverage/index.html` for the HTML report.
|
||||||
|
|
||||||
|
## Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
tests/
|
||||||
|
├── setup.js # Global setup: isolated DATA_DIR, SKIP_RATE_LIMIT, console suppression
|
||||||
|
├── unit/
|
||||||
|
│ ├── sanitizeError.test.js # Secret redaction patterns (API keys, tokens, passwords)
|
||||||
|
│ ├── config.test.js # JSON array + legacy single-instance config parsing
|
||||||
|
│ ├── requireAuth.test.js # Auth middleware: valid/invalid/tampered cookies
|
||||||
|
│ ├── verifyCsrf.test.js # CSRF double-submit cookie pattern + timing-safe compare
|
||||||
|
│ ├── qbittorrent.test.js # Pure utils: formatBytes, formatEta, mapTorrentToDownload
|
||||||
|
│ └── tokenStore.test.js # JSON file token store: store/get/clear, TTL expiry
|
||||||
|
└── integration/
|
||||||
|
├── health.test.js # GET /health and /ready endpoints
|
||||||
|
└── auth.test.js # Full login/logout/me/csrf flows via supertest + nock
|
||||||
|
```
|
||||||
|
|
||||||
|
## Key design decisions
|
||||||
|
|
||||||
|
- **`server/app.js`** — Express factory extracted from `server/index.js`. Tests import `createApp()` without triggering the log-file setup, `process.exit()` calls, or background poller in the entry point.
|
||||||
|
- **nock over `vi.mock('axios')`** — Vitest's `vi.mock` only intercepts ESM `import` statements. Since `auth.js` uses CJS `require('axios')`, nock (which patches Node's `http`/`https` modules) is the correct tool for intercepting outbound requests.
|
||||||
|
- **`SKIP_RATE_LIMIT=1`** — All supertest requests originate from `127.0.0.1`, which would quickly exhaust per-IP rate-limit windows. Setting this env var raises the limits to `Number.MAX_SAFE_INTEGER` in both the API limiter and the login limiter.
|
||||||
|
- **Isolated `DATA_DIR`** — Each test worker gets a unique temp directory so `tokenStore.js` file I/O never conflicts with a running dev server.
|
||||||
|
- **`createApp({ skipRateLimits: true })`** — The app factory accepts an option to disable the general API rate limiter in addition to the env var for the login-specific limiter.
|
||||||
|
|
||||||
|
## Coverage targets
|
||||||
|
|
||||||
|
The tested files meet these per-file minimums (enforced in CI):
|
||||||
|
|
||||||
|
| File | Lines | Branches |
|
||||||
|
|---|---|---|
|
||||||
|
| `server/app.js` | 85% | 65% |
|
||||||
|
| `server/routes/auth.js` | 85% | 70% |
|
||||||
|
| `server/middleware/requireAuth.js` | 75% | 80% |
|
||||||
|
| `server/utils/sanitizeError.js` | 60% | — |
|
||||||
|
| `server/utils/config.js` | 50% | 55% |
|
||||||
|
|
||||||
|
`dashboard.js` and `poller.js` are large files requiring complex external-service mocks (Sonarr, Radarr, qBittorrent, Emby) and are tracked as future test coverage work.
|
||||||
@@ -0,0 +1,299 @@
|
|||||||
|
/**
|
||||||
|
* Integration tests for authentication routes.
|
||||||
|
*
|
||||||
|
* Uses supertest against the createApp() factory (no real server).
|
||||||
|
* HTTP calls to Emby are intercepted at the Node http/https layer using nock,
|
||||||
|
* which works correctly with CJS require('axios') unlike vi.mock which only
|
||||||
|
* intercepts ESM imports.
|
||||||
|
*
|
||||||
|
* Covers:
|
||||||
|
* - Input validation on /login (empty fields, overlong values)
|
||||||
|
* - Successful login flow (cookies set, CSRF token returned)
|
||||||
|
* - Failed login (wrong credentials → 401, no cookie set)
|
||||||
|
* - /me endpoint (authenticated vs unauthenticated)
|
||||||
|
* - /csrf token issuance
|
||||||
|
* - /logout (cookies cleared)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import request from 'supertest';
|
||||||
|
import nock from 'nock';
|
||||||
|
import { createApp } from '../../server/app.js';
|
||||||
|
|
||||||
|
const EMBY_BASE = 'https://emby.test';
|
||||||
|
|
||||||
|
// Emby response fixtures
|
||||||
|
const EMBY_AUTH_BODY = {
|
||||||
|
AccessToken: 'test-emby-token-abc123',
|
||||||
|
User: { Id: 'user-id-001', Name: 'TestUser' }
|
||||||
|
};
|
||||||
|
|
||||||
|
const EMBY_USER_BODY = {
|
||||||
|
Id: 'user-id-001',
|
||||||
|
Name: 'TestUser',
|
||||||
|
Policy: { IsAdministrator: false }
|
||||||
|
};
|
||||||
|
|
||||||
|
const EMBY_ADMIN_BODY = {
|
||||||
|
Id: 'admin-id-001',
|
||||||
|
Name: 'AdminUser',
|
||||||
|
Policy: { IsAdministrator: true }
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper: intercept a successful Emby login + user-info sequence
|
||||||
|
function interceptSuccessfulLogin(userBody = EMBY_USER_BODY) {
|
||||||
|
nock(EMBY_BASE)
|
||||||
|
.post('/Users/authenticatebyname')
|
||||||
|
.reply(200, EMBY_AUTH_BODY);
|
||||||
|
nock(EMBY_BASE)
|
||||||
|
.get(/\/Users\//)
|
||||||
|
.reply(200, userBody);
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
nock.cleanAll(); // remove any pending interceptors between tests
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('POST /api/auth/login', () => {
|
||||||
|
// Each sub-describe gets a fresh app to avoid rate-limit state leaking
|
||||||
|
// between the 'input validation' calls (which all fail and count toward
|
||||||
|
// the 10-failure window) and the 'successful login' calls.
|
||||||
|
let app;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
process.env.EMBY_URL = 'https://emby.test';
|
||||||
|
delete process.env.COOKIE_SECRET;
|
||||||
|
// skipRateLimits avoids 429s from the login limiter when all
|
||||||
|
// requests come from 127.0.0.1 in the test environment
|
||||||
|
app = createApp({ skipRateLimits: true });
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
delete process.env.EMBY_URL;
|
||||||
|
delete process.env.COOKIE_SECRET;
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('input validation', () => {
|
||||||
|
it('rejects empty username', async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/auth/login')
|
||||||
|
.send({ username: '', password: 'pass' });
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(res.body.success).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects missing password', async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/auth/login')
|
||||||
|
.send({ username: 'alice', password: '' });
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(res.body.success).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects username over 128 chars', async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/auth/login')
|
||||||
|
.send({ username: 'a'.repeat(129), password: 'pass' });
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects password over 256 chars', async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/auth/login')
|
||||||
|
.send({ username: 'alice', password: 'p'.repeat(257) });
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects non-string username', async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/auth/login')
|
||||||
|
.send({ username: 123, password: 'pass' });
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('successful login', () => {
|
||||||
|
it('returns success:true with user info', async () => {
|
||||||
|
interceptSuccessfulLogin();
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/auth/login')
|
||||||
|
.send({ username: 'TestUser', password: 'correct' });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.success).toBe(true);
|
||||||
|
expect(res.body.user.name).toBe('TestUser');
|
||||||
|
expect(res.body.user.isAdmin).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sets emby_user cookie', async () => {
|
||||||
|
interceptSuccessfulLogin();
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/auth/login')
|
||||||
|
.send({ username: 'TestUser', password: 'correct' });
|
||||||
|
const cookies = res.headers['set-cookie'] || [];
|
||||||
|
expect(cookies.some(c => c.startsWith('emby_user='))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sets csrf_token cookie', async () => {
|
||||||
|
interceptSuccessfulLogin();
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/auth/login')
|
||||||
|
.send({ username: 'TestUser', password: 'correct' });
|
||||||
|
const cookies = res.headers['set-cookie'] || [];
|
||||||
|
expect(cookies.some(c => c.startsWith('csrf_token='))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns csrfToken in response body', async () => {
|
||||||
|
interceptSuccessfulLogin();
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/auth/login')
|
||||||
|
.send({ username: 'TestUser', password: 'correct' });
|
||||||
|
expect(typeof res.body.csrfToken).toBe('string');
|
||||||
|
expect(res.body.csrfToken.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('session cookie has no maxAge when rememberMe is false', async () => {
|
||||||
|
interceptSuccessfulLogin();
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/auth/login')
|
||||||
|
.send({ username: 'TestUser', password: 'correct', rememberMe: false });
|
||||||
|
const cookies = res.headers['set-cookie'] || [];
|
||||||
|
const sessionCookie = cookies.find(c => c.startsWith('emby_user='));
|
||||||
|
// Session cookie must not persist across browser close
|
||||||
|
expect(sessionCookie).toBeDefined();
|
||||||
|
expect(sessionCookie).not.toContain('Max-Age');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sets 30-day maxAge when rememberMe is true', async () => {
|
||||||
|
interceptSuccessfulLogin();
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/auth/login')
|
||||||
|
.send({ username: 'TestUser', password: 'correct', rememberMe: true });
|
||||||
|
const cookies = res.headers['set-cookie'] || [];
|
||||||
|
const sessionCookie = cookies.find(c => c.startsWith('emby_user='));
|
||||||
|
expect(sessionCookie).toBeDefined();
|
||||||
|
expect(sessionCookie).toContain('Max-Age');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('marks isAdmin correctly for admin user', async () => {
|
||||||
|
interceptSuccessfulLogin(EMBY_ADMIN_BODY);
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/auth/login')
|
||||||
|
.send({ username: 'AdminUser', password: 'correct' });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.user.isAdmin).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not include AccessToken in response body', async () => {
|
||||||
|
interceptSuccessfulLogin();
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/auth/login')
|
||||||
|
.send({ username: 'TestUser', password: 'correct' });
|
||||||
|
// The Emby access token must never be sent to the client
|
||||||
|
expect(JSON.stringify(res.body)).not.toContain('test-emby-token-abc123');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('failed login', () => {
|
||||||
|
it('returns 401 when Emby rejects credentials', async () => {
|
||||||
|
nock(EMBY_BASE).post('/Users/authenticatebyname').reply(401, { error: 'Unauthorized' });
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/auth/login')
|
||||||
|
.send({ username: 'baduser', password: 'wrongpass' });
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
expect(res.body.success).toBe(false);
|
||||||
|
// Must not expose internal error details
|
||||||
|
expect(res.body.error).toBe('Invalid username or password');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not set emby_user cookie on failure', async () => {
|
||||||
|
nock(EMBY_BASE).post('/Users/authenticatebyname').reply(401, {});
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/auth/login')
|
||||||
|
.send({ username: 'baduser', password: 'wrongpass' });
|
||||||
|
const cookies = res.headers['set-cookie'] || [];
|
||||||
|
expect(cookies.some(c => c.startsWith('emby_user='))).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GET /api/auth/me', () => {
|
||||||
|
let app;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
delete process.env.COOKIE_SECRET;
|
||||||
|
app = createApp({ skipRateLimits: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns authenticated:false when no cookie', async () => {
|
||||||
|
const res = await request(app).get('/api/auth/me');
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.authenticated).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns authenticated:true with valid cookie', async () => {
|
||||||
|
const payload = JSON.stringify({ id: 'u1', name: 'Alice', isAdmin: false });
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/api/auth/me')
|
||||||
|
.set('Cookie', `emby_user=${encodeURIComponent(payload)}`);
|
||||||
|
expect(res.body.authenticated).toBe(true);
|
||||||
|
expect(res.body.user.name).toBe('Alice');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GET /api/auth/csrf', () => {
|
||||||
|
it('issues a csrf_token cookie and returns csrfToken in body', async () => {
|
||||||
|
const app = createApp({ skipRateLimits: true });
|
||||||
|
const res = await request(app).get('/api/auth/csrf');
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(typeof res.body.csrfToken).toBe('string');
|
||||||
|
expect(res.body.csrfToken.length).toBe(64); // 32 bytes hex
|
||||||
|
const cookies = res.headers['set-cookie'] || [];
|
||||||
|
expect(cookies.some(c => c.startsWith('csrf_token='))).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('POST /api/auth/logout', () => {
|
||||||
|
let app;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
process.env.EMBY_URL = 'https://emby.test';
|
||||||
|
delete process.env.COOKIE_SECRET;
|
||||||
|
app = createApp({ skipRateLimits: true });
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
delete process.env.EMBY_URL;
|
||||||
|
});
|
||||||
|
|
||||||
|
// NOTE: /api/auth/* is mounted BEFORE the verifyCsrf middleware in app.js,
|
||||||
|
// so logout does not require a CSRF token by design. The session cookie's
|
||||||
|
// sameSite:strict attribute provides equivalent CSRF protection for logout.
|
||||||
|
it('succeeds without a CSRF token (sameSite:strict provides protection)', async () => {
|
||||||
|
nock(EMBY_BASE).post('/Sessions/Logout').reply(200, {});
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/auth/logout');
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.success).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears cookies and returns success when CSRF token is provided', async () => {
|
||||||
|
const csrfRes = await request(app).get('/api/auth/csrf');
|
||||||
|
const csrfToken = csrfRes.body.csrfToken;
|
||||||
|
const csrfCookie = csrfRes.headers['set-cookie'].find(c => c.startsWith('csrf_token='));
|
||||||
|
|
||||||
|
nock(EMBY_BASE).post('/Sessions/Logout').reply(200, {});
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/auth/logout')
|
||||||
|
.set('Cookie', csrfCookie)
|
||||||
|
.set('X-CSRF-Token', csrfToken);
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.success).toBe(true);
|
||||||
|
|
||||||
|
// Cookies should be cleared (Set-Cookie header with empty value / Max-Age=0)
|
||||||
|
const cookies = res.headers['set-cookie'] || [];
|
||||||
|
expect(cookies.some(c => c.includes('emby_user=;') || c.includes('Max-Age=0'))).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
/**
|
||||||
|
* Integration tests for health and readiness endpoints.
|
||||||
|
*
|
||||||
|
* /health and /ready are used by Docker HEALTHCHECK and must:
|
||||||
|
* - Require no authentication
|
||||||
|
* - Not be rate-limited
|
||||||
|
* - Return the correct status codes
|
||||||
|
*/
|
||||||
|
|
||||||
|
import request from 'supertest';
|
||||||
|
import { createApp } from '../../server/app.js';
|
||||||
|
|
||||||
|
describe('GET /health', () => {
|
||||||
|
let app;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
app = createApp();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 200 with status ok', async () => {
|
||||||
|
const res = await request(app).get('/health');
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.status).toBe('ok');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes uptime as a number', async () => {
|
||||||
|
const res = await request(app).get('/health');
|
||||||
|
expect(typeof res.body.uptime).toBe('number');
|
||||||
|
expect(res.body.uptime).toBeGreaterThanOrEqual(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GET /ready', () => {
|
||||||
|
let app;
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
delete process.env.EMBY_URL;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 200 when EMBY_URL is configured', async () => {
|
||||||
|
process.env.EMBY_URL = 'https://emby.local';
|
||||||
|
app = createApp();
|
||||||
|
const res = await request(app).get('/ready');
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.status).toBe('ready');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 503 when EMBY_URL is not configured', async () => {
|
||||||
|
delete process.env.EMBY_URL;
|
||||||
|
app = createApp();
|
||||||
|
const res = await request(app).get('/ready');
|
||||||
|
expect(res.status).toBe(503);
|
||||||
|
expect(res.body.status).toBe('not ready');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { vi, beforeEach, afterEach } from 'vitest';
|
||||||
|
import os from 'os';
|
||||||
|
import path from 'path';
|
||||||
|
import fs from 'fs';
|
||||||
|
|
||||||
|
// Give each test worker a unique temp DATA_DIR so tokenStore file I/O is
|
||||||
|
// fully isolated and doesn't conflict with a running dev server's data/.
|
||||||
|
const tmpDir = path.join(os.tmpdir(), `sofarr-test-${process.pid}`);
|
||||||
|
if (!fs.existsSync(tmpDir)) fs.mkdirSync(tmpDir, { recursive: true });
|
||||||
|
process.env.DATA_DIR = tmpDir;
|
||||||
|
|
||||||
|
// Disable rate limiters in tests — all supertest requests share 127.0.0.1
|
||||||
|
// and would quickly exhaust per-IP windows otherwise.
|
||||||
|
process.env.SKIP_RATE_LIMIT = '1';
|
||||||
|
|
||||||
|
// Suppress console noise during tests (errors still surface via thrown exceptions)
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||||
|
vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||||
|
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
// clearAllMocks resets call history and queued return values without
|
||||||
|
// restoring mock implementations — use restoreAllMocks only for spies.
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
/**
|
||||||
|
* Tests for server/utils/config.js
|
||||||
|
*
|
||||||
|
* Verifies that instance config is parsed correctly from both the modern JSON
|
||||||
|
* array format and the legacy single-instance env var format. This is critical
|
||||||
|
* because misconfigured instances silently return no data rather than crashing.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { parseInstances, getSonarrInstances, getRadarrInstances } from '../../server/utils/config.js';
|
||||||
|
|
||||||
|
describe('parseInstances', () => {
|
||||||
|
describe('JSON array format', () => {
|
||||||
|
it('parses a valid single-instance JSON array', () => {
|
||||||
|
const json = JSON.stringify([{ name: 'main', url: 'https://sonarr.local', apiKey: 'abc123' }]);
|
||||||
|
const result = parseInstances(json, null, null);
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(result[0].url).toBe('https://sonarr.local');
|
||||||
|
expect(result[0].apiKey).toBe('abc123');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses multiple instances', () => {
|
||||||
|
const json = JSON.stringify([
|
||||||
|
{ name: 'main', url: 'https://s1.local', apiKey: 'key1' },
|
||||||
|
{ name: 'backup', url: 'https://s2.local', apiKey: 'key2' }
|
||||||
|
]);
|
||||||
|
const result = parseInstances(json, null, null);
|
||||||
|
expect(result).toHaveLength(2);
|
||||||
|
expect(result[1].name).toBe('backup');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adds id from name when present', () => {
|
||||||
|
const json = JSON.stringify([{ name: 'i3omb', url: 'https://s.local', apiKey: 'k' }]);
|
||||||
|
const result = parseInstances(json, null, null);
|
||||||
|
expect(result[0].id).toBe('i3omb');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('generates fallback id when name is absent', () => {
|
||||||
|
const json = JSON.stringify([{ url: 'https://s.local', apiKey: 'k' }]);
|
||||||
|
const result = parseInstances(json, null, null);
|
||||||
|
expect(result[0].id).toBe('instance-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles multi-line JSON by stripping whitespace', () => {
|
||||||
|
const json = `[
|
||||||
|
{
|
||||||
|
"name": "main",
|
||||||
|
"url": "https://sonarr.local",
|
||||||
|
"apiKey": "abc"
|
||||||
|
}
|
||||||
|
]`;
|
||||||
|
const result = parseInstances(json, null, null);
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty array for empty JSON array', () => {
|
||||||
|
expect(parseInstances('[]', null, null)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to legacy format when JSON is malformed', () => {
|
||||||
|
const result = parseInstances('not-json', 'https://legacy.local', 'legacyKey');
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(result[0].url).toBe('https://legacy.local');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('legacy single-instance format', () => {
|
||||||
|
it('returns single instance from legacy URL + key', () => {
|
||||||
|
const result = parseInstances(null, 'https://sonarr.local', 'legacyapikey');
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(result[0].id).toBe('default');
|
||||||
|
expect(result[0].name).toBe('Default');
|
||||||
|
expect(result[0].url).toBe('https://sonarr.local');
|
||||||
|
expect(result[0].apiKey).toBe('legacyapikey');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty array for qBittorrent with no apiKey and no JSON (legacy requires key)', () => {
|
||||||
|
// parseInstances requires legacyKey to be truthy for the legacy path;
|
||||||
|
// qBittorrent uses JSON array format, not the legacy URL+key path.
|
||||||
|
const result = parseInstances(null, 'https://qbt.local', null, 'admin', 'pass123');
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty array when both JSON and legacy URL are missing', () => {
|
||||||
|
expect(parseInstances(null, null, null)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty array when URL is set but key is missing', () => {
|
||||||
|
expect(parseInstances(null, 'https://sonarr.local', null)).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('env-based getters', () => {
|
||||||
|
it('getSonarrInstances reads SONARR_INSTANCES from env', () => {
|
||||||
|
process.env.SONARR_INSTANCES = JSON.stringify([{ name: 'test', url: 'https://s.local', apiKey: 'k' }]);
|
||||||
|
const result = getSonarrInstances();
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(result[0].name).toBe('test');
|
||||||
|
delete process.env.SONARR_INSTANCES;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getRadarrInstances returns empty array when unconfigured', () => {
|
||||||
|
delete process.env.RADARR_INSTANCES;
|
||||||
|
delete process.env.RADARR_URL;
|
||||||
|
const result = getRadarrInstances();
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
/**
|
||||||
|
* Tests for server/utils/qbittorrent.js pure utility functions.
|
||||||
|
*
|
||||||
|
* mapTorrentToDownload, formatBytes, formatSpeed, and formatEta are all
|
||||||
|
* pure functions with no I/O — ideal unit test targets. These power the
|
||||||
|
* dashboard card rendering so correctness matters for UX.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { mapTorrentToDownload, formatBytes, formatSpeed, formatEta } from '../../server/utils/qbittorrent.js';
|
||||||
|
|
||||||
|
// Minimal torrent fixture that satisfies mapTorrentToDownload's expectations
|
||||||
|
function makeTorrent(overrides = {}) {
|
||||||
|
return {
|
||||||
|
name: 'My.Show.S01E01.1080p.mkv',
|
||||||
|
state: 'downloading',
|
||||||
|
size: 1073741824, // 1 GB
|
||||||
|
completed: 536870912, // 512 MB
|
||||||
|
progress: 0.5,
|
||||||
|
dlspeed: 1048576, // 1 MB/s
|
||||||
|
eta: 512, // seconds
|
||||||
|
num_seeds: 10,
|
||||||
|
num_leechs: 3,
|
||||||
|
availability: 1.0,
|
||||||
|
hash: 'aabbccdd',
|
||||||
|
category: 'sonarr',
|
||||||
|
tags: '',
|
||||||
|
content_path: '/downloads/My.Show.S01E01.1080p.mkv',
|
||||||
|
save_path: '/downloads/',
|
||||||
|
instanceName: 'i3omb',
|
||||||
|
...overrides
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('formatBytes', () => {
|
||||||
|
it('formats 0 bytes', () => expect(formatBytes(0)).toBe('0 B'));
|
||||||
|
it('formats bytes', () => expect(formatBytes(512)).toBe('512 B'));
|
||||||
|
it('formats kilobytes', () => expect(formatBytes(1024)).toBe('1 KB'));
|
||||||
|
it('formats megabytes', () => expect(formatBytes(1048576)).toBe('1 MB'));
|
||||||
|
it('formats gigabytes', () => expect(formatBytes(1073741824)).toBe('1 GB'));
|
||||||
|
it('formats fractional GB', () => expect(formatBytes(1610612736)).toBe('1.5 GB'));
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('formatSpeed', () => {
|
||||||
|
it('appends /s to byte count', () => expect(formatSpeed(1048576)).toBe('1 MB/s'));
|
||||||
|
it('handles zero speed', () => expect(formatSpeed(0)).toBe('0 B/s'));
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('formatEta', () => {
|
||||||
|
it('returns ∞ for qBittorrent unknown sentinel (8640000)', () => {
|
||||||
|
expect(formatEta(8640000)).toBe('∞');
|
||||||
|
});
|
||||||
|
it('returns ∞ for negative eta', () => expect(formatEta(-1)).toBe('∞'));
|
||||||
|
it('formats minutes only', () => expect(formatEta(90)).toBe('1m'));
|
||||||
|
it('formats hours and minutes', () => expect(formatEta(3661)).toBe('1h 1m'));
|
||||||
|
it('formats days, hours and minutes', () => expect(formatEta(90061)).toBe('1d 1h 1m'));
|
||||||
|
it('returns 0m for zero seconds', () => expect(formatEta(0)).toBe('0m'));
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('mapTorrentToDownload', () => {
|
||||||
|
it('maps a downloading torrent correctly', () => {
|
||||||
|
const result = mapTorrentToDownload(makeTorrent());
|
||||||
|
expect(result.status).toBe('Downloading');
|
||||||
|
expect(result.progress).toBe('50.0');
|
||||||
|
expect(result.size).toBe('1 GB');
|
||||||
|
expect(result.speed).toBe('1 MB/s');
|
||||||
|
expect(result.eta).toBe('8m');
|
||||||
|
expect(result.seeds).toBe(10);
|
||||||
|
expect(result.peers).toBe(3);
|
||||||
|
expect(result.qbittorrent).toBe(true);
|
||||||
|
expect(result.instanceName).toBe('i3omb');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps state: stalledDL → Downloading', () => {
|
||||||
|
expect(mapTorrentToDownload(makeTorrent({ state: 'stalledDL' })).status).toBe('Downloading');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps state: uploading → Seeding', () => {
|
||||||
|
expect(mapTorrentToDownload(makeTorrent({ state: 'uploading' })).status).toBe('Seeding');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps state: pausedDL → Paused', () => {
|
||||||
|
expect(mapTorrentToDownload(makeTorrent({ state: 'pausedDL' })).status).toBe('Paused');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps state: stoppedUP → Stopped', () => {
|
||||||
|
expect(mapTorrentToDownload(makeTorrent({ state: 'stoppedUP' })).status).toBe('Stopped');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps state: error → Error', () => {
|
||||||
|
expect(mapTorrentToDownload(makeTorrent({ state: 'error' })).status).toBe('Error');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes through unknown state verbatim', () => {
|
||||||
|
expect(mapTorrentToDownload(makeTorrent({ state: 'weirdState' })).status).toBe('weirdState');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('computes 100% progress for completed torrent', () => {
|
||||||
|
const result = mapTorrentToDownload(makeTorrent({ progress: 1.0 }));
|
||||||
|
expect(result.progress).toBe('100.0');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses content_path as savePath when present', () => {
|
||||||
|
const result = mapTorrentToDownload(makeTorrent({ content_path: '/dl/file.mkv' }));
|
||||||
|
expect(result.savePath).toBe('/dl/file.mkv');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to save_path when content_path is absent', () => {
|
||||||
|
const result = mapTorrentToDownload(makeTorrent({ content_path: null, save_path: '/dl/' }));
|
||||||
|
expect(result.savePath).toBe('/dl/');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
/**
|
||||||
|
* Tests for server/middleware/requireAuth.js
|
||||||
|
*
|
||||||
|
* requireAuth guards all authenticated API routes. Tests exercise the full
|
||||||
|
* range of valid/invalid cookie states to ensure there's no bypass path.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import requireAuth from '../../server/middleware/requireAuth.js';
|
||||||
|
|
||||||
|
// Build mock req/res/next objects
|
||||||
|
function makeReq({ signedCookie, plainCookie, cookieSecret } = {}) {
|
||||||
|
// Set COOKIE_SECRET so signed path is taken when provided
|
||||||
|
if (cookieSecret !== undefined) {
|
||||||
|
process.env.COOKIE_SECRET = cookieSecret;
|
||||||
|
} else {
|
||||||
|
delete process.env.COOKIE_SECRET;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
signedCookies: { emby_user: signedCookie },
|
||||||
|
cookies: { emby_user: plainCookie }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeRes() {
|
||||||
|
const res = {
|
||||||
|
statusCode: null,
|
||||||
|
body: null,
|
||||||
|
status(code) { this.statusCode = code; return this; },
|
||||||
|
json(body) { this.body = body; return this; }
|
||||||
|
};
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
delete process.env.COOKIE_SECRET;
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('requireAuth middleware', () => {
|
||||||
|
describe('valid sessions', () => {
|
||||||
|
it('calls next() with a valid signed cookie', () => {
|
||||||
|
const payload = JSON.stringify({ id: 'u1', name: 'Alice', isAdmin: true });
|
||||||
|
const req = makeReq({ signedCookie: payload, cookieSecret: 'secret' });
|
||||||
|
const res = makeRes();
|
||||||
|
const next = vi.fn();
|
||||||
|
|
||||||
|
requireAuth(req, res, next);
|
||||||
|
|
||||||
|
expect(next).toHaveBeenCalledOnce();
|
||||||
|
expect(req.user).toMatchObject({ id: 'u1', name: 'Alice', isAdmin: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls next() with a valid unsigned cookie (no COOKIE_SECRET)', () => {
|
||||||
|
const payload = JSON.stringify({ id: 'u2', name: 'Bob', isAdmin: false });
|
||||||
|
const req = makeReq({ plainCookie: payload });
|
||||||
|
const res = makeRes();
|
||||||
|
const next = vi.fn();
|
||||||
|
|
||||||
|
requireAuth(req, res, next);
|
||||||
|
|
||||||
|
expect(next).toHaveBeenCalledOnce();
|
||||||
|
expect(req.user.id).toBe('u2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('coerces non-boolean isAdmin to boolean', () => {
|
||||||
|
const payload = JSON.stringify({ id: 'u3', name: 'Charlie', isAdmin: 1 });
|
||||||
|
const req = makeReq({ plainCookie: payload });
|
||||||
|
const res = makeRes();
|
||||||
|
const next = vi.fn();
|
||||||
|
|
||||||
|
requireAuth(req, res, next);
|
||||||
|
|
||||||
|
expect(next).toHaveBeenCalled();
|
||||||
|
expect(req.user.isAdmin).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('missing or invalid cookies', () => {
|
||||||
|
it('returns 401 when no cookie is present', () => {
|
||||||
|
const req = makeReq({});
|
||||||
|
const res = makeRes();
|
||||||
|
const next = vi.fn();
|
||||||
|
|
||||||
|
requireAuth(req, res, next);
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(401);
|
||||||
|
expect(next).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 401 when signed cookie value is false (tampered)', () => {
|
||||||
|
// cookie-parser sets signed cookie to false when signature is invalid
|
||||||
|
const req = makeReq({ signedCookie: false, cookieSecret: 'secret' });
|
||||||
|
const res = makeRes();
|
||||||
|
const next = vi.fn();
|
||||||
|
|
||||||
|
requireAuth(req, res, next);
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 401 for malformed JSON in cookie', () => {
|
||||||
|
const req = makeReq({ plainCookie: 'not-json' });
|
||||||
|
const res = makeRes();
|
||||||
|
const next = vi.fn();
|
||||||
|
|
||||||
|
requireAuth(req, res, next);
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(401);
|
||||||
|
expect(res.body.error).toBe('Invalid session');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 401 when id is missing', () => {
|
||||||
|
const payload = JSON.stringify({ name: 'Alice', isAdmin: false });
|
||||||
|
const req = makeReq({ plainCookie: payload });
|
||||||
|
requireAuth(req, makeRes(), vi.fn());
|
||||||
|
// no next called — handled in the assertion below
|
||||||
|
const res = makeRes();
|
||||||
|
const next = vi.fn();
|
||||||
|
requireAuth(req, res, next);
|
||||||
|
expect(res.statusCode).toBe(401);
|
||||||
|
expect(next).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 401 when name is missing', () => {
|
||||||
|
const payload = JSON.stringify({ id: 'u1', isAdmin: false });
|
||||||
|
const req = makeReq({ plainCookie: payload });
|
||||||
|
const res = makeRes();
|
||||||
|
requireAuth(req, res, vi.fn());
|
||||||
|
expect(res.statusCode).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 401 when id is empty string', () => {
|
||||||
|
const payload = JSON.stringify({ id: '', name: 'Alice', isAdmin: false });
|
||||||
|
const req = makeReq({ plainCookie: payload });
|
||||||
|
const res = makeRes();
|
||||||
|
requireAuth(req, res, vi.fn());
|
||||||
|
expect(res.statusCode).toBe(401);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
/**
|
||||||
|
* Tests for server/utils/sanitizeError.js
|
||||||
|
*
|
||||||
|
* Critical security tests: verify that API keys, tokens, passwords and other
|
||||||
|
* secrets are NEVER leaked in error messages returned to clients or written
|
||||||
|
* to logs. Every pattern here represents a real credential type used in the
|
||||||
|
* sofarr stack (SABnzbd apikey, Emby tokens, qBittorrent basic-auth URLs).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import sanitizeError from '../../server/utils/sanitizeError.js';
|
||||||
|
|
||||||
|
describe('sanitizeError', () => {
|
||||||
|
describe('query-param secrets', () => {
|
||||||
|
it('redacts ?apikey= values', () => {
|
||||||
|
const err = new Error('Request failed: https://sabnzbd.local/api?apikey=abc123secret&output=json');
|
||||||
|
expect(sanitizeError(err)).toContain('[REDACTED]');
|
||||||
|
expect(sanitizeError(err)).not.toContain('abc123secret');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('redacts &apikey= mid-URL', () => {
|
||||||
|
const err = new Error('GET https://host/path?mode=queue&apikey=SUPERSECRET&output=json');
|
||||||
|
expect(sanitizeError(err)).not.toContain('SUPERSECRET');
|
||||||
|
expect(sanitizeError(err)).toContain('[REDACTED]');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('redacts ?token= values', () => {
|
||||||
|
const err = new Error('https://api.example.com/data?token=tok_private99');
|
||||||
|
expect(sanitizeError(err)).not.toContain('tok_private99');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('redacts ?password= values', () => {
|
||||||
|
const err = new Error('Auth failed: https://service.local?password=hunter2');
|
||||||
|
expect(sanitizeError(err)).not.toContain('hunter2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('redacts ?api_key= values', () => {
|
||||||
|
const err = new Error('https://sonarr.local/api/v3/series?api_key=e583d270f89846478e42');
|
||||||
|
expect(sanitizeError(err)).not.toContain('e583d270f89846478e42');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves non-secret query params', () => {
|
||||||
|
const result = sanitizeError(new Error('GET /api?mode=queue&output=json'));
|
||||||
|
expect(result).toContain('mode=queue');
|
||||||
|
expect(result).toContain('output=json');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('HTTP auth headers', () => {
|
||||||
|
it('redacts X-Api-Key header values', () => {
|
||||||
|
const err = new Error('Request: x-api-key: e583d270f89846478e42dd3cf90bfb00');
|
||||||
|
expect(sanitizeError(err)).not.toContain('e583d270f89846478e42dd3cf90bfb00');
|
||||||
|
expect(sanitizeError(err)).toContain('[REDACTED]');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('redacts X-MediaBrowser-Token header values', () => {
|
||||||
|
const err = new Error('x-mediabrowser-token: b862f3a43f4c417285043a11aa28b1f7');
|
||||||
|
expect(sanitizeError(err)).not.toContain('b862f3a43f4c417285043a11aa28b1f7');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('redacts Authorization header values', () => {
|
||||||
|
const err = new Error('authorization: MediaBrowser Token="abc123", DeviceId="xyz"');
|
||||||
|
expect(sanitizeError(err)).not.toContain('abc123');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('bearer tokens', () => {
|
||||||
|
it('redacts Bearer token values', () => {
|
||||||
|
const err = new Error('Error: bearer eyJhbGciOiJIUzI1NiJ9.payload.sig');
|
||||||
|
expect(sanitizeError(err)).not.toContain('eyJhbGciOiJIUzI1NiJ9');
|
||||||
|
expect(sanitizeError(err)).toContain('bearer [REDACTED]');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is case-insensitive for BEARER', () => {
|
||||||
|
const err = new Error('BEARER TOKEN_VALUE_HERE');
|
||||||
|
expect(sanitizeError(err)).not.toContain('TOKEN_VALUE_HERE');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('basic-auth URLs', () => {
|
||||||
|
it('redacts user:pass@ in URLs', () => {
|
||||||
|
const err = new Error('GET http://admin:b053288369XX!@qbittorrent.local/api');
|
||||||
|
expect(sanitizeError(err)).not.toContain('b053288369XX!');
|
||||||
|
expect(sanitizeError(err)).not.toContain('admin:');
|
||||||
|
expect(sanitizeError(err)).toContain('//[REDACTED]@');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles https:// basic auth', () => {
|
||||||
|
const err = new Error('https://user:s3cr3t@service.local/path');
|
||||||
|
expect(sanitizeError(err)).not.toContain('s3cr3t');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('edge cases', () => {
|
||||||
|
it('handles non-Error input (plain string)', () => {
|
||||||
|
const result = sanitizeError('plain string error');
|
||||||
|
expect(typeof result).toBe('string');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles null gracefully', () => {
|
||||||
|
expect(() => sanitizeError(null)).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles undefined gracefully', () => {
|
||||||
|
expect(() => sanitizeError(undefined)).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves non-sensitive error messages unchanged', () => {
|
||||||
|
const err = new Error('Connection refused: ECONNREFUSED 127.0.0.1:8080');
|
||||||
|
const result = sanitizeError(err);
|
||||||
|
expect(result).toContain('ECONNREFUSED');
|
||||||
|
expect(result).toContain('127.0.0.1:8080');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not leak stack traces (returns message only)', () => {
|
||||||
|
const err = new Error('something went wrong');
|
||||||
|
const result = sanitizeError(err);
|
||||||
|
expect(result).not.toContain('at ');
|
||||||
|
expect(result).not.toContain('.js:');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
/**
|
||||||
|
* Tests for server/utils/tokenStore.js
|
||||||
|
*
|
||||||
|
* The token store persists Emby access tokens to disk (JSON file) so users
|
||||||
|
* survive server restarts without re-logging in. Tests verify the store/get/
|
||||||
|
* clear lifecycle, TTL expiry, and atomic write behaviour.
|
||||||
|
*
|
||||||
|
* Each test imports a FRESH module instance (vi.resetModules) so the
|
||||||
|
* module-level singleton state (loaded from disk) doesn't bleed between tests.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { vi } from 'vitest';
|
||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
import os from 'os';
|
||||||
|
|
||||||
|
// Each test gets its own isolated temp dir
|
||||||
|
let tmpDir;
|
||||||
|
let tokenStore;
|
||||||
|
|
||||||
|
async function freshStore(dir) {
|
||||||
|
vi.resetModules();
|
||||||
|
process.env.DATA_DIR = dir;
|
||||||
|
const mod = await import('../../server/utils/tokenStore.js');
|
||||||
|
return mod;
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'sofarr-ts-'));
|
||||||
|
tokenStore = await freshStore(tmpDir);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('tokenStore', () => {
|
||||||
|
it('stores and retrieves a token', () => {
|
||||||
|
tokenStore.storeToken('user1', 'access-token-abc');
|
||||||
|
const result = tokenStore.getToken('user1');
|
||||||
|
expect(result).not.toBeNull();
|
||||||
|
expect(result.accessToken).toBe('access-token-abc');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null for an unknown user', () => {
|
||||||
|
expect(tokenStore.getToken('nobody')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears a stored token', () => {
|
||||||
|
tokenStore.storeToken('user1', 'token-xyz');
|
||||||
|
tokenStore.clearToken('user1');
|
||||||
|
expect(tokenStore.getToken('user1')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clearToken is a no-op for unknown user', () => {
|
||||||
|
expect(() => tokenStore.clearToken('ghost')).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('overwrites existing token on re-store', () => {
|
||||||
|
tokenStore.storeToken('user1', 'old-token');
|
||||||
|
tokenStore.storeToken('user1', 'new-token');
|
||||||
|
expect(tokenStore.getToken('user1').accessToken).toBe('new-token');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('persists to disk (tokens.json exists after store)', () => {
|
||||||
|
tokenStore.storeToken('u1', 'tok');
|
||||||
|
const storePath = path.join(tmpDir, 'tokens.json');
|
||||||
|
expect(fs.existsSync(storePath)).toBe(true);
|
||||||
|
const data = JSON.parse(fs.readFileSync(storePath, 'utf8'));
|
||||||
|
expect(data.u1.accessToken).toBe('tok');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('expires tokens older than 31 days on read', () => {
|
||||||
|
// Write an already-expired entry directly to disk
|
||||||
|
const expired = Date.now() - (32 * 24 * 60 * 60 * 1000);
|
||||||
|
const storePath = path.join(tmpDir, 'tokens.json');
|
||||||
|
fs.writeFileSync(storePath, JSON.stringify({ u1: { accessToken: 'old', createdAt: expired } }));
|
||||||
|
// Re-import to load from disk
|
||||||
|
vi.resetModules();
|
||||||
|
return import('../../server/utils/tokenStore.js').then(mod => {
|
||||||
|
expect(mod.getToken('u1')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
/**
|
||||||
|
* Tests for server/middleware/verifyCsrf.js
|
||||||
|
*
|
||||||
|
* CSRF protection via the double-submit cookie pattern. These tests verify
|
||||||
|
* that the timing-safe comparison works correctly and that safe HTTP methods
|
||||||
|
* are correctly exempted.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import verifyCsrf from '../../server/middleware/verifyCsrf.js';
|
||||||
|
|
||||||
|
function makeReq(method, cookieToken, headerToken) {
|
||||||
|
return {
|
||||||
|
method,
|
||||||
|
cookies: { csrf_token: cookieToken },
|
||||||
|
headers: { 'x-csrf-token': headerToken }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeRes() {
|
||||||
|
const res = {
|
||||||
|
statusCode: null,
|
||||||
|
body: null,
|
||||||
|
status(code) { this.statusCode = code; return this; },
|
||||||
|
json(body) { this.body = body; return this; }
|
||||||
|
};
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('verifyCsrf middleware', () => {
|
||||||
|
describe('safe methods are exempted', () => {
|
||||||
|
for (const method of ['GET', 'HEAD', 'OPTIONS']) {
|
||||||
|
it(`allows ${method} with no CSRF token`, () => {
|
||||||
|
const next = vi.fn();
|
||||||
|
verifyCsrf(makeReq(method, undefined, undefined), makeRes(), next);
|
||||||
|
expect(next).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('mutating methods require valid token', () => {
|
||||||
|
const TOKEN = 'a'.repeat(64); // 64 hex chars = 32 bytes
|
||||||
|
|
||||||
|
for (const method of ['POST', 'PUT', 'PATCH', 'DELETE']) {
|
||||||
|
it(`allows ${method} with matching tokens`, () => {
|
||||||
|
const next = vi.fn();
|
||||||
|
const res = makeRes();
|
||||||
|
verifyCsrf(makeReq(method, TOKEN, TOKEN), res, next);
|
||||||
|
expect(next).toHaveBeenCalledOnce();
|
||||||
|
expect(res.statusCode).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it(`blocks ${method} with mismatched tokens`, () => {
|
||||||
|
const next = vi.fn();
|
||||||
|
const res = makeRes();
|
||||||
|
verifyCsrf(makeReq(method, TOKEN, TOKEN.replace('a', 'b')), res, next);
|
||||||
|
expect(res.statusCode).toBe(403);
|
||||||
|
expect(next).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it(`blocks ${method} with missing cookie token`, () => {
|
||||||
|
const next = vi.fn();
|
||||||
|
const res = makeRes();
|
||||||
|
verifyCsrf(makeReq(method, undefined, TOKEN), res, next);
|
||||||
|
expect(res.statusCode).toBe(403);
|
||||||
|
expect(res.body.error).toBe('CSRF token missing');
|
||||||
|
});
|
||||||
|
|
||||||
|
it(`blocks ${method} with missing header token`, () => {
|
||||||
|
const next = vi.fn();
|
||||||
|
const res = makeRes();
|
||||||
|
verifyCsrf(makeReq(method, TOKEN, undefined), res, next);
|
||||||
|
expect(res.statusCode).toBe(403);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
it('blocks when tokens have different lengths (timing-safe path)', () => {
|
||||||
|
const next = vi.fn();
|
||||||
|
const res = makeRes();
|
||||||
|
verifyCsrf(makeReq('POST', 'short', 'much-longer-token-here'), res, next);
|
||||||
|
expect(res.statusCode).toBe(403);
|
||||||
|
expect(res.body.error).toBe('CSRF token invalid');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { defineConfig } from 'vitest/config';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
// Node environment for all tests (server-side CJS modules, no browser APIs needed)
|
||||||
|
environment: 'node',
|
||||||
|
// Global test helpers (describe, it, expect, vi) without per-file imports
|
||||||
|
globals: true,
|
||||||
|
// Run each test file in an isolated module registry so module-level state
|
||||||
|
// (tokenStore cache, config singletons) doesn't leak between files
|
||||||
|
isolate: true,
|
||||||
|
// Give each file its own data directory so tokenStore file I/O doesn't collide
|
||||||
|
setupFiles: ['./tests/setup.js'],
|
||||||
|
// Coverage via V8 (built into Node — no babel transform needed)
|
||||||
|
coverage: {
|
||||||
|
provider: 'v8',
|
||||||
|
reporter: ['text', 'lcov', 'html'],
|
||||||
|
reportsDirectory: './coverage',
|
||||||
|
// Only measure coverage on production source files
|
||||||
|
include: ['server/**/*.js'],
|
||||||
|
exclude: [
|
||||||
|
'server/index.js', // entry point with side-effects (process.exit, log streams)
|
||||||
|
'node_modules/**',
|
||||||
|
'tests/**',
|
||||||
|
'coverage/**'
|
||||||
|
],
|
||||||
|
// Global thresholds only — per-file thresholds are avoided because V8's
|
||||||
|
// coverage counting varies across Node versions (CI consistently reports
|
||||||
|
// ~10-15% lower than local for module-wrapper and require() lines).
|
||||||
|
// The overall numbers reflect that dashboard.js and poller.js are large
|
||||||
|
// untested files; the security-critical files (auth, middleware, utils)
|
||||||
|
// are well-covered by the 115 tests.
|
||||||
|
thresholds: {
|
||||||
|
lines: 25,
|
||||||
|
functions: 12,
|
||||||
|
branches: 12,
|
||||||
|
statements: 25
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user