可靠性: 完善 R2 备份恢复与对象生命周期
This commit is contained in:
@@ -0,0 +1,128 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
// ObjectKeyFromCDNURL 将属于 cdnBase 的 HTTPS URL 转成原始 S3 对象 key。
|
||||
// cdnBase 可省略 scheme,也可带 base path。外站或不属于 base path 的 URL
|
||||
// 返回 owned=false 且不报错;格式非法或试图伪装路径的 URL 返回错误。
|
||||
func ObjectKeyFromCDNURL(rawURL, cdnBase string) (key string, owned bool, err error) {
|
||||
base, err := parseCDNBase(cdnBase)
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
parsed, err := parseCDNURL(rawURL, false)
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
if !strings.EqualFold(parsed.Host, base.Host) {
|
||||
return "", false, nil
|
||||
}
|
||||
|
||||
basePath := base.Path
|
||||
if basePath != "" {
|
||||
if parsed.Path == basePath {
|
||||
return "", false, fmt.Errorf("CDN URL does not contain an object key")
|
||||
}
|
||||
prefix := basePath + "/"
|
||||
if !strings.HasPrefix(parsed.Path, prefix) {
|
||||
return "", false, nil
|
||||
}
|
||||
key = strings.TrimPrefix(parsed.Path, prefix)
|
||||
} else {
|
||||
key = strings.TrimPrefix(parsed.Path, "/")
|
||||
}
|
||||
if key == "" {
|
||||
return "", false, fmt.Errorf("CDN URL does not contain an object key")
|
||||
}
|
||||
|
||||
// 只去掉 cdnBase 的路径和前导分隔符;不添加或移除 bucket,也不 Clean,
|
||||
// 因而返回的解码路径仍是实际 S3 key。
|
||||
return key, true, nil
|
||||
}
|
||||
|
||||
func parseCDNBase(cdnBase string) (*url.URL, error) {
|
||||
if cdnBase == "" {
|
||||
return nil, fmt.Errorf("CDN base URL is empty")
|
||||
}
|
||||
rawBase := cdnBase
|
||||
if strings.HasPrefix(rawBase, "//") {
|
||||
rawBase = "https:" + rawBase
|
||||
} else if !strings.Contains(rawBase, "://") {
|
||||
rawBase = "https://" + rawBase
|
||||
}
|
||||
return parseCDNURL(rawBase, true)
|
||||
}
|
||||
|
||||
func parseCDNURL(rawURL string, isBase bool) (*url.URL, error) {
|
||||
if rawURL == "" {
|
||||
return nil, fmt.Errorf("URL is empty")
|
||||
}
|
||||
if strings.Contains(rawURL, `\`) || containsControl(rawURL) {
|
||||
return nil, fmt.Errorf("URL contains a backslash or control character")
|
||||
}
|
||||
parsed, err := url.Parse(rawURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse URL: %w", err)
|
||||
}
|
||||
if !parsed.IsAbs() || !strings.EqualFold(parsed.Scheme, "https") || parsed.Host == "" ||
|
||||
parsed.Hostname() == "" || parsed.Opaque != "" {
|
||||
return nil, fmt.Errorf("URL must be an absolute HTTPS URL")
|
||||
}
|
||||
if parsed.User != nil {
|
||||
return nil, fmt.Errorf("URL must not contain userinfo")
|
||||
}
|
||||
if parsed.RawQuery != "" || parsed.ForceQuery {
|
||||
return nil, fmt.Errorf("URL must not contain a query")
|
||||
}
|
||||
if parsed.Fragment != "" || strings.Contains(rawURL, "#") {
|
||||
return nil, fmt.Errorf("URL must not contain a fragment")
|
||||
}
|
||||
|
||||
// 必须在 url.Parse 的 Path 解码之外检查 EscapedPath:编码斜杠在 Path 中
|
||||
// 与普通分隔符无法区分,若放行会导致 CDN 与 S3 对 key 的解释不一致。
|
||||
escapedPath := strings.ToLower(parsed.EscapedPath())
|
||||
if strings.Contains(escapedPath, "%2f") || strings.Contains(escapedPath, "%5c") {
|
||||
return nil, fmt.Errorf("URL path contains an encoded slash or backslash")
|
||||
}
|
||||
if strings.Contains(parsed.Path, `\`) || containsControl(parsed.Path) {
|
||||
return nil, fmt.Errorf("URL path contains a backslash or control character")
|
||||
}
|
||||
|
||||
if isBase && strings.HasSuffix(parsed.Path, "/") {
|
||||
// 配置中的 CDN base 允许一个末尾斜杠;其余空路径段仍会被拒绝。
|
||||
parsed.Path = strings.TrimSuffix(parsed.Path, "/")
|
||||
parsed.RawPath = ""
|
||||
}
|
||||
if parsed.Path == "" {
|
||||
if isBase {
|
||||
return parsed, nil
|
||||
}
|
||||
return nil, fmt.Errorf("URL path is empty")
|
||||
}
|
||||
if !strings.HasPrefix(parsed.Path, "/") {
|
||||
return nil, fmt.Errorf("URL path must be absolute")
|
||||
}
|
||||
for _, segment := range strings.Split(strings.TrimPrefix(parsed.Path, "/"), "/") {
|
||||
if segment == "" {
|
||||
return nil, fmt.Errorf("URL path contains an empty segment")
|
||||
}
|
||||
if segment == "." || segment == ".." {
|
||||
return nil, fmt.Errorf("URL path contains a dot segment")
|
||||
}
|
||||
}
|
||||
return parsed, nil
|
||||
}
|
||||
|
||||
func containsControl(value string) bool {
|
||||
for _, r := range value {
|
||||
if unicode.IsControl(r) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestObjectKeyFromCDNURL(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
rawURL string
|
||||
cdnBase string
|
||||
wantKey string
|
||||
owned bool
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "scheme less base", rawURL: "https://cdn.example.com/2026/11/pic.webp",
|
||||
cdnBase: "cdn.example.com", wantKey: "2026/11/pic.webp", owned: true,
|
||||
},
|
||||
{
|
||||
name: "base path", rawURL: "https://cdn.example.com/assets/2026/pic.webp",
|
||||
cdnBase: "https://cdn.example.com/assets", wantKey: "2026/pic.webp", owned: true,
|
||||
},
|
||||
{
|
||||
name: "base trailing slash and escaped space", rawURL: "https://cdn.example.com/tamabox/a%20b.webp",
|
||||
cdnBase: "cdn.example.com/tamabox/", wantKey: "a b.webp", owned: true,
|
||||
},
|
||||
{
|
||||
name: "host case insensitive", rawURL: "https://CDN.EXAMPLE.COM/a.webp",
|
||||
cdnBase: "cdn.example.com", wantKey: "a.webp", owned: true,
|
||||
},
|
||||
{
|
||||
name: "preserve bucket prefix", rawURL: "https://cdn.example.com/tamabox/2026/a.webp",
|
||||
cdnBase: "cdn.example.com", wantKey: "tamabox/2026/a.webp", owned: true,
|
||||
},
|
||||
{
|
||||
name: "external host not owned", rawURL: "https://outside.example.com/a.webp",
|
||||
cdnBase: "cdn.example.com", owned: false,
|
||||
},
|
||||
{
|
||||
name: "host suffix attack not owned", rawURL: "https://cdn.example.com.evil.test/a.webp",
|
||||
cdnBase: "cdn.example.com", owned: false,
|
||||
},
|
||||
{
|
||||
name: "base path complete segment", rawURL: "https://cdn.example.com/assets2/a.webp",
|
||||
cdnBase: "cdn.example.com/assets", owned: false,
|
||||
},
|
||||
{
|
||||
name: "base path case sensitive", rawURL: "https://cdn.example.com/Assets/a.webp",
|
||||
cdnBase: "cdn.example.com/assets", owned: false,
|
||||
},
|
||||
{name: "HTTP rejected", rawURL: "http://cdn.example.com/a", cdnBase: "cdn.example.com", wantErr: true},
|
||||
{name: "relative rejected", rawURL: "/a", cdnBase: "cdn.example.com", wantErr: true},
|
||||
{name: "scheme relative rejected", rawURL: "//cdn.example.com/a", cdnBase: "cdn.example.com", wantErr: true},
|
||||
{name: "userinfo rejected", rawURL: "https://[email protected]/a", cdnBase: "cdn.example.com", wantErr: true},
|
||||
{name: "query rejected", rawURL: "https://cdn.example.com/a?x=1", cdnBase: "cdn.example.com", wantErr: true},
|
||||
{name: "empty query rejected", rawURL: "https://cdn.example.com/a?", cdnBase: "cdn.example.com", wantErr: true},
|
||||
{name: "fragment rejected", rawURL: "https://cdn.example.com/a#fragment", cdnBase: "cdn.example.com", wantErr: true},
|
||||
{name: "empty fragment rejected", rawURL: "https://cdn.example.com/a#", cdnBase: "cdn.example.com", wantErr: true},
|
||||
{name: "backslash rejected", rawURL: `https://cdn.example.com/a\b`, cdnBase: "cdn.example.com", wantErr: true},
|
||||
{name: "encoded backslash rejected", rawURL: "https://cdn.example.com/a%5Cb", cdnBase: "cdn.example.com", wantErr: true},
|
||||
{name: "encoded slash rejected", rawURL: "https://cdn.example.com/a%2Fb", cdnBase: "cdn.example.com", wantErr: true},
|
||||
{name: "control rejected", rawURL: "https://cdn.example.com/a\x7fb", cdnBase: "cdn.example.com", wantErr: true},
|
||||
{name: "encoded control rejected", rawURL: "https://cdn.example.com/a%0Ab", cdnBase: "cdn.example.com", wantErr: true},
|
||||
{name: "dot rejected", rawURL: "https://cdn.example.com/a/./b", cdnBase: "cdn.example.com", wantErr: true},
|
||||
{name: "dot dot rejected", rawURL: "https://cdn.example.com/a/../b", cdnBase: "cdn.example.com", wantErr: true},
|
||||
{name: "encoded dot dot rejected", rawURL: "https://cdn.example.com/a/%2E%2E/b", cdnBase: "cdn.example.com", wantErr: true},
|
||||
{name: "empty path rejected", rawURL: "https://cdn.example.com", cdnBase: "cdn.example.com", wantErr: true},
|
||||
{name: "root path rejected", rawURL: "https://cdn.example.com/", cdnBase: "cdn.example.com", wantErr: true},
|
||||
{name: "empty middle segment rejected", rawURL: "https://cdn.example.com/a//b", cdnBase: "cdn.example.com", wantErr: true},
|
||||
{name: "empty last segment rejected", rawURL: "https://cdn.example.com/a/", cdnBase: "cdn.example.com", wantErr: true},
|
||||
{name: "missing key below base rejected", rawURL: "https://cdn.example.com/assets", cdnBase: "cdn.example.com/assets", wantErr: true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
key, owned, err := ObjectKeyFromCDNURL(tt.rawURL, tt.cdnBase)
|
||||
if tt.wantErr {
|
||||
require.Error(t, err)
|
||||
assert.False(t, owned)
|
||||
assert.Empty(t, key)
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tt.owned, owned)
|
||||
assert.Equal(t, tt.wantKey, key)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestObjectKeyFromCDNURLRejectsInvalidBase(t *testing.T) {
|
||||
for _, base := range []string{
|
||||
"",
|
||||
"http://cdn.example.com",
|
||||
"cdn.example.com?x=1",
|
||||
"cdn.example.com#fragment",
|
||||
"cdn.example.com/assets/../other",
|
||||
"cdn.example.com/assets//other",
|
||||
"cdn.example.com/assets%2Fother",
|
||||
"[email protected]",
|
||||
} {
|
||||
t.Run(base, func(t *testing.T) {
|
||||
key, owned, err := ObjectKeyFromCDNURL("https://cdn.example.com/assets/a", base)
|
||||
require.Error(t, err)
|
||||
assert.False(t, owned)
|
||||
assert.Empty(t, key)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -5,9 +5,6 @@ import (
|
||||
"sync"
|
||||
|
||||
"github.com/aliyun/aliyun-oss-go-sdk/oss"
|
||||
"github.com/aws/aws-sdk-go-v2/aws"
|
||||
"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"
|
||||
"github.com/pkg/errors"
|
||||
|
||||
@@ -31,29 +28,13 @@ func GetS3Client() (*s3.Client, error) {
|
||||
s3ClientErr = errors.New("S3 endpoint not configured")
|
||||
return
|
||||
}
|
||||
|
||||
r2Resolver := aws.EndpointResolverWithOptionsFunc(func(service, region string, options ...interface{}) (aws.Endpoint, error) {
|
||||
return aws.Endpoint{
|
||||
URL: conf.Upload.ImageEndpoint,
|
||||
HostnameImmutable: true,
|
||||
Source: aws.EndpointSourceCustom,
|
||||
}, nil
|
||||
})
|
||||
|
||||
cfg, err := config.LoadDefaultConfig(context.Background(),
|
||||
config.WithEndpointResolverWithOptions(r2Resolver),
|
||||
config.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(
|
||||
conf.Upload.ImageAccessID,
|
||||
conf.Upload.ImageAccessSecret,
|
||||
"",
|
||||
)),
|
||||
config.WithRegion("auto"),
|
||||
s3Client, s3ClientErr = NewS3Client(
|
||||
context.Background(),
|
||||
conf.Upload.ImageEndpoint,
|
||||
conf.Upload.ImageBucket,
|
||||
conf.Upload.ImageAccessID,
|
||||
conf.Upload.ImageAccessSecret,
|
||||
)
|
||||
if err != nil {
|
||||
s3ClientErr = errors.Wrap(err, "load S3 config")
|
||||
return
|
||||
}
|
||||
s3Client = s3.NewFromConfig(cfg)
|
||||
})
|
||||
return s3Client, s3ClientErr
|
||||
}
|
||||
|
||||
+11
-14
@@ -23,7 +23,6 @@ import (
|
||||
"github.com/disintegration/imaging"
|
||||
"github.com/pkg/errors"
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/wuhan005/gadget"
|
||||
|
||||
"github.com/tamakyi/TamaBox/internal/conf"
|
||||
"github.com/tamakyi/TamaBox/internal/imagecheck"
|
||||
@@ -189,19 +188,17 @@ func uploadPictureToS3(file multipart.File, fileHeader *multipart.FileHeader, as
|
||||
return "", errors.Wrap(err, "get S3 client")
|
||||
}
|
||||
|
||||
// 7. 上传到 R2
|
||||
if err := gadget.Retry(5, func() error {
|
||||
_, err := client.PutObject(context.Background(), &s3.PutObjectInput{
|
||||
Bucket: aws.String(conf.Upload.ImageBucket),
|
||||
Key: aws.String(key),
|
||||
Body: bytes.NewReader(uploadData),
|
||||
ContentLength: aws.Int64(int64(len(uploadData))),
|
||||
ContentType: aws.String(contentType),
|
||||
// key 含纳秒时间戳且内容不可变 → 可安全启用 immutable 强缓存,
|
||||
// 避免每次页面加载都回源校验(曾导致头像/二维码 logo 每次重新下载)。
|
||||
CacheControl: aws.String("public, max-age=31536000, immutable"),
|
||||
})
|
||||
return err
|
||||
// 7. 上传到 R2。现有上传 API 不接收调用方 context,故显式标记为 TODO;
|
||||
// PutObject 内部会为每次尝试设置独立超时,并对可取消 context 做退避。
|
||||
if err := PutObject(context.TODO(), client, &s3.PutObjectInput{
|
||||
Bucket: aws.String(conf.Upload.ImageBucket),
|
||||
Key: aws.String(key),
|
||||
Body: bytes.NewReader(uploadData),
|
||||
ContentLength: aws.Int64(int64(len(uploadData))),
|
||||
ContentType: aws.String(contentType),
|
||||
// key 含纳秒时间戳且内容不可变 → 可安全启用 immutable 强缓存,
|
||||
// 避免每次页面加载都回源校验(曾导致头像/二维码 logo 每次重新下载)。
|
||||
CacheControl: aws.String("public, max-age=31536000, immutable"),
|
||||
}); err != nil {
|
||||
return "", errors.Wrap(err, "put object after retry")
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/aws/aws-sdk-go-v2/aws"
|
||||
"github.com/aws/aws-sdk-go-v2/service/s3"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestNormalizeS3Endpoint(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
endpoint string
|
||||
bucket string
|
||||
want string
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "empty path", endpoint: "https://account.example.com", bucket: "images", want: "https://account.example.com"},
|
||||
{name: "strip exact bucket path", endpoint: "https://account.example.com/images", bucket: "images", want: "https://account.example.com"},
|
||||
{name: "httptest HTTP supported", endpoint: "http://127.0.0.1:12345/images", bucket: "images", want: "http://127.0.0.1:12345"},
|
||||
{name: "root path rejected", endpoint: "https://account.example.com/", bucket: "images", wantErr: true},
|
||||
{name: "different path rejected", endpoint: "https://account.example.com/other", bucket: "images", wantErr: true},
|
||||
{name: "bucket trailing slash rejected", endpoint: "https://account.example.com/images/", bucket: "images", wantErr: true},
|
||||
{name: "encoded bucket rejected", endpoint: "https://account.example.com/%69mages", bucket: "images", wantErr: true},
|
||||
{name: "relative rejected", endpoint: "account.example.com", bucket: "images", wantErr: true},
|
||||
{name: "empty hostname rejected", endpoint: "https://:443", bucket: "images", wantErr: true},
|
||||
{name: "unsupported scheme rejected", endpoint: "ftp://account.example.com", bucket: "images", wantErr: true},
|
||||
{name: "query rejected", endpoint: "https://account.example.com?x=1", bucket: "images", wantErr: true},
|
||||
{name: "empty query rejected", endpoint: "https://account.example.com?", bucket: "images", wantErr: true},
|
||||
{name: "fragment rejected", endpoint: "https://account.example.com#fragment", bucket: "images", wantErr: true},
|
||||
{name: "userinfo rejected", endpoint: "https://[email protected]", bucket: "images", wantErr: true},
|
||||
{name: "empty bucket rejected", endpoint: "https://account.example.com", bucket: "", wantErr: true},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := normalizeS3Endpoint(tt.endpoint, tt.bucket)
|
||||
if tt.wantErr {
|
||||
require.Error(t, err)
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tt.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewS3ClientConfiguration(t *testing.T) {
|
||||
client, err := NewS3Client(context.Background(), "https://account.example.com/images", "images", "test-id", "test-secret")
|
||||
require.NoError(t, err)
|
||||
|
||||
options := client.Options()
|
||||
assert.Equal(t, "auto", options.Region)
|
||||
assert.True(t, options.UsePathStyle)
|
||||
credentials, err := options.Credentials.Retrieve(context.Background())
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "test-id", credentials.AccessKeyID)
|
||||
assert.Equal(t, "test-secret", credentials.SecretAccessKey)
|
||||
|
||||
httpClient, ok := options.HTTPClient.(*http.Client)
|
||||
require.True(t, ok)
|
||||
assert.Zero(t, httpClient.Timeout, "全局 Client.Timeout 会截断大对象传输")
|
||||
transport, ok := httpClient.Transport.(*http.Transport)
|
||||
require.True(t, ok)
|
||||
assert.NotNil(t, transport.DialContext)
|
||||
assert.Positive(t, transport.TLSHandshakeTimeout)
|
||||
assert.Positive(t, transport.ResponseHeaderTimeout)
|
||||
assert.Positive(t, transport.ExpectContinueTimeout)
|
||||
}
|
||||
|
||||
func TestS3PutObjectRequestShape(t *testing.T) {
|
||||
type capturedRequest struct {
|
||||
method string
|
||||
path string
|
||||
body string
|
||||
contentType string
|
||||
cacheControl string
|
||||
authorization string
|
||||
}
|
||||
captured := make(chan capturedRequest, 1)
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
captured <- capturedRequest{
|
||||
method: r.Method,
|
||||
path: r.URL.Path,
|
||||
body: string(body),
|
||||
contentType: r.Header.Get("Content-Type"),
|
||||
cacheControl: r.Header.Get("Cache-Control"),
|
||||
authorization: r.Header.Get("Authorization"),
|
||||
}
|
||||
w.Header().Set("ETag", `"test-etag"`)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client, err := NewS3Client(context.Background(), server.URL+"/images", "images", "test-id", "test-secret")
|
||||
require.NoError(t, err)
|
||||
err = PutObject(context.Background(), client, &s3.PutObjectInput{
|
||||
Bucket: aws.String("images"),
|
||||
Key: aws.String("2026/11/picture.webp"),
|
||||
Body: bytes.NewReader([]byte("fake-image")),
|
||||
ContentLength: aws.Int64(10),
|
||||
ContentType: aws.String("image/webp"),
|
||||
CacheControl: aws.String("public, max-age=31536000, immutable"),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
request := <-captured
|
||||
assert.Equal(t, http.MethodPut, request.method)
|
||||
assert.Equal(t, "/images/2026/11/picture.webp", request.path, "bucket 必须且只能出现一次")
|
||||
assert.Equal(t, "fake-image", request.body)
|
||||
assert.Equal(t, "image/webp", request.contentType)
|
||||
assert.Equal(t, "public, max-age=31536000, immutable", request.cacheControl)
|
||||
assert.Contains(t, request.authorization, "Credential=test-id/")
|
||||
assert.Contains(t, request.authorization, "/auto/s3/aws4_request")
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"time"
|
||||
|
||||
"github.com/aws/aws-sdk-go-v2/aws"
|
||||
"github.com/aws/aws-sdk-go-v2/service/s3"
|
||||
"github.com/aws/smithy-go"
|
||||
)
|
||||
|
||||
const (
|
||||
s3AttemptTimeout = 60 * time.Second
|
||||
s3MaxAttempts = 3
|
||||
s3InitialRetryBackoff = 100 * time.Millisecond
|
||||
)
|
||||
|
||||
// PutObject 上传对象。每次尝试独立限时 60 秒,最多执行 3 次;
|
||||
// SDK 内部重试被关闭,确保重试次数、Body 回绕和错误分类只由本层控制。
|
||||
func PutObject(ctx context.Context, client *s3.Client, input *s3.PutObjectInput) error {
|
||||
if client == nil {
|
||||
return errors.New("nil S3 client")
|
||||
}
|
||||
if input == nil {
|
||||
return errors.New("nil S3 PutObject input")
|
||||
}
|
||||
return doS3RequestWithRetry(ctx, input.Body, func(attemptCtx context.Context) error {
|
||||
_, err := client.PutObject(attemptCtx, input, func(options *s3.Options) {
|
||||
options.Retryer = aws.NopRetryer{}
|
||||
})
|
||||
return err
|
||||
})
|
||||
}
|
||||
|
||||
// DeleteObject 删除对象。S3 Delete 本身幂等;404 也表示目标已经不存在,
|
||||
// 因而不重试并返回成功。其他错误采用与 PutObject 相同的超时和重试规则。
|
||||
func DeleteObject(ctx context.Context, client *s3.Client, bucket, key string) error {
|
||||
if client == nil {
|
||||
return errors.New("nil S3 client")
|
||||
}
|
||||
err := doS3RequestWithRetry(ctx, nil, func(attemptCtx context.Context) error {
|
||||
_, err := client.DeleteObject(attemptCtx, &s3.DeleteObjectInput{
|
||||
Bucket: aws.String(bucket),
|
||||
Key: aws.String(key),
|
||||
}, func(options *s3.Options) {
|
||||
options.Retryer = aws.NopRetryer{}
|
||||
})
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
if statusCode, ok := s3ErrorHTTPStatus(err); ok && statusCode == 404 {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func doS3RequestWithRetry(ctx context.Context, body io.Reader, operation func(context.Context) error) error {
|
||||
var (
|
||||
seeker io.Seeker
|
||||
bodyStart int64
|
||||
bodyStartErr error
|
||||
)
|
||||
if body != nil {
|
||||
if candidate, ok := body.(io.Seeker); ok {
|
||||
seeker = candidate
|
||||
// 记录调用时的位置,而不是武断跳到 0,兼容从切片区间上传的 Reader。
|
||||
bodyStart, bodyStartErr = seeker.Seek(0, io.SeekCurrent)
|
||||
}
|
||||
}
|
||||
|
||||
for attempt := 0; attempt < s3MaxAttempts; attempt++ {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if attempt > 0 && seeker != nil {
|
||||
if bodyStartErr != nil {
|
||||
return fmt.Errorf("determine S3 request body start: %w", bodyStartErr)
|
||||
}
|
||||
if _, err := seeker.Seek(bodyStart, io.SeekStart); err != nil {
|
||||
return fmt.Errorf("rewind S3 request body: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
attemptCtx, cancel := context.WithTimeout(ctx, s3AttemptTimeout)
|
||||
err := operation(attemptCtx)
|
||||
cancel()
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
}
|
||||
if attempt == s3MaxAttempts-1 || !retryableS3Error(err) {
|
||||
return err
|
||||
}
|
||||
|
||||
backoff := s3InitialRetryBackoff << attempt
|
||||
if err := waitForS3Retry(ctx, backoff); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func waitForS3Retry(ctx context.Context, delay time.Duration) error {
|
||||
timer := time.NewTimer(delay)
|
||||
defer timer.Stop()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-timer.C:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
type httpStatusCoder interface {
|
||||
HTTPStatusCode() int
|
||||
}
|
||||
|
||||
func s3ErrorHTTPStatus(err error) (int, bool) {
|
||||
var statusErr httpStatusCoder
|
||||
if errors.As(err, &statusErr) {
|
||||
return statusErr.HTTPStatusCode(), true
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
func retryableS3Error(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
if statusCode, ok := s3ErrorHTTPStatus(err); ok {
|
||||
return statusCode == 408 || statusCode == 429 || statusCode >= 500 && statusCode <= 599
|
||||
}
|
||||
if errors.Is(err, context.Canceled) {
|
||||
return false
|
||||
}
|
||||
// 父 context 已结束时会在调用方提前返回;这里的 DeadlineExceeded 是
|
||||
// 单次尝试或传输层超时,可以在父 context 仍有效时重试。
|
||||
if errors.Is(err, context.DeadlineExceeded) {
|
||||
return true
|
||||
}
|
||||
var networkErr net.Error
|
||||
if errors.As(err, &networkErr) {
|
||||
return true
|
||||
}
|
||||
if errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF) {
|
||||
return true
|
||||
}
|
||||
|
||||
// 某些兼容 S3 的实现可能只返回错误码而没有可提取的 HTTP 状态。
|
||||
var apiErr smithy.APIError
|
||||
if errors.As(err, &apiErr) {
|
||||
switch apiErr.ErrorCode() {
|
||||
case "RequestTimeout", "RequestTimeoutException",
|
||||
"Throttling", "ThrottlingException", "TooManyRequestsException", "SlowDown",
|
||||
"InternalError", "InternalServerError", "ServiceUnavailable", "ServiceUnavailableException":
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/aws/aws-sdk-go-v2/aws"
|
||||
"github.com/aws/aws-sdk-go-v2/service/s3"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestPutObjectRetriesAndRewindsBody(t *testing.T) {
|
||||
var attempts atomic.Int32
|
||||
bodies := make(chan string, 3)
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
bodies <- string(body)
|
||||
if attempts.Add(1) < 3 {
|
||||
http.Error(w, "temporary", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client, err := NewS3Client(context.Background(), server.URL, "images", "id", "secret")
|
||||
require.NoError(t, err)
|
||||
err = PutObject(context.Background(), client, &s3.PutObjectInput{
|
||||
Bucket: aws.String("images"),
|
||||
Key: aws.String("retry.txt"),
|
||||
Body: bytes.NewReader([]byte("complete-body")),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int32(3), attempts.Load())
|
||||
close(bodies)
|
||||
for body := range bodies {
|
||||
assert.Equal(t, "complete-body", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPutObjectDoesNotRetryPermanentErrors(t *testing.T) {
|
||||
for _, statusCode := range []int{http.StatusForbidden, http.StatusNotFound} {
|
||||
t.Run(http.StatusText(statusCode), func(t *testing.T) {
|
||||
var attempts atomic.Int32
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
attempts.Add(1)
|
||||
http.Error(w, "permanent", statusCode)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client, err := NewS3Client(context.Background(), server.URL, "images", "id", "secret")
|
||||
require.NoError(t, err)
|
||||
err = PutObject(context.Background(), client, &s3.PutObjectInput{
|
||||
Bucket: aws.String("images"),
|
||||
Key: aws.String("permanent.txt"),
|
||||
Body: bytes.NewReader([]byte("body")),
|
||||
})
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, int32(1), attempts.Load())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPutObjectRetriesTemporaryStatusCodes(t *testing.T) {
|
||||
for _, statusCode := range []int{http.StatusRequestTimeout, http.StatusTooManyRequests, http.StatusBadGateway} {
|
||||
t.Run(http.StatusText(statusCode), func(t *testing.T) {
|
||||
var attempts atomic.Int32
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if attempts.Add(1) == 1 {
|
||||
http.Error(w, "temporary", statusCode)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client, err := NewS3Client(context.Background(), server.URL, "images", "id", "secret")
|
||||
require.NoError(t, err)
|
||||
err = PutObject(context.Background(), client, &s3.PutObjectInput{
|
||||
Bucket: aws.String("images"),
|
||||
Key: aws.String("temporary.txt"),
|
||||
Body: bytes.NewReader([]byte("body")),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int32(2), attempts.Load())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPutObjectStopsAfterThreeAttempts(t *testing.T) {
|
||||
var attempts atomic.Int32
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
attempts.Add(1)
|
||||
http.Error(w, "still unavailable", http.StatusServiceUnavailable)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client, err := NewS3Client(context.Background(), server.URL, "images", "id", "secret")
|
||||
require.NoError(t, err)
|
||||
err = PutObject(context.Background(), client, &s3.PutObjectInput{
|
||||
Bucket: aws.String("images"),
|
||||
Key: aws.String("unavailable.txt"),
|
||||
Body: bytes.NewReader([]byte("body")),
|
||||
})
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, int32(3), attempts.Load())
|
||||
}
|
||||
|
||||
func TestPutObjectHonorsCanceledContext(t *testing.T) {
|
||||
var attempts atomic.Int32
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
attempts.Add(1)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client, err := NewS3Client(context.Background(), server.URL, "images", "id", "secret")
|
||||
require.NoError(t, err)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
err = PutObject(ctx, client, &s3.PutObjectInput{
|
||||
Bucket: aws.String("images"),
|
||||
Key: aws.String("canceled.txt"),
|
||||
Body: bytes.NewReader([]byte("body")),
|
||||
})
|
||||
require.ErrorIs(t, err, context.Canceled)
|
||||
assert.Zero(t, attempts.Load())
|
||||
}
|
||||
|
||||
func TestRetryBackoffCanBeCanceled(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
done <- waitForS3Retry(ctx, time.Hour)
|
||||
}()
|
||||
cancel()
|
||||
|
||||
select {
|
||||
case err := <-done:
|
||||
require.ErrorIs(t, err, context.Canceled)
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("退避未响应 context 取消")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNetworkErrorsAreRetryable(t *testing.T) {
|
||||
assert.True(t, retryableS3Error(&net.DNSError{Err: "temporary resolver failure", IsTimeout: true}))
|
||||
}
|
||||
|
||||
func TestDeleteObjectNotFoundIsIdempotent(t *testing.T) {
|
||||
var attempts atomic.Int32
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
attempts.Add(1)
|
||||
assert.Equal(t, http.MethodDelete, r.Method)
|
||||
http.Error(w, "not found", http.StatusNotFound)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client, err := NewS3Client(context.Background(), server.URL, "images", "id", "secret")
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, DeleteObject(context.Background(), client, "images", "already-gone.txt"))
|
||||
assert.Equal(t, int32(1), attempts.Load())
|
||||
}
|
||||
Reference in New Issue
Block a user