From b2ce986604521234140adcc3d02226676435ee1e Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Tue, 6 Oct 2026 11:54:00 +0200 Subject: [PATCH] feat(14.2.1-01): add optional surf LocaleResolver seam Look up a backpack-published resolver so a compiled translate plugin can strip an enabled URL prefix and write a validated locale onto context. Co-authored-by: Cursor --- docs/architecture/request-lifecycle.md | 15 ++--- docs/services/localization.md | 2 +- docs/services/routing.md | 2 +- modules/surf/README.md | 4 +- modules/surf/locale_resolver.go | 15 +++++ modules/surf/locale_resolver_test.go | 81 ++++++++++++++++++++++++++ modules/surf/router.go | 30 ++++++++-- 7 files changed, 134 insertions(+), 15 deletions(-) create mode 100644 modules/surf/locale_resolver.go create mode 100644 modules/surf/locale_resolver_test.go diff --git a/docs/architecture/request-lifecycle.md b/docs/architecture/request-lifecycle.md index 8abe81c..5ea7501 100644 --- a/docs/architecture/request-lifecycle.md +++ b/docs/architecture/request-lifecycle.md @@ -35,13 +35,14 @@ In Go the same declaration is a `pact.HasRoutes` method. Paths use Go `http.Serv surf wraps every non-raw route in the same layers. From the outside in: -1. CORS, only for the paths configured under `http.cors.paths`, including preflight requests. -2. Panic recovery. A panic becomes an opaque JSON 500 from [wire](../../modules/wire/README.md), and because the response is buffered until the handler returns, the client never receives half a body. -3. The request locale, taken from the `Accept-Language` header and stored in the context. -4. The body limit: `http.body_limits.default_bytes`, or the route's own `body.limit:`. -5. The route's middleware, in the order you listed them: group middleware first, then the route's own. -6. The path constraints. A request whose parameter fails `pact.Router.Where` or `pact.Router.WhereIn` gets a 404 before your handler runs. -7. Your handler. +1. Optional locale-prefix rewrite, when a `surf.LocaleResolver` is published: an enabled locale as the first path segment is stripped so later layers and routing see the unprefixed path. +2. CORS, only for the paths configured under `http.cors.paths`, including preflight requests. +3. Panic recovery. A panic becomes an opaque JSON 500 from [wire](../../modules/wire/README.md), and because the response is buffered until the handler returns, the client never receives half a body. +4. The request locale, taken from the published `surf.LocaleResolver` when one exists, or from the `Accept-Language` header otherwise, and stored in the context. +5. The body limit: `http.body_limits.default_bytes`, or the route's own `body.limit:`. +6. The route's middleware, in the order you listed them: group middleware first, then the route's own. +7. The path constraints. A request whose parameter fails `pact.Router.Where` or `pact.Router.WhereIn` gets a 404 before your handler runs. +8. Your handler. Raw groups, declared with `pact.Router.GroupRaw`, are for webhooks and file streams. They skip the default body limit, refuse house middleware, and a panic in them returns a bare 500. diff --git a/docs/services/localization.md b/docs/services/localization.md index 848c841..a821ca9 100644 --- a/docs/services/localization.md +++ b/docs/services/localization.md @@ -75,7 +75,7 @@ for _, n := range []int{0, 1, 7} { ## The request locale -The locale lives on the request context, not in a global. For every route, [surf](../../modules/surf/README.md) sets it from the `Accept-Language` header; the `locale.from-principal` middleware switches it to the signed-in user's preferred locale. Code reads it with `towel.Locale`, and code outside a request sets it with `towel.WithLocale`, as the example above does. A context without a locale uses `app.locale`. +The locale lives on the request context, not in a global. For every route, [surf](../../modules/surf/README.md) looks up an optional `surf.LocaleResolver` published by a compiled plugin and writes that validated code with `towel.WithLocale`. When no resolver is published, surf sets the locale from the `Accept-Language` header and the `locale.from-principal` middleware can switch it to the signed-in user's preferred locale. Code reads it with `towel.Locale`, and code outside a request sets it with `towel.WithLocale`, as the example above does. A context without a locale uses `app.locale`. ## Strings for the admin diff --git a/docs/services/routing.md b/docs/services/routing.md index 5817bf3..ae60091 100644 --- a/docs/services/routing.md +++ b/docs/services/routing.md @@ -101,7 +101,7 @@ The framework registers these names: | `locale.from-principal` | Switches the request locale to the signed-in user's preferred locale. | | `backend` | The admin guard, when the admin is enabled. | -Every route also gets, around its own middleware, JSON panic recovery, the request locale from `Accept-Language`, the body limit from `http.body_limits.default_bytes`, and CORS headers when its path matches `http.cors.paths`. The order is described in [Request lifecycle](../architecture/request-lifecycle.md). +Every route also gets, around its own middleware, JSON panic recovery, the request locale from a published [`surf.LocaleResolver`](../../modules/surf/README.md) or from `Accept-Language` when none is published, the body limit from `http.body_limits.default_bytes`, and CORS headers when its path matches `http.cors.paths`. The order is described in [Request lifecycle](../architecture/request-lifecycle.md). `pact.Router.GroupRaw` declares a raw group for routes that must not be wrapped in the house JSON middleware, such as webhooks, file streams or the OAuth endpoints: the default body limit is skipped, and a panic returns a bare 500. diff --git a/modules/surf/README.md b/modules/surf/README.md index 045b531..42c035f 100644 --- a/modules/surf/README.md +++ b/modules/surf/README.md @@ -18,8 +18,9 @@ surf turns the routes that plugins declare through `pact.HasRoutes` into one `ht - Built-in middleware names: `throttle:` or `throttle:,`, `body.limit:`, `locale.from-principal`, plus `backend` (the admin guard) when the admin is enabled. - Fixed-window rate limiting (`surf.FixedWindowLimiter`): named buckets from plugins that implement `surf.BucketProvider`, or inline limits keyed by the signed-in user, or by client IP for guests. Rejected requests get a 429 with `Retry-After` and `X-RateLimit-*` headers. The in-process `surf.MemoryStore` sits behind the `surf.Store` interface. - Client IP resolution for limiter keys (`surf.ClientIP`) that only trusts `X-Forwarded-For` hops when the direct peer is inside a configured trusted proxy range (`surf.TrustedProxies`). -- Every non-raw route runs inside JSON panic recovery (an opaque 500 via [wire](../wire/README.md)), gets the request locale from the `Accept-Language` header (see [towel](../towel/README.md)) and a request body cap. Responses are buffered until the handler returns, so a panic never leaves a half-written body. +- Every non-raw route runs inside JSON panic recovery (an opaque 500 via [wire](../wire/README.md)), gets the request locale from a published `surf.LocaleResolver` when one exists or from the `Accept-Language` header otherwise (see [towel](../towel/README.md)), and a request body cap. Responses are buffered until the handler returns, so a panic never leaves a half-written body. - Path-scoped CORS configured with the same keys as Laravel's `config/cors.php` (`surf.CORSConfig`), including preflight handling. Every `OPTIONS` request on a CORS path is answered with 204 before routing, with the headers Laravel's `HandleCors` sends: `Cache-Control: no-cache, private` always and, on a preflight (an `Origin` and an `Access-Control-Request-Method`), the requested method (upper-cased) and headers echoed in `Access-Control-Allow-Methods` and `Access-Control-Allow-Headers` when `*` allows any, `Vary` on the request headers and PHP's default `Content-Type: text/html; charset=UTF-8`. +- Optional `surf.LocaleResolver`: a compiled plugin publishes the interface through backpack; surf looks it up without importing the plugin. When present, it may strip an enabled locale prefix before routing and writes a validated code with `towel.WithLocale`. When absent, hosts keep the raw `Accept-Language` header and `surf.LocaleFromPrincipal` overlay. - `surf.LocaleFromPrincipal` switches the request locale to the signed-in user's preferred locale. - A read-only route table (`surf.Router.Routes`) and the `serve` and `route:list` commands. @@ -118,6 +119,7 @@ The generated application `main` wires surf in with `surf.ServeCommand` and `sur | `surf.ClientIP` | Resolves the client IP, honouring trusted proxies. | | `surf.TrustedProxies` | Parses `http.trusted_proxies` into CIDR prefixes. | | `surf.CORSConfig` | CORS settings; `surf.LoadCORSConfig` reads them from config. | +| `surf.LocaleResolver` | Optional backpack-published request locale; `Resolve` returns a validated code and `Rewrite` strips an enabled prefix before routing. | | `surf.LocaleFromPrincipal` | Middleware that applies the signed-in user's preferred locale. | | `surf.ServeCommand` | The `serve` console command. | | `surf.RouteListCommand` | The `route:list` console command. | diff --git a/modules/surf/locale_resolver.go b/modules/surf/locale_resolver.go new file mode 100644 index 0000000..4369fb1 --- /dev/null +++ b/modules/surf/locale_resolver.go @@ -0,0 +1,15 @@ +package surf + +import "net/http" + +// LocaleResolver is an optional backpack-published service that selects a +// validated locale code for a request. When none is published, surf keeps +// writing the raw Accept-Language header onto the request context. +type LocaleResolver interface { + // Resolve returns a validated locale code for r. It may set cookies on w + // and must not store request locale on a process-wide variable. + Resolve(w http.ResponseWriter, r *http.Request) string + // Rewrite returns a request whose URL has a valid enabled locale prefix + // stripped so routing sees the unprefixed path, or r unchanged. + Rewrite(r *http.Request) *http.Request +} diff --git a/modules/surf/locale_resolver_test.go b/modules/surf/locale_resolver_test.go new file mode 100644 index 0000000..815fe32 --- /dev/null +++ b/modules/surf/locale_resolver_test.go @@ -0,0 +1,81 @@ +package surf + +import ( + "net/http" + "net/http/httptest" + "testing" + + "git.golem15.com/golem15/summercms/modules/backpack" + "git.golem15.com/golem15/summercms/modules/towel" +) + +type stubLocaleResolver struct { + code string + rewriteTo string +} + +func (s stubLocaleResolver) Resolve(http.ResponseWriter, *http.Request) string { + return s.code +} + +func (s stubLocaleResolver) Rewrite(r *http.Request) *http.Request { + if s.rewriteTo == "" { + return r + } + clone := r.Clone(r.Context()) + clone.URL.Path = s.rewriteTo + return clone +} + +func TestLocaleResolverWritesValidatedCodeAndStripsPrefix(t *testing.T) { + cfg := writeHTTPConfig(t, "body_limits:\n default_bytes: 1024\n upload_bytes: 1024\n") + app := backpack.New(cfg) + if err := app.Publish[LocaleResolver](stubLocaleResolver{code: "pl", rewriteTo: "/posts"}); err != nil { + t.Fatal(err) + } + r := New(nil) + r.app = app + r.defaultBytes = 1024 + r.BindPlugin("acme.blog") + var gotPath, gotLoc string + var ok bool + r.Get("/posts", func(w http.ResponseWriter, req *http.Request) { + gotPath = req.URL.Path + gotLoc, ok = towel.Locale(req.Context()) + w.WriteHeader(http.StatusNoContent) + }) + h, err := r.compile() + if err != nil { + t.Fatal(err) + } + req := httptest.NewRequest(http.MethodGet, "/pl/posts", nil) + req.Header.Set("Accept-Language", "de") + h.ServeHTTP(httptest.NewRecorder(), req) + if gotPath != "/posts" { + t.Fatalf("path = %q, want /posts", gotPath) + } + if !ok || gotLoc != "pl" { + t.Fatalf("locale = %q ok=%t, want pl from resolver", gotLoc, ok) + } +} + +func TestLocaleAbsentResolverKeepsAcceptLanguage(t *testing.T) { + r := New(nil) + r.BindPlugin("acme.blog") + var gotLoc string + var ok bool + r.Get("/posts", func(w http.ResponseWriter, req *http.Request) { + gotLoc, ok = towel.Locale(req.Context()) + w.WriteHeader(http.StatusNoContent) + }) + h, err := r.compile() + if err != nil { + t.Fatal(err) + } + req := httptest.NewRequest(http.MethodGet, "/posts", nil) + req.Header.Set("Accept-Language", "pl") + h.ServeHTTP(httptest.NewRecorder(), req) + if !ok || gotLoc != "pl" { + t.Fatalf("locale = %q ok=%t, want pl from Accept-Language", gotLoc, ok) + } +} diff --git a/modules/surf/router.go b/modules/surf/router.go index f0c67b2..e639e6e 100644 --- a/modules/surf/router.go +++ b/modules/surf/router.go @@ -59,6 +59,7 @@ type Router struct { defaultBytes int64 uploadBytes int64 built map[string]pact.Middleware + app *backpack.App } var ( @@ -375,7 +376,7 @@ func (r *Router) compile() (http.Handler, error) { if err := registerOverlapFamilies(mux, r.routes, handlers, families); err != nil { return nil, err } - return pathScopedCORS(r.corsCfg, mux), nil + return r.stripLocalePrefix(pathScopedCORS(r.corsCfg, mux)), nil } // handleRoute registers a route, converting a ServeMux conflict panic into an error. @@ -436,7 +437,7 @@ func (r *Router) wrap(rt route) (http.Handler, error) { if limit > 0 { h = bodyLimit(limit)(h) } - h = locale(h) + h = r.locale(h) if rt.raw { h = recoverBare(h) } else { @@ -458,6 +459,7 @@ func Assemble(app *backpack.App, plugins []party.Plugin) (http.Handler, error) { // so callers (route:list) can inspect Routes() after a successful boot. func BuildRouter(app *backpack.App, plugins []party.Plugin) (*Router, error) { r := New(corsOrigins(app)) + r.app = app trusted := TrustedProxies(nil) if app != nil { trusted = TrustedProxies(app.Config) @@ -704,9 +706,27 @@ func (w *bufferedResponse) commit(dst http.ResponseWriter) { _, _ = dst.Write(w.body.Bytes()) } -func locale(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - next.ServeHTTP(w, r.WithContext(towel.WithLocale(r.Context(), r.Header.Get("Accept-Language")))) +func (r *Router) locale(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + if r != nil && r.app != nil { + if resolver, ok := r.app.Lookup[LocaleResolver](); ok && resolver != nil { + code := resolver.Resolve(w, req) + next.ServeHTTP(w, req.WithContext(towel.WithLocale(req.Context(), code))) + return + } + } + next.ServeHTTP(w, req.WithContext(towel.WithLocale(req.Context(), req.Header.Get("Accept-Language")))) + }) +} + +func (r *Router) stripLocalePrefix(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + if r != nil && r.app != nil { + if resolver, ok := r.app.Lookup[LocaleResolver](); ok && resolver != nil { + req = resolver.Rewrite(req) + } + } + next.ServeHTTP(w, req) }) }