Files
summercms/surf/limiter_store.go
Jakub Zych 68c6ab85c5 feat(06-02): add fixed-window limiter, Store, and ClientIP
- MemoryStore mirrors Laravel tooManyAttempts-before-hit first-hit-wins
- FixedWindowLimiter + throttle factory; success and 429 rate-limit headers
- Trusted-proxy ClientIP; remove noOpLimit; keep Limiter interface seam
2026-09-19 19:35:53 +02:00

116 lines
2.6 KiB
Go

package surf
import (
"sync"
"time"
)
// Store mirrors Illuminate\Cache\RateLimiter's hit/tooManyAttempts/
// availableIn control flow: a fixed window, first-hit-wins (an existing
// unexpired window is never extended), with resetAttempts as a side effect
// of TooManyAttempts observing an expired window.
type Store interface {
Hit(key string, decay time.Duration) (attempts int)
TooManyAttempts(key string, max int) bool
AvailableIn(key string) time.Duration
}
type counterEntry struct {
count int
resetAt time.Time
}
// MemoryStore is an in-process, mutex-guarded Store.
type MemoryStore struct {
mu sync.Mutex
entries map[string]*counterEntry
sweep time.Duration
stop chan struct{}
}
// NewMemoryStore returns an in-process, mutex-guarded Store. sweep controls
// the background expired-entry cleanup interval (memory hygiene only --
// correctness does not depend on it, since expiry is checked lazily).
// A non-positive sweep disables the background goroutine.
func NewMemoryStore(sweep time.Duration) *MemoryStore {
s := &MemoryStore{
entries: make(map[string]*counterEntry),
sweep: sweep,
stop: make(chan struct{}),
}
if sweep > 0 {
go s.loop()
}
return s
}
func (s *MemoryStore) loop() {
ticker := time.NewTicker(s.sweep)
defer ticker.Stop()
for {
select {
case <-ticker.C:
s.purge()
case <-s.stop:
return
}
}
}
func (s *MemoryStore) purge() {
s.mu.Lock()
defer s.mu.Unlock()
now := time.Now()
for k, e := range s.entries {
if now.After(e.resetAt) {
delete(s.entries, k)
}
}
}
// Hit increments key's counter, opening a decay window on first hit
// (first-hit-wins: an existing unexpired window is never extended).
func (s *MemoryStore) Hit(key string, decay time.Duration) int {
s.mu.Lock()
defer s.mu.Unlock()
now := time.Now()
e, ok := s.entries[key]
if !ok || now.After(e.resetAt) {
e = &counterEntry{count: 0, resetAt: now.Add(decay)}
s.entries[key] = e
}
e.count++
return e.count
}
// TooManyAttempts is true only while count >= max and the window has not
// expired. An expired window is deleted (PHP resetAttempts) and returns false.
func (s *MemoryStore) TooManyAttempts(key string, max int) bool {
s.mu.Lock()
defer s.mu.Unlock()
e, ok := s.entries[key]
if !ok {
return false
}
if time.Now().After(e.resetAt) {
delete(s.entries, key)
return false
}
return e.count >= max
}
// AvailableIn is the time until the window resets, or 0 if the key is absent.
func (s *MemoryStore) AvailableIn(key string) time.Duration {
s.mu.Lock()
defer s.mu.Unlock()
e, ok := s.entries[key]
if !ok {
return 0
}
d := e.resetAt.Sub(time.Now())
if d < 0 {
return 0
}
return d
}