Files
pulse-signage/src/web/routes/settings/routes.js
T
lzstealth c4ae69d25f
Publish Docker Image / build-and-push-existing-registry (./build/Dockerfile, web, pulse-signage-web) (push) Successful in 2m0s
Publish Docker Image / build-and-push-existing-registry (./build/Dockerfile.player, player, pulse-signage-player) (push) Successful in 31s
release: v2.11.2
2026-09-05 18:14:38 +01:00

561 lines
39 KiB
JavaScript

// Settings page route registration.
const { ANNOUNCEMENT_ICON_CATALOG } = require('#src/data/announcement-icons');
const { AUDIT_CATEGORY_KEYS, AUDIT_CATEGORY_LABELS, SCREEN_CONTROL_COMMAND_KEYS, SCREEN_CONTROL_COMMAND_LABELS } = require('#src/data/audit-log');
const { fetchAppSettings, saveAppSettings } = require('#src/data/app-settings');
const { appVersion, detectSchemaVersion } = require('#src/db/migrations');
const UPLOAD_MIME_TYPE_OPTIONS = [
{ value: 'image/png', label: 'PNG', category: 'Images' },
{ value: 'image/jpeg', label: 'JPEG', category: 'Images' },
{ value: 'image/gif', label: 'GIF', category: 'Images' },
{ value: 'image/webp', label: 'WebP', category: 'Images' },
{ value: 'image/svg+xml', label: 'SVG', category: 'Images' },
{ value: 'video/mp4', label: 'MP4', category: 'Video' },
{ value: 'video/webm', label: 'WebM', category: 'Video' },
{ value: 'video/ogg', label: 'Ogg', category: 'Video' }
];
module.exports = function registerSettingsPageRoutes(app, deps) {
const pages = deps.pages;
const pool = deps.pool;
const recordRequestAuditEvent = deps.recordRequestAuditEvent;
const sendAccountEmail = deps.sendAccountEmail;
if (!pages || !pool || typeof deps.requirePermission !== 'function') {
throw new Error('registerSettingsPageRoutes requires pool, pages, and requirePermission.');
}
const requireSettingsRead = deps.requirePermission('system-settings.read');
const MAX_SUGGESTED_ICONS = 48;
const SECTION_SETTING_KEYS = {
defaults: [
'player.default_slide_duration_seconds',
'player.default_fade_between_slides',
'player.skip_unavailable_rtmp',
'announcements.default_icon',
'announcements.default_duration_value',
'announcements.default_duration_unit',
'data-sources.rss_default_interval_value',
'data-sources.rss_default_interval_unit',
'data-sources.api_default_interval_value',
'data-sources.api_default_interval_unit'
],
security: [
'security.session_lifetime_days',
'security.allow_user_session_revocation',
'security.max_active_sessions',
'security.login_max_attempts',
'security.login_lockout_minutes',
'security.login_rate_limit_scope',
'security.password_min_length',
'security.password_min_categories',
'security.password_require_lowercase',
'security.password_require_uppercase',
'security.password_require_number',
'security.password_require_symbol',
'security.require_password_change_for_new_users',
'security.require_password_change_after_admin_reset',
'security.allow_admin_email_verification_bypass'
],
email: ['email.smtp_enabled', 'email.smtp_host', 'email.smtp_port', 'email.smtp_security', 'email.smtp_username', 'email.from_address', 'email.from_name'],
'email-templates': ['email.verification_subject', 'email.verification_body', 'email.verification_button_alignment', 'email.verification_button_text', 'email.reset_subject', 'email.reset_body', 'email.reset_button_alignment', 'email.reset_button_text', 'email.invitation_subject', 'email.invitation_body', 'email.invitation_button_alignment', 'email.invitation_button_text'],
audit: ['audit.enabled', 'audit.categories', 'audit.screen_control_commands', 'audit.include_request_metadata', 'audit.retention_days'],
media: ['uploads.image_max_bytes', 'uploads.video_max_bytes', 'uploads.wysiwyg_image_max_bytes', 'uploads.allowed_mime_types'],
weather: ['weather.open_meteo_api_key', 'weather.pirate_weather_api_key'],
icons: ['announcements.suggested_icons']
};
const ALL_SETTING_KEYS = Object.keys(SECTION_SETTING_KEYS).reduce(function (keys, section) {
return keys.concat(SECTION_SETTING_KEYS[section].filter(function (key) {
return !keys.includes(key);
}));
}, []);
const ALL_SETTING_KEY_PLACEHOLDERS = ALL_SETTING_KEYS.map(function () { return '?'; }).join(', ');
function maskApiKey(value) {
const key = String(value || '');
if (!key) {
return '';
}
return key.length <= 8 ? 'Configured' : key.slice(0, 4) + '...' + key.slice(-4);
}
function buildSettingChanges(previousSettings, nextSettings, keys) {
const changes = {};
(Array.isArray(keys) ? keys : []).forEach(function (key) {
const previousValue = previousSettings && previousSettings[key];
const nextValue = nextSettings[key];
if (JSON.stringify(previousValue) !== JSON.stringify(nextValue)) {
changes[key] = { from: previousValue, to: nextValue };
}
});
return changes;
}
async function recordSettingsAuditEvent(req, previousSettings, nextSettings, section) {
if (typeof recordRequestAuditEvent !== 'function') {
return;
}
const changes = buildSettingChanges(previousSettings, nextSettings, SECTION_SETTING_KEYS[section]);
const changedKeys = Object.keys(changes);
if (!changedKeys.length) {
return;
}
await recordRequestAuditEvent(pool, req, {
category: 'system-settings',
eventType: 'system-settings.updated',
actorUserId: req.currentUser && req.currentUser.id,
targetType: 'settings',
targetId: section,
targetLabel: section,
details: { section: section, changes: changes }
});
}
function buildSuggestedAnnouncementIcons(settings) {
const configuredKeys = settings && Array.isArray(settings['announcements.suggested_icons'])
? settings['announcements.suggested_icons']
: [];
const selectedKeys = new Set(configuredKeys.map(function (key) {
return String(key || '').trim().toLowerCase();
}));
const catalogByKey = new Map(ANNOUNCEMENT_ICON_CATALOG.map(function (option) {
return [option.value, option];
}));
const selectedOptions = Array.from(new Set(configuredKeys.map(function (key) {
return catalogByKey.get(String(key || '').trim().toLowerCase()) || null;
}).filter(Boolean)));
const remainingOptions = ANNOUNCEMENT_ICON_CATALOG.filter(function (option) {
return !selectedKeys.has(option.value);
});
return selectedOptions.concat(remainingOptions).map(function (option) {
return Object.assign({}, option, {
isSelected: selectedKeys.has(option.value),
isDefault: selectedOptions.length > 0 && selectedOptions[0].value === option.value
});
});
}
async function renderSettings(req, res, message) {
const settings = await fetchAppSettings(pool);
const diagnostics = await buildDiagnostics();
res.send(pages.renderSettingsPage(settings, buildSuggestedAnnouncementIcons(settings), req.currentUser, message, {
imageMaxMb: Math.round(Number(settings['uploads.image_max_bytes']) / 1024 / 1024),
videoMaxMb: Math.round(Number(settings['uploads.video_max_bytes']) / 1024 / 1024),
wysiwygImageMaxMb: (Number(settings['uploads.wysiwyg_image_max_bytes']) / 1024 / 1024).toFixed(2).replace(/0+$/, '').replace(/\.$/, '')
,allowedMimeTypes: UPLOAD_MIME_TYPE_OPTIONS.map(function (option) {
return Object.assign({}, option, { isSelected: settings['uploads.allowed_mime_types'].indexOf(option.value) !== -1 });
}),
imageMimeTypes: UPLOAD_MIME_TYPE_OPTIONS.filter(function (option) { return option.category === 'Images'; }).map(function (option) {
return Object.assign({}, option, { isSelected: settings['uploads.allowed_mime_types'].indexOf(option.value) !== -1 });
}),
videoMimeTypes: UPLOAD_MIME_TYPE_OPTIONS.filter(function (option) { return option.category === 'Video'; }).map(function (option) {
return Object.assign({}, option, { isSelected: settings['uploads.allowed_mime_types'].indexOf(option.value) !== -1 });
}),
sessionLifetimeDays: Number(settings['security.session_lifetime_days']) || 14,
allowUserSessionRevocation: Boolean(settings['security.allow_user_session_revocation']),
maxActiveSessions: Number(settings['security.max_active_sessions']) || 0,
loginMaxAttempts: Number(settings['security.login_max_attempts']) || 5,
loginLockoutMinutes: Number(settings['security.login_lockout_minutes']) || 15,
loginRateLimitScope: String(settings['security.login_rate_limit_scope'] || 'both'),
passwordMinLength: Number(settings['security.password_min_length']) || 10,
passwordMinCategories: Number(settings['security.password_min_categories']) || 3,
passwordRequireLowercase: Boolean(settings['security.password_require_lowercase']),
passwordRequireUppercase: Boolean(settings['security.password_require_uppercase']),
passwordRequireNumber: Boolean(settings['security.password_require_number']),
passwordRequireSymbol: Boolean(settings['security.password_require_symbol']),
requirePasswordChangeForNewUsers: Boolean(settings['security.require_password_change_for_new_users']),
requirePasswordChangeAfterAdminReset: Boolean(settings['security.require_password_change_after_admin_reset']),
allowAdminEmailVerificationBypass: Boolean(settings['security.allow_admin_email_verification_bypass']),
smtpEnabled: Boolean(settings['email.smtp_enabled']),
smtpHost: String(settings['email.smtp_host'] || ''),
smtpPort: Number(settings['email.smtp_port']) || 587,
smtpSecurity: String(settings['email.smtp_security'] || 'starttls'),
smtpUsername: String(settings['email.smtp_username'] || ''),
smtpConfigured: Boolean(settings['email.smtp_password']),
emailFromAddress: String(settings['email.from_address'] || ''),
emailFromName: String(settings['email.from_name'] || ''),
verificationExpiryMinutes: Number(settings['email.verification_expiry_minutes']) || 30,
resetExpiryMinutes: Number(settings['email.reset_expiry_minutes']) || 30,
invitationExpiryHours: Number(settings['email.invitation_expiry_hours']) || 24,
verificationEmailSubject: String(settings['email.verification_subject'] || ''),
verificationEmailBody: String(settings['email.verification_body'] || ''),
verificationButtonAlignment: String(settings['email.verification_button_alignment'] || 'center'),
verificationButtonText: String(settings['email.verification_button_text'] || 'Verify email address'),
resetEmailSubject: String(settings['email.reset_subject'] || ''),
resetEmailBody: String(settings['email.reset_body'] || ''),
resetButtonAlignment: String(settings['email.reset_button_alignment'] || 'center'),
resetButtonText: String(settings['email.reset_button_text'] || 'Reset password'),
invitationEmailSubject: String(settings['email.invitation_subject'] || ''),
invitationEmailBody: String(settings['email.invitation_body'] || ''),
invitationButtonAlignment: String(settings['email.invitation_button_alignment'] || 'center'),
invitationButtonText: String(settings['email.invitation_button_text'] || 'Accept invitation'),
defaultSlideDurationSeconds: Number(settings['player.default_slide_duration_seconds']) || 10,
defaultFadeBetweenSlides: Boolean(settings['player.default_fade_between_slides']),
defaultSkipUnavailableRtmp: Boolean(settings['player.skip_unavailable_rtmp']),
defaultAnnouncementIcon: String(settings['announcements.default_icon'] || 'megaphone-fill'),
defaultAnnouncementDurationValue: Number(settings['announcements.default_duration_value']) || 10,
defaultAnnouncementDurationUnit: String(settings['announcements.default_duration_unit'] || 'seconds')
,rssDefaultIntervalValue: Number(settings['data-sources.rss_default_interval_value']) || 60
,rssDefaultIntervalUnit: String(settings['data-sources.rss_default_interval_unit'] || 'minutes')
,apiDefaultIntervalValue: Number(settings['data-sources.api_default_interval_value']) || 60
,apiDefaultIntervalUnit: String(settings['data-sources.api_default_interval_unit'] || 'minutes')
,weatherPirateWeatherConfigured: Boolean(settings['weather.pirate_weather_api_key'])
,weatherPirateWeatherKeyLabel: maskApiKey(settings['weather.pirate_weather_api_key'])
,weatherOpenMeteoConfigured: Boolean(settings['weather.open_meteo_api_key'])
,weatherOpenMeteoKeyLabel: maskApiKey(settings['weather.open_meteo_api_key'])
,auditRetentionDays: Number(settings['audit.retention_days']) || 0
,auditEnabled: Boolean(settings['audit.enabled'])
,auditIncludeRequestMetadata: Boolean(settings['audit.include_request_metadata'])
,auditCategories: AUDIT_CATEGORY_KEYS.map(function (category) {
return { key: category, label: AUDIT_CATEGORY_LABELS[category] || category, isSelected: settings['audit.categories'].includes(category) };
})
,auditEventCategories: AUDIT_CATEGORY_KEYS.filter(function (category) { return category !== 'users' && category !== 'roles' && category !== 'system-settings' && category !== 'slides' && category !== 'templates' && category !== 'playlists' && category !== 'screens' && category !== 'announcements' && category !== 'canvas-sizes' && category !== 'api-sources' && category !== 'rss-feeds' && category !== 'timetables' && category !== 'weather' && category !== 'screen-controls'; }).map(function (category) {
return { key: category, label: AUDIT_CATEGORY_LABELS[category] || category, isSelected: settings['audit.categories'].includes(category) };
})
,auditAdministrationCategories: AUDIT_CATEGORY_KEYS.filter(function (category) { return category === 'users' || category === 'roles' || category === 'system-settings'; }).map(function (category) {
return { key: category, label: AUDIT_CATEGORY_LABELS[category] || category, isSelected: settings['audit.categories'].includes(category) };
})
,auditContentCategories: AUDIT_CATEGORY_KEYS.filter(function (category) { return category === 'slides' || category === 'templates' || category === 'playlists' || category === 'screens' || category === 'announcements' || category === 'canvas-sizes'; }).map(function (category) {
return { key: category, label: AUDIT_CATEGORY_LABELS[category] || category, isSelected: settings['audit.categories'].includes(category) };
})
,auditDataSourceCategories: AUDIT_CATEGORY_KEYS.filter(function (category) { return category === 'api-sources' || category === 'rss-feeds' || category === 'timetables' || category === 'weather'; }).map(function (category) {
return { key: category, label: AUDIT_CATEGORY_LABELS[category] || category, isSelected: settings['audit.categories'].includes(category) };
})
,auditScreenControlCommands: SCREEN_CONTROL_COMMAND_KEYS.map(function (command) {
const selectedCommands = Array.isArray(settings['audit.screen_control_commands']) ? settings['audit.screen_control_commands'] : [];
return { key: command, label: SCREEN_CONTROL_COMMAND_LABELS[command] || command, isSelected: selectedCommands.includes(command) };
})
}, diagnostics));
}
async function buildDiagnostics() {
const diagnostics = {
applicationVersion: appVersion,
schemaVersion: 'Unknown',
databaseStatus: 'Unavailable',
activeSessionCount: 0,
backgroundTaskCount: 0,
criticalTableCounts: []
};
try {
await pool.query('SELECT 1 AS health');
diagnostics.databaseStatus = 'Connected';
diagnostics.schemaVersion = await detectSchemaVersion(pool);
const [sessionRows] = await pool.query('SELECT COUNT(*) AS count FROM a_sessions WHERE expires_at > NOW()');
diagnostics.activeSessionCount = Number(sessionRows && sessionRows[0] && sessionRows[0].count) || 0;
const [taskRows] = await pool.query('SELECT COUNT(*) AS count FROM o_background_tasks');
diagnostics.backgroundTaskCount = Number(taskRows && taskRows[0] && taskRows[0].count) || 0;
const criticalTables = [
['Users', 'a_users'],
['Roles', 'a_roles'],
['Screens', 'd_screens'],
['Playlists', 'c_playlists'],
['Slides', 'c_slides'],
['Templates', 'c_templates'],
['Announcements', 'd_announcements'],
['RSS feeds', 'i_rss_feeds'],
['API sources', 'i_api_sources']
];
for (const [label, tableName] of criticalTables) {
try {
const [rows] = await pool.query('SELECT COUNT(*) AS count FROM ' + tableName);
diagnostics.criticalTableCounts.push({ label: label, count: Number(rows && rows[0] && rows[0].count) || 0 });
} catch (_error) {
diagnostics.criticalTableCounts.push({ label: label, count: 'Unavailable' });
}
}
} catch (_error) {
// Keep diagnostics page renderable while the database is unavailable.
}
return diagnostics;
}
app.get('/settings/system', requireSettingsRead, async function (req, res, next) {
try {
await renderSettings(req, res, req.query.message ? String(req.query.message) : '');
} catch (error) {
next(error);
}
});
app.post('/settings/system', deps.requirePermission('system-settings.update'), async function (req, res, next) {
try {
const section = String(req.body && req.body.settings_section || '').trim().toLowerCase();
if (section !== 'icons' && section !== 'media' && section !== 'security' && section !== 'email' && section !== 'email-templates' && section !== 'defaults' && section !== 'audit' && section !== 'weather') {
const error = new Error('Unknown settings section.');
error.statusCode = 400;
error.expose = true;
throw error;
}
const previousSettings = await fetchAppSettings(pool);
if (section === 'email' && String(req.body && req.body.settings_action || '').trim().toLowerCase() === 'test_email') {
const recipient = String(req.currentUser && req.currentUser.email || '').trim();
if (!previousSettings['email.smtp_enabled'] || typeof sendAccountEmail !== 'function') {
const error = new Error('Email delivery is not configured.');
error.statusCode = 400;
error.expose = true;
throw error;
}
if (!recipient) {
const error = new Error('Your account does not have an email address.');
error.statusCode = 400;
error.expose = true;
throw error;
}
await sendAccountEmail(previousSettings, {
to: recipient,
subject: 'Pulse Signage SMTP test email',
text: 'This is a test email from Pulse Signage. Your SMTP settings are working.'
});
return res.redirect('/settings/system?message=' + encodeURIComponent('Test email sent to ' + recipient + '.') + '#email-delivery');
}
if (section === 'email-templates') {
const verificationSubject = String(req.body && req.body.verification_subject || '').trim();
const verificationBody = String(req.body && req.body.verification_body || '').trim();
const resetSubject = String(req.body && req.body.reset_subject || '').trim();
const resetBody = String(req.body && req.body.reset_body || '').trim();
const verificationButtonText = String(req.body && req.body.verification_button_text || '').trim();
const resetButtonText = String(req.body && req.body.reset_button_text || '').trim();
const invitationSubject = String(req.body && req.body.invitation_subject || '').trim();
const invitationBody = String(req.body && req.body.invitation_body || '').trim();
const invitationButtonText = String(req.body && req.body.invitation_button_text || '').trim();
if (!verificationSubject || !verificationBody.includes('[[url]]') || !resetSubject || !resetBody.includes('[[url]]') || !invitationSubject || !invitationBody.includes('[[url]]') || !verificationButtonText || !resetButtonText || !invitationButtonText) {
const error = new Error('Email templates require a subject, message, and the correct link placeholder.');
error.statusCode = 400;
error.expose = true;
throw error;
}
const savedSettings = await saveAppSettings(pool, {
'email.verification_subject': verificationSubject,
'email.verification_body': verificationBody,
'email.verification_button_alignment': ['left', 'center', 'right'].includes(String(req.body && req.body.verification_button_alignment || '').trim()) ? String(req.body.verification_button_alignment).trim() : 'center',
'email.verification_button_text': verificationButtonText,
'email.reset_subject': resetSubject,
'email.reset_body': resetBody,
'email.reset_button_alignment': ['left', 'center', 'right'].includes(String(req.body && req.body.reset_button_alignment || '').trim()) ? String(req.body.reset_button_alignment).trim() : 'center',
'email.reset_button_text': resetButtonText
,'email.invitation_subject': invitationSubject
,'email.invitation_body': invitationBody
,'email.invitation_button_alignment': ['left', 'center', 'right'].includes(String(req.body && req.body.invitation_button_alignment || '').trim()) ? String(req.body.invitation_button_alignment).trim() : 'center'
,'email.invitation_button_text': invitationButtonText
}, req.currentUser && req.currentUser.id);
await recordSettingsAuditEvent(req, previousSettings, savedSettings, section);
return res.redirect('/settings/system?message=' + encodeURIComponent('Account email templates saved.') + '#email-templates');
}
if (section === 'defaults') {
const defaultSlideDurationSeconds = Number(req.body && req.body.default_slide_duration_seconds);
const defaultAnnouncementDurationValue = Number(req.body && req.body.default_announcement_duration_value);
const defaultAnnouncementDurationUnit = String(req.body && req.body.default_announcement_duration_unit || 'seconds').trim().toLowerCase();
const rssDefaultIntervalValue = Number(req.body && req.body.rss_default_interval_value);
const rssDefaultIntervalUnit = String(req.body && req.body.rss_default_interval_unit || 'minutes').trim().toLowerCase();
const apiDefaultIntervalValue = Number(req.body && req.body.api_default_interval_value);
const apiDefaultIntervalUnit = String(req.body && req.body.api_default_interval_unit || 'minutes').trim().toLowerCase();
const defaultFadeBetweenSlides = req.body && (req.body.default_fade_between_slides === '1' || req.body.default_fade_between_slides === 'true');
const defaultSkipUnavailableRtmp = req.body && (req.body.default_skip_unavailable_rtmp === '1' || req.body.default_skip_unavailable_rtmp === 'true');
const defaultAnnouncementIcon = String(req.body && req.body.default_announcement_icon || '').trim();
if (!Number.isInteger(defaultSlideDurationSeconds) || defaultSlideDurationSeconds < 1 || defaultSlideDurationSeconds > 3600 || !Number.isInteger(defaultAnnouncementDurationValue) || defaultAnnouncementDurationValue < 1 || defaultAnnouncementDurationValue > 3600 || !['seconds', 'minutes'].includes(defaultAnnouncementDurationUnit) || !Number.isInteger(rssDefaultIntervalValue) || rssDefaultIntervalValue < 1 || rssDefaultIntervalValue > 86400 || !['seconds', 'minutes', 'hours'].includes(rssDefaultIntervalUnit) || !Number.isInteger(apiDefaultIntervalValue) || apiDefaultIntervalValue < 1 || apiDefaultIntervalValue > 86400 || !['seconds', 'minutes', 'hours'].includes(apiDefaultIntervalUnit) || !ANNOUNCEMENT_ICON_CATALOG.some(function (option) { return option.value === defaultAnnouncementIcon; })) {
const error = new Error('Default values must be valid.');
error.statusCode = 400;
error.expose = true;
throw error;
}
const savedSettings = await saveAppSettings(pool, {
'player.default_slide_duration_seconds': defaultSlideDurationSeconds,
'player.default_fade_between_slides': defaultFadeBetweenSlides,
'player.skip_unavailable_rtmp': defaultSkipUnavailableRtmp,
'announcements.default_icon': defaultAnnouncementIcon,
'announcements.default_duration_value': defaultAnnouncementDurationValue,
'announcements.default_duration_unit': defaultAnnouncementDurationUnit,
'data-sources.rss_default_interval_value': rssDefaultIntervalValue,
'data-sources.rss_default_interval_unit': rssDefaultIntervalUnit,
'data-sources.api_default_interval_value': apiDefaultIntervalValue,
'data-sources.api_default_interval_unit': apiDefaultIntervalUnit
}, req.currentUser && req.currentUser.id);
await recordSettingsAuditEvent(req, previousSettings, savedSettings, section);
return res.redirect('/settings/system?message=' + encodeURIComponent('Defaults saved.') + '#application-defaults');
}
if (section === 'security') {
const sessionLifetimeDays = Number(req.body && req.body.session_lifetime_days);
const allowUserSessionRevocation = req.body && (req.body.allow_user_session_revocation === '1' || req.body.allow_user_session_revocation === 'true');
const maxActiveSessions = Number(req.body && req.body.max_active_sessions);
const loginMaxAttempts = Number(req.body && req.body.login_max_attempts);
const loginLockoutMinutes = Number(req.body && req.body.login_lockout_minutes);
const loginRateLimitScope = String(req.body && req.body.login_rate_limit_scope || 'both').trim().toLowerCase();
const passwordMinLength = Number(req.body && req.body.password_min_length);
const passwordMinCategories = Number(req.body && req.body.password_min_categories);
const passwordRequireLowercase = req.body && (req.body.password_require_lowercase === '1' || req.body.password_require_lowercase === 'true');
const passwordRequireUppercase = req.body && (req.body.password_require_uppercase === '1' || req.body.password_require_uppercase === 'true');
const passwordRequireNumber = req.body && (req.body.password_require_number === '1' || req.body.password_require_number === 'true');
const passwordRequireSymbol = req.body && (req.body.password_require_symbol === '1' || req.body.password_require_symbol === 'true');
const requirePasswordChangeForNewUsers = req.body && (req.body.require_password_change_for_new_users === '1' || req.body.require_password_change_for_new_users === 'true');
const requirePasswordChangeAfterAdminReset = req.body && (req.body.require_password_change_after_admin_reset === '1' || req.body.require_password_change_after_admin_reset === 'true');
const requiredCategoryCount = [passwordRequireLowercase, passwordRequireUppercase, passwordRequireNumber, passwordRequireSymbol].filter(Boolean).length;
if (!Number.isInteger(sessionLifetimeDays) || sessionLifetimeDays < 1 || sessionLifetimeDays > 365 || !Number.isInteger(maxActiveSessions) || maxActiveSessions < 0 || maxActiveSessions > 100 || !Number.isInteger(loginMaxAttempts) || loginMaxAttempts < 1 || loginMaxAttempts > 100 || !Number.isInteger(loginLockoutMinutes) || loginLockoutMinutes < 1 || loginLockoutMinutes > 1440 || !['both', 'username', 'ip'].includes(loginRateLimitScope) || !Number.isInteger(passwordMinLength) || passwordMinLength < 8 || passwordMinLength > 128 || !Number.isInteger(passwordMinCategories) || passwordMinCategories < 1 || passwordMinCategories > 4 || requiredCategoryCount > passwordMinCategories) {
const error = new Error('Session and password settings must use valid values; required password categories cannot exceed the minimum category count.');
error.statusCode = 400;
error.expose = true;
throw error;
}
const savedSettings = await saveAppSettings(pool, {
'security.session_lifetime_days': sessionLifetimeDays,
'security.allow_user_session_revocation': allowUserSessionRevocation,
'security.max_active_sessions': maxActiveSessions,
'security.login_max_attempts': loginMaxAttempts,
'security.login_lockout_minutes': loginLockoutMinutes,
'security.login_rate_limit_scope': loginRateLimitScope,
'security.password_min_length': passwordMinLength,
'security.password_min_categories': passwordMinCategories,
'security.password_require_lowercase': passwordRequireLowercase,
'security.password_require_uppercase': passwordRequireUppercase,
'security.password_require_number': passwordRequireNumber,
'security.password_require_symbol': passwordRequireSymbol,
'security.require_password_change_for_new_users': requirePasswordChangeForNewUsers,
'security.require_password_change_after_admin_reset': requirePasswordChangeAfterAdminReset
,'security.allow_admin_email_verification_bypass': req.body && (req.body.allow_admin_email_verification_bypass === '1' || req.body.allow_admin_email_verification_bypass === 'true')
}, req.currentUser && req.currentUser.id);
await recordSettingsAuditEvent(req, previousSettings, savedSettings, section);
return res.redirect('/settings/system?message=' + encodeURIComponent('Security settings saved.') + '#security-sessions');
}
if (section === 'email') {
const smtpEnabled = req.body && (req.body.smtp_enabled === '1' || req.body.smtp_enabled === 'true');
const smtpHost = String(req.body && req.body.smtp_host || '').trim();
const smtpPort = Number(req.body && req.body.smtp_port);
const smtpSecurity = String(req.body && req.body.smtp_security || 'starttls').trim().toLowerCase();
const smtpUsername = String(req.body && req.body.smtp_username || '').trim();
const smtpPassword = String(req.body && req.body.smtp_password || '');
const fromAddress = String(req.body && req.body.from_address || '').trim();
const fromName = String(req.body && req.body.from_name || '').trim();
const verificationExpiryMinutes = Number(req.body && req.body.verification_expiry_minutes);
const resetExpiryMinutes = Number(req.body && req.body.reset_expiry_minutes);
const invitationExpiryHours = Number(req.body && req.body.invitation_expiry_hours);
if (!Number.isInteger(verificationExpiryMinutes) || verificationExpiryMinutes < 1 || verificationExpiryMinutes > 10080 || !Number.isInteger(resetExpiryMinutes) || resetExpiryMinutes < 1 || resetExpiryMinutes > 10080 || !Number.isInteger(invitationExpiryHours) || invitationExpiryHours < 1 || invitationExpiryHours > 720 || (smtpEnabled && (!smtpHost || !Number.isInteger(smtpPort) || smtpPort < 1 || smtpPort > 65535 || !fromAddress))) {
const error = new Error('Enabled email delivery requires an SMTP host, valid port, and from address.');
error.statusCode = 400;
error.expose = true;
throw error;
}
const savedSettings = await saveAppSettings(pool, {
'email.smtp_enabled': smtpEnabled,
'email.smtp_host': smtpHost,
'email.smtp_port': smtpPort || 587,
'email.smtp_security': smtpSecurity,
'email.smtp_username': smtpUsername,
'email.smtp_password': smtpPassword || previousSettings['email.smtp_password'],
'email.from_address': fromAddress,
'email.from_name': fromName,
'email.verification_expiry_minutes': verificationExpiryMinutes,
'email.reset_expiry_minutes': resetExpiryMinutes,
'email.invitation_expiry_hours': invitationExpiryHours
}, req.currentUser && req.currentUser.id);
await recordSettingsAuditEvent(req, previousSettings, savedSettings, section);
return res.redirect('/settings/system?message=' + encodeURIComponent('Email settings saved.') + '#email-delivery');
}
if (section === 'audit') {
const auditEnabled = req.body && (req.body.audit_enabled === '1' || req.body.audit_enabled === 'true');
const includeRequestMetadata = req.body && (req.body.include_request_metadata === '1' || req.body.include_request_metadata === 'true');
const selectedCategories = req.body && (req.body['audit_categories[]'] !== undefined ? req.body['audit_categories[]'] : req.body.audit_categories);
const auditCategories = Array.from(new Set((Array.isArray(selectedCategories) ? selectedCategories : selectedCategories ? [selectedCategories] : []).map(function (category) {
return String(category || '').trim().toLowerCase();
}).filter(function (category) {
return AUDIT_CATEGORY_KEYS.includes(category) && category !== 'screen-controls';
})));
const selectedScreenControlCommands = req.body && (req.body['audit_screen_control_commands[]'] !== undefined ? req.body['audit_screen_control_commands[]'] : req.body.audit_screen_control_commands);
const screenControlCommands = Array.from(new Set((Array.isArray(selectedScreenControlCommands) ? selectedScreenControlCommands : selectedScreenControlCommands ? [selectedScreenControlCommands] : []).map(function (command) {
return String(command || '').trim().toLowerCase();
}).filter(function (command) {
return SCREEN_CONTROL_COMMAND_KEYS.includes(command);
})));
if (screenControlCommands.length) {
auditCategories.push('screen-controls');
}
const retentionDays = Number(req.body && req.body.audit_retention_days);
if (!auditCategories.length || !Number.isInteger(retentionDays) || retentionDays < 0 || retentionDays > 3650) {
const error = new Error('Select at least one audit category and use a retention period between 0 and 3650 days.');
error.statusCode = 400;
error.expose = true;
throw error;
}
const savedSettings = await saveAppSettings(pool, {
'audit.enabled': auditEnabled,
'audit.categories': auditCategories,
'audit.screen_control_commands': screenControlCommands,
'audit.include_request_metadata': includeRequestMetadata,
'audit.retention_days': retentionDays
}, req.currentUser && req.currentUser.id);
await recordSettingsAuditEvent(req, previousSettings, savedSettings, section);
return res.redirect('/settings/system?message=' + encodeURIComponent('Audit logging settings saved.') + '#audit-logging');
}
if (section === 'media') {
const imageMaxMb = Number(req.body && req.body.image_max_mb);
const videoMaxMb = Number(req.body && req.body.video_max_mb);
const wysiwygImageMaxMb = Number(req.body && req.body.wysiwyg_image_max_mb);
const selectedMimeTypes = req.body && (req.body['allowed_mime_types[]'] !== undefined ? req.body['allowed_mime_types[]'] : req.body.allowed_mime_types);
const allowedMimeTypes = Array.from(new Set((Array.isArray(selectedMimeTypes) ? selectedMimeTypes : selectedMimeTypes ? [selectedMimeTypes] : []).map(function (mimeType) {
return String(mimeType || '').trim().toLowerCase();
}).filter(function (mimeType) {
return UPLOAD_MIME_TYPE_OPTIONS.some(function (option) { return option.value === mimeType; });
})));
const hasImageMimeType = allowedMimeTypes.some(function (mimeType) { return mimeType.indexOf('image/') === 0; });
const hasVideoMimeType = allowedMimeTypes.some(function (mimeType) { return mimeType.indexOf('video/') === 0; });
if (!Number.isInteger(imageMaxMb) || imageMaxMb < 1 || imageMaxMb > 1024 || !Number.isInteger(videoMaxMb) || videoMaxMb < 1 || videoMaxMb > 4096 || !Number.isFinite(wysiwygImageMaxMb) || wysiwygImageMaxMb < 0.1 || wysiwygImageMaxMb > imageMaxMb || !hasImageMimeType || !hasVideoMimeType) {
const error = new Error('Media upload limits must be valid and at least one image and video type must remain enabled.');
error.statusCode = 400;
error.expose = true;
throw error;
}
await pool.query('DELETE FROM o_app_settings WHERE setting_key NOT IN (' + ALL_SETTING_KEY_PLACEHOLDERS + ')', ALL_SETTING_KEYS);
const savedSettings = await saveAppSettings(pool, {
'uploads.image_max_bytes': imageMaxMb * 1024 * 1024,
'uploads.video_max_bytes': videoMaxMb * 1024 * 1024,
'uploads.wysiwyg_image_max_bytes': Math.round(wysiwygImageMaxMb * 1024 * 1024),
'uploads.allowed_mime_types': allowedMimeTypes
}, req.currentUser && req.currentUser.id);
await recordSettingsAuditEvent(req, previousSettings, savedSettings, section);
return res.redirect('/settings/system?message=' + encodeURIComponent('Media settings saved.') + '#media-uploads');
}
if (section === 'weather') {
const submittedOpenMeteoKey = String(req.body && req.body.open_meteo_api_key || '').trim();
const submittedPirateWeatherKey = String(req.body && req.body.pirate_weather_api_key || '').trim();
const savedSettings = await saveAppSettings(pool, {
'weather.open_meteo_api_key': submittedOpenMeteoKey || previousSettings['weather.open_meteo_api_key'],
'weather.pirate_weather_api_key': submittedPirateWeatherKey || previousSettings['weather.pirate_weather_api_key']
}, req.currentUser && req.currentUser.id);
await recordSettingsAuditEvent(req, previousSettings, savedSettings, section);
return res.redirect('/settings/system?message=' + encodeURIComponent('Weather provider settings saved.') + '#weather-providers');
}
const selectedIcons = req.body && (req.body['suggested_icons[]'] !== undefined ? req.body['suggested_icons[]'] : req.body.suggested_icons);
const catalogKeys = new Set(ANNOUNCEMENT_ICON_CATALOG.map(function (option) {
return option.value;
}));
const suggestedIcons = Array.from(new Set((Array.isArray(selectedIcons) ? selectedIcons : selectedIcons ? [selectedIcons] : []).map(function (iconKey) {
return String(iconKey || '').trim().toLowerCase();
}).filter(function (iconKey) {
return catalogKeys.has(iconKey);
})));
if (suggestedIcons.length > MAX_SUGGESTED_ICONS) {
const error = new Error('Choose no more than ' + MAX_SUGGESTED_ICONS + ' suggested icons.');
error.statusCode = 400;
error.expose = true;
throw error;
}
await pool.query('DELETE FROM o_app_settings WHERE setting_key NOT IN (' + ALL_SETTING_KEY_PLACEHOLDERS + ')', ALL_SETTING_KEYS);
const savedSettings = await saveAppSettings(pool, {
'announcements.suggested_icons': suggestedIcons
}, req.currentUser && req.currentUser.id);
await recordSettingsAuditEvent(req, previousSettings, savedSettings, section);
res.redirect('/settings/system?message=' + encodeURIComponent('Settings saved.'));
} catch (error) {
next(error);
}
});
};