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 }