| 1 | package home |
| 2 | |
| 3 | import ( |
| 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. |
| 36 | type 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. |
| 46 | var ( |
| 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. |
| 53 | func (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. |
| 61 | func (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. |
| 87 | func (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. |
| 112 | func 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. |
| 122 | func 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. |
| 132 | func 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). |
| 141 | func 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. |
| 147 | type 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. |
| 152 | var 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. |
| 165 | var 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. |
| 168 | func 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. |
| 178 | func (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. |
| 203 | type errorView struct { |
| 204 | Title string |
| 205 | Error chrome.ErrorPage |
| 206 | } |
| 207 | |
| 208 | // notFound is the 404 for an unknown route, on either origin. |
| 209 | func (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. |
| 216 | func (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. |
| 227 | func (a *App) serverError(w http.ResponseWriter, r *http.Request) { |
| 228 | a.errorPage(w, r, http.StatusInternalServerError) |
| 229 | } |
| 230 | |