| 1 | package sheets |
| 2 | |
| 3 | import ( |
| 4 | "encoding/json" |
| 5 | "net/http" |
| 6 | "strconv" |
| 7 | "strings" |
| 8 | |
| 9 | "amadan.net/oficina/sheets/internal/sheets/store" |
| 10 | ) |
| 11 | |
| 12 | // maxFormatCells bounds one format write. |
| 13 | // |
| 14 | // Formatting a range is a per-cell write — a Format row interned once, |
| 15 | // then one Cell row updated per coordinate — so "select the column and |
| 16 | // make it bold" over a hundred-thousand-cell selection is a hundred |
| 17 | // thousand updates inside one request. Capped rather than streamed |
| 18 | // because the cap is generous against any selection a person makes by |
| 19 | // hand, and a bounded refusal beats a request that appears to hang. |
| 20 | const maxFormatCells = 4000 |
| 21 | |
| 22 | // handleFormatPost applies a format patch to a rectangle of cells. |
| 23 | // |
| 24 | // It is a sibling of handleCellPost and deliberately shaped like it: |
| 25 | // same auth, same tab resolution, same publish-then-answer, same |
| 26 | // response type. A format change IS a change to the grid, and a client |
| 27 | // that had to merge two different response shapes would have two paints |
| 28 | // that could disagree. |
| 29 | // |
| 30 | // **Formatting is not in the event log, so it is not undoable.** Undo |
| 31 | // is cell-level and replays input events (spec §7); a format carries no |
| 32 | // event, so Ctrl-Z after bolding undoes the last VALUE edit instead, |
| 33 | // which is surprising in the way this app tries not to be. It is a |
| 34 | // deliberate, named limitation of this slice rather than an oversight — |
| 35 | // the fix is a format event kind, which changes the undo model and is |
| 36 | // not a thing to do in passing. Until then the UI does not offer undo |
| 37 | // for formatting. |
| 38 | func (a *App) handleFormatPost(w http.ResponseWriter, r *http.Request) { |
| 39 | // The viewer is checked but not recorded: formatting appends no |
| 40 | // event, so there is no actor column to write one to. |
| 41 | if _, ok := a.actor(r); !ok { |
| 42 | http.Error(w, "Sign in first.", http.StatusForbidden) |
| 43 | return |
| 44 | } |
| 45 | h, ok := a.openTab(w, r, SheetEditor) |
| 46 | if !ok { |
| 47 | return |
| 48 | } |
| 49 | defer h.Release() |
| 50 | |
| 51 | // One route, two decoders, converging on one patch BEFORE anything |
| 52 | // is validated (this slice's decision D). The scriptless table view |
| 53 | // posts a form here; the grid posts JSON. Everything below this |
| 54 | // point is identical for both, which is what keeps the allowlist, |
| 55 | // the bounds and the cell cap from existing twice. |
| 56 | var p formatPatch |
| 57 | form := isFormPost(r) |
| 58 | if form { |
| 59 | // The same 8 KiB the JSON branch has always had. Without it the |
| 60 | // form path was the one way into this handler with no body |
| 61 | // bound at all: FormValue parses the whole request before the |
| 62 | // shared maxFormatCells check is anywhere near being reached. |
| 63 | r.Body = http.MaxBytesReader(w, r.Body, 8<<10) |
| 64 | var ok bool |
| 65 | if p, ok = formatFromForm(r); !ok { |
| 66 | http.Error(w, "Malformed request.", http.StatusBadRequest) |
| 67 | return |
| 68 | } |
| 69 | } else if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 8<<10)).Decode(&p); err != nil { |
| 70 | // Not echoed: a number-format pattern is authored content, and |
| 71 | // an error string is a log line waiting to happen (AGENTS.md). |
| 72 | http.Error(w, "Malformed request.", http.StatusBadRequest) |
| 73 | return |
| 74 | } |
| 75 | if err := p.validate(); err != nil { |
| 76 | http.Error(w, "That formatting could not be applied.", http.StatusBadRequest) |
| 77 | return |
| 78 | } |
| 79 | r1, c1, r2, c2, ok := p.normalize(maxWindowRows*10, maxWindowCols*10) |
| 80 | if !ok { |
| 81 | http.Error(w, "Range out of bounds.", http.StatusBadRequest) |
| 82 | return |
| 83 | } |
| 84 | if (r2-r1+1)*(c2-c1+1) > maxFormatCells { |
| 85 | http.Error(w, "That range is too large to format in one go.", http.StatusRequestEntityTooLarge) |
| 86 | return |
| 87 | } |
| 88 | |
| 89 | coords, err := a.applyFormat(h, p, r1, c1, r2, c2) |
| 90 | if err != nil { |
| 91 | a.fail(w, r, err) |
| 92 | return |
| 93 | } |
| 94 | resp, err := formatResponse(h, coords, p.Client) |
| 95 | if err != nil { |
| 96 | a.fail(w, r, err) |
| 97 | return |
| 98 | } |
| 99 | a.touchSheet(sheetIDFrom(r)) |
| 100 | a.publish(tabIDFrom(r), resp) |
| 101 | if form { |
| 102 | // 303 back to the page, so a reload does not re-post the patch. |
| 103 | // |
| 104 | // The target is BUILT HERE from the sheet id in the path and a |
| 105 | // page number this function bounds itself — never taken from |
| 106 | // the request. A caller-supplied return is an open redirect, |
| 107 | // and this repo has already shipped one fix in that family |
| 108 | // (fix/safe-return-backslash). |
| 109 | http.Redirect(w, r, tableReturn(sheetIDFrom(r), r.FormValue("page")), http.StatusSeeOther) |
| 110 | return |
| 111 | } |
| 112 | writeJSON(w, http.StatusOK, resp) |
| 113 | } |
| 114 | |
| 115 | // isFormPost reports whether this request is the scriptless table |
| 116 | // view's rather than the grid's. |
| 117 | // |
| 118 | // Content-Type and not a query flag or a hidden field: the body's own |
| 119 | // encoding is the thing that actually differs, a flag could disagree |
| 120 | // with it, and r.FormValue on a JSON body silently reads nothing at all |
| 121 | // — which would decode as a patch of zeroes rather than as an error. |
| 122 | // Only url-encoded. multipart/form-data was accepted by the first |
| 123 | // version of this and is not: the rendered form posts url-encoded, so |
| 124 | // multipart bought nothing and cost a parser that will happily read a |
| 125 | // far larger body than the cap above intends before any of this |
| 126 | // handler's own bounds apply. |
| 127 | func isFormPost(r *http.Request) bool { |
| 128 | return strings.HasPrefix(r.Header.Get("Content-Type"), |
| 129 | "application/x-www-form-urlencoded") |
| 130 | } |
| 131 | |
| 132 | // tableReturn builds the table page URL to send a form caller back to. |
| 133 | // |
| 134 | // page is parsed and bounded rather than concatenated: it arrives from |
| 135 | // the form, and the one thing a redirect target must not contain is |
| 136 | // anything the caller chose. An unparseable or out-of-range page falls |
| 137 | // back to the first, which is a worse landing than the one they came |
| 138 | // from and a better one than a URL somebody else wrote. |
| 139 | func tableReturn(sheetID uint64, page string) string { |
| 140 | n, err := strconv.Atoi(page) |
| 141 | pages := (maxWindowRows + tableRowsPerPage - 1) / tableRowsPerPage |
| 142 | if err != nil || n < 1 || n > pages { |
| 143 | n = 1 |
| 144 | } |
| 145 | return "/sheets/" + strconv.FormatUint(sheetID, 10) + "/table?page=" + strconv.Itoa(n) |
| 146 | } |
| 147 | |
| 148 | // applyFormat writes the patch across the rectangle and returns the |
| 149 | // coordinates it touched. |
| 150 | // |
| 151 | // Every cell in the range is visited, including blank ones: a coloured |
| 152 | // header nobody has typed into yet is a real thing to want, and |
| 153 | // store.Handle.SetFormat creates the Cell row to carry it. |
| 154 | func (a *App) applyFormat(h *store.Handle, p formatPatch, r1, c1, r2, c2 int) ([]store.CellCoord, error) { |
| 155 | existing, err := h.Formats() |
| 156 | if err != nil { |
| 157 | return nil, err |
| 158 | } |
| 159 | |
| 160 | out := make([]store.CellCoord, 0, (r2-r1+1)*(c2-c1+1)) |
| 161 | for row := r1; row <= r2; row++ { |
| 162 | rowID, err := h.EnsureRow(row) |
| 163 | if err != nil { |
| 164 | return nil, err |
| 165 | } |
| 166 | for col := c1; col <= c2; col++ { |
| 167 | colID, err := h.EnsureCol(col) |
| 168 | if err != nil { |
| 169 | return nil, err |
| 170 | } |
| 171 | coord := store.CellCoord{RowID: rowID, ColID: colID} |
| 172 | |
| 173 | // The cell's CURRENT format is the base, so a patch that |
| 174 | // only sets bold keeps the fill somebody applied earlier. |
| 175 | // Merging against the zero value instead would make every |
| 176 | // toolbar button a reset button for the other eight. |
| 177 | base := store.Format{} |
| 178 | if f, ok := existing[coord]; ok && f != nil { |
| 179 | base = *f |
| 180 | } |
| 181 | if err := h.SetFormat(rowID, colID, p.applyTo(base)); err != nil { |
| 182 | return nil, err |
| 183 | } |
| 184 | out = append(out, coord) |
| 185 | } |
| 186 | } |
| 187 | return out, nil |
| 188 | } |
| 189 | |
| 190 | // formatResponse re-reads the touched cells and builds the paint. |
| 191 | // |
| 192 | // Re-read rather than predicted, for one reason worth stating: a number |
| 193 | // format changes the DISPLAY string, and the server owns every string |
| 194 | // on screen (grid.go's display). A response that echoed the patch and |
| 195 | // left the client to re-render would put a number-format implementation |
| 196 | // in JavaScript, which is the one thing this design will not do. |
| 197 | func formatResponse(h *store.Handle, coords []store.CellCoord, client string) (gridResponse, error) { |
| 198 | idx := newPosIndex(h) |
| 199 | formats, err := h.Formats() |
| 200 | if err != nil { |
| 201 | return gridResponse{}, err |
| 202 | } |
| 203 | col := newCollector(formats) |
| 204 | |
| 205 | cells := make([]gridCell, 0, len(coords)) |
| 206 | for _, c := range coords { |
| 207 | rowPos, ok := idx.rowPos[c.RowID] |
| 208 | if !ok { |
| 209 | continue |
| 210 | } |
| 211 | colPos, ok := idx.colPos[c.ColID] |
| 212 | if !ok { |
| 213 | continue |
| 214 | } |
| 215 | raw, _ := h.RawInput(c.RowID, c.ColID) |
| 216 | cells = append(cells, gridCell{ |
| 217 | R: rowPos, C: colPos, |
| 218 | Raw: raw, |
| 219 | Display: display(h.Value(c.RowID, c.ColID), col.numFmtFor(c.RowID, c.ColID)), |
| 220 | Num: isNumber(h.Value(c.RowID, c.ColID)), |
| 221 | F: col.keyFor(c.RowID, c.ColID), |
| 222 | }) |
| 223 | } |
| 224 | return gridResponse{ |
| 225 | // The tab's version is unchanged: a format writes no input |
| 226 | // event, so nothing has advanced it. Sending the current |
| 227 | // version rather than zero matters — applyBatch discards a |
| 228 | // batch whose version TRAILS what it has painted, and zero |
| 229 | // would be discarded by every client that had ever seen an |
| 230 | // edit. |
| 231 | Version: h.Version(), |
| 232 | Cells: cells, |
| 233 | Formats: col.done(), |
| 234 | Client: client, |
| 235 | }, nil |
| 236 | } |
| 237 | |