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
8 "github.com/go-chi/chi/v5"
9
10 "amadan.net/oficina/sheets/internal/sheets/live"
11 "amadan.net/oficina/sheets/internal/sheets/store"
12)
13
14// maxRawBytes caps one cell's input.
15//
16// A formula or a label, not a document. The engine would happily parse
17// a megabyte of nesting and spend a long time doing it, and the cell
18// that holds it is one nobody can read anyway.
19const maxRawBytes = 8 << 10
20
21// cellWrite is the POST body.
22//
23// An explicit struct, decoded field by field — never bound onto a GORM
24// model (AGENTS.md), which would let a request set UpdatedBy, a version
25// or a tab id.
26type cellWrite struct {
27 R int `json:"r"`
28 C int `json:"c"`
29 Raw string `json:"raw"`
30 Client string `json:"client"`
31
32 // FromR and FromC name the cell this input was COPIED FROM, and
33 // turn an ordinary write into spec §4.2's translation pass: every
34 // relative reference in Raw is offset by the destination minus the
35 // origin before anything is stored. Absent — the ordinary case,
36 // somebody typing — nothing is translated.
37 //
38 // Pointers, so "copied from row 0" and "not a copy" are different
39 // requests. With plain ints they are the same zero value, and every
40 // paste into row 0 or column A would translate by its full offset
41 // from a cell nobody copied.
42 FromR *int `json:"fromR"`
43 FromC *int `json:"fromC"`
44}
45
46// origin validates the copy origin and returns the offset to apply.
47//
48// The bounds are the same ones the destination is checked against, and
49// for the same reason: both come from the request. An unbounded origin
50// asks calc to offset a reference by millions and Render to print a
51// column label with six letters in it, which parses back as a name.
52func (in cellWrite) origin() (dRow, dCol int, ok bool) {
53 if in.FromR == nil || in.FromC == nil {
54 return 0, 0, true
55 }
56 if !inCellRange(*in.FromR, *in.FromC) {
57 return 0, 0, false
58 }
59 return in.R - *in.FromR, in.C - *in.FromC, true
60}
61
62// inCellRange is the one definition of "a coordinate this route will
63// accept", used for the destination and for the copy origin. Two copies
64// of it is two places for the bound to drift.
65func inCellRange(r, c int) bool {
66 return r >= 0 && c >= 0 && r < maxWindowRows*10 && c < maxWindowCols*10
67}
68
69// handleCellsGet serves a window of a tab.
70func (a *App) handleCellsGet(w http.ResponseWriter, r *http.Request) {
71 h, ok := a.openTab(w, r, SheetViewer)
72 if !ok {
73 return
74 }
75 defer h.Release()
76
77 rows := clamp(intParam(r, "rows"), defaultWindowRows, maxWindowRows)
78 cols := clamp(intParam(r, "cols"), defaultWindowCols, maxWindowCols)
79
80 firstRow, firstCol := intParam(r, "firstRow"), intParam(r, "firstCol")
81 if firstRow < 0 || firstCol < 0 || firstRow >= maxWindowRows || firstCol >= maxWindowCols {
82 http.Error(w, "Window out of range.", 400)
83 return
84 }
85 cells, styles, err := snapshotWindow(h, newPosIndex(h), firstRow, firstCol, rows, cols)
86 if err != nil {
87 a.fail(w, r, err)
88 return
89 }
90 // Sizes ride along with the window rather than needing a second
91 // round trip: they are layout, so a page that fetched them
92 // afterwards would visibly reflow every column once.
93 resp := gridResponse{
94 Version: h.Version(), Rows: rows, Cols: cols, Cells: cells, Formats: styles,
95 }
96 if cw, rh, err := h.Sizes(); err == nil && (len(cw) > 0 || len(rh) > 0) {
97 resp.Sizes = &wireSizes{Cols: intKeys(cw), Rows: intKeys(rh)}
98 }
99 writeJSON(w, http.StatusOK, resp)
100}
101
102// handleCellPost writes one cell and fans the result out.
103func (a *App) handleCellPost(w http.ResponseWriter, r *http.Request) {
104 actor, ok := a.actor(r)
105 if !ok {
106 http.Error(w, "Sign in first.", http.StatusForbidden)
107 return
108 }
109 h, ok := a.openTab(w, r, SheetEditor)
110 if !ok {
111 return
112 }
113 defer h.Release()
114
115 var in cellWrite
116 if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, maxRawBytes*2)).Decode(&in); err != nil {
117 // The body is not echoed. It is a cell's contents, and an error
118 // string is a log line waiting to happen (AGENTS.md).
119 http.Error(w, "Malformed request.", http.StatusBadRequest)
120 return
121 }
122 if !inCellRange(in.R, in.C) {
123 http.Error(w, "Cell out of range.", http.StatusBadRequest)
124 return
125 }
126 if len(in.Raw) > maxRawBytes {
127 http.Error(w, "Cell input too long.", http.StatusRequestEntityTooLarge)
128 return
129 }
130 // §4.2's translation pass, before the length check has any more
131 // work to do and before anything reaches the store: what is written
132 // and what the formula bar later shows must be the same string, so
133 // the rewrite happens here and not on the way back out.
134 dRow, dCol, ok := in.origin()
135 if !ok {
136 http.Error(w, "Cell out of range.", http.StatusBadRequest)
137 return
138 }
139 in.Raw = translateCopied(in.Raw, dRow, dCol)
140 // Re-checked AFTER translating. Render is not length-preserving —
141 // "A1" becomes "AA100" — so a formula that fitted on the way in can
142 // exceed the cap on the way to storage, and the check above would
143 // have passed it. Cheap, and the alternative is a stored cell
144 // larger than the limit this route advertises.
145 if len(in.Raw) > maxRawBytes {
146 http.Error(w, "Cell input too long.", http.StatusRequestEntityTooLarge)
147 return
148 }
149
150 // EnsureRow/EnsureCol so typing into a cell nothing has touched
151 // works: a tab starts with no rows or columns at all, and a grid
152 // coordinate has to become a stable id before anything can be
153 // written to it.
154 rowID, err := h.EnsureRow(in.R)
155 if err != nil {
156 a.fail(w, r, err)
157 return
158 }
159 colID, err := h.EnsureCol(in.C)
160 if err != nil {
161 a.fail(w, r, err)
162 return
163 }
164
165 rep, err := h.SetReport(rowID, colID, in.Raw, actor.ID)
166 if err != nil {
167 // A parse failure is the caller's fault and a storage failure is
168 // ours, but neither may quote the input, so both answer the same
169 // fixed string with the coordinate the caller already knows.
170 http.Error(w, "That cell could not be written.", http.StatusBadRequest)
171 return
172 }
173
174 cells, styles := fromReport(rep, newPosIndex(h), h)
175 resp := gridResponse{
176 Version: rep.Version,
177 Cells: cells,
178 Formats: styles,
179 Resync: rep.Capped,
180 Stamps: rep.Stamps,
181 Client: in.Client,
182 }
183 a.touchSheet(sheetIDFrom(r))
184 a.publish(tabIDFrom(r), resp)
185 writeJSON(w, http.StatusOK, resp)
186}
187
188// publish fans a write out to every other tab on this one.
189//
190// Marshalled once here rather than once per subscriber: with ten tabs
191// open, encoding per subscriber is ten encodings of identical bytes.
192func (a *App) publish(tabID uint64, resp gridResponse) {
193 if a.hub == nil || tabID == 0 {
194 return
195 }
196 data, err := marshalGrid(resp)
197 if err != nil {
198 return
199 }
200 a.hub.Publish(tabID, live.Message{Version: resp.Version, Data: data})
201}
202
203// openTab resolves {sheetID}/{tabID}, checks the viewer holds at least
204// `need` on the sheet, and opens the tab's handle. It answers for
205// itself if it cannot; the bool is whether the caller should carry on.
206//
207// The tab is checked against the sheet, not just looked up: both ids
208// come from the URL, so without the pairing check any tab could be read
209// through any sheet, and the sheet id in the path — the thing the grant
210// is keyed on — would mean nothing.
211//
212// Every refusal here is a 404. A viewer asking to write, and a stranger
213// asking to read, both get the same answer as a sheet that does not
214// exist. Anything else lets somebody enumerate the team's sheets by
215// status code.
216func (a *App) openTab(w http.ResponseWriter, r *http.Request, need SheetRole) (*store.Handle, bool) {
217 if a.db == nil || a.sealer == nil {
218 a.fail(w, r, errNoSealer)
219 return nil, false
220 }
221 viewer, ok := a.actor(r)
222 if !ok {
223 a.notFound(w, r)
224 return nil, false
225 }
226 sheetID, tabID := sheetIDFrom(r), tabIDFrom(r)
227 if sheetID == 0 || tabID == 0 || !a.tabBelongsTo(sheetID, tabID) {
228 a.notFound(w, r)
229 return nil, false
230 }
231 if role, has := a.roleFor(sheetID, viewer); !has || !role.Allows(need) {
232 a.notFound(w, r)
233 return nil, false
234 }
235 h, err := store.Open(a.db, a.sealer, tabID)
236 if err != nil {
237 a.fail(w, r, err)
238 return nil, false
239 }
240 return h, true
241}
242
243func sheetIDFrom(r *http.Request) uint64 { return uintParam(r, "sheetID") }
244func tabIDFrom(r *http.Request) uint64 { return uintParam(r, "tabID") }
245
246// uintParam reads a path parameter. A malformed one is 0, which every
247// caller treats as "not found" — a URL that was never ours.
248func uintParam(r *http.Request, name string) uint64 {
249 v, err := strconv.ParseUint(chi.URLParam(r, name), 10, 64)
250 if err != nil {
251 return 0
252 }
253 return v
254}
255
256// intParam reads a query parameter, defaulting to 0 so clamp can apply
257// the real default in one place.
258func intParam(r *http.Request, name string) int {
259 v, err := strconv.Atoi(r.URL.Query().Get(name))
260 if err != nil {
261 return 0
262 }
263 return v
264}
265
266// writeJSON sends a response. An encode failure means the connection is
267// already gone, so there is nothing left to say about it.
268func writeJSON(w http.ResponseWriter, status int, resp gridResponse) {
269 w.Header().Set("Cache-Control", "no-store")
270 w.Header().Set("Content-Type", "application/json; charset=utf-8")
271 w.WriteHeader(status)
272 _ = json.NewEncoder(w).Encode(resp)
273}
274