// Audit event definitions and data access helpers for administrative activity. const AUDIT_EVENT_CATEGORIES = Object.freeze({ AUTHENTICATION: 'authentication', SECURITY: 'security', SESSIONS: 'sessions', USERS: 'users', ROLES: 'roles', SETTINGS: 'system-settings', SLIDES: 'slides', TEMPLATES: 'templates', PLAYLISTS: 'playlists', SCREENS: 'screens', ANNOUNCEMENTS: 'announcements', CANVAS_SIZES: 'canvas-sizes', API_SOURCES: 'api-sources', RSS_FEEDS: 'rss-feeds', TIMETABLES: 'timetables', WEATHER: 'weather', SCREEN_CONTROLS: 'screen-controls' }); const AUDIT_CATEGORY_KEYS = Object.freeze(Object.values(AUDIT_EVENT_CATEGORIES)); const AUDIT_CATEGORY_LABELS = Object.freeze({ authentication: 'Authentication', security: 'Security', sessions: 'Sessions', users: 'Users', roles: 'Roles', 'system-settings': 'System Settings', slides: 'Slides', templates: 'Templates', playlists: 'Playlists', screens: 'Screens', announcements: 'Announcements', 'canvas-sizes': 'Canvas Sizes', 'api-sources': 'API Sources', 'rss-feeds': 'RSS Feeds', timetables: 'Timetables', weather: 'Weather', 'screen-controls': 'Screen Controls' }); const SCREEN_CONTROL_COMMAND_KEYS = Object.freeze(['pause', 'blackout', 'reload', 'navigation', 'moveclient', 'setclientname']); const SCREEN_CONTROL_COMMAND_LABELS = Object.freeze({ pause: 'Pause / Resume', blackout: 'Blackout / Restore', reload: 'Reload', navigation: 'Forward / Back', moveclient: 'Move client', setclientname: 'Rename client' }); const { fetchAppSettings } = require('./app-settings'); function formatUserAgentLabel(userAgent) { const value = String(userAgent || '').trim(); if (!value) return ''; const browserMatch = value.match(/(?:Edg|OPR|Chrome|Firefox|Version|Electron)\/([\d.]+)/i); let browser = ''; if (/Edg\//i.test(value)) browser = 'Edge'; else if (/OPR\//i.test(value)) browser = 'Opera'; else if (/Electron\//i.test(value)) browser = 'Electron'; else if (/Chrome\//i.test(value)) browser = 'Chrome'; else if (/Firefox\//i.test(value)) browser = 'Firefox'; else if (/Version\/.*Safari\//i.test(value)) browser = 'Safari'; const browserLabel = browserMatch && browser ? browser + ' ' + browserMatch[1] : browser; let operatingSystem = ''; if (/Windows NT/i.test(value)) operatingSystem = 'Windows'; else if (/Macintosh|Mac OS X/i.test(value)) operatingSystem = 'macOS'; else if (/Android/i.test(value)) operatingSystem = 'Android'; else if (/iPhone|iPad|iPod/i.test(value)) operatingSystem = 'iOS'; else if (/Linux/i.test(value)) operatingSystem = 'Linux'; return [browserLabel, operatingSystem].filter(Boolean).join(' on ') || value; } function normalizeDetails(details) { if (details === undefined || details === null) { return null; } return JSON.stringify(details); } function buildAuditChanges(previousValues, nextValues) { const previous = previousValues && typeof previousValues === 'object' ? previousValues : {}; const next = nextValues && typeof nextValues === 'object' ? nextValues : {}; const changes = {}; const keys = new Set(Object.keys(previous).concat(Object.keys(next))); keys.forEach(function (key) { if (JSON.stringify(previous[key]) !== JSON.stringify(next[key])) { changes[key] = { from: previous[key], to: next[key] }; } }); return changes; } function getRequestMetadata(req) { const forwardedAddress = String(req && req.headers && req.headers['x-forwarded-for'] || '').split(',')[0].trim(); return { ipAddress: forwardedAddress || String(req && req.ip || req && req.socket && req.socket.remoteAddress || '').trim() || null, userAgent: String(req && req.headers && req.headers['user-agent'] || '').trim() || null }; } async function recordAuditEvent(pool, event) { const input = event && typeof event === 'object' ? event : {}; const category = String(input.category || '').trim().toLowerCase(); const eventType = String(input.eventType || '').trim().toLowerCase(); if (!category || !eventType) { throw new Error('Audit events require a category and event type.'); } await pool.query( `INSERT INTO o_audit_events (category, event_type, actor_user_id, target_type, target_id, target_label, ip_address, user_agent, details_json) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, [ category, eventType, Number.isInteger(Number(input.actorUserId)) && Number(input.actorUserId) > 0 ? Number(input.actorUserId) : null, String(input.targetType || '').trim() || null, String(input.targetId || '').trim() || null, String(input.targetLabel || '').trim() || null, String(input.ipAddress || '').trim() || null, String(input.userAgent || '').trim() || null, normalizeDetails(input.details) ] ); } async function recordRequestAuditEvent(pool, req, event) { try { const settings = await fetchAppSettings(pool); const category = String(event && event.category || '').trim().toLowerCase(); const enabledCategories = Array.isArray(settings['audit.categories']) ? settings['audit.categories'] : AUDIT_CATEGORY_KEYS; if (!settings['audit.enabled'] || !enabledCategories.includes(category)) { return; } if (category === 'screen-controls') { const command = String(event && event.eventType || '').replace(/^screen-control\./, '').trim().toLowerCase(); const commandGroup = command === 'previous' || command === 'next' ? 'navigation' : command; const enabledCommands = Array.isArray(settings['audit.screen_control_commands']) ? settings['audit.screen_control_commands'] : []; if (!enabledCommands.includes(commandGroup)) { return; } } const metadata = settings['audit.include_request_metadata'] ? getRequestMetadata(req) : {}; await recordAuditEvent(pool, Object.assign({}, event, metadata)); } catch (error) { // Auditing must not turn a successful login or administration action into a failed request. console.error('Unable to record audit event:', error.message); } } module.exports = { AUDIT_EVENT_CATEGORIES, AUDIT_CATEGORY_KEYS, AUDIT_CATEGORY_LABELS, SCREEN_CONTROL_COMMAND_KEYS, SCREEN_CONTROL_COMMAND_LABELS, formatUserAgentLabel, getRequestMetadata, buildAuditChanges, recordAuditEvent, recordRequestAuditEvent };