// Player onboarding routes and signup flow helpers. const { isClientNameAvailable, withClientNameReservation } = require('#src/data/client-name-check'); const { createStyledQrCodeSvg } = require('#src/data/qr-code'); const { getSharedSecret, verifyPageAuthToken } = require('#src/request-auth'); 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, screenSlug) { const normalizedDeviceId = normalizeDeviceId(deviceId); const normalizedPublicBaseUrl = String(publicBaseUrl || '').trim().replace(/\/$/, ''); const normalizedInternalBaseUrl = String(internalBaseUrl || '').trim().replace(/\/$/, ''); if (!normalizedDeviceId) { return null; } await pool.query( `INSERT INTO d_players (device_id, public_base_url, internal_base_url, last_seen_at) VALUES (?, ?, ?, CURRENT_TIMESTAMP) ON DUPLICATE KEY UPDATE public_base_url = VALUES(public_base_url), internal_base_url = VALUES(internal_base_url), last_seen_at = CURRENT_TIMESTAMP, modified_at = CURRENT_TIMESTAMP`, [normalizedDeviceId, normalizedPublicBaseUrl || null, normalizedInternalBaseUrl || null] ); return { device_id: normalizedDeviceId, public_base_url: normalizedPublicBaseUrl || null, internal_base_url: 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; } const screenId = Number(screenRows[0].id); const connection = await pool.getConnection(); try { await connection.beginTransaction(); // Current model: every screen binds to the shared player row '1'. // If we introduce multiple players, resolve the correct player row here instead of hardcoding it. await connection.query("UPDATE d_screens SET player_id = '1', modified_at = CURRENT_TIMESTAMP WHERE id = ?", [screenId]); await connection.commit(); } catch (error) { await connection.rollback(); throw error; } finally { connection.release(); } return { screen_id: screenId, screen_slug: normalizedScreenSlug, player_id: '1' }; } 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; if (!app || !pool || !common || !playerRuntime) { throw new Error('registerPlayerOnboardingRoutes requires app, pool, common, and playerRuntime.'); } const sharedSecret = getSharedSecret(); 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 || '').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 status = await getOnboardingStatus(pool, req.query.deviceId); res.json({ deviceId: normalizeDeviceId(req.query.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)}/screen/${encodeURIComponent(status.screen_slug)}` : null }); } catch (error) { next(error); } }); app.get('/api/onboarding/screens', requireOnboardingPageAuth, async function (_req, res, next) { try { 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); if (!deviceId) { return res.status(400).json({ error: 'Device ID is required' }); } const onboardingUrl = `${getPublicBaseUrl(req, options && options.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, async function (req, res, next) { try { const deviceId = normalizeDeviceId(req.body && req.body.deviceId); 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 (!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)}/screen/${encodeURIComponent(status.screen_slug)}` : `${getPublicBaseUrl(req)}/screen/${encodeURIComponent(screenSlug)}`, queued: Boolean(status && status.queued) }); } catch (error) { next(error); } }); } module.exports = { normalizeDeviceId: normalizeDeviceId, getPublicBaseUrl: getPublicBaseUrl, getPlayerPublicBaseUrl: getPlayerPublicBaseUrl, getPlayerInternalBaseUrl: getPlayerInternalBaseUrl, getOnboardingStatus: getOnboardingStatus, commitDeviceBinding: commitDeviceBinding, upsertPlayerRegistration: upsertPlayerRegistration, bindPlayerToScreen: bindPlayerToScreen, bindDeviceToScreen: bindDeviceToScreen, registerPlayerOnboardingRoutes: registerPlayerOnboardingRoutes };