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 <img> 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.
This commit is contained in:
ScreenTinker 2026-06-09 11:18:56 -05:00
parent 61279e9bea
commit 6760f61fb8
2 changed files with 55 additions and 4 deletions

View file

@ -11,6 +11,36 @@ function formatFileSize(bytes) {
return `${bytes} B`;
}
// Lazy-load authenticated thumbnails/previews. A plain <img> 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 = `
<div class="page-header">
@ -369,14 +399,14 @@ async function loadContent() {
<span style="font-size:10px;color:var(--text-muted)">${t('content.type_remote_short')}</span>
</div>`
: c.thumbnail_path
? `<img src="/api/content/${c.id}/thumbnail" alt="${esc(c.filename)}" loading="lazy">`
? `<img data-auth-src="/api/content/${c.id}/thumbnail" alt="${esc(c.filename)}" style="background:var(--bg-secondary)">`
: c.mime_type?.startsWith('video/')
? `<div class="video-icon">
<svg width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<polygon points="5 3 19 12 5 21 5 3"/>
</svg>
</div>`
: `<img src="/api/content/${c.id}/file" alt="${esc(c.filename)}" loading="lazy">`
: `<img data-auth-src="/api/content/${c.id}/file" alt="${esc(c.filename)}" style="background:var(--bg-secondary)">`
}
</div>
<div class="content-item-body">
@ -406,6 +436,7 @@ async function loadContent() {
</div>
</div>
`).join('');
hydrateAuthImages(grid);
// Drag-to-move: each content item exposes its id; folder cards are the drop targets.
grid.querySelectorAll('.content-item').forEach(item => {

View file

@ -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). <img> 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);