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:
2026-10-20 03:12:25 +02:00
parent 7c2b7fcb79
commit b1236cbfd4
14 changed files with 813 additions and 28 deletions

View File

@@ -32,6 +32,7 @@ Use cases:
- 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.
- Branded error pages for browsers: a top-level browser navigation (`Sec-Fetch-Mode: navigate` when the header is sent, otherwise GET or HEAD; no `X-Requested-With`; an `Accept` header that names `text/html` or `application/xhtml+xml` with a higher q than any JSON type; not `/api`, `/api/...`, `/_<name>/...` or the admin prefix) that hits a framework-generated error gets a self-contained SummerCMS HTML page with the same status. Covered: unmatched routes (404), wrong methods (405, `Allow` kept), failed `Where`/`WhereIn` constraints and overlap-family misses (404, 405), the uploads route while its bucket or database is unpublished (404), and recovered panics in normal and raw groups (500; the panic value is never shown). Every other request keeps the exact stdlib or [wire](../wire/README.md) body. Method, path and `X-Request-Id` appear on the page only when `app.debug` is true or outside the production environment. The page sends `Cache-Control: no-store`, `X-Content-Type-Options: nosniff`, `Vary: Accept, Sec-Fetch-Mode, X-Requested-With` and a `Content-Security-Policy` that allows no script. An application or plugin replaces the page by publishing a `surf.ErrorPageRenderer`; `http.error_pages.enabled: false` turns the feature off. Handlers' own error bodies and middleware refusals are not touched.
- 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.
@@ -141,6 +142,8 @@ The generated application `main` wires surf in with `surf.ServeCommand` and `sur
| `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.ErrorPage` | What a browser error page shows: status, title, message, home link, and (only with `ShowDetails`) method, path and request id. |
| `surf.ErrorPageRenderer` | Optional backpack-published override of the HTML error page; `RenderErrorPage` returns the full document, or `ok=false` to keep the built-in page. |
| `surf.ServeCommand` | The `serve` console command. |
| `surf.RouteListCommand` | The `route:list` console command. |
@@ -169,6 +172,7 @@ The generated application `main` wires surf in with `surf.ServeCommand` and `sur
| `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`. |
| `http.error_pages.enabled` | `true` | Answers browser navigations that hit a framework-generated 404, 405 or 500 with the HTML error page (or a published `surf.ErrorPageRenderer`'s page). `false` restores the plain stdlib and JSON bodies for every request. Environment: `SUMMER_HTTP__ERROR_PAGES__ENABLED`. |
| `storage.uploads.serve` | `false` | Mounts the uploads route `GET <storage.uploads.public_path_prefix>/{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
@@ -198,7 +202,7 @@ The `serve` command also opens the database through [lagoon](../lagoon/README.md
- 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`.
- Standard library: `bytes`, `context`, `crypto/rand`, `embed`, `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`, `unicode/utf8`.
- Tests additionally use `github.com/testcontainers/testcontainers-go` (Redis container).
## Testing

View File

@@ -168,19 +168,19 @@ func TestAccessLogClientClosedRequest(t *testing.T) {
})
}, outcome{200, true}},
{"5xx through the JSON recovery after the client went away", func(cancel context.CancelFunc) http.Handler {
return recordPattern("GET /blog/posts/{id}", recoverJSON(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
return recordPattern("GET /blog/posts/{id}", recoverJSON(nil, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
cancel()
w.WriteHeader(http.StatusInternalServerError)
})))
}, outcome{499, true}},
{"panic after the client went away (JSON group)", func(cancel context.CancelFunc) http.Handler {
return recordPattern("GET /blog/posts/{id}", recoverJSON(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
return recordPattern("GET /blog/posts/{id}", recoverJSON(nil, http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
cancel()
panic("blog: broken")
})))
}, outcome{500, true}},
{"panic after the client went away (raw group)", func(cancel context.CancelFunc) http.Handler {
return recordPattern("GET /blog/posts/{id}", recoverBare(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
return recordPattern("GET /blog/posts/{id}", recoverBare(nil, http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
cancel()
panic("blog: broken")
})))

381
modules/surf/errorpage.go Normal file
View File

@@ -0,0 +1,381 @@
package surf
import (
"bytes"
_ "embed"
"encoding/base64"
"html/template"
"net/http"
"strconv"
"strings"
"unicode/utf8"
"git.golem15.com/golem15/summercms/modules/backpack"
)
// ErrorPage describes a framework-generated error answered with an HTML page
// to a browser navigation: an unmatched route, a wrong method, a failed
// Where/WhereIn constraint, the uploads route while it is unavailable, or a
// recovered panic. An ErrorPageRenderer receives it to build its own page.
type ErrorPage struct {
// Status is the HTTP status the response carries (404, 405 or 500).
Status int
// Title is the short English heading for Status, such as "Page not found".
Title string
// Message is one English sentence explaining Status.
Message string
// Home is the link back to the site, always "/".
Home string
// ShowDetails is true when app.debug is true or the environment is set
// and is not production. Method, Path and RequestID are empty otherwise.
ShowDetails bool
// Method is the request method; empty unless ShowDetails is true.
Method string
// Path is the request path routing saw; empty unless ShowDetails is true.
Path string
// RequestID is the X-Request-Id request header, trimmed and cut to 128
// bytes; empty unless ShowDetails is true or when the header is absent.
RequestID string
}
// ErrorPageRenderer is an optional backpack-published service that replaces
// the built-in HTML error page. surf looks it up for every page it renders,
// so an application or plugin publishes it in Register:
//
// app.Publish[surf.ErrorPageRenderer](myRenderer{})
//
// surf still sets the status and the page headers (Content-Type, nosniff,
// no-store, Vary and the error-page Content-Security-Policy, which allows no
// script). Returning ok=false or an empty body keeps the built-in page, and
// so does a panic inside RenderErrorPage.
type ErrorPageRenderer interface {
// RenderErrorPage returns the complete HTML document for page.
RenderErrorPage(r *http.Request, page ErrorPage) (body []byte, ok bool)
}
// errorPageCSP goes on every HTML error response. There is no script-src,
// so no script runs on an error page, an override's included.
const errorPageCSP = "default-src 'none'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'"
// errorPageMaxRanges caps how many Accept media ranges negotiation reads.
const errorPageMaxRanges = 32
// errorPageMaxRequestID caps the X-Request-Id shown on the page, in bytes.
const errorPageMaxRequestID = 128
//go:embed errorpage.html
var errorPageHTML string
//go:embed errorpage_sun.webp
var errorPageLogo []byte
var (
errorPageTemplate = template.Must(template.New("errorpage").Parse(errorPageHTML))
// errorPageLogoURI is a constant built from the embedded file, so it is
// safe to hand html/template as a URL.
errorPageLogoURI = template.URL("data:image/webp;base64," + base64.StdEncoding.EncodeToString(errorPageLogo))
)
// errorPageView is what the built-in template renders.
type errorPageView struct {
ErrorPage
Logo template.URL
}
// errorPages negotiates and writes the HTML error page. A nil *errorPages
// keeps every call site's pre-error-page behaviour.
type errorPages struct {
app *backpack.App
adminPrefix string
}
// newErrorPages returns the error-page writer for app, or nil when
// http.error_pages.enabled is present and false. A nil app, a nil config or
// an absent key keep error pages on.
func newErrorPages(app *backpack.App) *errorPages {
if app != nil && app.Config != nil {
if _, ok := app.Config.Lookup("http.error_pages.enabled"); ok && !app.Config.Bool("http.error_pages.enabled") {
return nil
}
}
return &errorPages{app: app}
}
// setAdminPrefix excludes the mounted admin prefix (and every path under it)
// from negotiation.
func (p *errorPages) setAdminPrefix(prefix string) {
if p == nil {
return
}
p.adminPrefix = strings.TrimSuffix(prefix, "/")
}
// wants reports whether r gets the HTML page instead of today's response.
func (p *errorPages) wants(r *http.Request) bool {
if p == nil || r == nil || r.URL == nil {
return false
}
path := r.URL.Path
if apiStylePath(path) {
return false
}
if p.adminPrefix != "" && (path == p.adminPrefix || strings.HasPrefix(path, p.adminPrefix+"/")) {
return false
}
return browserNavigation(r)
}
// browserNavigation reports whether r looks like a top-level browser
// navigation that prefers HTML: Sec-Fetch-Mode navigate when the header is
// present (GET or HEAD when it is absent), no X-Requested-With, and an Accept
// header that names an HTML type with a q above every JSON type it names.
func browserNavigation(r *http.Request) bool {
if modes := r.Header.Values("Sec-Fetch-Mode"); len(modes) > 0 {
if !strings.EqualFold(strings.TrimSpace(modes[0]), "navigate") {
return false
}
} else if r.Method != http.MethodGet && r.Method != http.MethodHead {
return false
}
if len(r.Header.Values("X-Requested-With")) > 0 {
return false
}
return acceptPrefersHTML(r.Header.Values("Accept"))
}
// acceptPrefersHTML reads at most errorPageMaxRanges media ranges. text/html
// or application/xhtml+xml must be named with q > 0 and a q strictly above
// that of every JSON type named (application/json, text/json,
// application/*+json). Wildcards count for neither side; a range with a
// malformed q is skipped.
func acceptPrefersHTML(values []string) bool {
htmlQ, jsonQ := -1.0, -1.0
read := 0
for _, line := range values {
for _, rng := range strings.Split(line, ",") {
if read >= errorPageMaxRanges {
break
}
read++
media, params, _ := strings.Cut(rng, ";")
media = strings.ToLower(strings.TrimSpace(media))
q, ok := acceptQ(params)
if !ok {
continue
}
switch {
case media == "text/html" || media == "application/xhtml+xml":
htmlQ = max(htmlQ, q)
case media == "application/json" || media == "text/json" ||
(strings.HasPrefix(media, "application/") && strings.HasSuffix(media, "+json")):
jsonQ = max(jsonQ, q)
}
}
}
return htmlQ > 0 && htmlQ > jsonQ
}
// acceptQ returns the q parameter of a media range (1 when absent) and false
// when it is malformed or outside 0..1.
func acceptQ(params string) (float64, bool) {
for _, param := range strings.Split(params, ";") {
key, value, found := strings.Cut(param, "=")
if !found || !strings.EqualFold(strings.TrimSpace(key), "q") {
continue
}
q, err := strconv.ParseFloat(strings.TrimSpace(value), 64)
if err != nil || q < 0 || q > 1 {
return 0, false
}
return q, true
}
return 1, true
}
// apiStylePath reports whether path is an API path that never gets the page:
// /api, anything under /api/, and the /_<name>/ prefixes.
func apiStylePath(path string) bool {
return path == "/api" || strings.HasPrefix(path, "/api/") || strings.HasPrefix(path, "/_")
}
// pageFor returns the English title and message for status.
func pageFor(status int) (string, string) {
switch status {
case http.StatusNotFound:
return "Page not found", "The page you are looking for does not exist or has moved."
case http.StatusMethodNotAllowed:
return "Method not allowed", "This address does not accept that kind of request."
case http.StatusInternalServerError:
return "Something went wrong", "The server hit an unexpected error. Please try again in a moment."
}
title := http.StatusText(status)
if title == "" {
title = "Error"
}
return title, "The server could not complete this request."
}
// showDetails reports whether the page lists method, path and request id:
// app.debug is true, or the environment is set and is not production. A nil
// app or config, or an empty environment, counts as production.
func showDetails(app *backpack.App) bool {
if app == nil || app.Config == nil {
return false
}
if app.Config.Bool("app.debug") {
return true
}
env := strings.TrimSpace(app.Config.Environment())
return env != "" && !strings.EqualFold(env, "production")
}
// requestID returns the X-Request-Id header trimmed and cut to
// errorPageMaxRequestID bytes on a rune boundary.
func requestID(r *http.Request) string {
id := strings.ToValidUTF8(strings.TrimSpace(r.Header.Get("X-Request-Id")), "")
if len(id) <= errorPageMaxRequestID {
return id
}
cut := errorPageMaxRequestID
for cut > 0 && !utf8.RuneStart(id[cut]) {
cut--
}
return id[:cut]
}
// pageData fills the ErrorPage for status and r.
func (p *errorPages) pageData(r *http.Request, status int) ErrorPage {
title, message := pageFor(status)
page := ErrorPage{Status: status, Title: title, Message: message, Home: "/"}
if showDetails(p.app) {
page.ShowDetails = true
page.Method = r.Method
page.Path = r.URL.Path
page.RequestID = requestID(r)
}
return page
}
// render returns the published renderer's page, or nil when there is none,
// it declines, returns an empty body or panics.
func (p *errorPages) render(r *http.Request, page ErrorPage) (body []byte) {
if p.app == nil {
return nil
}
renderer, ok := p.app.Lookup[ErrorPageRenderer]()
if !ok || renderer == nil {
return nil
}
defer func() {
if recover() != nil {
body = nil
}
}()
out, ok := renderer.RenderErrorPage(r, page)
if !ok || len(out) == 0 {
return nil
}
return out
}
// write answers r with the HTML error page for status. The recovered panic
// value, if any, never reaches it.
func (p *errorPages) write(w http.ResponseWriter, r *http.Request, status int) {
page := p.pageData(r, status)
body := p.render(r, page)
if body == nil {
var buf bytes.Buffer
if err := errorPageTemplate.Execute(&buf, errorPageView{ErrorPage: page, Logo: errorPageLogoURI}); err != nil {
http.Error(w, http.StatusText(status), status)
return
}
body = buf.Bytes()
}
h := w.Header()
h.Set("Content-Type", "text/html; charset=utf-8")
h.Set("X-Content-Type-Options", "nosniff")
h.Set("Cache-Control", "no-store")
h.Set("Content-Security-Policy", errorPageCSP)
addVary(h, "Accept")
addVary(h, "Sec-Fetch-Mode")
addVary(h, "X-Requested-With")
h.Del("Content-Length")
w.WriteHeader(status)
if r.Method != http.MethodHead {
_, _ = w.Write(body)
}
}
// notFound answers 404: the page for a browser navigation, else http.NotFound.
func (p *errorPages) notFound(w http.ResponseWriter, r *http.Request) {
if p.wants(r) {
p.write(w, r, http.StatusNotFound)
return
}
http.NotFound(w, r)
}
// methodNotAllowed answers 405 after the caller has set Allow: the page for a
// browser navigation, else the stdlib text.
func (p *errorPages) methodNotAllowed(w http.ResponseWriter, r *http.Request) {
if p.wants(r) {
p.write(w, r, http.StatusMethodNotAllowed)
return
}
http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed)
}
// fallback wraps mux so the 404 and 405 that ServeMux writes itself become
// the page for a browser navigation. Every other request, and every request
// a route matches, goes straight to mux. A nil receiver returns mux.
func (p *errorPages) fallback(mux *http.ServeMux) http.Handler {
if p == nil {
return mux
}
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
if !p.wants(req) {
mux.ServeHTTP(w, req)
return
}
h, pattern := mux.Handler(req)
if pattern != "" {
mux.ServeHTTP(w, req)
return
}
capture := &statusCapture{header: make(http.Header)}
h.ServeHTTP(capture, req)
switch capture.status {
case http.StatusNotFound, http.StatusMethodNotAllowed:
default:
// A path-cleaning redirect also has no pattern: let mux answer.
mux.ServeHTTP(w, req)
return
}
if allow := capture.header.Get("Allow"); allow != "" {
w.Header().Set("Allow", allow)
}
p.write(w, req, capture.status)
})
}
// statusCapture records the header and status of ServeMux's own 404/405
// handler and discards the body.
type statusCapture struct {
header http.Header
status int
}
func (c *statusCapture) Header() http.Header { return c.header }
func (c *statusCapture) WriteHeader(status int) {
if c.status == 0 {
c.status = status
}
}
func (c *statusCapture) Write(b []byte) (int, error) {
if c.status == 0 {
c.status = http.StatusOK
}
return len(b), nil
}

