// 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 `Local control

Local control

`; } function renderPageV2() { return ` Local Control
`; } 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 };