307 lines
12 KiB
JavaScript
307 lines
12 KiB
JavaScript
// Admin manage routes for screens and commands.
|
|
|
|
const { buildAuditChanges } = require('#src/data/audit-log');
|
|
|
|
module.exports = function registerManageRoutes(app, deps) {
|
|
const pool = deps.pool;
|
|
const common = deps.common;
|
|
const pages = deps.pages;
|
|
const getAuditUserId = deps.getAuditUserId;
|
|
const recordRequestAuditEvent = deps.recordRequestAuditEvent;
|
|
const redirectAfterSave = deps.redirectAfterSave;
|
|
const notifyPlayerScreens = deps.notifyPlayerScreens;
|
|
const broadcastDashboardState = deps.broadcastDashboardState;
|
|
const getScreenDeleteBlockMessage = deps.getScreenDeleteBlockMessage;
|
|
const getScreenConnections = deps.getScreenConnections;
|
|
const forwardPlayerCommand = deps.forwardPlayerCommand;
|
|
const forwardPlayerCommandToBaseUrl = deps.forwardPlayerCommandToBaseUrl;
|
|
const requirePermission = deps.requirePermission;
|
|
|
|
const SCREEN_NAME_MAX_LENGTH = 255;
|
|
const SCREEN_SLUG_MAX_LENGTH = 255;
|
|
|
|
function readLimitedText(value, maxLength) {
|
|
const text = String(value || '').trim();
|
|
const limit = Number(maxLength);
|
|
if (!Number.isFinite(limit) || limit < 1 || text.length <= limit) {
|
|
return text;
|
|
}
|
|
return text.slice(0, limit);
|
|
}
|
|
|
|
function normalizeExplicitPlayerBaseUrl(value) {
|
|
return String(value || '').trim().replace(/\/$/, '');
|
|
}
|
|
|
|
async function fetchPlayerRegistrations() {
|
|
if (typeof common.fetchPlayerRegistrations !== 'function') {
|
|
return [];
|
|
}
|
|
|
|
try {
|
|
const registrations = await common.fetchPlayerRegistrations(pool);
|
|
return Array.isArray(registrations) ? registrations : [];
|
|
} catch (_error) {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
async function fetchLiveConnectionsForScreen(slug) {
|
|
if (typeof getScreenConnections !== 'function') {
|
|
return [];
|
|
}
|
|
|
|
try {
|
|
const response = await getScreenConnections(slug);
|
|
return Array.isArray(response && response.connections) ? response.connections : [];
|
|
} catch (_error) {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
async function resolvePlayerBaseUrlsForConnections(connections) {
|
|
const connectionList = Array.isArray(connections) ? connections : [];
|
|
const seen = new Set();
|
|
const registrations = await fetchPlayerRegistrations();
|
|
|
|
return connectionList.map(function (connection) {
|
|
const selectedPublicBaseUrl = normalizeExplicitPlayerBaseUrl(connection && connection.playerPublicBaseUrl);
|
|
if (!selectedPublicBaseUrl || seen.has(selectedPublicBaseUrl)) {
|
|
return '';
|
|
}
|
|
|
|
seen.add(selectedPublicBaseUrl);
|
|
const matchedPlayer = registrations.find(function (player) {
|
|
return normalizeExplicitPlayerBaseUrl(player && player.public_base_url) === selectedPublicBaseUrl;
|
|
}) || null;
|
|
|
|
return normalizeExplicitPlayerBaseUrl(matchedPlayer && matchedPlayer.internal_base_url) || selectedPublicBaseUrl;
|
|
}).filter(Boolean);
|
|
}
|
|
|
|
async function forwardPlayerCommandForConnections(slug, connections, commandPayload) {
|
|
const targetBaseUrls = await resolvePlayerBaseUrlsForConnections(connections);
|
|
if (targetBaseUrls.length && typeof forwardPlayerCommandToBaseUrl === 'function') {
|
|
const results = await Promise.allSettled(targetBaseUrls.map(function (targetBaseUrl) {
|
|
return forwardPlayerCommandToBaseUrl(targetBaseUrl, slug, commandPayload);
|
|
}));
|
|
|
|
return {
|
|
ok: true,
|
|
sent: results.filter(function (result) {
|
|
return result.status === 'fulfilled';
|
|
}).length,
|
|
results: results
|
|
};
|
|
}
|
|
|
|
return forwardPlayerCommand(slug, commandPayload);
|
|
}
|
|
|
|
async function fetchAllScreenTargets() {
|
|
const [rows] = await pool.query(
|
|
`SELECT s.slug
|
|
FROM d_screens s
|
|
WHERE s.slug IS NOT NULL
|
|
ORDER BY s.slug ASC`
|
|
);
|
|
return (rows || [])
|
|
.map(function (row) {
|
|
return {
|
|
slug: String(row && row.slug ? row.slug : '').trim(),
|
|
playerId: '',
|
|
publicBaseUrl: '',
|
|
internalBaseUrl: ''
|
|
};
|
|
})
|
|
.filter(function (row) {
|
|
return Boolean(row.slug);
|
|
});
|
|
}
|
|
|
|
app.post('/commands', requirePermission('dashboard.allow'), async function (req, res, next) {
|
|
try {
|
|
const command = String((req.body && req.body.command) || req.query.command || '').trim().toLowerCase();
|
|
const blackoutValue = req.body && Object.prototype.hasOwnProperty.call(req.body, 'blackout')
|
|
? req.body.blackout
|
|
: req.query.blackout;
|
|
const pausedValue = req.body && Object.prototype.hasOwnProperty.call(req.body, 'paused')
|
|
? req.body.paused
|
|
: req.query.paused;
|
|
|
|
if (!command) {
|
|
return res.status(400).json({ error: 'Command is required' });
|
|
}
|
|
|
|
if (command !== 'reload' && command !== 'blackout' && command !== 'pause') {
|
|
return res.status(400).json({ error: 'Unsupported command' });
|
|
}
|
|
|
|
const targets = await fetchAllScreenTargets();
|
|
if (!targets.length) {
|
|
await broadcastDashboardState();
|
|
return res.json({ ok: true, command: command, sent: 0 });
|
|
}
|
|
|
|
const payload = command === 'blackout'
|
|
? {
|
|
command: 'blackout',
|
|
blackout: blackoutValue === true || blackoutValue === 'true' || blackoutValue === '1' ? true : false
|
|
}
|
|
: command === 'pause'
|
|
? {
|
|
command: 'pause',
|
|
paused: pausedValue === true || pausedValue === 'true' || pausedValue === '1' ? true : false
|
|
}
|
|
: 'reload';
|
|
|
|
const results = await Promise.allSettled(targets.map(async function (target) {
|
|
const liveConnections = await fetchLiveConnectionsForScreen(target.slug);
|
|
if (liveConnections.length) {
|
|
return forwardPlayerCommandForConnections(target.slug, liveConnections, payload);
|
|
}
|
|
|
|
return forwardPlayerCommand(target.slug, payload);
|
|
}));
|
|
const sentCount = results.reduce(function (total, result) {
|
|
if (result.status !== 'fulfilled') {
|
|
return total;
|
|
}
|
|
|
|
const sentValue = Number(result.value && typeof result.value.sent === 'number' ? result.value.sent : 1);
|
|
return total + (Number.isFinite(sentValue) && sentValue > 0 ? sentValue : 1);
|
|
}, 0);
|
|
await broadcastDashboardState();
|
|
|
|
return res.json({
|
|
ok: true,
|
|
command: command,
|
|
sent: sentCount,
|
|
blackout: command === 'blackout' ? Boolean(payload.blackout) : undefined,
|
|
paused: command === 'pause' ? Boolean(payload.paused) : undefined
|
|
});
|
|
} catch (error) {
|
|
next(error);
|
|
}
|
|
});
|
|
|
|
app.get('/screens/new', requirePermission('screens.create'), async function (req, res, next) {
|
|
try {
|
|
const data = await common.fetchScreenEditData(pool);
|
|
res.send(pages.renderScreenFormPage(data, req.query.message ? String(req.query.message) : '', req.currentUser));
|
|
} catch (error) {
|
|
next(error);
|
|
}
|
|
});
|
|
|
|
app.post('/screens', requirePermission('screens.create'), async function (req, res, next) {
|
|
try {
|
|
const name = typeof common.validateMaxLength === 'function'
|
|
? common.validateMaxLength(req.body.name || '', SCREEN_NAME_MAX_LENGTH, 'Screen name')
|
|
: readLimitedText(req.body.name || '', 255);
|
|
if (!name) {
|
|
return res.status(400).send('Screen name is required.');
|
|
}
|
|
if (await common.fetchDuplicateName(pool, 'd_screens', name)) {
|
|
return res.status(400).send('A screen with that name already exists.');
|
|
}
|
|
const slugInput = typeof common.validateMaxLength === 'function'
|
|
? common.validateMaxLength(req.body.slug || '', SCREEN_SLUG_MAX_LENGTH, 'Screen URL')
|
|
: readLimitedText(req.body.slug || '', 255);
|
|
const playlistId = req.body.playlist_id ? Number(req.body.playlist_id) : null;
|
|
const slug = await common.uniqueScreenSlug(pool, common.slugify(slugInput || name));
|
|
const actorId = getAuditUserId(req);
|
|
const [result] = await pool.query('INSERT INTO d_screens (name, slug, playlist_id, created_by, modified_by) VALUES (?, ?, ?, ?, ?)', [name, slug, playlistId, actorId, actorId]);
|
|
if (typeof recordRequestAuditEvent === 'function') await recordRequestAuditEvent(pool, req, { category: 'screens', eventType: 'screen.created', actorUserId: actorId, targetType: 'screen', targetId: result.insertId, targetLabel: name, details: { slug: slug, playlistId: playlistId } });
|
|
redirectAfterSave(req, res, '/screens?edit=' + result.insertId, {
|
|
closeUrl: '/screens',
|
|
newUrl: '/screens/new',
|
|
message: 'Screen created.'
|
|
});
|
|
} catch (error) {
|
|
next(error);
|
|
}
|
|
});
|
|
|
|
app.post('/screens/:id', requirePermission('screens.update'), async function (req, res, next) {
|
|
try {
|
|
const name = typeof common.validateMaxLength === 'function'
|
|
? common.validateMaxLength(req.body.name || '', SCREEN_NAME_MAX_LENGTH, 'Screen name')
|
|
: readLimitedText(req.body.name || '', 255);
|
|
if (!name) {
|
|
return res.status(400).send('Screen name is required.');
|
|
}
|
|
const screen = await common.fetchScreenById(pool, Number(req.params.id));
|
|
if (!screen) {
|
|
return res.status(404).send('Screen not found');
|
|
}
|
|
if (await common.fetchDuplicateName(pool, 'd_screens', name, screen.id)) {
|
|
return res.status(400).send('A screen with that name already exists.');
|
|
}
|
|
const playlistId = req.body.playlist_id ? Number(req.body.playlist_id) : null;
|
|
const previousPlaylistId = screen.playlist_id;
|
|
const slug = String(screen.slug || '').trim();
|
|
const previousSlug = String(screen.slug || '').trim();
|
|
const changes = buildAuditChanges({
|
|
name: screen.name,
|
|
slug: previousSlug,
|
|
playlistId: previousPlaylistId === null ? null : Number(previousPlaylistId)
|
|
}, {
|
|
name: name,
|
|
slug: slug,
|
|
playlistId: playlistId
|
|
});
|
|
await pool.query('UPDATE d_screens SET name = ?, slug = ?, playlist_id = ?, modified_by = ? WHERE id = ?', [name, slug, playlistId, getAuditUserId(req), screen.id]);
|
|
if (previousPlaylistId !== playlistId && previousSlug) {
|
|
await notifyPlayerScreens([previousSlug], 'refresh');
|
|
}
|
|
if (previousSlug && previousSlug !== slug) {
|
|
const previousConnections = await fetchLiveConnectionsForScreen(previousSlug);
|
|
const redirectPayload = {
|
|
command: 'redirect',
|
|
url: `/screen/${encodeURIComponent(slug)}`
|
|
};
|
|
if (previousConnections.length) {
|
|
await forwardPlayerCommandForConnections(previousSlug, previousConnections, redirectPayload);
|
|
} else {
|
|
await forwardPlayerCommand(previousSlug, redirectPayload);
|
|
}
|
|
}
|
|
if (typeof recordRequestAuditEvent === 'function') await recordRequestAuditEvent(pool, req, { category: 'screens', eventType: 'screen.updated', actorUserId: getAuditUserId(req), targetType: 'screen', targetId: screen.id, targetLabel: name, details: { changes: changes } });
|
|
redirectAfterSave(req, res, '/screens?edit=' + screen.id, {
|
|
closeUrl: '/screens',
|
|
newUrl: '/screens/new',
|
|
message: 'Screen updated.'
|
|
});
|
|
} catch (error) {
|
|
next(error);
|
|
}
|
|
});
|
|
|
|
app.post('/screens/:id/delete', requirePermission('screens.delete'), async function (req, res, next) {
|
|
try {
|
|
const screen = await common.fetchScreenById(pool, Number(req.params.id));
|
|
if (!screen) {
|
|
return res.status(404).send('Screen not found');
|
|
}
|
|
const blockMessage = await getScreenDeleteBlockMessage(pool, screen, getScreenConnections);
|
|
if (blockMessage) {
|
|
return res.redirect('/screens?message=' + encodeURIComponent(blockMessage));
|
|
}
|
|
await pool.query('DELETE FROM d_screens WHERE id = ?', [screen.id]);
|
|
if (typeof recordRequestAuditEvent === 'function') await recordRequestAuditEvent(pool, req, { category: 'screens', eventType: 'screen.deleted', actorUserId: getAuditUserId(req), targetType: 'screen', targetId: screen.id, targetLabel: screen.name });
|
|
res.redirect('/screens?message=' + encodeURIComponent('Screen deleted.'));
|
|
} catch (error) {
|
|
next(error);
|
|
}
|
|
});
|
|
};
|