Files
hotdog-cms/internal/endpoint/ratelimit.go
T
jcoffey-dev 85bbf5f828 Forms, the endpoint, and search from it
Forms are YAML in forms/, rendered by {{ form }} with a small script and stylesheet the build publishes. cl-cms endpoint serves /_cl/ for any number of sites by host: it checks Origin, a per-IP rate limit, text-only bodies, a trap field, a signed 3 s to 24 h token and optional Turnstile, then the declared fields (types, lengths, options, no line breaks in single-line fields), and delivers by SMTP over TLS, optionally appending to a 0600 JSONL file. It also answers search from a collection's search.json, so a site can search in the browser or from the endpoint. cl-cms serve can pass /_cl/ to an endpoint, and -reload=false leaves out the live-reload script for headless browsers.
2026-10-10 14:10:17 -07:00

50 lines
1.2 KiB
Go

package endpoint
import (
"sync"
"time"
)
// limiter counts attempts per key in a sliding window, in memory. Behind a
// load balancer each endpoint counts on its own, so the effective limit is
// the configured one times the number of endpoints; for a contact form that
// is fine. A shared limit would need shared state, which is the first thing
// here that would ask for a data store.
type limiter struct {
mu sync.Mutex
count int
window time.Duration
hits map[string][]time.Time
calls int
}
func newLimiter(count int, window time.Duration) *limiter {
return &limiter{count: count, window: window, hits: map[string][]time.Time{}}
}
// allow records an attempt and reports whether it is within the limit.
func (l *limiter) allow(key string, now time.Time) bool {
l.mu.Lock()
defer l.mu.Unlock()
l.calls++
if l.calls%1000 == 0 { // now and then, forget addresses that have gone quiet
for k, ts := range l.hits {
if len(ts) == 0 || now.Sub(ts[len(ts)-1]) > l.window {
delete(l.hits, k)
}
}
}
ts := l.hits[key]
cut := 0
for cut < len(ts) && now.Sub(ts[cut]) > l.window {
cut++
}
ts = ts[cut:]
if len(ts) >= l.count {
l.hits[key] = ts
return false
}
l.hits[key] = append(ts, now)
return true
}