Release v2.17.5
This commit is contained in:
1 parent
46636939de
commit
86b9609ea7
11 files changed
+285
-56
No files matched your search
@@ -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.17.5 - 2026-10-23
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Added query-aligned database indexes for playlist loading, template and slide lookups, session cleanup, background task claiming, and data-source list ordering.
|
||||||
|
- Audit log filtering, sorting, and pagination run in SQL; CSV exports still include all matching events.
|
||||||
|
|
||||||
## 2.17.4 - 2026-10-23
|
## 2.17.4 - 2026-10-23
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "pulse-signage-player",
|
"name": "pulse-signage-player",
|
||||||
"version": "2.17.4",
|
"version": "2.17.5",
|
||||||
"private": false,
|
"private": false,
|
||||||
"description": "Pulse Signage player application bundle",
|
"description": "Pulse Signage player application bundle",
|
||||||
"engines": {
|
"engines": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "pulse-signage-web",
|
"name": "pulse-signage-web",
|
||||||
"version": "2.17.4",
|
"version": "2.17.5",
|
||||||
"private": false,
|
"private": false,
|
||||||
"description": "Pulse Signage web and bridge application bundle",
|
"description": "Pulse Signage web and bridge application bundle",
|
||||||
"engines": {
|
"engines": {
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "pulse-signage",
|
"name": "pulse-signage",
|
||||||
"version": "2.17.4",
|
"version": "2.17.5",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "pulse-signage",
|
"name": "pulse-signage",
|
||||||
"version": "2.17.4",
|
"version": "2.17.5",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"animate.css": "^4.1.1",
|
"animate.css": "^4.1.1",
|
||||||
"bootstrap-icons": "1.13.1",
|
"bootstrap-icons": "1.13.1",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "pulse-signage",
|
"name": "pulse-signage",
|
||||||
"version": "2.17.4",
|
"version": "2.17.5",
|
||||||
"private": false,
|
"private": false,
|
||||||
"description": "Pulse Signage application with MySQL and media storage",
|
"description": "Pulse Signage application with MySQL and media storage",
|
||||||
"engines": {
|
"engines": {
|
||||||
|
|||||||
+15
-8
@@ -64,7 +64,8 @@ async function ensureSchema(pool, options) {
|
|||||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
created_by INT NULL,
|
created_by INT NULL,
|
||||||
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
modified_by INT NULL
|
modified_by INT NULL,
|
||||||
|
INDEX idx_templates_canvas_size (canvas_size_id)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||||
`);
|
`);
|
||||||
|
|
||||||
@@ -86,7 +87,8 @@ async function ensureSchema(pool, options) {
|
|||||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
created_by INT NULL,
|
created_by INT NULL,
|
||||||
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
modified_by INT NULL
|
modified_by INT NULL,
|
||||||
|
INDEX idx_template_regions_template_z (template_id, z_index)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||||
`);
|
`);
|
||||||
|
|
||||||
@@ -100,7 +102,8 @@ async function ensureSchema(pool, options) {
|
|||||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
created_by INT NULL,
|
created_by INT NULL,
|
||||||
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
modified_by INT NULL
|
modified_by INT NULL,
|
||||||
|
INDEX idx_slides_template (template_id)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||||
`);
|
`);
|
||||||
|
|
||||||
@@ -143,7 +146,8 @@ async function ensureSchema(pool, options) {
|
|||||||
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
modified_by INT NULL,
|
modified_by INT NULL,
|
||||||
CONSTRAINT fk_playlist_slides_playlist FOREIGN KEY (playlist_id) REFERENCES c_playlists(id) ON DELETE CASCADE,
|
CONSTRAINT fk_playlist_slides_playlist FOREIGN KEY (playlist_id) REFERENCES c_playlists(id) ON DELETE CASCADE,
|
||||||
CONSTRAINT fk_playlist_slides_slide FOREIGN KEY (slide_id) REFERENCES c_slides(id) ON DELETE CASCADE
|
CONSTRAINT fk_playlist_slides_slide FOREIGN KEY (slide_id) REFERENCES c_slides(id) ON DELETE CASCADE,
|
||||||
|
INDEX idx_playlist_slides_playlist_position (playlist_id, position)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||||
`);
|
`);
|
||||||
|
|
||||||
@@ -240,7 +244,8 @@ async function ensureSchema(pool, options) {
|
|||||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
created_by INT NULL,
|
created_by INT NULL,
|
||||||
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
modified_by INT NULL
|
modified_by INT NULL,
|
||||||
|
INDEX idx_rss_feeds_modified_at (modified_at)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||||
`);
|
`);
|
||||||
|
|
||||||
@@ -281,7 +286,8 @@ async function ensureSchema(pool, options) {
|
|||||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
created_by INT NULL,
|
created_by INT NULL,
|
||||||
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
modified_by INT NULL
|
modified_by INT NULL,
|
||||||
|
INDEX idx_api_source_groups_modified_at (modified_at)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||||
`);
|
`);
|
||||||
await pool.query(`
|
await pool.query(`
|
||||||
@@ -520,7 +526,8 @@ async function ensureSchema(pool, options) {
|
|||||||
created_by INT NULL,
|
created_by INT NULL,
|
||||||
last_used_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
last_used_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
modified_by INT NULL,
|
modified_by INT NULL,
|
||||||
CONSTRAINT fk_auth_sessions_user FOREIGN KEY (user_id) REFERENCES a_users(id) ON DELETE CASCADE
|
CONSTRAINT fk_auth_sessions_user FOREIGN KEY (user_id) REFERENCES a_users(id) ON DELETE CASCADE,
|
||||||
|
INDEX idx_sessions_expires_at (expires_at)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||||
`);
|
`);
|
||||||
|
|
||||||
@@ -551,7 +558,7 @@ async function ensureSchema(pool, options) {
|
|||||||
started_at TIMESTAMP NULL,
|
started_at TIMESTAMP NULL,
|
||||||
finished_at TIMESTAMP NULL,
|
finished_at TIMESTAMP NULL,
|
||||||
error_message MEDIUMTEXT NULL,
|
error_message MEDIUMTEXT NULL,
|
||||||
INDEX idx_background_tasks_status (status),
|
INDEX idx_background_tasks_status_created_at (status, created_at),
|
||||||
INDEX idx_background_tasks_key (task_key),
|
INDEX idx_background_tasks_key (task_key),
|
||||||
INDEX idx_background_tasks_type (task_type)
|
INDEX idx_background_tasks_type (task_type)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||||
|
|||||||
@@ -746,6 +746,21 @@ const VERSIONED_MIGRATIONS = [
|
|||||||
);
|
);
|
||||||
await pool.query('DELETE FROM a_permissions WHERE permission_key = ?', ['dashboard.allow']);
|
await pool.query('DELETE FROM a_permissions WHERE permission_key = ?', ['dashboard.allow']);
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: '2.17.5',
|
||||||
|
label: 'v2.17.5 query indexes',
|
||||||
|
run: async function (pool) {
|
||||||
|
await ensureIndex(pool, 'c_playlist_slides', 'idx_playlist_slides_playlist_position', 'playlist_id, position');
|
||||||
|
await ensureIndex(pool, 'c_template_regions', 'idx_template_regions_template_z', 'template_id, z_index');
|
||||||
|
await ensureIndex(pool, 'c_slides', 'idx_slides_template', 'template_id');
|
||||||
|
await ensureIndex(pool, 'c_templates', 'idx_templates_canvas_size', 'canvas_size_id');
|
||||||
|
await ensureIndex(pool, 'a_sessions', 'idx_sessions_expires_at', 'expires_at');
|
||||||
|
await ensureIndex(pool, 'o_background_tasks', 'idx_background_tasks_status_created_at', 'status, created_at');
|
||||||
|
await ensureIndex(pool, 'i_rss_feeds', 'idx_rss_feeds_modified_at', 'modified_at');
|
||||||
|
await ensureIndex(pool, 'i_api_source_groups', 'idx_api_source_groups_modified_at', 'modified_at');
|
||||||
|
await dropIndexIfExists(pool, 'o_background_tasks', 'idx_background_tasks_status');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -896,6 +911,38 @@ async function ensureForeignKey(pool, tableName, constraintName, columnName, ref
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function ensureIndex(pool, tableName, indexName, columns) {
|
||||||
|
const [rows] = await pool.query(
|
||||||
|
`SELECT COUNT(*) AS index_count
|
||||||
|
FROM information_schema.STATISTICS
|
||||||
|
WHERE TABLE_SCHEMA = DATABASE()
|
||||||
|
AND TABLE_NAME = ?
|
||||||
|
AND INDEX_NAME = ?`,
|
||||||
|
[tableName, indexName]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (Number(rows && rows[0] && rows[0].index_count) > 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await pool.query('ALTER TABLE ' + tableName + ' ADD INDEX ' + indexName + ' (' + columns + ')');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function dropIndexIfExists(pool, tableName, indexName) {
|
||||||
|
const [rows] = await pool.query(
|
||||||
|
`SELECT COUNT(*) AS index_count
|
||||||
|
FROM information_schema.STATISTICS
|
||||||
|
WHERE TABLE_SCHEMA = DATABASE()
|
||||||
|
AND TABLE_NAME = ?
|
||||||
|
AND INDEX_NAME = ?`,
|
||||||
|
[tableName, indexName]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (Number(rows && rows[0] && rows[0].index_count) > 0) {
|
||||||
|
await pool.query('ALTER TABLE ' + tableName + ' DROP INDEX ' + indexName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function dropForeignKeyIfExists(pool, tableName, columnName) {
|
async function dropForeignKeyIfExists(pool, tableName, columnName) {
|
||||||
const [rows] = await pool.query(
|
const [rows] = await pool.query(
|
||||||
`SELECT CONSTRAINT_NAME AS constraint_name
|
`SELECT CONSTRAINT_NAME AS constraint_name
|
||||||
|
|||||||
@@ -1,10 +1,29 @@
|
|||||||
const { renderView } = require('#src/web/view');
|
const { renderView } = require('#src/web/view');
|
||||||
const { hasPermission, PERMISSION_DENIED_MESSAGE } = require('#src/rbac');
|
const { hasPermission, PERMISSION_DENIED_MESSAGE } = require('#src/rbac');
|
||||||
const { AUDIT_CATEGORY_KEYS, formatUserAgentLabel } = require('#src/data/audit-log');
|
const { AUDIT_CATEGORY_KEYS, formatUserAgentLabel } = require('#src/data/audit-log');
|
||||||
|
const { buildSearchFilter } = require('#src/data/utils');
|
||||||
const { buildPagination } = require('#src/web/lib/pagination');
|
const { buildPagination } = require('#src/web/lib/pagination');
|
||||||
const { createSearchMatcher, getSearchQuery, getSortDirectionQuery, getSortQuery, parsePageNumber, sortRows } = require('#src/web/lib/list-query');
|
const { getSearchQuery, getSortDirectionQuery, getSortQuery, parsePageNumber } = require('#src/web/lib/list-query');
|
||||||
|
|
||||||
const PAGE_SIZE = 25;
|
const PAGE_SIZE = 25;
|
||||||
|
const AUDIT_FROM_SQL = `FROM o_audit_events e
|
||||||
|
LEFT JOIN a_users u ON u.id = e.actor_user_id`;
|
||||||
|
const AUDIT_TARGET_LABEL_SQL = "CASE WHEN COALESCE(e.target_label, '') <> '' THEN e.target_label WHEN e.target_type IS NOT NULL AND e.target_id IS NOT NULL THEN CONCAT(e.target_type, ' #', e.target_id) ELSE '' END";
|
||||||
|
const AUDIT_SEARCH_COLUMNS = [
|
||||||
|
'e.category',
|
||||||
|
'e.event_type',
|
||||||
|
"COALESCE(NULLIF(u.name, ''), u.username, 'System')",
|
||||||
|
AUDIT_TARGET_LABEL_SQL,
|
||||||
|
'e.ip_address',
|
||||||
|
'e.user_agent'
|
||||||
|
];
|
||||||
|
const AUDIT_SORT_COLUMNS = {
|
||||||
|
time: 'e.occurred_at',
|
||||||
|
event: "REPLACE(REPLACE(REPLACE(e.event_type, '.', ' '), '_', ' '), '-', ' ')",
|
||||||
|
actor: "COALESCE(NULLIF(u.name, ''), u.username, 'System')",
|
||||||
|
target: AUDIT_TARGET_LABEL_SQL,
|
||||||
|
category: 'e.category'
|
||||||
|
};
|
||||||
|
|
||||||
const SORTED_AUDIT_CATEGORY_KEYS = AUDIT_CATEGORY_KEYS.slice().sort(function (left, right) {
|
const SORTED_AUDIT_CATEGORY_KEYS = AUDIT_CATEGORY_KEYS.slice().sort(function (left, right) {
|
||||||
return String(left).localeCompare(String(right));
|
return String(left).localeCompare(String(right));
|
||||||
@@ -168,35 +187,78 @@ function mapAuditRow(row, formatDashboardDate) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function filterAuditRows(rows, req, formatDashboardDate) {
|
function buildAuditFilters(req) {
|
||||||
const category = String(req.query.category || '').trim().toLowerCase();
|
const category = String(req.query.category || '').trim().toLowerCase();
|
||||||
const eventType = String(req.query.event_type || '').trim().toLowerCase();
|
const eventType = String(req.query.event_type || '').trim().toLowerCase();
|
||||||
const search = getSearchQuery(req);
|
const search = getSearchQuery(req);
|
||||||
const requestedSort = getSortQuery(req);
|
const requestedSort = getSortQuery(req);
|
||||||
const sort = requestedSort || 'time';
|
const sort = Object.prototype.hasOwnProperty.call(AUDIT_SORT_COLUMNS, requestedSort) ? requestedSort : 'time';
|
||||||
const direction = requestedSort ? getSortDirectionQuery(req) : 'desc';
|
const direction = requestedSort ? getSortDirectionQuery(req) : 'desc';
|
||||||
const mappedRows = (rows || []).map(function (row) { return mapAuditRow(row, formatDashboardDate); });
|
const whereClauses = [];
|
||||||
const filteredRows = sortRows(mappedRows.filter(function (event) {
|
const params = [];
|
||||||
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 (category) {
|
||||||
if (sort === 'event') return event.eventLabel;
|
whereClauses.push('e.category = ?');
|
||||||
if (sort === 'actor') return event.actorLabel;
|
params.push(category);
|
||||||
if (sort === 'target') return event.targetLabelDisplay;
|
}
|
||||||
if (sort === 'category') return event.category;
|
if (eventType) {
|
||||||
return event.occurred_at;
|
whereClauses.push('e.event_type = ?');
|
||||||
}, direction);
|
params.push(eventType);
|
||||||
return { rows: filteredRows, category: category, eventType: eventType, search: search, sort: sort, direction: direction };
|
}
|
||||||
|
|
||||||
|
const searchFilter = buildSearchFilter(AUDIT_SEARCH_COLUMNS, search);
|
||||||
|
if (searchFilter.clause) {
|
||||||
|
whereClauses.push(searchFilter.clause.replace(/^\s*WHERE\s+/i, ''));
|
||||||
|
params.push(...searchFilter.params);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
category: category,
|
||||||
|
eventType: eventType,
|
||||||
|
search: search,
|
||||||
|
sort: sort,
|
||||||
|
direction: direction,
|
||||||
|
whereSql: whereClauses.length ? ' WHERE ' + whereClauses.join(' AND ') : '',
|
||||||
|
params: params
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchAuditRows(pool) {
|
function buildAuditOrderBy(filters) {
|
||||||
|
const sortExpression = AUDIT_SORT_COLUMNS[filters.sort] || AUDIT_SORT_COLUMNS.time;
|
||||||
|
const tieBreakers = filters.sort === 'time' ? 'e.id DESC' : 'e.occurred_at DESC, e.id DESC';
|
||||||
|
return ' ORDER BY ' + sortExpression + ' ' + filters.direction.toUpperCase() + ', ' + tieBreakers;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchAuditCount(pool, filters) {
|
||||||
const [rows] = await pool.query(
|
const [rows] = await pool.query(
|
||||||
`SELECT e.id, e.occurred_at, e.category, e.event_type, e.target_type, e.target_id,
|
'SELECT COUNT(*) AS count ' + AUDIT_FROM_SQL + filters.whereSql,
|
||||||
|
filters.params
|
||||||
|
);
|
||||||
|
return Number(rows && rows[0] && rows[0].count) || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchAuditEventTypes(pool, filters) {
|
||||||
|
const [rows] = await pool.query(
|
||||||
|
'SELECT DISTINCT e.event_type AS event_type ' + AUDIT_FROM_SQL + filters.whereSql + (filters.whereSql ? ' AND ' : ' WHERE ') + "e.event_type IS NOT NULL AND e.event_type <> '' ORDER BY e.event_type ASC",
|
||||||
|
filters.params
|
||||||
|
);
|
||||||
|
return (rows || []).map(function (row) {
|
||||||
|
return String(row.event_type || '').trim();
|
||||||
|
}).filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchAuditRows(pool, filters, page) {
|
||||||
|
let sql = `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,
|
e.target_label, e.ip_address, e.user_agent, e.details_json,
|
||||||
u.name AS actor_name, u.username AS actor_username
|
u.name AS actor_name, u.username AS actor_username
|
||||||
FROM o_audit_events e
|
${AUDIT_FROM_SQL}${filters.whereSql}${buildAuditOrderBy(filters)}`;
|
||||||
LEFT JOIN a_users u ON u.id = e.actor_user_id
|
const params = filters.params.slice();
|
||||||
ORDER BY e.occurred_at DESC, e.id DESC`, []
|
if (page) {
|
||||||
);
|
sql += ' LIMIT ? OFFSET ?';
|
||||||
|
params.push(page.limit, page.offset);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [rows] = await pool.query(sql, params);
|
||||||
return rows || [];
|
return rows || [];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -214,27 +276,28 @@ module.exports = function registerAuditLogRoutes(app, deps) {
|
|||||||
const search = getSearchQuery(req);
|
const search = getSearchQuery(req);
|
||||||
const sort = getSortQuery(req);
|
const sort = getSortQuery(req);
|
||||||
const direction = getSortDirectionQuery(req);
|
const direction = getSortDirectionQuery(req);
|
||||||
const filtered = filterAuditRows(await fetchAuditRows(pool), req, formatDashboardDate);
|
const filters = buildAuditFilters(req);
|
||||||
const filteredEvents = filtered.rows;
|
const totalEvents = await fetchAuditCount(pool, filters);
|
||||||
const eventTypes = Array.from(new Set(filtered.rows.map(function (event) {
|
const pagination = buildPagination(totalEvents, parsePageNumber(req.query.page), 'page', {
|
||||||
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,
|
search: search,
|
||||||
sort: sort,
|
sort: sort,
|
||||||
direction: direction,
|
direction: direction,
|
||||||
category: category,
|
category: category,
|
||||||
event_type: eventType
|
event_type: eventType
|
||||||
}, PAGE_SIZE, 'events', 'Audit event pages');
|
}, PAGE_SIZE, 'events', 'Audit event pages');
|
||||||
const startIndex = (pagination.currentPage - 1) * PAGE_SIZE;
|
const rawEvents = await fetchAuditRows(pool, filters, {
|
||||||
const events = filteredEvents.slice(startIndex, startIndex + PAGE_SIZE);
|
limit: PAGE_SIZE,
|
||||||
|
offset: (pagination.currentPage - 1) * PAGE_SIZE
|
||||||
|
});
|
||||||
|
const events = rawEvents.map(function (row) { return mapAuditRow(row, formatDashboardDate); });
|
||||||
|
const eventTypes = await fetchAuditEventTypes(pool, filters);
|
||||||
|
const exportQuery = new URLSearchParams({
|
||||||
|
category: filters.category,
|
||||||
|
event_type: filters.eventType,
|
||||||
|
search: filters.search,
|
||||||
|
sort: filters.sort,
|
||||||
|
direction: filters.direction
|
||||||
|
}).toString();
|
||||||
res.send(renderView('settings/audit-log/index', {
|
res.send(renderView('settings/audit-log/index', {
|
||||||
title: 'Audit log',
|
title: 'Audit log',
|
||||||
active: 'audit-log',
|
active: 'audit-log',
|
||||||
@@ -257,11 +320,13 @@ module.exports = function registerAuditLogRoutes(app, deps) {
|
|||||||
|
|
||||||
app.get('/settings/audit-log/export', requireAuditExportAccess(deps.setAuthMessageCookie), async function (req, res, next) {
|
app.get('/settings/audit-log/export', requireAuditExportAccess(deps.setAuthMessageCookie), async function (req, res, next) {
|
||||||
try {
|
try {
|
||||||
const filtered = filterAuditRows(await fetchAuditRows(pool), req, formatDashboardDate);
|
const filters = buildAuditFilters(req);
|
||||||
|
const rawEvents = await fetchAuditRows(pool, filters);
|
||||||
|
const events = rawEvents.map(function (row) { return mapAuditRow(row, formatDashboardDate); });
|
||||||
const lines = [
|
const lines = [
|
||||||
['Occurred At (UTC)', 'Category', 'Event Type', 'Actor', 'Target Type', 'Target ID', 'Target', 'IP Address', 'User Agent', 'Details'].map(csvCell).join(',')
|
['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) {
|
events.forEach(function (event) {
|
||||||
lines.push([
|
lines.push([
|
||||||
event.occurredAtValue,
|
event.occurredAtValue,
|
||||||
event.category,
|
event.category,
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ const registerAuditLogRoutes = require('../src/web/routes/settings/audit-log');
|
|||||||
|
|
||||||
test('audit log uses its own read permission and shared pagination partial', async () => {
|
test('audit log uses its own read permission and shared pagination partial', async () => {
|
||||||
const handlers = {};
|
const handlers = {};
|
||||||
|
const queries = [];
|
||||||
const app = {
|
const app = {
|
||||||
get(path, ...routeHandlers) {
|
get(path, ...routeHandlers) {
|
||||||
handlers[path] = routeHandlers;
|
handlers[path] = routeHandlers;
|
||||||
@@ -15,8 +16,16 @@ test('audit log uses its own read permission and shared pagination partial', asy
|
|||||||
|
|
||||||
registerAuditLogRoutes(app, {
|
registerAuditLogRoutes(app, {
|
||||||
pool: {
|
pool: {
|
||||||
async query(sql) {
|
async query(sql, params) {
|
||||||
if (String(sql).includes('FROM o_audit_events')) {
|
const query = String(sql);
|
||||||
|
queries.push([query, params || []]);
|
||||||
|
if (query.includes('SELECT COUNT(*) AS count')) {
|
||||||
|
return [[{ count: 2 }]];
|
||||||
|
}
|
||||||
|
if (query.includes('SELECT DISTINCT e.event_type')) {
|
||||||
|
return [[{ event_type: 'login.failed' }, { event_type: 'login.success' }]];
|
||||||
|
}
|
||||||
|
if (query.includes('FROM o_audit_events')) {
|
||||||
return [[
|
return [[
|
||||||
{
|
{
|
||||||
id: 1,
|
id: 1,
|
||||||
@@ -102,6 +111,12 @@ test('audit log uses its own read permission and shared pagination partial', asy
|
|||||||
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']);
|
||||||
|
const pageQuery = queries.find(([sql]) => sql.includes('LIMIT ? OFFSET ?'));
|
||||||
|
assert.ok(pageQuery);
|
||||||
|
assert.match(pageQuery[0], /ORDER BY e\.occurred_at DESC, e\.id DESC LIMIT \? OFFSET \?/);
|
||||||
|
assert.deepEqual(pageQuery[1].slice(-2), [25, 0]);
|
||||||
|
assert.ok(queries.some(([sql]) => sql.includes('SELECT COUNT(*) AS count')));
|
||||||
|
assert.ok(queries.some(([sql]) => sql.includes('SELECT DISTINCT e.event_type') && /WHERE e\.event_type IS NOT NULL/.test(sql)));
|
||||||
|
|
||||||
const exportResponse = {
|
const exportResponse = {
|
||||||
headers: {},
|
headers: {},
|
||||||
@@ -114,4 +129,59 @@ test('audit log uses its own read permission and shared pagination partial', asy
|
|||||||
assert.match(exportResponse.headers['Content-Type'], /text\/csv/);
|
assert.match(exportResponse.headers['Content-Type'], /text\/csv/);
|
||||||
assert.match(exportResponse.body, /Occurred At \(UTC\)/);
|
assert.match(exportResponse.body, /Occurred At \(UTC\)/);
|
||||||
assert.match(exportResponse.body, /login.success/);
|
assert.match(exportResponse.body, /login.success/);
|
||||||
|
const exportQuery = queries.filter(([sql]) => sql.includes('FROM o_audit_events') && !sql.includes('SELECT COUNT(*)') && !sql.includes('SELECT DISTINCT')).at(-1);
|
||||||
|
assert.ok(exportQuery);
|
||||||
|
assert.doesNotMatch(exportQuery[0], /LIMIT \?/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('audit log applies SQL filters, escaped search, and selected sort before pagination', async () => {
|
||||||
|
const handlers = {};
|
||||||
|
const queries = [];
|
||||||
|
const app = {
|
||||||
|
get(path, ...routeHandlers) {
|
||||||
|
handlers[path] = routeHandlers;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
registerAuditLogRoutes(app, {
|
||||||
|
pool: {
|
||||||
|
async query(sql, params) {
|
||||||
|
const query = String(sql);
|
||||||
|
queries.push([query, params || []]);
|
||||||
|
if (query.includes('SELECT COUNT(*) AS count')) return [[{ count: 40 }]];
|
||||||
|
return [[]];
|
||||||
|
}
|
||||||
|
},
|
||||||
|
formatDashboardDate() {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await handlers['/settings/audit-log'][1]({
|
||||||
|
currentUser: { id: 7, permissions: ['audit-log.read'] },
|
||||||
|
query: {
|
||||||
|
category: 'Authentication',
|
||||||
|
event_type: 'Login.Success',
|
||||||
|
search: ' Alice%_ ',
|
||||||
|
sort: 'actor',
|
||||||
|
direction: 'desc',
|
||||||
|
page: '2'
|
||||||
|
}
|
||||||
|
}, { send() {} }, function (error) {
|
||||||
|
throw error;
|
||||||
|
});
|
||||||
|
|
||||||
|
const countQuery = queries.find(([sql]) => sql.includes('SELECT COUNT(*) AS count'));
|
||||||
|
const pageQuery = queries.find(([sql]) => sql.includes('LIMIT ? OFFSET ?'));
|
||||||
|
const eventTypesQuery = queries.find(([sql]) => sql.includes('SELECT DISTINCT e.event_type'));
|
||||||
|
assert.ok(countQuery);
|
||||||
|
assert.ok(pageQuery);
|
||||||
|
assert.ok(eventTypesQuery);
|
||||||
|
assert.match(countQuery[0], /e\.category = \? AND e\.event_type = \?/);
|
||||||
|
assert.match(countQuery[0], /LOWER\(COALESCE\(CAST\(e\.category AS CHAR\)/);
|
||||||
|
assert.deepEqual(countQuery[1].slice(0, 2), ['authentication', 'login.success']);
|
||||||
|
assert.ok(countQuery[1].includes('%alice\\%\\_%'));
|
||||||
|
assert.match(pageQuery[0], /ORDER BY COALESCE\(NULLIF\(u\.name, ''\), u\.username, 'System'\) DESC, e\.occurred_at DESC, e\.id DESC/);
|
||||||
|
assert.deepEqual(pageQuery[1].slice(-2), [25, 25]);
|
||||||
|
assert.match(eventTypesQuery[0], /AND e\.event_type IS NOT NULL/);
|
||||||
});
|
});
|
||||||
@@ -49,6 +49,37 @@ test('pending migrations are empty when the schema already matches the app versi
|
|||||||
assert.equal(pendingMigrations.length, 0);
|
assert.equal(pendingMigrations.length, 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('query index migration upgrades a 2.17.4 installation', async () => {
|
||||||
|
const queries = [];
|
||||||
|
const pool = {
|
||||||
|
queries,
|
||||||
|
async query(sql, params) {
|
||||||
|
queries.push([sql, params]);
|
||||||
|
if (String(sql).includes('FROM information_schema.STATISTICS')) {
|
||||||
|
return [[{ index_count: params[1] === 'idx_background_tasks_status' ? 1 : 0 }]];
|
||||||
|
}
|
||||||
|
return [[]];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const pending = await getPendingMigrations(pool, { currentVersion: '2.17.4' });
|
||||||
|
const indexMigration = pending.find(migration => migration.version === '2.17.5');
|
||||||
|
|
||||||
|
assert.ok(indexMigration);
|
||||||
|
await indexMigration.run(pool);
|
||||||
|
|
||||||
|
const alterations = queries.map(([sql]) => String(sql)).filter(sql => sql.startsWith('ALTER TABLE'));
|
||||||
|
assert.equal(alterations.length, 9);
|
||||||
|
assert.ok(alterations.some(sql => sql.includes('ADD INDEX idx_playlist_slides_playlist_position')));
|
||||||
|
assert.ok(alterations.some(sql => sql.includes('ADD INDEX idx_template_regions_template_z')));
|
||||||
|
assert.ok(alterations.some(sql => sql.includes('ADD INDEX idx_slides_template')));
|
||||||
|
assert.ok(alterations.some(sql => sql.includes('ADD INDEX idx_templates_canvas_size')));
|
||||||
|
assert.ok(alterations.some(sql => sql.includes('ADD INDEX idx_sessions_expires_at')));
|
||||||
|
assert.ok(alterations.some(sql => sql.includes('ADD INDEX idx_background_tasks_status_created_at')));
|
||||||
|
assert.ok(alterations.some(sql => sql.includes('ADD INDEX idx_rss_feeds_modified_at')));
|
||||||
|
assert.ok(alterations.some(sql => sql.includes('ADD INDEX idx_api_source_groups_modified_at')));
|
||||||
|
assert.ok(alterations.some(sql => sql.includes('DROP INDEX idx_background_tasks_status')));
|
||||||
|
});
|
||||||
|
|
||||||
test('playlist shuffle migration upgrades an existing 2.15.0 installation', async () => {
|
test('playlist shuffle migration upgrades an existing 2.15.0 installation', async () => {
|
||||||
const pool = createPool([]);
|
const pool = createPool([]);
|
||||||
const pending = await getPendingMigrations(pool, { currentVersion: '2.15.0' });
|
const pending = await getPendingMigrations(pool, { currentVersion: '2.15.0' });
|
||||||
|
|||||||
@@ -161,8 +161,9 @@ test('2.17.0 migration adds the expiring view-as session columns', async () => {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
const pending = await getPendingMigrations(pool, { currentVersion: '2.16.2' });
|
const pending = await getPendingMigrations(pool, { currentVersion: '2.16.2' });
|
||||||
assert.deepEqual(pending.map(function (migration) { return migration.version; }), ['2.17.0', '2.17.1']);
|
const viewAsMigration = pending.find(function (migration) { return migration.version === '2.17.0'; });
|
||||||
await pending[0].run(pool);
|
assert.ok(viewAsMigration);
|
||||||
|
await viewAsMigration.run(pool);
|
||||||
|
|
||||||
assert.ok(pool.queries.some(function ([sql]) {
|
assert.ok(pool.queries.some(function ([sql]) {
|
||||||
return String(sql).includes('ALTER TABLE a_sessions ADD COLUMN view_as_user_id INT NULL AFTER user_id');
|
return String(sql).includes('ALTER TABLE a_sessions ADD COLUMN view_as_user_id INT NULL AFTER user_id');
|
||||||
@@ -189,8 +190,9 @@ test('2.17.1 migration removes obsolete dashboard.allow grants and permission',
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
const pending = await getPendingMigrations(pool, { currentVersion: '2.17.0' });
|
const pending = await getPendingMigrations(pool, { currentVersion: '2.17.0' });
|
||||||
assert.deepEqual(pending.map(function (migration) { return migration.version; }), ['2.17.1']);
|
const obsoletePermissionMigration = pending.find(function (migration) { return migration.version === '2.17.1'; });
|
||||||
await pending[0].run(pool);
|
assert.ok(obsoletePermissionMigration);
|
||||||
|
await obsoletePermissionMigration.run(pool);
|
||||||
|
|
||||||
assert.ok(pool.queries.some(function ([sql, params]) {
|
assert.ok(pool.queries.some(function ([sql, params]) {
|
||||||
return String(sql).includes('DELETE role_permissions') && params[0] === 'dashboard.allow';
|
return String(sql).includes('DELETE role_permissions') && params[0] === 'dashboard.allow';
|
||||||
|
|||||||
Reference in new issue
Block a user