eleven / ocho Public

Clone
git clone https://amadan.net/eleven/ocho

Plain git — no account needed to clone.

Download

Download this file

1package main
2
3import (
4 "errors"
5 "io"
6 "io/fs"
7 "os"
8 "path/filepath"
9 "time"
10)
11
12// blobStore is the seam between the messenger and wherever attachment bytes
13// physically live. It exists so the flagship can later point media at an
14// object store (issue #550) while self-hosters keep today's on-disk layout —
15// the handlers and GC talk only to this interface.
16//
17// The store holds BYTES ONLY. Everything the server knows *about* a blob —
18// its size (quota accounting) and thread tag (thread-scoped GC) — stays in
19// SQLite's blobs table, which travels with the rest of the instance data.
20// Content arrives as client-side ciphertext; the store never learns filenames,
21// MIME types, or plaintext (those ride inside the encrypted message payload,
22// per the E2EE rule). Objects are write-once and immutable: an id is minted
23// exactly once (randToken), never rewritten, only ever deleted — so there is
24// no versioning or CAS here, unlike home's vaultStore.
25type blobStore interface {
26 // create begins a staged write. The staged bytes are invisible to open
27 // until commit publishes them under an id, so a partial or abandoned
28 // upload can never be served. The two-step shape mirrors what the upload
29 // handler actually does: it learns the size only after streaming the
30 // body, and must apply the quota verdict between "fully received" and
31 // "fetchable".
32 create() (blobWriter, error)
33 // open returns a reader over a committed blob's bytes, plus its size and
34 // modification time (http.ServeContent wants seek + modtime for Range and
35 // conditional requests — video playback scrubs). An id the store doesn't
36 // hold returns an error satisfying errors.Is(err, fs.ErrNotExist).
37 open(id string) (r io.ReadSeekCloser, size int64, modTime time.Time, err error)
38 // remove deletes a blob's bytes. Removing an id that isn't there is not
39 // an error — GC call sites unlink best-effort.
40 remove(id string) error
41}
42
43// blobWriter is one staged write. Exactly one of commit or abort ends it.
44type blobWriter interface {
45 io.Writer
46 // commit publishes the staged bytes under id: after commit, open(id)
47 // serves exactly what was written. Publication is atomic — a crash
48 // mid-commit leaves either nothing or the whole blob, never a readable
49 // partial.
50 commit(id string) error
51 // abort discards the staged bytes. Best-effort with no error to report:
52 // every caller is already unwinding a failure.
53 abort()
54}
55
56// diskBlobStore is implementation #1: one file per blob id under a single
57// directory. It wraps exactly the storage that predates the seam — the dir
58// sits next to the DB file (<db>-blobs/) so it's per-instance and a data
59// snapshot captures both by copying the DB and its sibling dir. Large media
60// never bloats the SQLite file itself (keeping WAL/VACUUM/snapshots lean).
61type diskBlobStore struct{ dir string }
62
63func newDiskBlobStore(dir string) (*diskBlobStore, error) {
64 if err := os.MkdirAll(dir, 0o755); err != nil {
65 return nil, err
66 }
67 return &diskBlobStore{dir: dir}, nil
68}
69
70// path is the on-disk location of a blob's bytes. Blob ids are randToken
71// (base64url), so they're always safe filename components — but the HTTP
72// handler still validates the id (validBlobID) before it can reach here.
73func (s *diskBlobStore) path(id string) string { return filepath.Join(s.dir, id) }
74
75func (s *diskBlobStore) create() (blobWriter, error) {
76 // The "." in the temp name makes it an invalid blob id (validBlobID), so
77 // an in-flight staged write can never be fetched via /api/blob. Same dir
78 // as the final file so the commit rename is atomic (same filesystem).
79 f, err := os.CreateTemp(s.dir, "up.*.part")
80 if err != nil {
81 return nil, err
82 }
83 return &diskBlobWriter{f: f, store: s}, nil
84}
85
86func (s *diskBlobStore) open(id string) (io.ReadSeekCloser, int64, time.Time, error) {
87 f, err := os.Open(s.path(id))
88 if err != nil {
89 return nil, 0, time.Time{}, err // wraps fs.ErrNotExist for a missing id
90 }
91 fi, err := f.Stat()
92 if err != nil {
93 f.Close()
94 return nil, 0, time.Time{}, err
95 }
96 return f, fi.Size(), fi.ModTime(), nil
97}
98
99func (s *diskBlobStore) remove(id string) error {
100 if err := os.Remove(s.path(id)); err != nil && !errors.Is(err, fs.ErrNotExist) {
101 return err
102 }
103 return nil
104}
105
106type diskBlobWriter struct {
107 f *os.File
108 store *diskBlobStore
109}
110
111func (w *diskBlobWriter) Write(p []byte) (int, error) { return w.f.Write(p) }
112
113func (w *diskBlobWriter) commit(id string) error {
114 // Close error deliberately ignored, matching the pre-seam upload path:
115 // the copy's own error return is the signal callers act on.
116 w.f.Close()
117 if err := os.Rename(w.f.Name(), w.store.path(id)); err != nil {
118 os.Remove(w.f.Name())
119 return err
120 }
121 return nil
122}
123
124func (w *diskBlobWriter) abort() {
125 w.f.Close()
126 os.Remove(w.f.Name())
127}
128