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}}) }