Files
Hermes 283121edba Merge krystie-improvements into main
Resolves 24 conflicts between Hermes (DC-008/009/010 + response-helper
envelope standardization) and Krystie (DC-005 src/ refactor path fixes,
DC-006 TOTP integration, DC-007 new test suites, cloud backup
destinations).

Conflict resolutions:
- src/utils/logging.js:    took ours (consumers depend on logError/
                            safeErrorMessage/createLogger exports)
- src/config/site.js:      merged (her factored validateAndLogConfig +
                            applyConfigFields helpers)
- src/context/dns.js:      took hers (admin/readonly role iteration for
                            write operations)
- src/utilities/backup-
  manager.js:              took hers (Dropbox/WebDAV/SFTP cloud feature)
- status/dist/*, status/
  sw.js:                   took hers (minified bundles + newer SW cache)

Additional fix (post-merge regression):
- src/monitoring/health-checker.js: fixed DC-005 path miss —
  'require(./platform-paths)' → 'require(../../platform-paths)'

Test status: 921/922 passing. One known failure in logging.test.js
(async file-handle timing) tracked as follow-up.
2026-06-25 16:43:10 -07:00

267 lines
10 KiB
JavaScript

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 || ''
}
},
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();
// 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');
}
}
}
// 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) {
notificationConfig.providers.email = {
...notificationConfig.providers.email,
...providers.email
};
}
}
// Update events
if (events) {
notificationConfig.events = { ...notificationConfig.events, ...events };
}
// 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;
};