feat(webhook): inbound delivery audit log
Build / build (push) Successful in 10m35s

Persists every inbound webhook hit (project + site) so users can debug
"why didn't my deploy fire?" without grepping daemon logs. Surfaces a
14-day rolling history under the WebhookPanel on each project + site
detail page; refreshes every 30s while open. Daily cron prunes records
older than 14 days alongside the existing event log prune.

Schema:
- webhook_deliveries(id, target_type, target_id, target_name, received_at,
  source_ip, signature_state, status_code, outcome, detail, body_size)
- indexes on (target_type,target_id,received_at) and (received_at)

Backend:
- store: WebhookDelivery model + Insert/List/Prune helpers
- webhook/handler: deferred recordDelivery() captures the final outcome
  on every return path including HMAC rejects, image mismatch, no-stage,
  auto_deploy=false, and successful deploys; signatureStateFor()
  classifies "unconfigured" vs "missing" vs "invalid" vs "valid"
- api: GET /api/{projects,sites}/{id}/webhook/deliveries with
  parseLimit() helper (default 50, max 200)
- main: daily prune cron retains the last 14 days

Frontend:
- WebhookDeliveryLog.svelte: panel with refresh button, status code +
  outcome + signature badges, relative time tooltip-on-hover for
  absolute time, source IP column
