Files
openaccounting-server/core/storage/s3.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

242 lines
5.8 KiB
Go

package storage
import (
"fmt"
"io"
"path"
"strings"
"time"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/aws/awserr"
"github.com/aws/aws-sdk-go/aws/credentials"
"github.com/aws/aws-sdk-go/aws/session"
"github.com/aws/aws-sdk-go/service/s3"
"github.com/aws/aws-sdk-go/service/s3/s3manager"
"github.com/openaccounting/oa-server/core/util/id"
)
// S3Storage implements the Storage interface for Amazon S3
type S3Storage struct {
client *s3.S3
uploader *s3manager.Uploader
bucket string
prefix string
}
// NewS3Storage creates a new S3 storage backend
func NewS3Storage(config S3Config) (*S3Storage, error) {
if config.Bucket == "" {
return nil, fmt.Errorf("S3 bucket name is required")
}
// Create AWS config
awsConfig := &aws.Config{
Region: aws.String(config.Region),
}
// Set custom endpoint if provided (for S3-compatible services)
if config.Endpoint != "" {
awsConfig.Endpoint = aws.String(config.Endpoint)
awsConfig.S3ForcePathStyle = aws.Bool(config.PathStyle)
}
// Set credentials if provided
if config.AccessKeyID != "" && config.SecretAccessKey != "" {
awsConfig.Credentials = credentials.NewStaticCredentials(
config.AccessKeyID,
config.SecretAccessKey,
"",
)
}
// Create session
sess, err := session.NewSession(awsConfig)
if err != nil {
return nil, fmt.Errorf("failed to create AWS session: %w", err)
}
// Create S3 client
client := s3.New(sess)
uploader := s3manager.NewUploader(sess)
return &S3Storage{
client: client,
uploader: uploader,
bucket: config.Bucket,
prefix: config.Prefix,
}, nil
}
// Store saves a file to S3
func (s *S3Storage) Store(filename string, content io.Reader, contentType string) (string, error) {
// Generate a unique storage key
storageKey := s.generateStorageKey(filename)
// Prepare upload input
input := &s3manager.UploadInput{
Bucket: aws.String(s.bucket),
Key: aws.String(storageKey),
Body: content,
}
// Set content type if provided
if contentType != "" {
input.ContentType = aws.String(contentType)
}
// Upload the file
_, err := s.uploader.Upload(input)
if err != nil {
return "", fmt.Errorf("failed to upload file to S3: %w", err)
}
return storageKey, nil
}
// Retrieve gets a file from S3
func (s *S3Storage) Retrieve(path string) (io.ReadCloser, error) {
input := &s3.GetObjectInput{
Bucket: aws.String(s.bucket),
Key: aws.String(path),
}
result, err := s.client.GetObject(input)
if err != nil {
if aerr, ok := err.(awserr.Error); ok {
switch aerr.Code() {
case s3.ErrCodeNoSuchKey:
return nil, &FileNotFoundError{Path: path}
}
}
return nil, fmt.Errorf("failed to retrieve file from S3: %w", err)
}
return result.Body, nil
}
// Delete removes a file from S3
func (s *S3Storage) Delete(path string) error {
input := &s3.DeleteObjectInput{
Bucket: aws.String(s.bucket),
Key: aws.String(path),
}
_, err := s.client.DeleteObject(input)
if err != nil {
return fmt.Errorf("failed to delete file from S3: %w", err)
}
return nil
}
// GetURL returns a presigned URL for accessing the file
func (s *S3Storage) GetURL(path string, expiry time.Duration) (string, error) {
// Check if file exists first
exists, err := s.Exists(path)
if err != nil {
return "", err
}
if !exists {
return "", &FileNotFoundError{Path: path}
}
// Generate presigned URL
req, _ := s.client.GetObjectRequest(&s3.GetObjectInput{
Bucket: aws.String(s.bucket),
Key: aws.String(path),
})
url, err := req.Presign(expiry)
if err != nil {
return "", fmt.Errorf("failed to generate presigned URL: %w", err)
}
return url, nil
}
// GetURLWithContext returns a presigned URL for accessing the file (S3 doesn't use user context)
func (s *S3Storage) GetURLWithContext(path string, expiry time.Duration, userID, orgID string) (string, error) {
// For S3, user context is not needed as presigned URLs are cryptographically secure
return s.GetURL(path, expiry)
}
// Exists checks if a file exists in S3
func (s *S3Storage) Exists(path string) (bool, error) {
input := &s3.HeadObjectInput{
Bucket: aws.String(s.bucket),
Key: aws.String(path),
}
_, err := s.client.HeadObject(input)
if err != nil {
if aerr, ok := err.(awserr.Error); ok {
switch aerr.Code() {
case s3.ErrCodeNoSuchKey, "NotFound":
return false, nil
}
}
return false, fmt.Errorf("failed to check file existence in S3: %w", err)
}
return true, nil
}
// GetMetadata returns file metadata from S3
func (s *S3Storage) GetMetadata(path string) (*FileMetadata, error) {
input := &s3.HeadObjectInput{
Bucket: aws.String(s.bucket),
Key: aws.String(path),
}
result, err := s.client.HeadObject(input)
if err != nil {
if aerr, ok := err.(awserr.Error); ok {
switch aerr.Code() {
case s3.ErrCodeNoSuchKey, "NotFound":
return nil, &FileNotFoundError{Path: path}
}
}
return nil, fmt.Errorf("failed to get file metadata from S3: %w", err)
}
metadata := &FileMetadata{
Size: aws.Int64Value(result.ContentLength),
}
if result.LastModified != nil {
metadata.LastModified = *result.LastModified
}
if result.ContentType != nil {
metadata.ContentType = *result.ContentType
}
if result.ETag != nil {
metadata.ETag = strings.Trim(*result.ETag, "\"")
}
return metadata, nil
}
// generateStorageKey creates a unique storage key for a file
func (s *S3Storage) generateStorageKey(filename string) string {
// Generate a unique ID for the file
fileID := id.String(id.New())
// Extract file extension
ext := path.Ext(filename)
// Create a key structure: prefix/YYYY/MM/DD/uuid.ext
now := time.Now()
datePath := fmt.Sprintf("%04d/%02d/%02d", now.Year(), now.Month(), now.Day())
key := path.Join(datePath, fileID+ext)
// Add prefix if configured
if s.prefix != "" {
key = path.Join(s.prefix, key)
}
return key
}