Sonarr queue and history records do not expose episodeNumber at the
top level — it is only present inside the nested episode object
(record.episode.episodeNumber). Same for seasonNumber. The original
extractEpisode() read record.episodeNumber which was always undefined,
so gatherEpisodes() always returned an empty array.
Fix: prefer the nested episode object fields, falling back to the
top-level fields for forward-compatibility.
- Add includeEpisode:true to Sonarr queue and history API requests
in both the poller and historyFetcher
- Add extractEpisode() / gatherEpisodes() helpers in dashboard.js
and history.js to build a sorted, deduplicated episodes array
covering all records matching a download title (handles multi-
episode packs and series packs)
- Replace episodeInfo: sonarrMatch with episodes: gatherEpisodes()
across all 8 assignment sites in dashboard.js
- Add episodes field to /api/history/recent response items
- Frontend: formatEpisodeInfo() renders S01E05 for single episodes
or 'Multiple episodes' with hover tooltip listing all for packs
- CSS: .episode-info and .multi-episode tooltip styles
- ARCHITECTURE.md: update polling table and download/history schemas
server/index.js:
- Import http and https modules
- Resolve TLS_ENABLED early (before Helmet) so upgradeInsecureRequests
CSP directive fires when TLS is active directly (not only via proxy)
- loadTlsCredentials() reads TLS_CERT/TLS_KEY (defaulting to bundled
snakeoil) and returns null on failure (graceful HTTP fallback)
- Start https.createServer or http.createServer depending on credentials
- Startup banner now shows protocol, TLS cert path, and snakeoil warning
certs/:
- Add bundled snakeoil self-signed certificate (RSA 2048, 10yr, SAN for
localhost + 127.0.0.1) for out-of-the-box HTTPS without configuration
- .gitignore allows only snakeoil.{crt,key} — real certs must not be
committed
Dockerfile:
- COPY certs/ into image so snakeoil default is always available
- HEALTHCHECK updated to https:// with --no-check-certificate
docker-compose.yaml:
- Port now exposes HTTPS directly by default
- TLS_CERT/TLS_KEY/TLS_ENABLED/TRUST_PROXY documented with Option A/B
- cert volume mount examples added (commented out)
- healthcheck updated to https with --no-check-certificate
.env.sample:
- New TLS/HTTPS section with TLS_ENABLED, TLS_CERT, TLS_KEY
- openssl self-signed cert generation example included
docs/ARCHITECTURE.md:
- Configuration table: TLS_ENABLED, TLS_CERT, TLS_KEY env vars added
- Docker image section: TLS default behaviour documented
- Docker Compose example: Option A (direct TLS) / Option B (proxy) layout
- Security checklist: HTTPS now first item, updated for TLS modes
secure:true cookies are only sent by browsers over HTTPS connections.
When NODE_ENV=production (always set in the Docker container) but no
TLS proxy is in front, the browser receives the cookie on login but
refuses to send it on subsequent HTTP requests — causing every
authenticated endpoint (/stream, /status, etc.) to return 401.
The correct signal is TRUST_PROXY: it is only set when a TLS-terminating
reverse proxy is confirmed to be in front. Affects emby_user and
csrf_token cookies across login, /csrf refresh, and logout.
The previous fix was applied to server/app.js (the test factory) but
index.js has its own independent Helmet configuration which is what the
production server actually executes. Both files now gate
upgrade-insecure-requests on TRUST_PROXY instead of NODE_ENV.
NODE_ENV=production enabled upgrade-insecure-requests unconditionally,
which instructed browsers to upgrade HTTP subresource requests to HTTPS.
When sofarr is accessed directly over HTTP (no reverse proxy), this
silently blocks all CSS, JS, and image loads — the page renders unstyled
with no functionality.
The correct signal for 'we are behind HTTPS' is TRUST_PROXY, not
NODE_ENV. upgrade-insecure-requests is now only emitted when a
TLS-terminating reverse proxy is confirmed to be in front.
style-src 'self' already permits same-origin stylesheets without a nonce.
Injecting a nonce onto <link rel=stylesheet> causes silent CSS failure on
mobile Safari and any setup where a caching proxy serves stale HTML (the
nonce in the HTML no longer matches the per-request CSP header nonce).
Nonce injection is now limited to <script> tags only, where it is
actually required to permit the same-origin app.js.
Timing bars in the status panel and any other dynamically-injected
style= attributes were being silently blocked by the Content Security
Policy. style-src only governs <style> blocks and linked stylesheets;
inline element attributes need style-src-attr separately.
Adding style-src-attr 'unsafe-inline' is the minimal fix — it only
affects attribute-level inline styles, not script execution.
Also removes the temporary debug console.log added in the previous commit.
cache.js: Map values serialise as '{}' under JSON.stringify, causing
emby:users to show 0 bytes and null item count in the status panel.
Convert Maps via Object.fromEntries before stringifying, and report
Map.size as itemCount.
index.js: JS and CSS served with Cache-Control: no-cache so browsers
always revalidate on load. ETag still prevents re-downloading unchanged
files — only a new deploy triggers an actual download.
server/utils/logger.js was still writing to ../../server.log relative
to __dirname (/app/server.log) which is root-owned. The non-root node
user (UID 1000) cannot write there, causing an EACCES crash on startup.
Fix: use DATA_DIR env var (same as index.js) so all log writes go to
/app/data/server.log which is owned by the node user.
better-sqlite3 is a native C++ addon that requires compilation on Alpine
(musl libc, no pre-built binaries exist) and fails on Debian slim too
because prebuild-install cannot detect the libc type correctly.
Replace with a pure-JS JSON file token store (server/utils/tokenStore.js):
- Atomic writes via temp file + rename (no corruption on crash)
- Same API: storeToken/getToken/clearToken
- TTL enforcement on read and hourly prune
- Zero native code, zero build tools required
Dockerfile:
- Revert to node:22-alpine (was node:22-slim)
- Remove build tools (python3/make/g++) — no longer needed
- Restore wget HEALTHCHECK (available in Alpine busybox)
docker-compose.yaml: restore wget healthcheck
package.json: remove better-sqlite3 dependency
- index.html: checkbox between password field and login button
- app.js: reads #remember-me and passes rememberMe in POST body
- auth.js: rememberMe=true sets 30-day maxAge; false = session cookie
(expires when browser closes)
- style.css: .form-group--checkbox and .checkbox-label styles
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.
#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
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.
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.
#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
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.
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.
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)
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
- 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
#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.
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.
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.
- 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
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.
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
- 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
- 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
- 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
- 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)
- 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
- 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
- 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)
- 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
- 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)
- 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
- 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
- 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
- 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)