package object_storage import ( "context" "errors" "fmt" "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/config" "github.com/aws/aws-sdk-go-v2/service/s3" "github.com/aws/aws-sdk-go-v2/service/s3/types" "github.com/aws/smithy-go" "io" "strings" "time" ) type S3StorageConfig struct { Bucket string DefaultLinkTTL time.Duration } type s3Storage struct { client *s3.Client presignedClient *s3.PresignClient config S3StorageConfig } func newS3Storage(c Config) (*s3Storage, error) { cfg, ok := c.(S3StorageConfig) if !ok { return nil, ErrInvalidConfig } storageConfig, err := config.LoadDefaultConfig(context.TODO()) if err != nil { return nil, ErrInvalidConfig } s := &s3Storage{ client: s3.NewFromConfig(storageConfig), config: cfg, } s.presignedClient = s3.NewPresignClient(s.client) return s, nil } func (s *s3Storage) PutNewObject(ctx context.Context, objectId string, category Category, r io.Reader, opts ...PutOptions) error { if err := s.checkInstance(); err != nil { return fmt.Errorf("%w: %v", ErrGetPresignedLink, err) } if len(opts) > 1 { return fmt.Errorf("%w: %v", ErrPutNewObject, ErrToManyOptions) } fullKey := strings.Join([]string{string(category), objectId}, keySeparator) if len(opts) != 0 && opts[0].LocalPrefix != nil && len(opts[0].LocalPrefix) > 0 { fullKey = strings.Join([]string{string(category), strings.Join(opts[0].LocalPrefix, keySeparator), objectId}, keySeparator) } if _, err := s.exists(ctx, fullKey); err != nil { if !errors.Is(err, ErrObjectNotFound) { return fmt.Errorf("%w: %v", ErrPutNewObject, err) } } else { return fmt.Errorf("%w: %v", ErrPutNewObject, ErrObjectAlreadyExists) } putOptions := &s3.PutObjectInput{ Bucket: aws.String(s.config.Bucket), Key: aws.String(fullKey), Body: r, ChecksumAlgorithm: types.ChecksumAlgorithmSha256, } if len(opts) != 0 { putOptions.ContentType = aws.String(opts[0].ContentType) } if _, err := s.client.PutObject(ctx, putOptions); err != nil { return fmt.Errorf("%w: %v", ErrPutNewObject, err) } return nil } func (s *s3Storage) GetObject(ctx context.Context, objectId string, category Category, opts ...GetOptions) (io.ReadCloser, error) { if err := s.checkInstance(); err != nil { return nil, fmt.Errorf("%w: %v", ErrGetPresignedLink, err) } if len(opts) > 1 { return nil, fmt.Errorf("%w: %v", ErrGetObject, ErrToManyOptions) } fullKey := strings.Join([]string{string(category), objectId}, keySeparator) if len(opts) != 0 && opts[0].LocalPrefix != nil && len(opts[0].LocalPrefix) > 0 { fullKey = strings.Join([]string{string(category), strings.Join(opts[0].LocalPrefix, keySeparator), objectId}, keySeparator) } out, err := s.client.GetObject(ctx, &s3.GetObjectInput{ Bucket: aws.String(s.config.Bucket), Key: aws.String(fullKey), ChecksumMode: types.ChecksumModeEnabled, }) if err != nil { var apiErr smithy.APIError if errors.As(err, &apiErr) && apiErr.ErrorCode() == s3ErrorPatternNoSuchKey { return nil, fmt.Errorf("%w: %v", ErrGetObject, ErrObjectNotFound) } return nil, fmt.Errorf("%w: %v", ErrGetObject, err) } return out.Body, nil } func (s *s3Storage) DeleteObject(ctx context.Context, objectId string, category Category, opts ...DeleteOptions) error { if err := s.checkInstance(); err != nil { return fmt.Errorf("%w: %v", ErrGetPresignedLink, err) } if len(opts) > 1 { return fmt.Errorf("%w: %v", ErrDeleteObject, ErrToManyOptions) } fullKey := strings.Join([]string{string(category), objectId}, keySeparator) if len(opts) != 0 && opts[0].LocalPrefix != nil && len(opts[0].LocalPrefix) > 0 { fullKey = strings.Join([]string{string(category), strings.Join(opts[0].LocalPrefix, keySeparator), objectId}, keySeparator) } head, err := s.exists(ctx, fullKey) if err != nil { return err } if _, err = s.client.DeleteObject(ctx, &s3.DeleteObjectInput{ Bucket: aws.String(s.config.Bucket), Key: aws.String(fullKey), IfMatch: head.ETag, }); err != nil { var apiErr smithy.APIError if errors.As(err, &apiErr) { return fmt.Errorf("%w: %v", ErrDeleteObject, ErrObjectNotFound) } return fmt.Errorf("%w: %v", ErrDeleteObject, err) } return nil } func (s *s3Storage) UpdateObject(ctx context.Context, objectId string, category Category, r io.Reader, opts ...UpdateOptions) error { if err := s.checkInstance(); err != nil { return fmt.Errorf("%w: %v", ErrGetPresignedLink, err) } if len(opts) > 1 { return fmt.Errorf("%w: %v", ErrUpdateObject, ErrToManyOptions) } fullKey := strings.Join([]string{string(category), objectId}, keySeparator) if len(opts) != 0 && opts[0].LocalPrefix != nil && len(opts[0].LocalPrefix) > 0 { fullKey = strings.Join([]string{string(category), strings.Join(opts[0].LocalPrefix, keySeparator), objectId}, keySeparator) } head, err := s.exists(ctx, fullKey) if err != nil { return err } input := &s3.PutObjectInput{ Bucket: aws.String(s.config.Bucket), Key: aws.String(fullKey), Body: r, IfMatch: head.ETag, } if len(opts) != 0 && opts[0].ContentType != "" { input.ContentType = aws.String(opts[0].ContentType) } if _, err = s.client.PutObject(ctx, input); err != nil { return fmt.Errorf("%w: %v", ErrUpdateObject, err) } return nil } func (s *s3Storage) GetPresignedLink(ctx context.Context, objectId string, category Category, opts ...LinkOptions) (string, error) { if err := s.checkInstance(); err != nil { return "", fmt.Errorf("%w: %v", ErrGetPresignedLink, err) } if len(opts) > 1 { return "", fmt.Errorf("%w: %v", ErrGetPresignedLink, ErrToManyOptions) } fullKey := strings.Join([]string{string(category), objectId}, keySeparator) if len(opts) != 0 && opts[0].LocalPrefix != nil && len(opts[0].LocalPrefix) > 0 { fullKey = strings.Join([]string{string(category), strings.Join(opts[0].LocalPrefix, keySeparator), objectId}, keySeparator) } if _, err := s.exists(ctx, fullKey); err != nil { return "", err } duration := s.config.DefaultLinkTTL if len(opts) != 0 && opts[0].TTL != nil { duration = *opts[0].TTL } presigned, err := s.presignedClient.PresignGetObject(ctx, &s3.GetObjectInput{ Bucket: aws.String(s.config.Bucket), Key: aws.String(fullKey), }, s3.WithPresignExpires(duration)) if err != nil { return "", fmt.Errorf("%w: %v", ErrGetPresignedLink, err) } return presigned.URL, nil } func (s *s3Storage) checkInstance() error { if s == nil || s.client == nil { return ErrInvalidInstance } return nil } func (s *s3Storage) exists(ctx context.Context, fullKey string) (*s3.HeadObjectOutput, error) { head, err := s.client.HeadObject(ctx, &s3.HeadObjectInput{ Bucket: aws.String(s.config.Bucket), Key: aws.String(fullKey), }) if err != nil { var apiErr smithy.APIError if errors.As(err, &apiErr) && apiErr.ErrorCode() == s3ErrorPatternNotFound { return nil, ErrObjectNotFound } return nil, fmt.Errorf("%w: %v", ErrCheckExists, err) } return head, nil }