mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-14 06:16:20 -06:00
A customer blocked a screen once to see what the button did, then spent an evening
unable to get it back. Three separate faults stacked up.
1. Unblock did not stick. applyToDevice() restores `blocked` on re-pair — deliberately,
so a block cannot be shrugged off by deleting the device — which makes the SAVED copy
the real authority. Unblock only ever wrote `devices`, so the saved row stayed 1 and the
next delete + re-pair silently re-blocked. There was no way out from the dashboard at
all: unblock, re-pair, refused, repeat. Block and unblock now both mirror to the saved
copy, so the survives-a-re-pair property is deliberate rather than a leftover.
2. The refusal was invisible. handleServerRejection() clears credentials and calls
onUnpaired, but only ProvisioningActivity ever assigned that callback — and it is long
gone by the time playback is running. So the screen sat on "Connecting to server" and
the player eventually blamed the URL, sending the operator off checking their network
while the server had already said exactly what was wrong. MainActivity now handles it.
(This half was mine: clearing those leaked callbacks to stop the relaunch loop removed
the only thing that surfaced a rejection. It was a broken path — it fired into a
destroyed Activity — but it was the only one, and MainActivity should have owned it.)
3. The reason was thrown away. The server sends device:auth-error {error: "Device
blocked"} and the client discarded it. It is kept now, and a blocked screen says so
instead of implying a network fault. Localised in all six languages, matching the other
on-screen status strings.
Also ran on prod: one stale saved block cleared (fingerprint ef6540376599, the reporter's
tablet), DB backed up first. It was the only such row.
Tests pin both directions, because the two are easy to confuse: unblock must clear the
saved copy, AND a genuine block must still survive a delete + re-pair.
353 lines
19 KiB
JavaScript
353 lines
19 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.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
|
|
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, 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);
|
|
}
|
|
// #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.
|
|
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;
|