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 "context"
5 "log/slog"
6 "net/http"
7 "net/url"
8 "strings"
9
10 "amadan.net/oficina/estilo.git/chrome"
11 "amadan.net/oficina/estilo.git/sky"
12 "amadan.net/oficina/estilo.git/theme"
13
14 "github.com/go-chi/chi/v5"
15
16 "amadan.net/oficina/home/connect"
17 "amadan.net/rastrillo/rastrillo/auth"
18 "amadan.net/rastrillo/rastrillo/db"
19 "amadan.net/rastrillo/rastrillo/migrate"
20 "amadan.net/rastrillo/rastrillo/sessions"
21
22 "amadan.net/oficina/memoria/internal/memoria/index"
23 "amadan.net/oficina/memoria/internal/memoria/seal"
24)
25
26// App wires the whole app: schema, sealing, index, router, static files.
27//
28// The order matters. Migrations first, then the index is filled from the
29// sealed rows — the index lives only in memory (see internal/memoria/index
30// for why), so a boot that skipped the rebuild would serve an empty
31// corpus and call it "nothing matched".
32func App(d *db.DB, origin string, sealer *seal.Sealer, logger *slog.Logger) (*http.ServeMux, *app, error) {
33 migrated, err := migrate.Apply(context.Background(), d, BootSchema)
34 if err != nil {
35 return nil, nil, err
36 }
37 logger.Info("migrate", "applied", migrated.Applied,
38 "skipped", migrated.Skipped, "adopted", migrated.Adopted)
39
40 ix, err := index.Open()
41 if err != nil {
42 return nil, nil, err
43 }
44
45 // Custody starts nil and stays nil until UseSiblings supplies one.
46 // nil withholds every sibling passage rather than rendering it, so
47 // the un-wired state is the closed one — slice 0 stores nothing owned
48 // by anybody else, which makes nil correct rather than a gap. See
49 // Store.custody, and UseSiblings for why this is a setter.
50 store := NewStore(d, sealer, ix, nil)
51 indexed, err := store.Rebuild(context.Background())
52 if err != nil {
53 // A failed rebuild is fatal, not a warning. Serving with a
54 // partly-filled index means every search silently under-reports,
55 // which is the plausible-empty-list failure this codebase treats
56 // as the worst available.
57 return nil, nil, err
58 }
59 logger.Info("index rebuilt", "passages", indexed)
60
61 // Reclaim runs that were in flight when the process last stopped.
62 //
63 // Sound by construction rather than by timeout: a process that has
64 // just started cannot be running a sync, so anything still marked
65 // in flight was killed by the platform sleeping the app. Without
66 // this the connector wedges permanently on its NORMAL path — the
67 // goroutine dies without writing, the row stays "fetching", and
68 // every later sync is refused as already running.
69 reclaimed, err := store.ReclaimRuns(context.Background())
70 if err != nil {
71 return nil, nil, err
72 }
73 if reclaimed > 0 {
74 logger.Info("reclaimed interrupted syncs", "runs", reclaimed)
75 }
76
77 a := &app{db: d, logger: logger, store: store, syncer: NewSyncer(store, logger), origin: origin}
78
79 if err := a.wireNative(origin); err != nil {
80 return nil, nil, err
81 }
82 r := chi.NewRouter()
83 // Outermost, so a panic anywhere in the router is the 500 page with a
84 // reference. The page reads the session itself (admissionOf), so it
85 // shows the viewer's own chrome however deep the panic was.
86 r.Use(a.recoverToErrorPage)
87 r.Use(a.native.Resume)
88 r.Use(a.homeMiddleware(origin))
89 // Unknown routes, and a GET of a POST-only path, are the 404 page (a
90 // browser) or plain text (anything else). They were Go's plain
91 // "404 page not found" in the browser's monospace.
92 r.NotFound(a.routeNotFound)
93 r.MethodNotAllowed(a.methodNotAllowed(r))
94
95 r.Post("/native/request", a.native.ServeHTTP)
96 r.Post("/native/exchange", a.native.ServeHTTP)
97 r.Get("/native/start", a.native.ServeHTTP)
98 // The approval page is the native kit's, styled by one inline
99 // <style>: allowed by its hash on this route alone (native.go).
100 r.Get("/native/authorize", nativeApprovalCSP(a.native.ServeHTTP))
101 r.Post("/native/authorize", nativeApprovalCSP(a.native.ServeHTTP))
102
103 // Appearance (estilo chrome §8.3): inside the app-wide CSRF check
104 // (homeMiddleware) and OUTSIDE the session gate, so a session that
105 // expired mid-page cannot turn the click into a sign-in loop. It sets
106 // one host-only preference cookie and redirects to a SafeReturn path.
107 r.Post("/appearance", chrome.HandleAppearance)
108
109 // Public: the sign-in page and auth's own callbacks.
110 r.Get("/signin", a.signin)
111 r.Get("/signin/local", func(w http.ResponseWriter, r *http.Request) {
112 if a.home == nil {
113 a.routeNotFound(w, r)
114 return
115 }
116 a.localSignin(w, r)
117 })
118 // Where Sign out lands, standalone and with Home alike.
119 r.Get("/signed-out", a.signedOut)
120 // The Open source page is public: the sign-in page links to it (spec
121 // §8.4), and it says nothing about the team. It still shows the
122 // viewer's own chrome when somebody is signed in.
123 r.Get("/help/open-source", a.credits)
124 r.With(a.requireSession).Get("/account/home", func(w http.ResponseWriter, r *http.Request) {
125 if a.home == nil {
126 a.routeNotFound(w, r)
127 return
128 }
129 a.homeAccount(w, r)
130 })
131 r.With(a.requireSession).Post("/account/home", func(w http.ResponseWriter, r *http.Request) {
132 if a.home == nil {
133 a.routeNotFound(w, r)
134 return
135 }
136 a.home.routes.LinkStart(w, r)
137 })
138 r.With(a.requireSession).Get("/account/home/confirm", func(w http.ResponseWriter, r *http.Request) {
139 if a.home == nil {
140 a.routeNotFound(w, r)
141 return
142 }
143 a.home.bridge.ConfirmPage(w, r)
144 })
145 r.With(a.requireSession).Post("/account/home/confirm", func(w http.ResponseWriter, r *http.Request) {
146 if a.home == nil {
147 a.routeNotFound(w, r)
148 return
149 }
150 a.home.routes.ConfirmPost(w, r)
151 })
152
153 // Everything else requires a session. memoria holds the team's work
154 // in one place, so there is no anonymous surface at all — not even a
155 // landing page. §4.3: admission is not authorization, but it is the
156 // door.
157 r.Group(func(r chi.Router) {
158 r.Use(a.requireSession)
159 r.Get("/api/native/session", a.nativeSession)
160 r.Post("/api/native/search", a.nativeSearch)
161 r.Post("/api/native/notes", a.nativeNote)
162 r.Get("/", a.index)
163 r.Get("/add", a.addContent)
164 r.Get("/search", a.searchResults)
165 r.Post("/documents", a.createDocument)
166 r.Get("/documents/{id}", a.showDocument)
167 r.Get("/documents/{id}/delete", a.confirmDelete)
168 r.Post("/documents/{id}/delete", a.deleteDocument)
169
170 // Connector routes sit INSIDE this group, so they inherit
171 // csrf.Protect and requireSession. A route mounted on the outer
172 // mux (as /static/ is) would bypass both.
173 r.Group(func(r chi.Router) {
174 r.Use(a.requireOwner)
175 r.Get("/connectors", a.connectors)
176 r.Get("/connectors/notion", a.notionConnector)
177 r.Post("/connectors/notion", a.saveNotionConnector)
178 r.Post("/connectors/notion/sync", a.startNotionSync)
179 r.Post("/connectors/notion/disconnect", a.disconnectNotion)
180 })
181 })
182
183 mux := http.NewServeMux()
184 mux.HandleFunc("GET /static/credits.css", serveCreditsCSS)
185 mux.Handle("GET /static/", assets.Handler())
186 // Onest, Calma's typeface, served by estilo under its versioned
187 // prefix. A literal, so the route gate can read it;
188 // TestFontRouteIsEstilosPrefix keeps it equal to theme.FontPrefix.
189 mux.Handle("GET /static/estilo/fonts/v1/", theme.FontHandler())
190 // The sign-in door's sky (estilo sky), under its versioned prefix:
191 // public artwork, served before any session gate like the font. A
192 // literal for the same reason; TestSkyRouteIsEstilosPrefix keeps it
193 // equal to sky.Prefix.
194 mux.Handle("GET /static/estilo/sky/v1/", sky.Handler())
195 mux.Handle("/", r)
196 return mux, a, nil
197}
198
199// MountAuth adds auth's routes. Separate from App because auth needs the
200// admission gate and App builds the router; the caller wires both.
201func (a *app) MountAuth(mux *http.ServeMux, origin string) {
202 r := chi.NewRouter()
203 r.Use(a.homeMiddleware(origin))
204 // Guarded, not bare: the classifier inside Begin dials the submitted
205 // address's domain before Authorize runs. beginGuard says why. And
206 // continued, not redirected: signinBegin says why.
207 r.Post("/auth/begin", a.signinBegin(origin, a.auth.Begin))
208 // Verify answers GET with the confirm door (renderConfirm), which
209 // spends nothing, and POST with the redemption, so it is mounted for
210 // both methods in both modes: without the POST the door's Sign in
211 // button gets a 405 and nobody signs in. The path is baked into every
212 // emailed link. A verified address that is not admitted is answered
213 // by auth's Refused hook (app.refused), inside Callback and Verify.
214 if a.home == nil {
215 r.Get("/auth/callback", a.auth.Callback)
216 r.Get("/auth/verify", a.auth.Verify)
217 r.Post("/auth/verify", a.auth.Verify)
218 // Where a verified link lands (NewAuth's SignedInPath), and on to
219 // the page the person was heading for (return.go).
220 r.Get("/auth/landed", a.landed)
221 r.Post("/signout", signedOutAfter(a.auth.Signout))
222 } else {
223 r.Get("/auth/callback", a.home.routes.LocalGuard(a.auth.Callback))
224 r.Get("/auth/verify", a.home.routes.LocalGuard(a.auth.Verify))
225 r.Post("/auth/verify", a.home.routes.LocalGuard(a.auth.Verify))
226 // Chains to id.*'s /signout (a 307), which ends the Oficina
227 // session too and returns the browser to /signed-out (§9.5).
228 r.Post("/signout", a.home.routes.Signout)
229 r.Get("/auth/start", a.home.bridge.Start)
230 r.Post("/auth/assertion", a.home.bridge.Callback)
231 r.Get("/auth/finish", a.home.bridge.Finish)
232 r.Get("/auth/local/complete", a.landedAfter(a.home.routes.LocalComplete))
233 }
234 mux.Handle("/auth/", r)
235 mux.Handle("POST /signout", r)
236}
237
238// requireSession is auth.RequireSession, and it FAILS CLOSED.
239//
240// An earlier version fell through when auth was nil, so that tests could
241// reach the routes without a mailer and a magic-link round trip. Sheets
242// carries that seam and it is defensible there. Here it was not, for a
243// reason a code review found: the safety argument rested on a test named
244// TestProductionRefusesToStartWithoutSignin, and **that test did not
245// exist** — the name survived a rename and lived on only in comments. So
246// deleting main.go's UseAuth call compiled, left the suite green, and
247// served every route unauthenticated.
248//
249// A seam whose guard is imaginary is a hole. Tests now build the app
250// through AppForTest, which says what it is doing in its name.
251func (a *app) requireSession(next http.Handler) http.Handler {
252 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
253 if a.allowUnauthenticated {
254 next.ServeHTTP(w, r)
255 return
256 }
257 if a.auth == nil {
258 a.logger.Error("no sign-in configured; refusing every request")
259 http.Error(w, "sign-in is not configured", http.StatusInternalServerError)
260 return
261 }
262 // A signed-out page request goes to /signin WITH the page it asked
263 // for (spec §9.4): rastrillo's own refusal sends a bare /signin, so
264 // a document link opened signed out landed on the index. Anything
265 // else keeps rastrillo's answer below (a POST still gets its 403).
266 // Both modes: with Home, connect's Signin keeps a safe return_to
267 // through the exchange (feat/home-consumer added the Home half;
268 // a.sess reads the same cookie and table as Home's sessions).
269 if r.Method == http.MethodGet || r.Method == http.MethodHead {
270 if _, ok := a.sess.From(r); !ok {
271 w.Header().Set("Cache-Control", "no-store")
272 http.Redirect(w, r, signinPath+"?return_to="+url.QueryEscape(r.URL.RequestURI()), http.StatusSeeOther)
273 return
274 }
275 }
276 guarded := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
277 address, ok := a.identity(r)
278 if !ok || !a.Admit(address) {
279 // Still closed. What changes is what a REMOVED person is
280 // told: their session is ended and the sign-in page says
281 // why, where they used to meet a 404 on every page for
282 // thirty days.
283 if _, st := a.admissionOf(r); st == removed {
284 a.offboard(w, r)
285 return
286 }
287 a.errorPage(w, r, http.StatusNotFound, kindRoute, "")
288 return
289 }
290 next.ServeHTTP(w, r)
291 })
292 a.auth.RequireSession(guarded).ServeHTTP(w, r)
293 })
294}
295
296// requireOwner guards the connector routes.
297//
298// "Owner" is Config.Owners, and IN SLICE 0 THAT IS THE ENTIRE ADMITTED
299// SET — MEMORIA_OWNERS is the admission list, so this is currently
300// equivalent to requireSession. It is written as its own middleware
301// anyway, and this comment says the equivalence out loud, because an
302// earlier draft described the connector pages as "owners only" as though
303// that were a restriction. Naming a gate for a distinction the app does
304// not have is how a guard that guards nothing gets shipped.
305//
306// It fails CLOSED: no identity, or an empty owner list, refuses.
307//
308// Slice 1 replaces the owners list with S7 and there will be no list to
309// check. These routes then need an owner predicate from wherever
310// membership lives, or they lose their guard — recorded in the spec's
311// open items, not left to be discovered.
312func (a *app) requireOwner(next http.Handler) http.Handler {
313 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
314 addr, ok := a.identity(r)
315 if !ok || !a.isOwner(addr) {
316 // 404, not 403: AGENTS.md's rule, and a 403 would confirm
317 // the route exists to someone who may not use it. The same
318 // page as an unknown address, so it confirms nothing either.
319 a.errorPage(w, r, http.StatusNotFound, kindRoute, "")
320 return
321 }
322 next.ServeHTTP(w, r)
323 })
324}
325
326// identity is the signed-in address, or false.
327func (a *app) identity(r *http.Request) (string, bool) {
328 if a.testIdentity != "" {
329 return a.testIdentity, true
330 }
331 if s, ok := sessions.Current(r); ok && s.Method == connect.HomeMethod {
332 if a.home == nil {
333 return "", false
334 }
335 person, err := a.home.bridge.Resolve(r.Context(), s.Subject)
336 if err != nil {
337 return "", false
338 }
339 return person.Label, a.Admit(person.Label)
340 }
341 if s, ok := sessions.Current(r); ok && s.Method != "magiclink" && s.Method != "keymail" {
342 return "", false
343 }
344 id, ok := auth.From(r)
345 if !ok {
346 return "", false
347 }
348 return strings.ToLower(strings.TrimSpace(id.Address)), true
349}
350
351func (a *app) isOwner(addr string) bool {
352 if len(a.owners) == 0 {
353 return false
354 }
355 for _, o := range a.owners {
356 if o == addr {
357 return true
358 }
359 }
360 return false
361}
362
363// UseSiblings supplies the apps of record memoria may ask about
364// passages. Called by main from Config.
365//
366// A setter, in the shape UseAuth already has here, and with the same
367// hazard: production can forget to call it. What makes that survivable is
368// which way the omission fails — a store with no custody withholds every
369// sibling passage and says so on the page, where UseAuth's omission
370// served every route unauthenticated. The failure is loud and visible to
371// the first reader, not silent.
372func (a *app) UseSiblings(c Custody) { a.store.custody = c }
373
374// WarmSiblings starts §6.5's wake poll and returns immediately.
375//
376// **Asynchronous on purpose, and gate 24 is written around that.** A
377// synchronous warm-up would make the first request wait for three sibling
378// wakes, which puts the latency back on the reader and undoes the whole
379// point; an assertion that it finishes "before the first query" is either
380// that, or a race between goroutines. So the gate asserts UNPROMPTED
381// PRESENCE instead — every fixture is polled within a stated few seconds
382// of boot, with no query issued at all — and the claim is that the poll
383// happens on its own rather than that it wins a race.
384//
385// context.WithoutCancel: the poll outlives the request context that
386// happened to be current, because it belongs to the process's wake and
387// not to anybody's request.
388func (a *app) WarmSiblings(ctx context.Context) {
389 s, ok := a.store.custody.(*Siblings)
390 if !ok || s == nil {
391 // No siblings configured (slice 0), or a Custody that is not the
392 // production roster. Nothing to warm, and not an error.
393 return
394 }
395 go s.Warm(context.WithoutCancel(ctx), a.store, a.logger)
396}
397
398// SetOwners supplies the owner list. Called by main from Config.
399func (a *app) SetOwners(owners []string) { a.owners = owners }
400
401// Store exposes the store, for gates that must inspect it.
402func (a *app) Store() *Store { return a.store }
403
404// Syncer exposes the syncer, so a test can drive a run against a fake.
405func (a *app) Syncer() *Syncer { return a.syncer }
406
407// AsPerson sets the identity AppForTest presents, so a gate can check
408// that a non-owner is refused. Test seam only; production never sets it.
409func (a *app) AsPerson(addr string) { a.testIdentity = addr }
410
411// AppForTest builds the app with a stub session so tests can exercise the
412// routes. It is exported, obvious, and takes a testing marker so it
413// cannot be reached from production by accident: cmd/memoria does not
414// import testing.
415func AppForTest(d *db.DB, origin string, sealer *seal.Sealer, logger *slog.Logger) (*http.ServeMux, *app, error) {
416 mux, a, err := App(d, origin, sealer, logger)
417 if err != nil {
418 return nil, nil, err
419 }
420 a.allowUnauthenticated = true
421 // An identity, and an owner list containing it.
422 //
423 // AppForTest used to bypass RequireSession entirely, which left no
424 // identity in the request context — so a gate on "is this person an
425 // owner" could not have been written at all, only asserted. Found by
426 // a design review.
427 a.testIdentity = TestOwner
428 a.owners = []string{TestOwner}
429 return mux, a, nil
430}
431
432// TestOwner is the address AppForTest signs in as.
433const TestOwner = "owner@test"
434