| 1 | package calls |
| 2 | |
| 3 | // HTML error pages (shared chrome spec §3). Before this an unknown URL |
| 4 | // answered chi's plain-text "404 page not found", with or without |
| 5 | // Accept: text/html, and the handled error pages were titled "Something |
| 6 | // went wrong" whatever had happened, a 404 included. |
| 7 | // |
| 8 | // Which answers are pages is decided by the request, not the route: a |
| 9 | // page is rendered only when Accept names text/html, as every browser |
| 10 | // navigation and form post does. The room's own fetches (the stream, |
| 11 | // signal, ICE and info), the native app's JSON and the share form's JSON |
| 12 | // answer never ask for HTML, so they keep plain answers. room.js reads |
| 13 | // those by status (readRefusal), and the statuses are unchanged. |
| 14 | // |
| 15 | // A meeting that is missing, ended, or not yours is the same 404 |
| 16 | // (AGENTS.md: a row that isn't yours 404s, never 403s). |
| 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 here for you — an unknown address, or a page |
| 35 | // this session may not open (a guest outside /m/, a non-member on |
| 36 | // Settings). Both are "we can't find that page": which of the two it |
| 37 | // is must not be readable from outside. |
| 38 | kindRoute errorKind = iota |
| 39 | // kindMeeting: the meeting (or its invitation, or a peer in it) is |
| 40 | // missing, ended, or not shared with you. |
| 41 | kindMeeting |
| 42 | // kindStatus: the page for the status itself (5xx). |
| 43 | kindStatus |
| 44 | ) |
| 45 | |
| 46 | // errorWords is one row of §3.2: the title part, the heading, the body. |
| 47 | type errorWords struct { |
| 48 | title, heading, body string |
| 49 | } |
| 50 | |
| 51 | func errorCopy(status int, kind errorKind) errorWords { |
| 52 | switch { |
| 53 | case status == http.StatusNotFound && kind == kindMeeting: |
| 54 | return errorWords{"Meeting not available", "This meeting isn’t available", "It may have ended, or it hasn’t been shared with you."} |
| 55 | case status == http.StatusNotFound: |
| 56 | return errorWords{"Page not found", "We can’t find that page", "The link may be out of date, or the page may have moved."} |
| 57 | case status == http.StatusForbidden: |
| 58 | return errorWords{"No access", "You don’t have access to this page", "Ask whoever runs this team if you think you should."} |
| 59 | case status == http.StatusServiceUnavailable: |
| 60 | return errorWords{"Calls is unavailable", "Calls 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 | // wantsHTML reports whether the request asked for a page. |
| 66 | func wantsHTML(r *http.Request) bool { |
| 67 | return strings.Contains(r.Header.Get("Accept"), "text/html") |
| 68 | } |
| 69 | |
| 70 | // plainError is the answer to a request that did not ask for a page. |
| 71 | func plainError(w http.ResponseWriter, status int) { |
| 72 | w.Header().Set("Cache-Control", "no-store") |
| 73 | http.Error(w, http.StatusText(status), status) |
| 74 | } |
| 75 | |
| 76 | // ErrorPage answers status for a handler: a meeting-shaped 404 (almost |
| 77 | // every handled 404 in Calls is about a meeting or its invitation), the |
| 78 | // 5xx page with the reference rastrillo minted for the log line, or |
| 79 | // plain text for a request that did not ask for a page. It is also |
| 80 | // rastrillo.Options.ErrorPage, for panics outside the router. |
| 81 | func (a *app) ErrorPage(w http.ResponseWriter, r *http.Request, status int, ref string) { |
| 82 | a.errorPage(w, r, status, kindMeeting, ref) |
| 83 | } |
| 84 | |
| 85 | // notFound is the route-shaped 404: the router's own, and the guards'. |
| 86 | func (a *app) notFound(w http.ResponseWriter, r *http.Request) { |
| 87 | a.errorPage(w, r, http.StatusNotFound, kindRoute, "") |
| 88 | } |
| 89 | |
| 90 | // errorPage renders the page inside the normal layout, with the |
| 91 | // viewer's own chrome (read optionally: an error page never signs |
| 92 | // anybody out or creates anything). |
| 93 | func (a *app) errorPage(w http.ResponseWriter, r *http.Request, status int, kind errorKind, ref string) { |
| 94 | if !wantsHTML(r) { |
| 95 | plainError(w, status) |
| 96 | return |
| 97 | } |
| 98 | c := errorCopy(status, kind) |
| 99 | page := chrome.ErrorPage{ |
| 100 | Status: status, |
| 101 | Title: c.heading, |
| 102 | Body: c.body, |
| 103 | Home: chrome.Link{Href: "/", Label: "Go to Calls"}, |
| 104 | } |
| 105 | // Try again is the same URL, and only for a GET: re-sending a POST |
| 106 | // from an error page is the one thing it must never do. |
| 107 | if status >= 500 && r.Method == http.MethodGet { |
| 108 | page.Home = chrome.Link{Href: r.URL.RequestURI(), Label: "Try again"} |
| 109 | } |
| 110 | if ref != "" { |
| 111 | page.Ref, page.RefLabel = ref, "Reference:" |
| 112 | } |
| 113 | if back := sameOriginReferer(r, a.cfg.Origin); back != "" && status < 500 { |
| 114 | page.Back = &chrome.Link{Href: back, Label: "Back"} |
| 115 | } |
| 116 | var buf strings.Builder |
| 117 | data := a.page(r, titled(c.title), map[string]any{"Error": page}) |
| 118 | if err := pages["errors"].ExecuteTemplate(&buf, "layout", data); err != nil { |
| 119 | a.logger.Error("calls: render error page", "err", err) |
| 120 | plainError(w, status) |
| 121 | return |
| 122 | } |
| 123 | w.Header().Set("Cache-Control", "no-store") |
| 124 | w.Header().Set("Content-Type", "text/html; charset=utf-8") |
| 125 | w.WriteHeader(status) |
| 126 | _, _ = w.Write([]byte(buf.String())) |
| 127 | } |
| 128 | |
| 129 | // sameOriginReferer is the page a person came from, when it is this app |
| 130 | // and not this page: rastrillo's rule for the error page's Back link. An |
| 131 | // unchecked Referer is an open redirect with better manners. |
| 132 | func sameOriginReferer(r *http.Request, origin string) string { |
| 133 | raw := r.Header.Get("Referer") |
| 134 | if raw == "" || origin == "" { |
| 135 | return "" |
| 136 | } |
| 137 | u, err := url.Parse(raw) |
| 138 | if err != nil || u.User != nil || u.Scheme+"://"+u.Host != origin { |
| 139 | return "" |
| 140 | } |
| 141 | back := u.EscapedPath() |
| 142 | if u.RawQuery != "" { |
| 143 | back += "?" + u.RawQuery |
| 144 | } |
| 145 | // Never an invitation path: the Back link would copy the capability |
| 146 | // into this page. |
| 147 | if strings.HasPrefix(back, "/j/") { |
| 148 | return "" |
| 149 | } |
| 150 | if !strings.HasPrefix(back, "/") || strings.HasPrefix(back, "//") || back == r.URL.RequestURI() { |
| 151 | return "" |
| 152 | } |
| 153 | return back |
| 154 | } |
| 155 | |
| 156 | // methodNotAllowed is the router's MethodNotAllowed. A GET or HEAD to a |
| 157 | // path that only takes a POST (somebody opening /signout from history) |
| 158 | // is the same 404 page as an unknown route: to a person there is no |
| 159 | // page there. Any other method gets 405 in plain text, with the Allow |
| 160 | // header set here — chi's default sets it, a custom handler does not. |
| 161 | func (a *app) methodNotAllowed(routes chi.Routes) http.HandlerFunc { |
| 162 | return func(w http.ResponseWriter, r *http.Request) { |
| 163 | var allowed []string |
| 164 | for _, m := range []string{http.MethodGet, http.MethodHead, http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete} { |
| 165 | if routes.Match(chi.NewRouteContext(), m, r.URL.Path) { |
| 166 | allowed = append(allowed, m) |
| 167 | } |
| 168 | } |
| 169 | get := false |
| 170 | for _, m := range allowed { |
| 171 | get = get || m == http.MethodGet |
| 172 | } |
| 173 | if (r.Method == http.MethodGet || r.Method == http.MethodHead) && !get { |
| 174 | a.notFound(w, r) |
| 175 | return |
| 176 | } |
| 177 | w.Header().Set("Allow", strings.Join(allowed, ", ")) |
| 178 | http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed) |
| 179 | } |
| 180 | } |
| 181 | |
| 182 | // routePattern is the matched route, "/m/{sqid}" rather than the path it |
| 183 | // matched: safe to log whatever the path held (/j/ is a capability). |
| 184 | func routePattern(r *http.Request) string { |
| 185 | if rc := chi.RouteContext(r.Context()); rc != nil && rc.RoutePattern() != "" { |
| 186 | return rc.RoutePattern() |
| 187 | } |
| 188 | return "(unrouted)" |
| 189 | } |
| 190 | |
| 191 | // recoverToErrorPage turns a panic in a handler into the 500 page with a |
| 192 | // reference, logged with the stack under the same reference. The page |
| 193 | // reads the session itself, so it shows the viewer's own chrome |
| 194 | // wherever in the router the panic happened. Panics outside the router |
| 195 | // reach rastrillo.Serve's own recoverer through Options.ErrorPage. |
| 196 | func (a *app) recoverToErrorPage(next http.Handler) http.Handler { |
| 197 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 198 | defer func() { |
| 199 | v := recover() |
| 200 | if v == nil { |
| 201 | return |
| 202 | } |
| 203 | if v == http.ErrAbortHandler { |
| 204 | panic(v) |
| 205 | } |
| 206 | ref := rastrillo.NewRef() |
| 207 | // The value is logged as its type only: a panic raised with a |
| 208 | // meeting title in it would be a leak of its own. |
| 209 | a.logger.Error("calls: 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 | |