From cc1e8af76192dceded57af666ae9feb48ce34d6d Mon Sep 17 00:00:00 2001 From: Gronod Date: Sun, 17 May 2026 07:24:15 +0100 Subject: [PATCH] fix: proxy cover art through server to satisfy CSP img-src 'self' The new CSP blocks direct browser requests to external image origins (themoviedb.org, thetvdb.com, etc.) used for poster art. - dashboard.js: add GET /api/dashboard/cover-art?url=... proxy endpoint (auth-required, http/https only, image content-type validated, 5MB cap, 24h Cache-Control, streams response directly to client) - app.js: route coverArt src through /api/dashboard/cover-art proxy - server/utils/logger.js: fix hardcoded /app/server.log path (use DATA_DIR) --- public/app.js | 6 +++++- server/routes/dashboard.js | 37 +++++++++++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/public/app.js b/public/app.js index f798355..eacfc87 100644 --- a/public/app.js +++ b/public/app.js @@ -387,7 +387,11 @@ function createDownloadCard(download) { const coverDiv = document.createElement('div'); coverDiv.className = 'download-cover'; const coverImg = document.createElement('img'); - coverImg.src = download.coverArt; + // Proxy cover art through the server so the CSP img-src 'self' rule + // is satisfied (external poster URLs would be blocked otherwise). + coverImg.src = download.coverArt + ? '/api/dashboard/cover-art?url=' + encodeURIComponent(download.coverArt) + : ''; coverImg.alt = download.movieName || download.seriesName || download.title; coverImg.loading = 'lazy'; coverDiv.appendChild(coverImg); diff --git a/server/routes/dashboard.js b/server/routes/dashboard.js index 3e7576a..9c5e226 100644 --- a/server/routes/dashboard.js +++ b/server/routes/dashboard.js @@ -721,4 +721,41 @@ router.get('/status', requireAuth, (req, res) => { } }); +// Cover art proxy — fetches external poster images server-side so the +// browser loads them from 'self' and the CSP img-src stays tight. +// Requires authentication. Only proxies http/https URLs. +router.get('/cover-art', requireAuth, async (req, res) => { + const { url } = req.query; + if (!url || typeof url !== 'string') { + return res.status(400).json({ error: 'Missing url parameter' }); + } + let parsed; + try { + parsed = new URL(url); + } catch { + return res.status(400).json({ error: 'Invalid url' }); + } + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + return res.status(400).json({ error: 'Only http/https URLs are supported' }); + } + try { + const response = await axios.get(url, { + responseType: 'stream', + timeout: 8000, + maxContentLength: 5 * 1024 * 1024 // 5 MB max + }); + const contentType = response.headers['content-type'] || 'image/jpeg'; + // Only proxy image content types + if (!contentType.startsWith('image/')) { + return res.status(400).json({ error: 'Remote URL is not an image' }); + } + res.setHeader('Content-Type', contentType); + res.setHeader('Cache-Control', 'public, max-age=86400'); // 24h browser cache + res.setHeader('X-Content-Type-Options', 'nosniff'); + response.data.pipe(res); + } catch (err) { + res.status(502).json({ error: 'Failed to fetch cover art' }); + } +}); + module.exports = router;