Files
pulse-signage/src/player/local-control.js
T
lzstealth 0a03cdd0b7
Publish Docker Image / build-and-push-existing-registry (./build/Dockerfile, web, pulse-signage-web) (push) Successful in 2m6s
Publish Docker Image / build-and-push-existing-registry (./build/Dockerfile.player, player, pulse-signage-player) (push) Successful in 33s
Release v2.12.0
2026-09-11 17:22:31 +01:00

584 lines
32 KiB
JavaScript

// Player-local control login, cache, and command routes.
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const { WebSocketServer, WebSocket } = require('ws');
const { verifyPassword, createSessionToken, hashSessionToken } = require('#src/auth');
const { verifyRequestAuth } = require('#src/request-auth');
const DEFAULT_CACHE_MAX_AGE_MS = 24 * 60 * 60 * 1000;
const DEFAULT_LOGIN_RATE_LIMIT_WINDOW_MS = 15 * 60 * 1000;
const DEFAULT_LOGIN_RATE_LIMIT_MAX_ATTEMPTS = 5;
const LOCAL_COMMANDS = new Set(['reload', 'previous', 'next', 'pause', 'blackout']);
function parseCookies(value) {
return String(value || '').split(';').reduce(function (cookies, part) {
const separator = part.indexOf('=');
if (separator === -1) {
return cookies;
}
const name = decodeURIComponent(part.slice(0, separator).trim());
const cookieValue = decodeURIComponent(part.slice(separator + 1).trim());
if (name) {
cookies[name] = cookieValue;
}
return cookies;
}, {});
}
function serializeCookie(name, value, maxAgeMs) {
return `${encodeURIComponent(name)}=${encodeURIComponent(value)}; Max-Age=${Math.max(0, Math.trunc(Number(maxAgeMs) / 1000))}; Path=/local-control; HttpOnly; SameSite=Lax`;
}
function fingerprintUsername(username) {
return crypto.createHash('sha256').update(String(username || '').trim()).digest('hex');
}
function getSessionCookieName(playerIdentifier) {
const suffix = String(playerIdentifier || '').trim().replace(/[^a-zA-Z0-9_-]/g, '').slice(0, 128) || 'default';
return `pulse_local_control_${suffix}_session`;
}
function createLocalControlService(options) {
const app = options && options.app;
const server = options && options.server;
const playerRuntime = options && options.playerRuntime;
const pool = options && options.pool;
const cachePath = path.resolve(String(options && options.cachePath || 'player-cache/local-control-users.json'));
const sessionCookieName = getSessionCookieName(options && options.playerIdentifier);
const cacheMaxAgeMs = Number(options && options.cacheMaxAgeMs) > 0
? Number(options.cacheMaxAgeMs)
: DEFAULT_CACHE_MAX_AGE_MS;
const loginRateLimitWindowMs = Number(options && options.loginRateLimitWindowMs) > 0
? Number(options.loginRateLimitWindowMs)
: DEFAULT_LOGIN_RATE_LIMIT_WINDOW_MS;
const loginRateLimitMaxAttempts = Number(options && options.loginRateLimitMaxAttempts) > 0
? Math.trunc(Number(options.loginRateLimitMaxAttempts))
: DEFAULT_LOGIN_RATE_LIMIT_MAX_ATTEMPTS;
const sessions = new Map();
const loginFailures = new Map();
const localControlSockets = new Set();
const localControlWs = new WebSocketServer({ noServer: true });
let cache = { syncedAt: null, users: [] };
function normalizeUsers(users) {
return (Array.isArray(users) ? users : []).map(function (user) {
return {
username_hash: String(user && (user.username_hash || fingerprintUsername(user.username)) || '').trim(),
password_hash: String(user && user.password_hash || '').trim(),
password_salt: String(user && user.password_salt || '').trim()
};
}).filter(function (user) {
return Boolean(user.username_hash && user.password_hash && user.password_salt);
});
}
async function loadCache() {
try {
const payload = JSON.parse(await fs.promises.readFile(cachePath, 'utf8'));
cache = {
syncedAt: String(payload && payload.syncedAt || '').trim() || null,
users: normalizeUsers(payload && payload.users)
};
} catch (_error) {
cache = { syncedAt: null, users: [] };
}
return cache;
}
async function saveUsers(users) {
const nextCache = {
syncedAt: new Date().toISOString(),
users: normalizeUsers(users)
};
const usersChanged = JSON.stringify(nextCache.users) !== JSON.stringify(cache.users);
await fs.promises.mkdir(path.dirname(cachePath), { recursive: true, mode: 0o700 });
const temporaryPath = `${cachePath}.${process.pid}.tmp`;
await fs.promises.writeFile(temporaryPath, JSON.stringify(nextCache, null, 2), { encoding: 'utf8', mode: 0o600 });
await fs.promises.chmod(temporaryPath, 0o600);
await fs.promises.rename(temporaryPath, cachePath);
await fs.promises.chmod(cachePath, 0o600);
cache = nextCache;
if (usersChanged) {
sessions.clear();
localControlSockets.forEach(function (socket) {
try {
socket.terminate();
} catch (_error) {
localControlSockets.delete(socket);
}
});
}
return cache;
}
function isCacheUsable() {
const syncedAt = new Date(cache.syncedAt || 0).getTime();
return Boolean(cache.users.length && Number.isFinite(syncedAt) && Date.now() - syncedAt <= cacheMaxAgeMs);
}
function getLoginRateLimitKey(req, username) {
const remoteAddress = String(req && req.socket && req.socket.remoteAddress || '').trim();
return `${remoteAddress}:${fingerprintUsername(username)}`;
}
function getLoginFailureTimestamps(key, now) {
const cutoff = now - loginRateLimitWindowMs;
const timestamps = (loginFailures.get(key) || []).filter(function (timestamp) {
return timestamp > cutoff;
});
if (timestamps.length) {
loginFailures.set(key, timestamps);
} else {
loginFailures.delete(key);
}
return timestamps;
}
function getLoginRateLimitRetryAfter(req, username) {
const now = Date.now();
const timestamps = getLoginFailureTimestamps(getLoginRateLimitKey(req, username), now);
if (timestamps.length < loginRateLimitMaxAttempts) {
return 0;
}
return Math.max(1, Math.ceil((timestamps[0] + loginRateLimitWindowMs - now) / 1000));
}
function recordLoginFailure(req, username) {
const key = getLoginRateLimitKey(req, username);
const timestamps = getLoginFailureTimestamps(key, Date.now());
timestamps.push(Date.now());
loginFailures.set(key, timestamps);
}
function clearLoginFailures(req, username) {
loginFailures.delete(getLoginRateLimitKey(req, username));
}
function getUserFromRequest(req) {
const cookies = parseCookies(req && req.headers && req.headers.cookie);
const token = String(cookies[sessionCookieName] || '').trim();
if (!token) {
return null;
}
const session = sessions.get(hashSessionToken(token));
if (!session || session.expiresAt <= Date.now()) {
sessions.delete(hashSessionToken(token));
return null;
}
return session.user;
}
function requireLocalAuth(req, res, next) {
const user = getUserFromRequest(req);
if (!user) {
return res.status(401).json({ error: 'Local control login required.' });
}
req.localControlUser = user;
return next();
}
async function getScreenNames(slugs) {
if (!pool || !Array.isArray(slugs) || !slugs.length) {
return new Map();
}
try {
const [rows] = await pool.query('SELECT slug, name FROM d_screens WHERE slug IN (?)', [slugs]);
return new Map((Array.isArray(rows) ? rows : []).map(function (row) {
return [String(row && row.slug || '').trim(), String(row && row.name || '').trim()];
}));
} catch (_error) {
return new Map();
}
}
async function getState() {
const slugs = playerRuntime.snapshotSlugs();
const screenNames = await getScreenNames(slugs);
return {
screens: slugs.map(function (slug) {
return { slug: slug, name: screenNames.get(slug) || slug, connections: playerRuntime.snapshotConnections(slug) };
}),
syncedAt: cache.syncedAt
};
}
function sendState(socket, state) {
if (socket && socket.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify({ type: 'local-control-state', state: state }));
}
}
function broadcastState() {
if (!localControlSockets.size) {
return;
}
getState().then(function (state) {
localControlSockets.forEach(function (socket) { sendState(socket, state); });
}).catch(function () {});
}
function renderPage() {
return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Local control</title><style>body{font-family:system-ui,sans-serif;background:#18212b;color:#f3f6f8;margin:0;padding:2rem}main{max-width:58rem;margin:auto}section{background:#24313d;border:1px solid #405160;padding:1rem;margin:1rem 0;border-radius:6px}input,button{font:inherit;padding:.55rem;margin:.25rem 0}input{width:100%;box-sizing:border-box;background:#16202a;color:#fff;border:1px solid #607384}button{cursor:pointer}table{width:100%;border-collapse:collapse}td,th{text-align:left;padding:.6rem;border-bottom:1px solid #405160}#message{min-height:1.4rem}</style></head><body><main><h1>Local control</h1><section id="login"><form id="login-form"><label>Username<input name="username" autocomplete="username" required></label><label>Password<input name="password" type="password" autocomplete="current-password" required></label><button type="submit">Sign in</button></form></section><section id="controls" hidden><p id="message"></p><button id="logout" type="button">Sign out</button><table><thead><tr><th>Client</th><th>Screen</th><th>Actions</th></tr></thead><tbody id="clients"></tbody></table></section></main><script>(function(){var login=document.getElementById('login');var controls=document.getElementById('controls');var message=document.getElementById('message');var clients=document.getElementById('clients');function request(url,options){return fetch(url,options||{}).then(function(response){return response.json().catch(function(){return {};}).then(function(body){if(!response.ok){throw new Error(body.error||'Request failed.');}return body;});});}function showError(error){message.textContent=error.message||String(error);}function sendCommand(screen,connection,command){return request('/local-control/api/commands',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({slug:screen.slug,connectionId:connection.id,command:command})}).then(function(){message.textContent='Command sent.';}).catch(showError);}function render(state){login.hidden=true;controls.hidden=false;clients.textContent='';(state.screens||[]).forEach(function(screen){(screen.connections||[]).forEach(function(connection){var row=document.createElement('tr');[connection.clientName||connection.label||connection.clientId||'Client',screen.name||screen.slug].forEach(function(value){var cell=document.createElement('td');cell.textContent=value;row.appendChild(cell);});var cell=document.createElement('td');['Reload','Previous','Next','Pause','Blackout'].forEach(function(label){var action=document.createElement('button');action.type='button';action.textContent=label;action.addEventListener('click',function(){sendCommand(screen,connection,label.toLowerCase());});cell.appendChild(action);});row.appendChild(cell);clients.appendChild(row);});});}function load(){request('/local-control/api/state').then(render).catch(function(error){if(error.message.indexOf('login')!==-1){login.hidden=false;controls.hidden=true;}else{showError(error);}});}document.getElementById('login-form').addEventListener('submit',function(event){event.preventDefault();var data=new FormData(event.currentTarget);request('/local-control/api/login',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({username:data.get('username'),password:data.get('password')})}).then(load).catch(showError);});document.getElementById('logout').addEventListener('click',function(){request('/local-control/api/logout',{method:'POST'}).then(function(){location.reload();}).catch(showError);});load();setInterval(load,10000);}());</script></body></html>`;
}
function renderPageV2() {
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Local Control</title>
<style>
:root { color-scheme: dark; --page: #111a22; --panel: #1c2934; --panel-soft: #223442; --line: #3a4b59; --text: #f3f6f8; --muted: #aab8c2; --accent: #72c7b8; }
* { box-sizing: border-box; }
body { margin: 0; min-width: 320px; padding: 1.25rem; background: var(--page) radial-gradient(circle at top, #243847 0, var(--page) 42rem) no-repeat; background-size: 100% 42rem; color: var(--text); font-family: system-ui, sans-serif; }
main { width: min(100%, 66rem); margin: 0 auto; }
header { display: flex; align-items: center; justify-content: space-between; gap: 1rem; width: min(100%, 30rem); margin: 0 auto; padding: .25rem 0 1.25rem; }
header.wide { width: 100%; }
h1 { margin: 0; font-size: clamp(1.45rem, 4vw, 2rem); letter-spacing: .01em; }
section { background: color-mix(in srgb, var(--panel) 94%, transparent); border: 1px solid var(--line); border-radius: 10px; box-shadow: 0 1rem 2rem rgb(0 0 0 / 12%); }
#login { max-width: 30rem; margin: 0 auto; padding: 1.25rem; }
form { display: grid; gap: .8rem; }
label { display: grid; gap: .35rem; color: var(--muted); font-size: .9rem; }
input, button { font: inherit; }
input { width: 100%; padding: .7rem .75rem; border: 1px solid #607384; border-radius: 6px; background: #13202a; color: var(--text); }
button { min-height: 2.5rem; padding: .55rem .8rem; border: 1px solid #6d8797; border-radius: 6px; background: var(--panel-soft); color: var(--text); cursor: pointer; }
button:hover, button:focus-visible { border-color: var(--accent); outline: 2px solid rgb(114 199 184 / 25%); outline-offset: 1px; }
#logout { flex: 0 0 auto; }
#controls { overflow: hidden; }
table { width: 100%; border-collapse: collapse; }
th, td { padding: .85rem 1rem; text-align: left; vertical-align: middle; border-bottom: 1px solid var(--line); }
th { color: var(--muted); font-size: .78rem; font-weight: 600; letter-spacing: .06em; text-transform: uppercase; }
tbody tr:last-child td { border-bottom: 0; }
td:first-child { width: 31%; font-weight: 650; }
.client-cell { position: relative; padding-right: 6rem !important; }
td:nth-child(2) { width: 22%; color: var(--muted); }
td:nth-child(3) { width: 22%; color: var(--muted); }
.actions { display: grid; gap: .3rem; min-width: 14rem; }
.action-row { display: flex; flex-wrap: nowrap; gap: .3rem; }
.action-row button { flex: 1 1 0; margin: 0; min-height: 2.1rem; padding: .35rem .55rem; border: 0; font-size: .84rem; white-space: nowrap; }
.btn-danger { background: #dc3545; color: #fff; }
.btn-warning { background: #ffc107; color: #111; }
.btn-info { background: #0dcaf0; color: #111; }
.btn-secondary { background: #6c757d; color: #fff; }
.btn-success { background: #198754; color: #fff; }
.button-icon { display: inline-flex; width: 1rem; height: 1rem; margin-right: .35rem; vertical-align: -.15rem; }
.button-icon svg { width: 100%; height: 100%; fill: currentColor; }
.state-badge { position: absolute; top: .85rem; right: 1rem; padding: .2rem .45rem; border: 1px solid #d8a95b; border-radius: 999px; color: #ffd98b; font-size: .72rem; font-weight: 650; letter-spacing: .03em; }
.state-badge.blackout { border-color: #9aa7b2; color: #d9e0e5; }
@media (max-width: 600px) {
body { padding: .75rem; }
header { padding-bottom: 1rem; }
#login { padding: 1rem; }
table, thead, tbody, tr, td { display: block; }
thead { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
tbody { padding: .55rem; }
tr { margin: .55rem 0; padding: .85rem; border: 1px solid var(--line); border-radius: 8px; background: var(--panel-soft); }
td, td:first-child, td:nth-child(2), td:nth-child(3) { width: auto; padding: .15rem 0; border: 0; }
.client-cell { padding-right: 5.5rem !important; }
.client-cell .state-badge { top: .15rem; right: 0; }
td::before { display: block; margin-bottom: .15rem; color: var(--muted); font-size: .72rem; font-weight: 600; letter-spacing: .06em; text-transform: uppercase; content: attr(data-label); }
td:first-child { font-size: 1.05rem; }
td:nth-child(2) { margin-top: .6rem; }
td:last-child { margin-top: .8rem; }
.actions { gap: .25rem; overflow-x: auto; padding-bottom: .2rem; }
.action-row { gap: .25rem; }
.action-row button { padding: .35rem .5rem; font-size: .76rem; }
}
</style>
</head>
<body>
<main>
<header id="page-header"><h1>Local control</h1><button id="logout" type="button" hidden>Sign out</button></header>
<section id="login"><form id="login-form"><label>Username<input name="username" autocomplete="username" required></label><label>Password<input name="password" type="password" autocomplete="current-password" required></label><button type="submit">Sign in</button></form></section>
<section id="controls" hidden><table><thead><tr><th>Client</th><th>Screen</th><th>Current Slide</th><th>Actions</th></tr></thead><tbody id="clients"></tbody></table></section>
</main>
<script>
(function () {
var login = document.getElementById('login');
var controls = document.getElementById('controls');
var pageHeader = document.getElementById('page-header');
var logout = document.getElementById('logout');
var clients = document.getElementById('clients');
var socket = null;
var reconnectTimer = null;
function request(url, options) {
return fetch(url, options || {}).then(function (response) {
return response.json().catch(function () { return {}; }).then(function (body) {
if (!response.ok) { throw new Error(body.error || 'Request failed.'); }
return body;
});
});
}
function showError(error) { console.error(error); }
function getActionClass(command, connection) {
if (command === 'reload') { return 'btn-danger'; }
if (command === 'previous' || command === 'next') { return 'btn-warning'; }
if (command === 'pause') { return 'btn-info'; }
return connection.blackout ? 'btn-success' : 'btn-secondary';
}
function getActionContent(command, connection) {
var icons = {
reload: '<path d="M11.534 7h3.932a.25.25 0 0 1 .192.41l-1.966 2.36a.25.25 0 0 1-.384 0l-1.966-2.36a.25.25 0 0 1 .192-.41m-11 2h3.932a.25.25 0 0 0 .192-.41L2.692 6.23a.25.25 0 0 0-.384 0L.342 8.59A.25.25 0 0 0 .534 9"/><path fill-rule="evenodd" d="M8 3c-1.552 0-2.94.707-3.857 1.818a.5.5 0 1 1-.771-.636A6.002 6.002 0 0 1 13.917 7H12.9A5 5 0 0 0 8 3M3.1 9a5.002 5.002 0 0 0 8.757 2.182.5.5 0 1 1 .771.636A6.002 6.002 0 0 1 2.083 9z"/>',
previous: '<path d="M.5 3.5A.5.5 0 0 0 0 4v8a.5.5 0 0 0 1 0V8.753l6.267 3.636c.54.313 1.233-.066 1.233-.697v-2.94l6.267 3.636c.54.314 1.233-.065 1.233-.696V4.308c0-.63-.693-1.01-1.233-.696L8.5 7.248v-2.94c0-.63-.692-1.01-1.233-.696L1 7.248V4a.5.5 0 0 0-.5-.5"/>',
next: '<path d="M15.5 3.5a.5.5 0 0 1 .5.5v8a.5.5 0 0 1-1 0V8.753l-6.267 3.636c-.54.313-1.233-.066-1.233-.697v-2.94l-6.267 3.636C.693 12.703 0 12.324 0 11.693V4.308c0-.63.693-1.01 1.233-.696L7.5 7.248v-2.94c0-.63.693-1.01 1.233-.696L15 7.248V4a.5.5 0 0 1 .5-.5"/>',
pause: connection.paused ? '<path d="m11.596 8.697-6.363 3.692c-.54.313-1.233-.066-1.233-.697V4.308c0-.63.692-1.01 1.233-.696l6.363 3.692a.802.802 0 0 1 0 1.393"/>' : '<path d="M5.5 3.5A1.5 1.5 0 0 1 7 5v6a1.5 1.5 0 0 1-3 0V5a1.5 1.5 0 0 1 1.5-1.5m5 0A1.5 1.5 0 0 1 12 5v6a1.5 1.5 0 0 1-3 0V5a1.5 1.5 0 0 1 1.5-1.5"/>',
blackout: connection.blackout ? '<path d="M16 8s-3-5.5-8-5.5S0 8 0 8s3 5.5 8 5.5S16 8 16 8M1.173 8a13 13 0 0 1 1.66-2.043C4.12 4.668 5.88 3.5 8 3.5s3.879 1.168 5.168 2.457A13 13 0 0 1 14.828 8q-.086.13-.195.288c-.335.48-.83 1.12-1.465 1.755C11.879 11.332 10.119 12.5 8 12.5s-3.879-1.168-5.168-2.457A13 13 0 0 1 1.172 8z"/><path d="M8 5.5a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5M4.5 8a3.5 3.5 0 1 1 7 0 3.5 3.5 0 0 1-7 0"/>' : '<path d="M13.359 11.238C15.06 9.72 16 8 16 8s-3-5.5-8-5.5a7 7 0 0 0-2.79.588l.77.771A6 6 0 0 1 8 3.5c2.12 0 3.879 1.168 5.168 2.457A13 13 0 0 1 14.828 8q-.086.13-.195.288c-.335.48-.83 1.12-1.465 1.755q-.247.248-.517.486z"/><path d="M11.297 9.176a3.5 3.5 0 0 0-4.474-4.474l.823.823a2.5 2.5 0 0 1 2.829 2.829zm-2.943 1.299.822.822a3.5 3.5 0 0 1-4.474-4.474l.823.823a2.5 2.5 0 0 0 2.829 2.829"/><path d="M3.35 5.47q-.27.24-.518.487A13 13 0 0 0 1.172 8l.195.288c.335.48.83 1.12 1.465 1.755C4.121 11.332 5.881 12.5 8 12.5c.716 0 1.39-.133 2.02-.36l.77.772A7 7 0 0 1 8 13.5C3 13.5 0 8 0 8s.939-1.721 2.641-3.238l.708.709zm10.296 8.884-12-12 .708-.708 12 12z"/>'
};
var text = command === 'pause' ? (connection.paused ? 'Resume' : 'Pause') : command === 'blackout' ? (connection.blackout ? 'Restore' : 'Blackout') : '';
return '<span class="button-icon" aria-hidden="true"><svg viewBox="0 0 16 16" focusable="false">' + icons[command] + '</svg></span>' + text;
}
function addStateBadge(clientCell, connection) {
if (!connection.paused && !connection.blackout) { return; }
var badge = document.createElement('span');
badge.className = 'state-badge' + (connection.blackout ? ' blackout' : '');
badge.textContent = connection.blackout ? 'Blackout' : 'Paused';
badge.setAttribute('aria-label', connection.blackout ? 'Blackout' : 'Paused');
clientCell.appendChild(badge);
}
function updateConnectionRow(row, connection) {
var clientCell = row.children[0];
var stateBadge = clientCell.querySelector('.state-badge');
if (stateBadge) { stateBadge.remove(); }
addStateBadge(clientCell, connection);
var pauseButton = row.querySelector('button[data-command="pause"]');
pauseButton.className = 'local-action ' + getActionClass('pause', connection);
pauseButton.innerHTML = getActionContent('pause', connection);
pauseButton.setAttribute('aria-label', connection.paused ? 'Resume client' : 'Pause client');
pauseButton.title = connection.paused ? 'Resume client' : 'Pause client';
var blackoutButton = row.querySelector('button[data-command="blackout"]');
blackoutButton.className = 'local-action ' + getActionClass('blackout', connection);
blackoutButton.innerHTML = getActionContent('blackout', connection);
blackoutButton.setAttribute('aria-label', connection.blackout ? 'Restore client' : 'Blackout client');
blackoutButton.title = connection.blackout ? 'Restore client' : 'Blackout client';
}
function sendCommand(screen, connection, command, row) {
return request('/local-control/api/commands', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ slug: screen.slug, connectionId: connection.id, command: command }) })
.then(function () {
if (command === 'pause') { connection.paused = !connection.paused; }
if (command === 'blackout') { connection.blackout = !connection.blackout; }
updateConnectionRow(row, connection);
})
.catch(showError);
}
function render(state) {
login.hidden = true;
controls.hidden = false;
logout.hidden = false;
pageHeader.className = 'wide';
clients.textContent = '';
(state.screens || []).forEach(function (screen) {
(screen.connections || []).forEach(function (connection) {
var row = document.createElement('tr');
var clientCell = document.createElement('td');
clientCell.className = 'client-cell';
clientCell.dataset.label = 'Client';
clientCell.textContent = connection.clientName || connection.label || connection.clientId || 'Client';
addStateBadge(clientCell, connection);
row.appendChild(clientCell);
var screenCell = document.createElement('td');
screenCell.dataset.label = 'Screen';
screenCell.textContent = screen.name || screen.slug;
row.appendChild(screenCell);
var slideCell = document.createElement('td');
slideCell.dataset.label = 'Current Slide';
slideCell.textContent = connection.currentSlideTitle || 'No slide currently showing';
row.appendChild(slideCell);
var actionCell = document.createElement('td');
actionCell.dataset.label = 'Actions';
actionCell.className = 'actions';
[['Reload', 'Previous', 'Next'], ['Pause', 'Blackout']].forEach(function (labels) {
var actionRow = document.createElement('div');
actionRow.className = 'action-row';
labels.forEach(function (label) {
var action = document.createElement('button');
action.type = 'button';
action.dataset.command = label.toLowerCase();
action.className = 'local-action ' + getActionClass(label.toLowerCase(), connection);
action.innerHTML = getActionContent(label.toLowerCase(), connection);
action.setAttribute('aria-label', label + ' client');
action.title = label + ' client';
action.addEventListener('click', function () { sendCommand(screen, connection, label.toLowerCase(), row); });
actionRow.appendChild(action);
});
actionCell.appendChild(actionRow);
});
row.appendChild(actionCell);
clients.appendChild(row);
});
});
}
function load() {
request('/local-control/api/state').then(function (state) {
render(state);
connectSocket();
}).catch(function (error) {
if (error.message.indexOf('login') !== -1) { login.hidden = false; controls.hidden = true; logout.hidden = true; pageHeader.className = ''; }
else { showError(error); }
});
}
function scheduleReconnect() {
if (reconnectTimer || login.hidden === false) { return; }
reconnectTimer = window.setTimeout(function () {
reconnectTimer = null;
connectSocket();
}, 5000);
}
function connectSocket() {
if (!window.WebSocket || !login.hidden || (socket && (socket.readyState === WebSocket.OPEN || socket.readyState === WebSocket.CONNECTING))) { return; }
var protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
socket = new WebSocket(protocol + '//' + window.location.host + '/local-control/ws');
socket.onmessage = function (event) {
try {
var payload = JSON.parse(String(event.data || '{}'));
if (payload && payload.type === 'local-control-state') { render(payload.state); }
} catch (_error) {
}
};
socket.onclose = function () { socket = null; scheduleReconnect(); };
socket.onerror = function () { try { socket.close(); } catch (_error) {} };
}
document.getElementById('login-form').addEventListener('submit', function (event) {
event.preventDefault();
var data = new FormData(event.currentTarget);
request('/local-control/api/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username: data.get('username'), password: data.get('password') }) }).then(load).catch(showError);
});
logout.addEventListener('click', function () { request('/local-control/api/logout', { method: 'POST' }).then(function () { location.reload(); }).catch(showError); });
load();
if (!window.WebSocket) { setInterval(load, 10000); }
}());
</script>
</body>
</html>`;
}
if (!app || !playerRuntime) {
throw new Error('createLocalControlService requires app and playerRuntime.');
}
if (server) {
server.on('upgrade', function (request, socket, head) {
let pathname = '';
try {
pathname = new URL(request.url, 'http://localhost').pathname;
} catch (_error) {
socket.destroy();
return;
}
if (pathname !== '/local-control/ws') {
return;
}
if (!getUserFromRequest(request)) {
socket.destroy();
return;
}
localControlWs.handleUpgrade(request, socket, head, function (ws) {
localControlWs.emit('connection', ws, request);
});
});
localControlWs.on('connection', function (socket) {
localControlSockets.add(socket);
getState().then(function (state) { sendState(socket, state); }).catch(function () {});
socket.on('close', function () { localControlSockets.delete(socket); });
socket.on('error', function () { localControlSockets.delete(socket); });
});
if (typeof playerRuntime.subscribeSnapshot === 'function') {
playerRuntime.subscribeSnapshot(broadcastState);
}
}
app.use('/media/player-cache', function (_req, res) {
return res.sendStatus(404);
});
app.get('/local-control', function (req, res) {
res.set('Cache-Control', 'no-store');
res.type('html').send(renderPageV2());
});
app.post('/local-control/api/login', async function (req, res, next) {
try {
await loadCache();
if (!isCacheUsable()) {
return res.status(503).json({ error: 'Local control authorization is unavailable.' });
}
const username = String(req.body && req.body.username || '').trim();
const retryAfter = getLoginRateLimitRetryAfter(req, username);
if (retryAfter) {
res.set('Retry-After', String(retryAfter));
return res.status(429).json({ error: 'Too many local control login attempts. Try again later.' });
}
const user = cache.users.find(function (candidate) {
return candidate.username_hash === fingerprintUsername(username);
});
if (!user || !verifyPassword(String(req.body && req.body.password || ''), user)) {
recordLoginFailure(req, username);
return res.status(401).json({ error: 'Invalid local control credentials.' });
}
clearLoginFailures(req, username);
const token = createSessionToken();
sessions.set(hashSessionToken(token), { user: { id: user.id, name: user.name, username: user.username }, expiresAt: Date.now() + 12 * 60 * 60 * 1000 });
res.set('Set-Cookie', serializeCookie(sessionCookieName, token, 12 * 60 * 60 * 1000));
return res.json({ ok: true });
} catch (error) {
return next(error);
}
});
app.post('/local-control/api/logout', function (req, res) {
const cookies = parseCookies(req.headers && req.headers.cookie);
sessions.delete(hashSessionToken(String(cookies[sessionCookieName] || '').trim()));
res.set('Set-Cookie', serializeCookie(sessionCookieName, '', 0));
return res.json({ ok: true });
});
app.get('/local-control/api/state', requireLocalAuth, async function (_req, res, next) {
try {
return res.json(await getState());
} catch (error) {
return next(error);
}
});
app.post('/local-control/api/commands', requireLocalAuth, async function (req, res, next) {
try {
const body = req.body && typeof req.body === 'object' ? req.body : {};
const command = String(body.command || '').trim().toLowerCase();
const slug = String(body.slug || '').trim();
const connectionId = String(body.connectionId || '').trim();
if (!LOCAL_COMMANDS.has(command) || !slug || !connectionId) {
return res.status(400).json({ error: 'A valid local client command is required.' });
}
const sent = await playerRuntime.sendCommandToConnection(slug, connectionId, { command: command, screenSlug: slug, connectionId: connectionId });
if (!sent) {
return res.status(409).json({ error: 'Local client is not connected.' });
}
return res.json({ ok: true });
} catch (error) {
return next(error);
}
});
app.post('/api/internal/sync/player-control', function (req, res) {
if (!verifyRequestAuth(req)) {
return res.status(401).json({ error: 'Request authentication required.' });
}
return saveUsers(req.body && req.body.users).then(function () {
return res.json({ ok: true, syncedAt: cache.syncedAt, userCount: cache.users.length });
}).catch(function (error) {
return res.status(500).json({ error: error.message || 'Unable to save local control users.' });
});
});
return {
loadCache: loadCache,
saveUsers: saveUsers,
isCacheUsable: isCacheUsable,
getUserFromRequest: getUserFromRequest
};
}
module.exports = { createLocalControlService: createLocalControlService };