const express = require('express'); const router = express.Router(); const { v4: uuidv4 } = require('uuid'); const { db } = require('../db/database'); // Phase 2.2m: workspace-aware schedule access. Schedules inherit workspace_id // from their target (device or device_group). All polymorphic references // (content / widget / layout / playlist) must live in the same workspace as // the target. This closes a long-standing leak where POST accepted those // payload refs with no ownership check at all (only the target was checked). const { accessContext } = require('../lib/tenancy'); const { effectiveDeviceTz } = require('../lib/device-timezone'); const { resolveItemDuration } = require('../lib/item-duration'); // Helper: build the expanded schedule query for a device (device-level + group-level) function getDeviceSchedulesQuery() { return ` SELECT s.*, c.filename as content_name, w.name as widget_name, p.name as playlist_name, dg.name as group_name, dg.color as group_color FROM schedules s LEFT JOIN content c ON s.content_id = c.id LEFT JOIN widgets w ON s.widget_id = w.id LEFT JOIN playlists p ON s.playlist_id = p.id LEFT JOIN device_groups dg ON s.group_id = dg.id WHERE s.enabled = 1 AND ( s.device_id = ? OR s.group_id IN ( SELECT group_id FROM device_group_members WHERE device_id = ? ) ) ORDER BY CASE WHEN s.device_id IS NOT NULL THEN 1 ELSE 0 END DESC, s.priority DESC, s.created_at ASC `; } // Every schedule in a workspace, each row carrying the NAME of what it targets. // // The per-device query answers "what plays on THIS screen". This answers "what is // scheduled anywhere", which is what an operator actually needs to see: with a // single-device calendar you cannot tell whether a gap is deliberate or whether you // pointed the schedule at the wrong screen — the failure mode a real user hit. function getWorkspaceSchedulesQuery() { return ` SELECT s.*, c.filename as content_name, w.name as widget_name, p.name as playlist_name, dg.name as group_name, dg.color as group_color, d.name as device_name FROM schedules s LEFT JOIN content c ON s.content_id = c.id LEFT JOIN widgets w ON s.widget_id = w.id LEFT JOIN playlists p ON s.playlist_id = p.id LEFT JOIN device_groups dg ON s.group_id = dg.id LEFT JOIN devices d ON s.device_id = d.id WHERE s.enabled = 1 AND s.workspace_id = ? ORDER BY CASE WHEN s.device_id IS NOT NULL THEN 1 ELSE 0 END DESC, s.priority DESC, s.created_at ASC `; } // Load a schedule + access context, sending 403/404 on failure. function loadScheduleAccess(req, res, requireWrite) { const schedule = db.prepare('SELECT * FROM schedules WHERE id = ?').get(req.params.id); if (!schedule) { res.status(404).json({ error: 'Schedule not found' }); return null; } if (!schedule.workspace_id) { res.status(403).json({ error: 'Schedule not assigned to a workspace' }); return null; } const ws = db.prepare('SELECT * FROM workspaces WHERE id = ?').get(schedule.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 (requireWrite && !ctx.actingAs && ctx.workspaceRole === 'workspace_viewer') { res.status(403).json({ error: 'Read-only access' }); return null; } req.schedule = schedule; req.scheduleCtx = ctx; return schedule; } function requireScheduleWrite(req, res, next) { if (!loadScheduleAccess(req, res, true)) return; next(); } // Verify caller has at least read access to the given workspace (used when // resolving the target's workspace before stamping a new schedule). function workspaceAccess(req, workspaceId) { if (!workspaceId) return null; const ws = db.prepare('SELECT * FROM workspaces WHERE id = ?').get(workspaceId); if (!ws) return null; return accessContext(req.user.id, req.user.role, ws); } // Verify a referenced row exists and lives in the given workspace. Returns // null on success, or { status, error } on failure. Used for content / widget // / layout / playlist refs (where workspace_id IS NULL is the platform-template // path and is always allowed) and for devices / device_groups (where // workspace_id is required - those tables never carry template rows). // layout_zones has no workspace_id of its own — a zone belongs to a layout, and the layout carries // the workspace. zone_id was the one polymorphic reference missing from the ownership checks, so a // schedule could be pointed at a zone in someone else's workspace. function checkZoneInWorkspace(zoneId, workspaceId) { const row = db.prepare( 'SELECT l.workspace_id FROM layout_zones z JOIN layouts l ON l.id = z.layout_id WHERE z.id = ?' ).get(zoneId); if (!row) return { status: 404, error: 'zone not found' }; if (row.workspace_id === workspaceId) return null; if (row.workspace_id == null) return null; // platform-template layout return { status: 403, error: 'zone is not in this workspace' }; } function checkRefInWorkspace(table, id, workspaceId, opts = { allowNullWorkspace: false }) { const row = db.prepare(`SELECT workspace_id FROM ${table} WHERE id = ?`).get(id); if (!row) return { status: 404, error: `${table.replace(/_/g, ' ').slice(0, -1)} not found` }; if (row.workspace_id === workspaceId) return null; if (opts.allowNullWorkspace && row.workspace_id == null) return null; return { status: 403, error: `${table.replace(/_/g, ' ').slice(0, -1)} is not in this workspace` }; } // List schedules (filterable). Phase 2.2m: workspace-scoped. router.get('/', (req, res) => { if (!req.workspaceId) return res.json([]); const { device_id, group_id, start, end } = req.query; let sql = `SELECT s.*, c.filename as content_name, w.name as widget_name, p.name as playlist_name, dg.name as group_name, dg.color as group_color FROM schedules s LEFT JOIN content c ON s.content_id = c.id LEFT JOIN widgets w ON s.widget_id = w.id LEFT JOIN playlists p ON s.playlist_id = p.id LEFT JOIN device_groups dg ON s.group_id = dg.id WHERE s.workspace_id = ?`; const params = [req.workspaceId]; if (device_id) { sql += ` AND (s.device_id = ? OR s.group_id IN (SELECT group_id FROM device_group_members WHERE device_id = ?))`; params.push(device_id, device_id); } if (group_id) { sql += ' AND s.group_id = ?'; params.push(group_id); } if (start) { sql += ' AND s.end_time >= ?'; params.push(start); } if (end) { sql += ' AND s.start_time <= ?'; params.push(end); } sql += ' ORDER BY s.start_time ASC'; res.json(db.prepare(sql).all(...params)); }); // Get schedules for a device. Phase 2.2m: device access via workspace_id. router.get('/device/:deviceId', (req, res) => { const device = db.prepare('SELECT 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 ctx = workspaceAccess(req, device.workspace_id); if (!ctx) return res.status(403).json({ error: 'Access denied' }); const schedules = db.prepare(getDeviceSchedulesQuery()).all(req.params.deviceId, req.params.deviceId); res.json(schedules); }); // Expanded week view (resolves recurrences). Phase 2.2m: device access via workspace. router.get('/week', (req, res) => { const { date, device_id, all } = req.query; if (!device_id && !all) return res.status(400).json({ error: 'device_id or all=1 required' }); // all=1 -> every schedule on every screen, for the "all screens" calendar. The workspace // comes from the caller's resolved tenancy, never from the query string: a client-supplied // workspace_id here would be a cross-tenant read waiting to happen. let scopeWorkspaceId = all ? req.workspaceId : null; if (all && !scopeWorkspaceId) return res.json([]); if (device_id) { const device = db.prepare('SELECT workspace_id FROM devices WHERE id = ?').get(device_id); 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' }); scopeWorkspaceId = device.workspace_id; } const ctx = workspaceAccess(req, scopeWorkspaceId); if (!ctx) return res.status(403).json({ error: 'Access denied' }); const weekStart = date ? new Date(date) : new Date(); weekStart.setHours(0, 0, 0, 0); weekStart.setDate(weekStart.getDate() - weekStart.getDay()); const weekEnd = new Date(weekStart); weekEnd.setDate(weekEnd.getDate() + 7); const schedules = device_id ? db.prepare(getDeviceSchedulesQuery()).all(device_id, device_id) : db.prepare(getWorkspaceSchedulesQuery()).all(scopeWorkspaceId); const events = []; for (const s of schedules) { const expanded = expandSchedule(s, weekStart, weekEnd); events.push(...expanded); } res.json(events); }); // Create schedule. Phase 2.2m: schedule.workspace_id is inherited from the // target (device or group). Single workspace lookup also enforces caller's // write access. Closes 4 pre-existing leaks: content / widget / layout / // playlist were accepted with NO ownership check at all. router.post('/', (req, res) => { const { device_id, group_id, zone_id, content_id, widget_id, layout_id, playlist_id, title, start_time, end_time, timezone, recurrence, recurrence_end, priority, color } = req.body; if (!start_time || !end_time) { return res.status(400).json({ error: 'start_time and end_time required' }); } if (device_id && group_id) { return res.status(400).json({ error: 'Cannot set both device_id and group_id. A schedule applies to one device OR one group.' }); } if (!device_id && !group_id) { return res.status(400).json({ error: 'Either device_id or group_id is required' }); } // Resolve target's workspace_id and verify caller has write access there. let targetWorkspaceId = null; let targetTz = null; if (device_id) { const device = db.prepare('SELECT workspace_id, timezone, reported_timezone FROM devices WHERE id = ?').get(device_id); 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' }); targetWorkspaceId = device.workspace_id; targetTz = effectiveDeviceTz(device); } if (group_id) { const group = db.prepare('SELECT workspace_id, leader_device_id FROM device_groups WHERE id = ?').get(group_id); if (!group) return res.status(404).json({ error: 'Group not found' }); if (!group.workspace_id) return res.status(403).json({ error: 'Group not assigned to a workspace' }); targetWorkspaceId = group.workspace_id; // A group can span zones, so there is no single right answer. The leader defines the // group's wall clock; failing that, the oldest member that reports one. The resolved // value is stored explicitly so the caller can see which zone it landed on. const leader = group.leader_device_id ? db.prepare('SELECT timezone, reported_timezone FROM devices WHERE id = ?').get(group.leader_device_id) : null; targetTz = effectiveDeviceTz(leader); if (!targetTz) { const member = db.prepare(`SELECT d.timezone, d.reported_timezone FROM devices d JOIN device_group_members m ON m.device_id = d.id WHERE m.group_id = ? AND COALESCE(d.timezone, d.reported_timezone) IS NOT NULL ORDER BY d.created_at LIMIT 1`).get(group_id); targetTz = effectiveDeviceTz(member); } } const ctx = workspaceAccess(req, targetWorkspaceId); 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' }); } // Payload refs must live in the same workspace. Platform templates // (workspace_id IS NULL) on content / widget / layout / playlist are allowed. const refChecks = [ ['content', content_id, true], ['widgets', widget_id, true], ['layouts', layout_id, true], ['playlists', playlist_id, true], ]; for (const [table, id, allowNull] of refChecks) { if (!id) continue; const err = checkRefInWorkspace(table, id, targetWorkspaceId, { allowNullWorkspace: allowNull }); if (err) return res.status(err.status).json({ error: err.error }); } if (zone_id) { const zErr = checkZoneInWorkspace(zone_id, targetWorkspaceId); if (zErr) return res.status(zErr.status).json({ error: zErr.error }); } // A content-only schedule is turned into a playlist holding that one item. // // The dialog offers "Content (single item, optional)" and the value was stored faithfully — but // the engine only ever acts on layout_id and playlist_id, so content_id was read by nothing at // all. The schedule fired, changed nothing, and the calendar drew a block labelled with the // filename as confirmation that it would. Rather than add a third override path through the // engine and every player, give the item the same shape everything already understands: its own // playlist. That reuses the whole published/assign/push pipeline as-is. let effectivePlaylistId = playlist_id || null; if (!effectivePlaylistId && content_id) { const c = db.prepare('SELECT filename, duration_sec FROM content WHERE id = ?').get(content_id); const genId = uuidv4(); db.prepare('INSERT INTO playlists (id, name, workspace_id, user_id, status) VALUES (?, ?, ?, ?, ?)') .run(genId, `Scheduled: ${(c && c.filename) || 'item'}`, targetWorkspaceId, req.user.id, 'published'); // #237: a scheduled video is a one-item playlist with nowhere to edit the duration, so a // flat 10 here would cut the clip off with no visible knob to fix it. db.prepare('INSERT INTO playlist_items (playlist_id, content_id, sort_order, duration_sec) VALUES (?, ?, 0, ?)') .run(genId, content_id, resolveItemDuration(null, c)); // Publish through the shared path rather than hand-rolling the snapshot: players read // denormalized fields (filename, mime_type, filepath, remote_url, schedules...) out of // published_snapshot, and duplicating that shape here would rot the moment it changes. require('./playlists').publishPlaylist(genId); effectivePlaylistId = genId; } const id = uuidv4(); db.prepare(` INSERT INTO schedules (id, user_id, workspace_id, device_id, group_id, zone_id, content_id, widget_id, layout_id, playlist_id, title, start_time, end_time, timezone, recurrence, recurrence_end, priority, color) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `).run(id, req.user.id, targetWorkspaceId, device_id || null, group_id || null, zone_id || null, content_id || null, widget_id || null, layout_id || null, effectivePlaylistId, title || '', start_time, end_time, timezone || targetTz || 'UTC', recurrence || null, recurrence_end || null, priority || 0, color || '#3B82F6'); const schedule = db.prepare('SELECT * FROM schedules WHERE id = ?').get(id); res.status(201).json(schedule); }); // Update schedule. Phase 2.2m: every polymorphic target that is changing must // live in the schedule's workspace. Closes the pre-existing leak where // verifyOwnership keyed only on user_id (workspace-blind). router.put('/:id', requireScheduleWrite, (req, res) => { const schedule = req.schedule; const newDeviceId = req.body.device_id !== undefined ? req.body.device_id : schedule.device_id; const newGroupId = req.body.group_id !== undefined ? req.body.group_id : schedule.group_id; if (newDeviceId && newGroupId) { return res.status(400).json({ error: 'Cannot set both device_id and group_id' }); } if (!newDeviceId && !newGroupId) { return res.status(400).json({ error: 'Either device_id or group_id is required' }); } // For each field changing to a non-null value, verify the referenced row // lives in the schedule's workspace. Devices and groups must match exactly // (no NULL workspace path); content / widget / layout / playlist may be // platform templates (NULL workspace_id). const ownershipChecks = [ ['devices', req.body.device_id, schedule.device_id, false], ['device_groups', req.body.group_id, schedule.group_id, false], ['content', req.body.content_id, schedule.content_id, true], ['widgets', req.body.widget_id, schedule.widget_id, true], ['layouts', req.body.layout_id, schedule.layout_id, true], ['playlists', req.body.playlist_id, schedule.playlist_id, true], ]; for (const [table, newVal, oldVal, allowNull] of ownershipChecks) { if (newVal === undefined || newVal === oldVal || !newVal) continue; const err = checkRefInWorkspace(table, newVal, schedule.workspace_id, { allowNullWorkspace: allowNull }); if (err) return res.status(err.status).json({ error: err.error }); } const fields = ['device_id', 'group_id', 'zone_id', 'content_id', 'widget_id', 'layout_id', 'playlist_id', 'title', 'start_time', 'end_time', 'timezone', 'recurrence', 'recurrence_end', 'priority', 'enabled', 'color']; const updates = []; const values = []; fields.forEach(f => { if (req.body[f] !== undefined) { updates.push(`${f} = ?`); values.push(req.body[f]); } }); if (req.body.group_id && !updates.some(u => u.startsWith('device_id'))) { updates.push('device_id = ?'); values.push(null); } if (req.body.device_id && !updates.some(u => u.startsWith('group_id'))) { updates.push('group_id = ?'); values.push(null); } if (updates.length > 0) { updates.push("updated_at = strftime('%s','now')"); values.push(req.params.id); db.prepare(`UPDATE schedules SET ${updates.join(', ')} WHERE id = ?`).run(...values); } res.json(db.prepare('SELECT * FROM schedules WHERE id = ?').get(req.params.id)); }); // Delete schedule router.delete('/:id', requireScheduleWrite, (req, res) => { db.prepare('DELETE FROM schedules WHERE id = ?').run(req.params.id); res.json({ success: true }); }); // Helper: expand a schedule with recurrence into individual events for a date range function expandSchedule(schedule, rangeStart, rangeEnd) { const events = []; const start = new Date(schedule.start_time); const end = new Date(schedule.end_time); const durationMs = end - start; if (!schedule.recurrence) { if (end >= rangeStart && start <= rangeEnd) { events.push({ ...schedule, instance_start: schedule.start_time, instance_end: schedule.end_time }); } return events; } const rule = parseRRule(schedule.recurrence); if (!rule) { events.push({ ...schedule, instance_start: schedule.start_time, instance_end: schedule.end_time }); return events; } const recEnd = schedule.recurrence_end ? new Date(schedule.recurrence_end) : rangeEnd; // Walk DAY BY DAY across the visible range and draw every day the rule actually fires. // // The old loop stepped by the recurrence unit from the schedule's original start, which got both // of the common presets wrong: // - WEEKLY advanced a whole week at a time, so dayOfWeek never changed and a // FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR rule matched only its start day — one event a week, or // none at all if it had been created on a weekend. // - Starting from the original start with a 366-iteration cap meant a schedule begun more than // a year ago never reached the current week, so it drew nothing whatsoever. // The engine meanwhile evaluates day-of-week directly, so it ran Mon-Fri regardless. The calendar // is the operator's only view of what is scheduled, and it disagreed with reality in both // directions. Iterating the range instead means the drawing follows the same rule the engine // applies, and the cost is bounded by the window being displayed rather than by history. const dayMs = 24 * 60 * 60 * 1000; const startTimeOfDay = { h: start.getHours(), m: start.getMinutes(), s: start.getSeconds() }; // First candidate day: the later of the schedule's start and the window's start. let cursor = new Date(Math.max(start.getTime(), rangeStart.getTime())); cursor.setHours(startTimeOfDay.h, startTimeOfDay.m, startTimeOfDay.s, 0); if (cursor.getTime() + durationMs < rangeStart.getTime()) cursor = new Date(cursor.getTime() + dayMs); const lastDay = new Date(Math.min(rangeEnd.getTime(), recEnd.getTime())); const interval = Math.max(1, rule.interval || 1); while (cursor <= lastDay) { const instanceEnd = new Date(cursor.getTime() + durationMs); let fires = false; switch (rule.freq) { case 'DAILY': // Honour the interval by counting whole days from the original start. fires = Math.floor((cursor - start) / dayMs) % interval === 0; break; case 'WEEKLY': // byDay is what makes Mon-Fri work. Without it, weekly means "the start's weekday". fires = rule.byDay ? rule.byDay.includes(cursor.getDay()) : cursor.getDay() === start.getDay(); break; case 'MONTHLY': fires = cursor.getDate() === start.getDate(); break; default: fires = true; } if (fires && (cursor >= rangeStart || instanceEnd >= rangeStart)) { events.push({ ...schedule, instance_start: cursor.toISOString(), instance_end: instanceEnd.toISOString(), }); } cursor = new Date(cursor.getTime() + dayMs); cursor.setHours(startTimeOfDay.h, startTimeOfDay.m, startTimeOfDay.s, 0); // DST-safe re-anchor } return events; } function parseRRule(rrule) { if (!rrule) return null; const parts = rrule.split(';'); const rule = {}; const dayMap = { SU: 0, MO: 1, TU: 2, WE: 3, TH: 4, FR: 5, SA: 6 }; for (const part of parts) { const [key, val] = part.split('='); switch (key) { case 'FREQ': rule.freq = val; break; case 'INTERVAL': rule.interval = parseInt(val); break; case 'BYDAY': rule.byDay = val.split(',').map(d => dayMap[d]).filter(d => d !== undefined); break; case 'COUNT': rule.count = parseInt(val); break; case 'UNTIL': rule.until = val; break; } } return rule; } module.exports = router; // Exported for testing, the same way playlists.js exports publishPlaylist. The calendar's // correctness is arithmetic and deserves to be checked without standing up a server. module.exports.expandSchedule = expandSchedule;