refactor(10.2-01): nest framework packages under modules
- Move remaining beach packages and embedded admin assets\n- Rewrite framework, example, build, and gate paths
This commit is contained in:
182
modules/surf/limiter.go
Normal file
182
modules/surf/limiter.go
Normal file
@@ -0,0 +1,182 @@
|
||||
package surf
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/bouncer"
|
||||
"git.golem15.com/golem15/summercms/modules/pact"
|
||||
)
|
||||
|
||||
const tooManyAttemptsBody = `{"message":"Too Many Attempts."}`
|
||||
|
||||
// Bucket is one named rate-limit definition. Key composes the limiter key
|
||||
// from the request (token id, IP, route param -- D-01's per-bucket rule).
|
||||
type Bucket struct {
|
||||
Name string
|
||||
Max int
|
||||
Decay time.Duration
|
||||
Key func(r *http.Request) string
|
||||
}
|
||||
|
||||
// BucketProvider is implemented by plugins that declare named buckets (not a
|
||||
// pact interface: it lives in surf and is type-asserted directly in
|
||||
// Assemble/BuildRouter, since pact cannot import surf without a cycle).
|
||||
type BucketProvider interface {
|
||||
Buckets() map[string]Bucket
|
||||
}
|
||||
|
||||
// FixedWindowLimiter is the concrete rate limiter (distinct from the
|
||||
// pre-existing surf.Limiter interface). It owns the Store, the named-bucket
|
||||
// table, and the inline-throttle parser, and produces the "throttle"
|
||||
// middleware factory's per-route pact.Middleware.
|
||||
type FixedWindowLimiter struct {
|
||||
store Store
|
||||
trusted []netip.Prefix
|
||||
mu sync.Mutex
|
||||
buckets map[string]Bucket
|
||||
owners map[string]string
|
||||
inline map[string]Bucket
|
||||
}
|
||||
|
||||
// NewFixedWindowLimiter is the ONE constructor signature for this type --
|
||||
// trusted is required at construction (not a later setter) because both
|
||||
// named-bucket Key closures (registered later via RegisterBucket) and the
|
||||
// inline "N,M" throttle's own key resolver need the same trusted-proxy list.
|
||||
func NewFixedWindowLimiter(store Store, trusted []netip.Prefix) *FixedWindowLimiter {
|
||||
cp := make([]netip.Prefix, len(trusted))
|
||||
copy(cp, trusted)
|
||||
return &FixedWindowLimiter{
|
||||
store: store,
|
||||
trusted: cp,
|
||||
buckets: make(map[string]Bucket),
|
||||
owners: make(map[string]string),
|
||||
inline: make(map[string]Bucket),
|
||||
}
|
||||
}
|
||||
|
||||
// RegisterBucket stores a named bucket. Duplicate names fail.
|
||||
func (l *FixedWindowLimiter) RegisterBucket(pluginID, name string, b Bucket) error {
|
||||
if l == nil {
|
||||
return fmt.Errorf("surf: limiter is nil")
|
||||
}
|
||||
if name == "" {
|
||||
return fmt.Errorf("surf: plugin %q registered empty bucket", pluginID)
|
||||
}
|
||||
switch {
|
||||
case l.store == nil:
|
||||
return fmt.Errorf("surf: plugin %q bucket %q: limiter has no store", pluginID, name)
|
||||
case b.Key == nil:
|
||||
return fmt.Errorf("surf: plugin %q bucket %q has nil Key", pluginID, name)
|
||||
case b.Max < 1:
|
||||
return fmt.Errorf("surf: plugin %q bucket %q has Max < 1", pluginID, name)
|
||||
case b.Decay <= 0:
|
||||
return fmt.Errorf("surf: plugin %q bucket %q has non-positive Decay", pluginID, name)
|
||||
}
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
if existing, ok := l.owners[name]; ok {
|
||||
return fmt.Errorf("surf: bucket %q already registered by %s", name, existing)
|
||||
}
|
||||
b.Name = name
|
||||
l.buckets[name] = b
|
||||
l.owners[name] = pluginID
|
||||
return nil
|
||||
}
|
||||
|
||||
// Middleware builds the throttle: factory body. param is either a
|
||||
// registered bucket name or a literal "N,M" pair (inline throttle).
|
||||
func (l *FixedWindowLimiter) Middleware(param string) pact.Middleware {
|
||||
failClosed := func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
})
|
||||
}
|
||||
if l == nil {
|
||||
return failClosed
|
||||
}
|
||||
b, err := l.resolve(param)
|
||||
if err != nil || b.Key == nil || l.store == nil {
|
||||
return failClosed
|
||||
}
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
key := b.Key(r)
|
||||
allowed, attempts, retryAfter := l.store.Attempt(key, b.Max, b.Decay)
|
||||
if !allowed {
|
||||
secs := int(retryAfter / time.Second)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Retry-After", strconv.Itoa(secs))
|
||||
w.Header().Set("X-RateLimit-Reset", strconv.FormatInt(time.Now().Add(retryAfter).Unix(), 10))
|
||||
w.Header().Set("X-RateLimit-Limit", strconv.Itoa(b.Max))
|
||||
w.Header().Set("X-RateLimit-Remaining", "0")
|
||||
w.WriteHeader(http.StatusTooManyRequests)
|
||||
_, _ = w.Write([]byte(tooManyAttemptsBody))
|
||||
return
|
||||
}
|
||||
remaining := b.Max - attempts
|
||||
if remaining < 0 {
|
||||
remaining = 0
|
||||
}
|
||||
w.Header().Set("X-RateLimit-Limit", strconv.Itoa(b.Max))
|
||||
w.Header().Set("X-RateLimit-Remaining", strconv.Itoa(remaining))
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ValidateThrottle is called once per route at Assemble time so a malformed
|
||||
// inline "N,M" or an unregistered bucket name fails boot instead of the
|
||||
// first live request.
|
||||
func (l *FixedWindowLimiter) ValidateThrottle(param string) error {
|
||||
if l == nil {
|
||||
return fmt.Errorf("surf: limiter is nil")
|
||||
}
|
||||
if l.store == nil {
|
||||
return fmt.Errorf("surf: limiter has no store")
|
||||
}
|
||||
_, err := l.resolve(param)
|
||||
return err
|
||||
}
|
||||
|
||||
func (l *FixedWindowLimiter) resolve(param string) (Bucket, error) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
if b, ok := l.buckets[param]; ok {
|
||||
return b, nil
|
||||
}
|
||||
if b, ok := l.inline[param]; ok {
|
||||
return b, nil
|
||||
}
|
||||
nStr, mStr, ok := strings.Cut(param, ",")
|
||||
if !ok {
|
||||
return Bucket{}, fmt.Errorf("surf: unknown throttle %q", param)
|
||||
}
|
||||
n, errN := strconv.Atoi(strings.TrimSpace(nStr))
|
||||
m, errM := strconv.Atoi(strings.TrimSpace(mStr))
|
||||
if errN != nil || errM != nil || n < 1 || m < 1 || int64(m) > math.MaxInt64/int64(time.Minute) {
|
||||
return Bucket{}, fmt.Errorf("surf: malformed throttle %q", param)
|
||||
}
|
||||
trusted := l.trusted
|
||||
b := Bucket{
|
||||
Name: param,
|
||||
Max: n,
|
||||
Decay: time.Duration(m) * time.Minute,
|
||||
Key: func(r *http.Request) string {
|
||||
if r != nil {
|
||||
if u, ok := bouncer.User(r.Context()); ok {
|
||||
return "u:" + strconv.FormatUint(uint64(u.ID), 10)
|
||||
}
|
||||
}
|
||||
return "inline:domainless|" + ClientIP(r, trusted)
|
||||
},
|
||||
}
|
||||
l.inline[param] = b
|
||||
return b, nil
|
||||
}
|
||||
Reference in New Issue
Block a user