mirror of
https://github.com/screentinker/screentinker.git
synced 2026-06-14 18:22:46 -06:00
Merge pull request #55 from screentinker/fix/content-thumbnail-auth
fix(content): thumbnails for not-yet-assigned uploads (#39)
This commit is contained in:
commit
97c52408de
|
|
@ -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 => {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in a new issue