yaju-is-always-watching-you/internal/webhook/handler.go
awayatan e95794680b
Some checks failed
CI / test (push) Has been cancelled
Log every webhook request, not just dispatch failures
The handler was silent on success, unauthorized signatures, and unhandled
events, making it impossible to tell from server logs alone whether a
delivery even arrived. Now every request logs its event type and resulting
status code.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-27 00:06:43 +09:00

114 lines
3 KiB
Go

// 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 ""
}