Skip to content

Commit 2549118

Browse files
clucraftclaude
andcommitted
Add self-hosted ntfy support with authentication
- Add server URL field (defaults to ntfy.sh if blank) - Add optional username/password for protected servers - Auth fields only shown when custom server URL is entered - Database migration for ntfy_server_url, ntfy_username, ntfy_password - Update CHANGELOG with self-hosted ntfy feature Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 109ce08 commit 2549118

7 files changed

Lines changed: 146 additions & 21 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1414
- Test API key button to verify connection before saving
1515
- Full support for AI extraction, verification, stock status checking, and price arbitration
1616
- Get API key from [Google AI Studio](https://aistudio.google.com/apikey)
17+
- **Self-Hosted ntfy Support** - Use your own ntfy server instead of ntfy.sh
18+
- Server URL field (defaults to ntfy.sh if left blank)
19+
- Optional username/password authentication for protected servers
20+
- Auth fields only shown when a custom server URL is entered
1721

1822
---
1923

@@ -215,7 +219,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
215219

216220
| Version | Date | Description |
217221
|---------|------|-------------|
218-
| 1.0.6 | 2026-01-26 | Google Gemini AI support as new provider option |
222+
| 1.0.6 | 2026-01-26 | Google Gemini AI support, self-hosted ntfy support |
219223
| 1.0.5 | 2026-01-25 | AI model selector, per-product AI controls, Gotify support, Ollama fixes |
220224
| 1.0.4 | 2026-01-24 | Multi-strategy price voting system with user selection for ambiguous prices |
221225
| 1.0.3 | 2026-01-24 | Notification history with bell icon, clear button, and full history page |

‎backend/src/index.ts‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,15 @@ async function runMigrations() {
116116
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'users' AND column_name = 'ntfy_topic') THEN
117117
ALTER TABLE users ADD COLUMN ntfy_topic TEXT;
118118
END IF;
119+
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'users' AND column_name = 'ntfy_server_url') THEN
120+
ALTER TABLE users ADD COLUMN ntfy_server_url TEXT;
121+
END IF;
122+
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'users' AND column_name = 'ntfy_username') THEN
123+
ALTER TABLE users ADD COLUMN ntfy_username TEXT;
124+
END IF;
125+
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'users' AND column_name = 'ntfy_password') THEN
126+
ALTER TABLE users ADD COLUMN ntfy_password TEXT;
127+
END IF;
119128
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'users' AND column_name = 'ntfy_enabled') THEN
120129
ALTER TABLE users ADD COLUMN ntfy_enabled BOOLEAN DEFAULT true;
121130
END IF;

