From c3b5a0053c0ebe18e819ba597887bf9840a2f8d1 Mon Sep 17 00:00:00 2001 From: Mark Rapson Date: Sat, 8 Aug 2026 20:38:44 +0100 Subject: [PATCH] Split web and player build artifacts --- .dockerignore | 2 + .github/workflows/docker-publish.yml | 16 +- CHANGELOG.md | 8 + Dockerfile | 20 -- build/Dockerfile | 17 ++ build/Dockerfile.player | 17 ++ build/package.player.json | 19 ++ build/package.web.json | 22 ++ docker-compose/.env.example | 3 +- docker-compose/.env.remote.example | 2 +- docker-compose/README.md | 10 +- docker-compose/docker-compose.remote.yml | 2 +- docker-compose/docker-compose.yml | 6 +- package.json | 2 +- src/player-bridge/index.js | 130 +++++++++- src/player.js | 245 +++++++++++++++--- src/player/onboarding/index.js | 11 +- src/player/routes.js | 10 +- src/web/lib/background-tasks/index.js | 8 +- .../background-tasks/tasks-adhoc/font-sync.js | 5 +- .../tasks-startup/font-sync.js | 79 ++++-- .../tasks-startup/media-sync.js | 77 +++++- src/web/lib/media/upload-sync.js | 88 +++++-- src/web/middleware.js | 2 +- src/web/routes/internal/sync.js | 69 ++++- src/web/routes/register.js | 1 + test/initial-font-sync-task.test.js | 62 +++++ test/initial-media-sync-task.test.js | 62 +++++ test/internal-sync-route.test.js | 158 +++++++++++ test/player-bridge-web-base-url.test.js | 14 +- test/player-routes.test.js | 40 +++ test/upload-sync.test.js | 2 +- test/web-middleware.test.js | 44 ++++ 33 files changed, 1113 insertions(+), 140 deletions(-) delete mode 100644 Dockerfile create mode 100644 build/Dockerfile create mode 100644 build/Dockerfile.player create mode 100644 build/package.player.json create mode 100644 build/package.web.json create mode 100644 test/initial-font-sync-task.test.js create mode 100644 test/initial-media-sync-task.test.js create mode 100644 test/internal-sync-route.test.js create mode 100644 test/web-middleware.test.js diff --git a/.dockerignore b/.dockerignore index c1060d4..7d7b85f 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,5 +1,7 @@ * !package.json +!build/ +!build/** !src/ !src/** !scripts/ diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 7fe71d2..2349541 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -10,6 +10,16 @@ jobs: build-and-push: runs-on: ubuntu-latest + strategy: + matrix: + include: + - name: web + image: git.lzstealth.com/lzstealth/pulse-signage-web + dockerfile: ./build/Dockerfile + - name: player + image: git.lzstealth.com/lzstealth/pulse-signage-player + dockerfile: ./build/Dockerfile.player + steps: - name: Checkout repository uses: actions/checkout@v4 @@ -28,18 +38,16 @@ jobs: id: meta uses: docker/metadata-action@v5 with: - images: git.lzstealth.com/LZStealth/pulse-signage + images: ${{ matrix.image }} tags: | type=raw,value=latest type=ref,event=tag - type=semver,pattern=v{{major}}.{{minor}} - type=semver,pattern=v{{major}} - name: Build and push image uses: docker/build-push-action@v6 with: context: . - file: ./Dockerfile + file: ${{ matrix.dockerfile }} push: true tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 4f9a2bd..a501c33 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,14 @@ All notable changes to this project will be documented in this file. +## 2.6.12 - 2026-08-08 + +### Fixed + +- The Docker build inputs now live under `build/`, with separate web and player package manifests so the player image no longer carries the web browser tooling bundle. +- Remote player sync and upload routing now carry the exact connected player device id through the bridge, so startup media and font jobs target the correct player instance in split-device deployments. +- Player websocket registration now learns the public base URL from the actual screen request origin, so localhost and 127.0.0.1 aliases both keep websocket commands working. + ## 2.6.11 - 2026-08-08 ### Fixed diff --git a/Dockerfile b/Dockerfile deleted file mode 100644 index ae01de8..0000000 --- a/Dockerfile +++ /dev/null @@ -1,20 +0,0 @@ -FROM node:24-alpine - -WORKDIR /app - -RUN apk add --no-cache ffmpeg chromium nss freetype harfbuzz ttf-freefont - -COPY package*.json ./ -RUN npm install --omit=dev --no-audit --no-fund - -COPY src ./src -COPY scripts ./scripts - -RUN mkdir -p /app/src/web/public/vendor/animate.css && cp /app/node_modules/animate.css/animate.min.css /app/src/web/public/vendor/animate.css/animate.min.css -RUN mkdir -p /app/src/player/public/vendor/animate.css && cp /app/node_modules/animate.css/animate.min.css /app/src/player/public/vendor/animate.css/animate.min.css - -RUN mkdir -p /app/media - -EXPOSE 3000 - -CMD ["npm", "run", "start:web"] diff --git a/build/Dockerfile b/build/Dockerfile new file mode 100644 index 0000000..638b506 --- /dev/null +++ b/build/Dockerfile @@ -0,0 +1,17 @@ +FROM node:24-alpine + +WORKDIR /app + +RUN apk add --no-cache chromium nss freetype harfbuzz ttf-freefont + +COPY build/package.web.json ./package.json +RUN npm install --omit=dev --no-audit --no-fund + +COPY src ./src +COPY scripts ./scripts + +RUN mkdir -p /app/media + +EXPOSE 3000 + +CMD ["npm", "run", "start:web"] \ No newline at end of file diff --git a/build/Dockerfile.player b/build/Dockerfile.player new file mode 100644 index 0000000..ff0fbee --- /dev/null +++ b/build/Dockerfile.player @@ -0,0 +1,17 @@ +FROM node:24-alpine + +WORKDIR /app + +RUN apk add --no-cache ffmpeg + +COPY build/package.player.json ./package.json +RUN npm install --omit=dev --no-audit --no-fund + +COPY src ./src +COPY scripts ./scripts + +RUN mkdir -p /app/media + +EXPOSE 3000 + +CMD ["npm", "run", "start:player"] \ No newline at end of file diff --git a/build/package.player.json b/build/package.player.json new file mode 100644 index 0000000..af8c98d --- /dev/null +++ b/build/package.player.json @@ -0,0 +1,19 @@ +{ + "name": "pulse-signage-player", + "version": "2.6.12", + "private": false, + "description": "Pulse Signage player application bundle", + "main": "src/common.js", + "scripts": { + "start": "node -r dotenv/config src/player.js", + "start:player": "node -r dotenv/config src/player.js" + }, + "dependencies": { + "dotenv": "^17.4.2", + "express": "^4.21.2", + "handlebars": "^4.7.8", + "hls.js": "^1.5.15", + "mysql2": "^3.14.3", + "ws": "^8.21.0" + } +} \ No newline at end of file diff --git a/build/package.web.json b/build/package.web.json new file mode 100644 index 0000000..100aa5c --- /dev/null +++ b/build/package.web.json @@ -0,0 +1,22 @@ +{ + "name": "pulse-signage-web", + "version": "2.6.12", + "private": false, + "description": "Pulse Signage web and bridge application bundle", + "main": "src/common.js", + "scripts": { + "start": "node -r dotenv/config src/web.js", + "start:web": "node -r dotenv/config src/web.js" + }, + "dependencies": { + "@sparticuz/chromium": "^137.0.0", + "dotenv": "^17.4.2", + "express": "^4.21.2", + "handlebars": "^4.7.8", + "multer": "^2.2.0", + "mysql2": "^3.14.3", + "puppeteer-core": "^24.16.0", + "sharp": "^0.35.3", + "ws": "^8.21.0" + } +} \ No newline at end of file diff --git a/docker-compose/.env.example b/docker-compose/.env.example index bbc954d..9b5241e 100644 --- a/docker-compose/.env.example +++ b/docker-compose/.env.example @@ -1,5 +1,6 @@ # Shared application settings -PULSE_SIGNAGE_IMAGE="git.lzstealth.com/lzstealth/pulse-signage:latest" +PULSE_SIGNAGE_WEB_IMAGE="git.lzstealth.com/lzstealth/pulse-signage-web:latest" +PULSE_SIGNAGE_PLAYER_IMAGE="git.lzstealth.com/lzstealth/pulse-signage-player:latest" PULSE_SIGNAGE_SHARED_SECRET="" # Database settings for the web, player, and bridge services diff --git a/docker-compose/.env.remote.example b/docker-compose/.env.remote.example index afcab8b..5c3b6dc 100644 --- a/docker-compose/.env.remote.example +++ b/docker-compose/.env.remote.example @@ -1,5 +1,5 @@ # Shared application settings -PULSE_SIGNAGE_IMAGE="git.lzstealth.com/lzstealth/pulse-signage:latest" +PULSE_SIGNAGE_PLAYER_IMAGE="git.lzstealth.com/lzstealth/pulse-signage-player:latest" PULSE_SIGNAGE_SHARED_SECRET="" # Player settings diff --git a/docker-compose/README.md b/docker-compose/README.md index 81b17fe..c50e5b0 100644 --- a/docker-compose/README.md +++ b/docker-compose/README.md @@ -113,7 +113,8 @@ Use this file as a starting point for the public compose stack. Important values: -- `PULSE_SIGNAGE_IMAGE` - image to run for all app services +- `PULSE_SIGNAGE_WEB_IMAGE` - image to run for the web app and bridge services, typically `.../pulse-signage-web:latest` +- `PULSE_SIGNAGE_PLAYER_IMAGE` - image to run for the player services, typically `.../pulse-signage-player:latest` - `PULSE_SIGNAGE_SHARED_SECRET` - long random secret shared by the web, player, and bridge services for authenticated requests - `PLAYER_IDENTIFIER` - unique local player identifier - `DB_*` - MySQL credentials and database name for the stack @@ -132,7 +133,7 @@ Use this file on a remote player device. Important values: -- `PULSE_SIGNAGE_IMAGE` - image to run on the device +- `PULSE_SIGNAGE_PLAYER_IMAGE` - image to run on the device, typically `.../pulse-signage-player:latest` - `PULSE_SIGNAGE_SHARED_SECRET` - must match the public stack and should be the same long random value used everywhere in the deployment - `PLAYER_IDENTIFIER` - unique remote player identifier - `PLAYER_PUBLIC_URL` - public URL for the remote player @@ -157,7 +158,8 @@ Leave it blank only if you intentionally want to run without request signing in | Variable | Used By | Purpose | | --- | --- | --- | -| `PULSE_SIGNAGE_IMAGE` | web, player, bridge, remote player | Docker image to run for the app services. | +| `PULSE_SIGNAGE_WEB_IMAGE` | web, bridge | Docker image to run for the web app and bridge services. | +| `PULSE_SIGNAGE_PLAYER_IMAGE` | player, remote player | Docker image to run for the player services. | | `PULSE_SIGNAGE_SHARED_SECRET` | web, player, bridge, remote player | Shared secret for authenticated requests between services. | | `DB_HOST` | web, player, bridge | Database host name. | | `DB_PORT` | web, player, bridge | Database port. | @@ -218,7 +220,7 @@ Each compose file creates its own named network: - A remote player must use the same `PULSE_SIGNAGE_SHARED_SECRET` as the bridge it connects to. - The bridge service is the dashboard-facing command path for connected remote players. - The remote player should point `BRIDGE_PUBLIC_URL` at the bridge, not at the public web endpoint. -- The `PULSE_SIGNAGE_IMAGE` tag defaults to the published image, but it can be overridden for local builds or custom releases. +- The `PULSE_SIGNAGE_WEB_IMAGE` and `PULSE_SIGNAGE_PLAYER_IMAGE` tags default to the published `pulse-signage-web` and `pulse-signage-player` repositories with `latest` and `v1.2.3` style tags, but they can be overridden for local builds or custom releases. ## Recommended Setup diff --git a/docker-compose/docker-compose.remote.yml b/docker-compose/docker-compose.remote.yml index 1122899..fc46ada 100644 --- a/docker-compose/docker-compose.remote.yml +++ b/docker-compose/docker-compose.remote.yml @@ -3,7 +3,7 @@ name: pulse-signage-remote services: player: - image: ${PULSE_SIGNAGE_IMAGE:-git.lzstealth.com/lzstealth/pulse-signage:latest} + image: ${PULSE_SIGNAGE_PLAYER_IMAGE:-git.lzstealth.com/lzstealth/pulse-signage-player:latest} restart: unless-stopped networks: - pulse_signage diff --git a/docker-compose/docker-compose.yml b/docker-compose/docker-compose.yml index 385d5b0..38eb9dd 100644 --- a/docker-compose/docker-compose.yml +++ b/docker-compose/docker-compose.yml @@ -2,7 +2,7 @@ name: pulse-signage services: web: - image: ${PULSE_SIGNAGE_IMAGE:-git.lzstealth.com/lzstealth/pulse-signage:latest} + image: ${PULSE_SIGNAGE_WEB_IMAGE:-git.lzstealth.com/lzstealth/pulse-signage-web:latest} restart: unless-stopped networks: - pulse_signage @@ -28,7 +28,7 @@ services: condition: service_healthy player: - image: ${PULSE_SIGNAGE_IMAGE:-git.lzstealth.com/lzstealth/pulse-signage:latest} + image: ${PULSE_SIGNAGE_PLAYER_IMAGE:-git.lzstealth.com/lzstealth/pulse-signage-player:latest} restart: unless-stopped networks: - pulse_signage @@ -52,7 +52,7 @@ services: condition: service_healthy player-bridge: - image: ${PULSE_SIGNAGE_IMAGE:-git.lzstealth.com/lzstealth/pulse-signage:latest} + image: ${PULSE_SIGNAGE_WEB_IMAGE:-git.lzstealth.com/lzstealth/pulse-signage-web:latest} restart: unless-stopped networks: - pulse_signage diff --git a/package.json b/package.json index 450a4d7..8a70d47 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "pulse-signage", - "version": "2.6.11", + "version": "2.6.12", "private": false, "description": "Pulse Signage application with MySQL and media storage", "repository": { diff --git a/src/player-bridge/index.js b/src/player-bridge/index.js index 0f9eb9e..681012e 100644 --- a/src/player-bridge/index.js +++ b/src/player-bridge/index.js @@ -92,6 +92,20 @@ function resolveSnapshotUpstreamBaseUrl(player) { return normalizeProxyBaseUrl(player && player.public_base_url) || null; } +function resolvePlayerSocketForDeviceId(playerSockets, deviceId) { + const normalizedDeviceId = normalizeDeviceId(deviceId); + if (!normalizedDeviceId || !playerSockets || typeof playerSockets.get !== 'function') { + return null; + } + + const socket = playerSockets.get(normalizedDeviceId); + if (!socket || socket.readyState !== WebSocket.OPEN) { + return null; + } + + return socket; +} + function resolveScreenCommandTargets(slug, playerSockets, screenPlayerDeviceIds) { const key = String(slug || '').trim(); if (!key || !screenPlayerDeviceIds || typeof screenPlayerDeviceIds.get !== 'function' || !playerSockets || typeof playerSockets.get !== 'function') { @@ -369,8 +383,8 @@ async function start() { return relativePath; } - function sendPlayerCommand(commandPayload) { - const socket = getConnectedPlayerSocket(); + function sendPlayerCommand(commandPayload, deviceId) { + const socket = resolvePlayerSocketForDeviceId(playerSockets, deviceId); if (!socket) { return Promise.resolve({ ok: false, status: 503, error: 'Player is not connected.' }); } @@ -513,15 +527,37 @@ async function start() { return res.status(400).json({ error: 'Filename is required' }); } + const deviceId = normalizeDeviceId(req.query.deviceId || req.query.playerIdentifier || req.headers['x-pulse-player-device-id']); + if (!deviceId) { + return res.status(400).json({ error: 'Device ID is required.' }); + } + + logBridge('Forwarding media upload to player', { + deviceId: deviceId, + relativePath: relativePath, + contentLength: Buffer.isBuffer(req.body) ? req.body.length : 0 + }); + const bodyBuffer = Buffer.isBuffer(req.body) ? req.body : Buffer.from(req.body || ''); const response = await sendPlayerCommand({ command: 'media-put', relativePath: relativePath, bodyBase64: bodyBuffer.toString('base64') + }, deviceId); + + logBridge('Player media upload completed', { + deviceId: deviceId, + relativePath: relativePath, + ok: Boolean(response && response.ok), + status: response && response.status ? response.status : null }); res.status(response.status || (response.ok ? 200 : 502)).json(response); } catch (error) { + logBridge('Player media upload failed', { + relativePath: req.params && req.params.filename ? String(req.params.filename).trim() : '', + error: error && error.message ? error.message : String(error) + }); next(error); } }); @@ -533,13 +569,34 @@ async function start() { return res.status(400).json({ error: 'Filename is required' }); } + const deviceId = normalizeDeviceId(req.query.deviceId || req.query.playerIdentifier || req.headers['x-pulse-player-device-id']); + if (!deviceId) { + return res.status(400).json({ error: 'Device ID is required.' }); + } + + logBridge('Forwarding media delete to player', { + deviceId: deviceId, + relativePath: relativePath + }); + const response = await sendPlayerCommand({ command: 'media-delete', relativePath: relativePath + }, deviceId); + + logBridge('Player media delete completed', { + deviceId: deviceId, + relativePath: relativePath, + ok: Boolean(response && response.ok), + status: response && response.status ? response.status : null }); res.status(response.status || (response.ok ? 200 : 502)).json(response); } catch (error) { + logBridge('Player media delete failed', { + relativePath: req.params && req.params.filename ? String(req.params.filename).trim() : '', + error: error && error.message ? error.message : String(error) + }); next(error); } }); @@ -1054,19 +1111,32 @@ async function start() { app.post('/api/internal/sync/player-media', requireRequestAuth, async function (_req, res, next) { try { + logBridge('Relaying player media sync request to web'); const webBaseUrl = resolveWebBaseUrl(_req); if (!webBaseUrl) { return res.status(502).json({ error: 'Web base URL is not configured.' }); } + const requestBody = _req.body && typeof _req.body === 'object' && !Array.isArray(_req.body) + ? Object.assign({}, _req.body) + : {}; + const response = await fetch(`${webBaseUrl}/api/internal/sync/player-media`, { method: 'POST', headers: Object.assign({ - Accept: 'application/json' + Accept: 'application/json', + 'Content-Type': 'application/json' }, createRequestAuthHeaders({ method: 'POST', - pathname: '/api/internal/sync/player-media' - })) + pathname: '/api/internal/sync/player-media', + body: requestBody + })), + body: JSON.stringify(requestBody) + }); + + logBridge('Web player media sync response received', { + ok: Boolean(response && response.ok), + status: response && response.status ? response.status : null }); res.status(response.status); @@ -1076,6 +1146,53 @@ async function start() { } res.send(await response.text()); } catch (error) { + logBridge('Player media sync relay failed', { + error: error && error.message ? error.message : String(error) + }); + next(error); + } + }); + + app.post('/api/internal/sync/player-font', requireRequestAuth, async function (_req, res, next) { + try { + logBridge('Relaying player font sync request to web'); + const webBaseUrl = resolveWebBaseUrl(_req); + if (!webBaseUrl) { + return res.status(502).json({ error: 'Web base URL is not configured.' }); + } + + const requestBody = _req.body && typeof _req.body === 'object' && !Array.isArray(_req.body) + ? Object.assign({}, _req.body) + : {}; + + const response = await fetch(`${webBaseUrl}/api/internal/sync/player-font`, { + method: 'POST', + headers: Object.assign({ + Accept: 'application/json', + 'Content-Type': 'application/json' + }, createRequestAuthHeaders({ + method: 'POST', + pathname: '/api/internal/sync/player-font', + body: requestBody + })), + body: JSON.stringify(requestBody) + }); + + logBridge('Web player font sync response received', { + ok: Boolean(response && response.ok), + status: response && response.status ? response.status : null + }); + + res.status(response.status); + const contentType = response.headers.get('content-type'); + if (contentType) { + res.type(contentType); + } + res.send(await response.text()); + } catch (error) { + logBridge('Player font sync relay failed', { + error: error && error.message ? error.message : String(error) + }); next(error); } }); @@ -1091,7 +1208,8 @@ module.exports = { start: start, resolveWebBaseUrl: resolveWebBaseUrl, resolveScreenCommandTargets: resolveScreenCommandTargets, - resolveSnapshotUpstreamBaseUrl: resolveSnapshotUpstreamBaseUrl + resolveSnapshotUpstreamBaseUrl: resolveSnapshotUpstreamBaseUrl, + resolvePlayerSocketForDeviceId: resolvePlayerSocketForDeviceId }; if (require.main === module) { diff --git a/src/player.js b/src/player.js index 737fc9e..d582169 100644 --- a/src/player.js +++ b/src/player.js @@ -30,8 +30,12 @@ async function start() { const MEDIA_DIR = path.join(__dirname, '..', 'media'); const ONBOARDING_QUEUE_FILE = path.join(MEDIA_DIR, 'player-onboarding-queue.json'); const DB_SYNC_INTERVAL_MS = Number(process.env.PLAYER_DB_SYNC_INTERVAL_MS || 15000); + const RECONNECT_SYNC_STALE_MS = 60 * 1000; const onboardingStore = createOnboardingStore(ONBOARDING_QUEUE_FILE); let thinClientSocket = null; + let lastDisconnectAt = 0; + let playerPublicBaseUrl = PLAYER_PUBLIC_URL || null; + let refreshThinClientRegistration = null; const playerRuntime = createPlayerRuntime({ pool: pool, normalizeDeviceId: normalizeDeviceId, @@ -67,6 +71,35 @@ async function start() { let hasLoggedPlayerStartup = false; + function normalizePlayerPublicBaseUrl(value) { + const normalized = String(value || '').trim().replace(/\/$/, ''); + if (!normalized) { + return null; + } + + try { + return new URL(normalized).origin.replace(/\/$/, ''); + } catch (_error) { + return normalized; + } + } + + function setPlayerPublicBaseUrl(value) { + const nextBaseUrl = normalizePlayerPublicBaseUrl(value); + if (!nextBaseUrl || nextBaseUrl === playerPublicBaseUrl) { + return; + } + + playerPublicBaseUrl = nextBaseUrl; + if (typeof refreshThinClientRegistration === 'function') { + refreshThinClientRegistration(); + } + } + + function getPlayerPublicBaseUrl() { + return playerPublicBaseUrl; + } + function logPlayerStartup(connectionState) { if (hasLoggedPlayerStartup) { return; @@ -76,7 +109,7 @@ async function start() { console.info('[player] startup', { mode: isRemotePlayer ? 'bridge client' : 'local', connected: connectionState && typeof connectionState.connected === 'boolean' ? connectionState.connected : false, - publicBaseUrl: PLAYER_PUBLIC_URL || null, + publicBaseUrl: getPlayerPublicBaseUrl(), bridgeBaseUrl: PLAYER_INTERNAL_URL || null, bridgeWebSocketUrl: BRIDGE_PUBLIC_URL ? createThinClientWebSocketUrl() : null }); @@ -104,25 +137,96 @@ async function start() { return false; } + const requestBody = { + playerIdentifier: PLAYER_DEVICE_ID, + playerPublicBaseUrl: getPlayerPublicBaseUrl(), + playerInternalBaseUrl: PLAYER_INTERNAL_URL + }; + try { + console.info('[player] Triggering startup media sync', { + playerIdentifier: PLAYER_DEVICE_ID, + bridgeBaseUrl: BRIDGE_PUBLIC_URL + }); const authHeaders = createRequestAuthHeaders({ method: 'POST', - pathname: '/api/internal/sync/player-media' + pathname: '/api/internal/sync/player-media', + body: requestBody }); const response = await fetch(`${BRIDGE_PUBLIC_URL}/api/internal/sync/player-media`, { method: 'POST', headers: Object.assign({ - Accept: 'application/json' - }, authHeaders) + Accept: 'application/json', + 'Content-Type': 'application/json' + }, authHeaders), + body: JSON.stringify(requestBody) + }); + + console.info('[player] Startup media sync response', { + ok: Boolean(response && response.ok), + status: response && response.status ? response.status : null }); return Boolean(response && response.ok); } catch (_error) { + console.warn('[player] Startup media sync failed'); + return false; + } + } + + async function triggerWebFontSync() { + if (!isRemotePlayer || !BRIDGE_PUBLIC_URL) { + return false; + } + + const requestBody = { + playerIdentifier: PLAYER_DEVICE_ID, + playerPublicBaseUrl: getPlayerPublicBaseUrl(), + playerInternalBaseUrl: PLAYER_INTERNAL_URL + }; + + try { + console.info('[player] Triggering startup font sync', { + playerIdentifier: PLAYER_DEVICE_ID, + bridgeBaseUrl: BRIDGE_PUBLIC_URL + }); + const authHeaders = createRequestAuthHeaders({ + method: 'POST', + pathname: '/api/internal/sync/player-font', + body: requestBody + }); + const response = await fetch(`${BRIDGE_PUBLIC_URL}/api/internal/sync/player-font`, { + method: 'POST', + headers: Object.assign({ + Accept: 'application/json', + 'Content-Type': 'application/json' + }, authHeaders), + body: JSON.stringify(requestBody) + }); + + console.info('[player] Startup font sync response', { + ok: Boolean(response && response.ok), + status: response && response.status ? response.status : null + }); + + return Boolean(response && response.ok); + } catch (_error) { + console.warn('[player] Startup font sync failed'); return false; } } let webMediaSyncCompleted = false; + let webFontSyncCompleted = false; + let webFontSyncTriggered = false; + + function shouldTriggerReconnectSync() { + if (!lastDisconnectAt) { + return true; + } + + return Date.now() - lastDisconnectAt >= RECONNECT_SYNC_STALE_MS; + } async function handleThinClientCommand(socket, rawMessage) { let payload = null; @@ -153,9 +257,18 @@ async function start() { response.error = 'Invalid media payload.'; } else { const bodyBuffer = Buffer.from(bodyBase64, 'base64'); + console.info('[player] Writing media file', { + relativePath: relativePath, + filePath: filePath, + bytes: bodyBuffer.length + }); await fs.promises.mkdir(path.dirname(filePath), { recursive: true }); await fs.promises.writeFile(filePath, bodyBuffer); response.ok = true; + console.info('[player] Media file written', { + relativePath: relativePath, + filePath: filePath + }); } } else if (command === 'media-delete') { const relativePath = String(payload.relativePath || payload.filename || '').trim(); @@ -163,6 +276,10 @@ async function start() { if (!filePath) { response.error = 'Invalid media path.'; } else { + console.info('[player] Removing media file', { + relativePath: relativePath, + filePath: filePath + }); try { await fs.promises.unlink(filePath); } catch (error) { @@ -171,6 +288,10 @@ async function start() { } } response.ok = true; + console.info('[player] Media file removed', { + relativePath: relativePath, + filePath: filePath + }); } } else if (['refresh', 'reload', 'redirect', 'pause', 'blackout', 'previous', 'next', 'setclientname'].indexOf(command) !== -1) { const screenSlug = String(payload.screenSlug || payload.slug || '').trim(); @@ -212,7 +333,7 @@ async function start() { common: common, playerRuntime: playerRuntime, onboardingStore: onboardingStore, - playerPublicBaseUrl: PLAYER_PUBLIC_URL, + playerPublicBaseUrl: getPlayerPublicBaseUrl(), playerInternalBaseUrl: PLAYER_INTERNAL_URL, bridgeBaseUrl: BRIDGE_PUBLIC_URL, playerDeviceId: PLAYER_DEVICE_ID @@ -225,10 +346,10 @@ async function start() { playerRuntime: playerRuntime, playerPlaylistService: playerPlaylistService, rtmpStreamService: rtmpStreamService, - playerPublicBaseUrl: PLAYER_PUBLIC_URL, playerInternalBaseUrl: PLAYER_INTERNAL_URL, bridgeBaseUrl: BRIDGE_PUBLIC_URL, - playerDeviceId: PLAYER_DEVICE_ID + playerDeviceId: PLAYER_DEVICE_ID, + onPlayerPublicBaseUrl: setPlayerPublicBaseUrl }); function createThinClientWebSocketUrl() { @@ -276,6 +397,54 @@ async function start() { }); thinClientSocket = socket; + function sendHeartbeat() { + if (!socket || socket.readyState !== WebSocket.OPEN) { + return; + } + + socket.send(JSON.stringify({ + type: 'heartbeat', + deviceId: PLAYER_DEVICE_ID, + publicBaseUrl: getPlayerPublicBaseUrl(), + internalBaseUrl: PLAYER_INTERNAL_URL + })); + } + + refreshThinClientRegistration = sendHeartbeat; + + function triggerMediaSyncIfNeeded() { + if (webMediaSyncTriggered || webMediaSyncCompleted) { + return; + } + + webMediaSyncTriggered = true; + triggerWebMediaSync().then(function (success) { + webMediaSyncCompleted = Boolean(success) || webMediaSyncCompleted; + if (!success) { + webMediaSyncTriggered = false; + } + }).catch(function () { + webMediaSyncTriggered = false; + }); + } + + function triggerFontSyncIfNeeded() { + if (webFontSyncTriggered || webFontSyncCompleted) { + return; + } + + webFontSyncTriggered = true; + triggerWebFontSync().then(function (success) { + webFontSyncCompleted = Boolean(success) || webFontSyncCompleted; + if (!success) { + webFontSyncTriggered = false; + } + }).catch(function () { + webFontSyncTriggered = false; + webFontSyncCompleted = false; + }); + } + function sendSnapshot(slug) { if (!socket || socket.readyState !== WebSocket.OPEN) { return; @@ -300,7 +469,7 @@ async function start() { socket.send(JSON.stringify({ type: 'register', deviceId: PLAYER_DEVICE_ID, - publicBaseUrl: PLAYER_PUBLIC_URL, + publicBaseUrl: getPlayerPublicBaseUrl(), internalBaseUrl: PLAYER_INTERNAL_URL })); @@ -308,38 +477,35 @@ async function start() { sendSnapshot(slug); }); - if (!webMediaSyncCompleted) { - triggerWebMediaSync().then(function (success) { - webMediaSyncTriggered = Boolean(success); - webMediaSyncCompleted = Boolean(success) || webMediaSyncCompleted; - }).catch(function () { - webMediaSyncTriggered = false; - }); - } - heartbeatTimer = setInterval(function () { - if (!socket || socket.readyState !== WebSocket.OPEN) { - return; - } - socket.send(JSON.stringify({ - type: 'heartbeat', - deviceId: PLAYER_DEVICE_ID, - publicBaseUrl: PLAYER_PUBLIC_URL, - internalBaseUrl: PLAYER_INTERNAL_URL - })); - - if (!webMediaSyncTriggered && !webMediaSyncCompleted) { - triggerWebMediaSync().then(function (success) { - webMediaSyncTriggered = Boolean(success); - webMediaSyncCompleted = Boolean(success) || webMediaSyncCompleted; - }).catch(function () { - webMediaSyncTriggered = false; - }); - } + sendHeartbeat(); }, DB_SYNC_INTERVAL_MS); }); socket.on('message', function (rawMessage) { + let parsedMessage = null; + try { + parsedMessage = JSON.parse(String(rawMessage || '{}')); + } catch (_error) { + parsedMessage = null; + } + + if (parsedMessage && String(parsedMessage.type || '').trim() === 'registered') { + console.info('[player] Bridge registration acknowledged', { + playerIdentifier: PLAYER_DEVICE_ID + }); + sendHeartbeat(); + return; + } + + if (parsedMessage && String(parsedMessage.type || '').trim() === 'heartbeat-ack') { + if (shouldTriggerReconnectSync()) { + triggerMediaSyncIfNeeded(); + triggerFontSyncIfNeeded(); + } + return; + } + handleThinClientCommand(socket, rawMessage).catch(function (error) { try { socket.send(JSON.stringify({ @@ -355,8 +521,13 @@ async function start() { }); socket.on('close', function () { + lastDisconnectAt = Date.now(); + webFontSyncTriggered = false; + webFontSyncCompleted = false; + webMediaSyncCompleted = false; clearTimers(); thinClientSocket = null; + refreshThinClientRegistration = null; reconnectTimer = setTimeout(connect, PLAYER_AGENT_RECONNECT_DELAY_MS); }); @@ -403,7 +574,7 @@ async function start() { try { await recordPlayerHeartbeat(pool, { deviceId: PLAYER_DEVICE_ID, - publicBaseUrl: PLAYER_PUBLIC_URL, + publicBaseUrl: getPlayerPublicBaseUrl(), internalBaseUrl: PLAYER_INTERNAL_URL }).catch(function (error) { console.error(error); @@ -432,7 +603,7 @@ async function start() { if (PLAYER_DEVICE_ID && !isRemotePlayer) { const { upsertPlayerRegistration } = require('./player/onboarding'); - await upsertPlayerRegistration(pool, PLAYER_DEVICE_ID, PLAYER_PUBLIC_URL, PLAYER_INTERNAL_URL).catch(function (error) { + await upsertPlayerRegistration(pool, PLAYER_DEVICE_ID, getPlayerPublicBaseUrl(), PLAYER_INTERNAL_URL).catch(function (error) { console.error(error); }); } diff --git a/src/player/onboarding/index.js b/src/player/onboarding/index.js index e1010b2..8465235 100644 --- a/src/player/onboarding/index.js +++ b/src/player/onboarding/index.js @@ -16,15 +16,16 @@ function normalizeDeviceId(value) { } function getPublicBaseUrl(req, configuredUrl) { - const configured = String(configuredUrl || process.env.PLAYER_PUBLIC_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(/\/$/, ''); + if (host) { + return `${protocol}://${host}`.replace(/\/$/, ''); + } + + const configured = String(configuredUrl || process.env.PLAYER_PUBLIC_URL || process.env.PLAYER_BASE_URL || '').trim().replace(/\/$/, ''); + return configured || null; } function getRequestIp(req) { diff --git a/src/player/routes.js b/src/player/routes.js index efb88f2..dde588e 100644 --- a/src/player/routes.js +++ b/src/player/routes.js @@ -4,6 +4,7 @@ const fs = require('fs'); const express = require('express'); const path = require('path'); const { getSharedSecret, createPageAuthBundle, verifyPageAuthToken, verifyRequestAuth, createRequestAuthHeaders } = require('#src/request-auth'); +const { getPlayerPublicBaseUrl } = require('./onboarding'); const { buildThumbnailPreviewData } = require('./thumbnail-preview'); const TRANSIENT_DB_ERROR_CODES = ['ECONNREFUSED', 'ECONNRESET', 'ETIMEDOUT', 'EPIPE', 'ENOTFOUND', 'PROTOCOL_CONNECTION_LOST', 'POOL_CLOSED', 'ERR_POOL_CLOSED']; @@ -31,10 +32,10 @@ function registerPlayerRoutes(app, options) { const playerRuntime = options && options.playerRuntime ? options.playerRuntime : null; const playerPlaylistService = options && options.playerPlaylistService ? options.playerPlaylistService : null; const rtmpStreamService = options && options.rtmpStreamService ? options.rtmpStreamService : null; - const playerPublicUrl = String(options && options.playerPublicBaseUrl || process.env.PLAYER_PUBLIC_URL || process.env.PLAYER_BASE_URL || '').trim().replace(/\/$/, ''); const playerInternalUrl = String(options && options.playerInternalBaseUrl || process.env.PLAYER_INTERNAL_URL || process.env.PLAYER_BASE_URL || '').trim().replace(/\/$/, ''); const bridgeBaseUrl = String(options && options.bridgeBaseUrl || process.env.BRIDGE_PUBLIC_URL || '').trim().replace(/\/$/, ''); const playerDeviceId = String(options && options.playerDeviceId || '').trim() || null; + const onPlayerPublicBaseUrl = typeof options.onPlayerPublicBaseUrl === 'function' ? options.onPlayerPublicBaseUrl : null; if (!app || !common || !mediaDir || !assetDir || !playerRuntime || !rtmpStreamService) { throw new Error('registerPlayerRoutes requires app, common, mediaDir, assetDir, playerRuntime, and rtmpStreamService.'); @@ -296,6 +297,13 @@ function registerPlayerRoutes(app, options) { }); app.get('/screen/:slug', function (req, res) { + if (onPlayerPublicBaseUrl) { + try { + onPlayerPublicBaseUrl(getPlayerPublicBaseUrl(req, null)); + } catch (_error) { + } + } + res.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate'); res.set('Pragma', 'no-cache'); if (bridgeBaseUrl) { diff --git a/src/web/lib/background-tasks/index.js b/src/web/lib/background-tasks/index.js index 2b57cd6..b12b654 100644 --- a/src/web/lib/background-tasks/index.js +++ b/src/web/lib/background-tasks/index.js @@ -52,7 +52,13 @@ function registerStartupTasks(options) { } async function initialize() { - await loadTaskModules(backgroundTaskDirectory, options); + const startupTaskFile = path.join(backgroundTaskDirectory, 'data-source-refresh.js'); + const taskModule = require(startupTaskFile); + const exported = getTaskExport(taskModule); + + if (typeof exported === 'function') { + await exported(options); + } } return { diff --git a/src/web/lib/background-tasks/tasks-adhoc/font-sync.js b/src/web/lib/background-tasks/tasks-adhoc/font-sync.js index 7d4191c..170169a 100644 --- a/src/web/lib/background-tasks/tasks-adhoc/font-sync.js +++ b/src/web/lib/background-tasks/tasks-adhoc/font-sync.js @@ -9,6 +9,7 @@ function registerFontSyncTask(options) { backgroundTaskQueue.setTaskHandler('font-sync', async function (task) { const payload = task && task.payload ? task.payload : task || {}; const uploadDir = String(payload.uploadDir || '').trim(); + const playerIdentifier = String(payload.playerIdentifier || payload.deviceId || '').trim(); const operations = Array.isArray(payload.operations) ? payload.operations : Array.isArray(payload.uploadPaths) @@ -28,9 +29,9 @@ function registerFontSyncTask(options) { continue; } if (String(operation.type || '').trim().toLowerCase() === 'delete') { - await uploadSyncService.removeUploadFileFromPlayer(uploadPath, uploadDir); + await uploadSyncService.removeUploadFileFromPlayer(uploadPath, uploadDir, undefined, playerIdentifier); } else { - await uploadSyncService.pushUploadFileToPlayer(uploadPath, uploadDir); + await uploadSyncService.pushUploadFileToPlayer(uploadPath, uploadDir, undefined, playerIdentifier); } } }); diff --git a/src/web/lib/background-tasks/tasks-startup/font-sync.js b/src/web/lib/background-tasks/tasks-startup/font-sync.js index b415342..00a9aba 100644 --- a/src/web/lib/background-tasks/tasks-startup/font-sync.js +++ b/src/web/lib/background-tasks/tasks-startup/font-sync.js @@ -1,16 +1,40 @@ const { collectFontLibrarySyncOperations } = require('../../media/font-library'); +const { fetchPlayerRegistrations } = require('#src/data/player-registry'); const TASK = { key: 'initial-font-sync', category: 'fonts' }; +function isRecentPlayerRegistration(player, staleSeconds) { + const lastSeenAt = player && player.last_seen_at; + const lastSeenAtValue = lastSeenAt instanceof Date ? lastSeenAt.getTime() : new Date(lastSeenAt).getTime(); + const cutoffTime = Date.now() - Math.max(30, Number(staleSeconds || 60)) * 1000; + + return Number.isFinite(lastSeenAtValue) && lastSeenAtValue >= cutoffTime; +} + +function normalizePlayerMetadata(player) { + const playerIdentifier = String(player && player.identifier || '').trim(); + const playerPublicBaseUrl = String(player && player.public_base_url || '').trim(); + const playerInternalBaseUrl = String(player && player.internal_base_url || '').trim(); + + return { + playerIdentifier: playerIdentifier || null, + playerPublicBaseUrl: playerPublicBaseUrl || null, + playerInternalBaseUrl: playerInternalBaseUrl || null, + playerLabel: playerIdentifier || playerPublicBaseUrl || playerInternalBaseUrl || null, + playerActive: true + }; +} + function registerInitialFontSyncTask(options) { + const pool = options && options.pool; const backgroundTaskQueue = options && options.backgroundTaskQueue; const mediaDir = String(options && options.mediaDir || '').trim(); const uploadSyncService = options && options.uploadSyncService; - if (!backgroundTaskQueue || !mediaDir) { + if (!pool || !backgroundTaskQueue || !mediaDir) { throw new Error('registerInitialFontSyncTask requires the initial font sync dependencies.'); } @@ -18,20 +42,45 @@ function registerInitialFontSyncTask(options) { ? uploadSyncService.getPlayerTaskMetadata() : Promise.resolve({}); - return Promise.resolve(metadataPromise).then(function (metadata) { - return backgroundTaskQueue.enqueueTask({ - key: TASK.key, - title: 'Initial font sync', - category: TASK.category, - taskType: 'font-sync', - metadata: Object.assign({}, metadata || {}), - payload: { - mode: 'initial', - uploadDir: mediaDir, - operations: collectFontLibrarySyncOperations(mediaDir) - }, - persist: true - }); + return Promise.resolve(metadataPromise).then(async function () { + let players = []; + try { + players = await fetchPlayerRegistrations(pool); + } catch (error) { + console.warn('Unable to fetch player registrations for initial font sync:', error); + return null; + } + + const livePlayers = Array.isArray(players) + ? players.filter(function (player) { + return isRecentPlayerRegistration(player, 60); + }) + : []; + + if (!livePlayers.length) { + return null; + } + + const operations = collectFontLibrarySyncOperations(mediaDir); + return Promise.all(livePlayers.map(function (player) { + const metadata = normalizePlayerMetadata(player); + return backgroundTaskQueue.enqueueTask({ + key: TASK.key, + title: 'Initial font sync', + category: TASK.category, + taskType: 'font-sync', + metadata: metadata, + payload: { + mode: 'initial', + uploadDir: mediaDir, + operations: operations, + playerIdentifier: metadata.playerIdentifier, + playerPublicBaseUrl: metadata.playerPublicBaseUrl, + playerInternalBaseUrl: metadata.playerInternalBaseUrl + }, + persist: true + }); + })); }).catch(function (error) { console.warn('Unable to queue initial font sync:', error); }); diff --git a/src/web/lib/background-tasks/tasks-startup/media-sync.js b/src/web/lib/background-tasks/tasks-startup/media-sync.js index 56e3cd6..94ec988 100644 --- a/src/web/lib/background-tasks/tasks-startup/media-sync.js +++ b/src/web/lib/background-tasks/tasks-startup/media-sync.js @@ -3,12 +3,37 @@ const TASK = { category: 'media-sync', }; +const { fetchPlayerRegistrations } = require('#src/data/player-registry'); + +function isRecentPlayerRegistration(player, staleSeconds) { + const lastSeenAt = player && player.last_seen_at; + const lastSeenAtValue = lastSeenAt instanceof Date ? lastSeenAt.getTime() : new Date(lastSeenAt).getTime(); + const cutoffTime = Date.now() - Math.max(30, Number(staleSeconds || 60)) * 1000; + + return Number.isFinite(lastSeenAtValue) && lastSeenAtValue >= cutoffTime; +} + +function normalizePlayerMetadata(player) { + const playerIdentifier = String(player && player.identifier || '').trim(); + const playerPublicBaseUrl = String(player && player.public_base_url || '').trim(); + const playerInternalBaseUrl = String(player && player.internal_base_url || '').trim(); + + return { + playerIdentifier: playerIdentifier || null, + playerPublicBaseUrl: playerPublicBaseUrl || null, + playerInternalBaseUrl: playerInternalBaseUrl || null, + playerLabel: playerIdentifier || playerPublicBaseUrl || playerInternalBaseUrl || null, + playerActive: true + }; +} + function registerInitialMediaSyncTask(options) { + const pool = options && options.pool; const backgroundTaskQueue = options && options.backgroundTaskQueue; const mediaDir = String(options && options.mediaDir || '').trim(); const uploadSyncService = options && options.uploadSyncService; - if (!backgroundTaskQueue || !mediaDir) { + if (!pool || !backgroundTaskQueue || !mediaDir) { throw new Error('registerInitialMediaSyncTask requires the initial media sync dependencies.'); } @@ -16,19 +41,43 @@ function registerInitialMediaSyncTask(options) { ? uploadSyncService.getPlayerTaskMetadata() : Promise.resolve({}); - return Promise.resolve(metadataPromise).then(function (metadata) { - return backgroundTaskQueue.enqueueTask({ - key: TASK.key, - title: 'Initial media sync', - category: TASK.category, - taskType: 'media-sync', - metadata: Object.assign({}, metadata || {}), - payload: { - mode: 'initial', - uploadDir: mediaDir - }, - persist: true - }); + return Promise.resolve(metadataPromise).then(async function () { + let players = []; + try { + players = await fetchPlayerRegistrations(pool); + } catch (error) { + console.warn('Unable to fetch player registrations for initial media sync:', error); + return null; + } + + const livePlayers = Array.isArray(players) + ? players.filter(function (player) { + return isRecentPlayerRegistration(player, 60); + }) + : []; + + if (!livePlayers.length) { + return null; + } + + return Promise.all(livePlayers.map(function (player) { + const metadata = normalizePlayerMetadata(player); + return backgroundTaskQueue.enqueueTask({ + key: TASK.key, + title: 'Initial media sync', + category: TASK.category, + taskType: 'media-sync', + metadata: metadata, + payload: { + mode: 'initial', + uploadDir: mediaDir, + playerIdentifier: metadata.playerIdentifier, + playerPublicBaseUrl: metadata.playerPublicBaseUrl, + playerInternalBaseUrl: metadata.playerInternalBaseUrl + }, + persist: true + }); + })); }).catch(function (error) { console.warn('Unable to queue initial media sync:', error); }); diff --git a/src/web/lib/media/upload-sync.js b/src/web/lib/media/upload-sync.js index 1f551b3..098ecd7 100644 --- a/src/web/lib/media/upload-sync.js +++ b/src/web/lib/media/upload-sync.js @@ -36,6 +36,22 @@ function normalizeBaseUrl(value) { return String(value || '').trim().replace(/\/$/, ''); } +function appendPlayerDeviceIdToUrl(baseUrl, playerIdentifier) { + const targetBaseUrl = normalizeBaseUrl(baseUrl); + const deviceId = String(playerIdentifier || '').trim(); + if (!targetBaseUrl || !deviceId) { + return targetBaseUrl; + } + + try { + const url = new URL(targetBaseUrl); + url.searchParams.set('deviceId', deviceId); + return url.toString().replace(/\/$/, ''); + } catch (_error) { + return targetBaseUrl; + } +} + function normalizePlayerRowBaseUrl(player) { return normalizeBaseUrl(player && player.internal_base_url); } @@ -74,8 +90,8 @@ function createUploadSyncService(options) { throw new Error('createUploadSyncService requires the upload dependencies.'); } - async function getPlayerInternalBaseUrl() { - const metadata = await getPlayerTaskMetadata(); + async function getPlayerInternalBaseUrl(preferredPlayerIdentifier, preferredPlayerInternalBaseUrl) { + const metadata = await getPlayerTaskMetadata(preferredPlayerIdentifier, preferredPlayerInternalBaseUrl); if (!metadata || metadata.playerActive === false) { return null; } @@ -83,11 +99,44 @@ function createUploadSyncService(options) { return metadata.playerInternalBaseUrl ? metadata.playerInternalBaseUrl : null; } - async function getPlayerTaskMetadata() { - if (playerTaskMetadata) { + async function getPlayerTaskMetadata(preferredPlayerIdentifier, preferredPlayerInternalBaseUrl) { + const normalizedPreferredPlayerIdentifier = String(preferredPlayerIdentifier || '').trim(); + const normalizedPreferredPlayerInternalBaseUrl = String(preferredPlayerInternalBaseUrl || '').trim().replace(/\/$/, ''); + + if (normalizedPreferredPlayerIdentifier && pool && typeof fetchPlayerRegistrations === 'function') { + try { + const players = await fetchPlayerRegistrations(pool); + const registeredPlayers = Array.isArray(players) ? players : []; + const exactPlayer = registeredPlayers.find(function (player) { + return String(player && player.identifier || '').trim() === normalizedPreferredPlayerIdentifier; + }) || null; + if (exactPlayer) { + const resolvedInternalBaseUrl = normalizePlayerRowBaseUrl(exactPlayer) || normalizedPreferredPlayerInternalBaseUrl || null; + const resolvedPublicBaseUrl = normalizeBaseUrl(exactPlayer && exactPlayer.public_base_url); + const resolvedIdentifier = String(exactPlayer && exactPlayer.identifier || '').trim(); + playerInternalBaseUrl = resolvedInternalBaseUrl || null; + playerTaskMetadata = { + playerIdentifier: resolvedIdentifier || normalizedPreferredPlayerIdentifier || null, + playerPublicBaseUrl: resolvedPublicBaseUrl || null, + playerInternalBaseUrl: resolvedInternalBaseUrl || null, + playerLabel: resolvedIdentifier || resolvedPublicBaseUrl || resolvedInternalBaseUrl || null, + playerActive: true + }; + return playerTaskMetadata; + } + } catch (_error) { + } + } + + if (playerTaskMetadata && playerTaskMetadata.playerActive !== false) { return playerTaskMetadata; } + if (playerTaskMetadata && playerTaskMetadata.playerActive === false) { + playerTaskMetadata = null; + playerInternalBaseUrl = null; + } + if (playerTaskMetadataPromise) { return playerTaskMetadataPromise; } @@ -421,7 +470,8 @@ function createUploadSyncService(options) { pendingPlayerUploadSyncs.set(normalizeUploadReference(operation.uploadPath), { type: operation.type === 'delete' ? 'delete' : 'put', uploadPath: normalizeUploadReference(operation.uploadPath), - uploadDir: operation.uploadDir || null + uploadDir: operation.uploadDir || null, + metadata: operation.metadata || null }); schedulePendingPlayerUploadSyncFlush(); @@ -443,7 +493,7 @@ function createUploadSyncService(options) { } } - async function pushUploadFileToPlayer(uploadPath, localUploadDir, resolvedPlayerInternalBaseUrl) { + async function pushUploadFileToPlayer(uploadPath, localUploadDir, resolvedPlayerInternalBaseUrl, preferredPlayerIdentifier) { if (!uploadPath || !shouldMirrorUploads(localUploadDir)) { return false; } @@ -453,6 +503,7 @@ function createUploadSyncService(options) { return false; } + const playerMetadata = await getPlayerTaskMetadata(preferredPlayerIdentifier, targetBaseUrl); const relativePath = getUploadRelativePath(uploadPath); const sourcePath = resolveUploadFilePath(localUploadDir, uploadPath); if (!relativePath || !sourcePath) { @@ -474,7 +525,8 @@ function createUploadSyncService(options) { pathname: `/api/media/${encodeURIComponent(relativePath)}`, body: fileBuffer }); - const response = await fetch(`${targetBaseUrl}/api/media/${encodeURIComponent(relativePath)}`, { + const mediaUploadUrl = appendPlayerDeviceIdToUrl(`${targetBaseUrl}/api/media/${encodeURIComponent(relativePath)}`, playerMetadata && playerMetadata.playerIdentifier); + const response = await fetch(mediaUploadUrl, { method: 'PUT', headers: { 'Content-Type': 'application/octet-stream', @@ -497,7 +549,7 @@ function createUploadSyncService(options) { } } - async function removeUploadFileFromPlayer(uploadPath, localUploadDir, resolvedPlayerInternalBaseUrl) { + async function removeUploadFileFromPlayer(uploadPath, localUploadDir, resolvedPlayerInternalBaseUrl, preferredPlayerIdentifier) { if (!uploadPath || !shouldMirrorUploads(localUploadDir)) { return false; } @@ -507,6 +559,7 @@ function createUploadSyncService(options) { return false; } + const playerMetadata = await getPlayerTaskMetadata(preferredPlayerIdentifier, targetBaseUrl); const relativePath = getUploadRelativePath(uploadPath); if (!relativePath) { return false; @@ -516,7 +569,8 @@ function createUploadSyncService(options) { method: 'DELETE', pathname: `/api/media/${encodeURIComponent(relativePath)}` }); - const response = await fetch(`${targetBaseUrl}/api/media/${encodeURIComponent(relativePath)}`, { + const mediaDeleteUrl = appendPlayerDeviceIdToUrl(`${targetBaseUrl}/api/media/${encodeURIComponent(relativePath)}`, playerMetadata && playerMetadata.playerIdentifier); + const response = await fetch(mediaDeleteUrl, { method: 'DELETE', headers: { Accept: 'application/json', @@ -744,16 +798,16 @@ function createUploadSyncService(options) { } const resolvedPlayerInternalBaseUrl = playerMetadata && playerMetadata.playerInternalBaseUrl ? playerMetadata.playerInternalBaseUrl - : await getPlayerInternalBaseUrl(); + : await getPlayerInternalBaseUrl(playerMetadata && playerMetadata.playerIdentifier, playerMetadata && playerMetadata.playerInternalBaseUrl); let successCount = 0; let failureCount = 0; for (let i = 0; i < pendingEntries.length; i += 1) { const operation = pendingEntries[i]; let success = false; if (operation.type === 'delete') { - success = await removeUploadFileFromPlayer(operation.uploadPath, operation.uploadDir, resolvedPlayerInternalBaseUrl); + success = await removeUploadFileFromPlayer(operation.uploadPath, operation.uploadDir, resolvedPlayerInternalBaseUrl, playerMetadata && playerMetadata.playerIdentifier); } else { - success = await pushUploadFileToPlayer(operation.uploadPath, operation.uploadDir, resolvedPlayerInternalBaseUrl); + success = await pushUploadFileToPlayer(operation.uploadPath, operation.uploadDir, resolvedPlayerInternalBaseUrl, playerMetadata && playerMetadata.playerIdentifier); } if (success) { successCount += 1; @@ -795,6 +849,8 @@ function createUploadSyncService(options) { return; } + const playerMetadata = await getPlayerTaskMetadata(taskPayload.playerIdentifier, taskPayload.playerInternalBaseUrl); + const data = await common.fetchAdminData(pool); const uploadRefs = new Set(); (data.slides || []).forEach(function (slide) { @@ -812,7 +868,8 @@ function createUploadSyncService(options) { queuePlayerUploadSync({ type: 'put', uploadPath: uploadPath, - uploadDir: uploadDir + uploadDir: uploadDir, + metadata: playerMetadata }); }); fontLibraryOperations.forEach(function (operation) { @@ -823,7 +880,8 @@ function createUploadSyncService(options) { queuePlayerUploadSync({ type: String(operation.type || 'put').trim().toLowerCase() === 'delete' ? 'delete' : 'put', uploadPath: operation.uploadPath, - uploadDir: uploadDir + uploadDir: uploadDir, + metadata: playerMetadata }); }); await flushPendingPlayerUploadSyncs(); @@ -870,7 +928,7 @@ function createUploadSyncService(options) { safePayload.operation = Object.assign({}, safePayload.operation); delete safePayload.operation.pool; } - const playerMetadata = await getPlayerTaskMetadata(); + const playerMetadata = await getPlayerTaskMetadata(safePayload.playerIdentifier, safePayload.playerInternalBaseUrl); const definition = { key: taskKey, diff --git a/src/web/middleware.js b/src/web/middleware.js index 05faa22..bc4c989 100644 --- a/src/web/middleware.js +++ b/src/web/middleware.js @@ -29,7 +29,7 @@ module.exports = function registerMiddleware(app, deps) { }); app.use(function (req, res, next) { - if (req.path === '/' || req.path === '/login' || req.path === '/logout' || req.path.indexOf('/api/internal/slide-thumbnails/') === 0 || req.path === '/api/internal/sync/player-media') { + if (req.path === '/' || req.path === '/login' || req.path === '/logout' || req.path.indexOf('/api/internal/slide-thumbnails/') === 0 || req.path === '/api/internal/sync/player-media' || req.path === '/api/internal/sync/player-font') { return next(); } diff --git a/src/web/routes/internal/sync.js b/src/web/routes/internal/sync.js index 470ca10..ab74fd4 100644 --- a/src/web/routes/internal/sync.js +++ b/src/web/routes/internal/sync.js @@ -1,6 +1,7 @@ // Internal sync trigger routes for player-driven queue flushes. const { verifyRequestAuth } = require('#src/request-auth'); +const { collectFontLibrarySyncOperations } = require('#src/web/lib/media/font-library'); function requireRequestAuth(req, res, next) { if (!verifyRequestAuth(req)) { @@ -11,21 +12,77 @@ function requireRequestAuth(req, res, next) { } module.exports = function registerInternalSyncRoutes(app, deps) { + const backgroundTaskQueue = deps && deps.backgroundTaskQueue; const uploadSyncService = deps && deps.uploadSyncService; const mediaDir = String(deps && deps.mediaDir || '').trim(); - if (!uploadSyncService || typeof uploadSyncService.flushPendingPlayerUploadSyncs !== 'function' || typeof uploadSyncService.runMediaSyncTask !== 'function' || !mediaDir) { + if (!backgroundTaskQueue || typeof backgroundTaskQueue.enqueueTask !== 'function' || !uploadSyncService || !mediaDir) { throw new Error('registerInternalSyncRoutes requires the sync dependencies.'); } app.post('/api/internal/sync/player-media', requireRequestAuth, async function (_req, res, next) { try { - await uploadSyncService.runMediaSyncTask({ - mode: 'initial', - uploadDir: mediaDir + const requestBody = _req.body && typeof _req.body === 'object' && !Array.isArray(_req.body) + ? _req.body + : {}; + const playerIdentifier = String(requestBody.playerIdentifier || requestBody.deviceId || '').trim(); + const playerPublicBaseUrl = String(requestBody.playerPublicBaseUrl || '').trim(); + const playerInternalBaseUrl = String(requestBody.playerInternalBaseUrl || '').trim(); + const task = await backgroundTaskQueue.enqueueTask({ + key: 'player-media-sync', + title: 'Player media sync', + category: 'media-sync', + taskType: 'media-sync', + metadata: { + playerIdentifier: playerIdentifier || null, + playerPublicBaseUrl: playerPublicBaseUrl || null, + playerInternalBaseUrl: playerInternalBaseUrl || null, + playerLabel: playerIdentifier || playerPublicBaseUrl || playerInternalBaseUrl || null + }, + payload: { + mode: 'initial', + uploadDir: mediaDir, + playerIdentifier: playerIdentifier, + playerPublicBaseUrl: playerPublicBaseUrl, + playerInternalBaseUrl: playerInternalBaseUrl + } }); - await uploadSyncService.flushPendingPlayerUploadSyncs(); - res.json({ ok: true }); + res.status(202).json({ ok: true, queued: true, task: task }); + } catch (error) { + next(error); + } + }); + + app.post('/api/internal/sync/player-font', requireRequestAuth, async function (_req, res, next) { + try { + const requestBody = _req.body && typeof _req.body === 'object' && !Array.isArray(_req.body) + ? _req.body + : {}; + const playerIdentifier = String(requestBody.playerIdentifier || requestBody.deviceId || '').trim(); + const playerPublicBaseUrl = String(requestBody.playerPublicBaseUrl || '').trim(); + const playerInternalBaseUrl = String(requestBody.playerInternalBaseUrl || '').trim(); + const operations = collectFontLibrarySyncOperations(mediaDir); + const task = await backgroundTaskQueue.enqueueTask({ + key: 'player-font-sync', + title: 'Player font sync', + category: 'fonts', + taskType: 'font-sync', + metadata: { + playerIdentifier: playerIdentifier || null, + playerPublicBaseUrl: playerPublicBaseUrl || null, + playerInternalBaseUrl: playerInternalBaseUrl || null, + playerLabel: playerIdentifier || playerPublicBaseUrl || playerInternalBaseUrl || null + }, + payload: { + mode: 'initial', + uploadDir: mediaDir, + operations: operations, + playerIdentifier: playerIdentifier, + playerPublicBaseUrl: playerPublicBaseUrl, + playerInternalBaseUrl: playerInternalBaseUrl + } + }); + res.status(202).json({ ok: true, queued: true, task: task }); } catch (error) { next(error); } diff --git a/src/web/routes/register.js b/src/web/routes/register.js index 8d4a667..1151ed9 100644 --- a/src/web/routes/register.js +++ b/src/web/routes/register.js @@ -25,6 +25,7 @@ function registerRoutes(app, deps) { registerSignageRoutes(app, deps); registerSettingsAndContentRoutes(app, deps); registerInternalSyncRoutes(app, { + backgroundTaskQueue: deps.backgroundTaskQueue, uploadSyncService: deps.uploadSyncService, mediaDir: deps.mediaDir }); diff --git a/test/initial-font-sync-task.test.js b/test/initial-font-sync-task.test.js new file mode 100644 index 0000000..6801f06 --- /dev/null +++ b/test/initial-font-sync-task.test.js @@ -0,0 +1,62 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); + +require('../src/common'); + +const { registerInitialFontSyncTask } = require('../src/web/lib/background-tasks/tasks-startup/font-sync'); + +test('initial font sync queues a separate task for each live player', async () => { + const queuedTasks = []; + const liveLastSeenAt = new Date(Date.now() - 10_000).toISOString(); + const staleLastSeenAt = new Date(Date.now() - 5 * 60 * 1000).toISOString(); + + await registerInitialFontSyncTask({ + pool: { + async query() { + return [[ + { + identifier: 'player-one', + public_base_url: 'https://player-one.example', + internal_base_url: 'http://player-one:8081', + last_seen_at: liveLastSeenAt + }, + { + identifier: 'player-two', + public_base_url: 'https://player-two.example', + internal_base_url: 'http://player-two:8081', + last_seen_at: liveLastSeenAt + }, + { + identifier: 'player-stale', + public_base_url: 'https://player-stale.example', + internal_base_url: 'http://player-stale:8081', + last_seen_at: staleLastSeenAt + } + ]]; + } + }, + backgroundTaskQueue: { + async enqueueTask(definition) { + queuedTasks.push(definition); + return definition; + } + }, + mediaDir: 'e:/Projects Git/pulse-signage/media', + uploadSyncService: { + async getPlayerTaskMetadata() { + return {}; + } + } + }); + + assert.equal(queuedTasks.length, 2); + assert.deepEqual(queuedTasks.map(function (task) { + return task.metadata.playerIdentifier; + }).sort(), ['player-one', 'player-two']); + assert.deepEqual(queuedTasks.map(function (task) { + return task.payload.playerIdentifier; + }).sort(), ['player-one', 'player-two']); + assert.ok(queuedTasks.every(function (task) { + return task.key === 'initial-font-sync' && task.title === 'Initial font sync' && task.taskType === 'font-sync'; + })); +}); \ No newline at end of file diff --git a/test/initial-media-sync-task.test.js b/test/initial-media-sync-task.test.js new file mode 100644 index 0000000..820fbf0 --- /dev/null +++ b/test/initial-media-sync-task.test.js @@ -0,0 +1,62 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); + +require('../src/common'); + +const { registerInitialMediaSyncTask } = require('../src/web/lib/background-tasks/tasks-startup/media-sync'); + +test('initial media sync queues a separate task for each live player', async () => { + const queuedTasks = []; + const liveLastSeenAt = new Date(Date.now() - 10_000).toISOString(); + const staleLastSeenAt = new Date(Date.now() - 5 * 60 * 1000).toISOString(); + + await registerInitialMediaSyncTask({ + pool: { + async query() { + return [[ + { + identifier: 'player-one', + public_base_url: 'https://player-one.example', + internal_base_url: 'http://player-one:8081', + last_seen_at: liveLastSeenAt + }, + { + identifier: 'player-two', + public_base_url: 'https://player-two.example', + internal_base_url: 'http://player-two:8081', + last_seen_at: liveLastSeenAt + }, + { + identifier: 'player-stale', + public_base_url: 'https://player-stale.example', + internal_base_url: 'http://player-stale:8081', + last_seen_at: staleLastSeenAt + } + ]]; + } + }, + backgroundTaskQueue: { + async enqueueTask(definition) { + queuedTasks.push(definition); + return definition; + } + }, + mediaDir: 'e:/Projects Git/pulse-signage/media', + uploadSyncService: { + async getPlayerTaskMetadata() { + return {}; + } + } + }); + + assert.equal(queuedTasks.length, 2); + assert.deepEqual(queuedTasks.map(function (task) { + return task.metadata.playerIdentifier; + }).sort(), ['player-one', 'player-two']); + assert.deepEqual(queuedTasks.map(function (task) { + return task.payload.playerIdentifier; + }).sort(), ['player-one', 'player-two']); + assert.ok(queuedTasks.every(function (task) { + return task.key === 'initial-media-sync' && task.title === 'Initial media sync' && task.taskType === 'media-sync'; + })); +}); \ No newline at end of file diff --git a/test/internal-sync-route.test.js b/test/internal-sync-route.test.js new file mode 100644 index 0000000..98e98c6 --- /dev/null +++ b/test/internal-sync-route.test.js @@ -0,0 +1,158 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const express = require('express'); + +require('../src/common'); + +const { createRequestAuthHeaders } = require('../src/request-auth'); +const registerInternalSyncRoutes = require('../src/web/routes/internal/sync'); + +test('player media sync requests are queued as background tasks', async () => { + const originalSecret = process.env.PULSE_SIGNAGE_SHARED_SECRET; + process.env.PULSE_SIGNAGE_SHARED_SECRET = 'test-shared-secret'; + + const queuedTasks = []; + const app = express(); + app.use(express.json()); + + registerInternalSyncRoutes(app, { + backgroundTaskQueue: { + async enqueueTask(definition) { + queuedTasks.push(definition); + return { + id: 42, + key: definition.key, + taskType: definition.taskType, + title: definition.title, + category: definition.category, + status: 'queued', + createdAt: new Date().toISOString(), + startedAt: '', + finishedAt: '', + errorMessage: '', + attempts: 0, + metadata: definition.metadata, + payload: definition.payload + }; + } + }, + uploadSyncService: {}, + mediaDir: 'e:/Projects Git/pulse-signage/media' + }); + + app.use(function (error, _req, res, _next) { + res.status(error.statusCode || 500).json({ error: String(error && error.message ? error.message : error) }); + }); + + const server = app.listen(0); + + try { + const address = server.address(); + const body = { + deviceId: 'player-remote', + playerPublicBaseUrl: 'https://player.example.test', + playerInternalBaseUrl: 'http://player-bridge:8090' + }; + const headers = Object.assign({ + 'content-type': 'application/json' + }, createRequestAuthHeaders({ + method: 'POST', + pathname: '/api/internal/sync/player-media', + body: body + })); + + const response = await fetch(`http://127.0.0.1:${address.port}/api/internal/sync/player-media`, { + method: 'POST', + headers: headers, + body: JSON.stringify(body) + }); + + assert.equal(response.status, 202); + assert.equal(queuedTasks.length, 1); + assert.equal(queuedTasks[0].key, 'player-media-sync'); + assert.equal(queuedTasks[0].taskType, 'media-sync'); + assert.equal(queuedTasks[0].payload.mode, 'initial'); + assert.equal(queuedTasks[0].payload.playerIdentifier, 'player-remote'); + assert.equal(queuedTasks[0].metadata.playerLabel, 'player-remote'); + } finally { + await new Promise(function (resolve) { + server.close(resolve); + }); + process.env.PULSE_SIGNAGE_SHARED_SECRET = originalSecret; + } +}); + +test('player font sync requests are queued as background tasks', async () => { + const originalSecret = process.env.PULSE_SIGNAGE_SHARED_SECRET; + process.env.PULSE_SIGNAGE_SHARED_SECRET = 'test-shared-secret'; + + const queuedTasks = []; + const app = express(); + app.use(express.json()); + + registerInternalSyncRoutes(app, { + backgroundTaskQueue: { + async enqueueTask(definition) { + queuedTasks.push(definition); + return { + id: 43, + key: definition.key, + taskType: definition.taskType, + title: definition.title, + category: definition.category, + status: 'queued', + createdAt: new Date().toISOString(), + startedAt: '', + finishedAt: '', + errorMessage: '', + attempts: 0, + metadata: definition.metadata, + payload: definition.payload + }; + } + }, + uploadSyncService: {}, + mediaDir: 'e:/Projects Git/pulse-signage/media' + }); + + app.use(function (error, _req, res, _next) { + res.status(error.statusCode || 500).json({ error: String(error && error.message ? error.message : error) }); + }); + + const server = app.listen(0); + + try { + const address = server.address(); + const body = { + deviceId: 'player-remote', + playerPublicBaseUrl: 'https://player.example.test', + playerInternalBaseUrl: 'http://player-bridge:8090' + }; + const headers = Object.assign({ + 'content-type': 'application/json' + }, createRequestAuthHeaders({ + method: 'POST', + pathname: '/api/internal/sync/player-font', + body: body + })); + + const response = await fetch(`http://127.0.0.1:${address.port}/api/internal/sync/player-font`, { + method: 'POST', + headers: headers, + body: JSON.stringify(body) + }); + + assert.equal(response.status, 202); + assert.equal(queuedTasks.length, 1); + assert.equal(queuedTasks[0].key, 'player-font-sync'); + assert.equal(queuedTasks[0].taskType, 'font-sync'); + assert.equal(queuedTasks[0].payload.mode, 'initial'); + assert.equal(queuedTasks[0].payload.playerIdentifier, 'player-remote'); + assert.equal(queuedTasks[0].metadata.playerLabel, 'player-remote'); + } finally { + await new Promise(function (resolve) { + server.close(resolve); + }); + process.env.PULSE_SIGNAGE_SHARED_SECRET = originalSecret; + } +}); \ No newline at end of file diff --git a/test/player-bridge-web-base-url.test.js b/test/player-bridge-web-base-url.test.js index 6e3b41f..5ec3a5e 100644 --- a/test/player-bridge-web-base-url.test.js +++ b/test/player-bridge-web-base-url.test.js @@ -4,7 +4,7 @@ const assert = require('node:assert/strict'); require('../src/common'); const originalWebBaseUrl = process.env.WEB_INTERNAL_URL; -const { resolveWebBaseUrl, resolveScreenCommandTargets, resolveSnapshotUpstreamBaseUrl } = require('../src/player-bridge/index'); +const { resolveWebBaseUrl, resolveScreenCommandTargets, resolveSnapshotUpstreamBaseUrl, resolvePlayerSocketForDeviceId } = require('../src/player-bridge/index'); test.after(() => { if (originalWebBaseUrl === undefined) { @@ -87,4 +87,16 @@ test('resolveSnapshotUpstreamBaseUrl falls back to the public player url for rem public_base_url: 'https://remote-player.example', internal_base_url: 'https://pulse-dev-bridge.lzstealth.com' }), 'https://remote-player.example'); +}); + +test('resolvePlayerSocketForDeviceId only returns the exact connected player socket', () => { + const playerSocketA = { readyState: 1 }; + const playerSocketB = { readyState: 1 }; + const playerSockets = new Map([ + ['player-a', playerSocketA], + ['player-b', playerSocketB] + ]); + + assert.equal(resolvePlayerSocketForDeviceId(playerSockets, 'player-b'), playerSocketB); + assert.equal(resolvePlayerSocketForDeviceId(playerSockets, 'player-missing'), null); }); \ No newline at end of file diff --git a/test/player-routes.test.js b/test/player-routes.test.js index 70768c5..0638a4a 100644 --- a/test/player-routes.test.js +++ b/test/player-routes.test.js @@ -97,6 +97,46 @@ function createPlayerRouteOptions(overrides) { }, overrides); } +test('screen route reports the request origin for the player public base url', async () => { + const { app, handlers } = createAppAndHandlers(); + let reportedBaseUrl = null; + + registerPlayerRoutes(app, { + app, + pool: { async query() { return [[{ id: 1 }]]; } }, + common: { renderPlayerPage() { return ''; } }, + ...createPlayerRouteOptions({ + playerPlaylistService: { + async buildScreenPlaylist() { + return { + screen: { id: 7, slug: 'test2' }, + playlist: null, + slides: [], + rssFeeds: [], + apiSources: [], + timetableGroups: [], + revision: 'abc123' + }; + } + }, + onPlayerPublicBaseUrl(value) { + reportedBaseUrl = value; + } + }) + }); + + const handler = handlers['/screen/:slug']; + const res = createResponse(); + + await handler({ + params: { slug: 'test2' }, + headers: { host: '127.0.0.1:8080' }, + socket: { encrypted: false } + }, res); + + assert.equal(reportedBaseUrl, 'http://127.0.0.1:8080'); +}); + test('screen route falls back to offline rendering when playlist build fails', async () => { const { app, handlers } = createAppAndHandlers(); const renderCalls = []; diff --git a/test/upload-sync.test.js b/test/upload-sync.test.js index a4dfbc8..c670ecc 100644 --- a/test/upload-sync.test.js +++ b/test/upload-sync.test.js @@ -99,7 +99,7 @@ test('fqdn player registration wins over a local configured player target for me assert.equal(success, true); assert.equal(fetchCalls.length, 1); - assert.equal(fetchCalls[0].url, 'https://pulse-dev-bridge.lzstealth.com/api/media/uploads%2Fsample.bin'); + assert.equal(fetchCalls[0].url, 'https://pulse-dev-bridge.lzstealth.com/api/media/uploads%2Fsample.bin?deviceId=player-remote'); } finally { global.fetch = originalFetch; fs.rmSync(uploadDir, { recursive: true, force: true }); diff --git a/test/web-middleware.test.js b/test/web-middleware.test.js new file mode 100644 index 0000000..437fd01 --- /dev/null +++ b/test/web-middleware.test.js @@ -0,0 +1,44 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const path = require('path'); + +const registerMiddleware = require('../src/web/middleware'); + +test('web middleware bypasses auth for internal player sync routes', () => { + const calls = []; + const app = { + middlewares: [], + use(...args) { + this.middlewares.push(args); + } + }; + + registerMiddleware(app, { + pool: {}, + loadCurrentUser: async () => null, + requireAuth(req, _res, next) { + calls.push(req.path); + next(); + }, + MEDIA_DIR: path.join(process.cwd(), 'media'), + UPLOADS_DIR: path.join(process.cwd(), 'media', 'uploads-test'), + THUMBNAILS_DIR: path.join(process.cwd(), 'media', 'thumbnails-test'), + ASSET_DIR: path.join(process.cwd(), 'src', 'web', 'public') + }); + + const authGate = app.middlewares[app.middlewares.length - 1][0]; + const nextCalls = []; + + authGate({ path: '/api/internal/sync/player-media' }, {}, function () { + nextCalls.push('media'); + }); + authGate({ path: '/api/internal/sync/player-font' }, {}, function () { + nextCalls.push('font'); + }); + authGate({ path: '/dashboard' }, {}, function () { + nextCalls.push('dashboard'); + }); + + assert.deepEqual(nextCalls, ['media', 'font', 'dashboard']); + assert.deepEqual(calls, ['/dashboard']); +}); \ No newline at end of file