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 "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.
20const 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.
38func (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.
127func 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.
139func 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.
154func (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.
197func 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