oficina / memoria Public

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

Plain git — no account needed to clone.

Download

Download this file

1package memoria
2
3// HTML error pages (shared chrome spec §3). Before this an unknown URL, a
4// document that is not there and every handler failure answered Go's
5// plain-text "404 page not found" or a one-line string ("could not read
6// that document") in the browser's default monospace, with no title, no
7// chrome and no way back (polish audit §B7).
8//
9// Which answers are pages is decided by the request, not the route: a page
10// is rendered only when Accept names text/html, as every browser
11// navigation and form post does. The upload tray (Accept:
12// application/json), the native API and anything else that did not ask for
13// a page keep the plain answers they always had.
14//
15// A document that is missing and one that is not yours are the same 404
16// (AGENTS.md: a row that isn't yours 404s, never 403s): nothing here
17// distinguishes them.
18
19import (
20 "bytes"
21 "fmt"
22 "net/http"
23 "net/url"
24 "runtime/debug"
25 "strings"
26
27 "amadan.net/oficina/estilo.git/chrome"
28 "amadan.net/rastrillo/rastrillo"
29 "github.com/go-chi/chi/v5"
30)
31
32// errorKind says which row of spec §3.2 a failure is.
33type errorKind int
34
35const (
36 // kindRoute: nothing lives at this address.
37 kindRoute errorKind = iota
38 // kindDocument: the document is missing, or not yours.
39 kindDocument
40 // kindStatus: the page for the status itself (400, 403, 5xx).
41 kindStatus
42)
43
44// errorWords is one row of §3.2: the title part, the heading and the body.
45type errorWords struct {
46 title, heading, body string
47}
48
49func errorCopy(status int, kind errorKind) errorWords {
50 switch {
51 case status == http.StatusNotFound && kind == kindDocument:
52 return errorWords{"Document not available", "This document isn’t available", "It may have been deleted, or it hasn’t been shared with you."}
53 case status == http.StatusNotFound:
54 return errorWords{"Page not found", "We can’t find that page", "The link may be out of date, or the page may have moved."}
55 case status == http.StatusForbidden:
56 return errorWords{"No access", "You don’t have access to this page", "Ask whoever runs this team if you think you should."}
57 case status == http.StatusBadRequest:
58 return errorWords{"That didn’t work", "That didn’t work", "Go back and try again."}
59 case status == http.StatusServiceUnavailable:
60 return errorWords{appName + " is unavailable", appName + " 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// errorView is templates/error.html's data.
66type errorView struct {
67 page
68 Error chrome.ErrorPage
69}
70
71// wantsHTML reports whether the request asked for a page.
72func wantsHTML(r *http.Request) bool {
73 return strings.Contains(r.Header.Get("Accept"), "text/html")
74}
75
76// plainError is the answer to a request that did not ask for a page.
77func plainError(w http.ResponseWriter, status int) {
78 w.Header().Set("Cache-Control", "no-store")
79 http.Error(w, http.StatusText(status), status)
80}
81
82// routePattern is the matched route, "/documents/{id}" rather than the
83// path it matched: a path is safe to log only because it is never logged.
84func routePattern(r *http.Request) string {
85 if rc := chi.RouteContext(r.Context()); rc != nil && rc.RoutePattern() != "" {
86 return rc.RoutePattern()
87 }
88 return "(unrouted)"
89}
90
91// errorPage answers status: a page inside the normal layout with the
92// viewer's own chrome for a browser, plain text otherwise. ref is the
93// reference a 500 quotes, empty otherwise.
94func (a *app) errorPage(w http.ResponseWriter, r *http.Request, status int, kind errorKind, ref string) {
95 if !wantsHTML(r) {
96 plainError(w, status)
97 return
98 }
99 c := errorCopy(status, kind)
100 pg := a.pageFor(r, titled(c.title))
101 a.writeErrorPage(w, r, status, kind, ref, pg)
102}
103
104// writeErrorPage renders the page with pg as its chrome.
105func (a *app) writeErrorPage(w http.ResponseWriter, r *http.Request, status int, kind errorKind, ref string, pg page) {
106 c := errorCopy(status, kind)
107 body := chrome.ErrorPage{
108 Status: status,
109 Title: c.heading,
110 Body: c.body,
111 Home: chrome.Link{Href: "/", Label: "Go to " + appName},
112 }
113 // Try again is the same URL, and only for a GET: re-sending a POST
114 // from an error page is the one thing it must never do.
115 if status >= 500 && r.Method == http.MethodGet {
116 body.Home = chrome.Link{Href: r.URL.RequestURI(), Label: "Try again"}
117 }
118 if ref != "" {
119 body.Ref, body.RefLabel = ref, "Reference:"
120 }
121 if back := sameOriginReferer(r, a.origin); back != "" && status < 500 {
122 body.Back = &chrome.Link{Href: back, Label: "Back"}
123 }
124 var buf bytes.Buffer
125 if err := pages["error"].ExecuteTemplate(&buf, "layout", errorView{page: pg, Error: body}); err != nil {
126 // The page could not render: say so in the plainest way left.
127 plainError(w, status)
128 return
129 }
130 w.Header().Set("Cache-Control", "no-store")
131 w.Header().Set("Content-Type", "text/html; charset=utf-8")
132 w.WriteHeader(status)
133 _, _ = buf.WriteTo(w)
134}
135
136// sameOriginReferer is the page a person came from, when it is this app
137// and not this page: rastrillo's rule for the error page's Back link. An
138// unchecked Referer is an open redirect with better manners.
139func sameOriginReferer(r *http.Request, origin string) string {
140 raw := r.Header.Get("Referer")
141 if raw == "" || origin == "" {
142 return ""
143 }
144 u, err := url.Parse(raw)
145 if err != nil || u.User != nil || u.Scheme+"://"+u.Host != origin {
146 return ""
147 }
148 back := u.EscapedPath()
149 if u.RawQuery != "" {
150 back += "?" + u.RawQuery
151 }
152 if !strings.HasPrefix(back, "/") || strings.HasPrefix(back, "//") || back == r.URL.RequestURI() ||
153 strings.HasPrefix(back, "/auth/") || strings.HasPrefix(back, "/signin") {
154 return ""
155 }
156 return back
157}
158
159// routeNotFound is the router's NotFound: an unknown address.
160func (a *app) routeNotFound(w http.ResponseWriter, r *http.Request) {
161 a.errorPage(w, r, http.StatusNotFound, kindRoute, "")
162}
163
164// methodNotAllowed is the router's MethodNotAllowed. A GET or HEAD to a
165// path that only takes a POST (somebody opening /documents/1/delete from
166// history, say) is the same 404 page as an unknown route: to a person there
167// is no page there. Any other method gets 405 in plain text, with the Allow
168// header set here — chi's default sets it, a custom handler does not.
169func (a *app) methodNotAllowed(routes chi.Routes) http.HandlerFunc {
170 return func(w http.ResponseWriter, r *http.Request) {
171 var allowed []string
172 for _, m := range []string{http.MethodGet, http.MethodHead, http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete} {
173 if routes.Match(chi.NewRouteContext(), m, r.URL.Path) {
174 allowed = append(allowed, m)
175 }
176 }
177 get := false
178 for _, m := range allowed {
179 get = get || m == http.MethodGet
180 }
181 if (r.Method == http.MethodGet || r.Method == http.MethodHead) && !get {
182 a.routeNotFound(w, r)
183 return
184 }
185 w.Header().Set("Allow", strings.Join(allowed, ", "))
186 http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed)
187 }
188}
189
190// recoverToErrorPage turns a panic in a handler into the 500 page with a
191// reference, logged with the stack under the same reference. The page
192// reads the session itself (admissionOf), so it shows the viewer's own
193// chrome wherever in the router the panic happened. Panics outside the
194// router reach rastrillo.Serve's own recoverer through ErrorPage.
195//
196// The value is logged as its type only: a panic raised with a passage in
197// it would put content in the log (AGENTS.md, gate 4).
198func (a *app) recoverToErrorPage(next http.Handler) http.Handler {
199 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
200 defer func() {
201 v := recover()
202 if v == nil {
203 return
204 }
205 if v == http.ErrAbortHandler {
206 panic(v)
207 }
208 ref := rastrillo.NewRef()
209 a.logger.Error("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
216// ErrorPage is rastrillo.Options.ErrorPage: the page rastrillo.Serve's own
217// panic recovery renders, for a panic outside the router. It reads the
218// session optionally like every other error page.
219func (a *app) ErrorPage(w http.ResponseWriter, r *http.Request, status int, ref string) {
220 a.errorPage(w, r, status, kindStatus, ref)
221}
222