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 "net/http"
6 "strconv"
7 "strings"
8 "unicode"
9
10 "amadan.net/oficina/sheets/internal/sheets/store"
11 "amadan.net/oficina/sheets/internal/sheets/xlsx"
12)
13
14// maxFilenameRunes caps the sheet title used in a download's filename.
15//
16// Long enough that a real title survives whole, short enough that the
17// Content-Disposition header stays a header. Some proxies truncate long
18// ones, and a truncated filename is a corrupt-looking download.
19const maxFilenameRunes = 60
20
21// handleExport downloads a sheet as .xlsx.
22//
23// xlsx.Export has existed since import landed and had no route —
24// PRODUCT.md said so in as many words ("Export's HTTP surface and CSV
25// are both still to build"). A converter nothing can reach is not a
26// feature, and a File menu whose Download item is absent is the second
27// thing anybody notices about a spreadsheet.
28//
29// A VIEWER may download. That is deliberate and worth stating: this app
30// does not pretend a read-only grant stops somebody keeping a copy —
31// they can already read every cell, and a download restriction would be
32// security theatre over a person who can select-all and paste. What
33// gates the file is the grant itself, checked the same way every other
34// route checks it.
35func (a *App) handleExport(w http.ResponseWriter, r *http.Request) {
36 s, ok := a.sheetFromURL(w, r)
37 if !ok {
38 return
39 }
40 if a.db == nil || a.sealer == nil {
41 a.fail(w, r, errNoSealer)
42 return
43 }
44 h, err := store.Open(a.db, a.sealer, s.TabID)
45 if err != nil {
46 a.fail(w, r, err)
47 return
48 }
49 defer h.Release()
50
51 // Built in a buffer before a byte is sent. Streaming straight to the
52 // ResponseWriter means a failure halfway through has already sent a
53 // 200 and half a workbook, and Excel opens that as a corrupt file
54 // with no indication of what went wrong — the same reason render
55 // buffers a page.
56 var buf bytes.Buffer
57 if err := xlsx.Export(h, &buf); err != nil {
58 // The error is not surfaced: excelize's messages can quote a
59 // cell reference and a value, and this string is one hop from a
60 // log line (AGENTS.md).
61 a.fail(w, r, err)
62 return
63 }
64
65 w.Header().Set("Content-Type",
66 "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
67 w.Header().Set("Content-Disposition", contentDisposition(s))
68 w.Header().Set("Content-Length", strconv.Itoa(buf.Len()))
69 // A sealed document must not sit in a shared cache: the response
70 // body is every plaintext cell in the sheet.
71 w.Header().Set("Cache-Control", "no-store")
72 w.WriteHeader(http.StatusOK)
73 _, _ = w.Write(buf.Bytes())
74}
75
76// contentDisposition names the download, twice.
77//
78// RFC 6266's two-parameter form: an ASCII `filename` every client
79// understands, and an RFC 5987 `filename*` carrying the real title in
80// UTF-8 for every client of the last fifteen years. A client that
81// understands both must prefer `filename*`.
82//
83// Both, because the ASCII form drops every non-ASCII rune — so a suite
84// called Oficina would download "Presupuesto — año" as
85// "Presupuesto ao.xlsx" on day one. The fallback is still worth having:
86// it is what a client that cannot parse the extended form gets, and a
87// mangled name beats a refused download.
88func contentDisposition(s SheetSummary) string {
89 ascii := downloadName(s)
90 return `attachment; filename="` + ascii + `"; filename*=UTF-8''` + rfc5987(s)
91}
92
93// rfc5987 percent-encodes a filename for the extended parameter.
94//
95// Everything outside the RFC's `attr-char` set is escaped, which is
96// deliberately conservative: the set excludes the quote, the semicolon
97// and the backslash that could otherwise break out of the header, so
98// this is the encoding and the safety check at once.
99func rfc5987(s SheetSummary) string {
100 name := s.Title
101 if strings.TrimSpace(name) == "" {
102 name = "sheet-" + strconv.FormatUint(s.ID, 10)
103 }
104 var b strings.Builder
105 for _, r := range []byte(name + ".xlsx") {
106 if isAttrChar(r) {
107 b.WriteByte(r)
108 continue
109 }
110 b.WriteString("%" + strings.ToUpper(hexByte(r)))
111 if b.Len() > 400 {
112 break
113 }
114 }
115 return b.String()
116}
117
118func isAttrChar(c byte) bool {
119 switch {
120 case c >= 'A' && c <= 'Z', c >= 'a' && c <= 'z', c >= '0' && c <= '9':
121 return true
122 }
123 return strings.IndexByte("!#$&+-.^_`|~", c) >= 0
124}
125
126func hexByte(c byte) string {
127 const digits = "0123456789abcdef"
128 return string([]byte{digits[c>>4], digits[c&0x0f]})
129}
130
131// downloadName turns a sheet's title into an ASCII filename.
132//
133// ASCII, quoted, and conservative about what survives, because this
134// goes into a header whose quoting rules are older than Unicode and
135// because a filename with a quote or a newline in it is a header
136// somebody can split. The title is not secret from the person
137// downloading it — they are reading the sheet — so the sanitising here
138// is about the header, not about disclosure.
139//
140// A title that sanitises to nothing (all punctuation, or a script this
141// keeps none of) falls back to the sheet's id, which is a filename
142// somebody can still tell apart from another download.
143func downloadName(s SheetSummary) string {
144 var b strings.Builder
145 lastSpace := false
146 for _, r := range s.Title {
147 switch {
148 case r > unicode.MaxASCII:
149 continue
150 case unicode.IsLetter(r) || unicode.IsDigit(r):
151 b.WriteRune(r)
152 lastSpace = false
153 case r == '-' || r == '_':
154 b.WriteRune(r)
155 lastSpace = false
156 case unicode.IsSpace(r):
157 // Runs of whitespace collapse to one space rather than
158 // being dropped: "Q1 Budget" should not become "Q1Budget".
159 if !lastSpace && b.Len() > 0 {
160 b.WriteByte(' ')
161 lastSpace = true
162 }
163 }
164 if b.Len() >= maxFilenameRunes {
165 break
166 }
167 }
168 name := strings.TrimSpace(b.String())
169 if name == "" {
170 name = "sheet-" + strconv.FormatUint(s.ID, 10)
171 }
172 return name + ".xlsx"
173}
174