| 1 | package sheets |
| 2 | |
| 3 | import ( |
| 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. |
| 23 | const 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. |
| 34 | const multipartSlack = 1 << 20 |
| 35 | |
| 36 | // importField is the file input's name, in the form and in the handler. |
| 37 | const 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. |
| 45 | const 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. |
| 55 | var 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. |
| 60 | const ( |
| 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. |
| 83 | func (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. |
| 164 | func (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 | |