Release 2.8.0
Publish Docker Image / build-and-push-existing-registry (./build/Dockerfile, web, pulse-signage-web) (push) Successful in 1m49s
Publish Docker Image / build-and-push-existing-registry (./build/Dockerfile.player, player, pulse-signage-player) (push) Successful in 32s

This commit is contained in:
2026-08-16 17:15:31 +01:00
parent 1bdc122995
commit 203d0bfc01
105 changed files with 4185 additions and 677 deletions
+25 -2
View File
@@ -13,7 +13,26 @@ function normalizeReturnUrl(value) {
return url;
}
module.exports = function renderAccountPage(currentUser, message, returnUrl) {
module.exports = function renderAccountPage(currentUser, message, returnUrl, allowUserSessionRevocation, sessions, passwordRequirements) {
const requirements = passwordRequirements || {
minimumLength: 10,
minimumCategories: 3,
requireLowercase: false,
requireUppercase: false,
requireNumber: false,
requireSymbol: false
};
const requiredCategories = [];
if (requirements.requireLowercase) requiredCategories.push('lowercase');
if (requirements.requireUppercase) requiredCategories.push('uppercase');
if (requirements.requireNumber) requiredCategories.push('number');
if (requirements.requireSymbol) requiredCategories.push('symbol');
const passwordRequirementsText = requiredCategories.length
? 'Use at least ' + requirements.minimumLength + ' characters and include ' + requiredCategories.join(', ') + '.'
: requirements.minimumCategories === 4
? 'Use at least ' + requirements.minimumLength + ' characters and include uppercase, lowercase, number, and symbol.'
: 'Use at least ' + requirements.minimumLength + ' characters and include ' + requirements.minimumCategories + ' of: uppercase, lowercase, number, and symbol.';
return renderView('account/password', {
title: 'My account',
active: 'account',
@@ -21,6 +40,10 @@ module.exports = function renderAccountPage(currentUser, message, returnUrl) {
message: message || '',
username: currentUser ? currentUser.username : '',
name: currentUser ? String(currentUser.name || '') : '',
returnUrl: normalizeReturnUrl(returnUrl)
returnUrl: normalizeReturnUrl(returnUrl),
allowUserSessionRevocation: Boolean(allowUserSessionRevocation),
sessions: Array.isArray(sessions) ? sessions : [],
passwordMinimumLength: requirements.minimumLength,
passwordRequirementsText: passwordRequirementsText
});
};
+139 -5
View File
@@ -1,5 +1,7 @@
// Admin account route registration and profile helpers.
const { fetchAppSettings } = require('../../../data/app-settings');
module.exports = function registerAccountRoutes(app, deps) {
const pool = deps.pool;
const common = deps.common;
@@ -11,9 +13,129 @@ module.exports = function registerAccountRoutes(app, deps) {
const validatePasswordStrength = deps.validatePasswordStrength;
const createUserSession = deps.createUserSession;
const setSessionCookie = deps.setSessionCookie;
const getSessionMaxAgeMs = deps.getSessionMaxAgeMs;
const parseCookies = deps.parseCookies;
const hashSessionToken = deps.hashSessionToken;
const sessionCookieName = deps.sessionCookieName;
const recordRequestAuditEvent = deps.recordRequestAuditEvent;
async function getPasswordRequirements() {
const settings = await fetchAppSettings(pool);
return buildPasswordRequirements(settings);
}
function buildPasswordRequirements(settings) {
return {
minimumLength: settings['security.password_min_length'],
minimumCategories: settings['security.password_min_categories'],
requireLowercase: settings['security.password_require_lowercase'],
requireUppercase: settings['security.password_require_uppercase'],
requireNumber: settings['security.password_require_number'],
requireSymbol: settings['security.password_require_symbol']
};
}
function getSessionMetadata(req) {
const forwardedAddress = String(req && req.headers && req.headers['x-forwarded-for'] || '').split(',')[0].trim();
return {
ipAddress: forwardedAddress || String(req && req.ip || req && req.socket && req.socket.remoteAddress || 'unknown').trim(),
userAgent: req && req.headers && req.headers['user-agent']
};
}
app.get('/account', function (req, res) {
res.send(pages.renderAccountPage(req.currentUser, req.query.message ? String(req.query.message) : '', '/dashboard'));
fetchAppSettings(pool).then(function (settings) {
const passwordRequirements = buildPasswordRequirements(settings);
if (!settings['security.allow_user_session_revocation']) {
return res.send(pages.renderAccountPage(req.currentUser, req.query.message ? String(req.query.message) : '', '/dashboard', false, [], passwordRequirements));
}
const cookies = parseCookies(req.headers.cookie || '');
const tokenHash = cookies[sessionCookieName] ? hashSessionToken(cookies[sessionCookieName]) : '';
return pool.query(
'SELECT id, session_hash, ip_address, user_agent, created_at, last_used_at, expires_at FROM a_sessions WHERE user_id = ? AND expires_at > NOW() ORDER BY last_used_at DESC',
[req.currentUser.id]
).then(function (result) {
const rows = result[0] || [];
const sessions = rows.map(function (session) {
return {
id: Number(session.id),
isCurrent: Boolean(tokenHash && session.session_hash === tokenHash),
ipAddress: String(session.ip_address || 'Unknown'),
userAgent: String(session.user_agent || 'Unknown browser'),
createdAtLabel: formatDashboardDate(session.created_at),
lastUsedAtLabel: formatDashboardDate(session.last_used_at),
expiresAtLabel: formatDashboardDate(session.expires_at)
};
});
res.send(pages.renderAccountPage(req.currentUser, req.query.message ? String(req.query.message) : '', '/dashboard', true, sessions, passwordRequirements));
});
}).catch(function (error) {
res.status(500).send(error.message || 'Unable to load account settings.');
});
});
app.post('/account/sessions/:id/revoke', async function (req, res, next) {
try {
const sessionId = Number(req.params.id);
if (!Number.isInteger(sessionId) || sessionId <= 0) {
return res.status(400).send('Invalid session.');
}
const settings = await fetchAppSettings(pool);
if (!settings['security.allow_user_session_revocation']) {
return res.redirect('/account?message=' + encodeURIComponent('Signing out other sessions is disabled by an administrator.'));
}
const cookies = parseCookies(req.headers.cookie || '');
const token = cookies[sessionCookieName];
if (!token) {
return res.redirect('/account?message=' + encodeURIComponent('Current session could not be identified.'));
}
const [result] = await pool.query(
'DELETE FROM a_sessions WHERE id = ? AND user_id = ? AND session_hash <> ?',
[sessionId, req.currentUser.id, hashSessionToken(token)]
);
if (result && result.affectedRows && typeof recordRequestAuditEvent === 'function') {
await recordRequestAuditEvent(pool, req, {
category: 'sessions',
eventType: 'session.revoked',
actorUserId: req.currentUser.id,
targetType: 'user',
targetId: req.currentUser.id,
targetLabel: req.currentUser.username,
details: { scope: 'single' }
});
}
res.redirect('/account?message=' + encodeURIComponent(result && result.affectedRows ? 'Session signed out.' : 'The current session cannot be signed out.'));
} catch (error) {
next(error);
}
});
app.post('/account/sessions/revoke', async function (req, res, next) {
try {
const settings = await fetchAppSettings(pool);
if (!settings['security.allow_user_session_revocation']) {
return res.redirect('/account?message=' + encodeURIComponent('Signing out other sessions is disabled by an administrator.'));
}
const cookies = parseCookies(req.headers.cookie || '');
const token = cookies[sessionCookieName];
if (token) {
await pool.query('DELETE FROM a_sessions WHERE user_id = ? AND session_hash <> ?', [req.currentUser.id, hashSessionToken(token)]);
}
if (typeof recordRequestAuditEvent === 'function') {
await recordRequestAuditEvent(pool, req, {
category: 'sessions',
eventType: 'session.revoked',
actorUserId: req.currentUser.id,
targetType: 'user',
targetId: req.currentUser.id,
targetLabel: req.currentUser.username,
details: { scope: 'other_sessions' }
});
}
res.redirect('/account?message=' + encodeURIComponent('Other active sessions were signed out.'));
} catch (error) {
next(error);
}
});
app.post('/account/name', async function (req, res, next) {
@@ -53,7 +175,7 @@ module.exports = function registerAccountRoutes(app, deps) {
if (!verifyPassword(currentPassword, user)) {
return res.status(400).send('Current password is incorrect.');
}
const passwordStrengthMessage = validatePasswordStrength(newPassword);
const passwordStrengthMessage = validatePasswordStrength(newPassword, await getPasswordRequirements());
if (passwordStrengthMessage) {
return res.status(400).send(passwordStrengthMessage);
}
@@ -63,13 +185,25 @@ module.exports = function registerAccountRoutes(app, deps) {
const passwordRecord = hashPassword(newPassword);
await pool.query(
'UPDATE a_users SET password_hash = ?, password_salt = ?, password_iterations = ?, modified_by = ? WHERE id = ?',
'UPDATE a_users SET password_hash = ?, password_salt = ?, password_iterations = ?, must_change_password = 0, modified_by = ? WHERE id = ?',
[passwordRecord.hash, passwordRecord.salt, passwordRecord.iterations, getAuditUserId(req), user.id]
);
await pool.query('DELETE FROM a_sessions WHERE user_id = ?', [user.id]);
if (typeof recordRequestAuditEvent === 'function') {
await recordRequestAuditEvent(pool, req, {
category: 'security',
eventType: 'password.changed',
actorUserId: user.id,
targetType: 'user',
targetId: user.id,
targetLabel: user.username,
details: { source: 'account' }
});
}
const token = await createUserSession(pool, user.id);
setSessionCookie(res, token);
const sessionMaxAgeMs = typeof getSessionMaxAgeMs === 'function' ? await getSessionMaxAgeMs() : undefined;
const token = await createUserSession(pool, user.id, sessionMaxAgeMs, getSessionMetadata(req));
setSessionCookie(res, token, sessionMaxAgeMs);
res.redirect('/account?message=' + encodeURIComponent('Password updated.'));
} catch (error) {
next(error);
+42 -10
View File
@@ -3,6 +3,7 @@
const fs = require('fs');
const path = require('path');
const { loadFontLibrary } = require('#src/web/lib/media/font-library');
const { fetchAppSettings } = require('#src/data/app-settings');
const { PERMISSION_DENIED_MESSAGE } = require('#src/rbac');
module.exports = function registerContentRoutes(app, deps) {
@@ -19,6 +20,7 @@ module.exports = function registerContentRoutes(app, deps) {
const removeUnusedUploadFiles = deps.removeUnusedUploadFiles;
const syncPlaylistUploadsOnChange = deps.syncPlaylistUploadsOnChange;
const getAuditUserId = deps.getAuditUserId;
const recordRequestAuditEvent = deps.recordRequestAuditEvent;
const redirectAfterSave = deps.redirectAfterSave;
const notifyPlayerScreens = deps.notifyPlayerScreens;
const broadcastDashboardState = deps.broadcastDashboardState;
@@ -42,6 +44,7 @@ module.exports = function registerContentRoutes(app, deps) {
const rssData = await common.fetchRssFeedsData(pool);
const apiData = typeof common.fetchApiSourcesData === 'function' ? await common.fetchApiSourcesData(pool) : { apiSources: [] };
const timetableData = typeof common.fetchTimetablesData === 'function' ? await common.fetchTimetablesData(pool) : { timetableGroups: [] };
const appSettings = await fetchAppSettings(pool);
const apiSources = Array.isArray(apiData.apiSources) ? apiData.apiSources.map(function (source) {
return Object.assign({}, source, {
responseJson: typeof common.parseJsonSafe === 'function' ? common.parseJsonSafe(source.last_response_json) : null
@@ -60,7 +63,13 @@ module.exports = function registerContentRoutes(app, deps) {
rssFeeds: rssFeeds,
apiSources: apiSources,
timetableGroups: timetableData.timetableGroups || [],
fontLibrary: loadFontLibrary(deps.uploadDir)
fontLibrary: loadFontLibrary(deps.uploadDir),
uploadLimits: {
imageMaxBytes: Number(appSettings['uploads.image_max_bytes']) || IMAGE_UPLOAD_MAX_BYTES,
videoMaxBytes: Number(appSettings['uploads.video_max_bytes']) || VIDEO_UPLOAD_MAX_BYTES,
wysiwygImageMaxBytes: Number(appSettings['uploads.wysiwyg_image_max_bytes']) || WYSIWYG_IMAGE_UPLOAD_MAX_BYTES,
allowedMimeTypes: Array.isArray(appSettings['uploads.allowed_mime_types']) ? appSettings['uploads.allowed_mime_types'] : []
}
});
}
@@ -123,20 +132,24 @@ module.exports = function registerContentRoutes(app, deps) {
return null;
}
function getUploadedFileLimitBytes(file, uploadContext) {
function getUploadedFileLimitBytes(file, uploadContext, settings) {
if (String(uploadContext || '').trim().toLowerCase() === 'wysiwyg') {
return WYSIWYG_IMAGE_UPLOAD_MAX_BYTES;
return Number(settings && settings['uploads.wysiwyg_image_max_bytes']) || WYSIWYG_IMAGE_UPLOAD_MAX_BYTES;
}
return getUploadedFileMediaType(file, uploadContext) === 'video' ? VIDEO_UPLOAD_MAX_BYTES : IMAGE_UPLOAD_MAX_BYTES;
return getUploadedFileMediaType(file, uploadContext) === 'video'
? Number(settings && settings['uploads.video_max_bytes']) || VIDEO_UPLOAD_MAX_BYTES
: Number(settings && settings['uploads.image_max_bytes']) || IMAGE_UPLOAD_MAX_BYTES;
}
function getUploadedFileLimitLabel(file, uploadContext) {
function getUploadedFileLimitLabel(file, uploadContext, settings) {
if (String(uploadContext || '').trim().toLowerCase() === 'wysiwyg') {
return '10 MB';
const limitMb = getUploadedFileLimitBytes(file, uploadContext, settings) / 1024 / 1024;
return (Number.isInteger(limitMb) ? String(limitMb) : limitMb.toFixed(2).replace(/0+$/, '').replace(/\.$/, '')) + ' MB';
}
return getUploadedFileMediaType(file, uploadContext) === 'video' ? '1 GB' : '100 MB';
const limitMb = Math.round(getUploadedFileLimitBytes(file, uploadContext, settings) / 1024 / 1024);
return limitMb >= 1024 && limitMb % 1024 === 0 ? (limitMb / 1024) + ' GB' : limitMb + ' MB';
}
async function removeUploadedFile(file) {
@@ -154,8 +167,18 @@ module.exports = function registerContentRoutes(app, deps) {
async function validateUploadedFiles(files, uploadContext) {
const list = Array.isArray(files) ? files.filter(Boolean) : [];
const settings = await fetchAppSettings(pool);
for (let i = 0; i < list.length; i += 1) {
const file = list[i];
const fileMimeType = String(file && file.mimetype || '').trim().toLowerCase();
const allowedMimeTypes = Array.isArray(settings['uploads.allowed_mime_types']) ? settings['uploads.allowed_mime_types'] : [];
if (allowedMimeTypes.indexOf(fileMimeType) === -1) {
await removeUploadedFile(file);
const error = new Error('This upload type is disabled in Media Uploads settings.');
error.statusCode = 400;
error.expose = true;
throw error;
}
const mediaType = getUploadedFileMediaType(file, uploadContext);
if (!mediaType) {
await removeUploadedFile(file);
@@ -165,11 +188,11 @@ module.exports = function registerContentRoutes(app, deps) {
throw error;
}
if (Number(file.size || 0) > getUploadedFileLimitBytes(file, uploadContext)) {
if (Number(file.size || 0) > getUploadedFileLimitBytes(file, uploadContext, settings)) {
await removeUploadedFile(file);
const error = new Error(String(uploadContext || '').trim().toLowerCase() === 'wysiwyg'
? 'Image must be 2 MB or smaller. Larger images should use the dedicated Image region.'
: 'File must be ' + getUploadedFileLimitLabel(file, uploadContext) + ' or smaller.');
? 'Image must be ' + getUploadedFileLimitLabel(file, uploadContext, settings) + ' or smaller. Larger images should use the dedicated Image region.'
: 'File must be ' + getUploadedFileLimitLabel(file, uploadContext, settings) + ' or smaller.');
error.statusCode = 400;
error.expose = true;
throw error;
@@ -447,6 +470,7 @@ module.exports = function registerContentRoutes(app, deps) {
queueSlideThumbnailRefresh(result.insertId, null).catch(function (error) {
console.warn('Unable to queue slide thumbnail refresh:', error);
});
if (typeof recordRequestAuditEvent === 'function') await recordRequestAuditEvent(pool, req, { category: 'slides', eventType: 'slide.created', actorUserId: actorId, targetType: 'slide', targetId: result.insertId, targetLabel: payload.title });
redirectAfterSave(req, res, '/slides/' + result.insertId + '/edit', {
closeUrl: '/slides',
newUrl: '/slides/new',
@@ -488,6 +512,7 @@ module.exports = function registerContentRoutes(app, deps) {
screenSlideCounts: screenSlideCounts
});
await broadcastDashboardState();
if (typeof recordRequestAuditEvent === 'function') await recordRequestAuditEvent(pool, req, { category: 'slides', eventType: 'slide.updated', actorUserId: actorId, targetType: 'slide', targetId: slide.id, targetLabel: payload.title });
queueSlideThumbnailRefresh(slide.id, slide.thumbnail_path).catch(function (error) {
console.warn('Unable to queue slide thumbnail refresh:', error);
});
@@ -526,6 +551,7 @@ module.exports = function registerContentRoutes(app, deps) {
screenSlideCounts: screenSlideCounts
});
await broadcastDashboardState();
if (typeof recordRequestAuditEvent === 'function') await recordRequestAuditEvent(pool, req, { category: 'slides', eventType: 'slide.deleted', actorUserId: getAuditUserId(req), targetType: 'slide', targetId: slide.id, targetLabel: slide.title });
res.redirect('/slides?message=' + encodeURIComponent('Slide deleted.'));
} catch (error) {
next(error);
@@ -584,6 +610,7 @@ module.exports = function registerContentRoutes(app, deps) {
localUploadDir: deps.uploadDir,
nextUploadRefs: collectUploadReferencesFromPayload(payload)
});
if (typeof recordRequestAuditEvent === 'function') await recordRequestAuditEvent(pool, req, { category: 'templates', eventType: 'template.created', actorUserId: actorId, targetType: 'template', targetId: result.insertId, targetLabel: payload.name });
redirectAfterSave(req, res, '/templates/' + result.insertId + '/edit', {
closeUrl: '/templates',
newUrl: '/templates/new',
@@ -654,6 +681,7 @@ module.exports = function registerContentRoutes(app, deps) {
});
}
await notifyPlayerScreens(affectedScreens, 'refresh');
if (typeof recordRequestAuditEvent === 'function') await recordRequestAuditEvent(pool, req, { category: 'templates', eventType: 'template.updated', actorUserId: actorId, targetType: 'template', targetId: template.id, targetLabel: payload.name });
redirectAfterSave(req, res, '/templates/' + template.id + '/edit', {
closeUrl: '/templates',
newUrl: '/templates/new',
@@ -686,6 +714,7 @@ module.exports = function registerContentRoutes(app, deps) {
previousUploadRefs: uploadRefs
});
await notifyPlayerScreens(affectedScreens, 'refresh');
if (typeof recordRequestAuditEvent === 'function') await recordRequestAuditEvent(pool, req, { category: 'templates', eventType: 'template.deleted', actorUserId: getAuditUserId(req), targetType: 'template', targetId: template.id, targetLabel: template.name });
res.redirect('/templates?message=' + encodeURIComponent('Template deleted.'));
} catch (error) {
next(error);
@@ -743,6 +772,7 @@ module.exports = function registerContentRoutes(app, deps) {
}
const actorId = getAuditUserId(req);
const [result] = await pool.query('INSERT INTO c_canvas_sizes (name, width, height, created_by, modified_by) VALUES (?, ?, ?, ?, ?)', [payload.name, payload.width, payload.height, actorId, actorId]);
if (typeof recordRequestAuditEvent === 'function') await recordRequestAuditEvent(pool, req, { category: 'canvas-sizes', eventType: 'canvas-size.created', actorUserId: actorId, targetType: 'canvas-size', targetId: result.insertId, targetLabel: payload.name, details: { width: payload.width, height: payload.height } });
redirectAfterSave(req, res, '/canvas-sizes/' + result.insertId + '/edit', {
closeUrl: '/canvas-sizes',
newUrl: '/canvas-sizes/new',
@@ -784,6 +814,7 @@ module.exports = function registerContentRoutes(app, deps) {
return res.status(400).send('That canvas size already exists.');
}
await pool.query('UPDATE c_canvas_sizes SET name = ?, width = ?, height = ?, modified_by = ? WHERE id = ?', [payload.name, payload.width, payload.height, getAuditUserId(req), canvasSize.id]);
if (typeof recordRequestAuditEvent === 'function') await recordRequestAuditEvent(pool, req, { category: 'canvas-sizes', eventType: 'canvas-size.updated', actorUserId: getAuditUserId(req), targetType: 'canvas-size', targetId: canvasSize.id, targetLabel: payload.name, details: { width: payload.width, height: payload.height } });
redirectAfterSave(req, res, '/canvas-sizes', {
closeUrl: '/canvas-sizes',
newUrl: '/canvas-sizes/new',
@@ -806,6 +837,7 @@ module.exports = function registerContentRoutes(app, deps) {
}
await pool.query('UPDATE c_templates SET canvas_size_id = NULL, modified_by = ? WHERE canvas_size_id = ?', [getAuditUserId(req), canvasSize.id]);
await pool.query('DELETE FROM c_canvas_sizes WHERE id = ?', [canvasSize.id]);
if (typeof recordRequestAuditEvent === 'function') await recordRequestAuditEvent(pool, req, { category: 'canvas-sizes', eventType: 'canvas-size.deleted', actorUserId: getAuditUserId(req), targetType: 'canvas-size', targetId: canvasSize.id, targetLabel: canvasSize.name });
res.redirect('/canvas-sizes?message=' + encodeURIComponent('Canvas size deleted.'));
} catch (error) {
next(error);
+4
View File
@@ -5,6 +5,7 @@ module.exports = function registerManageRoutes(app, deps) {
const common = deps.common;
const pages = deps.pages;
const getAuditUserId = deps.getAuditUserId;
const recordRequestAuditEvent = deps.recordRequestAuditEvent;
const redirectAfterSave = deps.redirectAfterSave;
const notifyPlayerScreens = deps.notifyPlayerScreens;
const broadcastDashboardState = deps.broadcastDashboardState;
@@ -217,6 +218,7 @@ module.exports = function registerManageRoutes(app, deps) {
const slug = await common.uniqueScreenSlug(pool, common.slugify(slugInput || name));
const actorId = getAuditUserId(req);
const [result] = await pool.query('INSERT INTO d_screens (name, slug, playlist_id, created_by, modified_by) VALUES (?, ?, ?, ?, ?)', [name, slug, playlistId, actorId, actorId]);
if (typeof recordRequestAuditEvent === 'function') await recordRequestAuditEvent(pool, req, { category: 'screens', eventType: 'screen.created', actorUserId: actorId, targetType: 'screen', targetId: result.insertId, targetLabel: name, details: { slug: slug, playlistId: playlistId } });
redirectAfterSave(req, res, '/screens?edit=' + result.insertId, {
closeUrl: '/screens',
newUrl: '/screens/new',
@@ -262,6 +264,7 @@ module.exports = function registerManageRoutes(app, deps) {
await forwardPlayerCommand(previousSlug, redirectPayload);
}
}
if (typeof recordRequestAuditEvent === 'function') await recordRequestAuditEvent(pool, req, { category: 'screens', eventType: 'screen.updated', actorUserId: getAuditUserId(req), targetType: 'screen', targetId: screen.id, targetLabel: name, details: { slug: slug, playlistId: playlistId } });
redirectAfterSave(req, res, '/screens?edit=' + screen.id, {
closeUrl: '/screens',
newUrl: '/screens/new',
@@ -283,6 +286,7 @@ module.exports = function registerManageRoutes(app, deps) {
return res.redirect('/screens?message=' + encodeURIComponent(blockMessage));
}
await pool.query('DELETE FROM d_screens WHERE id = ?', [screen.id]);
if (typeof recordRequestAuditEvent === 'function') await recordRequestAuditEvent(pool, req, { category: 'screens', eventType: 'screen.deleted', actorUserId: getAuditUserId(req), targetType: 'screen', targetId: screen.id, targetLabel: screen.name });
res.redirect('/screens?message=' + encodeURIComponent('Screen deleted.'));
} catch (error) {
next(error);
+9 -1
View File
@@ -1,5 +1,7 @@
// Playlist admin routes and playlist-slide management.
const { fetchAppSettings } = require('../../../data/app-settings');
module.exports = function registerPlaylistRoutes(app, deps) {
const pool = deps.pool;
const common = deps.common;
@@ -9,6 +11,7 @@ module.exports = function registerPlaylistRoutes(app, deps) {
const fetchPlaylistCanvasId = deps.fetchPlaylistCanvasId;
const readArrayField = deps.readArrayField;
const getAuditUserId = deps.getAuditUserId;
const recordRequestAuditEvent = deps.recordRequestAuditEvent;
const redirectAfterSave = deps.redirectAfterSave;
const notifyPlayerScreens = deps.notifyPlayerScreens;
const broadcastDashboardState = deps.broadcastDashboardState;
@@ -295,6 +298,7 @@ module.exports = function registerPlaylistRoutes(app, deps) {
};
await savePlaylistItems(connection, playlist, req.body, actorId, { updatePlaylist: false });
await connection.commit();
if (typeof recordRequestAuditEvent === 'function') await recordRequestAuditEvent(pool, req, { category: 'playlists', eventType: 'playlist.created', actorUserId: actorId, targetType: 'playlist', targetId: result.insertId, targetLabel: name });
redirectAfterSave(req, res, '/playlists/' + result.insertId + '/edit', {
closeUrl: '/playlists',
newUrl: '/playlists/new',
@@ -352,6 +356,7 @@ module.exports = function registerPlaylistRoutes(app, deps) {
await connection.commit();
await notifyPlayerScreens(saveResult.affectedScreens, 'refresh');
await broadcastDashboardState();
if (typeof recordRequestAuditEvent === 'function') await recordRequestAuditEvent(pool, req, { category: 'playlists', eventType: 'playlist.updated', actorUserId: actorId, targetType: 'playlist', targetId: playlist.id, targetLabel: name });
redirectAfterSave(req, res, '/playlists/' + playlist.id + '/edit', {
closeUrl: '/playlists',
newUrl: '/playlists/new',
@@ -383,6 +388,7 @@ module.exports = function registerPlaylistRoutes(app, deps) {
return res.redirect('/playlists?message=' + encodeURIComponent(blockMessage));
}
await pool.query('DELETE FROM c_playlists WHERE id = ?', [playlist.id]);
if (typeof recordRequestAuditEvent === 'function') await recordRequestAuditEvent(pool, req, { category: 'playlists', eventType: 'playlist.deleted', actorUserId: getAuditUserId(req), targetType: 'playlist', targetId: playlist.id, targetLabel: playlist.name });
res.redirect('/playlists?message=' + encodeURIComponent('Playlist deleted.'));
} catch (error) {
next(error);
@@ -411,7 +417,9 @@ module.exports = function registerPlaylistRoutes(app, deps) {
if (playlistCanvasId && slideCanvasId !== playlistCanvasId) {
return res.status(400).send('The slide canvas size must match the existing playlist items.');
}
const durationValue = Number(req.body.duration_seconds || 10);
const hasSubmittedDuration = req.body.duration_seconds !== undefined && String(req.body.duration_seconds).trim() !== '';
const settings = hasSubmittedDuration ? null : await fetchAppSettings(pool);
const durationValue = Number(hasSubmittedDuration ? req.body.duration_seconds : settings['player.default_slide_duration_seconds']);
const durationSeconds = Number.isFinite(durationValue) ? Math.max(0.001, Math.round(durationValue * 1000) / 1000) : 10;
const [positionRows] = await pool.query('SELECT COALESCE(MAX(position), -1) AS max_position FROM c_playlist_slides WHERE playlist_id = ?', [playlist.id]);
const nextPosition = Number(positionRows[0].max_position) + 1;
+87 -23
View File
@@ -5,6 +5,7 @@
const common = deps.common;
const pages = deps.pages;
const getAuditUserId = deps.getAuditUserId;
const recordRequestAuditEvent = deps.recordRequestAuditEvent;
const rbacData = deps.rbacData;
const permissions = Array.isArray(deps.permissions) ? deps.permissions : [];
const readArrayField = deps.readArrayField;
@@ -114,6 +115,7 @@
sectionIndex: toSortIndex(permission.sectionIndex),
sectionOrder: Number(permission.sectionOrder) || 999,
resourceIndex: toSortIndex(permission.resourceIndex),
resourceOrder: Number(permission.resourceOrder) || 999,
permissions: []
};
groupIndex.set(sectionKey, group);
@@ -191,7 +193,7 @@
};
}
app.get('/rbac', requirePermission('rbac.read'), async function (req, res, next) {
app.get('/settings/roles', requirePermission('rbac.read'), async function (req, res, next) {
try {
const page = Math.max(1, Math.floor(Number(req.query.page) || 1));
const search = common.getSearchQuery(req);
@@ -207,7 +209,7 @@
}
});
app.get('/rbac/new', requirePermission('rbac.create'), function (req, res, next) {
app.get('/settings/roles/new', requirePermission('rbac.create'), function (req, res, next) {
const page = Math.max(1, Math.floor(Number(req.query.page) || 1));
const search = common.getSearchQuery(req);
const sort = common.getSortQuery(req);
@@ -223,7 +225,7 @@
});
});
app.get('/rbac/:id/duplicate', requirePermission('rbac.read'), requirePermission('rbac.create'), async function (req, res, next) {
app.get('/settings/roles/:id/duplicate', requirePermission('rbac.read'), requirePermission('rbac.create'), async function (req, res, next) {
try {
const roleId = Number(req.params.id);
if (!Number.isInteger(roleId) || roleId <= 0) {
@@ -252,7 +254,7 @@
}
});
app.post('/rbac', requirePermission('rbac.create'), async function (req, res, next) {
app.post('/settings/roles', requirePermission('rbac.create'), async function (req, res, next) {
try {
const name = common.validateMaxLength(req.body.name || '', ROLE_NAME_MAX_LENGTH, 'Role name');
const description = common.validateMaxLength(req.body.description || '', ROLE_DESCRIPTION_MAX_LENGTH, 'Role description');
@@ -319,13 +321,24 @@
} finally {
connection.release();
}
res.redirect('/rbac/' + insertedRoleId + '/edit?message=' + encodeURIComponent('Role created.'));
if (typeof recordRequestAuditEvent === 'function') {
await recordRequestAuditEvent(pool, req, {
category: 'roles',
eventType: 'role.created',
actorUserId: actorId,
targetType: 'role',
targetId: insertedRoleId,
targetLabel: name,
details: { permissionKeys: selectedPermissionKeys, userIds: normalizedUserIds }
});
}
res.redirect('/settings/roles/' + insertedRoleId + '/edit?message=' + encodeURIComponent('Role created.'));
} catch (error) {
next(error);
}
});
app.get('/rbac/:id/edit', requirePermission('rbac.update'), async function (req, res, next) {
app.get('/settings/roles/:id/edit', requirePermission('rbac.update'), async function (req, res, next) {
try {
const roleId = Number(req.params.id);
if (!Number.isInteger(roleId) || roleId <= 0) {
@@ -354,7 +367,7 @@
}
});
app.post('/rbac/:id', requirePermission('rbac.update'), async function (req, res, next) {
app.post('/settings/roles/:id', requirePermission('rbac.update'), async function (req, res, next) {
try {
const roleId = Number(req.params.id);
if (!Number.isInteger(roleId) || roleId <= 0) {
@@ -376,15 +389,18 @@
const selectedUserIds = shouldSyncUsers
? readArrayField(req.body, ['user_ids[]', 'user_ids'])
: [];
const visibleUserIds = shouldSyncUsers
? normalizeSelectedIds(readArrayField(req.body, ['user_ids_present[]', 'user_ids_present']))
: [];
const normalizedPermissionKeys = shouldSyncPermissions ? normalizePermissionKeys(selectedPermissionKeys) : [];
const normalizedUserIds = shouldSyncUsers ? normalizeSelectedIds(selectedUserIds) : [];
let normalizedUserIds = shouldSyncUsers ? normalizeSelectedIds(selectedUserIds) : [];
if (!name) {
return res.redirect('/rbac/' + roleId + '/edit?message=' + encodeURIComponent('Role name is required.'));
return res.redirect('/settings/roles/' + roleId + '/edit?message=' + encodeURIComponent('Role name is required.'));
}
if (await common.fetchDuplicateName(pool, 'a_roles', name, roleId)) {
return res.redirect('/rbac/' + roleId + '/edit?message=' + encodeURIComponent('A role with that name already exists.'));
return res.redirect('/settings/roles/' + roleId + '/edit?message=' + encodeURIComponent('A role with that name already exists.'));
}
const validPermissionKeys = new Set(permissions.map(function (permission) {
@@ -393,12 +409,17 @@
if (shouldSyncPermissions && normalizedPermissionKeys.some(function (permissionKey) {
return !validPermissionKeys.has(permissionKey);
})) {
return res.redirect('/rbac/' + roleId + '/edit?message=' + encodeURIComponent('One or more selected permissions are invalid.'));
return res.redirect('/settings/roles/' + roleId + '/edit?message=' + encodeURIComponent('One or more selected permissions are invalid.'));
}
let availableUsers = [];
if (shouldSyncUsers) {
availableUsers = await rbacData.fetchUsersWithRoles(pool);
const existingRoleUserIds = await rbacData.fetchRoleUserIds(pool, roleId);
const visibleUserIdSet = new Set(visibleUserIds);
normalizedUserIds = existingRoleUserIds.filter(function (userId) {
return !visibleUserIdSet.has(userId);
}).concat(normalizedUserIds);
}
const validUserIds = new Set(availableUsers.map(function (user) {
return Number(user.id);
@@ -406,7 +427,7 @@
if (shouldSyncUsers && normalizedUserIds.some(function (userId) {
return !validUserIds.has(userId);
})) {
return res.redirect('/rbac/' + roleId + '/edit?message=' + encodeURIComponent('One or more selected users are invalid.'));
return res.redirect('/settings/roles/' + roleId + '/edit?message=' + encodeURIComponent('One or more selected users are invalid.'));
}
const connection = await pool.getConnection();
@@ -429,13 +450,24 @@
} finally {
connection.release();
}
res.redirect('/rbac/' + roleId + '/edit?message=' + encodeURIComponent('Role updated.'));
if (typeof recordRequestAuditEvent === 'function') {
await recordRequestAuditEvent(pool, req, {
category: 'roles',
eventType: 'role.updated',
actorUserId: req.currentUser.id,
targetType: 'role',
targetId: roleId,
targetLabel: name,
details: { permissionsChanged: shouldSyncPermissions, usersChanged: shouldSyncUsers }
});
}
res.redirect('/settings/roles/' + roleId + '/edit?message=' + encodeURIComponent('Role updated.'));
} catch (error) {
next(error);
}
});
app.post('/rbac/:id/permissions', requirePermission('rbac.update'), async function (req, res, next) {
app.post('/settings/roles/:id/permissions', requirePermission('rbac.update'), async function (req, res, next) {
try {
const roleId = Number(req.params.id);
if (!Number.isInteger(roleId) || roleId <= 0) {
@@ -459,7 +491,7 @@
if (normalizedPermissionKeys.some(function (permissionKey) {
return !validPermissionKeys.has(permissionKey);
})) {
return res.redirect('/rbac/' + roleId + '/edit?message=' + encodeURIComponent('One or more selected permissions are invalid.'));
return res.redirect('/settings/roles/' + roleId + '/edit?message=' + encodeURIComponent('One or more selected permissions are invalid.'));
}
const connection = await pool.getConnection();
@@ -473,13 +505,24 @@
} finally {
connection.release();
}
res.redirect('/rbac/' + roleId + '/edit?message=' + encodeURIComponent('Permissions updated.'));
if (typeof recordRequestAuditEvent === 'function') {
await recordRequestAuditEvent(pool, req, {
category: 'roles',
eventType: 'role.permissions_updated',
actorUserId: req.currentUser.id,
targetType: 'role',
targetId: roleId,
targetLabel: role.name,
details: { permissionKeys: normalizedPermissionKeys }
});
}
res.redirect('/settings/roles/' + roleId + '/edit?message=' + encodeURIComponent('Permissions updated.'));
} catch (error) {
next(error);
}
});
app.post('/rbac/:id/users', requirePermission('rbac.update'), async function (req, res, next) {
app.post('/settings/roles/:id/users', requirePermission('rbac.update'), async function (req, res, next) {
try {
const roleId = Number(req.params.id);
if (!Number.isInteger(roleId) || roleId <= 0) {
@@ -505,17 +548,28 @@
if (normalizedUserIds.some(function (userId) {
return !validUserIds.has(userId);
})) {
return res.redirect('/rbac/' + roleId + '/edit?message=' + encodeURIComponent('One or more selected users are invalid.'));
return res.redirect('/settings/roles/' + roleId + '/edit?message=' + encodeURIComponent('One or more selected users are invalid.'));
}
await rbacData.syncRoleUsers(pool, roleId, normalizedUserIds);
res.redirect('/rbac/' + roleId + '/edit?message=' + encodeURIComponent('Users updated.'));
if (typeof recordRequestAuditEvent === 'function') {
await recordRequestAuditEvent(pool, req, {
category: 'roles',
eventType: 'role.users_updated',
actorUserId: req.currentUser.id,
targetType: 'role',
targetId: roleId,
targetLabel: role.name,
details: { userIds: normalizedUserIds }
});
}
res.redirect('/settings/roles/' + roleId + '/edit?message=' + encodeURIComponent('Users updated.'));
} catch (error) {
next(error);
}
});
app.post('/rbac/:id/delete', requirePermission('rbac.delete'), async function (req, res, next) {
app.post('/settings/roles/:id/delete', requirePermission('rbac.delete'), async function (req, res, next) {
try {
const roleId = Number(req.params.id);
if (!Number.isInteger(roleId) || roleId <= 0) {
@@ -527,14 +581,24 @@
return res.status(404).send('Role not found.');
}
if (String(role.role_key || '') === 'administrators') {
return res.redirect('/rbac?message=' + encodeURIComponent('The built-in Administrators role cannot be deleted.'));
return res.redirect('/settings/roles?message=' + encodeURIComponent('The built-in Administrators role cannot be deleted.'));
}
if (Number(role.user_count) > 0) {
return res.redirect('/rbac?message=' + encodeURIComponent('Remove all users from this role before deleting it.'));
return res.redirect('/settings/roles?message=' + encodeURIComponent('Remove all users from this role before deleting it.'));
}
await pool.query('DELETE FROM a_roles WHERE id = ?', [roleId]);
res.redirect('/rbac?message=' + encodeURIComponent('Role deleted.'));
if (typeof recordRequestAuditEvent === 'function') {
await recordRequestAuditEvent(pool, req, {
category: 'roles',
eventType: 'role.deleted',
actorUserId: req.currentUser.id,
targetType: 'role',
targetId: roleId,
targetLabel: role.name
});
}
res.redirect('/settings/roles?message=' + encodeURIComponent('Role deleted.'));
} catch (error) {
next(error);
}
+180 -33
View File
@@ -1,11 +1,14 @@
// Admin user route registration and user-role management.
const { fetchAppSettings } = require('../../../data/app-settings');
module.exports = function registerUsersRoutes(app, deps) {
const pool = deps.pool;
const common = deps.common;
const pages = deps.pages;
const formatDashboardDate = deps.formatDashboardDate;
const getAuditUserId = deps.getAuditUserId;
const recordRequestAuditEvent = deps.recordRequestAuditEvent;
const hashPassword = deps.hashPassword;
const validatePasswordStrength = deps.validatePasswordStrength;
const readArrayField = deps.readArrayField;
@@ -23,6 +26,23 @@
return rbacData.fetchRoles(pool);
}
async function shouldRequirePasswordChange(settingKey) {
const settings = await fetchAppSettings(pool);
return Boolean(settings[settingKey]);
}
async function getPasswordRequirements() {
const settings = await fetchAppSettings(pool);
return {
minimumLength: settings['security.password_min_length'],
minimumCategories: settings['security.password_min_categories'],
requireLowercase: settings['security.password_require_lowercase'],
requireUppercase: settings['security.password_require_uppercase'],
requireNumber: settings['security.password_require_number'],
requireSymbol: settings['security.password_require_symbol']
};
}
function mapRolesForForm(roles, selectedRoleIds) {
const selectedIds = new Set((Array.isArray(selectedRoleIds) ? selectedRoleIds : []).map(function (roleId) {
return Number(roleId);
@@ -57,7 +77,7 @@
return { ok: true, roleIds: normalizedRoleIds };
}
app.get('/users', requirePermission('users.read'), async function (req, res, next) {
app.get('/settings/users', requirePermission('users.read'), async function (req, res, next) {
try {
const page = Math.max(1, Math.floor(Number(req.query.page) || 1));
const search = common.getSearchQuery(req);
@@ -81,7 +101,7 @@
}
});
app.get('/users/new', requirePermission('users.create'), function (req, res) {
app.get('/settings/users/new', requirePermission('users.create'), function (req, res) {
fetchRoleOptions().then(function (roles) {
res.send(pages.renderUsersAddPage(req.query.message ? String(req.query.message) : '', req.currentUser, mapRolesForForm(roles, []), {}, 'primary'));
}).catch(function (error) {
@@ -89,7 +109,7 @@
});
});
app.get('/users/:id/duplicate', requirePermission('users.read'), requirePermission('users.create'), async function (req, res, next) {
app.get('/settings/users/:id/duplicate', requirePermission('users.read'), requirePermission('users.create'), async function (req, res, next) {
try {
const userId = Number(req.params.id);
if (!Number.isInteger(userId) || userId <= 0) {
@@ -100,8 +120,9 @@
if (!user) {
return res.status(404).send('User not found.');
}
if (Number(req.currentUser.id) === userId) {
return res.redirect('/users?message=' + encodeURIComponent('Use Add user to create another login for yourself.'));
return res.redirect('/settings/users?message=' + encodeURIComponent('Use Add user to create another login for yourself.'));
}
const roles = await fetchRoleOptions();
@@ -121,7 +142,7 @@
}
});
app.get('/users/:id/edit', requirePermission('users.update'), async function (req, res, next) {
app.get('/settings/users/:id/edit', requirePermission('users.update'), async function (req, res, next) {
try {
const userId = Number(req.params.id);
if (!Number.isInteger(userId) || userId <= 0) {
@@ -140,19 +161,62 @@
const canDelete = !countRows.length || Number(countRows[0].user_count) > 1;
const roles = await fetchRoleOptions();
const sessions = (await rbacData.fetchActiveUserSessions(pool, userId)).map(function (session) {
return {
id: Number(session.id),
ipAddress: String(session.ip_address || 'Unknown'),
userAgent: String(session.user_agent || 'Unknown browser'),
createdAtLabel: formatDashboardDate(session.created_at),
lastUsedAtLabel: formatDashboardDate(session.last_used_at),
expiresAtLabel: formatDashboardDate(session.expires_at)
};
});
res.send(pages.renderUsersEditPage(Object.assign({}, user, {
isCurrentUser: false,
createdAtLabel: formatDashboardDate(user.created_at),
modifiedAtLabel: formatDashboardDate(user.modified_at),
roleNames: String(user.roleNames || '').trim() || 'No roles assigned',
inUse: !canDelete
}), req.query.message ? String(req.query.message) : '', req.currentUser, mapRolesForForm(roles, user.roleIds)));
}), req.query.message ? String(req.query.message) : '', req.currentUser, mapRolesForForm(roles, user.roleIds), sessions));
} catch (error) {
next(error);
}
});
app.post('/users', requirePermission('users.create'), async function (req, res, next) {
app.post('/settings/users/:id/sessions/:sessionId/revoke', requirePermission('users.update'), async function (req, res, next) {
try {
const userId = Number(req.params.id);
const sessionId = Number(req.params.sessionId);
if (!Number.isInteger(userId) || userId <= 0 || !Number.isInteger(sessionId) || sessionId <= 0) {
return res.status(400).send('Invalid session.');
}
if (Number(req.currentUser.id) === userId) {
return res.redirect('/account?message=' + encodeURIComponent('Use My Account to manage your own sessions.'));
}
const [result] = await pool.query('DELETE FROM a_sessions WHERE id = ? AND user_id = ?', [sessionId, userId]);
res.redirect('/settings/users/' + userId + '/edit?message=' + encodeURIComponent(result && result.affectedRows ? 'Session signed out.' : 'Session was not found.'));
} catch (error) {
next(error);
}
});
app.post('/settings/users/:id/sessions/revoke-all', requirePermission('users.update'), 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('/account?message=' + encodeURIComponent('Use My Account to manage your own sessions.'));
}
await pool.query('DELETE FROM a_sessions WHERE user_id = ?', [userId]);
res.redirect('/settings/users/' + userId + '/edit?message=' + encodeURIComponent('All sessions signed out.'));
} catch (error) {
next(error);
}
});
app.post('/settings/users', requirePermission('users.create'), async function (req, res, next) {
const connection = await pool.getConnection();
try {
const name = common.validateMaxLength(req.body.name || '', USER_NAME_MAX_LENGTH, 'Name');
@@ -178,7 +242,7 @@
if (!username) {
return renderValidationError('Username is required.');
}
const passwordStrengthMessage = validatePasswordStrength(password);
const passwordStrengthMessage = validatePasswordStrength(password, await getPasswordRequirements());
if (passwordStrengthMessage) {
return renderValidationError(passwordStrengthMessage);
}
@@ -198,18 +262,30 @@
}
const passwordRecord = hashPassword(password);
const mustChangePassword = await shouldRequirePasswordChange('security.require_password_change_for_new_users');
const actorId = getAuditUserId(req);
await connection.beginTransaction();
const [result] = await connection.query(
'INSERT INTO a_users (name, username, password_hash, password_salt, password_iterations, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?)',
[name, username, passwordRecord.hash, passwordRecord.salt, passwordRecord.iterations, actorId, actorId]
'INSERT INTO a_users (name, username, password_hash, password_salt, password_iterations, must_change_password, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
[name, username, passwordRecord.hash, passwordRecord.salt, passwordRecord.iterations, mustChangePassword ? 1 : 0, actorId, actorId]
);
await rbacData.syncUserRoles(connection, result.insertId, roleCheck.roleIds);
await connection.commit();
if (saveAction === 'new') {
return res.redirect('/users/new?message=' + encodeURIComponent('User created.'));
if (typeof recordRequestAuditEvent === 'function') {
await recordRequestAuditEvent(pool, req, {
category: 'users',
eventType: 'user.created',
actorUserId: actorId,
targetType: 'user',
targetId: result.insertId,
targetLabel: username,
details: { roleIds: roleCheck.roleIds }
});
}
res.redirect('/users/' + result.insertId + '/edit?message=' + encodeURIComponent('User created.'));
if (saveAction === 'new') {
return res.redirect('/settings/users/new?message=' + encodeURIComponent('User created.'));
}
res.redirect('/settings/users/' + result.insertId + '/edit?message=' + encodeURIComponent('User created.'));
} catch (error) {
try {
await connection.rollback();
@@ -222,7 +298,7 @@
}
});
app.post('/users/:id/roles', requirePermission('users.update'), async function (req, res, next) {
app.post('/settings/users/:id/roles', requirePermission('users.update'), async function (req, res, next) {
try {
const userId = Number(req.params.id);
if (!Number.isInteger(userId) || userId <= 0) {
@@ -240,17 +316,28 @@
const selectedRoleIds = readArrayField(req.body, ['role_ids[]', 'role_ids']);
const roleCheck = await validateRoleIds(selectedRoleIds);
if (!roleCheck.ok) {
return res.redirect('/users/' + userId + '/edit?message=' + encodeURIComponent(roleCheck.message));
return res.redirect('/settings/users/' + userId + '/edit?message=' + encodeURIComponent(roleCheck.message));
}
await rbacData.syncUserRoles(pool, userId, roleCheck.roleIds);
res.redirect('/users/' + userId + '/edit?message=' + encodeURIComponent('Roles updated.'));
if (typeof recordRequestAuditEvent === 'function') {
await recordRequestAuditEvent(pool, req, {
category: 'users',
eventType: 'user.roles_updated',
actorUserId: req.currentUser.id,
targetType: 'user',
targetId: userId,
targetLabel: String(userId),
details: { roleIds: roleCheck.roleIds }
});
}
res.redirect('/settings/users/' + userId + '/edit?message=' + encodeURIComponent('Roles updated.'));
} catch (error) {
next(error);
}
});
app.post('/users/:id/username', requirePermission('users.update'), async function (req, res, next) {
app.post('/settings/users/:id/username', requirePermission('users.update'), async function (req, res, next) {
const connection = await pool.getConnection();
try {
const userId = Number(req.params.id);
@@ -274,6 +361,7 @@
}
const username = common.validateMaxLength(req.body.username || user.username || '', USER_USERNAME_MAX_LENGTH, 'Username');
const accountLocked = req.body.account_locked === '1' || (Array.isArray(req.body.account_locked) && req.body.account_locked.includes('1'));
const [countRows] = await pool.query('SELECT COUNT(*) AS user_count FROM a_users');
const canDelete = !countRows.length || Number(countRows[0].user_count) > 1;
@@ -296,7 +384,7 @@
return renderValidationError('Username is required.');
}
if (shouldUpdatePassword) {
const passwordStrengthMessage = validatePasswordStrength(password);
const passwordStrengthMessage = validatePasswordStrength(password, await getPasswordRequirements());
if (passwordStrengthMessage) {
return renderValidationError(passwordStrengthMessage);
}
@@ -317,25 +405,61 @@
}
await connection.beginTransaction();
const [result] = await connection.query('UPDATE a_users SET name = ?, username = ?, modified_by = ? WHERE id = ?', [name, username, getAuditUserId(req), userId]);
const [result] = await connection.query('UPDATE a_users SET name = ?, username = ?, account_locked = ?, modified_by = ? WHERE id = ?', [name, username, accountLocked ? 1 : 0, getAuditUserId(req), userId]);
if (!result.affectedRows) {
await connection.rollback();
return res.status(404).send('User not found.');
}
await rbacData.syncUserRoles(connection, userId, roleCheck.roleIds);
if (accountLocked) {
await connection.query('DELETE FROM a_sessions WHERE user_id = ?', [userId]);
}
if (shouldUpdatePassword) {
const passwordRecord = hashPassword(password);
const mustChangePassword = await shouldRequirePasswordChange('security.require_password_change_after_admin_reset');
await connection.query(
'UPDATE a_users SET password_hash = ?, password_salt = ?, password_iterations = ?, modified_by = ? WHERE id = ?',
[passwordRecord.hash, passwordRecord.salt, passwordRecord.iterations, getAuditUserId(req), userId]
'UPDATE a_users SET password_hash = ?, password_salt = ?, password_iterations = ?, must_change_password = ?, modified_by = ? WHERE id = ?',
[passwordRecord.hash, passwordRecord.salt, passwordRecord.iterations, mustChangePassword ? 1 : 0, getAuditUserId(req), userId]
);
await connection.query('DELETE FROM a_sessions WHERE user_id = ?', [userId]);
}
await connection.commit();
if (saveAction === 'new') {
return res.redirect('/users/new?message=' + encodeURIComponent('User updated.'));
if (typeof recordRequestAuditEvent === 'function') {
await recordRequestAuditEvent(pool, req, {
category: 'users',
eventType: 'user.updated',
actorUserId: req.currentUser.id,
targetType: 'user',
targetId: userId,
targetLabel: username,
details: { roleIds: roleCheck.roleIds, passwordReset: shouldUpdatePassword, accountLocked: accountLocked }
});
if (Boolean(user.account_locked) !== accountLocked) {
await recordRequestAuditEvent(pool, req, {
category: 'security',
eventType: accountLocked ? 'account.locked' : 'account.unlocked',
actorUserId: req.currentUser.id,
targetType: 'user',
targetId: userId,
targetLabel: username
});
}
if (shouldUpdatePassword) {
await recordRequestAuditEvent(pool, req, {
category: 'security',
eventType: 'password.reset',
actorUserId: req.currentUser.id,
targetType: 'user',
targetId: userId,
targetLabel: username,
details: { source: 'administrator' }
});
}
}
res.redirect('/users?message=' + encodeURIComponent('User updated.'));
if (saveAction === 'new') {
return res.redirect('/settings/users/new?message=' + encodeURIComponent('User updated.'));
}
res.redirect('/settings/users?message=' + encodeURIComponent('User updated.'));
} catch (error) {
try {
await connection.rollback();
@@ -348,12 +472,12 @@
}
});
app.post('/users/:id/password', requirePermission('users.update'), async function (req, res, next) {
app.post('/settings/users/:id/password', requirePermission('users.update'), 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 || '');
const editUrl = '/users/' + userId + '/edit';
const editUrl = '/settings/users/' + userId + '/edit';
if (!Number.isInteger(userId) || userId <= 0) {
return res.status(400).send('Invalid user.');
@@ -361,7 +485,7 @@
if (Number(req.currentUser.id) === userId) {
return res.redirect('/account?message=' + encodeURIComponent('Use My Account to change your own password.'));
}
const passwordStrengthMessage = validatePasswordStrength(password);
const passwordStrengthMessage = validatePasswordStrength(password, await getPasswordRequirements());
if (passwordStrengthMessage) {
return res.redirect(editUrl + '?message=' + encodeURIComponent(passwordStrengthMessage));
}
@@ -375,37 +499,60 @@
}
const passwordRecord = hashPassword(password);
const mustChangePassword = await shouldRequirePasswordChange('security.require_password_change_after_admin_reset');
await pool.query(
'UPDATE a_users SET password_hash = ?, password_salt = ?, password_iterations = ?, modified_by = ? WHERE id = ?',
[passwordRecord.hash, passwordRecord.salt, passwordRecord.iterations, getAuditUserId(req), userId]
'UPDATE a_users SET password_hash = ?, password_salt = ?, password_iterations = ?, must_change_password = ?, modified_by = ? WHERE id = ?',
[passwordRecord.hash, passwordRecord.salt, passwordRecord.iterations, mustChangePassword ? 1 : 0, getAuditUserId(req), userId]
);
await pool.query('DELETE FROM a_sessions WHERE user_id = ?', [userId]);
if (typeof recordRequestAuditEvent === 'function') {
await recordRequestAuditEvent(pool, req, {
category: 'security',
eventType: 'password.reset',
actorUserId: req.currentUser.id,
targetType: 'user',
targetId: userId,
targetLabel: rows[0].username || String(userId),
details: { source: 'administrator' }
});
}
res.redirect(editUrl + '?message=' + encodeURIComponent('Password updated.'));
} catch (error) {
next(error);
}
});
app.post('/users/:id/delete', requirePermission('users.delete'), async function (req, res, next) {
app.post('/settings/users/:id/delete', requirePermission('users.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('/users?message=' + encodeURIComponent('You cannot delete your own account from the users page.'));
return res.redirect('/settings/users?message=' + encodeURIComponent('You cannot delete your own account from the users page.'));
}
const [countRows] = await pool.query('SELECT COUNT(*) AS user_count FROM a_users');
if (!countRows.length || Number(countRows[0].user_count) <= 1) {
return res.redirect('/users?message=' + encodeURIComponent('At least one user must remain.'));
return res.redirect('/settings/users?message=' + encodeURIComponent('At least one user must remain.'));
}
const [userRows] = await pool.query('SELECT username FROM a_users WHERE id = ? LIMIT 1', [userId]);
const [result] = await pool.query('DELETE FROM a_users WHERE id = ?', [userId]);
if (!result.affectedRows) {
return res.status(404).send('User not found.');
}
res.redirect('/users?message=' + encodeURIComponent('User deleted.'));
if (typeof recordRequestAuditEvent === 'function') {
await recordRequestAuditEvent(pool, req, {
category: 'users',
eventType: 'user.deleted',
actorUserId: req.currentUser.id,
targetType: 'user',
targetId: userId,
targetLabel: userRows[0] && userRows[0].username ? userRows[0].username : String(userId)
});
}
res.redirect('/settings/users?message=' + encodeURIComponent('User deleted.'));
} catch (error) {
next(error);
}
+147 -12
View File
@@ -1,12 +1,14 @@
// Authentication route registration for the web app.
const { normalizeReturnToPath, getRequestOrigin } = require('../../lib/auth/session');
const { fetchAppSettings } = require('../../../data/app-settings');
module.exports = function registerAuthRoutes(app, deps) {
const pool = deps.pool;
const pages = deps.pages;
const createUserSession = deps.createUserSession;
const setSessionCookie = deps.setSessionCookie;
const getSessionMaxAgeMs = deps.getSessionMaxAgeMs;
const clearSessionCookie = deps.clearSessionCookie;
const parseCookies = deps.parseCookies;
const consumeAuthMessageCookie = deps.consumeAuthMessageCookie;
@@ -14,13 +16,65 @@ module.exports = function registerAuthRoutes(app, deps) {
const hashSessionToken = deps.hashSessionToken;
const verifyPassword = deps.verifyPassword;
const sessionCookieName = deps.sessionCookieName;
const recordRequestAuditEvent = deps.recordRequestAuditEvent;
function getReturnTo(req) {
return normalizeReturnToPath(req && (req.query && req.query.returnTo || req.body && req.body.returnTo), getRequestOrigin(req));
}
function buildLoginRedirect(returnTo) {
return returnTo ? '/login?returnTo=' + encodeURIComponent(returnTo) : '/login';
function buildLoginRedirect(returnTo, username) {
const query = [];
if (returnTo) query.push('returnTo=' + encodeURIComponent(returnTo));
if (username) query.push('username=' + encodeURIComponent(username));
return query.length ? '/login?' + query.join('&') : '/login';
}
function getClientAddress(req) {
const forwardedAddress = String(req && req.headers && req.headers['x-forwarded-for'] || '').split(',')[0].trim();
return forwardedAddress || String(req && req.ip || req && req.socket && req.socket.remoteAddress || 'unknown').trim() || 'unknown';
}
function getRateLimitKey(username, address, scope) {
if (scope === 'username') return 'username:' + username;
if (scope === 'ip') return 'ip:' + address;
return 'both:' + JSON.stringify([username, address]);
}
async function getLoginRateLimitSettings() {
const settings = await fetchAppSettings(pool);
return {
maxAttempts: Number(settings['security.login_max_attempts']) || 5,
lockoutMinutes: Number(settings['security.login_lockout_minutes']) || 15,
scope: String(settings['security.login_rate_limit_scope'] || 'both')
};
}
async function getLoginRateLimitStatus(rateKey) {
const [rows] = await pool.query('SELECT locked_until FROM a_login_attempts WHERE rate_key = ? LIMIT 1', [rateKey]);
const lockedUntil = rows && rows[0] && rows[0].locked_until ? new Date(rows[0].locked_until).getTime() : 0;
return {
limited: lockedUntil > Date.now(),
remainingMinutes: Math.max(1, Math.ceil((lockedUntil - Date.now()) / 60000))
};
}
async function recordFailedLogin(rateKey, settings) {
const [rows] = await pool.query('SELECT failed_count, locked_until FROM a_login_attempts WHERE rate_key = ? LIMIT 1', [rateKey]);
const existing = rows && rows[0] ? rows[0] : null;
const existingLockedUntil = existing && existing.locked_until ? new Date(existing.locked_until).getTime() : 0;
const currentCount = existingLockedUntil && existingLockedUntil <= Date.now() ? 0 : Number(existing && existing.failed_count) || 0;
const failedCount = currentCount + 1;
const lockedUntil = failedCount >= settings.maxAttempts ? new Date(Date.now() + settings.lockoutMinutes * 60 * 1000) : null;
if (existing) {
await pool.query('UPDATE a_login_attempts SET failed_count = ?, last_failed_at = ?, locked_until = ? WHERE rate_key = ?', [failedCount, new Date(), lockedUntil, rateKey]);
return lockedUntil ? { remainingMinutes: Math.max(1, Math.ceil((lockedUntil.getTime() - Date.now()) / 60000)) } : null;
}
await pool.query('INSERT INTO a_login_attempts (rate_key, failed_count, last_failed_at, locked_until) VALUES (?, ?, ?, ?)', [rateKey, failedCount, new Date(), lockedUntil]);
return lockedUntil ? { remainingMinutes: Math.max(1, Math.ceil((lockedUntil.getTime() - Date.now()) / 60000)) } : null;
}
async function clearFailedLogins(rateKey) {
await pool.query('DELETE FROM a_login_attempts WHERE rate_key = ?', [rateKey]);
}
app.get('/', function (req, res) {
@@ -36,7 +90,7 @@ module.exports = function registerAuthRoutes(app, deps) {
const message = typeof consumeAuthMessageCookie === 'function'
? consumeAuthMessageCookie(req, res)
: (req.query.message ? String(req.query.message) : '');
res.send(pages.renderLoginPage(message, returnTo));
res.send(pages.renderLoginPage(message, returnTo, req.query.username ? String(req.query.username) : ''));
});
app.post('/login', async function (req, res, next) {
@@ -47,21 +101,92 @@ module.exports = function registerAuthRoutes(app, deps) {
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 a_users WHERE username = ? LIMIT 1', [username]);
const user = rows[0] || null;
if (!user || !verifyPassword(password, user)) {
const loginRateLimitSettings = await getLoginRateLimitSettings();
const rateKey = getRateLimitKey(username.toLowerCase(), getClientAddress(req), loginRateLimitSettings.scope);
const rateLimitStatus = await getLoginRateLimitStatus(rateKey);
if (rateLimitStatus.limited) {
if (typeof recordRequestAuditEvent === 'function') {
await recordRequestAuditEvent(pool, req, {
category: 'authentication',
eventType: 'login.locked_out',
targetType: 'user',
targetLabel: username,
details: { reason: 'rate_limit' }
});
}
const message = 'Too many failed login attempts. Try again in ' + rateLimitStatus.remainingMinutes + ' minute' + (rateLimitStatus.remainingMinutes === 1 ? '' : 's') + '.';
const returnTo = getReturnTo(req);
if (typeof setAuthMessageCookie === 'function') {
setAuthMessageCookie(res, 'Invalid username or password.');
return res.redirect(buildLoginRedirect(returnTo));
setAuthMessageCookie(res, message);
return res.redirect(buildLoginRedirect(returnTo, username));
}
return res.status(401).send(pages.renderLoginPage('Invalid username or password.', returnTo));
return res.status(401).send(pages.renderLoginPage(message, returnTo, username));
}
const [rows] = await pool.query('SELECT id, name, username, password_hash, password_salt, password_iterations, account_locked FROM a_users WHERE username = ? LIMIT 1', [username]);
const user = rows[0] || null;
if (user && user.account_locked) {
if (typeof recordRequestAuditEvent === 'function') {
await recordRequestAuditEvent(pool, req, {
category: 'security',
eventType: 'login.blocked_account_locked',
targetType: 'user',
targetId: user.id,
targetLabel: user.username
});
}
const returnTo = getReturnTo(req);
const message = 'This account is locked. Contact an administrator.';
if (typeof setAuthMessageCookie === 'function') {
setAuthMessageCookie(res, message);
return res.redirect(buildLoginRedirect(returnTo, username));
}
return res.status(401).send(pages.renderLoginPage(message, returnTo, username));
}
if (!user || !verifyPassword(password, user)) {
const lockout = await recordFailedLogin(rateKey, loginRateLimitSettings);
if (typeof recordRequestAuditEvent === 'function') {
await recordRequestAuditEvent(pool, req, {
category: 'authentication',
eventType: lockout ? 'login.lockout' : 'login.failed',
targetType: user ? 'user' : 'username',
targetId: user ? user.id : null,
targetLabel: user ? user.username : username,
details: { reason: user ? 'invalid_password' : 'unknown_username' }
});
}
const message = lockout
? 'Too many failed login attempts. Try again in ' + lockout.remainingMinutes + ' minute' + (lockout.remainingMinutes === 1 ? '' : 's') + '.'
: 'Invalid username or password.';
const returnTo = getReturnTo(req);
if (typeof setAuthMessageCookie === 'function') {
setAuthMessageCookie(res, message);
return res.redirect(buildLoginRedirect(returnTo, username));
}
return res.status(401).send(pages.renderLoginPage(message, returnTo, username));
}
await clearFailedLogins(rateKey);
const returnTo = getReturnTo(req);
const token = await createUserSession(pool, user.id);
setSessionCookie(res, token);
const sessionMaxAgeMs = typeof getSessionMaxAgeMs === 'function' ? await getSessionMaxAgeMs() : undefined;
const token = await createUserSession(pool, user.id, sessionMaxAgeMs, {
ipAddress: getClientAddress(req),
userAgent: req.headers && req.headers['user-agent']
});
const rememberMe = req.body.remember_me === '1' || req.body.remember_me === 'true' || req.body.remember_me === true;
setSessionCookie(res, token, rememberMe ? sessionMaxAgeMs : null);
if (typeof recordRequestAuditEvent === 'function') {
await recordRequestAuditEvent(pool, req, {
category: 'authentication',
eventType: 'login.success',
actorUserId: user.id,
targetType: 'user',
targetId: user.id,
targetLabel: user.username,
details: { rememberMe: rememberMe }
});
}
res.redirect(returnTo || '/dashboard');
} catch (error) {
next(error);
@@ -75,6 +200,16 @@ module.exports = function registerAuthRoutes(app, deps) {
if (token) {
await pool.query('DELETE FROM a_sessions WHERE session_hash = ?', [hashSessionToken(token)]);
}
if (typeof recordRequestAuditEvent === 'function' && req.currentUser) {
await recordRequestAuditEvent(pool, req, {
category: 'sessions',
eventType: 'session.logout',
actorUserId: req.currentUser.id,
targetType: 'user',
targetId: req.currentUser.id,
targetLabel: req.currentUser.username
});
}
clearSessionCookie(res);
if (typeof setAuthMessageCookie === 'function') {
setAuthMessageCookie(res, 'You have been signed out.');
+2 -1
View File
@@ -2,13 +2,14 @@
const { renderView } = require('../../view');
module.exports = function renderLoginPage(message, returnTo) {
module.exports = function renderLoginPage(message, returnTo, username) {
return renderView('auth/login', {
title: 'Sign in',
authShell: true,
bodyClass: 'login-page-body',
message: message || '',
returnTo: returnTo || '',
username: username || '',
messageVariant: 'warning'
});
};
@@ -5,6 +5,7 @@ const renderApiSourcesPage = require('./list');
const renderApiSourceAddPage = require('./add');
const renderApiSourceEditPage = require('./edit');
const { buildDuplicateApiSourceName, buildDuplicateApiSource } = require('./duplicate');
const { fetchAppSettings } = require('../../../../data/app-settings');
function toIsoTimestamp(value) {
if (!value) {
@@ -123,8 +124,16 @@ module.exports = function registerApiSourceRoutes(app, deps) {
}
});
app.get('/data-sources/api-sources/new', requirePermission('api-sources.create'), function (req, res) {
res.send(renderApiSourceAddPage(null, req.query.message ? String(req.query.message) : '', req.currentUser));
app.get('/data-sources/api-sources/new', requirePermission('api-sources.create'), async function (req, res, next) {
try {
const settings = await fetchAppSettings(pool);
res.send(renderApiSourceAddPage({
updateIntervalValue: settings['data-sources.api_default_interval_value'],
updateIntervalUnit: settings['data-sources.api_default_interval_unit']
}, req.query.message ? String(req.query.message) : '', req.currentUser));
} catch (error) {
next(error);
}
});
app.get('/data-sources/api-sources/:id/edit', requirePermission('api-sources.update'), async function (req, res, next) {
@@ -5,6 +5,7 @@ const renderRssFeedsPage = require('./list');
const renderRssFeedAddPage = require('./add');
const renderRssFeedEditPage = require('./edit');
const { buildDuplicateRssFeedName, buildDuplicateRssFeed } = require('./duplicate');
const { fetchAppSettings } = require('../../../../data/app-settings');
async function getDataSourceUsageMaps(pool, common) {
const [slides] = await pool.query('SELECT content_json FROM c_slides WHERE content_json IS NOT NULL');
@@ -99,8 +100,16 @@ module.exports = function registerRssFeedRoutes(app, deps) {
}
});
app.get('/data-sources/rss-feeds/new', requirePermission('rss-feeds.create'), function (req, res) {
res.send(renderRssFeedAddPage(null, req.query.message ? String(req.query.message) : '', req.currentUser));
app.get('/data-sources/rss-feeds/new', requirePermission('rss-feeds.create'), async function (req, res, next) {
try {
const settings = await fetchAppSettings(pool);
res.send(renderRssFeedAddPage({
updateIntervalValue: settings['data-sources.rss_default_interval_value'],
updateIntervalUnit: settings['data-sources.rss_default_interval_unit']
}, req.query.message ? String(req.query.message) : '', req.currentUser));
} catch (error) {
next(error);
}
});
app.get('/data-sources/rss-feeds/:id/edit', requirePermission('rss-feeds.update'), async function (req, res, next) {
+34 -7
View File
@@ -15,6 +15,8 @@ const registerRssFeedRoutes = require('./data-sources/rss-feeds/routes');
const registerTimetableRoutes = require('./data-sources/timetables/routes');
const registerSettingsRoutes = require('./settings/background-tasks');
const registerFontRoutes = require('./settings/fonts');
const registerSettingsPageRoutes = require('./settings/routes');
const registerAuditLogRoutes = require('./settings/audit-log');
const registerAboutRoutes = require('./settings/about/routes');
const registerInternalSyncRoutes = require('./internal/sync');
const registerScreensRoutes = require('./signage/screens/routes');
@@ -25,6 +27,18 @@ function registerRoutes(app, deps) {
registerAuthAndAccountRoutes(app, deps);
registerSignageRoutes(app, deps);
registerSettingsAndContentRoutes(app, deps);
registerSettingsPageRoutes(app, {
pool: deps.pool,
pages: deps.pages,
mediaDir: deps.mediaDir,
requirePermission: deps.requirePermission,
recordRequestAuditEvent: deps.recordRequestAuditEvent
});
registerAuditLogRoutes(app, {
pool: deps.pool,
formatDashboardDate: deps.formatDashboardDate,
setAuthMessageCookie: deps.setAuthMessageCookie
});
registerAboutRoutes(app, deps);
registerInternalSyncRoutes(app, {
backgroundTaskQueue: deps.backgroundTaskQueue,
@@ -52,13 +66,16 @@ function registerAuthAndAccountRoutes(app, deps) {
pages: deps.pages,
createUserSession: deps.createUserSession,
setSessionCookie: deps.setSessionCookie,
getSessionMaxAgeMs: deps.getSessionMaxAgeMs,
getConfiguredMaxActiveSessions: deps.getConfiguredMaxActiveSessions,
clearSessionCookie: deps.clearSessionCookie,
setAuthMessageCookie: deps.setAuthMessageCookie,
consumeAuthMessageCookie: deps.consumeAuthMessageCookie,
parseCookies: deps.parseCookies,
hashSessionToken: deps.hashSessionToken,
verifyPassword: deps.verifyPassword,
sessionCookieName: deps.sessionCookieName
sessionCookieName: deps.sessionCookieName,
recordRequestAuditEvent: deps.recordRequestAuditEvent
});
registerPagesRoutes(app, {
@@ -82,7 +99,12 @@ function registerAuthAndAccountRoutes(app, deps) {
hashPassword: deps.hashPassword,
validatePasswordStrength: deps.validatePasswordStrength,
createUserSession: deps.createUserSession,
setSessionCookie: deps.setSessionCookie
setSessionCookie: deps.setSessionCookie,
getSessionMaxAgeMs: deps.getSessionMaxAgeMs,
parseCookies: deps.parseCookies,
hashSessionToken: deps.hashSessionToken,
sessionCookieName: deps.sessionCookieName,
recordRequestAuditEvent: deps.recordRequestAuditEvent
});
registerUsersRoutes(app, {
@@ -95,7 +117,8 @@ function registerAuthAndAccountRoutes(app, deps) {
validatePasswordStrength: deps.validatePasswordStrength,
readArrayField: deps.readArrayField,
rbacData: deps.rbacData,
requirePermission: deps.requirePermission
requirePermission: deps.requirePermission,
recordRequestAuditEvent: deps.recordRequestAuditEvent
});
}
@@ -114,7 +137,8 @@ function registerSignageRoutes(app, deps) {
notifyPlayerScreens: deps.notifyPlayerScreens,
broadcastDashboardState: deps.broadcastDashboardState,
getPlaylistDeleteBlockMessage: deps.playerActionService.getPlaylistDeleteBlockMessage,
requirePermission: deps.requirePermission
requirePermission: deps.requirePermission,
recordRequestAuditEvent: deps.recordRequestAuditEvent
});
registerManageRoutes(app, {
@@ -130,7 +154,8 @@ function registerSignageRoutes(app, deps) {
forwardPlayerCommand: deps.playerActionService.forwardPlayerCommand,
forwardPlayerCommandToBaseUrl: deps.playerActionService.forwardPlayerCommandToBaseUrl,
forwardPlayerCommandToDevice: deps.playerActionService.forwardPlayerCommandToDevice,
requirePermission: deps.requirePermission
requirePermission: deps.requirePermission,
recordRequestAuditEvent: deps.recordRequestAuditEvent
});
registerAnnouncementRoutes(app, {
@@ -169,7 +194,8 @@ function registerSettingsAndContentRoutes(app, deps) {
readArrayField: deps.readArrayField,
permissions: PERMISSIONS,
normalizePermissionKeys: normalizePermissionKeys,
requirePermission: deps.requirePermission
requirePermission: deps.requirePermission,
recordRequestAuditEvent: deps.recordRequestAuditEvent
});
registerContentRoutes(app, {
@@ -195,7 +221,8 @@ function registerSettingsAndContentRoutes(app, deps) {
getSlideDeleteBlockMessage: deps.playerActionService.getSlideDeleteBlockMessage,
getTemplateDeleteBlockMessage: deps.playerActionService.getTemplateDeleteBlockMessage,
getCanvasSizeDeleteBlockMessage: deps.playerActionService.getCanvasSizeDeleteBlockMessage,
requirePermission: deps.requirePermission
requirePermission: deps.requirePermission,
recordRequestAuditEvent: deps.recordRequestAuditEvent
});
registerRootDataSourceRoutes(app, {
+181
View File
@@ -0,0 +1,181 @@
const { renderView } = require('#src/web/view');
const { hasPermission, PERMISSION_DENIED_MESSAGE } = require('#src/rbac');
const { AUDIT_CATEGORY_KEYS } = require('#src/data/audit-log');
const { buildPagination } = require('#src/web/lib/pagination');
const { createSearchMatcher, getSearchQuery, getSortDirectionQuery, getSortQuery, parsePageNumber, sortRows } = require('#src/web/lib/list-query');
const PAGE_SIZE = 25;
function requireAuditLogAccess(setAuthMessageCookie) {
return function (req, res, next) {
if (!req.currentUser) {
if (typeof setAuthMessageCookie === 'function') {
setAuthMessageCookie(res, 'Please sign in to continue.');
}
return res.redirect('/login');
}
if (hasPermission(req.currentUser, 'audit-log.read')) {
return next();
}
const error = new Error(PERMISSION_DENIED_MESSAGE);
error.statusCode = 403;
error.expose = true;
return next(error);
};
}
function requireAuditExportAccess(setAuthMessageCookie) {
return function (req, res, next) {
if (!req.currentUser) {
if (typeof setAuthMessageCookie === 'function') setAuthMessageCookie(res, 'Please sign in to continue.');
return res.redirect('/login');
}
if (hasPermission(req.currentUser, 'audit-log.allow')) return next();
const error = new Error(PERMISSION_DENIED_MESSAGE);
error.statusCode = 403;
error.expose = true;
return next(error);
};
}
function csvCell(value) {
let text = String(value === undefined || value === null ? '' : value);
if (/^[=+\-@]/.test(text)) text = "'" + text;
return '"' + text.replace(/"/g, '""').replace(/\r?\n/g, ' ') + '"';
}
function mapAuditRow(row, formatDashboardDate) {
let details = '';
if (row.details_json) {
try {
details = JSON.stringify(typeof row.details_json === 'string' ? JSON.parse(row.details_json) : row.details_json);
} catch (_error) {
details = String(row.details_json);
}
}
const occurredAt = new Date(row.occurred_at);
return Object.assign({}, row, {
occurredAtValue: Number.isNaN(occurredAt.getTime()) ? '' : occurredAt.toISOString(),
occurredAtLabel: formatDashboardDate(row.occurred_at),
actorLabel: row.actor_name || row.actor_username || 'System',
eventLabel: String(row.event_type || '').replace(/[._-]+/g, ' '),
targetLabelDisplay: row.target_label || (row.target_type && row.target_id ? row.target_type + ' #' + row.target_id : ''),
detailsDisplay: details
});
}
function filterAuditRows(rows, req, formatDashboardDate) {
const category = String(req.query.category || '').trim().toLowerCase();
const eventType = String(req.query.event_type || '').trim().toLowerCase();
const search = getSearchQuery(req);
const requestedSort = getSortQuery(req);
const sort = requestedSort || 'time';
const direction = requestedSort ? getSortDirectionQuery(req) : 'desc';
const mappedRows = (rows || []).map(function (row) { return mapAuditRow(row, formatDashboardDate); });
const filteredRows = sortRows(mappedRows.filter(function (event) {
return (!category || event.category === category) && (!eventType || event.event_type === eventType);
}).filter(createSearchMatcher(search, ['category', 'event_type', 'actorLabel', 'targetLabelDisplay', 'ip_address', 'user_agent'])), function (event) {
if (sort === 'event') return event.eventLabel;
if (sort === 'actor') return event.actorLabel;
if (sort === 'target') return event.targetLabelDisplay;
if (sort === 'category') return event.category;
return event.occurred_at;
}, direction);
return { rows: filteredRows, category: category, eventType: eventType, search: search, sort: sort, direction: direction };
}
async function fetchAuditRows(pool) {
const [rows] = await pool.query(
`SELECT e.id, e.occurred_at, e.category, e.event_type, e.target_type, e.target_id,
e.target_label, e.ip_address, e.user_agent, e.details_json,
u.name AS actor_name, u.username AS actor_username
FROM o_audit_events e
LEFT JOIN a_users u ON u.id = e.actor_user_id
ORDER BY e.occurred_at DESC, e.id DESC`, []
);
return rows || [];
}
module.exports = function registerAuditLogRoutes(app, deps) {
const pool = deps.pool;
const formatDashboardDate = deps.formatDashboardDate;
if (!pool || typeof formatDashboardDate !== 'function') {
throw new Error('registerAuditLogRoutes requires pool and formatDashboardDate.');
}
app.get('/settings/audit-log', requireAuditLogAccess(deps.setAuthMessageCookie), async function (req, res, next) {
try {
const category = String(req.query.category || '').trim().toLowerCase();
const eventType = String(req.query.event_type || '').trim().toLowerCase();
const search = getSearchQuery(req);
const sort = getSortQuery(req);
const direction = getSortDirectionQuery(req);
const filtered = filterAuditRows(await fetchAuditRows(pool), req, formatDashboardDate);
const filteredEvents = filtered.rows;
const eventTypes = Array.from(new Set(filtered.rows.map(function (event) {
return String(event.event_type || '').trim();
}).filter(Boolean))).sort();
const exportQuery = new URLSearchParams({
category: filtered.category,
event_type: filtered.eventType,
search: filtered.search,
sort: filtered.sort,
direction: filtered.direction
}).toString();
const pagination = buildPagination(filteredEvents.length, parsePageNumber(req.query.page), 'page', {
search: search,
sort: sort,
direction: direction,
category: category,
event_type: eventType
}, PAGE_SIZE, 'events', 'Audit event pages');
const startIndex = (pagination.currentPage - 1) * PAGE_SIZE;
const events = filteredEvents.slice(startIndex, startIndex + PAGE_SIZE);
res.send(renderView('settings/audit-log/index', {
title: 'Audit log',
active: 'audit-log',
currentUser: req.currentUser,
events: events,
categories: AUDIT_CATEGORY_KEYS,
eventTypes: eventTypes,
selectedCategory: category,
selectedEventType: eventType,
search: search,
sort: sort,
direction: direction,
pagination: pagination,
exportUrl: '/settings/audit-log/export' + (exportQuery ? '?' + exportQuery : '')
}));
} catch (error) {
next(error);
}
});
app.get('/settings/audit-log/export', requireAuditExportAccess(deps.setAuthMessageCookie), async function (req, res, next) {
try {
const filtered = filterAuditRows(await fetchAuditRows(pool), req, formatDashboardDate);
const lines = [
['Occurred At (UTC)', 'Category', 'Event Type', 'Actor', 'Target Type', 'Target ID', 'Target', 'IP Address', 'User Agent', 'Details'].map(csvCell).join(',')
];
filtered.rows.forEach(function (event) {
lines.push([
event.occurredAtValue,
event.category,
event.event_type,
event.actorLabel,
event.target_type,
event.target_id,
event.targetLabelDisplay,
event.ip_address,
event.user_agent,
event.detailsDisplay
].map(csvCell).join(','));
});
res.setHeader('Content-Type', 'text/csv; charset=utf-8');
res.setHeader('Content-Disposition', 'attachment; filename="pulse-signage-audit-log.csv"');
res.send('\ufeff' + lines.join('\r\n') + '\r\n');
} catch (error) {
next(error);
}
});
};
+19
View File
@@ -0,0 +1,19 @@
// Visual-only application settings page.
const { renderView } = require('../../view');
module.exports = function renderSettingsPage(settings, suggestedAnnouncementIcons, currentUser, message, mediaSettings, diagnostics) {
const iconOptions = Array.isArray(suggestedAnnouncementIcons) ? suggestedAnnouncementIcons : [];
return renderView('settings/system/index', {
title: 'Settings',
active: 'settings',
currentUser: currentUser || null,
message: message || '',
settings: settings || {},
suggestedAnnouncementIcons: iconOptions,
defaultAnnouncementIconSuggestions: iconOptions.filter(function (option) { return option.isSelected; }),
mediaSettings: mediaSettings || {},
diagnostics: diagnostics || {},
scripts: ['js/icon-picker.js', 'js/settings/settings-page.js']
});
};
+25 -23
View File
@@ -53,17 +53,17 @@ function buildPermissionSections(permissionGroups) {
});
section.groups.sort(function (left, right) {
const leftIndex = toSortIndex(left.resourceIndex);
const rightIndex = toSortIndex(right.resourceIndex);
if (leftIndex !== rightIndex) {
return leftIndex - rightIndex;
}
const leftOrder = toSortIndex(left.resourceOrder);
const rightOrder = toSortIndex(right.resourceOrder);
if (leftOrder !== rightOrder) {
return leftOrder - rightOrder;
}
const leftIndex = toSortIndex(left.resourceIndex);
const rightIndex = toSortIndex(right.resourceIndex);
if (leftIndex !== rightIndex) {
return leftIndex - rightIndex;
}
return String(left.title || '').localeCompare(String(right.title || ''));
});
@@ -77,17 +77,17 @@ function buildPermissionSections(permissionGroups) {
});
sections.sort(function (left, right) {
const leftIndex = toSortIndex(left.sourceIndex);
const rightIndex = toSortIndex(right.sourceIndex);
if (leftIndex !== rightIndex) {
return leftIndex - rightIndex;
}
const leftOrder = toSortIndex(left.sectionOrder);
const rightOrder = toSortIndex(right.sectionOrder);
if (leftOrder !== rightOrder) {
return leftOrder - rightOrder;
}
const leftIndex = toSortIndex(left.sourceIndex);
const rightIndex = toSortIndex(right.sourceIndex);
if (leftIndex !== rightIndex) {
return leftIndex - rightIndex;
}
return String(left.title || '').localeCompare(String(right.title || ''));
});
@@ -101,9 +101,10 @@ function buildRbacAddViewModel(message, currentUser, formValues, permissionGroup
isEdit: false,
usersPresent: true,
formId: 'role-create-form',
formAction: '/rbac',
footerSaveUrl: '/rbac',
footerCancelUrl: '/rbac',
formAction: '/settings/roles',
footerSaveUrl: '/settings/roles',
footerCancelUrl: '/settings/roles',
cancelConfirmMessage: "You've made changes. Are you sure you want to leave this page?",
footerDeleteUrl: '',
footerDeleteDisabled: true,
footerDeleteTitle: 'Delete is available after the role has been created.',
@@ -116,9 +117,9 @@ function buildRbacAddViewModel(message, currentUser, formValues, permissionGroup
permissionSections: buildPermissionSections(permissionGroups),
users: users || [],
pagination: pagination || null,
roleEditBasePath: '/rbac/new',
roleEditBasePath: '/settings/roles/new',
scripts: ['js/rbac-permissions.js'],
formAttrs: 'data-async-save data-async-save-close-url="/rbac" data-async-save-new-redirect="response-url" data-async-save-new-url="/rbac/new"'
formAttrs: 'data-async-save data-async-save-close-url="/settings/roles" data-async-save-new-redirect="response-url" data-async-save-new-url="/settings/roles/new"'
};
}
@@ -129,10 +130,11 @@ function buildRbacEditViewModel(role, message, currentUser, permissionGroups, us
isEdit: true,
usersPresent: true,
formId: 'role-edit-form',
formAction: '/rbac/' + Number(role && role.id),
footerSaveUrl: '/rbac/' + Number(role && role.id),
footerCancelUrl: '/rbac',
footerDeleteUrl: '/rbac/' + Number(role && role.id) + '/delete',
formAction: '/settings/roles/' + Number(role && role.id),
footerSaveUrl: '/settings/roles/' + Number(role && role.id),
footerCancelUrl: '/settings/roles',
cancelConfirmMessage: "You've made changes. Are you sure you want to leave this page?",
footerDeleteUrl: '/settings/roles/' + Number(role && role.id) + '/delete',
footerDeleteDisabled: Boolean(role && role.inUse),
footerDeleteTitle: role && role.inUse ? 'Delete is disabled while this role is assigned to users.' : '',
footerShowDelete: true,
@@ -144,9 +146,9 @@ function buildRbacEditViewModel(role, message, currentUser, permissionGroups, us
permissionSections: buildPermissionSections(permissionGroups),
users: users || [],
pagination: pagination || null,
roleEditBasePath: '/rbac/' + Number(role && role.id) + '/edit',
roleEditBasePath: '/settings/roles/' + Number(role && role.id) + '/edit',
scripts: ['js/rbac-permissions.js'],
formAttrs: 'data-async-save data-async-save-close-url="/rbac" data-async-save-new-url="/rbac/new"'
formAttrs: 'data-async-save data-async-save-close-url="/settings/roles" data-async-save-new-url="/settings/roles/new"'
};
}
+397
View File
@@ -0,0 +1,397 @@
// Settings page route registration.
const { ANNOUNCEMENT_ICON_CATALOG } = require('#src/data/announcement-icons');
const { AUDIT_CATEGORY_KEYS, AUDIT_CATEGORY_LABELS } = require('#src/data/audit-log');
const { fetchAppSettings, saveAppSettings } = require('#src/data/app-settings');
const { appVersion, detectSchemaVersion } = require('#src/db/migrations');
const UPLOAD_MIME_TYPE_OPTIONS = [
{ value: 'image/png', label: 'PNG', category: 'Images' },
{ value: 'image/jpeg', label: 'JPEG', category: 'Images' },
{ value: 'image/gif', label: 'GIF', category: 'Images' },
{ value: 'image/webp', label: 'WebP', category: 'Images' },
{ value: 'image/svg+xml', label: 'SVG', category: 'Images' },
{ value: 'video/mp4', label: 'MP4', category: 'Video' },
{ value: 'video/webm', label: 'WebM', category: 'Video' },
{ value: 'video/ogg', label: 'Ogg', category: 'Video' }
];
module.exports = function registerSettingsPageRoutes(app, deps) {
const pages = deps.pages;
const pool = deps.pool;
const recordRequestAuditEvent = deps.recordRequestAuditEvent;
if (!pages || !pool || typeof deps.requirePermission !== 'function') {
throw new Error('registerSettingsPageRoutes requires pool, pages, and requirePermission.');
}
const requireSettingsRead = deps.requirePermission('system-settings.read');
const MAX_SUGGESTED_ICONS = 48;
const SECTION_SETTING_KEYS = {
defaults: [
'player.default_slide_duration_seconds',
'player.default_fade_between_slides',
'player.skip_unavailable_rtmp',
'announcements.default_icon',
'announcements.default_duration_value',
'announcements.default_duration_unit',
'data-sources.rss_default_interval_value',
'data-sources.rss_default_interval_unit',
'data-sources.api_default_interval_value',
'data-sources.api_default_interval_unit'
],
security: [
'security.session_lifetime_days',
'security.allow_user_session_revocation',
'security.max_active_sessions',
'security.login_max_attempts',
'security.login_lockout_minutes',
'security.login_rate_limit_scope',
'security.password_min_length',
'security.password_min_categories',
'security.password_require_lowercase',
'security.password_require_uppercase',
'security.password_require_number',
'security.password_require_symbol',
'security.require_password_change_for_new_users',
'security.require_password_change_after_admin_reset'
],
audit: ['audit.enabled', 'audit.categories', 'audit.include_request_metadata', 'audit.retention_days'],
media: ['uploads.image_max_bytes', 'uploads.video_max_bytes', 'uploads.wysiwyg_image_max_bytes', 'uploads.allowed_mime_types'],
icons: ['announcements.suggested_icons']
};
function buildSettingChanges(previousSettings, nextSettings, keys) {
const changes = {};
(Array.isArray(keys) ? keys : []).forEach(function (key) {
const previousValue = previousSettings && previousSettings[key];
const nextValue = nextSettings[key];
if (JSON.stringify(previousValue) !== JSON.stringify(nextValue)) {
changes[key] = { from: previousValue, to: nextValue };
}
});
return changes;
}
async function recordSettingsAuditEvent(req, previousSettings, nextSettings, section) {
if (typeof recordRequestAuditEvent !== 'function') {
return;
}
const changes = buildSettingChanges(previousSettings, nextSettings, SECTION_SETTING_KEYS[section]);
const changedKeys = Object.keys(changes);
if (!changedKeys.length) {
return;
}
await recordRequestAuditEvent(pool, req, {
category: 'system-settings',
eventType: 'system-settings.updated',
actorUserId: req.currentUser && req.currentUser.id,
targetType: 'settings',
targetId: section,
targetLabel: section,
details: { section: section, changes: changes }
});
}
function buildSuggestedAnnouncementIcons(settings) {
const configuredKeys = settings && Array.isArray(settings['announcements.suggested_icons'])
? settings['announcements.suggested_icons']
: [];
const selectedKeys = new Set(configuredKeys.map(function (key) {
return String(key || '').trim().toLowerCase();
}));
const catalogByKey = new Map(ANNOUNCEMENT_ICON_CATALOG.map(function (option) {
return [option.value, option];
}));
const selectedOptions = Array.from(new Set(configuredKeys.map(function (key) {
return catalogByKey.get(String(key || '').trim().toLowerCase()) || null;
}).filter(Boolean)));
const remainingOptions = ANNOUNCEMENT_ICON_CATALOG.filter(function (option) {
return !selectedKeys.has(option.value);
});
return selectedOptions.concat(remainingOptions).map(function (option) {
return Object.assign({}, option, {
isSelected: selectedKeys.has(option.value),
isDefault: selectedOptions.length > 0 && selectedOptions[0].value === option.value
});
});
}
async function renderSettings(req, res, message) {
const settings = await fetchAppSettings(pool);
const diagnostics = await buildDiagnostics();
res.send(pages.renderSettingsPage(settings, buildSuggestedAnnouncementIcons(settings), req.currentUser, message, {
imageMaxMb: Math.round(Number(settings['uploads.image_max_bytes']) / 1024 / 1024),
videoMaxMb: Math.round(Number(settings['uploads.video_max_bytes']) / 1024 / 1024),
wysiwygImageMaxMb: (Number(settings['uploads.wysiwyg_image_max_bytes']) / 1024 / 1024).toFixed(2).replace(/0+$/, '').replace(/\.$/, '')
,allowedMimeTypes: UPLOAD_MIME_TYPE_OPTIONS.map(function (option) {
return Object.assign({}, option, { isSelected: settings['uploads.allowed_mime_types'].indexOf(option.value) !== -1 });
}),
imageMimeTypes: UPLOAD_MIME_TYPE_OPTIONS.filter(function (option) { return option.category === 'Images'; }).map(function (option) {
return Object.assign({}, option, { isSelected: settings['uploads.allowed_mime_types'].indexOf(option.value) !== -1 });
}),
videoMimeTypes: UPLOAD_MIME_TYPE_OPTIONS.filter(function (option) { return option.category === 'Video'; }).map(function (option) {
return Object.assign({}, option, { isSelected: settings['uploads.allowed_mime_types'].indexOf(option.value) !== -1 });
}),
sessionLifetimeDays: Number(settings['security.session_lifetime_days']) || 14,
allowUserSessionRevocation: Boolean(settings['security.allow_user_session_revocation']),
maxActiveSessions: Number(settings['security.max_active_sessions']) || 0,
loginMaxAttempts: Number(settings['security.login_max_attempts']) || 5,
loginLockoutMinutes: Number(settings['security.login_lockout_minutes']) || 15,
loginRateLimitScope: String(settings['security.login_rate_limit_scope'] || 'both'),
passwordMinLength: Number(settings['security.password_min_length']) || 10,
passwordMinCategories: Number(settings['security.password_min_categories']) || 3,
passwordRequireLowercase: Boolean(settings['security.password_require_lowercase']),
passwordRequireUppercase: Boolean(settings['security.password_require_uppercase']),
passwordRequireNumber: Boolean(settings['security.password_require_number']),
passwordRequireSymbol: Boolean(settings['security.password_require_symbol']),
requirePasswordChangeForNewUsers: Boolean(settings['security.require_password_change_for_new_users']),
requirePasswordChangeAfterAdminReset: Boolean(settings['security.require_password_change_after_admin_reset']),
defaultSlideDurationSeconds: Number(settings['player.default_slide_duration_seconds']) || 10,
defaultFadeBetweenSlides: Boolean(settings['player.default_fade_between_slides']),
defaultSkipUnavailableRtmp: Boolean(settings['player.skip_unavailable_rtmp']),
defaultAnnouncementIcon: String(settings['announcements.default_icon'] || 'megaphone-fill'),
defaultAnnouncementDurationValue: Number(settings['announcements.default_duration_value']) || 10,
defaultAnnouncementDurationUnit: String(settings['announcements.default_duration_unit'] || 'seconds')
,rssDefaultIntervalValue: Number(settings['data-sources.rss_default_interval_value']) || 60
,rssDefaultIntervalUnit: String(settings['data-sources.rss_default_interval_unit'] || 'minutes')
,apiDefaultIntervalValue: Number(settings['data-sources.api_default_interval_value']) || 60
,apiDefaultIntervalUnit: String(settings['data-sources.api_default_interval_unit'] || 'minutes')
,auditRetentionDays: Number(settings['audit.retention_days']) || 0
,auditEnabled: Boolean(settings['audit.enabled'])
,auditIncludeRequestMetadata: Boolean(settings['audit.include_request_metadata'])
,auditCategories: AUDIT_CATEGORY_KEYS.map(function (category) {
return { key: category, label: AUDIT_CATEGORY_LABELS[category] || category, isSelected: settings['audit.categories'].includes(category) };
})
,auditEventCategories: AUDIT_CATEGORY_KEYS.filter(function (category) { return category !== 'users' && category !== 'roles' && category !== 'system-settings' && category !== 'slides' && category !== 'templates' && category !== 'playlists' && category !== 'screens' && category !== 'announcements' && category !== 'canvas-sizes' && category !== 'api-sources' && category !== 'rss-feeds' && category !== 'timetables'; }).map(function (category) {
return { key: category, label: AUDIT_CATEGORY_LABELS[category] || category, isSelected: settings['audit.categories'].includes(category) };
})
,auditAdministrationCategories: AUDIT_CATEGORY_KEYS.filter(function (category) { return category === 'users' || category === 'roles' || category === 'system-settings'; }).map(function (category) {
return { key: category, label: AUDIT_CATEGORY_LABELS[category] || category, isSelected: settings['audit.categories'].includes(category) };
})
,auditContentCategories: AUDIT_CATEGORY_KEYS.filter(function (category) { return category === 'slides' || category === 'templates' || category === 'playlists' || category === 'screens' || category === 'announcements' || category === 'canvas-sizes'; }).map(function (category) {
return { key: category, label: AUDIT_CATEGORY_LABELS[category] || category, isSelected: settings['audit.categories'].includes(category) };
})
,auditDataSourceCategories: AUDIT_CATEGORY_KEYS.filter(function (category) { return category === 'api-sources' || category === 'rss-feeds' || category === 'timetables'; }).map(function (category) {
return { key: category, label: AUDIT_CATEGORY_LABELS[category] || category, isSelected: settings['audit.categories'].includes(category) };
})
}, diagnostics));
}
async function buildDiagnostics() {
const diagnostics = {
applicationVersion: appVersion,
schemaVersion: 'Unknown',
databaseStatus: 'Unavailable',
activeSessionCount: 0,
backgroundTaskCount: 0,
criticalTableCounts: []
};
try {
await pool.query('SELECT 1 AS health');
diagnostics.databaseStatus = 'Connected';
diagnostics.schemaVersion = await detectSchemaVersion(pool);
const [sessionRows] = await pool.query('SELECT COUNT(*) AS count FROM a_sessions WHERE expires_at > NOW()');
diagnostics.activeSessionCount = Number(sessionRows && sessionRows[0] && sessionRows[0].count) || 0;
const [taskRows] = await pool.query('SELECT COUNT(*) AS count FROM o_background_tasks');
diagnostics.backgroundTaskCount = Number(taskRows && taskRows[0] && taskRows[0].count) || 0;
const criticalTables = [
['Users', 'a_users'],
['Roles', 'a_roles'],
['Screens', 'd_screens'],
['Playlists', 'c_playlists'],
['Slides', 'c_slides'],
['Templates', 'c_templates'],
['Announcements', 'd_announcements'],
['RSS feeds', 'i_rss_feeds'],
['API sources', 'i_api_sources']
];
for (const [label, tableName] of criticalTables) {
try {
const [rows] = await pool.query('SELECT COUNT(*) AS count FROM ' + tableName);
diagnostics.criticalTableCounts.push({ label: label, count: Number(rows && rows[0] && rows[0].count) || 0 });
} catch (_error) {
diagnostics.criticalTableCounts.push({ label: label, count: 'Unavailable' });
}
}
} catch (_error) {
// Keep diagnostics page renderable while the database is unavailable.
}
return diagnostics;
}
app.get('/settings/system', requireSettingsRead, async function (req, res, next) {
try {
await renderSettings(req, res, req.query.message ? String(req.query.message) : '');
} catch (error) {
next(error);
}
});
app.post('/settings/system', deps.requirePermission('system-settings.update'), async function (req, res, next) {
try {
const section = String(req.body && req.body.settings_section || '').trim().toLowerCase();
if (section !== 'icons' && section !== 'media' && section !== 'security' && section !== 'defaults' && section !== 'audit') {
const error = new Error('Unknown settings section.');
error.statusCode = 400;
error.expose = true;
throw error;
}
const previousSettings = await fetchAppSettings(pool);
if (section === 'defaults') {
const defaultSlideDurationSeconds = Number(req.body && req.body.default_slide_duration_seconds);
const defaultAnnouncementDurationValue = Number(req.body && req.body.default_announcement_duration_value);
const defaultAnnouncementDurationUnit = String(req.body && req.body.default_announcement_duration_unit || 'seconds').trim().toLowerCase();
const rssDefaultIntervalValue = Number(req.body && req.body.rss_default_interval_value);
const rssDefaultIntervalUnit = String(req.body && req.body.rss_default_interval_unit || 'minutes').trim().toLowerCase();
const apiDefaultIntervalValue = Number(req.body && req.body.api_default_interval_value);
const apiDefaultIntervalUnit = String(req.body && req.body.api_default_interval_unit || 'minutes').trim().toLowerCase();
const defaultFadeBetweenSlides = req.body && (req.body.default_fade_between_slides === '1' || req.body.default_fade_between_slides === 'true');
const defaultSkipUnavailableRtmp = req.body && (req.body.default_skip_unavailable_rtmp === '1' || req.body.default_skip_unavailable_rtmp === 'true');
const defaultAnnouncementIcon = String(req.body && req.body.default_announcement_icon || '').trim();
if (!Number.isInteger(defaultSlideDurationSeconds) || defaultSlideDurationSeconds < 1 || defaultSlideDurationSeconds > 3600 || !Number.isInteger(defaultAnnouncementDurationValue) || defaultAnnouncementDurationValue < 1 || defaultAnnouncementDurationValue > 3600 || !['seconds', 'minutes'].includes(defaultAnnouncementDurationUnit) || !Number.isInteger(rssDefaultIntervalValue) || rssDefaultIntervalValue < 1 || rssDefaultIntervalValue > 86400 || !['seconds', 'minutes', 'hours'].includes(rssDefaultIntervalUnit) || !Number.isInteger(apiDefaultIntervalValue) || apiDefaultIntervalValue < 1 || apiDefaultIntervalValue > 86400 || !['seconds', 'minutes', 'hours'].includes(apiDefaultIntervalUnit) || !ANNOUNCEMENT_ICON_CATALOG.some(function (option) { return option.value === defaultAnnouncementIcon; })) {
const error = new Error('Default values must be valid.');
error.statusCode = 400;
error.expose = true;
throw error;
}
const savedSettings = await saveAppSettings(pool, {
'player.default_slide_duration_seconds': defaultSlideDurationSeconds,
'player.default_fade_between_slides': defaultFadeBetweenSlides,
'player.skip_unavailable_rtmp': defaultSkipUnavailableRtmp,
'announcements.default_icon': defaultAnnouncementIcon,
'announcements.default_duration_value': defaultAnnouncementDurationValue,
'announcements.default_duration_unit': defaultAnnouncementDurationUnit,
'data-sources.rss_default_interval_value': rssDefaultIntervalValue,
'data-sources.rss_default_interval_unit': rssDefaultIntervalUnit,
'data-sources.api_default_interval_value': apiDefaultIntervalValue,
'data-sources.api_default_interval_unit': apiDefaultIntervalUnit
}, req.currentUser && req.currentUser.id);
await recordSettingsAuditEvent(req, previousSettings, savedSettings, section);
return res.redirect('/settings/system?message=' + encodeURIComponent('Defaults saved.') + '#application-defaults');
}
if (section === 'security') {
const sessionLifetimeDays = Number(req.body && req.body.session_lifetime_days);
const allowUserSessionRevocation = req.body && (req.body.allow_user_session_revocation === '1' || req.body.allow_user_session_revocation === 'true');
const maxActiveSessions = Number(req.body && req.body.max_active_sessions);
const loginMaxAttempts = Number(req.body && req.body.login_max_attempts);
const loginLockoutMinutes = Number(req.body && req.body.login_lockout_minutes);
const loginRateLimitScope = String(req.body && req.body.login_rate_limit_scope || 'both').trim().toLowerCase();
const passwordMinLength = Number(req.body && req.body.password_min_length);
const passwordMinCategories = Number(req.body && req.body.password_min_categories);
const passwordRequireLowercase = req.body && (req.body.password_require_lowercase === '1' || req.body.password_require_lowercase === 'true');
const passwordRequireUppercase = req.body && (req.body.password_require_uppercase === '1' || req.body.password_require_uppercase === 'true');
const passwordRequireNumber = req.body && (req.body.password_require_number === '1' || req.body.password_require_number === 'true');
const passwordRequireSymbol = req.body && (req.body.password_require_symbol === '1' || req.body.password_require_symbol === 'true');
const requirePasswordChangeForNewUsers = req.body && (req.body.require_password_change_for_new_users === '1' || req.body.require_password_change_for_new_users === 'true');
const requirePasswordChangeAfterAdminReset = req.body && (req.body.require_password_change_after_admin_reset === '1' || req.body.require_password_change_after_admin_reset === 'true');
const requiredCategoryCount = [passwordRequireLowercase, passwordRequireUppercase, passwordRequireNumber, passwordRequireSymbol].filter(Boolean).length;
if (!Number.isInteger(sessionLifetimeDays) || sessionLifetimeDays < 1 || sessionLifetimeDays > 365 || !Number.isInteger(maxActiveSessions) || maxActiveSessions < 0 || maxActiveSessions > 100 || !Number.isInteger(loginMaxAttempts) || loginMaxAttempts < 1 || loginMaxAttempts > 100 || !Number.isInteger(loginLockoutMinutes) || loginLockoutMinutes < 1 || loginLockoutMinutes > 1440 || !['both', 'username', 'ip'].includes(loginRateLimitScope) || !Number.isInteger(passwordMinLength) || passwordMinLength < 8 || passwordMinLength > 128 || !Number.isInteger(passwordMinCategories) || passwordMinCategories < 1 || passwordMinCategories > 4 || requiredCategoryCount > passwordMinCategories) {
const error = new Error('Session and password settings must use valid values; required password categories cannot exceed the minimum category count.');
error.statusCode = 400;
error.expose = true;
throw error;
}
const savedSettings = await saveAppSettings(pool, {
'security.session_lifetime_days': sessionLifetimeDays,
'security.allow_user_session_revocation': allowUserSessionRevocation,
'security.max_active_sessions': maxActiveSessions,
'security.login_max_attempts': loginMaxAttempts,
'security.login_lockout_minutes': loginLockoutMinutes,
'security.login_rate_limit_scope': loginRateLimitScope,
'security.password_min_length': passwordMinLength,
'security.password_min_categories': passwordMinCategories,
'security.password_require_lowercase': passwordRequireLowercase,
'security.password_require_uppercase': passwordRequireUppercase,
'security.password_require_number': passwordRequireNumber,
'security.password_require_symbol': passwordRequireSymbol,
'security.require_password_change_for_new_users': requirePasswordChangeForNewUsers,
'security.require_password_change_after_admin_reset': requirePasswordChangeAfterAdminReset
}, req.currentUser && req.currentUser.id);
await recordSettingsAuditEvent(req, previousSettings, savedSettings, section);
return res.redirect('/settings/system?message=' + encodeURIComponent('Security settings saved.') + '#security-sessions');
}
if (section === 'audit') {
const auditEnabled = req.body && (req.body.audit_enabled === '1' || req.body.audit_enabled === 'true');
const includeRequestMetadata = req.body && (req.body.include_request_metadata === '1' || req.body.include_request_metadata === 'true');
const selectedCategories = req.body && (req.body['audit_categories[]'] !== undefined ? req.body['audit_categories[]'] : req.body.audit_categories);
const auditCategories = Array.from(new Set((Array.isArray(selectedCategories) ? selectedCategories : selectedCategories ? [selectedCategories] : []).map(function (category) {
return String(category || '').trim().toLowerCase();
}).filter(function (category) {
return AUDIT_CATEGORY_KEYS.includes(category);
})));
const retentionDays = Number(req.body && req.body.audit_retention_days);
if (!auditCategories.length || !Number.isInteger(retentionDays) || retentionDays < 0 || retentionDays > 3650) {
const error = new Error('Select at least one audit category and use a retention period between 0 and 3650 days.');
error.statusCode = 400;
error.expose = true;
throw error;
}
const savedSettings = await saveAppSettings(pool, {
'audit.enabled': auditEnabled,
'audit.categories': auditCategories,
'audit.include_request_metadata': includeRequestMetadata,
'audit.retention_days': retentionDays
}, req.currentUser && req.currentUser.id);
await recordSettingsAuditEvent(req, previousSettings, savedSettings, section);
return res.redirect('/settings/system?message=' + encodeURIComponent('Audit logging settings saved.') + '#audit-logging');
}
if (section === 'media') {
const imageMaxMb = Number(req.body && req.body.image_max_mb);
const videoMaxMb = Number(req.body && req.body.video_max_mb);
const wysiwygImageMaxMb = Number(req.body && req.body.wysiwyg_image_max_mb);
const selectedMimeTypes = req.body && (req.body['allowed_mime_types[]'] !== undefined ? req.body['allowed_mime_types[]'] : req.body.allowed_mime_types);
const allowedMimeTypes = Array.from(new Set((Array.isArray(selectedMimeTypes) ? selectedMimeTypes : selectedMimeTypes ? [selectedMimeTypes] : []).map(function (mimeType) {
return String(mimeType || '').trim().toLowerCase();
}).filter(function (mimeType) {
return UPLOAD_MIME_TYPE_OPTIONS.some(function (option) { return option.value === mimeType; });
})));
const hasImageMimeType = allowedMimeTypes.some(function (mimeType) { return mimeType.indexOf('image/') === 0; });
const hasVideoMimeType = allowedMimeTypes.some(function (mimeType) { return mimeType.indexOf('video/') === 0; });
if (!Number.isInteger(imageMaxMb) || imageMaxMb < 1 || imageMaxMb > 1024 || !Number.isInteger(videoMaxMb) || videoMaxMb < 1 || videoMaxMb > 4096 || !Number.isFinite(wysiwygImageMaxMb) || wysiwygImageMaxMb < 0.1 || wysiwygImageMaxMb > imageMaxMb || !hasImageMimeType || !hasVideoMimeType) {
const error = new Error('Media upload limits must be valid and at least one image and video type must remain enabled.');
error.statusCode = 400;
error.expose = true;
throw error;
}
await pool.query('DELETE FROM o_app_settings WHERE setting_key NOT IN (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', ['announcements.suggested_icons', 'announcements.default_icon', 'announcements.default_duration_value', 'announcements.default_duration_unit', 'player.default_slide_duration_seconds', 'player.default_fade_between_slides', 'player.skip_unavailable_rtmp', 'data-sources.rss_default_interval_value', 'data-sources.rss_default_interval_unit', 'data-sources.api_default_interval_value', 'data-sources.api_default_interval_unit', 'uploads.image_max_bytes', 'uploads.video_max_bytes', 'uploads.wysiwyg_image_max_bytes', 'uploads.allowed_mime_types', 'security.session_lifetime_days', 'security.allow_user_session_revocation', 'security.max_active_sessions', 'security.login_max_attempts', 'security.login_lockout_minutes', 'security.login_rate_limit_scope', 'security.password_min_length', 'security.password_min_categories', 'security.password_require_lowercase', 'security.password_require_uppercase', 'security.password_require_number', 'security.password_require_symbol', 'security.require_password_change_for_new_users', 'security.require_password_change_after_admin_reset', 'audit.enabled', 'audit.categories', 'audit.include_request_metadata', 'audit.retention_days']);
const savedSettings = await saveAppSettings(pool, {
'uploads.image_max_bytes': imageMaxMb * 1024 * 1024,
'uploads.video_max_bytes': videoMaxMb * 1024 * 1024,
'uploads.wysiwyg_image_max_bytes': Math.round(wysiwygImageMaxMb * 1024 * 1024),
'uploads.allowed_mime_types': allowedMimeTypes
}, req.currentUser && req.currentUser.id);
await recordSettingsAuditEvent(req, previousSettings, savedSettings, section);
return res.redirect('/settings/system?message=' + encodeURIComponent('Media settings saved.') + '#media-uploads');
}
const selectedIcons = req.body && (req.body['suggested_icons[]'] !== undefined ? req.body['suggested_icons[]'] : req.body.suggested_icons);
const catalogKeys = new Set(ANNOUNCEMENT_ICON_CATALOG.map(function (option) {
return option.value;
}));
const suggestedIcons = Array.from(new Set((Array.isArray(selectedIcons) ? selectedIcons : selectedIcons ? [selectedIcons] : []).map(function (iconKey) {
return String(iconKey || '').trim().toLowerCase();
}).filter(function (iconKey) {
return catalogKeys.has(iconKey);
})));
if (suggestedIcons.length > MAX_SUGGESTED_ICONS) {
const error = new Error('Choose no more than ' + MAX_SUGGESTED_ICONS + ' suggested icons.');
error.statusCode = 400;
error.expose = true;
throw error;
}
await pool.query('DELETE FROM o_app_settings WHERE setting_key NOT IN (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', ['announcements.suggested_icons', 'announcements.default_icon', 'announcements.default_duration_value', 'announcements.default_duration_unit', 'player.default_slide_duration_seconds', 'player.default_fade_between_slides', 'player.skip_unavailable_rtmp', 'data-sources.rss_default_interval_value', 'data-sources.rss_default_interval_unit', 'data-sources.api_default_interval_value', 'data-sources.api_default_interval_unit', 'uploads.image_max_bytes', 'uploads.video_max_bytes', 'uploads.wysiwyg_image_max_bytes', 'uploads.allowed_mime_types', 'security.session_lifetime_days', 'security.allow_user_session_revocation', 'security.max_active_sessions', 'security.login_max_attempts', 'security.login_lockout_minutes', 'security.login_rate_limit_scope', 'security.password_min_length', 'security.password_min_categories', 'security.password_require_lowercase', 'security.password_require_uppercase', 'security.password_require_number', 'security.password_require_symbol', 'security.require_password_change_for_new_users', 'security.require_password_change_after_admin_reset', 'audit.enabled', 'audit.categories', 'audit.include_request_metadata', 'audit.retention_days']);
const savedSettings = await saveAppSettings(pool, {
'announcements.suggested_icons': suggestedIcons
}, req.currentUser && req.currentUser.id);
await recordSettingsAuditEvent(req, previousSettings, savedSettings, section);
res.redirect('/settings/system?message=' + encodeURIComponent('Settings saved.'));
} catch (error) {
next(error);
}
});
};
+2 -2
View File
@@ -3,6 +3,6 @@
const { renderView } = require('../../../view');
const { buildUsersEditViewModel } = require('./form-view-model');
module.exports = function renderUsersEditPage(user, message, currentUser, roles) {
return renderView('settings/users/form', buildUsersEditViewModel(user, message, currentUser, roles));
module.exports = function renderUsersEditPage(user, message, currentUser, roles, sessions) {
return renderView('settings/users/form', buildUsersEditViewModel(user, message, currentUser, roles, sessions));
};
@@ -24,9 +24,9 @@ function buildUsersAddViewModel(message, currentUser, roles, formValues, message
active: 'users',
isEdit: false,
formId: 'user-form',
formAction: '/users',
footerSaveUrl: '/users',
footerCancelUrl: '/users',
formAction: '/settings/users',
footerSaveUrl: '/settings/users',
footerCancelUrl: '/settings/users',
footerDeleteUrl: '',
footerDeleteDisabled: true,
footerDeleteTitle: 'Delete is available after the user has been created.',
@@ -36,21 +36,21 @@ function buildUsersAddViewModel(message, currentUser, roles, formValues, message
currentUser: currentUser || null,
roles: mapRolesForForm(roles, []),
formValues: formValues || {},
formAttrs: 'data-async-save data-async-save-close-url="/users" data-async-save-new-redirect="response-url" data-async-save-new-url="/users/new"'
formAttrs: 'data-async-save data-async-save-close-url="/settings/users" data-async-save-new-redirect="response-url" data-async-save-new-url="/settings/users/new"'
};
}
function buildUsersEditViewModel(user, message, currentUser, roles) {
function buildUsersEditViewModel(user, message, currentUser, roles, sessions) {
return {
title: 'Edit user',
active: 'users',
isEdit: true,
formId: 'user-profile-form',
formAction: '/users/' + user.id + '/username',
formAttrs: 'data-async-save data-async-save-close-url="/users"',
footerSaveUrl: '/users/' + user.id + '/username',
footerCancelUrl: '/users',
footerDeleteUrl: '/users/' + user.id + '/delete',
formAction: '/settings/users/' + user.id + '/username',
formAttrs: 'data-async-save data-async-save-close-url="/settings/users"',
footerSaveUrl: '/settings/users/' + user.id + '/username',
footerCancelUrl: '/settings/users',
footerDeleteUrl: '/settings/users/' + user.id + '/delete',
footerDeleteDisabled: Boolean(user && user.inUse),
footerDeleteConfirmMessage: 'Delete this user?',
footerDeleteTitle: user && user.inUse ? 'Delete is disabled while this user is the only account left.' : '',
@@ -59,9 +59,10 @@ function buildUsersEditViewModel(user, message, currentUser, roles) {
message: message,
currentUser: currentUser || null,
user: user,
sessions: Array.isArray(sessions) ? sessions : [],
inUse: Boolean(user && user.inUse),
roles: mapRolesForForm(roles, user && user.roleIds),
formAttrs: 'data-async-save data-async-save-close-url="/users" data-async-save-new-url="/users/new"'
formAttrs: 'data-async-save data-async-save-close-url="/settings/users" data-async-save-new-url="/settings/users/new"'
};
}
@@ -23,6 +23,7 @@ function buildAnnouncementFormViewModel(announcement, message, currentUser, opti
const announcementTypes = Array.isArray(options && options.announcementTypes) ? options.announcementTypes : [];
const announcementColors = Array.isArray(options && options.announcementColors) ? options.announcementColors : [];
const announcementIcons = Array.isArray(options && options.announcementIcons) ? options.announcementIcons : [];
const announcementIconCatalog = Array.isArray(options && options.announcementIconCatalog) ? options.announcementIconCatalog : announcementIcons;
const announcementScreens = Array.isArray(options && options.announcementScreens) ? options.announcementScreens : [];
const viewOptions = options || {};
const viewAnnouncement = Object.assign({}, buildDefaultAnnouncement(), announcement || {});
@@ -68,6 +69,7 @@ function buildAnnouncementFormViewModel(announcement, message, currentUser, opti
announcementTypes: announcementTypes,
announcementColors: announcementColors,
announcementIcons: announcementIcons,
announcementIconCatalog: announcementIconCatalog,
announcementScreens: announcementScreens
};
}
+31 -6
View File
@@ -11,6 +11,7 @@ module.exports = function registerAnnouncementRoutes(app, deps) {
const { buildPagination } = require('../../../lib/pagination');
const announcementData = require('#src/data/announcements');
const announcementIcons = require('#src/data/announcement-icons');
const { fetchAppSettings } = require('#src/data/app-settings');
const { buildDuplicateAnnouncementName, buildDuplicateAnnouncement } = require('./duplicate');
const LIST_PAGE_SIZE = 25;
@@ -30,6 +31,21 @@ module.exports = function registerAnnouncementRoutes(app, deps) {
{ value: 'light', label: 'Light' }
];
const ANNOUNCEMENT_ICONS = announcementIcons.ANNOUNCEMENT_ICON_OPTIONS || [];
const ANNOUNCEMENT_ICON_CATALOG = announcementIcons.ANNOUNCEMENT_ICON_CATALOG || ANNOUNCEMENT_ICONS;
async function fetchSuggestedAnnouncementIcons() {
const settings = await fetchAppSettings(pool);
const catalogByKey = new Map(ANNOUNCEMENT_ICON_CATALOG.map(function (option) {
return [option.value, option];
}));
const configuredKeys = Array.isArray(settings['announcements.suggested_icons'])
? settings['announcements.suggested_icons']
: [];
const configuredOptions = configuredKeys.map(function (key) {
return catalogByKey.get(String(key || '').trim().toLowerCase()) || null;
}).filter(Boolean);
return configuredOptions.length ? configuredOptions : ANNOUNCEMENT_ICONS;
}
function normalizeDurationUnit(value) {
return String(value || 'minutes').trim().toLowerCase() === 'seconds' ? 'seconds' : 'minutes';
@@ -269,21 +285,28 @@ module.exports = function registerAnnouncementRoutes(app, deps) {
app.get('/announcements/new', requirePermission('announcements.create'), async function (req, res, next) {
try {
const screens = await fetchAnnouncementScreens();
const suggestedAnnouncementIcons = await fetchSuggestedAnnouncementIcons();
const settings = await fetchAppSettings(pool);
const configuredIcon = String(settings['announcements.default_icon'] || '').trim().toLowerCase();
const defaultIcon = ANNOUNCEMENT_ICON_CATALOG.some(function (option) { return option.value === configuredIcon; })
? configuredIcon
: (suggestedAnnouncementIcons[0] && suggestedAnnouncementIcons[0].value ? suggestedAnnouncementIcons[0].value : announcementIcons.DEFAULT_ANNOUNCEMENT_ICON);
res.send(pages.renderAnnouncementAddPage(buildAnnouncementFormData({
message: '',
short_label: '',
announcement_type: 'lower-third',
color_key: 'primary',
icon_key: announcementIcons.DEFAULT_ANNOUNCEMENT_ICON,
icon_key: defaultIcon,
durationMode: 'duration',
duration_seconds: null,
durationValue: 5,
durationUnit: 'minutes',
durationValue: Number(settings['announcements.default_duration_value']) || 10,
durationUnit: String(settings['announcements.default_duration_unit'] || 'seconds'),
screen_ids: []
}), req.query.message ? String(req.query.message) : '', req.currentUser, {
announcementTypes: ANNOUNCEMENT_TYPES,
announcementColors: ANNOUNCEMENT_COLORS,
announcementIcons: ANNOUNCEMENT_ICONS,
announcementIcons: suggestedAnnouncementIcons,
announcementIconCatalog: ANNOUNCEMENT_ICON_CATALOG,
announcementScreens: buildAnnouncementScreenOptions(screens, [])
}));
} catch (error) {
@@ -301,7 +324,8 @@ module.exports = function registerAnnouncementRoutes(app, deps) {
res.send(pages.renderAnnouncementEditPage(buildAnnouncementFormData(announcement), null, req.query.message ? String(req.query.message) : '', req.currentUser, {
announcementTypes: ANNOUNCEMENT_TYPES,
announcementColors: ANNOUNCEMENT_COLORS,
announcementIcons: ANNOUNCEMENT_ICONS,
announcementIcons: await fetchSuggestedAnnouncementIcons(),
announcementIconCatalog: ANNOUNCEMENT_ICON_CATALOG,
announcementScreens: buildAnnouncementScreenOptions(screens, announcement.screen_ids || [])
}));
} catch (error) {
@@ -327,7 +351,8 @@ module.exports = function registerAnnouncementRoutes(app, deps) {
res.send(pages.renderAnnouncementAddPage(buildAnnouncementFormData(buildDuplicateAnnouncement(announcement, duplicateName)), req.query.message ? String(req.query.message) : 'Review the copied values and save when ready.', req.currentUser, {
announcementTypes: ANNOUNCEMENT_TYPES,
announcementColors: ANNOUNCEMENT_COLORS,
announcementIcons: ANNOUNCEMENT_ICONS,
announcementIcons: await fetchSuggestedAnnouncementIcons(),
announcementIconCatalog: ANNOUNCEMENT_ICON_CATALOG,
announcementScreens: buildAnnouncementScreenOptions(screens, announcement.screen_ids || []),
messageVariant: 'info'
}));
@@ -175,6 +175,7 @@ function formatCanvasSizeLabel(size) {
function buildPlaylistFormViewModel(playlist, data, message, currentUser, options) {
const isEdit = Boolean(options && options.isEdit);
const defaultSlideDurationSeconds = Number(data && data.defaultSlideDurationSeconds) || 10;
const viewPlaylist = Object.assign({
id: null,
name: '',
@@ -245,7 +246,7 @@ function buildPlaylistFormViewModel(playlist, data, message, currentUser, option
videoDurationSeconds: '',
useVideoDuration: false,
disableAudio: true,
durationSeconds: 10,
durationSeconds: defaultSlideDurationSeconds,
scheduleRules: [],
scheduleSummary: 'Always visible',
displayOrder: ''
@@ -280,7 +281,8 @@ function buildPlaylistFormViewModel(playlist, data, message, currentUser, option
selectableSlides: selectableSlides,
playlistCanvasId: playlistCanvasId,
playlistCanvasMismatch: playlistCanvasMismatch,
scripts: ['js/lib/modal.js?v=20260726.7', 'js/playlists/playlist-table-drag.js?v=20260802.5', 'js/playlists/playlist-schedule.js?v=20260803.1'],
defaultSlideDurationSeconds: defaultSlideDurationSeconds,
scripts: ['js/lib/modal.js?v=20260726.7', 'js/playlists/playlist-table-drag.js?v=20260802.5', 'js/playlists/playlist-schedule.js?v=20260816.1'],
message: message,
currentUser: currentUser || null,
inUse: Boolean(viewPlaylist.inUse)
+11 -1
View File
@@ -8,6 +8,7 @@ module.exports = function registerPlaylistsRoutes(app, deps) {
const { buildPagination } = require('../../../lib/pagination');
const requirePermission = deps.requirePermission;
const { buildDuplicatePlaylistName, buildDuplicatePlaylist } = require('./duplicate');
const { fetchAppSettings } = require('../../../../data/app-settings');
const LIST_PAGE_SIZE = 25;
@@ -31,6 +32,8 @@ module.exports = function registerPlaylistsRoutes(app, deps) {
return res.status(404).send('Playlist not found');
}
playlist.inUse = (await fetchScreensByPlaylistId(pool, playlist.id)).length > 0;
const settings = await fetchAppSettings(pool);
data.defaultSlideDurationSeconds = Number(settings['player.default_slide_duration_seconds']) || 10;
return res.send(pages.renderPlaylistEditPage(playlist, data, req.query.message ? String(req.query.message) : '', req.currentUser));
}
const data = await common.fetchPlaylistsPage(pool, page, LIST_PAGE_SIZE, search, sort, direction);
@@ -46,7 +49,12 @@ module.exports = function registerPlaylistsRoutes(app, deps) {
app.get('/playlists/new', requirePermission('playlists.create'), function (req, res) {
(async function () {
const data = await common.fetchAdminData(pool);
res.send(await pages.renderPlaylistFormPage(req.query.message ? String(req.query.message) : '', req.currentUser, data, pool));
const settings = await fetchAppSettings(pool);
data.defaultSlideDurationSeconds = Number(settings['player.default_slide_duration_seconds']) || 10;
res.send(await pages.renderPlaylistFormPage({
fade_between_slides: Boolean(settings['player.default_fade_between_slides']),
skip_unavailable_rtmp: Boolean(settings['player.skip_unavailable_rtmp'])
}, req.query.message ? String(req.query.message) : '', req.currentUser, data, pool));
})().catch(function (error) {
res.status(500).send(error.message || 'Unable to load page.');
});
@@ -81,6 +89,8 @@ module.exports = function registerPlaylistsRoutes(app, deps) {
return res.status(404).send('Playlist not found');
}
const data = await common.fetchAdminData(pool);
const settings = await fetchAppSettings(pool);
data.defaultSlideDurationSeconds = Number(settings['player.default_slide_duration_seconds']) || 10;
playlist.inUse = (await fetchScreensByPlaylistId(pool, playlist.id)).length > 0;
res.send(pages.renderPlaylistEditPage(playlist, data, req.query.message ? String(req.query.message) : '', req.currentUser));
} catch (error) {
@@ -26,6 +26,7 @@ function buildSlideFormViewModel(data, slide, message, currentUser, isEdit) {
const apiSources = data && data.apiSources ? data.apiSources : [];
const timetableGroups = data && data.timetableGroups ? data.timetableGroups : [];
const fontLibrary = data && data.fontLibrary ? data.fontLibrary : null;
const uploadLimits = data && data.uploadLimits ? data.uploadLimits : {};
const templates = buildTemplates(data && data.templates ? data.templates : [], templateRegions);
const viewSlide = slide || buildDefaultSlide();
@@ -50,7 +51,8 @@ function buildSlideFormViewModel(data, slide, message, currentUser, isEdit) {
fontStylesheetHref: fontLibrary && fontLibrary.stylesheetHref ? fontLibrary.stylesheetHref : '',
fontFamilyFormats: fontLibrary && fontLibrary.fontFamilyFormats ? fontLibrary.fontFamilyFormats : '',
existingTemplateId: viewSlide && viewSlide.template_id ? viewSlide.template_id : null,
existingContent: viewSlide && viewSlide.content ? viewSlide.content : {}
existingContent: viewSlide && viewSlide.content ? viewSlide.content : {},
uploadLimits: uploadLimits
},
assetVersion: assetVersion,
slideEditorScripts: ['vendor/qr-code-styling/qr-code-styling-loader.js?v=' + assetVersion].concat(getRegionEditorScripts(assetVersion)),