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=" 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) }