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 "encoding/json"
5 "errors"
6 "github.com/google/uuid"
7 "io"
8 "net/http"
9 "strings"
10
11 "amadan.net/oficina/suite"
12 nativeauth "amadan.net/rastrillo/native/server"
13 "amadan.net/rastrillo/rastrillo/sessions"
14
15 // The one import left on rastrillo's pre-move path, on purpose:
16 // rastrillo/native's server (2a5d07b, and its main) still takes a
17 // github.com/carlosframework/rastrillo *sessions.Sessions. Both
18 // sessions packages read and write the same sessions table, so a
19 // session native sign-in mints is an ordinary session to the app
20 // (native_exchange_test.go). Move this to
21 // amadan.net/rastrillo/rastrillo/sessions when native/server moves.
22 presessions "github.com/carlosframework/rastrillo/sessions"
23)
24
25func (a *app) wireNative(origin string) error {
26 cfg := sessions.Config{DB: a.db.Writer(), Origin: origin, SigninPath: "/signin", Logger: a.logger}
27 s, e := sessions.New(cfg)
28 if e != nil {
29 return e
30 }
31 a.sess = s
32 // The same table, cookie and origin, through the type native/server
33 // takes. The app itself reads sessions through a.sess only.
34 pre, e := presessions.New(presessions.Config{DB: cfg.DB, Origin: cfg.Origin, SigninPath: cfg.SigninPath, Logger: cfg.Logger})
35 if e != nil {
36 return e
37 }
38 a.native = nativeauth.New(pre, origin, "Memoria", a.requireSession)
39 return nil
40}
41func nativeJSON(w http.ResponseWriter, v any) {
42 w.Header().Set("Content-Type", "application/json")
43 w.Header().Set("Cache-Control", "no-store")
44 _ = json.NewEncoder(w).Encode(v)
45}
46func (a *app) nativeSession(w http.ResponseWriter, r *http.Request) {
47 nativeJSON(w, map[string]string{"app": "memoria"})
48}
49func (a *app) nativeSearch(w http.ResponseWriter, r *http.Request) {
50 address, ok := a.identity(r)
51 if !ok || address == "" {
52 http.Error(w, "Sign in again", 401)
53 return
54 }
55 r.Body = http.MaxBytesReader(w, r.Body, 8192)
56 var in struct {
57 Query string `json:"query"`
58 }
59 d := json.NewDecoder(r.Body)
60 d.DisallowUnknownFields()
61 if d.Decode(&in) != nil || d.Decode(new(any)) != io.EOF || len(in.Query) > 2048 {
62 http.Error(w, "Use a shorter query", 400)
63 return
64 }
65 in.Query = strings.TrimSpace(in.Query)
66 if in.Query == "" {
67 http.Error(w, "Enter something to search for", 400)
68 return
69 }
70 // WithoutFacts: this response has no field for the fact box, so building
71 // one would call the model (§8.9) and send passages for an answer the
72 // native client never receives.
73 found, err := a.store.SearchWithoutFacts(r.Context(), suite.Subject{Kind: suite.SubjectAddress, Value: address}, in.Query, 25)
74 if err != nil {
75 http.Error(w, "Search is unavailable. Try again.", 503)
76 return
77 }
78 // Search resolves every hit and asks the owning app for this person's current
79 // permission. Never use Summaries or Body to bypass that decision for native UI.
80 type result struct {
81 ID int64 `json:"id"`
82 DocumentID int64 `json:"document_id"`
83 Ordinal int `json:"ordinal"`
84 Title string `json:"title"`
85 Heading string `json:"heading"`
86 Text string `json:"text"`
87 Source string `json:"source"`
88 Path string `json:"path"`
89 }
90 out := make([]result, 0, len(found.Results))
91 for _, v := range found.Results {
92 out = append(out, result{v.ChunkID, v.DocumentID, v.Ordinal, v.Title, v.Heading, strings.NewReplacer("<mark>", "", "</mark>", "").Replace(v.Snippet), v.Source, v.Path})
93 }
94 notices := found.Notices
95 if notices == nil {
96 notices = []string{}
97 }
98 nativeJSON(w, map[string]any{"results": out, "notices": notices})
99}
100
101// nativeNote creates a distinct team note, even when its title matches another.
102//
103// client_id is the note's idempotency key, minted once per draft by the
104// client. Without it the server minted a fresh key per REQUEST, so a save
105// that committed but whose response was lost (timeout, a phone asleep)
106// showed as an error, and pressing Save again made a second copy (B10).
107// With it, a replay of the same draft lands on the same (source, key)
108// and Store.Ingest's same-key-same-body path answers with the first id
109// and changes nothing. A replay whose draft was edited in between revises
110// that note rather than adding another beside it, which is the point: it
111// is still one draft.
112//
113// It must be a canonical, non-nil UUID, and it is canonicalised before use, so "A1…" and
114// "a1…" are one key and a free-form string cannot choose its own key.
115// Absent, the server mints one as before, so older clients keep working
116// — and keep the old duplicate-on-retry behaviour.
117func (a *app) nativeNote(w http.ResponseWriter, r *http.Request) {
118 r.Body = http.MaxBytesReader(w, r.Body, 3<<20)
119 var in struct {
120 Title string `json:"title"`
121 Body string `json:"body"`
122 ClientID string `json:"client_id"`
123 }
124 d := json.NewDecoder(r.Body)
125 d.DisallowUnknownFields()
126 if d.Decode(&in) != nil || d.Decode(new(any)) != io.EOF || len(in.Title) > 512 || len(in.Body) > 2<<20 || strings.TrimSpace(in.Title) == "" || strings.TrimSpace(in.Body) == "" {
127 http.Error(w, "Use a title and a note no larger than 2 MiB", 400)
128 return
129 }
130 key := uuid.NewString()
131 if in.ClientID != "" {
132 parsed, err := uuid.Parse(in.ClientID)
133 // uuid.Nil parses and is 36 characters, but it is the value a
134 // struct default or a failed decode produces: every such client
135 // would converge on one note that revises forever.
136 if err != nil || len(in.ClientID) != 36 || parsed == uuid.Nil {
137 http.Error(w, "The note's client id is not a UUID", 400)
138 return
139 }
140 key = parsed.String()
141 }
142 id, err := a.store.Ingest(r.Context(), IngestSpec{Source: "paste", Key: "native-" + key, Title: strings.TrimSpace(in.Title), Body: in.Body})
143 if err != nil && !errors.Is(err, ErrIndexLag) {
144 http.Error(w, "The note could not be confirmed", 500)
145 return
146 }
147 nativeJSON(w, map[string]any{"id": id, "indexed": err == nil})
148}
149
150// nativeApprovalStyle is the CSP source for the one <style> element on
151// rastrillo/native's approval page (/native/authorize, server auth.go's
152// approval template, at the pin in go.mod). That page is the native
153// kit's, not ours: its whole stylesheet is inline, and rastrillo
154// fb806a2's default style-src refuses it, which left the card, spacing
155// and button unstyled (Astra on #27, P2). Its stylesheet holds no
156// template action, so it is one fixed string and one hash.
157//
158// Allowed by hash, on that route only, rather than 'unsafe-inline'
159// anywhere: the hash admits exactly that stylesheet, and every other page
160// keeps rastrillo's strict policy. TestNativeApprovalStyleIsTheHashedOne
161// re-hashes the served <style> on every run, so a native bump that
162// changes the CSS fails in CI instead of shipping an unstyled page. The
163// lasting fix is upstream: an external stylesheet in native/server.
164const nativeApprovalStyle = "'sha256-zliDvO3z4TJ7/8VeNbSfTcTmu729BtmpmXEY89IdRBI='"
165
166// nativeApprovalCSP adds nativeApprovalStyle to the style-src of the
167// policy already on the response. rastrillo's security headers run first
168// and set it, so this edits the policy production really sends, and a
169// tightening of rastrillo's default reaches this route too. A policy that
170// already allows inline styles is left alone: adding a hash to it would
171// void its 'unsafe-inline' (Home mode's connect.FormPolicy). No policy at
172// all (the bare router in tests) is left alone too.
173func nativeApprovalCSP(next http.HandlerFunc) http.HandlerFunc {
174 return func(w http.ResponseWriter, r *http.Request) {
175 h := w.Header()
176 if p := h.Get("Content-Security-Policy"); p != "" {
177 h.Set("Content-Security-Policy", withStyleSource(p, nativeApprovalStyle))
178 }
179 next(w, r)
180 }
181}
182
183// withStyleSource appends source to policy's style-src, or adds a
184// style-src of 'self' and source when it has none. A style-src that
185// allows 'unsafe-inline' is returned unchanged.
186func withStyleSource(policy, source string) string {
187 parts := strings.Split(policy, ";")
188 for i, d := range parts {
189 f := strings.Fields(d)
190 if len(f) == 0 || f[0] != "style-src" {
191 continue
192 }
193 for _, s := range f[1:] {
194 if s == "'unsafe-inline'" {
195 return policy
196 }
197 }
198 parts[i] = strings.TrimRight(d, " ") + " " + source
199 return strings.Join(parts, ";")
200 }
201 return policy + "; style-src 'self' " + source
202}
203