diff --git a/CHANGELOG.md b/CHANGELOG.md index 4626662..e76d3a5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,37 @@ All notable changes to this project will be documented in this file. +## 2.10.1 - 2026-08-28 + +### Added + +- Added linear gradient backgrounds to templates, including multiple colours and angle control. +- Added a visual gradient stop editor with draggable stops, click-to-add support, and stop reordering. + +## 2.10.0 - 2026-08-28 + +### Added + +- Added secure kiosk onboarding with QR-first and manual pairing flows. +- Added browser-specific pairing sessions with expiring pairing codes. +- Added circular QR presentation and a compatible QR scanner with decoder fallbacks. +- Added responsive player onboarding and a post-pair option to connect another player. +- Added stable player identity bindings for paired players and moved-client aliases. +- Added per-tab client identities backed by browser session storage for commands, pairing, and screen moves. +- Added RBAC protection for player pairing through the `pairing.allow` permission. +- Added a dedicated web onboarding workflow for pairing and managing player setup. + +### Changed + +- Restricted direct screen URLs to the player configured for the requested screen. +- Added pairing entry points to the dashboard and connected clients workflows. +- Made pairing QR codes easier to scan by encoding only the short pairing code. +- Added a mobile-friendly connected clients link after successful pairing. +- Made connected-client controls and player pairing UI render independently according to their permissions. +- Added player keyboard feedback for slide navigation, plus `P` pause/unpause and `B` blackout toggles. +- Removed client identities from onboarding and screen-move URLs; authorized player data now loads after the tab identity handshake. +- Updated connected-client commands and screen moves to resolve tab and registered-player identities reliably. + ## 2.9.0 - 2026-08-28 ### Added @@ -17,6 +48,8 @@ All notable changes to this project will be documented in this file. - Weather placeholders now show all current fields, or all fields for the first daily/hourly entry before the remaining entries in a Show more section. - Weather previews, player playback, and slide thumbnails now use cached Weather data and consistent text/icon sizing. - API, RSS, and Weather lists now show enabled status and their forms provide action-oriented Enable/Disable controls. +- Enable/Disable actions on API, RSS, and Weather forms now run asynchronously without reloading unsaved form changes. +- Weather locations can now be duplicated from the weather list. - Startup data-source refreshes now respect each API, RSS, and weather source's configured repull interval. - RSS feeds now persist their last collection timestamp. - Refreshed the vendored AdminLTE assets to 4.8.5. @@ -24,11 +57,6 @@ All notable changes to this project will be documented in this file. - Removed Digital Signage Subheading and top padding. - API and RSS source saves now preserve cached data without pulling; added explicit manual refresh actions. -### Fixed - -- Prevented deletion of Weather locations that are referenced by slides, including locations nested in saved slide content. -- Preserved Weather Bootstrap Icon classes through player rich-text sanitization so Weather icons render during playback. - ## 2.8.7 - 2026-08-22 ### Added diff --git a/build/package.player.json b/build/package.player.json index 3230c03..540c403 100644 --- a/build/package.player.json +++ b/build/package.player.json @@ -1,6 +1,6 @@ { "name": "pulse-signage-player", - "version": "2.9.0", + "version": "2.10.1", "private": false, "description": "Pulse Signage player application bundle", "engines": { diff --git a/build/package.web.json b/build/package.web.json index 9ea4bdd..267fc73 100644 --- a/build/package.web.json +++ b/build/package.web.json @@ -1,6 +1,6 @@ { "name": "pulse-signage-web", - "version": "2.9.0", + "version": "2.10.1", "private": false, "description": "Pulse Signage web and bridge application bundle", "engines": { @@ -20,6 +20,7 @@ "mysql2": "^3.23.3", "puppeteer-core": "^25.7.0", "sharp": "^0.35.3", + "jsqr": "^1.4.0", "ws": "^8.21.3" } } \ No newline at end of file diff --git a/docker-compose/.env.example b/docker-compose/.env.example index 715cc93..dc0b56f 100644 --- a/docker-compose/.env.example +++ b/docker-compose/.env.example @@ -15,6 +15,7 @@ MYSQL_ROOT_PASSWORD="root_password" PLAYER_IDENTIFIER="player-local" PLAYER_PUBLIC_URL="http://localhost:8081" PLAYER_INTERNAL_URL="http://player:8081" +WEB_PUBLIC_URL="http://localhost:8080" # Web app bootstrap settings DEFAULT_ADMIN_USERNAME="admin" diff --git a/docker-compose/docker-compose.yml b/docker-compose/docker-compose.yml index 87a8d15..fe062c9 100644 --- a/docker-compose/docker-compose.yml +++ b/docker-compose/docker-compose.yml @@ -15,6 +15,7 @@ services: DB_USER: ${DB_USER:-pulse-signage} DB_PASSWORD: ${DB_PASSWORD:-signage_password} PULSE_SIGNAGE_SHARED_SECRET: ${PULSE_SIGNAGE_SHARED_SECRET:-} + WEB_PUBLIC_URL: ${WEB_PUBLIC_URL:-http://localhost:8080} BRIDGE_INTERNAL_URL: ${BRIDGE_INTERNAL_URL:-http://player-bridge:8090} DEFAULT_ADMIN_USERNAME: ${DEFAULT_ADMIN_USERNAME:-admin} DEFAULT_ADMIN_NAME: ${DEFAULT_ADMIN_NAME:-Admin} @@ -38,6 +39,7 @@ services: PLAYER_INTERNAL_URL: ${PLAYER_INTERNAL_URL:-http://player:8081} PLAYER_IDENTIFIER: ${PLAYER_IDENTIFIER:-player-local} PULSE_SIGNAGE_SHARED_SECRET: ${PULSE_SIGNAGE_SHARED_SECRET:-} + WEB_PUBLIC_URL: ${WEB_PUBLIC_URL:-http://localhost:8080} DB_HOST: ${DB_HOST:-mysql} DB_PORT: ${DB_PORT:-3306} DB_NAME: ${DB_NAME:-pulse-signage} @@ -59,6 +61,7 @@ services: - "8090:8090" environment: WEB_INTERNAL_URL: ${WEB_INTERNAL_URL:-http://web:8080} + WEB_PUBLIC_URL: ${WEB_PUBLIC_URL:-http://localhost:8080} PULSE_SIGNAGE_SHARED_SECRET: ${PULSE_SIGNAGE_SHARED_SECRET:-} DB_HOST: ${DB_HOST:-mysql} DB_PORT: ${DB_PORT:-3306} diff --git a/docs/api.md b/docs/api.md index d1e6ea4..1eb4cca 100644 --- a/docs/api.md +++ b/docs/api.md @@ -6,7 +6,7 @@ Player service base URL: `http://localhost:8081` This document covers the player HTTP surface only. The admin dashboard exposes its own routes for screen commands and onboarding management. -Access note: most player endpoints are unauthenticated because they are meant to run inside a trusted deployment network. Anything that mutates state or writes files should be treated as internal-only unless you add your own auth layer in front of it. +Access note: most player endpoints are unauthenticated because they are meant to run inside a trusted deployment network. Anything that mutates state or writes files should be treated as internal-only unless you add your own auth layer in front of it. Pairing uses a short-lived random PIN displayed by the kiosk; the PIN is accepted only through the authenticated Web UI pairing flow. When `PULSE_SIGNAGE_SHARED_SECRET` is set, the player pages sign same-origin API fetches with `x-pulse-page-auth`, and the web app signs server-to-player requests with `x-pulse-request-timestamp` plus `x-pulse-request-signature`. Page tokens auto-renew before expiry while the page stays active, and signed server requests are only accepted when their timestamp is fresh. If the secret is unset, those checks stay disabled for compatibility. @@ -17,12 +17,12 @@ Returns the player onboarding landing page. Access: public within the trusted player deployment. ### `GET /onboard` -Returns the onboarding form page. -Access: public within the trusted player deployment. +Redirects to the authenticated Web UI pairing page for compatibility with older QR codes. +Access: the Web UI pairing page requires a logged-in Web UI session. ### `GET /screen/{slug}` Returns the rendered player page for a screen. -Access: public within the trusted player deployment. +Access: the configured player may load only its persisted paired screen. An unpaired player is redirected to `/`; a different screen slug is rejected. The route is public within the trusted player deployment, but it no longer changes the player's binding. ### `GET /api/onboarding/status` Returns the persisted onboarding status for a device. @@ -56,7 +56,7 @@ Response fields: ### `POST /api/onboarding` Binds a device to a screen and client name. -Access: internal-only. Protect this endpoint if the player service is reachable outside your trusted network. +Access: internal-only. Browser submissions must go through the authenticated Web UI pairing page. The Web UI resolves the short-lived kiosk PIN to a device ID before forwarding the signed request. Protect this endpoint if the player service is reachable outside your trusted network. Accepted request fields: diff --git a/package-lock.json b/package-lock.json index 9164b14..7f0203a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "pulse-signage", - "version": "2.9.0", + "version": "2.10.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pulse-signage", - "version": "2.9.0", + "version": "2.10.1", "dependencies": { "@sparticuz/chromium": "^149.0.0", "animate.css": "^4.1.1", @@ -16,6 +16,7 @@ "express": "^5.2.1", "handlebars": "^4.7.8", "hls.js": "^1.7.0", + "jsqr": "^1.4.0", "multer": "^2.2.0", "mysql2": "^3.23.3", "puppeteer-core": "^25.7.0", @@ -24,6 +25,9 @@ }, "devDependencies": { "nodemon": "^3.1.10" + }, + "engines": { + "node": ">=26.0.0" } }, "node_modules/@emnapi/runtime": { @@ -1794,6 +1798,12 @@ "integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==", "license": "MIT" }, + "node_modules/jsqr": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/jsqr/-/jsqr-1.4.0.tgz", + "integrity": "sha512-dxLob7q65Xg2DvstYkRpkYtmKm2sPJ9oFhrhmudT1dZvNFFTlroai3AWSpLey/w5vMcLBXRgOJsbXpdN9HzU/A==", + "license": "Apache-2.0" + }, "node_modules/long": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", diff --git a/package.json b/package.json index 9f6a74a..4100d38 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "pulse-signage", - "version": "2.9.0", + "version": "2.10.1", "private": false, "description": "Pulse Signage application with MySQL and media storage", "engines": { @@ -28,6 +28,7 @@ "express": "^5.2.1", "handlebars": "^4.7.8", "hls.js": "^1.7.0", + "jsqr": "^1.4.0", "multer": "^2.2.0", "mysql2": "^3.23.3", "puppeteer-core": "^25.7.0", diff --git a/scripts/pulse-signage-kiosk.bat b/scripts/pulse-signage-kiosk.bat index 1863eba..f8bbfe7 100644 --- a/scripts/pulse-signage-kiosk.bat +++ b/scripts/pulse-signage-kiosk.bat @@ -53,9 +53,12 @@ if not defined BROWSER_PATH ( exit /b 1 ) +set "KIOSK_PROFILE=%LocalAppData%\PulseSignage\kiosk-browser-profile" +if not exist "!KIOSK_PROFILE!" mkdir "!KIOSK_PROFILE!" + echo Launching !BROWSER_KIND! in kiosk mode: !TARGET_URL! if /I "!BROWSER_KIND!"=="firefox" ( - start "" "!BROWSER_PATH!" -kiosk "!TARGET_URL!" + start "" "!BROWSER_PATH!" -no-remote -profile "!KIOSK_PROFILE!" -new-window -kiosk "!TARGET_URL!" ) else ( - start "" "!BROWSER_PATH!" --disable-notifications --kiosk "!TARGET_URL!" + start "" "!BROWSER_PATH!" --disable-notifications --no-first-run --no-default-browser-check --new-window --kiosk --user-data-dir="!KIOSK_PROFILE!" "!TARGET_URL!" ) \ No newline at end of file diff --git a/scripts/pulse-signage-kiosk.sh b/scripts/pulse-signage-kiosk.sh index 0a98f63..e71e949 100644 --- a/scripts/pulse-signage-kiosk.sh +++ b/scripts/pulse-signage-kiosk.sh @@ -35,13 +35,16 @@ else exit 1 fi +kiosk_profile="${XDG_DATA_HOME:-$HOME/.local/share}/pulse-signage/kiosk-browser-profile" +mkdir -p "$kiosk_profile" + echo "Launching ${browser_kind} in kiosk mode: ${target_url}" case "$browser_kind" in firefox) - exec "$browser" -kiosk "$target_url" + exec "$browser" -no-remote -profile "$kiosk_profile" -new-window -kiosk "$target_url" ;; edge|chrome) - exec "$browser" --disable-notifications --kiosk "$target_url" + exec "$browser" --disable-notifications --no-first-run --no-default-browser-check --new-window --kiosk --user-data-dir="$kiosk_profile" "$target_url" ;; esac \ No newline at end of file diff --git a/src/data/admin.js b/src/data/admin.js index 2aa0b4b..4ec6cfd 100644 --- a/src/data/admin.js +++ b/src/data/admin.js @@ -6,7 +6,7 @@ async function fetchAdminData(pool) { const [playlists] = await pool.query('SELECT id, name, fade_between_slides, skip_unavailable_rtmp, created_at, modified_at, created_by, modified_by FROM c_playlists ORDER BY id DESC'); const [canvasSizes] = await pool.query('SELECT id, name, width, height, created_at, modified_at, created_by, modified_by FROM c_canvas_sizes ORDER BY width ASC, height ASC, name ASC'); const [templates] = await pool.query(` - SELECT st.id, st.name, st.canvas_size_id, st.background_image_path, st.background_color, st.created_at, st.modified_at, + SELECT st.id, st.name, st.canvas_size_id, st.background_image_path, st.background_color, st.background_gradient, st.created_at, st.modified_at, cs.name AS canvas_size_name, cs.width AS canvas_size_width, cs.height AS canvas_size_height FROM c_templates st LEFT JOIN c_canvas_sizes cs ON cs.id = st.canvas_size_id @@ -112,7 +112,7 @@ async function fetchSlidesPage(pool, page, pageSize, searchTerm, sortKey, sortDi async function fetchTemplatesPage(pool, page, pageSize, searchTerm, sortKey, sortDirection) { const paged = await fetchPagedRows(pool, { - selectSql: `SELECT st.id, st.name, st.canvas_size_id, st.background_image_path, st.background_color, st.created_at, st.modified_at, + selectSql: `SELECT st.id, st.name, st.canvas_size_id, st.background_image_path, st.background_color, st.background_gradient, st.created_at, st.modified_at, cs.name AS canvas_size_name, cs.width AS canvas_size_width, cs.height AS canvas_size_height, (SELECT COUNT(*) FROM c_template_regions str WHERE str.template_id = st.id) AS region_count, (SELECT COUNT(*) FROM c_slides s WHERE s.template_id = st.id) AS slide_count diff --git a/src/data/templates.js b/src/data/templates.js index 6b12ab1..418897a 100644 --- a/src/data/templates.js +++ b/src/data/templates.js @@ -14,6 +14,39 @@ function sanitizeBackgroundColor(value) { return '#111111'; } +function normalizeBackgroundGradient(value) { + let gradient = value; + if (typeof gradient === 'string') { + try { + gradient = JSON.parse(gradient); + } catch (_error) { + gradient = null; + } + } + if (!gradient || typeof gradient !== 'object' || Array.isArray(gradient)) { + return null; + } + const sourceStops = Array.isArray(gradient.stops) && gradient.stops.length + ? gradient.stops + : (Array.isArray(gradient.colors) ? gradient.colors.map((color, index, colors) => ({ + color, + position: colors.length > 1 ? Math.round((index / (colors.length - 1)) * 100) : 0 + })) : []); + const stops = sourceStops.slice(0, 12).map((stop) => ({ + color: sanitizeBackgroundColor(stop && stop.color), + position: Math.max(0, Math.min(100, Number.isFinite(Number(stop && stop.position)) ? Number(stop.position) : 0)) + })); + if (stops.length < 2) { + return null; + } + const angle = Number(gradient.angle); + return JSON.stringify({ + type: 'linear', + stops, + angle: Number.isFinite(angle) ? Math.max(0, Math.min(360, angle)) : 90 + }); +} + function normalizeTemplateRegionType(value) { const rawType = String(value || 'text').trim(); return rawType || 'text'; @@ -106,7 +139,7 @@ function ensureUniqueTemplateRegionNames(regions) { async function fetchTemplateById(pool, id) { const [templates] = await pool.query(` - SELECT st.id, st.name, st.canvas_size_id, st.background_image_path, st.background_color, st.created_at, st.modified_at, + SELECT st.id, st.name, st.canvas_size_id, st.background_image_path, st.background_color, st.background_gradient, st.created_at, st.modified_at, cs.name AS canvas_size_name, cs.width AS canvas_size_width, cs.height AS canvas_size_height FROM c_templates st LEFT JOIN c_canvas_sizes cs ON cs.id = st.canvas_size_id @@ -123,7 +156,7 @@ async function fetchTemplateById(pool, id) { async function fetchTemplatesData(pool) { const [templates] = await pool.query(` - SELECT st.id, st.name, st.canvas_size_id, st.background_image_path, st.background_color, st.created_at, st.modified_at, + SELECT st.id, st.name, st.canvas_size_id, st.background_image_path, st.background_color, st.background_gradient, st.created_at, st.modified_at, cs.name AS canvas_size_name, cs.width AS canvas_size_width, cs.height AS canvas_size_height FROM c_templates st LEFT JOIN c_canvas_sizes cs ON cs.id = st.canvas_size_id @@ -212,6 +245,10 @@ async function buildTemplatePayload(pool, req, existingTemplate) { const backgroundImage = filesByField.background_image; const removeBackgroundImage = Boolean(req.body.remove_background_image); const backgroundColor = sanitizeBackgroundColor(req.body.background_color || (existingTemplate && existingTemplate.background_color)); + const submittedBackgroundGradient = Object.prototype.hasOwnProperty.call(req.body, 'background_gradient') + ? req.body.background_gradient + : existingTemplate && existingTemplate.background_gradient; + const backgroundGradient = normalizeBackgroundGradient(submittedBackgroundGradient); const backgroundImagePath = backgroundImage ? `/media/uploads/${backgroundImage.filename}` : removeBackgroundImage @@ -256,6 +293,7 @@ async function buildTemplatePayload(pool, req, existingTemplate) { canvasSizeHeight: canvasHeight, backgroundImagePath, backgroundColor, + backgroundGradient, regions }; } @@ -265,5 +303,6 @@ module.exports = { fetchTemplatesData, extractTemplateRegions, buildTemplatePayload, + normalizeBackgroundGradient, parseJsonSafe }; diff --git a/src/data/weather.js b/src/data/weather.js index ec1de61..db1a6de 100644 --- a/src/data/weather.js +++ b/src/data/weather.js @@ -89,7 +89,7 @@ async function fetchWeatherLocationForecast(pool, location) { if (!apiKey) throw new Error('Pirate Weather API key is not configured.'); url = 'https://api.pirateweather.net/forecast/' + encodeURIComponent(apiKey) + '/' + latitude + ',' + longitude + '?units=' + (temperatureUnit === 'fahrenheit' ? 'us' : 'si'); } else { - const params = new URLSearchParams({ latitude: String(latitude), longitude: String(longitude), timezone: String(source.timezone || 'auto'), forecast_days: '7', current: 'temperature_2m,relative_humidity_2m,apparent_temperature,is_day,precipitation,rain,weather_code,wind_speed_10m,wind_direction_10m,uv_index,cloud_cover', hourly: 'temperature_2m,precipitation_probability,precipitation,weather_code,wind_speed_10m,uv_index,cloud_cover', daily: 'weather_code,temperature_2m_max,temperature_2m_min,sunrise,sunset,precipitation_probability_max,precipitation_sum,wind_speed_10m_max,uv_index_max,cloud_cover_mean', temperature_unit: temperatureUnit, wind_speed_unit: windUnit, precipitation_unit: precipitationUnit }); + const params = new URLSearchParams({ latitude: String(latitude), longitude: String(longitude), timezone: String(source.timezone || 'auto'), forecast_days: '7', forecast_hours: '24', current: 'temperature_2m,relative_humidity_2m,apparent_temperature,is_day,precipitation,rain,weather_code,wind_speed_10m,wind_direction_10m,uv_index,cloud_cover', hourly: 'temperature_2m,precipitation_probability,precipitation,weather_code,wind_speed_10m,uv_index,cloud_cover', daily: 'weather_code,temperature_2m_max,temperature_2m_min,sunrise,sunset,precipitation_probability_max,precipitation_sum,wind_speed_10m_max,uv_index_max,cloud_cover_mean', temperature_unit: temperatureUnit, wind_speed_unit: windUnit, precipitation_unit: precipitationUnit }); const apiKey = String(settings['weather.open_meteo_api_key'] || '').trim(); if (apiKey) params.set('apikey', apiKey); url = 'https://api.open-meteo.com/v1/forecast?' + params.toString(); diff --git a/src/db/common.js b/src/db/common.js index 40b5040..7ff71fe 100644 --- a/src/db/common.js +++ b/src/db/common.js @@ -17,7 +17,8 @@ function createPool() { async function pruneStaleOnboardingDevices(pool) { await pool.query( `DELETE FROM d_onboarding_devices - WHERE modified_at < (CURRENT_TIMESTAMP - INTERVAL 1 MINUTE)` + WHERE screen_id IS NULL + AND modified_at < (CURRENT_TIMESTAMP - INTERVAL 1 MINUTE)` ); } diff --git a/src/db/index.js b/src/db/index.js index f1802b2..9da60a6 100644 --- a/src/db/index.js +++ b/src/db/index.js @@ -57,6 +57,7 @@ async function ensureSchema(pool, options) { canvas_size_id INT NULL, background_image_path VARCHAR(512) NULL, background_color VARCHAR(32) NULL, + background_gradient LONGTEXT NULL, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, created_by INT NULL, modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, diff --git a/src/db/migrations.js b/src/db/migrations.js index be3c721..8a5f0e9 100644 --- a/src/db/migrations.js +++ b/src/db/migrations.js @@ -541,10 +541,17 @@ const VERSIONED_MIGRATIONS = [ version: '2.8.9', label: 'v2.8.9 data source enablement schema', run: async function (pool) { - await ensureColumn(pool, 'i_api_sources', 'enabled', 'TINYINT(1) NOT NULL DEFAULT 1', 'items_path'); + await ensureColumn(pool, 'i_api_sources', 'enabled', 'TINYINT(1) NOT NULL DEFAULT 1', 'api_url'); await ensureColumn(pool, 'i_rss_feeds', 'enabled', 'TINYINT(1) NOT NULL DEFAULT 1', 'feed_url'); await ensureColumn(pool, 'i_weather_locations', 'enabled', 'TINYINT(1) NOT NULL DEFAULT 1', 'precipitation_unit'); } + }, + { + version: '2.10.1', + label: 'v2.10.1 template background gradient schema', + run: async function (pool) { + await ensureColumn(pool, 'c_templates', 'background_gradient', 'LONGTEXT NULL', 'background_color'); + } } ]; diff --git a/src/player-bridge/index.js b/src/player-bridge/index.js index 825fbe0..942394d 100644 --- a/src/player-bridge/index.js +++ b/src/player-bridge/index.js @@ -13,6 +13,7 @@ const { commitDeviceBinding, bindPlayerToScreen, getOnboardingStatus, getPlayerP const { createStyledQrCodeSvg } = require('../data/qr-code'); const { verifyPageAuthToken } = require('#src/request-auth'); + function createThinClientConfig() { return { port: Number(process.env.THIN_CLIENT_PORT || 8090), @@ -354,6 +355,27 @@ async function start() { return socket && socket.playerDeviceId ? String(socket.playerDeviceId).trim() : ''; } + function findPlayerByPairingCode(pairingCode) { + const normalizedCode = String(pairingCode || '').trim().toUpperCase(); + if (!normalizedCode) { + return null; + } + for (const [deviceId, socket] of playerSockets.entries()) { + const pairingCodes = socket && Array.isArray(socket.pairingSessions) + ? socket.pairingSessions.map(function (entry) { return entry.code; }) + : (socket && Array.isArray(socket.pairingCodes) ? socket.pairingCodes : [socket && socket.pairingCode]); + if (socket && pairingCodes.some(function (code) { + return String(code || '').trim().toUpperCase() === normalizedCode; + })) { + const session = socket.pairingSessions && socket.pairingSessions.find(function (entry) { + return String(entry.code || '').trim().toUpperCase() === normalizedCode; + }); + return { deviceId: deviceId, socket: socket, clientId: session && session.clientId ? session.clientId : null, code: normalizedCode }; + } + } + return null; + } + function removeConnectedPlayerSocket(socket) { if (!socket || !socket.playerDeviceId) { return false; @@ -514,6 +536,21 @@ async function start() { }; } + function requireOnboardingAuth(req, res, next) { + const token = String(req.headers['x-pulse-page-auth'] || '').trim(); + const payload = verifyPageAuthToken(token); + if (payload && ['onboarding', 'player'].indexOf(String(payload.scope || '').trim()) !== -1) { + req.playerPageAuth = payload; + return next(); + } + + if (verifyRequestAuth(req)) { + return next(); + } + + return res.status(401).json({ error: 'Onboarding authentication required.' }); + } + app.get('/api/media/config', requireRequestAuth, function (_req, res) { res.json({ mediaDir: config.mediaDir, @@ -630,12 +667,45 @@ async function start() { } }); + app.post('/api/screens/:slug/announcements/refresh', requireRequestAuth, function (req, res) { + const slug = String(req.params.slug || '').trim(); + if (!slug) { + return res.status(400).json({ error: 'Screen slug is required.' }); + } + + const targetPlayers = Array.from(playerSockets.values()).filter(function (socket) { + return socket && socket.readyState === WebSocket.OPEN; + }).map(function (socket) { + return { socket: socket }; + }); + let sent = 0; + targetPlayers.forEach(function (target) { + if (!target || !target.socket || target.socket.readyState !== WebSocket.OPEN) { + return; + } + + try { + target.socket.send(JSON.stringify({ + type: 'command', + command: 'announcement-refresh', + screenSlug: slug, + sentAt: new Date().toISOString() + })); + sent += 1; + } catch (_error) { + } + }); + + return res.json({ ok: true, screenSlug: slug, sent: sent }); + }); + app.get('/api/onboarding/status', async function (req, res, next) { try { - const deviceId = normalizeDeviceId(req.query.deviceId) || getConnectedPlayerDeviceId(); + const requestedDeviceId = normalizeDeviceId(req.query.deviceId); + const deviceId = requestedDeviceId; const status = await getOnboardingStatus(pool, deviceId); res.json({ - deviceId: normalizeDeviceId(deviceId), + deviceId: requestedDeviceId, onboarded: Boolean(status && status.screen_id), clientName: status ? status.client_name : null, screenId: status ? status.screen_id : null, @@ -648,6 +718,58 @@ async function start() { } }); + app.get('/api/onboarding/resolve', requireRequestAuth, function (req, res) { + const pairing = findPlayerByPairingCode(req.query.pairingCode); + if (!pairing) { + return res.status(401).json({ error: 'A valid kiosk pairing code is required.' }); + } + res.json({ + deviceId: pairing.deviceId, + clientId: pairing.clientId || null, + }); + }); + + app.get('/api/onboarding/url', requireRequestAuth, function (req, res) { + const pairing = findPlayerByPairingCode(req.query.pairingCode); + if (!pairing) { + return res.status(401).json({ error: 'A valid kiosk pairing code is required.' }); + } + const webBaseUrl = String(process.env.WEB_PUBLIC_URL || '').trim().replace(/\/$/, ''); + if (!webBaseUrl) { + return res.status(503).json({ error: 'WEB_PUBLIC_URL is not configured on the bridge.' }); + } + res.json({ url: `${webBaseUrl}/pairing?code=${encodeURIComponent(pairing.code)}` }); + }); + + app.get('/api/onboarding/qr', requireRequestAuth, async function (req, res, next) { + try { + const pairing = findPlayerByPairingCode(req.query.pairingCode); + if (!pairing) { + return res.status(401).json({ error: 'A valid kiosk pairing code is required.' }); + } + const webBaseUrl = String(process.env.WEB_PUBLIC_URL || '').trim().replace(/\/$/, ''); + if (!webBaseUrl) { + return res.status(503).json({ error: 'WEB_PUBLIC_URL is not configured on the bridge.' }); + } + const svg = await createStyledQrCodeSvg({ + value: `${webBaseUrl}/pairing?code=${encodeURIComponent(pairing.code)}`, + 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.get('/api/onboarding/screens', requirePageAuth(['onboarding', 'player']), async function (_req, res, next) { try { const [rows] = await pool.query('SELECT id, name, slug FROM d_screens ORDER BY name ASC, id ASC'); @@ -657,25 +779,18 @@ async function start() { } }); - app.get('/api/onboarding/qr', async function (req, res, next) { + app.post('/api/onboarding', express.json(), requireOnboardingAuth, async function (req, res, next) { try { - const deviceId = normalizeDeviceId(req.query.deviceId) || getConnectedPlayerDeviceId(); - if (!deviceId) { - return res.status(400).json({ error: 'Device ID is required' }); + const pairingCode = String(req.body && req.body.pairingCode || '').trim().toUpperCase(); + const pairing = findPlayerByPairingCode(pairingCode); + const clientId = normalizeDeviceId(req.body && req.body.clientId); + if (!pairing) { + return res.status(401).json({ error: 'A valid kiosk pairing code is required.' }); } - const onboardingUrl = `${getPlayerPublicBaseUrl(req)}/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', requirePageAuth(['onboarding', 'player']), express.json(), async function (req, res, next) { - try { - const deviceId = normalizeDeviceId(req.body && req.body.deviceId) || getConnectedPlayerDeviceId(); + if (!clientId) { + return res.status(400).json({ error: 'Client ID is required.' }); + } + const deviceId = pairing.deviceId; const clientName = String((req.body && req.body.clientName) || '').trim(); const screenSlug = String((req.body && req.body.screenSlug) || '').trim(); if (!deviceId) { @@ -688,8 +803,13 @@ async function start() { return res.status(400).json({ error: 'Screen is required' }); } - const status = await commitDeviceBinding(pool, deviceId, clientName, screenSlug, null, []); + const status = await commitDeviceBinding(pool, clientId, clientName, screenSlug, null, []); await bindPlayerToScreen(pool, deviceId, screenSlug); + await sendPlayerCommandToSocket(pairing.socket, { + command: 'redirect', + url: `${String(pairing.socket.publicBaseUrl || getPlayerPublicBaseUrl(req)).replace(/\/$/, '')}/screen/${encodeURIComponent(screenSlug)}`, + clientId: clientId + }); res.json({ deviceId: deviceId, clientName: status ? status.client_name : clientName, @@ -709,6 +829,11 @@ async function start() { app.get('/api/screens/:slug/playlist', requirePageAuth(['player']), async function (req, res, next) { try { + const clientId = String(req.headers['x-pulse-client-id'] || '').trim(); + const status = await getOnboardingStatus(pool, clientId); + if (!clientId || !status || String(status.screen_slug || '').trim() !== String(req.params.slug || '').trim()) { + return res.status(403).json({ error: 'This browser tab is not authorized for this screen.' }); + } res.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate'); const data = await playerPlaylistService.buildScreenPlaylist(req.params.slug); if (!data.screen) { @@ -729,6 +854,11 @@ async function start() { app.get('/api/screens/:slug/announcement', requirePageAuth(['player']), async function (req, res, next) { try { + const clientId = String(req.headers['x-pulse-client-id'] || '').trim(); + const status = await getOnboardingStatus(pool, clientId); + if (!clientId || !status || String(status.screen_slug || '').trim() !== String(req.params.slug || '').trim()) { + return res.status(403).json({ error: 'This browser tab is not authorized for this screen.' }); + } res.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate'); const announcement = typeof common.fetchActiveAnnouncement === 'function' ? await common.fetchActiveAnnouncement(pool, req.params.slug) @@ -841,6 +971,17 @@ async function start() { } socket.playerDeviceId = deviceId; + socket.pairingSessions = Array.isArray(payload.pairingSessions) ? payload.pairingSessions.map(function (entry) { + return { + deviceId: normalizeDeviceId(entry && entry.deviceId), + clientId: normalizeDeviceId(entry && entry.clientId), + code: String(entry && entry.code || '').trim().toUpperCase() + }; + }).filter(function (entry) { return entry.deviceId && entry.code; }) : []; + socket.pairingCodes = Array.from(new Set((Array.isArray(payload.pairingCodes) ? payload.pairingCodes : [payload.pairingCode]).map(function (value) { + return String(value || '').trim().toUpperCase(); + }).filter(Boolean))); + socket.pairingCode = socket.pairingCodes[0] || ''; if (messageType === 'snapshot') { const slug = String(payload.slug || '').trim(); @@ -853,19 +994,28 @@ async function start() { } if (messageType === 'register') { + socket.publicBaseUrl = String(payload.publicBaseUrl || '').trim().replace(/\/$/, ''); const player = await upsertPlayerRegistration(pool, { deviceId: deviceId, publicBaseUrl: payload.publicBaseUrl, internalBaseUrl: payload.internalBaseUrl }); + const previousSocket = playerSockets.get(deviceId); playerSockets.set(deviceId, socket); + if (previousSocket && previousSocket !== socket && previousSocket.readyState !== WebSocket.CLOSED) { + try { + previousSocket.close(1000, 'Replaced by a newer player connection.'); + } catch (_error) { + } + } logBridge(`Player ${formatPlayerConnectionLabel(deviceId)} has connected`); socket.send(JSON.stringify({ type: 'registered', ok: true, player: player })); return; } if (messageType === 'heartbeat') { + socket.publicBaseUrl = String(payload.publicBaseUrl || socket.publicBaseUrl || '').trim().replace(/\/$/, ''); const player = await recordPlayerHeartbeat(pool, { deviceId: deviceId, publicBaseUrl: payload.publicBaseUrl, @@ -928,15 +1078,15 @@ async function start() { }); socket.on('close', function () { - clearPlayerSnapshotSources(socket.playerDeviceId); if (removeConnectedPlayerSocket(socket)) { + clearPlayerSnapshotSources(socket.playerDeviceId); logPlayerDisconnect(socket); } }); socket.on('error', function () { - clearPlayerSnapshotSources(socket.playerDeviceId); if (removeConnectedPlayerSocket(socket)) { + clearPlayerSnapshotSources(socket.playerDeviceId); logPlayerDisconnect(socket); } }); diff --git a/src/player.js b/src/player.js index 3611f49..e8197ef 100644 --- a/src/player.js +++ b/src/player.js @@ -37,6 +37,9 @@ async function start() { let lastDisconnectAt = 0; let playerPublicBaseUrl = PLAYER_PUBLIC_URL || null; let refreshThinClientRegistration = null; + let activePairingCode = ''; + let activePairingCodes = []; + let activePairingSessions = []; const playerRuntime = createPlayerRuntime({ pool: pool, normalizeDeviceId: normalizeDeviceId, @@ -262,7 +265,7 @@ async function start() { } response.ok = true; } - } else if (['refresh', 'reload', 'redirect', 'pause', 'blackout', 'previous', 'next', 'setclientname'].indexOf(command) !== -1) { + } else if (['refresh', 'reload', 'redirect', 'pause', 'blackout', 'previous', 'next', 'setclientname', 'announcement-refresh'].indexOf(command) !== -1) { const screenSlug = String(payload.screenSlug || payload.slug || '').trim(); if (!screenSlug) { response.error = 'Screen slug is required.'; @@ -305,7 +308,17 @@ async function start() { playerPublicBaseUrl: getPlayerPublicBaseUrl(), playerInternalBaseUrl: PLAYER_INTERNAL_URL, bridgeBaseUrl: BRIDGE_PUBLIC_URL, - playerDeviceId: PLAYER_DEVICE_ID + playerDeviceId: PLAYER_DEVICE_ID, + onPairingCode: function (code, codes) { + activePairingCode = String(code || '').trim().toUpperCase(); + activePairingSessions = Array.isArray(codes) ? codes.map(function (entry) { + return { deviceId: String(entry && entry.deviceId || '').trim(), clientId: String(entry && entry.clientId || '').trim(), code: String(entry && entry.code || '').trim().toUpperCase() }; + }).filter(function (entry) { return entry.deviceId && entry.code; }) : []; + activePairingCodes = activePairingSessions.map(function (entry) { return entry.code; }); + if (typeof refreshThinClientRegistration === 'function') { + refreshThinClientRegistration(); + } + } }); registerPlayerRoutes(app, { pool: pool, @@ -375,7 +388,10 @@ async function start() { type: 'heartbeat', deviceId: PLAYER_DEVICE_ID, publicBaseUrl: getPlayerPublicBaseUrl(), - internalBaseUrl: PLAYER_INTERNAL_URL + internalBaseUrl: PLAYER_INTERNAL_URL, + pairingCode: activePairingCode, + pairingCodes: activePairingCodes, + pairingSessions: activePairingSessions })); } @@ -439,7 +455,10 @@ async function start() { type: 'register', deviceId: PLAYER_DEVICE_ID, publicBaseUrl: getPlayerPublicBaseUrl(), - internalBaseUrl: PLAYER_INTERNAL_URL + internalBaseUrl: PLAYER_INTERNAL_URL, + pairingCode: activePairingCode, + pairingCodes: activePairingCodes, + pairingSessions: activePairingSessions })); playerRuntime.snapshotSlugs().forEach(function (slug) { diff --git a/src/player/onboarding/index.js b/src/player/onboarding/index.js index 48363b0..f43ed15 100644 --- a/src/player/onboarding/index.js +++ b/src/player/onboarding/index.js @@ -1,20 +1,44 @@ // Player onboarding routes and signup flow helpers. const express = require('express'); +const crypto = require('crypto'); 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 { 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'); @@ -120,7 +144,7 @@ async function commitDeviceBinding(pool, deviceId, clientName, screenSlug, isNam } const screen = screenRows[0]; - const available = await isClientNameAvailable(pool, normalizedClientName, normalizedDeviceId, liveConnections); + const available = await isClientNameAvailable(pool, normalizedClientName, null, liveConnections); if (!available) { const error = new Error('Client name already exists.'); error.statusCode = 400; @@ -217,6 +241,38 @@ function registerPlayerOnboardingRoutes(app, options) { 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); + 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.'); @@ -289,15 +345,71 @@ function registerPlayerOnboardingRoutes(app, options) { next(); } - app.get('/', function (_req, res) { + 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'); - res.send(common.renderPlayerOnboardingLandingPage()); + 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 { - res.send(common.renderPlayerOnboardingFormPage(String(req.query.deviceId || playerDeviceId || '').trim())); + 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); } @@ -315,7 +427,7 @@ function registerPlayerOnboardingRoutes(app, options) { next(); }, async function (req, res, next) { try { - const deviceId = normalizeDeviceId(req.query.deviceId) || playerDeviceId; + const deviceId = normalizeDeviceId(req.query.deviceId || req.query.clientId); if (bridgeBaseUrl) { const response = await fetchThinClient(req, '/api/onboarding/status?deviceId=' + encodeURIComponent(deviceId || ''), { method: 'GET' @@ -347,6 +459,26 @@ function registerPlayerOnboardingRoutes(app, options) { } }); + 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) { @@ -366,14 +498,40 @@ function registerPlayerOnboardingRoutes(app, options) { } }); + 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 onboardingUrl = `${getPublicBaseUrl(req, playerPublicUrl)}/onboard?deviceId=${encodeURIComponent(deviceId)}`; - const svg = await createStyledQrCodeSvg({ value: onboardingUrl, qr_margin: 20 }); + 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); @@ -382,20 +540,28 @@ function registerPlayerOnboardingRoutes(app, options) { } }); - app.post('/api/onboarding', requireOnboardingPageAuth, express.json(), async function (req, res, next) { + app.post('/api/onboarding', express.json(), requireOnboardingAuth, async function (req, res, next) { try { - const deviceId = normalizeDeviceId(req.body && req.body.deviceId) || playerDeviceId; + 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 || {}, { - deviceId: deviceId + clientId: clientId || null }); const response = await fetch(new URL('/api/onboarding', bridgeBaseUrl).toString(), { method: 'POST', @@ -413,6 +579,9 @@ function registerPlayerOnboardingRoutes(app, options) { if (!payload) { return res.status(502).json({ error: 'Player bridge returned an invalid response.' }); } + if (response.ok) { + pairingSessions.delete(deviceId); + } payload.playerUrl = payload && payload.screenSlug ? `${getPublicBaseUrl(req, playerPublicUrl)}/screen/${encodeURIComponent(payload.screenSlug)}` : `${getPublicBaseUrl(req, playerPublicUrl)}/screen/${encodeURIComponent(screenSlug)}`; return res.json(payload); } @@ -426,8 +595,11 @@ function registerPlayerOnboardingRoutes(app, options) { 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); + 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.json({ deviceId: deviceId, clientName: status ? status.client_name : clientName, @@ -456,5 +628,6 @@ module.exports = { upsertPlayerRegistration: upsertPlayerRegistration, bindPlayerToScreen: bindPlayerToScreen, bindDeviceToScreen: bindDeviceToScreen, + isValidOnboardingPairingCode: isValidOnboardingPairingCode, registerPlayerOnboardingRoutes: registerPlayerOnboardingRoutes }; \ No newline at end of file diff --git a/src/player/onboarding/player-onboarding-form.script.html b/src/player/onboarding/player-onboarding-form.script.html index e07a305..0ddbc63 100644 --- a/src/player/onboarding/player-onboarding-form.script.html +++ b/src/player/onboarding/player-onboarding-form.script.html @@ -65,10 +65,11 @@ var formData = new FormData(form); var clientName = String(formData.get("clientName") || "").trim(); var screenSlug = String(formData.get("screenSlug") || "").trim(); + var pairingCode = String(formData.get("pairingCode") || "").trim(); if (!clientName) { setMessage("Client name is required."); return; } if (!screenSlug) { setMessage("Screen is required."); return; } setMessage("Saving client..."); - var payload = { clientName: clientName, screenSlug: screenSlug }; + var payload = { clientName: clientName, screenSlug: screenSlug, pairingCode: pairingCode }; if (deviceId) { payload.deviceId = deviceId; } diff --git a/src/player/onboarding/player-onboarding-landing.script.html b/src/player/onboarding/player-onboarding-landing.script.html index 1130c96..4b6a4fe 100644 --- a/src/player/onboarding/player-onboarding-landing.script.html +++ b/src/player/onboarding/player-onboarding-landing.script.html @@ -13,23 +13,12 @@ } var screenKey = "pulse-signage-player-screen-slug"; var qr = document.getElementById("onboarding-qr"); - var status = document.getElementById("onboarding-status"); - var localForm = document.getElementById("onboarding-local-form"); - var localMessage = document.getElementById("onboarding-message"); - var localScreenSelect = document.getElementById("onboarding-screen-select"); + var pairingCodeElement = document.getElementById("onboarding-pairing-code"); var qrPlaceholderSrc = "data:image/svg+xml;charset=utf-8,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 320 320%22%3E%3Crect width=%22320%22 height=%22320%22 rx=%2224%22 fill=%22%23ffffff%22/%3E%3Crect x=%2230%22 y=%2230%22 width=%22260%22 height=%22260%22 rx=%2218%22 fill=%22%23f8fafc%22 stroke=%22%23cbd5e1%22 stroke-width=%223%22 stroke-dasharray=%2212 10%22/%3E%3Cpath d=%22M106 118h108M106 156h108M106 194h72%22 stroke=%22%2394a3b8%22 stroke-width=%2214%22 stroke-linecap=%22round%22/%3E%3Ccircle cx=%22128%22 cy=%22248%22 r=%2212%22 fill=%22%2394a3b8%22/%3E%3Ctext x=%22160%22 y=%2278%22 text-anchor=%22middle%22 fill=%22%230f172a%22 font-family=%22Arial,sans-serif%22 font-size=%2224%22 font-weight=%22700%22%3EQR code loading%3C/text%3E%3Ctext x=%22160%22 y=%22266%22 text-anchor=%22middle%22 fill=%22%234b5563%22 font-family=%22Arial,sans-serif%22 font-size=%2214%22%3EPlease wait%3C/text%3E%3C/svg%3E"; - function parseResponseError(response) { - return response.text().then(function (text) { - var fallbackMessage = text && text.trim() ? text.trim() : "Unable to save onboarding."; - try { - var payload = JSON.parse(text); - return payload && payload.error ? payload.error : fallbackMessage; - } catch (_error) { - return fallbackMessage; - } - }); - } function getDeviceId() { + var configuredDeviceId = document.getElementById("onboarding-shell"); + configuredDeviceId = configuredDeviceId ? String(configuredDeviceId.getAttribute("data-player-device-id") || "").trim() : ""; + if (configuredDeviceId) { return configuredDeviceId; } var stored = ""; try { stored = window.sessionStorage.getItem(deviceKey) || ""; } catch (_error) { stored = ""; } if (stored) { return stored; } @@ -37,115 +26,57 @@ try { window.sessionStorage.setItem(deviceKey, next); } catch (_error2) {} return next; } - function setStatus(message) { if (status) { status.textContent = message; } } - function setLocalMessage(message) { if (localMessage) { localMessage.textContent = message || ""; } } - function setSelectOptions(select, screens, selectedSlug) { - if (!select) { return; } - while (select.firstChild) { select.removeChild(select.firstChild); } - var placeholder = document.createElement("option"); - placeholder.value = ""; - placeholder.textContent = "Select a screen"; - select.appendChild(placeholder); - (Array.isArray(screens) ? screens : []).forEach(function (screen) { - var option = document.createElement("option"); - option.value = String(screen && screen.slug ? screen.slug : ""); - option.textContent = String(screen && (screen.name || screen.slug) ? (screen.name || screen.slug) : "Screen"); - if (selectedSlug && String(option.value) === String(selectedSlug)) { - option.selected = true; - } - select.appendChild(option); - }); - } - function loadScreens(selectedSlug) { - return fetch("/api/onboarding/screens", { cache: "no-store" }) - .then(function (response) { return response.ok ? response.json() : null; }) - .then(function (payload) { - var screens = payload && Array.isArray(payload.screens) ? payload.screens : []; - setSelectOptions(localScreenSelect, screens, selectedSlug); - return screens; - }) - .catch(function () { setSelectOptions(localScreenSelect, [], selectedSlug); return []; }); - } - function loadQr(deviceId) { + function loadQr(deviceId, clientId) { if (!qr) { return; } qr.onerror = function () { qr.src = qrPlaceholderSrc; }; - qr.src = "/api/onboarding/qr?deviceId=" + encodeURIComponent(deviceId); + qr.src = "/api/onboarding/qr?deviceId=" + encodeURIComponent(deviceId) + "&clientId=" + encodeURIComponent(clientId); } - function submitOnboarding(deviceId, clientName, screenSlug) { - return fetch("/api/onboarding", { - method: "POST", - headers: { "Content-Type": "application/json", Accept: "application/json" }, - body: JSON.stringify({ deviceId: deviceId, clientName: clientName, screenSlug: screenSlug }) - }) - .then(function (response) { - if (response.ok) { - return response.json(); - } - return parseResponseError(response).then(function (messageText) { - throw new Error(messageText); - }); - }) + function loadPairingSession(deviceId, clientId) { + return fetch("/api/onboarding/session?deviceId=" + encodeURIComponent(deviceId) + "&clientId=" + encodeURIComponent(clientId), { cache: "no-store" }) + .then(function (response) { return response.ok ? response.json() : null; }) .then(function (payload) { - if (!payload || !payload.screenSlug) { throw new Error("Unable to save onboarding."); } - if (payload.clientName) { setSessionStorageItem(clientNameKey, payload.clientName); } - if (payload.clientName && payload.screenSlug) { setSessionStorageItem(getClientNameStorageKey(payload.screenSlug), payload.clientName); } - try { window.localStorage.setItem(screenKey, payload.screenSlug); } catch (_error) {} - setLocalMessage("Onboarding complete."); - if (localForm) { - Array.prototype.slice.call(localForm.querySelectorAll("input, select, button")).forEach(function (control) { - control.disabled = true; - }); + if (payload && payload.pairingCode && pairingCodeElement) { + pairingCodeElement.textContent = payload.pairingCode; } - }); + return payload; + }) + .catch(function () { return null; }); + } + function getClientId() { + var stored = getSessionStorageItem("pulse-signage-player-client-id"); + if (stored) { return stored; } + var next = (window.crypto && window.crypto.randomUUID ? window.crypto.randomUUID() : "client-" + Date.now() + "-" + Math.random().toString(16).slice(2)); + setSessionStorageItem("pulse-signage-player-client-id", next); + return next; } function redirectIfOnboarded(deviceId) { - return fetch("/api/onboarding/status?deviceId=" + encodeURIComponent(deviceId), { cache: "no-store" }) + var bindingId = getClientId() || deviceId; + return fetch("/api/onboarding/status?deviceId=" + encodeURIComponent(bindingId), { cache: "no-store" }) .then(function (response) { return response.ok ? response.json() : null; }) .then(function (payload) { if (payload && payload.onboarded && payload.screenSlug) { if (payload.clientName) { setSessionStorageItem(clientNameKey, payload.clientName); } if (payload.clientName && payload.screenSlug) { setSessionStorageItem(getClientNameStorageKey(payload.screenSlug), payload.clientName); } try { window.localStorage.setItem(screenKey, payload.screenSlug); } catch (_error) {} - window.location.replace("/screen/" + encodeURIComponent(payload.screenSlug)); + window.location.replace("/screen/" + encodeURIComponent(payload.screenSlug) + "?clientId=" + encodeURIComponent(clientId)); return true; } return false; }) .catch(function () { return false; }); } + var clientId = getClientId(); var deviceId = getDeviceId(); - if (localForm) { - localForm.addEventListener("submit", function (event) { - event.preventDefault(); - var formData = new FormData(localForm); - var clientName = String(formData.get("clientName") || "").trim(); - var screenSlug = String(formData.get("screenSlug") || "").trim(); - if (!clientName) { setLocalMessage("Client name is required."); return; } - if (!screenSlug) { setLocalMessage("Screen is required."); return; } - setLocalMessage("Saving client..."); - submitOnboarding(deviceId, clientName, screenSlug).catch(function (error) { - setLocalMessage(error && error.message ? error.message : "Unable to save onboarding."); - }); - }); - } - loadScreens().then(function () { - try { - var storedClientName = getSessionStorageItem(clientNameKey) || ""; - if (storedClientName && localForm) { - var clientNameInput = localForm.querySelector("input[name=\"clientName\"]"); - if (clientNameInput) { clientNameInput.value = storedClientName; } - } - } catch (_error) {} - }); redirectIfOnboarded(deviceId).then(function (redirected) { if (redirected) { return; } if (qr && !qr.getAttribute("src")) { qr.src = qrPlaceholderSrc; } - loadQr(deviceId); - setStatus("Waiting for onboarding to finish."); + loadPairingSession(deviceId, clientId).then(function () { + loadQr(deviceId, clientId); + }); window.setInterval(function () { redirectIfOnboarded(deviceId); }, 2000); }); }()); diff --git a/src/player/player-client-name.script.html b/src/player/player-client-name.script.html index 65a8b8e..611e942 100644 --- a/src/player/player-client-name.script.html +++ b/src/player/player-client-name.script.html @@ -90,7 +90,7 @@ }); }).then(function (payload) { var serverName = payload && payload.clientName ? String(payload.clientName).trim() : ''; - if (serverName) { + if (serverName && !getOnboardingClientName()) { applyOnboardingClientName(serverName, null); } return onboardingClientName || getOnboardingClientName(); diff --git a/src/player/player-page-announcements.js b/src/player/player-page-announcements.js index 3d065ff..1102211 100644 --- a/src/player/player-page-announcements.js +++ b/src/player/player-page-announcements.js @@ -131,6 +131,9 @@ if (window.__pulsePageAuthToken) { request.setRequestHeader('x-pulse-page-auth', window.__pulsePageAuthToken); } + if (typeof getCommandClientId === 'function') { + request.setRequestHeader('x-pulse-client-id', getCommandClientId()); + } if (announcementEtag) { request.setRequestHeader('If-None-Match', announcementEtag); } diff --git a/src/player/player-page.script.html b/src/player/player-page.script.html index 957b76a..abb43be 100644 --- a/src/player/player-page.script.html +++ b/src/player/player-page.script.html @@ -1,6 +1,6 @@ diff --git a/test/admin-client-commands.test.js b/test/admin-client-commands.test.js index b1f134c..bfad052 100644 --- a/test/admin-client-commands.test.js +++ b/test/admin-client-commands.test.js @@ -32,7 +32,7 @@ test('move client rebinding redirects the live player to the target screen', asy return [[{ id: 12, name: 'Source Screen', slug: 'source-screen' }]]; } if (sql.includes('SELECT d.client_name, s.slug AS current_screen_slug')) { - return [[{ client_name: 'Lobby Client', current_screen_slug: 'source-screen' }]]; + return [[{ client_name: 'Stored Client Name', current_screen_slug: 'source-screen' }]]; } if (sql.includes('SELECT id, name, slug FROM d_screens WHERE slug = ?') && params && params[0] === 'target-screen') { return [[{ id: 27, name: 'Target Screen', slug: 'target-screen' }]]; @@ -126,6 +126,7 @@ test('move client rebinding redirects the live player to the target screen', asy body: { command: 'moveclient', deviceId: 'device-123', + clientId: 'tab-123', clientName: 'Lobby Client', targetScreenSlug: 'target-screen', connectionId: 'conn-1', @@ -136,8 +137,16 @@ test('move client rebinding redirects the live player to the target screen', asy assert.equal(response.statusCode, 200); assert.equal(response.body.ok, true); - assert.equal(response.body.targetScreenSlug, 'target-screen'); - assert.equal(response.body.playerUrl, 'http://remote-player.example/screen/target-screen'); + assert.equal(response.body.playerUrl, 'http://remote-player.example/screen/target-screen'); + assert.equal(calls.some((entry) => entry.kind === 'query' + && entry.sql.includes('SET screen_id = ?, modified_at = CURRENT_TIMESTAMP') + && entry.params[0] === 27 + && entry.params[1] === 'device-123'), false); + assert.equal(calls.some((entry) => entry.kind === 'query' + && entry.sql.includes('SET client_name = ?, screen_id = ?, modified_at = CURRENT_TIMESTAMP') + && entry.params[0] === 'Lobby Client' + && entry.params[1] === 27 + && entry.params[2] === 'tab-123'), true); assert.equal(calls.some((entry) => entry.kind === 'broadcastDashboardState'), true); assert.equal(calls.some((entry) => entry.kind === 'forwardPlayerCommand' && entry.slug === 'source-screen' && entry.payload && entry.payload.command === 'redirect'), false); assert.equal(calls.some((entry) => entry.kind === 'forwardPlayerCommandToBaseUrl' && entry.baseUrl === 'http://remote-player.example'), true); diff --git a/test/admin-page.test.js b/test/admin-page.test.js index 783bafb..8495ffc 100644 --- a/test/admin-page.test.js +++ b/test/admin-page.test.js @@ -15,4 +15,11 @@ test('async save runs success hooks before redirecting close or new saves', () = assert.ok(adminPageScript.includes('if (typeof settings.afterSuccess === \'function\')')); assert.ok(adminPageScript.includes('clearFormDirty(form);')); assert.ok(adminPageScript.includes("if (submitterValue === 'close' || submitterValue === 'new')")); +}); + +test('data-source toggles update in place instead of reloading the edit form', () => { + assert.ok(adminPageScript.includes('function updateDataSourceToggle(form, response)')); + assert.ok(adminPageScript.includes('data-async-data-source-toggle')); + assert.ok(adminPageScript.includes("showToast(message || (willEnable ? 'Data source enabled.' : 'Data source disabled.'), 'success')")); + assert.ok(!adminPageScript.includes("/^\\/data-sources\\/(?:api-sources|rss-feeds|weather)\\/\\d+$/.test(actionPath);")); }); \ No newline at end of file diff --git a/test/onboarding-device-prune.test.js b/test/onboarding-device-prune.test.js new file mode 100644 index 0000000..aaa624f --- /dev/null +++ b/test/onboarding-device-prune.test.js @@ -0,0 +1,20 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const { pruneStaleOnboardingDevices } = require('../src/db/common'); + +test('prunes old unbound onboarding devices without deleting completed bindings', async () => { + let queryText = ''; + const pool = { + async query(sql) { + queryText = sql; + return [[]]; + } + }; + + await pruneStaleOnboardingDevices(pool); + + assert.match(queryText, /modified_at < \(CURRENT_TIMESTAMP - INTERVAL 1 MINUTE\)/); + assert.match(queryText, /WHERE screen_id IS NULL/); + assert.doesNotMatch(queryText, /d_players\.identifier = d_onboarding_devices\.device_id/); +}); \ No newline at end of file diff --git a/test/player-render-helpers.test.js b/test/player-render-helpers.test.js index df695ad..f9fd626 100644 --- a/test/player-render-helpers.test.js +++ b/test/player-render-helpers.test.js @@ -11,7 +11,8 @@ const { normalizeSlide, renderEditorJsContent, renderHtmlRegionContent, - sanitizeRichText + sanitizeRichText, + getAnnouncementIconsDataScript } = require('../src/player/render-helpers'); test('mediaKind classifies player media by extension', () => { @@ -89,6 +90,10 @@ test('timetable region registers the timetable type', () => { assert.ok(timetableRegionSource.includes("sanitizeRichText(substituteTimetableVariables(value")); }); +test('player announcement bootstrap includes catalog-only icons', () => { + assert.match(getAnnouncementIconsDataScript(), /bank/); +}); + test('shared iframe renderers size preview content explicitly', () => { const playerHtmlRegionSource = fs.readFileSync(require.resolve('../src/player/regions/html.js'), 'utf8'); const playerWebpageRegionSource = fs.readFileSync(require.resolve('../src/player/regions/webpage.js'), 'utf8'); diff --git a/test/player-routes.test.js b/test/player-routes.test.js index 0638a4a..ae1c7e2 100644 --- a/test/player-routes.test.js +++ b/test/player-routes.test.js @@ -97,6 +97,84 @@ function createPlayerRouteOptions(overrides) { }, overrides); } +test('local player serves a neutral shell for direct screen URLs', async () => { + const { app, handlers } = createAppAndHandlers(); + registerPlayerRoutes(app, { + app, + pool: { + async query(sql) { + if (String(sql).includes('FROM d_onboarding_devices d')) { + return [[{ slug: 'target-screen' }]]; + } + return [[]]; + } + }, + common: { renderPlayerPage() { return 'online'; } }, + ...createPlayerRouteOptions({ + playerDeviceId: 'player-local' + }) + }); + + const movedResponse = createResponse(); + await handlers['/screen/:slug']({ params: { slug: 'target-screen' }, query: { clientId: 'tab-local' }, headers: {} }, movedResponse); + assert.equal(movedResponse.statusCode, 200); + assert.equal(movedResponse.body, 'online'); + + const oldResponse = createResponse(); + await handlers['/screen/:slug']({ params: { slug: 'old-screen' }, query: { clientId: 'tab-local' }, headers: {} }, oldResponse); + assert.equal(oldResponse.statusCode, 200); + assert.equal(oldResponse.body, 'online'); +}); + +test('remote player serves a neutral shell for direct screen URLs', async () => { + const { app, handlers } = createAppAndHandlers(); + const originalFetch = global.fetch; + global.fetch = async function (url) { + if (String(url).includes('/api/onboarding/status')) { + return { + status: 200, + headers: { get() { return 'application/json'; } }, + async json() { + return { screenId: 27, screenSlug: 'target-screen' }; + } + }; + } + return { + status: 200, + headers: { get() { return 'application/json'; } }, + async json() { + return { screen: { slug: 'target-screen' } }; + } + }; + }; + + try { + registerPlayerRoutes(app, { + app, + pool: null, + common: { renderPlayerPage() { return 'online'; } }, + ...createPlayerRouteOptions({ + bridgeBaseUrl: 'http://bridge.test', + playerDeviceId: 'player-remote' + }) + }); + + const movedResponse = createResponse(); + await handlers['/screen/:slug']({ params: { slug: 'target-screen' }, query: { clientId: 'tab-remote' }, headers: {} }, movedResponse); + await new Promise((resolve) => setImmediate(resolve)); + assert.equal(movedResponse.statusCode, 200); + assert.equal(movedResponse.body, 'online'); + + const oldResponse = createResponse(); + await handlers['/screen/:slug']({ params: { slug: 'old-screen' }, query: { clientId: 'tab-remote' }, headers: {} }, oldResponse); + await new Promise((resolve) => setImmediate(resolve)); + assert.equal(oldResponse.statusCode, 200); + assert.equal(oldResponse.body, 'online'); + } finally { + global.fetch = originalFetch; + } +}); + test('screen route reports the request origin for the player public base url', async () => { const { app, handlers } = createAppAndHandlers(); let reportedBaseUrl = null; @@ -249,20 +327,7 @@ test('screen route renders the shell when playlist data is missing', async () => assert.equal(res.statusCode, 200); assert.match(String(res.body), /Loading screen/); - assert.deepEqual(renderCalls, [ - { - slug: 'test2', - data: { - screen: { id: 7, slug: 'test2' }, - playlist: null, - slides: [], - rssFeeds: [], - apiSources: [], - timetableGroups: [], - revision: 'abc123' - } - } - ]); + assert.deepEqual(renderCalls, [{ slug: 'test2', data: null }]); }); test('playlist api route returns 404, etag, and 304 responses', async () => { diff --git a/test/schema-update-log.test.js b/test/schema-update-log.test.js index b3b7c2b..82f8b90 100644 --- a/test/schema-update-log.test.js +++ b/test/schema-update-log.test.js @@ -44,7 +44,7 @@ test('pending migrations are empty when the schema already matches the app versi } ]); - const pendingMigrations = await getPendingMigrations(pool, { currentVersion: '2.8.9' }); + const pendingMigrations = await getPendingMigrations(pool, { currentVersion: '2.10.1' }); assert.equal(pendingMigrations.length, 0); }); diff --git a/test/templates.test.js b/test/templates.test.js index 6ecbd48..e510d99 100644 --- a/test/templates.test.js +++ b/test/templates.test.js @@ -6,7 +6,8 @@ require('../src/common'); const { buildTemplatePayload, - extractTemplateRegions + extractTemplateRegions, + normalizeBackgroundGradient } = require('../src/data/templates'); const renderTemplateAddPage = require('../src/web/routes/signage/templates/add'); const renderTemplateEditPage = require('../src/web/routes/signage/templates/edit'); @@ -81,6 +82,7 @@ test('buildTemplatePayload resolves canvas size and rejects duplicate region nam canvasSizeHeight: 720, backgroundImagePath: null, backgroundColor: '#111111', + backgroundGradient: null, regions: [{ region_key: 'Header', region_type: 'text', @@ -229,4 +231,21 @@ test('template designer warns when invalid region names block save', () => { assert.ok(script.includes("templateForm.addEventListener('invalid'")); assert.ok(script.includes('function notifyRegionNameValidationError(message)')); assert.ok(script.includes('regionNameValidationToastShown = false;')); +}); + +test('normalizeBackgroundGradient accepts linear colors and clamps the angle', () => { + assert.equal(normalizeBackgroundGradient(JSON.stringify({ + type: 'radial', + colors: ['#123456', '#abcdef', '#fedcba'], + angle: 400 + })), JSON.stringify({ + type: 'linear', + stops: [ + { color: '#123456', position: 0 }, + { color: '#abcdef', position: 50 }, + { color: '#fedcba', position: 100 } + ], + angle: 360 + })); + assert.equal(normalizeBackgroundGradient('{}'), null); }); \ No newline at end of file diff --git a/test/weather-duplicate.test.js b/test/weather-duplicate.test.js new file mode 100644 index 0000000..de0fe4b --- /dev/null +++ b/test/weather-duplicate.test.js @@ -0,0 +1,49 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +const { buildDuplicateWeatherLocationName, buildDuplicateWeatherLocation } = require('../src/web/routes/data-sources/weather/duplicate'); + +test('weather location duplicate helper copies settings and resets runtime state', () => { + const location = { + id: 7, + name: 'London', + location_label: 'London, UK', + latitude: 51.5074, + longitude: -0.1278, + timezone: 'Europe/London', + provider: 'open-meteo', + temperature_unit: 'celsius', + wind_unit: 'kmh', + precipitation_unit: 'mm', + update_interval_value: 30, + update_interval_unit: 'minutes', + last_pulled_at: '2026-08-28T10:00:00.000Z', + last_pull_error: 'Bad response', + last_response_status: 500, + last_response_content_type: 'application/json', + last_response_json: '{"ok":false}' + }; + + const duplicate = buildDuplicateWeatherLocation(location, buildDuplicateWeatherLocationName(location.name)); + + assert.equal(duplicate.id, null); + assert.equal(duplicate.name, 'Copy of London'); + assert.equal(duplicate.location_label, 'London, UK'); + assert.equal(duplicate.latitude, 51.5074); + assert.equal(duplicate.longitude, -0.1278); + assert.equal(duplicate.provider, 'open-meteo'); + assert.equal(duplicate.update_interval_value, 30); + assert.equal(duplicate.last_pulled_at, null); + assert.equal(duplicate.last_pull_error, ''); + assert.equal(duplicate.last_response_status, null); + assert.equal(duplicate.last_response_json, ''); +}); + +test('weather list template includes duplicate action', () => { + const template = fs.readFileSync(path.join(__dirname, '..', 'src', 'web', 'views', 'data-sources', 'weather', 'list.hbs'), 'utf8'); + + assert.match(template, /\/data-sources\/weather\/{\{id\}\}\/duplicate/); + assert.match(template, />Dupe<\/a>/); +}); diff --git a/test/weather-forecast.test.js b/test/weather-forecast.test.js new file mode 100644 index 0000000..a86e811 --- /dev/null +++ b/test/weather-forecast.test.js @@ -0,0 +1,38 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +const { fetchWeatherLocationForecast } = require('../src/data/weather'); + +test('Open-Meteo hourly forecast starts at the current hour and spans 24 hours', async () => { + const originalFetch = global.fetch; + const originalDateNow = Date.now; + let requestUrl; + global.fetch = async function (url) { + requestUrl = new URL(url); + return { ok: true, json: async function () { return { hourly: { time: [] } }; } }; + }; + Date.now = function () { return new Date('2026-08-28T12:34:00Z').getTime(); }; + + try { + await fetchWeatherLocationForecast({ query: async function () { return [[]]; } }, { + latitude: 51.5, + longitude: -0.1, + timezone: 'Europe/London', + provider: 'open-meteo' + }); + } finally { + global.fetch = originalFetch; + Date.now = originalDateNow; + } + + assert.equal(requestUrl.searchParams.get('forecast_hours'), '24'); + assert.equal(requestUrl.searchParams.get('forecast_days'), '7'); +}); + +test('weather forecast preview only shows the fetch hint without a snapshot', () => { + const template = fs.readFileSync(path.join(__dirname, '..', 'src', 'web', 'views', 'data-sources', 'weather', 'forecast-preview.hbs'), 'utf8'); + + assert.match(template, /\{\{#unless weatherPreview\.hasSnapshot\}\}.*Daily and hourly forecasts will appear here after the first successful fetch\..*\{\{\/unless\}\}/); +}); \ No newline at end of file diff --git a/test/web-dashboard-page.test.js b/test/web-dashboard-page.test.js index 50b17b3..75bbbb3 100644 --- a/test/web-dashboard-page.test.js +++ b/test/web-dashboard-page.test.js @@ -7,7 +7,7 @@ require('../src/common'); const renderDashboardPage = require('../src/web/routes/signage/dashboard'); -test('dashboard onboarding link uses the player base url', () => { +test('dashboard pairing link uses the clients pairing permission', () => { const html = renderDashboardPage( { screens: [ @@ -22,11 +22,11 @@ test('dashboard onboarding link uses the player base url', () => { connectedPlayersCount: 0 }, '', - { id: 1, permissions: ['screens.read', 'clients.read'] } + { id: 1, permissions: ['screens.read', 'clients.read', 'pairing.allow'] } ); - assert.match(html, /href="http:\/\/player\.local"/); - assert.doesNotMatch(html, /http:\/\/player\.local\//); + assert.match(html, /href="\/pairing"/); + assert.doesNotMatch(html, /http:\/\/player\.local/); }); test('dashboard screen snapshot omits player links and duplicate connection counts', () => { @@ -200,7 +200,6 @@ test('dashboard move client button opens the move modal for the selected row', ( addEventListener() {} }; const connectionInput = { value: '' }; - const deviceInput = { value: '' }; const clientNameInput = { value: '' }; const playerBaseUrlInput = { value: '' }; const targetSelect = { value: 'alpha' }; @@ -263,9 +262,6 @@ test('dashboard move client button opens the move modal for the selected row', ( if (selector === '[data-client-move-connection-id]') { return connectionInput; } - if (selector === '[data-client-move-device-id]') { - return deviceInput; - } if (selector === '[data-client-move-client-name]') { return clientNameInput; } @@ -338,7 +334,6 @@ test('dashboard move client button opens the move modal for the selected row', ( assert.equal(targetSelect.options[0].disabled, true); assert.equal(targetSelect.options[1].disabled, false); assert.equal(connectionInput.value, 'conn-123'); - assert.equal(deviceInput.value, 'device-123'); assert.equal(clientNameInput.value, 'Lobby Client'); assert.equal(playerBaseUrlInput.value, 'http://player.local'); }); diff --git a/test/web-ui-helpers.test.js b/test/web-ui-helpers.test.js index 5c4e929..b29b797 100644 --- a/test/web-ui-helpers.test.js +++ b/test/web-ui-helpers.test.js @@ -26,8 +26,8 @@ test('client row keys prefer the client name', () => { loadScript(path.join(__dirname, '..', 'src', 'web', 'public', 'js', 'web-ui-helpers.js'), sandbox); const helpers = sandbox.window.webUiHelpers; - assert.equal(helpers.getClientRowKey({ client_name: 'Conference Left', screen_slug: 'alpha', deviceId: 'device-123', id: 'conn-1', clientId: 'client-1' }), 'Conference Left'); - assert.equal(helpers.getClientRowKey({ client_name: 'Conference Right', screen_slug: 'beta', deviceId: 'device-123', id: 'conn-2', clientId: 'client-2' }), 'Conference Right'); - assert.equal(helpers.getClientRowKey({ screen_slug: 'alpha', id: 'conn-1', clientId: 'client-1' }), 'alpha'); + assert.equal(helpers.getClientRowKey({ client_name: 'Conference Left', screen_slug: 'alpha', deviceId: 'device-123', id: 'conn-1', clientId: 'client-1' }), 'conn-1'); + assert.equal(helpers.getClientRowKey({ client_name: 'Conference Right', screen_slug: 'beta', deviceId: 'device-123', id: 'conn-2', clientId: 'client-2' }), 'conn-2'); + assert.equal(helpers.getClientRowKey({ screen_slug: 'alpha', id: 'conn-1', clientId: 'client-1' }), 'conn-1'); assert.equal(helpers.getClientRowKey({ clientId: 'client-1' }), 'client-1'); }); \ No newline at end of file