package storage import ( "context" "fmt" "io" "lendry-erp/media/internal/config" "lendry-erp/media/pkg/logger" "strings" "time" "github.com/aws/aws-sdk-go-v2/aws" awsConfig "github.com/aws/aws-sdk-go-v2/config" "github.com/aws/aws-sdk-go-v2/credentials" "github.com/aws/aws-sdk-go-v2/feature/s3/manager" "github.com/aws/aws-sdk-go-v2/service/s3" s3Types "github.com/aws/aws-sdk-go-v2/service/s3/types" ) type S3Storage struct { client *s3.Client uploader *manager.Uploader downloader *manager.Downloader bucket string cfg *config.Config presigner *s3.PresignClient } func NewS3Storage(c *config.Config) (*S3Storage, error) { var loadOpts []func(*awsConfig.LoadOptions) error if c.Storage.Region != "" { loadOpts = append(loadOpts, awsConfig.WithRegion(c.Storage.Region)) } if c.Storage.AccessKey != "" && c.Storage.SecretKey != "" { loadOpts = append(loadOpts, awsConfig.WithCredentialsProvider( credentials.NewStaticCredentialsProvider(c.Storage.AccessKey, c.Storage.SecretKey, ""), )) } awsCfg, err := awsConfig.LoadDefaultConfig(context.Background(), loadOpts...) if err != nil { return nil, fmt.Errorf("load aws config: %w", err) } var clientOpts []func(*s3.Options) if strings.TrimSpace(c.Storage.Endpoint) != "" { ep := c.Storage.Endpoint clientOpts = append(clientOpts, func(o *s3.Options) { o.UsePathStyle = true o.BaseEndpoint = aws.String(ep) }) } client := s3.NewFromConfig(awsCfg, clientOpts...) uploader := manager.NewUploader(client) downloader := manager.NewDownloader(client) presigner := s3.NewPresignClient(client) s := &S3Storage{ client: client, uploader: uploader, downloader: downloader, bucket: c.Storage.Bucket, cfg: c, presigner: presigner, } ctx := context.Background() _, headErr := s.client.HeadBucket(ctx, &s3.HeadBucketInput{Bucket: aws.String(s.bucket)}) if headErr != nil { _, createErr := s.client.CreateBucket(ctx, &s3.CreateBucketInput{ Bucket: aws.String(s.bucket), CreateBucketConfiguration: &s3Types.CreateBucketConfiguration{ LocationConstraint: s3Types.BucketLocationConstraint(aws.ToString(&c.Storage.Region)), }, }) if createErr != nil { return nil, fmt.Errorf("create bucket: %w (head err: %v)", createErr, headErr) } logger.Info("🪣 Created S3 bucket: %s", s.bucket) } logger.Info("✅ Connected to S3 bucket: %s (region=%s)", s.bucket, c.Storage.Region) return s, nil } func (s *S3Storage) UploadStream( ctx context.Context, key string, reader io.Reader, contentType string, ) error { _, err := s.uploader.Upload(ctx, &s3.PutObjectInput{ Bucket: aws.String(s.bucket), Key: aws.String(key), Body: reader, ContentType: aws.String(contentType), }) if err != nil { return fmt.Errorf("s3 upload: %w", err) } return nil } func (s *S3Storage) GetStream( ctx context.Context, key string, ) (io.ReadCloser, string, error) { out, err := s.client.GetObject(ctx, &s3.GetObjectInput{ Bucket: aws.String(s.bucket), Key: aws.String(key), }) if err != nil { return nil, "", fmt.Errorf("s3 get: %w", err) } contentType := "" if out.ContentType != nil { contentType = *out.ContentType } return out.Body, contentType, nil } func (s *S3Storage) Delete(ctx context.Context, key string) error { _, err := s.client.DeleteObject(ctx, &s3.DeleteObjectInput{ Bucket: aws.String(s.bucket), Key: aws.String(key), }) if err != nil { return fmt.Errorf("s3 delete: %w", err) } return nil } func (s *S3Storage) GetPublicURL(key string) string { // Этот метод можно оставить на случай, если вы решите сделать // бакет публичным в обход временных ссылок (Presigned URLs) host := "http://localhost:9000" // Замените на хост MinIO для клиента, если нужно if !strings.HasPrefix(host, "http://") && !strings.HasPrefix(host, "https://") { host = "http://" + host } return fmt.Sprintf("%s/%s/%s", strings.TrimRight(host, "/"), s.bucket, key) } func (s *S3Storage) GetPresignedURL(ctx context.Context, key string, expire time.Duration, method string) (string, error) { switch strings.ToUpper(method) { case "GET": ps, err := s.presigner.PresignGetObject(ctx, &s3.GetObjectInput{ Bucket: aws.String(s.bucket), Key: aws.String(key), }, s3.WithPresignExpires(expire)) if err != nil { return "", fmt.Errorf("presign GET: %w", err) } return ps.URL, nil case "PUT": ps, err := s.presigner.PresignPutObject(ctx, &s3.PutObjectInput{ Bucket: aws.String(s.bucket), Key: aws.String(key), }, s3.WithPresignExpires(expire)) if err != nil { return "", fmt.Errorf("presign PUT: %w", err) } return ps.URL, nil default: return "", fmt.Errorf("unsupported method for presign: %s", method) } } func (s *S3Storage) Close() error { // AWS SDK v2 не требует явного закрытия клиента, // но мы реализуем метод для соответствия интерфейсу return nil } // ===================================================================== // ИНТЕРФЕЙС ХРАНИЛИЩА // ===================================================================== // Storage определяет контракт для работы с файлами. // Благодаря ему UseCase'ы (например, UploadUseCase) не привязаны жестко к S3 // и их можно легко тестировать с помощью моков. type Storage interface { UploadStream(ctx context.Context, key string, reader io.Reader, contentType string) error GetStream(ctx context.Context, key string) (io.ReadCloser, string, error) Delete(ctx context.Context, key string) error GetPresignedURL(ctx context.Context, key string, expire time.Duration, method string) (string, error) Close() error }