diff --git a/CHANGELOG.md b/CHANGELOG.md index 8fd95d5..4fc5616 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Test API key button to verify connection before saving - Full support for AI extraction, verification, stock status checking, and price arbitration - Get API key from [Google AI Studio](https://aistudio.google.com/apikey) +- **Self-Hosted ntfy Support** - Use your own ntfy server instead of ntfy.sh + - Server URL field (defaults to ntfy.sh if left blank) + - Optional username/password authentication for protected servers + - Auth fields only shown when a custom server URL is entered --- @@ -215,7 +219,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 | Version | Date | Description | |---------|------|-------------| -| 1.0.6 | 2026-01-26 | Google Gemini AI support as new provider option | +| 1.0.6 | 2026-01-26 | Google Gemini AI support, self-hosted ntfy support | | 1.0.5 | 2026-01-25 | AI model selector, per-product AI controls, Gotify support, Ollama fixes | | 1.0.4 | 2026-01-24 | Multi-strategy price voting system with user selection for ambiguous prices | | 1.0.3 | 2026-01-24 | Notification history with bell icon, clear button, and full history page | diff --git a/backend/src/index.ts b/backend/src/index.ts index 9dcdc21..979556c 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -116,6 +116,15 @@ async function runMigrations() { IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'users' AND column_name = 'ntfy_topic') THEN ALTER TABLE users ADD COLUMN ntfy_topic TEXT; END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'users' AND column_name = 'ntfy_server_url') THEN + ALTER TABLE users ADD COLUMN ntfy_server_url TEXT; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'users' AND column_name = 'ntfy_username') THEN + ALTER TABLE users ADD COLUMN ntfy_username TEXT; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'users' AND column_name = 'ntfy_password') THEN + ALTER TABLE users ADD COLUMN ntfy_password TEXT; + END IF; IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'users' AND column_name = 'ntfy_enabled') THEN ALTER TABLE users ADD COLUMN ntfy_enabled BOOLEAN DEFAULT true; END IF; diff --git a/backend/src/models/index.ts b/backend/src/models/index.ts index 568654e..ad6781d 100644 --- a/backend/src/models/index.ts +++ b/backend/src/models/index.ts @@ -31,6 +31,9 @@ export interface NotificationSettings { pushover_app_token: string | null; pushover_enabled: boolean; ntfy_topic: string | null; + ntfy_server_url: string | null; + ntfy_username: string | null; + ntfy_password: string | null; ntfy_enabled: boolean; gotify_url: string | null; gotify_app_token: string | null; @@ -81,7 +84,7 @@ export const userQueries = { `SELECT telegram_bot_token, telegram_chat_id, COALESCE(telegram_enabled, true) as telegram_enabled, discord_webhook_url, COALESCE(discord_enabled, true) as discord_enabled, pushover_user_key, pushover_app_token, COALESCE(pushover_enabled, true) as pushover_enabled, - ntfy_topic, COALESCE(ntfy_enabled, true) as ntfy_enabled, + ntfy_topic, ntfy_server_url, ntfy_username, ntfy_password, COALESCE(ntfy_enabled, true) as ntfy_enabled, gotify_url, gotify_app_token, COALESCE(gotify_enabled, true) as gotify_enabled FROM users WHERE id = $1`, [id] @@ -133,6 +136,18 @@ export const userQueries = { fields.push(`ntfy_topic = $${paramIndex++}`); values.push(settings.ntfy_topic); } + if (settings.ntfy_server_url !== undefined) { + fields.push(`ntfy_server_url = $${paramIndex++}`); + values.push(settings.ntfy_server_url); + } + if (settings.ntfy_username !== undefined) { + fields.push(`ntfy_username = $${paramIndex++}`); + values.push(settings.ntfy_username); + } + if (settings.ntfy_password !== undefined) { + fields.push(`ntfy_password = $${paramIndex++}`); + values.push(settings.ntfy_password); + } if (settings.ntfy_enabled !== undefined) { fields.push(`ntfy_enabled = $${paramIndex++}`); values.push(settings.ntfy_enabled); @@ -158,7 +173,7 @@ export const userQueries = { RETURNING telegram_bot_token, telegram_chat_id, COALESCE(telegram_enabled, true) as telegram_enabled, discord_webhook_url, COALESCE(discord_enabled, true) as discord_enabled, pushover_user_key, pushover_app_token, COALESCE(pushover_enabled, true) as pushover_enabled, - ntfy_topic, COALESCE(ntfy_enabled, true) as ntfy_enabled, + ntfy_topic, ntfy_server_url, ntfy_username, ntfy_password, COALESCE(ntfy_enabled, true) as ntfy_enabled, gotify_url, gotify_app_token, COALESCE(gotify_enabled, true) as gotify_enabled`, values ); diff --git a/backend/src/routes/settings.ts b/backend/src/routes/settings.ts index 5584555..e2f6348 100644 --- a/backend/src/routes/settings.ts +++ b/backend/src/routes/settings.ts @@ -28,6 +28,9 @@ router.get('/notifications', async (req: AuthRequest, res: Response) => { pushover_app_token: settings.pushover_app_token || null, pushover_enabled: settings.pushover_enabled ?? true, ntfy_topic: settings.ntfy_topic || null, + ntfy_server_url: settings.ntfy_server_url || null, + ntfy_username: settings.ntfy_username || null, + ntfy_password: settings.ntfy_password || null, ntfy_enabled: settings.ntfy_enabled ?? true, gotify_url: settings.gotify_url || null, gotify_app_token: settings.gotify_app_token || null, @@ -53,6 +56,9 @@ router.put('/notifications', async (req: AuthRequest, res: Response) => { pushover_app_token, pushover_enabled, ntfy_topic, + ntfy_server_url, + ntfy_username, + ntfy_password, ntfy_enabled, gotify_url, gotify_app_token, @@ -69,6 +75,9 @@ router.put('/notifications', async (req: AuthRequest, res: Response) => { pushover_app_token, pushover_enabled, ntfy_topic, + ntfy_server_url, + ntfy_username, + ntfy_password, ntfy_enabled, gotify_url, gotify_app_token, @@ -90,6 +99,9 @@ router.put('/notifications', async (req: AuthRequest, res: Response) => { pushover_app_token: settings.pushover_app_token || null, pushover_enabled: settings.pushover_enabled ?? true, ntfy_topic: settings.ntfy_topic || null, + ntfy_server_url: settings.ntfy_server_url || null, + ntfy_username: settings.ntfy_username || null, + ntfy_password: settings.ntfy_password || null, ntfy_enabled: settings.ntfy_enabled ?? true, gotify_url: settings.gotify_url || null, gotify_app_token: settings.gotify_app_token || null, @@ -218,14 +230,20 @@ router.post('/notifications/test/ntfy', async (req: AuthRequest, res: Response) } const { sendNtfyNotification } = await import('../services/notifications'); - const success = await sendNtfyNotification(settings.ntfy_topic, { - productName: 'Test Product', - productUrl: 'https://example.com', - type: 'price_drop', - oldPrice: 29.99, - newPrice: 19.99, - currency: 'USD', - }); + const success = await sendNtfyNotification( + settings.ntfy_topic, + { + productName: 'Test Product', + productUrl: 'https://example.com', + type: 'price_drop', + oldPrice: 29.99, + newPrice: 19.99, + currency: 'USD', + }, + settings.ntfy_server_url, + settings.ntfy_username, + settings.ntfy_password + ); if (success) { res.json({ message: 'Test notification sent successfully' }); diff --git a/backend/src/services/notifications.ts b/backend/src/services/notifications.ts index 1deaf04..9028b8e 100644 --- a/backend/src/services/notifications.ts +++ b/backend/src/services/notifications.ts @@ -197,7 +197,10 @@ export async function sendPushoverNotification( export async function sendNtfyNotification( topic: string, - payload: NotificationPayload + payload: NotificationPayload, + serverUrl?: string | null, + username?: string | null, + password?: string | null ): Promise { try { const currencySymbol = getCurrencySymbol(payload.currency); @@ -225,15 +228,26 @@ export async function sendNtfyNotification( tags = ['package', 'tada']; } - await axios.post(`https://ntfy.sh/${topic}`, message, { - headers: { - 'Title': title, - 'Tags': tags.join(','), - 'Click': payload.productUrl, - }, - }); + // Use custom server URL or default to ntfy.sh + const baseUrl = serverUrl ? serverUrl.replace(/\/$/, '') : 'https://ntfy.sh'; + const url = `${baseUrl}/${topic}`; - console.log(`ntfy notification sent to topic ${topic}`); + // Build headers + const headers: Record = { + 'Title': title, + 'Tags': tags.join(','), + 'Click': payload.productUrl, + }; + + // Add basic auth if credentials provided + if (username && password) { + const auth = Buffer.from(`${username}:${password}`).toString('base64'); + headers['Authorization'] = `Basic ${auth}`; + } + + await axios.post(url, message, { headers }); + + console.log(`ntfy notification sent to topic ${topic} on ${baseUrl}`); return true; } catch (error) { console.error('Failed to send ntfy notification:', error); @@ -334,6 +348,9 @@ export async function sendNotifications( pushover_app_token: string | null; pushover_enabled?: boolean; ntfy_topic: string | null; + ntfy_server_url?: string | null; + ntfy_username?: string | null; + ntfy_password?: string | null; ntfy_enabled?: boolean; gotify_url: string | null; gotify_app_token: string | null; @@ -368,7 +385,13 @@ export async function sendNotifications( if (settings.ntfy_topic && settings.ntfy_enabled !== false) { channelPromises.push({ channel: 'ntfy', - promise: sendNtfyNotification(settings.ntfy_topic, payload), + promise: sendNtfyNotification( + settings.ntfy_topic, + payload, + settings.ntfy_server_url, + settings.ntfy_username, + settings.ntfy_password + ), }); } diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 924d325..adb5d07 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -194,6 +194,9 @@ export interface NotificationSettings { pushover_app_token: string | null; pushover_enabled: boolean; ntfy_topic: string | null; + ntfy_server_url: string | null; + ntfy_username: string | null; + ntfy_password: string | null; ntfy_enabled: boolean; gotify_url: string | null; gotify_app_token: string | null; @@ -214,6 +217,9 @@ export const settingsApi = { pushover_app_token?: string | null; pushover_enabled?: boolean; ntfy_topic?: string | null; + ntfy_server_url?: string | null; + ntfy_username?: string | null; + ntfy_password?: string | null; ntfy_enabled?: boolean; gotify_url?: string | null; gotify_app_token?: string | null; diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx index 56f70cc..2dea901 100644 --- a/frontend/src/pages/Settings.tsx +++ b/frontend/src/pages/Settings.tsx @@ -46,6 +46,9 @@ export default function Settings() { const [pushoverAppToken, setPushoverAppToken] = useState(''); const [pushoverEnabled, setPushoverEnabled] = useState(true); const [ntfyTopic, setNtfyTopic] = useState(''); + const [ntfyServerUrl, setNtfyServerUrl] = useState(''); + const [ntfyUsername, setNtfyUsername] = useState(''); + const [ntfyPassword, setNtfyPassword] = useState(''); const [ntfyEnabled, setNtfyEnabled] = useState(true); const [gotifyUrl, setGotifyUrl] = useState(''); const [gotifyAppToken, setGotifyAppToken] = useState(''); @@ -114,6 +117,9 @@ export default function Settings() { setPushoverAppToken(notificationsRes.data.pushover_app_token || ''); setPushoverEnabled(notificationsRes.data.pushover_enabled ?? true); setNtfyTopic(notificationsRes.data.ntfy_topic || ''); + setNtfyServerUrl(notificationsRes.data.ntfy_server_url || ''); + setNtfyUsername(notificationsRes.data.ntfy_username || ''); + setNtfyPassword(notificationsRes.data.ntfy_password || ''); setNtfyEnabled(notificationsRes.data.ntfy_enabled ?? true); setGotifyUrl(notificationsRes.data.gotify_url || ''); setGotifyAppToken(notificationsRes.data.gotify_app_token || ''); @@ -341,8 +347,13 @@ export default function Settings() { try { const response = await settingsApi.updateNotifications({ ntfy_topic: ntfyTopic || null, + ntfy_server_url: ntfyServerUrl || null, + ntfy_username: ntfyUsername || null, + ntfy_password: ntfyPassword || null, }); setNotificationSettings(response.data); + // Clear password field after save for security + setNtfyPassword(''); setSuccess('ntfy settings saved successfully'); } catch { setError('Failed to save ntfy settings'); @@ -1385,6 +1396,19 @@ export default function Settings() { )} +
+ + setNtfyServerUrl(e.target.value)} + placeholder="https://ntfy.sh" + /> +

+ Leave blank to use ntfy.sh, or enter your self-hosted server URL +

+
+
+ {ntfyServerUrl && ( + <> +
+ + setNtfyUsername(e.target.value)} + placeholder="username" + /> +
+ +
+ + setNtfyPassword(e.target.value)} + placeholder={notificationSettings?.ntfy_password ? '••••••••' : 'password'} + /> +

+ Only required if your self-hosted ntfy server has authentication enabled +

+
+ + )} +