| 1 | package sheets |
| 2 | |
| 3 | import ( |
| 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. |
| 30 | var 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. |
| 39 | func (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. |
| 122 | func (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. |
| 140 | func (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 | |