package storage import ( "context" "errors" "fmt" "io" "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/service/s3" "github.com/aws/aws-sdk-go-v2/service/s3/types" "github.com/aws/smithy-go" "shared-album/internal/config" ) type Client struct { s3 *s3.Client presign *s3.PresignClient bucket string presignTTL time.Duration } func New(ctx context.Context, cfg config.S3) (*Client, error) { awsCfg, err := awsconfig.LoadDefaultConfig(ctx, awsconfig.WithRegion(cfg.Region), awsconfig.WithCredentialsProvider(credentials.NewStaticCredentialsProvider( cfg.AccessKey, cfg.SecretKey, "", )), ) if err != nil { return nil, fmt.Errorf("load aws config: %w", err) } client := s3.NewFromConfig(awsCfg, func(o *s3.Options) { o.BaseEndpoint = aws.String(cfg.Endpoint) o.UsePathStyle = cfg.UsePathStyle }) return &Client{ s3: client, presign: s3.NewPresignClient(client), bucket: cfg.Bucket, presignTTL: cfg.PresignTTL, }, nil } func (c *Client) Bucket() string { return c.bucket } func (c *Client) EnsureBucket(ctx context.Context) error { _, err := c.s3.HeadBucket(ctx, &s3.HeadBucketInput{Bucket: aws.String(c.bucket)}) if err == nil { return nil } var nf *types.NotFound if !errors.As(err, &nf) && !isNoSuchBucket(err) { return fmt.Errorf("head bucket: %w", err) } if _, err := c.s3.CreateBucket(ctx, &s3.CreateBucketInput{Bucket: aws.String(c.bucket)}); err != nil { return fmt.Errorf("create bucket: %w", err) } return nil } func (c *Client) Ping(ctx context.Context) error { _, err := c.s3.HeadBucket(ctx, &s3.HeadBucketInput{Bucket: aws.String(c.bucket)}) return err } func (c *Client) Put(ctx context.Context, key string, body io.Reader, size int64, contentType string) error { in := &s3.PutObjectInput{ Bucket: aws.String(c.bucket), Key: aws.String(key), Body: body, ContentType: aws.String(contentType), } if size >= 0 { in.ContentLength = aws.Int64(size) } if _, err := c.s3.PutObject(ctx, in); err != nil { return fmt.Errorf("put object %s: %w", key, err) } return nil } func (c *Client) Delete(ctx context.Context, keys ...string) error { if len(keys) == 0 { return nil } objs := make([]types.ObjectIdentifier, 0, len(keys)) for _, k := range keys { objs = append(objs, types.ObjectIdentifier{Key: aws.String(k)}) } if _, err := c.s3.DeleteObjects(ctx, &s3.DeleteObjectsInput{ Bucket: aws.String(c.bucket), Delete: &types.Delete{Objects: objs, Quiet: aws.Bool(true)}, }); err != nil { return fmt.Errorf("delete objects: %w", err) } return nil } func (c *Client) DeletePrefix(ctx context.Context, prefix string) error { var keys []string paginator := s3.NewListObjectsV2Paginator(c.s3, &s3.ListObjectsV2Input{ Bucket: aws.String(c.bucket), Prefix: aws.String(prefix), }) for paginator.HasMorePages() { page, err := paginator.NextPage(ctx) if err != nil { return fmt.Errorf("list objects: %w", err) } for _, o := range page.Contents { keys = append(keys, aws.ToString(o.Key)) } } return c.Delete(ctx, keys...) } func (c *Client) PresignGet(ctx context.Context, key string) (string, error) { out, err := c.presign.PresignGetObject(ctx, &s3.GetObjectInput{ Bucket: aws.String(c.bucket), Key: aws.String(key), }, s3.WithPresignExpires(c.presignTTL)) if err != nil { return "", fmt.Errorf("presign %s: %w", key, err) } return out.URL, nil } func isNoSuchBucket(err error) bool { var apiErr smithy.APIError if errors.As(err, &apiErr) { code := apiErr.ErrorCode() return strings.EqualFold(code, "NoSuchBucket") || strings.EqualFold(code, "NotFound") } return false }