From 4948ef9f1cc650edb96a56935e9a6311f3e79321 Mon Sep 17 00:00:00 2001 From: fen Date: Wed, 9 Sep 2026 22:21:15 -0500 Subject: [PATCH] #38 iteration 1: file attachments, 1 file per paste - internal/store/blob.go: BlobStore interface + fs implementation with traversal-safe keys (/), 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) --- .gitignore | 1 + internal/api/attachments.go | 351 ++++++++++++++++++++++++++++++ internal/api/attachments_test.go | 308 ++++++++++++++++++++++++++ internal/api/server.go | 8 +- internal/api/validate.go | 4 + internal/store/attachment.go | 147 +++++++++++++ internal/store/blob.go | 139 ++++++++++++ internal/store/blob_test.go | 108 +++++++++ internal/store/store.go | 26 ++- internal/web/static/app.css | 11 + internal/web/templates/new.html | 131 ++++++++++- internal/web/templates/paste.html | 13 ++ 12 files changed, 1244 insertions(+), 3 deletions(-) create mode 100644 internal/api/attachments.go create mode 100644 internal/api/attachments_test.go create mode 100644 internal/store/attachment.go create mode 100644 internal/store/blob.go create mode 100644 internal/store/blob_test.go diff --git a/.gitignore b/.gitignore index a1acada..fef4f42 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ palette.db-shm palette.db-wal admin-key settings.json +:memory:.files/ diff --git a/internal/api/attachments.go b/internal/api/attachments.go new file mode 100644 index 0000000..04c4c56 --- /dev/null +++ b/internal/api/attachments.go @@ -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() +} diff --git a/internal/api/attachments_test.go b/internal/api/attachments_test.go new file mode 100644 index 0000000..c9840cc --- /dev/null +++ b/internal/api/attachments_test.go @@ -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("") + 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(``) + 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) + } +} diff --git a/internal/api/server.go b/internal/api/server.go index a1a2ae4..ff4d4a5 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -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 diff --git a/internal/api/validate.go b/internal/api/validate.go index 40c7cdb..0f2c057 100644 --- a/internal/api/validate.go +++ b/internal/api/validate.go @@ -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 } diff --git a/internal/store/attachment.go b/internal/store/attachment.go new file mode 100644 index 0000000..a944cb3 --- /dev/null +++ b/internal/store/attachment.go @@ -0,0 +1,147 @@ +package store + +import ( + "crypto/sha256" + "database/sql" + "errors" + "fmt" + "path/filepath" + "strings" + "time" +) + +// #38: file attachments, one file per paste (iteration 1). A paste either +// has text content OR one attached file. Bytes live in the BlobStore; this +// table carries the metadata. + +type Attachment struct { + ID string `json:"id"` + PasteID string `json:"paste_id"` + Filename string `json:"filename"` + Mime string `json:"mime"` + Size int64 `json:"size"` + SHA256 string `json:"sha256"` + CreatedAt int64 `json:"created_at"` + + SizeHuman string `json:"-"` // template-only: human-readable size +} + +const MaxFilenameLen = 255 + +// ErrFileTooLarge is returned when an attachment exceeds the per-file cap. +var ErrFileTooLarge = errors.New("file too large") + +// SanitizeFilename cleans a user-supplied filename: strips directory +// components, control chars, and caps at 255 bytes. The extension is never +// trusted for mime decisions (mime is sniffed server-side). +func SanitizeFilename(name string) string { + name = filepath.Base(strings.ReplaceAll(name, "\\", "/")) + name = strings.TrimSpace(name) + var b strings.Builder + for _, r := range name { + if r < 32 || r == 127 { + continue + } + b.WriteRune(r) + } + name = b.String() + if len(name) > MaxFilenameLen { + runes := []rune(name) + for len(string(runes)) > MaxFilenameLen { + runes = runes[:len(runes)-1] + } + name = string(runes) + } + if name == "" || name == "." || name == ".." { + name = "file" + } + return name +} + +// CreateAttachment stores the file bytes and inserts the attachments row. +// The mime MUST already be sniffed server-side (http.DetectContentType by +// the caller); it is never taken from the client. +func (s *Store) CreateAttachment(a *Attachment, r interface{ Read([]byte) (int, error) }, blobs BlobStore) error { + pasteID := a.PasteID + sha, size, err := blobs.Put(pasteID+"/pending", r) + if err != nil { + return err + } + if a.Size > 0 && size > a.Size { + // caller-provided pre-check limit; treat as too large + blobs.Delete(pasteID + "/" + sha) + return ErrFileTooLarge + } + a.SHA256 = sha + a.Size = size + now := time.Now().Unix() + id := genSlug(20) + _, err = s.db.Exec(`INSERT INTO attachments (id, paste_id, filename, mime, size, sha256, created_at) + VALUES (?,?,?,?,?,?,?)`, id, pasteID, a.Filename, a.Mime, size, sha, now) + if err != nil { + blobs.Delete(pasteID + "/" + sha) + return err + } + a.ID = id + a.CreatedAt = now + return nil +} + +// GetAttachment returns the attachment row for a paste, or nil. +// One file per paste (iteration 1), so a single-row lookup keyed on paste. +func (s *Store) GetAttachmentForPaste(pasteID string) (*Attachment, error) { + row := s.db.QueryRow(`SELECT id, paste_id, filename, mime, size, sha256, created_at + FROM attachments WHERE paste_id = ? ORDER BY created_at ASC LIMIT 1`, pasteID) + return scanAttachment(row) +} + +func (s *Store) GetAttachment(id string) (*Attachment, error) { + row := s.db.QueryRow(`SELECT id, paste_id, filename, mime, size, sha256, created_at + FROM attachments WHERE id = ?`, id) + return scanAttachment(row) +} + +func scanAttachment(row *sql.Row) (*Attachment, error) { + var a Attachment + err := row.Scan(&a.ID, &a.PasteID, &a.Filename, &a.Mime, &a.Size, &a.SHA256, &a.CreatedAt) + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, err + } + a.SizeHuman = humanBytes(a.Size) + return &a, nil +} + +// humanBytes formats a byte count for display (KB/MB). +func humanBytes(n int64) string { + switch { + case n >= 1<<20: + return fmt.Sprintf("%.1f MB", float64(n)/(1<<20)) + case n >= 1<<10: + return fmt.Sprintf("%.1f KB", float64(n)/(1<<10)) + default: + return fmt.Sprintf("%d B", n) + } +} + +// DeleteAttachment removes the row and its blob. +func (s *Store) DeleteAttachment(a *Attachment, blobs BlobStore) error { + blobs.Delete(a.PasteID + "/" + a.SHA256) + _, err := s.db.Exec(`DELETE FROM attachments WHERE id = ?`, a.ID) + return err +} + +// HasAttachment reports whether a paste already carries a file (#38: one file per paste). +func (s *Store) HasAttachment(pasteID string) bool { + var n int + s.db.QueryRow(`SELECT COUNT(*) FROM attachments WHERE paste_id = ?`, pasteID).Scan(&n) + return n > 0 +} + +// HashBytes is a small helper used by handlers to name/verify blobs. +func HashBytes(b []byte) string { + h := sha256.Sum256(b) + return string(h[:]) +} diff --git a/internal/store/blob.go b/internal/store/blob.go new file mode 100644 index 0000000..dbb47bf --- /dev/null +++ b/internal/store/blob.go @@ -0,0 +1,139 @@ +package store + +import ( + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "strings" +) + +// #38: blob storage abstraction. Files live outside SQLite on the data +// volume behind this interface so a MinIO/S3 backend can replace the fs +// implementation later without touching handlers or the UI. + +// ErrBlobNotFound is returned by Get/Stat when the key does not exist. +var ErrBlobNotFound = errors.New("blob not found") + +// BlobStore persists attachment bytes by key. +type BlobStore interface { + Put(key string, r io.Reader) (sha string, size int64, err error) + Get(key string) (io.ReadSeekCloser, error) + Delete(key string) error + Stat(key string) (int64, error) +} + +// FsBlobStore is the filesystem implementation: blobs are stored under +// root/, where key is "/" (see SanitizeBlobKey). +type FsBlobStore struct { + root string +} + +// NewFsBlobStore creates the blob root directory. +func NewFsBlobStore(root string) (*FsBlobStore, error) { + if err := os.MkdirAll(root, 0o700); err != nil { + return nil, err + } + return &FsBlobStore{root: root}, nil +} + +// SanitizeBlobKey validates a blob key and returns the safe on-disk path +// under root. Keys must be exactly "/"; anything with +// separators outside that shape, "..", or absolute paths is rejected, so a +// crafted key can never escape the blob root (traversal). +func (f *FsBlobStore) path(key string) (string, error) { + clean := filepath.ToSlash(key) + parts := strings.Split(clean, "/") + if len(parts) != 2 || parts[0] == "" || parts[1] == "" || + parts[0] == "." || parts[0] == ".." || parts[1] == "." || parts[1] == ".." { + return "", fmt.Errorf("invalid blob key %q", key) + } + return filepath.Join(f.root, filepath.FromSlash(clean)), nil +} + +func (f *FsBlobStore) Put(key string, r io.Reader) (string, int64, error) { + dst, err := f.path(key) + if err != nil { + return "", 0, err + } + if err := os.MkdirAll(filepath.Dir(dst), 0o700); err != nil { + return "", 0, err + } + h := sha256.New() + tmp := dst + ".tmp" + out, err := os.OpenFile(tmp, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600) + if err != nil { + return "", 0, err + } + size, err := io.Copy(io.MultiWriter(out, h), r) + if cerr := out.Close(); err == nil { + err = cerr + } + if err != nil { + os.Remove(tmp) + return "", 0, err + } + sha := hex.EncodeToString(h.Sum(nil)) + // The caller-supplied key must match the content hash; rename to the + // canonical "/" path so keys are always content-derived. + canonical, err := f.path(key[:strings.IndexByte(key, '/')] + "/" + sha) + if err != nil { + os.Remove(tmp) + return "", 0, err + } + if err := os.MkdirAll(filepath.Dir(canonical), 0o700); err != nil { + os.Remove(tmp) + return "", 0, err + } + if err := os.Rename(tmp, canonical); err != nil { + os.Remove(tmp) + return "", 0, err + } + return sha, size, nil +} + +func (f *FsBlobStore) Get(key string) (io.ReadSeekCloser, error) { + dst, err := f.path(key) + if err != nil { + return nil, err + } + file, err := os.Open(dst) + if os.IsNotExist(err) { + return nil, ErrBlobNotFound + } + return file, err +} + +func (f *FsBlobStore) Delete(key string) error { + dst, err := f.path(key) + if err != nil { + return err + } + err = os.Remove(dst) + if os.IsNotExist(err) { + return ErrBlobNotFound + } + if err == nil { + // best-effort cleanup of the now-empty paste directory + os.Remove(filepath.Dir(dst)) + } + return err +} + +func (f *FsBlobStore) Stat(key string) (int64, error) { + dst, err := f.path(key) + if err != nil { + return 0, err + } + fi, err := os.Stat(dst) + if os.IsNotExist(err) { + return 0, ErrBlobNotFound + } + if err != nil { + return 0, err + } + return fi.Size(), nil +} diff --git a/internal/store/blob_test.go b/internal/store/blob_test.go new file mode 100644 index 0000000..70a20c8 --- /dev/null +++ b/internal/store/blob_test.go @@ -0,0 +1,108 @@ +package store + +import ( + "strings" + "testing" +) + +func testBlobs(t *testing.T) *FsBlobStore { + t.Helper() + b, err := NewFsBlobStore(t.TempDir() + "/files") + if err != nil { + t.Fatal(err) + } + return b +} + +func TestBlobPutGetStatDeleteRoundtrip(t *testing.T) { + b := testBlobs(t) + data := []byte("hello attachment world") + sha, size, err := b.Put("abc123/pending", strings.NewReader(string(data))) + if err != nil { + t.Fatal(err) + } + if size != int64(len(data)) { + t.Fatalf("size = %d want %d", size, len(data)) + } + if len(sha) != 64 { + t.Fatalf("sha256 = %q", sha) + } + // canonical key is / + got, err := b.Get("abc123/" + sha) + if err != nil { + t.Fatal(err) + } + buf := make([]byte, len(data)+10) + n, _ := got.Read(buf) + got.Close() + if string(buf[:n]) != string(data) { + t.Fatalf("roundtrip mismatch: %q", buf[:n]) + } + sz, err := b.Stat("abc123/" + sha) + if err != nil || sz != int64(len(data)) { + t.Fatalf("stat = %d, %v", sz, err) + } + if err := b.Delete("abc123/" + sha); err != nil { + t.Fatalf("delete: %v", err) + } + if _, err := b.Get("abc123/" + sha); err != ErrBlobNotFound { + t.Fatalf("get after delete: %v", err) + } +} + +func TestBlobStatMissing(t *testing.T) { + b := testBlobs(t) + if _, err := b.Stat("nope/deadbeef"); err != ErrBlobNotFound { + t.Fatalf("want ErrBlobNotFound, got %v", err) + } +} + +func TestBlobTraversalPrevention(t *testing.T) { + b := testBlobs(t) + evil := []string{ + "../../etc/passwd", + "../escape", + "..\\windows", + "/abs/path", + "a/b/c", // too many segments + "onlyone", // no slash + "./relative", // dot segment + "../..", // bare traversal + "ok/../traverse", // traversal inside + } + for _, key := range evil { + if _, _, err := b.Put(key, strings.NewReader("x")); err == nil { + t.Errorf("Put accepted evil key %q", key) + } + if _, err := b.Get(key); err == nil { + t.Errorf("Get accepted evil key %q", key) + } + if err := b.Delete(key); err == nil { + t.Errorf("Delete accepted evil key %q", key) + } + } +} + +func TestSanitizeFilename(t *testing.T) { + cases := [][2]string{ + {"../../etc/passwd", "passwd"}, + {"C:\\Users\\evil\\file.txt", "file.txt"}, + {"normal.txt", "normal.txt"}, + {"ac", "ac"}, + {"", "file"}, + {"..", "file"}, + {".hidden", ".hidden"}, + {"with\x00null.txt", "withnull.txt"}, + {"new\nline.txt", "newline.txt"}, + } + for _, c := range cases { + got := SanitizeFilename(c[0]) + if got != c[1] { + t.Errorf("SanitizeFilename(%q) = %q want %q", c[0], got, c[1]) + } + } + long := strings.Repeat("x", 300) + if got := SanitizeFilename(long); len(got) != MaxFilenameLen { + t.Errorf("long name len = %d want %d", len(got), MaxFilenameLen) + } +} diff --git a/internal/store/store.go b/internal/store/store.go index 8755207..a7c491c 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -78,7 +78,8 @@ type CanRow struct { } type Store struct { - db *sql.DB + db *sql.DB + blob BlobStore // #38: attachment byte storage (nil in some unit tests) } func OpenStore(path string) (*Store, error) { @@ -95,6 +96,12 @@ func OpenStore(path string) (*Store, error) { if err := s.migrate(); err != nil { return nil, err } + // #38: attachment blobs live beside the database under .files + blobs, err := NewFsBlobStore(path + ".files") + if err != nil { + return nil, err + } + s.blob = blobs return s, nil } @@ -142,9 +149,26 @@ deletion_token TEXT last_viewed INTEGER NOT NULL, PRIMARY KEY (paste_id, viewer_id) )`) // #49: per-viewer read dedupe window + // #38: file attachments, one per paste in iteration 1. mime is sniffed + // server-side before insert; bytes live in the BlobStore keyed + // /. + s.db.Exec(`CREATE TABLE IF NOT EXISTS attachments ( + id TEXT PRIMARY KEY, + paste_id TEXT NOT NULL, + filename TEXT NOT NULL, + mime TEXT NOT NULL, + size INTEGER NOT NULL, + sha256 TEXT NOT NULL, + created_at INTEGER NOT NULL + )`) + s.db.Exec(`CREATE INDEX IF NOT EXISTS idx_attachments_paste ON attachments(paste_id)`) return err } +// Blobs returns the attachment blob store (nil when unavailable, e.g. some +// unit-test stores). +func (s *Store) Blobs() BlobStore { return s.blob } + // SlugAlphabet is the paste-id charset (no ambiguous chars). var SlugAlphabet = "23456789abcdefghjkmnpqrstuvwxyz" diff --git a/internal/web/static/app.css b/internal/web/static/app.css index 5a9a411..5b18ace 100644 --- a/internal/web/static/app.css +++ b/internal/web/static/app.css @@ -219,6 +219,14 @@ body { .iconbtn.dark-toggle .icon-sun { display: inline-block; } .dark .iconbtn.dark-toggle .icon-sun { display: none; } .dark .iconbtn.dark-toggle .icon-moon { display: inline-block; } +/* #127: gear highlights like selected nav tabs; dark toggle button */ +.iconbtn.gear.on { background: var(--accent); color: var(--bg); border-color: var(--accent); } +.iconbtn.dark-toggle { display: inline-flex; align-items: center; gap: 6px; padding: 5px 9px; } +.iconbtn.dark-toggle svg { width: 22px; height: 22px; } +.iconbtn.dark-toggle .icon-moon { display: none; } +.iconbtn.dark-toggle .icon-sun { display: inline-block; } +.dark .iconbtn.dark-toggle .icon-sun { display: none; } +.dark .iconbtn.dark-toggle .icon-moon { display: inline-block; } .settings-head { padding: 12px 18px; border-bottom: 1px solid var(--border); } .settings-head h1 { font-size: 29.2px; font-weight: 600; margin: 0; } .settings-body { padding: 16px 18px; color: var(--muted-fg); font-size: 21.6px; } @@ -502,6 +510,9 @@ a.admin-link:hover { color: var(--fg); text-decoration: underline; } .iconbtn.dark-toggle { padding: 4px 7px; flex-shrink: 0; } .iconbtn.dark-toggle svg { width: 18px; height: 18px; } .iconbtn.dark-toggle span { display: none; } + .iconbtn.dark-toggle { padding: 4px 7px; flex-shrink: 0; } + .iconbtn.dark-toggle svg { width: 18px; height: 18px; } + .iconbtn.dark-toggle span { display: none; } /* new paste: stack editor above sidebar, natural page height */ .deck { diff --git a/internal/web/templates/new.html b/internal/web/templates/new.html index 777ed47..d4df719 100644 --- a/internal/web/templates/new.html +++ b/internal/web/templates/new.html @@ -63,6 +63,15 @@ +
+

