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>
This commit is contained in:
clucraft 2026-01-26 21:27:09 -05:00
parent 109ce08d29
commit 2549118555
7 changed files with 146 additions and 21 deletions

View file

@ -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 |

View file

@ -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;

View file

@ -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
);

View file

@ -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' });

View file

@ -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<boolean> {
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<string, string> = {
'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
),
});
}

View file

@ -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;

View file

@ -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() {
</div>
)}
<div className="settings-form-group">
<label>Server URL (optional)</label>
<input
type="text"
value={ntfyServerUrl}
onChange={(e) => setNtfyServerUrl(e.target.value)}
placeholder="https://ntfy.sh"
/>
<p className="hint">
Leave blank to use ntfy.sh, or enter your self-hosted server URL
</p>
</div>
<div className="settings-form-group">
<label>Topic Name</label>
<input
@ -1399,6 +1423,32 @@ export default function Settings() {
</p>
</div>
{ntfyServerUrl && (
<>
<div className="settings-form-group">
<label>Username (optional)</label>
<input
type="text"
value={ntfyUsername}
onChange={(e) => setNtfyUsername(e.target.value)}
placeholder="username"
/>
</div>
<div className="settings-form-group">
<label>Password (optional)</label>
<PasswordInput
value={ntfyPassword}
onChange={(e) => setNtfyPassword(e.target.value)}
placeholder={notificationSettings?.ntfy_password ? '••••••••' : 'password'}
/>
<p className="hint">
Only required if your self-hosted ntfy server has authentication enabled
</p>
</div>
</>
)}
<div className="settings-form-actions">
<button
className="btn btn-primary"