summaryrefslogtreecommitdiff
path: root/internal/api
diff options
context:
space:
mode:
Diffstat (limited to 'internal/api')
-rw-r--r--internal/api/drops.go165
-rw-r--r--internal/api/drops_test.go159
-rw-r--r--internal/api/push.go105
-rw-r--r--internal/api/push_test.go159
-rw-r--r--internal/api/server.go11
5 files changed, 599 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})
+}
+
diff --git a/internal/api/drops_test.go b/internal/api/drops_test.go
new file mode 100644
index 0000000..ab67489
--- /dev/null
+++ b/internal/api/drops_test.go
@@ -0,0 +1,159 @@
+package api
+
+import (
+ "bytes"
+ "encoding/json"
+ "mime/multipart"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+func testServerWithDrops(t *testing.T) (*Server, string) {
+ t.Helper()
+ srv, _ := testServer(t)
+ dropsDir := t.TempDir()
+ srv.SetDropsDir(dropsDir)
+ return srv, dropsDir
+}
+
+func TestHandleListDrops_Empty(t *testing.T) {
+ srv, _ := testServerWithDrops(t)
+
+ req := httptest.NewRequest("GET", "/api/drops", nil)
+ rec := httptest.NewRecorder()
+ srv.mux.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("want 200, got %d", rec.Code)
+ }
+
+ var files []map[string]interface{}
+ if err := json.NewDecoder(rec.Body).Decode(&files); err != nil {
+ t.Fatalf("decode response: %v", err)
+ }
+ if len(files) != 0 {
+ t.Errorf("want empty list, got %d entries", len(files))
+ }
+}
+
+func TestHandleListDrops_WithFile(t *testing.T) {
+ srv, dropsDir := testServerWithDrops(t)
+
+ // Create a file in the drops dir.
+ if err := os.WriteFile(filepath.Join(dropsDir, "hello.txt"), []byte("world"), 0600); err != nil {
+ t.Fatal(err)
+ }
+
+ req := httptest.NewRequest("GET", "/api/drops", nil)
+ rec := httptest.NewRecorder()
+ srv.mux.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("want 200, got %d: %s", rec.Code, rec.Body.String())
+ }
+
+ var files []map[string]interface{}
+ if err := json.NewDecoder(rec.Body).Decode(&files); err != nil {
+ t.Fatalf("decode response: %v", err)
+ }
+ if len(files) != 1 {
+ t.Fatalf("want 1 file, got %d", len(files))
+ }
+ if files[0]["name"] != "hello.txt" {
+ t.Errorf("name: want %q, got %v", "hello.txt", files[0]["name"])
+ }
+}
+
+func TestHandlePostDrop_Multipart(t *testing.T) {
+ srv, dropsDir := testServerWithDrops(t)
+
+ var buf bytes.Buffer
+ w := multipart.NewWriter(&buf)
+ fw, err := w.CreateFormFile("file", "test.txt")
+ if err != nil {
+ t.Fatal(err)
+ }
+ fw.Write([]byte("hello world")) //nolint:errcheck
+ w.Close()
+
+ req := httptest.NewRequest("POST", "/api/drops", &buf)
+ req.Header.Set("Content-Type", w.FormDataContentType())
+ rec := httptest.NewRecorder()
+ srv.mux.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusCreated {
+ t.Fatalf("want 201, got %d: %s", rec.Code, rec.Body.String())
+ }
+
+ var resp map[string]interface{}
+ if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
+ t.Fatalf("decode response: %v", err)
+ }
+ if resp["name"] != "test.txt" {
+ t.Errorf("name: want %q, got %v", "test.txt", resp["name"])
+ }
+
+ // Verify file was created on disk.
+ content, err := os.ReadFile(filepath.Join(dropsDir, "test.txt"))
+ if err != nil {
+ t.Fatalf("reading uploaded file: %v", err)
+ }
+ if string(content) != "hello world" {
+ t.Errorf("content: want %q, got %q", "hello world", content)
+ }
+}
+
+func TestHandleGetDrop_Download(t *testing.T) {
+ srv, dropsDir := testServerWithDrops(t)
+
+ if err := os.WriteFile(filepath.Join(dropsDir, "download.txt"), []byte("download me"), 0600); err != nil {
+ t.Fatal(err)
+ }
+
+ req := httptest.NewRequest("GET", "/api/drops/download.txt", nil)
+ rec := httptest.NewRecorder()
+ srv.mux.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("want 200, got %d", rec.Code)
+ }
+
+ cd := rec.Header().Get("Content-Disposition")
+ if !strings.Contains(cd, "attachment") {
+ t.Errorf("want Content-Disposition: attachment, got %q", cd)
+ }
+ if rec.Body.String() != "download me" {
+ t.Errorf("body: want %q, got %q", "download me", rec.Body.String())
+ }
+}
+
+func TestHandleGetDrop_PathTraversal(t *testing.T) {
+ srv, _ := testServerWithDrops(t)
+
+ // Attempt path traversal — should be rejected.
+ req := httptest.NewRequest("GET", "/api/drops/..%2Fetc%2Fpasswd", nil)
+ rec := httptest.NewRecorder()
+ srv.mux.ServeHTTP(rec, req)
+
+ // The Go net/http router will handle %2F-encoded slashes as literal characters,
+ // so the filename becomes "../etc/passwd". Our handler should reject it.
+ if rec.Code == http.StatusOK {
+ t.Error("expected non-200 for path traversal attempt")
+ }
+}
+
+func TestHandleGetDrop_NotFound(t *testing.T) {
+ srv, _ := testServerWithDrops(t)
+
+ req := httptest.NewRequest("GET", "/api/drops/notexist.txt", nil)
+ rec := httptest.NewRecorder()
+ srv.mux.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusNotFound {
+ t.Fatalf("want 404, got %d", rec.Code)
+ }
+}
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)
+}
diff --git a/internal/api/push_test.go b/internal/api/push_test.go
new file mode 100644
index 0000000..dfd5a3a
--- /dev/null
+++ b/internal/api/push_test.go
@@ -0,0 +1,159 @@
+package api
+
+import (
+ "bytes"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "sync"
+ "testing"
+
+ "github.com/thepeterstone/claudomator/internal/storage"
+)
+
+// mockPushStore implements pushSubscriptionStore for testing.
+type mockPushStore struct {
+ mu sync.Mutex
+ subs []storage.PushSubscription
+}
+
+func (m *mockPushStore) SavePushSubscription(sub storage.PushSubscription) error {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ // Upsert by endpoint.
+ for i, s := range m.subs {
+ if s.Endpoint == sub.Endpoint {
+ m.subs[i] = sub
+ return nil
+ }
+ }
+ m.subs = append(m.subs, sub)
+ return nil
+}
+
+func (m *mockPushStore) DeletePushSubscription(endpoint string) error {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ filtered := m.subs[:0]
+ for _, s := range m.subs {
+ if s.Endpoint != endpoint {
+ filtered = append(filtered, s)
+ }
+ }
+ m.subs = filtered
+ return nil
+}
+
+func (m *mockPushStore) ListPushSubscriptions() ([]storage.PushSubscription, error) {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ cp := make([]storage.PushSubscription, len(m.subs))
+ copy(cp, m.subs)
+ return cp, nil
+}
+
+func testServerWithPush(t *testing.T) (*Server, *mockPushStore) {
+ t.Helper()
+ srv, _ := testServer(t)
+ ps := &mockPushStore{}
+ srv.SetVAPIDConfig("testpub", "testpriv", "mailto:test@example.com")
+ srv.SetPushStore(ps)
+ return srv, ps
+}
+
+func TestHandleGetVAPIDKey(t *testing.T) {
+ srv, _ := testServerWithPush(t)
+
+ req := httptest.NewRequest("GET", "/api/push/vapid-key", nil)
+ rec := httptest.NewRecorder()
+ srv.mux.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("want 200, got %d", rec.Code)
+ }
+
+ var resp map[string]string
+ if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
+ t.Fatalf("decode response: %v", err)
+ }
+ if resp["public_key"] != "testpub" {
+ t.Errorf("want public_key %q, got %q", "testpub", resp["public_key"])
+ }
+}
+
+func TestHandlePushSubscribe_CreatesSub(t *testing.T) {
+ srv, ps := testServerWithPush(t)
+
+ body := `{"endpoint":"https://push.example.com/sub1","keys":{"p256dh":"key1","auth":"auth1"}}`
+ req := httptest.NewRequest("POST", "/api/push/subscribe", bytes.NewBufferString(body))
+ req.Header.Set("Content-Type", "application/json")
+ rec := httptest.NewRecorder()
+ srv.mux.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusCreated {
+ t.Fatalf("want 201, got %d: %s", rec.Code, rec.Body.String())
+ }
+
+ subs, _ := ps.ListPushSubscriptions()
+ if len(subs) != 1 {
+ t.Fatalf("want 1 subscription, got %d", len(subs))
+ }
+ if subs[0].Endpoint != "https://push.example.com/sub1" {
+ t.Errorf("endpoint: want %q, got %q", "https://push.example.com/sub1", subs[0].Endpoint)
+ }
+}
+
+func TestHandlePushSubscribe_MissingEndpoint(t *testing.T) {
+ srv, _ := testServerWithPush(t)
+
+ body := `{"keys":{"p256dh":"key1","auth":"auth1"}}`
+ req := httptest.NewRequest("POST", "/api/push/subscribe", bytes.NewBufferString(body))
+ req.Header.Set("Content-Type", "application/json")
+ rec := httptest.NewRecorder()
+ srv.mux.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusBadRequest {
+ t.Fatalf("want 400, got %d", rec.Code)
+ }
+}
+
+func TestHandlePushUnsubscribe_DeletesSub(t *testing.T) {
+ srv, ps := testServerWithPush(t)
+
+ // Add a subscription.
+ ps.SavePushSubscription(storage.PushSubscription{ //nolint:errcheck
+ ID: "sub-1",
+ Endpoint: "https://push.example.com/todelete",
+ P256DHKey: "key",
+ AuthKey: "auth",
+ })
+
+ body := `{"endpoint":"https://push.example.com/todelete"}`
+ req := httptest.NewRequest("DELETE", "/api/push/subscribe", bytes.NewBufferString(body))
+ req.Header.Set("Content-Type", "application/json")
+ rec := httptest.NewRecorder()
+ srv.mux.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusNoContent {
+ t.Fatalf("want 204, got %d: %s", rec.Code, rec.Body.String())
+ }
+
+ subs, _ := ps.ListPushSubscriptions()
+ if len(subs) != 0 {
+ t.Errorf("want 0 subscriptions after delete, got %d", len(subs))
+ }
+}
+
+func TestHandlePushUnsubscribe_MissingEndpoint(t *testing.T) {
+ srv, _ := testServerWithPush(t)
+
+ body := `{}`
+ req := httptest.NewRequest("DELETE", "/api/push/subscribe", bytes.NewBufferString(body))
+ req.Header.Set("Content-Type", "application/json")
+ rec := httptest.NewRecorder()
+ srv.mux.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusBadRequest {
+ t.Fatalf("want 400, got %d", rec.Code)
+ }
+}
diff --git a/internal/api/server.go b/internal/api/server.go
index 800ad3e..488c500 100644
--- a/internal/api/server.go
+++ b/internal/api/server.go
@@ -50,6 +50,11 @@ type Server struct {
elaborateLimiter *ipRateLimiter // per-IP rate limiter for elaborate/validate endpoints
webhookSecret string // HMAC-SHA256 secret for GitHub webhook validation
projects []config.Project // configured projects for webhook routing
+ vapidPublicKey string
+ vapidPrivateKey string
+ vapidEmail string
+ pushStore pushSubscriptionStore
+ dropsDir string
}
// SetAPIToken configures a bearer token that must be supplied to access the API.
@@ -128,6 +133,12 @@ func (s *Server) routes() {
s.mux.HandleFunc("GET /api/tasks/{id}/deployment-status", s.handleGetDeploymentStatus)
s.mux.HandleFunc("GET /api/health", s.handleHealth)
s.mux.HandleFunc("POST /api/webhooks/github", s.handleGitHubWebhook)
+ s.mux.HandleFunc("GET /api/push/vapid-key", s.handleGetVAPIDKey)
+ s.mux.HandleFunc("POST /api/push/subscribe", s.handlePushSubscribe)
+ s.mux.HandleFunc("DELETE /api/push/subscribe", s.handlePushUnsubscribe)
+ s.mux.HandleFunc("GET /api/drops", s.handleListDrops)
+ s.mux.HandleFunc("GET /api/drops/{filename}", s.handleGetDrop)
+ s.mux.HandleFunc("POST /api/drops", s.handlePostDrop)
s.mux.Handle("GET /", http.FileServerFS(webui.Files))
}