package services import ( "context" "fmt" "sync" "time" "github.com/google/uuid" // Added import "go.uber.org/zap" "gorm.io/gorm" ) // PlaybackAnalyticsRateLimiter gère le rate limiting pour les analytics de playback // T0389: Create Playback Analytics Rate Limiting type PlaybackAnalyticsRateLimiter struct { db *gorm.DB logger *zap.Logger // Rate limiting par utilisateur (requêtes par minute) requestsPerMinute int requestsWindow time.Duration // Throttling (délai minimum entre requêtes) minRequestInterval time.Duration // Quotas (limites quotidiennes et hebdomadaires) dailyQuota int weeklyQuota int // Cache en mémoire pour le rate limiting mu sync.RWMutex userRequests map[uuid.UUID][]time.Time // userID -> []time.Time userLastRequest map[uuid.UUID]time.Time // userID -> last request time userDailyCount map[uuid.UUID]int // userID -> daily count userWeeklyCount map[uuid.UUID]int // userID -> weekly count lastCleanup time.Time } // RateLimitConfig configuration pour le rate limiter // T0389: Create Playback Analytics Rate Limiting type RateLimitConfig struct { RequestsPerMinute int // Nombre de requêtes par minute RequestsWindow time.Duration // Fenêtre de temps pour les requêtes MinRequestInterval time.Duration // Délai minimum entre requêtes (throttling) DailyQuota int // Quota quotidien WeeklyQuota int // Quota hebdomadaire } // DefaultRateLimitConfig retourne une configuration par défaut func DefaultRateLimitConfig() RateLimitConfig { return RateLimitConfig{ RequestsPerMinute: 60, // 60 requêtes par minute RequestsWindow: 1 * time.Minute, // Fenêtre de 1 minute MinRequestInterval: 1 * time.Second, // Minimum 1 seconde entre requêtes DailyQuota: 10000, // 10000 analytics par jour WeeklyQuota: 50000, // 50000 analytics par semaine } } // NewPlaybackAnalyticsRateLimiter crée un nouveau rate limiter pour les analytics // T0389: Create Playback Analytics Rate Limiting func NewPlaybackAnalyticsRateLimiter(db *gorm.DB, logger *zap.Logger, config RateLimitConfig) *PlaybackAnalyticsRateLimiter { if logger == nil { logger = zap.NewNop() } limiter := &PlaybackAnalyticsRateLimiter{ db: db, logger: logger, requestsPerMinute: config.RequestsPerMinute, requestsWindow: config.RequestsWindow, minRequestInterval: config.MinRequestInterval, dailyQuota: config.DailyQuota, weeklyQuota: config.WeeklyQuota, userRequests: make(map[uuid.UUID][]time.Time), userLastRequest: make(map[uuid.UUID]time.Time), userDailyCount: make(map[uuid.UUID]int), userWeeklyCount: make(map[uuid.UUID]int), lastCleanup: time.Now(), } // Démarrer le nettoyage périodique go limiter.cleanup() return limiter } // RateLimitResult représente le résultat d'une vérification de rate limit // T0389: Create Playback Analytics Rate Limiting type RateLimitResult struct { Allowed bool Reason string RetryAfter time.Duration Remaining int QuotaUsed int QuotaLimit int } // CheckRateLimit vérifie si une requête est autorisée selon les limites // T0389: Create Playback Analytics Rate Limiting func (rl *PlaybackAnalyticsRateLimiter) CheckRateLimit(ctx context.Context, userID uuid.UUID) (*RateLimitResult, error) { rl.mu.Lock() defer rl.mu.Unlock() now := time.Now() // Nettoyer périodiquement le cache if now.Sub(rl.lastCleanup) > 5*time.Minute { rl.cleanupLocked(now) rl.lastCleanup = now } // 1. Vérifier le throttling (délai minimum entre requêtes) if lastRequest, exists := rl.userLastRequest[userID]; exists { timeSinceLastRequest := now.Sub(lastRequest) if timeSinceLastRequest < rl.minRequestInterval { retryAfter := rl.minRequestInterval - timeSinceLastRequest return &RateLimitResult{ Allowed: false, Reason: "throttling: request too soon", RetryAfter: retryAfter, }, nil } } // 2. Vérifier le rate limiting (requêtes par minute) cutoff := now.Add(-rl.requestsWindow) validRequests := []time.Time{} if requests, exists := rl.userRequests[userID]; exists { for _, reqTime := range requests { if reqTime.After(cutoff) { validRequests = append(validRequests, reqTime) } } } if len(validRequests) >= rl.requestsPerMinute { // Calculer le temps d'attente jusqu'à ce que la plus ancienne requête expire oldestRequest := validRequests[0] retryAfter := oldestRequest.Add(rl.requestsWindow).Sub(now) if retryAfter < 0 { retryAfter = 0 } return &RateLimitResult{ Allowed: false, Reason: fmt.Sprintf("rate limit exceeded: %d requests per %v", rl.requestsPerMinute, rl.requestsWindow), RetryAfter: retryAfter, Remaining: 0, }, nil } // 3. Vérifier les quotas (quotas quotidiens et hebdomadaires) dailyCount, weeklyCount, err := rl.getQuotaCounts(ctx, userID, now) if err != nil { rl.logger.Warn("Failed to get quota counts, using cache", zap.Error(err), zap.String("user_id", userID.String())) // Utiliser les valeurs en cache en cas d'erreur dailyCount = rl.userDailyCount[userID] weeklyCount = rl.userWeeklyCount[userID] } if dailyCount >= rl.dailyQuota { return &RateLimitResult{ Allowed: false, Reason: fmt.Sprintf("daily quota exceeded: %d/%d", dailyCount, rl.dailyQuota), RetryAfter: timeUntilMidnight(now), QuotaUsed: dailyCount, QuotaLimit: rl.dailyQuota, }, nil } if weeklyCount >= rl.weeklyQuota { return &RateLimitResult{ Allowed: false, Reason: fmt.Sprintf("weekly quota exceeded: %d/%d", weeklyCount, rl.weeklyQuota), RetryAfter: timeUntilNextWeek(now), QuotaUsed: weeklyCount, QuotaLimit: rl.weeklyQuota, }, nil } // Toutes les vérifications passées, autoriser la requête validRequests = append(validRequests, now) rl.userRequests[userID] = validRequests rl.userLastRequest[userID] = now remaining := rl.requestsPerMinute - len(validRequests) return &RateLimitResult{ Allowed: true, Remaining: remaining, QuotaUsed: dailyCount, QuotaLimit: rl.dailyQuota, }, nil } // RecordRequest enregistre une requête (appelé après qu'une requête a été traitée avec succès) // T0389: Create Playback Analytics Rate Limiting func (rl *PlaybackAnalyticsRateLimiter) RecordRequest(ctx context.Context, userID uuid.UUID) error { rl.mu.Lock() defer rl.mu.Unlock() // Mettre à jour les compteurs de quota rl.userDailyCount[userID]++ rl.userWeeklyCount[userID]++ // Enregistrer dans la base de données pour persistance // Note: On pourrait créer une table de quotas si nécessaire // Pour l'instant, on utilise uniquement le cache en mémoire return nil } // GetQuotaInfo retourne les informations de quota pour un utilisateur // T0389: Create Playback Analytics Rate Limiting func (rl *PlaybackAnalyticsRateLimiter) GetQuotaInfo(ctx context.Context, userID uuid.UUID) (map[string]interface{}, error) { rl.mu.RLock() defer rl.mu.RUnlock() now := time.Now() dailyCount, weeklyCount, err := rl.getQuotaCounts(ctx, userID, now) if err != nil { // Utiliser les valeurs en cache dailyCount = rl.userDailyCount[userID] weeklyCount = rl.userWeeklyCount[userID] } // Calculer les requêtes restantes dans la fenêtre actuelle cutoff := now.Add(-rl.requestsWindow) validRequests := []time.Time{} if requests, exists := rl.userRequests[userID]; exists { for _, reqTime := range requests { if reqTime.After(cutoff) { validRequests = append(validRequests, reqTime) } } } remainingRequests := rl.requestsPerMinute - len(validRequests) if remainingRequests < 0 { remainingRequests = 0 } return map[string]interface{}{ "rate_limit": map[string]interface{}{ "requests_per_minute": rl.requestsPerMinute, "remaining": remainingRequests, "window": rl.requestsWindow.String(), }, "throttling": map[string]interface{}{ "min_interval": rl.minRequestInterval.String(), }, "quotas": map[string]interface{}{ "daily": map[string]interface{}{ "used": dailyCount, "limit": rl.dailyQuota, "remaining": rl.dailyQuota - dailyCount, }, "weekly": map[string]interface{}{ "used": weeklyCount, "limit": rl.weeklyQuota, "remaining": rl.weeklyQuota - weeklyCount, }, }, }, nil } // getQuotaCounts récupère les compteurs de quota depuis la base de données // T0389: Create Playback Analytics Rate Limiting func (rl *PlaybackAnalyticsRateLimiter) getQuotaCounts(ctx context.Context, userID uuid.UUID, now time.Time) (int, int, error) { // Calculer les dates de début startOfDay := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) startOfWeek := startOfDay weekday := int(now.Weekday()) if weekday == 0 { weekday = 7 // Dimanche = 7 } startOfWeek = startOfWeek.AddDate(0, 0, -weekday+1) // Lundi // Compter les analytics enregistrées aujourd'hui var dailyCount int64 err := rl.db.WithContext(ctx). Model(&struct { Count int64 }{}). Select("COUNT(*)"). Table("playback_analytics"). Where("user_id = ? AND created_at >= ?", userID.String(), startOfDay). Scan(&dailyCount).Error if err != nil { return 0, 0, err } // Compter les analytics enregistrées cette semaine var weeklyCount int64 err = rl.db.WithContext(ctx). Model(&struct { Count int64 }{}). Select("COUNT(*)"). Table("playback_analytics"). Where("user_id = ? AND created_at >= ?", userID.String(), startOfWeek). Scan(&weeklyCount).Error if err != nil { return 0, 0, err } return int(dailyCount), int(weeklyCount), nil } // cleanup nettoie périodiquement le cache // T0389: Create Playback Analytics Rate Limiting func (rl *PlaybackAnalyticsRateLimiter) cleanup() { ticker := time.NewTicker(5 * time.Minute) defer ticker.Stop() for range ticker.C { rl.mu.Lock() rl.cleanupLocked(time.Now()) rl.lastCleanup = time.Now() rl.mu.Unlock() } } // cleanupLocked nettoie le cache (doit être appelé avec le mutex verrouillé) // T0389: Create Playback Analytics Rate Limiting func (rl *PlaybackAnalyticsRateLimiter) cleanupLocked(now time.Time) { cutoff := now.Add(-rl.requestsWindow) // Nettoyer les requêtes expirées for userID, requests := range rl.userRequests { validRequests := []time.Time{} for _, reqTime := range requests { if reqTime.After(cutoff) { validRequests = append(validRequests, reqTime) } } if len(validRequests) == 0 { delete(rl.userRequests, userID) } else { rl.userRequests[userID] = validRequests } } // Nettoyer les dernières requêtes si trop anciennes cutoffLastRequest := now.Add(-1 * time.Hour) for userID, lastRequest := range rl.userLastRequest { if lastRequest.Before(cutoffLastRequest) { delete(rl.userLastRequest, userID) } } } // timeUntilMidnight calcule le temps jusqu'à minuit func timeUntilMidnight(now time.Time) time.Duration { midnight := time.Date(now.Year(), now.Month(), now.Day()+1, 0, 0, 0, 0, now.Location()) return midnight.Sub(now) } // timeUntilNextWeek calcule le temps jusqu'au prochain lundi func timeUntilNextWeek(now time.Time) time.Duration { weekday := int(now.Weekday()) if weekday == 0 { weekday = 7 // Dimanche = 7 } daysUntilMonday := 8 - weekday // Jours jusqu'au prochain lundi nextMonday := time.Date(now.Year(), now.Month(), now.Day()+daysUntilMonday, 0, 0, 0, 0, now.Location()) return nextMonday.Sub(now) }