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;