From 403a928b9e63c0f9a7b47eab4f955c4d6ebff815 Mon Sep 17 00:00:00 2001 From: Mark Rapson Date: Mon, 17 Aug 2026 00:46:07 +0100 Subject: [PATCH] Release 2.8.4 --- CHANGELOG.md | 12 +++ build/package.player.json | 2 +- build/package.web.json | 2 +- package-lock.json | 4 +- package.json | 2 +- src/player-bridge/index.js | 1 + src/player.js | 1 + src/player/playlist.js | 103 +++++++++++++++++++ src/player/regions/api.js | 6 +- src/player/regions/rss.js | 6 +- src/web/lib/media/slide-thumbnails.js | 128 +++++++++++++++++++++--- src/web/lib/media/upload-sync.js | 4 + src/web/middleware.js | 2 +- src/web/routes/signage/slides/routes.js | 61 ++++++++++- test/slide-form-editor.test.js | 2 + 15 files changed, 309 insertions(+), 27 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 251f3dc..fe99dd7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,18 @@ All notable changes to this project will be documented in this file. +## 2.8.4 - 2026-08-17 + +### Added + +- Added local caching for API and RSS image placeholders under `player-cache/remote-images` for offline player playback. +- Added reconciliation of cached remote images so files no longer referenced by slides are removed. + +### Fixed + +- Fixed popup preview authentication for background thumbnail capture. +- Fixed thumbnails so they use the same popup-preview canvas and resolve API/RSS placeholders, fonts, styles, and cached images correctly. + ## 2.8.3 - 2026-08-17 ### Added diff --git a/build/package.player.json b/build/package.player.json index e4396e6..b7616d9 100644 --- a/build/package.player.json +++ b/build/package.player.json @@ -1,6 +1,6 @@ { "name": "pulse-signage-player", - "version": "2.8.3", + "version": "2.8.4", "private": false, "description": "Pulse Signage player application bundle", "engines": { diff --git a/build/package.web.json b/build/package.web.json index 44113cd..8a1176c 100644 --- a/build/package.web.json +++ b/build/package.web.json @@ -1,6 +1,6 @@ { "name": "pulse-signage-web", - "version": "2.8.3", + "version": "2.8.4", "private": false, "description": "Pulse Signage web and bridge application bundle", "engines": { diff --git a/package-lock.json b/package-lock.json index 45bcf7c..9dbbb84 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "pulse-signage", - "version": "2.8.3", + "version": "2.8.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pulse-signage", - "version": "2.8.3", + "version": "2.8.4", "dependencies": { "@sparticuz/chromium": "^149.0.0", "animate.css": "^4.1.1", diff --git a/package.json b/package.json index 67ee60d..9c620d0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "pulse-signage", - "version": "2.8.3", + "version": "2.8.4", "private": false, "description": "Pulse Signage application with MySQL and media storage", "engines": { diff --git a/src/player-bridge/index.js b/src/player-bridge/index.js index c18c193..825fbe0 100644 --- a/src/player-bridge/index.js +++ b/src/player-bridge/index.js @@ -187,6 +187,7 @@ async function start() { const playerPlaylistService = createPlayerPlaylistService({ pool: pool, common: common, + mediaDir: config.mediaDir, snapshotDir: path.join(config.mediaDir, 'player-cache', 'screen-playlists') }); app.use(express.json()); diff --git a/src/player.js b/src/player.js index 12f82e8..3611f49 100644 --- a/src/player.js +++ b/src/player.js @@ -61,6 +61,7 @@ async function start() { : createPlayerPlaylistService({ pool: pool, common: common, + mediaDir: MEDIA_DIR, snapshotDir: path.join(MEDIA_DIR, 'player-cache', 'screen-playlists') }); const rtmpStreamService = createRtmpStreamService({ diff --git a/src/player/playlist.js b/src/player/playlist.js index a27e123..4c5250c 100644 --- a/src/player/playlist.js +++ b/src/player/playlist.js @@ -9,6 +9,8 @@ function createPlayerPlaylistService(options) { const pool = options && options.pool ? options.pool : null; const common = options && options.common ? options.common : null; const snapshotDir = options && options.snapshotDir ? options.snapshotDir : null; + const mediaDir = options && options.mediaDir ? path.resolve(String(options.mediaDir)) : null; + const remoteImageCacheDir = mediaDir ? path.join(mediaDir, 'player-cache', 'remote-images') : null; if (!pool) { throw new Error('pool is required'); @@ -61,6 +63,105 @@ function createPlayerPlaylistService(options) { return createStyledQrCodeDataUrl(value); } + function getPlaceholderImageExpressions(value, output) { + const expressions = output || []; + const source = String(value || ''); + const pattern = /\{\{\s*([^{}]*?\.image\s*\([^{}]*\)[^{}]*?)\s*\}\}/gi; + let match = null; + while ((match = pattern.exec(source))) { + expressions.push(String(match[1] || '').trim()); + } + return expressions; + } + + function resolvePathValue(value, expression) { + const parsed = String(expression || '').replace(/\.image\s*\([^)]*\)\s*$/i, '').trim(); + return parsed.split('.').reduce(function (current, segment) { + return current === undefined || current === null ? '' : current[segment]; + }, value); + } + + function getApiItems(responseJson, itemsPath) { + let current = responseJson; + const pathValue = String(itemsPath || '').trim(); + if (pathValue) { + pathValue.split('.').forEach(function (segment) { + current = current === undefined || current === null ? '' : current[segment]; + }); + return Array.isArray(current) ? current : []; + } + if (Array.isArray(responseJson)) return responseJson; + if (responseJson && Array.isArray(responseJson.items)) return responseJson.items; + if (responseJson && Array.isArray(responseJson.results)) return responseJson.results; + if (responseJson && Array.isArray(responseJson.data)) return responseJson.data; + return responseJson ? [responseJson] : []; + } + + async function cacheRemoteImage(url) { + const remoteUrl = String(url || '').trim(); + if (!remoteImageCacheDir || !/^https?:\/\//i.test(remoteUrl)) return ''; + const hash = crypto.createHash('sha256').update(remoteUrl).digest('hex'); + await fs.promises.mkdir(remoteImageCacheDir, { recursive: true }); + const existing = (await fs.promises.readdir(remoteImageCacheDir)).find(function (name) { return name.startsWith(hash + '.'); }); + if (existing) return '/media/player-cache/remote-images/' + existing; + try { + const response = await fetch(remoteUrl, { signal: AbortSignal.timeout(15000) }); + if (!response.ok || !String(response.headers.get('content-type') || '').toLowerCase().startsWith('image/')) return ''; + const bytes = Buffer.from(await response.arrayBuffer()); + if (bytes.length > 10 * 1024 * 1024) return ''; + const contentType = String(response.headers.get('content-type') || '').toLowerCase(); + const extension = contentType.includes('svg') ? '.svg' : contentType.includes('png') ? '.png' : contentType.includes('webp') ? '.webp' : contentType.includes('gif') ? '.gif' : '.jpg'; + const fileName = hash + extension; + const filePath = path.join(remoteImageCacheDir, fileName); + await fs.promises.writeFile(filePath, bytes); + return '/media/player-cache/remote-images/' + fileName; + } catch (_error) { + return ''; + } + } + + async function cachePlaceholderImages(slides, rssFeeds, apiSources) { + if (!remoteImageCacheDir) return; + const usedPaths = new Set(); + const allSlideRows = await pool.query('SELECT content_json FROM c_slides').then(function (result) { return result[0] || []; }); + const allContents = allSlideRows.map(function (row) { return common.parseJsonSafe(row.content_json) || {}; }); + const sourceContent = allContents.concat((slides || []).map(function (slide) { return slide.content || {}; })); + const cacheSource = async function (source, item, expressions) { + if (!source || !item) return; + for (const expression of expressions) { + const remoteUrl = String(resolvePathValue(item, expression) || '').trim(); + const localPath = await cacheRemoteImage(remoteUrl); + if (localPath) { + source.imageCache = source.imageCache || {}; + source.imageCache[remoteUrl] = localPath; + usedPaths.add(localPath); + } + } + }; + for (const content of sourceContent) { + for (const key of Object.keys(content || {})) { + const region = content[key]; + if (!region || typeof region !== 'object') continue; + const expressions = getPlaceholderImageExpressions(region.value, []); + if (!expressions.length) continue; + const itemNumber = Math.max(1, Number(region.item_number || 1)) - 1; + if (String(region.type || '').toLowerCase() === 'api') { + const source = (apiSources || []).find(function (entry) { return Number(entry.id) === Number(region.source_id); }); + const items = source ? getApiItems(source.responseJson, region.items_path) : []; + await cacheSource(source, items[itemNumber], expressions); + } + if (String(region.type || '').toLowerCase() === 'rss') { + const feed = (rssFeeds || []).find(function (entry) { return Number(entry.id) === Number(region.feed_id); }); + await cacheSource(feed, feed && feed.items && feed.items[itemNumber], expressions); + } + } + } + const files = await fs.promises.readdir(remoteImageCacheDir).catch(function () { return []; }); + await Promise.all(files.filter(function (file) { return !usedPaths.has('/media/player-cache/remote-images/' + file); }).map(function (file) { + return fs.promises.unlink(path.join(remoteImageCacheDir, file)).catch(function () {}); + })); + } + async function buildScreenPlaylist(slug) { try { const [screenRows] = await pool.query('SELECT id, name, slug, playlist_id, created_at, modified_at, created_by, modified_by FROM d_screens WHERE slug = ?', [slug]); @@ -241,6 +342,8 @@ function createPlayerPlaylistService(options) { timetableGroups = Array.isArray(timetableData && timetableData.timetableGroups) ? timetableData.timetableGroups : []; } + await cachePlaceholderImages(slides, rssFeeds, apiSources); + const revision = getPlaylistRevision(screen, playlist, slideRows, scheduleRuleRows, templateRows, regionRows, rssFeeds, apiSources, timetableGroups); const payload = { screen: screen, playlist: playlist, slides: slides, rssFeeds: rssFeeds, apiSources: apiSources, timetableGroups: timetableGroups, revision: revision }; await writeSnapshot(slug, payload); diff --git a/src/player/regions/api.js b/src/player/regions/api.js index daa593c..447a7ba 100644 --- a/src/player/regions/api.js +++ b/src/player/regions/api.js @@ -56,7 +56,7 @@ function getApiItem(sourceId, itemNumber, itemsPathOverride) { return items[index] || null; } -function substituteApiVariables(html, item) { +function substituteApiVariables(html, item, sourceId) { var source = String(html || ''); return source.replace(/\{\{\s*([^{}]+?)\s*\}\}/g, function (_match, expression) { if (!item || typeof item !== 'object') { @@ -70,6 +70,8 @@ function substituteApiVariables(html, item) { var resolved = placeholderUtils.resolvePlaceholderExpression(item, expression); if (typeof placeholderUtils.isImagePlaceholderExpression === 'function' && placeholderUtils.isImagePlaceholderExpression(expression)) { var imageSource = placeholderUtils.formatPlaceholderValue(resolved); + var source = getApiSourceById(sourceId); + imageSource = source && source.imageCache && source.imageCache[imageSource] ? source.imageCache[imageSource] : imageSource; if (!/^(?:https?:\/\/|\/media\/|\/assets\/|\/[^/])/i.test(imageSource)) { return ''; } @@ -137,7 +139,7 @@ function renderApiRegion(region, regionContent) { var fontColor = sanitizeTextColor(regionContent.font_color || region.fontColor); var item = getApiItem(sourceId, itemNumber, itemsPath); var hasSource = String(sourceId === undefined || sourceId === null ? '' : sourceId).trim() !== ''; - var body = hasSource ? (item ? substituteApiVariables(content, item) : '') : content; + var body = hasSource ? (item ? substituteApiVariables(content, item, sourceId) : '') : content; var contentStyle = 'width:' + region.pixelWidth + 'px;height:' + region.pixelHeight + 'px;transform:scale(' + region.canvasScale + ');transform-origin:top left;' + (fontFamily ? 'font-family:' + escapeHtml(fontFamily) + ';' : '') + 'font-size:' + fontSize + 'px;color:' + escapeHtml(fontColor) + ';'; if (!body) { return ''; diff --git a/src/player/regions/rss.js b/src/player/regions/rss.js index a36c76f..f17d538 100644 --- a/src/player/regions/rss.js +++ b/src/player/regions/rss.js @@ -64,7 +64,7 @@ function resolveRssPath(value, path) { return current === undefined || current === null ? '' : current; } -function substituteRssVariables(html, item) { +function substituteRssVariables(html, item, feedId) { var source = String(html || ''); return source.replace(/\{\{\s*([^{}]+?)\s*\}\}/g, function (_match, expression) { if (!item || typeof item !== 'object') { @@ -76,6 +76,8 @@ function substituteRssVariables(html, item) { var resolved = placeholderUtils.resolvePlaceholderExpression(item, expression); if (typeof placeholderUtils.isImagePlaceholderExpression === 'function' && placeholderUtils.isImagePlaceholderExpression(expression)) { var imageSource = placeholderUtils.formatPlaceholderValue(resolved); + var feed = getRssFeedById(feedId); + imageSource = feed && feed.imageCache && feed.imageCache[imageSource] ? feed.imageCache[imageSource] : imageSource; if (!/^(?:https?:\/\/|\/media\/|\/assets\/|\/[^/])/i.test(imageSource)) { return ''; } @@ -103,7 +105,7 @@ function renderRssRegion(region, regionContent) { var fontColor = sanitizeTextColor(regionContent.font_color || region.fontColor || defaultStyle.font_color); var item = getRssFeedItem(feedId, itemNumber); var hasFeed = String(feedId === undefined || feedId === null ? '' : feedId).trim() !== ''; - var body = hasFeed ? (item ? substituteRssVariables(content, item) : '') : content; + var body = hasFeed ? (item ? substituteRssVariables(content, item, feedId) : '') : content; var contentStyle = 'width:' + region.pixelWidth + 'px;height:' + region.pixelHeight + 'px;transform:scale(' + region.canvasScale + ');transform-origin:top left;' + (fontFamily ? 'font-family:' + escapeHtml(fontFamily) + ';' : '') + 'font-size:' + fontSize + 'px;color:' + escapeHtml(fontColor) + ';'; if (!body) { return ''; diff --git a/src/web/lib/media/slide-thumbnails.js b/src/web/lib/media/slide-thumbnails.js index a478e65..c57673f 100644 --- a/src/web/lib/media/slide-thumbnails.js +++ b/src/web/lib/media/slide-thumbnails.js @@ -2,6 +2,8 @@ const fs = require('fs'); const path = require('path'); +const crypto = require('crypto'); +const vm = require('vm'); const { escapeHtml, mediaKind, @@ -10,7 +12,13 @@ const { sanitizeFontSize, sanitizeTextColor } = require('#src/player/render-helpers'); -const { createRequestAuthHeaders } = require('#src/request-auth'); +const { createRequestAuthHeaders, createPageAuthToken } = require('#src/request-auth'); + +const placeholderUtils = (() => { + const sandbox = { window: {} }; + vm.runInNewContext(fs.readFileSync(path.join(__dirname, '..', '..', 'public', 'js', 'shared', 'placeholder-utils.js'), 'utf8'), sandbox); + return sandbox.window.placeholderUtils || {}; +})(); const SYSTEM_CHROMIUM_PATHS = [ process.env.PUPPETEER_EXECUTABLE_PATH, @@ -124,11 +132,51 @@ function hasVisibleContent(html) { return Boolean(raw.replace(/<[^>]+>/g, '').trim()); } -function buildTextRegionMarkup(region, regionContent) { +function resolvePlaceholderPath(value, expression) { + const pathValue = String(expression || '').replace(/\.image\s*\([^)]*\)\s*$/i, '').trim(); + return pathValue.split('.').reduce(function (current, segment) { + return current === undefined || current === null ? '' : current[segment]; + }, value); +} + +function getImagePlaceholderConfig(expression) { + const match = String(expression || '').match(/\.image\s*\(\s*([0-9]+)?\s*,?\s*([0-9]+)?\s*\)/i); + return { + width: match && match[1] ? Number(match[1]) : 0, + height: match && match[2] ? Number(match[2]) : 0 + }; +} + +function substitutePlaceholders(html, regionContent, options) { + const source = String(html || ''); + const type = String(regionContent && regionContent.type || '').trim().toLowerCase(); + const item = options && typeof options.getItem === 'function' ? options.getItem(type, regionContent) : null; + if (!item) return source; + return source.replace(/\{\{\s*([^{}]+?)\s*\}\}/gi, function (_match, expression) { + const resolved = typeof placeholderUtils.resolvePlaceholderExpression === 'function' + ? placeholderUtils.resolvePlaceholderExpression(item, expression) + : resolvePlaceholderPath(item, expression); + const value = typeof placeholderUtils.formatPlaceholderValue === 'function' ? placeholderUtils.formatPlaceholderValue(resolved) : String(resolved || ''); + if (typeof placeholderUtils.isImagePlaceholderExpression !== 'function' || !placeholderUtils.isImagePlaceholderExpression(expression)) { + return escapeHtml(value); + } + const remoteUrl = String(value || '').trim(); + if (!/^https?:\/\//i.test(remoteUrl)) return ''; + const imageConfig = typeof placeholderUtils.getImagePlaceholderConfig === 'function' ? placeholderUtils.getImagePlaceholderConfig(expression) : getImagePlaceholderConfig(expression); + const cacheFile = options && typeof options.getCachedImagePath === 'function' ? options.getCachedImagePath(remoteUrl) : ''; + const imageUrl = cacheFile || remoteUrl; + const style = imageConfig.width && imageConfig.height + ? 'display:block;width:' + imageConfig.width + 'px;height:' + imageConfig.height + 'px;object-fit:contain;' + : 'display:block;max-width:' + (imageConfig.width || 100) + 'px;max-height:' + (imageConfig.height || 100) + 'px;width:auto;height:auto;'; + return ''; + }); +} + +function buildTextRegionMarkup(region, regionContent, options) { const fontFamily = sanitizeFontFamily(regionContent.font_family || region.font_family); const fontSize = sanitizeFontSize(regionContent.font_size || region.font_size); const fontColor = sanitizeTextColor(regionContent.font_color || region.font_color); - const renderedBody = renderEditorJsContent(regionContent.value || ''); + const renderedBody = renderEditorJsContent(substitutePlaceholders(regionContent.value || '', regionContent, options)); if (!hasVisibleContent(renderedBody)) { return ''; } @@ -136,7 +184,7 @@ function buildTextRegionMarkup(region, regionContent) { return '
' + renderedBody + '
'; } -function buildRegionInnerHtml(region, regionContent, baseUrl) { +function buildRegionInnerHtml(region, regionContent, baseUrl, options) { const regionType = String(regionContent.type || region.region_type || 'text').trim().toLowerCase(); const rawValue = normalizeRenderableValue(regionContent && regionContent.value !== undefined ? regionContent.value : ''); @@ -182,7 +230,7 @@ function buildRegionInnerHtml(region, regionContent, baseUrl) { return '
' + escapeHtml(label) + '
'; } - return buildTextRegionMarkup(region, regionContent); + return buildTextRegionMarkup(region, regionContent, Object.assign({}, options, { baseUrl: baseUrl })); } async function loadChromium() { @@ -199,7 +247,7 @@ function loadSharp() { return require('sharp'); } -function buildThumbnailPreviewMarkup(slide, baseUrl) { +function buildThumbnailPreviewMarkup(slide, baseUrl, options) { const template = slide && slide.template ? slide.template : null; if (!template) { return ''; @@ -214,7 +262,7 @@ function buildThumbnailPreviewMarkup(slide, baseUrl) { pixelWidth: Math.max(1, Math.round(Number(region && region.width || 0) || 1)), pixelHeight: Math.max(1, Math.round(Number(region && region.height || 0) || 1)) }); - return buildRegionInnerHtml(previewRegion, regionContent, normalizedBaseUrl); + return buildRegionInnerHtml(previewRegion, regionContent, normalizedBaseUrl, options); }).join(''); } @@ -228,7 +276,7 @@ function buildThumbnailPreviewPayload(slide, options) { backgroundColor: template && template.background_color ? String(template.background_color) : '#111111', backgroundImagePath: template && template.background_image_path ? String(template.background_image_path) : '', fontStylesheetHref: String(options && options.fontStylesheetHref || '').trim(), - html: buildThumbnailPreviewMarkup(slide, options && options.baseUrl) + html: buildThumbnailPreviewMarkup(slide, options && options.baseUrl, options) }; } @@ -291,6 +339,53 @@ async function captureSlideThumbnail(options) { throw new Error('Slide not found.'); } + const rssFeedsData = typeof common.fetchRssFeedsData === 'function' ? await common.fetchRssFeedsData(pool) : { rssFeeds: [] }; + const rssFeeds = await Promise.all((rssFeedsData.rssFeeds || []).map(async function (feed) { + const items = typeof common.fetchRssFeedItemsByFeedId === 'function' ? await common.fetchRssFeedItemsByFeedId(pool, feed.id) : []; + return Object.assign({}, feed, { items: items }); + })); + const apiSourcesData = typeof common.fetchApiSourcesData === 'function' ? await common.fetchApiSourcesData(pool) : { apiSources: [] }; + const apiSources = (apiSourcesData.apiSources || []).map(function (source) { + return Object.assign({}, source, { responseJson: common.parseJsonSafe ? common.parseJsonSafe(source.last_response_json) : null }); + }); + + function getApiItems(source) { + const response = source && source.responseJson; + const itemsPath = String(source && source.items_path || '').trim(); + if (itemsPath) { + const selected = itemsPath.split('.').reduce(function (current, segment) { + return current === undefined || current === null ? '' : current[segment]; + }, response); + return Array.isArray(selected) ? selected : []; + } + if (Array.isArray(response)) return response; + if (response && Array.isArray(response.items)) return response.items; + if (response && Array.isArray(response.results)) return response.results; + if (response && Array.isArray(response.data)) return response.data; + return response ? [response] : []; + } + + function getThumbnailItem(type, regionContent) { + const index = Math.max(0, Math.max(1, Number(regionContent && regionContent.item_number || 1)) - 1); + if (type === 'api') { + const source = apiSources.find(function (entry) { return Number(entry.id) === Number(regionContent.source_id); }); + return getApiItems(source)[index] || null; + } + if (type === 'rss') { + const feed = rssFeeds.find(function (entry) { return Number(entry.id) === Number(regionContent.feed_id); }); + return feed && Array.isArray(feed.items) ? feed.items[index] || null : null; + } + return null; + } + + function getCachedImagePath(remoteUrl) { + const hash = crypto.createHash('sha256').update(String(remoteUrl || '')).digest('hex'); + const cacheDir = path.join(mediaDir, 'player-cache', 'remote-images'); + const candidates = ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg']; + const candidate = candidates.map(function (extension) { return path.join(cacheDir, hash + extension); }).find(function (filePath) { return fs.existsSync(filePath); }); + return candidate ? '/media/player-cache/remote-images/' + path.basename(candidate) : ''; + } + const thumbnailDir = path.join(mediaDir, 'thumbnails'); const thumbnailRelativePath = String(slide.thumbnail_path || '').trim() || '/media/thumbnails/slides/slide-' + slide.id + '.png'; const filePath = path.join(mediaDir, thumbnailRelativePath.replace(/^\/+media\//, '')); @@ -340,22 +435,29 @@ async function captureSlideThumbnail(options) { try { const page = await browser.newPage(); try { - const previewPath = '/api/internal/slide-thumbnails/' + slide.id + '/popup-preview'; - const previewUrl = baseUrl + previewPath; const previewPayload = buildThumbnailPreviewPayload(slide, { baseUrl: baseUrl, - fontStylesheetHref: options && options.fontStylesheetHref ? options.fontStylesheetHref : '' + fontStylesheetHref: options && options.fontStylesheetHref ? options.fontStylesheetHref : '', + getItem: getThumbnailItem, + getCachedImagePath: getCachedImagePath }); + const previewPath = '/slides/popup-preview'; + const previewUrl = baseUrl + previewPath + '#' + encodeURIComponent(JSON.stringify(previewPayload)); await page.setViewport({ width: Math.max(1, Number(previewPayload.canvasWidth || PLAYER_VIEWPORT.width)), height: Math.max(1, Number(previewPayload.canvasHeight || PLAYER_VIEWPORT.height)), deviceScaleFactor: 1 }); - await page.setExtraHTTPHeaders(createRequestAuthHeaders({ + await page.setExtraHTTPHeaders(Object.assign({}, createRequestAuthHeaders({ method: 'GET', pathname: previewPath + }), { + 'x-pulse-page-auth': createPageAuthToken({ scope: 'thumbnail-preview', slideId: slide.id }) })); - await page.goto(previewUrl, { waitUntil: 'domcontentloaded', timeout: 30000 }); + const previewResponse = await page.goto(previewUrl, { waitUntil: 'domcontentloaded', timeout: 30000 }); + if (!previewResponse || previewResponse.status() >= 400) { + throw new Error('Popup preview returned HTTP ' + (previewResponse ? previewResponse.status() : 'no response') + '.'); + } await waitForThumbnailRender(page); const canvas = await page.$('#popup-preview-canvas'); if (!canvas) { diff --git a/src/web/lib/media/upload-sync.js b/src/web/lib/media/upload-sync.js index 65d7001..1ffd552 100644 --- a/src/web/lib/media/upload-sync.js +++ b/src/web/lib/media/upload-sync.js @@ -432,6 +432,10 @@ function createUploadSyncService(options) { const nextRelativePath = relativeDir ? path.posix.join(relativeDir, entryName) : entryName; const nextAbsolutePath = path.join(currentDir, entryName); + if (!relativeDir && entryName === 'player-cache') { + continue; + } + if (entry.isDirectory && entry.isDirectory()) { await walkDirectory(nextAbsolutePath, nextRelativePath); continue; diff --git a/src/web/middleware.js b/src/web/middleware.js index 942d60b..7b64a49 100644 --- a/src/web/middleware.js +++ b/src/web/middleware.js @@ -29,7 +29,7 @@ module.exports = function registerMiddleware(app, deps) { }); app.use(function (req, res, next) { - if (req.path === '/' || req.path === '/login' || req.path === '/logout' || req.path.indexOf('/api/internal/slide-thumbnails/') === 0 || req.path === '/api/internal/sync/player-media' || req.path === '/api/internal/sync/player-font') { + if (req.path === '/' || req.path === '/login' || req.path === '/logout' || req.path === '/slides/popup-preview' || req.path.indexOf('/api/internal/slide-thumbnails/') === 0 || req.path === '/api/internal/sync/player-media' || req.path === '/api/internal/sync/player-font') { return next(); } diff --git a/src/web/routes/signage/slides/routes.js b/src/web/routes/signage/slides/routes.js index 10da3a1..3f6a8bc 100644 --- a/src/web/routes/signage/slides/routes.js +++ b/src/web/routes/signage/slides/routes.js @@ -1,10 +1,12 @@ // Slide route registration and pagination wiring. const { renderFragment } = require('../../../view'); +const fs = require('fs'); +const crypto = require('crypto'); const path = require('path'); -const { verifyRequestAuth } = require('#src/request-auth'); +const { verifyRequestAuth, verifyPageAuthToken } = require('#src/request-auth'); const { getFontStylesheetHref } = require('#src/web/lib/media/font-library'); -const { buildThumbnailPreviewPayload } = require('#src/web/lib/media/slide-thumbnail-preview'); +const { buildThumbnailPreviewPayload } = require('#src/web/lib/media/slide-thumbnails'); function safeJsonForScript(value) { return JSON.stringify(value === undefined ? null : value).replace(/ { test('slide thumbnail previews treat image-only text as visible content', () => { assert.ok(slideThumbnailPreviewSource.includes('/ {