From 83049786eb1bdb1de229aec47032388fb1a8e574 Mon Sep 17 00:00:00 2001 From: Gronod Date: Sat, 16 May 2026 15:07:50 +0100 Subject: [PATCH 01/22] security: fix issues #1-4 from security audit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #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. --- .dockerignore | 1 + server/index.js | 2 -- server/middleware/requireAuth.js | 14 ++++++++++++++ server/routes/auth.js | 12 +++++++++--- server/routes/emby.js | 3 +++ server/routes/radarr.js | 3 +++ server/routes/sabnzbd.js | 3 +++ server/routes/sonarr.js | 3 +++ 8 files changed, 36 insertions(+), 5 deletions(-) create mode 100644 server/middleware/requireAuth.js diff --git a/.dockerignore b/.dockerignore index c68e5f2..e2c8501 100644 --- a/.dockerignore +++ b/.dockerignore @@ -13,3 +13,4 @@ README.md .dockerignore Dockerfile .gitea/ +docs/ \ No newline at end of file diff --git a/server/index.js b/server/index.js index 7231319..cd8a215 100644 --- a/server/index.js +++ b/server/index.js @@ -1,5 +1,4 @@ const express = require('express'); -const cors = require('cors'); const path = require('path'); const cookieParser = require('cookie-parser'); const fs = require('fs'); @@ -59,7 +58,6 @@ 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(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..002af74 --- /dev/null +++ b/server/middleware/requireAuth.js @@ -0,0 +1,14 @@ +function requireAuth(req, res, next) { + const userCookie = req.cookies.emby_user; + if (!userCookie) { + return res.status(401).json({ error: 'Not authenticated' }); + } + try { + req.user = JSON.parse(userCookie); + } catch { + return res.status(401).json({ error: 'Invalid session' }); + } + next(); +} + +module.exports = requireAuth; diff --git a/server/routes/auth.js b/server/routes/auth.js index 7d488bc..0a2c8ba 100644 --- a/server/routes/auth.js +++ b/server/routes/auth.js @@ -37,14 +37,16 @@ router.post('/login', async (req, res) => { console.log(`[Auth] Login successful for user: ${user.Name}`); // Set authentication cookie + // Note: token is intentionally excluded from the cookie — it is not needed client-side const isAdmin = !!(user.Policy && user.Policy.IsAdministrator); res.cookie('emby_user', JSON.stringify({ id: user.Id, name: user.Name, - isAdmin: isAdmin, - token: authData.AccessToken + isAdmin: isAdmin }), { httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'strict', maxAge: 24 * 60 * 60 * 1000 // 24 hours }); @@ -91,7 +93,11 @@ router.get('/me', (req, res) => { // Logout router.post('/logout', (req, res) => { - res.clearCookie('emby_user'); + res.clearCookie('emby_user', { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'strict' + }); res.json({ success: true }); }); diff --git a/server/routes/emby.js b/server/routes/emby.js index 6dab4da..4854eef 100644 --- a/server/routes/emby.js +++ b/server/routes/emby.js @@ -1,10 +1,13 @@ const express = require('express'); const axios = require('axios'); const router = express.Router(); +const requireAuth = require('../middleware/requireAuth'); 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 { diff --git a/server/routes/radarr.js b/server/routes/radarr.js index 083670f..99115de 100644 --- a/server/routes/radarr.js +++ b/server/routes/radarr.js @@ -1,10 +1,13 @@ const express = require('express'); const axios = require('axios'); const router = express.Router(); +const requireAuth = require('../middleware/requireAuth'); 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 { diff --git a/server/routes/sabnzbd.js b/server/routes/sabnzbd.js index 0120ee3..ffa479b 100644 --- a/server/routes/sabnzbd.js +++ b/server/routes/sabnzbd.js @@ -1,10 +1,13 @@ const express = require('express'); const axios = require('axios'); const router = express.Router(); +const requireAuth = require('../middleware/requireAuth'); 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 { diff --git a/server/routes/sonarr.js b/server/routes/sonarr.js index 68ea828..e0989c0 100644 --- a/server/routes/sonarr.js +++ b/server/routes/sonarr.js @@ -1,10 +1,13 @@ const express = require('express'); const axios = require('axios'); const router = express.Router(); +const requireAuth = require('../middleware/requireAuth'); 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 { From de8563704a07e9ccb9913b940089dc56bb0b8f3f Mon Sep 17 00:00:00 2001 From: Gronod Date: Sat, 16 May 2026 15:08:44 +0100 Subject: [PATCH 02/22] security: ensure log files excluded recursively from git and Docker builds (issue #16) *.log only matched root-level logs; add **/*.log to cover server/server.log and any other subdirectory log files in both .gitignore and .dockerignore. --- .dockerignore | 1 + .gitignore | 1 + 2 files changed, 2 insertions(+) diff --git a/.dockerignore b/.dockerignore index e2c8501..7a09ebc 100644 --- a/.dockerignore +++ b/.dockerignore @@ -6,6 +6,7 @@ node_modules/ .gitignore .DS_Store *.log +**/*.log client/ dist/ build/ diff --git a/.gitignore b/.gitignore index fdd8c5a..606e68a 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ dist/ build/ .DS_Store *.log +**/*.log From 24b7797b60c9f6043d5da81ea0cea2d2c7d59979 Mon Sep 17 00:00:00 2001 From: Gronod Date: Sat, 16 May 2026 15:14:33 +0100 Subject: [PATCH 03/22] =?UTF-8?q?feat:=20multi-tag=20badges=20for=20showAl?= =?UTF-8?q?l=20=E2=80=94=20amber=20for=20unmatched,=20accent=20for=20match?= =?UTF-8?q?ed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- public/app.js | 19 ++++++--- public/style.css | 12 ++++++ server/routes/dashboard.js | 81 +++++++++++++++++++++++++++----------- 3 files changed, 83 insertions(+), 29 deletions(-) diff --git a/public/app.js b/public/app.js index ea037f6..81cc0c9 100644 --- a/public/app.js +++ b/public/app.js @@ -434,11 +434,20 @@ 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.allTags && download.allTags.length > 0) { + const unmatchedTags = download.allTags.filter(t => t !== download.matchedUserTag); + for (const tag of unmatchedTags) { + const badge = document.createElement('span'); + badge.className = 'download-user-badge unmatched'; + badge.textContent = tag; + header.appendChild(badge); + } + if (download.matchedUserTag) { + 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/style.css b/public/style.css index 178fd11..a8a04b1 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 ===== */ @@ -734,6 +740,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/routes/dashboard.js b/server/routes/dashboard.js index d537adc..7fc6e78 100644 --- a/server/routes/dashboard.js +++ b/server/routes/dashboard.js @@ -40,6 +40,15 @@ function extractUserTag(tags, tagMap) { return userTag ? userTag.label : null; } +// Return all resolved tag labels for a series/movie +function extractAllTags(tags, tagMap) { + if (!tags || tags.length === 0) return []; + if (tagMap) { + return tags.map(id => tagMap.get(id)).filter(Boolean); + } + return tags.map(t => t && t.label).filter(Boolean); +} + // Replicate Ombi's StringHelper.SanitizeTagLabel: lowercase, replace non-alphanumeric with hyphen, collapse, trim function sanitizeTagLabel(input) { if (!input) return ''; @@ -224,8 +233,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 matchedUserTag = extractUserTag(series.tags, sonarrTagMap); + const allTags = extractAllTags(series.tags, sonarrTagMap); + const hasAnyTag = allTags.length > 0; + if (showAll ? hasAnyTag : (matchedUserTag && tagMatchesUser(matchedUserTag, username))) { const dlObj = { type: 'series', title: nzbName, @@ -239,7 +250,8 @@ router.get('/user-downloads', async (req, res) => { eta: slot.timeleft, seriesName: series.title, episodeInfo: sonarrMatch, - userTag: userTag + allTags, + matchedUserTag: matchedUserTag || null }; const issues = getImportIssues(sonarrMatch); if (issues) dlObj.importIssues = issues; @@ -262,8 +274,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 matchedUserTag = extractUserTag(movie.tags, radarrTagMap); + const allTags = extractAllTags(movie.tags, radarrTagMap); + const hasAnyTag = allTags.length > 0; + if (showAll ? hasAnyTag : (matchedUserTag && tagMatchesUser(matchedUserTag, username))) { const dlObj = { type: 'movie', title: nzbName, @@ -277,7 +291,8 @@ router.get('/user-downloads', async (req, res) => { eta: slot.timeleft, movieName: movie.title, movieInfo: radarrMatch, - userTag: userTag + allTags, + matchedUserTag: matchedUserTag || null }; const issues = getImportIssues(radarrMatch); if (issues) dlObj.importIssues = issues; @@ -317,8 +332,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 matchedUserTag = extractUserTag(series.tags, sonarrTagMap); + const allTags = extractAllTags(series.tags, sonarrTagMap); + const hasAnyTag = allTags.length > 0; + if (showAll ? hasAnyTag : (matchedUserTag && tagMatchesUser(matchedUserTag, username))) { const dlObj = { type: 'series', title: nzbName, @@ -328,7 +345,8 @@ router.get('/user-downloads', async (req, res) => { completedAt: slot.completed_time, seriesName: series.title, episodeInfo: sonarrMatch, - userTag: userTag + allTags, + matchedUserTag: matchedUserTag || null }; if (isAdmin) { dlObj.downloadPath = slot.storage || null; @@ -349,8 +367,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 matchedUserTag = extractUserTag(movie.tags, radarrTagMap); + const allTags = extractAllTags(movie.tags, radarrTagMap); + const hasAnyTag = allTags.length > 0; + if (showAll ? hasAnyTag : (matchedUserTag && tagMatchesUser(matchedUserTag, username))) { const dlObj = { type: 'movie', title: nzbName, @@ -360,7 +380,8 @@ router.get('/user-downloads', async (req, res) => { completedAt: slot.completed_time, movieName: movie.title, movieInfo: radarrMatch, - userTag: userTag + allTags, + matchedUserTag: matchedUserTag || null }; if (isAdmin) { dlObj.downloadPath = slot.storage || null; @@ -417,15 +438,18 @@ 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 matchedUserTag = extractUserTag(series.tags, sonarrTagMap); + const allTags = extractAllTags(series.tags, sonarrTagMap); + const hasAnyTag = allTags.length > 0; + if (showAll ? hasAnyTag : (matchedUserTag && tagMatchesUser(matchedUserTag, username))) { 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; const sonarrIssues = getImportIssues(sonarrMatch); if (sonarrIssues) download.importIssues = sonarrIssues; if (isAdmin) { @@ -448,15 +472,18 @@ 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 matchedUserTag = extractUserTag(movie.tags, radarrTagMap); + const allTags = extractAllTags(movie.tags, radarrTagMap); + const hasAnyTag = allTags.length > 0; + if (showAll ? hasAnyTag : (matchedUserTag && tagMatchesUser(matchedUserTag, username))) { 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; const radarrIssues = getImportIssues(radarrMatch); if (radarrIssues) download.importIssues = radarrIssues; if (isAdmin) { @@ -479,15 +506,18 @@ 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 matchedUserTag = extractUserTag(series.tags, sonarrTagMap); + const allTags = extractAllTags(series.tags, sonarrTagMap); + const hasAnyTag = allTags.length > 0; + if (showAll ? hasAnyTag : (matchedUserTag && tagMatchesUser(matchedUserTag, username))) { 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; if (isAdmin) { download.downloadPath = download.savePath || null; download.targetPath = series.path || null; @@ -508,15 +538,18 @@ 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 matchedUserTag = extractUserTag(movie.tags, radarrTagMap); + const allTags = extractAllTags(movie.tags, radarrTagMap); + const hasAnyTag = allTags.length > 0; + if (showAll ? hasAnyTag : (matchedUserTag && tagMatchesUser(matchedUserTag, username))) { 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; if (isAdmin) { download.downloadPath = download.savePath || null; download.targetPath = movie.path || null; From 43839fd8e35f7fab8868882f9ec0c373f503e716 Mon Sep 17 00:00:00 2001 From: Gronod Date: Sat, 16 May 2026 15:16:44 +0100 Subject: [PATCH 04/22] fix: always show matched user tag badge, not just in showAll mode Unmatched amber badges still only appear when showAll is active. --- public/app.js | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/public/app.js b/public/app.js index 81cc0c9..229d2e0 100644 --- a/public/app.js +++ b/public/app.js @@ -442,12 +442,12 @@ function createDownloadCard(download) { badge.textContent = tag; header.appendChild(badge); } - if (download.matchedUserTag) { - const matchedBadge = document.createElement('span'); - matchedBadge.className = 'download-user-badge'; - matchedBadge.textContent = download.matchedUserTag; - header.appendChild(matchedBadge); - } + } + if (download.matchedUserTag) { + const matchedBadge = document.createElement('span'); + matchedBadge.className = 'download-user-badge'; + matchedBadge.textContent = download.matchedUserTag; + header.appendChild(matchedBadge); } const details = document.createElement('div'); From 1f4aa19a7295fda0e62012ad4fbcfe22061e7435 Mon Sep 17 00:00:00 2001 From: Gronod Date: Sat, 16 May 2026 15:24:12 +0100 Subject: [PATCH 05/22] fix: extractUserTag now correctly finds the tag matching the current user MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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/routes/dashboard.js | 97 +++++++++++++++++--------------------- 1 file changed, 42 insertions(+), 55 deletions(-) diff --git a/server/routes/dashboard.js b/server/routes/dashboard.js index 7fc6e78..5256b7e 100644 --- a/server/routes/dashboard.js +++ b/server/routes/dashboard.js @@ -20,27 +20,9 @@ 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 - if (tagMap) { - for (const tagId of tags) { - const label = tagMap.get(tagId); - if (label) return label; - } - return null; - } - - // Sonarr style - tags are objects with label - const userTag = tags.find(tag => tag && tag.label); - return userTag ? userTag.label : null; -} - -// Return all resolved tag labels for a series/movie +// 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) { @@ -49,6 +31,17 @@ function extractAllTags(tags, tagMap) { 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 function sanitizeTagLabel(input) { if (!input) return ''; @@ -233,10 +226,10 @@ router.get('/user-downloads', async (req, res) => { if (sonarrMatch && sonarrMatch.seriesId) { const series = seriesMap.get(sonarrMatch.seriesId) || sonarrMatch.series; if (series) { - const matchedUserTag = extractUserTag(series.tags, sonarrTagMap); const allTags = extractAllTags(series.tags, sonarrTagMap); + const matchedUserTag = extractUserTag(series.tags, sonarrTagMap, username); const hasAnyTag = allTags.length > 0; - if (showAll ? hasAnyTag : (matchedUserTag && tagMatchesUser(matchedUserTag, username))) { + if (showAll ? hasAnyTag : !!matchedUserTag) { const dlObj = { type: 'series', title: nzbName, @@ -274,10 +267,10 @@ router.get('/user-downloads', async (req, res) => { if (radarrMatch && radarrMatch.movieId) { const movie = moviesMap.get(radarrMatch.movieId) || radarrMatch.movie; if (movie) { - const matchedUserTag = extractUserTag(movie.tags, radarrTagMap); const allTags = extractAllTags(movie.tags, radarrTagMap); + const matchedUserTag = extractUserTag(movie.tags, radarrTagMap, username); const hasAnyTag = allTags.length > 0; - if (showAll ? hasAnyTag : (matchedUserTag && tagMatchesUser(matchedUserTag, username))) { + if (showAll ? hasAnyTag : !!matchedUserTag) { const dlObj = { type: 'movie', title: nzbName, @@ -332,10 +325,10 @@ router.get('/user-downloads', async (req, res) => { if (sonarrMatch && sonarrMatch.seriesId) { const series = seriesMap.get(sonarrMatch.seriesId) || sonarrMatch.series; if (series) { - const matchedUserTag = extractUserTag(series.tags, sonarrTagMap); const allTags = extractAllTags(series.tags, sonarrTagMap); + const matchedUserTag = extractUserTag(series.tags, sonarrTagMap, username); const hasAnyTag = allTags.length > 0; - if (showAll ? hasAnyTag : (matchedUserTag && tagMatchesUser(matchedUserTag, username))) { + if (showAll ? hasAnyTag : !!matchedUserTag) { const dlObj = { type: 'series', title: nzbName, @@ -367,10 +360,10 @@ router.get('/user-downloads', async (req, res) => { if (radarrMatch && radarrMatch.movieId) { const movie = moviesMap.get(radarrMatch.movieId) || radarrMatch.movie; if (movie) { - const matchedUserTag = extractUserTag(movie.tags, radarrTagMap); const allTags = extractAllTags(movie.tags, radarrTagMap); + const matchedUserTag = extractUserTag(movie.tags, radarrTagMap, username); const hasAnyTag = allTags.length > 0; - if (showAll ? hasAnyTag : (matchedUserTag && tagMatchesUser(matchedUserTag, username))) { + if (showAll ? hasAnyTag : !!matchedUserTag) { const dlObj = { type: 'movie', title: nzbName, @@ -409,12 +402,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)); @@ -438,10 +429,10 @@ router.get('/user-downloads', async (req, res) => { if (sonarrMatch && sonarrMatch.seriesId) { const series = seriesMap.get(sonarrMatch.seriesId) || sonarrMatch.series; if (series) { - const matchedUserTag = extractUserTag(series.tags, sonarrTagMap); const allTags = extractAllTags(series.tags, sonarrTagMap); + const matchedUserTag = extractUserTag(series.tags, sonarrTagMap, username); const hasAnyTag = allTags.length > 0; - if (showAll ? hasAnyTag : (matchedUserTag && tagMatchesUser(matchedUserTag, username))) { + if (showAll ? hasAnyTag : !!matchedUserTag) { console.log(`[Dashboard] Matched torrent "${torrentName}" to Sonarr series "${series.title}"`); const download = mapTorrentToDownload(torrent); download.type = 'series'; @@ -472,10 +463,10 @@ router.get('/user-downloads', async (req, res) => { if (radarrMatch && radarrMatch.movieId) { const movie = moviesMap.get(radarrMatch.movieId) || radarrMatch.movie; if (movie) { - const matchedUserTag = extractUserTag(movie.tags, radarrTagMap); const allTags = extractAllTags(movie.tags, radarrTagMap); + const matchedUserTag = extractUserTag(movie.tags, radarrTagMap, username); const hasAnyTag = allTags.length > 0; - if (showAll ? hasAnyTag : (matchedUserTag && tagMatchesUser(matchedUserTag, username))) { + if (showAll ? hasAnyTag : !!matchedUserTag) { console.log(`[Dashboard] Matched torrent "${torrentName}" to Radarr movie "${movie.title}"`); const download = mapTorrentToDownload(torrent); download.type = 'movie'; @@ -506,10 +497,10 @@ router.get('/user-downloads', async (req, res) => { if (sonarrHistoryMatch && sonarrHistoryMatch.seriesId) { const series = seriesMap.get(sonarrHistoryMatch.seriesId) || sonarrHistoryMatch.series; if (series) { - const matchedUserTag = extractUserTag(series.tags, sonarrTagMap); const allTags = extractAllTags(series.tags, sonarrTagMap); + const matchedUserTag = extractUserTag(series.tags, sonarrTagMap, username); const hasAnyTag = allTags.length > 0; - if (showAll ? hasAnyTag : (matchedUserTag && tagMatchesUser(matchedUserTag, username))) { + if (showAll ? hasAnyTag : !!matchedUserTag) { console.log(`[Dashboard] Matched torrent "${torrentName}" to Sonarr history "${series.title}"`); const download = mapTorrentToDownload(torrent); download.type = 'series'; @@ -538,10 +529,10 @@ router.get('/user-downloads', async (req, res) => { if (radarrHistoryMatch && radarrHistoryMatch.movieId) { const movie = moviesMap.get(radarrHistoryMatch.movieId) || radarrHistoryMatch.movie; if (movie) { - const matchedUserTag = extractUserTag(movie.tags, radarrTagMap); const allTags = extractAllTags(movie.tags, radarrTagMap); + const matchedUserTag = extractUserTag(movie.tags, radarrTagMap, username); const hasAnyTag = allTags.length > 0; - if (showAll ? hasAnyTag : (matchedUserTag && tagMatchesUser(matchedUserTag, username))) { + if (showAll ? hasAnyTag : !!matchedUserTag) { console.log(`[Dashboard] Matched torrent "${torrentName}" to Radarr history "${movie.title}"`); const download = mapTorrentToDownload(torrent); download.type = 'movie'; @@ -636,24 +627,20 @@ 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)); From 8b81f16dacf799cf6b5780351f7f7d2e4db1bfda Mon Sep 17 00:00:00 2001 From: Gronod Date: Sat, 16 May 2026 15:29:50 +0100 Subject: [PATCH 06/22] fix: proper multi-user tag badges using full Emby user list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- public/app.js | 22 +++++++++++----- server/routes/dashboard.js | 54 +++++++++++++++++++++++++++++++++++--- 2 files changed, 66 insertions(+), 10 deletions(-) diff --git a/public/app.js b/public/app.js index 229d2e0..28e9b9c 100644 --- a/public/app.js +++ b/public/app.js @@ -434,16 +434,26 @@ function createDownloadCard(download) { infoDiv.appendChild(movie); } - if (showAll && download.allTags && download.allTags.length > 0) { - const unmatchedTags = download.allTags.filter(t => t !== download.matchedUserTag); - for (const tag of unmatchedTags) { + 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 = tag; + badge.textContent = b.label; header.appendChild(badge); } - } - if (download.matchedUserTag) { + 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; diff --git a/server/routes/dashboard.js b/server/routes/dashboard.js index 5256b7e..3f93c78 100644 --- a/server/routes/dashboard.js +++ b/server/routes/dashboard.js @@ -94,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(`${EMBY_URL}/Users`, { + headers: { 'X-MediaBrowser-Token': 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 @@ -181,6 +216,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 @@ -244,7 +282,8 @@ router.get('/user-downloads', async (req, res) => { seriesName: series.title, episodeInfo: sonarrMatch, allTags, - matchedUserTag: matchedUserTag || null + matchedUserTag: matchedUserTag || null, + tagBadges: showAll ? buildTagBadges(allTags, embyUserMap) : undefined }; const issues = getImportIssues(sonarrMatch); if (issues) dlObj.importIssues = issues; @@ -285,7 +324,8 @@ router.get('/user-downloads', async (req, res) => { movieName: movie.title, movieInfo: radarrMatch, allTags, - matchedUserTag: matchedUserTag || null + matchedUserTag: matchedUserTag || null, + tagBadges: showAll ? buildTagBadges(allTags, embyUserMap) : undefined }; const issues = getImportIssues(radarrMatch); if (issues) dlObj.importIssues = issues; @@ -339,7 +379,8 @@ router.get('/user-downloads', async (req, res) => { seriesName: series.title, episodeInfo: sonarrMatch, allTags, - matchedUserTag: matchedUserTag || null + matchedUserTag: matchedUserTag || null, + tagBadges: showAll ? buildTagBadges(allTags, embyUserMap) : undefined }; if (isAdmin) { dlObj.downloadPath = slot.storage || null; @@ -374,7 +415,8 @@ router.get('/user-downloads', async (req, res) => { movieName: movie.title, movieInfo: radarrMatch, allTags, - matchedUserTag: matchedUserTag || null + matchedUserTag: matchedUserTag || null, + tagBadges: showAll ? buildTagBadges(allTags, embyUserMap) : undefined }; if (isAdmin) { dlObj.downloadPath = slot.storage || null; @@ -441,6 +483,7 @@ router.get('/user-downloads', async (req, res) => { download.episodeInfo = sonarrMatch; 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) { @@ -475,6 +518,7 @@ router.get('/user-downloads', async (req, res) => { download.movieInfo = radarrMatch; 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) { @@ -509,6 +553,7 @@ router.get('/user-downloads', async (req, res) => { download.episodeInfo = sonarrHistoryMatch; 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; @@ -541,6 +586,7 @@ router.get('/user-downloads', async (req, res) => { download.movieInfo = radarrHistoryMatch; 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; From 54647ab7cf2ca0b0984936e32464333dfd505c0b Mon Sep 17 00:00:00 2001 From: Gronod Date: Sat, 16 May 2026 15:34:24 +0100 Subject: [PATCH 07/22] feat: add favicon from sofarr-logoonly.png 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. --- public/favicon-192.png | Bin 0 -> 11225 bytes public/favicon-32.png | Bin 0 -> 1071 bytes public/favicon.ico | Bin 0 -> 543 bytes public/index.html | 4 ++++ 4 files changed, 4 insertions(+) create mode 100644 public/favicon-192.png create mode 100644 public/favicon-32.png create mode 100644 public/favicon.ico diff --git a/public/favicon-192.png b/public/favicon-192.png new file mode 100644 index 0000000000000000000000000000000000000000..ac9ff2cb4aa9abf1eba5449c9cb783422b1ab0de GIT binary patch literal 11225 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 literal 0 HcmV?d00001 diff --git a/public/favicon-32.png b/public/favicon-32.png new file mode 100644 index 0000000000000000000000000000000000000000..36850a40fd4ceba650ef9c22d3568dbf3765d2ad GIT binary patch literal 1071 zcmV+~1kn45P)zu(-R{o(eAv4fV+^@ipUk(+?lSyle&6r! z46un!{NKSk=T}0m7Lf$SlCDc$gz$a!Z&aFP|HP>Q3jpXpn8vn>!*qeb=rWP6`XMB)WvgWgD7?fVKr;MxX{Jgd^#J&0omhysYjyyWfRK!QpjqO=1ku2MO2=`H{B z+8*^2D3&z3&Pn?(=!Mp=Yh#Kgnwyn4B8^wIDq9s_NQe;1izO~+uDJto_==nVeb>YX zuPuN?y`C`WI!6S7#etV@*1#uYCl38k$NJNS!QmIs*pvr(0V1rDsDl6kmp>46D{bx; zqBCE7s%f$-pO_j1vZg`)5bXq@LbKs&Q0-$wCuzK4CvlS~J*%6q{iyZlg~mTcDE~Nu zz=@z1cdom5mlF_j=dUMD-#hWf;YTkWe}0bxJ!P`DBM2OPe~m4L06+&*ab*WU;18{v zf{3tGDrp5^rgh(a4Jef~d{H2$;wVlm8XYB)O8NWf>D2%T!B<`;Gu*oGxg16V>UHJ- zNf{Uzu*?q*pTV^4o;(%I0Z?sE9{rleEFy}hgkppcI#w%DkT{$W1t_6_;!&F&%DU0W z##0^5+33{=cS)hKQ9^6E7)A|NL{mryp#ng{nUtrZO66Tcp+qQa0LbqTo{=z|bmj7g z(9}xc2o2B?&;TqF2CCY5G$10@ExbqSEUC>;W0&#r$6vsXXig%EeC=ii2M5Q_Y8$LXC%A=VCtQt-)w#O;Gk-2 zzsp4tl#q65$;vLP{xYutKu81N*zSA6&bFKlt;6-rTV}32{m@>(BFS1!M`zQi^fnts zVxZ7kQDF{18O5SzOZ)p&sUMY+F)Dfr$|Xvelzh!>p@2z3kV1lt9B^Yxu6#?!R+Tkc zWdTlCVxtuupaF3tAqq+Ww;;5Sux-D8Or^3t;-VRtR0fss;>4RTKDL_M{tFNQS{^@i zo%Xy2MPn!4K777<#; pjiahemZdbW@-=G{o7lwK_yaC8uhl(0kjDT3002ovPDHLkV1h->^J)M9 literal 0 HcmV?d00001 diff --git a/public/favicon.ico b/public/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..5066b26728e3f476811d0906e54b8f3b4068a674 GIT binary patch literal 543 zcmV+)0^t1s0096201yxW0000W00{yB02TlM0EtjeM-2)Z3IG5A4M|8uQUCw|5C8xG z5C{eU001BJ|6u?C0nkZAK~#90#ge~klTj3gpL6d`lbTi~ZEAy9Ekr7DaB-1N3eHk# zGf78_f`j7ZV2I%0AK<2RaSJ#KPL>F@f*D+N2>v3C)e=QZYxCv%?mZ4wDA=t?-}M|G zo^yC%uOk4c3gw#Faidlafc{5=O2v$}k|U-+doFmN{2f?h@qiJYe7=9{8>oQ%V`8XQ z12%OrIJ1-rEIO0Cu&PVO`bDd{tZ|qEgqfPzf*_!RssOnB+?`qJ%M`;#*R&qoez%>T zzgqb!*u#KQEBo!@f4+y45QbJr-^iQjdhS}Kyo?Ou?xNgFqY*A&BW4adCWi{`uSW<<-q_DBJgO z^k8;r;aD()5T!LoOCy*OX+lqIO?UH5&K>e zGv!rkossA@Z!na$2uBwd-0;MNZ+s}|!}&`JNVD0r?RHzD!ZFT{k9T)fB2^V(X2t?g hDk;@!*cEY4@*8j4!sqosvA+NS002ovPDHLkV1o3r_Z9#E literal 0 HcmV?d00001 diff --git a/public/index.html b/public/index.html index 6c49234..6f5a0fc 100644 --- a/public/index.html +++ b/public/index.html @@ -4,6 +4,10 @@ sofarr - Your Downloads Dashboard + + + + From 6675e5dcfe1790edff548d934d1afa56d8bc5343 Mon Sep 17 00:00:00 2001 From: Gronod Date: Sat, 16 May 2026 15:41:23 +0100 Subject: [PATCH 08/22] docs: update architecture docs and diagrams for recent changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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() --- docs/ARCHITECTURE.md | 43 ++++++++++++++++++-------- docs/diagrams/activity-matching.puml | 46 ++++++++++++++++++++++------ docs/diagrams/class-data.puml | 15 +++++++-- docs/diagrams/class-server.puml | 26 +++++++++++++++- docs/diagrams/component.puml | 11 +++++-- docs/diagrams/seq-auth.puml | 2 +- docs/diagrams/seq-dashboard.puml | 27 ++++++++++++---- 7 files changed, 135 insertions(+), 35 deletions(-) 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 From 8f96a5f2968e35cd09b87475c74aff2336447e90 Mon Sep 17 00:00:00 2001 From: Gronod Date: Sat, 16 May 2026 16:17:43 +0100 Subject: [PATCH 09/22] fix(security #5): remove plaintext logging of Emby auth response and user object MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- public/images/sofarr-logoonly.png | Bin 0 -> 34794 bytes server/routes/auth.js | 4 +--- 2 files changed, 1 insertion(+), 3 deletions(-) create mode 100644 public/images/sofarr-logoonly.png diff --git a/public/images/sofarr-logoonly.png b/public/images/sofarr-logoonly.png new file mode 100644 index 0000000000000000000000000000000000000000..5485376da11d1053d00313d7bf345b38134afcf9 GIT binary patch literal 34794 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

