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:
Jakub Zych
2026-09-28 02:21:02 +02:00
parent ac1f6d14f4
commit 5e50b166ef
277 changed files with 303 additions and 303 deletions

182
modules/surf/limiter.go Normal file
View 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
}