176 lines
10 KiB
Markdown
176 lines
10 KiB
Markdown
# 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"`
|
|
|
|
## 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.
|
|
|
|
## 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.
|
|
- 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:<bucket>` or `throttle:<max>,<minutes>`, `body.limit:<bytes>`, `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.
|
|
- Path-scoped CORS configured with the same keys as Laravel's `config/cors.php` (`surf.CORSConfig`), including preflight handling.
|
|
- `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.
|
|
|
|
## 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) }
|
|
```
|
|
|
|
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.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.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:<bytes>` 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; `*` allows any. |
|
|
| `http.cors.allowed_origins_patterns` | empty | Regular expressions matched against the origin. |
|
|
| `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`. |
|
|
|
|
```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.
|
|
|
|
## CLI commands
|
|
|
|
| Command | Flags | Description |
|
|
|---------|-------|-------------|
|
|
| `serve` | `--addr` (default `:8080`) | Opens the database and uploads bucket, assembles the router and serves HTTP until SIGINT or SIGTERM, then shuts down gracefully within 10 seconds. |
|
|
| `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. |
|
|
|
|
## Dependencies
|
|
|
|
- SummerCMS modules: [backpack](../backpack/README.md), [bonfire](../bonfire/README.md), [bouncer](../bouncer/README.md), [cabana](../cabana/README.md), [compass](../compass/README.md), [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`).
|
|
- Standard library: `bytes`, `context`, `fmt`, `math`, `net`, `net/http`, `net/netip`, `os`, `os/signal`, `regexp`, `strconv`, `strings`, `sync`, `syscall`, `time`.
|
|
|
|
## Testing
|
|
|
|
```sh
|
|
go test ./modules/surf/...
|
|
```
|
|
|
|
The tests use `net/http/httptest` and in-memory stores and need no external services.
|