Files
palette/internal/api/attachments.go
T
fen 8901a3c82c
CI / test (pull_request) Successful in 25s
CI / docker (pull_request) Skipped
#281: /raw streams attachment blob for all attachment mimes
handleRaw only streamed the blob behind an isImageMime gate (#221), so
non-image attachment pastes fell through to empty row.Content and /raw
served 0 bytes. Serve the blob for every attachment mime, passing the
sniffed mime through serveContentType so active-content types (html,
svg, xml) still serve as text/plain per the #34 rule. Regression tests
cover text and html attachments (size, Content-Type, byte equality).
2026-09-17 20:31:57 -05:00

353 lines
9.7 KiB
Go

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()
}