Add file management API with local storage backend
- Implement FileHandler with CRUD operations for files/directories - Add FileService with business logic and SHA-256 hashing - Create LocalStorage backend for filesystem persistence - Add database repository with pagination and name uniqueness constraints - Configure max upload size in storage settings - Include comprehensive tests for all layers
This commit is contained in:
@@ -0,0 +1,91 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// LocalStorage implements StorageBackend using the local filesystem.
|
||||
type LocalStorage struct {
|
||||
basePath string
|
||||
}
|
||||
|
||||
// NewLocalStorage creates a LocalStorage rooted at basePath. The path is
|
||||
// resolved to an absolute path and verified to exist.
|
||||
func NewLocalStorage(basePath string) (*LocalStorage, error) {
|
||||
abs, err := filepath.Abs(basePath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("resolve storage path: %w", err)
|
||||
}
|
||||
return &LocalStorage{basePath: abs}, nil
|
||||
}
|
||||
|
||||
// Save writes the contents of reader to path under the storage root.
|
||||
func (s *LocalStorage) Save(_ context.Context, path string, reader io.Reader) (int64, error) {
|
||||
fullPath := filepath.Join(s.basePath, path)
|
||||
if !isSubPath(s.basePath, fullPath) {
|
||||
return 0, fmt.Errorf("storage: path traversal attempt: %s", path)
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(fullPath), 0750); err != nil {
|
||||
return 0, fmt.Errorf("create parent directory: %w", err)
|
||||
}
|
||||
|
||||
file, err := os.Create(fullPath)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("create file: %w", err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
written, err := io.Copy(file, reader)
|
||||
if err != nil {
|
||||
// Best-effort cleanup on write failure.
|
||||
os.Remove(fullPath)
|
||||
return 0, fmt.Errorf("write file: %w", err)
|
||||
}
|
||||
|
||||
return written, nil
|
||||
}
|
||||
|
||||
// Open returns a reader for the file at path under the storage root.
|
||||
func (s *LocalStorage) Open(_ context.Context, path string) (io.ReadCloser, error) {
|
||||
fullPath := filepath.Join(s.basePath, path)
|
||||
if !isSubPath(s.basePath, fullPath) {
|
||||
return nil, fmt.Errorf("storage: path traversal attempt: %s", path)
|
||||
}
|
||||
|
||||
file, err := os.Open(fullPath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, fmt.Errorf("file not found on disk: %s", path)
|
||||
}
|
||||
return nil, fmt.Errorf("open file: %w", err)
|
||||
}
|
||||
return file, nil
|
||||
}
|
||||
|
||||
// Delete removes the file at path under the storage root.
|
||||
func (s *LocalStorage) Delete(_ context.Context, path string) error {
|
||||
fullPath := filepath.Join(s.basePath, path)
|
||||
if !isSubPath(s.basePath, fullPath) {
|
||||
return fmt.Errorf("storage: path traversal attempt: %s", path)
|
||||
}
|
||||
|
||||
err := os.Remove(fullPath)
|
||||
if os.IsNotExist(err) {
|
||||
return nil // idempotent: already gone
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// isSubPath returns true if target is within base (no path traversal).
|
||||
func isSubPath(base, target string) bool {
|
||||
rel, err := filepath.Rel(base, target)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return rel != ".." && !filepath.IsAbs(rel) && rel[:2] != ".."
|
||||
}
|
||||
Reference in New Issue
Block a user