174 lines
5.0 KiB
Go
174 lines
5.0 KiB
Go
|
|
package storage
|
||
|
|
|
||
|
|
import (
|
||
|
|
"context"
|
||
|
|
"fmt"
|
||
|
|
"io"
|
||
|
|
"mime"
|
||
|
|
"net/url"
|
||
|
|
"path"
|
||
|
|
"strings"
|
||
|
|
|
||
|
|
"haixun-backend/internal/config"
|
||
|
|
|
||
|
|
"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 Uploader interface {
|
||
|
|
Upload(ctx context.Context, key, contentType string, body io.Reader) (string, error)
|
||
|
|
}
|
||
|
|
|
||
|
|
// PublicURLResolver turns stored object keys into externally reachable URLs (Threads image publish).
|
||
|
|
type PublicURLResolver interface {
|
||
|
|
ResolvePublicURL(key string) (string, error)
|
||
|
|
}
|
||
|
|
|
||
|
|
// ObjectDeleter removes stored objects by key.
|
||
|
|
type ObjectDeleter interface {
|
||
|
|
DeleteObject(ctx context.Context, key string) error
|
||
|
|
}
|
||
|
|
|
||
|
|
// ObjectReader fetches stored objects by key.
|
||
|
|
type ObjectReader interface {
|
||
|
|
GetObject(ctx context.Context, key string) (io.ReadCloser, string, error)
|
||
|
|
}
|
||
|
|
|
||
|
|
// PublishAttachmentStore resolves public URLs and deletes ephemeral publish attachments.
|
||
|
|
type PublishAttachmentStore interface {
|
||
|
|
PublicURLResolver
|
||
|
|
ObjectDeleter
|
||
|
|
}
|
||
|
|
|
||
|
|
type S3Uploader struct {
|
||
|
|
client *s3.Client
|
||
|
|
bucket string
|
||
|
|
publicBaseURL string
|
||
|
|
bucketReady bool
|
||
|
|
}
|
||
|
|
|
||
|
|
func NewS3Uploader(ctx context.Context, cfg config.S3Conf) (*S3Uploader, error) {
|
||
|
|
if strings.TrimSpace(cfg.Bucket) == "" {
|
||
|
|
return nil, nil
|
||
|
|
}
|
||
|
|
awsCfg, err := awsconfig.LoadDefaultConfig(ctx,
|
||
|
|
awsconfig.WithRegion(cfg.Region),
|
||
|
|
awsconfig.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(cfg.AccessKey, cfg.SecretKey, "")),
|
||
|
|
)
|
||
|
|
if err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
client := s3.NewFromConfig(awsCfg, func(o *s3.Options) {
|
||
|
|
if cfg.Endpoint != "" {
|
||
|
|
o.BaseEndpoint = aws.String(cfg.Endpoint)
|
||
|
|
}
|
||
|
|
o.UsePathStyle = cfg.UsePathStyle
|
||
|
|
})
|
||
|
|
return &S3Uploader{client: client, bucket: cfg.Bucket, publicBaseURL: strings.TrimRight(cfg.PublicBaseURL, "/")}, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func (u *S3Uploader) Upload(ctx context.Context, key, contentType string, body io.Reader) (string, error) {
|
||
|
|
if u == nil || u.client == nil {
|
||
|
|
return "", fmt.Errorf("s3 uploader is not configured")
|
||
|
|
}
|
||
|
|
if !u.bucketReady {
|
||
|
|
if _, err := u.client.HeadBucket(ctx, &s3.HeadBucketInput{Bucket: aws.String(u.bucket)}); err != nil {
|
||
|
|
if _, createErr := u.client.CreateBucket(ctx, &s3.CreateBucketInput{Bucket: aws.String(u.bucket)}); createErr != nil {
|
||
|
|
return "", createErr
|
||
|
|
}
|
||
|
|
}
|
||
|
|
policy := fmt.Sprintf(`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":["*"]},"Action":["s3:GetObject"],"Resource":["arn:aws:s3:::%s/*"]}]}`, u.bucket)
|
||
|
|
_, _ = u.client.PutBucketPolicy(ctx, &s3.PutBucketPolicyInput{Bucket: aws.String(u.bucket), Policy: aws.String(policy)})
|
||
|
|
u.bucketReady = true
|
||
|
|
}
|
||
|
|
key = strings.TrimLeft(path.Clean("/"+key), "/")
|
||
|
|
if contentType == "" {
|
||
|
|
contentType = mime.TypeByExtension(path.Ext(key))
|
||
|
|
}
|
||
|
|
if contentType == "" {
|
||
|
|
contentType = "application/octet-stream"
|
||
|
|
}
|
||
|
|
_, err := u.client.PutObject(ctx, &s3.PutObjectInput{
|
||
|
|
Bucket: aws.String(u.bucket),
|
||
|
|
Key: aws.String(key),
|
||
|
|
Body: body,
|
||
|
|
ContentType: aws.String(contentType),
|
||
|
|
})
|
||
|
|
if err != nil {
|
||
|
|
return "", err
|
||
|
|
}
|
||
|
|
return key, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func (u *S3Uploader) ResolvePublicURL(key string) (string, error) {
|
||
|
|
if u == nil {
|
||
|
|
return "", fmt.Errorf("s3 uploader is not configured")
|
||
|
|
}
|
||
|
|
key = strings.TrimSpace(key)
|
||
|
|
if key == "" {
|
||
|
|
return "", fmt.Errorf("asset key is required")
|
||
|
|
}
|
||
|
|
key = NormalizeObjectKey(u.bucket, key)
|
||
|
|
base := strings.TrimRight(strings.TrimSpace(u.publicBaseURL), "/")
|
||
|
|
if base == "" {
|
||
|
|
return "", fmt.Errorf("HAIXUN_STORAGE_S3_PUBLIC_BASE_URL is required for Threads image publish")
|
||
|
|
}
|
||
|
|
if strings.HasSuffix(base, "/public/assets") {
|
||
|
|
return base + "?key=" + url.QueryEscape(key), nil
|
||
|
|
}
|
||
|
|
if strings.HasSuffix(base, "/"+u.bucket) {
|
||
|
|
return base + "/" + key, nil
|
||
|
|
}
|
||
|
|
return base + "/" + key, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func (u *S3Uploader) GetObject(ctx context.Context, key string) (io.ReadCloser, string, error) {
|
||
|
|
if u == nil || u.client == nil {
|
||
|
|
return nil, "", fmt.Errorf("s3 uploader is not configured")
|
||
|
|
}
|
||
|
|
key = strings.TrimSpace(key)
|
||
|
|
if key == "" {
|
||
|
|
return nil, "", fmt.Errorf("asset key is required")
|
||
|
|
}
|
||
|
|
if !IsPublicAssetKey(key) {
|
||
|
|
return nil, "", fmt.Errorf("asset key is not public")
|
||
|
|
}
|
||
|
|
key = NormalizeObjectKey(u.bucket, key)
|
||
|
|
out, err := u.client.GetObject(ctx, &s3.GetObjectInput{
|
||
|
|
Bucket: aws.String(u.bucket),
|
||
|
|
Key: aws.String(key),
|
||
|
|
})
|
||
|
|
if err != nil {
|
||
|
|
return nil, "", err
|
||
|
|
}
|
||
|
|
contentType := ""
|
||
|
|
if out.ContentType != nil {
|
||
|
|
contentType = strings.TrimSpace(*out.ContentType)
|
||
|
|
}
|
||
|
|
if contentType == "" {
|
||
|
|
contentType = mime.TypeByExtension(path.Ext(key))
|
||
|
|
}
|
||
|
|
if contentType == "" {
|
||
|
|
contentType = "application/octet-stream"
|
||
|
|
}
|
||
|
|
return out.Body, contentType, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func (u *S3Uploader) DeleteObject(ctx context.Context, key string) error {
|
||
|
|
if u == nil || u.client == nil {
|
||
|
|
return fmt.Errorf("s3 uploader is not configured")
|
||
|
|
}
|
||
|
|
key = strings.TrimSpace(key)
|
||
|
|
if key == "" {
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
key = NormalizeObjectKey(u.bucket, key)
|
||
|
|
_, err := u.client.DeleteObject(ctx, &s3.DeleteObjectInput{
|
||
|
|
Bucket: aws.String(u.bucket),
|
||
|
|
Key: aws.String(key),
|
||
|
|
})
|
||
|
|
return err
|
||
|
|
}
|