Files
pulse-signage/src/player/onboarding/index.js
T

450 lines
18 KiB
JavaScript

// 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, 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 onboardingSignupAttempts = new Map();
function normalizeDeviceId(value) {
return String(value || '').trim().replace(/[^a-zA-Z0-9_-]/g, '').slice(0, 128);
}
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');
const forwardedHost = String(req.headers['x-forwarded-host'] || '').trim().split(',')[0];
const host = forwardedHost || String(req.headers.host || '').trim();
if (host) {
return `${protocol}://${host}`.replace(/\/$/, '');
}
const configured = String(configuredUrl || process.env.PLAYER_PUBLIC_URL || process.env.PLAYER_BASE_URL || '').trim().replace(/\/$/, '');
return configured || null;
}
function getRequestIp(req) {
const forwardedFor = String(req && req.headers && req.headers['x-forwarded-for'] || '').trim().split(',')[0];
if (forwardedFor) {
return forwardedFor;
}
const remoteAddress = req && req.socket && req.socket.remoteAddress ? String(req.socket.remoteAddress).trim() : '';
if (!remoteAddress) {
return 'unknown';
}
return remoteAddress.toLowerCase().startsWith('::ffff:') ? remoteAddress.slice(7) : remoteAddress;
}
function getOnboardingLimitKey(req, deviceId) {
return [getRequestIp(req), normalizeDeviceId(deviceId) || 'anonymous'].join('|');
}
function getPlayerPublicBaseUrl(req, configuredUrl) {
return getPublicBaseUrl(req, configuredUrl);
}
function getPlayerInternalBaseUrl(configuredUrl) {
const configured = String(configuredUrl || process.env.PLAYER_INTERNAL_URL || process.env.PLAYER_BASE_URL || '').trim().replace(/\/$/, '');
return configured || null;
}
function clearOnboardingSignupAttempts() {
if (onboardingSignupAttempts.size > 1000) {
onboardingSignupAttempts.clear();
}
}
function isOnboardingSignupRateLimited(req, deviceId) {
const now = Date.now();
const key = getOnboardingLimitKey(req, deviceId);
const attempts = onboardingSignupAttempts.get(key) || [];
const windowStart = now - ONBOARDING_SIGNUP_LIMIT_WINDOW_MS;
const recentAttempts = attempts.filter(function (timestamp) {
return timestamp >= windowStart;
});
if (recentAttempts.length >= ONBOARDING_SIGNUP_LIMIT_MAX_ATTEMPTS) {
onboardingSignupAttempts.set(key, recentAttempts);
return Math.max(1, Math.ceil((recentAttempts[0] + ONBOARDING_SIGNUP_LIMIT_WINDOW_MS - now) / 1000));
}
recentAttempts.push(now);
onboardingSignupAttempts.set(key, recentAttempts);
clearOnboardingSignupAttempts();
return 0;
}
async function getOnboardingStatus(pool, deviceId) {
const normalizedDeviceId = normalizeDeviceId(deviceId);
if (!normalizedDeviceId) {
return null;
}
const [rows] = await pool.query(
`SELECT d.device_id, d.client_name, d.screen_id, s.name AS screen_name, s.slug AS screen_slug, s.playlist_id
FROM d_onboarding_devices d
LEFT JOIN d_screens s ON s.id = d.screen_id
WHERE d.device_id = ?`,
[normalizedDeviceId]
);
return rows[0] || null;
}
async function commitDeviceBinding(pool, deviceId, clientName, screenSlug, isNameAvailableOnScreen, liveConnections) {
const normalizedDeviceId = normalizeDeviceId(deviceId);
const normalizedClientName = String(clientName || '').trim();
const normalizedScreenSlug = String(screenSlug || '').trim();
if (!normalizedDeviceId) {
throw new Error('Device ID is required.');
}
if (!normalizedClientName) {
throw new Error('Client name is required.');
}
if (!normalizedScreenSlug) {
throw new Error('Screen is required.');
}
return withClientNameReservation(pool, normalizedClientName, async function () {
const [screenRows] = await pool.query('SELECT id, name, slug FROM d_screens WHERE slug = ?', [normalizedScreenSlug]);
if (!screenRows.length) {
throw new Error('Screen not found.');
}
const screen = screenRows[0];
const available = await isClientNameAvailable(pool, normalizedClientName, normalizedDeviceId, liveConnections);
if (!available) {
const error = new Error('Client name already exists.');
error.statusCode = 400;
throw error;
}
await pool.query(
'INSERT INTO d_onboarding_devices (device_id, client_name, screen_id) VALUES (?, ?, ?) ON DUPLICATE KEY UPDATE client_name = VALUES(client_name), screen_id = VALUES(screen_id), modified_at = CURRENT_TIMESTAMP',
[normalizedDeviceId, normalizedClientName, screen.id]
);
return getOnboardingStatus(pool, normalizedDeviceId);
});
}
async function upsertPlayerRegistration(pool, deviceId, publicBaseUrl, internalBaseUrl) {
const normalizedDeviceId = normalizeDeviceId(deviceId);
const normalizedPublicBaseUrl = String(publicBaseUrl || '').trim().replace(/\/$/, '');
const normalizedInternalBaseUrl = String(internalBaseUrl || '').trim().replace(/\/$/, '');
if (!normalizedDeviceId) {
return null;
}
return upsertPlayerRegistrationRecord(pool, {
identifier: normalizedDeviceId,
publicBaseUrl: normalizedPublicBaseUrl || null,
internalBaseUrl: normalizedInternalBaseUrl || null
});
}
async function bindPlayerToScreen(pool, deviceId, screenSlug) {
const normalizedDeviceId = normalizeDeviceId(deviceId);
const normalizedScreenSlug = String(screenSlug || '').trim();
if (!normalizedDeviceId || !normalizedScreenSlug) {
return null;
}
const [screenRows] = await pool.query('SELECT id FROM d_screens WHERE slug = ? LIMIT 1', [normalizedScreenSlug]);
if (!screenRows.length) {
return null;
}
return {
screen_id: Number(screenRows[0].id),
screen_slug: normalizedScreenSlug
};
}
async function bindDeviceToScreen(pool, deviceId, clientName, screenSlug, isNameAvailableOnScreen, playerRuntime, onboardingStore) {
const liveConnections = playerRuntime && typeof playerRuntime.snapshotAllConnections === 'function'
? playerRuntime.snapshotAllConnections()
: [];
try {
return await commitDeviceBinding(pool, deviceId, clientName, screenSlug, isNameAvailableOnScreen, liveConnections);
} catch (error) {
if (!isTransientDbError(error)) {
throw error;
}
if (onboardingStore && typeof onboardingStore.enqueueBinding === 'function') {
await onboardingStore.enqueueBinding({
deviceId: deviceId,
clientName: clientName,
screenSlug: screenSlug,
queuedAt: new Date().toISOString()
});
}
return {
device_id: normalizeDeviceId(deviceId),
client_name: String(clientName || '').trim(),
screen_slug: String(screenSlug || '').trim(),
queued: true
};
}
}
function registerPlayerOnboardingRoutes(app, options) {
const pool = options && options.pool ? options.pool : null;
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 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);
if (!app || !common) {
throw new Error('registerPlayerOnboardingRoutes requires app and common.');
}
if (!bridgeBaseUrl && (!pool || !playerRuntime)) {
throw new Error('registerPlayerOnboardingRoutes requires pool and playerRuntime unless bridgeBaseUrl is configured.');
}
const sharedSecret = getSharedSecret();
async function fetchThinClient(req, pathname, options) {
if (!bridgeBaseUrl) {
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, bridgeBaseUrl).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();
}
const token = String(req.headers['x-pulse-page-auth'] || '').trim();
const payload = verifyPageAuthToken(token);
if (!payload || String(payload.scope || '').trim() !== 'onboarding') {
return res.status(401).json({ error: 'Onboarding page authentication required.' });
}
req.playerPageAuth = payload;
next();
}
app.get('/', function (_req, res) {
res.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
res.send(common.renderPlayerOnboardingLandingPage());
});
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()));
} catch (error) {
next(error);
}
});
app.get('/api/onboarding/status', function (req, res, next) {
if (sharedSecret) {
const token = String(req.headers['x-pulse-page-auth'] || '').trim();
const payload = verifyPageAuthToken(token);
if (!payload || ['onboarding', 'player'].indexOf(String(payload.scope || '').trim()) === -1) {
return res.status(401).json({ error: 'Onboarding page authentication required.' });
}
req.playerPageAuth = payload;
}
next();
}, async function (req, res, next) {
try {
const deviceId = normalizeDeviceId(req.query.deviceId) || playerDeviceId;
if (bridgeBaseUrl) {
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, playerPublicUrl)}/screen/${encodeURIComponent(payload.screenSlug)}` : null;
return res.json(payload);
}
const status = await getOnboardingStatus(pool, deviceId);
res.json({
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, playerPublicUrl)}/screen/${encodeURIComponent(status.screen_slug)}` : null
});
} catch (error) {
next(error);
}
});
app.get('/api/onboarding/screens', requireOnboardingPageAuth, async function (_req, res, next) {
try {
if (bridgeBaseUrl) {
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) {
next(error);
}
});
app.get('/api/onboarding/qr', async function (req, res, next) {
try {
const deviceId = normalizeDeviceId(req.query.deviceId) || playerDeviceId;
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 });
res.set('Content-Type', 'image/svg+xml; charset=utf-8');
res.set('Cache-Control', 'no-store');
res.send(svg);
} catch (error) {
next(error);
}
});
app.post('/api/onboarding', requireOnboardingPageAuth, express.json(), async function (req, res, next) {
try {
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);
if (retryAfterSeconds) {
res.set('Retry-After', String(retryAfterSeconds));
return res.status(429).json({ error: 'Too many onboarding attempts. Please try again later.' });
}
if (bridgeBaseUrl) {
const forwardedBody = Object.assign({}, req.body || {}, {
deviceId: deviceId
});
const response = await fetch(new URL('/api/onboarding', bridgeBaseUrl).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, playerPublicUrl)}/screen/${encodeURIComponent(payload.screenSlug)}` : `${getPublicBaseUrl(req, playerPublicUrl)}/screen/${encodeURIComponent(screenSlug)}`;
return res.json(payload);
}
if (!deviceId) {
return res.status(400).json({ error: 'Device ID is required' });
}
if (!clientName) {
return res.status(400).json({ error: 'Client name is required' });
}
if (!screenSlug) {
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);
res.json({
deviceId: deviceId,
clientName: status ? status.client_name : clientName,
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, playerPublicUrl)}/screen/${encodeURIComponent(status.screen_slug)}` : `${getPublicBaseUrl(req, playerPublicUrl)}/screen/${encodeURIComponent(screenSlug)}`,
queued: Boolean(status && status.queued)
});
} catch (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.'
});
}
});
}
module.exports = {
normalizeDeviceId: normalizeDeviceId,
getPublicBaseUrl: getPublicBaseUrl,
getPlayerPublicBaseUrl: getPlayerPublicBaseUrl,
getPlayerInternalBaseUrl: getPlayerInternalBaseUrl,
getOnboardingStatus: getOnboardingStatus,
commitDeviceBinding: commitDeviceBinding,
upsertPlayerRegistration: upsertPlayerRegistration,
bindPlayerToScreen: bindPlayerToScreen,
bindDeviceToScreen: bindDeviceToScreen,
registerPlayerOnboardingRoutes: registerPlayerOnboardingRoutes
};