113 lines
3.6 KiB
Go
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}
|
|
}
|