mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-20 02:42:11 +00:00
81b31e4d3b
Retire runtime/API/UI monitored-system volume enforcement now that infrastructure monitoring is no longer capped. Keep only legacy metadata scrubbing and purchase-start compatibility for old max_monitored_systems references. Rename the remaining preview surface to monitored-system impact and make previews explanatory rather than save-blocking. Update subsystem contracts and RA7 evidence for the caps-retired invariant.
872 lines
26 KiB
Go
872 lines
26 KiB
Go
package api
|
|
|
|
import (
|
|
"context"
|
|
"crypto/hmac"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/config"
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/models"
|
|
"github.com/rs/zerolog/log"
|
|
"github.com/stripe/stripe-go/v82"
|
|
"github.com/stripe/stripe-go/v82/webhook"
|
|
)
|
|
|
|
const (
|
|
stripeWebhookBodyLimit = 1024 * 1024 // 1MiB
|
|
// Keep handled event markers long enough to cover Stripe's automatic retry
|
|
// window and operator-driven resends, but not forever.
|
|
stripeWebhookDoneRetention = 30 * 24 * time.Hour
|
|
stripeWebhookPruneInterval = time.Hour
|
|
)
|
|
|
|
var errStripeWebhookEventInFlight = errors.New("stripe webhook event is in-flight")
|
|
|
|
// StripeWebhookHandlers handles Stripe webhooks for hosted Cloud provisioning.
|
|
//
|
|
// SECURITY: Signature verification (ConstructEvent) is the authentication mechanism for this endpoint.
|
|
type StripeWebhookHandlers struct {
|
|
hostedMode bool
|
|
|
|
persistence *config.MultiTenantPersistence
|
|
rbacProvider HostedRBACProvider
|
|
magicLinks *MagicLinkService
|
|
publicURL func(*http.Request) string
|
|
|
|
billingStore *config.FileBillingStore
|
|
|
|
deduper *stripeWebhookDeduper
|
|
index *stripeCustomerOrgIndex
|
|
|
|
now func() time.Time
|
|
}
|
|
|
|
func NewStripeWebhookHandlers(
|
|
billingStore *config.FileBillingStore,
|
|
persistence *config.MultiTenantPersistence,
|
|
rbacProvider HostedRBACProvider,
|
|
magicLinks *MagicLinkService,
|
|
publicURL func(*http.Request) string,
|
|
hostedMode bool,
|
|
dataPath string,
|
|
) *StripeWebhookHandlers {
|
|
baseDir := resolvePulseDataDir(dataPath)
|
|
return &StripeWebhookHandlers{
|
|
hostedMode: hostedMode,
|
|
persistence: persistence,
|
|
rbacProvider: rbacProvider,
|
|
magicLinks: magicLinks,
|
|
publicURL: publicURL,
|
|
billingStore: billingStore,
|
|
deduper: newStripeWebhookDeduper(filepath.Join(baseDir, "stripe", "webhook-events")),
|
|
index: newStripeCustomerOrgIndex(filepath.Join(baseDir, "stripe", "customers")),
|
|
now: time.Now,
|
|
}
|
|
}
|
|
|
|
func (h *StripeWebhookHandlers) HandleStripeWebhook(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
if !h.hostedMode {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
if h.billingStore == nil || h.persistence == nil || h.rbacProvider == nil || h.deduper == nil || h.index == nil {
|
|
writeErrorResponse(w, http.StatusServiceUnavailable, "stripe_unavailable", "Stripe webhook handler is not configured", nil)
|
|
return
|
|
}
|
|
|
|
secret := strings.TrimSpace(os.Getenv("STRIPE_WEBHOOK_SECRET"))
|
|
if secret == "" {
|
|
writeErrorResponse(w, http.StatusServiceUnavailable, "stripe_unavailable", "Stripe webhook secret is not configured", nil)
|
|
return
|
|
}
|
|
|
|
r.Body = http.MaxBytesReader(w, r.Body, stripeWebhookBodyLimit)
|
|
payload, err := io.ReadAll(r.Body)
|
|
if err != nil {
|
|
writeErrorResponse(w, http.StatusBadRequest, "invalid_request", "Failed to read request body", nil)
|
|
return
|
|
}
|
|
|
|
sigHeader := r.Header.Get("Stripe-Signature")
|
|
if strings.TrimSpace(sigHeader) == "" {
|
|
// Intentionally vague; missing signature is treated as invalid auth.
|
|
writeErrorResponse(w, http.StatusBadRequest, "invalid_signature", "Invalid Stripe signature", nil)
|
|
return
|
|
}
|
|
|
|
event, err := webhook.ConstructEventWithOptions(payload, sigHeader, secret, webhook.ConstructEventOptions{
|
|
IgnoreAPIVersionMismatch: true,
|
|
})
|
|
if err != nil {
|
|
writeErrorResponse(w, http.StatusBadRequest, "invalid_signature", "Invalid Stripe signature", nil)
|
|
return
|
|
}
|
|
|
|
already, err := h.deduper.Do(event.ID, func() error {
|
|
return h.handleEvent(r.Context(), &event, r)
|
|
})
|
|
if err != nil {
|
|
if errors.Is(err, errStripeWebhookEventInFlight) {
|
|
log.Warn().
|
|
Str("event_id", event.ID).
|
|
Str("type", string(event.Type)).
|
|
Msg("Stripe webhook event is already in-flight; returning non-2xx so Stripe retries")
|
|
writeErrorResponse(w, http.StatusConflict, "stripe_in_flight", "Stripe webhook is being processed; retry later", nil)
|
|
return
|
|
}
|
|
log.Error().Err(err).Str("event_id", event.ID).Str("type", string(event.Type)).Msg("Stripe webhook processing failed")
|
|
writeErrorResponse(w, http.StatusInternalServerError, "stripe_processing_failed", "Failed to process Stripe webhook", nil)
|
|
return
|
|
}
|
|
|
|
if already {
|
|
// Stripe treats any 2xx as success; returning JSON helps local debugging.
|
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
|
"received": true,
|
|
"status": "duplicate",
|
|
})
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
|
"received": true,
|
|
"status": "processed",
|
|
})
|
|
}
|
|
|
|
func (h *StripeWebhookHandlers) handleEvent(ctx context.Context, event *stripe.Event, r *http.Request) error {
|
|
if event == nil {
|
|
return errors.New("stripe event is nil")
|
|
}
|
|
|
|
switch event.Type {
|
|
case "checkout.session.completed":
|
|
var session stripeCheckoutSession
|
|
if err := json.Unmarshal(event.Data.Raw, &session); err != nil {
|
|
return fmt.Errorf("decode checkout.session: %w", err)
|
|
}
|
|
return h.handleCheckoutSessionCompleted(ctx, session, r)
|
|
case "customer.subscription.updated":
|
|
var sub stripeSubscription
|
|
if err := json.Unmarshal(event.Data.Raw, &sub); err != nil {
|
|
return fmt.Errorf("decode subscription: %w", err)
|
|
}
|
|
return h.handleSubscriptionUpdated(ctx, sub)
|
|
case "customer.subscription.deleted":
|
|
var sub stripeSubscription
|
|
if err := json.Unmarshal(event.Data.Raw, &sub); err != nil {
|
|
return fmt.Errorf("decode subscription: %w", err)
|
|
}
|
|
return h.handleSubscriptionDeleted(ctx, sub)
|
|
default:
|
|
log.Info().Str("type", string(event.Type)).Str("event_id", event.ID).Msg("Stripe webhook ignored (unhandled type)")
|
|
return nil
|
|
}
|
|
}
|
|
|
|
type stripeCheckoutSession struct {
|
|
ID string `json:"id"`
|
|
Mode string `json:"mode"`
|
|
Customer string `json:"customer"`
|
|
Subscription string `json:"subscription"`
|
|
CustomerEmail string `json:"customer_email"`
|
|
CustomerDetails stripeCustDetails `json:"customer_details"`
|
|
ClientReference string `json:"client_reference_id"`
|
|
Metadata map[string]string `json:"metadata"`
|
|
SubscriptionData map[string]any `json:"subscription_data"`
|
|
}
|
|
|
|
type stripeCustDetails struct {
|
|
Email string `json:"email"`
|
|
}
|
|
|
|
type stripeSubscription struct {
|
|
ID string `json:"id"`
|
|
Customer string `json:"customer"`
|
|
Status string `json:"status"`
|
|
CancelAtPeriodEnd bool `json:"cancel_at_period_end"`
|
|
CurrentPeriodEnd int64 `json:"current_period_end"`
|
|
EndedAt int64 `json:"ended_at"`
|
|
CancellationReason string `json:"cancellation_reason"`
|
|
Items struct {
|
|
Data []struct {
|
|
Price struct {
|
|
ID string `json:"id"`
|
|
Product string `json:"product"`
|
|
Metadata map[string]string `json:"metadata"`
|
|
} `json:"price"`
|
|
} `json:"data"`
|
|
} `json:"items"`
|
|
Metadata map[string]string `json:"metadata"`
|
|
}
|
|
|
|
func (h *StripeWebhookHandlers) handleCheckoutSessionCompleted(ctx context.Context, session stripeCheckoutSession, r *http.Request) error {
|
|
// Expect subscription-mode sessions for Cloud.
|
|
if strings.TrimSpace(session.Customer) == "" {
|
|
return fmt.Errorf("checkout session missing customer")
|
|
}
|
|
|
|
// SECURITY: customer email is not a safe org identifier.
|
|
// If present, it's used only for best-effort post-checkout UX (magic link) and audit logs.
|
|
email := strings.ToLower(strings.TrimSpace(session.CustomerEmail))
|
|
if email == "" {
|
|
email = strings.ToLower(strings.TrimSpace(session.CustomerDetails.Email))
|
|
}
|
|
|
|
orgName := ""
|
|
if session.Metadata != nil {
|
|
orgName = strings.TrimSpace(session.Metadata["org_name"])
|
|
if orgName == "" {
|
|
orgName = strings.TrimSpace(session.Metadata["org"])
|
|
}
|
|
}
|
|
// Prefer existing mapping by customer ID; otherwise require server-owned linkage (metadata/client_reference_id).
|
|
orgID, ok, err := h.index.LookupOrgID(session.Customer)
|
|
if err != nil {
|
|
return fmt.Errorf("lookup org by customer id: %w", err)
|
|
}
|
|
orgResolvedBy := "customer_index"
|
|
if !ok {
|
|
orgID = ""
|
|
if session.Metadata != nil {
|
|
orgID = strings.TrimSpace(session.Metadata["org_id"])
|
|
}
|
|
if orgID == "" {
|
|
orgID = strings.TrimSpace(session.ClientReference)
|
|
}
|
|
orgResolvedBy = "session_linkage"
|
|
}
|
|
orgID = strings.TrimSpace(orgID)
|
|
if orgID == "" {
|
|
return fmt.Errorf(
|
|
"checkout session %q missing org linkage for customer %q",
|
|
strings.TrimSpace(session.ID),
|
|
strings.TrimSpace(session.Customer),
|
|
)
|
|
}
|
|
if !isValidOrganizationID(orgID) {
|
|
return fmt.Errorf(
|
|
"checkout session %q resolved invalid org %q via %s",
|
|
strings.TrimSpace(session.ID),
|
|
orgID,
|
|
orgResolvedBy,
|
|
)
|
|
}
|
|
|
|
// SECURITY: only provision into an org that already exists. Do not create tenants from webhook payloads.
|
|
org, err := h.persistence.LoadOrganizationStrict(orgID)
|
|
if err != nil {
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
return fmt.Errorf(
|
|
"checkout session %q linked to missing org %q via %s",
|
|
strings.TrimSpace(session.ID),
|
|
orgID,
|
|
orgResolvedBy,
|
|
)
|
|
}
|
|
return fmt.Errorf("load org: %w", err)
|
|
}
|
|
if org == nil {
|
|
return fmt.Errorf("load org: empty org")
|
|
}
|
|
|
|
// Persist customer->org mapping once the linkage has been validated.
|
|
if !ok {
|
|
if err := h.index.Save(session.Customer, orgID); err != nil {
|
|
return fmt.Errorf("save customer index: %w", err)
|
|
}
|
|
}
|
|
|
|
planVersion := derivePlanVersion(session.Metadata, "")
|
|
|
|
state := &billingState{
|
|
Capabilities: cloudCapabilitiesFromLicensing(),
|
|
Limits: map[string]int64{},
|
|
MetersEnabled: []string{},
|
|
PlanVersion: planVersion,
|
|
SubscriptionState: subscriptionStateActiveValue,
|
|
StripeCustomerID: session.Customer,
|
|
StripeSubscriptionID: strings.TrimSpace(session.Subscription),
|
|
}
|
|
|
|
if err := h.billingStore.SaveBillingState(orgID, state); err != nil {
|
|
return fmt.Errorf("save billing state: %w", err)
|
|
}
|
|
|
|
// Best-effort: issue a magic link so the user can sign in quickly after checkout.
|
|
// (In dev/staging this is log-only; production should swap in a real emailer.)
|
|
if h.magicLinks != nil && email != "" {
|
|
// Only send a link to an existing org member/owner. Stripe customer email is user-controlled.
|
|
userID, role, ok := org.ResolvePrincipalByEmail(email)
|
|
if ok && strings.TrimSpace(userID) != "" && models.IsValidOrganizationRole(role) && h.magicLinks.AllowRequest(email) {
|
|
token, genErr := h.magicLinks.GenerateToken(email, orgID)
|
|
if genErr == nil {
|
|
baseURL := ""
|
|
if h.publicURL != nil && r != nil {
|
|
baseURL = h.publicURL(r)
|
|
}
|
|
if baseURL != "" {
|
|
if sendErr := h.magicLinks.SendMagicLink(email, orgID, token, baseURL); sendErr != nil {
|
|
log.Warn().Err(sendErr).Str("email", email).Str("org_id", orgID).Msg("Stripe checkout: failed to send magic link")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
log.Info().
|
|
Str("org_id", orgID).
|
|
Str("email", email).
|
|
Str("org_name", orgName).
|
|
Str("customer_id", session.Customer).
|
|
Str("resolved_by", orgResolvedBy).
|
|
Msg("Stripe checkout.session.completed processed")
|
|
|
|
return nil
|
|
}
|
|
|
|
func (h *StripeWebhookHandlers) handleSubscriptionUpdated(ctx context.Context, sub stripeSubscription) error {
|
|
customerID := strings.TrimSpace(sub.Customer)
|
|
if customerID == "" {
|
|
return fmt.Errorf("subscription missing customer")
|
|
}
|
|
|
|
orgID, ok, err := h.index.LookupOrgID(customerID)
|
|
if err != nil {
|
|
return fmt.Errorf("lookup org by customer id: %w", err)
|
|
}
|
|
if !ok {
|
|
// Backstop for older data: scan org billing files.
|
|
orgID, ok, err = h.scanOrgByStripeCustomerID(customerID)
|
|
if err != nil {
|
|
return fmt.Errorf("scan org by customer id: %w", err)
|
|
}
|
|
if ok {
|
|
if saveErr := h.index.Save(customerID, orgID); saveErr != nil {
|
|
log.Warn().
|
|
Err(saveErr).
|
|
Str("customer_id", customerID).
|
|
Str("org_id", orgID).
|
|
Msg("Stripe subscription.updated: failed to backfill customer org index")
|
|
}
|
|
}
|
|
}
|
|
if !ok {
|
|
log.Warn().Str("customer_id", customerID).Str("subscription_id", sub.ID).Msg("Stripe subscription.updated: org not found for customer")
|
|
return nil
|
|
}
|
|
|
|
before, err := h.billingStore.GetBillingState(orgID)
|
|
if err != nil {
|
|
return fmt.Errorf("load billing state: %w", err)
|
|
}
|
|
state := normalizeBillingState(before)
|
|
|
|
subState := mapStripeSubscriptionStatusToState(sub.Status)
|
|
state.SubscriptionState = subState
|
|
|
|
priceID := firstPriceID(sub)
|
|
state.StripePriceID = priceID
|
|
state.StripeCustomerID = customerID
|
|
state.StripeSubscriptionID = strings.TrimSpace(sub.ID)
|
|
state.PlanVersion = derivePlanVersion(sub.Metadata, priceID)
|
|
|
|
if shouldGrantPaidCapabilities(subState) {
|
|
state.Capabilities = cloudCapabilitiesFromLicensing()
|
|
state.Limits = map[string]int64{}
|
|
} else {
|
|
state.Capabilities = []string{}
|
|
state.Limits = map[string]int64{}
|
|
}
|
|
|
|
if err := h.billingStore.SaveBillingState(orgID, state); err != nil {
|
|
return fmt.Errorf("save billing state: %w", err)
|
|
}
|
|
|
|
log.Info().
|
|
Str("org_id", orgID).
|
|
Str("customer_id", customerID).
|
|
Str("subscription_id", sub.ID).
|
|
Str("subscription_state", string(subState)).
|
|
Msg("Stripe customer.subscription.updated processed")
|
|
|
|
return nil
|
|
}
|
|
|
|
func (h *StripeWebhookHandlers) handleSubscriptionDeleted(ctx context.Context, sub stripeSubscription) error {
|
|
customerID := strings.TrimSpace(sub.Customer)
|
|
if customerID == "" {
|
|
return fmt.Errorf("subscription missing customer")
|
|
}
|
|
|
|
orgID, ok, err := h.index.LookupOrgID(customerID)
|
|
if err != nil {
|
|
return fmt.Errorf("lookup org by customer id: %w", err)
|
|
}
|
|
if !ok {
|
|
orgID, ok, err = h.scanOrgByStripeCustomerID(customerID)
|
|
if err != nil {
|
|
return fmt.Errorf("scan org by customer id: %w", err)
|
|
}
|
|
if ok {
|
|
if saveErr := h.index.Save(customerID, orgID); saveErr != nil {
|
|
log.Warn().
|
|
Err(saveErr).
|
|
Str("customer_id", customerID).
|
|
Str("org_id", orgID).
|
|
Msg("Stripe subscription.deleted: failed to backfill customer org index")
|
|
}
|
|
}
|
|
}
|
|
if !ok {
|
|
log.Warn().Str("customer_id", customerID).Str("subscription_id", sub.ID).Msg("Stripe subscription.deleted: org not found for customer")
|
|
return nil
|
|
}
|
|
|
|
before, err := h.billingStore.GetBillingState(orgID)
|
|
if err != nil {
|
|
return fmt.Errorf("load billing state: %w", err)
|
|
}
|
|
state := normalizeBillingState(before)
|
|
|
|
// CRITICAL: revoke paid capabilities immediately on cancellation.
|
|
state.SubscriptionState = subscriptionStateCanceledValue
|
|
state.Capabilities = []string{}
|
|
state.Limits = map[string]int64{}
|
|
state.StripeCustomerID = customerID
|
|
state.StripeSubscriptionID = strings.TrimSpace(sub.ID)
|
|
|
|
if err := h.billingStore.SaveBillingState(orgID, state); err != nil {
|
|
return fmt.Errorf("save billing state: %w", err)
|
|
}
|
|
|
|
log.Info().
|
|
Str("org_id", orgID).
|
|
Str("customer_id", customerID).
|
|
Str("subscription_id", sub.ID).
|
|
Msg("Stripe customer.subscription.deleted processed (capabilities revoked)")
|
|
|
|
return nil
|
|
}
|
|
|
|
func (h *StripeWebhookHandlers) scanOrgByStripeCustomerID(customerID string) (string, bool, error) {
|
|
orgs, err := h.persistence.ListOrganizations()
|
|
if err != nil {
|
|
return "", false, err
|
|
}
|
|
customerID = strings.TrimSpace(customerID)
|
|
if customerID == "" {
|
|
return "", false, nil
|
|
}
|
|
|
|
for _, org := range orgs {
|
|
if org == nil || strings.TrimSpace(org.ID) == "" {
|
|
continue
|
|
}
|
|
state, loadErr := h.billingStore.GetBillingState(org.ID)
|
|
if loadErr != nil || state == nil {
|
|
continue
|
|
}
|
|
if strings.TrimSpace(state.StripeCustomerID) == customerID {
|
|
return org.ID, true, nil
|
|
}
|
|
}
|
|
|
|
return "", false, nil
|
|
}
|
|
|
|
func firstPriceID(sub stripeSubscription) string {
|
|
for _, item := range sub.Items.Data {
|
|
if strings.TrimSpace(item.Price.ID) != "" {
|
|
return strings.TrimSpace(item.Price.ID)
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func mapStripeSubscriptionStatusToState(status string) subscriptionState {
|
|
return mapStripeSubscriptionStatusToStateFromLicensing(status)
|
|
}
|
|
|
|
func shouldGrantPaidCapabilities(state subscriptionState) bool {
|
|
return shouldGrantPaidCapabilitiesFromLicensing(state)
|
|
}
|
|
|
|
func derivePlanVersion(metadata map[string]string, priceID string) string {
|
|
return deriveStripePlanVersionFromLicensing(metadata, priceID)
|
|
}
|
|
|
|
func resolvePulseDataDir(dataPath string) string {
|
|
return config.ResolveRuntimeDataDir(dataPath)
|
|
}
|
|
|
|
// stripeWebhookDeduper provides durable idempotency for Stripe webhook event IDs.
|
|
// Stripe retries webhooks; without a persistent dedupe store, retries can provision duplicate tenants.
|
|
type stripeWebhookDeduper struct {
|
|
dir string
|
|
lockTTL time.Duration
|
|
doneTTL time.Duration
|
|
pruneTTL time.Duration
|
|
now func() time.Time
|
|
hashSalt []byte
|
|
pruneMu sync.Mutex
|
|
lastPrune time.Time
|
|
}
|
|
|
|
func newStripeWebhookDeduper(dir string) *stripeWebhookDeduper {
|
|
return &stripeWebhookDeduper{
|
|
dir: dir,
|
|
lockTTL: 10 * time.Minute,
|
|
doneTTL: stripeWebhookDoneRetention,
|
|
pruneTTL: stripeWebhookPruneInterval,
|
|
now: time.Now,
|
|
// Salt prevents event IDs from being used directly as filenames if they contain odd characters.
|
|
// (Event IDs are normally safe, but this keeps the filesystem contract tight.)
|
|
hashSalt: []byte("pulse-stripe-webhook-v1"),
|
|
}
|
|
}
|
|
|
|
func (d *stripeWebhookDeduper) Do(eventID string, fn func() error) (already bool, err error) {
|
|
if d == nil {
|
|
return false, errors.New("deduper is nil")
|
|
}
|
|
if strings.TrimSpace(eventID) == "" {
|
|
return false, errors.New("event id is required")
|
|
}
|
|
if fn == nil {
|
|
return false, errors.New("handler is required")
|
|
}
|
|
|
|
donePath := d.donePath(eventID)
|
|
if fileExists(donePath) {
|
|
return true, nil
|
|
}
|
|
|
|
lockPath := d.lockPath(eventID)
|
|
acquired, lockErr := d.acquireLock(lockPath)
|
|
if lockErr != nil {
|
|
return false, fmt.Errorf("acquire dedupe lock: %w", lockErr)
|
|
}
|
|
if !acquired {
|
|
// Another in-flight processor exists. If processing has already completed, treat as a duplicate.
|
|
// If the lock exists but the done file does not, we must return a non-2xx so Stripe retries later.
|
|
// (Otherwise a concurrent Stripe retry could stop retrying while the original attempt still fails.)
|
|
if fileExists(donePath) {
|
|
return true, nil
|
|
}
|
|
return false, errStripeWebhookEventInFlight
|
|
}
|
|
|
|
defer func() {
|
|
if rmErr := os.Remove(lockPath); rmErr != nil && !errors.Is(rmErr, os.ErrNotExist) {
|
|
log.Warn().Err(rmErr).Str("path", lockPath).Msg("Stripe dedupe: failed to remove lock file")
|
|
}
|
|
}()
|
|
|
|
if err := fn(); err != nil {
|
|
return false, fmt.Errorf("process webhook event: %w", err)
|
|
}
|
|
|
|
if err := os.MkdirAll(filepath.Dir(donePath), 0o700); err != nil {
|
|
return false, fmt.Errorf("create dedupe dir: %w", err)
|
|
}
|
|
|
|
meta := map[string]any{
|
|
"handled_at": d.now().UTC().UnixMilli(),
|
|
}
|
|
data, err := json.Marshal(meta)
|
|
if err != nil {
|
|
return false, fmt.Errorf("marshal dedupe metadata: %w", err)
|
|
}
|
|
tmp := donePath + ".tmp"
|
|
if err := os.WriteFile(tmp, data, 0o600); err != nil {
|
|
return false, fmt.Errorf("write dedupe tmp: %w", err)
|
|
}
|
|
if err := os.Rename(tmp, donePath); err != nil {
|
|
if rmErr := os.Remove(tmp); rmErr != nil && !errors.Is(rmErr, os.ErrNotExist) {
|
|
return false, errors.Join(
|
|
fmt.Errorf("commit dedupe: %w", err),
|
|
fmt.Errorf("remove dedupe tmp %s: %w", tmp, rmErr),
|
|
)
|
|
}
|
|
return false, fmt.Errorf("commit dedupe: %w", err)
|
|
}
|
|
if pruneErr := d.maybePruneArtifacts(); pruneErr != nil {
|
|
log.Warn().Err(pruneErr).Str("dir", d.dir).Msg("Stripe dedupe: failed to prune old webhook markers")
|
|
}
|
|
|
|
return false, nil
|
|
}
|
|
|
|
func (d *stripeWebhookDeduper) acquireLock(lockPath string) (bool, error) {
|
|
if err := os.MkdirAll(filepath.Dir(lockPath), 0o700); err != nil {
|
|
return false, fmt.Errorf("create lock dir: %w", err)
|
|
}
|
|
|
|
f, err := os.OpenFile(lockPath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
|
|
if err == nil {
|
|
if closeErr := f.Close(); closeErr != nil {
|
|
if rmErr := os.Remove(lockPath); rmErr != nil && !errors.Is(rmErr, os.ErrNotExist) {
|
|
return false, errors.Join(
|
|
fmt.Errorf("close lock file: %w", closeErr),
|
|
fmt.Errorf("cleanup lock file %s: %w", lockPath, rmErr),
|
|
)
|
|
}
|
|
return false, fmt.Errorf("close lock file: %w", closeErr)
|
|
}
|
|
return true, nil
|
|
}
|
|
|
|
if !errors.Is(err, os.ErrExist) {
|
|
return false, fmt.Errorf("create lock: %w", err)
|
|
}
|
|
|
|
// Break stale locks (e.g., process crash) so Stripe retries can succeed.
|
|
info, statErr := os.Stat(lockPath)
|
|
if statErr != nil && !errors.Is(statErr, os.ErrNotExist) {
|
|
return false, fmt.Errorf("stat lock file: %w", statErr)
|
|
}
|
|
if statErr == nil && d.now().Sub(info.ModTime()) > d.lockTTL {
|
|
if rmErr := os.Remove(lockPath); rmErr != nil && !errors.Is(rmErr, os.ErrNotExist) {
|
|
return false, fmt.Errorf("remove stale lock: %w", rmErr)
|
|
}
|
|
f, err := os.OpenFile(lockPath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
|
|
if err == nil {
|
|
if closeErr := f.Close(); closeErr != nil {
|
|
if rmErr := os.Remove(lockPath); rmErr != nil && !errors.Is(rmErr, os.ErrNotExist) {
|
|
return false, errors.Join(
|
|
fmt.Errorf("close recreated lock file: %w", closeErr),
|
|
fmt.Errorf("cleanup recreated lock file %s: %w", lockPath, rmErr),
|
|
)
|
|
}
|
|
return false, fmt.Errorf("close recreated lock file: %w", closeErr)
|
|
}
|
|
return true, nil
|
|
}
|
|
if errors.Is(err, os.ErrExist) {
|
|
return false, nil
|
|
}
|
|
return false, fmt.Errorf("recreate lock: %w", err)
|
|
}
|
|
|
|
return false, nil
|
|
}
|
|
|
|
func (d *stripeWebhookDeduper) donePath(eventID string) string {
|
|
return filepath.Join(d.dir, d.filenameForID(eventID)+".done")
|
|
}
|
|
|
|
func (d *stripeWebhookDeduper) lockPath(eventID string) string {
|
|
return filepath.Join(d.dir, d.filenameForID(eventID)+".lock")
|
|
}
|
|
|
|
func (d *stripeWebhookDeduper) filenameForID(id string) string {
|
|
// Use a deterministic HMAC so we never trust arbitrary IDs as filesystem paths.
|
|
mac := hmac.New(sha256.New, d.hashSalt)
|
|
_, _ = mac.Write([]byte(id))
|
|
return hex.EncodeToString(mac.Sum(nil))
|
|
}
|
|
|
|
func (d *stripeWebhookDeduper) maybePruneArtifacts() error {
|
|
if d == nil || strings.TrimSpace(d.dir) == "" {
|
|
return nil
|
|
}
|
|
|
|
now := d.now()
|
|
|
|
d.pruneMu.Lock()
|
|
defer d.pruneMu.Unlock()
|
|
|
|
if d.pruneTTL > 0 && !d.lastPrune.IsZero() && now.Sub(d.lastPrune) < d.pruneTTL {
|
|
return nil
|
|
}
|
|
|
|
if err := d.pruneArtifacts(now); err != nil {
|
|
return err
|
|
}
|
|
|
|
d.lastPrune = now
|
|
return nil
|
|
}
|
|
|
|
func (d *stripeWebhookDeduper) pruneArtifacts(now time.Time) error {
|
|
entries, err := os.ReadDir(d.dir)
|
|
if err != nil {
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
return nil
|
|
}
|
|
return fmt.Errorf("read dedupe dir: %w", err)
|
|
}
|
|
|
|
doneCutoff := now.Add(-d.doneTTL)
|
|
lockCutoff := now.Add(-d.lockTTL)
|
|
|
|
for _, entry := range entries {
|
|
if entry.IsDir() {
|
|
continue
|
|
}
|
|
|
|
name := entry.Name()
|
|
path := filepath.Join(d.dir, name)
|
|
|
|
switch {
|
|
case strings.HasSuffix(name, ".done"):
|
|
if d.doneTTL <= 0 {
|
|
continue
|
|
}
|
|
info, infoErr := entry.Info()
|
|
if infoErr != nil {
|
|
if errors.Is(infoErr, os.ErrNotExist) {
|
|
continue
|
|
}
|
|
return fmt.Errorf("stat dedupe marker %s: %w", path, infoErr)
|
|
}
|
|
if info.ModTime().After(doneCutoff) || info.ModTime().Equal(doneCutoff) {
|
|
continue
|
|
}
|
|
case strings.HasSuffix(name, ".lock"), strings.HasSuffix(name, ".tmp"):
|
|
info, infoErr := entry.Info()
|
|
if infoErr != nil {
|
|
if errors.Is(infoErr, os.ErrNotExist) {
|
|
continue
|
|
}
|
|
return fmt.Errorf("stat dedupe artifact %s: %w", path, infoErr)
|
|
}
|
|
if info.ModTime().After(lockCutoff) || info.ModTime().Equal(lockCutoff) {
|
|
continue
|
|
}
|
|
default:
|
|
continue
|
|
}
|
|
|
|
if rmErr := os.Remove(path); rmErr != nil && !errors.Is(rmErr, os.ErrNotExist) {
|
|
return fmt.Errorf("remove dedupe artifact %s: %w", path, rmErr)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
type stripeCustomerOrgIndex struct {
|
|
dir string
|
|
}
|
|
|
|
func newStripeCustomerOrgIndex(dir string) *stripeCustomerOrgIndex {
|
|
return &stripeCustomerOrgIndex{dir: dir}
|
|
}
|
|
|
|
func (i *stripeCustomerOrgIndex) LookupOrgID(customerID string) (string, bool, error) {
|
|
if i == nil {
|
|
return "", false, errors.New("index is nil")
|
|
}
|
|
customerID = strings.TrimSpace(customerID)
|
|
if customerID == "" {
|
|
return "", false, nil
|
|
}
|
|
if !isSafeStripeID(customerID) {
|
|
return "", false, fmt.Errorf("invalid stripe customer id")
|
|
}
|
|
|
|
path := filepath.Join(i.dir, customerID+".json")
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
return "", false, nil
|
|
}
|
|
return "", false, fmt.Errorf("read customer org index %s: %w", path, err)
|
|
}
|
|
var rec struct {
|
|
OrgID string `json:"org_id"`
|
|
}
|
|
if err := json.Unmarshal(data, &rec); err != nil {
|
|
return "", false, fmt.Errorf("decode customer org index %s: %w", path, err)
|
|
}
|
|
orgID := strings.TrimSpace(rec.OrgID)
|
|
if orgID == "" {
|
|
return "", false, nil
|
|
}
|
|
return orgID, true, nil
|
|
}
|
|
|
|
func (i *stripeCustomerOrgIndex) Save(customerID, orgID string) error {
|
|
if i == nil {
|
|
return errors.New("index is nil")
|
|
}
|
|
customerID = strings.TrimSpace(customerID)
|
|
orgID = strings.TrimSpace(orgID)
|
|
if customerID == "" || orgID == "" {
|
|
return fmt.Errorf("customerID and orgID are required")
|
|
}
|
|
if !isSafeStripeID(customerID) {
|
|
return fmt.Errorf("invalid stripe customer id")
|
|
}
|
|
if !isValidOrganizationID(orgID) {
|
|
return fmt.Errorf("invalid org id")
|
|
}
|
|
|
|
if err := os.MkdirAll(i.dir, 0o700); err != nil {
|
|
return fmt.Errorf("create customer org index directory: %w", err)
|
|
}
|
|
|
|
path := filepath.Join(i.dir, customerID+".json")
|
|
data, err := json.Marshal(map[string]any{
|
|
"org_id": orgID,
|
|
"updated_at": time.Now().UTC().UnixMilli(),
|
|
"customer_id": customerID,
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("marshal customer org index entry: %w", err)
|
|
}
|
|
tmp := path + ".tmp"
|
|
if err := os.WriteFile(tmp, data, 0o600); err != nil {
|
|
return fmt.Errorf("write customer org index temp file: %w", err)
|
|
}
|
|
if err := os.Rename(tmp, path); err != nil {
|
|
if rmErr := os.Remove(tmp); rmErr != nil && !errors.Is(rmErr, os.ErrNotExist) {
|
|
return errors.Join(
|
|
fmt.Errorf("commit customer org index: %w", err),
|
|
fmt.Errorf("remove customer org index temp file: %w", rmErr),
|
|
)
|
|
}
|
|
return fmt.Errorf("commit customer org index: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func isSafeStripeID(id string) bool {
|
|
// Stripe IDs are typically like "cus_...", "sub_...", "evt_...".
|
|
// Keep this strict to avoid filesystem surprises.
|
|
if len(id) < 5 || len(id) > 128 {
|
|
return false
|
|
}
|
|
for i := 0; i < len(id); i++ {
|
|
c := id[i]
|
|
if (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '_' || c == '-' {
|
|
continue
|
|
}
|
|
return false
|
|
}
|
|
if filepath.Base(id) != id {
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
// fileExists is defined in router.go (same package). Keep a single implementation
|
|
// to avoid duplicate symbol errors across the internal/api package.
|