oficina / sheets Public

Clone
git clone https://amadan.net/oficina/sheets

Plain git — no account needed to clone.

Download

Download this file

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.
5package sheets
6
7import (
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.
30func 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.
61func 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.
76func 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".
85func 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