// Package webhook is the HTTP transport layer: it verifies Forgejo's // webhook signature, decodes the event payload, and dispatches to bot.Bot. package webhook import ( "crypto/hmac" "crypto/sha256" "encoding/hex" "encoding/json" "io" "log" "net/http" "git.folja.dev/awayatan/yaju-is-always-watching-you/internal/forgejo" ) // Dispatcher is satisfied by *bot.Bot. type Dispatcher interface { HandleIssueComment(forgejo.IssueCommentPayload) error HandlePush(forgejo.PushPayload) error } type Handler struct { secret []byte dispatcher Dispatcher } func NewHandler(secret string, dispatcher Dispatcher) *Handler { return &Handler{secret: []byte(secret), dispatcher: dispatcher} } func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } body, err := io.ReadAll(io.LimitReader(r.Body, 10<<20)) // 10MiB cap if err != nil { http.Error(w, "read body", http.StatusBadRequest) return } if !h.validSignature(r, body) { http.Error(w, "invalid signature", http.StatusUnauthorized) return } event := firstHeader(r, "X-Forgejo-Event", "X-Gitea-Event") var dispatchErr error switch event { case "issue_comment": var p forgejo.IssueCommentPayload if err := json.Unmarshal(body, &p); err != nil { http.Error(w, "decode payload", http.StatusBadRequest) return } dispatchErr = h.dispatcher.HandleIssueComment(p) case "push": var p forgejo.PushPayload if err := json.Unmarshal(body, &p); err != nil { http.Error(w, "decode payload", http.StatusBadRequest) return } dispatchErr = h.dispatcher.HandlePush(p) default: // Unhandled event types are not an error — the webhook may be // subscribed to more events than this bot reacts to. } if dispatchErr != nil { log.Printf("yaju-keisatsu: handling %s event: %v", event, dispatchErr) http.Error(w, "internal error", http.StatusInternalServerError) return } w.WriteHeader(http.StatusNoContent) } // validSignature checks X-Forgejo-Signature (falling back to // X-Gitea-Signature for webhooks configured in Gitea-compat mode): the hex // HMAC-SHA256 of the raw body under the shared webhook secret. func (h *Handler) validSignature(r *http.Request, body []byte) bool { sig := firstHeader(r, "X-Forgejo-Signature", "X-Gitea-Signature") if sig == "" { return false } got, err := hex.DecodeString(sig) if err != nil { return false } mac := hmac.New(sha256.New, h.secret) mac.Write(body) want := mac.Sum(nil) return hmac.Equal(got, want) } func firstHeader(r *http.Request, names ...string) string { for _, n := range names { if v := r.Header.Get(n); v != "" { return v } } return "" }