Files

113 lines
3.6 KiB
Go

package storage
import (
"context"
"fmt"
"net"
"net/http"
"net/url"
"strings"
"time"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/credentials"
"github.com/aws/aws-sdk-go-v2/service/s3"
)
const (
s3DialTimeout = 10 * time.Second
s3TLSHandshakeTimeout = 10 * time.Second
s3ResponseHeaderTimeout = 30 * time.Second
)
// NewS3Client 构造用于 Cloudflare R2 的 S3 客户端。
// endpoint 可以不带路径,也可以带且仅带 /<bucket>;后者会被剥离,
// 再由 path-style 请求恰好添加一次 bucket,避免出现 /bucket/bucket/key。
func NewS3Client(ctx context.Context, endpoint, bucket, accessID, accessSecret string) (*s3.Client, error) {
if err := ctx.Err(); err != nil {
return nil, err
}
normalizedEndpoint, err := normalizeS3Endpoint(endpoint, bucket)
if err != nil {
return nil, err
}
// 显式构造配置,不加载本机 AWS 配置或默认凭据链,避免离线环境意外访问
// 元数据服务,也保证这里只使用调用方提供的静态凭据。
cfg := aws.Config{
Region: "auto",
BaseEndpoint: aws.String(normalizedEndpoint),
Credentials: aws.NewCredentialsCache(credentials.NewStaticCredentialsProvider(
accessID,
accessSecret,
"",
)),
HTTPClient: newS3HTTPClient(),
Retryer: func() aws.Retryer {
return aws.NopRetryer{}
},
}
return s3.NewFromConfig(cfg, func(options *s3.Options) {
options.UsePathStyle = true
}), nil
}
func normalizeS3Endpoint(endpoint, bucket string) (string, error) {
if bucket == "" || strings.ContainsAny(bucket, `/\`) {
return "", fmt.Errorf("invalid S3 bucket %q", bucket)
}
parsed, err := url.Parse(endpoint)
if err != nil {
return "", fmt.Errorf("parse S3 endpoint: %w", err)
}
if !parsed.IsAbs() || parsed.Host == "" || parsed.Hostname() == "" || parsed.Opaque != "" ||
(!strings.EqualFold(parsed.Scheme, "https") && !strings.EqualFold(parsed.Scheme, "http")) {
return "", fmt.Errorf("S3 endpoint must be an absolute HTTP(S) URL: %q", endpoint)
}
if parsed.User != nil {
return "", fmt.Errorf("S3 endpoint must not contain userinfo")
}
if parsed.RawQuery != "" || parsed.ForceQuery {
return "", fmt.Errorf("S3 endpoint must not contain a query")
}
if parsed.Fragment != "" || strings.Contains(endpoint, "#") {
return "", fmt.Errorf("S3 endpoint must not contain a fragment")
}
// EscapedPath 用于精确比较,拒绝用 %62ucket 等编码形式绕过路径约束。
// 空路径保持原样;唯一允许的非空路径是 /<bucket>,包括 "/" 在内的
// 其他路径均拒绝,避免静默改变 endpoint 的语义。
switch parsed.EscapedPath() {
case "":
return parsed.String(), nil
case "/" + bucket:
normalized := *parsed
normalized.Path = ""
normalized.RawPath = ""
return normalized.String(), nil
default:
return "", fmt.Errorf("S3 endpoint path must be empty or exactly /%s", bucket)
}
}
func newS3HTTPClient() *http.Client {
dialer := &net.Dialer{
Timeout: s3DialTimeout,
KeepAlive: 30 * time.Second,
}
transport := &http.Transport{
Proxy: http.ProxyFromEnvironment,
DialContext: dialer.DialContext,
ForceAttemptHTTP2: true,
MaxIdleConns: 100,
IdleConnTimeout: 90 * time.Second,
TLSHandshakeTimeout: s3TLSHandshakeTimeout,
ResponseHeaderTimeout: s3ResponseHeaderTimeout,
ExpectContinueTimeout: 1 * time.Second,
}
// 不设置 http.Client.Timeout:该超时覆盖读取/写入响应的整个生命周期,
// 会截断大对象传输。每次 PutObject/DeleteObject 尝试另有独立 60 秒上限。
return &http.Client{Transport: transport}
}