package storage import ( "context" "crypto/rand" "encoding/hex" "errors" "fmt" "io" "path/filepath" "strings" "time" "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/service/s3" "github.com/aws/aws-sdk-go-v2/service/s3/types" ) type S3Storage struct { client s3Client bucket string customURL string proxyS3 bool } // s3Client is the interface that wraps the basic S3 client operations we need type s3Client interface { PutObject(ctx context.Context, params *s3.PutObjectInput, optFns ...func(*s3.Options)) (*s3.PutObjectOutput, error) GetObject(ctx context.Context, params *s3.GetObjectInput, optFns ...func(*s3.Options)) (*s3.GetObjectOutput, error) DeleteObject(ctx context.Context, params *s3.DeleteObjectInput, optFns ...func(*s3.Options)) (*s3.DeleteObjectOutput, error) ListObjectsV2(ctx context.Context, params *s3.ListObjectsV2Input, optFns ...func(*s3.Options)) (*s3.ListObjectsV2Output, error) HeadObject(ctx context.Context, params *s3.HeadObjectInput, optFns ...func(*s3.Options)) (*s3.HeadObjectOutput, error) } func NewS3Storage(client s3Client, bucket string, customURL string, proxyS3 bool) *S3Storage { return &S3Storage{ client: client, bucket: bucket, customURL: customURL, proxyS3: proxyS3, } } func (s *S3Storage) generateID() (string, error) { bytes := make([]byte, 16) if _, err := rand.Read(bytes); err != nil { return "", err } return hex.EncodeToString(bytes), nil } func (s *S3Storage) generateObjectKey(id string, ext string, createTime time.Time) string { // Create year/month structure year := createTime.Format("2006") month := createTime.Format("01") filename := id if ext != "" { filename = id + ext } return fmt.Sprintf("%s/%s/%s", year, month, filename) } func (s *S3Storage) getObjectURL(key string) string { if s.customURL != "" { return fmt.Sprintf("%s/%s", strings.TrimRight(s.customURL, "/"), key) } if s.proxyS3 { return fmt.Sprintf("/media/%s", key) } return fmt.Sprintf("https://%s.s3.amazonaws.com/%s", s.bucket, key) } func (s *S3Storage) Save(ctx context.Context, name string, contentType string, reader io.Reader) (*FileInfo, error) { // Generate a unique ID for the file id, err := s.generateID() if err != nil { return nil, fmt.Errorf("failed to generate file ID: %w", err) } // Get file extension from original name or content type ext := filepath.Ext(name) if ext == "" { // If no extension in name, try to get it from content type switch contentType { case "image/jpeg": ext = ".jpg" case "image/png": ext = ".png" case "image/gif": ext = ".gif" case "image/webp": ext = ".webp" case "image/svg+xml": ext = ".svg" case "video/mp4": ext = ".mp4" case "video/webm": ext = ".webm" case "audio/mpeg": ext = ".mp3" case "audio/ogg": ext = ".ogg" case "audio/wav": ext = ".wav" case "application/pdf": ext = ".pdf" case "application/msword": ext = ".doc" case "application/vnd.openxmlformats-officedocument.wordprocessingml.document": ext = ".docx" case "application/vnd.ms-excel": ext = ".xls" case "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": ext = ".xlsx" case "application/zip": ext = ".zip" case "application/x-rar-compressed": ext = ".rar" case "text/plain": ext = ".txt" case "text/csv": ext = ".csv" } } // Create the object key with year/month structure now := time.Now() key := s.generateObjectKey(id, ext, now) // Check if the file exists _, err = s.client.HeadObject(ctx, &s3.HeadObjectInput{ Bucket: aws.String(s.bucket), Key: aws.String(key), }) if err == nil { return nil, fmt.Errorf("file already exists with ID: %s", id) } var noSuchKey *types.NoSuchKey if !errors.As(err, &noSuchKey) { return nil, fmt.Errorf("failed to check if file exists: %w", err) } // Upload the file _, err = s.client.PutObject(ctx, &s3.PutObjectInput{ Bucket: aws.String(s.bucket), Key: aws.String(key), Body: reader, ContentType: aws.String(contentType), Metadata: map[string]string{ "x-amz-meta-original-name": name, "x-amz-meta-created-at": now.Format(time.RFC3339), }, }) if err != nil { return nil, fmt.Errorf("failed to upload file: %w", err) } info := &FileInfo{ ID: id, Name: name, Size: 0, // Size is not available until after upload ContentType: contentType, CreatedAt: now, URL: s.getObjectURL(key), } return info, nil } func (s *S3Storage) Get(ctx context.Context, id string) (io.ReadCloser, *FileInfo, error) { // Try to find the file with different extensions exts := []string{"", ".jpg", ".png", ".gif", ".webp", ".svg", ".mp4", ".webm", ".mp3", ".ogg", ".wav", ".pdf", ".doc", ".docx", ".xls", ".xlsx", ".zip", ".rar", ".txt", ".csv"} var result *s3.GetObjectOutput var err error var key string for _, ext := range exts { key = s.generateObjectKey(id, ext, time.Now()) result, err = s.client.GetObject(ctx, &s3.GetObjectInput{ Bucket: aws.String(s.bucket), Key: aws.String(key), }) if err == nil { break } } if err != nil { return nil, nil, fmt.Errorf("failed to get file from S3: %w", err) } info := &FileInfo{ ID: id, Name: result.Metadata["x-amz-meta-original-name"], Size: aws.ToInt64(result.ContentLength), ContentType: aws.ToString(result.ContentType), CreatedAt: time.Now(), UpdatedAt: time.Now(), URL: s.getObjectURL(key), } return result.Body, info, nil } func (s *S3Storage) Delete(ctx context.Context, id string) error { // Try to find and delete the file with different extensions exts := []string{"", ".jpg", ".png", ".gif", ".webp", ".svg", ".mp4", ".webm", ".mp3", ".ogg", ".wav", ".pdf", ".doc", ".docx", ".xls", ".xlsx", ".zip", ".rar", ".txt", ".csv"} var lastErr error for _, ext := range exts { key := s.generateObjectKey(id, ext, time.Now()) _, err := s.client.DeleteObject(ctx, &s3.DeleteObjectInput{ Bucket: aws.String(s.bucket), Key: aws.String(key), }) if err == nil { return nil } lastErr = err } return fmt.Errorf("failed to delete file from S3: %w", lastErr) } func (s *S3Storage) Exists(ctx context.Context, id string) (bool, error) { // Try to find the file with different extensions exts := []string{"", ".jpg", ".png", ".gif", ".webp", ".svg", ".mp4", ".webm", ".mp3", ".ogg", ".wav", ".pdf", ".doc", ".docx", ".xls", ".xlsx", ".zip", ".rar", ".txt", ".csv"} for _, ext := range exts { key := s.generateObjectKey(id, ext, time.Now()) _, err := s.client.HeadObject(ctx, &s3.HeadObjectInput{ Bucket: aws.String(s.bucket), Key: aws.String(key), }) if err == nil { return true, nil } } return false, nil } func (s *S3Storage) List(ctx context.Context, prefix string, limit int, offset int) ([]*FileInfo, error) { var files []*FileInfo var continuationToken *string count := 0 skip := offset for { input := &s3.ListObjectsV2Input{ Bucket: aws.String(s.bucket), Prefix: aws.String(prefix), ContinuationToken: continuationToken, MaxKeys: aws.Int32(100), // Fetch in batches of 100 } result, err := s.client.ListObjectsV2(ctx, input) if err != nil { return nil, fmt.Errorf("failed to list files from S3: %w", err) } // Process each object for _, obj := range result.Contents { if skip > 0 { skip-- continue } // Get object metadata head, err := s.client.HeadObject(ctx, &s3.HeadObjectInput{ Bucket: aws.String(s.bucket), Key: obj.Key, }) if err != nil { continue // Skip files we can't get metadata for } // Extract the ID from the key (remove extension if present) id := aws.ToString(obj.Key) if ext := filepath.Ext(id); ext != "" { id = id[:len(id)-len(ext)] } info := &FileInfo{ ID: id, Name: head.Metadata["x-amz-meta-original-name"], Size: aws.ToInt64(obj.Size), ContentType: aws.ToString(head.ContentType), CreatedAt: aws.ToTime(obj.LastModified), UpdatedAt: aws.ToTime(obj.LastModified), URL: s.getObjectURL(aws.ToString(obj.Key)), } files = append(files, info) count++ if count >= limit { return files, nil } } if !aws.ToBool(result.IsTruncated) { break } continuationToken = result.NextContinuationToken } return files, nil }