mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-13 13:53:12 -06:00
Two field-reported gaps, unrelated except that both are about being able to read something off a screen. A PANEL'S IPv6 WAS NEVER COLLECTED, LET ALONE SHOWN. DeviceInfo.getLocalIp() filters to Inet4Address, so a v6-only panel reported no address at all and the dashboard rendered a dash for a screen that was perfectly reachable. It now reports both stacks in their own fields: a dual-stack panel genuinely has two addresses and either may be the one you need, so collapsing them into one column would make it mean "whichever interface enumerated first". Link-local (fe80::/10) is deliberately excluded. Every interface has one, they tend to enumerate first, and none can be dialled without also knowing the zone index — so admitting them would fill the field with a string nobody can paste anywhere and hide the address that works. Any %iface suffix is trimmed for the same reason. The 45-char cap the writer already applied is exactly the longest legitimate IPv6 text form, so it needed no change. The dashboard card renders only when a panel actually has a v6 address, rather than showing an empty row to the overwhelmingly v4 fleet. THE PAIRING CODE DID NOT SCALE, WHICH IS WORST WHERE IT MATTERS MOST. Every size on the pre-playback screens was a hard-coded pixel value. A CSS pixel covers a quarter of the screen area on a 4K panel that it does on 1080p, and a sixteenth on 8K — so the 72px code that fills a 1080p screen is a smudge on the 4K wall it was installed on, which is where signage actually goes. What has to stay constant is ANGULAR size, so the root font size is now viewport-proportional and everything on those screens is a rem against it. The code holds 6.67% of screen height at every resolution: 72px at 1080p — bit for bit what it renders today, so nothing changes for the existing fleet — 144px at 4K, 288px at 8K. Verified in a browser rather than by arithmetic: at a 1409px viewport the root computes to 13.0473px, which is 0.926vmin to four decimals. vmin, not vw, because portrait-mounted panels are common here and vw would render a 1080x1920 screen at half size. Clamped at both ends so the dashboard's preview iframe stays legible instead of microscopic and an ultrawide does not get silly. Applied to the web player (which BrightSign also runs) and to Tizen, where a 1920x1080 logical viewport makes it arithmetically identical to the values it replaces — the point being the panels where it is not. A test asserts the scaling cannot reach playback content: the whole safety argument is that only the chrome uses rem, and a stage or zone rule adopting it would start resizing CONTENT, which is a worse bug than the one being fixed. Android is untouched — its pairing code already autosizes within a dp-scaled layout. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014skWYXJUWhF73EvNPgB2AS
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.local_ip6,
|
|
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;
|