Commit Graph

86 Commits

Author SHA1 Message Date
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
1f41114482 fix(security #11): remove unused node-cron dependency
node-cron was listed in dependencies but never imported anywhere in
the codebase. Removed via npm uninstall.
2026-05-16 16:22:36 +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
6675e5dcfe docs: update architecture docs and diagrams for recent changes
All checks were successful
Build and Push Docker Image / build (push) Successful in 24s
ARCHITECTURE.md:
- Directory structure: add middleware/requireAuth.js and favicon assets
- §4.1: remove CORS from middleware list
- §4.2: all proxy routes now auth-required via requireAuth; add
  middleware description
- §6: cookie payload corrected (no token); document secure+sameSite
- §7: add emby:users cache key (60s TTL)
- §8: Download Object table: userTag → allTags/matchedUserTag/tagBadges
- §9 POST /login: document cookie security attributes
- §10: add Tag Badge Rendering section; remove hardcoded line count

Diagrams:
- class-server.puml: add requireAuth middleware module; update
  dashboard.js methods (extractAllTags, extractUserTag w/ username,
  buildTagBadges, getEmbyUsers); add TagBadge value class; add auth
  relationships for all proxy routes
- class-data.puml: Download Object userTag → allTags/matchedUserTag/
  tagBadges; add TagBadge class; remove token from Session Cookie
- seq-auth.puml: cookie payload no longer contains token; add
  secure/sameSite note
- component.puml: remove CORS component; add requireAuth; consolidate
  Emby connection to show tag badge + user-summary usage
- activity-matching.puml: update to extractAllTags/extractUserTag
  (with username); showAll uses hasAnyTag; tagBadges built from
  embyUserMap; add Emby user fetch step; update legend
- seq-dashboard.puml: add emby:users cache lookup / Emby fetch for
  showAll; update matching groups to show tag classification; add
  tag badge rendering note on renderDownloads()
2026-05-16 15:41:23 +01:00
54647ab7cf feat: add favicon from sofarr-logoonly.png
All checks were successful
Build and Push Docker Image / build (push) Successful in 25s
Generated favicon.ico (16/32/48px multi-size), favicon-32.png, and
favicon-192.png (apple-touch-icon/PWA) from the logo, centred on a
transparent square canvas. Linked all three in index.html with
appropriate rel/type/sizes attributes plus theme-color meta tag.
2026-05-16 15:34:24 +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
43839fd8e3 fix: always show matched user tag badge, not just in showAll mode
All checks were successful
Build and Push Docker Image / build (push) Successful in 24s
Unmatched amber badges still only appear when showAll is active.
2026-05-16 15:16:44 +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
de8563704a security: ensure log files excluded recursively from git and Docker builds (issue #16)
All checks were successful
Build and Push Docker Image / build (push) Successful in 33s
*.log only matched root-level logs; add **/*.log to cover server/server.log
and any other subdirectory log files in both .gitignore and .dockerignore.
2026-05-16 15:08:44 +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
2137f65766 Merge develop into main for v0.1.4
All checks were successful
Create Release / release (push) Successful in 14s
Build and Push Docker Image / build (push) Successful in 23s
v0.1.4
2026-05-16 14:58:39 +01:00
0ddb7a407e chore: bump version to 0.1.4
All checks were successful
Build and Push Docker Image / build (push) Successful in 30s
2026-05-16 14:58:17 +01:00
5ed547579d fix: improve mobile layout and prevent text overflow on small screens
All checks were successful
Build and Push Docker Image / build (push) Successful in 32s
- download title: replace nowrap+ellipsis with break-word wrapping
- download header: flex-wrap so badges don't push off-screen
- path-item: word-break:break-all instead of nowrap overflow
- missing-text: allow wrapping instead of nowrap
- header-controls/user-info/admin-controls: full-width on mobile
- downloads-container: tighter padding on mobile
- status table: smaller padding + word-break on cache key codes
- timing row: narrower label/value columns on mobile
- import issue tooltip: constrained to viewport width, right-aligned
- ≤400px breakpoint: hide cover art, reduce app padding further
2026-05-16 14:55:43 +01:00
2bef9f9dee Merge main back into develop (v0.1.3)
All checks were successful
Build and Push Docker Image / build (push) Successful in 32s
2026-05-16 00:32:24 +01:00
fdecdd979b chore: bump version to 0.1.3
All checks were successful
Build and Push Docker Image / build (push) Successful in 25s
2026-05-16 00:32:16 +01:00
e97bd3c67b docs: comprehensive architecture documentation with PlantUML diagrams
- docs/ARCHITECTURE.md: full system overview, technology stack, directory
  structure, component architecture, data flow, auth, polling/caching,
  download matching pipeline, API reference, frontend architecture,
  configuration, deployment guide
- docs/diagrams/component.puml: system component diagram
- docs/diagrams/seq-auth.puml: authentication sequence diagram
- docs/diagrams/seq-dashboard.puml: dashboard request sequence diagram
- docs/diagrams/seq-polling.puml: background polling cycle sequence
- docs/diagrams/class-server.puml: server-side class/module diagram
- docs/diagrams/class-data.puml: data model / entity diagram
- docs/diagrams/state-ui.puml: frontend UI state diagram
- docs/diagrams/state-poller.puml: poller state diagram
- docs/diagrams/activity-matching.puml: download matching activity diagram
2026-05-16 00:32:16 +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
e5b2fc8ea4 docs: add POLL_INTERVAL to README, .env.sample, and .env.example
- Document background polling and on-demand mode in README
- Add POLL_INTERVAL to all config examples (Docker, Compose, .env)
- New 'Background Polling' section in Features
- Sanitize leaked credentials in .env.example
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
c0478ed1b2 feat: revise login screen with logo and smooth transition to splash
- Replace 'Login to Emby' heading with sofarr logo and subtitle
- Subtitle reads 'Login with your Emby credentials'
- Login form fades out smoothly before splash screen appears
- Form labels remain left-aligned within centered login box
2026-05-16 00:32:16 +01:00
b146a180d0 feat: add splash screen with logo on app load and after login
- Show sofarr logo splash screen while app initialises
- On page load: splash stays visible while checking auth and fetching data
- After login: splash reappears while fetching initial downloads
- Minimum 1.2s display with smooth fade-out transition
- Subtle pulse animation on the logo
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
137d40affe ci: remove arm builds, amd64 only for now 2026-05-16 00:32:16 +01:00
84e4201dc1 ci: build develop tag on every push to develop branch
- Triggers on develop branch in addition to release/* branches
- Develop pushes get tagged as :develop only
- Release pushes continue to get :version, :release, and :latest tags
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
36d183cba9 docs: comprehensive architecture documentation with PlantUML diagrams
All checks were successful
Build and Push Docker Image / build (push) Successful in 23s
- docs/ARCHITECTURE.md: full system overview, technology stack, directory
  structure, component architecture, data flow, auth, polling/caching,
  download matching pipeline, API reference, frontend architecture,
  configuration, deployment guide
- docs/diagrams/component.puml: system component diagram
- docs/diagrams/seq-auth.puml: authentication sequence diagram
- docs/diagrams/seq-dashboard.puml: dashboard request sequence diagram
- docs/diagrams/seq-polling.puml: background polling cycle sequence
- docs/diagrams/class-server.puml: server-side class/module diagram
- docs/diagrams/class-data.puml: data model / entity diagram
- docs/diagrams/state-ui.puml: frontend UI state diagram
- docs/diagrams/state-poller.puml: poller state diagram
- docs/diagrams/activity-matching.puml: download matching activity diagram
2026-05-16 00:30:38 +01:00
b1f81eff0f Revert "perf: split into fast poll + slow-cached library fetches"
All checks were successful
Build and Push Docker Image / build (push) Successful in 24s
This reverts commit 78a8737f29.
2026-05-16 00:21:46 +01:00
78a8737f29 perf: split into fast poll + slow-cached library fetches
All checks were successful
Build and Push Docker Image / build (push) Successful in 24s
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:20:11 +01:00
d5542abd27 perf: drop includeSeries/includeMovie from history fetches
All checks were successful
Build and Push Docker Image / build (push) Successful in 23s
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:16:31 +01:00
980e20247c perf: reduce history page sizes and drop includeEpisode
All checks were successful
Build and Push Docker Image / build (push) Successful in 22s
- 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:14:12 +01:00