Release 2.5.2
This commit is contained in:
@@ -3,6 +3,7 @@ media/
|
|||||||
!src/web/lib/media/
|
!src/web/lib/media/
|
||||||
!src/web/lib/media/**
|
!src/web/lib/media/**
|
||||||
docker-compose.dev.yml
|
docker-compose.dev.yml
|
||||||
|
/dev-demo-seed.js
|
||||||
.vscode/
|
.vscode/
|
||||||
.env
|
.env
|
||||||
npm-debug.log*
|
npm-debug.log*
|
||||||
|
|||||||
@@ -2,6 +2,18 @@
|
|||||||
|
|
||||||
All notable changes to this project will be documented in this file.
|
All notable changes to this project will be documented in this file.
|
||||||
|
|
||||||
|
## 2.5.2 - 2026-08-03
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Player page auth now writes a cookie for websocket reuse, and player and announcement sockets accept that cookie so they no longer depend on query-string tokens.
|
||||||
|
- RSS and API region placeholder panels now use shared field-aware chip rendering, with updated spacing and defaults across the slide editor.
|
||||||
|
- RTMP regions now default audio to enabled in the editor, and the slide rich-text editor no longer allows anchor tags.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Player websocket auth now falls back to the `pulse_page_auth` cookie when the auth query parameter is unavailable.
|
||||||
|
|
||||||
## 2.5.1 - 2026-08-03
|
## 2.5.1 - 2026-08-03
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "pulse-signage",
|
"name": "pulse-signage",
|
||||||
"version": "2.5.1",
|
"version": "2.5.2",
|
||||||
"private": false,
|
"private": false,
|
||||||
"description": "Pulse Signage application with MySQL and media storage",
|
"description": "Pulse Signage application with MySQL and media storage",
|
||||||
"repository": {
|
"repository": {
|
||||||
|
|||||||
Vendored
+1
-1
@@ -5,7 +5,7 @@ async function bootstrapDatabase(pool) {
|
|||||||
const [canvasSizeCountRows] = await pool.query('SELECT COUNT(*) AS canvas_size_count FROM c_canvas_sizes');
|
const [canvasSizeCountRows] = await pool.query('SELECT COUNT(*) AS canvas_size_count FROM c_canvas_sizes');
|
||||||
if (!canvasSizeCountRows.length || Number(canvasSizeCountRows[0].canvas_size_count) === 0) {
|
if (!canvasSizeCountRows.length || Number(canvasSizeCountRows[0].canvas_size_count) === 0) {
|
||||||
await pool.query(`
|
await pool.query(`
|
||||||
INSERT INTO c_canvas_sizes (name, width, height) VALUES
|
INSERT IGNORE INTO c_canvas_sizes (name, width, height) VALUES
|
||||||
('Full HD', 1920, 1080),
|
('Full HD', 1920, 1080),
|
||||||
('HD', 1280, 720),
|
('HD', 1280, 720),
|
||||||
('4K UHD', 3840, 2160),
|
('4K UHD', 3840, 2160),
|
||||||
|
|||||||
+39
-13
@@ -55,16 +55,7 @@ const VERSIONED_MIGRATIONS = [
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Recreate the screen-to-player foreign key after the column exists and legacy data is copied over.
|
// Recreate the screen-to-player foreign key after the column exists and legacy data is copied over.
|
||||||
const [screenPlayerFkRows] = await pool.query(
|
await ensureForeignKey(pool, 'd_screens', 'fk_screens_player', 'player_id', 'd_players', 'device_id', 'RESTRICT');
|
||||||
`SELECT COUNT(*) AS fk_count
|
|
||||||
FROM information_schema.KEY_COLUMN_USAGE
|
|
||||||
WHERE TABLE_SCHEMA = DATABASE()
|
|
||||||
AND TABLE_NAME = 'd_screens'
|
|
||||||
AND CONSTRAINT_NAME = 'fk_screens_player'`
|
|
||||||
);
|
|
||||||
if (Number(screenPlayerFkRows && screenPlayerFkRows[0] && screenPlayerFkRows[0].fk_count) === 0) {
|
|
||||||
await pool.query('ALTER TABLE d_screens ADD CONSTRAINT fk_screens_player FOREIGN KEY (player_id) REFERENCES d_players(device_id) ON DELETE RESTRICT');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (await columnExists(pool, 'c_template_regions', 'font_family')) {
|
if (await columnExists(pool, 'c_template_regions', 'font_family')) {
|
||||||
await pool.query('ALTER TABLE c_template_regions DROP COLUMN font_family');
|
await pool.query('ALTER TABLE c_template_regions DROP COLUMN font_family');
|
||||||
@@ -243,24 +234,59 @@ async function ensureColumn(pool, tableName, columnName, columnDefinition, after
|
|||||||
}
|
}
|
||||||
|
|
||||||
const afterClause = afterColumn ? ' AFTER ' + afterColumn : '';
|
const afterClause = afterColumn ? ' AFTER ' + afterColumn : '';
|
||||||
|
try {
|
||||||
await pool.query('ALTER TABLE ' + tableName + ' ADD COLUMN ' + columnName + ' ' + columnDefinition + afterClause);
|
await pool.query('ALTER TABLE ' + tableName + ' ADD COLUMN ' + columnName + ' ' + columnDefinition + afterClause);
|
||||||
|
} catch (error) {
|
||||||
|
if (!error || (error.code !== 'ER_DUP_FIELDNAME' && error.errno !== 1060)) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function ensureForeignKey(pool, tableName, constraintName, columnName, referencedTable, referencedColumn, onDeleteAction) {
|
async function ensureForeignKey(pool, tableName, constraintName, columnName, referencedTable, referencedColumn, onDeleteAction) {
|
||||||
const [rows] = await pool.query(
|
const [existingFkRows] = await pool.query(
|
||||||
`SELECT COUNT(*) AS fk_count
|
`SELECT COUNT(*) AS fk_count
|
||||||
FROM information_schema.KEY_COLUMN_USAGE
|
FROM information_schema.KEY_COLUMN_USAGE
|
||||||
WHERE TABLE_SCHEMA = DATABASE()
|
WHERE TABLE_SCHEMA = DATABASE()
|
||||||
AND TABLE_NAME = ?
|
AND TABLE_NAME = ?
|
||||||
|
AND COLUMN_NAME = ?
|
||||||
|
AND REFERENCED_TABLE_NAME IS NOT NULL`,
|
||||||
|
[tableName, columnName]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (Number(existingFkRows && existingFkRows[0] && existingFkRows[0].fk_count) > 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [rows] = await pool.query(
|
||||||
|
`SELECT COUNT(*) AS fk_count
|
||||||
|
FROM information_schema.KEY_COLUMN_USAGE
|
||||||
|
WHERE TABLE_SCHEMA = DATABASE()
|
||||||
AND CONSTRAINT_NAME = ?`,
|
AND CONSTRAINT_NAME = ?`,
|
||||||
[tableName, constraintName]
|
[constraintName]
|
||||||
);
|
);
|
||||||
|
|
||||||
if (Number(rows && rows[0] && rows[0].fk_count) > 0) {
|
if (Number(rows && rows[0] && rows[0].fk_count) > 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await pool.query('ALTER TABLE ' + tableName + ' ADD CONSTRAINT ' + constraintName + ' FOREIGN KEY (' + columnName + ') REFERENCES ' + referencedTable + '(' + referencedColumn + ') ON DELETE ' + onDeleteAction);
|
const fallbackNames = [
|
||||||
|
constraintName,
|
||||||
|
tableName + '_' + columnName + '_fk',
|
||||||
|
tableName + '_' + columnName + '_fk_2',
|
||||||
|
tableName + '_' + columnName + '_fk_3'
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const candidateName of fallbackNames) {
|
||||||
|
try {
|
||||||
|
await pool.query('ALTER TABLE ' + tableName + ' ADD CONSTRAINT ' + candidateName + ' FOREIGN KEY (' + columnName + ') REFERENCES ' + referencedTable + '(' + referencedColumn + ') ON DELETE ' + onDeleteAction);
|
||||||
|
return;
|
||||||
|
} catch (error) {
|
||||||
|
if (!error || (error.code !== 'ER_FK_DUP_NAME' && error.errno !== 1826)) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function dropColumnIfExists(pool, tableName, columnName) {
|
async function dropColumnIfExists(pool, tableName, columnName) {
|
||||||
|
|||||||
@@ -101,6 +101,7 @@
|
|||||||
gap: 2.5rem;
|
gap: 2.5rem;
|
||||||
min-width: 100%;
|
min-width: 100%;
|
||||||
width: max-content;
|
width: max-content;
|
||||||
|
backface-visibility: hidden;
|
||||||
will-change: transform;
|
will-change: transform;
|
||||||
animation: lower-third-scroll var(--announcement-scroll-duration, 20s) linear infinite;
|
animation: lower-third-scroll var(--announcement-scroll-duration, 20s) linear infinite;
|
||||||
}
|
}
|
||||||
@@ -144,10 +145,10 @@
|
|||||||
|
|
||||||
@keyframes lower-third-scroll {
|
@keyframes lower-third-scroll {
|
||||||
from {
|
from {
|
||||||
transform: translateX(0);
|
transform: translate3d(0, 0, 0);
|
||||||
}
|
}
|
||||||
to {
|
to {
|
||||||
transform: translateX(-50%);
|
transform: translate3d(-50%, 0, 0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -103,6 +103,7 @@
|
|||||||
gap: 2.5rem;
|
gap: 2.5rem;
|
||||||
min-width: 100%;
|
min-width: 100%;
|
||||||
width: max-content;
|
width: max-content;
|
||||||
|
backface-visibility: hidden;
|
||||||
will-change: transform;
|
will-change: transform;
|
||||||
animation: top-banner-scroll var(--announcement-scroll-duration, 20s) linear infinite;
|
animation: top-banner-scroll var(--announcement-scroll-duration, 20s) linear infinite;
|
||||||
}
|
}
|
||||||
@@ -146,10 +147,10 @@
|
|||||||
|
|
||||||
@keyframes top-banner-scroll {
|
@keyframes top-banner-scroll {
|
||||||
from {
|
from {
|
||||||
transform: translateX(0);
|
transform: translate3d(0, 0, 0);
|
||||||
}
|
}
|
||||||
to {
|
to {
|
||||||
transform: translateX(-50%);
|
transform: translate3d(-50%, 0, 0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -215,12 +215,7 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var socketUrl = new URL(announcementSocketPath, window.location.origin);
|
var socket = new WebSocket(new URL(announcementSocketPath, window.location.origin).toString());
|
||||||
if (window.__pulsePageAuthToken) {
|
|
||||||
socketUrl.searchParams.set('auth', window.__pulsePageAuthToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
var socket = new WebSocket(socketUrl.toString());
|
|
||||||
announcementSocket = socket;
|
announcementSocket = socket;
|
||||||
|
|
||||||
socket.onopen = function () {
|
socket.onopen = function () {
|
||||||
|
|||||||
@@ -214,6 +214,8 @@ body.thumbnail-preview .player-offline-banner {
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transition: opacity 560ms ease;
|
transition: opacity 560ms ease;
|
||||||
|
will-change: opacity;
|
||||||
|
backface-visibility: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.slide-shell.is-visible {
|
.slide-shell.is-visible {
|
||||||
@@ -243,7 +245,8 @@ body.screen-blackout #app {
|
|||||||
height: var(--player-canvas-height, 100vh);
|
height: var(--player-canvas-height, 100vh);
|
||||||
max-width: 100vw;
|
max-width: 100vw;
|
||||||
max-height: 100vh;
|
max-height: 100vh;
|
||||||
transform: translate(-50%, -50%);
|
transform: translate3d(-50%, -50%, 0);
|
||||||
|
backface-visibility: hidden;
|
||||||
z-index: 9999;
|
z-index: 9999;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -526,12 +526,7 @@ function connectCommandSocket() {
|
|||||||
if (commandSocket && (commandSocket.readyState === WebSocket.OPEN || commandSocket.readyState === WebSocket.CONNECTING)) {
|
if (commandSocket && (commandSocket.readyState === WebSocket.OPEN || commandSocket.readyState === WebSocket.CONNECTING)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
var protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
var socket = new WebSocket(new URL(commandSocketPath, window.location.origin).toString());
|
||||||
var socketUrl = new URL(commandSocketPath, window.location.origin);
|
|
||||||
if (window.__pulsePageAuthToken) {
|
|
||||||
socketUrl.searchParams.set('auth', window.__pulsePageAuthToken);
|
|
||||||
}
|
|
||||||
var socket = new WebSocket(socketUrl.toString());
|
|
||||||
commandSocket = socket;
|
commandSocket = socket;
|
||||||
|
|
||||||
socket.onopen = function () {
|
socket.onopen = function () {
|
||||||
|
|||||||
@@ -132,7 +132,7 @@ function renderPlayerPage(slug, initialData) {
|
|||||||
bodyClass: bodyClass,
|
bodyClass: bodyClass,
|
||||||
body: '<div id="app"><div class="empty">Loading screen...</div></div>',
|
body: '<div id="app"><div class="empty">Loading screen...</div></div>',
|
||||||
stylesheets: fontStylesheetHref ? [fontStylesheetHref] : [],
|
stylesheets: fontStylesheetHref ? [fontStylesheetHref] : [],
|
||||||
script: createPageFetchAuthScript(pageAuthToken) + hlsScriptTag + serviceWorkerScript + createThumbnailPreviewBootstrapScript(initialData) + '<script>' + offlineScript + '</script>' + '<script>' + playlistScript + '</script>' + '<script>' + commandScript + '</script>' + '<script>' + renderingScript + '</script>' + '<script>' + playbackScript + '</script>' + '<script>' + getAnnouncementIconsDataScript() + '</script>' + '<script>' + getPlayerAnnouncementTemplatesDataScript() + '</script>' + '<script>' + getPlayerAnnouncementTemplatesScript() + '</script>' + onboardingScript + script + '<script>' + getPlayerPageAnnouncementsScript()() + '</script>'
|
script: createPageFetchAuthScript(pageAuthToken, '/ws/screens/' + encodeURIComponent(slug || '')) + hlsScriptTag + serviceWorkerScript + createThumbnailPreviewBootstrapScript(initialData) + '<script>' + offlineScript + '</script>' + '<script>' + playlistScript + '</script>' + '<script>' + commandScript + '</script>' + '<script>' + renderingScript + '</script>' + '<script>' + playbackScript + '</script>' + '<script>' + getAnnouncementIconsDataScript() + '</script>' + '<script>' + getPlayerAnnouncementTemplatesDataScript() + '</script>' + '<script>' + getPlayerAnnouncementTemplatesScript() + '</script>' + onboardingScript + script + '<script>' + getPlayerPageAnnouncementsScript()() + '</script>'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+31
-2
@@ -4,6 +4,7 @@ const crypto = require('crypto');
|
|||||||
const { WebSocketServer, WebSocket } = require('ws');
|
const { WebSocketServer, WebSocket } = require('ws');
|
||||||
const { isClientNameAvailable } = require('#src/data/client-name-check');
|
const { isClientNameAvailable } = require('#src/data/client-name-check');
|
||||||
const { verifyPageAuthToken, verifyRequestAuth } = require('#src/request-auth');
|
const { verifyPageAuthToken, verifyRequestAuth } = require('#src/request-auth');
|
||||||
|
const PAGE_AUTH_COOKIE_NAME = 'pulse_page_auth';
|
||||||
|
|
||||||
function normalizePlayerPublicBaseUrl(pageUrl) {
|
function normalizePlayerPublicBaseUrl(pageUrl) {
|
||||||
const value = String(pageUrl || '').trim();
|
const value = String(pageUrl || '').trim();
|
||||||
@@ -43,6 +44,34 @@ function createPlayerRuntime(options) {
|
|||||||
return ip;
|
return ip;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseCookies(cookieHeader) {
|
||||||
|
return String(cookieHeader || '').split(/;\s*/).reduce(function (cookies, pair) {
|
||||||
|
if (!pair) {
|
||||||
|
return cookies;
|
||||||
|
}
|
||||||
|
const separatorIndex = pair.indexOf('=');
|
||||||
|
if (separatorIndex === -1) {
|
||||||
|
return cookies;
|
||||||
|
}
|
||||||
|
const name = decodeURIComponent(pair.slice(0, separatorIndex).trim());
|
||||||
|
const value = decodeURIComponent(pair.slice(separatorIndex + 1).trim());
|
||||||
|
if (name) {
|
||||||
|
cookies[name] = value;
|
||||||
|
}
|
||||||
|
return cookies;
|
||||||
|
}, {});
|
||||||
|
}
|
||||||
|
|
||||||
|
function readPageAuthToken(request) {
|
||||||
|
const queryToken = String(new URL(request.url, 'http://localhost').searchParams.get('auth') || '').trim();
|
||||||
|
if (queryToken) {
|
||||||
|
return queryToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cookies = parseCookies(request.headers && request.headers.cookie || '');
|
||||||
|
return String(cookies[PAGE_AUTH_COOKIE_NAME] || '').trim();
|
||||||
|
}
|
||||||
|
|
||||||
function getConnectionBucket(slug) {
|
function getConnectionBucket(slug) {
|
||||||
const key = String(slug || '').trim();
|
const key = String(slug || '').trim();
|
||||||
if (!key) {
|
if (!key) {
|
||||||
@@ -314,7 +343,7 @@ function createPlayerRuntime(options) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (playerMatch) {
|
if (playerMatch) {
|
||||||
const authToken = String(new URL(request.url, 'http://localhost').searchParams.get('auth') || '').trim();
|
const authToken = readPageAuthToken(request);
|
||||||
const payload = verifyPageAuthToken(authToken);
|
const payload = verifyPageAuthToken(authToken);
|
||||||
if (!payload || String(payload.scope || '').trim() !== 'player') {
|
if (!payload || String(payload.scope || '').trim() !== 'player') {
|
||||||
socket.destroy();
|
socket.destroy();
|
||||||
@@ -323,7 +352,7 @@ function createPlayerRuntime(options) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (announcementMatch) {
|
if (announcementMatch) {
|
||||||
const authToken = String(new URL(request.url, 'http://localhost').searchParams.get('auth') || '').trim();
|
const authToken = readPageAuthToken(request);
|
||||||
const payload = verifyPageAuthToken(authToken);
|
const payload = verifyPageAuthToken(authToken);
|
||||||
if (!payload || String(payload.scope || '').trim() !== 'player') {
|
if (!payload || String(payload.scope || '').trim() !== 'player') {
|
||||||
socket.destroy();
|
socket.destroy();
|
||||||
|
|||||||
+22
-1
@@ -246,12 +246,13 @@ function verifyRequestAuth(req) {
|
|||||||
return timingSafeEqualHex(expectedSignature, signature);
|
return timingSafeEqualHex(expectedSignature, signature);
|
||||||
}
|
}
|
||||||
|
|
||||||
function createPageFetchAuthScript(token) {
|
function createPageFetchAuthScript(token, cookiePath) {
|
||||||
const normalizedToken = String(token && typeof token === 'object' ? token.token : token || '').trim();
|
const normalizedToken = String(token && typeof token === 'object' ? token.token : token || '').trim();
|
||||||
if (!normalizedToken) {
|
if (!normalizedToken) {
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const normalizedCookiePath = String(cookiePath || '').trim();
|
||||||
const pageAuthExpiresAt = token && typeof token === 'object' && Number.isFinite(Number(token.expiresAt))
|
const pageAuthExpiresAt = token && typeof token === 'object' && Number.isFinite(Number(token.expiresAt))
|
||||||
? Number(token.expiresAt)
|
? Number(token.expiresAt)
|
||||||
: null;
|
: null;
|
||||||
@@ -262,6 +263,8 @@ function createPageFetchAuthScript(token) {
|
|||||||
' (function () {',
|
' (function () {',
|
||||||
' var pageAuthToken = ' + JSON.stringify(normalizedToken) + ';',
|
' var pageAuthToken = ' + JSON.stringify(normalizedToken) + ';',
|
||||||
' var pageAuthExpiresAt = ' + JSON.stringify(pageAuthExpiresAt) + ';',
|
' var pageAuthExpiresAt = ' + JSON.stringify(pageAuthExpiresAt) + ';',
|
||||||
|
' var pageAuthCookiePath = ' + JSON.stringify(normalizedCookiePath) + ';',
|
||||||
|
' var pageAuthCookieName = "pulse_page_auth";',
|
||||||
' var pageAuthRenewalTimer = null;',
|
' var pageAuthRenewalTimer = null;',
|
||||||
' var pageAuthRenewalInFlight = null;',
|
' var pageAuthRenewalInFlight = null;',
|
||||||
' var pageAuthRenewalSkewMs = ' + JSON.stringify(renewSkewMs) + ';',
|
' var pageAuthRenewalSkewMs = ' + JSON.stringify(renewSkewMs) + ';',
|
||||||
@@ -287,11 +290,28 @@ function createPageFetchAuthScript(token) {
|
|||||||
' });',
|
' });',
|
||||||
' }, delayMs);',
|
' }, delayMs);',
|
||||||
' }',
|
' }',
|
||||||
|
' function writePageAuthCookie(nextToken, nextExpiresAt) {',
|
||||||
|
' if (!pageAuthCookiePath) {',
|
||||||
|
' return;',
|
||||||
|
' }',
|
||||||
|
' var cookieParts = [pageAuthCookieName + "=" + encodeURIComponent(String(nextToken || "").trim()), "Path=" + pageAuthCookiePath, "SameSite=Lax"];',
|
||||||
|
' var expiresInMs = Number(nextExpiresAt || 0) - Date.now();',
|
||||||
|
' if (Number.isFinite(expiresInMs) && expiresInMs > 0) {',
|
||||||
|
' cookieParts.push("Max-Age=" + Math.max(1, Math.floor(expiresInMs / 1000)));',
|
||||||
|
' } else {',
|
||||||
|
' cookieParts.push("Max-Age=0");',
|
||||||
|
' }',
|
||||||
|
' if (window.location.protocol === "https:") {',
|
||||||
|
' cookieParts.push("Secure");',
|
||||||
|
' }',
|
||||||
|
' document.cookie = cookieParts.join("; ");',
|
||||||
|
' }',
|
||||||
' function setPageAuthToken(nextToken, nextExpiresAt) {',
|
' function setPageAuthToken(nextToken, nextExpiresAt) {',
|
||||||
' pageAuthToken = String(nextToken || "").trim();',
|
' pageAuthToken = String(nextToken || "").trim();',
|
||||||
' pageAuthExpiresAt = Number(nextExpiresAt || 0) || null;',
|
' pageAuthExpiresAt = Number(nextExpiresAt || 0) || null;',
|
||||||
' window.__pulsePageAuthToken = pageAuthToken;',
|
' window.__pulsePageAuthToken = pageAuthToken;',
|
||||||
' window.__pulsePageAuthExpiresAt = pageAuthExpiresAt;',
|
' window.__pulsePageAuthExpiresAt = pageAuthExpiresAt;',
|
||||||
|
' writePageAuthCookie(pageAuthToken, pageAuthExpiresAt);',
|
||||||
' schedulePageAuthRenewal();',
|
' schedulePageAuthRenewal();',
|
||||||
' }',
|
' }',
|
||||||
' async function renewPageAuthToken() {',
|
' async function renewPageAuthToken() {',
|
||||||
@@ -326,6 +346,7 @@ function createPageFetchAuthScript(token) {
|
|||||||
' window.__pulseRenewPageAuthToken = renewPageAuthToken;',
|
' window.__pulseRenewPageAuthToken = renewPageAuthToken;',
|
||||||
' window.__pulsePageAuthToken = pageAuthToken;',
|
' window.__pulsePageAuthToken = pageAuthToken;',
|
||||||
' window.__pulsePageAuthExpiresAt = pageAuthExpiresAt;',
|
' window.__pulsePageAuthExpiresAt = pageAuthExpiresAt;',
|
||||||
|
' writePageAuthCookie(pageAuthToken, pageAuthExpiresAt);',
|
||||||
' window.addEventListener("focus", function () {',
|
' window.addEventListener("focus", function () {',
|
||||||
' schedulePageAuthRenewal();',
|
' schedulePageAuthRenewal();',
|
||||||
' });',
|
' });',
|
||||||
|
|||||||
@@ -1743,6 +1743,7 @@ html[data-bs-theme='dark'] .template-field-card .tox .tox-collection {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
|
flex-wrap: nowrap;
|
||||||
gap: 0;
|
gap: 0;
|
||||||
min-height: 3.25rem;
|
min-height: 3.25rem;
|
||||||
}
|
}
|
||||||
@@ -1756,6 +1757,7 @@ html[data-bs-theme='dark'] .template-field-card .tox .tox-collection {
|
|||||||
|
|
||||||
.template-field-head strong {
|
.template-field-head strong {
|
||||||
font-size: 0.95rem;
|
font-size: 0.95rem;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.template-field-head .chip {
|
.template-field-head .chip {
|
||||||
@@ -1869,6 +1871,7 @@ html[data-bs-theme='dark'] .template-field-card .tox .tox-collection {
|
|||||||
display: grid;
|
display: grid;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
padding-top: 0.25rem;
|
padding-top: 0.25rem;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.api-region-placeholder-title {
|
.api-region-placeholder-title {
|
||||||
@@ -1880,7 +1883,8 @@ html[data-bs-theme='dark'] .template-field-card .tox .tox-collection {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.api-region-sample-accordion {
|
.api-region-sample-accordion {
|
||||||
padding: 0.85rem 1rem;
|
padding: 0.85rem 1rem 1rem;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
border: 1px solid var(--bs-border-color);
|
border: 1px solid var(--bs-border-color);
|
||||||
border-radius: 0.85rem;
|
border-radius: 0.85rem;
|
||||||
background: var(--bs-secondary-bg);
|
background: var(--bs-secondary-bg);
|
||||||
|
|||||||
@@ -191,6 +191,7 @@
|
|||||||
var region = context.region;
|
var region = context.region;
|
||||||
var current = context.current || {};
|
var current = context.current || {};
|
||||||
var config = context.config || {};
|
var config = context.config || {};
|
||||||
|
var placeholderFields = Array.isArray(context.placeholderFields) ? context.placeholderFields : [];
|
||||||
var placeholderChips = String(context.placeholderChips || '');
|
var placeholderChips = String(context.placeholderChips || '');
|
||||||
var sourceOptions = String(context.sourceOptions || '');
|
var sourceOptions = String(context.sourceOptions || '');
|
||||||
var itemsPathValue = context.itemsPath !== undefined
|
var itemsPathValue = context.itemsPath !== undefined
|
||||||
@@ -234,7 +235,7 @@
|
|||||||
'<div class="muted slide-image-file">Use transforms like {{name.upper()}}, {{name.title()}}, or {{name.lower()}} on leaf fields.</div>' +
|
'<div class="muted slide-image-file">Use transforms like {{name.upper()}}, {{name.title()}}, or {{name.lower()}} on leaf fields.</div>' +
|
||||||
'<div class="api-region-placeholder-section">' +
|
'<div class="api-region-placeholder-section">' +
|
||||||
'<div class="api-region-placeholder-title">Available placeholders</div>' +
|
'<div class="api-region-placeholder-title">Available placeholders</div>' +
|
||||||
'<div class="d-flex flex-wrap gap-2" data-placeholder-chips>' + (placeholderChips || '<span class="muted slide-image-file">No JSON fields available.</span>') + '</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>' +
|
'</div>' +
|
||||||
'<details class="api-region-sample-accordion" data-api-sample-data-accordion>' +
|
'<details class="api-region-sample-accordion" data-api-sample-data-accordion>' +
|
||||||
'<summary>Data</summary>' +
|
'<summary>Data</summary>' +
|
||||||
|
|||||||
@@ -218,7 +218,7 @@
|
|||||||
'<span class="chip">RSS</span>' +
|
'<span class="chip">RSS</span>' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
'<div class="card-body p-3 d-grid">' +
|
'<div class="card-body p-3 d-grid gap-3 pb-0">' +
|
||||||
'<div class="editor-holder" data-region-id="' + region.id + '">' +
|
'<div class="editor-holder" data-region-id="' + region.id + '">' +
|
||||||
'<textarea class="editor-source" rows="10">' + escapeHtml(current.value !== undefined ? current.value : '') + '</textarea>' +
|
'<textarea class="editor-source" rows="10">' + escapeHtml(current.value !== undefined ? current.value : '') + '</textarea>' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
@@ -237,9 +237,12 @@
|
|||||||
'</div>' +
|
'</div>' +
|
||||||
'<input type="hidden" name="region_font_size_' + region.id + '" value="' + escapeHtml(context.fontSize || '') + '" />' +
|
'<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 : '') + '" />' +
|
'<input type="hidden" name="region_text_' + region.id + '" value="' + escapeHtml(current.value !== undefined ? current.value : '') + '" />' +
|
||||||
'<div class="muted slide-image-file">Use placeholders like {{title.upper()}}, {{title.title()}}, or {{title.lower()}}. Available placeholders:</div>' +
|
'<div class="muted slide-image-file">Use transforms like {{title.upper()}}, {{title.title()}}, or {{title.lower()}} on leaf fields.</div>' +
|
||||||
|
'<div class="api-region-placeholder-section">' +
|
||||||
|
'<div class="api-region-placeholder-title">Available placeholders</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="d-flex flex-wrap gap-2" data-placeholder-chips>' + (placeholderChips || '<span class="muted slide-image-file">No RSS fields available.</span>') + '</div>' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
|
'</div>' +
|
||||||
'</div>';
|
'</div>';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -21,7 +21,7 @@
|
|||||||
function renderEditorCard(context) {
|
function renderEditorCard(context) {
|
||||||
var region = context.region;
|
var region = context.region;
|
||||||
var current = String(context.current || '');
|
var current = String(context.current || '');
|
||||||
var disableAudio = context.disableAudio === undefined ? true : Boolean(context.disableAudio);
|
var disableAudio = context.disableAudio === undefined ? false : Boolean(context.disableAudio);
|
||||||
return '' +
|
return '' +
|
||||||
'<div class="card card-outline card-secondary admin-form-card template-field-card mb-3" data-region-id="' + region.id + '">' +
|
'<div class="card card-outline card-secondary admin-form-card template-field-card mb-3" data-region-id="' + region.id + '">' +
|
||||||
'<div class="card-header template-field-head">' +
|
'<div class="card-header template-field-head">' +
|
||||||
|
|||||||
@@ -233,7 +233,7 @@ export function createSlideFormEditorController(options) {
|
|||||||
promotion: false,
|
promotion: false,
|
||||||
statusbar: true,
|
statusbar: true,
|
||||||
resize: true,
|
resize: true,
|
||||||
plugins: 'lists link code advlist fullscreen table',
|
plugins: 'lists code advlist fullscreen table',
|
||||||
toolbar: 'undo redo | fontfamily fontsizeinput | forecolor backcolor bold italic underline strikethrough subscript superscript removeformat | align lineheight indent outdent bullist numlist table | fullscreen',
|
toolbar: 'undo redo | fontfamily fontsizeinput | forecolor backcolor bold italic underline strikethrough subscript superscript removeformat | align lineheight indent outdent bullist numlist table | fullscreen',
|
||||||
toolbar_mode: 'sliding',
|
toolbar_mode: 'sliding',
|
||||||
license_key: 'gpl',
|
license_key: 'gpl',
|
||||||
@@ -244,6 +244,7 @@ export function createSlideFormEditorController(options) {
|
|||||||
content_style: 'body { font-family: ' + defaultEditorFontFamily + '; font-size: 32px; line-height: 1.5; background-color: ' + getEditorBackgroundColorValue() + '; } p { margin: 1em 0; } p:first-child { margin-top: 0; } p:last-child { margin-bottom: 1em; } table { border-collapse: collapse; width: 100%; } td, th { border: 1px solid currentColor; padding: 0.35em 0.5em; vertical-align: top; } th { font-weight: 700; }' + (editorContentStyle ? ' ' + editorContentStyle : ''),
|
content_style: 'body { font-family: ' + defaultEditorFontFamily + '; font-size: 32px; line-height: 1.5; background-color: ' + getEditorBackgroundColorValue() + '; } p { margin: 1em 0; } p:first-child { margin-top: 0; } p:last-child { margin-bottom: 1em; } table { border-collapse: collapse; width: 100%; } td, th { border: 1px solid currentColor; padding: 0.35em 0.5em; vertical-align: top; } th { font-weight: 700; }' + (editorContentStyle ? ' ' + editorContentStyle : ''),
|
||||||
font_family_formats: getFontFamilyFormats(),
|
font_family_formats: getFontFamilyFormats(),
|
||||||
font_size_input_default_unit: 'px',
|
font_size_input_default_unit: 'px',
|
||||||
|
invalid_elements: 'a',
|
||||||
forced_root_block: 'p',
|
forced_root_block: 'p',
|
||||||
force_br_newlines: false,
|
force_br_newlines: false,
|
||||||
newline_behavior: 'default',
|
newline_behavior: 'default',
|
||||||
|
|||||||
@@ -200,7 +200,16 @@ export function createSlideFormRegionHelpers(options) {
|
|||||||
|
|
||||||
function updateRssPlaceholderChips(regionId, feedId) {
|
function updateRssPlaceholderChips(regionId, feedId) {
|
||||||
var card = templateFields ? templateFields.querySelector('[data-region-id="' + regionId + '"]') : null;
|
var card = templateFields ? templateFields.querySelector('[data-region-id="' + regionId + '"]') : null;
|
||||||
updatePlaceholderChipList(card, getRssFieldList(feedId), 'No RSS fields available.');
|
if (!card) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var container = card.querySelector('[data-placeholder-chips]');
|
||||||
|
if (!container) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
container.innerHTML = buildLimitedPlaceholderChipMarkup(getRssFieldList(feedId), 'No RSS fields available.');
|
||||||
}
|
}
|
||||||
|
|
||||||
function getCurrentRssConfig(region) {
|
function getCurrentRssConfig(region) {
|
||||||
@@ -502,6 +511,11 @@ export function createSlideFormRegionHelpers(options) {
|
|||||||
return '<span class="chip">{{' + escapeHtml(field) + '}}</span>';
|
return '<span class="chip">{{' + escapeHtml(field) + '}}</span>';
|
||||||
}).join('')
|
}).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.'),
|
||||||
|
placeholderFields: region.region_type === 'api'
|
||||||
|
? getApiFieldList(getCurrentApiConfig(region).source_id, getCurrentApiConfig(region).items_path)
|
||||||
|
: region.region_type === 'rss'
|
||||||
|
? getRssFieldList(getCurrentRssConfig(region).feed_id)
|
||||||
|
: [],
|
||||||
sampleDataPreview: buildApiSampleDataMarkup(getCurrentApiConfig(region).source_id, getCurrentApiConfig(region).item_number, getCurrentApiConfig(region).items_path),
|
sampleDataPreview: buildApiSampleDataMarkup(getCurrentApiConfig(region).source_id, getCurrentApiConfig(region).item_number, getCurrentApiConfig(region).items_path),
|
||||||
timetableGroups: timetableGroups
|
timetableGroups: timetableGroups
|
||||||
}, existingContent, region.region_type === 'rss' ? rssFeeds : region.region_type === 'timetable' ? timetableGroups : apiSources);
|
}, existingContent, region.region_type === 'rss' ? rssFeeds : region.region_type === 'timetable' ? timetableGroups : apiSources);
|
||||||
@@ -533,10 +547,13 @@ export function createSlideFormRegionHelpers(options) {
|
|||||||
return '<option value="' + escapeHtml(source.id) + '"' + selected + '>' + escapeHtml(source.name || ('Source ' + source.id)) + '</option>';
|
return '<option value="' + escapeHtml(source.id) + '"' + selected + '>' + escapeHtml(source.name || ('Source ' + source.id)) + '</option>';
|
||||||
}).join(''),
|
}).join(''),
|
||||||
placeholderChips: region.region_type === 'rss'
|
placeholderChips: region.region_type === 'rss'
|
||||||
? getRssFieldList(rssConfig.feed_id).map(function (field) {
|
? buildLimitedPlaceholderChipMarkup(getRssFieldList(rssConfig.feed_id), 'No RSS fields available.')
|
||||||
return '<span class="chip">{{' + escapeHtml(field) + '}}</span>';
|
|
||||||
}).join('')
|
|
||||||
: buildLimitedPlaceholderChipMarkup(getApiFieldList(apiConfig.source_id, apiItemsPath), 'No JSON fields available.'),
|
: buildLimitedPlaceholderChipMarkup(getApiFieldList(apiConfig.source_id, apiItemsPath), 'No JSON fields available.'),
|
||||||
|
placeholderFields: region.region_type === 'api'
|
||||||
|
? getApiFieldList(apiConfig.source_id, apiItemsPath)
|
||||||
|
: region.region_type === 'rss'
|
||||||
|
? getRssFieldList(rssConfig.feed_id)
|
||||||
|
: [],
|
||||||
sampleDataPreview: buildApiSampleDataMarkup(apiConfig.source_id, apiConfig.item_number, apiItemsPath),
|
sampleDataPreview: buildApiSampleDataMarkup(apiConfig.source_id, apiConfig.item_number, apiItemsPath),
|
||||||
timetableGroups: timetableGroups
|
timetableGroups: timetableGroups
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -138,7 +138,7 @@
|
|||||||
|
|
||||||
<template id="region-card-template">
|
<template id="region-card-template">
|
||||||
<div class="card card-outline card-secondary admin-form-card region-item">
|
<div class="card card-outline card-secondary admin-form-card region-item">
|
||||||
<div class="card-header template-field-head"><strong data-region-title>Region</strong><span class="chip" data-region-chip>Text</span></div>
|
<div class="card-header template-field-head d-flex align-items-center flex-nowrap gap-2"><strong class="flex-grow-1 text-truncate" data-region-title>Region</strong><span class="chip ms-auto" data-region-chip>Text</span></div>
|
||||||
<div class="card-body p-3 d-grid gap-3 pb-0">
|
<div class="card-body p-3 d-grid gap-3 pb-0">
|
||||||
<div class="region-field-grid region-field-grid--identity">
|
<div class="region-field-grid region-field-grid--identity">
|
||||||
<label>Region name<input class="form-control" name="region_name[]" value="" placeholder="region_1" required /></label>
|
<label>Region name<input class="form-control" name="region_name[]" value="" placeholder="region_1" required /></label>
|
||||||
|
|||||||
@@ -87,6 +87,42 @@ test('player runtime snapshots websocket state and checks live names', async ()
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('player runtime accepts websocket auth from cookies', async () => {
|
||||||
|
process.env.PULSE_SIGNAGE_SHARED_SECRET = 'runtime-secret';
|
||||||
|
|
||||||
|
const runtime = createPlayerRuntime({ pool: null });
|
||||||
|
const server = http.createServer();
|
||||||
|
runtime.installWebsocket(server);
|
||||||
|
|
||||||
|
await new Promise((resolve) => server.listen(0, resolve));
|
||||||
|
const { port } = server.address();
|
||||||
|
const token = createPageAuthToken({ scope: 'player', slug: 'cookie-test' });
|
||||||
|
const client = new WebSocket(`ws://127.0.0.1:${port}/ws/screens/cookie-test`, {
|
||||||
|
headers: {
|
||||||
|
Cookie: `pulse_page_auth=${encodeURIComponent(token)}`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
client.once('open', resolve);
|
||||||
|
client.once('error', reject);
|
||||||
|
});
|
||||||
|
|
||||||
|
client.send(JSON.stringify({ type: 'state', clientId: 'cookie-client', clientName: 'Cookie Player', deviceId: 'device-cookie' }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const snapshot = runtime.snapshotConnections('cookie-test')[0];
|
||||||
|
return snapshot && snapshot.clientName === 'Cookie Player' ? snapshot : null;
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(runtime.snapshotConnections('cookie-test')[0].clientName, 'Cookie Player');
|
||||||
|
} finally {
|
||||||
|
client.close();
|
||||||
|
await new Promise((resolve) => server.close(resolve));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('player runtime sends targeted and broadcast commands to live sockets', async () => {
|
test('player runtime sends targeted and broadcast commands to live sockets', async () => {
|
||||||
process.env.PULSE_SIGNAGE_SHARED_SECRET = 'runtime-secret';
|
process.env.PULSE_SIGNAGE_SHARED_SECRET = 'runtime-secret';
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user