Save worktree changes

This commit is contained in:
2026-07-25 02:29:19 +01:00
parent 8d3b7d557b
commit db9d718cd8
170 changed files with 11719 additions and 3414 deletions
+181 -27
View File
@@ -1,36 +1,111 @@
const fs = require('fs');
const express = require('express');
const path = require('path');
const { getSharedSecret, createPageAuthBundle, verifyPageAuthToken, verifyRequestAuth } = require('../request-auth');
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 uploadDir = options && options.uploadDir ? options.uploadDir : 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;
if (!app || !pool || !common || !uploadDir || !assetDir || !playerRuntime || !playerPlaylistService) {
throw new Error('registerPlayerRoutes requires app, pool, common, uploadDir, assetDir, playerRuntime, and playerPlaylistService.');
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();
}
app.use('/assets', express.static(assetDir));
app.use('/uploads', express.static(uploadDir));
app.use('/media', express.static(mediaDir));
app.use('/assets/vendor', express.static(path.join(__dirname, '..', '..', 'node_modules', 'hls.js', 'dist')));
app.get('/api/uploads/config', function (_req, res) {
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({
uploadDir: uploadDir
mediaDir: mediaDir
});
});
app.put('/api/uploads/:filename', express.raw({ type: '*/*', limit: '100mb' }), async function (req, res, next) {
app.put('/api/media/:filename', requireRequestAuth, express.raw({ type: '*/*', limit: '100mb' }), async function (req, res, next) {
try {
const filename = require('path').basename(String(req.params.filename || '').trim());
if (!filename) {
return res.status(400).json({ error: 'Filename is required' });
}
const filePath = require('path').join(uploadDir, filename);
const filePath = require('path').join(mediaDir, filename);
const body = Buffer.isBuffer(req.body) ? req.body : Buffer.from(req.body || '');
await fs.promises.mkdir(uploadDir, { recursive: true });
await fs.promises.mkdir(mediaDir, { recursive: true });
await fs.promises.writeFile(filePath, body);
res.json({ ok: true, filename: filename });
} catch (error) {
@@ -38,13 +113,13 @@ function registerPlayerRoutes(app, options) {
}
});
app.delete('/api/uploads/:filename', async function (req, res, next) {
app.delete('/api/media/:filename', requireRequestAuth, async function (req, res, next) {
try {
const filename = require('path').basename(String(req.params.filename || '').trim());
if (!filename) {
return res.status(400).json({ error: 'Filename is required' });
}
const filePath = require('path').join(uploadDir, filename);
const filePath = require('path').join(mediaDir, filename);
try {
await fs.promises.unlink(filePath);
} catch (error) {
@@ -58,6 +133,53 @@ function registerPlayerRoutes(app, options) {
}
});
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 session = await rtmpStreamService.ensureSession(source, useMutedOutput);
await session.ready.catch(function () {
return false;
});
res.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
res.json({
key: session.key,
playlistUrl: session.playlistUrl,
disableAudio: session.disableAudio,
ready: 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');
@@ -65,11 +187,12 @@ function registerPlayerRoutes(app, options) {
res.send(common.renderPlayerPage(req.params.slug, data));
}).catch(function (error) {
console.error(error);
res.status(500).send('Internal server error');
res.set('X-Player-Offline', '1');
res.send(common.renderPlayerPage(req.params.slug, null));
});
});
app.get('/api/screens/:slug/playlist', async function (req, res, next) {
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);
@@ -89,25 +212,33 @@ function registerPlayerRoutes(app, options) {
}
});
app.get(['/api/screens/:slug/connections', '/api/screens/:slug/clients'], async function (req, res, next) {
app.get(['/api/screens/:slug/connections', '/api/screens/:slug/clients'], requireRequestAuth, async function (req, res, next) {
try {
const [screenRows] = await pool.query('SELECT id, name, slug FROM screens WHERE slug = ?', [req.params.slug]);
if (!screenRows.length) {
return res.status(404).json({ error: 'Screen not found' });
}
const connections = playerRuntime.snapshotConnections(req.params.slug);
let screen = null;
let screenLookupFailed = false;
try {
const [screenRows] = await pool.query('SELECT id, name, slug FROM screens WHERE slug = ?', [req.params.slug]);
screen = screenRows[0] || null;
} catch (error) {
screenLookupFailed = isTransientDbError(error);
if (!screenLookupFailed) {
throw error;
}
}
res.json({
screen: screenRows[0],
screen: screen,
screenSlug: req.params.slug,
count: connections.length,
connections: connections
connections: connections,
degraded: screenLookupFailed
});
} catch (error) {
next(error);
}
});
app.post('/api/screens/:slug/commands', async function (req, res, next) {
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();
@@ -121,15 +252,29 @@ function registerPlayerRoutes(app, options) {
return res.status(400).json({ error: 'Unsupported command' });
}
const liveConnections = playerRuntime.snapshotConnections(req.params.slug);
const isRedirectCommand = command === 'redirect';
let screenRows = [];
let screen = null;
let screenLookupFailed = false;
if (!isRedirectCommand) {
[screenRows] = await pool.query('SELECT id, name, slug FROM screens WHERE slug = ?', [req.params.slug]);
if (!screenRows.length) {
return res.status(404).json({ error: 'Screen not found' });
try {
const [screenRows] = await pool.query('SELECT id, name, slug FROM 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;
@@ -141,12 +286,21 @@ function registerPlayerRoutes(app, options) {
? 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: screenRows[0] || null,
screen: screen,
screenSlug: req.params.slug,
command: command,
connectionId: connectionId || null,
sent: sent
sent: sent,
degraded: screenLookupFailed
});
} catch (error) {
next(error);