mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-14 14:23:14 -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
458 lines
24 KiB
JavaScript
458 lines
24 KiB
JavaScript
const express = require('express');
|
|
const router = express.Router();
|
|
const { db } = require('../db/database');
|
|
const { PLATFORM_ROLES, ELEVATED_ROLES, isPlatformStaff } = require('../middleware/auth');
|
|
// Phase 2.2a: workspace-aware access. accessContext returns { workspaceRole, actingAs }
|
|
// or null based on the caller's reach into a specific workspace.
|
|
const { accessContext } = require('../lib/tenancy');
|
|
const { stripDeviceSecrets, stripDeviceSecretsForList } = require('../lib/device-sanitize');
|
|
const { layoutZones, orphanCountsByDevice } = require('../lib/zone-validate');
|
|
const deviceSettings = require('../lib/device-settings'); // #150 delete+re-pair settings preservation
|
|
const playerCapabilities = require('../lib/player-capabilities');
|
|
|
|
// List devices in the caller's current workspace.
|
|
// Phase 2.2a: filter by workspace_id instead of user_id. The caller's current
|
|
// workspace is resolved by resolveTenancy middleware from JWT or query/header
|
|
// override. Platform_admin and org_owner/admin see whichever workspace they
|
|
// are currently switched into (cross-workspace visibility comes from
|
|
// switch-workspace, not from a special list filter).
|
|
router.get('/', (req, res) => {
|
|
if (!req.workspaceId) return res.json([]);
|
|
const limit = Math.min(parseInt(req.query.limit) || 100, 500);
|
|
const offset = parseInt(req.query.offset) || 0;
|
|
const devices = db.prepare(`
|
|
SELECT d.*,
|
|
t.battery_level, t.battery_charging, t.storage_free_mb, t.storage_total_mb,
|
|
t.ram_free_mb, t.ram_total_mb, t.wifi_ssid, t.wifi_rssi, t.uptime_seconds, t.local_ip,
|
|
t.cpu_usage,
|
|
s.filepath as screenshot_path, s.captured_at as screenshot_at,
|
|
u.email as owner_email, u.name as owner_name
|
|
FROM devices d
|
|
LEFT JOIN users u ON d.user_id = u.id
|
|
LEFT JOIN (
|
|
SELECT dt.* FROM device_telemetry dt
|
|
INNER JOIN (SELECT device_id, MAX(reported_at) as max_at FROM device_telemetry GROUP BY device_id) latest
|
|
ON dt.device_id = latest.device_id AND dt.reported_at = latest.max_at
|
|
) t ON d.id = t.device_id
|
|
LEFT JOIN (
|
|
SELECT sc.* FROM screenshots sc
|
|
INNER JOIN (SELECT device_id, MAX(captured_at) as max_at FROM screenshots GROUP BY device_id) latest
|
|
ON sc.device_id = latest.device_id AND sc.captured_at = latest.max_at
|
|
) s ON d.id = s.device_id
|
|
WHERE d.workspace_id = ?
|
|
ORDER BY d.sort_order ASC, d.created_at ASC
|
|
LIMIT ? OFFSET ?
|
|
`).all(req.workspaceId, limit, offset);
|
|
// #zone-orphan: lightweight per-device count of playlist items whose zone_id isn't in
|
|
// the device's active layout, so the dashboard can flag screens that need attention.
|
|
const orphanCounts = orphanCountsByDevice(devices.map(d => d.id));
|
|
// The RESOLVED capability set, the same shape GET /:id returns. The raw column shipped here
|
|
// before: a JSON *string* ('[]') or null, which every consumer would have had to parse — and
|
|
// `Array.isArray("[]")` is false, so the dashboard's `can()` helper reads a device that declared
|
|
// "I can do nothing" as "pre-capability server, show everything". Resolving it here means the
|
|
// fleet views (device cards, the wall panel list) can hide a control the panel cannot honour
|
|
// instead of offering it and having the socket drop it.
|
|
res.json(devices.map(d => ({
|
|
...stripDeviceSecretsForList(d),
|
|
capabilities: playerCapabilities.capabilitiesFor(d),
|
|
orphan_count: orphanCounts[d.id] || 0,
|
|
})));
|
|
});
|
|
|
|
// #106: reorder display tiles (cosmetic, within-section). Writes devices.sort_order
|
|
// = position in the given id array. Workspace-scoped: the UPDATE matches WHERE
|
|
// workspace_id = the caller's current workspace, so a forged id from another
|
|
// workspace is silently a no-op (can't reorder or probe devices you can't see).
|
|
// Write-gated: workspace_viewer (non-acting) is read-only. Ordering affects ONLY the
|
|
// dashboard listing — nothing the device/player reads (grouping/pairing/playback
|
|
// are independent). Mirrors the playlist items reorder.
|
|
router.post('/reorder', (req, res) => {
|
|
if (!req.workspaceId) return res.status(403).json({ error: 'No workspace' });
|
|
if (!req.actingAs && req.workspaceRole === 'workspace_viewer') {
|
|
return res.status(403).json({ error: 'Read-only access' });
|
|
}
|
|
const { order } = req.body;
|
|
if (!Array.isArray(order)) return res.status(400).json({ error: 'order must be an array of device IDs' });
|
|
const stmt = db.prepare("UPDATE devices SET sort_order = ?, updated_at = strftime('%s','now') WHERE id = ? AND workspace_id = ?");
|
|
const tx = db.transaction(() => {
|
|
order.forEach((id, index) => stmt.run(index, id, req.workspaceId));
|
|
});
|
|
tx();
|
|
res.json({ success: true });
|
|
});
|
|
|
|
// List unclaimed provisioning devices (admin only).
|
|
// #13: read-only, so platform_operator may view the pool too (cross-org staff
|
|
// troubleshooting). Claiming a device is a separate workspace-scoped mutation.
|
|
router.get('/unassigned', (req, res) => {
|
|
if (!ELEVATED_ROLES.includes(req.user.role) && !isPlatformStaff(req.user.role)) {
|
|
return res.status(403).json({ error: 'Admin access required' });
|
|
}
|
|
const devices = db.prepare(`
|
|
SELECT id, pairing_code, status, ip_address, android_version, app_version,
|
|
screen_width, screen_height, render_width, render_height, created_at, last_heartbeat
|
|
FROM devices WHERE user_id IS NULL
|
|
ORDER BY created_at DESC
|
|
`).all();
|
|
res.json(devices);
|
|
});
|
|
|
|
// #150: "previously removed devices" — fingerprint-keyed settings snapshots for the caller's
|
|
// current workspace, for the operator re-adopt flow (changed-fingerprint case). MUST be
|
|
// declared before GET '/:id' or Express matches 'removed' as an :id. Read-scoped to workspace.
|
|
router.get('/removed', (req, res) => {
|
|
if (!req.workspaceId) return res.json([]);
|
|
res.json(deviceSettings.listRemoved(req.workspaceId));
|
|
});
|
|
|
|
// Get single device with telemetry history
|
|
router.get('/:id', (req, res) => {
|
|
const device = db.prepare('SELECT d.*, u.email as owner_email, u.name as owner_name FROM devices d LEFT JOIN users u ON d.user_id = u.id WHERE d.id = ?').get(req.params.id);
|
|
if (!device) return res.status(404).json({ error: 'Device not found' });
|
|
// Phase 2.2a: workspace-aware read check. accessContext returns null when
|
|
// the caller has no path (direct member, org-level acting-as, or platform_admin)
|
|
// to the device's workspace.
|
|
if (!device.workspace_id) return res.status(403).json({ error: 'Device not assigned to a workspace' });
|
|
const ws = db.prepare('SELECT * FROM workspaces WHERE id = ?').get(device.workspace_id);
|
|
const ctx = ws && accessContext(req.user.id, req.user.role, ws);
|
|
if (!ctx) return res.status(403).json({ error: 'Access denied' });
|
|
if (ctx.workspaceRole) device._workspaceRole = ctx.workspaceRole; // Pass to frontend
|
|
if (ctx.actingAs) device._actingAs = true;
|
|
|
|
const telemetry = db.prepare(
|
|
'SELECT * FROM device_telemetry WHERE device_id = ? ORDER BY reported_at DESC LIMIT 20'
|
|
).all(req.params.id);
|
|
|
|
const screenshot = db.prepare(
|
|
'SELECT * FROM screenshots WHERE device_id = ? ORDER BY captured_at DESC LIMIT 1'
|
|
).get(req.params.id);
|
|
|
|
// Get playlist items and status if device has an assigned playlist
|
|
let assignments = [];
|
|
let playlist_status = null;
|
|
let playlist_has_published = false;
|
|
if (device.playlist_id) {
|
|
assignments = db.prepare(`
|
|
SELECT pi.id, pi.content_id, pi.widget_id, pi.zone_id, pi.sort_order, pi.duration_sec, pi.muted,
|
|
pi.created_at, pi.updated_at,
|
|
COALESCE(c.filename, w.name) as filename, c.mime_type, c.filepath, c.thumbnail_path,
|
|
c.duration_sec as content_duration, c.remote_url,
|
|
w.name as widget_name, w.widget_type, w.config as widget_config
|
|
FROM playlist_items pi
|
|
LEFT JOIN content c ON pi.content_id = c.id
|
|
LEFT JOIN widgets w ON pi.widget_id = w.id
|
|
WHERE pi.playlist_id = ?
|
|
ORDER BY pi.sort_order ASC
|
|
`).all(device.playlist_id);
|
|
const pl = db.prepare('SELECT status, published_snapshot FROM playlists WHERE id = ?').get(device.playlist_id);
|
|
if (pl) {
|
|
playlist_status = pl.status;
|
|
playlist_has_published = pl.published_snapshot !== null;
|
|
}
|
|
}
|
|
|
|
// #zone-orphan: flag any item whose zone_id isn't a zone in the device's ACTIVE layout
|
|
// (same rule as lib/zone-validate). The dashboard shows a per-item "reassign" warning;
|
|
// active_layout_zones ships the zone list here too so the inline reassign dropdown needs
|
|
// no separate /api/layouts round-trip. Informational only — playback uses the fallback.
|
|
const active_layout_zones = layoutZones(device.layout_id);
|
|
const activeZoneIdSet = new Set(active_layout_zones.map(z => z.id));
|
|
for (const a of assignments) a.orphan = !!a.zone_id && !activeZoneIdSet.has(a.zone_id);
|
|
|
|
// Uptime timeline: get status change events for last 24 hours
|
|
const dayAgo = Math.floor(Date.now() / 1000) - 86400;
|
|
let statusLog = [];
|
|
try {
|
|
statusLog = db.prepare(
|
|
'SELECT status, reason, detail, timestamp FROM device_status_log WHERE device_id = ? AND timestamp > ? ORDER BY timestamp ASC'
|
|
).all(req.params.id, dayAgo);
|
|
} catch (_) {}
|
|
|
|
// Offline-cause log: the unified incident feed (offline-cause + display/sleep + crash +
|
|
// reboot), most-recent first. Best-effort — an old DB without the table just yields [].
|
|
let deviceEvents = [];
|
|
try {
|
|
deviceEvents = db.prepare(
|
|
'SELECT id, type, reason, detail, timestamp FROM device_events WHERE device_id = ? ORDER BY timestamp DESC, id DESC LIMIT 50'
|
|
).all(req.params.id);
|
|
} catch (_) {}
|
|
|
|
// Also get telemetry timestamps as heartbeat proof (fills gaps between status events)
|
|
const uptimeData = db.prepare(
|
|
'SELECT reported_at FROM device_telemetry WHERE device_id = ? AND reported_at > ? ORDER BY reported_at ASC'
|
|
).all(req.params.id, dayAgo).map(r => r.reported_at);
|
|
|
|
// The RESOLVED capability set, not the raw column. The dashboard hides controls a panel cannot
|
|
// honour, and it must not have to know about the baseline fallback — a legacy device declaring
|
|
// nothing has to arrive at the dashboard looking exactly like one that declared its baseline,
|
|
// or ~440 existing displays lose their controls the moment this ships.
|
|
const capabilities = playerCapabilities.capabilitiesFor(device);
|
|
|
|
res.json({ ...stripDeviceSecrets(device), capabilities, telemetry, screenshot, assignments, active_layout_zones, playlist_status, playlist_has_published, uptimeData, statusLog, deviceEvents });
|
|
});
|
|
|
|
// Helper: check device write access via the workspace the device belongs to.
|
|
// Phase 2.2a: replaces user_id + team_members check. Allows: platform_admin,
|
|
// org_owner/admin of the device's org (acting-as), workspace_admin/editor of
|
|
// the device's workspace. Denies workspace_viewer and non-members.
|
|
function checkDeviceOwnership(req, res) {
|
|
const device = db.prepare('SELECT * FROM devices WHERE id = ?').get(req.params.id);
|
|
if (!device) { res.status(404).json({ error: 'Device not found' }); return null; }
|
|
if (!device.workspace_id) { res.status(403).json({ error: 'Device not assigned to a workspace' }); return null; }
|
|
const ws = db.prepare('SELECT * FROM workspaces WHERE id = ?').get(device.workspace_id);
|
|
const ctx = ws && accessContext(req.user.id, req.user.role, ws);
|
|
if (!ctx) { res.status(403).json({ error: 'Access denied' }); return null; }
|
|
// ctx.actingAs covers platform_admin and org_owner/admin paths (always writable).
|
|
// Direct workspace members: workspace_viewer is read-only.
|
|
if (!ctx.actingAs && ctx.workspaceRole === 'workspace_viewer') {
|
|
res.status(403).json({ error: 'Read-only access' }); return null;
|
|
}
|
|
return device;
|
|
}
|
|
|
|
// #104: device-manager preview payload. Returns the device's CURRENT payload exactly
|
|
// as the device renders it — its OWN layout/orientation/wall from the device row and
|
|
// its published items — built by the same buildPlaylistPayload the device socket uses.
|
|
// Device-bound layout (the correct side of the layout seam); derivePreviewLayout is
|
|
// playlist-only and never touches this path. wall_config is forced null in v1: a wall
|
|
// FOLLOWER would otherwise freeze waiting for leader wall:sync that a socket-free
|
|
// preview can't deliver, so wall members preview full-frame. Device-READ gated
|
|
// (mirrors GET /:id — viewers allowed); NOT requirePlaylistRead, NOT the write gate.
|
|
router.get('/:id/preview-payload', (req, res) => {
|
|
const device = db.prepare('SELECT id, workspace_id FROM devices WHERE id = ?').get(req.params.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' });
|
|
const ws = db.prepare('SELECT * FROM workspaces WHERE id = ?').get(device.workspace_id);
|
|
const ctx = ws && accessContext(req.user.id, req.user.role, ws);
|
|
if (!ctx) return res.status(403).json({ error: 'Access denied' });
|
|
const { buildPlaylistPayload } = require('../ws/deviceSocket');
|
|
const payload = buildPlaylistPayload(req.params.id);
|
|
payload.wall_config = null; // v1: wall members preview full-frame (no socket-free follower freeze)
|
|
res.json(payload);
|
|
});
|
|
|
|
// Update device
|
|
// Clear a device's playlist — the "No playlist" option in the dashboard picker.
|
|
//
|
|
// There was no way to do this. PUT /devices/:id ignores playlist_id (it always has), and
|
|
// POST /playlists/:id/assign can only ever SET one, so the picker carried a guard that
|
|
// silently discarded the selection: `if (!newPlaylistId) return; // Don't allow deselecting`.
|
|
// The option was offered, selecting it did nothing, and no error said so — reported on #234
|
|
// as "I selected No playlist and it still showed the same video". It did.
|
|
//
|
|
// Device-scoped rather than playlist-scoped because there is no playlist to authorize
|
|
// against when clearing; ownership is checked the same way every other device mutation
|
|
// checks it. Clearing an already-clear device is a no-op success, so the button is safe to
|
|
// press twice.
|
|
router.delete('/:id/playlist', (req, res) => {
|
|
const device = checkDeviceOwnership(req, res);
|
|
if (!device) return;
|
|
|
|
db.prepare('UPDATE devices SET playlist_id = NULL, updated_at = ? WHERE id = ?')
|
|
.run(Math.floor(Date.now() / 1000), req.params.id);
|
|
|
|
// Push the now-empty playlist so the screen stops, rather than leaving the old content up
|
|
// until something else happens to update it.
|
|
try {
|
|
const io = req.app.get('io');
|
|
if (io) {
|
|
const { buildPlaylistPayload } = require('../ws/deviceSocket');
|
|
const commandQueue = require('../lib/command-queue');
|
|
commandQueue.queueOrEmitPlaylistUpdate(io.of('/device'), req.params.id, buildPlaylistPayload);
|
|
}
|
|
} catch (e) { /* silent — the DB is the source of truth, the push is best-effort */ }
|
|
|
|
res.json({ success: true });
|
|
});
|
|
|
|
router.put('/:id', (req, res) => {
|
|
const device = checkDeviceOwnership(req, res);
|
|
if (!device) return;
|
|
|
|
const { name, notes, timezone, orientation, default_content_id, layout_id, ota_enabled, ota_beta, reboot_schedule } = req.body;
|
|
// #150: validate orientation against the known enum (previously accepted any string, which
|
|
// let a bad value reach the player -> unknown rotation falls back to landscape silently).
|
|
if (orientation !== undefined && !deviceSettings.ORIENTATIONS.has(orientation)) {
|
|
return res.status(400).json({ error: `Invalid orientation. Allowed: ${[...deviceSettings.ORIENTATIONS].join(', ')}` });
|
|
}
|
|
// Whitelist allowed fields to prevent SQL injection via field names
|
|
const ALLOWED_FIELDS = ['name', 'notes', 'timezone', 'orientation', 'default_content_id'];
|
|
const updates = [];
|
|
const values = [];
|
|
Object.entries({ name, notes, timezone, orientation, default_content_id }).forEach(([key, val]) => {
|
|
if (val !== undefined && ALLOWED_FIELDS.includes(key)) {
|
|
updates.push(`${key} = ?`);
|
|
values.push(val);
|
|
}
|
|
});
|
|
// #public-api: allow setting the device's layout here too (symmetry with
|
|
// PUT /api/layouts/device/:id). Validate it's a template or in the device's
|
|
// workspace; null clears it (fullscreen).
|
|
if (layout_id !== undefined) {
|
|
if (layout_id !== null) {
|
|
const layout = db.prepare('SELECT id FROM layouts WHERE id = ? AND (is_template = 1 OR workspace_id = ?)').get(layout_id, device.workspace_id);
|
|
if (!layout) return res.status(400).json({ error: 'layout_id not found in this workspace' });
|
|
}
|
|
updates.push('layout_id = ?'); values.push(layout_id || null);
|
|
}
|
|
// #155/#161: per-device self-update (OTA) toggle. Coerce to 0/1.
|
|
if (ota_enabled !== undefined) {
|
|
updates.push('ota_enabled = ?'); values.push(ota_enabled ? 1 : 0);
|
|
}
|
|
if (ota_beta !== undefined) {
|
|
// Per-display pre-release opt-in (#234 follow-up). Stops a test build being reverted by the
|
|
// next OTA check, which is what a prerelease version sorting below its own release causes.
|
|
updates.push('ota_beta = ?'); values.push(ota_beta ? 1 : 0);
|
|
}
|
|
// #12 scheduled reboot: device-local "HH:MM" (null/'' clears -> off). Reset the
|
|
// once-per-day guard on any change so a newly-set time can still fire later today.
|
|
if (reboot_schedule !== undefined) {
|
|
let val = null;
|
|
if (reboot_schedule !== null && reboot_schedule !== '') {
|
|
if (!/^([01]\d|2[0-3]):([0-5]\d)$/.test(String(reboot_schedule))) {
|
|
return res.status(400).json({ error: 'reboot_schedule must be "HH:MM" (24h) or null' });
|
|
}
|
|
val = String(reboot_schedule);
|
|
}
|
|
updates.push('reboot_schedule = ?'); values.push(val);
|
|
updates.push('reboot_last_date = ?'); values.push(null);
|
|
}
|
|
if (updates.length > 0) {
|
|
values.push(req.params.id);
|
|
db.prepare(`UPDATE devices SET ${updates.join(', ')}, updated_at = strftime('%s','now') WHERE id = ?`).run(...values);
|
|
}
|
|
|
|
const updated = db.prepare('SELECT * FROM devices WHERE id = ?').get(req.params.id);
|
|
res.json(stripDeviceSecrets(updated));
|
|
});
|
|
|
|
// #146 Item D: operator BLOCK / UNBLOCK toggle. Writes devices.blocked; the device
|
|
// socket re-reads `blocked` on every register, so the block takes effect on the
|
|
// device's NEXT register with NO server restart (and, via the #146 identity chain, is
|
|
// enforced even if that reconnect arrives without a device_id). Write-gated + workspace-
|
|
// scoped by checkDeviceOwnership. OUTAGE PROCEDURE (dashboard down): set it by hand via
|
|
// direct SQLite — `UPDATE devices SET blocked = 1 WHERE id = '<device_id>';` (0 to
|
|
// unblock) — same column, same next-register effect.
|
|
/*
|
|
* Set or rotate the on-device settings PIN.
|
|
*
|
|
* Body: { pin: "123456" } to set explicitly, or { rotate: true } for a fresh random one.
|
|
*
|
|
* Was provisioned once at pairing and never changeable, which made it a shared secret with no
|
|
* expiry: anyone who watched it typed kept it for the life of the panel, and revoking it meant
|
|
* unpairing and re-pairing. Now it can be rotated the moment an installer leaves.
|
|
*
|
|
* Pushed to the panel immediately over its socket. Without that the new PIN would only take effect
|
|
* at the next pairing — so the operator would believe they had revoked access while the old PIN
|
|
* still opened the menu, which is worse than not offering the feature.
|
|
*/
|
|
router.post('/:id/settings-pin', (req, res) => {
|
|
const device = checkDeviceOwnership(req, res);
|
|
if (!device) return;
|
|
|
|
const pinLib = require('../lib/settings-pin');
|
|
let pin;
|
|
if (req.body && req.body.rotate) {
|
|
pin = pinLib.generatePin();
|
|
} else {
|
|
const v = pinLib.validatePin(req.body && req.body.pin);
|
|
if (!v.ok) return res.status(400).json({ error: v.error });
|
|
pin = v.pin;
|
|
}
|
|
|
|
db.prepare("UPDATE devices SET settings_pin = ?, updated_at = strftime('%s','now') WHERE id = ?")
|
|
.run(pin, req.params.id);
|
|
|
|
// Live push. A panel that is offline picks it up on its next pair/reconnect; the response says
|
|
// which happened so the dashboard can tell the operator whether it is in force yet.
|
|
let delivered = false;
|
|
try {
|
|
const io = req.app.get('io');
|
|
if (io) {
|
|
const ns = io.of('/device');
|
|
const room = ns.adapter.rooms.get(req.params.id);
|
|
if (room && room.size > 0) {
|
|
ns.to(req.params.id).emit('device:settings-pin', { settings_pin: pin });
|
|
delivered = true;
|
|
}
|
|
}
|
|
} catch (e) { console.warn(`[settings-pin] push failed: ${e.message}`); }
|
|
|
|
// Deliberately NOT logging the PIN itself.
|
|
console.log(`[settings-pin] device ${req.params.id} pin ${req.body && req.body.rotate ? 'rotated' : 'set'} by user ${req.user.id} (delivered=${delivered})`);
|
|
res.json({ success: true, settings_pin: pin, delivered });
|
|
});
|
|
|
|
router.post('/:id/block', (req, res) => {
|
|
const device = checkDeviceOwnership(req, res);
|
|
if (!device) return;
|
|
db.prepare("UPDATE devices SET blocked = 1, updated_at = strftime('%s','now') WHERE id = ?").run(req.params.id);
|
|
// Mirror onto the saved settings so the block survives a delete + re-pair on purpose rather than
|
|
// by accident of whatever the saved copy happened to hold.
|
|
try { deviceSettings.setBlockedByDevice(req.params.id, true); } catch (e) { console.warn(`[blocked] save mirror failed: ${e.message}`); }
|
|
console.warn(`[blocked] device ${req.params.id} blocked via dashboard (user ${req.user.id})`);
|
|
res.json({ success: true, id: req.params.id, blocked: true });
|
|
});
|
|
router.post('/:id/unblock', (req, res) => {
|
|
const device = checkDeviceOwnership(req, res);
|
|
if (!device) return;
|
|
db.prepare("UPDATE devices SET blocked = 0, updated_at = strftime('%s','now') WHERE id = ?").run(req.params.id);
|
|
// MUST clear the saved copy too. applyToDevice() restores `blocked` on re-pair, so leaving the
|
|
// saved 1 in place made unblock temporary: the next delete + re-pair silently re-blocked the
|
|
// device, with nothing in the dashboard to explain it and no way for the operator to escape.
|
|
try { deviceSettings.setBlockedByDevice(req.params.id, false); } catch (e) { console.warn(`[blocked] save mirror failed: ${e.message}`); }
|
|
console.log(`[blocked] device ${req.params.id} unblocked via dashboard (user ${req.user.id})`);
|
|
res.json({ success: true, id: req.params.id, blocked: false });
|
|
});
|
|
|
|
// #150: re-adopt — apply a removed device's saved settings onto device :id. For the case the
|
|
// fingerprint did NOT auto-match (factory reset / new hardware), so the automatic re-pair
|
|
// restore couldn't fire. Auth: caller can write device :id (checkDeviceOwnership) AND the
|
|
// snapshot belongs to the SAME workspace as the device (no cross-tenant apply).
|
|
router.post('/:id/re-adopt', (req, res) => {
|
|
const device = checkDeviceOwnership(req, res);
|
|
if (!device) return;
|
|
const { fingerprint } = req.body || {};
|
|
if (!fingerprint) return res.status(400).json({ error: 'fingerprint required' });
|
|
const snap = deviceSettings.getByFingerprint(fingerprint);
|
|
if (!snap) return res.status(404).json({ error: 'No saved settings for that fingerprint' });
|
|
if (snap.workspace_id !== device.workspace_id) {
|
|
return res.status(403).json({ error: 'Saved settings belong to a different workspace' });
|
|
}
|
|
deviceSettings.applyToDevice(req.params.id, fingerprint);
|
|
const updated = db.prepare('SELECT * FROM devices WHERE id = ?').get(req.params.id);
|
|
console.log(`[#150] re-adopted settings (fp ${fingerprint.slice(0, 8)}…) onto device ${req.params.id} by user ${req.user.id}`);
|
|
res.json(stripDeviceSecrets(updated));
|
|
});
|
|
|
|
// Delete device
|
|
router.delete('/:id', (req, res) => {
|
|
const device = checkDeviceOwnership(req, res);
|
|
if (!device) return;
|
|
|
|
// #150: snapshot this device's settings (keyed by its fingerprint) BEFORE the row dies,
|
|
// so a re-pair of the SAME physical device restores orientation/name/playlist/etc instead
|
|
// of silently resetting to defaults. No-op if the device has no fingerprint link yet.
|
|
try { deviceSettings.snapshot(req.params.id); } catch (e) { console.warn(`[#150] settings snapshot failed for ${req.params.id}: ${e.message}`); }
|
|
|
|
// Clean up related data (playlist is NOT deleted — may be shared with other devices)
|
|
db.prepare('DELETE FROM schedules WHERE device_id = ?').run(req.params.id);
|
|
db.prepare('DELETE FROM screenshots WHERE device_id = ?').run(req.params.id);
|
|
db.prepare('DELETE FROM device_telemetry WHERE device_id = ?').run(req.params.id);
|
|
db.prepare('DELETE FROM video_wall_devices WHERE device_id = ?').run(req.params.id);
|
|
db.prepare('DELETE FROM devices WHERE id = ?').run(req.params.id);
|
|
|
|
// Notify dashboard in real-time. Phase 2.3: scope to the device's
|
|
// (now-deleted but still-known) workspace room. `device.workspace_id`
|
|
// came from checkDeviceOwnership() above.
|
|
const io = req.app.get('io');
|
|
if (io) {
|
|
const { workspaceRoom, emitToWorkspace } = require('../lib/socket-rooms');
|
|
emitToWorkspace(io.of('/dashboard'), workspaceRoom(device.workspace_id), 'dashboard:device-removed', { device_id: req.params.id });
|
|
}
|
|
|
|
res.json({ success: true });
|
|
});
|
|
|
|
module.exports = router;
|