Commit Graph

33 Commits

Author SHA1 Message Date
44cff5bf41 fix(security #15): read API keys from process.env at request time
Module-level const assignments (SONARR_API_KEY, RADARR_API_KEY,
SABNZBD_API_KEY, EMBY_URL, EMBY_API_KEY) captured values at startup
and would not pick up rotated credentials without a restart.

Replaced all module-level captures in emby.js, sabnzbd.js, sonarr.js,
radarr.js, and dashboard.js with inline process.env reads at each
call site. A process restart is still needed for dotenv-loaded values
but environment-injected vars (Docker, Kubernetes) are re-read live.
2026-05-16 16:26:53 +01:00
bdfb042527 fix(security #13,#14): revoke Emby token on logout; stable DeviceId prevents unbounded sessions
#13 Logout doesn't revoke Emby token:
  - Added in-memory tokenStore (userId -> { accessToken })
  - AccessToken stored server-side after successful login; never sent
    to client
  - POST /logout calls Emby POST /Sessions/Logout with the stored
    token before clearing it; failure is warned but does not block
    the local cookie clear

#14 Unbounded Emby session creation per login:
  - DeviceId in the Emby auth request is now a stable SHA-256 hash
    of the lowercase username (sofarr-<16 hex chars>)
  - Emby treats the same DeviceId as the same device and reuses the
    existing session slot instead of creating a new one each login
2026-05-16 16:25:05 +01:00
b608fa0337 fix(security #12): add helmet security response headers
Adds X-DNS-Prefetch-Control, X-Frame-Options, X-Content-Type-Options,
Referrer-Policy, X-XSS-Protection, HSTS (in prod) and others.
CSP disabled for now as the SPA uses inline scripts/styles; a
nonce/hash-based policy is a future hardening step.
2026-05-16 16:23:47 +01:00
8fa20c6990 fix(security #10): sanitize error details to prevent API key leakage
Added server/utils/sanitizeError.js which redacts:
- ?apikey= query parameters (SABnzbd passes key in URL)
- ?token= query parameters
- X-Api-Key / X-MediaBrowser-Token / X-Emby-Authorization header
  values if they appear in the error message string

Applied to all catch blocks in emby.js, sabnzbd.js, sonarr.js,
radarr.js, and dashboard.js. Internal error.message still logged
server-side (unredacted) for debugging.
2026-05-16 16:22:11 +01:00
d8584d0511 fix(security #7,#8,#9): signed cookies, isAdmin tamper-proof, schema validation
#7 isAdmin trusted from unsigned cookie:
  - isAdmin is derived server-side from Emby Policy at login time
  - Cookie is now signed (HMAC) when COOKIE_SECRET env var is set;
    Express rejects tampered signatures (signedCookies returns false)
  - dashboard.js /user-downloads and /status now use requireAuth
    middleware (req.user) instead of re-parsing cookie directly

#8 cookie-parser used without signing secret:
  - cookieParser(COOKIE_SECRET) in index.js when env var is set
  - Hard-fails at startup in production if COOKIE_SECRET unset
  - Warns in development

#9 Cookie JSON parsed without schema validation:
  - parseSessionCookie() in auth.js and requireAuth.js both validate:
    id (non-empty string), name (non-empty string), isAdmin (boolean)
  - Invalid/tampered cookies return null / 401 respectively
2026-05-16 16:20:37 +01:00
1eadb30481 fix(security #6): add rate limiting to POST /api/auth/login
Uses express-rate-limit@6 (pinned for Node 12 dev compat; Node 18
in prod container is unaffected). Limits each IP to 10 attempts per
15-minute window. Returns 429 with a safe error message on breach.
2026-05-16 16:18:34 +01:00
8f96a5f296 fix(security #5): remove plaintext logging of Emby auth response and user object
The full authResponse.data (containing AccessToken) and user object
were being logged via console.log → written to server.log on disk.
Replaced with a single safe log line showing only name and isAdmin.
2026-05-16 16:17:43 +01:00
8b81f16dac fix: proper multi-user tag badges using full Emby user list
All checks were successful
Build and Push Docker Image / build (push) Successful in 28s
Server:
- Add getEmbyUsers(): fetches all Emby users, builds Map of
  lowercase/sanitized name -> display name, cached 60s
- Add buildTagBadges(allTags, embyUserMap): classifies each tag
  as { label, matchedUser: displayName|null } against the full
  Emby user database
- Attach tagBadges[] to every download object when showAll=true
  (all 10 construction sites across SABnzbd queue/history and
  qBittorrent queue/history blocks)
- matchedUserTag still set to the tag matching the *current* user
  for the non-showAll badge

Frontend:
- showAll mode: renders tagBadges[] — unmatched tags (no Emby user)
  amber leftmost, matched tags show Emby display name in accent
  colour rightmost
- Normal mode: renders matchedUserTag badge only (current user's tag)
2026-05-16 15:29:50 +01:00
1f4aa19a72 fix: extractUserTag now correctly finds the tag matching the current user
All checks were successful
Build and Push Docker Image / build (push) Successful in 27s
Previously extractUserTag returned the first tag in the list regardless
of whether it matched the logged-in user, so matchedUserTag was wrong
and unmatched tags weren't separated correctly.

- extractUserTag(tags, tagMap, username): finds tag label that matches
  username via tagMatchesUser(); returns null if no match
- extractAllTags(): moved before extractUserTag for readability
- All 10 call sites in user-downloads pass username arg
- user-summary uses extractAllTags() directly (wants all tags, not just
  the current user's) — as a bonus this now correctly counts items
  tagged for multiple users
2026-05-16 15:24:12 +01:00
24b7797b60 feat: multi-tag badges for showAll — amber for unmatched, accent for matched
All checks were successful
Build and Push Docker Image / build (push) Successful in 27s
- server: add extractAllTags() returning all tag labels for a series/movie
- server: showAll now includes items with ANY tag (not just user-matched);
  non-admin path unchanged (must match current user's tag)
- server: replace userTag with allTags[] + matchedUserTag on every download object
- frontend: render all tags in header; unmatched tags amber (left), matched
  user tag in accent colour (rightmost); only visible in showAll mode
- css: add --unmatched-tag-bg/color variables to all three themes (light,
  dark, mono) and .download-user-badge.unmatched style
2026-05-16 15:14:33 +01:00
83049786eb security: fix issues #1-4 from security audit
All checks were successful
Build and Push Docker Image / build (push) Successful in 39s
#1 Session cookie: add secure (production-only) and sameSite=strict
    to prevent transmission over HTTP and cross-site request abuse.
#2 Remove Emby AccessToken from cookie payload — it was stored in
    the browser cookie but is never needed client-side; reduces blast
    radius if cookie is ever exposed.
#3 Add requireAuth middleware to all proxy routes (/api/emby,
    /api/sabnzbd, /api/sonarr, /api/radarr) — previously unauthenticated,
    now require a valid emby_user session cookie.
#4 Remove open CORS wildcard (cors() with no options). The frontend
    is served from the same origin so no CORS headers are required.
    Also update clearCookie() to include matching cookie options.
2026-05-16 15:07:50 +01:00
0c8d5d8a4a Revert "perf: split into fast poll + slow-cached library fetches"
This reverts commit 78a8737f29.
2026-05-16 00:32:16 +01:00
268238215e perf: split into fast poll + slow-cached library fetches
Fast poll (every cycle): SABnzbd, Sonarr/Radarr queue + history,
qBittorrent — all lightweight with no include* params.

Slow cache (5 min TTL): Sonarr series, Radarr movies, tags —
fetched only when cache expires. These rarely change.

This eliminates the 2s+ includeSeries/includeMovie joins from
every poll cycle. First poll is still slow (cold cache), but
subsequent polls should complete in <500ms.
2026-05-16 00:32:16 +01:00
31ff973eff perf: drop includeSeries/includeMovie from history fetches
Sonarr/Radarr history with include* params forces expensive DB
joins (~2s each). History records still have seriesId/movieId
for matching; the series/movie objects come from the queue-built
maps instead.

Trade-off: completed downloads only show if the series/movie
is also currently in the queue. Active downloads unaffected.
2026-05-16 00:32:16 +01:00
1327c8e466 perf: reduce history page sizes and drop includeEpisode
- Sonarr/Radarr history: pageSize 20 -> 10
- SABnzbd history: limit 20 -> 10
- Drop includeEpisode from Sonarr queue and history (never rendered)
- These reduce DB join overhead and response payload size
2026-05-16 00:32:16 +01:00
c10d20d9f5 fix: crash from stale references to removed sonarrSeries/radarrMovies
Debug logging at line 389/393 still referenced radarrMovies.data and
sonarrSeries.data which were removed in the previous commit. Updated
to use moviesMap/seriesMap built from embedded queue/history objects.
2026-05-16 00:32:16 +01:00
d50a6fe19c perf: eliminate full Sonarr Series + Radarr Movies library fetches
The poller was fetching the entire series and movie libraries on every
poll cycle (~9s each). Queue and history records already embed the full
series/movie object via includeSeries/includeMovie params.

Changes:
- Remove 'Sonarr Series' and 'Radarr Movies' timed fetches from poller
- Tag queue/history records with _instanceUrl in the poller instead
- Build seriesMap/moviesMap from embedded objects in dashboard
- Remove poll:sonarr-series and poll:radarr-movies cache keys
- Fix missing axios and config imports in dashboard
- Net result: ~18s saved per poll cycle, ~2 fewer API calls
2026-05-16 00:32:16 +01:00
6e3a98ae75 feat: status page shows effective refresh mode across all active clients
- Server tracks each client's refresh rate via query param on /user-downloads
- Active clients expire after 30s of no requests
- Status panel 'Data Refresh' card shows:
  - Background poll interval (or Disabled)
  - Effective mode: Background if all clients >= poll rate,
    Foreground (with rate) if any client is faster, Idle if no clients
  - Active client list with per-user refresh rate and last-seen age
- Foreground mode shown with orange badge for visibility
- Client refresh rate sent on every dashboard request
2026-05-16 00:32:16 +01:00
57e1db18e2 feat: live-updating status panel with per-task poll timings
- Each service fetch is individually timed (SABnzbd, Sonarr, Radarr, qBit)
- Status panel shows timing bar chart with ms per task and total
- Shows 'Last Poll' age that updates live
- Shows client refresh rate (1s/5s/10s/Off)
- Status panel auto-refreshes in sync with dashboard refresh cycle
- Changing refresh rate restarts the status panel refresh too
- TTL counters update live on each refresh
2026-05-16 00:32:16 +01:00
c03e4620ea feat: add admin-only status page with cache stats
- New /api/dashboard/status endpoint (admin-only, 403 for non-admins)
- Returns server info (uptime, Node version, memory usage)
- Returns polling mode and interval
- Returns cache stats: entry count, total size, per-key breakdown
  with item count, size in KB, and TTL remaining
- Status button in admin controls header
- Collapsible status panel with grid layout
- Responsive: single column on mobile
2026-05-16 00:32:16 +01:00
85bac5994e feat: make background polling disablable with on-demand fallback
- Set POLL_INTERVAL=0, off, false, or disabled to disable background polling
- When disabled, data is fetched on-demand when a user opens the dashboard
- On-demand results cached for 30s so other users benefit from fresh data
- A user with a faster refresh rate keeps the cache warm for everyone
- When polling is enabled, behaviour is unchanged (default 5s)
2026-05-16 00:32:16 +01:00
f28d94d9a3 perf: background poller for near-instant dashboard responses
- New poller.js polls all services on a configurable interval
- POLL_INTERVAL env var (default 5000ms / 5 seconds)
- All data stored in cache with TTL of 3x poll interval
- Dashboard endpoint now reads from cache only (no network calls)
- API responses are near-instant regardless of service count
- First poll runs immediately on server start
2026-05-16 00:32:16 +01:00
1574cce788 perf: reduce history page size from 100 to 20
- SABnzbd, Sonarr, and Radarr history now fetch 20 items instead of 100
- Only recent completions are needed for the dashboard
- Reduces response payload and serialization time
2026-05-16 00:32:16 +01:00
b48332f075 perf: persist qBittorrent clients between requests
- Reuse client instances so auth cookies survive across requests
- Eliminates redundant login round-trips on every dashboard refresh
- Clients still re-authenticate automatically if session expires (403)
2026-05-16 00:32:16 +01:00
3edc98b8d6 perf: cache slow-changing data (series, movies, tags) with 60s TTL
- Add MemoryCache utility with get/set/invalidate/clear
- Cache Sonarr series, Sonarr tags, Radarr movies, Radarr tags
- 60-second TTL - first request fetches, subsequent requests served from cache
- Queue, history, and torrent data remain uncached (changes frequently)
- On cache hit, these 4 heavy API calls resolve instantly
2026-05-16 00:32:16 +01:00
c6b5aaf3de feat: show import-pending red lozenge when Sonarr/Radarr has issues
- Detect trackedDownloadState=importPending or status=warning/error
- Extract statusMessages and errorMessage from queue records
- Display red 'Import Pending' badge on download card header
- Hover reveals tooltip with the specific issue messages
- Visible to all users (not admin-only)
2026-05-16 00:32:16 +01:00
bafa03aac2 fix: handle Ombi-mangled tags for email-style usernames
- Replicate Ombi's SanitizeTagLabel logic (lowercase, replace non-alnum with hyphen, collapse, trim)
- Try exact tag match first (handles users with normal usernames)
- Fall back to sanitized comparison (handles email usernames like robcunn@live.co.uk → robcunn-live-co-uk)
- Applied to all tag matching locations
2026-05-16 00:32:16 +01:00
59b096a60a feat: link series/movie titles to Sonarr/Radarr for admin users
- Series title links to Sonarr series page (/series/{titleSlug})
- Movie title links to Radarr movie page (/movie/{titleSlug})
- Links open in new tab, only shown for admin users
- Instance URL preserved through data aggregation for multi-instance support
2026-05-16 00:32:16 +01:00
d09b0ab40a fix: show full download path (content_path) for qBittorrent
- Prefer content_path over save_path for qBittorrent torrents
- content_path is the full path to the single file or top-level
  folder for multi-file torrents
- save_path is just the base download directory
2026-05-16 00:32:16 +01:00
b75cd18580 feat: show download/target paths for admin users
- Admin users see download path (SABnzbd storage / qBittorrent save_path)
- Admin users see target path (Sonarr series folder / Radarr movie folder)
- Paths displayed in monospace font at bottom of card details
- Non-admin users unaffected (paths not sent in API response)
2026-05-16 00:32:16 +01:00
0957f83411 feat: admin users can view all downloads with user badges
- Admin users (Emby IsAdministrator) see a 'Show all users' toggle
- When toggled, all tagged downloads are shown regardless of user
- Each download card shows the tagged user's name as a badge
- Non-admin users see only their own downloads (unchanged behavior)
- Backend accepts ?showAll=true query param (admin-only)
2026-05-15 20:46:56 +01:00
f500f4db3b feat: fix download-to-user matching, add cover art to downloads
- Fix seriesMap key (use Sonarr internal id, not tvdbId)
- Fix Sonarr tag resolution (use tag map like Radarr)
- Use sourceTitle for history record matching
- Fall back to embedded movie/series objects when API timeouts
- Add includeMovie/includeSeries params to queue/history API calls
- Add coverArt field to all download responses (TMDB poster URLs)
- Add cover art display to frontend download cards
- Fix user-summary route to use instance config and tag maps
2026-05-15 14:54:21 +01:00
5d04d2796b Initial commit: Media Download Dashboard with SABnzbd, Sonarr, Radarr, and Emby integration 2026-05-15 10:36:29 +01:00