feat(webhook): per-project and per-site webhook URLs
Build / build (push) Successful in 10m25s

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)
This commit is contained in:
2026-04-23 15:18:19 +03:00
parent e08acf5c0e
commit 0632f512e6
21 changed files with 1119 additions and 363 deletions
+153 -81
View File
@@ -2,17 +2,15 @@ package webhook
import (
"context"
"crypto/subtle"
"encoding/json"
"errors"
"fmt"
"log/slog"
"net/http"
"strings"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
"github.com/alexei/tinyforge/internal/docker"
"github.com/alexei/tinyforge/internal/store"
)
@@ -22,18 +20,27 @@ type DeployTriggerer interface {
TriggerDeploy(ctx context.Context, projectID, stageID, imageTag string) error
}
// ImageInspector abstracts Docker image inspection for testability.
type ImageInspector interface {
InspectImage(ctx context.Context, imageRef string) (docker.ImageInfo, error)
// SiteSyncTriggerer is called when a static-site webhook determines a sync
// should happen. The manager handles the actual git-pull + redeploy.
type SiteSyncTriggerer interface {
Deploy(ctx context.Context, siteID string, force bool) error
}
// Payload is the expected JSON body for a webhook request.
// Payload is the expected JSON body for a project webhook request.
type Payload struct {
// Image is the full image reference including tag, e.g.
// "git.dolgolyov-family.by/alexei/web-app-launcher:dev-abc123".
Image string `json:"image"`
}
// SitePayload is the expected JSON body for a static-site webhook request.
// Callers point Gitea/GitHub/GitLab webhooks at the site URL; only the ref
// matters for branch filtering. Body is optional — an empty body triggers
// a sync using the site's configured branch.
type SitePayload struct {
Ref string `json:"ref"` // e.g. "refs/heads/main"; optional
}
// ParsedImage holds the components extracted from a full image reference string.
type ParsedImage struct {
// Registry is the hostname, e.g. "git.dolgolyov-family.by".
@@ -104,23 +111,34 @@ func ParseImageRef(ref string) (ParsedImage, error) {
// Handler is the HTTP handler for webhook requests.
type Handler struct {
store *store.Store
deployer DeployTriggerer
inspector ImageInspector
store *store.Store
deployer DeployTriggerer
sites SiteSyncTriggerer
}
// NewHandler creates a new webhook Handler.
func NewHandler(st *store.Store, deployer DeployTriggerer, inspector ImageInspector) *Handler {
return &Handler{
store: st,
deployer: deployer,
inspector: inspector,
}
// NewHandler creates a new webhook Handler. The sites triggerer is optional
// and may be nil (site webhooks will return 404).
func NewHandler(st *store.Store, deployer DeployTriggerer, sites SiteSyncTriggerer) *Handler {
return &Handler{store: st, deployer: deployer, sites: sites}
}
// Route returns a chi router with the webhook endpoint mounted.
// SetSiteSyncTriggerer injects the static-site manager after construction.
// The site manager depends on the store + docker client, which are wired up
// in the same startup path as the handler; this setter lets callers defer the
// dependency if needed.
func (h *Handler) SetSiteSyncTriggerer(s SiteSyncTriggerer) {
h.sites = s
}
// Route returns a chi router with the webhook endpoints mounted.
//
// Routes:
//
// POST /{secret} — per-project deploy trigger
// POST /sites/{secret} — per-site sync trigger
func (h *Handler) Route() chi.Router {
r := chi.NewRouter()
r.Post("/sites/{secret}", h.handleSiteWebhook)
r.Post("/{secret}", h.handleWebhook)
return r
}
@@ -137,9 +155,13 @@ func respondWebhookError(w http.ResponseWriter, status int, msg string) {
respondWebhookJSON(w, status, map[string]any{"success": false, "error": msg})
}
// handleWebhook processes an incoming webhook request.
// URL format: POST /api/webhook/{secret-uuid}
// Returns 404 for invalid secrets (no information leak).
// handleWebhook processes an incoming project webhook request.
//
// URL: POST /api/webhook/{secret}
//
// The secret identifies exactly one project. Stage routing is delegated to
// the project's configured stages (tag_pattern match). Returns 404 for
// unknown secrets (no information leak).
func (h *Handler) handleWebhook(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
@@ -149,20 +171,17 @@ func (h *Handler) handleWebhook(w http.ResponseWriter, r *http.Request) {
return
}
// Validate the webhook secret against stored settings.
settings, err := h.store.GetSettings()
project, err := h.store.GetProjectByWebhookSecret(secret)
if err != nil {
slog.Error("webhook: failed to read settings", "error", err)
if errors.Is(err, store.ErrNotFound) {
http.NotFound(w, r)
return
}
slog.Error("webhook: project lookup failed", "error", err)
http.NotFound(w, r)
return
}
if settings.WebhookSecret == "" || subtle.ConstantTimeCompare([]byte(settings.WebhookSecret), []byte(secret)) != 1 {
http.NotFound(w, r)
return
}
// Parse the request body.
var payload Payload
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
respondWebhookError(w, http.StatusBadRequest, "invalid JSON payload")
@@ -180,37 +199,48 @@ func (h *Handler) handleWebhook(w http.ResponseWriter, r *http.Request) {
return
}
// Default tag to "latest" if omitted.
if parsed.Tag == "" {
parsed.Tag = "latest"
}
slog.Info("webhook: received push", "image", parsed.FullName(), "tag", parsed.Tag)
// Look up a matching project by image name.
project, stage, found, err := FindProjectAndStage(ctx, h.store, parsed)
if err != nil {
slog.Error("webhook: lookup error", "error", err)
respondWebhookError(w, http.StatusInternalServerError, "internal error")
// Guardrail: refuse payloads whose image doesn't match the project's
// configured image. Not a security control (the secret already scopes
// access) — just a misconfiguration check that prevents accidental
// cross-project deploys from a misaimed CI pipeline.
if project.Image != "" && !imageMatches(project.Image, parsed.FullName()) {
slog.Warn("webhook: image mismatch",
"project", project.Name, "expected", project.Image, "received", parsed.FullName())
respondWebhookError(w, http.StatusBadRequest,
fmt.Sprintf("image %q does not match project image %q", parsed.FullName(), project.Image))
return
}
slog.Info("webhook: received push",
"project", project.Name, "image", parsed.FullName(), "tag", parsed.Tag)
stage, found, err := matchStage(h.store, project.ID, parsed.Tag)
if err != nil {
slog.Error("webhook: stage match failed", "project", project.Name, "error", err)
respondWebhookError(w, http.StatusInternalServerError, "internal error")
return
}
if !found {
// Unknown project — auto-create with defaults from image inspection.
slog.Info("webhook: unknown image, auto-creating project", "image", parsed.FullName())
project, stage, err = AutoCreateProject(ctx, h.store, h.inspector, parsed)
if err != nil {
slog.Error("webhook: auto-create failed", "error", err)
respondWebhookError(w, http.StatusInternalServerError, "failed to auto-create project")
return
}
slog.Info("webhook: auto-created project", "project", project.Name, "id", project.ID, "stage", stage.Name)
slog.Info("webhook: no stage matches tag",
"project", project.Name, "tag", parsed.Tag)
respondWebhookJSON(w, http.StatusOK, map[string]any{
"success": true, "deploy": false, "project": project.Name,
"reason": "no stage pattern matched tag",
})
return
}
// Only deploy if auto_deploy is enabled for the matched stage.
if !stage.AutoDeploy {
slog.Info("webhook: auto_deploy disabled, skipping", "project", project.Name, "stage", stage.Name)
respondWebhookJSON(w, http.StatusOK, map[string]any{"success": true, "deploy": false, "project": project.Name, "stage": stage.Name})
slog.Info("webhook: auto_deploy disabled, skipping",
"project", project.Name, "stage", stage.Name)
respondWebhookJSON(w, http.StatusOK, map[string]any{
"success": true, "deploy": false,
"project": project.Name, "stage": stage.Name,
})
return
}
@@ -220,44 +250,86 @@ func (h *Handler) handleWebhook(w http.ResponseWriter, r *http.Request) {
return
}
slog.Info("webhook: triggered deploy", "project", project.Name, "stage", stage.Name, "tag", parsed.Tag)
respondWebhookJSON(w, http.StatusOK, map[string]any{"success": true, "deploy": true, "project": project.Name, "stage": stage.Name, "tag": parsed.Tag})
slog.Info("webhook: triggered deploy",
"project", project.Name, "stage", stage.Name, "tag", parsed.Tag)
respondWebhookJSON(w, http.StatusOK, map[string]any{
"success": true, "deploy": true,
"project": project.Name, "stage": stage.Name, "tag": parsed.Tag,
})
}
// EnsureWebhookSecret checks whether a webhook secret exists in settings.
// If not, it generates a new UUID and stores it. Returns the current secret.
func EnsureWebhookSecret(st *store.Store) (string, error) {
settings, err := st.GetSettings()
// handleSiteWebhook processes an incoming static-site webhook request.
//
// URL: POST /api/webhook/sites/{secret}
//
// The secret identifies exactly one static site. If the payload includes a
// ref (Git push event), it must match the site's configured branch (when the
// site's sync_trigger is "push"). For tag-based sync, the ref must match the
// stored tag pattern. Manual-trigger sites ignore webhooks entirely.
func (h *Handler) handleSiteWebhook(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
if h.sites == nil {
http.NotFound(w, r)
return
}
secret := chi.URLParam(r, "secret")
if secret == "" {
http.NotFound(w, r)
return
}
site, err := h.store.GetStaticSiteByWebhookSecret(secret)
if err != nil {
return "", fmt.Errorf("get settings: %w", err)
if errors.Is(err, store.ErrNotFound) {
http.NotFound(w, r)
return
}
slog.Error("webhook: site lookup failed", "error", err)
http.NotFound(w, r)
return
}
if settings.WebhookSecret != "" {
return settings.WebhookSecret, nil
// Manual sites do not auto-sync via webhook. Return success but skip.
if site.SyncTrigger == "manual" {
slog.Info("webhook: site sync_trigger=manual, skipping",
"site", site.Name)
respondWebhookJSON(w, http.StatusOK, map[string]any{
"success": true, "sync": false, "site": site.Name,
"reason": "sync_trigger is manual",
})
return
}
settings.WebhookSecret = uuid.New().String()
if err := st.UpdateSettings(settings); err != nil {
return "", fmt.Errorf("store webhook secret: %w", err)
// Body is optional — decode best-effort.
var payload SitePayload
if r.ContentLength > 0 {
_ = json.NewDecoder(r.Body).Decode(&payload)
}
slog.Info("webhook: generated new secret")
return settings.WebhookSecret, nil
}
// RegenerateWebhookSecret generates a new webhook secret UUID, replacing and
// invalidating the old one. Returns the new secret.
func RegenerateWebhookSecret(st *store.Store) (string, error) {
settings, err := st.GetSettings()
if err != nil {
return "", fmt.Errorf("get settings: %w", err)
}
settings.WebhookSecret = uuid.New().String()
if err := st.UpdateSettings(settings); err != nil {
return "", fmt.Errorf("store webhook secret: %w", err)
}
slog.Info("webhook: regenerated secret")
return settings.WebhookSecret, nil
if payload.Ref != "" && !siteRefMatches(site, payload.Ref) {
slog.Info("webhook: site ref does not match configured branch/tag",
"site", site.Name, "ref", payload.Ref,
"branch", site.Branch, "tag_pattern", site.TagPattern,
"trigger", site.SyncTrigger)
respondWebhookJSON(w, http.StatusOK, map[string]any{
"success": true, "sync": false, "site": site.Name,
"reason": "ref does not match configured branch or tag pattern",
})
return
}
// Fire and forget — sync may take a while (git fetch + container rebuild).
go func(siteID, siteName string) {
if err := h.sites.Deploy(context.Background(), siteID, false); err != nil {
slog.Error("webhook: site sync failed", "site", siteName, "error", err)
}
}(site.ID, site.Name)
_ = ctx
slog.Info("webhook: triggered site sync", "site", site.Name, "ref", payload.Ref)
respondWebhookJSON(w, http.StatusOK, map[string]any{
"success": true, "sync": true, "site": site.Name,
})
}