‎backend/src/models/index.ts‎

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,9 @@ export interface NotificationSettings {
3131
pushover_app_token: string | null;
3232
pushover_enabled: boolean;
3333
ntfy_topic: string | null;
34+
ntfy_server_url: string | null;
35+
ntfy_username: string | null;
36+
ntfy_password: string | null;
3437
ntfy_enabled: boolean;
3538
gotify_url: string | null;
3639
gotify_app_token: string | null;
@@ -81,7 +84,7 @@ export const userQueries = {
8184
`SELECT telegram_bot_token, telegram_chat_id, COALESCE(telegram_enabled, true) as telegram_enabled,
8285
discord_webhook_url, COALESCE(discord_enabled, true) as discord_enabled,
8386
pushover_user_key, pushover_app_token, COALESCE(pushover_enabled, true) as pushover_enabled,
84-
ntfy_topic, COALESCE(ntfy_enabled, true) as ntfy_enabled,
87+
ntfy_topic, ntfy_server_url, ntfy_username, ntfy_password, COALESCE(ntfy_enabled, true) as ntfy_enabled,
8588
gotify_url, gotify_app_token, COALESCE(gotify_enabled, true) as gotify_enabled
8689
FROM users WHERE id = $1`,
8790
[id]
@@ -133,6 +136,18 @@ export const userQueries = {
133136
fields.push(`ntfy_topic = $${paramIndex++}`);
134137
values.push(settings.ntfy_topic);
135138
}
139+
if (settings.ntfy_server_url !== undefined) {
140+
fields.push(`ntfy_server_url = $${paramIndex++}`);
141+
values.push(settings.ntfy_server_url);
142+
}
143+
if (settings.ntfy_username !== undefined) {
144+
fields.push(`ntfy_username = $${paramIndex++}`);
145+
values.push(settings.ntfy_username);
146+
}
147+
if (settings.ntfy_password !== undefined) {
148+
fields.push(`ntfy_password = $${paramIndex++}`);
149+
values.push(settings.ntfy_password);
150+
}
136151
if (settings.ntfy_enabled !== undefined) {
137152
fields.push(`ntfy_enabled = $${paramIndex++}`);
138153
values.push(settings.ntfy_enabled);
@@ -158,7 +173,7 @@ export const userQueries = {
158173
RETURNING telegram_bot_token, telegram_chat_id, COALESCE(telegram_enabled, true) as telegram_enabled,
159174
discord_webhook_url, COALESCE(discord_enabled, true) as discord_enabled,
160175
pushover_user_key, pushover_app_token, COALESCE(pushover_enabled, true) as pushover_enabled,
161-
ntfy_topic, COALESCE(ntfy_enabled, true) as ntfy_enabled,
176+
ntfy_topic, ntfy_server_url, ntfy_username, ntfy_password, COALESCE(ntfy_enabled, true) as ntfy_enabled,
162177
gotify_url, gotify_app_token, COALESCE(gotify_enabled, true) as gotify_enabled`,
163178
values
164179
);

‎backend/src/routes/settings.ts‎

Lines changed: 26 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,9 @@ router.get('/notifications', async (req: AuthRequest, res: Response) => {
2828
pushover_app_token: settings.pushover_app_token || null,
2929
pushover_enabled: settings.pushover_enabled ?? true,
3030
ntfy_topic: settings.ntfy_topic || null,
31+
ntfy_server_url: settings.ntfy_server_url || null,
32+
ntfy_username: settings.ntfy_username || null,
33+
ntfy_password: settings.ntfy_password || null,
3134
ntfy_enabled: settings.ntfy_enabled ?? true,
3235
gotify_url: settings.gotify_url || null,
3336
gotify_app_token: settings.gotify_app_token || null,
@@ -53,6 +56,9 @@ router.put('/notifications', async (req: AuthRequest, res: Response) => {
5356
pushover_app_token,
5457
pushover_enabled,
5558
ntfy_topic,
59+
ntfy_server_url,
60+
ntfy_username,
61+
ntfy_password,
5662
ntfy_enabled,
5763
gotify_url,
5864
gotify_app_token,
@@ -69,6 +75,9 @@ router.put('/notifications', async (req: AuthRequest, res: Response) => {
6975
pushover_app_token,
7076
pushover_enabled,
7177
ntfy_topic,
78+
ntfy_server_url,
79+
ntfy_username,
80+
ntfy_password,
7281
ntfy_enabled,
7382
gotify_url,
7483
gotify_app_token,
@@ -90,6 +99,9 @@ router.put('/notifications', async (req: AuthRequest, res: Response) => {
9099
pushover_app_token: settings.pushover_app_token || null,
91100
pushover_enabled: settings.pushover_enabled ?? true,
92101
ntfy_topic: settings.ntfy_topic || null,
102+
ntfy_server_url: settings.ntfy_server_url || null,
103+
ntfy_username: settings.ntfy_username || null,
104+
ntfy_password: settings.ntfy_password || null,
93105
ntfy_enabled: settings.ntfy_enabled ?? true,
94106
gotify_url: settings.gotify_url || null,
95107
gotify_app_token: settings.gotify_app_token || null,
@@ -218,14 +230,20 @@ router.post('/notifications/test/ntfy', async (req: AuthRequest, res: Response)
218230
}
219231

220232
const { sendNtfyNotification } = await import('../services/notifications');
221-
const success = await sendNtfyNotification(settings.ntfy_topic, {
222-
productName: 'Test Product',
223-
productUrl: 'https://example.com',
224-
type: 'price_drop',
225-
oldPrice: 29.99,
226-
newPrice: 19.99,
227-
currency: 'USD',
228-
});
233+
const success = await sendNtfyNotification(
234+
settings.ntfy_topic,
235+
{
236+
productName: 'Test Product',
237+
productUrl: 'https://example.com',
238+
type: 'price_drop',
239+
oldPrice: 29.99,
240+
newPrice: 19.99,
241+
currency: 'USD',
242+
},
243+
settings.ntfy_server_url,
244+
settings.ntfy_username,
245+
settings.ntfy_password
246+
);
229247

230248
if (success) {
231249
res.json({ message: 'Test notification sent successfully' });

‎backend/src/services/notifications.ts‎

Lines changed: 33 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -197,7 +197,10 @@ export async function sendPushoverNotification(
197197

198198
export async function sendNtfyNotification(
199199
topic: string,
200-
payload: NotificationPayload
200+
payload: NotificationPayload,
201+
serverUrl?: string | null,
202+
username?: string | null,
203+
password?: string | null
201204
): Promise<boolean> {
202205
try {
203206
const currencySymbol = getCurrencySymbol(payload.currency);
@@ -225,15 +228,26 @@ export async function sendNtfyNotification(
225228
tags = ['package', 'tada'];
226229
}
227230

228-
await axios.post(`https://ntfy.sh/${topic}`, message, {
229-
headers: {
230-
'Title': title,
231-
'Tags': tags.join(','),
232-
'Click': payload.productUrl,
233-
},
234-
});
231+
// Use custom server URL or default to ntfy.sh
232+
const baseUrl = serverUrl ? serverUrl.replace(/\/$/, '') : 'https://ntfy.sh';
233+
const url = `${baseUrl}/${topic}`;
234+
235+
// Build headers
236+
const headers: Record<string, string> = {
237+
'Title': title,
238+
'Tags': tags.join(','),
239+
'Click': payload.productUrl,
240+
};
241+
242+
// Add basic auth if credentials provided
243+
if (username && password) {
244+
const auth = Buffer.from(`${username}:${password}`).toString('base64');
245+
headers['Authorization'] = `Basic ${auth}`;
246+
}
247+
248+
await axios.post(url, message, { headers });
235249

236-
console.log(`ntfy notification sent to topic ${topic}`);
250+
console.log(`ntfy notification sent to topic ${topic} on ${baseUrl}`);
237251
return true;
238252
} catch (error) {
239253
console.error('Failed to send ntfy notification:', error);
@@ -334,6 +348,9 @@ export async function sendNotifications(
334348
pushover_app_token: string | null;
335349
pushover_enabled?: boolean;
336350
ntfy_topic: string | null;
351+
ntfy_server_url?: string | null;
352+
ntfy_username?: string | null;
353+
ntfy_password?: string | null;
337354
ntfy_enabled?: boolean;
338355
gotify_url: string | null;
339356
gotify_app_token: string | null;
@@ -368,7 +385,13 @@ export async function sendNotifications(
368385
if (settings.ntfy_topic && settings.ntfy_enabled !== false) {
369386
channelPromises.push({
370387
channel: 'ntfy',
371-
promise: sendNtfyNotification(settings.ntfy_topic, payload),
388+
promise: sendNtfyNotification(
389+
settings.ntfy_topic,
390+
payload,
391+
settings.ntfy_server_url,
392+
settings.ntfy_username,
393+
settings.ntfy_password
394+
),
372395
});
373396
}
374397

‎frontend/src/api/client.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,9 @@ export interface NotificationSettings {
194194
pushover_app_token: string | null;
195195
pushover_enabled: boolean;
196196
ntfy_topic: string | null;
197+
ntfy_server_url: string | null;
198+
ntfy_username: string | null;
199+
ntfy_password: string | null;
197200
ntfy_enabled: boolean;
198201
gotify_url: string | null;
199202
gotify_app_token: string | null;
@@ -214,6 +217,9 @@ export const settingsApi = {
214217
pushover_app_token?: string | null;
215218
pushover_enabled?: boolean;
216219
ntfy_topic?: string | null;
220+
ntfy_server_url?: string | null;
221+
ntfy_username?: string | null;
222+
ntfy_password?: string | null;
217223
ntfy_enabled?: boolean;
218224
gotify_url?: string | null;
219225
gotify_app_token?: string | null;

‎frontend/src/pages/Settings.tsx‎

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,9 @@ export default function Settings() {
4646
const [pushoverAppToken, setPushoverAppToken] = useState('');
4747
const [pushoverEnabled, setPushoverEnabled] = useState(true);
4848
const [ntfyTopic, setNtfyTopic] = useState('');
49+
const [ntfyServerUrl, setNtfyServerUrl] = useState('');
50+
const [ntfyUsername, setNtfyUsername] = useState('');
51+
const [ntfyPassword, setNtfyPassword] = useState('');
4952
const [ntfyEnabled, setNtfyEnabled] = useState(true);
5053
const [gotifyUrl, setGotifyUrl] = useState('');
5154
const [gotifyAppToken, setGotifyAppToken] = useState('');
@@ -114,6 +117,9 @@ export default function Settings() {
114117
setPushoverAppToken(notificationsRes.data.pushover_app_token || '');
115118
setPushoverEnabled(notificationsRes.data.pushover_enabled ?? true);
116119
setNtfyTopic(notificationsRes.data.ntfy_topic || '');
120+
setNtfyServerUrl(notificationsRes.data.ntfy_server_url || '');
121+
setNtfyUsername(notificationsRes.data.ntfy_username || '');
122+
setNtfyPassword(notificationsRes.data.ntfy_password || '');
117123
setNtfyEnabled(notificationsRes.data.ntfy_enabled ?? true);
118124
setGotifyUrl(notificationsRes.data.gotify_url || '');
119125
setGotifyAppToken(notificationsRes.data.gotify_app_token || '');
@@ -341,8 +347,13 @@ export default function Settings() {
341347
try {
342348
const response = await settingsApi.updateNotifications({
343349
ntfy_topic: ntfyTopic || null,
350+
ntfy_server_url: ntfyServerUrl || null,
351+
ntfy_username: ntfyUsername || null,
352+
ntfy_password: ntfyPassword || null,
344353
});
345354
setNotificationSettings(response.data);
355+
// Clear password field after save for security
356+
setNtfyPassword('');
346357
setSuccess('ntfy settings saved successfully');
347358
} catch {
348359
setError('Failed to save ntfy settings');
@@ -1385,6 +1396,19 @@ export default function Settings() {
13851396
</div>
13861397
)}
13871398

1399+
<div className="settings-form-group">
1400+
<label>Server URL (optional)</label>
1401+
<input
1402+
type="text"
1403+
value={ntfyServerUrl}
1404+
onChange={(e) => setNtfyServerUrl(e.target.value)}
1405+
placeholder="https://ntfy.sh"
1406+
/>
1407+
<p className="hint">
1408+
Leave blank to use ntfy.sh, or enter your self-hosted server URL
1409+
</p>
1410+
</div>
1411+
13881412
<div className="settings-form-group">
13891413
<label>Topic Name</label>
13901414
<input
@@ -1399,6 +1423,32 @@ export default function Settings() {
13991423
</p>
14001424
</div>
14011425

1426+
{ntfyServerUrl && (
1427+
<>
1428+
<div className="settings-form-group">
1429+
<label>Username (optional)</label>
1430+
<input
1431+
type="text"
1432+
value={ntfyUsername}
1433+
onChange={(e) => setNtfyUsername(e.target.value)}
1434+
placeholder="username"
1435+
/>
1436+
</div>
1437+
1438+
<div className="settings-form-group">
1439+
<label>Password (optional)</label>
1440+
<PasswordInput
1441+
value={ntfyPassword}
1442+
onChange={(e) => setNtfyPassword(e.target.value)}
1443+
placeholder={notificationSettings?.ntfy_password ? '••••••••' : 'password'}
1444+
/>
1445+
<p className="hint">
1446+
Only required if your self-hosted ntfy server has authentication enabled
1447+
</p>
1448+
</div>
1449+
</>
1450+
)}
1451+
14021452
<div className="settings-form-actions">
14031453
<button
14041454
className="btn btn-primary"

0 commit comments

Comments
 (0)