// 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" "fmt" "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) { event := firstHeader(r, "X-Forgejo-Event", "X-Gitea-Event") status, err := h.handle(w, r, event) if err != nil { log.Printf("yaju-keisatsu: %s event -> %d: %v", event, status, err) } else { log.Printf("yaju-keisatsu: %s event -> %d", event, status) } if status != http.StatusNoContent { http.Error(w, http.StatusText(status), status) return } w.WriteHeader(status) } func (h *Handler) handle(w http.ResponseWriter, r *http.Request, event string) (int, error) { if r.Method != http.MethodPost { return http.StatusMethodNotAllowed, nil } body, err := io.ReadAll(io.LimitReader(r.Body, 10<<20)) // 10MiB cap if err != nil { return http.StatusBadRequest, fmt.Errorf("read body: %w", err) } if !h.validSignature(r, body) { return http.StatusUnauthorized, fmt.Errorf("invalid signature") } switch event { case "issue_comment": var p forgejo.IssueCommentPayload if err := json.Unmarshal(body, &p); err != nil { return http.StatusBadRequest, fmt.Errorf("decode payload: %w", err) } if err := h.dispatcher.HandleIssueComment(p); err != nil { return http.StatusInternalServerError, err } case "push": var p forgejo.PushPayload if err := json.Unmarshal(body, &p); err != nil { return http.StatusBadRequest, fmt.Errorf("decode payload: %w", err) } if err := h.dispatcher.HandlePush(p); err != nil { return http.StatusInternalServerError, err } default: // Unhandled event types are not an error — the webhook may be // subscribed to more events than this bot reacts to. } return http.StatusNoContent, nil } // 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 "" }