mirror of
https://github.com/jkaninda/go-storage.git
synced 2026-03-09 19:19:01 +01:00
349 lines
8.1 KiB
Go
349 lines
8.1 KiB
Go
package go_storage
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
)
|
|
|
|
// LocalStorage implements the Storage interface using the local filesystem.
|
|
// It is safe for concurrent use as it relies on filesystem operations which
|
|
// are handled by the OS.
|
|
type LocalStorage struct {
|
|
basePath string
|
|
}
|
|
|
|
// NewLocalStorage creates a new LocalStorage instance with the given base directory.
|
|
// The base directory will be created if it doesn't exist.
|
|
func NewLocalStorage(basePath string) (*LocalStorage, error) {
|
|
absPath, err := filepath.Abs(basePath)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to resolve absolute path: %w", err)
|
|
}
|
|
|
|
if err := os.MkdirAll(absPath, 0755); err != nil {
|
|
return nil, fmt.Errorf("failed to create base directory: %w", err)
|
|
}
|
|
|
|
return &LocalStorage{
|
|
basePath: absPath,
|
|
}, nil
|
|
}
|
|
|
|
// resolvePath safely resolves a remote path to an absolute local path,
|
|
// ensuring it stays within the base directory.
|
|
func (s *LocalStorage) resolvePath(remotePath string) (string, error) {
|
|
// Clean the path to remove any .. or . components
|
|
cleanPath := filepath.Clean(remotePath)
|
|
|
|
// Remove leading slashes to make it relative
|
|
cleanPath = strings.TrimPrefix(cleanPath, string(filepath.Separator))
|
|
|
|
// Join with base path
|
|
fullPath := filepath.Join(s.basePath, cleanPath)
|
|
|
|
// Verify the resolved path is still within basePath
|
|
if !strings.HasPrefix(fullPath, s.basePath) {
|
|
return "", fmt.Errorf("path traversal attempt detected: %s", remotePath)
|
|
}
|
|
|
|
return fullPath, nil
|
|
}
|
|
|
|
// Upload uploads a local file to the storage with the given target name.
|
|
func (s *LocalStorage) Upload(ctx context.Context, localPath string, remotePath string) error {
|
|
select {
|
|
case <-ctx.Done():
|
|
return ctx.Err()
|
|
default:
|
|
}
|
|
|
|
targetPath, err := s.resolvePath(remotePath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Open source file
|
|
srcFile, err := os.Open(localPath)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to open source file: %w", err)
|
|
}
|
|
defer srcFile.Close()
|
|
|
|
// Create parent directories if needed
|
|
if err := os.MkdirAll(filepath.Dir(targetPath), 0755); err != nil {
|
|
return fmt.Errorf("failed to create parent directories: %w", err)
|
|
}
|
|
|
|
// Create destination file
|
|
dstFile, err := os.Create(targetPath)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create destination file: %w", err)
|
|
}
|
|
defer dstFile.Close()
|
|
|
|
// Copy with context cancellation checks
|
|
buf := make([]byte, 32*1024) // 32KB buffer
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
// Clean up partial file on cancellation
|
|
os.Remove(targetPath)
|
|
return ctx.Err()
|
|
default:
|
|
}
|
|
|
|
n, readErr := srcFile.Read(buf)
|
|
if n > 0 {
|
|
if _, writeErr := dstFile.Write(buf[:n]); writeErr != nil {
|
|
os.Remove(targetPath)
|
|
return fmt.Errorf("failed to write to destination: %w", writeErr)
|
|
}
|
|
}
|
|
if readErr == io.EOF {
|
|
break
|
|
}
|
|
if readErr != nil {
|
|
os.Remove(targetPath)
|
|
return fmt.Errorf("failed to read source file: %w", readErr)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// Download downloads a file from storage to a local path.
|
|
func (s *LocalStorage) Download(ctx context.Context, remotePath string, localPath string) error {
|
|
select {
|
|
case <-ctx.Done():
|
|
return ctx.Err()
|
|
default:
|
|
}
|
|
|
|
sourcePath, err := s.resolvePath(remotePath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Open source file
|
|
srcFile, err := os.Open(sourcePath)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to open remote file: %w", err)
|
|
}
|
|
defer srcFile.Close()
|
|
|
|
// Create parent directories for local path if needed
|
|
if err := os.MkdirAll(filepath.Dir(localPath), 0755); err != nil {
|
|
return fmt.Errorf("failed to create local parent directories: %w", err)
|
|
}
|
|
|
|
// Create destination file
|
|
dstFile, err := os.Create(localPath)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create local file: %w", err)
|
|
}
|
|
defer dstFile.Close()
|
|
|
|
// Copy with context cancellation checks
|
|
buf := make([]byte, 32*1024) // 32KB buffer
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
// Clean up partial file on cancellation
|
|
os.Remove(localPath)
|
|
return ctx.Err()
|
|
default:
|
|
}
|
|
|
|
n, readErr := srcFile.Read(buf)
|
|
if n > 0 {
|
|
if _, writeErr := dstFile.Write(buf[:n]); writeErr != nil {
|
|
os.Remove(localPath)
|
|
return fmt.Errorf("failed to write to local file: %w", writeErr)
|
|
}
|
|
}
|
|
if readErr == io.EOF {
|
|
break
|
|
}
|
|
if readErr != nil {
|
|
os.Remove(localPath)
|
|
return fmt.Errorf("failed to read remote file: %w", readErr)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// List returns all items in the storage that match the given prefix.
|
|
func (s *LocalStorage) List(ctx context.Context, prefix string) ([]Item, error) {
|
|
select {
|
|
case <-ctx.Done():
|
|
return nil, ctx.Err()
|
|
default:
|
|
}
|
|
|
|
var items []Item
|
|
|
|
// Clean and resolve the prefix path
|
|
searchPath := s.basePath
|
|
cleanPrefix := strings.TrimPrefix(filepath.Clean(prefix), string(filepath.Separator))
|
|
|
|
if cleanPrefix != "" && cleanPrefix != "." {
|
|
searchPath = filepath.Join(s.basePath, cleanPrefix)
|
|
}
|
|
|
|
// Check if the search path exists
|
|
info, err := os.Stat(searchPath)
|
|
if os.IsNotExist(err) {
|
|
// If the exact path doesn't exist, try to find files with this prefix
|
|
// by walking the parent directory
|
|
parentDir := filepath.Dir(searchPath)
|
|
baseName := filepath.Base(searchPath)
|
|
|
|
if _, err := os.Stat(parentDir); os.IsNotExist(err) {
|
|
return items, nil // Return empty list if parent doesn't exist
|
|
}
|
|
|
|
entries, err := os.ReadDir(parentDir)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to read directory: %w", err)
|
|
}
|
|
|
|
for _, entry := range entries {
|
|
select {
|
|
case <-ctx.Done():
|
|
return nil, ctx.Err()
|
|
default:
|
|
}
|
|
|
|
if strings.HasPrefix(entry.Name(), baseName) {
|
|
fullPath := filepath.Join(parentDir, entry.Name())
|
|
item, err := s.fileInfoToItem(fullPath)
|
|
if err != nil {
|
|
continue // Skip files we can't stat
|
|
}
|
|
items = append(items, item)
|
|
}
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to stat path: %w", err)
|
|
}
|
|
|
|
// If it's a file, return just that file
|
|
if !info.IsDir() {
|
|
item, err := s.fileInfoToItem(searchPath)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return []Item{item}, nil
|
|
}
|
|
|
|
// Walk the directory
|
|
err = filepath.WalkDir(searchPath, func(path string, d os.DirEntry, err error) error {
|
|
select {
|
|
case <-ctx.Done():
|
|
return ctx.Err()
|
|
default:
|
|
}
|
|
|
|
if err != nil {
|
|
return nil // Skip entries with errors
|
|
}
|
|
|
|
item, err := s.fileInfoToItem(path)
|
|
if err != nil {
|
|
return nil // Skip files we can't stat
|
|
}
|
|
items = append(items, item)
|
|
return nil
|
|
})
|
|
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to walk directory: %w", err)
|
|
}
|
|
|
|
return items, nil
|
|
}
|
|
|
|
// fileInfoToItem converts a file path to an Item.
|
|
func (s *LocalStorage) fileInfoToItem(path string) (Item, error) {
|
|
info, err := os.Stat(path)
|
|
if err != nil {
|
|
return Item{}, err
|
|
}
|
|
|
|
// Get relative path from base
|
|
relPath, err := filepath.Rel(s.basePath, path)
|
|
if err != nil {
|
|
relPath = path
|
|
}
|
|
|
|
// Normalize to forward slashes for consistency
|
|
relPath = filepath.ToSlash(relPath)
|
|
|
|
size := info.Size()
|
|
if info.IsDir() {
|
|
size = 0
|
|
}
|
|
|
|
return Item{
|
|
Key: relPath,
|
|
ModifiedTime: info.ModTime(),
|
|
Size: size,
|
|
IsDirectory: info.IsDir(),
|
|
}, nil
|
|
}
|
|
|
|
// Remove deletes a file from storage.
|
|
func (s *LocalStorage) Remove(ctx context.Context, remotePath string) error {
|
|
select {
|
|
case <-ctx.Done():
|
|
return ctx.Err()
|
|
default:
|
|
}
|
|
|
|
targetPath, err := s.resolvePath(remotePath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Check if file exists
|
|
info, err := os.Stat(targetPath)
|
|
if os.IsNotExist(err) {
|
|
return fmt.Errorf("file not found: %s", remotePath)
|
|
}
|
|
if err != nil {
|
|
return fmt.Errorf("failed to stat file: %w", err)
|
|
}
|
|
|
|
// Remove file or directory
|
|
if info.IsDir() {
|
|
if err := os.RemoveAll(targetPath); err != nil {
|
|
return fmt.Errorf("failed to remove directory: %w", err)
|
|
}
|
|
} else {
|
|
if err := os.Remove(targetPath); err != nil {
|
|
return fmt.Errorf("failed to remove file: %w", err)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// Close releases any resources held by the storage implementation.
|
|
// For LocalStorage, this is a no-op as there are no persistent resources.
|
|
func (s *LocalStorage) Close() error {
|
|
return nil
|
|
}
|
|
|
|
// BasePath returns the base directory path of the storage.
|
|
func (s *LocalStorage) BasePath() string {
|
|
return s.basePath
|
|
}
|