package storage import ( "context" "fmt" "net/url" "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/perfect-panel/server/internal/config" ) type PresignUploadResult struct { URL string Method string ExpiresAt int64 } type ObjectMeta struct { ETag string ContentType string ContentLength int64 } type S3Store struct { cfg config.S3Config client *s3.Client presign *s3.PresignClient } func NewS3Store(ctx context.Context, cfg config.S3Config) (*S3Store, error) { loaders := make([]func(*awsconfig.LoadOptions) error, 0, 3) if cfg.Region != "" { loaders = append(loaders, awsconfig.WithRegion(cfg.Region)) } if cfg.AccessKey != "" || cfg.SecretKey != "" || cfg.SessionToken != "" { loaders = append(loaders, awsconfig.WithCredentialsProvider( credentials.NewStaticCredentialsProvider(cfg.AccessKey, cfg.SecretKey, cfg.SessionToken), )) } awsCfg, err := awsconfig.LoadDefaultConfig(ctx, loaders...) if err != nil { return nil, err } client := s3.NewFromConfig(awsCfg, func(o *s3.Options) { o.UsePathStyle = cfg.UsePathStyle if cfg.Endpoint != "" { o.BaseEndpoint = aws.String(cfg.Endpoint) } }) return &S3Store{ cfg: cfg, client: client, presign: s3.NewPresignClient(client), }, nil } func (s *S3Store) PresignPutObject(ctx context.Context, objectKey string, contentType string, expires time.Duration) (*PresignUploadResult, error) { req, err := s.presign.PresignPutObject(ctx, &s3.PutObjectInput{ Bucket: aws.String(s.cfg.Bucket), Key: aws.String(objectKey), ContentType: aws.String(contentType), }, s3.WithPresignExpires(expires)) if err != nil { return nil, err } return &PresignUploadResult{ URL: req.URL, Method: req.Method, ExpiresAt: time.Now().Add(expires).Unix(), }, nil } func (s *S3Store) HeadObject(ctx context.Context, objectKey string) (*ObjectMeta, error) { resp, err := s.client.HeadObject(ctx, &s3.HeadObjectInput{ Bucket: aws.String(s.cfg.Bucket), Key: aws.String(objectKey), }) if err != nil { return nil, err } return &ObjectMeta{ ETag: strings.Trim(aws.ToString(resp.ETag), "\""), ContentType: aws.ToString(resp.ContentType), ContentLength: aws.ToInt64(resp.ContentLength), }, nil } func (s *S3Store) BuildObjectURL(objectKey string) string { if s.cfg.PublicBaseURL != "" { return strings.TrimRight(s.cfg.PublicBaseURL, "/") + "/" + strings.TrimLeft(objectKey, "/") } if s.cfg.Endpoint != "" { base := strings.TrimRight(s.cfg.Endpoint, "/") return fmt.Sprintf("%s/%s/%s", base, url.PathEscape(s.cfg.Bucket), strings.TrimLeft(objectKey, "/")) } return fmt.Sprintf("https://%s.s3.%s.amazonaws.com/%s", s.cfg.Bucket, s.cfg.Region, strings.TrimLeft(objectKey, "/")) } func (s *S3Store) PutJSONTagging(ctx context.Context, objectKey string, values map[string]string) error { tagging := make([]types.Tag, 0, len(values)) for k, v := range values { key := k value := v tagging = append(tagging, types.Tag{Key: &key, Value: &value}) } _, err := s.client.PutObjectTagging(ctx, &s3.PutObjectTaggingInput{ Bucket: aws.String(s.cfg.Bucket), Key: aws.String(objectKey), Tagging: &types.Tagging{ TagSet: tagging, }, }) return err }