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
@@ -1,9 +1,11 @@
// Background tasks settings page renderer.
const { renderView } = require('../../view');
const { hasAnyPermission } = require('../../../rbac');
const { buildPagination, buildQueryString } = require('../../lib/pagination');
const { parsePageNumber, normalizeSortDirection, sortRows, createSearchMatcher } = require('../../lib/list-query');
const TASKS_PER_PAGE = 10;
const TASKS_PER_PAGE = 25;
const QUEUE_PAGE_URL = '/settings/tasks-background';
const SCHEDULED_PAGE_URL = '/settings/tasks-scheduled';
@@ -91,7 +93,7 @@ function buildQueuePageViewModel(data, message, currentUser) {
const tasks = (data && data.tasks) || [];
const summary = (data && data.summary) || { counts: {}, total: 0, activeCount: 0 };
const canManage = hasAnyPermission(currentUser, ['background-tasks.allow']);
const sort = String(data && data.sort || '').trim() || 'started';
const sort = String(data && data.sort || '').trim() || 'queued';
const direction = normalizeSortDirection(data && data.direction || 'desc');
const queryState = {
page: parsePageNumber(data && data.page),
@@ -188,6 +190,7 @@ function buildQueuePageViewModel(data, message, currentUser) {
return renderView('settings/tasks-background/index', {
title: 'Background tasks',
active: 'background-tasks',
bodyClass: 'background-tasks-page',
message: message,
currentUser: currentUser || null,
scripts: ['js/settings/background-tasks.js'],
@@ -211,7 +214,7 @@ function buildScheduledPageViewModel(data, message, currentUser) {
const summary = (data && data.summary) || { counts: {}, total: 0, activeCount: 0, scheduledCount: 0 };
const canManage = hasAnyPermission(currentUser, ['scheduled-tasks.allow']);
const search = String(data && data.search || '').trim();
const sort = String(data && data.sort || '').trim();
const sort = String(data && data.sort || '').trim() || 'task';
const direction = normalizeSortDirection(data && data.direction);
const filteredRecurringTasks = recurringTasks.filter(function (task) {
return taskMatchesSearch(search, [
@@ -249,7 +252,7 @@ function buildScheduledPageViewModel(data, message, currentUser) {
return String(value || '').trim();
}).join(' ');
}
return task && task.nextRunAt;
return task && task.title;
}, direction);
return renderView('settings/tasks-scheduled/index', {
+66 -30
View File
@@ -1,9 +1,20 @@
// Settings route registration for the background tasks page.
const { hasAnyPermission } = require('../../../rbac');
function requireSettingsAccess() {
function redirectToLogin(res, setAuthMessageCookie) {
if (typeof setAuthMessageCookie === 'function') {
setAuthMessageCookie(res, 'Please sign in to continue.');
return res.redirect('/login');
}
return res.redirect('/login');
}
function requireSettingsAccess(setAuthMessageCookie) {
return function (req, res, next) {
if (!req.currentUser) {
return res.redirect('/login?message=' + encodeURIComponent('Please sign in to continue.'));
return redirectToLogin(res, setAuthMessageCookie);
}
if (hasAnyPermission(req.currentUser, ['background-tasks.read', 'background-tasks.allow'])) {
@@ -17,10 +28,10 @@ function requireSettingsAccess() {
};
}
function requireScheduledSettingsAccess() {
function requireScheduledSettingsAccess(setAuthMessageCookie) {
return function (req, res, next) {
if (!req.currentUser) {
return res.redirect('/login?message=' + encodeURIComponent('Please sign in to continue.'));
return redirectToLogin(res, setAuthMessageCookie);
}
if (hasAnyPermission(req.currentUser, ['scheduled-tasks.read', 'scheduled-tasks.allow'])) {
@@ -34,32 +45,41 @@ function requireScheduledSettingsAccess() {
};
}
module.exports = function registerAdminSettingsRoutes(app, deps) {
module.exports = function registerSettingsRoutes(app, deps) {
const pages = deps.pages;
const backgroundTaskQueue = deps.backgroundTaskQueue;
const setAuthMessageCookie = deps.setAuthMessageCookie;
if (!pages || !backgroundTaskQueue) {
throw new Error('registerAdminSettingsRoutes requires pages and backgroundTaskQueue.');
throw new Error('registerSettingsRoutes requires pages and backgroundTaskQueue.');
}
app.get('/settings/tasks-background', requireSettingsAccess(), function (req, res) {
const tasks = backgroundTaskQueue.listTasks();
const recurringTasks = backgroundTaskQueue.listRecurringTasks();
const summary = backgroundTaskQueue.getSummary();
res.send(pages.renderBackgroundTasksPage({ tasks: tasks, recurringTasks: recurringTasks, summary: summary, page: req.query.page, recurringPage: req.query.recurringPage, search: req.query.search, sort: req.query.sort, direction: req.query.direction }, req.query.message ? String(req.query.message) : '', req.currentUser));
app.get('/settings/tasks-background', requireSettingsAccess(setAuthMessageCookie), async function (req, res, next) {
try {
const tasks = await backgroundTaskQueue.listTasks();
const recurringTasks = await backgroundTaskQueue.listRecurringTasks();
const summary = await backgroundTaskQueue.getSummary();
res.send(pages.renderBackgroundTasksPage({ tasks: tasks, recurringTasks: recurringTasks, summary: summary, page: req.query.page, recurringPage: req.query.recurringPage, search: req.query.search, sort: req.query.sort, direction: req.query.direction }, req.query.message ? String(req.query.message) : '', req.currentUser));
} catch (error) {
next(error);
}
});
app.get('/settings/tasks-scheduled', requireScheduledSettingsAccess(), function (req, res) {
const tasks = backgroundTaskQueue.listTasks();
const recurringTasks = backgroundTaskQueue.listRecurringTasks();
const summary = backgroundTaskQueue.getSummary();
res.send(pages.renderBackgroundTasksScheduledPage({ tasks: tasks, recurringTasks: recurringTasks, summary: summary, page: req.query.page, recurringPage: req.query.recurringPage, search: req.query.search, sort: req.query.sort, direction: req.query.direction }, req.query.message ? String(req.query.message) : '', req.currentUser));
app.get('/settings/tasks-scheduled', requireScheduledSettingsAccess(setAuthMessageCookie), async function (req, res, next) {
try {
const tasks = await backgroundTaskQueue.listTasks();
const recurringTasks = await backgroundTaskQueue.listRecurringTasks();
const summary = await backgroundTaskQueue.getSummary();
res.send(pages.renderBackgroundTasksScheduledPage({ tasks: tasks, recurringTasks: recurringTasks, summary: summary, page: req.query.page, recurringPage: req.query.recurringPage, search: req.query.search, sort: req.query.sort, direction: req.query.direction }, req.query.message ? String(req.query.message) : '', req.currentUser));
} catch (error) {
next(error);
}
});
function requireSettingsManageAccess() {
return function (req, res, next) {
if (!req.currentUser) {
return res.redirect('/login?message=' + encodeURIComponent('Please sign in to continue.'));
return redirectToLogin(res, setAuthMessageCookie);
}
if (hasAnyPermission(req.currentUser, ['background-tasks.allow'])) {
@@ -75,7 +95,7 @@ module.exports = function registerAdminSettingsRoutes(app, deps) {
function requireRecurringTaskAccess(req, res, next) {
if (!req.currentUser) {
return res.redirect('/login?message=' + encodeURIComponent('Please sign in to continue.'));
return redirectToLogin(res, setAuthMessageCookie);
}
if (!hasAnyPermission(req.currentUser, ['scheduled-tasks.allow'])) {
@@ -88,23 +108,39 @@ module.exports = function registerAdminSettingsRoutes(app, deps) {
return next();
}
app.post('/settings/tasks-background/clear-finished', requireSettingsManageAccess(), function (req, res) {
const removedCount = backgroundTaskQueue.clearFinishedTasks();
res.redirect('/settings/tasks-background?message=' + encodeURIComponent(removedCount ? 'Cleared ' + removedCount + ' finished task' + (removedCount === 1 ? '' : 's') + '.' : 'No finished tasks to clear.'));
app.post('/settings/tasks-background/clear-finished', requireSettingsManageAccess(), async function (req, res, next) {
try {
const removedCount = await backgroundTaskQueue.clearFinishedTasks();
res.redirect('/settings/tasks-background?message=' + encodeURIComponent(removedCount ? 'Cleared ' + removedCount + ' finished task' + (removedCount === 1 ? '' : 's') + '.' : 'No finished tasks to clear.'));
} catch (error) {
next(error);
}
});
app.post('/settings/tasks-background/:id/cancel', requireSettingsManageAccess(), function (req, res) {
const canceled = backgroundTaskQueue.cancelTask(Number(req.params.id));
res.redirect('/settings/tasks-background?message=' + encodeURIComponent(canceled ? 'Task canceled.' : 'Unable to cancel that task.'));
app.post('/settings/tasks-background/:id/cancel', requireSettingsManageAccess(), async function (req, res, next) {
try {
const canceled = await backgroundTaskQueue.cancelTask(Number(req.params.id));
res.redirect('/settings/tasks-background?message=' + encodeURIComponent(canceled ? 'Task canceled.' : 'Unable to cancel that task.'));
} catch (error) {
next(error);
}
});
app.post('/settings/tasks-background/:id/retry', requireSettingsManageAccess(), function (req, res) {
const retriedTask = backgroundTaskQueue.retryTask(Number(req.params.id));
res.redirect('/settings/tasks-background?message=' + encodeURIComponent(retriedTask ? 'Task requeued.' : 'Unable to retry that task.'));
app.post('/settings/tasks-background/:id/retry', requireSettingsManageAccess(), async function (req, res, next) {
try {
const retriedTask = await backgroundTaskQueue.retryTask(Number(req.params.id));
res.redirect('/settings/tasks-background?message=' + encodeURIComponent(retriedTask ? 'Task requeued.' : 'Unable to retry that task.'));
} catch (error) {
next(error);
}
});
app.post('/settings/tasks-scheduled/recurring/:key/run', requireRecurringTaskAccess, function (req, res) {
const ran = backgroundTaskQueue.runRecurringTask(String(req.params.key || '').trim());
res.redirect('/settings/tasks-scheduled?message=' + encodeURIComponent(ran ? 'Scheduled refresh run queued.' : 'Unable to run that scheduled refresh.'));
app.post('/settings/tasks-scheduled/recurring/:key/run', requireRecurringTaskAccess, async function (req, res, next) {
try {
const ran = await backgroundTaskQueue.runRecurringTask(String(req.params.key || '').trim());
res.redirect('/settings/tasks-scheduled?message=' + encodeURIComponent(ran ? 'Scheduled refresh run queued.' : 'Unable to run that scheduled refresh.'));
} catch (error) {
next(error);
}
});
};
+217
View File
@@ -0,0 +1,217 @@
// Settings route registration for managed fonts.
const fs = require('fs');
const { hasAnyPermission } = require('../../../rbac');
const fontLibrary = require('../../lib/media/font-library');
function normalizeFontFamilyName(value) {
return String(value || '').replace(/\s+/g, ' ').trim().toLowerCase();
}
async function fetchUsedFontFamilies(pool, fonts) {
const usedFamilies = new Set();
for (let i = 0; i < (Array.isArray(fonts) ? fonts.length : 0); i += 1) {
const family = normalizeFontFamilyName(fonts[i] && (fonts[i].family || fonts[i].name));
if (!family) {
continue;
}
const [rows] = await pool.query(
'SELECT COUNT(*) AS match_count FROM c_slides WHERE LOCATE(LOWER(?), LOWER(COALESCE(content_json, ""))) > 0',
[family]
);
if (Number(rows && rows[0] && rows[0].match_count) > 0) {
usedFamilies.add(family);
}
}
return usedFamilies;
}
async function isFontUsed(pool, family) {
const normalizedFamily = normalizeFontFamilyName(family);
if (!normalizedFamily) {
return false;
}
const [rows] = await pool.query(
'SELECT COUNT(*) AS match_count FROM c_slides WHERE LOCATE(LOWER(?), LOWER(COALESCE(content_json, ""))) > 0',
[normalizedFamily]
);
return Number(rows && rows[0] && rows[0].match_count) > 0;
}
function redirectToLogin(res, setAuthMessageCookie) {
if (typeof setAuthMessageCookie === 'function') {
setAuthMessageCookie(res, 'Please sign in to continue.');
return res.redirect('/login');
}
return res.redirect('/login');
}
function requireFontsAccess(setAuthMessageCookie) {
return function (req, res, next) {
if (!req.currentUser) {
return redirectToLogin(res, setAuthMessageCookie);
}
if (hasAnyPermission(req.currentUser, ['fonts.read', 'fonts.create', 'fonts.delete'])) {
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 registerFontRoutes(app, deps) {
const pool = deps.pool;
const pages = deps.pages;
const upload = deps.upload;
const uploadDir = deps.uploadDir;
const backgroundTaskQueue = deps.backgroundTaskQueue;
const setAuthMessageCookie = deps.setAuthMessageCookie;
if (!pool || !pages || !upload || !uploadDir) {
throw new Error('registerFontRoutes requires pool, pages, upload, and uploadDir.');
}
app.get('/settings/fonts', requireFontsAccess(setAuthMessageCookie), async function (req, res, next) {
try {
const library = fontLibrary.loadFontLibrary(uploadDir);
const usedFamilies = await fetchUsedFontFamilies(pool, library.fonts);
const fonts = library.fonts.map(function (font) {
const family = normalizeFontFamilyName(font.family || font.name);
return Object.assign({}, font, {
inUse: usedFamilies.has(family)
});
});
res.send(pages.renderFontsPage({
fonts: fonts,
stylesheetHref: library.stylesheetHref
}, req.query.message ? String(req.query.message) : '', req.currentUser));
} catch (error) {
next(error);
}
});
app.post('/settings/fonts', requireFontsAccess(setAuthMessageCookie), upload.single('font_file'), async function (req, res, next) {
try {
if (!req.file) {
return res.status(400).send('No font file was uploaded.');
}
const result = await fontLibrary.addFontFile(uploadDir, req.file, String(req.body && req.body.font_family || '').trim());
if (backgroundTaskQueue && typeof backgroundTaskQueue.enqueueTask === 'function') {
await backgroundTaskQueue.enqueueTask({
key: `font-sync:${result && result.font && result.font.id ? result.font.id : Date.now()}`,
title: 'Font sync',
category: 'fonts',
taskType: 'font-sync',
payload: {
uploadDir: uploadDir,
operations: fontLibrary.collectFontLibrarySyncOperations(uploadDir)
},
persist: true
});
}
res.redirect('/settings/fonts?message=' + encodeURIComponent('Font uploaded.'));
} catch (error) {
if (req.file && req.file.path) {
try {
await fs.promises.unlink(req.file.path);
} catch (_cleanupError) {}
}
if (error && /already exists/i.test(String(error.message || ''))) {
const library = fontLibrary.loadFontLibrary(uploadDir);
const usedFamilies = await fetchUsedFontFamilies(pool, library.fonts);
const fonts = library.fonts.map(function (font) {
const family = normalizeFontFamilyName(font.family || font.name);
return Object.assign({}, font, {
inUse: usedFamilies.has(family)
});
});
return res.status(400).send(pages.renderFontsPage({
fonts: fonts,
stylesheetHref: library.stylesheetHref
}, String(error.message || 'Font already exists.'), req.currentUser));
}
next(error);
}
});
app.post('/settings/fonts/:id/toggle', requireFontsAccess(setAuthMessageCookie), async function (req, res, next) {
try {
const enabled = String(req.body && req.body.enabled || '').trim() !== '0';
const updated = await fontLibrary.setFontEnabled(uploadDir, String(req.params.id || '').trim(), enabled);
if (!updated) {
return res.redirect('/settings/fonts?message=' + encodeURIComponent('That font was not found.'));
}
if (backgroundTaskQueue && typeof backgroundTaskQueue.enqueueTask === 'function') {
await backgroundTaskQueue.enqueueTask({
key: `font-sync:toggle:${updated && updated.id ? updated.id : Date.now()}`,
title: 'Font sync',
category: 'fonts',
taskType: 'font-sync',
payload: {
uploadDir: uploadDir,
operations: fontLibrary.collectFontLibrarySyncOperations(uploadDir)
},
persist: true
});
}
res.redirect('/settings/fonts?message=' + encodeURIComponent(enabled ? 'Font enabled.' : 'Font disabled.'));
} catch (error) {
next(error);
}
});
app.post('/settings/fonts/:id/delete', requireFontsAccess(setAuthMessageCookie), async function (req, res, next) {
try {
const fontId = String(req.params.id || '').trim();
const currentFont = fontLibrary.loadFontLibrary(uploadDir).fonts.find(function (font) {
return String(font.id || font.fileName || '').trim() === fontId || String(font.fileName || '').trim() === fontId;
});
const family = currentFont ? String(currentFont.family || currentFont.name || '').trim() : '';
if (family && await isFontUsed(pool, family)) {
return res.redirect('/settings/fonts?message=' + encodeURIComponent('That font is still used by one or more slides.'));
}
const removed = await fontLibrary.deleteFontFile(uploadDir, fontId);
if (!removed) {
return res.redirect('/settings/fonts?message=' + encodeURIComponent('That font was not found.'));
}
if (backgroundTaskQueue && typeof backgroundTaskQueue.enqueueTask === 'function') {
await backgroundTaskQueue.enqueueTask({
key: `font-sync:delete:${removed && removed.id ? removed.id : Date.now()}`,
title: 'Font sync',
category: 'fonts',
taskType: 'font-sync',
payload: {
uploadDir: uploadDir,
operations: [
removed.fileName ? { type: 'delete', uploadPath: '/media/fonts/' + removed.fileName } : null
].concat(fontLibrary.collectFontLibrarySyncOperations(uploadDir)).filter(Boolean)
},
persist: true
});
}
res.redirect('/settings/fonts?message=' + encodeURIComponent('Font removed.'));
} catch (error) {
next(error);
}
});
};
+14
View File
@@ -0,0 +1,14 @@
// Managed fonts settings page renderer.
const { renderView } = require('../../../view');
module.exports = function renderFontsPage(data, message, currentUser) {
return renderView('settings/fonts/list', {
title: 'Fonts',
active: 'fonts',
message: message,
currentUser: currentUser || null,
fonts: data.fonts || [],
stylesheetHref: data.stylesheetHref || ''
});
};
+2
View File
@@ -1,3 +1,5 @@
// RBAC role add page renderer.
const { renderView } = require('../../../view');
module.exports = function renderRbacAddPage(message, currentUser, formValues, permissionGroups, messageVariant) {
+3
View File
@@ -1,3 +1,5 @@
// RBAC role edit page renderer.
const { renderView } = require('../../../view');
module.exports = function renderRbacEditPage(role, message, currentUser, permissionGroups, users, pagination) {
@@ -7,6 +9,7 @@ module.exports = function renderRbacEditPage(role, message, currentUser, permiss
message: message,
currentUser: currentUser || null,
role: role,
inUse: Boolean(role && role.inUse),
permissionGroups: permissionGroups || [],
users: users || [],
pagination: pagination || null,
+2
View File
@@ -1,3 +1,5 @@
// RBAC role-and-permission list page renderer.
const { renderView } = require('../../../view');
module.exports = function renderRbacPage(data, message, currentUser) {
+2
View File
@@ -1,3 +1,5 @@
// User add page renderer.
const { renderView } = require('../../../view');
module.exports = function renderUsersAddPage(message, currentUser, roles, formValues, messageVariant) {
+3
View File
@@ -1,3 +1,5 @@
// User edit page renderer.
const { renderView } = require('../../../view');
module.exports = function renderUsersEditPage(user, message, currentUser, roles) {
@@ -7,6 +9,7 @@ module.exports = function renderUsersEditPage(user, message, currentUser, roles)
message: message,
currentUser: currentUser || null,
user: user,
inUse: Boolean(user && user.inUse),
roles: roles || []
});
};
+2
View File
@@ -1,3 +1,5 @@
// User list page renderer.
const { renderView } = require('../../../view');
module.exports = function renderUsersPage(data, message, currentUser) {