diff --git a/.dockerignore b/.dockerignore index 6727b2c..cf8ce7f 100644 --- a/.dockerignore +++ b/.dockerignore @@ -7,3 +7,4 @@ !src/** !scripts/ !scripts/** +/local-examples/ diff --git a/.gitignore b/.gitignore index a1c3764..00a3e5a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ node_modules/ +/local-examples/ media/ !src/web/lib/media/ !src/web/lib/media/** diff --git a/CHANGELOG.md b/CHANGELOG.md index 2ee188c..7cd8284 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,17 @@ All notable changes to this project will be documented in this file. +## 2.15.4 - 2026-10-16 + +### Added + +- Added configurable audit events for media-library assets, managed fonts, and weather icon sets. +- Added All / Between Dates / Before Date / After Date filtering to timetable regions, with optional inclusive start-time boundaries shared by previews and playback. + +### Fixed + +- Prevented deletion of media referenced from rich-text slide content. + ## 2.15.3 - 2026-10-15 ### Fixed diff --git a/build/package.player.json b/build/package.player.json index 4fd3d52..ee6cd63 100644 --- a/build/package.player.json +++ b/build/package.player.json @@ -1,6 +1,6 @@ { "name": "pulse-signage-player", - "version": "2.15.3", + "version": "2.15.4", "private": false, "description": "Pulse Signage player application bundle", "engines": { diff --git a/build/package.web.json b/build/package.web.json index c1e3337..e7da516 100644 --- a/build/package.web.json +++ b/build/package.web.json @@ -1,6 +1,6 @@ { "name": "pulse-signage-web", - "version": "2.15.3", + "version": "2.15.4", "private": false, "description": "Pulse Signage web and bridge application bundle", "engines": { diff --git a/package-lock.json b/package-lock.json index 4bc5295..689bda5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "pulse-signage", - "version": "2.15.3", + "version": "2.15.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pulse-signage", - "version": "2.15.3", + "version": "2.15.4", "dependencies": { "@sparticuz/chromium": "^149.0.0", "animate.css": "^4.1.1", diff --git a/package.json b/package.json index d416c62..924f774 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "pulse-signage", - "version": "2.15.3", + "version": "2.15.4", "private": false, "description": "Pulse Signage application with MySQL and media storage", "engines": { diff --git a/src/data/audit-log.js b/src/data/audit-log.js index 4c16134..199fc56 100644 --- a/src/data/audit-log.js +++ b/src/data/audit-log.js @@ -17,6 +17,7 @@ const AUDIT_EVENT_CATEGORIES = Object.freeze({ RSS_FEEDS: 'rss-feeds', TIMETABLES: 'timetables', WEATHER: 'weather', + MEDIA_ASSETS: 'media-assets', SCREEN_CONTROLS: 'screen-controls' }); const AUDIT_CATEGORY_KEYS = Object.freeze(Object.values(AUDIT_EVENT_CATEGORIES)); @@ -37,6 +38,7 @@ const AUDIT_CATEGORY_LABELS = Object.freeze({ 'rss-feeds': 'RSS Feeds', timetables: 'Timetables', weather: 'Weather', + 'media-assets': 'Media Assets', 'screen-controls': 'Screen Controls' }); const SCREEN_CONTROL_COMMAND_KEYS = Object.freeze(['pause', 'blackout', 'reload', 'navigation', 'moveclient', 'setclientname']); diff --git a/src/data/slides.js b/src/data/slides.js index 5502c5f..f1132ff 100644 --- a/src/data/slides.js +++ b/src/data/slides.js @@ -417,6 +417,24 @@ async function buildTemplateContent(pool, template, body, filesByField, existing delete generic.timetable_display_mode; delete generic.timetable_max_items; + generic.date_filter = ['between', 'before', 'after'].includes(generic.date_filter) ? generic.date_filter : 'all'; + for (const field of ['date_from', 'date_to']) { + const value = String(generic[field] || '').trim(); + const date = value ? new Date(value) : null; + const active = generic.date_filter === 'between' || generic.date_filter === (field === 'date_from' ? 'after' : 'before'); + if (active && date && Number.isNaN(date.getTime())) { + const error = new Error('Timetable date range must contain valid date-times.'); + error.statusCode = 400; + throw error; + } + generic[field] = date && !Number.isNaN(date.getTime()) ? date.toISOString() : ''; + } + if (generic.date_filter === 'between' && generic.date_from && generic.date_to && generic.date_from > generic.date_to) { + const error = new Error('Timetable date range must end on or after its start.'); + error.statusCode = 400; + throw error; + } + generic.text = normalizedText; generic.value = normalizedText; generic.type = region.region_type; diff --git a/src/player/regions/timetable.js b/src/player/regions/timetable.js index a1f4342..3b76143 100644 --- a/src/player/regions/timetable.js +++ b/src/player/regions/timetable.js @@ -63,9 +63,16 @@ function isLive(entry, now) { return Boolean(start && end && now >= start && now < end); } -function getVisibleEntries(groupId, displayMode, maxItems, groups) { +function getVisibleEntries(groupId, displayMode, maxItems, groups, config) { var now = new Date(); - var entries = getEntries(groupId, groups).slice().sort(function (left, right) { + var range = config || {}; + var from = toDate(range.date_filter === 'between' || range.date_filter === 'after' ? range.date_from : ''); + var to = toDate(range.date_filter === 'between' || range.date_filter === 'before' ? range.date_to : ''); + var candidates = getEntries(groupId, groups).filter(function (entry) { + var start = toDate(entry && entry.start_datetime); + return (!from && !to) || Boolean(start && (!from || start >= from) && (!to || start <= to)); + }); + var entries = candidates.slice().sort(function (left, right) { var leftStart = toDate(left && left.start_datetime); var rightStart = toDate(right && right.start_datetime); return (leftStart ? leftStart.getTime() : 0) - (rightStart ? rightStart.getTime() : 0) || Number(left.id || 0) - Number(right.id || 0); @@ -83,7 +90,7 @@ function getVisibleEntries(groupId, displayMode, maxItems, groups) { }); if (!entries.length && mode === 'upcoming') { - entries = getEntries(groupId, groups).filter(function (entry) { + entries = candidates.filter(function (entry) { return isLive(entry, now); }); } @@ -135,7 +142,7 @@ function renderRegion(region, regionContent) { var displayMode = regionContent && regionContent.display_mode ? regionContent.display_mode : 'upcoming'; var maxItems = regionContent && regionContent.max_items !== undefined ? regionContent.max_items : 5; var group = getGroupById(groupId, groups); - var entries = getVisibleEntries(groupId, displayMode, maxItems, groups); + var entries = getVisibleEntries(groupId, displayMode, maxItems, groups, regionContent); var timeZone = group && (group.timezone || group.time_zone) ? String(group.timezone || group.time_zone) : ''; var width = Math.max(1, Math.round(Number(region && region.pixelWidth ? region.pixelWidth : 0) || 1)); var height = Math.max(1, Math.round(Number(region && region.pixelHeight ? region.pixelHeight : 0) || 1)); diff --git a/src/web.js b/src/web.js index b8c5772..6fda0d4 100644 --- a/src/web.js +++ b/src/web.js @@ -124,6 +124,7 @@ async function start() { // Middleware and route registration. registerMiddleware(app, { pool: pool, + pages: pages, loadCurrentUser: loadCurrentUser, requireAuth: requireAuth, MEDIA_DIR: webConfig.mediaDir, diff --git a/src/web/lib/media/library.js b/src/web/lib/media/library.js index e6b60ea..efb1384 100644 --- a/src/web/lib/media/library.js +++ b/src/web/lib/media/library.js @@ -250,7 +250,7 @@ async function countMediaAssetReferences(pool, mediaPath) { const [slideRows] = await pool.query( `SELECT COUNT(*) AS ref_count FROM c_slides - WHERE LOCATE(?, COALESCE(content_json, '')) > 0`, + WHERE LOCATE(?, CAST(COALESCE(content_json, '') AS CHAR)) > 0`, [normalizedPath] ); const [templateRows] = await pool.query( diff --git a/src/web/middleware.js b/src/web/middleware.js index eb2ca31..eb4f6e3 100644 --- a/src/web/middleware.js +++ b/src/web/middleware.js @@ -2,6 +2,7 @@ const express = require('express'); const fs = require('fs'); +const registerErrorPageMiddleware = require('#src/web/middleware/error-page'); module.exports = function registerMiddleware(app, deps) { const pool = deps.pool; @@ -28,6 +29,8 @@ module.exports = function registerMiddleware(app, deps) { } }); + registerErrorPageMiddleware(app, deps.pages); + app.use(function (req, res, next) { if (req.path === '/' || req.path === '/login' || req.path === '/logout' || req.path === '/forgot-password' || req.path === '/reset-password' || req.path === '/verify-email' || req.path === '/slides/popup-preview' || req.path.indexOf('/api/internal/slide-thumbnails/') === 0 || req.path === '/api/internal/sync/player-media' || req.path === '/api/internal/sync/player-font' || req.path === '/api/internal/sync/player-control') { return next(); diff --git a/src/web/middleware/error-page.js b/src/web/middleware/error-page.js new file mode 100644 index 0000000..eefa826 --- /dev/null +++ b/src/web/middleware/error-page.js @@ -0,0 +1,48 @@ +function wantsHtmlErrorPage(req, res) { + const isXhr = String(req.get && req.get('X-Requested-With') || '').toLowerCase() === 'xmlhttprequest'; + const requestPath = String(req.originalUrl || req.path || ''); + const acceptsHtml = !req.accepts || req.accepts('html'); + const contentType = String(res.get && res.get('Content-Type') || '').toLowerCase(); + + return !isXhr && requestPath.indexOf('/api/') !== 0 && acceptsHtml && (!contentType || contentType.indexOf('text/html') === 0); +} + +function createErrorPage(pages, req, statusCode, message) { + const isServerError = statusCode >= 500; + return pages.renderErrorPage({ + statusCode: statusCode, + message: isServerError ? '' : String(message || ''), + detail: isServerError ? 'The server could not complete the request.' : '', + backUrl: req.currentUser ? '/dashboard' : '/login', + backLabel: req.currentUser ? 'Back to dashboard' : 'Sign in' + }, req.currentUser); +} + +module.exports = function registerErrorPageMiddleware(app, pages) { + app.use(function (req, res, next) { + const send = res.send; + const sendStatus = res.sendStatus; + + res.send = function (body) { + const statusCode = Number(res.statusCode) || 200; + const isHtml = typeof body === 'string' && /^\s*= 400 && statusCode < 600 && !isHtml && wantsHtmlErrorPage(req, res)) { + return send.call(res, createErrorPage(pages, req, statusCode, body)); + } + + return send.apply(res, arguments); + }; + + res.sendStatus = function (statusCode) { + if (statusCode >= 400 && statusCode < 600 && wantsHtmlErrorPage(req, res)) { + res.statusCode = statusCode; + return res.send(createErrorPage(pages, req, statusCode)); + } + + return sendStatus.call(res, statusCode); + }; + + next(); + }); +}; \ No newline at end of file diff --git a/src/web/public/js/regions/type/timetable.js b/src/web/public/js/regions/type/timetable.js index e2dc799..aba26d1 100644 --- a/src/web/public/js/regions/type/timetable.js +++ b/src/web/public/js/regions/type/timetable.js @@ -98,15 +98,40 @@ return Boolean(start && now < start); } + function toLocalInput(value) { + var date = toDate(value); + if (!date) { + return ''; + } + function pad(part) { return String(part).padStart(2, '0'); } + return date.getFullYear() + '-' + pad(date.getMonth() + 1) + '-' + pad(date.getDate()) + 'T' + pad(date.getHours()) + ':' + pad(date.getMinutes()) + ':' + pad(date.getSeconds()); + } + + function getDateRangeConfig(regionId, card, current) { + var config = {}; + ['date_filter', 'date_from', 'date_to'].forEach(function (field) { + var input = card && card.querySelector ? card.querySelector('[name="region_' + field + '_' + regionId + '"]') : null; + config[field] = input ? input.value : current[field] || (field === 'date_filter' ? 'all' : ''); + }); + return config; + } + function isLive(entry, now) { var start = toDate(entry && entry.start_datetime); var end = toDate(entry && entry.end_datetime); return Boolean(start && end && now >= start && now < end); } - function getVisibleEntries(groupId, displayMode, maxItems, groups) { + function getVisibleEntries(groupId, displayMode, maxItems, groups, config) { var now = new Date(); - var entries = getEntries(groupId, groups).slice().sort(function (left, right) { + var range = config || {}; + var from = toDate(range.date_filter === 'between' || range.date_filter === 'after' ? range.date_from : ''); + var to = toDate(range.date_filter === 'between' || range.date_filter === 'before' ? range.date_to : ''); + var candidates = getEntries(groupId, groups).filter(function (entry) { + var start = toDate(entry && entry.start_datetime); + return (!from && !to) || Boolean(start && (!from || start >= from) && (!to || start <= to)); + }); + var entries = candidates.slice().sort(function (left, right) { var leftStart = toDate(left && left.start_datetime); var rightStart = toDate(right && right.start_datetime); return (leftStart ? leftStart.getTime() : 0) - (rightStart ? rightStart.getTime() : 0) || Number(left.id || 0) - Number(right.id || 0); @@ -124,7 +149,7 @@ }); if (!entries.length && mode === 'upcoming') { - entries = getEntries(groupId, groups).filter(function (entry) { + entries = candidates.filter(function (entry) { return isLive(entry, now); }); } @@ -167,6 +192,9 @@ display_mode: String(current.display_mode || current.timetable_display_mode || 'upcoming').trim().toLowerCase() || 'upcoming', value: String(current.text !== undefined ? current.text : current.value !== undefined ? current.value : ''), max_items: Math.max(1, Number(current.max_items || current.timetable_max_items || 5)), + date_filter: ['between', 'before', 'after'].includes(current.date_filter) ? current.date_filter : 'all', + date_from: current.date_from || '', + date_to: current.date_to || '', font_family: current.font_family || region.font_family || getDefaultStyle().font_family, font_size: current.font_size || region.font_size || getDefaultStyle().font_size, font_color: current.font_color || region.font_color || getDefaultStyle().font_color @@ -230,8 +258,9 @@ var displayMode = timetableDisplayModeInput ? timetableDisplayModeInput.value : String(current.display_mode || current.timetable_display_mode || 'upcoming').trim().toLowerCase() || 'upcoming'; var maxItems = Math.max(1, Number(timetableMaxItemsInput ? timetableMaxItemsInput.value : current.max_items || current.timetable_max_items || 5)); var group = getGroupById(groupId, timetableGroups); - var entries = getVisibleEntries(groupId, displayMode, maxItems, timetableGroups); - if (!entries.length) { + var range = getDateRangeConfig(regionId, card, current); + var entries = getVisibleEntries(groupId, displayMode, maxItems, timetableGroups, range); + if (!entries.length && range.date_filter === 'all') { entries = getEntries(groupId, timetableGroups).slice(0, maxItems); } @@ -267,9 +296,10 @@ var currentGroupId = current.timetable_group_id === undefined || current.timetable_group_id === null || current.timetable_group_id === '' ? '' : Number(current.timetable_group_id); var currentDisplayMode = String(current.display_mode || current.timetable_display_mode || 'upcoming').trim().toLowerCase() || 'upcoming'; var currentMaxItems = Math.max(1, Number(current.max_items || current.timetable_max_items || 5)); + var dateFilter = ['between', 'before', 'after'].includes(current.date_filter) ? current.date_filter : 'all'; var currentGroup = getGroupById(currentGroupId, timetableGroups); - var currentEntries = getVisibleEntries(currentGroupId, currentDisplayMode, currentMaxItems, timetableGroups); - if (!currentEntries.length) { + var currentEntries = getVisibleEntries(currentGroupId, currentDisplayMode, currentMaxItems, timetableGroups, current); + if (!currentEntries.length && dateFilter === 'all') { currentEntries = getEntries(currentGroupId, timetableGroups).slice(0, currentMaxItems); } var groupOptions = timetableGroups.map(function (group) { @@ -295,7 +325,7 @@ '' + '' + '
' + - '
' + + '
' + '' + '' + '
' + - '
' + + '
' + '' + '' + '
' + + '
' + + '' + + '' + + '
' + + '
' + + '
' + + '
' + + ['from', 'to'].map(function (boundary) { + var field = 'date_' + boundary; + var enabled = dateFilter === 'between' || dateFilter === (boundary === 'from' ? 'after' : 'before'); + var label = boundary === 'from' ? 'Starts on or after (local time)' : 'Starts on or before (local time)'; + return '
' + + '' + + '' + + '' + + '
'; + }).join('') + + '
' + '
' + - '
Use the selected group and display mode to choose which timetable entry fields are available.
' + '
' + '
Available placeholders ' + renderPlaceholderInfo(region.id) + '
' + '
' + renderSchedulePlaceholderChips(currentGroup, currentEntries) + '
' + @@ -350,24 +402,50 @@ panel.innerHTML = renderSchedulePlaceholderChips(current.group, current.entries); } + function updateDateRange(card) { + var regionId = card.getAttribute('data-region-id'); + var config = getDateRangeConfig(regionId, card, {}); + var panel = card.querySelector('[data-timetable-date-range]'); + if (!panel) { + return; + } + panel.hidden = config.date_filter === 'all'; + panel.querySelectorAll('[data-timetable-date]').forEach(function (input) { + var field = input.getAttribute('data-timetable-date'); + var enabled = config.date_filter === 'between' || config.date_filter === (field === 'date_from' ? 'after' : 'before'); + input.closest('[data-timetable-boundary]').hidden = !enabled; + input.disabled = !enabled; + var date = toDate(input.value); + card.querySelector('[name="region_' + input.getAttribute('data-timetable-date') + '_' + regionId + '"]').value = date ? date.toISOString() : ''; + }); + var fromInput = panel.querySelector('[data-timetable-date="date_from"]'); + var toInput = panel.querySelector('[data-timetable-date="date_to"]'); + var from = toDate(fromInput.value); + var to = toDate(toInput.value); + toInput.setCustomValidity(config.date_filter === 'between' && from && to && from > to ? 'The end of the date range must be on or after its start.' : ''); + } + if (!window.__scheduleRegionPlaceholderRefreshInstalled) { window.__scheduleRegionPlaceholderRefreshInstalled = true; - document.addEventListener('change', function (event) { + function handleTimetableInput(event) { var target = event.target; if (!target || !target.closest) { return; } - if (!target.closest('select[name^="region_timetable_group_id_"], select[name^="region_timetable_display_mode_"], input[name^="region_timetable_max_items_"]')) { + if (!target.closest('select[name^="region_timetable_group_id_"], select[name^="region_timetable_display_mode_"], input[name^="region_timetable_max_items_"], select[name^="region_date_filter_"], [data-timetable-date]')) { return; } var card = target.closest('[data-region-id]'); if (card) { + updateDateRange(card); updateSchedulePlaceholderPanel(card); } - }, true); + } + document.addEventListener('input', handleTimetableInput, true); + document.addEventListener('change', handleTimetableInput, true); } function buildEditorCardContext(context) { @@ -410,6 +488,7 @@ tz_long: timezoneValues.tz_long, timeZone: timezoneValues.tz_long, max_items: timetableMaxItemsInput ? timetableMaxItemsInput.value || current.max_items : current.max_items, + ...getDateRangeConfig(region.id, card, current), existingContent: existingContent || {}, timetableGroups: Array.isArray(timetableGroups) ? timetableGroups : [] }; @@ -423,7 +502,7 @@ var displayMode = regionContent && regionContent.display_mode ? regionContent.display_mode : 'upcoming'; var maxItems = regionContent && regionContent.max_items !== undefined ? regionContent.max_items : 5; var group = getGroupById(groupId, groups); - var entries = getVisibleEntries(groupId, displayMode, maxItems, groups); + var entries = getVisibleEntries(groupId, displayMode, maxItems, groups, regionContent); if (!value) { return '
'; diff --git a/src/web/routes/register.js b/src/web/routes/register.js index 424c26a..9f1c7cd 100644 --- a/src/web/routes/register.js +++ b/src/web/routes/register.js @@ -334,6 +334,7 @@ function registerSettingsAndContentRoutes(app, deps) { uploadDir: deps.uploadDir, backgroundTaskQueue: deps.backgroundTaskQueue, setAuthMessageCookie: deps.setAuthMessageCookie, + recordRequestAuditEvent: deps.recordRequestAuditEvent, requirePermission: deps.requirePermission }); @@ -344,6 +345,7 @@ function registerSettingsAndContentRoutes(app, deps) { uploadDir: deps.uploadDir, getAuditUserId: deps.getAuditUserId, setAuthMessageCookie: deps.setAuthMessageCookie, + recordRequestAuditEvent: deps.recordRequestAuditEvent, requirePermission: deps.requirePermission, syncPlaylistUploadsOnChange: deps.syncPlaylistUploadsOnChange }); diff --git a/src/web/routes/settings/fonts.js b/src/web/routes/settings/fonts.js index e43887f..fbb7f2f 100644 --- a/src/web/routes/settings/fonts.js +++ b/src/web/routes/settings/fonts.js @@ -123,6 +123,7 @@ module.exports = function registerFontRoutes(app, deps) { const uploadDir = deps.uploadDir; const backgroundTaskQueue = deps.backgroundTaskQueue; const setAuthMessageCookie = deps.setAuthMessageCookie; + const recordRequestAuditEvent = deps.recordRequestAuditEvent; const FONT_FAMILY_MAX_LENGTH = 255; @@ -146,6 +147,13 @@ module.exports = function registerFontRoutes(app, deps) { } const result = await fontLibrary.addFontFile(uploadDir, req.file, String(req.body && req.body.font_family || '').trim().slice(0, FONT_FAMILY_MAX_LENGTH)); + if (typeof recordRequestAuditEvent === 'function') { + await recordRequestAuditEvent(pool, req, { + category: 'media-assets', eventType: 'font.uploaded', actorUserId: req.currentUser && req.currentUser.id, + targetType: 'font', targetId: result.font.id, targetLabel: result.font.family, + details: { fileName: result.font.fileName, format: result.font.format } + }); + } if (backgroundTaskQueue && typeof backgroundTaskQueue.enqueueTask === 'function') { await backgroundTaskQueue.enqueueTask({ key: `font-sync:${result && result.font && result.font.id ? result.font.id : Date.now()}`, @@ -186,6 +194,14 @@ module.exports = function registerFontRoutes(app, deps) { return res.redirect('/settings/fonts?message=' + encodeURIComponent('That font was not found.')); } + if (typeof recordRequestAuditEvent === 'function') { + await recordRequestAuditEvent(pool, req, { + category: 'media-assets', eventType: enabled ? 'font.enabled' : 'font.disabled', actorUserId: req.currentUser && req.currentUser.id, + targetType: 'font', targetId: updated.id, targetLabel: updated.family || updated.name, + details: { enabled: Boolean(enabled) } + }); + } + if (backgroundTaskQueue && typeof backgroundTaskQueue.enqueueTask === 'function') { await backgroundTaskQueue.enqueueTask({ key: `font-sync:toggle:${updated && updated.id ? updated.id : Date.now()}`, @@ -222,6 +238,14 @@ module.exports = function registerFontRoutes(app, deps) { return res.redirect('/settings/fonts?message=' + encodeURIComponent('That font was not found.')); } + if (typeof recordRequestAuditEvent === 'function') { + await recordRequestAuditEvent(pool, req, { + category: 'media-assets', eventType: 'font.deleted', actorUserId: req.currentUser && req.currentUser.id, + targetType: 'font', targetId: removed.id, targetLabel: removed.family || removed.name, + details: { fileName: removed.fileName, format: removed.format } + }); + } + if (backgroundTaskQueue && typeof backgroundTaskQueue.enqueueTask === 'function') { await backgroundTaskQueue.enqueueTask({ key: `font-sync:delete:${removed && removed.id ? removed.id : Date.now()}`, diff --git a/src/web/routes/settings/media-library.js b/src/web/routes/settings/media-library.js index 78bc72f..83b1d19 100644 --- a/src/web/routes/settings/media-library.js +++ b/src/web/routes/settings/media-library.js @@ -117,6 +117,7 @@ function filterAndSortAssets(assets, query) { module.exports = function registerMediaLibraryRoutes(app, deps) { const requireAccess = requireMediaAccess(deps.setAuthMessageCookie); + const recordRequestAuditEvent = deps.recordRequestAuditEvent; const pageSize = 24; if (!deps.pool || !deps.pages || !deps.upload || !deps.uploadDir) { throw new Error('registerMediaLibraryRoutes requires pool, pages, upload, and uploadDir.'); @@ -196,7 +197,19 @@ module.exports = function registerMediaLibraryRoutes(app, deps) { return res.redirect('/media-library?message=' + encodeURIComponent('File must be ' + limitLabel + ' or smaller.')); } await mediaLibrary.createImageVariants(req.file); - await mediaLibrary.registerMediaAsset(deps.pool, Object.assign({}, req.file, { mediaType: mediaType }), '/media/uploads/' + req.file.filename, deps.getAuditUserId(req)); + const mediaPath = '/media/uploads/' + req.file.filename; + await mediaLibrary.registerMediaAsset(deps.pool, Object.assign({}, req.file, { mediaType: mediaType }), mediaPath, deps.getAuditUserId(req)); + if (typeof recordRequestAuditEvent === 'function') { + await recordRequestAuditEvent(deps.pool, req, { + category: 'media-assets', + eventType: 'media.uploaded', + actorUserId: deps.getAuditUserId(req), + targetType: 'media-asset', + targetId: mediaPath, + targetLabel: req.file.originalname || req.file.filename, + details: { mediaPath: mediaPath, mediaType: mediaType, mimeType: mimeType, fileSize: Number(req.file.size) || 0 } + }); + } res.redirect('/media-library?message=' + encodeURIComponent('Media uploaded.')); } catch (error) { if (req.file && req.file.path) { @@ -206,8 +219,8 @@ module.exports = function registerMediaLibraryRoutes(app, deps) { } }); - async function deleteMediaAsset(assetId) { - const [rows] = await deps.pool.query('SELECT id, media_path FROM c_media_assets WHERE id = ?', [Number(assetId)]); + async function deleteMediaAsset(assetId, req) { + const [rows] = await deps.pool.query('SELECT id, media_path, original_name, media_type, file_size FROM c_media_assets WHERE id = ?', [Number(assetId)]); const asset = rows && rows[0]; if (!asset) { return 'missing'; @@ -218,6 +231,17 @@ module.exports = function registerMediaLibraryRoutes(app, deps) { } const filePath = path.join(path.dirname(deps.uploadDir), asset.media_path.replace(/^\/media\//, '')); await deps.pool.query('DELETE FROM c_media_assets WHERE id = ?', [asset.id]); + if (typeof recordRequestAuditEvent === 'function') { + await recordRequestAuditEvent(deps.pool, req, { + category: 'media-assets', + eventType: 'media.deleted', + actorUserId: deps.getAuditUserId(req), + targetType: 'media-asset', + targetId: asset.id, + targetLabel: asset.original_name || asset.media_path, + details: { mediaPath: asset.media_path, mediaType: asset.media_type || null, fileSize: Number(asset.file_size) || 0 } + }); + } await fs.promises.unlink(filePath).catch(function (error) { if (error && error.code !== 'ENOENT') { throw error; @@ -250,7 +274,7 @@ module.exports = function registerMediaLibraryRoutes(app, deps) { let deletedCount = 0; let inUseCount = 0; for (let index = 0; index < assetIds.length; index += 1) { - const result = await deleteMediaAsset(assetIds[index]); + const result = await deleteMediaAsset(assetIds[index], req); if (result === 'deleted') deletedCount += 1; if (result === 'in-use') inUseCount += 1; } @@ -263,7 +287,7 @@ module.exports = function registerMediaLibraryRoutes(app, deps) { app.post('/media-library/:id/delete', requireAccess, deps.requirePermission('media-library.delete'), async function (req, res, next) { try { - const result = await deleteMediaAsset(req.params.id); + const result = await deleteMediaAsset(req.params.id, req); if (result === 'missing') { return res.redirect('/media-library?message=' + encodeURIComponent('Media asset not found.')); } diff --git a/src/web/routes/settings/weather-icons.js b/src/web/routes/settings/weather-icons.js index 31269a9..f0d5d82 100644 --- a/src/web/routes/settings/weather-icons.js +++ b/src/web/routes/settings/weather-icons.js @@ -6,6 +6,7 @@ const { buildPagination } = require('#src/web/lib/pagination'); const { createSearchMatcher, getSearchQuery, getSortDirectionQuery, getSortQuery, parsePageNumber, sortRows } = require('#src/web/lib/list-query'); module.exports = function registerWeatherIconRoutes(app, deps) { + const recordRequestAuditEvent = deps.recordRequestAuditEvent; const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 5 * 1024 * 1024, files: library.CONDITIONS.length * 2, fields: 2 } }); const fields = library.CONDITIONS.flatMap(function (condition) { return [{ name: 'icon_' + condition.key, maxCount: 1 }, { name: 'night_icon_' + condition.key, maxCount: 1 }]; @@ -57,8 +58,17 @@ module.exports = function registerWeatherIconRoutes(app, deps) { async function save(req, res, next) { try { const previousSets = library.readWeatherIconSets(deps.mediaDir); - await library.saveWeatherIconSet(deps.mediaDir, req.body.name, Object.values(req.files || {}).flat(), req.params.id); + const uploadedFiles = Object.values(req.files || {}).flat(); + const savedSet = await library.saveWeatherIconSet(deps.mediaDir, req.body.name, uploadedFiles, req.params.id); req.weatherIconRemovalOperations = library.collectWeatherIconRemovalOperations(previousSets, library.readWeatherIconSets(deps.mediaDir)); + if (typeof recordRequestAuditEvent === 'function') { + await recordRequestAuditEvent(deps.pool, req, { + category: 'media-assets', eventType: req.params.id ? 'weather-icon-set.updated' : 'weather-icon-set.created', + actorUserId: req.currentUser && req.currentUser.id, targetType: 'weather-icon-set', + targetId: savedSet.id, targetLabel: savedSet.name, + details: { uploadedFileCount: uploadedFiles.length, iconCount: Object.keys(savedSet.icons || {}).length, nightIconCount: Object.keys(savedSet.nightIcons || {}).length } + }); + } } catch (error) { try { return await page(req, res, error.message, 400); } catch (pageError) { return next(pageError); } } @@ -74,6 +84,13 @@ module.exports = function registerWeatherIconRoutes(app, deps) { const enabled = String(req.body && req.body.enabled || '').trim() !== '0'; const updated = library.setWeatherIconSetEnabled(deps.mediaDir, req.params.id, enabled); if (!updated) return res.redirect('/settings/weather-icons?message=' + encodeURIComponent('That weather icon set was not found.')); + if (typeof recordRequestAuditEvent === 'function') { + await recordRequestAuditEvent(deps.pool, req, { + category: 'media-assets', eventType: enabled ? 'weather-icon-set.enabled' : 'weather-icon-set.disabled', + actorUserId: req.currentUser && req.currentUser.id, targetType: 'weather-icon-set', + targetId: updated.id, targetLabel: updated.name, details: { enabled: Boolean(enabled) } + }); + } await sync(); res.redirect('/settings/weather-icons?message=' + encodeURIComponent(enabled ? 'Weather icon set enabled.' : 'Weather icon set disabled.')); } catch (error) { next(error); } @@ -89,6 +106,13 @@ module.exports = function registerWeatherIconRoutes(app, deps) { }); if (used) return await page(req, res, 'That icon set is still used by one or more slides.', 400); const removed = library.deleteWeatherIconSet(deps.mediaDir, req.params.id); + if (typeof recordRequestAuditEvent === 'function') { + await recordRequestAuditEvent(deps.pool, req, { + category: 'media-assets', eventType: 'weather-icon-set.deleted', actorUserId: req.currentUser && req.currentUser.id, + targetType: 'weather-icon-set', targetId: removed.id, targetLabel: removed.name, + details: { iconCount: Object.keys(removed.icons || {}).length, nightIconCount: Object.keys(removed.nightIcons || {}).length } + }); + } await sync(library.collectWeatherIconRemovalOperations([removed], [])); res.redirect('/settings/weather-icons?message=' + encodeURIComponent('Weather icon set deleted.')); } catch (error) { next(error); } diff --git a/test/audit-log-settings.test.js b/test/audit-log-settings.test.js index d4e541b..ac0e003 100644 --- a/test/audit-log-settings.test.js +++ b/test/audit-log-settings.test.js @@ -37,6 +37,8 @@ const request = { test('audit categories include weather data sources', () => { assert.ok(AUDIT_CATEGORY_KEYS.includes('weather')); assert.equal(AUDIT_CATEGORY_LABELS.weather, 'Weather'); + assert.ok(AUDIT_CATEGORY_KEYS.includes('media-assets')); + assert.equal(AUDIT_CATEGORY_LABELS['media-assets'], 'Media Assets'); }); test('screen control auditing is disabled by default', () => { diff --git a/test/fonts-routes.test.js b/test/fonts-routes.test.js index 2b44dc2..0227e31 100644 --- a/test/fonts-routes.test.js +++ b/test/fonts-routes.test.js @@ -226,4 +226,52 @@ test('fonts page renders the upload card above the table card', () => { assert.match(html, /data-table-sort-key="file"/); assert.match(html, /data-table-sort-key="format"/); assert.match(html, /data-table-sort-key="status"/); +}); + +test('font upload, status changes, and deletion are audited', async () => { + const originals = { + loadFontLibrary: fontLibrary.loadFontLibrary, + addFontFile: fontLibrary.addFontFile, + setFontEnabled: fontLibrary.setFontEnabled, + deleteFontFile: fontLibrary.deleteFontFile + }; + const { app, handlers } = createHandlers(); + const events = []; + const font = { id: 'alpha.woff2', family: 'Alpha Sans', name: 'Alpha Sans', fileName: 'alpha.woff2', format: 'woff2', enabled: true }; + fontLibrary.loadFontLibrary = function () { return { fonts: [font], stylesheetHref: '' }; }; + fontLibrary.addFontFile = async function () { return { font: font, fonts: [font] }; }; + fontLibrary.setFontEnabled = async function (_dir, _id, enabled) { return Object.assign({}, font, { enabled: enabled }); }; + fontLibrary.deleteFontFile = async function () { return font; }; + + try { + registerFontRoutes(app, { + pool: { async query() { return [[{ match_count: 0 }]]; } }, + pages: { renderFontsPage() { return ''; } }, + upload: { single() { return function (_req, _res, next) { next(); }; } }, + uploadDir: 'unused', + backgroundTaskQueue: null, + recordRequestAuditEvent(_pool, _req, event) { events.push(event); } + }); + const response = { redirect() { return this; } }; + const next = function (error) { if (error) throw error; }; + const currentUser = { id: 12, permissionKeys: ['fonts.create', 'fonts.update', 'fonts.delete'] }; + + await handlers['POST /settings/fonts'].at(-1)( + { currentUser: currentUser, body: { font_family: 'Alpha Sans' }, file: { path: 'unused' } }, response, next + ); + await handlers['POST /settings/fonts/:id/toggle'].at(-1)( + { currentUser: currentUser, params: { id: font.id }, body: { enabled: '0' } }, response, next + ); + await handlers['POST /settings/fonts/:id/delete'].at(-1)( + { currentUser: currentUser, params: { id: font.id } }, response, next + ); + + assert.deepEqual(events.map(function (event) { return event.eventType; }), ['font.uploaded', 'font.disabled', 'font.deleted']); + assert.ok(events.every(function (event) { return event.category === 'media-assets' && event.actorUserId === 12; })); + } finally { + fontLibrary.loadFontLibrary = originals.loadFontLibrary; + fontLibrary.addFontFile = originals.addFontFile; + fontLibrary.setFontEnabled = originals.setFontEnabled; + fontLibrary.deleteFontFile = originals.deleteFontFile; + } }); \ No newline at end of file diff --git a/test/media-library.test.js b/test/media-library.test.js index df840f3..90a0d1a 100644 --- a/test/media-library.test.js +++ b/test/media-library.test.js @@ -5,6 +5,9 @@ const os = require('os'); const path = require('path'); const crypto = require('crypto'); const sharp = require('sharp'); +require('../src/common'); +const mediaLibrary = require('../src/web/lib/media/library'); +const registerMediaLibraryRoutes = require('../src/web/routes/settings/media-library'); const { normalizeMediaPath, createImageVariants, @@ -140,4 +143,72 @@ test('media library counts slide and template references', async () => { assert.equal(queries[0].params[0], '/media/uploads/shared.png'); assert.equal(queries[1].params[0], '/media/uploads/shared.png'); assert.match(queries[0].sql, /LOCATE/); + assert.match(queries[0].sql, /CAST\(COALESCE\(content_json, ''\) AS CHAR\)/); +}); + +test('media library upload and delete routes audit successful asset changes', async () => { + const originalCreateVariants = mediaLibrary.createImageVariants; + const originalRegisterAsset = mediaLibrary.registerMediaAsset; + const originalCountReferences = mediaLibrary.countMediaAssetReferences; + const originalRemoveVariants = mediaLibrary.removeImageVariants; + const root = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'pulse-media-audit-')); + const uploadDir = path.join(root, 'media', 'uploads'); + const filePath = path.join(uploadDir, 'photo.png'); + await fs.promises.mkdir(uploadDir, { recursive: true }); + await fs.promises.writeFile(filePath, 'image'); + const routeHandlers = {}; + const events = []; + const app = { + get(route, ...handlers) { routeHandlers['GET ' + route] = handlers; }, + post(route, ...handlers) { routeHandlers['POST ' + route] = handlers; } + }; + const pool = { + async query(sql) { + if (sql.includes('FROM o_app_settings')) return [[]]; + if (sql.includes('FROM c_media_assets')) { + return [[{ id: 9, media_path: '/media/uploads/photo.png', original_name: 'Photo.png', media_type: 'image', file_size: 5 }]]; + } + return [[]]; + } + }; + + mediaLibrary.createImageVariants = async function () {}; + mediaLibrary.registerMediaAsset = async function () {}; + mediaLibrary.countMediaAssetReferences = async function () { return 0; }; + mediaLibrary.removeImageVariants = async function () {}; + + try { + registerMediaLibraryRoutes(app, { + pool: pool, + pages: {}, + upload: { single() { return function (_req, _res, next) { next(); }; } }, + uploadDir: uploadDir, + getAuditUserId(req) { return req.currentUser.id; }, + recordRequestAuditEvent(_pool, _req, event) { events.push(event); }, + requirePermission() { return function (_req, _res, next) { next(); }; } + }); + const response = { redirect(value) { this.location = value; return this; } }; + const next = function (error) { if (error) throw error; }; + const request = { + currentUser: { id: 4 }, + body: {}, + file: { path: filePath, filename: 'photo.png', originalname: 'Photo.png', mimetype: 'image/png', size: 5 } + }; + + await routeHandlers['POST /media-library'].at(-1)(request, response, next); + await routeHandlers['POST /media-library/:id/delete'].at(-1)( + { currentUser: { id: 4 }, params: { id: '9' } }, response, next + ); + + assert.deepEqual(events.map(function (event) { return event.eventType; }), ['media.uploaded', 'media.deleted']); + assert.equal(events[0].targetLabel, 'Photo.png'); + assert.equal(events[1].targetId, 9); + assert.ok(events.every(function (event) { return event.category === 'media-assets' && event.actorUserId === 4; })); + } finally { + mediaLibrary.createImageVariants = originalCreateVariants; + mediaLibrary.registerMediaAsset = originalRegisterAsset; + mediaLibrary.countMediaAssetReferences = originalCountReferences; + mediaLibrary.removeImageVariants = originalRemoveVariants; + await fs.promises.rm(root, { recursive: true, force: true }); + } }); diff --git a/test/timetable-region-timezone.test.js b/test/timetable-region-timezone.test.js index f88a7c1..455e4d9 100644 --- a/test/timetable-region-timezone.test.js +++ b/test/timetable-region-timezone.test.js @@ -83,6 +83,91 @@ function loadScheduleModule(overrides) { return registry.get('timetable'); } +test('timetable date ranges agree in preview and player, before limiting items', () => { + const previewModule = loadScheduleModule(); + const groups = [{ id: 1, entries: [ + { id: 1, title: 'Early', start_datetime: '2099-08-20T09:00:00Z' }, + { id: 2, title: 'Middle', start_datetime: '2099-08-20T10:00:00Z' }, + { id: 3, title: 'Late', start_datetime: '2099-08-20T11:00:00Z' } + ] }]; + let playerModule; + const sandbox = { + window: { + pulsePlayerRegionTypes: { register(type, module) { playerModule = module; } }, + placeholderUtils: { + resolvePlaceholderExpression(entry, expression) { return entry[expression]; }, + formatPlaceholderValue(value) { return value; } + } + }, + initialData: { timetableGroups: groups }, + escapeHtml: String, + sanitizeRichText: String + }; + vm.runInNewContext(fs.readFileSync(require.resolve('../src/player/regions/timetable.js'), 'utf8'), sandbox); + const cases = [ + [{}, ['Early', 'Middle', 'Late']], + [{ date_filter: 'all', date_from: '2099-08-20T10:00:00Z' }, ['Early', 'Middle', 'Late']], + [{ date_filter: 'between', date_from: '2099-08-20T10:00:00Z' }, ['Middle', 'Late']], + [{ date_filter: 'between', date_to: '2099-08-20T10:00:00Z' }, ['Early', 'Middle']], + [{ date_filter: 'between', date_from: '2099-08-20T12:00:00+02:00', date_to: '2099-08-20T10:00:00Z' }, ['Middle']], + [{ date_filter: 'between', date_from: '2099-08-20T10:00:00Z', max_items: 1 }, ['Middle']], + [{ date_filter: 'between', date_from: '2099-08-21T00:00:00Z' }, []], + [{ date_filter: 'after', date_from: '2099-08-20T10:00:00Z', date_to: '2099-08-20T09:00:00Z' }, ['Middle', 'Late']], + [{ date_filter: 'before', date_to: '2099-08-20T10:00:00Z', date_from: '2099-08-20T11:00:00Z' }, ['Early', 'Middle']] + ]; + for (const [range, titles] of cases) { + const config = { value: '{{title}}', timetable_group_id: 1, display_mode: 'both', max_items: 5, ...range }; + const region = { id: 47, baseStyle: '' }; + const preview = previewModule.renderPreview(region, config, { timetableGroups: groups }); + const player = playerModule.renderRegion(region, config); + for (const title of ['Early', 'Middle', 'Late']) { + assert.equal(preview.includes(title), titles.includes(title), 'preview ' + JSON.stringify(range)); + assert.equal(player.includes(title), titles.includes(title), 'player ' + JSON.stringify(range)); + } + } + + groups[0].entries = [{ id: 4, title: 'Live', start_datetime: new Date(Date.now() - 60000).toISOString(), end_datetime: new Date(Date.now() + 60000).toISOString() }]; + const config = { value: '{{title}}', timetable_group_id: 1, display_mode: 'upcoming', date_filter: 'between', date_from: '2099-08-20T00:00:00Z' }; + assert.doesNotMatch(previewModule.renderPreview({}, config, { timetableGroups: groups }), /Live/); + assert.doesNotMatch(playerModule.renderRegion({}, config), /Live/); +}); + +test('timetable date controls default to all and pass unsaved date ranges to preview', () => { + const module = loadScheduleModule(); + const region = { id: 47, region_key: 'timetable', label: 'Timetable' }; + const html = module.renderEditorCard({ region, current: {}, timetableGroups: [] }); + assert.match(html, /