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 可以不带路径,也可以带且仅带 /;后者会被剥离, // 再由 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 等编码形式绕过路径约束。 // 空路径保持原样;唯一允许的非空路径是 /,包括 "/" 在内的 // 其他路径均拒绝,避免静默改变 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} }