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).
353 lines
9.7 KiB
Go
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()
|
|
}
|