NextMyDay/internal/core/middleware/ratelimit.go
Claus Lohmar d11e14295b feat(core): add the store, identity plumbing and the door (SPEC-003/004)
- store: SQLite (WAL, foreign keys ON, CGO-free driver) + the embedded
  migration runner; each module's migrations are name-prefixed (D-012)
- audit: the append-only audit log; the actor threads via context (R-1)
- users: the user_id/GUID/email anchors (D-013)
- config: the YAML settings file (D-035) + validation
- httpx: cookies · loopback detection · trusted-proxy XFF · CSRF · security
  headers · redaction
- middleware: correlation id · audit · rate limiting
- mail: the invite/recovery mailer seam
- server: the core DOOR — the resolution switch, the dispatch registry
  (RegisterModule), the role gates, /setup · enroll/login · recovery flows,
  the reserved `static` workflow, and the loopback-only bootstrap (D-025)
2026-10-14 10:53:51 +00:00

129 lines
3 KiB
Go

package middleware
import (
"net/http"
"strconv"
"sync"
"time"
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/routing"
)
// RateLimiter is a per-key fixed-window limiter (A3, SPEC-003 §4/AC-9). It
// guards the auth-sensitive routes: /{GUID}, /access, /setup. State is
// in-memory and single-process, which matches the single-instance model
// (D-012).
type RateLimiter struct {
mu sync.Mutex
limit int
window time.Duration
now func() time.Time
state map[string]*window
}
type window struct {
count int
reset time.Time
}
// DefaultRateLimit / DefaultRateWindow are the code defaults for the
// auth-sensitive routes.
const (
DefaultRateLimit = 30
DefaultRateWindow = time.Minute
)
// NewRateLimiter builds a limiter allowing limit requests per window per key.
// Non-positive values fall back to the defaults.
func NewRateLimiter(limit int, win time.Duration) *RateLimiter {
if limit <= 0 {
limit = DefaultRateLimit
}
if win <= 0 {
win = DefaultRateWindow
}
return &RateLimiter{
limit: limit,
window: win,
now: time.Now,
state: make(map[string]*window),
}
}
// Allow records a request for key and reports whether it is within the limit.
func (l *RateLimiter) Allow(key string) bool {
l.mu.Lock()
defer l.mu.Unlock()
now := l.now()
w, ok := l.state[key]
if !ok || !now.Before(w.reset) {
l.state[key] = &window{count: 1, reset: now.Add(l.window)}
return true
}
w.count++
return w.count <= l.limit
}
// SetClock injects a clock (tests).
func (l *RateLimiter) SetClock(fn func() time.Time) {
if fn == nil {
return
}
l.mu.Lock()
defer l.mu.Unlock()
l.now = fn
}
// RetryAfter returns the whole seconds until the window resets for key (for the
// Retry-After header). It is best-effort.
func (l *RateLimiter) RetryAfter(key string) int {
l.mu.Lock()
defer l.mu.Unlock()
w, ok := l.state[key]
if !ok {
return 0
}
d := time.Until(w.reset)
if d < 0 {
return 0
}
return int(d.Seconds()) + 1
}
// Class classifies a path into an auth-sensitive bucket, or "" when the path is
// not rate-limited. /access and /setup are explicit; any non-reserved first
// segment is a GUID lookup.
func Class(path string) string {
p := routing.Parse(path)
if len(p) == 0 || p[0] == "" {
return ""
}
switch p[0] {
case "access", "setup":
return p[0]
}
if routing.IsReservedWorkflow(p[0]) {
return ""
}
return "guid"
}
// AuthSensitive reports whether path is one of the rate-limited routes (A3).
func AuthSensitive(path string) bool { return Class(path) != "" }
// Middleware wraps next with a limiter keyed by keyFn. It answers 429 with a
// Retry-After header once the limit is exceeded.
func (l *RateLimiter) Middleware(keyFn func(*http.Request) string, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
key := keyFn(r)
if !l.Allow(key) {
if secs := l.RetryAfter(key); secs > 0 {
w.Header().Set("Retry-After", strconv.Itoa(secs))
}
http.Error(w, "too many requests", http.StatusTooManyRequests)
return
}
next.ServeHTTP(w, r)
})
}