diff --git a/CHANGELOG.md b/CHANGELOG.md index 597c0b7..41e9da7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ All notable changes to this project will be documented in this file. - Fixed playlist schedule summaries showing weekday text and omitting boundary times when dates are returned as `Date` objects. - Preserved saved API region items-path overrides when reopening the slide editor instead of replacing them with the source default. +- Fixed managed-font loading in thumbnail popup previews and preserved entity-encoded font-family quotes across preview sanitization. ## 2.20.4 - 2026-10-25 diff --git a/src/web/public/js/regions/region-utils.js b/src/web/public/js/regions/region-utils.js index 21857fd..b86bd68 100644 --- a/src/web/public/js/regions/region-utils.js +++ b/src/web/public/js/regions/region-utils.js @@ -14,6 +14,12 @@ .replace(/'/g, '''); } + function decodeAttributeValue(value) { + var decoder = document.createElement('textarea'); + decoder.innerHTML = String(value || '').replace(//gi, ''); @@ -123,7 +129,7 @@ return ''; } - var value = doubleQuoted !== undefined ? doubleQuoted : singleQuoted !== undefined ? singleQuoted : bareValue !== undefined ? bareValue : ''; + var value = decodeAttributeValue(doubleQuoted !== undefined ? doubleQuoted : singleQuoted !== undefined ? singleQuoted : bareValue !== undefined ? bareValue : ''); if (lowerKey === 'href' && /^(?:\s*javascript:|\s*data:)/i.test(String(value || ''))) { return ''; } @@ -185,7 +191,7 @@ return ''; } - var value = doubleQuoted !== undefined ? doubleQuoted : singleQuoted !== undefined ? singleQuoted : bareValue !== undefined ? bareValue : ''; + var value = decodeAttributeValue(doubleQuoted !== undefined ? doubleQuoted : singleQuoted !== undefined ? singleQuoted : bareValue !== undefined ? bareValue : ''); if (lowerKey === 'href' && /^(?:\s*javascript:|\s*data:)/i.test(String(value || ''))) { return ''; } diff --git a/src/web/routes/register.js b/src/web/routes/register.js index 29554ac..e03a3b4 100644 --- a/src/web/routes/register.js +++ b/src/web/routes/register.js @@ -102,6 +102,7 @@ function registerAuthAndAccountRoutes(app, deps) { pool: deps.pool, common: deps.common, pages: deps.pages, + mediaDir: deps.mediaDir, requirePermission: deps.requirePermission, buildDashboardState: deps.buildDashboardState, fetchScreensByPlaylistId: deps.fetchScreensByPlaylistId, diff --git a/test/slide-preview-renderer.test.js b/test/slide-preview-renderer.test.js index fbd564d..73a5a92 100644 --- a/test/slide-preview-renderer.test.js +++ b/test/slide-preview-renderer.test.js @@ -35,6 +35,57 @@ function loadEditorTimeDateModule() { return modules.get('time-date'); } +test('preview sanitizers preserve entity-quoted font families without double escaping', () => { + const entities = { apos: "'", quot: '"', amp: '&', lt: '<', gt: '>' }; + const sandbox = { + window: {}, + document: { + createElement(tag) { + assert.equal(tag, 'textarea'); + return { + set innerHTML(value) { + this.value = value.replace(/&(#x[\da-f]+|#\d+|apos|quot|amp|lt|gt);/gi, (_match, entity) => { + if (entity[0] !== '#') return entities[entity]; + return String.fromCodePoint(entity[1].toLowerCase() === 'x' ? parseInt(entity.slice(2), 16) : Number(entity.slice(1))); + }); + } + }; + } + } + }; + vm.runInNewContext(fs.readFileSync(require.resolve('../src/web/public/js/regions/region-utils'), 'utf8'), sandbox); + + for (const sanitize of [sandbox.window.pulseRegionUtils.sanitizePreviewHtml, sandbox.window.pulseRegionUtils.sanitizeRichText]) { + for (const quotes of ["'Ubuntu Regular'", ''Ubuntu Regular'', '"Ubuntu Regular"']) { + const markup = sanitize('Player'); + assert.doesNotMatch(markup, /&#39;|&quot;/); + assert.equal(sanitize(markup), markup); + assert.match(markup, /font-family: (?:'Ubuntu Regular'|"Ubuntu Regular");/); + } + const unsafe = sanitize('Player'); + assert.doesNotMatch(unsafe, /href=|style=/); + } +}); + +test('page route wiring preserves the configured media directory for popup font stylesheets', () => { + let pageDependencies; + const sandbox = { + module: { exports: {} }, + require(name) { + if (name === '#src/rbac') return { PERMISSIONS: [], normalizePermissionKeys: value => value }; + return function (_app, dependencies) { + if (name === '#src/web/routes/admin/pages') pageDependencies = dependencies; + }; + } + }; + vm.runInNewContext(fs.readFileSync(require.resolve('../src/web/routes/register'), 'utf8'), sandbox); + sandbox.module.exports.registerRoutes({}, { + mediaDir: '/configured/signage-media', + playerActionService: {} + }); + assert.equal(pageDependencies.mediaDir, '/configured/signage-media'); +}); + test('thumbnail payload passes saved region geometry and content to the popup renderer', () => { const payload = buildThumbnailPreviewPayload({ template: {