package api import ( "errors" "log/slog" "net/http" "strings" "github.com/go-chi/chi/v5" "github.com/alexei/tinyforge/internal/crypto" "github.com/alexei/tinyforge/internal/store" ) // workloadNotificationRow is the JSON shape returned to clients. The // `secret_set` boolean replaces the actual ciphertext: once stored a // secret is write-only, mirroring how workload_env hides encrypted // values. Rotating means submitting a new value. type workloadNotificationRow struct { ID string `json:"id"` WorkloadID string `json:"workload_id"` Name string `json:"name"` URL string `json:"url"` SecretSet bool `json:"secret_set"` EventTypes string `json:"event_types"` Enabled bool `json:"enabled"` SortOrder int `json:"sort_order"` CreatedAt string `json:"created_at"` UpdatedAt string `json:"updated_at"` } func toWorkloadNotificationRow(n store.WorkloadNotification) workloadNotificationRow { return workloadNotificationRow{ ID: n.ID, WorkloadID: n.WorkloadID, Name: n.Name, URL: n.URL, SecretSet: n.Secret != "", EventTypes: n.EventTypes, Enabled: n.Enabled, SortOrder: n.SortOrder, CreatedAt: n.CreatedAt, UpdatedAt: n.UpdatedAt, } } func (s *Server) listWorkloadNotifications(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") if _, err := s.store.GetWorkloadByID(id); err != nil { if errors.Is(err, store.ErrNotFound) { respondNotFound(w, "workload") return } respondError(w, http.StatusInternalServerError, "get workload") return } rows, err := s.store.ListWorkloadNotifications(id) if err != nil { respondError(w, http.StatusInternalServerError, "list workload notifications") return } out := make([]workloadNotificationRow, 0, len(rows)) for _, n := range rows { out = append(out, toWorkloadNotificationRow(n)) } respondJSON(w, http.StatusOK, out) } // workloadNotificationRequest is the POST/PUT body. Secret is the raw // plaintext webhook signing key; the server encrypts it at rest with // the global encryption key before INSERT. An empty Secret on UPDATE // leaves the stored secret untouched so the operator can edit the URL // or event filter without re-entering the secret each time. type workloadNotificationRequest struct { Name string `json:"name"` URL string `json:"url"` Secret string `json:"secret"` EventTypes string `json:"event_types"` Enabled *bool `json:"enabled"` SortOrder int `json:"sort_order"` } func (s *Server) createWorkloadNotification(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") if _, err := s.store.GetWorkloadByID(id); err != nil { if errors.Is(err, store.ErrNotFound) { respondNotFound(w, "workload") return } respondError(w, http.StatusInternalServerError, "get workload") return } var req workloadNotificationRequest if !decodeJSONStrict(w, r, &req) { return } req.URL = strings.TrimSpace(req.URL) req.Name = strings.TrimSpace(req.Name) if req.URL == "" { respondError(w, http.StatusBadRequest, "url is required") return } encSecret := "" if req.Secret != "" { v, err := crypto.Encrypt(s.encKey, req.Secret) if err != nil { slog.Error("workload notifications: encrypt secret", "workload", id, "error", err) respondError(w, http.StatusInternalServerError, "encrypt secret") return } encSecret = v } enabled := true if req.Enabled != nil { enabled = *req.Enabled } created, err := s.store.CreateWorkloadNotification(store.WorkloadNotification{ WorkloadID: id, Name: req.Name, URL: req.URL, Secret: encSecret, EventTypes: req.EventTypes, Enabled: enabled, SortOrder: req.SortOrder, }) if err != nil { slog.Error("workload notifications: create", "workload", id, "error", err) respondError(w, http.StatusInternalServerError, "create workload notification") return } respondJSON(w, http.StatusCreated, toWorkloadNotificationRow(created)) } func (s *Server) updateWorkloadNotification(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") nid := chi.URLParam(r, "nid") if _, err := s.store.GetWorkloadByID(id); err != nil { if errors.Is(err, store.ErrNotFound) { respondNotFound(w, "workload") return } respondError(w, http.StatusInternalServerError, "get workload") return } existing, err := s.store.GetWorkloadNotification(nid) if err != nil { if errors.Is(err, store.ErrNotFound) { respondNotFound(w, "workload_notification") return } respondError(w, http.StatusInternalServerError, "get workload_notification") return } if existing.WorkloadID != id { // Route mismatch — the row exists but under a different workload. // Return 404 rather than 403 so we don't leak the existence of // foreign rows to an unauthorised caller. respondNotFound(w, "workload_notification") return } var req workloadNotificationRequest if !decodeJSONStrict(w, r, &req) { return } req.URL = strings.TrimSpace(req.URL) req.Name = strings.TrimSpace(req.Name) if req.URL == "" { respondError(w, http.StatusBadRequest, "url is required") return } existing.Name = req.Name existing.URL = req.URL existing.EventTypes = req.EventTypes existing.SortOrder = req.SortOrder if req.Enabled != nil { existing.Enabled = *req.Enabled } // Empty Secret on UPDATE preserves the stored ciphertext — explicit // rotation requires sending the new plaintext. This avoids forcing // the operator to re-enter their secret on every URL edit. if req.Secret != "" { v, err := crypto.Encrypt(s.encKey, req.Secret) if err != nil { slog.Error("workload notifications: encrypt secret", "workload", id, "error", err) respondError(w, http.StatusInternalServerError, "encrypt secret") return } existing.Secret = v } if err := s.store.UpdateWorkloadNotification(existing); err != nil { if errors.Is(err, store.ErrNotFound) { respondNotFound(w, "workload_notification") return } slog.Error("workload notifications: update", "workload", id, "error", err) respondError(w, http.StatusInternalServerError, "update workload notification") return } respondJSON(w, http.StatusOK, toWorkloadNotificationRow(existing)) } func (s *Server) deleteWorkloadNotification(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") nid := chi.URLParam(r, "nid") existing, err := s.store.GetWorkloadNotification(nid) if err != nil { if errors.Is(err, store.ErrNotFound) { respondNotFound(w, "workload_notification") return } respondError(w, http.StatusInternalServerError, "get workload_notification") return } if existing.WorkloadID != id { respondNotFound(w, "workload_notification") return } if err := s.store.DeleteWorkloadNotification(nid); err != nil { if errors.Is(err, store.ErrNotFound) { respondNotFound(w, "workload_notification") return } slog.Error("workload notifications: delete", "workload", id, "error", err) respondError(w, http.StatusInternalServerError, "delete workload notification") return } respondJSON(w, http.StatusOK, map[string]any{"success": true}) }