| 1 | package memoria |
| 2 | |
| 3 | // HTML error pages (shared chrome spec §3). Before this an unknown URL, a |
| 4 | // document that is not there and every handler failure answered Go's |
| 5 | // plain-text "404 page not found" or a one-line string ("could not read |
| 6 | // that document") in the browser's default monospace, with no title, no |
| 7 | // chrome and no way back (polish audit §B7). |
| 8 | // |
| 9 | // Which answers are pages is decided by the request, not the route: a page |
| 10 | // is rendered only when Accept names text/html, as every browser |
| 11 | // navigation and form post does. The upload tray (Accept: |
| 12 | // application/json), the native API and anything else that did not ask for |
| 13 | // a page keep the plain answers they always had. |
| 14 | // |
| 15 | // A document that is missing and one that is not yours are the same 404 |
| 16 | // (AGENTS.md: a row that isn't yours 404s, never 403s): nothing here |
| 17 | // distinguishes them. |
| 18 | |
| 19 | import ( |
| 20 | "bytes" |
| 21 | "fmt" |
| 22 | "net/http" |
| 23 | "net/url" |
| 24 | "runtime/debug" |
| 25 | "strings" |
| 26 | |
| 27 | "amadan.net/oficina/estilo.git/chrome" |
| 28 | "amadan.net/rastrillo/rastrillo" |
| 29 | "github.com/go-chi/chi/v5" |
| 30 | ) |
| 31 | |
| 32 | // errorKind says which row of spec §3.2 a failure is. |
| 33 | type errorKind int |
| 34 | |
| 35 | const ( |
| 36 | // kindRoute: nothing lives at this address. |
| 37 | kindRoute errorKind = iota |
| 38 | // kindDocument: the document is missing, or not yours. |
| 39 | kindDocument |
| 40 | // kindStatus: the page for the status itself (400, 403, 5xx). |
| 41 | kindStatus |
| 42 | ) |
| 43 | |
| 44 | // errorWords is one row of §3.2: the title part, the heading and the body. |
| 45 | type errorWords struct { |
| 46 | title, heading, body string |
| 47 | } |
| 48 | |
| 49 | func errorCopy(status int, kind errorKind) errorWords { |
| 50 | switch { |
| 51 | case status == http.StatusNotFound && kind == kindDocument: |
| 52 | return errorWords{"Document not available", "This document isn’t available", "It may have been deleted, or it hasn’t been shared with you."} |
| 53 | case status == http.StatusNotFound: |
| 54 | return errorWords{"Page not found", "We can’t find that page", "The link may be out of date, or the page may have moved."} |
| 55 | case status == http.StatusForbidden: |
| 56 | return errorWords{"No access", "You don’t have access to this page", "Ask whoever runs this team if you think you should."} |
| 57 | case status == http.StatusBadRequest: |
| 58 | return errorWords{"That didn’t work", "That didn’t work", "Go back and try again."} |
| 59 | case status == http.StatusServiceUnavailable: |
| 60 | return errorWords{appName + " is unavailable", appName + " is unavailable right now", "Try again in a few minutes."} |
| 61 | } |
| 62 | return errorWords{"Something went wrong", "Something went wrong", "Try again in a moment. If it keeps happening, quote the reference below."} |
| 63 | } |
| 64 | |
| 65 | // errorView is templates/error.html's data. |
| 66 | type errorView struct { |
| 67 | page |
| 68 | Error chrome.ErrorPage |
| 69 | } |
| 70 | |
| 71 | // wantsHTML reports whether the request asked for a page. |
| 72 | func wantsHTML(r *http.Request) bool { |
| 73 | return strings.Contains(r.Header.Get("Accept"), "text/html") |
| 74 | } |
| 75 | |
| 76 | // plainError is the answer to a request that did not ask for a page. |
| 77 | func plainError(w http.ResponseWriter, status int) { |
| 78 | w.Header().Set("Cache-Control", "no-store") |
| 79 | http.Error(w, http.StatusText(status), status) |
| 80 | } |
| 81 | |
| 82 | // routePattern is the matched route, "/documents/{id}" rather than the |
| 83 | // path it matched: a path is safe to log only because it is never logged. |
| 84 | func routePattern(r *http.Request) string { |
| 85 | if rc := chi.RouteContext(r.Context()); rc != nil && rc.RoutePattern() != "" { |
| 86 | return rc.RoutePattern() |
| 87 | } |
| 88 | return "(unrouted)" |
| 89 | } |
| 90 | |
| 91 | // errorPage answers status: a page inside the normal layout with the |
| 92 | // viewer's own chrome for a browser, plain text otherwise. ref is the |
| 93 | // reference a 500 quotes, empty otherwise. |
| 94 | func (a *app) errorPage(w http.ResponseWriter, r *http.Request, status int, kind errorKind, ref string) { |
| 95 | if !wantsHTML(r) { |
| 96 | plainError(w, status) |
| 97 | return |
| 98 | } |
| 99 | c := errorCopy(status, kind) |
| 100 | pg := a.pageFor(r, titled(c.title)) |
| 101 | a.writeErrorPage(w, r, status, kind, ref, pg) |
| 102 | } |
| 103 | |
| 104 | // writeErrorPage renders the page with pg as its chrome. |
| 105 | func (a *app) writeErrorPage(w http.ResponseWriter, r *http.Request, status int, kind errorKind, ref string, pg page) { |
| 106 | c := errorCopy(status, kind) |
| 107 | body := chrome.ErrorPage{ |
| 108 | Status: status, |
| 109 | Title: c.heading, |
| 110 | Body: c.body, |
| 111 | Home: chrome.Link{Href: "/", Label: "Go to " + appName}, |
| 112 | } |
| 113 | // Try again is the same URL, and only for a GET: re-sending a POST |
| 114 | // from an error page is the one thing it must never do. |
| 115 | if status >= 500 && r.Method == http.MethodGet { |
| 116 | body.Home = chrome.Link{Href: r.URL.RequestURI(), Label: "Try again"} |
| 117 | } |
| 118 | if ref != "" { |
| 119 | body.Ref, body.RefLabel = ref, "Reference:" |
| 120 | } |
| 121 | if back := sameOriginReferer(r, a.origin); back != "" && status < 500 { |
| 122 | body.Back = &chrome.Link{Href: back, Label: "Back"} |
| 123 | } |
| 124 | var buf bytes.Buffer |
| 125 | if err := pages["error"].ExecuteTemplate(&buf, "layout", errorView{page: pg, Error: body}); err != nil { |
| 126 | // The page could not render: say so in the plainest way left. |
| 127 | plainError(w, status) |
| 128 | return |
| 129 | } |
| 130 | w.Header().Set("Cache-Control", "no-store") |
| 131 | w.Header().Set("Content-Type", "text/html; charset=utf-8") |
| 132 | w.WriteHeader(status) |
| 133 | _, _ = buf.WriteTo(w) |
| 134 | } |
| 135 | |
| 136 | // sameOriginReferer is the page a person came from, when it is this app |
| 137 | // and not this page: rastrillo's rule for the error page's Back link. An |
| 138 | // unchecked Referer is an open redirect with better manners. |
| 139 | func sameOriginReferer(r *http.Request, origin string) string { |
| 140 | raw := r.Header.Get("Referer") |
| 141 | if raw == "" || origin == "" { |
| 142 | return "" |
| 143 | } |
| 144 | u, err := url.Parse(raw) |
| 145 | if err != nil || u.User != nil || u.Scheme+"://"+u.Host != origin { |
| 146 | return "" |
| 147 | } |
| 148 | back := u.EscapedPath() |
| 149 | if u.RawQuery != "" { |
| 150 | back += "?" + u.RawQuery |
| 151 | } |
| 152 | if !strings.HasPrefix(back, "/") || strings.HasPrefix(back, "//") || back == r.URL.RequestURI() || |
| 153 | strings.HasPrefix(back, "/auth/") || strings.HasPrefix(back, "/signin") { |
| 154 | return "" |
| 155 | } |
| 156 | return back |
| 157 | } |
| 158 | |
| 159 | // routeNotFound is the router's NotFound: an unknown address. |
| 160 | func (a *app) routeNotFound(w http.ResponseWriter, r *http.Request) { |
| 161 | a.errorPage(w, r, http.StatusNotFound, kindRoute, "") |
| 162 | } |
| 163 | |
| 164 | // methodNotAllowed is the router's MethodNotAllowed. A GET or HEAD to a |
| 165 | // path that only takes a POST (somebody opening /documents/1/delete from |
| 166 | // history, say) is the same 404 page as an unknown route: to a person there |
| 167 | // is no page there. Any other method gets 405 in plain text, with the Allow |
| 168 | // header set here — chi's default sets it, a custom handler does not. |
| 169 | func (a *app) methodNotAllowed(routes chi.Routes) http.HandlerFunc { |
| 170 | return func(w http.ResponseWriter, r *http.Request) { |
| 171 | var allowed []string |
| 172 | for _, m := range []string{http.MethodGet, http.MethodHead, http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete} { |
| 173 | if routes.Match(chi.NewRouteContext(), m, r.URL.Path) { |
| 174 | allowed = append(allowed, m) |
| 175 | } |
| 176 | } |
| 177 | get := false |
| 178 | for _, m := range allowed { |
| 179 | get = get || m == http.MethodGet |
| 180 | } |
| 181 | if (r.Method == http.MethodGet || r.Method == http.MethodHead) && !get { |
| 182 | a.routeNotFound(w, r) |
| 183 | return |
| 184 | } |
| 185 | w.Header().Set("Allow", strings.Join(allowed, ", ")) |
| 186 | http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed) |
| 187 | } |
| 188 | } |
| 189 | |
| 190 | // recoverToErrorPage turns a panic in a handler into the 500 page with a |
| 191 | // reference, logged with the stack under the same reference. The page |
| 192 | // reads the session itself (admissionOf), so it shows the viewer's own |
| 193 | // chrome wherever in the router the panic happened. Panics outside the |
| 194 | // router reach rastrillo.Serve's own recoverer through ErrorPage. |
| 195 | // |
| 196 | // The value is logged as its type only: a panic raised with a passage in |
| 197 | // it would put content in the log (AGENTS.md, gate 4). |
| 198 | func (a *app) recoverToErrorPage(next http.Handler) http.Handler { |
| 199 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 200 | defer func() { |
| 201 | v := recover() |
| 202 | if v == nil { |
| 203 | return |
| 204 | } |
| 205 | if v == http.ErrAbortHandler { |
| 206 | panic(v) |
| 207 | } |
| 208 | ref := rastrillo.NewRef() |
| 209 | a.logger.Error("panic", "ref", ref, "route", routePattern(r), "panic", fmt.Sprintf("%T", v), "stack", string(debug.Stack())) |
| 210 | a.errorPage(w, r, http.StatusInternalServerError, kindStatus, ref) |
| 211 | }() |
| 212 | next.ServeHTTP(w, r) |
| 213 | }) |
| 214 | } |
| 215 | |
| 216 | // ErrorPage is rastrillo.Options.ErrorPage: the page rastrillo.Serve's own |
| 217 | // panic recovery renders, for a panic outside the router. It reads the |
| 218 | // session optionally like every other error page. |
| 219 | func (a *app) ErrorPage(w http.ResponseWriter, r *http.Request, status int, ref string) { |
| 220 | a.errorPage(w, r, status, kindStatus, ref) |
| 221 | } |
| 222 | |