From 6760f61fb8784d33841cd9e5706911d447d1f2e6 Mon Sep 17 00:00:00 2001 From: ScreenTinker Date: Tue, 9 Jun 2026 11:18:56 -0500 Subject: [PATCH] fix(content): show thumbnails for not-yet-assigned content (#39) After uploading, content thumbnails were blank until the item was added to a playlist/widget. The public /api/content/:id/thumbnail (and /file) endpoints are reference-gated (an anonymous player with a UUID must not pull arbitrary tenants' media), and a plain can't send a Bearer token - so a just-uploaded item 403'd. - Backend: add an authenticated bypass - a logged-in user who can access the content's workspace (verified from the Bearer token) may view its file/thumbnail even when unreferenced. Anonymous players still hit the reference gate. - Frontend: the content library lazy-fetches thumbnails/previews WITH the token and swaps in an object URL (IntersectionObserver keeps it under the rate limit; the URL is revoked after load). Verified: unreferenced thumbnail now 200 with a bearer token, still 403 anonymous. --- frontend/js/views/content-library.js | 35 ++++++++++++++++++++++++++-- server/server.js | 24 +++++++++++++++++-- 2 files changed, 55 insertions(+), 4 deletions(-) diff --git a/frontend/js/views/content-library.js b/frontend/js/views/content-library.js index 0c8c8f6..9a0038e 100644 --- a/frontend/js/views/content-library.js +++ b/frontend/js/views/content-library.js @@ -11,6 +11,36 @@ function formatFileSize(bytes) { return `${bytes} B`; } +// Lazy-load authenticated thumbnails/previews. A plain can't send the +// Bearer token, and the content thumbnail/file endpoints require auth (or a +// playlist/widget reference) - so a just-uploaded item's thumbnail 403'd. We fetch +// with the token and swap in an object URL. IntersectionObserver keeps it lazy so +// we stay under the /api/content rate limit; the object URL is revoked after load. +let _authImgObserver = null; +function loadAuthImage(img) { + const url = img.dataset.authSrc; + if (!url) return; + delete img.dataset.authSrc; + fetch(url, { headers: { Authorization: `Bearer ${localStorage.getItem('token')}` } }) + .then(r => (r.ok ? r.blob() : Promise.reject(r.status))) + .then(blob => { + const obj = URL.createObjectURL(blob); + img.addEventListener('load', () => URL.revokeObjectURL(obj), { once: true }); + img.src = obj; + }) + .catch(() => { img.style.opacity = '0.25'; }); +} +function hydrateAuthImages(root) { + const imgs = root.querySelectorAll('img[data-auth-src]'); + if (typeof IntersectionObserver === 'undefined') { imgs.forEach(loadAuthImage); return; } + if (!_authImgObserver) { + _authImgObserver = new IntersectionObserver((entries, obs) => { + for (const e of entries) if (e.isIntersecting) { obs.unobserve(e.target); loadAuthImage(e.target); } + }, { rootMargin: '300px' }); + } + imgs.forEach(img => _authImgObserver.observe(img)); +} + export function render(container) { container.innerHTML = ` ` : c.thumbnail_path - ? `${esc(c.filename)}` + ? `${esc(c.filename)}` : c.mime_type?.startsWith('video/') ? `
` - : `${esc(c.filename)}` + : `${esc(c.filename)}` }
@@ -406,6 +436,7 @@ async function loadContent() {
`).join(''); + hydrateAuthImages(grid); // Drag-to-move: each content item exposes its id; folder cards are the drop targets. grid.querySelectorAll('.content-item').forEach(item => { diff --git a/server/server.js b/server/server.js index fb44113..ef02016 100644 --- a/server/server.js +++ b/server/server.js @@ -327,6 +327,26 @@ app.get('/api/devices/:id/screenshot', (req, res) => { res.sendFile(safePath); }); +// A logged-in user who can access the content's workspace may view its file / +// thumbnail even when it isn't referenced by a playlist/widget yet (e.g. the +// content library showing a just-uploaded, not-yet-assigned item). can't +// send an Authorization header, so the dashboard fetches these with the Bearer +// token; this verifies it and checks workspace membership. Anonymous players +// (no token) still fall back to the playlist/widget reference gate. (#39) +function requesterCanAccessContent(req, content) { + try { + const m = (req.headers.authorization || '').match(/^Bearer (.+)$/); + if (!m) return false; + const jwt = require('jsonwebtoken'); + const decoded = jwt.verify(m[1], config.jwtSecret, { algorithms: ['HS256'] }); + if (!decoded || !decoded.id) return false; + if (decoded.role === 'platform_admin') return true; + const { db } = require('./db/database'); + return !!db.prepare('SELECT 1 FROM workspace_members WHERE workspace_id = ? AND user_id = ?') + .get(content.workspace_id, decoded.id); + } catch { return false; } +} + // Public content file serving (must be BEFORE protected routes) app.get('/api/content/:id/file', (req, res) => { const { db } = require('./db/database'); @@ -340,7 +360,7 @@ app.get('/api/content/:id/file', (req, res) => { // Perf note: LIKE scan on widgets.config is O(n) per request. Fine at current scale // (<100 widgets); revisit with a content_widget_refs join table if this grows. const inWidget = inPlaylist ? null : db.prepare('SELECT id FROM widgets WHERE workspace_id = ? AND config LIKE ? LIMIT 1').get(content.workspace_id, `%/api/content/${req.params.id}/%`); - if (!inPlaylist && !inWidget) return res.status(403).json({ error: 'Content not assigned to any playlist or widget' }); + if (!inPlaylist && !inWidget && !requesterCanAccessContent(req, content)) return res.status(403).json({ error: 'Content not assigned to any playlist or widget' }); const safePath = path.resolve(config.contentDir, path.basename(content.filepath)); if (!safePath.startsWith(path.resolve(config.contentDir))) return res.status(403).json({ error: 'Invalid path' }); res.sendFile(safePath); @@ -357,7 +377,7 @@ app.get('/api/content/:id/thumbnail', (req, res) => { // thumbnail (the /file route already had this check; the thumbnail route did not). const inPlaylist = db.prepare('SELECT id FROM playlist_items WHERE content_id = ? LIMIT 1').get(req.params.id); const inWidget = inPlaylist ? null : db.prepare('SELECT id FROM widgets WHERE workspace_id = ? AND config LIKE ? LIMIT 1').get(content.workspace_id, `%/api/content/${req.params.id}/%`); - if (!inPlaylist && !inWidget) return res.status(403).json({ error: 'Content not assigned to any playlist or widget' }); + if (!inPlaylist && !inWidget && !requesterCanAccessContent(req, content)) return res.status(403).json({ error: 'Content not assigned to any playlist or widget' }); const safePath = path.resolve(config.contentDir, path.basename(content.thumbnail_path)); if (!safePath.startsWith(path.resolve(config.contentDir))) return res.status(403).json({ error: 'Invalid path' }); res.sendFile(safePath);