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"` } // 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 } respondJSON(w, http.StatusOK, webhookURLResponse{ WebhookURL: "/api/webhook/" + secret, WebhookSecret: secret, }) } // 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 } respondJSON(w, http.StatusOK, webhookURLResponse{ WebhookURL: "/api/webhook/sites/" + secret, WebhookSecret: secret, }) } // 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, }) }