256 lines
7.8 KiB
Go
256 lines
7.8 KiB
Go
package http
|
|
|
|
import (
|
|
"context"
|
|
"crypto/hmac"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
stdhttp "net/http"
|
|
"strings"
|
|
"sync"
|
|
)
|
|
|
|
const maxGitoWebhookBodyBytes = 1 << 20
|
|
|
|
// GitoChangedFile is a single file changed by the push that produced the
|
|
// branch.updated delivery. It is carried through as a wakeup-relevance hint
|
|
// only; the receiver's handler seam (*gitosync.Bridge) never trusts it as the
|
|
// final changed set and always re-verifies against the target revision.
|
|
type GitoChangedFile struct {
|
|
Path string
|
|
ChangeType string
|
|
}
|
|
|
|
// GitoBranchUpdatedEvent is the decoded branch.updated payload the HTTP
|
|
// receiver passes to the configured handler. It carries the fields needed to
|
|
// route and dedup the event at the HTTP boundary, plus ChangedFiles as a
|
|
// hint-only field the handler seam may use for logging/decision context.
|
|
type GitoBranchUpdatedEvent struct {
|
|
RepoID string
|
|
Branch string
|
|
Before string
|
|
After string
|
|
ChangedFiles []GitoChangedFile
|
|
}
|
|
|
|
// GitoBranchEventHandler is the seam the HTTP receiver forwards a verified
|
|
// branch.updated event to. Satisfied by *gitosync.Bridge (via an adapter
|
|
// defined in main) and test fakes.
|
|
type GitoBranchEventHandler interface {
|
|
Handle(ctx context.Context, ev GitoBranchUpdatedEvent) error
|
|
}
|
|
|
|
// gitoDeliveryStore is a process-lifetime idempotency store for HTTP-layer
|
|
// delivery-id and revision duplicate detection.
|
|
type gitoDeliveryStore struct {
|
|
mu sync.Mutex
|
|
records map[string]struct{}
|
|
}
|
|
|
|
func newGitoDeliveryStore() *gitoDeliveryStore {
|
|
return &gitoDeliveryStore{records: make(map[string]struct{})}
|
|
}
|
|
|
|
func (s *gitoDeliveryStore) hasSeen(key string) bool {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
_, ok := s.records[key]
|
|
return ok
|
|
}
|
|
|
|
func (s *gitoDeliveryStore) markSeen(key string) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.records[key] = struct{}{}
|
|
}
|
|
|
|
// gitoWebhookPayload is the minimal JSON structure expected in a Gito
|
|
// branch.updated HTTP webhook body. It includes optional fields used for
|
|
// contract validation (type, changed_files) without requiring full proto
|
|
// socket decoding.
|
|
type gitoWebhookPayload struct {
|
|
Type string `json:"type"`
|
|
RepoID string `json:"repo_id"`
|
|
Branch string `json:"branch"`
|
|
Before string `json:"before"`
|
|
After string `json:"after"`
|
|
ChangedFiles interface{} `json:"changed_files"`
|
|
}
|
|
|
|
// validateBranchUpdatedPayload validates that the decoded payload satisfies
|
|
// the branch.updated contract. Returns an error description when the payload
|
|
// is invalid. This mirrors the validation in
|
|
// gitoevents.DecodeBranchUpdatedPayload but works on the plain JSON struct
|
|
// to avoid import cycles.
|
|
func validateBranchUpdatedPayload(p *gitoWebhookPayload) error {
|
|
// body type must be "branch.updated" if present
|
|
if p.Type != "" && p.Type != "branch.updated" {
|
|
return fmt.Errorf("payload type %q, want %q", p.Type, "branch.updated")
|
|
}
|
|
|
|
// repo_id and branch are required (trim whitespace before check)
|
|
p.RepoID = strings.TrimSpace(p.RepoID)
|
|
p.Branch = strings.TrimSpace(p.Branch)
|
|
if p.RepoID == "" {
|
|
return errors.New("payload missing required field repo_id")
|
|
}
|
|
if p.Branch == "" {
|
|
return errors.New("payload missing required field branch")
|
|
}
|
|
|
|
// changed_files must be a list of objects if present
|
|
if p.ChangedFiles != nil {
|
|
changedFiles, ok := p.ChangedFiles.([]interface{})
|
|
if !ok {
|
|
return errors.New("payload changed_files is not a list")
|
|
}
|
|
for i, item := range changedFiles {
|
|
if _, ok := item.(map[string]interface{}); !ok {
|
|
return fmt.Errorf("payload changed_files[%d] is not an object", i)
|
|
}
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// decodeGitoChangedFiles converts the validated changed_files JSON value into
|
|
// typed hint entries. Callers must run validateBranchUpdatedPayload first;
|
|
// this never returns an error and skips fields it cannot read as strings.
|
|
func decodeGitoChangedFiles(raw interface{}) []GitoChangedFile {
|
|
list, ok := raw.([]interface{})
|
|
if !ok {
|
|
return nil
|
|
}
|
|
out := make([]GitoChangedFile, 0, len(list))
|
|
for _, item := range list {
|
|
m, ok := item.(map[string]interface{})
|
|
if !ok {
|
|
continue
|
|
}
|
|
path, _ := m["path"].(string)
|
|
changeType, _ := m["change_type"].(string)
|
|
out = append(out, GitoChangedFile{Path: path, ChangeType: changeType})
|
|
}
|
|
return out
|
|
}
|
|
|
|
func (h *Handler) ReceiveGitoWebhook(w stdhttp.ResponseWriter, r *stdhttp.Request) {
|
|
if !h.gitoConfig.ready() {
|
|
writeError(w, stdhttp.StatusServiceUnavailable, "gito webhook secret is not configured")
|
|
return
|
|
}
|
|
|
|
body, err := io.ReadAll(stdhttp.MaxBytesReader(w, r.Body, maxGitoWebhookBodyBytes))
|
|
if err != nil {
|
|
if errors.As(err, new(*stdhttp.MaxBytesError)) {
|
|
writeError(w, stdhttp.StatusRequestEntityTooLarge, "webhook body is too large")
|
|
return
|
|
}
|
|
writeError(w, stdhttp.StatusBadRequest, "invalid webhook body")
|
|
return
|
|
}
|
|
|
|
signature := strings.TrimSpace(r.Header.Get("X-Gito-Signature"))
|
|
if signature == "" || !validGitoWebhookSignature(h.gitoConfig.secret, body, signature) {
|
|
writeError(w, stdhttp.StatusUnauthorized, "invalid gito webhook signature")
|
|
return
|
|
}
|
|
|
|
event := strings.TrimSpace(r.Header.Get("X-Gito-Event"))
|
|
if event != "branch.updated" {
|
|
writeError(w, stdhttp.StatusBadRequest, "unexpected gito event type")
|
|
return
|
|
}
|
|
deliveryID := strings.TrimSpace(r.Header.Get("X-Gito-Delivery"))
|
|
if deliveryID == "" {
|
|
writeError(w, stdhttp.StatusBadRequest, "missing gito delivery id")
|
|
return
|
|
}
|
|
|
|
var p gitoWebhookPayload
|
|
if err := json.Unmarshal(body, &p); err != nil {
|
|
writeError(w, stdhttp.StatusBadRequest, "invalid JSON body")
|
|
return
|
|
}
|
|
|
|
if err := validateBranchUpdatedPayload(&p); err != nil {
|
|
writeError(w, stdhttp.StatusBadRequest, fmt.Sprintf("invalid branch.updated payload: %s", err.Error()))
|
|
return
|
|
}
|
|
|
|
ev := GitoBranchUpdatedEvent{
|
|
RepoID: p.RepoID,
|
|
Branch: p.Branch,
|
|
Before: strings.TrimSpace(p.Before),
|
|
After: strings.TrimSpace(p.After),
|
|
ChangedFiles: decodeGitoChangedFiles(p.ChangedFiles),
|
|
}
|
|
|
|
// Off-target events are acked without side effects.
|
|
targetBranch := h.gitoConfig.branch
|
|
if targetBranch == "" {
|
|
targetBranch = "develop"
|
|
}
|
|
if ev.RepoID != h.gitoConfig.repoID || ev.Branch != targetBranch {
|
|
writeJSON(w, stdhttp.StatusAccepted, map[string]string{"status": "ignored"})
|
|
return
|
|
}
|
|
|
|
// HTTP-layer idempotency: check delivery key first, then revision key
|
|
// when after is set. Mark both after successful handling.
|
|
var deliveryKey, revisionKey string
|
|
deliveryKey = "delivery:" + deliveryID
|
|
if h.gitoDelivery.hasSeen(deliveryKey) {
|
|
writeJSON(w, stdhttp.StatusAccepted, map[string]string{"status": "duplicate"})
|
|
return
|
|
}
|
|
if ev.After != "" {
|
|
revisionKey = "revision:" + ev.RepoID + ":" + ev.Branch + ":" + ev.After
|
|
if h.gitoDelivery.hasSeen(revisionKey) {
|
|
writeJSON(w, stdhttp.StatusAccepted, map[string]string{"status": "duplicate"})
|
|
return
|
|
}
|
|
}
|
|
|
|
if h.gitoEventHandler != nil {
|
|
if err := h.gitoEventHandler.Handle(r.Context(), ev); err != nil {
|
|
// Do not mark keys on error so the caller can retry.
|
|
if h.logger != nil {
|
|
h.logger.Error("gito webhook handler failed", "error", err)
|
|
}
|
|
writeError(w, stdhttp.StatusInternalServerError, "internal server error")
|
|
return
|
|
}
|
|
}
|
|
|
|
h.gitoDelivery.markSeen(deliveryKey)
|
|
if revisionKey != "" {
|
|
h.gitoDelivery.markSeen(revisionKey)
|
|
}
|
|
|
|
writeJSON(w, stdhttp.StatusAccepted, map[string]string{"status": "accepted"})
|
|
}
|
|
|
|
// validGitoWebhookSignature verifies a Gito HMAC-SHA256 webhook signature.
|
|
// Accepts both raw hex and the "sha256=<hex>" prefixed form.
|
|
func validGitoWebhookSignature(secret string, body []byte, signature string) bool {
|
|
sig := signature
|
|
if strings.HasPrefix(sig, "sha256=") {
|
|
sig = sig[len("sha256="):]
|
|
}
|
|
|
|
mac := hmac.New(sha256.New, []byte(secret))
|
|
_, _ = mac.Write(body)
|
|
expected := mac.Sum(nil)
|
|
|
|
actual, err := hex.DecodeString(sig)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
return hmac.Equal(actual, expected)
|
|
}
|