diff --git a/CHANGELOG.md b/CHANGELOG.md index 72487a8..5670fa3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,18 @@ All notable changes to this project will be documented in this file. +## 2.7.0 - 2026-08-14 + +### Added + +- The WYSIWYG editor now supports adding small images. + +### Changed + +- The WYSIWYG image insertion flow also received a small code cleanup to simplify the related helper logic. +- The default table formatting has been applied. +- Timetable regions now use the renamed helpers end to end in the editor and player, including timezone-aware rendering for timetable entry placeholders. + ## 2.6.27 - 2026-08-14 ### Fixed diff --git a/build/package.player.json b/build/package.player.json index b8fb850..86c102e 100644 --- a/build/package.player.json +++ b/build/package.player.json @@ -1,6 +1,6 @@ { "name": "pulse-signage-player", - "version": "2.6.27", + "version": "2.7.0", "private": false, "description": "Pulse Signage player application bundle", "main": "src/common.js", diff --git a/build/package.web.json b/build/package.web.json index 24ad31e..bc13912 100644 --- a/build/package.web.json +++ b/build/package.web.json @@ -1,6 +1,6 @@ { "name": "pulse-signage-web", - "version": "2.6.27", + "version": "2.7.0", "private": false, "description": "Pulse Signage web and bridge application bundle", "main": "src/common.js", diff --git a/package.json b/package.json index 5e4cf15..ddba884 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "pulse-signage", - "version": "2.6.27", + "version": "2.7.0", "private": false, "description": "Pulse Signage application with MySQL and media storage", "repository": { diff --git a/src/data/slides.js b/src/data/slides.js index 8a687c8..6b5bccf 100644 --- a/src/data/slides.js +++ b/src/data/slides.js @@ -6,27 +6,90 @@ const { parseJsonSafe, validateMaxLength } = require('./utils'); const TITLE_MAX_LENGTH = 255; const { buildQrCodeContent } = require('./qr-code'); -const ALLOWED_RICH_TEXT_TAGS = ['b', 'strong', 'i', 'em', 'u', 'br', 'p', 'div', 'ul', 'ol', 'li']; const DEFAULT_FONT_SIZE = 32; +const ALLOWED_RICH_TEXT_TAGS = ['a', 'b', 'blockquote', 'br', 'code', 'div', 'em', 'figure', 'figcaption', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'hr', 'i', 'img', 'li', 'ol', 'p', 'pre', 'span', 'strong', 'sub', 'sup', 'table', 'tbody', 'td', 'th', 'thead', 'tr', 'u', 'ul']; + +function sanitizeRichTextAttributes(tagName, attrText) { + const allowedAttributes = { + a: ['href', 'title', 'target', 'rel', 'class', 'style'], + blockquote: ['class', 'style'], + div: ['class', 'style'], + figure: ['class', 'style'], + figcaption: ['class', 'style'], + h1: ['class', 'style'], + h2: ['class', 'style'], + h3: ['class', 'style'], + h4: ['class', 'style'], + h5: ['class', 'style'], + h6: ['class', 'style'], + img: ['src', 'alt', 'title', 'width', 'height', 'class', 'style', 'loading', 'decoding'], + li: ['class', 'style'], + ol: ['class', 'style', 'start'], + p: ['class', 'style'], + pre: ['class', 'style'], + span: ['class', 'style'], + table: ['class', 'style'], + td: ['class', 'style', 'colspan', 'rowspan'], + th: ['class', 'style', 'colspan', 'rowspan', 'scope'], + tr: ['class', 'style'], + ul: ['class', 'style'] + }; + const allowed = allowedAttributes[tagName] || []; + if (!allowed.length) { + return ''; + } + + const attrs = []; + String(attrText || '').replace(/([a-zA-Z0-9:-]+)(?:\s*=\s*("([^"]*)"|'([^']*)'|([^\s"'>/=`]+)))?/g, (_full, key, _valuePart, doubleQuoted, singleQuoted, bareValue) => { + const lowerKey = String(key || '').toLowerCase(); + if (!allowed.includes(lowerKey)) { + return ''; + } + + const value = doubleQuoted !== undefined ? doubleQuoted : singleQuoted !== undefined ? singleQuoted : bareValue !== undefined ? bareValue : ''; + if (lowerKey === 'href' && /^(?:\s*javascript:|\s*data:)/i.test(String(value || ''))) { + return ''; + } + if (lowerKey === 'style' && /(?:expression\s*\(|javascript:|url\s*\()/i.test(String(value || ''))) { + return ''; + } + if (lowerKey === 'target') { + const targetValue = String(value || '').trim(); + if (targetValue === '_blank') { + attrs.push(' target="_blank"'); + if (!attrs.includes(' rel="noreferrer noopener"')) { + attrs.push(' rel="noreferrer noopener"'); + } + return ''; + } + } + attrs.push(' ' + lowerKey + '="' + String(value || '').replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"').replace(/'/g, ''') + '"'); + return ''; + }); + + return attrs.join(''); +} + function sanitizeRichText(html) { let output = String(html || ''); output = output.replace(//gi, ''); output = output.replace(//gi, ''); return output.replace(/<[^>]+>/g, (tag) => { - const match = tag.match(/^<\s*(\/?)\s*([a-z0-9]+)(?:\s[^>]*)?>$/i); + const match = tag.match(/^<\s*(\/?)\s*([a-z0-9]+)([\s\S]*?)(\/?)>$/i); if (!match) { return ''; } const closing = Boolean(match[1]); const name = String(match[2] || '').toLowerCase(); + const attrText = String(match[3] || ''); if (!ALLOWED_RICH_TEXT_TAGS.includes(name)) { return ''; } - if (name === 'br') { - return '
'; + if (closing) { + return ``; } - return closing ? `` : `<${name}>`; + return `<${name}${sanitizeRichTextAttributes(name, attrText)}>`; }); } @@ -430,7 +493,7 @@ async function buildTemplateContent(pool, template, body, filesByField, existing const style = getTextRegionStyle(body, region, existingContent); content[region.region_key] = { type: 'text', - value: stripEditorOnlyMarkup(normalizeEditorMarkup(submitted === undefined ? current : String(submitted || ''))), + value: sanitizeRichText(stripEditorOnlyMarkup(normalizeEditorMarkup(submitted === undefined ? current : String(submitted || '')))), font_family: style.font_family, font_size: style.font_size, font_color: style.font_color diff --git a/src/player/player-page.template.html b/src/player/player-page.template.html index e65d528..be66e1f 100644 --- a/src/player/player-page.template.html +++ b/src/player/player-page.template.html @@ -7,7 +7,7 @@ - + {{{STYLESHEETS}}} diff --git a/src/player/public/css/player.css b/src/player/public/css/player.css index 7daf5e4..f121358 100644 --- a/src/player/public/css/player.css +++ b/src/player/public/css/player.css @@ -4,7 +4,7 @@ body { width: 100%; height: 100%; overflow: hidden; - background: #111; + background: #0a0a0a; color: #fff; font-family: Arial, sans-serif; } @@ -43,7 +43,7 @@ body.thumbnail-preview .player-offline-banner { display: flex; align-items: center; justify-content: center; - background: #111; + background: #0a0a0a; position: relative; } @@ -294,15 +294,6 @@ body.screen-blackout #app { display: block; } -.slide img, -.slide video, -.slide iframe { - width: 100%; - height: 100%; - object-fit: contain; - border: 0; -} - .body { position: absolute; left: 5%; diff --git a/src/player/public/js/player-page-rendering.js b/src/player/public/js/player-page-rendering.js index 8e3a2e3..b662bdf 100644 --- a/src/player/public/js/player-page-rendering.js +++ b/src/player/public/js/player-page-rendering.js @@ -334,7 +334,7 @@ function setPlayerCanvasDimensions(canvasWidth, canvasHeight) { document.documentElement.style.setProperty('--player-canvas-height', height + 'px'); } -const ALLOWED_RICH_TEXT_TAGS = ['a', 'b', 'blockquote', 'br', 'code', 'div', 'em', 'figure', 'figcaption', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'hr', 'i', 'li', 'ol', 'p', 'pre', 'span', 'strong', 'sub', 'sup', 'table', 'tbody', 'td', 'th', 'thead', 'tr', 'u', 'ul']; +const ALLOWED_RICH_TEXT_TAGS = ['a', 'b', 'blockquote', 'br', 'code', 'col', 'colgroup', 'div', 'em', 'figure', 'figcaption', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'hr', 'i', 'img', 'li', 'ol', 'p', 'pre', 'span', 'strong', 'sub', 'sup', 'table', 'tbody', 'td', 'th', 'thead', 'tr', 'u', 'ul']; function sanitizeRichTextAttributes(tagName, attrText) { const allowedAttributes = { @@ -349,14 +349,19 @@ function sanitizeRichTextAttributes(tagName, attrText) { h4: ['class', 'style'], h5: ['class', 'style'], h6: ['class', 'style'], + img: ['src', 'alt', 'title', 'width', 'height', 'class', 'style', 'loading', 'decoding'], + col: ['class', 'style', 'span', 'width'], + colgroup: ['class', 'style', 'span'], li: ['class', 'style'], ol: ['class', 'style', 'start'], p: ['class', 'style'], pre: ['class', 'style'], span: ['class', 'style'], table: ['class', 'style'], + tbody: ['class', 'style'], td: ['class', 'style', 'colspan', 'rowspan'], th: ['class', 'style', 'colspan', 'rowspan', 'scope'], + thead: ['class', 'style'], tr: ['class', 'style'], ul: ['class', 'style'] }; @@ -365,6 +370,14 @@ function sanitizeRichTextAttributes(tagName, attrText) { return ''; } + if (tagName === 'img') { + const srcMatch = String(attrText || '').match(/\bsrc\s*=\s*("([^"]*)"|'([^']*)'|([^\s"'>/=`]+))/i); + const srcValue = srcMatch ? String(srcMatch[2] !== undefined ? srcMatch[2] : srcMatch[3] !== undefined ? srcMatch[3] : srcMatch[4] !== undefined ? srcMatch[4] : '').trim() : ''; + if (!srcValue || !/^(?:https?:\/\/|\/media\/|\/assets\/|\/[^/]|data:image\/)/i.test(srcValue)) { + return ''; + } + } + const attrs = []; String(attrText || '').replace(/([a-zA-Z0-9:-]+)(?:\s*=\s*("([^"]*)"|'([^']*)'|([^\s"'>/=`]+)))?/g, (_full, key, _valuePart, doubleQuoted, singleQuoted, bareValue) => { const lowerKey = String(key || '').toLowerCase(); diff --git a/src/player/regions/time-date.js b/src/player/regions/time-date.js index 8cc0a7e..5445aa8 100644 --- a/src/player/regions/time-date.js +++ b/src/player/regions/time-date.js @@ -1,6 +1,7 @@ // Time/date region rendering and live updates. var registry = window.pulsePlayerRegionTypes; +var placeholderUtils = window.placeholderUtils || {}; var DEFAULT_FORMAT = '{{hh}}:{{mm}}'; var DEFAULT_STYLE = { font_family: 'Arial', @@ -9,15 +10,6 @@ var DEFAULT_STYLE = { }; var timeDateFormatterCache = Object.create(null); -function escapeHtml(value) { - return String(value === undefined || value === null ? '' : value) - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') - .replace(/'/g, '''); -} - function sanitizeTagAttributes(tagName, attrText) { var allowedAttributes = { a: ['href', 'title', 'target', 'rel', 'class', 'style'], @@ -133,7 +125,7 @@ function resolveTimeZone(value) { } } -function getFormatter(key, options) { +function getTimeDateFormatter(key, options) { if (!timeDateFormatterCache[key]) { timeDateFormatterCache[key] = new Intl.DateTimeFormat('en-GB', options); } @@ -141,10 +133,10 @@ function getFormatter(key, options) { return timeDateFormatterCache[key]; } -function getFormattedParts(timeZone, date) { +function getTimeDateFormattedParts(timeZone, date) { var targetDate = date instanceof Date ? date : new Date(); var resolvedTimeZone = resolveTimeZone(timeZone); - var numericParts = getFormatter('numeric:' + resolvedTimeZone, { + var numericParts = getTimeDateFormatter('numeric:' + resolvedTimeZone, { timeZone: resolvedTimeZone, hour12: false, hour: '2-digit', @@ -154,29 +146,29 @@ function getFormattedParts(timeZone, date) { month: '2-digit', year: 'numeric' }).formatToParts(targetDate); - var weekdayLong = getFormatter('weekday-long:' + resolvedTimeZone, { + var weekdayLong = getTimeDateFormatter('weekday-long:' + resolvedTimeZone, { timeZone: resolvedTimeZone, weekday: 'long' }).formatToParts(targetDate); - var weekdayShort = getFormatter('weekday-short:' + resolvedTimeZone, { + var weekdayShort = getTimeDateFormatter('weekday-short:' + resolvedTimeZone, { timeZone: resolvedTimeZone, weekday: 'short' }).formatToParts(targetDate); - var monthLong = getFormatter('month-long:' + resolvedTimeZone, { + var monthLong = getTimeDateFormatter('month-long:' + resolvedTimeZone, { timeZone: resolvedTimeZone, month: 'long' }).formatToParts(targetDate); - var monthShort = getFormatter('month-short:' + resolvedTimeZone, { + var monthShort = getTimeDateFormatter('month-short:' + resolvedTimeZone, { timeZone: resolvedTimeZone, month: 'short' }).formatToParts(targetDate); - var ampm = getFormatter('ampm:' + resolvedTimeZone, { + var ampm = getTimeDateFormatter('ampm:' + resolvedTimeZone, { timeZone: resolvedTimeZone, hour12: true, hour: '2-digit', minute: '2-digit' }).formatToParts(targetDate); - var timezoneShort = getFormatter('tz-short:' + resolvedTimeZone, { + var timezoneShort = getTimeDateFormatter('tz-short:' + resolvedTimeZone, { timeZone: resolvedTimeZone, timeZoneName: 'short' }).formatToParts(targetDate); @@ -220,20 +212,21 @@ function getFormattedParts(timeZone, date) { }; } -function resolveTimeDatePlaceholder(values, expression) { - if (typeof placeholderUtils.resolvePlaceholderExpression === 'function' && typeof placeholderUtils.formatPlaceholderValue === 'function') { - return placeholderUtils.formatPlaceholderValue(placeholderUtils.resolvePlaceholderExpression(values, expression)); +function resolveTimeDateTemplatePlaceholder(values, expression) { + var currentPlaceholderUtils = window.placeholderUtils || placeholderUtils || {}; + if (typeof currentPlaceholderUtils.resolvePlaceholderExpression === 'function' && typeof currentPlaceholderUtils.formatPlaceholderValue === 'function') { + return currentPlaceholderUtils.formatPlaceholderValue(currentPlaceholderUtils.resolvePlaceholderExpression(values, expression)); } var parsed = String(expression || '').trim(); return Object.prototype.hasOwnProperty.call(values, parsed) ? values[parsed] : ''; } -function renderTemplate(format, timeZone, date) { +function renderTimeDateTemplate(format, timeZone, date) { var template = String(format || '').trim() || DEFAULT_FORMAT; - var values = getFormattedParts(timeZone, date); + var values = getTimeDateFormattedParts(timeZone, date); return template.replace(/\{\{\s*([a-zA-Z0-9_.()\-]+)\s*\}\}/g, function (_match, key) { - return String(resolveTimeDatePlaceholder(values, key, { timeZone: timeZone }) || ''); + return String(resolveTimeDateTemplatePlaceholder(values, key, { timeZone: timeZone }) || ''); }); } @@ -257,7 +250,7 @@ function renderTimeDateRegion(region, regionContent) { var fontSize = style.font_size ? 'font-size:' + Math.max(1, Math.round(Number(style.font_size))) + 'px;' : ''; var fontColor = style.font_color ? 'color:' + escapeHtml(style.font_color) + ';' : ''; var contentStyle = 'width:' + region.pixelWidth + 'px;height:' + region.pixelHeight + 'px;transform:scale(' + region.canvasScale + ');transform-origin:top left;' + (fontFamily ? fontFamily : '') + fontSize + fontColor + 'white-space:pre-wrap;line-height:1.1;'; - var renderedText = renderTemplate(format, timeZone, new Date()); + var renderedText = renderTimeDateTemplate(format, timeZone, new Date()); return '
' + renderEditorJsContent(renderedText) + '
'; } @@ -273,7 +266,7 @@ function updateTimeDateRegion(element) { return; } - scaleWrapper.innerHTML = renderEditorJsContent(renderTemplate(format, timeZone, new Date())); + scaleWrapper.innerHTML = renderEditorJsContent(renderTimeDateTemplate(format, timeZone, new Date())); } function scheduleTimeDateRegionUpdate(element) { diff --git a/src/player/regions/schedule.js b/src/player/regions/timetable.js similarity index 65% rename from src/player/regions/schedule.js rename to src/player/regions/timetable.js index 69d97cf..1eceb47 100644 --- a/src/player/regions/schedule.js +++ b/src/player/regions/timetable.js @@ -2,101 +2,6 @@ var registry = window.pulsePlayerRegionTypes; -function escapeHtml(value) { - return String(value === undefined || value === null ? '' : value) - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') - .replace(/'/g, '''); -} - -function sanitizeRichTextAttributes(tagName, attrText) { - var allowedAttributes = { - a: ['href', 'title', 'target', 'rel', 'class', 'style'], - blockquote: ['class', 'style'], - col: ['class', 'style', 'span', 'width'], - colgroup: ['class', 'style', 'span'], - div: ['class', 'style'], - figure: ['class', 'style'], - figcaption: ['class', 'style'], - h1: ['class', 'style'], - h2: ['class', 'style'], - h3: ['class', 'style'], - h4: ['class', 'style'], - h5: ['class', 'style'], - h6: ['class', 'style'], - li: ['class', 'style'], - ol: ['class', 'style', 'start'], - p: ['class', 'style'], - pre: ['class', 'style'], - span: ['class', 'style'], - table: ['class', 'style'], - tbody: ['class', 'style'], - td: ['class', 'style', 'colspan', 'rowspan'], - th: ['class', 'style', 'colspan', 'rowspan', 'scope'], - thead: ['class', 'style'], - tr: ['class', 'style'], - ul: ['class', 'style'] - }; - var allowed = allowedAttributes[tagName] || []; - if (!allowed.length) { - return ''; - } - - var attrs = []; - String(attrText || '').replace(/([a-zA-Z0-9:-]+)(?:\s*=\s*("([^"]*)"|'([^']*)'|([^\s"'>/=`]+)))?/g, function (_full, key, _valuePart, doubleQuoted, singleQuoted, bareValue) { - var lowerKey = String(key || '').toLowerCase(); - if (allowed.indexOf(lowerKey) === -1) { - return ''; - } - - var value = doubleQuoted !== undefined ? doubleQuoted : singleQuoted !== undefined ? singleQuoted : bareValue !== undefined ? bareValue : ''; - if (lowerKey === 'href' && /^(?:\s*javascript:|\s*data:)/i.test(String(value || ''))) { - return ''; - } - if (lowerKey === 'style' && /(?:expression\s*\(|javascript:|url\s*\()/i.test(String(value || ''))) { - return ''; - } - if (lowerKey === 'target') { - var targetValue = String(value || '').trim(); - if (targetValue === '_blank') { - attrs.push(' target="_blank"'); - if (attrs.indexOf(' rel="noreferrer noopener"') === -1) { - attrs.push(' rel="noreferrer noopener"'); - } - return ''; - } - } - attrs.push(' ' + lowerKey + '="' + escapeHtml(value) + '"'); - return ''; - }); - - return attrs.join(''); -} - -function sanitizeRichText(html) { - var output = String(html || ''); - output = output.replace(//gi, ''); - output = output.replace(//gi, ''); - return output.replace(/<[^>]+>/g, function (tag) { - var match = tag.match(/^<\s*(\/?)\s*([a-z0-9]+)([\s\S]*?)(\/?)>$/i); - if (!match) { - return ''; - } - var closing = Boolean(match[1]); - var name = String(match[2] || '').toLowerCase(); - var allowed = ['a', 'b', 'blockquote', 'br', 'code', 'col', 'colgroup', 'div', 'em', 'figure', 'figcaption', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'hr', 'i', 'li', 'ol', 'p', 'pre', 'span', 'strong', 'sub', 'sup', 'table', 'tbody', 'td', 'th', 'thead', 'tr', 'u', 'ul']; - if (allowed.indexOf(name) === -1) { - return ''; - } - if (closing) { - return ''; - } - return '<' + name + sanitizeRichTextAttributes(name, String(match[3] || '')) + '>'; - }); -} - function substituteTimetableVariables(html, entry) { var source = String(html || ''); return source.replace(/\{\{\s*([^{}]+?)\s*\}\}/g, function (_match, expression) { diff --git a/src/player/render-helpers.js b/src/player/render-helpers.js index 12a1196..d8d504d 100644 --- a/src/player/render-helpers.js +++ b/src/player/render-helpers.js @@ -41,7 +41,7 @@ function sanitizeTextColor(value, fallback) { return fallback || '#000000'; } -const ALLOWED_RICH_TEXT_TAGS = ['a', 'b', 'blockquote', 'br', 'code', 'div', 'em', 'figure', 'figcaption', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'hr', 'i', 'li', 'ol', 'p', 'pre', 'span', 'strong', 'sub', 'sup', 'table', 'tbody', 'td', 'th', 'thead', 'tr', 'u', 'ul']; +const ALLOWED_RICH_TEXT_TAGS = ['a', 'b', 'blockquote', 'br', 'code', 'col', 'colgroup', 'div', 'em', 'figure', 'figcaption', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'hr', 'i', 'img', 'li', 'ol', 'p', 'pre', 'span', 'strong', 'sub', 'sup', 'table', 'tbody', 'td', 'th', 'thead', 'tr', 'u', 'ul']; function sanitizeRichTextAttributes(tagName, attrText) { const allowedAttributes = { @@ -56,6 +56,9 @@ function sanitizeRichTextAttributes(tagName, attrText) { h4: ['class', 'style'], h5: ['class', 'style'], h6: ['class', 'style'], + img: ['src', 'alt', 'title', 'width', 'height', 'class', 'style', 'loading', 'decoding'], + col: ['class', 'style', 'span', 'width'], + colgroup: ['class', 'style', 'span'], li: ['class', 'style'], ol: ['class', 'style', 'start'], p: ['class', 'style'], @@ -72,6 +75,14 @@ function sanitizeRichTextAttributes(tagName, attrText) { return ''; } + if (tagName === 'img') { + const srcMatch = String(attrText || '').match(/\bsrc\s*=\s*("([^"]*)"|'([^']*)'|([^\s"'>/=`]+))/i); + const srcValue = srcMatch ? String(srcMatch[2] !== undefined ? srcMatch[2] : srcMatch[3] !== undefined ? srcMatch[3] : srcMatch[4] !== undefined ? srcMatch[4] : '').trim() : ''; + if (!srcValue || !/^(?:https?:\/\/|\/media\/|\/assets\/|\/[^/]|data:image\/)/i.test(srcValue)) { + return ''; + } + } + const attrs = []; String(attrText || '').replace(/([a-zA-Z0-9:-]+)(?:\s*=\s*("([^"]*)"|'([^']*)'|([^\s"'>/=`]+)))?/g, (_full, key, _valuePart, doubleQuoted, singleQuoted, bareValue) => { const lowerKey = String(key || '').toLowerCase(); diff --git a/src/web/lib/media/slide-thumbnail-preview.js b/src/web/lib/media/slide-thumbnail-preview.js index 4436a26..1062cdb 100644 --- a/src/web/lib/media/slide-thumbnail-preview.js +++ b/src/web/lib/media/slide-thumbnail-preview.js @@ -53,7 +53,16 @@ function buildThumbnailRegionStyle(region, canvasWidth, canvasHeight) { } function hasVisibleContent(html) { - return Boolean(String(html || '').replace(/<[^>]+>/g, '').trim()); + var raw = String(html || '').trim(); + if (!raw) { + return false; + } + + if (/]+>/g, '').trim()); } function buildTextRegionMarkup(region, regionContent) { diff --git a/src/web/lib/media/slide-thumbnails.js b/src/web/lib/media/slide-thumbnails.js index b19f67e..a36cb3e 100644 --- a/src/web/lib/media/slide-thumbnails.js +++ b/src/web/lib/media/slide-thumbnails.js @@ -83,7 +83,16 @@ function buildThumbnailRegionStyle(region, canvasWidth, canvasHeight) { } function hasVisibleContent(html) { - return Boolean(String(html || '').replace(/<[^>]+>/g, '').trim()); + var raw = String(html || '').trim(); + if (!raw) { + return false; + } + + if (/]+>/g, '').trim()); } function buildTextRegionMarkup(region, regionContent) { diff --git a/src/web/public/js/admin/admin-page.js b/src/web/public/js/admin/admin-page.js index a48ec98..7f8347a 100644 --- a/src/web/public/js/admin/admin-page.js +++ b/src/web/public/js/admin/admin-page.js @@ -423,19 +423,6 @@ return true; } - if (submitterValue === 'close' || submitterValue === 'new') { - var redirectUrl = submitterValue === 'close' - ? String(response.url || form.dataset.asyncSaveCloseUrl || window.location.href) - : String(response.url || form.dataset.asyncSaveNewUrl || window.location.href); - window.location.replace(redirectUrl); - return true; - } - - if (form.dataset && form.dataset.asyncSaveNewRedirect === 'response-url') { - window.location.replace(String(response.url || form.dataset.asyncSaveNewUrl || window.location.href)); - return true; - } - var responseText = await response.text(); var responseDocument = null; try { @@ -456,6 +443,19 @@ clearFormDirty(form); + if (submitterValue === 'close' || submitterValue === 'new') { + var redirectUrl = submitterValue === 'close' + ? String(response.url || form.dataset.asyncSaveCloseUrl || window.location.href) + : String(response.url || form.dataset.asyncSaveNewUrl || window.location.href); + window.location.replace(redirectUrl); + return true; + } + + if (form.dataset && form.dataset.asyncSaveNewRedirect === 'response-url') { + window.location.replace(String(response.url || form.dataset.asyncSaveNewUrl || window.location.href)); + return true; + } + var successMessage = typeof settings.getSuccessMessage === 'function' ? settings.getSuccessMessage({ form: form, diff --git a/src/web/public/js/regions/region-utils.js b/src/web/public/js/regions/region-utils.js index 5fb243b..25defa5 100644 --- a/src/web/public/js/regions/region-utils.js +++ b/src/web/public/js/regions/region-utils.js @@ -28,11 +28,19 @@ var name = String(match[2] || '').toLowerCase(); var attrText = String(match[3] || ''); var selfClosing = Boolean(match[4]) || name === 'br' || name === 'hr'; - var allowed = ['a', 'b', 'blockquote', 'br', 'code', 'div', 'em', 'figure', 'figcaption', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'hr', 'i', 'li', 'ol', 'p', 'pre', 'span', 'strong', 'sub', 'sup', 'table', 'tbody', 'td', 'th', 'thead', 'tr', 'u', 'ul']; + var allowed = ['a', 'b', 'blockquote', 'br', 'code', 'col', 'colgroup', 'div', 'em', 'figure', 'figcaption', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'hr', 'i', 'img', 'li', 'ol', 'p', 'pre', 'span', 'strong', 'sub', 'sup', 'table', 'tbody', 'td', 'th', 'thead', 'tr', 'u', 'ul']; if (allowed.indexOf(name) === -1) { return ''; } + if (name === 'img') { + var srcMatch = String(attrText || '').match(/\bsrc\s*=\s*("([^"]*)"|'([^']*)'|([^\s"'>/=`]+))/i); + var srcValue = srcMatch ? String(srcMatch[2] !== undefined ? srcMatch[2] : srcMatch[3] !== undefined ? srcMatch[3] : srcMatch[4] !== undefined ? srcMatch[4] : '').trim() : ''; + if (!srcValue || !/^(?:https?:\/\/|\/media\/|\/assets\/|\/[^/]|data:image\/)/i.test(srcValue)) { + return ''; + } + } + if (closing) { return ''; } @@ -42,7 +50,100 @@ } function sanitizeRichText(html) { - return sanitizePreviewHtml(html); + var output = String(html || ''); + output = output.replace(//gi, ''); + output = output.replace(//gi, ''); + return output.replace(/<[^>]+>/g, function (tag) { + var match = tag.match(/^<\s*(\/?)\s*([a-z0-9]+)([\s\S]*?)(\/?)>$/i); + if (!match) { + return ''; + } + + var closing = Boolean(match[1]); + var name = String(match[2] || '').toLowerCase(); + var attrText = String(match[3] || ''); + var allowed = ['a', 'b', 'blockquote', 'br', 'code', 'col', 'colgroup', 'div', 'em', 'figure', 'figcaption', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'hr', 'i', 'img', 'li', 'ol', 'p', 'pre', 'span', 'strong', 'sub', 'sup', 'table', 'tbody', 'td', 'th', 'thead', 'tr', 'u', 'ul']; + if (allowed.indexOf(name) === -1) { + return ''; + } + + if (closing) { + return ''; + } + + return '<' + name + sanitizeRichTextAttributes(name, attrText) + '>'; + }); + } + + function sanitizeRichTextAttributes(tagName, attrText) { + var allowedAttributes = { + a: ['href', 'title', 'target', 'rel', 'class', 'style'], + blockquote: ['class', 'style'], + div: ['class', 'style'], + figure: ['class', 'style'], + figcaption: ['class', 'style'], + h1: ['class', 'style'], + h2: ['class', 'style'], + h3: ['class', 'style'], + h4: ['class', 'style'], + h5: ['class', 'style'], + h6: ['class', 'style'], + img: ['src', 'alt', 'title', 'width', 'height', 'class', 'style', 'loading', 'decoding'], + col: ['class', 'style', 'span', 'width'], + colgroup: ['class', 'style', 'span'], + li: ['class', 'style'], + ol: ['class', 'style', 'start'], + p: ['class', 'style'], + pre: ['class', 'style'], + span: ['class', 'style'], + table: ['class', 'style'], + td: ['class', 'style', 'colspan', 'rowspan'], + th: ['class', 'style', 'colspan', 'rowspan', 'scope'], + tr: ['class', 'style'], + ul: ['class', 'style'] + }; + var allowed = allowedAttributes[tagName] || []; + if (!allowed.length) { + return ''; + } + + if (tagName === 'img') { + var srcMatch = String(attrText || '').match(/\bsrc\s*=\s*("([^"]*)"|'([^']*)'|([^\s"'>/=`]+))/i); + var srcValue = srcMatch ? String(srcMatch[2] !== undefined ? srcMatch[2] : srcMatch[3] !== undefined ? srcMatch[3] : srcMatch[4] !== undefined ? srcMatch[4] : '').trim() : ''; + if (!srcValue || !/^(?:https?:\/\/|\/media\/|\/assets\/|\/[^/]|data:image\/)/i.test(srcValue)) { + return ''; + } + } + + var attrs = []; + String(attrText || '').replace(/([a-zA-Z0-9:-]+)(?:\s*=\s*("([^"]*)"|'([^']*)'|([^\s"'>/=`]+)))?/g, function (_full, key, _valuePart, doubleQuoted, singleQuoted, bareValue) { + var lowerKey = String(key || '').toLowerCase(); + if (allowed.indexOf(lowerKey) === -1) { + return ''; + } + + var value = doubleQuoted !== undefined ? doubleQuoted : singleQuoted !== undefined ? singleQuoted : bareValue !== undefined ? bareValue : ''; + if (lowerKey === 'href' && /^(?:\s*javascript:|\s*data:)/i.test(String(value || ''))) { + return ''; + } + if (lowerKey === 'style' && /(?:expression\s*\(|javascript:|url\s*\()/i.test(String(value || ''))) { + return ''; + } + if (lowerKey === 'target') { + var targetValue = String(value || '').trim(); + if (targetValue === '_blank') { + attrs.push(' target="_blank"'); + if (attrs.indexOf(' rel="noreferrer noopener"') === -1) { + attrs.push(' rel="noreferrer noopener"'); + } + return ''; + } + } + attrs.push(' ' + lowerKey + '="' + escapeHtml(value) + '"'); + return ''; + }); + + return attrs.join(''); } function sanitizeTagAttributes(tagName, attrText) { @@ -64,6 +165,7 @@ pre: ['class', 'style'], span: ['class', 'style'], table: ['class', 'style'], + img: ['src', 'alt', 'title', 'width', 'height', 'class', 'style', 'loading', 'decoding'], td: ['class', 'style', 'colspan', 'rowspan'], th: ['class', 'style', 'colspan', 'rowspan', 'scope'], tr: ['class', 'style'], @@ -844,6 +946,7 @@ escapeHtml: escapeHtml, sanitizePreviewHtml: sanitizePreviewHtml, sanitizeRichText: sanitizeRichText, + sanitizeRichTextAttributes: sanitizeRichTextAttributes, sanitizeFontFamily: sanitizeFontFamily, sanitizeTextColor: sanitizeTextColor, normalizeAcceptList: normalizeAcceptList, diff --git a/src/web/public/js/regions/type/api.js b/src/web/public/js/regions/type/api.js index c5451fa..0a2c9e8 100644 --- a/src/web/public/js/regions/type/api.js +++ b/src/web/public/js/regions/type/api.js @@ -9,8 +9,8 @@ return utils.escapeHtml ? utils.escapeHtml(value) : String(value === undefined || value === null ? '' : value); } - function sanitizeRichText(html) { - return utils.sanitizeRichText ? utils.sanitizeRichText(html) : escapeHtml(html); + function sanitizePreviewHtml(html) { + return utils.sanitizePreviewHtml ? utils.sanitizePreviewHtml(html) : escapeHtml(html); } function sanitizeFontFamily(value) { @@ -128,7 +128,7 @@ summary.push('

' + escapeHtml(title) + '

'); } if (description) { - summary.push('
' + sanitizeRichText(description) + '
'); + summary.push('
' + sanitizePreviewHtml(description) + '
'); } if (!summary.length) { return ''; @@ -183,7 +183,7 @@ if (!body) { return ''; } - var renderedBody = sanitizeRichText(body); + var renderedBody = sanitizePreviewHtml(body); return renderedBody ? '
' + renderedBody + '
' : ''; } diff --git a/src/web/public/js/regions/type/rss.js b/src/web/public/js/regions/type/rss.js index 62dddc2..4241846 100644 --- a/src/web/public/js/regions/type/rss.js +++ b/src/web/public/js/regions/type/rss.js @@ -17,10 +17,6 @@ return utils.sanitizePreviewHtml ? utils.sanitizePreviewHtml(html) : escapeHtml(html); } - function sanitizeRichText(html) { - return utils.sanitizeRichText ? utils.sanitizeRichText(html) : sanitizePreviewHtml(html); - } - function sanitizeFontFamily(value) { return utils.sanitizeFontFamily ? utils.sanitizeFontFamily(value) : String(value || '').trim(); } @@ -198,7 +194,7 @@ summaryParts.push('

' + escapeHtml(item.title) + '

'); } if (item.description) { - summaryParts.push('
' + sanitizeRichText(item.description) + '
'); + summaryParts.push('
' + sanitizePreviewHtml(item.description) + '
'); } body = summaryParts.join(''); } @@ -206,7 +202,7 @@ if (!body) { return ''; } - var renderedBody = sanitizeRichText(body); + var renderedBody = sanitizePreviewHtml(body); return renderedBody ? '
' + renderedBody + '
' : ''; } diff --git a/src/web/public/js/regions/type/schedule.js b/src/web/public/js/regions/type/timetable.js similarity index 97% rename from src/web/public/js/regions/type/schedule.js rename to src/web/public/js/regions/type/timetable.js index 3fb588c..3630e2e 100644 --- a/src/web/public/js/regions/type/schedule.js +++ b/src/web/public/js/regions/type/timetable.js @@ -15,10 +15,6 @@ return utils.escapeHtml ? utils.escapeHtml(value) : String(value === undefined || value === null ? '' : value); } - function sanitizeRichText(html) { - return utils.sanitizeRichText ? utils.sanitizeRichText(html) : escapeHtml(html); - } - function getDefaultStyle() { return { font_family: DEFAULT_STYLE.font_family, @@ -417,7 +413,7 @@ return '
' + entries.map(function (entry, index) { var entryDate = entry && (entry.start_datetime || entry.end_datetime || entry.date || entry.time || ''); var timezoneValues = getTimezoneValues(group, entryDate); - return '
' + sanitizeRichText(renderTemplate(value, Object.assign({}, entry || {}, { + return '
' + (utils.sanitizeRichText ? utils.sanitizeRichText(renderTemplate(value, Object.assign({}, entry || {}, { start: entry && entry.start_datetime !== undefined ? entry.start_datetime : '', end: entry && entry.end_datetime !== undefined ? entry.end_datetime : '', tz: timezoneValues.tz, @@ -426,7 +422,16 @@ group: group || {}, entries: entries, index: index + 1 - }))) + '
'; + }))) : escapeHtml(renderTemplate(value, Object.assign({}, entry || {}, { + start: entry && entry.start_datetime !== undefined ? entry.start_datetime : '', + end: entry && entry.end_datetime !== undefined ? entry.end_datetime : '', + tz: timezoneValues.tz, + tz_long: timezoneValues.tz_long, + timeZone: timezoneValues.tz_long, + group: group || {}, + entries: entries, + index: index + 1 + })))) + '
'; }).join('') + '
'; } diff --git a/src/web/public/js/slides/slide-form-editor.js b/src/web/public/js/slides/slide-form-editor.js index 2a5b436..a90711f 100644 --- a/src/web/public/js/slides/slide-form-editor.js +++ b/src/web/public/js/slides/slide-form-editor.js @@ -11,6 +11,16 @@ export function createSlideFormEditorController(options) { var getRegionTypeModule = typeof settings.getRegionTypeModule === 'function' ? settings.getRegionTypeModule : function () { return null; }; + var imageUploadUrl = String(settings.imageUploadUrl || '/slides/uploads').trim() || '/slides/uploads'; + var imageUploadMaxBytes = Math.max(1, Number(settings.imageUploadMaxBytes || 2 * 1024 * 1024)); + var imageUploadLimitLabel = String(settings.imageUploadLimitLabel || '').trim() || Math.max(1, Math.round(imageUploadMaxBytes / (1024 * 1024))) + ' MB'; + var imageUploadContext = String(settings.imageUploadContext || 'wysiwyg').trim() || 'wysiwyg'; + var imageUploadAllowedExtensions = ['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'avif', 'tif', 'tiff']; + var imageUploadAllowedMimeTypes = ['image/png', 'image/jpeg', 'image/gif', 'image/webp', 'image/bmp', 'image/avif', 'image/tiff']; + var imageUploadFileTypes = imageUploadAllowedExtensions.join(','); + var editorImageUploadPaths = new Set(); + var committedEditorImageUploadPaths = new Set(); + var pendingEditorImageUploadCleanupPaths = new Set(); var getEditorBackgroundColor = typeof settings.getEditorBackgroundColor === 'function' ? settings.getEditorBackgroundColor : function () { return '#111111'; }; @@ -29,6 +39,10 @@ export function createSlideFormEditorController(options) { return true; } + if (//gi, '') .replace(/]*>(?:\s| |)*<\/p>/gi, '') @@ -209,6 +223,191 @@ export function createSlideFormEditorController(options) { : themeAssets.contentCss; } + function normalizeUploadPath(value) { + return String(value || '').trim(); + } + + function collectEditorImageUploadPaths(html) { + var matches = String(html || '').match(/\/media\/uploads\/[^^\s"'<>]+/g); + return matches ? Array.from(new Set(matches.map(normalizeUploadPath).filter(Boolean))) : []; + } + + function collectCurrentEditorImageUploadPaths() { + var currentPaths = new Set(); + + editorInstances.forEach(function (editor, regionId) { + var hidden = getEditorHiddenInput(regionId); + var sourceElm = editor && editor.targetElm ? editor.targetElm : null; + var fallbackContent = hidden && hidden.value !== undefined ? hidden.value : (sourceElm && sourceElm.value !== undefined ? sourceElm.value : ''); + collectEditorImageUploadPaths(getEditorContentSafely(editor, fallbackContent)).forEach(function (path) { + currentPaths.add(path); + }); + }); + + return currentPaths; + } + + function getImageUploadCleanupPaths() { + var currentPaths = collectCurrentEditorImageUploadPaths(); + return Array.from(editorImageUploadPaths).filter(function (path) { + return !currentPaths.has(path); + }); + } + + function getCommittedImageUploadCleanupPaths() { + var currentPaths = collectCurrentEditorImageUploadPaths(); + return Array.from(committedEditorImageUploadPaths).filter(function (path) { + return !currentPaths.has(path); + }); + } + + function getPendingImageUploadPaths() { + return Array.from(editorImageUploadPaths).filter(function (path) { + return !committedEditorImageUploadPaths.has(path); + }); + } + + function queueImageUploadCleanupPaths(paths) { + Array.from(new Set((paths || []).map(normalizeUploadPath).filter(Boolean))).forEach(function (path) { + pendingEditorImageUploadCleanupPaths.add(path); + }); + } + + function getPendingImageUploadCleanupPaths() { + var currentPaths = collectCurrentEditorImageUploadPaths(); + return Array.from(pendingEditorImageUploadCleanupPaths).filter(function (path) { + return !currentPaths.has(path); + }); + } + + function markImageUploadsCommitted() { + committedEditorImageUploadPaths = collectCurrentEditorImageUploadPaths(); + } + + function getAllImageUploadPaths() { + return Array.from(editorImageUploadPaths); + } + + function clearImageUploadPaths() { + editorImageUploadPaths.clear(); + committedEditorImageUploadPaths.clear(); + pendingEditorImageUploadCleanupPaths.clear(); + } + + function getFileExtension(fileName) { + var match = String(fileName || '').toLowerCase().match(/\.([a-z0-9]+)$/); + return match ? String(match[1] || '') : ''; + } + + function getImageUploadValidationMessage(blobInfo) { + var blob = blobInfo && typeof blobInfo.blob === 'function' ? blobInfo.blob() : null; + var fileName = blobInfo && typeof blobInfo.filename === 'function' ? String(blobInfo.filename() || '') : ''; + var mimeType = blob && blob.type ? String(blob.type || '').trim().toLowerCase() : ''; + var extension = getFileExtension(fileName); + + if (!blob) { + return 'No image file was provided.'; + } + + if (Number(blob.size || 0) > imageUploadMaxBytes) { + return 'Image must be ' + imageUploadLimitLabel + ' or smaller. Larger images should use the dedicated Image region.'; + } + + if (mimeType && imageUploadAllowedMimeTypes.indexOf(mimeType) === -1) { + return 'This editor accepts PNG, JPG, GIF, WebP, BMP, AVIF, or TIFF images.'; + } + + if (!mimeType && extension && imageUploadAllowedExtensions.indexOf(extension) === -1) { + return 'This editor accepts PNG, JPG, GIF, WebP, BMP, AVIF, or TIFF images.'; + } + + if (!mimeType && !extension) { + return 'This editor accepts PNG, JPG, GIF, WebP, BMP, AVIF, or TIFF images.'; + } + + return ''; + } + + function uploadEditorImage(blobInfo, progress) { + var validationError = getImageUploadValidationMessage(blobInfo); + if (validationError) { + return Promise.reject(new Error(validationError)); + } + + return new Promise(function (resolve, reject) { + var xhr = new XMLHttpRequest(); + var formData = new FormData(); + var blob = blobInfo.blob(); + var fileName = typeof blobInfo.filename === 'function' ? String(blobInfo.filename() || 'image') : 'image'; + + formData.append('file', blob, fileName); + + xhr.open('POST', imageUploadUrl, true); + xhr.responseType = 'text'; + xhr.withCredentials = true; + xhr.setRequestHeader('X-Requested-With', 'XMLHttpRequest'); + xhr.setRequestHeader('Accept', 'application/json, text/plain, */*'); + xhr.setRequestHeader('X-Upload-Context', imageUploadContext); + + xhr.upload.onprogress = function (event) { + if (!progress) { + return; + } + + if (!event || !event.lengthComputable || !event.total) { + progress(0); + return; + } + + progress(Math.round((event.loaded / event.total) * 100)); + }; + + xhr.onload = function () { + var responseText = String(xhr.responseText || ''); + if (xhr.status < 200 || xhr.status >= 300) { + reject(new Error(responseText || 'Unable to upload image.')); + return; + } + + if (responseText.trim().toLowerCase().indexOf(' getUploadedFileLimitBytes(file)) { + if (Number(file.size || 0) > getUploadedFileLimitBytes(file, uploadContext)) { await removeUploadedFile(file); - const error = new Error('File must be ' + getUploadedFileLimitLabel(file) + ' or smaller.'); + 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.'); error.statusCode = 400; error.expose = true; throw error; @@ -376,7 +393,9 @@ module.exports = function registerContentRoutes(app, deps) { return res.status(400).json({ error: 'No file was uploaded.' }); } - await validateUploadedFiles([req.file]); + const uploadContext = String(req.get('X-Upload-Context') || req.query.context || '').trim().toLowerCase(); + + await validateUploadedFiles([req.file], uploadContext); res.json({ path: '/media/uploads/' + req.file.filename, diff --git a/test/admin-page.test.js b/test/admin-page.test.js index 29f4c88..783bafb 100644 --- a/test/admin-page.test.js +++ b/test/admin-page.test.js @@ -8,4 +8,11 @@ test('async save errors keep validation failures as warning toasts', () => { assert.ok(adminPageScript.includes('function isWarningSaveError(error)')); assert.ok(adminPageScript.includes('error.status = response.status;')); assert.ok(adminPageScript.includes('var variant = isWarningSaveError(error) ? \'warning\' : \'danger\';')); +}); + +test('async save runs success hooks before redirecting close or new saves', () => { + assert.ok(adminPageScript.includes('var responseText = await response.text();')); + assert.ok(adminPageScript.includes('if (typeof settings.afterSuccess === \'function\')')); + assert.ok(adminPageScript.includes('clearFormDirty(form);')); + assert.ok(adminPageScript.includes("if (submitterValue === 'close' || submitterValue === 'new')")); }); \ No newline at end of file diff --git a/test/canvas-sizes.test.js b/test/canvas-sizes.test.js index a3e75f4..6132cd1 100644 --- a/test/canvas-sizes.test.js +++ b/test/canvas-sizes.test.js @@ -292,3 +292,127 @@ test('slide upload cleanup route removes unused uploads', async () => { assert.equal(cleanupCall.uploadDir, 'e:\\Projects Git\\pulse-signage\\media\\uploads'); assert.deepEqual(cleanupCall.uploadPaths, ['/media/uploads/test-file.png']); }); + +test('wysiwyg image uploads are capped below the dedicated image region limit', async () => { + const handlers = {}; + const app = { + get(path, ...routeHandlers) { + handlers[path] = routeHandlers; + }, + post(path, ...routeHandlers) { + handlers[path] = routeHandlers; + } + }; + + const deps = { + pool: { + async query() { + return [[]]; + } + }, + common: { + fetchTemplatesData: async () => ({}), + fetchRssFeedsData: async () => ({ rssFeeds: [] }), + fetchApiSourcesData: async () => ({ apiSources: [] }), + fetchTimetablesData: async () => ({ timetableGroups: [] }), + parseJsonSafe: () => null, + fetchRssFeedItemsByFeedId: async () => [], + normalizeRssFeedItem: (item) => item, + fetchSlidesPage: async () => ({}), + fetchSlideById: async () => null, + fetchTemplatesPage: async () => ({}), + fetchTemplateById: async () => null, + fetchCanvasSizesPage: async () => ({}), + fetchCanvasSizeById: async () => null, + getSearchQuery: () => '', + getSortQuery: () => '', + getSortDirectionQuery: () => 'asc', + fetchDuplicateName: async () => null, + buildCanvasSizePayload + }, + pages: { + renderCanvasSizesPage() { return ''; }, + renderCanvasSizeEditPage() { return ''; }, + renderCanvasSizeAddPage() { return ''; }, + renderSlideAddPage() { return ''; }, + renderSlideEditPage() { return ''; }, + renderTemplatesPage() { return ''; }, + renderTemplateAddPage() { return ''; }, + renderTemplateEditPage() { return ''; } + }, + upload: { + any() { + return function (_req, _res, next) { + next(); + }; + }, + single() { + return function (_req, _res, next) { + next(); + }; + } + }, + setAuthMessageCookie() {}, + fetchScreensBySlideId: async () => [], + fetchScreensByTemplateId: async () => [], + collectUploadReferencesFromSlide: () => [], + collectUploadReferencesFromTemplate: () => [], + collectUploadReferencesFromPayload: () => [], + removeUnusedUploadFiles: async () => {}, + syncPlaylistUploadsOnChange: async () => {}, + getAuditUserId: () => 1, + redirectAfterSave: () => {}, + notifyPlayerScreens: async () => 0, + broadcastDashboardState: async () => {}, + backgroundTaskQueue: { enqueueTask: async () => null }, + getSlideDeleteBlockMessage: async () => '', + getTemplateDeleteBlockMessage: async () => '', + getCanvasSizeDeleteBlockMessage: async () => '', + requirePermission() { + return function (_req, _res, next) { + next(); + }; + }, + hasAnyPermission: () => true, + uploadDir: 'e:\\Projects Git\\pulse-signage\\media\\uploads' + }; + + registerContentRoutes(app, deps); + + const routeHandlers = handlers['/slides/uploads']; + assert.ok(Array.isArray(routeHandlers)); + + const req = { + file: { + filename: 'wysiwyg-large.png', + originalname: 'wysiwyg-large.png', + mimetype: 'image/png', + size: 11 * 1024 * 1024 + }, + get(headerName) { + return headerName === 'X-Upload-Context' ? 'wysiwyg' : ''; + }, + currentUser: { id: 1, permissions: ['slides.create'] } + }; + const res = { + statusCode: 0, + body: '', + json(body) { + this.body = body; + return this; + }, + status(code) { + this.statusCode = code; + return this; + } + }; + let nextError = null; + + await routeHandlers[2](req, res, (error) => { + nextError = error || null; + }); + + assert.ok(nextError); + assert.equal(nextError.statusCode, 400); + assert.equal(nextError.message, 'Image must be 2 MB or smaller. Larger images should use the dedicated Image region.'); +}); diff --git a/test/player-render-helpers.test.js b/test/player-render-helpers.test.js index a10a8cb..4ae6a3a 100644 --- a/test/player-render-helpers.test.js +++ b/test/player-render-helpers.test.js @@ -1,8 +1,11 @@ const test = require('node:test'); const assert = require('node:assert/strict'); +const fs = require('node:fs'); require('../src/common'); +const timetableRegionSource = fs.readFileSync(require.resolve('../src/player/regions/timetable.js'), 'utf8'); + const { mediaKind, normalizeSlide, @@ -18,11 +21,11 @@ test('mediaKind classifies player media by extension', () => { }); test('sanitizeRichText strips unsafe content but preserves allowed markup', () => { - const html = '
LinkText
'; + const html = '
LinkText
NameValue
AlphaBeta
Photo
'; assert.equal( sanitizeRichText(html), - '
LinkText
' + '
LinkText
NameValue
AlphaBeta
Photo
' ); }); @@ -78,4 +81,9 @@ test('renderEditorJsContent sanitizes editor blocks and wraps legacy text', () = '

Title

badok

  1. One
  2. Two
' ); assert.equal(renderEditorJsContent('plain text'), '

plain text

'); +}); + +test('timetable region registers the timetable type', () => { + assert.ok(timetableRegionSource.includes("registry.register('timetable'")); + assert.ok(timetableRegionSource.includes("sanitizeRichText(substituteTimetableVariables(value")); }); \ No newline at end of file diff --git a/test/player-time-date-region.test.js b/test/player-time-date-region.test.js new file mode 100644 index 0000000..5afe250 --- /dev/null +++ b/test/player-time-date-region.test.js @@ -0,0 +1,65 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const vm = require('node:vm'); + +function loadTimeDateModule() { + const webUiHelpersScript = fs.readFileSync(require.resolve('../src/web/public/js/web-ui-helpers.js'), 'utf8'); + const renderingScript = fs.readFileSync(require.resolve('../src/player/public/js/player-page-rendering.js'), 'utf8'); + const placeholderScript = fs.readFileSync(require.resolve('../src/web/public/js/shared/placeholder-utils.js'), 'utf8'); + const timeDateScript = fs.readFileSync(require.resolve('../src/player/regions/time-date.js'), 'utf8'); + const registry = new Map(); + const sandbox = { + document: { + addEventListener() {} + }, + window: { + pulsePlayerRegionTypes: { + register(type, module) { + registry.set(type, module); + } + }, + innerWidth: 1280, + innerHeight: 720, + Intl: Intl, + Date: Date, + Object: Object, + Array: Array, + Number: Number, + String: String, + Boolean: Boolean, + Math: Math, + JSON: JSON, + RegExp: RegExp, + console: console + } + }; + + sandbox.window = Object.assign({}, sandbox.window); + + vm.runInNewContext(webUiHelpersScript, sandbox, { filename: 'web-ui-helpers.js' }); + sandbox.escapeHtml = sandbox.window.escapeHtml; + vm.runInNewContext(renderingScript, sandbox, { filename: 'player-page-rendering.js' }); + vm.runInNewContext(placeholderScript, sandbox, { filename: 'placeholder-utils.js' }); + vm.runInNewContext(timeDateScript, sandbox, { filename: 'time-date.js' }); + + return registry.get('time-date'); +} + +test('time/date region renders placeholder tokens on the player side', () => { + const module = loadTimeDateModule(); + const markup = module.renderRegion( + { + pixelWidth: 320, + pixelHeight: 180, + canvasScale: 1, + baseStyle: 'position:absolute;' + }, + { + value: '{{hh}}:{{mm}}', + timezone: 'UTC' + } + ); + + assert.match(markup, /

\d{2}:\d{2}<\/p>/); +}); \ No newline at end of file diff --git a/test/slide-form-editor.test.js b/test/slide-form-editor.test.js index 06ea893..72ffcb0 100644 --- a/test/slide-form-editor.test.js +++ b/test/slide-form-editor.test.js @@ -3,7 +3,57 @@ const assert = require('node:assert/strict'); const fs = require('node:fs'); const slideFormEditorSource = fs.readFileSync(require.resolve('../src/web/public/js/slides/slide-form-editor.js'), 'utf8'); +const slideFormSource = fs.readFileSync(require.resolve('../src/web/public/js/slides/slide-form.js'), 'utf8'); +const slideThumbnailPreviewSource = fs.readFileSync(require.resolve('../src/web/lib/media/slide-thumbnail-preview.js'), 'utf8'); +const slideThumbnailsSource = fs.readFileSync(require.resolve('../src/web/lib/media/slide-thumbnails.js'), 'utf8'); test('slide editor disables pasted data images in TinyMCE', () => { assert.ok(slideFormEditorSource.includes('paste_data_images: false')); +}); + +test('slide editor enables server-backed image uploads', () => { + assert.ok(slideFormEditorSource.includes("plugins: 'lists code advlist fullscreen table image'")); + assert.ok(slideFormEditorSource.includes('automatic_uploads: true')); + assert.ok(slideFormEditorSource.includes('images_file_types: imageUploadFileTypes')); + assert.ok(slideFormEditorSource.includes('images_upload_handler: uploadEditorImage')); + assert.ok(slideFormEditorSource.includes('table image chip | fullscreen')); + assert.ok(slideFormEditorSource.includes('relative_urls: false')); + assert.ok(slideFormEditorSource.includes('remove_script_host: false')); +}); + +test('slide editor inserts tables with zero padding and spacing by default', () => { + assert.ok(slideFormEditorSource.includes("table_default_attributes: {")); + assert.ok(slideFormEditorSource.includes("cellpadding: '0'")); + assert.ok(slideFormEditorSource.includes("cellspacing: '0'")); + assert.ok(slideFormEditorSource.includes('td, th { border: 1px solid currentColor; padding: 0; vertical-align: top; }')); +}); + +test('slide editor uses a smaller wysiwyg image limit', () => { + assert.ok(slideFormEditorSource.includes('var imageUploadMaxBytes = Math.max(1, Number(settings.imageUploadMaxBytes || 2 * 1024 * 1024));')); + assert.ok(slideFormEditorSource.includes('Image must be ')); + assert.ok(slideFormEditorSource.includes('Larger images should use the dedicated Image region.')); + assert.ok(slideFormEditorSource.includes("xhr.setRequestHeader('X-Upload-Context', imageUploadContext);")); +}); + +test('slide editor tracks uploaded image paths for cleanup', () => { + assert.ok(slideFormEditorSource.includes('var editorImageUploadPaths = new Set();')); + assert.ok(slideFormEditorSource.includes('getImageUploadCleanupPaths')); + assert.ok(slideFormEditorSource.includes('getCommittedImageUploadCleanupPaths')); + assert.ok(slideFormEditorSource.includes('getPendingImageUploadPaths')); + assert.ok(slideFormEditorSource.includes('clearImageUploadPaths')); +}); + +test('slide editor keeps image-only rich text from being treated as empty', () => { + assert.ok(slideFormEditorSource.includes('/ { + assert.ok(slideFormSource.includes('getImageUploadCleanupPaths')); + assert.ok(slideFormSource.includes('getCommittedImageUploadCleanupPaths')); + assert.ok(slideFormSource.includes("regionMediaController.queueUploadCleanup(slideFormEditorController.getImageUploadCleanupPaths())")); +}); + +test('slide thumbnail previews treat image-only text as visible content', () => { + assert.ok(slideThumbnailPreviewSource.includes('/ { assert.equal(content.timetable.max_items, '7'); assert.equal(Object.prototype.hasOwnProperty.call(content.timetable, 'timetable_display_mode'), false); assert.equal(Object.prototype.hasOwnProperty.call(content.timetable, 'timetable_max_items'), false); +}); + +test('buildSlidePayload preserves safe image markup in text regions', async () => { + const pool = { + async query(sql) { + if (sql.includes('FROM c_templates st')) { + return [[{ id: 9, name: 'Template 9', canvas_size_id: 1, canvas_size_width: 1920, canvas_size_height: 1080 }]]; + } + + if (sql.includes('FROM c_template_regions')) { + return [[{ id: 47, template_id: 9, region_key: 'body', region_type: 'text', label: 'Body' }]]; + } + + return [[]]; + } + }; + + const payload = await buildSlidePayload(pool, { + body: { + title: 'Text slide', + template_id: '9', + region_text_47: '

Photo

' + }, + files: [] + }, null); + + const content = JSON.parse(payload.contentJson); + assert.equal(content.body.type, 'text'); + assert.equal(content.body.value, '

Photo

'); +}); + +test('buildSlidePayload preserves safe color spans in text regions', async () => { + const pool = { + async query(sql) { + if (sql.includes('FROM c_templates st')) { + return [[{ id: 9, name: 'Template 9', canvas_size_id: 1, canvas_size_width: 1920, canvas_size_height: 1080 }]]; + } + + if (sql.includes('FROM c_template_regions')) { + return [[{ id: 47, template_id: 9, region_key: 'body', region_type: 'text', label: 'Body' }]]; + } + + return [[]]; + } + }; + + const payload = await buildSlidePayload(pool, { + body: { + title: 'Text slide', + template_id: '9', + region_text_47: '

Hello

' + }, + files: [] + }, null); + + const content = JSON.parse(payload.contentJson); + assert.equal(content.body.type, 'text'); + assert.equal(content.body.value, '

Hello

'); }); \ No newline at end of file