Release v2.2.0

This commit is contained in:
2026-08-01 21:41:44 +01:00
parent c643d2fb07
commit d6417b667c
673 changed files with 146752 additions and 7389 deletions
+3 -1
View File
@@ -1,4 +1,6 @@
module.exports = function registerAdminAccountRoutes(app, deps) {
// Admin account route registration and profile helpers.
module.exports = function registerAccountRoutes(app, deps) {
const pool = deps.pool;
const common = deps.common;
const pages = deps.pages;
+3 -1
View File
@@ -1,4 +1,6 @@
module.exports = function registerAdminScreenCommandRoutes(app, deps) {
// Admin client command routes for connected screens.
module.exports = function registerScreenCommandRoutes(app, deps) {
const pool = deps.pool;
const forwardPlayerCommand = deps.forwardPlayerCommand;
const getScreenConnections = deps.getScreenConnections;
+27 -12
View File
@@ -1,11 +1,15 @@
// Admin content routes for media uploads and related files.
const fs = require('fs');
const path = require('path');
const { loadFontLibrary } = require('../../lib/media/font-library');
module.exports = function registerAdminContentRoutes(app, deps) {
module.exports = function registerContentRoutes(app, deps) {
const pool = deps.pool;
const common = deps.common;
const pages = deps.pages;
const upload = deps.upload;
const setAuthMessageCookie = deps.setAuthMessageCookie;
const fetchScreensBySlideId = deps.fetchScreensBySlideId;
const fetchScreensByTemplateId = deps.fetchScreensByTemplateId;
const collectUploadReferencesFromSlide = deps.collectUploadReferencesFromSlide;
@@ -25,12 +29,12 @@ module.exports = function registerAdminContentRoutes(app, deps) {
const hasAnyPermission = deps.hasAnyPermission;
const { buildPagination } = require('../../lib/pagination');
const LIST_PAGE_SIZE = 10;
const LIST_PAGE_SIZE = 25;
const IMAGE_UPLOAD_MAX_BYTES = 100 * 1024 * 1024;
const VIDEO_UPLOAD_MAX_BYTES = 1024 * 1024 * 1024;
if (!pool || !common || !pages || !upload || typeof fetchScreensBySlideId !== 'function' || typeof fetchScreensByTemplateId !== 'function' || typeof collectUploadReferencesFromSlide !== 'function' || typeof collectUploadReferencesFromTemplate !== 'function' || typeof collectUploadReferencesFromPayload !== 'function' || typeof removeUnusedUploadFiles !== 'function' || typeof syncPlaylistUploadsOnChange !== 'function' || typeof getAuditUserId !== 'function' || typeof redirectAfterSave !== 'function' || typeof notifyPlayerScreens !== 'function' || typeof broadcastDashboardState !== 'function' || typeof getSlideDeleteBlockMessage !== 'function' || typeof getTemplateDeleteBlockMessage !== 'function' || typeof getCanvasSizeDeleteBlockMessage !== 'function' || typeof hasAnyPermission !== 'function') {
throw new Error('registerAdminContentRoutes requires the content route dependencies.');
throw new Error('registerContentRoutes requires the content route dependencies.');
}
async function removeMediaFile(uploadPath) {
@@ -53,7 +57,11 @@ module.exports = function registerAdminContentRoutes(app, deps) {
function requireSlideUploadPermission(req, res, next) {
if (!req.currentUser) {
return res.redirect('/login?message=' + encodeURIComponent('Please sign in to continue.'));
if (typeof setAuthMessageCookie === 'function') {
setAuthMessageCookie(res, 'Please sign in to continue.');
return res.redirect('/login');
}
return res.redirect('/login');
}
if (hasAnyPermission(req.currentUser, ['slides.create', 'slides.update'])) {
@@ -279,7 +287,7 @@ module.exports = function registerAdminContentRoutes(app, deps) {
return typeof common.normalizeRssFeedItem === 'function' ? common.normalizeRssFeedItem(item) : item;
}) });
}));
Object.assign(data, rssData, apiData, { rssFeeds: rssFeeds, apiSources: apiSources });
Object.assign(data, rssData, apiData, { rssFeeds: rssFeeds, apiSources: apiSources, fontLibrary: loadFontLibrary(deps.uploadDir) });
res.send(pages.renderSlideFormPage(data, 'create', null, req.query.message ? String(req.query.message) : '', req.currentUser));
} catch (error) {
next(error);
@@ -292,6 +300,7 @@ module.exports = function registerAdminContentRoutes(app, deps) {
if (!slide) {
return res.status(404).send('Slide not found');
}
slide.inUse = Boolean(await getSlideDeleteBlockMessage(pool, slide));
const data = await common.fetchTemplatesData(pool);
const rssData = await common.fetchRssFeedsData(pool);
const apiData = typeof common.fetchApiSourcesData === 'function' ? await common.fetchApiSourcesData(pool) : { apiSources: [] };
@@ -308,7 +317,7 @@ module.exports = function registerAdminContentRoutes(app, deps) {
return typeof common.normalizeRssFeedItem === 'function' ? common.normalizeRssFeedItem(item) : item;
}) });
}));
Object.assign(data, rssData, apiData, { rssFeeds: rssFeeds, apiSources: apiSources });
Object.assign(data, rssData, apiData, { rssFeeds: rssFeeds, apiSources: apiSources, fontLibrary: loadFontLibrary(deps.uploadDir) });
res.send(pages.renderSlideFormPage(data, 'edit', slide, req.query.message ? String(req.query.message) : '', req.currentUser));
} catch (error) {
next(error);
@@ -482,6 +491,9 @@ module.exports = function registerAdminContentRoutes(app, deps) {
app.post('/templates', requirePermission('templates.create'), upload.any(), async function (req, res, next) {
try {
const payload = await common.buildTemplatePayload(pool, req, null);
if (!payload.regions.length) {
return res.status(400).send('At least 1 region needs to be added.');
}
if (await common.fetchDuplicateName(pool, 'c_templates', payload.name)) {
return res.redirect('/templates/new?message=' + encodeURIComponent('A template with that name already exists.'));
}
@@ -493,8 +505,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 c_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]
'INSERT INTO c_template_regions (template_id, region_key, region_type, label, lock_ratio, x, y, width, height, z_index, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
[result.insertId, region.region_key, region.region_type, region.label, region.lock_ratio, region.x, region.y, region.width, region.height, region.z_index, actorId, actorId]
);
}
await syncPlaylistUploadsOnChange({
@@ -520,6 +532,7 @@ module.exports = function registerAdminContentRoutes(app, deps) {
return res.status(404).send('Template not found');
}
template.region_usage = await fetchTemplateRegionUsage(template);
template.inUse = (await fetchSlidesByTemplateId(template.id)).length > 0;
const sizeData = await common.fetchCanvasSizesData(pool);
res.send(pages.renderTemplateFormPage(template, 'edit', req.query.message ? String(req.query.message) : '', sizeData.canvasSizes, req.currentUser));
} catch (error) {
@@ -554,8 +567,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 c_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]
'INSERT INTO c_template_regions (template_id, region_key, region_type, label, lock_ratio, x, y, width, height, z_index, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
[template.id, region.region_key, region.region_type, region.label, region.lock_ratio, region.x, region.y, region.width, region.height, region.z_index, actorId, actorId]
);
}
await syncPlaylistUploadsOnChange({
@@ -633,7 +646,7 @@ module.exports = function registerAdminContentRoutes(app, deps) {
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)) {
if (await common.fetchDuplicateName(pool, 'c_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)) {
@@ -657,6 +670,8 @@ module.exports = function registerAdminContentRoutes(app, deps) {
if (!canvasSize) {
return res.status(404).send('Canvas size not found');
}
const [rows] = await pool.query('SELECT COUNT(*) AS ref_count FROM c_templates WHERE canvas_size_id = ?', [canvasSize.id]);
canvasSize.inUse = Number(rows[0] && rows[0].ref_count) > 0;
res.send(pages.renderCanvasSizeEditPage(canvasSize, null, req.query.message ? String(req.query.message) : '', req.currentUser));
} catch (error) {
next(error);
@@ -670,7 +685,7 @@ module.exports = function registerAdminContentRoutes(app, deps) {
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)) {
if (await common.fetchDuplicateName(pool, 'c_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)) {
+158 -153
View File
@@ -1,21 +1,22 @@
module.exports = function registerAdminDataSourceRoutes(app, deps) {
// Admin data-source route registration for API and RSS sources.
module.exports = function registerDataSourceRoutes(app, deps) {
const pool = deps.pool;
const common = deps.common;
const pages = deps.pages;
const backgroundTaskQueue = deps.backgroundTaskQueue;
const { hasAnyPermission } = require('../../../rbac');
const setAuthMessageCookie = deps.setAuthMessageCookie;
const { hasAnyPermission } = require('#src/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;
const { buildPagination } = require('../../lib/pagination');
const { buildPagination } = require('#src/web/lib/pagination');
const { createDataSourceTaskService } = require('#src/web/lib/background-tasks/tasks-scheduled/data-source-refresh');
const LIST_PAGE_SIZE = 10;
const LIST_PAGE_SIZE = 25;
function toIsoTimestamp(value) {
if (!value) {
@@ -26,150 +27,112 @@ module.exports = function registerAdminDataSourceRoutes(app, deps) {
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 i_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();
function slideUsesDataSourceField(content, fieldName, targetId) {
const normalizedTargetId = Number(targetId);
if (!Number.isFinite(normalizedTargetId) || !content || typeof content !== 'object') {
return false;
}
}
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 i_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 (Array.isArray(content)) {
return content.some(function (item) {
return slideUsesDataSourceField(item, fieldName, normalizedTargetId);
});
}
}
if (!pool || !common || !pages || typeof getAuditUserId !== 'function' || typeof redirectAfterSave !== 'function' || typeof requirePermission !== 'function' || !backgroundTaskQueue) {
throw new Error('registerAdminDataSourceRoutes requires the data source route dependencies.');
}
if (Object.prototype.hasOwnProperty.call(content, fieldName) && Number(content[fieldName]) === normalizedTargetId) {
return true;
}
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
return Object.keys(content).some(function (key) {
return slideUsesDataSourceField(content[key], fieldName, normalizedTargetId);
});
}
function removeRecurringRefresh(sourceType, id) {
backgroundTaskQueue.removeRecurringTask(formatRecurringKey(sourceType, id));
}
function getTaskStatusById(taskId) {
if (!backgroundTaskQueue || typeof backgroundTaskQueue.getTaskById !== 'function') {
return null;
async function getDataSourceDeleteBlockMessage(fieldName, dataSourceId, label) {
const isInUse = await getDataSourceInUse(fieldName, dataSourceId);
if (isInUse) {
return 'This ' + label + ' is still used by one or more slides.';
}
const task = backgroundTaskQueue.getTaskById(taskId);
if (!task) {
return null;
return '';
}
async function getDataSourceInUse(fieldName, dataSourceId) {
const [slides] = await pool.query('SELECT id, content_json FROM c_slides WHERE content_json IS NOT NULL');
for (let index = 0; index < slides.length; index += 1) {
const content = typeof common.parseJsonSafe === 'function' ? common.parseJsonSafe(slides[index].content_json) : null;
if (slideUsesDataSourceField(content, fieldName, dataSourceId)) {
return true;
}
}
return false;
}
async function getDataSourceUsageMaps() {
const [slides] = await pool.query('SELECT content_json FROM c_slides WHERE content_json IS NOT NULL');
const apiSourceIds = new Set();
const rssFeedIds = new Set();
for (let index = 0; index < slides.length; index += 1) {
const content = typeof common.parseJsonSafe === 'function' ? common.parseJsonSafe(slides[index].content_json) : null;
if (!content || typeof content !== 'object') {
continue;
}
(function walk(value) {
if (!value || typeof value !== 'object') {
return;
}
if (Array.isArray(value)) {
value.forEach(walk);
return;
}
if (Object.prototype.hasOwnProperty.call(value, 'source_id')) {
const sourceId = Number(value.source_id);
if (Number.isFinite(sourceId)) {
apiSourceIds.add(sourceId);
}
}
if (Object.prototype.hasOwnProperty.call(value, 'feed_id')) {
const feedId = Number(value.feed_id);
if (Number.isFinite(feedId)) {
rssFeedIds.add(feedId);
}
}
Object.keys(value).forEach(function (key) {
walk(value[key]);
});
})(content);
}
return {
id: task.id,
key: task.key,
status: task.status,
finishedAt: task.finishedAt || '',
errorMessage: task.errorMessage || ''
apiSourceIds: apiSourceIds,
rssFeedIds: rssFeedIds
};
}
async function loadRssFeedItems(feedUrl, itemLimit) {
if (typeof fetchRssFeedItems === 'function') {
return fetchRssFeedItems(feedUrl, itemLimit);
}
return [];
if (!pool || !common || !pages || typeof getAuditUserId !== 'function' || typeof redirectAfterSave !== 'function' || typeof requirePermission !== 'function' || !backgroundTaskQueue) {
throw new Error('registerDataSourceRoutes requires the data source route dependencies.');
}
async function loadApiSourceResponse(apiUrl) {
if (typeof fetchApiSourceResponse === 'function') {
return fetchApiSourceResponse(apiUrl);
}
const dataSourceTasks = createDataSourceTaskService({
pool: pool,
common: common,
backgroundTaskQueue: backgroundTaskQueue
});
return {
responseJson: null,
responseStatus: null,
responseContentType: null
};
}
function sendRefreshTaskState(req, res, sourceType, sourceId) {
async 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 task = await dataSourceTasks.getTaskStatusById(taskId);
const expectedKey = sourceType + '-refresh:' + Number(sourceId);
if (!task || task.key !== expectedKey) {
return res.status(404).json({ error: 'Refresh task not found.' });
@@ -180,7 +143,11 @@ module.exports = function registerAdminDataSourceRoutes(app, deps) {
app.get('/data-sources', function (req, res, next) {
if (!req.currentUser) {
return res.redirect('/login?message=' + encodeURIComponent('Please sign in to continue.'));
if (typeof setAuthMessageCookie === 'function') {
setAuthMessageCookie(res, 'Please sign in to continue.');
return res.redirect('/login');
}
return res.redirect('/login');
}
if (hasAnyPermission(req.currentUser, ['rss-feeds.read', 'api-sources.read'])) {
@@ -203,13 +170,22 @@ module.exports = function registerAdminDataSourceRoutes(app, deps) {
const sort = common.getSortQuery(req);
const direction = common.getSortDirectionQuery(req);
const data = await common.fetchApiSourcesPage(pool, page, LIST_PAGE_SIZE, search, sort, direction);
const usageMaps = await getDataSourceUsageMaps();
const apiSources = (data.apiSources || []).map(function (apiSource) {
return Object.assign({}, apiSource, {
authMethod: apiSource.auth_method || 'none',
authUsername: apiSource.auth_username || '',
authPassword: apiSource.auth_password || '',
authBearerToken: apiSource.auth_bearer_token || '',
authHeaderName: apiSource.auth_header_name || 'X-API-Key',
authHeaderValue: apiSource.auth_header_value || '',
itemsPath: apiSource.items_path || '',
intervalLabel: apiSource.update_interval_unit === 'seconds'
? (Math.max(1, Number(apiSource.update_interval_value) || 0) === 1 ? 'Every second' : `Every ${Math.max(1, Number(apiSource.update_interval_value) || 0)} seconds`)
: (Math.max(1, Number(apiSource.update_interval_value) || 0) === 1 ? 'Every minute' : `Every ${Math.max(1, Number(apiSource.update_interval_value) || 0)} minutes`),
lastPullLabel: apiSource.last_pulled_at ? formatDashboardDate(apiSource.last_pulled_at) : 'Never',
lastPulledAtValue: apiSource.last_pulled_at ? new Date(apiSource.last_pulled_at).toISOString() : ''
lastPulledAtValue: apiSource.last_pulled_at ? new Date(apiSource.last_pulled_at).toISOString() : '',
inUse: usageMaps.apiSourceIds.has(Number(apiSource.id))
});
});
res.send(pages.renderApiSourcesPage({
@@ -235,12 +211,12 @@ module.exports = function registerAdminDataSourceRoutes(app, deps) {
const actorId = getAuditUserId(req);
await connection.beginTransaction();
const [result] = await connection.query(
'INSERT INTO i_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]
'INSERT INTO i_api_sources (name, api_url, auth_method, auth_username, auth_password, auth_bearer_token, auth_header_name, auth_header_value, items_path, update_interval_value, update_interval_unit, last_pulled_at, last_pull_error, last_response_status, last_response_content_type, last_response_json, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
[payload.name, payload.apiUrl, payload.authMethod, payload.authUsername || null, payload.authPassword || null, payload.authBearerToken || null, payload.authHeaderName || null, payload.authHeaderValue || null, payload.itemsPath || null, payload.updateIntervalValue, payload.updateIntervalUnit, null, null, null, null, null, actorId, actorId]
);
await connection.commit();
registerRecurringRefresh('api-source', result.insertId, payload.name, payload.updateIntervalValue, payload.updateIntervalUnit, function () {
return refreshApiSourceInBackground(result.insertId, payload.apiUrl, actorId);
dataSourceTasks.registerRecurringRefresh('api-source', result.insertId, payload.name, payload.updateIntervalValue, payload.updateIntervalUnit, function () {
return dataSourceTasks.refreshApiSourceInBackground(result.insertId, actorId);
});
const refreshTask = await backgroundTaskQueue.enqueueTask({
key: 'api-source-refresh:' + result.insertId,
@@ -280,8 +256,17 @@ module.exports = function registerAdminDataSourceRoutes(app, deps) {
return res.status(404).send('API source not found');
}
const inUse = await getDataSourceInUse('source_id', apiSource.id);
res.send(pages.renderApiSourceEditPage(Object.assign({}, apiSource, {
apiUrl: apiSource.api_url,
authMethod: apiSource.auth_method || 'none',
authUsername: apiSource.auth_username || '',
authPassword: apiSource.auth_password || '',
authBearerToken: apiSource.auth_bearer_token || '',
authHeaderName: apiSource.auth_header_name || 'X-API-Key',
authHeaderValue: apiSource.auth_header_value || '',
itemsPath: apiSource.items_path || '',
updateIntervalValue: apiSource.update_interval_value,
updateIntervalUnit: apiSource.update_interval_unit || 'minutes',
lastPulledAtValue: toIsoTimestamp(apiSource.last_pulled_at),
@@ -289,7 +274,8 @@ module.exports = function registerAdminDataSourceRoutes(app, deps) {
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 || '',
inUse: inUse
}), {
lastResponseJson: apiSource.last_response_json || '',
lastPullError: apiSource.last_pull_error || ''
@@ -299,8 +285,8 @@ module.exports = function registerAdminDataSourceRoutes(app, deps) {
}
});
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.get('/data-sources/api-sources/:id/state', requirePermission('api-sources.update'), function (req, res, next) {
sendRefreshTaskState(req, res, 'api-source', Number(req.params.id)).catch(next);
});
app.post('/data-sources/api-sources/:id', requirePermission('api-sources.update'), async function (req, res, next) {
@@ -312,18 +298,18 @@ module.exports = function registerAdminDataSourceRoutes(app, deps) {
}
const payload = common.buildApiSourcePayload(req, apiSource);
if (await common.fetchDuplicateName(pool, 'api_sources', payload.name, apiSource.id)) {
if (await common.fetchDuplicateName(pool, 'i_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 i_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]
'UPDATE i_api_sources SET name = ?, api_url = ?, auth_method = ?, auth_username = ?, auth_password = ?, auth_bearer_token = ?, auth_header_name = ?, auth_header_value = ?, items_path = ?, update_interval_value = ?, update_interval_unit = ?, last_pulled_at = ?, last_pull_error = ?, last_response_status = ?, last_response_content_type = ?, last_response_json = ?, modified_by = ? WHERE id = ?',
[payload.name, payload.apiUrl, payload.authMethod, payload.authUsername || null, payload.authPassword || null, payload.authBearerToken || null, payload.authHeaderName || null, payload.authHeaderValue || null, payload.itemsPath || null, payload.updateIntervalValue, payload.updateIntervalUnit, null, null, null, null, null, actorId, apiSource.id]
);
await connection.commit();
registerRecurringRefresh('api-source', apiSource.id, payload.name, payload.updateIntervalValue, payload.updateIntervalUnit, function () {
return refreshApiSourceInBackground(apiSource.id, payload.apiUrl, actorId);
dataSourceTasks.registerRecurringRefresh('api-source', apiSource.id, payload.name, payload.updateIntervalValue, payload.updateIntervalUnit, function () {
return dataSourceTasks.refreshApiSourceInBackground(apiSource.id, actorId);
});
const message = 'API source updated. Refresh is running in the background.';
const refreshTask = await backgroundTaskQueue.enqueueTask({
@@ -363,12 +349,17 @@ module.exports = function registerAdminDataSourceRoutes(app, deps) {
return res.status(404).send('API source not found');
}
const blockMessage = await getDataSourceDeleteBlockMessage('source_id', apiSource.id, 'API source');
if (blockMessage) {
return res.redirect('/data-sources/api-sources?message=' + encodeURIComponent(blockMessage));
}
const connection = await pool.getConnection();
try {
await connection.beginTransaction();
await connection.query('DELETE FROM i_api_sources WHERE id = ?', [apiSource.id]);
await connection.commit();
removeRecurringRefresh('api-source', apiSource.id);
dataSourceTasks.removeRecurringRefresh('api-source', apiSource.id);
} catch (error) {
await connection.rollback();
throw error;
@@ -388,8 +379,14 @@ module.exports = function registerAdminDataSourceRoutes(app, deps) {
const sort = common.getSortQuery(req);
const direction = common.getSortDirectionQuery(req);
const data = await common.fetchRssFeedsPage(pool, page, LIST_PAGE_SIZE, search, sort, direction);
const usageMaps = await getDataSourceUsageMaps();
const rssFeeds = (data.rssFeeds || []).map(function (rssFeed) {
return Object.assign({}, rssFeed, {
inUse: usageMaps.rssFeedIds.has(Number(rssFeed.id))
});
});
res.send(pages.renderRssFeedsPage({
rssFeeds: data.rssFeeds || [],
rssFeeds: rssFeeds,
pagination: buildPagination(data.totalItems, data.currentPage, 'page', { search: search, sort: sort, direction: direction }, LIST_PAGE_SIZE, 'RSS feeds', 'RSS feed pages')
}, req.query.message ? String(req.query.message) : '', req.currentUser));
} catch (error) {
@@ -405,7 +402,7 @@ module.exports = function registerAdminDataSourceRoutes(app, deps) {
const connection = await pool.getConnection();
try {
const payload = common.buildRssFeedPayload(req, null);
if (await common.fetchDuplicateName(pool, 'rss_feeds', payload.name)) {
if (await common.fetchDuplicateName(pool, 'i_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);
@@ -415,8 +412,8 @@ module.exports = function registerAdminDataSourceRoutes(app, deps) {
[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);
dataSourceTasks.registerRecurringRefresh('rss-feed', result.insertId, payload.name, payload.updateIntervalValue, payload.updateIntervalUnit, function () {
return dataSourceTasks.refreshRssFeedInBackground(result.insertId, payload.feedUrl, payload.itemLimit, actorId);
});
const refreshTask = await backgroundTaskQueue.enqueueTask({
key: 'rss-feed-refresh:' + result.insertId,
@@ -456,6 +453,8 @@ module.exports = function registerAdminDataSourceRoutes(app, deps) {
return res.status(404).send('RSS feed not found');
}
const inUse = await getDataSourceInUse('feed_id', rssFeed.id);
const pulledItems = typeof common.fetchRssFeedItemsByFeedId === 'function'
? await common.fetchRssFeedItemsByFeedId(pool, rssFeed.id)
: [];
@@ -464,7 +463,8 @@ module.exports = function registerAdminDataSourceRoutes(app, deps) {
feedUrl: rssFeed.feed_url,
updateIntervalValue: rssFeed.update_interval_value,
updateIntervalUnit: rssFeed.update_interval_unit || 'minutes',
itemLimit: rssFeed.item_limit
itemLimit: rssFeed.item_limit,
inUse: inUse
}), {
pulledItems: pulledItems,
pullError: ''
@@ -474,8 +474,8 @@ module.exports = function registerAdminDataSourceRoutes(app, deps) {
}
});
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.get('/data-sources/rss-feeds/:id/state', requirePermission('rss-feeds.update'), function (req, res, next) {
sendRefreshTaskState(req, res, 'rss-feed', Number(req.params.id)).catch(next);
});
app.post('/data-sources/rss-feeds/:id', requirePermission('rss-feeds.update'), async function (req, res, next) {
@@ -487,7 +487,7 @@ module.exports = function registerAdminDataSourceRoutes(app, deps) {
}
const payload = common.buildRssFeedPayload(req, rssFeed);
if (await common.fetchDuplicateName(pool, 'rss_feeds', payload.name, rssFeed.id)) {
if (await common.fetchDuplicateName(pool, 'i_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);
@@ -497,8 +497,8 @@ module.exports = function registerAdminDataSourceRoutes(app, deps) {
[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);
dataSourceTasks.registerRecurringRefresh('rss-feed', rssFeed.id, payload.name, payload.updateIntervalValue, payload.updateIntervalUnit, function () {
return dataSourceTasks.refreshRssFeedInBackground(rssFeed.id, payload.feedUrl, payload.itemLimit, actorId);
});
const message = 'RSS feed updated. Refresh is running in the background.';
const refreshTask = await backgroundTaskQueue.enqueueTask({
@@ -538,12 +538,17 @@ module.exports = function registerAdminDataSourceRoutes(app, deps) {
return res.status(404).send('RSS feed not found');
}
const blockMessage = await getDataSourceDeleteBlockMessage('feed_id', rssFeed.id, 'RSS feed');
if (blockMessage) {
return res.redirect('/data-sources/rss-feeds?message=' + encodeURIComponent(blockMessage));
}
const connection = await pool.getConnection();
try {
await connection.beginTransaction();
await connection.query('DELETE FROM i_rss_feeds WHERE id = ?', [rssFeed.id]);
await connection.commit();
removeRecurringRefresh('rss-feed', rssFeed.id);
dataSourceTasks.removeRecurringRefresh('rss-feed', rssFeed.id);
} catch (error) {
await connection.rollback();
throw error;
+9 -4
View File
@@ -1,4 +1,6 @@
module.exports = function registerAdminManageRoutes(app, deps) {
// Admin manage routes for screens, playlists, slides, and templates.
module.exports = function registerManageRoutes(app, deps) {
const pool = deps.pool;
const common = deps.common;
const pages = deps.pages;
@@ -559,7 +561,7 @@ module.exports = function registerAdminManageRoutes(app, deps) {
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);
const [result] = await pool.query('INSERT INTO d_screens (name, slug, playlist_id, created_by, modified_by) VALUES (?, ?, ?, ?, ?)', [name, slug, playlistId, actorId, actorId]);
const [result] = await pool.query('INSERT INTO d_screens (name, slug, playlist_id, player_id, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?)', [name, slug, playlistId, '1', actorId, actorId]);
redirectAfterSave(req, res, '/screens?edit=' + result.insertId, {
closeUrl: '/screens',
newUrl: '/screens/new',
@@ -588,14 +590,17 @@ module.exports = function registerAdminManageRoutes(app, deps) {
const previousPlaylistId = screen.playlist_id;
const slug = await common.uniqueScreenSlug(pool, common.slugify(slugInput || name), screen.id);
const previousSlug = String(screen.slug || '').trim();
await pool.query('UPDATE d_screens SET name = ?, slug = ?, playlist_id = ?, modified_by = ? WHERE id = ?', [name, slug, playlistId, getAuditUserId(req), screen.id]);
await pool.query('UPDATE d_screens SET name = ?, slug = ?, playlist_id = ?, player_id = ?, modified_by = ? WHERE id = ?', [name, slug, playlistId, '1', getAuditUserId(req), screen.id]);
if (previousPlaylistId !== playlistId && previousSlug) {
await notifyPlayerScreens([previousSlug], 'refresh');
}
if (previousSlug && previousSlug !== slug) {
const playerRecord = typeof common.fetchScreenPlayerRecord === 'function'
? await common.fetchScreenPlayerRecord(pool, previousSlug)
: null;
await forwardPlayerCommand(previousSlug, {
command: 'redirect',
url: `${PLAYER_PUBLIC_BASE_URL}/screen/${encodeURIComponent(slug)}`
url: (playerRecord && playerRecord.public_base_url ? String(playerRecord.public_base_url).replace(/\/$/, '') : PLAYER_PUBLIC_BASE_URL) + `/screen/${encodeURIComponent(slug)}`
});
}
redirectAfterSave(req, res, '/screens?edit=' + screen.id, {
+3 -1
View File
@@ -1,4 +1,6 @@
module.exports = function registerAdminPagesRoutes(app, deps) {
// Admin page route composition for the signage sections.
module.exports = function registerPagesRoutes(app, deps) {
require('../signage/dashboard/routes')(app, deps);
require('../signage/clients/routes')(app, deps);
require('../signage/screens/routes')(app, deps);
+6 -3
View File
@@ -1,4 +1,6 @@
module.exports = function registerAdminRbacRoutes(app, deps) {
// Admin RBAC route registration and permission management.
module.exports = function registerRbacRoutes(app, deps) {
const pool = deps.pool;
const common = deps.common;
const pages = deps.pages;
@@ -10,7 +12,7 @@ module.exports = function registerAdminRbacRoutes(app, deps) {
const { buildPagination } = require('../../lib/pagination');
const requirePermission = deps.requirePermission;
const LIST_PAGE_SIZE = 10;
const LIST_PAGE_SIZE = 25;
function slugifyRoleKey(name) {
const value = String(name || '').trim().toLowerCase();
@@ -259,6 +261,7 @@ module.exports = function registerAdminRbacRoutes(app, deps) {
excludeUserId: currentUserId
});
const users = mapUsersForView(data.users, viewModel.selectedUserIds);
viewModel.role.inUse = String(viewModel.role.role_key || '') === 'administrators' || Number(viewModel.role.user_count) > 0;
res.send(pages.renderRbacEditPage(viewModel.role, req.query.message ? String(req.query.message) : '', req.currentUser, viewModel.permissionGroups, users, buildPagination(data.totalItems, data.currentPage, 'page', { search: search, sort: sort, direction: direction }, LIST_PAGE_SIZE, 'users', 'User pages')));
} catch (error) {
@@ -295,7 +298,7 @@ module.exports = function registerAdminRbacRoutes(app, deps) {
return res.redirect('/rbac/' + roleId + '/edit?message=' + encodeURIComponent('Role name is required.'));
}
if (await common.fetchDuplicateName(pool, 'roles', name, roleId)) {
if (await common.fetchDuplicateName(pool, 'a_roles', name, roleId)) {
return res.redirect('/rbac/' + roleId + '/edit?message=' + encodeURIComponent('A role with that name already exists.'));
}
+9 -3
View File
@@ -1,4 +1,6 @@
module.exports = function registerAdminUsersRoutes(app, deps) {
// Admin user route registration and user-role management.
module.exports = function registerUsersRoutes(app, deps) {
const pool = deps.pool;
const common = deps.common;
const pages = deps.pages;
@@ -10,7 +12,7 @@ module.exports = function registerAdminUsersRoutes(app, deps) {
const { buildPagination } = require('../../lib/pagination');
const requirePermission = deps.requirePermission;
const LIST_PAGE_SIZE = 10;
const LIST_PAGE_SIZE = 25;
async function fetchRoleOptions() {
return rbacData.fetchRoles(pool);
@@ -101,12 +103,16 @@ module.exports = function registerAdminUsersRoutes(app, deps) {
return res.redirect('/account?message=' + encodeURIComponent('Use My Account to update your own login details.'));
}
const [countRows] = await pool.query('SELECT COUNT(*) AS user_count FROM a_users');
const canDelete = !countRows.length || Number(countRows[0].user_count) > 1;
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'
roleNames: String(user.roleNames || '').trim() || 'No roles assigned',
inUse: !canDelete
}), req.query.message ? String(req.query.message) : '', req.currentUser, mapRolesForForm(roles, user.roleIds)));
} catch (error) {
next(error);