veza/veza-backend-api/internal/services/webhook_service.go
2025-12-03 20:29:37 +01:00

218 lines
5.8 KiB
Go

package services
import (
"bytes"
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"net/http"
"time"
"veza-backend-api/internal/models"
"github.com/google/uuid"
"go.uber.org/zap"
"gorm.io/gorm"
)
// WebhookService gère les webhooks
type WebhookService struct {
db *gorm.DB
logger *zap.Logger
secret string
client *http.Client
}
// WebhookPayload représente le payload d'un webhook
type WebhookPayload struct {
Event string `json:"event"`
Timestamp time.Time `json:"timestamp"`
Data map[string]interface{} `json:"data"`
}
// NewWebhookService crée un nouveau service de webhooks
func NewWebhookService(db *gorm.DB, logger *zap.Logger, secret string) *WebhookService {
return &WebhookService{
db: db,
logger: logger,
secret: secret,
client: &http.Client{
Timeout: 10 * time.Second,
},
}
}
// RegisterWebhook enregistre une nouvelle URL de webhook
func (s *WebhookService) RegisterWebhook(ctx context.Context, userID uuid.UUID, url string, events []string) (*models.Webhook, error) {
webhook := &models.Webhook{
UserID: userID,
URL: url,
Events: events,
Active: true,
CreatedAt: time.Now(),
}
if err := s.db.WithContext(ctx).Create(webhook).Error; err != nil {
return nil, fmt.Errorf("failed to register webhook: %w", err)
}
s.logger.Info("Webhook registered",
zap.String("user_id", userID.String()),
zap.String("url", url),
zap.Strings("events", events))
return webhook, nil
}
// DeliverWebhook envoie un webhook avec retry et signature HMAC
func (s *WebhookService) DeliverWebhook(ctx context.Context, webhook *models.Webhook, event string, data map[string]interface{}) error {
payload := WebhookPayload{
Event: event,
Timestamp: time.Now(),
Data: data,
}
jsonData, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("failed to marshal payload: %w", err)
}
// Générer signature HMAC
signature := s.generateSignature(jsonData)
// Créer la requête HTTP
req, err := http.NewRequestWithContext(ctx, "POST", webhook.URL, bytes.NewBuffer(jsonData))
if err != nil {
return fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Veza-Signature", signature)
req.Header.Set("X-Veza-Event", event)
req.Header.Set("X-Veza-Timestamp", payload.Timestamp.Format(time.RFC3339))
// Envoyer avec retry
maxRetries := 3
backoff := time.Second
for i := 0; i < maxRetries; i++ {
resp, err := s.client.Do(req)
if err != nil {
s.logger.Warn("Webhook delivery failed, retrying",
zap.Int("attempt", i+1),
zap.Error(err))
if i < maxRetries-1 {
time.Sleep(backoff)
backoff *= 2 // Exponential backoff
continue
}
return fmt.Errorf("webhook delivery failed after %d attempts: %w", maxRetries, err)
}
defer resp.Body.Close()
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
s.logger.Info("Webhook delivered successfully",
zap.String("url", webhook.URL),
zap.String("event", event))
return nil
}
s.logger.Warn("Webhook returned non-200 status",
zap.String("url", webhook.URL),
zap.Int("status", resp.StatusCode))
}
return fmt.Errorf("webhook delivery failed")
}
// generateSignature génère une signature HMAC-SHA256
func (s *WebhookService) generateSignature(payload []byte) string {
mac := hmac.New(sha256.New, []byte(s.secret))
mac.Write(payload)
return hex.EncodeToString(mac.Sum(nil))
}
// VerifySignature vérifie une signature HMAC
func (s *WebhookService) VerifySignature(signature string, payload []byte) bool {
expected := s.generateSignature(payload)
return hmac.Equal([]byte(signature), []byte(expected))
}
// TriggerEvent déclenche un événement pour tous les webhooks concernés
func (s *WebhookService) TriggerEvent(ctx context.Context, event string, data map[string]interface{}, userID *uuid.UUID) error {
// Récupérer les webhooks actifs pour cet événement
var webhooks []models.Webhook
query := s.db.WithContext(ctx).Where("active = ? AND events @> ARRAY[?]", true, event)
if userID != nil {
query = query.Where("user_id = ?", *userID)
}
if err := query.Find(&webhooks).Error; err != nil {
return fmt.Errorf("failed to fetch webhooks: %w", err)
}
// Envoyer les webhooks en async
for _, webhook := range webhooks {
go func(w models.Webhook) {
if err := s.DeliverWebhook(ctx, &w, event, data); err != nil {
s.logger.Error("Failed to deliver webhook",
zap.Error(err),
zap.String("url", w.URL),
zap.String("event", event))
}
}(webhook)
}
return nil
}
// ListWebhooks liste les webhooks d'un utilisateur
func (s *WebhookService) ListWebhooks(ctx context.Context, userID uuid.UUID) ([]models.Webhook, error) {
var webhooks []models.Webhook
if err := s.db.WithContext(ctx).
Where("user_id = ?", userID).
Find(&webhooks).Error; err != nil {
return nil, fmt.Errorf("failed to list webhooks: %w", err)
}
return webhooks, nil
}
// GetWebhook récupère un webhook par son ID et userID
func (s *WebhookService) GetWebhook(ctx context.Context, webhookID, userID uuid.UUID) (*models.Webhook, error) {
var webhook models.Webhook
if err := s.db.WithContext(ctx).
Where("id = ? AND user_id = ?", webhookID, userID).
First(&webhook).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return nil, fmt.Errorf("webhook not found")
}
return nil, fmt.Errorf("failed to get webhook: %w", err)
}
return &webhook, nil
}
// DeleteWebhook supprime un webhook
func (s *WebhookService) DeleteWebhook(ctx context.Context, webhookID, userID uuid.UUID) error {
result := s.db.WithContext(ctx).
Where("id = ? AND user_id = ?", webhookID, userID).
Delete(&models.Webhook{})
if result.Error != nil {
return fmt.Errorf("failed to delete webhook: %w", result.Error)
}
if result.RowsAffected == 0 {
return fmt.Errorf("webhook not found")
}
return nil
}