oficina / docs Public

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

Plain git — no account needed to clone.

Download

Download this file

1package docs
2
3import (
4 "amadan.net/oficina/home/roster"
5 "errors"
6 "html/template"
7 "io"
8 "log/slog"
9 "net/http"
10 "strconv"
11 "strings"
12 "time"
13
14 "github.com/google/uuid"
15 "gorm.io/gorm"
16
17 "amadan.net/oficina/estilo.git/chrome"
18 estilomenu "amadan.net/oficina/estilo.git/menu"
19 "amadan.net/oficina/estilo.git/savestatus"
20 "amadan.net/rastrillo/rastrillo"
21
22 "github.com/carlosframework/docs/internal/core"
23)
24
25// maxUpdateBytes caps a single client-submitted Yjs update
26// (POST /d/{sqid}/updates). A single keystroke's update is tens of
27// bytes, and a whole seeded document (seed.go) is a few kilobytes —
28// a megabyte is far above anything a legitimate client ever sends in
29// one update, and far below anything that threatens this instance's
30// memory. http.MaxBytesReader enforces this before the body is read
31// into memory at all, not after — updatesPost wraps r.Body in one
32// before it ever calls io.ReadAll.
33const maxUpdateBytes = 1 << 20
34
35// handlers holds what the app's HTTP handlers need — rastrillo
36// SKILL.md's "one handler struct holding d.G", here Store (Task 2)
37// instead: this app reads and writes documents through Store, never
38// through a *gorm.DB of its own.
39type handlers struct {
40 requireCurrent func(http.Handler) http.Handler
41 homeEnabled bool
42
43 render *renderer
44 logger *slog.Logger
45 store *Store
46
47 // hub is the live-update fan-out (hub.go). It lives here rather
48 // than on Store on purpose: Store is persistence and knows nothing
49 // about transports, and doc_updates stays the plain append-only
50 // table the design spec's §4.3 insisted on when it rejected
51 // rastrillo/eventlog. The consequence is that a writer which does
52 // not go through updatesPost does not notify — today that is only
53 // sample seeding, which runs before any editor can be open.
54 hub *hub
55
56 // previews is the document list's thumbnail cache (preview.go).
57 // Here rather than on Store for the same reason hub is: Store is
58 // persistence, and a laid-out page of line boxes is presentation.
59 previews *previews
60
61 // streamHeartbeat and streamTTL override stream.go's own defaults.
62 // Zero means "use the default", so nothing but a test ever sets
63 // them — see heartbeatInterval/ttlInterval for why the seam
64 // exists at all.
65 streamHeartbeat time.Duration
66 streamTTL time.Duration
67
68 // isMember answers "is this address on the configured allowlist",
69 // as opposed to admitted by a grant. It gates the member-only powers
70 // — creating a document, and changing who reaches one — so that a
71 // guest reads and writes what they were granted and nothing else
72 // (suite §1 ruling 1, Docs §8.2, branch review finding 1).
73 //
74 // A func rather than the allowlist value because handlers has no
75 // business with the rest of that type, and because app.go must be the
76 // one place the two halves of admission are wired together.
77 isMember func(address string) bool
78
79 // oneShots parks a freshly minted public URL for exactly one render
80 // (oneshot.go). Here rather than on Store for the reason hub and
81 // previews are: Store is persistence, and this value is deliberately
82 // never persisted.
83 oneShots *oneShots
84
85 // origin is cfg.Origin, the app's own validated base URL (app.go's
86 // New refuses to boot without one outside development). The share
87 // page needs it to print a public link somebody can copy into a
88 // message: a relative path is not a thing you can paste to a person
89 // who is not already here.
90 //
91 // The configured origin rather than r.Host, deliberately. r.Host is
92 // attacker-controlled, and a link built from it is a link an
93 // attacker can aim somewhere else by sending the owner a request
94 // with a Host header of their choosing. csrf.Protect already trusts
95 // cfg.Origin for exactly this reason; this trusts the same value.
96 origin string
97
98 // peek reads the request's session without requiring one, for the
99 // chrome on routes no session guards (chrome.go's viewer). It never
100 // writes. directory is the hub directory origin behind the app
101 // switcher's "All apps", empty when none is configured.
102 peek func(*http.Request) (string, bool)
103 directory string
104 // remembered is the page a sign-in in progress will return to
105 // (signin_return.go), "/" when none.
106 remembered func(*http.Request) string
107}
108
109// listPage is GET /'s view data (spec §6.4). Title and the group
110// labels are resolved through rastrillo.T from Catalog (render.go);
111// a row's Title and When are the document's own data, not authored
112// copy, so list.html prints them directly rather than through T.
113//
114// CreateLabel and CreatePlaceholder back the create form list.html
115// renders in its page header — present regardless of Groups, so it
116// reaches a person with no documents yet exactly as it reaches
117// everyone else (fix round 1: the original version of this screen
118// had no path to POST /documents at all outside a hand-typed curl).
119//
120// SignOutLabel backs the sign-out control in the shell bar's account
121// menu, which this screen fills with an "account" block — added in the fix round that deleted the development-only
122// stand-in identity: POST /signout (auth.go) always existed and
123// worked, but nothing on any page linked to it, which matters once
124// this is reachable at a real URL rather than only ever behind a
125// laptop's own access. A session lasts 30 days
126// (rastrillo/auth.DefaultSessionTTL); a page with no way to end one
127// on purpose is a real gap on something public, not a cosmetic one.
128type listPage struct {
129 Frame Frame
130 PartialCatalog string
131 HomeLinkLabel string
132
133 // CanCreate gates the create form in the list header. A guest is
134 // answered 403 by POST /documents, and a form whose only outcome is a
135 // bare error page is a control that exists to fail — the same defect
136 // as the editor a reader could type into. (Re-review, finding 3.)
137 CanCreate bool
138
139 Title string
140 Groups []docGroup
141 EmptyTitle string
142 EmptyBody string
143 CreateLabel string
144 CreatePlaceholder string
145 SignOutLabel string
146
147 // Query is what was searched for, echoed back into the field so a
148 // result page is a page a person can edit and re-submit rather
149 // than one they have to retype into.
150 //
151 // Results is the ranked, ungrouped result list, used INSTEAD of
152 // Groups when Searching. Recency grouping is what an unfiltered
153 // list is for; grouping a ranked list under "Today" and "Earlier"
154 // would order the same rows twice, by two different rules, and
155 // show only the second.
156 Searching bool
157 Query string
158 Results []docRow
159 SearchLabel string
160 SearchPlace string
161 NoResultsHead string
162 NoResultsBody string
163 ClearLabel string
164
165 // JumpTo and JumpToScript back the keyboard palette
166 // (views/partials/jumpto.html), which every signed-in page
167 // carries so Control-K works from the list and from a document
168 // alike.
169 JumpTo jumpToView
170 JumpToScript string
171}
172
173// docGroup is one recency bucket ("Today", "This week", ...) and the
174// rows in it, in the order Store.List returned them — newest first.
175type docGroup struct {
176 Label string
177 Rows []docRow
178}
179
180// docRow is one document's row. Href points at the read-only view
181// (Task 4's GET /d/{sqid}, show below) — an ordinary link, so a click
182// works with no JavaScript.
183type docRow struct {
184 Href string
185 Title string
186 When string
187
188 // Thumb is the document's first page as line boxes (preview.go),
189 // drawn by list.html into the row's reserved 26x34 box. No bars
190 // means no thumbnail — an empty document, one whose structure
191 // could not be read, or one past a cold render's budget — and
192 // renders as the empty box the row has always shown.
193 Thumb thumbView
194
195 // SharedNote is set on a row the reader does not own — "Shared with
196 // you" — and empty on their own.
197 //
198 // The list used to be able to say nothing about whose a document was,
199 // because every row was the reader's; the comment on `list` said so
200 // and gave that as the reason. Sharing made it false, and a row that
201 // looks like yours and is not is the setup for opening somebody's
202 // document expecting to edit it. (Branch review, finding 9.)
203 SharedNote string
204}
205
206// owner uses the linked local person ID for Home sessions and the existing
207// address-to-person mapping for standalone authentication.
208func (h *handlers) owner(r *http.Request) (int64, error) {
209 actor, err := requestActor(r, h.store)
210 return actor.PersonID, err
211}
212
213// actor supplies the same local identity to ownership and address grants.
214func (h *handlers) actor(r *http.Request) (Actor, error) {
215 return requestActor(r, h.store)
216}
217
218// member reports whether the person on this request is a full member of
219// the instance rather than a guest admitted by a grant.
220//
221// A guest reaching a member-only route is answered 403 rather than 404.
222// The app answers 404 for documents, and for a good reason (§4.2: "not
223// yours" must not be distinguishable from "does not exist"), but there is
224// no document here to hide and nothing secret about the instance having
225// members — the guest was invited by one. Pretending POST /documents does
226// not exist would be a lie told for no gain.
227func (h *handlers) member(r *http.Request) bool {
228 if h.store.team != nil {
229 a, ok := r.Context().Value(accessContextKey{}).(*requestAccess)
230 return ok && a != nil && a.runtime == h.store.team && a.state() == roster.Member
231 }
232 id, err := requestActor(r, h.store)
233 if err != nil {
234 return false
235 }
236 return id.VerifiedAddress != nil && (h.isMember == nil || h.isMember(*id.VerifiedAddress))
237}
238
239// list serves GET /: the document list, spec §6.4 and Paul's four
240// rulings against it — newest first, grouped by recency, one row per
241// document. Cut, deliberately, not by oversight: no template
242// carousel, no "Owned by anyone"/"Last opened by me" filter chrome, no
243// grid/list/AZ/folder toggle, no commercial or platform chrome. The
244// row's 26×34 slot at its left edge (list.html's [docs-thumb]) now
245// holds a real rendering of the document's page one, drawn from the
246// PDF exporter's own layout through export.PageOne (preview.go). It
247// was reserved and deliberately empty until that was possible —
248// "reserved, not faked" — and the reservation did its job: the box is
249// the same box, so filling it reflowed nothing.
250//
251// What is drawn is where the text is, not the text: at this size a
252// glyph is a fraction of a pixel. Spec §11.4's raster pipeline is
253// still unbuilt and still what a larger thumbnail would need.
254//
255// A row shows title and when it was last touched, not who: List is
256// already scoped to the signed-in viewer's own documents (Store.List's
257// owner parameter), so every row's "who" is the same person reading
258// the page — rendering it would be inventing display data, not
259// showing real ownership.
260func (h *handlers) list(w http.ResponseWriter, r *http.Request) {
261 if wantsNative(r) {
262 h.nativeList(w, r)
263 return
264 }
265 // actor, not owner: this page answers "what can I reach", which
266 // since sharing landed is a wider question than "what is mine".
267 who, err := h.actor(r)
268 if err != nil {
269 h.logger.Error("docs: resolve signed-in person", "err", err)
270 h.fail(w, r, h.internalFailureStatus(), failStatus)
271 return
272 }
273
274 // The query is taken from the URL, so a search is a link: it can be
275 // bookmarked, reloaded, shared with yourself, and gone back to.
276 // That is also what makes the whole feature work with no
277 // JavaScript (spec §6.2) — the form is a GET and the page it lands
278 // on is an ordinary page.
279 query := strings.TrimSpace(r.URL.Query().Get("q"))
280
281 data := listPage{
282 PartialCatalog: h.partialCatalog(r),
283 HomeLinkLabel: h.homeLinkLabel(r),
284 CanCreate: h.member(r),
285 Title: rastrillo.T(r, "docs.list.title"),
286 EmptyTitle: rastrillo.T(r, "docs.list.empty.title"),
287 EmptyBody: rastrillo.T(r, "docs.list.empty.body"),
288 CreateLabel: rastrillo.T(r, "docs.list.create.label"),
289 CreatePlaceholder: rastrillo.T(r, "docs.list.create.placeholder"),
290 SignOutLabel: rastrillo.T(r, "docs.signout.label"),
291 Query: query,
292 SearchLabel: rastrillo.T(r, "docs.list.search.label"),
293 SearchPlace: rastrillo.T(r, "docs.list.search.placeholder"),
294 NoResultsHead: rastrillo.T(r, "docs.list.search.none.title"),
295 NoResultsBody: rastrillo.T(r, "docs.list.search.none.body"),
296 ClearLabel: rastrillo.T(r, "docs.list.search.clear"),
297 JumpTo: newJumpToView(r),
298 JumpToScript: jumpToScriptPath(),
299 }
300 // The list is the app's root: its tab says just "Docs". A result page
301 // names what was searched for (spec §10).
302 var titleParts []string
303 if query != "" {
304 titleParts = []string{"“" + query + "”"}
305 }
306 data.Frame = h.frame(r, frameOptions{titleParts: titleParts})
307
308 if query != "" {
309 hits, err := h.store.SearchFor(r.Context(), who, query)
310 if err != nil {
311 h.logger.Error("docs: search documents", "err", err)
312 h.fail(w, r, h.internalFailureStatus(), failStatus)
313 return
314 }
315 now := time.Now().UTC()
316 data.Searching = true
317 thumb := h.thumbnailer(r.Context())
318 for _, d := range hits {
319 data.Results = append(data.Results, docRow{
320 SharedNote: sharedNote(r, d, who.PersonID),
321 Href: "/d/" + d.Sqid,
322 Title: d.Title,
323 When: formatWhen(now, d.UpdatedAt),
324 Thumb: thumb(d),
325 })
326 }
327 if err := h.render.page(w, r, "list", data); err != nil {
328 h.logger.Error("docs: render list", "err", err)
329 h.fail(w, r, h.internalFailureStatus(), failStatus)
330 }
331 return
332 }
333
334 docs, err := h.store.ListFor(r.Context(), who)
335 if err != nil {
336 h.logger.Error("docs: list documents", "err", err)
337 h.fail(w, r, h.internalFailureStatus(), failStatus)
338 return
339 }
340 data.Groups = groupByRecency(r, time.Now().UTC(), docs, h.thumbnailer(r.Context()), who.PersonID)
341 if err := h.render.page(w, r, "list", data); err != nil {
342 h.logger.Error("docs: render list", "err", err)
343 h.fail(w, r, h.internalFailureStatus(), failStatus)
344 }
345}
346
347// create serves POST /documents: a plain form post, no JavaScript
348// required (spec §6.2). It reads the permitted field by name —
349// rastrillo SKILL.md §4's "never bind a request body onto a GORM
350// model" — and Store.Create builds the row itself, so there is no
351// mass-assignment surface here at all. On success it redirects with
352// 303 See Other rather than the 302 a naive handler reaches for: a
353// visitor reloading the response, or a browser retrying the
354// navigation, replays the GET the redirect points at, never the POST
355// that created the document.
356func (h *handlers) create(w http.ResponseWriter, r *http.Request) {
357 // Member-only: a guest reads and writes what they were granted, and
358 // minting documents of their own is not that. Without this, one grant
359 // is a transitive key to the front door — the guest creates, shares,
360 // and admits the next person (branch review, finding 1).
361 if !h.requireMemberRequest(w, r, false) {
362 return
363 }
364 owner, err := h.owner(r)
365 if err != nil {
366 h.logger.Error("docs: resolve signed-in person", "err", err)
367 h.fail(w, r, h.internalFailureStatus(), failStatus)
368 return
369 }
370
371 r.Body = http.MaxBytesReader(w, r.Body, 16*1024)
372 if err := r.ParseForm(); err != nil {
373 h.fail(w, r, http.StatusBadRequest, failStatus)
374 return
375 }
376 token := r.PostFormValue("creation_token")
377 if token != "" {
378 parsed, err := uuid.Parse(token)
379 if err != nil {
380 h.fail(w, r, http.StatusBadRequest, failStatus)
381 return
382 }
383 token = parsed.String()
384 }
385 title := strings.TrimSpace(r.PostFormValue("title"))
386 if len(title) > 1024 {
387 h.fail(w, r, http.StatusBadRequest, failStatus)
388 return
389 }
390 if title == "" {
391 title = rastrillo.T(r, "docs.document.untitled")
392 }
393
394 doc, err := h.store.CreateDraft(r.Context(), owner, title, token)
395 if err != nil {
396 h.logger.Error("docs: create document", "err", err)
397 h.fail(w, r, h.internalFailureStatus(), failStatus)
398 return
399 }
400 if wantsNative(r) {
401 nativeJSON(w, r, 201, map[string]any{"sqid": doc.Sqid, "title": doc.Title, "guid": doc.GUID})
402 return
403 }
404 http.Redirect(w, r, "/d/"+doc.Sqid, http.StatusSeeOther)
405}
406
407// showPage is GET /d/{sqid}'s view data (spec §6.1/§6.2). Title is the
408// document's own title — data, not authored copy, printed as-is the
409// same way docRow.Title is on the list. HTML is Store.HTML's rendered
410// replay of the CRDT's ProseMirror fragment through wazero, never a
411// database column; a document with no updates yet (a document just
412// created) replays to "", which views/show.html must render as an
413// ordinary empty canvas rather than erroring. It is template.HTML, not
414// string, so the template can emit it unescaped — Store.HTML's own
415// doc comment is where that "trusted markup" decision is made.
416type showPage struct {
417 Frame Frame
418 HomeLinkLabel string
419
420 Title string
421 HTML template.HTML
422 SignOutLabel string // see listPage.SignOutLabel's own doc comment
423
424 // BackLabel is the document page's one route back to the list —
425 // the label on the shell bar's brand link, which views/show.html
426 // fills with a "brand" block (it was [docs-back], a breadcrumb in
427 // this page's own header, until the chrome moved to the topbar
428 // shell). Until it landed, GET /d/{sqid} offered no in-app
429 // navigation at all — its header held sign-out and nothing else — so a person who arrived by a
430 // bookmark or a mailed link, rather than by clicking through from
431 // GET /, had no way back to their own documents except the
432 // browser's back button, which that arrival gives them nothing to
433 // go back *to*.
434 //
435 // It deliberately reuses docs.list.title rather than adding a
436 // docs.show.back of its own: this is a breadcrumb naming its
437 // destination screen, and a breadcrumb that can drift from the
438 // title of the screen it points at is a translation bug waiting
439 // to happen in eleven locales nobody in this repo reads. One key,
440 // one string, and the link can never disagree with the page it
441 // leads to.
442 BackLabel string
443 Sqid string // views/show.html's [docs-editor] mount point's data-sqid, the editor bundle's own base for /d/{sqid}/updates
444
445 // CanShare and ShareLabel drive the share control views/show.html
446 // puts in the shell bar's "nav" block — which topbar.html renders
447 // inside [rst-shell-tail], immediately before the account
448 // disclosure. That is the position Paul asked for, and it is a slot
449 // the shell already owns; drawing a bar of our own to hold it is the
450 // chrome drift spec §10 says to watch for.
451 //
452 // CanShare is ownership, not readability. A grantee — a writer
453 // included, since no grant carries re-sharing — is not offered it,
454 // because sharePage is owner-only and answers them 404. Rendering a
455 // control that visibly fails is worse than rendering none: it reads
456 // as a broken app rather than as a boundary.
457 //
458 // ShareLabel reuses docs.share.title rather than adding a key of its
459 // own, for BackLabel's reason directly above: one string, so a
460 // control can never disagree with the page it leads to across
461 // eleven locales.
462 //
463 // It is a label rather than a glyph because rastrillo's icon set has
464 // no "share" in it. That set is a closed vocabulary of twelve, whose
465 // own comments argue each addition, and Icon renders an unknown slug
466 // as nothing at all — so {{icon "share"}} would have shipped an
467 // invisible control that no test of ours would have caught. A
468 // thirteenth icon is a rastrillo change, not a docs one.
469 CanShare bool
470 ShareLabel string
471
472 // CanWrite gates the editor: the ProseMirror mount point, its
473 // data-sqid, and the script that mounts it (views/show.html).
474 //
475 // A reader grantee reaches this page — GetFor admits them, and that is
476 // correct, they may read it. What was not correct was rendering them
477 // the owner's page: the bundle mounted, the toolbar enabled, and the
478 // document became contenteditable for somebody every write of whose
479 // the server refuses. The refusal worked; that is not the point. By
480 // the time the 404 arrives the person has typed a paragraph, and
481 // save-queue.mjs classifies a 4xx as permanent, drops the batch and
482 // keeps taking keystrokes. They lose the work, having been shown an
483 // editor. "It fails safely on the server" is not a defence when the
484 // cost is a person's work. (Branch review, finding 2.)
485 //
486 // So the editor is absent rather than disabled. A disabled editor is
487 // one CSS rule from being an editor again; an absent one has nothing
488 // to re-enable. views/public.html already demonstrates the shape.
489 CanWrite bool
490 EditorScript string // views/show.html's <script src>, from editorScriptPath() — never a literal, so it can never drift from what staticHandler actually serves
491
492 SaveStatus savestatus.Data
493
494 // See listPage's own fields: the palette is on every signed-in
495 // page, not only the list.
496 JumpTo jumpToView
497 JumpToScript string
498
499 // Toolbar is the formatting bar's copy (export.go's
500 // newToolbarView) and FontFaces the @font-face rules for the core
501 // font set — both rendered into show.html rather than written into
502 // the bundle or the stylesheet by hand, for the same two reasons:
503 // every user-visible string is a catalog key, and every font path
504 // is content-hashed at init and knowable only at run time.
505 //
506 // Menu is the menu bar's copy (menu.go's newMenuView). The two
507 // export links used to be four fields here, rendered as buttons
508 // pinned to the formatting bar's trailing end; they are items in
509 // the File menu now, and menu.go resolves their catalog keys
510 // itself, so those fields are gone rather than left set and unread.
511 Toolbar toolbarView
512 Menu menuView
513 FontFaces []fontFace
514
515 // ViewMode is "paged" or "infinite" (menu.go's viewMode, from the
516 // docs-view cookie), rendered onto [docs-canvas] so the first paint
517 // already matches the person's preference instead of reflowing when
518 // the deferred bundle runs. PageLabel is the page-number template
519 // the bundle stamps into each gap — copy, so it comes from the
520 // catalog here rather than being a literal in the bundle.
521 ViewMode string
522 PageLabel string
523
524 // Dialogs is the copy for the two <dialog>s Tools opens — word
525 // count and the shortcuts sheet. Server-rendered like everything
526 // else, so the bundle carries no strings of its own; it fills in
527 // numbers and rows.
528 Dialogs dialogView
529}
530
531// dialogView is the copy for the Tools menu's two dialogs.
532type dialogView struct {
533 WordCountTitle string
534 WordCountWords string
535 WordCountCharacters string
536 WordCountCharactersNoSpaces string
537 WordCountParagraphs string
538 WordCountSelection string
539 WordCountClose string
540
541 ShortcutsTitle string
542 ShortcutsClose string
543 // The bindings no menu item carries, so shortcuts.mjs cannot read
544 // them off the bar. Each is {Label, Keys}.
545 ShortcutsGroup string
546 ShortcutsExtra []shortcutRow
547}
548
549type shortcutRow struct {
550 Label string
551 Keys string
552}
553
554func newDialogView(r *http.Request) dialogView {
555 return dialogView{
556 WordCountTitle: rastrillo.T(r, "docs.editor.wordcount.title"),
557 WordCountWords: rastrillo.T(r, "docs.editor.wordcount.words"),
558 WordCountCharacters: rastrillo.T(r, "docs.editor.wordcount.characters"),
559 WordCountCharactersNoSpaces: rastrillo.T(r, "docs.editor.wordcount.charactersnospaces"),
560 WordCountParagraphs: rastrillo.T(r, "docs.editor.wordcount.paragraphs"),
561 WordCountSelection: rastrillo.T(r, "docs.editor.wordcount.selection"),
562 WordCountClose: rastrillo.T(r, "docs.editor.wordcount.close"),
563
564 ShortcutsTitle: rastrillo.T(r, "docs.editor.shortcuts.title"),
565 ShortcutsClose: rastrillo.T(r, "docs.editor.shortcuts.close"),
566 ShortcutsGroup: rastrillo.T(r, "docs.editor.shortcuts.editing"),
567 // Tab and Shift+Tab are repurposed inside the editable surface
568 // and appear in no menu; Backspace-after-autoformat and
569 // Shift+Enter are bindings with no control at all. They are the
570 // four a person is least likely to discover and most likely to
571 // need told.
572 ShortcutsExtra: []shortcutRow{
573 {rastrillo.T(r, "docs.editor.shortcuts.indent"), estilomenu.Shortcut(r, "Tab")},
574 {rastrillo.T(r, "docs.editor.shortcuts.outdent"), estilomenu.Shortcut(r, "Shift+Tab")},
575 {rastrillo.T(r, "docs.editor.shortcuts.undoformat"), estilomenu.Shortcut(r, "Backspace")},
576 {rastrillo.T(r, "docs.editor.shortcuts.linebreak"), estilomenu.Shortcut(r, "Shift+Enter")},
577 {rastrillo.T(r, "docs.editor.shortcuts.leave"), estilomenu.Shortcut(r, "Esc")},
578 },
579 }
580}
581
582// show serves GET /d/{sqid}: the document read-only, its title and
583// its CRDT content — rendered server-side as HTML through Store.HTML,
584// not plain text — inside a page-shaped canvas (views/show.html). An
585// unknown sqid and a sqid someone else owns 404 identically — spec
586// §4.2's contract that a document you cannot see is indistinguishable
587// from one that does not exist — because Store.GetFor's reader-scoped
588// query never distinguishes "no such row" from "not yours" in the
589// first place; there is no separate check here that could 403 by
590// mistake.
591func (h *handlers) show(w http.ResponseWriter, r *http.Request) {
592 sqid := r.PathValue("sqid")
593
594 actor, err := h.actor(r)
595 if err != nil {
596 h.logger.Error("docs: resolve signed-in person", "err", err)
597 h.fail(w, r, h.internalFailureStatus(), failStatus)
598 return
599 }
600
601 // GetFor, not Get: a grantee reads this page too. Get stays owner-only
602 // and is what the share and revoke paths use.
603 doc, err := h.store.GetFor(r.Context(), actor, sqid)
604 if err != nil {
605 if errors.Is(err, gorm.ErrRecordNotFound) {
606 h.fail(w, r, http.StatusNotFound, failDocument)
607 return
608 }
609 h.logger.Error("docs: get document", "sqid", sqid, "err", err)
610 h.fail(w, r, h.internalFailureStatus(), failStatus)
611 return
612 }
613
614 if wantsNative(r) {
615 nativeJSON(w, r, 200, map[string]any{"sqid": doc.Sqid, "title": doc.Title, "guid": doc.GUID, "can_share": doc.OwnerID == actor.PersonID && h.member(r), "can_write": h.store.CanWrite(r.Context(), actor, doc.ID)})
616 return
617 }
618 html, err := h.store.HTML(r.Context(), doc)
619 if err != nil {
620 h.logger.Error("docs: document html", "sqid", sqid, "err", err)
621 h.fail(w, r, h.internalFailureStatus(), failStatus)
622 return
623 }
624
625 data := showPage{
626 HomeLinkLabel: h.homeLinkLabel(r),
627 Title: doc.Title,
628 HTML: html,
629 SignOutLabel: rastrillo.T(r, "docs.signout.label"),
630 BackLabel: rastrillo.T(r, "docs.list.title"),
631 Sqid: doc.Sqid,
632 CanShare: doc.OwnerID == actor.PersonID && h.member(r),
633 CanWrite: h.store.CanWrite(r.Context(), actor, doc.ID),
634 ShareLabel: rastrillo.T(r, "docs.share.title"),
635 EditorScript: editorScriptPath(),
636 SaveStatus: savestatus.Data{Placement: "floating", Retrying: rastrillo.T(r, "docs.editor.status.retrying"), Failed: rastrillo.T(r, "docs.editor.status.failed")},
637 JumpTo: newJumpToView(r),
638 JumpToScript: jumpToScriptPath(),
639
640 Toolbar: newToolbarView(r),
641 Menu: newMenuView(r, doc.Sqid, h.member(r)),
642 FontFaces: fontFaces(),
643 ViewMode: viewMode(r),
644 PageLabel: rastrillo.T(r, "docs.editor.page.number"),
645 Dialogs: newDialogView(r),
646 }
647 // The bar: the document's title, editable in place for a writer, and
648 // the owner's Share… (spec §5.1, §5.4).
649 chromeFor := frameOptions{titleParts: []string{doc.Title}, context: doc.Title}
650 if data.CanWrite {
651 chromeFor.edit = &chrome.TitleEdit{
652 Action: "/d/" + doc.Sqid + "/title",
653 Name: "title",
654 Value: doc.Title,
655 Label: rastrillo.T(r, "docs.chrome.title.label"),
656 Submit: rastrillo.T(r, "docs.chrome.title.rename"),
657 }
658 }
659 if data.CanShare {
660 chromeFor.actions = []chrome.Action{{Item: estilomenu.Item{Href: "/d/" + doc.Sqid + "/share", Label: rastrillo.T(r, "docs.share.action")}}}
661 }
662 data.Frame = h.frame(r, chromeFor)
663 if err := h.render.page(w, r, "show", data); err != nil {
664 h.logger.Error("docs: render show", "err", err)
665 h.fail(w, r, h.internalFailureStatus(), failStatus)
666 }
667}
668
669// maxLogBytes caps the concatenated update log GET /d/{sqid}/updates
670// will return. Per the design spec's own D2 note, this bound is what
671// a later snapshot/compaction plan removes; until then it must fail
672// LOUDLY (updatesGet logs the document and answers 500) rather than
673// silently truncate — a truncated log would hand the browser a
674// corrupt document that looks intact.
675const maxLogBytes = 8 << 20
676
677// getDocumentForUpdates is the reader-scoped lookup both
678// updatesGet and updatesPost share: it resolves the signed-in
679// person, then loads the document through Store.GetFor — already
680// reader-scoped and already 404-not-403 (its own doc comment) — so a
681// stranger's sqid returns exactly the same outcome as one that does
682// not exist. ok is false once a response has already been written
683// (404 or 500) and the caller must return immediately without doing
684// anything further.
685func (h *handlers) getDocumentForUpdates(w http.ResponseWriter, r *http.Request) (doc *Document, ok bool) {
686 sqid := r.PathValue("sqid")
687
688 actor, err := h.actor(r)
689 if err != nil {
690 h.logger.Error("docs: resolve signed-in person", "err", err)
691 h.fail(w, r, h.internalFailureStatus(), failStatus)
692 return nil, false
693 }
694
695 // Readable, not owned. This backs the stream, the updates GET and both
696 // exports, all of which a grantee may use. The updates POST authorizes
697 // separately in ApplyUpdate, where a reader is refused.
698 doc, err = h.store.GetFor(r.Context(), actor, sqid)
699 if err != nil {
700 if errors.Is(err, gorm.ErrRecordNotFound) {
701 h.fail(w, r, http.StatusNotFound, failDocument)
702 return nil, false
703 }
704 h.logger.Error("docs: get document", "sqid", sqid, "err", err)
705 h.fail(w, r, h.internalFailureStatus(), failStatus)
706 return nil, false
707 }
708 return doc, true
709}
710
711// updatesGet serves GET /d/{sqid}/updates: by default, the newest
712// usable snapshot (if any covers past `since`) followed by the tail
713// of doc_updates rows after it — snapshot-then-tail, not the raw log
714// (Task 6). `?raw=1` is the escape hatch that returns exactly the
715// doc_updates rows and nothing else, for a browser whose snapshot
716// will not decode; the server cannot detect that corruption itself
717// (it never calls into the core here), so the browser is the only
718// reader that can, and raw=1 is what lets it fall back to the one
719// source it can always trust. Reader-scoped exactly like show
720// (getDocumentForUpdates), and capped at maxLogBytes above.
721//
722// Why snapshot-then-tail loses nothing: a snapshot IS an ordinary Yjs
723// update, covering every row up to and including its own seq. The
724// browser's applyServerLog already loops Y.readUpdate over a
725// concatenated body, and Yjs integration is idempotent, so a client
726// asking `?since=5` that receives a snapshot reaching seq 256 has not
727// skipped rows 6-256 — the snapshot contains them. That is why no
728// client change is needed for the happy path; only raw=1 (wired up by
729// a later task) exists for the unhappy one.
730//
731// The size is summed and checked BEFORE anything is written to w:
732// writing the 200 status and a partial body first, then discovering
733// the cap is exceeded, would leave no way back except closing the
734// connection mid-response — indistinguishable, to a browser, from a
735// truncated document that looks intact. Failing before the first
736// byte goes out keeps that outcome impossible.
737func (h *handlers) updatesGet(w http.ResponseWriter, r *http.Request) {
738 doc, ok := h.getDocumentForUpdates(w, r)
739 if !ok {
740 return
741 }
742
743 since, ok := parseSince(w, r)
744 if !ok {
745 return
746 }
747
748 // raw=1 skips the snapshot lookup entirely and behaves exactly as
749 // this handler always has: doc_updates rows after `since`, nothing
750 // else. That is deliberate, not merely "don't bother fetching a
751 // snapshot we'd discard" — it is what makes raw=1 trustworthy as an
752 // escape hatch: its behavior does not depend on snapshot.go at all.
753 raw := r.URL.Query().Get("raw") == "1"
754
755 // A snapshot is an accelerator, never the source of truth (the
756 // snapshots design's D2, echoed in store.go's replay): if the
757 // lookup itself fails, this falls back to serving the plain log
758 // rather than 500ing a request the log alone can still answer.
759 var snapshot *DocSnapshot
760 if !raw {
761 snap, err := h.store.newestSnapshot(r.Context(), doc.ID)
762 if err != nil {
763 h.logger.Error("docs: load newest snapshot, serving the plain log instead", "sqid", doc.Sqid, "err", err)
764 } else if snap != nil && snap.Seq > since {
765 // A snapshot at or behind `since` buys nothing — the
766 // client already holds everything it covers — so it is
767 // only used when it actually advances the tail's start.
768 snapshot = snap
769 }
770 }
771
772 tailSince := since
773 if snapshot != nil {
774 tailSince = snapshot.Seq
775 }
776
777 var updates []DocUpdate
778 if err := h.store.db.WithContext(r.Context()).
779 Where("doc_id = ? AND seq > ?", doc.ID, tailSince).
780 Order("seq ASC").
781 Find(&updates).Error; err != nil {
782 h.logger.Error("docs: load update log", "sqid", doc.Sqid, "err", err)
783 http.Error(w, rastrillo.T(r, "docs.error.generic"), h.internalFailureStatus())
784 return
785 }
786
787 for i := range updates {
788 if err := h.store.openContentUpdate(doc.GUID, &updates[i]); err != nil {
789 h.logger.Error("docs: load update log", "sqid", doc.Sqid, "err", err)
790 http.Error(w, rastrillo.T(r, "docs.error.generic"), h.internalFailureStatus())
791 return
792 }
793 }
794
795 total := 0
796 if snapshot != nil {
797 total = len(snapshot.State)
798 }
799 for _, u := range updates {
800 total += len(u.UpdateBin)
801 }
802 if total > maxLogBytes {
803 h.logger.Error("docs: update log exceeds maxLogBytes", "sqid", doc.Sqid, "bytes", total, "cap", maxLogBytes)
804 http.Error(w, rastrillo.T(r, "docs.error.generic"), h.internalFailureStatus())
805 return
806 }
807
808 // Docs-Seq is derived from the bytes in hand — max(since, the
809 // highest seq actually being written) — and never from a second
810 // MAX(seq) query. See parseSince's own doc comment and the design's
811 // D2 for the update this loses if that is ever "simplified". A
812 // snapshot's own seq column is fine to fold in here even though it
813 // is not itself a doc_updates row: it travels with bytes (its
814 // State) that contain its whole prefix, so counting it is exactly
815 // as safe as counting the highest tail row's seq.
816 highest := since
817 if snapshot != nil && snapshot.Seq > highest {
818 highest = snapshot.Seq
819 }
820 for _, u := range updates {
821 if u.Seq > highest {
822 highest = u.Seq
823 }
824 }
825
826 w.Header().Set("Content-Type", "application/octet-stream")
827 w.Header().Set("Docs-Seq", strconv.FormatInt(highest, 10))
828 parts := make([][]byte, 0, len(updates)+1)
829 if snapshot != nil {
830 parts = append(parts, snapshot.State)
831 }
832 for _, u := range updates {
833 parts = append(parts, u.UpdateBin)
834 }
835 if err := writeGuardedBody(w, r, 0, parts...); err != nil {
836 h.logger.Error("docs: write update log", "err", err)
837 }
838}
839
840// updatesPost serves POST /d/{sqid}/updates: one new Yjs update from
841// the browser's editor, applied through Store.ApplyUpdate — the
842// point where a client's bytes first reach the CRDT core.
843//
844// r.Body is wrapped in http.MaxBytesReader before anything reads it,
845// so an oversized body is refused (413) before it is buffered into
846// memory at all, never after. A rejection from the core (bad bytes,
847// not a database or programming error) is a 400: the client sent
848// something the CRDT would not accept, and Store.ApplyUpdate's own
849// doc comment guarantees nothing was persisted for it.
850func (h *handlers) updatesPost(w http.ResponseWriter, r *http.Request) {
851 doc, ok := h.getDocumentForUpdates(w, r)
852 if !ok {
853 return
854 }
855
856 r.Body = http.MaxBytesReader(w, r.Body, maxUpdateBytes)
857 update, err := io.ReadAll(r.Body)
858 if err != nil {
859 var mbe *http.MaxBytesError
860 if errors.As(err, &mbe) {
861 http.Error(w, rastrillo.T(r, "docs.error.updatetoolarge"), http.StatusRequestEntityTooLarge)
862 return
863 }
864 h.logger.Error("docs: read posted update", "sqid", doc.Sqid, "err", err)
865 http.Error(w, rastrillo.T(r, "docs.error.generic"), h.internalFailureStatus())
866 return
867 }
868
869 actor, err := h.actor(r)
870 if err != nil {
871 h.logger.Error("docs: resolve actor for update", "sqid", doc.Sqid, "err", err)
872 http.Error(w, rastrillo.T(r, "docs.error.generic"), h.internalFailureStatus())
873 return
874 }
875
876 seq, err := h.store.ApplyUpdate(r.Context(), doc, actor, update)
877 if err != nil {
878 // A reader posting an update gets the same 404 a stranger gets —
879 // the same BODY too, which it did not used to be. This branch
880 // answered http.NotFound ("404 page not found") while a stranger
881 // got http.Error with the catalog's text, so the sentence above
882 // and store.go's "refused identically" were both false. Harmless
883 // as an oracle (a reader already knows the document exists) and
884 // still worth fixing, because the comment was the thing being
885 // relied on. (Branch review, finding 7f.)
886 if errors.Is(err, ErrNotWritable) {
887 http.Error(w, rastrillo.T(r, "docs.error.notfound"), http.StatusNotFound)
888 return
889 }
890 if errors.Is(err, errTeamUnavailable) {
891 http.Error(w, rastrillo.T(r, "docs.error.generic"), http.StatusServiceUnavailable)
892 return
893 }
894 if updateIsClientFault(err) {
895 // Info, not Error: a client sending bytes the core
896 // cleanly rejects is an ordinary, expected outcome of
897 // accepting arbitrary input over HTTP, not an operational
898 // problem with this instance. Fix round 1 on Task 5: this
899 // used to be the ONLY branch, so a real database or
900 // wasm-rebuild failure was also reported to the client as
901 // "your bytes were bad" and logged where nobody would
902 // think to look for an outage.
903 h.logger.Info("docs: reject posted update", "sqid", doc.Sqid, "err", err)
904 http.Error(w, rastrillo.T(r, "docs.error.badupdate"), http.StatusBadRequest)
905 return
906 }
907 h.logger.Error("docs: apply posted update", "sqid", doc.Sqid, "doc_id", doc.ID, "err", err)
908 http.Error(w, rastrillo.T(r, "docs.error.generic"), h.internalFailureStatus())
909 return
910 }
911
912 // Both of these happen only on a nil error, which is what makes
913 // the seq trustworthy (design D4). An error here can be returned
914 // over an already-committed row — ApplyUpdate's trailing
915 // updated_at bump lives outside its transaction — and in that case
916 // the client retries, writes a duplicate row Yjs integrates
917 // idempotently, and the broadcast happens on the retry instead.
918 w.Header().Set("Docs-Seq", strconv.FormatInt(seq, 10))
919 h.hub.broadcast(doc.ID, seq)
920
921 w.WriteHeader(http.StatusNoContent)
922}
923
924// parseSince reads the ?since= cursor off a request: the highest seq
925// the client already holds, so the response can carry only what came
926// after it. Absent means 0, which asks for the whole log — what the
927// editor's first load wants.
928//
929// A malformed or negative value is a 400 rather than a silent 0.
930// Treating "banana" as "send everything" would answer an expensive
931// question nobody asked, and would hide a client bug behind a
932// response that looks fine; a client that cannot say where it is
933// should be told so.
934func parseSince(w http.ResponseWriter, r *http.Request) (int64, bool) {
935 raw := r.URL.Query().Get("since")
936 if raw == "" {
937 return 0, true
938 }
939 since, err := strconv.ParseInt(raw, 10, 64)
940 if err != nil || since < 0 {
941 http.Error(w, rastrillo.T(r, "docs.error.badsince"), http.StatusBadRequest)
942 return 0, false
943 }
944 return since, true
945}
946
947// updateIsClientFault classifies a Store.ApplyUpdate error: true means
948// the core looked at the bytes the client just posted and refused
949// them — a 400, the client's problem, and unremarkable enough to log
950// at Info. False means everything else: a database error, a replay
951// failure reading this document's own history, or a trap whose
952// recovery itself failed (core.Handle.Apply's plain errors.Join
953// branch — the document may now be unusable, not merely this one
954// update) — none of which the client caused, all of which deserve a
955// 500 and an Error-level log an operator will actually see.
956//
957// errors.As(err, &ur) is the load-bearing check, not the three core
958// checks after it: only Store.ApplyUpdate's own call to
959// core.Handle.Apply for THIS update wraps its error in
960// *updateRejection (store.go) — a failure replaying this document's
961// existing log calls the exact same core.Handle.Apply and would
962// satisfy core.Recovered/core.Rejected/ErrUpdateLogFull just as
963// easily, but is never wrapped in *updateRejection, so it can never
964// reach the 400 branch by accident.
965func updateIsClientFault(err error) bool {
966 var ur *updateRejection
967 if !errors.As(err, &ur) {
968 return false
969 }
970 return core.Recovered(err) || core.Rejected(err) || errors.Is(err, core.ErrUpdateLogFull)
971}
972
973// sharedNote labels a row the reader does not own. Empty for their own
974// documents: a list where every row carries a badge is a list where none
975// of them reads as information.
976func sharedNote(r *http.Request, d *Document, mine int64) string {
977 if d.OwnerID == mine {
978 return ""
979 }
980 return rastrillo.T(r, "docs.list.shared")
981}
982
983// groupByRecency buckets docs — already newest-first, per Store.List
984// — into recency groups without re-sorting: consecutive documents
985// that share a bucket join the same group, and a new bucket starts a
986// new group the moment recencyBucket's key changes from the previous
987// document's. That is safe only because recencyBucket is monotonic in
988// time (a later document's bucket never sorts before an earlier
989// one's) AND docs is ordered by the exact same clock this function
990// buckets and displays by (UpdatedAt) — Store.List orders by
991// `updated_at DESC`, not CreatedAt, specifically so that holds. Fix
992// round 2's bug was ordering by one column while bucketing by
993// another: they agreed only because nothing yet moved UpdatedAt away
994// from CreatedAt, and the moment ApplyUpdate started bumping
995// UpdatedAt, an order-by-CreatedAt list would have produced duplicate
996// group labels ("Today … Yesterday … Today") the instant an older
997// document got a new update. See store_test.go's
998// TestListOrdersByUpdatedAt.
999func groupByRecency(r *http.Request, now time.Time, docs []*Document, thumb func(*Document) thumbView, mine int64) []docGroup {
1000 var groups []docGroup
1001 var key string
1002 for _, d := range docs {
1003 k, labelKey := recencyBucket(now, d.UpdatedAt)
1004 if len(groups) == 0 || k != key {
1005 groups = append(groups, docGroup{Label: rastrillo.T(r, labelKey)})
1006 key = k
1007 }
1008 g := &groups[len(groups)-1]
1009 g.Rows = append(g.Rows, docRow{
1010 SharedNote: sharedNote(r, d, mine),
1011 Href: "/d/" + d.Sqid,
1012 Title: d.Title,
1013 When: formatWhen(now, d.UpdatedAt),
1014 Thumb: thumb(d),
1015 })
1016 }
1017 return groups
1018}
1019
1020// recencyBucket returns a stable bucket key and the catalog key for
1021// its label, comparing calendar days in UTC — Document.CreatedAt and
1022// UpdatedAt are both stored in UTC (Store.Create), and there is no
1023// per-viewer time zone to convert to yet (a signed-in viewer exists
1024// now, but nothing stores their zone). time.Truncate(24*time.Hour)
1025// lines up with the calendar day specifically because both arguments
1026// are UTC: the day never shifts under DST the way it can in a zone
1027// that observes it.
1028func recencyBucket(now, t time.Time) (key, labelKey string) {
1029 days := int(now.Truncate(24*time.Hour).Sub(t.Truncate(24*time.Hour)).Hours() / 24)
1030 switch {
1031 case days <= 0:
1032 return "today", "docs.list.group.today"
1033 case days == 1:
1034 return "yesterday", "docs.list.group.yesterday"
1035 case days <= 6:
1036 return "week", "docs.list.group.week"
1037 default:
1038 return "earlier", "docs.list.group.earlier"
1039 }
1040}
1041
1042// formatWhen renders t for a row's trailing timestamp: a time of day
1043// for today, otherwise a date, with the year appended only once t
1044// falls in a year other than now's.
1045//
1046// This is English-only, hardcoded Go layout strings rather than
1047// something Intl-backed, matching this app's single declared locale
1048// (Locales, above). datetime.js's date fields get away with the
1049// browser's own Intl for exactly this reason; there is no Go-side
1050// equivalent wired up here, so a second locale needs this to move
1051// rather than simply gaining a catalog entry.
1052func formatWhen(now, t time.Time) string {
1053 if now.Truncate(24 * time.Hour).Equal(t.Truncate(24 * time.Hour)) {
1054 return t.Format("3:04 PM")
1055 }
1056 if now.Year() == t.Year() {
1057 return t.Format("Jan 2")
1058 }
1059 return t.Format("Jan 2, 2006")
1060}
1061
1062func (h *handlers) rename(w http.ResponseWriter, r *http.Request) {
1063 doc, ok := h.getDocumentForUpdates(w, r)
1064 if !ok {
1065 return
1066 }
1067 actor, err := h.actor(r)
1068 if err != nil {
1069 h.fail(w, r, h.internalFailureStatus(), failStatus)
1070 return
1071 }
1072 r.Body = http.MaxBytesReader(w, r.Body, 16*1024)
1073 if err := r.ParseForm(); err != nil {
1074 h.fail(w, r, http.StatusBadRequest, failStatus)
1075 return
1076 }
1077 title := strings.TrimSpace(r.PostFormValue("title"))
1078 if title == "" {
1079 title = rastrillo.T(r, "docs.document.untitled")
1080 }
1081 if len(title) > 1024 {
1082 h.fail(w, r, http.StatusBadRequest, failStatus)
1083 return
1084 }
1085 if err := h.store.Rename(r.Context(), actor, doc, title); err != nil {
1086 if errors.Is(err, ErrNotWritable) || errors.Is(err, gorm.ErrRecordNotFound) {
1087 h.fail(w, r, http.StatusNotFound, failDocument)
1088 return
1089 }
1090 h.fail(w, r, h.internalFailureStatus(), failStatus)
1091 return
1092 }
1093 // The native apps rename with a fetch and read 204. The bar's title
1094 // field is a plain form, so a browser is sent back to the document,
1095 // 303 so a reload never re-posts.
1096 if wantsNative(r) {
1097 w.WriteHeader(http.StatusNoContent)
1098 return
1099 }
1100 http.Redirect(w, r, "/d/"+doc.Sqid, http.StatusSeeOther)
1101}
1102