mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-05 21:39:54 +02:00
295 lines
12 KiB
Go
295 lines
12 KiB
Go
package handler
|
|
|
|
import (
|
|
"errors"
|
|
"io"
|
|
"net/http"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/multica-ai/multica/server/internal/cloudruntime"
|
|
)
|
|
|
|
// Cloud billing endpoints proxy to the same multica-cloud HTTP service
|
|
// that backs cloud-runtime (Fleet and Billing share `:8080` per the
|
|
// upstream README). All paths here forward verbatim to /api/v1/billing/*
|
|
// on the cloud side, mirroring the cloud-runtime handler shape:
|
|
//
|
|
// - User-facing endpoints sit under /api/cloud-billing/* in our router
|
|
// and require the regular Auth middleware. We inject the resolved
|
|
// user_id as `X-User-ID` so the cloud side can scope owner queries.
|
|
//
|
|
// - The Stripe webhook is the one outlier: it lives at
|
|
// /api/webhooks/stripe (outside the Auth group), takes the raw
|
|
// request body byte-for-byte, and forwards `Stripe-Signature` for
|
|
// the cloud side to verify against its STRIPE_WEBHOOK_SECRET. The
|
|
// upstream contract is explicit about this:
|
|
// "webhook 使用原始请求体进行签名校验,不要在反向代理里改写 body."
|
|
//
|
|
// All proxy paths share `proxyCloudRuntime` for the standard JSON
|
|
// shape and only the webhook needs a custom raw-body forwarder.
|
|
|
|
// maxStripeWebhookBodySize bounds the raw body we'll forward upstream.
|
|
// Stripe's documented event payload upper bound is well under this;
|
|
// the cap exists to keep a malicious sender from making us read
|
|
// arbitrary memory before the upstream gets to reject the signature.
|
|
const maxStripeWebhookBodySize = 1 << 20 // 1 MiB
|
|
|
|
// stripeSignatureHeader is the canonical name of the header Stripe
|
|
// uses to ship its HMAC over the raw body. We forward whatever the
|
|
// client sent verbatim; the cloud side is the one that knows the
|
|
// shared secret and rejects on mismatch.
|
|
const stripeSignatureHeader = "Stripe-Signature"
|
|
|
|
// GetCloudBillingBalance forwards GET /api/v1/billing/balance.
|
|
//
|
|
// Returns the caller's wallet balance. Cloud reads `X-User-ID`; we
|
|
// stamp it from the authenticated context.
|
|
func (h *Handler) GetCloudBillingBalance(w http.ResponseWriter, r *http.Request) {
|
|
h.proxyCloudRuntime(w, r, http.MethodGet, "/api/v1/billing/balance", cloudRuntimeProxyOptions{
|
|
withUserID: true,
|
|
})
|
|
}
|
|
|
|
// ListCloudBillingTransactions forwards GET /api/v1/billing/transactions.
|
|
//
|
|
// The upstream supports `page` / `page_size`; we forward the query
|
|
// string unchanged.
|
|
func (h *Handler) ListCloudBillingTransactions(w http.ResponseWriter, r *http.Request) {
|
|
h.proxyCloudRuntime(w, r, http.MethodGet, "/api/v1/billing/transactions", cloudRuntimeProxyOptions{
|
|
withUserID: true,
|
|
withQuery: true,
|
|
})
|
|
}
|
|
|
|
// ListCloudBillingBatches forwards GET /api/v1/billing/batches.
|
|
//
|
|
// Returns paginated topup / bonus batches for the owner; same query
|
|
// shape as transactions.
|
|
func (h *Handler) ListCloudBillingBatches(w http.ResponseWriter, r *http.Request) {
|
|
h.proxyCloudRuntime(w, r, http.MethodGet, "/api/v1/billing/batches", cloudRuntimeProxyOptions{
|
|
withUserID: true,
|
|
withQuery: true,
|
|
})
|
|
}
|
|
|
|
// ListCloudBillingTopups forwards GET /api/v1/billing/topups.
|
|
func (h *Handler) ListCloudBillingTopups(w http.ResponseWriter, r *http.Request) {
|
|
h.proxyCloudRuntime(w, r, http.MethodGet, "/api/v1/billing/topups", cloudRuntimeProxyOptions{
|
|
withUserID: true,
|
|
withQuery: true,
|
|
})
|
|
}
|
|
|
|
// ListCloudBillingPriceTiers forwards GET /api/v1/billing/price-tiers.
|
|
//
|
|
// Per the upstream doc, this endpoint requires `X-User-ID` (it sits
|
|
// under the same auth fence as the rest of /api/v1/billing/*), even
|
|
// though the response is the same for every owner today. We stamp the
|
|
// header so cloud can audit who's listing tiers — and so the contract
|
|
// stays uniform if pricing later differentiates per-customer.
|
|
func (h *Handler) ListCloudBillingPriceTiers(w http.ResponseWriter, r *http.Request) {
|
|
h.proxyCloudRuntime(w, r, http.MethodGet, "/api/v1/billing/price-tiers", cloudRuntimeProxyOptions{
|
|
withUserID: true,
|
|
})
|
|
}
|
|
|
|
// CreateCloudBillingCheckoutSession forwards POST /api/v1/billing/checkout-sessions.
|
|
//
|
|
// Body shape (per upstream): `{tier_id, customer_email?}`. We don't
|
|
// care about its contents — proxyCloudRuntime validates only that
|
|
// it's syntactically JSON and forwards the bytes.
|
|
func (h *Handler) CreateCloudBillingCheckoutSession(w http.ResponseWriter, r *http.Request) {
|
|
h.proxyCloudRuntime(w, r, http.MethodPost, "/api/v1/billing/checkout-sessions", cloudRuntimeProxyOptions{
|
|
withUserID: true,
|
|
withBody: true,
|
|
})
|
|
}
|
|
|
|
// GetCloudBillingCheckoutSession forwards GET /api/v1/billing/checkout-sessions/{session_id}.
|
|
//
|
|
// The path param goes into the upstream URL; if missing we return 400
|
|
// before doing any network work.
|
|
func (h *Handler) GetCloudBillingCheckoutSession(w http.ResponseWriter, r *http.Request) {
|
|
sessionID := chi.URLParam(r, "sessionId")
|
|
if sessionID == "" {
|
|
writeError(w, http.StatusBadRequest, "session_id is required")
|
|
return
|
|
}
|
|
// Stripe Checkout session IDs are `cs_<base62>` by construction —
|
|
// alphanumeric plus underscore, nothing else. We splice the value
|
|
// straight into the upstream URL path, so anything outside that
|
|
// alphabet could in principle introduce path / query / fragment
|
|
// segments that re-target the request. Allow-list rather than
|
|
// deny-list: a future Stripe-format bump cannot quietly slip a
|
|
// new "harmful" character past us, it just rejects until we
|
|
// widen the rule consciously.
|
|
if !isValidStripeSessionID(sessionID) {
|
|
writeError(w, http.StatusBadRequest, "invalid session_id")
|
|
return
|
|
}
|
|
h.proxyCloudRuntime(w, r, http.MethodGet, "/api/v1/billing/checkout-sessions/"+sessionID, cloudRuntimeProxyOptions{
|
|
withUserID: true,
|
|
})
|
|
}
|
|
|
|
// isValidStripeSessionID reports whether s is a syntactically
|
|
// plausible Stripe Checkout session id: a non-empty string of
|
|
// `[A-Za-z0-9_]`. Stripe's own format is `cs_<base62>`, but we
|
|
// accept the broader alphanumeric+underscore set because (a) it
|
|
// covers every Stripe ID variant that might ever route here and
|
|
// (b) it stays in lockstep with Stripe's own ID grammar without
|
|
// hard-coding the `cs_` prefix.
|
|
func isValidStripeSessionID(s string) bool {
|
|
if s == "" {
|
|
return false
|
|
}
|
|
for _, c := range s {
|
|
switch {
|
|
case c >= 'a' && c <= 'z':
|
|
case c >= 'A' && c <= 'Z':
|
|
case c >= '0' && c <= '9':
|
|
case c == '_':
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
// CreateCloudBillingPortalSession forwards POST /api/v1/billing/portal-sessions.
|
|
//
|
|
// Body shape is upstream-defined; can be empty. We treat it as
|
|
// optional JSON: cloud_runtime helper rejects empty bodies on
|
|
// withBody=true, so for portal-sessions we explicitly do NOT mark
|
|
// withBody and also send no body upstream. If the upstream contract
|
|
// later requires a body, switch this to withBody and let cloud
|
|
// validate.
|
|
func (h *Handler) CreateCloudBillingPortalSession(w http.ResponseWriter, r *http.Request) {
|
|
h.proxyCloudRuntime(w, r, http.MethodPost, "/api/v1/billing/portal-sessions", cloudRuntimeProxyOptions{
|
|
withUserID: true,
|
|
})
|
|
}
|
|
|
|
// HandleCloudBillingStripeWebhook is the public ingress for Stripe
|
|
// webhook deliveries. Three things are critical here and are *not*
|
|
// shared with the standard proxyCloudRuntime path:
|
|
//
|
|
// 1. NO authentication. Stripe POSTs from its own infrastructure;
|
|
// we don't have a user context and don't try to invent one.
|
|
// Application-layer auth is replaced by Stripe's HMAC signature,
|
|
// which the upstream cloud service verifies. This route therefore
|
|
// sits OUTSIDE the Auth group in router.go.
|
|
//
|
|
// 2. The body is forwarded byte-for-byte. Stripe's signature is
|
|
// computed over the exact bytes it sent. We must NOT json.Unmarshal
|
|
// /re-marshal, trim whitespace, or otherwise touch the payload —
|
|
// even a single byte difference fails verification.
|
|
//
|
|
// 3. The `Stripe-Signature` header is forwarded verbatim. That's
|
|
// the entire authentication channel from Stripe to the upstream.
|
|
//
|
|
// We DO bound the read with MaxBytesReader so a malicious or
|
|
// misconfigured sender can't make us buffer arbitrary memory before
|
|
// the upstream rejects on signature.
|
|
//
|
|
// Two pre-checks happen BEFORE we read the body:
|
|
//
|
|
// - Per-IP rate limit (mirrors HandleAutopilotWebhook). The
|
|
// endpoint is public; without rate limiting an attacker spraying
|
|
// bogus payloads forces an upstream round-trip per request and
|
|
// burns the cloud's webhook handling budget. A spray of bad
|
|
// signatures still counts toward this limit, so the fast-path
|
|
// 429 stops the bleed.
|
|
//
|
|
// - Mandatory `Stripe-Signature` header. Real Stripe deliveries
|
|
// always include it; absence ≡ not from Stripe. Returning 401
|
|
// locally saves an upstream RTT on the most common kind of
|
|
// unauthorized poke (curl from a script kiddie). This is a strict
|
|
// superset of what the upstream would do — Cloud also rejects
|
|
// missing-signature with 401 — so it does not change Stripe's
|
|
// own delivery dashboard view.
|
|
func (h *Handler) HandleCloudBillingStripeWebhook(w http.ResponseWriter, r *http.Request) {
|
|
if h.CloudRuntime == nil || !h.CloudRuntime.Enabled() {
|
|
writeError(w, http.StatusServiceUnavailable, "cloud runtime is not configured")
|
|
return
|
|
}
|
|
|
|
// Per-IP rate limit BEFORE reading the body or hitting upstream.
|
|
// We deliberately reuse the same limiter as the autopilot
|
|
// webhook: both are public unauthenticated ingress with the same
|
|
// abuse profile, and budgeting them together gives a single knob
|
|
// to tune.
|
|
if h.WebhookIPRateLimiter != nil {
|
|
if ip := h.clientIPForRateLimit(r); ip != "" {
|
|
if !h.WebhookIPRateLimiter.Allow(r.Context(), ip) {
|
|
writeError(w, http.StatusTooManyRequests, "rate limit exceeded")
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
// Real Stripe deliveries always include Stripe-Signature; the
|
|
// absence is a confident "not from Stripe" signal. Reject locally
|
|
// to save the upstream RTT. NOTE: we use Header.Values to detect
|
|
// presence rather than Get, so a header explicitly set to "" still
|
|
// counts as missing (matches the upstream's interpretation).
|
|
if len(r.Header.Values(stripeSignatureHeader)) == 0 {
|
|
writeError(w, http.StatusUnauthorized, "missing Stripe-Signature header")
|
|
return
|
|
}
|
|
|
|
// Body cap matches the JSON proxy. Stripe's documented payload
|
|
// ceiling is much smaller; the limit is defense, not contract.
|
|
r.Body = http.MaxBytesReader(w, r.Body, maxStripeWebhookBodySize)
|
|
// io.ReadAll is appropriate here because the webhook body is
|
|
// fully consumed before forwarding (Stripe signs the bytes; we
|
|
// can't stream). Unlike the JSON proxy we deliberately do NOT
|
|
// trim whitespace or json-validate — the upstream signature
|
|
// check is computed over exactly what we received, so any
|
|
// transformation here would silently break verification.
|
|
body, err := io.ReadAll(r.Body)
|
|
if err != nil {
|
|
var maxErr *http.MaxBytesError
|
|
if errors.As(err, &maxErr) {
|
|
writeError(w, http.StatusRequestEntityTooLarge, "request body is too large")
|
|
return
|
|
}
|
|
writeError(w, http.StatusBadRequest, "invalid request body")
|
|
return
|
|
}
|
|
|
|
// Forward Stripe-Signature verbatim, plus Stripe's original
|
|
// Content-Type (so the upstream sees the exact same header set
|
|
// it would if Stripe were calling it directly). cloudruntime's
|
|
// default Content-Type would override `application/json;
|
|
// charset=utf-8` to plain `application/json`; signature
|
|
// verification doesn't care (HMAC is over the body), but
|
|
// preserving the exact header is cheap and removes a debug-time
|
|
// "why does this header look different?" surprise. Caller-
|
|
// supplied Headers in cloudruntime.Request override defaults, so
|
|
// putting Content-Type here is enough.
|
|
headers := http.Header{}
|
|
if sigs := r.Header.Values(stripeSignatureHeader); len(sigs) > 0 {
|
|
headers[stripeSignatureHeader] = sigs
|
|
}
|
|
if cts := r.Header.Values("Content-Type"); len(cts) > 0 {
|
|
headers["Content-Type"] = cts
|
|
}
|
|
|
|
resp, err := h.CloudRuntime.Do(r.Context(), cloudruntime.Request{
|
|
Method: http.MethodPost,
|
|
Path: "/api/v1/webhooks/stripe",
|
|
Body: body,
|
|
Headers: headers,
|
|
RequestID: cloudRuntimeRequestID(r),
|
|
// Intentionally no UserID — webhook is unauthenticated by
|
|
// design; injecting an empty header would still be harmless,
|
|
// but staying explicit makes the contract obvious to readers.
|
|
})
|
|
if err != nil {
|
|
writeCloudRuntimeError(w, r, err)
|
|
return
|
|
}
|
|
writeCloudRuntimeResponse(w, resp)
|
|
}
|