oficina / sheets Public

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

Plain git — no account needed to clone.

Download

Download this file

1package sheets
2
3import (
4 "errors"
5 "net/http"
6 "strconv"
7 "strings"
8
9 "amadan.net/oficina/sheets/internal/sheets/store"
10)
11
12// Rename exists because Create stopped asking for a name.
13//
14// The sheet list used to carry a form with a title field, so a sheet
15// was named once, at the moment it was made, and never again — there
16// was no rename route and layout.html said so in a comment. That was
17// survivable only because the title field existed. Removing it in
18// favour of a one-click Create, which lands every new sheet as
19// "Untitled sheet", would have made the missing route the whole
20// product: a list of sheets all called the same thing, distinguished
21// only by their preview thumbnails, with no way to fix any of them.
22//
23// So this is not a feature that arrived beside the topbar Create
24// button. It is the other half of it.
25
26// errBlankTitle is returned rather than silently keeping the old title.
27// A person who cleared the field and submitted meant something by it,
28// and the honest answer is that a sheet needs a name — the same rule
29// createSheet applies.
30var errBlankTitle = errors.New("sheets: a sheet needs a title")
31
32// handleSheetRename renames a sheet from the title in the top bar.
33//
34// Editors, not just owners: renaming is an edit to the document, of a
35// piece with editing a cell, and mayWrite is what says who may make
36// one. A viewer or commenter gets the 404 that AGENTS.md requires for
37// a row that is not theirs to change — never a 403, which would
38// confirm the sheet exists to somebody who may not see it.
39func (a *App) handleSheetRename(w http.ResponseWriter, r *http.Request) {
40 s, ok := a.sheetFromURL(w, r)
41 if !ok {
42 return
43 }
44 viewer, ok := a.actor(r)
45 if !ok {
46 a.notFound(w, r)
47 return
48 }
49 // Bounded, then parsed, and only THEN authorised. The order is the
50 // point and it is deliberately not the order the rest of the app
51 // uses.
52 //
53 // Every other write route here authorises first and reads the body
54 // after (openTab before the decode, in handlers_format and
55 // handlers_size). That leaves the body read — the one step whose
56 // duration the client chooses — sitting between the permission
57 // check and the write, so an editor can begin a rename, hold the
58 // body open while their grant is revoked, then finish it and have
59 // the write go through. Reading first collapses that: the delay a
60 // client controls now happens BEFORE anything is checked, and the
61 // gap between mayWrite and the UPDATE is straight-line code.
62 //
63 // It is not free — a request that turns out to be forbidden has
64 // paid to parse its own body — but the cap on the line below is
65 // what makes that cheap enough to be the obvious trade. The wider
66 // question, whether every other write route should be reordered
67 // the same way, is a real one and belongs in the branch discussion
68 // rather than in this handler.
69 //
70 // 8 KiB matches handlers_format's cap on the same shape of form
71 // post. Go's own ParseForm ceiling for a urlencoded body is 10 MiB,
72 // so this is not the difference between bounded and unbounded — it
73 // is three orders of magnitude off a limit no rename needs, for a
74 // value truncated to maxTitleRunes moments later.
75 r.Body = http.MaxBytesReader(w, r.Body, 8<<10)
76 if err := r.ParseForm(); err != nil {
77 // Refused rather than carried on with. FormValue would swallow
78 // this error and fall through to the QUERY, which is how
79 // ?title=x on an oversized body silently became the new name —
80 // caught by review, not by the suite.
81 a.badRequest(w, r, http.StatusBadRequest, "Malformed request.")
82 return
83 }
84 // PostForm, never Form: the title comes from the body a person
85 // submitted, never from the URL. r.FormValue merges the query in,
86 // so with it a GET-shaped ?title= could rename a sheet — a
87 // state-changing value arriving somewhere it can be linked to.
88 //
89 // Read field by field into a local, never bound onto a GORM model
90 // (AGENTS.md / Mechanical rules): a form that could reach the model
91 // could set CreatedBy, or DeletedAt, or an id.
92 title := r.PostForm.Get("title")
93
94 // Last thing before the write, with nothing client-paced in
95 // between. See the note above on why this moved down here.
96 if !a.mayWrite(s.ID, viewer) {
97 a.notFound(w, r)
98 return
99 }
100
101 if err := a.renameSheet(s.ID, title); err != nil {
102 if errors.Is(err, errBlankTitle) {
103 if wantsNative(r) {
104 http.Error(w, "A sheet needs a title.", 400)
105 return
106 }
107 // Straight back to the sheet, which still shows the name it
108 // had. Nothing was changed and there is nothing to explain
109 // that the unchanged title does not already say.
110 a.sheetRedirect(w, r, s.ID)
111 return
112 }
113 a.fail(w, r, err)
114 return
115 }
116 // 303 rather than a render: a POST that renders is a POST that
117 // resubmits on reload.
118 a.sheetRedirect(w, r, s.ID)
119}
120
121// sheetRedirect sends the browser back to a sheet.
122func (a *App) sheetRedirect(w http.ResponseWriter, r *http.Request, id uint64) {
123 if nativeNotice(w, r, "renamed") {
124 return
125 }
126 http.Redirect(w, r, "/sheets/"+strconv.FormatUint(id, 10), http.StatusSeeOther)
127}
128
129// renameSheet writes a new sealed title.
130//
131// The title is sealed under the same "sheet.title" purpose createSheet
132// uses. A rename that wrote plaintext would put a sheet's name in the
133// database file, and the one rule does not have an exception for the
134// second time a name is set.
135//
136// Update names its column explicitly rather than saving a struct, so
137// this statement cannot touch a field nobody asked it to. GORM bumps
138// UpdatedAt as part of it, which is what makes a renamed sheet rise to
139// the top of the list — correct, since renaming it is changing it.
140func (a *App) renameSheet(sheetID uint64, title string) error {
141 if a.db == nil {
142 return errNoDB
143 }
144 if a.sealer == nil {
145 return errNoSealer
146 }
147 clean := strings.TrimSpace(title)
148 if clean == "" {
149 return errBlankTitle
150 }
151 // Truncated to the same limit createSheet applies, by runes rather
152 // than bytes so a multi-byte title is cut where a character ends.
153 if r := []rune(clean); len(r) > maxTitleRunes {
154 clean = string(r[:maxTitleRunes])
155 }
156 sealed, err := a.sealer.Seal("sheet.title", []byte(clean))
157 if err != nil {
158 return err
159 }
160 return a.db.Model(&store.Sheet{}).
161 Where("id = ?", sheetID).
162 Update("title_sealed", sealed).Error
163}
164