Compare commits

...
7 Commits
Author SHA1 Message Date
lzstealth 9097d45d6a Improve announcement rendering and theme assets 2026-08-26 01:08:59 +01:00
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
36 changed files with 690 additions and 138 deletions
+29
View File
@@ -2,6 +2,35 @@
All notable changes to this project will be documented in this file.
## 2.8.8 - 2026-08-26
### Changed
- Refreshed the vendored AdminLTE assets to 4.8.5.
- Added AdminLTE extended palette colours to announcement colour choices and player rendering.
- Remove Digital Signage Subheading and top padding.
## 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
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "pulse-signage-player",
"version": "2.8.4",
"version": "2.8.8",
"private": false,
"description": "Pulse Signage player application bundle",
"engines": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "pulse-signage-web",
"version": "2.8.4",
"version": "2.8.8",
"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.4",
"version": "2.8.8",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "pulse-signage",
"version": "2.8.4",
"version": "2.8.8",
"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.4",
"version": "2.8.8",
"private": false,
"description": "Pulse Signage application with MySQL and media storage",
"engines": {
+5 -1
View File
@@ -11,7 +11,11 @@ const { validateMaxLength } = require('./utils');
const SHORT_LABEL_MAX_LENGTH = 255;
const ANNOUNCEMENT_TYPES = ['lower-third', 'fullscreen', 'top-banner'];
const ANNOUNCEMENT_COLORS = ['primary', 'secondary', 'success', 'info', 'warning', 'danger', 'dark', 'light'];
const ANNOUNCEMENT_COLORS = [
'primary', 'secondary', 'success', 'info', 'warning', 'danger', 'dark', 'light',
'orange', 'amber', 'olive', 'teal', 'sky', 'indigo', 'violet', 'fuchsia', 'pink',
'navy', 'steel', 'slate', 'graphite', 'midnight'
];
function normalizeAnnouncementType(value) {
const normalized = String(value || '').trim().toLowerCase();
+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');
}
}
];
@@ -22,7 +22,11 @@
function normalizeAnnouncementColor(value) {
var normalized = String(value || '').trim().toLowerCase();
if (['primary', 'secondary', 'success', 'info', 'warning', 'danger', 'dark', 'light'].indexOf(normalized) !== -1) {
if ([
'primary', 'secondary', 'success', 'info', 'warning', 'danger', 'dark', 'light',
'orange', 'amber', 'olive', 'teal', 'sky', 'indigo', 'violet', 'fuchsia', 'pink',
'navy', 'steel', 'slate', 'graphite', 'midnight'
].indexOf(normalized) !== -1) {
return normalized;
}
return 'primary';
@@ -42,7 +46,21 @@
warning: { accent: '#ffc107', foreground: '#201400', glow: 'rgba(255, 193, 7, 0.30)' },
danger: { accent: '#dc3545', foreground: '#ffffff', glow: 'rgba(220, 53, 69, 0.34)' },
dark: { accent: '#212529', foreground: '#ffffff', glow: 'rgba(33, 37, 41, 0.34)' },
light: { accent: '#f8f9fa', foreground: '#1f2937', glow: 'rgba(248, 249, 250, 0.34)' }
light: { accent: '#f8f9fa', foreground: '#1f2937', glow: 'rgba(248, 249, 250, 0.34)' },
orange: { accent: '#c84e10', foreground: '#ffffff', glow: 'rgba(200, 78, 16, 0.34)' },
amber: { accent: '#a56710', foreground: '#ffffff', glow: 'rgba(165, 103, 16, 0.34)' },
olive: { accent: '#5f7f0f', foreground: '#ffffff', glow: 'rgba(95, 127, 15, 0.34)' },
teal: { accent: '#12827d', foreground: '#ffffff', glow: 'rgba(18, 130, 125, 0.34)' },
sky: { accent: '#127caf', foreground: '#ffffff', glow: 'rgba(18, 124, 175, 0.34)' },
indigo: { accent: '#6f60ea', foreground: '#ffffff', glow: 'rgba(111, 96, 234, 0.34)' },
violet: { accent: '#9553db', foreground: '#ffffff', glow: 'rgba(149, 83, 219, 0.34)' },
fuchsia: { accent: '#b347be', foreground: '#ffffff', glow: 'rgba(179, 71, 190, 0.34)' },
pink: { accent: '#cd388d', foreground: '#ffffff', glow: 'rgba(205, 56, 141, 0.34)' },
navy: { accent: '#1d2d4c', foreground: '#ffffff', glow: 'rgba(29, 45, 76, 0.34)' },
steel: { accent: '#3a4860', foreground: '#ffffff', glow: 'rgba(58, 72, 96, 0.34)' },
slate: { accent: '#566577', foreground: '#ffffff', glow: 'rgba(86, 101, 119, 0.34)' },
graphite: { accent: '#32363c', foreground: '#ffffff', glow: 'rgba(50, 54, 60, 0.34)' },
midnight: { accent: '#1e1d2d', foreground: '#ffffff', glow: 'rgba(30, 29, 45, 0.34)' }
};
return tokens[colorKey] || tokens.primary;
+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) {
+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') {
+42 -37
View File
@@ -203,10 +203,7 @@ function buildRegionInnerHtml(region, regionContent, baseUrl, options) {
}
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') {
@@ -220,9 +217,13 @@ function buildRegionInnerHtml(region, regionContent, baseUrl, options) {
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') {
@@ -301,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,
@@ -401,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 });
@@ -417,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);
});
}
@@ -435,19 +426,14 @@ async function captureSlideThumbnail(options) {
try {
const page = await browser.newPage();
try {
const previewPayload = buildThumbnailPreviewPayload(slide, {
baseUrl: baseUrl,
fontStylesheetHref: options && options.fontStylesheetHref ? options.fontStylesheetHref : '',
getItem: getThumbnailItem,
getCachedImagePath: getCachedImagePath
const previewPath = '/api/internal/slide-thumbnails/' + encodeURIComponent(String(slide.id)) + '/popup-preview';
const previewUrl = baseUrl + previewPath;
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
});
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(Object.assign({}, createRequestAuthHeaders({
method: 'GET',
pathname: previewPath
@@ -463,7 +449,26 @@ async function captureSlideThumbnail(options) {
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;
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+6 -1
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,
@@ -2424,7 +2429,7 @@ html[data-bs-theme='dark'] .template-field-card .tox .tox-collection {
.announcement-color-picker__grid {
display: grid;
grid-template-columns: repeat(8, minmax(0, 1fr));
grid-template-columns: repeat(auto-fit, minmax(5.5rem, 1fr));
gap: 0.375rem;
}
+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>'
});
+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;
+17 -16
View File
@@ -2,25 +2,26 @@
(function () {
var storageKey = 'lte-theme';
var theme = 'dark';
function getPreferredTheme() {
if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
return 'dark';
}
return 'light';
}
var root = document.documentElement;
var authoredTheme = root.getAttribute('data-bs-theme');
var theme = authoredTheme === 'light' || authoredTheme === 'dark' ? authoredTheme : 'dark';
var storedTheme = null;
try {
var storedTheme = window.localStorage.getItem(storageKey);
if (storedTheme === 'dark' || storedTheme === 'light' || storedTheme === 'auto') {
theme = storedTheme === 'auto' ? getPreferredTheme() : storedTheme;
}
storedTheme = window.localStorage.getItem(storageKey);
} catch (error) {
theme = 'dark';
storedTheme = null;
}
document.documentElement.dataset.bsTheme = theme;
document.documentElement.style.colorScheme = theme;
if (storedTheme === 'light' || storedTheme === 'dark') {
theme = storedTheme;
} else if (storedTheme === 'auto' || (authoredTheme !== 'light' && authoredTheme !== 'dark')) {
theme = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
root.setAttribute('data-bs-theme', theme);
root.style.colorScheme = theme;
if (theme !== authoredTheme) {
root.setAttribute('data-lte-theme-resolved', '');
}
}());
+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 () {
+15 -1
View File
@@ -28,7 +28,21 @@ module.exports = function registerAnnouncementRoutes(app, deps) {
{ value: 'warning', label: 'Warning' },
{ value: 'danger', label: 'Danger' },
{ value: 'dark', label: 'Dark' },
{ value: 'light', label: 'Light' }
{ value: 'light', label: 'Light' },
{ value: 'orange', label: 'Orange' },
{ value: 'amber', label: 'Amber' },
{ value: 'olive', label: 'Olive' },
{ value: 'teal', label: 'Teal' },
{ value: 'sky', label: 'Sky' },
{ value: 'indigo', label: 'Indigo' },
{ value: 'violet', label: 'Violet' },
{ value: 'fuchsia', label: 'Fuchsia' },
{ value: 'pink', label: 'Pink' },
{ value: 'navy', label: 'Navy' },
{ value: 'steel', label: 'Steel' },
{ value: 'slate', label: 'Slate' },
{ value: 'graphite', label: 'Graphite' },
{ value: 'midnight', label: 'Midnight' }
];
const ANNOUNCEMENT_ICONS = announcementIcons.ANNOUNCEMENT_ICON_OPTIONS || [];
const ANNOUNCEMENT_ICON_CATALOG = announcementIcons.ANNOUNCEMENT_ICON_CATALOG || ANNOUNCEMENT_ICONS;
@@ -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">
+1
View File
@@ -8,6 +8,7 @@
<script src="/assets/js/theme-init.js"></script>
<link rel="stylesheet" href="/assets/adminlte/bootstrap-icons/css/bootstrap-icons.min.css" />
<link rel="stylesheet" href="/assets/adminlte/css/adminlte.min.css" />
<link rel="stylesheet" href="/assets/adminlte/css/adminlte-colors.min.css" />
<link rel="stylesheet" href="/assets/css/theme-custom.css?v={{appVersion}}" />
{{#if stylesheets.length}}
{{#each stylesheets}}
+4 -3
View File
@@ -10,6 +10,7 @@
<link rel="preload" href="/assets/adminlte/bootstrap-icons/fonts/bootstrap-icons.woff" as="font" type="font/woff" crossorigin="anonymous" />
<link rel="stylesheet" href="/assets/adminlte/bootstrap-icons/css/bootstrap-icons.min.css" />
<link rel="stylesheet" href="/assets/adminlte/css/adminlte.min.css" />
<link rel="stylesheet" href="/assets/adminlte/css/adminlte-colors.min.css" />
<link rel="stylesheet" href="/assets/css/theme-custom.css?v={{appVersion}}" />
<link rel="stylesheet" href="/assets/vendor/animate.css/animate.min.css?v={{appVersion}}" />
{{#if stylesheets.length}}
@@ -171,11 +172,11 @@
</a>
</div>
<div class="sidebar-wrapper d-flex flex-column flex-grow-1 min-h-0">
<nav class="mt-2 flex-grow-1" aria-label="Main navigation">
<nav class="flex-grow-1" aria-label="Main navigation">
<ul class="nav sidebar-menu flex-column" data-lte-toggle="treeview" data-accordion="false" role="menu" id="navigation">
{{#if (anyPermission currentUser 'dashboard.read' 'clients.read' 'screens.read' 'announcements.read' 'playlists.read' 'slides.read' 'templates.read' 'canvas-sizes.read')}}
{{!-- {{#if (anyPermission currentUser 'dashboard.read' 'clients.read' 'screens.read' 'announcements.read' 'playlists.read' 'slides.read' 'templates.read' 'canvas-sizes.read')}}
<li class="nav-header">DIGITAL SIGNAGE</li>
{{/if}}
{{/if}} --}}
{{#if (hasPermission currentUser 'dashboard.read')}}
<li class="nav-item">
<a class="nav-link {{#if (eq active 'dashboard')}}active{{/if}}" href="/dashboard">
+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');
});