diff --git a/frontend/js/api.js b/frontend/js/api.js index a1ebecb..786516b 100644 --- a/frontend/js/api.js +++ b/frontend/js/api.js @@ -33,6 +33,11 @@ export const api = { updateDevice: (id, data) => request(`/devices/${id}`, { method: 'PUT', body: JSON.stringify(data) }), deleteDevice: (id) => request(`/devices/${id}`, { method: 'DELETE' }), + // #109 PiP overlay: push/clear a floating overlay on a device or group. `id` may be a + // device id OR a group id (the server resolves + expands). Needs full scope (no-op for JWT). + sendPip: (id, opts) => request('/pip', { method: 'POST', body: JSON.stringify({ device_id: id, ...opts }) }), + clearPip: (id, pipId) => request('/pip/clear', { method: 'POST', body: JSON.stringify({ device_id: id, pip_id: pipId || undefined }) }), + // Provisioning pairDevice: (pairing_code, name) => request('/provision/pair', { method: 'POST', diff --git a/frontend/js/views/device-detail.js b/frontend/js/views/device-detail.js index 0cb65df..94570d8 100644 --- a/frontend/js/views/device-detail.js +++ b/frontend/js/views/device-detail.js @@ -410,6 +410,29 @@ async function loadDevice(deviceId, activeTab = null) { ${t('device.ctl.shutdown')} + + +
+
Overlay (PiP) — test
+
+ + + + + + +
+
@@ -868,6 +891,25 @@ async function setupActions(device) { document.getElementById('forceUpdateBtn')?.addEventListener('click', () => { sendWithFeedback('update', 'Update', 'device.toast.update_triggered'); }); + + // #109: PiP overlay tester — pushes/clears an overlay via the public API (POST /api/pip). + document.getElementById('sendPipBtn')?.addEventListener('click', async () => { + const uri = (document.getElementById('pipUri')?.value || '').trim(); + if (!uri) { showToast('Enter an overlay URL', 'error'); return; } + try { + const res = await api.sendPip(device.id, { + type: document.getElementById('pipType').value, + uri, + position: document.getElementById('pipPosition').value, + duration: Number(document.getElementById('pipDuration').value) || 0, + }); + showToast(`Overlay sent (${res.sent} sent, ${res.offline} offline)`, res.sent ? 'success' : 'warning'); + } catch (err) { showToast(err.message, 'error'); } + }); + document.getElementById('clearPipBtn')?.addEventListener('click', async () => { + try { await api.clearPip(device.id); showToast('Overlay cleared', 'success'); } + catch (err) { showToast(err.message, 'error'); } + }); } function setupRemote(device) { diff --git a/server/config/api-surface.js b/server/config/api-surface.js index d67485d..dc9a3da 100644 --- a/server/config/api-surface.js +++ b/server/config/api-surface.js @@ -36,6 +36,7 @@ const PUBLIC_ROUTERS = [ { path: '/api/playlists', mod: './routes/playlists' }, { path: '/api/activity', mod: './routes/activity' }, { path: '/api/kiosk', mod: './routes/kiosk', renderBypass: true }, + { path: '/api/pip', mod: './routes/pip' }, ]; const JWT_ONLY_ROUTERS = [ diff --git a/server/routes/pip.js b/server/routes/pip.js new file mode 100644 index 0000000..68a29e5 --- /dev/null +++ b/server/routes/pip.js @@ -0,0 +1,159 @@ +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'); + +// 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'), (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); + res.json({ success: true, pip_id, target: targets.kind, ...summarize(results) }); +}); + +// 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); + res.json({ success: true, target: targets.kind, ...summarize(results) }); +} + +router.post('/clear', requireScope('full'), handleClear); +router.delete('/', requireScope('full'), handleClear); + +module.exports = router; diff --git a/server/test/api.test.js b/server/test/api.test.js index 6ed3221..13d3291 100644 --- a/server/test/api.test.js +++ b/server/test/api.test.js @@ -88,6 +88,12 @@ before(async () => { S.deviceToken = 'devtok_' + crypto.randomBytes(16).toString('hex'); db.prepare("INSERT INTO devices (id,name,user_id,workspace_id,device_token,status,created_at) VALUES (?,?,?,?,?,'offline',strftime('%s','now'))") .run(S.deviceId, 'WS-dev', S.user1, S.wsA, S.deviceToken); + // #109 PiP fixtures: a device in workspace B (cross-tenant isolation) and the wsA + // device as a member of the wsA group (group-targeting expansion). + S.deviceIdB = crypto.randomUUID(); + db.prepare("INSERT INTO devices (id,name,user_id,workspace_id,device_token,status,created_at) VALUES (?,?,?,?,?,'offline',strftime('%s','now'))") + .run(S.deviceIdB, 'WS-dev-B', S.user1, S.wsB, 'devtok_' + crypto.randomBytes(16).toString('hex')); + db.prepare('INSERT INTO device_group_members (group_id, device_id) VALUES (?, ?)').run(S.groupId, S.deviceId); db.close(); }); @@ -133,7 +139,7 @@ test('partition: the public token surface is exactly the reviewed set (snapshot const EXPECTED_PUBLIC = [ '/api/devices', '/api/content', '/api/folders', '/api/assignments', '/api/layouts', '/api/widgets', '/api/schedules', '/api/walls', '/api/reports', '/api/groups', - '/api/playlists', '/api/activity', '/api/kiosk', + '/api/playlists', '/api/activity', '/api/kiosk', '/api/pip', ].sort(); assert.deepEqual(PUBLIC_ROUTERS.map(r => r.path).sort(), EXPECTED_PUBLIC); }); @@ -312,3 +318,59 @@ test('token-auth: last_used_at is stamped on first use', async () => { const after = (await jfetch('/api/tokens', auth(S.jwt))).body.find(t => t.id === created.body.id); assert.ok(after.last_used_at, 'last_used_at is set after first use'); }); + +// ───────────────────────── TIER 5: #109 PiP OVERLAY (POST /api/pip) ───────────────────────── +// MVP: image/web overlay pushed to a device/group, full-scope, workspace-isolated. +const pipBody = (over = {}) => ({ device_id: S.deviceId, type: 'image', uri: 'https://example.com/x.png', position: 'top-right', width: 480, height: 360, duration: 30, ...over }); + +// authz: requireScope('full') +test('pip: read/write tokens are rejected (403, needs full)', async () => { + assert.equal((await jfetch('/api/pip', post(S.tok.read, pipBody()))).status, 403); + assert.equal((await jfetch('/api/pip', post(S.tok.write, pipBody()))).status, 403); +}); +test('pip: full token is accepted (offline device reported, not queued)', async () => { + const res = await jfetch('/api/pip', post(S.tok.full, pipBody())); + assert.equal(res.status, 200); + assert.equal(res.body.target, 'device'); + assert.ok(res.body.pip_id, 'server generates a pip_id'); + assert.equal(res.body.total, 1); + assert.equal(res.body.offline, 1, 'the offline device is reported offline'); + assert.equal(res.body.sent, 0); +}); + +// workspace isolation: a wsA token cannot address a wsB device, nor a non-existent id. +test('pip: workspace isolation — wsA token cannot target a wsB device (404)', async () => { + assert.equal((await jfetch('/api/pip', post(S.tok.full, pipBody({ device_id: S.deviceIdB }))).then(r => r.status)), 404); +}); +test('pip: unknown device/group id is 404', async () => { + assert.equal((await jfetch('/api/pip', post(S.tok.full, pipBody({ device_id: crypto.randomUUID() })))).status, 404); +}); + +// group targeting: device_id resolves to a group and expands to its members. +test('pip: group id expands to members (group with 1 member)', async () => { + const res = await jfetch('/api/pip', post(S.tok.full, pipBody({ device_id: S.groupId }))); + assert.equal(res.status, 200); + assert.equal(res.body.target, 'group'); + assert.equal(res.body.total, 1, 'the group has one member device'); +}); + +// payload validation +test('pip: payload validation (type / uri / position / bounds / color)', async () => { + assert.equal((await jfetch('/api/pip', post(S.tok.full, pipBody({ type: 'video' })))).status, 400); // not in allowlist + assert.equal((await jfetch('/api/pip', post(S.tok.full, pipBody({ uri: 'ftp://x/y' })))).status, 400); // bad scheme + assert.equal((await jfetch('/api/pip', post(S.tok.full, pipBody({ uri: 'not a url' })))).status, 400); + assert.equal((await jfetch('/api/pip', post(S.tok.full, pipBody({ position: 'middle' })))).status, 400); + assert.equal((await jfetch('/api/pip', post(S.tok.full, pipBody({ width: 5 })))).status, 400); // below DIM_MIN + assert.equal((await jfetch('/api/pip', post(S.tok.full, pipBody({ duration: -1 })))).status, 400); + assert.equal((await jfetch('/api/pip', post(S.tok.full, pipBody({ opacity: 2 })))).status, 400); + assert.equal((await jfetch('/api/pip', post(S.tok.full, pipBody({ background_color: 'red' })))).status, 400); + assert.equal((await jfetch('/api/pip', post(S.tok.full, pipBody({ device_id: '' })))).status, 400); +}); + +// clear: POST /api/pip/clear and DELETE /api/pip, both full-scope. +test('pip clear: full token clears (POST /clear and DELETE), read token rejected', async () => { + assert.equal((await jfetch('/api/pip/clear', post(S.tok.full, { device_id: S.deviceId }))).status, 200); + assert.equal((await jfetch('/api/pip/clear', post(S.tok.read, { device_id: S.deviceId }))).status, 403); + const del = await jfetch('/api/pip', { method: 'DELETE', ...auth(S.tok.full), body: JSON.stringify({ device_id: S.deviceId }) }); + assert.equal(del.status, 200); +}); diff --git a/server/test/pip-overlay.test.js b/server/test/pip-overlay.test.js new file mode 100644 index 0000000..0b78626 --- /dev/null +++ b/server/test/pip-overlay.test.js @@ -0,0 +1,135 @@ +'use strict'; + +// #109 PiP player-layer test. Loads the REAL tizen/js/player.js + tizen/js/pip-overlay.js +// into a vm context with a minimal DOM shim (the repo has no jsdom; node --test only). +// Proves the overlay shows and auto-dismisses WITHOUT changing the playlist signature +// underneath — i.e. PipOverlay writes only to #pip and never to #stage / PlaylistPlayer. + +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const vm = require('node:vm'); + +// --- minimal DOM element shim: only what PlaylistPlayer.renderImage + PipOverlay use --- +function makeEl() { + const el = { + tag: '', style: {}, className: '', attrs: {}, children: [], _html: '', _src: '', _text: '', + appendChild(c) { this.children.push(c); this._html = ''; return c; }, + querySelector(sel) { return this.children.find(c => c.tag === sel) || null; }, + setAttribute(k, v) { this.attrs[k] = v; }, + removeAttribute(k) { delete this.attrs[k]; }, + addEventListener() {}, removeEventListener() {}, + classList: { add() {}, remove() {}, contains() { return false; } }, + load() {}, pause() {}, play() { return { catch() {} }; }, + }; + Object.defineProperty(el, 'innerHTML', { get() { return this._html; }, set(v) { this._html = v; if (v === '') this.children = []; } }); + Object.defineProperty(el, 'src', { get() { return this._src; }, set(v) { this._src = v; } }); + Object.defineProperty(el, 'textContent', { get() { return this._text; }, set(v) { this._text = v; } }); + return el; +} + +function loadPlayerContext() { + // Controllable timer so the duration teardown is deterministic (no wall-clock waits). + const timers = {}; + let seq = 0; + const sandbox = { + console, + Date, + setTimeout: (fn) => { const id = ++seq; timers[id] = fn; return id; }, + clearTimeout: (id) => { delete timers[id]; }, + setInterval: () => 0, + clearInterval: () => {}, + localStorage: { getItem: () => null, setItem() {}, removeItem() {} }, + navigator: { language: 'en' }, + }; + sandbox.document = { createElement: (tag) => { const e = makeEl(); e.tag = tag; return e; } }; + sandbox.window = sandbox; + vm.createContext(sandbox); + const read = (p) => fs.readFileSync(path.join(__dirname, '..', '..', 'tizen', 'js', p), 'utf8'); + vm.runInContext(read('player.js'), sandbox, { filename: 'player.js' }); + vm.runInContext(read('pip-overlay.js'), sandbox, { filename: 'pip-overlay.js' }); + return { sandbox, timers }; +} + +test('pip: overlay shows in #pip and never touches #stage / the playlist signature', () => { + const { sandbox } = loadPlayerContext(); + const stage = makeEl(); + const pip = makeEl(); + + // A 1-item image playlist; capture the signature the renderer computes. + const player = new sandbox.PlaylistPlayer(stage, () => 'http://server'); + player.load([{ content_id: 'c1', mime_type: 'image/png', sort_order: 0, duration_sec: 10 }]); + const sigBefore = player.sig; + const stageChildrenBefore = stage.children.length; + assert.ok(sigBefore, 'player computed a playlist signature'); + assert.ok(stageChildrenBefore >= 1, 'playlist rendered into #stage'); + + const logs = []; + const overlay = new sandbox.PipOverlay(pip, { document: sandbox.document, log: (lvl, msg) => logs.push([lvl, msg]) }); + + overlay.show({ pip_id: 'p1', type: 'image', uri: 'http://img/x.png', position: 'top-right', width: 480, height: 360, duration: 30 }); + assert.equal(pip.children.length, 1, 'overlay box rendered into #pip'); + assert.equal(player.sig, sigBefore, 'playlist signature unchanged by pip show'); + assert.equal(stage.children.length, stageChildrenBefore, '#stage untouched by pip show'); + assert.ok(logs.some(l => l[1].indexOf('pip show') === 0), 'show reported over the log channel'); +}); + +test('pip: duration timer auto-dismisses without disturbing the playlist', () => { + const { sandbox, timers } = loadPlayerContext(); + const stage = makeEl(); + const pip = makeEl(); + const player = new sandbox.PlaylistPlayer(stage, () => 'http://server'); + player.load([{ content_id: 'c1', mime_type: 'image/png', sort_order: 0, duration_sec: 10 }]); + const sigBefore = player.sig; + + const overlay = new sandbox.PipOverlay(pip, { document: sandbox.document }); + overlay.show({ pip_id: 'p1', type: 'image', uri: 'http://img/x.png', duration: 5 }); + assert.equal(pip.children.length, 1, 'overlay shown'); + + // Fire the scheduled duration timer (deterministic: the sandbox setTimeout captured it). + const ids = Object.keys(timers); + assert.equal(ids.length, 1, 'a single duration timer was scheduled'); + timers[ids[0]](); + + assert.equal(pip.children.length, 0, 'overlay auto-dismissed at duration'); + assert.equal(player.sig, sigBefore, 'playlist signature still unchanged after dismiss'); +}); + +test('pip: web type renders an iframe; last-show-wins; targeted clear is id-aware', () => { + const { sandbox } = loadPlayerContext(); + const pip = makeEl(); + const overlay = new sandbox.PipOverlay(pip, { document: sandbox.document }); + + overlay.show({ pip_id: 'web1', type: 'web', uri: 'https://example.com', duration: 0 }); + assert.equal(pip.children.length, 1); + const box = pip.children[0]; + assert.ok(box.children.some(c => c.tag === 'iframe'), 'web overlay uses an