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
3import (
4 "bytes"
5 "embed"
6 "html/template"
7 "net/http"
8 "strings"
9 "time"
10
11 "amadan.net/oficina/estilo.git/chrome"
12 "amadan.net/oficina/estilo.git/credits"
13 estilomenu "amadan.net/oficina/estilo.git/menu"
14 "amadan.net/oficina/home/connect"
15 "amadan.net/rastrillo/rastrillo"
16 "amadan.net/rastrillo/rastrillo/ui"
17
18 "amadan.net/oficina/memoria/internal/memoria/icons"
19)
20
21//go:embed templates static
22var appFS embed.FS
23
24// assets fingerprints static/: {{asset "static/tokens.css"}} in a
25// template returns a content-hashed URL, served immutable-cacheable by
26// the handler App mounts.
27var assets = rastrillo.NewAssets(appFS)
28
29// pages is one *template.Template per page, each combining layout.html
30// with that page's own file — kept separate so every page can define a
31// template named "content" without colliding with the others.
32var pages = map[string]*template.Template{}
33
34// fragments holds the templates that are also served on their own, with
35// no layout around them. results.html is parsed into both this and every
36// page, so the region search.js swaps in on a keystroke and the region a
37// scriptless form submit renders are the same markup from the same file.
38var fragments *template.Template
39
40// funcs is shared by both sets. Two call sites, one map: a helper added
41// for a page and forgotten for the fragment would fail at execute time,
42// on the request, rather than at parse time.
43func funcs() template.FuncMap {
44 return template.FuncMap{
45 "asset": assets.Path,
46 // The app's own icon set (internal/memoria/icons), scaffolded
47 // by rastrillo new. iconAssets is whatever <head> markup the
48 // chosen delivery needs — empty for the vendored-inline
49 // default, so the layout calls it unconditionally and
50 // switching delivery later needs no template edit.
51 "icon": icons.Icon,
52 "iconAssets": icons.Assets,
53 "safeSnippet": safeSnippet,
54 "tone": tone,
55 }
56}
57
58func init() {
59 // Every page is the layout, the results region (the index and the
60 // fragment share it), the page's own file, and the suite's chrome:
61 // estilo's bar, account menu, switcher, error body and sign-in door
62 // (chrome), their menu rows (menu), and the rastrillo partials the door
63 // is built from (ui). ui.Funcs goes first and funcs() over it, so icon
64 // and iconAssets stay this app's own set rather than reverting to
65 // rastrillo's built-in one (ui.FuncsWith's documented trap).
66 for _, name := range []string{"index", "add", "document", "delete", "connectors", "notion", "home", "credits", "signin", "signin_oauth", "error"} {
67 pages[name] = template.Must(template.Must(template.Must(template.Must(template.New("layout").
68 Funcs(ui.Funcs()).Funcs(funcs()).
69 ParseFS(appFS, "templates/layout.html", "templates/results.html", "templates/"+name+".html")).
70 ParseFS(ui.Templates(), "*.html")).
71 ParseFS(chrome.Templates(), "templates/*.html")).
72 ParseFS(estilomenu.Templates(), "templates/*.html"))
73 }
74 pages["credits"] = template.Must(pages["credits"].ParseFS(credits.Templates(), "templates/credits.html"))
75 pages["home"] = template.Must(pages["home"].ParseFS(connect.Templates(), "templates/home_link.html"))
76 pages["signin_oauth"] = template.Must(pages["signin_oauth"].ParseFS(connect.Templates(), "templates/oauth.html"))
77 fragments = template.Must(template.New("fragments").
78 Funcs(funcs()).
79 ParseFS(appFS, "templates/results.html"))
80}
81
82// pageFor is the page data every layout needs, for this request: the
83// chrome (Frame), and the footer's passage count and AI state. The count
84// is read only for somebody signed in, because only they are told it.
85func (a *app) pageFor(r *http.Request, f frame) page {
86 p := page{Frame: a.frameFor(r, f), ModelOn: a.modelOn()}
87 if p.Frame.Signed {
88 p.Chunks = a.chunkCount()
89 }
90 return p
91}
92
93// render executes name's layout into a buffer before anything touches
94// the wire: a template error becomes a clean 500 instead of garbage
95// appended to a half-written page, and headers (flash cookies, say)
96// stay settable until the status line is written.
97func render(w http.ResponseWriter, name string, data any) {
98 renderStatus(w, name, data, http.StatusOK)
99}
100
101// renderStatus is render with a status other than 200: the refused door
102// is a 403 (app.refused).
103func renderStatus(w http.ResponseWriter, name string, data any, status int) {
104 var buf bytes.Buffer
105 if err := pages[name].ExecuteTemplate(&buf, "layout", data); err != nil {
106 http.Error(w, "something went wrong", http.StatusInternalServerError)
107 return
108 }
109 if status != http.StatusOK {
110 w.Header().Set("Content-Type", "text/html; charset=utf-8")
111 w.WriteHeader(status)
112 }
113 buf.WriteTo(w)
114}
115
116// renderFragment executes one template with no layout around it, for the
117// endpoints a script calls rather than a person navigates to.
118//
119// Buffered for the same reason render is: a template error becomes a
120// clean 500 rather than half a list of results, and a half-swapped
121// results region is the plausible-empty-list failure wearing a hat.
122func renderFragment(w http.ResponseWriter, name string, data any) {
123 var buf bytes.Buffer
124 if err := fragments.ExecuteTemplate(&buf, name, data); err != nil {
125 http.Error(w, "something went wrong", http.StatusInternalServerError)
126 return
127 }
128 w.Header().Set("Content-Type", "text/html; charset=utf-8")
129 buf.WriteTo(w)
130}
131
132// safeSnippet renders an FTS5 snippet, which arrives as document text with
133// <mark> tags inserted around the matched terms.
134//
135// The text around those tags is content someone stored, so it cannot be
136// trusted — a document containing "<script>" must not execute in a search
137// result. Everything is therefore escaped first and exactly two tags are
138// re-admitted afterwards. Doing it the other way round (trusting the
139// snippet and escaping selectively) is the version that ships an XSS.
140func safeSnippet(s string) template.HTML {
141 escaped := template.HTMLEscapeString(s)
142 escaped = strings.ReplaceAll(escaped, "&lt;mark&gt;", "<mark>")
143 escaped = strings.ReplaceAll(escaped, "&lt;/mark&gt;", "</mark>")
144 return template.HTML(escaped)
145}
146
147// tone maps a flash kind to the design system's tone vocabulary.
148//
149// **The tone attribute is `rst-tone`, and getting here took three tries.**
150// tokens.css pairs every kind: a class spelling and an attribute spelling,
151// weighing the same. For a callout's tone those are
152// `.rst-callout[data-tone="negative"]` and
153// `[rst-callout][rst-tone~="negative"]` — and the header says so directly:
154// *"rst-tone carries the four tones that data-tone carries today."*
155//
156// Attempt one was `rst-callout="danger"`: a value on the kind attribute,
157// which nothing selects. Attempt two was `[rst-callout] data-tone=...`:
158// half of one pair and half of the other, matching neither, and its tests
159// passed because the string `data-tone="negative"` does occur in the
160// stylesheet — inside the *class* selector standing next to the one that
161// was needed. Both rendered every flash in the product as an untoned
162// callout: the right box, the wrong colour, on every error a reader is
163// meant to act on.
164//
165// The lesson is not about tones. **Grepping for a string is not reading a
166// selector**, and a check that greps will confirm any spelling that
167// appears anywhere in the file.
168//
169// The default is deliberately the loud one. A flash whose kind nobody
170// recognised is more likely to be an error than a compliment, and an
171// unstyled warning is a worse failure than an over-coloured success.
172func tone(kind string) string {
173 switch kind {
174 case "good":
175 return "positive"
176 case "warn":
177 return "warning"
178 default:
179 return "negative"
180 }
181}
182
183// stamp is how a page prints a stored time: converted to UTC and saying
184// so. Since rastrillo fb806a2 the driver hands every stored time back in
185// UTC, where before it came back in the host's zone, so an unlabelled
186// clock reading changed meaning on any host not in UTC. The sources page
187// already said UTC; now every stored time does (TestStampSaysUTC).
188func stamp(t time.Time) string { return t.UTC().Format("2 Jan 2006, 15:04 UTC") }
189