- Container exec/shell via WebSocket + xterm.js (subtle >_ button on cards) - Live dashboard updates via SSE (resource alerts, health changes, update notices) - Docker Compose import with YAML parsing, preview, and dependency-ordered deploy - Volume & network management modal with disk usage overview - CPU/memory resource limits on deploy and live update - Email SMTP notifications (nodemailer) alongside Discord/Telegram/ntfy - Scheduled auto-update scheduler with maintenance windows (daily/weekly/monthly) New deps: ws, js-yaml, nodemailer Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
223 lines
8.4 KiB
JavaScript
223 lines
8.4 KiB
JavaScript
const express = require('express');
|
|
const { validateURL, validateToken } = require('../input-validator');
|
|
const validatorLib = require('validator');
|
|
const { paginate, parsePaginationParams } = require('../pagination');
|
|
const { ValidationError } = require('../errors');
|
|
|
|
/**
|
|
* Notifications route factory
|
|
* @param {Object} deps - Explicit dependencies
|
|
* @param {Object} deps.notification - Notification manager
|
|
* @param {Function} deps.asyncHandler - Async route handler wrapper
|
|
* @returns {express.Router}
|
|
*/
|
|
module.exports = function({ notification, asyncHandler }) {
|
|
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
|
|
};
|
|
res.json({ success: true, 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();
|
|
res.json({ success: true, message: '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');
|
|
}
|
|
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');
|
|
res.json({ success: true, ...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);
|
|
res.json({ success: true, history: result.data, total: notificationHistory.length, pagination: result.pagination });
|
|
} else {
|
|
const limit = parseInt(req.query.limit) || 50;
|
|
res.json({
|
|
success: true,
|
|
history: notificationHistory.slice(0, limit),
|
|
total: notificationHistory.length
|
|
});
|
|
}
|
|
}, 'notifications-history'));
|
|
|
|
// DELETE /history — Clear notification history
|
|
router.delete('/history', asyncHandler(async (req, res) => {
|
|
notification.clearHistory();
|
|
res.json({ success: true, message: '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();
|
|
res.json({
|
|
success: true,
|
|
lastCheck: notificationConfig.healthCheck.lastCheck,
|
|
containersMonitored: Object.keys(notification.getHealthState()).length
|
|
});
|
|
}, 'notifications-health-check'));
|
|
|
|
return router;
|
|
};
|