diff --git a/.dockerignore b/.dockerignore index c68e5f2..7a09ebc 100644 --- a/.dockerignore +++ b/.dockerignore @@ -6,6 +6,7 @@ node_modules/ .gitignore .DS_Store *.log +**/*.log client/ dist/ build/ @@ -13,3 +14,4 @@ README.md .dockerignore Dockerfile .gitea/ +docs/ \ No newline at end of file diff --git a/.env.example b/.env.example index 4120a26..759ae13 100644 --- a/.env.example +++ b/.env.example @@ -2,6 +2,10 @@ 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 + # Background polling interval in ms (default: 5000) # Set to 0 or "off" to disable and fetch on-demand instead # POLL_INTERVAL=5000 diff --git a/.env.sample b/.env.sample index a302aff..ec72b95 100644 --- a/.env.sample +++ b/.env.sample @@ -14,6 +14,11 @@ PORT=3001 # - silent: No logging LOG_LEVEL=info +# Cookie signing secret for tamper-proof session cookies +# Required in production (server exits on startup if unset). +# Generate with: openssl rand -hex 32 +COOKIE_SECRET=your-cookie-secret-here + # Background polling interval in milliseconds (default: 5000) # sofarr polls all services in the background and caches results so # dashboard requests are near-instant. diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..d3c7579 --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -0,0 +1,26 @@ +name: CI + +on: + push: + branches: ["**"] + pull_request: + branches: ["**"] + +jobs: + audit: + name: npm audit + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: "18" + cache: "npm" + + - name: Install dependencies + run: npm ci + + - name: Run security audit + run: npm audit --audit-level=moderate diff --git a/.gitignore b/.gitignore index fdd8c5a..606e68a 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ dist/ build/ .DS_Store *.log +**/*.log diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index e35a2c4..8aca5c6 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -107,6 +107,8 @@ sofarr/ │ │ ├── sabnzbd.js # Proxy routes to SABnzbd API │ │ ├── sonarr.js # Proxy routes to Sonarr API │ │ └── radarr.js # Proxy routes to Radarr API +│ ├── middleware/ +│ │ └── requireAuth.js # httpOnly cookie auth middleware │ └── utils/ │ ├── cache.js # MemoryCache class (Map + TTL + stats) │ ├── config.js # Multi-instance service configuration parser @@ -117,6 +119,9 @@ sofarr/ │ ├── 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 ├── Dockerfile # Production container image ├── docker-compose.yaml # Example compose deployment @@ -135,7 +140,7 @@ Responsibilities: - Load environment variables via `dotenv` - Configure structured logging with level filtering (`LOG_LEVEL`) - Redirect `console.*` to both stdout and `server.log` -- Mount Express middleware (CORS, cookie-parser, JSON, static files) +- Mount Express middleware (cookie-parser, JSON, static files) - Mount route modules under `/api/*` - Start the background poller @@ -143,12 +148,14 @@ Responsibilities: | Module | Mount Point | Auth Required | Purpose | |--------|------------|---------------|---------| -| `auth.js` | `/api/auth` | No | Login, session check, logout | -| `dashboard.js` | `/api/dashboard` | Yes (cookie) | Aggregated download data, status | -| `emby.js` | `/api/emby` | No | Proxy to Emby API | -| `sabnzbd.js` | `/api/sabnzbd` | No | Proxy to SABnzbd API | -| `sonarr.js` | `/api/sonarr` | No | Proxy to Sonarr API | -| `radarr.js` | `/api/radarr` | No | Proxy to Radarr API | +| `auth.js` | `/api/auth` | No (public) | Login, session check, logout | +| `dashboard.js` | `/api/dashboard` | Yes (`requireAuth`) | Aggregated download data, status | +| `emby.js` | `/api/emby` | Yes (`requireAuth`) | Proxy to Emby API | +| `sabnzbd.js` | `/api/sabnzbd` | Yes (`requireAuth`) | Proxy to SABnzbd API | +| `sonarr.js` | `/api/sonarr` | Yes (`requireAuth`) | Proxy to Sonarr API | +| `radarr.js` | `/api/radarr` | Yes (`requireAuth`) | Proxy to Radarr API | + +`requireAuth` (`server/middleware/requireAuth.js`) reads the `emby_user` httpOnly cookie and attaches the parsed user to `req.user`. Returns `401` if the cookie is absent or malformed. > **Note:** The proxy routes (`emby`, `sabnzbd`, `sonarr`, `radarr`) use legacy single-instance env vars and are largely unused by the dashboard — they exist for direct API access. The dashboard reads from the poller cache. @@ -208,7 +215,7 @@ When a user requests `/api/dashboard/user-downloads`: 1. User submits credentials via the login form 2. Backend calls Emby `POST /Users/authenticatebyname` 3. On success, fetches full user profile to determine admin status -4. Sets an `httpOnly` cookie (`emby_user`) containing: `{ id, name, isAdmin, token }` +4. Sets an `httpOnly` cookie (`emby_user`) containing: `{ id, name, isAdmin }` — the Emby `AccessToken` is intentionally **not** stored in the cookie 5. Cookie expires after 24 hours 6. All subsequent dashboard requests read this cookie for identity @@ -253,6 +260,7 @@ Users are matched to downloads via tags in Sonarr/Radarr: | `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` | Full Emby user list (60s TTL) | ### TTL Strategy @@ -314,7 +322,9 @@ Each matched download produces an object with: | `eta` | string | Estimated time remaining | | `seriesName` / `movieName` | string | Friendly media title | | `episodeInfo` / `movieInfo` | object | Full *arr queue/history record | -| `userTag` | string | Matched user tag | +| `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 | @@ -346,7 +356,7 @@ Authenticate a user via Emby. { "success": false, "error": "Invalid username or password" } ``` -**Side Effect:** Sets `emby_user` httpOnly cookie (24h TTL). +**Side Effect:** Sets `emby_user` httpOnly cookie (24h TTL, `httpOnly`, `secure` in production, `sameSite: strict`). Cookie payload: `{ id, name, isAdmin }` — Emby `AccessToken` is not stored. --- @@ -447,7 +457,7 @@ Admin-only per-user download counts (fetches live from APIs, not cached). ## 10. Frontend Architecture -The frontend is a **vanilla JavaScript SPA** with no build step. All logic resides in `app.js` (754 lines), styled by `style.css`, and structured by `index.html`. +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 @@ -473,7 +483,7 @@ The frontend is a **vanilla JavaScript SPA** with no build step. All logic resid | `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 | +| `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) | @@ -489,6 +499,15 @@ Three CSS themes via `data-theme` attribute on ``: 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. diff --git a/docs/diagrams/activity-matching.puml b/docs/diagrams/activity-matching.puml index c74b956..b9d7718 100644 --- a/docs/diagrams/activity-matching.puml +++ b/docs/diagrams/activity-matching.puml @@ -22,6 +22,12 @@ end note :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" { @@ -32,13 +38,20 @@ partition "Process SABnzbd Queue Slots" { if (Title matches Sonarr **queue** record?) then (yes) :series = seriesMap.get(match.seriesId)\n|| match.series; if (series exists?) then (yes) - :userTag = extractUserTag(series.tags, sonarrTagMap); - if (showAll OR tagMatchesUser?) 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 @@ -46,13 +59,19 @@ partition "Process SABnzbd Queue Slots" { if (Title matches Radarr **queue** record?) then (yes) :movie = moviesMap.get(match.movieId)\n|| match.movie; if (movie exists?) then (yes) - :userTag = extractUserTag(movie.tags, radarrTagMap); - if (showAll OR tagMatchesUser?) 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 @@ -67,16 +86,20 @@ partition "Process SABnzbd History Slots" { if (Title matches Sonarr **history** record?) then (yes) :series = seriesMap.get(match.seriesId)\n|| match.series; if (series found?) then (yes) - :Check user tag, build download\n(type=series, with completedAt); - :Push to **userDownloads** if tag matches; + :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) - :Check user tag, build download\n(type=movie, with completedAt); - :Push to **userDownloads** if tag matches; + :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) @@ -119,10 +142,15 @@ legend right (bidirectional substring, case-insensitive): ""rTitle.includes(dlTitle) || dlTitle.includes(rTitle)"" - **Tag Matching Logic**: + **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 diff --git a/docs/diagrams/class-data.puml b/docs/diagrams/class-data.puml index ed92cf4..e3631d1 100644 --- a/docs/diagrams/class-data.puml +++ b/docs/diagrams/class-data.puml @@ -153,7 +153,9 @@ package "sofarr Internal Models" { + movieName : string | null + episodeInfo : object | null + movieInfo : object | null - + userTag : string + + allTags : string[] + + matchedUserTag : string | null + + tagBadges : TagBadge[] | undefined + importIssues : string[] | null + downloadPath : string | null + targetPath : string | null @@ -170,6 +172,11 @@ package "sofarr Internal Models" { + completedAt : string } + class "TagBadge" as tagbadge <> { + + label : string + + matchedUser : string | null + } + class "API Response\n/user-downloads" as apir { + user : string + isAdmin : boolean @@ -201,7 +208,7 @@ package "sofarr Internal Models" { + id : string + name : string + isAdmin : boolean - + token : string + ' Note: Emby AccessToken intentionally excluded } apir *-- dl @@ -215,7 +222,9 @@ sabh ..> dl : matched &\ntransformed qbt ..> dl : mapTorrentToDownload() ss ..> dl : coverArt, seriesName,\npath, tags rm ..> dl : coverArt, movieName,\npath, tags -tag ..> dl : userTag resolution +tag ..> dl : allTags / matchedUserTag eu ..> cookie : login creates +eu ..> tagbadge : buildTagBadges() +dl *-- tagbadge : tagBadges[] @enduml diff --git a/docs/diagrams/class-server.puml b/docs/diagrams/class-server.puml index 7e7042b..fb9719a 100644 --- a/docs/diagrams/class-server.puml +++ b/docs/diagrams/class-server.puml @@ -33,7 +33,10 @@ package "server/routes" { + GET /status -- - getCoverArt(item) : string|null - - extractUserTag(tags, tagMap) : string|null + - extractAllTags(tags, tagMap) : string[] + - extractUserTag(tags, tagMap, username) : string|null + - buildTagBadges(allTags, embyUserMap) : TagBadge[] + - getEmbyUsers() : Promise - sanitizeTagLabel(input) : string - tagMatchesUser(tag, username) : boolean - getImportIssues(record) : string[]|null @@ -69,6 +72,16 @@ package "server/routes" { } } +package "server/middleware" { + class "requireAuth.js" as requireauth <> { + + requireAuth(req, res, next) : void + -- + Reads emby_user cookie + Attaches parsed user to req.user + Returns 401 if absent/invalid + } +} + package "server/utils" { class "MemoryCache" as cache { - store : Map @@ -158,6 +171,11 @@ package "server/utils" { + logToFile(message) : void } + class "TagBadge" as tb <> { + + label : string + + matchedUser : string | null + } + class "ClientInfo" as ci <> { + user : string + refreshRateMs : number @@ -172,6 +190,12 @@ ep --> emby_r ep --> sab_r ep --> sonarr_r ep --> radarr_r + +dashboard --> requireauth : uses +emby_r --> requireauth : uses +sab_r --> requireauth : uses +sonarr_r --> requireauth : uses +radarr_r --> requireauth : uses ep --> poller : startPoller() dashboard --> cache : read/write diff --git a/docs/diagrams/component.puml b/docs/diagrams/component.puml index 61f5191..7567ad4 100644 --- a/docs/diagrams/component.puml +++ b/docs/diagrams/component.puml @@ -16,10 +16,10 @@ package "Browser" as browser { package "Express Server" as server { package "Middleware" { - [CORS] as cors [cookie-parser] as cp [express.json] as ej [express.static] as es + [requireAuth.js] as requireauth } package "Routes" as routes { @@ -41,7 +41,6 @@ package "Express Server" as server { [index.js\nEntry Point] as entry - entry --> cors entry --> cp entry --> ej entry --> es @@ -51,6 +50,12 @@ package "Express Server" as server { entry --> sab_route entry --> sonarr_route entry --> radarr_route + + emby_route --> requireauth + sab_route --> requireauth + sonarr_route --> requireauth + radarr_route --> requireauth + dashboard --> requireauth entry --> poller : startPoller() dashboard --> cache : read poll:* keys @@ -76,7 +81,7 @@ cloud "External Services" as external { } auth --> emby : authenticate\nuser profile -dashboard ..> emby : /user-summary\n(live fetch) +dashboard --> emby : GET /Users\n(user-summary + tag badge classification) emby_route --> emby sab_route --> sab sonarr_route --> sonarr diff --git a/docs/diagrams/seq-auth.puml b/docs/diagrams/seq-auth.puml index f81c644..e559c95 100644 --- a/docs/diagrams/seq-auth.puml +++ b/docs/diagrams/seq-auth.puml @@ -37,7 +37,7 @@ alt Valid credentials auth -> emby : GET /Users/{userId} emby --> auth : { Name, Policy: { IsAdministrator } } deactivate emby - auth -> auth : Set httpOnly cookie\nemby_user = { id, name, isAdmin, token }\n(24h TTL) + auth -> auth : Set httpOnly cookie\nemby_user = { id, name, isAdmin }\n(24h TTL, secure in prod, sameSite=strict)\nNote: AccessToken NOT stored auth --> browser : { success: true, user: { name, isAdmin } } browser -> browser : fadeOutLogin() browser -> browser : showSplash() diff --git a/docs/diagrams/seq-dashboard.puml b/docs/diagrams/seq-dashboard.puml index 98864dc..34f1772 100644 --- a/docs/diagrams/seq-dashboard.puml +++ b/docs/diagrams/seq-dashboard.puml @@ -50,19 +50,28 @@ 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 : Resolve series/movie\n→ extract user tag\n→ filter by username + 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 history - dashboard -> dashboard : Match title vs Radarr history - dashboard -> dashboard : Resolve via seriesMap/moviesMap\n→ extract user tag\n→ filter + dashboard -> dashboard : Match title vs Sonarr/Radarr history + dashboard -> dashboard : Same tag extraction + inclusion logic end end @@ -72,14 +81,20 @@ group qBittorrent Matching 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 with series/movie info + dashboard -> dashboard : mapTorrentToDownload()\n→ enrich: allTags, matchedUserTag, tagBadges end end dashboard --> browser : { user, isAdmin,\ndownloads: [...] } deactivate dashboard -browser -> browser : renderDownloads()\n(diff-based update) +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 diff --git a/package-lock.json b/package-lock.json index 8192e97..63b366e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { - "name": "media-download-dashboard", - "version": "1.0.0", + "name": "sofarr", + "version": "0.1.4", "lockfileVersion": 2, "requires": true, "packages": { "": { - "name": "media-download-dashboard", - "version": "1.0.0", + "name": "sofarr", + "version": "0.1.4", "license": "MIT", "dependencies": { "axios": "^1.6.0", @@ -14,11 +14,12 @@ "cors": "^2.8.5", "dotenv": "^16.3.1", "express": "^4.18.2", - "node-cron": "^3.0.3" + "express-rate-limit": "^6.7.0", + "helmet": "^4.6.0" }, "devDependencies": { "concurrently": "^7.6.0", - "nodemon": "^2.0.22" + "nodemon": "^3.1.14" } }, "node_modules/@babel/runtime": { @@ -133,10 +134,13 @@ } }, "node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "engines": { + "node": "18 || 20 || >=22" + } }, "node_modules/binary-extensions": { "version": "2.3.0", @@ -174,13 +178,15 @@ } }, "node_modules/brace-expansion": { - "version": "1.1.14", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", - "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", "dev": true, "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" } }, "node_modules/braces": { @@ -325,12 +331,6 @@ "node": ">= 0.8" } }, - "node_modules/concat-map": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "dev": true - }, "node_modules/concurrently": { "version": "7.6.0", "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-7.6.0.tgz", @@ -623,6 +623,17 @@ "url": "https://opencollective.com/express" } }, + "node_modules/express-rate-limit": { + "version": "6.7.0", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-6.7.0.tgz", + "integrity": "sha512-vhwIdRoqcYB/72TK3tRZI+0ttS8Ytrk24GfmsxDXK9o9IhHNO5bXRiXQSExPQ4GbaE5tvIS7j1SGrxsuWs+sGA==", + "engines": { + "node": ">= 12.9.0" + }, + "peerDependencies": { + "express": "^4 || ^5" + } + }, "node_modules/fill-range": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", @@ -836,6 +847,14 @@ "node": ">= 0.4" } }, + "node_modules/helmet": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/helmet/-/helmet-4.6.0.tgz", + "integrity": "sha512-HVqALKZlR95ROkrnesdhbbZJFi/rIVSoNq6f3jA/9u6MIbTsPh3xZwihjeI5+DO/2sOV6HMHooXcEOuwskHpTg==", + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/http-errors": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", @@ -1038,15 +1057,18 @@ } }, "node_modules/minimatch": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", - "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", "dev": true, "dependencies": { - "brace-expansion": "^1.1.7" + "brace-expansion": "^5.0.5" }, "engines": { - "node": "*" + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, "node_modules/ms": { @@ -1062,30 +1084,19 @@ "node": ">= 0.6" } }, - "node_modules/node-cron": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/node-cron/-/node-cron-3.0.3.tgz", - "integrity": "sha512-dOal67//nohNgYWb+nWmg5dkFdIwDm8EpeGYMekPMrngV3637lqnX0lbUcCtgibHTz6SEz7DAIjKvKDFYCnO1A==", - "dependencies": { - "uuid": "8.3.2" - }, - "engines": { - "node": ">=6.0.0" - } - }, "node_modules/nodemon": { - "version": "2.0.22", - "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-2.0.22.tgz", - "integrity": "sha512-B8YqaKMmyuCO7BowF1Z1/mkPqLk6cs/l63Ojtd6otKjMx47Dq1utxfRxcavH1I7VSaL8n5BUaoutadnsX3AAVQ==", + "version": "3.1.14", + "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.14.tgz", + "integrity": "sha512-jakjZi93UtB3jHMWsXL68FXSAosbLfY0In5gtKq3niLSkrWznrVBzXFNOEMJUfc9+Ke7SHWoAZsiMkNP3vq6Jw==", "dev": true, "dependencies": { "chokidar": "^3.5.2", - "debug": "^3.2.7", + "debug": "^4", "ignore-by-default": "^1.0.1", - "minimatch": "^3.1.2", + "minimatch": "^10.2.1", "pstree.remy": "^1.1.8", - "semver": "^5.7.1", - "simple-update-notifier": "^1.0.7", + "semver": "^7.5.3", + "simple-update-notifier": "^2.0.0", "supports-color": "^5.5.0", "touch": "^3.1.0", "undefsafe": "^2.0.5" @@ -1094,7 +1105,7 @@ "nodemon": "bin/nodemon.js" }, "engines": { - "node": ">=8.10.0" + "node": ">=10" }, "funding": { "type": "opencollective", @@ -1102,12 +1113,20 @@ } }, "node_modules/nodemon/node_modules/debug": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", - "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "dev": true, "dependencies": { - "ms": "^2.1.1" + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } } }, "node_modules/nodemon/node_modules/has-flag": { @@ -1318,12 +1337,15 @@ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" }, "node_modules/semver": { - "version": "5.7.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", - "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz", + "integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==", "dev": true, "bin": { - "semver": "bin/semver" + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" } }, "node_modules/send": { @@ -1454,24 +1476,15 @@ } }, "node_modules/simple-update-notifier": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-1.1.0.tgz", - "integrity": "sha512-VpsrsJSUcJEseSbMHkrsrAVSdvVS5I96Qo1QAQ4FxQ9wXFcB+pjj7FB7/us9+GcgfW4ziHtYMc1J0PLczb55mg==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", + "integrity": "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==", "dev": true, "dependencies": { - "semver": "~7.0.0" + "semver": "^7.5.3" }, "engines": { - "node": ">=8.10.0" - } - }, - "node_modules/simple-update-notifier/node_modules/semver": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.0.0.tgz", - "integrity": "sha512-+GB6zVA9LWh6zovYQLALHwv5rb2PHGlJi3lfiqIHxR0uuwCgefcOJc59v9fv1w8GbStwxuuqqAjI9NMAOOgq1A==", - "dev": true, - "bin": { - "semver": "bin/semver.js" + "node": ">=10" } }, "node_modules/spawn-command": { @@ -1607,15 +1620,6 @@ "node": ">= 0.4.0" } }, - "node_modules/uuid": { - "version": "8.3.2", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", - "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", - "deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).", - "bin": { - "uuid": "dist/bin/uuid" - } - }, "node_modules/vary": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", @@ -1764,9 +1768,9 @@ } }, "balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", "dev": true }, "binary-extensions": { @@ -1795,13 +1799,12 @@ } }, "brace-expansion": { - "version": "1.1.14", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", - "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", "dev": true, "requires": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" + "balanced-match": "^4.0.2" } }, "braces": { @@ -1907,12 +1910,6 @@ "delayed-stream": "~1.0.0" } }, - "concat-map": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "dev": true - }, "concurrently": { "version": "7.6.0", "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-7.6.0.tgz", @@ -2124,6 +2121,12 @@ "vary": "~1.1.2" } }, + "express-rate-limit": { + "version": "6.7.0", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-6.7.0.tgz", + "integrity": "sha512-vhwIdRoqcYB/72TK3tRZI+0ttS8Ytrk24GfmsxDXK9o9IhHNO5bXRiXQSExPQ4GbaE5tvIS7j1SGrxsuWs+sGA==", + "requires": {} + }, "fill-range": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", @@ -2259,6 +2262,11 @@ "function-bind": "^1.1.2" } }, + "helmet": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/helmet/-/helmet-4.6.0.tgz", + "integrity": "sha512-HVqALKZlR95ROkrnesdhbbZJFi/rIVSoNq6f3jA/9u6MIbTsPh3xZwihjeI5+DO/2sOV6HMHooXcEOuwskHpTg==" + }, "http-errors": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", @@ -2400,12 +2408,12 @@ } }, "minimatch": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", - "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", "dev": true, "requires": { - "brace-expansion": "^1.1.7" + "brace-expansion": "^5.0.5" } }, "ms": { @@ -2418,39 +2426,31 @@ "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==" }, - "node-cron": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/node-cron/-/node-cron-3.0.3.tgz", - "integrity": "sha512-dOal67//nohNgYWb+nWmg5dkFdIwDm8EpeGYMekPMrngV3637lqnX0lbUcCtgibHTz6SEz7DAIjKvKDFYCnO1A==", - "requires": { - "uuid": "8.3.2" - } - }, "nodemon": { - "version": "2.0.22", - "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-2.0.22.tgz", - "integrity": "sha512-B8YqaKMmyuCO7BowF1Z1/mkPqLk6cs/l63Ojtd6otKjMx47Dq1utxfRxcavH1I7VSaL8n5BUaoutadnsX3AAVQ==", + "version": "3.1.14", + "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.14.tgz", + "integrity": "sha512-jakjZi93UtB3jHMWsXL68FXSAosbLfY0In5gtKq3niLSkrWznrVBzXFNOEMJUfc9+Ke7SHWoAZsiMkNP3vq6Jw==", "dev": true, "requires": { "chokidar": "^3.5.2", - "debug": "^3.2.7", + "debug": "^4", "ignore-by-default": "^1.0.1", - "minimatch": "^3.1.2", + "minimatch": "^10.2.1", "pstree.remy": "^1.1.8", - "semver": "^5.7.1", - "simple-update-notifier": "^1.0.7", + "semver": "^7.5.3", + "simple-update-notifier": "^2.0.0", "supports-color": "^5.5.0", "touch": "^3.1.0", "undefsafe": "^2.0.5" }, "dependencies": { "debug": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", - "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "dev": true, "requires": { - "ms": "^2.1.1" + "ms": "^2.1.3" } }, "has-flag": { @@ -2595,9 +2595,9 @@ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" }, "semver": { - "version": "5.7.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", - "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz", + "integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==", "dev": true }, "send": { @@ -2694,20 +2694,12 @@ } }, "simple-update-notifier": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-1.1.0.tgz", - "integrity": "sha512-VpsrsJSUcJEseSbMHkrsrAVSdvVS5I96Qo1QAQ4FxQ9wXFcB+pjj7FB7/us9+GcgfW4ziHtYMc1J0PLczb55mg==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", + "integrity": "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==", "dev": true, "requires": { - "semver": "~7.0.0" - }, - "dependencies": { - "semver": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.0.0.tgz", - "integrity": "sha512-+GB6zVA9LWh6zovYQLALHwv5rb2PHGlJi3lfiqIHxR0uuwCgefcOJc59v9fv1w8GbStwxuuqqAjI9NMAOOgq1A==", - "dev": true - } + "semver": "^7.5.3" } }, "spawn-command": { @@ -2807,11 +2799,6 @@ "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==" }, - "uuid": { - "version": "8.3.2", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", - "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==" - }, "vary": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", diff --git a/package.json b/package.json index ff08aa8..451f907 100644 --- a/package.json +++ b/package.json @@ -1,24 +1,27 @@ { "name": "sofarr", - "version": "0.1.4", + "version": "0.1.5", "description": "A personal media download dashboard that shows your downloads 'so far' while you relax on the sofa waiting for your *arr services to finish", "main": "server/index.js", "scripts": { "dev": "nodemon server/index.js", "start": "node server/index.js", - "install:all": "npm install" + "install:all": "npm install", + "audit": "npm audit --audit-level=moderate", + "audit:fix": "npm audit fix" }, "dependencies": { - "express": "^4.18.2", + "axios": "^1.6.0", + "cookie-parser": "^1.4.6", "cors": "^2.8.5", "dotenv": "^16.3.1", - "axios": "^1.6.0", - "node-cron": "^3.0.3", - "cookie-parser": "^1.4.6" + "express": "^4.18.2", + "express-rate-limit": "^6.7.0", + "helmet": "^4.6.0" }, "devDependencies": { - "nodemon": "^2.0.22", - "concurrently": "^7.6.0" + "concurrently": "^7.6.0", + "nodemon": "^3.1.14" }, "keywords": [ "sabnzbd", diff --git a/public/app.js b/public/app.js index ea037f6..75a38ec 100644 --- a/public/app.js +++ b/public/app.js @@ -99,7 +99,15 @@ function dismissSplash(startTime) { 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 }); @@ -136,6 +144,7 @@ async function handleLogin(e) { const username = document.getElementById('username').value; const password = document.getElementById('password').value; + const rememberMe = document.getElementById('remember-me').checked; try { const response = await fetch('/api/auth/login', { @@ -143,7 +152,7 @@ async function handleLogin(e) { headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ username, password }) + body: JSON.stringify({ username, password, rememberMe }) }); const data = await response.json(); @@ -151,9 +160,13 @@ async function handleLogin(e) { if (data.success) { currentUser = data.user; isAdmin = !!data.user.isAdmin; - // Fade out login, then show splash while loading data + // 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(); const splashStart = Date.now(); await fetchUserDownloads(true); @@ -434,11 +447,30 @@ function createDownloadCard(download) { infoDiv.appendChild(movie); } - if (showAll && download.userTag) { - const userBadge = document.createElement('span'); - userBadge.className = 'download-user-badge'; - userBadge.textContent = download.userTag; - header.appendChild(userBadge); + 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'); diff --git a/public/favicon-192.png b/public/favicon-192.png new file mode 100644 index 0000000..ac9ff2c Binary files /dev/null and b/public/favicon-192.png differ diff --git a/public/favicon-32.png b/public/favicon-32.png new file mode 100644 index 0000000..36850a4 Binary files /dev/null and b/public/favicon-32.png differ diff --git a/public/favicon.ico b/public/favicon.ico new file mode 100644 index 0000000..5066b26 Binary files /dev/null and b/public/favicon.ico differ diff --git a/public/images/sofarr-logoonly.png b/public/images/sofarr-logoonly.png new file mode 100644 index 0000000..5485376 Binary files /dev/null and b/public/images/sofarr-logoonly.png differ diff --git a/public/index.html b/public/index.html index 6c49234..5c23feb 100644 --- a/public/index.html +++ b/public/index.html @@ -4,6 +4,10 @@ sofarr - Your Downloads Dashboard + + + + @@ -27,6 +31,12 @@ +
+ +
diff --git a/public/style.css b/public/style.css index 178fd11..8e8220b 100644 --- a/public/style.css +++ b/public/style.css @@ -64,6 +64,8 @@ --footer-text: rgba(255, 255, 255, 0.9); --input-bg: #ffffff; --select-bg: #ffffff; + --unmatched-tag-bg: #fff3e0; + --unmatched-tag-color: #e65100; } [data-theme="dark"] { @@ -100,6 +102,8 @@ --footer-text: rgba(200, 200, 220, 0.8); --input-bg: #2a2a3d; --select-bg: #2a2a3d; + --unmatched-tag-bg: #3d2a00; + --unmatched-tag-color: #ffb74d; } [data-theme="mono"] { @@ -136,6 +140,8 @@ --footer-text: rgba(180, 180, 180, 0.7); --input-bg: #252525; --select-bg: #252525; + --unmatched-tag-bg: #2a2a2a; + --unmatched-tag-color: #a0a0a0; } /* ===== Base ===== */ @@ -606,6 +612,32 @@ body { border-color: var(--accent); } +.form-group--checkbox { + margin-bottom: 20px; +} + +.checkbox-label { + display: flex; + align-items: center; + gap: 8px; + cursor: pointer; + font-size: 0.9rem; + color: var(--text-secondary); + user-select: none; +} + +.checkbox-label input[type="checkbox"] { + width: 16px; + height: 16px; + accent-color: var(--accent); + cursor: pointer; + flex-shrink: 0; +} + +.checkbox-label span { + line-height: 1; +} + .login-btn { width: 100%; padding: 10px; @@ -734,6 +766,12 @@ body { white-space: nowrap; } +.download-user-badge.unmatched { + background: var(--unmatched-tag-bg); + color: var(--unmatched-tag-color); + margin-left: 0; +} + /* ===== Status Button ===== */ .status-btn { padding: 4px 12px; diff --git a/server/index.js b/server/index.js index 7231319..fd59923 100644 --- a/server/index.js +++ b/server/index.js @@ -1,7 +1,7 @@ const express = require('express'); -const cors = require('cors'); const path = require('path'); const cookieParser = require('cookie-parser'); +const helmet = require('helmet'); const fs = require('fs'); require('dotenv').config(); @@ -59,8 +59,18 @@ const { startPoller, POLL_INTERVAL, POLLING_ENABLED } = require('./utils/poller' const app = express(); const PORT = process.env.PORT || 3001; -app.use(cors()); -app.use(cookieParser()); +app.use(helmet({ + contentSecurityPolicy: false // SPA uses inline scripts; CSP requires a nonce/hash strategy +})); + +const cookieSecret = process.env.COOKIE_SECRET; +if (!cookieSecret && process.env.NODE_ENV === 'production') { + console.error('[Security] COOKIE_SECRET is not set in production — cookies are unsigned and can be tampered with!'); + process.exit(1); +} else if (!cookieSecret) { + console.warn('[Security] COOKIE_SECRET is not set — using unsigned cookies (acceptable for development only)'); +} +app.use(cookieParser(cookieSecret || undefined)); app.use(express.json()); app.use(express.static(path.join(__dirname, '../public'))); diff --git a/server/middleware/requireAuth.js b/server/middleware/requireAuth.js new file mode 100644 index 0000000..2504fcb --- /dev/null +++ b/server/middleware/requireAuth.js @@ -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; diff --git a/server/routes/auth.js b/server/routes/auth.js index 7d488bc..1b82a21 100644 --- a/server/routes/auth.js +++ b/server/routes/auth.js @@ -1,29 +1,57 @@ const express = require('express'); const axios = require('axios'); +const crypto = require('crypto'); +const rateLimit = require('express-rate-limit'); const router = express.Router(); const EMBY_URL = process.env.EMBY_URL; -const EMBY_API_KEY = process.env.EMBY_API_KEY; + +// Server-side token store: userId -> { accessToken } +// Keeps AccessToken off the client; required for logout revocation. +const tokenStore = new Map(); + +function storeToken(userId, accessToken) { + tokenStore.set(userId, { accessToken }); +} + +function getToken(userId) { + return tokenStore.get(userId) || null; +} + +function clearToken(userId) { + tokenStore.delete(userId); +} + + +const loginLimiter = rateLimit({ + windowMs: 15 * 60 * 1000, // 15 minutes + max: 10, + standardHeaders: true, + legacyHeaders: false, + message: { success: false, error: 'Too many login attempts, please try again later' } +}); // Authenticate user with Emby -router.post('/login', async (req, res) => { +router.post('/login', loginLimiter, async (req, res) => { try { - const { username, password } = req.body; + const { username, password, rememberMe } = req.body; console.log(`[Auth] Attempting login for user: ${username}`); - // Authenticate with Emby + // Authenticate with Emby using a stable DeviceId derived from the username. + // Using a deterministic DeviceId causes Emby to reuse the existing session + // for this device rather than creating a new one on each login. + const stableDeviceId = 'sofarr-' + crypto.createHash('sha256').update(username.toLowerCase()).digest('hex').slice(0, 16); const authResponse = await axios.post(`${EMBY_URL}/Users/authenticatebyname`, { Username: username, Pw: password }, { 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; - console.log(`[Auth] Emby auth response:`, JSON.stringify(authData)); // Get user info using the access token const userResponse = await axios.get(`${EMBY_URL}/Users/${authData.User.Id || authData.User.id}`, { @@ -33,28 +61,31 @@ router.post('/login', async (req, res) => { }); const user = userResponse.data; - console.log(`[Auth] User info:`, JSON.stringify(user)); - console.log(`[Auth] Login successful for user: ${user.Name}`); - - // Set authentication cookie const isAdmin = !!(user.Policy && user.Policy.IsAdministrator); - res.cookie('emby_user', JSON.stringify({ - id: user.Id, - name: user.Name, - isAdmin: isAdmin, - token: authData.AccessToken - }), { + 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 (signed when COOKIE_SECRET is set). + // rememberMe=true → persistent cookie, expires in 30 days + // rememberMe=false → session cookie, expires when browser closes + const cookiePayload = JSON.stringify({ id: user.Id, name: user.Name, isAdmin }); + const signed = !!process.env.COOKIE_SECRET; + const cookieOptions = { httpOnly: true, - maxAge: 24 * 60 * 60 * 1000 // 24 hours - }); + secure: process.env.NODE_ENV === 'production', + sameSite: 'strict', + signed + }; + if (rememberMe) { + cookieOptions.maxAge = 30 * 24 * 60 * 60 * 1000; // 30 days + } + res.cookie('emby_user', cookiePayload, cookieOptions); res.json({ success: true, - user: { - id: user.Id, - name: user.Name, - isAdmin: isAdmin - } + user: { id: user.Id, name: user.Name, isAdmin } }); } catch (error) { console.error(`[Auth] Login failed:`, error.message); @@ -65,33 +96,55 @@ 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 router.get('/me', (req, res) => { - try { - const userCookie = req.cookies.emby_user; - - if (!userCookie) { - return res.json({ authenticated: false }); - } - - const user = JSON.parse(userCookie); - res.json({ - authenticated: true, - user: { - id: user.id, - name: user.name, - isAdmin: !!user.isAdmin - } - }); - } catch (error) { - console.error(`[Auth] Error getting current user:`, error.message); - res.json({ authenticated: false }); - } + const user = parseSessionCookie(req); + if (!user) return res.json({ authenticated: false }); + res.json({ + authenticated: true, + user: { id: user.id, name: user.name, isAdmin: user.isAdmin } + }); }); // Logout -router.post('/logout', (req, res) => { - res.clearCookie('emby_user'); +router.post('/logout', async (req, res) => { + const user = parseSessionCookie(req); + if (user) { + const stored = getToken(user.id); + if (stored) { + try { + await axios.post(`${EMBY_URL}/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 + }); res.json({ success: true }); }); diff --git a/server/routes/dashboard.js b/server/routes/dashboard.js index d537adc..3e7576a 100644 --- a/server/routes/dashboard.js +++ b/server/routes/dashboard.js @@ -1,14 +1,14 @@ const express = require('express'); const router = express.Router(); +const requireAuth = require('../middleware/requireAuth'); const axios = require('axios'); const { mapTorrentToDownload } = require('../utils/qbittorrent'); const cache = require('../utils/cache'); const { pollAllServices, getLastPollTimings, POLLING_ENABLED } = require('../utils/poller'); const { getSonarrInstances, getRadarrInstances } = 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 function getCoverArt(item) { @@ -20,24 +20,26 @@ function getCoverArt(item) { return fanart ? (fanart.remoteUrl || fanart.url || null) : null; } -// Helper function to extract user tag from series/movie -// For Radarr: tags is array of IDs, tagMap is id -> label mapping -// For Sonarr: tags is array of objects with label property -function extractUserTag(tags, tagMap) { - if (!tags || tags.length === 0) return null; - - // If tagMap provided (Radarr), look up label by ID +// Return all resolved tag labels for a series/movie. +// For Radarr: tags is array of IDs, tagMap is id -> label mapping. +// For Sonarr: tags are objects with a label property. +function extractAllTags(tags, tagMap) { + if (!tags || tags.length === 0) return []; if (tagMap) { - for (const tagId of tags) { - const label = tagMap.get(tagId); - if (label) return label; - } - return null; + return tags.map(id => tagMap.get(id)).filter(Boolean); } - - // Sonarr style - tags are objects with label - const userTag = tags.find(tag => tag && tag.label); - return userTag ? userTag.label : null; + return tags.map(t => t && t.label).filter(Boolean); +} + +// 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 @@ -92,6 +94,41 @@ function getRadarrLink(movie) { return `${movie._instanceUrl}/movie/${movie.titleSlug}`; } +// Fetch all Emby users and return a Map 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 const activeClients = new Map(); const CLIENT_STALE_MS = 30000; // consider client gone after 30s of no requests @@ -106,15 +143,9 @@ function getActiveClients() { } // Get user downloads for authenticated user -router.get('/user-downloads', async (req, res) => { +router.get('/user-downloads', requireAuth, async (req, res) => { try { - // Get authenticated user from cookie - const userCookie = req.cookies.emby_user; - if (!userCookie) { - return res.status(401).json({ error: 'Not authenticated' }); - } - - const user = JSON.parse(userCookie); + const user = req.user; const username = user.name.toLowerCase(); const usernameSanitized = sanitizeTagLabel(user.name); const isAdmin = !!user.isAdmin; @@ -179,6 +210,9 @@ router.get('/user-downloads', async (req, res) => { 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])); + // When showing all downloads, fetch full Emby user list to classify tags + const embyUserMap = showAll ? await getEmbyUsers() : new Map(); + console.log(`[Dashboard] Cache data - Series: ${seriesMap.size}, Movies: ${moviesMap.size}, qBit: ${qbittorrentTorrents.length}`); // Match SABnzbd downloads to Sonarr/Radarr activity @@ -224,8 +258,10 @@ router.get('/user-downloads', async (req, res) => { if (sonarrMatch && sonarrMatch.seriesId) { const series = seriesMap.get(sonarrMatch.seriesId) || sonarrMatch.series; if (series) { - const userTag = extractUserTag(series.tags, sonarrTagMap); - if (userTag && (showAll || tagMatchesUser(userTag, username))) { + const allTags = extractAllTags(series.tags, sonarrTagMap); + const matchedUserTag = extractUserTag(series.tags, sonarrTagMap, username); + const hasAnyTag = allTags.length > 0; + if (showAll ? hasAnyTag : !!matchedUserTag) { const dlObj = { type: 'series', title: nzbName, @@ -239,7 +275,9 @@ router.get('/user-downloads', async (req, res) => { eta: slot.timeleft, seriesName: series.title, episodeInfo: sonarrMatch, - userTag: userTag + allTags, + matchedUserTag: matchedUserTag || null, + tagBadges: showAll ? buildTagBadges(allTags, embyUserMap) : undefined }; const issues = getImportIssues(sonarrMatch); if (issues) dlObj.importIssues = issues; @@ -262,8 +300,10 @@ router.get('/user-downloads', async (req, res) => { if (radarrMatch && radarrMatch.movieId) { const movie = moviesMap.get(radarrMatch.movieId) || radarrMatch.movie; if (movie) { - const userTag = extractUserTag(movie.tags, radarrTagMap); - if (userTag && (showAll || tagMatchesUser(userTag, username))) { + const allTags = extractAllTags(movie.tags, radarrTagMap); + const matchedUserTag = extractUserTag(movie.tags, radarrTagMap, username); + const hasAnyTag = allTags.length > 0; + if (showAll ? hasAnyTag : !!matchedUserTag) { const dlObj = { type: 'movie', title: nzbName, @@ -277,7 +317,9 @@ router.get('/user-downloads', async (req, res) => { eta: slot.timeleft, movieName: movie.title, movieInfo: radarrMatch, - userTag: userTag + allTags, + matchedUserTag: matchedUserTag || null, + tagBadges: showAll ? buildTagBadges(allTags, embyUserMap) : undefined }; const issues = getImportIssues(radarrMatch); if (issues) dlObj.importIssues = issues; @@ -317,8 +359,10 @@ router.get('/user-downloads', async (req, res) => { if (sonarrMatch && sonarrMatch.seriesId) { const series = seriesMap.get(sonarrMatch.seriesId) || sonarrMatch.series; if (series) { - const userTag = extractUserTag(series.tags, sonarrTagMap); - if (userTag && (showAll || tagMatchesUser(userTag, username))) { + const allTags = extractAllTags(series.tags, sonarrTagMap); + const matchedUserTag = extractUserTag(series.tags, sonarrTagMap, username); + const hasAnyTag = allTags.length > 0; + if (showAll ? hasAnyTag : !!matchedUserTag) { const dlObj = { type: 'series', title: nzbName, @@ -328,7 +372,9 @@ router.get('/user-downloads', async (req, res) => { completedAt: slot.completed_time, seriesName: series.title, episodeInfo: sonarrMatch, - userTag: userTag + allTags, + matchedUserTag: matchedUserTag || null, + tagBadges: showAll ? buildTagBadges(allTags, embyUserMap) : undefined }; if (isAdmin) { dlObj.downloadPath = slot.storage || null; @@ -349,8 +395,10 @@ router.get('/user-downloads', async (req, res) => { if (radarrMatch && radarrMatch.movieId) { const movie = moviesMap.get(radarrMatch.movieId) || radarrMatch.movie; if (movie) { - const userTag = extractUserTag(movie.tags, radarrTagMap); - if (userTag && (showAll || tagMatchesUser(userTag, username))) { + const allTags = extractAllTags(movie.tags, radarrTagMap); + const matchedUserTag = extractUserTag(movie.tags, radarrTagMap, username); + const hasAnyTag = allTags.length > 0; + if (showAll ? hasAnyTag : !!matchedUserTag) { const dlObj = { type: 'movie', title: nzbName, @@ -360,7 +408,9 @@ router.get('/user-downloads', async (req, res) => { completedAt: slot.completed_time, movieName: movie.title, movieInfo: radarrMatch, - userTag: userTag + allTags, + matchedUserTag: matchedUserTag || null, + tagBadges: showAll ? buildTagBadges(allTags, embyUserMap) : undefined }; if (isAdmin) { dlObj.downloadPath = slot.storage || null; @@ -388,12 +438,10 @@ router.get('/user-downloads', async (req, res) => { // Show movies/series tagged for this user (from embedded objects in queue/history) const userMovies = Array.from(moviesMap.values()).filter(m => { - const tag = extractUserTag(m.tags, radarrTagMap); - return tag && tagMatchesUser(tag, username); + return !!extractUserTag(m.tags, radarrTagMap, username); }); const userSeries = Array.from(seriesMap.values()).filter(s => { - const tag = extractUserTag(s.tags, sonarrTagMap); - return tag && tagMatchesUser(tag, username); + return !!extractUserTag(s.tags, sonarrTagMap, username); }); console.log(`[Dashboard] Movies tagged for ${username}:`, userMovies.map(m => m.title)); console.log(`[Dashboard] Series tagged for ${username}:`, userSeries.map(s => s.title)); @@ -417,15 +465,19 @@ router.get('/user-downloads', async (req, res) => { if (sonarrMatch && sonarrMatch.seriesId) { const series = seriesMap.get(sonarrMatch.seriesId) || sonarrMatch.series; if (series) { - const userTag = extractUserTag(series.tags, sonarrTagMap); - if (userTag && (showAll || tagMatchesUser(userTag, username))) { + const allTags = extractAllTags(series.tags, sonarrTagMap); + 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}"`); const download = mapTorrentToDownload(torrent); download.type = 'series'; download.coverArt = getCoverArt(series); download.seriesName = series.title; 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) { @@ -448,15 +500,19 @@ router.get('/user-downloads', async (req, res) => { if (radarrMatch && radarrMatch.movieId) { const movie = moviesMap.get(radarrMatch.movieId) || radarrMatch.movie; if (movie) { - const userTag = extractUserTag(movie.tags, radarrTagMap); - if (userTag && (showAll || tagMatchesUser(userTag, username))) { + const allTags = extractAllTags(movie.tags, radarrTagMap); + 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}"`); const download = mapTorrentToDownload(torrent); download.type = 'movie'; download.coverArt = getCoverArt(movie); download.movieName = movie.title; 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) { @@ -479,15 +535,19 @@ router.get('/user-downloads', async (req, res) => { if (sonarrHistoryMatch && sonarrHistoryMatch.seriesId) { const series = seriesMap.get(sonarrHistoryMatch.seriesId) || sonarrHistoryMatch.series; if (series) { - const userTag = extractUserTag(series.tags, sonarrTagMap); - if (userTag && (showAll || tagMatchesUser(userTag, username))) { + const allTags = extractAllTags(series.tags, sonarrTagMap); + 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}"`); const download = mapTorrentToDownload(torrent); download.type = 'series'; download.coverArt = getCoverArt(series); download.seriesName = series.title; 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; @@ -508,15 +568,19 @@ router.get('/user-downloads', async (req, res) => { if (radarrHistoryMatch && radarrHistoryMatch.movieId) { const movie = moviesMap.get(radarrHistoryMatch.movieId) || radarrHistoryMatch.movie; if (movie) { - const userTag = extractUserTag(movie.tags, radarrTagMap); - if (userTag && (showAll || tagMatchesUser(userTag, username))) { + const allTags = extractAllTags(movie.tags, radarrTagMap); + 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}"`); const download = mapTorrentToDownload(torrent); download.type = 'movie'; download.coverArt = getCoverArt(movie); download.movieName = movie.title; 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; @@ -547,19 +611,19 @@ router.get('/user-downloads', async (req, res) => { } catch (error) { console.error(`[Dashboard] Error fetching user downloads:`, error.message); 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 -router.get('/user-summary', async (req, res) => { +router.get('/user-summary', requireAuth, async (req, res) => { try { const sonarrInstances = getSonarrInstances(); const radarrInstances = getRadarrInstances(); // Get all Emby users - const usersResponse = await axios.get(`${EMBY_URL}/Users`, { - headers: { 'X-MediaBrowser-Token': EMBY_API_KEY } + const usersResponse = await axios.get(`${process.env.EMBY_URL}/Users`, { + headers: { 'X-MediaBrowser-Token': process.env.EMBY_API_KEY } }); // Get all series, movies, and tags from all instances @@ -603,40 +667,32 @@ router.get('/user-summary', async (req, res) => { // Process series tags allSeries.forEach(series => { - const userTag = extractUserTag(series.tags, sonarrTagMap); - if (userTag) { - const username = userTag.toLowerCase(); - if (userDownloads[username]) { - userDownloads[username].seriesCount++; - } - } + const tags = extractAllTags(series.tags, sonarrTagMap); + tags.forEach(userTag => { + const uname = userTag.toLowerCase(); + if (userDownloads[uname]) userDownloads[uname].seriesCount++; + }); }); // Process movie tags allMovies.forEach(movie => { - const userTag = extractUserTag(movie.tags, radarrTagMap); - if (userTag) { - const username = userTag.toLowerCase(); - if (userDownloads[username]) { - userDownloads[username].movieCount++; - } - } + const tags = extractAllTags(movie.tags, radarrTagMap); + tags.forEach(userTag => { + const uname = userTag.toLowerCase(); + if (userDownloads[uname]) userDownloads[uname].movieCount++; + }); }); res.json(Object.values(userDownloads)); } 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', (req, res) => { +router.get('/status', requireAuth, (req, res) => { try { - const userCookie = req.cookies.emby_user; - if (!userCookie) { - return res.status(401).json({ error: 'Not authenticated' }); - } - const user = JSON.parse(userCookie); + const user = req.user; if (!user.isAdmin) { return res.status(403).json({ error: 'Admin access required' }); } diff --git a/server/routes/emby.js b/server/routes/emby.js index 6dab4da..f349e7f 100644 --- a/server/routes/emby.js +++ b/server/routes/emby.js @@ -1,51 +1,52 @@ const express = require('express'); const axios = require('axios'); const router = express.Router(); +const requireAuth = require('../middleware/requireAuth'); +const sanitizeError = require('../utils/sanitizeError'); -const EMBY_URL = process.env.EMBY_URL; -const EMBY_API_KEY = process.env.EMBY_API_KEY; +router.use(requireAuth); // Get active sessions router.get('/sessions', async (req, res) => { try { - const response = await axios.get(`${EMBY_URL}/Sessions`, { - headers: { 'X-MediaBrowser-Token': EMBY_API_KEY } + const response = await axios.get(`${process.env.EMBY_URL}/Sessions`, { + headers: { 'X-MediaBrowser-Token': process.env.EMBY_API_KEY } }); res.json(response.data); } 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 router.get('/users/:id', async (req, res) => { try { - const response = await axios.get(`${EMBY_URL}/Users/${req.params.id}`, { - headers: { 'X-MediaBrowser-Token': EMBY_API_KEY } + const response = await axios.get(`${process.env.EMBY_URL}/Users/${req.params.id}`, { + headers: { 'X-MediaBrowser-Token': process.env.EMBY_API_KEY } }); res.json(response.data); } 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 router.get('/users', async (req, res) => { try { - const response = await axios.get(`${EMBY_URL}/Users`, { - headers: { 'X-MediaBrowser-Token': EMBY_API_KEY } + const response = await axios.get(`${process.env.EMBY_URL}/Users`, { + headers: { 'X-MediaBrowser-Token': process.env.EMBY_API_KEY } }); res.json(response.data); } 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 router.get('/session/:sessionId/user', async (req, res) => { try { - const response = await axios.get(`${EMBY_URL}/Sessions`, { - headers: { 'X-MediaBrowser-Token': EMBY_API_KEY } + const response = await axios.get(`${process.env.EMBY_URL}/Sessions`, { + headers: { 'X-MediaBrowser-Token': process.env.EMBY_API_KEY } }); 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' }); } - const userResponse = await axios.get(`${EMBY_URL}/Users/${session.UserId}`, { - headers: { 'X-MediaBrowser-Token': EMBY_API_KEY } + const userResponse = await axios.get(`${process.env.EMBY_URL}/Users/${session.UserId}`, { + headers: { 'X-MediaBrowser-Token': process.env.EMBY_API_KEY } }); res.json(userResponse.data); } 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) }); } }); diff --git a/server/routes/radarr.js b/server/routes/radarr.js index 083670f..e9f348b 100644 --- a/server/routes/radarr.js +++ b/server/routes/radarr.js @@ -1,56 +1,57 @@ const express = require('express'); const axios = require('axios'); const router = express.Router(); +const requireAuth = require('../middleware/requireAuth'); +const sanitizeError = require('../utils/sanitizeError'); -const RADARR_URL = process.env.RADARR_URL; -const RADARR_API_KEY = process.env.RADARR_API_KEY; +router.use(requireAuth); // Get queue router.get('/queue', async (req, res) => { try { - const response = await axios.get(`${RADARR_URL}/api/v3/queue`, { - headers: { 'X-Api-Key': RADARR_API_KEY } + const response = await axios.get(`${process.env.RADARR_URL}/api/v3/queue`, { + headers: { 'X-Api-Key': process.env.RADARR_API_KEY } }); res.json(response.data); } 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 router.get('/history', async (req, res) => { try { - const response = await axios.get(`${RADARR_URL}/api/v3/history`, { - headers: { 'X-Api-Key': RADARR_API_KEY }, + const response = await axios.get(`${process.env.RADARR_URL}/api/v3/history`, { + headers: { 'X-Api-Key': process.env.RADARR_API_KEY }, params: { pageSize: req.query.pageSize || 50 } }); res.json(response.data); } catch (error) { - res.status(500).json({ error: 'Failed to fetch Radarr history', details: error.message }); + res.status(500).json({ error: 'Failed to fetch Radarr history', details: sanitizeError(error) }); } }); // Get movie details router.get('/movies/:id', async (req, res) => { try { - const response = await axios.get(`${RADARR_URL}/api/v3/movie/${req.params.id}`, { - headers: { 'X-Api-Key': RADARR_API_KEY } + const response = await axios.get(`${process.env.RADARR_URL}/api/v3/movie/${req.params.id}`, { + headers: { 'X-Api-Key': process.env.RADARR_API_KEY } }); res.json(response.data); } 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 router.get('/movies', async (req, res) => { try { - const response = await axios.get(`${RADARR_URL}/api/v3/movie`, { - headers: { 'X-Api-Key': RADARR_API_KEY } + const response = await axios.get(`${process.env.RADARR_URL}/api/v3/movie`, { + headers: { 'X-Api-Key': process.env.RADARR_API_KEY } }); res.json(response.data); } 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) }); } }); diff --git a/server/routes/sabnzbd.js b/server/routes/sabnzbd.js index 0120ee3..19ff152 100644 --- a/server/routes/sabnzbd.js +++ b/server/routes/sabnzbd.js @@ -1,40 +1,41 @@ const express = require('express'); const axios = require('axios'); const router = express.Router(); +const requireAuth = require('../middleware/requireAuth'); +const sanitizeError = require('../utils/sanitizeError'); -const SABNZBD_URL = process.env.SABNZBD_URL; -const SABNZBD_API_KEY = process.env.SABNZBD_API_KEY; +router.use(requireAuth); // Get current queue router.get('/queue', async (req, res) => { try { - const response = await axios.get(`${SABNZBD_URL}/api`, { + const response = await axios.get(`${process.env.SABNZBD_URL}/api`, { params: { mode: 'queue', - apikey: SABNZBD_API_KEY, + apikey: process.env.SABNZBD_API_KEY, output: 'json' } }); res.json(response.data); } 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 router.get('/history', async (req, res) => { try { - const response = await axios.get(`${SABNZBD_URL}/api`, { + const response = await axios.get(`${process.env.SABNZBD_URL}/api`, { params: { mode: 'history', - apikey: SABNZBD_API_KEY, + apikey: process.env.SABNZBD_API_KEY, output: 'json', limit: req.query.limit || 50 } }); res.json(response.data); } catch (error) { - res.status(500).json({ error: 'Failed to fetch SABnzbd history', details: error.message }); + res.status(500).json({ error: 'Failed to fetch SABnzbd history', details: sanitizeError(error) }); } }); diff --git a/server/routes/sonarr.js b/server/routes/sonarr.js index 68ea828..889b9ab 100644 --- a/server/routes/sonarr.js +++ b/server/routes/sonarr.js @@ -1,56 +1,57 @@ const express = require('express'); const axios = require('axios'); const router = express.Router(); +const requireAuth = require('../middleware/requireAuth'); +const sanitizeError = require('../utils/sanitizeError'); -const SONARR_URL = process.env.SONARR_URL; -const SONARR_API_KEY = process.env.SONARR_API_KEY; +router.use(requireAuth); // Get queue router.get('/queue', async (req, res) => { try { - const response = await axios.get(`${SONARR_URL}/api/v3/queue`, { - headers: { 'X-Api-Key': SONARR_API_KEY } + const response = await axios.get(`${process.env.SONARR_URL}/api/v3/queue`, { + headers: { 'X-Api-Key': process.env.SONARR_API_KEY } }); res.json(response.data); } 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 router.get('/history', async (req, res) => { try { - const response = await axios.get(`${SONARR_URL}/api/v3/history`, { - headers: { 'X-Api-Key': SONARR_API_KEY }, + const response = await axios.get(`${process.env.SONARR_URL}/api/v3/history`, { + headers: { 'X-Api-Key': process.env.SONARR_API_KEY }, params: { pageSize: req.query.pageSize || 50 } }); res.json(response.data); } catch (error) { - res.status(500).json({ error: 'Failed to fetch Sonarr history', details: error.message }); + res.status(500).json({ error: 'Failed to fetch Sonarr history', details: sanitizeError(error) }); } }); // Get series details router.get('/series/:id', async (req, res) => { try { - const response = await axios.get(`${SONARR_URL}/api/v3/series/${req.params.id}`, { - headers: { 'X-Api-Key': SONARR_API_KEY } + const response = await axios.get(`${process.env.SONARR_URL}/api/v3/series/${req.params.id}`, { + headers: { 'X-Api-Key': process.env.SONARR_API_KEY } }); res.json(response.data); } 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 router.get('/series', async (req, res) => { try { - const response = await axios.get(`${SONARR_URL}/api/v3/series`, { - headers: { 'X-Api-Key': SONARR_API_KEY } + const response = await axios.get(`${process.env.SONARR_URL}/api/v3/series`, { + headers: { 'X-Api-Key': process.env.SONARR_API_KEY } }); res.json(response.data); } 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) }); } }); diff --git a/server/utils/sanitizeError.js b/server/utils/sanitizeError.js new file mode 100644 index 0000000..47af741 --- /dev/null +++ b/server/utils/sanitizeError.js @@ -0,0 +1,15 @@ +const API_KEY_PATTERN = /([?&]apikey=)[^&\s]*/gi; +const TOKEN_PATTERN = /([?&]token=)[^&\s]*/gi; +const HEADER_PATTERN = /x-(?:api-key|mediabrowser-token|emby-authorization):[^\s,]*/gi; + +function sanitizeError(err) { + let msg = err.message || String(err); + // Redact API keys in URLs (SABnzbd passes apikey as query param) + msg = msg.replace(API_KEY_PATTERN, '$1[REDACTED]'); + msg = msg.replace(TOKEN_PATTERN, '$1[REDACTED]'); + // Redact auth header values if they appear in the message + msg = msg.replace(HEADER_PATTERN, (m) => m.split(':')[0] + ':[REDACTED]'); + return msg; +} + +module.exports = sanitizeError;