package middlewares import ( "context" "encoding/json" "errors" "fmt" configs "goFiber/config" database "goFiber/database/config" "goFiber/database/models" "log" "net/http" "strconv" "strings" "time" "github.com/gofiber/fiber/v3" "github.com/redis/go-redis/v9" "gorm.io/gorm" ) type rateLimitRuntime struct { Name string `json:"name"` MaxRequests int64 `json:"max_requests"` WindowSeconds int `json:"window_seconds"` IsActive bool `json:"is_active"` } // RequireRateLimit applies Redis-backed per-IP rate limiting by setting name. func RequireRateLimit(name string, fallbackMax int64, fallbackWindowSeconds int) fiber.Handler { return func(c fiber.Ctx) error { if database.DB == nil { return c.Next() } setting, err := loadRateLimitRuntime(name, fallbackMax, fallbackWindowSeconds) if err != nil { return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": "rate limit configuration error"}) } if !setting.IsActive { return c.Next() } if database.RedisClient == nil { rateLimitLogf("[rate-limit][warn] redis unavailable, skipping enforcement name=%s", setting.Name) return c.Next() } ip := strings.TrimSpace(c.IP()) if ip == "" { ip = "unknown" } counterKey := fmt.Sprintf("ratelimit:%s:%s", setting.Name, ip) count, err := database.RedisClient.Incr(context.Background(), counterKey).Result() if err != nil { return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": "rate limit check failed"}) } if count == 1 { _ = database.RedisClient.Expire(context.Background(), counterKey, time.Duration(setting.WindowSeconds)*time.Second).Err() } if count > setting.MaxRequests { ttl, _ := database.RedisClient.TTL(context.Background(), counterKey).Result() retryAfter := int(ttl.Seconds()) if retryAfter < 1 { retryAfter = setting.WindowSeconds } c.Set("Retry-After", strconv.Itoa(retryAfter)) log.Printf("[rate-limit][blocked] name=%s ip=%s count=%d max=%d window=%ds", setting.Name, ip, count, setting.MaxRequests, setting.WindowSeconds) return c.Status(http.StatusTooManyRequests).JSON(fiber.Map{ "error": "too many requests", "retry_after": retryAfter, }) } rateLimitLogf("[rate-limit][allow] name=%s ip=%s count=%d max=%d window=%ds", setting.Name, ip, count, setting.MaxRequests, setting.WindowSeconds) return c.Next() } } func loadRateLimitRuntime(name string, fallbackMax int64, fallbackWindowSeconds int) (*rateLimitRuntime, error) { cacheKey := "ratelimit:setting:" + name if cached, err := database.Get(cacheKey); err == nil { var s rateLimitRuntime if jsonErr := json.Unmarshal([]byte(cached), &s); jsonErr == nil { return &s, nil } } else if !errors.Is(err, redis.Nil) { return nil, err } setting := &rateLimitRuntime{ Name: name, MaxRequests: fallbackMax, WindowSeconds: fallbackWindowSeconds, IsActive: true, } var dbSetting models.RateLimitSetting if err := database.DB.Where("name = ?", name).First(&dbSetting).Error; err != nil { if !errors.Is(err, gorm.ErrRecordNotFound) { return nil, err } rateLimitLogf("[rate-limit][config] setting=%s not found, using fallback max=%d window=%ds", name, fallbackMax, fallbackWindowSeconds) } else { setting.MaxRequests = dbSetting.MaxRequests setting.WindowSeconds = dbSetting.WindowSeconds setting.IsActive = dbSetting.IsActive rateLimitLogf("[rate-limit][config] loaded from db name=%s active=%t max=%d window=%ds", name, setting.IsActive, setting.MaxRequests, setting.WindowSeconds) } cacheJSON, _ := json.Marshal(setting) _ = database.SetEx(cacheKey, string(cacheJSON), 60) return setting, nil } func rateLimitLogf(format string, args ...interface{}) { if configs.AppConfig != nil && configs.AppConfig.CorsDebug { log.Printf(format, args...) } }