mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-13 22:03:13 -06:00
Editing a layout notified nothing at all — no push to the displays using it — so a zone change waited for the next heartbeat refresh at best. Combined with the Android rebuild being keyed on the layout ID (which does not change when you edit a layout in place), that is why adding a fourth zone took a force-stop to appear. The player-side fix makes the rebuild happen; this makes it prompt. Renaming: duplicating a template produces "<template> (Copy)" and there was nowhere to change it. The server has always accepted a name on PUT /layouts/:id; no UI ever sent one. The only name field in the editor belongs to the selected ZONE, which is easy to mistake for the layout's own — zones could always be renamed, layouts never could. The heading is now an input and its value rides along with the Save the user already presses. Verified on an Android 12 emulator, app left running throughout: 3-zone layout assigned -> "Multi-zone layout with 3 zones (was=null)" 4th zone added in place -> "Multi-zone layout with 4 zones (layout=a96c39ab, was=a96c39ab)" The ids match, so the old id-only condition would have skipped the rebuild entirely. Applied ~1s after the PUT, with no restart and no force-stop. Also verified the background-audio fix on the same device: 1 started audio player with the video in the foreground, 0 once another app was brought to the front. (First attempt was invalid — HOME re-shows this player because it is the default launcher, so it never backgrounds.) 859 server tests green. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
309 lines
15 KiB
JavaScript
309 lines
15 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)) {
|
|
db.prepare('DELETE FROM layout_zones WHERE layout_id = ?').run(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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
`);
|
|
zones.forEach((z, i) => {
|
|
stmt.run(z.id || uuidv4(), req.params.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);
|
|
});
|
|
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;
|