Files
openaccounting-server/core/storage/local.go
Aaron Guise 8b6ba74ce9 feat: implement secure file upload system with JWT authentication
- Add JWT-based secure file access for local storage with 1-hour expiry
- Implement GORM repository methods for attachment CRUD operations
- Add secure file serving endpoint with token validation
- Update storage interface to support user context in URL generation
- Add comprehensive security features including path traversal protection
- Update documentation with security model and configuration examples
- Add utility functions for hex/byte conversion and UUID validation
- Configure secure file permissions (0600) for uploaded files

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-03 15:45:25 +12:00

256 lines
6.7 KiB
Go

package storage
import (
"fmt"
"io"
"net/url"
"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
tokenService *TokenService
}
// 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 with secure permissions
if err := os.MkdirAll(rootDir, 0700); err != nil {
return nil, fmt.Errorf("failed to create storage directory: %w", err)
}
// Initialize token service for secure URL generation
tokenService := NewTokenService(config.SigningKey)
return &LocalStorage{
rootDir: rootDir,
baseURL: config.BaseURL,
tokenService: tokenService,
}, 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 with secure permissions
dir := filepath.Dir(fullPath)
if err := os.MkdirAll(dir, 0700); err != nil {
return "", fmt.Errorf("failed to create directory: %w", err)
}
// Create and write the file with secure permissions
file, err := os.OpenFile(fullPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0600)
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 secure URL for accessing the file with JWT token
func (l *LocalStorage) GetURL(path string, expiry time.Duration) (string, error) {
return l.GetURLWithContext(path, expiry, "", "")
}
// GetURLWithContext returns a secure URL for accessing the file with JWT token and user context
func (l *LocalStorage) GetURLWithContext(path string, expiry time.Duration, userID, orgID string) (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}
}
// Generate secure JWT token for file access
token, err := l.tokenService.GenerateFileToken(path, userID, orgID, expiry)
if err != nil {
return "", fmt.Errorf("failed to generate access token: %w", err)
}
// Return secure URL with token parameter
params := url.Values{}
params.Set("token", token)
return "/secure-files?" + params.Encode(), 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
}