Attachment

+
+ Drop a file here, press Ctrl+V, or click to browse +
+ +
+ +

Custom URL

@@ -180,6 +189,8 @@ const ERROR_MESSAGES = { slug_invalid: 'Please keep the Custom URL under 64 characters, using only letters, numbers, dashes, or underscores.', content_empty: 'Write or paste something first.', content_too_large: 'This paste is too large. The limit is 5 MB.', + file_too_large: 'File is too large. The limit is 25 MB.', + one_file_only: 'Choose either text or a file for now.', expiry_invalid: 'Please pick an expiry between 1 minute and 1 year.', rate_limited: 'Too many tries. Wait a minute and try again.', }; @@ -250,6 +261,8 @@ content.addEventListener('paste', () => setTimeout(guessLang, 0)); async function create() { // #4: can mode — bundle the editor + extra items into a can via multipart if ($('iscan').checked) return createCan(); + // #38: file attached -> file paste (1 file = 1 paste; text is ignored) + if (attachedFile) return createFilePaste(); const body = { content: content.value, @@ -280,6 +293,12 @@ async function create() { toast('Create failed', 'error'); return; } + finishCreate(data); +} + +// shared success handling for both create paths (#38): result box, copy +// button, password auto-unlock, then redirect to the paste. +function finishCreate(data) { const url = location.origin + '/' + (data.custom_slug || data.id); showResult('' + url + ' ', 'ok'); $('result').dataset.token = data.deletion_token || ''; @@ -299,7 +318,8 @@ async function create() { fd.append('password', $('password').value); fd.append('next', dest); try { - await fetch('/' + data.id, {method: 'POST', body: fd}); + fetch('/' + data.id, {method: 'POST', body: fd}).finally(() => { location.href = dest; }); + return; } catch(e) {} } // show the paste @@ -307,6 +327,115 @@ async function create() { } $('create').addEventListener('click', create); +// #38: file attachment support — 1 file = 1 paste. Three input paths: +// Ctrl+V paste of a file, upload button (file picker), drag-and-drop. +const MAX_FILE_BYTES = 25 * 1024 * 1024; +let attachedFile = null; // the single attached File, or null + +function humanSize(n) { + if (n >= 1048576) return (n / 1048576).toFixed(1) + ' MB'; + if (n >= 1024) return (n / 1024).toFixed(1) + ' KB'; + return n + ' B'; +} + +function setAttachedFile(file) { + if (!file) return clearAttachedFile(); + if (file.size > MAX_FILE_BYTES) { + toast('File is too large. The limit is 25 MB.', 'error'); + return; + } + attachedFile = file; + renderFileChip(); + $('file-text-note').style.display = 'block'; +} + +function clearAttachedFile() { + attachedFile = null; + $('file-input').value = ''; + renderFileChip(); + $('file-text-note').style.display = 'none'; +} + +function renderFileChip() { + const slot = $('file-chip-slot'); + slot.innerHTML = ''; + if (!attachedFile) return; + const chip = document.createElement('div'); + chip.className = 'file-chip'; + chip.innerHTML = '' + + '' + humanSize(attachedFile.size) + '' + + ''; + chip.querySelector('.file-chip-name').textContent = attachedFile.name; + chip.querySelector('.file-chip-remove').addEventListener('click', clearAttachedFile); + slot.appendChild(chip); +} + +// (1) file picker via the dropzone click +$('dropzone').addEventListener('click', () => $('file-input').click()); +$('file-input').addEventListener('change', () => { + if ($('file-input').files.length) setAttachedFile($('file-input').files[0]); +}); + +// (2) drag-and-drop onto the dropzone (and the page broadly) +const dz = $('dropzone'); +['dragenter', 'dragover'].forEach(ev => { + document.addEventListener(ev, e => { + if (!e.dataTransfer || ![...e.dataTransfer.types].includes('Files')) return; + e.preventDefault(); + dz.classList.add('dragover'); + }); +}); +['dragleave', 'drop'].forEach(ev => { + document.addEventListener(ev, e => { + if (ev === 'drop') e.preventDefault(); + if (e.target === dz || ev === 'drop') dz.classList.remove('dragover'); + }); +}); +document.addEventListener('drop', e => { + if (!e.dataTransfer || !e.dataTransfer.files.length) return; + e.preventDefault(); + setAttachedFile(e.dataTransfer.files[0]); +}); + +// (3) Ctrl+V of a file anywhere on the page +document.addEventListener('paste', e => { + const files = e.clipboardData && e.clipboardData.files; + if (!files || !files.length) return; // normal text paste into the editor + e.preventDefault(); + setAttachedFile(files[0]); +}); + +// file create path: POST multipart. 1 file = 1 paste: when a file is +// attached the editor text is ignored (server enforces this too). +async function createFilePaste() { + const fd = new FormData(); + fd.append('file', attachedFile); + if ($('title').value) fd.append('title', $('title').value); + if ($('custom').value) fd.append('custom_slug', $('custom').value); + if ($('haspw').checked) fd.append('password', $('password').value); + if ($('burn').checked) { + fd.append('burn_after_read', 'true'); + fd.append('burn_after_reads', String(parseInt($('burnreads').value, 10) || 1)); + } + if ($('unlisted').checked) fd.append('visibility', 'unlisted'); + const exp = document.querySelector('input[name="exp"]:checked').value; + if (exp === 'custom') { + const dur = composeCustomExpiry(); + if (dur === null) { toast('Check the custom expiry', 'error'); return; } + fd.append('expires_in', dur); + } else if (exp) { + fd.append('expires_in', exp); + } + const res = await fetch('/api/pastes', { method: 'POST', body: fd }); + const data = await res.json(); + if (!res.ok) { + showResult(friendlyError(data), 'err'); + toast('Create failed', 'error'); + return; + } + finishCreate(data); +} + // #4: can creation — POST multipart to /api/pastes/can. The main editor is // the first item; each extra can-item row is another text item. async function createCan() { diff --git a/internal/web/templates/paste.html b/internal/web/templates/paste.html index cfca561..c5c660f 100644 --- a/internal/web/templates/paste.html +++ b/internal/web/templates/paste.html @@ -40,6 +40,19 @@
{{end}} + {{if .Attachment}} +
+
+ + {{.Attachment.Filename}} + {{.Attachment.SizeHuman}} + + {{if or (eq .Attachment.Mime "image/png") (eq .Attachment.Mime "image/jpeg") (eq .Attachment.Mime "image/gif") (eq .Attachment.Mime "image/webp")}} +
{{.Attachment.Filename}}
+ {{end}} +
+
+ {{end}}
{{.Gutter}}
{{.ContentHTML}}