431 lines
16 KiB
JavaScript
431 lines
16 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 } = require('#src/request-auth');
|
|
const { buildThumbnailPreviewData } = require('./thumbnail-preview');
|
|
|
|
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 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 playerPublicBaseUrl = String(options && options.playerPublicBaseUrl || process.env.PLAYER_PUBLIC_BASE_URL || process.env.PLAYER_BASE_URL || '').trim().replace(/\/$/, '');
|
|
const playerInternalBaseUrl = String(options && options.playerInternalBaseUrl || process.env.PLAYER_INTERNAL_BASE_URL || process.env.PLAYER_BASE_URL || '').trim().replace(/\/$/, '');
|
|
const playerIdentifier = String(options && options.playerIdentifier || '1').trim() || '1';
|
|
|
|
if (!app || !pool || !common || !mediaDir || !assetDir || !playerRuntime || !playerPlaylistService || !rtmpStreamService) {
|
|
throw new Error('registerPlayerRoutes requires app, pool, common, mediaDir, assetDir, playerRuntime, playerPlaylistService, and rtmpStreamService.');
|
|
}
|
|
|
|
const sharedSecret = getSharedSecret();
|
|
|
|
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;
|
|
}
|
|
|
|
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) {
|
|
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', function (req, res) {
|
|
res.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
|
|
res.set('Pragma', 'no-cache');
|
|
const { bindPlayerToScreen } = require('./onboarding');
|
|
if (playerIdentifier) {
|
|
void bindPlayerToScreen(pool, playerIdentifier, req.params.slug)
|
|
.catch(function (error) {
|
|
console.error(error);
|
|
});
|
|
}
|
|
playerPlaylistService.buildScreenPlaylist(req.params.slug).then(function (data) {
|
|
res.send(common.renderPlayerPage(req.params.slug, data));
|
|
}).catch(function (error) {
|
|
console.error(error);
|
|
res.set('X-Player-Offline', '1');
|
|
res.send(common.renderPlayerPage(req.params.slug, null));
|
|
});
|
|
});
|
|
|
|
app.get('/api/internal/slide-thumbnails/:id/preview', requireRequestAuth, async function (req, res, next) {
|
|
try {
|
|
const slide = await common.fetchSlideById(pool, Number(req.params.id));
|
|
if (!slide) {
|
|
return res.status(404).send('Slide not found');
|
|
}
|
|
|
|
const data = buildThumbnailPreviewData(slide);
|
|
|
|
if (typeof common.fetchRssFeedsData === 'function' && typeof common.fetchRssFeedItemsByFeedId === 'function') {
|
|
const rssData = await common.fetchRssFeedsData(pool);
|
|
data.rssFeeds = await Promise.all((rssData.rssFeeds || []).map(async function (feed) {
|
|
const items = await common.fetchRssFeedItemsByFeedId(pool, feed.id);
|
|
return Object.assign({}, feed, {
|
|
items: items.map(function (item) {
|
|
return typeof common.normalizeRssFeedItem === 'function' ? common.normalizeRssFeedItem(item) : item;
|
|
})
|
|
});
|
|
}));
|
|
}
|
|
|
|
if (typeof common.fetchApiSourcesData === 'function') {
|
|
const apiData = await common.fetchApiSourcesData(pool);
|
|
data.apiSources = (apiData.apiSources || []).map(function (source) {
|
|
return Object.assign({}, source, {
|
|
responseJson: typeof common.parseJsonSafe === 'function' ? common.parseJsonSafe(source.last_response_json) : null
|
|
});
|
|
});
|
|
}
|
|
|
|
if (typeof common.fetchTimetablesData === 'function') {
|
|
const timetableData = await common.fetchTimetablesData(pool);
|
|
data.timetableGroups = Array.isArray(timetableData && timetableData.timetableGroups) ? timetableData.timetableGroups : [];
|
|
}
|
|
|
|
res.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
|
|
res.send(common.renderPlayerPage('slide-thumbnail-preview-' + slide.id, data));
|
|
} catch (error) {
|
|
next(error);
|
|
}
|
|
});
|
|
|
|
app.get('/api/screens/:slug/playlist', requirePageAuth(['player']), async function (req, res, next) {
|
|
try {
|
|
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 {
|
|
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;
|
|
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) {
|
|
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
|
|
}; |