Files
tiny-forge/internal/api/webhooks.go
T
alexei.dolgolyov 0632f512e6
Build / build (push) Successful in 10m25s
feat(webhook): per-project and per-site webhook URLs
Replace the single global webhook secret with entity-scoped secrets stored
on each project and static site. Webhook-driven project autocreate is
removed — projects must exist before their URL can trigger deploys.

Also wires static-site webhooks (sync_trigger=push|tag), turning the
previously inert "push" trigger into a functional one: POST the site's
webhook URL from a Git provider and Tinyforge re-syncs on matching refs.

- Adds webhook_secret columns + unique indexes to projects and static_sites
- Per-entity GET/regenerate endpoints under /api/projects/{id}/webhook
  and /api/sites/{id}/webhook (admin-only)
- Removes /api/settings/webhook-url and the global webhook panel
- Reusable WebhookPanel Svelte component on both detail pages, i18n in en/ru
- Tests for matcher (siteRefMatches, ParseImageRef) and handler (project
  match/mismatch/404 and site push/manual/branch-skip)
2026-04-23 15:18:19 +03:00

123 lines
3.9 KiB
Go

package api
import (
"errors"
"log/slog"
"net/http"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
"github.com/alexei/tinyforge/internal/store"
)
// 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 := uuid.New().String()
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 := uuid.New().String()
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,
})
}