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

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