feat: add direct s3 upload flow

This commit is contained in:
2026-05-13 11:24:13 -07:00
parent f6911965dc
commit 4fa9fcd232
16 changed files with 773 additions and 106 deletions
+128
View File
@@ -0,0 +1,128 @@
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
}