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);
});