1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
package main

// Attaching files to a draft.
//
// This is the second thing mailweb stores that did not come off the IMAP server
// — the first being the draft itself — and the same reasoning applies with more
// force. The mirror keeps headers and one display part per message because
// anything it drops can be fetched again; an attachment on an unsent draft
// exists nowhere else. Until the mail goes out, this table is the only copy.
//
// Two rules shape the file.
//
// The bytes decide the type, never the request. A browser sends the type its
// operating system guessed from a file extension, and curl sends whatever it is
// told; both are the sender's word about their own data. mailweb already draws
// this line for the mail it displays — the inline PDF route asserts
// application/pdf only after looking at the magic number — and an attachment
// mailweb is about to send under the account owner's name deserves the same
// treatment as one somebody else sent to it.
//
// Nothing is stored unbounded. A draft is reachable by anything that can reach
// the listen address, so an upload route with no cap is a way to fill the disk
// from a loopback port. The caps are stated in the refusal, since a client that
// is told "too large" and not told the limit can only guess.

import (
	"database/sql"
	"errors"
	"fmt"
	"io"
	"mime"
	"net/http"
	"path/filepath"
	"strconv"
	"strings"

	"codeberg.org/Profpatsch/Profpatsch/users/Profpatsch/mailtext"
)

// The caps on what a draft may carry.
//
// maxAssetSize is per file and maxDraftAssets per draft. The figures are chosen
// against what mail servers actually accept: base64 inflates by a third, so a
// 30MB draft is roughly 40MB on the wire, which is at or just under the common
// 40–50MB limit. A larger cap here would only move the refusal from mailweb,
// which can explain it, to the SMTP server, which reports it as a number after
// the upload has already been made.
const (
	maxAssetSize     = 25 << 20 // 25 MB, one file
	maxDraftAssets   = 30 << 20 // 30 MB, all files on one draft
	maxAssetFilename = 200      // characters, so a name fits in a header
)

// detectAssetType decides what an uploaded file is, from the bytes first and
// the name second.
//
// http.DetectContentType implements the WHATWG sniffing algorithm over the
// first 512 bytes, which recognises every image format a phone produces, PDFs,
// zips and the common audio and video containers. It is deliberately
// conservative and answers application/octet-stream when it does not know,
// which is where the filename gets a say: a .docx or a .csv has no magic number
// worth the name, and an extension is better than nothing for a format whose
// bytes say nothing.
//
// The order matters and is the whole point. If the name were consulted first,
// a file called invoice.pdf would be labelled application/pdf whatever it
// contained, and mailweb would be asserting to a recipient something it never
// checked. Taking the bytes first means the claim mailweb makes is one it
// verified, and the name only fills a silence.
//
// The request's own Content-Type is never consulted at all.
func detectAssetType(data []byte, filename string) string {
	sniffed := http.DetectContentType(data)
	if sniffed != "application/octet-stream" {
		// DetectContentType appends a charset for text types, which belongs in
		// the header it writes; the stored type is the media type itself.
		if base, _, err := mime.ParseMediaType(sniffed); err == nil {
			return base
		}
		return sniffed
	}
	if ext := filepath.Ext(filename); ext != "" {
		if byName := mime.TypeByExtension(ext); byName != "" {
			if base, _, err := mime.ParseMediaType(byName); err == nil {
				return base
			}
		}
	}
	return "application/octet-stream"
}

// assetFilename cleans a name so it is a name and not a path.
//
// A filename arrives from whoever uploaded the file and ends up in a
// Content-Disposition header. Everything a path could mean is stripped: only
// the base is kept, so "../../etc/passwd" becomes "passwd", and the result goes
// through the same sanitiser the attachment routes use for quotes and control
// characters. A name that cleans away to nothing gets a neutral one rather than
// an empty parameter.
func assetFilename(name string) string {
	// Both separators, because a name typed on Windows arrives with
	// backslashes and filepath.Base on Linux would not treat them as path
	// separators at all.
	name = strings.ReplaceAll(name, "\\", "/")
	name = filepath.Base(strings.TrimSpace(name))
	name = sanitiseFilename(name)
	if name == "." || name == ".." || name == "/" {
		name = ""
	}
	if len([]rune(name)) > maxAssetFilename {
		name = string([]rune(name)[:maxAssetFilename])
	}
	if name == "" {
		return "attachment"
	}
	return name
}

