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); }); }