| 1 | package memoria |
| 2 | |
| 3 | // The shared chrome (estilo chrome, round 2): every page's bar, account |
| 4 | // menu and <title> are built here, once, so no page can drift from |
| 5 | // another. |
| 6 | // |
| 7 | // Before this the header was rastrillo's rst-page-header with a hand-made |
| 8 | // nav: at 1440px it wrapped onto two rows (97px), the Help dropdown was a |
| 9 | // block inside an inline nav and its open panel covered Sources and Sign |
| 10 | // out, Sign out was a button beside navigation, and nothing said who was |
| 11 | // signed in. Titles mixed "—" and "·". |
| 12 | // |
| 13 | // Spec: estilo spec/shared-chrome, docs/superpowers/specs/ |
| 14 | // 2026-09-23-shared-chrome.md, §5 (bar), §6 (switcher), §8 (account), §10 |
| 15 | // (titles), §12.1 (Memoria's row). Memoria follows Docs, Sheets and Slides. |
| 16 | |
| 17 | import ( |
| 18 | "errors" |
| 19 | "net/http" |
| 20 | "net/url" |
| 21 | "strings" |
| 22 | |
| 23 | "amadan.net/oficina/estilo.git/chrome" |
| 24 | estilomenu "amadan.net/oficina/estilo.git/menu" |
| 25 | "amadan.net/oficina/home/connect" |
| 26 | "amadan.net/oficina/home/connect/linkstore" |
| 27 | "amadan.net/rastrillo/rastrillo/sessions" |
| 28 | ) |
| 29 | |
| 30 | // appName is the brand and the last part of every <title>. |
| 31 | const appName = "Memoria" |
| 32 | |
| 33 | // Frame is what layout.html reads from every page: the bar, the parts of |
| 34 | // the <title> before the app's name, the Appearance preference for |
| 35 | // <html data-theme>, and whether anybody is signed in (the footer's |
| 36 | // passage count and egress claim are said only to someone who can |
| 37 | // search). |
| 38 | type Frame struct { |
| 39 | Chrome chrome.Bar |
| 40 | TitleParts []string |
| 41 | Theme string |
| 42 | Signed bool |
| 43 | } |
| 44 | |
| 45 | // frame describes one page's chrome. The zero value is a page whose |
| 46 | // <title> is just "Memoria". |
| 47 | type frame struct { |
| 48 | // parts precede "Memoria" in <title>, most specific first. The first |
| 49 | // is the page's h1 (spec §10). |
| 50 | parts []string |
| 51 | // context is the open document's title, shown in the bar. |
| 52 | context string |
| 53 | // signinPage is one of the sign-in, sent and signed-out pages: the |
| 54 | // corner is empty, and the brand is plain text, because everything it |
| 55 | // could link to is behind the session gate and would bounce straight |
| 56 | // back. |
| 57 | signinPage bool |
| 58 | // ret, when set, is where Appearance returns to instead of this |
| 59 | // request's own path and query. |
| 60 | ret string |
| 61 | } |
| 62 | |
| 63 | // titled is the frame for a page whose <title> is these parts. |
| 64 | func titled(parts ...string) frame { return frame{parts: parts} } |
| 65 | |
| 66 | // viewer is who a page's chrome is for. |
| 67 | type viewer struct { |
| 68 | address string |
| 69 | method string |
| 70 | ok bool |
| 71 | } |
| 72 | |
| 73 | // admission is what a request's session says about its person. |
| 74 | type admission int |
| 75 | |
| 76 | const ( |
| 77 | // signedOut: no session at all. |
| 78 | signedOut admission = iota |
| 79 | // admitted: a session whose person may use this instance. |
| 80 | admitted |
| 81 | // removed: a session whose person was resolved and is NOT admitted — |
| 82 | // taken off the owners list after signing in. The session is ended and |
| 83 | // the person told so (offboard), rather than being 404'd on every page |
| 84 | // or bounced between /signin and a page that refuses them. |
| 85 | removed |
| 86 | // unresolved: a session that could not be resolved to a person (a Home |
| 87 | // subject whose lookup failed). Not signed out, not removed: nothing is |
| 88 | // ended on a lookup that may be transient. |
| 89 | unresolved |
| 90 | ) |
| 91 | |
| 92 | // admissionOf reads the request's session without requiring one, and |
| 93 | // without side effects. It is the one place a page learns who is signed |
| 94 | // in outside the session gate (error pages, the sign-in page's shortcut, |
| 95 | // Appearance), and it runs the SAME predicate the gate does: Admit. |
| 96 | func (a *app) admissionOf(r *http.Request) (viewer, admission) { |
| 97 | if a.allowUnauthenticated { |
| 98 | // AppForTest: the stub identity is signed in and admitted. |
| 99 | if a.testIdentity == "" { |
| 100 | return viewer{}, signedOut |
| 101 | } |
| 102 | return viewer{address: a.testIdentity, method: "magiclink", ok: true}, admitted |
| 103 | } |
| 104 | s, ok := sessions.Current(r) |
| 105 | if !ok && a.sess != nil { |
| 106 | s, ok = a.sess.From(r) |
| 107 | } |
| 108 | if !ok || s.Subject == "" { |
| 109 | return viewer{}, signedOut |
| 110 | } |
| 111 | switch s.Method { |
| 112 | case connect.HomeMethod: |
| 113 | if a.home == nil { |
| 114 | return viewer{}, unresolved |
| 115 | } |
| 116 | person, err := a.home.bridge.Resolve(r.Context(), s.Subject) |
| 117 | if errors.Is(err, linkstore.ErrRefused) { |
| 118 | // The link store refuses a subject whose linked address Admit |
| 119 | // no longer admits (lookupHomeLocalIdentity answers ErrNoRows |
| 120 | // for it), or that has no link at all: either way the session |
| 121 | // speaks for nobody this instance admits. Removed. A storage |
| 122 | // failure (ErrStorage) may be transient, and ends nothing. |
| 123 | return viewer{}, removed |
| 124 | } |
| 125 | if err != nil { |
| 126 | return viewer{}, unresolved |
| 127 | } |
| 128 | if !a.Admit(person.Label) { |
| 129 | return viewer{}, removed |
| 130 | } |
| 131 | return viewer{address: normaliseAddress(person.Label), method: s.Method, ok: true}, admitted |
| 132 | case "magiclink", "keymail": |
| 133 | if !a.Admit(s.Subject) { |
| 134 | return viewer{}, removed |
| 135 | } |
| 136 | return viewer{address: normaliseAddress(s.Subject), method: s.Method, ok: true}, admitted |
| 137 | } |
| 138 | // Any other method is not a sign-in memoria recognises (identity says |
| 139 | // the same), and it is treated as nobody. |
| 140 | return viewer{}, signedOut |
| 141 | } |
| 142 | |
| 143 | // offboard ends the session of a person who is no longer admitted and |
| 144 | // sends a page request to the sign-in page, which says so. Anything else |
| 145 | // gets the 404 the gate has always answered. |
| 146 | func (a *app) offboard(w http.ResponseWriter, r *http.Request) { |
| 147 | if a.sess != nil { |
| 148 | a.sess.SignOut(w, r) |
| 149 | } |
| 150 | w.Header().Set("Cache-Control", "no-store") |
| 151 | if r.Method == http.MethodGet || r.Method == http.MethodHead { |
| 152 | http.Redirect(w, r, signinPath+"?err=removed", http.StatusSeeOther) |
| 153 | return |
| 154 | } |
| 155 | a.errorPage(w, r, http.StatusNotFound, kindRoute, "") |
| 156 | } |
| 157 | |
| 158 | // frameFor builds a page's Frame for the request's viewer. |
| 159 | func (a *app) frameFor(r *http.Request, f frame) Frame { |
| 160 | var v viewer |
| 161 | if !f.signinPage { |
| 162 | v, _ = a.admissionOf(r) |
| 163 | } |
| 164 | return a.frameAs(r, v, f) |
| 165 | } |
| 166 | |
| 167 | // frameAs builds a page's Frame for viewer v. |
| 168 | func (a *app) frameAs(r *http.Request, v viewer, f frame) Frame { |
| 169 | bar := chrome.Bar{ |
| 170 | App: chrome.App{ |
| 171 | Name: appName, |
| 172 | Slug: "memoria", |
| 173 | Home: "/", |
| 174 | Icon: assets.Path("static/brand/icon.svg"), |
| 175 | }, |
| 176 | Context: f.context, |
| 177 | Labels: chrome.BarLabels{Skip: "Skip to content", Menu: "Menu"}, |
| 178 | } |
| 179 | switch { |
| 180 | case f.signinPage: |
| 181 | bar.App.Home = "" |
| 182 | case v.ok: |
| 183 | bar.Account = a.account(r, v, f.ret) |
| 184 | bar.Switcher = a.switcher() |
| 185 | bar.Nav, bar.NavLabel = a.nav(r, v), "Memoria" |
| 186 | default: |
| 187 | bar.SignIn = &chrome.Link{Href: signinPath + "?return_to=" + url.QueryEscape(returnTarget(r)), Label: "Sign in"} |
| 188 | } |
| 189 | return Frame{ |
| 190 | Chrome: bar, |
| 191 | TitleParts: f.parts, |
| 192 | Theme: chrome.ThemeAttr(chrome.Appearance(r)), |
| 193 | Signed: v.ok, |
| 194 | } |
| 195 | } |
| 196 | |
| 197 | // nav is the bar's section links. Sources is rendered only for owners: |
| 198 | // /connectors is behind requireOwner, which 404s everyone else on purpose, |
| 199 | // so a non-owner must never be shown the link (spec §12.1, Memoria). |
| 200 | func (a *app) nav(r *http.Request, v viewer) []chrome.Link { |
| 201 | links := []chrome.Link{{Href: "/", Label: "Search", Current: r.URL.Path == "/"}} |
| 202 | if a.isOwner(v.address) { |
| 203 | links = append(links, chrome.Link{Href: "/connectors", Label: "Sources", Current: r.URL.Path == "/connectors" || strings.HasPrefix(r.URL.Path, "/connectors/")}) |
| 204 | } |
| 205 | return links |
| 206 | } |
| 207 | |
| 208 | // returnTarget is where a person should come back to: this page, if it is |
| 209 | // a page (a GET), otherwise the index. |
| 210 | func returnTarget(r *http.Request) string { |
| 211 | if r.Method != http.MethodGet && r.Method != http.MethodHead { |
| 212 | return "/" |
| 213 | } |
| 214 | return r.URL.RequestURI() |
| 215 | } |
| 216 | |
| 217 | // account is the account menu (spec §8.2): who is signed in, "Connect your |
| 218 | // Oficina account" when that means something, Appearance, Help (Open |
| 219 | // source, last), and the one Sign out in the app. |
| 220 | func (a *app) account(r *http.Request, v viewer, ret string) *chrome.Account { |
| 221 | if ret == "" { |
| 222 | ret = returnTarget(r) |
| 223 | } |
| 224 | acc := &chrome.Account{ |
| 225 | TriggerLabel: "Account", |
| 226 | Initials: chrome.Initials("", v.address), |
| 227 | Address: v.address, |
| 228 | Labels: chrome.AccountLabels{SignedInAs: "Signed in as", Appearance: "Appearance", Help: "Help"}, |
| 229 | Appearance: &chrome.AppearanceMenu{ |
| 230 | Action: "/appearance", |
| 231 | Return: ret, |
| 232 | Options: chrome.AppearanceOptions(chrome.Appearance(r), "Light", "Dark", "System"), |
| 233 | }, |
| 234 | Help: []estilomenu.Item{{Href: "/help/open-source", Label: "Open source"}}, |
| 235 | SignOut: chrome.SignOut{Action: "/signout", Label: "Sign out"}, |
| 236 | } |
| 237 | if v.address != "" { |
| 238 | acc.TriggerLabel = "Account: " + v.address |
| 239 | } |
| 240 | if a.offersHomeLink(v) { |
| 241 | acc.Items = []estilomenu.Item{{Href: "/account/home", Label: "Connect your Oficina account"}} |
| 242 | } |
| 243 | return acc |
| 244 | } |
| 245 | |
| 246 | // offersHomeLink is whether the account menu offers to connect the |
| 247 | // person's Oficina account: only when Home is configured, and never to |
| 248 | // somebody who signed in through Home, whose account already is the |
| 249 | // connection (spec §12.1 item 4). |
| 250 | func (a *app) offersHomeLink(v viewer) bool { |
| 251 | return a.home != nil && v.ok && (v.method == "magiclink" || v.method == "keymail") |
| 252 | } |
| 253 | |
| 254 | // switcher is the app switcher, or nil when no hub directory is |
| 255 | // configured. It shows "All apps" alone and no tiles (Paul's §13 answer 2): |
| 256 | // a tile today still costs an assertion exchange through the directory |
| 257 | // session. |
| 258 | func (a *app) switcher() *chrome.Switcher { |
| 259 | if a.home == nil || a.directory == "" { |
| 260 | return nil |
| 261 | } |
| 262 | return &chrome.Switcher{ |
| 263 | Label: "Oficina apps", |
| 264 | CurrentLabel: "(this app)", |
| 265 | AllApps: estilomenu.Item{Href: a.directory + "/", Label: "All apps"}, |
| 266 | } |
| 267 | } |
| 268 | |