584 lines
32 KiB
JavaScript
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 };
|