Eric Bower
·
2026-10-12
1package storage
2
3import (
4 "fmt"
5 "io"
6 "io/fs"
7 "log/slog"
8 "os"
9 "path"
10 "path/filepath"
11 "strings"
12 "time"
13
14 "github.com/google/renameio/v2"
15 "github.com/picosh/pico/pkg/send/utils"
16 "github.com/picosh/pico/pkg/shared/mime"
17)
18
19var KB = 1000
20var MB = KB * 1000
21
22// https://stackoverflow.com/a/32482941
23func dirSize(path string) (int64, error) {
24 var size int64
25 err := filepath.Walk(path, func(_ string, info os.FileInfo, err error) error {
26 if err != nil {
27 return err
28 }
29 if !info.IsDir() {
30 size += info.Size()
31 }
32 return err
33 })
34
35 return size, err
36}
37
38// legacyKeepDir is the marker pgs used to write to keep empty directories.
39// Listings hide it until cmd/scripts/clean-keep-dirs has removed them all.
40const legacyKeepDir = "._pico_keep_dir"
41
42type StorageFS struct {
43 Dir string
44 Logger *slog.Logger
45}
46
47var _ StorageServe = &StorageFS{}
48var _ StorageServe = (*StorageFS)(nil)
49
50func NewStorageFS(logger *slog.Logger, dir string) (*StorageFS, error) {
51 return &StorageFS{Logger: logger, Dir: dir}, nil
52}
53
54func (s *StorageFS) GetBucket(name string) (Bucket, error) {
55 dirPath := filepath.Join(s.Dir, name)
56 bucket := Bucket{
57 Name: name,
58 Path: dirPath,
59 }
60 // s.Logger.Info("get bucket", "dir", dirPath)
61
62 info, err := os.Stat(dirPath)
63 if os.IsNotExist(err) {
64 return bucket, fmt.Errorf("directory does not exist: %v %w", dirPath, err)
65 }
66
67 if err != nil {
68 return bucket, fmt.Errorf("directory error: %v %w", dirPath, err)
69
70 }
71
72 if !info.IsDir() {
73 return bucket, fmt.Errorf("directory is a file, not a directory: %#v", dirPath)
74 }
75
76 return bucket, nil
77}
78
79func (s *StorageFS) UpsertBucket(name string) (Bucket, error) {
80 s.Logger.Info("upsert bucket", "name", name)
81 bucket, err := s.GetBucket(name)
82 if err == nil {
83 return bucket, nil
84 }
85
86 dir := filepath.Join(s.Dir, name)
87 s.Logger.Info("bucket not found, creating", "dir", dir, "err", err)
88 err = os.MkdirAll(dir, os.ModePerm)
89 if err != nil {
90 return bucket, err
91 }
92
93 return bucket, nil
94}
95
96func (s *StorageFS) GetBucketQuota(bucket Bucket) (uint64, error) {
97 dsize, err := dirSize(bucket.Path)
98 return uint64(dsize), err
99}
100
101// DeleteBucket will delete all contents regardless if files exist inside of it.
102func (s *StorageFS) DeleteBucket(bucket Bucket) error {
103 return os.RemoveAll(bucket.Path)
104}
105
106func (s *StorageFS) GetObject(bucket Bucket, fpath string) (utils.ReadAndReaderAtCloser, *ObjectInfo, error) {
107 contentType := mime.GetMimeType(fpath)
108 objInfo := &ObjectInfo{
109 Size: 0,
110 LastModified: time.Time{},
111 ETag: "",
112 ContentType: contentType,
113 }
114
115 dat, err := os.Open(filepath.Join(bucket.Path, fpath))
116 if err != nil {
117 return nil, objInfo, err
118 }
119
120 info, err := dat.Stat()
121 if err != nil {
122 _ = dat.Close()
123 return nil, objInfo, err
124 }
125 if info.IsDir() {
126 _ = dat.Close()
127 return nil, objInfo, fmt.Errorf("%s is a directory: %w", fpath, fs.ErrNotExist)
128 }
129 fillObjectInfo(objInfo, info)
130 return dat, objInfo, nil
131}
132
133// fillObjectInfo sets an object's size, mtime and ETag from its file. The
134// ETag is built from size and mtime, as web servers do, so serving a file
135// doesn't mean reading it twice.
136func fillObjectInfo(objInfo *ObjectInfo, info os.FileInfo) {
137 objInfo.Size = info.Size()
138 objInfo.LastModified = info.ModTime()
139 objInfo.ETag = fmt.Sprintf("%x-%x", info.ModTime().UnixNano(), info.Size())
140}
141
142func (s *StorageFS) StatObject(bucket Bucket, fpath string) (*ObjectInfo, error) {
143 info, err := os.Stat(filepath.Join(bucket.Path, fpath))
144 if err != nil {
145 return nil, err
146 }
147 if info.IsDir() {
148 return nil, fmt.Errorf("%s is a directory: %w", fpath, fs.ErrNotExist)
149 }
150 objInfo := &ObjectInfo{ContentType: mime.GetMimeType(fpath)}
151 fillObjectInfo(objInfo, info)
152 return objInfo, nil
153}
154
155func (s *StorageFS) PutObject(bucket Bucket, fpath string, contents io.Reader, info *ObjectInfo) (string, int64, error) {
156 loc := filepath.Join(bucket.Path, fpath)
157 err := os.MkdirAll(filepath.Dir(loc), os.ModePerm)
158 if err != nil {
159 return "", 0, err
160 }
161 out, err := renameio.NewPendingFile(loc, renameio.WithPermissions(os.ModePerm))
162 if err != nil {
163 return "", 0, err
164 }
165
166 size, err := io.Copy(out, contents)
167 if err != nil {
168 return "", 0, err
169 }
170
171 if err := out.CloseAtomicallyReplace(); err != nil {
172 return "", 0, err
173 }
174
175 if !info.LastModified.IsZero() {
176 uTime := info.LastModified
177 _ = os.Chtimes(loc, uTime, uTime)
178 }
179
180 return loc, size, nil
181}
182
183func (s *StorageFS) PutDir(bucket Bucket, dir string) error {
184 return os.MkdirAll(filepath.Join(bucket.Path, dir), os.ModePerm)
185}
186
187func (s *StorageFS) DeleteObject(bucket Bucket, fpath string) error {
188 loc := filepath.Join(bucket.Path, fpath)
189 // A leftover marker would keep an otherwise empty directory in place.
190 _ = os.Remove(filepath.Join(loc, legacyKeepDir))
191 err := os.Remove(loc)
192 if err != nil && !os.IsNotExist(err) {
193 return err
194 }
195 return nil
196}
197
198func (s *StorageFS) ListBuckets() ([]string, error) {
199 entries, err := os.ReadDir(s.Dir)
200 if err != nil {
201 return []string{}, err
202 }
203
204 buckets := []string{}
205 for _, e := range entries {
206 if !e.IsDir() {
207 continue
208 }
209 buckets = append(buckets, e.Name())
210 }
211 return buckets, nil
212}
213
214func (s *StorageFS) ListObjects(bucket Bucket, dir string, recursive bool) ([]os.FileInfo, error) {
215 fileList := []os.FileInfo{}
216
217 fpath := path.Join(bucket.Path, dir)
218
219 info, err := os.Stat(fpath)
220 if err != nil {
221 if os.IsNotExist(err) {
222 return fileList, nil
223 }
224 return fileList, err
225 }
226
227 if info.IsDir() && !strings.HasSuffix(dir, "/") {
228 fileList = append(fileList, &utils.VirtualFile{
229 FName: "",
230 FIsDir: info.IsDir(),
231 FSize: info.Size(),
232 FModTime: info.ModTime(),
233 })
234
235 return fileList, err
236 }
237
238 var files []utils.VirtualFile
239
240 if recursive {
241 err = filepath.WalkDir(fpath, func(s string, d fs.DirEntry, err error) error {
242 if err != nil {
243 return err
244 }
245 if d.Name() == legacyKeepDir {
246 return nil
247 }
248 info, err := d.Info()
249 if err != nil {
250 return nil
251 }
252 fname := strings.TrimPrefix(s, fpath)
253 if fname == "" {
254 return nil
255 }
256 // rsync does not expect prefixed `/` so without this `rsync --delete` is borked
257 fname = strings.TrimPrefix(fname, "/")
258 files = append(files, utils.VirtualFile{
259 FName: fname,
260 FIsDir: info.IsDir(),
261 FSize: info.Size(),
262 FModTime: info.ModTime(),
263 })
264 return nil
265 })
266 if err != nil {
267 fileList = append(fileList, info)
268 return fileList, nil
269 }
270 } else {
271 fls, err := os.ReadDir(fpath)
272 if err != nil {
273 fileList = append(fileList, info)
274 return fileList, nil
275 }
276 for _, d := range fls {
277 if d.Name() == legacyKeepDir {
278 continue
279 }
280 info, err := d.Info()
281 if err != nil {
282 continue
283 }
284 fp := info.Name()
285 files = append(files, utils.VirtualFile{
286 FName: fp,
287 FIsDir: info.IsDir(),
288 FSize: info.Size(),
289 FModTime: info.ModTime(),
290 })
291 }
292 }
293
294 for _, f := range files {
295 fileList = append(fileList, &f)
296 }
297
298 return fileList, err
299}