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

This commit is contained in:
lzstealth committed 2026-10-17 16:01:00 +01:00
1 parent 64aeb1b47e
commit 05c9341b08
25 files changed
+595 -31

No files matched your search

+1
View File
@@ -7,3 +7,4 @@
!src/** !src/**
!scripts/ !scripts/
!scripts/** !scripts/**
/local-examples/
+1
View File
@@ -1,4 +1,5 @@
node_modules/ node_modules/
/local-examples/
media/ media/
!src/web/lib/media/ !src/web/lib/media/
!src/web/lib/media/** !src/web/lib/media/**
+11
View File
@@ -2,6 +2,17 @@
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.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 ## 2.15.3 - 2026-10-15
### Fixed ### Fixed
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "pulse-signage-player", "name": "pulse-signage-player",
"version": "2.15.3", "version": "2.15.4",
"private": false, "private": false,
"description": "Pulse Signage player application bundle", "description": "Pulse Signage player application bundle",
"engines": { "engines": {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "pulse-signage-web", "name": "pulse-signage-web",
"version": "2.15.3", "version": "2.15.4",
"private": false, "private": false,
"description": "Pulse Signage web and bridge application bundle", "description": "Pulse Signage web and bridge application bundle",
"engines": { "engines": {
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "pulse-signage", "name": "pulse-signage",
"version": "2.15.3", "version": "2.15.4",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "pulse-signage", "name": "pulse-signage",
"version": "2.15.3", "version": "2.15.4",
"dependencies": { "dependencies": {
"@sparticuz/chromium": "^149.0.0", "@sparticuz/chromium": "^149.0.0",
"animate.css": "^4.1.1", "animate.css": "^4.1.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "pulse-signage", "name": "pulse-signage",
"version": "2.15.3", "version": "2.15.4",
"private": false, "private": false,
"description": "Pulse Signage application with MySQL and media storage", "description": "Pulse Signage application with MySQL and media storage",
"engines": { "engines": {
+2
View File
@@ -17,6 +17,7 @@ const AUDIT_EVENT_CATEGORIES = Object.freeze({
RSS_FEEDS: 'rss-feeds', RSS_FEEDS: 'rss-feeds',
TIMETABLES: 'timetables', TIMETABLES: 'timetables',
WEATHER: 'weather', WEATHER: 'weather',
MEDIA_ASSETS: 'media-assets',
SCREEN_CONTROLS: 'screen-controls' SCREEN_CONTROLS: 'screen-controls'
}); });
const AUDIT_CATEGORY_KEYS = Object.freeze(Object.values(AUDIT_EVENT_CATEGORIES)); 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', 'rss-feeds': 'RSS Feeds',
timetables: 'Timetables', timetables: 'Timetables',
weather: 'Weather', weather: 'Weather',
'media-assets': 'Media Assets',
'screen-controls': 'Screen Controls' 'screen-controls': 'Screen Controls'
}); });
const SCREEN_CONTROL_COMMAND_KEYS = Object.freeze(['pause', 'blackout', 'reload', 'navigation', 'moveclient', 'setclientname']); const SCREEN_CONTROL_COMMAND_KEYS = Object.freeze(['pause', 'blackout', 'reload', 'navigation', 'moveclient', 'setclientname']);
+18
View File
@@ -417,6 +417,24 @@ async function buildTemplateContent(pool, template, body, filesByField, existing
delete generic.timetable_display_mode; delete generic.timetable_display_mode;
delete generic.timetable_max_items; 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.text = normalizedText;
generic.value = normalizedText; generic.value = normalizedText;
generic.type = region.region_type; generic.type = region.region_type;
+11 -4
View File
@@ -63,9 +63,16 @@ function isLive(entry, now) {
return Boolean(start && end && now >= start && now < end); 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 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 leftStart = toDate(left && left.start_datetime);
var rightStart = toDate(right && right.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); 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') { if (!entries.length && mode === 'upcoming') {
entries = getEntries(groupId, groups).filter(function (entry) { entries = candidates.filter(function (entry) {
return isLive(entry, now); return isLive(entry, now);
}); });
} }
@@ -135,7 +142,7 @@ function renderRegion(region, regionContent) {
var displayMode = regionContent && regionContent.display_mode ? regionContent.display_mode : 'upcoming'; var displayMode = regionContent && regionContent.display_mode ? regionContent.display_mode : 'upcoming';
var maxItems = regionContent && regionContent.max_items !== undefined ? regionContent.max_items : 5; var maxItems = regionContent && regionContent.max_items !== undefined ? regionContent.max_items : 5;
var group = getGroupById(groupId, groups); 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 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 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)); var height = Math.max(1, Math.round(Number(region && region.pixelHeight ? region.pixelHeight : 0) || 1));
+1
View File
@@ -124,6 +124,7 @@ async function start() {
// Middleware and route registration. // Middleware and route registration.
registerMiddleware(app, { registerMiddleware(app, {
pool: pool, pool: pool,
pages: pages,
loadCurrentUser: loadCurrentUser, loadCurrentUser: loadCurrentUser,
requireAuth: requireAuth, requireAuth: requireAuth,
MEDIA_DIR: webConfig.mediaDir, MEDIA_DIR: webConfig.mediaDir,
+1 -1
View File
@@ -250,7 +250,7 @@ async function countMediaAssetReferences(pool, mediaPath) {
const [slideRows] = await pool.query( const [slideRows] = await pool.query(
`SELECT COUNT(*) AS ref_count `SELECT COUNT(*) AS ref_count
FROM c_slides FROM c_slides
WHERE LOCATE(?, COALESCE(content_json, '')) > 0`, WHERE LOCATE(?, CAST(COALESCE(content_json, '') AS CHAR)) > 0`,
[normalizedPath] [normalizedPath]
); );
const [templateRows] = await pool.query( const [templateRows] = await pool.query(
+3
View File
@@ -2,6 +2,7 @@
const express = require('express'); const express = require('express');
const fs = require('fs'); const fs = require('fs');
const registerErrorPageMiddleware = require('#src/web/middleware/error-page');
module.exports = function registerMiddleware(app, deps) { module.exports = function registerMiddleware(app, deps) {
const pool = deps.pool; const pool = deps.pool;
@@ -28,6 +29,8 @@ module.exports = function registerMiddleware(app, deps) {
} }
}); });
registerErrorPageMiddleware(app, deps.pages);
app.use(function (req, res, next) { 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') { 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(); return next();
+48
View File
@@ -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*</.test(body);
if (statusCode >= 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();
});
};
+93 -14
View File
@@ -98,15 +98,40 @@
return Boolean(start && now < start); 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) { function isLive(entry, now) {
var start = toDate(entry && entry.start_datetime); var start = toDate(entry && entry.start_datetime);
var end = toDate(entry && entry.end_datetime); var end = toDate(entry && entry.end_datetime);
return Boolean(start && end && now >= start && now < end); 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 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 leftStart = toDate(left && left.start_datetime);
var rightStart = toDate(right && right.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); 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') { if (!entries.length && mode === 'upcoming') {
entries = getEntries(groupId, groups).filter(function (entry) { entries = candidates.filter(function (entry) {
return isLive(entry, now); return isLive(entry, now);
}); });
} }
@@ -167,6 +192,9 @@
display_mode: String(current.display_mode || current.timetable_display_mode || 'upcoming').trim().toLowerCase() || 'upcoming', 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 : ''), 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)), 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_family: current.font_family || region.font_family || getDefaultStyle().font_family,
font_size: current.font_size || region.font_size || getDefaultStyle().font_size, font_size: current.font_size || region.font_size || getDefaultStyle().font_size,
font_color: current.font_color || region.font_color || getDefaultStyle().font_color 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 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 maxItems = Math.max(1, Number(timetableMaxItemsInput ? timetableMaxItemsInput.value : current.max_items || current.timetable_max_items || 5));
var group = getGroupById(groupId, timetableGroups); var group = getGroupById(groupId, timetableGroups);
var entries = getVisibleEntries(groupId, displayMode, maxItems, timetableGroups); var range = getDateRangeConfig(regionId, card, current);
if (!entries.length) { var entries = getVisibleEntries(groupId, displayMode, maxItems, timetableGroups, range);
if (!entries.length && range.date_filter === 'all') {
entries = getEntries(groupId, timetableGroups).slice(0, maxItems); 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 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 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 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 currentGroup = getGroupById(currentGroupId, timetableGroups);
var currentEntries = getVisibleEntries(currentGroupId, currentDisplayMode, currentMaxItems, timetableGroups); var currentEntries = getVisibleEntries(currentGroupId, currentDisplayMode, currentMaxItems, timetableGroups, current);
if (!currentEntries.length) { if (!currentEntries.length && dateFilter === 'all') {
currentEntries = getEntries(currentGroupId, timetableGroups).slice(0, currentMaxItems); currentEntries = getEntries(currentGroupId, timetableGroups).slice(0, currentMaxItems);
} }
var groupOptions = timetableGroups.map(function (group) { var groupOptions = timetableGroups.map(function (group) {
@@ -295,7 +325,7 @@
'<input type="hidden" name="region_text_' + region.id + '" value="' + escapeHtml(currentValue) + '" />' + '<input type="hidden" name="region_text_' + region.id + '" value="' + escapeHtml(currentValue) + '" />' +
'</div>' + '</div>' +
'<div class="row g-3">' + '<div class="row g-3">' +
'<div class="col-12 col-lg-6">' + '<div class="col-12 col-lg-4">' +
'<label class="form-label" for="region_timetable_group_id_' + region.id + '">Timetable group</label>' + '<label class="form-label" for="region_timetable_group_id_' + region.id + '">Timetable group</label>' +
'<select id="region_timetable_group_id_' + region.id + '" name="region_timetable_group_id_' + region.id + '" class="form-select">' + '<select id="region_timetable_group_id_' + region.id + '" name="region_timetable_group_id_' + region.id + '" class="form-select">' +
'<option value="">Select a group</option>' + '<option value="">Select a group</option>' +
@@ -310,12 +340,34 @@
'<option value="both"' + (currentDisplayMode === 'both' ? ' selected' : '') + '>Both</option>' + '<option value="both"' + (currentDisplayMode === 'both' ? ' selected' : '') + '>Both</option>' +
'</select>' + '</select>' +
'</div>' + '</div>' +
'<div class="col-6 col-lg-3">' + '<div class="col-6 col-lg-2">' +
'<label class="form-label" for="region_timetable_max_items_' + region.id + '">Max items</label>' + '<label class="form-label" for="region_timetable_max_items_' + region.id + '">Max items</label>' +
'<input id="region_timetable_max_items_' + region.id + '" type="number" min="1" step="1" name="region_timetable_max_items_' + region.id + '" class="form-control" value="' + escapeHtml(currentMaxItems || 5) + '" />' + '<input id="region_timetable_max_items_' + region.id + '" type="number" min="1" step="1" name="region_timetable_max_items_' + region.id + '" class="form-control" value="' + escapeHtml(currentMaxItems || 5) + '" />' +
'</div>' + '</div>' +
'<div class="col-12 col-lg-3">' +
'<label class="form-label" for="region_date_filter_' + region.id + '">Date filter</label>' +
'<select id="region_date_filter_' + region.id + '" name="region_date_filter_' + region.id + '" class="form-select">' +
'<option value="all"' + (dateFilter === 'all' ? ' selected' : '') + '>All</option>' +
'<option value="between"' + (dateFilter === 'between' ? ' selected' : '') + '>Between Dates</option>' +
'<option value="before"' + (dateFilter === 'before' ? ' selected' : '') + '>Before Date</option>' +
'<option value="after"' + (dateFilter === 'after' ? ' selected' : '') + '>After Date</option>' +
'</select>' +
'</div>' +
'</div>' +
'<div data-timetable-date-range' + (dateFilter === 'all' ? ' hidden' : '') + '>' +
'<div class="row g-3">' +
['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 '<div class="col-12 col-md-6" data-timetable-boundary' + (!enabled ? ' hidden' : '') + '>' +
'<label class="form-label" for="region_' + field + '_' + region.id + '">' + label + '</label>' +
'<input id="region_' + field + '_' + region.id + '" type="datetime-local" step="1" class="form-control" data-timetable-date="' + field + '" value="' + escapeHtml(toLocalInput(current[field])) + '"' + (!enabled ? ' disabled' : '') + ' />' +
'<input type="hidden" name="region_' + field + '_' + region.id + '" value="' + escapeHtml(current[field] || '') + '" />' +
'</div>';
}).join('') +
'</div>' +
'</div>' + '</div>' +
'<div class="text-body-secondary small">Use the selected group and display mode to choose which timetable entry fields are available.</div>' +
'<div class="api-region-placeholder-section schedule-placeholder-section" data-schedule-placeholder-chips>' + '<div class="api-region-placeholder-section schedule-placeholder-section" data-schedule-placeholder-chips>' +
'<div class="api-region-placeholder-title api-region-placeholder-title-help d-flex align-items-center gap-2">Available placeholders ' + renderPlaceholderInfo(region.id) + '</div>' + '<div class="api-region-placeholder-title api-region-placeholder-title-help d-flex align-items-center gap-2">Available placeholders ' + renderPlaceholderInfo(region.id) + '</div>' +
'<div class="d-flex flex-wrap gap-2">' + renderSchedulePlaceholderChips(currentGroup, currentEntries) + '</div>' + '<div class="d-flex flex-wrap gap-2">' + renderSchedulePlaceholderChips(currentGroup, currentEntries) + '</div>' +
@@ -350,24 +402,50 @@
panel.innerHTML = renderSchedulePlaceholderChips(current.group, current.entries); 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) { if (!window.__scheduleRegionPlaceholderRefreshInstalled) {
window.__scheduleRegionPlaceholderRefreshInstalled = true; window.__scheduleRegionPlaceholderRefreshInstalled = true;
document.addEventListener('change', function (event) { function handleTimetableInput(event) {
var target = event.target; var target = event.target;
if (!target || !target.closest) { if (!target || !target.closest) {
return; 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; return;
} }
var card = target.closest('[data-region-id]'); var card = target.closest('[data-region-id]');
if (card) { if (card) {
updateDateRange(card);
updateSchedulePlaceholderPanel(card); updateSchedulePlaceholderPanel(card);
} }
}, true); }
document.addEventListener('input', handleTimetableInput, true);
document.addEventListener('change', handleTimetableInput, true);
} }
function buildEditorCardContext(context) { function buildEditorCardContext(context) {
@@ -410,6 +488,7 @@
tz_long: timezoneValues.tz_long, tz_long: timezoneValues.tz_long,
timeZone: timezoneValues.tz_long, timeZone: timezoneValues.tz_long,
max_items: timetableMaxItemsInput ? timetableMaxItemsInput.value || current.max_items : current.max_items, max_items: timetableMaxItemsInput ? timetableMaxItemsInput.value || current.max_items : current.max_items,
...getDateRangeConfig(region.id, card, current),
existingContent: existingContent || {}, existingContent: existingContent || {},
timetableGroups: Array.isArray(timetableGroups) ? timetableGroups : [] timetableGroups: Array.isArray(timetableGroups) ? timetableGroups : []
}; };
@@ -423,7 +502,7 @@
var displayMode = regionContent && regionContent.display_mode ? regionContent.display_mode : 'upcoming'; var displayMode = regionContent && regionContent.display_mode ? regionContent.display_mode : 'upcoming';
var maxItems = regionContent && regionContent.max_items !== undefined ? regionContent.max_items : 5; var maxItems = regionContent && regionContent.max_items !== undefined ? regionContent.max_items : 5;
var group = getGroupById(groupId, groups); var group = getGroupById(groupId, groups);
var entries = getVisibleEntries(groupId, displayMode, maxItems, groups); var entries = getVisibleEntries(groupId, displayMode, maxItems, groups, regionContent);
if (!value) { if (!value) {
return '<div class="slide-preview-region timetable" style="width:100%;height:100%;overflow:hidden;' + (style.font_family ? 'font-family:' + escapeHtml(style.font_family) + ';' : '') + (style.font_size ? 'font-size:' + Math.max(1, Math.round(Number(style.font_size))) + 'px;' : '') + (style.font_color ? 'color:' + escapeHtml(style.font_color) + ';' : '') + '"></div>'; return '<div class="slide-preview-region timetable" style="width:100%;height:100%;overflow:hidden;' + (style.font_family ? 'font-family:' + escapeHtml(style.font_family) + ';' : '') + (style.font_size ? 'font-size:' + Math.max(1, Math.round(Number(style.font_size))) + 'px;' : '') + (style.font_color ? 'color:' + escapeHtml(style.font_color) + ';' : '') + '"></div>';
+2
View File
@@ -334,6 +334,7 @@ function registerSettingsAndContentRoutes(app, deps) {
uploadDir: deps.uploadDir, uploadDir: deps.uploadDir,
backgroundTaskQueue: deps.backgroundTaskQueue, backgroundTaskQueue: deps.backgroundTaskQueue,
setAuthMessageCookie: deps.setAuthMessageCookie, setAuthMessageCookie: deps.setAuthMessageCookie,
recordRequestAuditEvent: deps.recordRequestAuditEvent,
requirePermission: deps.requirePermission requirePermission: deps.requirePermission
}); });
@@ -344,6 +345,7 @@ function registerSettingsAndContentRoutes(app, deps) {
uploadDir: deps.uploadDir, uploadDir: deps.uploadDir,
getAuditUserId: deps.getAuditUserId, getAuditUserId: deps.getAuditUserId,
setAuthMessageCookie: deps.setAuthMessageCookie, setAuthMessageCookie: deps.setAuthMessageCookie,
recordRequestAuditEvent: deps.recordRequestAuditEvent,
requirePermission: deps.requirePermission, requirePermission: deps.requirePermission,
syncPlaylistUploadsOnChange: deps.syncPlaylistUploadsOnChange syncPlaylistUploadsOnChange: deps.syncPlaylistUploadsOnChange
}); });
+24
View File
@@ -123,6 +123,7 @@ module.exports = function registerFontRoutes(app, deps) {
const uploadDir = deps.uploadDir; const uploadDir = deps.uploadDir;
const backgroundTaskQueue = deps.backgroundTaskQueue; const backgroundTaskQueue = deps.backgroundTaskQueue;
const setAuthMessageCookie = deps.setAuthMessageCookie; const setAuthMessageCookie = deps.setAuthMessageCookie;
const recordRequestAuditEvent = deps.recordRequestAuditEvent;
const FONT_FAMILY_MAX_LENGTH = 255; 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)); 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') { if (backgroundTaskQueue && typeof backgroundTaskQueue.enqueueTask === 'function') {
await backgroundTaskQueue.enqueueTask({ await backgroundTaskQueue.enqueueTask({
key: `font-sync:${result && result.font && result.font.id ? result.font.id : Date.now()}`, 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.')); 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') { if (backgroundTaskQueue && typeof backgroundTaskQueue.enqueueTask === 'function') {
await backgroundTaskQueue.enqueueTask({ await backgroundTaskQueue.enqueueTask({
key: `font-sync:toggle:${updated && updated.id ? updated.id : Date.now()}`, 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.')); 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') { if (backgroundTaskQueue && typeof backgroundTaskQueue.enqueueTask === 'function') {
await backgroundTaskQueue.enqueueTask({ await backgroundTaskQueue.enqueueTask({
key: `font-sync:delete:${removed && removed.id ? removed.id : Date.now()}`, key: `font-sync:delete:${removed && removed.id ? removed.id : Date.now()}`,
+29 -5
View File
@@ -117,6 +117,7 @@ function filterAndSortAssets(assets, query) {
module.exports = function registerMediaLibraryRoutes(app, deps) { module.exports = function registerMediaLibraryRoutes(app, deps) {
const requireAccess = requireMediaAccess(deps.setAuthMessageCookie); const requireAccess = requireMediaAccess(deps.setAuthMessageCookie);
const recordRequestAuditEvent = deps.recordRequestAuditEvent;
const pageSize = 24; const pageSize = 24;
if (!deps.pool || !deps.pages || !deps.upload || !deps.uploadDir) { if (!deps.pool || !deps.pages || !deps.upload || !deps.uploadDir) {
throw new Error('registerMediaLibraryRoutes requires pool, pages, upload, and 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.')); return res.redirect('/media-library?message=' + encodeURIComponent('File must be ' + limitLabel + ' or smaller.'));
} }
await mediaLibrary.createImageVariants(req.file); 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.')); res.redirect('/media-library?message=' + encodeURIComponent('Media uploaded.'));
} catch (error) { } catch (error) {
if (req.file && req.file.path) { if (req.file && req.file.path) {
@@ -206,8 +219,8 @@ module.exports = function registerMediaLibraryRoutes(app, deps) {
} }
}); });
async function deleteMediaAsset(assetId) { async function deleteMediaAsset(assetId, req) {
const [rows] = await deps.pool.query('SELECT id, media_path FROM c_media_assets WHERE id = ?', [Number(assetId)]); 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]; const asset = rows && rows[0];
if (!asset) { if (!asset) {
return 'missing'; 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\//, '')); 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]); 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) { await fs.promises.unlink(filePath).catch(function (error) {
if (error && error.code !== 'ENOENT') { if (error && error.code !== 'ENOENT') {
throw error; throw error;
@@ -250,7 +274,7 @@ module.exports = function registerMediaLibraryRoutes(app, deps) {
let deletedCount = 0; let deletedCount = 0;
let inUseCount = 0; let inUseCount = 0;
for (let index = 0; index < assetIds.length; index += 1) { 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 === 'deleted') deletedCount += 1;
if (result === 'in-use') inUseCount += 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) { app.post('/media-library/:id/delete', requireAccess, deps.requirePermission('media-library.delete'), async function (req, res, next) {
try { try {
const result = await deleteMediaAsset(req.params.id); const result = await deleteMediaAsset(req.params.id, req);
if (result === 'missing') { if (result === 'missing') {
return res.redirect('/media-library?message=' + encodeURIComponent('Media asset not found.')); return res.redirect('/media-library?message=' + encodeURIComponent('Media asset not found.'));
} }
+25 -1
View File
@@ -6,6 +6,7 @@ const { buildPagination } = require('#src/web/lib/pagination');
const { createSearchMatcher, getSearchQuery, getSortDirectionQuery, getSortQuery, parsePageNumber, sortRows } = require('#src/web/lib/list-query'); const { createSearchMatcher, getSearchQuery, getSortDirectionQuery, getSortQuery, parsePageNumber, sortRows } = require('#src/web/lib/list-query');
module.exports = function registerWeatherIconRoutes(app, deps) { 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 upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 5 * 1024 * 1024, files: library.CONDITIONS.length * 2, fields: 2 } });
const fields = library.CONDITIONS.flatMap(function (condition) { const fields = library.CONDITIONS.flatMap(function (condition) {
return [{ name: 'icon_' + condition.key, maxCount: 1 }, { name: 'night_icon_' + condition.key, maxCount: 1 }]; 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) { async function save(req, res, next) {
try { try {
const previousSets = library.readWeatherIconSets(deps.mediaDir); 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)); 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) { } catch (error) {
try { return await page(req, res, error.message, 400); } catch (pageError) { return next(pageError); } 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 enabled = String(req.body && req.body.enabled || '').trim() !== '0';
const updated = library.setWeatherIconSetEnabled(deps.mediaDir, req.params.id, enabled); 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 (!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(); await sync();
res.redirect('/settings/weather-icons?message=' + encodeURIComponent(enabled ? 'Weather icon set enabled.' : 'Weather icon set disabled.')); res.redirect('/settings/weather-icons?message=' + encodeURIComponent(enabled ? 'Weather icon set enabled.' : 'Weather icon set disabled.'));
} catch (error) { next(error); } } 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); 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); 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], [])); await sync(library.collectWeatherIconRemovalOperations([removed], []));
res.redirect('/settings/weather-icons?message=' + encodeURIComponent('Weather icon set deleted.')); res.redirect('/settings/weather-icons?message=' + encodeURIComponent('Weather icon set deleted.'));
} catch (error) { next(error); } } catch (error) { next(error); }
+2
View File
@@ -37,6 +37,8 @@ const request = {
test('audit categories include weather data sources', () => { test('audit categories include weather data sources', () => {
assert.ok(AUDIT_CATEGORY_KEYS.includes('weather')); assert.ok(AUDIT_CATEGORY_KEYS.includes('weather'));
assert.equal(AUDIT_CATEGORY_LABELS.weather, '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', () => { test('screen control auditing is disabled by default', () => {
+48
View File
@@ -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="file"/);
assert.match(html, /data-table-sort-key="format"/); assert.match(html, /data-table-sort-key="format"/);
assert.match(html, /data-table-sort-key="status"/); 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;
}
}); });
+71
View File
@@ -5,6 +5,9 @@ const os = require('os');
const path = require('path'); const path = require('path');
const crypto = require('crypto'); const crypto = require('crypto');
const sharp = require('sharp'); 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 { const {
normalizeMediaPath, normalizeMediaPath,
createImageVariants, 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[0].params[0], '/media/uploads/shared.png');
assert.equal(queries[1].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, /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 });
}
}); });
+85
View File
@@ -83,6 +83,91 @@ function loadScheduleModule(overrides) {
return registry.get('timetable'); 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, /<option value="all" selected>All<\/option>/);
assert.match(html, /data-timetable-date-range hidden/);
assert.match(html, /data-timetable-date="date_from"[^>]* disabled/);
const saved = { date_filter: 'between', date_from: '2099-08-20T10:00:00.000Z', date_to: '' };
const enabled = module.renderEditorCard({ region, current: saved, timetableGroups: [] });
assert.match(enabled, /<option value="between" selected>Between Dates<\/option>/);
assert.doesNotMatch(enabled, /data-timetable-date-range hidden/);
assert.doesNotMatch(enabled, /data-timetable-date="date_from"[^>]* disabled/);
const config = module.getCurrentConfig(region, { timetable: saved });
assert.equal(config.date_from, saved.date_from);
const fields = {
'[name="region_date_filter_47"]': { value: 'between' },
'[name="region_date_from_47"]': { value: '' },
'[name="region_date_to_47"]': { value: '2099-08-21T10:00:00.000Z' }
};
const context = module.buildPreviewRenderContext(region, { querySelector(selector) { return fields[selector] || null; } }, { timetable: saved }, []);
assert.equal(context.date_filter, 'between');
assert.equal(context.date_from, '');
assert.equal(context.date_to, fields['[name="region_date_to_47"]'].value);
assert.doesNotMatch(html, /Use the selected group and display mode/);
for (const mode of ['before', 'after']) {
const current = { ...saved, date_filter: mode };
const markup = module.renderEditorCard({ region, current, timetableGroups: [] });
assert.match(markup, new RegExp('<option value="' + mode + '" selected>'));
assert.equal(module.getCurrentConfig(region, { timetable: current }).date_filter, mode);
const activeField = mode === 'after' ? 'date_from' : 'date_to';
const inactiveField = mode === 'after' ? 'date_to' : 'date_from';
assert.doesNotMatch(markup, new RegExp('data-timetable-date="' + activeField + '"[^>]* disabled'));
assert.match(markup, new RegExp('data-timetable-date="' + inactiveField + '"[^>]* disabled'));
}
});
test('timetable region editor lists timezone transforms in helper text', () => { test('timetable region editor lists timezone transforms in helper text', () => {
const module = loadScheduleModule(); const module = loadScheduleModule();
const html = module.renderEditorCard({ const html = module.renderEditorCard({
+44 -1
View File
@@ -25,7 +25,10 @@ test('buildSlidePayload normalizes timetable region fields', async () => {
region_text_47: '<p>{{title}}</p>', region_text_47: '<p>{{title}}</p>',
region_timetable_group_id_47: '1', region_timetable_group_id_47: '1',
region_timetable_display_mode_47: 'current', region_timetable_display_mode_47: 'current',
region_timetable_max_items_47: '7' region_timetable_max_items_47: '7',
region_date_filter_47: 'between',
region_date_from_47: '2099-08-20T12:00:00+02:00',
region_date_to_47: ''
}, },
files: [] files: []
}, null); }, null);
@@ -37,10 +40,50 @@ test('buildSlidePayload normalizes timetable region fields', async () => {
assert.equal(content.timetable.timetable_group_id, '1'); assert.equal(content.timetable.timetable_group_id, '1');
assert.equal(content.timetable.display_mode, 'current'); assert.equal(content.timetable.display_mode, 'current');
assert.equal(content.timetable.max_items, '7'); assert.equal(content.timetable.max_items, '7');
assert.equal(content.timetable.date_filter, 'between');
assert.equal(content.timetable.date_from, '2099-08-20T10:00:00.000Z');
assert.equal(content.timetable.date_to, '');
assert.equal(Object.prototype.hasOwnProperty.call(content.timetable, 'timetable_display_mode'), false); assert.equal(Object.prototype.hasOwnProperty.call(content.timetable, 'timetable_display_mode'), false);
assert.equal(Object.prototype.hasOwnProperty.call(content.timetable, 'timetable_max_items'), false); assert.equal(Object.prototype.hasOwnProperty.call(content.timetable, 'timetable_max_items'), false);
}); });
test('timetable date settings persist, clear, default to all, and reject invalid ranges', async () => {
const pool = {
async query(sql) {
if (sql.includes('FROM c_templates st')) {
return [[{ id: 9, name: 'Template 9' }]];
}
if (sql.includes('FROM c_template_regions')) {
return [[{ id: 47, region_key: 'timetable', region_type: 'timetable' }]];
}
return [[]];
}
};
const existing = { content_json: JSON.stringify({ timetable: { type: 'timetable', date_filter: 'between', date_from: '2099-08-20T10:00:00.000Z', date_to: '2099-08-20T11:00:00.000Z' } }) };
async function save(fields, slide) {
const payload = await buildSlidePayload(pool, { body: { title: 'Timetable', template_id: '9', ...fields }, files: [] }, slide);
return JSON.parse(payload.contentJson).timetable;
}
assert.equal((await save({})).date_filter, 'all');
assert.equal((await save({}, existing)).date_from, '2099-08-20T10:00:00.000Z');
assert.equal((await save({ region_date_from_47: '' }, existing)).date_from, '');
const all = await save({ region_date_filter_47: 'all' }, existing);
assert.equal(all.date_filter, 'all');
assert.equal(all.date_to, '2099-08-20T11:00:00.000Z');
for (const mode of ['before', 'after']) {
const saved = await save({ region_date_filter_47: mode }, existing);
assert.equal(saved.date_filter, mode);
assert.equal(saved.date_from, '2099-08-20T10:00:00.000Z');
assert.equal(saved.date_to, '2099-08-20T11:00:00.000Z');
}
assert.equal((await save({ region_date_filter_47: 'after', region_date_to_47: '2099-08-19T00:00:00Z' }, existing)).date_filter, 'after');
assert.equal((await save({ region_date_filter_47: 'before', region_date_from_47: '2099-08-21T00:00:00Z' }, existing)).date_filter, 'before');
await assert.rejects(save({ region_date_filter_47: 'before', region_date_to_47: 'invalid' }), { statusCode: 400 });
await assert.rejects(save({ region_date_filter_47: 'after', region_date_from_47: 'invalid' }), { statusCode: 400 });
await assert.rejects(save({ region_date_filter_47: 'between', region_date_from_47: 'invalid' }), { statusCode: 400 });
await assert.rejects(save({ region_date_from_47: '2099-08-21T00:00:00Z' }, existing), { statusCode: 400 });
});
test('buildSlidePayload preserves safe image markup in text regions', async () => { test('buildSlidePayload preserves safe image markup in text regions', async () => {
const pool = { const pool = {
async query(sql) { async query(sql) {
+70
View File
@@ -206,4 +206,74 @@ test('weather icon usage recognizes one set reference', function () {
}; };
assert.equal(isUsed('set'), true); assert.equal(isUsed('set'), true);
assert.equal(isUsed('other'), false); assert.equal(isUsed('other'), false);
});
test('weather icon set create, update, toggle, and delete are audited', async function () {
const originals = {
readWeatherIconSets: library.readWeatherIconSets,
saveWeatherIconSet: library.saveWeatherIconSet,
collectWeatherIconRemovalOperations: library.collectWeatherIconRemovalOperations,
setWeatherIconSetEnabled: library.setWeatherIconSetEnabled,
deleteWeatherIconSet: library.deleteWeatherIconSet
};
const handlers = {};
const events = [];
let sets = [];
const app = {
get(route, ...callbacks) { handlers['GET ' + route] = callbacks; },
post(route, ...callbacks) { handlers['POST ' + route] = callbacks; }
};
const iconSet = { id: 'clear-set', name: 'Clear skies', icons: { clear: '/media/assets/weather-icons/clear-set/clear.png' }, nightIcons: {} };
library.readWeatherIconSets = function () { return sets.slice(); };
library.saveWeatherIconSet = async function (_root, name, _files, id) {
const saved = Object.assign({}, iconSet, { id: id || iconSet.id, name: name });
sets = id ? sets.map(function (set) { return set.id === id ? saved : set; }) : sets.concat(saved);
return saved;
};
library.collectWeatherIconRemovalOperations = function () { return []; };
library.setWeatherIconSetEnabled = function (_root, id, enabled) {
const updated = Object.assign({}, sets.find(function (set) { return set.id === id; }), { enabled: enabled });
sets = sets.map(function (set) { return set.id === id ? updated : set; });
return updated;
};
library.deleteWeatherIconSet = function (_root, id) {
const removed = sets.find(function (set) { return set.id === id; });
sets = sets.filter(function (set) { return set.id !== id; });
return removed;
};
try {
require('../src/web/routes/settings/weather-icons') (app, {
mediaDir: 'unused',
pool: { async query() { return [[]]; } },
requirePermission() { return function (_req, _res, next) { next(); }; },
recordRequestAuditEvent(_pool, _req, event) { events.push(event); }
});
const response = { redirect() { return this; }, status() { return this; }, send() { return this; } };
const next = function (error) { if (error) throw error; };
const currentUser = { id: 21 };
const createRequest = { currentUser: currentUser, body: { name: 'Clear skies' }, files: {}, params: {} };
await handlers['POST /settings/weather-icons'].at(-1)(createRequest, response, next);
await handlers['POST /settings/weather-icons/:id'].at(-1)(
{ currentUser: currentUser, body: { name: 'Updated skies' }, files: {}, params: { id: iconSet.id } }, response, next
);
await handlers['POST /settings/weather-icons/:id/toggle'].at(-1)(
{ currentUser: currentUser, body: { enabled: '0' }, params: { id: iconSet.id } }, response, next
);
await handlers['POST /settings/weather-icons/:id/delete'].at(-1)(
{ currentUser: currentUser, params: { id: iconSet.id } }, response, next
);
assert.deepEqual(events.map(function (event) { return event.eventType; }), [
'weather-icon-set.created', 'weather-icon-set.updated', 'weather-icon-set.disabled', 'weather-icon-set.deleted'
]);
assert.ok(events.every(function (event) { return event.category === 'media-assets' && event.actorUserId === 21; }));
} finally {
library.readWeatherIconSets = originals.readWeatherIconSets;
library.saveWeatherIconSet = originals.saveWeatherIconSet;
library.collectWeatherIconRemovalOperations = originals.collectWeatherIconRemovalOperations;
library.setWeatherIconSetEnabled = originals.setWeatherIconSetEnabled;
library.deleteWeatherIconSet = originals.deleteWeatherIconSet;
}
}); });