screentinker/server/routes/pip.js
Claude c393cf8ab3 Hold overlay pushes to the same write check as every other fleet action
A PiP overlay renders across a live screen — an arbitrary web page, at full resolution, for as long
as the operator wants. That is a fleet-affecting write, but the three routes that perform it carried
only requireScope('full'), which gates API tokens and is a deliberate pass-through for dashboard
sessions. The file's own comment says so ("No-op for JWT sessions"), on the assumption that
something else covered that case. Nothing did.

Every sibling route pairs the two checks — device-groups.js gates POST /:id/command with
`requireScope('full'), requireGroupWrite`. These had only the half that does nothing for a logged-in
user, so a member who is refused on every other device mutation was accepted here.

requireFleetWrite restores the pairing on POST /, POST /clear and DELETE /, resolving the caller's
context against the workspace the same way the rest of the codebase does.

5 tests pin both directions: refused for a read-only member on all three routes and for an
unauthenticated caller, still allowed for a workspace_editor and for an org owner acting into the
workspace (actingAs, whose workspaceRole is null and must not read as a viewer).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-07-30 20:46:03 -05:00

182 lines
8.9 KiB
JavaScript

const express = require('express');
const router = express.Router();
const { v4: uuidv4 } = require('uuid');
const { db } = require('../db/database');
// #109 PiP: a real-time floating overlay PUSHED to a device/group. Fleet-affecting,
// full-trust (a `web` overlay renders an arbitrary page in the player), so — like the
// group command route — it requires the 'full' token scope. No-op for JWT sessions.
const { requireScope } = require('../middleware/apiToken');
const { accessContext } = require('../lib/tenancy');
// requireScope('full') gates API TOKENS and is a deliberate pass-through for JWT sessions
// (middleware/apiToken.js: `if (!req.viaToken) return next()`). It was the ONLY guard on these
// routes, so a dashboard session carried no write check at all here — every sibling
// fleet-affecting route pairs the scope check with a role check (see device-groups.js, where
// POST /:id/command is `requireScope('full'), requireGroupWrite`). This restores that pairing:
// a read-only member is refused, exactly as they are on every other device mutation.
function requireFleetWrite(req, res, next) {
if (!req.workspaceId) return res.status(403).json({ error: 'No workspace context' });
const ws = db.prepare('SELECT * FROM workspaces WHERE id = ?').get(req.workspaceId);
const ctx = ws && accessContext(req.user.id, req.user.role, ws);
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' });
}
next();
}
// Reuse the existing 6-hex color contract (#RRGGBB). Overlay transparency is expressed
// via the separate `opacity` field, so no alpha channel is accepted here.
const VALID_COLOR = /^#[0-9A-Fa-f]{6}$/;
const PIP_TYPES = ['image', 'web'];
const PIP_POSITIONS = ['top-right', 'top-left', 'bottom-right', 'bottom-left', 'center'];
// Numeric bounds (px / seconds). MVP keeps these conservative; sizes are clamped by
// validation, not silently coerced.
const DIM_MIN = 40, DIM_MAX = 3840; // overlay box px
const DUR_MIN = 0, DUR_MAX = 86400; // seconds; 0 = until explicitly cleared
const RADIUS_MAX = 512; // border-radius px
function intInRange(v, def, lo, hi) {
if (v === undefined || v === null || v === '') return { ok: true, val: def };
const n = Number(v);
if (!Number.isFinite(n)) return { ok: false };
const r = Math.round(n);
if (r < lo || r > hi) return { ok: false };
return { ok: true, val: r };
}
function floatInRange(v, def, lo, hi) {
if (v === undefined || v === null || v === '') return { ok: true, val: def };
const n = Number(v);
if (!Number.isFinite(n) || n < lo || n > hi) return { ok: false };
return { ok: true, val: n };
}
// Resolve a target id to its online/offline device list within the CALLER'S workspace.
// A device first, then a group; null if neither exists in this workspace (the handler
// 404s). Scoping every query by req.workspaceId is the workspace-isolation guarantee:
// a token bound to workspace A can never address a device/group in workspace B.
function resolveTargets(req, id) {
const wsId = req.workspaceId;
if (!wsId || !id) return null;
const device = db.prepare('SELECT id, name, status FROM devices WHERE id = ? AND workspace_id = ?').get(id, wsId);
if (device) return { kind: 'device', devices: [device] };
const group = db.prepare('SELECT id, name FROM device_groups WHERE id = ? AND workspace_id = ?').get(id, wsId);
if (group) {
const devices = db.prepare(`
SELECT d.id, d.name, d.status FROM devices d
JOIN device_group_members dgm ON d.id = dgm.device_id
WHERE dgm.group_id = ? AND d.workspace_id = ?
`).all(id, wsId);
return { kind: 'group', devices };
}
return null;
}
// Emit `event` to each online target, mirroring the group command route's room-size
// online check and {device_id, name, status: sent|offline} result shape. Offline
// devices are reported, never queued — PiP is ephemeral (a stale flash on reconnect
// is worse than a miss; see the proposal §6).
function emitToTargets(req, devices, event, payload) {
const deviceNs = req.app.get('io').of('/device');
const results = [];
for (const device of devices) {
const room = deviceNs.adapter.rooms.get(device.id);
if (room && room.size > 0) {
deviceNs.to(device.id).emit(event, payload);
results.push({ device_id: device.id, name: device.name, status: 'sent' });
} else {
results.push({ device_id: device.id, name: device.name, status: 'offline' });
}
}
return results;
}
function summarize(results) {
const sent = results.filter(r => r.status === 'sent').length;
const offline = results.filter(r => r.status === 'offline').length;
return { sent, offline, total: results.length, results };
}
// POST /api/pip — show an overlay on a device or group.
router.post('/', requireScope('full'), requireFleetWrite, (req, res) => {
const b = req.body || {};
if (!b.device_id) return res.status(400).json({ error: 'device_id required (device or group id)' });
if (!PIP_TYPES.includes(b.type)) return res.status(400).json({ error: `invalid type, use one of: ${PIP_TYPES.join(', ')}` });
// uri must be an absolute http(s) URL — the PLAYER fetches it directly (no server
// proxy), same trust model as remote_url content.
let parsed;
try { parsed = new URL(b.uri); } catch { return res.status(400).json({ error: 'uri must be a valid absolute URL' }); }
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
return res.status(400).json({ error: 'uri scheme must be http or https' });
}
const position = b.position == null || b.position === '' ? 'top-right' : b.position;
if (!PIP_POSITIONS.includes(position)) return res.status(400).json({ error: `invalid position, use one of: ${PIP_POSITIONS.join(', ')}` });
const width = intInRange(b.width, 480, DIM_MIN, DIM_MAX);
if (!width.ok) return res.status(400).json({ error: `width must be ${DIM_MIN}-${DIM_MAX}` });
const height = intInRange(b.height, 360, DIM_MIN, DIM_MAX);
if (!height.ok) return res.status(400).json({ error: `height must be ${DIM_MIN}-${DIM_MAX}` });
const duration = intInRange(b.duration, 0, DUR_MIN, DUR_MAX);
if (!duration.ok) return res.status(400).json({ error: `duration must be ${DUR_MIN}-${DUR_MAX} seconds (0 = until cleared)` });
const opacity = floatInRange(b.opacity, 1, 0, 1);
if (!opacity.ok) return res.status(400).json({ error: 'opacity must be between 0 and 1' });
const borderRadius = intInRange(b.border_radius, 0, 0, RADIUS_MAX);
if (!borderRadius.ok) return res.status(400).json({ error: `border_radius must be 0-${RADIUS_MAX}` });
if (b.title_color != null && b.title_color !== '' && !VALID_COLOR.test(b.title_color)) {
return res.status(400).json({ error: 'invalid title_color, use #RRGGBB' });
}
if (b.background_color != null && b.background_color !== '' && !VALID_COLOR.test(b.background_color)) {
return res.status(400).json({ error: 'invalid background_color, use #RRGGBB' });
}
const targets = resolveTargets(req, b.device_id);
if (!targets) return res.status(404).json({ error: 'device or group not found in this workspace' });
const pip_id = uuidv4();
const payload = {
pip_id,
type: b.type,
uri: b.uri,
position,
width: width.val,
height: height.val,
duration: duration.val,
opacity: opacity.val,
border_radius: borderRadius.val,
close_button: b.close_button === true,
};
if (b.title != null && b.title !== '') payload.title = String(b.title).slice(0, 200);
if (b.title_color) payload.title_color = b.title_color;
if (b.background_color) payload.background_color = b.background_color;
const results = emitToTargets(req, targets.devices, 'device:pip-show', payload);
const summary = summarize(results);
console.log(`[pip] show ${pip_id} (${b.type}) -> ${targets.kind} ${b.device_id}: ${summary.sent} sent, ${summary.offline} offline`);
res.json({ success: true, pip_id, target: targets.kind, ...summary });
});
// Clear an overlay. DELETE /api/pip and POST /api/pip/clear are equivalent; an omitted
// pip_id clears whatever is showing.
function handleClear(req, res) {
const b = req.body || {};
if (!b.device_id) return res.status(400).json({ error: 'device_id required (device or group id)' });
const targets = resolveTargets(req, b.device_id);
if (!targets) return res.status(404).json({ error: 'device or group not found in this workspace' });
const payload = b.pip_id ? { pip_id: String(b.pip_id) } : {};
const results = emitToTargets(req, targets.devices, 'device:pip-clear', payload);
const summary = summarize(results);
console.log(`[pip] clear ${b.pip_id || '(all)'} -> ${targets.kind} ${b.device_id}: ${summary.sent} sent, ${summary.offline} offline`);
res.json({ success: true, target: targets.kind, ...summary });
}
router.post('/clear', requireScope('full'), requireFleetWrite, handleClear);
router.delete('/', requireScope('full'), requireFleetWrite, handleClear);
module.exports = router;