Files
pulse-signage/src/player/routes.js
T
lzstealth 3f4f57020a
Publish Docker Image / build-and-push-existing-registry (./build/Dockerfile, web, pulse-signage-web) (push) Successful in 1m13s
Publish Docker Image / build-and-push-existing-registry (./build/Dockerfile.player, player, pulse-signage-player) (push) Successful in 31s
Release v2.10.3
2026-08-29 01:56:45 +01:00

692 lines
26 KiB
JavaScript

// Player route registration and filesystem-backed media helpers.
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 TRANSIENT_DB_ERROR_CODES = ['ECONNREFUSED', 'ECONNRESET', 'ETIMEDOUT', 'EPIPE', 'ENOTFOUND', 'PROTOCOL_CONNECTION_LOST', 'POOL_CLOSED', 'ERR_POOL_CLOSED'];
function isTransientDbError(error) {
return Boolean(error && TRANSIENT_DB_ERROR_CODES.indexOf(String(error.code || '').trim()) !== -1);
}
function isBridgeFetchError(error) {
const message = String(error && error.message || '').toLowerCase();
return Boolean(error && (
message.indexOf('fetch failed') !== -1 ||
message.indexOf('network error') !== -1 ||
message.indexOf('econnreset') !== -1 ||
message.indexOf('econnrefused') !== -1 ||
message.indexOf('enotfound') !== -1
));
}
function getRequestClientId(req) {
const headerClientId = String(req && req.headers && req.headers['x-pulse-client-id'] || '').trim();
if (headerClientId) {
return headerClientId;
}
const queryClientId = String(req && req.query && req.query.clientId || '').trim();
if (queryClientId) {
return queryClientId;
}
const cookieHeader = String(req && req.headers && req.headers.cookie || '');
const cookie = cookieHeader.split(';').map(function (part) {
const separator = part.indexOf('=');
return separator === -1 ? null : [part.slice(0, separator).trim(), part.slice(separator + 1).trim()];
}).filter(Boolean).find(function (entry) { return entry[0] === 'pulse-player-client-id'; });
return cookie ? decodeURIComponent(cookie[1]) : '';
}
function registerPlayerRoutes(app, options) {
const pool = options && options.pool ? options.pool : null;
const common = options && options.common ? options.common : null;
const mediaDir = options && options.mediaDir ? options.mediaDir : null;
const assetDir = options && options.assetDir ? options.assetDir : null;
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 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 snapshotDir = options && options.snapshotDir ? path.resolve(String(options.snapshotDir)) : 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.');
}
if (!bridgeBaseUrl && (!pool || !playerPlaylistService)) {
throw new Error('registerPlayerRoutes requires pool and playerPlaylistService unless bridgeBaseUrl is configured.');
}
const sharedSecret = getSharedSecret();
async function fetchBridge(req, pathname, options) {
if (!bridgeBaseUrl) {
return null;
}
const requestOptions = options && typeof options === 'object' ? options : {};
const method = String(requestOptions.method || req.method || 'GET').trim().toUpperCase();
const body = Object.prototype.hasOwnProperty.call(requestOptions, 'body') ? requestOptions.body : undefined;
const requestPathname = String(pathname || '').split('?')[0];
const headers = Object.assign({}, requestOptions.headers || {}, createRequestAuthHeaders({
method: method,
pathname: requestPathname,
body: body
}));
const requestHeaders = req && req.headers ? req.headers : {};
if (requestHeaders['x-pulse-page-auth']) {
headers['x-pulse-page-auth'] = String(requestHeaders['x-pulse-page-auth']).trim();
}
if (requestHeaders['if-none-match']) {
headers['if-none-match'] = String(requestHeaders['if-none-match']).trim();
}
if (requestHeaders['x-pulse-client-id']) {
headers['x-pulse-client-id'] = String(requestHeaders['x-pulse-client-id']).trim();
} else {
const clientId = getRequestClientId(req);
if (clientId) {
headers['x-pulse-client-id'] = clientId;
}
}
if (requestOptions.contentType) {
headers['content-type'] = requestOptions.contentType;
}
return fetch(new URL(pathname, bridgeBaseUrl).toString(), {
method: method,
headers: headers,
body: body === undefined || body === null || method === 'GET' || method === 'HEAD' ? undefined : body
});
}
async function readJsonResponse(response) {
if (!response) {
return null;
}
const contentType = String(response.headers && typeof response.headers.get === 'function' ? response.headers.get('content-type') : '').toLowerCase();
if (contentType.indexOf('application/json') === -1 && contentType.indexOf('+json') === -1) {
return null;
}
try {
return await response.json();
} catch (_error) {
return null;
}
}
async function getBoundScreenSlug(req) {
if (!playerDeviceId) {
return null;
}
const clientId = String(req.query && req.query.clientId || '').trim();
if (!clientId) {
return null;
}
const bindingId = clientId;
if (bridgeBaseUrl) {
const response = await fetchBridge(req, '/api/onboarding/status?deviceId=' + encodeURIComponent(bindingId), {
method: 'GET'
});
const status = await readJsonResponse(response);
return status && status.screenSlug ? String(status.screenSlug).trim() : null;
}
const [rows] = await pool.query(
`SELECT s.slug
FROM d_onboarding_devices d
JOIN d_screens s ON s.id = d.screen_id
WHERE d.device_id = ?`,
[bindingId]
);
return rows[0] && rows[0].slug ? String(rows[0].slug).trim() : null;
}
async function isClientAuthorizedForScreen(req, requestedSlug) {
const clientId = getRequestClientId(req);
if (!clientId) {
return false;
}
const boundSlug = await getBoundScreenSlug({ query: { clientId: clientId } });
return boundSlug === String(requestedSlug || '').trim();
}
function isAuthorizedScreenMove(req, requestedSlug) {
const queryToken = String(req.query && req.query.moveToken || '').trim();
const cookieHeader = String(req.headers && req.headers.cookie || '');
const cookieToken = cookieHeader.split(';').map(function (part) {
const separator = part.indexOf('=');
return separator === -1 ? null : [part.slice(0, separator).trim(), part.slice(separator + 1).trim()];
}).filter(Boolean).find(function (entry) { return entry[0] === 'pulse-screen-move'; });
const token = queryToken || (cookieToken ? decodeURIComponent(cookieToken[1]) : '');
if (!token) {
return false;
}
const payload = verifyPageAuthToken(token);
return Boolean(payload
&& String(payload.scope || '').trim() === 'screen-move'
&& String(payload.playerId || '').trim() === String(playerDeviceId || '').trim()
&& String(payload.screenSlug || '').trim() === String(requestedSlug || '').trim());
}
app.post('/api/screen-move-authorize', express.json(), function (req, res) {
const token = String(req.body && req.body.moveToken || '').trim();
const payload = verifyPageAuthToken(token);
if (!payload
|| String(payload.scope || '').trim() !== 'screen-move'
|| String(payload.playerId || '').trim() !== String(playerDeviceId || '').trim()) {
return res.status(401).json({ error: 'Invalid screen move authorization.' });
}
res.setHeader('Set-Cookie', `pulse-screen-move=${encodeURIComponent(token)}; Max-Age=60; Path=/; HttpOnly; SameSite=Lax`);
return res.json({ ok: true });
});
function requirePageAuth(allowedScopes) {
return function (req, res, next) {
if (!sharedSecret) {
return next();
}
const token = String(req.headers['x-pulse-page-auth'] || '').trim();
const payload = verifyPageAuthToken(token);
if (!payload) {
return res.status(401).json({ error: 'Page authentication required.' });
}
const scopes = Array.isArray(allowedScopes) ? allowedScopes : [];
if (scopes.length && scopes.indexOf(String(payload.scope || '').trim()) === -1) {
return res.status(403).json({ error: 'Page authentication scope is not allowed for this route.' });
}
req.playerPageAuth = payload;
next();
};
}
function requireRequestAuth(req, res, next) {
if (!sharedSecret) {
return next();
}
if (!verifyRequestAuth(req)) {
return res.status(401).json({ error: 'Request authentication required.' });
}
next();
}
function resolveMediaFilePath(fileName) {
const relativePath = path.normalize(String(fileName || '').trim()).replace(/^([\\/])+/, '');
if (!relativePath || relativePath === '.' || relativePath.startsWith('..') || path.isAbsolute(relativePath)) {
return null;
}
const resolvedMediaDir = path.resolve(mediaDir);
const resolvedFilePath = path.resolve(mediaDir, relativePath);
if (resolvedFilePath !== resolvedMediaDir && !resolvedFilePath.startsWith(resolvedMediaDir + path.sep)) {
return null;
}
return resolvedFilePath;
}
function getSnapshotFilePath(slug) {
const normalizedSlug = String(slug || '').trim();
return snapshotDir && normalizedSlug ? path.join(snapshotDir, `${normalizedSlug}.json`) : null;
}
async function readPlaylistSnapshot(slug) {
const filePath = getSnapshotFilePath(slug);
if (!filePath) {
return null;
}
try {
return JSON.parse(await fs.promises.readFile(filePath, 'utf8'));
} catch (_error) {
return null;
}
}
async function writePlaylistSnapshot(slug, payload) {
const filePath = getSnapshotFilePath(slug);
if (!filePath || !payload) {
return;
}
await fs.promises.mkdir(path.dirname(filePath), { recursive: true });
await fs.promises.writeFile(filePath, JSON.stringify(payload, null, 2), 'utf8');
}
app.use('/assets', express.static(assetDir));
app.use('/assets/adminlte/bootstrap-icons', express.static(path.join(__dirname, '..', 'web', 'public', 'adminlte', 'bootstrap-icons')));
app.use('/media', express.static(mediaDir));
app.use('/assets/vendor', express.static(path.join(__dirname, '..', '..', 'node_modules', 'hls.js', 'dist')));
app.get('/sw.js', function (_req, res) {
res.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
res.type('application/javascript');
res.sendFile(path.join(__dirname, 'public', 'sw.js'));
});
app.post('/api/auth/page', function (req, res, next) {
try {
if (!sharedSecret) {
return res.status(404).json({ error: 'Page authentication is disabled.' });
}
const token = String(req.headers['x-pulse-page-auth'] || '').trim();
const payload = verifyPageAuthToken(token);
if (!payload || ['player', 'onboarding'].indexOf(String(payload.scope || '').trim()) === -1) {
return res.status(401).json({ error: 'Page authentication required.' });
}
const tokenBundle = createPageAuthBundle({
scope: payload.scope,
slug: payload.slug || null,
deviceId: payload.deviceId || null
});
res.json(tokenBundle);
} catch (error) {
next(error);
}
});
app.get('/api/media/config', requireRequestAuth, function (_req, res) {
if (bridgeBaseUrl) {
void fetch(new URL('/api/media/config', bridgeBaseUrl).toString(), {
method: 'GET',
headers: createRequestAuthHeaders({
method: 'GET',
pathname: '/api/media/config'
})
}).then(async function (response) {
res.status(response.status);
const contentType = response.headers.get('content-type');
if (contentType) {
res.type(contentType);
}
res.send(await response.text());
}).catch(function (_error) {
res.status(502).json({ error: 'Thin client unavailable.' });
});
return;
}
res.json({
mediaDir: mediaDir,
uploadDir: path.join(mediaDir, 'uploads')
});
});
app.put('/api/media/:filename', express.raw({ type: '*/*', limit: '1gb' }), requireRequestAuth, async function (req, res, next) {
try {
const filePath = resolveMediaFilePath(req.params.filename);
if (!filePath) {
return res.status(400).json({ error: 'Filename is required' });
}
const body = Buffer.isBuffer(req.body) ? req.body : Buffer.from(req.body || '');
await fs.promises.mkdir(path.dirname(filePath), { recursive: true });
await fs.promises.writeFile(filePath, body);
res.json({ ok: true, filename: req.params.filename });
} catch (error) {
next(error);
}
});
app.delete('/api/media/:filename', requireRequestAuth, async function (req, res, next) {
try {
const filePath = resolveMediaFilePath(req.params.filename);
if (!filePath) {
return res.status(400).json({ error: 'Filename is required' });
}
try {
await fs.promises.unlink(filePath);
} catch (error) {
if (!error || error.code !== 'ENOENT') {
throw error;
}
}
res.json({ ok: true, filename: req.params.filename });
} catch (error) {
next(error);
}
});
app.get('/api/rtmp/session', requirePageAuth(['player']), async function (req, res, next) {
try {
const source = String(req.query.source || '').trim();
const disableAudio = String(req.query.disableAudio || '').trim().toLowerCase();
const useMutedOutput = disableAudio === '1' || disableAudio === 'true' || disableAudio === 'yes' || disableAudio === 'on';
const status = await rtmpStreamService.getSessionStatus(source, useMutedOutput);
const session = status.session;
const ready = Boolean(status.ready);
const live = Boolean(status.live);
res.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
if (!ready || !live) {
return res.status(503).json({
ready: false,
live: false,
timedOut: Boolean(status.timedOut),
stderr: String(status.stderr || '')
});
}
res.json({
key: session.key,
playlistUrl: session.playlistUrl,
disableAudio: session.disableAudio,
ready: true,
live: true
});
} catch (error) {
next(error);
}
});
app.get('/api/rtmp/streams/:key/index.m3u8', async function (req, res, next) {
try {
res.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
const manifestPath = await rtmpStreamService.getManifestFilePath(req.params.key);
if (!manifestPath) {
return res.status(404).send('Stream not found');
}
res.sendFile(manifestPath);
} catch (error) {
next(error);
}
});
app.get('/api/rtmp/streams/:key/:fileName', async function (req, res, next) {
try {
const segmentPath = await rtmpStreamService.getSegmentFilePath(req.params.key, req.params.fileName);
if (!segmentPath) {
return res.status(404).send('Stream not found');
}
res.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
res.sendFile(segmentPath);
} catch (error) {
next(error);
}
});
app.get('/screen/:slug', async function (req, res, next) {
if (onPlayerPublicBaseUrl && !bridgeBaseUrl) {
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) {
const pageAuthToken = createPageAuthBundle({ scope: 'player', slug: String(req.params.slug || '').trim() }).token;
void fetchBridge(req, '/api/screens/' + encodeURIComponent(req.params.slug) + '/playlist?ts=' + Date.now(), {
method: 'GET',
headers: pageAuthToken ? { 'x-pulse-page-auth': pageAuthToken } : {}
}).then(async function (response) {
if (!response || response.status >= 400) {
const snapshot = await readPlaylistSnapshot(req.params.slug);
res.set('X-Player-Offline', '1');
return res.send(common.renderPlayerPage(req.params.slug, snapshot));
}
const data = await readJsonResponse(response);
if (!data) {
const snapshot = await readPlaylistSnapshot(req.params.slug);
res.set('X-Player-Offline', '1');
return res.send(common.renderPlayerPage(req.params.slug, snapshot));
}
await writePlaylistSnapshot(req.params.slug, data);
res.send(common.renderPlayerPage(req.params.slug, null));
}).catch(async function (error) {
if (!isBridgeFetchError(error)) {
console.error(error);
}
const snapshot = await readPlaylistSnapshot(req.params.slug);
res.set('X-Player-Offline', '1');
res.send(common.renderPlayerPage(req.params.slug, snapshot));
});
return;
}
playerPlaylistService.buildScreenPlaylist(req.params.slug).then(function (data) {
res.send(common.renderPlayerPage(req.params.slug, null));
}).catch(function (error) {
console.error(error);
res.set('X-Player-Offline', '1');
res.send(common.renderPlayerPage(req.params.slug, null));
});
});
app.get('/api/screens/:slug/playlist', requirePageAuth(['player']), async function (req, res, next) {
try {
if (playerDeviceId && !(await isClientAuthorizedForScreen(req, req.params.slug))) {
return res.status(403).json({ error: 'This browser tab is not authorized for this screen.' });
}
if (bridgeBaseUrl) {
const response = await fetchBridge(req, '/api/screens/' + encodeURIComponent(req.params.slug) + '/playlist', {
method: 'GET'
});
if (!response) {
const snapshot = await readPlaylistSnapshot(req.params.slug);
if (!snapshot) {
return res.status(502).json({ error: 'Thin client unavailable.' });
}
res.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
res.set('ETag', '"' + String(snapshot.revision || '') + '"');
return res.json(snapshot);
}
res.status(response.status);
const etag = response.headers.get('etag');
const cacheControl = response.headers.get('cache-control');
if (etag) {
res.set('ETag', etag);
}
if (cacheControl) {
res.set('Cache-Control', cacheControl);
}
if (response.status === 304) {
return res.end();
}
res.type(response.headers.get('content-type') || 'application/json');
const responseText = await response.text();
try {
await writePlaylistSnapshot(req.params.slug, JSON.parse(responseText));
} catch (_error) {
}
return res.send(responseText);
}
res.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
const data = await playerPlaylistService.buildScreenPlaylist(req.params.slug);
if (!data.screen) {
return res.status(404).json({ error: 'Screen not found' });
}
const etag = '"' + String(data.revision || '') + '"';
res.set('ETag', etag);
if (String(req.headers['if-none-match'] || '').split(',').map(function (value) {
return String(value || '').trim();
}).includes(etag)) {
return res.status(304).end();
}
res.json(data);
} catch (error) {
next(error);
}
});
app.get('/api/screens/:slug/announcement', requirePageAuth(['player']), async function (req, res, next) {
try {
if (playerDeviceId && !(await isClientAuthorizedForScreen(req, req.params.slug))) {
return res.status(403).json({ error: 'This browser tab is not authorized for this screen.' });
}
if (bridgeBaseUrl) {
const response = await fetchBridge(req, '/api/screens/' + encodeURIComponent(req.params.slug) + '/announcement', {
method: 'GET'
});
if (!response) {
return res.status(502).json({ error: 'Thin client unavailable.' });
}
res.status(response.status);
const etag = response.headers.get('etag');
const cacheControl = response.headers.get('cache-control');
if (etag) {
res.set('ETag', etag);
}
if (cacheControl) {
res.set('Cache-Control', cacheControl);
}
if (response.status === 304) {
return res.end();
}
res.type(response.headers.get('content-type') || 'application/json');
return res.send(await response.text());
}
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)
: null;
const revision = announcement
? [announcement.id, announcement.modified_at || '', announcement.expires_at || '', announcement.enabled ? '1' : '0'].join(':')
: 'none';
const etag = '"' + String(revision || 'none') + '"';
res.set('ETag', etag);
if (String(req.headers['if-none-match'] || '').split(',').map(function (value) {
return String(value || '').trim();
}).includes(etag)) {
return res.status(304).end();
}
res.json({
announcement: announcement,
revision: revision
});
} catch (error) {
if (isTransientDbError(error)) {
return res.status(503).json({ error: 'Announcement state unavailable.' });
}
next(error);
}
});
app.post('/api/screens/:slug/announcements/refresh', requireRequestAuth, async function (req, res, next) {
try {
const sent = typeof playerRuntime.broadcastAnnouncementRefresh === 'function'
? playerRuntime.broadcastAnnouncementRefresh(req.params.slug)
: 0;
res.json({ ok: true, screenSlug: req.params.slug, sent: sent });
} catch (error) {
next(error);
}
});
app.get(['/api/screens/:slug/connections', '/api/screens/:slug/clients'], requireRequestAuth, async function (req, res, next) {
try {
const connections = playerRuntime.snapshotConnections(req.params.slug);
let screen = null;
let screenLookupFailed = false;
if (pool && typeof pool.query === 'function') {
try {
const [screenRows] = await pool.query('SELECT id, name, slug FROM d_screens WHERE slug = ?', [req.params.slug]);
screen = screenRows[0] || null;
} catch (error) {
screenLookupFailed = isTransientDbError(error);
if (!screenLookupFailed) {
throw error;
}
}
}
res.json({
screen: screen,
screenSlug: req.params.slug,
count: connections.length,
connections: connections,
degraded: screenLookupFailed
});
} catch (error) {
next(error);
}
});
app.post('/api/screens/:slug/commands', requireRequestAuth, async function (req, res, next) {
try {
const command = String((req.body && req.body.command) || req.query.command || '').trim().toLowerCase();
const connectionId = String((req.body && (req.body.connectionId || req.body.clientId)) || req.query.connectionId || req.query.clientId || '').trim();
const blackoutValue = req.body && Object.prototype.hasOwnProperty.call(req.body, 'blackout')
? req.body.blackout
: req.query.blackout;
if (!command) {
return res.status(400).json({ error: 'Command is required' });
}
if (['refresh', 'reload', 'redirect', 'pause', 'blackout', 'previous', 'next', 'setclientname'].indexOf(command) === -1) {
return res.status(400).json({ error: 'Unsupported command' });
}
const liveConnections = playerRuntime.snapshotConnections(req.params.slug);
const isRedirectCommand = command === 'redirect';
let screen = null;
let screenLookupFailed = false;
if (!isRedirectCommand && pool && typeof pool.query === 'function') {
try {
const [screenRows] = await pool.query('SELECT id, name, slug FROM d_screens WHERE slug = ?', [req.params.slug]);
screen = screenRows[0] || null;
} catch (error) {
screenLookupFailed = isTransientDbError(error);
if (!screenLookupFailed) {
throw error;
}
}
}
if (!screen && liveConnections.length) {
screen = {
name: req.params.slug,
slug: req.params.slug
};
}
const commandPayload = req.body && typeof req.body === 'object' && !Array.isArray(req.body)
? Object.assign({}, req.body, { command: command })
: command;
if (command === 'blackout' && blackoutValue !== undefined && commandPayload && typeof commandPayload === 'object') {
commandPayload.blackout = blackoutValue;
}
const sent = connectionId
? await playerRuntime.sendCommandToConnection(req.params.slug, connectionId, commandPayload)
: await playerRuntime.broadcastCommand(req.params.slug, commandPayload);
if (!screen && !screenLookupFailed && !liveConnections.length) {
return res.status(404).json({ error: 'Screen not found' });
}
if (!screen && screenLookupFailed && !liveConnections.length) {
return res.status(503).json({ error: 'Screen metadata unavailable while the database is down.' });
}
res.json({
screen: screen,
screenSlug: req.params.slug,
command: command,
connectionId: connectionId || null,
sent: sent,
degraded: screenLookupFailed
});
} catch (error) {
next(error);
}
});
}
module.exports = {
registerPlayerRoutes: registerPlayerRoutes
};