- Mounted below WebhookPanel on project + site detail pages
- en/ru i18n strings for outcome/signature enums and column labels
This commit is contained in:
2026-05-07 02:40:39 +03:00
parent 831b5c1a43
commit 0f60a7a5db
12 changed files with 591 additions and 16 deletions
+2
View File
@@ -249,6 +249,7 @@ func (s *Server) Router() chi.Router {
r.Post("/webhook/signing-secret/regenerate", s.regenerateProjectSigningSecret)
r.Delete("/webhook/signing-secret", s.disableProjectSigningSecret)
r.Put("/webhook/require-signature", s.updateProjectSigningRequirement)
r.Get("/webhook/deliveries", s.listProjectWebhookDeliveries)
// Per-project outgoing-webhook signing & test.
r.Get("/notification-secret", s.getProjectNotificationSecret)
@@ -332,6 +333,7 @@ func (s *Server) Router() chi.Router {
r.Post("/webhook/signing-secret/regenerate", s.regenerateStaticSiteSigningSecret)
r.Delete("/webhook/signing-secret", s.disableStaticSiteSigningSecret)
r.Put("/webhook/require-signature", s.updateStaticSiteSigningRequirement)
r.Get("/webhook/deliveries", s.listStaticSiteWebhookDeliveries)
// Per-site outgoing-webhook signing & test.
r.Get("/notification-secret", s.getStaticSiteNotificationSecret)
+60
View File
@@ -6,6 +6,7 @@ import (
"errors"
"log/slog"
"net/http"
"strconv"
"github.com/go-chi/chi/v5"
@@ -248,6 +249,65 @@ func (s *Server) disableStaticSiteSigningSecret(w http.ResponseWriter, r *http.R
respondJSON(w, http.StatusOK, map[string]bool{"success": true})
}
// listProjectWebhookDeliveries handles GET /api/projects/{id}/webhook/deliveries.
// Returns the most recent webhook deliveries for the project so users can
// debug "why didn't my deploy fire?" without grepping daemon logs.
func (s *Server) listProjectWebhookDeliveries(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
}
limit := parseLimit(r.URL.Query().Get("limit"), 50, 200)
deliveries, err := s.store.ListWebhookDeliveriesByTarget("project", id, limit)
if err != nil {
slog.Error("list project webhook deliveries", "project", id, "error", err)
respondError(w, http.StatusInternalServerError, "failed to list deliveries")
return
}
respondJSON(w, http.StatusOK, deliveries)
}
// listStaticSiteWebhookDeliveries handles GET /api/sites/{id}/webhook/deliveries.
func (s *Server) listStaticSiteWebhookDeliveries(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
}
limit := parseLimit(r.URL.Query().Get("limit"), 50, 200)
deliveries, err := s.store.ListWebhookDeliveriesByTarget("site", id, limit)
if err != nil {
slog.Error("list site webhook deliveries", "site", id, "error", err)
respondError(w, http.StatusInternalServerError, "failed to list deliveries")
return
}
respondJSON(w, http.StatusOK, deliveries)
}
// parseLimit clamps a query-string limit to [1, max], falling back to def.
func parseLimit(raw string, def, max int) int {
if raw == "" {
return def
}
n, err := strconv.Atoi(raw)
if err != nil || n <= 0 {
return def
}
if n > max {
return max
}
return n
}
// updateStaticSiteSigningRequirement handles PUT /api/sites/{id}/webhook/require-signature.
func (s *Server) updateStaticSiteSigningRequirement(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
+18
View File
@@ -159,6 +159,24 @@ func (s *Store) runMigrations() error {
`ALTER TABLE projects ADD COLUMN webhook_require_signature INTEGER NOT NULL DEFAULT 0`,
`ALTER TABLE static_sites ADD COLUMN webhook_signing_secret TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE static_sites ADD COLUMN webhook_require_signature INTEGER NOT NULL DEFAULT 0`,
// Webhook delivery audit log (2026-05-07). Persists every inbound
// webhook request (project or site) with its outcome so users can
// debug "why didn't my deploy fire?" without grepping daemon logs.
`CREATE TABLE IF NOT EXISTS webhook_deliveries (
id INTEGER PRIMARY KEY AUTOINCREMENT,
target_type TEXT NOT NULL,
target_id TEXT NOT NULL DEFAULT '',
target_name TEXT NOT NULL DEFAULT '',
received_at TEXT NOT NULL DEFAULT (datetime('now')),
source_ip TEXT NOT NULL DEFAULT '',
signature_state TEXT NOT NULL DEFAULT '',
status_code INTEGER NOT NULL DEFAULT 0,
outcome TEXT NOT NULL DEFAULT '',
detail TEXT NOT NULL DEFAULT '',
body_size INTEGER NOT NULL DEFAULT 0
)`,
`CREATE INDEX IF NOT EXISTS idx_webhook_deliveries_target ON webhook_deliveries(target_type, target_id, received_at)`,
`CREATE INDEX IF NOT EXISTS idx_webhook_deliveries_received_at ON webhook_deliveries(received_at)`,
}
// Additive stack tables (2026-04-16). Created here rather than in the
+84
View File
@@ -0,0 +1,84 @@
package store
import (
"fmt"
)
// WebhookDelivery is one persisted inbound webhook hit. Recorded after the
// handler decides what to do so the row reflects the final outcome.
type WebhookDelivery struct {
ID int64 `json:"id"`
TargetType string `json:"target_type"` // "project" or "site"
TargetID string `json:"target_id"`
TargetName string `json:"target_name"`
ReceivedAt string `json:"received_at"`
SourceIP string `json:"source_ip"`
SignatureState string `json:"signature_state"` // "valid" / "invalid" / "missing" / "unconfigured"
StatusCode int `json:"status_code"`
Outcome string `json:"outcome"` // "deploy" / "skip" / "rejected" / etc.
Detail string `json:"detail"`
BodySize int `json:"body_size"`
}
// InsertWebhookDelivery persists a single webhook delivery record. Best-effort
// — failures here must not block the actual webhook handler, so callers
// should log and continue rather than propagate.
func (s *Store) InsertWebhookDelivery(d WebhookDelivery) error {
_, err := s.db.Exec(
`INSERT INTO webhook_deliveries
(target_type, target_id, target_name, source_ip, signature_state,
status_code, outcome, detail, body_size)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
d.TargetType, d.TargetID, d.TargetName, d.SourceIP, d.SignatureState,
d.StatusCode, d.Outcome, d.Detail, d.BodySize,
)
if err != nil {
return fmt.Errorf("insert webhook delivery: %w", err)
}
return nil
}
// ListWebhookDeliveriesByTarget returns the most recent N deliveries for a
// specific target. Used by the per-entity panel on the project / site detail
// pages.
func (s *Store) ListWebhookDeliveriesByTarget(targetType, targetID string, limit int) ([]WebhookDelivery, error) {
if limit <= 0 || limit > 200 {
limit = 50
}
rows, err := s.db.Query(
`SELECT id, target_type, target_id, target_name, received_at, source_ip,
signature_state, status_code, outcome, detail, body_size
FROM webhook_deliveries
WHERE target_type = ? AND target_id = ?
ORDER BY id DESC
LIMIT ?`,
targetType, targetID, limit,
)
if err != nil {
return nil, fmt.Errorf("query webhook deliveries: %w", err)
}
defer rows.Close()
out := []WebhookDelivery{}
for rows.Next() {
var d WebhookDelivery
if err := rows.Scan(&d.ID, &d.TargetType, &d.TargetID, &d.TargetName, &d.ReceivedAt,
&d.SourceIP, &d.SignatureState, &d.StatusCode, &d.Outcome, &d.Detail, &d.BodySize); err != nil {
return nil, fmt.Errorf("scan webhook delivery: %w", err)
}
out = append(out, d)
}
return out, rows.Err()
}
// PruneWebhookDeliveriesBefore deletes rows older than the given timestamp.
// Returns the number of rows removed. Run on the same daily cron as the
// event log to keep the table from growing without bound.
func (s *Store) PruneWebhookDeliveriesBefore(beforeTS string) (int64, error) {
res, err := s.db.Exec(`DELETE FROM webhook_deliveries WHERE received_at < ?`, beforeTS)
if err != nil {
return 0, fmt.Errorf("prune webhook deliveries: %w", err)
}
n, _ := res.RowsAffected()
return n, nil
}
+162 -16
View File
@@ -24,6 +24,62 @@ import (
// same header so existing CI integrations work unchanged.
const signatureHeader = "X-Hub-Signature-256"
// signature verification states recorded in the webhook delivery log.
const (
sigStateUnconfigured = "unconfigured"
sigStateMissing = "missing"
sigStateInvalid = "invalid"
sigStateValid = "valid"
)
// outcome values for the delivery log. Stable identifiers — frontend keys
// off these for badge colouring + i18n.
const (
outcomeDeploy = "deploy"
outcomeSkip = "skip"
outcomeRejected = "rejected"
outcomeNotFound = "not_found"
outcomeBadRequest = "bad_request"
outcomeError = "error"
)
// signatureStateFor classifies the HMAC verification result for the delivery
// log: distinguishes "no signing secret configured" from "secret configured
// but caller sent nothing" so users can spot mis-configured CIs.
func signatureStateFor(signingSecret, header string, verified, attempted bool) string {
if signingSecret == "" {
return sigStateUnconfigured
}
if header == "" {
return sigStateMissing
}
if attempted && verified {
return sigStateValid
}
return sigStateInvalid
}
// recordDelivery persists a single inbound webhook delivery as a best-effort
// audit record. Errors are logged but never propagate — the user-visible
// response must not be affected by audit-log churn.
func (h *Handler) recordDelivery(d store.WebhookDelivery) {
if err := h.store.InsertWebhookDelivery(d); err != nil {
slog.Warn("webhook: record delivery", "error", err)
}
}
// clientIP returns the most-trusted source IP for logging. Strips the
// Forwarded-For chain to its first hop and falls back to RemoteAddr.
func clientIP(r *http.Request) string {
if fwd := r.Header.Get("X-Forwarded-For"); fwd != "" {
if i := strings.IndexByte(fwd, ','); i >= 0 {
return strings.TrimSpace(fwd[:i])
}
return strings.TrimSpace(fwd)
}
return r.RemoteAddr
}
// verifyHMAC validates the X-Hub-Signature-256 header against the raw body
// using HMAC-SHA256. The function does the comparison in constant time.
//
@@ -244,8 +300,21 @@ func respondWebhookError(w http.ResponseWriter, status int, msg string) {
func (h *Handler) handleWebhook(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
// Build the audit record incrementally; record on every return path so
// users can debug "why didn't my deploy fire?" without grepping logs.
delivery := store.WebhookDelivery{
TargetType: "project",
SourceIP: clientIP(r),
SignatureState: sigStateUnconfigured,
StatusCode: http.StatusOK,
Outcome: outcomeSkip,
}
defer func() { h.recordDelivery(delivery) }()
secret := chi.URLParam(r, "secret")
if secret == "" {
delivery.StatusCode = http.StatusNotFound
delivery.Outcome = outcomeNotFound
http.NotFound(w, r)
return
}
@@ -253,50 +322,79 @@ func (h *Handler) handleWebhook(w http.ResponseWriter, r *http.Request) {
project, err := h.store.GetProjectByWebhookSecret(secret)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
delivery.StatusCode = http.StatusNotFound
delivery.Outcome = outcomeNotFound
delivery.Detail = "unknown webhook secret"
http.NotFound(w, r)
return
}
slog.Error("webhook: project lookup failed", "error", err)
delivery.StatusCode = http.StatusNotFound
delivery.Outcome = outcomeError
delivery.Detail = "lookup failed"
http.NotFound(w, r)
return
}
delivery.TargetID = project.ID
delivery.TargetName = project.Name
// Read body once so we can both verify HMAC and decode JSON.
body, err := io.ReadAll(io.LimitReader(r.Body, maxWebhookBodyBytes))
if err != nil {
delivery.StatusCode = http.StatusBadRequest
delivery.Outcome = outcomeBadRequest
delivery.Detail = "failed to read request body"
respondWebhookError(w, http.StatusBadRequest, "failed to read request body")
return
}
delivery.BodySize = len(body)
// HMAC enforcement: a configured signing secret + the require_signature
// flag together produce a hard reject on missing/invalid signatures.
// When the flag is off we still verify any submitted signature so a
// CI misconfiguration surfaces as a 401 rather than silent acceptance.
verified, attempted := verifyHMAC(project.WebhookSigningSecret, body, r.Header.Get(signatureHeader))
header := r.Header.Get(signatureHeader)
verified, attempted := verifyHMAC(project.WebhookSigningSecret, body, header)
delivery.SignatureState = signatureStateFor(project.WebhookSigningSecret, header, verified, attempted)
if project.WebhookRequireSignature && !verified {
slog.Warn("webhook: signature required but invalid/missing", "project", project.Name)
delivery.StatusCode = http.StatusUnauthorized
delivery.Outcome = outcomeRejected
delivery.Detail = "invalid or missing signature"
respondWebhookError(w, http.StatusUnauthorized, "invalid or missing signature")
return
}
if attempted && !verified {
slog.Warn("webhook: bad signature", "project", project.Name)
delivery.StatusCode = http.StatusUnauthorized
delivery.Outcome = outcomeRejected
delivery.Detail = "invalid signature"
respondWebhookError(w, http.StatusUnauthorized, "invalid signature")
return
}
var payload Payload
if err := json.Unmarshal(body, &payload); err != nil {
delivery.StatusCode = http.StatusBadRequest
delivery.Outcome = outcomeBadRequest
delivery.Detail = "invalid JSON payload"
respondWebhookError(w, http.StatusBadRequest, "invalid JSON payload")
return
}
if payload.Image == "" {
delivery.StatusCode = http.StatusBadRequest
delivery.Outcome = outcomeBadRequest
delivery.Detail = "missing image field"
respondWebhookError(w, http.StatusBadRequest, "missing image field")
return
}
parsed, err := ParseImageRef(payload.Image)
if err != nil {
delivery.StatusCode = http.StatusBadRequest
delivery.Outcome = outcomeBadRequest
delivery.Detail = "invalid image reference"
respondWebhookError(w, http.StatusBadRequest, "invalid image reference")
return
}
@@ -305,15 +403,13 @@ func (h *Handler) handleWebhook(w http.ResponseWriter, r *http.Request) {
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))
delivery.StatusCode = http.StatusBadRequest
delivery.Outcome = outcomeBadRequest
delivery.Detail = fmt.Sprintf("image %q does not match project image %q", parsed.FullName(), project.Image)
respondWebhookError(w, http.StatusBadRequest, delivery.Detail)
return
}
@@ -323,12 +419,16 @@ func (h *Handler) handleWebhook(w http.ResponseWriter, r *http.Request) {
stage, found, err := matchStage(h.store, project.ID, parsed.Tag)
if err != nil {
slog.Error("webhook: stage match failed", "project", project.Name, "error", err)
delivery.StatusCode = http.StatusInternalServerError
delivery.Outcome = outcomeError
delivery.Detail = "stage match failed"
respondWebhookError(w, http.StatusInternalServerError, "internal error")
return
}
if !found {
slog.Info("webhook: no stage matches tag",
"project", project.Name, "tag", parsed.Tag)
delivery.Detail = fmt.Sprintf("no stage matches tag %q", parsed.Tag)
respondWebhookJSON(w, http.StatusOK, map[string]any{
"success": true, "deploy": false, "project": project.Name,
"reason": "no stage pattern matched tag",
@@ -339,6 +439,7 @@ func (h *Handler) handleWebhook(w http.ResponseWriter, r *http.Request) {
if !stage.AutoDeploy {
slog.Info("webhook: auto_deploy disabled, skipping",
"project", project.Name, "stage", stage.Name)
delivery.Detail = fmt.Sprintf("stage %q has auto_deploy disabled", stage.Name)
respondWebhookJSON(w, http.StatusOK, map[string]any{
"success": true, "deploy": false,
"project": project.Name, "stage": stage.Name,
@@ -348,12 +449,17 @@ func (h *Handler) handleWebhook(w http.ResponseWriter, r *http.Request) {
if err := h.deployer.TriggerDeploy(ctx, project.ID, stage.ID, parsed.Tag); err != nil {
slog.Error("webhook: deploy trigger failed", "error", err)
delivery.StatusCode = http.StatusInternalServerError
delivery.Outcome = outcomeError
delivery.Detail = "deploy trigger failed: " + err.Error()
respondWebhookError(w, http.StatusInternalServerError, "deploy trigger failed")
return
}
slog.Info("webhook: triggered deploy",
"project", project.Name, "stage", stage.Name, "tag", parsed.Tag)
delivery.Outcome = outcomeDeploy
delivery.Detail = fmt.Sprintf("stage=%s tag=%s", stage.Name, parsed.Tag)
respondWebhookJSON(w, http.StatusOK, map[string]any{
"success": true, "deploy": true,
"project": project.Name, "stage": stage.Name, "tag": parsed.Tag,
@@ -371,13 +477,27 @@ func (h *Handler) handleWebhook(w http.ResponseWriter, r *http.Request) {
func (h *Handler) handleSiteWebhook(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
delivery := store.WebhookDelivery{
TargetType: "site",
SourceIP: clientIP(r),
SignatureState: sigStateUnconfigured,
StatusCode: http.StatusOK,
Outcome: outcomeSkip,
}
defer func() { h.recordDelivery(delivery) }()
if h.sites == nil {
delivery.StatusCode = http.StatusNotFound
delivery.Outcome = outcomeNotFound
delivery.Detail = "static site manager not configured"
http.NotFound(w, r)
return
}
secret := chi.URLParam(r, "secret")
if secret == "" {
delivery.StatusCode = http.StatusNotFound
delivery.Outcome = outcomeNotFound
http.NotFound(w, r)
return
}
@@ -385,18 +505,26 @@ func (h *Handler) handleSiteWebhook(w http.ResponseWriter, r *http.Request) {
site, err := h.store.GetStaticSiteByWebhookSecret(secret)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
delivery.StatusCode = http.StatusNotFound
delivery.Outcome = outcomeNotFound
delivery.Detail = "unknown webhook secret"
http.NotFound(w, r)
return
}
slog.Error("webhook: site lookup failed", "error", err)
delivery.StatusCode = http.StatusNotFound
delivery.Outcome = outcomeError
delivery.Detail = "lookup failed"
http.NotFound(w, r)
return
}
delivery.TargetID = site.ID
delivery.TargetName = site.Name
// 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)
delivery.Detail = "sync_trigger=manual"
respondWebhookJSON(w, http.StatusOK, map[string]any{
"success": true, "sync": false, "site": site.Name,
"reason": "sync_trigger is manual",
@@ -404,32 +532,42 @@ func (h *Handler) handleSiteWebhook(w http.ResponseWriter, r *http.Request) {
return
}
// Body is optional. We attempt to decode but accept an empty body (no Ref
// filter); a malformed non-empty body is treated as bad-request to avoid
// silently bypassing the branch/tag filter.
var payload SitePayload
body, err := io.ReadAll(io.LimitReader(r.Body, maxWebhookBodyBytes))
if err != nil {
delivery.StatusCode = http.StatusBadRequest
delivery.Outcome = outcomeBadRequest
delivery.Detail = "failed to read request body"
respondWebhookError(w, http.StatusBadRequest, "failed to read request body")
return
}
delivery.BodySize = len(body)
// HMAC enforcement matches the project flow: hard reject when required,
// soft reject when an invalid signature is supplied without enforcement.
verified, attempted := verifyHMAC(site.WebhookSigningSecret, body, r.Header.Get(signatureHeader))
header := r.Header.Get(signatureHeader)
verified, attempted := verifyHMAC(site.WebhookSigningSecret, body, header)
delivery.SignatureState = signatureStateFor(site.WebhookSigningSecret, header, verified, attempted)
if site.WebhookRequireSignature && !verified {
slog.Warn("webhook: site signature required but invalid/missing", "site", site.Name)
delivery.StatusCode = http.StatusUnauthorized
delivery.Outcome = outcomeRejected
delivery.Detail = "invalid or missing signature"
respondWebhookError(w, http.StatusUnauthorized, "invalid or missing signature")
return
}
if attempted && !verified {
slog.Warn("webhook: site bad signature", "site", site.Name)
delivery.StatusCode = http.StatusUnauthorized
delivery.Outcome = outcomeRejected
delivery.Detail = "invalid signature"
respondWebhookError(w, http.StatusUnauthorized, "invalid signature")
return
}
if len(body) > 0 {
if err := json.Unmarshal(body, &payload); err != nil {
delivery.StatusCode = http.StatusBadRequest
delivery.Outcome = outcomeBadRequest
delivery.Detail = "invalid JSON payload"
respondWebhookError(w, http.StatusBadRequest, "invalid JSON payload")
return
}
@@ -440,6 +578,7 @@ func (h *Handler) handleSiteWebhook(w http.ResponseWriter, r *http.Request) {
"site", site.Name, "ref", payload.Ref,
"branch", site.Branch, "tag_pattern", site.TagPattern,
"trigger", site.SyncTrigger)
delivery.Detail = fmt.Sprintf("ref %q does not match", payload.Ref)
respondWebhookJSON(w, http.StatusOK, map[string]any{
"success": true, "sync": false, "site": site.Name,
"reason": "ref does not match configured branch or tag pattern",
@@ -447,11 +586,12 @@ func (h *Handler) handleSiteWebhook(w http.ResponseWriter, r *http.Request) {
return
}
// Cap concurrent syncs so a runaway CI cannot fan out unbounded
// git-clone goroutines.
select {
case h.siteSyncSem <- struct{}{}:
default:
delivery.StatusCode = http.StatusServiceUnavailable
delivery.Outcome = outcomeError
delivery.Detail = "site sync queue full"
respondWebhookError(w, http.StatusServiceUnavailable, "site sync queue full")
return
}
@@ -467,6 +607,12 @@ func (h *Handler) handleSiteWebhook(w http.ResponseWriter, r *http.Request) {
_ = ctx
slog.Info("webhook: triggered site sync", "site", site.Name, "ref", payload.Ref)
delivery.Outcome = outcomeDeploy
if payload.Ref != "" {
delivery.Detail = fmt.Sprintf("ref=%s", payload.Ref)
} else {
delivery.Detail = "no ref filter"
}
respondWebhookJSON(w, http.StatusOK, map[string]any{
"success": true, "sync": true, "site": site.Name,
})