oficina / slides Public

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

Plain git — no account needed to clone.

Download

Download this file

1package slides
2
3// HTML error pages (shared chrome spec §3). Before this an unknown URL
4// answered chi's plain-text "404 page not found" in monospace, with no
5// chrome and an empty <title> (polish audit, "prototype"), and the
6// handled error pages always showed signed-out chrome, whoever was
7// signed in.
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. The editor's fetches (slide HTML,
12// updates, presence), the SSE streams and every <img> of a thumbnail or
13// an asset never ask for HTML, so they keep plain answers.
14//
15// A deck that is missing and one that is not shared with you are the
16// same 404 (visibleDecks): nothing here distinguishes them.
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 lives at this address.
35 kindRoute errorKind = iota
36 // kindDeck: the deck (or share link, or talk) is missing, or not
37 // shared with you.
38 kindDeck
39 // kindStatus: the page for the status itself (403, 410, 422, 5xx).
40 kindStatus
41)
42
43// errorWords is one row of §3.2, resolved from the catalog: the title
44// part, the heading and the body.
45type errorWords struct {
46 title, heading, body string
47}
48
49func errorCopy(r *http.Request, status int, kind errorKind) errorWords {
50 key := "slides.error.500"
51 switch {
52 case status == http.StatusNotFound && kind == kindDeck:
53 key = "slides.error.deck"
54 case status == http.StatusNotFound:
55 key = "slides.error.404"
56 case status == http.StatusForbidden:
57 key = "slides.error.403"
58 case status == http.StatusGone:
59 // A talk that finished (presentation.go's gone): its own words,
60 // because it is a different fact from a mistyped address.
61 key = "slides.present.over"
62 case status == http.StatusUnprocessableEntity:
63 key = "slides.error.422"
64 case status == http.StatusServiceUnavailable:
65 key = "slides.error.503"
66 }
67 return errorWords{
68 title: rastrillo.T(r, key+".title"),
69 heading: rastrillo.T(r, key+".heading"),
70 body: rastrillo.T(r, key+".body"),
71 }
72}
73
74// errorView is templates/errors.html's data.
75type errorView struct {
76 shell
77 Error chrome.ErrorPage
78}
79
80// wantsHTML reports whether the request asked for a page.
81func wantsHTML(r *http.Request) bool {
82 return strings.Contains(r.Header.Get("Accept"), "text/html")
83}
84
85// plainError is the answer to a request that did not ask for a page.
86func plainError(w http.ResponseWriter, status int) {
87 w.Header().Set("Cache-Control", "no-store")
88 http.Error(w, http.StatusText(status), status)
89}
90
91// routePattern is the matched route, "/d/{sqid}" rather than the path it
92// matched: safe to log whatever the path held (a /s/ or /p/ path is a
93// capability).
94func routePattern(r *http.Request) string {
95 if rc := chi.RouteContext(r.Context()); rc != nil && rc.RoutePattern() != "" {
96 return rc.RoutePattern()
97 }
98 return "(unrouted)"
99}
100
101// errorPage answers status: a page inside the normal layout with the
102// viewer's own chrome for a browser, plain text otherwise. ref is the
103// reference a 500 quotes, empty otherwise.
104func (a *app) errorPage(w http.ResponseWriter, r *http.Request, status int, kind errorKind, ref string) {
105 if !wantsHTML(r) {
106 plainError(w, status)
107 return
108 }
109 writeErrorPage(w, r, status, kind, ref, a.cfg.Origin, a.publicShell(r, titled(errorCopy(r, status, kind).title)))
110}
111
112// writeErrorPage renders the page with sh as its chrome.
113func writeErrorPage(w http.ResponseWriter, r *http.Request, status int, kind errorKind, ref, origin string, sh shell) {
114 c := errorCopy(r, status, kind)
115 page := chrome.ErrorPage{
116 Status: status,
117 Title: c.heading,
118 Body: c.body,
119 Home: chrome.Link{Href: "/", Label: rastrillo.T(r, "slides.error.home")},
120 }
121 // Try again is the same URL, and only for a GET: re-sending a POST
122 // from an error page is the one thing it must never do.
123 if status >= 500 && r.Method == http.MethodGet {
124 page.Home = chrome.Link{Href: r.URL.RequestURI(), Label: rastrillo.T(r, "slides.error.again")}
125 }
126 if ref != "" {
127 page.Ref, page.RefLabel = ref, rastrillo.T(r, "slides.error.ref")
128 }
129 if back := sameOriginReferer(r, origin); back != "" && status < 500 {
130 page.Back = &chrome.Link{Href: back, Label: rastrillo.T(r, "slides.error.back")}
131 }
132 var buf strings.Builder
133 if err := pages["errors"].ExecuteTemplate(&buf, "layout", errorView{shell: sh, Error: page}); err != nil {
134 // The page could not render: say so in the plainest way left.
135 plainError(w, status)
136 return
137 }
138 w.Header().Set("Cache-Control", "no-store")
139 w.Header().Set("Content-Type", "text/html; charset=utf-8")
140 w.WriteHeader(status)
141 _, _ = w.Write([]byte(buf.String()))
142}
143
144// sameOriginReferer is the page a person came from, when it is this app
145// and not this page: rastrillo's rule for the error page's Back link. An
146// unchecked Referer is an open redirect with better manners. origin is
147// the app's own (Config.Origin); without one there is no Back link.
148func sameOriginReferer(r *http.Request, origin string) string {
149 raw := r.Header.Get("Referer")
150 if raw == "" || origin == "" {
151 return ""
152 }
153 u, err := url.Parse(raw)
154 if err != nil || u.User != nil || u.Scheme+"://"+u.Host != origin {
155 return ""
156 }
157 back := u.EscapedPath()
158 if u.RawQuery != "" {
159 back += "?" + u.RawQuery
160 }
161 // Never a capability path (a share link, a talk, a phone's pairing
162 // code): the Back link would copy it into this page.
163 for _, p := range []string{"/s/", "/p/", "/r/"} {
164 if strings.HasPrefix(back, p) {
165 return ""
166 }
167 }
168 if !strings.HasPrefix(back, "/") || strings.HasPrefix(back, "//") || back == r.URL.RequestURI() {
169 return ""
170 }
171 return back
172}
173
174// routeNotFound is the router's NotFound: an unknown address.
175func (a *app) routeNotFound(w http.ResponseWriter, r *http.Request) {
176 a.errorPage(w, r, http.StatusNotFound, kindRoute, "")
177}
178
179// methodNotAllowed is the router's MethodNotAllowed. A GET or HEAD to a
180// path that only takes a POST (somebody opening /signout from history)
181// is the same 404 page as an unknown route: to a person there is no
182// page there. Any other method gets 405 in plain text, with the Allow
183// header set here — chi's default sets it, a custom handler does not.
184func (a *app) methodNotAllowed(routes chi.Routes) http.HandlerFunc {
185 return func(w http.ResponseWriter, r *http.Request) {
186 var allowed []string
187 for _, m := range []string{http.MethodGet, http.MethodHead, http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete} {
188 if routes.Match(chi.NewRouteContext(), m, r.URL.Path) {
189 allowed = append(allowed, m)
190 }
191 }
192 get := false
193 for _, m := range allowed {
194 get = get || m == http.MethodGet
195 }
196 if (r.Method == http.MethodGet || r.Method == http.MethodHead) && !get {
197 a.routeNotFound(w, r)
198 return
199 }
200 w.Header().Set("Allow", strings.Join(allowed, ", "))
201 http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed)
202 }
203}
204
205// recoverToErrorPage turns a panic in a handler into the 500 page with a
206// reference, logged with the stack under the same reference. The page
207// reads the session itself (publicShell), so it shows the viewer's own
208// chrome wherever in the router the panic happened. Panics outside the
209// router reach rastrillo.Serve's own recoverer through a.ErrorPage.
210func (a *app) recoverToErrorPage(next http.Handler) http.Handler {
211 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
212 defer func() {
213 v := recover()
214 if v == nil {
215 return
216 }
217 if v == http.ErrAbortHandler {
218 panic(v)
219 }
220 ref := rastrillo.NewRef()
221 // The value is logged as its type only: a panic raised with a
222 // deck's text in it would be a bug of its own.
223 a.logger.Error("slides: panic", "ref", ref, "route", routePattern(r), "panic", fmt.Sprintf("%T", v), "stack", string(debug.Stack()))
224 a.errorPage(w, r, http.StatusInternalServerError, kindStatus, ref)
225 }()
226 next.ServeHTTP(w, r)
227 })
228}
229
230// ErrorPage is rastrillo.Options.ErrorPage for a built app: the page
231// rastrillo.Serve's own panic recovery renders, for a panic outside the
232// router. It reads the session optionally like every other error page.
233func (a *app) ErrorPage(w http.ResponseWriter, r *http.Request, status int, ref string) {
234 a.errorPage(w, r, status, kindStatus, ref)
235}
236
237// ErrorPage renders the error page with signed-out chrome, for a caller
238// that has no app to resolve a viewer with.
239func ErrorPage(w http.ResponseWriter, r *http.Request, status int, ref string) {
240 if !wantsHTML(r) {
241 plainError(w, status)
242 return
243 }
244 sh := shell{Frame: (&app{}).frameFor(r, viewerState{}, titled(errorCopy(r, status, kindStatus).title))}
245 writeErrorPage(w, r, status, kindStatus, ref, "", sh)
246}
247