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 }