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

@@ -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")
}
}