oficina / sheets Public

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

Plain git — no account needed to clone.

Download

Download this file

1package sheets
2
3// HTML error pages (spec §3). Before this every failure a browser could
4// reach was http.Error's plain text in the browser's monospace font, with
5// no chrome, no <title> and no way back: "Not found." for a stale link or
6// a revoked share, and chi's own "404 page not found" for an unknown
7// route.
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. a.notFound is the case the spec names
12// (§3.3): one helper reached from the grid's page and from its /api
13// fetches through sheetFromURL, so the split has to happen inside it.
14// The grid's fetches, the live stream and the native apps never ask for
15// HTML, so they keep the plain answers they have always had.
16//
17// A missing sheet and one that is not shared with you are the same 404
18// (AGENTS.md): nothing here distinguishes them, so nothing is enumerable.
19
20import (
21 "errors"
22 "fmt"
23 "log/slog"
24 "net/http"
25 "net/url"
26 "runtime/debug"
27 "strings"
28
29 "amadan.net/oficina/estilo.git/chrome"
30 "amadan.net/rastrillo/rastrillo"
31 "github.com/go-chi/chi/v5"
32)
33
34var errNoTemplate = errors.New("sheets: no such page template")
35
36// errorCopy is one row of the spec's §3.2 table.
37type errorCopy struct {
38 title, heading, body string
39}
40
41// errorKind says which row a failure is.
42type errorKind int
43
44const (
45 // kindRoute: nothing lives at this address.
46 kindRoute errorKind = iota
47 // kindSheet: the sheet (or link) is missing, or not shared with you.
48 kindSheet
49 // kindStatus: the page for the status itself (400, 403, 413, 5xx).
50 kindStatus
51)
52
53func copyFor(status int, kind errorKind) errorCopy {
54 switch {
55 case status == http.StatusNotFound && kind == kindSheet:
56 return errorCopy{"Sheet not available", "This sheet isn’t available", "It may have been deleted, or it hasn’t been shared with you."}
57 case status == http.StatusNotFound:
58 return errorCopy{"Page not found", "We can’t find that page", "The link may be out of date, or the page may have moved."}
59 case status == http.StatusForbidden:
60 return errorCopy{"No access", "You don’t have access to this page", "Ask whoever runs this team if you think you should."}
61 case status == http.StatusRequestEntityTooLarge:
62 return errorCopy{"Too long", "That’s too long", "Go back, make it shorter and try again."}
63 case status == http.StatusBadRequest:
64 return errorCopy{"That didn’t work", "That didn’t work", "Go back and try again."}
65 case status == http.StatusServiceUnavailable:
66 return errorCopy{appName + " is unavailable", appName + " is unavailable right now", "Try again in a few minutes."}
67 }
68 return errorCopy{"Something went wrong", "Something went wrong", "Try again in a moment. If it keeps happening, quote the reference below."}
69}
70
71type errorPageView struct {
72 Error chrome.ErrorPage
73}
74
75// wantsHTML reports whether the request asked for a page.
76func wantsHTML(r *http.Request) bool {
77 return strings.Contains(r.Header.Get("Accept"), "text/html")
78}
79
80// notFound answers 404 with nothing that could name a sheet: the "sheet
81// not available" page for a browser, and the plain "Not found." it has
82// always been for the grid's fetches and the native apps.
83func (a *App) notFound(w http.ResponseWriter, r *http.Request) {
84 a.answer(w, r, http.StatusNotFound, kindSheet, "Not found.")
85}
86
87// routeNotFound is the router's NotFound: an unknown address.
88func (a *App) routeNotFound(w http.ResponseWriter, r *http.Request) {
89 a.answer(w, r, http.StatusNotFound, kindRoute, "Not found.")
90}
91
92// forbidden answers a request that needed an actor and had none. Behind
93// requireSession it is unreachable; it is what a handler says if that
94// guard is ever not in front of it.
95func (a *App) forbidden(w http.ResponseWriter, r *http.Request) {
96 a.answer(w, r, http.StatusForbidden, kindStatus, "Sign in first.")
97}
98
99// badRequest answers a form that could not be read, or asked for
100// something the route cannot do. text is the plain answer a script or a
101// native client has always read.
102func (a *App) badRequest(w http.ResponseWriter, r *http.Request, status int, text string) {
103 a.answer(w, r, status, kindStatus, text)
104}
105
106// fail answers 500.
107//
108// The error is deliberately dropped rather than shown or logged: it can
109// carry a sealed title's plaintext, a cell's contents, or a SQL
110// statement quoting either, and `carlos logs` is platform-held storage
111// we cannot grep or redact (AGENTS.md / Mechanical rules). The page
112// quotes a reference, and the log line carries that reference and the
113// route pattern, never the path: a /l/ path is a capability.
114func (a *App) fail(w http.ResponseWriter, r *http.Request, _ error) {
115 if !wantsHTML(r) {
116 http.Error(w, "Something went wrong.", http.StatusInternalServerError)
117 return
118 }
119 ref := rastrillo.NewRef()
120 slog.Error("sheets: error page", "ref", ref, "route", routePattern(r))
121 a.errorPage(w, r, http.StatusInternalServerError, kindStatus, ref)
122}
123
124func (a *App) answer(w http.ResponseWriter, r *http.Request, status int, kind errorKind, text string) {
125 if !wantsHTML(r) {
126 http.Error(w, text, status)
127 return
128 }
129 a.errorPage(w, r, status, kind, "")
130}
131
132// routePattern is the matched route, "/sheets/{sheetID}" rather than the
133// path it matched: safe to log whatever the path held.
134func routePattern(r *http.Request) string {
135 if rc := chi.RouteContext(r.Context()); rc != nil && rc.RoutePattern() != "" {
136 return rc.RoutePattern()
137 }
138 return "(unrouted)"
139}
140
141// errorPage renders the page for status inside the normal layout, with
142// whatever chrome the request's session gives it. ref is the reference a
143// 500 quotes, empty otherwise.
144func (a *App) errorPage(w http.ResponseWriter, r *http.Request, status int, kind errorKind, ref string) {
145 c := copyFor(status, kind)
146 page := chrome.ErrorPage{
147 Status: status,
148 Title: c.heading,
149 Body: c.body,
150 Home: chrome.Link{Href: "/", Label: "Go to " + appName},
151 }
152 // Try again is the same URL, and only for a GET: re-sending a POST
153 // from an error page is the one thing it must never do.
154 if status >= 500 && r.Method == http.MethodGet {
155 page.Home = chrome.Link{Href: r.URL.RequestURI(), Label: "Try again"}
156 }
157 if ref != "" {
158 page.Ref, page.RefLabel = ref, "Reference:"
159 }
160 if back := a.sameOriginReferer(r); back != "" && status < 500 {
161 page.Back = &chrome.Link{Href: back, Label: "Back"}
162 }
163 t, ok := pages["error"]
164 var buf strings.Builder
165 if ok {
166 err := t.ExecuteTemplate(&buf, "layout", PageData{Frame: a.frameFor(r, titled(c.title)), Data: errorPageView{Error: page}})
167 ok = err == nil
168 }
169 w.Header().Set("Cache-Control", "no-store")
170 if !ok {
171 // The page could not render: say so in the plainest way left.
172 http.Error(w, "Something went wrong.", status)
173 return
174 }
175 pageHeaders(w)
176 w.Header().Set("Content-Type", "text/html; charset=utf-8")
177 w.WriteHeader(status)
178 _, _ = w.Write([]byte(buf.String()))
179}
180
181// sameOriginReferer is the page a person came from, when it is this app
182// and not this page: rastrillo's rule for the error page's Back link. An
183// unchecked Referer is an open redirect with better manners.
184func (a *App) sameOriginReferer(r *http.Request) string {
185 raw := r.Header.Get("Referer")
186 if raw == "" || a.origin == "" {
187 return ""
188 }
189 u, err := url.Parse(raw)
190 if err != nil || u.User != nil || u.Scheme+"://"+u.Host != a.origin {
191 return ""
192 }
193 back := u.EscapedPath()
194 if u.RawQuery != "" {
195 back += "?" + u.RawQuery
196 }
197 if !strings.HasPrefix(back, "/") || strings.HasPrefix(back, "//") || back == r.URL.RequestURI() || strings.HasPrefix(back, "/l/") {
198 return ""
199 }
200 return back
201}
202
203// methodNotAllowed is the router's MethodNotAllowed. A GET or HEAD to a
204// path that only takes a POST (somebody opening /signout from history) is
205// the same 404 page as an unknown route: to a person there is no page
206// there. Any other method gets 405 in plain text, with the Allow header
207// set here — chi's default sets it, a custom handler does not.
208func (a *App) methodNotAllowed(routes chi.Routes) http.HandlerFunc {
209 return func(w http.ResponseWriter, r *http.Request) {
210 var allowed []string
211 for _, m := range []string{http.MethodGet, http.MethodHead, http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete} {
212 if routes.Match(chi.NewRouteContext(), m, r.URL.Path) {
213 allowed = append(allowed, m)
214 }
215 }
216 get := false
217 for _, m := range allowed {
218 get = get || m == http.MethodGet
219 }
220 if (r.Method == http.MethodGet || r.Method == http.MethodHead) && !get {
221 a.routeNotFound(w, r)
222 return
223 }
224 w.Header().Set("Allow", strings.Join(allowed, ", "))
225 http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed)
226 }
227}
228
229// recoverToErrorPage turns a panic in a handler into the 500 page with a
230// reference, logged with the stack under the same reference.
231//
232// It is mounted INSIDE the signed-in group, after the session guard, so
233// the page carries the viewer's own chrome: mounted outside, every 500
234// would look signed out, which is Calendar's bug (spec §3.3). Panics
235// outside it reach rastrillo.Serve's own recoverer through
236// App.ErrorPage.
237func (a *App) recoverToErrorPage(next http.Handler) http.Handler {
238 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
239 defer func() {
240 v := recover()
241 if v == nil {
242 return
243 }
244 if v == http.ErrAbortHandler {
245 panic(v)
246 }
247 ref := rastrillo.NewRef()
248 // The panic value and stack name code, not content: a panic
249 // raised with a cell's text in it would be a bug in its own
250 // right, and the value is logged as the fmt of its type only.
251 slog.Error("sheets: panic", "ref", ref, "route", routePattern(r), "panic", fmt.Sprintf("%T", v), "stack", string(debug.Stack()))
252 if !wantsHTML(r) {
253 http.Error(w, "Something went wrong.", http.StatusInternalServerError)
254 return
255 }
256 a.errorPage(w, r, http.StatusInternalServerError, kindStatus, ref)
257 }()
258 next.ServeHTTP(w, r)
259 })
260}
261
262// ErrorPage is rastrillo.Options.ErrorPage: the page rastrillo.Serve's
263// own panic recovery renders, for a panic outside the signed-in group.
264// It reads the session optionally like every other error page.
265func (a *App) ErrorPage(w http.ResponseWriter, r *http.Request, status int, ref string) {
266 if !wantsHTML(r) {
267 http.Error(w, "Something went wrong.", status)
268 return
269 }
270 a.errorPage(w, r, status, kindStatus, ref)
271}
272