Release v2.2.0
This commit is contained in:
+108
-3
@@ -1,8 +1,23 @@
|
||||
// Player runtime state, websocket connections, and request-auth enforcement.
|
||||
|
||||
const crypto = require('crypto');
|
||||
const { WebSocketServer, WebSocket } = require('ws');
|
||||
const { isClientNameAvailable } = require('../data/client-name-check');
|
||||
const { verifyPageAuthToken, verifyRequestAuth } = require('../request-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'
|
||||
@@ -12,6 +27,7 @@ function createPlayerRuntime(options) {
|
||||
};
|
||||
const connectionsBySlug = new Map();
|
||||
const dashboardListenersBySlug = new Map();
|
||||
const announcementListenersBySlug = new Map();
|
||||
const wss = new WebSocketServer({ noServer: true });
|
||||
|
||||
function normalizeClientIp(value) {
|
||||
@@ -72,6 +88,29 @@ function createPlayerRuntime(options) {
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
@@ -123,6 +162,7 @@ function createPlayerRuntime(options) {
|
||||
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),
|
||||
@@ -178,6 +218,30 @@ function createPlayerRuntime(options) {
|
||||
});
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -235,7 +299,9 @@ function createPlayerRuntime(options) {
|
||||
|
||||
const dashboardMatch = pathname.match(/^\/ws\/screens\/([^/]+)\/events$/);
|
||||
const playerMatch = pathname.match(/^\/ws\/screens\/([^/]+)$/);
|
||||
if (!dashboardMatch && !playerMatch) {
|
||||
const announcementMatch = pathname.match(/^\/ws\/screens\/([^/]+)\/announcements$/);
|
||||
|
||||
if (!dashboardMatch && !playerMatch && !announcementMatch) {
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
@@ -256,9 +322,18 @@ function createPlayerRuntime(options) {
|
||||
}
|
||||
}
|
||||
|
||||
const slug = decodeURIComponent((dashboardMatch || playerMatch)[1]);
|
||||
if (announcementMatch) {
|
||||
const authToken = String(new URL(request.url, 'http://localhost').searchParams.get('auth') || '').trim();
|
||||
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' : 'player');
|
||||
wss.emit('connection', ws, request, slug, dashboardMatch ? 'dashboard' : announcementMatch ? 'announcements' : 'player');
|
||||
});
|
||||
}
|
||||
|
||||
@@ -288,6 +363,30 @@ function createPlayerRuntime(options) {
|
||||
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);
|
||||
@@ -304,6 +403,7 @@ function createPlayerRuntime(options) {
|
||||
page: null,
|
||||
paused: false,
|
||||
blackout: false,
|
||||
playerPublicBaseUrl: null,
|
||||
clientIp: forwardedFor || normalizedRemoteAddress,
|
||||
remoteAddress: normalizedRemoteAddress,
|
||||
label: forwardedFor || normalizedRemoteAddress || 'connected client',
|
||||
@@ -341,6 +441,10 @@ function createPlayerRuntime(options) {
|
||||
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;
|
||||
@@ -372,6 +476,7 @@ function createPlayerRuntime(options) {
|
||||
|
||||
return {
|
||||
installWebsocket: installWebsocket,
|
||||
broadcastAnnouncementRefresh: broadcastAnnouncementRefresh,
|
||||
snapshotConnections: snapshotConnections,
|
||||
snapshotAllConnections: snapshotAllConnections,
|
||||
isClientNameAvailableOnScreen: isClientNameAvailableOnScreen,
|
||||
|
||||
Reference in New Issue
Block a user