From 7f920ca63af5329c19a0e5a879c649c594beea35 Mon Sep 17 00:00:00 2001 From: Peter Stone Date: Mon, 16 Mar 2026 20:43:28 +0000 Subject: feat: add web push notifications and file drop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Web Push: - WebPushNotifier with VAPID auth; urgency mapped to event type (BLOCKED=urgent, FAILED=high, COMPLETED=low) - Auto-generates VAPID keys on first serve, persists to config file - push_subscriptions table in SQLite (upsert by endpoint) - GET /api/push/vapid-key, POST/DELETE /api/push/subscribe endpoints - Service worker (sw.js) handles push events and notification clicks - Notification bell button in web UI; subscribes on click File Drop: - GET /api/drops, GET /api/drops/{filename}, POST /api/drops - Persistent ~/.claudomator/drops/ directory - CLAUDOMATOR_DROP_DIR env var passed to agent subprocesses - Drops tab (📁) in web UI with file listing and download links Co-Authored-By: Claude Sonnet 4.6 --- internal/api/push.go | 105 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 105 insertions(+) create mode 100644 internal/api/push.go (limited to 'internal/api/push.go') diff --git a/internal/api/push.go b/internal/api/push.go new file mode 100644 index 0000000..6fd805a --- /dev/null +++ b/internal/api/push.go @@ -0,0 +1,105 @@ +package api + +import ( + "encoding/json" + "net/http" + + "github.com/google/uuid" + "github.com/thepeterstone/claudomator/internal/storage" +) + +// pushSubscriptionStore is the minimal interface needed by push handlers. +type pushSubscriptionStore interface { + SavePushSubscription(sub storage.PushSubscription) error + DeletePushSubscription(endpoint string) error + ListPushSubscriptions() ([]storage.PushSubscription, error) +} + +// SetVAPIDConfig configures VAPID keys and email for web push notifications. +func (s *Server) SetVAPIDConfig(pub, priv, email string) { + s.vapidPublicKey = pub + s.vapidPrivateKey = priv + s.vapidEmail = email +} + +// SetPushStore configures the push subscription store. +func (s *Server) SetPushStore(store pushSubscriptionStore) { + s.pushStore = store +} + +// SetDropsDir configures the file drop directory. +func (s *Server) SetDropsDir(dir string) { + s.dropsDir = dir +} + +// handleGetVAPIDKey returns the VAPID public key for client-side push subscription. +func (s *Server) handleGetVAPIDKey(w http.ResponseWriter, r *http.Request) { + writeJSON(w, http.StatusOK, map[string]string{"public_key": s.vapidPublicKey}) +} + +// handlePushSubscribe saves a new push subscription. +func (s *Server) handlePushSubscribe(w http.ResponseWriter, r *http.Request) { + var input struct { + Endpoint string `json:"endpoint"` + Keys struct { + P256DH string `json:"p256dh"` + Auth string `json:"auth"` + } `json:"keys"` + } + if err := json.NewDecoder(r.Body).Decode(&input); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON: " + err.Error()}) + return + } + if input.Endpoint == "" { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "endpoint is required"}) + return + } + if input.Keys.P256DH == "" || input.Keys.Auth == "" { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "keys.p256dh and keys.auth are required"}) + return + } + + sub := storage.PushSubscription{ + ID: uuid.New().String(), + Endpoint: input.Endpoint, + P256DHKey: input.Keys.P256DH, + AuthKey: input.Keys.Auth, + } + + store := s.pushStore + if store == nil { + store = s.store + } + + if err := store.SavePushSubscription(sub); err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + writeJSON(w, http.StatusCreated, map[string]string{"id": sub.ID}) +} + +// handlePushUnsubscribe deletes a push subscription. +func (s *Server) handlePushUnsubscribe(w http.ResponseWriter, r *http.Request) { + var input struct { + Endpoint string `json:"endpoint"` + } + if err := json.NewDecoder(r.Body).Decode(&input); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON: " + err.Error()}) + return + } + if input.Endpoint == "" { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "endpoint is required"}) + return + } + + store := s.pushStore + if store == nil { + store = s.store + } + + if err := store.DeletePushSubscription(input.Endpoint); err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + w.WriteHeader(http.StatusNoContent) +} -- cgit v1.2.3