Files
summercms/modules/surf/errorpage.go
Jakub Zych b1236cbfd4 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.
2026-10-20 03:12:25 +02:00

382 lines
12 KiB
Go

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
}