package storage import ( "fmt" "io" "os" "path/filepath" "strings" "time" "github.com/openaccounting/oa-server/core/util/id" ) // LocalStorage implements the Storage interface for local filesystem type LocalStorage struct { rootDir string baseURL string } // NewLocalStorage creates a new local filesystem storage backend func NewLocalStorage(config LocalConfig) (*LocalStorage, error) { rootDir := config.RootDir if rootDir == "" { rootDir = "./uploads" } // Ensure the root directory exists if err := os.MkdirAll(rootDir, 0755); err != nil { return nil, fmt.Errorf("failed to create storage directory: %w", err) } return &LocalStorage{ rootDir: rootDir, baseURL: config.BaseURL, }, nil } // Store saves a file to the local filesystem func (l *LocalStorage) Store(filename string, content io.Reader, contentType string) (string, error) { // Generate a unique storage path storagePath := l.generateStoragePath(filename) fullPath := filepath.Join(l.rootDir, storagePath) // Ensure the directory exists dir := filepath.Dir(fullPath) if err := os.MkdirAll(dir, 0755); err != nil { return "", fmt.Errorf("failed to create directory: %w", err) } // Create and write the file file, err := os.Create(fullPath) if err != nil { return "", fmt.Errorf("failed to create file: %w", err) } defer file.Close() _, err = io.Copy(file, content) if err != nil { // Clean up the file if write failed os.Remove(fullPath) return "", fmt.Errorf("failed to write file: %w", err) } return storagePath, nil } // Retrieve gets a file from the local filesystem func (l *LocalStorage) Retrieve(path string) (io.ReadCloser, error) { // Validate path to prevent directory traversal if err := l.validatePath(path); err != nil { return nil, err } fullPath := filepath.Join(l.rootDir, path) file, err := os.Open(fullPath) if err != nil { if os.IsNotExist(err) { return nil, &FileNotFoundError{Path: path} } return nil, fmt.Errorf("failed to open file: %w", err) } return file, nil } // Delete removes a file from the local filesystem func (l *LocalStorage) Delete(path string) error { // Validate path to prevent directory traversal if err := l.validatePath(path); err != nil { return err } fullPath := filepath.Join(l.rootDir, path) err := os.Remove(fullPath) if err != nil && !os.IsNotExist(err) { return fmt.Errorf("failed to delete file: %w", err) } // Try to remove empty parent directories l.cleanupEmptyDirs(filepath.Dir(fullPath)) return nil } // GetURL returns a URL for accessing the file func (l *LocalStorage) GetURL(path string, expiry time.Duration) (string, error) { // Validate path to prevent directory traversal if err := l.validatePath(path); err != nil { return "", err } // Check if file exists exists, err := l.Exists(path) if err != nil { return "", err } if !exists { return "", &FileNotFoundError{Path: path} } if l.baseURL != "" { // Return a public URL if base URL is configured return l.baseURL + "/" + path, nil } // For local storage without a base URL, return the file path // In a real application, you might serve these through an endpoint return "/files/" + path, nil } // Exists checks if a file exists at the given path func (l *LocalStorage) Exists(path string) (bool, error) { // Validate path to prevent directory traversal if err := l.validatePath(path); err != nil { return false, err } fullPath := filepath.Join(l.rootDir, path) _, err := os.Stat(fullPath) if err != nil { if os.IsNotExist(err) { return false, nil } return false, fmt.Errorf("failed to check file existence: %w", err) } return true, nil } // GetMetadata returns file metadata func (l *LocalStorage) GetMetadata(path string) (*FileMetadata, error) { // Validate path to prevent directory traversal if err := l.validatePath(path); err != nil { return nil, err } fullPath := filepath.Join(l.rootDir, path) info, err := os.Stat(fullPath) if err != nil { if os.IsNotExist(err) { return nil, &FileNotFoundError{Path: path} } return nil, fmt.Errorf("failed to get file metadata: %w", err) } return &FileMetadata{ Size: info.Size(), LastModified: info.ModTime(), ContentType: "", // Local storage doesn't store content type ETag: "", // Local storage doesn't have ETags }, nil } // generateStoragePath creates a unique storage path for a file func (l *LocalStorage) generateStoragePath(filename string) string { // Generate a unique ID for the file fileID := id.String(id.New()) // Extract file extension ext := filepath.Ext(filename) // Create a path structure: YYYY/MM/DD/uuid.ext now := time.Now() datePath := fmt.Sprintf("%04d/%02d/%02d", now.Year(), now.Month(), now.Day()) return filepath.Join(datePath, fileID+ext) } // validatePath ensures the path doesn't contain directory traversal attempts func (l *LocalStorage) validatePath(path string) error { // Clean the path and check for traversal attempts cleanPath := filepath.Clean(path) // Reject paths that try to go up directories if strings.Contains(cleanPath, "..") { return &InvalidPathError{Path: path} } // Reject absolute paths if filepath.IsAbs(cleanPath) { return &InvalidPathError{Path: path} } return nil } // cleanupEmptyDirs removes empty parent directories up to the root func (l *LocalStorage) cleanupEmptyDirs(dir string) { // Don't remove the root directory if dir == l.rootDir { return } // Check if directory is empty entries, err := os.ReadDir(dir) if err != nil || len(entries) > 0 { return } // Remove empty directory if err := os.Remove(dir); err == nil { // Recursively clean parent directories l.cleanupEmptyDirs(filepath.Dir(dir)) } } // FileNotFoundError is returned when a file doesn't exist type FileNotFoundError struct { Path string } func (e *FileNotFoundError) Error() string { return "file not found: " + e.Path } // InvalidPathError is returned when a path is invalid or contains traversal attempts type InvalidPathError struct { Path string } func (e *InvalidPathError) Error() string { return "invalid path: " + e.Path }