431 lines
13 KiB
Go
431 lines
13 KiB
Go
package s3
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"time"
|
|
|
|
awsv2 "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"
|
|
awss3 "github.com/aws/aws-sdk-go-v2/service/s3"
|
|
"github.com/aws/aws-sdk-go-v2/service/s3/types"
|
|
"github.com/aws/smithy-go"
|
|
|
|
"wucher/internal/config"
|
|
filemanager "wucher/internal/domain/file_manager"
|
|
"wucher/internal/shared/pkg/metrics"
|
|
)
|
|
|
|
type FileObjectStorage struct {
|
|
cfg config.FileStorageConfig
|
|
client *awss3.Client
|
|
presigner *awss3.PresignClient
|
|
component string
|
|
}
|
|
|
|
func NewFileObjectStorage(ctx context.Context, cfg config.FileStorageConfig) (*FileObjectStorage, error) {
|
|
if err := validateConfig(cfg); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
loadOptions := make([]func(*awsconfig.LoadOptions) error, 0, 3)
|
|
if cfg.Region != "" {
|
|
loadOptions = append(loadOptions, awsconfig.WithRegion(cfg.Region))
|
|
}
|
|
if strings.TrimSpace(cfg.Endpoint) != "" &&
|
|
strings.TrimSpace(cfg.AccessKeyID) != "" &&
|
|
strings.TrimSpace(cfg.SecretAccessKey) != "" {
|
|
loadOptions = append(loadOptions, awsconfig.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(
|
|
strings.TrimSpace(cfg.AccessKeyID),
|
|
strings.TrimSpace(cfg.SecretAccessKey),
|
|
"",
|
|
)))
|
|
}
|
|
awsCfg, err := awsconfig.LoadDefaultConfig(ctx, loadOptions...)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
client := awss3.NewFromConfig(awsCfg, func(options *awss3.Options) {
|
|
options.UsePathStyle = cfg.ForcePathStyle
|
|
if endpoint := strings.TrimSpace(cfg.Endpoint); endpoint != "" {
|
|
options.BaseEndpoint = awsv2.String(endpoint)
|
|
}
|
|
})
|
|
|
|
storage := &FileObjectStorage{
|
|
cfg: cfg,
|
|
client: client,
|
|
presigner: awss3.NewPresignClient(client),
|
|
component: "api",
|
|
}
|
|
|
|
if cfg.AutoCreateBucket {
|
|
if err := storage.EnsureBucket(ctx); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
return storage, nil
|
|
}
|
|
|
|
func (s *FileObjectStorage) EnsureBucket(ctx context.Context) error {
|
|
headStarted := time.Now()
|
|
if _, err := s.client.HeadBucket(ctx, &awss3.HeadBucketInput{
|
|
Bucket: awsv2.String(s.cfg.Bucket),
|
|
}); err == nil {
|
|
metrics.ObserveAWSRequest(s.component, "s3", "HeadBucket", nil, headStarted)
|
|
return nil
|
|
} else {
|
|
metrics.ObserveAWSRequest(s.component, "s3", "HeadBucket", err, headStarted)
|
|
}
|
|
|
|
input := &awss3.CreateBucketInput{
|
|
Bucket: awsv2.String(s.cfg.Bucket),
|
|
}
|
|
if s.cfg.Region != "" && s.cfg.Region != "us-east-1" {
|
|
input.CreateBucketConfiguration = &types.CreateBucketConfiguration{
|
|
LocationConstraint: types.BucketLocationConstraint(s.cfg.Region),
|
|
}
|
|
}
|
|
|
|
createStarted := time.Now()
|
|
_, err := s.client.CreateBucket(ctx, input)
|
|
metrics.ObserveAWSRequest(s.component, "s3", "CreateBucket", err, createStarted)
|
|
if err == nil {
|
|
return nil
|
|
}
|
|
if isBucketAlreadyOwnedByYou(err) {
|
|
return nil
|
|
}
|
|
return err
|
|
}
|
|
|
|
func (s *FileObjectStorage) PutObject(ctx context.Context, input filemanager.PutObjectInput) (filemanager.ObjectMetadata, error) {
|
|
objectKey := buildObjectKey(s.cfg.ObjectKeyPrefix, input.ObjectKey)
|
|
|
|
started := time.Now()
|
|
output, err := s.client.PutObject(ctx, &awss3.PutObjectInput{
|
|
Bucket: awsv2.String(s.cfg.Bucket),
|
|
Key: awsv2.String(objectKey),
|
|
Body: input.Body,
|
|
ContentType: optionalString(input.ContentType),
|
|
Metadata: input.Metadata,
|
|
})
|
|
metrics.ObserveAWSRequest(s.component, "s3", "PutObject", err, started)
|
|
metrics.ObserveFileUpload(s.component, err)
|
|
if err == nil {
|
|
metrics.ObserveAWSTransferredBytes(s.component, "s3", "PutObject", "upload", input.SizeBytes)
|
|
}
|
|
if err != nil {
|
|
return filemanager.ObjectMetadata{}, err
|
|
}
|
|
|
|
return filemanager.ObjectMetadata{
|
|
Bucket: s.cfg.Bucket,
|
|
ObjectKey: objectKey,
|
|
ETag: awsv2.ToString(output.ETag),
|
|
VersionID: awsv2.ToString(output.VersionId),
|
|
}, nil
|
|
}
|
|
|
|
func (s *FileObjectStorage) CopyObject(ctx context.Context, sourceObjectKey, destinationObjectKey string) (filemanager.ObjectMetadata, error) {
|
|
sourceKey := buildObjectKey(s.cfg.ObjectKeyPrefix, sourceObjectKey)
|
|
destinationKey := buildObjectKey(s.cfg.ObjectKeyPrefix, destinationObjectKey)
|
|
|
|
started := time.Now()
|
|
_, err := s.client.CopyObject(ctx, &awss3.CopyObjectInput{
|
|
Bucket: awsv2.String(s.cfg.Bucket),
|
|
CopySource: awsv2.String(url.PathEscape(s.cfg.Bucket + "/" + sourceKey)),
|
|
Key: awsv2.String(destinationKey),
|
|
MetadataDirective: types.MetadataDirectiveCopy,
|
|
})
|
|
metrics.ObserveAWSRequest(s.component, "s3", "CopyObject", err, started)
|
|
if err != nil {
|
|
return filemanager.ObjectMetadata{}, err
|
|
}
|
|
return s.HeadObject(ctx, destinationObjectKey)
|
|
}
|
|
|
|
func (s *FileObjectStorage) DeleteObject(ctx context.Context, objectKey string) error {
|
|
started := time.Now()
|
|
_, err := s.client.DeleteObject(ctx, &awss3.DeleteObjectInput{
|
|
Bucket: awsv2.String(s.cfg.Bucket),
|
|
Key: awsv2.String(buildObjectKey(s.cfg.ObjectKeyPrefix, objectKey)),
|
|
})
|
|
metrics.ObserveAWSRequest(s.component, "s3", "DeleteObject", err, started)
|
|
metrics.ObserveFileDelete(s.component, err)
|
|
return err
|
|
}
|
|
|
|
func (s *FileObjectStorage) PutObjectTagging(ctx context.Context, objectKey string, tags map[string]string) error {
|
|
if len(tags) == 0 {
|
|
return nil
|
|
}
|
|
key := buildObjectKey(s.cfg.ObjectKeyPrefix, objectKey)
|
|
tagSet := make([]types.Tag, 0, len(tags))
|
|
for k, v := range tags {
|
|
keyTag := strings.TrimSpace(k)
|
|
valueTag := strings.TrimSpace(v)
|
|
if keyTag == "" || valueTag == "" {
|
|
continue
|
|
}
|
|
tagSet = append(tagSet, types.Tag{
|
|
Key: awsv2.String(keyTag),
|
|
Value: awsv2.String(valueTag),
|
|
})
|
|
}
|
|
if len(tagSet) == 0 {
|
|
return nil
|
|
}
|
|
|
|
started := time.Now()
|
|
_, err := s.client.PutObjectTagging(ctx, &awss3.PutObjectTaggingInput{
|
|
Bucket: awsv2.String(s.cfg.Bucket),
|
|
Key: awsv2.String(key),
|
|
Tagging: &types.Tagging{TagSet: tagSet},
|
|
})
|
|
metrics.ObserveAWSRequest(s.component, "s3", "PutObjectTagging", err, started)
|
|
return err
|
|
}
|
|
|
|
func (s *FileObjectStorage) DeleteObjectTaggingKeys(ctx context.Context, objectKey string, keys []string) error {
|
|
if len(keys) == 0 {
|
|
return nil
|
|
}
|
|
key := buildObjectKey(s.cfg.ObjectKeyPrefix, objectKey)
|
|
|
|
started := time.Now()
|
|
existing, err := s.client.GetObjectTagging(ctx, &awss3.GetObjectTaggingInput{
|
|
Bucket: awsv2.String(s.cfg.Bucket),
|
|
Key: awsv2.String(key),
|
|
})
|
|
metrics.ObserveAWSRequest(s.component, "s3", "GetObjectTagging", err, started)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
removed := make(map[string]struct{}, len(keys))
|
|
for i := range keys {
|
|
k := strings.TrimSpace(keys[i])
|
|
if k == "" {
|
|
continue
|
|
}
|
|
removed[k] = struct{}{}
|
|
}
|
|
tagSet := make([]types.Tag, 0, len(existing.TagSet))
|
|
for i := range existing.TagSet {
|
|
tagKey := strings.TrimSpace(awsv2.ToString(existing.TagSet[i].Key))
|
|
if _, drop := removed[tagKey]; drop {
|
|
continue
|
|
}
|
|
tagSet = append(tagSet, existing.TagSet[i])
|
|
}
|
|
|
|
started = time.Now()
|
|
_, err = s.client.PutObjectTagging(ctx, &awss3.PutObjectTaggingInput{
|
|
Bucket: awsv2.String(s.cfg.Bucket),
|
|
Key: awsv2.String(key),
|
|
Tagging: &types.Tagging{TagSet: tagSet},
|
|
})
|
|
metrics.ObserveAWSRequest(s.component, "s3", "PutObjectTagging", err, started)
|
|
return err
|
|
}
|
|
|
|
func (s *FileObjectStorage) HeadObject(ctx context.Context, objectKey string) (filemanager.ObjectMetadata, error) {
|
|
key := buildObjectKey(s.cfg.ObjectKeyPrefix, objectKey)
|
|
started := time.Now()
|
|
output, err := s.client.HeadObject(ctx, &awss3.HeadObjectInput{
|
|
Bucket: awsv2.String(s.cfg.Bucket),
|
|
Key: awsv2.String(key),
|
|
})
|
|
metrics.ObserveAWSRequest(s.component, "s3", "HeadObject", err, started)
|
|
metrics.ObserveFileHead(s.component, err)
|
|
if err != nil {
|
|
if isObjectNotFoundError(err) {
|
|
return filemanager.ObjectMetadata{}, filemanager.ErrUploadObjectNotFound
|
|
}
|
|
return filemanager.ObjectMetadata{}, err
|
|
}
|
|
|
|
return filemanager.ObjectMetadata{
|
|
Bucket: s.cfg.Bucket,
|
|
ObjectKey: key,
|
|
ETag: awsv2.ToString(output.ETag),
|
|
VersionID: awsv2.ToString(output.VersionId),
|
|
SizeBytes: awsv2.ToInt64(output.ContentLength),
|
|
LastModified: output.LastModified,
|
|
ContentType: awsv2.ToString(output.ContentType),
|
|
}, nil
|
|
}
|
|
|
|
func (s *FileObjectStorage) GetObject(ctx context.Context, objectKey string) (io.ReadCloser, filemanager.ObjectMetadata, error) {
|
|
key := buildObjectKey(s.cfg.ObjectKeyPrefix, objectKey)
|
|
started := time.Now()
|
|
output, err := s.client.GetObject(ctx, &awss3.GetObjectInput{
|
|
Bucket: awsv2.String(s.cfg.Bucket),
|
|
Key: awsv2.String(key),
|
|
})
|
|
metrics.ObserveAWSRequest(s.component, "s3", "GetObject", err, started)
|
|
if err != nil {
|
|
if isObjectNotFoundError(err) {
|
|
return nil, filemanager.ObjectMetadata{}, filemanager.ErrUploadObjectNotFound
|
|
}
|
|
return nil, filemanager.ObjectMetadata{}, err
|
|
}
|
|
|
|
metrics.ObserveAWSTransferredBytes(s.component, "s3", "GetObject", "download", awsv2.ToInt64(output.ContentLength))
|
|
return output.Body, filemanager.ObjectMetadata{
|
|
Bucket: s.cfg.Bucket,
|
|
ObjectKey: key,
|
|
ETag: awsv2.ToString(output.ETag),
|
|
VersionID: awsv2.ToString(output.VersionId),
|
|
SizeBytes: awsv2.ToInt64(output.ContentLength),
|
|
LastModified: output.LastModified,
|
|
ContentType: awsv2.ToString(output.ContentType),
|
|
}, nil
|
|
}
|
|
|
|
func (s *FileObjectStorage) PresignPutObject(ctx context.Context, input filemanager.PresignPutObjectInput) (filemanager.PresignedPutObject, error) {
|
|
key := buildObjectKey(s.cfg.ObjectKeyPrefix, input.ObjectKey)
|
|
ttl := input.TTL
|
|
if ttl <= 0 {
|
|
ttl = s.cfg.PresignPutTTL
|
|
}
|
|
if ttl <= 0 {
|
|
ttl = 15 * time.Minute
|
|
}
|
|
|
|
putInput := &awss3.PutObjectInput{
|
|
Bucket: awsv2.String(s.cfg.Bucket),
|
|
Key: awsv2.String(key),
|
|
ContentType: optionalString(input.ContentType),
|
|
Metadata: input.Metadata,
|
|
}
|
|
|
|
output, err := s.presigner.PresignPutObject(ctx, putInput, func(options *awss3.PresignOptions) {
|
|
options.Expires = ttl
|
|
})
|
|
if err != nil {
|
|
return filemanager.PresignedPutObject{}, err
|
|
}
|
|
|
|
return filemanager.PresignedPutObject{
|
|
Bucket: s.cfg.Bucket,
|
|
ObjectKey: key,
|
|
Method: output.Method,
|
|
URL: output.URL,
|
|
Headers: canonicalSignedHeaders(output.SignedHeader),
|
|
ExpiresAt: time.Now().UTC().Add(ttl),
|
|
}, nil
|
|
}
|
|
|
|
func (s *FileObjectStorage) PresignGetObject(ctx context.Context, objectKey string, ttl time.Duration) (string, error) {
|
|
if ttl <= 0 {
|
|
ttl = s.cfg.PresignGetTTL
|
|
}
|
|
|
|
started := time.Now()
|
|
output, err := s.presigner.PresignGetObject(ctx, &awss3.GetObjectInput{
|
|
Bucket: awsv2.String(s.cfg.Bucket),
|
|
Key: awsv2.String(buildObjectKey(s.cfg.ObjectKeyPrefix, objectKey)),
|
|
}, func(options *awss3.PresignOptions) {
|
|
options.Expires = ttl
|
|
})
|
|
metrics.ObserveAWSRequest(s.component, "s3", "GetObject", err, started)
|
|
metrics.ObserveFilePresignDownload(s.component, err)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return output.URL, nil
|
|
}
|
|
|
|
func validateConfig(cfg config.FileStorageConfig) error {
|
|
if strings.ToLower(strings.TrimSpace(cfg.Provider)) != "s3" {
|
|
return errors.New("unsupported file storage provider")
|
|
}
|
|
if strings.TrimSpace(cfg.Bucket) == "" {
|
|
return errors.New("file storage bucket is required")
|
|
}
|
|
if strings.TrimSpace(cfg.Region) == "" {
|
|
return errors.New("file storage region is required")
|
|
}
|
|
if endpoint := strings.TrimSpace(cfg.Endpoint); endpoint != "" {
|
|
parsed, err := url.Parse(endpoint)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if parsed.Scheme != "https" && !cfg.AllowInsecureEndpoint {
|
|
return errors.New("insecure file storage endpoint is disabled")
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func buildObjectKey(prefix, objectKey string) string {
|
|
objectKey = strings.TrimSpace(objectKey)
|
|
objectKey = strings.TrimPrefix(objectKey, "/")
|
|
objectKey = strings.TrimPrefix(objectKey, "./")
|
|
if objectKey == "" {
|
|
return objectKey
|
|
}
|
|
|
|
prefix = strings.TrimSpace(prefix)
|
|
prefix = strings.Trim(prefix, "/")
|
|
if prefix == "" {
|
|
return objectKey
|
|
}
|
|
if objectKey == prefix || strings.HasPrefix(objectKey, prefix+"/") {
|
|
return objectKey
|
|
}
|
|
return prefix + "/" + objectKey
|
|
}
|
|
|
|
func optionalString(value string) *string {
|
|
value = strings.TrimSpace(value)
|
|
if value == "" {
|
|
return nil
|
|
}
|
|
return awsv2.String(value)
|
|
}
|
|
|
|
func isBucketAlreadyOwnedByYou(err error) bool {
|
|
var apiErr smithy.APIError
|
|
if !errors.As(err, &apiErr) {
|
|
return false
|
|
}
|
|
return apiErr.ErrorCode() == "BucketAlreadyOwnedByYou" || apiErr.ErrorCode() == "BucketAlreadyExists"
|
|
}
|
|
|
|
func isObjectNotFoundError(err error) bool {
|
|
var apiErr smithy.APIError
|
|
if !errors.As(err, &apiErr) {
|
|
return false
|
|
}
|
|
switch strings.TrimSpace(apiErr.ErrorCode()) {
|
|
case "NotFound", "NoSuchKey", "404":
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func canonicalSignedHeaders(header http.Header) map[string]string {
|
|
if len(header) == 0 {
|
|
return nil
|
|
}
|
|
out := make(map[string]string, len(header))
|
|
for key, values := range header {
|
|
k := strings.TrimSpace(strings.ToLower(key))
|
|
if k == "" || len(values) == 0 {
|
|
continue
|
|
}
|
|
out[k] = strings.TrimSpace(values[0])
|
|
}
|
|
return out
|
|
}
|