Add onboarding weather and template gradients
Publish Docker Image / build-and-push-existing-registry (./build/Dockerfile, web, pulse-signage-web) (push) Successful in 1m53s
Publish Docker Image / build-and-push-existing-registry (./build/Dockerfile.player, player, pulse-signage-player) (push) Successful in 34s

This commit is contained in:
2026-08-28 20:05:23 +01:00
parent aa07a78912
commit 3960931ebe
80 changed files with 12750 additions and 519 deletions
+186 -13
View File
@@ -1,20 +1,44 @@
// Player onboarding routes and signup flow helpers.
const express = require('express');
const crypto = require('crypto');
const { isClientNameAvailable, withClientNameReservation } = require('#src/data/client-name-check');
const { createStyledQrCodeSvg } = require('#src/data/qr-code');
const { getSharedSecret, verifyPageAuthToken, createRequestAuthHeaders } = require('#src/request-auth');
const { getSharedSecret, verifyPageAuthToken, verifyRequestAuth, 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;
const ONBOARDING_SIGNUP_LIMIT_MAX_ATTEMPTS = 8;
const PAIRING_CODE_LENGTH = 6;
const PAIRING_CODE_TTL_MS = 15 * 60 * 1000;
const PAIRING_CODE_ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
const onboardingSignupAttempts = new Map();
function normalizeDeviceId(value) {
return String(value || '').trim().replace(/[^a-zA-Z0-9_-]/g, '').slice(0, 128);
}
function createPairingCode() {
const bytes = crypto.randomBytes(PAIRING_CODE_LENGTH);
let code = '';
for (let index = 0; index < PAIRING_CODE_LENGTH; index += 1) {
code += PAIRING_CODE_ALPHABET[bytes[index] % PAIRING_CODE_ALPHABET.length];
}
return code;
}
function createPairingSession(deviceId, clientId) {
return { deviceId: normalizeDeviceId(deviceId), clientId: normalizeDeviceId(clientId), code: createPairingCode(), expiresAt: Date.now() + PAIRING_CODE_TTL_MS };
}
function isValidOnboardingPairingCode(pairingSession, pairingCode, now) {
const suppliedCode = Buffer.from(String(pairingCode || '').trim().toUpperCase());
const expectedCode = Buffer.from(String(pairingSession && pairingSession.code || '').trim());
const currentTime = Number(now || Date.now());
return Boolean(pairingSession && pairingSession.deviceId && pairingSession.expiresAt > currentTime && suppliedCode.length === expectedCode.length && suppliedCode.length > 0 && crypto.timingSafeEqual(suppliedCode, expectedCode));
}
function getPublicBaseUrl(req, configuredUrl) {
const forwardedProto = String(req.headers['x-forwarded-proto'] || '').trim().split(',')[0];
const protocol = forwardedProto || (req.socket && req.socket.encrypted ? 'https' : 'http');
@@ -120,7 +144,7 @@ async function commitDeviceBinding(pool, deviceId, clientName, screenSlug, isNam
}
const screen = screenRows[0];
const available = await isClientNameAvailable(pool, normalizedClientName, normalizedDeviceId, liveConnections);
const available = await isClientNameAvailable(pool, normalizedClientName, null, liveConnections);
if (!available) {
const error = new Error('Client name already exists.');
error.statusCode = 400;
@@ -217,6 +241,38 @@ function registerPlayerOnboardingRoutes(app, options) {
const playerPublicUrl = String(options && options.playerPublicBaseUrl || process.env.PLAYER_PUBLIC_URL || process.env.PLAYER_BASE_URL || '').trim().replace(/\/$/, '');
const bridgeBaseUrl = String(options && options.bridgeBaseUrl || process.env.BRIDGE_PUBLIC_URL || '').trim().replace(/\/$/, '');
const playerDeviceId = normalizeDeviceId(options && options.playerDeviceId);
const onPairingCode = options && typeof options.onPairingCode === 'function' ? options.onPairingCode : null;
const pairingSessions = new Map();
function getPairingSession(deviceId, clientId) {
const normalizedDeviceId = normalizeDeviceId(deviceId) || playerDeviceId;
if (!normalizedDeviceId) {
return null;
}
const sessionKey = `${normalizedDeviceId}:${normalizeDeviceId(clientId) || 'default'}`;
let pairingSession = pairingSessions.get(sessionKey);
if (!isValidOnboardingPairingCode(pairingSession, pairingSession && pairingSession.code)) {
pairingSession = createPairingSession(normalizedDeviceId, clientId);
pairingSessions.set(sessionKey, pairingSession);
}
if (onPairingCode) {
onPairingCode(pairingSession.code, Array.from(pairingSessions.entries()).filter(function (entry) {
return isValidOnboardingPairingCode(entry[1], entry[1] && entry[1].code);
}).map(function (entry) {
return { deviceId: entry[1].deviceId, clientId: entry[1].clientId || null, code: entry[1].code };
}));
}
return pairingSession;
}
function findPairingSession(pairingCode) {
for (const [deviceId, pairingSession] of pairingSessions.entries()) {
if (isValidOnboardingPairingCode(pairingSession, pairingCode)) {
return { deviceId: pairingSession.deviceId, session: pairingSession };
}
}
return null;
}
if (!app || !common) {
throw new Error('registerPlayerOnboardingRoutes requires app and common.');
@@ -289,15 +345,71 @@ function registerPlayerOnboardingRoutes(app, options) {
next();
}
app.get('/', function (_req, res) {
function requireOnboardingAuth(req, res, next) {
if (!sharedSecret) {
return next();
}
const pageToken = String(req.headers['x-pulse-page-auth'] || '').trim();
const pagePayload = verifyPageAuthToken(pageToken);
if (pagePayload && String(pagePayload.scope || '').trim() === 'onboarding') {
req.playerPageAuth = pagePayload;
return next();
}
if (verifyRequestAuth(req)) {
return next();
}
return res.status(401).json({ error: 'Onboarding authentication required.' });
}
app.get('/', async function (req, res, next) {
res.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
res.send(common.renderPlayerOnboardingLandingPage());
try {
const deviceId = normalizeDeviceId(req.query && req.query.clientId);
let status = null;
if (deviceId) {
if (bridgeBaseUrl) {
const response = await fetchThinClient(req, '/api/onboarding/status?deviceId=' + encodeURIComponent(deviceId), {
method: 'GET'
});
status = await readJsonResponse(response);
} else {
status = await getOnboardingStatus(pool, deviceId);
}
}
const screenId = status && (status.screen_id || status.screenId);
const screenSlug = status && (status.screen_slug || status.screenSlug);
if (screenId && screenSlug) {
return res.redirect('/screen/' + encodeURIComponent(screenSlug));
}
res.send(common.renderPlayerOnboardingLandingPage({ pairingCode: '' }));
} catch (error) {
next(error);
}
});
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 || playerDeviceId || '').trim()));
const deviceId = playerDeviceId;
const onboardingBaseUrl = String(process.env.WEB_PUBLIC_URL || '').trim().replace(/\/$/, '') || getPublicBaseUrl(req, playerPublicUrl);
const clientId = normalizeDeviceId(req.query.clientId);
const pairingSession = getPairingSession(deviceId, clientId);
const pairingParams = [];
if (pairingSession && pairingSession.code) {
pairingParams.push(`code=${encodeURIComponent(pairingSession.code)}`);
}
const pairingQuery = pairingParams.length ? `?${pairingParams.join('&')}` : '';
if (bridgeBaseUrl && pairingSession && pairingSession.code) {
const response = await fetchThinClient(req, `/api/onboarding/url?pairingCode=${encodeURIComponent(pairingSession.code)}`);
const payload = await readJsonResponse(response);
if (payload && payload.url) {
return res.redirect(String(payload.url));
}
}
return res.redirect(`${onboardingBaseUrl}/pairing${pairingQuery}`);
} catch (error) {
next(error);
}
@@ -315,7 +427,7 @@ function registerPlayerOnboardingRoutes(app, options) {
next();
}, async function (req, res, next) {
try {
const deviceId = normalizeDeviceId(req.query.deviceId) || playerDeviceId;
const deviceId = normalizeDeviceId(req.query.deviceId || req.query.clientId);
if (bridgeBaseUrl) {
const response = await fetchThinClient(req, '/api/onboarding/status?deviceId=' + encodeURIComponent(deviceId || ''), {
method: 'GET'
@@ -347,6 +459,26 @@ function registerPlayerOnboardingRoutes(app, options) {
}
});
app.get('/api/onboarding/resolve', requireOnboardingAuth, function (req, res) {
const pairingCode = String(req.query.pairingCode || '').trim();
const pairing = findPairingSession(pairingCode);
if (!pairing) {
return res.status(401).json({ error: 'A valid kiosk pairing code is required.' });
}
res.json({ deviceId: pairing.deviceId, clientId: pairing.session.clientId || null });
});
app.get('/api/onboarding/session', requireOnboardingPageAuth, function (req, res) {
const deviceId = normalizeDeviceId(req.query.deviceId) || playerDeviceId;
const clientId = normalizeDeviceId(req.query.clientId);
const pairingSession = getPairingSession(deviceId, clientId);
if (!pairingSession) {
return res.status(503).json({ error: 'Player identity is unavailable.' });
}
res.set('Cache-Control', 'no-store');
res.json({ deviceId: deviceId, pairingCode: pairingSession.code });
});
app.get('/api/onboarding/screens', requireOnboardingPageAuth, async function (_req, res, next) {
try {
if (bridgeBaseUrl) {
@@ -366,14 +498,40 @@ function registerPlayerOnboardingRoutes(app, options) {
}
});
app.get('/api/onboarding/qr', async function (req, res, next) {
try {
const deviceId = normalizeDeviceId(req.query.deviceId) || playerDeviceId;
const clientId = normalizeDeviceId(req.query.clientId);
if (!deviceId) {
return res.status(400).json({ error: 'Device ID is required' });
}
const onboardingUrl = `${getPublicBaseUrl(req, playerPublicUrl)}/onboard?deviceId=${encodeURIComponent(deviceId)}`;
const svg = await createStyledQrCodeSvg({ value: onboardingUrl, qr_margin: 20 });
const pairingSession = getPairingSession(deviceId, clientId);
if (!pairingSession) {
return res.status(503).json({ error: 'Pairing session is unavailable.' });
}
if (bridgeBaseUrl) {
const response = await fetchThinClient(req, `/api/onboarding/qr?pairingCode=${encodeURIComponent(pairingSession.code)}`);
if (response && response.ok) {
const svg = await response.text();
res.set('Content-Type', response.headers.get('content-type') || 'image/svg+xml; charset=utf-8');
res.set('Cache-Control', 'no-store');
return res.send(svg);
}
}
const onboardingBaseUrl = String(process.env.WEB_PUBLIC_URL || '').trim().replace(/\/$/, '') || getPublicBaseUrl(req, playerPublicUrl);
const onboardingUrl = `${onboardingBaseUrl}/pairing?code=${encodeURIComponent(pairingSession.code)}`;
const svg = await createStyledQrCodeSvg({
value: onboardingUrl,
qr_margin: 20,
qr_dots_type: 'dots',
qr_dots_color: '#f4f8f5',
qr_corners_square_type: 'dot',
qr_corners_square_color: '#f4f8f5',
qr_corners_dot_type: 'dot',
qr_corners_dot_color: '#f0bd70',
qr_background_transparent: true
});
res.set('Content-Type', 'image/svg+xml; charset=utf-8');
res.set('Cache-Control', 'no-store');
res.send(svg);
@@ -382,20 +540,28 @@ function registerPlayerOnboardingRoutes(app, options) {
}
});
app.post('/api/onboarding', requireOnboardingPageAuth, express.json(), async function (req, res, next) {
app.post('/api/onboarding', express.json(), requireOnboardingAuth, async function (req, res, next) {
try {
const deviceId = normalizeDeviceId(req.body && req.body.deviceId) || playerDeviceId;
const deviceId = playerDeviceId;
const clientName = String((req.body && req.body.clientName) || '').trim();
const screenSlug = String((req.body && req.body.screenSlug) || '').trim();
const pairingCode = String((req.body && req.body.pairingCode) || '').trim();
const clientId = normalizeDeviceId(req.body && req.body.clientId);
const retryAfterSeconds = isOnboardingSignupRateLimited(req, deviceId);
if (retryAfterSeconds) {
res.set('Retry-After', String(retryAfterSeconds));
return res.status(429).json({ error: 'Too many onboarding attempts. Please try again later.' });
}
const pairing = findPairingSession(pairingCode);
const pairingSession = pairing && pairing.session;
if (!isValidOnboardingPairingCode(pairingSession, pairingCode)) {
return res.status(401).json({ error: 'A valid kiosk pairing code is required.' });
}
if (bridgeBaseUrl) {
const forwardedBody = Object.assign({}, req.body || {}, {
deviceId: deviceId
clientId: clientId || null
});
const response = await fetch(new URL('/api/onboarding', bridgeBaseUrl).toString(), {
method: 'POST',
@@ -413,6 +579,9 @@ function registerPlayerOnboardingRoutes(app, options) {
if (!payload) {
return res.status(502).json({ error: 'Player bridge returned an invalid response.' });
}
if (response.ok) {
pairingSessions.delete(deviceId);
}
payload.playerUrl = payload && payload.screenSlug ? `${getPublicBaseUrl(req, playerPublicUrl)}/screen/${encodeURIComponent(payload.screenSlug)}` : `${getPublicBaseUrl(req, playerPublicUrl)}/screen/${encodeURIComponent(screenSlug)}`;
return res.json(payload);
}
@@ -426,8 +595,11 @@ function registerPlayerOnboardingRoutes(app, options) {
return res.status(400).json({ error: 'Screen is required' });
}
const status = await bindDeviceToScreen(pool, deviceId, clientName, screenSlug, playerRuntime.isClientNameAvailableOnScreen, playerRuntime, onboardingStore);
await bindPlayerToScreen(pool, deviceId, screenSlug);
if (!clientId) {
return res.status(400).json({ error: 'Client ID is required' });
}
const status = await bindDeviceToScreen(pool, clientId, clientName, screenSlug, playerRuntime.isClientNameAvailableOnScreen, playerRuntime, onboardingStore);
pairingSessions.delete(deviceId);
res.json({
deviceId: deviceId,
clientName: status ? status.client_name : clientName,
@@ -456,5 +628,6 @@ module.exports = {
upsertPlayerRegistration: upsertPlayerRegistration,
bindPlayerToScreen: bindPlayerToScreen,
bindDeviceToScreen: bindDeviceToScreen,
isValidOnboardingPairingCode: isValidOnboardingPairingCode,
registerPlayerOnboardingRoutes: registerPlayerOnboardingRoutes
};
@@ -65,10 +65,11 @@
var formData = new FormData(form);
var clientName = String(formData.get("clientName") || "").trim();
var screenSlug = String(formData.get("screenSlug") || "").trim();
var pairingCode = String(formData.get("pairingCode") || "").trim();
if (!clientName) { setMessage("Client name is required."); return; }
if (!screenSlug) { setMessage("Screen is required."); return; }
setMessage("Saving client...");
var payload = { clientName: clientName, screenSlug: screenSlug };
var payload = { clientName: clientName, screenSlug: screenSlug, pairingCode: pairingCode };
if (deviceId) {
payload.deviceId = deviceId;
}
@@ -13,23 +13,12 @@
}
var screenKey = "pulse-signage-player-screen-slug";
var qr = document.getElementById("onboarding-qr");
var status = document.getElementById("onboarding-status");
var localForm = document.getElementById("onboarding-local-form");
var localMessage = document.getElementById("onboarding-message");
var localScreenSelect = document.getElementById("onboarding-screen-select");
var pairingCodeElement = document.getElementById("onboarding-pairing-code");
var qrPlaceholderSrc = "data:image/svg+xml;charset=utf-8,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 320 320%22%3E%3Crect width=%22320%22 height=%22320%22 rx=%2224%22 fill=%22%23ffffff%22/%3E%3Crect x=%2230%22 y=%2230%22 width=%22260%22 height=%22260%22 rx=%2218%22 fill=%22%23f8fafc%22 stroke=%22%23cbd5e1%22 stroke-width=%223%22 stroke-dasharray=%2212 10%22/%3E%3Cpath d=%22M106 118h108M106 156h108M106 194h72%22 stroke=%22%2394a3b8%22 stroke-width=%2214%22 stroke-linecap=%22round%22/%3E%3Ccircle cx=%22128%22 cy=%22248%22 r=%2212%22 fill=%22%2394a3b8%22/%3E%3Ctext x=%22160%22 y=%2278%22 text-anchor=%22middle%22 fill=%22%230f172a%22 font-family=%22Arial,sans-serif%22 font-size=%2224%22 font-weight=%22700%22%3EQR code loading%3C/text%3E%3Ctext x=%22160%22 y=%22266%22 text-anchor=%22middle%22 fill=%22%234b5563%22 font-family=%22Arial,sans-serif%22 font-size=%2214%22%3EPlease wait%3C/text%3E%3C/svg%3E";
function parseResponseError(response) {
return response.text().then(function (text) {
var fallbackMessage = text && text.trim() ? text.trim() : "Unable to save onboarding.";
try {
var payload = JSON.parse(text);
return payload && payload.error ? payload.error : fallbackMessage;
} catch (_error) {
return fallbackMessage;
}
});
}
function getDeviceId() {
var configuredDeviceId = document.getElementById("onboarding-shell");
configuredDeviceId = configuredDeviceId ? String(configuredDeviceId.getAttribute("data-player-device-id") || "").trim() : "";
if (configuredDeviceId) { return configuredDeviceId; }
var stored = "";
try { stored = window.sessionStorage.getItem(deviceKey) || ""; } catch (_error) { stored = ""; }
if (stored) { return stored; }
@@ -37,115 +26,57 @@
try { window.sessionStorage.setItem(deviceKey, next); } catch (_error2) {}
return next;
}
function setStatus(message) { if (status) { status.textContent = message; } }
function setLocalMessage(message) { if (localMessage) { localMessage.textContent = message || ""; } }
function setSelectOptions(select, screens, selectedSlug) {
if (!select) { return; }
while (select.firstChild) { select.removeChild(select.firstChild); }
var placeholder = document.createElement("option");
placeholder.value = "";
placeholder.textContent = "Select a screen";
select.appendChild(placeholder);
(Array.isArray(screens) ? screens : []).forEach(function (screen) {
var option = document.createElement("option");
option.value = String(screen && screen.slug ? screen.slug : "");
option.textContent = String(screen && (screen.name || screen.slug) ? (screen.name || screen.slug) : "Screen");
if (selectedSlug && String(option.value) === String(selectedSlug)) {
option.selected = true;
}
select.appendChild(option);
});
}
function loadScreens(selectedSlug) {
return fetch("/api/onboarding/screens", { cache: "no-store" })
.then(function (response) { return response.ok ? response.json() : null; })
.then(function (payload) {
var screens = payload && Array.isArray(payload.screens) ? payload.screens : [];
setSelectOptions(localScreenSelect, screens, selectedSlug);
return screens;
})
.catch(function () { setSelectOptions(localScreenSelect, [], selectedSlug); return []; });
}
function loadQr(deviceId) {
function loadQr(deviceId, clientId) {
if (!qr) { return; }
qr.onerror = function () {
qr.src = qrPlaceholderSrc;
};
qr.src = "/api/onboarding/qr?deviceId=" + encodeURIComponent(deviceId);
qr.src = "/api/onboarding/qr?deviceId=" + encodeURIComponent(deviceId) + "&clientId=" + encodeURIComponent(clientId);
}
function submitOnboarding(deviceId, clientName, screenSlug) {
return fetch("/api/onboarding", {
method: "POST",
headers: { "Content-Type": "application/json", Accept: "application/json" },
body: JSON.stringify({ deviceId: deviceId, clientName: clientName, screenSlug: screenSlug })
})
.then(function (response) {
if (response.ok) {
return response.json();
}
return parseResponseError(response).then(function (messageText) {
throw new Error(messageText);
});
})
function loadPairingSession(deviceId, clientId) {
return fetch("/api/onboarding/session?deviceId=" + encodeURIComponent(deviceId) + "&clientId=" + encodeURIComponent(clientId), { cache: "no-store" })
.then(function (response) { return response.ok ? response.json() : null; })
.then(function (payload) {
if (!payload || !payload.screenSlug) { throw new Error("Unable to save onboarding."); }
if (payload.clientName) { setSessionStorageItem(clientNameKey, payload.clientName); }
if (payload.clientName && payload.screenSlug) { setSessionStorageItem(getClientNameStorageKey(payload.screenSlug), payload.clientName); }
try { window.localStorage.setItem(screenKey, payload.screenSlug); } catch (_error) {}
setLocalMessage("Onboarding complete.");
if (localForm) {
Array.prototype.slice.call(localForm.querySelectorAll("input, select, button")).forEach(function (control) {
control.disabled = true;
});
if (payload && payload.pairingCode && pairingCodeElement) {
pairingCodeElement.textContent = payload.pairingCode;
}
});
return payload;
})
.catch(function () { return null; });
}
function getClientId() {
var stored = getSessionStorageItem("pulse-signage-player-client-id");
if (stored) { return stored; }
var next = (window.crypto && window.crypto.randomUUID ? window.crypto.randomUUID() : "client-" + Date.now() + "-" + Math.random().toString(16).slice(2));
setSessionStorageItem("pulse-signage-player-client-id", next);
return next;
}
function redirectIfOnboarded(deviceId) {
return fetch("/api/onboarding/status?deviceId=" + encodeURIComponent(deviceId), { cache: "no-store" })
var bindingId = getClientId() || deviceId;
return fetch("/api/onboarding/status?deviceId=" + encodeURIComponent(bindingId), { cache: "no-store" })
.then(function (response) { return response.ok ? response.json() : null; })
.then(function (payload) {
if (payload && payload.onboarded && payload.screenSlug) {
if (payload.clientName) { setSessionStorageItem(clientNameKey, payload.clientName); }
if (payload.clientName && payload.screenSlug) { setSessionStorageItem(getClientNameStorageKey(payload.screenSlug), payload.clientName); }
try { window.localStorage.setItem(screenKey, payload.screenSlug); } catch (_error) {}
window.location.replace("/screen/" + encodeURIComponent(payload.screenSlug));
window.location.replace("/screen/" + encodeURIComponent(payload.screenSlug) + "?clientId=" + encodeURIComponent(clientId));
return true;
}
return false;
})
.catch(function () { return false; });
}
var clientId = getClientId();
var deviceId = getDeviceId();
if (localForm) {
localForm.addEventListener("submit", function (event) {
event.preventDefault();
var formData = new FormData(localForm);
var clientName = String(formData.get("clientName") || "").trim();
var screenSlug = String(formData.get("screenSlug") || "").trim();
if (!clientName) { setLocalMessage("Client name is required."); return; }
if (!screenSlug) { setLocalMessage("Screen is required."); return; }
setLocalMessage("Saving client...");
submitOnboarding(deviceId, clientName, screenSlug).catch(function (error) {
setLocalMessage(error && error.message ? error.message : "Unable to save onboarding.");
});
});
}
loadScreens().then(function () {
try {
var storedClientName = getSessionStorageItem(clientNameKey) || "";
if (storedClientName && localForm) {
var clientNameInput = localForm.querySelector("input[name=\"clientName\"]");
if (clientNameInput) { clientNameInput.value = storedClientName; }
}
} catch (_error) {}
});
redirectIfOnboarded(deviceId).then(function (redirected) {
if (redirected) { return; }
if (qr && !qr.getAttribute("src")) {
qr.src = qrPlaceholderSrc;
}
loadQr(deviceId);
setStatus("Waiting for onboarding to finish.");
loadPairingSession(deviceId, clientId).then(function () {
loadQr(deviceId, clientId);
});
window.setInterval(function () { redirectIfOnboarded(deviceId); }, 2000);
});
}());