#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,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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user