| 1 | package sheets |
| 2 | |
| 3 | // HTML error pages (spec §3). Before this every failure a browser could |
| 4 | // reach was http.Error's plain text in the browser's monospace font, with |
| 5 | // no chrome, no <title> and no way back: "Not found." for a stale link or |
| 6 | // a revoked share, and chi's own "404 page not found" for an unknown |
| 7 | // route. |
| 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. a.notFound is the case the spec names |
| 12 | // (§3.3): one helper reached from the grid's page and from its /api |
| 13 | // fetches through sheetFromURL, so the split has to happen inside it. |
| 14 | // The grid's fetches, the live stream and the native apps never ask for |
| 15 | // HTML, so they keep the plain answers they have always had. |
| 16 | // |
| 17 | // A missing sheet and one that is not shared with you are the same 404 |
| 18 | // (AGENTS.md): nothing here distinguishes them, so nothing is enumerable. |
| 19 | |
| 20 | import ( |
| 21 | "errors" |
| 22 | "fmt" |
| 23 | "log/slog" |
| 24 | "net/http" |
| 25 | "net/url" |
| 26 | "runtime/debug" |
| 27 | "strings" |
| 28 | |
| 29 | "amadan.net/oficina/estilo.git/chrome" |
| 30 | "amadan.net/rastrillo/rastrillo" |
| 31 | "github.com/go-chi/chi/v5" |
| 32 | ) |
| 33 | |
| 34 | var errNoTemplate = errors.New("sheets: no such page template") |
| 35 | |
| 36 | // errorCopy is one row of the spec's §3.2 table. |
| 37 | type errorCopy struct { |
| 38 | title, heading, body string |
| 39 | } |
| 40 | |
| 41 | // errorKind says which row a failure is. |
| 42 | type errorKind int |
| 43 | |
| 44 | const ( |
| 45 | // kindRoute: nothing lives at this address. |
| 46 | kindRoute errorKind = iota |
| 47 | // kindSheet: the sheet (or link) is missing, or not shared with you. |
| 48 | kindSheet |
| 49 | // kindStatus: the page for the status itself (400, 403, 413, 5xx). |
| 50 | kindStatus |
| 51 | ) |
| 52 | |
| 53 | func copyFor(status int, kind errorKind) errorCopy { |
| 54 | switch { |
| 55 | case status == http.StatusNotFound && kind == kindSheet: |
| 56 | return errorCopy{"Sheet not available", "This sheet isn’t available", "It may have been deleted, or it hasn’t been shared with you."} |
| 57 | case status == http.StatusNotFound: |
| 58 | return errorCopy{"Page not found", "We can’t find that page", "The link may be out of date, or the page may have moved."} |
| 59 | case status == http.StatusForbidden: |
| 60 | return errorCopy{"No access", "You don’t have access to this page", "Ask whoever runs this team if you think you should."} |
| 61 | case status == http.StatusRequestEntityTooLarge: |
| 62 | return errorCopy{"Too long", "That’s too long", "Go back, make it shorter and try again."} |
| 63 | case status == http.StatusBadRequest: |
| 64 | return errorCopy{"That didn’t work", "That didn’t work", "Go back and try again."} |
| 65 | case status == http.StatusServiceUnavailable: |
| 66 | return errorCopy{appName + " is unavailable", appName + " is unavailable right now", "Try again in a few minutes."} |
| 67 | } |
| 68 | return errorCopy{"Something went wrong", "Something went wrong", "Try again in a moment. If it keeps happening, quote the reference below."} |
| 69 | } |
| 70 | |
| 71 | type errorPageView struct { |
| 72 | Error chrome.ErrorPage |
| 73 | } |
| 74 | |
| 75 | // wantsHTML reports whether the request asked for a page. |
| 76 | func wantsHTML(r *http.Request) bool { |
| 77 | return strings.Contains(r.Header.Get("Accept"), "text/html") |
| 78 | } |
| 79 | |
| 80 | // notFound answers 404 with nothing that could name a sheet: the "sheet |
| 81 | // not available" page for a browser, and the plain "Not found." it has |
| 82 | // always been for the grid's fetches and the native apps. |
| 83 | func (a *App) notFound(w http.ResponseWriter, r *http.Request) { |
| 84 | a.answer(w, r, http.StatusNotFound, kindSheet, "Not found.") |
| 85 | } |
| 86 | |
| 87 | // routeNotFound is the router's NotFound: an unknown address. |
| 88 | func (a *App) routeNotFound(w http.ResponseWriter, r *http.Request) { |
| 89 | a.answer(w, r, http.StatusNotFound, kindRoute, "Not found.") |
| 90 | } |
| 91 | |
| 92 | // forbidden answers a request that needed an actor and had none. Behind |
| 93 | // requireSession it is unreachable; it is what a handler says if that |
| 94 | // guard is ever not in front of it. |
| 95 | func (a *App) forbidden(w http.ResponseWriter, r *http.Request) { |
| 96 | a.answer(w, r, http.StatusForbidden, kindStatus, "Sign in first.") |
| 97 | } |
| 98 | |
| 99 | // badRequest answers a form that could not be read, or asked for |
| 100 | // something the route cannot do. text is the plain answer a script or a |
| 101 | // native client has always read. |
| 102 | func (a *App) badRequest(w http.ResponseWriter, r *http.Request, status int, text string) { |
| 103 | a.answer(w, r, status, kindStatus, text) |
| 104 | } |
| 105 | |
| 106 | // fail answers 500. |
| 107 | // |
| 108 | // The error is deliberately dropped rather than shown or logged: it can |
| 109 | // carry a sealed title's plaintext, a cell's contents, or a SQL |
| 110 | // statement quoting either, and `carlos logs` is platform-held storage |
| 111 | // we cannot grep or redact (AGENTS.md / Mechanical rules). The page |
| 112 | // quotes a reference, and the log line carries that reference and the |
| 113 | // route pattern, never the path: a /l/ path is a capability. |
| 114 | func (a *App) fail(w http.ResponseWriter, r *http.Request, _ error) { |
| 115 | if !wantsHTML(r) { |
| 116 | http.Error(w, "Something went wrong.", http.StatusInternalServerError) |
| 117 | return |
| 118 | } |
| 119 | ref := rastrillo.NewRef() |
| 120 | slog.Error("sheets: error page", "ref", ref, "route", routePattern(r)) |
| 121 | a.errorPage(w, r, http.StatusInternalServerError, kindStatus, ref) |
| 122 | } |
| 123 | |
| 124 | func (a *App) answer(w http.ResponseWriter, r *http.Request, status int, kind errorKind, text string) { |
| 125 | if !wantsHTML(r) { |
| 126 | http.Error(w, text, status) |
| 127 | return |
| 128 | } |
| 129 | a.errorPage(w, r, status, kind, "") |
| 130 | } |
| 131 | |
| 132 | // routePattern is the matched route, "/sheets/{sheetID}" rather than the |
| 133 | // path it matched: safe to log whatever the path held. |
| 134 | func routePattern(r *http.Request) string { |
| 135 | if rc := chi.RouteContext(r.Context()); rc != nil && rc.RoutePattern() != "" { |
| 136 | return rc.RoutePattern() |
| 137 | } |
| 138 | return "(unrouted)" |
| 139 | } |
| 140 | |
| 141 | // errorPage renders the page for status inside the normal layout, with |
| 142 | // whatever chrome the request's session gives it. ref is the reference a |
| 143 | // 500 quotes, empty otherwise. |
| 144 | func (a *App) errorPage(w http.ResponseWriter, r *http.Request, status int, kind errorKind, ref string) { |
| 145 | c := copyFor(status, kind) |
| 146 | page := chrome.ErrorPage{ |
| 147 | Status: status, |
| 148 | Title: c.heading, |
| 149 | Body: c.body, |
| 150 | Home: chrome.Link{Href: "/", Label: "Go to " + appName}, |
| 151 | } |
| 152 | // Try again is the same URL, and only for a GET: re-sending a POST |
| 153 | // from an error page is the one thing it must never do. |
| 154 | if status >= 500 && r.Method == http.MethodGet { |
| 155 | page.Home = chrome.Link{Href: r.URL.RequestURI(), Label: "Try again"} |
| 156 | } |
| 157 | if ref != "" { |
| 158 | page.Ref, page.RefLabel = ref, "Reference:" |
| 159 | } |
| 160 | if back := a.sameOriginReferer(r); back != "" && status < 500 { |
| 161 | page.Back = &chrome.Link{Href: back, Label: "Back"} |
| 162 | } |
| 163 | t, ok := pages["error"] |
| 164 | var buf strings.Builder |
| 165 | if ok { |
| 166 | err := t.ExecuteTemplate(&buf, "layout", PageData{Frame: a.frameFor(r, titled(c.title)), Data: errorPageView{Error: page}}) |
| 167 | ok = err == nil |
| 168 | } |
| 169 | w.Header().Set("Cache-Control", "no-store") |
| 170 | if !ok { |
| 171 | // The page could not render: say so in the plainest way left. |
| 172 | http.Error(w, "Something went wrong.", status) |
| 173 | return |
| 174 | } |
| 175 | pageHeaders(w) |
| 176 | w.Header().Set("Content-Type", "text/html; charset=utf-8") |
| 177 | w.WriteHeader(status) |
| 178 | _, _ = w.Write([]byte(buf.String())) |
| 179 | } |
| 180 | |
| 181 | // sameOriginReferer is the page a person came from, when it is this app |
| 182 | // and not this page: rastrillo's rule for the error page's Back link. An |
| 183 | // unchecked Referer is an open redirect with better manners. |
| 184 | func (a *App) sameOriginReferer(r *http.Request) string { |
| 185 | raw := r.Header.Get("Referer") |
| 186 | if raw == "" || a.origin == "" { |
| 187 | return "" |
| 188 | } |
| 189 | u, err := url.Parse(raw) |
| 190 | if err != nil || u.User != nil || u.Scheme+"://"+u.Host != a.origin { |
| 191 | return "" |
| 192 | } |
| 193 | back := u.EscapedPath() |
| 194 | if u.RawQuery != "" { |
| 195 | back += "?" + u.RawQuery |
| 196 | } |
| 197 | if !strings.HasPrefix(back, "/") || strings.HasPrefix(back, "//") || back == r.URL.RequestURI() || strings.HasPrefix(back, "/l/") { |
| 198 | return "" |
| 199 | } |
| 200 | return back |
| 201 | } |
| 202 | |
| 203 | // methodNotAllowed is the router's MethodNotAllowed. A GET or HEAD to a |
| 204 | // path that only takes a POST (somebody opening /signout from history) is |
| 205 | // the same 404 page as an unknown route: to a person there is no page |
| 206 | // there. Any other method gets 405 in plain text, with the Allow header |
| 207 | // set here — chi's default sets it, a custom handler does not. |
| 208 | func (a *App) methodNotAllowed(routes chi.Routes) http.HandlerFunc { |
| 209 | return func(w http.ResponseWriter, r *http.Request) { |
| 210 | var allowed []string |
| 211 | for _, m := range []string{http.MethodGet, http.MethodHead, http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete} { |
| 212 | if routes.Match(chi.NewRouteContext(), m, r.URL.Path) { |
| 213 | allowed = append(allowed, m) |
| 214 | } |
| 215 | } |
| 216 | get := false |
| 217 | for _, m := range allowed { |
| 218 | get = get || m == http.MethodGet |
| 219 | } |
| 220 | if (r.Method == http.MethodGet || r.Method == http.MethodHead) && !get { |
| 221 | a.routeNotFound(w, r) |
| 222 | return |
| 223 | } |
| 224 | w.Header().Set("Allow", strings.Join(allowed, ", ")) |
| 225 | http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed) |
| 226 | } |
| 227 | } |
| 228 | |
| 229 | // recoverToErrorPage turns a panic in a handler into the 500 page with a |
| 230 | // reference, logged with the stack under the same reference. |
| 231 | // |
| 232 | // It is mounted INSIDE the signed-in group, after the session guard, so |
| 233 | // the page carries the viewer's own chrome: mounted outside, every 500 |
| 234 | // would look signed out, which is Calendar's bug (spec §3.3). Panics |
| 235 | // outside it reach rastrillo.Serve's own recoverer through |
| 236 | // App.ErrorPage. |
| 237 | func (a *App) recoverToErrorPage(next http.Handler) http.Handler { |
| 238 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 239 | defer func() { |
| 240 | v := recover() |
| 241 | if v == nil { |
| 242 | return |
| 243 | } |
| 244 | if v == http.ErrAbortHandler { |
| 245 | panic(v) |
| 246 | } |
| 247 | ref := rastrillo.NewRef() |
| 248 | // The panic value and stack name code, not content: a panic |
| 249 | // raised with a cell's text in it would be a bug in its own |
| 250 | // right, and the value is logged as the fmt of its type only. |
| 251 | slog.Error("sheets: panic", "ref", ref, "route", routePattern(r), "panic", fmt.Sprintf("%T", v), "stack", string(debug.Stack())) |
| 252 | if !wantsHTML(r) { |
| 253 | http.Error(w, "Something went wrong.", http.StatusInternalServerError) |
| 254 | return |
| 255 | } |
| 256 | a.errorPage(w, r, http.StatusInternalServerError, kindStatus, ref) |
| 257 | }() |
| 258 | next.ServeHTTP(w, r) |
| 259 | }) |
| 260 | } |
| 261 | |
| 262 | // ErrorPage is rastrillo.Options.ErrorPage: the page rastrillo.Serve's |
| 263 | // own panic recovery renders, for a panic outside the signed-in group. |
| 264 | // It reads the session optionally like every other error page. |
| 265 | func (a *App) ErrorPage(w http.ResponseWriter, r *http.Request, status int, ref string) { |
| 266 | if !wantsHTML(r) { |
| 267 | http.Error(w, "Something went wrong.", status) |
| 268 | return |
| 269 | } |
| 270 | a.errorPage(w, r, status, kindStatus, ref) |
| 271 | } |
| 272 | |