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.
382 lines
12 KiB
Go
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
|
|
}
|