561 lines
39 KiB
JavaScript
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);
|
|
}
|
|
});
|
|
}; |