Compare commits

...
6 Commits
Author SHA1 Message Date
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 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
lzstealth 7bb34f40fe Release 2.8.3
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:08:05 +01:00
lzstealth ea72747822 Release 2.8.2
Publish Docker Image / build-and-push-existing-registry (./build/Dockerfile, web, pulse-signage-web) (push) Successful in 1m12s
Publish Docker Image / build-and-push-existing-registry (./build/Dockerfile.player, player, pulse-signage-player) (push) Successful in 34s
2026-08-16 22:41:39 +01:00
lzstealth a5bf8e6f7f Release 2.8.1
Publish Docker Image / build-and-push-existing-registry (./build/Dockerfile, web, pulse-signage-web) (push) Successful in 1m16s
Publish Docker Image / build-and-push-existing-registry (./build/Dockerfile.player, player, pulse-signage-player) (push) Successful in 33s
2026-08-16 22:08:32 +01:00
74 changed files with 1256 additions and 243 deletions
+50
View File
@@ -2,6 +2,56 @@
All notable changes to this project will be documented in this file.
## 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
- Added API, RSS, Timetable, and Time / Date placeholder help panels with shared transform and date-token documentation.
- Added render-time API and RSS image placeholders with proportional sizing and preview-only bounding boxes.
### Changed
- API and RSS regions now preserve authored content when no data source is selected and remain blank when a selected source has no authored content.
- Normal WYSIWYG image insertion remains upload-backed and separate from image placeholder transforms.
## 2.8.2 - 2026-08-16
### Changed
- Standardized update audit events on from/to changes and added readable table diffs for nested JSON, arrays, null values, and empty strings.
- Standardized internal `src/data` imports on the `#src` alias.
## 2.8.1 - 2026-08-16
### Fixed
- Prevented startup and runtime duplicate-key writes from consuming auto-increment values in permission, player, onboarding, settings, and relationship tables.
- Prevented partial timetable schemas from being incorrectly treated as fully migrated during schema version detection.
### Changed
- Renamed the built-in administrator role key to `super-admin`, while allowing its display name and description to be edited without being overwritten on restart.
## 2.8.0 - 2026-08-16
### Added
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "pulse-signage-player",
"version": "2.8.0",
"version": "2.8.5",
"private": false,
"description": "Pulse Signage player application bundle",
"engines": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "pulse-signage-web",
"version": "2.8.0",
"version": "2.8.5",
"private": false,
"description": "Pulse Signage web and bridge application bundle",
"engines": {
+1 -1
View File
@@ -19,7 +19,7 @@ PLAYER_INTERNAL_URL="http://player:8081"
# Web app bootstrap settings
DEFAULT_ADMIN_USERNAME="admin"
DEFAULT_ADMIN_NAME="Admin"
DEFAULT_ADMIN_PASSWORD="password123"
DEFAULT_ADMIN_PASSWORD="password123!"
# Bridge settings for the player-bridge service
WEB_INTERNAL_URL="http://web:8080"
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "pulse-signage",
"version": "2.8.0",
"version": "2.8.5",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "pulse-signage",
"version": "2.8.0",
"version": "2.8.5",
"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.0",
"version": "2.8.5",
"private": false,
"description": "Pulse Signage application with MySQL and media storage",
"engines": {
+11 -3
View File
@@ -165,11 +165,19 @@ async function saveAppSettings(pool, settings, modifiedBy) {
if (!Object.prototype.hasOwnProperty.call(inputSettings, definition.key)) {
continue;
}
await connection.query(
`UPDATE o_app_settings
SET setting_value = ?, modified_by = ?
WHERE setting_key = ?`,
[JSON.stringify(normalizedSettings[definition.key]), modifiedBy || null, definition.key]
);
await connection.query(
`INSERT INTO o_app_settings (setting_key, setting_value, created_by, modified_by)
VALUES (?, ?, ?, ?)
ON DUPLICATE KEY UPDATE setting_value = VALUES(setting_value), modified_by = VALUES(modified_by)`,
[definition.key, JSON.stringify(normalizedSettings[definition.key]), modifiedBy || null, modifiedBy || null]
SELECT ?, ?, ?, ?
WHERE NOT EXISTS (
SELECT 1 FROM o_app_settings WHERE setting_key = ?
)`,
[definition.key, JSON.stringify(normalizedSettings[definition.key]), modifiedBy || null, modifiedBy || null, definition.key]
);
}
if (typeof connection.commit === 'function') {
+16
View File
@@ -42,6 +42,21 @@ function normalizeDetails(details) {
return JSON.stringify(details);
}
function buildAuditChanges(previousValues, nextValues) {
const previous = previousValues && typeof previousValues === 'object' ? previousValues : {};
const next = nextValues && typeof nextValues === 'object' ? nextValues : {};
const changes = {};
const keys = new Set(Object.keys(previous).concat(Object.keys(next)));
keys.forEach(function (key) {
if (JSON.stringify(previous[key]) !== JSON.stringify(next[key])) {
changes[key] = { from: previous[key], to: next[key] };
}
});
return changes;
}
function getRequestMetadata(req) {
const forwardedAddress = String(req && req.headers && req.headers['x-forwarded-for'] || '').split(',')[0].trim();
return {
@@ -97,6 +112,7 @@ module.exports = {
AUDIT_CATEGORY_KEYS,
AUDIT_CATEGORY_LABELS,
getRequestMetadata,
buildAuditChanges,
recordAuditEvent,
recordRequestAuditEvent
};
+25 -14
View File
@@ -92,15 +92,19 @@ async function upsertPlayerRegistration(pool, options) {
return null;
}
await pool.query(
`UPDATE d_players
SET public_base_url = ?, internal_base_url = ?, last_seen_at = CURRENT_TIMESTAMP, modified_at = CURRENT_TIMESTAMP
WHERE identifier = ?`,
[publicBaseUrl || null, internalBaseUrl || null, identifier]
);
await pool.query(
`INSERT INTO d_players (identifier, public_base_url, internal_base_url, last_seen_at)
VALUES (?, ?, ?, CURRENT_TIMESTAMP)
ON DUPLICATE KEY UPDATE
public_base_url = VALUES(public_base_url),
internal_base_url = VALUES(internal_base_url),
last_seen_at = CURRENT_TIMESTAMP,
modified_at = CURRENT_TIMESTAMP`,
[identifier, publicBaseUrl || null, internalBaseUrl || null]
SELECT ?, ?, ?, CURRENT_TIMESTAMP
WHERE NOT EXISTS (
SELECT 1 FROM d_players WHERE identifier = ?
)`,
[identifier, publicBaseUrl || null, internalBaseUrl || null, identifier]
);
return resolvePlayerRegistration(pool, identifier);
@@ -115,15 +119,22 @@ async function recordPlayerHeartbeat(pool, options) {
return null;
}
await pool.query(
`UPDATE d_players
SET public_base_url = COALESCE(?, public_base_url),
internal_base_url = COALESCE(?, internal_base_url),
last_seen_at = CURRENT_TIMESTAMP,
modified_at = CURRENT_TIMESTAMP
WHERE identifier = ?`,
[publicBaseUrl || null, internalBaseUrl || null, identifier]
);
await pool.query(
`INSERT INTO d_players (identifier, public_base_url, internal_base_url, last_seen_at)
VALUES (?, ?, ?, CURRENT_TIMESTAMP)
ON DUPLICATE KEY UPDATE
public_base_url = COALESCE(VALUES(public_base_url), public_base_url),
internal_base_url = COALESCE(VALUES(internal_base_url), internal_base_url),
last_seen_at = CURRENT_TIMESTAMP,
modified_at = CURRENT_TIMESTAMP`,
[identifier, publicBaseUrl || null, internalBaseUrl || null]
SELECT ?, ?, ?, CURRENT_TIMESTAMP
WHERE NOT EXISTS (
SELECT 1 FROM d_players WHERE identifier = ?
)`,
[identifier, publicBaseUrl || null, internalBaseUrl || null, identifier]
);
return resolvePlayerRegistration(pool, identifier);
+2 -2
View File
@@ -418,7 +418,7 @@ async function buildTemplateContent(pool, template, body, filesByField, existing
content[region.region_key] = {
type: 'rss',
value: stripEditorOnlyMarkup(normalizeEditorMarkup(submitted === undefined ? String(current.value || '') : String(submitted || ''))),
feed_id: feedId === undefined || feedId === null || feedId === '' ? (current.feed_id || null) : Number(feedId),
feed_id: feedId === undefined || feedId === null ? (current.feed_id || null) : (feedId === '' ? null : Number(feedId)),
item_number: Math.min(itemCount, Number.isFinite(parsedItemNumber) && parsedItemNumber > 0 ? parsedItemNumber : 1),
variable_name: 'item',
font_family: style.font_family,
@@ -436,7 +436,7 @@ async function buildTemplateContent(pool, template, body, filesByField, existing
content[region.region_key] = {
type: 'api',
value: stripEditorOnlyMarkup(normalizeEditorMarkup(submitted === undefined ? String(current.value || '') : String(submitted || ''))),
source_id: sourceId === undefined || sourceId === null || sourceId === '' ? (current.source_id || null) : Number(sourceId),
source_id: sourceId === undefined || sourceId === null ? (current.source_id || null) : (sourceId === '' ? null : Number(sourceId)),
item_number: Number.isFinite(parsedItemNumber) && parsedItemNumber > 0 ? parsedItemNumber : 1,
items_path: itemsPath === undefined || itemsPath === null ? (current.items_path === undefined || current.items_path === null ? '' : String(current.items_path)) : String(itemsPath || '').trim(),
variable_name: 'item',
+34 -9
View File
@@ -15,11 +15,19 @@ async function bootstrapDatabase(pool) {
}
for (const permission of PERMISSIONS) {
await pool.query(
`UPDATE a_permissions
SET name = ?, section_name = ?, description = ?, modified_by = ?
WHERE permission_key = ?`,
[permission.name, permission.sectionName, permission.description || null, null, permission.key]
);
await pool.query(
`INSERT INTO a_permissions (permission_key, name, section_name, description, created_by, modified_by)
VALUES (?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE name = VALUES(name), section_name = VALUES(section_name), description = VALUES(description), modified_by = VALUES(modified_by)` ,
[permission.key, permission.name, permission.sectionName, permission.description || null, null, null]
SELECT ?, ?, ?, ?, ?, ?
WHERE NOT EXISTS (
SELECT 1 FROM a_permissions WHERE permission_key = ?
)`,
[permission.key, permission.name, permission.sectionName, permission.description || null, null, null, permission.key]
);
}
@@ -35,20 +43,37 @@ async function bootstrapDatabase(pool) {
);
}
const [legacyRoleRows] = await pool.query('SELECT id FROM a_roles WHERE role_key = ? LIMIT 1', ['administrators']);
const [currentRoleRows] = await pool.query('SELECT id FROM a_roles WHERE role_key = ? LIMIT 1', [DEFAULT_ROLE.key]);
if (legacyRoleRows.length && !currentRoleRows.length) {
await pool.query(
`UPDATE a_roles
SET role_key = ?, name = ?, description = ?, modified_by = ?
WHERE role_key = ?`,
[DEFAULT_ROLE.key, DEFAULT_ROLE.name, DEFAULT_ROLE.description || null, null, 'administrators']
);
}
await pool.query(
`INSERT INTO a_roles (role_key, name, description, created_by, modified_by)
VALUES (?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE name = VALUES(name), description = VALUES(description), modified_by = VALUES(modified_by)`,
[DEFAULT_ROLE.key, DEFAULT_ROLE.name, DEFAULT_ROLE.description || null, null, null]
SELECT ?, ?, ?, ?, ?
WHERE NOT EXISTS (
SELECT 1 FROM a_roles WHERE role_key = ?
)`,
[DEFAULT_ROLE.key, DEFAULT_ROLE.name, DEFAULT_ROLE.description || null, null, null, DEFAULT_ROLE.key]
);
const [defaultRoleRows] = await pool.query('SELECT id FROM a_roles WHERE role_key = ? LIMIT 1', [DEFAULT_ROLE.key]);
const defaultRoleId = defaultRoleRows.length ? Number(defaultRoleRows[0].id) : null;
if (defaultRoleId) {
await pool.query(
`INSERT IGNORE INTO a_role_permissions (role_id, permission_id, created_by, modified_by)
SELECT ?, id, NULL, NULL FROM a_permissions`,
[defaultRoleId]
`INSERT INTO a_role_permissions (role_id, permission_id, created_by, modified_by)
SELECT ?, permissions.id, NULL, NULL
FROM a_permissions permissions
LEFT JOIN a_role_permissions existing
ON existing.role_id = ? AND existing.permission_id = permissions.id
WHERE existing.id IS NULL`,
[defaultRoleId, defaultRoleId]
);
}
+8 -3
View File
@@ -538,7 +538,7 @@ async function detectSchemaVersion(pool) {
const scheduleGroupsExists = await tableExists(pool, 'i_schedule_groups');
const scheduleEntriesExists = await tableExists(pool, 'i_schedule_entries');
if ((timetableGroupsExists || timetableEntriesExists) && !scheduleGroupsExists && !scheduleEntriesExists) {
if (timetableGroupsExists && timetableEntriesExists && !scheduleGroupsExists && !scheduleEntriesExists) {
return '2.6.18';
}
@@ -556,9 +556,14 @@ async function recordSchemaVersion(pool, version) {
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`
);
const stateValue = String(version || appVersion || '0.0.0').trim();
await pool.query(
'INSERT INTO ' + APP_STATE_TABLE + ' (state_key, state_value) VALUES (?, ?) ON DUPLICATE KEY UPDATE state_value = VALUES(state_value)',
[APP_STATE_SCHEMA_VERSION_KEY, String(version || appVersion || '0.0.0').trim()]
'UPDATE ' + APP_STATE_TABLE + ' SET state_value = ? WHERE state_key = ?',
[stateValue, APP_STATE_SCHEMA_VERSION_KEY]
);
await pool.query(
'INSERT INTO ' + APP_STATE_TABLE + ' (state_key, state_value) SELECT ?, ? WHERE NOT EXISTS (SELECT 1 FROM ' + APP_STATE_TABLE + ' WHERE state_key = ?)',
[APP_STATE_SCHEMA_VERSION_KEY, stateValue, APP_STATE_SCHEMA_VERSION_KEY]
);
}
+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({
+12 -2
View File
@@ -128,8 +128,18 @@ async function commitDeviceBinding(pool, deviceId, clientName, screenSlug, isNam
}
await pool.query(
'INSERT INTO d_onboarding_devices (device_id, client_name, screen_id) VALUES (?, ?, ?) ON DUPLICATE KEY UPDATE client_name = VALUES(client_name), screen_id = VALUES(screen_id), modified_at = CURRENT_TIMESTAMP',
[normalizedDeviceId, normalizedClientName, screen.id]
`UPDATE d_onboarding_devices
SET client_name = ?, screen_id = ?, modified_at = CURRENT_TIMESTAMP
WHERE device_id = ?`,
[normalizedClientName, screen.id, normalizedDeviceId]
);
await pool.query(
`INSERT INTO d_onboarding_devices (device_id, client_name, screen_id)
SELECT ?, ?, ?
WHERE NOT EXISTS (
SELECT 1 FROM d_onboarding_devices WHERE device_id = ?
)`,
[normalizedDeviceId, normalizedClientName, screen.id, normalizedDeviceId]
);
return getOnboardingStatus(pool, normalizedDeviceId);
+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);
+22 -6
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') {
@@ -67,7 +67,25 @@ function substituteApiVariables(html, item) {
return '';
}
return escapeHtml(placeholderUtils.formatPlaceholderValue(placeholderUtils.resolvePlaceholderExpression(item, expression)));
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 '';
}
var imageConfig = typeof placeholderUtils.getImagePlaceholderConfig === 'function' ? placeholderUtils.getImagePlaceholderConfig(expression) : {};
var hasImageBounds = imageConfig.width && imageConfig.height;
var imageStyle = hasImageBounds
? 'display:block;width:100%;height:100%;object-fit:contain;'
: 'display:block;width:auto;height:auto;' + (imageConfig.width ? 'max-width:' + imageConfig.width + 'px;' : '') + (imageConfig.height ? 'max-height:' + imageConfig.height + 'px;' : '');
var image = '<img src="' + escapeHtml(imageSource) + '" alt="" style="' + imageStyle + '" />';
return hasImageBounds
? '<span style="display:inline-flex;align-items:center;justify-content:center;box-sizing:border-box;width:' + imageConfig.width + 'px;height:' + imageConfig.height + 'px;">' + image + '</span>'
: image;
}
return escapeHtml(placeholderUtils.formatPlaceholderValue(resolved));
});
}
@@ -120,10 +138,8 @@ function renderApiRegion(region, regionContent) {
var fontSize = sanitizeFontSize(regionContent.font_size || region.fontSize);
var fontColor = sanitizeTextColor(regionContent.font_color || region.fontColor);
var item = getApiItem(sourceId, itemNumber, itemsPath);
var body = item ? substituteApiVariables(content, item) : '';
if (!body && item) {
body = getApiPreviewFallback(item);
}
var hasSource = String(sourceId === undefined || sourceId === null ? '' : sourceId).trim() !== '';
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) {
+27 -15
View File
@@ -1,6 +1,7 @@
// RSS region helpers for resolving feeds, items, and nested values.
var registry = window.pulsePlayerRegionTypes;
var placeholderUtils = window.placeholderUtils || {};
function getDefaultStyle() {
return {
@@ -63,14 +64,34 @@ 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*([a-zA-Z0-9_]+)(?:\.([a-zA-Z0-9_.]+))?\s*\}\}/g, function (_match, tokenName, tokenPath) {
return source.replace(/\{\{\s*([^{}]+?)\s*\}\}/g, function (_match, expression) {
if (!item || typeof item !== 'object') {
return '';
}
var key = tokenPath ? tokenName + '.' + tokenPath : tokenName;
return escapeHtml(resolveRssPath(item, key || ''));
if (typeof placeholderUtils.resolvePlaceholderExpression !== 'function' || typeof placeholderUtils.formatPlaceholderValue !== 'function') {
return '';
}
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 '';
}
var imageConfig = typeof placeholderUtils.getImagePlaceholderConfig === 'function' ? placeholderUtils.getImagePlaceholderConfig(expression) : {};
var hasImageBounds = imageConfig.width && imageConfig.height;
var imageStyle = hasImageBounds
? 'display:block;width:100%;height:100%;object-fit:contain;'
: 'display:block;width:auto;height:auto;' + (imageConfig.width ? 'max-width:' + imageConfig.width + 'px;' : '') + (imageConfig.height ? 'max-height:' + imageConfig.height + 'px;' : '');
var image = '<img src="' + escapeHtml(imageSource) + '" alt="" style="' + imageStyle + '" />';
return hasImageBounds
? '<span style="display:inline-flex;align-items:center;justify-content:center;box-sizing:border-box;width:' + imageConfig.width + 'px;height:' + imageConfig.height + 'px;">' + image + '</span>'
: image;
}
return escapeHtml(placeholderUtils.formatPlaceholderValue(resolved));
});
}
@@ -83,17 +104,8 @@ function renderRssRegion(region, regionContent) {
var fontSize = sanitizeFontSize(regionContent.font_size || region.fontSize || defaultStyle.font_size);
var fontColor = sanitizeTextColor(regionContent.font_color || region.fontColor || defaultStyle.font_color);
var item = getRssFeedItem(feedId, itemNumber);
var body = item ? substituteRssVariables(content, item) : '';
if (!body && item) {
var summaryParts = [];
if (item.title) {
summaryParts.push('<h3>' + escapeHtml(item.title) + '</h3>');
}
if (item.description) {
summaryParts.push('<div>' + sanitizeRichText(item.description) + '</div>');
}
body = summaryParts.join('');
}
var hasFeed = String(feedId === undefined || feedId === null ? '' : feedId).trim() !== '';
var body = hasFeed ? (item ? substituteRssVariables(content, item, feedId) : '') : content;
var contentStyle = 'width:' + region.pixelWidth + 'px;height:' + region.pixelHeight + 'px;transform:scale(' + region.canvasScale + ');transform-origin:top left;' + (fontFamily ? 'font-family:' + escapeHtml(fontFamily) + ';' : '') + 'font-size:' + fontSize + 'px;color:' + escapeHtml(fontColor) + ';';
if (!body) {
return '';
+2 -2
View File
@@ -236,8 +236,8 @@ const PERMISSIONS = PERMISSION_SECTIONS.flatMap(function (section, sectionIndex)
});
const DEFAULT_ROLE = {
key: 'administrators',
name: 'Administrators',
key: 'super-admin',
name: 'Super Admin',
description: 'Full access to the admin interface.'
};
+25 -3
View File
@@ -236,7 +236,14 @@ async function syncUserRoles(pool, userId, roleIds) {
await pool.query('DELETE FROM a_user_roles WHERE user_id = ?', [userId]);
for (const roleId of uniqueRoleIds) {
await pool.query('INSERT IGNORE INTO a_user_roles (user_id, role_id, created_by, modified_by) VALUES (?, ?, ?, ?)', [userId, roleId, null, null]);
await pool.query(
`INSERT INTO a_user_roles (user_id, role_id, created_by, modified_by)
SELECT ?, ?, ?, ?
WHERE NOT EXISTS (
SELECT 1 FROM a_user_roles WHERE user_id = ? AND role_id = ?
)`,
[userId, roleId, null, null, userId, roleId]
);
}
}
@@ -249,7 +256,14 @@ async function syncRoleUsers(pool, roleId, userIds) {
await pool.query('DELETE FROM a_user_roles WHERE role_id = ?', [roleId]);
for (const userId of uniqueUserIds) {
await pool.query('INSERT IGNORE INTO a_user_roles (user_id, role_id, created_by, modified_by) VALUES (?, ?, ?, ?)', [userId, roleId, null, null]);
await pool.query(
`INSERT INTO a_user_roles (user_id, role_id, created_by, modified_by)
SELECT ?, ?, ?, ?
WHERE NOT EXISTS (
SELECT 1 FROM a_user_roles WHERE user_id = ? AND role_id = ?
)`,
[userId, roleId, null, null, userId, roleId]
);
}
}
@@ -268,7 +282,15 @@ async function syncRolePermissions(pool, roleId, permissionKeys) {
await pool.query('DELETE FROM a_role_permissions WHERE role_id = ?', [roleId]);
for (const permissionRow of permissionRows) {
await pool.query('INSERT IGNORE INTO a_role_permissions (role_id, permission_id, created_by, modified_by) VALUES (?, ?, ?, ?)', [roleId, Number(permissionRow.id), null, null]);
const permissionId = Number(permissionRow.id);
await pool.query(
`INSERT INTO a_role_permissions (role_id, permission_id, created_by, modified_by)
SELECT ?, ?, ?, ?
WHERE NOT EXISTS (
SELECT 1 FROM a_role_permissions WHERE role_id = ? AND permission_id = ?
)`,
[roleId, permissionId, null, null, roleId, permissionId]
);
}
}
+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
@@ -4,7 +4,7 @@ const path = require('path');
const PUBLIC_JS_ROOT = path.join(__dirname, '..', 'public', 'js');
const REGION_ROOT_DIR = path.join(PUBLIC_JS_ROOT, 'regions');
const REGION_TYPE_DIR = path.join(REGION_ROOT_DIR, 'type');
const REGION_CORE_SCRIPTS = ['js/shared/placeholder-utils.js', 'js/shared/placeholder-chips.js', 'js/shared/time-date-placeholders.js', 'js/regions/region-utils.js', 'js/regions/region-types.js'];
const REGION_CORE_SCRIPTS = ['js/shared/placeholder-utils.js', 'js/shared/placeholder-chips.js', 'js/shared/placeholder-info.js', 'js/shared/time-date-placeholders.js', 'js/regions/region-utils.js', 'js/regions/region-types.js'];
function withAssetVersion(scriptPath, assetVersion) {
if (!assetVersion) {
+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();
}
+45
View File
@@ -255,6 +255,39 @@
.template-preview-card .btn-group {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
.audit-change-list {
display: grid;
padding: 0.08rem 0.3rem;
border-radius: 0.2rem;
gap: 0.2rem;
min-width: 18rem;
}
.audit-change-row {
display: grid;
grid-template-columns: minmax(8rem, 0.7fr) minmax(5rem, 1fr) auto minmax(5rem, 1fr);
background: var(--bs-danger-bg-subtle);
gap: 0.35rem;
align-items: baseline;
font-size: 0.78rem;
}
background: var(--bs-success-bg-subtle);
.audit-change-to {
overflow-wrap: anywhere;
white-space: pre-wrap;
}
.audit-change-from {
color: var(--bs-danger-text-emphasis);
}
.audit-change-to {
color: var(--bs-success-text-emphasis);
}
.audit-change-from del,
.audit-change-to ins {
text-decoration-thickness: 2px;
}
.audit-change-arrow {
color: var(--bs-secondary-color);
}
gap: 0.5rem;
}
@@ -2350,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,
@@ -2500,6 +2538,13 @@ html[data-bs-theme='dark'] .template-field-card .tox .tox-collection {
text-transform: uppercase;
}
.api-region-placeholder-title-help {
font-size: 0.9rem;
font-weight: 800;
letter-spacing: normal;
text-transform: none;
}
.api-region-sample-accordion {
padding: 0.85rem 1rem 1rem;
margin-bottom: 0.75rem;
+40 -9
View File
@@ -94,7 +94,7 @@
};
}
function substituteVariables(html, item) {
function substituteVariables(html, item, options) {
var source = String(html || '');
return source.replace(/\{\{\s*([^{}]+?)\s*\}\}/g, function (_match, expression) {
if (!item || typeof item !== 'object') {
@@ -105,7 +105,23 @@
return '';
}
return escapeHtml(placeholderUtils.formatPlaceholderValue(placeholderUtils.resolvePlaceholderExpression(item, expression)));
var resolved = placeholderUtils.resolvePlaceholderExpression(item, expression);
if (typeof placeholderUtils.isImagePlaceholderExpression === 'function' && placeholderUtils.isImagePlaceholderExpression(expression)) {
var imageSource = placeholderUtils.formatPlaceholderValue(resolved);
if (!/^(?:https?:\/\/|\/media\/|\/assets\/|\/[^/])/i.test(imageSource)) {
return '';
}
var imageConfig = typeof placeholderUtils.getImagePlaceholderConfig === 'function' ? placeholderUtils.getImagePlaceholderConfig(expression) : {};
var imageStyle = options && options.preview && imageConfig.width && imageConfig.height
? 'display:block;width:100%;height:100%;object-fit:contain;'
: 'display:block;width:auto;height:auto;' + (imageConfig.width ? 'max-width:' + imageConfig.width + 'px;' : '') + (imageConfig.height ? 'max-height:' + imageConfig.height + 'px;' : '');
var image = '<img src="' + escapeHtml(imageSource) + '" alt="" style="' + imageStyle + '" />';
if (options && options.preview && imageConfig.width && imageConfig.height) {
return '<span style="display:inline-flex;align-items:center;justify-content:center;box-sizing:border-box;width:' + imageConfig.width + 'px;height:' + imageConfig.height + 'px;border:1px dashed rgba(120,120,120,.75);">' + image + '</span>';
}
return image;
}
return escapeHtml(placeholderUtils.formatPlaceholderValue(resolved));
});
}
@@ -136,6 +152,24 @@
return summary.join('');
}
function renderTransformInfo(regionId) {
var offcanvasId = 'api-placeholder-info-' + regionId;
return '' +
'<button type="button" class="btn btn-link btn-sm p-0 text-decoration-none" data-bs-toggle="offcanvas" data-bs-target="#' + offcanvasId + '" aria-controls="' + offcanvasId + '">More info</button>' +
'<div class="offcanvas offcanvas-end fw-normal" tabindex="-1" id="' + offcanvasId + '" aria-labelledby="' + offcanvasId + '-label">' +
'<div class="offcanvas-header">' +
'<h2 class="offcanvas-title fs-5" id="' + offcanvasId + '-label">API placeholder transforms</h2>' +
'<button type="button" class="btn-close" data-bs-dismiss="offcanvas" aria-label="Close"></button>' +
'</div>' +
'<div class="offcanvas-body">' +
'<p class="small text-body-secondary">Placeholders read values from the selected API item. Nested fields use dots.</p>' +
(window.placeholderInfo ? window.placeholderInfo.renderTextTransforms() : '') +
(window.placeholderInfo ? window.placeholderInfo.renderDateFormatTokens() : '') +
(window.placeholderInfo ? window.placeholderInfo.renderImageTransform() : '') +
'</div>' +
'</div>';
}
function normalizeRenderableValue(value) {
if (value && typeof value === 'object') {
if (value.value !== undefined) {
@@ -170,11 +204,8 @@
var fontSize = sanitizeFontSize(regionContent.font_size || region.fontSize);
var fontColor = sanitizeTextColor(regionContent.font_color || region.fontColor);
var item = getItem(sourceId, itemNumber, sources, itemsPath);
var body = item ? substituteVariables(content, item) : '';
if (!body && item) {
body = getPreviewFallback(item);
}
var hasSource = String(sourceId === undefined || sourceId === null ? '' : sourceId).trim() !== '';
var body = hasSource ? (item ? substituteVariables(content, item, { preview: true }) : '') : content;
body = String(body || '')
.replace(/<pre[^>]*class="[^"]*api-region-sample-preview[^"]*"[^>]*>[\s\S]*?<\/pre>/gi, '')
@@ -239,7 +270,7 @@
'<input type="hidden" name="region_font_size_' + region.id + '" value="' + escapeHtml(context.fontSize || '') + '" />' +
'<input type="hidden" name="region_text_' + region.id + '" value="' + escapeHtml(current.value !== undefined ? current.value : '') + '" />' +
'<div class="api-region-placeholder-section">' +
'<div class="api-region-placeholder-title">Available placeholders</div>' +
'<div class="api-region-placeholder-title api-region-placeholder-title-help d-flex align-items-center gap-2">Available placeholders ' + renderTransformInfo(region.id) + '</div>' +
'<div class="d-flex flex-wrap gap-2" data-placeholder-chips>' + ((placeholderFields.length && window.placeholderChips && typeof window.placeholderChips.renderChips === 'function') ? window.placeholderChips.renderChips(placeholderFields) : placeholderChips || '<span class="muted slide-image-file">No JSON fields available.</span>') + '</div>' +
'</div>' +
'<details class="api-region-sample-accordion" data-api-sample-data-accordion>' +
@@ -290,7 +321,7 @@
return fallbackValue;
}
}())),
source_id: (card.querySelector('select[name="region_api_source_id_' + region.id + '"]') || {}).value || current.source_id,
source_id: card.querySelector('select[name="region_api_source_id_' + region.id + '"]') ? card.querySelector('select[name="region_api_source_id_' + region.id + '"]').value : current.source_id,
item_number: (card.querySelector('input[name="region_api_item_number_' + region.id + '"]') || {}).value || current.item_number,
items_path: card.querySelector('input[name="region_api_items_path_' + region.id + '"]') ? (card.querySelector('input[name="region_api_items_path_' + region.id + '"]') || {}).value : current.items_path
} : current;
+43 -20
View File
@@ -3,6 +3,7 @@
(function () {
var registry = window.pulseRegionTypes;
var utils = window.pulseRegionUtils || {};
var placeholderUtils = window.placeholderUtils || {};
var DEFAULT_STYLE = {
font_family: 'Arial',
font_size: 32,
@@ -114,14 +115,32 @@
return current === undefined || current === null ? '' : current;
}
function substituteVariables(html, item) {
function substituteVariables(html, item, options) {
var source = String(html || '');
return source.replace(/\{\{\s*([a-zA-Z0-9_]+)(?:\.([a-zA-Z0-9_.]+))?\s*\}\}/g, function (_match, tokenName, tokenPath) {
return source.replace(/\{\{\s*([^{}]+?)\s*\}\}/g, function (_match, expression) {
if (!item || typeof item !== 'object') {
return '';
}
var key = tokenPath ? tokenName + '.' + tokenPath : tokenName;
return escapeHtml(resolvePath(item, key || ''));
if (typeof placeholderUtils.resolvePlaceholderExpression !== 'function' || typeof placeholderUtils.formatPlaceholderValue !== 'function') {
return '';
}
var resolved = placeholderUtils.resolvePlaceholderExpression(item, expression);
if (typeof placeholderUtils.isImagePlaceholderExpression === 'function' && placeholderUtils.isImagePlaceholderExpression(expression)) {
var imageSource = placeholderUtils.formatPlaceholderValue(resolved);
if (!/^(?:https?:\/\/|\/media\/|\/assets\/|\/[^/])/i.test(imageSource)) {
return '';
}
var imageConfig = typeof placeholderUtils.getImagePlaceholderConfig === 'function' ? placeholderUtils.getImagePlaceholderConfig(expression) : {};
var imageStyle = options && options.preview && imageConfig.width && imageConfig.height
? 'display:block;width:100%;height:100%;object-fit:contain;'
: 'display:block;width:auto;height:auto;' + (imageConfig.width ? 'max-width:' + imageConfig.width + 'px;' : '') + (imageConfig.height ? 'max-height:' + imageConfig.height + 'px;' : '');
var image = '<img src="' + escapeHtml(imageSource) + '" alt="" style="' + imageStyle + '" />';
if (options && options.preview && imageConfig.width && imageConfig.height) {
return '<span style="display:inline-flex;align-items:center;justify-content:center;box-sizing:border-box;width:' + imageConfig.width + 'px;height:' + imageConfig.height + 'px;border:1px dashed rgba(120,120,120,.75);">' + image + '</span>';
}
return image;
}
return escapeHtml(placeholderUtils.formatPlaceholderValue(resolved));
});
}
@@ -186,18 +205,8 @@
var fontSize = sanitizeFontSize(regionContent.font_size || region.fontSize || defaultStyle.font_size);
var fontColor = sanitizeTextColor(regionContent.font_color || region.fontColor || defaultStyle.font_color);
var item = getItem(feedId, itemNumber, feeds);
var body = item ? substituteVariables(content, item) : '';
if (!body && item) {
var summaryParts = [];
if (item.title) {
summaryParts.push('<h3>' + escapeHtml(item.title) + '</h3>');
}
if (item.description) {
summaryParts.push('<div>' + sanitizePreviewHtml(item.description) + '</div>');
}
body = summaryParts.join('');
}
var hasFeed = String(feedId === undefined || feedId === null ? '' : feedId).trim() !== '';
var body = hasFeed ? (item ? substituteVariables(content, item, { preview: true }) : '') : content;
if (!body) {
return '';
@@ -206,6 +215,21 @@
return renderedBody ? '<div class="template-region rss" style="width:100%;height:100%;overflow:hidden;font-family:' + escapeHtml(fontFamily) + ';font-size:' + fontSize + 'px;color:' + escapeHtml(fontColor) + ';"><div class="template-region-text-scale" style="width:100%;height:100%;overflow:hidden;">' + renderedBody + '</div></div>' : '';
}
function renderPlaceholderInfo(regionId) {
if (!window.placeholderInfo) {
return '';
}
return window.placeholderInfo.render(regionId, 'RSS placeholder transforms',
'<p class="small text-body-secondary">Placeholders read values from the selected RSS entry. Use the available field chips as the starting point for your message.</p>' +
'<h3 class="fs-6 mt-4 fw-normal">Placeholder paths</h3>' +
'<p class="small">Nested values use dots. Missing values render as empty text.</p>' +
(window.placeholderInfo ? window.placeholderInfo.renderTextTransforms() : '') +
'<h3 class="fs-6 mt-4 fw-normal">Date formatting</h3>' +
'<p class="small">Use <code>format("MMM D, YYYY")</code> with date fields. Use <code>tz()</code> for a short timezone name and <code>tz_long()</code> for the full timezone name.</p>' +
(window.placeholderInfo ? window.placeholderInfo.renderDateFormatTokens() : '') +
(window.placeholderInfo ? window.placeholderInfo.renderImageTransform() : ''));
}
function renderEditorCard(context) {
var region = context.region;
var current = context.current || {};
@@ -221,7 +245,7 @@
'<span class="chip">RSS</span>' +
'</div>' +
'</div>' +
'<div class="card-body p-3 d-grid gap-3 pb-0">' +
'<div class="card-body p-3 d-grid gap-3">' +
'<div class="d-flex justify-content-end">' +
'<div class="btn-group btn-group-sm template-editor-size-controls" role="group" aria-label="Editor size controls">' +
'<button type="button" class="btn btn-outline-secondary" data-editor-size-action="decrease" aria-label="Decrease editor size">-</button>' +
@@ -247,9 +271,8 @@
'<input type="hidden" name="region_font_size_' + region.id + '" value="' + escapeHtml(context.fontSize || '') + '" />' +
'<input type="hidden" name="region_text_' + region.id + '" value="' + escapeHtml(current.value !== undefined ? current.value : '') + '" />' +
'<div class="api-region-placeholder-section">' +
'<div class="api-region-placeholder-title">Available placeholders</div>' +
'<div class="api-region-placeholder-title api-region-placeholder-title-help d-flex align-items-center gap-2">Available placeholders ' + renderPlaceholderInfo(region.id) + '</div>' +
'<div class="d-flex flex-wrap gap-2" data-placeholder-chips>' + (placeholderChips || '<span class="muted slide-image-file">No RSS fields available.</span>') + '</div>' +
'<div class="text-body-secondary small mt-1">Placeholder values support transforms, for example <code>{{title.upper()}}</code>, <code>{{title.title()}}</code>, <code>{{title.lower()}}</code>, or <code>{{publishedAt.format("MMM D, YYYY")}}</code>.</div>' +
'</div>' +
'</div>' +
'</div>';
@@ -291,7 +314,7 @@
return fallbackValue;
}
}())),
feed_id: (card.querySelector('select[name="region_rss_feed_id_' + region.id + '"]') || {}).value || current.feed_id,
feed_id: card.querySelector('select[name="region_rss_feed_id_' + region.id + '"]') ? card.querySelector('select[name="region_rss_feed_id_' + region.id + '"]').value : current.feed_id,
item_number: (card.querySelector('input[name="region_rss_item_number_' + region.id + '"]') || {}).value || current.item_number
} : current;
+3 -3
View File
@@ -33,9 +33,9 @@
}
var style = styleOrContext && styleOrContext.style ? styleOrContext.style : styleOrContext || {};
var fontFamily = style && style.font_family ? 'font-family:' + escapeHtml(style.font_family) + ';' : '';
var color = style && style.font_color ? 'color:' + escapeHtml(style.font_color) + ';' : '';
var fontSize = style && style.font_size ? 'font-size:' + Math.max(1, Math.round(Number(style.font_size))) + 'px;' : '';
var fontFamily = 'font-family:' + escapeHtml(style && style.font_family || DEFAULT_STYLE.font_family) + ';';
var color = 'color:' + escapeHtml(style && style.font_color || DEFAULT_STYLE.font_color) + ';';
var fontSize = 'font-size:' + Math.max(1, Math.round(Number(style && style.font_size || DEFAULT_STYLE.font_size))) + 'px;';
var width = Math.max(1, Math.round(Number(region && region.width ? region.width : 0) || 1));
var height = Math.max(1, Math.round(Number(region && region.height ? region.height : 0) || 1));
var wrapperStyle = 'width:' + width + 'px;height:' + height + 'px;overflow:hidden;line-height:1.5;' + fontFamily + fontSize + color;
+14 -2
View File
@@ -286,6 +286,19 @@
return renderTimeDatePlaceholderChips();
}
function renderPlaceholderInfo(regionId) {
if (!window.placeholderInfo) {
return '';
}
var placeholders = window.timeDatePlaceholders && typeof window.timeDatePlaceholders.getTokens === 'function'
? window.timeDatePlaceholders.getTokens()
: [];
var table = '<div class="table-responsive"><table class="table table-sm align-middle mb-0"><thead><tr><th>Placeholder</th><th>Output</th><th>Description</th></tr></thead><tbody>' + placeholders.map(function (item) {
return '<tr><td><code>{{' + escapeHtml(item.token) + '}}</code></td><td>' + escapeHtml(item.output) + '</td><td>' + escapeHtml(item.description) + '</td></tr>';
}).join('') + '</tbody></table></div>';
return window.placeholderInfo.render(regionId, 'Time and date placeholders', table);
}
function renderEditorCard(context) {
var region = context.region;
var current = context.current || {};
@@ -317,9 +330,8 @@
'<datalist id="region_time_date_timezones_' + region.id + '">' + buildTimezoneOptionsMarkup(timeZone) + '</datalist>' +
'</div>' +
'<div>' +
'<div class="api-region-placeholder-title mb-2">Available placeholders</div>' +
'<div class="api-region-placeholder-title api-region-placeholder-title-help d-flex align-items-center gap-2 mb-2">Available placeholders ' + renderPlaceholderInfo(region.id) + '</div>' +
'<div class="d-flex flex-wrap gap-2">' + renderPlaceholderChips() + '</div>' +
'<div class="text-body-secondary small mt-1">Placeholder values support transforms, for example <code>{{title.upper()}}</code>, <code>{{title.title()}}</code>, or <code>{{title.lower()}}</code>.</div>' +
'</div>' +
'</div>' +
'</div>';
+16 -7
View File
@@ -240,6 +240,21 @@
};
}
function renderPlaceholderInfo(regionId) {
if (!window.placeholderInfo) {
return '';
}
var body = '<p class="small text-body-secondary">Placeholders read fields from the selected timetable entries. The selected group and display mode determine which entries and fields are available.</p>' +
'<h3 class="fs-6 mt-4 fw-normal">Placeholder paths</h3>' +
'<p class="small">Use the available field chips. Nested values use dots, and missing values render as empty text.</p>' +
window.placeholderInfo.renderTextTransforms() +
'<h3 class="fs-6 mt-4 fw-normal">Date and time formatting</h3>' +
'<p class="small">Use <code>format("MMM D, YYYY h:mm A")</code> with date fields. Use <code>tz()</code> for a short timezone name and <code>tz_long()</code> for the full timezone name.</p>' +
window.placeholderInfo.renderDateFormatTokens() +
'';
return window.placeholderInfo.render(regionId, 'Timetable placeholder transforms', body);
}
function renderEditorCard(context) {
var region = context.region;
var current = context.current || {};
@@ -298,14 +313,8 @@
'</div>' +
'<div class="text-body-secondary small">Use the selected group and display mode to choose which timetable entry fields are available.</div>' +
'<div class="api-region-placeholder-section schedule-placeholder-section" data-schedule-placeholder-chips>' +
'<div class="api-region-placeholder-title">Available placeholders</div>' +
'<div class="api-region-placeholder-title api-region-placeholder-title-help d-flex align-items-center gap-2">Available placeholders ' + renderPlaceholderInfo(region.id) + '</div>' +
'<div class="d-flex flex-wrap gap-2">' + renderSchedulePlaceholderChips(currentGroup, currentEntries) + '</div>' +
'<div class="text-body-secondary small mt-1">Placeholder values support transforms, for example <code>{{title.upper()}}</code>, <code>{{title.title()}}</code>, <code>{{title.lower()}}</code>, <code>{{start.format("MMM D, YYYY h:mm A")}}</code>, <code>{{start.tz()}}</code> for the short zone name, or <code>{{start.tz_long()}}</code> for the full IANA zone name.</div>' +
'<details class="mt-2">' +
'<summary class="small text-body-secondary">Supported date format tokens</summary>' +
'<div class="mt-2">' + renderScheduleFormatTokenTable() + '</div>' +
'<div class="text-body-secondary small mt-1">Use these tokens inside <code>.format(...)</code>; for example <code>{{start.format("MMM D, YYYY h:mm A")}}</code>.</div>' +
'</details>' +
'</div>' +
'</div>' +
'</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;
}
@@ -0,0 +1,67 @@
// Shared offcanvas renderer for region placeholder documentation.
(function () {
var root = window;
function escapeHtml(value) {
return String(value === undefined || value === null ? '' : value)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function render(regionId, title, body) {
var offcanvasId = String(regionId || 'region') + '-placeholder-info';
return '' +
'<button type="button" class="btn btn-link btn-sm p-0 text-decoration-none" data-bs-toggle="offcanvas" data-bs-target="#' + offcanvasId + '" aria-controls="' + offcanvasId + '">More info</button>' +
'<div class="offcanvas offcanvas-end fw-normal" tabindex="-1" id="' + offcanvasId + '" aria-labelledby="' + offcanvasId + '-label">' +
'<div class="offcanvas-header">' +
'<h2 class="offcanvas-title fs-5" id="' + offcanvasId + '-label">' + escapeHtml(title) + '</h2>' +
'<button type="button" class="btn-close" data-bs-dismiss="offcanvas" aria-label="Close"></button>' +
'</div>' +
'<div class="offcanvas-body">' + body + '</div>' +
'</div>';
}
function renderTextTransforms() {
return '<h3 class="fs-6 mt-4 fw-normal">Text transforms</h3>' +
'<dl class="small fw-normal">' +
'<dt class="fw-normal"><code>upper()</code></dt><dd>Converts text to uppercase.</dd>' +
'<dt class="fw-normal"><code>lower()</code></dt><dd>Converts text to lowercase.</dd>' +
'<dt class="fw-normal"><code>title()</code></dt><dd>Capitalizes each word.</dd>' +
'<dt class="fw-normal"><code>tz()</code></dt><dd>Returns the short timezone name for a date.</dd>' +
'<dt class="fw-normal"><code>tz_long()</code></dt><dd>Returns the full timezone name for a date.</dd>' +
'</dl>';
}
function renderDateFormatTokens() {
return '<h3 class="fs-6 mt-4 fw-normal">Date format tokens</h3>' +
'<p class="small">Use the <code>format("...")</code> transform with these tokens. Text inside square brackets is treated as a literal.</p>' +
'<div class="table-responsive small"><table class="table table-sm align-middle mb-0"><thead><tr><th>Token</th><th>Meaning</th><th>Example</th></tr></thead><tbody>' +
'<tr><td><code>YYYY</code></td><td>Four-digit year</td><td><code>2026</code></td></tr><tr><td><code>YY</code></td><td>Two-digit year</td><td><code>26</code></td></tr>' +
'<tr><td><code>MMMM</code></td><td>Full month name</td><td><code>August</code></td></tr><tr><td><code>MMM</code></td><td>Short month name</td><td><code>Aug</code></td></tr>' +
'<tr><td><code>MM</code></td><td>Two-digit month</td><td><code>08</code></td></tr><tr><td><code>M</code></td><td>Month number</td><td><code>8</code></td></tr>' +
'<tr><td><code>DD</code></td><td>Two-digit day</td><td><code>16</code></td></tr><tr><td><code>D</code></td><td>Day number</td><td><code>16</code></td></tr>' +
'<tr><td><code>dddd</code></td><td>Full weekday name</td><td><code>Sunday</code></td></tr><tr><td><code>ddd</code></td><td>Short weekday name</td><td><code>Sun</code></td></tr>' +
'<tr><td><code>HH</code> / <code>H</code></td><td>24-hour time</td><td><code>19</code> / <code>7</code></td></tr><tr><td><code>hh</code> / <code>h</code></td><td>12-hour time</td><td><code>07</code> / <code>7</code></td></tr>' +
'<tr><td><code>mm</code> / <code>m</code></td><td>Minutes</td><td><code>05</code> / <code>5</code></td></tr><tr><td><code>ss</code> / <code>s</code></td><td>Seconds</td><td><code>09</code> / <code>9</code></td></tr>' +
'<tr><td><code>A</code> / <code>a</code></td><td>AM or PM</td><td><code>PM</code> / <code>pm</code></td></tr>' +
'</tbody></table></div>';
}
function renderImageTransform() {
return '<h3 class="fs-6 mt-4 fw-normal">Image transform</h3>' +
'<p class="small">Use <code>image(width, height)</code> to render an image URL inside a proportional bounding box.</p>' +
'<ul class="small"><li>The preview shows the width and height boundary.</li><li>The player has no boundary.</li><li>The image keeps its original aspect ratio.</li><li>Both dimensions are required for a bounding box.</li></ul>';
}
root.placeholderInfo = {
escapeHtml: escapeHtml,
render: render,
renderTextTransforms: renderTextTransforms,
renderDateFormatTokens: renderDateFormatTokens,
renderImageTransform: renderImageTransform
};
}());
@@ -298,6 +298,27 @@
return resolved;
}
function isImagePlaceholderExpression(expression) {
var parsed = parsePlaceholderExpression(expression);
return parsed.transforms.some(function (transform) {
return transform && transform.name === 'image';
});
}
function getImagePlaceholderConfig(expression) {
var parsed = parsePlaceholderExpression(expression);
var imageTransform = parsed.transforms.find(function (transform) {
return transform && transform.name === 'image';
});
var args = imageTransform && Array.isArray(imageTransform.args) ? imageTransform.args : [];
var width = Number(args[0]);
var height = Number(args[1]);
return {
width: Number.isFinite(width) && width > 0 ? Math.round(width) : 0,
height: Number.isFinite(height) && height > 0 ? Math.round(height) : 0
};
}
function formatPlaceholderValue(value) {
if (value === undefined || value === null) {
return '';
@@ -349,6 +370,8 @@
resolvePath: resolvePath,
parsePlaceholderExpression: parsePlaceholderExpression,
resolvePlaceholderExpression: resolvePlaceholderExpression,
isImagePlaceholderExpression: isImagePlaceholderExpression,
getImagePlaceholderConfig: getImagePlaceholderConfig,
formatPlaceholderValue: formatPlaceholderValue,
collectPlaceholderFieldPaths: collectPlaceholderFieldPaths
};
@@ -44,10 +44,6 @@ export function createSlideFormEditorController(options) {
return String(value || '');
}
function normalizeEditorMarkup(value) {
return String(value === undefined || value === null ? '' : value).trim();
}
function isEmptyRichTextValue(value) {
var raw = String(value === undefined || value === null ? '' : value).trim();
if (!raw) {
@@ -575,7 +571,7 @@ export function createSlideFormEditorController(options) {
return Promise.resolve(null);
}
source.value = normalizeEditorMarkup(normalizeEditorData(source && source.value !== undefined && source.value !== null ? source.value : (hidden ? hidden.value : '')));
source.value = normalizeEditorData(source && source.value !== undefined && source.value !== null ? source.value : (hidden ? hidden.value : ''));
if (!source.id) {
source.id = 'slide-editor-region-' + regionId;
@@ -198,9 +198,9 @@ export function createSlideFormPreviewHelpers(options) {
return module.renderPreview(region, value, style, scale);
}
var fontFamily = style.font_family ? 'font-family:' + escapeHtml(style.font_family) + ';' : '';
var color = style.font_color ? 'color:' + escapeHtml(style.font_color) + ';' : '';
var fontSize = style.font_size ? 'font-size:' + Math.max(1, Math.round(Number(style.font_size))) + 'px;' : '';
var fontFamily = 'font-family:' + escapeHtml(style.font_family || 'Arial') + ';';
var color = 'color:' + escapeHtml(style.font_color || '#000000') + ';';
var fontSize = 'font-size:' + Math.max(1, Math.round(Number(style.font_size || 32))) + 'px;';
var width = Math.max(1, Math.round(Number(region && region.width ? region.width : 0) || 1));
var height = Math.max(1, Math.round(Number(region && region.height ? region.height : 0) || 1));
var wrapperStyle = 'width:' + width + 'px;height:' + height + 'px;overflow:hidden;' + fontFamily + fontSize + color;
@@ -123,7 +123,7 @@ export function createSlideFormRegionHelpers(options) {
return fields;
}
function buildLimitedPlaceholderChipMarkup(fieldList, emptyLabel) {
function buildLimitedPlaceholderChipMarkup(fieldList, emptyLabel, includeTransformHint) {
var fields = Array.isArray(fieldList) ? fieldList : [];
var visibleFields = fields.slice(0, 8);
var overflowFields = fields.slice(visibleFields.length);
@@ -137,7 +137,7 @@ export function createSlideFormRegionHelpers(options) {
? placeholderChips.renderChip(field)
: '<span class="chip">{{' + escapeHtml(field) + '}}</span>';
}).join('');
var transformHint = '<div class="text-body-secondary small mt-1">Placeholder values support transforms, for example <code>{{title.upper()}}</code>, <code>{{title.title()}}</code>, <code>{{title.lower()}}</code>, <code>{{publishedAt.format("MMM D, YYYY")}}</code>.</div>';
var transformHint = includeTransformHint === false ? '' : '<div class="text-body-secondary small mt-1">Placeholder values support transforms, for example <code>{{title.upper()}}</code>, <code>{{title.title()}}</code>, <code>{{title.lower()}}</code>, <code>{{publishedAt.format("MMM D, YYYY")}}</code>.</div>';
if (!overflowFields.length) {
return visibleMarkup + transformHint;
@@ -417,7 +417,7 @@ export function createSlideFormRegionHelpers(options) {
return;
}
container.innerHTML = buildLimitedPlaceholderChipMarkup(getApiFieldList(sourceId, itemsPathOverride), 'No JSON fields available.');
container.innerHTML = buildLimitedPlaceholderChipMarkup(getApiFieldList(sourceId, itemsPathOverride), 'No JSON fields available.', false);
}
function updateApiSampleDataPanel(regionId, sourceId, itemNumber, itemsPathOverride) {
@@ -561,7 +561,7 @@ export function createSlideFormRegionHelpers(options) {
? getRssFieldList(getCurrentRssConfig(region).feed_id).map(function (field) {
return '<span class="chip">{{' + escapeHtml(field) + '}}</span>';
}).join('')
: buildLimitedPlaceholderChipMarkup(getApiFieldList(getCurrentApiConfig(region).source_id, getCurrentApiConfig(region).items_path), 'No JSON fields available.'),
: buildLimitedPlaceholderChipMarkup(getApiFieldList(getCurrentApiConfig(region).source_id, getCurrentApiConfig(region).items_path), 'No JSON fields available.', false),
placeholderFields: region.region_type === 'api'
? getApiFieldList(getCurrentApiConfig(region).source_id, getCurrentApiConfig(region).items_path)
: region.region_type === 'rss'
@@ -602,7 +602,7 @@ export function createSlideFormRegionHelpers(options) {
}).join(''),
placeholderChips: region.region_type === 'rss'
? buildLimitedPlaceholderChipMarkup(getRssFieldList(rssConfig.feed_id), 'No RSS fields available.')
: buildLimitedPlaceholderChipMarkup(getApiFieldList(apiConfig.source_id, apiItemsPath), 'No JSON fields available.'),
: buildLimitedPlaceholderChipMarkup(getApiFieldList(apiConfig.source_id, apiItemsPath), 'No JSON fields available.', false),
placeholderFields: region.region_type === 'api'
? getApiFieldList(apiConfig.source_id, apiItemsPath)
: region.region_type === 'rss'
+2 -21
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) {
@@ -654,7 +635,7 @@ import { createSlideFormPreviewHelpers } from '/assets/js/slides/slide-form-prev
var content = typeModule && typeof typeModule.renderPreview === 'function'
? typeModule.renderPreview(region, previewContext, previewContext)
: renderPreviewTextRegion(region, previewContext.value, previewContext.style, scale);
if (typeModule && typeof typeModule.renderPreview === 'function') {
if (typeModule && typeof typeModule.renderPreview === 'function' && region.region_type !== 'api' && region.region_type !== 'rss') {
content = content || renderPreviewTextRegion(region, previewContext.value, previewContext.style, scale);
}
var selected = region.region_type === 'image'
+1 -1
View File
@@ -1,6 +1,6 @@
// Admin account route registration and profile helpers.
const { fetchAppSettings } = require('../../../data/app-settings');
const { fetchAppSettings } = require('#src/data/app-settings');
module.exports = function registerAccountRoutes(app, deps) {
const pool = deps.pool;
+55 -3
View File
@@ -4,6 +4,7 @@ const fs = require('fs');
const path = require('path');
const { loadFontLibrary } = require('#src/web/lib/media/font-library');
const { fetchAppSettings } = require('#src/data/app-settings');
const { buildAuditChanges } = require('#src/data/audit-log');
const { PERMISSION_DENIED_MESSAGE } = require('#src/rbac');
module.exports = function registerContentRoutes(app, deps) {
@@ -39,6 +40,26 @@ module.exports = function registerContentRoutes(app, deps) {
const IMAGE_UPLOAD_MAX_BYTES = 100 * 1024 * 1024;
const VIDEO_UPLOAD_MAX_BYTES = 1024 * 1024 * 1024;
function normalizeTemplateRegionsForAudit(regions) {
return (Array.isArray(regions) ? regions : []).map(function (region) {
const animation = typeof region.animation_json === 'string'
? common.parseJsonSafe(region.animation_json) || {}
: region.animation_json || {};
return {
region_key: region.region_key,
region_type: region.region_type,
label: region.label,
lock_ratio: region.lock_ratio,
animation_json: animation,
x: Number(region.x),
y: Number(region.y),
width: Number(region.width),
height: Number(region.height),
z_index: Number(region.z_index)
};
});
}
async function fetchSlideFormData() {
const data = await common.fetchTemplatesData(pool);
const rssData = await common.fetchRssFeedsData(pool);
@@ -497,6 +518,15 @@ module.exports = function registerContentRoutes(app, deps) {
}
const nextUploadRefs = collectUploadReferencesFromPayload(payload);
const actorId = getAuditUserId(req);
const changes = buildAuditChanges({
title: slide.title,
templateId: Number(slide.template_id),
content: slide.content
}, {
title: payload.title,
templateId: Number(payload.templateId),
content: common.parseJsonSafe(payload.contentJson) || {}
});
await pool.query(
'UPDATE c_slides SET title = ?, template_id = ?, content_json = ?, modified_by = ? WHERE id = ?',
[payload.title, payload.templateId, payload.contentJson, actorId, slide.id]
@@ -512,7 +542,7 @@ module.exports = function registerContentRoutes(app, deps) {
screenSlideCounts: screenSlideCounts
});
await broadcastDashboardState();
if (typeof recordRequestAuditEvent === 'function') await recordRequestAuditEvent(pool, req, { category: 'slides', eventType: 'slide.updated', actorUserId: actorId, targetType: 'slide', targetId: slide.id, targetLabel: payload.title });
if (typeof recordRequestAuditEvent === 'function') await recordRequestAuditEvent(pool, req, { category: 'slides', eventType: 'slide.updated', actorUserId: actorId, targetType: 'slide', targetId: slide.id, targetLabel: payload.title, details: { changes: changes } });
queueSlideThumbnailRefresh(slide.id, slide.thumbnail_path).catch(function (error) {
console.warn('Unable to queue slide thumbnail refresh:', error);
});
@@ -655,6 +685,19 @@ module.exports = function registerContentRoutes(app, deps) {
const nextUploadRefs = collectUploadReferencesFromPayload(payload);
const affectedScreens = await fetchScreensByTemplateId(pool, template.id);
const actorId = getAuditUserId(req);
const changes = buildAuditChanges({
name: template.name,
canvasSizeId: Number(template.canvas_size_id),
backgroundImagePath: template.background_image_path,
backgroundColor: template.background_color,
regions: normalizeTemplateRegionsForAudit(template.regions)
}, {
name: payload.name,
canvasSizeId: Number(payload.canvasSizeId),
backgroundImagePath: payload.backgroundImagePath,
backgroundColor: payload.backgroundColor,
regions: normalizeTemplateRegionsForAudit(payload.regions)
});
await pool.query(
'UPDATE c_templates SET name = ?, canvas_size_id = ?, background_image_path = ?, background_color = ?, modified_by = ? WHERE id = ?',
[payload.name, payload.canvasSizeId, payload.backgroundImagePath, payload.backgroundColor, actorId, template.id]
@@ -681,7 +724,7 @@ module.exports = function registerContentRoutes(app, deps) {
});
}
await notifyPlayerScreens(affectedScreens, 'refresh');
if (typeof recordRequestAuditEvent === 'function') await recordRequestAuditEvent(pool, req, { category: 'templates', eventType: 'template.updated', actorUserId: actorId, targetType: 'template', targetId: template.id, targetLabel: payload.name });
if (typeof recordRequestAuditEvent === 'function') await recordRequestAuditEvent(pool, req, { category: 'templates', eventType: 'template.updated', actorUserId: actorId, targetType: 'template', targetId: template.id, targetLabel: payload.name, details: { changes: changes } });
redirectAfterSave(req, res, '/templates/' + template.id + '/edit', {
closeUrl: '/templates',
newUrl: '/templates/new',
@@ -813,8 +856,17 @@ module.exports = function registerContentRoutes(app, deps) {
if (await canvasSizeExists(payload.width, payload.height, canvasSize.id)) {
return res.status(400).send('That canvas size already exists.');
}
const changes = buildAuditChanges({
name: canvasSize.name,
width: Number(canvasSize.width),
height: Number(canvasSize.height)
}, {
name: payload.name,
width: payload.width,
height: payload.height
});
await pool.query('UPDATE c_canvas_sizes SET name = ?, width = ?, height = ?, modified_by = ? WHERE id = ?', [payload.name, payload.width, payload.height, getAuditUserId(req), canvasSize.id]);
if (typeof recordRequestAuditEvent === 'function') await recordRequestAuditEvent(pool, req, { category: 'canvas-sizes', eventType: 'canvas-size.updated', actorUserId: getAuditUserId(req), targetType: 'canvas-size', targetId: canvasSize.id, targetLabel: payload.name, details: { width: payload.width, height: payload.height } });
if (typeof recordRequestAuditEvent === 'function') await recordRequestAuditEvent(pool, req, { category: 'canvas-sizes', eventType: 'canvas-size.updated', actorUserId: getAuditUserId(req), targetType: 'canvas-size', targetId: canvasSize.id, targetLabel: payload.name, details: { changes: changes } });
redirectAfterSave(req, res, '/canvas-sizes', {
closeUrl: '/canvas-sizes',
newUrl: '/canvas-sizes/new',
+12 -1
View File
@@ -1,5 +1,7 @@
// Admin manage routes for screens and commands.
const { buildAuditChanges } = require('#src/data/audit-log');
module.exports = function registerManageRoutes(app, deps) {
const pool = deps.pool;
const common = deps.common;
@@ -248,6 +250,15 @@ module.exports = function registerManageRoutes(app, deps) {
const previousPlaylistId = screen.playlist_id;
const slug = String(screen.slug || '').trim();
const previousSlug = String(screen.slug || '').trim();
const changes = buildAuditChanges({
name: screen.name,
slug: previousSlug,
playlistId: previousPlaylistId === null ? null : Number(previousPlaylistId)
}, {
name: name,
slug: slug,
playlistId: playlistId
});
await pool.query('UPDATE d_screens SET name = ?, slug = ?, playlist_id = ?, modified_by = ? WHERE id = ?', [name, slug, playlistId, getAuditUserId(req), screen.id]);
if (previousPlaylistId !== playlistId && previousSlug) {
await notifyPlayerScreens([previousSlug], 'refresh');
@@ -264,7 +275,7 @@ module.exports = function registerManageRoutes(app, deps) {
await forwardPlayerCommand(previousSlug, redirectPayload);
}
}
if (typeof recordRequestAuditEvent === 'function') await recordRequestAuditEvent(pool, req, { category: 'screens', eventType: 'screen.updated', actorUserId: getAuditUserId(req), targetType: 'screen', targetId: screen.id, targetLabel: name, details: { slug: slug, playlistId: playlistId } });
if (typeof recordRequestAuditEvent === 'function') await recordRequestAuditEvent(pool, req, { category: 'screens', eventType: 'screen.updated', actorUserId: getAuditUserId(req), targetType: 'screen', targetId: screen.id, targetLabel: name, details: { changes: changes } });
redirectAfterSave(req, res, '/screens?edit=' + screen.id, {
closeUrl: '/screens',
newUrl: '/screens/new',
+16 -2
View File
@@ -1,6 +1,7 @@
// Playlist admin routes and playlist-slide management.
const { fetchAppSettings } = require('../../../data/app-settings');
const { fetchAppSettings } = require('#src/data/app-settings');
const { buildAuditChanges } = require('#src/data/audit-log');
module.exports = function registerPlaylistRoutes(app, deps) {
const pool = deps.pool;
@@ -356,7 +357,20 @@ module.exports = function registerPlaylistRoutes(app, deps) {
await connection.commit();
await notifyPlayerScreens(saveResult.affectedScreens, 'refresh');
await broadcastDashboardState();
if (typeof recordRequestAuditEvent === 'function') await recordRequestAuditEvent(pool, req, { category: 'playlists', eventType: 'playlist.updated', actorUserId: actorId, targetType: 'playlist', targetId: playlist.id, targetLabel: name });
if (typeof recordRequestAuditEvent === 'function') {
const changes = buildAuditChanges({
name: playlist.name,
fadeBetweenSlides: Boolean(playlist.fade_between_slides),
skipUnavailableRtmp: Boolean(playlist.skip_unavailable_rtmp),
canvasId: playlist.canvas_id === null ? null : Number(playlist.canvas_id)
}, {
name: name,
fadeBetweenSlides: Boolean(fadeBetweenSlides),
skipUnavailableRtmp: Boolean(skipUnavailableRtmp),
canvasId: saveResult.nextCanvasId === null ? null : Number(saveResult.nextCanvasId)
});
await recordRequestAuditEvent(pool, req, { category: 'playlists', eventType: 'playlist.updated', actorUserId: actorId, targetType: 'playlist', targetId: playlist.id, targetLabel: name, details: { changes: changes } });
}
redirectAfterSave(req, res, '/playlists/' + playlist.id + '/edit', {
closeUrl: '/playlists',
newUrl: '/playlists/new',
+26 -7
View File
@@ -1,4 +1,6 @@
// Admin RBAC route registration and permission management.
const { DEFAULT_ROLE } = require('#src/rbac');
const { buildAuditChanges } = require('#src/data/audit-log');
module.exports = function registerRbacRoutes(app, deps) {
const pool = deps.pool;
@@ -359,7 +361,7 @@
excludeUserId: currentUserId
});
const users = mapUsersForView(data.users, viewModel.selectedUserIds);
viewModel.role.inUse = String(viewModel.role.role_key || '') === 'administrators' || Number(viewModel.role.user_count) > 0;
viewModel.role.inUse = String(viewModel.role.role_key || '') === DEFAULT_ROLE.key || String(viewModel.role.role_key || '') === 'administrators' || Number(viewModel.role.user_count) > 0;
res.send(pages.renderRbacEditPage(viewModel.role, req.query.message ? String(req.query.message) : '', req.currentUser, viewModel.permissionGroups, users, buildPagination(data.totalItems, data.currentPage, 'page', { search: search, sort: sort, direction: direction }, LIST_PAGE_SIZE, 'users', 'User pages')));
} catch (error) {
@@ -412,10 +414,14 @@
return res.redirect('/settings/roles/' + roleId + '/edit?message=' + encodeURIComponent('One or more selected permissions are invalid.'));
}
const existingRolePermissionKeys = shouldSyncPermissions
? await rbacData.fetchRolePermissionKeys(pool, roleId)
: [];
let existingRoleUserIds = [];
let availableUsers = [];
if (shouldSyncUsers) {
availableUsers = await rbacData.fetchUsersWithRoles(pool);
const existingRoleUserIds = await rbacData.fetchRoleUserIds(pool, roleId);
existingRoleUserIds = await rbacData.fetchRoleUserIds(pool, roleId);
const visibleUserIdSet = new Set(visibleUserIds);
normalizedUserIds = existingRoleUserIds.filter(function (userId) {
return !visibleUserIdSet.has(userId);
@@ -451,6 +457,17 @@
connection.release();
}
if (typeof recordRequestAuditEvent === 'function') {
const changes = buildAuditChanges({
name: role.name,
description: role.description,
permissionKeys: existingRolePermissionKeys,
userIds: existingRoleUserIds
}, {
name: name,
description: description || null,
permissionKeys: normalizedPermissionKeys,
userIds: normalizedUserIds
});
await recordRequestAuditEvent(pool, req, {
category: 'roles',
eventType: 'role.updated',
@@ -458,7 +475,7 @@
targetType: 'role',
targetId: roleId,
targetLabel: name,
details: { permissionsChanged: shouldSyncPermissions, usersChanged: shouldSyncUsers }
details: { changes: changes }
});
}
res.redirect('/settings/roles/' + roleId + '/edit?message=' + encodeURIComponent('Role updated.'));
@@ -493,6 +510,7 @@
})) {
return res.redirect('/settings/roles/' + roleId + '/edit?message=' + encodeURIComponent('One or more selected permissions are invalid.'));
}
const existingPermissionKeys = await rbacData.fetchRolePermissionKeys(pool, roleId);
const connection = await pool.getConnection();
try {
@@ -513,7 +531,7 @@
targetType: 'role',
targetId: roleId,
targetLabel: role.name,
details: { permissionKeys: normalizedPermissionKeys }
details: { changes: buildAuditChanges({ permissionKeys: existingPermissionKeys }, { permissionKeys: normalizedPermissionKeys }) }
});
}
res.redirect('/settings/roles/' + roleId + '/edit?message=' + encodeURIComponent('Permissions updated.'));
@@ -540,6 +558,7 @@
? [].concat(req.body.user_ids)
: [];
const normalizedUserIds = normalizeSelectedIds(selectedUserIds);
const existingUserIds = await rbacData.fetchRoleUserIds(pool, roleId);
const availableUsers = await rbacData.fetchUsersWithRoles(pool);
const validUserIds = new Set(availableUsers.map(function (user) {
return Number(user.id);
@@ -560,7 +579,7 @@
targetType: 'role',
targetId: roleId,
targetLabel: role.name,
details: { userIds: normalizedUserIds }
details: { changes: buildAuditChanges({ userIds: existingUserIds }, { userIds: normalizedUserIds }) }
});
}
res.redirect('/settings/roles/' + roleId + '/edit?message=' + encodeURIComponent('Users updated.'));
@@ -580,8 +599,8 @@
if (!role) {
return res.status(404).send('Role not found.');
}
if (String(role.role_key || '') === 'administrators') {
return res.redirect('/settings/roles?message=' + encodeURIComponent('The built-in Administrators role cannot be deleted.'));
if (String(role.role_key || '') === DEFAULT_ROLE.key || String(role.role_key || '') === 'administrators') {
return res.redirect('/settings/roles?message=' + encodeURIComponent('The built-in Super Admin role cannot be deleted.'));
}
if (Number(role.user_count) > 0) {
return res.redirect('/settings/roles?message=' + encodeURIComponent('Remove all users from this role before deleting it.'));
+19 -3
View File
@@ -1,6 +1,7 @@
// Admin user route registration and user-role management.
const { fetchAppSettings } = require('../../../data/app-settings');
const { fetchAppSettings } = require('#src/data/app-settings');
const { buildAuditChanges } = require('#src/data/audit-log');
module.exports = function registerUsersRoutes(app, deps) {
const pool = deps.pool;
@@ -319,6 +320,8 @@
return res.redirect('/settings/users/' + userId + '/edit?message=' + encodeURIComponent(roleCheck.message));
}
const existingUser = await rbacData.fetchUserWithRoles(pool, userId);
const changes = buildAuditChanges({ roleIds: existingUser ? existingUser.roleIds : [] }, { roleIds: roleCheck.roleIds });
await rbacData.syncUserRoles(pool, userId, roleCheck.roleIds);
if (typeof recordRequestAuditEvent === 'function') {
await recordRequestAuditEvent(pool, req, {
@@ -328,7 +331,7 @@
targetType: 'user',
targetId: userId,
targetLabel: String(userId),
details: { roleIds: roleCheck.roleIds }
details: { changes: changes }
});
}
res.redirect('/settings/users/' + userId + '/edit?message=' + encodeURIComponent('Roles updated.'));
@@ -404,6 +407,19 @@
return renderValidationError('That username already exists.');
}
const changes = buildAuditChanges({
name: user.name,
username: user.username,
roleIds: user.roleIds,
accountLocked: Boolean(user.account_locked),
passwordReset: false
}, {
name: name,
username: username,
roleIds: roleCheck.roleIds,
accountLocked: accountLocked,
passwordReset: shouldUpdatePassword
});
await connection.beginTransaction();
const [result] = await connection.query('UPDATE a_users SET name = ?, username = ?, account_locked = ?, modified_by = ? WHERE id = ?', [name, username, accountLocked ? 1 : 0, getAuditUserId(req), userId]);
if (!result.affectedRows) {
@@ -432,7 +448,7 @@
targetType: 'user',
targetId: userId,
targetLabel: username,
details: { roleIds: roleCheck.roleIds, passwordReset: shouldUpdatePassword, accountLocked: accountLocked }
details: { changes: changes }
});
if (Boolean(user.account_locked) !== accountLocked) {
await recordRequestAuditEvent(pool, req, {
+1 -1
View File
@@ -1,7 +1,7 @@
// Authentication route registration for the web app.
const { normalizeReturnToPath, getRequestOrigin } = require('../../lib/auth/session');
const { fetchAppSettings } = require('../../../data/app-settings');
const { fetchAppSettings } = require('#src/data/app-settings');
module.exports = function registerAuthRoutes(app, deps) {
const pool = deps.pool;
@@ -5,7 +5,7 @@ const renderApiSourcesPage = require('./list');
const renderApiSourceAddPage = require('./add');
const renderApiSourceEditPage = require('./edit');
const { buildDuplicateApiSourceName, buildDuplicateApiSource } = require('./duplicate');
const { fetchAppSettings } = require('../../../../data/app-settings');
const { fetchAppSettings } = require('#src/data/app-settings');
function toIsoTimestamp(value) {
if (!value) {
@@ -5,7 +5,7 @@ const renderRssFeedsPage = require('./list');
const renderRssFeedAddPage = require('./add');
const renderRssFeedEditPage = require('./edit');
const { buildDuplicateRssFeedName, buildDuplicateRssFeed } = require('./duplicate');
const { fetchAppSettings } = require('../../../../data/app-settings');
const { fetchAppSettings } = require('#src/data/app-settings');
async function getDataSourceUsageMaps(pool, common) {
const [slides] = await pool.query('SELECT content_json FROM c_slides WHERE content_json IS NOT NULL');
@@ -1,6 +1,6 @@
// Shared timetable group form view-model builder.
const { normalizeTimeZone } = require('../../../../data/timetables');
const { normalizeTimeZone } = require('#src/data/timetables');
const COMMON_TIME_ZONES = [
'UTC',
@@ -1,7 +1,7 @@
// Timetable group list page renderer.
const { renderView } = require('../../../view');
const { normalizeTimeZone } = require('../../../../data/timetables');
const { normalizeTimeZone } = require('#src/data/timetables');
function formatDateInTimeZone(value, timeZone) {
if (!value) {
+106 -3
View File
@@ -6,6 +6,10 @@ const { createSearchMatcher, getSearchQuery, getSortDirectionQuery, getSortQuery
const PAGE_SIZE = 25;
const SORTED_AUDIT_CATEGORY_KEYS = AUDIT_CATEGORY_KEYS.slice().sort(function (left, right) {
return String(left).localeCompare(String(right));
});
function requireAuditLogAccess(setAuthMessageCookie) {
return function (req, res, next) {
if (!req.currentUser) {
@@ -44,11 +48,109 @@ function csvCell(value) {
return '"' + text.replace(/"/g, '""').replace(/\r?\n/g, ' ') + '"';
}
function isRecord(value) {
return value !== null && typeof value === 'object' && !Array.isArray(value);
}
function formatAuditValue(value) {
if (typeof value === 'string') {
return value;
}
if (value === undefined) {
return 'undefined';
}
if (value === null || typeof value !== 'object') {
return String(value);
}
return JSON.stringify(value);
}
function isPrimitiveArray(value) {
return Array.isArray(value) && value.every(function (item) {
return item === null || typeof item !== 'object';
});
}
function isMissingAuditValue(value) {
return value === null || value === '';
}
function collectAuditChangeRows(previousValue, nextValue, path, rows) {
if (isMissingAuditValue(previousValue) && isMissingAuditValue(nextValue)) {
return;
}
if (isMissingAuditValue(previousValue)) {
rows.push({ path: path + ' added', direction: 'added', from: '', to: formatAuditValue(nextValue) });
return;
}
if (isMissingAuditValue(nextValue)) {
rows.push({ path: path + ' removed', direction: 'removed', from: formatAuditValue(previousValue), to: '' });
return;
}
if (isPrimitiveArray(previousValue) && isPrimitiveArray(nextValue)) {
const previousItems = new Set(previousValue.map(function (item) { return JSON.stringify(item); }));
const nextItems = new Set(nextValue.map(function (item) { return JSON.stringify(item); }));
const removedItems = previousValue.filter(function (item) { return !nextItems.has(JSON.stringify(item)); });
const addedItems = nextValue.filter(function (item) { return !previousItems.has(JSON.stringify(item)); });
if (removedItems.length) {
rows.push({ path: path + ' removed', direction: 'removed', from: formatAuditValue(removedItems), to: '' });
}
if (addedItems.length) {
rows.push({ path: path + ' added', direction: 'added', from: '', to: formatAuditValue(addedItems) });
}
return;
}
if (JSON.stringify(previousValue) === JSON.stringify(nextValue)) {
return;
}
if (isRecord(previousValue) && isRecord(nextValue)) {
const keys = new Set(Object.keys(previousValue).concat(Object.keys(nextValue)));
keys.forEach(function (key) {
collectAuditChangeRows(previousValue[key], nextValue[key], path ? path + '.' + key : key, rows);
});
return;
}
if (Array.isArray(previousValue) && Array.isArray(nextValue) && previousValue.length === nextValue.length && previousValue.some(isRecord)) {
for (let index = 0; index < previousValue.length; index += 1) {
collectAuditChangeRows(previousValue[index], nextValue[index], path + '[' + index + ']', rows);
}
return;
}
rows.push({
path: path,
direction: 'changed',
from: formatAuditValue(previousValue),
to: formatAuditValue(nextValue)
});
}
function buildAuditDetailView(details) {
if (!isRecord(details) || !isRecord(details.changes)) {
return { hasChanges: false, changeRows: [] };
}
const rows = [];
Object.keys(details.changes).forEach(function (key) {
const change = details.changes[key];
if (isRecord(change) && Object.prototype.hasOwnProperty.call(change, 'from') && Object.prototype.hasOwnProperty.call(change, 'to')) {
collectAuditChangeRows(change.from, change.to, key, rows);
}
});
return { hasChanges: rows.length > 0, changeRows: rows };
}
function mapAuditRow(row, formatDashboardDate) {
let details = '';
let parsedDetails = null;
if (row.details_json) {
try {
details = JSON.stringify(typeof row.details_json === 'string' ? JSON.parse(row.details_json) : row.details_json);
parsedDetails = typeof row.details_json === 'string' ? JSON.parse(row.details_json) : row.details_json;
details = JSON.stringify(parsedDetails);
} catch (_error) {
details = String(row.details_json);
}
@@ -60,7 +162,8 @@ function mapAuditRow(row, formatDashboardDate) {
actorLabel: row.actor_name || row.actor_username || 'System',
eventLabel: String(row.event_type || '').replace(/[._-]+/g, ' '),
targetLabelDisplay: row.target_label || (row.target_type && row.target_id ? row.target_type + ' #' + row.target_id : ''),
detailsDisplay: details
detailsDisplay: details,
auditDetailView: buildAuditDetailView(parsedDetails)
});
}
@@ -136,7 +239,7 @@ module.exports = function registerAuditLogRoutes(app, deps) {
active: 'audit-log',
currentUser: req.currentUser,
events: events,
categories: AUDIT_CATEGORY_KEYS,
categories: SORTED_AUDIT_CATEGORY_KEYS,
eventTypes: eventTypes,
selectedCategory: category,
selectedEventType: eventType,
+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);
}
@@ -124,6 +124,9 @@ function buildRbacAddViewModel(message, currentUser, formValues, permissionGroup
}
function buildRbacEditViewModel(role, message, currentUser, permissionGroups, users, pagination) {
const roleKey = String(role && role.role_key || '').trim();
const isProtectedRole = roleKey === 'super-admin' || roleKey === 'administrators';
const deleteDisabled = isProtectedRole || Boolean(role && role.inUse);
return {
title: 'Edit role',
active: 'rbac',
@@ -135,13 +138,16 @@ function buildRbacEditViewModel(role, message, currentUser, permissionGroups, us
footerCancelUrl: '/settings/roles',
cancelConfirmMessage: "You've made changes. Are you sure you want to leave this page?",
footerDeleteUrl: '/settings/roles/' + Number(role && role.id) + '/delete',
footerDeleteDisabled: Boolean(role && role.inUse),
footerDeleteTitle: role && role.inUse ? 'Delete is disabled while this role is assigned to users.' : '',
footerDeleteDisabled: deleteDisabled,
footerDeleteConfirmMessage: 'Delete ' + String(role && role.name || 'this role') + '?',
footerDeleteTitle: isProtectedRole
? 'The built-in Super Admin role cannot be deleted.'
: (role && role.inUse ? 'Delete is disabled while this role is assigned to users.' : ''),
footerShowDelete: true,
message: message,
currentUser: currentUser || null,
role: role,
inUse: Boolean(role && role.inUse),
role: Object.assign({}, role, { isProtected: isProtectedRole }),
inUse: deleteDisabled,
permissionGroups: permissionGroups || [],
permissionSections: buildPermissionSections(permissionGroups),
users: users || [],
+17 -6
View File
@@ -159,12 +159,23 @@ module.exports = function registerAnnouncementRoutes(app, deps) {
}
if (selectedIds.length) {
await pool.query(
'INSERT INTO d_announcement_screens (announcement_id, screen_id, created_by, modified_by) VALUES ? ON DUPLICATE KEY UPDATE modified_at = CURRENT_TIMESTAMP, modified_by = VALUES(modified_by)',
[selectedIds.map(function (screenId) {
return [announcementId, screenId, actorId || null, actorId || null];
})]
);
for (const screenId of selectedIds) {
await pool.query(
`UPDATE d_announcement_screens
SET modified_at = CURRENT_TIMESTAMP, modified_by = ?
WHERE announcement_id = ? AND screen_id = ?`,
[actorId || null, announcementId, screenId]
);
await pool.query(
`INSERT INTO d_announcement_screens (announcement_id, screen_id, created_by, modified_by)
SELECT ?, ?, ?, ?
WHERE NOT EXISTS (
SELECT 1 FROM d_announcement_screens
WHERE announcement_id = ? AND screen_id = ?
)`,
[announcementId, screenId, actorId || null, actorId || null, announcementId, screenId]
);
}
}
}
+1 -1
View File
@@ -8,7 +8,7 @@ module.exports = function registerPlaylistsRoutes(app, deps) {
const { buildPagination } = require('../../../lib/pagination');
const requirePermission = deps.requirePermission;
const { buildDuplicatePlaylistName, buildDuplicatePlaylist } = require('./duplicate');
const { fetchAppSettings } = require('../../../../data/app-settings');
const { fetchAppSettings } = require('#src/data/app-settings');
const LIST_PAGE_SIZE = 25;
+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');
+16 -1
View File
@@ -44,7 +44,22 @@
<td>{{actorLabel}}</td>
<td>{{targetLabelDisplay}}</td>
<td><div>{{ip_address}}</div><div class="text-muted small text-break">{{user_agent}}</div></td>
<td class="text-break"><small>{{detailsDisplay}}</small></td>
<td class="text-break">
{{#if auditDetailView.hasChanges}}
<div class="audit-change-list">
{{#each auditDetailView.changeRows}}
<div class="audit-change-row">
<code class="audit-change-path">{{path}}</code>
<span class="audit-change-from"><span class="visually-hidden">From: </span>{{from}}</span>
{{#if (eq direction "changed")}}<span class="audit-change-arrow" aria-hidden="true">&rarr;</span>{{else}}<span></span>{{/if}}
<span class="audit-change-to"><span class="visually-hidden">To: </span>{{to}}</span>
</div>
{{/each}}
</div>
{{else}}
<small>{{detailsDisplay}}</small>
{{/if}}
</td>
</tr>
{{/each}}
{{else}}
+1 -1
View File
@@ -27,7 +27,7 @@
</div>
<div class="card-footer d-flex justify-content-end flex-wrap">
<div class="btn-group" role="group" aria-label="Role actions">
{{{saveActionButtons formId=formId saveUrl=footerSaveUrl cancelUrl=footerCancelUrl cancelConfirmMessage=cancelConfirmMessage deleteUrl=footerDeleteUrl deleteDisabled=footerDeleteDisabled deleteTitle=footerDeleteTitle showDelete=footerShowDelete}}}
{{{saveActionButtons formId=formId saveUrl=footerSaveUrl cancelUrl=footerCancelUrl cancelConfirmMessage=cancelConfirmMessage deleteUrl=footerDeleteUrl deleteConfirmMessage=footerDeleteConfirmMessage deleteDisabled=footerDeleteDisabled deleteTitle=footerDeleteTitle showDelete=footerShowDelete}}}
</div>
</div>
</div>
@@ -1,6 +1,8 @@
const test = require('node:test');
const assert = require('node:assert/strict');
require('../src/common');
const registerAccountRoutes = require('../src/web/routes/admin/account');
const { validatePasswordStrength } = require('../src/auth');
@@ -1,6 +1,8 @@
const test = require('node:test');
const assert = require('node:assert/strict');
require('../src/common');
const registerUsersRoutes = require('../src/web/routes/admin/users');
test('user create route allows users without roles', async () => {
@@ -1,6 +1,8 @@
const test = require('node:test');
const assert = require('node:assert/strict');
require('../src/common');
const registerUsersRoutes = require('../src/web/routes/admin/users');
test('user edit save and new goes to the blank create page', async () => {
+2
View File
@@ -1,6 +1,8 @@
const test = require('node:test');
const assert = require('node:assert/strict');
require('../src/common');
const registerUsersRoutes = require('../src/web/routes/admin/users');
const { validatePasswordStrength } = require('../src/auth');
+1 -1
View File
@@ -76,5 +76,5 @@ test('save app settings writes only supplied keys in one transaction', async ()
assert.equal(queries[0], 'begin');
assert.equal(queries[queries.length - 2], 'commit');
assert.equal(queries[queries.length - 1], 'release');
assert.equal(queries.filter(function (entry) { return entry && entry.sql; }).length, 1);
assert.equal(queries.filter(function (entry) { return entry && entry.sql; }).length, 2);
});
+33 -1
View File
@@ -24,7 +24,26 @@ test('audit log uses its own read permission and shared pagination partial', asy
category: 'authentication',
event_type: 'login.success',
target_label: 'newer',
details_json: null
details_json: JSON.stringify({
changes: {
content: {
from: { title: 'Same', body: { color: 'red', keep: 'same' } },
to: { title: 'Same', body: { color: 'blue', keep: 'same' } }
},
permissions: {
from: ['dashboard.read', 'screens.read'],
to: ['screens.read', 'playlists.read']
},
backgroundColor: {
from: null,
to: '#111111'
},
logoPath: {
from: '',
to: '/media/logo.png'
}
}
})
},
{
id: 2,
@@ -60,10 +79,23 @@ test('audit log uses its own read permission and shared pagination partial', asy
throw error;
});
assert.match(response.body, /Audit log/);
assert.ok(response.body.indexOf('>All categories</option>') < response.body.indexOf('>announcements</option>'));
assert.ok(response.body.indexOf('>announcements</option>') < response.body.indexOf('>api-sources</option>'));
assert.ok(response.body.indexOf('>api-sources</option>') < response.body.indexOf('>canvas-sizes</option>'));
assert.match(response.body, /table-pagination/);
assert.match(response.body, /data-local-datetime/);
assert.match(response.body, /audit-event-type-options/);
assert.match(response.body, /login\.success/);
assert.match(response.body, /content\.body\.color/);
assert.match(response.body, />red</);
assert.match(response.body, />blue</);
assert.doesNotMatch(response.body, /content\.body\.keep/);
assert.match(response.body, /permissions removed/);
assert.match(response.body, /permissions added/);
assert.match(response.body, /dashboard\.read/);
assert.match(response.body, /playlists\.read/);
assert.match(response.body, /backgroundColor added/);
assert.match(response.body, /logoPath added/);
assert.ok(response.body.indexOf('login.success') < response.body.indexOf('login.failed'));
assert.match(response.body, />Export</);
assert.ok(handlers['/settings/audit-log/export']);
+16 -1
View File
@@ -3,7 +3,7 @@ const assert = require('node:assert/strict');
require('../src/common');
const { recordRequestAuditEvent } = require('../src/data/audit-log');
const { buildAuditChanges, recordRequestAuditEvent } = require('../src/data/audit-log');
function createPool(settings) {
const inserts = [];
@@ -44,4 +44,19 @@ test('audit writer omits request metadata when disabled', async () => {
assert.equal(pool.inserts.length, 1);
assert.equal(pool.inserts[0][6], null);
assert.equal(pool.inserts[0][7], null);
});
test('audit changes include only fields with different from and to values', () => {
assert.deepEqual(buildAuditChanges({
name: 'Old name',
playlistId: 4,
roleIds: [1, 2]
}, {
name: 'New name',
playlistId: 4,
roleIds: [2, 3]
}), {
name: { from: 'Old name', to: 'New name' },
roleIds: { from: [1, 2], to: [2, 3] }
});
});
+2 -2
View File
@@ -17,7 +17,7 @@ test('player registry upserts include the identifier field', async () => {
if (String(sql || '').includes('information_schema.COLUMNS')) {
return [[{ column_count: 1 }]];
}
if (String(sql || '').includes('INSERT INTO d_players')) {
if (String(sql || '').includes('UPDATE d_players')) {
return [{ affectedRows: 1 }];
}
if (String(sql || '').includes('SELECT id, identifier, public_base_url, internal_base_url, last_seen_at')) {
@@ -63,5 +63,5 @@ test('player registry upserts include the identifier field', async () => {
}));
assert.deepEqual((calls.find(function (call) {
return /INSERT INTO d_players/.test(String(call.sql || ''));
}) || {}).params, ['Shop2', 'http://player.local', 'http://player:8081']);
}) || {}).params, ['Shop2', 'http://player.local', 'http://player:8081', 'Shop2']);
});
+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
View File
@@ -1,6 +1,8 @@
const test = require('node:test');
const assert = require('node:assert/strict');
require('../src/common');
const registerPlaylistRoutes = require('../src/web/routes/admin/playlists');
function createAppAndHandlers() {
+17 -1
View File
@@ -5,7 +5,7 @@ const fs = require('node:fs');
const rbacPermissionsScript = fs.readFileSync(require.resolve('../src/web/public/js/rbac-permissions.js'), 'utf8');
const rbacFormTemplate = fs.readFileSync(require.resolve('../src/web/views/settings/rbac/form.hbs'), 'utf8');
const rbacRoutesSource = fs.readFileSync(require.resolve('../src/web/routes/admin/rbac.js'), 'utf8');
const { buildPermissionSections } = require('../src/web/routes/settings/rbac/form-view-model');
const { buildPermissionSections, buildRbacEditViewModel } = require('../src/web/routes/settings/rbac/form-view-model');
const rbacFormViewModel = fs.readFileSync(require.resolve('../src/web/routes/settings/rbac/form-view-model.js'), 'utf8');
const rbacSource = fs.readFileSync(require.resolve('../src/rbac.js'), 'utf8');
@@ -111,4 +111,20 @@ test('rbac duplicate route exists', () => {
assert.ok(rbacRoutes.includes("requirePermission('rbac.create')"));
assert.ok(duplicateHelpers.includes('buildDuplicateRoleName'));
assert.ok(duplicateHelpers.includes('buildDuplicateRole'));
});
test('built-in Super Admin role cannot be deleted but can be renamed', () => {
const model = buildRbacEditViewModel({
id: 1,
role_key: 'super-admin',
name: 'Super Admin',
inUse: false
}, '', null, [], [], null);
assert.equal(model.footerDeleteDisabled, true);
assert.equal(model.role.isProtected, true);
assert.equal(model.footerDeleteTitle, 'The built-in Super Admin role cannot be deleted.');
assert.equal(model.footerDeleteConfirmMessage, 'Delete Super Admin?');
assert.ok(!rbacFormTemplate.includes('The built-in role name cannot be changed.'));
assert.ok(!rbacFormTemplate.includes('The built-in role description cannot be changed.'));
});
+41
View File
@@ -76,4 +76,45 @@ test('pending migrations are reported when an older schema still needs scripts',
assert.ok(pendingMigrations.length > 0);
assert.equal(pendingMigrations.filter(function (migration) { return migration.version.indexOf('2.8.') === 0; }).length, 1);
assert.equal(pendingMigrations.find(function (migration) { return migration.version.indexOf('2.8.') === 0; }).version, '2.8.0');
});
test('a partial timetable schema does not fast-forward migration detection', async () => {
const pool = createPool([
{
match(sql) {
return sql.includes('FROM information_schema.COLUMNS') && sql.includes('TABLE_NAME = ?') && sql.includes('COLUMN_NAME = ?');
},
result: [[{ column_count: 0 }]]
},
{
match(_sql, params) {
return Array.isArray(params) && params[0] === 'i_timetable_groups';
},
result: [[{ table_count: 1 }]]
},
{
match(_sql, params) {
return Array.isArray(params) && params[0] === 'i_timetable_entries';
},
result: [[{ table_count: 0 }]]
},
{
match(_sql, params) {
return Array.isArray(params) && params[0] === 'i_schedule_groups';
},
result: [[{ table_count: 0 }]]
},
{
match(_sql, params) {
return Array.isArray(params) && params[0] === 'i_schedule_entries';
},
result: [[{ table_count: 0 }]]
}
]);
const pendingMigrations = await getPendingMigrations(pool, { currentVersion: '0.0.0' });
assert.ok(pendingMigrations.some(function (migration) { return migration.version === '2.6.16'; }));
assert.ok(pendingMigrations.some(function (migration) { return migration.version === '2.6.17'; }));
assert.ok(pendingMigrations.some(function (migration) { return migration.version === '2.6.18'; }));
});
+7
View File
@@ -23,6 +23,11 @@ test('slide editor enables server-backed image uploads', () => {
assert.ok(slideFormEditorSource.includes('remove_script_host: false'));
});
test('slide editor keeps normal TinyMCE image insertion unchanged', () => {
assert.doesNotMatch(slideFormEditorSource, /bindPlaceholderImageSupport|placeholderFallbackSource/);
assert.ok(slideFormEditorSource.includes('images_upload_handler: uploadEditorImage'));
});
test('slide editor inserts tables with zero padding and spacing by default', () => {
assert.ok(slideFormEditorSource.includes("table_default_attributes: {"));
assert.ok(slideFormEditorSource.includes("cellpadding: '0'"));
@@ -64,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', () => {
+2
View File
@@ -3,6 +3,8 @@ const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
require('../src/common');
const {
buildDuplicateTimetableGroupName,
buildDuplicateTimetableGroup,
+2
View File
@@ -1,6 +1,8 @@
const test = require('node:test');
const assert = require('node:assert/strict');
require('../src/common');
const registerUsersRoutes = require('../src/web/routes/admin/users');
test('user duplicate route pre-fills the add form', async () => {
+2
View File
@@ -1,6 +1,8 @@
const test = require('node:test');
const assert = require('node:assert/strict');
require('../src/common');
const registerManageRoutes = require('../src/web/routes/admin/manage');
test('screen update keeps the existing slug on edit', async () => {