feat(notifications): add ntfy as a first-class notification channel

Adds ntfy.sh (and self-hosted instances) as a new push notification
channel with full parity to the existing webhook channel.

- Backend: NtfyConfig type, getUserNtfyConfig, getAdminNtfyConfig,
  resolveNtfyUrl, sendNtfy (header-based API with Title/Priority/Tags/
  Click headers), testNtfy, NTFY_EVENT_META (priority + emoji tags per
  event), SSRF guard via existing checkSsrf + createPinnedDispatcher
- notificationPreferencesService: ntfy added to NotifChannel union,
  IMPLEMENTED_COMBOS, getActiveChannels parser, getAvailableChannels,
  ADMIN_GLOBAL_CHANNELS, and AvailableChannels interface
- notificationService: per-user ntfy dispatch after webhook block;
  admin-scoped ntfy via getAdminGlobalPref for version_available events
- Routes: POST /api/notifications/test-ntfy with saved-token fallback
- authService: admin_ntfy_server/topic/token in ADMIN_SETTINGS_KEYS,
  masked + encrypted on read/write
- settingsService: ntfy_token added to ENCRYPTED_SETTING_KEYS
- Frontend: ntfy topic/server/token inputs + Save/Test/Clear buttons in
  NotificationsTab; admin Ntfy panel in AdminPage; testNtfy API method
- i18n: full English strings; English placeholders in 14 other locales
- Tests: resolveNtfyUrl, sendNtfy, dispatch integration, UI tests,
  MSW handler for test-ntfy endpoint
This commit is contained in:
jubnl
2026-04-15 13:59:25 +02:00
parent f349e567f8
commit bfe84b3016
30 changed files with 1241 additions and 52 deletions
+21 -1
View File
@@ -1,7 +1,7 @@
import express, { Request, Response } from 'express';
import { authenticate } from '../middleware/auth';
import { AuthRequest } from '../types';
import { testSmtp, testWebhook, getAdminWebhookUrl, getUserWebhookUrl } from '../services/notifications';
import { testSmtp, testWebhook, testNtfy, getAdminWebhookUrl, getUserWebhookUrl, getUserNtfyConfig, getAdminNtfyConfig } from '../services/notifications';
import {
getNotifications,
getUnreadCount,
@@ -47,6 +47,26 @@ router.post('/test-webhook', authenticate, async (req: Request, res: Response) =
res.json(await testWebhook(url));
});
router.post('/test-ntfy', authenticate, async (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { topic, server, token } = req.body as { topic?: string; server?: string; token?: string };
// Always load saved config for fallbacks (token may be masked or absent in request)
const userCfg = getUserNtfyConfig(authReq.user.id);
const adminCfg = getAdminNtfyConfig();
const resolvedTopic = topic || userCfg?.topic || undefined;
const resolvedServer = server || userCfg?.server || adminCfg.server || undefined;
// Reuse saved token when request sends null, empty, or the masked placeholder
const resolvedToken = (token && token !== '••••••••')
? token
: (userCfg?.token ?? adminCfg.token ?? null);
if (!resolvedTopic) return res.status(400).json({ error: 'No ntfy topic configured' });
res.json(await testNtfy({ topic: resolvedTopic, server: resolvedServer ?? null, token: resolvedToken }));
});
// ── In-app notifications ──────────────────────────────────────────────────────
// GET /in-app — list notifications (paginated)