Release 2.8.2
This commit is contained in:
@@ -255,6 +255,39 @@
|
||||
.template-preview-card .btn-group {
|
||||
display: grid;
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// 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) {
|
||||
const pool = deps.pool;
|
||||
|
||||
@@ -4,6 +4,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 { buildAuditChanges } = require('#src/data/audit-log');
|
||||
const { PERMISSION_DENIED_MESSAGE } = require('#src/rbac');
|
||||
|
||||
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 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() {
|
||||
const data = await common.fetchTemplatesData(pool);
|
||||
const rssData = await common.fetchRssFeedsData(pool);
|
||||
@@ -497,6 +518,15 @@ module.exports = function registerContentRoutes(app, deps) {
|
||||
}
|
||||
const nextUploadRefs = collectUploadReferencesFromPayload(payload);
|
||||
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(
|
||||
'UPDATE c_slides SET title = ?, template_id = ?, content_json = ?, modified_by = ? WHERE id = ?',
|
||||
[payload.title, payload.templateId, payload.contentJson, actorId, slide.id]
|
||||
@@ -512,7 +542,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 });
|
||||
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) {
|
||||
console.warn('Unable to queue slide thumbnail refresh:', error);
|
||||
});
|
||||
@@ -655,6 +685,19 @@ module.exports = function registerContentRoutes(app, deps) {
|
||||
const nextUploadRefs = collectUploadReferencesFromPayload(payload);
|
||||
const affectedScreens = await fetchScreensByTemplateId(pool, template.id);
|
||||
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(
|
||||
'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]
|
||||
@@ -681,7 +724,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 });
|
||||
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', {
|
||||
closeUrl: '/templates',
|
||||
newUrl: '/templates/new',
|
||||
@@ -813,8 +856,17 @@ module.exports = function registerContentRoutes(app, deps) {
|
||||
if (await canvasSizeExists(payload.width, payload.height, canvasSize.id)) {
|
||||
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]);
|
||||
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', {
|
||||
closeUrl: '/canvas-sizes',
|
||||
newUrl: '/canvas-sizes/new',
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
// Admin manage routes for screens and commands.
|
||||
|
||||
const { buildAuditChanges } = require('#src/data/audit-log');
|
||||
|
||||
module.exports = function registerManageRoutes(app, deps) {
|
||||
const pool = deps.pool;
|
||||
const common = deps.common;
|
||||
@@ -248,6 +250,15 @@ module.exports = function registerManageRoutes(app, deps) {
|
||||
const previousPlaylistId = screen.playlist_id;
|
||||
const slug = 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]);
|
||||
if (previousPlaylistId !== playlistId && previousSlug) {
|
||||
await notifyPlayerScreens([previousSlug], 'refresh');
|
||||
@@ -264,7 +275,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 } });
|
||||
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, {
|
||||
closeUrl: '/screens',
|
||||
newUrl: '/screens/new',
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// 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) {
|
||||
const pool = deps.pool;
|
||||
@@ -356,7 +357,20 @@ 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 });
|
||||
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', {
|
||||
closeUrl: '/playlists',
|
||||
newUrl: '/playlists/new',
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// Admin RBAC route registration and permission management.
|
||||
const { DEFAULT_ROLE } = require('#src/rbac');
|
||||
const { buildAuditChanges } = require('#src/data/audit-log');
|
||||
|
||||
module.exports = function registerRbacRoutes(app, deps) {
|
||||
const pool = deps.pool;
|
||||
@@ -413,10 +414,14 @@
|
||||
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 = [];
|
||||
if (shouldSyncUsers) {
|
||||
availableUsers = await rbacData.fetchUsersWithRoles(pool);
|
||||
const existingRoleUserIds = await rbacData.fetchRoleUserIds(pool, roleId);
|
||||
existingRoleUserIds = await rbacData.fetchRoleUserIds(pool, roleId);
|
||||
const visibleUserIdSet = new Set(visibleUserIds);
|
||||
normalizedUserIds = existingRoleUserIds.filter(function (userId) {
|
||||
return !visibleUserIdSet.has(userId);
|
||||
@@ -452,6 +457,17 @@
|
||||
connection.release();
|
||||
}
|
||||
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, {
|
||||
category: 'roles',
|
||||
eventType: 'role.updated',
|
||||
@@ -459,7 +475,7 @@
|
||||
targetType: 'role',
|
||||
targetId: roleId,
|
||||
targetLabel: name,
|
||||
details: { permissionsChanged: shouldSyncPermissions, usersChanged: shouldSyncUsers }
|
||||
details: { changes: changes }
|
||||
});
|
||||
}
|
||||
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.'));
|
||||
}
|
||||
const existingPermissionKeys = await rbacData.fetchRolePermissionKeys(pool, roleId);
|
||||
|
||||
const connection = await pool.getConnection();
|
||||
try {
|
||||
@@ -514,7 +531,7 @@
|
||||
targetType: 'role',
|
||||
targetId: roleId,
|
||||
targetLabel: role.name,
|
||||
details: { permissionKeys: normalizedPermissionKeys }
|
||||
details: { changes: buildAuditChanges({ permissionKeys: existingPermissionKeys }, { permissionKeys: normalizedPermissionKeys }) }
|
||||
});
|
||||
}
|
||||
res.redirect('/settings/roles/' + roleId + '/edit?message=' + encodeURIComponent('Permissions updated.'));
|
||||
@@ -541,6 +558,7 @@
|
||||
? [].concat(req.body.user_ids)
|
||||
: [];
|
||||
const normalizedUserIds = normalizeSelectedIds(selectedUserIds);
|
||||
const existingUserIds = await rbacData.fetchRoleUserIds(pool, roleId);
|
||||
const availableUsers = await rbacData.fetchUsersWithRoles(pool);
|
||||
const validUserIds = new Set(availableUsers.map(function (user) {
|
||||
return Number(user.id);
|
||||
@@ -561,7 +579,7 @@
|
||||
targetType: 'role',
|
||||
targetId: roleId,
|
||||
targetLabel: role.name,
|
||||
details: { userIds: normalizedUserIds }
|
||||
details: { changes: buildAuditChanges({ userIds: existingUserIds }, { userIds: normalizedUserIds }) }
|
||||
});
|
||||
}
|
||||
res.redirect('/settings/roles/' + roleId + '/edit?message=' + encodeURIComponent('Users updated.'));
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// 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) {
|
||||
const pool = deps.pool;
|
||||
@@ -319,6 +320,8 @@
|
||||
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);
|
||||
if (typeof recordRequestAuditEvent === 'function') {
|
||||
await recordRequestAuditEvent(pool, req, {
|
||||
@@ -328,7 +331,7 @@
|
||||
targetType: 'user',
|
||||
targetId: userId,
|
||||
targetLabel: String(userId),
|
||||
details: { roleIds: roleCheck.roleIds }
|
||||
details: { changes: changes }
|
||||
});
|
||||
}
|
||||
res.redirect('/settings/users/' + userId + '/edit?message=' + encodeURIComponent('Roles updated.'));
|
||||
@@ -404,6 +407,19 @@
|
||||
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();
|
||||
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) {
|
||||
@@ -432,7 +448,7 @@
|
||||
targetType: 'user',
|
||||
targetId: userId,
|
||||
targetLabel: username,
|
||||
details: { roleIds: roleCheck.roleIds, passwordReset: shouldUpdatePassword, accountLocked: accountLocked }
|
||||
details: { changes: changes }
|
||||
});
|
||||
if (Boolean(user.account_locked) !== accountLocked) {
|
||||
await recordRequestAuditEvent(pool, req, {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Authentication route registration for the web app.
|
||||
|
||||
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) {
|
||||
const pool = deps.pool;
|
||||
|
||||
@@ -5,7 +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');
|
||||
const { fetchAppSettings } = require('#src/data/app-settings');
|
||||
|
||||
function toIsoTimestamp(value) {
|
||||
if (!value) {
|
||||
|
||||
@@ -5,7 +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');
|
||||
const { fetchAppSettings } = require('#src/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');
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Shared timetable group form view-model builder.
|
||||
|
||||
const { normalizeTimeZone } = require('../../../../data/timetables');
|
||||
const { normalizeTimeZone } = require('#src/data/timetables');
|
||||
|
||||
const COMMON_TIME_ZONES = [
|
||||
'UTC',
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Timetable group list page renderer.
|
||||
|
||||
const { renderView } = require('../../../view');
|
||||
const { normalizeTimeZone } = require('../../../../data/timetables');
|
||||
const { normalizeTimeZone } = require('#src/data/timetables');
|
||||
|
||||
function formatDateInTimeZone(value, timeZone) {
|
||||
if (!value) {
|
||||
|
||||
@@ -6,6 +6,10 @@ const { createSearchMatcher, getSearchQuery, getSortDirectionQuery, getSortQuery
|
||||
|
||||
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) {
|
||||
return function (req, res, next) {
|
||||
if (!req.currentUser) {
|
||||
@@ -44,11 +48,109 @@ function csvCell(value) {
|
||||
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) {
|
||||
let details = '';
|
||||
let parsedDetails = null;
|
||||
if (row.details_json) {
|
||||
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) {
|
||||
details = String(row.details_json);
|
||||
}
|
||||
@@ -60,7 +162,8 @@ function mapAuditRow(row, formatDashboardDate) {
|
||||
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
|
||||
detailsDisplay: details,
|
||||
auditDetailView: buildAuditDetailView(parsedDetails)
|
||||
});
|
||||
}
|
||||
|
||||
@@ -136,7 +239,7 @@ module.exports = function registerAuditLogRoutes(app, deps) {
|
||||
active: 'audit-log',
|
||||
currentUser: req.currentUser,
|
||||
events: events,
|
||||
categories: AUDIT_CATEGORY_KEYS,
|
||||
categories: SORTED_AUDIT_CATEGORY_KEYS,
|
||||
eventTypes: eventTypes,
|
||||
selectedCategory: category,
|
||||
selectedEventType: eventType,
|
||||
|
||||
@@ -8,7 +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 { fetchAppSettings } = require('#src/data/app-settings');
|
||||
|
||||
const LIST_PAGE_SIZE = 25;
|
||||
|
||||
|
||||
@@ -44,7 +44,22 @@
|
||||
<td>{{actorLabel}}</td>
|
||||
<td>{{targetLabelDisplay}}</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">→</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>
|
||||
{{/each}}
|
||||
{{else}}
|
||||
|
||||
Reference in New Issue
Block a user