#38 iteration 1: file attachments, 1 file per paste
- internal/store/blob.go: BlobStore interface + fs implementation with
traversal-safe keys (<paste-id>/<sha256>), put/get/stat/delete
- attachments table migration (id, paste_id, filename sanitized to 255,
mime sniffed server-side, size, sha256, created_at)
- POST /api/pastes now accepts multipart/form-data with a 'file' part;
1 file = 1 paste: file replaces text content when both are sent
- 25 MB per-file limit enforced server-side (413 file_too_large)
- GET /f/{attachment-id}/{filename}: stored sniffed mime, nosniff,
inline only for images/pdf, html/svg/xml forced to text/plain (#34 rule)
- paste view renders attachment chip + inline image preview
- /new: dropzone with file picker, drag-and-drop, Ctrl+V file paste,
file chip with name/size/remove, matches pill/radius design
- tests: blob roundtrip/traversal/sanitize; multipart create (mime
sniffing, client mime ignored, size limit, two-file reject, html/svg
forcing, 404s, password/expiry fields)
This commit is contained in:
@@ -0,0 +1,351 @@
|
||||
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
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
Reference in New Issue
Block a user