feat(surf): branded HTML error pages for browser navigations
A browser navigation (Sec-Fetch-Mode navigate, or GET/HEAD without it; no X-Requested-With; Accept naming text/html above every JSON type; not /api, /_<name> or the admin prefix) that hits a framework-generated 404, 405 or 500 gets a self-contained SummerCMS page: unmatched routes and wrong methods at the ServeMux, failed Where/WhereIn constraints, overlap-family misses, the unpublished uploads route and recovered panics. Every other request keeps the exact stdlib text, wire's opaque JSON 500 or the bare raw-group 500. Details (method, path, X-Request-Id) only with app.debug or outside production. Applications replace the page with a published surf.ErrorPageRenderer; http.error_pages.enabled false turns it off.
This commit is contained in:
@@ -38,14 +38,16 @@ surf wraps every non-raw route in the same layers. From the outside in:
|
||||
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. The route's CORS profile, when it or its group names `cors:<profile>`. It sits here whatever its position in the middleware list, so refusals and recovered panics carry its headers too.
|
||||
4. 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. Panic recovery. A panic becomes an opaque JSON 500 from [wire](../../modules/wire/README.md), or the HTML error page with status 500 when the request is a browser navigation (see [Error pages](../services/routing.md#error-pages)). Because the response is buffered until the handler returns, the client never receives half a body.
|
||||
5. The request locale, taken from the published `surf.LocaleResolver` when one exists, or from the `Accept-Language` header otherwise, and stored in the context.
|
||||
6. The body limit: `http.body_limits.default_bytes`, or the route's own `body.limit:<bytes>`.
|
||||
7. The route's middleware, in the order you listed them: group middleware first, then the route's own (`cors:<profile>` excepted, see step 3).
|
||||
8. The path constraints. A request whose parameter fails `pact.Router.Where` or `pact.Router.WhereIn` gets a 404 before your handler runs.
|
||||
9. 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.
|
||||
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, or the HTML error page for a browser navigation.
|
||||
|
||||
A request that no route matches, or that uses a method its path does not accept, is answered by the ServeMux and negotiated the same way: a browser navigation gets the HTML error page with status 404 or 405, every other caller the stdlib text.
|
||||
|
||||
## Request context values
|
||||
|
||||
|
||||
@@ -56,7 +56,7 @@ Laravel matches routes in the order `routes.php` declares them and checks `->whe
|
||||
|
||||
surf accepts such routes. When the application starts, it groups every set of routes that ServeMux would refuse side by side into an overlap family and registers the family once, under a generated pattern with a wildcard wherever its routes differ. A request that reaches the family is tried against its routes in registration order. The first route whose literal segments and constraints match runs, with its own path values, middleware, body limit and panic recovery. Declare overlapping routes in the order the PHP file declares them.
|
||||
|
||||
- **No match:** a request whose path no route of the family matches gets the same bare 404 as any unknown path, for every method.
|
||||
- **No match:** a request whose path no route of the family matches gets the same 404 as any unknown path, for every method: the bare stdlib text for an API client, the [error page](#error-pages) for a browser navigation.
|
||||
- **Wrong method:** a request whose path a route matches, but not with its method, gets the 405 and `Allow` header that ServeMux gives for the same routes without the overlap.
|
||||
- **More specific routes** stay outside the family: a literal `GET /shelves/items/similar` keeps answering its own path.
|
||||
- **Route table:** `surf.Router.Routes` and `route:list` list every route of a family on its own, with its own pattern and middleware.
|
||||
@@ -138,6 +138,76 @@ fmt.Println(rec.Code, rec.Body.String())
|
||||
|
||||
`wire.WriteOpaque500` writes the fixed 500 body that panic recovery also uses; it reveals nothing about the failure.
|
||||
|
||||
## Error pages
|
||||
|
||||
When the framework itself answers with an error, an API client and a browser get different bodies. An API client, curl, `fetch` or an XHR keeps exactly the response it always got: the stdlib `404 page not found` and `Method Not Allowed` text (with `Allow`), the opaque JSON 500 from `wire.WriteOpaque500`, or the empty 500 of a raw group. A person who opens the address in a browser gets a self-contained SummerCMS HTML page with the same status, a short explanation and a link to `/`.
|
||||
|
||||
A request gets the HTML page only when all of these hold:
|
||||
|
||||
- `Sec-Fetch-Mode` is `navigate` when the browser sends it; without the header, the method is GET or HEAD.
|
||||
- There is no `X-Requested-With` header.
|
||||
- `Accept` names `text/html` or `application/xhtml+xml` with a q above 0 and above the q of every JSON type it names (`application/json`, `text/json`, `application/*+json`). `*/*` and `text/*` count for neither side, and only the first 32 media ranges are read.
|
||||
- The path is not `/api`, not under `/api/`, and its first segment does not start with `_` (the `/_<name>/` plugin prefixes).
|
||||
- The path is not the admin prefix or under it, so the admin keeps its own behaviour.
|
||||
|
||||
The path checked is the one routing sees, after a locale prefix is stripped.
|
||||
|
||||
The page covers the errors surf writes itself: an unmatched path (404), a method the path does not accept (405, with the `Allow` header), a parameter that fails `pact.Router.Where` or `pact.Router.WhereIn`, an overlap-family miss or wrong method, the uploads route while its bucket or database is not published (404), and a recovered panic in a normal or raw group (500). The panic value is never shown. A handler's own error body, middleware refusals (401, 403, 413, 422, 429, 503) and the 404 for a missing upload row are not changed.
|
||||
|
||||
Method, path and the `X-Request-Id` request header (up to 128 bytes) are listed on the page only when `app.debug` is true or the environment is set and is not `production`. Every HTML error response carries `Cache-Control: no-store`, `X-Content-Type-Options: nosniff`, `Vary: Accept, Sec-Fetch-Mode, X-Requested-With` and a `Content-Security-Policy` without a script source, so no script runs on an error page.
|
||||
|
||||
Set `http.error_pages.enabled: false` to send the plain bodies to every client, browsers included.
|
||||
|
||||
An application or theme plugin replaces the page by publishing a `surf.ErrorPageRenderer` from `Register`. surf hands it a `surf.ErrorPage` for every error it would render and keeps the status and headers; returning `ok=false`, an empty body or panicking keeps the built-in page:
|
||||
|
||||
```go src=modules/surf/example_test.go#themePages.RenderErrorPage
|
||||
// RenderErrorPage returns the theme's own 404 and keeps the built-in page
|
||||
// for every other status.
|
||||
func (themePages) RenderErrorPage(r *http.Request, page surf.ErrorPage) ([]byte, bool) {
|
||||
if page.Status != http.StatusNotFound {
|
||||
return nil, false // keep the built-in page
|
||||
}
|
||||
body := `<!doctype html><title>Not found</title><h1>Nothing here</h1><a href="` + page.Home + `">Home</a>`
|
||||
return []byte(body), true
|
||||
}
|
||||
```
|
||||
|
||||
```go src=modules/surf/example_test.go#ThemePlugin.Register
|
||||
// Register publishes the theme's error page for surf to look up.
|
||||
func (ThemePlugin) Register(app *backpack.App) error {
|
||||
return app.Publish[surf.ErrorPageRenderer](themePages{})
|
||||
}
|
||||
```
|
||||
|
||||
A browser navigation to an unknown path then gets the theme's page, and curl still gets the stdlib text:
|
||||
|
||||
```go src=modules/surf/example_test.go#ExampleErrorPageRenderer
|
||||
app := backpack.New(nil)
|
||||
plugin := ThemePlugin{}
|
||||
if err := plugin.Register(app); err != nil { // the runtime calls Register
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
h, err := surf.Assemble(app, []party.Plugin{plugin})
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
browser := httptest.NewRequest("GET", "/nowhere", nil)
|
||||
browser.Header.Set("Accept", "text/html,application/xhtml+xml,*/*;q=0.8")
|
||||
browser.Header.Set("Sec-Fetch-Mode", "navigate")
|
||||
curl := httptest.NewRequest("GET", "/nowhere", nil)
|
||||
curl.Header.Set("Accept", "*/*")
|
||||
for _, req := range []*http.Request{browser, curl} {
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
fmt.Println(rec.Code, rec.Header().Get("Content-Type"), strings.TrimSpace(rec.Body.String()))
|
||||
}
|
||||
// Output:
|
||||
// 404 text/html; charset=utf-8 <!doctype html><title>Not found</title><h1>Nothing here</h1><a href="/">Home</a>
|
||||
// 404 text/plain; charset=utf-8 404 page not found
|
||||
```
|
||||
|
||||
## Testing and listing routes
|
||||
|
||||
`surf.Assemble` builds the complete handler from the application and its plugins, so a test can drive it with `net/http/httptest`:
|
||||
|
||||
@@ -60,6 +60,7 @@ These are the keys most applications set. Each module's reference page lists all
|
||||
| `site_cache.purge_url`, `site_cache.purge_token` | The host site's page-cache purge route and its bearer token (at least 32 characters). Set both or neither; http only to a loopback host. Keep the token in `SUMMER_SITE_CACHE__PURGE_TOKEN`. | [cooler](../../modules/cooler/README.md) |
|
||||
| `redis.url` | Redis URL (`redis://`, `rediss://` or `unix://`), required when a Redis cache or rate-limit store is selected. Keep it in `SUMMER_REDIS__URL` when it carries a password. | [cooler](../../modules/cooler/README.md) |
|
||||
| `http.access_log` | Logs one record per request with method, matched route pattern, status and duration. Defaults to `false`. | [surf](../../modules/surf/README.md) |
|
||||
| `http.error_pages.enabled` | HTML error pages for browser navigations (defaults to `true`); request details appear when `app.debug` is true or outside production. | [surf](../../modules/surf/README.md) |
|
||||
| `storage.uploads.bucket_url` | Uploads bucket, `file://`, `mem://` or `s3://` (the last only in a binary built with the `summercms_s3` tag from `build.tags` in `summer.yaml`; credentials from the AWS environment). Required by `serve`. | [lagoon](../../modules/lagoon/README.md) |
|
||||
| `storage.uploads.public_url` | Absolute `http` or `https` base of a public bucket or CDN; file and thumbnail URLs become that base plus the partition path. Unset keeps `storage.uploads.public_path_prefix`. Only for a bucket holding public files alone. | [lagoon](../../modules/lagoon/README.md) |
|
||||
| `storage.uploads.serve` | Mounts `attach.StaticHandlerPublic` under `storage.uploads.public_path_prefix` with `X-Content-Type-Options: nosniff`, a sandboxing CSP and `Content-Disposition: attachment` for anything but a raster image. Defaults to `false`. | [surf](../../modules/surf/README.md) |
|
||||
|
||||
Reference in New Issue
Block a user