mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-13 13:53:12 -06:00
Server side of the inverted capture path. The host half is not here — see the end of this message. Every other player is TOLD to capture: the server emits device:screenshot-request over the device socket and the page photographs itself. A BrightSign cannot photograph itself. Video decodes onto a hardware plane the DOM cannot read, so an in-page canvas returns a frame with the content missing — which is why that platform has been answering screenshot requests with a card explaining that the video is uncapturable. Only the host, through the player's own DWS, can get a real frame. The obvious way to ask the host is through the page, and it does not work. On an XT245 (BOS 9.1.93.2) page->host messaging is dead after load: instrumenting the host to echo the `reason` of EVERY roHtmlWidgetEvent produced nothing at all while the page was posting, though the boot-time probe round-trips. The registry is not an alternative either — a running BrightScript does not observe registry writes made by anyone else, proven by writing the key externally through the DWS and watching the host ignore it. What the host CAN do is HTTP; it already fetches its own package updates that way. So the direction is inverted: the request waits here and the host collects it. The image comes back over a plain POST, which means a capture will work even when the page is wedged — exactly when an operator most wants to see the screen. Held in memory on purpose. A capture request is worthless a minute after it was made — someone clicked a button and is watching for the result — so persisting it would only add a way to deliver a stale screenshot after a restart. Bounded and TTL'd so a fleet going offline mid-request cannot grow it, and a repeat request REPLACES rather than queues so a 1fps stream builds no backlog. Authenticated with the same device_id + device_token pair the socket uses. /api/brightsign/package is public because a player fetches it before it has any identity; a screenshot is a picture of a customer's screen and belongs to one display. deviceSocket now exposes ONE ingestScreenshot() used by both the socket handler and the HTTP route, so a BrightSign screenshot reaches the dashboard by exactly the route every other player's does rather than becoming a second, subtly different feature. Note those exports must be attached AFTER `module.exports = function setupDeviceSocket`, which reassigns the object — attaching above it silently wipes them, which cost a debugging round. NOT INCLUDED, deliberately: the host-side poll. Adding it to autorun.brs's main loop kills the BrightScript script within seconds of boot — the page keeps playing, because the widget outlives the script, so from the dashboard it looks healthy. Cause unidentified; BrightScript runtime faults do not reach /api/v1/logs, so there is no error text to read. Half a feature that silently takes down the host is worse than none, so the server waits for a host that can safely ask. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014skWYXJUWhF73EvNPgB2AS
222 lines
12 KiB
JavaScript
222 lines
12 KiB
JavaScript
const heartbeat = require('../services/heartbeat');
|
|
const { resolveSessionUser } = require('../middleware/auth');
|
|
const { db } = require('../db/database');
|
|
const { accessContext, accessibleWorkspaceIds } = require('../lib/tenancy');
|
|
const { workspaceRoom } = require('../lib/socket-rooms');
|
|
const { protectSocket } = require('../lib/safe-socket');
|
|
const playerCapabilities = require('../lib/player-capabilities');
|
|
const bsSnapshotQueue = require('../lib/brightsign-snapshot-queue');
|
|
|
|
// Phase 2.3: workspace-scoped socket rooms + per-command permission gates.
|
|
// Replaces the previous flat dashboardNs.emit broadcast (which leaked every
|
|
// device's status/screenshot/playback events to every connected dashboard)
|
|
// and the legacy admin/superadmin role bypass (dead code post-Phase-1
|
|
// rename - admin -> user, superadmin -> platform_admin).
|
|
//
|
|
// On connect: enumerate the user's accessible workspace_ids and socket.join
|
|
// a room per workspace. Outbound broadcasts route via dashboardNs.to(room).
|
|
// Inbound commands check permission against the target device's workspace.
|
|
|
|
// Permission gate for inbound socket commands. Read tier = workspace_viewer+;
|
|
// write tier = workspace_editor+. Platform_admin and org_owner/admin always
|
|
// pass via actingAs.
|
|
function canActOnDevice(socket, deviceId, tier /* 'read' | 'write' */) {
|
|
const device = db.prepare('SELECT workspace_id FROM devices WHERE id = ?').get(deviceId);
|
|
if (!device || !device.workspace_id) return false;
|
|
const ws = db.prepare('SELECT * FROM workspaces WHERE id = ?').get(device.workspace_id);
|
|
if (!ws) return false;
|
|
const ctx = accessContext(socket.userId, socket.userRole, ws);
|
|
if (!ctx) return false;
|
|
if (ctx.actingAs) return true; // platform_admin or org admin
|
|
if (tier === 'read') return !!ctx.workspaceRole; // viewer/editor/admin all OK
|
|
// write tier: workspace_editor or workspace_admin
|
|
return ctx.workspaceRole === 'workspace_editor' || ctx.workspaceRole === 'workspace_admin';
|
|
}
|
|
|
|
module.exports = function setupDashboardSocket(io) {
|
|
const dashboardNs = io.of('/dashboard');
|
|
const deviceNs = io.of('/device');
|
|
|
|
dashboardNs.use((socket, next) => {
|
|
const token = socket.handshake.auth?.token;
|
|
if (!token) return next(new Error('Authentication required'));
|
|
let session;
|
|
try {
|
|
// Same resolver as requireAuth, so the socket inherits the pre-TOTP refusal and the
|
|
// forced-password-change gate that the HTTP surface enforces.
|
|
session = resolveSessionUser(token);
|
|
} catch (err) {
|
|
if (err.code === 'mfa_required') return next(new Error('mfa_required'));
|
|
if (err.code === 'password_change_required') return next(new Error('password_change_required'));
|
|
return next(new Error('Invalid token'));
|
|
}
|
|
// Break-glass identities have no users row and no workspace membership, so
|
|
// canActOnDevice -> accessContext already denied them every command. Refuse the
|
|
// handshake rather than hold open a socket that can do nothing.
|
|
if (session.viaRecovery) return next(new Error('Invalid token'));
|
|
socket.userId = session.user.id;
|
|
// Role + existence come from the LIVE users row, not the token claim: a deleted or
|
|
// demoted user no longer keeps fleet control for the remainder of a 7-day JWT.
|
|
socket.userRole = session.user.role;
|
|
next();
|
|
});
|
|
|
|
dashboardNs.on('connection', (socket) => {
|
|
// #146: same per-connection fail-fast as the device namespace — a throwing
|
|
// dashboard handler disconnects only that client, never crashes the server.
|
|
protectSocket(socket, () => socket.userId);
|
|
// Note on workspace-switch lifecycle: the switcher (Phase 3 MVP) calls
|
|
// window.location.reload() after switching, which forces a new socket
|
|
// connection with fresh JWT claims. So workspace memberships are
|
|
// re-evaluated at connect time and we don't need to re-evaluate per-emit.
|
|
const wsIds = accessibleWorkspaceIds(socket.userId, socket.userRole);
|
|
for (const wsId of wsIds) socket.join(workspaceRoom(wsId));
|
|
console.log(`Dashboard client connected: ${socket.id} (user: ${socket.userId}, rooms: ${wsIds.length})`);
|
|
|
|
/*
|
|
* The capability gate for the remote-view handlers.
|
|
*
|
|
* dashboard:device-command below has always checked this; these four did not, and the
|
|
* reasoning that justifies it there applies here word for word — this socket is reachable
|
|
* directly, and a dashboard tab left open still renders the controls the panel had when the
|
|
* page was drawn. Measured: a display declaring `[]` still received screenshot-request,
|
|
* remote-touch, remote-key and remote-start, silently, and the operator got a toast saying
|
|
* the screenshot was on its way.
|
|
*
|
|
* The ack is OPTIONAL by design: the current dashboard senders (frontend/js/socket.js) pass
|
|
* no callback, and a newer one that does gets told which capability is missing instead of
|
|
* watching a spinner. Refusing loudly is the whole point of the mechanism.
|
|
*/
|
|
// Silent by design, unlike the command path: the fleet view asks EVERY visible card for a
|
|
// screenshot every 30s, so a log line per refusal would be hundreds every half-minute on a
|
|
// real fleet. The ack carries the reason to anyone who asked for one.
|
|
function capabilityRefused(device_id, cap, ack) {
|
|
const devRow = db.prepare('SELECT * FROM devices WHERE id = ?').get(device_id);
|
|
if (playerCapabilities.supports(devRow, cap)) return false;
|
|
if (typeof ack === 'function') ack({ delivered: false, reason: 'unsupported', capability: cap });
|
|
return true;
|
|
}
|
|
|
|
socket.on('dashboard:request-screenshot', (data, ack) => {
|
|
const { device_id } = data;
|
|
if (!canActOnDevice(socket, device_id, 'read')) return;
|
|
if (capabilityRefused(device_id, 'remote.screenshot', ack)) return;
|
|
const conn = heartbeat.getConnection(device_id);
|
|
if (conn) deviceNs.to(device_id).emit('device:screenshot-request', {});
|
|
// BrightSign additionally leaves the request where its HOST can collect it. The page there
|
|
// can capture only the graphics plane — video lives on a hardware plane the DOM cannot read —
|
|
// and it cannot forward the request to the host either, because page->host messaging is dead
|
|
// after load on that platform. So the host polls for this over HTTP, the one direction that
|
|
// works. See lib/brightsign-snapshot-queue.js.
|
|
try {
|
|
const row = db.prepare('SELECT platform FROM devices WHERE id = ?').get(device_id);
|
|
if (row && String(row.platform || '').toLowerCase() === 'brightsign') {
|
|
bsSnapshotQueue.request(device_id, { width: 960, height: 540 });
|
|
}
|
|
} catch (e) { /* the socket path already fired; queueing is the bonus, never the blocker */ }
|
|
if (typeof ack === 'function') ack({ delivered: !!conn, reason: conn ? undefined : 'offline' });
|
|
});
|
|
|
|
socket.on('dashboard:remote-touch', (data, ack) => {
|
|
const { device_id, x, y, x2, y2, duration, action } = data;
|
|
if (!canActOnDevice(socket, device_id, 'write')) return;
|
|
if (capabilityRefused(device_id, 'remote.input', ack)) return;
|
|
// #159: a swipe/drag carries an end point + duration (for scrolling); tap is just x/y.
|
|
deviceNs.to(device_id).emit('device:remote-touch', { x, y, x2, y2, duration, action });
|
|
if (typeof ack === 'function') ack({ delivered: true });
|
|
});
|
|
|
|
socket.on('dashboard:remote-key', (data, ack) => {
|
|
const { device_id, keycode } = data;
|
|
if (!canActOnDevice(socket, device_id, 'write')) return;
|
|
if (capabilityRefused(device_id, 'remote.input', ack)) return;
|
|
console.log(`Remote key: ${keycode} -> ${device_id}`);
|
|
deviceNs.to(device_id).emit('device:remote-key', { keycode });
|
|
if (typeof ack === 'function') ack({ delivered: true });
|
|
});
|
|
|
|
// Track which devices THIS dashboard socket has a live remote (screenshot-stream) session on, so
|
|
// we can stop them if the tab closes / the socket drops — an orphaned stream keeps the device
|
|
// capturing every second and can starve a weak panel's decoder (the black-screen we hit).
|
|
socket.remoteSessions = new Set();
|
|
|
|
socket.on('dashboard:remote-start', (data, ack) => {
|
|
const { device_id } = data;
|
|
if (!canActOnDevice(socket, device_id, 'write')) return;
|
|
if (capabilityRefused(device_id, 'remote.stream', ack)) return;
|
|
const room = deviceNs.adapter.rooms.get(device_id);
|
|
console.log(`Remote start for ${device_id}, room has ${room?.size || 0} socket(s)`);
|
|
socket.remoteSessions.add(device_id);
|
|
deviceNs.to(device_id).emit('device:remote-start', {});
|
|
console.log(`Remote session started for device ${device_id}`);
|
|
});
|
|
|
|
// Deliberately NOT capability-gated, for the same reason set_debug isn't: stopping is the
|
|
// thing you need most when a panel's declaration has changed underneath a live stream, and
|
|
// refusing it would strand that panel capturing forever.
|
|
socket.on('dashboard:remote-stop', (data) => {
|
|
const { device_id } = data;
|
|
if (!canActOnDevice(socket, device_id, 'write')) return;
|
|
socket.remoteSessions.delete(device_id);
|
|
deviceNs.to(device_id).emit('device:remote-stop', {});
|
|
console.log(`Remote session stopped for device ${device_id}`);
|
|
});
|
|
|
|
socket.on('dashboard:device-command', (data, ack) => {
|
|
const { device_id, type, payload } = data;
|
|
if (!canActOnDevice(socket, device_id, 'write')) {
|
|
if (typeof ack === 'function') ack({ delivered: false, reason: 'forbidden' });
|
|
return;
|
|
}
|
|
|
|
// Hiding the button is not enforcement. This socket is reachable directly, group sends fan
|
|
// out to mixed-platform fleets, and an older dashboard tab left open still renders the old
|
|
// controls. A command the panel cannot honour is refused HERE, with the capability named, so
|
|
// it fails loudly instead of being delivered and silently ignored — which is the failure
|
|
// this whole mechanism exists to end.
|
|
const devRow = db.prepare('SELECT * FROM devices WHERE id = ?').get(device_id);
|
|
const verdict = playerCapabilities.commandAllowed(devRow, type);
|
|
if (!verdict.ok) {
|
|
console.warn(`Command ${type} refused for device ${device_id}: needs ${verdict.capability}`);
|
|
if (typeof ack === 'function') {
|
|
ack({ delivered: false, reason: 'unsupported', capability: verdict.capability });
|
|
}
|
|
return;
|
|
}
|
|
|
|
const room = deviceNs.adapter.rooms.get(device_id);
|
|
if (room && room.size > 0) {
|
|
deviceNs.to(device_id).emit('device:command', { type, payload });
|
|
console.log(`Command delivered to device ${device_id}: ${type}`);
|
|
if (typeof ack === 'function') ack({ delivered: true });
|
|
return;
|
|
}
|
|
// Device offline at emit time. Try to queue (lazy require so reverting
|
|
// the queue commit doesn't break this commit - MODULE_NOT_FOUND on the
|
|
// first try gets cached by Node's module loader, giving consistent
|
|
// queued=false behavior on every subsequent call).
|
|
let queued = false;
|
|
try {
|
|
const queue = require('../lib/command-queue');
|
|
queued = queue.queueCommand(device_id, type, payload);
|
|
} catch (e) { /* command-queue module absent; fall through to lost */ }
|
|
console.log(`Command for offline device ${device_id}: ${type} (queued=${queued})`);
|
|
if (typeof ack === 'function') ack({ delivered: false, queued, reason: 'offline' });
|
|
});
|
|
|
|
socket.on('disconnect', () => {
|
|
console.log(`Dashboard client disconnected: ${socket.id}`);
|
|
// Stop any remote screenshot streams this socket left running (tab closed / navigated away),
|
|
// so the device isn't left capturing forever.
|
|
for (const device_id of socket.remoteSessions) {
|
|
deviceNs.to(device_id).emit('device:remote-stop', {});
|
|
console.log(`Auto-stopped orphaned remote session for ${device_id} (dashboard socket ${socket.id} gone)`);
|
|
}
|
|
socket.remoteSessions.clear();
|
|
});
|
|
});
|
|
|
|
return dashboardNs;
|
|
};
|
|
|