// Package api: event-trigger HTTP handlers. The dispatcher itself // lives in internal/events; this file is the REST surface that lets // operators create, edit, and test triggers from the UI. package api import ( "context" "errors" "net" "net/http" "net/url" "regexp" "strconv" "time" "github.com/go-chi/chi/v5" "github.com/alexei/tinyforge/internal/events" "github.com/alexei/tinyforge/internal/notify" "github.com/alexei/tinyforge/internal/store" ) // triggerInput is the JSON shape accepted by POST + PATCH. Pointers // distinguish "absent" from a zero/empty value so PATCH can leave a // field unchanged. Required fields on POST are validated explicitly. type triggerInput struct { Name *string `json:"name"` FilterSeverity *string `json:"filter_severity"` FilterSource *string `json:"filter_source"` FilterMessageRegex *string `json:"filter_message_regex"` ActionType *string `json:"action_type"` ActionTarget *string `json:"action_target"` ActionSecret *string `json:"action_secret"` // omit = leave unchanged; "" = clear Enabled *bool `json:"enabled"` } // actionSecretPlaceholder is what we return on read to signal "a secret // is configured" without exposing the actual value. The edit page // preserves this placeholder verbatim (or replaces it with a new value) // — the API treats the placeholder as "no change" on PATCH. This is // the same shape Stripe / GitHub use for their secret read APIs. const actionSecretPlaceholder = "********" // listEventTriggers handles GET /api/event-triggers. Secrets are // redacted to avoid exposing them on read; the edit page shows a // "configured" indicator when a placeholder is present. func (s *Server) listEventTriggers(w http.ResponseWriter, r *http.Request) { out, err := s.store.ListEventTriggers() if err != nil { respondError(w, http.StatusInternalServerError, "list event triggers") return } for i := range out { out[i] = redactTriggerSecret(out[i]) } respondJSON(w, http.StatusOK, out) } // getEventTrigger handles GET /api/event-triggers/{id}. func (s *Server) getEventTrigger(w http.ResponseWriter, r *http.Request) { id, ok := parseTriggerID(w, r) if !ok { return } t, err := s.store.GetEventTrigger(id) if err != nil { mapStoreError(w, err, "event trigger") return } respondJSON(w, http.StatusOK, redactTriggerSecret(t)) } // createEventTrigger handles POST /api/event-triggers. func (s *Server) createEventTrigger(w http.ResponseWriter, r *http.Request) { var in triggerInput if !decodeJSON(w, r, &in) { return } t := store.EventTrigger{ Name: derefString(in.Name), FilterSeverity: derefString(in.FilterSeverity), FilterSource: derefString(in.FilterSource), FilterMessageRegex: derefString(in.FilterMessageRegex), ActionType: firstNonEmpty(derefString(in.ActionType), store.EventTriggerActionWebhook), ActionTarget: derefString(in.ActionTarget), ActionSecret: derefString(in.ActionSecret), Enabled: in.Enabled == nil || *in.Enabled, } if msg := validateTrigger(t); msg != "" { respondError(w, http.StatusBadRequest, msg) return } out, err := s.store.CreateEventTrigger(t) if err != nil { // CreateEventTrigger returns validation-shaped errors plus // raw DB errors. Validation already ran above, so anything // here is a server-side problem — surface as 500 and avoid // echoing driver text to the client. respondError(w, http.StatusInternalServerError, "create event trigger") return } respondJSON(w, http.StatusCreated, redactTriggerSecret(out)) } // updateEventTrigger handles PATCH /api/event-triggers/{id}. Each // field on the input is optional (pointer); absent fields are left // unchanged. ActionSecret receives special treatment so the read-side // placeholder round-trips safely. func (s *Server) updateEventTrigger(w http.ResponseWriter, r *http.Request) { id, ok := parseTriggerID(w, r) if !ok { return } existing, err := s.store.GetEventTrigger(id) if err != nil { mapStoreError(w, err, "event trigger") return } var in triggerInput if !decodeJSON(w, r, &in) { return } if in.Name != nil { existing.Name = *in.Name } if in.FilterSeverity != nil { existing.FilterSeverity = *in.FilterSeverity } if in.FilterSource != nil { existing.FilterSource = *in.FilterSource } if in.FilterMessageRegex != nil { existing.FilterMessageRegex = *in.FilterMessageRegex } if in.ActionType != nil && *in.ActionType != "" { existing.ActionType = *in.ActionType } if in.ActionTarget != nil { existing.ActionTarget = *in.ActionTarget } // Secret round-trip: the read API returns a placeholder when a // secret is configured. If the client echoes the placeholder back // unchanged we leave the stored secret alone; any other value // (including the empty string) is treated as a deliberate update. if in.ActionSecret != nil && *in.ActionSecret != actionSecretPlaceholder { existing.ActionSecret = *in.ActionSecret } if in.Enabled != nil { existing.Enabled = *in.Enabled } if msg := validateTrigger(existing); msg != "" { respondError(w, http.StatusBadRequest, msg) return } out, err := s.store.UpdateEventTrigger(existing) if err != nil { if errors.Is(err, store.ErrNotFound) { respondNotFound(w, "event trigger") return } respondError(w, http.StatusInternalServerError, "update event trigger") return } respondJSON(w, http.StatusOK, redactTriggerSecret(out)) } // deleteEventTrigger handles DELETE /api/event-triggers/{id}. func (s *Server) deleteEventTrigger(w http.ResponseWriter, r *http.Request) { id, ok := parseTriggerID(w, r) if !ok { return } if err := s.store.DeleteEventTrigger(id); err != nil { mapStoreError(w, err, "event trigger") return } w.WriteHeader(http.StatusNoContent) } // testEventTrigger handles POST /api/event-triggers/{id}/test. Sends // a real TriggerWebhookPayload to the action target so receivers see // the same shape they'll see at runtime. Routes through the dedicated // SendSyncForTestPayload path that preserves the payload through the // HMAC+HTTP core unchanged. func (s *Server) testEventTrigger(w http.ResponseWriter, r *http.Request) { id, ok := parseTriggerID(w, r) if !ok { return } t, err := s.store.GetEventTrigger(id) if err != nil { mapStoreError(w, err, "event trigger") return } if t.ActionType != store.EventTriggerActionWebhook { respondError(w, http.StatusBadRequest, "action_type not testable") return } now := time.Now().UTC().Format(time.RFC3339) payload := events.TriggerWebhookPayload{ Type: "event_trigger", TriggerID: t.ID, Trigger: t.Name, Event: events.EventLogPayload{ ID: -1, Source: "test", Severity: "info", Message: "Test event from Tinyforge — trigger=" + t.Name, Metadata: `{"synthetic":true}`, CreatedAt: now, }, Timestamp: now, } ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) defer cancel() result := s.notifier.SendSyncForTestPayload(ctx, t.ActionTarget, t.ActionSecret, notify.TierEventTrigger, "event_trigger", payload) respondJSON(w, http.StatusOK, result) } // validateTrigger runs the full set of invariants over a fully-merged // trigger row. Called by both create and update so the contract is // enforced once. Returns an empty string for a valid trigger. func validateTrigger(t store.EventTrigger) string { if t.Name == "" { return "name is required" } if t.ActionType != "" && t.ActionType != store.EventTriggerActionWebhook { return "action_type must be 'webhook'" } if t.ActionTarget == "" { return "action_target is required" } if msg := validateWebhookURL(t.ActionTarget); msg != "" { return msg } if t.FilterMessageRegex != "" { if _, err := regexp.Compile(t.FilterMessageRegex); err != nil { return "filter_message_regex invalid: " + err.Error() } } return "" } // validateWebhookURL guards against the most common SSRF vectors that // admin-controlled webhook URLs enable: non-http(s) schemes, missing // host, and internal-network targets (loopback / link-local / RFC1918 // when the hostname resolves to a literal). Hostname-based lookups // are NOT resolved here — DNS rebinding is out of scope and would // require enforcement at dispatch time too. Admin gating remains the // primary control; this is defense-in-depth. func validateWebhookURL(raw string) string { u, err := url.Parse(raw) if err != nil { return "action_target invalid URL: " + err.Error() } if u.Scheme != "http" && u.Scheme != "https" { return "action_target must be http:// or https://" } host := u.Hostname() if host == "" { return "action_target missing host" } // Literal-IP guard: block loopback / link-local / unspecified // addresses outright. RFC1918 private ranges are intentionally // allowed since same-LAN receivers are a legitimate Tinyforge // deployment pattern. if ip := net.ParseIP(host); ip != nil { if ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() || ip.IsUnspecified() { return "action_target points at a reserved/loopback address" } } return "" } // redactTriggerSecret returns a copy of t with ActionSecret replaced // by the placeholder string when a secret is configured. Empty secret // stays empty so the UI can distinguish "no signing" from "signing // configured." func redactTriggerSecret(t store.EventTrigger) store.EventTrigger { if t.ActionSecret != "" { t.ActionSecret = actionSecretPlaceholder } return t } // mapStoreError translates a store-layer error into an HTTP status + // generic message. ErrNotFound → 404; everything else → 500 without // echoing driver text to the client (avoids leaking schema details // or transient error states to API consumers). func mapStoreError(w http.ResponseWriter, err error, resource string) { if errors.Is(err, store.ErrNotFound) { respondNotFound(w, resource) return } respondError(w, http.StatusInternalServerError, "get "+resource) } func parseTriggerID(w http.ResponseWriter, r *http.Request) (int64, bool) { raw := chi.URLParam(r, "id") id, err := strconv.ParseInt(raw, 10, 64) if err != nil || id <= 0 { respondError(w, http.StatusBadRequest, "invalid event trigger id") return 0, false } return id, true } func derefString(p *string) string { if p == nil { return "" } return *p } func firstNonEmpty(a, b string) string { if a != "" { return a } return b }