package webhook import ( "context" "encoding/json" "errors" "fmt" "log/slog" "net/http" "strings" "github.com/go-chi/chi/v5" "github.com/alexei/tinyforge/internal/store" ) // DeployTriggerer is called when a webhook determines a deploy should happen. // Same interface as registry.DeployTriggerer — kept separate to avoid import cycles. type DeployTriggerer interface { TriggerDeploy(ctx context.Context, projectID, stageID, imageTag string) 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 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". Registry string // Owner is the namespace/org, e.g. "alexei". Owner string // Name is the repository name, e.g. "web-app-launcher". Name string // Tag is the image tag, e.g. "dev-abc123". Empty string means "latest". Tag string } // FullName returns "owner/name" (the image path without registry and tag). func (p ParsedImage) FullName() string { if p.Owner != "" { return p.Owner + "/" + p.Name } return p.Name } // ParseImageRef splits a full image reference into its components. // Accepted formats: // // registry.example.com/owner/name:tag // registry.example.com/owner/name // owner/name:tag // name:tag func ParseImageRef(ref string) (ParsedImage, error) { ref = strings.TrimSpace(ref) if ref == "" { return ParsedImage{}, fmt.Errorf("empty image reference") } var parsed ParsedImage // Split off tag. if idx := strings.LastIndex(ref, ":"); idx != -1 { // Make sure the colon is not inside the registry host (e.g. "localhost:5000/img"). afterColon := ref[idx+1:] if !strings.Contains(afterColon, "/") { parsed.Tag = afterColon ref = ref[:idx] } } parts := strings.Split(ref, "/") switch len(parts) { case 1: // "name" parsed.Name = parts[0] case 2: // "owner/name" parsed.Owner = parts[0] parsed.Name = parts[1] default: // "registry/owner/name" or "registry/owner/sub/name" — first segment is registry. parsed.Registry = parts[0] parsed.Owner = strings.Join(parts[1:len(parts)-1], "/") parsed.Name = parts[len(parts)-1] } if parsed.Name == "" { return ParsedImage{}, fmt.Errorf("invalid image reference: missing name in %q", ref) } return parsed, nil } // Handler is the HTTP handler for webhook requests. type Handler struct { store *store.Store deployer DeployTriggerer sites SiteSyncTriggerer } // 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} } // 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 } // respondWebhookJSON writes a JSON response for webhook handlers. func respondWebhookJSON(w http.ResponseWriter, status int, data any) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) json.NewEncoder(w).Encode(data) //nolint:errcheck } // respondWebhookError writes a JSON error response for webhook handlers. func respondWebhookError(w http.ResponseWriter, status int, msg string) { respondWebhookJSON(w, status, map[string]any{"success": false, "error": msg}) } // 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() secret := chi.URLParam(r, "secret") if secret == "" { http.NotFound(w, r) return } project, err := h.store.GetProjectByWebhookSecret(secret) if err != nil { if errors.Is(err, store.ErrNotFound) { http.NotFound(w, r) return } slog.Error("webhook: project lookup failed", "error", err) http.NotFound(w, r) return } var payload Payload if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { respondWebhookError(w, http.StatusBadRequest, "invalid JSON payload") return } if payload.Image == "" { respondWebhookError(w, http.StatusBadRequest, "missing image field") return } parsed, err := ParseImageRef(payload.Image) if err != nil { respondWebhookError(w, http.StatusBadRequest, "invalid image reference") return } if parsed.Tag == "" { parsed.Tag = "latest" } // 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 { 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 } 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, }) return } if err := h.deployer.TriggerDeploy(ctx, project.ID, stage.ID, parsed.Tag); err != nil { slog.Error("webhook: deploy trigger failed", "error", err) respondWebhookError(w, http.StatusInternalServerError, "deploy trigger failed") 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, }) } // 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 { if errors.Is(err, store.ErrNotFound) { http.NotFound(w, r) return } slog.Error("webhook: site lookup failed", "error", err) http.NotFound(w, r) return } // 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 } // Body is optional — decode best-effort. var payload SitePayload if r.ContentLength > 0 { _ = json.NewDecoder(r.Body).Decode(&payload) } 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, }) }