package main import ( "errors" "io" "io/fs" "os" "path/filepath" "time" ) // blobStore is the seam between the messenger and wherever attachment bytes // physically live. It exists so the flagship can later point media at an // object store (issue #550) while self-hosters keep today's on-disk layout — // the handlers and GC talk only to this interface. // // The store holds BYTES ONLY. Everything the server knows *about* a blob — // its size (quota accounting) and thread tag (thread-scoped GC) — stays in // SQLite's blobs table, which travels with the rest of the instance data. // Content arrives as client-side ciphertext; the store never learns filenames, // MIME types, or plaintext (those ride inside the encrypted message payload, // per the E2EE rule). Objects are write-once and immutable: an id is minted // exactly once (randToken), never rewritten, only ever deleted — so there is // no versioning or CAS here, unlike home's vaultStore. type blobStore interface { // create begins a staged write. The staged bytes are invisible to open // until commit publishes them under an id, so a partial or abandoned // upload can never be served. The two-step shape mirrors what the upload // handler actually does: it learns the size only after streaming the // body, and must apply the quota verdict between "fully received" and // "fetchable". create() (blobWriter, error) // open returns a reader over a committed blob's bytes, plus its size and // modification time (http.ServeContent wants seek + modtime for Range and // conditional requests — video playback scrubs). An id the store doesn't // hold returns an error satisfying errors.Is(err, fs.ErrNotExist). open(id string) (r io.ReadSeekCloser, size int64, modTime time.Time, err error) // remove deletes a blob's bytes. Removing an id that isn't there is not // an error — GC call sites unlink best-effort. remove(id string) error } // blobWriter is one staged write. Exactly one of commit or abort ends it. type blobWriter interface { io.Writer // commit publishes the staged bytes under id: after commit, open(id) // serves exactly what was written. Publication is atomic — a crash // mid-commit leaves either nothing or the whole blob, never a readable // partial. commit(id string) error // abort discards the staged bytes. Best-effort with no error to report: // every caller is already unwinding a failure. abort() } // diskBlobStore is implementation #1: one file per blob id under a single // directory. It wraps exactly the storage that predates the seam — the dir // sits next to the DB file (-blobs/) so it's per-instance and a data // snapshot captures both by copying the DB and its sibling dir. Large media // never bloats the SQLite file itself (keeping WAL/VACUUM/snapshots lean). type diskBlobStore struct{ dir string } func newDiskBlobStore(dir string) (*diskBlobStore, error) { if err := os.MkdirAll(dir, 0o755); err != nil { return nil, err } return &diskBlobStore{dir: dir}, nil } // path is the on-disk location of a blob's bytes. Blob ids are randToken // (base64url), so they're always safe filename components — but the HTTP // handler still validates the id (validBlobID) before it can reach here. func (s *diskBlobStore) path(id string) string { return filepath.Join(s.dir, id) } func (s *diskBlobStore) create() (blobWriter, error) { // The "." in the temp name makes it an invalid blob id (validBlobID), so // an in-flight staged write can never be fetched via /api/blob. Same dir // as the final file so the commit rename is atomic (same filesystem). f, err := os.CreateTemp(s.dir, "up.*.part") if err != nil { return nil, err } return &diskBlobWriter{f: f, store: s}, nil } func (s *diskBlobStore) open(id string) (io.ReadSeekCloser, int64, time.Time, error) { f, err := os.Open(s.path(id)) if err != nil { return nil, 0, time.Time{}, err // wraps fs.ErrNotExist for a missing id } fi, err := f.Stat() if err != nil { f.Close() return nil, 0, time.Time{}, err } return f, fi.Size(), fi.ModTime(), nil } func (s *diskBlobStore) remove(id string) error { if err := os.Remove(s.path(id)); err != nil && !errors.Is(err, fs.ErrNotExist) { return err } return nil } type diskBlobWriter struct { f *os.File store *diskBlobStore } func (w *diskBlobWriter) Write(p []byte) (int, error) { return w.f.Write(p) } func (w *diskBlobWriter) commit(id string) error { // Close error deliberately ignored, matching the pre-seam upload path: // the copy's own error return is the signal callers act on. w.f.Close() if err := os.Rename(w.f.Name(), w.store.path(id)); err != nil { os.Remove(w.f.Name()) return err } return nil } func (w *diskBlobWriter) abort() { w.f.Close() os.Remove(w.f.Name()) }