summaryrefslogtreecommitdiff
path: root/internal/api/drops.go
diff options
context:
space:
mode:
authorPeter Stone <thepeterstone@gmail.com>2026-03-16 20:43:28 +0000
committerPeter Stone <thepeterstone@gmail.com>2026-03-16 20:43:28 +0000
commit7f920ca63af5329c19a0e5a879c649c594beea35 (patch)
tree803150e7c895d3232bad35c729aad647aaa54348 /internal/api/drops.go
parent072652f617653dce74368cedb42b88189e5014fb (diff)
feat: add web push notifications and file drop
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 <noreply@anthropic.com>
Diffstat (limited to 'internal/api/drops.go')
-rw-r--r--internal/api/drops.go165
1 files changed, 165 insertions, 0 deletions
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})
+}
+