yaju-is-always-watching-you/internal/webhook/handler.go
awayatan f47fef177b
Some checks failed
CI / test (push) Failing after 4s
Add yaju-is-always-watching-you: Forgejo goroku-check webhook bot
Watches issue/PR comments and pushes for flagged phrases; warns commenters
via reply and opens a redacted correction PR for flagged file content,
never rewriting history or deleting content directly.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-26 22:47:12 +09:00

106 lines
2.7 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"
"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 ""
}