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