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