| 1 | package slides |
| 2 | |
| 3 | // HTML error pages (shared chrome spec §3). Before this an unknown URL |
| 4 | // answered chi's plain-text "404 page not found" in monospace, with no |
| 5 | // chrome and an empty <title> (polish audit, "prototype"), and the |
| 6 | // handled error pages always showed signed-out chrome, whoever was |
| 7 | // signed in. |
| 8 | // |
| 9 | // Which answers are pages is decided by the request, not the route: a |
| 10 | // page is rendered only when Accept names text/html, as every browser |
| 11 | // navigation and form post does. The editor's fetches (slide HTML, |
| 12 | // updates, presence), the SSE streams and every <img> of a thumbnail or |
| 13 | // an asset never ask for HTML, so they keep plain answers. |
| 14 | // |
| 15 | // A deck that is missing and one that is not shared with you are the |
| 16 | // same 404 (visibleDecks): nothing here distinguishes them. |
| 17 | |
| 18 | import ( |
| 19 | "fmt" |
| 20 | "net/http" |
| 21 | "net/url" |
| 22 | "runtime/debug" |
| 23 | "strings" |
| 24 | |
| 25 | "amadan.net/oficina/estilo.git/chrome" |
| 26 | "amadan.net/rastrillo/rastrillo" |
| 27 | "github.com/go-chi/chi/v5" |
| 28 | ) |
| 29 | |
| 30 | // errorKind says which row of spec §3.2 a failure is. |
| 31 | type errorKind int |
| 32 | |
| 33 | const ( |
| 34 | // kindRoute: nothing lives at this address. |
| 35 | kindRoute errorKind = iota |
| 36 | // kindDeck: the deck (or share link, or talk) is missing, or not |
| 37 | // shared with you. |
| 38 | kindDeck |
| 39 | // kindStatus: the page for the status itself (403, 410, 422, 5xx). |
| 40 | kindStatus |
| 41 | ) |
| 42 | |
| 43 | // errorWords is one row of §3.2, resolved from the catalog: the title |
| 44 | // part, the heading and the body. |
| 45 | type errorWords struct { |
| 46 | title, heading, body string |
| 47 | } |
| 48 | |
| 49 | func errorCopy(r *http.Request, status int, kind errorKind) errorWords { |
| 50 | key := "slides.error.500" |
| 51 | switch { |
| 52 | case status == http.StatusNotFound && kind == kindDeck: |
| 53 | key = "slides.error.deck" |
| 54 | case status == http.StatusNotFound: |
| 55 | key = "slides.error.404" |
| 56 | case status == http.StatusForbidden: |
| 57 | key = "slides.error.403" |
| 58 | case status == http.StatusGone: |
| 59 | // A talk that finished (presentation.go's gone): its own words, |
| 60 | // because it is a different fact from a mistyped address. |
| 61 | key = "slides.present.over" |
| 62 | case status == http.StatusUnprocessableEntity: |
| 63 | key = "slides.error.422" |
| 64 | case status == http.StatusServiceUnavailable: |
| 65 | key = "slides.error.503" |
| 66 | } |
| 67 | return errorWords{ |
| 68 | title: rastrillo.T(r, key+".title"), |
| 69 | heading: rastrillo.T(r, key+".heading"), |
| 70 | body: rastrillo.T(r, key+".body"), |
| 71 | } |
| 72 | } |
| 73 | |
| 74 | // errorView is templates/errors.html's data. |
| 75 | type errorView struct { |
| 76 | shell |
| 77 | Error chrome.ErrorPage |
| 78 | } |
| 79 | |
| 80 | // wantsHTML reports whether the request asked for a page. |
| 81 | func wantsHTML(r *http.Request) bool { |
| 82 | return strings.Contains(r.Header.Get("Accept"), "text/html") |
| 83 | } |
| 84 | |
| 85 | // plainError is the answer to a request that did not ask for a page. |
| 86 | func plainError(w http.ResponseWriter, status int) { |
| 87 | w.Header().Set("Cache-Control", "no-store") |
| 88 | http.Error(w, http.StatusText(status), status) |
| 89 | } |
| 90 | |
| 91 | // routePattern is the matched route, "/d/{sqid}" rather than the path it |
| 92 | // matched: safe to log whatever the path held (a /s/ or /p/ path is a |
| 93 | // capability). |
| 94 | func routePattern(r *http.Request) string { |
| 95 | if rc := chi.RouteContext(r.Context()); rc != nil && rc.RoutePattern() != "" { |
| 96 | return rc.RoutePattern() |
| 97 | } |
| 98 | return "(unrouted)" |
| 99 | } |
| 100 | |
| 101 | // errorPage answers status: a page inside the normal layout with the |
| 102 | // viewer's own chrome for a browser, plain text otherwise. ref is the |
| 103 | // reference a 500 quotes, empty otherwise. |
| 104 | func (a *app) errorPage(w http.ResponseWriter, r *http.Request, status int, kind errorKind, ref string) { |
| 105 | if !wantsHTML(r) { |
| 106 | plainError(w, status) |
| 107 | return |
| 108 | } |
| 109 | writeErrorPage(w, r, status, kind, ref, a.cfg.Origin, a.publicShell(r, titled(errorCopy(r, status, kind).title))) |
| 110 | } |
| 111 | |
| 112 | // writeErrorPage renders the page with sh as its chrome. |
| 113 | func writeErrorPage(w http.ResponseWriter, r *http.Request, status int, kind errorKind, ref, origin string, sh shell) { |
| 114 | c := errorCopy(r, status, kind) |
| 115 | page := chrome.ErrorPage{ |
| 116 | Status: status, |
| 117 | Title: c.heading, |
| 118 | Body: c.body, |
| 119 | Home: chrome.Link{Href: "/", Label: rastrillo.T(r, "slides.error.home")}, |
| 120 | } |
| 121 | // Try again is the same URL, and only for a GET: re-sending a POST |
| 122 | // from an error page is the one thing it must never do. |
| 123 | if status >= 500 && r.Method == http.MethodGet { |
| 124 | page.Home = chrome.Link{Href: r.URL.RequestURI(), Label: rastrillo.T(r, "slides.error.again")} |
| 125 | } |
| 126 | if ref != "" { |
| 127 | page.Ref, page.RefLabel = ref, rastrillo.T(r, "slides.error.ref") |
| 128 | } |
| 129 | if back := sameOriginReferer(r, origin); back != "" && status < 500 { |
| 130 | page.Back = &chrome.Link{Href: back, Label: rastrillo.T(r, "slides.error.back")} |
| 131 | } |
| 132 | var buf strings.Builder |
| 133 | if err := pages["errors"].ExecuteTemplate(&buf, "layout", errorView{shell: sh, Error: page}); err != nil { |
| 134 | // The page could not render: say so in the plainest way left. |
| 135 | plainError(w, status) |
| 136 | return |
| 137 | } |
| 138 | w.Header().Set("Cache-Control", "no-store") |
| 139 | w.Header().Set("Content-Type", "text/html; charset=utf-8") |
| 140 | w.WriteHeader(status) |
| 141 | _, _ = w.Write([]byte(buf.String())) |
| 142 | } |
| 143 | |
| 144 | // sameOriginReferer is the page a person came from, when it is this app |
| 145 | // and not this page: rastrillo's rule for the error page's Back link. An |
| 146 | // unchecked Referer is an open redirect with better manners. origin is |
| 147 | // the app's own (Config.Origin); without one there is no Back link. |
| 148 | func sameOriginReferer(r *http.Request, origin string) string { |
| 149 | raw := r.Header.Get("Referer") |
| 150 | if raw == "" || origin == "" { |
| 151 | return "" |
| 152 | } |
| 153 | u, err := url.Parse(raw) |
| 154 | if err != nil || u.User != nil || u.Scheme+"://"+u.Host != origin { |
| 155 | return "" |
| 156 | } |
| 157 | back := u.EscapedPath() |
| 158 | if u.RawQuery != "" { |
| 159 | back += "?" + u.RawQuery |
| 160 | } |
| 161 | // Never a capability path (a share link, a talk, a phone's pairing |
| 162 | // code): the Back link would copy it into this page. |
| 163 | for _, p := range []string{"/s/", "/p/", "/r/"} { |
| 164 | if strings.HasPrefix(back, p) { |
| 165 | return "" |
| 166 | } |
| 167 | } |
| 168 | if !strings.HasPrefix(back, "/") || strings.HasPrefix(back, "//") || back == r.URL.RequestURI() { |
| 169 | return "" |
| 170 | } |
| 171 | return back |
| 172 | } |
| 173 | |
| 174 | // routeNotFound is the router's NotFound: an unknown address. |
| 175 | func (a *app) routeNotFound(w http.ResponseWriter, r *http.Request) { |
| 176 | a.errorPage(w, r, http.StatusNotFound, kindRoute, "") |
| 177 | } |
| 178 | |
| 179 | // methodNotAllowed is the router's MethodNotAllowed. A GET or HEAD to a |
| 180 | // path that only takes a POST (somebody opening /signout from history) |
| 181 | // is the same 404 page as an unknown route: to a person there is no |
| 182 | // page there. Any other method gets 405 in plain text, with the Allow |
| 183 | // header set here — chi's default sets it, a custom handler does not. |
| 184 | func (a *app) methodNotAllowed(routes chi.Routes) http.HandlerFunc { |
| 185 | return func(w http.ResponseWriter, r *http.Request) { |
| 186 | var allowed []string |
| 187 | for _, m := range []string{http.MethodGet, http.MethodHead, http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete} { |
| 188 | if routes.Match(chi.NewRouteContext(), m, r.URL.Path) { |
| 189 | allowed = append(allowed, m) |
| 190 | } |
| 191 | } |
| 192 | get := false |
| 193 | for _, m := range allowed { |
| 194 | get = get || m == http.MethodGet |
| 195 | } |
| 196 | if (r.Method == http.MethodGet || r.Method == http.MethodHead) && !get { |
| 197 | a.routeNotFound(w, r) |
| 198 | return |
| 199 | } |
| 200 | w.Header().Set("Allow", strings.Join(allowed, ", ")) |
| 201 | http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed) |
| 202 | } |
| 203 | } |
| 204 | |
| 205 | // recoverToErrorPage turns a panic in a handler into the 500 page with a |
| 206 | // reference, logged with the stack under the same reference. The page |
| 207 | // reads the session itself (publicShell), so it shows the viewer's own |
| 208 | // chrome wherever in the router the panic happened. Panics outside the |
| 209 | // router reach rastrillo.Serve's own recoverer through a.ErrorPage. |
| 210 | func (a *app) recoverToErrorPage(next http.Handler) http.Handler { |
| 211 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 212 | defer func() { |
| 213 | v := recover() |
| 214 | if v == nil { |
| 215 | return |
| 216 | } |
| 217 | if v == http.ErrAbortHandler { |
| 218 | panic(v) |
| 219 | } |
| 220 | ref := rastrillo.NewRef() |
| 221 | // The value is logged as its type only: a panic raised with a |
| 222 | // deck's text in it would be a bug of its own. |
| 223 | a.logger.Error("slides: panic", "ref", ref, "route", routePattern(r), "panic", fmt.Sprintf("%T", v), "stack", string(debug.Stack())) |
| 224 | a.errorPage(w, r, http.StatusInternalServerError, kindStatus, ref) |
| 225 | }() |
| 226 | next.ServeHTTP(w, r) |
| 227 | }) |
| 228 | } |
| 229 | |
| 230 | // ErrorPage is rastrillo.Options.ErrorPage for a built app: the page |
| 231 | // rastrillo.Serve's own panic recovery renders, for a panic outside the |
| 232 | // router. It reads the session optionally like every other error page. |
| 233 | func (a *app) ErrorPage(w http.ResponseWriter, r *http.Request, status int, ref string) { |
| 234 | a.errorPage(w, r, status, kindStatus, ref) |
| 235 | } |
| 236 | |
| 237 | // ErrorPage renders the error page with signed-out chrome, for a caller |
| 238 | // that has no app to resolve a viewer with. |
| 239 | func ErrorPage(w http.ResponseWriter, r *http.Request, status int, ref string) { |
| 240 | if !wantsHTML(r) { |
| 241 | plainError(w, status) |
| 242 | return |
| 243 | } |
| 244 | sh := shell{Frame: (&app{}).frameFor(r, viewerState{}, titled(errorCopy(r, status, kindStatus).title))} |
| 245 | writeErrorPage(w, r, status, kindStatus, ref, "", sh) |
| 246 | } |
| 247 | |