// handleDraftAssetUpload attaches a file to a draft: POST /draft/{token}/assets
//
// It accepts two encodings on one URL because it has two clients that cannot
// share one. A browser sends multipart/form-data, which is what a plain
// <input type=file multiple> produces and is how six photos are attached
// without any script running. Anything else is read as the raw body, so
// `curl --data-binary @foto.jpg '…/assets?filename=foto.jpg'` works, which is
// what a client driving mailweb through its text interface has.
//
// This is inert in the sense "Composing without committing" means: the bytes
// land in a table on this machine and nothing goes anywhere. Sending is a
// separate route and remains the act.
func (s *server) handleDraftAssetUpload(w http.ResponseWriter, r *http.Request) {
	d, ok := s.editableDraftForm(w, r)
	if !ok {
		return
	}

	used, err := draftAssetsSize(s.db.Read, d.ID)
	if err != nil {
		http.Error(w, fmt.Sprintf("db error: %v", err), http.StatusInternalServerError)
		return
	}

	// The reader is capped before anything is read, so an oversized upload is
	// refused as it arrives rather than after it has been buffered. The cap is
	// the whole-draft budget plus one file's worth of slack, which is what
	// lets the specific per-file and per-draft messages below be reached with
	// their own wording instead of everything failing as one opaque limit.
	r.Body = http.MaxBytesReader(w, r.Body, maxDraftAssets+maxAssetSize)

	type upload struct {
		name string
		data []byte
	}
	var uploads []upload

	if strings.HasPrefix(r.Header.Get("Content-Type"), "multipart/form-data") {
		// ParseMultipartForm's argument is how much is held in memory before
		// it spills to a temporary file; the MaxBytesReader above is what
		// actually bounds the request.
		if err := r.ParseMultipartForm(8 << 20); err != nil {
			http.Error(w, uploadTooLargeMessage(used, err), uploadErrorCode(err))
			return
		}
		files := r.MultipartForm.File["file"]
		if len(files) == 0 {
			http.Error(w,
				"no file in the request. Send it as multipart/form-data under the "+
					"field name `file` (which may be repeated), or send the bytes as "+
					"the whole request body with ?filename= naming it.",
				http.StatusBadRequest)
			return
		}
		for _, fh := range files {
			f, err := fh.Open()
			if err != nil {
				http.Error(w, fmt.Sprintf("read upload: %v", err), http.StatusBadRequest)
				return
			}
			data, err := io.ReadAll(f)
			f.Close()
			if err != nil {
				http.Error(w, uploadTooLargeMessage(used, err), uploadErrorCode(err))
				return
			}
			uploads = append(uploads, upload{name: fh.Filename, data: data})
		}
	} else {
		data, err := io.ReadAll(r.Body)
		if err != nil {
			http.Error(w, uploadTooLargeMessage(used, err), uploadErrorCode(err))
			return
		}
		if len(data) == 0 {
			http.Error(w,
				"the request body is empty, so there is nothing to attach. Send the "+
					"file's bytes as the body with ?filename= naming it, or send "+
					"multipart/form-data with the file under the field name `file`.",
				http.StatusBadRequest)
			return
		}
		uploads = append(uploads, upload{
			name: r.URL.Query().Get("filename"),
			data: data,
		})
	}

	// Every file is checked against both caps before any of them is stored, so
	// a batch that does not fit is refused whole. Storing the first four of six
	// photos and reporting a failure would leave the draft in a state nobody
	// asked for and nothing describes.
	total := used
	for _, u := range uploads {
		if int64(len(u.data)) > maxAssetSize {
			http.Error(w, fmt.Sprintf(
				"%s is %s, and one file may be at most %s. Nothing was attached.",
				assetFilename(u.name), humanSize(int64(len(u.data))), humanSize(maxAssetSize)),
				http.StatusRequestEntityTooLarge)
			return
		}
		total += int64(len(u.data))
	}
	if total > maxDraftAssets {
		http.Error(w, fmt.Sprintf(
			"this draft already carries %s and the upload adds %s, over the %s a "+
				"draft may hold. Nothing was attached; remove an attachment or send "+
				"them as two mails.",
			humanSize(used), humanSize(total-used), humanSize(maxDraftAssets)),
			http.StatusRequestEntityTooLarge)
		return
	}

	for _, u := range uploads {
		name := assetFilename(u.name)
		if _, err := addFileBlock(
			s.db.Write, d.ID, name, detectAssetType(u.data, name), u.data,
		); err != nil {
			http.Error(w, fmt.Sprintf("db error: %v", err), http.StatusInternalServerError)
			return
		}
	}

	if mailtext.WantsLLM(r) {
		w.Header().Set("Content-Type", mailtext.MediaType)
		fmt.Fprintf(w, "attached %d file%s to /draft/%s\n\nNothing has been sent.\n",
			len(uploads), plural(len(uploads)), d.Token)
		return
	}
	http.Redirect(w, r, "/draft/"+d.Token, http.StatusSeeOther)
}

