package api import ( "bytes" "fmt" "io" "net/http" "strconv" "strings" "time" "github.com/go-chi/chi/v5" "palette/internal/store" ) // #38: file attachments, iteration 1: one file per paste. A paste either has // text content OR one attached file. Multipart create + /f/ serving route. const ( MaxAttachmentBytes = 25 << 20 // 25 MB per file maxFileBytesHard = MaxAttachmentBytes + 1<<20 // sniff headroom; over this reject before reading it all ) // sniffMime runs http.DetectContentType on the first 512 bytes (and any // remainder of the head) of r, returning the sniffed mime and a reader that // replays the full stream. Mime is NEVER taken from the client. func sniffMime(r io.Reader) (string, io.Reader, error) { head := make([]byte, 512) n, err := io.ReadFull(r, head) if err != nil && err != io.ErrUnexpectedEOF && err != io.EOF { return "", nil, err } head = head[:n] mime := http.DetectContentType(head) return mime, io.MultiReader(bytes.NewReader(head), r), nil } // sanitizeMimeForServing maps the stored (sniffed) mime to the Content-Type // used on /f/. Active-content types (html, svg, xml...) are forced to // text/plain — same rule as the /raw #34 fix — so a malicious upload can // never execute on this origin. func serveContentType(mime string) string { base := mime if i := strings.IndexByte(mime, ';'); i >= 0 { base = strings.TrimSpace(mime[:i]) } base = strings.ToLower(base) switch base { case "text/html", "image/svg+xml", "application/xhtml+xml", "text/xml", "application/xml", "application/xhtml", "image/xml+svg": return "text/plain; charset=utf-8" } return mime } // inlineable reports whether the sniffed mime is safe to render inline // (Content-Disposition: inline); everything else downloads as an attachment. func inlineable(mime string) bool { base := mime if i := strings.IndexByte(mime, ';'); i >= 0 { base = strings.TrimSpace(mime[:i]) } base = strings.ToLower(base) switch { case strings.HasPrefix(base, "image/"), base == "application/pdf": return true } return false } // mime/multipart parts are fully read during parsing (the multipart reader // closes each part when advancing) and the mime is sniffed from bytes. // limitAttachment rejects reads past the 25 MB per-file cap server-side. type limitReader struct { r io.Reader n int64 max int64 } func (l *limitReader) Read(p []byte) (int, error) { if l.n > l.max { return 0, store.ErrFileTooLarge } n, err := l.r.Read(p) l.n += int64(n) if l.n > l.max && err == nil { err = store.ErrFileTooLarge } return n, err } // handleCreatePasteMultipart implements POST /api/pastes with // multipart/form-data (#38). Fields mirror the JSON create path; a 'file' // part makes the paste a file paste (1 file = 1 paste: if text content is // also present, the file wins and the text is ignored — simplest correct // behavior, documented in the PR). func (a *apiServer) handleCreatePasteMultipart(w http.ResponseWriter, r *http.Request, s Settings) { blobs := a.store.Blobs() if blobs == nil { writeErr(w, 500, "blob storage unavailable") return } // guard the raw body: 25 MB file + multipart overhead headroom mr, err := r.MultipartReader() if err != nil { writeErr(w, 400, "invalid multipart body") return } var ( p store.Paste fileSeen bool att store.Attachment fileBody io.Reader ) for { part, err := mr.NextPart() if err == io.EOF { break } if err != nil { if isBodyTooLarge(err) { writeErrCode(w, http.StatusRequestEntityTooLarge, "content_too_large", "request body too large") return } writeErr(w, 400, "invalid multipart body") return } name := part.FormName() if name == "file" { if fileSeen { writeErrCode(w, 400, "one_file_only", "Choose either text or a file for now. Only one file per paste.") part.Close() return } // The part must be fully read during parsing: the multipart // reader closes it as soon as the next part is fetched. Read it // here into memory (bounded by the 25 MB cap) and sniff the mime // from the content, never from client headers. limited := io.LimitReader(part, MaxAttachmentBytes+1) raw, err := io.ReadAll(limited) part.Close() if err != nil { writeErr(w, 400, "invalid file part") return } if int64(len(raw)) > MaxAttachmentBytes { writeErrCode(w, http.StatusRequestEntityTooLarge, "file_too_large", "File is too large. The limit is 25 MB.") return } if len(raw) == 0 { writeErrCode(w, 400, "content_empty", "The file is empty.") return } filename := store.SanitizeFilename(part.FileName()) mime := http.DetectContentType(raw[:min(512, len(raw))]) att = store.Attachment{PasteID: "pending", Filename: filename, Mime: mime} fileBody = bytes.NewReader(raw) fileSeen = true continue } val, err := io.ReadAll(io.LimitReader(part, 1<<16)) part.Close() if err != nil { writeErr(w, 400, "invalid multipart field") return } v := string(val) switch name { case "content": p.Content = v case "title": p.Title = &v case "language": p.Language = &v case "custom_slug": p.CustomSlug = &v case "password": p.Password = &v case "expires_in": p.ExpiresIn = &v case "visibility": p.Visibility = v case "burn_after_read": p.BurnAfterRead = v == "true" || v == "1" || v == "on" case "burn_after_reads": if n, err := strconv.Atoi(v); err == nil { p.BurnAfterReads = &n } } } if fileSeen { // 1 file = 1 paste: the file replaces text content. p.Content = "" } else if status, msg := checkContent(p.Content, s.MaxContentBytes); status != 0 { if status == http.StatusRequestEntityTooLarge { writeErrCode(w, status, "content_too_large", msg) } else { writeErrCode(w, status, "content_empty", msg) } return } // #86 metadata bounds + default expiry: same rules as the JSON path if p.Title != nil { t, err := checkTitle(*p.Title) if err != nil { writeErr(w, 400, err.Error()) return } p.Title = &t } if p.Language != nil { l, err := checkLanguage(*p.Language) if err != nil { writeErr(w, 400, err.Error()) return } if l == "" { p.Language = nil } else { p.Language = &l } } if p.BurnAfterReads != nil { if err := parseBurnAfterReads(*p.BurnAfterReads); err != nil { writeErr(w, 400, err.Error()) return } } if (p.ExpiresIn == nil || *p.ExpiresIn == "") && s.DefaultExpiry != "" { def := s.DefaultExpiry p.ExpiresIn = &def } p.ViewerID = currentViewerID(r) created, err := a.store.CreatePaste(&p) if err != nil { writeErrCode(w, 400, createErrCode(err), err.Error()) return } setDeletionTokenCookie(w, created.ID, created.DeletionToken) // #143 resp := map[string]any{ "id": created.ID, "deletion_token": created.DeletionToken, "url": "/" + created.ID, "raw_url": "/raw/" + created.ID, "api_url": "/api/pastes/" + created.ID, "expires_at": created.ExpiresAt, "created_at": created.CreatedAt, "rate_limit": map[string]int{"create_per_sec": 1, "burst": 5}, } if fileSeen { att.PasteID = created.ID // size pre-check happens inside the limited read; re-run with limit // enforced so oversized uploads fail before the blob is stored. err := a.store.CreateAttachment(&att, fileBody, blobs) if err == store.ErrFileTooLarge { a.store.SoftDelete(created.ID) writeErrCode(w, http.StatusRequestEntityTooLarge, "file_too_large", "File is too large. The limit is 25 MB.") return } if err != nil { a.store.SoftDelete(created.ID) writeErr(w, 500, "could not store file") return } resp["attachment"] = map[string]any{ "id": att.ID, "filename": att.Filename, "mime": att.Mime, "size": att.Size, "sha256": att.SHA256, "url": "/f/" + att.ID + "/" + att.Filename, } } writeJSON(w, 201, resp) } // handleServeAttachment serves GET /f/{attachment-id}/{filename} with the // stored (server-sniffed) mime, nosniff, and a safe Content-Disposition. // The filename path segment is decorative; lookups key on the attachment id. func (a *apiServer) handleServeAttachment(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "aid") att, err := a.store.GetAttachment(id) if err != nil { writeErr(w, 500, "db error") return } if att == nil { writeErr(w, 404, "attachment not found") return } // attachment inherits the paste's lifecycle: gone if the paste is gone row, err := a.store.GetPaste(att.PasteID) if err != nil || row == nil { writeErr(w, 404, "attachment not found") return } if row.ExpiresAt.Valid && row.ExpiresAt.Int64 < time.Now().Unix() { writeErr(w, 404, "attachment not found") return } if row.Burned() { writeErr(w, 404, "attachment not found") return } blobs := a.store.Blobs() if blobs == nil { writeErr(w, 500, "blob storage unavailable") return } blob, err := blobs.Get(att.PasteID + "/" + att.SHA256) if err != nil { writeErr(w, 404, "attachment not found") return } defer blob.Close() ct := serveContentType(att.Mime) w.Header().Set("Content-Type", ct) w.Header().Set("X-Content-Type-Options", "nosniff") disposition := "attachment" if inlineable(att.Mime) { disposition = "inline" } w.Header().Set("Content-Disposition", fmt.Sprintf(`%s; filename="%s"`, disposition, asciiFilename(att.Filename))) w.Header().Set("Content-Length", fmt.Sprintf("%d", att.Size)) http.ServeContent(w, r, "", time.Unix(att.CreatedAt, 0), blob) } // asciiFilename quotes a filename for the Content-Disposition header, // escaping quotes and backslashes and dropping non-ASCII bytes. func asciiFilename(name string) string { var b strings.Builder for _, r := range name { if r < 128 && r != '"' && r != '\\' && r > 31 { b.WriteRune(r) } } if b.Len() == 0 { return "file" } return b.String() }