Three user-facing notification features were silently dead from contract
drift between the settings UI and the backend:
1. SMTP auth never applied: the UI sent email.user/email.pass while the
manager read username/password. Route now normalizes aliases; legacy
config files canonicalize at load.
2. Event toggles were cosmetic: UI sent camelCase keys (containerDown),
the send() gate read kebab-case ('container-down'). EVENT_ALIASES now
folds every known spelling (manager gate, route store, legacy files);
UI sends and reads canonical keys.
3. Deploy/auto-restart notifications always dropped: deploy-success/
deploy-failed/auto-restart were missing from DEFAULT events, and
send('test') was itself gated -> the Test button was a no-op. Defaults
added; 'test' bypasses the gate.
Also: strict boolean contract (string 'false' for secure/enabled rejected
— previously coerced truthy, silently forcing TLS), SMTP port bounds,
non-destructive credential merge (blank password no longer clobbers the
stored one), GET /config returns port/secure/to/username + hasPassword
(password never returned), full form prefill + keep-hint placeholder.
Verified live pre-fix: send('test')/'deploymentSuccess'/'auto-restart'
all returned 'not enabled'. Post-fix: +20 tests (route + manager),
full suite 2641/2641 (112 suites).
Judge: GLM-5.3 cold read via delegate_task (deleg_ad765d63), grade B,
ship, zero blockers; judge independently ran the DC-092 suites (38/38).
Polish #1 (canonical event for provider titles) folded in. Remaining
emitters with the same gate-miss class (recipeRemoved, workflow,
ssl-cert-expiry, dns-propagation, drift-detected, dependency-restart-*)
noted for a follow-up tick.
348 lines
14 KiB
JavaScript
348 lines
14 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 || '',
|
|
// 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;
|
|
};
|