package subsonic
import (
"cmp"
"context"
"hash/maphash"
"sync"
"time"
"github.com/navidrome/navidrome/consts"
)
type authLimiter struct {
limit int
window time.Duration
seed maphash.Seed
mu sync.Mutex
keys map[uint64]*authAttempts
lastSweep time.Time
}
type authAttempts struct {
failures int
start time.Time
slots chan struct{}
refs int
}
type authSlot struct {
limiter *authLimiter
entry *authAttempts
}
func newAuthLimiter(limit int, window time.Duration) *authLimiter {
if limit <= 0 {
return nil
}
return &authLimiter{
limit: limit,
window: cmp.Or(window, consts.DefaultAuthWindowLength),
seed: maphash.MakeSeed(),
keys: map[uint64]*authAttempts{},
}
}
func (l *authLimiter) acquire(ctx context.Context, key string) (*authSlot, bool) {
if l == nil {
return nil, true
}
a, ok := l.reserve(key)
if !ok {
return nil, false
}
select {
case a.slots <- struct{}{}:
case <-ctx.Done():
l.unref(a)
return nil, false
}
l.mu.Lock()
blocked := a.failures >= l.limit
if blocked {
a.refs--
}
l.mu.Unlock()
if blocked {
<-a.slots
return nil, false
}
return &authSlot{limiter: l, entry: a}, true
}
func (l *authLimiter) reserve(key string) (*authAttempts, bool) {
now := time.Now()
l.mu.Lock()
defer l.mu.Unlock()
l.sweep(now)
h := maphash.String(l.seed, key)
a := l.keys[h]
switch {
case a == nil:
a = &authAttempts{start: now, slots: make(chan struct{}, l.limit)}
l.keys[h] = a
case now.Sub(a.start) >= l.window:
a.failures, a.start = 0, now
}
if a.failures >= l.limit {
return nil, false
}
a.refs++
return a, true
}
func (l *authLimiter) unref(a *authAttempts) {
l.mu.Lock()
a.refs--
l.mu.Unlock()
}
func (s *authSlot) release(failed bool) {
if s == nil {
return
}
s.limiter.mu.Lock()
if failed {
s.entry.failures++
}
s.entry.refs--
s.limiter.mu.Unlock()
<-s.entry.slots
}
func (l *authLimiter) sweep(now time.Time) {
if now.Sub(l.lastSweep) < l.window {
return
}
for h, a := range l.keys {
if a.refs == 0 && now.Sub(a.start) >= l.window {
delete(l.keys, h)
}
}
l.lastSweep = now
}