2026-01-20 21:52:44 +00:00
|
|
|
package storage
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"context"
|
|
|
|
|
"crypto/sha256"
|
|
|
|
|
"encoding/hex"
|
|
|
|
|
"fmt"
|
|
|
|
|
"io"
|
|
|
|
|
"os"
|
|
|
|
|
"path/filepath"
|
2026-04-27 12:04:38 +01:00
|
|
|
"time"
|
2026-01-20 21:52:44 +00:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
// Filesystem implements Storage using the local filesystem.
|
|
|
|
|
type Filesystem struct {
|
|
|
|
|
root string
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// NewFilesystem creates a new filesystem storage rooted at the given directory.
|
|
|
|
|
// The directory will be created if it does not exist.
|
|
|
|
|
func NewFilesystem(root string) (*Filesystem, error) {
|
|
|
|
|
absRoot, err := filepath.Abs(root)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("resolving root path: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-18 10:59:29 +00:00
|
|
|
if err := os.MkdirAll(absRoot, dirPermissions); err != nil {
|
2026-01-20 21:52:44 +00:00
|
|
|
return nil, fmt.Errorf("creating root directory: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return &Filesystem{root: absRoot}, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (fs *Filesystem) fullPath(path string) string {
|
|
|
|
|
return filepath.Join(fs.root, filepath.FromSlash(path))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (fs *Filesystem) Store(ctx context.Context, path string, r io.Reader) (int64, string, error) {
|
|
|
|
|
fullPath := fs.fullPath(path)
|
|
|
|
|
|
|
|
|
|
dir := filepath.Dir(fullPath)
|
2026-03-18 10:59:29 +00:00
|
|
|
if err := os.MkdirAll(dir, dirPermissions); err != nil {
|
2026-01-20 21:52:44 +00:00
|
|
|
return 0, "", fmt.Errorf("creating directory: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Write to temp file first for atomic operation
|
|
|
|
|
tmpFile, err := os.CreateTemp(dir, ".tmp-*")
|
|
|
|
|
if err != nil {
|
|
|
|
|
return 0, "", fmt.Errorf("creating temp file: %w", err)
|
|
|
|
|
}
|
|
|
|
|
tmpPath := tmpFile.Name()
|
|
|
|
|
|
|
|
|
|
// Clean up temp file on error
|
|
|
|
|
success := false
|
|
|
|
|
defer func() {
|
|
|
|
|
if !success {
|
|
|
|
|
_ = tmpFile.Close()
|
|
|
|
|
_ = os.Remove(tmpPath)
|
|
|
|
|
}
|
|
|
|
|
}()
|
|
|
|
|
|
|
|
|
|
// Write content and compute hash
|
|
|
|
|
h := sha256.New()
|
|
|
|
|
w := io.MultiWriter(tmpFile, h)
|
|
|
|
|
|
|
|
|
|
size, err := io.Copy(w, r)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return 0, "", fmt.Errorf("writing content: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if err := tmpFile.Close(); err != nil {
|
|
|
|
|
return 0, "", fmt.Errorf("closing temp file: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Atomic rename
|
|
|
|
|
if err := os.Rename(tmpPath, fullPath); err != nil {
|
|
|
|
|
return 0, "", fmt.Errorf("renaming temp file: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
success = true
|
|
|
|
|
hash := hex.EncodeToString(h.Sum(nil))
|
|
|
|
|
return size, hash, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (fs *Filesystem) Open(ctx context.Context, path string) (io.ReadCloser, error) {
|
|
|
|
|
fullPath := fs.fullPath(path)
|
|
|
|
|
|
|
|
|
|
f, err := os.Open(fullPath)
|
|
|
|
|
if err != nil {
|
|
|
|
|
if os.IsNotExist(err) {
|
|
|
|
|
return nil, ErrNotFound
|
|
|
|
|
}
|
|
|
|
|
return nil, fmt.Errorf("opening file: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return f, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (fs *Filesystem) Exists(ctx context.Context, path string) (bool, error) {
|
|
|
|
|
fullPath := fs.fullPath(path)
|
|
|
|
|
|
|
|
|
|
_, err := os.Stat(fullPath)
|
|
|
|
|
if err != nil {
|
|
|
|
|
if os.IsNotExist(err) {
|
|
|
|
|
return false, nil
|
|
|
|
|
}
|
|
|
|
|
return false, fmt.Errorf("checking file: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return true, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (fs *Filesystem) Delete(ctx context.Context, path string) error {
|
|
|
|
|
fullPath := fs.fullPath(path)
|
|
|
|
|
|
|
|
|
|
err := os.Remove(fullPath)
|
|
|
|
|
if err != nil && !os.IsNotExist(err) {
|
|
|
|
|
return fmt.Errorf("removing file: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Try to clean up empty parent directories
|
|
|
|
|
dir := filepath.Dir(fullPath)
|
|
|
|
|
for dir != fs.root {
|
|
|
|
|
if err := os.Remove(dir); err != nil {
|
|
|
|
|
break // Directory not empty or other error
|
|
|
|
|
}
|
|
|
|
|
dir = filepath.Dir(dir)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-27 12:04:38 +01:00
|
|
|
func (fs *Filesystem) SignedURL(_ context.Context, _ string, _ time.Duration) (string, error) {
|
|
|
|
|
return "", ErrSignedURLUnsupported
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-20 21:52:44 +00:00
|
|
|
func (fs *Filesystem) Size(ctx context.Context, path string) (int64, error) {
|
|
|
|
|
fullPath := fs.fullPath(path)
|
|
|
|
|
|
|
|
|
|
info, err := os.Stat(fullPath)
|
|
|
|
|
if err != nil {
|
|
|
|
|
if os.IsNotExist(err) {
|
|
|
|
|
return 0, ErrNotFound
|
|
|
|
|
}
|
|
|
|
|
return 0, fmt.Errorf("stat file: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return info.Size(), nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (fs *Filesystem) UsedSpace(ctx context.Context) (int64, error) {
|
|
|
|
|
var total int64
|
|
|
|
|
|
|
|
|
|
err := filepath.Walk(fs.root, func(path string, info os.FileInfo, err error) error {
|
|
|
|
|
if err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
if !info.IsDir() {
|
|
|
|
|
total += info.Size()
|
|
|
|
|
}
|
|
|
|
|
return nil
|
|
|
|
|
})
|
|
|
|
|
if err != nil {
|
|
|
|
|
return 0, fmt.Errorf("walking directory: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return total, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Root returns the root directory of the storage.
|
|
|
|
|
func (fs *Filesystem) Root() string {
|
|
|
|
|
return fs.root
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// FullPath returns the full filesystem path for a storage path.
|
|
|
|
|
// Useful for serving files directly or debugging.
|
|
|
|
|
func (fs *Filesystem) FullPath(path string) string {
|
|
|
|
|
return fs.fullPath(path)
|
|
|
|
|
}
|
2026-04-03 14:06:51 +01:00
|
|
|
|
|
|
|
|
func (fs *Filesystem) URL() string {
|
|
|
|
|
return "file://" + filepath.ToSlash(fs.root)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (fs *Filesystem) Close() error {
|
|
|
|
|
return nil
|
|
|
|
|
}
|