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 "bytes"
5 "context"
6 "errors"
7 "net/http"
8 "strconv"
9
10 "amadan.net/rastrillo/rastrillo/jobs"
11 "amadan.net/rastrillo/rastrillo/sessions"
12
13 "amadan.net/oficina/sheets/internal/sheets/xlsx"
14)
15
16// maxImportBytes caps an upload. A tab holds store.MaxCellsPerTab
17// (200,000) cells and an XLSX file is compressed, so 20 MB is far more
18// than a workbook that fits can weigh — the cap is here to bound what
19// one request can make the process hold, not to express a product
20// limit. The cell cap is the product limit, and it is checked by
21// xlsx.Import against the real cell count rather than guessed from a
22// byte count.
23const maxImportBytes = 20 << 20
24
25// multipartSlack is what MaxBytesReader gets ON TOP of maxImportBytes.
26//
27// MaxBytesReader bounds the whole request body — part headers, boundary
28// markers and the trailing boundary as well as the file — so reusing
29// maxImportBytes as the body limit refuses a file of exactly the size
30// the form advertises, and a slightly smaller one too. The form says
31// 20 MB, so 20 MB has to get through; the slack covers the framing and
32// the file's own size is then checked exactly, against the bytes that
33// actually arrived.
34const multipartSlack = 1 << 20
35
36// importField is the file input's name, in the form and in the handler.
37const importField = "file"
38
39// importJobName is what the status page calls this job, and it is a
40// CONSTANT rather than the uploaded filename on purpose: rastrillo's
41// jobs package logs the name on every start, finish and failure
42// (jobs.run's slog calls), and `carlos logs` is platform-held storage
43// we cannot grep. A filename is a sheet title in waiting — AGENTS.md
44// forbids it in a log line exactly as it forbids a cell's contents.
45const importJobName = "Spreadsheet import"
46
47// workbookExts are the extensions import accepts. Both are ordinary
48// Excel workbooks; .xlsm differs only by carrying macros, which we
49// ignore rather than run.
50//
51// Checked before a sheet is created, because an extension is free to
52// check and a person who dropped a PDF should be told so immediately
53// rather than through a background job that had to build a sheet in
54// order to fail.
55var workbookExts = map[string]bool{".xlsx": true, ".xlsm": true}
56
57// Refusals a person can act on. Each is a whole sentence naming what to
58// do differently, because this text is the only thing a person who
59// dropped the wrong file has to go on.
60const (
61 refusalNoFile = "Choose a spreadsheet to import."
62 refusalNotWorkbook = "That is not an Excel workbook. Import takes .xlsx and .xlsm files."
63 refusalTooLarge = "That file is bigger than 20 MB, which is more than import takes."
64 refusalBusy = "You already have an import running. Wait for it to finish, then try again."
65 refusalUnreadable = "That upload could not be read."
66)
67
68// handleSheetImport takes an uploaded workbook, makes the sheet it will
69// land in, and runs the import as a background job.
70//
71// The sheet is created HERE, synchronously, rather than inside the job,
72// for one reason: jobs.Start needs the finished job's location up front,
73// and that location must be a path this code built rather than anything
74// a user supplied (jobs.Start's own contract). Knowing the sheet id
75// before the job starts is what makes "/sheets/{id}" constructible.
76//
77// The cost is a sheet row that exists before its contents do, and
78// createFillingSheet is what pays it: the row is invisible on every
79// read path until runImport reveals it. Cleanup is then a tidiness
80// measure rather than the thing correctness rests on — which matters,
81// because the one failure no cleanup can cover is the process dying
82// mid-import.
83func (a *App) handleSheetImport(w http.ResponseWriter, r *http.Request) {
84 actor, ok := a.actor(r)
85 if !ok {
86 a.forbidden(w, r)
87 return
88 }
89 // jobs keys ownership on the session Subject, and jobs.Handlers
90 // reads that same value back out of sessions.Current to decide who
91 // may see a job. Starting one under any other identifier makes it
92 // unreachable: Get answers only the owner, so the page we are about
93 // to redirect to would 404 — a working import that looks like a
94 // broken one.
95 sess, ok := sessions.Current(r)
96 if !ok {
97 a.forbidden(w, r)
98 return
99 }
100
101 // Reclaim here as well as at boot. Boot alone leaves a gap with no
102 // second chance in it: a process that dies during a YOUNG import is
103 // replaced by one that starts before the threshold, finds nothing
104 // old enough to sweep, and never looks again — so the hidden row,
105 // its cells and its grant sit there until some later restart
106 // happens to fall the right side of the age check. Importing is the
107 // natural moment to look, it is one indexed query, and anybody
108 // affected by an abandoned import is by definition someone who
109 // imports.
110 a.sweepStaleImports()
111
112 data, filename, refusal := readUpload(w, r)
113 if refusal != "" {
114 a.renderImportPage(w, r, http.StatusBadRequest, refusal)
115 return
116 }
117
118 s, err := a.createFillingSheet(titleFromFilename(filename), actor)
119 if err != nil {
120 a.fail(w, r, err)
121 return
122 }
123
124 location := "/sheets/" + strconv.FormatUint(s.ID, 10)
125 job, err := a.jobs.Start(sess.Subject, importJobName, location,
126 func(ctx context.Context, progress func(string)) error {
127 return a.runImport(ctx, s, data, progress)
128 })
129 if err != nil {
130 // The sheet was made for a job that never started, so it holds
131 // nothing and nobody asked for it.
132 a.discardSheet(s.ID)
133 if errors.Is(err, jobs.ErrOwnerBusy) {
134 a.renderImportPage(w, r, http.StatusTooManyRequests, refusalBusy)
135 return
136 }
137 a.fail(w, r, err)
138 return
139 }
140 if wantsNative(r) {
141 nativeJSON(w, 202, map[string]string{"job": job.ID})
142 return
143 }
144 http.Redirect(w, r, "/jobs/"+job.ID, http.StatusSeeOther)
145}
146
147// runImport is the job body: open the new sheet's tab, read the
148// workbook into it, and either reveal the sheet or leave nothing
149// behind.
150//
151// The sheet is invisible for the whole of this function
152// (createFillingSheet, store.Sheet.FillingSince) and becomes visible
153// only on the last line. That is what makes the three ways this can go
154// wrong all end somewhere safe: a handled failure discards it, an
155// unhandled one leaves it hidden, and a process that dies mid-import —
156// a deploy, which the jobs package warns is a harder deadline than any
157// timeout — leaves a hidden row rather than a half-filled sheet the
158// whole team can see and nothing left alive to clean it up.
159//
160// It is not interruptible. xlsx.Import takes no context and runs to
161// completion, so ctx is checked at the top and not again — an honest
162// statement of what cancellation does here rather than a select that
163// implies more than it delivers.
164func (a *App) runImport(ctx context.Context, s SheetSummary, data []byte, progress func(string)) error {
165 if err := ctx.Err(); err != nil {
166 a.discardSheet(s.ID)
167 return err
168 }
169 progress("reading the workbook")
170
171 h, err := a.openTabFor(s)
172 if err != nil {
173 a.discardSheet(s.ID)
174 return errors.New("that sheet could not be opened")
175 }
176 defer h.Release()
177
178 if err := xlsx.Import(bytes.NewReader(data), h); err != nil {
179 a.discardSheet(s.ID)
180 return importFailure(err)
181 }
182 // Reveal it, and only then report success. If this fails the sheet
183 // stays hidden and the job says so, which is the right way round:
184 // the alternative is telling somebody their import worked and
185 // sending them to a sheet the list will not show.
186 if err := a.markFilled(s.ID); err != nil {
187 return errors.New("that sheet could not be opened")
188 }
189 // The list orders by real activity, and an imported sheet's
190 // activity is the import.
191 a.touchSheet(s.ID)
192 return nil
193}
194