feat(channels/webhook): 入站回调渠道插件(JSON/Form 解析 + HMAC 签名 + InboundHandler 供 gateway 挂载,主动推送不支持)
This commit is contained in:
1 parent
7adaf490c7
commit
0b036645bb
5 files changed
+1193
No files matched your search
@@ -0,0 +1,170 @@
|
||||
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}})
|
||||
}
|
||||
Reference in new issue
Block a user