Release 2.8.4
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
Generated
+2
-2
@@ -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",
|
||||
|
||||
+1
-1
@@ -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": {
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 '';
|
||||
|
||||
@@ -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 '';
|
||||
|
||||
@@ -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 '<img src="' + escapeHtml(resolveAssetUrl(options && options.baseUrl, imageUrl)) + '" alt="" style="' + style + '" />';
|
||||
});
|
||||
}
|
||||
|
||||
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 '<div class="slide-preview-region slide-preview-text-region" style="' + region.baseStyle + '"><div class="slide-preview-text-content" style="width:' + region.pixelWidth + 'px;height:' + region.pixelHeight + 'px;overflow:hidden;' + (fontFamily ? 'font-family:' + escapeHtml(fontFamily) + ';' : '') + 'font-size:' + fontSize + 'px;color:' + escapeHtml(fontColor || '#000000') + ';">' + renderedBody + '</div></div>';
|
||||
}
|
||||
|
||||
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 '<div class="slide-preview-region slide-preview-rtmp-region" style="' + region.baseStyle + '"><div class="slide-preview-rtmp-placeholder">' + escapeHtml(label) + '</div></div>';
|
||||
}
|
||||
|
||||
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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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(/</g, '\\u003c');
|
||||
@@ -27,6 +29,14 @@ module.exports = function registerSlidesRoutes(app, deps) {
|
||||
next();
|
||||
}
|
||||
|
||||
function requirePopupPreviewAccess(req, res, next) {
|
||||
if (verifyRequestAuth(req) || verifyPageAuthToken(req.headers && req.headers['x-pulse-page-auth'])) {
|
||||
return next();
|
||||
}
|
||||
|
||||
return requirePermission('slides.read')(req, res, next);
|
||||
}
|
||||
|
||||
const LIST_PAGE_SIZE = 25;
|
||||
|
||||
app.get('/slides', requirePermission('slides.read'), async function (req, res, next) {
|
||||
@@ -45,7 +55,7 @@ module.exports = function registerSlidesRoutes(app, deps) {
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/slides/popup-preview', requirePermission('slides.read'), function (_req, res) {
|
||||
app.get('/slides/popup-preview', requirePopupPreviewAccess, function (_req, res) {
|
||||
res.send(renderFragment('slides/popup-preview', {
|
||||
title: 'Slide preview',
|
||||
framePopupCard: true,
|
||||
@@ -61,9 +71,52 @@ module.exports = function registerSlidesRoutes(app, deps) {
|
||||
return res.status(404).send('Slide not found');
|
||||
}
|
||||
|
||||
const rssData = typeof common.fetchRssFeedsData === 'function' ? await common.fetchRssFeedsData(pool) : { rssFeeds: [] };
|
||||
const rssFeeds = await Promise.all((rssData.rssFeeds || []).map(async function (feed) {
|
||||
const items = typeof common.fetchRssFeedItemsByFeedId === 'function' ? await common.fetchRssFeedItemsByFeedId(pool, feed.id) : [];
|
||||
return Object.assign({}, feed, { items: items });
|
||||
}));
|
||||
const apiData = typeof common.fetchApiSourcesData === 'function' ? await common.fetchApiSourcesData(pool) : { apiSources: [] };
|
||||
const apiSources = (apiData.apiSources || []).map(function (source) {
|
||||
return Object.assign({}, source, { responseJson: common.parseJsonSafe ? common.parseJsonSafe(source.last_response_json) : null });
|
||||
});
|
||||
const getApiItems = function (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] : [];
|
||||
};
|
||||
const getItem = function (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;
|
||||
};
|
||||
const getCachedImagePath = function (remoteUrl) {
|
||||
const hash = crypto.createHash('sha256').update(String(remoteUrl || '')).digest('hex');
|
||||
const cacheDir = path.join(mediaDir, 'player-cache', 'remote-images');
|
||||
const extensions = ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg'];
|
||||
const file = extensions.map(function (extension) { return hash + extension; }).find(function (name) { return fs.existsSync(path.join(cacheDir, name)); });
|
||||
return file ? '/media/player-cache/remote-images/' + file : '';
|
||||
};
|
||||
const payload = buildThumbnailPreviewPayload(slide, {
|
||||
baseUrl: webBaseUrl || (String(req.headers.host || '').trim() ? `${req.protocol}://${String(req.headers.host).trim()}` : ''),
|
||||
fontStylesheetHref: getFontStylesheetHref(mediaDir)
|
||||
fontStylesheetHref: getFontStylesheetHref(mediaDir),
|
||||
getItem: getItem,
|
||||
getCachedImagePath: getCachedImagePath
|
||||
});
|
||||
|
||||
res.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
|
||||
|
||||
@@ -69,6 +69,8 @@ test('slide form queues editor image cleanup on save and close', () => {
|
||||
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)'));
|
||||
assert.ok(slideThumbnailsSource.includes('getCachedImagePath'));
|
||||
assert.ok(slideThumbnailsSource.includes('getThumbnailItem'));
|
||||
});
|
||||
|
||||
test('popup preview falls back to iframe sizing rules', () => {
|
||||
|
||||
Reference in New Issue
Block a user