可靠性: 完善 R2 备份恢复与对象生命周期
This commit is contained in:
@@ -0,0 +1,112 @@
|
||||
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}
|
||||
}
|
||||
Reference in New Issue
Block a user