#38 iteration 1: file attachments, 1 file per paste
CI / test (pull_request) Successful in 26s
CI / docker (pull_request) Skipped

- 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:
fen
2026-09-09 22:21:15 -05:00
parent 821f49993f
commit 4948ef9f1c
12 changed files with 1244 additions and 3 deletions
+308
View File
@@ -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)
}
}