Compare commits

...
8 Commits
Author SHA1 Message Date
lzstealth a7d867dd6f Adjust API source response header spacing
Publish Docker Image / build-and-push-existing-registry (./build/Dockerfile, web, pulse-signage-web) (push) Successful in 1m15s
Publish Docker Image / build-and-push-existing-registry (./build/Dockerfile.player, player, pulse-signage-player) (push) Successful in 32s
2026-08-22 01:39:35 +01:00
lzstealth 196c640f9b Release 2.8.7
Publish Docker Image / build-and-push-existing-registry (./build/Dockerfile, web, pulse-signage-web) (push) Successful in 1m47s
Publish Docker Image / build-and-push-existing-registry (./build/Dockerfile.player, player, pulse-signage-player) (push) Successful in 31s
2026-08-22 01:33:35 +01:00
lzstealth 7bd4a792ee Merge remote-tracking branch 'Gitea_SSH/main'
Publish Docker Image / build-and-push-existing-registry (./build/Dockerfile, web, pulse-signage-web) (push) Successful in 1m50s
Publish Docker Image / build-and-push-existing-registry (./build/Dockerfile.player, player, pulse-signage-player) (push) Successful in 32s
# Conflicts:
#	CHANGELOG.md
#	build/package.player.json
#	build/package.web.json
#	package-lock.json
#	package.json
2026-08-18 02:25:15 +01:00
lzstealth e1e759f64e Release 2.8.6 2026-08-18 02:23:42 +01:00
lzstealth f071c21219 Release 2.8.5
Publish Docker Image / build-and-push-existing-registry (./build/Dockerfile, web, pulse-signage-web) (push) Successful in 1m15s
Publish Docker Image / build-and-push-existing-registry (./build/Dockerfile.player, player, pulse-signage-player) (push) Successful in 32s
2026-08-17 02:15:18 +01:00
lzstealth e984f36875 Release 2.8.5
Publish Docker Image / build-and-push-existing-registry (./build/Dockerfile, web, pulse-signage-web) (push) Successful in 1m53s
Publish Docker Image / build-and-push-existing-registry (./build/Dockerfile.player, player, pulse-signage-player) (push) Successful in 32s
2026-08-17 02:07:48 +01:00
lzstealth bbd517abbb Fix scheduled task run notification
Publish Docker Image / build-and-push-existing-registry (./build/Dockerfile, web, pulse-signage-web) (push) Successful in 1m14s
Publish Docker Image / build-and-push-existing-registry (./build/Dockerfile.player, player, pulse-signage-player) (push) Successful in 32s
2026-08-17 00:54:13 +01:00
lzstealth 403a928b9e Release 2.8.4
Publish Docker Image / build-and-push-existing-registry (./build/Dockerfile, web, pulse-signage-web) (push) Successful in 1m14s
Publish Docker Image / build-and-push-existing-registry (./build/Dockerfile.player, player, pulse-signage-player) (push) Successful in 35s
2026-08-17 00:46:07 +01:00
37 changed files with 900 additions and 129 deletions
+34
View File
@@ -2,6 +2,40 @@
All notable changes to this project will be documented in this file.
## 2.8.7 - 2026-08-22
### Added
- Added configurable JSON POST requests and two-step login-then-token authentication for API sources.
## 2.8.6 - 2026-08-18
### Fixed
- Added live URL validation with inline feedback and explicit HTTP or HTTPS scheme enforcement for URL fields.
- Prevented Enter in slide editor inputs from implicitly saving the slide.
- Collapsed nested API response JSON sections by default while keeping the root response visible.
## 2.8.5 - 2026-08-17
### Fixed
- Fixed thumbnail capture timing so video assets are ready before the preview canvas is captured.
- Replaced unavailable webpage thumbnails with a subdued placeholder while keeping live webpage previews intact.
## 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.
- Fixed manual scheduled-task run notifications so they use task-neutral wording.
## 2.8.3 - 2026-08-17
### Added
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "pulse-signage-player",
"version": "2.8.3",
"version": "2.8.7",
"private": false,
"description": "Pulse Signage player application bundle",
"engines": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "pulse-signage-web",
"version": "2.8.3",
"version": "2.8.7",
"private": false,
"description": "Pulse Signage web and bridge application bundle",
"engines": {
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "pulse-signage",
"version": "2.8.3",
"version": "2.8.7",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "pulse-signage",
"version": "2.8.3",
"version": "2.8.7",
"dependencies": {
"@sparticuz/chromium": "^149.0.0",
"animate.css": "^4.1.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "pulse-signage",
"version": "2.8.3",
"version": "2.8.7",
"private": false,
"description": "Pulse Signage application with MySQL and media storage",
"engines": {
+181 -19
View File
@@ -8,6 +8,11 @@ const NAME_MAX_LENGTH = 255;
const URL_MAX_LENGTH = 1024;
const AUTH_MAX_LENGTH = 255;
const ITEMS_PATH_MAX_LENGTH = 255;
const REQUEST_BODY_MAX_LENGTH = 1000000;
const TOKEN_URL_MAX_LENGTH = 1024;
const TOKEN_RESPONSE_PATH_MAX_LENGTH = 255;
const TOKEN_HEADER_PREFIX_MAX_LENGTH = 64;
const tokenCache = new Map();
function normalizeUpdateIntervalUnit(value) {
const unit = String(value || '').trim().toLowerCase();
@@ -19,7 +24,11 @@ function normalizeUpdateIntervalUnit(value) {
function normalizeAuthMethod(value) {
const method = String(value || '').trim().toLowerCase();
return ['basic', 'bearer', 'api_key_header'].includes(method) ? method : 'none';
return ['basic', 'bearer', 'api_key_header', 'token_login'].includes(method) ? method : 'none';
}
function normalizeRequestMethod(value) {
return String(value || '').trim().toUpperCase() === 'POST' ? 'POST' : 'GET';
}
function buildAuthHeaders(source) {
@@ -50,7 +59,7 @@ function buildAuthHeaders(source) {
async function fetchApiSourcesData(pool) {
const [apiSources] = await pool.query(
'SELECT id, name, api_url, auth_method, auth_username, auth_password, auth_bearer_token, auth_header_name, auth_header_value, items_path, update_interval_value, update_interval_unit, last_pulled_at, last_pull_error, last_response_status, last_response_content_type, last_response_json, created_at, modified_at, created_by, modified_by FROM i_api_sources ORDER BY modified_at DESC, id DESC'
'SELECT id, name, api_url, request_method, request_body_json, auth_method, auth_username, auth_password, auth_bearer_token, auth_header_name, auth_header_value, token_url, token_request_body_json, token_response_path, token_header_name, token_header_prefix, items_path, update_interval_value, update_interval_unit, last_pulled_at, last_pull_error, last_response_status, last_response_content_type, last_response_json, created_at, modified_at, created_by, modified_by FROM i_api_sources ORDER BY modified_at DESC, id DESC'
);
return { apiSources: apiSources };
@@ -58,7 +67,7 @@ async function fetchApiSourcesData(pool) {
async function fetchApiSourcesPage(pool, page, pageSize, searchTerm, sortKey, sortDirection) {
const paged = await fetchPagedRows(pool, {
selectSql: 'SELECT id, name, api_url, auth_method, auth_username, auth_password, auth_bearer_token, auth_header_name, auth_header_value, items_path, update_interval_value, update_interval_unit, last_pulled_at, last_pull_error, last_response_status, last_response_content_type, last_response_json, created_at, modified_at, created_by, modified_by FROM i_api_sources ORDER BY modified_at DESC, id DESC',
selectSql: 'SELECT id, name, api_url, request_method, request_body_json, auth_method, auth_username, auth_password, auth_bearer_token, auth_header_name, auth_header_value, token_url, token_request_body_json, token_response_path, token_header_name, token_header_prefix, items_path, update_interval_value, update_interval_unit, last_pulled_at, last_pull_error, last_response_status, last_response_content_type, last_response_json, created_at, modified_at, created_by, modified_by FROM i_api_sources ORDER BY modified_at DESC, id DESC',
countSql: 'SELECT COUNT(*) AS count FROM i_api_sources',
searchColumns: ['name', 'api_url', 'last_pull_error'],
searchTerm: searchTerm,
@@ -82,7 +91,7 @@ async function fetchApiSourcesPage(pool, page, pageSize, searchTerm, sortKey, so
async function fetchApiSourceById(pool, id) {
const [rows] = await pool.query(
'SELECT id, name, api_url, auth_method, auth_username, auth_password, auth_bearer_token, auth_header_name, auth_header_value, items_path, update_interval_value, update_interval_unit, last_pulled_at, last_pull_error, last_response_status, last_response_content_type, last_response_json, created_at, modified_at, created_by, modified_by FROM i_api_sources WHERE id = ?',
'SELECT id, name, api_url, request_method, request_body_json, auth_method, auth_username, auth_password, auth_bearer_token, auth_header_name, auth_header_value, token_url, token_request_body_json, token_response_path, token_header_name, token_header_prefix, items_path, update_interval_value, update_interval_unit, last_pulled_at, last_pull_error, last_response_status, last_response_content_type, last_response_json, created_at, modified_at, created_by, modified_by FROM i_api_sources WHERE id = ?',
[id]
);
@@ -91,13 +100,18 @@ async function fetchApiSourceById(pool, id) {
async function loadUrlText(urlValue, requestOptions) {
const extraHeaders = requestOptions && requestOptions.headers ? requestOptions.headers : {};
const method = String(requestOptions && requestOptions.method || 'GET').toUpperCase();
const body = requestOptions && requestOptions.body !== undefined ? requestOptions.body : undefined;
const headers = Object.assign({
Accept: 'application/json, text/plain;q=0.9, */*;q=0.8',
'User-Agent': 'Pulse Signage API Reader'
}, extraHeaders);
if (typeof fetch === 'function') {
const response = await fetch(urlValue, {
headers: Object.assign({
Accept: 'application/json, text/plain;q=0.9, */*;q=0.8',
'User-Agent': 'Pulse Signage API Reader'
}, extraHeaders)
});
const fetchOptions = { method: method, headers: headers };
if (body !== undefined && method !== 'GET' && method !== 'HEAD') {
fetchOptions.body = body;
}
const response = await fetch(urlValue, fetchOptions);
return {
statusCode: response.status,
@@ -110,12 +124,9 @@ async function loadUrlText(urlValue, requestOptions) {
return await new Promise(function (resolve, reject) {
const url = new URL(urlValue);
const transport = url.protocol === 'https:' ? https : http;
const requestHeaders = Object.assign({
Accept: 'application/json, text/plain;q=0.9, */*;q=0.8',
'User-Agent': 'Pulse Signage API Reader'
}, extraHeaders);
const request = transport.get(url, Object.assign({}, requestOptions || {}, {
headers: requestHeaders
const request = transport.request(url, Object.assign({}, requestOptions || {}, {
method: method,
headers: headers
}), function (response) {
response.setEncoding('utf8');
let body = '';
@@ -133,15 +144,126 @@ async function loadUrlText(urlValue, requestOptions) {
response.on('error', reject);
});
if (body !== undefined && method !== 'GET' && method !== 'HEAD') {
request.write(body);
}
request.end();
request.on('error', reject);
});
}
function parseJsonRequestBody(value, fieldName) {
const text = String(value || '').trim();
if (!text) {
return undefined;
}
try {
return JSON.parse(text);
} catch (_error) {
const error = new Error(fieldName + ' must contain valid JSON.');
error.statusCode = 400;
throw error;
}
}
function resolveResponsePath(value, responsePath) {
let current = value;
String(responsePath || '').split('.').forEach(function (segment) {
if (current === undefined || current === null) {
current = undefined;
return;
}
current = current[segment];
});
return current;
}
function getTokenCacheKey(source) {
return JSON.stringify([
source && source.id || '',
source && (source.token_url || source.tokenUrl) || '',
source && (source.token_request_body_json || source.tokenRequestBodyJson) || '',
source && (source.token_response_path || source.tokenResponsePath) || 'access_token',
source && (source.token_header_name || source.tokenHeaderName) || 'Authorization',
source && (source.token_header_prefix || source.tokenHeaderPrefix) || 'Bearer'
]);
}
function clearCachedToken(source) {
tokenCache.delete(getTokenCacheKey(source));
}
async function fetchLoginToken(source) {
const cacheKey = getTokenCacheKey(source);
const cached = tokenCache.get(cacheKey);
if (cached && cached.expiresAt > Date.now()) {
return cached.value;
}
const tokenUrl = source.token_url || source.tokenUrl;
const tokenBody = parseJsonRequestBody(source.token_request_body_json || source.tokenRequestBodyJson, 'Login request body');
const tokenHeaders = { 'Content-Type': 'application/json' };
const response = await loadUrlText(tokenUrl, {
method: 'POST',
headers: tokenHeaders,
body: tokenBody === undefined ? undefined : JSON.stringify(tokenBody)
});
if (!response.ok) {
throw new Error(`Unable to obtain API token (${response.statusCode}).`);
}
let parsed;
try {
parsed = JSON.parse(String(response.bodyText || '').trim());
} catch (_error) {
throw new Error('Token response was not valid JSON.');
}
const tokenPath = source.token_response_path || source.tokenResponsePath || 'access_token';
const token = resolveResponsePath(parsed, tokenPath);
if (token === undefined || token === null || String(token).trim() === '') {
throw new Error('Token response did not contain a token at the configured path.');
}
const expiresIn = Number(parsed && (parsed.expires_in || parsed.expiresIn));
const lifetimeMs = Number.isFinite(expiresIn) && expiresIn > 0
? Math.max(30000, expiresIn * 1000 - 60000)
: 300000;
tokenCache.set(cacheKey, { value: String(token), expiresAt: Date.now() + lifetimeMs });
return String(token);
}
async function buildRequestHeaders(source) {
const method = normalizeAuthMethod(source && (source.auth_method || source.authMethod));
if (method !== 'token_login') {
return buildAuthHeaders(source);
}
const token = await fetchLoginToken(source);
const headerName = String(source.token_header_name || source.tokenHeaderName || 'Authorization').trim() || 'Authorization';
const prefix = String(source.token_header_prefix || source.tokenHeaderPrefix || 'Bearer').trim();
return { [headerName]: prefix ? prefix + ' ' + token : token };
}
async function fetchApiSourceResponse(apiSource) {
const source = apiSource && typeof apiSource === 'object' ? apiSource : { api_url: apiSource };
const response = await loadUrlText(source.api_url, {
headers: buildAuthHeaders(source)
});
const requestMethod = normalizeRequestMethod(source.request_method || source.requestMethod);
const requestBody = parseJsonRequestBody(source.request_body_json || source.requestBodyJson, 'API request body');
let response;
let tokenRetry = false;
do {
response = await loadUrlText(source.api_url || source.apiUrl, {
method: requestMethod,
headers: Object.assign({}, await buildRequestHeaders(source), requestBody === undefined ? {} : { 'Content-Type': 'application/json' }),
body: requestBody === undefined ? undefined : JSON.stringify(requestBody)
});
if (response.statusCode === 401 && normalizeAuthMethod(source.auth_method || source.authMethod) === 'token_login' && !tokenRetry) {
clearCachedToken(source);
tokenRetry = true;
} else {
break;
}
} while (true);
if (!response.ok) {
throw new Error(`Unable to load API response (${response.statusCode}).`);
}
@@ -178,11 +300,18 @@ function buildApiSourcePayload(req, existingApiSource) {
const name = validateMaxLength(req.body.name || fallback.name || '', NAME_MAX_LENGTH, 'API source name');
const apiUrl = validateMaxLength(req.body.api_url || req.body.apiUrl || fallback.api_url || '', URL_MAX_LENGTH, 'API source URL');
const authMethod = normalizeAuthMethod(readBodyValue('auth_method', readBodyValue('authMethod', fallback.auth_method || 'none')));
const requestMethod = normalizeRequestMethod(readBodyValue('request_method', readBodyValue('requestMethod', fallback.request_method || 'GET')));
const requestBodyJson = validateMaxLength(readBodyValue('request_body_json', readBodyValue('requestBodyJson', fallback.request_body_json || '')) || '', REQUEST_BODY_MAX_LENGTH, 'API request body');
const authUsername = validateMaxLength(readBodyValue('auth_username', readBodyValue('authUsername', fallback.auth_username || '')) || '', AUTH_MAX_LENGTH, 'API source username');
const authPassword = validateMaxLength(readBodyValue('auth_password', readBodyValue('authPassword', fallback.auth_password || '')) || '', AUTH_MAX_LENGTH, 'API source password');
const authBearerToken = validateMaxLength(readBodyValue('auth_bearer_token', readBodyValue('authBearerToken', fallback.auth_bearer_token || '')) || '', AUTH_MAX_LENGTH, 'API source bearer token');
const authHeaderName = validateMaxLength(readBodyValue('auth_header_name', readBodyValue('authHeaderName', fallback.auth_header_name || 'X-API-Key')) || 'X-API-Key', AUTH_MAX_LENGTH, 'API source header name') || 'X-API-Key';
const authHeaderValue = validateMaxLength(readBodyValue('auth_header_value', readBodyValue('authHeaderValue', fallback.auth_header_value || '')) || '', AUTH_MAX_LENGTH, 'API source header value');
const tokenUrl = validateMaxLength(readBodyValue('token_url', readBodyValue('tokenUrl', fallback.token_url || '')) || '', TOKEN_URL_MAX_LENGTH, 'API token URL');
const tokenRequestBodyJson = validateMaxLength(readBodyValue('token_request_body_json', readBodyValue('tokenRequestBodyJson', fallback.token_request_body_json || '')) || '', REQUEST_BODY_MAX_LENGTH, 'Login request body');
const tokenResponsePath = validateMaxLength(readBodyValue('token_response_path', readBodyValue('tokenResponsePath', fallback.token_response_path || 'access_token')) || 'access_token', TOKEN_RESPONSE_PATH_MAX_LENGTH, 'Token response path');
const tokenHeaderName = validateMaxLength(readBodyValue('token_header_name', readBodyValue('tokenHeaderName', fallback.token_header_name || 'Authorization')) || 'Authorization', AUTH_MAX_LENGTH, 'Token header name');
const tokenHeaderPrefix = validateMaxLength(readBodyValue('token_header_prefix', readBodyValue('tokenHeaderPrefix', fallback.token_header_prefix || 'Bearer')) || '', TOKEN_HEADER_PREFIX_MAX_LENGTH, 'Token prefix');
const itemsPath = validateMaxLength(readBodyValue('items_path', readBodyValue('itemsPath', fallback.items_path || '')) || '', ITEMS_PATH_MAX_LENGTH, 'API source items path');
const updateIntervalValue = Math.max(1, Number(req.body.update_interval_value || req.body.updateIntervalValue || fallback.update_interval_value || 60));
const updateIntervalUnit = normalizeUpdateIntervalUnit(req.body.update_interval_unit || req.body.updateIntervalUnit || fallback.update_interval_unit || 'minutes');
@@ -238,15 +367,48 @@ function buildApiSourcePayload(req, existingApiSource) {
throw error;
}
parseJsonRequestBody(requestBodyJson, 'API request body');
parseJsonRequestBody(tokenRequestBodyJson, 'Login request body');
if (authMethod === 'token_login') {
if (!tokenUrl) {
const error = new Error('Token login requires a login URL.');
error.statusCode = 400;
throw error;
}
try {
const tokenParsedUrl = new URL(tokenUrl);
if (tokenParsedUrl.protocol !== 'http:' && tokenParsedUrl.protocol !== 'https:') {
throw new Error('invalid protocol');
}
} catch (_error) {
const error = new Error('Enter a valid API token URL.');
error.statusCode = 400;
throw error;
}
if (!tokenRequestBodyJson) {
const error = new Error('Token login requires a JSON request body.');
error.statusCode = 400;
throw error;
}
}
return {
name: name,
apiUrl: parsedUrl.toString(),
requestMethod: requestMethod,
requestBodyJson: requestBodyJson,
authMethod: authMethod,
authUsername: authUsername,
authPassword: authPassword,
authBearerToken: authBearerToken,
authHeaderName: authHeaderName,
authHeaderValue: authHeaderValue,
tokenUrl: tokenUrl,
tokenRequestBodyJson: tokenRequestBodyJson,
tokenResponsePath: tokenResponsePath,
tokenHeaderName: tokenHeaderName,
tokenHeaderPrefix: tokenHeaderPrefix,
itemsPath: itemsPath,
updateIntervalValue: Math.floor(updateIntervalValue),
updateIntervalUnit: updateIntervalUnit
+14
View File
@@ -233,6 +233,20 @@ async function ensureSchema(pool, options) {
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(255) NOT NULL,
api_url VARCHAR(1024) NOT NULL,
request_method VARCHAR(10) NOT NULL DEFAULT 'GET',
request_body_json MEDIUMTEXT NULL,
auth_method VARCHAR(32) NOT NULL DEFAULT 'none',
auth_username VARCHAR(255) NULL,
auth_password MEDIUMTEXT NULL,
auth_bearer_token MEDIUMTEXT NULL,
auth_header_name VARCHAR(255) NULL,
auth_header_value MEDIUMTEXT NULL,
token_url VARCHAR(1024) NULL,
token_request_body_json MEDIUMTEXT NULL,
token_response_path VARCHAR(255) NULL DEFAULT 'access_token',
token_header_name VARCHAR(255) NULL DEFAULT 'Authorization',
token_header_prefix VARCHAR(64) NULL DEFAULT 'Bearer',
items_path VARCHAR(255) NULL,
update_interval_value INT NOT NULL DEFAULT 60,
update_interval_unit VARCHAR(10) NOT NULL DEFAULT 'minutes',
last_pulled_at TIMESTAMP NULL,
+13
View File
@@ -492,6 +492,19 @@ const VERSIONED_MIGRATIONS = [
AND permissions.permission_key = 'audit-log.allow'`
);
}
},
{
version: '2.8.7',
label: 'v2.8.7 API request and token authentication schema',
run: async function (pool) {
await ensureColumn(pool, 'i_api_sources', 'request_method', "VARCHAR(10) NOT NULL DEFAULT 'GET'", 'api_url');
await ensureColumn(pool, 'i_api_sources', 'request_body_json', 'MEDIUMTEXT NULL', 'request_method');
await ensureColumn(pool, 'i_api_sources', 'token_url', 'VARCHAR(1024) NULL', 'auth_header_value');
await ensureColumn(pool, 'i_api_sources', 'token_request_body_json', 'MEDIUMTEXT NULL', 'token_url');
await ensureColumn(pool, 'i_api_sources', 'token_response_path', "VARCHAR(255) NULL DEFAULT 'access_token'", 'token_request_body_json');
await ensureColumn(pool, 'i_api_sources', 'token_header_name', "VARCHAR(255) NULL DEFAULT 'Authorization'", 'token_response_path');
await ensureColumn(pool, 'i_api_sources', 'token_header_prefix', "VARCHAR(64) NULL DEFAULT 'Bearer'", 'token_header_name');
}
}
];
+1
View File
@@ -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());
+1
View File
@@ -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({
+103
View File
@@ -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);
+4 -2
View File
@@ -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 '';
+5 -2
View File
@@ -32,7 +32,7 @@ function buildHtmlDocument(html) {
return raw;
}
return '<!doctype html><html><head><style>html,body{margin:0;width:100%;height:100%;overflow:hidden;background:transparent;}</style></head><body>' + raw + '</body></html>';
return '<!doctype html><html><head><style>html,body{margin:0;width:100%;height:100%;overflow:hidden;background:transparent;}html,body{background:transparent !important;}</style></head><body>' + raw + '</body></html>';
}
function renderHtmlRegionContent(value) {
@@ -40,7 +40,10 @@ function renderHtmlRegionContent(value) {
if (!html) {
return '';
}
return '<iframe class="template-region-html-frame" sandbox="" scrolling="no" srcdoc="' + escapeHtml(buildHtmlDocument(html)) + '" title="HTML region" loading="eager" style="width:100%;height:100%;border:0;display:block;background:transparent;overflow:hidden;"></iframe>';
if (/^<!doctype\b/i.test(html) || /^<html\b/i.test(html)) {
return '<iframe class="template-region-html-frame" sandbox="" allowtransparency="true" scrolling="no" srcdoc="' + escapeHtml(html) + '" title="HTML region" loading="eager" style="width:100%;height:100%;border:0;display:block;background:transparent;overflow:hidden;"></iframe>';
}
return '<div class="template-region-html-content" style="width:100%;height:100%;overflow:hidden;background:transparent;">' + html + '</div>';
}
function renderHtmlRegion(region, regionContent) {
+4 -2
View File
@@ -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 '';
+7 -3
View File
@@ -142,9 +142,13 @@ function buildRegionInnerHtml(region, regionContent, baseUrl) {
if (regionType === 'html') {
const html = String(rawValue || '').trim();
return html
? '<div class="slide-preview-region slide-preview-html-region" style="' + region.baseStyle + '"><iframe sandbox="" allowtransparency="true" srcdoc="' + escapeHtml('<!doctype html><html><head><style>html,body{margin:0;width:100%;height:100%;overflow:hidden;background:transparent;}</style></head><body>' + html + '</body></html>') + '" title="' + escapeHtml(region.label || region.region_key || 'html') + '" loading="eager" scrolling="no" style="width:100%;height:100%;border:0;display:block;background:transparent;overflow:hidden;"></iframe></div>'
: '';
if (!html) {
return '';
}
if (/^<!doctype\b/i.test(html) || /^<html\b/i.test(html)) {
return '<div class="slide-preview-region slide-preview-html-region" style="' + region.baseStyle + '"><iframe sandbox="" allowtransparency="true" srcdoc="' + escapeHtml(html) + '" title="' + escapeHtml(region.label || region.region_key || 'html') + '" loading="eager" scrolling="no" style="width:100%;height:100%;border:0;display:block;background:transparent;overflow:hidden;"></iframe></div>';
}
return '<div class="slide-preview-region slide-preview-html-region" style="' + region.baseStyle + '"><div class="slide-preview-html-content" style="width:100%;height:100%;overflow:hidden;background:transparent;">' + html + '</div></div>';
}
if (regionType === 'rtmp') {
+151 -44
View File
@@ -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 : '');
@@ -155,10 +203,7 @@ function buildRegionInnerHtml(region, regionContent, baseUrl) {
}
if (regionType === 'webpage') {
const src = resolveAssetUrl(baseUrl, rawValue);
return src
? '<div class="slide-preview-region slide-preview-webpage-region" style="' + region.baseStyle + '"><iframe src="' + escapeHtml(src) + '" title="' + escapeHtml(region.label || region.region_key || 'webpage') + '" loading="eager" referrerpolicy="no-referrer" scrolling="no" style="width:100%;height:100%;border:0;display:block;background:#fff;overflow:hidden;"></iframe></div>'
: '';
return '<div class="slide-preview-region slide-preview-webpage-region" style="' + region.baseStyle + '"><div class="slide-preview-webpage-placeholder" style="width:100%;height:100%;display:flex;align-items:center;justify-content:center;background:rgba(255,255,255,0.08);backdrop-filter:blur(8px);-webkit-backdrop-filter:blur(8px);border:1px solid rgba(255,255,255,0.14);box-sizing:border-box;color:rgba(255,255,255,0.72);font-size:24px;font-family:Arial,sans-serif;">Webpage preview unavailable</div></div>';
}
if (regionType === 'qr-code') {
@@ -172,9 +217,13 @@ function buildRegionInnerHtml(region, regionContent, baseUrl) {
if (regionType === 'html') {
const html = String(rawValue || '').trim();
return html
? '<div class="slide-preview-region slide-preview-html-region" style="' + region.baseStyle + '"><iframe sandbox="" allowtransparency="true" srcdoc="' + escapeHtml('<!doctype html><html><head><style>html,body{margin:0;width:100%;height:100%;overflow:hidden;background:transparent;}</style></head><body>' + html + '</body></html>') + '" title="' + escapeHtml(region.label || region.region_key || 'html') + '" loading="eager" scrolling="no" style="width:100%;height:100%;border:0;display:block;background:transparent;overflow:hidden;"></iframe></div>'
: '';
if (!html) {
return '';
}
if (/^<!doctype\b/i.test(html) || /^<html\b/i.test(html)) {
return '<div class="slide-preview-region slide-preview-html-region" style="' + region.baseStyle + '"><iframe sandbox="" allowtransparency="true" srcdoc="' + escapeHtml(html) + '" title="' + escapeHtml(region.label || region.region_key || 'html') + '" loading="eager" scrolling="no" style="width:100%;height:100%;border:0;display:block;background:transparent;overflow:hidden;"></iframe></div>';
}
return '<div class="slide-preview-region slide-preview-html-region" style="' + region.baseStyle + '"><div class="slide-preview-html-content" style="width:100%;height:100%;overflow:hidden;background:transparent;">' + html + '</div></div>';
}
if (regionType === 'rtmp') {
@@ -182,7 +231,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 +248,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 +263,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 +277,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)
};
}
@@ -253,8 +302,7 @@ async function launchBrowser() {
args: [
'--no-sandbox',
'--disable-setuid-sandbox',
'--disable-dev-shm-usage',
'--disable-gpu'
'--disable-dev-shm-usage'
],
defaultViewport: { width: 1920, height: 1080, deviceScaleFactor: 1 },
executablePath: executablePath,
@@ -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\//, ''));
@@ -306,14 +401,9 @@ async function captureSlideThumbnail(options) {
}, { timeout: 30000 });
await page.waitForFunction(function () {
var canvas = document.querySelector('#popup-preview-canvas');
if (!canvas) {
return false;
}
var images = Array.prototype.slice.call(canvas.querySelectorAll('img'));
return images.every(function (image) {
return image.complete && typeof image.naturalWidth === 'number';
var videos = Array.prototype.slice.call(document.querySelectorAll('#popup-preview-canvas video'));
return videos.every(function (video) {
return video.readyState >= 2;
});
}, { timeout: 30000 });
@@ -322,17 +412,13 @@ async function captureSlideThumbnail(options) {
try {
await document.fonts.ready;
} catch (_error) {
// Ignore font readiness failures and fall back to the rendered frame.
return null;
}
}
});
await page.evaluate(function () {
return new Promise(function (resolve) {
window.requestAnimationFrame(function () {
window.requestAnimationFrame(resolve);
});
});
await new Promise(function (resolve) {
setTimeout(resolve, 1000);
});
}
@@ -340,28 +426,49 @@ async function captureSlideThumbnail(options) {
try {
const page = await browser.newPage();
try {
const previewPath = '/api/internal/slide-thumbnails/' + slide.id + '/popup-preview';
const previewPath = '/api/internal/slide-thumbnails/' + encodeURIComponent(String(slide.id)) + '/popup-preview';
const previewUrl = baseUrl + previewPath;
const previewPayload = buildThumbnailPreviewPayload(slide, {
baseUrl: baseUrl,
fontStylesheetHref: options && options.fontStylesheetHref ? options.fontStylesheetHref : ''
const canvasSize = getThumbnailCanvasSize(slide);
await page.setViewport({
width: Math.max(1, Number(canvasSize.width || PLAYER_VIEWPORT.width)),
height: Math.max(1, Number(canvasSize.height || PLAYER_VIEWPORT.height)),
deviceScaleFactor: 1
});
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) {
throw new Error('Popup preview did not produce a slide canvas.');
}
await canvas.screenshot({ path: fullSizePath });
await page.evaluate(function () {
var canvasElement = document.querySelector('#popup-preview-canvas');
if (canvasElement) {
canvasElement.style.transform = 'none';
canvasElement.style.transformOrigin = 'top left';
}
});
const canvasBounds = await canvas.boundingBox();
if (!canvasBounds) {
throw new Error('Popup preview canvas has no screenshot bounds.');
}
await page.screenshot({
path: fullSizePath,
clip: {
x: Math.max(0, canvasBounds.x),
y: Math.max(0, canvasBounds.y),
width: Math.max(1, canvasBounds.width),
height: Math.max(1, canvasBounds.height)
}
});
} finally {
await page.close().catch(function () {
return null;
+4
View File
@@ -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;
+1 -1
View File
@@ -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();
}
+5
View File
@@ -2383,6 +2383,11 @@ html[data-bs-theme='dark'] .template-field-card .tox .tox-collection {
margin-bottom: 0;
}
.template-field-card .form-label,
.region-item .form-label {
margin-bottom: 0.35rem;
}
.template-field-card .form-control,
.template-field-card .form-select,
.template-field-card textarea,
+14 -10
View File
@@ -886,7 +886,7 @@
var summaryLabel = isRoot ? (isArray ? 'Array' : 'Object') : String(key);
var summaryMeta = isArray ? '[' + value.length + ']' : '{' + Object.keys(value).length + '}';
return '' +
'<details class="json-tree-node" open>' +
'<details class="json-tree-node"' + (isRoot ? ' open' : '') + '>' +
'<summary><span class="json-tree-key">' + escapeHtml(summaryLabel) + '</span><span class="mx-1">:</span><span class="text-body-secondary">' + escapeHtml(summaryMeta) + '</span></summary>' +
'<div class="ms-3 ps-3 border-start">' + entries + '</div>' +
'</details>';
@@ -945,26 +945,30 @@
detail.open = expanded;
});
if (collapseButton) {
collapseButton.disabled = !details.length || !expanded;
}
if (expandButton) {
expandButton.disabled = !details.length || expanded;
}
syncButtons();
}
function syncButtons() {
var details = output.querySelectorAll('details');
var allExpanded = details.length > 0 && Array.prototype.every.call(details, function (detail) { return detail.open; });
var allCollapsed = details.length > 0 && Array.prototype.every.call(details, function (detail) { return !detail.open; });
if (collapseButton) {
collapseButton.setAttribute('aria-pressed', 'false');
collapseButton.disabled = !details.length || allCollapsed;
collapseButton.setAttribute('aria-pressed', String(allCollapsed));
}
if (expandButton) {
expandButton.setAttribute('aria-pressed', 'true');
expandButton.disabled = !details.length || allExpanded;
expandButton.setAttribute('aria-pressed', String(allExpanded));
}
}
output.innerHTML = formattedTree;
setAllSectionsExpanded(false);
var rootDetails = output.querySelector('details');
if (rootDetails) {
rootDetails.open = true;
}
syncButtons();
setAllSectionsExpanded(true);
if (collapseButton) {
collapseButton.addEventListener('click', function () {
@@ -11,6 +11,9 @@
var panels = Array.prototype.slice.call(form.querySelectorAll('[data-api-source-auth-panel]'));
var bearerTokenInput = form.querySelector('[data-api-source-bearer-token-input]');
var bearerTokenToggle = form.querySelector('[data-api-source-bearer-token-toggle]');
var requestMethodSelect = form.querySelector('[data-api-source-request-method]');
var requestSection = form.querySelector('[data-api-source-request-section]');
var requestBodyInput = form.querySelector('[data-api-source-request-body]');
function updateBearerTokenToggle() {
if (!bearerTokenInput || !bearerTokenToggle) {
@@ -47,14 +50,28 @@
});
}
function updateRequestBodyVisibility() {
if (!requestMethodSelect || !requestSection || !requestBodyInput) {
return;
}
var isPost = String(requestMethodSelect.value || 'GET').toUpperCase() === 'POST';
requestSection.hidden = !isPost;
}
if (methodSelect) {
methodSelect.addEventListener('change', updatePanels);
}
if (requestMethodSelect) {
requestMethodSelect.addEventListener('change', updateRequestBodyVisibility);
}
if (bearerTokenToggle && bearerTokenInput) {
bearerTokenToggle.addEventListener('click', toggleBearerTokenVisibility);
updateBearerTokenToggle();
}
updatePanels();
updateRequestBodyVisibility();
}());
+1 -1
View File
@@ -1029,7 +1029,7 @@
'</div>' +
'<div class="card-body p-3 d-grid gap-2">' +
'<label class="form-label mb-1" for="region_qr_code_' + region.id + '">URL</label>' +
'<input id="region_qr_code_' + region.id + '" type="url" name="region_qr_code_' + region.id + '" class="form-control" value="' + escapeHtml(current) + '" placeholder="https://example.com" />' +
'<input id="region_qr_code_' + region.id + '" type="url" name="region_qr_code_' + region.id + '" class="form-control" value="' + escapeHtml(current) + '" placeholder="https://example.com" required />' +
'<textarea name="region_qr_svg_' + region.id + '" class="visually-hidden" hidden>' + escapeHtml(currentSvg) + '</textarea>' +
'<input type="hidden" name="region_qr_preview_' + region.id + '" value="' + escapeHtml(String(context.qr_preview || '')) + '" />' +
'<div class="card card-outline card-secondary overflow-hidden mt-2">' +
+1 -1
View File
@@ -50,7 +50,7 @@
headerActions: '<span class="chip">Webpage</span>',
bodyHtml: '' +
'<div class="input-group flex-nowrap">' +
'<input type="url" name="region_webpage_' + region.id + '" class="form-control" value="' + escapeHtml(current) + '" placeholder="https://example.com" />' +
'<input type="url" name="region_webpage_' + region.id + '" class="form-control" value="' + escapeHtml(current) + '" placeholder="https://example.com" required />' +
'<button type="button" class="btn btn-outline-secondary text-nowrap" data-webpage-preview-update data-region-id="' + region.id + '">Update</button>' +
'</div>'
});
@@ -1,5 +1,6 @@
(function () {
if (window.location.pathname.replace(/\/$/, '') !== '/settings/tasks-background' && window.location.pathname.replace(/\/$/, '') !== '/settings/tasks-scheduled') {
var normalizedPathname = window.location.pathname.replace(/\/$/, '');
if (normalizedPathname !== '/settings/tasks-background' && normalizedPathname !== '/settings/tasks-scheduled') {
return;
}
+10 -20
View File
@@ -305,31 +305,12 @@ import { createSlideFormPreviewHelpers } from '/assets/js/slides/slide-form-prev
previewBox.innerHTML = value
? '<div class="slide-image-region-preview-shell" data-remove-' + (isVideo ? 'region-video' : isQrImage ? 'region-qr-image' : 'region-image') + '="' + escapeHtml(card.getAttribute('data-region-id') || '') + '" role="button" tabindex="0" aria-label="Remove ' + (isVideo ? 'video' : isQrImage ? 'QR image' : 'image') + '">' +
(isVideo
? '<video class="slide-image-region-preview" src="' + escapeHtml(value) + '" autoplay loop muted playsinline preload="metadata"></video>'
? '<video class="slide-image-region-preview" src="' + escapeHtml(value) + '" muted playsinline preload="metadata"></video>'
: '<img class="slide-image-region-preview" src="' + escapeHtml(value) + '" alt="' + (isQrImage ? 'Current QR image preview' : 'Current image preview') + '" />') +
'<span class="slide-image-region-preview-remove" aria-hidden="true"><i class="bi bi-trash3" aria-hidden="true"></i></span>' +
'</div>'
: '<div class="slide-image-region-preview slide-image-region-preview-empty">No ' + (isVideo ? 'video' : 'image') + '</div>';
if (isVideo) {
window.requestAnimationFrame(function () {
var video = previewBox.querySelector('video.slide-image-region-preview');
if (!video) {
return;
}
try {
video.load();
} catch (_error) {
// Ignore load failures; play() will retry if the browser allows it.
}
var playPromise = video.play && video.play();
if (playPromise && typeof playPromise.catch === 'function') {
playPromise.catch(function () {
return null;
});
}
});
}
}
function loadVideoDuration(mediaPath) {
@@ -861,6 +842,15 @@ import { createSlideFormPreviewHelpers } from '/assets/js/slides/slide-form-prev
requestPreviewRender();
}
slideForm.addEventListener('keydown', function (event) {
var target = event.target;
if (event.key !== 'Enter' || !target || !target.matches || !target.matches('input:not([type="submit"]):not([type="button"]):not([type="reset"]), select')) {
return;
}
event.preventDefault();
});
slideForm.addEventListener('submit', function (event) {
if (!window.webAsyncSaveForm || typeof window.webAsyncSaveForm.submit !== 'function') {
return;
+97
View File
@@ -1,6 +1,102 @@
// Shared browser helpers for web UI escaping and formatting.
(function () {
var urlValidationMessage = 'Please enter a URL.';
function isUrlInput(input) {
return input && input.matches && input.matches('input[type="url"]');
}
function isValidHttpUrl(input) {
input.setCustomValidity('');
var value = String(input.value || '').trim();
var hasValidNativeUrl = input.validity.valid;
var hasExplicitHttpScheme = /^https?:\/\/[^\s]+$/i.test(value);
var isValid = hasValidNativeUrl && hasExplicitHttpScheme;
if (!isValid) {
input.setCustomValidity(urlValidationMessage);
}
return isValid;
}
function getUrlFeedback(input) {
if (input._urlFeedback && input._urlFeedback.parentNode) {
return input._urlFeedback;
}
var feedbackId = input.id ? input.id + '-url-feedback' : '';
var feedback = feedbackId ? document.getElementById(feedbackId) : null;
if (feedback) {
var inputGroup = input.closest ? input.closest('.input-group') : null;
var feedbackAnchor = inputGroup || input;
feedbackAnchor.parentNode.insertBefore(feedback, feedbackAnchor.nextSibling);
input._urlFeedback = feedback;
return feedback;
}
feedback = document.createElement('div');
if (feedbackId) {
feedback.id = feedbackId;
}
feedback.className = 'invalid-feedback';
feedback.setAttribute('role', 'alert');
feedback.textContent = urlValidationMessage;
var inputGroup = input.closest ? input.closest('.input-group') : null;
var feedbackAnchor = inputGroup || input;
feedbackAnchor.parentNode.insertBefore(feedback, feedbackAnchor.nextSibling);
if (input.id) {
input.setAttribute('aria-describedby', feedback.id);
}
input._urlFeedback = feedback;
return feedback;
}
function updateUrlValidation(input, showFeedback) {
if (!isUrlInput(input)) {
return true;
}
var isValid = isValidHttpUrl(input);
if (!isValid && showFeedback) {
var feedback = getUrlFeedback(input);
feedback.hidden = false;
feedback.classList.add('d-block');
input.classList.add('is-invalid');
} else if (isValid) {
if (input.id) {
document.querySelectorAll('[id="' + input.id + '-url-feedback"]').forEach(function (feedback) {
feedback.hidden = true;
feedback.classList.remove('d-block');
});
} else if (input._urlFeedback) {
input._urlFeedback.hidden = true;
input._urlFeedback.classList.remove('d-block');
}
input.classList.remove('is-invalid');
}
return isValid;
}
function initializeUrlValidation() {
if (typeof document === 'undefined') {
return;
}
document.addEventListener('input', function (event) {
if (isUrlInput(event.target)) {
updateUrlValidation(event.target, true);
}
});
document.addEventListener('invalid', function (event) {
if (isUrlInput(event.target)) {
event.preventDefault();
updateUrlValidation(event.target, true);
}
}, true);
}
function escapeHtml(value) {
return String(value ?? '')
.replace(/&/g, '&amp;')
@@ -97,6 +193,7 @@
}
window.escapeHtml = escapeHtml;
initializeUrlValidation();
var defaultQrOptions = window.defaultQrOptions || {
width: 1000,
@@ -21,6 +21,13 @@ function buildDuplicateApiSource(apiSource, duplicateName) {
authBearerToken: apiSource.auth_bearer_token || '',
authHeaderName: apiSource.auth_header_name || 'X-API-Key',
authHeaderValue: apiSource.auth_header_value || '',
requestMethod: apiSource.request_method || 'GET',
requestBodyJson: apiSource.request_body_json || '',
tokenUrl: apiSource.token_url || '',
tokenRequestBodyJson: apiSource.token_request_body_json || '',
tokenResponsePath: apiSource.token_response_path || 'access_token',
tokenHeaderName: apiSource.token_header_name || 'Authorization',
tokenHeaderPrefix: apiSource.token_header_prefix || 'Bearer',
itemsPath: apiSource.items_path || ''
});
}
@@ -18,6 +18,13 @@ module.exports = function renderApiSourceEditPage(apiSource, data, message, curr
authBearerToken: apiSource.auth_bearer_token || '',
authHeaderName: apiSource.auth_header_name || 'X-API-Key',
authHeaderValue: apiSource.auth_header_value || '',
itemsPath: apiSource.items_path || ''
tokenUrl: apiSource.token_url || '',
tokenRequestBodyJson: apiSource.token_request_body_json || '',
tokenResponsePath: apiSource.token_response_path || 'access_token',
tokenHeaderName: apiSource.token_header_name || 'Authorization',
tokenHeaderPrefix: apiSource.token_header_prefix || 'Bearer',
itemsPath: apiSource.items_path || '',
requestMethod: apiSource.request_method || 'GET',
requestBodyJson: apiSource.request_body_json || ''
}), message, currentUser, viewData, true));
};
@@ -5,12 +5,19 @@ function buildDefaultApiSource() {
id: null,
name: '',
apiUrl: '',
requestMethod: 'GET',
requestBodyJson: '',
authMethod: 'none',
authUsername: '',
authPassword: '',
authBearerToken: '',
authHeaderName: 'X-API-Key',
authHeaderValue: '',
tokenUrl: '',
tokenRequestBodyJson: '',
tokenResponsePath: 'access_token',
tokenHeaderName: 'Authorization',
tokenHeaderPrefix: 'Bearer',
itemsPath: '',
updateIntervalValue: 60,
updateIntervalUnit: 'minutes',
@@ -103,6 +103,8 @@ module.exports = function registerApiSourceRoutes(app, deps) {
authBearerToken: apiSource.auth_bearer_token || '',
authHeaderName: apiSource.auth_header_name || 'X-API-Key',
authHeaderValue: apiSource.auth_header_value || '',
tokenUrl: apiSource.token_url || '',
tokenResponsePath: apiSource.token_response_path || 'access_token',
itemsPath: apiSource.items_path || '',
intervalLabel: apiSource.update_interval_unit === 'seconds'
? (Math.max(1, Number(apiSource.update_interval_value) || 0) === 1 ? 'Every second' : `Every ${Math.max(1, Number(apiSource.update_interval_value) || 0)} seconds`)
@@ -178,6 +180,11 @@ module.exports = function registerApiSourceRoutes(app, deps) {
authBearerToken: apiSource.auth_bearer_token || '',
authHeaderName: apiSource.auth_header_name || 'X-API-Key',
authHeaderValue: apiSource.auth_header_value || '',
tokenUrl: apiSource.token_url || '',
tokenRequestBodyJson: apiSource.token_request_body_json || '',
tokenResponsePath: apiSource.token_response_path || 'access_token',
tokenHeaderName: apiSource.token_header_name || 'Authorization',
tokenHeaderPrefix: apiSource.token_header_prefix || 'Bearer',
itemsPath: apiSource.items_path || '',
updateIntervalValue: apiSource.update_interval_value,
updateIntervalUnit: apiSource.update_interval_unit || 'minutes',
@@ -235,8 +242,8 @@ module.exports = function registerApiSourceRoutes(app, deps) {
const actorId = getAuditUserId(req);
await connection.beginTransaction();
const [result] = await connection.query(
'INSERT INTO i_api_sources (name, api_url, auth_method, auth_username, auth_password, auth_bearer_token, auth_header_name, auth_header_value, items_path, update_interval_value, update_interval_unit, last_pulled_at, last_pull_error, last_response_status, last_response_content_type, last_response_json, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
[payload.name, payload.apiUrl, payload.authMethod, payload.authUsername || null, payload.authPassword || null, payload.authBearerToken || null, payload.authHeaderName || null, payload.authHeaderValue || null, payload.itemsPath || null, payload.updateIntervalValue, payload.updateIntervalUnit, null, null, null, null, null, actorId, actorId]
'INSERT INTO i_api_sources (name, api_url, request_method, request_body_json, auth_method, auth_username, auth_password, auth_bearer_token, auth_header_name, auth_header_value, token_url, token_request_body_json, token_response_path, token_header_name, token_header_prefix, items_path, update_interval_value, update_interval_unit, last_pulled_at, last_pull_error, last_response_status, last_response_content_type, last_response_json, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
[payload.name, payload.apiUrl, payload.requestMethod, payload.requestBodyJson || null, payload.authMethod, payload.authUsername || null, payload.authPassword || null, payload.authBearerToken || null, payload.authHeaderName || null, payload.authHeaderValue || null, payload.tokenUrl || null, payload.tokenRequestBodyJson || null, payload.tokenResponsePath || null, payload.tokenHeaderName || null, payload.tokenHeaderPrefix || null, payload.itemsPath || null, payload.updateIntervalValue, payload.updateIntervalUnit, null, null, null, null, null, actorId, actorId]
);
await connection.commit();
dataSourceTasks.registerRecurringRefresh('api-source', result.insertId, payload.name, payload.updateIntervalValue, payload.updateIntervalUnit, function () {
@@ -288,8 +295,8 @@ module.exports = function registerApiSourceRoutes(app, deps) {
const actorId = getAuditUserId(req);
await connection.beginTransaction();
await connection.query(
'UPDATE i_api_sources SET name = ?, api_url = ?, auth_method = ?, auth_username = ?, auth_password = ?, auth_bearer_token = ?, auth_header_name = ?, auth_header_value = ?, items_path = ?, update_interval_value = ?, update_interval_unit = ?, last_pulled_at = ?, last_pull_error = ?, last_response_status = ?, last_response_content_type = ?, last_response_json = ?, modified_by = ? WHERE id = ?',
[payload.name, payload.apiUrl, payload.authMethod, payload.authUsername || null, payload.authPassword || null, payload.authBearerToken || null, payload.authHeaderName || null, payload.authHeaderValue || null, payload.itemsPath || null, payload.updateIntervalValue, payload.updateIntervalUnit, null, null, null, null, null, actorId, apiSource.id]
'UPDATE i_api_sources SET name = ?, api_url = ?, request_method = ?, request_body_json = ?, auth_method = ?, auth_username = ?, auth_password = ?, auth_bearer_token = ?, auth_header_name = ?, auth_header_value = ?, token_url = ?, token_request_body_json = ?, token_response_path = ?, token_header_name = ?, token_header_prefix = ?, items_path = ?, update_interval_value = ?, update_interval_unit = ?, last_pulled_at = ?, last_pull_error = ?, last_response_status = ?, last_response_content_type = ?, last_response_json = ?, modified_by = ? WHERE id = ?',
[payload.name, payload.apiUrl, payload.requestMethod, payload.requestBodyJson || null, payload.authMethod, payload.authUsername || null, payload.authPassword || null, payload.authBearerToken || null, payload.authHeaderName || null, payload.authHeaderValue || null, payload.tokenUrl || null, payload.tokenRequestBodyJson || null, payload.tokenResponsePath || null, payload.tokenHeaderName || null, payload.tokenHeaderPrefix || null, payload.itemsPath || null, payload.updateIntervalValue, payload.updateIntervalUnit, null, null, null, null, null, actorId, apiSource.id]
);
await connection.commit();
dataSourceTasks.registerRecurringRefresh('api-source', apiSource.id, payload.name, payload.updateIntervalValue, payload.updateIntervalUnit, function () {
+1 -1
View File
@@ -138,7 +138,7 @@ module.exports = function registerSettingsRoutes(app, deps) {
app.post('/settings/tasks-scheduled/recurring/:key/run', requireRecurringTaskAccess, async function (req, res, next) {
try {
const ran = await backgroundTaskQueue.runRecurringTask(String(req.params.key || '').trim());
res.redirect('/settings/tasks-scheduled?message=' + encodeURIComponent(ran ? 'Scheduled refresh run queued.' : 'Unable to run that scheduled refresh.'));
res.redirect('/settings/tasks-scheduled?message=' + encodeURIComponent(ran ? 'Task run queued.' : 'Unable to run that task.'));
} catch (error) {
next(error);
}
+57 -4
View File
@@ -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');
@@ -12,14 +12,30 @@
</div>
<div class="card-body">
<div class="row g-3 mb-4">
<div class="col-12 col-lg-6">
<div class="col-12 col-lg-5">
<label for="api-source-name" class="form-label">Name</label>
<input id="api-source-name" name="name" class="form-control" value="{{apiSource.name}}" maxlength="128" data-limit-text-length required />
</div>
<div class="col-12 col-lg-6">
<div class="col-12 col-lg-5">
<label for="api-source-url" class="form-label">API URL</label>
<input id="api-source-url" name="api_url" type="url" class="form-control" value="{{apiSource.apiUrl}}" maxlength="1024" data-limit-text-length placeholder="https://example.com/api.json" required />
</div>
<div class="col-12 col-lg-2">
<label for="api-source-request-method" class="form-label">Request method</label>
<select id="api-source-request-method" name="request_method" class="form-select" data-api-source-request-method>
<option value="GET" {{#if (eq apiSource.requestMethod 'GET')}}selected{{/if}}>GET</option>
<option value="POST" {{#if (eq apiSource.requestMethod 'POST')}}selected{{/if}}>POST</option>
</select>
</div>
</div>
<div class="mb-4" data-api-source-request-section>
<h4 class="api-source-section-heading">Request</h4>
<div class="row g-3 align-items-end">
<div class="col-12">
<div class="form-text mb-2">POST requests use JSON with the existing authentication settings.</div>
<textarea id="api-source-request-body" name="request_body_json" class="form-control font-monospace" rows="5" data-api-source-request-body placeholder="{&#10; &quot;ids&quot;: [1, 2, 3]&#10;}" spellcheck="false">{{apiSource.requestBodyJson}}</textarea>
</div>
</div>
</div>
<div class="mb-4">
<h4 class="api-source-section-heading">Access and parsing</h4>
@@ -31,6 +47,7 @@
<option value="basic" {{#if (eq apiSource.authMethod 'basic')}}selected{{/if}}>Basic</option>
<option value="bearer" {{#if (eq apiSource.authMethod 'bearer')}}selected{{/if}}>Bearer token</option>
<option value="api_key_header" {{#if (eq apiSource.authMethod 'api_key_header')}}selected{{/if}}>API key header</option>
<option value="token_login" {{#if (eq apiSource.authMethod 'token_login')}}selected{{/if}}>Login then token</option>
</select>
</div>
<div class="col-12 col-md-8">
@@ -40,7 +57,7 @@
</div>
<div class="row mt-2">
<div class="col-12 col-md-8 offset-md-4">
<div class="form-text">Optional. Use dot notation to point at the array this source should iterate through.</div>
<div class="form-text">Optional. Use dot notation to change the default base for placeholders.</div>
</div>
</div>
</div>
@@ -77,6 +94,30 @@
<input id="api-source-auth-header-value" name="auth_header_value" class="form-control" value="{{apiSource.authHeaderValue}}" maxlength="255" data-limit-text-length />
</div>
</div>
<div class="row g-3 mb-3" data-api-source-auth-panel="token_login" hidden>
<div class="col-12 col-md-8">
<label for="api-source-token-url" class="form-label">Login / token URL</label>
<input id="api-source-token-url" name="token_url" type="url" class="form-control" value="{{apiSource.tokenUrl}}" maxlength="1024" placeholder="https://example.com/login" />
<div class="form-text">The login request is sent as POST with the JSON body below.</div>
</div>
<div class="col-12 col-md-4">
<label for="api-source-token-response-path" class="form-label">Token response path</label>
<input id="api-source-token-response-path" name="token_response_path" class="form-control" value="{{apiSource.tokenResponsePath}}" placeholder="access_token" />
<div class="form-text">Example: <code>data.token</code></div>
</div>
<div class="col-12">
<label for="api-source-token-body" class="form-label">Login request body</label>
<textarea id="api-source-token-body" name="token_request_body_json" class="form-control font-monospace" rows="4" placeholder="{&#10; &quot;username&quot;: &quot;...&quot;,&#10; &quot;password&quot;: &quot;...&quot;&#10;}" spellcheck="false">{{apiSource.tokenRequestBodyJson}}</textarea>
</div>
<div class="col-12 col-md-6">
<label for="api-source-token-header-name" class="form-label">API token header</label>
<input id="api-source-token-header-name" name="token_header_name" class="form-control" value="{{apiSource.tokenHeaderName}}" placeholder="Authorization" />
</div>
<div class="col-12 col-md-6">
<label for="api-source-token-header-prefix" class="form-label">Token prefix</label>
<input id="api-source-token-header-prefix" name="token_header_prefix" class="form-control" value="{{apiSource.tokenHeaderPrefix}}" placeholder="Bearer" />
</div>
</div>
</div>
<div>
<h4 class="api-source-section-heading">Refresh</h4>
@@ -103,7 +144,7 @@
</form>
<div class="card card-outline card-secondary mt-3" id="api-source-response-panel">
<div class="card-header d-flex align-items-center gap-2">
<div class="card-header d-flex align-items-center">
<h3 class="card-title mb-0">Latest response</h3>
{{#if lastResponseJson}}
<div class="btn-group btn-group-sm ms-auto" role="group" aria-label="JSON tree actions">
+83
View File
@@ -0,0 +1,83 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const { fetchApiSourceResponse, buildApiSourcePayload } = require('../src/data/api-sources');
function jsonResponse(status, body) {
return {
status: status,
ok: status >= 200 && status < 300,
headers: { get: () => 'application/json' },
text: async () => JSON.stringify(body)
};
}
test('API source payload accepts POST request and token-login settings', () => {
const payload = buildApiSourcePayload({
body: {
name: 'Protected report',
api_url: 'https://example.com/report',
request_method: 'POST',
request_body_json: '{"site_id":42}',
auth_method: 'token_login',
token_url: 'https://example.com/login',
token_request_body_json: '{"username":"demo","password":"secret"}',
token_response_path: 'data.accessToken',
token_header_name: 'Authorization',
token_header_prefix: 'Bearer',
update_interval_value: '5',
update_interval_unit: 'minutes'
}
});
assert.equal(payload.requestMethod, 'POST');
assert.equal(payload.requestBodyJson, '{"site_id":42}');
assert.equal(payload.authMethod, 'token_login');
assert.equal(payload.tokenUrl, 'https://example.com/login');
assert.equal(payload.tokenResponsePath, 'data.accessToken');
});
test('API source sends login POST, uses token, and refreshes once after 401', async () => {
const originalFetch = global.fetch;
const calls = [];
let loginCount = 0;
let apiCount = 0;
global.fetch = async function (url, options) {
calls.push({ url, options });
if (url === 'https://example.com/login') {
loginCount += 1;
return jsonResponse(200, { accessToken: 'token-' + loginCount, expires_in: 300 });
}
apiCount += 1;
return apiCount === 1
? jsonResponse(401, { error: 'expired' })
: jsonResponse(200, { items: [{ id: 1 }] });
};
try {
const result = await fetchApiSourceResponse({
id: 987654,
api_url: 'https://example.com/report',
request_method: 'POST',
request_body_json: JSON.stringify({ site_id: 42 }),
auth_method: 'token_login',
token_url: 'https://example.com/login',
token_request_body_json: JSON.stringify({ username: 'demo', password: 'secret' }),
token_response_path: 'accessToken',
token_header_name: 'Authorization',
token_header_prefix: 'Bearer'
});
assert.deepEqual(JSON.parse(result.responseJson), { items: [{ id: 1 }] });
assert.equal(loginCount, 2);
assert.equal(apiCount, 2);
assert.equal(calls[0].options.method, 'POST');
assert.equal(calls[0].options.headers['Content-Type'], 'application/json');
assert.deepEqual(JSON.parse(calls[0].options.body), { username: 'demo', password: 'secret' });
assert.equal(calls[1].options.headers.Authorization, 'Bearer token-1');
assert.equal(calls[3].options.headers.Authorization, 'Bearer token-2');
assert.deepEqual(JSON.parse(calls[3].options.body), { site_id: 42 });
} finally {
global.fetch = originalFetch;
}
});
+2 -2
View File
@@ -99,9 +99,9 @@ test('shared iframe renderers size preview content explicitly', () => {
assert.ok(playerHtmlRegionSource.includes('style="width:100%;height:100%;border:0;display:block;background:transparent;overflow:hidden;"'));
assert.ok(playerWebpageRegionSource.includes('style="width:100%;height:100%;border:0;display:block;background:#fff;overflow:hidden;"'));
assert.ok(playerRenderHelpersSource.includes('style="width:100%;height:100%;border:0;display:block;background:transparent;overflow:hidden;"'));
assert.ok(slideThumbnailPreviewSource.includes('style="width:100%;height:100%;border:0;display:block;background:#fff;overflow:hidden;"'));
assert.ok(slideThumbnailPreviewSource.includes('slide-preview-webpage-region'));
assert.ok(slideThumbnailPreviewSource.includes('style="width:100%;height:100%;border:0;display:block;background:transparent;overflow:hidden;"'));
assert.ok(slideThumbnailsSource.includes('style="width:100%;height:100%;border:0;display:block;background:#fff;overflow:hidden;"'));
assert.ok(slideThumbnailsSource.includes('slide-preview-webpage-placeholder'));
assert.ok(slideThumbnailsSource.includes('style="width:100%;height:100%;border:0;display:block;background:transparent;overflow:hidden;"'));
});
+2 -2
View File
@@ -44,7 +44,7 @@ test('pending migrations are empty when the schema already matches the app versi
}
]);
const pendingMigrations = await getPendingMigrations(pool, { currentVersion: '2.8.0' });
const pendingMigrations = await getPendingMigrations(pool, { currentVersion: '2.8.7' });
assert.equal(pendingMigrations.length, 0);
});
@@ -74,7 +74,7 @@ test('pending migrations are reported when an older schema still needs scripts',
const pendingMigrations = await getPendingMigrations(pool, { currentVersion: '2.6.17' });
assert.ok(pendingMigrations.length > 0);
assert.equal(pendingMigrations.filter(function (migration) { return migration.version.indexOf('2.8.') === 0; }).length, 1);
assert.equal(pendingMigrations.filter(function (migration) { return migration.version.indexOf('2.8.') === 0; }).length, 2);
assert.equal(pendingMigrations.find(function (migration) { return migration.version.indexOf('2.8.') === 0; }).version, '2.8.0');
});
+2
View File
@@ -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', () => {