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

This commit is contained in:
2026-08-16 17:15:31 +01:00
parent 1bdc122995
commit 203d0bfc01
105 changed files with 4185 additions and 677 deletions
+42 -10
View File
@@ -3,6 +3,7 @@
const fs = require('fs');
const path = require('path');
const { loadFontLibrary } = require('#src/web/lib/media/font-library');
const { fetchAppSettings } = require('#src/data/app-settings');
const { PERMISSION_DENIED_MESSAGE } = require('#src/rbac');
module.exports = function registerContentRoutes(app, deps) {
@@ -19,6 +20,7 @@ module.exports = function registerContentRoutes(app, deps) {
const removeUnusedUploadFiles = deps.removeUnusedUploadFiles;
const syncPlaylistUploadsOnChange = deps.syncPlaylistUploadsOnChange;
const getAuditUserId = deps.getAuditUserId;
const recordRequestAuditEvent = deps.recordRequestAuditEvent;
const redirectAfterSave = deps.redirectAfterSave;
const notifyPlayerScreens = deps.notifyPlayerScreens;
const broadcastDashboardState = deps.broadcastDashboardState;
@@ -42,6 +44,7 @@ module.exports = function registerContentRoutes(app, deps) {
const rssData = await common.fetchRssFeedsData(pool);
const apiData = typeof common.fetchApiSourcesData === 'function' ? await common.fetchApiSourcesData(pool) : { apiSources: [] };
const timetableData = typeof common.fetchTimetablesData === 'function' ? await common.fetchTimetablesData(pool) : { timetableGroups: [] };
const appSettings = await fetchAppSettings(pool);
const apiSources = Array.isArray(apiData.apiSources) ? apiData.apiSources.map(function (source) {
return Object.assign({}, source, {
responseJson: typeof common.parseJsonSafe === 'function' ? common.parseJsonSafe(source.last_response_json) : null
@@ -60,7 +63,13 @@ module.exports = function registerContentRoutes(app, deps) {
rssFeeds: rssFeeds,
apiSources: apiSources,
timetableGroups: timetableData.timetableGroups || [],
fontLibrary: loadFontLibrary(deps.uploadDir)
fontLibrary: loadFontLibrary(deps.uploadDir),
uploadLimits: {
imageMaxBytes: Number(appSettings['uploads.image_max_bytes']) || IMAGE_UPLOAD_MAX_BYTES,
videoMaxBytes: Number(appSettings['uploads.video_max_bytes']) || VIDEO_UPLOAD_MAX_BYTES,
wysiwygImageMaxBytes: Number(appSettings['uploads.wysiwyg_image_max_bytes']) || WYSIWYG_IMAGE_UPLOAD_MAX_BYTES,
allowedMimeTypes: Array.isArray(appSettings['uploads.allowed_mime_types']) ? appSettings['uploads.allowed_mime_types'] : []
}
});
}
@@ -123,20 +132,24 @@ module.exports = function registerContentRoutes(app, deps) {
return null;
}
function getUploadedFileLimitBytes(file, uploadContext) {
function getUploadedFileLimitBytes(file, uploadContext, settings) {
if (String(uploadContext || '').trim().toLowerCase() === 'wysiwyg') {
return WYSIWYG_IMAGE_UPLOAD_MAX_BYTES;
return Number(settings && settings['uploads.wysiwyg_image_max_bytes']) || WYSIWYG_IMAGE_UPLOAD_MAX_BYTES;
}
return getUploadedFileMediaType(file, uploadContext) === 'video' ? VIDEO_UPLOAD_MAX_BYTES : IMAGE_UPLOAD_MAX_BYTES;
return getUploadedFileMediaType(file, uploadContext) === 'video'
? Number(settings && settings['uploads.video_max_bytes']) || VIDEO_UPLOAD_MAX_BYTES
: Number(settings && settings['uploads.image_max_bytes']) || IMAGE_UPLOAD_MAX_BYTES;
}
function getUploadedFileLimitLabel(file, uploadContext) {
function getUploadedFileLimitLabel(file, uploadContext, settings) {
if (String(uploadContext || '').trim().toLowerCase() === 'wysiwyg') {
return '10 MB';
const limitMb = getUploadedFileLimitBytes(file, uploadContext, settings) / 1024 / 1024;
return (Number.isInteger(limitMb) ? String(limitMb) : limitMb.toFixed(2).replace(/0+$/, '').replace(/\.$/, '')) + ' MB';
}
return getUploadedFileMediaType(file, uploadContext) === 'video' ? '1 GB' : '100 MB';
const limitMb = Math.round(getUploadedFileLimitBytes(file, uploadContext, settings) / 1024 / 1024);
return limitMb >= 1024 && limitMb % 1024 === 0 ? (limitMb / 1024) + ' GB' : limitMb + ' MB';
}
async function removeUploadedFile(file) {
@@ -154,8 +167,18 @@ module.exports = function registerContentRoutes(app, deps) {
async function validateUploadedFiles(files, uploadContext) {
const list = Array.isArray(files) ? files.filter(Boolean) : [];
const settings = await fetchAppSettings(pool);
for (let i = 0; i < list.length; i += 1) {
const file = list[i];
const fileMimeType = String(file && file.mimetype || '').trim().toLowerCase();
const allowedMimeTypes = Array.isArray(settings['uploads.allowed_mime_types']) ? settings['uploads.allowed_mime_types'] : [];
if (allowedMimeTypes.indexOf(fileMimeType) === -1) {
await removeUploadedFile(file);
const error = new Error('This upload type is disabled in Media Uploads settings.');
error.statusCode = 400;
error.expose = true;
throw error;
}
const mediaType = getUploadedFileMediaType(file, uploadContext);
if (!mediaType) {
await removeUploadedFile(file);
@@ -165,11 +188,11 @@ module.exports = function registerContentRoutes(app, deps) {
throw error;
}
if (Number(file.size || 0) > getUploadedFileLimitBytes(file, uploadContext)) {
if (Number(file.size || 0) > getUploadedFileLimitBytes(file, uploadContext, settings)) {
await removeUploadedFile(file);
const error = new Error(String(uploadContext || '').trim().toLowerCase() === 'wysiwyg'
? 'Image must be 2 MB or smaller. Larger images should use the dedicated Image region.'
: 'File must be ' + getUploadedFileLimitLabel(file, uploadContext) + ' or smaller.');
? 'Image must be ' + getUploadedFileLimitLabel(file, uploadContext, settings) + ' or smaller. Larger images should use the dedicated Image region.'
: 'File must be ' + getUploadedFileLimitLabel(file, uploadContext, settings) + ' or smaller.');
error.statusCode = 400;
error.expose = true;
throw error;
@@ -447,6 +470,7 @@ module.exports = function registerContentRoutes(app, deps) {
queueSlideThumbnailRefresh(result.insertId, null).catch(function (error) {
console.warn('Unable to queue slide thumbnail refresh:', error);
});
if (typeof recordRequestAuditEvent === 'function') await recordRequestAuditEvent(pool, req, { category: 'slides', eventType: 'slide.created', actorUserId: actorId, targetType: 'slide', targetId: result.insertId, targetLabel: payload.title });
redirectAfterSave(req, res, '/slides/' + result.insertId + '/edit', {
closeUrl: '/slides',
newUrl: '/slides/new',
@@ -488,6 +512,7 @@ module.exports = function registerContentRoutes(app, deps) {
screenSlideCounts: screenSlideCounts
});
await broadcastDashboardState();
if (typeof recordRequestAuditEvent === 'function') await recordRequestAuditEvent(pool, req, { category: 'slides', eventType: 'slide.updated', actorUserId: actorId, targetType: 'slide', targetId: slide.id, targetLabel: payload.title });
queueSlideThumbnailRefresh(slide.id, slide.thumbnail_path).catch(function (error) {
console.warn('Unable to queue slide thumbnail refresh:', error);
});
@@ -526,6 +551,7 @@ module.exports = function registerContentRoutes(app, deps) {
screenSlideCounts: screenSlideCounts
});
await broadcastDashboardState();
if (typeof recordRequestAuditEvent === 'function') await recordRequestAuditEvent(pool, req, { category: 'slides', eventType: 'slide.deleted', actorUserId: getAuditUserId(req), targetType: 'slide', targetId: slide.id, targetLabel: slide.title });
res.redirect('/slides?message=' + encodeURIComponent('Slide deleted.'));
} catch (error) {
next(error);
@@ -584,6 +610,7 @@ module.exports = function registerContentRoutes(app, deps) {
localUploadDir: deps.uploadDir,
nextUploadRefs: collectUploadReferencesFromPayload(payload)
});
if (typeof recordRequestAuditEvent === 'function') await recordRequestAuditEvent(pool, req, { category: 'templates', eventType: 'template.created', actorUserId: actorId, targetType: 'template', targetId: result.insertId, targetLabel: payload.name });
redirectAfterSave(req, res, '/templates/' + result.insertId + '/edit', {
closeUrl: '/templates',
newUrl: '/templates/new',
@@ -654,6 +681,7 @@ module.exports = function registerContentRoutes(app, deps) {
});
}
await notifyPlayerScreens(affectedScreens, 'refresh');
if (typeof recordRequestAuditEvent === 'function') await recordRequestAuditEvent(pool, req, { category: 'templates', eventType: 'template.updated', actorUserId: actorId, targetType: 'template', targetId: template.id, targetLabel: payload.name });
redirectAfterSave(req, res, '/templates/' + template.id + '/edit', {
closeUrl: '/templates',
newUrl: '/templates/new',
@@ -686,6 +714,7 @@ module.exports = function registerContentRoutes(app, deps) {
previousUploadRefs: uploadRefs
});
await notifyPlayerScreens(affectedScreens, 'refresh');
if (typeof recordRequestAuditEvent === 'function') await recordRequestAuditEvent(pool, req, { category: 'templates', eventType: 'template.deleted', actorUserId: getAuditUserId(req), targetType: 'template', targetId: template.id, targetLabel: template.name });
res.redirect('/templates?message=' + encodeURIComponent('Template deleted.'));
} catch (error) {
next(error);
@@ -743,6 +772,7 @@ module.exports = function registerContentRoutes(app, deps) {
}
const actorId = getAuditUserId(req);
const [result] = await pool.query('INSERT INTO c_canvas_sizes (name, width, height, created_by, modified_by) VALUES (?, ?, ?, ?, ?)', [payload.name, payload.width, payload.height, actorId, actorId]);
if (typeof recordRequestAuditEvent === 'function') await recordRequestAuditEvent(pool, req, { category: 'canvas-sizes', eventType: 'canvas-size.created', actorUserId: actorId, targetType: 'canvas-size', targetId: result.insertId, targetLabel: payload.name, details: { width: payload.width, height: payload.height } });
redirectAfterSave(req, res, '/canvas-sizes/' + result.insertId + '/edit', {
closeUrl: '/canvas-sizes',
newUrl: '/canvas-sizes/new',
@@ -784,6 +814,7 @@ module.exports = function registerContentRoutes(app, deps) {
return res.status(400).send('That canvas size already exists.');
}
await pool.query('UPDATE c_canvas_sizes SET name = ?, width = ?, height = ?, modified_by = ? WHERE id = ?', [payload.name, payload.width, payload.height, getAuditUserId(req), canvasSize.id]);
if (typeof recordRequestAuditEvent === 'function') await recordRequestAuditEvent(pool, req, { category: 'canvas-sizes', eventType: 'canvas-size.updated', actorUserId: getAuditUserId(req), targetType: 'canvas-size', targetId: canvasSize.id, targetLabel: payload.name, details: { width: payload.width, height: payload.height } });
redirectAfterSave(req, res, '/canvas-sizes', {
closeUrl: '/canvas-sizes',
newUrl: '/canvas-sizes/new',
@@ -806,6 +837,7 @@ module.exports = function registerContentRoutes(app, deps) {
}
await pool.query('UPDATE c_templates SET canvas_size_id = NULL, modified_by = ? WHERE canvas_size_id = ?', [getAuditUserId(req), canvasSize.id]);
await pool.query('DELETE FROM c_canvas_sizes WHERE id = ?', [canvasSize.id]);
if (typeof recordRequestAuditEvent === 'function') await recordRequestAuditEvent(pool, req, { category: 'canvas-sizes', eventType: 'canvas-size.deleted', actorUserId: getAuditUserId(req), targetType: 'canvas-size', targetId: canvasSize.id, targetLabel: canvasSize.name });
res.redirect('/canvas-sizes?message=' + encodeURIComponent('Canvas size deleted.'));
} catch (error) {
next(error);