diff --git a/server/player/index.html b/server/player/index.html index 7ec4d53..193127f 100644 --- a/server/player/index.html +++ b/server/player/index.html @@ -343,6 +343,41 @@ try { return JSON.parse(localStorage.getItem(LAYOUT_CACHE_KEY) || 'null'); } catch { return null; } } + // ==================== Identity reset (?reset=) ==================== + // A screen-only panel has no keyboard, no pointer and often no way to clear site data — but + // the URL it loads IS configurable from whatever manages it (the UniFi display UI, an MDM, a + // kiosk profile). This is the escape hatch for that: loading the player with ?reset= + // discards this install's identity so it comes back as a brand-new device with a fresh + // pairing code. That is the recovery path when a panel is holding an identity that belongs to + // a different screen, and the ordinary one for redeploying a panel to another site. + // + // ONCE PER TOKEN, and that is the whole design. The configured URL is permanent — nobody + // goes back and removes the parameter — so a reset that fired on every load would re-pair the + // screen on every reboot and look exactly like a device that cannot hold its pairing. The + // applied token is remembered, so ?reset=1 left in the URL forever resets exactly once; to + // reset again, change it to any other value (?reset=2). + // + // serverUrl is deliberately preserved: we are being served BY that server, so it is + // known-good, and clearing it would strand a panel that cannot be typed into. + (function applyIdentityReset() { + let token; + try { token = new URLSearchParams(location.search).get('reset'); } catch (e) { return; } + if (!token) return; + try { + if (localStorage.getItem('st_reset_applied') === token) return; // already honoured + const cfg = getConfig(); + delete cfg.deviceId; delete cfg.deviceToken; delete cfg.pairingCode; + cfg.paired = false; + localStorage.setItem(STORAGE_KEY, JSON.stringify(cfg)); + localStorage.removeItem('st_install_id'); // mint a NEW identity, not the old one + localStorage.removeItem(PLAYLIST_CACHE_KEY); + localStorage.removeItem(LAYOUT_CACHE_KEY); + localStorage.removeItem('st_group_sync'); + localStorage.setItem('st_reset_applied', token); + console.warn('[reset] identity cleared by ?reset=' + token + ' — this panel will pair as a new device'); + } catch (e) { /* storage unavailable: nothing to clear, and nothing to break */ } + })(); + // ==================== State ==================== let socket = null; let config = getConfig(); diff --git a/server/test/player-identity-reset.test.js b/server/test/player-identity-reset.test.js new file mode 100644 index 0000000..b5ed5ab --- /dev/null +++ b/server/test/player-identity-reset.test.js @@ -0,0 +1,138 @@ +'use strict'; + +// A screen-only panel has no keyboard, no pointer, and usually no way to clear site data. But +// the URL it loads is configurable from whatever manages it. ?reset= is the escape hatch: +// it discards this install's identity so the panel returns as a new device with a fresh pairing +// code. That is the recovery path when a panel ends up holding an identity that belongs to a +// different screen, and the ordinary path when redeploying a panel elsewhere. +// +// The critical property is ONCE PER TOKEN. The configured URL is permanent — nobody goes back to +// remove the parameter — so a reset that fired on every load would wipe the pairing on every +// reboot and present as a screen that cannot hold its pairing at all. Worse, it would look like +// an intermittent server fault rather than the URL doing exactly what it was told. +// +// serverUrl must survive, or a panel that cannot be typed into is stranded. + +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +const HTML = fs.readFileSync(path.join(__dirname, '..', 'player', 'index.html'), 'utf8'); + +// Lift the real IIFE out of the player and run it against a fake storage + URL. +function runReset(search, store) { + const marker = 'function applyIdentityReset()'; + const start = HTML.indexOf(marker); + assert.notEqual(start, -1, 'applyIdentityReset() should exist'); + let depth = 0, end = -1; + for (let j = HTML.indexOf('{', start); j < HTML.length; j++) { + if (HTML[j] === '{') depth++; + else if (HTML[j] === '}' && --depth === 0) { end = j + 1; break; } + } + const scope = { + STORAGE_KEY: 'rd_config', + PLAYLIST_CACHE_KEY: 'rd_playlist_cache', + LAYOUT_CACHE_KEY: 'rd_layout_cache', + getConfig: () => { try { return JSON.parse(store.rd_config || '{}'); } catch { return {}; } }, + localStorage: { + getItem: (k) => (k in store ? store[k] : null), + setItem: (k, v) => { store[k] = String(v); }, + removeItem: (k) => { delete store[k]; }, + }, + location: { search }, + URLSearchParams, + console: { warn() {} }, + }; + const fn = new Function(...Object.keys(scope), `${HTML.slice(start, end)} applyIdentityReset();`); + fn(...Object.values(scope)); + return store; +} + +const paired = () => ({ + rd_config: JSON.stringify({ serverUrl: 'https://screentinker.com', deviceId: 'dev-1', deviceToken: 'tok-1', paired: true }), + st_install_id: 'install-aaaa', + rd_playlist_cache: '[{"x":1}]', + rd_layout_cache: '{"zones":1}', + st_group_sync: '{"offset":5}', +}); + +test('THE POINT: ?reset= clears the identity so the panel pairs as a new device', () => { + const s = runReset('?reset=1', paired()); + const cfg = JSON.parse(s.rd_config); + assert.equal(cfg.deviceId, undefined, 'device id gone'); + assert.equal(cfg.deviceToken, undefined, 'token gone'); + assert.equal(cfg.paired, false); + assert.equal(s.st_install_id, undefined, 'a NEW identity is minted, not the old one reused'); +}); + +test('the server URL survives — a panel with no keyboard must not be stranded', () => { + const s = runReset('?reset=1', paired()); + assert.equal(JSON.parse(s.rd_config).serverUrl, 'https://screentinker.com'); +}); + +test('cached content is dropped so the new device does not show the old screen', () => { + const s = runReset('?reset=1', paired()); + assert.equal(s.rd_playlist_cache, undefined); + assert.equal(s.rd_layout_cache, undefined); + assert.equal(s.st_group_sync, undefined); +}); + +test('THE TRAP: the same token left in the URL forever resets exactly ONCE', () => { + const s = paired(); + runReset('?reset=1', s); + // Panel reboots. The configured URL still says ?reset=1 — it always will. + s.rd_config = JSON.stringify({ serverUrl: 'https://screentinker.com', deviceId: 'dev-2', deviceToken: 'tok-2', paired: true }); + s.st_install_id = 'install-bbbb'; + runReset('?reset=1', s); + const cfg = JSON.parse(s.rd_config); + assert.equal(cfg.deviceId, 'dev-2', 'the new pairing SURVIVES the reboot'); + assert.equal(cfg.paired, true); + assert.equal(s.st_install_id, 'install-bbbb', 'and keeps its identity'); +}); + +test('a DIFFERENT token resets again, so the hatch is reusable', () => { + const s = paired(); + runReset('?reset=1', s); + s.rd_config = JSON.stringify({ serverUrl: 'https://screentinker.com', deviceId: 'dev-2', paired: true }); + runReset('?reset=2', s); + assert.equal(JSON.parse(s.rd_config).deviceId, undefined, 'a new token means a new reset'); +}); + +test('no reset parameter changes nothing at all', () => { + const s = runReset('', paired()); + const cfg = JSON.parse(s.rd_config); + assert.equal(cfg.deviceId, 'dev-1'); + assert.equal(s.st_install_id, 'install-aaaa'); + assert.equal(s.st_reset_applied, undefined); +}); + +test('an unrelated query string is not mistaken for a reset', () => { + const s = runReset('?preview=1&playlist=abc', paired()); + assert.equal(JSON.parse(s.rd_config).deviceId, 'dev-1', 'preview mode is untouched'); +}); + +test('storage being unavailable does not throw — a dying panel must still boot', () => { + const marker = 'function applyIdentityReset()'; + const start = HTML.indexOf(marker); + let depth = 0, end = -1; + for (let j = HTML.indexOf('{', start); j < HTML.length; j++) { + if (HTML[j] === '{') depth++; + else if (HTML[j] === '}' && --depth === 0) { end = j + 1; break; } + } + const scope = { + STORAGE_KEY: 'rd_config', PLAYLIST_CACHE_KEY: 'p', LAYOUT_CACHE_KEY: 'l', + getConfig: () => ({}), + localStorage: { getItem() { throw new Error('denied'); }, setItem() { throw new Error('denied'); }, removeItem() { throw new Error('denied'); } }, + location: { search: '?reset=1' }, URLSearchParams, console: { warn() {} }, + }; + assert.doesNotThrow(() => + new Function(...Object.keys(scope), `${HTML.slice(start, end)} applyIdentityReset();`)(...Object.values(scope))); +}); + +test('it runs BEFORE config is read, or the reset would not take effect this boot', () => { + const resetAt = HTML.indexOf('function applyIdentityReset()'); + const configAt = HTML.indexOf('let config = getConfig();'); + assert.ok(resetAt !== -1 && configAt !== -1); + assert.ok(resetAt < configAt, 'identity is cleared before anything reads it'); +});