axe/internal/ratelimit/ratelimit.go
2026-10-18 22:07:25 +03:00

98 lines
3.0 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Package ratelimit — брутфорс-лимиты «оконный счётчик» по ключу
// (обычно IP). Два хранилища за общим интерфейсом Allower:
// - DBLimiter (db.go) — в проде: счётчик в БД переживает рестарт
// и общий для нескольких инстансов;
// - Limiter (этот файл) — in-memory, для юнит-тестов без базы.
package ratelimit
import (
"net/http"
"sync"
"time"
"github.com/gin-gonic/gin"
)
type bucket struct {
count int
start time.Time
}
// Allower — то, что умеет Middleware: засчитать попытку по ключу
// и сказать, разрешена ли она. *Limiter и *DBLimiter оба его реализуют.
type Allower interface {
Allow(key string) bool
}
// Limiter считает обращения по ключу в пределах скользящего окна.
// Значение ограничений задаётся при создании — там же, где оно
// наиболее очевидно читателю.
type Limiter struct {
mu sync.Mutex
limit int
window time.Duration
buckets map[string]*bucket
lastCleanup time.Time
}
// New создаёт лимитер на limit запросов за каждые window по одному ключу.
func New(limit int, window time.Duration) *Limiter {
return &Limiter{
limit: limit,
window: window,
buckets: make(map[string]*bucket),
}
}
// Allow засчитывает попытку и говорит, разрешена ли она.
func (l *Limiter) Allow(key string) bool {
now := time.Now()
l.mu.Lock()
defer l.mu.Unlock()
l.cleanupLocked(now)
b := l.buckets[key]
if b == nil || now.Sub(b.start) >= l.window {
l.buckets[key] = &bucket{count: 1, start: now}
return true
}
if b.count >= l.limit {
return false
}
b.count++
return true
}
// cleanupLocked выбрасывает протухшие ключи — карта не должна расти
// бесконечно под потоком разных IP. Чистка раз в окно, а не на каждый вызов.
func (l *Limiter) cleanupLocked(now time.Time) {
if now.Sub(l.lastCleanup) < l.window {
return
}
l.lastCleanup = now
for k, b := range l.buckets {
if now.Sub(b.start) >= l.window {
delete(l.buckets, k)
}
}
}
// ByIP — ключ лимита: адрес клиента. Просто и без чтения тела запроса.
func ByIP(c *gin.Context) string {
return c.ClientIP()
}
// Middleware ограничивает количество запросов на ключ (обычно — IP)
// и отвечает 429, не давая запросу дойти до хендлера.
func Middleware(l Allower, key func(*gin.Context) string, message string) gin.HandlerFunc {
return func(c *gin.Context) {
if !l.Allow(key(c)) {
c.AbortWithStatusJSON(http.StatusTooManyRequests, gin.H{"error": message})
return
}
c.Next()
}
}