mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-13 13:53:12 -06:00
Found by driving the real server and a real browser, not by reading. Each fix has a
test that fails without it.
1. A missing upload answered 200 with the DASHBOARD. express.static falls through on a
miss and the SPA catch-all caught it, so GET /uploads/content/<gone>.mp4 returned
15KB of index.html as text/html — under the `immutable, max-age=30d` header the mount
sets before it knows the file exists. Every player downloader treats 200 as success,
so a panel stores the HTML page AS the video and caches it for a month, rendering a
black frame with nothing in any log. Reachable exactly when it hurts: a content
replace writes a new random filename and unlinks the old one. The mount now
terminates a miss with a 404 and drops the cache header.
2. Four dashboard->device socket handlers had no capability gate. dashboard:device-command
has always refused a command the panel cannot honour, and the comment above it is right
about why ("hiding the button is not enforcement — this socket is reachable directly").
Every word applied to the four handlers immediately above it, which had none: a display
declaring [] still received screenshot-request, remote-touch, remote-key and
remote-start. Measured, not inferred. They now refuse on remote.screenshot /
remote.input / remote.stream and name the capability in the ack; remote-stop stays
ungated for the same reason set_debug does. The undeclared fleet is unaffected — an
absent declaration still resolves to its platform baseline and keeps everything.
The wall panel list (#235) made this visible: it offered a Screenshot button for every
panel, including a BrightSign, which has no screenshot capability at all, and popped a
toast promising an image that was never coming. GET /api/devices now ships the RESOLVED
capability array rather than the raw column ('[]' as a STRING, which Array.isArray reads
as "pre-capability server, show everything" — wrong in the one case that matters), so
the wall list and the fleet cards can hide what a panel cannot do. The remote pad's
Scrn Off / Scrn On were gated on remote.input while the Info tab gated the same two
commands on display.power; both now agree.
3. A register with no `platform` ERASED the stored one. captureIdentity coerces a missing
field to the literal 'unknown' and persistIdentity wrote it straight over. That column
is load-bearing: platformFamily() reads it, so one reconnect from an older build turned
a Tizen panel into a browser tab and handed it a volume slider the .wgt has no handler
for — the exact control BASELINE.tizen exists to hide — while a BrightSign lost screen
power and reboot and gained screenshots it cannot take. platform and client_type are
now preserved (physical facts); client_version and contract_version still decay, because
there "we no longer know" is the truthful answer. client_type 'wgt' is also read as a
second signal for a Tizen TV.
4. PUT /api/content/:id/replace carried its own shorter copy of the ingest logic. Replacing
a video left duration_sec at the OLD clip's length and nulled width/height, so #237's
brand-new "default an item to the clip's own length" then handed out the wrong number
for every later add — 32s scheduled for a 5s video is 27s of frozen frame. Replacing an
image measured it with raw sharp metadata and thumbnailed without .rotate(),
re-introducing the EXIF-orientation bug #172 had just fixed at ingest. Both paths now
share lib/content-ingest.deriveMediaMetadata.
Verified working and NOT changed: all six item-duration insert paths (a 31.7s clip stores
32 everywhere, an explicit value always wins, and no path can store a 0); the content
revision bump + filepath refresh reaching a real device socket; a landscape wall producing
byte-identical geometry to the pre-#236 expression; a portrait wall reaching the player as
side-by-side halves; cross-workspace isolation across 29 probes.
Full suite green (1319).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
210 lines
11 KiB
JavaScript
210 lines
11 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');
|
|
|
|
// 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', {});
|
|
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;
|
|
};
|
|
|