| 1 | package docs |
| 2 | |
| 3 | import ( |
| 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. |
| 33 | const 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. |
| 39 | type 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. |
| 128 | type 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. |
| 175 | type 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. |
| 183 | type 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. |
| 208 | func (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. |
| 214 | func (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. |
| 227 | func (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. |
| 260 | func (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. |
| 356 | func (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. |
| 416 | type 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. |
| 532 | type 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 | |
| 549 | type shortcutRow struct { |
| 550 | Label string |
| 551 | Keys string |
| 552 | } |
| 553 | |
| 554 | func 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. |
| 591 | func (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. |
| 675 | const 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. |
| 685 | func (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. |
| 737 | func (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. |
| 850 | func (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. |
| 934 | func 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. |
| 965 | func 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. |
| 976 | func 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. |
| 999 | func 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. |
| 1028 | func 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. |
| 1052 | func 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 | |
| 1062 | func (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 | |