Release v2.7.0
Publish Docker Image / build-and-push (./build/Dockerfile, git.lzstealth.com/lzstealth/pulse-signage-web, web) (push) Successful in 1m18s
Publish Docker Image / build-and-push (./build/Dockerfile.player, git.lzstealth.com/lzstealth/pulse-signage-player, player) (push) Successful in 33s
Publish Docker Image / build-and-push (./build/Dockerfile, git.lzstealth.com/lzstealth/pulse-signage-web, web) (push) Successful in 1m18s
Publish Docker Image / build-and-push (./build/Dockerfile.player, git.lzstealth.com/lzstealth/pulse-signage-player, player) (push) Successful in 33s
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
+1
-1
@@ -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": {
|
||||
|
||||
+69
-6
@@ -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, '"').replace(/'/g, ''') + '"');
|
||||
return '';
|
||||
});
|
||||
|
||||
return attrs.join('');
|
||||
}
|
||||
|
||||
function sanitizeRichText(html) {
|
||||
let output = String(html || '');
|
||||
output = output.replace(/<script[\s\S]*?<\/script>/gi, '');
|
||||
output = output.replace(/<style[\s\S]*?<\/style>/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 '<br>';
|
||||
if (closing) {
|
||||
return `</${name}>`;
|
||||
}
|
||||
return closing ? `</${name}>` : `<${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
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<link rel="icon" type="image/png" href="/assets/favicon.png" />
|
||||
<link rel="stylesheet" href="/assets/adminlte/bootstrap-icons/css/bootstrap-icons.min.css" />
|
||||
<link rel="stylesheet" href="/assets/vendor/animate.css/animate.min.css" />
|
||||
<link rel="stylesheet" href="/assets/css/player.css?v=36" />
|
||||
<link rel="stylesheet" href="/assets/css/player.css?v=37" />
|
||||
{{{STYLESHEETS}}}
|
||||
</head>
|
||||
<body class="{{BODY_CLASS}}">
|
||||
|
||||
@@ -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%;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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, '"')
|
||||
.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 '<div class="template-region time-date" data-time-date-format="' + escapeHtml(format) + '" data-time-date-timezone="' + escapeHtml(timeZone) + '" style="' + region.baseStyle + '"><div class="template-region-text-scale" style="' + contentStyle + '">' + renderEditorJsContent(renderedText) + '</div></div>';
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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, '"')
|
||||
.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(/<script[\s\S]*?<\/script>/gi, '');
|
||||
output = output.replace(/<style[\s\S]*?<\/style>/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 '</' + name + '>';
|
||||
}
|
||||
return '<' + name + sanitizeRichTextAttributes(name, String(match[3] || '')) + '>';
|
||||
});
|
||||
}
|
||||
|
||||
function substituteTimetableVariables(html, entry) {
|
||||
var source = String(html || '');
|
||||
return source.replace(/\{\{\s*([^{}]+?)\s*\}\}/g, function (_match, expression) {
|
||||
@@ -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();
|
||||
|
||||
@@ -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 (/<img\b/i.test(raw)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return Boolean(raw.replace(/<[^>]+>/g, '').trim());
|
||||
}
|
||||
|
||||
function buildTextRegionMarkup(region, regionContent) {
|
||||
|
||||
@@ -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 (/<img\b/i.test(raw)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return Boolean(raw.replace(/<[^>]+>/g, '').trim());
|
||||
}
|
||||
|
||||
function buildTextRegionMarkup(region, regionContent) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 '</' + name + '>';
|
||||
}
|
||||
@@ -42,7 +50,100 @@
|
||||
}
|
||||
|
||||
function sanitizeRichText(html) {
|
||||
return sanitizePreviewHtml(html);
|
||||
var output = String(html || '');
|
||||
output = output.replace(/<script[\s\S]*?<\/script>/gi, '');
|
||||
output = output.replace(/<style[\s\S]*?<\/style>/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 '</' + name + '>';
|
||||
}
|
||||
|
||||
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,
|
||||
|
||||
@@ -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('<h3>' + escapeHtml(title) + '</h3>');
|
||||
}
|
||||
if (description) {
|
||||
summary.push('<div>' + sanitizeRichText(description) + '</div>');
|
||||
summary.push('<div>' + sanitizePreviewHtml(description) + '</div>');
|
||||
}
|
||||
if (!summary.length) {
|
||||
return '';
|
||||
@@ -183,7 +183,7 @@
|
||||
if (!body) {
|
||||
return '';
|
||||
}
|
||||
var renderedBody = sanitizeRichText(body);
|
||||
var renderedBody = sanitizePreviewHtml(body);
|
||||
return renderedBody ? '<div class="template-region api" style="width:100%;height:100%;overflow:hidden;font-family:' + escapeHtml(fontFamily) + ';font-size:' + fontSize + 'px;color:' + escapeHtml(fontColor) + ';"><div class="template-region-text-scale" style="width:100%;height:100%;overflow:hidden;">' + renderedBody + '</div></div>' : '';
|
||||
}
|
||||
|
||||
|
||||
@@ -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('<h3>' + escapeHtml(item.title) + '</h3>');
|
||||
}
|
||||
if (item.description) {
|
||||
summaryParts.push('<div>' + sanitizeRichText(item.description) + '</div>');
|
||||
summaryParts.push('<div>' + sanitizePreviewHtml(item.description) + '</div>');
|
||||
}
|
||||
body = summaryParts.join('');
|
||||
}
|
||||
@@ -206,7 +202,7 @@
|
||||
if (!body) {
|
||||
return '';
|
||||
}
|
||||
var renderedBody = sanitizeRichText(body);
|
||||
var renderedBody = sanitizePreviewHtml(body);
|
||||
return renderedBody ? '<div class="template-region rss" style="width:100%;height:100%;overflow:hidden;font-family:' + escapeHtml(fontFamily) + ';font-size:' + fontSize + 'px;color:' + escapeHtml(fontColor) + ';"><div class="template-region-text-scale" style="width:100%;height:100%;overflow:hidden;">' + renderedBody + '</div></div>' : '';
|
||||
}
|
||||
|
||||
|
||||
+11
-6
@@ -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 '<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) + ';' : '') + '">' + entries.map(function (entry, index) {
|
||||
var entryDate = entry && (entry.start_datetime || entry.end_datetime || entry.date || entry.time || '');
|
||||
var timezoneValues = getTimezoneValues(group, entryDate);
|
||||
return '<div class="timetable-region-entry" data-timetable-entry-index="' + index + '">' + sanitizeRichText(renderTemplate(value, Object.assign({}, entry || {}, {
|
||||
return '<div class="timetable-region-entry" data-timetable-entry-index="' + index + '">' + (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
|
||||
}))) + '</div>';
|
||||
}))) : 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
|
||||
})))) + '</div>';
|
||||
}).join('') + '</div>';
|
||||
}
|
||||
|
||||
@@ -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 (/<img\b/i.test(raw)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
var stripped = raw
|
||||
.replace(/<\s*br\s*\/?>/gi, '')
|
||||
.replace(/<p[^>]*>(?:\s| |<br\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('<!doctype html') === 0 || responseText.toLowerCase().indexOf('<html') !== -1) {
|
||||
reject(new Error('Upload redirected to an HTML page. Please sign in again and retry.'));
|
||||
return;
|
||||
}
|
||||
|
||||
var payload = {};
|
||||
try {
|
||||
payload = JSON.parse(responseText || '{}') || {};
|
||||
} catch (_error) {
|
||||
reject(new Error('Unable to parse the upload response.'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!payload.path) {
|
||||
reject(new Error('Unable to upload image.'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (progress) {
|
||||
progress(100);
|
||||
}
|
||||
|
||||
editorImageUploadPaths.add(String(payload.path || '').trim());
|
||||
|
||||
resolve(String(payload.path || ''));
|
||||
};
|
||||
|
||||
xhr.onerror = function () {
|
||||
reject(new Error('Unable to upload image.'));
|
||||
};
|
||||
|
||||
xhr.ontimeout = function () {
|
||||
reject(new Error('Upload timed out. Please try again.'));
|
||||
};
|
||||
|
||||
xhr.send(formData);
|
||||
});
|
||||
}
|
||||
|
||||
function attachEditorEvents(regionId, editor) {
|
||||
var hidden = getEditorHiddenInput(regionId);
|
||||
var source = editor && editor.targetElm ? editor.targetElm : null;
|
||||
@@ -305,16 +504,27 @@ export function createSlideFormEditorController(options) {
|
||||
menubar: false,
|
||||
branding: false,
|
||||
promotion: false,
|
||||
relative_urls: false,
|
||||
remove_script_host: false,
|
||||
convert_urls: true,
|
||||
paste_data_images: false,
|
||||
plugins: 'lists code advlist fullscreen table',
|
||||
toolbar: 'undo redo | fontfamily fontsizeinput | forecolor backcolor bold italic underlineformats removeformat | align lineheight indent outdent bullist numlist table chip | fullscreen',
|
||||
automatic_uploads: true,
|
||||
images_file_types: imageUploadFileTypes,
|
||||
images_upload_handler: uploadEditorImage,
|
||||
plugins: 'lists code advlist fullscreen table image',
|
||||
toolbar: 'undo redo | fontfamily fontsizeinput | forecolor backcolor bold italic underlineformats removeformat | align lineheight indent outdent bullist numlist table image chip | fullscreen',
|
||||
toolbar_mode: 'sliding',
|
||||
license_key: 'gpl',
|
||||
table_default_attributes: {
|
||||
border: '1',
|
||||
cellpadding: '0',
|
||||
cellspacing: '0'
|
||||
},
|
||||
skin: themeAssets.skinName,
|
||||
skin_url: themeAssets.skinUrl,
|
||||
content_css: getContentCss(),
|
||||
body_class: themeAssets.bodyClass,
|
||||
content_style: 'body { font-family: ' + defaultEditorFontFamily + '; font-size: 32px; line-height: 1.5; background-color: ' + getEditorBackgroundColorValue() + '; } p { margin: 1em 0; } p:first-child { margin-top: 0; } p:last-child { margin-bottom: 1em; } table { border-collapse: collapse; width: 100%; } td, th { border: 1px solid currentColor; padding: 0.35em 0.5em; vertical-align: top; } th { font-weight: 700; }' + (editorContentStyle ? ' ' + editorContentStyle : ''),
|
||||
content_style: 'body { font-family: ' + defaultEditorFontFamily + '; font-size: 32px; line-height: 1.5; background-color: ' + getEditorBackgroundColorValue() + '; } p { margin: 1em 0; } p:first-child { margin-top: 0; } p:last-child { margin-bottom: 1em; } table { border-collapse: collapse; border-spacing: 0; width: 100%; } td, th { border: 1px solid currentColor; padding: 0; vertical-align: top; } th { font-weight: 700; }' + (editorContentStyle ? ' ' + editorContentStyle : ''),
|
||||
font_family_formats: getFontFamilyFormats(),
|
||||
font_size_input_default_unit: 'px',
|
||||
invalid_elements: 'a',
|
||||
@@ -394,6 +604,7 @@ export function createSlideFormEditorController(options) {
|
||||
if (editor.targetElm) {
|
||||
editor.targetElm.value = editor.getContent({ format: 'html' });
|
||||
}
|
||||
markImageUploadsCommitted();
|
||||
return editor;
|
||||
}).catch(function (error) {
|
||||
console.error('Failed to initialize TinyMCE.', error);
|
||||
@@ -431,7 +642,10 @@ export function createSlideFormEditorController(options) {
|
||||
return Promise.resolve();
|
||||
});
|
||||
|
||||
return Promise.all(saves);
|
||||
return Promise.all(saves).then(function (results) {
|
||||
getCommittedImageUploadCleanupPaths();
|
||||
return results;
|
||||
});
|
||||
}
|
||||
|
||||
function destroyEditors() {
|
||||
@@ -469,6 +683,14 @@ export function createSlideFormEditorController(options) {
|
||||
return {
|
||||
renderEditors: renderEditors,
|
||||
syncEditors: syncEditors,
|
||||
getImageUploadCleanupPaths: getImageUploadCleanupPaths,
|
||||
getCommittedImageUploadCleanupPaths: getCommittedImageUploadCleanupPaths,
|
||||
getPendingImageUploadPaths: getPendingImageUploadPaths,
|
||||
getPendingImageUploadCleanupPaths: getPendingImageUploadCleanupPaths,
|
||||
getAllImageUploadPaths: getAllImageUploadPaths,
|
||||
queueImageUploadCleanupPaths: queueImageUploadCleanupPaths,
|
||||
markImageUploadsCommitted: markImageUploadsCommitted,
|
||||
clearImageUploadPaths: clearImageUploadPaths,
|
||||
destroyEditors: function () {
|
||||
if (themeObserver) {
|
||||
themeObserver.disconnect();
|
||||
|
||||
@@ -41,6 +41,8 @@ import { createSlideFormPreviewHelpers } from '/assets/js/slides/slide-form-prev
|
||||
var uploadMaxLabel = '100 MB';
|
||||
var uploadVideoMaxBytes = 1024 * 1024 * 1024;
|
||||
var uploadVideoMaxLabel = '1 GB';
|
||||
var wysiwygImageUploadMaxBytes = 2 * 1024 * 1024;
|
||||
var wysiwygImageUploadLimitLabel = '2 MB';
|
||||
var videoDurationCache = Object.create(null);
|
||||
var previewRenderFrame = 0;
|
||||
var previewPopupWindow = null;
|
||||
@@ -106,6 +108,9 @@ import { createSlideFormPreviewHelpers } from '/assets/js/slides/slide-form-prev
|
||||
defaultFontSize: DEFAULT_FONT_SIZE,
|
||||
fontFamilyFormats: slideEditorData.fontFamilyFormats || '',
|
||||
fontStylesheetHref: fontStylesheetHref,
|
||||
imageUploadMaxBytes: wysiwygImageUploadMaxBytes,
|
||||
imageUploadLimitLabel: wysiwygImageUploadLimitLabel,
|
||||
imageUploadContext: 'wysiwyg',
|
||||
getRegionTypeModule: getRegionTypeModule,
|
||||
getEditorBackgroundColor: function () {
|
||||
var template = getTemplateById(templateSelect.value);
|
||||
@@ -923,6 +928,12 @@ import { createSlideFormPreviewHelpers } from '/assets/js/slides/slide-form-prev
|
||||
if (regionMediaController) {
|
||||
regionMediaController.queueUploadCleanup(regionMediaController.getPendingUploadCleanupPaths());
|
||||
}
|
||||
if (slideFormEditorController && typeof slideFormEditorController.getCommittedImageUploadCleanupPaths === 'function' && regionMediaController) {
|
||||
regionMediaController.queueUploadCleanup(slideFormEditorController.getCommittedImageUploadCleanupPaths());
|
||||
}
|
||||
if (slideFormEditorController && typeof slideFormEditorController.getImageUploadCleanupPaths === 'function' && regionMediaController) {
|
||||
regionMediaController.queueUploadCleanup(slideFormEditorController.getImageUploadCleanupPaths());
|
||||
}
|
||||
if (previewPopupWindow && !previewPopupWindow.closed) {
|
||||
previewPopupWindow.close();
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ module.exports = function registerContentRoutes(app, deps) {
|
||||
const { buildDuplicateCanvasSizeName, buildDuplicateCanvasSize } = require('#src/web/routes/signage/canvas-sizes/duplicate');
|
||||
|
||||
const LIST_PAGE_SIZE = 25;
|
||||
const WYSIWYG_IMAGE_UPLOAD_MAX_BYTES = 2 * 1024 * 1024;
|
||||
const IMAGE_UPLOAD_MAX_BYTES = 100 * 1024 * 1024;
|
||||
const VIDEO_UPLOAD_MAX_BYTES = 1024 * 1024 * 1024;
|
||||
|
||||
@@ -104,9 +105,15 @@ module.exports = function registerContentRoutes(app, deps) {
|
||||
next(error);
|
||||
}
|
||||
|
||||
function getUploadedFileMediaType(file) {
|
||||
function getUploadedFileMediaType(file, uploadContext) {
|
||||
const mimeType = String(file && file.mimetype || '').trim().toLowerCase();
|
||||
const extension = path.extname(String(file && file.originalname || '')).toLowerCase();
|
||||
const isWysiwyg = String(uploadContext || '').trim().toLowerCase() === 'wysiwyg';
|
||||
|
||||
if (isWysiwyg && mimeType.indexOf('image/') !== 0 && ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.avif', '.tif', '.tiff'].indexOf(extension) === -1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (mimeType.indexOf('video/') === 0 || ['.mp4', '.webm', '.ogg', '.ogv', '.mov', '.avi', '.mkv'].indexOf(extension) !== -1) {
|
||||
return 'video';
|
||||
}
|
||||
@@ -116,12 +123,20 @@ module.exports = function registerContentRoutes(app, deps) {
|
||||
return null;
|
||||
}
|
||||
|
||||
function getUploadedFileLimitBytes(file) {
|
||||
return getUploadedFileMediaType(file) === 'video' ? VIDEO_UPLOAD_MAX_BYTES : IMAGE_UPLOAD_MAX_BYTES;
|
||||
function getUploadedFileLimitBytes(file, uploadContext) {
|
||||
if (String(uploadContext || '').trim().toLowerCase() === 'wysiwyg') {
|
||||
return WYSIWYG_IMAGE_UPLOAD_MAX_BYTES;
|
||||
}
|
||||
|
||||
return getUploadedFileMediaType(file, uploadContext) === 'video' ? VIDEO_UPLOAD_MAX_BYTES : IMAGE_UPLOAD_MAX_BYTES;
|
||||
}
|
||||
|
||||
function getUploadedFileLimitLabel(file) {
|
||||
return getUploadedFileMediaType(file) === 'video' ? '1 GB' : '100 MB';
|
||||
function getUploadedFileLimitLabel(file, uploadContext) {
|
||||
if (String(uploadContext || '').trim().toLowerCase() === 'wysiwyg') {
|
||||
return '10 MB';
|
||||
}
|
||||
|
||||
return getUploadedFileMediaType(file, uploadContext) === 'video' ? '1 GB' : '100 MB';
|
||||
}
|
||||
|
||||
async function removeUploadedFile(file) {
|
||||
@@ -137,11 +152,11 @@ module.exports = function registerContentRoutes(app, deps) {
|
||||
}
|
||||
}
|
||||
|
||||
async function validateUploadedFiles(files) {
|
||||
async function validateUploadedFiles(files, uploadContext) {
|
||||
const list = Array.isArray(files) ? files.filter(Boolean) : [];
|
||||
for (let i = 0; i < list.length; i += 1) {
|
||||
const file = list[i];
|
||||
const mediaType = getUploadedFileMediaType(file);
|
||||
const mediaType = getUploadedFileMediaType(file, uploadContext);
|
||||
if (!mediaType) {
|
||||
await removeUploadedFile(file);
|
||||
const error = new Error('Unsupported upload type.');
|
||||
@@ -150,9 +165,11 @@ module.exports = function registerContentRoutes(app, deps) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (Number(file.size || 0) > 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,
|
||||
|
||||
@@ -9,3 +9,10 @@ test('async save errors keep validation failures as warning toasts', () => {
|
||||
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')"));
|
||||
});
|
||||
@@ -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.');
|
||||
});
|
||||
|
||||
@@ -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 = '<div class="wrap"><a href="https://example.com" target="_blank">Link</a><script>alert(1)</script><span style="color:red">Text</span><img src="x" onerror="alert(1)"></div>';
|
||||
const html = '<div class="wrap"><a href="https://example.com" target="_blank">Link</a><script>alert(1)</script><span style="color:red">Text</span><table class="grid"><colgroup><col span="1" style="width:50%"><col span="1" style="width:50%"></colgroup><thead><tr><th scope="col">Name</th><th scope="col">Value</th></tr></thead><tbody><tr><td>Alpha</td><td>Beta</td></tr></tbody></table><img src="/media/uploads/photo.png" alt="Photo" loading="lazy" onerror="alert(1)"></div>';
|
||||
|
||||
assert.equal(
|
||||
sanitizeRichText(html),
|
||||
'<div class="wrap"><a href="https://example.com" target="_blank" rel="noreferrer noopener">Link</a><span style="color:red">Text</span></div>'
|
||||
'<div class="wrap"><a href="https://example.com" target="_blank" rel="noreferrer noopener">Link</a><span style="color:red">Text</span><table class="grid"><colgroup><col span="1" style="width:50%"><col span="1" style="width:50%"></colgroup><thead><tr><th scope="col">Name</th><th scope="col">Value</th></tr></thead><tbody><tr><td>Alpha</td><td>Beta</td></tr></tbody></table><img src="/media/uploads/photo.png" alt="Photo" loading="lazy"></div>'
|
||||
);
|
||||
});
|
||||
|
||||
@@ -79,3 +82,8 @@ test('renderEditorJsContent sanitizes editor blocks and wraps legacy text', () =
|
||||
);
|
||||
assert.equal(renderEditorJsContent('plain text'), '<p>plain text</p>');
|
||||
});
|
||||
|
||||
test('timetable region registers the timetable type', () => {
|
||||
assert.ok(timetableRegionSource.includes("registry.register('timetable'"));
|
||||
assert.ok(timetableRegionSource.includes("sanitizeRichText(substituteTimetableVariables(value"));
|
||||
});
|
||||
@@ -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, /<p>\d{2}:\d{2}<\/p>/);
|
||||
});
|
||||
@@ -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('/<img\\b/i.test(raw)'));
|
||||
});
|
||||
|
||||
test('slide form queues editor image cleanup on save and close', () => {
|
||||
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('/<img\\b/i.test(raw)'));
|
||||
assert.ok(slideThumbnailsSource.includes('/<img\\b/i.test(raw)'));
|
||||
});
|
||||
@@ -6,7 +6,7 @@ const vm = require('node:vm');
|
||||
function loadScheduleModule(overrides) {
|
||||
const timeDatePlaceholdersScript = fs.readFileSync(require.resolve('../src/web/public/js/shared/time-date-placeholders.js'), 'utf8');
|
||||
const placeholderScript = fs.readFileSync(require.resolve('../src/web/public/js/shared/placeholder-utils.js'), 'utf8');
|
||||
const scriptPath = require.resolve('../src/web/public/js/regions/type/schedule.js');
|
||||
const scriptPath = require.resolve('../src/web/public/js/regions/type/timetable.js');
|
||||
const script = fs.readFileSync(scriptPath, 'utf8');
|
||||
const registry = new Map();
|
||||
const customWindow = overrides && overrides.window ? overrides.window : {};
|
||||
@@ -161,8 +161,8 @@ test('timetable region preview resolves timezone placeholders from explicit time
|
||||
id: 101,
|
||||
title: 'Launch',
|
||||
short_description: 'Doors open',
|
||||
start_datetime: '2026-08-10T10:00:00.000Z',
|
||||
end_datetime: '2026-08-10T11:00:00.000Z'
|
||||
start_datetime: '2026-08-20T10:00:00.000Z',
|
||||
end_datetime: '2026-08-20T11:00:00.000Z'
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -281,8 +281,8 @@ test('timetable preview does not force the group timezone into placeholder trans
|
||||
id: 101,
|
||||
title: 'Launch',
|
||||
short_description: 'Doors open',
|
||||
start_datetime: '2026-08-10T10:00:00.000Z',
|
||||
end_datetime: '2026-08-10T11:00:00.000Z'
|
||||
start_datetime: '2026-08-20T10:00:00.000Z',
|
||||
end_datetime: '2026-08-20T11:00:00.000Z'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -40,3 +40,61 @@ test('buildSlidePayload normalizes timetable region fields', async () => {
|
||||
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: '<p><img src="/media/uploads/photo.png" alt="Photo" onerror="alert(1)"></p>'
|
||||
},
|
||||
files: []
|
||||
}, null);
|
||||
|
||||
const content = JSON.parse(payload.contentJson);
|
||||
assert.equal(content.body.type, 'text');
|
||||
assert.equal(content.body.value, '<p><img src="/media/uploads/photo.png" alt="Photo"></p>');
|
||||
});
|
||||
|
||||
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: '<p><span style="color:#ff0000" class="text-emphasis">Hello</span></p>'
|
||||
},
|
||||
files: []
|
||||
}, null);
|
||||
|
||||
const content = JSON.parse(payload.contentJson);
|
||||
assert.equal(content.body.type, 'text');
|
||||
assert.equal(content.body.value, '<p><span style="color:#ff0000" class="text-emphasis">Hello</span></p>');
|
||||
});
|
||||
Reference in New Issue
Block a user