oficina / meet Public

Clone
git clone https://amadan.net/oficina/meet

Plain git — no account needed to clone.

Download

Download this file

1package 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
18import (
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.
31type errorKind int
32
33const (
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.
47type errorWords struct {
48 title, heading, body string
49}
50
51func 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.
66func 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.
71func 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.
81func (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'.
86func (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).
93func (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.
132func 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.
161func (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).
184func 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.
196func (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