520 lines
16 KiB
JavaScript
520 lines
16 KiB
JavaScript
// Player runtime state, websocket connections, and request-auth enforcement.
|
|
|
|
const crypto = require('crypto');
|
|
const { WebSocketServer, WebSocket } = require('ws');
|
|
const { isClientNameAvailable } = require('#src/data/client-name-check');
|
|
const { verifyPageAuthToken, verifyRequestAuth } = require('#src/request-auth');
|
|
const PAGE_AUTH_COOKIE_NAME = 'pulse_page_auth';
|
|
|
|
function normalizePlayerPublicBaseUrl(pageUrl) {
|
|
const value = String(pageUrl || '').trim();
|
|
if (!value) {
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
return new URL(value).origin.replace(/\/$/, '');
|
|
} catch (_error) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function createPlayerRuntime(options) {
|
|
const pool = options && options.pool ? options.pool : null;
|
|
const normalizeDeviceId = typeof options.normalizeDeviceId === 'function'
|
|
? options.normalizeDeviceId
|
|
: function (value) {
|
|
return String(value || '').trim().replace(/[^a-zA-Z0-9_-]/g, '').slice(0, 128);
|
|
};
|
|
const connectionsBySlug = new Map();
|
|
const dashboardListenersBySlug = new Map();
|
|
const announcementListenersBySlug = new Map();
|
|
const wss = new WebSocketServer({ noServer: true });
|
|
|
|
function normalizeClientIp(value) {
|
|
const ip = String(value || '').trim();
|
|
if (!ip) {
|
|
return null;
|
|
}
|
|
|
|
if (ip.toLowerCase().startsWith('::ffff:')) {
|
|
return ip.slice(7).trim() || null;
|
|
}
|
|
|
|
return ip;
|
|
}
|
|
|
|
function parseCookies(cookieHeader) {
|
|
return String(cookieHeader || '').split(/;\s*/).reduce(function (cookies, pair) {
|
|
if (!pair) {
|
|
return cookies;
|
|
}
|
|
const separatorIndex = pair.indexOf('=');
|
|
if (separatorIndex === -1) {
|
|
return cookies;
|
|
}
|
|
const name = decodeURIComponent(pair.slice(0, separatorIndex).trim());
|
|
const value = decodeURIComponent(pair.slice(separatorIndex + 1).trim());
|
|
if (name) {
|
|
cookies[name] = value;
|
|
}
|
|
return cookies;
|
|
}, {});
|
|
}
|
|
|
|
function readPageAuthToken(request) {
|
|
const queryToken = String(new URL(request.url, 'http://localhost').searchParams.get('auth') || '').trim();
|
|
if (queryToken) {
|
|
return queryToken;
|
|
}
|
|
|
|
const cookies = parseCookies(request.headers && request.headers.cookie || '');
|
|
return String(cookies[PAGE_AUTH_COOKIE_NAME] || '').trim();
|
|
}
|
|
|
|
function getConnectionBucket(slug) {
|
|
const key = String(slug || '').trim();
|
|
if (!key) {
|
|
return null;
|
|
}
|
|
if (!connectionsBySlug.has(key)) {
|
|
connectionsBySlug.set(key, new Map());
|
|
}
|
|
return connectionsBySlug.get(key);
|
|
}
|
|
|
|
function removeConnection(slug, connectionId) {
|
|
const bucket = connectionsBySlug.get(String(slug || '').trim());
|
|
if (!bucket) {
|
|
return;
|
|
}
|
|
bucket.delete(connectionId);
|
|
if (!bucket.size) {
|
|
connectionsBySlug.delete(String(slug || '').trim());
|
|
}
|
|
}
|
|
|
|
function getDashboardListenerBucket(slug) {
|
|
const key = String(slug || '').trim();
|
|
if (!key) {
|
|
return null;
|
|
}
|
|
if (!dashboardListenersBySlug.has(key)) {
|
|
dashboardListenersBySlug.set(key, new Set());
|
|
}
|
|
return dashboardListenersBySlug.get(key);
|
|
}
|
|
|
|
function removeDashboardListener(slug, socket) {
|
|
const key = String(slug || '').trim();
|
|
const bucket = dashboardListenersBySlug.get(key);
|
|
if (!bucket) {
|
|
return;
|
|
}
|
|
bucket.delete(socket);
|
|
if (!bucket.size) {
|
|
dashboardListenersBySlug.delete(key);
|
|
}
|
|
}
|
|
|
|
function getAnnouncementListenerBucket(slug) {
|
|
const key = String(slug || '').trim();
|
|
if (!key) {
|
|
return null;
|
|
}
|
|
if (!announcementListenersBySlug.has(key)) {
|
|
announcementListenersBySlug.set(key, new Set());
|
|
}
|
|
return announcementListenersBySlug.get(key);
|
|
}
|
|
|
|
function removeAnnouncementListener(slug, socket) {
|
|
const key = String(slug || '').trim();
|
|
const bucket = announcementListenersBySlug.get(key);
|
|
if (!bucket) {
|
|
return;
|
|
}
|
|
bucket.delete(socket);
|
|
if (!bucket.size) {
|
|
announcementListenersBySlug.delete(key);
|
|
}
|
|
}
|
|
|
|
function buildClientLabel(connection) {
|
|
const clientName = String(connection.clientName || '').trim();
|
|
const clientId = String(connection.clientId || '').trim();
|
|
const userAgent = String(connection.userAgent || '').trim();
|
|
const clientIp = String(connection.clientIp || '').trim();
|
|
const viewport = connection.viewport && typeof connection.viewport === 'object'
|
|
? connection.viewport
|
|
: null;
|
|
const labelParts = [];
|
|
|
|
if (userAgent) {
|
|
labelParts.push(userAgent.length > 72 ? `${userAgent.slice(0, 72)}...` : userAgent);
|
|
}
|
|
|
|
if (clientName) {
|
|
labelParts.push(clientName);
|
|
} else if (clientId) {
|
|
labelParts.push(`id ${clientId.slice(-6)}`);
|
|
}
|
|
|
|
if (clientIp) {
|
|
labelParts.push(clientIp);
|
|
}
|
|
|
|
if (viewport && Number.isFinite(Number(viewport.width)) && Number.isFinite(Number(viewport.height))) {
|
|
labelParts.push(`${Number(viewport.width)}x${Number(viewport.height)}`);
|
|
}
|
|
|
|
if (!labelParts.length) {
|
|
return connection.remoteAddress || 'connected client';
|
|
}
|
|
|
|
return labelParts.join(' • ');
|
|
}
|
|
|
|
function snapshotConnections(slug) {
|
|
const bucket = connectionsBySlug.get(String(slug || '').trim());
|
|
if (!bucket) {
|
|
return [];
|
|
}
|
|
|
|
return Array.from(bucket.values()).map(function (connection) {
|
|
return {
|
|
id: connection.id,
|
|
clientId: connection.clientId || null,
|
|
clientName: connection.clientName || null,
|
|
deviceId: connection.deviceId || null,
|
|
label: connection.label,
|
|
userAgent: connection.userAgent || null,
|
|
viewport: connection.viewport || null,
|
|
page: connection.page || null,
|
|
playerPublicBaseUrl: connection.playerPublicBaseUrl || null,
|
|
currentSlide: connection.currentSlide || null,
|
|
paused: Boolean(connection.paused),
|
|
blackout: Boolean(connection.blackout),
|
|
currentSlideId: connection.currentSlide && connection.currentSlide.id ? connection.currentSlide.id : null,
|
|
currentSlideTitle: connection.currentSlide && connection.currentSlide.title ? connection.currentSlide.title : null,
|
|
clientIp: connection.clientIp || null,
|
|
remoteAddress: connection.remoteAddress || null,
|
|
connectedAt: connection.connectedAt ? connection.connectedAt.toISOString() : null,
|
|
lastSeenAt: connection.lastSeenAt ? connection.lastSeenAt.toISOString() : null
|
|
};
|
|
});
|
|
}
|
|
|
|
function snapshotAllConnections() {
|
|
const allConnections = [];
|
|
for (const bucket of connectionsBySlug.values()) {
|
|
if (!bucket || typeof bucket.values !== 'function') {
|
|
continue;
|
|
}
|
|
for (const connection of bucket.values()) {
|
|
allConnections.push({
|
|
clientId: connection.clientId || null,
|
|
clientName: connection.clientName || null,
|
|
deviceId: connection.deviceId || null
|
|
});
|
|
}
|
|
}
|
|
return allConnections;
|
|
}
|
|
|
|
async function isClientNameAvailableOnScreen(poolArg, clientName, excludeDeviceId) {
|
|
return isClientNameAvailable(poolArg || pool, clientName, excludeDeviceId, snapshotAllConnections());
|
|
}
|
|
|
|
function broadcastConnectionSnapshot(slug) {
|
|
const key = String(slug || '').trim();
|
|
const bucket = dashboardListenersBySlug.get(key);
|
|
if (!bucket || !bucket.size) {
|
|
return;
|
|
}
|
|
|
|
const payload = JSON.stringify({
|
|
type: 'snapshot',
|
|
slug: key,
|
|
connections: snapshotConnections(slug),
|
|
sentAt: new Date().toISOString()
|
|
});
|
|
|
|
bucket.forEach(function (socket) {
|
|
if (socket.readyState === WebSocket.OPEN) {
|
|
socket.send(payload);
|
|
}
|
|
});
|
|
}
|
|
|
|
function broadcastAnnouncementRefresh(slug) {
|
|
const key = String(slug || '').trim();
|
|
const bucket = announcementListenersBySlug.get(key);
|
|
if (!bucket || !bucket.size) {
|
|
return 0;
|
|
}
|
|
|
|
const payload = JSON.stringify({
|
|
type: 'announcement-refresh',
|
|
slug: key,
|
|
sentAt: new Date().toISOString()
|
|
});
|
|
|
|
let sent = 0;
|
|
bucket.forEach(function (socket) {
|
|
if (socket.readyState === WebSocket.OPEN) {
|
|
socket.send(payload);
|
|
sent += 1;
|
|
}
|
|
});
|
|
|
|
return sent;
|
|
}
|
|
|
|
async function sendCommandToConnection(slug, connectionId, commandOrPayload) {
|
|
const bucket = connectionsBySlug.get(String(slug || '').trim());
|
|
if (!bucket || !bucket.size) {
|
|
return 0;
|
|
}
|
|
|
|
const target = bucket.get(String(connectionId || '').trim());
|
|
if (!target || target.socket.readyState !== WebSocket.OPEN) {
|
|
return 0;
|
|
}
|
|
|
|
const payload = typeof commandOrPayload === 'object' && commandOrPayload !== null
|
|
? Object.assign({}, commandOrPayload)
|
|
: { command: commandOrPayload };
|
|
payload.type = 'command';
|
|
payload.targetConnectionId = target.id;
|
|
payload.sentAt = new Date().toISOString();
|
|
|
|
target.socket.send(JSON.stringify(payload));
|
|
return 1;
|
|
}
|
|
|
|
async function broadcastCommand(slug, commandOrPayload) {
|
|
const bucket = connectionsBySlug.get(String(slug || '').trim());
|
|
if (!bucket || !bucket.size) {
|
|
return 0;
|
|
}
|
|
|
|
let sent = 0;
|
|
bucket.forEach(function (connection) {
|
|
if (connection.socket.readyState !== WebSocket.OPEN) {
|
|
return;
|
|
}
|
|
const payload = typeof commandOrPayload === 'object' && commandOrPayload !== null
|
|
? Object.assign({}, commandOrPayload)
|
|
: { command: commandOrPayload };
|
|
payload.type = 'command';
|
|
payload.sentAt = new Date().toISOString();
|
|
|
|
connection.socket.send(JSON.stringify(payload));
|
|
sent += 1;
|
|
});
|
|
|
|
return sent;
|
|
}
|
|
|
|
function handleUpgrade(request, socket, head) {
|
|
let pathname = '';
|
|
try {
|
|
pathname = new URL(request.url, 'http://localhost').pathname;
|
|
} catch (_error) {
|
|
socket.destroy();
|
|
return;
|
|
}
|
|
|
|
const dashboardMatch = pathname.match(/^\/ws\/screens\/([^/]+)\/events$/);
|
|
const playerMatch = pathname.match(/^\/ws\/screens\/([^/]+)$/);
|
|
const announcementMatch = pathname.match(/^\/ws\/screens\/([^/]+)\/announcements$/);
|
|
|
|
if (!dashboardMatch && !playerMatch && !announcementMatch) {
|
|
socket.destroy();
|
|
return;
|
|
}
|
|
|
|
if (dashboardMatch) {
|
|
if (!verifyRequestAuth(request)) {
|
|
socket.destroy();
|
|
return;
|
|
}
|
|
}
|
|
|
|
if (playerMatch) {
|
|
const authToken = readPageAuthToken(request);
|
|
const payload = verifyPageAuthToken(authToken);
|
|
if (!payload || String(payload.scope || '').trim() !== 'player') {
|
|
socket.destroy();
|
|
return;
|
|
}
|
|
}
|
|
|
|
if (announcementMatch) {
|
|
const authToken = readPageAuthToken(request);
|
|
const payload = verifyPageAuthToken(authToken);
|
|
if (!payload || String(payload.scope || '').trim() !== 'player') {
|
|
socket.destroy();
|
|
return;
|
|
}
|
|
}
|
|
|
|
const slug = decodeURIComponent((dashboardMatch || playerMatch || announcementMatch)[1]);
|
|
wss.handleUpgrade(request, socket, head, function (ws) {
|
|
wss.emit('connection', ws, request, slug, dashboardMatch ? 'dashboard' : announcementMatch ? 'announcements' : 'player');
|
|
});
|
|
}
|
|
|
|
wss.on('connection', function (socket, request, slug, role) {
|
|
if (role === 'dashboard') {
|
|
const listenerBucket = getDashboardListenerBucket(slug);
|
|
if (!listenerBucket) {
|
|
socket.close();
|
|
return;
|
|
}
|
|
|
|
listenerBucket.add(socket);
|
|
socket.send(JSON.stringify({
|
|
type: 'snapshot',
|
|
slug: String(slug || '').trim(),
|
|
connections: snapshotConnections(slug),
|
|
sentAt: new Date().toISOString()
|
|
}));
|
|
|
|
socket.on('close', function () {
|
|
removeDashboardListener(slug, socket);
|
|
});
|
|
|
|
socket.on('error', function () {
|
|
removeDashboardListener(slug, socket);
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (role === 'announcements') {
|
|
const listenerBucket = getAnnouncementListenerBucket(slug);
|
|
if (!listenerBucket) {
|
|
socket.close();
|
|
return;
|
|
}
|
|
|
|
listenerBucket.add(socket);
|
|
socket.send(JSON.stringify({
|
|
type: 'announcement-ready',
|
|
slug: String(slug || '').trim(),
|
|
sentAt: new Date().toISOString()
|
|
}));
|
|
|
|
socket.on('close', function () {
|
|
removeAnnouncementListener(slug, socket);
|
|
});
|
|
|
|
socket.on('error', function () {
|
|
removeAnnouncementListener(slug, socket);
|
|
});
|
|
return;
|
|
}
|
|
|
|
const remoteAddress = request.socket && request.socket.remoteAddress ? request.socket.remoteAddress : null;
|
|
const forwardedFor = normalizeClientIp(String(request.headers['x-forwarded-for'] || '').split(',')[0]);
|
|
const normalizedRemoteAddress = normalizeClientIp(remoteAddress);
|
|
const connectionId = crypto.randomUUID();
|
|
const connection = {
|
|
id: connectionId,
|
|
slug: slug,
|
|
socket: socket,
|
|
clientId: null,
|
|
clientName: null,
|
|
deviceId: null,
|
|
userAgent: null,
|
|
viewport: null,
|
|
page: null,
|
|
paused: false,
|
|
blackout: false,
|
|
playerPublicBaseUrl: null,
|
|
clientIp: forwardedFor || normalizedRemoteAddress,
|
|
remoteAddress: normalizedRemoteAddress,
|
|
label: forwardedFor || normalizedRemoteAddress || 'connected client',
|
|
connectedAt: new Date(),
|
|
lastSeenAt: new Date()
|
|
};
|
|
const bucket = getConnectionBucket(slug);
|
|
|
|
if (!bucket) {
|
|
socket.close();
|
|
return;
|
|
}
|
|
|
|
bucket.set(connectionId, connection);
|
|
|
|
socket.on('message', function (rawMessage) {
|
|
connection.lastSeenAt = new Date();
|
|
let payload = null;
|
|
try {
|
|
payload = JSON.parse(String(rawMessage || ''));
|
|
} catch (_error) {
|
|
return;
|
|
}
|
|
|
|
if (!payload || payload.type !== 'state') {
|
|
return;
|
|
}
|
|
|
|
connection.clientId = payload.clientId ? String(payload.clientId).trim() : connection.clientId;
|
|
connection.clientName = payload.clientName ? String(payload.clientName).trim() : connection.clientName;
|
|
connection.deviceId = payload.deviceId ? normalizeDeviceId(payload.deviceId) || connection.deviceId : connection.deviceId;
|
|
if (!connection.clientName && connection.clientId) {
|
|
connection.clientName = connection.clientId;
|
|
}
|
|
connection.userAgent = payload.userAgent ? String(payload.userAgent).trim() : connection.userAgent;
|
|
connection.viewport = payload.viewport && typeof payload.viewport === 'object' ? payload.viewport : connection.viewport;
|
|
connection.page = payload.page ? String(payload.page).trim() : connection.page;
|
|
const nextPlayerPublicBaseUrl = normalizePlayerPublicBaseUrl(payload.page);
|
|
if (nextPlayerPublicBaseUrl && nextPlayerPublicBaseUrl !== connection.playerPublicBaseUrl) {
|
|
connection.playerPublicBaseUrl = nextPlayerPublicBaseUrl;
|
|
}
|
|
connection.paused = Boolean(payload.paused);
|
|
connection.blackout = Boolean(payload.blackout);
|
|
connection.clientIp = payload.clientIp ? normalizeClientIp(payload.clientIp) || connection.clientIp : connection.clientIp;
|
|
connection.currentSlide = payload.currentSlide && typeof payload.currentSlide === 'object' ? {
|
|
id: payload.currentSlide.id || null,
|
|
title: payload.currentSlide.title || '',
|
|
kind: payload.currentSlide.kind || '',
|
|
playlistSignature: payload.currentSlide.playlistSignature || ''
|
|
} : connection.currentSlide;
|
|
connection.label = buildClientLabel(connection);
|
|
connection.lastSeenAt = new Date();
|
|
broadcastConnectionSnapshot(slug);
|
|
});
|
|
|
|
socket.on('close', function () {
|
|
removeConnection(slug, connectionId);
|
|
broadcastConnectionSnapshot(slug);
|
|
});
|
|
|
|
socket.on('error', function () {
|
|
removeConnection(slug, connectionId);
|
|
broadcastConnectionSnapshot(slug);
|
|
});
|
|
});
|
|
|
|
function installWebsocket(server) {
|
|
server.on('upgrade', handleUpgrade);
|
|
}
|
|
|
|
return {
|
|
installWebsocket: installWebsocket,
|
|
broadcastAnnouncementRefresh: broadcastAnnouncementRefresh,
|
|
snapshotConnections: snapshotConnections,
|
|
snapshotAllConnections: snapshotAllConnections,
|
|
isClientNameAvailableOnScreen: isClientNameAvailableOnScreen,
|
|
sendCommandToConnection: sendCommandToConnection,
|
|
broadcastCommand: broadcastCommand
|
|
};
|
|
}
|
|
|
|
module.exports = {
|
|
createPlayerRuntime: createPlayerRuntime
|
|
};
|