oficina / home Public

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

Plain git — no account needed to clone.

Download

Download this file

1package home
2
3import (
4 "net/http"
5 "net/url"
6 "strings"
7
8 "amadan.net/oficina/estilo.git/base"
9 "amadan.net/oficina/estilo.git/chrome"
10 "amadan.net/oficina/estilo.git/menu"
11 "amadan.net/oficina/home/handoff"
12 "amadan.net/rastrillo/rastrillo"
13 "amadan.net/rastrillo/rastrillo/sessions"
14 "github.com/go-chi/chi/v5"
15)
16
17// The shared chrome (estilo spec shared-chrome, §12.1 row 8). Every page
18// Home draws for a person, other than the public front page, sits under
19// estilo's 52px bar with the brand "Oficina":
20//
21// - on the directory, a signed-in person gets the account menu: who is
22// signed in, Passkeys and Recovery codes (pages on id.*), Appearance,
23// Help → Open source, and the one Sign out. Signed out, the corner is
24// "Sign in", except on the signed-out page itself.
25// - on id.*, the bar is the brand alone, linking back to the directory:
26// every page there is the door, and the credential origin renders
27// nothing account-shaped (AGENTS.md, Identity).
28//
29// No chrome.js: Home prefers no script (AGENTS.md), and every menu works
30// without it. The stated cost is estilo's: with scripts off, an open menu
31// closes when its own trigger is pressed again, not on Escape or a click
32// outside.
33
34// page is what every template is executed with: the handler's own data,
35// and the request's chrome.
36type page struct {
37 Data any
38 Chrome chrome.Bar
39 // Theme is <html data-theme>: "light", "dark", or empty to follow the
40 // system (the Appearance preference, per host).
41 Theme string
42}
43
44// The account menu's and the bar's words. Home speaks English only today;
45// estilo's templates carry none of their own.
46var (
47 barLabels = chrome.BarLabels{Skip: "Skip to content", Menu: "Menu"}
48 accountLabels = chrome.AccountLabels{SignedInAs: "Signed in", Appearance: "Appearance", Help: "Help"}
49)
50
51// onCredential reports whether r arrived for id.* rather than the
52// directory.
53func (a *App) onCredential(r *http.Request) bool {
54 return r != nil && strings.ToLower(r.Host) == a.credentialHost
55}
56
57// pageFor wraps a handler's data in the request's chrome. door marks the
58// sign-in, sent and signed-out pages, whose corner stays empty. hop marks
59// a page that moves on by itself: its bar is the brand alone, as plain
60// text, so the page still holds exactly one link, the way on.
61func (a *App) pageFor(r *http.Request, status int, data any, door, hop bool) page {
62 p := page{Data: data}
63 brand := chrome.App{Name: suiteName, Slug: "oficina", Home: "/", Icon: versioned("/static/brand/icon.svg")}
64 if hop {
65 brand.Home = ""
66 p.Chrome = chrome.Bar{App: brand, Labels: barLabels}
67 return p
68 }
69 if a.onCredential(r) || r == nil {
70 brand.Home = a.origins.Directory + "/"
71 p.Chrome = chrome.Bar{App: brand, Labels: barLabels}
72 return p
73 }
74 p.Theme = chrome.ThemeAttr(chrome.Appearance(r))
75 p.Chrome = chrome.Bar{App: brand, Labels: barLabels}
76 if _, ok := sessions.Current(r); ok {
77 p.Chrome.Account = a.account(r, status)
78 } else if !door {
79 p.Chrome.SignIn = &chrome.Link{Href: signInHref(r), Label: "Sign in"}
80 }
81 return p
82}
83
84// account is the directory's account menu. The directory cannot read the
85// address (it holds a person ref, never an address: the one rule), so the
86// identity row says only "Signed in" and the trigger is the person glyph.
87func (a *App) account(r *http.Request, status int) *chrome.Account {
88 return &chrome.Account{
89 TriggerLabel: "Account",
90 Labels: accountLabels,
91 Items: []menu.Item{
92 {Href: a.origins.Credential + "/passkey/enrol", Label: "Passkeys"},
93 {Href: a.origins.Credential + "/passkey/codes", Label: "Recovery codes"},
94 },
95 Appearance: &chrome.AppearanceMenu{
96 Action: "/appearance",
97 Return: appearanceReturn(r, status),
98 Options: chrome.AppearanceOptions(chrome.Appearance(r), "Light", "Dark", "System"),
99 },
100 Help: []menu.Item{{Href: "/help/open-source", Label: "Open source"}},
101 SignOut: chrome.SignOut{Action: signoutPath, Label: "Sign out"},
102 }
103}
104
105// appearanceReturn is where choosing an appearance comes back to: this
106// page, if it is one a GET can come back to, otherwise the directory. Only
107// a GET that drew a 200 qualifies. A 404's or an error page's own path is
108// known not to work, and the path of a page drawn for a POST is POST-only,
109// so a 303 back to it would land on a 404. The path alone: no directory
110// page reads its query, and a query is where a crafted link would carry
111// its text.
112func appearanceReturn(r *http.Request, status int) string {
113 if r.Method != http.MethodGet || status != http.StatusOK {
114 return "/"
115 }
116 return r.URL.EscapedPath()
117}
118
119// signInHref starts the directory's sign-in and comes back to this page.
120// An error page comes back to the directory instead: its own URL is the
121// one thing known not to exist.
122func signInHref(r *http.Request) string {
123 back := "/"
124 if r.Method == http.MethodGet && r.URL.Path == "/help/open-source" {
125 back = r.URL.Path
126 }
127 return handoff.StartPath + "?return_to=" + url.QueryEscape(back)
128}
129
130// mountChrome serves estilo's base, menu and chrome stylesheets, on both
131// origins.
132func mountChrome(r chi.Router) {
133 mountCSS(r, "/static/estilo/base.css", base.CSS())
134 mountCSS(r, "/static/estilo/menu.css", menu.CSS())
135 mountCSS(r, "/static/estilo/chrome.css", chrome.CSS())
136}
137
138// wantsHTML reports whether the request is a browser's page load: its
139// Accept names text/html. Everything else (a fetch, curl, a monitor) gets
140// plain text, as it always has (spec §3.3).
141func wantsHTML(r *http.Request) bool {
142 return strings.Contains(r.Header.Get("Accept"), "text/html")
143}
144
145// errorWords is one status's error page: the tab title, the heading and
146// the line under it.
147type errorWords struct{ page, heading, body string }
148
149// errorCopy is estilo spec §3.2's copy for the statuses Home answers, and
150// rastrillo's error copy (locales/en.toml) for the rest an HTTP handler
151// can plausibly be asked to draw. Any other status gets errorFallback.
152var errorCopy = map[int]errorWords{
153 http.StatusBadRequest: {"Bad request", "That didn’t go through", "Something in what was sent wasn’t right. Go back and try again."},
154 http.StatusUnauthorized: {"Sign in needed", "You need to sign in", "This page is only for someone who is signed in. Sign in, then try again."},
155 http.StatusForbidden: {"No access", "You can’t see this", "Your account doesn’t have access here. If you think it should, ask whoever runs this site."},
156 http.StatusNotFound: {"Page not found", "We can’t find that page", "The link may be out of date, or the page may have moved."},
157 http.StatusMethodNotAllowed: {"Not available", "That can’t be done here", "This page doesn’t take that kind of request. Go back and try again from the page itself."},
158 http.StatusRequestEntityTooLarge: {"Too large", "That was too large", "What was sent is bigger than this page accepts. Try again with less."},
159 http.StatusTooManyRequests: {"Too many tries", "Too many tries", "Wait a minute, then try again."},
160 http.StatusInternalServerError: {"Something went wrong", "Something went wrong", "Try again in a moment. If it keeps happening, quote the reference below."},
161 http.StatusServiceUnavailable: {"Briefly unavailable", "We’re briefly unavailable", "Oficina is being updated or is busy. Try again in a minute."},
162}
163
164// errorFallback is the error page for a status with no words of its own.
165var errorFallback = errorWords{"Something’s not right", "Something’s not right", "That request couldn’t be completed. Go back and try again."}
166
167// errorCopyFor is status's words, or the general ones.
168func errorCopyFor(status int) errorWords {
169 if c, ok := errorCopy[status]; ok {
170 return c
171 }
172 return errorFallback
173}
174
175// errorPage answers status as estilo's error page, under the bar, for a
176// browser, and as plain text for anything else. A 500 carries a reference
177// to quote, which is also logged, so the two can be matched.
178func (a *App) errorPage(w http.ResponseWriter, r *http.Request, status int) {
179 w.Header().Set("Cache-Control", "no-store")
180 if !wantsHTML(r) {
181 http.Error(w, strings.ToLower(http.StatusText(status)), status)
182 return
183 }
184 c := errorCopyFor(status)
185 home := chrome.Link{Href: "/", Label: "Go to Oficina"}
186 if a.onCredential(r) {
187 home.Href = a.origins.Directory + "/"
188 }
189 data := chrome.ErrorPage{Status: status, Title: c.heading, Body: c.body, Home: home}
190 if status >= 500 {
191 data.Ref, data.RefLabel = rastrillo.NewRef(), "Reference:"
192 a.logger.Error("error page", "ref", data.Ref, "path", r.URL.Path)
193 if r.Method == http.MethodGet {
194 // Beside the way home, not instead of it: the way home is the
195 // one link known to work.
196 data.Back = &chrome.Link{Href: r.URL.RequestURI(), Label: "Try again"}
197 }
198 }
199 a.renderPage(w, r, status, "error.html", errorView{chrome.Title(c.page, suiteName), data})
200}
201
202// errorView is error.html's data: the tab title and estilo-error's.
203type errorView struct {
204 Title string
205 Error chrome.ErrorPage
206}
207
208// notFound is the 404 for an unknown route, on either origin.
209func (a *App) notFound(w http.ResponseWriter, r *http.Request) {
210 a.errorPage(w, r, http.StatusNotFound)
211}
212
213// methodNotAllowed: a browser's GET or HEAD for a path that only takes a
214// POST is, to the person who typed it, a page that is not there; anything
215// else is told 405 in plain text, as before.
216func (a *App) methodNotAllowed(w http.ResponseWriter, r *http.Request) {
217 if (r.Method == http.MethodGet || r.Method == http.MethodHead) && wantsHTML(r) {
218 a.notFound(w, r)
219 return
220 }
221 w.Header().Set("Cache-Control", "no-store")
222 http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
223}
224
225// serverError is the 500 for a handler that failed after deciding to
226// answer: the error page with a reference.
227func (a *App) serverError(w http.ResponseWriter, r *http.Request) {
228 a.errorPage(w, r, http.StatusInternalServerError)
229}
230