171 lines
5.2 KiB
Go
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}})
|
|
}
|