mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-14 14:23:14 -06:00
Nudging one zone in the layout editor and pressing Save destroyed unrelated tenant data across the
whole workspace, and returned 200.
The handler deleted every zone and re-inserted the same ids. Its comment claimed that was safe —
"Reuse each zone's id when supplied so device->zone assignments survive an edit (a fresh uuid per
save would orphan them)" — but reusing the id does not help, because SQLite runs the referential
actions on the DELETE and re-inserting the same primary key afterwards resurrects nothing. Two
things point at those rows:
playlist_items.zone_id ON DELETE SET NULL -> every multi-zone playlist item un-assigned, so
those playlists silently fell back to fullscreen
schedules.zone_id ON DELETE CASCADE -> every zone-bound schedule permanently deleted
No warning, no undo, and nothing in the UI to suggest a geometry tweak had touched schedules at all.
Zones are now updated in place, inserted when new, and deleted only when the editor actually removed
them. An update touches no foreign key, so nothing pointing at a surviving zone is affected. The
cascades are left exactly as they are: on a genuinely removed zone they are the correct behaviour,
and the tests pin that too.
4 tests: a moved zone keeps item assignments and zone-bound schedules, the geometry change is really
applied, adding a zone disturbs nothing, and removing a zone still un-assigns its items and removes
its schedules.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
347 lines
17 KiB
JavaScript
347 lines
17 KiB
JavaScript
const express = require('express');
|
|
const router = express.Router();
|
|
const { v4: uuidv4 } = require('uuid');
|
|
const { db } = require('../db/database');
|
|
const { PLATFORM_ROLES, ELEVATED_ROLES } = require('../middleware/auth');
|
|
// Phase 2.2h: workspace-aware access. Templates (is_template=1) are the
|
|
// platform-shared pair (NULL user_id, NULL workspace_id) and are visible
|
|
// everywhere, writable only by platform_admin.
|
|
const { accessContext } = require('../lib/tenancy');
|
|
|
|
// List layouts in the caller's current workspace plus all templates.
|
|
// Phase 2.2h: workspace-scoped. Templates (is_template=1) remain visible to
|
|
// everyone; cross-workspace owned-layout visibility comes from switch-workspace.
|
|
router.get('/', (req, res) => {
|
|
const showTemplates = req.query.templates === 'true';
|
|
|
|
let layouts;
|
|
if (showTemplates) {
|
|
layouts = db.prepare('SELECT * FROM layouts WHERE is_template = 1 ORDER BY template_category, name').all();
|
|
} else if (!req.workspaceId) {
|
|
// No workspace context -> only templates are visible.
|
|
layouts = db.prepare('SELECT * FROM layouts WHERE is_template = 1 ORDER BY template_category, name').all();
|
|
} else {
|
|
layouts = db.prepare(
|
|
'SELECT * FROM layouts WHERE (workspace_id = ? OR is_template = 1) ORDER BY is_template DESC, created_at DESC'
|
|
).all(req.workspaceId);
|
|
}
|
|
|
|
// Attach zones to each layout
|
|
const zonesStmt = db.prepare('SELECT * FROM layout_zones WHERE layout_id = ? ORDER BY sort_order');
|
|
layouts.forEach(l => { l.zones = zonesStmt.all(l.id); });
|
|
|
|
res.json(layouts);
|
|
});
|
|
|
|
// Phase 2.2h: workspace-aware access. Mirrors content/widget/kiosk helpers.
|
|
// Templates (is_template=1) are readable by anyone authenticated; writable
|
|
// only by platform_admin (kept layered with the existing L78/L94 guards).
|
|
function checkLayoutRead(req, res) {
|
|
const layout = db.prepare('SELECT * FROM layouts WHERE id = ?').get(req.params.id);
|
|
if (!layout) { res.status(404).json({ error: 'Layout not found' }); return null; }
|
|
if (layout.is_template) return layout;
|
|
if (!layout.workspace_id) {
|
|
// Owned row with no workspace - treat as inaccessible (shouldn't exist post-migration).
|
|
res.status(403).json({ error: 'Layout not assigned to a workspace' }); return null;
|
|
}
|
|
const ws = db.prepare('SELECT * FROM workspaces WHERE id = ?').get(layout.workspace_id);
|
|
const ctx = ws && accessContext(req.user.id, req.user.role, ws);
|
|
if (!ctx) { res.status(403).json({ error: 'Access denied' }); return null; }
|
|
return layout;
|
|
}
|
|
|
|
function checkLayoutWrite(req, res) {
|
|
const layout = db.prepare('SELECT * FROM layouts WHERE id = ?').get(req.params.id);
|
|
if (!layout) { res.status(404).json({ error: 'Layout not found' }); return null; }
|
|
if (layout.is_template) {
|
|
// Templates: only platform_admin may write. Existing L78/L94 also check
|
|
// is_template explicitly with the same intent; this is the layered gate.
|
|
if (!PLATFORM_ROLES.includes(req.user.role)) {
|
|
res.status(403).json({ error: 'Platform admin required to modify templates' }); return null;
|
|
}
|
|
return layout;
|
|
}
|
|
if (!layout.workspace_id) {
|
|
res.status(403).json({ error: 'Layout not assigned to a workspace' }); return null;
|
|
}
|
|
const ws = db.prepare('SELECT * FROM workspaces WHERE id = ?').get(layout.workspace_id);
|
|
const ctx = ws && accessContext(req.user.id, req.user.role, ws);
|
|
if (!ctx) { res.status(403).json({ error: 'Access denied' }); return null; }
|
|
if (!ctx.actingAs && ctx.workspaceRole === 'workspace_viewer') {
|
|
res.status(403).json({ error: 'Read-only access' }); return null;
|
|
}
|
|
return layout;
|
|
}
|
|
|
|
// Get layout with zones
|
|
router.get('/:id', (req, res) => {
|
|
const layout = checkLayoutRead(req, res);
|
|
if (!layout) return;
|
|
|
|
layout.zones = db.prepare('SELECT * FROM layout_zones WHERE layout_id = ? ORDER BY sort_order').all(layout.id);
|
|
res.json(layout);
|
|
});
|
|
|
|
// Create layout in the caller's current workspace.
|
|
router.post('/', (req, res) => {
|
|
if (!req.workspaceId) return res.status(403).json({ error: 'No workspace context. Switch to a workspace before creating layouts.' });
|
|
const { name, width, height, zones } = req.body;
|
|
if (!name) return res.status(400).json({ error: 'name required' });
|
|
|
|
const id = uuidv4();
|
|
db.prepare('INSERT INTO layouts (id, user_id, workspace_id, name, width, height) VALUES (?, ?, ?, ?, ?, ?)')
|
|
.run(id, req.user.id, req.workspaceId, name, width || 1920, height || 1080);
|
|
|
|
// Create zones if provided
|
|
if (zones && Array.isArray(zones)) {
|
|
const stmt = db.prepare(`
|
|
INSERT INTO layout_zones (id, layout_id, name, x_percent, y_percent, width_percent, height_percent, z_index, zone_type, fit_mode, background_color, sort_order)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
`);
|
|
zones.forEach((z, i) => {
|
|
stmt.run(uuidv4(), id, z.name || `Zone ${i + 1}`, z.x_percent || 0, z.y_percent || 0,
|
|
z.width_percent || 100, z.height_percent || 100, z.z_index || 0,
|
|
z.zone_type || 'content', z.fit_mode || 'contain', z.background_color || '#000000', i);
|
|
});
|
|
}
|
|
|
|
const layout = db.prepare('SELECT * FROM layouts WHERE id = ?').get(id);
|
|
layout.zones = db.prepare('SELECT * FROM layout_zones WHERE layout_id = ? ORDER BY sort_order').all(id);
|
|
res.status(201).json(layout);
|
|
});
|
|
|
|
// Update layout
|
|
router.put('/:id', (req, res) => {
|
|
const layout = checkLayoutWrite(req, res);
|
|
if (!layout) return;
|
|
if (layout.is_template && !PLATFORM_ROLES.includes(req.user.role)) return res.status(403).json({ error: 'Cannot edit templates' });
|
|
|
|
const { name, width, height, zones } = req.body;
|
|
const txn = db.transaction(() => {
|
|
if (name) db.prepare('UPDATE layouts SET name = ?, updated_at = strftime(\'%s\',\'now\') WHERE id = ?').run(name, req.params.id);
|
|
if (width) db.prepare('UPDATE layouts SET width = ? WHERE id = ?').run(width, req.params.id);
|
|
if (height) db.prepare('UPDATE layouts SET height = ? WHERE id = ?').run(height, req.params.id);
|
|
|
|
// Atomic zone replace: the editor sends the FULL desired set, so the layout
|
|
// ends up with EXACTLY those zones - no accumulation from a per-zone
|
|
// delete/add loop. Reuse each zone's id when supplied so device->zone
|
|
// assignments survive an edit (a fresh uuid per save would orphan them).
|
|
if (Array.isArray(zones)) {
|
|
// DIFF, never delete-and-replace.
|
|
//
|
|
// The previous version deleted every zone and re-inserted the same ids, on the stated
|
|
// assumption that reusing an id preserved whatever pointed at it. It does not: SQLite runs
|
|
// the referential actions on the DELETE, and re-inserting the same primary key afterwards
|
|
// does not resurrect what they destroyed. Two things point at these rows:
|
|
//
|
|
// playlist_items.zone_id ON DELETE SET NULL -> every multi-zone playlist in the
|
|
// workspace silently fell back to fullscreen
|
|
// schedules.zone_id ON DELETE CASCADE -> every zone-bound schedule was DELETED,
|
|
// permanently, no warning and no undo
|
|
//
|
|
// So nudging one zone by a pixel and pressing Save destroyed unrelated tenant data and
|
|
// returned 200 OK. Updating in place touches no foreign key at all; only genuinely removed
|
|
// zones are deleted, which is the one case where those cascades are the intended behaviour.
|
|
const existingIds = db.prepare('SELECT id FROM layout_zones WHERE layout_id = ?')
|
|
.all(req.params.id).map(r => r.id);
|
|
const existingSet = new Set(existingIds);
|
|
const keptIds = new Set();
|
|
|
|
const insertZone = db.prepare(`
|
|
INSERT INTO layout_zones (id, layout_id, name, x_percent, y_percent, width_percent, height_percent, z_index, zone_type, fit_mode, background_color, sort_order)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
`);
|
|
const updateZone = db.prepare(`
|
|
UPDATE layout_zones SET name = ?, x_percent = ?, y_percent = ?, width_percent = ?,
|
|
height_percent = ?, z_index = ?, zone_type = ?, fit_mode = ?, background_color = ?, sort_order = ?
|
|
WHERE id = ? AND layout_id = ?
|
|
`);
|
|
|
|
zones.forEach((z, i) => {
|
|
const zid = z.id || uuidv4();
|
|
const vals = [
|
|
z.name || `Zone ${i + 1}`,
|
|
z.x_percent || 0, z.y_percent || 0, z.width_percent || 100, z.height_percent || 100,
|
|
z.z_index || 0, z.zone_type || 'content', z.fit_mode || 'contain',
|
|
z.background_color || '#000000', i,
|
|
];
|
|
if (existingSet.has(zid)) updateZone.run(...vals, zid, req.params.id);
|
|
else insertZone.run(zid, req.params.id, ...vals);
|
|
keptIds.add(zid);
|
|
});
|
|
|
|
// Only the zones the editor actually removed.
|
|
for (const zid of existingIds) {
|
|
if (!keptIds.has(zid)) {
|
|
db.prepare('DELETE FROM layout_zones WHERE id = ? AND layout_id = ?').run(zid, req.params.id);
|
|
}
|
|
}
|
|
db.prepare('UPDATE layouts SET updated_at = strftime(\'%s\',\'now\') WHERE id = ?').run(req.params.id);
|
|
}
|
|
});
|
|
txn();
|
|
|
|
const updated = db.prepare('SELECT * FROM layouts WHERE id = ?').get(req.params.id);
|
|
updated.zones = db.prepare('SELECT * FROM layout_zones WHERE layout_id = ? ORDER BY sort_order').all(req.params.id);
|
|
// Push to the displays using this layout. Editing a layout used to notify nothing at all, so a
|
|
// zone change waited for the next heartbeat refresh at best — and on Android it did not apply
|
|
// even then, because the rebuild was keyed on the layout ID, which does not change when you edit
|
|
// a layout in place. Reported on #234 as "I added 4 zones and they dont appear on the screen".
|
|
// The player-side fix makes the rebuild happen; this makes it happen promptly.
|
|
try {
|
|
const io = req.app.get('io');
|
|
if (io) {
|
|
const { buildPlaylistPayload } = require('../ws/deviceSocket');
|
|
const commandQueue = require('../lib/command-queue');
|
|
for (const d of db.prepare('SELECT id FROM devices WHERE layout_id = ?').all(req.params.id)) {
|
|
commandQueue.queueOrEmitPlaylistUpdate(io.of('/device'), d.id, buildPlaylistPayload);
|
|
}
|
|
}
|
|
} catch (e) { /* best-effort; the heartbeat refresh still picks it up */ }
|
|
|
|
res.json(updated);
|
|
});
|
|
|
|
// Delete layout
|
|
router.delete('/:id', (req, res) => {
|
|
const layout = checkLayoutWrite(req, res);
|
|
if (!layout) return;
|
|
if (layout.is_template && !PLATFORM_ROLES.includes(req.user.role)) return res.status(403).json({ error: 'Cannot delete templates' });
|
|
|
|
db.prepare('DELETE FROM layouts WHERE id = ?').run(req.params.id);
|
|
res.json({ success: true });
|
|
});
|
|
|
|
// Add zone to layout. Phase 2.2h: tightened to write-access; workspace_viewer
|
|
// can read the layout via GET but cannot add zones.
|
|
router.post('/:id/zones', (req, res) => {
|
|
const layout = checkLayoutWrite(req, res);
|
|
if (!layout) return;
|
|
|
|
const { name, x_percent, y_percent, width_percent, height_percent, z_index, zone_type, fit_mode, background_color } = req.body;
|
|
const maxOrder = db.prepare('SELECT MAX(sort_order) as m FROM layout_zones WHERE layout_id = ?').get(req.params.id).m || 0;
|
|
|
|
const id = uuidv4();
|
|
db.prepare(`
|
|
INSERT INTO layout_zones (id, layout_id, name, x_percent, y_percent, width_percent, height_percent, z_index, zone_type, fit_mode, background_color, sort_order)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
`).run(id, req.params.id, name || 'New Zone', x_percent || 0, y_percent || 0,
|
|
width_percent || 50, height_percent || 50, z_index || 0,
|
|
zone_type || 'content', fit_mode || 'contain', background_color || '#000000', maxOrder + 1);
|
|
|
|
db.prepare("UPDATE layouts SET updated_at = strftime('%s','now') WHERE id = ?").run(req.params.id);
|
|
|
|
const zone = db.prepare('SELECT * FROM layout_zones WHERE id = ?').get(id);
|
|
res.status(201).json(zone);
|
|
});
|
|
|
|
// Update zone
|
|
router.put('/:id/zones/:zoneId', (req, res) => {
|
|
const layout = checkLayoutWrite(req, res);
|
|
if (!layout) return;
|
|
const zone = db.prepare('SELECT * FROM layout_zones WHERE id = ? AND layout_id = ?').get(req.params.zoneId, req.params.id);
|
|
if (!zone) return res.status(404).json({ error: 'Zone not found' });
|
|
|
|
const fields = ['name', 'x_percent', 'y_percent', 'width_percent', 'height_percent', 'z_index', 'zone_type', 'fit_mode', 'background_color', 'sort_order'];
|
|
const updates = [];
|
|
const values = [];
|
|
fields.forEach(f => {
|
|
if (req.body[f] !== undefined) { updates.push(`${f} = ?`); values.push(req.body[f]); }
|
|
});
|
|
|
|
if (updates.length > 0) {
|
|
values.push(req.params.zoneId);
|
|
db.prepare(`UPDATE layout_zones SET ${updates.join(', ')} WHERE id = ?`).run(...values);
|
|
db.prepare("UPDATE layouts SET updated_at = strftime('%s','now') WHERE id = ?").run(req.params.id);
|
|
}
|
|
|
|
const updated = db.prepare('SELECT * FROM layout_zones WHERE id = ?').get(req.params.zoneId);
|
|
res.json(updated);
|
|
});
|
|
|
|
// Delete zone
|
|
router.delete('/:id/zones/:zoneId', (req, res) => {
|
|
const layout = checkLayoutWrite(req, res);
|
|
if (!layout) return;
|
|
db.prepare('DELETE FROM layout_zones WHERE id = ? AND layout_id = ?').run(req.params.zoneId, req.params.id);
|
|
db.prepare("UPDATE layouts SET updated_at = strftime('%s','now') WHERE id = ?").run(req.params.id);
|
|
res.json({ success: true });
|
|
});
|
|
|
|
// Duplicate layout (for using templates). Source needs read-access only;
|
|
// destination lands in the caller's current workspace.
|
|
router.post('/:id/duplicate', (req, res) => {
|
|
if (!req.workspaceId) return res.status(403).json({ error: 'No workspace context. Switch to a workspace before duplicating a layout.' });
|
|
const source = checkLayoutRead(req, res);
|
|
if (!source) return;
|
|
|
|
const newId = uuidv4();
|
|
const name = req.body.name || `${source.name} (Copy)`;
|
|
|
|
db.prepare('INSERT INTO layouts (id, user_id, workspace_id, name, width, height) VALUES (?, ?, ?, ?, ?, ?)')
|
|
.run(newId, req.user.id, req.workspaceId, name, source.width, source.height);
|
|
|
|
// Copy zones, keeping an old->new zone-id map. The copy gets fresh zone ids, so any
|
|
// playlist_items still pointing at the SOURCE zones would be orphaned if a device is
|
|
// moved onto this copy. We return the map (zone_id_map) so a follow-up remap can run.
|
|
// NOTE for review: we intentionally do NOT auto-rewrite playlist_items.zone_id here —
|
|
// the source layout's own assignments must keep pointing at the source. A safe remap is
|
|
// a scoped op ("migrate playlist P from layout A to its copy B"), best done explicitly;
|
|
// see find-orphan-zone-items.js + the player fallback, which already de-risk the runtime.
|
|
const zones = db.prepare('SELECT * FROM layout_zones WHERE layout_id = ?').all(req.params.id);
|
|
const stmt = db.prepare(`
|
|
INSERT INTO layout_zones (id, layout_id, name, x_percent, y_percent, width_percent, height_percent, z_index, zone_type, fit_mode, background_color, sort_order)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
`);
|
|
const zone_id_map = {};
|
|
zones.forEach(z => {
|
|
const nz = uuidv4();
|
|
zone_id_map[z.id] = nz;
|
|
stmt.run(nz, newId, z.name, z.x_percent, z.y_percent, z.width_percent, z.height_percent,
|
|
z.z_index, z.zone_type, z.fit_mode, z.background_color, z.sort_order);
|
|
});
|
|
|
|
const layout = db.prepare('SELECT * FROM layouts WHERE id = ?').get(newId);
|
|
layout.zones = db.prepare('SELECT * FROM layout_zones WHERE layout_id = ? ORDER BY sort_order').all(newId);
|
|
res.status(201).json({ ...layout, zone_id_map });
|
|
});
|
|
|
|
// Assign layout to device.
|
|
// Phase 2.2h: closes a pre-existing cross-tenant leak. Today the route only
|
|
// gated by device-ownership and didn't verify the layout_id at all, so any
|
|
// caller with write access to a device could assign another workspace's
|
|
// layout to it - the player would then render foreign zones/dimensions.
|
|
//
|
|
// New rules:
|
|
// 1. Caller must have write access to the DEVICE's workspace.
|
|
// 2. The layout must be either a template (is_template=1) or live in the
|
|
// same workspace as the device.
|
|
router.put('/device/:deviceId', (req, res) => {
|
|
const device = db.prepare('SELECT user_id, workspace_id FROM devices WHERE id = ?').get(req.params.deviceId);
|
|
if (!device) return res.status(404).json({ error: 'Device not found' });
|
|
if (!device.workspace_id) return res.status(403).json({ error: 'Device not assigned to a workspace' });
|
|
|
|
const deviceWs = db.prepare('SELECT * FROM workspaces WHERE id = ?').get(device.workspace_id);
|
|
const ctx = deviceWs && accessContext(req.user.id, req.user.role, deviceWs);
|
|
if (!ctx) return res.status(403).json({ error: 'Access denied' });
|
|
if (!ctx.actingAs && ctx.workspaceRole === 'workspace_viewer') {
|
|
return res.status(403).json({ error: 'Read-only access' });
|
|
}
|
|
|
|
const { layout_id } = req.body;
|
|
if (layout_id) {
|
|
const layout = db.prepare('SELECT is_template, workspace_id FROM layouts WHERE id = ?').get(layout_id);
|
|
if (!layout) return res.status(400).json({ error: 'Invalid layout_id' });
|
|
// Layout must be a template, or live in the device's workspace.
|
|
if (!layout.is_template && layout.workspace_id !== device.workspace_id) {
|
|
return res.status(403).json({ error: 'Layout is not in this device\'s workspace and is not a template' });
|
|
}
|
|
}
|
|
|
|
db.prepare("UPDATE devices SET layout_id = ?, updated_at = strftime('%s','now') WHERE id = ?")
|
|
.run(layout_id || null, req.params.deviceId);
|
|
res.json({ success: true });
|
|
});
|
|
|
|
module.exports = router;
|