// 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); } }); };