package service import ( "context" "errors" "fmt" "io" "strings" "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" ) type DocumentStorage interface { Upload(ctx context.Context, key string, body io.Reader, size int64, contentType string) (DocumentStorageUploadResult, error) Delete(ctx context.Context, key string) error URL(key string) string } type DocumentStorageUploadResult struct { Key string URL string ETag string } type S3DocumentStorageConfig struct { Region string Bucket string AccessKey string SecretKey string Endpoint string BaseURL string ForcePathStyle bool } type s3DocumentStorage struct { client *s3.Client bucket string base string } func NewS3DocumentStorage(ctx context.Context, cfg S3DocumentStorageConfig) (DocumentStorage, error) { bucket := strings.TrimSpace(cfg.Bucket) if bucket == "" { return nil, errors.New("s3 bucket is required") } region := strings.TrimSpace(cfg.Region) if region == "" { region = "us-east-1" } options := []func(*awsconfig.LoadOptions) error{ awsconfig.WithRegion(region), } endpoint := strings.TrimSpace(cfg.Endpoint) if endpoint != "" { resolver := aws.EndpointResolverWithOptionsFunc(func(service, region string, _ ...interface{}) (aws.Endpoint, error) { if service == s3.ServiceID { return aws.Endpoint{ URL: endpoint, SigningRegion: region, HostnameImmutable: true, }, nil } return aws.Endpoint{}, &aws.EndpointNotFoundError{} }) options = append(options, awsconfig.WithEndpointResolverWithOptions(resolver)) } accessKey := strings.TrimSpace(cfg.AccessKey) secretKey := strings.TrimSpace(cfg.SecretKey) if accessKey != "" && secretKey != "" { options = append(options, awsconfig.WithCredentialsProvider( credentials.NewStaticCredentialsProvider(accessKey, secretKey, ""), )) } awsCfg, err := awsconfig.LoadDefaultConfig(ctx, options...) if err != nil { return nil, err } client := s3.NewFromConfig(awsCfg, func(o *s3.Options) { o.UsePathStyle = cfg.ForcePathStyle }) baseURL := strings.TrimSuffix(strings.TrimSpace(cfg.BaseURL), "/") if baseURL == "" { if endpoint != "" { baseURL = fmt.Sprintf("%s/%s", strings.TrimSuffix(endpoint, "/"), bucket) } else { baseURL = fmt.Sprintf("https://%s.s3.%s.amazonaws.com", bucket, region) } } return &s3DocumentStorage{ client: client, bucket: bucket, base: baseURL, }, nil } func (s *s3DocumentStorage) Upload(ctx context.Context, key string, body io.Reader, size int64, contentType string) (DocumentStorageUploadResult, error) { if strings.TrimSpace(key) == "" { return DocumentStorageUploadResult{}, errors.New("storage key is required") } if size < 0 { size = 0 } input := &s3.PutObjectInput{ Bucket: aws.String(s.bucket), Key: aws.String(key), Body: body, } input.ContentLength = aws.Int64(size) if ct := strings.TrimSpace(contentType); ct != "" { input.ContentType = aws.String(ct) } out, err := s.client.PutObject(ctx, input) if err != nil { return DocumentStorageUploadResult{}, err } var etag string if out.ETag != nil { etag = strings.Trim(*out.ETag, "\"") } return DocumentStorageUploadResult{ Key: key, URL: s.URL(key), ETag: etag, }, nil } func (s *s3DocumentStorage) Delete(ctx context.Context, key string) error { if strings.TrimSpace(key) == "" { return nil } _, err := s.client.DeleteObject(ctx, &s3.DeleteObjectInput{ Bucket: aws.String(s.bucket), Key: aws.String(key), }) return err } func (s *s3DocumentStorage) URL(key string) string { key = strings.TrimPrefix(strings.TrimSpace(key), "/") if key == "" { return s.base } if s.base == "" { return key } return fmt.Sprintf("%s/%s", s.base, key) }