gohorsejobs/backend/internal/infrastructure/storage/s3_storage.go
Tiago Yamamoto ce6e35aefd feat(backend): implement S3 object storage with pre-signed URLs
- Add s3_storage.go service using AWS SDK v2
- Support custom S3-compatible endpoints (Civo)
- Implement pre-signed URL generation for uploads/downloads
- Add storage_handler.go with REST endpoints
- Register protected storage routes in router
- Graceful degradation when S3 not configured
2025-12-11 14:41:25 -03:00

139 lines
3.7 KiB
Go

package storage
import (
"context"
"fmt"
"os"
"time"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/config"
"github.com/aws/aws-sdk-go-v2/credentials"
"github.com/aws/aws-sdk-go-v2/service/s3"
)
// S3Storage handles S3-compatible object storage operations
type S3Storage struct {
client *s3.Client
presigner *s3.PresignClient
bucket string
endpoint string
}
// NewS3Storage creates a new S3 storage service
func NewS3Storage() (*S3Storage, error) {
region := os.Getenv("AWS_REGION")
if region == "" {
region = "us-east-1"
}
accessKey := os.Getenv("AWS_ACCESS_KEY_ID")
secretKey := os.Getenv("AWS_SECRET_ACCESS_KEY")
endpoint := os.Getenv("AWS_ENDPOINT")
bucket := os.Getenv("S3_BUCKET")
if accessKey == "" || secretKey == "" || bucket == "" {
return nil, fmt.Errorf("missing required S3 configuration (AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, S3_BUCKET)")
}
// Create custom credentials provider
creds := credentials.NewStaticCredentialsProvider(accessKey, secretKey, "")
// Build S3 config
cfg, err := config.LoadDefaultConfig(context.Background(),
config.WithRegion(region),
config.WithCredentialsProvider(creds),
)
if err != nil {
return nil, fmt.Errorf("failed to load AWS config: %w", err)
}
// Create S3 client with custom endpoint for S3-compatible storage (like Civo)
var client *s3.Client
if endpoint != "" {
client = s3.NewFromConfig(cfg, func(o *s3.Options) {
o.BaseEndpoint = aws.String(endpoint)
o.UsePathStyle = true // Required for most S3-compatible services
})
} else {
client = s3.NewFromConfig(cfg)
}
presigner := s3.NewPresignClient(client)
return &S3Storage{
client: client,
presigner: presigner,
bucket: bucket,
endpoint: endpoint,
}, nil
}
// GenerateUploadURL generates a pre-signed URL for uploading a file
func (s *S3Storage) GenerateUploadURL(key string, contentType string, expiryMinutes int) (string, error) {
if expiryMinutes <= 0 {
expiryMinutes = 15 // Default 15 minutes
}
input := &s3.PutObjectInput{
Bucket: aws.String(s.bucket),
Key: aws.String(key),
ContentType: aws.String(contentType),
}
presignResult, err := s.presigner.PresignPutObject(context.Background(), input,
s3.WithPresignExpires(time.Duration(expiryMinutes)*time.Minute))
if err != nil {
return "", fmt.Errorf("failed to generate upload URL: %w", err)
}
return presignResult.URL, nil
}
// GenerateDownloadURL generates a pre-signed URL for downloading a file
func (s *S3Storage) GenerateDownloadURL(key string, expiryMinutes int) (string, error) {
if expiryMinutes <= 0 {
expiryMinutes = 60 // Default 1 hour
}
input := &s3.GetObjectInput{
Bucket: aws.String(s.bucket),
Key: aws.String(key),
}
presignResult, err := s.presigner.PresignGetObject(context.Background(), input,
s3.WithPresignExpires(time.Duration(expiryMinutes)*time.Minute))
if err != nil {
return "", fmt.Errorf("failed to generate download URL: %w", err)
}
return presignResult.URL, nil
}
// DeleteObject deletes an object from the bucket
func (s *S3Storage) DeleteObject(key string) error {
input := &s3.DeleteObjectInput{
Bucket: aws.String(s.bucket),
Key: aws.String(key),
}
_, err := s.client.DeleteObject(context.Background(), input)
if err != nil {
return fmt.Errorf("failed to delete object: %w", err)
}
return nil
}
// GetPublicURL returns the public URL for an object (if bucket is public)
func (s *S3Storage) GetPublicURL(key string) string {
if s.endpoint != "" {
return fmt.Sprintf("%s/%s/%s", s.endpoint, s.bucket, key)
}
return fmt.Sprintf("https://%s.s3.amazonaws.com/%s", s.bucket, key)
}
// GetBucket returns the bucket name
func (s *S3Storage) GetBucket() string {
return s.bucket
}