diff --git a/frontend/js/views/device-detail.js b/frontend/js/views/device-detail.js index 4629a08..d0d0ffc 100644 --- a/frontend/js/views/device-detail.js +++ b/frontend/js/views/device-detail.js @@ -1018,9 +1018,7 @@ async function setupPlaylistActions(device) { } modal.remove(); showToast('Added to playlist', 'success'); - const assignments = await api.getAssignments(device.id); - document.getElementById('playlistContainer').innerHTML = renderPlaylist(assignments); - attachRemoveHandlers(device); + loadDevice(device.id, 'playlist'); } catch (err) { showToast(err.message, 'error'); } @@ -1063,6 +1061,7 @@ function attachRemoveHandlers(device) { try { await api.updateAssignment(assignmentId, { zone_id: select.value || null }); showToast(`Zone updated`, 'success'); + loadDevice(device.id, 'playlist'); } catch (err) { showToast(err.message, 'error'); } }; }); @@ -1078,9 +1077,7 @@ function attachRemoveHandlers(device) { try { await api.updateAssignment(id, { muted: !currentlyMuted }); showToast(currentlyMuted ? 'Unmuted' : 'Muted', 'success'); - const assignments = await api.getAssignments(device.id); - document.getElementById('playlistContainer').innerHTML = renderPlaylist(assignments); - attachRemoveHandlers(device); + loadDevice(device.id, 'playlist'); } catch (err) { showToast(err.message, 'error'); } }); }); @@ -1093,9 +1090,7 @@ function attachRemoveHandlers(device) { try { await api.deleteAssignment(id); showToast('Content removed from playlist', 'success'); - const assignments = await api.getAssignments(device.id); - document.getElementById('playlistContainer').innerHTML = renderPlaylist(assignments); - attachRemoveHandlers(device); + loadDevice(device.id, 'playlist'); } catch (err) { showToast(err.message, 'error'); } @@ -1146,12 +1141,10 @@ function attachRemoveHandlers(device) { try { await api.reorderAssignments(device.id, newOrder); showToast('Playlist reordered', 'success'); + loadDevice(device.id, 'playlist'); } catch (err) { showToast(err.message, 'error'); - // Reload to revert - const assignments = await api.getAssignments(device.id); - container.innerHTML = renderPlaylist(assignments); - attachRemoveHandlers(device); + loadDevice(device.id, 'playlist'); } }); }); diff --git a/frontend/js/views/playlists.js b/frontend/js/views/playlists.js index 332e7b8..7295dbf 100644 --- a/frontend/js/views/playlists.js +++ b/frontend/js/views/playlists.js @@ -304,7 +304,7 @@ function renderItems(items) {
${esc(item.filename || item.widget_name || 'Unknown')}
-
${item.widget_id ? 'Widget' : (item.mime_type || 'Unknown type')}
+
${item.widget_id ? 'Widget' : esc(item.mime_type || 'Unknown type')}
diff --git a/server/routes/assignments.js b/server/routes/assignments.js index 05f61c5..71a339f 100644 --- a/server/routes/assignments.js +++ b/server/routes/assignments.js @@ -9,8 +9,8 @@ function markDraft(playlistId) { } // Check device ownership for device-scoped routes -function checkDeviceAccess(req, res) { - const device = db.prepare('SELECT user_id FROM devices WHERE id = ?').get(req.params.deviceId); +function checkDeviceAccess(req, res, paramName = 'deviceId') { + const device = db.prepare('SELECT user_id FROM devices WHERE id = ?').get(req.params[paramName]); if (!device) { res.status(404).json({ error: 'Device not found' }); return false; } if (!['admin','superadmin'].includes(req.user.role) && device.user_id && device.user_id !== req.user.id) { res.status(403).json({ error: 'Access denied' }); return false; @@ -105,6 +105,9 @@ router.post('/device/:deviceId', (req, res) => { router.put('/:id', (req, res) => { const item = db.prepare('SELECT pi.*, p.user_id FROM playlist_items pi JOIN playlists p ON pi.playlist_id = p.id WHERE pi.id = ?').get(req.params.id); if (!item) return res.status(404).json({ error: 'Item not found' }); + if (!['admin','superadmin'].includes(req.user.role) && item.user_id !== req.user.id) { + return res.status(403).json({ error: 'Access denied' }); + } const { sort_order, duration_sec, zone_id } = req.body; const updates = []; @@ -128,6 +131,9 @@ router.put('/:id', (req, res) => { router.delete('/:id', (req, res) => { const item = db.prepare('SELECT pi.*, p.user_id FROM playlist_items pi JOIN playlists p ON pi.playlist_id = p.id WHERE pi.id = ?').get(req.params.id); if (!item) return res.status(404).json({ error: 'Item not found' }); + if (!['admin','superadmin'].includes(req.user.role) && item.user_id !== req.user.id) { + return res.status(403).json({ error: 'Access denied' }); + } db.prepare('DELETE FROM playlist_items WHERE id = ?').run(req.params.id); markDraft(item.playlist_id); @@ -161,6 +167,8 @@ router.post('/device/:deviceId/reorder', (req, res) => { // Copy playlist from one device to another router.post('/device/:deviceId/copy-to/:targetDeviceId', (req, res) => { + if (!checkDeviceAccess(req, res, 'deviceId')) return; + if (!checkDeviceAccess(req, res, 'targetDeviceId')) return; const sourceDevice = db.prepare('SELECT playlist_id FROM devices WHERE id = ?').get(req.params.deviceId); if (!sourceDevice?.playlist_id) return res.status(404).json({ error: 'Source device has no playlist' }); diff --git a/server/routes/content.js b/server/routes/content.js index 2d1d2f1..81d3fde 100644 --- a/server/routes/content.js +++ b/server/routes/content.js @@ -317,9 +317,12 @@ router.delete('/:id', (req, res) => { `).all(req.params.id); // Scrub published snapshots that reference this content + // Validate UUID format to prevent LIKE wildcard injection + const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + if (!UUID_RE.test(req.params.id)) return res.status(400).json({ error: 'Invalid content ID format' }); const snapshotPlaylists = db.prepare( - "SELECT id, published_snapshot FROM playlists WHERE published_snapshot LIKE ?" - ).all(`%${req.params.id}%`); + "SELECT id, published_snapshot FROM playlists WHERE user_id = ? AND published_snapshot LIKE ?" + ).all(content.user_id, `%${req.params.id}%`); for (const pl of snapshotPlaylists) { try { const items = JSON.parse(pl.published_snapshot); diff --git a/server/routes/device-groups.js b/server/routes/device-groups.js index 41599ef..90e7fc4 100644 --- a/server/routes/device-groups.js +++ b/server/routes/device-groups.js @@ -68,6 +68,12 @@ router.get('/:id/devices', requireGroupOwnership, (req, res) => { router.post('/:id/devices', requireGroupOwnership, (req, res) => { const { device_id } = req.body; if (!device_id) return res.status(400).json({ error: 'device_id required' }); + // Verify device belongs to the user (admin/superadmin bypass) + const device = db.prepare('SELECT user_id FROM devices WHERE id = ?').get(device_id); + if (!device) return res.status(404).json({ error: 'Device not found' }); + if (!['admin','superadmin'].includes(req.user.role) && device.user_id && device.user_id !== req.user.id) { + return res.status(403).json({ error: 'Access denied' }); + } try { db.prepare('INSERT OR IGNORE INTO device_group_members (device_id, group_id) VALUES (?, ?)').run(device_id, req.params.id); res.status(201).json({ success: true }); diff --git a/server/routes/playlists.js b/server/routes/playlists.js index 5b30037..00bf847 100644 --- a/server/routes/playlists.js +++ b/server/routes/playlists.js @@ -167,10 +167,18 @@ router.post('/:id/discard', requirePlaylistOwnership, (req, res) => { const transaction = db.transaction(() => { // Clear current draft items db.prepare('DELETE FROM playlist_items WHERE playlist_id = ?').run(req.params.id); - // Re-insert from snapshot + // Re-insert from snapshot, skipping items whose content/widget was deleted const insert = db.prepare('INSERT INTO playlist_items (playlist_id, content_id, widget_id, sort_order, duration_sec) VALUES (?, ?, ?, ?, ?)'); for (const item of publishedItems) { - insert.run(req.params.id, item.content_id || null, item.widget_id || null, item.sort_order, item.duration_sec); + try { + insert.run(req.params.id, item.content_id || null, item.widget_id || null, item.sort_order, item.duration_sec); + } catch (e) { + if (e.message.includes('FOREIGN KEY')) { + console.warn(`Discard: skipping snapshot item (content_id=${item.content_id}, widget_id=${item.widget_id}) — referenced entity was deleted`); + continue; + } + throw e; + } } db.prepare("UPDATE playlists SET status = 'published', updated_at = strftime('%s','now') WHERE id = ?").run(req.params.id); });