// handleDraftAsset serves one attachment back:
// GET /draft/{token}/assets/{asset}
//
// It exists so that what is about to be sent can be looked at first — a photo
// attached from a phone is worth seeing before it goes to a stranger. The
// headers are those of the attachment routes and for the same reasons: the
// stored type, which came from the bytes; nosniff, so a browser renders that or
// nothing; and a sandbox policy, because these bytes are displayed in a page of
// mailweb's own origin and an uploaded SVG or HTML file is script if anything
// lets it be.
func (s *server) handleDraftAsset(w http.ResponseWriter, r *http.Request) {
	d, ok := s.draftOf(w, r)
	if !ok {
		return
	}
	assetID, err := strconv.ParseInt(r.PathValue("asset"), 10, 64)
	if err != nil {
		http.Error(w, "invalid asset id", http.StatusBadRequest)
		return
	}
	// Scoped to this draft, so an asset id guessed from another draft is a 404
	// exactly as an id that never existed is.
	asset, err := loadDraftAsset(s.db.Read, d.ID, assetID)
	if errors.Is(err, sql.ErrNoRows) {
		http.NotFound(w, r)
		return
	}
	if err != nil {
		http.Error(w, fmt.Sprintf("db error: %v", err), http.StatusInternalServerError)
		return
	}

	w.Header().Set("Content-Type", asset.MimeType)
	w.Header().Set("X-Content-Type-Options", "nosniff")
	// An uploaded file is displayed inside mailweb's own origin, which no
	// message body ever is. An SVG is a document that can carry script and an
	// HTML file plainly is one, so the response is put in an opaque origin the
	// same way a framed PDF is: it renders, and it can reach nothing of
	// mailweb's.
	w.Header().Set("Content-Security-Policy", "sandbox")
	w.Header().Set("Content-Disposition",
		fmt.Sprintf("inline; filename=%q", sanitiseFilename(asset.Filename)))
	w.Write(asset.Bytes)
}

// handleDraftAssetRemove drops one attachment:
// POST /draft/{token}/assets/{asset}/remove
//
// A form post rather than the editor's DELETE, because the draft page offers it
// without script and a browser form can only GET or POST. It removes the block
// that carries the asset, and deleteDraftBlock takes the asset with it — there
// is no way to detach one from the other, which is what keeps the pair from
// drifting apart.
func (s *server) handleDraftAssetRemove(w http.ResponseWriter, r *http.Request) {
	d, ok := s.editableDraftForm(w, r)
	if !ok {
		return
	}
	assetID, err := strconv.ParseInt(r.PathValue("asset"), 10, 64)
	if err != nil {
		http.Error(w, "invalid asset id", http.StatusBadRequest)
		return
	}

	// The block is found through the draft that was already resolved, so an
	// asset id belonging to another draft finds nothing here rather than being
	// deleted through this one's URL.
	var blockID int64
	for _, b := range d.Blocks {
		if b.AssetID == assetID {
			blockID = b.ID
			break
		}
	}
	if blockID == 0 {
		http.NotFound(w, r)
		return
	}
	if err := deleteDraftBlock(s.db.Write, d.ID, blockID); err != nil &&
		!errors.Is(err, sql.ErrNoRows) {
		http.Error(w, fmt.Sprintf("db error: %v", err), http.StatusInternalServerError)
		return
	}

	if mailtext.WantsLLM(r) {
		w.Header().Set("Content-Type", mailtext.MediaType)
		fmt.Fprintf(w, "removed the attachment from /draft/%s\n", d.Token)
		return
	}
	http.Redirect(w, r, "/draft/"+d.Token, http.StatusSeeOther)
}

// uploadErrorCode maps a read failure to a status.
//
// MaxBytesReader reports its own error type when the cap is hit, which is a 413
// rather than a 400: the request was well formed and too big, and telling a
// client it was malformed would send it looking for the wrong bug.
func uploadErrorCode(err error) int {
	var tooLarge *http.MaxBytesError
	if errors.As(err, &tooLarge) {
		return http.StatusRequestEntityTooLarge
	}
	return http.StatusBadRequest
}

// uploadTooLargeMessage words a read failure, naming the limit when that is
// what was hit. A refusal that does not say the limit leaves the client to
// discover it by bisection.
func uploadTooLargeMessage(used int64, err error) string {
	var tooLarge *http.MaxBytesError
	if errors.As(err, &tooLarge) {
		return fmt.Sprintf(
			"the upload is too large. One file may be at most %s and a draft may "+
				"hold %s in total; this draft already carries %s. Nothing was attached.",
			humanSize(maxAssetSize), humanSize(maxDraftAssets), humanSize(used))
	}
	return fmt.Sprintf("could not read the upload: %v", err)
}

// humanSize renders a byte count the way the size hints do, so a limit and a
// message size are stated in the same units.
func humanSize(n int64) string {
	switch {
	case n >= 1<<20:
		return fmt.Sprintf("%.1fMB", float64(n)/(1<<20))
	case n >= 1<<10:
		return fmt.Sprintf("%.0fKB", float64(n)/(1<<10))
	default:
		return fmt.Sprintf("%dB", n)
	}
}

func plural(n int) string {
	if n == 1 {
		return ""
	}
	return "s"
}