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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user