# surf HTTP routing for SummerCMS: collects plugin routes and named middleware into a `net/http` ServeMux with constraints, rate limiting, body limits, CORS and panic recovery, and provides the `serve` and `route:list` commands. `import "git.golem15.com/golem15/summercms/modules/surf"` **Name:** Catching the incoming waves; it routes incoming HTTP requests to their handlers. ## Overview surf turns the routes that plugins declare through `pact.HasRoutes` into one `http.Handler`. `surf.BuildRouter` registers the built-in and plugin middleware, walks every plugin's route declarations through a Laravel-style group builder (`surf.Router`, implementing `pact.Router`), mounts the [cabana](../cabana/README.md) admin, and checks every route at boot; `surf.Assemble` then compiles the result onto a standard library ServeMux. Configuration mistakes such as duplicate routes, unknown middleware names or malformed throttles fail at boot, not on the first request. It is the counterpart of WinterCMS's plugin `routes.php` files with Laravel's `Route::group`, `->middleware()`, `->where()` and `throttle` middleware. ## Role in the framework The HTTP entry point, at the top of the stack: it depends on `backpack`, `bonfire`, `bouncer`, `cabana`, `compass`, `conga`, `cooler`, `lagoon`, `pact`, `party`, `towel` and `wire`, and provides the serve command. Plugins contribute routes through `pact`; `surf` collects them into one standard library ServeMux. Use cases: - Expose a plugin's API routes with named middleware, such as auth guards. - Apply rate limits, body limits and CORS per route group. - List every route in the application from the command line while developing. ## Features - Laravel-style route groups: `surf.Router.Group` with a path prefix and middleware list (`surf.Use` builds the list), `surf.Router.Get`, `surf.Router.Post`, `surf.Router.Put`, `surf.Router.Patch` and `surf.Router.Delete` (the same methods exist on each `surf.Group`), with Go 1.22+ path patterns such as `/posts/{id}`. - Path constraints: `surf.Router.Where` (regex, anchored to the whole segment) and `surf.Router.WhereIn` (allow-list) apply to the last declared route; a request that fails a constraint gets a 404. `surf.IntParam` reads a positive integer path value. - Overlapping constrained routes: two routes that ServeMux would refuse side by side, such as `/shelves/items/{id}` and `/shelves/{shelfId}/follow`, boot as one overlap family when their constraints keep them apart. Requests are tried against the family's routes in registration order, as Laravel does, and each route keeps its own path values and middleware. - Named middleware from plugins (`pact.HasMiddleware`), parameterized middleware used as `name:param` (`pact.HasMiddlewareFactories`) and house middleware for the JSON envelope and error handling (`pact.HasHouseMiddleware`). Duplicate or unknown names fail boot. - Raw groups (`surf.Router.GroupRaw`) for routes that must not be wrapped in house middleware, such as webhooks or file streams: house middleware is refused there, the default body limit is skipped and a panic returns a bare 500. - Built-in middleware names: `throttle:` or `throttle:,`, `body.limit:`, `cors:`, `turnstile:open` and `turnstile:closed` (Cloudflare Turnstile through `bouncer.Turnstile`, built from `turnstile.secret`, with the client IP from `surf.ClientIP` and refusals written by a published `bouncer.TurnstileWriter` when there is one), `locale.from-principal`, plus `backend` (the admin guard) when the admin is enabled. - Named CORS profiles: `cors:` applies `http.cors_profiles.` (read by `surf.LoadCORSProfiles`, validated like `http.cors`) to a route or route group. The profile wraps every other layer of the route, whatever its position in the list or the group nesting, so every response carries its headers: guard refusals (401, 403), throttle (429), Turnstile (422, 503), the body limit (413) and recovered panics (500) included. A handler's own `Vary` is merged with the profile's `Vary: Origin`, not replacing it. surf registers an `OPTIONS` route for each such path that answers the preflight from the profile, since ServeMux would answer it with 405 before the middleware runs. An unknown profile, two profiles on one path and a profiled path that `http.cors.paths` also matches fail boot. - 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; `surf.BuildRouter` uses a `surf.Store` published on the app instead when there is one, which is how `serve` shares counters across instances through [cooler](../cooler/README.md)'s Redis store (`http.rate_limit.store: redis`). - 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 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. `surf.LoadCORSConfig` refuses credentials with a `*` origin and origin patterns that do not compile; an allowed origin is echoed exactly, with `Vary: Origin` on every response whose answer depends on the request origin. 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. - Opt-in uploads route: with `storage.uploads.serve` true, `surf.BuildRouter` mounts `GET /{path...}` (owned by `summercms.surf`) on `attach.StaticHandlerPublic`, so a file is served only when its `system_files` row is public. Every response carries `X-Content-Type-Options: nosniff` and a sandboxing `Content-Security-Policy`, and a file whose type is not a raster image (JPEG, PNG, GIF, WebP, AVIF) is sent with `Content-Disposition: attachment`, so a stored HTML or SVG file never renders on the app origin. The bucket and the database are looked up per request, so `route:list` lists the route without opening either; until `serve` publishes them it answers 404. The route owns the whole prefix: `surf.BuildRouter` fails when a plugin route sits at or under it, and when the prefix is not an absolute URL path (no leading slash, a scheme or host, a query, an empty, `.` or `..` segment, or `{`/`}`). - Opt-in API documentation page: with `http.api_docs.enabled` true, `surf.BuildRouter` mounts `GET /api-docs` (a Swagger UI page) and `GET /api-docs.json` (owned by `summercms.apidocs`, public, no middleware). The spec comes from the one plugin implementing `surf.APIDocsProvider`, as YAML or JSON, converted once at boot to compact JSON in source key order; zero or several providers, or a spec that does not convert, fail boot. The page loads swagger-ui-dist `surf.SwaggerUIVersion` from jsDelivr with Subresource Integrity hashes on all three assets and a `Content-Security-Policy` whose only script source is a per-request nonce; every script tag, the two jsDelivr scripts included, carries it, so no other jsDelivr script can run. - Opt-in request log: with `http.access_log` true, every request writes one Info record `http request` after it is served, with `method`, `pattern` (the matched route, such as `GET /posts/{id}`), `status` and `duration_ms`. A request whose client went away adds `client_gone=true`; its 5xx is logged as 499 (client closed request) only when the client had already gone when the status was written and the handler did not panic, so a 5xx written before the cancellation and a recovered panic keep their real status. An unmatched request (a 404 or 405 from the mux, or a CORS preflight answered before routing) has an empty `pattern`. The URL path, the query string, headers and the client address are never logged, for an unmatched request too: a token route requested with another method answers 405 before any route handler runs. Records go to the app's published `*slog.Logger`, else `slog.Default`, so the [sunscreen](../sunscreen/README.md) redaction still applies. - A read-only route table (`surf.Router.Routes`), the admin mount status (`surf.Router.AdminPrefix`) and the `serve` and `route:list` commands. ## Usage A plugin declares routes, middleware and a rate-limit bucket; the runtime assembles them: ```go package blog import ( "net/http" "time" "git.golem15.com/golem15/summercms/modules/backpack" "git.golem15.com/golem15/summercms/modules/pact" "git.golem15.com/golem15/summercms/modules/surf" "git.golem15.com/golem15/summercms/modules/wire" ) type Plugin struct{} func (Plugin) ID() string { return "acme.blog" } func (Plugin) Requires() []string { return nil } func (Plugin) Register(*backpack.App) error { return nil } func (Plugin) Boot(*backpack.App) error { return nil } func (Plugin) Middlewares() map[string]pact.Middleware { return map[string]pact.Middleware{ "blog.no-store": func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Cache-Control", "no-store") next.ServeHTTP(w, r) }) }, } } func (Plugin) Buckets() map[string]surf.Bucket { return map[string]surf.Bucket{ "blog.comments": { Max: 5, Decay: time.Minute, Key: func(r *http.Request) string { return "comments|" + surf.ClientIP(r, nil) }, }, } } func (Plugin) Routes(r pact.Router) error { r.Group("/api/blog", surf.Use("throttle:60,1"), func(g pact.Router) { g.Get("/posts/{id}", showPost) g.Where("id", `[0-9]+`) g.Get("/posts/{status}/list", listPosts, "blog.no-store") g.WhereIn("status", "draft", "published") g.Post("/posts/{id}/comments", addComment, "throttle:blog.comments", "body.limit:65536") }) return nil } func showPost(w http.ResponseWriter, r *http.Request) { id, ok := surf.IntParam(r, "id") if !ok { http.NotFound(w, r) return } wire.WriteJSON(w, http.StatusOK, map[string]any{"id": id}) } func listPosts(w http.ResponseWriter, r *http.Request) { wire.WriteJSON(w, http.StatusOK, []string{}) } func addComment(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusCreated) } ``` Routes ported from a `routes.php` sometimes overlap: `GET /shelves/items/{id}` with `id` numeric and `GET /shelves/{shelfId}/follow` with `shelfId` numeric both match `/shelves/items/follow` as patterns, so ServeMux alone would refuse to register them. surf registers such routes as an overlap family and tries them in registration order, checking literal segments and `Where` constraints, so declare them in the same order as the PHP file. A request no member matches gets the bare 404. Overlapping routes must use literal segments and single-segment `{name}` wildcards; two routes of one method with the same shape, a `{name...}` wildcard in a family, or a more general route that would take a member's requests still fail at boot. `surf.Router.Routes` and `route:list` keep listing every route on its own. The generated application `main` wires surf in with `surf.ServeCommand` and `surf.RouteListCommand`; tests can call `surf.Assemble(app, plugins)` and drive the returned handler with `net/http/httptest`. ## API reference | Identifier | Description | |------------|-------------| | `surf.BuildRouter` | Registers built-in and plugin middleware, buckets, routes and the admin, and validates every route without compiling. | | `surf.Assemble` | `surf.BuildRouter` plus compilation into the final `http.Handler`. | | `surf.Router` | The route builder; implements `pact.Router`. `surf.New` creates an empty one. | | `surf.Group` | A prefixed route collection with inherited middleware. | | `surf.Router.RegisterMiddleware` | Stores a named middleware; duplicates fail. | | `surf.Router.RegisterMiddlewareFactory` | Stores a parameterized middleware used as `name:param`. | | `surf.Router.Routes` | Returns a copy of the registered routes as `surf.RouteInfo` values. | | `surf.Router.AdminPrefix` | The admin mount path and true when `surf.BuildRouter` mounted the cabana admin; false when no plugin registers an admin controller. | | `surf.RouteInfo` | Method, pattern, owning plugin, middleware and raw flag of one route. | | `surf.Use` | Builds a middleware name list for a group. | | `surf.Constraint` | A compiled path-parameter restriction built by `surf.Regex` or `surf.Enum`. | | `surf.IntParam` | Reads a positive integer path value. | | `surf.Bucket` | A named rate limit: maximum attempts, window length and key function. | | `surf.BucketProvider` | Implemented by plugins that declare named buckets. | | `surf.FixedWindowLimiter` | The rate limiter behind the `throttle` middleware; `surf.NewFixedWindowLimiter` creates one. | | `surf.Store` | Atomic fixed-window admission; `surf.NewMemoryStore` is the in-process implementation. | | `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.LoadCORSProfiles` | Reads and validates the `http.cors_profiles.` blocks the `cors:` middleware uses. | | `surf.APIDocsProvider` | Implemented by the one plugin that supplies the OpenAPI document (`APIDocsSpec`, YAML or JSON) for the opt-in api-docs page. | | `surf.SwaggerUIVersion` | The exact swagger-ui-dist release the api-docs page loads, pinned so its assets carry SRI hashes. | | `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. | ## Configuration `surf.BuildRouter` reads these keys from the app's [compass](../compass/README.md) config: | Key | Default | Controls | |-----|---------|----------| | `http.body_limits.default_bytes` | none, required | Request body cap in bytes for every non-raw route; `body.limit:` overrides it per route. Must be a whole number of at least 1. | | `http.body_limits.upload_bytes` | none, required | Upload body cap in bytes. Must be a whole number of at least 1; it is validated at boot. | | `http.trusted_proxies` | empty | List of CIDRs whose `X-Forwarded-For` header is trusted when resolving the client IP. Malformed entries are skipped. | | `http.cors.paths` | empty | Path globs (`api/*`) that get CORS headers. With no `http.cors` section no CORS headers are sent. | | `http.cors.allowed_origins` | empty | Allowed origins, echoed exactly; `*` allows any (without credentials only). An entry with a `*` inside, such as `https://*.example.com`, matches any characters there, as Laravel's `Str::is` does. With credentials, such a `*` must be the whole leftmost host label after a literal scheme (`https://*.example.com`; `https://*` and `*://*` fail boot), and no entry may match an origin outside the configured domains. | | `http.cors.allowed_origins_patterns` | empty | Go regular expressions matched against the origin. A pattern that does not compile, or one still wrapped in PHP delimiters (`#^...$#`), fails boot. With credentials, a pattern must be anchored with `^` and `$` and must not match an origin outside the configured domains (`^https?://.*$` fails boot). | | `http.cors.allowed_methods` | empty | Methods sent in `Access-Control-Allow-Methods`; `*` allows any. | | `http.cors.allowed_headers` | empty | Headers sent in `Access-Control-Allow-Headers`; `*` allows any. | | `http.cors.exposed_headers` | empty | Headers sent in `Access-Control-Expose-Headers`. | | `http.cors.max_age` | `0` | Preflight cache time in seconds. | | `http.cors.supports_credentials` | `false` | Sends `Access-Control-Allow-Credentials: true`. Combined with `*` in `allowed_origins` it fails boot. With credentials, a pattern or several origins, every response on a CORS path carries `Vary: Origin`, preflights and disallowed origins included. | | `http.cors_profiles..*` | none | A CORS profile for `cors:`: the `http.cors` keys except `paths` (`allowed_origins`, `allowed_origins_patterns`, `allowed_methods`, `allowed_headers`, `exposed_headers`, `max_age`, `supports_credentials`), validated the same way. Names use lower-case letters, digits, underscore and dash. | | `turnstile.secret` | empty | Cloudflare Turnstile secret for `turnstile:open` and `turnstile:closed`. Without it `turnstile:open` passes and `turnstile:closed` answers 503 in the production environment (passes elsewhere), and when routes name either, `surf.BuildRouter` logs one start-up warning, `surf: turnstile.secret is empty`, with the route count. A `*bouncer.Turnstile` published on the app replaces the verifier built from this key. Environment: `SUMMER_TURNSTILE__SECRET`. | | `turnstile.field` | `turnstile_token` | Request field (query, JSON body or form body) carrying the Turnstile token. | | `http.rate_limit.store` | `memory` | Where `throttle` counters live: `memory` (per instance) or `redis` (shared by every instance through [cooler](../cooler/README.md), which then needs `redis.url`). Only `serve` installs the Redis store; `route:list` keeps the memory store and never dials Redis. Any other value fails at boot. Environment: `SUMMER_HTTP__RATE_LIMIT__STORE`. | | `http.api_docs.enabled` | `false` | Mounts `GET ` (Swagger UI page) and `GET .json` (the spec of the one `surf.APIDocsProvider` plugin). Both are public. Environment: `SUMMER_HTTP__API_DOCS__ENABLED`. | | `http.api_docs.path` | `/api-docs` | Page path; the JSON route adds `.json`. Must be an absolute URL path; a plugin route on either path fails boot. | | `http.api_docs.title` | `API documentation` | Page title. | | `http.access_log` | `false` | Logs one `http request` record per request (method, matched pattern, status, duration in milliseconds; never the path or query). Environment: `SUMMER_HTTP__ACCESS_LOG`. | | `storage.uploads.serve` | `false` | Mounts the uploads route `GET /{path...}` (prefix default `/storage/uploads`; it must then be an absolute URL path, or start-up fails) on `attach.StaticHandlerPublic` with `X-Content-Type-Options: nosniff`, a sandboxing CSP and `Content-Disposition: attachment` for anything but a raster image. Prefer a separate origin for uploads in production. Environment: `SUMMER_STORAGE__UPLOADS__SERVE`. | ```yaml http: body_limits: default_bytes: 1048576 upload_bytes: 20971520 trusted_proxies: ["10.0.0.0/8"] cors: paths: ["api/*"] allowed_origins: ["https://blog.example.com"] allowed_methods: ["*"] allowed_headers: ["*"] supports_credentials: true ``` The `serve` command also opens the database through [lagoon](../lagoon/README.md) and the uploads bucket through `attach.OpenBucket`, so their settings must be present as well. It then installs [cooler](../cooler/README.md) (`cooler.Install`) before it builds the router: with `http.rate_limit.store: redis` it publishes cooler's Redis-backed store as the `surf.Store`, falling back to a per-instance `surf.MemoryStore` while Redis does not answer. A Redis that is down at start-up is a warning, not a boot failure; a missing or malformed `redis.url` is. It starts the background job worker of [conga](../conga/README.md) in the same process unless `queue.work_in_serve` is `false` (default `true`); set it to `false` when a separate `queue:work` process runs the jobs. The other `queue.*` keys are documented in the conga README. ## CLI commands | Command | Flags | Description | |---------|-------|-------------| | `serve` | `--addr` (default `:8080`) | Opens the database and uploads bucket, installs cooler (the shared rate-limit store when `http.rate_limit.store` is `redis`), assembles the router, binds the address, starts the in-process job worker (see `queue.work_in_serve`) and serves HTTP until SIGINT or SIGTERM, then shuts the server and the worker down gracefully within 10 seconds. After binding it prints the listen address with a browser URL (an unspecified host shows as 127.0.0.1) and an admin status line: the admin URL, with a warning naming `summer admin:create` when no administrator exists yet, or `admin: off` with the steps to add an admin controller. | | `route:list` | none | Builds the router the same way `serve` does, without opening the database or listening, and prints a table of method, pattern, plugin, middleware and raw flag for every route, followed by the admin status line (`admin: mounted at ` or `admin: off`). | ## Dependencies - SummerCMS modules: [backpack](../backpack/README.md), [bonfire](../bonfire/README.md), [bouncer](../bouncer/README.md), [cabana](../cabana/README.md), [compass](../compass/README.md), [conga](../conga/README.md) (the in-process job worker of `serve`), [cooler](../cooler/README.md) (the shared rate-limit store `serve` installs), [lagoon](../lagoon/README.md) (including `lagoon/attach`), [pact](../pact/README.md), [party](../party/README.md), [towel](../towel/README.md), [wire](../wire/README.md). - Third-party: `gocloud.dev/blob` (uploads bucket opened by `serve`), `github.com/goccy/go-yaml` (api-docs spec conversion). - Standard library: `bytes`, `context`, `crypto/rand`, `encoding/base64`, `encoding/json`, `errors`, `fmt`, `html/template`, `log/slog`, `math`, `mime`, `net`, `net/http`, `net/netip`, `net/url`, `os`, `os/signal`, `regexp`, `sort`, `strconv`, `strings`, `sync`, `syscall`, `time`. - Tests additionally use `github.com/testcontainers/testcontainers-go` (Redis container). ## Testing ```sh go test ./modules/surf/... ``` The tests use `net/http/httptest` and in-memory stores. `TestRateLimitAcrossInstancesSmoke` starts a Redis container through testcontainers-go and needs Docker; `go test -short ./modules/surf/...` skips it.