mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-13 22:03:13 -06:00
feat(#150): preserve per-device settings across delete+re-pair (fingerprint-keyed)
Delete+re-pair mints a new device row whose INSERT omits every setting, silently resetting orientation/name/playlist/etc to defaults (Bold MDM churn). Add a fingerprint-keyed device_settings table (no FK to devices -> survives the cascade): snapshot on DELETE, auto- restore on fingerprint-match re-pair (relinking the fp to the new id), operator re-adopt API (GET /devices/removed + POST /devices/:id/re-adopt) for the changed-fingerprint case. Purge on workspace/user/org deletion (no cross-tenant bleed). Orientation enum-validated on PUT + restore. blocked preserved (re-enforced by the register kill-switch). Wall membership deferred (TODO). Backend only — frontend re-adopt UI NOT built (awaiting API review). Local only, no bump/tag.
This commit is contained in:
parent
be01674d35
commit
2ba06e98ec
|
|
@ -251,6 +251,26 @@ const migrations = [
|
|||
// register gate on its next reconnect (no restart). Hand-settable by direct SQLite:
|
||||
// UPDATE devices SET blocked = 1 WHERE id = '<device_id>'; (0 to unblock)
|
||||
"ALTER TABLE devices ADD COLUMN blocked INTEGER NOT NULL DEFAULT 0",
|
||||
// #150: fingerprint-keyed device settings that SURVIVE device-row deletion, so a
|
||||
// delete + re-pair (MDM churn) restores orientation/name/playlist/etc for the SAME
|
||||
// physical device instead of silently resetting to defaults. NO FK to devices -> it
|
||||
// survives the delete cascade. workspace_id/device_name/last_seen/removed_at form the
|
||||
// human-readable index the operator "re-adopt" flow browses when the fingerprint changed.
|
||||
`CREATE TABLE IF NOT EXISTS device_settings (
|
||||
fingerprint TEXT PRIMARY KEY,
|
||||
workspace_id TEXT,
|
||||
device_name TEXT,
|
||||
orientation TEXT,
|
||||
timezone TEXT,
|
||||
notes TEXT,
|
||||
default_content_id TEXT,
|
||||
layout_id TEXT,
|
||||
playlist_id TEXT,
|
||||
blocked INTEGER,
|
||||
team_id TEXT,
|
||||
last_seen INTEGER,
|
||||
removed_at INTEGER
|
||||
)`,
|
||||
];
|
||||
// Apply each ALTER idempotently. A "duplicate column name" / "already exists"
|
||||
// error means the column is already present (expected on a migrated DB) - benign.
|
||||
|
|
|
|||
124
server/lib/device-settings.js
Normal file
124
server/lib/device-settings.js
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
'use strict';
|
||||
// #150 — fingerprint-keyed device settings that survive device-row deletion.
|
||||
//
|
||||
// Delete + re-pair (Bold's MDM churn) mints a BRAND-NEW device row whose INSERT omits every
|
||||
// per-device setting, so orientation/name/playlist/etc silently reset to defaults. This module
|
||||
// snapshots a device's settings (keyed by its durable hardware/canvas fingerprint) at DELETE
|
||||
// time, and re-applies them on the next re-pair for the SAME fingerprint — automatically and
|
||||
// silently. The same apply path also backs the operator "re-adopt" action for the case where
|
||||
// the fingerprint changed (factory reset / new hardware), see routes/devices.js.
|
||||
//
|
||||
// The table has NO FK to devices, so device deletion can't cascade it away. On workspace/user/
|
||||
// org deletion the rows ARE purged (purgeWorkspaces) so settings can never bleed across tenants.
|
||||
const { db } = require('../db/database');
|
||||
|
||||
const ORIENTATIONS = new Set(['landscape', 'portrait', 'landscape-flipped', 'portrait-flipped']);
|
||||
const validOrientation = (o) => (ORIENTATIONS.has(o) ? o : 'landscape');
|
||||
|
||||
// The devices-row columns we preserve/restore (approved scope: orientation, name, timezone,
|
||||
// notes, default_content_id, layout_id, playlist_id, blocked, team_id). sort_order is out of
|
||||
// scope by decision; wall membership (video_wall_devices grid geometry) is a deferred follow-up.
|
||||
const _selDevice = db.prepare(
|
||||
`SELECT name, orientation, timezone, notes, default_content_id, layout_id, playlist_id,
|
||||
blocked, team_id, workspace_id, last_heartbeat
|
||||
FROM devices WHERE id = ?`
|
||||
);
|
||||
const _fpForDevice = db.prepare(
|
||||
'SELECT fingerprint FROM device_fingerprints WHERE device_id = ? ORDER BY last_seen DESC LIMIT 1'
|
||||
);
|
||||
const _upsert = db.prepare(`
|
||||
INSERT INTO device_settings
|
||||
(fingerprint, workspace_id, device_name, orientation, timezone, notes, default_content_id,
|
||||
layout_id, playlist_id, blocked, team_id, last_seen, removed_at)
|
||||
VALUES
|
||||
(@fingerprint, @workspace_id, @device_name, @orientation, @timezone, @notes, @default_content_id,
|
||||
@layout_id, @playlist_id, @blocked, @team_id, @last_seen, @removed_at)
|
||||
ON CONFLICT(fingerprint) DO UPDATE SET
|
||||
workspace_id=excluded.workspace_id, device_name=excluded.device_name, orientation=excluded.orientation,
|
||||
timezone=excluded.timezone, notes=excluded.notes, default_content_id=excluded.default_content_id,
|
||||
layout_id=excluded.layout_id, playlist_id=excluded.playlist_id, blocked=excluded.blocked,
|
||||
team_id=excluded.team_id, last_seen=excluded.last_seen, removed_at=excluded.removed_at
|
||||
`);
|
||||
|
||||
// Snapshot a device's current settings keyed by its fingerprint, called BEFORE the row is
|
||||
// deleted. No-op (returns null) if the device has no fingerprint link yet — a never-fully-
|
||||
// provisioned device has no durable key and no user settings worth preserving. UPSERT keyed on
|
||||
// fingerprint => repeated delete/re-pair cycles update one row, never duplicate.
|
||||
function snapshot(deviceId, now = Math.floor(Date.now() / 1000)) {
|
||||
const d = _selDevice.get(deviceId);
|
||||
if (!d) return null;
|
||||
const fpRow = _fpForDevice.get(deviceId);
|
||||
if (!fpRow || !fpRow.fingerprint) return null;
|
||||
_upsert.run({
|
||||
fingerprint: fpRow.fingerprint,
|
||||
workspace_id: d.workspace_id || null,
|
||||
device_name: d.name || null,
|
||||
orientation: validOrientation(d.orientation),
|
||||
timezone: d.timezone || null,
|
||||
notes: d.notes || null,
|
||||
default_content_id: d.default_content_id || null,
|
||||
layout_id: d.layout_id || null,
|
||||
playlist_id: d.playlist_id || null,
|
||||
blocked: d.blocked ? 1 : 0,
|
||||
team_id: d.team_id || null,
|
||||
last_seen: d.last_heartbeat || now,
|
||||
removed_at: now,
|
||||
});
|
||||
return fpRow.fingerprint;
|
||||
}
|
||||
|
||||
// Apply saved settings for `fingerprint` onto `deviceId`. Backs BOTH the automatic re-pair
|
||||
// restore and the operator re-adopt. Orientation is enum-validated (invalid stored value ->
|
||||
// landscape). FK settings (playlist/layout/default_content) are existence-guarded so a
|
||||
// since-deleted target is skipped rather than written as a dangling id. Returns the applied
|
||||
// snapshot row, or null if there was nothing to apply.
|
||||
function applyToDevice(deviceId, fingerprint) {
|
||||
const s = db.prepare('SELECT * FROM device_settings WHERE fingerprint = ?').get(fingerprint);
|
||||
if (!s) return null;
|
||||
const sets = [], vals = [];
|
||||
const put = (col, val) => { sets.push(`${col} = ?`); vals.push(val); };
|
||||
|
||||
put('orientation', validOrientation(s.orientation));
|
||||
if (s.device_name != null) put('name', s.device_name);
|
||||
if (s.timezone != null) put('timezone', s.timezone);
|
||||
if (s.notes != null) put('notes', s.notes);
|
||||
put('blocked', s.blocked ? 1 : 0); // security: a blocked device stays blocked across re-pair
|
||||
if (s.team_id != null) put('team_id', s.team_id);
|
||||
// FK-existence guards — only restore if the referenced row still exists.
|
||||
if (s.playlist_id && db.prepare('SELECT 1 FROM playlists WHERE id = ?').get(s.playlist_id)) put('playlist_id', s.playlist_id);
|
||||
if (s.layout_id && db.prepare('SELECT 1 FROM layouts WHERE id = ?').get(s.layout_id)) put('layout_id', s.layout_id);
|
||||
if (s.default_content_id && db.prepare('SELECT 1 FROM content WHERE id = ?').get(s.default_content_id)) put('default_content_id', s.default_content_id);
|
||||
// TODO #150 follow-up: wall membership (video_wall_devices grid geometry) is NOT restored —
|
||||
// it lives in a separate CASCADE-deleted table with grid positions. Deferred; note in release.
|
||||
|
||||
vals.push(deviceId);
|
||||
db.prepare(`UPDATE devices SET ${sets.join(', ')}, updated_at = strftime('%s','now') WHERE id = ?`).run(...vals);
|
||||
return s;
|
||||
}
|
||||
|
||||
// The "previously removed devices" browser — snapshots for the given workspace(s).
|
||||
function listRemoved(workspaceIds) {
|
||||
const ids = (Array.isArray(workspaceIds) ? workspaceIds : [workspaceIds]).filter(Boolean);
|
||||
if (!ids.length) return [];
|
||||
const ph = ids.map(() => '?').join(',');
|
||||
return db.prepare(
|
||||
`SELECT fingerprint, workspace_id, device_name, orientation, playlist_id, layout_id,
|
||||
timezone, blocked, last_seen, removed_at
|
||||
FROM device_settings WHERE workspace_id IN (${ph}) ORDER BY removed_at DESC`
|
||||
).all(...ids);
|
||||
}
|
||||
|
||||
function getByFingerprint(fingerprint) {
|
||||
return db.prepare('SELECT * FROM device_settings WHERE fingerprint = ?').get(fingerprint);
|
||||
}
|
||||
|
||||
// Purge snapshots for whole workspaces (workspace/user/org deletion). Runs on the caller's
|
||||
// db handle (user-deletion runs inside a transaction). Prevents cross-tenant settings bleed.
|
||||
function purgeWorkspaces(dbConn, workspaceIds) {
|
||||
const ids = (workspaceIds || []).filter(Boolean);
|
||||
if (!ids.length) return 0;
|
||||
const ph = ids.map(() => '?').join(',');
|
||||
return (dbConn || db).prepare(`DELETE FROM device_settings WHERE workspace_id IN (${ph})`).run(...ids).changes;
|
||||
}
|
||||
|
||||
module.exports = { snapshot, applyToDevice, listRemoved, getByFingerprint, purgeWorkspaces, validOrientation, ORIENTATIONS };
|
||||
|
|
@ -76,6 +76,11 @@ function purgeWorkspaces(db, wsIds, have) {
|
|||
}
|
||||
}
|
||||
for (const t of WORKSPACE_SCOPED) if (have.has(t)) db.prepare(`DELETE FROM ${t} WHERE workspace_id IN (${wph})`).run(...wsIds);
|
||||
// #150: purge fingerprint-keyed device settings for these workspaces. device_settings has
|
||||
// NO FK to devices (so it survives device deletion by design), which means it is NOT caught
|
||||
// by this cascade either — purge it explicitly so saved settings can never bleed onto a
|
||||
// different tenant if the same physical device (same fingerprint) later pairs elsewhere.
|
||||
if (have.has('device_settings')) db.prepare(`DELETE FROM device_settings WHERE workspace_id IN (${wph})`).run(...wsIds);
|
||||
if (have.has('activity_log')) db.prepare(`UPDATE activity_log SET workspace_id = NULL WHERE workspace_id IN (${wph})`).run(...wsIds);
|
||||
db.prepare(`DELETE FROM workspaces WHERE id IN (${wph})`).run(...wsIds); // cascades workspace_members/invites
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ const { PLATFORM_ROLES, ELEVATED_ROLES, isPlatformStaff } = require('../middlewa
|
|||
const { accessContext } = require('../lib/tenancy');
|
||||
const { stripDeviceSecrets } = 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
|
||||
|
|
@ -85,6 +86,14 @@ router.get('/unassigned', (req, res) => {
|
|||
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);
|
||||
|
|
@ -202,6 +211,11 @@ router.put('/:id', (req, res) => {
|
|||
if (!device) return;
|
||||
|
||||
const { name, notes, timezone, orientation, default_content_id, layout_id } = 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 = [];
|
||||
|
|
@ -253,11 +267,36 @@ router.post('/:id/unblock', (req, res) => {
|
|||
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);
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ const sessionSettle = require('../lib/session-settle'); // #148 patch2: evicti
|
|||
const { resolveIdentity } = require('../lib/device-identity');
|
||||
const logCoalescer = require('../lib/log-coalescer');
|
||||
const loopLag = require('../services/loop-lag');
|
||||
const deviceSettings = require('../lib/device-settings'); // #150 delete+re-pair settings restore
|
||||
|
||||
// Debounce window for marking a device offline on socket disconnect. Brief
|
||||
// flap (Wi-Fi blip, Engine.IO ping miss, server-side eviction-then-reconnect)
|
||||
|
|
@ -628,6 +629,20 @@ module.exports = function setupDeviceSocket(io) {
|
|||
currentDeviceId = id;
|
||||
authenticated = true;
|
||||
|
||||
// #150: relink the fingerprint to the NEW device row (the fingerprint block above
|
||||
// leaves device_id NULL on a post-delete re-pair) so the settings key is reliable,
|
||||
// then restore any settings this physical device had at its last deletion —
|
||||
// orientation/name/playlist/etc come back automatically instead of resetting. Runs
|
||||
// BEFORE the dashboard:device-added emit below so that emit carries restored values.
|
||||
if (fingerprint) {
|
||||
try {
|
||||
db.prepare("INSERT INTO device_fingerprints (fingerprint, device_id, last_seen) VALUES (?, ?, strftime('%s','now')) ON CONFLICT(fingerprint) DO UPDATE SET device_id = excluded.device_id, last_seen = excluded.last_seen")
|
||||
.run(fingerprint, id);
|
||||
const restored = deviceSettings.applyToDevice(id, fingerprint);
|
||||
if (restored) console.log(`[#150] restored saved settings for re-paired device ${id} (fp ${fingerprint.slice(0, 8)}…)`);
|
||||
} catch (e) { console.warn(`[#150] settings restore failed for ${id}: ${e.message}`); }
|
||||
}
|
||||
|
||||
heartbeat.registerConnection(id, socket.id);
|
||||
socket.join(id);
|
||||
socket.emit('device:registered', { device_id: id, device_token: newToken, status: 'provisioning' });
|
||||
|
|
|
|||
Loading…
Reference in a new issue