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/drops.go | 165 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 165 insertions(+) create mode 100644 internal/api/drops.go (limited to 'internal/api/drops.go') diff --git a/internal/api/drops.go b/internal/api/drops.go new file mode 100644 index 0000000..a5000f1 --- /dev/null +++ b/internal/api/drops.go @@ -0,0 +1,165 @@ +package api + +import ( + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strings" + "time" +) + +// handleListDrops returns a JSON array of files in the drops directory. +func (s *Server) handleListDrops(w http.ResponseWriter, r *http.Request) { + if s.dropsDir == "" { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "drops directory not configured"}) + return + } + + entries, err := os.ReadDir(s.dropsDir) + if err != nil { + if os.IsNotExist(err) { + writeJSON(w, http.StatusOK, []map[string]interface{}{}) + return + } + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to list drops"}) + return + } + + type fileEntry struct { + Name string `json:"name"` + Size int64 `json:"size"` + Modified time.Time `json:"modified"` + } + files := []fileEntry{} + for _, e := range entries { + if e.IsDir() { + continue + } + info, err := e.Info() + if err != nil { + continue + } + files = append(files, fileEntry{ + Name: e.Name(), + Size: info.Size(), + Modified: info.ModTime().UTC(), + }) + } + writeJSON(w, http.StatusOK, files) +} + +// handleGetDrop serves a file from the drops directory as an attachment. +func (s *Server) handleGetDrop(w http.ResponseWriter, r *http.Request) { + if s.dropsDir == "" { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "drops directory not configured"}) + return + } + + filename := r.PathValue("filename") + if strings.Contains(filename, "/") || strings.Contains(filename, "..") { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid filename"}) + return + } + + path := filepath.Join(s.dropsDir, filepath.Clean(filename)) + // Extra safety: ensure the resolved path is still inside dropsDir. + if !strings.HasPrefix(path, s.dropsDir) { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid filename"}) + return + } + + f, err := os.Open(path) + if err != nil { + if os.IsNotExist(err) { + writeJSON(w, http.StatusNotFound, map[string]string{"error": "file not found"}) + return + } + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to open file"}) + return + } + defer f.Close() + + w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename)) + w.Header().Set("Content-Type", "application/octet-stream") + io.Copy(w, f) //nolint:errcheck +} + +// handlePostDrop accepts a file upload (multipart/form-data or raw body with ?filename=). +func (s *Server) handlePostDrop(w http.ResponseWriter, r *http.Request) { + if s.dropsDir == "" { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "drops directory not configured"}) + return + } + + if err := os.MkdirAll(s.dropsDir, 0700); err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to create drops directory"}) + return + } + + ct := r.Header.Get("Content-Type") + if strings.Contains(ct, "multipart/form-data") { + s.handleMultipartDrop(w, r) + return + } + + // Raw body with ?filename= query param. + filename := r.URL.Query().Get("filename") + if filename == "" { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "filename query param required for raw upload"}) + return + } + if strings.Contains(filename, "/") || strings.Contains(filename, "..") { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid filename"}) + return + } + path := filepath.Join(s.dropsDir, filename) + data, err := io.ReadAll(r.Body) + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to read body"}) + return + } + if err := os.WriteFile(path, data, 0600); err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to save file"}) + return + } + writeJSON(w, http.StatusCreated, map[string]interface{}{"name": filename, "size": len(data)}) +} + +func (s *Server) handleMultipartDrop(w http.ResponseWriter, r *http.Request) { + if err := r.ParseMultipartForm(32 << 20); err != nil { // 32 MB limit + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "failed to parse multipart form: " + err.Error()}) + return + } + + file, header, err := r.FormFile("file") + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "missing 'file' field: " + err.Error()}) + return + } + defer file.Close() + + filename := filepath.Base(header.Filename) + if filename == "" || filename == "." { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid filename"}) + return + } + + path := filepath.Join(s.dropsDir, filename) + dst, err := os.Create(path) + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to create file"}) + return + } + defer dst.Close() + + n, err := io.Copy(dst, file) + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to write file"}) + return + } + + writeJSON(w, http.StatusCreated, map[string]interface{}{"name": filename, "size": n}) +} + -- cgit v1.2.3