mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-19 21:31:46 +00:00
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:
@@ -30,7 +30,7 @@ const MFA_BACKUP_CODE_COUNT = 10;
|
||||
const ADMIN_SETTINGS_KEYS = [
|
||||
'allow_registration', 'allowed_file_types', 'require_mfa',
|
||||
'smtp_host', 'smtp_port', 'smtp_user', 'smtp_pass', 'smtp_from', 'smtp_skip_tls_verify',
|
||||
'notification_channels', 'admin_webhook_url',
|
||||
'notification_channels', 'admin_webhook_url', 'admin_ntfy_server', 'admin_ntfy_topic', 'admin_ntfy_token',
|
||||
'password_login', 'password_registration', 'oidc_login', 'oidc_registration',
|
||||
];
|
||||
|
||||
@@ -714,7 +714,7 @@ export function getAppSettings(userId: number): { error?: string; status?: numbe
|
||||
const result: Record<string, string> = {};
|
||||
for (const key of ADMIN_SETTINGS_KEYS) {
|
||||
const row = db.prepare("SELECT value FROM app_settings WHERE key = ?").get(key) as { value: string } | undefined;
|
||||
if (row) result[key] = (key === 'smtp_pass' || key === 'admin_webhook_url') ? '••••••••' : row.value;
|
||||
if (row) result[key] = (key === 'smtp_pass' || key === 'admin_webhook_url' || key === 'admin_ntfy_token') ? '••••••••' : row.value;
|
||||
}
|
||||
return { data: result };
|
||||
}
|
||||
@@ -768,6 +768,8 @@ export function updateAppSettings(
|
||||
if (key === 'smtp_pass') val = encrypt_api_key(val);
|
||||
if (key === 'admin_webhook_url' && val === '••••••••') continue;
|
||||
if (key === 'admin_webhook_url' && val) val = maybe_encrypt_api_key(val) ?? val;
|
||||
if (key === 'admin_ntfy_token' && val === '••••••••') continue;
|
||||
if (key === 'admin_ntfy_token' && val) val = maybe_encrypt_api_key(val) ?? val;
|
||||
db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)").run(key, val);
|
||||
}
|
||||
}
|
||||
@@ -778,6 +780,7 @@ export function updateAppSettings(
|
||||
const smtpChanged = changedKeys.some(k => k.startsWith('smtp_'));
|
||||
if (changedKeys.includes('notification_channels')) summary.notification_channels = body.notification_channels;
|
||||
if (changedKeys.includes('admin_webhook_url')) summary.admin_webhook_url_updated = true;
|
||||
if (changedKeys.some(k => k.startsWith('admin_ntfy_'))) summary.admin_ntfy_updated = true;
|
||||
if (smtpChanged) summary.smtp_settings_updated = true;
|
||||
if (changedKeys.includes('allow_registration')) summary.allow_registration = body.allow_registration;
|
||||
if (changedKeys.includes('allowed_file_types')) summary.allowed_file_types_updated = true;
|
||||
|
||||
@@ -3,7 +3,7 @@ import { decrypt_api_key } from './apiKeyCrypto';
|
||||
|
||||
// ── Types ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export type NotifChannel = 'email' | 'webhook' | 'inapp';
|
||||
export type NotifChannel = 'email' | 'webhook' | 'inapp' | 'ntfy';
|
||||
|
||||
export type NotifEventType =
|
||||
| 'trip_invite'
|
||||
@@ -20,19 +20,20 @@ export interface AvailableChannels {
|
||||
email: boolean;
|
||||
webhook: boolean;
|
||||
inapp: boolean;
|
||||
ntfy: boolean;
|
||||
}
|
||||
|
||||
// Which channels are implemented for each event type.
|
||||
// Only implemented combos show toggles in the user preferences UI.
|
||||
const IMPLEMENTED_COMBOS: Record<NotifEventType, NotifChannel[]> = {
|
||||
trip_invite: ['inapp', 'email', 'webhook'],
|
||||
booking_change: ['inapp', 'email', 'webhook'],
|
||||
trip_reminder: ['inapp', 'email', 'webhook'],
|
||||
vacay_invite: ['inapp', 'email', 'webhook'],
|
||||
photos_shared: ['inapp', 'email', 'webhook'],
|
||||
collab_message: ['inapp', 'email', 'webhook'],
|
||||
packing_tagged: ['inapp', 'email', 'webhook'],
|
||||
version_available: ['inapp', 'email', 'webhook'],
|
||||
trip_invite: ['inapp', 'email', 'webhook', 'ntfy'],
|
||||
booking_change: ['inapp', 'email', 'webhook', 'ntfy'],
|
||||
trip_reminder: ['inapp', 'email', 'webhook', 'ntfy'],
|
||||
vacay_invite: ['inapp', 'email', 'webhook', 'ntfy'],
|
||||
photos_shared: ['inapp', 'email', 'webhook', 'ntfy'],
|
||||
collab_message: ['inapp', 'email', 'webhook', 'ntfy'],
|
||||
packing_tagged: ['inapp', 'email', 'webhook', 'ntfy'],
|
||||
version_available: ['inapp', 'email', 'webhook', 'ntfy'],
|
||||
synology_session_cleared: ['inapp'],
|
||||
};
|
||||
|
||||
@@ -55,7 +56,7 @@ function getAppSetting(key: string): string | null {
|
||||
export function getActiveChannels(): NotifChannel[] {
|
||||
const raw = getAppSetting('notification_channels') || getAppSetting('notification_channel') || 'none';
|
||||
if (raw === 'none') return [];
|
||||
return raw.split(',').map(c => c.trim()).filter((c): c is NotifChannel => c === 'email' || c === 'webhook');
|
||||
return raw.split(',').map(c => c.trim()).filter((c): c is NotifChannel => c === 'email' || c === 'webhook' || c === 'ntfy');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -64,8 +65,8 @@ export function getActiveChannels(): NotifChannel[] {
|
||||
*/
|
||||
export function getAvailableChannels(): AvailableChannels {
|
||||
const hasSmtp = !!(process.env.SMTP_HOST || getAppSetting('smtp_host'));
|
||||
const hasWebhook = getActiveChannels().includes('webhook');
|
||||
return { email: hasSmtp, webhook: hasWebhook, inapp: true };
|
||||
const activeChannels = getActiveChannels();
|
||||
return { email: hasSmtp, webhook: activeChannels.includes('webhook'), ntfy: activeChannels.includes('ntfy'), inapp: true };
|
||||
}
|
||||
|
||||
// ── Per-user preference checks ─────────────────────────────────────────────
|
||||
@@ -115,8 +116,8 @@ export function getPreferencesMatrix(userId: number, userRole: string, scope: 'u
|
||||
const channels = IMPLEMENTED_COMBOS[eventType];
|
||||
preferences[eventType] = {};
|
||||
for (const channel of channels) {
|
||||
// Admin-scoped events use global settings for email/webhook
|
||||
if (scope === 'admin' && ADMIN_SCOPED_EVENTS.has(eventType) && (channel === 'email' || channel === 'webhook')) {
|
||||
// Admin-scoped events use global settings for email/webhook/ntfy
|
||||
if (scope === 'admin' && ADMIN_SCOPED_EVENTS.has(eventType) && (channel === 'email' || channel === 'webhook' || channel === 'ntfy')) {
|
||||
preferences[eventType]![channel] = getAdminGlobalPref(eventType, channel);
|
||||
} else {
|
||||
preferences[eventType]![channel] = stored[eventType]?.[channel] ?? true;
|
||||
@@ -134,12 +135,14 @@ export function getPreferencesMatrix(userId: number, userRole: string, scope: 'u
|
||||
if (scope === 'admin') {
|
||||
const hasSmtp = !!(process.env.SMTP_HOST || getAppSetting('smtp_host'));
|
||||
const hasAdminWebhook = !!(getAppSetting('admin_webhook_url'));
|
||||
available_channels = { email: hasSmtp, webhook: hasAdminWebhook, inapp: true };
|
||||
const hasAdminNtfy = !!(getAppSetting('admin_ntfy_topic'));
|
||||
available_channels = { email: hasSmtp, webhook: hasAdminWebhook, ntfy: hasAdminNtfy, inapp: true };
|
||||
} else {
|
||||
const activeChannels = getActiveChannels();
|
||||
available_channels = {
|
||||
email: activeChannels.includes('email'),
|
||||
webhook: activeChannels.includes('webhook'),
|
||||
ntfy: activeChannels.includes('ntfy'),
|
||||
inapp: true,
|
||||
};
|
||||
}
|
||||
@@ -154,19 +157,19 @@ export function getPreferencesMatrix(userId: number, userRole: string, scope: 'u
|
||||
|
||||
// ── Admin global preferences (stored in app_settings) ─────────────────────
|
||||
|
||||
const ADMIN_GLOBAL_CHANNELS: NotifChannel[] = ['email', 'webhook'];
|
||||
const ADMIN_GLOBAL_CHANNELS: NotifChannel[] = ['email', 'webhook', 'ntfy'];
|
||||
|
||||
/**
|
||||
* Returns the global admin preference for an event+channel.
|
||||
* Stored in app_settings as `admin_notif_pref_{event}_{channel}`.
|
||||
* Defaults to true (enabled) when no row exists.
|
||||
*/
|
||||
export function getAdminGlobalPref(event: NotifEventType, channel: 'email' | 'webhook'): boolean {
|
||||
export function getAdminGlobalPref(event: NotifEventType, channel: 'email' | 'webhook' | 'ntfy'): boolean {
|
||||
const val = getAppSetting(`admin_notif_pref_${event}_${channel}`);
|
||||
return val !== '0';
|
||||
}
|
||||
|
||||
function setAdminGlobalPref(event: NotifEventType, channel: 'email' | 'webhook', enabled: boolean): void {
|
||||
function setAdminGlobalPref(event: NotifEventType, channel: 'email' | 'webhook' | 'ntfy', enabled: boolean): void {
|
||||
db.prepare('INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)').run(
|
||||
`admin_notif_pref_${event}_${channel}`,
|
||||
enabled ? '1' : '0'
|
||||
@@ -250,7 +253,7 @@ export function setAdminPreferences(
|
||||
for (const [eventType, channels] of Object.entries(globalPrefs)) {
|
||||
if (!channels) continue;
|
||||
for (const [channel, enabled] of Object.entries(channels)) {
|
||||
setAdminGlobalPref(eventType as NotifEventType, channel as 'email' | 'webhook', enabled);
|
||||
setAdminGlobalPref(eventType as NotifEventType, channel as 'email' | 'webhook' | 'ntfy', enabled);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,15 +7,20 @@ import {
|
||||
isSmtpConfigured,
|
||||
ADMIN_SCOPED_EVENTS,
|
||||
type NotifEventType,
|
||||
type NotifChannel,
|
||||
} from './notificationPreferencesService';
|
||||
import {
|
||||
getEventText,
|
||||
sendEmail,
|
||||
sendWebhook,
|
||||
sendNtfy,
|
||||
getUserEmail,
|
||||
getUserLanguage,
|
||||
getUserWebhookUrl,
|
||||
getAdminWebhookUrl,
|
||||
getUserNtfyConfig,
|
||||
getAdminNtfyConfig,
|
||||
resolveNtfyUrl,
|
||||
getAppUrl,
|
||||
} from './notifications';
|
||||
import {
|
||||
@@ -270,6 +275,19 @@ export async function send(payload: NotificationPayload): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Ntfy (per-user) — skip for admin-scoped events (handled globally below) ──
|
||||
if (!ADMIN_SCOPED_EVENTS.has(event) && activeChannels.includes('ntfy') && isEnabledForEvent(recipientId, event, 'ntfy' as NotifChannel)) {
|
||||
const userNtfyCfg = getUserNtfyConfig(recipientId);
|
||||
const adminNtfyCfg = getAdminNtfyConfig();
|
||||
const ntfyUrl = resolveNtfyUrl(adminNtfyCfg, userNtfyCfg);
|
||||
if (ntfyUrl) {
|
||||
const lang = getUserLanguage(recipientId);
|
||||
const { title, body } = getEventText(lang, event, params);
|
||||
const token = userNtfyCfg?.token ?? adminNtfyCfg.token;
|
||||
promises.push(sendNtfy(ntfyUrl, token, { event, title, body, link: fullLink }));
|
||||
}
|
||||
}
|
||||
|
||||
const results = await Promise.allSettled(promises);
|
||||
for (const result of results) {
|
||||
if (result.status === 'rejected') {
|
||||
@@ -288,4 +306,16 @@ export async function send(payload: NotificationPayload): Promise<void> {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ── Admin ntfy (scope: admin) — global, respects global pref ─────────
|
||||
if (scope === 'admin' && getAdminGlobalPref(event, 'ntfy')) {
|
||||
const adminNtfyCfg = getAdminNtfyConfig();
|
||||
const adminNtfyUrl = resolveNtfyUrl(adminNtfyCfg, null);
|
||||
if (adminNtfyUrl) {
|
||||
const { title, body } = getEventText('en', event, params);
|
||||
await sendNtfy(adminNtfyUrl, adminNtfyCfg.token, { event, title, body, link: fullLink }).catch((err: unknown) => {
|
||||
logError(`notificationService.send admin ntfy failed event=${event}: ${err instanceof Error ? err.message : err}`);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -442,3 +442,134 @@ export async function testWebhook(url: string): Promise<{ success: boolean; erro
|
||||
}
|
||||
}
|
||||
|
||||
// ── Ntfy ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface NtfyConfig {
|
||||
server: string | null;
|
||||
topic: string | null;
|
||||
token: string | null;
|
||||
}
|
||||
|
||||
/** Priority and tags mapped to each notification event type. */
|
||||
const NTFY_EVENT_META: Partial<Record<NotifEventType, { priority: 1 | 2 | 3 | 4 | 5; tags: string[] }>> = {
|
||||
trip_invite: { priority: 4, tags: ['loudspeaker'] },
|
||||
booking_change: { priority: 3, tags: ['calendar'] },
|
||||
trip_reminder: { priority: 4, tags: ['bell', 'alarm_clock'] },
|
||||
vacay_invite: { priority: 4, tags: ['palm_tree'] },
|
||||
photos_shared: { priority: 3, tags: ['camera'] },
|
||||
collab_message: { priority: 3, tags: ['speech_balloon'] },
|
||||
packing_tagged: { priority: 3, tags: ['luggage'] },
|
||||
version_available: { priority: 4, tags: ['package'] },
|
||||
synology_session_cleared: { priority: 3, tags: ['warning'] },
|
||||
};
|
||||
const NTFY_DEFAULT_META = { priority: 3 as const, tags: [] as string[] };
|
||||
|
||||
export function getUserNtfyConfig(userId: number): NtfyConfig | null {
|
||||
const rows = db.prepare(
|
||||
"SELECT key, value FROM settings WHERE user_id = ? AND key IN ('ntfy_topic', 'ntfy_server', 'ntfy_token')"
|
||||
).all(userId) as { key: string; value: string }[];
|
||||
if (rows.length === 0) return null;
|
||||
const map: Record<string, string> = {};
|
||||
for (const r of rows) map[r.key] = r.value;
|
||||
return {
|
||||
topic: map['ntfy_topic'] || null,
|
||||
server: map['ntfy_server'] || null,
|
||||
token: map['ntfy_token'] ? decrypt_api_key(map['ntfy_token']) : null,
|
||||
};
|
||||
}
|
||||
|
||||
export function getAdminNtfyConfig(): NtfyConfig {
|
||||
const topic = getAppSetting('admin_ntfy_topic') || null;
|
||||
const server = getAppSetting('admin_ntfy_server') || null;
|
||||
const rawToken = getAppSetting('admin_ntfy_token') || null;
|
||||
return {
|
||||
topic,
|
||||
server,
|
||||
token: rawToken ? decrypt_api_key(rawToken) : null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the ntfy POST URL from admin base config + user override.
|
||||
* Returns null if topic cannot be determined.
|
||||
*/
|
||||
export function resolveNtfyUrl(adminCfg: NtfyConfig, userCfg: NtfyConfig | null): string | null {
|
||||
const topic = userCfg?.topic || adminCfg.topic;
|
||||
if (!topic) return null;
|
||||
const base = (userCfg?.server || adminCfg.server || 'https://ntfy.sh').replace(/\/+$/, '');
|
||||
return `${base}/${encodeURIComponent(topic)}`;
|
||||
}
|
||||
|
||||
export function isNtfyConfiguredForUser(userId: number): boolean {
|
||||
const cfg = getUserNtfyConfig(userId);
|
||||
return !!(cfg?.topic);
|
||||
}
|
||||
|
||||
export function isNtfyConfiguredAdmin(): boolean {
|
||||
return !!(getAppSetting('admin_ntfy_topic'));
|
||||
}
|
||||
|
||||
export async function sendNtfy(
|
||||
url: string,
|
||||
token: string | null,
|
||||
payload: { event: string; title: string; body: string; link?: string },
|
||||
): Promise<boolean> {
|
||||
if (!url) return false;
|
||||
|
||||
const ssrf = await checkSsrf(url);
|
||||
if (!ssrf.allowed) {
|
||||
logError(`Ntfy blocked by SSRF guard event=${payload.event} url=${url} reason=${ssrf.error}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
const meta = NTFY_EVENT_META[payload.event as NotifEventType] ?? NTFY_DEFAULT_META;
|
||||
|
||||
// ntfy header-based API: POST to topic URL, body = plain text message, metadata in headers
|
||||
const headers: Record<string, string> = {
|
||||
'Title': payload.title,
|
||||
'Priority': String(meta.priority),
|
||||
};
|
||||
if (meta.tags.length > 0) headers['Tags'] = meta.tags.join(',');
|
||||
if (payload.link) headers['Click'] = payload.link;
|
||||
if (token) headers['Authorization'] = `Bearer ${token}`;
|
||||
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: payload.body,
|
||||
signal: AbortSignal.timeout(10000),
|
||||
dispatcher: createPinnedDispatcher(ssrf.resolvedIp!),
|
||||
} as any);
|
||||
|
||||
if (!res.ok) {
|
||||
const errBody = await res.text().catch(() => '');
|
||||
logError(`Ntfy HTTP ${res.status}: ${errBody}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
logInfo(`Ntfy sent event=${payload.event}`);
|
||||
logDebug(`Ntfy url=${url} priority=${meta.priority} tags=${meta.tags.join(',')}`);
|
||||
return true;
|
||||
} catch (err) {
|
||||
logError(`Ntfy failed event=${payload.event}: ${err instanceof Error ? err.message : err}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function testNtfy(cfg: { topic: string; server?: string | null; token?: string | null }): Promise<{ success: boolean; error?: string }> {
|
||||
const adminCfg = getAdminNtfyConfig();
|
||||
const url = resolveNtfyUrl(adminCfg, { topic: cfg.topic, server: cfg.server ?? null, token: cfg.token ?? null });
|
||||
if (!url) return { success: false, error: 'Could not resolve ntfy URL — missing topic' };
|
||||
try {
|
||||
const sent = await sendNtfy(url, cfg.token ?? null, {
|
||||
event: 'test',
|
||||
title: 'Test Notification',
|
||||
body: 'This is a test notification from TREK. If you received this, your ntfy configuration is working correctly.',
|
||||
});
|
||||
return sent ? { success: true } : { success: false, error: 'Failed to send ntfy notification' };
|
||||
} catch (err) {
|
||||
return { success: false, error: err instanceof Error ? err.message : 'Unknown error' };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { db } from '../db/database';
|
||||
import { maybe_encrypt_api_key } from './apiKeyCrypto';
|
||||
|
||||
const ENCRYPTED_SETTING_KEYS = new Set(['webhook_url']);
|
||||
const ENCRYPTED_SETTING_KEYS = new Set(['webhook_url', 'ntfy_token']);
|
||||
|
||||
export function getUserSettings(userId: number): Record<string, unknown> {
|
||||
const rows = db.prepare('SELECT key, value FROM settings WHERE user_id = ?').all(userId) as { key: string; value: string }[];
|
||||
|
||||
Reference in New Issue
Block a user