main pico / pkg / storage / fs.go
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}