&G53 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 Hu0mjfol { }); 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,8 +32,7 @@ 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}`); + console.log(`[Auth] Login successful for user: ${user.Name}, isAdmin: ${!!(user.Policy && user.Policy.IsAdministrator)}`); // Set authentication cookie // Note: token is intentionally excluded from the cookie — it is not needed client-side From 1eadb30481adb979bdd9a7efa6c1e47a88dba232 Mon Sep 17 00:00:00 2001 From: Gronod Date: Sat, 16 May 2026 16:18:34 +0100 Subject: [PATCH 10/22] 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. --- package-lock.json | 26 ++++++++++++++++++++++---- package.json | 13 +++++++------ server/routes/auth.js | 12 +++++++++--- 3 files changed, 38 insertions(+), 13 deletions(-) diff --git a/package-lock.json b/package-lock.json index 8192e97..3d7d50e 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,6 +14,7 @@ "cors": "^2.8.5", "dotenv": "^16.3.1", "express": "^4.18.2", + "express-rate-limit": "^6.7.0", "node-cron": "^3.0.3" }, "devDependencies": { @@ -623,6 +624,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", @@ -2124,6 +2136,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", diff --git a/package.json b/package.json index ff08aa8..02ebae3 100644 --- a/package.json +++ b/package.json @@ -9,16 +9,17 @@ "install:all": "npm install" }, "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", + "node-cron": "^3.0.3" }, "devDependencies": { - "nodemon": "^2.0.22", - "concurrently": "^7.6.0" + "concurrently": "^7.6.0", + "nodemon": "^2.0.22" }, "keywords": [ "sabnzbd", diff --git a/server/routes/auth.js b/server/routes/auth.js index 19127fa..aec4512 100644 --- a/server/routes/auth.js +++ b/server/routes/auth.js @@ -1,12 +1,18 @@ const express = require('express'); const axios = require('axios'); +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; +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; From d8584d05113917eeb178c2608e399722e268c73b Mon Sep 17 00:00:00 2001 From: Gronod Date: Sat, 16 May 2026 16:20:37 +0100 Subject: [PATCH 11/22] 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 --- server/index.js | 9 ++++- server/middleware/requireAuth.js | 13 +++++-- server/routes/auth.js | 59 +++++++++++++++++--------------- server/routes/dashboard.js | 21 ++++-------- 4 files changed, 55 insertions(+), 47 deletions(-) diff --git a/server/index.js b/server/index.js index cd8a215..a93508b 100644 --- a/server/index.js +++ b/server/index.js @@ -58,7 +58,14 @@ const { startPoller, POLL_INTERVAL, POLLING_ENABLED } = require('./utils/poller' const app = express(); const PORT = process.env.PORT || 3001; -app.use(cookieParser()); +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 index 002af74..2504fcb 100644 --- a/server/middleware/requireAuth.js +++ b/server/middleware/requireAuth.js @@ -1,13 +1,20 @@ function requireAuth(req, res, next) { - const userCookie = req.cookies.emby_user; - if (!userCookie) { + 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 { - req.user = JSON.parse(userCookie); + 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(); } diff --git a/server/routes/auth.js b/server/routes/auth.js index aec4512..70a37a9 100644 --- a/server/routes/auth.js +++ b/server/routes/auth.js @@ -40,17 +40,17 @@ router.post('/login', loginLimiter, async (req, res) => { const user = userResponse.data; console.log(`[Auth] Login successful for user: ${user.Name}, isAdmin: ${!!(user.Policy && user.Policy.IsAdministrator)}`); - // Set authentication cookie - // Note: token is intentionally excluded from the cookie — it is not needed client-side + // Set authentication cookie. + // Note: token is intentionally excluded from the cookie — it is not needed client-side. + // Cookie is signed when COOKIE_SECRET is set (recommended in production). const isAdmin = !!(user.Policy && user.Policy.IsAdministrator); - res.cookie('emby_user', JSON.stringify({ - id: user.Id, - name: user.Name, - isAdmin: isAdmin - }), { + const cookiePayload = JSON.stringify({ id: user.Id, name: user.Name, isAdmin }); + const signed = !!process.env.COOKIE_SECRET; + res.cookie('emby_user', cookiePayload, { httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'strict', + signed, maxAge: 24 * 60 * 60 * 1000 // 24 hours }); @@ -71,28 +71,30 @@ router.post('/login', loginLimiter, 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 @@ -100,7 +102,8 @@ router.post('/logout', (req, res) => { res.clearCookie('emby_user', { httpOnly: true, secure: process.env.NODE_ENV === 'production', - sameSite: 'strict' + sameSite: 'strict', + signed: !!process.env.COOKIE_SECRET }); res.json({ success: true }); }); diff --git a/server/routes/dashboard.js b/server/routes/dashboard.js index 3f93c78..cebb4fa 100644 --- a/server/routes/dashboard.js +++ b/server/routes/dashboard.js @@ -1,5 +1,6 @@ const express = require('express'); const router = express.Router(); +const requireAuth = require('../middleware/requireAuth'); const axios = require('axios'); const { mapTorrentToDownload } = require('../utils/qbittorrent'); @@ -143,15 +144,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; @@ -622,7 +617,7 @@ router.get('/user-downloads', async (req, res) => { }); // 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(); @@ -696,13 +691,9 @@ router.get('/user-summary', async (req, res) => { }); // 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' }); } From 8fa20c699039d185e6625619db5a77e39a66c753 Mon Sep 17 00:00:00 2001 From: Gronod Date: Sat, 16 May 2026 16:22:11 +0100 Subject: [PATCH 12/22] 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. --- server/routes/dashboard.js | 5 +++-- server/routes/emby.js | 9 +++++---- server/routes/radarr.js | 9 +++++---- server/routes/sabnzbd.js | 5 +++-- server/routes/sonarr.js | 9 +++++---- server/utils/sanitizeError.js | 15 +++++++++++++++ 6 files changed, 36 insertions(+), 16 deletions(-) create mode 100644 server/utils/sanitizeError.js diff --git a/server/routes/dashboard.js b/server/routes/dashboard.js index cebb4fa..0564107 100644 --- a/server/routes/dashboard.js +++ b/server/routes/dashboard.js @@ -7,6 +7,7 @@ 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; @@ -612,7 +613,7 @@ router.get('/user-downloads', requireAuth, 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) }); } }); @@ -686,7 +687,7 @@ router.get('/user-summary', requireAuth, async (req, res) => { 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) }); } }); diff --git a/server/routes/emby.js b/server/routes/emby.js index 4854eef..da69e12 100644 --- a/server/routes/emby.js +++ b/server/routes/emby.js @@ -2,6 +2,7 @@ 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; @@ -16,7 +17,7 @@ router.get('/sessions', async (req, res) => { }); 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) }); } }); @@ -28,7 +29,7 @@ router.get('/users/:id', async (req, res) => { }); 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) }); } }); @@ -40,7 +41,7 @@ router.get('/users', async (req, res) => { }); 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) }); } }); @@ -62,7 +63,7 @@ router.get('/session/:sessionId/user', async (req, res) => { 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 99115de..9c5ced7 100644 --- a/server/routes/radarr.js +++ b/server/routes/radarr.js @@ -2,6 +2,7 @@ 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; @@ -16,7 +17,7 @@ router.get('/queue', async (req, res) => { }); 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) }); } }); @@ -29,7 +30,7 @@ router.get('/history', async (req, res) => { }); 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) }); } }); @@ -41,7 +42,7 @@ router.get('/movies/:id', async (req, res) => { }); 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) }); } }); @@ -53,7 +54,7 @@ router.get('/movies', async (req, res) => { }); 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 ffa479b..4515f5e 100644 --- a/server/routes/sabnzbd.js +++ b/server/routes/sabnzbd.js @@ -2,6 +2,7 @@ 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; @@ -20,7 +21,7 @@ router.get('/queue', async (req, res) => { }); 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) }); } }); @@ -37,7 +38,7 @@ router.get('/history', async (req, res) => { }); 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 e0989c0..255f801 100644 --- a/server/routes/sonarr.js +++ b/server/routes/sonarr.js @@ -2,6 +2,7 @@ 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; @@ -16,7 +17,7 @@ router.get('/queue', async (req, res) => { }); 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) }); } }); @@ -29,7 +30,7 @@ router.get('/history', async (req, res) => { }); 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) }); } }); @@ -41,7 +42,7 @@ router.get('/series/:id', async (req, res) => { }); 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) }); } }); @@ -53,7 +54,7 @@ router.get('/series', async (req, res) => { }); 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; From 1f41114482e9533e52a6ddbe5545dd442c0925d8 Mon Sep 17 00:00:00 2001 From: Gronod Date: Sat, 16 May 2026 16:22:36 +0100 Subject: [PATCH 13/22] 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. --- package-lock.json | 36 +----------------------------------- package.json | 3 +-- 2 files changed, 2 insertions(+), 37 deletions(-) diff --git a/package-lock.json b/package-lock.json index 3d7d50e..03f21d9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,8 +14,7 @@ "cors": "^2.8.5", "dotenv": "^16.3.1", "express": "^4.18.2", - "express-rate-limit": "^6.7.0", - "node-cron": "^3.0.3" + "express-rate-limit": "^6.7.0" }, "devDependencies": { "concurrently": "^7.6.0", @@ -1074,17 +1073,6 @@ "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", @@ -1619,15 +1607,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", @@ -2436,14 +2415,6 @@ "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", @@ -2825,11 +2796,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 02ebae3..90d4b91 100644 --- a/package.json +++ b/package.json @@ -14,8 +14,7 @@ "cors": "^2.8.5", "dotenv": "^16.3.1", "express": "^4.18.2", - "express-rate-limit": "^6.7.0", - "node-cron": "^3.0.3" + "express-rate-limit": "^6.7.0" }, "devDependencies": { "concurrently": "^7.6.0", From b608fa0337bc005c15ea4652a51e0c88fb913ccd Mon Sep 17 00:00:00 2001 From: Gronod Date: Sat, 16 May 2026 16:23:47 +0100 Subject: [PATCH 14/22] 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. --- package-lock.json | 16 +++++++++++++++- package.json | 3 ++- server/index.js | 5 +++++ 3 files changed, 22 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index 03f21d9..f0963f0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,7 +14,8 @@ "cors": "^2.8.5", "dotenv": "^16.3.1", "express": "^4.18.2", - "express-rate-limit": "^6.7.0" + "express-rate-limit": "^6.7.0", + "helmet": "^4.6.0" }, "devDependencies": { "concurrently": "^7.6.0", @@ -847,6 +848,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", @@ -2256,6 +2265,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", diff --git a/package.json b/package.json index 90d4b91..3db372a 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,8 @@ "cors": "^2.8.5", "dotenv": "^16.3.1", "express": "^4.18.2", - "express-rate-limit": "^6.7.0" + "express-rate-limit": "^6.7.0", + "helmet": "^4.6.0" }, "devDependencies": { "concurrently": "^7.6.0", diff --git a/server/index.js b/server/index.js index a93508b..fd59923 100644 --- a/server/index.js +++ b/server/index.js @@ -1,6 +1,7 @@ const express = require('express'); const path = require('path'); const cookieParser = require('cookie-parser'); +const helmet = require('helmet'); const fs = require('fs'); require('dotenv').config(); @@ -58,6 +59,10 @@ const { startPoller, POLL_INTERVAL, POLLING_ENABLED } = require('./utils/poller' const app = express(); const PORT = process.env.PORT || 3001; +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!'); From bdfb042527884e24b9fdc350f0f4ee7b6e887a1a Mon Sep 17 00:00:00 2001 From: Gronod Date: Sat, 16 May 2026 16:25:05 +0100 Subject: [PATCH 15/22] 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 --- server/routes/auth.js | 61 ++++++++++++++++++++++++++++++++++--------- 1 file changed, 48 insertions(+), 13 deletions(-) diff --git a/server/routes/auth.js b/server/routes/auth.js index 70a37a9..1e446ad 100644 --- a/server/routes/auth.js +++ b/server/routes/auth.js @@ -1,8 +1,28 @@ 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; + +// 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, @@ -18,13 +38,16 @@ router.post('/login', loginLimiter, async (req, res) => { 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"` } }); @@ -38,12 +61,13 @@ router.post('/login', loginLimiter, async (req, res) => { }); const user = userResponse.data; - console.log(`[Auth] Login successful for user: ${user.Name}, isAdmin: ${!!(user.Policy && user.Policy.IsAdministrator)}`); - - // Set authentication cookie. - // Note: token is intentionally excluded from the cookie — it is not needed client-side. - // Cookie is signed when COOKIE_SECRET is set (recommended in production). const isAdmin = !!(user.Policy && user.Policy.IsAdministrator); + 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). const cookiePayload = JSON.stringify({ id: user.Id, name: user.Name, isAdmin }); const signed = !!process.env.COOKIE_SECRET; res.cookie('emby_user', cookiePayload, { @@ -56,11 +80,7 @@ router.post('/login', loginLimiter, async (req, res) => { 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); @@ -98,7 +118,22 @@ router.get('/me', (req, res) => { }); // Logout -router.post('/logout', (req, res) => { +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', From 44cff5bf4156d66c0c8611c758d29977dc5b3e1d Mon Sep 17 00:00:00 2001 From: Gronod Date: Sat, 16 May 2026 16:26:53 +0100 Subject: [PATCH 16/22] 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. --- server/routes/dashboard.js | 10 ++++------ server/routes/emby.js | 23 ++++++++++------------- server/routes/radarr.js | 19 ++++++++----------- server/routes/sabnzbd.js | 11 ++++------- server/routes/sonarr.js | 19 ++++++++----------- 5 files changed, 34 insertions(+), 48 deletions(-) diff --git a/server/routes/dashboard.js b/server/routes/dashboard.js index 0564107..3e7576a 100644 --- a/server/routes/dashboard.js +++ b/server/routes/dashboard.js @@ -9,8 +9,6 @@ const { pollAllServices, getLastPollTimings, POLLING_ENABLED } = require('../uti 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) { @@ -102,8 +100,8 @@ async function getEmbyUsers() { const cached = cache.get('emby:users'); if (cached) return cached; 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 } }); // Build map: both raw lowercase and sanitized form -> display name const map = new Map(); @@ -624,8 +622,8 @@ router.get('/user-summary', requireAuth, async (req, res) => { 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 diff --git a/server/routes/emby.js b/server/routes/emby.js index da69e12..f349e7f 100644 --- a/server/routes/emby.js +++ b/server/routes/emby.js @@ -4,16 +4,13 @@ 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) { @@ -24,8 +21,8 @@ router.get('/sessions', async (req, res) => { // 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) { @@ -36,8 +33,8 @@ router.get('/users/:id', async (req, res) => { // 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) { @@ -48,8 +45,8 @@ router.get('/users', async (req, res) => { // 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); @@ -57,8 +54,8 @@ 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); diff --git a/server/routes/radarr.js b/server/routes/radarr.js index 9c5ced7..e9f348b 100644 --- a/server/routes/radarr.js +++ b/server/routes/radarr.js @@ -4,16 +4,13 @@ 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) { @@ -24,8 +21,8 @@ router.get('/queue', async (req, res) => { // 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); @@ -37,8 +34,8 @@ router.get('/history', async (req, res) => { // 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) { @@ -49,8 +46,8 @@ router.get('/movies/:id', async (req, res) => { // 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) { diff --git a/server/routes/sabnzbd.js b/server/routes/sabnzbd.js index 4515f5e..19ff152 100644 --- a/server/routes/sabnzbd.js +++ b/server/routes/sabnzbd.js @@ -4,18 +4,15 @@ 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' } }); @@ -28,10 +25,10 @@ router.get('/queue', async (req, res) => { // 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 } diff --git a/server/routes/sonarr.js b/server/routes/sonarr.js index 255f801..889b9ab 100644 --- a/server/routes/sonarr.js +++ b/server/routes/sonarr.js @@ -4,16 +4,13 @@ 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) { @@ -24,8 +21,8 @@ router.get('/queue', async (req, res) => { // 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); @@ -37,8 +34,8 @@ router.get('/history', async (req, res) => { // 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) { @@ -49,8 +46,8 @@ router.get('/series/:id', async (req, res) => { // 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) { From 14de5e46449681fd2b47991edff6e1f37c79a4ae Mon Sep 17 00:00:00 2001 From: Gronod Date: Sat, 16 May 2026 16:27:33 +0100 Subject: [PATCH 17/22] fix(security #17): add npm audit to CI pipeline and package scripts Added .gitea/workflows/ci.yml which runs 'npm audit --audit-level=moderate' on every push and PR. Fails the build on any moderate or higher severity finding. Also added 'npm run audit' and 'npm run audit:fix' convenience scripts to package.json for local use. --- .gitea/workflows/ci.yml | 26 ++++++++++++++++++++++++++ package.json | 4 +++- 2 files changed, 29 insertions(+), 1 deletion(-) create mode 100644 .gitea/workflows/ci.yml 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/package.json b/package.json index 3db372a..59d5e53 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,9 @@ "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": { "axios": "^1.6.0", From 663826e295c9b156660c04463e9f0040aa0ba97e Mon Sep 17 00:00:00 2001 From: Gronod Date: Sat, 16 May 2026 17:07:43 +0100 Subject: [PATCH 18/22] chore: add COOKIE_SECRET to .env, .env.example, .env.sample Generated a 64-char hex secret (openssl rand -hex 32 equivalent) and added it to .env. Updated .env.example and .env.sample with the new required variable and a generation hint. This is the production secret for HMAC-signing the emby_user session cookie. --- .env.example | 4 ++++ .env.sample | 5 +++++ 2 files changed, 9 insertions(+) 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. From 031877e6a062c8ab99ba5b7280f446588509d0ce Mon Sep 17 00:00:00 2001 From: Gronod Date: Sat, 16 May 2026 17:11:24 +0100 Subject: [PATCH 19/22] fix(ci): upgrade nodemon to ^3 to resolve semver ReDoS vulnerability nodemon@2 depends on simple-update-notifier which depends on a vulnerable range of semver (7.0.0-7.5.1, GHSA-c2qf-rxjj-qqgw). Upgrading to nodemon@3 pulls in a clean dependency tree. npm audit now reports 0 vulnerabilities. --- package-lock.json | 179 ++++++++++++++++++++++------------------------ package.json | 2 +- 2 files changed, 85 insertions(+), 96 deletions(-) diff --git a/package-lock.json b/package-lock.json index f0963f0..63b366e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,7 +19,7 @@ }, "devDependencies": { "concurrently": "^7.6.0", - "nodemon": "^2.0.22" + "nodemon": "^3.1.14" } }, "node_modules/@babel/runtime": { @@ -134,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", @@ -175,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": { @@ -326,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", @@ -1058,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": { @@ -1083,18 +1085,18 @@ } }, "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" @@ -1103,7 +1105,7 @@ "nodemon": "bin/nodemon.js" }, "engines": { - "node": ">=8.10.0" + "node": ">=10" }, "funding": { "type": "opencollective", @@ -1111,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": { @@ -1327,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": { @@ -1463,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": { @@ -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", @@ -2411,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": { @@ -2430,30 +2427,30 @@ "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==" }, "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": { @@ -2598,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": { @@ -2697,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": { diff --git a/package.json b/package.json index 59d5e53..a1ac085 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,7 @@ }, "devDependencies": { "concurrently": "^7.6.0", - "nodemon": "^2.0.22" + "nodemon": "^3.1.14" }, "keywords": [ "sabnzbd", From e83afde5ef7dfd88512808897aa3d808af0d7fe7 Mon Sep 17 00:00:00 2001 From: Gronod Date: Sat, 16 May 2026 17:15:28 +0100 Subject: [PATCH 20/22] feat: add 'Keep me logged in' checkbox to login form - 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 --- public/app.js | 3 ++- public/index.html | 6 ++++++ public/style.css | 26 ++++++++++++++++++++++++++ server/routes/auth.js | 15 ++++++++++----- 4 files changed, 44 insertions(+), 6 deletions(-) diff --git a/public/app.js b/public/app.js index 28e9b9c..fb5e607 100644 --- a/public/app.js +++ b/public/app.js @@ -136,6 +136,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 +144,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(); diff --git a/public/index.html b/public/index.html index 6f5a0fc..5c23feb 100644 --- a/public/index.html +++ b/public/index.html @@ -31,6 +31,12 @@ +

