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)
This commit is contained in:
@@ -387,7 +387,11 @@ function createDownloadCard(download) {
|
|||||||
const coverDiv = document.createElement('div');
|
const coverDiv = document.createElement('div');
|
||||||
coverDiv.className = 'download-cover';
|
coverDiv.className = 'download-cover';
|
||||||
const coverImg = document.createElement('img');
|
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.alt = download.movieName || download.seriesName || download.title;
|
||||||
coverImg.loading = 'lazy';
|
coverImg.loading = 'lazy';
|
||||||
coverDiv.appendChild(coverImg);
|
coverDiv.appendChild(coverImg);
|
||||||
|
|||||||
@@ -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;
|
module.exports = router;
|
||||||
|
|||||||
Reference in New Issue
Block a user