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 /_/ 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 }