Files
gochat/backend/internal/handler/webhook/shopify_webhook.go
T
rogee aeddedf2a3 Reorganize repo: backend/, deploy/, docs/ layout + AGENTS.md
Restructure the monorepo into clear top-level directories:
- backend/: Go module root (cmd, internal, pkg, configs, migrations,
  docs/swagger, scripts, tests, go.mod, Makefile, .air.toml)
- deploy/: Docker (Dockerfile, docker-compose*), quickstart, fluentd
- docs/: project documentation + reports/ (moved from repo root)
- AGENTS.md: new AI coding-agent guide at repo root

Update all references to the new layout:
- Dockerfile: COPY backend/go.mod, COPY backend/ (context = repo root)
- docker-compose files: context ../.., dockerfile deploy/docker/Dockerfile,
  env_file ../../.env, volume mounts ../../backend:/app
- deploy/quickstart/compose.yaml: dockerfile deploy/docker/Dockerfile
- CI: working-directory: backend for go commands, file deploy/docker/Dockerfile,
  coverage path backend/coverage.out, health_check backend/scripts/
- backend/Makefile: docker target uses -f ../deploy/docker/Dockerfile ../
- README: architecture tree, quickstart, config paths updated

Move root stray scripts (rename_models.*, run_m11_tests.sh, verify_build.sh,
gorm_bool_main.go) to backend/scripts/legacy/. All moves via git mv to
preserve history. Build, vet, SQLite tests, and docker compose config verified.
2026-07-07 14:44:12 +08:00

154 lines
4.2 KiB
Go

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
}