| 1 | // Reading an upload: what the browser sent, and whether we will take |
| 2 | // it. Split from handlers_import.go only because that file reached the |
| 3 | // 300-line cap; this half is the request-shaped part, and the other is |
| 4 | // the sheet-and-job-shaped part. |
| 5 | package sheets |
| 6 | |
| 7 | import ( |
| 8 | "errors" |
| 9 | "io" |
| 10 | "net/http" |
| 11 | "path" |
| 12 | "strings" |
| 13 | ) |
| 14 | |
| 15 | // readUpload reads the posted file into memory, bounded, and returns |
| 16 | // either its bytes and name or a refusal to show the person. |
| 17 | // |
| 18 | // Into memory rather than to a temp file because the job outlives the |
| 19 | // request: the multipart reader is closed the moment this handler |
| 20 | // returns, so the bytes have to be somewhere the goroutine still owns. |
| 21 | // maxImportBytes times jobs' own four-running-per-owner cap is what |
| 22 | // that costs at worst. |
| 23 | // |
| 24 | // The upload is deliberately NOT retained anywhere. Spec §11 says an |
| 25 | // uploaded file goes to blobs.Sealed, which is the rule for KEEPING |
| 26 | // one; not keeping it at all satisfies the same concern more simply, |
| 27 | // and the plaintext workbook never reaches storage in any form. A later |
| 28 | // task that wants the original back wires xlsx.StoreSealed in, which is |
| 29 | // what that function is already there for. |
| 30 | func readUpload(w http.ResponseWriter, r *http.Request) (data []byte, filename, refusal string) { |
| 31 | r.Body = http.MaxBytesReader(w, r.Body, maxImportBytes+multipartSlack) |
| 32 | |
| 33 | file, header, err := r.FormFile(importField) |
| 34 | if err != nil { |
| 35 | return nil, "", uploadRefusal(err) |
| 36 | } |
| 37 | defer file.Close() |
| 38 | |
| 39 | if !isWorkbookName(header.Filename) { |
| 40 | return nil, "", refusalNotWorkbook |
| 41 | } |
| 42 | body, err := io.ReadAll(file) |
| 43 | if err != nil { |
| 44 | return nil, "", uploadRefusal(err) |
| 45 | } |
| 46 | // The exact limit, checked on the file itself rather than on the |
| 47 | // body it arrived in. The slack above exists to let a genuine 20 MB |
| 48 | // file through the framing, not to raise the limit. |
| 49 | if len(body) > maxImportBytes { |
| 50 | return nil, "", refusalTooLarge |
| 51 | } |
| 52 | return body, header.Filename, "" |
| 53 | } |
| 54 | |
| 55 | // uploadRefusal turns a multipart failure into copy. |
| 56 | // |
| 57 | // A too-large body arrives here as *http.MaxBytesError, and it is worth |
| 58 | // naming separately: "that upload could not be read" sends somebody |
| 59 | // looking for a corrupt file when the real answer is that theirs is |
| 60 | // simply too big. |
| 61 | func uploadRefusal(err error) string { |
| 62 | var tooLarge *http.MaxBytesError |
| 63 | switch { |
| 64 | case errors.As(err, &tooLarge): |
| 65 | return refusalTooLarge |
| 66 | case errors.Is(err, http.ErrMissingFile): |
| 67 | return refusalNoFile |
| 68 | default: |
| 69 | return refusalUnreadable |
| 70 | } |
| 71 | } |
| 72 | |
| 73 | // isWorkbookName reports whether a filename looks like an Excel |
| 74 | // workbook. path.Base first: a filename is remote input, and while |
| 75 | // browsers send a bare name, nothing makes them. |
| 76 | func isWorkbookName(name string) bool { |
| 77 | return workbookExts[strings.ToLower(path.Ext(path.Base(name)))] |
| 78 | } |
| 79 | |
| 80 | // titleFromFilename names the sheet after the file it came from, minus |
| 81 | // the extension — the thing a person expects to see in the list. |
| 82 | // |
| 83 | // createSheet caps the length and refuses an empty title, so this only |
| 84 | // has to supply a sensible fallback for a file called ".xlsx". |
| 85 | func titleFromFilename(name string) string { |
| 86 | base := path.Base(strings.ReplaceAll(name, `\`, "/")) |
| 87 | base = strings.TrimSuffix(base, path.Ext(base)) |
| 88 | if clean := strings.TrimSpace(base); clean != "" { |
| 89 | return clean |
| 90 | } |
| 91 | return "Untitled sheet" |
| 92 | } |
| 93 | |