#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:
@@ -4,3 +4,4 @@ palette.db-shm
|
||||
palette.db-wal
|
||||
admin-key
|
||||
settings.json
|
||||
:memory:.files/
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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[:])
|
||||
}
|
||||
@@ -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/<key>, where key is "<paste-id>/<sha256-hex>" (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 "<paste-id>/<sha256-hex>"; 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 "<paste-id>/<sha256>" 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
|
||||
}
|
||||
@@ -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 <paste-id>/<sha256>
|
||||
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"},
|
||||
{"a<b>c", "a<b>c"},
|
||||
{"", "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)
|
||||
}
|
||||
}
|
||||
+25
-1
@@ -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 <db>.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
|
||||
// <paste_id>/<sha256>.
|
||||
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"
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -63,6 +63,15 @@
|
||||
<label class="toggle"><input type="checkbox" id="unlisted"> Unlisted</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="float side-section">
|
||||
<h3>Attachment</h3>
|
||||
<div class="dropzone" id="dropzone" title="Paste (Ctrl+V), drop a file here, or click to browse">
|
||||
Drop a file here, press Ctrl+V, or click to browse
|
||||
</div>
|
||||
<input type="file" id="file-input" class="file-input" aria-label="Choose a file">
|
||||
<div id="file-chip-slot"></div>
|
||||
<div class="hint" style="margin-top:6px; font-size:19px; display:none" id="file-text-note">File pastes ignore the text editor (one file per paste)</div>
|
||||
</div>
|
||||
<div class="float side-section">
|
||||
<h3>Custom URL</h3>
|
||||
<input type="text" id="custom" class="custom-input" placeholder="my-snippet">
|
||||
@@ -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('<a href="' + url + '">' + url + '</a> <button class="btn btn-icon" id="result-copy" title="Copy URL" type="button">⧉</button>', '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 = '<span class="file-chip-name"></span>' +
|
||||
'<span class="file-chip-size">' + humanSize(attachedFile.size) + '</span>' +
|
||||
'<button type="button" class="file-chip-remove" title="Remove file">×</button>';
|
||||
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() {
|
||||
|
||||
@@ -40,6 +40,19 @@
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
{{if .Attachment}}
|
||||
<div class="float">
|
||||
<div class="attachment-bar">
|
||||
<a class="attachment-chip" href="/f/{{.Attachment.ID}}/{{.Attachment.Filename}}" data-mime="{{.Attachment.Mime}}">
|
||||
<span class="attachment-name">{{.Attachment.Filename}}</span>
|
||||
<span class="attachment-size">{{.Attachment.SizeHuman}}</span>
|
||||
</a>
|
||||
{{if or (eq .Attachment.Mime "image/png") (eq .Attachment.Mime "image/jpeg") (eq .Attachment.Mime "image/gif") (eq .Attachment.Mime "image/webp")}}
|
||||
<div class="attachment-preview"><img src="/f/{{.Attachment.ID}}/{{.Attachment.Filename}}" alt="{{.Attachment.Filename}}"></div>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
<div class="float">
|
||||
<div class="code"><div class="gutter">{{.Gutter}}</div><div class="codebody" id="codebody">{{.ContentHTML}}</div></div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user