Add multi-player and remote bridge support

This commit is contained in:
2026-08-07 02:58:18 +01:00
parent a4f8a807ff
commit 74318eb34e
58 changed files with 3785 additions and 432 deletions
+130 -44
View File
@@ -1,8 +1,10 @@
// Player onboarding routes and signup flow helpers.
const express = require('express');
const { isClientNameAvailable, withClientNameReservation } = require('#src/data/client-name-check');
const { createStyledQrCodeSvg } = require('#src/data/qr-code');
const { getSharedSecret, verifyPageAuthToken } = require('#src/request-auth');
const { getSharedSecret, verifyPageAuthToken, createRequestAuthHeaders } = require('#src/request-auth');
const { resolvePlayerRegistration, upsertPlayerRegistration: upsertPlayerRegistrationRecord } = require('#src/data/player-registry');
const { isTransientDbError } = require('./store');
const ONBOARDING_SIGNUP_LIMIT_WINDOW_MS = 5 * 60 * 1000;
@@ -133,7 +135,7 @@ async function commitDeviceBinding(pool, deviceId, clientName, screenSlug, isNam
});
}
async function upsertPlayerRegistration(pool, deviceId, publicBaseUrl, internalBaseUrl, screenSlug) {
async function upsertPlayerRegistration(pool, deviceId, publicBaseUrl, internalBaseUrl) {
const normalizedDeviceId = normalizeDeviceId(deviceId);
const normalizedPublicBaseUrl = String(publicBaseUrl || '').trim().replace(/\/$/, '');
const normalizedInternalBaseUrl = String(internalBaseUrl || '').trim().replace(/\/$/, '');
@@ -141,18 +143,11 @@ async function upsertPlayerRegistration(pool, deviceId, publicBaseUrl, internalB
return null;
}
await pool.query(
`INSERT INTO d_players (device_id, public_base_url, internal_base_url, last_seen_at)
VALUES (?, ?, ?, CURRENT_TIMESTAMP)
ON DUPLICATE KEY UPDATE public_base_url = VALUES(public_base_url), internal_base_url = VALUES(internal_base_url), last_seen_at = CURRENT_TIMESTAMP, modified_at = CURRENT_TIMESTAMP`,
[normalizedDeviceId, normalizedPublicBaseUrl || null, normalizedInternalBaseUrl || null]
);
return {
device_id: normalizedDeviceId,
public_base_url: normalizedPublicBaseUrl || null,
internal_base_url: normalizedInternalBaseUrl || null
};
return upsertPlayerRegistrationRecord(pool, {
identifier: normalizedDeviceId,
publicBaseUrl: normalizedPublicBaseUrl || null,
internalBaseUrl: normalizedInternalBaseUrl || null
});
}
async function bindPlayerToScreen(pool, deviceId, screenSlug) {
@@ -167,25 +162,9 @@ async function bindPlayerToScreen(pool, deviceId, screenSlug) {
return null;
}
const screenId = Number(screenRows[0].id);
const connection = await pool.getConnection();
try {
await connection.beginTransaction();
// Current model: every screen binds to the shared player row '1'.
// If we introduce multiple players, resolve the correct player row here instead of hardcoding it.
await connection.query("UPDATE d_screens SET player_id = '1', modified_at = CURRENT_TIMESTAMP WHERE id = ?", [screenId]);
await connection.commit();
} catch (error) {
await connection.rollback();
throw error;
} finally {
connection.release();
}
return {
screen_id: screenId,
screen_slug: normalizedScreenSlug,
player_id: '1'
screen_id: Number(screenRows[0].id),
screen_slug: normalizedScreenSlug
};
}
@@ -224,13 +203,66 @@ function registerPlayerOnboardingRoutes(app, options) {
const common = options && options.common ? options.common : null;
const playerRuntime = options && options.playerRuntime ? options.playerRuntime : null;
const onboardingStore = options && options.onboardingStore ? options.onboardingStore : null;
const playerPublicBaseUrl = String(options && options.playerPublicBaseUrl || process.env.PLAYER_PUBLIC_BASE_URL || process.env.PLAYER_BASE_URL || '').trim().replace(/\/$/, '');
const thinClientBaseUrl = String(options && options.thinClientBaseUrl || process.env.THIN_CLIENT_BASE_URL || '').trim().replace(/\/$/, '');
const playerDeviceId = normalizeDeviceId(options && options.playerDeviceId);
if (!app || !pool || !common || !playerRuntime) {
throw new Error('registerPlayerOnboardingRoutes requires app, pool, common, and playerRuntime.');
if (!app || !common) {
throw new Error('registerPlayerOnboardingRoutes requires app and common.');
}
if (!thinClientBaseUrl && (!pool || !playerRuntime)) {
throw new Error('registerPlayerOnboardingRoutes requires pool and playerRuntime unless thinClientBaseUrl is configured.');
}
const sharedSecret = getSharedSecret();
async function fetchThinClient(req, pathname, options) {
if (!thinClientBaseUrl) {
return null;
}
const requestOptions = options && typeof options === 'object' ? options : {};
const method = String(requestOptions.method || req.method || 'GET').trim().toUpperCase();
const body = Object.prototype.hasOwnProperty.call(requestOptions, 'body') ? requestOptions.body : undefined;
const requestPathname = String(pathname || '').split('?')[0];
const headers = Object.assign({}, requestOptions.headers || {}, createRequestAuthHeaders({
method: method,
pathname: requestPathname,
body: body
}));
if (req.headers['x-pulse-page-auth']) {
headers['x-pulse-page-auth'] = String(req.headers['x-pulse-page-auth']).trim();
}
if (requestOptions.contentType) {
headers['content-type'] = requestOptions.contentType;
}
return fetch(new URL(pathname, thinClientBaseUrl).toString(), {
method: method,
headers: headers,
body: body === undefined || body === null || method === 'GET' || method === 'HEAD' ? undefined : body
});
}
async function readJsonResponse(response) {
if (!response) {
return null;
}
const contentType = String(response.headers && typeof response.headers.get === 'function' ? response.headers.get('content-type') : '').toLowerCase();
if (contentType.indexOf('application/json') === -1 && contentType.indexOf('+json') === -1) {
return null;
}
try {
return await response.json();
} catch (_error) {
return null;
}
}
function requireOnboardingPageAuth(req, res, next) {
if (!sharedSecret) {
return next();
@@ -254,7 +286,7 @@ function registerPlayerOnboardingRoutes(app, options) {
app.get('/onboard', async function (req, res, next) {
res.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
try {
res.send(common.renderPlayerOnboardingFormPage(String(req.query.deviceId || '').trim()));
res.send(common.renderPlayerOnboardingFormPage(String(req.query.deviceId || playerDeviceId || '').trim()));
} catch (error) {
next(error);
}
@@ -272,15 +304,32 @@ function registerPlayerOnboardingRoutes(app, options) {
next();
}, async function (req, res, next) {
try {
const status = await getOnboardingStatus(pool, req.query.deviceId);
const deviceId = normalizeDeviceId(req.query.deviceId) || playerDeviceId;
if (thinClientBaseUrl) {
const response = await fetchThinClient(req, '/api/onboarding/status?deviceId=' + encodeURIComponent(deviceId || ''), {
method: 'GET'
});
if (!response) {
return res.status(502).json({ error: 'Player bridge unavailable.' });
}
res.status(response.status);
const payload = await readJsonResponse(response);
if (!payload) {
return res.status(502).json({ error: 'Player bridge returned an invalid response.' });
}
payload.playerUrl = payload && payload.screenSlug ? `${getPublicBaseUrl(req, playerPublicBaseUrl)}/screen/${encodeURIComponent(payload.screenSlug)}` : null;
return res.json(payload);
}
const status = await getOnboardingStatus(pool, deviceId);
res.json({
deviceId: normalizeDeviceId(req.query.deviceId),
deviceId: normalizeDeviceId(deviceId),
onboarded: Boolean(status && status.screen_id),
clientName: status ? status.client_name : null,
screenId: status ? status.screen_id : null,
screenSlug: status ? status.screen_slug : null,
screenName: status ? status.screen_name : null,
playerUrl: status && status.screen_slug ? `${getPublicBaseUrl(req)}/screen/${encodeURIComponent(status.screen_slug)}` : null
playerUrl: status && status.screen_slug ? `${getPublicBaseUrl(req, playerPublicBaseUrl)}/screen/${encodeURIComponent(status.screen_slug)}` : null
});
} catch (error) {
next(error);
@@ -289,6 +338,16 @@ function registerPlayerOnboardingRoutes(app, options) {
app.get('/api/onboarding/screens', requireOnboardingPageAuth, async function (_req, res, next) {
try {
if (thinClientBaseUrl) {
const response = await fetchThinClient(_req, '/api/onboarding/screens', {
method: 'GET'
});
res.status(response.status);
const text = await response.text();
res.type(response.headers.get('content-type') || 'application/json');
return res.send(text);
}
const [rows] = await pool.query('SELECT id, name, slug FROM d_screens ORDER BY name ASC, id ASC');
res.json({ screens: rows });
} catch (error) {
@@ -298,11 +357,11 @@ function registerPlayerOnboardingRoutes(app, options) {
app.get('/api/onboarding/qr', async function (req, res, next) {
try {
const deviceId = normalizeDeviceId(req.query.deviceId);
const deviceId = normalizeDeviceId(req.query.deviceId) || playerDeviceId;
if (!deviceId) {
return res.status(400).json({ error: 'Device ID is required' });
}
const onboardingUrl = `${getPublicBaseUrl(req, options && options.playerPublicBaseUrl)}/onboard?deviceId=${encodeURIComponent(deviceId)}`;
const onboardingUrl = `${getPublicBaseUrl(req, playerPublicBaseUrl)}/onboard?deviceId=${encodeURIComponent(deviceId)}`;
const svg = await createStyledQrCodeSvg({ value: onboardingUrl, qr_margin: 20 });
res.set('Content-Type', 'image/svg+xml; charset=utf-8');
res.set('Cache-Control', 'no-store');
@@ -312,9 +371,9 @@ function registerPlayerOnboardingRoutes(app, options) {
}
});
app.post('/api/onboarding', requireOnboardingPageAuth, async function (req, res, next) {
app.post('/api/onboarding', requireOnboardingPageAuth, express.json(), async function (req, res, next) {
try {
const deviceId = normalizeDeviceId(req.body && req.body.deviceId);
const deviceId = normalizeDeviceId(req.body && req.body.deviceId) || playerDeviceId;
const clientName = String((req.body && req.body.clientName) || '').trim();
const screenSlug = String((req.body && req.body.screenSlug) || '').trim();
const retryAfterSeconds = isOnboardingSignupRateLimited(req, deviceId);
@@ -322,6 +381,30 @@ function registerPlayerOnboardingRoutes(app, options) {
res.set('Retry-After', String(retryAfterSeconds));
return res.status(429).json({ error: 'Too many onboarding attempts. Please try again later.' });
}
if (thinClientBaseUrl) {
const forwardedBody = Object.assign({}, req.body || {}, {
deviceId: deviceId
});
const response = await fetch(new URL('/api/onboarding', thinClientBaseUrl).toString(), {
method: 'POST',
headers: Object.assign({
'content-type': 'application/json'
}, createRequestAuthHeaders({
method: 'POST',
pathname: '/api/onboarding',
body: forwardedBody
}), req.headers['x-pulse-page-auth'] ? { 'x-pulse-page-auth': String(req.headers['x-pulse-page-auth']).trim() } : {}),
body: JSON.stringify(forwardedBody)
});
res.status(response.status);
const payload = await readJsonResponse(response);
if (!payload) {
return res.status(502).json({ error: 'Player bridge returned an invalid response.' });
}
payload.playerUrl = payload && payload.screenSlug ? `${getPublicBaseUrl(req, playerPublicBaseUrl)}/screen/${encodeURIComponent(payload.screenSlug)}` : `${getPublicBaseUrl(req, playerPublicBaseUrl)}/screen/${encodeURIComponent(screenSlug)}`;
return res.json(payload);
}
if (!deviceId) {
return res.status(400).json({ error: 'Device ID is required' });
}
@@ -340,11 +423,14 @@ function registerPlayerOnboardingRoutes(app, options) {
screenId: status && status.screen_id ? status.screen_id : null,
screenSlug: status ? status.screen_slug : screenSlug,
screenName: status && status.screen_name ? status.screen_name : null,
playerUrl: status && status.screen_slug ? `${getPublicBaseUrl(req)}/screen/${encodeURIComponent(status.screen_slug)}` : `${getPublicBaseUrl(req)}/screen/${encodeURIComponent(screenSlug)}`,
playerUrl: status && status.screen_slug ? `${getPublicBaseUrl(req, playerPublicBaseUrl)}/screen/${encodeURIComponent(status.screen_slug)}` : `${getPublicBaseUrl(req, playerPublicBaseUrl)}/screen/${encodeURIComponent(screenSlug)}`,
queued: Boolean(status && status.queued)
});
} catch (error) {
next(error);
const statusCode = Number(error && error.statusCode || error && error.status || 500);
res.status(Number.isFinite(statusCode) && statusCode >= 400 ? statusCode : 500).json({
error: error && error.message ? error.message : 'Onboarding failed.'
});
}
});
}
@@ -39,8 +39,14 @@
return screens;
});
}
if (!deviceId) { setMessage("Missing device id. Scan the QR code from the player screen again."); return; }
try { window.localStorage.setItem(deviceKey, deviceId); } catch (_error) {}
try {
if (!deviceId) {
deviceId = window.localStorage.getItem(deviceKey) || "";
}
if (deviceId) {
window.localStorage.setItem(deviceKey, deviceId);
}
} catch (_error) {}
loadScreens().then(function () {
try {
var storedClientName = window.localStorage.getItem(clientNameKey) || "";
@@ -58,10 +64,14 @@
if (!clientName) { setMessage("Client name is required."); return; }
if (!screenSlug) { setMessage("Screen is required."); return; }
setMessage("Saving client...");
var payload = { clientName: clientName, screenSlug: screenSlug };
if (deviceId) {
payload.deviceId = deviceId;
}
fetch("/api/onboarding", {
method: "POST",
headers: { "Content-Type": "application/json", Accept: "application/json" },
body: JSON.stringify({ deviceId: deviceId, clientName: clientName, screenSlug: screenSlug })
body: JSON.stringify(payload)
})
.then(function (response) {
if (response.ok) {
@@ -126,7 +126,7 @@
if (!storedClientName && storedScreenSlug) { storedClientName = window.localStorage.getItem(getClientNameStorageKey(storedScreenSlug)) || ""; }
if (storedClientName && localForm) {
var clientNameInput = localForm.querySelector("input[name=\"clientName\"]");
if (clientNameInput && !clientNameInput.value) { clientNameInput.value = storedClientName; }
if (clientNameInput) { clientNameInput.value = storedClientName; }
}
if (storedScreenSlug && localScreenSelect) { localScreenSelect.value = storedScreenSlug; }
} catch (_error) {}