#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()
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"palette/internal/store"
|
||||
)
|
||||
|
||||
// multipartCreate posts a multipart create to the routes handler; extra
|
||||
// fields are appended as text parts. Returns recorder and parsed response.
|
||||
func multipartCreate(t *testing.T, h http.Handler, filename string, content []byte, fields map[string]string) (*httptest.ResponseRecorder, map[string]any) {
|
||||
t.Helper()
|
||||
var buf bytes.Buffer
|
||||
mw := multipart.NewWriter(&buf)
|
||||
if filename != "" {
|
||||
fw, _ := mw.CreateFormFile("file", filename)
|
||||
fw.Write(content)
|
||||
}
|
||||
for k, v := range fields {
|
||||
mw.WriteField(k, v)
|
||||
}
|
||||
mw.Close()
|
||||
req := httptest.NewRequest("POST", "/api/pastes", &buf)
|
||||
req.Header.Set("Content-Type", mw.FormDataContentType())
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
var resp map[string]any
|
||||
json.Unmarshal(rec.Body.Bytes(), &resp)
|
||||
return rec, resp
|
||||
}
|
||||
|
||||
func TestMultipartAttachmentCreateAndServe(t *testing.T) {
|
||||
s := testServer(t)
|
||||
h := s.routes()
|
||||
|
||||
png := append([]byte("\x89PNG\r\n\x1a\n"), bytes.Repeat([]byte{0, 1, 2, 3}, 32)...)
|
||||
rec, resp := multipartCreate(t, h, "shot.png", png, map[string]string{"title": "with file"})
|
||||
if rec.Code != 201 {
|
||||
t.Fatalf("create: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
att, _ := resp["attachment"].(map[string]any)
|
||||
if att == nil {
|
||||
t.Fatalf("no attachment in response: %v", resp)
|
||||
}
|
||||
id, _ := att["id"].(string)
|
||||
url, _ := att["url"].(string)
|
||||
if url != "/f/"+id+"/shot.png" {
|
||||
t.Fatalf("attachment url = %q", url)
|
||||
}
|
||||
if att["mime"] != "image/png" {
|
||||
t.Fatalf("sniffed mime = %v want image/png", att["mime"])
|
||||
}
|
||||
|
||||
// serve: image mime -> inline, nosniff, stored bytes
|
||||
req := httptest.NewRequest("GET", url, nil)
|
||||
rec2 := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec2, req)
|
||||
if rec2.Code != 200 {
|
||||
t.Fatalf("serve: %d %s", rec2.Code, rec2.Body.String())
|
||||
}
|
||||
if got := rec2.Header().Get("Content-Type"); got != "image/png" {
|
||||
t.Fatalf("Content-Type = %q", got)
|
||||
}
|
||||
if got := rec2.Header().Get("X-Content-Type-Options"); got != "nosniff" {
|
||||
t.Fatalf("nosniff = %q", got)
|
||||
}
|
||||
if got := rec2.Header().Get("Content-Disposition"); !strings.HasPrefix(got, "inline") {
|
||||
t.Fatalf("Content-Disposition = %q", got)
|
||||
}
|
||||
if !bytes.Equal(rec2.Body.Bytes(), png) {
|
||||
t.Fatal("served bytes differ from upload")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMultipartFileReplacesText(t *testing.T) {
|
||||
s := testServer(t)
|
||||
h := s.routes()
|
||||
rec, resp := multipartCreate(t, h, "notes.txt", []byte("file body"), map[string]string{"content": "some text"})
|
||||
if rec.Code != 201 {
|
||||
t.Fatalf("create: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
id, _ := resp["id"].(string)
|
||||
req := httptest.NewRequest("GET", "/api/pastes/"+id, nil)
|
||||
rec2 := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec2, req)
|
||||
var got map[string]any
|
||||
json.Unmarshal(rec2.Body.Bytes(), &got)
|
||||
if got["content"] != "" {
|
||||
t.Fatalf("content should be empty when file provided, got %v", got["content"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestMultipartSecondFileRejected(t *testing.T) {
|
||||
s := testServer(t)
|
||||
h := s.routes()
|
||||
var buf bytes.Buffer
|
||||
mw := multipart.NewWriter(&buf)
|
||||
for _, name := range []string{"a.txt", "b.txt"} {
|
||||
fw, _ := mw.CreateFormFile("file", name)
|
||||
fw.Write([]byte("x"))
|
||||
}
|
||||
mw.Close()
|
||||
req := httptest.NewRequest("POST", "/api/pastes", &buf)
|
||||
req.Header.Set("Content-Type", mw.FormDataContentType())
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != 400 {
|
||||
t.Fatalf("two files: got %d want 400", rec.Code)
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), "one_file_only") {
|
||||
t.Fatalf("error code missing: %s", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestMultipartHtmlUploadServesAsPlainText(t *testing.T) {
|
||||
s := testServer(t)
|
||||
h := s.routes()
|
||||
html := []byte("<html><script>alert(1)</script></html>")
|
||||
rec, resp := multipartCreate(t, h, "page.html", html, nil)
|
||||
if rec.Code != 201 {
|
||||
t.Fatalf("create: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
url, _ := resp["attachment"].(map[string]any)["url"].(string)
|
||||
req := httptest.NewRequest("GET", url, nil)
|
||||
rec2 := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec2, req)
|
||||
if got := rec2.Header().Get("Content-Type"); got != "text/plain; charset=utf-8" {
|
||||
t.Fatalf("html served as %q, want text/plain", got)
|
||||
}
|
||||
if got := rec2.Header().Get("Content-Disposition"); !strings.HasPrefix(got, "attachment") {
|
||||
t.Fatalf("html Content-Disposition = %q, want attachment", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMultipartSvgUploadServesAsPlainText(t *testing.T) {
|
||||
s := testServer(t)
|
||||
h := s.routes()
|
||||
svg := []byte(`<?xml version="1.0"?><svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script></svg>`)
|
||||
rec, resp := multipartCreate(t, h, "evil.svg", svg, nil)
|
||||
if rec.Code != 201 {
|
||||
t.Fatalf("create: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
url, _ := resp["attachment"].(map[string]any)["url"].(string)
|
||||
req := httptest.NewRequest("GET", url, nil)
|
||||
rec2 := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec2, req)
|
||||
ct := rec2.Header().Get("Content-Type")
|
||||
if strings.Contains(ct, "svg") || strings.Contains(ct, "html") {
|
||||
t.Fatalf("svg served as %q", ct)
|
||||
}
|
||||
if ct != "text/plain; charset=utf-8" {
|
||||
t.Fatalf("svg Content-Type = %q", ct)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMultipartClientMimeIgnored(t *testing.T) {
|
||||
// client claims image/png; server must sniff the real type (text)
|
||||
s := testServer(t)
|
||||
h := s.routes()
|
||||
var buf bytes.Buffer
|
||||
mw := multipart.NewWriter(&buf)
|
||||
fw, _ := mw.CreateFormFile("file", "fake.png")
|
||||
fw.Write([]byte("just plain text, definitely not a png"))
|
||||
// note: CreateFormFile sets Content-Type: application/octet-stream; the
|
||||
// sniffed type for text content is text/plain either way.
|
||||
mw.Close()
|
||||
req := httptest.NewRequest("POST", "/api/pastes", &buf)
|
||||
req.Header.Set("Content-Type", mw.FormDataContentType())
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != 201 {
|
||||
t.Fatalf("create: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var resp map[string]any
|
||||
json.Unmarshal(rec.Body.Bytes(), &resp)
|
||||
att := resp["attachment"].(map[string]any)
|
||||
if att["mime"] != "text/plain; charset=utf-8" && att["mime"] != "text/plain" {
|
||||
t.Fatalf("mime = %v, want sniffed text/plain", att["mime"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestMultipartOversizeRejected(t *testing.T) {
|
||||
s := testServer(t)
|
||||
h := s.routes()
|
||||
big := bytes.Repeat([]byte("A"), MaxAttachmentBytes+1024)
|
||||
rec, _ := multipartCreate(t, h, "big.bin", big, nil)
|
||||
if rec.Code != http.StatusRequestEntityTooLarge {
|
||||
t.Fatalf("oversize: got %d want 413", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMultipartExactlyAtLimitAccepted(t *testing.T) {
|
||||
s := testServer(t)
|
||||
h := s.routes()
|
||||
exact := bytes.Repeat([]byte("A"), MaxAttachmentBytes)
|
||||
rec, resp := multipartCreate(t, h, "exact.bin", exact, nil)
|
||||
if rec.Code != 201 {
|
||||
t.Fatalf("at-limit: got %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
att := resp["attachment"].(map[string]any)
|
||||
if att["size"].(float64) != float64(MaxAttachmentBytes) {
|
||||
t.Fatalf("size = %v", att["size"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestMultipartEmptyFileRejected(t *testing.T) {
|
||||
s := testServer(t)
|
||||
h := s.routes()
|
||||
rec, _ := multipartCreate(t, h, "empty.txt", nil, nil)
|
||||
if rec.Code != 400 {
|
||||
t.Fatalf("empty file: got %d want 400", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeAttachment404Missing(t *testing.T) {
|
||||
s := testServer(t)
|
||||
h := s.routes()
|
||||
req := httptest.NewRequest("GET", "/f/zzzzzzzz/nonexistent.txt", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != 404 {
|
||||
t.Fatalf("missing attachment: got %d want 404", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeAttachmentUnknownPaste404(t *testing.T) {
|
||||
// attachment row referencing a paste that doesn't exist must 404, not leak
|
||||
s := testServer(t)
|
||||
h := s.routes()
|
||||
s.store.CreatePaste(&store.Paste{Content: "x"})
|
||||
att := store.Attachment{PasteID: "ghost00", Filename: "f.txt", Mime: "text/plain"}
|
||||
blobs := s.store.Blobs()
|
||||
if err := s.store.CreateAttachment(&att, strings.NewReader("hello"), blobs); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req := httptest.NewRequest("GET", "/f/"+att.ID+"/f.txt", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != 404 {
|
||||
t.Fatalf("orphan attachment: got %d want 404", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeAttachmentPdfInline(t *testing.T) {
|
||||
s := testServer(t)
|
||||
h := s.routes()
|
||||
pdf := []byte("%PDF-1.4\n%fake pdf body\n")
|
||||
rec, resp := multipartCreate(t, h, "doc.pdf", pdf, nil)
|
||||
if rec.Code != 201 {
|
||||
t.Fatalf("create: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
url, _ := resp["attachment"].(map[string]any)["url"].(string)
|
||||
req := httptest.NewRequest("GET", url, nil)
|
||||
rec2 := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec2, req)
|
||||
if got := rec2.Header().Get("Content-Type"); !strings.HasPrefix(got, "application/pdf") {
|
||||
t.Fatalf("pdf Content-Type = %q", got)
|
||||
}
|
||||
if got := rec2.Header().Get("Content-Disposition"); !strings.HasPrefix(got, "inline") {
|
||||
t.Fatalf("pdf Content-Disposition = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeAttachmentBurnedPaste404(t *testing.T) {
|
||||
s := testServer(t)
|
||||
h := s.routes()
|
||||
rec, resp := multipartCreate(t, h, "burn.txt", []byte("burn me"), nil)
|
||||
if rec.Code != 201 {
|
||||
t.Fatalf("create: %d", rec.Code)
|
||||
}
|
||||
att := resp["attachment"].(map[string]any)
|
||||
url, _ := att["url"].(string)
|
||||
pid, _ := resp["id"].(string)
|
||||
// burn the paste via API read (burn_after_read default off here, so force)
|
||||
s.store.SoftDelete(pid)
|
||||
req := httptest.NewRequest("GET", url, nil)
|
||||
rec2 := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec2, req)
|
||||
if rec2.Code != 404 {
|
||||
t.Fatalf("deleted paste attachment: got %d want 404", rec2.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMultipartPasswordFieldAccepted(t *testing.T) {
|
||||
s := testServer(t)
|
||||
h := s.routes()
|
||||
rec, resp := multipartCreate(t, h, "secret.txt", []byte("top secret"),
|
||||
map[string]string{"password": "hunter2", "expires_in": "1h"})
|
||||
if rec.Code != 201 {
|
||||
t.Fatalf("create: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if resp["attachment"] == nil {
|
||||
t.Fatal("attachment missing")
|
||||
}
|
||||
id, _ := resp["id"].(string)
|
||||
req := httptest.NewRequest("GET", "/api/pastes/"+id, nil)
|
||||
rec2 := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec2, req)
|
||||
if rec2.Code != 401 {
|
||||
t.Fatalf("paste should require password, got %d", rec2.Code)
|
||||
}
|
||||
}
|
||||
@@ -113,7 +113,8 @@ func (a *apiServer) routes() http.Handler {
|
||||
r.Get("/can/{id}", a.handleCanPage)
|
||||
r.Post("/can/{id}", a.handleCanPage)
|
||||
|
||||
// raw
|
||||
// raw + #38 attachment serving
|
||||
r.Get("/f/{aid}/{filename}", a.handleServeAttachment)
|
||||
r.Get("/raw/{id}", a.handleRaw)
|
||||
|
||||
// web pages
|
||||
@@ -180,6 +181,11 @@ func (a *apiServer) handleCreatePaste(w http.ResponseWriter, r *http.Request) {
|
||||
writeRateLimited(w, 1)
|
||||
return
|
||||
}
|
||||
ct := r.Header.Get("Content-Type")
|
||||
if strings.HasPrefix(ct, "multipart/form-data") {
|
||||
a.handleCreatePasteMultipart(w, r, s) // #38: file attachment create
|
||||
return
|
||||
}
|
||||
var p store.Paste
|
||||
if err := json.NewDecoder(r.Body).Decode(&p); err != nil {
|
||||
if isBodyTooLarge(err) { // #68: body cut off by MaxBytesReader
|
||||
|
||||
@@ -18,6 +18,10 @@ import (
|
||||
func (a *apiServer) maxRequestBody() int64 {
|
||||
s := a.settings.get()
|
||||
max := s.MaxContentBytes + 4096
|
||||
// #38: multipart create carries up to one 25 MB file plus overhead
|
||||
if a.cfg.MaxItemBytes+1<<20 > max {
|
||||
max = a.cfg.MaxItemBytes + 1<<20
|
||||
}
|
||||
if max < 64*1024 {
|
||||
max = 64 * 1024
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user