449 lines
18 KiB
JavaScript
449 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 configured = String(configuredUrl || process.env.PLAYER_PUBLIC_BASE_URL || process.env.PLAYER_BASE_URL || '').trim().replace(/\/$/, '');
|
|
if (configured) {
|
|
return configured;
|
|
}
|
|
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();
|
|
return `${protocol}://${host}`.replace(/\/$/, '');
|
|
}
|
|
|
|
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_BASE_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 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 || !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();
|
|
}
|
|
|
|
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 (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(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, playerPublicBaseUrl)}/screen/${encodeURIComponent(status.screen_slug)}` : null
|
|
});
|
|
} catch (error) {
|
|
next(error);
|
|
}
|
|
});
|
|
|
|
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) {
|
|
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, 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');
|
|
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 (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' });
|
|
}
|
|
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, playerPublicBaseUrl)}/screen/${encodeURIComponent(status.screen_slug)}` : `${getPublicBaseUrl(req, playerPublicBaseUrl)}/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
|
|
}; |