oficina / docs Public

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

Plain git — no account needed to clone.

Download

Download this file

1package docs
2
3// HTML error pages (spec §3). Before this, every failure a browser could
4// navigate to was http.Error's plain text in a monospace page with an
5// empty <title> — "Not found." for a bad document link, and Go's own
6// "404 page not found" for an unknown route.
7//
8// The rule for which answers are pages: a page is rendered only when the
9// request says it wants HTML (its Accept names text/html, as every
10// browser navigation and form post does). The update log, the stream,
11// search and the native API are fetched by script or by the native apps
12// and never ask for HTML, so they keep their plain-text and JSON answers
13// even when a shared helper (getDocumentForUpdates) serves both. Docs has
14// no /api/ prefix to decide it by, and a prefix would be the wrong rule
15// anyway (spec §3.3).
16//
17// A missing document and one that is not shared with you are the same
18// 404 (§4.2): nothing here distinguishes them, so nothing is enumerable.
19
20import (
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// failKind says which of the spec's error pages a status stands for.
33type failKind int
34
35const (
36 // failRoute: nothing lives at this address.
37 failRoute failKind = iota
38 // failDocument: the document is missing or not shared with you.
39 failDocument
40 // failStatus: the page for the status itself (403, 5xx, 400).
41 failStatus
42)
43
44type errorPageData struct {
45 Frame Frame
46 Error chrome.ErrorPage
47}
48
49// wantsHTML reports whether the request asked for a page.
50func wantsHTML(r *http.Request) bool {
51 return strings.Contains(r.Header.Get("Accept"), "text/html")
52}
53
54// fail answers an error. A request that asked for HTML gets the page;
55// anything else gets the plain text it got before this existed, so a
56// script or a native client reading the body sees no change.
57func (h *handlers) fail(w http.ResponseWriter, r *http.Request, status int, kind failKind) {
58 if !wantsHTML(r) {
59 key := "docs.error.generic"
60 if status == http.StatusNotFound {
61 key = "docs.error.notfound"
62 }
63 http.Error(w, rastrillo.T(r, key), status)
64 return
65 }
66 ref := ""
67 if status == http.StatusInternalServerError {
68 // The page tells a person to quote the reference below, so every
69 // 500 page has one, logged beside the path it answered.
70 ref = rastrillo.NewRef()
71 h.logger.Error("docs: error page", "ref", ref, "path", r.URL.Path)
72 }
73 h.errorPage(w, r, status, kind, ref)
74}
75
76// errorPage renders the page for status. ref is the reference quoted on
77// a 500 (rastrillo.NewRef), empty otherwise.
78func (h *handlers) errorPage(w http.ResponseWriter, r *http.Request, status int, kind failKind, ref string) {
79 app := rastrillo.T(r, "docs.app.name")
80 home := chrome.Link{Href: "/", Label: rastrillo.Tf(r, "docs.error.home", "app", app)}
81 key := "docs.error.page.500"
82 switch {
83 case status == http.StatusNotFound && kind == failDocument:
84 key = "docs.error.page.document"
85 case status == http.StatusNotFound:
86 key = "docs.error.page.404"
87 case status == http.StatusForbidden:
88 key = "docs.error.page.403"
89 case status == http.StatusBadRequest:
90 key = "docs.error.page.400"
91 case status == http.StatusServiceUnavailable:
92 key = "docs.error.page.503"
93 }
94 page := chrome.ErrorPage{
95 Status: status,
96 Title: rastrillo.Tf(r, key+".heading", "app", app),
97 Body: rastrillo.T(r, key+".body"),
98 Home: home,
99 }
100 // Try again is the same URL, and only for a GET: re-sending a POST
101 // from an error page is the one thing it must never do.
102 if status >= 500 && r.Method == http.MethodGet {
103 page.Home = chrome.Link{Href: r.URL.RequestURI(), Label: rastrillo.T(r, "docs.error.retry")}
104 }
105 if status == http.StatusInternalServerError && ref != "" {
106 page.Ref, page.RefLabel = ref, rastrillo.T(r, "docs.error.ref")
107 }
108 if back := h.sameOriginReferer(r); back != "" && status < 500 {
109 page.Back = &chrome.Link{Href: back, Label: rastrillo.T(r, "docs.error.back")}
110 }
111 data := errorPageData{
112 Frame: h.frame(r, frameOptions{titleParts: []string{rastrillo.Tf(r, key+".title", "app", app)}}),
113 Error: page,
114 }
115 w.Header().Set("Cache-Control", "no-store")
116 if err := h.render.pageStatus(w, r, "error", data, status); err != nil {
117 // The page could not render: say so in the plainest way left.
118 h.logger.Error("docs: render error page", "status", status, "err", err)
119 http.Error(w, rastrillo.T(r, "docs.error.generic"), status)
120 }
121}
122
123// sameOriginReferer is the page a person came from, when it is this app
124// and not this page — rastrillo's rule for the error page's Back link. An
125// unchecked Referer is an open redirect with better manners.
126func (h *handlers) sameOriginReferer(r *http.Request) string {
127 raw := r.Header.Get("Referer")
128 if raw == "" {
129 return ""
130 }
131 u, err := url.Parse(raw)
132 if err != nil || u.User != nil || u.Scheme+"://"+u.Host != h.origin {
133 return ""
134 }
135 back := u.EscapedPath()
136 if u.RawQuery != "" {
137 back += "?" + u.RawQuery
138 }
139 if !strings.HasPrefix(back, "/") || strings.HasPrefix(back, "//") || back == r.URL.RequestURI() {
140 return ""
141 }
142 return back
143}
144
145// notFound is the router's NotFound: an unknown route is the 404 page for
146// a browser and chi's plain text for anything else.
147func (h *handlers) notFound(w http.ResponseWriter, r *http.Request) {
148 h.fail(w, r, http.StatusNotFound, failRoute)
149}
150
151// methodNotAllowed is the router's MethodNotAllowed. A GET or HEAD to a
152// path that only takes a POST (someone opening /signout from history) is
153// the same 404 page as an unknown route: to a person there is no page
154// there. Any other method gets 405 in plain text, with the Allow header
155// set here — chi's default sets it, a custom handler does not.
156func (h *handlers) methodNotAllowed(routes chi.Routes) http.HandlerFunc {
157 return func(w http.ResponseWriter, r *http.Request) {
158 var allowed []string
159 for _, m := range []string{http.MethodGet, http.MethodHead, http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete} {
160 if routes.Match(chi.NewRouteContext(), m, r.URL.Path) {
161 allowed = append(allowed, m)
162 }
163 }
164 get := false
165 for _, m := range allowed {
166 get = get || m == http.MethodGet
167 }
168 if (r.Method == http.MethodGet || r.Method == http.MethodHead) && !get {
169 h.fail(w, r, http.StatusNotFound, failRoute)
170 return
171 }
172 w.Header().Set("Allow", strings.Join(allowed, ", "))
173 http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed)
174 }
175}
176
177// recoverToErrorPage turns a panic in a handler into the 500 page with a
178// reference, logged with the stack under the same reference.
179//
180// It is mounted INSIDE the signed-in group, after the session middleware,
181// so the page carries the viewer's own chrome: mounted outside, every 500
182// would look signed out, which is Calendar's bug (spec §3.3). Panics
183// outside it reach rastrillo.Serve's own recoverer through
184// App.ErrorPage.
185func (h *handlers) recoverToErrorPage(next http.Handler) http.Handler {
186 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
187 defer func() {
188 v := recover()
189 if v == nil {
190 return
191 }
192 if v == http.ErrAbortHandler {
193 panic(v)
194 }
195 ref := rastrillo.NewRef()
196 h.logger.Error("docs: panic", "ref", ref, "path", r.URL.Path, "panic", fmt.Sprint(v), "stack", string(debug.Stack()))
197 if !wantsHTML(r) {
198 http.Error(w, rastrillo.T(r, "docs.error.generic"), http.StatusInternalServerError)
199 return
200 }
201 h.errorPage(w, r, http.StatusInternalServerError, failStatus, ref)
202 }()
203 next.ServeHTTP(w, r)
204 })
205}
206
207// ErrorPage is rastrillo.Options.ErrorPage: the page rastrillo.Serve's
208// own panic recovery renders, for a panic outside the router's signed-in
209// group. It reads the session optionally like every other error page.
210func (a *App) ErrorPage(w http.ResponseWriter, r *http.Request, status int, ref string) {
211 if !wantsHTML(r) {
212 http.Error(w, rastrillo.T(r, "docs.error.generic"), status)
213 return
214 }
215 a.handlers.errorPage(w, r, status, failStatus, ref)
216}
217