oficina / memoria Public

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

Plain git — no account needed to clone.

Download

Download this file

1package 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
17import (
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>.
31const 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).
38type 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".
47type 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.
64func titled(parts ...string) frame { return frame{parts: parts} }
65
66// viewer is who a page's chrome is for.
67type 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.
74type admission int
75
76const (
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.
96func (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.
146func (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.
159func (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.
168func (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).
200func (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.
210func 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.
220func (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).
250func (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.
258func (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