package api import ( "context" "encoding/json" "io" "net/http" "unicode/utf8" "github.com/gin-gonic/gin" "go.uber.org/zap" "veza-backend-api/internal/core/marketplace" "veza-backend-api/internal/handlers" "veza-backend-api/internal/response" "veza-backend-api/internal/services" "veza-backend-api/internal/services/hyperswitch" "veza-backend-api/internal/workers" ) // setupWebhookRoutes configure les routes pour les webhooks func (r *APIRouter) setupWebhookRoutes(router *gin.RouterGroup) { webhookService := services.NewWebhookService(r.db.GormDB, r.logger, r.config.JWTSecret) webhookWorker := workers.NewWebhookWorker( r.db.GormDB, webhookService, r.logger, 100, 5, 3, ) go webhookWorker.Start(context.Background()) webhookHandler := handlers.NewWebhookHandler(webhookService, webhookWorker, r.logger) webhooks := router.Group("/webhooks") { // Hyperswitch payment webhook - PUBLIC (no auth), called by Hyperswitch webhooks.POST("/hyperswitch", r.hyperswitchWebhookHandler()) if r.config.AuthMiddleware != nil { protected := webhooks.Group("") protected.Use(r.config.AuthMiddleware.RequireAuth()) r.applyCSRFProtection(protected) protected.POST("", webhookHandler.RegisterWebhook()) protected.GET("", webhookHandler.ListWebhooks()) protected.DELETE("/:id", webhookHandler.DeleteWebhook()) protected.GET("/stats", webhookHandler.GetWebhookStats()) protected.POST("/:id/test", webhookHandler.TestWebhook()) protected.POST("/:id/regenerate-key", webhookHandler.RegenerateAPIKey()) } } } // hyperswitchWebhookHandler handles POST /webhooks/hyperswitch from Hyperswitch. // // v1.0.7 item E: every delivery is persisted to hyperswitch_webhook_log, // including deliveries that fail signature verification or processing // — the log is the forensic audit trail for both legitimate activity // and attack probes. A 64KB body cap protects the table from log-spam // DoS (rejected with 413 before any INSERT). func (r *APIRouter) hyperswitchWebhookHandler() gin.HandlerFunc { marketService := r.getMarketplaceService() webhookSecret := r.config.HyperswitchWebhookSecret return func(c *gin.Context) { // v1.0.7 item E: cap the body read at MaxWebhookPayloadBytes+1 // so we can detect oversize without loading arbitrary megabytes // into memory. An attacker posting 10MB gets 413 back; the log // table never sees the row. limited := io.LimitReader(c.Request.Body, hyperswitch.MaxWebhookPayloadBytes+1) body, err := io.ReadAll(limited) if err != nil { r.logger.Error("Hyperswitch webhook: failed to read body", zap.Error(err)) response.InternalServerError(c, "Failed to read webhook body") return } if len(body) > hyperswitch.MaxWebhookPayloadBytes { r.logger.Warn("Hyperswitch webhook: payload exceeds size cap, rejecting", zap.Int("size_bytes", len(body)), zap.Int("max_bytes", hyperswitch.MaxWebhookPayloadBytes)) c.AbortWithStatusJSON(http.StatusRequestEntityTooLarge, gin.H{ "error": "payload too large", "limit": hyperswitch.MaxWebhookPayloadBytes, }) return } // Payload as TEXT: Hyperswitch sends JSON, but a probe/attack // might send anything. Reject invalid UTF-8 outright — the // attack-forensics value is in headers + ip + timing, not the // binary body. payload := string(body) if !utf8.ValidString(payload) { r.logger.Warn("Hyperswitch webhook: payload contains invalid UTF-8, replacing with empty for log") payload = "" } // Collect forensics context regardless of outcome. requestID := c.GetHeader("X-Request-Id") if requestID == "" { requestID = c.GetHeader("X-Request-ID") } sigHeader := c.GetHeader("x-webhook-signature-512") logRow := &hyperswitch.WebhookLog{ Payload: payload, SignatureHeader: sigHeader, SourceIP: c.ClientIP(), UserAgent: c.GetHeader("User-Agent"), RequestID: requestID, // filled with UUID by LogWebhook if empty } // Signature verification. Regardless of outcome, the row lands // with signature_valid set accordingly. if webhookSecret == "" { r.logger.Error("Hyperswitch webhook: HYPERSWITCH_WEBHOOK_SECRET not configured, rejecting webhook") logRow.SignatureValid = false logRow.ProcessingResult = "error: webhook secret not configured" r.persistWebhookLog(c.Request.Context(), logRow) response.InternalServerError(c, "Webhook secret not configured") return } if err := hyperswitch.VerifyWebhookSignature(body, sigHeader, webhookSecret); err != nil { r.logger.Warn("Hyperswitch webhook: signature verification failed", zap.String("source_ip", logRow.SourceIP), zap.Error(err)) logRow.SignatureValid = false logRow.ProcessingResult = "signature_invalid" r.persistWebhookLog(c.Request.Context(), logRow) response.Unauthorized(c, "Invalid webhook signature") return } logRow.SignatureValid = true // v1.0.6: dispatch refund events to ProcessRefundWebhook. Payment // events keep flowing through ProcessPaymentWebhook unchanged. var peek marketplace.HyperswitchWebhookPayload if err := json.Unmarshal(body, &peek); err != nil { r.logger.Warn("Hyperswitch webhook: payload not JSON — dispatching as payment", zap.Error(err)) } var procErr error if peek.IsRefundEvent() { procErr = marketService.ProcessRefundWebhook(c.Request.Context(), body) } else { procErr = marketService.ProcessPaymentWebhook(c.Request.Context(), body) } if procErr != nil { r.logger.Error("Hyperswitch webhook: processing failed", zap.Bool("is_refund_event", peek.IsRefundEvent()), zap.Error(procErr)) logRow.ProcessingResult = "error: " + procErr.Error() r.persistWebhookLog(c.Request.Context(), logRow) response.InternalServerError(c, "Webhook processing failed") return } logRow.ProcessingResult = "ok" r.persistWebhookLog(c.Request.Context(), logRow) response.Success(c, gin.H{"received": true}) } } // persistWebhookLog writes the audit row. Any DB failure is logged and // swallowed — the endpoint's primary contract is to ack Hyperswitch, // not to persist audit perfectly. A persistent storage failure is // operationally visible via this log line. func (r *APIRouter) persistWebhookLog(ctx context.Context, row *hyperswitch.WebhookLog) { if r.db == nil || r.db.GormDB == nil { return } if err := hyperswitch.LogWebhook(ctx, r.db.GormDB, row); err != nil { r.logger.Error("Hyperswitch webhook: failed to persist audit log row", zap.String("request_id", row.RequestID), zap.Bool("signature_valid", row.SignatureValid), zap.Error(err)) } } // getMarketplaceService returns the marketplace service with Hyperswitch wiring. // Used by webhook handler; mirrors setupMarketplaceRoutes service creation. func (r *APIRouter) getMarketplaceService() marketplace.MarketplaceService { if r.config.MarketplaceServiceOverride != nil { return r.config.MarketplaceServiceOverride.(marketplace.MarketplaceService) } uploadDir := r.config.UploadDir if uploadDir == "" { uploadDir = "uploads/tracks" } storageService := services.NewTrackStorageService(uploadDir, false, r.logger) opts := []marketplace.ServiceOption{} if r.config.HyperswitchEnabled && r.config.HyperswitchAPIKey != "" && r.config.HyperswitchURL != "" { hsClient := hyperswitch.NewClient(r.config.HyperswitchURL, r.config.HyperswitchAPIKey) hsProvider := hyperswitch.NewProvider(hsClient) opts = append(opts, marketplace.WithPaymentProvider(hsProvider), marketplace.WithHyperswitchConfig(true, r.config.CheckoutSuccessURL), ) } if r.config.StripeConnectEnabled && r.config.StripeConnectSecretKey != "" { scs := services.NewStripeConnectService(r.db.GormDB, r.config.StripeConnectSecretKey, r.logger) opts = append(opts, marketplace.WithTransferService(scs, r.config.PlatformFeeRate)) } // v1.0.9 item G Phase 2: subscription webhook dispatcher. The // processor reads/writes subscription_invoices + user_subscriptions // directly via GORM; injecting it here makes ProcessPaymentWebhook // route subscription events instead of swallowing them as "order not // found". subscriptionProcessor := hyperswitch.NewSubscriptionWebhookProcessor(r.db.GormDB, r.logger) opts = append(opts, marketplace.WithSubscriptionWebhookHandler(subscriptionProcessor)) return marketplace.NewService(r.db.GormDB, r.logger, storageService, opts...) }