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)
})
}