// 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 controlLocal control
`;
}
function renderPageV2() {
return `
Local Control
| Client | Screen | Current Slide | Actions |
|---|
`;
}
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 };