const express = require('express'); const { validateURL, validateToken } = require('../src/security/input-validator'); const validatorLib = require('validator'); const { paginate, parsePaginationParams } = require('../src/utilities/pagination'); const { ValidationError } = require('../src/utilities/errors'); const { ok, successMessage } = require('../src/utils/responses'); /** * Notifications route factory * @param {Object} deps - Explicit dependencies * @param {Object} deps.notification - Notification manager * @param {Function} deps.asyncHandler - Async route handler wrapper * @param {Function} deps.ok - Success response helper * @returns {express.Router} */ module.exports = function({ notification, asyncHandler, ok }) { const router = express.Router(); // GET /config — Get notification configuration (sensitive data redacted) router.get('/config', asyncHandler(async (req, res) => { const notificationConfig = notification.getConfig(); // Return config without sensitive data const safeConfig = { enabled: notificationConfig.enabled, providers: { discord: { enabled: notificationConfig.providers.discord?.enabled || false, configured: !!notificationConfig.providers.discord?.webhookUrl }, telegram: { enabled: notificationConfig.providers.telegram?.enabled || false, configured: !!(notificationConfig.providers.telegram?.botToken && notificationConfig.providers.telegram?.chatId) }, ntfy: { enabled: notificationConfig.providers.ntfy?.enabled || false, configured: !!notificationConfig.providers.ntfy?.topic, serverUrl: notificationConfig.providers.ntfy?.serverUrl || 'https://ntfy.sh' }, email: { enabled: notificationConfig.providers.email?.enabled || false, configured: !!(notificationConfig.providers.email?.host && notificationConfig.providers.email?.to), host: notificationConfig.providers.email?.host || '', from: notificationConfig.providers.email?.from || '', // DC-092: the settings UI needs these to roundtrip the form. // Password is NEVER returned; hasPassword lets the UI show a // "leave blank to keep" hint instead of an empty-looking field. port: notificationConfig.providers.email?.port || 587, secure: notificationConfig.providers.email?.secure === true, to: notificationConfig.providers.email?.to || '', username: notificationConfig.providers.email?.username || '', hasPassword: !!notificationConfig.providers.email?.password } }, events: notificationConfig.events, healthCheck: notificationConfig.healthCheck }; ok(res, { config: safeConfig }); }, 'notifications-config-get')); // POST /config — Update notification configuration router.post('/config', asyncHandler(async (req, res) => { const { enabled, providers, events, healthCheck } = req.body; const notificationConfig = notification.getConfig(); // DC-092: clients have historically sent at least three field spellings: // the settings UI sends email.user/email.pass (its input ids are // email-user/email-pass) while the manager/route read username/password. // Normalize aliases onto the canonical keys BEFORE the merge so SMTP auth // actually applies for UI-saved configs. if (providers?.email) { if (providers.email.user !== undefined && providers.email.username === undefined) { providers.email.username = providers.email.user; } if (providers.email.pass !== undefined && providers.email.password === undefined) { providers.email.password = providers.email.pass; } delete providers.email.user; delete providers.email.pass; } // DC-092 strict boolean contract: enabled/secure must be actual // booleans. `"false"` (string) is truthy — !!"false" === true — and // previously persisted as-is, silently forcing TLS on the next send. // Reject instead of coercing. const boolOrThrow = (val, label) => { if (val === undefined) return; if (typeof val !== 'boolean') { throw new ValidationError(`${label} must be a boolean (got ${typeof val})`); } }; boolOrThrow(enabled, 'enabled'); boolOrThrow(providers?.discord?.enabled, 'providers.discord.enabled'); boolOrThrow(providers?.telegram?.enabled, 'providers.telegram.enabled'); boolOrThrow(providers?.ntfy?.enabled, 'providers.ntfy.enabled'); boolOrThrow(providers?.email?.enabled, 'providers.email.enabled'); boolOrThrow(providers?.email?.secure, 'providers.email.secure'); // Validate provider webhook URLs and tokens if (providers) { if (providers.discord?.webhookUrl) { try { validateURL(providers.discord.webhookUrl); } catch (validationErr) { throw new ValidationError('Invalid Discord webhook URL'); } } if (providers.telegram?.botToken) { try { validateToken(providers.telegram.botToken); } catch (validationErr) { throw new ValidationError('Invalid Telegram bot token format'); } } if (providers.ntfy?.serverUrl) { try { validateURL(providers.ntfy.serverUrl); } catch (validationErr) { throw new ValidationError('Invalid ntfy server URL'); } } if (providers.ntfy?.topic) { const topicRegex = /^[a-zA-Z0-9_-]{1,64}$/; if (!topicRegex.test(providers.ntfy.topic)) { throw new ValidationError('Invalid ntfy topic (alphanumeric, hyphens, underscores only, max 64 chars)'); } } if (providers.email?.to) { const emails = providers.email.to.split(',').map(e => e.trim()); for (const email of emails) { if (!validatorLib.isEmail(email)) { throw new ValidationError(`Invalid email address: ${email}`); } } } if (providers.email?.host && typeof providers.email.host === 'string') { if (!validatorLib.isFQDN(providers.email.host) && !validatorLib.isIP(providers.email.host)) { throw new ValidationError('Invalid SMTP host'); } } if (providers.email?.port !== undefined) { const p = Number(providers.email.port); if (!Number.isInteger(p) || p < 1 || p > 65535) { throw new ValidationError('SMTP port must be an integer 1-65535'); } } } // Update enabled state if (typeof enabled === 'boolean') { notificationConfig.enabled = enabled; } // Update providers (only update provided fields) if (providers) { if (providers.discord) { notificationConfig.providers.discord = { ...notificationConfig.providers.discord, ...providers.discord }; } if (providers.telegram) { notificationConfig.providers.telegram = { ...notificationConfig.providers.telegram, ...providers.telegram }; } if (providers.ntfy) { notificationConfig.providers.ntfy = { ...notificationConfig.providers.ntfy, ...providers.ntfy }; } if (providers.email) { // Non-destructive merge: an empty-string username/password from the // UI (password field is intentionally left blank to keep stored // credentials) must NOT clobber the stored credential. const stored = notificationConfig.providers.email; const incoming = { ...providers.email }; if (incoming.password === '') delete incoming.password; if (incoming.username === '') delete incoming.username; notificationConfig.providers.email = { ...stored, ...incoming }; } } // Update events. DC-092: the UI sends camelCase keys (containerDown); // the canonical store/gate keys are kebab-case (container-down). Fold // before merging so UI toggles actually reach the keys the send() gate // reads. Values must be booleans; unknown keys pass through unchanged // (canonicalized if known alias) and merge over defaults. if (events) { const EVENT_KEY_ALIASES = { containerDown: 'container-down', containerUp: 'container-up', deploymentSuccess: 'deploy-success', deploymentFailed: 'deploy-failed', deploySuccess: 'deploy-success', deployFailed: 'deploy-failed', resourceAlert: 'alert', updateAvailable: 'update-available', backupComplete: 'backup-complete', backupFailed: 'backup-failed', autoRestart: 'auto-restart', }; const folded = {}; for (const [k, v] of Object.entries(events)) { const canonicalKey = EVENT_KEY_ALIASES[k] || k; folded[canonicalKey] = v; } for (const [k, v] of Object.entries(folded)) { if (typeof v !== 'boolean') { throw new ValidationError(`events.${k} must be a boolean (got ${typeof v})`); } } notificationConfig.events = { ...notificationConfig.events, ...folded }; } // Update health check settings if (healthCheck) { const wasEnabled = notificationConfig.healthCheck?.enabled; notificationConfig.healthCheck = { ...notificationConfig.healthCheck, ...healthCheck }; // Restart daemon if settings changed if (healthCheck.enabled !== wasEnabled || healthCheck.intervalMinutes) { if (notificationConfig.healthCheck.enabled) { notification.startHealthDaemon(); } else { notification.stopHealthDaemon(); } } } await notification.saveConfig(); successMessage(res, 'Notification config updated'); }, 'notifications-config-update')); // POST /test — Test notification delivery router.post('/test', asyncHandler(async (req, res) => { const { provider } = req.body; if (provider) { // Test specific provider let result; switch (provider) { case 'discord': result = await notification.sendDiscord('Test Notification', 'This is a test notification from DashCaddy.', 'info'); break; case 'telegram': result = await notification.sendTelegram('Test Notification', 'This is a test notification from DashCaddy.', 'info'); break; case 'ntfy': result = await notification.sendNtfy('Test Notification', 'This is a test notification from DashCaddy.', 'info'); break; case 'email': result = await notification.sendEmail('Test Notification', 'This is a test notification from DashCaddy.', 'info'); break; default: throw new ValidationError('Unknown provider'); } // result.success reflects actual delivery; keep that semantic by using // res.json directly (ok() hardcodes success:true). res.json({ success: result.success, provider, error: result.error }); } else { // Test all enabled providers const result = await notification.send('test', 'Test Notification', 'This is a test notification from DashCaddy.', 'info'); ok(res, { ...result }); } }, 'notifications-test')); // GET /history — Get notification history router.get('/history', asyncHandler(async (req, res) => { const notificationHistory = notification.getHistory(); const paginationParams = parsePaginationParams(req.query); if (paginationParams) { const result = paginate(notificationHistory, paginationParams); ok(res, { history: result.data, total: notificationHistory.length, pagination: result.pagination }); } else { const limit = parseInt(req.query.limit) || 50; ok(res, { history: notificationHistory.slice(0, limit), total: notificationHistory.length }); } }, 'notifications-history')); // DELETE /history — Clear notification history router.delete('/history', asyncHandler(async (req, res) => { notification.clearHistory(); successMessage(res, 'Notification history cleared'); }, 'notifications-history-clear')); // POST /health-check — Manually trigger health check router.post('/health-check', asyncHandler(async (req, res) => { await notification.checkHealth(); const notificationConfig = notification.getConfig(); ok(res, { lastCheck: notificationConfig.healthCheck.lastCheck, containersMonitored: Object.keys(notification.getHealthState()).length }); }, 'notifications-health-check')); // GET /status — Get notification system status router.get('/status', asyncHandler(async (req, res) => { const notificationConfig = notification.getConfig(); const providers = notificationConfig.providers || {}; ok(res, { enabled: notificationConfig.enabled, providers: { discord: providers.discord?.enabled && !!providers.discord?.webhookUrl, telegram: providers.telegram?.enabled && !!providers.telegram?.botToken && !!providers.telegram?.chatId, ntfy: providers.ntfy?.enabled && !!providers.ntfy?.topic, email: providers.email?.enabled && !!providers.email?.host && !!providers.email?.to }, lastSent: notification.lastSent, healthCheck: notificationConfig.healthCheck?.enabled ? { enabled: true, lastCheck: notificationConfig.healthCheck.lastCheck, intervalMinutes: notificationConfig.healthCheck.intervalMinutes } : { enabled: false } }); }, 'notifications-status')); // POST /send — Manual test send (used by frontend "Send Test" button) router.post('/send', asyncHandler(async (req, res) => { const { event, data, type } = req.body; if (!event) { throw new ValidationError('Event type is required'); } // Use 'test' as the event for manual sends const result = await notification.send(event, data || {}, type || 'info'); // result.success reflects actual per-provider delivery; ok() hardcodes true, // so use res.json to preserve the partial-failure semantic. res.json({ success: result.success, event, results: result.results }); }, 'notifications-send')); return router; };