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 }