mirror of
https://github.com/clucraft/PriceGhost.git
synced 2026-04-25 00:36:32 +02:00
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:
parent
109ce08d29
commit
2549118555
7 changed files with 146 additions and 21 deletions
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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' });
|
||||
|
|
|
|||
|
|
@ -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
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue