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 }