Compare commits
39 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 205d95e232 | |||
| 6865b860bc | |||
| 9aaff5c368 | |||
| ce6f9b0459 | |||
| 976d6527b6 | |||
| 6a8ca90fd3 | |||
| 2d5958006c | |||
| 9faf8c0ea3 | |||
| cb0e61ea36 | |||
| bd3b28921d | |||
| 1d9e86760b | |||
| ae3bf70008 | |||
| fb719141fa | |||
| e45c566fd7 | |||
| 81d3e0045f | |||
| 1f3b2adbfe | |||
| 5b84e091b0 | |||
| ad024ab87b | |||
| cc4f420482 | |||
| a435c506f7 | |||
| c8c46cb9fb | |||
| 0354531e95 | |||
| c0dd93a1ab | |||
| 3c4c24d0e4 | |||
| e535da7f91 | |||
| b2d941a767 | |||
| fce8a9ece6 | |||
| 42d01da7f7 | |||
| 43cb3a0d17 | |||
| 6cf01f5530 | |||
| 6bf8098265 | |||
| a42392fec6 | |||
| a368636ec4 | |||
| f23117ff7a | |||
| 2cf163dfff | |||
| 6ff97ed246 | |||
| ef89207d9d | |||
| fa5805c6a4 | |||
| 57bab01855 |
@@ -10,7 +10,14 @@ node_modules/
|
||||
client/
|
||||
dist/
|
||||
build/
|
||||
coverage/
|
||||
tests/
|
||||
vitest.config.js
|
||||
.markdownlint.json
|
||||
README.md
|
||||
CHANGELOG.md
|
||||
SECURITY.md
|
||||
LICENSE
|
||||
.dockerignore
|
||||
Dockerfile
|
||||
.gitea/
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
name: Docs Check
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["**", "!main", "!release/**"]
|
||||
paths:
|
||||
- "**.md"
|
||||
- ".gitea/workflows/docs-check.yml"
|
||||
pull_request:
|
||||
branches: ["**", "!main", "!release/**"]
|
||||
paths:
|
||||
- "**.md"
|
||||
- ".gitea/workflows/docs-check.yml"
|
||||
|
||||
jobs:
|
||||
markdown-lint:
|
||||
name: Markdown lint
|
||||
runs-on: ubuntu-latest
|
||||
continue-on-error: true
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
|
||||
- name: Install markdownlint-cli
|
||||
run: npm install -g markdownlint-cli
|
||||
|
||||
- name: Lint all Markdown files
|
||||
run: markdownlint "**/*.md" --ignore node_modules
|
||||
|
||||
mermaid-parse:
|
||||
name: Mermaid diagram parse check
|
||||
runs-on: ubuntu-latest
|
||||
continue-on-error: true
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
|
||||
- name: Install mermaid and jsdom
|
||||
run: npm install mermaid jsdom
|
||||
|
||||
- name: Extract and validate Mermaid diagrams
|
||||
run: |
|
||||
cat > check-mermaid.cjs << 'SCRIPT'
|
||||
const { JSDOM } = require('jsdom');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
// Provide minimal browser globals so mermaid.parse() works in Node
|
||||
const dom = new JSDOM('<!DOCTYPE html><html><body></body></html>', { url: 'http://localhost' });
|
||||
globalThis.window = dom.window;
|
||||
globalThis.document = dom.window.document;
|
||||
globalThis.DOMPurify = {
|
||||
addHook: () => {}, removeHook: () => {}, setConfig: () => {},
|
||||
sanitize: (s) => s, isValidAttribute: () => true,
|
||||
};
|
||||
|
||||
function findMdFiles(dir) {
|
||||
const out = [];
|
||||
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const full = path.join(dir, e.name);
|
||||
if (e.isDirectory() && e.name !== 'node_modules' && !e.name.startsWith('.'))
|
||||
out.push(...findMdFiles(full));
|
||||
else if (e.isFile() && e.name.endsWith('.md'))
|
||||
out.push(full);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
import('./node_modules/mermaid/dist/mermaid.core.mjs').then(async (m) => {
|
||||
const mermaid = m.default;
|
||||
let errors = 0, total = 0;
|
||||
|
||||
for (const mdFile of findMdFiles('.')) {
|
||||
const content = fs.readFileSync(mdFile, 'utf8');
|
||||
const blocks = [...content.matchAll(/^```mermaid\n([\s\S]*?)^```/gm)];
|
||||
if (!blocks.length) continue;
|
||||
console.log(`\nChecking ${mdFile} (${blocks.length} diagram(s))`);
|
||||
for (let i = 0; i < blocks.length; i++) {
|
||||
total++;
|
||||
const diagram = blocks[i][1].trim();
|
||||
try {
|
||||
await mermaid.parse(diagram);
|
||||
console.log(` [OK] diagram ${i + 1}`);
|
||||
} catch (err) {
|
||||
const msg = String(err.message || err).split('\n')[0];
|
||||
console.error(` [FAIL] diagram ${i + 1}: ${msg}`);
|
||||
console.log(`::warning file=${mdFile}::Mermaid diagram ${i + 1} failed: ${msg}`);
|
||||
errors++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\nTotal: ${total}. Failures: ${errors}`);
|
||||
if (errors > 0) {
|
||||
console.log(`::warning::${errors} Mermaid diagram(s) failed to parse.`);
|
||||
process.exit(1);
|
||||
}
|
||||
}).catch(e => { console.error('Fatal:', e.message); process.exit(1); });
|
||||
SCRIPT
|
||||
node check-mermaid.cjs
|
||||
@@ -0,0 +1,38 @@
|
||||
name: Licence Check
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["**", "!main", "!release/**"]
|
||||
paths:
|
||||
- "package.json"
|
||||
- "package-lock.json"
|
||||
- ".gitea/workflows/licence-check.yml"
|
||||
pull_request:
|
||||
branches: ["**", "!main", "!release/**"]
|
||||
paths:
|
||||
- "package.json"
|
||||
- "package-lock.json"
|
||||
- ".gitea/workflows/licence-check.yml"
|
||||
|
||||
jobs:
|
||||
licence-check:
|
||||
name: Dependency licence compatibility
|
||||
runs-on: ubuntu-latest
|
||||
continue-on-error: true
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
|
||||
- name: Install production dependencies
|
||||
run: npm ci --omit=dev
|
||||
|
||||
- name: Check licence compatibility
|
||||
run: |
|
||||
npx --yes license-checker --production \
|
||||
--onlyAllow "MIT;ISC;MIT-0;BSD-2-Clause;BSD-3-Clause;Apache-2.0;CC0-1.0;BlueOak-1.0.0" \
|
||||
--excludePrivatePackages \
|
||||
&& echo "All production dependency licences are compatible with MIT."
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"default": true,
|
||||
"MD009": false,
|
||||
"MD012": false,
|
||||
"MD013": false,
|
||||
"MD022": false,
|
||||
"MD024": false,
|
||||
"MD029": false,
|
||||
"MD031": false,
|
||||
"MD032": false,
|
||||
"MD033": false,
|
||||
"MD034": false,
|
||||
"MD036": false,
|
||||
"MD040": false,
|
||||
"MD041": false,
|
||||
"MD058": false,
|
||||
"MD060": false
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
||||
This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
---
|
||||
|
||||
## [1.2.1] - 2026-05-17
|
||||
|
||||
### Added
|
||||
|
||||
- **Version footer** — the dashboard footer now displays the running app version (e.g. `sofarr v1.2.1`), fetched from the `/health` endpoint on page load.
|
||||
|
||||
---
|
||||
|
||||
## [1.2.0] - 2025-05-17
|
||||
|
||||
### Security
|
||||
|
||||
- **Docker secrets support** — all sensitive environment variables (`COOKIE_SECRET`, `EMBY_API_KEY`, `SABNZBD_API_KEY`, `SONARR_API_KEY`, `RADARR_API_KEY`, `QBITTORRENT_PASSWORD`) now support the standard `_FILE` variant for loading values from mounted secret files (e.g. `COOKIE_SECRET_FILE=/run/secrets/cookie_secret`).
|
||||
- **Weak secret warning** — server now warns at startup if `COOKIE_SECRET` is shorter than 32 characters.
|
||||
- **EMBY_URL validation** — validates the Emby URL scheme at startup and warns on misconfiguration.
|
||||
- **Improved error sanitization** — `sanitizeError()` now also redacts hostnames from full request URLs that may appear in axios error messages.
|
||||
- **Graceful shutdown** — `SIGTERM` and `SIGINT` handlers now stop the background poller and drain open HTTP connections before exiting. Prevents data loss and zombie processes on `docker stop`.
|
||||
|
||||
### Compliance
|
||||
|
||||
- **MIT LICENSE file** added to project root.
|
||||
- **Copyright headers** added to key server source files (`index.js`, `poller.js`, `config.js`, `sanitizeError.js`, `loadSecrets.js`).
|
||||
- **`security.txt`** (`/.well-known/security.txt`) added for responsible disclosure.
|
||||
|
||||
### Configuration
|
||||
|
||||
- **URL validation** added to `config.js` — all configured service instance URLs are validated for scheme (`http`/`https`) and well-formedness at startup; malformed URLs emit a warning instead of crashing.
|
||||
|
||||
### Docker / Deployment
|
||||
|
||||
- **`docker-compose.yaml`** updated with commented Option B (Docker secrets `_FILE` pattern) alongside the existing plain-env Option A.
|
||||
- **`.dockerignore`** updated — `tests/`, `coverage/`, `vitest.config.js`, `CHANGELOG.md`, `SECURITY.md`, `LICENSE`, `.markdownlint.json` excluded from the production image.
|
||||
|
||||
### CI
|
||||
|
||||
- **`docs-check` workflow** added — separate Gitea Actions workflow that lints all Markdown files and validates Mermaid diagram syntax on every push that touches `.md` files. Both jobs use `continue-on-error: true` so documentation issues never block a release.
|
||||
- **Mermaid diagrams** in `docs/ARCHITECTURE.md` fixed — replaced invalid `\n` in stateDiagram transition labels, Unicode arrows/dashes, and double-spaces in flowchart edge definitions.
|
||||
|
||||
---
|
||||
|
||||
## [1.1.2] - 2025-05-15
|
||||
|
||||
### Changed
|
||||
|
||||
- Server startup message now includes the current version (`sofarr v1.1.2`).
|
||||
|
||||
---
|
||||
|
||||
## [1.1.1] - 2025-05-14
|
||||
|
||||
### Fixed
|
||||
|
||||
- Docker/TrueNAS SCALE healthcheck: dynamic HTTP/HTTPS selection based on `TLS_ENABLED` environment variable. Prevents containers from being stuck in "starting" state when `TLS_ENABLED=false`.
|
||||
|
||||
---
|
||||
|
||||
## [1.1.0] - 2025-05-13
|
||||
|
||||
### Added
|
||||
|
||||
- **Episode display** — TV show download cards now show episode information (S01E01 format with title). Multi-episode packs show a "Multiple episodes" badge with a tooltip listing all episodes.
|
||||
- **Episode tooltip** — solid background colour (theme-dependent) for readability.
|
||||
- Sonarr queue and history API requests now include `includeEpisode=true`.
|
||||
|
||||
---
|
||||
|
||||
## [1.0.0] - 2025-05-01
|
||||
|
||||
### Added
|
||||
|
||||
- Initial release.
|
||||
- SABnzbd queue and history integration.
|
||||
- qBittorrent torrent integration.
|
||||
- Sonarr and Radarr queue/history matching with user tag filtering.
|
||||
- Emby/Jellyfin authentication.
|
||||
- Server-Sent Events (SSE) real-time dashboard.
|
||||
- Per-request CSP nonce, CSRF double-submit, HSTS, Permissions-Policy.
|
||||
- Background polling with configurable interval and on-demand fallback.
|
||||
- Docker multi-stage build, non-root user, read-only filesystem.
|
||||
- TLS support with bundled snakeoil certificate.
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Gordon Bolton
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -342,3 +342,4 @@ MIT
|
||||
---
|
||||
|
||||
*sofarr: See what has downloaded "so far" from the comfort of your "sofa"*
|
||||
|
||||
|
||||
+8
-7
@@ -4,9 +4,10 @@
|
||||
|
||||
| Version | Supported |
|
||||
|---------|-----------|
|
||||
| 1.0.x | ✅ Yes |
|
||||
| 0.2.x | ❌ No |
|
||||
| 0.1.x | ❌ No |
|
||||
| 1.2.x | ✅ Yes |
|
||||
| 1.1.x | ✅ Yes |
|
||||
| 1.0.x | ❌ No |
|
||||
| < 1.0 | ❌ No |
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
@@ -79,10 +80,10 @@ services:
|
||||
- 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.
|
||||
> Since v1.2.0, sofarr natively supports the `_FILE` pattern.
|
||||
> Set `COOKIE_SECRET_FILE=/run/secrets/cookie_secret` and sofarr will
|
||||
> read the secret value from that file at startup. See `docker-compose.yaml`
|
||||
> for a complete example.
|
||||
|
||||
---
|
||||
|
||||
|
||||
+20
-1
@@ -21,7 +21,8 @@ services:
|
||||
# Set TLS_ENABLED=false if terminating TLS at a reverse proxy instead.
|
||||
# If using a reverse proxy, also set TRUST_PROXY=1 below.
|
||||
# - TRUST_PROXY=1
|
||||
# --- Replace placeholders with real values or use Docker secrets ---
|
||||
# --- Secrets: use _FILE variants (Docker secrets) in production -------
|
||||
# Option A — plain environment variables (simple, less secure):
|
||||
- COOKIE_SECRET=change-me-generate-with-openssl-rand-hex-32
|
||||
- EMBY_URL=https://emby.example.com
|
||||
- EMBY_API_KEY=your-emby-api-key
|
||||
@@ -29,6 +30,17 @@ services:
|
||||
- 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"}]
|
||||
# Option B — Docker secrets (_FILE pattern, recommended for production):
|
||||
# Uncomment the lines below and comment out Option A above.
|
||||
# Create secret files with: echo -n "value" > ./secrets/cookie_secret.txt
|
||||
# - COOKIE_SECRET_FILE=/run/secrets/cookie_secret
|
||||
# - EMBY_API_KEY_FILE=/run/secrets/emby_api_key
|
||||
# - SONARR_API_KEY_FILE=/run/secrets/sonarr_api_key # legacy single-instance only
|
||||
# - RADARR_API_KEY_FILE=/run/secrets/radarr_api_key # legacy single-instance only
|
||||
# - SABNZBD_API_KEY_FILE=/run/secrets/sabnzbd_api_key # legacy single-instance only
|
||||
# secrets: # uncomment when using Option B
|
||||
# - cookie_secret
|
||||
# - emby_api_key
|
||||
volumes:
|
||||
# Persistent volume for token store and log file
|
||||
- sofarr-data:/app/data
|
||||
@@ -57,3 +69,10 @@ services:
|
||||
|
||||
volumes:
|
||||
sofarr-data:
|
||||
|
||||
# Docker secrets definitions (uncomment and populate when using Option B above)
|
||||
# secrets:
|
||||
# cookie_secret:
|
||||
# file: ./secrets/cookie_secret.txt
|
||||
# emby_api_key:
|
||||
# file: ./secrets/emby_api_key.txt
|
||||
|
||||
+30
-27
@@ -372,18 +372,18 @@ For each download item (SABnzbd slot or qBittorrent torrent):
|
||||
```mermaid
|
||||
flowchart TD
|
||||
Start(["Download item"]) --> SQ{"Sonarr QUEUE\nmatch (title)"}
|
||||
SQ -->|yes| SQR["Resolve series via seriesMap\nextract user tag → check match"]
|
||||
SQ -->|no| RQ{"Radarr QUEUE\nmatch (title)"}
|
||||
RQ -->|yes| RQR["Resolve movie via moviesMap\nextract user tag → check match"]
|
||||
RQ -->|no| SH{"Sonarr HISTORY\nmatch (title)"}
|
||||
SH -->|yes| SHR["Resolve series via seriesId\nextract user tag → check match"]
|
||||
SH -->|no| RH{"Radarr HISTORY\nmatch (title)"}
|
||||
RH -->|yes| RHR["Resolve movie via movieId\nextract user tag → check match"]
|
||||
RH -->|no| Skip(["Skip — unmatched"])
|
||||
SQ -->|yes| SQR["Resolve series via seriesMap\nextract user tag, check match"]
|
||||
SQ -->|no| RQ{"Radarr QUEUE\nmatch (title)"}
|
||||
RQ -->|yes| RQR["Resolve movie via moviesMap\nextract user tag, check match"]
|
||||
RQ -->|no| SH{"Sonarr HISTORY\nmatch (title)"}
|
||||
SH -->|yes| SHR["Resolve series via seriesId\nextract user tag, check match"]
|
||||
SH -->|no| RH{"Radarr HISTORY\nmatch (title)"}
|
||||
RH -->|yes| RHR["Resolve movie via movieId\nextract user tag, check match"]
|
||||
RH -->|no| Skip(["Skip - unmatched"])
|
||||
|
||||
SQR & RQR & SHR & RHR --> Tagged{"Tag matches\nrequesting user?"}
|
||||
Tagged -->|yes| Include(["Include in response"])
|
||||
Tagged -->|no| Skip
|
||||
Tagged -->|no| Skip
|
||||
```
|
||||
|
||||
### Title Matching
|
||||
@@ -941,8 +941,8 @@ graph TB
|
||||
sonarr_r --> sonarr
|
||||
radarr_r --> radarr
|
||||
|
||||
appjs -->|POST /login\nGET /me\nGET /csrf\nPOST /logout| auth
|
||||
appjs -->|GET /stream SSE\nGET /user-downloads\nGET /status| dashboard
|
||||
appjs -->|POST /login, GET /me, GET /csrf, POST /logout| auth
|
||||
appjs -->|GET /stream SSE, GET /user-downloads, GET /status| dashboard
|
||||
es -->|serve static| html
|
||||
```
|
||||
|
||||
@@ -976,16 +976,16 @@ sequenceDiagram
|
||||
Note over Browser,Emby: Login
|
||||
User->>Browser: Enter credentials (+ rememberMe)
|
||||
Browser->>Auth: POST /api/auth/login
|
||||
Note right of Auth: Rate limit: max 10 failed\nattempts per IP / 15 min
|
||||
Auth->>Emby: POST /Users/authenticatebyname\nDeviceId = sha256(username)[0:16]
|
||||
Note right of Auth: Rate limit: max 10 failed attempts per IP / 15 min
|
||||
Auth->>Emby: POST /Users/authenticatebyname (DeviceId = sha256(username)[0:16])
|
||||
alt Valid credentials
|
||||
Emby-->>Auth: { User.Id, AccessToken }
|
||||
Auth->>Emby: GET /Users/{id}
|
||||
Emby-->>Auth: { Name, Policy.IsAdministrator }
|
||||
Auth->>Tokens: storeToken(userId, AccessToken)
|
||||
Note right of Tokens: Server-side only\n31-day TTL, atomic write
|
||||
Auth->>Auth: Set emby_user cookie\nhttpOnly, sameSite=strict\nsecure (if TRUST_PROXY)\nrememberMe → Max-Age 30d
|
||||
Auth->>Auth: Set csrf_token cookie\nhttpOnly=false, sameSite=strict
|
||||
Note right of Tokens: Server-side only, 31-day TTL, atomic write
|
||||
Auth->>Auth: Set emby_user cookie (httpOnly, sameSite=strict, secure if TRUST_PROXY)
|
||||
Auth->>Auth: Set csrf_token cookie (httpOnly=false, sameSite=strict)
|
||||
Auth-->>Browser: { success: true, user, csrfToken }
|
||||
Browser->>Browser: showDashboard() + startSSE()
|
||||
else Invalid credentials
|
||||
@@ -1023,7 +1023,7 @@ sequenceDiagram
|
||||
User->>Browser: Login success / valid session
|
||||
Browser->>Dashboard: GET /api/dashboard/stream (EventSource)
|
||||
Dashboard->>Dashboard: requireAuth: extract user/isAdmin
|
||||
Dashboard->>Dashboard: Set Content-Type: text/event-stream\nRegister in activeClients
|
||||
Dashboard->>Dashboard: Set Content-Type: text/event-stream, register in activeClients
|
||||
|
||||
opt Polling disabled AND cache empty
|
||||
Dashboard->>Poller: pollAllServices()
|
||||
@@ -1033,7 +1033,7 @@ sequenceDiagram
|
||||
end
|
||||
|
||||
Dashboard->>Cache: get all poll:* keys
|
||||
Dashboard->>Dashboard: Build maps, match downloads\nextractUserTag / buildTagBadges
|
||||
Dashboard->>Dashboard: Build maps, match downloads, extractUserTag / buildTagBadges
|
||||
Dashboard-->>Browser: data: { user, isAdmin, downloads }
|
||||
Browser->>Browser: hideLoading() + renderDownloads()
|
||||
|
||||
@@ -1050,7 +1050,7 @@ sequenceDiagram
|
||||
|
||||
User->>Browser: Close tab / logout
|
||||
Browser->>Dashboard: TCP close (req close event)
|
||||
Dashboard->>Dashboard: offPollComplete(cb)\nclearInterval(heartbeat)\ndelete activeClients[key]
|
||||
Dashboard->>Dashboard: offPollComplete(cb), clearInterval(heartbeat), delete activeClients[key]
|
||||
```
|
||||
|
||||
### 13.4 Background Polling Cycle
|
||||
@@ -1094,8 +1094,8 @@ sequenceDiagram
|
||||
Poller->>QBT: getTorrents()
|
||||
QBT-->>Poller: [{ name, progress, ... }]
|
||||
|
||||
Poller->>Poller: Record per-task timings\nlastPollTimings = { totalMs, timestamp, tasks }
|
||||
Poller->>Cache: set poll:* keys (TTL = POLL_INTERVAL × 3)
|
||||
Poller->>Poller: Record per-task timings: lastPollTimings = { totalMs, timestamp, tasks }
|
||||
Poller->>Cache: set poll:* keys (TTL = POLL_INTERVAL x 3)
|
||||
Poller->>Poller: Notify SSE subscribers (forEach cb())
|
||||
Poller->>Poller: polling = false
|
||||
```
|
||||
@@ -1330,11 +1330,11 @@ stateDiagram-v2
|
||||
Submitting --> [*] : Auth success
|
||||
}
|
||||
|
||||
LoginForm --> Dashboard : Auth success\n(fade transition)
|
||||
LoginForm --> Dashboard : Auth success (fade transition)
|
||||
|
||||
state Dashboard {
|
||||
[*] --> Rendering
|
||||
Rendering --> Rendering : SSE message → renderDownloads()
|
||||
Rendering --> Rendering : SSE message triggers renderDownloads()
|
||||
Rendering --> Rendering : Theme change
|
||||
|
||||
state SSEConnection {
|
||||
@@ -1368,13 +1368,13 @@ stateDiagram-v2
|
||||
|
||||
state Disabled {
|
||||
[*] --> OnDemand
|
||||
OnDemand : No background timer.\nData fetched when dashboard\nrequest finds empty cache.
|
||||
OnDemand : No background timer. Data fetched when dashboard request finds empty cache.
|
||||
}
|
||||
|
||||
Disabled --> Polling : dashboard triggers pollAllServices()
|
||||
Polling --> Disabled : Poll complete (on-demand)
|
||||
|
||||
Idle --> Polling : setInterval fires\nor immediate first poll
|
||||
Idle --> Polling : setInterval fires or immediate first poll
|
||||
|
||||
state Polling {
|
||||
[*] --> Locked
|
||||
@@ -1382,7 +1382,7 @@ stateDiagram-v2
|
||||
Locked --> Fetching
|
||||
Fetching --> Storing : All promises resolved
|
||||
Fetching --> HandleError : Per-service error (caught)
|
||||
Storing --> Notifying : Cache updated\nTTL = POLL_INTERVAL × 3
|
||||
Storing --> Notifying : Cache updated, TTL = POLL_INTERVAL x 3
|
||||
Notifying : Notify SSE subscribers
|
||||
Notifying --> Done
|
||||
Done : polling = false
|
||||
@@ -1401,7 +1401,7 @@ stateDiagram-v2
|
||||
[*] --> Skip
|
||||
Skip : polling === true, skip cycle
|
||||
}
|
||||
Idle --> ConcurrentSkip : Interval fires while\nprevious still running
|
||||
Idle --> ConcurrentSkip : Interval fires while previous still running
|
||||
ConcurrentSkip --> Idle : Log skip
|
||||
```
|
||||
|
||||
@@ -1469,3 +1469,6 @@ flowchart TD
|
||||
style AF fill:#d4edda
|
||||
style AG fill:#f8d7da
|
||||
```
|
||||
|
||||
|
||||
|
||||
|
||||
Generated
+499
-5
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "sofarr",
|
||||
"version": "0.1.5",
|
||||
"version": "1.1.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "sofarr",
|
||||
"version": "0.1.5",
|
||||
"version": "1.1.2",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"axios": "^1.6.0",
|
||||
@@ -14,7 +14,8 @@
|
||||
"dotenv": "^16.3.1",
|
||||
"express": "^4.18.2",
|
||||
"express-rate-limit": "^7.0.0",
|
||||
"helmet": "^7.0.0"
|
||||
"helmet": "^7.0.0",
|
||||
"jsdom": "^29.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitest/coverage-v8": "^4.1.6",
|
||||
@@ -25,6 +26,53 @@
|
||||
"vitest": "^4.1.6"
|
||||
}
|
||||
},
|
||||
"node_modules/@asamuzakjp/css-color": {
|
||||
"version": "5.1.11",
|
||||
"resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz",
|
||||
"integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@asamuzakjp/generational-cache": "^1.0.1",
|
||||
"@csstools/css-calc": "^3.2.0",
|
||||
"@csstools/css-color-parser": "^4.1.0",
|
||||
"@csstools/css-parser-algorithms": "^4.0.0",
|
||||
"@csstools/css-tokenizer": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@asamuzakjp/dom-selector": {
|
||||
"version": "7.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz",
|
||||
"integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@asamuzakjp/generational-cache": "^1.0.1",
|
||||
"@asamuzakjp/nwsapi": "^2.3.9",
|
||||
"bidi-js": "^1.0.3",
|
||||
"css-tree": "^3.2.1",
|
||||
"is-potential-custom-element-name": "^1.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@asamuzakjp/generational-cache": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz",
|
||||
"integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@asamuzakjp/nwsapi": {
|
||||
"version": "2.3.9",
|
||||
"resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz",
|
||||
"integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@babel/helper-string-parser": {
|
||||
"version": "7.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
|
||||
@@ -95,6 +143,152 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@bramus/specificity": {
|
||||
"version": "2.4.2",
|
||||
"resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz",
|
||||
"integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"css-tree": "^3.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"specificity": "bin/cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/@csstools/color-helpers": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz",
|
||||
"integrity": "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/csstools"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/csstools"
|
||||
}
|
||||
],
|
||||
"license": "MIT-0",
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@csstools/css-calc": {
|
||||
"version": "3.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.2.1.tgz",
|
||||
"integrity": "sha512-DtdHlgXh5ZkA43cwBcAm+huzgJiwx3ZTWVjBs94kwz2xKqSimDA3lBgCjphYgwgVUMWatSM0pDd8TILB1yrVVg==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/csstools"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/csstools"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@csstools/css-parser-algorithms": "^4.0.0",
|
||||
"@csstools/css-tokenizer": "^4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@csstools/css-color-parser": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.1.tgz",
|
||||
"integrity": "sha512-eZ5XOtyhK+mggRafYUWzA0tvaYOFgdY8AkgQiCJF9qNAePnUo/zmsqqYubBBb3sQ8uNUaSKTY9s9klfRaAXL0g==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/csstools"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/csstools"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@csstools/color-helpers": "^6.0.2",
|
||||
"@csstools/css-calc": "^3.2.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@csstools/css-parser-algorithms": "^4.0.0",
|
||||
"@csstools/css-tokenizer": "^4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@csstools/css-parser-algorithms": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz",
|
||||
"integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/csstools"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/csstools"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@csstools/css-tokenizer": "^4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@csstools/css-syntax-patches-for-csstree": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.4.tgz",
|
||||
"integrity": "sha512-wgsqt92b7C7tQhIdPNxj0n9zuUbQlvAuI1exyzeNrOKOi62SD7ren8zqszmpVREjAOqg8cD2FqYhQfAuKjk4sw==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/csstools"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/csstools"
|
||||
}
|
||||
],
|
||||
"license": "MIT-0",
|
||||
"peerDependencies": {
|
||||
"css-tree": "^3.2.1"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"css-tree": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@csstools/css-tokenizer": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz",
|
||||
"integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/csstools"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/csstools"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/core": {
|
||||
"version": "1.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
|
||||
@@ -129,6 +323,23 @@
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@exodus/bytes": {
|
||||
"version": "1.15.0",
|
||||
"resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.0.tgz",
|
||||
"integrity": "sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@noble/hashes": "^1.8.0 || ^2.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@noble/hashes": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@jridgewell/resolve-uri": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
|
||||
@@ -198,7 +409,7 @@
|
||||
"version": "1.8.0",
|
||||
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz",
|
||||
"integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^14.21.3 || >=16"
|
||||
@@ -854,6 +1065,15 @@
|
||||
"node": "18 || 20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/bidi-js": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz",
|
||||
"integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"require-from-string": "^2.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/binary-extensions": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
|
||||
@@ -1168,6 +1388,32 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/css-tree": {
|
||||
"version": "3.2.1",
|
||||
"resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz",
|
||||
"integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mdn-data": "2.27.1",
|
||||
"source-map-js": "^1.2.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/data-urls": {
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz",
|
||||
"integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"whatwg-mimetype": "^5.0.0",
|
||||
"whatwg-url": "^16.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/date-fns": {
|
||||
"version": "2.30.0",
|
||||
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.30.0.tgz",
|
||||
@@ -1194,6 +1440,12 @@
|
||||
"ms": "2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/decimal.js": {
|
||||
"version": "10.6.0",
|
||||
"resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
|
||||
"integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/delayed-stream": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
|
||||
@@ -1291,6 +1543,18 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/entities": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz",
|
||||
"integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==",
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/es-define-property": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
|
||||
@@ -1713,6 +1977,18 @@
|
||||
"node": ">=16.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/html-encoding-sniffer": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz",
|
||||
"integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@exodus/bytes": "^1.6.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/html-escaper": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
|
||||
@@ -1873,6 +2149,12 @@
|
||||
"node": ">=0.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/is-potential-custom-element-name": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
|
||||
"integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/istanbul-lib-coverage": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz",
|
||||
@@ -1932,6 +2214,46 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/jsdom": {
|
||||
"version": "29.1.1",
|
||||
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz",
|
||||
"integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@asamuzakjp/css-color": "^5.1.11",
|
||||
"@asamuzakjp/dom-selector": "^7.1.1",
|
||||
"@bramus/specificity": "^2.4.2",
|
||||
"@csstools/css-syntax-patches-for-csstree": "^1.1.3",
|
||||
"@exodus/bytes": "^1.15.0",
|
||||
"css-tree": "^3.2.1",
|
||||
"data-urls": "^7.0.0",
|
||||
"decimal.js": "^10.6.0",
|
||||
"html-encoding-sniffer": "^6.0.0",
|
||||
"is-potential-custom-element-name": "^1.0.1",
|
||||
"lru-cache": "^11.3.5",
|
||||
"parse5": "^8.0.1",
|
||||
"saxes": "^6.0.0",
|
||||
"symbol-tree": "^3.2.4",
|
||||
"tough-cookie": "^6.0.1",
|
||||
"undici": "^7.25.0",
|
||||
"w3c-xmlserializer": "^5.0.0",
|
||||
"webidl-conversions": "^8.0.1",
|
||||
"whatwg-mimetype": "^5.0.0",
|
||||
"whatwg-url": "^16.0.1",
|
||||
"xml-name-validator": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.13.0 || >=24.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"canvas": "^3.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"canvas": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/json-stringify-safe": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz",
|
||||
@@ -2207,6 +2529,15 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lru-cache": {
|
||||
"version": "11.3.6",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.6.tgz",
|
||||
"integrity": "sha512-Gf/KoL3C/MlI7Bt0PGI9I+TeTC/I6r/csU58N4BSNc4lppLBeKsOdFYkK+dX0ABDUMJNfCHTyPpzwwO21Awd3A==",
|
||||
"license": "BlueOak-1.0.0",
|
||||
"engines": {
|
||||
"node": "20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/magic-string": {
|
||||
"version": "0.30.21",
|
||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
||||
@@ -2254,6 +2585,12 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/mdn-data": {
|
||||
"version": "2.27.1",
|
||||
"resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz",
|
||||
"integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==",
|
||||
"license": "CC0-1.0"
|
||||
},
|
||||
"node_modules/media-typer": {
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
|
||||
@@ -2518,6 +2855,18 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/parse5": {
|
||||
"version": "8.0.1",
|
||||
"resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz",
|
||||
"integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"entities": "^8.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/inikulin/parse5?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/parseurl": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
|
||||
@@ -2628,6 +2977,15 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/punycode": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
||||
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/qs": {
|
||||
"version": "6.15.2",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz",
|
||||
@@ -2690,6 +3048,15 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/require-from-string": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
|
||||
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/rolldown": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.1.tgz",
|
||||
@@ -2760,6 +3127,18 @@
|
||||
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/saxes": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz",
|
||||
"integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"xmlchars": "^2.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=v12.22.7"
|
||||
}
|
||||
},
|
||||
"node_modules/semver": {
|
||||
"version": "7.8.0",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz",
|
||||
@@ -2933,7 +3312,6 @@
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
@@ -3103,6 +3481,12 @@
|
||||
"url": "https://github.com/chalk/supports-color?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/symbol-tree": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
|
||||
"integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tinybench": {
|
||||
"version": "2.9.0",
|
||||
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
|
||||
@@ -3178,6 +3562,24 @@
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tldts": {
|
||||
"version": "7.0.30",
|
||||
"resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.30.tgz",
|
||||
"integrity": "sha512-ELrFxuqsDdHUwoh0XxDbxuLD3Wnz49Z57IFvTtvWy1hJdcMZjXLIuonjilCiWHlT2GbE4Wlv1wKVTzDFnXH1aw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tldts-core": "^7.0.30"
|
||||
},
|
||||
"bin": {
|
||||
"tldts": "bin/cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/tldts-core": {
|
||||
"version": "7.0.30",
|
||||
"resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.30.tgz",
|
||||
"integrity": "sha512-uiHN8PIB1VmWyS98eZYja4xzlYqeFZVjb4OuYlJQnZAuJhMw4PbKQOKgHKhBdJR3FE/t5mUQ1Kd80++B+qhD1Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/to-regex-range": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
|
||||
@@ -3210,6 +3612,30 @@
|
||||
"nodetouch": "bin/nodetouch.js"
|
||||
}
|
||||
},
|
||||
"node_modules/tough-cookie": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz",
|
||||
"integrity": "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"tldts": "^7.0.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
}
|
||||
},
|
||||
"node_modules/tr46": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz",
|
||||
"integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"punycode": "^2.3.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/tree-kill": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz",
|
||||
@@ -3247,6 +3673,15 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/undici": {
|
||||
"version": "7.25.0",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-7.25.0.tgz",
|
||||
"integrity": "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20.18.1"
|
||||
}
|
||||
},
|
||||
"node_modules/unpipe": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
|
||||
@@ -3468,6 +3903,50 @@
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/w3c-xmlserializer": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",
|
||||
"integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"xml-name-validator": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/webidl-conversions": {
|
||||
"version": "8.0.1",
|
||||
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz",
|
||||
"integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==",
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/whatwg-mimetype": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz",
|
||||
"integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/whatwg-url": {
|
||||
"version": "16.0.1",
|
||||
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz",
|
||||
"integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@exodus/bytes": "^1.11.0",
|
||||
"tr46": "^6.0.0",
|
||||
"webidl-conversions": "^8.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/why-is-node-running": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
|
||||
@@ -3510,6 +3989,21 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/xml-name-validator": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
|
||||
"integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/xmlchars": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz",
|
||||
"integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/y18n": {
|
||||
"version": "5.0.8",
|
||||
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
|
||||
|
||||
+3
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "sofarr",
|
||||
"version": "1.1.2",
|
||||
"version": "1.2.1",
|
||||
"description": "A personal media download dashboard that shows your downloads 'so far' while you relax on the sofa waiting for your *arr services to finish",
|
||||
"main": "server/index.js",
|
||||
"scripts": {
|
||||
@@ -21,7 +21,8 @@
|
||||
"dotenv": "^16.3.1",
|
||||
"express": "^4.18.2",
|
||||
"express-rate-limit": "^7.0.0",
|
||||
"helmet": "^7.0.0"
|
||||
"helmet": "^7.0.0",
|
||||
"jsdom": "^29.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitest/coverage-v8": "^4.1.6",
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
Contact: mailto:gordon@i3omb.com
|
||||
Expires: 2026-12-31T23:59:00.000Z
|
||||
Preferred-Languages: en
|
||||
Canonical: https://git.i3omb.com/Gandalf/sofarr
|
||||
Policy: https://git.i3omb.com/Gandalf/sofarr/src/branch/main/SECURITY.md
|
||||
+13
-1
@@ -27,13 +27,25 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
initThemeSwitcher();
|
||||
initTabs();
|
||||
initHistoryControls();
|
||||
|
||||
loadAppVersion();
|
||||
|
||||
document.getElementById('login-form').addEventListener('submit', handleLogin);
|
||||
document.getElementById('logout-btn').addEventListener('click', handleLogout);
|
||||
document.getElementById('show-all-toggle').addEventListener('change', handleShowAllToggle);
|
||||
document.getElementById('status-btn').addEventListener('click', toggleStatusPanel);
|
||||
});
|
||||
|
||||
function loadAppVersion() {
|
||||
fetch('/health')
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.version) {
|
||||
document.getElementById('app-version').textContent = `sofarr v${data.version}`;
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
function initThemeSwitcher() {
|
||||
const saved = localStorage.getItem('sofarr-theme') || 'light';
|
||||
document.querySelectorAll('.theme-btn').forEach(btn => {
|
||||
|
||||
+2
-1
@@ -46,7 +46,7 @@
|
||||
<!-- Dashboard -->
|
||||
<div id="dashboard-container" class="dashboard-container" style="display: none;">
|
||||
<header class="app-header">
|
||||
<h1>sofarr</h1>
|
||||
<h1><a href="https://git.i3omb.com/Gandalf/sofarr" target="_blank" rel="noopener noreferrer" class="title-link">sofarr</a></h1>
|
||||
<div class="header-controls">
|
||||
<div class="theme-switcher">
|
||||
<button class="theme-btn active" data-theme="light">Light</button>
|
||||
@@ -112,6 +112,7 @@
|
||||
|
||||
<footer class="app-footer">
|
||||
<p>Ensure your media is tagged with your username in Sonarr/Radarr to match downloads to users.</p>
|
||||
<p class="app-version" id="app-version"></p>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -866,6 +866,22 @@ body {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.app-version {
|
||||
font-size: 0.72rem;
|
||||
opacity: 0.5;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.title-link {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.title-link:hover {
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 3px;
|
||||
}
|
||||
|
||||
/* ===== Login ===== */
|
||||
.login-container {
|
||||
display: flex;
|
||||
|
||||
+33
-1
@@ -1,3 +1,4 @@
|
||||
// Copyright (c) 2025 Gordon Bolton. MIT License.
|
||||
const express = require('express');
|
||||
const path = require('path');
|
||||
const cookieParser = require('cookie-parser');
|
||||
@@ -8,6 +9,7 @@ const fs = require('fs');
|
||||
const http = require('http');
|
||||
const https = require('https');
|
||||
require('dotenv').config();
|
||||
require('./utils/loadSecrets')();
|
||||
const { version } = require('../package.json');
|
||||
|
||||
// Setup logging with levels
|
||||
@@ -84,6 +86,7 @@ const historyRoutes = require('./routes/history');
|
||||
const authRoutes = require('./routes/auth');
|
||||
const verifyCsrf = require('./middleware/verifyCsrf');
|
||||
const { startPoller, POLL_INTERVAL, POLLING_ENABLED } = require('./utils/poller');
|
||||
const { validateInstanceUrl } = require('./utils/config');
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Startup environment validation
|
||||
@@ -94,11 +97,16 @@ if (!cookieSecret && process.env.NODE_ENV === 'production') {
|
||||
process.exit(1);
|
||||
} else if (!cookieSecret) {
|
||||
console.warn('[Security] COOKIE_SECRET not set — unsigned cookies (dev only)');
|
||||
} else if (cookieSecret.length < 32) {
|
||||
console.warn('[Security] COOKIE_SECRET is shorter than 32 characters — use openssl rand -hex 32');
|
||||
}
|
||||
if (!process.env.EMBY_URL && process.env.NODE_ENV === 'production') {
|
||||
console.error('[Config] EMBY_URL is required');
|
||||
process.exit(1);
|
||||
}
|
||||
if (process.env.EMBY_URL) {
|
||||
validateInstanceUrl(process.env.EMBY_URL, 'EMBY_URL');
|
||||
}
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 3001;
|
||||
@@ -189,7 +197,7 @@ app.use(express.json({ limit: '64kb' })); // prevent oversized JSON payloads
|
||||
// Used by Docker HEALTHCHECK and orchestrators.
|
||||
// ---------------------------------------------------------------------------
|
||||
app.get('/health', (req, res) => {
|
||||
res.json({ status: 'ok', uptime: process.uptime() });
|
||||
res.json({ status: 'ok', uptime: process.uptime(), version });
|
||||
});
|
||||
|
||||
app.get('/ready', (req, res) => {
|
||||
@@ -318,3 +326,27 @@ server.listen(PORT, () => {
|
||||
console.log(`=================================`);
|
||||
startPoller();
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Graceful shutdown — handle SIGTERM (Docker stop) and SIGINT (Ctrl+C)
|
||||
// Stop the poller, close the HTTP server (stops accepting new connections),
|
||||
// then let Node drain existing keep-alive connections and exit cleanly.
|
||||
// ---------------------------------------------------------------------------
|
||||
const { stopPoller } = require('./utils/poller');
|
||||
|
||||
function shutdown(signal) {
|
||||
console.log(`[Server] ${signal} received — shutting down gracefully`);
|
||||
stopPoller();
|
||||
server.close(() => {
|
||||
console.log('[Server] HTTP server closed');
|
||||
process.exit(0);
|
||||
});
|
||||
// Force exit after 10 s if connections don't drain
|
||||
setTimeout(() => {
|
||||
console.error('[Server] Forced exit after 10 s timeout');
|
||||
process.exit(1);
|
||||
}, 10000).unref();
|
||||
}
|
||||
|
||||
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
||||
process.on('SIGINT', () => shutdown('SIGINT'));
|
||||
|
||||
+31
-5
@@ -1,5 +1,28 @@
|
||||
// Copyright (c) 2025 Gordon Bolton. MIT License.
|
||||
const { logToFile } = require('./logger');
|
||||
|
||||
// Validate that a configured service URL is well-formed and uses http(s).
|
||||
// Emits a warning (never throws) so a misconfigured instance degrades
|
||||
// gracefully rather than crashing the whole server.
|
||||
function validateInstanceUrl(url, instanceId) {
|
||||
if (!url || typeof url !== 'string') {
|
||||
logToFile(`[Config] WARNING: instance "${instanceId}" has no URL configured`);
|
||||
return false;
|
||||
}
|
||||
let parsed;
|
||||
try {
|
||||
parsed = new URL(url);
|
||||
} catch {
|
||||
logToFile(`[Config] WARNING: instance "${instanceId}" has an invalid URL: "${url}"`);
|
||||
return false;
|
||||
}
|
||||
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
||||
logToFile(`[Config] WARNING: instance "${instanceId}" URL must use http or https, got "${parsed.protocol}"`);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function parseInstances(envVar, legacyUrl, legacyKey, legacyUsername, legacyPassword) {
|
||||
// Try to parse JSON array format first
|
||||
if (envVar) {
|
||||
@@ -9,10 +32,11 @@ function parseInstances(envVar, legacyUrl, legacyKey, legacyUsername, legacyPass
|
||||
const instances = JSON.parse(cleaned);
|
||||
if (Array.isArray(instances) && instances.length > 0) {
|
||||
logToFile(`[Config] Parsed ${instances.length} instances from JSON array`);
|
||||
return instances.map((inst, idx) => ({
|
||||
...inst,
|
||||
id: inst.name || `instance-${idx + 1}`
|
||||
}));
|
||||
return instances.map((inst, idx) => {
|
||||
const id = inst.name || `instance-${idx + 1}`;
|
||||
validateInstanceUrl(inst.url, id);
|
||||
return { ...inst, id };
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
logToFile(`[Config] Failed to parse JSON array: ${err.message}`);
|
||||
@@ -22,6 +46,7 @@ function parseInstances(envVar, legacyUrl, legacyKey, legacyUsername, legacyPass
|
||||
// Fall back to legacy single-instance format
|
||||
if (legacyUrl && legacyKey) {
|
||||
logToFile(`[Config] Using legacy single-instance format`);
|
||||
validateInstanceUrl(legacyUrl, 'default');
|
||||
return [{
|
||||
id: 'default',
|
||||
name: 'Default',
|
||||
@@ -74,5 +99,6 @@ module.exports = {
|
||||
getSonarrInstances,
|
||||
getRadarrInstances,
|
||||
getQbittorrentInstances,
|
||||
parseInstances
|
||||
parseInstances,
|
||||
validateInstanceUrl
|
||||
};
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
// Copyright (c) 2025 Gordon Bolton. MIT License.
|
||||
//
|
||||
// Docker secrets support: if an environment variable named FOO_FILE is set,
|
||||
// read its contents from the file at that path and expose it as FOO.
|
||||
// This follows the standard *_FILE convention used by official Docker images.
|
||||
//
|
||||
// Supported secrets:
|
||||
// COOKIE_SECRET_FILE → COOKIE_SECRET
|
||||
// EMBY_API_KEY_FILE → EMBY_API_KEY
|
||||
// SABNZBD_API_KEY_FILE → SABNZBD_API_KEY (legacy single-instance)
|
||||
// SONARR_API_KEY_FILE → SONARR_API_KEY (legacy single-instance)
|
||||
// RADARR_API_KEY_FILE → RADARR_API_KEY (legacy single-instance)
|
||||
// QBITTORRENT_PASSWORD_FILE → QBITTORRENT_PASSWORD (legacy single-instance)
|
||||
//
|
||||
// For multi-instance JSON arrays the secret values must be embedded in the
|
||||
// JSON string itself; file-based loading is for the legacy single-key format.
|
||||
|
||||
const fs = require('fs');
|
||||
|
||||
const SECRET_MAPPINGS = [
|
||||
'COOKIE_SECRET',
|
||||
'EMBY_API_KEY',
|
||||
'SABNZBD_API_KEY',
|
||||
'SONARR_API_KEY',
|
||||
'RADARR_API_KEY',
|
||||
'QBITTORRENT_PASSWORD',
|
||||
];
|
||||
|
||||
function loadSecrets() {
|
||||
for (const key of SECRET_MAPPINGS) {
|
||||
const fileEnv = `${key}_FILE`;
|
||||
const filePath = process.env[fileEnv];
|
||||
if (!filePath) continue;
|
||||
if (process.env[key]) {
|
||||
console.warn(`[Secrets] Both ${key} and ${fileEnv} are set — ${fileEnv} takes precedence`);
|
||||
}
|
||||
try {
|
||||
const value = fs.readFileSync(filePath, 'utf8').trim();
|
||||
if (!value) {
|
||||
console.warn(`[Secrets] ${fileEnv} points to an empty file: ${filePath}`);
|
||||
continue;
|
||||
}
|
||||
process.env[key] = value;
|
||||
console.log(`[Secrets] Loaded ${key} from ${fileEnv}`);
|
||||
} catch (err) {
|
||||
console.error(`[Secrets] Failed to read ${fileEnv} (${filePath}): ${err.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = loadSecrets;
|
||||
@@ -1,3 +1,4 @@
|
||||
// Copyright (c) 2025 Gordon Bolton. MIT License.
|
||||
const axios = require('axios');
|
||||
const cache = require('./cache');
|
||||
const { getTorrents } = require('./qbittorrent');
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// Copyright (c) 2025 Gordon Bolton. MIT License.
|
||||
// Query-param secrets (SABnzbd apikey, generic token/password params)
|
||||
const QUERY_SECRET_PATTERN = /([?&](?:apikey|token|password|api_key|key|secret)=)[^&\s#]*/gi;
|
||||
// HTTP auth header values (X-Api-Key, X-MediaBrowser-Token, Authorization, X-Emby-Authorization)
|
||||
@@ -7,13 +8,18 @@ const HEADER_PATTERN = /(?:x-api-key|x-mediabrowser-token|x-emby-authorization|a
|
||||
const BEARER_PATTERN = /bearer\s+[A-Za-z0-9\-._~+/]+=*/gi;
|
||||
// Basic auth credentials in URLs (http://user:pass@host)
|
||||
const BASIC_AUTH_URL_PATTERN = /\/\/[^:@/\s]+:[^@/\s]+@/gi;
|
||||
// Redact only the host:port authority portion of URLs, preserving path/query so
|
||||
// other patterns (QUERY_SECRET_PATTERN etc.) can still act on them.
|
||||
// Negative lookahead skips URLs already handled by BASIC_AUTH_URL_PATTERN.
|
||||
const HOST_PATTERN = /(https?:\/\/)(?!\[REDACTED\]@)([^\s/?#]+)/gi;
|
||||
|
||||
function sanitizeError(err) {
|
||||
let msg = (err && 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]@');
|
||||
msg = msg.replace(BASIC_AUTH_URL_PATTERN, '//[REDACTED]@'); // must run before HOST_PATTERN
|
||||
msg = msg.replace(HOST_PATTERN, '$1[HOST]');
|
||||
// Never leak stack traces to API responses
|
||||
return msg;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user