feat(06-02): add fixed-window limiter, Store, and ClientIP

- MemoryStore mirrors Laravel tooManyAttempts-before-hit first-hit-wins
- FixedWindowLimiter + throttle factory; success and 429 rate-limit headers
- Trusted-proxy ClientIP; remove noOpLimit; keep Limiter interface seam
This commit is contained in:
Jakub Zych
2026-09-19 19:35:53 +02:00
parent e2aba0ebf0
commit 68c6ab85c5
7 changed files with 741 additions and 10 deletions

View File

@@ -4,6 +4,7 @@ import (
"fmt"
"net/http"
"strings"
"time"
"git.golem15.com/golem15/summercms/backpack"
"git.golem15.com/golem15/summercms/pact"
@@ -46,6 +47,7 @@ type Router struct {
seen map[string]string
origins []string
compileErr error
limiter *FixedWindowLimiter
}
var (
@@ -300,7 +302,6 @@ func (r *Router) compile() (http.Handler, error) {
func (r *Router) wrap(rt route) (http.Handler, error) {
h := constrain(rt.handler, rt.constraints)
h = noOpLimit(h)
h = orgSlot(h)
for i := len(rt.middleware) - 1; i >= 0; i-- {
name := rt.middleware[i]
@@ -311,6 +312,11 @@ func (r *Router) wrap(rt route) (http.Handler, error) {
base, param, hasParam := strings.Cut(name, ":")
if hasParam {
if factory, ok := r.factories[base]; ok {
if base == "throttle" && r.limiter != nil {
if err := r.limiter.ValidateThrottle(param); err != nil {
return nil, fmt.Errorf("surf: plugin %q: %w", rt.pluginID, err)
}
}
h = factory.fn(param)(h)
continue
}
@@ -324,6 +330,18 @@ func (r *Router) wrap(rt route) (http.Handler, error) {
// Assemble registers plugin middleware and routes, then compiles ServeMux.
func Assemble(app *backpack.App, plugins []party.Plugin) (http.Handler, error) {
r := New(corsOrigins(app))
trusted := TrustedProxies(nil)
if app != nil {
trusted = TrustedProxies(app.Config)
}
// longest bucket decay is 1 minute; sweep at 2x
lim := NewFixedWindowLimiter(NewMemoryStore(2*time.Minute), trusted)
r.limiter = lim
if err := r.RegisterMiddlewareFactory("surf", "throttle", func(param string) pact.Middleware {
return lim.Middleware(param)
}); err != nil {
return nil, err
}
for _, p := range plugins {
if hm, ok := p.(pact.HasMiddleware); ok {
for name, fn := range hm.Middlewares() {
@@ -342,6 +360,15 @@ func Assemble(app *backpack.App, plugins []party.Plugin) (http.Handler, error) {
}
}
}
for _, p := range plugins {
if bp, ok := p.(BucketProvider); ok {
for name, b := range bp.Buckets() {
if err := lim.RegisterBucket(p.ID(), name, b); err != nil {
return nil, err
}
}
}
}
for _, p := range plugins {
r.BindPlugin(p.ID())
if hr, ok := p.(pact.HasRoutes); ok {
@@ -429,19 +456,13 @@ func orgSlot(next http.Handler) http.Handler {
})
}
// Limiter wraps handlers. Phase 6 replaces the no-op with named buckets.
// Limiter wraps handlers. It is a retained Phase 3 seam with no implementer
// after Phase 6; rate limiting is provided by FixedWindowLimiter through
// the parameterized throttle middleware.
type Limiter interface {
Wrap(http.Handler) http.Handler
}
type noopLimiter struct{}
func (noopLimiter) Wrap(next http.Handler) http.Handler { return next }
func noOpLimit(next http.Handler) http.Handler {
return noopLimiter{}.Wrap(next)
}
func joinPath(prefix, path string) string {
prefix = strings.TrimSuffix(prefix, "/")
path = strings.TrimSpace(path)