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:
@@ -32,6 +32,7 @@ Use cases:
|
||||
- Fixed-window rate limiting (`surf.FixedWindowLimiter`): named buckets from plugins that implement `surf.BucketProvider`, or inline limits keyed by the signed-in user, or by client IP for guests. Rejected requests get a 429 with `Retry-After` and `X-RateLimit-*` headers. The in-process `surf.MemoryStore` sits behind the `surf.Store` interface; `surf.BuildRouter` uses a `surf.Store` published on the app instead when there is one, which is how `serve` shares counters across instances through [cooler](../cooler/README.md)'s Redis store (`http.rate_limit.store: redis`).
|
||||
- Client IP resolution for limiter keys (`surf.ClientIP`) that only trusts `X-Forwarded-For` hops when the direct peer is inside a configured trusted proxy range (`surf.TrustedProxies`).
|
||||
- Every non-raw route runs inside JSON panic recovery (an opaque 500 via [wire](../wire/README.md)), gets the request locale from a published `surf.LocaleResolver` when one exists or from the `Accept-Language` header otherwise (see [towel](../towel/README.md)), and a request body cap. Responses are buffered until the handler returns, so a panic never leaves a half-written body.
|
||||
- Branded error pages for browsers: a top-level browser navigation (`Sec-Fetch-Mode: navigate` when the header is sent, otherwise GET or HEAD; no `X-Requested-With`; an `Accept` header that names `text/html` or `application/xhtml+xml` with a higher q than any JSON type; not `/api`, `/api/...`, `/_<name>/...` or the admin prefix) that hits a framework-generated error gets a self-contained SummerCMS HTML page with the same status. Covered: unmatched routes (404), wrong methods (405, `Allow` kept), failed `Where`/`WhereIn` constraints and overlap-family misses (404, 405), the uploads route while its bucket or database is unpublished (404), and recovered panics in normal and raw groups (500; the panic value is never shown). Every other request keeps the exact stdlib or [wire](../wire/README.md) body. Method, path and `X-Request-Id` appear on the page only when `app.debug` is true or outside the production environment. The page sends `Cache-Control: no-store`, `X-Content-Type-Options: nosniff`, `Vary: Accept, Sec-Fetch-Mode, X-Requested-With` and a `Content-Security-Policy` that allows no script. An application or plugin replaces the page by publishing a `surf.ErrorPageRenderer`; `http.error_pages.enabled: false` turns the feature off. Handlers' own error bodies and middleware refusals are not touched.
|
||||
- Path-scoped CORS configured with the same keys as Laravel's `config/cors.php` (`surf.CORSConfig`), including preflight handling. `surf.LoadCORSConfig` refuses credentials with a `*` origin and origin patterns that do not compile; an allowed origin is echoed exactly, with `Vary: Origin` on every response whose answer depends on the request origin. Every `OPTIONS` request on a CORS path is answered with 204 before routing, with the headers Laravel's `HandleCors` sends: `Cache-Control: no-cache, private` always and, on a preflight (an `Origin` and an `Access-Control-Request-Method`), the requested method (upper-cased) and headers echoed in `Access-Control-Allow-Methods` and `Access-Control-Allow-Headers` when `*` allows any, `Vary` on the request headers and PHP's default `Content-Type: text/html; charset=UTF-8`.
|
||||
- Optional `surf.LocaleResolver`: a compiled plugin publishes the interface through backpack; surf looks it up without importing the plugin. When present, it may strip an enabled locale prefix before routing and writes a validated code with `towel.WithLocale`. When absent, hosts keep the raw `Accept-Language` header and `surf.LocaleFromPrincipal` overlay.
|
||||
- `surf.LocaleFromPrincipal` switches the request locale to the signed-in user's preferred locale.
|
||||
@@ -141,6 +142,8 @@ The generated application `main` wires surf in with `surf.ServeCommand` and `sur
|
||||
| `surf.SwaggerUIVersion` | The exact swagger-ui-dist release the api-docs page loads, pinned so its assets carry SRI hashes. |
|
||||
| `surf.LocaleResolver` | Optional backpack-published request locale; `Resolve` returns a validated code and `Rewrite` strips an enabled prefix before routing. |
|
||||
| `surf.LocaleFromPrincipal` | Middleware that applies the signed-in user's preferred locale. |
|
||||
| `surf.ErrorPage` | What a browser error page shows: status, title, message, home link, and (only with `ShowDetails`) method, path and request id. |
|
||||
| `surf.ErrorPageRenderer` | Optional backpack-published override of the HTML error page; `RenderErrorPage` returns the full document, or `ok=false` to keep the built-in page. |
|
||||
| `surf.ServeCommand` | The `serve` console command. |
|
||||
| `surf.RouteListCommand` | The `route:list` console command. |
|
||||
|
||||
@@ -169,6 +172,7 @@ The generated application `main` wires surf in with `surf.ServeCommand` and `sur
|
||||
| `http.api_docs.path` | `/api-docs` | Page path; the JSON route adds `.json`. Must be an absolute URL path; a plugin route on either path fails boot. |
|
||||
| `http.api_docs.title` | `API documentation` | Page title. |
|
||||
| `http.access_log` | `false` | Logs one `http request` record per request (method, matched pattern, status, duration in milliseconds; never the path or query). Environment: `SUMMER_HTTP__ACCESS_LOG`. |
|
||||
| `http.error_pages.enabled` | `true` | Answers browser navigations that hit a framework-generated 404, 405 or 500 with the HTML error page (or a published `surf.ErrorPageRenderer`'s page). `false` restores the plain stdlib and JSON bodies for every request. Environment: `SUMMER_HTTP__ERROR_PAGES__ENABLED`. |
|
||||
| `storage.uploads.serve` | `false` | Mounts the uploads route `GET <storage.uploads.public_path_prefix>/{path...}` (prefix default `/storage/uploads`; it must then be an absolute URL path, or start-up fails) on `attach.StaticHandlerPublic` with `X-Content-Type-Options: nosniff`, a sandboxing CSP and `Content-Disposition: attachment` for anything but a raster image. Prefer a separate origin for uploads in production. Environment: `SUMMER_STORAGE__UPLOADS__SERVE`. |
|
||||
|
||||
```yaml
|
||||
@@ -198,7 +202,7 @@ The `serve` command also opens the database through [lagoon](../lagoon/README.md
|
||||
|
||||
- SummerCMS modules: [backpack](../backpack/README.md), [bonfire](../bonfire/README.md), [bouncer](../bouncer/README.md), [cabana](../cabana/README.md), [compass](../compass/README.md), [conga](../conga/README.md) (the in-process job worker of `serve`), [cooler](../cooler/README.md) (the shared rate-limit store `serve` installs), [lagoon](../lagoon/README.md) (including `lagoon/attach`), [pact](../pact/README.md), [party](../party/README.md), [towel](../towel/README.md), [wire](../wire/README.md).
|
||||
- Third-party: `gocloud.dev/blob` (uploads bucket opened by `serve`), `github.com/goccy/go-yaml` (api-docs spec conversion).
|
||||
- Standard library: `bytes`, `context`, `crypto/rand`, `encoding/base64`, `encoding/json`, `errors`, `fmt`, `html/template`, `log/slog`, `math`, `mime`, `net`, `net/http`, `net/netip`, `net/url`, `os`, `os/signal`, `regexp`, `sort`, `strconv`, `strings`, `sync`, `syscall`, `time`.
|
||||
- Standard library: `bytes`, `context`, `crypto/rand`, `embed`, `encoding/base64`, `encoding/json`, `errors`, `fmt`, `html/template`, `log/slog`, `math`, `mime`, `net`, `net/http`, `net/netip`, `net/url`, `os`, `os/signal`, `regexp`, `sort`, `strconv`, `strings`, `sync`, `syscall`, `time`, `unicode/utf8`.
|
||||
- Tests additionally use `github.com/testcontainers/testcontainers-go` (Redis container).
|
||||
|
||||
## Testing
|
||||
|
||||
@@ -168,19 +168,19 @@ func TestAccessLogClientClosedRequest(t *testing.T) {
|
||||
})
|
||||
}, outcome{200, true}},
|
||||
{"5xx through the JSON recovery after the client went away", func(cancel context.CancelFunc) http.Handler {
|
||||
return recordPattern("GET /blog/posts/{id}", recoverJSON(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
return recordPattern("GET /blog/posts/{id}", recoverJSON(nil, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
cancel()
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
})))
|
||||
}, outcome{499, true}},
|
||||
{"panic after the client went away (JSON group)", func(cancel context.CancelFunc) http.Handler {
|
||||
return recordPattern("GET /blog/posts/{id}", recoverJSON(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
|
||||
return recordPattern("GET /blog/posts/{id}", recoverJSON(nil, http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
|
||||
cancel()
|
||||
panic("blog: broken")
|
||||
})))
|
||||
}, outcome{500, true}},
|
||||
{"panic after the client went away (raw group)", func(cancel context.CancelFunc) http.Handler {
|
||||
return recordPattern("GET /blog/posts/{id}", recoverBare(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
|
||||
return recordPattern("GET /blog/posts/{id}", recoverBare(nil, http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
|
||||
cancel()
|
||||
panic("blog: broken")
|
||||
})))
|
||||
|
||||
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
|
||||
}
|
||||
121
modules/surf/errorpage.html
Normal file
121
modules/surf/errorpage.html
Normal file
@@ -0,0 +1,121 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="robots" content="noindex">
|
||||
<title>{{.Status}} · {{.Title}} · SummerCMS</title>
|
||||
<link rel="icon" type="image/webp" href="{{.Logo}}">
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; }
|
||||
html, body { margin: 0; padding: 0; }
|
||||
body {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
background-color: #233148;
|
||||
background-image: radial-gradient(ellipse 80% 50% at 50% 0%, rgba(245, 197, 90, 0.10), rgba(245, 197, 90, 0) 70%);
|
||||
color: #e9edf3;
|
||||
font-family: Roboto, system-ui, -apple-system, "Segoe UI", sans-serif;
|
||||
font-size: 16px;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
.card {
|
||||
width: 100%;
|
||||
max-width: 480px;
|
||||
padding: 40px 32px;
|
||||
background: #29344a;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 24px 48px rgba(0, 0, 0, 0.28), 0 2px 6px rgba(0, 0, 0, 0.18);
|
||||
text-align: center;
|
||||
}
|
||||
.brand {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.brand img { width: 60px; height: 60px; display: block; }
|
||||
.wordmark { font-size: 18px; font-weight: 700; letter-spacing: 0.01em; color: #e9edf3; }
|
||||
.gold { color: #fbd77e; }
|
||||
@supports ((-webkit-background-clip: text) or (background-clip: text)) {
|
||||
.gold {
|
||||
background-image: linear-gradient(135deg, #fde9b4 0%, #f5c55a 100%);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
}
|
||||
}
|
||||
.status {
|
||||
display: inline-block;
|
||||
margin: 0;
|
||||
font-size: 72px;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
h1 { margin: 16px 0 8px; font-size: 22px; font-weight: 600; line-height: 1.3; color: #e9edf3; }
|
||||
.message { margin: 0 0 28px; color: #a3adbd; line-height: 1.6; }
|
||||
.home {
|
||||
display: inline-block;
|
||||
padding: 10px 24px;
|
||||
background: #f5c55a;
|
||||
color: #1d2940;
|
||||
border-radius: 999px;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
.home:hover { background: #fbd77e; }
|
||||
.home:focus-visible { outline: 2px solid #fbe3a6; outline-offset: 3px; }
|
||||
.details {
|
||||
margin: 28px 0 0;
|
||||
padding: 14px 16px;
|
||||
background: #18223a;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 6px;
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: 6px 14px;
|
||||
text-align: left;
|
||||
font-family: "Roboto Mono", ui-monospace, monospace;
|
||||
font-size: 13px;
|
||||
}
|
||||
.details dt { color: #8a94a3; }
|
||||
.details dd { margin: 0; color: #c5ccd6; word-break: break-all; }
|
||||
.caption { margin: 28px 0 0; font-size: 12px; color: #8a94a3; }
|
||||
@media (max-width: 520px) {
|
||||
body { padding: 16px; }
|
||||
.card { padding: 28px 20px; }
|
||||
.status { font-size: 56px; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main class="card">
|
||||
<div class="brand">
|
||||
<img src="{{.Logo}}" width="60" height="60" alt="SummerCMS">
|
||||
<span class="wordmark">Summer<span class="gold">CMS</span></span>
|
||||
</div>
|
||||
<p class="status gold">{{.Status}}</p>
|
||||
<h1>{{.Title}}</h1>
|
||||
<p class="message">{{.Message}}</p>
|
||||
<a class="home" href="{{.Home}}">Go to the home page</a>
|
||||
{{- if .ShowDetails}}
|
||||
<dl class="details">
|
||||
<dt>Status</dt><dd>{{.Status}}</dd>
|
||||
<dt>Method</dt><dd>{{.Method}}</dd>
|
||||
<dt>Path</dt><dd>{{.Path}}</dd>
|
||||
{{- if .RequestID}}
|
||||
<dt>Request ID</dt><dd>{{.RequestID}}</dd>
|
||||
{{- end}}
|
||||
</dl>
|
||||
{{- end}}
|
||||
<p class="caption">Powered by SummerCMS</p>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
BIN
modules/surf/errorpage_sun.webp
Normal file
BIN
modules/surf/errorpage_sun.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.2 KiB |
125
modules/surf/errorpage_test.go
Normal file
125
modules/surf/errorpage_test.go
Normal file
@@ -0,0 +1,125 @@
|
||||
package surf
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/pact"
|
||||
"git.golem15.com/golem15/summercms/modules/party"
|
||||
)
|
||||
|
||||
const chromeAccept = "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8"
|
||||
|
||||
// TestErrorPageTracer: a browser navigation to an unmatched path gets the
|
||||
// branded 404 through surf.Assemble; curl keeps the stdlib text.
|
||||
func TestErrorPageTracer(t *testing.T) {
|
||||
app := transportApp(t, "production", map[string]string{"http.yaml": transportBodyLimits})
|
||||
plugin := routesPluginFunc{id: "acme.ping", routes: func(r pact.Router) {
|
||||
r.Get("/api/ping", func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte("pong")) })
|
||||
}}
|
||||
h, err := Assemble(app, []party.Plugin{plugin})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/nowhere", nil)
|
||||
req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8")
|
||||
req.Header.Set("Sec-Fetch-Mode", "navigate")
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("browser status = %d, want 404", rec.Code)
|
||||
}
|
||||
if got := rec.Header().Get("Content-Type"); got != "text/html; charset=utf-8" {
|
||||
t.Fatalf("browser Content-Type = %q", got)
|
||||
}
|
||||
if got := rec.Header().Get("Cache-Control"); got != "no-store" {
|
||||
t.Fatalf("Cache-Control = %q", got)
|
||||
}
|
||||
if got := rec.Header().Get("Content-Security-Policy"); !strings.HasPrefix(got, "default-src 'none'") {
|
||||
t.Fatalf("Content-Security-Policy = %q", got)
|
||||
}
|
||||
body := rec.Body.String()
|
||||
for _, want := range []string{"404", `href="/"`, "data:image/webp;base64,"} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Fatalf("browser body lacks %q:\n%s", want, body)
|
||||
}
|
||||
}
|
||||
if strings.Contains(body, "<script") {
|
||||
t.Fatal("browser body contains a script tag")
|
||||
}
|
||||
|
||||
req = httptest.NewRequest(http.MethodGet, "/nowhere", nil)
|
||||
req.Header.Set("Accept", "*/*")
|
||||
rec = httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusNotFound || rec.Body.String() != "404 page not found\n" {
|
||||
t.Fatalf("curl got %d %q", rec.Code, rec.Body.String())
|
||||
}
|
||||
if got := rec.Header().Get("Content-Type"); got != "text/plain; charset=utf-8" {
|
||||
t.Fatalf("curl Content-Type = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestErrorPageCallSites: surf's own 404 and panic recovery negotiate too,
|
||||
// and http.error_pages.enabled false restores the stdlib text.
|
||||
func TestErrorPageCallSites(t *testing.T) {
|
||||
routes := routesPluginFunc{id: "acme.blog", routes: func(r pact.Router) {
|
||||
r.Get("/posts/{id}", func(w http.ResponseWriter, _ *http.Request) {})
|
||||
r.Where("id", "[0-9]+")
|
||||
r.Get("/boom", func(http.ResponseWriter, *http.Request) { panic("blog: secret panic detail") })
|
||||
r.GroupRaw("/raw", nil, func(g pact.Router) {
|
||||
g.Get("/boom", func(http.ResponseWriter, *http.Request) { panic("blog: secret panic detail") })
|
||||
})
|
||||
}}
|
||||
build := func(t *testing.T, httpYAML string) http.Handler {
|
||||
t.Helper()
|
||||
app := transportApp(t, "production", map[string]string{"http.yaml": transportBodyLimits + httpYAML})
|
||||
h, err := Assemble(app, []party.Plugin{routes})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return h
|
||||
}
|
||||
serve := func(h http.Handler, path string, browser bool) *httptest.ResponseRecorder {
|
||||
req := httptest.NewRequest(http.MethodGet, path, nil)
|
||||
if browser {
|
||||
req.Header.Set("Accept", chromeAccept)
|
||||
req.Header.Set("Sec-Fetch-Mode", "navigate")
|
||||
} else {
|
||||
req.Header.Set("Accept", "*/*")
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
return rec
|
||||
}
|
||||
on := build(t, "")
|
||||
for _, tc := range []struct {
|
||||
path string
|
||||
status int
|
||||
curl string
|
||||
}{
|
||||
{"/posts/abc", 404, "404 page not found\n"},
|
||||
{"/boom", 500, `{"error":true,"message":"Internal server error"}`},
|
||||
{"/raw/boom", 500, ""},
|
||||
} {
|
||||
rec := serve(on, tc.path, true)
|
||||
if rec.Code != tc.status || rec.Header().Get("Content-Type") != "text/html; charset=utf-8" {
|
||||
t.Fatalf("browser %s: %d %q", tc.path, rec.Code, rec.Header().Get("Content-Type"))
|
||||
}
|
||||
if strings.Contains(rec.Body.String(), "secret panic detail") {
|
||||
t.Fatalf("browser %s: panic value leaked", tc.path)
|
||||
}
|
||||
rec = serve(on, tc.path, false)
|
||||
if rec.Code != tc.status || strings.TrimSpace(rec.Body.String()) != strings.TrimSpace(tc.curl) {
|
||||
t.Fatalf("curl %s: %d %q", tc.path, rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
off := build(t, "error_pages:\n enabled: false\n")
|
||||
rec := serve(off, "/nowhere", true)
|
||||
if rec.Code != 404 || rec.Body.String() != "404 page not found\n" {
|
||||
t.Fatalf("disabled browser: %d %q", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -391,6 +391,7 @@ type familyDispatch struct {
|
||||
members []familyMember
|
||||
methods []string // every method in the router's table, for Allow
|
||||
mux *http.ServeMux
|
||||
pages *errorPages // nil keeps the stdlib 404/405 text
|
||||
}
|
||||
|
||||
func (f *familyDispatch) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -421,16 +422,16 @@ func (f *familyDispatch) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
if methodSeen {
|
||||
http.NotFound(w, r)
|
||||
f.pages.notFound(w, r)
|
||||
return
|
||||
}
|
||||
allow := f.allowed(r, vals)
|
||||
if len(allow) == 0 {
|
||||
http.NotFound(w, r)
|
||||
f.pages.notFound(w, r)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Allow", strings.Join(allow, ", "))
|
||||
http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed)
|
||||
f.pages.methodNotAllowed(w, r)
|
||||
}
|
||||
|
||||
// allowed lists the methods ServeMux would put in Allow for this path if the
|
||||
@@ -466,7 +467,7 @@ func (f *familyDispatch) allowed(r *http.Request, vals []string) []string {
|
||||
|
||||
// registerOverlapFamilies registers each family pattern on mux, then checks
|
||||
// that no other route takes a member's requests away from the family.
|
||||
func registerOverlapFamilies(mux *http.ServeMux, routes []route, handlers []http.Handler, fams []overlapFamily) error {
|
||||
func registerOverlapFamilies(mux *http.ServeMux, pages *errorPages, routes []route, handlers []http.Handler, fams []overlapFamily) error {
|
||||
if len(fams) == 0 {
|
||||
return nil
|
||||
}
|
||||
@@ -480,7 +481,7 @@ func registerOverlapFamilies(mux *http.ServeMux, routes []route, handlers []http
|
||||
}
|
||||
sort.Strings(methods)
|
||||
for _, fam := range fams {
|
||||
d := &familyDispatch{pattern: fam.pattern, wild: fam.wild, methods: methods, mux: mux}
|
||||
d := &familyDispatch{pattern: fam.pattern, wild: fam.wild, methods: methods, mux: mux, pages: pages}
|
||||
d.lits = make([]string, len(fam.wild))
|
||||
for _, idx := range fam.members {
|
||||
rt := routes[idx]
|
||||
|
||||
@@ -78,14 +78,16 @@ func (c Constraint) match(value string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func constrain(next http.Handler, cs []Constraint) http.Handler {
|
||||
// constrain answers 404 (the error page for a browser navigation, pages
|
||||
// permitting) when a Where/WhereIn constraint fails.
|
||||
func constrain(pages *errorPages, next http.Handler, cs []Constraint) http.Handler {
|
||||
if len(cs) == 0 {
|
||||
return next
|
||||
}
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
for _, c := range cs {
|
||||
if !c.match(r.PathValue(c.param)) {
|
||||
http.NotFound(w, r)
|
||||
pages.notFound(w, r)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,6 +68,9 @@ type Router struct {
|
||||
app *backpack.App
|
||||
adminPrefix string
|
||||
adminMounted bool
|
||||
// pages writes the HTML error page for browser navigations; nil keeps
|
||||
// every error response as it was (errorpage.go).
|
||||
pages *errorPages
|
||||
}
|
||||
|
||||
var (
|
||||
@@ -381,10 +384,10 @@ func (r *Router) compile() (http.Handler, error) {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if err := registerOverlapFamilies(mux, r.routes, handlers, families); err != nil {
|
||||
if err := registerOverlapFamilies(mux, r.pages, r.routes, handlers, families); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
h := r.stripLocalePrefix(pathScopedCORS(r.corsCfg, mux))
|
||||
h := r.stripLocalePrefix(pathScopedCORS(r.corsCfg, r.pages.fallback(mux)))
|
||||
if accessLogEnabled(r.app) {
|
||||
h = accessLog(r.app, h)
|
||||
}
|
||||
@@ -405,10 +408,10 @@ func handleRoute(mux *http.ServeMux, rt route, h http.Handler) (err error) {
|
||||
func (r *Router) wrap(rt route) (http.Handler, error) {
|
||||
if rt.preflight {
|
||||
// Preflights carry no credentials: no auth, throttle, body or locale.
|
||||
h := recoverBare(constrain(rt.handler, rt.constraints))
|
||||
h := recoverBare(r.pages, constrain(r.pages, rt.handler, rt.constraints))
|
||||
return recordPattern(rt.method+" "+rt.path, h), nil
|
||||
}
|
||||
h := constrain(rt.handler, rt.constraints)
|
||||
h := constrain(r.pages, rt.handler, rt.constraints)
|
||||
limit, err := routeBodyLimit(rt, r.defaultBytes)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("surf: plugin %q: %w", rt.pluginID, err)
|
||||
@@ -473,9 +476,9 @@ func (r *Router) wrap(rt route) (http.Handler, error) {
|
||||
}
|
||||
h = r.locale(h)
|
||||
if rt.raw {
|
||||
h = recoverBare(h)
|
||||
h = recoverBare(r.pages, h)
|
||||
} else {
|
||||
h = recoverJSON(h)
|
||||
h = recoverJSON(r.pages, h)
|
||||
}
|
||||
// The CORS profile wraps recovery, so a refusal from a guard listed
|
||||
// before it, a 413 from the body limit and a recovered 500 all carry
|
||||
@@ -502,6 +505,7 @@ func Assemble(app *backpack.App, plugins []party.Plugin) (http.Handler, error) {
|
||||
func BuildRouter(app *backpack.App, plugins []party.Plugin) (*Router, error) {
|
||||
r := New(corsOrigins(app))
|
||||
r.app = app
|
||||
r.pages = newErrorPages(app)
|
||||
trusted := TrustedProxies(nil)
|
||||
if app != nil {
|
||||
trusted = TrustedProxies(app.Config)
|
||||
@@ -645,6 +649,7 @@ func BuildRouter(app *backpack.App, plugins []party.Plugin) (*Router, error) {
|
||||
return nil, err
|
||||
}
|
||||
r.adminPrefix = admin.Prefix
|
||||
r.pages.setAdminPrefix(admin.Prefix)
|
||||
r.adminMounted = true
|
||||
}
|
||||
for _, rt := range r.routes {
|
||||
@@ -775,12 +780,19 @@ func corsOrigins(app *backpack.App) []string {
|
||||
}
|
||||
}
|
||||
|
||||
func recoverJSON(next http.Handler) http.Handler {
|
||||
// recoverJSON turns a panic into wire's opaque JSON 500, or the HTML error
|
||||
// page for a browser navigation (pages permitting). The panic value is never
|
||||
// written.
|
||||
func recoverJSON(pages *errorPages, next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
buffered := newBufferedResponse()
|
||||
defer func() {
|
||||
if rec := recover(); rec != nil {
|
||||
markPanicked(r)
|
||||
if pages.wants(r) {
|
||||
pages.write(w, r, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
wire.WriteOpaque500(w)
|
||||
return
|
||||
}
|
||||
@@ -790,12 +802,18 @@ func recoverJSON(next http.Handler) http.Handler {
|
||||
})
|
||||
}
|
||||
|
||||
func recoverBare(next http.Handler) http.Handler {
|
||||
// recoverBare turns a panic into a bare 500, or the HTML error page for a
|
||||
// browser navigation (pages permitting).
|
||||
func recoverBare(pages *errorPages, next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
buffered := newBufferedResponse()
|
||||
defer func() {
|
||||
if rec := recover(); rec != nil {
|
||||
markPanicked(r)
|
||||
if pages.wants(r) {
|
||||
pages.write(w, r, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -66,7 +66,7 @@ func mountUploads(r *Router, app *backpack.App) error {
|
||||
return fmt.Errorf("surf: route %s %s (plugin %q) is under the uploads prefix storage.uploads.public_path_prefix %s, which storage.uploads.serve mounts", rt.method, rt.path, rt.pluginID, prefix)
|
||||
}
|
||||
}
|
||||
handler := uploadsHandler(app, prefix)
|
||||
handler := uploadsHandler(app, r.pages, prefix)
|
||||
r.BindPlugin(uploadsPluginID)
|
||||
r.GroupRaw(prefix, nil, func(g pact.Router) {
|
||||
g.Get("/{path...}", handler)
|
||||
@@ -103,18 +103,19 @@ func uploadsPrefix(raw string) (string, error) {
|
||||
}
|
||||
|
||||
// uploadsHandler resolves the published bucket and database on every request
|
||||
// and answers 404 while either is missing.
|
||||
func uploadsHandler(app *backpack.App, prefix string) http.HandlerFunc {
|
||||
// and answers 404 (the error page for a browser navigation) while either is
|
||||
// missing.
|
||||
func uploadsHandler(app *backpack.App, pages *errorPages, prefix string) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, req *http.Request) {
|
||||
serveUploads(w, req, http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
bucket, ok := app.Lookup[*blob.Bucket]()
|
||||
if !ok || bucket == nil {
|
||||
http.NotFound(w, req)
|
||||
pages.notFound(w, req)
|
||||
return
|
||||
}
|
||||
gdb, ok := app.Lookup[*gorm.DB]()
|
||||
if !ok || gdb == nil {
|
||||
http.NotFound(w, req)
|
||||
pages.notFound(w, req)
|
||||
return
|
||||
}
|
||||
attach.StaticHandlerPublic(bucket, prefix, gdb).ServeHTTP(w, req)
|
||||
|
||||
Reference in New Issue
Block a user