add media service
This commit is contained in:
196
internal/infrastructure/storage/s3.go
Normal file
196
internal/infrastructure/storage/s3.go
Normal file
@@ -0,0 +1,196 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user