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.
|
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
|
## 2.6.27 - 2026-08-14
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "pulse-signage-player",
|
"name": "pulse-signage-player",
|
||||||
"version": "2.6.27",
|
"version": "2.7.0",
|
||||||
"private": false,
|
"private": false,
|
||||||
"description": "Pulse Signage player application bundle",
|
"description": "Pulse Signage player application bundle",
|
||||||
"main": "src/common.js",
|
"main": "src/common.js",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "pulse-signage-web",
|
"name": "pulse-signage-web",
|
||||||
"version": "2.6.27",
|
"version": "2.7.0",
|
||||||
"private": false,
|
"private": false,
|
||||||
"description": "Pulse Signage web and bridge application bundle",
|
"description": "Pulse Signage web and bridge application bundle",
|
||||||
"main": "src/common.js",
|
"main": "src/common.js",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "pulse-signage",
|
"name": "pulse-signage",
|
||||||
"version": "2.6.27",
|
"version": "2.7.0",
|
||||||
"private": false,
|
"private": false,
|
||||||
"description": "Pulse Signage application with MySQL and media storage",
|
"description": "Pulse Signage application with MySQL and media storage",
|
||||||
"repository": {
|
"repository": {
|
||||||
|
|||||||
+69
-6
@@ -6,27 +6,90 @@ const { parseJsonSafe, validateMaxLength } = require('./utils');
|
|||||||
const TITLE_MAX_LENGTH = 255;
|
const TITLE_MAX_LENGTH = 255;
|
||||||
const { buildQrCodeContent } = require('./qr-code');
|
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 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) {
|
function sanitizeRichText(html) {
|
||||||
let output = String(html || '');
|
let output = String(html || '');
|
||||||
output = output.replace(/<script[\s\S]*?<\/script>/gi, '');
|
output = output.replace(/<script[\s\S]*?<\/script>/gi, '');
|
||||||
output = output.replace(/<style[\s\S]*?<\/style>/gi, '');
|
output = output.replace(/<style[\s\S]*?<\/style>/gi, '');
|
||||||
return output.replace(/<[^>]+>/g, (tag) => {
|
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) {
|
if (!match) {
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
const closing = Boolean(match[1]);
|
const closing = Boolean(match[1]);
|
||||||
const name = String(match[2] || '').toLowerCase();
|
const name = String(match[2] || '').toLowerCase();
|
||||||
|
const attrText = String(match[3] || '');
|
||||||
if (!ALLOWED_RICH_TEXT_TAGS.includes(name)) {
|
if (!ALLOWED_RICH_TEXT_TAGS.includes(name)) {
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
if (name === 'br') {
|
if (closing) {
|
||||||
return '<br>';
|
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);
|
const style = getTextRegionStyle(body, region, existingContent);
|
||||||
content[region.region_key] = {
|
content[region.region_key] = {
|
||||||
type: 'text',
|
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_family: style.font_family,
|
||||||
font_size: style.font_size,
|
font_size: style.font_size,
|
||||||
font_color: style.font_color
|
font_color: style.font_color
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
<link rel="icon" type="image/png" href="/assets/favicon.png" />
|
<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/adminlte/bootstrap-icons/css/bootstrap-icons.min.css" />
|
||||||
<link rel="stylesheet" href="/assets/vendor/animate.css/animate.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}}}
|
{{{STYLESHEETS}}}
|
||||||
</head>
|
</head>
|
||||||
<body class="{{BODY_CLASS}}">
|
<body class="{{BODY_CLASS}}">
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ body {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: #111;
|
background: #0a0a0a;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
font-family: Arial, sans-serif;
|
font-family: Arial, sans-serif;
|
||||||
}
|
}
|
||||||
@@ -43,7 +43,7 @@ body.thumbnail-preview .player-offline-banner {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
background: #111;
|
background: #0a0a0a;
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -294,15 +294,6 @@ body.screen-blackout #app {
|
|||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
.slide img,
|
|
||||||
.slide video,
|
|
||||||
.slide iframe {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
object-fit: contain;
|
|
||||||
border: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.body {
|
.body {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 5%;
|
left: 5%;
|
||||||
|
|||||||
@@ -334,7 +334,7 @@ function setPlayerCanvasDimensions(canvasWidth, canvasHeight) {
|
|||||||
document.documentElement.style.setProperty('--player-canvas-height', height + 'px');
|
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) {
|
function sanitizeRichTextAttributes(tagName, attrText) {
|
||||||
const allowedAttributes = {
|
const allowedAttributes = {
|
||||||
@@ -349,14 +349,19 @@ function sanitizeRichTextAttributes(tagName, attrText) {
|
|||||||
h4: ['class', 'style'],
|
h4: ['class', 'style'],
|
||||||
h5: ['class', 'style'],
|
h5: ['class', 'style'],
|
||||||
h6: ['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'],
|
li: ['class', 'style'],
|
||||||
ol: ['class', 'style', 'start'],
|
ol: ['class', 'style', 'start'],
|
||||||
p: ['class', 'style'],
|
p: ['class', 'style'],
|
||||||
pre: ['class', 'style'],
|
pre: ['class', 'style'],
|
||||||
span: ['class', 'style'],
|
span: ['class', 'style'],
|
||||||
table: ['class', 'style'],
|
table: ['class', 'style'],
|
||||||
|
tbody: ['class', 'style'],
|
||||||
td: ['class', 'style', 'colspan', 'rowspan'],
|
td: ['class', 'style', 'colspan', 'rowspan'],
|
||||||
th: ['class', 'style', 'colspan', 'rowspan', 'scope'],
|
th: ['class', 'style', 'colspan', 'rowspan', 'scope'],
|
||||||
|
thead: ['class', 'style'],
|
||||||
tr: ['class', 'style'],
|
tr: ['class', 'style'],
|
||||||
ul: ['class', 'style']
|
ul: ['class', 'style']
|
||||||
};
|
};
|
||||||
@@ -365,6 +370,14 @@ function sanitizeRichTextAttributes(tagName, attrText) {
|
|||||||
return '';
|
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 = [];
|
const attrs = [];
|
||||||
String(attrText || '').replace(/([a-zA-Z0-9:-]+)(?:\s*=\s*("([^"]*)"|'([^']*)'|([^\s"'>/=`]+)))?/g, (_full, key, _valuePart, doubleQuoted, singleQuoted, bareValue) => {
|
String(attrText || '').replace(/([a-zA-Z0-9:-]+)(?:\s*=\s*("([^"]*)"|'([^']*)'|([^\s"'>/=`]+)))?/g, (_full, key, _valuePart, doubleQuoted, singleQuoted, bareValue) => {
|
||||||
const lowerKey = String(key || '').toLowerCase();
|
const lowerKey = String(key || '').toLowerCase();
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
// Time/date region rendering and live updates.
|
// Time/date region rendering and live updates.
|
||||||
|
|
||||||
var registry = window.pulsePlayerRegionTypes;
|
var registry = window.pulsePlayerRegionTypes;
|
||||||
|
var placeholderUtils = window.placeholderUtils || {};
|
||||||
var DEFAULT_FORMAT = '{{hh}}:{{mm}}';
|
var DEFAULT_FORMAT = '{{hh}}:{{mm}}';
|
||||||
var DEFAULT_STYLE = {
|
var DEFAULT_STYLE = {
|
||||||
font_family: 'Arial',
|
font_family: 'Arial',
|
||||||
@@ -9,15 +10,6 @@ var DEFAULT_STYLE = {
|
|||||||
};
|
};
|
||||||
var timeDateFormatterCache = Object.create(null);
|
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) {
|
function sanitizeTagAttributes(tagName, attrText) {
|
||||||
var allowedAttributes = {
|
var allowedAttributes = {
|
||||||
a: ['href', 'title', 'target', 'rel', 'class', 'style'],
|
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]) {
|
if (!timeDateFormatterCache[key]) {
|
||||||
timeDateFormatterCache[key] = new Intl.DateTimeFormat('en-GB', options);
|
timeDateFormatterCache[key] = new Intl.DateTimeFormat('en-GB', options);
|
||||||
}
|
}
|
||||||
@@ -141,10 +133,10 @@ function getFormatter(key, options) {
|
|||||||
return timeDateFormatterCache[key];
|
return timeDateFormatterCache[key];
|
||||||
}
|
}
|
||||||
|
|
||||||
function getFormattedParts(timeZone, date) {
|
function getTimeDateFormattedParts(timeZone, date) {
|
||||||
var targetDate = date instanceof Date ? date : new Date();
|
var targetDate = date instanceof Date ? date : new Date();
|
||||||
var resolvedTimeZone = resolveTimeZone(timeZone);
|
var resolvedTimeZone = resolveTimeZone(timeZone);
|
||||||
var numericParts = getFormatter('numeric:' + resolvedTimeZone, {
|
var numericParts = getTimeDateFormatter('numeric:' + resolvedTimeZone, {
|
||||||
timeZone: resolvedTimeZone,
|
timeZone: resolvedTimeZone,
|
||||||
hour12: false,
|
hour12: false,
|
||||||
hour: '2-digit',
|
hour: '2-digit',
|
||||||
@@ -154,29 +146,29 @@ function getFormattedParts(timeZone, date) {
|
|||||||
month: '2-digit',
|
month: '2-digit',
|
||||||
year: 'numeric'
|
year: 'numeric'
|
||||||
}).formatToParts(targetDate);
|
}).formatToParts(targetDate);
|
||||||
var weekdayLong = getFormatter('weekday-long:' + resolvedTimeZone, {
|
var weekdayLong = getTimeDateFormatter('weekday-long:' + resolvedTimeZone, {
|
||||||
timeZone: resolvedTimeZone,
|
timeZone: resolvedTimeZone,
|
||||||
weekday: 'long'
|
weekday: 'long'
|
||||||
}).formatToParts(targetDate);
|
}).formatToParts(targetDate);
|
||||||
var weekdayShort = getFormatter('weekday-short:' + resolvedTimeZone, {
|
var weekdayShort = getTimeDateFormatter('weekday-short:' + resolvedTimeZone, {
|
||||||
timeZone: resolvedTimeZone,
|
timeZone: resolvedTimeZone,
|
||||||
weekday: 'short'
|
weekday: 'short'
|
||||||
}).formatToParts(targetDate);
|
}).formatToParts(targetDate);
|
||||||
var monthLong = getFormatter('month-long:' + resolvedTimeZone, {
|
var monthLong = getTimeDateFormatter('month-long:' + resolvedTimeZone, {
|
||||||
timeZone: resolvedTimeZone,
|
timeZone: resolvedTimeZone,
|
||||||
month: 'long'
|
month: 'long'
|
||||||
}).formatToParts(targetDate);
|
}).formatToParts(targetDate);
|
||||||
var monthShort = getFormatter('month-short:' + resolvedTimeZone, {
|
var monthShort = getTimeDateFormatter('month-short:' + resolvedTimeZone, {
|
||||||
timeZone: resolvedTimeZone,
|
timeZone: resolvedTimeZone,
|
||||||
month: 'short'
|
month: 'short'
|
||||||
}).formatToParts(targetDate);
|
}).formatToParts(targetDate);
|
||||||
var ampm = getFormatter('ampm:' + resolvedTimeZone, {
|
var ampm = getTimeDateFormatter('ampm:' + resolvedTimeZone, {
|
||||||
timeZone: resolvedTimeZone,
|
timeZone: resolvedTimeZone,
|
||||||
hour12: true,
|
hour12: true,
|
||||||
hour: '2-digit',
|
hour: '2-digit',
|
||||||
minute: '2-digit'
|
minute: '2-digit'
|
||||||
}).formatToParts(targetDate);
|
}).formatToParts(targetDate);
|
||||||
var timezoneShort = getFormatter('tz-short:' + resolvedTimeZone, {
|
var timezoneShort = getTimeDateFormatter('tz-short:' + resolvedTimeZone, {
|
||||||
timeZone: resolvedTimeZone,
|
timeZone: resolvedTimeZone,
|
||||||
timeZoneName: 'short'
|
timeZoneName: 'short'
|
||||||
}).formatToParts(targetDate);
|
}).formatToParts(targetDate);
|
||||||
@@ -220,20 +212,21 @@ function getFormattedParts(timeZone, date) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveTimeDatePlaceholder(values, expression) {
|
function resolveTimeDateTemplatePlaceholder(values, expression) {
|
||||||
if (typeof placeholderUtils.resolvePlaceholderExpression === 'function' && typeof placeholderUtils.formatPlaceholderValue === 'function') {
|
var currentPlaceholderUtils = window.placeholderUtils || placeholderUtils || {};
|
||||||
return placeholderUtils.formatPlaceholderValue(placeholderUtils.resolvePlaceholderExpression(values, expression));
|
if (typeof currentPlaceholderUtils.resolvePlaceholderExpression === 'function' && typeof currentPlaceholderUtils.formatPlaceholderValue === 'function') {
|
||||||
|
return currentPlaceholderUtils.formatPlaceholderValue(currentPlaceholderUtils.resolvePlaceholderExpression(values, expression));
|
||||||
}
|
}
|
||||||
|
|
||||||
var parsed = String(expression || '').trim();
|
var parsed = String(expression || '').trim();
|
||||||
return Object.prototype.hasOwnProperty.call(values, parsed) ? values[parsed] : '';
|
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 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 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 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 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 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>';
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
scaleWrapper.innerHTML = renderEditorJsContent(renderTemplate(format, timeZone, new Date()));
|
scaleWrapper.innerHTML = renderEditorJsContent(renderTimeDateTemplate(format, timeZone, new Date()));
|
||||||
}
|
}
|
||||||
|
|
||||||
function scheduleTimeDateRegionUpdate(element) {
|
function scheduleTimeDateRegionUpdate(element) {
|
||||||
|
|||||||
@@ -2,101 +2,6 @@
|
|||||||
|
|
||||||
var registry = window.pulsePlayerRegionTypes;
|
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) {
|
function substituteTimetableVariables(html, entry) {
|
||||||
var source = String(html || '');
|
var source = String(html || '');
|
||||||
return source.replace(/\{\{\s*([^{}]+?)\s*\}\}/g, function (_match, expression) {
|
return source.replace(/\{\{\s*([^{}]+?)\s*\}\}/g, function (_match, expression) {
|
||||||
@@ -41,7 +41,7 @@ function sanitizeTextColor(value, fallback) {
|
|||||||
return fallback || '#000000';
|
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) {
|
function sanitizeRichTextAttributes(tagName, attrText) {
|
||||||
const allowedAttributes = {
|
const allowedAttributes = {
|
||||||
@@ -56,6 +56,9 @@ function sanitizeRichTextAttributes(tagName, attrText) {
|
|||||||
h4: ['class', 'style'],
|
h4: ['class', 'style'],
|
||||||
h5: ['class', 'style'],
|
h5: ['class', 'style'],
|
||||||
h6: ['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'],
|
li: ['class', 'style'],
|
||||||
ol: ['class', 'style', 'start'],
|
ol: ['class', 'style', 'start'],
|
||||||
p: ['class', 'style'],
|
p: ['class', 'style'],
|
||||||
@@ -72,6 +75,14 @@ function sanitizeRichTextAttributes(tagName, attrText) {
|
|||||||
return '';
|
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 = [];
|
const attrs = [];
|
||||||
String(attrText || '').replace(/([a-zA-Z0-9:-]+)(?:\s*=\s*("([^"]*)"|'([^']*)'|([^\s"'>/=`]+)))?/g, (_full, key, _valuePart, doubleQuoted, singleQuoted, bareValue) => {
|
String(attrText || '').replace(/([a-zA-Z0-9:-]+)(?:\s*=\s*("([^"]*)"|'([^']*)'|([^\s"'>/=`]+)))?/g, (_full, key, _valuePart, doubleQuoted, singleQuoted, bareValue) => {
|
||||||
const lowerKey = String(key || '').toLowerCase();
|
const lowerKey = String(key || '').toLowerCase();
|
||||||
|
|||||||
@@ -53,7 +53,16 @@ function buildThumbnailRegionStyle(region, canvasWidth, canvasHeight) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function hasVisibleContent(html) {
|
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) {
|
function buildTextRegionMarkup(region, regionContent) {
|
||||||
|
|||||||
@@ -83,7 +83,16 @@ function buildThumbnailRegionStyle(region, canvasWidth, canvasHeight) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function hasVisibleContent(html) {
|
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) {
|
function buildTextRegionMarkup(region, regionContent) {
|
||||||
|
|||||||
@@ -423,19 +423,6 @@
|
|||||||
return true;
|
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 responseText = await response.text();
|
||||||
var responseDocument = null;
|
var responseDocument = null;
|
||||||
try {
|
try {
|
||||||
@@ -456,6 +443,19 @@
|
|||||||
|
|
||||||
clearFormDirty(form);
|
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'
|
var successMessage = typeof settings.getSuccessMessage === 'function'
|
||||||
? settings.getSuccessMessage({
|
? settings.getSuccessMessage({
|
||||||
form: form,
|
form: form,
|
||||||
|
|||||||
@@ -28,11 +28,19 @@
|
|||||||
var name = String(match[2] || '').toLowerCase();
|
var name = String(match[2] || '').toLowerCase();
|
||||||
var attrText = String(match[3] || '');
|
var attrText = String(match[3] || '');
|
||||||
var selfClosing = Boolean(match[4]) || name === 'br' || name === 'hr';
|
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) {
|
if (allowed.indexOf(name) === -1) {
|
||||||
return '';
|
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) {
|
if (closing) {
|
||||||
return '</' + name + '>';
|
return '</' + name + '>';
|
||||||
}
|
}
|
||||||
@@ -42,7 +50,100 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function sanitizeRichText(html) {
|
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) {
|
function sanitizeTagAttributes(tagName, attrText) {
|
||||||
@@ -64,6 +165,7 @@
|
|||||||
pre: ['class', 'style'],
|
pre: ['class', 'style'],
|
||||||
span: ['class', 'style'],
|
span: ['class', 'style'],
|
||||||
table: ['class', 'style'],
|
table: ['class', 'style'],
|
||||||
|
img: ['src', 'alt', 'title', 'width', 'height', 'class', 'style', 'loading', 'decoding'],
|
||||||
td: ['class', 'style', 'colspan', 'rowspan'],
|
td: ['class', 'style', 'colspan', 'rowspan'],
|
||||||
th: ['class', 'style', 'colspan', 'rowspan', 'scope'],
|
th: ['class', 'style', 'colspan', 'rowspan', 'scope'],
|
||||||
tr: ['class', 'style'],
|
tr: ['class', 'style'],
|
||||||
@@ -844,6 +946,7 @@
|
|||||||
escapeHtml: escapeHtml,
|
escapeHtml: escapeHtml,
|
||||||
sanitizePreviewHtml: sanitizePreviewHtml,
|
sanitizePreviewHtml: sanitizePreviewHtml,
|
||||||
sanitizeRichText: sanitizeRichText,
|
sanitizeRichText: sanitizeRichText,
|
||||||
|
sanitizeRichTextAttributes: sanitizeRichTextAttributes,
|
||||||
sanitizeFontFamily: sanitizeFontFamily,
|
sanitizeFontFamily: sanitizeFontFamily,
|
||||||
sanitizeTextColor: sanitizeTextColor,
|
sanitizeTextColor: sanitizeTextColor,
|
||||||
normalizeAcceptList: normalizeAcceptList,
|
normalizeAcceptList: normalizeAcceptList,
|
||||||
|
|||||||
@@ -9,8 +9,8 @@
|
|||||||
return utils.escapeHtml ? utils.escapeHtml(value) : String(value === undefined || value === null ? '' : value);
|
return utils.escapeHtml ? utils.escapeHtml(value) : String(value === undefined || value === null ? '' : value);
|
||||||
}
|
}
|
||||||
|
|
||||||
function sanitizeRichText(html) {
|
function sanitizePreviewHtml(html) {
|
||||||
return utils.sanitizeRichText ? utils.sanitizeRichText(html) : escapeHtml(html);
|
return utils.sanitizePreviewHtml ? utils.sanitizePreviewHtml(html) : escapeHtml(html);
|
||||||
}
|
}
|
||||||
|
|
||||||
function sanitizeFontFamily(value) {
|
function sanitizeFontFamily(value) {
|
||||||
@@ -128,7 +128,7 @@
|
|||||||
summary.push('<h3>' + escapeHtml(title) + '</h3>');
|
summary.push('<h3>' + escapeHtml(title) + '</h3>');
|
||||||
}
|
}
|
||||||
if (description) {
|
if (description) {
|
||||||
summary.push('<div>' + sanitizeRichText(description) + '</div>');
|
summary.push('<div>' + sanitizePreviewHtml(description) + '</div>');
|
||||||
}
|
}
|
||||||
if (!summary.length) {
|
if (!summary.length) {
|
||||||
return '';
|
return '';
|
||||||
@@ -183,7 +183,7 @@
|
|||||||
if (!body) {
|
if (!body) {
|
||||||
return '';
|
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>' : '';
|
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);
|
return utils.sanitizePreviewHtml ? utils.sanitizePreviewHtml(html) : escapeHtml(html);
|
||||||
}
|
}
|
||||||
|
|
||||||
function sanitizeRichText(html) {
|
|
||||||
return utils.sanitizeRichText ? utils.sanitizeRichText(html) : sanitizePreviewHtml(html);
|
|
||||||
}
|
|
||||||
|
|
||||||
function sanitizeFontFamily(value) {
|
function sanitizeFontFamily(value) {
|
||||||
return utils.sanitizeFontFamily ? utils.sanitizeFontFamily(value) : String(value || '').trim();
|
return utils.sanitizeFontFamily ? utils.sanitizeFontFamily(value) : String(value || '').trim();
|
||||||
}
|
}
|
||||||
@@ -198,7 +194,7 @@
|
|||||||
summaryParts.push('<h3>' + escapeHtml(item.title) + '</h3>');
|
summaryParts.push('<h3>' + escapeHtml(item.title) + '</h3>');
|
||||||
}
|
}
|
||||||
if (item.description) {
|
if (item.description) {
|
||||||
summaryParts.push('<div>' + sanitizeRichText(item.description) + '</div>');
|
summaryParts.push('<div>' + sanitizePreviewHtml(item.description) + '</div>');
|
||||||
}
|
}
|
||||||
body = summaryParts.join('');
|
body = summaryParts.join('');
|
||||||
}
|
}
|
||||||
@@ -206,7 +202,7 @@
|
|||||||
if (!body) {
|
if (!body) {
|
||||||
return '';
|
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>' : '';
|
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);
|
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() {
|
function getDefaultStyle() {
|
||||||
return {
|
return {
|
||||||
font_family: DEFAULT_STYLE.font_family,
|
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) {
|
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 entryDate = entry && (entry.start_datetime || entry.end_datetime || entry.date || entry.time || '');
|
||||||
var timezoneValues = getTimezoneValues(group, entryDate);
|
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 : '',
|
start: entry && entry.start_datetime !== undefined ? entry.start_datetime : '',
|
||||||
end: entry && entry.end_datetime !== undefined ? entry.end_datetime : '',
|
end: entry && entry.end_datetime !== undefined ? entry.end_datetime : '',
|
||||||
tz: timezoneValues.tz,
|
tz: timezoneValues.tz,
|
||||||
@@ -426,7 +422,16 @@
|
|||||||
group: group || {},
|
group: group || {},
|
||||||
entries: entries,
|
entries: entries,
|
||||||
index: index + 1
|
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>';
|
}).join('') + '</div>';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -11,6 +11,16 @@ export function createSlideFormEditorController(options) {
|
|||||||
var getRegionTypeModule = typeof settings.getRegionTypeModule === 'function' ? settings.getRegionTypeModule : function () {
|
var getRegionTypeModule = typeof settings.getRegionTypeModule === 'function' ? settings.getRegionTypeModule : function () {
|
||||||
return null;
|
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 () {
|
var getEditorBackgroundColor = typeof settings.getEditorBackgroundColor === 'function' ? settings.getEditorBackgroundColor : function () {
|
||||||
return '#111111';
|
return '#111111';
|
||||||
};
|
};
|
||||||
@@ -29,6 +39,10 @@ export function createSlideFormEditorController(options) {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (/<img\b/i.test(raw)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
var stripped = raw
|
var stripped = raw
|
||||||
.replace(/<\s*br\s*\/?>/gi, '')
|
.replace(/<\s*br\s*\/?>/gi, '')
|
||||||
.replace(/<p[^>]*>(?:\s| |<br\s*\/?>)*<\/p>/gi, '')
|
.replace(/<p[^>]*>(?:\s| |<br\s*\/?>)*<\/p>/gi, '')
|
||||||
@@ -209,6 +223,191 @@ export function createSlideFormEditorController(options) {
|
|||||||
: themeAssets.contentCss;
|
: 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) {
|
function attachEditorEvents(regionId, editor) {
|
||||||
var hidden = getEditorHiddenInput(regionId);
|
var hidden = getEditorHiddenInput(regionId);
|
||||||
var source = editor && editor.targetElm ? editor.targetElm : null;
|
var source = editor && editor.targetElm ? editor.targetElm : null;
|
||||||
@@ -305,16 +504,27 @@ export function createSlideFormEditorController(options) {
|
|||||||
menubar: false,
|
menubar: false,
|
||||||
branding: false,
|
branding: false,
|
||||||
promotion: false,
|
promotion: false,
|
||||||
|
relative_urls: false,
|
||||||
|
remove_script_host: false,
|
||||||
|
convert_urls: true,
|
||||||
paste_data_images: false,
|
paste_data_images: false,
|
||||||
plugins: 'lists code advlist fullscreen table',
|
automatic_uploads: true,
|
||||||
toolbar: 'undo redo | fontfamily fontsizeinput | forecolor backcolor bold italic underlineformats removeformat | align lineheight indent outdent bullist numlist table chip | fullscreen',
|
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',
|
toolbar_mode: 'sliding',
|
||||||
license_key: 'gpl',
|
license_key: 'gpl',
|
||||||
|
table_default_attributes: {
|
||||||
|
border: '1',
|
||||||
|
cellpadding: '0',
|
||||||
|
cellspacing: '0'
|
||||||
|
},
|
||||||
skin: themeAssets.skinName,
|
skin: themeAssets.skinName,
|
||||||
skin_url: themeAssets.skinUrl,
|
skin_url: themeAssets.skinUrl,
|
||||||
content_css: getContentCss(),
|
content_css: getContentCss(),
|
||||||
body_class: themeAssets.bodyClass,
|
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_family_formats: getFontFamilyFormats(),
|
||||||
font_size_input_default_unit: 'px',
|
font_size_input_default_unit: 'px',
|
||||||
invalid_elements: 'a',
|
invalid_elements: 'a',
|
||||||
@@ -394,6 +604,7 @@ export function createSlideFormEditorController(options) {
|
|||||||
if (editor.targetElm) {
|
if (editor.targetElm) {
|
||||||
editor.targetElm.value = editor.getContent({ format: 'html' });
|
editor.targetElm.value = editor.getContent({ format: 'html' });
|
||||||
}
|
}
|
||||||
|
markImageUploadsCommitted();
|
||||||
return editor;
|
return editor;
|
||||||
}).catch(function (error) {
|
}).catch(function (error) {
|
||||||
console.error('Failed to initialize TinyMCE.', error);
|
console.error('Failed to initialize TinyMCE.', error);
|
||||||
@@ -431,7 +642,10 @@ export function createSlideFormEditorController(options) {
|
|||||||
return Promise.resolve();
|
return Promise.resolve();
|
||||||
});
|
});
|
||||||
|
|
||||||
return Promise.all(saves);
|
return Promise.all(saves).then(function (results) {
|
||||||
|
getCommittedImageUploadCleanupPaths();
|
||||||
|
return results;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function destroyEditors() {
|
function destroyEditors() {
|
||||||
@@ -469,6 +683,14 @@ export function createSlideFormEditorController(options) {
|
|||||||
return {
|
return {
|
||||||
renderEditors: renderEditors,
|
renderEditors: renderEditors,
|
||||||
syncEditors: syncEditors,
|
syncEditors: syncEditors,
|
||||||
|
getImageUploadCleanupPaths: getImageUploadCleanupPaths,
|
||||||
|
getCommittedImageUploadCleanupPaths: getCommittedImageUploadCleanupPaths,
|
||||||
|
getPendingImageUploadPaths: getPendingImageUploadPaths,
|
||||||
|
getPendingImageUploadCleanupPaths: getPendingImageUploadCleanupPaths,
|
||||||
|
getAllImageUploadPaths: getAllImageUploadPaths,
|
||||||
|
queueImageUploadCleanupPaths: queueImageUploadCleanupPaths,
|
||||||
|
markImageUploadsCommitted: markImageUploadsCommitted,
|
||||||
|
clearImageUploadPaths: clearImageUploadPaths,
|
||||||
destroyEditors: function () {
|
destroyEditors: function () {
|
||||||
if (themeObserver) {
|
if (themeObserver) {
|
||||||
themeObserver.disconnect();
|
themeObserver.disconnect();
|
||||||
|
|||||||
@@ -41,6 +41,8 @@ import { createSlideFormPreviewHelpers } from '/assets/js/slides/slide-form-prev
|
|||||||
var uploadMaxLabel = '100 MB';
|
var uploadMaxLabel = '100 MB';
|
||||||
var uploadVideoMaxBytes = 1024 * 1024 * 1024;
|
var uploadVideoMaxBytes = 1024 * 1024 * 1024;
|
||||||
var uploadVideoMaxLabel = '1 GB';
|
var uploadVideoMaxLabel = '1 GB';
|
||||||
|
var wysiwygImageUploadMaxBytes = 2 * 1024 * 1024;
|
||||||
|
var wysiwygImageUploadLimitLabel = '2 MB';
|
||||||
var videoDurationCache = Object.create(null);
|
var videoDurationCache = Object.create(null);
|
||||||
var previewRenderFrame = 0;
|
var previewRenderFrame = 0;
|
||||||
var previewPopupWindow = null;
|
var previewPopupWindow = null;
|
||||||
@@ -106,6 +108,9 @@ import { createSlideFormPreviewHelpers } from '/assets/js/slides/slide-form-prev
|
|||||||
defaultFontSize: DEFAULT_FONT_SIZE,
|
defaultFontSize: DEFAULT_FONT_SIZE,
|
||||||
fontFamilyFormats: slideEditorData.fontFamilyFormats || '',
|
fontFamilyFormats: slideEditorData.fontFamilyFormats || '',
|
||||||
fontStylesheetHref: fontStylesheetHref,
|
fontStylesheetHref: fontStylesheetHref,
|
||||||
|
imageUploadMaxBytes: wysiwygImageUploadMaxBytes,
|
||||||
|
imageUploadLimitLabel: wysiwygImageUploadLimitLabel,
|
||||||
|
imageUploadContext: 'wysiwyg',
|
||||||
getRegionTypeModule: getRegionTypeModule,
|
getRegionTypeModule: getRegionTypeModule,
|
||||||
getEditorBackgroundColor: function () {
|
getEditorBackgroundColor: function () {
|
||||||
var template = getTemplateById(templateSelect.value);
|
var template = getTemplateById(templateSelect.value);
|
||||||
@@ -923,6 +928,12 @@ import { createSlideFormPreviewHelpers } from '/assets/js/slides/slide-form-prev
|
|||||||
if (regionMediaController) {
|
if (regionMediaController) {
|
||||||
regionMediaController.queueUploadCleanup(regionMediaController.getPendingUploadCleanupPaths());
|
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) {
|
if (previewPopupWindow && !previewPopupWindow.closed) {
|
||||||
previewPopupWindow.close();
|
previewPopupWindow.close();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ module.exports = function registerContentRoutes(app, deps) {
|
|||||||
const { buildDuplicateCanvasSizeName, buildDuplicateCanvasSize } = require('#src/web/routes/signage/canvas-sizes/duplicate');
|
const { buildDuplicateCanvasSizeName, buildDuplicateCanvasSize } = require('#src/web/routes/signage/canvas-sizes/duplicate');
|
||||||
|
|
||||||
const LIST_PAGE_SIZE = 25;
|
const LIST_PAGE_SIZE = 25;
|
||||||
|
const WYSIWYG_IMAGE_UPLOAD_MAX_BYTES = 2 * 1024 * 1024;
|
||||||
const IMAGE_UPLOAD_MAX_BYTES = 100 * 1024 * 1024;
|
const IMAGE_UPLOAD_MAX_BYTES = 100 * 1024 * 1024;
|
||||||
const VIDEO_UPLOAD_MAX_BYTES = 1024 * 1024 * 1024;
|
const VIDEO_UPLOAD_MAX_BYTES = 1024 * 1024 * 1024;
|
||||||
|
|
||||||
@@ -104,9 +105,15 @@ module.exports = function registerContentRoutes(app, deps) {
|
|||||||
next(error);
|
next(error);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getUploadedFileMediaType(file) {
|
function getUploadedFileMediaType(file, uploadContext) {
|
||||||
const mimeType = String(file && file.mimetype || '').trim().toLowerCase();
|
const mimeType = String(file && file.mimetype || '').trim().toLowerCase();
|
||||||
const extension = path.extname(String(file && file.originalname || '')).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) {
|
if (mimeType.indexOf('video/') === 0 || ['.mp4', '.webm', '.ogg', '.ogv', '.mov', '.avi', '.mkv'].indexOf(extension) !== -1) {
|
||||||
return 'video';
|
return 'video';
|
||||||
}
|
}
|
||||||
@@ -116,12 +123,20 @@ module.exports = function registerContentRoutes(app, deps) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getUploadedFileLimitBytes(file) {
|
function getUploadedFileLimitBytes(file, uploadContext) {
|
||||||
return getUploadedFileMediaType(file) === 'video' ? VIDEO_UPLOAD_MAX_BYTES : IMAGE_UPLOAD_MAX_BYTES;
|
if (String(uploadContext || '').trim().toLowerCase() === 'wysiwyg') {
|
||||||
|
return WYSIWYG_IMAGE_UPLOAD_MAX_BYTES;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getUploadedFileLimitLabel(file) {
|
return getUploadedFileMediaType(file, uploadContext) === 'video' ? VIDEO_UPLOAD_MAX_BYTES : IMAGE_UPLOAD_MAX_BYTES;
|
||||||
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) {
|
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) : [];
|
const list = Array.isArray(files) ? files.filter(Boolean) : [];
|
||||||
for (let i = 0; i < list.length; i += 1) {
|
for (let i = 0; i < list.length; i += 1) {
|
||||||
const file = list[i];
|
const file = list[i];
|
||||||
const mediaType = getUploadedFileMediaType(file);
|
const mediaType = getUploadedFileMediaType(file, uploadContext);
|
||||||
if (!mediaType) {
|
if (!mediaType) {
|
||||||
await removeUploadedFile(file);
|
await removeUploadedFile(file);
|
||||||
const error = new Error('Unsupported upload type.');
|
const error = new Error('Unsupported upload type.');
|
||||||
@@ -150,9 +165,11 @@ module.exports = function registerContentRoutes(app, deps) {
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Number(file.size || 0) > getUploadedFileLimitBytes(file)) {
|
if (Number(file.size || 0) > getUploadedFileLimitBytes(file, uploadContext)) {
|
||||||
await removeUploadedFile(file);
|
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.statusCode = 400;
|
||||||
error.expose = true;
|
error.expose = true;
|
||||||
throw error;
|
throw error;
|
||||||
@@ -376,7 +393,9 @@ module.exports = function registerContentRoutes(app, deps) {
|
|||||||
return res.status(400).json({ error: 'No file was uploaded.' });
|
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({
|
res.json({
|
||||||
path: '/media/uploads/' + req.file.filename,
|
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('error.status = response.status;'));
|
||||||
assert.ok(adminPageScript.includes('var variant = isWarningSaveError(error) ? \'warning\' : \'danger\';'));
|
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.equal(cleanupCall.uploadDir, 'e:\\Projects Git\\pulse-signage\\media\\uploads');
|
||||||
assert.deepEqual(cleanupCall.uploadPaths, ['/media/uploads/test-file.png']);
|
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 test = require('node:test');
|
||||||
const assert = require('node:assert/strict');
|
const assert = require('node:assert/strict');
|
||||||
|
const fs = require('node:fs');
|
||||||
|
|
||||||
require('../src/common');
|
require('../src/common');
|
||||||
|
|
||||||
|
const timetableRegionSource = fs.readFileSync(require.resolve('../src/player/regions/timetable.js'), 'utf8');
|
||||||
|
|
||||||
const {
|
const {
|
||||||
mediaKind,
|
mediaKind,
|
||||||
normalizeSlide,
|
normalizeSlide,
|
||||||
@@ -18,11 +21,11 @@ test('mediaKind classifies player media by extension', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('sanitizeRichText strips unsafe content but preserves allowed markup', () => {
|
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(
|
assert.equal(
|
||||||
sanitizeRichText(html),
|
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>');
|
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 fs = require('node:fs');
|
||||||
|
|
||||||
const slideFormEditorSource = fs.readFileSync(require.resolve('../src/web/public/js/slides/slide-form-editor.js'), 'utf8');
|
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', () => {
|
test('slide editor disables pasted data images in TinyMCE', () => {
|
||||||
assert.ok(slideFormEditorSource.includes('paste_data_images: false'));
|
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) {
|
function loadScheduleModule(overrides) {
|
||||||
const timeDatePlaceholdersScript = fs.readFileSync(require.resolve('../src/web/public/js/shared/time-date-placeholders.js'), 'utf8');
|
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 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 script = fs.readFileSync(scriptPath, 'utf8');
|
||||||
const registry = new Map();
|
const registry = new Map();
|
||||||
const customWindow = overrides && overrides.window ? overrides.window : {};
|
const customWindow = overrides && overrides.window ? overrides.window : {};
|
||||||
@@ -161,8 +161,8 @@ test('timetable region preview resolves timezone placeholders from explicit time
|
|||||||
id: 101,
|
id: 101,
|
||||||
title: 'Launch',
|
title: 'Launch',
|
||||||
short_description: 'Doors open',
|
short_description: 'Doors open',
|
||||||
start_datetime: '2026-08-10T10:00:00.000Z',
|
start_datetime: '2026-08-20T10:00:00.000Z',
|
||||||
end_datetime: '2026-08-10T11: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,
|
id: 101,
|
||||||
title: 'Launch',
|
title: 'Launch',
|
||||||
short_description: 'Doors open',
|
short_description: 'Doors open',
|
||||||
start_datetime: '2026-08-10T10:00:00.000Z',
|
start_datetime: '2026-08-20T10:00:00.000Z',
|
||||||
end_datetime: '2026-08-10T11: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_display_mode'), false);
|
||||||
assert.equal(Object.prototype.hasOwnProperty.call(content.timetable, 'timetable_max_items'), false);
|
assert.equal(Object.prototype.hasOwnProperty.call(content.timetable, 'timetable_max_items'), false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('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