package calls // HTML error pages (shared chrome spec §3). Before this an unknown URL // answered chi's plain-text "404 page not found", with or without // Accept: text/html, and the handled error pages were titled "Something // went wrong" whatever had happened, a 404 included. // // Which answers are pages is decided by the request, not the route: a // page is rendered only when Accept names text/html, as every browser // navigation and form post does. The room's own fetches (the stream, // signal, ICE and info), the native app's JSON and the share form's JSON // answer never ask for HTML, so they keep plain answers. room.js reads // those by status (readRefusal), and the statuses are unchanged. // // A meeting that is missing, ended, or not yours is the same 404 // (AGENTS.md: a row that isn't yours 404s, never 403s). import ( "fmt" "net/http" "net/url" "runtime/debug" "strings" "amadan.net/oficina/estilo.git/chrome" "amadan.net/rastrillo/rastrillo" "github.com/go-chi/chi/v5" ) // errorKind says which row of spec §3.2 a failure is. type errorKind int const ( // kindRoute: nothing here for you — an unknown address, or a page // this session may not open (a guest outside /m/, a non-member on // Settings). Both are "we can't find that page": which of the two it // is must not be readable from outside. kindRoute errorKind = iota // kindMeeting: the meeting (or its invitation, or a peer in it) is // missing, ended, or not shared with you. kindMeeting // kindStatus: the page for the status itself (5xx). kindStatus ) // errorWords is one row of §3.2: the title part, the heading, the body. type errorWords struct { title, heading, body string } func errorCopy(status int, kind errorKind) errorWords { switch { case status == http.StatusNotFound && kind == kindMeeting: return errorWords{"Meeting not available", "This meeting isn’t available", "It may have ended, or it hasn’t been shared with you."} case status == http.StatusNotFound: return errorWords{"Page not found", "We can’t find that page", "The link may be out of date, or the page may have moved."} case status == http.StatusForbidden: return errorWords{"No access", "You don’t have access to this page", "Ask whoever runs this team if you think you should."} case status == http.StatusServiceUnavailable: return errorWords{"Calls is unavailable", "Calls is unavailable right now", "Try again in a few minutes."} } return errorWords{"Something went wrong", "Something went wrong", "Try again in a moment. If it keeps happening, quote the reference below."} } // wantsHTML reports whether the request asked for a page. func wantsHTML(r *http.Request) bool { return strings.Contains(r.Header.Get("Accept"), "text/html") } // plainError is the answer to a request that did not ask for a page. func plainError(w http.ResponseWriter, status int) { w.Header().Set("Cache-Control", "no-store") http.Error(w, http.StatusText(status), status) } // ErrorPage answers status for a handler: a meeting-shaped 404 (almost // every handled 404 in Calls is about a meeting or its invitation), the // 5xx page with the reference rastrillo minted for the log line, or // plain text for a request that did not ask for a page. It is also // rastrillo.Options.ErrorPage, for panics outside the router. func (a *app) ErrorPage(w http.ResponseWriter, r *http.Request, status int, ref string) { a.errorPage(w, r, status, kindMeeting, ref) } // notFound is the route-shaped 404: the router's own, and the guards'. func (a *app) notFound(w http.ResponseWriter, r *http.Request) { a.errorPage(w, r, http.StatusNotFound, kindRoute, "") } // errorPage renders the page inside the normal layout, with the // viewer's own chrome (read optionally: an error page never signs // anybody out or creates anything). func (a *app) errorPage(w http.ResponseWriter, r *http.Request, status int, kind errorKind, ref string) { if !wantsHTML(r) { plainError(w, status) return } c := errorCopy(status, kind) page := chrome.ErrorPage{ Status: status, Title: c.heading, Body: c.body, Home: chrome.Link{Href: "/", Label: "Go to Calls"}, } // Try again is the same URL, and only for a GET: re-sending a POST // from an error page is the one thing it must never do. if status >= 500 && r.Method == http.MethodGet { page.Home = chrome.Link{Href: r.URL.RequestURI(), Label: "Try again"} } if ref != "" { page.Ref, page.RefLabel = ref, "Reference:" } if back := sameOriginReferer(r, a.cfg.Origin); back != "" && status < 500 { page.Back = &chrome.Link{Href: back, Label: "Back"} } var buf strings.Builder data := a.page(r, titled(c.title), map[string]any{"Error": page}) if err := pages["errors"].ExecuteTemplate(&buf, "layout", data); err != nil { a.logger.Error("calls: render error page", "err", err) plainError(w, status) return } w.Header().Set("Cache-Control", "no-store") w.Header().Set("Content-Type", "text/html; charset=utf-8") w.WriteHeader(status) _, _ = w.Write([]byte(buf.String())) } // sameOriginReferer is the page a person came from, when it is this app // and not this page: rastrillo's rule for the error page's Back link. An // unchecked Referer is an open redirect with better manners. func sameOriginReferer(r *http.Request, origin string) string { raw := r.Header.Get("Referer") if raw == "" || origin == "" { return "" } u, err := url.Parse(raw) if err != nil || u.User != nil || u.Scheme+"://"+u.Host != origin { return "" } back := u.EscapedPath() if u.RawQuery != "" { back += "?" + u.RawQuery } // Never an invitation path: the Back link would copy the capability // into this page. if strings.HasPrefix(back, "/j/") { return "" } if !strings.HasPrefix(back, "/") || strings.HasPrefix(back, "//") || back == r.URL.RequestURI() { return "" } return back } // methodNotAllowed is the router's MethodNotAllowed. A GET or HEAD to a // path that only takes a POST (somebody opening /signout from history) // is the same 404 page as an unknown route: to a person there is no // page there. Any other method gets 405 in plain text, with the Allow // header set here — chi's default sets it, a custom handler does not. func (a *app) methodNotAllowed(routes chi.Routes) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { var allowed []string for _, m := range []string{http.MethodGet, http.MethodHead, http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete} { if routes.Match(chi.NewRouteContext(), m, r.URL.Path) { allowed = append(allowed, m) } } get := false for _, m := range allowed { get = get || m == http.MethodGet } if (r.Method == http.MethodGet || r.Method == http.MethodHead) && !get { a.notFound(w, r) return } w.Header().Set("Allow", strings.Join(allowed, ", ")) http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed) } } // routePattern is the matched route, "/m/{sqid}" rather than the path it // matched: safe to log whatever the path held (/j/ is a capability). func routePattern(r *http.Request) string { if rc := chi.RouteContext(r.Context()); rc != nil && rc.RoutePattern() != "" { return rc.RoutePattern() } return "(unrouted)" } // recoverToErrorPage turns a panic in a handler into the 500 page with a // reference, logged with the stack under the same reference. The page // reads the session itself, so it shows the viewer's own chrome // wherever in the router the panic happened. Panics outside the router // reach rastrillo.Serve's own recoverer through Options.ErrorPage. func (a *app) recoverToErrorPage(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { defer func() { v := recover() if v == nil { return } if v == http.ErrAbortHandler { panic(v) } ref := rastrillo.NewRef() // The value is logged as its type only: a panic raised with a // meeting title in it would be a leak of its own. a.logger.Error("calls: panic", "ref", ref, "route", routePattern(r), "panic", fmt.Sprintf("%T", v), "stack", string(debug.Stack())) a.errorPage(w, r, http.StatusInternalServerError, kindStatus, ref) }() next.ServeHTTP(w, r) }) }