Files
Cyrene_v2/plugins/channels/webhook/handler.go
T

171 lines
5.2 KiB
Go

package webhook
import (
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
)
// Inbound HTTP callback errors returned by the handler. They are sentinels so
// callers and tests can discriminate failure modes with errors.Is.
var (
errSignatureMissing = errors.New("webhook: missing signature header")
errSignatureMismatch = errors.New("webhook: signature mismatch")
errNoInboundHandler = errors.New("webhook: no inbound handler installed")
errOversizedBody = errors.New("webhook: request body too large")
)
// InboundHandler returns the http.Handler the gateway mounts at the configured
// endpoint path (M8). It accepts JSON or form callbacks, verifies the HMAC
// signature when a secret is configured, normalizes the payload to
// UnifiedMessage, and delivers it to the installed message handler.
//
// A handler is constructed from the adapter's immutable config, so it is safe
// to mount once and serve concurrently.
func (a *Adapter) InboundHandler() http.Handler {
return http.HandlerFunc(a.serveInbound)
}
// serveInbound is the HTTP entry point for an inbound callback.
func (a *Adapter) serveInbound(w http.ResponseWriter, r *http.Request) {
if !a.methodAllowed(r.Method) {
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "webhook: method not allowed")
return
}
body, err := readBody(w, r)
if err != nil {
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
return
}
if err := a.verifySignature(r, body); err != nil {
writeError(w, http.StatusUnauthorized, "unauthorized", err.Error())
return
}
msg, err := parseInbound(r.Header.Get("Content-Type"), body)
if err != nil {
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
return
}
if err := msg.Validate(); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
return
}
if err := a.deliver(r.Context(), msg); err != nil {
writeError(w, http.StatusServiceUnavailable, "unavailable", err.Error())
return
}
writeData(w, http.StatusOK, map[string]any{"accepted": true, "messageId": msg.MessageID})
}
// methodAllowed reports whether m is one of the adapter's accepted methods.
func (a *Adapter) methodAllowed(m string) bool {
for _, allowed := range a.cfg.Methods {
if allowed == m {
return true
}
}
return false
}
// readBody reads and size-caps the request body so a malicious or misconfigured
// caller cannot exhaust memory. It returns errOversizedBody when the cap is hit.
func readBody(w http.ResponseWriter, r *http.Request) ([]byte, error) {
body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, defaultMaxBodyBytes))
if err != nil {
if isMaxBytesError(err) {
return nil, fmt.Errorf("%w", errOversizedBody)
}
return nil, fmt.Errorf("webhook: read body: %w", err)
}
return body, nil
}
// isMaxBytesError reports whether err was produced by a MaxBytesReader overrun.
func isMaxBytesError(err error) bool {
var mbe *http.MaxBytesError
return errors.As(err, &mbe)
}
// verifySignature checks the HMAC-SHA256 signature over the raw body when a
// secret is configured. An empty secret disables verification entirely.
func (a *Adapter) verifySignature(r *http.Request, body []byte) error {
if a.cfg.Secret == "" {
return nil
}
got := r.Header.Get(a.cfg.SignatureHeader)
if got == "" {
return fmt.Errorf("%w: header %q", errSignatureMissing, a.cfg.SignatureHeader)
}
mac := hmac.New(sha256.New, []byte(a.cfg.Secret))
_, _ = mac.Write(body)
want := hex.EncodeToString(mac.Sum(nil))
if !hmac.Equal([]byte(got), []byte(want)) {
return fmt.Errorf("%w", errSignatureMismatch)
}
return nil
}
// deliver routes a normalized inbound message to the installed message handler,
// falling back to the emit callback captured by ReadMessages when no handler is
// set. It returns errNoInboundHandler if neither is available.
func (a *Adapter) deliver(ctx context.Context, msg adapter.UnifiedMessage) error {
a.mu.RLock()
handler := a.handler
emit := a.emit
a.mu.RUnlock()
if handler != nil {
handler(ctx, msg)
return nil
}
if emit != nil {
emit(msg)
return nil
}
return fmt.Errorf("%w", errNoInboundHandler)
}
// dataEnvelope is the success response shape `{"data": ...}`, matching the
// project's versioned API contract.
type dataEnvelope struct {
Data any `json:"data"`
}
// errorEnvelope is the failure response shape `{"error":{code,message}}`.
type errorEnvelope struct {
Error errorBody `json:"error"`
}
// errorBody carries a machine code and a human message.
type errorBody struct {
Code string `json:"code"`
Message string `json:"message"`
}
// writeData writes a JSON success envelope with the given status.
func writeData(w http.ResponseWriter, status int, data any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(dataEnvelope{Data: data})
}
// writeError writes a JSON error envelope with the given status.
func writeError(w http.ResponseWriter, status int, code, message string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(errorEnvelope{Error: errorBody{Code: code, Message: message}})
}