Save worktree changes

This commit is contained in:
2026-07-25 02:29:19 +01:00
parent 8d3b7d557b
commit db9d718cd8
170 changed files with 11719 additions and 3414 deletions
+12
View File
@@ -10,6 +10,17 @@ module.exports = {
parseJsonSafe: data.parseJsonSafe,
fetchAdminData: data.fetchAdminData,
fetchPlaylistById: data.fetchPlaylistById,
fetchApiSourcesData: data.fetchApiSourcesData,
fetchApiSourceById: data.fetchApiSourceById,
fetchApiSourceResponse: data.fetchApiSourceResponse,
buildApiSourcePayload: data.buildApiSourcePayload,
fetchRssFeedsData: data.fetchRssFeedsData,
fetchRssFeedById: data.fetchRssFeedById,
fetchRssFeedItemsByFeedId: data.fetchRssFeedItemsByFeedId,
normalizeRssFeedItem: data.normalizeRssFeedItem,
buildRssFeedPayload: data.buildRssFeedPayload,
fetchRssFeedItems: data.fetchRssFeedItems,
replaceRssFeedItems: data.replaceRssFeedItems,
fetchScreenById: data.fetchScreenById,
fetchScreenEditData: data.fetchScreenEditData,
fetchTemplateById: data.fetchTemplateById,
@@ -21,6 +32,7 @@ module.exports = {
buildSlidePayload: data.buildSlidePayload,
extractTemplateRegions: data.extractTemplateRegions,
buildTemplatePayload: data.buildTemplatePayload,
fetchDuplicateName: data.fetchDuplicateName,
mediaKind: player.mediaKind,
renderPlayerPage: player.renderPlayerPage,
renderPlayerOnboardingLandingPage: player.renderPlayerOnboardingLandingPage,
+1 -1
View File
@@ -8,7 +8,7 @@ async function fetchAdminData(pool) {
LEFT JOIN canvas_sizes cs ON cs.id = st.canvas_size_id
ORDER BY st.id DESC
`);
const [templateRegions] = await pool.query('SELECT id, template_id, region_key, region_type, label, font_family, x, y, width, height, z_index, created_at, modified_at, created_by, modified_by FROM slide_template_regions ORDER BY template_id ASC, z_index ASC, id ASC');
const [templateRegions] = await pool.query('SELECT id, template_id, region_key, region_type, label, font_family, lock_ratio, x, y, width, height, z_index, created_at, modified_at, created_by, modified_by FROM slide_template_regions ORDER BY template_id ASC, z_index ASC, id ASC');
const [slides] = await pool.query(`
SELECT s.id, s.title, s.body, s.template_id, s.content_json, s.media_path, s.media_type, s.created_at, s.modified_at, s.created_by, s.modified_by, st.name AS template_name, cs.width AS canvas_width, cs.height AS canvas_height
FROM slides s
+150
View File
@@ -0,0 +1,150 @@
const http = require('http');
const https = require('https');
function normalizeUpdateIntervalUnit(value) {
const unit = String(value || '').trim().toLowerCase();
return unit === 'seconds' ? 'seconds' : 'minutes';
}
async function fetchApiSourcesData(pool) {
const [apiSources] = await pool.query(
'SELECT id, name, api_url, update_interval_value, update_interval_unit, last_pulled_at, last_pull_error, last_response_status, last_response_content_type, last_response_json, created_at, modified_at, created_by, modified_by FROM api_sources ORDER BY modified_at DESC, id DESC'
);
return { apiSources: apiSources };
}
async function fetchApiSourceById(pool, id) {
const [rows] = await pool.query(
'SELECT id, name, api_url, update_interval_value, update_interval_unit, last_pulled_at, last_pull_error, last_response_status, last_response_content_type, last_response_json, created_at, modified_at, created_by, modified_by FROM api_sources WHERE id = ?',
[id]
);
return rows[0] || null;
}
async function loadUrlText(urlValue) {
if (typeof fetch === 'function') {
const response = await fetch(urlValue, {
headers: {
Accept: 'application/json, text/plain;q=0.9, */*;q=0.8',
'User-Agent': 'Pulse Signage API Reader'
}
});
return {
statusCode: response.status,
contentType: response.headers.get('content-type') || '',
bodyText: await response.text(),
ok: response.ok
};
}
return await new Promise(function (resolve, reject) {
const url = new URL(urlValue);
const transport = url.protocol === 'https:' ? https : http;
const request = transport.get(url, {
headers: {
Accept: 'application/json, text/plain;q=0.9, */*;q=0.8',
'User-Agent': 'Pulse Signage API Reader'
}
}, function (response) {
response.setEncoding('utf8');
let body = '';
response.on('data', function (chunk) {
body += chunk;
});
response.on('end', function () {
resolve({
statusCode: response.statusCode || 0,
contentType: String(response.headers['content-type'] || ''),
bodyText: body,
ok: !response.statusCode || response.statusCode < 400
});
});
response.on('error', reject);
});
request.on('error', reject);
});
}
async function fetchApiSourceResponse(apiUrl) {
const response = await loadUrlText(apiUrl);
if (!response.ok) {
throw new Error(`Unable to load API response (${response.statusCode}).`);
}
const text = String(response.bodyText || '').trim();
if (!text) {
throw new Error('API response did not return JSON.');
}
let parsed;
try {
parsed = JSON.parse(text);
} catch (_error) {
throw new Error('API response was not valid JSON.');
}
return {
responseJson: JSON.stringify(parsed, null, 2),
responseStatus: response.statusCode,
responseContentType: response.contentType
};
}
function buildApiSourcePayload(req, existingApiSource) {
const fallback = existingApiSource || {};
const name = String(req.body.name || fallback.name || '').trim();
const apiUrl = String(req.body.api_url || req.body.apiUrl || fallback.api_url || '').trim();
const updateIntervalValue = Math.max(1, Number(req.body.update_interval_value || req.body.updateIntervalValue || fallback.update_interval_value || 60));
const updateIntervalUnit = normalizeUpdateIntervalUnit(req.body.update_interval_unit || req.body.updateIntervalUnit || fallback.update_interval_unit || 'minutes');
if (!name) {
const error = new Error('API source name is required.');
error.statusCode = 400;
throw error;
}
if (!apiUrl) {
const error = new Error('API source URL is required.');
error.statusCode = 400;
throw error;
}
let parsedUrl;
try {
parsedUrl = new URL(apiUrl);
} catch (_error) {
const error = new Error('Enter a valid API URL.');
error.statusCode = 400;
throw error;
}
if (parsedUrl.protocol !== 'http:' && parsedUrl.protocol !== 'https:') {
const error = new Error('API URL must start with http or https.');
error.statusCode = 400;
throw error;
}
if (!Number.isFinite(updateIntervalValue)) {
const error = new Error('Update interval must be a number.');
error.statusCode = 400;
throw error;
}
return {
name: name,
apiUrl: parsedUrl.toString(),
updateIntervalValue: Math.floor(updateIntervalValue),
updateIntervalUnit: updateIntervalUnit
};
}
module.exports = {
fetchApiSourcesData: fetchApiSourcesData,
fetchApiSourceById: fetchApiSourceById,
fetchApiSourceResponse: fetchApiSourceResponse,
buildApiSourcePayload: buildApiSourcePayload
};
+16 -2
View File
@@ -1,10 +1,12 @@
const { fetchAdminData } = require('./admin');
const { fetchPlaylistById } = require('./playlists');
const { fetchApiSourcesData, fetchApiSourceById, fetchApiSourceResponse, buildApiSourcePayload } = require('./api-sources');
const { fetchRssFeedsData, fetchRssFeedById, fetchRssFeedItemsByFeedId, normalizeRssFeedItem, buildRssFeedPayload, fetchRssFeedItems, replaceRssFeedItems } = require('./rss-feeds');
const { slugify, uniqueScreenSlug, fetchScreenById, fetchScreenEditData } = require('./screens');
const { fetchTemplateById, fetchTemplatesData, extractTemplateRegions, buildTemplatePayload } = require('./templates');
const { fetchCanvasSizesData, fetchCanvasSizeById, buildCanvasSizePayload } = require('./canvas-sizes');
const { fetchSlideById, buildSlidePayload } = require('./slides');
const { parseJsonSafe } = require('./utils');
const { parseJsonSafe, fetchDuplicateName } = require('./utils');
module.exports = {
slugify,
@@ -12,6 +14,17 @@ module.exports = {
parseJsonSafe,
fetchAdminData,
fetchPlaylistById,
fetchApiSourcesData,
fetchApiSourceById,
fetchApiSourceResponse,
buildApiSourcePayload,
fetchRssFeedsData,
fetchRssFeedById,
fetchRssFeedItemsByFeedId,
normalizeRssFeedItem,
buildRssFeedPayload,
fetchRssFeedItems,
replaceRssFeedItems,
fetchScreenById,
fetchScreenEditData,
fetchTemplateById,
@@ -22,5 +35,6 @@ module.exports = {
buildCanvasSizePayload,
buildSlidePayload,
extractTemplateRegions,
buildTemplatePayload
buildTemplatePayload,
fetchDuplicateName
};
+294
View File
@@ -0,0 +1,294 @@
const http = require('http');
const https = require('https');
function normalizeUpdateIntervalUnit(value) {
const unit = String(value || '').trim().toLowerCase();
return unit === 'seconds' ? 'seconds' : 'minutes';
}
async function fetchRssFeedsData(pool) {
const [rssFeeds] = await pool.query(
'SELECT id, name, feed_url, update_interval_value, update_interval_unit, item_limit, created_at, modified_at, created_by, modified_by FROM rss_feeds ORDER BY modified_at DESC, id DESC'
);
return { rssFeeds: rssFeeds };
}
async function fetchRssFeedById(pool, id) {
const [rows] = await pool.query(
'SELECT id, name, feed_url, update_interval_value, update_interval_unit, item_limit, created_at, modified_at, created_by, modified_by FROM rss_feeds WHERE id = ?',
[id]
);
return rows[0] || null;
}
async function fetchRssFeedItemsByFeedId(pool, rssFeedId) {
const [rows] = await pool.query(
`SELECT id, rss_feed_id, position, item_json, created_at, modified_at
FROM rss_feed_items
WHERE rss_feed_id = ?
ORDER BY position ASC, id ASC`,
[rssFeedId]
);
return rows.map(function (row) {
return normalizeRssFeedItem(row);
});
}
function normalizeRssFeedItem(row) {
let itemJson = null;
if (row && row.item_json) {
try {
itemJson = JSON.parse(row.item_json);
} catch (_error) {
itemJson = null;
}
}
return Object.assign({}, row || {}, itemJson || {}, {
itemJson: itemJson
});
}
async function loadUrlText(urlValue) {
if (typeof fetch === 'function') {
const response = await fetch(urlValue, {
headers: {
Accept: 'application/rss+xml, application/xml, text/xml;q=0.9, */*;q=0.8',
'User-Agent': 'Pulse Signage RSS Reader'
}
});
if (!response.ok) {
throw new Error(`Unable to load RSS feed (${response.status}).`);
}
return await response.text();
}
return await new Promise(function (resolve, reject) {
const url = new URL(urlValue);
const transport = url.protocol === 'https:' ? https : http;
const request = transport.get(url, {
headers: {
Accept: 'application/rss+xml, application/xml, text/xml;q=0.9, */*;q=0.8',
'User-Agent': 'Pulse Signage RSS Reader'
}
}, function (response) {
if (response.statusCode && response.statusCode >= 400) {
reject(new Error(`Unable to load RSS feed (${response.statusCode}).`));
response.resume();
return;
}
response.setEncoding('utf8');
let body = '';
response.on('data', function (chunk) {
body += chunk;
});
response.on('end', function () {
resolve(body);
});
});
request.on('error', reject);
});
}
function decodeXmlEntities(value) {
return String(value || '')
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&apos;/g, "'");
}
function stripCdata(value) {
return String(value || '').replace(/^<!\[CDATA\[|\]\]>$/g, '');
}
function escapeRegExp(value) {
return String(value || '').replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function extractXmlTag(content, tagName) {
const safeTagName = escapeRegExp(tagName);
const pattern = new RegExp(`<${safeTagName}(?:\\s[^>]*)?>([\\s\\S]*?)<\/${safeTagName}>`, 'i');
const match = pattern.exec(content || '');
if (!match) {
return '';
}
return decodeXmlEntities(stripCdata(match[1]).trim());
}
function extractAtomLink(content) {
const linkMatch = /<link\b[^>]*rel=["']alternate["'][^>]*href=["']([^"']+)["'][^>]*\/>/i.exec(content || '')
|| /<link\b[^>]*href=["']([^"']+)["'][^>]*rel=["']alternate["'][^>]*\/>/i.exec(content || '')
|| /<link\b[^>]*href=["']([^"']+)["'][^>]*>/i.exec(content || '');
if (!linkMatch) {
return '';
}
return decodeXmlEntities(String(linkMatch[1] || '').trim());
}
function parseRssItems(xmlText, itemLimit) {
const normalizedXml = String(xmlText || '');
const itemMatches = Array.from(normalizedXml.matchAll(/<item\b[\s\S]*?<\/item>|<entry\b[\s\S]*?<\/entry>/gi)).map(function (match) {
return String(match[0] || '');
});
if (!itemMatches.length) {
return [];
}
return itemMatches.slice(0, Math.max(1, Number(itemLimit) || 1)).map(function (itemXml, index) {
const title = extractXmlTag(itemXml, 'title') || `Item ${index + 1}`;
const link = extractXmlTag(itemXml, 'link') || extractAtomLink(itemXml);
const pubDate = extractXmlTag(itemXml, 'pubDate') || extractXmlTag(itemXml, 'updated') || extractXmlTag(itemXml, 'dc:date');
const description = extractXmlTag(itemXml, 'description') || extractXmlTag(itemXml, 'summary') || extractXmlTag(itemXml, 'content:encoded');
const author = extractXmlTag(itemXml, 'author') || extractXmlTag(itemXml, 'dc:creator');
const comments = extractXmlTag(itemXml, 'comments');
const guidMatch = /<guid\b([^>]*)>([\s\S]*?)<\/guid>/i.exec(itemXml || '');
const guid = guidMatch ? decodeXmlEntities(stripCdata(String(guidMatch[2] || '').trim())) : '';
const guidIsPermaLinkMatch = guidMatch && /\bisPermaLink\s*=\s*(["']?)(true|false)\1/i.exec(String(guidMatch[1] || ''));
const categories = Array.from(String(itemXml || '').matchAll(/<category\b([^>]*)>([\s\S]*?)<\/category>/gi)).map(function (match) {
const categoryAttributes = String(match[1] || '');
const categoryDomainMatch = /\bdomain\s*=\s*(["'])([^"']+)\1/i.exec(categoryAttributes);
return {
value: decodeXmlEntities(stripCdata(String(match[2] || '').trim())),
domain: categoryDomainMatch ? decodeXmlEntities(String(categoryDomainMatch[2] || '').trim()) : ''
};
}).filter(function (category) {
return Boolean(category.value);
});
const enclosureMatch = /<enclosure\b([^>]*)\/?>(?:\s*)/i.exec(itemXml || '');
const enclosureUrlMatch = enclosureMatch ? /\burl\s*=\s*(["'])([^"']+)\1/i.exec(String(enclosureMatch[1] || '')) : null;
const enclosureLengthMatch = enclosureMatch ? /\blength\s*=\s*(["'])([^"']+)\1/i.exec(String(enclosureMatch[1] || '')) : null;
const enclosureTypeMatch = enclosureMatch ? /\btype\s*=\s*(["'])([^"']+)\1/i.exec(String(enclosureMatch[1] || '')) : null;
const sourceMatch = /<source\b([^>]*)>([\s\S]*?)<\/source>/i.exec(itemXml || '');
const sourceUrlMatch = sourceMatch ? /\burl\s*=\s*(["'])([^"']+)\1/i.exec(String(sourceMatch[1] || '')) : null;
const parsedItem = {
title: title,
link: link,
description: description,
author: author,
comments: comments,
guid: guid,
guidIsPermaLink: guidIsPermaLinkMatch ? String(guidIsPermaLinkMatch[2] || '').toLowerCase() === 'true' : null,
pubDate: pubDate,
categories: categories,
enclosure: enclosureMatch ? {
url: enclosureUrlMatch ? decodeXmlEntities(String(enclosureUrlMatch[2] || '').trim()) : '',
length: enclosureLengthMatch ? Number(enclosureLengthMatch[2]) || null : null,
type: enclosureTypeMatch ? decodeXmlEntities(String(enclosureTypeMatch[2] || '').trim()) : ''
} : null,
source: sourceMatch ? {
title: decodeXmlEntities(stripCdata(String(sourceMatch[2] || '').trim())),
url: sourceUrlMatch ? decodeXmlEntities(String(sourceUrlMatch[2] || '').trim()) : ''
} : null,
rawXml: itemXml
};
return parsedItem;
});
}
async function fetchRssFeedItems(feedUrl, itemLimit) {
const xmlText = await loadUrlText(feedUrl);
return parseRssItems(xmlText, itemLimit);
}
async function replaceRssFeedItems(connection, rssFeedId, items) {
const normalizedItems = Array.isArray(items) ? items : [];
await connection.query('DELETE FROM rss_feed_items WHERE rss_feed_id = ?', [rssFeedId]);
if (!normalizedItems.length) {
return 0;
}
const insertValues = normalizedItems.map(function (item, index) {
return [
rssFeedId,
index + 1,
JSON.stringify(item || {})
];
});
await connection.query(
'INSERT INTO rss_feed_items (rss_feed_id, position, item_json) VALUES ?',
[insertValues]
);
return insertValues.length;
}
function buildRssFeedPayload(req, existingRssFeed) {
const fallback = existingRssFeed || {};
const name = String(req.body.name || fallback.name || '').trim();
const feedUrl = String(req.body.feed_url || req.body.feedUrl || fallback.feed_url || '').trim();
const updateIntervalValue = Math.max(1, Number(req.body.update_interval_value || req.body.updateIntervalValue || fallback.update_interval_value || 60));
const updateIntervalUnit = normalizeUpdateIntervalUnit(req.body.update_interval_unit || req.body.updateIntervalUnit || fallback.update_interval_unit || 'minutes');
const itemLimit = Math.max(1, Number(req.body.item_limit || req.body.itemLimit || fallback.item_limit || 1));
if (!name) {
const error = new Error('RSS feed name is required.');
error.statusCode = 400;
throw error;
}
if (!feedUrl) {
const error = new Error('RSS feed URL is required.');
error.statusCode = 400;
throw error;
}
let parsedUrl;
try {
parsedUrl = new URL(feedUrl);
} catch (_error) {
const error = new Error('Enter a valid RSS feed URL.');
error.statusCode = 400;
throw error;
}
if (parsedUrl.protocol !== 'http:' && parsedUrl.protocol !== 'https:') {
const error = new Error('RSS feed URL must start with http or https.');
error.statusCode = 400;
throw error;
}
if (!Number.isFinite(updateIntervalValue)) {
const error = new Error('Update interval must be a number.');
error.statusCode = 400;
throw error;
}
if (!Number.isFinite(itemLimit)) {
const error = new Error('Item count must be a number.');
error.statusCode = 400;
throw error;
}
return {
name: name,
feedUrl: parsedUrl.toString(),
updateIntervalValue: Math.floor(updateIntervalValue),
updateIntervalUnit: updateIntervalUnit,
itemLimit: Math.floor(itemLimit)
};
}
module.exports = {
fetchRssFeedsData,
fetchRssFeedById,
fetchRssFeedItemsByFeedId,
normalizeRssFeedItem,
buildRssFeedPayload,
fetchRssFeedItems,
replaceRssFeedItems
};
+54 -2
View File
@@ -2,6 +2,7 @@ const { fetchTemplateById } = require('./templates');
const { parseJsonSafe } = require('./utils');
const ALLOWED_RICH_TEXT_TAGS = ['b', 'strong', 'i', 'em', 'u', 'br', 'p', 'div', 'ul', 'ol', 'li'];
const DEFAULT_FONT_SIZE = 32;
function sanitizeRichText(html) {
let output = String(html || '');
@@ -60,10 +61,19 @@ function sanitizeTextColor(value, fallback) {
return fallback || '#000000';
}
function sanitizeFontSize(value, fallback) {
const raw = String(value || '').trim();
const parsed = Math.round(Number(raw.replace(/[^0-9.]/g, '')));
if (Number.isFinite(parsed) && parsed > 0) {
return parsed;
}
return Math.max(8, Number(fallback || DEFAULT_FONT_SIZE));
}
function getTextRegionStyle(body, region, existingContent) {
const existing = existingContent && existingContent[region.region_key] ? existingContent[region.region_key] : {};
const fontFamily = String(body[`region_font_family_${region.id}`] || existing.font_family || region.font_family || 'Arial').trim() || 'Arial';
const fontSize = Math.max(8, Number(body[`region_font_size_${region.id}`] || existing.font_size || 24));
const fontSize = sanitizeFontSize(body[`region_font_size_${region.id}`], existing.font_size || DEFAULT_FONT_SIZE);
const fontColor = sanitizeTextColor(body[`region_font_color_${region.id}`] || existing.font_color || region.font_color || '#000000');
return {
font_family: fontFamily,
@@ -80,7 +90,7 @@ function buildTemplateContent(template, body, filesByField, existingContent) {
const existing = body[`existing_region_image_${region.id}`];
content[region.region_key] = {
type: 'image',
value: uploaded ? `/uploads/${uploaded.filename}` : String(existing || '').trim() || ((existingContent && existingContent[region.region_key]) ? existingContent[region.region_key].value : '')
value: uploaded ? `/media/${uploaded.filename}` : String(existing || '').trim() || ((existingContent && existingContent[region.region_key]) ? existingContent[region.region_key].value : '')
};
} else if (region.region_type === 'webpage') {
const submitted = body[`region_webpage_${region.id}`];
@@ -89,6 +99,14 @@ function buildTemplateContent(template, body, filesByField, existingContent) {
type: 'webpage',
value: submitted === undefined ? current : String(submitted || '').trim()
};
} else if (region.region_type === 'rtmp') {
const submitted = body[`region_rtmp_${region.id}`];
const current = existingContent && existingContent[region.region_key] ? existingContent[region.region_key] : {};
content[region.region_key] = {
type: 'rtmp',
value: submitted === undefined ? String(current.value || '').trim() : String(submitted || '').trim(),
disable_audio: Boolean(body[`region_disable_audio_${region.id}`])
};
} else if (region.region_type === 'html') {
const submitted = body[`region_html_${region.id}`];
const current = existingContent && existingContent[region.region_key] ? existingContent[region.region_key].value : '';
@@ -96,6 +114,40 @@ function buildTemplateContent(template, body, filesByField, existingContent) {
type: 'html',
value: submitted === undefined ? current : String(submitted || '')
};
} else if (region.region_type === 'rss') {
const submitted = body[`region_text_${region.id}`];
const current = existingContent && existingContent[region.region_key] ? existingContent[region.region_key] : {};
const style = getTextRegionStyle(body, region, existingContent);
const feedId = body[`region_rss_feed_id_${region.id}`];
const itemNumber = body[`region_rss_item_number_${region.id}`];
const parsedItemNumber = Math.max(1, Number(itemNumber || current.item_number || 1));
content[region.region_key] = {
type: 'rss',
value: submitted === undefined ? String(current.value || '') : String(submitted || ''),
feed_id: feedId === undefined || feedId === null || feedId === '' ? (current.feed_id || null) : Number(feedId),
item_number: Number.isFinite(parsedItemNumber) && parsedItemNumber > 0 ? parsedItemNumber : 1,
variable_name: 'item',
font_family: style.font_family,
font_size: style.font_size,
font_color: style.font_color
};
} else if (region.region_type === 'api') {
const submitted = body[`region_text_${region.id}`];
const current = existingContent && existingContent[region.region_key] ? existingContent[region.region_key] : {};
const style = getTextRegionStyle(body, region, existingContent);
const sourceId = body[`region_api_source_id_${region.id}`];
const itemNumber = body[`region_api_item_number_${region.id}`];
const parsedItemNumber = Math.max(1, Number(itemNumber || current.item_number || 1));
content[region.region_key] = {
type: 'api',
value: submitted === undefined ? String(current.value || '') : String(submitted || ''),
source_id: sourceId === undefined || sourceId === null || sourceId === '' ? (current.source_id || null) : Number(sourceId),
item_number: Number.isFinite(parsedItemNumber) && parsedItemNumber > 0 ? parsedItemNumber : 1,
variable_name: 'item',
font_family: style.font_family,
font_size: style.font_size,
font_color: style.font_color
};
} else {
const submitted = body[`region_text_${region.id}`];
const current = existingContent && existingContent[region.region_key] ? existingContent[region.region_key].value : '';
+31 -27
View File
@@ -1,6 +1,7 @@
const { parseJsonSafe, readFormArray } = require('./utils');
const ALLOWED_TEMPLATE_REGION_TYPES = ['text', 'image', 'webpage', 'html'];
const ALLOWED_TEMPLATE_REGION_TYPES = ['text', 'image', 'webpage', 'html', 'rtmp', 'rss', 'api'];
const FONT_FAMILY_REGION_TYPES = ['text', 'html', 'rss', 'api'];
function sanitizeBackgroundColor(value) {
const raw = String(value || '').trim();
@@ -15,6 +16,14 @@ function normalizeTemplateRegionType(value) {
return ALLOWED_TEMPLATE_REGION_TYPES.includes(rawType) ? rawType : 'text';
}
function normalizeTemplateRegionLockRatio(value) {
const rawRatio = String(value || '').trim();
if (!/^\d+\s*:\s*\d+$/.test(rawRatio)) {
return null;
}
return rawRatio.replace(/\s+/g, '');
}
function normalizeTemplateRegionName(value) {
return String(value || '').trim();
}
@@ -49,7 +58,7 @@ async function fetchTemplateById(pool, id) {
return null;
}
const template = templates[0];
const [regions] = await pool.query('SELECT id, template_id, region_key, region_type, label, font_family, x, y, width, height, z_index, created_at, modified_at, created_by, modified_by FROM slide_template_regions WHERE template_id = ? ORDER BY z_index ASC, id ASC', [id]);
const [regions] = await pool.query('SELECT id, template_id, region_key, region_type, label, font_family, lock_ratio, x, y, width, height, z_index, created_at, modified_at, created_by, modified_by FROM slide_template_regions WHERE template_id = ? ORDER BY z_index ASC, id ASC', [id]);
template.regions = regions;
return template;
}
@@ -62,7 +71,7 @@ async function fetchTemplatesData(pool) {
LEFT JOIN canvas_sizes cs ON cs.id = st.canvas_size_id
ORDER BY st.id DESC
`);
const [templateRegions] = await pool.query('SELECT id, template_id, region_key, region_type, label, font_family, x, y, width, height, z_index, created_at, modified_at, created_by, modified_by FROM slide_template_regions ORDER BY template_id ASC, z_index ASC, id ASC');
const [templateRegions] = await pool.query('SELECT id, template_id, region_key, region_type, label, font_family, lock_ratio, x, y, width, height, z_index, created_at, modified_at, created_by, modified_by FROM slide_template_regions ORDER BY template_id ASC, z_index ASC, id ASC');
return { templates, templateRegions };
}
@@ -71,17 +80,21 @@ function extractTemplateRegions(body) {
if (regionsJson) {
const parsed = parseJsonSafe(regionsJson);
if (Array.isArray(parsed)) {
return parsed.map((region) => ({
region_key: String(region.region_name || region.region_key || region.label || '').trim(),
region_type: normalizeTemplateRegionType(region.region_type),
label: String(region.region_name || region.label || region.region_key || '').trim(),
font_family: ['text', 'html'].includes(normalizeTemplateRegionType(region.region_type)) ? String(region.font_family || '').trim() || null : null,
x: Number(region.x || 0),
y: Number(region.y || 0),
width: Number(region.width || 100),
height: Number(region.height || 100),
z_index: Number(region.z_index || 0)
})).filter((region) => region.region_key && region.label);
return parsed.map((region) => {
const regionType = normalizeTemplateRegionType(region.region_type);
return {
region_key: String(region.region_name || region.region_key || region.label || '').trim(),
region_type: regionType,
label: String(region.region_name || region.label || region.region_key || '').trim(),
font_family: FONT_FAMILY_REGION_TYPES.includes(regionType) ? String(region.font_family || '').trim() || null : null,
lock_ratio: normalizeTemplateRegionLockRatio(region.lock_ratio),
x: Number(region.x || 0),
y: Number(region.y || 0),
width: Number(region.width || 100),
height: Number(region.height || 100),
z_index: Number(region.z_index || 0)
};
}).filter((region) => region.region_key && region.label);
}
}
@@ -89,6 +102,7 @@ function extractTemplateRegions(body) {
const names = readFormArray(body, 'region_name[]');
const labels = readFormArray(body, 'region_label[]');
const types = readFormArray(body, 'region_type[]');
const ratios = readFormArray(body, 'region_lock_ratio[]');
const xs = readFormArray(body, 'region_x[]');
const ys = readFormArray(body, 'region_y[]');
const widths = readFormArray(body, 'region_width[]');
@@ -108,7 +122,8 @@ function extractTemplateRegions(body) {
region_key: name,
region_type: regionType,
label: name,
font_family: ['text', 'html'].includes(regionType) ? String(fonts[i] || 'Arial').trim() || 'Arial' : null,
font_family: FONT_FAMILY_REGION_TYPES.includes(regionType) ? String(fonts[i] || 'Arial').trim() || 'Arial' : null,
lock_ratio: normalizeTemplateRegionLockRatio(ratios[i]),
x: Number(xs[i] || 0),
y: Number(ys[i] || 0),
width: Number(widths[i] || 100),
@@ -139,7 +154,7 @@ async function buildTemplatePayload(pool, req, existingTemplate) {
const removeBackgroundImage = Boolean(req.body.remove_background_image);
const backgroundColor = sanitizeBackgroundColor(req.body.background_color || (existingTemplate && existingTemplate.background_color));
const backgroundImagePath = backgroundImage
? `/uploads/${backgroundImage.filename}`
? `/media/${backgroundImage.filename}`
: removeBackgroundImage
? null
: String(req.body.existing_background_image_path || (existingTemplate && existingTemplate.background_image_path) || '').trim() || null;
@@ -171,17 +186,6 @@ async function buildTemplatePayload(pool, req, existingTemplate) {
error.statusCode = 400;
throw error;
}
regions = [{
region_key: 'region_1',
region_type: 'text',
label: 'Region 1',
font_family: 'Arial',
x: 120,
y: 120,
width: Math.max(200, Math.round(canvasWidth * 0.22)),
height: Math.max(120, Math.round(canvasHeight * 0.15)),
z_index: 1
}];
}
ensureUniqueTemplateRegionNames(regions);
+21 -1
View File
@@ -22,7 +22,27 @@ function readFormArray(body, key) {
return [body[key]];
}
async function fetchDuplicateName(pool, tableName, name, excludeId, columnName) {
const normalizedName = String(name || '').trim();
if (!normalizedName) {
return null;
}
const normalizedColumnName = String(columnName || 'name').trim() || 'name';
const params = [normalizedName];
let sql = `SELECT id, \`${normalizedColumnName}\` AS name FROM \`${tableName}\` WHERE LOWER(TRIM(\`${normalizedColumnName}\`)) = LOWER(TRIM(?))`;
if (excludeId !== undefined && excludeId !== null) {
sql += ' AND id <> ?';
params.push(excludeId);
}
sql += ' LIMIT 1';
const [rows] = await pool.query(sql, params);
return rows[0] || null;
}
module.exports = {
parseJsonSafe,
readFormArray
readFormArray,
fetchDuplicateName
};
-730
View File
@@ -1,730 +0,0 @@
const mysql = require('mysql2/promise');
const { hashPassword } = require('./auth');
const { PERMISSIONS, DEFAULT_ROLE, normalizePermissionKeys } = require('./rbac');
function createPool() {
return mysql.createPool({
host: process.env.DB_HOST || '127.0.0.1',
port: Number(process.env.DB_PORT || 3306),
user: process.env.DB_USER || 'signage_user',
password: process.env.DB_PASSWORD || 'signage_password',
database: process.env.DB_NAME || 'signage',
waitForConnections: true,
connectionLimit: 10,
namedPlaceholders: true
});
}
async function addColumnIfMissing(pool, tableName, columnName, columnDefinition) {
const [rows] = await pool.query(
`SELECT COUNT(*) AS column_count
FROM information_schema.columns
WHERE table_schema = DATABASE()
AND table_name = ?
AND column_name = ?`,
[tableName, columnName]
);
if (rows.length && Number(rows[0].column_count) > 0) {
return;
}
await pool.query(`ALTER TABLE \`${tableName}\` ADD COLUMN \`${columnName}\` ${columnDefinition}`);
}
async function dropColumnIfPresent(pool, tableName, columnName) {
const [rows] = await pool.query(
`SELECT COUNT(*) AS column_count
FROM information_schema.columns
WHERE table_schema = DATABASE()
AND table_name = ?
AND column_name = ?`,
[tableName, columnName]
);
if (!rows.length || Number(rows[0].column_count) === 0) {
return;
}
await pool.query(`ALTER TABLE \`${tableName}\` DROP COLUMN \`${columnName}\``);
}
async function addForeignKeyIfMissing(pool, tableName, columnName, constraintName, referencedTable, referencedColumn, onDeleteAction) {
const [rows] = await pool.query(
`SELECT COUNT(*) AS constraint_count
FROM information_schema.table_constraints
WHERE table_schema = DATABASE()
AND table_name = ?
AND constraint_name = ?`,
[tableName, constraintName]
);
if (rows.length && Number(rows[0].constraint_count) > 0) {
return;
}
await pool.query(
`ALTER TABLE \`${tableName}\`
ADD CONSTRAINT \`${constraintName}\`
FOREIGN KEY (\`${columnName}\`) REFERENCES \`${referencedTable}\`(\`${referencedColumn}\`)
ON DELETE ${onDeleteAction}
ON UPDATE CASCADE`
);
}
async function addUserAuditColumns(pool, tableName) {
await addColumnIfMissing(pool, tableName, 'created_by', 'INT NULL');
await addColumnIfMissing(pool, tableName, 'modified_by', 'INT NULL');
await pool.query(
`UPDATE \`${tableName}\` t
LEFT JOIN users created_user ON created_user.id = t.created_by
SET t.created_by = NULL
WHERE t.created_by IS NOT NULL
AND created_user.id IS NULL`
);
await pool.query(
`UPDATE \`${tableName}\` t
LEFT JOIN users modified_user ON modified_user.id = t.modified_by
SET t.modified_by = NULL
WHERE t.modified_by IS NOT NULL
AND modified_user.id IS NULL`
);
await addForeignKeyIfMissing(pool, tableName, 'created_by', `fk_${tableName}_created_by`, 'users', 'id', 'SET NULL');
await addForeignKeyIfMissing(pool, tableName, 'modified_by', `fk_${tableName}_modified_by`, 'users', 'id', 'SET NULL');
}
async function hasSingleColumnUniqueIndex(pool, tableName, columnName) {
const [rows] = await pool.query(
`SELECT INDEX_NAME, COUNT(*) AS column_count
FROM information_schema.statistics
WHERE table_schema = DATABASE()
AND table_name = ?
AND non_unique = 0
AND column_name = ?
GROUP BY INDEX_NAME`,
[tableName, columnName]
);
return (rows || []).some(function (row) {
return Number(row.column_count) === 1;
});
}
async function addUniqueIndexIfMissing(pool, tableName, columnName, indexName) {
const hasUniqueIndex = await hasSingleColumnUniqueIndex(pool, tableName, columnName);
if (hasUniqueIndex) {
return;
}
await pool.query(`ALTER TABLE \`${tableName}\` ADD UNIQUE KEY \`${indexName}\` (\`${columnName}\`)`);
}
async function dedupePermissionRows(pool) {
const [rows] = await pool.query('SELECT id, permission_key FROM permissions ORDER BY id ASC');
const canonicalIdByKey = new Map();
const duplicateRowsByKey = new Map();
for (const row of rows || []) {
const permissionKey = getPermissionKey(row);
const permissionId = Number(row.id);
if (!permissionKey || !Number.isInteger(permissionId) || permissionId <= 0) {
continue;
}
if (!canonicalIdByKey.has(permissionKey)) {
canonicalIdByKey.set(permissionKey, permissionId);
continue;
}
if (!duplicateRowsByKey.has(permissionKey)) {
duplicateRowsByKey.set(permissionKey, []);
}
duplicateRowsByKey.get(permissionKey).push(permissionId);
}
if (!duplicateRowsByKey.size) {
return;
}
for (const [permissionKey, duplicateIds] of duplicateRowsByKey.entries()) {
const canonicalId = canonicalIdByKey.get(permissionKey);
for (const duplicateId of duplicateIds) {
await pool.query(
'UPDATE IGNORE role_permissions SET permission_id = ? WHERE permission_id = ?',
[canonicalId, duplicateId]
);
}
}
const duplicateIds = [];
for (const duplicateList of duplicateRowsByKey.values()) {
duplicateIds.push.apply(duplicateIds, duplicateList);
}
if (duplicateIds.length) {
await pool.query('DELETE FROM permissions WHERE id IN (?)', [duplicateIds]);
}
}
async function pruneStaleOnboardingDevices(pool) {
await pool.query(
`DELETE FROM player_onboarding_devices
WHERE modified_at < (CURRENT_TIMESTAMP - INTERVAL 1 MINUTE)`
);
}
async function getTableColumnNames(pool, tableName) {
const [rows] = await pool.query(
`SELECT column_name
FROM information_schema.columns
WHERE table_schema = DATABASE()
AND table_name = ?`,
[tableName]
);
return new Set((rows || []).map(function (row) {
return String(row.COLUMN_NAME || row.column_name || '').trim().toLowerCase();
}).filter(Boolean));
}
function getPermissionKey(row) {
return String((row && row.permission_key) || '').trim().toLowerCase();
}
function getLegacyPermissionTargets(permissionKey) {
const normalizedKey = String(permissionKey || '').trim().toLowerCase();
const parts = normalizedKey.split('.');
if (parts.length !== 2) {
return [normalizedKey].filter(Boolean);
}
const sectionKey = parts[0];
const actionKey = parts[1];
if (normalizedKey === 'screens.allow') {
return ['clients.allow'];
}
if (actionKey === 'edit') {
return [`${sectionKey}.update`];
}
if (actionKey === 'update') {
return [`${sectionKey}.update`];
}
if (actionKey === 'view') {
return [`${sectionKey}.read`];
}
if (actionKey === 'manage') {
return [`${sectionKey}.read`, `${sectionKey}.create`, `${sectionKey}.update`, `${sectionKey}.delete`];
}
return [normalizedKey].filter(Boolean);
}
function buildPermissionSeedColumns(columnNames) {
const columns = [];
if (columnNames.has('permission_key')) {
columns.push('permission_key');
}
if (columnNames.has('name')) {
columns.push('name');
}
if (columnNames.has('section_name')) {
columns.push('section_name');
}
if (columnNames.has('description')) {
columns.push('description');
}
if (columnNames.has('created_by')) {
columns.push('created_by');
}
if (columnNames.has('modified_by')) {
columns.push('modified_by');
}
return columns;
}
async function backfillLegacyRbacSchema(pool) {
const permissionColumnNames = await getTableColumnNames(pool, 'permissions');
const [permissionRows] = await pool.query('SELECT id, permission_key, name, section_name FROM permissions ORDER BY id ASC');
const [rolePermissionRows] = await pool.query(
`SELECT rp.role_id, p.permission_key
FROM role_permissions rp
JOIN permissions p ON p.id = rp.permission_id`
);
const permissionIdByKey = new Map();
for (const row of permissionRows || []) {
const currentKey = getPermissionKey(row);
if (currentKey) {
permissionIdByKey.set(currentKey, Number(row.id));
}
}
const rolePermissionTargets = new Map();
const desiredPermissionKeys = new Set(PERMISSIONS.map(function (permission) {
return permission.key;
}));
const legacyPermissionRowIds = [];
function addRoleTarget(roleId, permissionKey) {
const normalizedPermissionKey = String(permissionKey || '').trim().toLowerCase();
if (!normalizedPermissionKey) {
return;
}
if (!rolePermissionTargets.has(roleId)) {
rolePermissionTargets.set(roleId, new Set());
}
rolePermissionTargets.get(roleId).add(normalizedPermissionKey);
}
for (const row of rolePermissionRows || []) {
const currentKey = getPermissionKey(row);
const targetKeys = getLegacyPermissionTargets(currentKey);
if (currentKey.endsWith('.view') || currentKey.endsWith('.manage') || currentKey.endsWith('.edit') || currentKey === 'screens.allow') {
for (const targetKey of targetKeys) {
addRoleTarget(Number(row.role_id), targetKey);
}
} else {
addRoleTarget(Number(row.role_id), currentKey);
}
}
for (const row of permissionRows || []) {
const currentKey = getPermissionKey(row);
const permissionId = Number(row.id);
if (currentKey.endsWith('.edit')) {
const targetKey = currentKey.replace(/\.edit$/, '.update');
const targetPermissionId = permissionIdByKey.get(targetKey);
if (targetPermissionId) {
legacyPermissionRowIds.push(permissionId);
} else if (targetKey) {
await pool.query('UPDATE permissions SET permission_key = ? WHERE id = ?', [targetKey, permissionId]);
permissionIdByKey.delete(currentKey);
permissionIdByKey.set(targetKey, permissionId);
}
continue;
}
if (currentKey.endsWith('.view') || currentKey.endsWith('.manage') || currentKey.endsWith('.edit') || currentKey === 'screens.allow') {
legacyPermissionRowIds.push(permissionId);
}
}
const roleRows = await pool.query('SELECT id, role_key, name FROM roles ORDER BY id ASC').then(function (result) {
return result[0] || [];
});
const defaultRoleRow = roleRows.find(function (row) {
return String(row.role_key || '').trim().toLowerCase() === DEFAULT_ROLE.key;
}) || null;
if (defaultRoleRow) {
if (!rolePermissionTargets.has(Number(defaultRoleRow.id))) {
rolePermissionTargets.set(Number(defaultRoleRow.id), new Set());
}
const defaultPermissions = rolePermissionTargets.get(Number(defaultRoleRow.id));
for (const permission of PERMISSIONS) {
defaultPermissions.add(permission.key);
}
}
const seedColumns = buildPermissionSeedColumns(permissionColumnNames);
if (!seedColumns.length) {
throw new Error('permissions table is missing required columns.');
}
for (const permission of PERMISSIONS) {
const seedValues = [];
if (permissionColumnNames.has('permission_key')) {
seedValues.push(permission.key);
}
if (permissionColumnNames.has('name')) {
seedValues.push(permission.name);
}
if (permissionColumnNames.has('section_name')) {
seedValues.push(permission.sectionName);
}
if (permissionColumnNames.has('description')) {
seedValues.push(permission.description || null);
}
if (permissionColumnNames.has('created_by')) {
seedValues.push(null);
}
if (permissionColumnNames.has('modified_by')) {
seedValues.push(null);
}
const updateAssignments = [];
if (permissionColumnNames.has('name')) {
updateAssignments.push('name = VALUES(name)');
}
if (permissionColumnNames.has('section_name')) {
updateAssignments.push('section_name = VALUES(section_name)');
}
if (permissionColumnNames.has('description')) {
updateAssignments.push('description = VALUES(description)');
}
if (permissionColumnNames.has('permission_key')) {
updateAssignments.push('permission_key = VALUES(permission_key)');
}
await pool.query(
`INSERT INTO permissions (${seedColumns.join(', ')})
VALUES (${seedColumns.map(function () { return '?'; }).join(', ')})
ON DUPLICATE KEY UPDATE ${updateAssignments.join(', ')}`,
seedValues
);
}
if (legacyPermissionRowIds.length) {
await pool.query('DELETE FROM permissions WHERE id IN (?)', [legacyPermissionRowIds]);
}
const [currentPermissionRows] = await pool.query('SELECT id, permission_key FROM permissions');
const permissionIdByKeyAfterBackfill = new Map();
for (const row of currentPermissionRows || []) {
const currentKey = getPermissionKey(row);
if (currentKey) {
permissionIdByKeyAfterBackfill.set(currentKey, Number(row.id));
}
}
await pool.query('DELETE FROM role_permissions');
for (const [roleId, permissionKeys] of rolePermissionTargets.entries()) {
const expandedPermissionKeys = normalizePermissionKeys(Array.from(permissionKeys.values()));
for (const permissionKey of expandedPermissionKeys) {
const permissionId = permissionIdByKeyAfterBackfill.get(permissionKey);
if (!permissionId) {
continue;
}
await pool.query(
'INSERT IGNORE INTO role_permissions (role_id, permission_id, created_by, modified_by) VALUES (?, ?, ?, ?)',
[Number(roleId), permissionId, null, null]
);
}
}
const [roleRowsAfter] = await pool.query('SELECT id, role_key, name FROM roles ORDER BY id ASC');
for (const row of roleRowsAfter || []) {
const currentKey = String(row.role_key || '').trim();
const isAdministratorsRole = String(row.name || '').trim().toLowerCase() === DEFAULT_ROLE.name.toLowerCase();
const expectedKey = isAdministratorsRole ? DEFAULT_ROLE.key : `role-${row.id}`;
if (!currentKey || currentKey !== expectedKey) {
await pool.query(
'UPDATE roles SET role_key = ?, name = ?, description = COALESCE(description, ?) WHERE id = ?',
[expectedKey, String(row.name || '').trim() || DEFAULT_ROLE.name, DEFAULT_ROLE.description || null, row.id]
);
}
}
}
async function ensureSchema(pool) {
await pool.query(`
CREATE TABLE IF NOT EXISTS canvas_sizes (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(255) NOT NULL,
width INT NOT NULL,
height INT NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uq_canvas_sizes_dimensions (width, height)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
await addColumnIfMissing(pool, 'canvas_sizes', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
await addUserAuditColumns(pool, 'canvas_sizes');
await pool.query(`
CREATE TABLE IF NOT EXISTS playlists (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(255) NOT NULL,
fade_between_slides TINYINT(1) NOT NULL DEFAULT 0,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
await addColumnIfMissing(pool, 'playlists', 'fade_between_slides', 'TINYINT(1) NOT NULL DEFAULT 0');
await addColumnIfMissing(pool, 'playlists', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
await addUserAuditColumns(pool, 'playlists');
await pool.query(`
CREATE TABLE IF NOT EXISTS slide_templates (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(255) NOT NULL,
canvas_size_id INT NULL,
background_image_path VARCHAR(512) NULL,
background_color VARCHAR(32) NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
await addColumnIfMissing(pool, 'slide_templates', 'canvas_size_id', 'INT NULL');
await addColumnIfMissing(pool, 'slide_templates', 'background_image_path', 'VARCHAR(512) NULL');
await addColumnIfMissing(pool, 'slide_templates', 'background_color', 'VARCHAR(32) NULL');
await addColumnIfMissing(pool, 'slide_templates', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
await addUserAuditColumns(pool, 'slide_templates');
await pool.query(`
INSERT IGNORE INTO canvas_sizes (name, width, height) VALUES
('Full HD', 1920, 1080),
('HD', 1280, 720),
('4K UHD', 3840, 2160),
('Portrait Full HD', 1080, 1920),
('Portrait HD', 720, 1280)
`);
const [legacyTemplateColumns] = await pool.query(`
SELECT COUNT(*) AS column_count
FROM information_schema.columns
WHERE table_schema = DATABASE()
AND table_name = 'slide_templates'
AND column_name IN ('canvas_width', 'canvas_height')
`);
if (legacyTemplateColumns[0] && Number(legacyTemplateColumns[0].column_count) === 2) {
await pool.query(`
UPDATE slide_templates st
JOIN canvas_sizes cs ON cs.width = st.canvas_width AND cs.height = st.canvas_height
SET st.canvas_size_id = cs.id
WHERE st.canvas_size_id IS NULL
`);
}
await pool.query(`
CREATE TABLE IF NOT EXISTS slide_template_regions (
id INT AUTO_INCREMENT PRIMARY KEY,
template_id INT NOT NULL,
region_key VARCHAR(100) NOT NULL,
region_type VARCHAR(20) NOT NULL,
label VARCHAR(255) NOT NULL,
x INT NOT NULL DEFAULT 0,
y INT NOT NULL DEFAULT 0,
width INT NOT NULL DEFAULT 100,
height INT NOT NULL DEFAULT 100,
z_index INT NOT NULL DEFAULT 0,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
await addColumnIfMissing(pool, 'slide_template_regions', 'font_family', 'VARCHAR(100) NULL');
await addColumnIfMissing(pool, 'slide_template_regions', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
await addUserAuditColumns(pool, 'slide_template_regions');
await pool.query(`
CREATE TABLE IF NOT EXISTS slides (
id INT AUTO_INCREMENT PRIMARY KEY,
title VARCHAR(255) NOT NULL,
body TEXT NULL,
template_id INT NULL,
content_json JSON NULL,
media_path VARCHAR(512) NULL,
media_type VARCHAR(100) NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
await addColumnIfMissing(pool, 'slides', 'body', 'TEXT NULL');
await addColumnIfMissing(pool, 'slides', 'template_id', 'INT NULL');
await addColumnIfMissing(pool, 'slides', 'content_json', 'JSON NULL');
await addColumnIfMissing(pool, 'slides', 'media_path', 'VARCHAR(512) NULL');
await addColumnIfMissing(pool, 'slides', 'media_type', 'VARCHAR(100) NULL');
await addColumnIfMissing(pool, 'slides', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
await addUserAuditColumns(pool, 'slides');
await pool.query(`
CREATE TABLE IF NOT EXISTS playlist_slides (
id INT AUTO_INCREMENT PRIMARY KEY,
playlist_id INT NOT NULL,
slide_id INT NOT NULL,
position INT NOT NULL DEFAULT 0,
duration_seconds INT NOT NULL DEFAULT 10,
schedule_mode VARCHAR(20) NOT NULL DEFAULT 'always',
schedule_start_datetime DATETIME NULL,
schedule_end_datetime DATETIME NULL,
schedule_start_time TIME NULL,
schedule_end_time TIME NULL,
schedule_days_json JSON NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT fk_playlist_slides_playlist FOREIGN KEY (playlist_id) REFERENCES playlists(id) ON DELETE CASCADE,
CONSTRAINT fk_playlist_slides_slide FOREIGN KEY (slide_id) REFERENCES slides(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
await addColumnIfMissing(pool, 'playlist_slides', 'duration_seconds', 'INT NOT NULL DEFAULT 10');
await addColumnIfMissing(pool, 'playlist_slides', 'schedule_mode', "VARCHAR(20) NOT NULL DEFAULT 'always'");
await addColumnIfMissing(pool, 'playlist_slides', 'schedule_start_datetime', 'DATETIME NULL');
await addColumnIfMissing(pool, 'playlist_slides', 'schedule_end_datetime', 'DATETIME NULL');
await addColumnIfMissing(pool, 'playlist_slides', 'schedule_start_time', 'TIME NULL');
await addColumnIfMissing(pool, 'playlist_slides', 'schedule_end_time', 'TIME NULL');
await addColumnIfMissing(pool, 'playlist_slides', 'schedule_days_json', 'JSON NULL');
await addColumnIfMissing(pool, 'playlist_slides', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
await addUserAuditColumns(pool, 'playlist_slides');
await pool.query(`
CREATE TABLE IF NOT EXISTS screens (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(255) NOT NULL,
slug VARCHAR(255) NOT NULL UNIQUE,
playlist_id INT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT fk_screens_playlist FOREIGN KEY (playlist_id) REFERENCES playlists(id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
await addColumnIfMissing(pool, 'screens', 'playlist_id', 'INT NULL');
await addColumnIfMissing(pool, 'screens', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
await addUserAuditColumns(pool, 'screens');
await pool.query(`
CREATE TABLE IF NOT EXISTS player_onboarding_devices (
device_id VARCHAR(128) PRIMARY KEY,
client_name VARCHAR(255) NULL,
screen_id INT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT fk_player_onboarding_devices_screen FOREIGN KEY (screen_id) REFERENCES screens(id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
await addColumnIfMissing(pool, 'player_onboarding_devices', 'client_name', 'VARCHAR(255) NULL');
await addColumnIfMissing(pool, 'player_onboarding_devices', 'screen_id', 'INT NULL');
await addColumnIfMissing(pool, 'player_onboarding_devices', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
await addUserAuditColumns(pool, 'player_onboarding_devices');
await pool.query(`
CREATE TABLE IF NOT EXISTS users (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(255) NULL,
username VARCHAR(255) NOT NULL UNIQUE,
password_hash CHAR(64) NOT NULL,
password_salt VARCHAR(64) NOT NULL,
password_iterations INT NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
await addColumnIfMissing(pool, 'users', 'name', 'VARCHAR(255) NULL');
await addColumnIfMissing(pool, 'users', 'password_hash', 'CHAR(64) NOT NULL');
await addColumnIfMissing(pool, 'users', 'password_salt', 'VARCHAR(64) NOT NULL');
await addColumnIfMissing(pool, 'users', 'password_iterations', 'INT NOT NULL');
await addColumnIfMissing(pool, 'users', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
await addUserAuditColumns(pool, 'users');
await pool.query(`
CREATE TABLE IF NOT EXISTS roles (
id INT AUTO_INCREMENT PRIMARY KEY,
role_key VARCHAR(100) NOT NULL UNIQUE,
name VARCHAR(255) NOT NULL,
description TEXT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
await addColumnIfMissing(pool, 'roles', 'role_key', 'VARCHAR(100) NULL');
await addColumnIfMissing(pool, 'roles', 'description', 'TEXT NULL');
await addColumnIfMissing(pool, 'roles', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
await addUserAuditColumns(pool, 'roles');
await pool.query(`
CREATE TABLE IF NOT EXISTS permissions (
id INT AUTO_INCREMENT PRIMARY KEY,
permission_key VARCHAR(100) NOT NULL UNIQUE,
name VARCHAR(255) NOT NULL,
section_name VARCHAR(255) NOT NULL,
description TEXT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
await addColumnIfMissing(pool, 'permissions', 'permission_key', 'VARCHAR(100) NULL');
await addColumnIfMissing(pool, 'permissions', 'name', 'VARCHAR(255) NULL');
await addColumnIfMissing(pool, 'permissions', 'section_name', 'VARCHAR(255) NULL');
await addColumnIfMissing(pool, 'permissions', 'description', 'TEXT NULL');
await addColumnIfMissing(pool, 'permissions', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
await addUserAuditColumns(pool, 'permissions');
await dropColumnIfPresent(pool, 'permissions', 'perm_key');
await dedupePermissionRows(pool);
await addUniqueIndexIfMissing(pool, 'permissions', 'permission_key', 'uq_permissions_permission_key');
await pool.query(`
CREATE TABLE IF NOT EXISTS role_permissions (
role_id INT NOT NULL,
permission_id INT NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (role_id, permission_id),
CONSTRAINT fk_role_permissions_role FOREIGN KEY (role_id) REFERENCES roles(id) ON DELETE CASCADE,
CONSTRAINT fk_role_permissions_permission FOREIGN KEY (permission_id) REFERENCES permissions(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
await addColumnIfMissing(pool, 'role_permissions', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
await addUserAuditColumns(pool, 'role_permissions');
await pool.query(`
CREATE TABLE IF NOT EXISTS user_roles (
user_id INT NOT NULL,
role_id INT NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (user_id, role_id),
CONSTRAINT fk_user_roles_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
CONSTRAINT fk_user_roles_role FOREIGN KEY (role_id) REFERENCES roles(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
await addColumnIfMissing(pool, 'user_roles', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
await addUserAuditColumns(pool, 'user_roles');
await pool.query(`
CREATE TABLE IF NOT EXISTS auth_sessions (
session_hash CHAR(64) PRIMARY KEY,
user_id INT NOT NULL,
expires_at DATETIME NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
last_used_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT fk_auth_sessions_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
await addUserAuditColumns(pool, 'auth_sessions');
const [userCountRows] = await pool.query('SELECT COUNT(*) AS user_count FROM users');
if (!userCountRows.length || Number(userCountRows[0].user_count) === 0) {
const username = String(process.env.DEFAULT_ADMIN_USERNAME || 'admin').trim() || 'admin';
const name = String(process.env.DEFAULT_ADMIN_NAME || 'Admin').trim() || 'Admin';
const password = String(process.env.DEFAULT_ADMIN_PASSWORD || 'admin').trim() || 'admin';
const passwordRecord = hashPassword(password);
await pool.query(
'INSERT INTO users (name, username, password_hash, password_salt, password_iterations, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?)',
[name, username, passwordRecord.hash, passwordRecord.salt, passwordRecord.iterations, null, null]
);
}
await pool.query('UPDATE users SET name = username WHERE name IS NULL OR name = ""');
await pool.query(
`INSERT INTO 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]
);
await backfillLegacyRbacSchema(pool);
if (!userCountRows.length || Number(userCountRows[0].user_count) === 0) {
const [roleRows] = await pool.query('SELECT id FROM roles WHERE role_key = ? LIMIT 1', [DEFAULT_ROLE.key]);
const defaultRoleId = roleRows.length ? Number(roleRows[0].id) : null;
if (defaultRoleId) {
await pool.query(
`INSERT IGNORE INTO user_roles (user_id, role_id, created_by, modified_by)
SELECT id, ?, NULL, NULL FROM users`,
[defaultRoleId]
);
}
}
}
module.exports = {
createPool,
ensureSchema,
pruneStaleOnboardingDevices
};
+379
View File
@@ -0,0 +1,379 @@
const mysql = require('mysql2/promise');
const { hashPassword } = require('../auth');
const { PERMISSIONS, DEFAULT_ROLE } = require('../rbac');
const migrations = require('./migrations');
function createPool() {
return mysql.createPool({
host: process.env.DB_HOST || '127.0.0.1',
port: Number(process.env.DB_PORT || 3306),
user: process.env.DB_USER || 'signage_user',
password: process.env.DB_PASSWORD || 'signage_password',
database: process.env.DB_NAME || 'signage',
waitForConnections: true,
connectionLimit: 10,
namedPlaceholders: true
});
}
async function pruneStaleOnboardingDevices(pool) {
await pool.query(
`DELETE FROM player_onboarding_devices
WHERE modified_at < (CURRENT_TIMESTAMP - INTERVAL 1 MINUTE)`
);
}
async function ensureSchema(pool) {
await pool.query(`
CREATE TABLE IF NOT EXISTS canvas_sizes (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(255) NOT NULL,
width INT NOT NULL,
height INT NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
created_by INT NULL,
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
modified_by INT NULL,
UNIQUE KEY uq_canvas_sizes_dimensions (width, height)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
await pool.query(`
CREATE TABLE IF NOT EXISTS playlists (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(255) NOT NULL,
fade_between_slides TINYINT(1) NOT NULL DEFAULT 0,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
created_by INT NULL,
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
modified_by INT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
await pool.query(`
CREATE TABLE IF NOT EXISTS slide_templates (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(255) NOT NULL,
canvas_size_id INT NULL,
background_image_path VARCHAR(512) NULL,
background_color VARCHAR(32) NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
created_by INT NULL,
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
modified_by INT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
await pool.query(`
INSERT IGNORE INTO canvas_sizes (name, width, height) VALUES
('Full HD', 1920, 1080),
('HD', 1280, 720),
('4K UHD', 3840, 2160),
('Portrait Full HD', 1080, 1920),
('Portrait HD', 720, 1280)
`);
await pool.query(`
CREATE TABLE IF NOT EXISTS slide_template_regions (
id INT AUTO_INCREMENT PRIMARY KEY,
template_id INT NOT NULL,
region_key VARCHAR(100) NOT NULL,
region_type VARCHAR(20) NOT NULL,
label VARCHAR(255) NOT NULL,
lock_ratio VARCHAR(20) NULL,
x INT NOT NULL DEFAULT 0,
y INT NOT NULL DEFAULT 0,
width INT NOT NULL DEFAULT 100,
height INT NOT NULL DEFAULT 100,
z_index INT NOT NULL DEFAULT 0,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
created_by INT NULL,
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
modified_by INT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
await pool.query(`
CREATE TABLE IF NOT EXISTS slides (
id INT AUTO_INCREMENT PRIMARY KEY,
title VARCHAR(255) NOT NULL,
body TEXT NULL,
template_id INT NULL,
content_json JSON NULL,
media_path VARCHAR(512) NULL,
media_type VARCHAR(100) NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
created_by INT NULL,
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
modified_by INT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
await pool.query(`
CREATE TABLE IF NOT EXISTS playlist_slides (
id INT AUTO_INCREMENT PRIMARY KEY,
playlist_id INT NOT NULL,
slide_id INT NOT NULL,
position INT NOT NULL DEFAULT 0,
duration_seconds INT NOT NULL DEFAULT 10,
schedule_mode VARCHAR(20) NOT NULL DEFAULT 'always',
schedule_start_datetime DATETIME NULL,
schedule_end_datetime DATETIME NULL,
schedule_start_time TIME NULL,
schedule_end_time TIME NULL,
schedule_days_json JSON NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
created_by INT NULL,
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
modified_by INT NULL,
CONSTRAINT fk_playlist_slides_playlist FOREIGN KEY (playlist_id) REFERENCES playlists(id) ON DELETE CASCADE,
CONSTRAINT fk_playlist_slides_slide FOREIGN KEY (slide_id) REFERENCES slides(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
await pool.query(`
CREATE TABLE IF NOT EXISTS screens (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(255) NOT NULL,
slug VARCHAR(255) NOT NULL UNIQUE,
playlist_id INT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
created_by INT NULL,
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
modified_by INT NULL,
CONSTRAINT fk_screens_playlist FOREIGN KEY (playlist_id) REFERENCES playlists(id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
await pool.query(`
CREATE TABLE IF NOT EXISTS rss_feeds (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(255) NOT NULL,
feed_url VARCHAR(1024) NOT NULL,
update_interval_value INT NOT NULL DEFAULT 60,
update_interval_unit VARCHAR(10) NOT NULL DEFAULT 'minutes',
item_limit INT NOT NULL DEFAULT 1,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
created_by INT NULL,
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
modified_by INT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
await pool.query(`
CREATE TABLE IF NOT EXISTS rss_feed_items (
id INT AUTO_INCREMENT PRIMARY KEY,
rss_feed_id INT NOT NULL,
position INT NOT NULL,
item_json MEDIUMTEXT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
created_by INT NULL,
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
modified_by INT NULL,
CONSTRAINT fk_rss_feed_items_rss_feed FOREIGN KEY (rss_feed_id) REFERENCES rss_feeds(id) ON DELETE CASCADE,
UNIQUE KEY uq_rss_feed_items_feed_position (rss_feed_id, position)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
await pool.query(`
CREATE TABLE IF NOT EXISTS api_sources (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(255) NOT NULL,
api_url VARCHAR(1024) NOT NULL,
update_interval_value INT NOT NULL DEFAULT 60,
update_interval_unit VARCHAR(10) NOT NULL DEFAULT 'minutes',
last_pulled_at TIMESTAMP NULL,
last_pull_error MEDIUMTEXT NULL,
last_response_status INT NULL,
last_response_content_type VARCHAR(255) NULL,
last_response_json MEDIUMTEXT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
created_by INT NULL,
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
modified_by INT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
await pool.query(`
CREATE TABLE IF NOT EXISTS player_onboarding_devices (
device_id VARCHAR(128) PRIMARY KEY,
client_name VARCHAR(255) NULL,
screen_id INT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
created_by INT NULL,
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
modified_by INT NULL,
CONSTRAINT fk_player_onboarding_devices_screen FOREIGN KEY (screen_id) REFERENCES screens(id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
await pool.query(`
CREATE TABLE IF NOT EXISTS users (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(255) NULL,
username VARCHAR(255) NOT NULL UNIQUE,
password_hash CHAR(64) NOT NULL,
password_salt VARCHAR(64) NOT NULL,
password_iterations INT NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
created_by INT NULL,
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
modified_by INT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
await pool.query(`
CREATE TABLE IF NOT EXISTS roles (
id INT AUTO_INCREMENT PRIMARY KEY,
role_key VARCHAR(100) NOT NULL UNIQUE,
name VARCHAR(255) NOT NULL,
description TEXT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
created_by INT NULL,
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
modified_by INT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
await pool.query(`
CREATE TABLE IF NOT EXISTS permissions (
id INT AUTO_INCREMENT PRIMARY KEY,
permission_key VARCHAR(100) NOT NULL UNIQUE,
name VARCHAR(255) NOT NULL,
section_name VARCHAR(255) NOT NULL,
description TEXT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
created_by INT NULL,
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
modified_by INT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
for (const permission of PERMISSIONS) {
await pool.query(
`INSERT INTO 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]
);
}
await pool.query(`
CREATE TABLE IF NOT EXISTS role_permissions (
role_id INT NOT NULL,
permission_id INT NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
created_by INT NULL,
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
modified_by INT NULL,
PRIMARY KEY (role_id, permission_id),
CONSTRAINT fk_role_permissions_role FOREIGN KEY (role_id) REFERENCES roles(id) ON DELETE CASCADE,
CONSTRAINT fk_role_permissions_permission FOREIGN KEY (permission_id) REFERENCES permissions(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
await pool.query(`
CREATE TABLE IF NOT EXISTS user_roles (
user_id INT NOT NULL,
role_id INT NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
created_by INT NULL,
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
modified_by INT NULL,
PRIMARY KEY (user_id, role_id),
CONSTRAINT fk_user_roles_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
CONSTRAINT fk_user_roles_role FOREIGN KEY (role_id) REFERENCES roles(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
await pool.query(`
CREATE TABLE IF NOT EXISTS auth_sessions (
session_hash CHAR(64) PRIMARY KEY,
user_id INT NOT NULL,
expires_at DATETIME NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
created_by INT NULL,
last_used_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
modified_by INT NULL,
CONSTRAINT fk_auth_sessions_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
await pool.query(`
CREATE TABLE IF NOT EXISTS background_tasks (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
task_key VARCHAR(191) NULL,
task_type VARCHAR(100) NOT NULL,
title VARCHAR(255) NOT NULL,
category VARCHAR(100) NOT NULL DEFAULT 'general',
status VARCHAR(20) NOT NULL,
payload_json MEDIUMTEXT NULL,
metadata_json MEDIUMTEXT NULL,
attempts INT NOT NULL DEFAULT 0,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
started_at TIMESTAMP NULL,
finished_at TIMESTAMP NULL,
error_message MEDIUMTEXT NULL,
INDEX idx_background_tasks_status (status),
INDEX idx_background_tasks_key (task_key),
INDEX idx_background_tasks_type (task_type)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
const [userCountRows] = await pool.query('SELECT COUNT(*) AS user_count FROM users');
const username = String(process.env.DEFAULT_ADMIN_USERNAME || 'admin').trim() || 'admin';
if (!userCountRows.length || Number(userCountRows[0].user_count) === 0) {
const name = String(process.env.DEFAULT_ADMIN_NAME || 'Admin').trim() || 'Admin';
const password = String(process.env.DEFAULT_ADMIN_PASSWORD || 'admin').trim() || 'admin';
const passwordRecord = hashPassword(password);
await pool.query(
'INSERT INTO users (name, username, password_hash, password_salt, password_iterations, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?)',
[name, username, passwordRecord.hash, passwordRecord.salt, passwordRecord.iterations, null, null]
);
}
await pool.query('UPDATE users SET name = username WHERE name IS NULL OR name = ""');
await pool.query(
`INSERT INTO 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]
);
await migrations.runMigrations(pool);
if (!userCountRows.length || Number(userCountRows[0].user_count) === 0) {
const [roleRows] = await pool.query('SELECT id FROM roles WHERE role_key = ? LIMIT 1', [DEFAULT_ROLE.key]);
const defaultRoleId = roleRows.length ? Number(roleRows[0].id) : null;
if (defaultRoleId) {
await pool.query(
`INSERT IGNORE INTO user_roles (user_id, role_id, created_by, modified_by)
SELECT id, ?, NULL, NULL FROM users`,
[defaultRoleId]
);
}
}
const [defaultAdminRows] = await pool.query('SELECT id FROM users WHERE username = ? LIMIT 1', [username]);
if (defaultAdminRows.length) {
const defaultAdminId = Number(defaultAdminRows[0].id);
const [defaultAdminRoleRows] = await pool.query('SELECT COUNT(*) AS role_count FROM user_roles WHERE user_id = ?', [defaultAdminId]);
if (!defaultAdminRoleRows.length || Number(defaultAdminRoleRows[0].role_count) === 0) {
const [roleRows] = await pool.query('SELECT id FROM roles WHERE role_key = ? LIMIT 1', [DEFAULT_ROLE.key]);
const defaultRoleId = roleRows.length ? Number(roleRows[0].id) : null;
if (defaultRoleId) {
await pool.query(
'INSERT IGNORE INTO user_roles (user_id, role_id, created_by, modified_by) VALUES (?, ?, ?, ?)',
[defaultAdminId, defaultRoleId, null, null]
);
}
}
}
}
module.exports = {
createPool,
ensureSchema,
pruneStaleOnboardingDevices
};
+588
View File
@@ -0,0 +1,588 @@
const { version: appVersion } = require('../../package.json');
function parseVersion(value) {
const parts = String(value || '0.0.0').split('.').map(function (part) {
return Math.max(0, Number(part) || 0);
});
return {
major: parts[0] || 0,
minor: parts[1] || 0,
patch: parts[2] || 0
};
}
function compareVersions(left, right) {
const leftVersion = parseVersion(left);
const rightVersion = parseVersion(right);
if (leftVersion.major !== rightVersion.major) {
return leftVersion.major - rightVersion.major;
}
if (leftVersion.minor !== rightVersion.minor) {
return leftVersion.minor - rightVersion.minor;
}
if (leftVersion.patch !== rightVersion.patch) {
return leftVersion.patch - rightVersion.patch;
}
return 0;
}
async function addColumnIfMissing(pool, tableName, columnName, columnDefinition) {
const [rows] = await pool.query(
`SELECT COUNT(*) AS column_count
FROM information_schema.columns
WHERE table_schema = DATABASE()
AND table_name = ?
AND column_name = ?`,
[tableName, columnName]
);
if (rows.length && Number(rows[0].column_count) > 0) {
return;
}
await pool.query(`ALTER TABLE \`${tableName}\` ADD COLUMN \`${columnName}\` ${columnDefinition}`);
}
async function dropColumnIfPresent(pool, tableName, columnName) {
const [rows] = await pool.query(
`SELECT COUNT(*) AS column_count
FROM information_schema.columns
WHERE table_schema = DATABASE()
AND table_name = ?
AND column_name = ?`,
[tableName, columnName]
);
if (!rows.length || Number(rows[0].column_count) === 0) {
return;
}
await pool.query(`ALTER TABLE \`${tableName}\` DROP COLUMN \`${columnName}\``);
}
async function addForeignKeyIfMissing(pool, tableName, columnName, constraintName, referencedTable, referencedColumn, onDeleteAction) {
const [rows] = await pool.query(
`SELECT COUNT(*) AS constraint_count
FROM information_schema.table_constraints
WHERE table_schema = DATABASE()
AND table_name = ?
AND constraint_name = ?`,
[tableName, constraintName]
);
if (rows.length && Number(rows[0].constraint_count) > 0) {
return;
}
await pool.query(
`ALTER TABLE \`${tableName}\`
ADD CONSTRAINT \`${constraintName}\`
FOREIGN KEY (\`${columnName}\`) REFERENCES \`${referencedTable}\`(\`${referencedColumn}\`)
ON DELETE ${onDeleteAction}
ON UPDATE CASCADE`
);
}
async function hasSingleColumnUniqueIndex(pool, tableName, columnName) {
const [rows] = await pool.query(
`SELECT INDEX_NAME, COUNT(*) AS column_count
FROM information_schema.statistics
WHERE table_schema = DATABASE()
AND table_name = ?
AND non_unique = 0
AND column_name = ?
GROUP BY INDEX_NAME`,
[tableName, columnName]
);
return (rows || []).some(function (row) {
return Number(row.column_count) === 1;
});
}
async function addUniqueIndexIfMissing(pool, tableName, columnName, indexName) {
const hasUniqueIndex = await hasSingleColumnUniqueIndex(pool, tableName, columnName);
if (hasUniqueIndex) {
return;
}
await pool.query(`ALTER TABLE \`${tableName}\` ADD UNIQUE KEY \`${indexName}\` (\`${columnName}\`)`);
}
async function dedupePermissionRows(pool) {
const [rows] = await pool.query('SELECT id, permission_key FROM permissions ORDER BY id ASC');
const canonicalIdByKey = new Map();
const duplicateRowsByKey = new Map();
for (const row of rows || []) {
const permissionKey = String((row && row.permission_key) || '').trim().toLowerCase();
const permissionId = Number(row.id);
if (!permissionKey || !Number.isInteger(permissionId) || permissionId <= 0) {
continue;
}
if (!canonicalIdByKey.has(permissionKey)) {
canonicalIdByKey.set(permissionKey, permissionId);
continue;
}
if (!duplicateRowsByKey.has(permissionKey)) {
duplicateRowsByKey.set(permissionKey, []);
}
duplicateRowsByKey.get(permissionKey).push(permissionId);
}
if (!duplicateRowsByKey.size) {
return;
}
for (const [permissionKey, duplicateIds] of duplicateRowsByKey.entries()) {
const canonicalId = canonicalIdByKey.get(permissionKey);
for (const duplicateId of duplicateIds) {
await pool.query(
'UPDATE IGNORE role_permissions SET permission_id = ? WHERE permission_id = ?',
[canonicalId, duplicateId]
);
}
}
const duplicateIds = [];
for (const duplicateList of duplicateRowsByKey.values()) {
duplicateIds.push.apply(duplicateIds, duplicateList);
}
if (duplicateIds.length) {
await pool.query('DELETE FROM permissions WHERE id IN (?)', [duplicateIds]);
}
}
async function addAuditColumns(pool, tableName) {
await addColumnIfMissing(pool, tableName, 'created_by', 'INT NULL');
await addColumnIfMissing(pool, tableName, 'modified_by', 'INT NULL');
await pool.query(
`UPDATE \`${tableName}\` t
LEFT JOIN users created_user ON created_user.id = t.created_by
SET t.created_by = NULL
WHERE t.created_by IS NOT NULL`
);
await pool.query(
`UPDATE \`${tableName}\` t
LEFT JOIN users modified_user ON modified_user.id = t.modified_by
SET t.modified_by = NULL
WHERE t.modified_by IS NOT NULL`
);
await addForeignKeyIfMissing(pool, tableName, 'created_by', `fk_${tableName}_created_by`, 'users', 'id', 'SET NULL');
await addForeignKeyIfMissing(pool, tableName, 'modified_by', `fk_${tableName}_modified_by`, 'users', 'id', 'SET NULL');
}
async function hasColumn(pool, tableName, columnName) {
const [rows] = await pool.query(
`SELECT COUNT(*) AS column_count
FROM information_schema.columns
WHERE table_schema = DATABASE()
AND table_name = ?
AND column_name = ?`,
[tableName, columnName]
);
return rows.length && Number(rows[0].column_count) > 0;
}
async function backfillLegacyRssFeedItemJson(pool) {
const [rows] = await pool.query(
`SELECT column_name
FROM information_schema.columns
WHERE table_schema = DATABASE()
AND table_name = 'rss_feed_items'`
);
const columnNames = new Set((rows || []).map(function (row) {
return String(row.COLUMN_NAME || row.column_name || '').trim().toLowerCase();
}).filter(Boolean));
if (!['title', 'link', 'pub_date', 'description'].every(function (columnName) {
return columnNames.has(columnName);
})) {
return;
}
await pool.query(
`UPDATE rss_feed_items
SET item_json = JSON_OBJECT(
'title', title,
'link', link,
'pubDate', pub_date,
'description', description
)
WHERE item_json IS NULL`
);
}
async function backfillLegacySlideTemplateCanvasSize(pool) {
const [legacyTemplateColumns] = await pool.query(`
SELECT COUNT(*) AS column_count
FROM information_schema.columns
WHERE table_schema = DATABASE()
AND table_name = 'slide_templates'
AND column_name IN ('canvas_width', 'canvas_height')
`);
if (!legacyTemplateColumns[0] || Number(legacyTemplateColumns[0].column_count) !== 2) {
return;
}
await pool.query(`
UPDATE slide_templates st
JOIN canvas_sizes cs ON cs.width = st.canvas_width AND cs.height = st.canvas_height
SET st.canvas_size_id = cs.id
WHERE st.canvas_size_id IS NULL
`);
}
function replaceUploadPrefixInValue(value) {
if (typeof value === 'string') {
return value.replace(/\/uploads\//g, '/media/');
}
if (Array.isArray(value)) {
return value.map(function (item) {
return replaceUploadPrefixInValue(item);
});
}
if (value && typeof value === 'object') {
return Object.keys(value).reduce(function (result, key) {
result[key] = replaceUploadPrefixInValue(value[key]);
return result;
}, {});
}
return value;
}
function parseJsonValue(value) {
if (value === null || value === undefined || value === '') {
return null;
}
if (typeof value !== 'string') {
return value;
}
try {
return JSON.parse(value);
} catch (_error) {
return value;
}
}
async function backfillLegacyMediaPaths(pool) {
const [slides] = await pool.query(`
SELECT id, media_path, content_json
FROM slides
WHERE media_path LIKE '/uploads/%'
OR content_json LIKE '%/uploads/%'
`);
for (const slide of slides || []) {
let mediaPath = String(slide.media_path || '').trim() || null;
let contentJson = slide.content_json;
let changed = false;
if (mediaPath && mediaPath.startsWith('/uploads/')) {
mediaPath = mediaPath.replace(/^\/uploads\//, '/media/');
changed = true;
}
const parsedContent = parseJsonValue(contentJson);
if (parsedContent && typeof parsedContent === 'object') {
const updatedContent = replaceUploadPrefixInValue(parsedContent);
if (JSON.stringify(updatedContent) !== JSON.stringify(parsedContent)) {
contentJson = JSON.stringify(updatedContent);
changed = true;
}
} else if (typeof parsedContent === 'string' && parsedContent.indexOf('/uploads/') !== -1) {
contentJson = parsedContent.replace(/\/uploads\//g, '/media/');
changed = true;
}
if (changed) {
await pool.query('UPDATE slides SET media_path = ?, content_json = ? WHERE id = ?', [mediaPath, contentJson, slide.id]);
}
}
const [templates] = await pool.query(`
SELECT id, background_image_path
FROM slide_templates
WHERE background_image_path LIKE '/uploads/%'
`);
for (const template of templates || []) {
const backgroundImagePath = String(template.background_image_path || '').trim();
if (!backgroundImagePath.startsWith('/uploads/')) {
continue;
}
await pool.query(
'UPDATE slide_templates SET background_image_path = ? WHERE id = ?',
[backgroundImagePath.replace(/^\/uploads\//, '/media/'), template.id]
);
}
}
async function ensureMigrationTable(pool) {
await pool.query(`
CREATE TABLE IF NOT EXISTS schema_migrations (
id INT AUTO_INCREMENT PRIMARY KEY,
migration_key VARCHAR(100) NOT NULL UNIQUE,
app_version VARCHAR(32) NOT NULL,
comment TEXT NOT NULL,
applied_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
}
async function getAppliedMigrationRows(pool) {
await ensureMigrationTable(pool);
const [rows] = await pool.query(
'SELECT migration_key, app_version, comment, applied_at FROM schema_migrations ORDER BY id ASC'
);
return rows || [];
}
function getLatestAppliedVersion(rows) {
let latestVersion = '0.0.0';
for (const row of rows || []) {
const candidateVersion = String(row.app_version || '0.0.0');
if (compareVersions(candidateVersion, latestVersion) > 0) {
latestVersion = candidateVersion;
}
}
return latestVersion;
}
async function recordMigration(pool, migration) {
await pool.query(
'INSERT IGNORE INTO schema_migrations (migration_key, app_version, comment) VALUES (?, ?, ?)',
[migration.key, migration.version, migration.comment]
);
}
const migrations = [
{
key: 'interval-value-rename',
version: appVersion,
comment: 'Rename RSS and API refresh interval columns to update_interval_value so seconds and minutes share one neutral numeric field.',
order: 10,
up: async function (pool) {
await addColumnIfMissing(pool, 'rss_feeds', 'update_interval_value', 'INT NOT NULL DEFAULT 60');
await addColumnIfMissing(pool, 'rss_feeds', 'update_interval_unit', "VARCHAR(10) NOT NULL DEFAULT 'minutes'");
await addColumnIfMissing(pool, 'api_sources', 'update_interval_value', 'INT NOT NULL DEFAULT 60');
await addColumnIfMissing(pool, 'api_sources', 'update_interval_unit', "VARCHAR(10) NOT NULL DEFAULT 'minutes'");
if (await hasColumn(pool, 'rss_feeds', 'update_interval_minutes')) {
await pool.query(`
UPDATE rss_feeds
SET update_interval_value = update_interval_minutes
`);
await dropColumnIfPresent(pool, 'rss_feeds', 'update_interval_minutes');
}
if (await hasColumn(pool, 'api_sources', 'update_interval_minutes')) {
await pool.query(`
UPDATE api_sources
SET update_interval_value = update_interval_minutes
`);
await dropColumnIfPresent(pool, 'api_sources', 'update_interval_minutes');
}
}
},
{
key: 'schema-columns-current',
version: appVersion,
comment: 'Add the current table columns and audit fields that define the released schema.',
order: 20,
up: async function (pool) {
// v1.4.6: keep the current canvas_sizes shape available in older databases.
await addColumnIfMissing(pool, 'canvas_sizes', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
await addAuditColumns(pool, 'canvas_sizes');
// v1.4.6: playlists gained fade_between_slides plus audit fields.
await addColumnIfMissing(pool, 'playlists', 'fade_between_slides', 'TINYINT(1) NOT NULL DEFAULT 0');
await addColumnIfMissing(pool, 'playlists', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
await addAuditColumns(pool, 'playlists');
// v1.4.6: slide_templates now store canvas sizing and background metadata.
await addColumnIfMissing(pool, 'slide_templates', 'canvas_size_id', 'INT NULL');
await addColumnIfMissing(pool, 'slide_templates', 'background_image_path', 'VARCHAR(512) NULL');
await addColumnIfMissing(pool, 'slide_templates', 'background_color', 'VARCHAR(32) NULL');
await addColumnIfMissing(pool, 'slide_templates', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
await addAuditColumns(pool, 'slide_templates');
await backfillLegacySlideTemplateCanvasSize(pool);
// v1.4.6: slide_template_regions gained font family and audit fields.
await addColumnIfMissing(pool, 'slide_template_regions', 'font_family', 'VARCHAR(100) NULL');
await addColumnIfMissing(pool, 'slide_template_regions', 'lock_ratio', 'VARCHAR(20) NULL');
await addColumnIfMissing(pool, 'slide_template_regions', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
await addAuditColumns(pool, 'slide_template_regions');
// v1.4.6: slides gained structured content and media fields.
await addColumnIfMissing(pool, 'slides', 'body', 'TEXT NULL');
await addColumnIfMissing(pool, 'slides', 'template_id', 'INT NULL');
await addColumnIfMissing(pool, 'slides', 'content_json', 'JSON NULL');
await addColumnIfMissing(pool, 'slides', 'media_path', 'VARCHAR(512) NULL');
await addColumnIfMissing(pool, 'slides', 'media_type', 'VARCHAR(100) NULL');
await addColumnIfMissing(pool, 'slides', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
await addAuditColumns(pool, 'slides');
// v1.4.6: playlist_slides gained scheduling fields and audit fields.
await addColumnIfMissing(pool, 'playlist_slides', 'duration_seconds', 'INT NOT NULL DEFAULT 10');
await addColumnIfMissing(pool, 'playlist_slides', 'schedule_mode', "VARCHAR(20) NOT NULL DEFAULT 'always'");
await addColumnIfMissing(pool, 'playlist_slides', 'schedule_start_datetime', 'DATETIME NULL');
await addColumnIfMissing(pool, 'playlist_slides', 'schedule_end_datetime', 'DATETIME NULL');
await addColumnIfMissing(pool, 'playlist_slides', 'schedule_start_time', 'TIME NULL');
await addColumnIfMissing(pool, 'playlist_slides', 'schedule_end_time', 'TIME NULL');
await addColumnIfMissing(pool, 'playlist_slides', 'schedule_days_json', 'JSON NULL');
await addColumnIfMissing(pool, 'playlist_slides', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
await addAuditColumns(pool, 'playlist_slides');
// v1.4.6: screens gained an optional playlist binding and audit fields.
await addColumnIfMissing(pool, 'screens', 'playlist_id', 'INT NULL');
await addColumnIfMissing(pool, 'screens', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
await addAuditColumns(pool, 'screens');
// v1.4.6: RSS feeds now use a neutral interval value plus unit.
await addColumnIfMissing(pool, 'rss_feeds', 'name', 'VARCHAR(255) NOT NULL');
await addColumnIfMissing(pool, 'rss_feeds', 'feed_url', 'VARCHAR(1024) NOT NULL');
await addColumnIfMissing(pool, 'rss_feeds', 'update_interval_value', 'INT NOT NULL DEFAULT 60');
await addColumnIfMissing(pool, 'rss_feeds', 'update_interval_unit', "VARCHAR(10) NOT NULL DEFAULT 'minutes'");
await addColumnIfMissing(pool, 'rss_feeds', 'item_limit', 'INT NOT NULL DEFAULT 1');
await addColumnIfMissing(pool, 'rss_feeds', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
await addAuditColumns(pool, 'rss_feeds');
// v1.4.6: rss_feed_items stores normalized JSON snapshots.
await addColumnIfMissing(pool, 'rss_feed_items', 'rss_feed_id', 'INT NOT NULL');
await addColumnIfMissing(pool, 'rss_feed_items', 'position', 'INT NOT NULL');
await addColumnIfMissing(pool, 'rss_feed_items', 'item_json', 'MEDIUMTEXT NULL');
await addColumnIfMissing(pool, 'rss_feed_items', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
await backfillLegacyRssFeedItemJson(pool);
// v1.4.6: API sources now track their latest response snapshot.
await addColumnIfMissing(pool, 'api_sources', 'name', 'VARCHAR(255) NOT NULL');
await addColumnIfMissing(pool, 'api_sources', 'api_url', 'VARCHAR(1024) NOT NULL');
await addColumnIfMissing(pool, 'api_sources', 'update_interval_value', 'INT NOT NULL DEFAULT 60');
await addColumnIfMissing(pool, 'api_sources', 'update_interval_unit', "VARCHAR(10) NOT NULL DEFAULT 'minutes'");
await addColumnIfMissing(pool, 'api_sources', 'last_pulled_at', 'TIMESTAMP NULL');
await addColumnIfMissing(pool, 'api_sources', 'last_pull_error', 'MEDIUMTEXT NULL');
await addColumnIfMissing(pool, 'api_sources', 'last_response_status', 'INT NULL');
await addColumnIfMissing(pool, 'api_sources', 'last_response_content_type', 'VARCHAR(255) NULL');
await addColumnIfMissing(pool, 'api_sources', 'last_response_json', 'MEDIUMTEXT NULL');
await addColumnIfMissing(pool, 'api_sources', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
await addAuditColumns(pool, 'api_sources');
// v1.4.6: onboarding devices, users, roles, permissions, and link tables now carry audit fields.
await addColumnIfMissing(pool, 'player_onboarding_devices', 'client_name', 'VARCHAR(255) NULL');
await addColumnIfMissing(pool, 'player_onboarding_devices', 'screen_id', 'INT NULL');
await addColumnIfMissing(pool, 'player_onboarding_devices', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
await addAuditColumns(pool, 'player_onboarding_devices');
await addColumnIfMissing(pool, 'users', 'name', 'VARCHAR(255) NULL');
await addColumnIfMissing(pool, 'users', 'password_hash', 'CHAR(64) NOT NULL');
await addColumnIfMissing(pool, 'users', 'password_salt', 'VARCHAR(64) NOT NULL');
await addColumnIfMissing(pool, 'users', 'password_iterations', 'INT NOT NULL');
await addColumnIfMissing(pool, 'users', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
await addAuditColumns(pool, 'users');
await addColumnIfMissing(pool, 'roles', 'role_key', 'VARCHAR(100) NULL');
await addColumnIfMissing(pool, 'roles', 'description', 'TEXT NULL');
await addColumnIfMissing(pool, 'roles', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
await addAuditColumns(pool, 'roles');
await addColumnIfMissing(pool, 'permissions', 'permission_key', 'VARCHAR(100) NULL');
await addColumnIfMissing(pool, 'permissions', 'name', 'VARCHAR(255) NULL');
await addColumnIfMissing(pool, 'permissions', 'section_name', 'VARCHAR(255) NULL');
await addColumnIfMissing(pool, 'permissions', 'description', 'TEXT NULL');
await addColumnIfMissing(pool, 'permissions', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
await addAuditColumns(pool, 'permissions');
await dedupePermissionRows(pool);
await addUniqueIndexIfMissing(pool, 'permissions', 'permission_key', 'uq_permissions_permission_key');
await addColumnIfMissing(pool, 'role_permissions', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
await addAuditColumns(pool, 'role_permissions');
await addColumnIfMissing(pool, 'user_roles', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
await addAuditColumns(pool, 'user_roles');
await addAuditColumns(pool, 'auth_sessions');
}
},
{
key: 'media-path-prefix-rename',
version: appVersion,
comment: 'Rename stored media URLs from /uploads to /media so existing slides and templates keep working after the storage root move.',
order: 25,
up: async function (pool) {
await backfillLegacyMediaPaths(pool);
}
},
{
key: 'background-tasks-table',
version: appVersion,
comment: 'Persist background tasks so queued work survives a web restart.',
order: 30,
up: async function (pool) {
await pool.query(`
CREATE TABLE IF NOT EXISTS background_tasks (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
task_key VARCHAR(191) NULL,
task_type VARCHAR(100) NOT NULL,
title VARCHAR(255) NOT NULL,
category VARCHAR(100) NOT NULL DEFAULT 'general',
status VARCHAR(20) NOT NULL,
payload_json MEDIUMTEXT NULL,
metadata_json MEDIUMTEXT NULL,
attempts INT NOT NULL DEFAULT 0,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
started_at TIMESTAMP NULL,
finished_at TIMESTAMP NULL,
error_message MEDIUMTEXT NULL,
INDEX idx_background_tasks_status (status),
INDEX idx_background_tasks_key (task_key),
INDEX idx_background_tasks_type (task_type)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
}
}
];
async function runMigrations(pool) {
const appliedRows = await getAppliedMigrationRows(pool);
const appliedKeys = new Set((appliedRows || []).map(function (row) {
return String(row.migration_key || '').trim();
}).filter(Boolean));
const pendingMigrations = migrations
.filter(function (migration) {
return compareVersions(migration.version, appVersion) <= 0
&& !appliedKeys.has(migration.key);
})
.sort(function (left, right) {
const versionOrder = compareVersions(left.version, right.version);
if (versionOrder !== 0) {
return versionOrder;
}
return Number(left.order || 0) - Number(right.order || 0);
});
for (const migration of pendingMigrations) {
await migration.up(pool);
await recordMigration(pool, migration);
}
}
module.exports = {
appVersion: appVersion,
compareVersions: compareVersions,
runMigrations: runMigrations
};
+14 -8
View File
@@ -5,20 +5,21 @@ const path = require('path');
const common = require('./common');
const { createPlayerRuntime } = require('./player/runtime');
const { createPlayerPlaylistService } = require('./player/playlist');
const { createRtmpStreamService } = require('./player/modules/rtmp-streams');
const { normalizeDeviceId, registerPlayerOnboardingRoutes, commitDeviceBinding } = require('./player/onboarding');
const { createOnboardingStore } = require('./player/onboarding-store');
const { createOnboardingStore } = require('./player/onboarding/store');
const { registerPlayerRoutes } = require('./player/routes');
const { pruneStaleOnboardingDevices } = require('./db');
// Player runtime, upload API, and websocket wiring.
// Player runtime, media API, and websocket wiring.
async function start() {
const app = express();
const pool = common.createPool();
const PORT = Number(process.env.PLAYER_PORT || 3001);
const ASSET_DIR = path.join(__dirname, 'player', 'public');
const UPLOAD_DIR = path.join(__dirname, '..', 'uploads');
const ONBOARDING_QUEUE_FILE = path.join(UPLOAD_DIR, 'player-onboarding-queue.json');
const MEDIA_DIR = path.join(__dirname, '..', 'media');
const ONBOARDING_QUEUE_FILE = path.join(MEDIA_DIR, 'player-onboarding-queue.json');
const DB_SYNC_INTERVAL_MS = Number(process.env.PLAYER_DB_SYNC_INTERVAL_MS || 15000);
const onboardingStore = createOnboardingStore(ONBOARDING_QUEUE_FILE);
const playerRuntime = createPlayerRuntime({
@@ -27,7 +28,11 @@ async function start() {
});
const playerPlaylistService = createPlayerPlaylistService({
pool: pool,
common: common
common: common,
snapshotDir: path.join(MEDIA_DIR, 'player-cache', 'screen-playlists')
});
const rtmpStreamService = createRtmpStreamService({
mediaDir: MEDIA_DIR
});
const server = http.createServer(app);
playerRuntime.installWebsocket(server);
@@ -42,10 +47,11 @@ async function start() {
registerPlayerRoutes(app, {
pool: pool,
common: common,
uploadDir: UPLOAD_DIR,
mediaDir: MEDIA_DIR,
assetDir: ASSET_DIR,
playerRuntime: playerRuntime,
playerPlaylistService: playerPlaylistService
playerPlaylistService: playerPlaylistService,
rtmpStreamService: rtmpStreamService
});
app.use(function (error, _req, res, _next) {
@@ -53,7 +59,7 @@ async function start() {
res.status(error.statusCode || 500).send(error.statusCode ? error.message : 'Internal server error');
});
fs.mkdirSync(UPLOAD_DIR, { recursive: true });
fs.mkdirSync(MEDIA_DIR, { recursive: true });
server.listen(PORT, function () {
console.log(`Pulse Signage app listening on port ${PORT}`);
+173
View File
@@ -0,0 +1,173 @@
const crypto = require('crypto');
const fs = require('fs');
const path = require('path');
const { spawn } = require('child_process');
function createRtmpStreamService(options) {
const mediaDir = options && options.mediaDir ? options.mediaDir : null;
const ffmpegPath = options && options.ffmpegPath ? options.ffmpegPath : 'ffmpeg';
const sessions = new Map();
if (!mediaDir) {
throw new Error('mediaDir is required');
}
const cacheRoot = path.join(mediaDir, 'rtmp-cache');
function normalizeSourceUrl(value) {
const sourceUrl = String(value || '').trim();
if (!sourceUrl || !/^rtmps?:\/\//i.test(sourceUrl)) {
const error = new Error('A valid RTMP URL is required.');
error.statusCode = 400;
throw error;
}
return sourceUrl;
}
function getSessionKey(sourceUrl, disableAudio) {
return crypto.createHash('sha1').update(String(sourceUrl)).update('\0').update(disableAudio ? '1' : '0').digest('hex');
}
async function ensureDirectory(dirPath) {
await fs.promises.mkdir(dirPath, { recursive: true });
}
async function waitForFile(filePath, timeoutMs) {
const startedAt = Date.now();
while (Date.now() - startedAt < timeoutMs) {
try {
const stat = await fs.promises.stat(filePath);
if (stat.isFile() && stat.size > 0) {
return true;
}
} catch (_error) {
// keep waiting
}
await new Promise((resolve) => setTimeout(resolve, 100));
}
return false;
}
function buildPlaylistUrl(key) {
return '/api/rtmp/streams/' + encodeURIComponent(key) + '/index.m3u8';
}
async function ensureSession(sourceUrl, disableAudio) {
const normalizedSource = normalizeSourceUrl(sourceUrl);
const normalizedDisableAudio = Boolean(disableAudio);
const key = getSessionKey(normalizedSource, normalizedDisableAudio);
if (sessions.has(key)) {
return sessions.get(key);
}
const directory = path.join(cacheRoot, key);
const manifestPath = path.join(directory, 'index.m3u8');
await ensureDirectory(directory);
const args = [
'-hide_banner',
'-loglevel', 'warning',
'-nostdin',
'-i', normalizedSource,
'-fflags', '+genpts',
'-f', 'hls',
'-hls_time', '1',
'-hls_init_time', '1',
'-hls_list_size', '8',
'-hls_flags', 'delete_segments+append_list+omit_endlist+independent_segments+program_date_time',
'-hls_segment_filename', path.join(directory, 'segment-%05d.ts')
];
if (normalizedDisableAudio) {
args.push('-an');
args.push('-c:v', 'copy');
} else {
args.push('-c', 'copy');
}
args.push(manifestPath);
const child = spawn(ffmpegPath, args, {
stdio: ['ignore', 'ignore', 'pipe']
});
child.stderr.on('data', function (chunk) {
const message = String(chunk || '').trim();
if (message) {
console.error('[rtmp]', message);
}
});
child.on('exit', function (code, signal) {
const session = sessions.get(key);
if (session && session.process === child) {
session.process = null;
session.exitCode = code;
session.exitSignal = signal;
}
});
const session = {
key: key,
sourceUrl: normalizedSource,
disableAudio: normalizedDisableAudio,
directory: directory,
manifestPath: manifestPath,
playlistUrl: buildPlaylistUrl(key),
process: child,
ready: waitForFile(manifestPath, 5000)
};
sessions.set(key, session);
return session;
}
async function getPlaylistUrl(sourceUrl, disableAudio) {
const session = await ensureSession(sourceUrl, disableAudio);
await session.ready.catch(function () {
return false;
});
return session.playlistUrl + '?t=' + Date.now();
}
function getSessionByKey(key) {
return sessions.get(String(key || '').trim()) || null;
}
async function getManifestFilePath(key) {
const session = getSessionByKey(key);
if (!session) {
return null;
}
await session.ready.catch(function () {
return false;
});
return session.manifestPath;
}
async function getSegmentFilePath(key, fileName) {
const session = getSessionByKey(key);
if (!session) {
return null;
}
const segmentName = path.basename(String(fileName || '').trim());
if (!segmentName || segmentName === 'index.m3u8') {
return null;
}
return path.join(session.directory, segmentName);
}
return {
ensureSession: ensureSession,
getPlaylistUrl: getPlaylistUrl,
getSessionByKey: getSessionByKey,
getManifestFilePath: getManifestFilePath,
getSegmentFilePath: getSegmentFilePath
};
}
module.exports = {
createRtmpStreamService: createRtmpStreamService
};
@@ -1,5 +1,11 @@
const { isClientNameAvailable, withClientNameReservation } = require('../client-name-check');
const { isTransientDbError } = require('./onboarding-store');
const { isClientNameAvailable, withClientNameReservation } = require('../../data/client-name-check');
const { getSharedSecret, verifyPageAuthToken } = require('../../request-auth');
const { isTransientDbError } = require('./store');
const ONBOARDING_SIGNUP_LIMIT_WINDOW_MS = 5 * 60 * 1000;
const ONBOARDING_SIGNUP_LIMIT_MAX_ATTEMPTS = 8;
const onboardingSignupAttempts = new Map();
function normalizeDeviceId(value) {
return String(value || '').trim().replace(/[^a-zA-Z0-9_-]/g, '').slice(0, 128);
}
@@ -16,6 +22,50 @@ function getPublicBaseUrl(req) {
return `${protocol}://${host}`.replace(/\/$/, '');
}
function getRequestIp(req) {
const forwardedFor = String(req && req.headers && req.headers['x-forwarded-for'] || '').trim().split(',')[0];
if (forwardedFor) {
return forwardedFor;
}
const remoteAddress = req && req.socket && req.socket.remoteAddress ? String(req.socket.remoteAddress).trim() : '';
if (!remoteAddress) {
return 'unknown';
}
return remoteAddress.toLowerCase().startsWith('::ffff:') ? remoteAddress.slice(7) : remoteAddress;
}
function getOnboardingLimitKey(req, deviceId) {
return [getRequestIp(req), normalizeDeviceId(deviceId) || 'anonymous'].join('|');
}
function clearOnboardingSignupAttempts() {
if (onboardingSignupAttempts.size > 1000) {
onboardingSignupAttempts.clear();
}
}
function isOnboardingSignupRateLimited(req, deviceId) {
const now = Date.now();
const key = getOnboardingLimitKey(req, deviceId);
const attempts = onboardingSignupAttempts.get(key) || [];
const windowStart = now - ONBOARDING_SIGNUP_LIMIT_WINDOW_MS;
const recentAttempts = attempts.filter(function (timestamp) {
return timestamp >= windowStart;
});
if (recentAttempts.length >= ONBOARDING_SIGNUP_LIMIT_MAX_ATTEMPTS) {
onboardingSignupAttempts.set(key, recentAttempts);
return Math.max(1, Math.ceil((recentAttempts[0] + ONBOARDING_SIGNUP_LIMIT_WINDOW_MS - now) / 1000));
}
recentAttempts.push(now);
onboardingSignupAttempts.set(key, recentAttempts);
clearOnboardingSignupAttempts();
return 0;
}
async function getOnboardingStatus(pool, deviceId) {
const normalizedDeviceId = normalizeDeviceId(deviceId);
if (!normalizedDeviceId) {
@@ -112,6 +162,23 @@ function registerPlayerOnboardingRoutes(app, options) {
throw new Error('registerPlayerOnboardingRoutes requires app, pool, common, playerRuntime, and QRCode.');
}
const sharedSecret = getSharedSecret();
function requireOnboardingPageAuth(req, res, next) {
if (!sharedSecret) {
return next();
}
const token = String(req.headers['x-pulse-page-auth'] || '').trim();
const payload = verifyPageAuthToken(token);
if (!payload || String(payload.scope || '').trim() !== 'onboarding') {
return res.status(401).json({ error: 'Onboarding page authentication required.' });
}
req.playerPageAuth = payload;
next();
}
app.get('/', function (_req, res) {
res.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
res.send(common.renderPlayerOnboardingLandingPage());
@@ -122,7 +189,17 @@ function registerPlayerOnboardingRoutes(app, options) {
res.send(common.renderPlayerOnboardingFormPage(String(req.query.deviceId || '').trim()));
});
app.get('/api/onboarding/status', async function (req, res, next) {
app.get('/api/onboarding/status', function (req, res, next) {
if (sharedSecret) {
const token = String(req.headers['x-pulse-page-auth'] || '').trim();
const payload = verifyPageAuthToken(token);
if (!payload || ['onboarding', 'player'].indexOf(String(payload.scope || '').trim()) === -1) {
return res.status(401).json({ error: 'Onboarding page authentication required.' });
}
req.playerPageAuth = payload;
}
next();
}, async function (req, res, next) {
try {
const status = await getOnboardingStatus(pool, req.query.deviceId);
res.json({
@@ -139,7 +216,7 @@ function registerPlayerOnboardingRoutes(app, options) {
}
});
app.get('/api/onboarding/screens', async function (_req, res, next) {
app.get('/api/onboarding/screens', requireOnboardingPageAuth, async function (_req, res, next) {
try {
const [rows] = await pool.query('SELECT id, name, slug FROM screens ORDER BY name ASC, id ASC');
res.json({ screens: rows });
@@ -164,11 +241,16 @@ function registerPlayerOnboardingRoutes(app, options) {
}
});
app.post('/api/onboarding', async function (req, res, next) {
app.post('/api/onboarding', requireOnboardingPageAuth, async function (req, res, next) {
try {
const deviceId = normalizeDeviceId(req.body && req.body.deviceId);
const clientName = String((req.body && req.body.clientName) || '').trim();
const screenSlug = String((req.body && req.body.screenSlug) || '').trim();
const retryAfterSeconds = isOnboardingSignupRateLimited(req, deviceId);
if (retryAfterSeconds) {
res.set('Retry-After', String(retryAfterSeconds));
return res.status(429).json({ error: 'Too many onboarding attempts. Please try again later.' });
}
if (!deviceId) {
return res.status(400).json({ error: 'Device ID is required' });
}
@@ -85,4 +85,4 @@
.catch(function (error) { setMessage(error && error.message ? error.message : "Unable to save onboarding."); });
});
}());
</script>
</script>
@@ -138,4 +138,4 @@
window.setInterval(function () { redirectIfOnboarded(deviceId); }, 2000);
});
}());
</script>
</script>
File diff suppressed because it is too large Load Diff
+150 -70
View File
@@ -1,8 +1,11 @@
const crypto = require('crypto');
const fs = require('fs');
const path = require('path');
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;
if (!pool) {
throw new Error('pool is required');
@@ -11,81 +14,155 @@ function createPlayerPlaylistService(options) {
throw new Error('common is required');
}
function getSnapshotFilePath(slug) {
if (!snapshotDir) {
return null;
}
const normalizedSlug = String(slug || '').trim();
if (!normalizedSlug) {
return null;
}
return path.join(snapshotDir, `${normalizedSlug}.json`);
}
async function readSnapshot(slug) {
const filePath = getSnapshotFilePath(slug);
if (!filePath) {
return null;
}
try {
const raw = await fs.promises.readFile(filePath, 'utf8');
const parsed = JSON.parse(raw);
if (!parsed || typeof parsed !== 'object') {
return null;
}
return parsed;
} catch (error) {
if (error && error.code === 'ENOENT') {
return null;
}
return null;
}
}
async function writeSnapshot(slug, payload) {
const filePath = getSnapshotFilePath(slug);
if (!filePath) {
return;
}
await fs.promises.mkdir(path.dirname(filePath), { recursive: true });
await fs.promises.writeFile(filePath, JSON.stringify(payload, null, 2), 'utf8');
}
async function buildScreenPlaylist(slug) {
const [screenRows] = await pool.query('SELECT id, name, slug, playlist_id, created_at, modified_at, created_by, modified_by FROM screens WHERE slug = ?', [slug]);
if (!screenRows.length) {
return { screen: null, playlist: null, slides: [] };
}
try {
const [screenRows] = await pool.query('SELECT id, name, slug, playlist_id, created_at, modified_at, created_by, modified_by FROM screens WHERE slug = ?', [slug]);
if (!screenRows.length) {
return { screen: null, playlist: null, slides: [], rssFeeds: [], apiSources: [] };
}
const screen = screenRows[0];
if (!screen.playlist_id) {
return {
screen: screen,
playlist: null,
slides: [],
revision: getPlaylistRevision(screen, null, [], [], [])
};
}
const screen = screenRows[0];
if (!screen.playlist_id) {
const payloadWithoutPlaylist = {
screen: screen,
playlist: null,
slides: [],
revision: getPlaylistRevision(screen, null, [], [], [], [], [])
};
await writeSnapshot(slug, payloadWithoutPlaylist);
return payloadWithoutPlaylist;
}
const [playlistRows] = await pool.query('SELECT id, name, fade_between_slides, created_at, modified_at, created_by, modified_by FROM playlists WHERE id = ?', [screen.playlist_id]);
const playlist = playlistRows[0] || null;
const [slideRows] = await pool.query(`
SELECT sl.id, sl.title, sl.body, sl.template_id, sl.content_json, sl.media_path, sl.media_type, sl.created_at, sl.modified_at,
ps.position, ps.duration_seconds AS duration_seconds, ps.schedule_mode, ps.schedule_start_datetime, ps.schedule_end_datetime, ps.schedule_start_time, ps.schedule_end_time, ps.schedule_days_json,
st.name AS template_name, cs.name AS canvas_size_name, cs.width AS canvas_size_width, cs.height AS canvas_size_height, cs.width AS canvas_width, cs.height AS canvas_height
FROM playlist_slides ps
JOIN slides sl ON sl.id = ps.slide_id
LEFT JOIN slide_templates st ON st.id = sl.template_id
LEFT JOIN canvas_sizes cs ON cs.id = st.canvas_size_id
WHERE ps.playlist_id = ?
ORDER BY ps.position ASC, ps.id ASC
`, [screen.playlist_id]);
const templateIds = slideRows
.filter(function (slide) { return slide.template_id; })
.map(function (slide) { return slide.template_id; });
const templatesById = {};
let templateRows = [];
let regionRows = [];
if (templateIds.length) {
[templateRows] = await pool.query(`
SELECT st.id, st.name, st.canvas_size_id, st.background_image_path, st.background_color, st.created_at, st.modified_at,
cs.name AS canvas_size_name, cs.width AS canvas_size_width, cs.height AS canvas_size_height, cs.width AS canvas_width, cs.height AS canvas_height
FROM slide_templates st
const [playlistRows] = await pool.query('SELECT id, name, fade_between_slides, created_at, modified_at, created_by, modified_by FROM playlists WHERE id = ?', [screen.playlist_id]);
const playlist = playlistRows[0] || null;
const [slideRows] = await pool.query(`
SELECT sl.id, sl.title, sl.body, sl.template_id, sl.content_json, sl.media_path, sl.media_type, sl.created_at, sl.modified_at,
ps.position, ps.duration_seconds AS duration_seconds, ps.schedule_mode, ps.schedule_start_datetime, ps.schedule_end_datetime, ps.schedule_start_time, ps.schedule_end_time, ps.schedule_days_json,
st.name AS template_name, cs.name AS canvas_size_name, cs.width AS canvas_size_width, cs.height AS canvas_size_height, cs.width AS canvas_width, cs.height AS canvas_height
FROM playlist_slides ps
JOIN slides sl ON sl.id = ps.slide_id
LEFT JOIN slide_templates st ON st.id = sl.template_id
LEFT JOIN canvas_sizes cs ON cs.id = st.canvas_size_id
WHERE st.id IN (?)
`, [templateIds]);
[regionRows] = await pool.query('SELECT id, template_id, region_key, region_type, label, font_family, x, y, width, height, z_index, created_at, modified_at, created_by, modified_by FROM slide_template_regions WHERE template_id IN (?) ORDER BY template_id ASC, z_index ASC, id ASC', [templateIds]);
templateRows.forEach(function (template) {
template.regions = regionRows.filter(function (region) { return region.template_id === template.id; });
templatesById[template.id] = template;
WHERE ps.playlist_id = ?
ORDER BY ps.position ASC, ps.id ASC
`, [screen.playlist_id]);
const templateIds = slideRows
.filter(function (slide) { return slide.template_id; })
.map(function (slide) { return slide.template_id; });
const templatesById = {};
let templateRows = [];
let regionRows = [];
if (templateIds.length) {
[templateRows] = await pool.query(`
SELECT st.id, st.name, st.canvas_size_id, st.background_image_path, st.background_color, st.created_at, st.modified_at,
cs.name AS canvas_size_name, cs.width AS canvas_size_width, cs.height AS canvas_size_height, cs.width AS canvas_width, cs.height AS canvas_height
FROM slide_templates st
LEFT JOIN canvas_sizes cs ON cs.id = st.canvas_size_id
WHERE st.id IN (?)
`, [templateIds]);
[regionRows] = await pool.query('SELECT id, template_id, region_key, region_type, label, font_family, lock_ratio, x, y, width, height, z_index, created_at, modified_at, created_by, modified_by FROM slide_template_regions WHERE template_id IN (?) ORDER BY template_id ASC, z_index ASC, id ASC', [templateIds]);
templateRows.forEach(function (template) {
template.regions = regionRows.filter(function (region) { return region.template_id === template.id; });
templatesById[template.id] = template;
});
}
const slides = slideRows.map(function (slide) {
return {
id: slide.id,
title: slide.title,
body: slide.body,
duration_seconds: slide.duration_seconds,
schedule_mode: slide.schedule_mode,
schedule_start_datetime: slide.schedule_start_datetime,
schedule_end_datetime: slide.schedule_end_datetime,
schedule_start_time: slide.schedule_start_time,
schedule_end_time: slide.schedule_end_time,
schedule_days_json: slide.schedule_days_json,
media_url: slide.media_path,
media_type: slide.media_type,
kind: common.mediaKind(slide.media_path),
template_id: slide.template_id,
template: slide.template_id ? templatesById[slide.template_id] || null : null,
content: common.parseJsonSafe(slide.content_json) || {}
};
});
let rssFeeds = [];
if (typeof common.fetchRssFeedsData === 'function' && typeof common.fetchRssFeedItemsByFeedId === 'function') {
const rssData = await common.fetchRssFeedsData(pool);
const feedRows = Array.isArray(rssData && rssData.rssFeeds) ? rssData.rssFeeds : [];
rssFeeds = await Promise.all(feedRows.map(async function (feed) {
const items = await common.fetchRssFeedItemsByFeedId(pool, feed.id);
return Object.assign({}, feed, { items: items.map(function (item) {
return typeof common.normalizeRssFeedItem === 'function' ? common.normalizeRssFeedItem(item) : item;
}) });
}));
}
let apiSources = [];
if (typeof common.fetchApiSourcesData === 'function') {
const apiData = await common.fetchApiSourcesData(pool);
const sourceRows = Array.isArray(apiData && apiData.apiSources) ? apiData.apiSources : [];
apiSources = sourceRows.map(function (source) {
return Object.assign({}, source, {
responseJson: common.parseJsonSafe ? common.parseJsonSafe(source.last_response_json) : null
});
});
}
const revision = getPlaylistRevision(screen, playlist, slideRows, templateRows, regionRows, rssFeeds, apiSources);
const payload = { screen: screen, playlist: playlist, slides: slides, rssFeeds: rssFeeds, apiSources: apiSources, revision: revision };
await writeSnapshot(slug, payload);
return payload;
} catch (error) {
const snapshot = await readSnapshot(slug);
if (snapshot) {
return snapshot;
}
throw error;
}
const slides = slideRows.map(function (slide) {
return {
id: slide.id,
title: slide.title,
body: slide.body,
duration_seconds: slide.duration_seconds,
schedule_mode: slide.schedule_mode,
schedule_start_datetime: slide.schedule_start_datetime,
schedule_end_datetime: slide.schedule_end_datetime,
schedule_start_time: slide.schedule_start_time,
schedule_end_time: slide.schedule_end_time,
schedule_days_json: slide.schedule_days_json,
media_url: slide.media_path,
media_type: slide.media_type,
kind: common.mediaKind(slide.media_path),
template_id: slide.template_id,
template: slide.template_id ? templatesById[slide.template_id] || null : null,
content: common.parseJsonSafe(slide.content_json) || {}
};
});
const revision = getPlaylistRevision(screen, playlist, slideRows, templateRows, regionRows);
return { screen: screen, playlist: playlist, slides: slides, revision: revision };
}
function updatePlaylistRevisionHash(hash, value) {
@@ -93,7 +170,7 @@ function createPlayerPlaylistService(options) {
hash.update('\0');
}
function getPlaylistRevision(screen, playlist, slideRows, templateRows, regionRows) {
function getPlaylistRevision(screen, playlist, slideRows, templateRows, regionRows, rssFeeds, apiSources) {
const hash = crypto.createHash('sha1');
updatePlaylistRevisionHash(hash, screen && screen.id);
@@ -149,6 +226,9 @@ function createPlayerPlaylistService(options) {
updatePlaylistRevisionHash(hash, region.modified_at);
});
updatePlaylistRevisionHash(hash, JSON.stringify(rssFeeds || []));
updatePlaylistRevisionHash(hash, JSON.stringify(apiSources || []));
return hash.digest('hex');
}
+17
View File
@@ -382,6 +382,23 @@ body.screen-blackout #app {
overflow: hidden;
}
.template-region.rtmp {
background: #000;
}
.template-region.rtmp video {
width: 100%;
height: 100%;
object-fit: contain;
display: block;
pointer-events: none;
}
.template-region-rtmp-placeholder {
position: absolute;
inset: 0;
}
.template-region-placeholder {
width: 100%;
height: 100%;
@@ -0,0 +1,354 @@
// Capture the current viewport dimensions.
function getCurrentViewport() {
return {
width: window.innerWidth,
height: window.innerHeight
};
}
// Command websocket and player-state helpers.
// Send the current playback state to the command websocket.
function sendCommandState(currentSlide) {
if (!commandSocket || commandSocket.readyState !== WebSocket.OPEN) {
return;
}
commandSocket.send(JSON.stringify({
type: 'state',
clientId: getCommandClientId(),
clientName: getOnboardingClientName() || null,
deviceId: getOnboardingDeviceId() || null,
userAgent: window.navigator.userAgent || '',
page: window.location.href,
viewport: getCurrentViewport(),
paused: isPaused,
blackout: isBlackout,
currentSlide: currentSlide ? {
id: currentSlide.id || null,
title: currentSlide.title || '',
kind: currentSlide.kind || '',
playlistSignature: currentPlaylistSignature || ''
} : null
}));
}
// Debounce command-state updates during rapid changes.
function scheduleCommandStateUpdate() {
if (commandStateTimer) {
window.clearTimeout(commandStateTimer);
}
commandStateTimer = window.setTimeout(function () {
commandStateTimer = null;
sendCommandState(lastRenderedSlide);
}, 300);
}
// Debounce rerenders after viewport changes.
function scheduleViewportRenderUpdate() {
if (viewportRenderTimer) {
window.clearTimeout(viewportRenderTimer);
}
viewportRenderTimer = window.setTimeout(function () {
viewportRenderTimer = null;
if (slides.length) {
var activeSlides = getCurrentActiveSlides();
if (getCurrentRenderKey(activeSlides) === lastRenderedViewKey) {
return;
}
showCurrent();
}
}, 150);
}
// Cancel the current slide-advance timer.
function clearSlideTimer() {
if (timer) {
window.clearTimeout(timer);
timer = null;
}
}
// Schedule the next slide transition.
function scheduleSlideAdvance(delayMs) {
clearSlideTimer();
var holdDelayMs = Math.max(1, Number(delayMs || 0));
slideExpiresAt = Date.now() + holdDelayMs;
timer = window.setTimeout(function () {
timer = null;
slideExpiresAt = null;
pausedRemainingMs = null;
const activeSlides = getCurrentActiveSlides();
if (activeSlides.length < 2) {
refresh();
return;
}
if (index >= activeSlides.length) {
index = 0;
}
index = (index + 1) % activeSlides.length;
showCurrent();
}, holdDelayMs);
}
// Cancel any pending fade-transition cleanup.
function clearSlideTransitionTimer() {
if (slideTransitionTimer) {
window.clearTimeout(slideTransitionTimer);
slideTransitionTimer = null;
}
}
// Swap slide markup with optional fade animation.
function renderSlideMarkup(markup, shouldFade) {
clearSlideTransitionTimer();
if (typeof destroyRtmpRegions === 'function') {
destroyRtmpRegions(app);
}
if (!shouldFade) {
app.innerHTML = markup;
if (typeof syncRtmpRegions === 'function') {
syncRtmpRegions(app);
}
return app.firstElementChild;
}
var topLevelChildren = Array.prototype.slice.call(app.children || []);
var existingShells = topLevelChildren.filter(function (child) {
return child && child.classList && child.classList.contains('slide-shell');
});
var previousShell = existingShells.length ? existingShells[existingShells.length - 1] : app.firstElementChild;
if (existingShells.length > 1) {
existingShells.slice(0, -1).forEach(function (shell) {
if (shell && shell.parentNode) {
shell.parentNode.removeChild(shell);
}
});
}
var nextShell = document.createElement('div');
nextShell.className = 'slide-shell';
nextShell.style.opacity = '0';
nextShell.innerHTML = markup;
if (!previousShell || (previousShell.classList && previousShell.classList.contains('empty'))) {
app.innerHTML = '';
nextShell.style.opacity = '1';
app.appendChild(nextShell);
if (typeof syncRtmpRegions === 'function') {
syncRtmpRegions(nextShell);
}
return nextShell;
}
if (!previousShell.classList.contains('slide-shell')) {
previousShell.classList.add('slide-shell');
}
previousShell.style.opacity = '1';
app.appendChild(nextShell);
void nextShell.offsetHeight;
window.requestAnimationFrame(function () {
nextShell.style.opacity = '1';
previousShell.style.opacity = '0';
});
if (typeof syncRtmpRegions === 'function') {
syncRtmpRegions(nextShell);
}
slideTransitionTimer = window.setTimeout(function () {
if (previousShell && previousShell.parentNode) {
previousShell.parentNode.removeChild(previousShell);
}
if (nextShell) {
nextShell.style.opacity = '1';
}
slideTransitionTimer = null;
}, slideFadeDurationMs);
return nextShell;
}
// Mirror blackout state onto the document body.
function syncBlackoutState() {
document.body.classList.toggle('screen-blackout', isBlackout);
}
// Apply pause state and preserve remaining slide time.
function setPaused(nextPaused) {
var normalized = Boolean(nextPaused);
if (isPaused === normalized) {
return;
}
if (normalized) {
pausedRemainingMs = slideExpiresAt ? Math.max(0, slideExpiresAt - Date.now()) : null;
isPaused = true;
clearSlideTimer();
sendCommandState(lastRenderedSlide);
return;
}
isPaused = false;
sendCommandState(lastRenderedSlide);
if (!slides.length || !lastRenderedSlide) {
return;
}
if (pausedRemainingMs !== null) {
scheduleSlideAdvance(pausedRemainingMs);
pausedRemainingMs = null;
}
}
// Apply blackout state and notify the server.
function setBlackout(nextBlackout) {
var normalized = Boolean(nextBlackout);
if (isBlackout === normalized) {
return;
}
isBlackout = normalized;
syncBlackoutState();
sendCommandState(lastRenderedSlide);
}
// Coerce command payload values into booleans or null.
function normalizeBoolean(value) {
if (value === true || value === false) {
return value;
}
if (value === null || value === undefined) {
return null;
}
var normalized = String(value).trim().toLowerCase();
if (['1', 'true', 'yes', 'on'].indexOf(normalized) !== -1) {
return true;
}
if (['0', 'false', 'no', 'off', ''].indexOf(normalized) !== -1) {
return false;
}
return null;
}
// Move to the previous or next active slide.
function navigateSlides(offset) {
const manualSlides = getCurrentActiveSlides();
if (!manualSlides.length) {
return;
}
let currentIndex = manualSlides.findIndex(function (slide) {
return slide && lastRenderedSlide && slide.id === lastRenderedSlide.id;
});
if (currentIndex < 0) {
currentIndex = Math.min(Math.max(index, 0), manualSlides.length - 1);
}
const nextIndex = (currentIndex + offset + manualSlides.length) % manualSlides.length;
clearSlideTimer();
applyPendingPlaylistUpdate();
renderSlideAtIndex(manualSlides, nextIndex);
}
// Route incoming websocket command messages.
function handleCommandMessage(rawMessage) {
var payload;
try {
payload = JSON.parse(String(rawMessage || ''));
} catch (_error) {
return;
}
if (!payload || payload.type !== 'command') {
return;
}
switch (payload.command) {
case 'refresh':
refresh();
return;
case 'setclientname':
if (payload.clientName) {
applyOnboardingClientName(payload.clientName, commandSocket);
}
return;
case 'redirect':
if (payload.url) {
window.location.replace(String(payload.url));
}
return;
case 'pause':
setPaused(!isPaused);
return;
case 'blackout':
var desiredBlackout = normalizeBoolean(payload.blackout);
if (desiredBlackout !== null) {
setBlackout(desiredBlackout);
} else {
setBlackout(!isBlackout);
}
return;
case 'previous':
case 'left':
navigateSlides(-1);
return;
case 'next':
case 'right':
navigateSlides(1);
return;
case 'reload':
window.location.reload();
return;
}
}
// Retry the command websocket after a disconnect.
function scheduleCommandReconnect() {
if (commandReconnectTimer) {
return;
}
commandReconnectTimer = window.setTimeout(function () {
commandReconnectTimer = null;
connectCommandSocket();
}, 5000);
}
// Open and wire the command websocket connection.
function connectCommandSocket() {
if (!window.WebSocket) {
return;
}
if (commandSocket && (commandSocket.readyState === WebSocket.OPEN || commandSocket.readyState === WebSocket.CONNECTING)) {
return;
}
var protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
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;
socket.onopen = function () {
if (typeof syncOnboardingClientNameFromServer === 'function') {
syncOnboardingClientNameFromServer(socket).then(function () {
sendCommandHello(socket);
});
return;
}
sendCommandHello(socket);
};
socket.onmessage = function (event) {
handleCommandMessage(event.data);
};
socket.onclose = function () {
commandSocket = null;
scheduleCommandReconnect();
};
socket.onerror = function () {
try {
socket.close();
} catch (_error) {
// ignore socket close errors
}
};
}
+160
View File
@@ -0,0 +1,160 @@
// Show or hide the offline status banner.
function setOfflineBannerVisible(visible, message) {
var normalizedVisible = Boolean(visible);
var bannerMessage = String(message || 'Offline mode: using cached playlist.').trim();
if (normalizedVisible) {
if (!offlineBanner) {
offlineBanner = document.createElement('div');
offlineBanner.className = 'player-offline-banner';
offlineBanner.style.position = 'fixed';
offlineBanner.style.right = '0';
offlineBanner.style.bottom = '0';
offlineBanner.style.left = 'auto';
offlineBanner.style.top = 'auto';
offlineBanner.style.width = '1.25rem';
offlineBanner.style.height = '1.25rem';
offlineBanner.style.zIndex = '9999';
offlineBanner.style.background = 'linear-gradient(135deg, #ff4d4f 0%, #b00020 100%)';
offlineBanner.style.clipPath = 'circle(100% at 100% 100%)';
offlineBanner.style.webkitClipPath = 'circle(100% at 100% 100%)';
offlineBanner.style.boxShadow = '0 0 0 1px rgba(0, 0, 0, 0.16), 0 4px 12px rgba(0, 0, 0, 0.18)';
offlineBanner.style.pointerEvents = 'none';
document.body.appendChild(offlineBanner);
}
offlineBanner.textContent = '';
offlineBanner.setAttribute('aria-label', bannerMessage);
offlineBanner.setAttribute('role', 'img');
offlineBanner.title = bannerMessage;
offlineBannerVisible = true;
return;
}
offlineBannerVisible = false;
if (offlineBanner && offlineBanner.parentNode) {
offlineBanner.parentNode.removeChild(offlineBanner);
}
offlineBanner = null;
}
// Update the offline banner based on connectivity or playlist availability.
function syncOfflineBanner() {
if (!window.navigator.onLine) {
setOfflineBannerVisible(true, 'Offline mode: using cached playlist.');
return;
}
if (offlineBannerVisible) {
setOfflineBannerVisible(false);
}
}
// Clear any pending playlist refresh retry.
function clearRefreshRetry() {
if (refreshRetryTimer) {
window.clearTimeout(refreshRetryTimer);
refreshRetryTimer = null;
}
}
// Retry playlist refresh with a short backoff while the player is offline.
function scheduleRefreshRetry() {
if (refreshRetryTimer) {
return;
}
if (window.navigator.onLine === false) {
refreshRetryDelayMs = refreshRetryDelayMs ? Math.min(refreshRetryDelayMs * 2, 15000) : 3000;
} else {
refreshRetryDelayMs = refreshRetryDelayMs ? Math.min(refreshRetryDelayMs * 2, 8000) : 3000;
}
refreshRetryTimer = window.setTimeout(function () {
refreshRetryTimer = null;
refresh();
}, refreshRetryDelayMs);
}
function clearScreenWakeLockRetry() {
if (screenWakeLockRetryTimer) {
window.clearTimeout(screenWakeLockRetryTimer);
screenWakeLockRetryTimer = null;
}
}
function supportsScreenWakeLock() {
return Boolean(window.navigator && window.navigator.wakeLock && typeof window.navigator.wakeLock.request === 'function');
}
function scheduleScreenWakeLockRetry() {
if (screenWakeLockRetryTimer) {
return;
}
if (!supportsScreenWakeLock() || document.visibilityState !== 'visible') {
return;
}
screenWakeLockRetryTimer = window.setTimeout(function () {
screenWakeLockRetryTimer = null;
acquireScreenWakeLock();
}, 2000);
}
function releaseScreenWakeLock() {
if (screenWakeLock && typeof screenWakeLock.release === 'function') {
try {
screenWakeLock.release();
} catch (_error) {
// ignore wake lock release errors
}
}
screenWakeLock = null;
screenWakeLockRequestPromise = null;
clearScreenWakeLockRetry();
}
function acquireScreenWakeLock() {
if (!supportsScreenWakeLock() || document.visibilityState !== 'visible') {
return Promise.resolve(null);
}
if (screenWakeLockRequestPromise) {
return screenWakeLockRequestPromise;
}
if (screenWakeLock && screenWakeLock.released === false) {
return Promise.resolve(screenWakeLock);
}
screenWakeLockRequestPromise = window.navigator.wakeLock.request('screen').then(function (sentinel) {
screenWakeLock = sentinel;
screenWakeLock.addEventListener('release', function () {
screenWakeLock = null;
if (document.visibilityState === 'visible') {
scheduleScreenWakeLockRetry();
}
});
clearScreenWakeLockRetry();
return screenWakeLock;
}).catch(function (error) {
screenWakeLock = null;
if (error && error.name !== 'NotAllowedError') {
scheduleScreenWakeLockRetry();
}
return null;
}).finally(function () {
screenWakeLockRequestPromise = null;
});
return screenWakeLockRequestPromise;
}
function syncScreenWakeLock() {
if (!supportsScreenWakeLock()) {
return;
}
if (document.visibilityState === 'visible') {
acquireScreenWakeLock();
return;
}
releaseScreenWakeLock();
}
// Clear the retry cadence after a successful refresh.
function markRefreshHealthy() {
refreshRetryDelayMs = 0;
clearRefreshRetry();
}
@@ -0,0 +1,190 @@
// Render the slide at the requested index within the active set.
function renderSlideAtIndex(sourceSlides, targetIndex) {
const availableSlides = Array.isArray(sourceSlides) ? sourceSlides : [];
if (!availableSlides.length) {
renderEmpty(slides.length ? 'No slides are scheduled for this time.' : 'No slides assigned to this screen yet.');
lastRenderedViewKey = getCurrentRenderKey(availableSlides);
return false;
}
let normalizedIndex = Number(targetIndex || 0);
if (!Number.isFinite(normalizedIndex) || normalizedIndex < 0 || normalizedIndex >= availableSlides.length) {
}
const slide = availableSlides[normalizedIndex];
if (!slide) {
renderEmpty(slides.length ? 'No slides are scheduled for this time.' : 'No slides assigned to this screen yet.');
return false;
}
index = normalizedIndex;
var markup = buildSlideMarkup(slide);
renderSlideMarkup(markup, currentPlaylistFadeBetweenSlides);
sendCommandState(slide);
if (!isPaused) {
scheduleSlideAdvance(Math.max(1, Number(slide.duration_seconds || 10)) * 1000);
}
lastRenderedViewKey = getCurrentRenderKey(availableSlides);
return true;
}
// Promote a deferred playlist update at the next safe point.
function applyPendingPlaylistUpdate() {
if (!pendingPlaylistUpdate) {
return false;
}
slides = pendingPlaylistUpdate.slides;
currentPlaylistSignature = pendingPlaylistUpdate.signature;
currentPlaylistFadeBetweenSlides = pendingPlaylistUpdate.fadeBetweenSlides;
pendingPlaylistUpdate = null;
clearActiveSlidesCache();
slideMarkupCache = Object.create(null);
templateLayoutCache = Object.create(null);
templateRenderPlanCache = Object.create(null);
renderCacheViewportKey = window.innerWidth + 'x' + window.innerHeight;
index = 0;
logDebug('Applied updated playlist on slide transition.');
return true;
}
// Render the current active slide or the empty state.
function showCurrent() {
clearSlideTimer();
applyPendingPlaylistUpdate();
const activeSlides = getCurrentActiveSlides();
syncWebpagePreloads(activeSlides, index);
if (typeof syncRtmpPreloads === 'function') {
syncRtmpPreloads(activeSlides, index);
}
if (!activeSlides.length) {
renderEmpty(slides.length ? 'No slides are scheduled for this time.' : 'No slides assigned to this screen yet.');
lastRenderedViewKey = getCurrentRenderKey(activeSlides);
return;
}
renderSlideAtIndex(activeSlides, index);
lastRenderedViewKey = getCurrentRenderKey(activeSlides);
}
// Fetch the latest playlist and queue any updates.
function refresh() {
var request = new XMLHttpRequest();
var url = window.location.origin + '/api/screens/' + encodeURIComponent(slug) + '/playlist?ts=' + Date.now();
request.open('GET', url, true);
request.timeout = 2500;
if (window.__pulsePageAuthToken) {
request.setRequestHeader('x-pulse-page-auth', window.__pulsePageAuthToken);
}
if (currentPlaylistEtag) {
request.setRequestHeader('If-None-Match', currentPlaylistEtag);
}
request.onreadystatechange = function () {
if (request.readyState !== 4) {
return;
}
if (request.status === 304) {
markRefreshHealthy();
setOfflineBannerVisible(false);
if (lastRenderedSlide && getCurrentActiveSlides().length < 2) {
scheduleSlideAdvance(Math.max(1, Number(lastRenderedSlide.duration_seconds || 10)) * 1000);
}
return;
}
if (request.status < 200 || request.status >= 300) {
setOfflineBannerVisible(true);
scheduleRefreshRetry();
logDebug(
'Screen not found or playlist unavailable.',
['URL: ' + url, 'Status: ' + request.status + ' ' + request.statusText, 'Response: ' + String(request.responseText || '').slice(0, 1000)].join(' | '),
'error'
);
return;
}
try {
const responseEtag = String(request.getResponseHeader('ETag') || '').trim();
const data = JSON.parse(request.responseText || '{}');
const nextSignature = getPlaylistRevision(data);
const nextSlides = Array.isArray(data.slides) ? data.slides.map(normalizeSlide) : [];
const nextFadeBetweenSlides = Boolean(data && data.playlist && data.playlist.fade_between_slides);
savePlaylistSnapshot({
slides: nextSlides,
signature: nextSignature,
fadeBetweenSlides: nextFadeBetweenSlides,
etag: responseEtag
});
markRefreshHealthy();
setOfflineBannerVisible(false);
const currentActiveSlides = getCurrentActiveSlides();
if (responseEtag) {
currentPlaylistEtag = responseEtag;
}
if (!currentPlaylistSignature) {
slides = nextSlides;
currentPlaylistSignature = nextSignature;
currentPlaylistFadeBetweenSlides = nextFadeBetweenSlides;
index = 0;
showCurrent();
sendCommandState(lastRenderedSlide);
return;
}
if (nextSignature === currentPlaylistSignature || (pendingPlaylistUpdate && nextSignature === pendingPlaylistUpdate.signature)) {
if (currentActiveSlides.length < 2 && lastRenderedSlide) {
scheduleSlideAdvance(Math.max(1, Number(lastRenderedSlide.duration_seconds || 10)) * 1000);
}
return;
}
syncWebpagePreloads(getActiveSlidesFrom(nextSlides), index);
if (typeof syncRtmpPreloads === 'function') {
syncRtmpPreloads(getActiveSlidesFrom(nextSlides), index);
}
if (currentActiveSlides.length < 2) {
slides = nextSlides;
currentPlaylistSignature = nextSignature;
currentPlaylistFadeBetweenSlides = nextFadeBetweenSlides;
pendingPlaylistUpdate = null;
index = 0;
showCurrent();
sendCommandState(lastRenderedSlide);
return;
}
pendingPlaylistUpdate = {
slides: nextSlides,
signature: nextSignature,
fadeBetweenSlides: nextFadeBetweenSlides
};
logDebug('Playlist update detected; applying on next slide transition.');
} catch (_error) {
logDebug(
'Unable to load screen playlist.',
['URL: ' + url, 'Response: ' + String(request.responseText || '').slice(0, 1000)].join(' | '),
'error'
);
setOfflineBannerVisible(true);
scheduleRefreshRetry();
}
};
request.onerror = function () {
logDebug(
'Unable to load screen playlist.',
['URL: ' + url, 'Network error during request.'].join(' | '),
'error'
);
setOfflineBannerVisible(true);
scheduleRefreshRetry();
if (lastRenderedSlide && getCurrentActiveSlides().length < 2) {
scheduleSlideAdvance(Math.max(1, Number(lastRenderedSlide.duration_seconds || 10)) * 1000);
}
};
request.ontimeout = function () {
logDebug(
'Playlist refresh timed out.',
['URL: ' + url, 'Timeout after ' + request.timeout + 'ms'].join(' | '),
'error'
);
setOfflineBannerVisible(true);
scheduleRefreshRetry();
if (lastRenderedSlide && getCurrentActiveSlides().length < 2) {
scheduleSlideAdvance(Math.max(1, Number(lastRenderedSlide.duration_seconds || 10)) * 1000);
}
};
request.send();
}
@@ -0,0 +1,216 @@
// Collect unique webpage URLs from the slide list.
function getWebpageUrls(sourceSlides) {
const urls = [];
(Array.isArray(sourceSlides) ? sourceSlides : []).forEach(function (slide) {
const content = slide && slide.content ? slide.content : {};
const regions = slide && slide.template && Array.isArray(slide.template.regions) ? slide.template.regions : [];
regions.forEach(function (region) {
if (region.region_type !== 'webpage') {
return;
}
const regionContent = content[region.region_key] || {};
const url = String(regionContent.value || '').trim();
if (url && urls.indexOf(url) === -1) {
urls.push(url);
}
});
});
return urls;
}
// Filter the slides down to those that are active right now.
function getActiveSlidesFrom(sourceSlides) {
const now = new Date();
return (Array.isArray(sourceSlides) ? sourceSlides : []).filter(function (slide) {
return isSlideActive(slide, now);
});
}
// Build a cache key for the active slide set.
function getActiveSlidesCacheKey() {
const now = new Date();
return [
currentPlaylistSignature || '',
now.getFullYear(),
now.getMonth(),
now.getDate(),
now.getHours(),
now.getMinutes(),
now.getSeconds()
].join('|');
}
// Return the cached active slide set for the current playlist and second.
function getCurrentActiveSlides() {
const cacheKey = getActiveSlidesCacheKey();
if (cacheKey !== activeSlidesCacheKey) {
activeSlidesCacheValue = getActiveSlidesFrom(slides);
activeSlidesCacheKey = cacheKey;
}
return activeSlidesCacheValue;
}
// Clear the cached active slide set.
function clearActiveSlidesCache() {
activeSlidesCacheKey = '';
activeSlidesCacheValue = [];
}
// Reset render caches when the viewport changes.
function syncRenderCacheViewport() {
var viewportKey = window.innerWidth + 'x' + window.innerHeight;
if (renderCacheViewportKey === viewportKey) {
return;
}
renderCacheViewportKey = viewportKey;
slideMarkupCache = Object.create(null);
templateLayoutCache = Object.create(null);
templateRenderPlanCache = Object.create(null);
}
// Build a signature for the currently rendered view.
function getCurrentRenderKey(activeSlides) {
const viewportKey = window.innerWidth + 'x' + window.innerHeight;
const availableSlides = Array.isArray(activeSlides) ? activeSlides : [];
if (!availableSlides.length) {
return [currentPlaylistSignature || '', viewportKey, 'empty', slides.length ? 'scheduled' : 'assigned'].join('|');
}
let normalizedIndex = Number(index || 0);
if (!Number.isFinite(normalizedIndex) || normalizedIndex < 0 || normalizedIndex >= availableSlides.length) {
normalizedIndex = 0;
}
const slide = availableSlides[normalizedIndex];
return [currentPlaylistSignature || '', viewportKey, 'slide', slide && slide.id ? slide.id : ''].join('|');
}
// Pick the current slide and the next slide for webpage preloading.
function getWebpagePreloadSlides(sourceSlides, targetIndex) {
const availableSlides = Array.isArray(sourceSlides) ? sourceSlides : [];
if (!availableSlides.length) {
return [];
}
let normalizedIndex = Number(targetIndex || 0);
if (!Number.isFinite(normalizedIndex) || normalizedIndex < 0 || normalizedIndex >= availableSlides.length) {
normalizedIndex = 0;
}
const preloadSlides = [];
const currentSlide = availableSlides[normalizedIndex];
const nextSlide = availableSlides[normalizedIndex + 1];
if (currentSlide) {
preloadSlides.push(currentSlide);
}
if (nextSlide && nextSlide !== currentSlide) {
preloadSlides.push(nextSlide);
}
return preloadSlides;
}
// Mount hidden iframe preloads for the chosen webpage URLs.
function syncWebpagePreloads(sourceSlides, targetIndex) {
const urls = getWebpageUrls(getWebpagePreloadSlides(sourceSlides, targetIndex));
const signature = urls.join('\n');
if (signature === preloadSignature) {
return;
}
if (!urls.length) {
preloadSignature = '';
if (preloadContainer) {
preloadContainer.innerHTML = '';
}
return;
}
if (!preloadContainer) {
preloadContainer = document.createElement('div');
preloadContainer.className = 'webpage-preloads';
preloadContainer.setAttribute('aria-hidden', 'true');
document.body.appendChild(preloadContainer);
}
preloadContainer.innerHTML = urls.map(function (url) {
return '<iframe class="webpage-preload-frame" src="' + escapeHtml(url) + '" title="Webpage preload" tabindex="-1" loading="eager" referrerpolicy="no-referrer" scrolling="no"></iframe>';
}).join('');
preloadSignature = signature;
}
// Return a stable client id for this browser session.
function getCommandClientId() {
if (commandClientId) {
return commandClientId;
}
try {
var storedClientId = window.localStorage.getItem(commandClientStorageKey);
if (storedClientId) {
commandClientId = storedClientId;
return commandClientId;
}
} catch (_error) {
// fall through to ephemeral ID generation
}
commandClientId = (window.crypto && window.crypto.randomUUID ? window.crypto.randomUUID() : 'client-' + Date.now() + '-' + Math.random().toString(16).slice(2));
try {
window.localStorage.setItem(commandClientStorageKey, commandClientId);
} catch (_error2) {
// ignore storage errors
}
return commandClientId;
}
// Load the most recent playlist snapshot from browser storage.
function loadPlaylistSnapshot() {
try {
var raw = window.localStorage.getItem(playlistSnapshotStorageKey);
if (!raw) {
return null;
}
var parsed = JSON.parse(raw);
if (!parsed || !Array.isArray(parsed.slides)) {
return null;
}
return {
slides: parsed.slides.map(normalizeSlide),
signature: String(parsed.signature || ''),
fadeBetweenSlides: Boolean(parsed.fadeBetweenSlides),
etag: String(parsed.etag || '')
};
} catch (_error) {
return null;
}
}
// Save the latest playlist snapshot for offline recovery.
function savePlaylistSnapshot(data) {
try {
window.localStorage.setItem(playlistSnapshotStorageKey, JSON.stringify({
slides: Array.isArray(data && data.slides) ? data.slides : [],
signature: String(data && data.signature || ''),
fadeBetweenSlides: Boolean(data && data.fadeBetweenSlides),
etag: String(data && data.etag || ''),
savedAt: new Date().toISOString()
}));
} catch (_error) {
// ignore storage errors
}
}
// Apply a playlist snapshot to the current in-memory state.
function applyPlaylistSnapshot(data) {
if (!data || !Array.isArray(data.slides)) {
return false;
}
slides = data.slides.map(normalizeSlide);
currentPlaylistSignature = String(data.signature || '');
currentPlaylistFadeBetweenSlides = Boolean(data.fadeBetweenSlides);
currentPlaylistEtag = String(data.etag || '');
pendingPlaylistUpdate = null;
clearActiveSlidesCache();
slideMarkupCache = Object.create(null);
templateLayoutCache = Object.create(null);
templateRenderPlanCache = Object.create(null);
renderCacheViewportKey = '';
index = 0;
return true;
}
@@ -0,0 +1,537 @@
// General sanitization and sizing helpers.
// Strip unsupported characters from a font family string.
function sanitizeFontFamily(value) {
return String(value || '').replace(/[^a-zA-Z0-9 ,\"-]/g, '').trim();
}
// Clamp font size to the supported range.
function sanitizeFontSize(value) {
return Math.max(8, Number(value || 0) || 24);
}
// Validate a text color and fall back when needed.
function sanitizeTextColor(value, fallback) {
var raw = String(value || '').trim();
if (/^#[0-9a-fA-F]{6}$/.test(raw) || /^#[0-9a-fA-F]{3}$/.test(raw)) {
return raw;
}
return fallback || '#000000';
}
// Read the template's canvas dimensions with safe defaults.
function getTemplateCanvasSize(template) {
return {
width: Math.max(1, Number(template.canvas_size_width || 1920)),
height: Math.max(1, Number(template.canvas_size_height || 1080))
};
}
// Read the server-supplied playlist revision, or fall back to the ETag.
function getPlaylistRevision(data) {
if (data && data.revision) {
return String(data.revision);
}
if (data && data.playlist && data.playlist.revision) {
return String(data.playlist.revision);
}
if (currentPlaylistEtag) {
return String(currentPlaylistEtag).replace(/^"|"$/g, '');
}
return String(Date.now());
}
// Scale a canvas to fit within the viewport.
function fitCanvasSize(canvasWidth, canvasHeight, maxWidth, maxHeight) {
var width = Math.max(1, Number(canvasWidth || 0) || 1920);
var height = Math.max(1, Number(canvasHeight || 0) || 1080);
var viewportWidth = Math.max(1, Number(maxWidth || 0) || width);
var viewportHeight = Math.max(1, Number(maxHeight || 0) || height);
var scale = Math.min(viewportWidth / width, viewportHeight / height);
return {
width: Math.round(width * scale),
height: Math.round(height * scale)
};
}
const ALLOWED_RICH_TEXT_TAGS = ['a', 'b', 'blockquote', 'br', 'code', 'div', 'em', 'figure', 'figcaption', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'hr', 'i', 'li', 'ol', 'p', 'pre', 'span', 'strong', 'sub', 'sup', 'table', 'tbody', 'td', 'th', 'thead', 'tr', 'u', 'ul'];
function sanitizeRichTextAttributes(tagName, attrText) {
const allowedAttributes = {
a: ['href', 'title', 'target', 'rel', 'class', 'style'],
blockquote: ['class', 'style'],
div: ['class', 'style'],
figure: ['class', 'style'],
figcaption: ['class', 'style'],
h1: ['class', 'style'],
h2: ['class', 'style'],
h3: ['class', 'style'],
h4: ['class', 'style'],
h5: ['class', 'style'],
h6: ['class', 'style'],
li: ['class', 'style'],
ol: ['class', 'style', 'start'],
p: ['class', 'style'],
pre: ['class', 'style'],
span: ['class', 'style'],
table: ['class', 'style'],
td: ['class', 'style', 'colspan', 'rowspan'],
th: ['class', 'style', 'colspan', 'rowspan', 'scope'],
tr: ['class', 'style'],
ul: ['class', 'style']
};
const allowed = allowedAttributes[tagName] || [];
if (!allowed.length) {
return '';
}
const attrs = [];
String(attrText || '').replace(/([a-zA-Z0-9:-]+)(?:\s*=\s*("([^"]*)"|'([^']*)'|([^\s"'>/=`]+)))?/g, (_full, key, _valuePart, doubleQuoted, singleQuoted, bareValue) => {
const lowerKey = String(key || '').toLowerCase();
if (!allowed.includes(lowerKey)) {
return '';
}
const value = doubleQuoted !== undefined ? doubleQuoted : singleQuoted !== undefined ? singleQuoted : bareValue !== undefined ? bareValue : '';
if (lowerKey === 'href' && /^(?:\s*javascript:|\s*data:)/i.test(String(value || ''))) {
return '';
}
if (lowerKey === 'style' && /(?:expression\s*\(|javascript:|url\s*\()/i.test(String(value || ''))) {
return '';
}
if (lowerKey === 'target') {
const targetValue = String(value || '').trim();
if (targetValue === '_blank') {
attrs.push(' target="_blank"');
if (!attrs.includes(' rel="noreferrer noopener"')) {
attrs.push(' rel="noreferrer noopener"');
}
return '';
}
}
attrs.push(' ' + lowerKey + '="' + escapeHtml(value) + '"');
return '';
});
return attrs.join('');
}
// Remove unsafe markup while preserving richer CKEditor formatting.
function sanitizeRichText(html) {
var output = String(html || '');
output = output.replace(/<script[\s\S]*?<\/script>/gi, '');
output = output.replace(/<style[\s\S]*?<\/style>/gi, '');
return output.replace(/<[^>]+>/g, function (tag) {
var match = tag.match(/^<\s*(\/?)\s*([a-z0-9]+)([\s\S]*?)(\/?)>$/i);
if (!match) {
return '';
}
var closing = Boolean(match[1]);
var name = String(match[2] || '').toLowerCase();
var attrText = String(match[3] || '');
if (ALLOWED_RICH_TEXT_TAGS.indexOf(name) === -1) {
return '';
}
if (closing) {
return '</' + name + '>';
}
return '<' + name + sanitizeRichTextAttributes(name, attrText) + '>';
});
}
// Render a single Editor.js block to HTML.
function renderEditorJsBlock(block) {
if (!block || !block.type || !block.data) {
return '';
}
if (block.type === 'header') {
var level = Math.max(1, Math.min(6, Number(block.data.level || 2)));
return '<h' + level + '>' + sanitizeRichText(block.data.text || '') + '</h' + level + '>';
}
if (block.type === 'list') {
var tag = block.data.style === 'ordered' ? 'ol' : 'ul';
var items = Array.isArray(block.data.items) ? block.data.items : [];
return '<' + tag + ' style="list-style-type:' + (tag === 'ol' ? 'decimal' : 'disc') + ';padding-left:1.4em;">' + items.map(function (item) { return renderEditorJsListItem(item, tag); }).join('') + '</' + tag + '>';
}
if (block.type === 'delimiter') {
return '<hr />';
}
if (block.type === 'code') {
return '<pre><code>' + escapeHtml(block.data.code || '') + '</code></pre>';
}
if (block.type === 'table') {
return renderEditorJsTable(block.data);
}
if (block.type === 'paragraph') {
return '<p>' + sanitizeRichText(block.data.text || '') + '</p>';
}
return '';
}
// Render a list item and any nested sub-items.
function renderEditorJsListItem(item, tag) {
if (item && typeof item === 'object') {
var content = item.content !== undefined ? item.content : (item.text !== undefined ? item.text : item.value !== undefined ? item.value : '');
var children = Array.isArray(item.items) ? item.items : Array.isArray(item.subItems) ? item.subItems : Array.isArray(item.children) ? item.children : [];
var nested = children.length ? '<' + tag + ' style="list-style-type:' + (tag === 'ol' ? 'decimal' : 'disc') + ';padding-left:1.4em;">' + children.map(function (child) { return renderEditorJsListItem(child, tag); }).join('') + '</' + tag + '>' : '';
return '<li>' + sanitizeRichText(content || '') + nested + '</li>';
}
return '<li>' + sanitizeRichText(item || '') + '</li>';
}
// Render an Editor.js table block.
function renderEditorJsTable(data) {
var rows = Array.isArray(data.content) ? data.content : Array.isArray(data.rows) ? data.rows : [];
if (!rows.length) {
return '';
}
var hasHeadings = Boolean(data.withHeadings);
var tableRows = rows.map(function (row, rowIndex) {
var cells = Array.isArray(row) ? row : [];
var cellTag = hasHeadings && rowIndex === 0 ? 'th' : 'td';
var cellAttrs = cellTag === 'th' ? ' scope="col"' : '';
return '<tr>' + cells.map(function (cell) {
return '<' + cellTag + cellAttrs + '>' + sanitizeRichText(cell || '') + '</' + cellTag + '>';
}).join('') + '</tr>';
}).join('');
return '<table class="ck-content-table">' + tableRows + '</table>';
}
// Render Editor.js JSON or plain content safely.
function renderEditorJsContent(value) {
if (value && typeof value === 'object') {
if (Array.isArray(value.blocks)) {
return value.blocks.map(renderEditorJsBlock).join('');
}
if (value.value !== undefined) {
return renderEditorJsContent(value.value);
}
}
var raw = String(value || '');
try {
var parsed = JSON.parse(raw);
if (parsed && Array.isArray(parsed.blocks)) {
return parsed.blocks.map(renderEditorJsBlock).join('');
}
} catch (_error) {
// fall through to legacy HTML rendering
}
return sanitizeRichText(raw);
}
// Parse string values that look like JSON.
function parseMaybeJson(value) {
if (typeof value !== 'string') {
return value;
}
var raw = value.trim();
if (!raw) {
return value;
}
if (raw.charAt(0) !== '{' && raw.charAt(0) !== '[') {
return value;
}
try {
return JSON.parse(raw);
} catch (_error) {
return value;
}
}
// Normalize a slide region's stored content value.
function normalizeContentValue(value) {
var normalized;
if (!value || typeof value !== 'object') {
return {
type: 'text',
value: parseMaybeJson(value)
};
}
normalized = {};
Object.keys(value).forEach(function (key) {
normalized[key] = value[key];
});
if (normalized.value !== undefined) {
normalized.value = parseMaybeJson(normalized.value);
}
if (normalized.font_family !== undefined && normalized.font_family !== null) {
normalized.font_family = sanitizeFontFamily(normalized.font_family);
}
if (normalized.font_size !== undefined && normalized.font_size !== null) {
normalized.font_size = sanitizeFontSize(normalized.font_size);
}
if (normalized.font_color !== undefined && normalized.font_color !== null) {
normalized.font_color = sanitizeTextColor(normalized.font_color);
}
if (!normalized.type) {
normalized.type = 'text';
}
return normalized;
}
// Normalize a slide and its nested region content.
function normalizeSlide(slide) {
var normalized = {};
var content;
Object.keys(slide || {}).forEach(function (key) {
normalized[key] = slide[key];
});
normalized.content = {};
content = slide && slide.content && typeof slide.content === 'object' ? slide.content : {};
Object.keys(content).forEach(function (regionKey) {
normalized.content[regionKey] = normalizeContentValue(content[regionKey]);
});
return normalized;
}
// Parse the stored schedule-day list into numbers.
function parseScheduleDays(value) {
if (!value) {
return [];
}
if (Array.isArray(value)) {
return value.map(function (item) { return Number(item); }).filter(function (item) { return !Number.isNaN(item); });
}
try {
const parsed = JSON.parse(value);
return Array.isArray(parsed) ? parsed.map(function (item) { return Number(item); }).filter(function (item) { return !Number.isNaN(item); }) : [];
} catch (_error) {
return [];
}
}
// Convert a HH:MM time string to minutes since midnight.
function parseTimeToMinutes(value) {
const raw = String(value || '').trim();
if (!raw) {
return null;
}
const match = raw.match(/^(\d{2}):(\d{2})/);
if (!match) {
return null;
}
return Number(match[1]) * 60 + Number(match[2]);
}
// Determine whether a slide should be shown at the current time.
function isSlideActive(slide, now) {
const mode = String(slide.schedule_mode || 'always');
if (mode === 'always') {
return true;
}
if (mode === 'dates') {
const start = slide.schedule_start_datetime ? new Date(slide.schedule_start_datetime) : null;
const end = slide.schedule_end_datetime ? new Date(slide.schedule_end_datetime) : null;
if (!start || !end || Number.isNaN(start.getTime()) || Number.isNaN(end.getTime())) {
return false;
}
return now >= start && now <= end;
}
if (mode === 'times') {
const days = parseScheduleDays(slide.schedule_days_json);
if (!days.length) {
return false;
}
const day = now.getDay();
if (days.indexOf(day) === -1) {
return false;
}
const startMinutes = parseTimeToMinutes(slide.schedule_start_time);
const endMinutes = parseTimeToMinutes(slide.schedule_end_time);
if (startMinutes === null || endMinutes === null) {
return false;
}
const nowMinutes = now.getHours() * 60 + now.getMinutes();
if (startMinutes <= endMinutes) {
return nowMinutes >= startMinutes && nowMinutes <= endMinutes;
}
return nowMinutes >= startMinutes || nowMinutes <= endMinutes;
}
return true;
}
// Build the cache key for a template layout.
function getTemplateLayoutCacheKey(template) {
var viewportKey = window.innerWidth + 'x' + window.innerHeight;
return [currentPlaylistSignature || '', template && template.id ? template.id : '', viewportKey].join('|');
}
// Build or reuse layout metadata for a template.
function getTemplateLayout(template) {
if (!template || !template.id) {
return null;
}
syncRenderCacheViewport();
var cacheKey = getTemplateLayoutCacheKey(template);
if (Object.prototype.hasOwnProperty.call(templateLayoutCache, cacheKey)) {
return templateLayoutCache[cacheKey];
}
var templateCanvas = getTemplateCanvasSize(template);
var canvasSize = fitCanvasSize(templateCanvas.width, templateCanvas.height, window.innerWidth, window.innerHeight);
var canvasScale = canvasSize.width / templateCanvas.width;
var regions = (template.regions || []).map(function (region) {
var left = (Number(region.x) / templateCanvas.width) * 100;
var top = (Number(region.y) / templateCanvas.height) * 100;
var width = (Number(region.width) / templateCanvas.width) * 100;
var height = (Number(region.height) / templateCanvas.height) * 100;
var baseStyle = 'left:' + left + '%;top:' + top + '%;width:' + width + '%;height:' + height + '%;z-index:' + Number(region.z_index || 0) + ';';
var pixelWidth = Math.max(1, Math.round(Number(region.width || 0) || 1));
var pixelHeight = Math.max(1, Math.round(Number(region.height || 0) || 1));
return {
regionKey: region.region_key,
regionType: region.region_type,
label: region.label,
baseStyle: baseStyle,
pixelWidth: pixelWidth,
pixelHeight: pixelHeight,
fontFamily: region.font_family || null,
fontSize: region.font_size || null,
fontColor: region.font_color || null,
canvasScale: canvasScale
};
});
var layout = {
canvasWidth: canvasSize.width,
canvasHeight: canvasSize.height,
background: template.background_image_path ? '<img class="template-background" src="' + escapeHtml(template.background_image_path) + '" alt="" />' : '',
backgroundColor: template.background_color || '#111111',
regions: regions
};
templateLayoutCache[cacheKey] = layout;
return layout;
}
// Build the cache key for a template render plan.
function getTemplateRenderPlanCacheKey(template) {
return getTemplateLayoutCacheKey(template);
}
// Build or reuse the render plan for a template.
function getTemplateRenderPlan(template) {
if (!template || !template.id) {
return null;
}
syncRenderCacheViewport();
var cacheKey = getTemplateRenderPlanCacheKey(template);
if (Object.prototype.hasOwnProperty.call(templateRenderPlanCache, cacheKey)) {
return templateRenderPlanCache[cacheKey];
}
var layout = getTemplateLayout(template);
var plan = {
layout: layout,
renderRegion: function (region, regionContent) {
if (region.regionType === 'image') {
return renderImageRegion(region, regionContent);
}
if (region.regionType === 'webpage') {
return renderWebpageRegion(region, regionContent);
}
if (region.regionType === 'rtmp') {
return renderRtmpRegion(region, regionContent);
}
if (region.regionType === 'rss') {
return renderRssRegion(region, regionContent);
}
if (region.regionType === 'api') {
return renderApiRegion(region, regionContent);
}
if (region.regionType === 'html') {
return renderHtmlRegion(region, regionContent);
}
return renderTextRegion(region, regionContent);
}
};
templateRenderPlanCache[cacheKey] = plan;
return plan;
}
// Render a template-based slide using the cached layout.
function renderTemplateSlideMarkup(slide) {
const template = slide.template;
const content = slide.content || {};
const plan = getTemplateRenderPlan(template);
const layout = plan ? plan.layout : null;
const regions = layout ? layout.regions.map(function (region) {
const regionContent = content[region.regionKey] || {};
return plan.renderRegion(region, regionContent);
}).join('') : '';
const stageStyle = layout ? 'background-color:' + escapeHtml(layout.backgroundColor || '#111111') + ';' : '';
return renderSlideShell(slide, '', (layout ? layout.canvasWidth : 0) + 'px', (layout ? layout.canvasHeight : 0) + 'px', '<div class="template-stage" style="' + stageStyle + '">' + (layout ? layout.background : '') + regions + '</div>');
}
// Media rendering helpers.
// Build the direct media element for a slide.
function renderMediaSlideContent(slide) {
if (slide.kind === 'image') {
return '<img src="' + escapeHtml(slide.media_url) + '" alt="slide" />';
}
return '';
}
// Render a slide that contains direct media content.
function renderMediaSlideMarkup(slide) {
const canvasSize = fitCanvasSize(16, 9, window.innerWidth, window.innerHeight);
const media = renderMediaSlideContent(slide);
return renderSlideShell(slide, 'slide-media', canvasSize.width + 'px', canvasSize.height + 'px', media);
}
// Render the shared slide shell around slide-specific inner content.
function renderSlideShell(slide, canvasClass, canvasWidth, canvasHeight, innerHtml) {
const body = slide.body ? '<div class="body">' + escapeHtml(slide.body) + '</div>' : '';
const className = canvasClass ? 'slide-canvas ' + canvasClass : 'slide-canvas';
return '<div class="slide"><div class="' + className + '" style="width:' + canvasWidth + ';height:' + canvasHeight + ';">' + innerHtml + body + '</div></div>';
}
// Build the cache key for rendered slide markup.
function getSlideMarkupCacheKey(slide) {
var viewportKey = window.innerWidth + 'x' + window.innerHeight;
return [currentPlaylistSignature || '', slide && slide.id ? slide.id : '', slide && slide.template_id ? slide.template_id : '', viewportKey].join('|');
}
// Look up a previously rendered slide in the cache.
function getCachedSlideMarkup(slide) {
var cacheKey = getSlideMarkupCacheKey(slide);
return Object.prototype.hasOwnProperty.call(slideMarkupCache, cacheKey) ? slideMarkupCache[cacheKey] : null;
}
// Store rendered slide markup in the cache.
function setCachedSlideMarkup(slide, markup) {
syncRenderCacheViewport();
slideMarkupCache[getSlideMarkupCacheKey(slide)] = markup;
}
// Slide rendering and markup cache helpers.
// Choose the right slide renderer and cache the result.
function buildSlideMarkup(slide) {
lastRenderedSlide = slide || null;
syncBlackoutState();
var cachedMarkup = getCachedSlideMarkup(slide);
if (cachedMarkup) {
return cachedMarkup;
}
var markup = '';
if (slide.template_id && slide.template) {
markup = renderTemplateSlideMarkup(slide);
setCachedSlideMarkup(slide, markup);
return markup;
}
markup = renderMediaSlideMarkup(slide);
setCachedSlideMarkup(slide, markup);
return markup;
}
+141
View File
@@ -0,0 +1,141 @@
const CACHE_VERSION = 'v1';
const PAGE_CACHE = `pulse-signage-player-pages-${CACHE_VERSION}`;
const ASSET_CACHE = `pulse-signage-player-assets-${CACHE_VERSION}`;
const MEDIA_CACHE = `pulse-signage-player-media-${CACHE_VERSION}`;
const PLAYLIST_CACHE = `pulse-signage-player-playlists-${CACHE_VERSION}`;
function normalizeRequest(request) {
const url = new URL(request.url);
return new Request(`${url.origin}${url.pathname}`, {
method: 'GET',
headers: request.headers,
mode: 'same-origin',
credentials: 'same-origin'
});
}
async function cacheResponse(cacheName, request, response, cacheKeyRequest) {
if (!response || !response.ok) {
return;
}
const cache = await caches.open(cacheName);
await cache.put(cacheKeyRequest || request, response.clone());
}
async function networkFirst(request, cacheName, cacheKeyRequest) {
try {
const response = await fetch(request);
if (response && response.ok) {
await cacheResponse(cacheName, request, response, cacheKeyRequest);
return response;
}
if (response && response.status === 304) {
const cached = await caches.match(cacheKeyRequest || request);
if (cached) {
return cached;
}
}
const cached = await caches.match(cacheKeyRequest || request);
if (cached) {
return cached;
}
return response;
} catch (_error) {
const cached = await caches.match(cacheKeyRequest || request);
if (cached) {
return cached;
}
throw _error;
}
}
async function cacheFirst(request, cacheName) {
const cached = await caches.match(request);
if (cached) {
return cached;
}
const response = await fetch(request);
if (response && response.ok) {
await cacheResponse(cacheName, request, response);
}
return response;
}
async function staleWhileRevalidate(request, cacheName) {
const cached = await caches.match(request);
const networkPromise = fetch(request).then(async function (response) {
if (response && response.ok) {
await cacheResponse(cacheName, request, response);
}
return response;
}).catch(function () {
return null;
});
if (cached) {
networkPromise.catch(function () {
return null;
});
return cached;
}
const networkResponse = await networkPromise;
if (networkResponse) {
return networkResponse;
}
return new Response('', { status: 504, statusText: 'Offline' });
}
self.addEventListener('install', function (event) {
self.skipWaiting();
event.waitUntil(Promise.resolve());
});
self.addEventListener('activate', function (event) {
event.waitUntil((async function () {
const expected = [PAGE_CACHE, ASSET_CACHE, MEDIA_CACHE, PLAYLIST_CACHE];
const keys = await caches.keys();
await Promise.all(keys.filter(function (key) {
return expected.indexOf(key) === -1;
}).map(function (key) {
return caches.delete(key);
}));
await self.clients.claim();
})());
});
self.addEventListener('fetch', function (event) {
const request = event.request;
if (request.method !== 'GET') {
return;
}
const url = new URL(request.url);
if (url.origin !== self.location.origin) {
return;
}
if (url.pathname === '/sw.js') {
return;
}
if (url.pathname.startsWith('/assets/')) {
event.respondWith(cacheFirst(request, ASSET_CACHE));
return;
}
if (url.pathname.startsWith('/media/')) {
event.respondWith(staleWhileRevalidate(request, MEDIA_CACHE));
return;
}
if (request.mode === 'navigate' || url.pathname === '/' || url.pathname === '/onboard' || /^\/screen\/[^/]+$/.test(url.pathname)) {
event.respondWith(networkFirst(request, PAGE_CACHE, normalizeRequest(request)));
return;
}
if (url.pathname.startsWith('/api/screens/') && url.pathname.endsWith('/playlist')) {
event.respondWith(networkFirst(request, PLAYLIST_CACHE, normalizeRequest(request)));
}
});
+97
View File
@@ -0,0 +1,97 @@
function getApiSourceById(sourceId) {
var sources = Array.isArray(initialData && initialData.apiSources) ? initialData.apiSources : [];
var normalizedId = Number(sourceId || 0);
return sources.find(function (source) {
return Number(source.id) === normalizedId;
}) || null;
}
function getApiSourceItems(sourceId) {
var source = getApiSourceById(sourceId);
var responseJson = source && source.responseJson && typeof source.responseJson === 'object' ? source.responseJson : null;
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] : [];
}
function getApiItem(sourceId, itemNumber) {
var items = getApiSourceItems(sourceId);
var parsedItemNumber = Math.max(1, Number(itemNumber || 1));
var index = Number.isFinite(parsedItemNumber) && parsedItemNumber > 0 ? parsedItemNumber - 1 : 0;
return items[index] || null;
}
function resolveApiPath(value, path) {
var current = value;
if (!path) {
return current;
}
String(path).split('.').forEach(function (segment) {
if (current === undefined || current === null) {
current = '';
return;
}
current = current[segment];
});
return current === undefined || current === null ? '' : current;
}
function substituteApiVariables(html, item) {
var source = String(html || '');
return source.replace(/\{\{\s*([a-zA-Z0-9_]+)(?:\.([a-zA-Z0-9_.]+))?\s*\}\}/g, function (_match, tokenName, tokenPath) {
if (!item || typeof item !== 'object') {
return '';
}
var key = tokenName === 'item' && tokenPath ? tokenPath : tokenName;
return escapeHtml(resolveApiPath(item, key || ''));
});
}
function getApiPreviewFallback(item) {
if (!item || typeof item !== 'object') {
return '<div class="template-region-placeholder">API item</div>';
}
var title = String(item.title || item.name || '').trim();
var description = String(item.description || item.summary || item.text || '').trim();
var summary = [];
if (title) {
summary.push('<h3>' + escapeHtml(title) + '</h3>');
}
if (description) {
summary.push('<div>' + sanitizeRichText(description) + '</div>');
}
if (!summary.length) {
return '<div class="template-region-placeholder">API item</div>';
}
return summary.join('');
}
function renderApiRegion(region, regionContent) {
var content = regionContent && regionContent.value !== undefined ? regionContent.value : '';
var sourceId = regionContent && regionContent.source_id !== undefined ? regionContent.source_id : null;
var itemNumber = regionContent && regionContent.item_number !== undefined ? regionContent.item_number : 1;
var fontFamily = sanitizeFontFamily(regionContent.font_family || region.fontFamily);
var fontSize = sanitizeFontSize(regionContent.font_size || region.fontSize);
var fontColor = sanitizeTextColor(regionContent.font_color || region.fontColor);
var item = getApiItem(sourceId, itemNumber);
var body = item ? substituteApiVariables(content, item) : '';
if (!body && item) {
body = getApiPreviewFallback(item);
}
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) + ';';
var renderedBody = body ? renderEditorJsContent(body) : '<div class="template-region-placeholder">API item</div>';
return '<div class="template-region api" style="' + region.baseStyle + '"><div class="template-region-text-scale" style="' + contentStyle + '">' + renderedBody + '</div></div>';
}
+11
View File
@@ -0,0 +1,11 @@
function renderHtmlRegionContent(value) {
var html = String(value || '').trim();
if (!html) {
return '<div class="template-region-placeholder">HTML</div>';
}
return '<iframe class="template-region-html-frame" sandbox="" scrolling="no" srcdoc="<!doctype html><html><head><style>html,body{margin:0;width:100%;height:100%;overflow:hidden;background:transparent;}</style></head><body>' + escapeHtml(html) + '</body></html>" title="HTML region" loading="eager"></iframe>';
}
function renderHtmlRegion(region, regionContent) {
return '<div class="template-region html" style="' + region.baseStyle + '">' + renderHtmlRegionContent(regionContent.value || '') + '</div>';
}
+4
View File
@@ -0,0 +1,4 @@
function renderImageRegion(region, regionContent) {
var src = regionContent.value || '';
return '<div class="template-region image" style="' + region.baseStyle + '"><img src="' + escapeHtml(src) + '" alt="' + escapeHtml(region.label) + '" /></div>';
}
+67
View File
@@ -0,0 +1,67 @@
function getRssFeedById(feedId) {
var feeds = Array.isArray(initialData && initialData.rssFeeds) ? initialData.rssFeeds : [];
var normalizedId = Number(feedId || 0);
return feeds.find(function (feed) {
return Number(feed.id) === normalizedId;
}) || null;
}
function getRssFeedItem(feedId, itemNumber) {
var feed = getRssFeedById(feedId);
var items = feed && Array.isArray(feed.items) ? feed.items : [];
var parsedItemNumber = Math.max(1, Number(itemNumber || 1));
var index = Number.isFinite(parsedItemNumber) && parsedItemNumber > 0 ? parsedItemNumber - 1 : 0;
return items[index] || null;
}
function resolveRssPath(value, path) {
var current = value;
if (!path) {
return current;
}
String(path).split('.').forEach(function (segment) {
if (current === undefined || current === null) {
current = '';
return;
}
current = current[segment];
});
return current === undefined || current === null ? '' : current;
}
function substituteRssVariables(html, item) {
var source = String(html || '');
return source.replace(/\{\{\s*([a-zA-Z0-9_]+)(?:\.([a-zA-Z0-9_.]+))?\s*\}\}/g, function (_match, tokenName, tokenPath) {
if (!item || typeof item !== 'object') {
return '';
}
var key = tokenName === 'item' && tokenPath ? tokenPath : tokenName;
return escapeHtml(resolveRssPath(item, key || ''));
});
}
function renderRssRegion(region, regionContent) {
var content = regionContent && regionContent.value !== undefined ? regionContent.value : '';
var feedId = regionContent && regionContent.feed_id !== undefined ? regionContent.feed_id : null;
var itemNumber = regionContent && regionContent.item_number !== undefined ? regionContent.item_number : 1;
var fontFamily = sanitizeFontFamily(regionContent.font_family || region.fontFamily);
var fontSize = sanitizeFontSize(regionContent.font_size || region.fontSize);
var fontColor = sanitizeTextColor(regionContent.font_color || region.fontColor);
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 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) + ';';
var renderedBody = body ? renderEditorJsContent(body) : '<div class="template-region-placeholder">RSS item</div>';
return '<div class="template-region rss" style="' + region.baseStyle + '"><div class="template-region-text-scale" style="' + contentStyle + '">' + renderedBody + '</div></div>';
}
+227
View File
@@ -0,0 +1,227 @@
function renderRtmpRegion(region, regionContent) {
var url = String(regionContent.value || '').trim();
var disableAudio = regionContent.disable_audio === undefined ? true : Boolean(regionContent.disable_audio);
if (!url) {
return '<div class="template-region rtmp" style="' + region.baseStyle + '"><div class="template-region-placeholder">RTMP stream</div></div>';
}
return '<div class="template-region rtmp" style="' + region.baseStyle + '"><video class="template-region-rtmp-video" data-rtmp-source="' + escapeHtml(url) + '" data-rtmp-disable-audio="' + (disableAudio ? '1' : '0') + '" autoplay playsinline preload="auto" tabindex="-1" disablepictureinpicture></video><div class="template-region-placeholder template-region-rtmp-placeholder">Loading RTMP stream...</div></div>';
}
function getRtmpSessionUrl(sourceUrl, disableAudio) {
return '/api/rtmp/session?source=' + encodeURIComponent(sourceUrl) + '&disableAudio=' + (disableAudio ? '1' : '0');
}
var rtmpPreloadSignature = '';
function getRtmpPreloadSlides(sourceSlides, targetIndex) {
var availableSlides = Array.isArray(sourceSlides) ? sourceSlides : [];
if (!availableSlides.length) {
return [];
}
var normalizedIndex = Number(targetIndex || 0);
if (!Number.isFinite(normalizedIndex) || normalizedIndex < 0 || normalizedIndex >= availableSlides.length) {
normalizedIndex = 0;
}
var preloadSlides = [];
var currentSlide = availableSlides[normalizedIndex];
var nextSlide = availableSlides[normalizedIndex + 1];
if (currentSlide) {
preloadSlides.push(currentSlide);
}
if (nextSlide && nextSlide !== currentSlide) {
preloadSlides.push(nextSlide);
}
return preloadSlides;
}
function getRtmpPreloadEntries(sourceSlides) {
var entries = [];
var seen = Object.create(null);
(Array.isArray(sourceSlides) ? sourceSlides : []).forEach(function (slide) {
var content = slide && slide.content ? slide.content : {};
var regions = slide && slide.template && Array.isArray(slide.template.regions) ? slide.template.regions : [];
regions.forEach(function (region) {
if (region.region_type !== 'rtmp') {
return;
}
var regionContent = content[region.region_key] || {};
var url = String(regionContent.value || '').trim();
if (!url) {
return;
}
var disableAudio = regionContent.disable_audio === undefined ? true : Boolean(regionContent.disable_audio);
var key = url + '\n' + (disableAudio ? '1' : '0');
if (seen[key]) {
return;
}
seen[key] = true;
entries.push({
url: url,
disableAudio: disableAudio
});
});
});
return entries;
}
function syncRtmpPreloads(sourceSlides, targetIndex) {
var entries = getRtmpPreloadEntries(getRtmpPreloadSlides(sourceSlides, targetIndex));
var signature = entries.map(function (entry) {
return entry.url + '\n' + (entry.disableAudio ? '1' : '0');
}).join('\n');
if (signature === rtmpPreloadSignature) {
return;
}
rtmpPreloadSignature = signature;
if (!entries.length) {
return;
}
entries.forEach(function (entry) {
fetch(getRtmpSessionUrl(entry.url, entry.disableAudio), {
credentials: 'same-origin'
}).catch(function () {
return null;
});
});
}
function syncRtmpRegions(root) {
if (!root) {
return;
}
var videos = root.querySelectorAll('video[data-rtmp-source]');
Array.prototype.forEach.call(videos, function (video) {
if (!video || video.dataset.rtmpInitialized === '1') {
return;
}
var sourceUrl = String(video.dataset.rtmpSource || '').trim();
var disableAudio = String(video.dataset.rtmpDisableAudio || '1') !== '0';
var region = video.parentNode;
var placeholder = region ? region.querySelector('.template-region-rtmp-placeholder') : null;
if (!sourceUrl) {
if (placeholder) {
placeholder.textContent = 'RTMP stream';
}
return;
}
video.dataset.rtmpInitialized = '1';
video.muted = disableAudio;
video.controls = false;
video.playsInline = true;
video.autoplay = true;
var markReady = function () {
if (placeholder) {
placeholder.style.display = 'none';
}
};
var tryPlay = function () {
if (video && typeof video.play === 'function') {
video.play().catch(function () {
return null;
});
}
};
video.addEventListener('canplay', markReady, { once: true });
video.addEventListener('playing', markReady, { once: true });
fetch(getRtmpSessionUrl(sourceUrl, disableAudio), {
credentials: 'same-origin'
}).then(function (response) {
if (!response.ok) {
throw new Error('Unable to initialize RTMP stream.');
}
return response.json();
}).then(function (payload) {
var playlistUrl = payload && payload.playlistUrl ? String(payload.playlistUrl).trim() : '';
if (!playlistUrl) {
throw new Error('RTMP playlist URL was not returned.');
}
if (window.Hls && window.Hls.isSupported && window.Hls.isSupported()) {
var hls = new window.Hls({
enableWorker: true,
lowLatencyMode: true,
liveSyncDurationCount: 4,
liveMaxLatencyDurationCount: 8,
maxBufferLength: 20,
maxLiveSyncPlaybackRate: 1,
backBufferLength: 30
});
video.__rtmpHls = hls;
hls.attachMedia(video);
hls.on(window.Hls.Events.MEDIA_ATTACHED, function () {
hls.loadSource(playlistUrl);
});
hls.on(window.Hls.Events.MANIFEST_PARSED, function () {
tryPlay();
});
hls.on(window.Hls.Events.ERROR, function (_event, data) {
if (data && data.fatal) {
try {
hls.destroy();
} catch (_error) {
// ignore cleanup errors
}
video.__rtmpHls = null;
if (placeholder) {
placeholder.style.display = 'flex';
placeholder.textContent = 'RTMP playback failed.';
}
}
});
return;
}
if (video.canPlayType && video.canPlayType('application/vnd.apple.mpegurl')) {
video.src = playlistUrl;
tryPlay();
return;
}
if (placeholder) {
placeholder.style.display = 'flex';
placeholder.textContent = 'RTMP playback is not supported in this browser.';
}
}).catch(function (_error) {
if (placeholder) {
placeholder.style.display = 'flex';
placeholder.textContent = 'Unable to load RTMP stream.';
}
});
});
}
function destroyRtmpRegions(root) {
if (!root) {
return;
}
var videos = root.querySelectorAll('video[data-rtmp-source]');
Array.prototype.forEach.call(videos, function (video) {
if (video.__rtmpHls) {
try {
video.__rtmpHls.destroy();
} catch (_error) {
// ignore cleanup errors
}
video.__rtmpHls = null;
}
});
}
+7
View File
@@ -0,0 +1,7 @@
function renderTextRegion(region, regionContent) {
var fontFamily = sanitizeFontFamily(regionContent.font_family || region.fontFamily);
var fontSize = sanitizeFontSize(regionContent.font_size || region.fontSize);
var fontColor = sanitizeTextColor(regionContent.font_color || region.fontColor);
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) + ';';
return '<div class="template-region text" style="' + region.baseStyle + '"><div class="template-region-text-scale" style="' + contentStyle + '">' + renderEditorJsContent(regionContent.value || '') + '</div></div>';
}
+5
View File
@@ -0,0 +1,5 @@
function renderWebpageRegion(region, regionContent) {
var url = String(regionContent.value || '').trim();
var iframe = url ? '<iframe src="' + escapeHtml(url) + '" title="' + escapeHtml(region.label) + '" loading="eager" referrerpolicy="no-referrer" scrolling="no"></iframe>' : '<div class="template-region-placeholder">Webpage</div>';
return '<div class="template-region webpage" style="' + region.baseStyle + '">' + iframe + '</div>';
}
+66 -2
View File
@@ -278,12 +278,32 @@ function fitCanvasSize(canvasWidth, canvasHeight, maxWidth, maxHeight) {
const playerPageTemplatePath = path.join(__dirname, 'player-page.template.html');
const playerClientNameScriptPath = path.join(__dirname, 'player-client-name.script.html');
const playerPageOfflineScriptPath = path.join(__dirname, 'public', 'js', 'player-page-offline.js');
const playerPagePlaylistScriptPath = path.join(__dirname, 'public', 'js', 'player-page-playlist.js');
const playerPageCommandsScriptPath = path.join(__dirname, 'public', 'js', 'player-page-commands.js');
const playerPageRenderingScriptPath = path.join(__dirname, 'public', 'js', 'player-page-rendering.js');
const playerPagePlaybackScriptPath = path.join(__dirname, 'public', 'js', 'player-page-playback.js');
const playerPageScriptPath = path.join(__dirname, 'player-page.script.html');
const playerOnboardingLandingScriptPath = path.join(__dirname, 'player-onboarding-landing.script.html');
const playerOnboardingFormScriptPath = path.join(__dirname, 'player-onboarding-form.script.html');
const playerRegionScriptPaths = [
path.join(__dirname, 'regions', 'image.js'),
path.join(__dirname, 'regions', 'webpage.js'),
path.join(__dirname, 'regions', 'html.js'),
path.join(__dirname, 'regions', 'rtmp.js'),
path.join(__dirname, 'regions', 'rss.js'),
path.join(__dirname, 'regions', 'api.js'),
path.join(__dirname, 'regions', 'text.js')
];
const playerOnboardingLandingScriptPath = path.join(__dirname, 'onboarding', 'player-onboarding-landing.script.html');
const playerOnboardingFormScriptPath = path.join(__dirname, 'onboarding', 'player-onboarding-form.script.html');
let playerPageTemplateCache = null;
let playerClientNameScriptCache = null;
let playerPageOfflineScriptCache = null;
let playerPagePlaylistScriptCache = null;
let playerPageCommandsScriptCache = null;
let playerPageRenderingScriptCache = null;
let playerPagePlaybackScriptCache = null;
let playerPageScriptCache = null;
let playerRegionScriptsCache = null;
let playerOnboardingLandingScriptCache = null;
let playerOnboardingFormScriptCache = null;
@@ -306,10 +326,48 @@ function getPlayerClientNameScript() {
return loadTemplate(playerClientNameScriptPath, playerClientNameScriptCache || (playerClientNameScriptCache = {}));
}
function getPlayerPageOfflineScript() {
return loadTemplate(playerPageOfflineScriptPath, playerPageOfflineScriptCache || (playerPageOfflineScriptCache = {}));
}
function getPlayerPagePlaylistScript() {
return loadTemplate(playerPagePlaylistScriptPath, playerPagePlaylistScriptCache || (playerPagePlaylistScriptCache = {}));
}
function getPlayerPageCommandsScript() {
return loadTemplate(playerPageCommandsScriptPath, playerPageCommandsScriptCache || (playerPageCommandsScriptCache = {}));
}
function getPlayerPageRenderingScript() {
return loadTemplate(playerPageRenderingScriptPath, playerPageRenderingScriptCache || (playerPageRenderingScriptCache = {}));
}
function getPlayerPagePlaybackScript() {
return loadTemplate(playerPagePlaybackScriptPath, playerPagePlaybackScriptCache || (playerPagePlaybackScriptCache = {}));
}
function getPlayerPageScript() {
return loadTemplate(playerPageScriptPath, playerPageScriptCache || (playerPageScriptCache = {}));
}
function getPlayerRegionScripts() {
const statSignature = playerRegionScriptPaths.map(function (filePath) {
return fs.statSync(filePath).mtimeMs;
}).join('|');
if (playerRegionScriptsCache && playerRegionScriptsCache.signature === statSignature) {
return playerRegionScriptsCache.value;
}
const value = playerRegionScriptPaths.map(function (filePath) {
return fs.readFileSync(filePath, 'utf8').trim();
}).join('\n\n');
playerRegionScriptsCache = {
signature: statSignature,
value: value
};
return value;
}
function getPlayerOnboardingLandingScript() {
return loadTemplate(playerOnboardingLandingScriptPath, playerOnboardingLandingScriptCache || (playerOnboardingLandingScriptCache = {}));
}
@@ -339,7 +397,13 @@ module.exports = {
loadTemplate: loadTemplate,
getPlayerPageTemplate: getPlayerPageTemplate,
getPlayerClientNameScript: getPlayerClientNameScript,
getPlayerPageOfflineScript: getPlayerPageOfflineScript,
getPlayerPagePlaylistScript: getPlayerPagePlaylistScript,
getPlayerPageCommandsScript: getPlayerPageCommandsScript,
getPlayerPageRenderingScript: getPlayerPageRenderingScript,
getPlayerPagePlaybackScript: getPlayerPagePlaybackScript,
getPlayerPageScript: getPlayerPageScript,
getPlayerRegionScripts: getPlayerRegionScripts,
getPlayerOnboardingLandingScript: getPlayerOnboardingLandingScript,
getPlayerOnboardingFormScript: getPlayerOnboardingFormScript
};
+31 -5
View File
@@ -1,5 +1,6 @@
const Handlebars = require('handlebars');
const { mediaKind, safeJsonForScript, getPlayerPageTemplate, getPlayerClientNameScript, getPlayerPageScript, getPlayerOnboardingLandingScript, getPlayerOnboardingFormScript } = require('./render-helpers');
const { mediaKind, safeJsonForScript, getPlayerPageTemplate, getPlayerClientNameScript, getPlayerPageOfflineScript, getPlayerPagePlaylistScript, getPlayerPageCommandsScript, getPlayerPageRenderingScript, getPlayerPagePlaybackScript, getPlayerPageScript, getPlayerRegionScripts, getPlayerOnboardingLandingScript, getPlayerOnboardingFormScript } = require('./render-helpers');
const { createPageAuthBundle, createPageFetchAuthScript } = require('../request-auth');
function renderPage(template, options) {
return template({
@@ -10,6 +11,20 @@ function renderPage(template, options) {
});
}
function getPlayerServiceWorkerRegistrationScript() {
return [
'<script>',
' if ("serviceWorker" in navigator) {',
' window.addEventListener("load", function () {',
' navigator.serviceWorker.register("/sw.js").catch(function () {',
' return null;',
' });',
' });',
' }',
'</script>'
].join('');
}
function renderOnboardingLandingBody() {
return [
'<main class="onboarding-shell">',
@@ -84,34 +99,45 @@ function renderOnboardingFormScript(deviceId) {
function renderPlayerPage(slug, initialData) {
const onboardingScript = getPlayerClientNameScript()();
const offlineScript = getPlayerPageOfflineScript()();
const playlistScript = getPlayerPagePlaylistScript()();
const commandScript = getPlayerPageCommandsScript()();
const renderingScript = getPlayerPageRenderingScript()();
const playbackScript = getPlayerPagePlaybackScript()();
const serviceWorkerScript = getPlayerServiceWorkerRegistrationScript();
const template = getPlayerPageTemplate();
const hlsScriptTag = '<script src="/assets/vendor/hls.min.js"></script>';
const pageAuthToken = createPageAuthBundle({ scope: 'player', slug: String(slug || '').trim() });
const script = getPlayerPageScript()({
SLUG_JSON: new Handlebars.SafeString(JSON.stringify(slug)),
INITIAL_DATA_JSON: new Handlebars.SafeString(safeJsonForScript(initialData || null))
INITIAL_DATA_JSON: new Handlebars.SafeString(safeJsonForScript(initialData || null)),
REGION_SCRIPTS: new Handlebars.SafeString(getPlayerRegionScripts())
});
return renderPage(template, {
title: 'Screen ' + slug,
body: '<div id="app"><div class="empty">Loading screen...</div></div>',
script: onboardingScript + script
script: createPageFetchAuthScript(pageAuthToken) + hlsScriptTag + serviceWorkerScript + '<script>' + offlineScript + '</script>' + '<script>' + playlistScript + '</script>' + '<script>' + commandScript + '</script>' + '<script>' + renderingScript + '</script>' + '<script>' + playbackScript + '</script>' + onboardingScript + script
});
}
function renderPlayerOnboardingLandingPage() {
const pageAuthToken = createPageAuthBundle({ scope: 'onboarding' });
return renderPage(getPlayerPageTemplate(), {
title: 'Onboard player',
bodyClass: 'onboarding-page',
body: renderOnboardingLandingBody(),
script: renderOnboardingLandingScript()
script: createPageFetchAuthScript(pageAuthToken) + getPlayerServiceWorkerRegistrationScript() + renderOnboardingLandingScript()
});
}
function renderPlayerOnboardingFormPage(deviceId) {
const pageAuthToken = createPageAuthBundle({ scope: 'onboarding', deviceId: String(deviceId || '').trim() });
return renderPage(getPlayerPageTemplate(), {
title: 'Onboard screen',
bodyClass: 'onboarding-page',
body: renderOnboardingFormBody(deviceId),
script: renderOnboardingFormScript(deviceId)
script: createPageFetchAuthScript(pageAuthToken) + getPlayerServiceWorkerRegistrationScript() + renderOnboardingFormScript(deviceId)
});
}
+181 -27
View File
@@ -1,36 +1,111 @@
const fs = require('fs');
const express = require('express');
const path = require('path');
const { getSharedSecret, createPageAuthBundle, verifyPageAuthToken, verifyRequestAuth } = require('../request-auth');
const TRANSIENT_DB_ERROR_CODES = ['ECONNREFUSED', 'ECONNRESET', 'ETIMEDOUT', 'EPIPE', 'ENOTFOUND', 'PROTOCOL_CONNECTION_LOST', 'POOL_CLOSED', 'ERR_POOL_CLOSED'];
function isTransientDbError(error) {
return Boolean(error && TRANSIENT_DB_ERROR_CODES.indexOf(String(error.code || '').trim()) !== -1);
}
function registerPlayerRoutes(app, options) {
const pool = options && options.pool ? options.pool : null;
const common = options && options.common ? options.common : null;
const uploadDir = options && options.uploadDir ? options.uploadDir : null;
const mediaDir = options && options.mediaDir ? options.mediaDir : null;
const assetDir = options && options.assetDir ? options.assetDir : null;
const playerRuntime = options && options.playerRuntime ? options.playerRuntime : null;
const playerPlaylistService = options && options.playerPlaylistService ? options.playerPlaylistService : null;
const rtmpStreamService = options && options.rtmpStreamService ? options.rtmpStreamService : null;
if (!app || !pool || !common || !uploadDir || !assetDir || !playerRuntime || !playerPlaylistService) {
throw new Error('registerPlayerRoutes requires app, pool, common, uploadDir, assetDir, playerRuntime, and playerPlaylistService.');
if (!app || !pool || !common || !mediaDir || !assetDir || !playerRuntime || !playerPlaylistService || !rtmpStreamService) {
throw new Error('registerPlayerRoutes requires app, pool, common, mediaDir, assetDir, playerRuntime, playerPlaylistService, and rtmpStreamService.');
}
const sharedSecret = getSharedSecret();
function requirePageAuth(allowedScopes) {
return function (req, res, next) {
if (!sharedSecret) {
return next();
}
const token = String(req.headers['x-pulse-page-auth'] || '').trim();
const payload = verifyPageAuthToken(token);
if (!payload) {
return res.status(401).json({ error: 'Page authentication required.' });
}
const scopes = Array.isArray(allowedScopes) ? allowedScopes : [];
if (scopes.length && scopes.indexOf(String(payload.scope || '').trim()) === -1) {
return res.status(403).json({ error: 'Page authentication scope is not allowed for this route.' });
}
req.playerPageAuth = payload;
next();
};
}
function requireRequestAuth(req, res, next) {
if (!sharedSecret) {
return next();
}
if (!verifyRequestAuth(req)) {
return res.status(401).json({ error: 'Request authentication required.' });
}
next();
}
app.use('/assets', express.static(assetDir));
app.use('/uploads', express.static(uploadDir));
app.use('/media', express.static(mediaDir));
app.use('/assets/vendor', express.static(path.join(__dirname, '..', '..', 'node_modules', 'hls.js', 'dist')));
app.get('/api/uploads/config', function (_req, res) {
app.get('/sw.js', function (_req, res) {
res.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
res.type('application/javascript');
res.sendFile(path.join(__dirname, 'public', 'sw.js'));
});
app.post('/api/auth/page', function (req, res, next) {
try {
if (!sharedSecret) {
return res.status(404).json({ error: 'Page authentication is disabled.' });
}
const token = String(req.headers['x-pulse-page-auth'] || '').trim();
const payload = verifyPageAuthToken(token);
if (!payload || ['player', 'onboarding'].indexOf(String(payload.scope || '').trim()) === -1) {
return res.status(401).json({ error: 'Page authentication required.' });
}
const tokenBundle = createPageAuthBundle({
scope: payload.scope,
slug: payload.slug || null,
deviceId: payload.deviceId || null
});
res.json(tokenBundle);
} catch (error) {
next(error);
}
});
app.get('/api/media/config', requireRequestAuth, function (_req, res) {
res.json({
uploadDir: uploadDir
mediaDir: mediaDir
});
});
app.put('/api/uploads/:filename', express.raw({ type: '*/*', limit: '100mb' }), async function (req, res, next) {
app.put('/api/media/:filename', requireRequestAuth, express.raw({ type: '*/*', limit: '100mb' }), async function (req, res, next) {
try {
const filename = require('path').basename(String(req.params.filename || '').trim());
if (!filename) {
return res.status(400).json({ error: 'Filename is required' });
}
const filePath = require('path').join(uploadDir, filename);
const filePath = require('path').join(mediaDir, filename);
const body = Buffer.isBuffer(req.body) ? req.body : Buffer.from(req.body || '');
await fs.promises.mkdir(uploadDir, { recursive: true });
await fs.promises.mkdir(mediaDir, { recursive: true });
await fs.promises.writeFile(filePath, body);
res.json({ ok: true, filename: filename });
} catch (error) {
@@ -38,13 +113,13 @@ function registerPlayerRoutes(app, options) {
}
});
app.delete('/api/uploads/:filename', async function (req, res, next) {
app.delete('/api/media/:filename', requireRequestAuth, async function (req, res, next) {
try {
const filename = require('path').basename(String(req.params.filename || '').trim());
if (!filename) {
return res.status(400).json({ error: 'Filename is required' });
}
const filePath = require('path').join(uploadDir, filename);
const filePath = require('path').join(mediaDir, filename);
try {
await fs.promises.unlink(filePath);
} catch (error) {
@@ -58,6 +133,53 @@ function registerPlayerRoutes(app, options) {
}
});
app.get('/api/rtmp/session', requirePageAuth(['player']), async function (req, res, next) {
try {
const source = String(req.query.source || '').trim();
const disableAudio = String(req.query.disableAudio || '').trim().toLowerCase();
const useMutedOutput = disableAudio === '1' || disableAudio === 'true' || disableAudio === 'yes' || disableAudio === 'on';
const session = await rtmpStreamService.ensureSession(source, useMutedOutput);
await session.ready.catch(function () {
return false;
});
res.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
res.json({
key: session.key,
playlistUrl: session.playlistUrl,
disableAudio: session.disableAudio,
ready: true
});
} catch (error) {
next(error);
}
});
app.get('/api/rtmp/streams/:key/index.m3u8', async function (req, res, next) {
try {
res.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
const manifestPath = await rtmpStreamService.getManifestFilePath(req.params.key);
if (!manifestPath) {
return res.status(404).send('Stream not found');
}
res.sendFile(manifestPath);
} catch (error) {
next(error);
}
});
app.get('/api/rtmp/streams/:key/:fileName', async function (req, res, next) {
try {
const segmentPath = await rtmpStreamService.getSegmentFilePath(req.params.key, req.params.fileName);
if (!segmentPath) {
return res.status(404).send('Stream not found');
}
res.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
res.sendFile(segmentPath);
} catch (error) {
next(error);
}
});
app.get('/screen/:slug', function (req, res) {
res.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
res.set('Pragma', 'no-cache');
@@ -65,11 +187,12 @@ function registerPlayerRoutes(app, options) {
res.send(common.renderPlayerPage(req.params.slug, data));
}).catch(function (error) {
console.error(error);
res.status(500).send('Internal server error');
res.set('X-Player-Offline', '1');
res.send(common.renderPlayerPage(req.params.slug, null));
});
});
app.get('/api/screens/:slug/playlist', async function (req, res, next) {
app.get('/api/screens/:slug/playlist', requirePageAuth(['player']), async function (req, res, next) {
try {
res.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
const data = await playerPlaylistService.buildScreenPlaylist(req.params.slug);
@@ -89,25 +212,33 @@ function registerPlayerRoutes(app, options) {
}
});
app.get(['/api/screens/:slug/connections', '/api/screens/:slug/clients'], async function (req, res, next) {
app.get(['/api/screens/:slug/connections', '/api/screens/:slug/clients'], requireRequestAuth, async function (req, res, next) {
try {
const [screenRows] = await pool.query('SELECT id, name, slug FROM screens WHERE slug = ?', [req.params.slug]);
if (!screenRows.length) {
return res.status(404).json({ error: 'Screen not found' });
}
const connections = playerRuntime.snapshotConnections(req.params.slug);
let screen = null;
let screenLookupFailed = false;
try {
const [screenRows] = await pool.query('SELECT id, name, slug FROM screens WHERE slug = ?', [req.params.slug]);
screen = screenRows[0] || null;
} catch (error) {
screenLookupFailed = isTransientDbError(error);
if (!screenLookupFailed) {
throw error;
}
}
res.json({
screen: screenRows[0],
screen: screen,
screenSlug: req.params.slug,
count: connections.length,
connections: connections
connections: connections,
degraded: screenLookupFailed
});
} catch (error) {
next(error);
}
});
app.post('/api/screens/:slug/commands', async function (req, res, next) {
app.post('/api/screens/:slug/commands', requireRequestAuth, async function (req, res, next) {
try {
const command = String((req.body && req.body.command) || req.query.command || '').trim().toLowerCase();
const connectionId = String((req.body && (req.body.connectionId || req.body.clientId)) || req.query.connectionId || req.query.clientId || '').trim();
@@ -121,15 +252,29 @@ function registerPlayerRoutes(app, options) {
return res.status(400).json({ error: 'Unsupported command' });
}
const liveConnections = playerRuntime.snapshotConnections(req.params.slug);
const isRedirectCommand = command === 'redirect';
let screenRows = [];
let screen = null;
let screenLookupFailed = false;
if (!isRedirectCommand) {
[screenRows] = await pool.query('SELECT id, name, slug FROM screens WHERE slug = ?', [req.params.slug]);
if (!screenRows.length) {
return res.status(404).json({ error: 'Screen not found' });
try {
const [screenRows] = await pool.query('SELECT id, name, slug FROM screens WHERE slug = ?', [req.params.slug]);
screen = screenRows[0] || null;
} catch (error) {
screenLookupFailed = isTransientDbError(error);
if (!screenLookupFailed) {
throw error;
}
}
}
if (!screen && liveConnections.length) {
screen = {
name: req.params.slug,
slug: req.params.slug
};
}
const commandPayload = req.body && typeof req.body === 'object' && !Array.isArray(req.body)
? Object.assign({}, req.body, { command: command })
: command;
@@ -141,12 +286,21 @@ function registerPlayerRoutes(app, options) {
? await playerRuntime.sendCommandToConnection(req.params.slug, connectionId, commandPayload)
: await playerRuntime.broadcastCommand(req.params.slug, commandPayload);
if (!screen && !screenLookupFailed && !liveConnections.length) {
return res.status(404).json({ error: 'Screen not found' });
}
if (!screen && screenLookupFailed && !liveConnections.length) {
return res.status(503).json({ error: 'Screen metadata unavailable while the database is down.' });
}
res.json({
screen: screenRows[0] || null,
screen: screen,
screenSlug: req.params.slug,
command: command,
connectionId: connectionId || null,
sent: sent
sent: sent,
degraded: screenLookupFailed
});
} catch (error) {
next(error);
+18 -1
View File
@@ -1,6 +1,7 @@
const crypto = require('crypto');
const { WebSocketServer, WebSocket } = require('ws');
const { isClientNameAvailable } = require('../client-name-check');
const { isClientNameAvailable } = require('../data/client-name-check');
const { verifyPageAuthToken, verifyRequestAuth } = require('../request-auth');
function createPlayerRuntime(options) {
const pool = options && options.pool ? options.pool : null;
@@ -239,6 +240,22 @@ function createPlayerRuntime(options) {
return;
}
if (dashboardMatch) {
if (!verifyRequestAuth(request)) {
socket.destroy();
return;
}
}
if (playerMatch) {
const authToken = String(new URL(request.url, 'http://localhost').searchParams.get('auth') || '').trim();
const payload = verifyPageAuthToken(authToken);
if (!payload || String(payload.scope || '').trim() !== 'player') {
socket.destroy();
return;
}
}
const slug = decodeURIComponent((dashboardMatch || playerMatch)[1]);
wss.handleUpgrade(request, socket, head, function (ws) {
wss.emit('connection', ws, request, slug, dashboardMatch ? 'dashboard' : 'player');
+34
View File
@@ -79,6 +79,40 @@ const PERMISSION_SECTIONS = [
{ key: 'delete', name: 'Delete', description: 'Delete canvas sizes.' }
]
},
{
key: 'rss-feeds',
order: 75,
name: 'RSS feeds',
sectionName: 'Data Sources',
actions: [
{ key: 'read', name: 'Read', description: 'View configured RSS feeds.' },
{ key: 'create', name: 'Create', description: 'Create new RSS feeds.' },
{ key: 'update', name: 'Update', description: 'Update RSS feeds.' },
{ key: 'delete', name: 'Delete', description: 'Delete RSS feeds.' }
]
},
{
key: 'api-sources',
order: 76,
name: 'API sources',
sectionName: 'Data Sources',
actions: [
{ key: 'read', name: 'Read', description: 'View configured API sources.' },
{ key: 'create', name: 'Create', description: 'Create new API sources.' },
{ key: 'update', name: 'Update', description: 'Update API sources.' },
{ key: 'delete', name: 'Delete', description: 'Delete API sources.' }
]
},
{
key: 'background-tasks',
order: 100,
name: 'Background tasks',
sectionName: 'Settings',
actions: [
{ key: 'read', name: 'Read', description: 'View background tasks and scheduled refreshes.' },
{ key: 'allow', name: 'Allow', description: 'Manage queued background tasks and clear finished items.' }
]
},
{
key: 'users',
order: 80,
+365
View File
@@ -0,0 +1,365 @@
const crypto = require('crypto');
const PAGE_TOKEN_HEADER = 'x-pulse-page-auth';
const REQUEST_TIMESTAMP_HEADER = 'x-pulse-request-timestamp';
const REQUEST_SIGNATURE_HEADER = 'x-pulse-request-signature';
const PAGE_TOKEN_TTL_MS = 12 * 60 * 60 * 1000;
const REQUEST_AUTH_MAX_SKEW_MS = 5 * 60 * 1000;
function getSharedSecret() {
return String(process.env.PULSE_SIGNAGE_SHARED_SECRET || process.env.PLAYER_API_SHARED_SECRET || process.env.PLAYER_SHARED_SECRET || '').trim();
}
function toBase64Url(value) {
return Buffer.from(String(value || ''), 'utf8').toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
}
function fromBase64Url(value) {
const normalized = String(value || '').replace(/-/g, '+').replace(/_/g, '/');
const padded = normalized + '='.repeat((4 - (normalized.length % 4)) % 4);
return Buffer.from(padded, 'base64').toString('utf8');
}
function canonicalize(value) {
if (Array.isArray(value)) {
return value.map(canonicalize);
}
if (Buffer.isBuffer(value)) {
return value.toString('base64');
}
if (value && typeof value === 'object') {
if (value instanceof Date) {
return value.toISOString();
}
const canonical = {};
Object.keys(value).sort().forEach(function (key) {
const normalizedValue = canonicalize(value[key]);
if (normalizedValue !== undefined) {
canonical[key] = normalizedValue;
}
});
return canonical;
}
return value === undefined ? undefined : value;
}
function stableJson(value) {
return JSON.stringify(canonicalize(value));
}
function hashPayload(value) {
const normalized = Buffer.isBuffer(value) ? value : Buffer.from(stableJson(value === undefined ? null : value) || '', 'utf8');
return crypto.createHash('sha256').update(normalized).digest('hex');
}
function getRequestPath(req) {
const explicitPath = String(req && req.path ? req.path : '').trim();
if (explicitPath) {
return explicitPath;
}
const rawUrl = String(req && req.url ? req.url : '').trim();
if (!rawUrl) {
return '';
}
try {
return new URL(rawUrl, 'http://localhost').pathname;
} catch (_error) {
return rawUrl.split('?')[0] || '';
}
}
function signText(secret, text) {
return crypto.createHmac('sha256', String(secret || '')).update(String(text || ''), 'utf8').digest('hex');
}
function timingSafeEqualHex(expectedHex, actualHex) {
const expected = Buffer.from(String(expectedHex || ''), 'hex');
const actual = Buffer.from(String(actualHex || ''), 'hex');
return expected.length === actual.length && expected.length > 0 && crypto.timingSafeEqual(expected, actual);
}
function buildPageAuthPayload(payload) {
const issuedAt = Date.now();
const normalizedPayload = canonicalize(payload || {});
return Object.assign({}, normalizedPayload, {
issuedAt: issuedAt,
expiresAt: issuedAt + PAGE_TOKEN_TTL_MS
});
}
function createPageAuthBundle(payload) {
const secret = getSharedSecret();
if (!secret) {
return {
token: '',
issuedAt: null,
expiresAt: null
};
}
const payloadWithExpiry = buildPageAuthPayload(payload);
const encodedPayload = toBase64Url(stableJson(payloadWithExpiry));
const signature = signText(secret, `page\n${encodedPayload}`);
return {
token: `${encodedPayload}.${signature}`,
issuedAt: payloadWithExpiry.issuedAt,
expiresAt: payloadWithExpiry.expiresAt
};
}
function createPageAuthToken(payload) {
return createPageAuthBundle(payload).token;
}
function verifyPageAuthToken(token) {
const secret = getSharedSecret();
if (!secret) {
return null;
}
const normalizedToken = String(token || '').trim();
if (!normalizedToken) {
return null;
}
const separatorIndex = normalizedToken.lastIndexOf('.');
if (separatorIndex <= 0) {
return null;
}
const payloadPart = normalizedToken.slice(0, separatorIndex);
const signaturePart = normalizedToken.slice(separatorIndex + 1);
const expectedSignature = signText(secret, `page\n${payloadPart}`);
if (!timingSafeEqualHex(expectedSignature, signaturePart)) {
return null;
}
try {
const payload = JSON.parse(fromBase64Url(payloadPart));
const now = Date.now();
const issuedAt = Number(payload && payload.issuedAt);
const expiresAt = Number(payload && payload.expiresAt);
if (!Number.isFinite(issuedAt) || !Number.isFinite(expiresAt)) {
return null;
}
if (issuedAt > now + REQUEST_AUTH_MAX_SKEW_MS) {
return null;
}
if (expiresAt <= now) {
return null;
}
return payload;
} catch (_error) {
return null;
}
}
function createRequestAuthHeaders(options) {
const secret = getSharedSecret();
if (!secret) {
return {};
}
const method = String(options && options.method || 'GET').trim().toUpperCase();
const pathname = String(options && options.pathname || '').trim();
const timestamp = String(options && options.timestamp || Date.now()).trim();
const bodyDigest = hashPayload(options && Object.prototype.hasOwnProperty.call(options, 'body') ? options.body : null);
const signature = signText(secret, `request\n${method}\n${pathname}\n${timestamp}\n${bodyDigest}`);
return {
[REQUEST_TIMESTAMP_HEADER]: timestamp,
[REQUEST_SIGNATURE_HEADER]: signature
};
}
function verifyRequestAuth(req) {
const secret = getSharedSecret();
if (!secret) {
return true;
}
const timestamp = String(req && req.headers ? req.headers[REQUEST_TIMESTAMP_HEADER] || '' : '').trim();
const signature = String(req && req.headers ? req.headers[REQUEST_SIGNATURE_HEADER] || '' : '').trim();
if (!timestamp || !signature) {
return false;
}
const parsedTimestamp = Number(timestamp);
if (!Number.isFinite(parsedTimestamp)) {
return false;
}
const now = Date.now();
if (Math.abs(now - parsedTimestamp) > REQUEST_AUTH_MAX_SKEW_MS) {
return false;
}
const expectedSignature = signText(secret, `request\n${String(req.method || 'GET').trim().toUpperCase()}\n${getRequestPath(req)}\n${timestamp}\n${hashPayload(req.body)}`);
return timingSafeEqualHex(expectedSignature, signature);
}
function createPageFetchAuthScript(token) {
const normalizedToken = String(token && typeof token === 'object' ? token.token : token || '').trim();
if (!normalizedToken) {
return '';
}
const pageAuthExpiresAt = token && typeof token === 'object' && Number.isFinite(Number(token.expiresAt))
? Number(token.expiresAt)
: null;
const renewSkewMs = REQUEST_AUTH_MAX_SKEW_MS;
return [
'<script>',
' (function () {',
' var pageAuthToken = ' + JSON.stringify(normalizedToken) + ';',
' var pageAuthExpiresAt = ' + JSON.stringify(pageAuthExpiresAt) + ';',
' var pageAuthRenewalTimer = null;',
' var pageAuthRenewalInFlight = null;',
' var pageAuthRenewalSkewMs = ' + JSON.stringify(renewSkewMs) + ';',
' var originalFetch = window.fetch && window.fetch.bind(window);',
' if (!originalFetch) {',
' return;',
' }',
' function schedulePageAuthRenewal() {',
' if (pageAuthRenewalTimer) {',
' clearTimeout(pageAuthRenewalTimer);',
' pageAuthRenewalTimer = null;',
' }',
' if (!pageAuthToken || !pageAuthExpiresAt) {',
' return;',
' }',
' var delayMs = pageAuthExpiresAt - Date.now() - pageAuthRenewalSkewMs;',
' if (!Number.isFinite(delayMs) || delayMs < 1000) {',
' delayMs = 1000;',
' }',
' pageAuthRenewalTimer = setTimeout(function () {',
' renewPageAuthToken().catch(function () {',
' return null;',
' });',
' }, delayMs);',
' }',
' function setPageAuthToken(nextToken, nextExpiresAt) {',
' pageAuthToken = String(nextToken || "").trim();',
' pageAuthExpiresAt = Number(nextExpiresAt || 0) || null;',
' window.__pulsePageAuthToken = pageAuthToken;',
' window.__pulsePageAuthExpiresAt = pageAuthExpiresAt;',
' schedulePageAuthRenewal();',
' }',
' async function renewPageAuthToken() {',
' if (!pageAuthToken || pageAuthRenewalInFlight) {',
' return pageAuthToken;',
' }',
' pageAuthRenewalInFlight = originalFetch("/api/auth/page", {',
' method: "POST",',
' headers: new Headers({',
' "Accept": "application/json",',
' ' + JSON.stringify(PAGE_TOKEN_HEADER) + ': pageAuthToken',
' })',
' }).then(function (response) {',
' if (!response.ok) {',
' return null;',
' }',
' return response.json().catch(function () {',
' return null;',
' });',
' }).then(function (payload) {',
' if (!payload || !payload.token) {',
' return null;',
' }',
' setPageAuthToken(payload.token, payload.expiresAt);',
' return pageAuthToken;',
' }).finally(function () {',
' pageAuthRenewalInFlight = null;',
' });',
' return pageAuthRenewalInFlight;',
' }',
' window.__pulseSetPageAuthToken = setPageAuthToken;',
' window.__pulseRenewPageAuthToken = renewPageAuthToken;',
' window.__pulsePageAuthToken = pageAuthToken;',
' window.__pulsePageAuthExpiresAt = pageAuthExpiresAt;',
' window.addEventListener("focus", function () {',
' schedulePageAuthRenewal();',
' });',
' document.addEventListener("visibilitychange", function () {',
' if (document.visibilityState === "visible") {',
' schedulePageAuthRenewal();',
' }',
' });',
' window.fetch = function (input, init) {',
' try {',
' var requestUrl = input instanceof Request ? new URL(input.url, window.location.href) : new URL(String(input), window.location.href);',
' if (requestUrl.origin === window.location.origin && requestUrl.pathname.indexOf("/api/") === 0) {',
' var isRenewalRequest = requestUrl.pathname === "/api/auth/page";',
' var requestInit = init ? Object.assign({}, init) : {};',
' var headers = input instanceof Request ? new Headers(input.headers) : new Headers(requestInit.headers || {});',
' headers.set(' + JSON.stringify(PAGE_TOKEN_HEADER) + ', pageAuthToken);',
' if (input instanceof Request) {',
' requestInit = { headers: headers };',
' var firstResponse = originalFetch(new Request(input, requestInit));',
' return firstResponse.then(function (response) {',
' if (response.status !== 401 || isRenewalRequest || !pageAuthToken) {',
' return response;',
' }',
' return renewPageAuthToken().then(function (nextToken) {',
' if (!nextToken) {',
' return response;',
' }',
' var retryHeaders = new Headers(input.headers);',
' retryHeaders.set(' + JSON.stringify(PAGE_TOKEN_HEADER) + ', nextToken);',
' return originalFetch(new Request(input, { headers: retryHeaders }));',
' }).catch(function () {',
' return response;',
' });',
' });',
' }',
' requestInit.headers = headers;',
' var firstRequest = originalFetch(input, requestInit);',
' return firstRequest.then(function (response) {',
' if (response.status !== 401 || isRenewalRequest || !pageAuthToken) {',
' return response;',
' }',
' return renewPageAuthToken().then(function (nextToken) {',
' if (!nextToken) {',
' return response;',
' }',
' var retryRequestInit = init ? Object.assign({}, init) : {};',
' var retryHeaders = new Headers(retryRequestInit.headers || {});',
' retryHeaders.set(' + JSON.stringify(PAGE_TOKEN_HEADER) + ', nextToken);',
' retryRequestInit.headers = retryHeaders;',
' return originalFetch(input, retryRequestInit);',
' }).catch(function () {',
' return response;',
' });',
' });',
' }',
' } catch (_error) {',
' return originalFetch(input, init);',
' }',
' return originalFetch(input, init);',
' };',
' schedulePageAuthRenewal();',
' }());',
'</script>'
].join('');
}
module.exports = {
getSharedSecret: getSharedSecret,
PAGE_TOKEN_HEADER: PAGE_TOKEN_HEADER,
REQUEST_TIMESTAMP_HEADER: REQUEST_TIMESTAMP_HEADER,
REQUEST_SIGNATURE_HEADER: REQUEST_SIGNATURE_HEADER,
PAGE_TOKEN_TTL_MS: PAGE_TOKEN_TTL_MS,
REQUEST_AUTH_MAX_SKEW_MS: REQUEST_AUTH_MAX_SKEW_MS,
createPageAuthToken: createPageAuthToken,
createPageAuthBundle: createPageAuthBundle,
verifyPageAuthToken: verifyPageAuthToken,
createRequestAuthHeaders: createRequestAuthHeaders,
verifyRequestAuth: verifyRequestAuth,
createPageFetchAuthScript: createPageFetchAuthScript
};
+132 -26
View File
@@ -6,20 +6,24 @@ const path = require('path');
const crypto = require('crypto');
const common = require('./common');
const { verifyPassword, createSessionToken, hashSessionToken, hashPassword } = require('./auth');
const pages = require('./web/routes');
const pages = require('./web/pages');
const registerAuthRoutes = require('./web/routes/auth');
const registerAdminPagesRoutes = require('./web/routes/admin-pages');
const registerAdminAccountRoutes = require('./web/routes/admin-account');
const registerAdminUsersRoutes = require('./web/routes/admin-users');
const registerAdminManageRoutes = require('./web/routes/admin-manage');
const registerAdminScreenCommandRoutes = require('./web/routes/admin-client-commands');
const registerAdminContentRoutes = require('./web/routes/admin-content');
const registerAdminPagesRoutes = require('./web/routes/admin/pages');
const registerAdminAccountRoutes = require('./web/routes/admin/account');
const registerAdminUsersRoutes = require('./web/routes/admin/users');
const registerAdminManageRoutes = require('./web/routes/admin/manage');
const registerAdminScreenCommandRoutes = require('./web/routes/admin/client-commands');
const registerAdminContentRoutes = require('./web/routes/admin/content');
const registerAdminDataSourceRoutes = require('./web/routes/data-sources');
const registerAdminSettingsRoutes = require('./web/routes/settings/background-tasks');
const { createBackgroundTaskQueue, normalizeIntervalMs } = require('./web/lib/background-task-queue');
const { refreshApiSource, refreshRssFeed } = require('./web/lib/data-source-refresh');
const { createWebBootstrap } = require('./web/bootstrap');
const { requirePermission } = require('./rbac');
const rbacData = require('./web/rbac-data');
const { createPlayerActionService } = require('./web/player-actions');
const { isClientNameAvailable, withClientNameReservation } = require('./client-name-check');
const { createSessionService } = require('./web/session');
const rbacData = require('./web/lib/rbac-data');
const { createPlayerActionService } = require('./web/lib/player-actions');
const { isClientNameAvailable, withClientNameReservation } = require('./data/client-name-check');
const { createSessionService } = require('./web/lib/session');
const {
formatDashboardDate,
readArrayField,
@@ -34,7 +38,7 @@ const {
fetchScreensByTemplateId,
fetchOrderedPlaylistSlides,
redirectAfterSave
} = require('./web/helpers');
} = require('./web/lib/helpers');
const PLAYER_INTERNAL_BASE_URL = (process.env.PLAYER_INTERNAL_BASE_URL || process.env.PLAYER_BASE_URL || 'http://player:3001').replace(/\/$/, '');
const PLAYER_PUBLIC_BASE_URL = (process.env.PLAYER_PUBLIC_BASE_URL || process.env.PLAYER_BASE_URL || 'http://localhost:3001').replace(/\/$/, '');
const PLAYER_WS_BASE_URL = PLAYER_INTERNAL_BASE_URL.replace(/^http/, 'ws');
@@ -47,8 +51,12 @@ async function start() {
const server = http.createServer(app);
const pool = common.createPool();
const PORT = Number(process.env.WEB_PORT || 3000);
const UPLOAD_DIR = path.join(__dirname, '..', 'uploads');
const MEDIA_DIR = path.join(__dirname, '..', 'media');
const ASSET_DIR = path.join(__dirname, 'web', 'public');
const backgroundTaskQueue = createBackgroundTaskQueue({
pool: pool,
maxConcurrent: 1
});
const playerActionService = createPlayerActionService({
common: common,
playerInternalBaseUrl: PLAYER_INTERNAL_BASE_URL
@@ -75,10 +83,11 @@ async function start() {
common: common,
playerInternalBaseUrl: PLAYER_INTERNAL_BASE_URL,
playerPublicBaseUrl: PLAYER_PUBLIC_BASE_URL,
uploadDir: UPLOAD_DIR,
uploadDir: MEDIA_DIR,
dashboardRefreshIntervalMs: Number(process.env.DASHBOARD_REFRESH_INTERVAL_MS || 2000),
formatDashboardDate: formatDashboardDate,
notifyPlayerScreens: notifyPlayerScreens
notifyPlayerScreens: notifyPlayerScreens,
backgroundTaskQueue: backgroundTaskQueue
});
const upload = webBootstrap.upload;
const collectUploadReferencesFromSlide = webBootstrap.collectUploadReferencesFromSlide;
@@ -86,6 +95,7 @@ async function start() {
const collectUploadReferencesFromPayload = webBootstrap.collectUploadReferencesFromPayload;
const syncPlaylistUploadsOnChange = webBootstrap.syncPlaylistUploadsOnChange;
const syncExistingUploadsToPlayer = webBootstrap.syncExistingUploadsToPlayer;
const runMediaSyncTask = webBootstrap.runMediaSyncTask;
const broadcastDashboardState = webBootstrap.broadcastDashboardState;
const sessionService = createSessionService({
sessionCookieName: SESSION_COOKIE_NAME,
@@ -100,10 +110,38 @@ async function start() {
const createUserSession = sessionService.createUserSession;
const requireAuth = sessionService.requireAuth;
backgroundTaskQueue.setTaskHandler('media-sync', function (task) {
return runMediaSyncTask(task && task.payload ? task.payload : task);
});
backgroundTaskQueue.setTaskHandler('data-source-refresh', async function (task) {
const payload = task && task.payload ? task.payload : {};
const sourceType = String(payload.sourceType || '').trim();
const sourceId = Number(payload.sourceId || 0);
if (sourceType === 'api-source') {
const apiSource = await common.fetchApiSourceById(pool, sourceId);
if (!apiSource) {
throw new Error('API source not found.');
}
return refreshApiSource(pool, common, apiSource.id, apiSource.api_url, Number(payload.actorId) || null);
}
if (sourceType === 'rss-feed') {
const rssFeed = await common.fetchRssFeedById(pool, sourceId);
if (!rssFeed) {
throw new Error('RSS feed not found.');
}
return refreshRssFeed(pool, common, rssFeed.id, rssFeed.feed_url, rssFeed.item_limit, Number(payload.actorId) || null);
}
throw new Error('Unsupported data source refresh task.');
});
app.use(express.urlencoded({ extended: true }));
app.use(express.json());
app.use('/assets', express.static(ASSET_DIR));
app.use('/uploads', express.static(UPLOAD_DIR));
app.use('/media', express.static(MEDIA_DIR));
app.use(async function (req, _res, next) {
try {
@@ -114,7 +152,13 @@ async function start() {
}
});
app.use('/admin', requireAuth);
app.use(function (req, res, next) {
if (req.path === '/' || req.path === '/login' || req.path === '/logout') {
return next();
}
return requireAuth(req, res, next);
});
registerAuthRoutes(app, {
pool: pool,
@@ -149,6 +193,7 @@ async function start() {
registerAdminUsersRoutes(app, {
pool: pool,
common: common,
pages: pages,
formatDashboardDate: formatDashboardDate,
getAuditUserId: getAuditUserId,
@@ -191,9 +236,10 @@ async function start() {
requirePermission: requirePermission
});
const registerAdminRbacRoutes = require('./web/routes/admin-rbac');
const registerAdminRbacRoutes = require('./web/routes/admin/rbac');
registerAdminRbacRoutes(app, {
pool: pool,
common: common,
pages: pages,
getAuditUserId: getAuditUserId,
rbacData: rbacData,
@@ -208,7 +254,7 @@ async function start() {
common: common,
pages: pages,
upload: upload,
uploadDir: UPLOAD_DIR,
uploadDir: MEDIA_DIR,
fetchScreensBySlideId: fetchScreensBySlideId,
fetchScreensByTemplateId: fetchScreensByTemplateId,
collectUploadReferencesFromSlide: collectUploadReferencesFromSlide,
@@ -225,6 +271,23 @@ async function start() {
requirePermission: requirePermission
});
registerAdminDataSourceRoutes(app, {
pool: pool,
common: common,
pages: pages,
formatDashboardDate: formatDashboardDate,
getAuditUserId: getAuditUserId,
redirectAfterSave: redirectAfterSave,
fetchRssFeedItems: common.fetchRssFeedItems,
backgroundTaskQueue: backgroundTaskQueue,
requirePermission: requirePermission
});
registerAdminSettingsRoutes(app, {
pages: pages,
backgroundTaskQueue: backgroundTaskQueue
});
app.use(function (req, res, next) {
const pathName = String(req.originalUrl || '');
@@ -239,7 +302,7 @@ async function start() {
title: 'Not found',
errorTitle: 'Oops! Page not found.',
message: 'We could not find the page you were looking for.',
backUrl: req.currentUser ? '/admin' : '/login',
backUrl: req.currentUser ? '/dashboard' : '/login',
backLabel: req.currentUser ? 'Back to dashboard' : 'Sign in'
}, req.currentUser));
});
@@ -247,7 +310,8 @@ async function start() {
app.use(function (error, req, res, _next) {
console.error(error);
const statusCode = Number(error && (error.statusCode || error.status)) || 500;
const wantsHtml = !String(req.originalUrl || '').startsWith('/api/') && (!req.accepts || req.accepts('html'));
const isXhr = String(req.get && req.get('X-Requested-With') || '').toLowerCase() === 'xmlhttprequest';
const wantsHtml = !isXhr && !String(req.originalUrl || '').startsWith('/api/') && (!req.accepts || req.accepts('html'));
if (wantsHtml && pages.renderErrorPage) {
const isPermissionError = statusCode === 403;
@@ -266,7 +330,7 @@ async function start() {
errorTitle: title,
message: message,
detail: statusCode >= 500 ? 'The server could not complete the request.' : '',
backUrl: req.currentUser ? '/admin' : '/login',
backUrl: req.currentUser ? '/dashboard' : '/login',
backLabel: req.currentUser ? 'Back to dashboard' : 'Sign in'
}, req.currentUser));
}
@@ -274,11 +338,53 @@ async function start() {
res.status(statusCode).send(statusCode >= 500 ? 'Internal server error' : String(error && error.message ? error.message : 'Error'));
});
// Ensure schema and mirror uploads before the web service starts handling traffic.
// Ensure schema and mirror media before the web service starts handling traffic.
await common.ensureSchema(pool);
fs.mkdirSync(UPLOAD_DIR, { recursive: true });
syncExistingUploadsToPlayer(pool, UPLOAD_DIR).catch(function (error) {
console.warn('Unable to sync existing uploads to player:', error);
await backgroundTaskQueue.initialize();
async function syncRecurringRefreshes() {
const apiSourcesData = await common.fetchApiSourcesData(pool);
(apiSourcesData.apiSources || []).forEach(function (apiSource) {
backgroundTaskQueue.registerRecurringTask({
key: 'api-source-refresh:' + Number(apiSource.id),
title: 'API source refresh',
category: 'data-source',
intervalMs: normalizeIntervalMs(apiSource.update_interval_value, apiSource.update_interval_unit),
metadata: {
sourceType: 'api-source',
sourceId: Number(apiSource.id),
sourceName: apiSource.name
},
run: function () {
return refreshApiSource(pool, common, apiSource.id, apiSource.api_url, null);
}
});
});
const rssFeedsData = await common.fetchRssFeedsData(pool);
(rssFeedsData.rssFeeds || []).forEach(function (rssFeed) {
backgroundTaskQueue.registerRecurringTask({
key: 'rss-feed-refresh:' + Number(rssFeed.id),
title: 'RSS feed refresh',
category: 'data-source',
intervalMs: normalizeIntervalMs(rssFeed.update_interval_value, rssFeed.update_interval_unit),
metadata: {
sourceType: 'rss-feed',
sourceId: Number(rssFeed.id),
sourceName: rssFeed.name
},
run: function () {
return refreshRssFeed(pool, common, rssFeed.id, rssFeed.feed_url, rssFeed.item_limit, null);
}
});
});
}
await syncRecurringRefreshes();
fs.mkdirSync(MEDIA_DIR, { recursive: true });
await syncExistingUploadsToPlayer(pool, MEDIA_DIR).catch(function (error) {
console.warn('Unable to sync existing media to player:', error);
});
webBootstrap.installDashboardWebsocket(server, loadCurrentUser);
+18 -5
View File
@@ -1,6 +1,7 @@
const { WebSocketServer, WebSocket } = require('ws');
const { createDashboardStateService } = require('./dashboard-state');
const { createUploadSyncService } = require('./upload-sync');
const { createDashboardStateService } = require('./lib/dashboard-state');
const { createUploadSyncService } = require('./lib/upload-sync');
const { createRequestAuthHeaders } = require('../request-auth');
function createWebBootstrap(options) {
const pool = options && options.pool;
@@ -11,6 +12,7 @@ function createWebBootstrap(options) {
const dashboardRefreshIntervalMs = Number(options && options.dashboardRefreshIntervalMs || 2000);
const formatDashboardDate = options && options.formatDashboardDate;
const notifyPlayerScreens = options && options.notifyPlayerScreens;
const backgroundTaskQueue = options && options.backgroundTaskQueue;
if (!pool || !common || !uploadDir || typeof formatDashboardDate !== 'function' || typeof notifyPlayerScreens !== 'function') {
throw new Error('createWebBootstrap requires the web bootstrap dependencies.');
@@ -51,7 +53,14 @@ function createWebBootstrap(options) {
return;
}
const socket = new WebSocket(getPlayerSnapshotSocketUrl(key));
const socketUrl = getPlayerSnapshotSocketUrl(key);
const authHeaders = createRequestAuthHeaders({
method: 'GET',
pathname: `/ws/screens/${encodeURIComponent(key)}/events`
});
const socket = new WebSocket(socketUrl, {
headers: authHeaders
});
playerSnapshotSockets.set(key, socket);
socket.onmessage = function (event) {
@@ -99,10 +108,12 @@ function createWebBootstrap(options) {
const buildDashboardState = dashboardStateService.buildDashboardState;
const uploadSyncService = createUploadSyncService({
pool: pool,
common: common,
playerInternalBaseUrl: playerInternalBaseUrl,
playerSnapshotCache: playerSnapshotCache,
notifyPlayerScreens: notifyPlayerScreens
notifyPlayerScreens: notifyPlayerScreens,
backgroundTaskQueue: backgroundTaskQueue
});
const upload = uploadSyncService.createUploadMiddleware(uploadDir);
@@ -111,6 +122,7 @@ function createWebBootstrap(options) {
const collectUploadReferencesFromPayload = uploadSyncService.collectUploadReferencesFromPayload;
const syncPlaylistUploadsOnChange = uploadSyncService.syncPlaylistUploadsOnChange;
const syncExistingUploadsToPlayer = uploadSyncService.syncExistingUploadsToPlayer;
const runMediaSyncTask = uploadSyncService.runMediaSyncTask;
async function sendDashboardStateToSocket(socket) {
if (!socket || socket.readyState !== WebSocket.OPEN) {
@@ -151,7 +163,7 @@ function createWebBootstrap(options) {
return;
}
if (pathname !== '/ws/admin/dashboard') {
if (pathname !== '/ws/dashboard') {
socket.destroy();
return;
}
@@ -200,6 +212,7 @@ function createWebBootstrap(options) {
collectUploadReferencesFromPayload: collectUploadReferencesFromPayload,
syncPlaylistUploadsOnChange: syncPlaylistUploadsOnChange,
syncExistingUploadsToPlayer: syncExistingUploadsToPlayer,
runMediaSyncTask: runMediaSyncTask,
broadcastDashboardState: broadcastDashboardState,
installDashboardWebsocket: installDashboardWebsocket
};
+743
View File
@@ -0,0 +1,743 @@
function normalizeText(value) {
return String(value || '').trim();
}
function toIsoDate(value) {
if (!value) {
return '';
}
const date = value instanceof Date ? value : new Date(value);
return Number.isNaN(date.getTime()) ? '' : date.toISOString();
}
function normalizeIntervalMs(value, unit) {
const numericValue = Math.max(1, Number(value) || 0);
const normalizedUnit = String(unit || 'minutes').trim().toLowerCase();
if (normalizedUnit === 'seconds') {
return numericValue * 1000;
}
return numericValue * 60 * 1000;
}
function createBackgroundTaskQueue(options) {
const pool = options && options.pool;
const maxConcurrent = Math.max(1, Number(options && options.maxConcurrent) || 1);
const taskHandlers = new Map();
const tasksById = new Map();
const recurringJobsByKey = new Map();
const taskIdToRecurringKey = new Map();
const pendingIds = [];
let nextTaskId = 1;
let activeCount = 0;
let drainScheduled = false;
let initializationPromise = null;
function createTaskCompletionController() {
let resolveCompletion = null;
let rejectCompletion = null;
const completionPromise = new Promise(function (resolve, reject) {
resolveCompletion = resolve;
rejectCompletion = reject;
});
return {
promise: completionPromise,
resolve: resolveCompletion,
reject: rejectCompletion
};
}
function parseJsonValue(value, fallback) {
if (value === null || value === undefined || value === '') {
return fallback;
}
if (typeof value === 'object') {
return value;
}
try {
return JSON.parse(String(value));
} catch (_error) {
return fallback;
}
}
function stringifyJsonValue(value) {
if (value === undefined || value === null) {
return null;
}
return JSON.stringify(value);
}
function buildSnapshot(task) {
return {
id: task.id,
key: task.key,
taskType: task.taskType || '',
title: task.title,
category: task.category,
status: task.status,
createdAt: task.createdAt,
startedAt: task.startedAt,
finishedAt: task.finishedAt,
errorMessage: task.errorMessage,
attempts: task.attempts || 0,
metadata: task.metadata,
payload: task.payload || null
};
}
function getTaskById(taskId) {
const numericTaskId = Number(taskId);
if (!Number.isFinite(numericTaskId) || numericTaskId <= 0) {
return null;
}
return tasksById.get(numericTaskId) || null;
}
function buildTaskFromRow(row) {
const task = {
id: Number(row.id),
key: String(row.task_key || '').trim(),
taskType: String(row.task_type || '').trim(),
title: String(row.title || 'Background task').trim() || 'Background task',
category: String(row.category || 'general').trim() || 'general',
status: String(row.status || 'queued').trim() || 'queued',
createdAt: row.created_at ? new Date(row.created_at).toISOString() : '',
startedAt: row.started_at ? new Date(row.started_at).toISOString() : '',
finishedAt: row.finished_at ? new Date(row.finished_at).toISOString() : '',
errorMessage: String(row.error_message || ''),
attempts: Math.max(0, Number(row.attempts) || 0),
metadata: parseJsonValue(row.metadata_json, {}),
payload: parseJsonValue(row.payload_json, null),
completionPromise: null,
resolveCompletion: null,
rejectCompletion: null,
persisted: true,
run: typeof row.run === 'function' ? row.run : function () {
return Promise.resolve();
}
};
const completionController = createTaskCompletionController();
task.completionPromise = completionController.promise;
task.resolveCompletion = completionController.resolve;
task.rejectCompletion = completionController.reject;
return task;
}
function buildTaskRecord(task) {
return {
task_key: task.key || null,
task_type: task.taskType || 'general',
title: task.title,
category: task.category || 'general',
status: task.status,
payload_json: stringifyJsonValue(task.payload),
metadata_json: stringifyJsonValue(task.metadata),
attempts: Math.max(0, Number(task.attempts) || 0),
created_at: task.createdAt ? new Date(task.createdAt) : new Date(),
started_at: task.startedAt ? new Date(task.startedAt) : null,
finished_at: task.finishedAt ? new Date(task.finishedAt) : null,
error_message: task.errorMessage || null
};
}
async function persistTaskInsert(task) {
if (!pool || !task.taskType) {
return task;
}
const record = buildTaskRecord(task);
const [result] = await pool.query(
`INSERT INTO background_tasks (
task_key,
task_type,
title,
category,
status,
payload_json,
metadata_json,
attempts,
created_at,
started_at,
finished_at,
error_message
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
record.task_key,
record.task_type,
record.title,
record.category,
record.status,
record.payload_json,
record.metadata_json,
record.attempts,
record.created_at,
record.started_at,
record.finished_at,
record.error_message
]
);
task.id = Number(result.insertId);
task.persisted = true;
return task;
}
async function persistTaskUpdate(task) {
if (!pool || !task.persisted) {
return;
}
const record = buildTaskRecord(task);
await pool.query(
`UPDATE background_tasks
SET task_key = ?, task_type = ?, title = ?, category = ?, status = ?, payload_json = ?, metadata_json = ?, attempts = ?, started_at = ?, finished_at = ?, error_message = ?
WHERE id = ?`,
[
record.task_key,
record.task_type,
record.title,
record.category,
record.status,
record.payload_json,
record.metadata_json,
record.attempts,
record.started_at,
record.finished_at,
record.error_message,
task.id
]
);
}
async function persistTaskDelete(taskId) {
if (!pool) {
return;
}
await pool.query('DELETE FROM background_tasks WHERE id = ?', [taskId]);
}
async function initialize() {
if (initializationPromise) {
return initializationPromise;
}
initializationPromise = (async function () {
if (!pool) {
return;
}
const [rows] = await pool.query(
'SELECT id, task_key, task_type, title, category, status, payload_json, metadata_json, attempts, created_at, started_at, finished_at, error_message FROM background_tasks ORDER BY id ASC'
);
let highestTaskId = 0;
for (const row of rows || []) {
const task = buildTaskFromRow(row);
if (!Number.isInteger(task.id) || task.id <= 0) {
continue;
}
highestTaskId = Math.max(highestTaskId, task.id);
tasksById.set(task.id, task);
if (task.status === 'running') {
task.status = 'queued';
task.startedAt = '';
task.finishedAt = '';
task.errorMessage = '';
await pool.query(
'UPDATE background_tasks SET status = ?, started_at = NULL, finished_at = NULL, error_message = NULL WHERE id = ?',
['queued', task.id]
);
}
if (task.status === 'queued' || task.status === 'running') {
pendingIds.push(task.id);
}
}
nextTaskId = Math.max(nextTaskId, highestTaskId + 1);
if (pendingIds.length) {
scheduleDrain();
}
})();
return initializationPromise;
}
function scheduleDrain() {
if (drainScheduled) {
return;
}
drainScheduled = true;
setTimeout(function () {
drainScheduled = false;
processQueue();
}, 0);
}
function clearRecurringTimer(job) {
if (job && job.timerId) {
clearTimeout(job.timerId);
job.timerId = null;
}
}
function setTaskHandler(taskType, handler) {
const normalizedTaskType = normalizeText(taskType);
if (!normalizedTaskType || typeof handler !== 'function') {
return false;
}
taskHandlers.set(normalizedTaskType, handler);
return true;
}
function scheduleRecurringRun(job, delayMs) {
if (!job || job.enabled === false) {
return;
}
clearRecurringTimer(job);
const safeDelay = Math.max(1, Number(delayMs) || job.intervalMs || 0);
job.nextRunAt = toIsoDate(new Date(Date.now() + safeDelay));
job.timerId = setTimeout(function () {
job.timerId = null;
triggerRecurringJob(job.key);
}, safeDelay);
}
function triggerRecurringJob(recurringKey) {
const job = recurringJobsByKey.get(recurringKey);
if (!job || job.enabled === false) {
return;
}
if (job.activeTaskId && tasksById.has(job.activeTaskId)) {
scheduleRecurringRun(job, job.intervalMs);
return;
}
job.activeTaskId = -1;
enqueueTask({
key: `${job.key}:${Date.now()}`,
title: job.title,
category: job.category,
taskType: job.taskType || '',
metadata: Object.assign({}, job.metadata || {}, {
recurringKey: job.key,
recurringTitle: job.title
}),
payload: Object.assign({}, job.payload || {}, {
recurringKey: job.key
}),
run: job.run,
persist: Boolean(job.taskType)
}).then(function (task) {
if (task && Number.isInteger(task.id)) {
job.activeTaskId = task.id;
taskIdToRecurringKey.set(task.id, job.key);
} else {
job.activeTaskId = null;
}
}).catch(function (error) {
job.activeTaskId = null;
console.error(error);
});
scheduleRecurringRun(job, job.intervalMs);
}
function syncRecurringTaskState(task, status, errorMessage) {
const recurringKey = taskIdToRecurringKey.get(task.id) || (task && task.metadata && task.metadata.recurringKey);
if (!recurringKey) {
return;
}
const job = recurringJobsByKey.get(recurringKey);
if (!job) {
taskIdToRecurringKey.delete(task.id);
return;
}
job.activeTaskId = null;
job.lastRunAt = toIsoDate(new Date());
job.lastStatus = status;
job.lastError = errorMessage ? String(errorMessage) : '';
taskIdToRecurringKey.delete(task.id);
}
async function processQueue() {
while (activeCount < maxConcurrent) {
const nextTaskId = pendingIds.shift();
if (!nextTaskId) {
break;
}
const task = tasksById.get(nextTaskId);
if (!task || task.status !== 'queued') {
continue;
}
activeCount += 1;
task.status = 'running';
task.startedAt = toIsoDate(new Date());
task.errorMessage = '';
task.attempts = Math.max(0, Number(task.attempts) || 0) + 1;
try {
await persistTaskUpdate(task);
} catch (error) {
task.status = 'failed';
task.errorMessage = String(error && error.message ? error.message : 'Unable to update task state.');
task.finishedAt = toIsoDate(new Date());
if (typeof task.rejectCompletion === 'function') {
const completionError = new Error(task.errorMessage);
completionError.task = buildSnapshot(task);
task.rejectCompletion(completionError);
}
activeCount = Math.max(0, activeCount - 1);
scheduleDrain();
continue;
}
Promise.resolve()
.then(function () {
if (task.taskType) {
const handler = taskHandlers.get(task.taskType);
if (!handler) {
throw new Error('No handler registered for task type ' + task.taskType + '.');
}
return handler({
id: task.id,
key: task.key,
title: task.title,
category: task.category,
taskType: task.taskType,
payload: task.payload,
metadata: task.metadata,
attempts: task.attempts
});
}
return task.run({
id: task.id,
key: task.key,
title: task.title,
category: task.category,
metadata: task.metadata
});
})
.then(function () {
task.status = 'completed';
task.finishedAt = toIsoDate(new Date());
if (typeof task.resolveCompletion === 'function') {
task.resolveCompletion(buildSnapshot(task));
}
syncRecurringTaskState(task, task.status, '');
return persistTaskUpdate(task);
})
.catch(function (error) {
task.status = 'failed';
task.errorMessage = String(error && error.message ? error.message : 'Background task failed.');
task.finishedAt = toIsoDate(new Date());
if (typeof task.rejectCompletion === 'function') {
const completionError = new Error(task.errorMessage);
completionError.task = buildSnapshot(task);
task.rejectCompletion(completionError);
}
syncRecurringTaskState(task, task.status, task.errorMessage);
return persistTaskUpdate(task);
})
.finally(function () {
activeCount = Math.max(0, activeCount - 1);
scheduleDrain();
});
}
}
async function enqueueTask(definition) {
const normalizedKey = normalizeText(definition && definition.key);
const normalizedTitle = normalizeText(definition && definition.title) || 'Background task';
const normalizedTaskType = normalizeText(definition && definition.taskType);
const shouldPersist = Boolean((definition && definition.persist) || (pool && normalizedTaskType));
const existingTask = normalizedKey
? Array.from(tasksById.values()).find(function (task) {
return task.key === normalizedKey && task.status === 'queued' && (!normalizedTaskType || task.taskType === normalizedTaskType);
})
: null;
if (existingTask) {
existingTask.title = normalizedTitle;
existingTask.category = normalizeText(definition && definition.category) || existingTask.category || 'general';
existingTask.metadata = definition && definition.metadata ? definition.metadata : {};
existingTask.taskType = normalizedTaskType || existingTask.taskType || '';
existingTask.payload = definition && definition.payload !== undefined ? definition.payload : existingTask.payload;
existingTask.run = typeof definition.run === 'function' ? definition.run : existingTask.run;
existingTask.createdAt = toIsoDate(new Date());
existingTask.errorMessage = '';
existingTask.persisted = existingTask.persisted || shouldPersist;
await persistTaskUpdate(existingTask);
return buildSnapshot(existingTask);
}
const task = {
id: shouldPersist ? 0 : nextTaskId,
key: normalizedKey,
taskType: normalizedTaskType,
title: normalizedTitle,
category: normalizeText(definition && definition.category) || 'general',
status: 'queued',
createdAt: toIsoDate(new Date()),
startedAt: '',
finishedAt: '',
errorMessage: '',
attempts: 0,
metadata: definition && definition.metadata ? definition.metadata : {},
payload: definition && definition.payload !== undefined ? definition.payload : null,
persisted: shouldPersist,
run: typeof definition.run === 'function' ? definition.run : function () {
return Promise.resolve();
},
completionPromise: null,
resolveCompletion: null,
rejectCompletion: null
};
const completionController = createTaskCompletionController();
task.completionPromise = completionController.promise;
task.resolveCompletion = completionController.resolve;
task.rejectCompletion = completionController.reject;
if (shouldPersist) {
await persistTaskInsert(task);
} else {
nextTaskId += 1;
}
tasksById.set(task.id, task);
pendingIds.push(task.id);
scheduleDrain();
return buildSnapshot(task);
}
function registerRecurringTask(definition) {
const normalizedKey = normalizeText(definition && definition.key);
if (!normalizedKey) {
throw new Error('Recurring tasks require a key.');
}
const intervalMs = Math.max(1000, Number(definition && definition.intervalMs) || 0);
if (!Number.isFinite(intervalMs) || intervalMs < 1000) {
throw new Error('Recurring tasks require a valid interval.');
}
const job = recurringJobsByKey.get(normalizedKey) || {
key: normalizedKey,
activeTaskId: null,
lastRunAt: '',
lastStatus: '',
lastError: '',
nextRunAt: '',
timerId: null,
enabled: true
};
clearRecurringTimer(job);
job.title = normalizeText(definition && definition.title) || 'Background task';
job.category = normalizeText(definition && definition.category) || 'general';
job.intervalMs = intervalMs;
job.metadata = definition && definition.metadata ? definition.metadata : {};
job.run = typeof definition.run === 'function' ? definition.run : function () {
return Promise.resolve();
};
job.enabled = definition && definition.enabled === false ? false : true;
recurringJobsByKey.set(normalizedKey, job);
if (job.enabled) {
scheduleRecurringRun(job, intervalMs);
}
return buildRecurringSnapshot(job);
}
function removeRecurringTask(recurringKey) {
const normalizedKey = normalizeText(recurringKey);
const job = recurringJobsByKey.get(normalizedKey);
if (!job) {
return false;
}
clearRecurringTimer(job);
recurringJobsByKey.delete(normalizedKey);
return true;
}
function buildRecurringSnapshot(job) {
return {
key: job.key,
title: job.title,
category: job.category,
intervalMs: job.intervalMs,
enabled: job.enabled !== false,
activeTaskId: job.activeTaskId || null,
createdAt: job.createdAt || '',
nextRunAt: job.nextRunAt || '',
lastRunAt: job.lastRunAt || '',
lastStatus: job.lastStatus || '',
lastError: job.lastError || '',
metadata: job.metadata || {}
};
}
function listTasks() {
return Array.from(tasksById.values())
.slice()
.sort(function (left, right) {
const statusRank = {
running: 0,
queued: 1,
failed: 2,
completed: 3,
canceled: 4
};
const leftRank = Object.prototype.hasOwnProperty.call(statusRank, left.status) ? statusRank[left.status] : 9;
const rightRank = Object.prototype.hasOwnProperty.call(statusRank, right.status) ? statusRank[right.status] : 9;
if (leftRank !== rightRank) {
return leftRank - rightRank;
}
return right.id - left.id;
})
.map(buildSnapshot);
}
function listRecurringTasks() {
return Array.from(recurringJobsByKey.values())
.slice()
.sort(function (left, right) {
return left.key.localeCompare(right.key);
})
.map(buildRecurringSnapshot);
}
function clearFinishedTasks() {
let removedCount = 0;
Array.from(tasksById.values()).forEach(function (task) {
if (task.status === 'running' || task.status === 'queued') {
return;
}
tasksById.delete(task.id);
removedCount += 1;
persistTaskDelete(task.id).catch(function (error) {
console.warn('Unable to delete finished task from persistence:', error);
});
});
return removedCount;
}
function cancelTask(taskId) {
const task = getTaskById(taskId);
if (!task || task.status !== 'queued') {
return false;
}
task.status = 'canceled';
task.finishedAt = toIsoDate(new Date());
const pendingIndex = pendingIds.indexOf(task.id);
if (pendingIndex >= 0) {
pendingIds.splice(pendingIndex, 1);
}
if (typeof task.rejectCompletion === 'function') {
const cancellationError = new Error('Task canceled.');
cancellationError.task = buildSnapshot(task);
task.rejectCompletion(cancellationError);
}
persistTaskUpdate(task).catch(function (error) {
console.warn('Unable to persist canceled task:', error);
});
return true;
}
function retryTask(taskId) {
const task = getTaskById(taskId);
if (!task || task.status !== 'failed') {
return null;
}
return enqueueTask({
key: task.key,
title: task.title,
category: task.category,
metadata: task.metadata,
run: task.run
});
}
function getSummary() {
const counts = {
queued: 0,
running: 0,
completed: 0,
failed: 0,
canceled: 0
};
listTasks().forEach(function (task) {
if (Object.prototype.hasOwnProperty.call(counts, task.status)) {
counts[task.status] += 1;
}
});
return {
activeCount: activeCount,
counts: counts,
scheduledCount: recurringJobsByKey.size,
total: listTasks().length
};
}
return {
enqueueTask: enqueueTask,
enqueueTaskAndWait: function (definition) {
return enqueueTask(definition).then(function (snapshot) {
const task = snapshot && snapshot.id ? getTaskById(snapshot.id) : null;
if (!task || !task.completionPromise) {
return snapshot;
}
return task.completionPromise;
});
},
initialize: initialize,
setTaskHandler: setTaskHandler,
registerRecurringTask: registerRecurringTask,
removeRecurringTask: removeRecurringTask,
listTasks: listTasks,
listRecurringTasks: listRecurringTasks,
getTaskById: getTaskById,
getSummary: getSummary,
cancelTask: cancelTask,
retryTask: retryTask,
clearFinishedTasks: clearFinishedTasks
};
}
module.exports = {
createBackgroundTaskQueue: createBackgroundTaskQueue,
normalizeIntervalMs: normalizeIntervalMs
};
+67
View File
@@ -0,0 +1,67 @@
async function refreshApiSource(pool, common, apiSourceId, apiUrl, actorId) {
const connection = await pool.getConnection();
try {
let responseDetails = null;
let pullError = '';
try {
responseDetails = await common.fetchApiSourceResponse(apiUrl);
} catch (error) {
pullError = String(error && error.message ? error.message : 'Unable to load API response.');
}
await connection.beginTransaction();
await connection.query(
'UPDATE api_sources SET last_pulled_at = ?, last_pull_error = ?, last_response_status = ?, last_response_content_type = ?, last_response_json = ?, modified_by = ? WHERE id = ?',
[new Date(), pullError || null, responseDetails ? responseDetails.responseStatus : null, responseDetails ? responseDetails.responseContentType : null, responseDetails ? responseDetails.responseJson : null, actorId, apiSourceId]
);
await connection.commit();
} catch (error) {
try {
await connection.rollback();
} catch (_rollbackError) {
// Ignore rollback failures and surface the original error.
}
throw error;
} finally {
connection.release();
}
}
async function refreshRssFeed(pool, common, rssFeedId, feedUrl, itemLimit, actorId) {
const connection = await pool.getConnection();
try {
let updatedItems = [];
let pullError = '';
try {
updatedItems = await common.fetchRssFeedItems(feedUrl, itemLimit);
} catch (error) {
pullError = String(error && error.message ? error.message : 'Unable to load feed items.');
}
await connection.beginTransaction();
if (typeof common.replaceRssFeedItems === 'function') {
await common.replaceRssFeedItems(connection, rssFeedId, updatedItems);
}
await connection.commit();
if (pullError) {
console.error('[data-source-refresh] RSS feed refresh completed with an error for feed ' + rssFeedId + ': ' + pullError);
}
} catch (error) {
try {
await connection.rollback();
} catch (_rollbackError) {
// Ignore rollback failures and surface the original error.
}
throw error;
} finally {
connection.release();
}
}
module.exports = {
refreshApiSource: refreshApiSource,
refreshRssFeed: refreshRssFeed
};
@@ -1,3 +1,5 @@
const { createRequestAuthHeaders } = require('../../request-auth');
function createPlayerActionService(options) {
const playerInternalBaseUrl = String(options && options.playerInternalBaseUrl || '').replace(/\/$/, '');
const common = options && options.common;
@@ -13,11 +15,17 @@ function createPlayerActionService(options) {
if (connectionId) {
payload.connectionId = connectionId;
}
const authHeaders = createRequestAuthHeaders({
method: 'POST',
pathname: `/api/screens/${encodeURIComponent(slug)}/commands`,
body: payload
});
const response = await fetch(`${playerInternalBaseUrl}/api/screens/${encodeURIComponent(slug)}/commands`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json'
Accept: 'application/json',
...authHeaders
},
body: JSON.stringify(payload)
});
@@ -35,10 +43,15 @@ function createPlayerActionService(options) {
}
async function getScreenConnections(slug) {
const authHeaders = createRequestAuthHeaders({
method: 'GET',
pathname: `/api/screens/${encodeURIComponent(slug)}/connections`
});
const response = await fetch(`${playerInternalBaseUrl}/api/screens/${encodeURIComponent(slug)}/connections`, {
method: 'GET',
headers: {
Accept: 'application/json'
Accept: 'application/json',
...authHeaders
}
});
@@ -1,4 +1,4 @@
const { PERMISSIONS, normalizePermissionKeys } = require('../rbac');
const { PERMISSIONS, normalizePermissionKeys } = require('../../rbac');
function parseCsvIds(value) {
return String(value || '')
@@ -1,4 +1,4 @@
const { normalizePermissionKeys } = require('../rbac');
const { normalizePermissionKeys } = require('../../rbac');
function createSessionService(options) {
const sessionCookieName = String(options && options.sessionCookieName || '').trim();
@@ -2,16 +2,19 @@ const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const multer = require('multer');
const { createRequestAuthHeaders } = require('../../request-auth');
function normalizeUploadRoot(uploadDir) {
return path.resolve(String(uploadDir || '').trim());
}
function createUploadSyncService(options) {
const pool = options && options.pool;
const common = options && options.common;
const playerInternalBaseUrl = String(options && options.playerInternalBaseUrl || '').replace(/\/$/, '');
const playerSnapshotCache = options && options.playerSnapshotCache;
const notifyPlayerScreens = options && options.notifyPlayerScreens;
const backgroundTaskQueue = options && options.backgroundTaskQueue;
let playerUploadSyncMode = null;
let playerUploadSyncModePromise = null;
@@ -42,7 +45,7 @@ function createUploadSyncService(options) {
function normalizeUploadReference(uploadPath) {
const value = String(uploadPath || '').trim();
if (!value || !value.startsWith('/uploads/')) {
if (!value || !value.startsWith('/media/')) {
return null;
}
return value;
@@ -157,16 +160,21 @@ function createUploadSyncService(options) {
playerUploadSyncModePromise = (async function () {
try {
const response = await fetch(`${playerInternalBaseUrl}/api/uploads/config`, {
const authHeaders = createRequestAuthHeaders({
method: 'GET',
pathname: '/api/media/config'
});
const response = await fetch(`${playerInternalBaseUrl}/api/media/config`, {
headers: {
Accept: 'application/json'
Accept: 'application/json',
...authHeaders
}
});
if (!response.ok) {
return null;
}
const data = await response.json();
const playerUploadDir = data && data.uploadDir ? normalizeUploadRoot(data.uploadDir) : null;
const playerUploadDir = data && data.mediaDir ? normalizeUploadRoot(data.mediaDir) : null;
if (!playerUploadDir) {
return null;
}
@@ -237,10 +245,16 @@ function createUploadSyncService(options) {
}
try {
const response = await fetch(`${playerInternalBaseUrl}/api/uploads/${encodeURIComponent(filename)}`, {
const authHeaders = createRequestAuthHeaders({
method: 'PUT',
pathname: `/api/media/${encodeURIComponent(filename)}`,
body: fileBuffer
});
const response = await fetch(`${playerInternalBaseUrl}/api/media/${encodeURIComponent(filename)}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/octet-stream'
'Content-Type': 'application/octet-stream',
...authHeaders
},
body: fileBuffer
});
@@ -262,10 +276,15 @@ function createUploadSyncService(options) {
const filename = path.basename(uploadPath);
try {
const response = await fetch(`${playerInternalBaseUrl}/api/uploads/${encodeURIComponent(filename)}`, {
const authHeaders = createRequestAuthHeaders({
method: 'DELETE',
pathname: `/api/media/${encodeURIComponent(filename)}`
});
const response = await fetch(`${playerInternalBaseUrl}/api/media/${encodeURIComponent(filename)}`, {
method: 'DELETE',
headers: {
Accept: 'application/json'
Accept: 'application/json',
...authHeaders
}
});
if (!response.ok && response.status !== 404) {
@@ -298,30 +317,10 @@ function createUploadSyncService(options) {
}
async function syncExistingUploadsToPlayer(pool, localUploadDir) {
if (!(await shouldMirrorUploads(localUploadDir))) {
return;
}
const data = await common.fetchAdminData(pool);
const uploadRefs = new Set();
(data.slides || []).forEach(function (slide) {
collectUploadReferencesFromSlide(slide).forEach(function (reference) {
uploadRefs.add(reference);
});
return queueMediaSyncTask('media-sync:initial', 'Initial media sync', {
mode: 'initial',
uploadDir: localUploadDir
});
(data.templates || []).forEach(function (template) {
collectUploadReferencesFromTemplate(template).forEach(function (reference) {
uploadRefs.add(reference);
});
});
Array.from(uploadRefs).forEach(function (uploadPath) {
queuePlayerUploadSync({
type: 'put',
uploadPath: uploadPath,
uploadDir: localUploadDir
});
});
await flushPendingPlayerUploadSyncs();
}
function getVisibleCurrentSlideIds() {
@@ -436,30 +435,10 @@ function createUploadSyncService(options) {
return;
}
if (operation.previousUploadRefs.length) {
const nextUploadRefSet = new Set(operation.nextUploadRefs);
await removeUnusedUploadFiles(operation.pool, operation.localUploadDir, operation.previousUploadRefs.filter(function (reference) {
return !nextUploadRefSet.has(reference);
}));
}
if (operation.nextUploadRefs.length) {
await syncUploadRefsToPlayer(operation.nextUploadRefs, operation.localUploadDir);
}
if (operation.refreshScreenSlugs.length) {
const refreshTargets = splitRefreshScreenSlugsByVisibility(operation.refreshScreenSlugs, operation.blockedSlideIds, operation.screenSlideCounts);
if (refreshTargets.ready.length) {
await notifyPlayerScreens(refreshTargets.ready, 'refresh');
}
refreshTargets.blocked.forEach(function (screenSlug) {
queuePlaylistUploadSync({
key: operation.key + ':refresh:' + screenSlug,
blockedSlideIds: operation.blockedSlideIds,
refreshScreenSlugs: [screenSlug]
});
});
}
return queueMediaSyncTask('media-sync:' + operation.key, 'Media sync', {
mode: 'playlist',
operation: operation
});
}
async function flushPendingPlaylistUploadSyncs() {
@@ -529,6 +508,96 @@ function createUploadSyncService(options) {
return pendingPlayerUploadSyncFlushInFlight;
}
async function runMediaSyncTask(payload) {
const taskPayload = payload || {};
const mode = String(taskPayload.mode || '').trim();
if (mode === 'initial') {
const uploadDir = String(taskPayload.uploadDir || '').trim();
if (!(await shouldMirrorUploads(uploadDir))) {
return;
}
const data = await common.fetchAdminData(pool);
const uploadRefs = new Set();
(data.slides || []).forEach(function (slide) {
collectUploadReferencesFromSlide(slide).forEach(function (reference) {
uploadRefs.add(reference);
});
});
(data.templates || []).forEach(function (template) {
collectUploadReferencesFromTemplate(template).forEach(function (reference) {
uploadRefs.add(reference);
});
});
Array.from(uploadRefs).forEach(function (uploadPath) {
queuePlayerUploadSync({
type: 'put',
uploadPath: uploadPath,
uploadDir: uploadDir
});
});
await flushPendingPlayerUploadSyncs();
return;
}
if (mode === 'playlist') {
const operation = normalizePlaylistUploadSyncOperation(taskPayload.operation || taskPayload);
if (operation.previousUploadRefs.length) {
const nextUploadRefSet = new Set(operation.nextUploadRefs);
await removeUnusedUploadFiles(pool, operation.localUploadDir, operation.previousUploadRefs.filter(function (reference) {
return !nextUploadRefSet.has(reference);
}));
}
if (operation.nextUploadRefs.length) {
await syncUploadRefsToPlayer(operation.nextUploadRefs, operation.localUploadDir);
}
if (operation.refreshScreenSlugs.length) {
const refreshTargets = splitRefreshScreenSlugsByVisibility(operation.refreshScreenSlugs, operation.blockedSlideIds, operation.screenSlideCounts);
if (refreshTargets.ready.length) {
await notifyPlayerScreens(refreshTargets.ready, 'refresh');
}
refreshTargets.blocked.forEach(function (screenSlug) {
queuePlaylistUploadSync({
key: operation.key + ':refresh:' + screenSlug,
blockedSlideIds: operation.blockedSlideIds,
refreshScreenSlugs: [screenSlug]
});
});
}
return;
}
throw new Error('Unknown media sync task mode.');
}
async function queueMediaSyncTask(taskKey, title, payload) {
const safePayload = Object.assign({}, payload || {});
delete safePayload.pool;
if (safePayload.operation && typeof safePayload.operation === 'object') {
safePayload.operation = Object.assign({}, safePayload.operation);
delete safePayload.operation.pool;
}
const definition = {
key: taskKey,
title: title,
category: 'media-sync',
taskType: 'media-sync',
payload: safePayload,
persist: true
};
if (backgroundTaskQueue && typeof backgroundTaskQueue.enqueueTaskAndWait === 'function') {
return backgroundTaskQueue.enqueueTaskAndWait(definition);
}
return runMediaSyncTask(safePayload);
}
return {
createUploadMiddleware: createUploadMiddleware,
normalizeUploadReference: normalizeUploadReference,
@@ -554,7 +623,9 @@ function createUploadSyncService(options) {
schedulePendingPlaylistUploadSyncFlush: schedulePendingPlaylistUploadSyncFlush,
syncPlaylistUploadsOnChange: syncPlaylistUploadsOnChange,
flushPendingPlaylistUploadSyncs: flushPendingPlaylistUploadSyncs,
flushPendingPlayerUploadSyncs: flushPendingPlayerUploadSyncs
flushPendingPlayerUploadSyncs: flushPendingPlayerUploadSyncs,
runMediaSyncTask: runMediaSyncTask,
queueMediaSyncTask: queueMediaSyncTask
};
}
+62
View File
@@ -0,0 +1,62 @@
const { renderView } = require('../view');
function getErrorCopy(statusCode, message) {
const normalizedStatusCode = Number(statusCode) || 500;
if (normalizedStatusCode === 403) {
return {
title: 'Access denied',
errorTitle: 'Access denied',
errorMessage: message || 'You do not have permission to access this area.',
backLabel: 'Back to dashboard'
};
}
if (normalizedStatusCode === 404) {
return {
title: 'Not found',
errorTitle: 'Oops! Page not found.',
errorMessage: message || 'We could not find the page you were looking for. Meanwhile, you may return to the dashboard or try searching for what you need.',
backLabel: 'Back to dashboard'
};
}
if (normalizedStatusCode >= 500) {
return {
title: 'Something went wrong',
errorTitle: 'Something went wrong.',
errorMessage: message || 'An unexpected error occurred. Please try again in a moment.',
backLabel: 'Back to dashboard'
};
}
return {
title: 'Error',
errorTitle: 'Error',
errorMessage: message || 'An unexpected error occurred.',
backLabel: 'Back to dashboard'
};
}
module.exports = function renderErrorPage(options, currentUser) {
const errorOptions = options || {};
const statusCode = Number(errorOptions.statusCode || 500);
const copy = getErrorCopy(statusCode, String(errorOptions.message || '').trim());
return renderView('error/error', {
title: String(errorOptions.title || copy.title || 'Error').trim(),
active: '',
messageVariant: 'primary',
currentUser: currentUser || null,
statusCode: statusCode,
errorTitle: String(errorOptions.errorTitle || copy.errorTitle || errorOptions.title || 'Error').trim(),
errorMessage: String(errorOptions.message || copy.errorMessage || 'An unexpected error occurred.').trim(),
detail: String(errorOptions.detail || '').trim(),
backUrl: String(errorOptions.backUrl || '/dashboard').trim() || '/dashboard',
backLabel: String(errorOptions.backLabel || copy.backLabel || 'Back to dashboard').trim() || 'Back to dashboard',
searchUrl: String(errorOptions.searchUrl || '').trim(),
bodyClass: 'error-page bg-dark text-white',
errorShell: true,
stylesheets: [],
scripts: []
});
};
+41
View File
@@ -0,0 +1,41 @@
const path = require('path');
function routePath(...segments) {
return path.join(__dirname, '..', 'routes', ...segments);
}
module.exports = {
renderLoginPage: require(routePath('auth', 'login')),
renderAccountPage: require(routePath('account', 'password')),
renderUsersPage: require(routePath('settings', 'users', 'list')),
renderUsersAddPage: require(routePath('settings', 'users', 'add')),
renderUsersEditPage: require(routePath('settings', 'users', 'edit')),
renderDashboardPage: require(routePath('signage', 'dashboard', 'index')),
renderConnectedClientsPage: require(routePath('signage', 'clients', 'list')),
renderPlaylistsPage: require(routePath('signage', 'playlists', 'list')),
renderPlaylistFormPage: require(routePath('signage', 'playlists', 'add')),
renderPlaylistEditPage: require(routePath('signage', 'playlists', 'edit')),
renderPlaylistSlideConfigPage: require(routePath('signage', 'playlists', 'slide-config')),
renderApiSourcesPage: require(routePath('data-sources', 'api-sources', 'list')),
renderApiSourceFormPage: require(routePath('data-sources', 'api-sources', 'add')),
renderApiSourceEditPage: require(routePath('data-sources', 'api-sources', 'edit')),
renderRssFeedsPage: require(routePath('data-sources', 'rss-feeds', 'list')),
renderRssFeedFormPage: require(routePath('data-sources', 'rss-feeds', 'add')),
renderRssFeedEditPage: require(routePath('data-sources', 'rss-feeds', 'edit')),
renderScreensPage: require(routePath('signage', 'screens', 'list')),
renderScreenFormPage: require(routePath('signage', 'screens', 'add')),
renderScreenEditPage: require(routePath('signage', 'screens', 'edit')),
renderSlidesPage: require(routePath('signage', 'slides', 'list')),
renderSlideFormPage: require(routePath('signage', 'slides', 'form')),
renderTemplatesPage: require(routePath('signage', 'templates', 'list')),
renderTemplateFormPage: require(routePath('signage', 'templates', 'add')),
renderTemplateEditPage: require(routePath('signage', 'templates', 'edit')),
renderCanvasSizesPage: require(routePath('signage', 'canvas-sizes', 'list')),
renderCanvasSizeFormPage: require(routePath('signage', 'canvas-sizes', 'add')),
renderCanvasSizeEditPage: require(routePath('signage', 'canvas-sizes', 'edit')),
renderBackgroundTasksPage: require(routePath('settings', 'background-tasks-page')),
renderErrorPage: require('./error'),
renderRbacPage: require(routePath('settings', 'rbac', 'list')),
renderRbacAddPage: require(routePath('settings', 'rbac', 'add')),
renderRbacEditPage: require(routePath('settings', 'rbac', 'edit'))
};
+57 -5
View File
@@ -527,6 +527,14 @@ td[data-label="Slides"] {
color: var(--bs-secondary-color);
}
.slide-form-stack .ck.ck-toolbar .ck-font-size-input,
.template-field-card .ck.ck-toolbar .ck-font-size-input {
flex: 0 0 5% !important;
width: 5% !important;
max-width: 5% !important;
min-width: 0 !important;
}
.slide-form-stack .ck.ck-dropdown__panel,
.slide-form-stack .ck.ck-list__panel,
.slide-form-stack .ck.ck-balloon-panel,
@@ -597,10 +605,14 @@ td[data-label="Slides"] {
box-shadow: inset 0 0 0 1px var(--bs-primary), 0 0 0 0.2rem rgba(var(--bs-primary-rgb), 0.18);
}
.slide-form-stack .ck-editor__editable_inline,
.template-field-card .ck-editor__editable_inline {
min-height: 10rem;
}
.slide-form-stack .ck-content,
.template-field-card .ck-content {
font-family: inherit;
font-size: 1rem;
line-height: 1.55;
}
@@ -624,6 +636,50 @@ td[data-label="Slides"] {
margin-bottom: 0.75rem;
}
html[data-bs-theme='dark'] .ck-body-wrapper .ck.ck-dropdown__panel,
html[data-bs-theme='dark'] .ck-body-wrapper .ck.ck-list__panel,
html[data-bs-theme='dark'] .ck-body-wrapper .ck.ck-list,
html[data-bs-theme='dark'] .ck-body-wrapper .ck.ck-balloon-panel {
background: var(--bs-body-bg) !important;
background-color: var(--bs-body-bg) !important;
border-color: var(--bs-border-color) !important;
color: var(--bs-body-color) !important;
}
html[data-bs-theme='dark'] .ck-body-wrapper .ck.ck-list__item .ck-button,
html[data-bs-theme='dark'] .ck-body-wrapper .ck.ck-list .ck-button,
html[data-bs-theme='dark'] .ck-body-wrapper .ck.ck-list__item .ck-button__label,
html[data-bs-theme='dark'] .ck-body-wrapper .ck.ck-list .ck-button__label,
html[data-bs-theme='dark'] .ck-body-wrapper .ck.ck-dropdown__panel .ck-button__label,
html[data-bs-theme='dark'] .ck-body-wrapper .ck.ck-dropdown__panel .ck-list__item-text,
html[data-bs-theme='dark'] .ck-body-wrapper .ck.ck-dropdown__panel .ck-list__item-label,
html[data-bs-theme='dark'] .ck-body-wrapper .ck.ck-color-grid,
html[data-bs-theme='dark'] .ck-body-wrapper .ck.ck-color-grid__tile {
color: var(--bs-body-color) !important;
}
html[data-bs-theme='dark'] .ck-body-wrapper .ck.ck-list .ck-button {
background: transparent !important;
}
html[data-bs-theme='dark'] .ck-body-wrapper .ck.ck-list .ck-list-item-button {
background: transparent !important;
background-color: transparent !important;
}
html[data-bs-theme='dark'] .ck-body-wrapper .ck.ck-list .ck-list-item-button:hover {
background: var(--bs-secondary-bg) !important;
background-color: var(--bs-secondary-bg) !important;
}
html[data-bs-theme='dark'] .ck-body-wrapper .ck.ck-list .ck-button:hover {
background: var(--bs-secondary-bg) !important;
}
html[data-bs-theme='dark'] .ck-body-wrapper .ck.ck-color-grid__tile {
border-color: var(--bs-border-color) !important;
}
.slide-preview-dimensions-chip {
display: inline-flex;
@@ -1035,10 +1091,6 @@ td[data-label="Slides"] {
min-width: 0;
}
.template-field-card .ck-editor__editable_inline {
min-height: 18rem;
}
.template-field-card.is-expanded .ck-editor__editable_inline {
min-height: 32rem;
}
+248 -3
View File
@@ -196,6 +196,8 @@
}
function initAsyncSaveForms() {
var refreshSequence = 0;
function setSaveActionValue(form, value) {
if (!form) {
return;
@@ -212,6 +214,123 @@
hiddenInput.value = String(value || '').trim().toLowerCase();
}
function getResponseQueryValue(responseUrl, key) {
try {
var url = new URL(responseUrl, window.location.href);
return String(url.searchParams.get(key) || '').trim();
} catch (_error) {
return '';
}
}
function rebindRefreshTarget(targetElement) {
if (!targetElement) {
return;
}
if (typeof window.initJsonTogglePanels === 'function') {
window.initJsonTogglePanels(targetElement);
}
if (typeof window.initLocalDateTimes === 'function') {
window.initLocalDateTimes(targetElement);
}
}
function replaceRefreshTargetFromPage(refreshTargetSelector, sequenceId) {
if (sequenceId !== refreshSequence) {
return;
}
fetch(window.location.href, {
method: 'GET',
headers: {
'X-Requested-With': 'XMLHttpRequest',
'Accept': 'text/html, application/xhtml+xml'
},
credentials: 'same-origin',
cache: 'no-store'
}).then(function (response) {
if (!response.ok) {
throw new Error('Unable to refresh saved data.');
}
return response.text();
}).then(function (text) {
if (sequenceId !== refreshSequence) {
return;
}
var responseDocument = new DOMParser().parseFromString(text || '', 'text/html');
var currentTarget = document.querySelector(refreshTargetSelector);
var nextTarget = responseDocument.querySelector(refreshTargetSelector);
if (!currentTarget || !nextTarget) {
return;
}
currentTarget.outerHTML = nextTarget.outerHTML;
rebindRefreshTarget(document.querySelector(refreshTargetSelector));
}).catch(function (_error) {
// Ignore refresh replacement failures and leave the existing content in place.
});
}
function watchRefreshTask(refreshStateUrl, refreshTaskId, refreshTargetSelector, sequenceId) {
var pollDelayMs = 1000;
var maxAttempts = 60;
function poll(attempt) {
if (sequenceId !== refreshSequence) {
return;
}
var stateUrl;
try {
stateUrl = new URL(refreshStateUrl, window.location.href);
} catch (_error) {
return;
}
stateUrl.searchParams.set('refresh_task_id', refreshTaskId);
fetch(stateUrl.toString(), {
method: 'GET',
headers: {
'X-Requested-With': 'XMLHttpRequest',
'Accept': 'application/json, text/plain, */*'
},
credentials: 'same-origin',
cache: 'no-store'
}).then(function (response) {
if (!response.ok) {
throw new Error('Unable to check refresh status.');
}
return response.json();
}).then(function (payload) {
if (sequenceId !== refreshSequence) {
return;
}
var status = String(payload && payload.status || '').trim().toLowerCase();
if (status === 'queued' || status === 'running') {
if (attempt < maxAttempts) {
window.setTimeout(function () {
poll(attempt + 1);
}, pollDelayMs);
}
return;
}
replaceRefreshTargetFromPage(refreshTargetSelector, sequenceId);
}).catch(function () {
if (attempt < maxAttempts) {
window.setTimeout(function () {
poll(attempt + 1);
}, pollDelayMs);
}
});
}
poll(0);
}
document.addEventListener('click', function (event) {
var target = event.target;
if (!target || !target.closest) {
@@ -288,9 +407,21 @@
}).then(function (response) {
if (!response.ok) {
return response.text().then(function (text) {
throw new Error(text || 'Unable to save changes.');
var error = new Error(text || 'Unable to save changes.');
error.status = response.status;
throw error;
});
}
var actionUrl = '';
try {
actionUrl = new URL(form.action, window.location.href).pathname;
} catch (_error) {
actionUrl = String(form.action || '');
}
var refreshTargetSelector = String(form.getAttribute('data-async-save-refresh-target') || '').trim();
var refreshStateUrl = String(form.getAttribute('data-async-save-refresh-state-url') || '').trim();
var refreshTaskId = getResponseQueryValue(response.url || '', 'refresh_task_id');
var shouldFollowRedirect = Boolean(form.hasAttribute('data-async-save-new-url')) && !/\/\d+(?:\/|$)/.test(actionUrl);
if (submitterValue === 'close' || submitterValue === 'new') {
var redirectUrl = submitterValue === 'close'
? String(form.dataset.asyncSaveCloseUrl || response.url || window.location.href)
@@ -298,21 +429,45 @@
window.location.replace(redirectUrl);
return;
}
if (shouldFollowRedirect && response.url) {
clearFormDirty(form);
window.location.replace(response.url);
return;
}
clearFormDirty(form);
return response.text().then(function (text) {
var savedMessage = '';
var responseDocument = null;
try {
var doc = new DOMParser().parseFromString(text || '', 'text/html');
var toastBody = doc.querySelector('.toast-body');
responseDocument = new DOMParser().parseFromString(text || '', 'text/html');
var toastBody = responseDocument.querySelector('.toast-body');
if (toastBody && toastBody.textContent) {
savedMessage = toastBody.textContent.trim();
}
} catch (_error) {
savedMessage = '';
}
if (refreshTargetSelector && refreshStateUrl && refreshTaskId) {
refreshSequence += 1;
watchRefreshTask(refreshStateUrl, refreshTaskId, refreshTargetSelector, refreshSequence);
} else if (refreshTargetSelector && responseDocument) {
var currentTarget = document.querySelector(refreshTargetSelector);
var nextTarget = responseDocument.querySelector(refreshTargetSelector);
if (currentTarget && nextTarget) {
currentTarget.outerHTML = nextTarget.outerHTML;
rebindRefreshTarget(document.querySelector(refreshTargetSelector));
}
}
showToast(savedMessage || 'Saved.', 'success');
});
}).catch(function (error) {
if (typeof showToast === 'function') {
var variant = Number(error && error.status) >= 400 && Number(error && error.status) < 500 ? 'warning' : 'danger';
showToast(error.message || 'Unable to save changes.', variant);
return;
}
window.alert(error.message || 'Unable to save changes.');
}).finally(function () {
delete form.dataset.busy;
@@ -336,6 +491,92 @@
});
}
function initJsonTogglePanels(root) {
var scope = root && root.querySelectorAll ? root : document;
var panels = scope.querySelectorAll('[data-json-toggle-panel]');
Array.prototype.forEach.call(panels, function (panel) {
var output = panel.querySelector('[data-json-toggle-output]');
if (!output) {
return;
}
var card = panel.closest ? panel.closest('.card') : null;
var button = card ? card.querySelector('[data-json-toggle]') : null;
var label = button ? button.querySelector('[data-json-toggle-label]') : null;
var sourceNode = panel.querySelector('[data-json-toggle-source]');
var rawJson = '';
try {
rawJson = JSON.parse(String(sourceNode ? sourceNode.textContent : '""'));
} catch (_error) {
rawJson = '';
}
if (typeof rawJson !== 'string' || !rawJson.trim()) {
if (button) {
button.classList.add('d-none');
}
return;
}
var parsedJson;
try {
parsedJson = JSON.parse(rawJson);
} catch (_error) {
if (button) {
button.classList.add('d-none');
}
return;
}
var compactJson = JSON.stringify(parsedJson);
var formattedJson = JSON.stringify(parsedJson, null, 2);
var isFormatted = true;
function syncButtonLabel() {
if (!button || !label) {
return;
}
label.textContent = isFormatted
? String(button.getAttribute('data-json-toggle-label-compact') || 'Unformat JSON')
: String(button.getAttribute('data-json-toggle-label-formatted') || 'Format JSON');
button.setAttribute('aria-pressed', isFormatted ? 'true' : 'false');
}
function syncOutput() {
output.textContent = isFormatted ? formattedJson : compactJson;
syncButtonLabel();
}
output.textContent = formattedJson;
syncButtonLabel();
if (button) {
button.addEventListener('click', function () {
isFormatted = !isFormatted;
syncOutput();
});
}
});
}
function initLocalDateTimes(root) {
var scope = root && root.querySelectorAll ? root : document;
var elements = scope.querySelectorAll('[data-local-datetime]');
Array.prototype.forEach.call(elements, function (element) {
var rawValue = String(element.getAttribute('datetime') || element.getAttribute('data-local-datetime') || element.textContent || '').trim();
if (!rawValue) {
return;
}
var date = new Date(rawValue);
if (Number.isNaN(date.getTime())) {
return;
}
element.textContent = formatDashboardDate(date);
});
}
if (window.initSortableTables) {
window.initSortableTables();
}
@@ -346,4 +587,8 @@
initAsyncCommandForms();
initAsyncSaveForms();
initSubmitOnChange();
initJsonTogglePanels();
initLocalDateTimes();
window.initJsonTogglePanels = initJsonTogglePanels;
window.initLocalDateTimes = initLocalDateTimes;
}());
+594
View File
@@ -0,0 +1,594 @@
(function () {
function escapeHtml(value) {
return String(value ?? '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function formatDashboardDate(value) {
if (!value) {
return '';
}
var date = new Date(value);
if (Number.isNaN(date.getTime())) {
return '';
}
return new Intl.DateTimeFormat('en-US', {
month: 'short',
day: '2-digit',
year: 'numeric',
hour: 'numeric',
minute: '2-digit',
second: '2-digit'
}).format(date);
}
function getClientRowKey(client) {
return String(client && client.id ? client.id : client && client.clientId ? client.clientId : '').trim();
}
function getClientDisplayName(client) {
if (client && client.client_name) {
return String(client.client_name).trim();
}
var clientId = String(client && client.clientId ? client.clientId : '').trim();
if (clientId) {
return clientId;
}
return '';
}
function setButtonVariant(button, classesToRemove, classToAdd) {
if (!button) {
return;
}
if (button.classList) {
classesToRemove.forEach(function (className) {
button.classList.remove(className);
});
if (classToAdd) {
button.classList.add(classToAdd);
}
return;
}
var className = String(button.className || '');
classesToRemove.forEach(function (removeClass) {
className = className.replace(new RegExp('(^|\\s)' + removeClass + '(?=\\s|$)', 'g'), ' ');
});
if (classToAdd) {
className += ' ' + classToAdd;
}
button.className = className.replace(/\s+/g, ' ').trim();
}
function normalizeDisplayIp(value) {
var ip = String(value || '').trim();
if (!ip) {
return '';
}
if (ip.toLowerCase().indexOf('::ffff:') === 0) {
return ip.slice(7).trim();
}
return ip;
}
function initConfirmForms() {
document.addEventListener('submit', function (event) {
var form = event.target;
if (!form || !form.getAttribute) {
return;
}
if (form.hasAttribute && form.hasAttribute('data-async-command')) {
return;
}
var message = form.getAttribute('data-confirm-message');
if (message && !window.confirm(message)) {
event.preventDefault();
}
});
}
function markFormDirty(form) {
if (!form || !form.hasAttribute || form.hasAttribute('data-clean-on-load')) {
return;
}
form.dataset.dirty = 'true';
}
function clearFormDirty(form) {
if (!form) {
return;
}
form.dataset.dirty = 'false';
}
function isFormDirty(form) {
return Boolean(form && form.dataset && form.dataset.dirty === 'true');
}
function initDirtyTracking() {
document.addEventListener('input', function (event) {
var target = event.target;
if (!target || !target.form) {
return;
}
markFormDirty(target.form);
}, true);
document.addEventListener('change', function (event) {
var target = event.target;
if (!target || !target.form) {
return;
}
markFormDirty(target.form);
}, true);
}
function initCancelConfirm() {
document.addEventListener('click', function (event) {
var cancelTarget = event.target.closest('[data-confirm-unsaved]');
if (!cancelTarget) {
return;
}
var form = cancelTarget.form || cancelTarget.closest('form') || document.querySelector('form[data-dirty="true"]');
if (!isFormDirty(form)) {
return;
}
var message = cancelTarget.getAttribute('data-confirm-unsaved') || 'You have unsaved changes. Leave this page?';
if (!window.confirm(message)) {
event.preventDefault();
event.stopPropagation();
}
}, true);
}
function initAsyncCommandForms() {
document.addEventListener('submit', function (event) {
var form = event.target;
if (!form || !form.hasAttribute || !form.hasAttribute('data-async-command')) {
return;
}
if (form.dataset && form.dataset.busy === 'true') {
event.preventDefault();
return;
}
var message = form.getAttribute('data-confirm-message');
if (message && !window.confirm(message)) {
event.preventDefault();
return;
}
event.preventDefault();
form.dataset.busy = 'true';
var formData = new FormData(form);
var body = new URLSearchParams();
formData.forEach(function (value, key) {
body.append(key, value);
});
fetch(form.action, {
method: (form.method || 'POST').toUpperCase(),
headers: {
'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8',
'X-Requested-With': 'XMLHttpRequest',
'Accept': 'application/json, text/plain, */*'
},
body: body.toString(),
credentials: 'same-origin'
}).finally(function () {
delete form.dataset.busy;
});
}, true);
}
function initAsyncSaveForms() {
var refreshSequence = 0;
function setSaveActionValue(form, value) {
if (!form) {
return;
}
var hiddenInput = form.querySelector('input[type="hidden"][name="save_action"]');
if (!hiddenInput) {
hiddenInput = document.createElement('input');
hiddenInput.type = 'hidden';
hiddenInput.name = 'save_action';
form.appendChild(hiddenInput);
}
hiddenInput.value = String(value || '').trim().toLowerCase();
}
function getResponseQueryValue(responseUrl, key) {
try {
var url = new URL(responseUrl, window.location.href);
return String(url.searchParams.get(key) || '').trim();
} catch (_error) {
return '';
}
}
function rebindRefreshTarget(targetElement) {
if (!targetElement) {
return;
}
if (typeof window.initJsonTogglePanels === 'function') {
window.initJsonTogglePanels(targetElement);
}
if (typeof window.initLocalDateTimes === 'function') {
window.initLocalDateTimes(targetElement);
}
}
function replaceRefreshTargetFromPage(refreshTargetSelector, sequenceId) {
if (sequenceId !== refreshSequence) {
return;
}
fetch(window.location.href, {
method: 'GET',
headers: {
'X-Requested-With': 'XMLHttpRequest',
'Accept': 'text/html, application/xhtml+xml'
},
credentials: 'same-origin',
cache: 'no-store'
}).then(function (response) {
if (!response.ok) {
throw new Error('Unable to refresh saved data.');
}
return response.text();
}).then(function (text) {
if (sequenceId !== refreshSequence) {
return;
}
var responseDocument = new DOMParser().parseFromString(text || '', 'text/html');
var currentTarget = document.querySelector(refreshTargetSelector);
var nextTarget = responseDocument.querySelector(refreshTargetSelector);
if (!currentTarget || !nextTarget) {
return;
}
currentTarget.outerHTML = nextTarget.outerHTML;
rebindRefreshTarget(document.querySelector(refreshTargetSelector));
}).catch(function (_error) {
// Ignore refresh replacement failures and leave the existing content in place.
});
}
function watchRefreshTask(refreshStateUrl, refreshTaskId, refreshTargetSelector, sequenceId) {
var pollDelayMs = 1000;
var maxAttempts = 60;
function poll(attempt) {
if (sequenceId !== refreshSequence) {
return;
}
var stateUrl;
try {
stateUrl = new URL(refreshStateUrl, window.location.href);
} catch (_error) {
return;
}
stateUrl.searchParams.set('refresh_task_id', refreshTaskId);
fetch(stateUrl.toString(), {
method: 'GET',
headers: {
'X-Requested-With': 'XMLHttpRequest',
'Accept': 'application/json, text/plain, */*'
},
credentials: 'same-origin',
cache: 'no-store'
}).then(function (response) {
if (!response.ok) {
throw new Error('Unable to check refresh status.');
}
return response.json();
}).then(function (payload) {
if (sequenceId !== refreshSequence) {
return;
}
var status = String(payload && payload.status || '').trim().toLowerCase();
if (status === 'queued' || status === 'running') {
if (attempt < maxAttempts) {
window.setTimeout(function () {
poll(attempt + 1);
}, pollDelayMs);
}
return;
}
replaceRefreshTargetFromPage(refreshTargetSelector, sequenceId);
}).catch(function () {
if (attempt < maxAttempts) {
window.setTimeout(function () {
poll(attempt + 1);
}, pollDelayMs);
}
});
}
poll(0);
}
document.addEventListener('click', function (event) {
var target = event.target;
if (!target || !target.closest) {
return;
}
var button = target.closest('button[name="save_action"]');
if (!button) {
return;
}
var form = button.form || button.closest('form');
if (!form || !form.hasAttribute || !form.hasAttribute('data-async-save')) {
return;
}
setSaveActionValue(form, button.value || '');
form.dataset.submitterValue = String(button.value || '').trim().toLowerCase();
}, true);
document.addEventListener('submit', function (event) {
var form = event.target;
if (!form || !form.hasAttribute || !form.hasAttribute('data-async-save')) {
return;
}
if (form.dataset && form.dataset.busy === 'true') {
event.preventDefault();
return;
}
var message = form.getAttribute('data-confirm-message');
if (message && !window.confirm(message)) {
event.preventDefault();
return;
}
event.preventDefault();
form.dataset.busy = 'true';
var hiddenSaveAction = form.querySelector('input[type="hidden"][name="save_action"]');
var formData = new FormData(form);
var submitterValue = String((hiddenSaveAction && hiddenSaveAction.value) || form.dataset.submitterValue || '').trim().toLowerCase();
if (event.submitter && event.submitter.name) {
submitterValue = String(event.submitter.value || '').trim().toLowerCase();
formData.set(event.submitter.name, event.submitter.value || '');
}
var hasFileValue = false;
formData.forEach(function (value) {
if (value && typeof value === 'object' && typeof value.name === 'string') {
hasFileValue = true;
}
});
var isMultipart = hasFileValue || String(form.enctype || '').toLowerCase() === 'multipart/form-data';
var body = isMultipart ? formData : new URLSearchParams();
if (!isMultipart) {
formData.forEach(function (value, key) {
body.append(key, value);
});
}
fetch(form.action, {
method: (form.method || 'POST').toUpperCase(),
headers: Object.assign({
'X-Requested-With': 'XMLHttpRequest',
'Accept': 'text/html, application/json, text/plain, */*'
}, isMultipart ? {} : {
'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8'
}),
body: isMultipart ? body : body.toString(),
credentials: 'same-origin'
}).then(function (response) {
if (!response.ok) {
return response.text().then(function (text) {
var error = new Error(text || 'Unable to save changes.');
error.status = response.status;
throw error;
});
}
var actionUrl = '';
try {
actionUrl = new URL(form.action, window.location.href).pathname;
} catch (_error) {
actionUrl = String(form.action || '');
}
var refreshTargetSelector = String(form.getAttribute('data-async-save-refresh-target') || '').trim();
var refreshStateUrl = String(form.getAttribute('data-async-save-refresh-state-url') || '').trim();
var refreshTaskId = getResponseQueryValue(response.url || '', 'refresh_task_id');
var shouldFollowRedirect = Boolean(form.hasAttribute('data-async-save-new-url')) && !/\/\d+(?:\/|$)/.test(actionUrl);
if (submitterValue === 'close' || submitterValue === 'new') {
var redirectUrl = submitterValue === 'close'
? String(form.dataset.asyncSaveCloseUrl || response.url || window.location.href)
: String(form.dataset.asyncSaveNewUrl || response.url || window.location.href);
window.location.replace(redirectUrl);
return;
}
if (shouldFollowRedirect && response.url) {
clearFormDirty(form);
window.location.replace(response.url);
return;
}
clearFormDirty(form);
return response.text().then(function (text) {
var savedMessage = '';
var responseDocument = null;
try {
responseDocument = new DOMParser().parseFromString(text || '', 'text/html');
var toastBody = responseDocument.querySelector('.toast-body');
if (toastBody && toastBody.textContent) {
savedMessage = toastBody.textContent.trim();
}
} catch (_error) {
savedMessage = '';
}
if (refreshTargetSelector && refreshStateUrl && refreshTaskId) {
refreshSequence += 1;
watchRefreshTask(refreshStateUrl, refreshTaskId, refreshTargetSelector, refreshSequence);
} else if (refreshTargetSelector && responseDocument) {
var currentTarget = document.querySelector(refreshTargetSelector);
var nextTarget = responseDocument.querySelector(refreshTargetSelector);
if (currentTarget && nextTarget) {
currentTarget.outerHTML = nextTarget.outerHTML;
rebindRefreshTarget(document.querySelector(refreshTargetSelector));
}
}
showToast(savedMessage || 'Saved.', 'success');
});
}).catch(function (error) {
if (typeof showToast === 'function') {
var variant = Number(error && error.status) >= 400 && Number(error && error.status) < 500 ? 'warning' : 'danger';
showToast(error.message || 'Unable to save changes.', variant);
return;
}
window.alert(error.message || 'Unable to save changes.');
}).finally(function () {
delete form.dataset.busy;
delete form.dataset.submitterValue;
if (hiddenSaveAction) {
hiddenSaveAction.value = '';
}
});
}, true);
}
function initSubmitOnChange() {
var fields = document.querySelectorAll('[data-submit-on-change]');
Array.prototype.forEach.call(fields, function (field) {
field.addEventListener('change', function () {
var form = field.form || field.closest('form');
if (form) {
form.submit();
}
});
});
}
function initJsonTogglePanels(root) {
var scope = root && root.querySelectorAll ? root : document;
var panels = scope.querySelectorAll('[data-json-toggle-panel]');
Array.prototype.forEach.call(panels, function (panel) {
var output = panel.querySelector('[data-json-toggle-output]');
if (!output) {
return;
}
var card = panel.closest ? panel.closest('.card') : null;
var button = card ? card.querySelector('[data-json-toggle]') : null;
var label = button ? button.querySelector('[data-json-toggle-label]') : null;
var sourceNode = panel.querySelector('[data-json-toggle-source]');
var rawJson = '';
try {
rawJson = JSON.parse(String(sourceNode ? sourceNode.textContent : '""'));
} catch (_error) {
rawJson = '';
}
if (typeof rawJson !== 'string' || !rawJson.trim()) {
if (button) {
button.classList.add('d-none');
}
return;
}
var parsedJson;
try {
parsedJson = JSON.parse(rawJson);
} catch (_error) {
if (button) {
button.classList.add('d-none');
}
return;
}
var compactJson = JSON.stringify(parsedJson);
var formattedJson = JSON.stringify(parsedJson, null, 2);
var isFormatted = true;
function syncButtonLabel() {
if (!button || !label) {
return;
}
label.textContent = isFormatted
? String(button.getAttribute('data-json-toggle-label-compact') || 'Unformat JSON')
: String(button.getAttribute('data-json-toggle-label-formatted') || 'Format JSON');
button.setAttribute('aria-pressed', isFormatted ? 'true' : 'false');
}
function syncOutput() {
output.textContent = isFormatted ? formattedJson : compactJson;
syncButtonLabel();
}
output.textContent = formattedJson;
syncButtonLabel();
if (button) {
button.addEventListener('click', function () {
isFormatted = !isFormatted;
syncOutput();
});
}
});
}
function initLocalDateTimes(root) {
var scope = root && root.querySelectorAll ? root : document;
var elements = scope.querySelectorAll('[data-local-datetime]');
Array.prototype.forEach.call(elements, function (element) {
var rawValue = String(element.getAttribute('datetime') || element.getAttribute('data-local-datetime') || element.textContent || '').trim();
if (!rawValue) {
return;
}
var date = new Date(rawValue);
if (Number.isNaN(date.getTime())) {
return;
}
element.textContent = formatDashboardDate(date);
});
}
if (window.initSortableTables) {
window.initSortableTables();
}
initConfirmForms();
initDirtyTracking();
initCancelConfirm();
initAsyncCommandForms();
initAsyncSaveForms();
initSubmitOnChange();
initJsonTogglePanels();
initLocalDateTimes();
window.initJsonTogglePanels = initJsonTogglePanels;
window.initLocalDateTimes = initLocalDateTimes;
}());
+111
View File
@@ -0,0 +1,111 @@
(function () {
function getGroupCheckboxes(group) {
return Array.prototype.slice.call(group.querySelectorAll('input[name="permission_keys[]"]'));
}
function getPermissionKey(checkbox) {
return String((checkbox && (checkbox.getAttribute('data-permission-key') || checkbox.value)) || '').trim().toLowerCase();
}
function getActionKey(checkbox) {
var permissionKey = getPermissionKey(checkbox);
var parts = permissionKey.split('.');
if (parts.length !== 2) {
return '';
}
return String(parts[1] || '').trim().toLowerCase();
}
function syncPermissionGroup(group) {
var checkboxes = getGroupCheckboxes(group);
if (!checkboxes.length) {
return;
}
var readCheckbox = null;
var nonReadChecked = false;
checkboxes.forEach(function (checkbox) {
if (getActionKey(checkbox) === 'read') {
readCheckbox = checkbox;
return;
}
if (checkbox.checked) {
nonReadChecked = true;
}
});
if (!readCheckbox) {
return;
}
if (!readCheckbox.checked && nonReadChecked) {
readCheckbox.checked = true;
}
if (!readCheckbox.checked) {
checkboxes.forEach(function (checkbox) {
if (getActionKey(checkbox) !== 'read') {
checkbox.checked = false;
}
});
}
}
function handleGroupChange(event) {
var checkbox = event.target && event.target.matches ? event.target : null;
if (!checkbox || checkbox.tagName !== 'INPUT' || checkbox.type !== 'checkbox') {
return;
}
var actionKey = getActionKey(checkbox);
var group = checkbox.closest('.accordion-item');
if (!group) {
return;
}
var checkboxes = getGroupCheckboxes(group);
var readCheckbox = checkboxes.find(function (candidate) {
return getActionKey(candidate) === 'read';
}) || null;
if (!readCheckbox) {
return;
}
if (actionKey === 'read' && !checkbox.checked) {
checkboxes.forEach(function (candidate) {
if (candidate !== checkbox) {
candidate.checked = false;
}
});
return;
}
if (actionKey !== 'read' && checkbox.checked) {
readCheckbox.checked = true;
}
}
function initPermissionGroups() {
document.querySelectorAll('.accordion-item').forEach(function (group) {
syncPermissionGroup(group);
});
document.addEventListener('change', function (event) {
var checkbox = event.target;
if (!checkbox || checkbox.tagName !== 'INPUT' || checkbox.type !== 'checkbox') {
return;
}
if (String(checkbox.getAttribute('name') || '') !== 'permission_keys[]') {
return;
}
handleGroupChange(event);
syncPermissionGroup(checkbox.closest('.accordion-item'));
});
}
initPermissionGroups();
}());
+102
View File
@@ -0,0 +1,102 @@
(function () {
function updateSidebarStatus(status, label) {
var dot = document.getElementById('sidebar-status-dot');
var text = document.getElementById('sidebar-status-text');
var pill = document.getElementById('sidebar-status-pill');
if (!dot && !text && !pill) {
return;
}
var normalizedStatus = status === 'online' || status === 'offline' || status === 'unknown'
? status
: (status ? 'online' : 'offline');
var statusLabel = normalizedStatus === 'online'
? (label || 'Connected to player feed')
: normalizedStatus === 'offline'
? 'Disconnected from player feed'
: (label || 'Connecting to player feed');
var pillLabel = normalizedStatus === 'online' ? 'Connected' : normalizedStatus === 'offline' ? 'Disconnected' : 'Connecting';
dot.classList.remove('status-dot--unknown', 'status-dot--online', 'status-dot--offline');
dot.classList.add(normalizedStatus === 'online' ? 'status-dot--online' : normalizedStatus === 'offline' ? 'status-dot--offline' : 'status-dot--unknown');
dot.setAttribute('aria-label', statusLabel);
dot.setAttribute('title', statusLabel);
if (text) {
text.textContent = statusLabel;
}
if (pill) {
pill.classList.remove('status-pill--unknown', 'status-pill--online', 'status-pill--offline');
pill.classList.add(normalizedStatus === 'online' ? 'status-pill--online' : normalizedStatus === 'offline' ? 'status-pill--offline' : 'status-pill--unknown');
pill.textContent = pillLabel;
}
}
function connectDashboardSocket() {
if (!window.WebSocket) {
return;
}
var hasStatusIndicators = document.getElementById('sidebar-status-dot') || document.getElementById('sidebar-status-text') || document.getElementById('sidebar-status-pill');
if (!hasStatusIndicators) {
return;
}
var socket = null;
var reconnectTimer = null;
function scheduleReconnect() {
if (reconnectTimer) {
return;
}
reconnectTimer = window.setTimeout(function () {
reconnectTimer = null;
connect();
}, 5000);
}
function connect() {
var protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
socket = new WebSocket(protocol + '//' + window.location.host + '/ws/dashboard');
updateSidebarStatus('unknown', 'Connecting to player feed');
socket.onmessage = function (event) {
try {
var payload = JSON.parse(String(event.data || '{}'));
if (payload && payload.type === 'dashboard-state') {
if (typeof window.webHandleDashboardState === 'function') {
window.webHandleDashboardState(payload.state);
}
updateSidebarStatus(Boolean(payload.state && payload.state.playerServiceConnected), 'Live player feed');
}
} catch (_error) {
// Ignore malformed dashboard payloads.
}
};
socket.onclose = function () {
updateSidebarStatus('offline');
scheduleReconnect();
};
socket.onerror = function () {
updateSidebarStatus('offline');
try {
socket.close();
} catch (_error) {
// ignore close errors
}
};
socket.onopen = function () {
updateSidebarStatus('unknown', 'Connecting to player feed');
};
}
connect();
}
connectDashboardSocket();
}());
+171
View File
@@ -0,0 +1,171 @@
(function () {
var THEME_STORAGE_KEY = 'web-theme';
var CKEDITOR_THEME_STYLE_ID = 'ckeditor-dark-theme-overrides';
function getPreferredTheme() {
if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
return 'dark';
}
return 'light';
}
function getStoredTheme() {
try {
var storedTheme = window.localStorage.getItem(THEME_STORAGE_KEY);
if (storedTheme === 'dark' || storedTheme === 'light') {
return storedTheme;
}
} catch (error) {
return null;
}
return null;
}
function setStoredTheme(theme) {
try {
window.localStorage.setItem(THEME_STORAGE_KEY, theme);
} catch (error) {
return;
}
}
function getOrCreateCkeditorThemeStyleElement() {
var styleElement = document.getElementById(CKEDITOR_THEME_STYLE_ID);
if (styleElement) {
return styleElement;
}
styleElement = document.createElement('style');
styleElement.id = CKEDITOR_THEME_STYLE_ID;
document.head.appendChild(styleElement);
return styleElement;
}
function syncCkeditorTheme(theme) {
var styleElement = getOrCreateCkeditorThemeStyleElement();
if (theme !== 'dark') {
styleElement.textContent = '';
return;
}
styleElement.textContent = [
'.ck-body-wrapper .ck.ck-dropdown__panel,',
'.ck-body-wrapper .ck.ck-list__panel,',
'.ck-body-wrapper .ck.ck-list,',
'.ck-body-wrapper .ck.ck-balloon-panel {',
' background: var(--bs-body-bg) !important;',
' background-color: var(--bs-body-bg) !important;',
' border-color: var(--bs-border-color) !important;',
' color: var(--bs-body-color) !important;',
'}',
'.ck-body-wrapper .ck.ck-list .ck-list-item-button {',
' background: transparent !important;',
' background-color: transparent !important;',
' color: var(--bs-body-color) !important;',
'}',
'.ck-body-wrapper .ck.ck-list .ck-list-item-button:hover {',
' background: var(--bs-secondary-bg) !important;',
' background-color: var(--bs-secondary-bg) !important;',
'}',
'.ck-body-wrapper .ck.ck-color-grid,',
'.ck-body-wrapper .ck.ck-color-grid__tile {',
' color: var(--bs-body-color) !important;',
'}',
'.ck-body-wrapper .ck.ck-color-grid__tile {',
' border-color: var(--bs-border-color) !important;',
'}'
].join('\n');
}
function applyTheme(theme) {
var normalizedTheme = theme === 'dark' ? 'dark' : 'light';
var nextTheme = normalizedTheme === 'dark' ? 'light' : 'dark';
var nextThemeLabel = nextTheme === 'dark' ? 'Dark mode' : 'Light mode';
document.documentElement.dataset.bsTheme = normalizedTheme;
document.documentElement.style.colorScheme = normalizedTheme;
syncCkeditorTheme(normalizedTheme);
Array.prototype.forEach.call(document.querySelectorAll('[data-theme-toggle]'), function (toggleButton) {
var icon = toggleButton.querySelector('.theme-toggle__icon');
toggleButton.setAttribute('aria-pressed', normalizedTheme === 'dark' ? 'true' : 'false');
toggleButton.setAttribute('aria-label', 'Switch to ' + nextThemeLabel.toLowerCase());
if (icon) {
icon.classList.remove('theme-toggle__icon--moon', 'theme-toggle__icon--sun');
icon.classList.add(normalizedTheme === 'dark' ? 'theme-toggle__icon--sun' : 'theme-toggle__icon--moon');
}
});
return normalizedTheme;
}
function initThemeToggle() {
var toggleButtons = Array.prototype.slice.call(document.querySelectorAll('[data-theme-toggle]'));
var storedTheme = getStoredTheme();
var theme = storedTheme || getPreferredTheme();
applyTheme(theme);
if (!toggleButtons.length) {
return;
}
Array.prototype.forEach.call(toggleButtons, function (toggleButton) {
toggleButton.addEventListener('click', function () {
var nextTheme = document.documentElement.dataset.bsTheme === 'dark' ? 'light' : 'dark';
setStoredTheme(nextTheme);
applyTheme(nextTheme);
});
});
}
function initSidebarToggle() {
var body = document.body;
var toggleButtons = Array.prototype.slice.call(document.querySelectorAll('[data-sidebar-toggle]'));
var backdrop = document.querySelector('[data-sidebar-backdrop]');
if (!toggleButtons.length || !backdrop) {
return;
}
function setSidebarOpen(isOpen) {
body.classList.toggle('is-sidebar-open', isOpen);
Array.prototype.forEach.call(toggleButtons, function (toggleButton) {
toggleButton.setAttribute('aria-expanded', isOpen ? 'true' : 'false');
});
}
function toggleSidebar() {
setSidebarOpen(!body.classList.contains('is-sidebar-open'));
}
Array.prototype.forEach.call(toggleButtons, function (toggleButton) {
toggleButton.addEventListener('click', function () {
toggleSidebar();
});
});
backdrop.addEventListener('click', function () {
setSidebarOpen(false);
});
document.addEventListener('keydown', function (event) {
if (event.key === 'Escape') {
setSidebarOpen(false);
}
});
}
window.getPreferredTheme = getPreferredTheme;
window.getStoredTheme = getStoredTheme;
window.setStoredTheme = setStoredTheme;
window.applyTheme = applyTheme;
window.initThemeToggle = initThemeToggle;
window.initSidebarToggle = initSidebarToggle;
initThemeToggle();
initSidebarToggle();
}());
+134
View File
@@ -0,0 +1,134 @@
(function () {
function getBootstrapToast(toast) {
if (!toast || !window.bootstrap || !window.bootstrap.Toast) {
return null;
}
return window.bootstrap.Toast.getOrCreateInstance(toast, {
autohide: true,
delay: 4000
});
}
function removeToast(toast) {
if (toast && toast.parentNode) {
toast.parentNode.removeChild(toast);
}
}
function dismissToast(toast) {
var instance = getBootstrapToast(toast);
if (instance) {
instance.hide();
return;
}
removeToast(toast);
}
function setToastVariant(toast, variant) {
if (!toast || !toast.classList) {
return;
}
var nextVariant = String(variant || 'info').trim().toLowerCase();
var variants = ['primary', 'secondary', 'success', 'danger', 'warning', 'info', 'light', 'dark'];
variants.forEach(function (value) {
toast.classList.remove('text-bg-' + value);
});
toast.classList.add('text-bg-' + (variants.indexOf(nextVariant) === -1 ? 'info' : nextVariant));
}
function getMessageVariant(message, fallbackVariant) {
var text = String(message || '').trim();
if (/\b(?:unable to|cannot|can't|could not|failed to)\s+delete\b/i.test(text) || /\bdelete\b.*\b(?:before|first)\b/i.test(text) || /\bstill (?:in use|linked|assigned|used)\b/i.test(text)) {
return 'danger';
}
if (/\b(?:already exists|already exist|already taken|duplicate|must be unique|name already exists)\b/i.test(text)) {
return 'warning';
}
return String(fallbackVariant || 'info').trim().toLowerCase() || 'info';
}
function showToast(message, variant) {
var text = String(message || '').trim();
if (!text) {
return;
}
var container = document.getElementById('app-toast-container');
if (!container) {
return;
}
var existingToast = document.getElementById('app-toast');
if (existingToast) {
var existingBody = existingToast.querySelector('.toast-body');
if (existingBody) {
existingBody.textContent = text;
}
var nextVariant = getMessageVariant(text, variant);
existingToast.setAttribute('data-toast-variant', nextVariant);
setToastVariant(existingToast, nextVariant);
var existingInstance = getBootstrapToast(existingToast);
if (existingInstance) {
existingInstance.show();
}
return;
}
var toast = document.createElement('div');
toast.className = 'toast align-items-center border-0';
toast.id = 'app-toast';
toast.setAttribute('role', 'status');
toast.setAttribute('aria-live', 'polite');
toast.setAttribute('aria-atomic', 'true');
toast.setAttribute('data-bs-autohide', 'true');
toast.setAttribute('data-bs-delay', '4000');
toast.innerHTML = '<div class="d-flex"><div class="toast-body"></div><button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast" aria-label="Dismiss notification"></button></div>';
var toastVariant = getMessageVariant(text, variant);
toast.setAttribute('data-toast-variant', toastVariant);
setToastVariant(toast, toastVariant);
toast.querySelector('.toast-body').textContent = text;
toast.addEventListener('hidden.bs.toast', function () {
removeToast(toast);
});
container.appendChild(toast);
var instance = getBootstrapToast(toast);
if (instance) {
instance.show();
}
}
function initToast() {
var toast = document.getElementById('app-toast');
if (!toast) {
return;
}
try {
var url = new URL(window.location.href);
if (url.searchParams.has('message')) {
url.searchParams.delete('message');
window.history.replaceState({}, document.title, url.pathname + url.search + url.hash);
}
} catch (_error) {
// ignore URL cleanup failures
}
var existingVariant = String(toast.getAttribute('data-toast-variant') || '').trim().toLowerCase() || getMessageVariant((toast.querySelector('.toast-body') && toast.querySelector('.toast-body').textContent) || '', 'info');
setToastVariant(toast, existingVariant);
var instance = getBootstrapToast(toast);
if (instance) {
instance.show();
}
}
window.dismissToast = dismissToast;
window.showToast = showToast;
window.initToast = initToast;
initToast();
}());
@@ -91,7 +91,7 @@
var reloadConfirmMessage = 'Reloading will restart the player page. Continue?';
var blackoutCommandValue = blackout ? 'false' : 'true';
return '<div class="actions justify-content-end"><form method="post" action="/admin/clients/' + encodeURIComponent(client.screen_slug) + '/commands" class="d-inline-block m-0" data-confirm-message="' + escapeHtml(reloadConfirmMessage) + '" data-async-command><input type="hidden" name="command" value="reload" /><input type="hidden" name="connectionId" value="' + escapeHtml(client.id) + '" /><button type="submit" class="btn btn-sm btn-danger" data-action="reload"><i class="bi bi-arrow-repeat me-1" aria-hidden="true"></i>Reload</button></form><form method="post" action="/admin/clients/' + encodeURIComponent(client.screen_slug) + '/commands" class="d-inline-block m-0" data-async-command><input type="hidden" name="command" value="previous" /><input type="hidden" name="connectionId" value="' + escapeHtml(client.id) + '" /><button type="submit" class="btn btn-sm btn-outline-secondary" data-action="previous" aria-label="Previous slide"><i class="bi bi-skip-backward-fill" aria-hidden="true"></i></button></form><form method="post" action="/admin/clients/' + encodeURIComponent(client.screen_slug) + '/commands" class="d-inline-block m-0" data-async-command><input type="hidden" name="command" value="next" /><input type="hidden" name="connectionId" value="' + escapeHtml(client.id) + '" /><button type="submit" class="btn btn-sm btn-outline-secondary" data-action="next" aria-label="Next slide"><i class="bi bi-skip-forward-fill" aria-hidden="true"></i></button></form><form method="post" action="/admin/clients/' + encodeURIComponent(client.screen_slug) + '/commands" class="d-inline-block m-0" data-async-command><input type="hidden" name="command" value="pause" /><input type="hidden" name="connectionId" value="' + escapeHtml(client.id) + '" /><button type="submit" class="' + pauseButtonClass + '" data-action="pause"><i class="bi bi-' + pauseButtonIcon + ' me-1" aria-hidden="true"></i>' + pauseButtonLabel + '</button></form><form method="post" action="/admin/clients/' + encodeURIComponent(client.screen_slug) + '/commands" class="d-inline-block m-0" data-async-command><input type="hidden" name="command" value="blackout" /><input type="hidden" name="blackout" value="' + blackoutCommandValue + '" /><input type="hidden" name="connectionId" value="' + escapeHtml(client.id) + '" /><button type="submit" class="' + blackoutButtonClass + '" data-action="blackout"><i class="bi bi-' + blackoutButtonIcon + ' me-1" aria-hidden="true"></i>' + blackoutButtonLabel + '</button></form></div>';
return '<div class="actions justify-content-end"><form method="post" action="/clients/' + encodeURIComponent(client.screen_slug) + '/commands" class="d-inline-block m-0" data-confirm-message="' + escapeHtml(reloadConfirmMessage) + '" data-async-command><input type="hidden" name="command" value="reload" /><input type="hidden" name="connectionId" value="' + escapeHtml(client.id) + '" /><button type="submit" class="btn btn-sm btn-danger" data-action="reload"><i class="bi bi-arrow-repeat me-1" aria-hidden="true"></i>Reload</button></form><form method="post" action="/clients/' + encodeURIComponent(client.screen_slug) + '/commands" class="d-inline-block m-0" data-async-command><input type="hidden" name="command" value="previous" /><input type="hidden" name="connectionId" value="' + escapeHtml(client.id) + '" /><button type="submit" class="btn btn-sm btn-outline-secondary" data-action="previous" aria-label="Previous slide"><i class="bi bi-skip-backward-fill" aria-hidden="true"></i></button></form><form method="post" action="/clients/' + encodeURIComponent(client.screen_slug) + '/commands" class="d-inline-block m-0" data-async-command><input type="hidden" name="command" value="next" /><input type="hidden" name="connectionId" value="' + escapeHtml(client.id) + '" /><button type="submit" class="btn btn-sm btn-outline-secondary" data-action="next" aria-label="Next slide"><i class="bi bi-skip-forward-fill" aria-hidden="true"></i></button></form><form method="post" action="/clients/' + encodeURIComponent(client.screen_slug) + '/commands" class="d-inline-block m-0" data-async-command><input type="hidden" name="command" value="pause" /><input type="hidden" name="connectionId" value="' + escapeHtml(client.id) + '" /><button type="submit" class="' + pauseButtonClass + '" data-action="pause"><i class="bi bi-' + pauseButtonIcon + ' me-1" aria-hidden="true"></i>' + pauseButtonLabel + '</button></form><form method="post" action="/clients/' + encodeURIComponent(client.screen_slug) + '/commands" class="d-inline-block m-0" data-async-command><input type="hidden" name="command" value="blackout" /><input type="hidden" name="blackout" value="' + blackoutCommandValue + '" /><input type="hidden" name="connectionId" value="' + escapeHtml(client.id) + '" /><button type="submit" class="' + blackoutButtonClass + '" data-action="blackout"><i class="bi bi-' + blackoutButtonIcon + ' me-1" aria-hidden="true"></i>' + blackoutButtonLabel + '</button></form></div>';
}
function updateClientActionCell(cell, client) {
@@ -119,7 +119,7 @@
if (connectionInput) {
connectionInput.value = client.id || '';
}
pauseForm.action = '/admin/clients/' + encodeURIComponent(client.screen_slug) + '/commands';
pauseForm.action = '/clients/' + encodeURIComponent(client.screen_slug) + '/commands';
}
var reloadButton = cell.querySelector('button[data-action="reload"]');
@@ -132,7 +132,7 @@
if (reloadInput) {
reloadInput.value = client.id || '';
}
reloadForm.action = '/admin/clients/' + encodeURIComponent(client.screen_slug) + '/commands';
reloadForm.action = '/clients/' + encodeURIComponent(client.screen_slug) + '/commands';
reloadForm.setAttribute('data-confirm-message', 'Reloading will restart the player page. Continue?');
}
}
@@ -162,7 +162,7 @@
if (blackoutConnectionInput) {
blackoutConnectionInput.value = client.id || '';
}
blackoutForm.action = '/admin/clients/' + encodeURIComponent(client.screen_slug) + '/commands';
blackoutForm.action = '/clients/' + encodeURIComponent(client.screen_slug) + '/commands';
}
var previousButton = cell.querySelector('button[data-action="previous"]');
@@ -182,7 +182,7 @@
if (previousConnectionInput) {
previousConnectionInput.value = client.id || '';
}
previousForm.action = '/admin/clients/' + encodeURIComponent(client.screen_slug) + '/commands';
previousForm.action = '/clients/' + encodeURIComponent(client.screen_slug) + '/commands';
}
var nextButton = cell.querySelector('button[data-action="next"]');
@@ -202,7 +202,7 @@
if (nextConnectionInput) {
nextConnectionInput.value = client.id || '';
}
nextForm.action = '/admin/clients/' + encodeURIComponent(client.screen_slug) + '/commands';
nextForm.action = '/clients/' + encodeURIComponent(client.screen_slug) + '/commands';
}
}
@@ -416,7 +416,7 @@
body.append('deviceId', String(deviceId || '').trim());
body.append('clientName', String(clientName || '').trim());
return fetch('/admin/clients/' + encodeURIComponent(String(screenSlug || '').trim()) + '/commands', {
return fetch('/clients/' + encodeURIComponent(String(screenSlug || '').trim()) + '/commands', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8',
@@ -495,4 +495,4 @@
window.webHandleDashboardState = handleDashboardState;
initClientRenameHandler();
}());
}());
@@ -553,7 +553,7 @@
'</td>' +
'<td><input name="duration_seconds[]" type="number" min="1" value="' + values.duration_seconds + '" required form="playlist-edit-form" /></td>' +
'<td><div class="actions playlist-item-actions">' +
'<button type="button" class="btn btn-sm btn-primary" data-schedule-config="/admin/playlists/' + encodeURIComponent(playlistId) + '/slides/0/config" data-schedule-config-row="' + rowKey + '">Schedule</button>' +
'<button type="button" class="btn btn-sm btn-primary" data-schedule-config="/playlists/' + encodeURIComponent(playlistId) + '/slides/0/config" data-schedule-config-row="' + rowKey + '">Schedule</button>' +
'<button type="button" class="btn btn-sm btn-danger" data-playlist-remove-row>Remove</button>' +
'</div></td>';
return row;
@@ -655,3 +655,4 @@
initPlaylistScheduleForm();
initPlaylistEditStaging();
}());
@@ -0,0 +1,63 @@
(function () {
var REFRESH_INTERVAL_MS = 10000;
var pathname = window.location.pathname.replace(/\/$/, '');
var stateNode = document.getElementById('background-tasks-state');
var currentVersion = stateNode ? String(stateNode.getAttribute('data-state-version') || '') : '';
if (pathname !== '/settings/background-tasks') {
return;
}
function stripMessageParameter() {
try {
var url = new URL(window.location.href);
if (!url.searchParams.has('message')) {
return;
}
url.searchParams.delete('message');
window.history.replaceState({}, document.title, url.pathname + (url.search ? url.search : '') + url.hash);
} catch (_error) {
// Ignore URL cleanup failures.
}
}
function refreshPage() {
if (document.visibilityState !== 'visible') {
return;
}
fetch('/settings/background-tasks/state', {
method: 'GET',
headers: {
'Accept': 'application/json',
'X-Requested-With': 'XMLHttpRequest'
},
credentials: 'same-origin',
cache: 'no-store'
}).then(function (response) {
if (!response.ok) {
return null;
}
return response.json();
}).then(function (payload) {
if (!payload || !payload.version) {
return;
}
var nextVersion = String(payload.version || '');
if (!currentVersion) {
currentVersion = nextVersion;
return;
}
if (nextVersion !== currentVersion) {
window.location.reload();
}
}).catch(function (_error) {
// Ignore refresh probe errors and try again on the next interval.
});
}
stripMessageParameter();
window.setInterval(refreshPage, REFRESH_INTERVAL_MS);
}());
+183 -33
View File
@@ -3,6 +3,7 @@ import {
BlockQuote,
Bold,
ClassicEditor,
Base64UploadAdapter,
ClipboardPipeline,
Essentials,
FontBackgroundColor,
@@ -11,6 +12,20 @@ import {
FontSize,
Heading,
HorizontalLine,
Image,
ImageBlockEditing,
ImageCaption,
ImageInlineEditing,
ImageInsert,
ImageInsertUI,
ImageInsertViaUrl,
ImageInsertViaUrlUI,
ImageResize,
ImageResizeEditing,
ImageResizeHandles,
ImageStyle,
ImageTextAlternative,
ImageUpload,
Indent,
IndentBlock,
Italic,
@@ -25,6 +40,8 @@ import {
import { createTemplateSelectorLockController } from '/assets/js/slides/slide-form-template-lock.js';
(function () {
var DEFAULT_FONT_SIZE = 32;
var dataElement = document.getElementById('slide-editor-data');
if (!dataElement) {
return;
@@ -94,7 +111,7 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
function getCurrentFontSizeValue() {
var value = fontSizeCommand.value;
if (value === null || value === undefined || value === '') {
return '';
return String(DEFAULT_FONT_SIZE);
}
var parsed = Math.round(Number(String(value).replace(/[^0-9.]/g, '')));
@@ -118,6 +135,7 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
view.element.inputMode = 'numeric';
view.element.autocomplete = 'off';
view.element.spellcheck = false;
view.element.style.width = '3.5em';
syncValue();
});
@@ -170,6 +188,10 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
var clipboardPipeline = editor.plugins.get(ClipboardPipeline);
this.listenTo(clipboardPipeline, 'inputTransformation', function (_event, data) {
if (clipboardContainsImageContent(data.dataTransfer)) {
return;
}
var plainText = data.dataTransfer.getData('text/plain');
data.content = editor.data.processor.toView(plainTextToHtml(plainText));
}, { priority: 'high' });
@@ -211,6 +233,22 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
return paragraphs.length ? paragraphs.join('') : '<p></p>';
}
function clipboardContainsImageContent(dataTransfer) {
if (!dataTransfer) {
return false;
}
var files = Array.from(dataTransfer.files || []);
if (files.some(function (file) {
return file && String(file.type || '').indexOf('image/') === 0;
})) {
return true;
}
var html = dataTransfer.getData('text/html') || '';
return /<img\b|<figure\b[^>]*\bclass=["'][^"']*\bimage\b/i.test(html);
}
function getTemplateById(id) {
return templates.find(function (item) { return Number(item.id) === Number(id); }) || null;
}
@@ -240,30 +278,108 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
}
function sanitizePreviewHtml(html) {
var output = String(html || '');
output = output.replace(/<script[\s\S]*?<\/script>/gi, '');
output = output.replace(/<style[\s\S]*?<\/style>/gi, '');
return output.replace(/<[^>]+>/g, function (tag) {
var match = tag.match(/^<\s*(\/?)\s*([a-z0-9]+)([\s\S]*?)(\/?)>$/i);
if (!match) {
return '';
var container = document.createElement('div');
container.innerHTML = String(html || '');
return sanitizePreviewNode(container);
}
function sanitizePreviewNode(node) {
var allowed = ['a', 'b', 'blockquote', 'br', 'code', 'div', 'em', 'figure', 'figcaption', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'hr', 'i', 'img', 'li', 'ol', 'p', 'pre', 'span', 'strong', 'sub', 'sup', 'table', 'tbody', 'td', 'th', 'thead', 'tr', 'u', 'ul'];
var output = '';
Array.from(node.childNodes || []).forEach(function (child) {
if (child.nodeType === Node.TEXT_NODE) {
output += escapeHtml(child.textContent);
return;
}
var closing = Boolean(match[1]);
var name = String(match[2] || '').toLowerCase();
var attrText = String(match[3] || '');
var selfClosing = Boolean(match[4]) || name === 'br' || name === 'hr';
var allowed = ['a', 'b', 'blockquote', 'br', 'code', 'div', 'em', 'figure', 'figcaption', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'hr', 'i', 'li', 'ol', 'p', 'pre', 'span', 'strong', 'sub', 'sup', 'table', 'tbody', 'td', 'th', 'thead', 'tr', 'u', 'ul'];
if (child.nodeType !== Node.ELEMENT_NODE) {
return;
}
var name = String(child.tagName || '').toLowerCase();
if (allowed.indexOf(name) === -1) {
return '';
return;
}
if (closing) {
return '</' + name + '>';
if (name === 'br' || name === 'hr') {
output += '<' + name + '>';
return;
}
if (selfClosing) {
return '<' + name + sanitizeTagAttributes(name, attrText) + '>';
var attrs = sanitizePreviewElementAttributes(child, name);
if (name === 'img') {
output += '<img' + attrs + '>';
return;
}
return '<' + name + sanitizeTagAttributes(name, attrText) + '>';
output += '<' + name + attrs + '>' + sanitizePreviewNode(child) + '</' + name + '>';
});
return output;
}
function sanitizePreviewElementAttributes(element, tagName) {
var allowedAttributes = {
a: ['href', 'title', 'target', 'rel', 'class', 'style'],
blockquote: ['class', 'style'],
div: ['class', 'style'],
figure: ['class', 'style'],
figcaption: ['class', 'style'],
h1: ['class', 'style'],
h2: ['class', 'style'],
h3: ['class', 'style'],
h4: ['class', 'style'],
h5: ['class', 'style'],
h6: ['class', 'style'],
img: ['alt', 'class', 'decoding', 'height', 'loading', 'src', 'style', 'title', 'width'],
li: ['class', 'style'],
ol: ['class', 'style', 'start'],
p: ['class', 'style'],
pre: ['class', 'style'],
span: ['class', 'style'],
table: ['class', 'style'],
td: ['class', 'style', 'colspan', 'rowspan'],
th: ['class', 'style', 'colspan', 'rowspan', 'scope'],
tr: ['class', 'style'],
ul: ['class', 'style']
};
var allowed = allowedAttributes[tagName] || [];
var attrs = [];
Array.from(element.attributes || []).forEach(function (attribute) {
var lowerKey = String(attribute.name || '').toLowerCase();
if (allowed.indexOf(lowerKey) === -1) {
return;
}
var value = String(attribute.value || '');
if (tagName === 'img' && lowerKey === 'src') {
value = sanitizeImageSrc(value);
}
if (lowerKey === 'href' && /^(?:\s*javascript:|\s*data:)/i.test(value)) {
return;
}
if (tagName === 'img' && lowerKey === 'src' && !value) {
return;
}
if (lowerKey === 'style' && /(?:expression\s*\(|javascript:|url\s*\()/i.test(value)) {
return;
}
if (lowerKey === 'target') {
var targetValue = value.trim();
if (targetValue === '_blank') {
attrs.push(' target="_blank"');
if (attrs.indexOf(' rel="noreferrer noopener"') === -1) {
attrs.push(' rel="noreferrer noopener"');
}
return;
}
}
attrs.push(' ' + lowerKey + '="' + escapeHtml(value) + '"');
});
return attrs.join('');
}
function sanitizeTagAttributes(tagName, attrText) {
@@ -279,6 +395,7 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
h4: ['class', 'style'],
h5: ['class', 'style'],
h6: ['class', 'style'],
img: ['alt', 'class', 'decoding', 'height', 'loading', 'src', 'style', 'title', 'width'],
li: ['class', 'style'],
ol: ['class', 'style', 'start'],
p: ['class', 'style'],
@@ -302,9 +419,15 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
return '';
}
var value = doubleQuoted !== undefined ? doubleQuoted : singleQuoted !== undefined ? singleQuoted : bareValue !== undefined ? bareValue : '';
if (tagName === 'img' && lowerKey === 'src') {
value = sanitizeImageSrc(value);
}
if (lowerKey === 'href' && /^(?:\s*javascript:|\s*data:)/i.test(String(value || ''))) {
return '';
}
if (tagName === 'img' && lowerKey === 'src' && !value) {
return '';
}
if (lowerKey === 'style' && /(?:expression\s*\(|javascript:|url\s*\()/i.test(String(value || ''))) {
return '';
}
@@ -325,6 +448,23 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
return attrs.join('');
}
function sanitizeImageSrc(value) {
var raw = String(value || '').trim();
if (!raw) {
return '';
}
if (/^(?:https?:|\/|\.\.?\/|\/\/)/i.test(raw)) {
return raw;
}
if (/^data:image\/(?:png|jpe?g|gif|webp|bmp);base64,/i.test(raw)) {
return raw;
}
return '';
}
function renderPreviewListItem(item, tag) {
if (item && typeof item === 'object') {
var content = item.content !== undefined ? item.content : (item.text !== undefined ? item.text : item.value !== undefined ? item.value : '');
@@ -442,7 +582,7 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
var current = existingContent[region.region_key] || {};
return {
font_family: String(current.font_family || region.font_family || 'Arial').trim() || 'Arial',
font_size: Math.max(8, Number(current.font_size || 24)),
font_size: Math.max(8, Number(current.font_size || DEFAULT_FONT_SIZE)),
font_color: String(current.font_color || region.font_color || '#000000').trim() || '#000000'
};
}
@@ -869,8 +1009,6 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
'undo',
'redo',
'|',
'heading',
'|',
'fontSizeInput',
'fontFamily',
'fontColor',
@@ -889,12 +1027,20 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
'alignment',
'|',
'outdent',
'indent'
'indent',
'|',
'imageInsert',
'imageInsertViaUrl',
'imageStyle:block',
'imageStyle:inline',
'imageTextAlternative',
'imageResize'
],
shouldNotGroupWhenFull: false
},
plugins: [
Alignment,
Base64UploadAdapter,
BlockQuote,
Bold,
Essentials,
@@ -904,11 +1050,24 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
FontFamily,
FontSize,
FontSizeInputUI,
Heading,
HorizontalLine,
Indent,
IndentBlock,
Italic,
Image,
ImageBlockEditing,
ImageCaption,
ImageInlineEditing,
ImageInsert,
ImageInsertUI,
ImageInsertViaUrl,
ImageInsertViaUrlUI,
ImageResize,
ImageResizeEditing,
ImageResizeHandles,
ImageStyle,
ImageTextAlternative,
ImageUpload,
Paragraph,
Strikethrough,
Subscript,
@@ -922,15 +1081,6 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
options: [20, 24, 28, 32, 36, 40, 44],
supportAllValues: true
},
heading: {
options: [
{ model: 'paragraph', title: 'Paragraph', class: 'ck-heading_paragraph' },
{ model: 'heading1', view: 'h1', title: 'Heading 1', class: 'ck-heading_heading1' },
{ model: 'heading2', view: 'h2', title: 'Heading 2', class: 'ck-heading_heading2' },
{ model: 'heading3', view: 'h3', title: 'Heading 3', class: 'ck-heading_heading3' },
{ model: 'heading4', view: 'h4', title: 'Heading 4', class: 'ck-heading_heading4' }
]
},
initialData: normalizeEditorData((source && source.value) || (hidden ? hidden.value : ''))
};
+511 -20
View File
@@ -25,6 +25,8 @@ import {
import { createTemplateSelectorLockController } from '/assets/js/slides/slide-form-template-lock.js';
(function () {
var DEFAULT_FONT_SIZE = 32;
var dataElement = document.getElementById('slide-editor-data');
if (!dataElement) {
return;
@@ -38,6 +40,8 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
}
var templates = Array.isArray(slideEditorData.templates) ? slideEditorData.templates : [];
var rssFeeds = Array.isArray(slideEditorData.rssFeeds) ? slideEditorData.rssFeeds : [];
var apiSources = Array.isArray(slideEditorData.apiSources) ? slideEditorData.apiSources : [];
var existingTemplateId = slideEditorData.existingTemplateId !== undefined ? slideEditorData.existingTemplateId : null;
var existingContent = slideEditorData.existingContent || {};
var templateSelect = document.getElementById('template-select');
@@ -94,7 +98,7 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
function getCurrentFontSizeValue() {
var value = fontSizeCommand.value;
if (value === null || value === undefined || value === '') {
return '';
return String(DEFAULT_FONT_SIZE);
}
var parsed = Math.round(Number(String(value).replace(/[^0-9.]/g, '')));
@@ -118,6 +122,7 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
view.element.inputMode = 'numeric';
view.element.autocomplete = 'off';
view.element.spellcheck = false;
view.element.style.width = '3.5em';
syncValue();
});
@@ -406,6 +411,15 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
return '<iframe class="slide-preview-webpage-frame" src="' + escapeHtml(src) + '" title="Webpage preview" loading="eager" referrerpolicy="no-referrer" scrolling="no"></iframe>';
}
function renderPreviewRtmpRegion(value, disableAudio) {
var src = String(value || '').trim();
var label = src ? 'RTMP' : 'RTMP stream';
if (disableAudio) {
label += ' (muted)';
}
return '<div class="slide-preview-placeholder">' + label + '</div>';
}
function renderPreviewHtmlRegion(value) {
var html = String(value || '').trim();
if (!html) {
@@ -434,19 +448,286 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
}
function normalizeFontSizeValue(value) {
var size = Math.max(1, Math.round(Number(String(value || '').replace(/[^0-9.]/g, ''))));
return size ? String(size) : '';
var raw = String(value || '').trim().toLowerCase();
if (/^\d+(?:\.\d+)?px$/.test(raw)) {
return String(Math.max(1, Math.round(Number(raw.replace(/px$/, '')))));
}
if (/^\d+(?:\.\d+)?$/.test(raw)) {
return String(Math.max(1, Math.round(Number(raw))));
}
return '';
}
function syncEditorFontSizeHidden(regionId, editor) {
var fontSizeHidden = getEditorFontSizeHiddenInput(regionId);
if (!fontSizeHidden || !editor) {
return;
}
var fontSizeCommand = editor.commands.get('fontSize');
fontSizeHidden.value = normalizeFontSizeValue(fontSizeCommand && fontSizeCommand.value) || String(DEFAULT_FONT_SIZE);
}
function getCurrentTextStyle(region) {
var current = existingContent[region.region_key] || {};
return {
font_family: String(current.font_family || region.font_family || 'Arial').trim() || 'Arial',
font_size: Math.max(8, Number(current.font_size || 24)),
font_size: Math.max(8, Number(current.font_size || DEFAULT_FONT_SIZE)),
font_color: String(current.font_color || region.font_color || '#000000').trim() || '#000000'
};
}
function getRssFeedById(feedId) {
var normalizedId = Number(feedId || 0);
return rssFeeds.find(function (feed) {
return Number(feed.id) === normalizedId;
}) || null;
}
function getRssFieldList(feedId) {
var feed = getRssFeedById(feedId);
var sampleItem = feed && Array.isArray(feed.items) ? feed.items[0] : null;
var sampleJson = sampleItem && sampleItem.itemJson && typeof sampleItem.itemJson === 'object' ? sampleItem.itemJson : null;
function walkFields(value, prefix, output) {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return;
}
Object.keys(value).forEach(function (key) {
if (key === 'rawXml') {
return;
}
var nextPath = prefix ? prefix + '.' + key : key;
var nextValue = value[key];
if (nextValue && typeof nextValue === 'object' && !Array.isArray(nextValue)) {
walkFields(nextValue, nextPath, output);
return;
}
if (output.indexOf(nextPath) === -1) {
output.push(nextPath);
}
});
}
var fields = [];
walkFields(sampleJson, '', fields);
return fields;
}
function updatePlaceholderChipList(card, fieldList, emptyLabel) {
if (!card) {
return;
}
var container = card.querySelector('[data-placeholder-chips]');
if (!container) {
return;
}
var chips = Array.isArray(fieldList) ? fieldList.map(function (field) {
return '<span class="chip">{{' + escapeHtml(field) + '}}</span>';
}).join('') : '';
container.innerHTML = chips || '<span class="muted slide-image-file">' + escapeHtml(emptyLabel) + '</span>';
}
function updateRssPlaceholderChips(regionId, feedId) {
var card = templateFields.querySelector('[data-region-id="' + regionId + '"]');
updatePlaceholderChipList(card, getRssFieldList(feedId), 'No RSS fields available.');
}
function getCurrentRssConfig(region) {
var current = existingContent[region.region_key] || {};
var parsedItemNumber = Math.max(1, Number(current.item_number || 1));
return {
feed_id: current.feed_id === undefined || current.feed_id === null || current.feed_id === '' ? '' : Number(current.feed_id),
item_number: Number.isFinite(parsedItemNumber) && parsedItemNumber > 0 ? parsedItemNumber : 1,
value: current.value !== undefined ? current.value : ''
};
}
function resolveRssPath(value, path) {
var current = value;
if (!path) {
return current;
}
String(path).split('.').forEach(function (segment) {
if (current === undefined || current === null) {
current = '';
return;
}
current = current[segment];
});
return current === undefined || current === null ? '' : current;
}
function normalizeRssVariableName(value) {
var next = String(value || '').trim();
return next || 'item';
}
function substituteRssVariables(html, item) {
var source = String(html || '');
return source.replace(/\{\{\s*([a-zA-Z0-9_]+)(?:\.([a-zA-Z0-9_.]+))?\s*\}\}/g, function (_match, tokenName, tokenPath) {
if (!item || typeof item !== 'object') {
return '';
}
var key = tokenName === 'item' && tokenPath ? tokenPath : tokenName;
return escapeHtml(resolveRssPath(item, key || ''));
});
}
function getRssItem(region, rssConfig) {
var feed = getRssFeedById(rssConfig.feed_id);
var items = feed && Array.isArray(feed.items) ? feed.items : [];
var parsedItemNumber = Math.max(1, Number(rssConfig.item_number || 1));
var index = Number.isFinite(parsedItemNumber) && parsedItemNumber > 0 ? parsedItemNumber - 1 : 0;
return items[index] || null;
}
function getRssPreviewFallback(item) {
if (!item || typeof item !== 'object') {
return '<div class="slide-preview-placeholder">RSS item</div>';
}
var title = String(item.title || '').trim();
var description = String(item.description || '').trim();
var summary = [];
if (title) {
summary.push('<h3>' + escapeHtml(title) + '</h3>');
}
if (description) {
summary.push('<p>' + sanitizePreviewHtml(description) + '</p>');
}
if (!summary.length) {
return '<div class="slide-preview-placeholder">RSS item</div>';
}
return summary.join('');
}
function getApiSourceById(sourceId) {
var normalizedId = Number(sourceId || 0);
return apiSources.find(function (source) {
return Number(source.id) === normalizedId;
}) || null;
}
function getApiSourceItems(sourceId) {
var source = getApiSourceById(sourceId);
var responseJson = source && source.responseJson && typeof source.responseJson === 'object' ? source.responseJson : null;
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] : [];
}
function getApiFieldList(sourceId) {
var sampleItem = getApiSourceItems(sourceId)[0] || null;
function walkFields(value, prefix, output) {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return;
}
Object.keys(value).forEach(function (key) {
var nextPath = prefix ? prefix + '.' + key : key;
var nextValue = value[key];
if (nextValue && typeof nextValue === 'object' && !Array.isArray(nextValue)) {
walkFields(nextValue, nextPath, output);
return;
}
if (output.indexOf(nextPath) === -1) {
output.push(nextPath);
}
});
}
var fields = [];
walkFields(sampleItem && typeof sampleItem === 'object' ? sampleItem : null, '', fields);
return fields;
}
function updateApiPlaceholderChips(regionId, sourceId) {
var card = templateFields.querySelector('[data-region-id="' + regionId + '"]');
updatePlaceholderChipList(card, getApiFieldList(sourceId), 'No JSON fields available.');
}
function getCurrentApiConfig(region) {
var current = existingContent[region.region_key] || {};
var parsedItemNumber = Math.max(1, Number(current.item_number || 1));
return {
source_id: current.source_id === undefined || current.source_id === null || current.source_id === '' ? '' : Number(current.source_id),
item_number: Number.isFinite(parsedItemNumber) && parsedItemNumber > 0 ? parsedItemNumber : 1,
value: current.value !== undefined ? current.value : ''
};
}
function resolveApiPath(value, path) {
var current = value;
if (!path) {
return current;
}
String(path).split('.').forEach(function (segment) {
if (current === undefined || current === null) {
current = '';
return;
}
current = current[segment];
});
return current === undefined || current === null ? '' : current;
}
function substituteApiVariables(html, item) {
var source = String(html || '');
return source.replace(/\{\{\s*([a-zA-Z0-9_]+)(?:\.([a-zA-Z0-9_.]+))?\s*\}\}/g, function (_match, tokenName, tokenPath) {
if (!item || typeof item !== 'object') {
return '';
}
var key = tokenName === 'item' && tokenPath ? tokenPath : tokenName;
return escapeHtml(resolveApiPath(item, key || ''));
});
}
function getApiItem(sourceId, itemNumber) {
var items = getApiSourceItems(sourceId);
var parsedItemNumber = Math.max(1, Number(itemNumber || 1));
var index = Number.isFinite(parsedItemNumber) && parsedItemNumber > 0 ? parsedItemNumber - 1 : 0;
return items[index] || null;
}
function getApiPreviewFallback(item) {
if (!item || typeof item !== 'object') {
return '<div class="slide-preview-placeholder">API item</div>';
}
var title = String(item.title || item.name || '').trim();
var description = String(item.description || item.summary || item.text || '').trim();
var summary = [];
if (title) {
summary.push('<h3>' + escapeHtml(title) + '</h3>');
}
if (description) {
summary.push('<p>' + sanitizePreviewHtml(description) + '</p>');
}
if (!summary.length) {
return '<div class="slide-preview-placeholder">API item</div>';
}
return summary.join('');
}
function getTextStyleFromCard(_card, region) {
return getCurrentTextStyle(region);
}
@@ -724,10 +1005,25 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
var imageHidden = card && card.querySelector('input[type="hidden"][name="existing_region_image_' + region.id + '"]');
var imageFileInput = card && card.querySelector('input[type="file"][name="region_image_' + region.id + '"]');
var webpageInput = card && card.querySelector('input[type="url"][name="region_webpage_' + region.id + '"]');
var rtmpInput = card && card.querySelector('input[type="url"][name="region_rtmp_' + region.id + '"]');
var disableAudioInput = card && card.querySelector('input[type="checkbox"][name="region_disable_audio_' + region.id + '"]');
var rssFeedInput = card && card.querySelector('select[name="region_rss_feed_id_' + region.id + '"]');
var rssItemInput = card && card.querySelector('input[name="region_rss_item_number_' + region.id + '"]');
var apiSourceInput = card && card.querySelector('select[name="region_api_source_id_' + region.id + '"]');
var apiItemInput = card && card.querySelector('input[name="region_api_item_number_' + region.id + '"]');
var existingRtmp = existingContent[region.region_key] || {};
var existingRss = existingContent[region.region_key] || {};
var existingApi = existingContent[region.region_key] || {};
var value = region.region_type === 'image'
? ((imageFileInput && imageFileInput.dataset.previewUrl) ? imageFileInput.dataset.previewUrl : (imageHidden ? imageHidden.value : ''))
: region.region_type === 'webpage'
? (webpageInput ? webpageInput.value : '')
: region.region_type === 'rtmp'
? (rtmpInput ? rtmpInput.value : '')
: region.region_type === 'rss'
? (hiddenInput ? hiddenInput.value : '')
: region.region_type === 'api'
? (hiddenInput ? hiddenInput.value : '')
: region.region_type === 'html'
? (htmlInput ? htmlInput.value : '')
: (hiddenInput ? hiddenInput.value : '');
@@ -735,6 +1031,8 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
? null
: region.region_type === 'webpage'
? null
: region.region_type === 'rtmp'
? null
: region.region_type === 'html'
? null
: getTextStyleFromCard(card, region);
@@ -742,6 +1040,23 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
? (value ? '<img class="slide-preview-image" src="' + escapeHtml(value) + '" alt="" />' : '<div class="slide-preview-placeholder">Image</div>')
: region.region_type === 'webpage'
? renderPreviewWebpageRegion(value)
: region.region_type === 'rtmp'
? renderPreviewRtmpRegion(value, disableAudioInput ? disableAudioInput.checked : (existingRtmp.disable_audio === undefined ? true : Boolean(existingRtmp.disable_audio)))
: region.region_type === 'rss'
? (function () {
var item = getRssItem(region, {
feed_id: rssFeedInput ? rssFeedInput.value : existingRss.feed_id,
item_number: rssItemInput ? rssItemInput.value : existingRss.item_number
});
var rendered = substituteRssVariables(value, item);
return renderPreviewTextRegion(region, rendered ? rendered : getRssPreviewFallback(item), style, scale);
}())
: region.region_type === 'api'
? (function () {
var item = getApiItem(apiSourceInput ? apiSourceInput.value : existingApi.source_id, apiItemInput ? apiItemInput.value : existingApi.item_number);
var rendered = substituteApiVariables(value, item);
return renderPreviewTextRegion(region, rendered ? rendered : getApiPreviewFallback(item), style, scale);
}())
: region.region_type === 'html'
? renderPreviewHtmlRegion(value)
: renderPreviewTextRegion(region, value, style, scale);
@@ -749,6 +1064,12 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
? ' slide-preview-image-region'
: region.region_type === 'webpage'
? ' slide-preview-webpage-region'
: region.region_type === 'rtmp'
? ' slide-preview-rtmp-region'
: region.region_type === 'rss'
? ' slide-preview-rss-region'
: region.region_type === 'api'
? ' slide-preview-api-region'
: region.region_type === 'html'
? ' slide-preview-html-region'
: ' slide-preview-text-region';
@@ -779,6 +1100,96 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
'</div>';
}
function renderRssRegion(region) {
var current = existingContent[region.region_key] || {};
var config = getCurrentRssConfig(region);
var fieldList = getRssFieldList(config.feed_id);
var placeholderChips = fieldList.map(function (field) {
return '<span class="chip">{{' + escapeHtml(field) + '}}</span>';
}).join('');
var feedOptions = rssFeeds.map(function (feed) {
var selected = Number(feed.id) === Number(config.feed_id) ? ' selected' : '';
return '<option value="' + escapeHtml(feed.id) + '"' + selected + '>' + escapeHtml(feed.name || ('Feed ' + feed.id)) + '</option>';
}).join('');
return '' +
'<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">' +
'<strong>' + escapeHtml(region.label) + '</strong>' +
'<div class="template-field-actions">' +
'<button type="button" class="btn btn-sm btn-outline-secondary template-editor-toggle" data-toggle-editor-height="' + region.id + '">Expand</button>' +
'<span class="chip">RSS</span>' +
'</div>' +
'</div>' +
'<div class="card-body p-3 d-grid gap-3">' +
'<div class="ckeditor-holder" data-region-id="' + region.id + '">' +
'<textarea class="ckeditor-source" rows="10">' + escapeHtml(current.value !== undefined ? current.value : '') + '</textarea>' +
'</div>' +
'<div class="row g-3 align-items-end">' +
'<div class="col-12 col-md-8">' +
'<label class="form-label" for="region_rss_feed_id_' + region.id + '">RSS feed</label>' +
'<select name="region_rss_feed_id_' + region.id + '" class="form-select">' +
'<option value="">Select a feed</option>' +
feedOptions +
'</select>' +
'</div>' +
'<div class="col-6 col-md-4">' +
'<label class="form-label" for="region_rss_item_number_' + region.id + '">Entry number</label>' +
'<input type="number" min="1" step="1" name="region_rss_item_number_' + region.id + '" class="form-control" value="' + escapeHtml(config.item_number) + '" />' +
'</div>' +
'</div>' +
'<input type="hidden" name="region_font_size_' + region.id + '" value="' + escapeHtml(getCurrentTextStyle(region).font_size) + '" />' +
'<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}} or {{link}}. 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>' +
'</div>';
}
function renderApiRegion(region) {
var current = existingContent[region.region_key] || {};
var config = getCurrentApiConfig(region);
var fieldList = getApiFieldList(config.source_id);
var placeholderChips = fieldList.map(function (field) {
return '<span class="chip">{{' + escapeHtml(field) + '}}</span>';
}).join('');
var sourceOptions = apiSources.map(function (source) {
var selected = Number(source.id) === Number(config.source_id) ? ' selected' : '';
return '<option value="' + escapeHtml(source.id) + '"' + selected + '>' + escapeHtml(source.name || ('Source ' + source.id)) + '</option>';
}).join('');
return '' +
'<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">' +
'<strong>' + escapeHtml(region.label) + '</strong>' +
'<div class="template-field-actions">' +
'<button type="button" class="btn btn-sm btn-outline-secondary template-editor-toggle" data-toggle-editor-height="' + region.id + '">Expand</button>' +
'<span class="chip">API</span>' +
'</div>' +
'</div>' +
'<div class="card-body p-3 d-grid gap-3">' +
'<div class="ckeditor-holder" data-region-id="' + region.id + '">' +
'<textarea class="ckeditor-source" rows="10">' + escapeHtml(current.value !== undefined ? current.value : '') + '</textarea>' +
'</div>' +
'<div class="row g-3 align-items-end">' +
'<div class="col-12 col-md-8">' +
'<label class="form-label" for="region_api_source_id_' + region.id + '">API source</label>' +
'<select name="region_api_source_id_' + region.id + '" class="form-select">' +
'<option value="">Select a source</option>' +
sourceOptions +
'</select>' +
'</div>' +
'<div class="col-6 col-md-4">' +
'<label class="form-label" for="region_api_item_number_' + region.id + '">Item number</label>' +
'<input type="number" min="1" step="1" name="region_api_item_number_' + region.id + '" class="form-control" value="' + escapeHtml(config.item_number) + '" />' +
'</div>' +
'</div>' +
'<input type="hidden" name="region_font_size_' + region.id + '" value="' + escapeHtml(getCurrentTextStyle(region).font_size) + '" />' +
'<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}} or {{description}}. 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>' +
'</div>';
}
function renderWebpageRegion(region) {
var current = getCurrentRegionValue(region);
return '' +
@@ -796,6 +1207,29 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
'</div>';
}
function renderRtmpRegion(region) {
var current = existingContent[region.region_key] || {};
var url = String(current.value || '').trim();
var disableAudio = current.disable_audio === undefined ? true : Boolean(current.disable_audio);
return '' +
'<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">' +
'<strong>' + escapeHtml(region.label) + '</strong>' +
'<span class="chip">RTMP</span>' +
'</div>' +
'<div class="card-body p-3 d-grid gap-3">' +
'<label style="display:block;">RTMP URL' +
'<input type="url" name="region_rtmp_' + region.id + '" class="form-control" value="' + escapeHtml(url) + '" placeholder="rtmp://example.com/live/stream" />' +
'</label>' +
'<label class="form-check m-0">' +
'<input class="form-check-input" type="checkbox" name="region_disable_audio_' + region.id + '" value="1"' + (disableAudio ? ' checked' : '') + ' />' +
'<span class="form-check-label">Disable audio</span>' +
'</label>' +
'<div class="muted slide-image-file">Player controls stay hidden and the stream is not interactive.</div>' +
'</div>' +
'</div>';
}
function renderHtmlRegion(region) {
var current = getCurrentRegionValue(region);
return '' +
@@ -851,6 +1285,15 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
if (region.region_type === 'webpage') {
return renderWebpageRegion(region);
}
if (region.region_type === 'rtmp') {
return renderRtmpRegion(region);
}
if (region.region_type === 'rss') {
return renderRssRegion(region);
}
if (region.region_type === 'api') {
return renderApiRegion(region);
}
if (region.region_type === 'html') {
return renderHtmlRegion(region);
}
@@ -869,8 +1312,6 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
'undo',
'redo',
'|',
'heading',
'|',
'fontSizeInput',
'fontFamily',
'fontColor',
@@ -904,7 +1345,6 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
FontFamily,
FontSize,
FontSizeInputUI,
Heading,
HorizontalLine,
Indent,
IndentBlock,
@@ -922,29 +1362,19 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
options: [20, 24, 28, 32, 36, 40, 44],
supportAllValues: true
},
heading: {
options: [
{ model: 'paragraph', title: 'Paragraph', class: 'ck-heading_paragraph' },
{ model: 'heading1', view: 'h1', title: 'Heading 1', class: 'ck-heading_heading1' },
{ model: 'heading2', view: 'h2', title: 'Heading 2', class: 'ck-heading_heading2' },
{ model: 'heading3', view: 'h3', title: 'Heading 3', class: 'ck-heading_heading3' },
{ model: 'heading4', view: 'h4', title: 'Heading 4', class: 'ck-heading_heading4' }
]
},
initialData: normalizeEditorData((source && source.value) || (hidden ? hidden.value : ''))
};
ClassicEditor.create(source || holder, editorConfig).then(function (editor) {
editorInstances.set(regionId, editor);
var fontSizeHidden = getEditorFontSizeHiddenInput(regionId);
var fontSizeCommand = editor.commands.get('fontSize');
if (hidden) {
hidden.value = editor.getData();
}
if (fontSizeHidden && fontSizeCommand) {
fontSizeHidden.value = normalizeFontSizeValue(fontSizeCommand.value) || fontSizeHidden.value;
syncEditorFontSizeHidden(regionId, editor);
if (fontSizeCommand) {
fontSizeCommand.on('change:value', function () {
fontSizeHidden.value = normalizeFontSizeValue(fontSizeCommand.value) || fontSizeHidden.value;
syncEditorFontSizeHidden(regionId, editor);
});
}
editor.model.document.on('change:data', function () {
@@ -990,6 +1420,42 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
requestPreviewRender();
});
});
templateFields.querySelectorAll('select[name^="region_rss_feed_id_"], input[name^="region_rss_item_number_"], select[name^="region_api_source_id_"], input[name^="region_api_item_number_"]').forEach(function (input) {
input.addEventListener('input', function () {
var regionId = input.name.replace(/^region_(?:rss_feed_id|rss_item_number|api_source_id|api_item_number)_/, '');
if (input.name.indexOf('region_rss_') === 0) {
var rssFeed = input.name.indexOf('region_rss_feed_id_') === 0 ? input.value : (templateFields.querySelector('select[name="region_rss_feed_id_' + regionId + '"]') || {}).value;
updateRssPlaceholderChips(regionId, rssFeed);
}
if (input.name.indexOf('region_api_') === 0) {
var apiSource = input.name.indexOf('region_api_source_id_') === 0 ? input.value : (templateFields.querySelector('select[name="region_api_source_id_' + regionId + '"]') || {}).value;
updateApiPlaceholderChips(regionId, apiSource);
}
templateSelectorLock.markEdited();
requestPreviewRender();
});
input.addEventListener('change', function () {
var regionId = input.name.replace(/^region_(?:rss_feed_id|rss_item_number|api_source_id|api_item_number)_/, '');
if (input.name.indexOf('region_rss_') === 0) {
var rssFeed = input.name.indexOf('region_rss_feed_id_') === 0 ? input.value : (templateFields.querySelector('select[name="region_rss_feed_id_' + regionId + '"]') || {}).value;
updateRssPlaceholderChips(regionId, rssFeed);
}
if (input.name.indexOf('region_api_') === 0) {
var apiSource = input.name.indexOf('region_api_source_id_') === 0 ? input.value : (templateFields.querySelector('select[name="region_api_source_id_' + regionId + '"]') || {}).value;
updateApiPlaceholderChips(regionId, apiSource);
}
templateSelectorLock.markEdited();
requestPreviewRender();
});
});
if (existingTemplateId) {
templateSelectorLock.arm();
templateSelectorLock.markEdited();
requestPreviewRender();
return;
}
window.requestAnimationFrame(function () {
templateSelectorLock.arm();
});
@@ -1001,6 +1467,8 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
return;
}
event.preventDefault();
var slideFormUrl = new URL(slideForm.action, window.location.href);
var isCreateForm = slideFormUrl.pathname === '/slides';
var submitterValue = event.submitter && event.submitter.name === 'save_action'
? String(event.submitter.value || '').trim().toLowerCase()
: '';
@@ -1011,6 +1479,7 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
if (!editor || !hidden) {
return Promise.resolve();
}
syncEditorFontSizeHidden(regionId, editor);
hidden.value = editor.getData();
return Promise.resolve();
});
@@ -1043,6 +1512,20 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
return;
}
if (isCreateForm) {
try {
if (response.url) {
window.location.replace(response.url);
return;
}
} catch (_error) {
// Fall through and show a toast if the redirect URL cannot be resolved.
}
slideForm.dataset.dirty = 'false';
showToast('Saved slide.', 'success');
return;
}
var responseText = await response.text();
var savedMessage = '';
try {
@@ -1057,6 +1540,13 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
showToast(savedMessage || 'Saved slide.', 'success');
slideForm.dataset.dirty = 'false';
} catch (error) {
if (typeof showToast === 'function') {
var variant = Number(error && error.status) >= 400 && Number(error && error.status) < 500 ? 'warning' : 'danger';
showToast(error && error.message ? error.message : 'Unable to save slide.', variant);
} else {
window.alert(error && error.message ? error.message : 'Unable to save slide.');
}
} finally {
submitting = false;
}
@@ -1105,3 +1595,4 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
}
});
})();
+2 -2
View File
@@ -58,7 +58,7 @@
function connect() {
var protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
socket = new WebSocket(protocol + '//' + window.location.host + '/ws/admin/dashboard');
socket = new WebSocket(protocol + '//' + window.location.host + '/ws/dashboard');
updateSidebarStatus('unknown', 'Connecting to player feed');
socket.onmessage = function (event) {
@@ -98,4 +98,4 @@
}
connectDashboardSocket();
}());
}());
+1 -1
View File
@@ -230,7 +230,7 @@
}
function getRegionChipLabel(regionType) {
return regionType === 'image' ? 'Image' : regionType === 'webpage' ? 'Webpage' : regionType === 'html' ? 'HTML' : 'Text';
return regionType === 'image' ? 'Image' : regionType === 'webpage' ? 'Webpage' : regionType === 'html' ? 'HTML' : regionType === 'rss' ? 'RSS' : 'Text';
}
function populateRegionCard(card, region) {
@@ -12,6 +12,62 @@
return value === undefined || value === null || value === '' ? fallback : value;
}
function normalizeLockRatio(value) {
var raw = String(value || '').trim();
if (!raw) {
return '';
}
if (!/^\d+\s*:\s*\d+$/.test(raw)) {
return '';
}
return raw.replace(/\s+/g, '');
}
function parseLockRatio(value) {
var normalized = normalizeLockRatio(value);
if (!normalized) {
return null;
}
var parts = normalized.split(':');
var width = Number(parts[0]);
var height = Number(parts[1]);
if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) {
return null;
}
return {
label: normalized,
ratio: width / height
};
}
function getDefaultRegionSize(regionType, lockRatio) {
var ratio = parseLockRatio(lockRatio);
var locked = regionType === 'image' || regionType === 'webpage' || regionType === 'html' || regionType === 'rtmp' || regionType === 'rss' || regionType === 'api';
if (ratio) {
if (ratio.ratio >= 1) {
var baseWidth = locked ? 420 : 300;
return {
width: Math.max(12, Math.round(baseWidth)),
height: Math.max(12, Math.round(baseWidth / ratio.ratio))
};
}
var baseHeight = locked ? 300 : 240;
return {
width: Math.max(12, Math.round(baseHeight * ratio.ratio)),
height: Math.max(12, Math.round(baseHeight))
};
}
return {
width: locked ? 420 : 300,
height: locked ? 240 : 120
};
}
function clamp(value, min, max) {
return Math.max(min, Math.min(max, value));
}
@@ -34,6 +90,7 @@
label: name,
region_type: card.querySelector('[name="region_type[]"]').value,
font_family: card.querySelector('[name="font_family[]"]').value,
lock_ratio: normalizeLockRatio(card.querySelector('[name="region_lock_ratio[]"]').value),
x: Number(card.querySelector('[name="region_x[]"]').value || 0),
y: Number(card.querySelector('[name="region_y[]"]').value || 0),
width: Number(card.querySelector('[name="region_width[]"]').value || 0),
@@ -52,6 +109,7 @@
}
if (values.region_type !== undefined) { card.querySelector('[name="region_type[]"]').value = values.region_type; }
if (values.font_family !== undefined) { card.querySelector('[name="font_family[]"]').value = values.font_family; }
if (values.lock_ratio !== undefined) { card.querySelector('[name="region_lock_ratio[]"]').value = normalizeLockRatio(values.lock_ratio); }
if (values.x !== undefined) { card.querySelector('[name="region_x[]"]').value = Math.round(values.x); }
if (values.y !== undefined) { card.querySelector('[name="region_y[]"]').value = Math.round(values.y); }
if (values.width !== undefined) { card.querySelector('[name="region_width[]"]').value = Math.round(values.width); }
@@ -112,6 +170,9 @@
clampRegion: clampRegion,
getOverlayRect: getOverlayRect,
toCanvasPoint: toCanvasPoint,
canvasRectToPixels: canvasRectToPixels
canvasRectToPixels: canvasRectToPixels,
normalizeLockRatio: normalizeLockRatio,
parseLockRatio: parseLockRatio,
getDefaultRegionSize: getDefaultRegionSize
};
}());
@@ -59,6 +59,44 @@
return utils.clamp ? utils.clamp(value, min, max) : Math.max(min, Math.min(max, value));
}
function normalizeLockRatio(value) {
return utils.normalizeLockRatio ? utils.normalizeLockRatio(value) : String(value || '').trim().replace(/\s+/g, '');
}
function parseLockRatio(value) {
if (utils.parseLockRatio) {
return utils.parseLockRatio(value);
}
var normalized = normalizeLockRatio(value);
if (!normalized || !/^\d+:\d+$/.test(normalized)) {
return null;
}
var parts = normalized.split(':');
var width = Number(parts[0]);
var height = Number(parts[1]);
if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) {
return null;
}
return { label: normalized, ratio: width / height };
}
function getDefaultRegionSize(regionType, lockRatio) {
if (utils.getDefaultRegionSize) {
return utils.getDefaultRegionSize(regionType, lockRatio);
}
var ratio = parseLockRatio(lockRatio);
var locked = regionType === 'image' || regionType === 'webpage' || regionType === 'html' || regionType === 'rtmp' || regionType === 'rss';
if (ratio) {
if (ratio.ratio >= 1) {
var baseWidth = locked ? 420 : 300;
return { width: Math.max(12, Math.round(baseWidth)), height: Math.max(12, Math.round(baseWidth / ratio.ratio)) };
}
var baseHeight = locked ? 300 : 240;
return { width: Math.max(12, Math.round(baseHeight * ratio.ratio)), height: Math.max(12, Math.round(baseHeight)) };
}
return { width: locked ? 420 : 300, height: locked ? 240 : 120 };
}
function getCards() {
return Array.prototype.slice.call(regionList.querySelectorAll('.region-item'));
}
@@ -126,6 +164,7 @@
label: getRegionName(card),
region_type: card.querySelector('[name="region_type[]"]').value,
font_family: card.querySelector('[name="font_family[]"]').value,
lock_ratio: normalizeLockRatio(card.querySelector('[name="region_lock_ratio[]"]').value),
x: Number(card.querySelector('[name="region_x[]"]').value || 0),
y: Number(card.querySelector('[name="region_y[]"]').value || 0),
width: Number(card.querySelector('[name="region_width[]"]').value || 0),
@@ -148,6 +187,7 @@
}
if (values.region_type !== undefined) { card.querySelector('[name="region_type[]"]').value = values.region_type; }
if (values.font_family !== undefined) { card.querySelector('[name="font_family[]"]').value = values.font_family; }
if (values.lock_ratio !== undefined) { card.querySelector('[name="region_lock_ratio[]"]').value = normalizeLockRatio(values.lock_ratio); }
if (values.x !== undefined) { card.querySelector('[name="region_x[]"]').value = Math.round(values.x); }
if (values.y !== undefined) { card.querySelector('[name="region_y[]"]').value = Math.round(values.y); }
if (values.width !== undefined) { card.querySelector('[name="region_width[]"]').value = Math.round(values.width); }
@@ -229,8 +269,50 @@
stage.style.backgroundColor = backgroundColorInput && backgroundColorInput.value ? backgroundColorInput.value : '#111111';
}
function updateRegionLockBadge(card) {
var lockChip = card.querySelector('[data-region-lock-chip]');
if (!lockChip) {
return;
}
var lockRatio = normalizeLockRatio(card.querySelector('[name="region_lock_ratio[]"]').value);
if (!lockRatio) {
lockChip.hidden = true;
lockChip.textContent = '';
return;
}
lockChip.hidden = false;
lockChip.textContent = 'Locked ' + lockRatio;
}
function getRegionLockRatio(card) {
return normalizeLockRatio(card.querySelector('[name="region_lock_ratio[]"]').value);
}
function isRegionLocked(card) {
return Boolean(getRegionLockRatio(card));
}
function syncLockedDimensions(card, changedField) {
var lockRatio = getRegionLockRatio(card);
var aspect = lockRatio ? parseLockRatio(lockRatio) : null;
if (!aspect) {
return;
}
var widthInput = card.querySelector('[name="region_width[]"]');
var heightInput = card.querySelector('[name="region_height[]"]');
var width = Math.max(1, Number(widthInput.value || 0));
var height = Math.max(1, Number(heightInput.value || 0));
if (changedField === 'width') {
heightInput.value = Math.max(1, Math.round(width / aspect.ratio));
} else if (changedField === 'height') {
widthInput.value = Math.max(1, Math.round(height * aspect.ratio));
}
}
function getRegionChipLabel(regionType) {
return regionType === 'image' ? 'Image' : regionType === 'webpage' ? 'Webpage' : regionType === 'html' ? 'HTML' : 'Text';
return regionType === 'image' ? 'Image' : regionType === 'webpage' ? 'Webpage' : regionType === 'html' ? 'HTML' : regionType === 'rtmp' ? 'RTMP' : regionType === 'rss' ? 'RSS' : 'Text';
}
function populateRegionCard(card, region) {
@@ -252,7 +334,7 @@
nameInput.value = region.region_key || region.label || '';
}
if (fontFamilyInput) {
fontFamilyInput.value = region.region_type === 'image' ? '' : (region.font_family || 'Arial');
fontFamilyInput.value = region.region_type === 'image' || region.region_type === 'rtmp' ? '' : (region.font_family || 'Arial');
}
if (regionTypeInput) {
regionTypeInput.value = region.region_type || 'text';
@@ -263,11 +345,16 @@
if (regionLabelInput) {
regionLabelInput.value = region.label || region.region_key || '';
}
var regionLockRatioInput = card.querySelector('[name="region_lock_ratio[]"]');
if (regionLockRatioInput) {
regionLockRatioInput.value = normalizeLockRatio(region.lock_ratio);
}
card.querySelector('[name="region_x[]"]').value = valueOrDefault(region.x, 80);
card.querySelector('[name="region_y[]"]').value = valueOrDefault(region.y, 80);
card.querySelector('[name="region_z[]"]').value = valueOrDefault(region.z_index, 1);
card.querySelector('[name="region_width[]"]').value = valueOrDefault(region.width, 300);
card.querySelector('[name="region_height[]"]').value = valueOrDefault(region.height, 120);
updateRegionLockBadge(card);
}
function updateRegionLabel(card) {
@@ -293,6 +380,9 @@
}
populateRegionCard(card, region);
var nameInput = card.querySelector('[name="region_name[]"]');
var lockRatioInput = card.querySelector('[name="region_lock_ratio[]"]');
var widthInput = card.querySelector('[name="region_width[]"]');
var heightInput = card.querySelector('[name="region_height[]"]');
nameInput.addEventListener('input', function () {
syncRegionIdentity(card, nameInput.value);
updateRegionLabel(card);
@@ -300,6 +390,30 @@
renderRegionSidebar();
renderOverlay();
});
if (lockRatioInput) {
lockRatioInput.addEventListener('input', function () {
updateRegionLockBadge(card);
requestOverlayRender();
});
}
if (widthInput) {
widthInput.addEventListener('input', function () {
if (isRegionLocked(card)) {
syncLockedDimensions(card, 'width');
updateRegionLockBadge(card);
}
requestOverlayRender();
});
}
if (heightInput) {
heightInput.addEventListener('input', function () {
if (isRegionLocked(card)) {
syncLockedDimensions(card, 'height');
updateRegionLockBadge(card);
}
requestOverlayRender();
});
}
card.addEventListener('click', function (event) {
if (event.target && event.target.classList && event.target.classList.contains('remove-region')) {
return;
@@ -423,15 +537,17 @@
function createDefaultRegion(type) {
var count = getCards().length + 1;
var name = 'region_' + count;
var size = getDefaultRegionSize(type, '');
return {
region_key: name,
label: name,
region_type: type,
font_family: type === 'text' || type === 'html' ? 'Arial' : '',
font_family: type === 'text' || type === 'html' || type === 'rss' || type === 'api' ? 'Arial' : '',
x: 80,
y: 80,
width: type === 'image' || type === 'webpage' || type === 'html' ? 420 : 300,
height: type === 'image' || type === 'webpage' || type === 'html' ? 240 : 120,
width: size.width,
height: size.height,
lock_ratio: '',
z_index: 1
};
}
@@ -499,15 +615,69 @@
function resizeFromHandle(index, dir, event) {
var startPoint = toCanvasPoint(event);
var startRegion = readCard(cardAt(index));
var lockRatio = normalizeLockRatio(startRegion.lock_ratio);
var aspect = lockRatio ? parseLockRatio(lockRatio) : null;
function fitFromWidth(width) {
var nextWidth = Math.max(12, Math.round(width));
return {
width: nextWidth,
height: aspect ? Math.max(12, Math.round(nextWidth / aspect.ratio)) : startRegion.height
};
}
function fitFromHeight(height) {
var nextHeight = Math.max(12, Math.round(height));
return {
width: aspect ? Math.max(12, Math.round(nextHeight * aspect.ratio)) : startRegion.width,
height: nextHeight
};
}
function moveHandler(moveEvent) {
var currentPoint = toCanvasPoint(moveEvent);
var dx = currentPoint.x - startPoint.x;
var dy = currentPoint.y - startPoint.y;
var next = { x: startRegion.x, y: startRegion.y, width: startRegion.width, height: startRegion.height };
if (dir.indexOf('w') !== -1) { next.x = startRegion.x + dx; next.width = startRegion.width - dx; }
if (dir.indexOf('e') !== -1) { next.width = startRegion.width + dx; }
if (dir.indexOf('n') !== -1) { next.y = startRegion.y + dy; next.height = startRegion.height - dy; }
if (dir.indexOf('s') !== -1) { next.height = startRegion.height + dy; }
if (aspect) {
if (dir === 'e') {
var eastSize = fitFromWidth(startRegion.width + dx);
next.width = eastSize.width;
next.height = eastSize.height;
next.y = startRegion.y + Math.round((startRegion.height - next.height) / 2);
} else if (dir === 'w') {
var westSize = fitFromWidth(startRegion.width - dx);
next.width = westSize.width;
next.height = westSize.height;
next.x = startRegion.x + startRegion.width - next.width;
next.y = startRegion.y + Math.round((startRegion.height - next.height) / 2);
} else if (dir === 'n') {
var northSize = fitFromHeight(startRegion.height - dy);
next.width = northSize.width;
next.height = northSize.height;
next.x = startRegion.x + Math.round((startRegion.width - next.width) / 2);
next.y = startRegion.y + startRegion.height - next.height;
} else if (dir === 's') {
var southSize = fitFromHeight(startRegion.height + dy);
next.width = southSize.width;
next.height = southSize.height;
next.x = startRegion.x + Math.round((startRegion.width - next.width) / 2);
} else {
var useWidth = Math.abs(dx) >= Math.abs(dy * aspect.ratio);
var cornerSize = useWidth ? fitFromWidth(dir.indexOf('w') !== -1 ? startRegion.width - dx : startRegion.width + dx) : fitFromHeight(dir.indexOf('n') !== -1 ? startRegion.height - dy : startRegion.height + dy);
next.width = cornerSize.width;
next.height = cornerSize.height;
if (dir.indexOf('w') !== -1) { next.x = startRegion.x + startRegion.width - next.width; }
if (dir.indexOf('n') !== -1) { next.y = startRegion.y + startRegion.height - next.height; }
if (dir.indexOf('e') !== -1) { next.x = startRegion.x; }
if (dir.indexOf('s') !== -1) { next.y = startRegion.y; }
}
} else {
if (dir.indexOf('w') !== -1) { next.x = startRegion.x + dx; next.width = startRegion.width - dx; }
if (dir.indexOf('e') !== -1) { next.width = startRegion.width + dx; }
if (dir.indexOf('n') !== -1) { next.y = startRegion.y + dy; next.height = startRegion.height - dy; }
if (dir.indexOf('s') !== -1) { next.height = startRegion.height + dy; }
}
if (next.width < 12) { if (dir.indexOf('w') !== -1) { next.x -= 12 - next.width; } next.width = 12; }
if (next.height < 12) { if (dir.indexOf('n') !== -1) { next.y -= 12 - next.height; } next.height = 12; }
next = clampRegion(next);
@@ -586,6 +756,12 @@
event.preventDefault();
return;
}
getCards().forEach(function (card) {
if (isRegionLocked(card)) {
syncLockedDimensions(card, 'width');
updateRegionLockBadge(card);
}
});
syncCanvasSizeSelection();
event.formData.set('regions_json', JSON.stringify(getCards().map(readCard)));
});
@@ -594,6 +770,12 @@
if (!validateRegionNames()) {
return;
}
getCards().forEach(function (card) {
if (isRegionLocked(card)) {
syncLockedDimensions(card, 'width');
updateRegionLockBadge(card);
}
});
syncCanvasSizeSelection();
regionsJsonInput.value = JSON.stringify(getCards().map(readCard));
});
+61
View File
@@ -1,6 +1,66 @@
(function () {
var storageKey = 'lte-theme';
var theme = 'auto';
var ckeditorThemeStyleId = 'ckeditor-dark-theme-overrides';
function getPreferredTheme() {
if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
return 'dark';
}
return 'light';
}
function getOrCreateCkeditorThemeStyleElement() {
var styleElement = document.getElementById(ckeditorThemeStyleId);
if (styleElement) {
return styleElement;
}
styleElement = document.createElement('style');
styleElement.id = ckeditorThemeStyleId;
document.head.appendChild(styleElement);
return styleElement;
}
function syncCkeditorTheme(currentTheme) {
var styleElement = getOrCreateCkeditorThemeStyleElement();
if (currentTheme !== 'dark') {
styleElement.textContent = '';
return;
}
styleElement.textContent = [
'.ck.ck-dropdown__panel,',
'.ck.ck-list__panel,',
'.ck.ck-list,',
'.ck.ck-balloon-panel {',
' background: var(--bs-body-bg) !important;',
' background-color: var(--bs-body-bg) !important;',
' border-color: var(--bs-border-color) !important;',
' color: var(--bs-body-color) !important;',
'}',
'.ck.ck-list .ck-list-item-button {',
' background: transparent !important;',
' background-color: transparent !important;',
' color: var(--bs-body-color) !important;',
'}',
'.ck.ck-list .ck-list-item-button:hover {',
' background: var(--bs-secondary-bg) !important;',
' background-color: var(--bs-secondary-bg) !important;',
'}',
'.ck.ck-color-grid,',
'.ck.ck-color-grid__tile {',
' color: var(--bs-body-color) !important;',
'}',
'.ck.ck-color-grid__tile {',
' border-color: var(--bs-border-color) !important;',
'}'
].join('\n');
}
try {
var storedTheme = window.localStorage.getItem(storageKey);
@@ -13,4 +73,5 @@
document.documentElement.dataset.bsTheme = theme;
document.documentElement.style.colorScheme = theme;
syncCkeditorTheme(theme === 'auto' ? getPreferredTheme() : theme);
}());
+7 -4
View File
@@ -30,12 +30,12 @@
return;
}
var nextVariant = String(variant || 'success').trim().toLowerCase();
var nextVariant = String(variant || 'info').trim().toLowerCase();
var variants = ['primary', 'secondary', 'success', 'danger', 'warning', 'info', 'light', 'dark'];
variants.forEach(function (value) {
toast.classList.remove('text-bg-' + value);
});
toast.classList.add('text-bg-' + (variants.indexOf(nextVariant) === -1 ? 'success' : nextVariant));
toast.classList.add('text-bg-' + (variants.indexOf(nextVariant) === -1 ? 'info' : nextVariant));
}
function getMessageVariant(message, fallbackVariant) {
@@ -43,7 +43,10 @@
if (/\b(?:unable to|cannot|can't|could not|failed to)\s+delete\b/i.test(text) || /\bdelete\b.*\b(?:before|first)\b/i.test(text) || /\bstill (?:in use|linked|assigned|used)\b/i.test(text)) {
return 'danger';
}
return String(fallbackVariant || 'success').trim().toLowerCase() || 'success';
if (/\b(?:already exists|already exist|already taken|duplicate|must be unique|name already exists)\b/i.test(text)) {
return 'warning';
}
return String(fallbackVariant || 'info').trim().toLowerCase() || 'info';
}
function showToast(message, variant) {
@@ -114,7 +117,7 @@
// ignore URL cleanup failures
}
var existingVariant = String(toast.getAttribute('data-toast-variant') || '').trim().toLowerCase() || getMessageVariant((toast.querySelector('.toast-body') && toast.querySelector('.toast-body').textContent) || '', 'success');
var existingVariant = String(toast.getAttribute('data-toast-variant') || '').trim().toLowerCase() || getMessageVariant((toast.querySelector('.toast-body') && toast.querySelector('.toast-body').textContent) || '', 'info');
setToastVariant(toast, existingVariant);
var instance = getBootstrapToast(toast);
+4 -4
View File
@@ -2,11 +2,11 @@ const { renderView } = require('../../view');
function normalizeReturnUrl(value) {
const url = String(value || '').trim();
if (!url || url === '/admin/account' || url.indexOf('/admin/account?') === 0) {
return '/admin';
if (!url || url === '/account' || url.indexOf('/account?') === 0) {
return '/dashboard';
}
if (!/^\/admin(?:\/|\?|$)/.test(url)) {
return '/admin';
if (!/^\/(?:account|dashboard)(?:\/|\?|$)/.test(url)) {
return '/dashboard';
}
return url;
}
-95
View File
@@ -1,95 +0,0 @@
module.exports = function registerAdminPagesRoutes(app, deps) {
const pool = deps.pool;
const common = deps.common;
const pages = deps.pages;
const buildDashboardState = deps.buildDashboardState;
const requirePermission = deps.requirePermission;
function requireQueryPermission(readPermissionKey, editPermissionKey) {
return function (req, res, next) {
const permissionKey = req.query && req.query.edit ? editPermissionKey : readPermissionKey;
return requirePermission(permissionKey)(req, res, next);
};
}
app.get('/admin', requirePermission('dashboard.read'), async function (req, res, next) {
try {
const data = await buildDashboardState(pool);
res.send(pages.renderDashboardPage(data, req.query.message ? String(req.query.message) : '', req.currentUser));
} catch (error) {
next(error);
}
});
app.get('/admin/clients', requirePermission('clients.read'), async function (req, res, next) {
try {
const data = await buildDashboardState(pool);
res.send(pages.renderConnectedClientsPage(data, req.query.message ? String(req.query.message) : '', req.currentUser));
} catch (error) {
next(error);
}
});
app.get('/admin/screens', requireQueryPermission('screens.read', 'screens.update'), async function (req, res, next) {
try {
const data = await buildDashboardState(pool);
if (req.query.edit) {
const screen = await common.fetchScreenById(pool, Number(req.query.edit));
if (!screen) {
return res.status(404).send('Screen not found');
}
const editData = await common.fetchScreenEditData(pool);
return res.send(pages.renderScreenEditPage(screen, editData, req.query.message ? String(req.query.message) : '', req.currentUser));
}
res.send(pages.renderScreensPage(data, req.query.message ? String(req.query.message) : '', req.currentUser));
} catch (error) {
next(error);
}
});
app.get('/admin/screens/:id/edit', requirePermission('screens.update'), async function (req, res, next) {
try {
const screen = await common.fetchScreenById(pool, Number(req.params.id));
if (!screen) {
return res.status(404).send('Screen not found');
}
const editData = await common.fetchScreenEditData(pool);
return res.send(pages.renderScreenEditPage(screen, editData, req.query.message ? String(req.query.message) : '', req.currentUser));
} catch (error) {
next(error);
}
});
app.get('/admin/playlists', requireQueryPermission('playlists.read', 'playlists.update'), async function (req, res, next) {
try {
const data = await common.fetchAdminData(pool);
if (req.query.edit) {
const playlist = await common.fetchPlaylistById(pool, Number(req.query.edit));
if (!playlist) {
return res.status(404).send('Playlist not found');
}
return res.send(pages.renderPlaylistEditPage(playlist, data, req.query.message ? String(req.query.message) : '', req.currentUser));
}
res.send(pages.renderPlaylistsPage(data, req.query.message ? String(req.query.message) : '', req.currentUser));
} catch (error) {
next(error);
}
});
app.get('/admin/playlists/new', requirePermission('playlists.create'), function (req, res) {
res.send(pages.renderPlaylistFormPage(req.query.message ? String(req.query.message) : '', req.currentUser));
});
app.get('/admin/playlists/:id/edit', requirePermission('playlists.update'), async function (req, res, next) {
try {
const playlist = await common.fetchPlaylistById(pool, Number(req.params.id));
if (!playlist) {
return res.status(404).send('Playlist not found');
}
const data = await common.fetchAdminData(pool);
res.send(pages.renderPlaylistEditPage(playlist, data, req.query.message ? String(req.query.message) : '', req.currentUser));
} catch (error) {
next(error);
}
});
};
-422
View File
@@ -1,422 +0,0 @@
module.exports = function registerAdminRbacRoutes(app, deps) {
const pool = deps.pool;
const pages = deps.pages;
const getAuditUserId = deps.getAuditUserId;
const rbacData = deps.rbacData;
const permissions = Array.isArray(deps.permissions) ? deps.permissions : [];
const readArrayField = deps.readArrayField;
const normalizePermissionKeys = deps.normalizePermissionKeys;
const requirePermission = deps.requirePermission;
function slugifyRoleKey(name) {
const value = String(name || '').trim().toLowerCase();
const slug = value.replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
return slug || 'role';
}
function normalizeSelectedIds(values) {
return Array.from(new Set((Array.isArray(values) ? values : []).map(function (value) {
return Number(value);
}).filter(function (value) {
return Number.isInteger(value) && value > 0;
})));
}
function getActionLabel(actionKey) {
const normalizedActionKey = String(actionKey || '').trim().toLowerCase();
if (normalizedActionKey === 'edit') {
return 'Update';
}
if (normalizedActionKey === 'allow') {
return 'Allow';
}
if (!normalizedActionKey) {
return 'Read';
}
return normalizedActionKey.charAt(0).toUpperCase() + normalizedActionKey.slice(1);
}
async function createUniqueRoleKey(baseName) {
const baseKey = slugifyRoleKey(baseName);
let candidate = baseKey;
let suffix = 2;
while (true) {
const [rows] = await pool.query('SELECT id FROM roles WHERE role_key = ? LIMIT 1', [candidate]);
if (!rows.length) {
return candidate;
}
candidate = `${baseKey}-${suffix}`;
suffix += 1;
}
}
function mapPermissionsForView(permissionRows, selectedPermissionKeys) {
const selectedKeys = new Set(normalizePermissionKeys(selectedPermissionKeys));
const permissionDefinitions = new Map(permissions.map(function (permission) {
return [String(permission.key || '').trim(), permission];
}));
return (Array.isArray(permissionRows) ? permissionRows : []).map(function (permission) {
const definition = permissionDefinitions.get(String(permission.permission_key || '').trim()) || null;
return Object.assign({}, permission, {
resourceKey: definition ? definition.sectionKey : String(permission.section_name || '').trim().toLowerCase().replace(/[^a-z0-9]+/g, '-'),
resourceName: definition ? definition.name : permission.section_name,
categoryName: definition ? definition.sectionName : permission.section_name,
sectionOrder: definition ? definition.sectionOrder : 999,
actionKey: definition ? definition.actionKey : 'read',
actionLabel: definition ? definition.actionName : getActionLabel(permission.actionKey),
isSelected: selectedKeys.has(String(permission.permission_key || '').trim())
});
});
}
function mapUsersForView(userRows, selectedUserIds) {
const selectedIds = new Set(normalizeSelectedIds(selectedUserIds));
return (Array.isArray(userRows) ? userRows : []).map(function (user) {
return Object.assign({}, user, {
isSelected: selectedIds.has(Number(user.id))
});
});
}
function buildPermissionGroups(permissionRows) {
const groups = [];
const groupIndex = new Map();
(Array.isArray(permissionRows) ? permissionRows : []).forEach(function (permission) {
const sectionKey = String(permission.resourceKey || permission.sectionKey || permission.sectionName || '').trim().toLowerCase().replace(/[^a-z0-9]+/g, '-');
if (!groupIndex.has(sectionKey)) {
const group = {
id: sectionKey || 'permissions',
title: String(permission.resourceName || permission.categoryName || 'Permissions').trim(),
categoryName: String(permission.categoryName || '').trim(),
sectionOrder: Number(permission.sectionOrder) || 999,
permissions: []
};
groupIndex.set(sectionKey, group);
groups.push(group);
}
groupIndex.get(sectionKey).permissions.push(permission);
});
groups.forEach(function (group) {
group.permissions.sort(function (left, right) {
const actionOrder = { create: 1, read: 2, update: 3, edit: 3, delete: 4, allow: 5 };
const leftOrder = actionOrder[String(left.actionKey || '').trim()] || 99;
const rightOrder = actionOrder[String(right.actionKey || '').trim()] || 99;
if (leftOrder !== rightOrder) {
return leftOrder - rightOrder;
}
return String(left.name || '').localeCompare(String(right.name || ''));
});
});
groups.sort(function (left, right) {
const leftOrder = Number(left.sectionOrder) || 999;
const rightOrder = Number(right.sectionOrder) || 999;
if (leftOrder !== rightOrder) {
return leftOrder - rightOrder;
}
return String(left.title || '').localeCompare(String(right.title || ''));
});
return groups;
}
async function buildRoleCreateViewModel(formValues, selectedPermissionKeys) {
const permissionRows = await rbacData.fetchPermissions(pool);
return {
formValues: {
name: String(formValues && formValues.name || '').trim(),
description: String(formValues && formValues.description || '').trim()
},
permissionGroups: buildPermissionGroups(mapPermissionsForView(permissionRows, selectedPermissionKeys))
};
}
async function loadRoleViewModel(roleId) {
const role = await rbacData.fetchRoleById(pool, roleId);
if (!role) {
return null;
}
const permissionRows = await rbacData.fetchPermissions(pool);
const selectedPermissionKeys = await rbacData.fetchRolePermissionKeys(pool, role.id);
const selectedUserIds = await rbacData.fetchRoleUserIds(pool, role.id);
const users = await rbacData.fetchUsersWithRoles(pool);
return {
role: Object.assign({}, role, {
permissionKeys: selectedPermissionKeys,
permissionCount: Number(role.permission_count) || 0,
userCount: Number(role.user_count) || 0
}),
permissionGroups: buildPermissionGroups(mapPermissionsForView(permissionRows, selectedPermissionKeys)),
users: mapUsersForView(users, selectedUserIds)
};
}
app.get('/admin/rbac', requirePermission('rbac.read'), async function (req, res, next) {
try {
const roles = await rbacData.fetchRoles(pool);
res.send(pages.renderRbacPage({ roles: roles }, req.query.message ? String(req.query.message) : '', req.currentUser));
} catch (error) {
next(error);
}
});
app.get('/admin/rbac/new', requirePermission('rbac.create'), function (req, res, next) {
buildRoleCreateViewModel({
name: String(req.query.name || '').trim(),
description: String(req.query.description || '').trim()
}, []).then(function (viewModel) {
res.send(pages.renderRbacAddPage(req.query.message ? String(req.query.message) : '', req.currentUser, viewModel.formValues, viewModel.permissionGroups, 'primary'));
}).catch(function (error) {
next(error);
});
});
app.post('/admin/rbac', requirePermission('rbac.create'), async function (req, res, next) {
try {
const name = String(req.body.name || '').trim();
const description = String(req.body.description || '').trim();
const selectedPermissionKeys = normalizePermissionKeys(Array.isArray(req.body['permission_keys[]'])
? req.body['permission_keys[]']
: req.body.permission_keys
? [].concat(req.body.permission_keys)
: []);
const validPermissionKeys = new Set(permissions.map(function (permission) {
return String(permission.key || '').trim();
}));
const createViewModel = await buildRoleCreateViewModel({ name: name, description: description }, selectedPermissionKeys);
if (!name) {
return res.status(400).send(pages.renderRbacAddPage('Role name is required.', req.currentUser, createViewModel.formValues, createViewModel.permissionGroups, 'warning'));
}
if (selectedPermissionKeys.some(function (permissionKey) {
return !validPermissionKeys.has(permissionKey);
})) {
return res.status(400).send(pages.renderRbacAddPage('One or more selected permissions are invalid.', req.currentUser, createViewModel.formValues, createViewModel.permissionGroups, 'warning'));
}
const roleKey = await createUniqueRoleKey(name);
const actorId = getAuditUserId(req);
const connection = await pool.getConnection();
try {
await connection.beginTransaction();
const [result] = await connection.query(
'INSERT INTO roles (role_key, name, description, created_by, modified_by) VALUES (?, ?, ?, ?, ?)',
[roleKey, name, description || null, actorId, actorId]
);
if (selectedPermissionKeys.length) {
await rbacData.syncRolePermissions(connection, Number(result.insertId), selectedPermissionKeys);
}
await connection.commit();
} catch (error) {
await connection.rollback();
throw error;
} finally {
connection.release();
}
res.redirect('/admin/rbac?message=' + encodeURIComponent('Role created.'));
} catch (error) {
next(error);
}
});
app.get('/admin/rbac/:id/edit', requirePermission('rbac.update'), async function (req, res, next) {
try {
const roleId = Number(req.params.id);
if (!Number.isInteger(roleId) || roleId <= 0) {
return res.status(400).send('Invalid role.');
}
const viewModel = await loadRoleViewModel(roleId);
if (!viewModel) {
return res.status(404).send('Role not found.');
}
const currentUserId = req.currentUser ? Number(req.currentUser.id) : null;
const users = Array.isArray(viewModel.users)
? viewModel.users.filter(function (user) {
return Number(user && user.id) !== currentUserId;
})
: [];
res.send(pages.renderRbacEditPage(viewModel.role, req.query.message ? String(req.query.message) : '', req.currentUser, viewModel.permissionGroups, users));
} catch (error) {
next(error);
}
});
app.post('/admin/rbac/:id', requirePermission('rbac.update'), async function (req, res, next) {
try {
const roleId = Number(req.params.id);
if (!Number.isInteger(roleId) || roleId <= 0) {
return res.status(400).send('Invalid role.');
}
const role = await rbacData.fetchRoleById(pool, roleId);
if (!role) {
return res.status(404).send('Role not found.');
}
const name = String(req.body.name || '').trim();
const description = String(req.body.description || '').trim();
const shouldSyncPermissions = Object.prototype.hasOwnProperty.call(req.body || {}, 'permissions_present');
const shouldSyncUsers = Object.prototype.hasOwnProperty.call(req.body || {}, 'users_present');
const selectedPermissionKeys = shouldSyncPermissions
? readArrayField(req.body, ['permission_keys[]', 'permission_keys'])
: [];
const selectedUserIds = shouldSyncUsers
? readArrayField(req.body, ['user_ids[]', 'user_ids'])
: [];
const normalizedPermissionKeys = shouldSyncPermissions ? normalizePermissionKeys(selectedPermissionKeys) : [];
const normalizedUserIds = shouldSyncUsers ? normalizeSelectedIds(selectedUserIds) : [];
if (!name) {
return res.redirect('/admin/rbac/' + roleId + '/edit?message=' + encodeURIComponent('Role name is required.'));
}
const validPermissionKeys = new Set(permissions.map(function (permission) {
return String(permission.key || '').trim();
}));
if (shouldSyncPermissions && normalizedPermissionKeys.some(function (permissionKey) {
return !validPermissionKeys.has(permissionKey);
})) {
return res.redirect('/admin/rbac/' + roleId + '/edit?message=' + encodeURIComponent('One or more selected permissions are invalid.'));
}
let availableUsers = [];
if (shouldSyncUsers) {
availableUsers = await rbacData.fetchUsersWithRoles(pool);
}
const validUserIds = new Set(availableUsers.map(function (user) {
return Number(user.id);
}));
if (shouldSyncUsers && normalizedUserIds.some(function (userId) {
return !validUserIds.has(userId);
})) {
return res.redirect('/admin/rbac/' + roleId + '/edit?message=' + encodeURIComponent('One or more selected users are invalid.'));
}
const connection = await pool.getConnection();
try {
await connection.beginTransaction();
await connection.query(
'UPDATE roles SET name = ?, description = ?, modified_by = ? WHERE id = ?',
[name, description || null, getAuditUserId(req), roleId]
);
if (shouldSyncPermissions) {
await rbacData.syncRolePermissions(connection, roleId, normalizedPermissionKeys);
}
if (shouldSyncUsers) {
await rbacData.syncRoleUsers(connection, roleId, normalizedUserIds);
}
await connection.commit();
} catch (error) {
await connection.rollback();
throw error;
} finally {
connection.release();
}
res.redirect('/admin/rbac/' + roleId + '/edit?message=' + encodeURIComponent('Role updated.'));
} catch (error) {
next(error);
}
});
app.post('/admin/rbac/:id/permissions', requirePermission('rbac.update'), async function (req, res, next) {
try {
const roleId = Number(req.params.id);
if (!Number.isInteger(roleId) || roleId <= 0) {
return res.status(400).send('Invalid role.');
}
const role = await rbacData.fetchRoleById(pool, roleId);
if (!role) {
return res.status(404).send('Role not found.');
}
const selectedPermissionKeys = Array.isArray(req.body['permission_keys[]'])
? req.body['permission_keys[]']
: req.body.permission_keys
? [].concat(req.body.permission_keys)
: [];
const validPermissionKeys = new Set(permissions.map(function (permission) {
return String(permission.key || '').trim();
}));
const normalizedPermissionKeys = normalizePermissionKeys(selectedPermissionKeys);
if (normalizedPermissionKeys.some(function (permissionKey) {
return !validPermissionKeys.has(permissionKey);
})) {
return res.redirect('/admin/rbac/' + roleId + '/edit?message=' + encodeURIComponent('One or more selected permissions are invalid.'));
}
await rbacData.syncRolePermissions(pool, roleId, normalizedPermissionKeys);
res.redirect('/admin/rbac/' + roleId + '/edit?message=' + encodeURIComponent('Permissions updated.'));
} catch (error) {
next(error);
}
});
app.post('/admin/rbac/:id/users', requirePermission('rbac.update'), async function (req, res, next) {
try {
const roleId = Number(req.params.id);
if (!Number.isInteger(roleId) || roleId <= 0) {
return res.status(400).send('Invalid role.');
}
const role = await rbacData.fetchRoleById(pool, roleId);
if (!role) {
return res.status(404).send('Role not found.');
}
const selectedUserIds = Array.isArray(req.body['user_ids[]'])
? req.body['user_ids[]']
: req.body.user_ids
? [].concat(req.body.user_ids)
: [];
const normalizedUserIds = normalizeSelectedIds(selectedUserIds);
const availableUsers = await rbacData.fetchUsersWithRoles(pool);
const validUserIds = new Set(availableUsers.map(function (user) {
return Number(user.id);
}));
if (normalizedUserIds.some(function (userId) {
return !validUserIds.has(userId);
})) {
return res.redirect('/admin/rbac/' + roleId + '/edit?message=' + encodeURIComponent('One or more selected users are invalid.'));
}
await rbacData.syncRoleUsers(pool, roleId, normalizedUserIds);
res.redirect('/admin/rbac/' + roleId + '/edit?message=' + encodeURIComponent('Users updated.'));
} catch (error) {
next(error);
}
});
app.post('/admin/rbac/:id/delete', requirePermission('rbac.delete'), async function (req, res, next) {
try {
const roleId = Number(req.params.id);
if (!Number.isInteger(roleId) || roleId <= 0) {
return res.status(400).send('Invalid role.');
}
const role = await rbacData.fetchRoleById(pool, roleId);
if (!role) {
return res.status(404).send('Role not found.');
}
if (String(role.role_key || '') === 'administrators') {
return res.redirect('/admin/rbac?message=' + encodeURIComponent('The built-in Administrators role cannot be deleted.'));
}
if (Number(role.user_count) > 0) {
return res.redirect('/admin/rbac?message=' + encodeURIComponent('Remove all users from this role before deleting it.'));
}
await pool.query('DELETE FROM roles WHERE id = ?', [roleId]);
res.redirect('/admin/rbac?message=' + encodeURIComponent('Role deleted.'));
} catch (error) {
next(error);
}
});
};
-289
View File
@@ -1,289 +0,0 @@
module.exports = function registerAdminUsersRoutes(app, deps) {
const pool = deps.pool;
const pages = deps.pages;
const formatDashboardDate = deps.formatDashboardDate;
const getAuditUserId = deps.getAuditUserId;
const hashPassword = deps.hashPassword;
const readArrayField = deps.readArrayField;
const rbacData = deps.rbacData;
const requirePermission = deps.requirePermission;
async function fetchRoleOptions() {
return rbacData.fetchRoles(pool);
}
function mapRolesForForm(roles, selectedRoleIds) {
const selectedIds = new Set((Array.isArray(selectedRoleIds) ? selectedRoleIds : []).map(function (roleId) {
return Number(roleId);
}).filter(function (roleId) {
return Number.isInteger(roleId) && roleId > 0;
}));
return (Array.isArray(roles) ? roles : []).map(function (role) {
return Object.assign({}, role, {
isSelected: selectedIds.has(Number(role.id))
});
});
}
async function validateRoleIds(roleIds) {
const availableRoles = await fetchRoleOptions();
const validRoleIds = new Set(availableRoles.map(function (role) {
return Number(role.id);
}));
const normalizedRoleIds = Array.from(new Set((Array.isArray(roleIds) ? roleIds : []).map(function (roleId) {
return Number(roleId);
}).filter(function (roleId) {
return Number.isInteger(roleId) && roleId > 0;
})));
if (!normalizedRoleIds.length) {
return { ok: false, message: 'Select at least one role.' };
}
if (normalizedRoleIds.some(function (roleId) {
return !validRoleIds.has(roleId);
})) {
return { ok: false, message: 'One or more selected roles are invalid.' };
}
return { ok: true, roleIds: normalizedRoleIds };
}
app.get('/admin/users', requirePermission('users.read'), async function (req, res, next) {
try {
const users = await rbacData.fetchUsersWithRoles(pool);
const mappedUsers = users.map(function (user) {
return Object.assign({}, user, {
isCurrentUser: Number(user.id) === Number(req.currentUser.id),
createdAtLabel: formatDashboardDate(user.created_at),
modifiedAtLabel: formatDashboardDate(user.modified_at),
roleNames: String(user.roleNames || '').trim() || 'No roles assigned'
});
});
res.send(pages.renderUsersPage({ users: mappedUsers }, req.query.message ? String(req.query.message) : '', req.currentUser));
} catch (error) {
next(error);
}
});
app.get('/admin/users/new', requirePermission('users.create'), function (req, res) {
fetchRoleOptions().then(function (roles) {
res.send(pages.renderUsersAddPage(req.query.message ? String(req.query.message) : '', req.currentUser, mapRolesForForm(roles, []), {}, 'primary'));
}).catch(function (error) {
res.status(500).send(error.message || 'Unable to load roles.');
});
});
app.get('/admin/users/:id/edit', requirePermission('users.update'), async function (req, res, next) {
try {
const userId = Number(req.params.id);
if (!Number.isInteger(userId) || userId <= 0) {
return res.status(400).send('Invalid user.');
}
const user = await rbacData.fetchUserWithRoles(pool, userId);
if (!user) {
return res.status(404).send('User not found.');
}
if (Number(req.currentUser.id) === userId) {
return res.redirect('/admin/account?message=' + encodeURIComponent('Use My Account to update your own login details.'));
}
const roles = await fetchRoleOptions();
res.send(pages.renderUsersEditPage(Object.assign({}, user, {
isCurrentUser: false,
createdAtLabel: formatDashboardDate(user.created_at),
modifiedAtLabel: formatDashboardDate(user.modified_at),
roleNames: String(user.roleNames || '').trim() || 'No roles assigned'
}), req.query.message ? String(req.query.message) : '', req.currentUser, mapRolesForForm(roles, user.roleIds)));
} catch (error) {
next(error);
}
});
app.post('/admin/users', requirePermission('users.create'), async function (req, res, next) {
const connection = await pool.getConnection();
try {
const name = String(req.body.name || '').trim();
const username = String(req.body.username || '').trim();
const password = String(req.body.password || '');
const confirmPassword = String(req.body.confirm_password || '');
const selectedRoleIds = readArrayField(req.body, ['role_ids[]', 'role_ids']);
const roleCheck = await validateRoleIds(selectedRoleIds);
const formValues = {
username: username,
name: name
};
async function renderValidationError(message) {
const roles = await fetchRoleOptions();
return res.status(400).send(pages.renderUsersAddPage(message, req.currentUser, mapRolesForForm(roles, selectedRoleIds), formValues, 'warning'));
}
if (!name) {
return renderValidationError('Name is required.');
}
if (!username) {
return renderValidationError('Username is required.');
}
if (!password || password.length < 8) {
return renderValidationError('Password must be at least 8 characters.');
}
if (password !== confirmPassword) {
return renderValidationError('Passwords do not match.');
}
if (!roleCheck.ok) {
return renderValidationError(roleCheck.message);
}
const [existingRows] = await connection.query('SELECT id FROM users WHERE username = ? LIMIT 1', [username]);
if (existingRows.length) {
return renderValidationError('That username already exists.');
}
const passwordRecord = hashPassword(password);
const actorId = getAuditUserId(req);
await connection.beginTransaction();
const [result] = await connection.query(
'INSERT INTO users (name, username, password_hash, password_salt, password_iterations, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?)',
[name, username, passwordRecord.hash, passwordRecord.salt, passwordRecord.iterations, actorId, actorId]
);
await rbacData.syncUserRoles(connection, result.insertId, roleCheck.roleIds);
await connection.commit();
res.redirect('/admin/users?message=' + encodeURIComponent('User created.'));
} catch (error) {
try {
await connection.rollback();
} catch (_rollbackError) {
// Ignore rollback failures and surface the original error.
}
next(error);
} finally {
connection.release();
}
});
app.post('/admin/users/:id/roles', requirePermission('users.update'), async function (req, res, next) {
try {
const userId = Number(req.params.id);
if (!Number.isInteger(userId) || userId <= 0) {
return res.status(400).send('Invalid user.');
}
if (Number(req.currentUser.id) === userId) {
return res.redirect('/admin/account?message=' + encodeURIComponent('Use My Account to update your own login details.'));
}
const [rows] = await pool.query('SELECT id FROM users WHERE id = ? LIMIT 1', [userId]);
if (!rows.length) {
return res.status(404).send('User not found.');
}
const selectedRoleIds = readArrayField(req.body, ['role_ids[]', 'role_ids']);
const roleCheck = await validateRoleIds(selectedRoleIds);
if (!roleCheck.ok) {
return res.redirect('/admin/users/' + userId + '/edit?message=' + encodeURIComponent(roleCheck.message));
}
await rbacData.syncUserRoles(pool, userId, roleCheck.roleIds);
res.redirect('/admin/users/' + userId + '/edit?message=' + encodeURIComponent('Roles updated.'));
} catch (error) {
next(error);
}
});
app.post('/admin/users/:id/username', requirePermission('users.update'), async function (req, res, next) {
try {
const userId = Number(req.params.id);
const name = String(req.body.name || '').trim();
const username = String(req.body.username || '').trim();
if (!Number.isInteger(userId) || userId <= 0) {
return res.status(400).send('Invalid user.');
}
if (!name) {
return res.redirect('/admin/users/' + userId + '/edit?message=' + encodeURIComponent('Name is required.'));
}
if (Number(req.currentUser.id) === userId) {
return res.redirect('/admin/account?message=' + encodeURIComponent('Use My Account to update your own username or password.'));
}
if (!username) {
return res.redirect('/admin/users/' + userId + '/edit?message=' + encodeURIComponent('Username is required.'));
}
const [existingRows] = await pool.query('SELECT id FROM users WHERE username = ? AND id <> ? LIMIT 1', [username, userId]);
if (existingRows.length) {
return res.redirect('/admin/users/' + userId + '/edit?message=' + encodeURIComponent('That username already exists.'));
}
const [result] = await pool.query('UPDATE users SET name = ?, username = ?, modified_by = ? WHERE id = ?', [name, username, getAuditUserId(req), userId]);
if (!result.affectedRows) {
return res.status(404).send('User not found.');
}
res.redirect('/admin/users?message=' + encodeURIComponent('Username updated.'));
} catch (error) {
next(error);
}
});
app.post('/admin/users/:id/password', requirePermission('users.update'), async function (req, res, next) {
try {
const userId = Number(req.params.id);
const password = String(req.body.password || '');
const confirmPassword = String(req.body.confirm_password || '');
if (!Number.isInteger(userId) || userId <= 0) {
return res.status(400).send('Invalid user.');
}
if (Number(req.currentUser.id) === userId) {
return res.redirect('/admin/account?message=' + encodeURIComponent('Use My Account to change your own password.'));
}
if (!password || password.length < 8) {
return res.redirect('/admin/users?message=' + encodeURIComponent('Password must be at least 8 characters.'));
}
if (password !== confirmPassword) {
return res.redirect('/admin/users?message=' + encodeURIComponent('Passwords do not match.'));
}
const [rows] = await pool.query('SELECT id FROM users WHERE id = ? LIMIT 1', [userId]);
if (!rows.length) {
return res.status(404).send('User not found.');
}
const passwordRecord = hashPassword(password);
await pool.query(
'UPDATE users SET password_hash = ?, password_salt = ?, password_iterations = ?, modified_by = ? WHERE id = ?',
[passwordRecord.hash, passwordRecord.salt, passwordRecord.iterations, getAuditUserId(req), userId]
);
await pool.query('DELETE FROM auth_sessions WHERE user_id = ?', [userId]);
res.redirect('/admin/users?message=' + encodeURIComponent('Password updated.'));
} catch (error) {
next(error);
}
});
app.post('/admin/users/:id/delete', requirePermission('users.delete'), async function (req, res, next) {
try {
const userId = Number(req.params.id);
if (!Number.isInteger(userId) || userId <= 0) {
return res.status(400).send('Invalid user.');
}
if (Number(req.currentUser.id) === userId) {
return res.redirect('/admin/users?message=' + encodeURIComponent('You cannot delete your own account from the users page.'));
}
const [countRows] = await pool.query('SELECT COUNT(*) AS user_count FROM users');
if (!countRows.length || Number(countRows[0].user_count) <= 1) {
return res.redirect('/admin/users?message=' + encodeURIComponent('At least one user must remain.'));
}
const [result] = await pool.query('DELETE FROM users WHERE id = ?', [userId]);
if (!result.affectedRows) {
return res.status(404).send('User not found.');
}
res.redirect('/admin/users?message=' + encodeURIComponent('User deleted.'));
} catch (error) {
next(error);
}
});
};
@@ -1,5 +1,6 @@
module.exports = function registerAdminAccountRoutes(app, deps) {
const pool = deps.pool;
const common = deps.common;
const pages = deps.pages;
const formatDashboardDate = deps.formatDashboardDate;
const getAuditUserId = deps.getAuditUserId;
@@ -8,30 +9,34 @@ module.exports = function registerAdminAccountRoutes(app, deps) {
const createUserSession = deps.createUserSession;
const setSessionCookie = deps.setSessionCookie;
app.get('/admin/account', function (req, res) {
app.get('/account', function (req, res) {
res.send(pages.renderAccountPage(req.currentUser, req.query.message ? String(req.query.message) : '', req.query.return_url ? String(req.query.return_url) : ''));
});
app.post('/admin/account/name', async function (req, res, next) {
app.post('/account/name', async function (req, res, next) {
try {
const name = String(req.body.name || '').trim();
if (!name) {
return res.status(400).send('Name is required.');
}
if (await common.fetchDuplicateName(pool, 'users', name, req.currentUser.id)) {
return res.redirect('/account?message=' + encodeURIComponent('That name already exists.'));
}
const actorId = getAuditUserId(req);
const [result] = await pool.query('UPDATE users SET name = ?, modified_by = ? WHERE id = ?', [name, actorId, req.currentUser.id]);
if (!result.affectedRows) {
return res.status(404).send('User not found.');
}
res.redirect('/admin/account?message=' + encodeURIComponent('Name updated.'));
res.redirect('/account?message=' + encodeURIComponent('Name updated.'));
} catch (error) {
next(error);
}
});
app.post('/admin/account/password', async function (req, res, next) {
app.post('/account/password', async function (req, res, next) {
try {
const currentPassword = String(req.body.current_password || '');
const newPassword = String(req.body.new_password || '');
@@ -61,9 +66,9 @@ module.exports = function registerAdminAccountRoutes(app, deps) {
const token = await createUserSession(pool, user.id);
setSessionCookie(res, token);
res.redirect('/admin/account?message=' + encodeURIComponent('Password updated.'));
res.redirect('/account?message=' + encodeURIComponent('Password updated.'));
} catch (error) {
next(error);
}
});
};
};
@@ -6,7 +6,7 @@ module.exports = function registerAdminScreenCommandRoutes(app, deps) {
const withClientNameReservation = deps.withClientNameReservation;
const requirePermission = deps.requirePermission;
app.post('/admin/clients/:slug/commands', requirePermission('clients.allow'), async function (req, res, next) {
app.post('/clients/:slug/commands', requirePermission('clients.allow'), async function (req, res, next) {
try {
const slug = String(req.params.slug || '').trim();
const command = String((req.body && req.body.command) || req.query.command || '').trim().toLowerCase();
@@ -152,4 +152,4 @@ module.exports = function registerAdminScreenCommandRoutes(app, deps) {
next(error);
}
});
};
};
@@ -56,7 +56,7 @@ module.exports = function registerAdminContentRoutes(app, deps) {
}, {});
}
app.get('/admin/slides', requirePermission('slides.read'), async function (req, res, next) {
app.get('/slides', requirePermission('slides.read'), async function (req, res, next) {
try {
const data = await common.fetchAdminData(pool);
res.send(pages.renderSlidesPage(data, req.query.message ? String(req.query.message) : '', req.currentUser));
@@ -65,31 +65,66 @@ module.exports = function registerAdminContentRoutes(app, deps) {
}
});
app.get('/admin/slides/new', requirePermission('slides.create'), async function (req, res, next) {
app.get('/slides/new', requirePermission('slides.create'), async function (req, res, next) {
try {
const data = await common.fetchTemplatesData(pool);
const rssData = await common.fetchRssFeedsData(pool);
const apiData = typeof common.fetchApiSourcesData === 'function' ? await common.fetchApiSourcesData(pool) : { apiSources: [] };
const apiSources = Array.isArray(apiData.apiSources) ? apiData.apiSources.map(function (source) {
return Object.assign({}, source, {
responseJson: typeof common.parseJsonSafe === 'function' ? common.parseJsonSafe(source.last_response_json) : null
});
}) : [];
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.map(function (item) {
return typeof common.normalizeRssFeedItem === 'function' ? common.normalizeRssFeedItem(item) : item;
}) });
}));
Object.assign(data, rssData, apiData, { rssFeeds: rssFeeds, apiSources: apiSources });
res.send(pages.renderSlideFormPage(data, 'create', null, req.query.message ? String(req.query.message) : '', req.currentUser));
} catch (error) {
next(error);
}
});
app.get('/admin/slides/:id/edit', requirePermission('slides.update'), async function (req, res, next) {
app.get('/slides/:id/edit', requirePermission('slides.update'), async function (req, res, next) {
try {
const slide = await common.fetchSlideById(pool, Number(req.params.id));
if (!slide) {
return res.status(404).send('Slide not found');
}
const data = await common.fetchTemplatesData(pool);
const rssData = await common.fetchRssFeedsData(pool);
const apiData = typeof common.fetchApiSourcesData === 'function' ? await common.fetchApiSourcesData(pool) : { apiSources: [] };
const apiSources = Array.isArray(apiData.apiSources) ? apiData.apiSources.map(function (source) {
return Object.assign({}, source, {
responseJson: typeof common.parseJsonSafe === 'function' ? common.parseJsonSafe(source.last_response_json) : null
});
}) : [];
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.map(function (item) {
return typeof common.normalizeRssFeedItem === 'function' ? common.normalizeRssFeedItem(item) : item;
}) });
}));
Object.assign(data, rssData, apiData, { rssFeeds: rssFeeds, apiSources: apiSources });
res.send(pages.renderSlideFormPage(data, 'edit', slide, req.query.message ? String(req.query.message) : '', req.currentUser));
} catch (error) {
next(error);
}
});
app.post('/admin/slides', requirePermission('slides.create'), upload.any(), async function (req, res, next) {
app.post('/slides', requirePermission('slides.create'), upload.any(), async function (req, res, next) {
try {
const payload = await common.buildSlidePayload(pool, req, null);
if (await common.fetchDuplicateName(pool, 'slides', payload.title, null, 'title')) {
return res.status(400).send('A slide with that title already exists.');
}
const actorId = getAuditUserId(req);
const [result] = await pool.query(
'INSERT INTO slides (title, body, template_id, content_json, media_path, media_type, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
@@ -101,9 +136,9 @@ module.exports = function registerAdminContentRoutes(app, deps) {
localUploadDir: deps.uploadDir,
nextUploadRefs: collectUploadReferencesFromPayload(payload)
});
redirectAfterSave(req, res, '/admin/slides/' + result.insertId + '/edit', {
closeUrl: '/admin/slides',
newUrl: '/admin/slides/new',
redirectAfterSave(req, res, '/slides/' + result.insertId + '/edit', {
closeUrl: '/slides',
newUrl: '/slides/new',
message: 'Slide created.'
});
} catch (error) {
@@ -111,7 +146,7 @@ module.exports = function registerAdminContentRoutes(app, deps) {
}
});
app.post('/admin/slides/:id', requirePermission('slides.update'), upload.any(), async function (req, res, next) {
app.post('/slides/:id', requirePermission('slides.update'), upload.any(), async function (req, res, next) {
try {
const slide = await common.fetchSlideById(pool, Number(req.params.id));
if (!slide) {
@@ -121,6 +156,9 @@ module.exports = function registerAdminContentRoutes(app, deps) {
const screenSlideCounts = await fetchScreenSlideCountsBySlug(affectedScreens);
const existingUploadRefs = collectUploadReferencesFromSlide(slide);
const payload = await common.buildSlidePayload(pool, req, slide);
if (await common.fetchDuplicateName(pool, 'slides', payload.title, slide.id, 'title')) {
return res.status(400).send('A slide with that title already exists.');
}
const nextUploadRefs = collectUploadReferencesFromPayload(payload);
const actorId = getAuditUserId(req);
await pool.query(
@@ -138,9 +176,9 @@ module.exports = function registerAdminContentRoutes(app, deps) {
screenSlideCounts: screenSlideCounts
});
await broadcastDashboardState();
redirectAfterSave(req, res, '/admin/slides/' + slide.id + '/edit', {
closeUrl: '/admin/slides',
newUrl: '/admin/slides/new',
redirectAfterSave(req, res, '/slides/' + slide.id + '/edit', {
closeUrl: '/slides',
newUrl: '/slides/new',
message: 'Slide updated.'
});
} catch (error) {
@@ -148,7 +186,7 @@ module.exports = function registerAdminContentRoutes(app, deps) {
}
});
app.post('/admin/slides/:id/delete', requirePermission('slides.delete'), async function (req, res, next) {
app.post('/slides/:id/delete', requirePermission('slides.delete'), async function (req, res, next) {
try {
const slide = await common.fetchSlideById(pool, Number(req.params.id));
if (!slide) {
@@ -156,7 +194,7 @@ module.exports = function registerAdminContentRoutes(app, deps) {
}
const blockMessage = await getSlideDeleteBlockMessage(pool, slide);
if (blockMessage) {
return res.redirect('/admin/slides?message=' + encodeURIComponent(blockMessage));
return res.redirect('/slides?message=' + encodeURIComponent(blockMessage));
}
const affectedScreens = await fetchScreensBySlideId(pool, slide.id);
const screenSlideCounts = await fetchScreenSlideCountsBySlug(affectedScreens);
@@ -172,13 +210,13 @@ module.exports = function registerAdminContentRoutes(app, deps) {
screenSlideCounts: screenSlideCounts
});
await broadcastDashboardState();
res.redirect('/admin/slides?message=' + encodeURIComponent('Slide deleted.'));
res.redirect('/slides?message=' + encodeURIComponent('Slide deleted.'));
} catch (error) {
next(error);
}
});
app.get('/admin/templates', requirePermission('templates.read'), async function (req, res, next) {
app.get('/templates', requirePermission('templates.read'), async function (req, res, next) {
try {
const data = await common.fetchTemplatesData(pool);
res.send(pages.renderTemplatesPage(data, req.query.message ? String(req.query.message) : '', req.currentUser));
@@ -187,7 +225,7 @@ module.exports = function registerAdminContentRoutes(app, deps) {
}
});
app.get('/admin/templates/new', requirePermission('templates.create'), async function (req, res, next) {
app.get('/templates/new', requirePermission('templates.create'), async function (req, res, next) {
try {
const data = await common.fetchCanvasSizesData(pool);
res.send(pages.renderTemplateFormPage(null, 'create', req.query.message ? String(req.query.message) : '', data.canvasSizes, req.currentUser));
@@ -196,9 +234,12 @@ module.exports = function registerAdminContentRoutes(app, deps) {
}
});
app.post('/admin/templates', requirePermission('templates.create'), upload.any(), async function (req, res, next) {
app.post('/templates', requirePermission('templates.create'), upload.any(), async function (req, res, next) {
try {
const payload = await common.buildTemplatePayload(pool, req, null);
if (await common.fetchDuplicateName(pool, 'slide_templates', payload.name)) {
return res.redirect('/templates/new?message=' + encodeURIComponent('A template with that name already exists.'));
}
const actorId = getAuditUserId(req);
const [result] = await pool.query(
'INSERT INTO slide_templates (name, canvas_size_id, background_image_path, background_color, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?)',
@@ -207,8 +248,8 @@ module.exports = function registerAdminContentRoutes(app, deps) {
for (let i = 0; i < payload.regions.length; i += 1) {
const region = payload.regions[i];
await pool.query(
'INSERT INTO slide_template_regions (template_id, region_key, region_type, label, font_family, x, y, width, height, z_index, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
[result.insertId, region.region_key, region.region_type, region.label, region.font_family, region.x, region.y, region.width, region.height, region.z_index, actorId, actorId]
'INSERT INTO slide_template_regions (template_id, region_key, region_type, label, font_family, lock_ratio, x, y, width, height, z_index, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
[result.insertId, region.region_key, region.region_type, region.label, region.font_family, region.lock_ratio, region.x, region.y, region.width, region.height, region.z_index, actorId, actorId]
);
}
await syncPlaylistUploadsOnChange({
@@ -217,9 +258,9 @@ module.exports = function registerAdminContentRoutes(app, deps) {
localUploadDir: deps.uploadDir,
nextUploadRefs: collectUploadReferencesFromPayload(payload)
});
redirectAfterSave(req, res, '/admin/templates/' + result.insertId + '/edit', {
closeUrl: '/admin/templates',
newUrl: '/admin/templates/new',
redirectAfterSave(req, res, '/templates/' + result.insertId + '/edit', {
closeUrl: '/templates',
newUrl: '/templates/new',
message: 'Template created.'
});
} catch (error) {
@@ -227,7 +268,7 @@ module.exports = function registerAdminContentRoutes(app, deps) {
}
});
app.get('/admin/templates/:id/edit', requirePermission('templates.update'), async function (req, res, next) {
app.get('/templates/:id/edit', requirePermission('templates.update'), async function (req, res, next) {
try {
const template = await common.fetchTemplateById(pool, Number(req.params.id));
if (!template) {
@@ -240,7 +281,7 @@ module.exports = function registerAdminContentRoutes(app, deps) {
}
});
app.post('/admin/templates/:id', requirePermission('templates.update'), upload.any(), async function (req, res, next) {
app.post('/templates/:id', requirePermission('templates.update'), upload.any(), async function (req, res, next) {
try {
const template = await common.fetchTemplateById(pool, Number(req.params.id));
if (!template) {
@@ -248,6 +289,9 @@ module.exports = function registerAdminContentRoutes(app, deps) {
}
const existingUploadRefs = collectUploadReferencesFromTemplate(template);
const payload = await common.buildTemplatePayload(pool, req, template);
if (await common.fetchDuplicateName(pool, 'slide_templates', payload.name, template.id)) {
return res.redirect('/templates/' + template.id + '/edit?message=' + encodeURIComponent('A template with that name already exists.'));
}
const nextUploadRefs = collectUploadReferencesFromPayload(payload);
const affectedScreens = await fetchScreensByTemplateId(pool, template.id);
const actorId = getAuditUserId(req);
@@ -259,8 +303,8 @@ module.exports = function registerAdminContentRoutes(app, deps) {
for (let i = 0; i < payload.regions.length; i += 1) {
const region = payload.regions[i];
await pool.query(
'INSERT INTO slide_template_regions (template_id, region_key, region_type, label, font_family, x, y, width, height, z_index, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
[template.id, region.region_key, region.region_type, region.label, region.font_family, region.x, region.y, region.width, region.height, region.z_index, actorId, actorId]
'INSERT INTO slide_template_regions (template_id, region_key, region_type, label, font_family, lock_ratio, x, y, width, height, z_index, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
[template.id, region.region_key, region.region_type, region.label, region.font_family, region.lock_ratio, region.x, region.y, region.width, region.height, region.z_index, actorId, actorId]
);
}
await syncPlaylistUploadsOnChange({
@@ -271,9 +315,9 @@ module.exports = function registerAdminContentRoutes(app, deps) {
nextUploadRefs: nextUploadRefs
});
await notifyPlayerScreens(affectedScreens, 'refresh');
redirectAfterSave(req, res, '/admin/templates/' + template.id + '/edit', {
closeUrl: '/admin/templates',
newUrl: '/admin/templates/new',
redirectAfterSave(req, res, '/templates/' + template.id + '/edit', {
closeUrl: '/templates',
newUrl: '/templates/new',
message: 'Template updated.'
});
} catch (error) {
@@ -281,7 +325,7 @@ module.exports = function registerAdminContentRoutes(app, deps) {
}
});
app.post('/admin/templates/:id/delete', requirePermission('templates.delete'), async function (req, res, next) {
app.post('/templates/:id/delete', requirePermission('templates.delete'), async function (req, res, next) {
try {
const template = await common.fetchTemplateById(pool, Number(req.params.id));
if (!template) {
@@ -289,7 +333,7 @@ module.exports = function registerAdminContentRoutes(app, deps) {
}
const blockMessage = await getTemplateDeleteBlockMessage(pool, template);
if (blockMessage) {
return res.redirect('/admin/templates?message=' + encodeURIComponent(blockMessage));
return res.redirect('/templates?message=' + encodeURIComponent(blockMessage));
}
const uploadRefs = collectUploadReferencesFromTemplate(template);
const affectedScreens = await fetchScreensByTemplateId(pool, template.id);
@@ -303,13 +347,13 @@ module.exports = function registerAdminContentRoutes(app, deps) {
previousUploadRefs: uploadRefs
});
await notifyPlayerScreens(affectedScreens, 'refresh');
res.redirect('/admin/templates?message=' + encodeURIComponent('Template deleted.'));
res.redirect('/templates?message=' + encodeURIComponent('Template deleted.'));
} catch (error) {
next(error);
}
});
app.get('/admin/canvas-sizes', requirePermission('canvas-sizes.read'), async function (req, res, next) {
app.get('/canvas-sizes', requirePermission('canvas-sizes.read'), async function (req, res, next) {
try {
const data = await common.fetchAdminData(pool);
res.send(pages.renderCanvasSizesPage(data, req.query.message ? String(req.query.message) : '', req.currentUser));
@@ -318,21 +362,24 @@ module.exports = function registerAdminContentRoutes(app, deps) {
}
});
app.get('/admin/canvas-sizes/new', requirePermission('canvas-sizes.create'), function (req, res) {
app.get('/canvas-sizes/new', requirePermission('canvas-sizes.create'), function (req, res) {
res.send(pages.renderCanvasSizeFormPage(null, 'create', req.query.message ? String(req.query.message) : '', req.currentUser));
});
app.post('/admin/canvas-sizes', requirePermission('canvas-sizes.create'), async function (req, res, next) {
app.post('/canvas-sizes', requirePermission('canvas-sizes.create'), async function (req, res, next) {
try {
const payload = common.buildCanvasSizePayload(req, null);
if (await common.fetchDuplicateName(pool, 'canvas_sizes', payload.name)) {
return res.redirect('/canvas-sizes/new?message=' + encodeURIComponent('A canvas size with that name already exists.'));
}
if (await canvasSizeExists(payload.width, payload.height)) {
return res.redirect('/admin/canvas-sizes/new?message=' + encodeURIComponent('That canvas size already exists.'));
return res.redirect('/canvas-sizes/new?message=' + encodeURIComponent('That canvas size already exists.'));
}
const actorId = getAuditUserId(req);
await pool.query('INSERT INTO canvas_sizes (name, width, height, created_by, modified_by) VALUES (?, ?, ?, ?, ?)', [payload.name, payload.width, payload.height, actorId, actorId]);
redirectAfterSave(req, res, '/admin/canvas-sizes', {
closeUrl: '/admin/canvas-sizes',
newUrl: '/admin/canvas-sizes/new',
const [result] = await pool.query('INSERT INTO canvas_sizes (name, width, height, created_by, modified_by) VALUES (?, ?, ?, ?, ?)', [payload.name, payload.width, payload.height, actorId, actorId]);
redirectAfterSave(req, res, '/canvas-sizes/' + result.insertId + '/edit', {
closeUrl: '/canvas-sizes',
newUrl: '/canvas-sizes/new',
message: 'Canvas size created.'
});
} catch (error) {
@@ -340,7 +387,7 @@ module.exports = function registerAdminContentRoutes(app, deps) {
}
});
app.get('/admin/canvas-sizes/:id/edit', requirePermission('canvas-sizes.update'), async function (req, res, next) {
app.get('/canvas-sizes/:id/edit', requirePermission('canvas-sizes.update'), async function (req, res, next) {
try {
const canvasSize = await common.fetchCanvasSizeById(pool, Number(req.params.id));
if (!canvasSize) {
@@ -352,20 +399,23 @@ module.exports = function registerAdminContentRoutes(app, deps) {
}
});
app.post('/admin/canvas-sizes/:id', requirePermission('canvas-sizes.update'), async function (req, res, next) {
app.post('/canvas-sizes/:id', requirePermission('canvas-sizes.update'), async function (req, res, next) {
try {
const canvasSize = await common.fetchCanvasSizeById(pool, Number(req.params.id));
if (!canvasSize) {
return res.status(404).send('Canvas size not found');
}
const payload = common.buildCanvasSizePayload(req, canvasSize);
if (await common.fetchDuplicateName(pool, 'canvas_sizes', payload.name, canvasSize.id)) {
return res.redirect('/canvas-sizes/' + canvasSize.id + '/edit?message=' + encodeURIComponent('A canvas size with that name already exists.'));
}
if (await canvasSizeExists(payload.width, payload.height, canvasSize.id)) {
return res.redirect('/admin/canvas-sizes/' + canvasSize.id + '/edit?message=' + encodeURIComponent('That canvas size already exists.'));
return res.redirect('/canvas-sizes/' + canvasSize.id + '/edit?message=' + encodeURIComponent('That canvas size already exists.'));
}
await pool.query('UPDATE canvas_sizes SET name = ?, width = ?, height = ?, modified_by = ? WHERE id = ?', [payload.name, payload.width, payload.height, getAuditUserId(req), canvasSize.id]);
redirectAfterSave(req, res, '/admin/canvas-sizes', {
closeUrl: '/admin/canvas-sizes',
newUrl: '/admin/canvas-sizes/new',
redirectAfterSave(req, res, '/canvas-sizes', {
closeUrl: '/canvas-sizes',
newUrl: '/canvas-sizes/new',
message: 'Canvas size updated.'
});
} catch (error) {
@@ -373,7 +423,7 @@ module.exports = function registerAdminContentRoutes(app, deps) {
}
});
app.post('/admin/canvas-sizes/:id/delete', requirePermission('canvas-sizes.delete'), async function (req, res, next) {
app.post('/canvas-sizes/:id/delete', requirePermission('canvas-sizes.delete'), async function (req, res, next) {
try {
const canvasSize = await common.fetchCanvasSizeById(pool, Number(req.params.id));
if (!canvasSize) {
@@ -381,13 +431,13 @@ module.exports = function registerAdminContentRoutes(app, deps) {
}
const blockMessage = await getCanvasSizeDeleteBlockMessage(pool, canvasSize);
if (blockMessage) {
return res.redirect('/admin/canvas-sizes?message=' + encodeURIComponent(blockMessage));
return res.redirect('/canvas-sizes?message=' + encodeURIComponent(blockMessage));
}
await pool.query('UPDATE slide_templates SET canvas_size_id = NULL, modified_by = ? WHERE canvas_size_id = ?', [getAuditUserId(req), canvasSize.id]);
await pool.query('DELETE FROM canvas_sizes WHERE id = ?', [canvasSize.id]);
res.redirect('/admin/canvas-sizes?message=' + encodeURIComponent('Canvas size deleted.'));
res.redirect('/canvas-sizes?message=' + encodeURIComponent('Canvas size deleted.'));
} catch (error) {
next(error);
}
});
};
};
+532
View File
@@ -0,0 +1,532 @@
module.exports = function registerAdminDataSourceRoutes(app, deps) {
const pool = deps.pool;
const common = deps.common;
const pages = deps.pages;
const backgroundTaskQueue = deps.backgroundTaskQueue;
const { hasAnyPermission } = require('../../../rbac');
const formatDashboardDate = deps.formatDashboardDate || function (value) {
return value ? String(value) : '';
};
const getAuditUserId = deps.getAuditUserId;
const redirectAfterSave = deps.redirectAfterSave;
const requirePermission = deps.requirePermission;
const fetchRssFeedItems = deps.fetchRssFeedItems || (common && common.fetchRssFeedItems) || null;
const replaceRssFeedItems = deps.replaceRssFeedItems || (common && common.replaceRssFeedItems) || null;
const fetchApiSourceResponse = common && common.fetchApiSourceResponse ? common.fetchApiSourceResponse : null;
function toIsoTimestamp(value) {
if (!value) {
return '';
}
const date = new Date(value);
return Number.isNaN(date.getTime()) ? '' : date.toISOString();
}
async function refreshApiSourceInBackground(apiSourceId, apiUrl, actorId) {
const connection = await pool.getConnection();
try {
let responseDetails = null;
let pullError = '';
try {
responseDetails = await loadApiSourceResponse(apiUrl);
} catch (error) {
pullError = String(error && error.message ? error.message : 'Unable to load API response.');
}
await connection.beginTransaction();
await connection.query(
'UPDATE api_sources SET last_pulled_at = ?, last_pull_error = ?, last_response_status = ?, last_response_content_type = ?, last_response_json = ?, modified_by = ? WHERE id = ?',
[new Date(), pullError || null, responseDetails ? responseDetails.responseStatus : null, responseDetails ? responseDetails.responseContentType : null, responseDetails ? responseDetails.responseJson : null, actorId, apiSourceId]
);
await connection.commit();
} catch (error) {
try {
await connection.rollback();
} catch (_rollbackError) {
// Ignore rollback failures and surface the original error.
}
throw error;
} finally {
connection.release();
}
}
async function refreshRssFeedInBackground(rssFeedId, feedUrl, itemLimit, actorId) {
const connection = await pool.getConnection();
try {
let updatedItems = [];
let pullError = '';
try {
updatedItems = await loadRssFeedItems(feedUrl, itemLimit);
} catch (error) {
pullError = String(error && error.message ? error.message : 'Unable to load feed items.');
}
await connection.beginTransaction();
await connection.query(
'UPDATE rss_feeds SET modified_by = ? WHERE id = ?',
[actorId, rssFeedId]
);
if (replaceRssFeedItems) {
await replaceRssFeedItems(connection, rssFeedId, updatedItems);
}
await connection.commit();
if (pullError) {
console.error('[admin-data-sources] RSS feed refresh completed with an error for feed ' + rssFeedId + ': ' + pullError);
}
} catch (error) {
try {
await connection.rollback();
} catch (_rollbackError) {
// Ignore rollback failures and surface the original error.
}
throw error;
} finally {
connection.release();
}
}
if (!pool || !common || !pages || typeof getAuditUserId !== 'function' || typeof redirectAfterSave !== 'function' || typeof requirePermission !== 'function' || !backgroundTaskQueue) {
throw new Error('registerAdminDataSourceRoutes requires the data source route dependencies.');
}
function formatRecurringKey(sourceType, id) {
return sourceType + '-refresh:' + Number(id);
}
function buildRecurringTitle(sourceType) {
return sourceType === 'rss-feed' ? 'RSS feed refresh' : 'API source refresh';
}
function registerRecurringRefresh(sourceType, id, name, intervalValue, intervalUnit, run) {
backgroundTaskQueue.registerRecurringTask({
key: formatRecurringKey(sourceType, id),
title: buildRecurringTitle(sourceType),
category: 'data-source',
intervalMs: require('../../lib/background-task-queue').normalizeIntervalMs(intervalValue, intervalUnit),
metadata: {
sourceType: sourceType,
sourceId: Number(id),
sourceName: name
},
run: run
});
}
function removeRecurringRefresh(sourceType, id) {
backgroundTaskQueue.removeRecurringTask(formatRecurringKey(sourceType, id));
}
function getTaskStatusById(taskId) {
if (!backgroundTaskQueue || typeof backgroundTaskQueue.getTaskById !== 'function') {
return null;
}
const task = backgroundTaskQueue.getTaskById(taskId);
if (!task) {
return null;
}
return {
id: task.id,
key: task.key,
status: task.status,
finishedAt: task.finishedAt || '',
errorMessage: task.errorMessage || ''
};
}
async function loadRssFeedItems(feedUrl, itemLimit) {
if (typeof fetchRssFeedItems === 'function') {
return fetchRssFeedItems(feedUrl, itemLimit);
}
return [];
}
async function loadApiSourceResponse(apiUrl) {
if (typeof fetchApiSourceResponse === 'function') {
return fetchApiSourceResponse(apiUrl);
}
return {
responseJson: null,
responseStatus: null,
responseContentType: null
};
}
function sendRefreshTaskState(req, res, sourceType, sourceId) {
const taskId = Number(req.query.refresh_task_id);
if (!Number.isFinite(taskId) || taskId <= 0) {
return res.status(400).json({ error: 'Missing refresh task id.' });
}
const task = getTaskStatusById(taskId);
const expectedKey = sourceType + '-refresh:' + Number(sourceId);
if (!task || task.key !== expectedKey) {
return res.status(404).json({ error: 'Refresh task not found.' });
}
res.json(task);
}
app.get('/data-sources', function (req, res, next) {
if (!req.currentUser) {
return res.redirect('/login?message=' + encodeURIComponent('Please sign in to continue.'));
}
if (hasAnyPermission(req.currentUser, ['rss-feeds.read', 'api-sources.read'])) {
if (hasAnyPermission(req.currentUser, ['rss-feeds.read'])) {
return res.redirect('/data-sources/rss-feeds');
}
return res.redirect('/data-sources/api-sources');
}
const error = new Error('You do not have permission to access this area.');
error.statusCode = 403;
error.expose = true;
next(error);
});
app.get('/data-sources/api-sources', requirePermission('api-sources.read'), async function (req, res, next) {
try {
const data = await common.fetchApiSourcesData(pool);
res.send(pages.renderApiSourcesPage(data, req.query.message ? String(req.query.message) : '', req.currentUser, formatDashboardDate));
} catch (error) {
next(error);
}
});
app.get('/data-sources/api-sources/new', requirePermission('api-sources.create'), function (req, res) {
res.send(pages.renderApiSourceFormPage(null, 'create', req.query.message ? String(req.query.message) : '', req.currentUser));
});
app.post('/data-sources/api-sources', requirePermission('api-sources.create'), async function (req, res, next) {
const connection = await pool.getConnection();
try {
const payload = common.buildApiSourcePayload(req, null);
if (await common.fetchDuplicateName(pool, 'api_sources', payload.name)) {
return res.redirect('/data-sources/api-sources/new?message=' + encodeURIComponent('An API source with that name already exists.'));
}
const actorId = getAuditUserId(req);
await connection.beginTransaction();
const [result] = await connection.query(
'INSERT INTO api_sources (name, api_url, update_interval_value, update_interval_unit, last_pulled_at, last_pull_error, last_response_status, last_response_content_type, last_response_json, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
[payload.name, payload.apiUrl, payload.updateIntervalValue, payload.updateIntervalUnit, null, null, null, null, null, actorId, actorId]
);
await connection.commit();
registerRecurringRefresh('api-source', result.insertId, payload.name, payload.updateIntervalValue, payload.updateIntervalUnit, function () {
return refreshApiSourceInBackground(result.insertId, payload.apiUrl, actorId);
});
const refreshTask = await backgroundTaskQueue.enqueueTask({
key: 'api-source-refresh:' + result.insertId,
title: 'API source refresh',
category: 'data-source',
taskType: 'data-source-refresh',
payload: {
sourceType: 'api-source',
sourceId: result.insertId,
sourceName: payload.name,
actorId: actorId
},
metadata: {
sourceType: 'api-source',
sourceId: result.insertId,
sourceName: payload.name
}
});
const message = 'API source created. Refresh is running in the background.';
res.redirect('/data-sources/api-sources/' + result.insertId + '/edit?message=' + encodeURIComponent(message) + '&refresh_task_id=' + encodeURIComponent(refreshTask && refreshTask.id ? refreshTask.id : ''));
} catch (error) {
try {
await connection.rollback();
} catch (_rollbackError) {
// Ignore rollback failures and surface the original error.
}
next(error);
} finally {
connection.release();
}
});
app.get('/data-sources/api-sources/:id/edit', requirePermission('api-sources.update'), async function (req, res, next) {
try {
const apiSource = await common.fetchApiSourceById(pool, Number(req.params.id));
if (!apiSource) {
return res.status(404).send('API source not found');
}
res.send(pages.renderApiSourceEditPage(Object.assign({}, apiSource, {
apiUrl: apiSource.api_url,
updateIntervalValue: apiSource.update_interval_value,
updateIntervalUnit: apiSource.update_interval_unit || 'minutes',
lastPulledAtValue: toIsoTimestamp(apiSource.last_pulled_at),
lastPulledAtLabel: apiSource.last_pulled_at ? String(apiSource.last_pulled_at) : '',
lastPullError: apiSource.last_pull_error || '',
lastResponseStatus: apiSource.last_response_status,
lastResponseContentType: apiSource.last_response_content_type,
lastResponseJson: apiSource.last_response_json || ''
}), {
lastResponseJson: apiSource.last_response_json || '',
lastPullError: apiSource.last_pull_error || ''
}, req.query.message ? String(req.query.message) : '', req.currentUser));
} catch (error) {
next(error);
}
});
app.get('/data-sources/api-sources/:id/state', requirePermission('api-sources.update'), function (req, res) {
sendRefreshTaskState(req, res, 'api-source', Number(req.params.id));
});
app.post('/data-sources/api-sources/:id', requirePermission('api-sources.update'), async function (req, res, next) {
const connection = await pool.getConnection();
try {
const apiSource = await common.fetchApiSourceById(pool, Number(req.params.id));
if (!apiSource) {
return res.status(404).send('API source not found');
}
const payload = common.buildApiSourcePayload(req, apiSource);
if (await common.fetchDuplicateName(pool, 'api_sources', payload.name, apiSource.id)) {
return res.redirect('/data-sources/api-sources/' + apiSource.id + '/edit?message=' + encodeURIComponent('An API source with that name already exists.'));
}
const actorId = getAuditUserId(req);
await connection.beginTransaction();
await connection.query(
'UPDATE api_sources SET name = ?, api_url = ?, update_interval_value = ?, update_interval_unit = ?, last_pulled_at = ?, last_pull_error = ?, last_response_status = ?, last_response_content_type = ?, last_response_json = ?, modified_by = ? WHERE id = ?',
[payload.name, payload.apiUrl, payload.updateIntervalValue, payload.updateIntervalUnit, null, null, null, null, null, actorId, apiSource.id]
);
await connection.commit();
registerRecurringRefresh('api-source', apiSource.id, payload.name, payload.updateIntervalValue, payload.updateIntervalUnit, function () {
return refreshApiSourceInBackground(apiSource.id, payload.apiUrl, actorId);
});
const message = 'API source updated. Refresh is running in the background.';
const refreshTask = await backgroundTaskQueue.enqueueTask({
key: 'api-source-refresh:' + apiSource.id,
title: 'API source refresh',
category: 'data-source',
taskType: 'data-source-refresh',
payload: {
sourceType: 'api-source',
sourceId: apiSource.id,
sourceName: payload.name,
actorId: actorId
},
metadata: {
sourceType: 'api-source',
sourceId: apiSource.id,
sourceName: payload.name
}
});
res.redirect('/data-sources/api-sources/' + apiSource.id + '/edit?message=' + encodeURIComponent(message) + '&refresh_task_id=' + encodeURIComponent(refreshTask && refreshTask.id ? refreshTask.id : ''));
} catch (error) {
try {
await connection.rollback();
} catch (_rollbackError) {
// Ignore rollback failures and surface the original error.
}
next(error);
} finally {
connection.release();
}
});
app.post('/data-sources/api-sources/:id/delete', requirePermission('api-sources.delete'), async function (req, res, next) {
try {
const apiSource = await common.fetchApiSourceById(pool, Number(req.params.id));
if (!apiSource) {
return res.status(404).send('API source not found');
}
const connection = await pool.getConnection();
try {
await connection.beginTransaction();
await connection.query('DELETE FROM api_sources WHERE id = ?', [apiSource.id]);
await connection.commit();
removeRecurringRefresh('api-source', apiSource.id);
} catch (error) {
await connection.rollback();
throw error;
} finally {
connection.release();
}
res.redirect('/data-sources/api-sources?message=' + encodeURIComponent('API source deleted.'));
} catch (error) {
next(error);
}
});
app.get('/data-sources/rss-feeds', requirePermission('rss-feeds.read'), async function (req, res, next) {
try {
const data = await common.fetchRssFeedsData(pool);
res.send(pages.renderRssFeedsPage(data, req.query.message ? String(req.query.message) : '', req.currentUser));
} catch (error) {
next(error);
}
});
app.get('/data-sources/rss-feeds/new', requirePermission('rss-feeds.create'), function (req, res) {
res.send(pages.renderRssFeedFormPage(null, 'create', req.query.message ? String(req.query.message) : '', req.currentUser));
});
app.post('/data-sources/rss-feeds', requirePermission('rss-feeds.create'), async function (req, res, next) {
const connection = await pool.getConnection();
try {
const payload = common.buildRssFeedPayload(req, null);
if (await common.fetchDuplicateName(pool, 'rss_feeds', payload.name)) {
return res.redirect('/data-sources/rss-feeds/new?message=' + encodeURIComponent('An RSS feed with that name already exists.'));
}
const actorId = getAuditUserId(req);
await connection.beginTransaction();
const [result] = await connection.query(
'INSERT INTO rss_feeds (name, feed_url, update_interval_value, update_interval_unit, item_limit, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?)',
[payload.name, payload.feedUrl, payload.updateIntervalValue, payload.updateIntervalUnit, payload.itemLimit, actorId, actorId]
);
await connection.commit();
registerRecurringRefresh('rss-feed', result.insertId, payload.name, payload.updateIntervalValue, payload.updateIntervalUnit, function () {
return refreshRssFeedInBackground(result.insertId, payload.feedUrl, payload.itemLimit, actorId);
});
const refreshTask = await backgroundTaskQueue.enqueueTask({
key: 'rss-feed-refresh:' + result.insertId,
title: 'RSS feed refresh',
category: 'data-source',
taskType: 'data-source-refresh',
payload: {
sourceType: 'rss-feed',
sourceId: result.insertId,
sourceName: payload.name,
actorId: actorId
},
metadata: {
sourceType: 'rss-feed',
sourceId: result.insertId,
sourceName: payload.name
}
});
const message = 'RSS feed created. Refresh is running in the background.';
res.redirect('/data-sources/rss-feeds/' + result.insertId + '/edit?message=' + encodeURIComponent(message) + '&refresh_task_id=' + encodeURIComponent(refreshTask && refreshTask.id ? refreshTask.id : ''));
} catch (error) {
try {
await connection.rollback();
} catch (_rollbackError) {
// Ignore rollback failures and surface the original error.
}
next(error);
} finally {
connection.release();
}
});
app.get('/data-sources/rss-feeds/:id/edit', requirePermission('rss-feeds.update'), async function (req, res, next) {
try {
const rssFeed = await common.fetchRssFeedById(pool, Number(req.params.id));
if (!rssFeed) {
return res.status(404).send('RSS feed not found');
}
const pulledItems = typeof common.fetchRssFeedItemsByFeedId === 'function'
? await common.fetchRssFeedItemsByFeedId(pool, rssFeed.id)
: [];
res.send(pages.renderRssFeedEditPage(Object.assign({}, rssFeed, {
feedUrl: rssFeed.feed_url,
updateIntervalValue: rssFeed.update_interval_value,
updateIntervalUnit: rssFeed.update_interval_unit || 'minutes',
itemLimit: rssFeed.item_limit
}), {
pulledItems: pulledItems,
pullError: ''
}, req.query.message ? String(req.query.message) : '', req.currentUser));
} catch (error) {
next(error);
}
});
app.get('/data-sources/rss-feeds/:id/state', requirePermission('rss-feeds.update'), function (req, res) {
sendRefreshTaskState(req, res, 'rss-feed', Number(req.params.id));
});
app.post('/data-sources/rss-feeds/:id', requirePermission('rss-feeds.update'), async function (req, res, next) {
const connection = await pool.getConnection();
try {
const rssFeed = await common.fetchRssFeedById(pool, Number(req.params.id));
if (!rssFeed) {
return res.status(404).send('RSS feed not found');
}
const payload = common.buildRssFeedPayload(req, rssFeed);
if (await common.fetchDuplicateName(pool, 'rss_feeds', payload.name, rssFeed.id)) {
return res.redirect('/data-sources/rss-feeds/' + rssFeed.id + '/edit?message=' + encodeURIComponent('An RSS feed with that name already exists.'));
}
const actorId = getAuditUserId(req);
await connection.beginTransaction();
await connection.query(
'UPDATE rss_feeds SET name = ?, feed_url = ?, update_interval_value = ?, update_interval_unit = ?, item_limit = ?, modified_by = ? WHERE id = ?',
[payload.name, payload.feedUrl, payload.updateIntervalValue, payload.updateIntervalUnit, payload.itemLimit, actorId, rssFeed.id]
);
await connection.commit();
registerRecurringRefresh('rss-feed', rssFeed.id, payload.name, payload.updateIntervalValue, payload.updateIntervalUnit, function () {
return refreshRssFeedInBackground(rssFeed.id, payload.feedUrl, payload.itemLimit, actorId);
});
const message = 'RSS feed updated. Refresh is running in the background.';
const refreshTask = await backgroundTaskQueue.enqueueTask({
key: 'rss-feed-refresh:' + rssFeed.id,
title: 'RSS feed refresh',
category: 'data-source',
taskType: 'data-source-refresh',
payload: {
sourceType: 'rss-feed',
sourceId: rssFeed.id,
sourceName: payload.name,
actorId: actorId
},
metadata: {
sourceType: 'rss-feed',
sourceId: rssFeed.id,
sourceName: payload.name
}
});
res.redirect('/data-sources/rss-feeds/' + rssFeed.id + '/edit?message=' + encodeURIComponent(message) + '&refresh_task_id=' + encodeURIComponent(refreshTask && refreshTask.id ? refreshTask.id : ''));
} catch (error) {
try {
await connection.rollback();
} catch (_rollbackError) {
// Ignore rollback failures and surface the original error.
}
next(error);
} finally {
connection.release();
}
});
app.post('/data-sources/rss-feeds/:id/delete', requirePermission('rss-feeds.delete'), async function (req, res, next) {
try {
const rssFeed = await common.fetchRssFeedById(pool, Number(req.params.id));
if (!rssFeed) {
return res.status(404).send('RSS feed not found');
}
const connection = await pool.getConnection();
try {
await connection.beginTransaction();
await connection.query('DELETE FROM rss_feeds WHERE id = ?', [rssFeed.id]);
await connection.commit();
removeRecurringRefresh('rss-feed', rssFeed.id);
} catch (error) {
await connection.rollback();
throw error;
} finally {
connection.release();
}
res.redirect('/data-sources/rss-feeds?message=' + encodeURIComponent('RSS feed deleted.'));
} catch (error) {
next(error);
}
});
};
@@ -30,7 +30,7 @@ module.exports = function registerAdminManageRoutes(app, deps) {
.filter(Boolean);
}
app.post('/admin/commands', requirePermission('dashboard.allow'), async function (req, res, next) {
app.post('/commands', requirePermission('dashboard.allow'), async function (req, res, next) {
try {
const command = String((req.body && req.body.command) || req.query.command || '').trim().toLowerCase();
const blackoutValue = req.body && Object.prototype.hasOwnProperty.call(req.body, 'blackout')
@@ -72,18 +72,21 @@ module.exports = function registerAdminManageRoutes(app, deps) {
}
});
app.post('/admin/playlists', requirePermission('playlists.create'), async function (req, res, next) {
app.post('/playlists', requirePermission('playlists.create'), async function (req, res, next) {
try {
const name = String(req.body.name || '').trim();
const fadeBetweenSlides = req.body.fade_between_slides ? 1 : 0;
if (!name) {
return res.status(400).send('Playlist name is required.');
}
if (await common.fetchDuplicateName(pool, 'playlists', name)) {
return res.status(400).send('A playlist with that name already exists.');
}
const actorId = getAuditUserId(req);
await pool.query('INSERT INTO playlists (name, fade_between_slides, created_by, modified_by) VALUES (?, ?, ?, ?)', [name, fadeBetweenSlides, actorId, actorId]);
redirectAfterSave(req, res, '/admin/playlists', {
closeUrl: '/admin/playlists',
newUrl: '/admin/playlists/new',
const [result] = await pool.query('INSERT INTO playlists (name, fade_between_slides, created_by, modified_by) VALUES (?, ?, ?, ?)', [name, fadeBetweenSlides, actorId, actorId]);
redirectAfterSave(req, res, '/playlists?edit=' + result.insertId, {
closeUrl: '/playlists',
newUrl: '/playlists/new',
message: 'Playlist created.'
});
} catch (error) {
@@ -91,7 +94,7 @@ module.exports = function registerAdminManageRoutes(app, deps) {
}
});
app.post('/admin/playlists/:id', requirePermission('playlists.update'), async function (req, res, next) {
app.post('/playlists/:id', requirePermission('playlists.update'), async function (req, res, next) {
const connection = await pool.getConnection();
try {
const name = String(req.body.name || '').trim();
@@ -103,6 +106,9 @@ module.exports = function registerAdminManageRoutes(app, deps) {
if (!playlist) {
return res.status(404).send('Playlist not found');
}
if (await common.fetchDuplicateName(pool, 'playlists', name, playlist.id)) {
return res.status(400).send('A playlist with that name already exists.');
}
const affectedScreens = await fetchScreensByPlaylistId(connection, playlist.id);
@@ -239,9 +245,9 @@ module.exports = function registerAdminManageRoutes(app, deps) {
await connection.commit();
await notifyPlayerScreens(affectedScreens, 'refresh');
await broadcastDashboardState();
redirectAfterSave(req, res, '/admin/playlists?edit=' + playlist.id, {
closeUrl: '/admin/playlists',
newUrl: '/admin/playlists/new',
redirectAfterSave(req, res, '/playlists?edit=' + playlist.id, {
closeUrl: '/playlists',
newUrl: '/playlists/new',
message: 'Playlist updated.'
});
} catch (error) {
@@ -256,7 +262,7 @@ module.exports = function registerAdminManageRoutes(app, deps) {
}
});
app.post('/admin/playlists/:id/delete', requirePermission('playlists.delete'), async function (req, res, next) {
app.post('/playlists/:id/delete', requirePermission('playlists.delete'), async function (req, res, next) {
try {
const playlist = await common.fetchPlaylistById(pool, Number(req.params.id));
if (!playlist) {
@@ -264,16 +270,16 @@ module.exports = function registerAdminManageRoutes(app, deps) {
}
const blockMessage = await getPlaylistDeleteBlockMessage(pool, playlist);
if (blockMessage) {
return res.redirect('/admin/playlists?message=' + encodeURIComponent(blockMessage));
return res.redirect('/playlists?message=' + encodeURIComponent(blockMessage));
}
await pool.query('DELETE FROM playlists WHERE id = ?', [playlist.id]);
res.redirect('/admin/playlists?message=' + encodeURIComponent('Playlist deleted.'));
res.redirect('/playlists?message=' + encodeURIComponent('Playlist deleted.'));
} catch (error) {
next(error);
}
});
app.post('/admin/playlists/:id/slides', requirePermission('playlists.update'), async function (req, res, next) {
app.post('/playlists/:id/slides', requirePermission('playlists.update'), async function (req, res, next) {
try {
const playlist = await common.fetchPlaylistById(pool, Number(req.params.id));
if (!playlist) {
@@ -302,13 +308,13 @@ module.exports = function registerAdminManageRoutes(app, deps) {
await pool.query('INSERT INTO playlist_slides (playlist_id, slide_id, position, duration_seconds, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?)', [playlist.id, slideId, nextPosition, durationSeconds, actorId, actorId]);
await notifyPlayerScreens(await fetchScreensByPlaylistId(pool, playlist.id), 'refresh');
await broadcastDashboardState();
res.redirect('/admin/playlists?edit=' + playlist.id + '&message=' + encodeURIComponent('Slide added to playlist.'));
res.redirect('/playlists?edit=' + playlist.id + '&message=' + encodeURIComponent('Slide added to playlist.'));
} catch (error) {
next(error);
}
});
app.post('/admin/playlists/:id/slides/:playlistSlideId', requirePermission('playlists.update'), async function (req, res, next) {
app.post('/playlists/:id/slides/:playlistSlideId', requirePermission('playlists.update'), async function (req, res, next) {
try {
const playlist = await common.fetchPlaylistById(pool, Number(req.params.id));
if (!playlist) {
@@ -326,13 +332,13 @@ module.exports = function registerAdminManageRoutes(app, deps) {
}
await notifyPlayerScreens(await fetchScreensByPlaylistId(pool, playlist.id), 'refresh');
await broadcastDashboardState();
res.redirect('/admin/playlists?edit=' + playlist.id + '&message=' + encodeURIComponent('Slide duration updated.'));
res.redirect('/playlists?edit=' + playlist.id + '&message=' + encodeURIComponent('Slide duration updated.'));
} catch (error) {
next(error);
}
});
app.post('/admin/playlists/:id/slides/:playlistSlideId/move', requirePermission('playlists.update'), async function (req, res, next) {
app.post('/playlists/:id/slides/:playlistSlideId/move', requirePermission('playlists.update'), async function (req, res, next) {
const connection = await pool.getConnection();
try {
const playlist = await common.fetchPlaylistById(connection, Number(req.params.id));
@@ -358,7 +364,7 @@ module.exports = function registerAdminManageRoutes(app, deps) {
const swapIndex = direction === 'up' ? currentIndex - 1 : currentIndex + 1;
if (swapIndex < 0 || swapIndex >= orderedSlides.length) {
await connection.rollback();
return res.redirect('/admin/playlists?edit=' + playlist.id + '&message=' + encodeURIComponent('Slide is already at the ' + (direction === 'up' ? 'top' : 'bottom') + '.'));
return res.redirect('/playlists?edit=' + playlist.id + '&message=' + encodeURIComponent('Slide is already at the ' + (direction === 'up' ? 'top' : 'bottom') + '.'));
}
const currentSlide = orderedSlides[currentIndex];
@@ -370,7 +376,7 @@ module.exports = function registerAdminManageRoutes(app, deps) {
await connection.commit();
await notifyPlayerScreens(await fetchScreensByPlaylistId(connection, playlist.id), 'refresh');
await broadcastDashboardState();
res.redirect('/admin/playlists?edit=' + playlist.id + '&message=' + encodeURIComponent('Slide order updated.'));
res.redirect('/playlists?edit=' + playlist.id + '&message=' + encodeURIComponent('Slide order updated.'));
} catch (error) {
await connection.rollback();
next(error);
@@ -379,7 +385,7 @@ module.exports = function registerAdminManageRoutes(app, deps) {
}
});
app.get('/admin/playlists/:id/slides/:playlistSlideId/config', requirePermission('playlists.update'), async function (req, res, next) {
app.get('/playlists/:id/slides/:playlistSlideId/config', requirePermission('playlists.update'), async function (req, res, next) {
try {
const playlist = await common.fetchPlaylistById(pool, Number(req.params.id));
if (!playlist) {
@@ -416,7 +422,7 @@ module.exports = function registerAdminManageRoutes(app, deps) {
}
});
app.post('/admin/playlists/:id/slides/:playlistSlideId/config', requirePermission('playlists.update'), async function (req, res, next) {
app.post('/playlists/:id/slides/:playlistSlideId/config', requirePermission('playlists.update'), async function (req, res, next) {
try {
const playlist = await common.fetchPlaylistById(pool, Number(req.params.id));
if (!playlist) {
@@ -490,13 +496,13 @@ module.exports = function registerAdminManageRoutes(app, deps) {
}
await notifyPlayerScreens(await fetchScreensByPlaylistId(pool, playlist.id), 'refresh');
await broadcastDashboardState();
res.redirect('/admin/playlists?edit=' + playlist.id + '&message=' + encodeURIComponent('Slide timings updated.'));
res.redirect('/playlists?edit=' + playlist.id + '&message=' + encodeURIComponent('Slide timings updated.'));
} catch (error) {
next(error);
}
});
app.post('/admin/playlists/:id/slides/:playlistSlideId/delete', requirePermission('playlists.update'), async function (req, res, next) {
app.post('/playlists/:id/slides/:playlistSlideId/delete', requirePermission('playlists.update'), async function (req, res, next) {
try {
const playlist = await common.fetchPlaylistById(pool, Number(req.params.id));
if (!playlist) {
@@ -505,13 +511,13 @@ module.exports = function registerAdminManageRoutes(app, deps) {
await pool.query('DELETE FROM playlist_slides WHERE id = ? AND playlist_id = ?', [Number(req.params.playlistSlideId), playlist.id]);
await notifyPlayerScreens(await fetchScreensByPlaylistId(pool, playlist.id), 'refresh');
await broadcastDashboardState();
res.redirect('/admin/playlists?edit=' + playlist.id + '&message=' + encodeURIComponent('Slide removed.'));
res.redirect('/playlists?edit=' + playlist.id + '&message=' + encodeURIComponent('Slide removed.'));
} catch (error) {
next(error);
}
});
app.get('/admin/screens/new', requirePermission('screens.create'), async function (req, res, next) {
app.get('/screens/new', requirePermission('screens.create'), async function (req, res, next) {
try {
const data = await common.fetchScreenEditData(pool);
res.send(pages.renderScreenFormPage(data, req.query.message ? String(req.query.message) : '', req.currentUser));
@@ -520,20 +526,23 @@ module.exports = function registerAdminManageRoutes(app, deps) {
}
});
app.post('/admin/screens', requirePermission('screens.create'), async function (req, res, next) {
app.post('/screens', requirePermission('screens.create'), async function (req, res, next) {
try {
const name = String(req.body.name || '').trim();
if (!name) {
return res.status(400).send('Screen name is required.');
}
if (await common.fetchDuplicateName(pool, 'screens', name)) {
return res.status(400).send('A screen with that name already exists.');
}
const slugInput = String(req.body.slug || '').trim();
const playlistId = req.body.playlist_id ? Number(req.body.playlist_id) : null;
const slug = await common.uniqueScreenSlug(pool, common.slugify(slugInput || name));
const actorId = getAuditUserId(req);
await pool.query('INSERT INTO screens (name, slug, playlist_id, created_by, modified_by) VALUES (?, ?, ?, ?, ?)', [name, slug, playlistId, actorId, actorId]);
redirectAfterSave(req, res, '/admin/screens', {
closeUrl: '/admin/screens',
newUrl: '/admin/screens/new',
const [result] = await pool.query('INSERT INTO screens (name, slug, playlist_id, created_by, modified_by) VALUES (?, ?, ?, ?, ?)', [name, slug, playlistId, actorId, actorId]);
redirectAfterSave(req, res, '/screens?edit=' + result.insertId, {
closeUrl: '/screens',
newUrl: '/screens/new',
message: 'Screen created.'
});
} catch (error) {
@@ -541,7 +550,7 @@ module.exports = function registerAdminManageRoutes(app, deps) {
}
});
app.post('/admin/screens/:id', requirePermission('screens.update'), async function (req, res, next) {
app.post('/screens/:id', requirePermission('screens.update'), async function (req, res, next) {
try {
const name = String(req.body.name || '').trim();
if (!name) {
@@ -551,6 +560,9 @@ module.exports = function registerAdminManageRoutes(app, deps) {
if (!screen) {
return res.status(404).send('Screen not found');
}
if (await common.fetchDuplicateName(pool, 'screens', name, screen.id)) {
return res.status(400).send('A screen with that name already exists.');
}
const slugInput = String(req.body.slug || '').trim();
const playlistId = req.body.playlist_id ? Number(req.body.playlist_id) : null;
const previousPlaylistId = screen.playlist_id;
@@ -566,9 +578,9 @@ module.exports = function registerAdminManageRoutes(app, deps) {
url: `${PLAYER_PUBLIC_BASE_URL}/screen/${encodeURIComponent(slug)}`
});
}
redirectAfterSave(req, res, '/admin/screens?edit=' + screen.id, {
closeUrl: '/admin/screens',
newUrl: '/admin/screens/new',
redirectAfterSave(req, res, '/screens?edit=' + screen.id, {
closeUrl: '/screens',
newUrl: '/screens/new',
message: 'Screen updated.'
});
} catch (error) {
@@ -576,7 +588,7 @@ module.exports = function registerAdminManageRoutes(app, deps) {
}
});
app.post('/admin/screens/:id/delete', requirePermission('screens.delete'), async function (req, res, next) {
app.post('/screens/:id/delete', requirePermission('screens.delete'), async function (req, res, next) {
try {
const screen = await common.fetchScreenById(pool, Number(req.params.id));
if (!screen) {
@@ -584,12 +596,12 @@ module.exports = function registerAdminManageRoutes(app, deps) {
}
const blockMessage = await getScreenDeleteBlockMessage(pool, screen, getScreenConnections);
if (blockMessage) {
return res.redirect('/admin/screens?message=' + encodeURIComponent(blockMessage));
return res.redirect('/screens?message=' + encodeURIComponent(blockMessage));
}
await pool.query('DELETE FROM screens WHERE id = ?', [screen.id]);
res.redirect('/admin/screens?message=' + encodeURIComponent('Screen deleted.'));
res.redirect('/screens?message=' + encodeURIComponent('Screen deleted.'));
} catch (error) {
next(error);
}
});
};
};
+9
View File
@@ -0,0 +1,9 @@
module.exports = function registerAdminPagesRoutes(app, deps) {
require('../signage/dashboard/routes')(app, deps);
require('../signage/clients/routes')(app, deps);
require('../signage/screens/routes')(app, deps);
require('../signage/playlists/routes')(app, deps);
require('../signage/slides/routes')(app, deps);
require('../signage/canvas-sizes/routes')(app, deps);
require('../signage/templates/routes')(app, deps);
};
+432
View File
@@ -0,0 +1,432 @@
module.exports = function registerAdminRbacRoutes(app, deps) {
const pool = deps.pool;
const common = deps.common;
const pages = deps.pages;
const getAuditUserId = deps.getAuditUserId;
const rbacData = deps.rbacData;
const permissions = Array.isArray(deps.permissions) ? deps.permissions : [];
const readArrayField = deps.readArrayField;
const normalizePermissionKeys = deps.normalizePermissionKeys;
const requirePermission = deps.requirePermission;
function slugifyRoleKey(name) {
const value = String(name || '').trim().toLowerCase();
const slug = value.replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
return slug || 'role';
}
function normalizeSelectedIds(values) {
return Array.from(new Set((Array.isArray(values) ? values : []).map(function (value) {
return Number(value);
}).filter(function (value) {
return Number.isInteger(value) && value > 0;
})));
}
function getActionLabel(actionKey) {
const normalizedActionKey = String(actionKey || '').trim().toLowerCase();
if (normalizedActionKey === 'edit') {
return 'Update';
}
if (normalizedActionKey === 'allow') {
return 'Allow';
}
if (!normalizedActionKey) {
return 'Read';
}
return normalizedActionKey.charAt(0).toUpperCase() + normalizedActionKey.slice(1);
}
async function createUniqueRoleKey(baseName) {
const baseKey = slugifyRoleKey(baseName);
let candidate = baseKey;
let suffix = 2;
while (true) {
const [rows] = await pool.query('SELECT id FROM roles WHERE role_key = ? LIMIT 1', [candidate]);
if (!rows.length) {
return candidate;
}
candidate = `${baseKey}-${suffix}`;
suffix += 1;
}
}
function mapPermissionsForView(permissionRows, selectedPermissionKeys) {
const selectedKeys = new Set(normalizePermissionKeys(selectedPermissionKeys));
const permissionDefinitions = new Map(permissions.map(function (permission) {
return [String(permission.key || '').trim(), permission];
}));
return (Array.isArray(permissionRows) ? permissionRows : []).map(function (permission) {
const definition = permissionDefinitions.get(String(permission.permission_key || '').trim()) || null;
return Object.assign({}, permission, {
resourceKey: definition ? definition.sectionKey : String(permission.section_name || '').trim().toLowerCase().replace(/[^a-z0-9]+/g, '-'),
resourceName: definition ? definition.name : permission.section_name,
categoryName: definition ? definition.sectionName : permission.section_name,
sectionOrder: definition ? definition.sectionOrder : 999,
actionKey: definition ? definition.actionKey : 'read',
actionLabel: definition ? definition.actionName : getActionLabel(permission.actionKey),
isSelected: selectedKeys.has(String(permission.permission_key || '').trim())
});
});
}
function mapUsersForView(userRows, selectedUserIds) {
const selectedIds = new Set(normalizeSelectedIds(selectedUserIds));
return (Array.isArray(userRows) ? userRows : []).map(function (user) {
return Object.assign({}, user, {
isSelected: selectedIds.has(Number(user.id))
});
});
}
function buildPermissionGroups(permissionRows) {
const groups = [];
const groupIndex = new Map();
(Array.isArray(permissionRows) ? permissionRows : []).forEach(function (permission) {
const sectionKey = String(permission.resourceKey || permission.sectionKey || permission.sectionName || '').trim().toLowerCase().replace(/[^a-z0-9]+/g, '-');
if (!groupIndex.has(sectionKey)) {
const group = {
id: sectionKey || 'permissions',
title: String(permission.resourceName || permission.categoryName || 'Permissions').trim(),
categoryName: String(permission.categoryName || '').trim(),
sectionOrder: Number(permission.sectionOrder) || 999,
permissions: []
};
groupIndex.set(sectionKey, group);
groups.push(group);
}
groupIndex.get(sectionKey).permissions.push(permission);
});
groups.forEach(function (group) {
group.permissions.sort(function (left, right) {
const actionOrder = { create: 1, read: 2, update: 3, edit: 3, delete: 4, allow: 5 };
const leftOrder = actionOrder[String(left.actionKey || '').trim()] || 99;
const rightOrder = actionOrder[String(right.actionKey || '').trim()] || 99;
if (leftOrder !== rightOrder) {
return leftOrder - rightOrder;
}
return String(left.name || '').localeCompare(String(right.name || ''));
});
});
groups.sort(function (left, right) {
const leftOrder = Number(left.sectionOrder) || 999;
const rightOrder = Number(right.sectionOrder) || 999;
if (leftOrder !== rightOrder) {
return leftOrder - rightOrder;
}
return String(left.title || '').localeCompare(String(right.title || ''));
});
return groups;
}
async function buildRoleCreateViewModel(formValues, selectedPermissionKeys) {
const permissionRows = await rbacData.fetchPermissions(pool);
return {
formValues: {
name: String(formValues && formValues.name || '').trim(),
description: String(formValues && formValues.description || '').trim()
},
permissionGroups: buildPermissionGroups(mapPermissionsForView(permissionRows, selectedPermissionKeys))
};
}
async function loadRoleViewModel(roleId) {
const role = await rbacData.fetchRoleById(pool, roleId);
if (!role) {
return null;
}
const permissionRows = await rbacData.fetchPermissions(pool);
const selectedPermissionKeys = await rbacData.fetchRolePermissionKeys(pool, role.id);
const selectedUserIds = await rbacData.fetchRoleUserIds(pool, role.id);
const users = await rbacData.fetchUsersWithRoles(pool);
return {
role: Object.assign({}, role, {
permissionKeys: selectedPermissionKeys,
permissionCount: Number(role.permission_count) || 0,
userCount: Number(role.user_count) || 0
}),
permissionGroups: buildPermissionGroups(mapPermissionsForView(permissionRows, selectedPermissionKeys)),
users: mapUsersForView(users, selectedUserIds)
};
}
app.get('/rbac', requirePermission('rbac.read'), async function (req, res, next) {
try {
const roles = await rbacData.fetchRoles(pool);
res.send(pages.renderRbacPage({ roles: roles }, req.query.message ? String(req.query.message) : '', req.currentUser));
} catch (error) {
next(error);
}
});
app.get('/rbac/new', requirePermission('rbac.create'), function (req, res, next) {
buildRoleCreateViewModel({
name: String(req.query.name || '').trim(),
description: String(req.query.description || '').trim()
}, []).then(function (viewModel) {
res.send(pages.renderRbacAddPage(req.query.message ? String(req.query.message) : '', req.currentUser, viewModel.formValues, viewModel.permissionGroups, 'primary'));
}).catch(function (error) {
next(error);
});
});
app.post('/rbac', requirePermission('rbac.create'), async function (req, res, next) {
try {
const name = String(req.body.name || '').trim();
const description = String(req.body.description || '').trim();
const selectedPermissionKeys = normalizePermissionKeys(Array.isArray(req.body['permission_keys[]'])
? req.body['permission_keys[]']
: req.body.permission_keys
? [].concat(req.body.permission_keys)
: []);
const validPermissionKeys = new Set(permissions.map(function (permission) {
return String(permission.key || '').trim();
}));
const createViewModel = await buildRoleCreateViewModel({ name: name, description: description }, selectedPermissionKeys);
if (!name) {
return res.status(400).send(pages.renderRbacAddPage('Role name is required.', req.currentUser, createViewModel.formValues, createViewModel.permissionGroups, 'warning'));
}
if (await common.fetchDuplicateName(pool, 'roles', name)) {
return res.status(400).send(pages.renderRbacAddPage('A role with that name already exists.', req.currentUser, createViewModel.formValues, createViewModel.permissionGroups, 'warning'));
}
if (selectedPermissionKeys.some(function (permissionKey) {
return !validPermissionKeys.has(permissionKey);
})) {
return res.status(400).send(pages.renderRbacAddPage('One or more selected permissions are invalid.', req.currentUser, createViewModel.formValues, createViewModel.permissionGroups, 'warning'));
}
const roleKey = await createUniqueRoleKey(name);
const actorId = getAuditUserId(req);
const connection = await pool.getConnection();
let insertedRoleId = null;
try {
await connection.beginTransaction();
const [result] = await connection.query(
'INSERT INTO roles (role_key, name, description, created_by, modified_by) VALUES (?, ?, ?, ?, ?)',
[roleKey, name, description || null, actorId, actorId]
);
insertedRoleId = Number(result.insertId);
if (selectedPermissionKeys.length) {
await rbacData.syncRolePermissions(connection, insertedRoleId, selectedPermissionKeys);
}
await connection.commit();
} catch (error) {
await connection.rollback();
throw error;
} finally {
connection.release();
}
res.redirect('/rbac/' + insertedRoleId + '/edit?message=' + encodeURIComponent('Role created.'));
} catch (error) {
next(error);
}
});
app.get('/rbac/:id/edit', requirePermission('rbac.update'), async function (req, res, next) {
try {
const roleId = Number(req.params.id);
if (!Number.isInteger(roleId) || roleId <= 0) {
return res.status(400).send('Invalid role.');
}
const viewModel = await loadRoleViewModel(roleId);
if (!viewModel) {
return res.status(404).send('Role not found.');
}
const currentUserId = req.currentUser ? Number(req.currentUser.id) : null;
const users = Array.isArray(viewModel.users)
? viewModel.users.filter(function (user) {
return Number(user && user.id) !== currentUserId;
})
: [];
res.send(pages.renderRbacEditPage(viewModel.role, req.query.message ? String(req.query.message) : '', req.currentUser, viewModel.permissionGroups, users));
} catch (error) {
next(error);
}
});
app.post('/rbac/:id', requirePermission('rbac.update'), async function (req, res, next) {
try {
const roleId = Number(req.params.id);
if (!Number.isInteger(roleId) || roleId <= 0) {
return res.status(400).send('Invalid role.');
}
const role = await rbacData.fetchRoleById(pool, roleId);
if (!role) {
return res.status(404).send('Role not found.');
}
const name = String(req.body.name || '').trim();
const description = String(req.body.description || '').trim();
const shouldSyncPermissions = Object.prototype.hasOwnProperty.call(req.body || {}, 'permissions_present');
const shouldSyncUsers = Object.prototype.hasOwnProperty.call(req.body || {}, 'users_present');
const selectedPermissionKeys = shouldSyncPermissions
? readArrayField(req.body, ['permission_keys[]', 'permission_keys'])
: [];
const selectedUserIds = shouldSyncUsers
? readArrayField(req.body, ['user_ids[]', 'user_ids'])
: [];
const normalizedPermissionKeys = shouldSyncPermissions ? normalizePermissionKeys(selectedPermissionKeys) : [];
const normalizedUserIds = shouldSyncUsers ? normalizeSelectedIds(selectedUserIds) : [];
if (!name) {
return res.redirect('/rbac/' + roleId + '/edit?message=' + encodeURIComponent('Role name is required.'));
}
if (await common.fetchDuplicateName(pool, 'roles', name, roleId)) {
return res.redirect('/rbac/' + roleId + '/edit?message=' + encodeURIComponent('A role with that name already exists.'));
}
const validPermissionKeys = new Set(permissions.map(function (permission) {
return String(permission.key || '').trim();
}));
if (shouldSyncPermissions && normalizedPermissionKeys.some(function (permissionKey) {
return !validPermissionKeys.has(permissionKey);
})) {
return res.redirect('/rbac/' + roleId + '/edit?message=' + encodeURIComponent('One or more selected permissions are invalid.'));
}
let availableUsers = [];
if (shouldSyncUsers) {
availableUsers = await rbacData.fetchUsersWithRoles(pool);
}
const validUserIds = new Set(availableUsers.map(function (user) {
return Number(user.id);
}));
if (shouldSyncUsers && normalizedUserIds.some(function (userId) {
return !validUserIds.has(userId);
})) {
return res.redirect('/rbac/' + roleId + '/edit?message=' + encodeURIComponent('One or more selected users are invalid.'));
}
const connection = await pool.getConnection();
try {
await connection.beginTransaction();
await connection.query(
'UPDATE roles SET name = ?, description = ?, modified_by = ? WHERE id = ?',
[name, description || null, getAuditUserId(req), roleId]
);
if (shouldSyncPermissions) {
await rbacData.syncRolePermissions(connection, roleId, normalizedPermissionKeys);
}
if (shouldSyncUsers) {
await rbacData.syncRoleUsers(connection, roleId, normalizedUserIds);
}
await connection.commit();
} catch (error) {
await connection.rollback();
throw error;
} finally {
connection.release();
}
res.redirect('/rbac/' + roleId + '/edit?message=' + encodeURIComponent('Role updated.'));
} catch (error) {
next(error);
}
});
app.post('/rbac/:id/permissions', requirePermission('rbac.update'), async function (req, res, next) {
try {
const roleId = Number(req.params.id);
if (!Number.isInteger(roleId) || roleId <= 0) {
return res.status(400).send('Invalid role.');
}
const role = await rbacData.fetchRoleById(pool, roleId);
if (!role) {
return res.status(404).send('Role not found.');
}
const selectedPermissionKeys = Array.isArray(req.body['permission_keys[]'])
? req.body['permission_keys[]']
: req.body.permission_keys
? [].concat(req.body.permission_keys)
: [];
const validPermissionKeys = new Set(permissions.map(function (permission) {
return String(permission.key || '').trim();
}));
const normalizedPermissionKeys = normalizePermissionKeys(selectedPermissionKeys);
if (normalizedPermissionKeys.some(function (permissionKey) {
return !validPermissionKeys.has(permissionKey);
})) {
return res.redirect('/rbac/' + roleId + '/edit?message=' + encodeURIComponent('One or more selected permissions are invalid.'));
}
await rbacData.syncRolePermissions(pool, roleId, normalizedPermissionKeys);
res.redirect('/rbac/' + roleId + '/edit?message=' + encodeURIComponent('Permissions updated.'));
} catch (error) {
next(error);
}
});
app.post('/rbac/:id/users', requirePermission('rbac.update'), async function (req, res, next) {
try {
const roleId = Number(req.params.id);
if (!Number.isInteger(roleId) || roleId <= 0) {
return res.status(400).send('Invalid role.');
}
const role = await rbacData.fetchRoleById(pool, roleId);
if (!role) {
return res.status(404).send('Role not found.');
}
const selectedUserIds = Array.isArray(req.body['user_ids[]'])
? req.body['user_ids[]']
: req.body.user_ids
? [].concat(req.body.user_ids)
: [];
const normalizedUserIds = normalizeSelectedIds(selectedUserIds);
const availableUsers = await rbacData.fetchUsersWithRoles(pool);
const validUserIds = new Set(availableUsers.map(function (user) {
return Number(user.id);
}));
if (normalizedUserIds.some(function (userId) {
return !validUserIds.has(userId);
})) {
return res.redirect('/rbac/' + roleId + '/edit?message=' + encodeURIComponent('One or more selected users are invalid.'));
}
await rbacData.syncRoleUsers(pool, roleId, normalizedUserIds);
res.redirect('/rbac/' + roleId + '/edit?message=' + encodeURIComponent('Users updated.'));
} catch (error) {
next(error);
}
});
app.post('/rbac/:id/delete', requirePermission('rbac.delete'), async function (req, res, next) {
try {
const roleId = Number(req.params.id);
if (!Number.isInteger(roleId) || roleId <= 0) {
return res.status(400).send('Invalid role.');
}
const role = await rbacData.fetchRoleById(pool, roleId);
if (!role) {
return res.status(404).send('Role not found.');
}
if (String(role.role_key || '') === 'administrators') {
return res.redirect('/rbac?message=' + encodeURIComponent('The built-in Administrators role cannot be deleted.'));
}
if (Number(role.user_count) > 0) {
return res.redirect('/rbac?message=' + encodeURIComponent('Remove all users from this role before deleting it.'));
}
await pool.query('DELETE FROM roles WHERE id = ?', [roleId]);
res.redirect('/rbac?message=' + encodeURIComponent('Role deleted.'));
} catch (error) {
next(error);
}
});
};
+303
View File
@@ -0,0 +1,303 @@
module.exports = function registerAdminUsersRoutes(app, deps) {
const pool = deps.pool;
const common = deps.common;
const pages = deps.pages;
const formatDashboardDate = deps.formatDashboardDate;
const getAuditUserId = deps.getAuditUserId;
const hashPassword = deps.hashPassword;
const readArrayField = deps.readArrayField;
const rbacData = deps.rbacData;
const requirePermission = deps.requirePermission;
async function fetchRoleOptions() {
return rbacData.fetchRoles(pool);
}
function mapRolesForForm(roles, selectedRoleIds) {
const selectedIds = new Set((Array.isArray(selectedRoleIds) ? selectedRoleIds : []).map(function (roleId) {
return Number(roleId);
}).filter(function (roleId) {
return Number.isInteger(roleId) && roleId > 0;
}));
return (Array.isArray(roles) ? roles : []).map(function (role) {
return Object.assign({}, role, {
isSelected: selectedIds.has(Number(role.id))
});
});
}
async function validateRoleIds(roleIds) {
const availableRoles = await fetchRoleOptions();
const validRoleIds = new Set(availableRoles.map(function (role) {
return Number(role.id);
}));
const normalizedRoleIds = Array.from(new Set((Array.isArray(roleIds) ? roleIds : []).map(function (roleId) {
return Number(roleId);
}).filter(function (roleId) {
return Number.isInteger(roleId) && roleId > 0;
})));
if (!normalizedRoleIds.length) {
return { ok: false, message: 'Select at least one role.' };
}
if (normalizedRoleIds.some(function (roleId) {
return !validRoleIds.has(roleId);
})) {
return { ok: false, message: 'One or more selected roles are invalid.' };
}
return { ok: true, roleIds: normalizedRoleIds };
}
app.get('/users', requirePermission('users.read'), async function (req, res, next) {
try {
const users = await rbacData.fetchUsersWithRoles(pool);
const mappedUsers = users.map(function (user) {
return Object.assign({}, user, {
isCurrentUser: Number(user.id) === Number(req.currentUser.id),
createdAtLabel: formatDashboardDate(user.created_at),
modifiedAtLabel: formatDashboardDate(user.modified_at),
roleNames: String(user.roleNames || '').trim() || 'No roles assigned'
});
});
res.send(pages.renderUsersPage({ users: mappedUsers }, req.query.message ? String(req.query.message) : '', req.currentUser));
} catch (error) {
next(error);
}
});
app.get('/users/new', requirePermission('users.create'), function (req, res) {
fetchRoleOptions().then(function (roles) {
res.send(pages.renderUsersAddPage(req.query.message ? String(req.query.message) : '', req.currentUser, mapRolesForForm(roles, []), {}, 'primary'));
}).catch(function (error) {
res.status(500).send(error.message || 'Unable to load roles.');
});
});
app.get('/users/:id/edit', requirePermission('users.update'), async function (req, res, next) {
try {
const userId = Number(req.params.id);
if (!Number.isInteger(userId) || userId <= 0) {
return res.status(400).send('Invalid user.');
}
const user = await rbacData.fetchUserWithRoles(pool, userId);
if (!user) {
return res.status(404).send('User not found.');
}
if (Number(req.currentUser.id) === userId) {
return res.redirect('/account?message=' + encodeURIComponent('Use My Account to update your own login details.'));
}
const roles = await fetchRoleOptions();
res.send(pages.renderUsersEditPage(Object.assign({}, user, {
isCurrentUser: false,
createdAtLabel: formatDashboardDate(user.created_at),
modifiedAtLabel: formatDashboardDate(user.modified_at),
roleNames: String(user.roleNames || '').trim() || 'No roles assigned'
}), req.query.message ? String(req.query.message) : '', req.currentUser, mapRolesForForm(roles, user.roleIds)));
} catch (error) {
next(error);
}
});
app.post('/users', requirePermission('users.create'), async function (req, res, next) {
const connection = await pool.getConnection();
try {
const name = String(req.body.name || '').trim();
const username = String(req.body.username || '').trim();
const password = String(req.body.password || '');
const confirmPassword = String(req.body.confirm_password || '');
const selectedRoleIds = readArrayField(req.body, ['role_ids[]', 'role_ids']);
const roleCheck = await validateRoleIds(selectedRoleIds);
const formValues = {
username: username,
name: name
};
async function renderValidationError(message) {
const roles = await fetchRoleOptions();
return res.status(400).send(pages.renderUsersAddPage(message, req.currentUser, mapRolesForForm(roles, selectedRoleIds), formValues, 'warning'));
}
if (!name) {
return renderValidationError('Name is required.');
}
if (!username) {
return renderValidationError('Username is required.');
}
if (!password || password.length < 8) {
return renderValidationError('Password must be at least 8 characters.');
}
if (password !== confirmPassword) {
return renderValidationError('Passwords do not match.');
}
if (!roleCheck.ok) {
return renderValidationError(roleCheck.message);
}
if (await common.fetchDuplicateName(pool, 'users', name)) {
return renderValidationError('That name already exists.');
}
const [existingRows] = await connection.query('SELECT id FROM users WHERE username = ? LIMIT 1', [username]);
if (existingRows.length) {
return renderValidationError('That username already exists.');
}
const passwordRecord = hashPassword(password);
const actorId = getAuditUserId(req);
await connection.beginTransaction();
const [result] = await connection.query(
'INSERT INTO users (name, username, password_hash, password_salt, password_iterations, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?)',
[name, username, passwordRecord.hash, passwordRecord.salt, passwordRecord.iterations, actorId, actorId]
);
await rbacData.syncUserRoles(connection, result.insertId, roleCheck.roleIds);
await connection.commit();
res.redirect('/users?message=' + encodeURIComponent('User created.'));
} catch (error) {
try {
await connection.rollback();
} catch (_rollbackError) {
// Ignore rollback failures and surface the original error.
}
next(error);
} finally {
connection.release();
}
});
app.post('/users/:id/roles', requirePermission('users.update'), async function (req, res, next) {
try {
const userId = Number(req.params.id);
if (!Number.isInteger(userId) || userId <= 0) {
return res.status(400).send('Invalid user.');
}
if (Number(req.currentUser.id) === userId) {
return res.redirect('/account?message=' + encodeURIComponent('Use My Account to update your own login details.'));
}
const [rows] = await pool.query('SELECT id FROM users WHERE id = ? LIMIT 1', [userId]);
if (!rows.length) {
return res.status(404).send('User not found.');
}
const selectedRoleIds = readArrayField(req.body, ['role_ids[]', 'role_ids']);
const roleCheck = await validateRoleIds(selectedRoleIds);
if (!roleCheck.ok) {
return res.redirect('/users/' + userId + '/edit?message=' + encodeURIComponent(roleCheck.message));
}
await rbacData.syncUserRoles(pool, userId, roleCheck.roleIds);
res.redirect('/users/' + userId + '/edit?message=' + encodeURIComponent('Roles updated.'));
} catch (error) {
next(error);
}
});
app.post('/users/:id/username', requirePermission('users.update'), async function (req, res, next) {
try {
const userId = Number(req.params.id);
const name = String(req.body.name || '').trim();
if (!Number.isInteger(userId) || userId <= 0) {
return res.status(400).send('Invalid user.');
}
if (!name) {
return res.redirect('/users/' + userId + '/edit?message=' + encodeURIComponent('Name is required.'));
}
if (Number(req.currentUser.id) === userId) {
return res.redirect('/account?message=' + encodeURIComponent('Use My Account to update your own username or password.'));
}
const [userRows] = await pool.query('SELECT id, username FROM users WHERE id = ? LIMIT 1', [userId]);
if (!userRows.length) {
return res.status(404).send('User not found.');
}
const username = String(req.body.username || userRows[0].username || '').trim();
if (!username) {
return res.redirect('/users/' + userId + '/edit?message=' + encodeURIComponent('Username is required.'));
}
if (await common.fetchDuplicateName(pool, 'users', name, userId)) {
return res.redirect('/users/' + userId + '/edit?message=' + encodeURIComponent('That name already exists.'));
}
const [existingRows] = await pool.query('SELECT id FROM users WHERE username = ? AND id <> ? LIMIT 1', [username, userId]);
if (existingRows.length) {
return res.redirect('/users/' + userId + '/edit?message=' + encodeURIComponent('That username already exists.'));
}
const [result] = await pool.query('UPDATE users SET name = ?, username = ?, modified_by = ? WHERE id = ?', [name, username, getAuditUserId(req), userId]);
if (!result.affectedRows) {
return res.status(404).send('User not found.');
}
res.redirect('/users?message=' + encodeURIComponent('User updated.'));
} catch (error) {
next(error);
}
});
app.post('/users/:id/password', requirePermission('users.update'), async function (req, res, next) {
try {
const userId = Number(req.params.id);
const password = String(req.body.password || '');
const confirmPassword = String(req.body.confirm_password || '');
if (!Number.isInteger(userId) || userId <= 0) {
return res.status(400).send('Invalid user.');
}
if (Number(req.currentUser.id) === userId) {
return res.redirect('/account?message=' + encodeURIComponent('Use My Account to change your own password.'));
}
if (!password || password.length < 8) {
return res.redirect('/users?message=' + encodeURIComponent('Password must be at least 8 characters.'));
}
if (password !== confirmPassword) {
return res.redirect('/users?message=' + encodeURIComponent('Passwords do not match.'));
}
const [rows] = await pool.query('SELECT id FROM users WHERE id = ? LIMIT 1', [userId]);
if (!rows.length) {
return res.status(404).send('User not found.');
}
const passwordRecord = hashPassword(password);
await pool.query(
'UPDATE users SET password_hash = ?, password_salt = ?, password_iterations = ?, modified_by = ? WHERE id = ?',
[passwordRecord.hash, passwordRecord.salt, passwordRecord.iterations, getAuditUserId(req), userId]
);
await pool.query('DELETE FROM auth_sessions WHERE user_id = ?', [userId]);
res.redirect('/users?message=' + encodeURIComponent('Password updated.'));
} catch (error) {
next(error);
}
});
app.post('/users/:id/delete', requirePermission('users.delete'), async function (req, res, next) {
try {
const userId = Number(req.params.id);
if (!Number.isInteger(userId) || userId <= 0) {
return res.status(400).send('Invalid user.');
}
if (Number(req.currentUser.id) === userId) {
return res.redirect('/users?message=' + encodeURIComponent('You cannot delete your own account from the users page.'));
}
const [countRows] = await pool.query('SELECT COUNT(*) AS user_count FROM users');
if (!countRows.length || Number(countRows[0].user_count) <= 1) {
return res.redirect('/users?message=' + encodeURIComponent('At least one user must remain.'));
}
const [result] = await pool.query('DELETE FROM users WHERE id = ?', [userId]);
if (!result.affectedRows) {
return res.status(404).send('User not found.');
}
res.redirect('/users?message=' + encodeURIComponent('User deleted.'));
} catch (error) {
next(error);
}
});
};
@@ -10,12 +10,12 @@ module.exports = function registerAuthRoutes(app, deps) {
const sessionCookieName = deps.sessionCookieName;
app.get('/', function (req, res) {
res.redirect(req.currentUser ? '/admin' : '/login');
res.redirect(req.currentUser ? '/dashboard' : '/login');
});
app.get('/login', function (req, res) {
if (req.currentUser) {
return res.redirect('/admin');
return res.redirect('/dashboard');
}
res.send(pages.renderLoginPage(req.query.message ? String(req.query.message) : ''));
});
@@ -36,7 +36,7 @@ module.exports = function registerAuthRoutes(app, deps) {
const token = await createUserSession(pool, user.id);
setSessionCookie(res, token);
res.redirect('/admin');
res.redirect('/dashboard');
} catch (error) {
next(error);
}
+1 -1
View File
@@ -1,7 +1,7 @@
const { renderView } = require('../../view');
module.exports = function renderLoginPage(message) {
return renderView('login', {
return renderView('auth/login', {
title: 'Sign in',
authShell: true,
bodyClass: 'login-page-body',
@@ -0,0 +1,31 @@
const { renderView } = require('../../../view');
function buildDefaultApiSource() {
return {
id: null,
name: '',
apiUrl: '',
updateIntervalValue: 60,
updateIntervalUnit: 'minutes',
lastPulledAt: null,
lastPullError: '',
lastResponseStatus: null,
lastResponseContentType: '',
lastResponseJson: ''
};
}
module.exports = function renderApiSourceFormPage(apiSource, mode, message, currentUser) {
const isEdit = mode === 'edit';
const viewApiSource = Object.assign(buildDefaultApiSource(), apiSource || {});
return renderView(isEdit ? 'data-sources/api-sources/edit' : 'data-sources/api-sources/add', {
title: isEdit ? 'Edit API source' : 'Add API source',
active: 'api-sources',
message: message,
currentUser: currentUser || null,
apiSource: viewApiSource,
lastResponseJson: viewApiSource.lastResponseJson || '',
lastPullError: viewApiSource.lastPullError || ''
});
};
@@ -0,0 +1,13 @@
const renderApiSourceFormPage = require('./add');
module.exports = function renderApiSourceEditPage(apiSource, data, message, currentUser) {
const viewData = Object.assign({
lastResponseJson: '',
lastPullError: ''
}, data || {});
return renderApiSourceFormPage(Object.assign({}, apiSource, {
lastResponseJson: viewData.lastResponseJson,
lastPullError: viewData.lastPullError
}), 'edit', message, currentUser);
};
@@ -0,0 +1,48 @@
const { renderView } = require('../../../view');
function toIsoTimestamp(value) {
if (!value) {
return '';
}
const date = new Date(value);
return Number.isNaN(date.getTime()) ? '' : date.toISOString();
}
function formatIntervalLabel(interval, unit) {
const value = Math.max(1, Number(interval) || 0);
const normalizedUnit = String(unit || 'minutes').trim().toLowerCase() === 'seconds' ? 'seconds' : 'minutes';
if (normalizedUnit === 'seconds') {
return value === 1 ? 'Every second' : `Every ${value} seconds`;
}
return value === 1 ? 'Every minute' : `Every ${value} minutes`;
}
function formatLastPullLabel(apiSource, formatDashboardDate) {
if (!apiSource.last_pulled_at) {
return 'Never';
}
const timestamp = typeof formatDashboardDate === 'function'
? formatDashboardDate(apiSource.last_pulled_at)
: String(apiSource.last_pulled_at);
return timestamp || 'Never';
}
module.exports = function renderApiSourcesPage(data, message, currentUser, formatDashboardDate) {
const apiSources = (data.apiSources || []).map(function (apiSource) {
return Object.assign({}, apiSource, {
intervalLabel: formatIntervalLabel(apiSource.update_interval_value, apiSource.update_interval_unit),
lastPullLabel: formatLastPullLabel(apiSource, formatDashboardDate),
lastPulledAtValue: toIsoTimestamp(apiSource.last_pulled_at)
});
});
return renderView('data-sources/api-sources/list', {
title: 'API sources',
active: 'api-sources',
message: message,
currentUser: currentUser || null,
apiSources: apiSources
});
};
+2
View File
@@ -0,0 +1,2 @@
module.exports = require('../admin/data-sources');
@@ -0,0 +1,27 @@
const { renderView } = require('../../../view');
function buildDefaultRssFeed() {
return {
id: null,
name: '',
feedUrl: '',
updateIntervalValue: 60,
updateIntervalUnit: 'minutes',
itemLimit: 1
};
}
module.exports = function renderRssFeedFormPage(rssFeed, mode, message, currentUser) {
const isEdit = mode === 'edit';
const viewRssFeed = Object.assign(buildDefaultRssFeed(), rssFeed || {});
return renderView(isEdit ? 'data-sources/rss-feeds/edit' : 'data-sources/rss-feeds/add', {
title: isEdit ? 'Edit RSS feed' : 'Add RSS feed',
active: 'rss-feeds',
message: message,
currentUser: currentUser || null,
rssFeed: viewRssFeed,
pulledItems: viewRssFeed.pulledItems || [],
pullError: viewRssFeed.pullError || ''
});
};
@@ -0,0 +1,13 @@
const renderRssFeedFormPage = require('./add');
module.exports = function renderRssFeedEditPage(rssFeed, data, message, currentUser) {
const viewData = Object.assign({
pulledItems: [],
pullError: ''
}, data || {});
return renderRssFeedFormPage(Object.assign({}, rssFeed, {
pulledItems: viewData.pulledItems,
pullError: viewData.pullError
}), 'edit', message, currentUser);
};
@@ -0,0 +1,32 @@
const { renderView } = require('../../../view');
function formatIntervalLabel(interval, unit) {
const value = Math.max(1, Number(interval) || 0);
const normalizedUnit = String(unit || 'minutes').trim().toLowerCase() === 'seconds' ? 'seconds' : 'minutes';
if (normalizedUnit === 'seconds') {
return value === 1 ? 'Every second' : `Every ${value} seconds`;
}
return value === 1 ? 'Every minute' : `Every ${value} minutes`;
}
function formatItemLabel(count) {
const value = Math.max(1, Number(count) || 0);
return value === 1 ? 'Latest item' : `Latest ${value} items`;
}
module.exports = function renderRssFeedsPage(data, message, currentUser) {
const rssFeeds = (data.rssFeeds || []).map(function (rssFeed) {
return Object.assign({}, rssFeed, {
intervalLabel: formatIntervalLabel(rssFeed.update_interval_value, rssFeed.update_interval_unit),
itemLabel: formatItemLabel(rssFeed.item_limit)
});
});
return renderView('data-sources/rss-feeds/list', {
title: 'RSS feeds',
active: 'rss-feeds',
message: message,
currentUser: currentUser || null,
rssFeeds: rssFeeds
});
};
+2 -2
View File
@@ -42,7 +42,7 @@ module.exports = function renderErrorPage(options, currentUser) {
const errorOptions = options || {};
const statusCode = Number(errorOptions.statusCode || 500);
const copy = getErrorCopy(statusCode, String(errorOptions.message || '').trim());
return renderView('error', {
return renderView('error/error', {
title: String(errorOptions.title || copy.title || 'Error').trim(),
active: '',
messageVariant: 'primary',
@@ -51,7 +51,7 @@ module.exports = function renderErrorPage(options, currentUser) {
errorTitle: String(errorOptions.errorTitle || copy.errorTitle || errorOptions.title || 'Error').trim(),
errorMessage: String(errorOptions.message || copy.errorMessage || 'An unexpected error occurred.').trim(),
detail: String(errorOptions.detail || '').trim(),
backUrl: String(errorOptions.backUrl || '/admin').trim() || '/admin',
backUrl: String(errorOptions.backUrl || '/dashboard').trim() || '/dashboard',
backLabel: String(errorOptions.backLabel || copy.backLabel || 'Back to dashboard').trim() || 'Back to dashboard',
searchUrl: String(errorOptions.searchUrl || '').trim(),
bodyClass: 'error-page bg-dark text-white',
+7
View File
@@ -10,6 +10,12 @@ module.exports = {
renderPlaylistFormPage: require('./playlists/add'),
renderPlaylistEditPage: require('./playlists/edit'),
renderPlaylistSlideConfigPage: require('./playlists/slide-config'),
renderApiSourcesPage: require('./data-sources/api-sources/list'),
renderApiSourceFormPage: require('./data-sources/api-sources/add'),
renderApiSourceEditPage: require('./data-sources/api-sources/edit'),
renderRssFeedsPage: require('./data-sources/rss-feeds/list'),
renderRssFeedFormPage: require('./data-sources/rss-feeds/add'),
renderRssFeedEditPage: require('./data-sources/rss-feeds/edit'),
renderScreensPage: require('./screens/list'),
renderScreenFormPage: require('./screens/add'),
renderScreenEditPage: require('./screens/edit'),
@@ -21,6 +27,7 @@ module.exports = {
renderCanvasSizesPage: require('./canvas-sizes/list'),
renderCanvasSizeFormPage: require('./canvas-sizes/add'),
renderCanvasSizeEditPage: require('./canvas-sizes/edit'),
renderBackgroundTasksPage: require('./settings/background-tasks-page'),
renderErrorPage: require('./error'),
renderRbacPage: require('./rbac/list'),
renderRbacAddPage: require('./rbac/add'),
@@ -0,0 +1,88 @@
const { renderView } = require('../../view');
const { hasAnyPermission } = require('../../../rbac');
const TASKS_PER_PAGE = 10;
function formatIntervalLabel(intervalMs) {
const value = Math.max(1, Number(intervalMs) || 0);
if (value % 60000 === 0) {
const minutes = Math.max(1, value / 60000);
return minutes === 1 ? 'Every minute' : `Every ${minutes} minutes`;
}
if (value % 1000 === 0) {
const seconds = Math.max(1, value / 1000);
return seconds === 1 ? 'Every second' : `Every ${seconds} seconds`;
}
return `${value} ms`;
}
function parsePageNumber(value) {
const pageNumber = Math.floor(Number(value) || 1);
return Math.max(1, pageNumber);
}
function buildPagination(totalItems, currentPage, pageParam) {
const normalizedPageParam = String(pageParam || 'page').trim() || 'page';
const totalPages = Math.max(1, Math.ceil(totalItems / TASKS_PER_PAGE));
const safeCurrentPage = Math.min(Math.max(1, currentPage), totalPages);
const startIndex = (safeCurrentPage - 1) * TASKS_PER_PAGE;
const endIndex = Math.min(totalItems, startIndex + TASKS_PER_PAGE);
const pages = [];
for (let pageNumber = 1; pageNumber <= totalPages; pageNumber += 1) {
pages.push({
number: pageNumber,
active: pageNumber === safeCurrentPage,
url: `?${normalizedPageParam}=${pageNumber}`
});
}
return {
currentPage: safeCurrentPage,
totalPages: totalPages,
totalItems: totalItems,
hasMultiplePages: totalPages > 1,
startItem: totalItems === 0 ? 0 : startIndex + 1,
endItem: endIndex,
hasPrevious: safeCurrentPage > 1,
hasNext: safeCurrentPage < totalPages,
previousUrl: `?${normalizedPageParam}=${safeCurrentPage - 1}`,
nextUrl: `?${normalizedPageParam}=${safeCurrentPage + 1}`,
pages: pages,
pageSize: TASKS_PER_PAGE,
pageParam: normalizedPageParam
};
}
module.exports = function renderBackgroundTasksPage(data, message, currentUser) {
const tasks = (data && data.tasks) || [];
const recurringTasks = (data && data.recurringTasks) || [];
const summary = (data && data.summary) || { counts: {}, total: 0, activeCount: 0 };
const canManage = hasAnyPermission(currentUser, ['background-tasks.allow']);
const pagination = buildPagination(tasks.length, parsePageNumber(data && data.page), 'page');
const recurringPagination = buildPagination(recurringTasks.length, parsePageNumber(data && data.recurringPage), 'recurringPage');
const visibleTasks = tasks.slice((pagination.currentPage - 1) * TASKS_PER_PAGE, pagination.currentPage * TASKS_PER_PAGE);
const visibleRecurringTasks = recurringTasks.slice((recurringPagination.currentPage - 1) * TASKS_PER_PAGE, recurringPagination.currentPage * TASKS_PER_PAGE);
return renderView('settings/background-tasks/index', {
title: 'Background tasks',
active: 'background-tasks',
message: message,
currentUser: currentUser || null,
scripts: ['js/settings/background-tasks.js'],
stateVersion: data && data.stateVersion ? String(data.stateVersion) : '',
tasks: visibleTasks,
recurringTasks: visibleRecurringTasks.map(function (task) {
return Object.assign({}, task, {
intervalLabel: formatIntervalLabel(task.intervalMs)
});
}),
summary: summary,
pagination: pagination,
recurringPagination: recurringPagination,
canManage: canManage,
canClearFinished: tasks.some(function (task) {
return task.status !== 'queued' && task.status !== 'running';
})
});
};
@@ -0,0 +1,99 @@
const { hasAnyPermission } = require('../../../rbac');
function requireSettingsAccess() {
return function (req, res, next) {
if (!req.currentUser) {
return res.redirect('/login?message=' + encodeURIComponent('Please sign in to continue.'));
}
if (hasAnyPermission(req.currentUser, ['background-tasks.read', 'background-tasks.allow'])) {
return next();
}
const error = new Error('You do not have permission to access this area.');
error.statusCode = 403;
error.expose = true;
next(error);
};
}
module.exports = function registerAdminSettingsRoutes(app, deps) {
const pages = deps.pages;
const backgroundTaskQueue = deps.backgroundTaskQueue;
if (!pages || !backgroundTaskQueue) {
throw new Error('registerAdminSettingsRoutes requires pages and backgroundTaskQueue.');
}
function buildQueueVersion(tasks, recurringTasks, summary) {
return [
'q:' + Number(summary && summary.counts && summary.counts.queued || 0),
'r:' + Number(summary && summary.counts && summary.counts.running || 0),
'f:' + Number(summary && summary.counts && summary.counts.failed || 0),
'c:' + Number(summary && summary.counts && summary.counts.completed || 0),
'a:' + Number(summary && summary.activeCount || 0),
't:' + Number(summary && summary.total || 0),
'tasks:' + (tasks || []).map(function (task) {
return [task.id, task.status, task.startedAt || '', task.finishedAt || '', task.errorMessage || '', task.attempts || 0].join(':');
}).join('|'),
'recurring:' + (recurringTasks || []).map(function (task) {
return [task.key, task.nextRunAt || '', task.lastRunAt || '', task.lastStatus || '', task.lastError || ''].join(':');
}).join('|')
].join('~');
}
app.get('/settings/background-tasks', requireSettingsAccess(), function (req, res) {
const tasks = backgroundTaskQueue.listTasks();
const recurringTasks = backgroundTaskQueue.listRecurringTasks();
const summary = backgroundTaskQueue.getSummary();
const version = buildQueueVersion(tasks, recurringTasks, summary);
res.send(pages.renderBackgroundTasksPage({ tasks: tasks, recurringTasks: recurringTasks, summary: summary, stateVersion: version, page: req.query.page, recurringPage: req.query.recurringPage }, req.query.message ? String(req.query.message) : '', req.currentUser));
});
app.get('/settings/background-tasks/state', requireSettingsAccess(), function (req, res) {
const tasks = backgroundTaskQueue.listTasks();
const recurringTasks = backgroundTaskQueue.listRecurringTasks();
const summary = backgroundTaskQueue.getSummary();
const version = buildQueueVersion(tasks, recurringTasks, summary);
res.set('Cache-Control', 'no-store, no-cache, must-revalidate, private');
res.json({
version: version,
summary: summary,
taskCount: tasks.length,
recurringCount: recurringTasks.length
});
});
function requireSettingsManageAccess() {
return function (req, res, next) {
if (!req.currentUser) {
return res.redirect('/login?message=' + encodeURIComponent('Please sign in to continue.'));
}
if (hasAnyPermission(req.currentUser, ['background-tasks.allow'])) {
return next();
}
const error = new Error('You do not have permission to access this area.');
error.statusCode = 403;
error.expose = true;
next(error);
};
}
app.post('/settings/background-tasks/clear-finished', requireSettingsManageAccess(), function (req, res) {
const removedCount = backgroundTaskQueue.clearFinishedTasks();
res.redirect('/settings/background-tasks?message=' + encodeURIComponent(removedCount ? 'Cleared ' + removedCount + ' finished task' + (removedCount === 1 ? '' : 's') + '.' : 'No finished tasks to clear.'));
});
app.post('/settings/background-tasks/:id/cancel', requireSettingsManageAccess(), function (req, res) {
const canceled = backgroundTaskQueue.cancelTask(Number(req.params.id));
res.redirect('/settings/background-tasks?message=' + encodeURIComponent(canceled ? 'Task canceled.' : 'Unable to cancel that task.'));
});
app.post('/settings/background-tasks/:id/retry', requireSettingsManageAccess(), function (req, res) {
const retryTask = backgroundTaskQueue.retryTask(Number(req.params.id));
res.redirect('/settings/background-tasks?message=' + encodeURIComponent(retryTask ? 'Task requeued.' : 'Unable to retry that task.'));
});
};
@@ -1,7 +1,7 @@
const { renderView } = require('../../view');
const { renderView } = require('../../../view');
module.exports = function renderRbacAddPage(message, currentUser, formValues, permissionGroups, messageVariant) {
return renderView('rbac/add', {
return renderView('settings/rbac/add', {
title: 'Create role',
active: 'rbac',
message: message,
@@ -1,7 +1,7 @@
const { renderView } = require('../../view');
const { renderView } = require('../../../view');
module.exports = function renderRbacEditPage(role, message, currentUser, permissionGroups, users) {
return renderView('rbac/edit', {
return renderView('settings/rbac/edit', {
title: 'Edit role',
active: 'rbac',
message: message,
@@ -1,7 +1,7 @@
const { renderView } = require('../../view');
const { renderView } = require('../../../view');
module.exports = function renderRbacPage(data, message, currentUser) {
return renderView('rbac/list', {
return renderView('settings/rbac/list', {
title: 'Roles and permissions',
active: 'rbac',
message: message,
@@ -1,7 +1,7 @@
const { renderView } = require('../../view');
const { renderView } = require('../../../view');
module.exports = function renderUsersAddPage(message, currentUser, roles, formValues, messageVariant) {
return renderView('users/add', {
return renderView('settings/users/add', {
title: 'Add user',
active: 'users',
message: message,
@@ -1,7 +1,7 @@
const { renderView } = require('../../view');
const { renderView } = require('../../../view');
module.exports = function renderUsersEditPage(user, message, currentUser, roles) {
return renderView('users/edit', {
return renderView('settings/users/edit', {
title: 'Edit user',
active: 'users',
message: message,

Some files were not shown because too many files have changed in this diff Show More