mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-14 14:23:14 -06:00
It was generated once at pairing and never changed. On a fleet that makes it a
shared secret with no expiry: anyone who watches it typed once — an installer, a
contractor, someone filming a screen — keeps it for the life of the panel, and
the only way to take it back was to unpair and re-pair every affected display. A
customer asked whether it rotates, which was the right question.
POST /api/devices/:id/settings-pin takes { rotate: true } or { pin: "123456" },
and pushes the result to the panel over its socket immediately. The live push is
the part that matters: without it a new PIN would only take effect at the next
pairing, so an operator revoking a leaked PIN would believe access was closed
while the old one still opened the menu. The response reports whether the panel
actually took it, so an offline display is stated rather than assumed.
Validation is the security-relevant half and is pure and tested: six digits,
digits only, and a blocklist of the PINs people actually pick (repeats and
sequences) refused on explicit set and never produced by the generator. A PIN
that can be set to "0000" or left empty is a gate that is not there.
Generation uses crypto.randomInt rather than Math.random — this is a credential,
and a rotation requested BECAUSE a PIN leaked must not be predictable from
anything else. Leading zeros are padded, or roughly one PIN in ten would be five
digits and rejected by the on-device prompt.
Android applies it live via device:settings-pin instead of only at pairing. The
PIN is never written to a log on either side, and it stays out of device list
responses as before.
1084 pass; Android compiles.
Asked for by chris@chris-pc.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
441 lines
23 KiB
JavaScript
441 lines
23 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
|
|
|
|
// 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));
|
|
res.json(devices.map(d => ({ ...stripDeviceSecretsForList(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);
|
|
|
|
res.json({ ...stripDeviceSecrets(device), 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;
|