// 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 };