121
modules/surf/errorpage.html Normal file
View File

@@ -0,0 +1,121 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex">
<title>{{.Status}} · {{.Title}} · SummerCMS</title>
<link rel="icon" type="image/webp" href="{{.Logo}}">
<style>
*, *::before, *::after { box-sizing: border-box; }
html, body { margin: 0; padding: 0; }
body {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
background-color: #233148;
background-image: radial-gradient(ellipse 80% 50% at 50% 0%, rgba(245, 197, 90, 0.10), rgba(245, 197, 90, 0) 70%);
color: #e9edf3;
font-family: Roboto, system-ui, -apple-system, "Segoe UI", sans-serif;
font-size: 16px;
-webkit-font-smoothing: antialiased;
}
.card {
width: 100%;
max-width: 480px;
padding: 40px 32px;
background: #29344a;
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 16px;
box-shadow: 0 24px 48px rgba(0, 0, 0, 0.28), 0 2px 6px rgba(0, 0, 0, 0.18);
text-align: center;
}
.brand {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
margin-bottom: 24px;
}
.brand img { width: 60px; height: 60px; display: block; }
.wordmark { font-size: 18px; font-weight: 700; letter-spacing: 0.01em; color: #e9edf3; }
.gold { color: #fbd77e; }
@supports ((-webkit-background-clip: text) or (background-clip: text)) {
.gold {
background-image: linear-gradient(135deg, #fde9b4 0%, #f5c55a 100%);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
}
.status {
display: inline-block;
margin: 0;
font-size: 72px;
font-weight: 700;
line-height: 1;
letter-spacing: -0.02em;
}
h1 { margin: 16px 0 8px; font-size: 22px; font-weight: 600; line-height: 1.3; color: #e9edf3; }
.message { margin: 0 0 28px; color: #a3adbd; line-height: 1.6; }
.home {
display: inline-block;
padding: 10px 24px;
background: #f5c55a;
color: #1d2940;
border-radius: 999px;
font-weight: 600;
text-decoration: none;
transition: background-color 0.15s ease;
}
.home:hover { background: #fbd77e; }
.home:focus-visible { outline: 2px solid #fbe3a6; outline-offset: 3px; }
.details {
margin: 28px 0 0;
padding: 14px 16px;
background: #18223a;
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 6px;
display: grid;
grid-template-columns: auto 1fr;
gap: 6px 14px;
text-align: left;
font-family: "Roboto Mono", ui-monospace, monospace;
font-size: 13px;
}
.details dt { color: #8a94a3; }
.details dd { margin: 0; color: #c5ccd6; word-break: break-all; }
.caption { margin: 28px 0 0; font-size: 12px; color: #8a94a3; }
@media (max-width: 520px) {
body { padding: 16px; }
.card { padding: 28px 20px; }
.status { font-size: 56px; }
}
</style>
</head>
<body>
<main class="card">
<div class="brand">
<img src="{{.Logo}}" width="60" height="60" alt="SummerCMS">
<span class="wordmark">Summer<span class="gold">CMS</span></span>
</div>
<p class="status gold">{{.Status}}</p>
<h1>{{.Title}}</h1>
<p class="message">{{.Message}}</p>
<a class="home" href="{{.Home}}">Go to the home page</a>
{{- if .ShowDetails}}
<dl class="details">
<dt>Status</dt><dd>{{.Status}}</dd>
<dt>Method</dt><dd>{{.Method}}</dd>
<dt>Path</dt><dd>{{.Path}}</dd>
{{- if .RequestID}}
<dt>Request ID</dt><dd>{{.RequestID}}</dd>
{{- end}}
</dl>
{{- end}}
<p class="caption">Powered by SummerCMS</p>
</main>
</body>
</html>

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

View File

@@ -0,0 +1,125 @@
package surf
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"git.golem15.com/golem15/summercms/modules/pact"
"git.golem15.com/golem15/summercms/modules/party"
)
const chromeAccept = "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8"
// TestErrorPageTracer: a browser navigation to an unmatched path gets the
// branded 404 through surf.Assemble; curl keeps the stdlib text.
func TestErrorPageTracer(t *testing.T) {
app := transportApp(t, "production", map[string]string{"http.yaml": transportBodyLimits})
plugin := routesPluginFunc{id: "acme.ping", routes: func(r pact.Router) {
r.Get("/api/ping", func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte("pong")) })
}}
h, err := Assemble(app, []party.Plugin{plugin})
if err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(http.MethodGet, "/nowhere", nil)
req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8")
req.Header.Set("Sec-Fetch-Mode", "navigate")
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusNotFound {
t.Fatalf("browser status = %d, want 404", rec.Code)
}
if got := rec.Header().Get("Content-Type"); got != "text/html; charset=utf-8" {
t.Fatalf("browser Content-Type = %q", got)
}
if got := rec.Header().Get("Cache-Control"); got != "no-store" {
t.Fatalf("Cache-Control = %q", got)
}
if got := rec.Header().Get("Content-Security-Policy"); !strings.HasPrefix(got, "default-src 'none'") {
t.Fatalf("Content-Security-Policy = %q", got)
}
body := rec.Body.String()
for _, want := range []string{"404", `href="/"`, "data:image/webp;base64,"} {
if !strings.Contains(body, want) {
t.Fatalf("browser body lacks %q:\n%s", want, body)
}
}
if strings.Contains(body, "<script") {
t.Fatal("browser body contains a script tag")
}
req = httptest.NewRequest(http.MethodGet, "/nowhere", nil)
req.Header.Set("Accept", "*/*")
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusNotFound || rec.Body.String() != "404 page not found\n" {
t.Fatalf("curl got %d %q", rec.Code, rec.Body.String())
}
if got := rec.Header().Get("Content-Type"); got != "text/plain; charset=utf-8" {
t.Fatalf("curl Content-Type = %q", got)
}
}
// TestErrorPageCallSites: surf's own 404 and panic recovery negotiate too,
// and http.error_pages.enabled false restores the stdlib text.
func TestErrorPageCallSites(t *testing.T) {
routes := routesPluginFunc{id: "acme.blog", routes: func(r pact.Router) {
r.Get("/posts/{id}", func(w http.ResponseWriter, _ *http.Request) {})
r.Where("id", "[0-9]+")
r.Get("/boom", func(http.ResponseWriter, *http.Request) { panic("blog: secret panic detail") })
r.GroupRaw("/raw", nil, func(g pact.Router) {
g.Get("/boom", func(http.ResponseWriter, *http.Request) { panic("blog: secret panic detail") })
})
}}
build := func(t *testing.T, httpYAML string) http.Handler {
t.Helper()
app := transportApp(t, "production", map[string]string{"http.yaml": transportBodyLimits + httpYAML})
h, err := Assemble(app, []party.Plugin{routes})
if err != nil {
t.Fatal(err)
}
return h
}
serve := func(h http.Handler, path string, browser bool) *httptest.ResponseRecorder {
req := httptest.NewRequest(http.MethodGet, path, nil)
if browser {
req.Header.Set("Accept", chromeAccept)
req.Header.Set("Sec-Fetch-Mode", "navigate")
} else {
req.Header.Set("Accept", "*/*")
}
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
return rec
}
on := build(t, "")
for _, tc := range []struct {
path string
status int
curl string
}{
{"/posts/abc", 404, "404 page not found\n"},
{"/boom", 500, `{"error":true,"message":"Internal server error"}`},
{"/raw/boom", 500, ""},
} {
rec := serve(on, tc.path, true)
if rec.Code != tc.status || rec.Header().Get("Content-Type") != "text/html; charset=utf-8" {
t.Fatalf("browser %s: %d %q", tc.path, rec.Code, rec.Header().Get("Content-Type"))
}
if strings.Contains(rec.Body.String(), "secret panic detail") {
t.Fatalf("browser %s: panic value leaked", tc.path)
}
rec = serve(on, tc.path, false)
if rec.Code != tc.status || strings.TrimSpace(rec.Body.String()) != strings.TrimSpace(tc.curl) {
t.Fatalf("curl %s: %d %q", tc.path, rec.Code, rec.Body.String())
}
}
off := build(t, "error_pages:\n enabled: false\n")
rec := serve(off, "/nowhere", true)
if rec.Code != 404 || rec.Body.String() != "404 page not found\n" {
t.Fatalf("disabled browser: %d %q", rec.Code, rec.Body.String())
}
}

View File

@@ -234,8 +234,60 @@ func ExampleAPIDocsProvider() {
// 200 {"openapi":"3.0.3","info":{"title":"Blog API","version":"1"},"paths":{"/api/blog/posts/{id}":{"get":{"summary":"Show a post"}}}}
}
// themePages is the acme.theme error page.
type themePages struct{}
// 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
}
// ThemePlugin is the acme.theme plugin; Register publishes its error page.
type ThemePlugin struct{}
func (ThemePlugin) ID() string { return "acme.theme" }
func (ThemePlugin) Requires() []string { return nil }
func (ThemePlugin) Boot(*backpack.App) error { return nil }
// 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{})
}
func 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
}
// TestDocsDeclarations checks the plugin declarations the Routing and Rate
// limiting pages show.
// limiting pages show, the error-page override included.
func TestDocsDeclarations(t *testing.T) {
p := &BlogPlugin{}
if _, ok := p.Middlewares()["acme.auth"]; !ok {
@@ -247,4 +299,11 @@ func TestDocsDeclarations(t *testing.T) {
if spec, err := p.APIDocsSpec(); err != nil || !strings.HasPrefix(string(spec), "openapi:") {
t.Fatalf("APIDocsSpec() = %q, %v", spec, err)
}
req := httptest.NewRequest("GET", "/nowhere", nil)
if body, ok := (themePages{}).RenderErrorPage(req, surf.ErrorPage{Status: http.StatusNotFound, Home: "/"}); !ok || !strings.Contains(string(body), `href="/"`) {
t.Fatalf("RenderErrorPage(404) = %q, %v", body, ok)
}
if _, ok := (themePages{}).RenderErrorPage(req, surf.ErrorPage{Status: http.StatusInternalServerError}); ok {
t.Fatal("RenderErrorPage(500) should keep the built-in page")
}
}

View File

@@ -391,6 +391,7 @@ type familyDispatch struct {
members []familyMember
methods []string // every method in the router's table, for Allow
mux *http.ServeMux
pages *errorPages // nil keeps the stdlib 404/405 text
}
func (f *familyDispatch) ServeHTTP(w http.ResponseWriter, r *http.Request) {
@@ -421,16 +422,16 @@ func (f *familyDispatch) ServeHTTP(w http.ResponseWriter, r *http.Request) {
return
}
if methodSeen {
http.NotFound(w, r)
f.pages.notFound(w, r)
return
}
allow := f.allowed(r, vals)
if len(allow) == 0 {
http.NotFound(w, r)
f.pages.notFound(w, r)
return
}
w.Header().Set("Allow", strings.Join(allow, ", "))
http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed)
f.pages.methodNotAllowed(w, r)
}
// allowed lists the methods ServeMux would put in Allow for this path if the
@@ -466,7 +467,7 @@ func (f *familyDispatch) allowed(r *http.Request, vals []string) []string {
// registerOverlapFamilies registers each family pattern on mux, then checks
// that no other route takes a member's requests away from the family.
func registerOverlapFamilies(mux *http.ServeMux, routes []route, handlers []http.Handler, fams []overlapFamily) error {
func registerOverlapFamilies(mux *http.ServeMux, pages *errorPages, routes []route, handlers []http.Handler, fams []overlapFamily) error {
if len(fams) == 0 {
return nil
}
@@ -480,7 +481,7 @@ func registerOverlapFamilies(mux *http.ServeMux, routes []route, handlers []http
}
sort.Strings(methods)
for _, fam := range fams {
d := &familyDispatch{pattern: fam.pattern, wild: fam.wild, methods: methods, mux: mux}
d := &familyDispatch{pattern: fam.pattern, wild: fam.wild, methods: methods, mux: mux, pages: pages}
d.lits = make([]string, len(fam.wild))
for _, idx := range fam.members {
rt := routes[idx]

View File

@@ -78,14 +78,16 @@ func (c Constraint) match(value string) bool {
return false
}
func constrain(next http.Handler, cs []Constraint) http.Handler {
// constrain answers 404 (the error page for a browser navigation, pages
// permitting) when a Where/WhereIn constraint fails.
func constrain(pages *errorPages, next http.Handler, cs []Constraint) http.Handler {
if len(cs) == 0 {
return next
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
for _, c := range cs {
if !c.match(r.PathValue(c.param)) {
http.NotFound(w, r)
pages.notFound(w, r)
return
}
}

View File

@@ -68,6 +68,9 @@ type Router struct {
app *backpack.App
adminPrefix string
adminMounted bool
// pages writes the HTML error page for browser navigations; nil keeps
// every error response as it was (errorpage.go).
pages *errorPages
}
var (
@@ -381,10 +384,10 @@ func (r *Router) compile() (http.Handler, error) {
return nil, err
}
}
if err := registerOverlapFamilies(mux, r.routes, handlers, families); err != nil {
if err := registerOverlapFamilies(mux, r.pages, r.routes, handlers, families); err != nil {
return nil, err
}
h := r.stripLocalePrefix(pathScopedCORS(r.corsCfg, mux))
h := r.stripLocalePrefix(pathScopedCORS(r.corsCfg, r.pages.fallback(mux)))
if accessLogEnabled(r.app) {
h = accessLog(r.app, h)
}
@@ -405,10 +408,10 @@ func handleRoute(mux *http.ServeMux, rt route, h http.Handler) (err error) {
func (r *Router) wrap(rt route) (http.Handler, error) {
if rt.preflight {
// Preflights carry no credentials: no auth, throttle, body or locale.
h := recoverBare(constrain(rt.handler, rt.constraints))
h := recoverBare(r.pages, constrain(r.pages, rt.handler, rt.constraints))
return recordPattern(rt.method+" "+rt.path, h), nil
}
h := constrain(rt.handler, rt.constraints)
h := constrain(r.pages, rt.handler, rt.constraints)
limit, err := routeBodyLimit(rt, r.defaultBytes)
if err != nil {
return nil, fmt.Errorf("surf: plugin %q: %w", rt.pluginID, err)
@@ -473,9 +476,9 @@ func (r *Router) wrap(rt route) (http.Handler, error) {
}
h = r.locale(h)
if rt.raw {
h = recoverBare(h)
h = recoverBare(r.pages, h)
} else {
h = recoverJSON(h)
h = recoverJSON(r.pages, h)
}
// The CORS profile wraps recovery, so a refusal from a guard listed
// before it, a 413 from the body limit and a recovered 500 all carry
@@ -502,6 +505,7 @@ func Assemble(app *backpack.App, plugins []party.Plugin) (http.Handler, error) {
func BuildRouter(app *backpack.App, plugins []party.Plugin) (*Router, error) {
r := New(corsOrigins(app))
r.app = app
r.pages = newErrorPages(app)
trusted := TrustedProxies(nil)
if app != nil {
trusted = TrustedProxies(app.Config)
@@ -645,6 +649,7 @@ func BuildRouter(app *backpack.App, plugins []party.Plugin) (*Router, error) {
return nil, err
}
r.adminPrefix = admin.Prefix
r.pages.setAdminPrefix(admin.Prefix)
r.adminMounted = true
}
for _, rt := range r.routes {
@@ -775,12 +780,19 @@ func corsOrigins(app *backpack.App) []string {
}
}
func recoverJSON(next http.Handler) http.Handler {
// recoverJSON turns a panic into wire's opaque JSON 500, or the HTML error
// page for a browser navigation (pages permitting). The panic value is never
// written.
func recoverJSON(pages *errorPages, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
buffered := newBufferedResponse()
defer func() {
if rec := recover(); rec != nil {
markPanicked(r)
if pages.wants(r) {
pages.write(w, r, http.StatusInternalServerError)
return
}
wire.WriteOpaque500(w)
return
}
@@ -790,12 +802,18 @@ func recoverJSON(next http.Handler) http.Handler {
})
}
func recoverBare(next http.Handler) http.Handler {
// recoverBare turns a panic into a bare 500, or the HTML error page for a
// browser navigation (pages permitting).
func recoverBare(pages *errorPages, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
buffered := newBufferedResponse()
defer func() {
if rec := recover(); rec != nil {
markPanicked(r)
if pages.wants(r) {
pages.write(w, r, http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusInternalServerError)
return
}

View File

@@ -66,7 +66,7 @@ func mountUploads(r *Router, app *backpack.App) error {
return fmt.Errorf("surf: route %s %s (plugin %q) is under the uploads prefix storage.uploads.public_path_prefix %s, which storage.uploads.serve mounts", rt.method, rt.path, rt.pluginID, prefix)
}
}
handler := uploadsHandler(app, prefix)
handler := uploadsHandler(app, r.pages, prefix)
r.BindPlugin(uploadsPluginID)
r.GroupRaw(prefix, nil, func(g pact.Router) {
g.Get("/{path...}", handler)
@@ -103,18 +103,19 @@ func uploadsPrefix(raw string) (string, error) {
}
// uploadsHandler resolves the published bucket and database on every request
// and answers 404 while either is missing.
func uploadsHandler(app *backpack.App, prefix string) http.HandlerFunc {
// and answers 404 (the error page for a browser navigation) while either is
// missing.
func uploadsHandler(app *backpack.App, pages *errorPages, prefix string) http.HandlerFunc {
return func(w http.ResponseWriter, req *http.Request) {
serveUploads(w, req, http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
bucket, ok := app.Lookup[*blob.Bucket]()
if !ok || bucket == nil {
http.NotFound(w, req)
pages.notFound(w, req)
return
}
gdb, ok := app.Lookup[*gorm.DB]()
if !ok || gdb == nil {
http.NotFound(w, req)
pages.notFound(w, req)
return
}
attach.StaticHandlerPublic(bucket, prefix, gdb).ServeHTTP(w, req)