1832 lines
70 KiB
JavaScript
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);
|
|
});
|
|
}
|