Files
multica/server/internal/handler/cloud_billing.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)
}