package httpserver import ( "math" "net/http" "strconv" "strings" "sync" "time" ) type rateLimitStore struct { mu sync.Mutex ratePerSec float64 burst float64 lastCleanup time.Time entries map[string]*rateLimitEntry } type rateLimitEntry struct { tokens float64 lastSeen time.Time } func newRateLimitStore(perMinute, burst int) *rateLimitStore { return &rateLimitStore{ ratePerSec: float64(perMinute) / 60, burst: float64(burst), entries: make(map[string]*rateLimitEntry), } } func (s *rateLimitStore) Allow(key string, now time.Time) (bool, time.Duration) { key = strings.TrimSpace(key) if key == "" { key = "global" } s.mu.Lock() defer s.mu.Unlock() if entry, ok := s.entries[key]; ok { elapsed := now.Sub(entry.lastSeen).Seconds() entry.tokens = math.Min(s.burst, entry.tokens+(elapsed*s.ratePerSec)) entry.lastSeen = now if entry.tokens >= 1 { entry.tokens-- s.cleanup(now) return true, 0 } s.cleanup(now) return false, retryAfter(entry.tokens, s.ratePerSec) } s.entries[key] = &rateLimitEntry{ tokens: s.burst - 1, lastSeen: now, } s.cleanup(now) return true, 0 } func (s *rateLimitStore) cleanup(now time.Time) { if !s.lastCleanup.IsZero() && now.Sub(s.lastCleanup) < 5*time.Minute { return } for key, entry := range s.entries { if now.Sub(entry.lastSeen) > 15*time.Minute { delete(s.entries, key) } } s.lastCleanup = now } func retryAfter(tokens, ratePerSec float64) time.Duration { if ratePerSec <= 0 { return time.Minute } if tokens < 0 { tokens = 0 } seconds := (1 - tokens) / ratePerSec if seconds < 1 { seconds = 1 } return time.Duration(math.Ceil(seconds * float64(time.Second))) } func retryAfterHeader(delay time.Duration) string { seconds := int(math.Ceil(delay.Seconds())) if seconds < 1 { seconds = 1 } return strconv.Itoa(seconds) } func (h *handler) loginRateLimit(next http.Handler) http.Handler { return h.rateLimit(h.loginRateLimiter, func(r *http.Request) string { return clientIP(r) }, func(w http.ResponseWriter, r *http.Request, delay time.Duration) { w.Header().Set("Retry-After", retryAfterHeader(delay)) http.Error(w, "too many login attempts", http.StatusTooManyRequests) })(next) } func (h *handler) clientAPIRateLimit(next http.Handler) http.Handler { return h.rateLimit(h.clientRateLimiter, func(r *http.Request) string { return clientIP(r) }, func(w http.ResponseWriter, r *http.Request, delay time.Duration) { w.Header().Set("Retry-After", retryAfterHeader(delay)) writeJSON(w, http.StatusTooManyRequests, map[string]any{"error": "rate limit exceeded"}) })(next) } func (h *handler) rateLimit(store *rateLimitStore, keyFn func(*http.Request) string, reject func(http.ResponseWriter, *http.Request, time.Duration)) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { key := clientIP(r) if keyFn != nil { key = keyFn(r) } allowed, delay := store.Allow(key, time.Now().UTC()) if !allowed { reject(w, r, delay) return } next.ServeHTTP(w, r) }) } }