Files
pulse-signage/src/webui.js
T

1832 lines
70 KiB
JavaScript

const express = require('express');
const http = require('http');
const multer = require('multer');
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const { WebSocketServer, WebSocket } = require('ws');
const common = require('./common');
const { verifyPassword, createSessionToken, hashSessionToken, hashPassword } = require('./auth');
const pages = require('./webui/routes');
const PLAYER_INTERNAL_BASE_URL = (process.env.PLAYER_INTERNAL_BASE_URL || process.env.PLAYER_BASE_URL || 'http://player:3001').replace(/\/$/, '');
const PLAYER_PUBLIC_BASE_URL = (process.env.PLAYER_PUBLIC_BASE_URL || process.env.PLAYER_BASE_URL || 'http://localhost:3001').replace(/\/$/, '');
const PLAYER_WS_BASE_URL = PLAYER_INTERNAL_BASE_URL.replace(/^http/, 'ws');
const SESSION_COOKIE_NAME = 'digital_signage_session';
const SESSION_MAX_AGE_DAYS = Number(process.env.SESSION_MAX_AGE_DAYS || 14);
const SESSION_MAX_AGE_MS = (Number.isFinite(SESSION_MAX_AGE_DAYS) && SESSION_MAX_AGE_DAYS > 0 ? SESSION_MAX_AGE_DAYS : 14) * 24 * 60 * 60 * 1000;
const playerSnapshotCache = new Map();
const playerSnapshotSockets = new Map();
function readArrayField(body, keys) {
const searchKeys = Array.isArray(keys) ? keys : [keys];
for (let i = 0; i < searchKeys.length; i += 1) {
const value = body[searchKeys[i]];
if (Array.isArray(value)) {
return value;
}
if (value !== undefined && value !== null && value !== '') {
return [value];
}
}
return [];
}
function parseDateTimeLocal(value) {
if (!value) {
return null;
}
const date = new Date(String(value));
return Number.isNaN(date.getTime()) ? null : date;
}
function parseTimeLocal(value) {
const raw = String(value || '').trim();
if (!raw) {
return null;
}
if (!/^\d{2}:\d{2}(:\d{2})?$/.test(raw)) {
return null;
}
return raw.length === 5 ? raw + ':00' : raw;
}
function normalizeScheduleMode(value) {
const mode = String(value || 'always');
if (mode === 'dates' || mode === 'times') {
return mode;
}
return 'always';
}
function getAuditUserId(req) {
return req && req.currentUser ? Number(req.currentUser.id) : null;
}
function getCanvasSignature(width, height) {
const normalizedWidth = Number(width);
const normalizedHeight = Number(height);
if (!Number.isFinite(normalizedWidth) || !Number.isFinite(normalizedHeight)) {
return null;
}
return normalizedWidth + 'x' + normalizedHeight;
}
async function fetchPlaylistCanvasSignature(pool, playlistId) {
const [rows] = await pool.query(`
SELECT DISTINCT cs.width AS canvas_width, cs.height AS canvas_height
FROM playlist_slides ps
JOIN slides sl ON sl.id = ps.slide_id
LEFT JOIN slide_templates st ON st.id = sl.template_id
LEFT JOIN canvas_sizes cs ON cs.id = st.canvas_size_id
WHERE ps.playlist_id = ?
AND cs.width IS NOT NULL
AND cs.height IS NOT NULL
`, [playlistId]);
const signatures = Array.from(new Set(rows.map((row) => getCanvasSignature(row.canvas_width, row.canvas_height)).filter(Boolean)));
if (!signatures.length) {
return null;
}
return signatures.length === 1 ? signatures[0] : 'mismatch';
}
function getPlayerSnapshotSocketUrl(slug) {
const url = new URL(PLAYER_WS_BASE_URL);
url.pathname = `/ws/screens/${encodeURIComponent(slug)}/events`;
url.search = '';
return url.toString();
}
function storePlayerSnapshot(slug, connections) {
const normalizedConnections = Array.isArray(connections) ? connections : [];
playerSnapshotCache.set(String(slug || '').trim(), {
slug: String(slug || '').trim(),
count: normalizedConnections.length,
connections: normalizedConnections
});
}
function clearPlayerSnapshotSocket(slug) {
const key = String(slug || '').trim();
playerSnapshotSockets.delete(key);
}
function ensurePlayerSnapshotSubscription(slug) {
const key = String(slug || '').trim();
if (!key || playerSnapshotSockets.has(key)) {
return;
}
const socket = new WebSocket(getPlayerSnapshotSocketUrl(key));
playerSnapshotSockets.set(key, socket);
socket.onmessage = function (event) {
try {
const payload = JSON.parse(String(event.data || '{}'));
if (!payload || payload.type !== 'snapshot' || payload.slug !== key) {
return;
}
storePlayerSnapshot(key, payload.connections || []);
broadcastDashboardState().catch(function (error) {
console.error(error);
});
} catch (_error) {
// Ignore malformed player snapshot payloads.
}
};
socket.onclose = function () {
clearPlayerSnapshotSocket(key);
setTimeout(function () {
ensurePlayerSnapshotSubscription(key);
}, 2000);
};
socket.onerror = function () {
try {
socket.close();
} catch (_error) {
// ignore close errors
}
};
}
function enrichScreensWithConnections(screens, connectionsBySlug) {
return (screens || []).map(function (screen) {
const connectionState = connectionsBySlug[screen.slug] || { count: 0, connections: [] };
return Object.assign({}, screen, {
player_connection_count: connectionState.count || 0,
player_connections: Array.isArray(connectionState.connections) ? connectionState.connections : []
});
});
}
function buildClientRows(screens, connectionsBySlug) {
return (screens || []).flatMap(function (screen) {
const connectionState = connectionsBySlug[screen.slug] || { count: 0, connections: [] };
return (connectionState.connections || []).map(function (connection) {
return Object.assign({}, connection, {
screen_slug: screen.slug,
screen_name: screen.name,
playlist_name: screen.playlist_name || null,
connectedAtLabel: formatDashboardDate(connection.connectedAt),
lastSeenAtLabel: formatDashboardDate(connection.lastSeenAt),
player_url: `${PLAYER_PUBLIC_BASE_URL}/screen/${encodeURIComponent(screen.slug)}`
});
});
});
}
async function forwardPlayerCommand(slug, commandOrPayload, connectionId) {
const payload = typeof commandOrPayload === 'object' && commandOrPayload !== null
? Object.assign({}, commandOrPayload)
: { command: commandOrPayload };
if (connectionId) {
payload.connectionId = connectionId;
}
const response = await fetch(`${PLAYER_INTERNAL_BASE_URL}/api/screens/${encodeURIComponent(slug)}/commands`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json'
},
body: JSON.stringify(payload)
});
if (!response.ok) {
const errorText = await response.text().catch(function () { return ''; });
const error = new Error(errorText || `Unable to send command to player ${slug}.`);
error.statusCode = response.status;
throw error;
}
return response.json().catch(function () {
return { ok: true };
});
}
async function notifyPlayerScreens(slugs, commandOrPayload) {
const uniqueSlugs = Array.from(new Set((slugs || []).map(function (slug) {
return String(slug || '').trim();
}).filter(Boolean)));
if (!uniqueSlugs.length) {
return 0;
}
const results = await Promise.allSettled(uniqueSlugs.map(function (slug) {
return forwardPlayerCommand(slug, commandOrPayload || 'refresh');
}));
return results.filter(function (result) {
return result.status === 'fulfilled';
}).length;
}
async function fetchAllScreenSlugs(connection) {
const [rows] = await connection.query('SELECT slug FROM screens WHERE slug IS NOT NULL');
return rows.map(function (row) {
return row.slug;
});
}
async function fetchScreensByPlaylistId(connection, playlistId) {
const [rows] = await connection.query(
'SELECT slug FROM screens WHERE playlist_id = ? AND slug IS NOT NULL',
[playlistId]
);
return rows.map(function (row) {
return row.slug;
});
}
async function fetchScreensBySlideId(connection, slideId) {
const [rows] = await connection.query(
`SELECT DISTINCT s.slug
FROM screens s
JOIN playlist_slides ps ON ps.playlist_id = s.playlist_id
WHERE ps.slide_id = ?
AND s.slug IS NOT NULL`,
[slideId]
);
return rows.map(function (row) {
return row.slug;
});
}
function formatDashboardDate(value) {
if (!value) {
return '';
}
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return '';
}
return new Intl.DateTimeFormat('en-US', {
month: 'short',
day: '2-digit',
year: 'numeric',
hour: 'numeric',
minute: '2-digit',
second: '2-digit'
}).format(date);
}
async function buildDashboardState(pool) {
const data = await common.fetchAdminData(pool);
const screensData = data.screens || [];
screensData.forEach(function (screen) {
ensurePlayerSnapshotSubscription(screen.slug);
});
const connectionsBySlug = {};
screensData.forEach(function (screen) {
const cached = playerSnapshotCache.get(String(screen.slug || '').trim());
if (cached && Array.isArray(cached.connections)) {
connectionsBySlug[screen.slug] = cached;
}
});
const screens = enrichScreensWithConnections(data.screens || [], connectionsBySlug).map(function (screen) {
return Object.assign({}, screen, {
player_url: `${PLAYER_PUBLIC_BASE_URL}/screen/${encodeURIComponent(screen.slug)}`
});
});
const clients = buildClientRows(data.screens || [], connectionsBySlug);
const playerServiceConnected = Array.from(playerSnapshotSockets.values()).some(function (socket) {
return socket && socket.readyState === WebSocket.OPEN;
});
return {
playlists: data.playlists || [],
screens: screens,
clients: clients,
slides: data.slides || [],
playerServiceConnected: playerServiceConnected,
connectedClientsCount: screens.reduce(function (total, screen) {
return total + Number(screen.player_connection_count || 0);
}, 0)
};
}
function buildDashboardPayload(state) {
return JSON.stringify({
type: 'dashboard-state',
state: state
});
}
async function fetchOrderedPlaylistSlides(connection, playlistId) {
const [rows] = await connection.query(
'SELECT id, position FROM playlist_slides WHERE playlist_id = ? ORDER BY position ASC, id ASC',
[playlistId]
);
return rows;
}
function createUploadMiddleware(uploadDir) {
const storage = multer.diskStorage({
destination: function (_req, _file, cb) {
cb(null, uploadDir);
},
filename: function (_req, file, cb) {
const safeExt = path.extname(file.originalname || '').toLowerCase();
const stamp = `${Date.now()}-${crypto.randomUUID()}`;
cb(null, `${stamp}${safeExt}`);
}
});
return multer({ storage });
}
function normalizeUploadReference(uploadPath) {
const value = String(uploadPath || '').trim();
if (!value || !value.startsWith('/uploads/')) {
return null;
}
return value;
}
function collectUploadReferencesFromValue(value, refs) {
if (!value) {
return refs;
}
const stack = [value];
while (stack.length) {
const current = stack.pop();
if (Array.isArray(current)) {
current.forEach(function (item) {
stack.push(item);
});
continue;
}
if (current && typeof current === 'object') {
Object.keys(current).forEach(function (key) {
stack.push(current[key]);
});
continue;
}
if (typeof current === 'string') {
const reference = normalizeUploadReference(current);
if (reference) {
refs.add(reference);
}
}
}
return refs;
}
function collectUploadReferencesFromSlide(slide) {
const refs = new Set();
if (!slide) {
return refs;
}
collectUploadReferencesFromValue(slide.media_path, refs);
collectUploadReferencesFromValue(common.parseJsonSafe(slide.content_json), refs);
return refs;
}
function collectUploadReferencesFromTemplate(template) {
const refs = new Set();
if (!template) {
return refs;
}
collectUploadReferencesFromValue(template.background_image_path, refs);
return refs;
}
function collectUploadReferencesFromPayload(payload) {
const refs = new Set();
if (!payload) {
return refs;
}
collectUploadReferencesFromValue(payload.mediaPath, refs);
collectUploadReferencesFromValue(common.parseJsonSafe(payload.contentJson), refs);
collectUploadReferencesFromValue(payload.backgroundImagePath, refs);
return refs;
}
async function countUploadReferences(pool, uploadPath) {
const [slideRows] = await pool.query(
`SELECT COUNT(*) AS ref_count
FROM slides
WHERE media_path = ?
OR JSON_SEARCH(COALESCE(content_json, JSON_OBJECT()), 'one', ?) IS NOT NULL`,
[uploadPath, uploadPath]
);
const [templateRows] = await pool.query(
'SELECT COUNT(*) AS ref_count FROM slide_templates WHERE background_image_path = ?',
[uploadPath]
);
return Number(slideRows[0].ref_count || 0) + Number(templateRows[0].ref_count || 0);
}
async function removeUnusedUploadFiles(pool, uploadDir, uploadPaths) {
const uniquePaths = Array.from(new Set((uploadPaths || []).map(normalizeUploadReference).filter(Boolean)));
for (let i = 0; i < uniquePaths.length; i += 1) {
const uploadPath = uniquePaths[i];
const referenceCount = await countUploadReferences(pool, uploadPath);
if (referenceCount > 0) {
continue;
}
const filePath = path.join(uploadDir, path.basename(uploadPath));
try {
await fs.promises.unlink(filePath);
} catch (error) {
if (error && error.code !== 'ENOENT') {
console.warn('Unable to remove unused upload file:', filePath, error);
}
}
}
}
function parseCookies(cookieHeader) {
return String(cookieHeader || '').split(/;\s*/).reduce(function (cookies, pair) {
if (!pair) {
return cookies;
}
const separatorIndex = pair.indexOf('=');
if (separatorIndex === -1) {
return cookies;
}
const name = decodeURIComponent(pair.slice(0, separatorIndex).trim());
const value = decodeURIComponent(pair.slice(separatorIndex + 1).trim());
if (name) {
cookies[name] = value;
}
return cookies;
}, {});
}
function serializeCookie(name, value, options) {
const parts = [`${encodeURIComponent(name)}=${encodeURIComponent(value)}`];
if (options && options.maxAge !== undefined) {
parts.push(`Max-Age=${Math.max(0, Math.trunc(Number(options.maxAge) / 1000))}`);
}
parts.push('Path=/');
parts.push('HttpOnly');
parts.push('SameSite=Lax');
return parts.join('; ');
}
function clearSessionCookie(res) {
res.setHeader('Set-Cookie', serializeCookie(SESSION_COOKIE_NAME, '', { maxAge: 0 }));
}
function setSessionCookie(res, token) {
res.setHeader('Set-Cookie', serializeCookie(SESSION_COOKIE_NAME, token, { maxAge: SESSION_MAX_AGE_MS }));
}
async function loadCurrentUser(pool, req) {
const cookies = parseCookies(req.headers.cookie || '');
const token = cookies[SESSION_COOKIE_NAME];
if (!token) {
return null;
}
const tokenHash = hashSessionToken(token);
const [rows] = await pool.query(
`SELECT s.user_id, u.id, u.name, u.username
FROM auth_sessions s
JOIN users u ON u.id = s.user_id
WHERE s.session_hash = ?
AND s.expires_at > NOW()
LIMIT 1`,
[tokenHash]
);
if (!rows.length) {
return null;
}
await pool.query('UPDATE auth_sessions SET last_used_at = CURRENT_TIMESTAMP, modified_by = ? WHERE session_hash = ?', [rows[0].user_id, tokenHash]);
return rows[0];
}
async function createUserSession(pool, userId) {
const token = createSessionToken();
const tokenHash = hashSessionToken(token);
const expiresAt = new Date(Date.now() + SESSION_MAX_AGE_MS);
await pool.query(
'INSERT INTO auth_sessions (session_hash, user_id, expires_at, created_by, modified_by) VALUES (?, ?, ?, ?, ?)',
[tokenHash, userId, expiresAt, userId, userId]
);
return token;
}
function requireAuth(req, res, next) {
if (req.currentUser) {
return next();
}
res.redirect('/login?message=' + encodeURIComponent('Please sign in to continue.'));
}
async function start() {
const app = express();
const server = http.createServer(app);
const pool = common.createPool();
const PORT = Number(process.env.WEB_PORT || 3000);
const UPLOAD_DIR = process.env.UPLOAD_DIR || path.join(__dirname, '..', 'uploads');
const ASSET_DIR = path.join(__dirname, 'webui', 'public');
const dashboardWs = new WebSocketServer({ noServer: true });
const dashboardClients = new Set();
const dashboardRefreshIntervalMs = Number(process.env.DASHBOARD_REFRESH_INTERVAL_MS || 2000);
let dashboardRefreshInFlight = null;
fs.mkdirSync(UPLOAD_DIR, { recursive: true });
const upload = createUploadMiddleware(UPLOAD_DIR);
app.use(express.urlencoded({ extended: true }));
app.use(express.json());
app.use('/assets', express.static(ASSET_DIR));
app.use('/uploads', express.static(UPLOAD_DIR));
app.use(async function (req, _res, next) {
try {
req.currentUser = await loadCurrentUser(pool, req);
next();
} catch (error) {
next(error);
}
});
app.get('/', function (_req, res) {
res.redirect(_req.currentUser ? '/admin' : '/login');
});
app.get('/login', function (req, res) {
if (req.currentUser) {
return res.redirect('/admin');
}
res.send(pages.renderLoginPage(req.query.message ? String(req.query.message) : ''));
});
app.post('/login', async function (req, res, next) {
try {
const username = String(req.body.username || '').trim();
const password = String(req.body.password || '');
if (!username || !password) {
return res.status(400).send('Username and password are required.');
}
const [rows] = await pool.query('SELECT id, name, username, password_hash, password_salt, password_iterations FROM users WHERE username = ? LIMIT 1', [username]);
const user = rows[0] || null;
if (!user || !verifyPassword(password, user)) {
return res.redirect('/login?message=' + encodeURIComponent('Invalid username or password.'));
}
const token = await createUserSession(pool, user.id);
setSessionCookie(res, token);
res.redirect('/admin');
} catch (error) {
next(error);
}
});
app.get('/logout', async function (req, res, next) {
try {
const cookies = parseCookies(req.headers.cookie || '');
const token = cookies[SESSION_COOKIE_NAME];
if (token) {
await pool.query('DELETE FROM auth_sessions WHERE session_hash = ?', [hashSessionToken(token)]);
}
clearSessionCookie(res);
res.redirect('/login?message=' + encodeURIComponent('You have been signed out.'));
} catch (error) {
next(error);
}
});
app.use('/admin', requireAuth);
app.get('/admin', async function (req, res, next) {
try {
const data = await buildDashboardState(pool);
res.send(pages.renderDashboardPage(data, req.query.message ? String(req.query.message) : '', req.currentUser));
} catch (error) {
next(error);
}
});
app.get('/admin/account', function (req, res) {
res.send(pages.renderAccountPage(req.currentUser, req.query.message ? String(req.query.message) : ''));
});
app.post('/admin/account/name', async function (req, res, next) {
try {
const name = String(req.body.name || '').trim();
if (!name) {
return res.status(400).send('Name is required.');
}
const actorId = getAuditUserId(req);
const [result] = await pool.query('UPDATE users SET name = ?, modified_by = ? WHERE id = ?', [name, actorId, req.currentUser.id]);
if (!result.affectedRows) {
return res.status(404).send('User not found.');
}
res.redirect('/admin/account?message=' + encodeURIComponent('Name updated.'));
} catch (error) {
next(error);
}
});
app.post('/admin/account/password', async function (req, res, next) {
try {
const currentPassword = String(req.body.current_password || '');
const newPassword = String(req.body.new_password || '');
const confirmPassword = String(req.body.confirm_password || '');
const [rows] = await pool.query('SELECT id, name, username, password_hash, password_salt, password_iterations FROM users WHERE id = ? LIMIT 1', [req.currentUser.id]);
const user = rows[0] || null;
if (!user) {
return res.status(404).send('User not found.');
}
if (!verifyPassword(currentPassword, user)) {
return res.status(400).send('Current password is incorrect.');
}
if (!newPassword || newPassword.length < 8) {
return res.status(400).send('New password must be at least 8 characters.');
}
if (newPassword !== confirmPassword) {
return res.status(400).send('New passwords do not match.');
}
const passwordRecord = hashPassword(newPassword);
await pool.query(
'UPDATE users SET password_hash = ?, password_salt = ?, password_iterations = ?, modified_by = ? WHERE id = ?',
[passwordRecord.hash, passwordRecord.salt, passwordRecord.iterations, getAuditUserId(req), user.id]
);
await pool.query('DELETE FROM auth_sessions WHERE user_id = ?', [user.id]);
const token = await createUserSession(pool, user.id);
setSessionCookie(res, token);
res.redirect('/admin/account?message=' + encodeURIComponent('Password updated.'));
} catch (error) {
next(error);
}
});
app.get('/admin/users', async function (req, res, next) {
try {
const [rows] = await pool.query('SELECT id, name, username, created_at, modified_at FROM users ORDER BY id ASC');
const users = rows.map(function (user) {
return Object.assign({}, user, {
isCurrentUser: Number(user.id) === Number(req.currentUser.id),
createdAtLabel: formatDashboardDate(user.created_at),
modifiedAtLabel: formatDashboardDate(user.modified_at)
});
});
res.send(pages.renderUsersPage({ users: users }, req.query.message ? String(req.query.message) : '', req.currentUser));
} catch (error) {
next(error);
}
});
app.get('/admin/users/new', function (req, res) {
res.send(pages.renderUsersAddPage(req.query.message ? String(req.query.message) : '', req.currentUser));
});
app.get('/admin/users/:id/edit', async function (req, res, next) {
try {
const userId = Number(req.params.id);
if (!Number.isInteger(userId) || userId <= 0) {
return res.status(400).send('Invalid user.');
}
const [rows] = await pool.query('SELECT id, name, username, created_at, modified_at FROM users WHERE id = ? LIMIT 1', [userId]);
const user = rows[0] || null;
if (!user) {
return res.status(404).send('User not found.');
}
if (Number(req.currentUser.id) === userId) {
return res.redirect('/admin/account?message=' + encodeURIComponent('Use My Account to update your own login details.'));
}
res.send(pages.renderUsersEditPage(Object.assign({}, user, {
isCurrentUser: false,
createdAtLabel: formatDashboardDate(user.created_at),
modifiedAtLabel: formatDashboardDate(user.modified_at)
}), req.query.message ? String(req.query.message) : '', req.currentUser));
} catch (error) {
next(error);
}
});
app.post('/admin/users', async function (req, res, next) {
try {
const name = String(req.body.name || '').trim();
const username = String(req.body.username || '').trim();
const password = String(req.body.password || '');
const confirmPassword = String(req.body.confirm_password || '');
if (!name) {
return res.redirect('/admin/users/new?message=' + encodeURIComponent('Name is required.'));
}
if (!username) {
return res.redirect('/admin/users/new?message=' + encodeURIComponent('Username is required.'));
}
if (!password || password.length < 8) {
return res.redirect('/admin/users/new?message=' + encodeURIComponent('Password must be at least 8 characters.'));
}
if (password !== confirmPassword) {
return res.redirect('/admin/users/new?message=' + encodeURIComponent('Passwords do not match.'));
}
const [existingRows] = await pool.query('SELECT id FROM users WHERE username = ? LIMIT 1', [username]);
if (existingRows.length) {
return res.redirect('/admin/users/new?message=' + encodeURIComponent('That username already exists.'));
}
const passwordRecord = hashPassword(password);
const actorId = getAuditUserId(req);
await pool.query(
'INSERT INTO users (name, username, password_hash, password_salt, password_iterations, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?)',
[name, username, passwordRecord.hash, passwordRecord.salt, passwordRecord.iterations, actorId, actorId]
);
res.redirect('/admin/users?message=' + encodeURIComponent('User created.'));
} catch (error) {
next(error);
}
});
app.post('/admin/users/:id/username', async function (req, res, next) {
try {
const userId = Number(req.params.id);
const name = String(req.body.name || '').trim();
const username = String(req.body.username || '').trim();
if (!Number.isInteger(userId) || userId <= 0) {
return res.status(400).send('Invalid user.');
}
if (!name) {
return res.redirect('/admin/users/' + userId + '/edit?message=' + encodeURIComponent('Name is required.'));
}
if (Number(req.currentUser.id) === userId) {
return res.redirect('/admin/account?message=' + encodeURIComponent('Use My Account to update your own username or password.'));
}
if (!username) {
return res.redirect('/admin/users/' + userId + '/edit?message=' + encodeURIComponent('Username is required.'));
}
const [existingRows] = await pool.query('SELECT id FROM users WHERE username = ? AND id <> ? LIMIT 1', [username, userId]);
if (existingRows.length) {
return res.redirect('/admin/users/' + userId + '/edit?message=' + encodeURIComponent('That username already exists.'));
}
const [result] = await pool.query('UPDATE users SET name = ?, username = ?, modified_by = ? WHERE id = ?', [name, username, getAuditUserId(req), userId]);
if (!result.affectedRows) {
return res.status(404).send('User not found.');
}
res.redirect('/admin/users?message=' + encodeURIComponent('Username updated.'));
} catch (error) {
next(error);
}
});
app.post('/admin/users/:id/password', async function (req, res, next) {
try {
const userId = Number(req.params.id);
const password = String(req.body.password || '');
const confirmPassword = String(req.body.confirm_password || '');
if (!Number.isInteger(userId) || userId <= 0) {
return res.status(400).send('Invalid user.');
}
if (Number(req.currentUser.id) === userId) {
return res.redirect('/admin/account?message=' + encodeURIComponent('Use My Account to change your own password.'));
}
if (!password || password.length < 8) {
return res.redirect('/admin/users?message=' + encodeURIComponent('Password must be at least 8 characters.'));
}
if (password !== confirmPassword) {
return res.redirect('/admin/users?message=' + encodeURIComponent('Passwords do not match.'));
}
const [rows] = await pool.query('SELECT id FROM users WHERE id = ? LIMIT 1', [userId]);
if (!rows.length) {
return res.status(404).send('User not found.');
}
const passwordRecord = hashPassword(password);
await pool.query(
'UPDATE users SET password_hash = ?, password_salt = ?, password_iterations = ?, modified_by = ? WHERE id = ?',
[passwordRecord.hash, passwordRecord.salt, passwordRecord.iterations, getAuditUserId(req), userId]
);
await pool.query('DELETE FROM auth_sessions WHERE user_id = ?', [userId]);
res.redirect('/admin/users?message=' + encodeURIComponent('Password updated.'));
} catch (error) {
next(error);
}
});
app.post('/admin/users/:id/delete', async function (req, res, next) {
try {
const userId = Number(req.params.id);
if (!Number.isInteger(userId) || userId <= 0) {
return res.status(400).send('Invalid user.');
}
if (Number(req.currentUser.id) === userId) {
return res.redirect('/admin/users?message=' + encodeURIComponent('You cannot delete your own account from the users page.'));
}
const [countRows] = await pool.query('SELECT COUNT(*) AS user_count FROM users');
if (!countRows.length || Number(countRows[0].user_count) <= 1) {
return res.redirect('/admin/users?message=' + encodeURIComponent('At least one user must remain.'));
}
const [result] = await pool.query('DELETE FROM users WHERE id = ?', [userId]);
if (!result.affectedRows) {
return res.status(404).send('User not found.');
}
res.redirect('/admin/users?message=' + encodeURIComponent('User deleted.'));
} catch (error) {
next(error);
}
});
app.get('/admin/clients', async function (req, res, next) {
try {
const data = await common.fetchAdminData(pool);
res.send(pages.renderConnectedClientsPage(data, req.query.message ? String(req.query.message) : '', req.currentUser));
} catch (error) {
next(error);
}
});
async function getDashboardStateSnapshot() {
if (!dashboardRefreshInFlight) {
dashboardRefreshInFlight = buildDashboardState(pool).finally(function () {
dashboardRefreshInFlight = null;
});
}
return dashboardRefreshInFlight;
}
function sendDashboardState(socket, state) {
if (!socket || socket.readyState !== WebSocket.OPEN) {
return;
}
socket.send(buildDashboardPayload(state));
}
async function broadcastDashboardState() {
if (!dashboardClients.size) {
return;
}
const state = await getDashboardStateSnapshot();
const payload = buildDashboardPayload(state);
dashboardClients.forEach(function (socket) {
if (socket.readyState === WebSocket.OPEN) {
socket.send(payload);
}
});
}
async function sendDashboardStateToSocket(socket) {
try {
const state = await getDashboardStateSnapshot();
sendDashboardState(socket, state);
} catch (_error) {
try {
socket.close();
} catch (_closeError) {
// ignore close errors
}
}
}
app.post('/admin/commands', async function (req, res, next) {
try {
const command = String(req.body.command || '').trim().toLowerCase();
if (!command) {
return res.status(400).send('Command is required.');
}
if (['reload', 'blackout'].indexOf(command) === -1) {
return res.status(400).send('Unsupported command.');
}
const slugs = await fetchAllScreenSlugs(pool);
const sent = command === 'blackout'
? await notifyPlayerScreens(slugs, {
command: 'blackout',
blackout: req.body.blackout
})
: await notifyPlayerScreens(slugs, command);
await broadcastDashboardState();
const message = sent
? `Sent ${command} command to ${sent} screen${sent === 1 ? '' : 's'}.`
: `No screens were available for the ${command} command.`;
if (String(req.get('X-Requested-With') || '').toLowerCase() === 'xmlhttprequest') {
return res.json({
ok: true,
command: command,
sent: sent,
message: message
});
}
res.redirect('/admin?message=' + encodeURIComponent(message));
} catch (error) {
next(error);
}
});
app.post('/admin/screens/:slug/commands', async function (req, res, next) {
try {
const command = String(req.body.command || '').trim().toLowerCase();
const connectionId = String(req.body.connectionId || '').trim();
const blackoutValue = req.body && Object.prototype.hasOwnProperty.call(req.body, 'blackout')
? req.body.blackout
: undefined;
if (!command) {
return res.status(400).send('Command is required.');
}
if (['refresh', 'reload', 'pause', 'blackout', 'previous', 'next', 'left', 'right'].indexOf(command) === -1) {
return res.status(400).send('Unsupported command.');
}
const commandPayload = command === 'blackout' && blackoutValue !== undefined
? {
command: command,
blackout: blackoutValue
}
: command;
await forwardPlayerCommand(req.params.slug, commandPayload, connectionId);
await broadcastDashboardState();
if (String(req.get('X-Requested-With') || '').toLowerCase() === 'xmlhttprequest') {
return res.json({
ok: true,
message: connectionId ? `Sent ${command} command to a connected client on ${req.params.slug}.` : `Sent ${command} command to ${req.params.slug}.`
});
}
res.redirect('/admin?message=' + encodeURIComponent(connectionId ? `Sent ${command} command to a connected client on ${req.params.slug}.` : `Sent ${command} command to ${req.params.slug}.`));
} catch (error) {
next(error);
}
});
app.get('/admin/playlists', async function (req, res, next) {
try {
const data = await common.fetchAdminData(pool);
if (req.query.edit) {
const playlist = await common.fetchPlaylistById(pool, Number(req.query.edit));
if (!playlist) {
return res.status(404).send('Playlist not found');
}
return res.send(pages.renderPlaylistEditPage(playlist, data, req.query.message ? String(req.query.message) : '', req.currentUser));
}
res.send(pages.renderPlaylistsPage(data, req.query.message ? String(req.query.message) : '', req.currentUser));
} catch (error) {
next(error);
}
});
app.get('/admin/playlists/new', function (req, res) {
res.send(pages.renderPlaylistFormPage(req.query.message ? String(req.query.message) : '', req.currentUser));
});
app.post('/admin/playlists', async function (req, res, next) {
try {
const name = String(req.body.name || '').trim();
const fadeBetweenSlides = req.body.fade_between_slides ? 1 : 0;
if (!name) {
return res.status(400).send('Playlist name is required.');
}
const actorId = getAuditUserId(req);
await pool.query('INSERT INTO playlists (name, fade_between_slides, created_by, modified_by) VALUES (?, ?, ?, ?)', [name, fadeBetweenSlides, actorId, actorId]);
res.redirect('/admin/playlists?message=' + encodeURIComponent('Playlist created.'));
} catch (error) {
next(error);
}
});
app.post('/admin/playlists/:id', async function (req, res, next) {
const connection = await pool.getConnection();
try {
const name = String(req.body.name || '').trim();
const fadeBetweenSlides = req.body.fade_between_slides ? 1 : 0;
if (!name) {
return res.status(400).send('Playlist name is required.');
}
const playlist = await common.fetchPlaylistById(connection, Number(req.params.id));
if (!playlist) {
return res.status(404).send('Playlist not found');
}
const affectedScreens = await fetchScreensByPlaylistId(connection, playlist.id);
const slideIds = readArrayField(req.body, ['slide_id[]', 'slide_id']);
const durations = readArrayField(req.body, ['duration_seconds[]', 'duration_seconds']);
const scheduleModes = readArrayField(req.body, ['schedule_mode[]', 'schedule_mode']);
const scheduleStartDateTimes = readArrayField(req.body, ['schedule_start_datetime[]', 'schedule_start_datetime']);
const scheduleEndDateTimes = readArrayField(req.body, ['schedule_end_datetime[]', 'schedule_end_datetime']);
const scheduleStartTimes = readArrayField(req.body, ['schedule_start_time[]', 'schedule_start_time']);
const scheduleEndTimes = readArrayField(req.body, ['schedule_end_time[]', 'schedule_end_time']);
const scheduleDaysJsonValues = readArrayField(req.body, ['schedule_days_json[]', 'schedule_days_json']);
if (durations.length && durations.length !== slideIds.length) {
return res.status(400).send('Playlist slide data is invalid.');
}
if (scheduleModes.length && scheduleModes.length !== slideIds.length) {
return res.status(400).send('Playlist schedule data is invalid.');
}
const normalizedSlides = [];
const seenSlideIds = new Set();
for (let i = 0; i < slideIds.length; i += 1) {
const slideId = Number(slideIds[i]);
if (!Number.isInteger(slideId) || slideId <= 0) {
return res.status(400).send('Invalid slide selection.');
}
if (seenSlideIds.has(slideId)) {
return res.status(400).send('A slide can only be added to a playlist once.');
}
seenSlideIds.add(slideId);
const durationRaw = Number(durations[i]);
const durationSeconds = Number.isFinite(durationRaw) ? Math.max(1, Math.trunc(durationRaw)) : 10;
const scheduleMode = normalizeScheduleMode(scheduleModes[i]);
let scheduleStartDatetime = null;
let scheduleEndDatetime = null;
let scheduleStartTime = null;
let scheduleEndTime = null;
let scheduleDaysJson = null;
if (scheduleMode === 'dates') {
scheduleStartDatetime = parseDateTimeLocal(scheduleStartDateTimes[i]);
scheduleEndDatetime = parseDateTimeLocal(scheduleEndDateTimes[i]);
if (!scheduleStartDatetime || !scheduleEndDatetime) {
return res.status(400).send('Start and end datetimes are required for date scheduling.');
}
if (scheduleEndDatetime < scheduleStartDatetime) {
return res.status(400).send('End datetime must be after start datetime.');
}
} else if (scheduleMode === 'times') {
scheduleStartTime = parseTimeLocal(scheduleStartTimes[i]);
scheduleEndTime = parseTimeLocal(scheduleEndTimes[i]);
if (!scheduleStartTime || !scheduleEndTime) {
return res.status(400).send('Start and end times are required for time scheduling.');
}
let scheduleDays = [];
try {
const parsedDays = JSON.parse(String(scheduleDaysJsonValues[i] || '[]'));
scheduleDays = Array.isArray(parsedDays) ? parsedDays : [];
} catch (_error) {
scheduleDays = [];
}
scheduleDays = scheduleDays
.map(function (value) { return Number(value); })
.filter(function (value) { return Number.isInteger(value) && value >= 0 && value <= 6; });
if (!scheduleDays.length) {
return res.status(400).send('Select at least one day for time scheduling.');
}
scheduleDaysJson = JSON.stringify(Array.from(new Set(scheduleDays)).sort());
}
normalizedSlides.push({
slideId,
position: i,
durationSeconds,
scheduleMode,
scheduleStartDatetime,
scheduleEndDatetime,
scheduleStartTime,
scheduleEndTime,
scheduleDaysJson
});
}
if (normalizedSlides.length) {
const [slides] = await connection.query(
`SELECT sl.id, cs.width AS canvas_width, cs.height AS canvas_height
FROM slides sl
LEFT JOIN slide_templates st ON st.id = sl.template_id
LEFT JOIN canvas_sizes cs ON cs.id = st.canvas_size_id
WHERE sl.id IN (?)`,
[normalizedSlides.map(function (item) { return item.slideId; })]
);
if (slides.length !== normalizedSlides.length) {
return res.status(400).send('One or more selected slides no longer exist.');
}
const signatures = Array.from(new Set(
slides
.map(function (slide) { return getCanvasSignature(slide.canvas_width, slide.canvas_height); })
.filter(Boolean)
));
if (signatures.length > 1) {
return res.status(400).send('All playlist slides must share the same canvas size.');
}
}
const actorId = getAuditUserId(req);
await connection.beginTransaction();
await connection.query('UPDATE playlists SET name = ?, fade_between_slides = ?, modified_by = ? WHERE id = ?', [name, fadeBetweenSlides, actorId, playlist.id]);
await connection.query('DELETE FROM playlist_slides WHERE playlist_id = ?', [playlist.id]);
for (let i = 0; i < normalizedSlides.length; i += 1) {
const item = normalizedSlides[i];
await connection.query(
'INSERT INTO playlist_slides (playlist_id, slide_id, position, duration_seconds, schedule_mode, schedule_start_datetime, schedule_end_datetime, schedule_start_time, schedule_end_time, schedule_days_json, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
[
playlist.id,
item.slideId,
item.position,
item.durationSeconds,
item.scheduleMode,
item.scheduleStartDatetime,
item.scheduleEndDatetime,
item.scheduleStartTime,
item.scheduleEndTime,
item.scheduleDaysJson,
actorId,
actorId
]
);
}
await connection.commit();
await notifyPlayerScreens(affectedScreens, 'refresh');
await broadcastDashboardState();
res.redirect('/admin/playlists?edit=' + playlist.id + '&message=' + encodeURIComponent('Playlist updated.'));
} catch (error) {
try {
await connection.rollback();
} catch (_rollbackError) {
// Ignore rollback failures and surface the original error.
}
next(error);
} finally {
connection.release();
}
});
app.post('/admin/playlists/:id/delete', async function (req, res, next) {
try {
const playlist = await common.fetchPlaylistById(pool, Number(req.params.id));
if (!playlist) {
return res.status(404).send('Playlist not found');
}
await pool.query('DELETE FROM playlists WHERE id = ?', [playlist.id]);
res.redirect('/admin/playlists?message=' + encodeURIComponent('Playlist deleted.'));
} catch (error) {
next(error);
}
});
app.post('/admin/playlists/:id/slides', async function (req, res, next) {
try {
const playlist = await common.fetchPlaylistById(pool, Number(req.params.id));
if (!playlist) {
return res.status(404).send('Playlist not found');
}
const slideId = Number(req.body.slide_id);
if (!slideId) {
return res.status(400).send('Slide is required.');
}
const playlistCanvasSignature = await fetchPlaylistCanvasSignature(pool, playlist.id);
if (playlistCanvasSignature === 'mismatch') {
return res.status(400).send('This playlist already contains slides with different canvas sizes.');
}
const slide = await common.fetchSlideById(pool, slideId);
if (!slide) {
return res.status(404).send('Slide not found');
}
const slideCanvasSignature = getCanvasSignature(slide.canvas_width, slide.canvas_height);
if (playlistCanvasSignature && slideCanvasSignature !== playlistCanvasSignature) {
return res.status(400).send('The slide canvas size must match the existing playlist items.');
}
const durationSeconds = Math.max(1, Number(req.body.duration_seconds || 10));
const [positionRows] = await pool.query('SELECT COALESCE(MAX(position), -1) AS max_position FROM playlist_slides WHERE playlist_id = ?', [playlist.id]);
const nextPosition = Number(positionRows[0].max_position) + 1;
const actorId = getAuditUserId(req);
await pool.query('INSERT INTO playlist_slides (playlist_id, slide_id, position, duration_seconds, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?)', [playlist.id, slideId, nextPosition, durationSeconds, actorId, actorId]);
await notifyPlayerScreens(await fetchScreensByPlaylistId(pool, playlist.id), 'refresh');
await broadcastDashboardState();
res.redirect('/admin/playlists?edit=' + playlist.id + '&message=' + encodeURIComponent('Slide added to playlist.'));
} catch (error) {
next(error);
}
});
app.post('/admin/playlists/:id/slides/:playlistSlideId', async function (req, res, next) {
try {
const playlist = await common.fetchPlaylistById(pool, Number(req.params.id));
if (!playlist) {
return res.status(404).send('Playlist not found');
}
const playlistSlideId = Number(req.params.playlistSlideId);
const durationSeconds = Math.max(1, Number(req.body.duration_seconds || 10));
const actorId = getAuditUserId(req);
const [result] = await pool.query(
'UPDATE playlist_slides SET duration_seconds = ?, modified_by = ? WHERE id = ? AND playlist_id = ?',
[durationSeconds, actorId, playlistSlideId, playlist.id]
);
if (!result.affectedRows) {
return res.status(404).send('Playlist slide not found');
}
await notifyPlayerScreens(await fetchScreensByPlaylistId(pool, playlist.id), 'refresh');
await broadcastDashboardState();
res.redirect('/admin/playlists?edit=' + playlist.id + '&message=' + encodeURIComponent('Slide duration updated.'));
} catch (error) {
next(error);
}
});
app.post('/admin/playlists/:id/slides/:playlistSlideId/move', async function (req, res, next) {
const connection = await pool.getConnection();
try {
const playlist = await common.fetchPlaylistById(connection, Number(req.params.id));
if (!playlist) {
return res.status(404).send('Playlist not found');
}
const playlistSlideId = Number(req.params.playlistSlideId);
const direction = String(req.body.direction || '').toLowerCase();
if (direction !== 'up' && direction !== 'down') {
return res.status(400).send('Invalid move direction.');
}
await connection.beginTransaction();
const orderedSlides = await fetchOrderedPlaylistSlides(connection, playlist.id);
const currentIndex = orderedSlides.findIndex(function (item) {
return Number(item.id) === playlistSlideId;
});
if (currentIndex === -1) {
await connection.rollback();
return res.status(404).send('Playlist slide not found');
}
const swapIndex = direction === 'up' ? currentIndex - 1 : currentIndex + 1;
if (swapIndex < 0 || swapIndex >= orderedSlides.length) {
await connection.rollback();
return res.redirect('/admin/playlists?edit=' + playlist.id + '&message=' + encodeURIComponent('Slide is already at the ' + (direction === 'up' ? 'top' : 'bottom') + '.'));
}
const currentSlide = orderedSlides[currentIndex];
const swapSlide = orderedSlides[swapIndex];
const actorId = getAuditUserId(req);
await connection.query('UPDATE playlist_slides SET position = ?, modified_by = ? WHERE id = ? AND playlist_id = ?', [swapSlide.position, actorId, currentSlide.id, playlist.id]);
await connection.query('UPDATE playlist_slides SET position = ?, modified_by = ? WHERE id = ? AND playlist_id = ?', [currentSlide.position, actorId, swapSlide.id, playlist.id]);
await connection.commit();
await notifyPlayerScreens(await fetchScreensByPlaylistId(connection, playlist.id), 'refresh');
await broadcastDashboardState();
res.redirect('/admin/playlists?edit=' + playlist.id + '&message=' + encodeURIComponent('Slide order updated.'));
} catch (error) {
await connection.rollback();
next(error);
} finally {
connection.release();
}
});
app.get('/admin/playlists/:id/slides/:playlistSlideId/config', async function (req, res, next) {
try {
const playlist = await common.fetchPlaylistById(pool, Number(req.params.id));
if (!playlist) {
return res.status(404).send('Playlist not found');
}
const data = await common.fetchAdminData(pool);
const playlistSlide = (data.playlistSlides || []).find(function (item) {
return item.id === Number(req.params.playlistSlideId) && item.playlist_id === playlist.id;
});
if (!playlistSlide) {
return res.status(404).send('Playlist slide not found');
}
playlistSlide.schedule_days = common.parseJsonSafe(playlistSlide.schedule_days_json) || [];
return res.send(pages.renderPlaylistSlideConfigPage(playlist, playlistSlide, req.query.message ? String(req.query.message) : '', req.query.row_key ? String(req.query.row_key) : '', req.currentUser));
} catch (error) {
next(error);
}
});
app.post('/admin/playlists/:id/slides/:playlistSlideId/config', async function (req, res, next) {
try {
const playlist = await common.fetchPlaylistById(pool, Number(req.params.id));
if (!playlist) {
return res.status(404).send('Playlist not found');
}
const rowKey = String(req.body.row_key || '').trim();
if (rowKey) {
return res.status(400).send('Schedule changes from the playlist editor are staged until you click Save changes.');
}
const playlistSlideId = Number(req.params.playlistSlideId);
let scheduleMode = normalizeScheduleMode(req.body.schedule_mode);
let scheduleStartDatetime = null;
let scheduleEndDatetime = null;
let scheduleStartTime = null;
let scheduleEndTime = null;
let scheduleDaysJson = null;
const hasDateRange = Boolean(req.body.schedule_start_datetime && req.body.schedule_end_datetime);
const hasTimeRange = Boolean(req.body.schedule_start_time && req.body.schedule_end_time);
const hasSelectedDays = Boolean(readArrayField(req.body, ['schedule_days', 'schedule_days[]']).length);
if (scheduleMode === 'dates' && !hasDateRange) {
scheduleMode = 'always';
} else if (scheduleMode === 'times' && (!hasTimeRange || !hasSelectedDays)) {
scheduleMode = 'always';
}
if (scheduleMode === 'dates') {
scheduleStartDatetime = parseDateTimeLocal(req.body.schedule_start_datetime);
scheduleEndDatetime = parseDateTimeLocal(req.body.schedule_end_datetime);
if (!scheduleStartDatetime || !scheduleEndDatetime) {
scheduleMode = 'always';
scheduleStartDatetime = null;
scheduleEndDatetime = null;
} else if (scheduleEndDatetime < scheduleStartDatetime) {
return res.status(400).send('End datetime must be after start datetime.');
}
} else if (scheduleMode === 'times') {
scheduleStartTime = parseTimeLocal(req.body.schedule_start_time);
scheduleEndTime = parseTimeLocal(req.body.schedule_end_time);
const scheduleDays = readArrayField(req.body, ['schedule_days', 'schedule_days[]']).map(function (value) {
return Number(value);
}).filter(function (value) {
return Number.isInteger(value) && value >= 0 && value <= 6;
});
if (!scheduleStartTime || !scheduleEndTime) {
scheduleMode = 'always';
scheduleStartTime = null;
scheduleEndTime = null;
scheduleDaysJson = null;
} else if (!scheduleDays.length) {
scheduleMode = 'always';
scheduleStartTime = null;
scheduleEndTime = null;
scheduleDaysJson = null;
} else {
if (scheduleEndTime < scheduleStartTime) {
return res.status(400).send('End time must be after start time.');
}
scheduleDaysJson = JSON.stringify(Array.from(new Set(scheduleDays)).sort());
}
}
const actorId = getAuditUserId(req);
const [result] = await pool.query(
'UPDATE playlist_slides SET schedule_mode = ?, schedule_start_datetime = ?, schedule_end_datetime = ?, schedule_start_time = ?, schedule_end_time = ?, schedule_days_json = ?, modified_by = ? WHERE id = ? AND playlist_id = ?',
[scheduleMode, scheduleStartDatetime, scheduleEndDatetime, scheduleStartTime, scheduleEndTime, scheduleDaysJson, actorId, playlistSlideId, playlist.id]
);
if (!result.affectedRows) {
return res.status(404).send('Playlist slide not found');
}
await notifyPlayerScreens(await fetchScreensByPlaylistId(pool, playlist.id), 'refresh');
await broadcastDashboardState();
res.redirect('/admin/playlists?edit=' + playlist.id + '&message=' + encodeURIComponent('Slide timings updated.'));
} catch (error) {
next(error);
}
});
app.post('/admin/playlists/:id/slides/:playlistSlideId/delete', async function (req, res, next) {
try {
const playlist = await common.fetchPlaylistById(pool, Number(req.params.id));
if (!playlist) {
return res.status(404).send('Playlist not found');
}
await pool.query('DELETE FROM playlist_slides WHERE id = ? AND playlist_id = ?', [Number(req.params.playlistSlideId), playlist.id]);
await notifyPlayerScreens(await fetchScreensByPlaylistId(pool, playlist.id), 'refresh');
await broadcastDashboardState();
res.redirect('/admin/playlists?edit=' + playlist.id + '&message=' + encodeURIComponent('Slide removed.'));
} catch (error) {
next(error);
}
});
app.get('/admin/screens', async function (req, res, next) {
try {
const data = await buildDashboardState(pool);
if (req.query.edit) {
const screen = await common.fetchScreenById(pool, Number(req.query.edit));
if (!screen) {
return res.status(404).send('Screen not found');
}
const editData = await common.fetchScreenEditData(pool);
return res.send(pages.renderScreenEditPage(screen, editData, req.query.message ? String(req.query.message) : '', req.currentUser));
}
res.send(pages.renderScreensPage(data, req.query.message ? String(req.query.message) : '', req.currentUser));
} catch (error) {
next(error);
}
});
app.get('/admin/screens/:id/edit', 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 editData = await common.fetchScreenEditData(pool);
return res.send(pages.renderScreenEditPage(screen, editData, req.query.message ? String(req.query.message) : '', req.currentUser));
} catch (error) {
next(error);
}
});
app.get('/admin/playlists/:id/edit', async function (req, res, next) {
try {
const playlist = await common.fetchPlaylistById(pool, Number(req.params.id));
if (!playlist) {
return res.status(404).send('Playlist not found');
}
const data = await common.fetchAdminData(pool);
res.send(pages.renderPlaylistEditPage(playlist, data, req.query.message ? String(req.query.message) : '', req.currentUser));
} catch (error) {
next(error);
}
});
app.get('/admin/screens/new', 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('/admin/screens', async function (req, res, next) {
try {
const name = String(req.body.name || '').trim();
if (!name) {
return res.status(400).send('Screen name is required.');
}
const slugInput = String(req.body.slug || '').trim();
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);
await pool.query('INSERT INTO screens (name, slug, playlist_id, created_by, modified_by) VALUES (?, ?, ?, ?, ?)', [name, slug, playlistId, actorId, actorId]);
res.redirect('/admin/screens?message=' + encodeURIComponent('Screen created.'));
} catch (error) {
next(error);
}
});
app.post('/admin/screens/:id', async function (req, res, next) {
try {
const name = String(req.body.name || '').trim();
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');
}
const slugInput = String(req.body.slug || '').trim();
const playlistId = req.body.playlist_id ? Number(req.body.playlist_id) : null;
const slug = await common.uniqueScreenSlug(pool, common.slugify(slugInput || name), screen.id);
const previousSlug = String(screen.slug || '').trim();
await pool.query('UPDATE screens SET name = ?, slug = ?, playlist_id = ?, modified_by = ? WHERE id = ?', [name, slug, playlistId, getAuditUserId(req), screen.id]);
if (previousSlug && previousSlug !== slug) {
await forwardPlayerCommand(previousSlug, {
command: 'redirect',
url: `${PLAYER_PUBLIC_BASE_URL}/screen/${encodeURIComponent(slug)}`
});
}
res.redirect('/admin/screens?edit=' + screen.id + '&message=' + encodeURIComponent('Screen updated.'));
} catch (error) {
next(error);
}
});
app.post('/admin/screens/:id/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');
}
await pool.query('DELETE FROM screens WHERE id = ?', [screen.id]);
res.redirect('/admin/screens?message=' + encodeURIComponent('Screen deleted.'));
} catch (error) {
next(error);
}
});
app.get('/admin/slides', async function (req, res, next) {
try {
const data = await common.fetchAdminData(pool);
res.send(pages.renderSlidesPage(data, req.query.message ? String(req.query.message) : '', req.currentUser));
} catch (error) {
next(error);
}
});
app.get('/admin/slides/new', async function (req, res, next) {
try {
const data = await common.fetchTemplatesData(pool);
res.send(pages.renderSlideFormPage(data, 'create', null, req.query.message ? String(req.query.message) : '', req.currentUser));
} catch (error) {
next(error);
}
});
app.get('/admin/slides/:id/edit', 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 = await common.fetchTemplatesData(pool);
res.send(pages.renderSlideFormPage(data, 'edit', slide, req.query.message ? String(req.query.message) : '', req.currentUser));
} catch (error) {
next(error);
}
});
app.post('/admin/slides', upload.any(), async function (req, res, next) {
try {
const payload = await common.buildSlidePayload(pool, req, null);
const actorId = getAuditUserId(req);
const [result] = await pool.query(
'INSERT INTO slides (title, body, template_id, content_json, media_path, media_type, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
[payload.title, payload.body, payload.templateId, payload.contentJson, payload.mediaPath, payload.mediaType, actorId, actorId]
);
res.redirect('/admin/slides/' + result.insertId + '/edit?message=' + encodeURIComponent('Slide created.'));
} catch (error) {
next(error);
}
});
app.post('/admin/slides/:id', upload.any(), 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 affectedScreens = await fetchScreensBySlideId(pool, slide.id);
const existingUploadRefs = collectUploadReferencesFromSlide(slide);
const payload = await common.buildSlidePayload(pool, req, slide);
const nextUploadRefs = collectUploadReferencesFromPayload(payload);
const actorId = getAuditUserId(req);
await pool.query(
'UPDATE slides SET title = ?, body = ?, template_id = ?, content_json = ?, media_path = ?, media_type = ?, modified_by = ? WHERE id = ?',
[payload.title, payload.body, payload.templateId, payload.contentJson, payload.mediaPath, payload.mediaType, actorId, slide.id]
);
await removeUnusedUploadFiles(pool, UPLOAD_DIR, Array.from(existingUploadRefs).filter(function (reference) {
return !nextUploadRefs.has(reference);
}));
await notifyPlayerScreens(affectedScreens, 'refresh');
await broadcastDashboardState();
res.redirect('/admin/slides/' + slide.id + '/edit?message=' + encodeURIComponent('Slide updated.'));
} catch (error) {
next(error);
}
});
app.post('/admin/slides/:id/delete', 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 affectedScreens = await fetchScreensBySlideId(pool, slide.id);
const uploadRefs = collectUploadReferencesFromSlide(slide);
await pool.query('DELETE FROM slides WHERE id = ?', [slide.id]);
await removeUnusedUploadFiles(pool, UPLOAD_DIR, Array.from(uploadRefs));
await notifyPlayerScreens(affectedScreens, 'refresh');
await broadcastDashboardState();
res.redirect('/admin/slides?message=' + encodeURIComponent('Slide deleted.'));
} catch (error) {
next(error);
}
});
app.get('/admin/templates', async function (req, res, next) {
try {
const data = await common.fetchTemplatesData(pool);
res.send(pages.renderTemplatesPage(data, req.query.message ? String(req.query.message) : '', req.currentUser));
} catch (error) {
next(error);
}
});
app.get('/admin/templates/new', async function (req, res, next) {
try {
const data = await common.fetchCanvasSizesData(pool);
res.send(pages.renderTemplateFormPage(null, 'create', req.query.message ? String(req.query.message) : '', data.canvasSizes, req.currentUser));
} catch (error) {
next(error);
}
});
app.post('/admin/templates', upload.any(), async function (req, res, next) {
try {
const payload = await common.buildTemplatePayload(pool, req, null);
const actorId = getAuditUserId(req);
const [result] = await pool.query(
'INSERT INTO slide_templates (name, canvas_size_id, background_image_path, created_by, modified_by) VALUES (?, ?, ?, ?, ?)',
[payload.name, payload.canvasSizeId, payload.backgroundImagePath, actorId, actorId]
);
for (let i = 0; i < payload.regions.length; i += 1) {
const region = payload.regions[i];
await pool.query(
'INSERT INTO slide_template_regions (template_id, region_key, region_type, label, font_family, x, y, width, height, z_index, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
[result.insertId, region.region_key, region.region_type, region.label, region.font_family, region.x, region.y, region.width, region.height, region.z_index, actorId, actorId]
);
}
res.redirect('/admin/templates/' + result.insertId + '/edit?message=' + encodeURIComponent('Template created.'));
} catch (error) {
next(error);
}
});
app.get('/admin/templates/:id/edit', async function (req, res, next) {
try {
const template = await common.fetchTemplateById(pool, Number(req.params.id));
if (!template) {
return res.status(404).send('Template not found');
}
const sizeData = await common.fetchCanvasSizesData(pool);
res.send(pages.renderTemplateFormPage(template, 'edit', req.query.message ? String(req.query.message) : '', sizeData.canvasSizes, req.currentUser));
} catch (error) {
next(error);
}
});
app.post('/admin/templates/:id', upload.any(), async function (req, res, next) {
try {
const template = await common.fetchTemplateById(pool, Number(req.params.id));
if (!template) {
return res.status(404).send('Template not found');
}
const existingUploadRefs = collectUploadReferencesFromTemplate(template);
const payload = await common.buildTemplatePayload(pool, req, template);
const nextUploadRefs = collectUploadReferencesFromPayload(payload);
const actorId = getAuditUserId(req);
await pool.query(
'UPDATE slide_templates SET name = ?, canvas_size_id = ?, background_image_path = ?, modified_by = ? WHERE id = ?',
[payload.name, payload.canvasSizeId, payload.backgroundImagePath, actorId, template.id]
);
await removeUnusedUploadFiles(pool, UPLOAD_DIR, Array.from(existingUploadRefs).filter(function (reference) {
return !nextUploadRefs.has(reference);
}));
await pool.query('DELETE FROM slide_template_regions WHERE template_id = ?', [template.id]);
for (let i = 0; i < payload.regions.length; i += 1) {
const region = payload.regions[i];
await pool.query(
'INSERT INTO slide_template_regions (template_id, region_key, region_type, label, font_family, x, y, width, height, z_index, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
[template.id, region.region_key, region.region_type, region.label, region.font_family, region.x, region.y, region.width, region.height, region.z_index, actorId, actorId]
);
}
res.redirect('/admin/templates/' + template.id + '/edit?message=' + encodeURIComponent('Template updated.'));
} catch (error) {
next(error);
}
});
app.post('/admin/templates/:id/delete', async function (req, res, next) {
try {
const template = await common.fetchTemplateById(pool, Number(req.params.id));
if (!template) {
return res.status(404).send('Template not found');
}
const uploadRefs = collectUploadReferencesFromTemplate(template);
await pool.query('UPDATE slides SET template_id = NULL, modified_by = ? WHERE template_id = ?', [getAuditUserId(req), template.id]);
await pool.query('DELETE FROM slide_template_regions WHERE template_id = ?', [template.id]);
await pool.query('DELETE FROM slide_templates WHERE id = ?', [template.id]);
await removeUnusedUploadFiles(pool, UPLOAD_DIR, Array.from(uploadRefs));
res.redirect('/admin/templates?message=' + encodeURIComponent('Template deleted.'));
} catch (error) {
next(error);
}
});
app.get('/admin/canvas-sizes', async function (req, res, next) {
try {
const data = await common.fetchAdminData(pool);
res.send(pages.renderCanvasSizesPage(data, req.query.message ? String(req.query.message) : '', req.currentUser));
} catch (error) {
next(error);
}
});
app.get('/admin/canvas-sizes/new', function (req, res) {
res.send(pages.renderCanvasSizeFormPage(null, 'create', req.query.message ? String(req.query.message) : '', req.currentUser));
});
app.post('/admin/canvas-sizes', async function (req, res, next) {
try {
const payload = common.buildCanvasSizePayload(req, null);
const actorId = getAuditUserId(req);
await pool.query('INSERT INTO canvas_sizes (name, width, height, created_by, modified_by) VALUES (?, ?, ?, ?, ?)', [payload.name, payload.width, payload.height, actorId, actorId]);
res.redirect('/admin/canvas-sizes?message=' + encodeURIComponent('Canvas size created.'));
} catch (error) {
next(error);
}
});
app.get('/admin/canvas-sizes/:id/edit', async function (req, res, next) {
try {
const canvasSize = await common.fetchCanvasSizeById(pool, Number(req.params.id));
if (!canvasSize) {
return res.status(404).send('Canvas size not found');
}
res.send(pages.renderCanvasSizeEditPage(canvasSize, null, req.query.message ? String(req.query.message) : '', req.currentUser));
} catch (error) {
next(error);
}
});
app.post('/admin/canvas-sizes/:id', async function (req, res, next) {
try {
const canvasSize = await common.fetchCanvasSizeById(pool, Number(req.params.id));
if (!canvasSize) {
return res.status(404).send('Canvas size not found');
}
const payload = common.buildCanvasSizePayload(req, canvasSize);
await pool.query('UPDATE canvas_sizes SET name = ?, width = ?, height = ?, modified_by = ? WHERE id = ?', [payload.name, payload.width, payload.height, getAuditUserId(req), canvasSize.id]);
res.redirect('/admin/canvas-sizes?message=' + encodeURIComponent('Canvas size updated.'));
} catch (error) {
next(error);
}
});
app.post('/admin/canvas-sizes/:id/delete', async function (req, res, next) {
try {
const canvasSize = await common.fetchCanvasSizeById(pool, Number(req.params.id));
if (!canvasSize) {
return res.status(404).send('Canvas size not found');
}
await pool.query('UPDATE slide_templates SET canvas_size_id = NULL, modified_by = ? WHERE canvas_size_id = ?', [getAuditUserId(req), canvasSize.id]);
await pool.query('DELETE FROM canvas_sizes WHERE id = ?', [canvasSize.id]);
res.redirect('/admin/canvas-sizes?message=' + encodeURIComponent('Canvas size deleted.'));
} catch (error) {
next(error);
}
});
app.use(function (error, _req, res, _next) {
console.error(error);
res.status(error.statusCode || 500).send(error.statusCode ? error.message : 'Internal server error');
});
await common.ensureSchema(pool);
server.on('upgrade', async function (request, socket, head) {
let pathname = '';
try {
pathname = new URL(request.url, 'http://localhost').pathname;
} catch (_error) {
socket.destroy();
return;
}
if (pathname !== '/ws/admin/dashboard') {
socket.destroy();
return;
}
try {
const currentUser = await loadCurrentUser(pool, request);
if (!currentUser) {
socket.destroy();
return;
}
} catch (_error) {
socket.destroy();
return;
}
dashboardWs.handleUpgrade(request, socket, head, function (ws) {
dashboardWs.emit('connection', ws, request);
});
});
dashboardWs.on('connection', function (socket) {
dashboardClients.add(socket);
sendDashboardStateToSocket(socket);
socket.on('close', function () {
dashboardClients.delete(socket);
});
socket.on('error', function () {
dashboardClients.delete(socket);
});
});
server.listen(PORT, function () {
console.log(`Pulse Signage app listening on port ${PORT}`);
});
setInterval(function () {
broadcastDashboardState().catch(function (error) {
console.error(error);
});
}, dashboardRefreshIntervalMs);
}
module.exports = { start };
if (require.main === module) {
start().catch(function (error) {
console.error(error);
process.exit(1);
});
}