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:
381
modules/surf/errorpage.go
Normal file
381
modules/surf/errorpage.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user