package webhook import ( "context" "crypto/hmac" "crypto/sha256" "encoding/base64" "encoding/json" "fmt" "io" "net/http" "os" "github.com/gin-gonic/gin" "gorm.io/gorm" "github.com/gochat/gochat/internal/model" "github.com/gochat/gochat/internal/service" applogger "github.com/gochat/gochat/pkg/logger" ) // ShopifyWebhookHandler handles Chatwoot-compatible Shopify public webhooks. // Reference: reference/chatwoot/app/controllers/webhooks/shopify_controller.rb type ShopifyWebhookHandler struct { db *gorm.DB clientSecret string } func NewShopifyWebhookHandler(db *gorm.DB, clientSecret string) *ShopifyWebhookHandler { return &ShopifyWebhookHandler{db: db, clientSecret: clientSecret} } func (h *ShopifyWebhookHandler) HandleShopifyWebhook(c *gin.Context) { body, err := io.ReadAll(c.Request.Body) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "failed to read request body"}) return } defer c.Request.Body.Close() if err := h.verifyHMAC(c.GetHeader("X-Shopify-Hmac-SHA256"), body); err != nil { applogger.L().Warnf("Shopify webhook: HMAC verification failed: %v", err) c.Status(http.StatusUnauthorized) return } var payload map[string]interface{} if len(body) > 0 { if err := json.Unmarshal(body, &payload); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid JSON payload"}) return } } else { payload = map[string]interface{}{} } topic := c.GetHeader("X-Shopify-Topic") payload["_shopify_topic"] = topic shopDomain := c.GetHeader("X-Shopify-Shop-Domain") if shopDomain == "" { shopDomain, _ = payload["shop_domain"].(string) } if topic == "shop/redact" { if shopDomain != "" { if err := h.deleteShopifyHooksByDomain(c.Request.Context(), shopDomain); err != nil { applogger.L().Warnf("Shopify webhook: shop/redact cleanup failed for %s: %v", shopDomain, err) } } c.Status(http.StatusOK) return } if shopDomain != "" { if hook, err := h.findShopifyHookByDomain(c.Request.Context(), shopDomain); err == nil && hook != nil { processor := service.NewShopifyEventProcessor(nil, nil) if err := processor.ProcessEvent(c.Request.Context(), hook, payload); err != nil { applogger.L().Warnf("Shopify webhook: event processing failed for hook %d: %v", hook.ID, err) } } } c.Status(http.StatusOK) } func (h *ShopifyWebhookHandler) verifyHMAC(signature string, body []byte) error { secret := h.clientSecret if secret == "" { secret = os.Getenv("SHOPIFY_CLIENT_SECRET") } if secret == "" { return fmt.Errorf("SHOPIFY_CLIENT_SECRET is not configured") } if signature == "" { return fmt.Errorf("missing X-Shopify-Hmac-SHA256 header") } mac := hmac.New(sha256.New, []byte(secret)) mac.Write(body) expected := base64.StdEncoding.EncodeToString(mac.Sum(nil)) if !hmac.Equal([]byte(expected), []byte(signature)) { return fmt.Errorf("invalid Shopify HMAC") } return nil } func (h *ShopifyWebhookHandler) deleteShopifyHooksByDomain(ctx context.Context, shopDomain string) error { if h.db == nil { return fmt.Errorf("shopify webhook database is not configured") } hooks, err := h.shopifyHooks(ctx, shopDomain) if err != nil { return err } for i := range hooks { if err := h.db.Delete(&model.IntegrationHook{}, hooks[i].ID).Error; err != nil { return err } } return nil } func (h *ShopifyWebhookHandler) findShopifyHookByDomain(ctx context.Context, shopDomain string) (*model.IntegrationHook, error) { hooks, err := h.shopifyHooks(ctx, shopDomain) if err != nil || len(hooks) == 0 { return nil, err } return &hooks[0], nil } func (h *ShopifyWebhookHandler) shopifyHooks(ctx context.Context, shopDomain string) ([]model.IntegrationHook, error) { if h.db == nil { return nil, fmt.Errorf("shopify webhook database is not configured") } var hooks []model.IntegrationHook if err := h.db.WithContext(ctx).Where("hook_type = ?", model.HookTypeShopify).Find(&hooks).Error; err != nil { return nil, err } matched := make([]model.IntegrationHook, 0, len(hooks)) for _, hook := range hooks { var settings model.ShopifySettings if len(hook.Settings) == 0 || json.Unmarshal(hook.Settings, &settings) != nil { continue } if settings.ShopDomain == shopDomain { matched = append(matched, hook) } } return matched, nil }