diff --git a/README.md b/README.md index d12ff91..28b1de8 100644 --- a/README.md +++ b/README.md @@ -173,15 +173,11 @@ prism_api_key: "Bearer YOUR_API_KEY_HERE" Reboot your Home Assistant system and you'll then be able to send Signal notifications to yourself by using this notify prism action. -## API Reference +## Sending Notifications -### Send Notification +Send notifications via HTTP POST to `/{appName}`: -#### POST /{appName} - -Send a notification to a specific app. Messages are routed based on your app configuration in the web UI. - -JSON format: +**JSON format:** ```bash curl -X POST http://localhost:8080/my-app \ @@ -190,7 +186,7 @@ curl -X POST http://localhost:8080/my-app \ -d '{"title": "Alert", "message": "Something happened"}' ``` -Plain text (ntfy-compatible): +**Plain text (ntfy-compatible):** ```bash curl -X POST http://localhost:8080/my-app \ @@ -198,52 +194,7 @@ curl -X POST http://localhost:8080/my-app \ -d "Simple message text" ``` -**App Routing:** -- Configure which integration(s) receive messages from each app via the web UI -- Apps can route to Signal, Telegram, WebPush, or multiple destinations -- Special apps like "Proton Mail" are created automatically - -### WebPush/Webhook Management - -#### POST /api/v1/webpush/app - -Register or update a WebPush subscription or plain webhook. - -Encrypted WebPush (all crypto fields required): - -```bash -curl -X POST http://localhost:8080/api/v1/webpush/app \ - -H "Authorization: Bearer YOUR_API_KEY" \ - -H "Content-Type: application/json" \ - -d '{ - "appName": "my-app", - "pushEndpoint": "https://updates.push.services.mozilla.org/...", - "p256dh": "base64-encoded-key", - "auth": "base64-encoded-auth", - "vapidPrivateKey": "base64-encoded-vapid-key" - }' -``` - -Plain HTTP webhook (no encryption): - -```bash -curl -X POST http://localhost:8080/api/v1/webpush/app \ - -H "Authorization: Bearer YOUR_API_KEY" \ - -H "Content-Type: application/json" \ - -d '{ - "appName": "my-app", - "pushEndpoint": "https://your-server.com/webhook" - }' -``` - -#### DELETE /api/v1/webpush/app/{appName} - -Unregister a WebPush subscription (clears WebPush settings, reverts to Signal). - -```bash -curl -X DELETE http://localhost:8080/api/v1/webpush/app/my-app \ - -H "Authorization: Bearer YOUR_API_KEY" -``` +Messages are routed based on your app's subscriptions configured in the web UI. Apps can have multiple subscriptions (Signal + WebPush + Telegram) and will receive notifications on all configured channels. ## Monitoring diff --git a/VERSION b/VERSION index 9325c3c..60a2d3e 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.3.0 \ No newline at end of file +0.4.0 \ No newline at end of file diff --git a/public/index.css b/public/index.css index 79abcd8..2daf747 100644 --- a/public/index.css +++ b/public/index.css @@ -148,7 +148,6 @@ details[open] > .card-header::before { } /* Tooltips */ -.channel-badge:has(.tooltip), .integration-status:has(.tooltip) { cursor: help; } @@ -172,7 +171,7 @@ details[open] > .card-header::before { transition: opacity 0.2s; pointer-events: none; z-index: 10; - box-shadow: 0 0.25rem 0.5rem rgba(0, 0, 0, 0.2); + box-shadow: 0 0.25rem 0.5rem rgba(0, 0, 0, 0.3); } .tooltip::after { @@ -232,18 +231,11 @@ details[open] > .card-header::before { flex: 1; display: flex; flex-direction: column; - gap: 0.25rem; + gap: 0.5rem; } .app-name { - font-size: 1.1em; -} - -.app-channel { - display: flex; - align-items: center; - gap: 0.5rem; - font-size: 0.9em; + font-size: 1.2em; } .channel-badge { @@ -252,26 +244,69 @@ details[open] > .card-header::before { font-size: 0.85em; font-weight: 500; position: relative; + border: none; + background: none; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.25rem; + min-width: 5rem; } -.channel-signal { +.badge-active { + cursor: pointer; + transition: filter 0.2s ease; +} + +.badge-active:hover { + filter: brightness(0.9); +} + +.badge-active.htmx-request { + opacity: 0.6; + cursor: wait; + pointer-events: none; +} + +.badge-inactive { + cursor: pointer; + border: 0.0625rem dashed currentColor; + transition: filter 0.2s ease; +} + +.badge-inactive:hover { + filter: brightness(1.2); +} + +.badge-inactive.htmx-request { + opacity: 0.6; + cursor: wait; + pointer-events: none; +} + +.channel-signal.badge-active { background: var(--accent); color: var(--text-on-color); } -.channel-webpush { +.channel-signal.badge-inactive { + color: var(--accent); + background: transparent; +} + +.channel-webpush.badge-active { background: var(--success); color: var(--text-on-color); } -.channel-telegram { - background: #0088cc; +.channel-telegram.badge-active { + background: var(--accent); color: var(--text-on-color); } -.app-detail { - color: var(--text-secondary); - font-size: 0.85em; +.channel-telegram.badge-inactive { + color: var(--accent); + background: transparent; } .app-actions { @@ -280,20 +315,6 @@ details[open] > .card-header::before { align-items: center; } -.channel-form { - display: inline; -} - -.channel-select { - padding: 0.375rem 0.5rem; - background: var(--bg-secondary); - color: var(--text-primary); - border: 0.0625rem solid var(--border-color); - border-radius: 0.25rem; - cursor: pointer; - font-size: 0.9em; -} - .btn-delete { padding: 0.375rem 0.75rem; background: var(--error); @@ -309,6 +330,23 @@ details[open] > .card-header::before { filter: brightness(0.85); } +.btn-delete-sub { + background: none; + border: none; + color: inherit; + font-size: 1.1em; + font-weight: bold; + cursor: pointer; + padding: 0 0.25rem; + margin-left: 0.25rem; + opacity: 0.7; + transition: opacity 0.2s ease; +} + +.btn-delete-sub:hover { + opacity: 1; +} + /* Loading Spinner */ .loading { color: var(--text-secondary); @@ -401,8 +439,9 @@ details[open] > .integration-header::before { } .integration-status.unlinked { - background: var(--text-secondary); - color: var(--text-on-color); + background: var(--bg-secondary); + color: var(--text-primary); + border: 0.0625rem solid var(--border-color); } .link-instructions { @@ -477,8 +516,9 @@ details[open] > .integration-header::before { } .btn-secondary { - background: var(--text-secondary); - color: var(--text-on-color); + background: var(--bg-secondary); + color: var(--text-primary); + border: 0.0625rem solid var(--border-color); } .btn-danger { @@ -533,3 +573,73 @@ details[open] > .integration-header::before { height: auto; display: block; } + +/* Toast Notifications */ +#toast-container { + position: fixed; + bottom: 1.5rem; + right: 1.5rem; + z-index: 9999; + display: flex; + flex-direction: column; + gap: 0.5rem; + pointer-events: none; +} + +.toast { + background: var(--bg-secondary); + color: var(--text-primary); + padding: 0.875rem 1.25rem; + border-radius: 0.375rem; + box-shadow: + 0 0.25rem 0.5rem rgba(0, 0, 0, 0.1), + 0 0 0 0.0625rem var(--border-color); + min-width: 15rem; + max-width: 25rem; + pointer-events: auto; + animation: toastSlideIn 0.3s ease; + display: flex; + align-items: center; + gap: 0.75rem; +} + +.toast.success { + background: var(--success); + color: var(--text-on-color); +} + +.toast.error { + background: var(--error); + color: var(--text-on-color); +} + +.toast.info { + background: var(--accent); + color: var(--text-on-color); +} + +.toast.hiding { + animation: toastSlideOut 0.3s ease forwards; +} + +@keyframes toastSlideIn { + from { + transform: translateX(calc(100% + 1.5rem)); + opacity: 0; + } + to { + transform: translateX(0); + opacity: 1; + } +} + +@keyframes toastSlideOut { + from { + transform: translateX(0); + opacity: 1; + } + to { + transform: translateX(calc(100% + 1.5rem)); + opacity: 0; + } +} diff --git a/public/index.html b/public/index.html index 40c1e34..e59a004 100644 --- a/public/index.html +++ b/public/index.html @@ -10,12 +10,13 @@ +
- Registered Applications + Registered Apps
+ hx-trigger="load, reload">
- +
+ + diff --git a/public/integration.js b/public/integration.js index 9af2649..fc0283e 100644 --- a/public/integration.js +++ b/public/integration.js @@ -17,10 +17,17 @@ document.addEventListener('DOMContentLoaded', () => { if (action === 'link-signal') linkSignal(btn); else if (action === 'delete-telegram') deleteTelegram(btn); else if (action === 'delete-proton') deleteProton(btn); - else if (action === 'reload') location.reload(); + else if (action === 'reload') reloadIntegrations(); }); }); +function reloadIntegrations() { + const integrations = document.getElementById('integrations'); + if (integrations) { + htmx.trigger(integrations, 'reload'); + } +} + async function handleAuthForm(form, endpoint, statusId, getPayload) { const status = document.getElementById(statusId); const btn = form.querySelector('button[type="submit"]'); @@ -41,7 +48,8 @@ async function handleAuthForm(form, endpoint, statusId, getPayload) { }); if (response.ok) { - location.reload(); + checkToast(response); + reloadIntegrations(); } else { const error = await response.json(); showError(error.error || 'Failed to save'); @@ -51,10 +59,24 @@ async function handleAuthForm(form, endpoint, statusId, getPayload) { } } +function checkToast(response) { + const trigger = response.headers.get('HX-Trigger'); + if (trigger) { + try { + const data = JSON.parse(trigger); + if (data.showToast && window.showToast) { + showToast(data.showToast.message, data.showToast.type); + } + } catch (_e) { + // Ignore + } + } +} + async function submitTelegramAuth(e) { await handleAuthForm( e.target, - '/api/v1/telegram/auth', + '/api/v1/telegram/link', 'telegram-auth-status', (fd) => ({ bot_token: fd.get('bot_token'), @@ -66,7 +88,7 @@ async function submitTelegramAuth(e) { async function submitTelegramChatId(e) { await handleAuthForm( e.target, - '/api/v1/telegram/auth', + '/api/v1/telegram/link', 'telegram-chatid-status', (fd, form) => ({ bot_token: form.dataset.botToken, @@ -120,7 +142,11 @@ async function linkSignal(btn) { qrCode.src = qrUrl; qrContainer.style.display = 'block'; + let statusCheckInProgress = false; signalLinkingPoll = setInterval(async () => { + if (statusCheckInProgress) return; + + statusCheckInProgress = true; try { const statusResp = await fetch('/api/v1/signal/status'); const statusData = await statusResp.json(); @@ -129,10 +155,13 @@ async function linkSignal(btn) { clearInterval(signalLinkingPoll); qrContainer.innerHTML = '

Linked! Refreshing...

'; - setTimeout(() => location.reload(), 1000); + showToast('Signal linked', 'success'); + setTimeout(() => reloadIntegrations(), 1000); } } catch (err) { console.error('Status check failed:', err); + } finally { + statusCheckInProgress = false; } }, 2000); } catch (err) { @@ -146,10 +175,11 @@ async function deleteTelegram(btn) { btn.disabled = true; try { - const response = await fetch('/api/v1/telegram/auth', { method: 'DELETE' }); + const response = await fetch('/api/v1/telegram/link', { method: 'DELETE' }); if (response.ok) { - location.reload(); + checkToast(response); + reloadIntegrations(); } else { const error = await response.json(); alert(`Error: ${error.error || 'Failed to unlink integration'}`); @@ -170,7 +200,8 @@ async function deleteProton(btn) { const response = await fetch('/api/v1/proton/auth', { method: 'DELETE' }); if (response.ok) { - location.reload(); + checkToast(response); + reloadIntegrations(); } else { const error = await response.json(); alert(`Error: ${error.error || 'Failed to unlink integration'}`); diff --git a/public/toast.js b/public/toast.js new file mode 100644 index 0000000..ee0beea --- /dev/null +++ b/public/toast.js @@ -0,0 +1,33 @@ +function showToast(message, type = 'info') { + const container = document.getElementById('toast-container'); + if (!container) return; + + const toast = document.createElement('div'); + toast.className = `toast ${type}`; + toast.textContent = message; + + container.appendChild(toast); + + setTimeout(() => { + toast.classList.add('hiding'); + setTimeout(() => toast.remove(), 300); + }, 3000); +} + +document.addEventListener('DOMContentLoaded', () => { + document.body.addEventListener('htmx:afterRequest', (event) => { + const xhr = event.detail.xhr; + const triggerHeader = xhr.getResponseHeader('HX-Trigger'); + + if (triggerHeader) { + try { + const trigger = JSON.parse(triggerHeader); + if (trigger.showToast) { + showToast(trigger.showToast.message, trigger.showToast.type); + } + } catch (_e) { + // Not JSON, ignore + } + } + }); +}); diff --git a/service/integration/proton/messages.go b/service/integration/proton/messages.go index 2ccc780..b1e8014 100644 --- a/service/integration/proton/messages.go +++ b/service/integration/proton/messages.go @@ -109,12 +109,20 @@ func (m *Monitor) sendNotification(msg *protonmail.Message) { } func (m *Monitor) clearNotification(msgID string) { - mapping, err := m.dispatcher.GetStore().GetApp(prismTopic) - if err != nil || mapping == nil { + app, err := m.dispatcher.GetStore().GetApp(prismTopic) + if err != nil || app == nil { return } - if mapping.Channel != notification.ChannelWebPush { + hasWebPush := false + for _, sub := range app.Subscriptions { + if sub.Channel == notification.ChannelWebPush { + hasWebPush = true + break + } + } + + if !hasWebPush { return } diff --git a/service/integration/proton/routes.go b/service/integration/proton/routes.go index fedeee9..bf2e2b4 100644 --- a/service/integration/proton/routes.go +++ b/service/integration/proton/routes.go @@ -116,10 +116,10 @@ func (h *authHandler) handleAuth(w http.ResponseWriter, r *http.Request) { } if h.dispatcher != nil { - if err := h.dispatcher.RegisterApp(prismTopic); err != nil { - h.logger.Warn("Failed to auto-create Proton Mail app mapping", "error", err) + if _, err := h.dispatcher.CreateAppWithDefaultSubscription(prismTopic); err != nil { + h.logger.Warn("Failed to auto-create Proton Mail app with subscription", "error", err) } else { - h.logger.Info("Created Proton Mail app mapping") + h.logger.Info("Created Proton Mail app with default subscription") } } @@ -136,6 +136,7 @@ func (h *authHandler) handleAuth(w http.ResponseWriter, r *http.Request) { } } + util.SetToast(w, "Proton Mail linked", "success") w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]string{"status": "success"}) } @@ -154,6 +155,7 @@ func (h *authHandler) handleDelete(w http.ResponseWriter, r *http.Request) { return } + util.SetToast(w, "Proton Mail unlinked", "success") w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]string{"status": "deleted"}) } diff --git a/service/integration/signal/handlers.go b/service/integration/signal/handlers.go index 7ec22ef..4cbad97 100644 --- a/service/integration/signal/handlers.go +++ b/service/integration/signal/handlers.go @@ -63,7 +63,7 @@ func (h *Handlers) HandleFragment(w http.ResponseWriter, r *http.Request) { } else if account == nil { integData.StatusClass = "disconnected" integData.StatusText = "Unlinked" - integData.StatusTooltip = "Click to link device with Signal" + integData.StatusTooltip = "Click Link button below to link" integData.Open = true } else { integData.StatusClass = "connected" diff --git a/service/integration/signal/integration.go b/service/integration/signal/integration.go index b8e0968..4c96061 100644 --- a/service/integration/signal/integration.go +++ b/service/integration/signal/integration.go @@ -24,7 +24,10 @@ type Integration struct { func NewIntegration(cfg *config.Config, store *notification.Store, logger *slog.Logger, tmpl *util.TemplateRenderer) *Integration { client := NewClient() - sender := NewSender(client, store, logger) + var sender *Sender + if client.IsEnabled() { + sender = NewSender(client, store, logger) + } return &Integration{ cfg: cfg, client: client, diff --git a/service/integration/signal/sender.go b/service/integration/signal/sender.go index 288a5ab..37dd0e3 100644 --- a/service/integration/signal/sender.go +++ b/service/integration/signal/sender.go @@ -22,11 +22,44 @@ func NewSender(client *Client, store *notification.Store, logger *slog.Logger) * } } -func (s *Sender) Send(mapping *notification.Mapping, notif notification.Notification) error { +func (s *Sender) IsLinked() (bool, error) { + if s.client == nil { + return false, nil + } + + account, err := s.client.GetLinkedAccount() + if err != nil { + return false, err + } + + return account != nil, nil +} + +func (s *Sender) CreateDefaultSignalSubscription(appName string) (*notification.SignalSubscription, error) { + if s.client == nil { + return nil, fmt.Errorf("signal integration not enabled") + } + + groupID, account, err := s.client.CreateGroup(appName) + if err != nil { + return nil, err + } + + return ¬ification.SignalSubscription{ + GroupID: groupID, + Account: account, + }, nil +} + +func (s *Sender) Send(sub *notification.Subscription, notif notification.Notification) error { if s.client == nil { return notification.NewPermanentError(fmt.Errorf("signal integration not enabled")) } + if sub.Signal == nil { + return notification.NewPermanentError(fmt.Errorf("no signal subscription data")) + } + account, err := s.client.GetLinkedAccount() if err != nil { return util.LogError(s.logger, "Failed to get linked account", err) @@ -37,33 +70,22 @@ func (s *Sender) Send(mapping *notification.Mapping, notif notification.Notifica } var signalGroupID string - needsNewGroup := mapping.Signal == nil || mapping.Signal.GroupID == "" + needsNewGroup := sub.Signal.GroupID == "" - if !needsNewGroup && mapping.Signal.Account != account.Number { + if !needsNewGroup && sub.Signal.Account != account.Number { s.logger.Info("Signal account changed, recreating group", - "app", mapping.AppName, - "oldAccount", mapping.Signal.Account, + "app", sub.AppName, + "oldAccount", sub.Signal.Account, "newAccount", account.Number) needsNewGroup = true } if needsNewGroup { - newGroupID, accountNumber, err := s.client.CreateGroup(mapping.AppName) - if err != nil { - return util.LogError(s.logger, "Failed to create group", err, "app", mapping.AppName) - } - signalGroupID = newGroupID - - if err := s.store.UpdateSignal(mapping.AppName, ¬ification.SignalSubscription{ - GroupID: signalGroupID, - Account: accountNumber, - }); err != nil { - return util.LogError(s.logger, "Failed to persist signal group", err) - } - } else { - signalGroupID = mapping.Signal.GroupID + return notification.NewPermanentError(fmt.Errorf("signal group not configured for subscription %s", sub.ID)) } + signalGroupID = sub.Signal.GroupID + message := notif.Message if notif.Title != "" { message = fmt.Sprintf("%s\n\n%s", notif.Title, notif.Message) diff --git a/service/integration/telegram/routes.go b/service/integration/telegram/routes.go index 9703f34..b95654f 100644 --- a/service/integration/telegram/routes.go +++ b/service/integration/telegram/routes.go @@ -20,19 +20,19 @@ func GetTemplates() embed.FS { return templates } -type authHandler struct { +type linkHandler struct { db *sql.DB apiKey string logger *slog.Logger } -type telegramAuthRequest struct { +type telegramLinkRequest struct { BotToken string `json:"bot_token"` ChatID string `json:"chat_id"` } -func (h *authHandler) handleAuth(w http.ResponseWriter, r *http.Request) { - var req telegramAuthRequest +func (h *linkHandler) handleLink(w http.ResponseWriter, r *http.Request) { + var req telegramLinkRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { util.JSONError(w, "Invalid request body", http.StatusBadRequest) return @@ -59,11 +59,12 @@ func (h *authHandler) handleAuth(w http.ResponseWriter, r *http.Request) { return } + util.SetToast(w, "Telegram linked", "success") w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]string{"status": "success"}) } -func (h *authHandler) handleDelete(w http.ResponseWriter, r *http.Request) { +func (h *linkHandler) handleUnlink(w http.ResponseWriter, r *http.Request) { credStore, err := credentials.NewStore(h.db, h.apiKey) if err != nil { util.LogAndError(w, h.logger, "Failed to initialize credentials store", http.StatusInternalServerError, err) @@ -75,6 +76,7 @@ func (h *authHandler) handleDelete(w http.ResponseWriter, r *http.Request) { return } + util.SetToast(w, "Telegram unlinked", "success") w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]string{"status": "deleted"}) } @@ -86,9 +88,9 @@ func RegisterRoutes(router *chi.Mux, handlers *Handlers, auth func(http.Handler) handlers.SetDB(db, apiKey) - authH := &authHandler{db: db, apiKey: apiKey, logger: logger} + linkH := &linkHandler{db: db, apiKey: apiKey, logger: logger} router.With(auth).Get("/fragment/telegram", handlers.HandleFragment) - router.With(auth).Post("/api/v1/telegram/auth", authH.handleAuth) - router.With(auth).Delete("/api/v1/telegram/auth", authH.handleDelete) + router.With(auth).Post("/api/v1/telegram/link", linkH.handleLink) + router.With(auth).Delete("/api/v1/telegram/link", linkH.handleUnlink) } diff --git a/service/integration/telegram/sender.go b/service/integration/telegram/sender.go index f42685e..207ec03 100644 --- a/service/integration/telegram/sender.go +++ b/service/integration/telegram/sender.go @@ -3,10 +3,15 @@ package telegram import ( "fmt" "log/slog" + "strconv" "prism/service/notification" ) +func parseInt64(s string) (int64, error) { + return strconv.ParseInt(s, 10, 64) +} + type Sender struct { client *Client store *notification.Store @@ -23,13 +28,33 @@ func NewSender(client *Client, store *notification.Store, logger *slog.Logger, d } } -func (s *Sender) Send(mapping *notification.Mapping, notif notification.Notification) error { +func (s *Sender) GetChatID() int64 { + return s.DefaultChatID +} + +func (s *Sender) IsLinked() (bool, error) { + if s.client == nil { + return false, nil + } + + if !s.client.IsAvailable() { + return false, nil + } + + if s.DefaultChatID == 0 { + return false, nil + } + + return true, nil +} + +func (s *Sender) Send(sub *notification.Subscription, notif notification.Notification) error { if s.client == nil { return notification.NewPermanentError(fmt.Errorf("telegram integration not enabled")) } - if s.DefaultChatID == 0 { - return notification.NewPermanentError(fmt.Errorf("no telegram chat configured (set TELEGRAM_CHAT_ID in .env)")) + if sub.Telegram == nil || sub.Telegram.ChatID == "" { + return notification.NewPermanentError(fmt.Errorf("no telegram chat configured for subscription")) } message := notif.Message @@ -37,10 +62,15 @@ func (s *Sender) Send(mapping *notification.Mapping, notif notification.Notifica message = fmt.Sprintf("%s\n%s", notif.Title, notif.Message) } - fullMessage := fmt.Sprintf("%s\n\n%s", mapping.AppName, message) + fullMessage := fmt.Sprintf("%s\n\n%s", sub.AppName, message) - if err := s.client.SendMessage(s.DefaultChatID, fullMessage); err != nil { - s.logger.Error("Failed to send telegram message", "chatID", s.DefaultChatID, "error", err) + chatID, err := parseInt64(sub.Telegram.ChatID) + if err != nil { + return notification.NewPermanentError(fmt.Errorf("invalid chat ID: %w", err)) + } + + if err := s.client.SendMessage(chatID, fullMessage); err != nil { + s.logger.Error("Failed to send telegram message", "chatID", chatID, "error", err) return err } diff --git a/service/integration/webpush/handlers.go b/service/integration/webpush/handlers.go index a6f44d7..755c90f 100644 --- a/service/integration/webpush/handlers.go +++ b/service/integration/webpush/handlers.go @@ -1,6 +1,8 @@ package webpush import ( + "crypto/rand" + "encoding/hex" "encoding/json" "log/slog" "net/http" @@ -12,6 +14,14 @@ import ( "github.com/go-chi/chi/v5" ) +func generateSubscriptionID() (string, error) { + b := make([]byte, 16) + if _, err := rand.Read(b); err != nil { + return "", err + } + return hex.EncodeToString(b), nil +} + type Handlers struct { store *notification.Store logger *slog.Logger @@ -49,7 +59,7 @@ func (h *Handlers) HandleRegister(w http.ResponseWriter, r *http.Request) { return } - existing, err := h.store.GetApp(req.AppName) + subID, err := generateSubscriptionID() if err != nil { util.LogAndError(w, h.logger, "Internal server error", http.StatusInternalServerError, err) return @@ -69,47 +79,47 @@ func (h *Handlers) HandleRegister(w http.ResponseWriter, r *http.Request) { } } - if existing != nil { - if err := h.store.UpdateWebPush(req.AppName, webPush); err != nil { - util.LogAndError(w, h.logger, "Internal server error", http.StatusInternalServerError, err) - return - } - h.logger.Info("Updated webpush for existing endpoint", "app", req.AppName, "pushEndpoint", req.PushEndpoint) - } else { - channel := notification.ChannelWebPush - if err := h.store.Register(req.AppName, &channel, nil, webPush); err != nil { - util.LogAndError(w, h.logger, "Failed to register webpush endpoint", http.StatusInternalServerError, err) - return - } - h.logger.Info("Registered new webpush endpoint", "app", req.AppName, "pushEndpoint", req.PushEndpoint) + sub := notification.Subscription{ + ID: subID, + AppName: req.AppName, + Channel: notification.ChannelWebPush, + WebPush: webPush, } + if err := h.store.AddSubscription(sub); err != nil { + util.LogAndError(w, h.logger, "Failed to add subscription", http.StatusInternalServerError, err) + return + } + + h.logger.Info("Added webpush subscription", "app", req.AppName, "subscriptionID", subID, "pushEndpoint", req.PushEndpoint) + w.Header().Set("Content-Type", "application/json") response := map[string]string{ - "appName": req.AppName, - "channel": notification.ChannelWebPush.String(), + "appName": req.AppName, + "channel": notification.ChannelWebPush.String(), + "subscriptionId": subID, } _ = json.NewEncoder(w).Encode(response) } func (h *Handlers) HandleUnregister(w http.ResponseWriter, r *http.Request) { - appName := chi.URLParam(r, "appName") - if appName == "" { - http.Error(w, "appName is required", http.StatusBadRequest) + subscriptionID := chi.URLParam(r, "subscriptionId") + if subscriptionID == "" { + http.Error(w, "subscriptionId is required", http.StatusBadRequest) return } - if err := h.store.ClearWebPush(appName); err != nil { - util.LogAndError(w, h.logger, "Failed to clear webpush endpoint", http.StatusInternalServerError, err) + if err := h.store.DeleteSubscription(subscriptionID); err != nil { + util.LogAndError(w, h.logger, "Failed to delete subscription", http.StatusInternalServerError, err) return } - h.logger.Info("Cleared webpush subscription", "app", appName) + h.logger.Info("Deleted webpush subscription", "subscriptionID", subscriptionID) w.Header().Set("Content-Type", "application/json") response := map[string]string{ - "status": "unregistered", - "appName": appName, + "status": "deleted", + "subscriptionId": subscriptionID, } _ = json.NewEncoder(w).Encode(response) } diff --git a/service/integration/webpush/routes.go b/service/integration/webpush/routes.go index aa648d9..9f70283 100644 --- a/service/integration/webpush/routes.go +++ b/service/integration/webpush/routes.go @@ -12,9 +12,9 @@ import ( func RegisterRoutes(router *chi.Mux, store *notification.Store, logger *slog.Logger, authMiddleware func(http.Handler) http.Handler) { handlers := NewHandlers(store, logger) - router.Route("/api/v1/webpush/app", func(r chi.Router) { + router.Route("/api/v1/webpush/subscriptions", func(r chi.Router) { r.Use(authMiddleware) r.Post("/", handlers.HandleRegister) - r.Delete("/{appName}", handlers.HandleUnregister) + r.Delete("/{subscriptionId}", handlers.HandleUnregister) }) } diff --git a/service/integration/webpush/sender.go b/service/integration/webpush/sender.go index 751a252..73341cb 100644 --- a/service/integration/webpush/sender.go +++ b/service/integration/webpush/sender.go @@ -22,9 +22,9 @@ func NewSender(logger *slog.Logger) *Sender { } } -func (s *Sender) Send(mapping *notification.Mapping, notif notification.Notification) error { - if mapping.WebPush == nil { - return notification.NewPermanentError(fmt.Errorf("no push endpoint configured for %s", mapping.AppName)) +func (s *Sender) Send(sub *notification.Subscription, notif notification.Notification) error { + if sub.WebPush == nil { + return notification.NewPermanentError(fmt.Errorf("no push endpoint configured for subscription %s", sub.ID)) } payload, err := json.Marshal(notif) @@ -32,17 +32,17 @@ func (s *Sender) Send(mapping *notification.Mapping, notif notification.Notifica return fmt.Errorf("failed to marshal notification: %w", err) } - if mapping.WebPush.HasEncryption() { + if sub.WebPush.HasEncryption() { subscription := &webpush.Subscription{ - Endpoint: mapping.WebPush.Endpoint, + Endpoint: sub.WebPush.Endpoint, Keys: webpush.Keys{ - P256dh: mapping.WebPush.P256dh, - Auth: mapping.WebPush.Auth, + P256dh: sub.WebPush.P256dh, + Auth: sub.WebPush.Auth, }, } resp, err := webpush.SendNotification(payload, subscription, &webpush.Options{ - VAPIDPrivateKey: mapping.WebPush.VapidPrivateKey, + VAPIDPrivateKey: sub.WebPush.VapidPrivateKey, TTL: 86400, }) if err != nil { @@ -54,9 +54,9 @@ func (s *Sender) Send(mapping *notification.Mapping, notif notification.Notifica return fmt.Errorf("webpush returned status %d", resp.StatusCode) } - s.logger.Debug("Sent encrypted webpush notification", "app", mapping.AppName, "url", mapping.WebPush.Endpoint) + s.logger.Debug("Sent encrypted webpush notification", "app", sub.AppName, "url", sub.WebPush.Endpoint) } else { - resp, err := http.Post(mapping.WebPush.Endpoint, "application/json", bytes.NewBuffer(payload)) + resp, err := http.Post(sub.WebPush.Endpoint, "application/json", bytes.NewBuffer(payload)) if err != nil { return fmt.Errorf("failed to send webhook: %w", err) } @@ -66,7 +66,7 @@ func (s *Sender) Send(mapping *notification.Mapping, notif notification.Notifica return fmt.Errorf("webhook returned status %d", resp.StatusCode) } - s.logger.Debug("Sent plain webhook notification", "app", mapping.AppName, "url", mapping.WebPush.Endpoint) + s.logger.Debug("Sent plain webhook notification", "app", sub.AppName, "url", sub.WebPush.Endpoint) } return nil diff --git a/service/notification/dispatcher.go b/service/notification/dispatcher.go index ae2b702..1489ba0 100644 --- a/service/notification/dispatcher.go +++ b/service/notification/dispatcher.go @@ -1,6 +1,8 @@ package notification import ( + "crypto/rand" + "encoding/hex" "fmt" "log/slog" "time" @@ -9,7 +11,19 @@ import ( ) type NotificationSender interface { - Send(mapping *Mapping, notif Notification) error + Send(sub *Subscription, notif Notification) error +} + +type linkedChecker interface { + IsLinked() (bool, error) +} + +type signalAutoConfigurer interface { + CreateDefaultSignalSubscription(appName string) (*SignalSubscription, error) +} + +type telegramSender interface { + GetChatID() int64 } type Dispatcher struct { @@ -60,54 +74,65 @@ func (d *Dispatcher) IsValidChannel(channel Channel) bool { return channel.IsAvailable(d.HasSignal(), d.HasTelegram()) } -func (d *Dispatcher) RegisterApp(appName string) error { - availableChannels := d.GetAvailableChannels() - return d.store.RegisterDefault(appName, availableChannels) -} - func (d *Dispatcher) Send(appName string, notif Notification) error { - mapping, err := d.store.GetApp(appName) + app, err := d.store.GetApp(appName) if err != nil { - return util.LogError(d.logger, "Failed to get app mapping", err, "app", appName) + return util.LogError(d.logger, "Failed to get app", err, "app", appName) } - if mapping == nil { - d.logger.Info("Registering new app", "app", appName) - - availableChannels := d.GetAvailableChannels() - if err := d.store.RegisterDefault(appName, availableChannels); err != nil { - return util.LogError(d.logger, "Failed to register app", err, "app", appName) - } - - mapping, err = d.store.GetApp(appName) + if app == nil { + d.logger.Info("Registering new app and auto-configuring subscription", "app", appName) + app, err = d.CreateAppWithDefaultSubscription(appName) if err != nil { - return util.LogError(d.logger, "Failed to get mapping after registration", err, "app", appName) - } - - if mapping == nil { - return fmt.Errorf("mapping still nil after registration for app: %s", appName) + d.logger.Warn("Failed to auto-configure subscription", "app", appName, "error", err) + return nil } } - sender, ok := d.senders[mapping.Channel] - if !ok { - d.logger.Error("No sender registered for channel", "channel", mapping.Channel) - return fmt.Errorf("no sender for channel: %s", mapping.Channel) + if len(app.Subscriptions) == 0 { + d.logger.Info("App has no subscriptions, attempting to auto-configure", "app", appName) + app, err = d.CreateAppWithDefaultSubscription(appName) + if err != nil { + d.logger.Warn("Failed to auto-configure subscription", "app", appName, "error", err) + return nil + } } - return d.sendWithRetry(sender, mapping, notif, appName) + var lastErr error + successCount := 0 + + for _, sub := range app.Subscriptions { + sender, ok := d.senders[sub.Channel] + if !ok { + d.logger.Error("No sender for channel", "channel", sub.Channel, "subscriptionID", sub.ID) + lastErr = fmt.Errorf("no sender for channel: %s", sub.Channel) + continue + } + + if err := d.sendWithRetry(sender, &sub, notif, appName, sub.ID); err != nil { + lastErr = err + } else { + successCount++ + } + } + + if successCount == 0 && lastErr != nil { + return lastErr + } + + return nil } -func (d *Dispatcher) sendWithRetry(sender NotificationSender, mapping *Mapping, notif Notification, appName string) error { +func (d *Dispatcher) sendWithRetry(sender NotificationSender, sub *Subscription, notif Notification, appName, subscriptionID string) error { maxRetries := 10 baseDelay := 500 * time.Millisecond var lastErr error for attempt := 0; attempt < maxRetries; attempt++ { - err := sender.Send(mapping, notif) + err := sender.Send(sub, notif) if err == nil { if attempt > 0 { - d.logger.Info("Notification sent after retry", "app", appName, "attempt", attempt+1) + d.logger.Info("Notification sent after retry", "app", appName, "subscriptionID", subscriptionID, "attempt", attempt+1) } return nil } @@ -115,17 +140,141 @@ func (d *Dispatcher) sendWithRetry(sender NotificationSender, mapping *Mapping, lastErr = err if IsPermanent(err) { - d.logger.Error("Permanent error, not retrying", "app", appName, "error", err) + d.logger.Error("Permanent error, not retrying", "app", appName, "subscriptionID", subscriptionID, "error", err) return err } if attempt < maxRetries-1 { delay := baseDelay * time.Duration(1< 0 { - channel = availableChannels[0] - } else { - channel = ChannelWebPush + query := ` + INSERT INTO subscriptions (id, appName, channel, signalGroupId, signalAccount, telegramChatId, pushEndpoint, p256dh, auth, vapidPrivateKey) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ` + + var signalGroupID, signalAccount, telegramChatID, pushEndpoint, p256dh, auth, vapidPrivateKey *string + + if sub.Signal != nil { + signalGroupID = &sub.Signal.GroupID + signalAccount = &sub.Signal.Account + } + if sub.Telegram != nil { + telegramChatID = &sub.Telegram.ChatID + } + if sub.WebPush != nil { + pushEndpoint = &sub.WebPush.Endpoint + p256dh = &sub.WebPush.P256dh + auth = &sub.WebPush.Auth + vapidPrivateKey = &sub.WebPush.VapidPrivateKey } - return s.Register(appName, &channel, nil, nil) + _, err := s.db.Exec(query, sub.ID, sub.AppName, sub.Channel, signalGroupID, signalAccount, telegramChatID, pushEndpoint, p256dh, auth, vapidPrivateKey) + return err } -func (s *Store) GetApp(appName string) (*Mapping, error) { - query := ` - SELECT appName, signalGroupId, signalAccount, channel, pushEndpoint, p256dh, auth, vapidPrivateKey - FROM mappings - WHERE appName = ? - ` +func (s *Store) GetApp(appName string) (*App, error) { + query := `SELECT appName FROM apps WHERE appName = ?` row := s.db.QueryRow(query, appName) - var m Mapping - var signalGroupID, signalAccount, pushEndpoint, p256dh, auth, vapidPrivateKey sql.NullString - err := row.Scan(&m.AppName, &signalGroupID, &signalAccount, &m.Channel, &pushEndpoint, &p256dh, &auth, &vapidPrivateKey) + var app App + if err := row.Scan(&app.AppName); err == sql.ErrNoRows { + return nil, nil + } else if err != nil { + return nil, err + } + + subs, err := s.GetSubscriptions(appName) + if err != nil { + return nil, err + } + app.Subscriptions = subs + + return &app, nil +} + +func (s *Store) GetSubscriptions(appName string) ([]Subscription, error) { + query := ` + SELECT id, appName, channel, signalGroupId, signalAccount, telegramChatId, pushEndpoint, p256dh, auth, vapidPrivateKey + FROM subscriptions + WHERE appName = ? + ` + rows, err := s.db.Query(query, appName) + if err != nil { + return nil, err + } + defer rows.Close() + + var subscriptions []Subscription + for rows.Next() { + var sub Subscription + var signalGroupID, signalAccount, telegramChatID, pushEndpoint, p256dh, auth, vapidPrivateKey sql.NullString + + if err := rows.Scan(&sub.ID, &sub.AppName, &sub.Channel, &signalGroupID, &signalAccount, &telegramChatID, &pushEndpoint, &p256dh, &auth, &vapidPrivateKey); err != nil { + return nil, err + } + + if signalGroupID.Valid && signalAccount.Valid { + sub.Signal = &SignalSubscription{ + GroupID: signalGroupID.String, + Account: signalAccount.String, + } + } + if telegramChatID.Valid { + sub.Telegram = &TelegramSubscription{ + ChatID: telegramChatID.String, + } + } + if pushEndpoint.Valid { + sub.WebPush = &WebPushSubscription{ + Endpoint: pushEndpoint.String, + } + if p256dh.Valid { + sub.WebPush.P256dh = p256dh.String + } + if auth.Valid { + sub.WebPush.Auth = auth.String + } + if vapidPrivateKey.Valid { + sub.WebPush.VapidPrivateKey = vapidPrivateKey.String + } + } + + subscriptions = append(subscriptions, sub) + } + + return subscriptions, rows.Err() +} + +func (s *Store) GetAllApps() ([]App, error) { + query := `SELECT appName FROM apps ORDER BY appName` + rows, err := s.db.Query(query) + if err != nil { + return nil, err + } + defer rows.Close() + + var apps []App + for rows.Next() { + var app App + if err := rows.Scan(&app.AppName); err != nil { + return nil, err + } + + subs, err := s.GetSubscriptions(app.AppName) + if err != nil { + return nil, err + } + app.Subscriptions = subs + + apps = append(apps, app) + } + + return apps, rows.Err() +} + +func (s *Store) GetSignalGroup(appName string) (*SignalSubscription, error) { + query := `SELECT groupId, account FROM signal_groups WHERE appName = ?` + row := s.db.QueryRow(query, appName) + + var groupID, account string + if err := row.Scan(&groupID, &account); err != nil { + if err == sql.ErrNoRows { + return nil, nil + } + return nil, err + } + + return &SignalSubscription{ + GroupID: groupID, + Account: account, + }, nil +} + +func (s *Store) SaveSignalGroup(appName string, sub *SignalSubscription) error { + if err := s.RegisterApp(appName); err != nil { + return err + } + + query := `INSERT INTO signal_groups (appName, groupId, account) VALUES (?, ?, ?) + ON CONFLICT(appName) DO UPDATE SET groupId=excluded.groupId, account=excluded.account` + _, err := s.db.Exec(query, appName, sub.GroupID, sub.Account) + return err +} + +func (s *Store) DeleteSubscription(subscriptionID string) error { + query := `DELETE FROM subscriptions WHERE id = ?` + _, err := s.db.Exec(query, subscriptionID) + return err +} + +func (s *Store) GetSubscription(subscriptionID string) (*Subscription, error) { + query := ` + SELECT id, appName, channel, signalGroupId, signalAccount, telegramChatId, pushEndpoint, p256dh, auth, vapidPrivateKey + FROM subscriptions + WHERE id = ? + ` + row := s.db.QueryRow(query, subscriptionID) + + var sub Subscription + var signalGroupID, signalAccount, telegramChatID, pushEndpoint, p256dh, auth, vapidPrivateKey sql.NullString + + err := row.Scan(&sub.ID, &sub.AppName, &sub.Channel, &signalGroupID, &signalAccount, &telegramChatID, &pushEndpoint, &p256dh, &auth, &vapidPrivateKey) if err == sql.ErrNoRows { return nil, nil } @@ -137,117 +273,35 @@ func (s *Store) GetApp(appName string) (*Mapping, error) { } if signalGroupID.Valid && signalAccount.Valid { - m.Signal = &SignalSubscription{ + sub.Signal = &SignalSubscription{ GroupID: signalGroupID.String, Account: signalAccount.String, } } + if telegramChatID.Valid { + sub.Telegram = &TelegramSubscription{ + ChatID: telegramChatID.String, + } + } if pushEndpoint.Valid { - m.WebPush = &WebPushSubscription{ + sub.WebPush = &WebPushSubscription{ Endpoint: pushEndpoint.String, } if p256dh.Valid { - m.WebPush.P256dh = p256dh.String + sub.WebPush.P256dh = p256dh.String } if auth.Valid { - m.WebPush.Auth = auth.String + sub.WebPush.Auth = auth.String } if vapidPrivateKey.Valid { - m.WebPush.VapidPrivateKey = vapidPrivateKey.String + sub.WebPush.VapidPrivateKey = vapidPrivateKey.String } } - return &m, nil -} - -func (s *Store) GetAllMappings() ([]Mapping, error) { - query := ` - SELECT appName, signalGroupId, signalAccount, channel, pushEndpoint, p256dh, auth, vapidPrivateKey - FROM mappings - ` - rows, err := s.db.Query(query) - if err != nil { - return nil, err - } - defer rows.Close() - - var mappings []Mapping - for rows.Next() { - var m Mapping - var signalGroupID, signalAccount, pushEndpoint, p256dh, auth, vapidPrivateKey sql.NullString - if err := rows.Scan(&m.AppName, &signalGroupID, &signalAccount, &m.Channel, &pushEndpoint, &p256dh, &auth, &vapidPrivateKey); err != nil { - return nil, err - } - - if signalGroupID.Valid && signalAccount.Valid { - m.Signal = &SignalSubscription{ - GroupID: signalGroupID.String, - Account: signalAccount.String, - } - } - if pushEndpoint.Valid { - m.WebPush = &WebPushSubscription{ - Endpoint: pushEndpoint.String, - } - if p256dh.Valid { - m.WebPush.P256dh = p256dh.String - } - if auth.Valid { - m.WebPush.Auth = auth.String - } - if vapidPrivateKey.Valid { - m.WebPush.VapidPrivateKey = vapidPrivateKey.String - } - } - - mappings = append(mappings, m) - } - - return mappings, rows.Err() -} - -func (s *Store) UpdateChannel(appName string, channel Channel) error { - query := `UPDATE mappings SET channel = ? WHERE appName = ?` - _, err := s.db.Exec(query, channel, appName) - return err -} - -func (s *Store) UpdateSignal(appName string, signal *SignalSubscription) error { - if signal == nil { - return fmt.Errorf("signal cannot be nil") - } - - query := `UPDATE mappings SET signalGroupId = ?, signalAccount = ? WHERE appName = ?` - _, err := s.db.Exec(query, signal.GroupID, signal.Account, appName) - return err -} - -func (s *Store) UpdateWebPush(appName string, webPush *WebPushSubscription) error { - if webPush == nil { - return fmt.Errorf("webPush cannot be nil") - } - - query := ` - UPDATE mappings - SET pushEndpoint = ?, p256dh = ?, auth = ?, vapidPrivateKey = ? - WHERE appName = ? - ` - _, err := s.db.Exec(query, webPush.Endpoint, webPush.P256dh, webPush.Auth, webPush.VapidPrivateKey, appName) - return err + return &sub, nil } func (s *Store) RemoveApp(appName string) error { - query := `DELETE FROM mappings WHERE appName = ?` - _, err := s.db.Exec(query, appName) - return err -} - -func (s *Store) ClearWebPush(appName string) error { - query := ` - UPDATE mappings - SET pushEndpoint = NULL, p256dh = NULL, auth = NULL, vapidPrivateKey = NULL, channel = 'signal' - WHERE appName = ? - ` - _, err := s.db.Exec(query, appName) + _, err := s.db.Exec(`DELETE FROM apps WHERE appName = ?`, appName) return err } diff --git a/service/server/handlers_action.go b/service/server/handlers_action.go deleted file mode 100644 index 41844a9..0000000 --- a/service/server/handlers_action.go +++ /dev/null @@ -1,54 +0,0 @@ -package server - -import ( - "net/http" - - "prism/service/notification" - "prism/service/util" - - "github.com/go-chi/chi/v5" -) - -func (s *Server) handleDeleteAppAction(w http.ResponseWriter, r *http.Request) { - app := chi.URLParam(r, "appName") - if app == "" { - http.Error(w, "Missing app parameter", http.StatusBadRequest) - return - } - - if err := s.store.RemoveApp(app); err != nil { - util.LogAndError(w, s.logger, "Failed to delete app", http.StatusInternalServerError, err) - return - } - - w.Header().Set("Content-Type", "text/html") - s.handleFragmentApps(w, r) -} - -func (s *Server) handleToggleChannelAction(w http.ResponseWriter, r *http.Request) { - if err := r.ParseForm(); err != nil { - http.Error(w, "Invalid form data", http.StatusBadRequest) - return - } - - app := r.FormValue("app") - channel := r.FormValue("channel") - - if app == "" || channel == "" { - http.Error(w, "Missing app or channel parameter", http.StatusBadRequest) - return - } - - if !s.dispatcher.IsValidChannel(notification.Channel(channel)) { - http.Error(w, "Invalid channel", http.StatusBadRequest) - return - } - - if err := s.store.UpdateChannel(app, notification.Channel(channel)); err != nil { - util.LogAndError(w, s.logger, "Failed to update channel", http.StatusInternalServerError, err) - return - } - - w.Header().Set("Content-Type", "text/html") - s.handleFragmentApps(w, r) -} diff --git a/service/server/handlers_admin.go b/service/server/handlers_admin.go deleted file mode 100644 index 22ac218..0000000 --- a/service/server/handlers_admin.go +++ /dev/null @@ -1,152 +0,0 @@ -package server - -import ( - "encoding/json" - "net/http" - "time" - - "prism/service/notification" - "prism/service/util" - - "github.com/go-chi/chi/v5" -) - -func (s *Server) handleGetMappings(w http.ResponseWriter, r *http.Request) { - mappings, err := s.store.GetAllMappings() - if err != nil { - util.LogAndError(w, s.logger, "Internal server error", http.StatusInternalServerError, err) - return - } - - w.Header().Set("Content-Type", "application/json") - if err := json.NewEncoder(w).Encode(mappings); err != nil { - s.logger.Error("Failed to encode response", "error", err) - } -} - -type createMappingRequest struct { - App string `json:"app"` - Channel notification.Channel `json:"channel"` - PushEndpoint *string `json:"pushEndpoint,omitempty"` -} - -func (s *Server) handleCreateMapping(w http.ResponseWriter, r *http.Request) { - var req createMappingRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - http.Error(w, "Invalid request body", http.StatusBadRequest) - return - } - - if req.App == "" { - http.Error(w, "Missing required fields", http.StatusBadRequest) - return - } - - if req.Channel == "" { - req.Channel = notification.ChannelWebPush - } - - if req.Channel == notification.ChannelWebPush && req.PushEndpoint == nil { - http.Error(w, "pushEndpoint required for webpush channel", http.StatusBadRequest) - return - } - - var webPush *notification.WebPushSubscription - if req.Channel == notification.ChannelWebPush && req.PushEndpoint != nil { - webPush = ¬ification.WebPushSubscription{ - Endpoint: *req.PushEndpoint, - } - } - - if err := s.store.Register(req.App, &req.Channel, nil, webPush); err != nil { - util.LogAndError(w, s.logger, "Failed to create mapping", http.StatusInternalServerError, err) - return - } - - w.WriteHeader(http.StatusCreated) - if err := json.NewEncoder(w).Encode(map[string]string{"status": "created"}); err != nil { - s.logger.Error("Failed to encode response", "error", err) - } -} - -func (s *Server) handleDeleteMapping(w http.ResponseWriter, r *http.Request) { - appName := chi.URLParam(r, "appName") - if appName == "" { - http.Error(w, "Missing appName parameter", http.StatusBadRequest) - return - } - - if err := s.store.RemoveApp(appName); err != nil { - util.LogAndError(w, s.logger, "Failed to delete mapping", http.StatusInternalServerError, err) - return - } - - w.WriteHeader(http.StatusNoContent) -} - -type updateChannelRequest struct { - Channel notification.Channel `json:"channel"` - UpEndpoint *string `json:"upEndpoint,omitempty"` -} - -func (s *Server) handleUpdateChannel(w http.ResponseWriter, r *http.Request) { - appName := chi.URLParam(r, "appName") - if appName == "" { - http.Error(w, "Missing appName parameter", http.StatusBadRequest) - return - } - - var req updateChannelRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - http.Error(w, "Invalid request body", http.StatusBadRequest) - return - } - - if req.Channel == notification.ChannelWebPush && req.UpEndpoint == nil { - http.Error(w, "upEndpoint required for webpush channel", http.StatusBadRequest) - return - } - - if err := s.store.UpdateChannel(appName, req.Channel); err != nil { - util.LogAndError(w, s.logger, "Failed to update channel", http.StatusInternalServerError, err) - return - } - - w.Header().Set("Content-Type", "application/json") - if err := json.NewEncoder(w).Encode(map[string]string{"status": "updated"}); err != nil { - s.logger.Error("Failed to encode response", "error", err) - } -} - -func (s *Server) handleGetStats(w http.ResponseWriter, r *http.Request) { - mappings, err := s.store.GetAllMappings() - if err != nil { - util.LogAndError(w, s.logger, "Failed to get mappings", http.StatusInternalServerError, err) - return - } - - uptime := time.Since(s.startTime) - - stats := map[string]any{ - "uptime": util.FormatUptime(uptime), - "uptimeSeconds": int(uptime.Seconds()), - "mappingsCount": len(mappings), - "signalCount": countByChannel(mappings, notification.ChannelSignal), - "webpushCount": countByChannel(mappings, notification.ChannelWebPush), - } - - w.Header().Set("Content-Type", "application/json") - if err := json.NewEncoder(w).Encode(stats); err != nil { - s.logger.Error("Failed to encode response", "error", err) - } -} - -func countByChannel(mappings []notification.Mapping, channel notification.Channel) int { - count := 0 - for _, m := range mappings { - if m.Channel == channel { - count++ - } - } - return count -} diff --git a/service/server/handlers_apps.go b/service/server/handlers_apps.go new file mode 100644 index 0000000..fe1b1c6 --- /dev/null +++ b/service/server/handlers_apps.go @@ -0,0 +1,37 @@ +package server + +import ( + "encoding/json" + "net/http" + + "prism/service/util" + + "github.com/go-chi/chi/v5" +) + +func (s *Server) handleDeleteApp(w http.ResponseWriter, r *http.Request) { + app := chi.URLParam(r, "appName") + if app == "" { + http.Error(w, "Missing app parameter", http.StatusBadRequest) + return + } + + if err := s.store.RemoveApp(app); err != nil { + util.LogAndError(w, s.logger, "Failed to delete app", http.StatusInternalServerError, err) + return + } + + util.SetToast(w, "App deleted", "success") + s.handleFragmentApps(w, r) +} + +func (s *Server) handleGetApps(w http.ResponseWriter, r *http.Request) { + apps, err := s.store.GetAllApps() + if err != nil { + util.LogAndError(w, s.logger, "Failed to get apps", http.StatusInternalServerError, err) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(apps) +} diff --git a/service/server/handlers_fragment.go b/service/server/handlers_fragment.go index b0265bf..32ada42 100644 --- a/service/server/handlers_fragment.go +++ b/service/server/handlers_fragment.go @@ -2,32 +2,35 @@ package server import ( "bytes" - "fmt" "net/http" "net/url" + "prism/service/integration/signal" "prism/service/notification" "prism/service/util" ) type AppListItem struct { - AppName string - Channel string - ChannelBadge string - ChannelConfigured bool - Tooltip string - Hostname string - ChannelOptions []SelectOption + AppName string + Channels []ChannelState } -type SelectOption struct { - Value string - Label string - Selected bool +type ChannelState struct { + Channel string + Label string + Active bool + Toggleable bool + Subscriptions []SubscriptionItem +} + +type SubscriptionItem struct { + ID string + Tooltip string + Hostname string } func (s *Server) handleFragmentApps(w http.ResponseWriter, r *http.Request) { - mappings, err := s.store.GetAllMappings() + apps, err := s.store.GetAllApps() if err != nil { util.LogAndError(w, s.logger, "Internal server error", http.StatusInternalServerError, err) return @@ -35,8 +38,10 @@ func (s *Server) handleFragmentApps(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/html") + data := s.buildAppListData(apps) + var buf bytes.Buffer - if err := s.fragmentTmpl.ExecuteTemplate(&buf, "app-list.html", s.buildAppListData(mappings)); err != nil { + if err := s.fragmentTmpl.ExecuteTemplate(&buf, "app-list.html", data); err != nil { util.LogAndError(w, s.logger, "Failed to execute template", http.StatusInternalServerError, err) return } @@ -44,112 +49,111 @@ func (s *Server) handleFragmentApps(w http.ResponseWriter, r *http.Request) { _, _ = w.Write(buf.Bytes()) } -func (s *Server) buildAppListData(mappings []notification.Mapping) []AppListItem { - if len(mappings) == 0 { +func (s *Server) buildAppListData(apps []notification.App) []AppListItem { + if len(apps) == 0 { return nil } - signalLinked := false - if s.integrations.Signal != nil { - handlers := s.integrations.Signal.GetHandlers() - if handlers != nil { - account, _ := handlers.GetClient().GetLinkedAccount() - signalLinked = account != nil - } - } - - telegramLinked := false - var telegramInfo string - if s.integrations.Telegram != nil { + signalEnabled := s.integrations.Signal != nil && s.integrations.Signal.IsEnabled() + telegramEnabled := s.integrations.Telegram != nil && s.integrations.Telegram.IsEnabled() + telegramBotName := "" + if telegramEnabled { handlers := s.integrations.Telegram.GetHandlers() - if handlers != nil && handlers.GetClient() != nil { - chatID := handlers.GetChatID() - telegramLinked = chatID != 0 - if telegramLinked { - if bot, err := handlers.GetClient().GetMe(); err == nil { - telegramInfo = "@" + bot.Username + if handlers != nil { + client := handlers.GetClient() + if client != nil { + if bot, err := client.GetMe(); err == nil && bot != nil && bot.Username != "" { + telegramBotName = "@" + bot.Username } } } } - items := make([]AppListItem, 0, len(mappings)) - for _, m := range mappings { - item := s.buildAppListItem(m, signalLinked, telegramLinked, telegramInfo) + items := make([]AppListItem, 0, len(apps)) + for _, app := range apps { + channels := []ChannelState{} + + if signalEnabled { + var signalSub *notification.Subscription + for i := range app.Subscriptions { + if app.Subscriptions[i].Channel == notification.ChannelSignal { + signalSub = &app.Subscriptions[i] + break + } + } + + state := ChannelState{ + Channel: string(notification.ChannelSignal), + Label: notification.ChannelSignal.Label(), + Active: signalSub != nil, + Toggleable: true, + } + + if signalSub != nil && signalSub.Signal != nil { + state.Subscriptions = []SubscriptionItem{{ + ID: signalSub.ID, + Tooltip: signal.FormatPhoneNumber(signalSub.Signal.Account), + }} + } + + channels = append(channels, state) + } + + if telegramEnabled { + var telegramSub *notification.Subscription + for i := range app.Subscriptions { + if app.Subscriptions[i].Channel == notification.ChannelTelegram { + telegramSub = &app.Subscriptions[i] + break + } + } + + state := ChannelState{ + Channel: string(notification.ChannelTelegram), + Label: notification.ChannelTelegram.Label(), + Active: telegramSub != nil, + Toggleable: true, + } + + if telegramSub != nil { + state.Subscriptions = []SubscriptionItem{{ + ID: telegramSub.ID, + Tooltip: telegramBotName, + }} + } + + channels = append(channels, state) + } + + webPushSubs := []SubscriptionItem{} + for _, sub := range app.Subscriptions { + if sub.Channel == notification.ChannelWebPush && sub.WebPush != nil { + item := SubscriptionItem{ + ID: sub.ID, + Tooltip: sub.WebPush.Endpoint, + } + if u, err := url.Parse(sub.WebPush.Endpoint); err == nil { + item.Hostname = u.Hostname() + } + webPushSubs = append(webPushSubs, item) + } + } + + if len(webPushSubs) > 0 { + channels = append(channels, ChannelState{ + Channel: string(notification.ChannelWebPush), + Label: notification.ChannelWebPush.Label(), + Active: true, + Toggleable: false, + Subscriptions: webPushSubs, + }) + } + + item := AppListItem{ + AppName: app.AppName, + Channels: channels, + } items = append(items, item) } return items } - -func (s *Server) buildAppListItem(m notification.Mapping, signalLinked, telegramLinked bool, telegramInfo string) AppListItem { - isSignal := m.Channel == notification.ChannelSignal - isWebPush := m.Channel == notification.ChannelWebPush - isTelegram := m.Channel == notification.ChannelTelegram - - item := AppListItem{ - AppName: m.AppName, - Channel: string(m.Channel), - } - - if isSignal && m.Signal != nil && m.Signal.GroupID != "" { - item.ChannelBadge = m.Channel.Label() - item.Tooltip = fmt.Sprintf("Group ID: %s", m.Signal.GroupID) - item.ChannelConfigured = true - } else if isWebPush && m.WebPush != nil && m.WebPush.Endpoint != "" { - item.ChannelBadge = m.Channel.Label() - item.Tooltip = m.WebPush.Endpoint - item.ChannelConfigured = true - if u, err := url.Parse(m.WebPush.Endpoint); err == nil { - item.Hostname = u.Hostname() - } - } else if isTelegram && telegramLinked { - item.ChannelBadge = m.Channel.Label() - item.Tooltip = telegramInfo - item.ChannelConfigured = true - } else { - item.ChannelBadge = "Unlinked" - item.ChannelConfigured = false - if isSignal { - item.Tooltip = "No Signal group created yet. Will auto-create on first notification." - } else if isTelegram { - item.Tooltip = "Set TELEGRAM_CHAT_ID in .env" - } else { - item.Tooltip = "No WebPush endpoint registered." - } - } - - item.ChannelOptions = s.buildChannelOptions(m, signalLinked, telegramLinked) - return item -} - -func (s *Server) buildChannelOptions(m notification.Mapping, signalLinked, telegramConfigured bool) []SelectOption { - isSignal := m.Channel == notification.ChannelSignal - isWebPush := m.Channel == notification.ChannelWebPush - isTelegram := m.Channel == notification.ChannelTelegram - - var options []SelectOption - - if s.integrations.Signal != nil { - options = append(options, SelectOption{ - Value: notification.ChannelSignal.String(), - Label: notification.ChannelSignal.Label(), - Selected: isSignal, - }) - } - if s.integrations.Telegram != nil { - options = append(options, SelectOption{ - Value: notification.ChannelTelegram.String(), - Label: notification.ChannelTelegram.Label(), - Selected: isTelegram, - }) - } - if m.WebPush != nil && m.WebPush.Endpoint != "" { - options = append(options, SelectOption{ - Value: notification.ChannelWebPush.String(), - Label: notification.ChannelWebPush.Label(), - Selected: isWebPush, - }) - } - - return options -} diff --git a/service/server/handlers_ntfy.go b/service/server/handlers_ntfy.go index 3696f74..bb410a0 100644 --- a/service/server/handlers_ntfy.go +++ b/service/server/handlers_ntfy.go @@ -1,8 +1,10 @@ package server import ( + "bytes" "encoding/json" "io" + "mime" "net/http" "net/url" "strings" @@ -16,10 +18,12 @@ import ( func (s *Server) handleNtfyPublish(w http.ResponseWriter, r *http.Request) { appName := chi.URLParam(r, "appName") - if appName == "" { - appName = chi.URLParam(r, "endpoint") + decodedAppName, err := url.PathUnescape(appName) + if err != nil { + http.Error(w, "Invalid app name", http.StatusBadRequest) + return } - appName, _ = url.QueryUnescape(appName) + appName = decodedAppName if appName == "" || strings.Contains(appName, "/") { http.Error(w, "Invalid app name", http.StatusBadRequest) return @@ -31,49 +35,7 @@ func (s *Server) handleNtfyPublish(w http.ResponseWriter, r *http.Request) { return } - var message, title string - contentType := r.Header.Get("Content-Type") - - if strings.Contains(contentType, "application/json") { - var payload struct { - Title string `json:"title"` - Message string `json:"message"` - } - if err := json.Unmarshal(body, &payload); err == nil { - message = payload.Message - title = payload.Title - } else { - message = string(body) - } - } else if strings.Contains(contentType, "application/x-www-form-urlencoded") { - if values, err := url.ParseQuery(string(body)); err == nil { - message = values.Get("message") - if message == "" { - message = string(body) - } - if title == "" { - if t := values.Get("title"); t != "" { - title = t - } else if t := values.Get("t"); t != "" { - title = t - } - } - } else { - message = string(body) - } - } else { - message = string(body) - } - - if title == "" { - title = r.Header.Get("X-Title") - if title == "" { - title = r.Header.Get("Title") - } - if title == "" { - title = r.Header.Get("t") - } - } + message, title := parseNtfyPayload(r, body) if message == "" { http.Error(w, "Message required", http.StatusBadRequest) @@ -110,6 +72,57 @@ func (s *Server) handleNtfyPublish(w http.ResponseWriter, r *http.Request) { } } +func parseNtfyPayload(r *http.Request, body []byte) (string, string) { + var message, title string + + mediaType, _, err := mime.ParseMediaType(r.Header.Get("Content-Type")) + if err != nil { + mediaType = "" + } + + switch mediaType { + case "application/json": + var payload struct { + Title string `json:"title"` + Message string `json:"message"` + } + if err := json.Unmarshal(body, &payload); err == nil { + message = payload.Message + title = payload.Title + } else { + message = string(body) + } + case "application/x-www-form-urlencoded": + r.Body = io.NopCloser(bytes.NewReader(body)) + if err := r.ParseForm(); err == nil { + message = r.PostForm.Get("message") + if message == "" { + message = string(body) + } + title = firstNonEmpty(r.PostForm.Get("title"), r.PostForm.Get("t")) + } else { + message = string(body) + } + default: + message = string(body) + } + + if title == "" { + title = firstNonEmpty(r.Header.Get("X-Title"), r.Header.Get("Title"), r.Header.Get("t")) + } + + return message, title +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + if value != "" { + return value + } + } + return "" +} + func truncate(s string, max int) string { if len(s) <= max { return s diff --git a/service/server/handlers_subscriptions.go b/service/server/handlers_subscriptions.go new file mode 100644 index 0000000..bcfdd22 --- /dev/null +++ b/service/server/handlers_subscriptions.go @@ -0,0 +1,168 @@ +package server + +import ( + "fmt" + "net/http" + + "prism/service/notification" + "prism/service/util" + + "github.com/go-chi/chi/v5" +) + +type subscriptionFormData struct { + Channel string + GroupID string + ChatID string +} + +func (s *Server) handleCreateSubscription(w http.ResponseWriter, r *http.Request) { + appName := chi.URLParam(r, "appName") + if appName == "" { + http.Error(w, "Missing app parameter", http.StatusBadRequest) + return + } + + if err := r.ParseForm(); err != nil { + util.JSONError(w, "Invalid form data", http.StatusBadRequest) + return + } + + form := subscriptionFormData{ + Channel: r.FormValue("channel"), + GroupID: r.FormValue("group_id"), + ChatID: r.FormValue("chat_id"), + } + + channel := notification.Channel(form.Channel) + if !s.dispatcher.IsValidChannel(channel) { + util.SetToast(w, fmt.Sprintf("Invalid or unavailable channel: %s", form.Channel), "error") + s.handleFragmentApps(w, r) + return + } + + app, err := s.store.GetApp(appName) + if err != nil { + util.LogAndError(w, s.logger, "Failed to load app subscriptions", http.StatusInternalServerError, err) + return + } + if app != nil { + for _, existingSub := range app.Subscriptions { + if existingSub.Channel == channel { + util.SetToast(w, fmt.Sprintf("%s already enabled", channel.Label()), "error") + s.handleFragmentApps(w, r) + return + } + } + } + + subID, err := notification.GenerateSubscriptionID() + if err != nil { + util.LogAndError(w, s.logger, "Failed to generate subscription ID", http.StatusInternalServerError, err) + return + } + + sub := notification.Subscription{ + ID: subID, + AppName: appName, + Channel: channel, + } + + switch channel { + case notification.ChannelSignal: + if s.integrations.Signal == nil || !s.integrations.Signal.IsEnabled() { + util.SetToast(w, "Signal not configured", "error") + s.handleFragmentApps(w, r) + return + } + client := s.integrations.Signal.GetHandlers().GetClient() + account, err := client.GetLinkedAccount() + if err != nil || account == nil { + util.SetToast(w, "Signal not linked - configure in Integrations below", "error") + s.handleFragmentApps(w, r) + return + } + + if form.GroupID != "" { + sub.Signal = ¬ification.SignalSubscription{ + GroupID: form.GroupID, + Account: account.Number, + } + } else { + cachedGroup, err := s.store.GetSignalGroup(appName) + if err != nil { + util.LogAndError(w, s.logger, "Failed to check for cached Signal group", http.StatusInternalServerError, err) + return + } + + if cachedGroup != nil && cachedGroup.Account == account.Number { + sub.Signal = cachedGroup + } else { + signalSub, err := s.integrations.Signal.GetSender().CreateDefaultSignalSubscription(appName) + if err != nil { + util.LogAndError(w, s.logger, "Failed to create Signal subscription", http.StatusInternalServerError, err) + return + } + sub.Signal = signalSub + + if err := s.store.SaveSignalGroup(appName, signalSub); err != nil { + s.logger.Warn("Failed to cache Signal group", "error", err) + } + } + } + + case notification.ChannelTelegram: + if s.integrations.Telegram == nil || !s.integrations.Telegram.IsEnabled() { + util.SetToast(w, "Telegram not configured", "error") + s.handleFragmentApps(w, r) + return + } + chatID := s.integrations.Telegram.GetHandlers().GetChatID() + if chatID == 0 { + util.SetToast(w, "Telegram not linked - configure in Integrations below", "error") + s.handleFragmentApps(w, r) + return + } + if form.ChatID != "" { + chatID = 0 + fmt.Sscanf(form.ChatID, "%d", &chatID) + } + sub.Telegram = ¬ification.TelegramSubscription{ + ChatID: fmt.Sprintf("%d", chatID), + } + + default: + util.JSONError(w, "Unsupported channel for manual subscription", http.StatusBadRequest) + return + } + + if err := s.store.AddSubscription(sub); err != nil { + util.LogAndError(w, s.logger, "Failed to create subscription", http.StatusInternalServerError, err) + return + } + + util.SetToast(w, fmt.Sprintf("%s enabled", channel.Label()), "success") + s.handleFragmentApps(w, r) +} + +func (s *Server) handleDeleteSubscription(w http.ResponseWriter, r *http.Request) { + subscriptionID := chi.URLParam(r, "subscriptionId") + if subscriptionID == "" { + http.Error(w, "Missing subscription ID", http.StatusBadRequest) + return + } + + sub, err := s.store.GetSubscription(subscriptionID) + if err != nil { + util.LogAndError(w, s.logger, "Failed to get subscription", http.StatusInternalServerError, err) + return + } + + if err := s.store.DeleteSubscription(subscriptionID); err != nil { + util.LogAndError(w, s.logger, "Failed to delete subscription", http.StatusInternalServerError, err) + return + } + + util.SetToast(w, fmt.Sprintf("%s disabled", sub.Channel.Label()), "success") + s.handleFragmentApps(w, r) +} diff --git a/service/server/server.go b/service/server/server.go index 510ec50..cb3ac21 100644 --- a/service/server/server.go +++ b/service/server/server.go @@ -99,30 +99,20 @@ func (s *Server) setupRoutes() { util.LogAndError(w, s.logger, "Internal Server Error", http.StatusInternalServerError, err) } }) - r.Get("/favicon.ico", func(w http.ResponseWriter, r *http.Request) { - http.Redirect(w, r, "/favicon.webp", http.StatusMovedPermanently) - }) integration.RegisterAll(s.integrations, r, s.cfg, s.store, s.logger, authMiddleware) r.With(authMiddleware(s.cfg.APIKey)).Get("/fragment/apps", s.handleFragmentApps) r.With(authMiddleware(s.cfg.APIKey)).Get("/fragment/integrations", s.handleFragmentIntegrations) - r.Route("/action", func(r chi.Router) { + r.Route("/apps", func(r chi.Router) { r.Use(authMiddleware(s.cfg.APIKey)) - r.Delete("/app/{appName}", s.handleDeleteAppAction) - r.Post("/toggle-channel", s.handleToggleChannelAction) - }) - - r.Route("/api/v1/admin", func(r chi.Router) { - r.Use(authMiddleware(s.cfg.APIKey)) - r.Get("/mappings", s.handleGetMappings) - r.Post("/mappings", s.handleCreateMapping) - r.Delete("/mappings/{appName}", s.handleDeleteMapping) - r.Put("/mappings/{appName}/channel", s.handleUpdateChannel) - r.Get("/stats", s.handleGetStats) + r.Delete("/{appName}", s.handleDeleteApp) + r.Post("/{appName}/subscriptions", s.handleCreateSubscription) + r.Delete("/{appName}/subscriptions/{subscriptionId}", s.handleDeleteSubscription) }) + r.With(authMiddleware(s.cfg.APIKey)).Get("/api/v1/apps", s.handleGetApps) r.With(authMiddleware(s.cfg.APIKey)).Get("/api/v1/health", s.handleHealth) r.With(authMiddleware(s.cfg.APIKey)).Post("/{appName}", s.handleNtfyPublish) diff --git a/service/server/templates/app-list.html b/service/server/templates/app-list.html index 7a203b0..0ec272b 100644 --- a/service/server/templates/app-list.html +++ b/service/server/templates/app-list.html @@ -1,38 +1,56 @@ {{if .}}