635 lines
25 KiB
JavaScript
635 lines
25 KiB
JavaScript
// Player onboarding routes and signup flow helpers.
|
|
|
|
const express = require('express');
|
|
const crypto = require('crypto');
|
|
const { findAvailableClientName, withClientNameReservation } = require('#src/data/client-name-check');
|
|
const { createStyledQrCodeSvg } = require('#src/data/qr-code');
|
|
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');
|
|
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 || '').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 || '').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 selectedClientName = await findAvailableClientName(pool, normalizedClientName, normalizedDeviceId, liveConnections);
|
|
if (!selectedClientName) {
|
|
const error = new Error('Client name already exists.');
|
|
error.statusCode = 400;
|
|
throw error;
|
|
}
|
|
|
|
await pool.query(
|
|
`UPDATE d_onboarding_devices
|
|
SET client_name = ?, screen_id = ?, last_seen_at = CURRENT_TIMESTAMP, modified_at = CURRENT_TIMESTAMP
|
|
WHERE device_id = ?`,
|
|
[selectedClientName, screen.id, normalizedDeviceId]
|
|
);
|
|
await pool.query(
|
|
`INSERT INTO d_onboarding_devices (device_id, client_name, screen_id)
|
|
SELECT ?, ?, ?
|
|
WHERE NOT EXISTS (
|
|
SELECT 1 FROM d_onboarding_devices WHERE device_id = ?
|
|
)`,
|
|
[normalizedDeviceId, selectedClientName, screen.id, normalizedDeviceId]
|
|
);
|
|
|
|
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 || '').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.');
|
|
}
|
|
|
|
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();
|
|
}
|
|
|
|
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');
|
|
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 {
|
|
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);
|
|
}
|
|
});
|
|
|
|
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 || req.query.clientId);
|
|
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/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) {
|
|
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;
|
|
const clientId = normalizeDeviceId(req.query.clientId);
|
|
if (!deviceId) {
|
|
return res.status(400).json({ error: 'Device ID is required' });
|
|
}
|
|
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);
|
|
} catch (error) {
|
|
next(error);
|
|
}
|
|
});
|
|
|
|
app.post('/api/onboarding', express.json(), requireOnboardingAuth, async function (req, res, next) {
|
|
try {
|
|
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 || {}, {
|
|
clientId: clientId || null
|
|
});
|
|
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.' });
|
|
}
|
|
if (response.ok) {
|
|
pairingSessions.delete(deviceId);
|
|
res.cookie('pulse-player-client-id', clientId, { path: '/', sameSite: 'lax' });
|
|
}
|
|
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' });
|
|
}
|
|
|
|
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.cookie('pulse-player-client-id', clientId, { path: '/', sameSite: 'lax' });
|
|
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,
|
|
isValidOnboardingPairingCode: isValidOnboardingPairingCode,
|
|
registerPlayerOnboardingRoutes: registerPlayerOnboardingRoutes
|
|
}; |