#38 iteration 1: file attachments, 1 file per paste
- internal/store/blob.go: BlobStore interface + fs implementation with
traversal-safe keys (<paste-id>/<sha256>), put/get/stat/delete
- attachments table migration (id, paste_id, filename sanitized to 255,
mime sniffed server-side, size, sha256, created_at)
- POST /api/pastes now accepts multipart/form-data with a 'file' part;
1 file = 1 paste: file replaces text content when both are sent
- 25 MB per-file limit enforced server-side (413 file_too_large)
- GET /f/{attachment-id}/{filename}: stored sniffed mime, nosniff,
inline only for images/pdf, html/svg/xml forced to text/plain (#34 rule)
- paste view renders attachment chip + inline image preview
- /new: dropzone with file picker, drag-and-drop, Ctrl+V file paste,
file chip with name/size/remove, matches pill/radius design
- tests: blob roundtrip/traversal/sanitize; multipart create (mime
sniffing, client mime ignored, size limit, two-file reject, html/svg
forcing, 404s, password/expiry fields)
This commit is contained in:
@@ -0,0 +1,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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user