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

This commit is contained in:
2026-08-16 22:41:39 +01:00
parent a5bf8e6f7f
commit ea72747822
31 changed files with 379 additions and 31 deletions
+7
View File
@@ -2,6 +2,13 @@
All notable changes to this project will be documented in this file. All notable changes to this project will be documented in this file.
## 2.8.2 - 2026-08-16
### Changed
- Standardized update audit events on from/to changes and added readable table diffs for nested JSON, arrays, null values, and empty strings.
- Standardized internal `src/data` imports on the `#src` alias.
## 2.8.1 - 2026-08-16 ## 2.8.1 - 2026-08-16
### Fixed ### Fixed
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "pulse-signage-player", "name": "pulse-signage-player",
"version": "2.8.1", "version": "2.8.2",
"private": false, "private": false,
"description": "Pulse Signage player application bundle", "description": "Pulse Signage player application bundle",
"engines": { "engines": {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "pulse-signage-web", "name": "pulse-signage-web",
"version": "2.8.1", "version": "2.8.2",
"private": false, "private": false,
"description": "Pulse Signage web and bridge application bundle", "description": "Pulse Signage web and bridge application bundle",
"engines": { "engines": {
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "pulse-signage", "name": "pulse-signage",
"version": "2.8.1", "version": "2.8.2",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "pulse-signage", "name": "pulse-signage",
"version": "2.8.1", "version": "2.8.2",
"dependencies": { "dependencies": {
"@sparticuz/chromium": "^149.0.0", "@sparticuz/chromium": "^149.0.0",
"animate.css": "^4.1.1", "animate.css": "^4.1.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "pulse-signage", "name": "pulse-signage",
"version": "2.8.1", "version": "2.8.2",
"private": false, "private": false,
"description": "Pulse Signage application with MySQL and media storage", "description": "Pulse Signage application with MySQL and media storage",
"engines": { "engines": {
+16
View File
@@ -42,6 +42,21 @@ function normalizeDetails(details) {
return JSON.stringify(details); return JSON.stringify(details);
} }
function buildAuditChanges(previousValues, nextValues) {
const previous = previousValues && typeof previousValues === 'object' ? previousValues : {};
const next = nextValues && typeof nextValues === 'object' ? nextValues : {};
const changes = {};
const keys = new Set(Object.keys(previous).concat(Object.keys(next)));
keys.forEach(function (key) {
if (JSON.stringify(previous[key]) !== JSON.stringify(next[key])) {
changes[key] = { from: previous[key], to: next[key] };
}
});
return changes;
}
function getRequestMetadata(req) { function getRequestMetadata(req) {
const forwardedAddress = String(req && req.headers && req.headers['x-forwarded-for'] || '').split(',')[0].trim(); const forwardedAddress = String(req && req.headers && req.headers['x-forwarded-for'] || '').split(',')[0].trim();
return { return {
@@ -97,6 +112,7 @@ module.exports = {
AUDIT_CATEGORY_KEYS, AUDIT_CATEGORY_KEYS,
AUDIT_CATEGORY_LABELS, AUDIT_CATEGORY_LABELS,
getRequestMetadata, getRequestMetadata,
buildAuditChanges,
recordAuditEvent, recordAuditEvent,
recordRequestAuditEvent recordRequestAuditEvent
}; };
+33
View File
@@ -255,6 +255,39 @@
.template-preview-card .btn-group { .template-preview-card .btn-group {
display: grid; display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr)); grid-template-columns: repeat(3, minmax(0, 1fr));
.audit-change-list {
display: grid;
padding: 0.08rem 0.3rem;
border-radius: 0.2rem;
gap: 0.2rem;
min-width: 18rem;
}
.audit-change-row {
display: grid;
grid-template-columns: minmax(8rem, 0.7fr) minmax(5rem, 1fr) auto minmax(5rem, 1fr);
background: var(--bs-danger-bg-subtle);
gap: 0.35rem;
align-items: baseline;
font-size: 0.78rem;
}
background: var(--bs-success-bg-subtle);
.audit-change-to {
overflow-wrap: anywhere;
white-space: pre-wrap;
}
.audit-change-from {
color: var(--bs-danger-text-emphasis);
}
.audit-change-to {
color: var(--bs-success-text-emphasis);
}
.audit-change-from del,
.audit-change-to ins {
text-decoration-thickness: 2px;
}
.audit-change-arrow {
color: var(--bs-secondary-color);
}
gap: 0.5rem; gap: 0.5rem;
} }
+1 -1
View File
@@ -1,6 +1,6 @@
// Admin account route registration and profile helpers. // Admin account route registration and profile helpers.
const { fetchAppSettings } = require('../../../data/app-settings'); const { fetchAppSettings } = require('#src/data/app-settings');
module.exports = function registerAccountRoutes(app, deps) { module.exports = function registerAccountRoutes(app, deps) {
const pool = deps.pool; const pool = deps.pool;
+55 -3
View File
@@ -4,6 +4,7 @@ const fs = require('fs');
const path = require('path'); const path = require('path');
const { loadFontLibrary } = require('#src/web/lib/media/font-library'); const { loadFontLibrary } = require('#src/web/lib/media/font-library');
const { fetchAppSettings } = require('#src/data/app-settings'); const { fetchAppSettings } = require('#src/data/app-settings');
const { buildAuditChanges } = require('#src/data/audit-log');
const { PERMISSION_DENIED_MESSAGE } = require('#src/rbac'); const { PERMISSION_DENIED_MESSAGE } = require('#src/rbac');
module.exports = function registerContentRoutes(app, deps) { module.exports = function registerContentRoutes(app, deps) {
@@ -39,6 +40,26 @@ module.exports = function registerContentRoutes(app, deps) {
const IMAGE_UPLOAD_MAX_BYTES = 100 * 1024 * 1024; const IMAGE_UPLOAD_MAX_BYTES = 100 * 1024 * 1024;
const VIDEO_UPLOAD_MAX_BYTES = 1024 * 1024 * 1024; const VIDEO_UPLOAD_MAX_BYTES = 1024 * 1024 * 1024;
function normalizeTemplateRegionsForAudit(regions) {
return (Array.isArray(regions) ? regions : []).map(function (region) {
const animation = typeof region.animation_json === 'string'
? common.parseJsonSafe(region.animation_json) || {}
: region.animation_json || {};
return {
region_key: region.region_key,
region_type: region.region_type,
label: region.label,
lock_ratio: region.lock_ratio,
animation_json: animation,
x: Number(region.x),
y: Number(region.y),
width: Number(region.width),
height: Number(region.height),
z_index: Number(region.z_index)
};
});
}
async function fetchSlideFormData() { async function fetchSlideFormData() {
const data = await common.fetchTemplatesData(pool); const data = await common.fetchTemplatesData(pool);
const rssData = await common.fetchRssFeedsData(pool); const rssData = await common.fetchRssFeedsData(pool);
@@ -497,6 +518,15 @@ module.exports = function registerContentRoutes(app, deps) {
} }
const nextUploadRefs = collectUploadReferencesFromPayload(payload); const nextUploadRefs = collectUploadReferencesFromPayload(payload);
const actorId = getAuditUserId(req); const actorId = getAuditUserId(req);
const changes = buildAuditChanges({
title: slide.title,
templateId: Number(slide.template_id),
content: slide.content
}, {
title: payload.title,
templateId: Number(payload.templateId),
content: common.parseJsonSafe(payload.contentJson) || {}
});
await pool.query( await pool.query(
'UPDATE c_slides SET title = ?, template_id = ?, content_json = ?, modified_by = ? WHERE id = ?', 'UPDATE c_slides SET title = ?, template_id = ?, content_json = ?, modified_by = ? WHERE id = ?',
[payload.title, payload.templateId, payload.contentJson, actorId, slide.id] [payload.title, payload.templateId, payload.contentJson, actorId, slide.id]
@@ -512,7 +542,7 @@ module.exports = function registerContentRoutes(app, deps) {
screenSlideCounts: screenSlideCounts screenSlideCounts: screenSlideCounts
}); });
await broadcastDashboardState(); 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 }); if (typeof recordRequestAuditEvent === 'function') await recordRequestAuditEvent(pool, req, { category: 'slides', eventType: 'slide.updated', actorUserId: actorId, targetType: 'slide', targetId: slide.id, targetLabel: payload.title, details: { changes: changes } });
queueSlideThumbnailRefresh(slide.id, slide.thumbnail_path).catch(function (error) { queueSlideThumbnailRefresh(slide.id, slide.thumbnail_path).catch(function (error) {
console.warn('Unable to queue slide thumbnail refresh:', error); console.warn('Unable to queue slide thumbnail refresh:', error);
}); });
@@ -655,6 +685,19 @@ module.exports = function registerContentRoutes(app, deps) {
const nextUploadRefs = collectUploadReferencesFromPayload(payload); const nextUploadRefs = collectUploadReferencesFromPayload(payload);
const affectedScreens = await fetchScreensByTemplateId(pool, template.id); const affectedScreens = await fetchScreensByTemplateId(pool, template.id);
const actorId = getAuditUserId(req); const actorId = getAuditUserId(req);
const changes = buildAuditChanges({
name: template.name,
canvasSizeId: Number(template.canvas_size_id),
backgroundImagePath: template.background_image_path,
backgroundColor: template.background_color,
regions: normalizeTemplateRegionsForAudit(template.regions)
}, {
name: payload.name,
canvasSizeId: Number(payload.canvasSizeId),
backgroundImagePath: payload.backgroundImagePath,
backgroundColor: payload.backgroundColor,
regions: normalizeTemplateRegionsForAudit(payload.regions)
});
await pool.query( await pool.query(
'UPDATE c_templates SET name = ?, canvas_size_id = ?, background_image_path = ?, background_color = ?, modified_by = ? WHERE id = ?', 'UPDATE c_templates SET name = ?, canvas_size_id = ?, background_image_path = ?, background_color = ?, modified_by = ? WHERE id = ?',
[payload.name, payload.canvasSizeId, payload.backgroundImagePath, payload.backgroundColor, actorId, template.id] [payload.name, payload.canvasSizeId, payload.backgroundImagePath, payload.backgroundColor, actorId, template.id]
@@ -681,7 +724,7 @@ module.exports = function registerContentRoutes(app, deps) {
}); });
} }
await notifyPlayerScreens(affectedScreens, 'refresh'); 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 }); if (typeof recordRequestAuditEvent === 'function') await recordRequestAuditEvent(pool, req, { category: 'templates', eventType: 'template.updated', actorUserId: actorId, targetType: 'template', targetId: template.id, targetLabel: payload.name, details: { changes: changes } });
redirectAfterSave(req, res, '/templates/' + template.id + '/edit', { redirectAfterSave(req, res, '/templates/' + template.id + '/edit', {
closeUrl: '/templates', closeUrl: '/templates',
newUrl: '/templates/new', newUrl: '/templates/new',
@@ -813,8 +856,17 @@ module.exports = function registerContentRoutes(app, deps) {
if (await canvasSizeExists(payload.width, payload.height, canvasSize.id)) { if (await canvasSizeExists(payload.width, payload.height, canvasSize.id)) {
return res.status(400).send('That canvas size already exists.'); return res.status(400).send('That canvas size already exists.');
} }
const changes = buildAuditChanges({
name: canvasSize.name,
width: Number(canvasSize.width),
height: Number(canvasSize.height)
}, {
name: payload.name,
width: payload.width,
height: payload.height
});
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]); 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 } }); 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: { changes: changes } });
redirectAfterSave(req, res, '/canvas-sizes', { redirectAfterSave(req, res, '/canvas-sizes', {
closeUrl: '/canvas-sizes', closeUrl: '/canvas-sizes',
newUrl: '/canvas-sizes/new', newUrl: '/canvas-sizes/new',
+12 -1
View File
@@ -1,5 +1,7 @@
// Admin manage routes for screens and commands. // Admin manage routes for screens and commands.
const { buildAuditChanges } = require('#src/data/audit-log');
module.exports = function registerManageRoutes(app, deps) { module.exports = function registerManageRoutes(app, deps) {
const pool = deps.pool; const pool = deps.pool;
const common = deps.common; const common = deps.common;
@@ -248,6 +250,15 @@ module.exports = function registerManageRoutes(app, deps) {
const previousPlaylistId = screen.playlist_id; const previousPlaylistId = screen.playlist_id;
const slug = String(screen.slug || '').trim(); const slug = String(screen.slug || '').trim();
const previousSlug = String(screen.slug || '').trim(); const previousSlug = String(screen.slug || '').trim();
const changes = buildAuditChanges({
name: screen.name,
slug: previousSlug,
playlistId: previousPlaylistId === null ? null : Number(previousPlaylistId)
}, {
name: name,
slug: slug,
playlistId: playlistId
});
await pool.query('UPDATE d_screens SET name = ?, slug = ?, playlist_id = ?, modified_by = ? WHERE id = ?', [name, slug, playlistId, getAuditUserId(req), screen.id]); await pool.query('UPDATE d_screens SET name = ?, slug = ?, playlist_id = ?, modified_by = ? WHERE id = ?', [name, slug, playlistId, getAuditUserId(req), screen.id]);
if (previousPlaylistId !== playlistId && previousSlug) { if (previousPlaylistId !== playlistId && previousSlug) {
await notifyPlayerScreens([previousSlug], 'refresh'); await notifyPlayerScreens([previousSlug], 'refresh');
@@ -264,7 +275,7 @@ module.exports = function registerManageRoutes(app, deps) {
await forwardPlayerCommand(previousSlug, redirectPayload); 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 } }); if (typeof recordRequestAuditEvent === 'function') await recordRequestAuditEvent(pool, req, { category: 'screens', eventType: 'screen.updated', actorUserId: getAuditUserId(req), targetType: 'screen', targetId: screen.id, targetLabel: name, details: { changes: changes } });
redirectAfterSave(req, res, '/screens?edit=' + screen.id, { redirectAfterSave(req, res, '/screens?edit=' + screen.id, {
closeUrl: '/screens', closeUrl: '/screens',
newUrl: '/screens/new', newUrl: '/screens/new',
+16 -2
View File
@@ -1,6 +1,7 @@
// Playlist admin routes and playlist-slide management. // Playlist admin routes and playlist-slide management.
const { fetchAppSettings } = require('../../../data/app-settings'); const { fetchAppSettings } = require('#src/data/app-settings');
const { buildAuditChanges } = require('#src/data/audit-log');
module.exports = function registerPlaylistRoutes(app, deps) { module.exports = function registerPlaylistRoutes(app, deps) {
const pool = deps.pool; const pool = deps.pool;
@@ -356,7 +357,20 @@ module.exports = function registerPlaylistRoutes(app, deps) {
await connection.commit(); await connection.commit();
await notifyPlayerScreens(saveResult.affectedScreens, 'refresh'); await notifyPlayerScreens(saveResult.affectedScreens, 'refresh');
await broadcastDashboardState(); await broadcastDashboardState();
if (typeof recordRequestAuditEvent === 'function') await recordRequestAuditEvent(pool, req, { category: 'playlists', eventType: 'playlist.updated', actorUserId: actorId, targetType: 'playlist', targetId: playlist.id, targetLabel: name }); if (typeof recordRequestAuditEvent === 'function') {
const changes = buildAuditChanges({
name: playlist.name,
fadeBetweenSlides: Boolean(playlist.fade_between_slides),
skipUnavailableRtmp: Boolean(playlist.skip_unavailable_rtmp),
canvasId: playlist.canvas_id === null ? null : Number(playlist.canvas_id)
}, {
name: name,
fadeBetweenSlides: Boolean(fadeBetweenSlides),
skipUnavailableRtmp: Boolean(skipUnavailableRtmp),
canvasId: saveResult.nextCanvasId === null ? null : Number(saveResult.nextCanvasId)
});
await recordRequestAuditEvent(pool, req, { category: 'playlists', eventType: 'playlist.updated', actorUserId: actorId, targetType: 'playlist', targetId: playlist.id, targetLabel: name, details: { changes: changes } });
}
redirectAfterSave(req, res, '/playlists/' + playlist.id + '/edit', { redirectAfterSave(req, res, '/playlists/' + playlist.id + '/edit', {
closeUrl: '/playlists', closeUrl: '/playlists',
newUrl: '/playlists/new', newUrl: '/playlists/new',
+22 -4
View File
@@ -1,5 +1,6 @@
// Admin RBAC route registration and permission management. // Admin RBAC route registration and permission management.
const { DEFAULT_ROLE } = require('#src/rbac'); const { DEFAULT_ROLE } = require('#src/rbac');
const { buildAuditChanges } = require('#src/data/audit-log');
module.exports = function registerRbacRoutes(app, deps) { module.exports = function registerRbacRoutes(app, deps) {
const pool = deps.pool; const pool = deps.pool;
@@ -413,10 +414,14 @@
return res.redirect('/settings/roles/' + 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 existingRolePermissionKeys = shouldSyncPermissions
? await rbacData.fetchRolePermissionKeys(pool, roleId)
: [];
let existingRoleUserIds = [];
let availableUsers = []; let availableUsers = [];
if (shouldSyncUsers) { if (shouldSyncUsers) {
availableUsers = await rbacData.fetchUsersWithRoles(pool); availableUsers = await rbacData.fetchUsersWithRoles(pool);
const existingRoleUserIds = await rbacData.fetchRoleUserIds(pool, roleId); existingRoleUserIds = await rbacData.fetchRoleUserIds(pool, roleId);
const visibleUserIdSet = new Set(visibleUserIds); const visibleUserIdSet = new Set(visibleUserIds);
normalizedUserIds = existingRoleUserIds.filter(function (userId) { normalizedUserIds = existingRoleUserIds.filter(function (userId) {
return !visibleUserIdSet.has(userId); return !visibleUserIdSet.has(userId);
@@ -452,6 +457,17 @@
connection.release(); connection.release();
} }
if (typeof recordRequestAuditEvent === 'function') { if (typeof recordRequestAuditEvent === 'function') {
const changes = buildAuditChanges({
name: role.name,
description: role.description,
permissionKeys: existingRolePermissionKeys,
userIds: existingRoleUserIds
}, {
name: name,
description: description || null,
permissionKeys: normalizedPermissionKeys,
userIds: normalizedUserIds
});
await recordRequestAuditEvent(pool, req, { await recordRequestAuditEvent(pool, req, {
category: 'roles', category: 'roles',
eventType: 'role.updated', eventType: 'role.updated',
@@ -459,7 +475,7 @@
targetType: 'role', targetType: 'role',
targetId: roleId, targetId: roleId,
targetLabel: name, targetLabel: name,
details: { permissionsChanged: shouldSyncPermissions, usersChanged: shouldSyncUsers } details: { changes: changes }
}); });
} }
res.redirect('/settings/roles/' + roleId + '/edit?message=' + encodeURIComponent('Role updated.')); res.redirect('/settings/roles/' + roleId + '/edit?message=' + encodeURIComponent('Role updated.'));
@@ -494,6 +510,7 @@
})) { })) {
return res.redirect('/settings/roles/' + 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 existingPermissionKeys = await rbacData.fetchRolePermissionKeys(pool, roleId);
const connection = await pool.getConnection(); const connection = await pool.getConnection();
try { try {
@@ -514,7 +531,7 @@
targetType: 'role', targetType: 'role',
targetId: roleId, targetId: roleId,
targetLabel: role.name, targetLabel: role.name,
details: { permissionKeys: normalizedPermissionKeys } details: { changes: buildAuditChanges({ permissionKeys: existingPermissionKeys }, { permissionKeys: normalizedPermissionKeys }) }
}); });
} }
res.redirect('/settings/roles/' + roleId + '/edit?message=' + encodeURIComponent('Permissions updated.')); res.redirect('/settings/roles/' + roleId + '/edit?message=' + encodeURIComponent('Permissions updated.'));
@@ -541,6 +558,7 @@
? [].concat(req.body.user_ids) ? [].concat(req.body.user_ids)
: []; : [];
const normalizedUserIds = normalizeSelectedIds(selectedUserIds); const normalizedUserIds = normalizeSelectedIds(selectedUserIds);
const existingUserIds = await rbacData.fetchRoleUserIds(pool, roleId);
const availableUsers = await rbacData.fetchUsersWithRoles(pool); const availableUsers = await rbacData.fetchUsersWithRoles(pool);
const validUserIds = new Set(availableUsers.map(function (user) { const validUserIds = new Set(availableUsers.map(function (user) {
return Number(user.id); return Number(user.id);
@@ -561,7 +579,7 @@
targetType: 'role', targetType: 'role',
targetId: roleId, targetId: roleId,
targetLabel: role.name, targetLabel: role.name,
details: { userIds: normalizedUserIds } details: { changes: buildAuditChanges({ userIds: existingUserIds }, { userIds: normalizedUserIds }) }
}); });
} }
res.redirect('/settings/roles/' + roleId + '/edit?message=' + encodeURIComponent('Users updated.')); res.redirect('/settings/roles/' + roleId + '/edit?message=' + encodeURIComponent('Users updated.'));
+19 -3
View File
@@ -1,6 +1,7 @@
// Admin user route registration and user-role management. // Admin user route registration and user-role management.
const { fetchAppSettings } = require('../../../data/app-settings'); const { fetchAppSettings } = require('#src/data/app-settings');
const { buildAuditChanges } = require('#src/data/audit-log');
module.exports = function registerUsersRoutes(app, deps) { module.exports = function registerUsersRoutes(app, deps) {
const pool = deps.pool; const pool = deps.pool;
@@ -319,6 +320,8 @@
return res.redirect('/settings/users/' + userId + '/edit?message=' + encodeURIComponent(roleCheck.message)); return res.redirect('/settings/users/' + userId + '/edit?message=' + encodeURIComponent(roleCheck.message));
} }
const existingUser = await rbacData.fetchUserWithRoles(pool, userId);
const changes = buildAuditChanges({ roleIds: existingUser ? existingUser.roleIds : [] }, { roleIds: roleCheck.roleIds });
await rbacData.syncUserRoles(pool, userId, roleCheck.roleIds); await rbacData.syncUserRoles(pool, userId, roleCheck.roleIds);
if (typeof recordRequestAuditEvent === 'function') { if (typeof recordRequestAuditEvent === 'function') {
await recordRequestAuditEvent(pool, req, { await recordRequestAuditEvent(pool, req, {
@@ -328,7 +331,7 @@
targetType: 'user', targetType: 'user',
targetId: userId, targetId: userId,
targetLabel: String(userId), targetLabel: String(userId),
details: { roleIds: roleCheck.roleIds } details: { changes: changes }
}); });
} }
res.redirect('/settings/users/' + userId + '/edit?message=' + encodeURIComponent('Roles updated.')); res.redirect('/settings/users/' + userId + '/edit?message=' + encodeURIComponent('Roles updated.'));
@@ -404,6 +407,19 @@
return renderValidationError('That username already exists.'); return renderValidationError('That username already exists.');
} }
const changes = buildAuditChanges({
name: user.name,
username: user.username,
roleIds: user.roleIds,
accountLocked: Boolean(user.account_locked),
passwordReset: false
}, {
name: name,
username: username,
roleIds: roleCheck.roleIds,
accountLocked: accountLocked,
passwordReset: shouldUpdatePassword
});
await connection.beginTransaction(); await connection.beginTransaction();
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]); 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) { if (!result.affectedRows) {
@@ -432,7 +448,7 @@
targetType: 'user', targetType: 'user',
targetId: userId, targetId: userId,
targetLabel: username, targetLabel: username,
details: { roleIds: roleCheck.roleIds, passwordReset: shouldUpdatePassword, accountLocked: accountLocked } details: { changes: changes }
}); });
if (Boolean(user.account_locked) !== accountLocked) { if (Boolean(user.account_locked) !== accountLocked) {
await recordRequestAuditEvent(pool, req, { await recordRequestAuditEvent(pool, req, {
+1 -1
View File
@@ -1,7 +1,7 @@
// Authentication route registration for the web app. // Authentication route registration for the web app.
const { normalizeReturnToPath, getRequestOrigin } = require('../../lib/auth/session'); const { normalizeReturnToPath, getRequestOrigin } = require('../../lib/auth/session');
const { fetchAppSettings } = require('../../../data/app-settings'); const { fetchAppSettings } = require('#src/data/app-settings');
module.exports = function registerAuthRoutes(app, deps) { module.exports = function registerAuthRoutes(app, deps) {
const pool = deps.pool; const pool = deps.pool;
@@ -5,7 +5,7 @@ const renderApiSourcesPage = require('./list');
const renderApiSourceAddPage = require('./add'); const renderApiSourceAddPage = require('./add');
const renderApiSourceEditPage = require('./edit'); const renderApiSourceEditPage = require('./edit');
const { buildDuplicateApiSourceName, buildDuplicateApiSource } = require('./duplicate'); const { buildDuplicateApiSourceName, buildDuplicateApiSource } = require('./duplicate');
const { fetchAppSettings } = require('../../../../data/app-settings'); const { fetchAppSettings } = require('#src/data/app-settings');
function toIsoTimestamp(value) { function toIsoTimestamp(value) {
if (!value) { if (!value) {
@@ -5,7 +5,7 @@ const renderRssFeedsPage = require('./list');
const renderRssFeedAddPage = require('./add'); const renderRssFeedAddPage = require('./add');
const renderRssFeedEditPage = require('./edit'); const renderRssFeedEditPage = require('./edit');
const { buildDuplicateRssFeedName, buildDuplicateRssFeed } = require('./duplicate'); const { buildDuplicateRssFeedName, buildDuplicateRssFeed } = require('./duplicate');
const { fetchAppSettings } = require('../../../../data/app-settings'); const { fetchAppSettings } = require('#src/data/app-settings');
async function getDataSourceUsageMaps(pool, common) { async function getDataSourceUsageMaps(pool, common) {
const [slides] = await pool.query('SELECT content_json FROM c_slides WHERE content_json IS NOT NULL'); const [slides] = await pool.query('SELECT content_json FROM c_slides WHERE content_json IS NOT NULL');
@@ -1,6 +1,6 @@
// Shared timetable group form view-model builder. // Shared timetable group form view-model builder.
const { normalizeTimeZone } = require('../../../../data/timetables'); const { normalizeTimeZone } = require('#src/data/timetables');
const COMMON_TIME_ZONES = [ const COMMON_TIME_ZONES = [
'UTC', 'UTC',
@@ -1,7 +1,7 @@
// Timetable group list page renderer. // Timetable group list page renderer.
const { renderView } = require('../../../view'); const { renderView } = require('../../../view');
const { normalizeTimeZone } = require('../../../../data/timetables'); const { normalizeTimeZone } = require('#src/data/timetables');
function formatDateInTimeZone(value, timeZone) { function formatDateInTimeZone(value, timeZone) {
if (!value) { if (!value) {
+106 -3
View File
@@ -6,6 +6,10 @@ const { createSearchMatcher, getSearchQuery, getSortDirectionQuery, getSortQuery
const PAGE_SIZE = 25; const PAGE_SIZE = 25;
const SORTED_AUDIT_CATEGORY_KEYS = AUDIT_CATEGORY_KEYS.slice().sort(function (left, right) {
return String(left).localeCompare(String(right));
});
function requireAuditLogAccess(setAuthMessageCookie) { function requireAuditLogAccess(setAuthMessageCookie) {
return function (req, res, next) { return function (req, res, next) {
if (!req.currentUser) { if (!req.currentUser) {
@@ -44,11 +48,109 @@ function csvCell(value) {
return '"' + text.replace(/"/g, '""').replace(/\r?\n/g, ' ') + '"'; return '"' + text.replace(/"/g, '""').replace(/\r?\n/g, ' ') + '"';
} }
function isRecord(value) {
return value !== null && typeof value === 'object' && !Array.isArray(value);
}
function formatAuditValue(value) {
if (typeof value === 'string') {
return value;
}
if (value === undefined) {
return 'undefined';
}
if (value === null || typeof value !== 'object') {
return String(value);
}
return JSON.stringify(value);
}
function isPrimitiveArray(value) {
return Array.isArray(value) && value.every(function (item) {
return item === null || typeof item !== 'object';
});
}
function isMissingAuditValue(value) {
return value === null || value === '';
}
function collectAuditChangeRows(previousValue, nextValue, path, rows) {
if (isMissingAuditValue(previousValue) && isMissingAuditValue(nextValue)) {
return;
}
if (isMissingAuditValue(previousValue)) {
rows.push({ path: path + ' added', direction: 'added', from: '', to: formatAuditValue(nextValue) });
return;
}
if (isMissingAuditValue(nextValue)) {
rows.push({ path: path + ' removed', direction: 'removed', from: formatAuditValue(previousValue), to: '' });
return;
}
if (isPrimitiveArray(previousValue) && isPrimitiveArray(nextValue)) {
const previousItems = new Set(previousValue.map(function (item) { return JSON.stringify(item); }));
const nextItems = new Set(nextValue.map(function (item) { return JSON.stringify(item); }));
const removedItems = previousValue.filter(function (item) { return !nextItems.has(JSON.stringify(item)); });
const addedItems = nextValue.filter(function (item) { return !previousItems.has(JSON.stringify(item)); });
if (removedItems.length) {
rows.push({ path: path + ' removed', direction: 'removed', from: formatAuditValue(removedItems), to: '' });
}
if (addedItems.length) {
rows.push({ path: path + ' added', direction: 'added', from: '', to: formatAuditValue(addedItems) });
}
return;
}
if (JSON.stringify(previousValue) === JSON.stringify(nextValue)) {
return;
}
if (isRecord(previousValue) && isRecord(nextValue)) {
const keys = new Set(Object.keys(previousValue).concat(Object.keys(nextValue)));
keys.forEach(function (key) {
collectAuditChangeRows(previousValue[key], nextValue[key], path ? path + '.' + key : key, rows);
});
return;
}
if (Array.isArray(previousValue) && Array.isArray(nextValue) && previousValue.length === nextValue.length && previousValue.some(isRecord)) {
for (let index = 0; index < previousValue.length; index += 1) {
collectAuditChangeRows(previousValue[index], nextValue[index], path + '[' + index + ']', rows);
}
return;
}
rows.push({
path: path,
direction: 'changed',
from: formatAuditValue(previousValue),
to: formatAuditValue(nextValue)
});
}
function buildAuditDetailView(details) {
if (!isRecord(details) || !isRecord(details.changes)) {
return { hasChanges: false, changeRows: [] };
}
const rows = [];
Object.keys(details.changes).forEach(function (key) {
const change = details.changes[key];
if (isRecord(change) && Object.prototype.hasOwnProperty.call(change, 'from') && Object.prototype.hasOwnProperty.call(change, 'to')) {
collectAuditChangeRows(change.from, change.to, key, rows);
}
});
return { hasChanges: rows.length > 0, changeRows: rows };
}
function mapAuditRow(row, formatDashboardDate) { function mapAuditRow(row, formatDashboardDate) {
let details = ''; let details = '';
let parsedDetails = null;
if (row.details_json) { if (row.details_json) {
try { try {
details = JSON.stringify(typeof row.details_json === 'string' ? JSON.parse(row.details_json) : row.details_json); parsedDetails = typeof row.details_json === 'string' ? JSON.parse(row.details_json) : row.details_json;
details = JSON.stringify(parsedDetails);
} catch (_error) { } catch (_error) {
details = String(row.details_json); details = String(row.details_json);
} }
@@ -60,7 +162,8 @@ function mapAuditRow(row, formatDashboardDate) {
actorLabel: row.actor_name || row.actor_username || 'System', actorLabel: row.actor_name || row.actor_username || 'System',
eventLabel: String(row.event_type || '').replace(/[._-]+/g, ' '), eventLabel: String(row.event_type || '').replace(/[._-]+/g, ' '),
targetLabelDisplay: row.target_label || (row.target_type && row.target_id ? row.target_type + ' #' + row.target_id : ''), targetLabelDisplay: row.target_label || (row.target_type && row.target_id ? row.target_type + ' #' + row.target_id : ''),
detailsDisplay: details detailsDisplay: details,
auditDetailView: buildAuditDetailView(parsedDetails)
}); });
} }
@@ -136,7 +239,7 @@ module.exports = function registerAuditLogRoutes(app, deps) {
active: 'audit-log', active: 'audit-log',
currentUser: req.currentUser, currentUser: req.currentUser,
events: events, events: events,
categories: AUDIT_CATEGORY_KEYS, categories: SORTED_AUDIT_CATEGORY_KEYS,
eventTypes: eventTypes, eventTypes: eventTypes,
selectedCategory: category, selectedCategory: category,
selectedEventType: eventType, selectedEventType: eventType,
+1 -1
View File
@@ -8,7 +8,7 @@ module.exports = function registerPlaylistsRoutes(app, deps) {
const { buildPagination } = require('../../../lib/pagination'); const { buildPagination } = require('../../../lib/pagination');
const requirePermission = deps.requirePermission; const requirePermission = deps.requirePermission;
const { buildDuplicatePlaylistName, buildDuplicatePlaylist } = require('./duplicate'); const { buildDuplicatePlaylistName, buildDuplicatePlaylist } = require('./duplicate');
const { fetchAppSettings } = require('../../../../data/app-settings'); const { fetchAppSettings } = require('#src/data/app-settings');
const LIST_PAGE_SIZE = 25; const LIST_PAGE_SIZE = 25;
+16 -1
View File
@@ -44,7 +44,22 @@
<td>{{actorLabel}}</td> <td>{{actorLabel}}</td>
<td>{{targetLabelDisplay}}</td> <td>{{targetLabelDisplay}}</td>
<td><div>{{ip_address}}</div><div class="text-muted small text-break">{{user_agent}}</div></td> <td><div>{{ip_address}}</div><div class="text-muted small text-break">{{user_agent}}</div></td>
<td class="text-break"><small>{{detailsDisplay}}</small></td> <td class="text-break">
{{#if auditDetailView.hasChanges}}
<div class="audit-change-list">
{{#each auditDetailView.changeRows}}
<div class="audit-change-row">
<code class="audit-change-path">{{path}}</code>
<span class="audit-change-from"><span class="visually-hidden">From: </span>{{from}}</span>
{{#if (eq direction "changed")}}<span class="audit-change-arrow" aria-hidden="true">&rarr;</span>{{else}}<span></span>{{/if}}
<span class="audit-change-to"><span class="visually-hidden">To: </span>{{to}}</span>
</div>
{{/each}}
</div>
{{else}}
<small>{{detailsDisplay}}</small>
{{/if}}
</td>
</tr> </tr>
{{/each}} {{/each}}
{{else}} {{else}}
@@ -1,6 +1,8 @@
const test = require('node:test'); const test = require('node:test');
const assert = require('node:assert/strict'); const assert = require('node:assert/strict');
require('../src/common');
const registerAccountRoutes = require('../src/web/routes/admin/account'); const registerAccountRoutes = require('../src/web/routes/admin/account');
const { validatePasswordStrength } = require('../src/auth'); const { validatePasswordStrength } = require('../src/auth');
@@ -1,6 +1,8 @@
const test = require('node:test'); const test = require('node:test');
const assert = require('node:assert/strict'); const assert = require('node:assert/strict');
require('../src/common');
const registerUsersRoutes = require('../src/web/routes/admin/users'); const registerUsersRoutes = require('../src/web/routes/admin/users');
test('user create route allows users without roles', async () => { test('user create route allows users without roles', async () => {
@@ -1,6 +1,8 @@
const test = require('node:test'); const test = require('node:test');
const assert = require('node:assert/strict'); const assert = require('node:assert/strict');
require('../src/common');
const registerUsersRoutes = require('../src/web/routes/admin/users'); const registerUsersRoutes = require('../src/web/routes/admin/users');
test('user edit save and new goes to the blank create page', async () => { test('user edit save and new goes to the blank create page', async () => {
+2
View File
@@ -1,6 +1,8 @@
const test = require('node:test'); const test = require('node:test');
const assert = require('node:assert/strict'); const assert = require('node:assert/strict');
require('../src/common');
const registerUsersRoutes = require('../src/web/routes/admin/users'); const registerUsersRoutes = require('../src/web/routes/admin/users');
const { validatePasswordStrength } = require('../src/auth'); const { validatePasswordStrength } = require('../src/auth');
+33 -1
View File
@@ -24,7 +24,26 @@ test('audit log uses its own read permission and shared pagination partial', asy
category: 'authentication', category: 'authentication',
event_type: 'login.success', event_type: 'login.success',
target_label: 'newer', target_label: 'newer',
details_json: null details_json: JSON.stringify({
changes: {
content: {
from: { title: 'Same', body: { color: 'red', keep: 'same' } },
to: { title: 'Same', body: { color: 'blue', keep: 'same' } }
},
permissions: {
from: ['dashboard.read', 'screens.read'],
to: ['screens.read', 'playlists.read']
},
backgroundColor: {
from: null,
to: '#111111'
},
logoPath: {
from: '',
to: '/media/logo.png'
}
}
})
}, },
{ {
id: 2, id: 2,
@@ -60,10 +79,23 @@ test('audit log uses its own read permission and shared pagination partial', asy
throw error; throw error;
}); });
assert.match(response.body, /Audit log/); assert.match(response.body, /Audit log/);
assert.ok(response.body.indexOf('>All categories</option>') < response.body.indexOf('>announcements</option>'));
assert.ok(response.body.indexOf('>announcements</option>') < response.body.indexOf('>api-sources</option>'));
assert.ok(response.body.indexOf('>api-sources</option>') < response.body.indexOf('>canvas-sizes</option>'));
assert.match(response.body, /table-pagination/); assert.match(response.body, /table-pagination/);
assert.match(response.body, /data-local-datetime/); assert.match(response.body, /data-local-datetime/);
assert.match(response.body, /audit-event-type-options/); assert.match(response.body, /audit-event-type-options/);
assert.match(response.body, /login\.success/); assert.match(response.body, /login\.success/);
assert.match(response.body, /content\.body\.color/);
assert.match(response.body, />red</);
assert.match(response.body, />blue</);
assert.doesNotMatch(response.body, /content\.body\.keep/);
assert.match(response.body, /permissions removed/);
assert.match(response.body, /permissions added/);
assert.match(response.body, /dashboard\.read/);
assert.match(response.body, /playlists\.read/);
assert.match(response.body, /backgroundColor added/);
assert.match(response.body, /logoPath added/);
assert.ok(response.body.indexOf('login.success') < response.body.indexOf('login.failed')); assert.ok(response.body.indexOf('login.success') < response.body.indexOf('login.failed'));
assert.match(response.body, />Export</); assert.match(response.body, />Export</);
assert.ok(handlers['/settings/audit-log/export']); assert.ok(handlers['/settings/audit-log/export']);
+16 -1
View File
@@ -3,7 +3,7 @@ const assert = require('node:assert/strict');
require('../src/common'); require('../src/common');
const { recordRequestAuditEvent } = require('../src/data/audit-log'); const { buildAuditChanges, recordRequestAuditEvent } = require('../src/data/audit-log');
function createPool(settings) { function createPool(settings) {
const inserts = []; const inserts = [];
@@ -44,4 +44,19 @@ test('audit writer omits request metadata when disabled', async () => {
assert.equal(pool.inserts.length, 1); assert.equal(pool.inserts.length, 1);
assert.equal(pool.inserts[0][6], null); assert.equal(pool.inserts[0][6], null);
assert.equal(pool.inserts[0][7], null); assert.equal(pool.inserts[0][7], null);
});
test('audit changes include only fields with different from and to values', () => {
assert.deepEqual(buildAuditChanges({
name: 'Old name',
playlistId: 4,
roleIds: [1, 2]
}, {
name: 'New name',
playlistId: 4,
roleIds: [2, 3]
}), {
name: { from: 'Old name', to: 'New name' },
roleIds: { from: [1, 2], to: [2, 3] }
});
}); });
+2
View File
@@ -1,6 +1,8 @@
const test = require('node:test'); const test = require('node:test');
const assert = require('node:assert/strict'); const assert = require('node:assert/strict');
require('../src/common');
const registerPlaylistRoutes = require('../src/web/routes/admin/playlists'); const registerPlaylistRoutes = require('../src/web/routes/admin/playlists');
function createAppAndHandlers() { function createAppAndHandlers() {
+2
View File
@@ -3,6 +3,8 @@ const assert = require('node:assert/strict');
const fs = require('node:fs'); const fs = require('node:fs');
const path = require('node:path'); const path = require('node:path');
require('../src/common');
const { const {
buildDuplicateTimetableGroupName, buildDuplicateTimetableGroupName,
buildDuplicateTimetableGroup, buildDuplicateTimetableGroup,
+2
View File
@@ -1,6 +1,8 @@
const test = require('node:test'); const test = require('node:test');
const assert = require('node:assert/strict'); const assert = require('node:assert/strict');
require('../src/common');
const registerUsersRoutes = require('../src/web/routes/admin/users'); const registerUsersRoutes = require('../src/web/routes/admin/users');
test('user duplicate route pre-fills the add form', async () => { test('user duplicate route pre-fills the add form', async () => {
+2
View File
@@ -1,6 +1,8 @@
const test = require('node:test'); const test = require('node:test');
const assert = require('node:assert/strict'); const assert = require('node:assert/strict');
require('../src/common');
const registerManageRoutes = require('../src/web/routes/admin/manage'); const registerManageRoutes = require('../src/web/routes/admin/manage');
test('screen update keeps the existing slug on edit', async () => { test('screen update keeps the existing slug on edit', async () => {