package api import ( "crypto/rand" "encoding/hex" "errors" "log/slog" "net/http" "github.com/go-chi/chi/v5" "github.com/alexei/tinyforge/internal/store" ) // generateWebhookSecret returns a 256-bit hex-encoded random token. Mirrors // the helper in internal/store; kept here to avoid an import cycle and so the // rotation handlers don't pretend to use uuid for what is really a secret. func generateWebhookSecret() string { b := make([]byte, 32) if _, err := rand.Read(b); err != nil { panic("crypto/rand failed: " + err.Error()) } return hex.EncodeToString(b) } // webhookURLResponse is the common payload returned by every webhook endpoint. // Clients never see raw secrets except at issue/rotate time via these fields; // the URL shape is "/api/webhook/..." so callers can prepend their own origin. type webhookURLResponse struct { WebhookURL string `json:"webhook_url"` WebhookSecret string `json:"webhook_secret"` HasSigningSecret bool `json:"has_signing_secret"` WebhookRequireSignature bool `json:"webhook_require_signature"` } // signingSecretResponse is returned when a signing secret is issued or rotated. type signingSecretResponse struct { SigningSecret string `json:"signing_secret"` } // signingToggleRequest is the body of the require-signature toggle endpoint. type signingToggleRequest struct { RequireSignature bool `json:"require_signature"` } // getProjectWebhook handles GET /api/projects/{id}/webhook. // Returns the project's webhook URL + secret, generating one lazily if the // project predates the per-project webhook migration. func (s *Server) getProjectWebhook(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") secret, err := s.store.EnsureProjectWebhookSecret(id) if err != nil { if errors.Is(err, store.ErrNotFound) { respondNotFound(w, "project") return } slog.Error("get project webhook: ensure secret", "project", id, "error", err) respondError(w, http.StatusInternalServerError, "failed to get webhook secret") return } project, err := s.store.GetProjectByID(id) if err != nil { respondError(w, http.StatusInternalServerError, "failed to get project") return } respondJSON(w, http.StatusOK, webhookURLResponse{ WebhookURL: "/api/webhook/" + secret, WebhookSecret: secret, HasSigningSecret: project.WebhookSigningSecret != "", WebhookRequireSignature: project.WebhookRequireSignature, }) } // regenerateProjectSigningSecret handles POST /api/projects/{id}/webhook/signing-secret/regenerate. // Issues a fresh HMAC signing secret for inbound webhook verification. The // secret is returned exactly once — the UI is responsible for letting the // user copy it into their CI configuration. func (s *Server) regenerateProjectSigningSecret(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") if _, err := s.store.GetProjectByID(id); err != nil { if errors.Is(err, store.ErrNotFound) { respondNotFound(w, "project") return } respondError(w, http.StatusInternalServerError, "failed to get project") return } secret := generateWebhookSecret() if err := s.store.SetProjectWebhookSigningSecret(id, secret); err != nil { slog.Error("rotate project signing secret", "project", id, "error", err) respondError(w, http.StatusInternalServerError, "failed to rotate signing secret") return } slog.Info("project webhook signing secret rotated", "project", id) respondJSON(w, http.StatusOK, signingSecretResponse{SigningSecret: secret}) } // disableProjectSigningSecret handles DELETE /api/projects/{id}/webhook/signing-secret. // Clears the HMAC signing secret and disables enforcement. func (s *Server) disableProjectSigningSecret(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") if err := s.store.SetProjectWebhookSigningSecret(id, ""); err != nil { if errors.Is(err, store.ErrNotFound) { respondNotFound(w, "project") return } respondError(w, http.StatusInternalServerError, "failed to clear signing secret") return } if err := s.store.SetProjectWebhookRequireSignature(id, false); err != nil { slog.Warn("disable project require_signature", "project", id, "error", err) } respondJSON(w, http.StatusOK, map[string]bool{"success": true}) } // updateProjectSigningRequirement handles PUT /api/projects/{id}/webhook/require-signature. // Toggles whether unsigned/invalidly-signed inbound webhook requests are // rejected with 401. func (s *Server) updateProjectSigningRequirement(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") var req signingToggleRequest if !decodeJSON(w, r, &req) { return } if req.RequireSignature { project, err := s.store.GetProjectByID(id) if err != nil { if errors.Is(err, store.ErrNotFound) { respondNotFound(w, "project") return } respondError(w, http.StatusInternalServerError, "failed to get project") return } if project.WebhookSigningSecret == "" { respondError(w, http.StatusBadRequest, "issue a signing secret before enabling enforcement") return } } if err := s.store.SetProjectWebhookRequireSignature(id, req.RequireSignature); err != nil { if errors.Is(err, store.ErrNotFound) { respondNotFound(w, "project") return } respondError(w, http.StatusInternalServerError, "failed to update setting") return } respondJSON(w, http.StatusOK, map[string]bool{"success": true}) } // regenerateProjectWebhook handles POST /api/projects/{id}/webhook/regenerate. // Rotates the project's webhook secret, invalidating the old URL. func (s *Server) regenerateProjectWebhook(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") // Verify project exists before rotating. if _, err := s.store.GetProjectByID(id); err != nil { if errors.Is(err, store.ErrNotFound) { respondNotFound(w, "project") return } slog.Error("regenerate project webhook: lookup", "project", id, "error", err) respondError(w, http.StatusInternalServerError, "failed to get project") return } secret := generateWebhookSecret() if err := s.store.SetProjectWebhookSecret(id, secret); err != nil { slog.Error("regenerate project webhook: set secret", "project", id, "error", err) respondError(w, http.StatusInternalServerError, "failed to rotate webhook secret") return } slog.Info("project webhook secret rotated", "project", id) respondJSON(w, http.StatusOK, webhookURLResponse{ WebhookURL: "/api/webhook/" + secret, WebhookSecret: secret, }) } // getStaticSiteWebhook handles GET /api/sites/{id}/webhook. func (s *Server) getStaticSiteWebhook(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") secret, err := s.store.EnsureStaticSiteWebhookSecret(id) if err != nil { if errors.Is(err, store.ErrNotFound) { respondNotFound(w, "static site") return } slog.Error("get site webhook: ensure secret", "site", id, "error", err) respondError(w, http.StatusInternalServerError, "failed to get webhook secret") return } site, err := s.store.GetStaticSiteByID(id) if err != nil { respondError(w, http.StatusInternalServerError, "failed to get static site") return } respondJSON(w, http.StatusOK, webhookURLResponse{ WebhookURL: "/api/webhook/sites/" + secret, WebhookSecret: secret, HasSigningSecret: site.WebhookSigningSecret != "", WebhookRequireSignature: site.WebhookRequireSignature, }) } // regenerateStaticSiteSigningSecret handles POST /api/sites/{id}/webhook/signing-secret/regenerate. func (s *Server) regenerateStaticSiteSigningSecret(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") if _, err := s.store.GetStaticSiteByID(id); err != nil { if errors.Is(err, store.ErrNotFound) { respondNotFound(w, "static site") return } respondError(w, http.StatusInternalServerError, "failed to get static site") return } secret := generateWebhookSecret() if err := s.store.SetStaticSiteWebhookSigningSecret(id, secret); err != nil { slog.Error("rotate site signing secret", "site", id, "error", err) respondError(w, http.StatusInternalServerError, "failed to rotate signing secret") return } slog.Info("static site webhook signing secret rotated", "site", id) respondJSON(w, http.StatusOK, signingSecretResponse{SigningSecret: secret}) } // disableStaticSiteSigningSecret handles DELETE /api/sites/{id}/webhook/signing-secret. func (s *Server) disableStaticSiteSigningSecret(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") if err := s.store.SetStaticSiteWebhookSigningSecret(id, ""); err != nil { if errors.Is(err, store.ErrNotFound) { respondNotFound(w, "static site") return } respondError(w, http.StatusInternalServerError, "failed to clear signing secret") return } if err := s.store.SetStaticSiteWebhookRequireSignature(id, false); err != nil { slog.Warn("disable site require_signature", "site", id, "error", err) } respondJSON(w, http.StatusOK, map[string]bool{"success": true}) } // updateStaticSiteSigningRequirement handles PUT /api/sites/{id}/webhook/require-signature. func (s *Server) updateStaticSiteSigningRequirement(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") var req signingToggleRequest if !decodeJSON(w, r, &req) { return } if req.RequireSignature { site, err := s.store.GetStaticSiteByID(id) if err != nil { if errors.Is(err, store.ErrNotFound) { respondNotFound(w, "static site") return } respondError(w, http.StatusInternalServerError, "failed to get static site") return } if site.WebhookSigningSecret == "" { respondError(w, http.StatusBadRequest, "issue a signing secret before enabling enforcement") return } } if err := s.store.SetStaticSiteWebhookRequireSignature(id, req.RequireSignature); err != nil { if errors.Is(err, store.ErrNotFound) { respondNotFound(w, "static site") return } respondError(w, http.StatusInternalServerError, "failed to update setting") return } respondJSON(w, http.StatusOK, map[string]bool{"success": true}) } // regenerateStaticSiteWebhook handles POST /api/sites/{id}/webhook/regenerate. func (s *Server) regenerateStaticSiteWebhook(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") if _, err := s.store.GetStaticSiteByID(id); err != nil { if errors.Is(err, store.ErrNotFound) { respondNotFound(w, "static site") return } slog.Error("regenerate site webhook: lookup", "site", id, "error", err) respondError(w, http.StatusInternalServerError, "failed to get static site") return } secret := generateWebhookSecret() if err := s.store.SetStaticSiteWebhookSecret(id, secret); err != nil { slog.Error("regenerate site webhook: set secret", "site", id, "error", err) respondError(w, http.StatusInternalServerError, "failed to rotate webhook secret") return } slog.Info("static site webhook secret rotated", "site", id) respondJSON(w, http.StatusOK, webhookURLResponse{ WebhookURL: "/api/webhook/sites/" + secret, WebhookSecret: secret, }) }