Files
tiny-forge/internal/store/webhook_deliveries.go
T
alexei.dolgolyov 0f60a7a5db
Build / build (push) Successful in 10m35s
feat(webhook): inbound delivery audit log
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
2026-05-07 02:40:39 +03:00

85 lines
3.0 KiB
Go

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
}