| 1 | package docs |
| 2 | |
| 3 | // HTML error pages (spec §3). Before this, every failure a browser could |
| 4 | // navigate to was http.Error's plain text in a monospace page with an |
| 5 | // empty <title> — "Not found." for a bad document link, and Go's own |
| 6 | // "404 page not found" for an unknown route. |
| 7 | // |
| 8 | // The rule for which answers are pages: a page is rendered only when the |
| 9 | // request says it wants HTML (its Accept names text/html, as every |
| 10 | // browser navigation and form post does). The update log, the stream, |
| 11 | // search and the native API are fetched by script or by the native apps |
| 12 | // and never ask for HTML, so they keep their plain-text and JSON answers |
| 13 | // even when a shared helper (getDocumentForUpdates) serves both. Docs has |
| 14 | // no /api/ prefix to decide it by, and a prefix would be the wrong rule |
| 15 | // anyway (spec §3.3). |
| 16 | // |
| 17 | // A missing document and one that is not shared with you are the same |
| 18 | // 404 (§4.2): nothing here distinguishes them, so nothing is enumerable. |
| 19 | |
| 20 | import ( |
| 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 | // failKind says which of the spec's error pages a status stands for. |
| 33 | type failKind int |
| 34 | |
| 35 | const ( |
| 36 | // failRoute: nothing lives at this address. |
| 37 | failRoute failKind = iota |
| 38 | // failDocument: the document is missing or not shared with you. |
| 39 | failDocument |
| 40 | // failStatus: the page for the status itself (403, 5xx, 400). |
| 41 | failStatus |
| 42 | ) |
| 43 | |
| 44 | type errorPageData struct { |
| 45 | Frame Frame |
| 46 | Error chrome.ErrorPage |
| 47 | } |
| 48 | |
| 49 | // wantsHTML reports whether the request asked for a page. |
| 50 | func wantsHTML(r *http.Request) bool { |
| 51 | return strings.Contains(r.Header.Get("Accept"), "text/html") |
| 52 | } |
| 53 | |
| 54 | // fail answers an error. A request that asked for HTML gets the page; |
| 55 | // anything else gets the plain text it got before this existed, so a |
| 56 | // script or a native client reading the body sees no change. |
| 57 | func (h *handlers) fail(w http.ResponseWriter, r *http.Request, status int, kind failKind) { |
| 58 | if !wantsHTML(r) { |
| 59 | key := "docs.error.generic" |
| 60 | if status == http.StatusNotFound { |
| 61 | key = "docs.error.notfound" |
| 62 | } |
| 63 | http.Error(w, rastrillo.T(r, key), status) |
| 64 | return |
| 65 | } |
| 66 | ref := "" |
| 67 | if status == http.StatusInternalServerError { |
| 68 | // The page tells a person to quote the reference below, so every |
| 69 | // 500 page has one, logged beside the path it answered. |
| 70 | ref = rastrillo.NewRef() |
| 71 | h.logger.Error("docs: error page", "ref", ref, "path", r.URL.Path) |
| 72 | } |
| 73 | h.errorPage(w, r, status, kind, ref) |
| 74 | } |
| 75 | |
| 76 | // errorPage renders the page for status. ref is the reference quoted on |
| 77 | // a 500 (rastrillo.NewRef), empty otherwise. |
| 78 | func (h *handlers) errorPage(w http.ResponseWriter, r *http.Request, status int, kind failKind, ref string) { |
| 79 | app := rastrillo.T(r, "docs.app.name") |
| 80 | home := chrome.Link{Href: "/", Label: rastrillo.Tf(r, "docs.error.home", "app", app)} |
| 81 | key := "docs.error.page.500" |
| 82 | switch { |
| 83 | case status == http.StatusNotFound && kind == failDocument: |
| 84 | key = "docs.error.page.document" |
| 85 | case status == http.StatusNotFound: |
| 86 | key = "docs.error.page.404" |
| 87 | case status == http.StatusForbidden: |
| 88 | key = "docs.error.page.403" |
| 89 | case status == http.StatusBadRequest: |
| 90 | key = "docs.error.page.400" |
| 91 | case status == http.StatusServiceUnavailable: |
| 92 | key = "docs.error.page.503" |
| 93 | } |
| 94 | page := chrome.ErrorPage{ |
| 95 | Status: status, |
| 96 | Title: rastrillo.Tf(r, key+".heading", "app", app), |
| 97 | Body: rastrillo.T(r, key+".body"), |
| 98 | Home: home, |
| 99 | } |
| 100 | // Try again is the same URL, and only for a GET: re-sending a POST |
| 101 | // from an error page is the one thing it must never do. |
| 102 | if status >= 500 && r.Method == http.MethodGet { |
| 103 | page.Home = chrome.Link{Href: r.URL.RequestURI(), Label: rastrillo.T(r, "docs.error.retry")} |
| 104 | } |
| 105 | if status == http.StatusInternalServerError && ref != "" { |
| 106 | page.Ref, page.RefLabel = ref, rastrillo.T(r, "docs.error.ref") |
| 107 | } |
| 108 | if back := h.sameOriginReferer(r); back != "" && status < 500 { |
| 109 | page.Back = &chrome.Link{Href: back, Label: rastrillo.T(r, "docs.error.back")} |
| 110 | } |
| 111 | data := errorPageData{ |
| 112 | Frame: h.frame(r, frameOptions{titleParts: []string{rastrillo.Tf(r, key+".title", "app", app)}}), |
| 113 | Error: page, |
| 114 | } |
| 115 | w.Header().Set("Cache-Control", "no-store") |
| 116 | if err := h.render.pageStatus(w, r, "error", data, status); err != nil { |
| 117 | // The page could not render: say so in the plainest way left. |
| 118 | h.logger.Error("docs: render error page", "status", status, "err", err) |
| 119 | http.Error(w, rastrillo.T(r, "docs.error.generic"), status) |
| 120 | } |
| 121 | } |
| 122 | |
| 123 | // sameOriginReferer is the page a person came from, when it is this app |
| 124 | // and not this page — rastrillo's rule for the error page's Back link. An |
| 125 | // unchecked Referer is an open redirect with better manners. |
| 126 | func (h *handlers) sameOriginReferer(r *http.Request) string { |
| 127 | raw := r.Header.Get("Referer") |
| 128 | if raw == "" { |
| 129 | return "" |
| 130 | } |
| 131 | u, err := url.Parse(raw) |
| 132 | if err != nil || u.User != nil || u.Scheme+"://"+u.Host != h.origin { |
| 133 | return "" |
| 134 | } |
| 135 | back := u.EscapedPath() |
| 136 | if u.RawQuery != "" { |
| 137 | back += "?" + u.RawQuery |
| 138 | } |
| 139 | if !strings.HasPrefix(back, "/") || strings.HasPrefix(back, "//") || back == r.URL.RequestURI() { |
| 140 | return "" |
| 141 | } |
| 142 | return back |
| 143 | } |
| 144 | |
| 145 | // notFound is the router's NotFound: an unknown route is the 404 page for |
| 146 | // a browser and chi's plain text for anything else. |
| 147 | func (h *handlers) notFound(w http.ResponseWriter, r *http.Request) { |
| 148 | h.fail(w, r, http.StatusNotFound, failRoute) |
| 149 | } |
| 150 | |
| 151 | // methodNotAllowed is the router's MethodNotAllowed. A GET or HEAD to a |
| 152 | // path that only takes a POST (someone opening /signout from history) is |
| 153 | // the same 404 page as an unknown route: to a person there is no page |
| 154 | // there. Any other method gets 405 in plain text, with the Allow header |
| 155 | // set here — chi's default sets it, a custom handler does not. |
| 156 | func (h *handlers) methodNotAllowed(routes chi.Routes) http.HandlerFunc { |
| 157 | return func(w http.ResponseWriter, r *http.Request) { |
| 158 | var allowed []string |
| 159 | for _, m := range []string{http.MethodGet, http.MethodHead, http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete} { |
| 160 | if routes.Match(chi.NewRouteContext(), m, r.URL.Path) { |
| 161 | allowed = append(allowed, m) |
| 162 | } |
| 163 | } |
| 164 | get := false |
| 165 | for _, m := range allowed { |
| 166 | get = get || m == http.MethodGet |
| 167 | } |
| 168 | if (r.Method == http.MethodGet || r.Method == http.MethodHead) && !get { |
| 169 | h.fail(w, r, http.StatusNotFound, failRoute) |
| 170 | return |
| 171 | } |
| 172 | w.Header().Set("Allow", strings.Join(allowed, ", ")) |
| 173 | http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed) |
| 174 | } |
| 175 | } |
| 176 | |
| 177 | // recoverToErrorPage turns a panic in a handler into the 500 page with a |
| 178 | // reference, logged with the stack under the same reference. |
| 179 | // |
| 180 | // It is mounted INSIDE the signed-in group, after the session middleware, |
| 181 | // so the page carries the viewer's own chrome: mounted outside, every 500 |
| 182 | // would look signed out, which is Calendar's bug (spec §3.3). Panics |
| 183 | // outside it reach rastrillo.Serve's own recoverer through |
| 184 | // App.ErrorPage. |
| 185 | func (h *handlers) recoverToErrorPage(next http.Handler) http.Handler { |
| 186 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 187 | defer func() { |
| 188 | v := recover() |
| 189 | if v == nil { |
| 190 | return |
| 191 | } |
| 192 | if v == http.ErrAbortHandler { |
| 193 | panic(v) |
| 194 | } |
| 195 | ref := rastrillo.NewRef() |
| 196 | h.logger.Error("docs: panic", "ref", ref, "path", r.URL.Path, "panic", fmt.Sprint(v), "stack", string(debug.Stack())) |
| 197 | if !wantsHTML(r) { |
| 198 | http.Error(w, rastrillo.T(r, "docs.error.generic"), http.StatusInternalServerError) |
| 199 | return |
| 200 | } |
| 201 | h.errorPage(w, r, http.StatusInternalServerError, failStatus, ref) |
| 202 | }() |
| 203 | next.ServeHTTP(w, r) |
| 204 | }) |
| 205 | } |
| 206 | |
| 207 | // ErrorPage is rastrillo.Options.ErrorPage: the page rastrillo.Serve's |
| 208 | // own panic recovery renders, for a panic outside the router's signed-in |
| 209 | // group. It reads the session optionally like every other error page. |
| 210 | func (a *App) ErrorPage(w http.ResponseWriter, r *http.Request, status int, ref string) { |
| 211 | if !wantsHTML(r) { |
| 212 | http.Error(w, rastrillo.T(r, "docs.error.generic"), status) |
| 213 | return |
| 214 | } |
| 215 | a.handlers.errorPage(w, r, status, failStatus, ref) |
| 216 | } |
| 217 | |