+ +
diff --git a/public/style.css b/public/style.css index a8a04b1..8e8220b 100644 --- a/public/style.css +++ b/public/style.css @@ -612,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; diff --git a/server/routes/auth.js b/server/routes/auth.js index 1e446ad..1b82a21 100644 --- a/server/routes/auth.js +++ b/server/routes/auth.js @@ -34,7 +34,7 @@ const loginLimiter = rateLimit({ // Authenticate user with Emby 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}`); @@ -68,15 +68,20 @@ router.post('/login', loginLimiter, async (req, res) => { 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; - res.cookie('emby_user', cookiePayload, { + const cookieOptions = { httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'strict', - signed, - maxAge: 24 * 60 * 60 * 1000 // 24 hours - }); + signed + }; + if (rememberMe) { + cookieOptions.maxAge = 30 * 24 * 60 * 60 * 1000; // 30 days + } + res.cookie('emby_user', cookiePayload, cookieOptions); res.json({ success: true, From 11749a428c6837986e415c9c91823fb6b7b8e249 Mon Sep 17 00:00:00 2001 From: Gronod Date: Sat, 16 May 2026 17:16:31 +0100 Subject: [PATCH 21/22] fix: splash screen hangs after login, never dismisses Root cause: showSplash() sets display:flex + opacity:1 synchronously, then dismissSplash() immediately adds the fade-out class (opacity:0). The browser batches these in the same paint frame so the CSS transition from opacity:1 -> 0 never starts, and transitionend never fires, leaving the Promise unresolved and the splash stuck. Two-part fix: 1. handleLogin: await two requestAnimationFrames between showSplash() and dismissSplash() so the browser paints opacity:1 first, ensuring the CSS opacity transition actually runs. 2. dismissSplash: add a 500ms fallback setTimeout that hides the splash and resolves the Promise even if transitionend is never fired (acts as a safety net for any future edge cases). --- public/app.js | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/public/app.js b/public/app.js index fb5e607..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 }); @@ -152,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); From 6b8c215497deb3c845182dd4e7bbe8a7b1208d8a Mon Sep 17 00:00:00 2001 From: Gronod Date: Sat, 16 May 2026 17:18:05 +0100 Subject: [PATCH 22/22] chore: bump version to 0.1.5 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index a1ac085..451f907 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "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": {