fix(media): 七牛对象存储 - 支持无绑定域名通过后端代理读取
This commit is contained in:
1 parent
1c919991b0
commit
192cb12758
8 files changed
+130
-12
No files matched your search
@@ -3,6 +3,7 @@ module infinite-canvas/backend
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/aws/aws-sdk-go v1.40.45
|
||||
github.com/gin-gonic/gin v1.12.0
|
||||
github.com/qiniu/go-sdk/v7 v7.27.0
|
||||
github.com/redis/go-redis/v9 v9.22.0
|
||||
|
||||
@@ -61,6 +61,7 @@ github.com/armon/go-metrics v0.3.9/go.mod h1:4O98XIr/9W0sxpJ8UaYkvjk10Iff7SnFrb4
|
||||
github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8=
|
||||
github.com/armon/go-radix v1.0.0/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8=
|
||||
github.com/avast/retry-go v3.0.0+incompatible/go.mod h1:XtSnn+n/sHqQIpZ10K1qAevBhOOCWBLXXy3hyiqqBrY=
|
||||
github.com/aws/aws-sdk-go v1.40.45 h1:QN1nsY27ssD/JmW4s83qmSb+uL6DG4GmCDzjmJB4xUI=
|
||||
github.com/aws/aws-sdk-go v1.40.45/go.mod h1:585smgzpB/KqRA+K3y/NL/oYRqQvpNJYvLm+LY1U59Q=
|
||||
github.com/aws/aws-sdk-go-v2 v1.9.1/go.mod h1:cK/D0BBs0b/oWPIcX/Z/obahJK1TT7IPVjy53i/mX/4=
|
||||
github.com/aws/aws-sdk-go-v2/service/cloudwatch v1.8.1/go.mod h1:CM+19rL1+4dFWnOQKwDc7H1KwXTz+h61oUSHyhV0b3o=
|
||||
|
||||
@@ -28,6 +28,8 @@ import (
|
||||
|
||||
"infinite-canvas/backend/internal/model"
|
||||
|
||||
"github.com/aws/aws-sdk-go/aws/credentials"
|
||||
awsv4 "github.com/aws/aws-sdk-go/aws/signer/v4"
|
||||
qiniuAuth "github.com/qiniu/go-sdk/v7/auth"
|
||||
qiniuStorage "github.com/qiniu/go-sdk/v7/storage"
|
||||
cos "github.com/tencentyun/cos-go-sdk-v5"
|
||||
@@ -128,7 +130,7 @@ func (s *Service) prepareResourceDelivery(userID string, resource *model.Resourc
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if setting.Provider == qiniuKodoProvider {
|
||||
if setting.Provider == qiniuKodoProvider && setting.CDNBaseURL != "" {
|
||||
// 七牛私有空间即使配置了绑定域名,也不能匿名访问;必须使用
|
||||
// Kodo 私有下载签名,否则浏览器会收到 NotSupportAnonymous。
|
||||
redirectURL, err := signedOSSObjectURL(setting, resource.ObjectKey, time.Now().Add(directResourceURLTTL))
|
||||
@@ -1091,9 +1093,6 @@ func signedQiniuObjectURL(setting ossSettingValue, objectKey string, expiresAt t
|
||||
if setting.AccessKeyID == "" || setting.AccessKeySecret == "" {
|
||||
return "", errors.New("七牛云 Kodo 访问密钥不可用")
|
||||
}
|
||||
if setting.CDNBaseURL == "" {
|
||||
return "", errors.New("七牛云 Kodo 绑定域名为空")
|
||||
}
|
||||
objectKey = strings.TrimLeft(strings.TrimSpace(objectKey), "/")
|
||||
if objectKey == "" {
|
||||
return "", errors.New("七牛云 Kodo 对象路径为空")
|
||||
@@ -1102,10 +1101,64 @@ func signedQiniuObjectURL(setting ossSettingValue, objectKey string, expiresAt t
|
||||
if deadline <= time.Now().Unix() {
|
||||
return "", errors.New("七牛云 Kodo 签名有效期必须晚于当前时间")
|
||||
}
|
||||
if setting.CDNBaseURL == "" {
|
||||
return signedQiniuS3ObjectURL(setting, objectKey, expiresAt)
|
||||
}
|
||||
mac := qiniuAuth.New(setting.AccessKeyID, setting.AccessKeySecret)
|
||||
return qiniuStorage.MakePrivateURLv2(mac, strings.TrimRight(setting.CDNBaseURL, "/"), objectKey, deadline), nil
|
||||
}
|
||||
|
||||
// signedQiniuS3ObjectURL 用七牛兼容 S3 的 AWS Signature V4 访问私有空间。
|
||||
// 没有绑定域名时,浏览器不直接访问该地址,而是由后端代理读取并返回文件。
|
||||
func signedQiniuS3ObjectURL(setting ossSettingValue, objectKey string, expiresAt time.Time) (string, error) {
|
||||
region := qiniuS3Region(setting)
|
||||
if region == "" {
|
||||
return "", errors.New("七牛云 Kodo S3 Region 不可用")
|
||||
}
|
||||
baseURL := &url.URL{Scheme: "https", Host: setting.Bucket + ".s3." + region + ".qiniucs.com"}
|
||||
// 保留对象键的原始路径,让 url.URL 和 AWS signer 只做一次 RFC 3986 转义。
|
||||
baseURL.Path = "/" + objectKey
|
||||
req, err := http.NewRequest(http.MethodGet, baseURL.String(), nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
credentialsValue := credentials.NewStaticCredentials(setting.AccessKeyID, setting.AccessKeySecret, "")
|
||||
signer := awsv4.NewSigner(credentialsValue)
|
||||
if _, err := signer.Presign(req, nil, "s3", region, time.Until(expiresAt), time.Now().UTC()); err != nil {
|
||||
return "", fmt.Errorf("七牛云 Kodo S3 签名失败:%w", err)
|
||||
}
|
||||
return req.URL.String(), nil
|
||||
}
|
||||
|
||||
func qiniuS3Region(setting ossSettingValue) string {
|
||||
region := strings.ToLower(strings.TrimSpace(setting.Region))
|
||||
if region == "" {
|
||||
endpoint := strings.ToLower(setting.Endpoint)
|
||||
for _, candidate := range []string{"z0", "z1", "z2", "na0", "as0", "cn-east-1", "cn-north-1", "cn-south-1", "us-north-1", "ap-southeast-1", "cn-east-2"} {
|
||||
if strings.Contains(endpoint, candidate) {
|
||||
region = candidate
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
switch region {
|
||||
case "", "z0", "cn-east-1":
|
||||
return "cn-east-1"
|
||||
case "z1", "cn-north-1":
|
||||
return "cn-north-1"
|
||||
case "z2", "cn-south-1":
|
||||
return "cn-south-1"
|
||||
case "na0", "us-north-1":
|
||||
return "us-north-1"
|
||||
case "as0", "ap-southeast-1":
|
||||
return "ap-southeast-1"
|
||||
case "cn-east-2", "zhejiang2":
|
||||
return "cn-east-2"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func qiniuRegion(region string) *qiniuStorage.Region {
|
||||
switch strings.ToLower(strings.TrimSpace(region)) {
|
||||
case "z1", "cn-north-1":
|
||||
|
||||
@@ -286,6 +286,40 @@ func TestAliyunOSSSettingKeepsCDNBaseURL(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestQiniuKodoSettingAllowsMissingCDNBaseURL(t *testing.T) {
|
||||
next, err := ossSettingFromRequest(OSSSettingRequest{
|
||||
Enabled: true, Provider: qiniuKodoProvider, Region: "z0", Endpoint: "https://up-z0.qiniup.com",
|
||||
Bucket: "private-bucket", AccessKeyID: "access-id", AccessKeySecret: "secret-value",
|
||||
}, ossSettingValue{})
|
||||
if err != nil {
|
||||
t.Fatalf("ossSettingFromRequest() error = %v", err)
|
||||
}
|
||||
if next.CDNBaseURL != "" {
|
||||
t.Fatalf("CDNBaseURL = %q, want empty", next.CDNBaseURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSignedQiniuS3ObjectURL(t *testing.T) {
|
||||
value, err := signedQiniuObjectURL(ossSettingValue{
|
||||
Provider: qiniuKodoProvider, Endpoint: "https://up-z0.qiniup.com", Bucket: "private-bucket",
|
||||
AccessKeyID: "access-id", AccessKeySecret: "secret-value",
|
||||
}, "users/u-1/image/test image.png", time.Now().Add(time.Hour))
|
||||
if err != nil {
|
||||
t.Fatalf("signedQiniuObjectURL() error = %v", err)
|
||||
}
|
||||
parsed, err := url.Parse(value)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
query := parsed.Query()
|
||||
if parsed.Host != "private-bucket.s3.cn-east-1.qiniucs.com" || parsed.Path != "/users/u-1/image/test image.png" || query.Get("X-Amz-Algorithm") != "AWS4-HMAC-SHA256" || query.Get("X-Amz-Credential") == "" || query.Get("X-Amz-Signature") == "" || query.Get("X-Amz-Expires") == "" {
|
||||
t.Fatalf("signed Qiniu S3 URL = %q", value)
|
||||
}
|
||||
if strings.Contains(value, "secret-value") {
|
||||
t.Fatalf("signed URL leaked access key secret: %q", value)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOSSCDNBaseURLRejectsNonDomainParts(t *testing.T) {
|
||||
for _, value := range []string{"ftp://media.example.com", "https://media.example.com/assets", "https://media.example.com?token=value", "https://user@media.example.com"} {
|
||||
if _, err := ossCDNBaseURL(value); err == nil {
|
||||
@@ -436,6 +470,32 @@ func TestPrepareResourceDeliverySignsPrivateQiniuCDNURL(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrepareResourceDeliveryProxiesQiniuWithoutCDNBaseURL(t *testing.T) {
|
||||
svc := newResourceTestService(t)
|
||||
settingJSON, _ := json.Marshal(ossSettingValue{
|
||||
Enabled: true, Provider: qiniuKodoProvider, Region: "z0", Endpoint: "https://up-z0.qiniup.com",
|
||||
Bucket: "private-bucket", AccessKeyID: "access-id", AccessKeySecret: "secret-value",
|
||||
})
|
||||
if err := svc.repo.SaveSystemSetting(&model.SystemSetting{Key: ossSettingKey, ValueJSON: string(settingJSON)}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resource := model.Resource{
|
||||
ID: "resource-qiniu-proxy", UserID: "user-1", Kind: "image", Status: model.ResourceStatusReady,
|
||||
Provider: qiniuKodoProvider, Endpoint: "https://up-z0.qiniup.com", Bucket: "private-bucket",
|
||||
ObjectKey: "users/user-1/image/private.png", MimeType: "image/png",
|
||||
}
|
||||
if err := svc.repo.CreateResource(&resource); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
delivery, err := svc.PrepareResourceDelivery("user-1", resource.ID, ResourceDeliveryOptions{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if delivery.Resource == nil || delivery.RedirectURL != "" {
|
||||
t.Fatalf("PrepareResourceDelivery() = %#v, want backend proxy delivery", delivery)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCurrentUserCDNSettingAppliesToHistoricalResourcesInSameStorage(t *testing.T) {
|
||||
t.Setenv("CANVAS_ALLOW_PRIVATE_UPSTREAMS", "true")
|
||||
server := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
|
||||
|
||||
@@ -490,9 +490,6 @@ func ossSettingFromRequest(req OSSSettingRequest, current ossSettingValue) (ossS
|
||||
return next, err
|
||||
}
|
||||
}
|
||||
if next.Provider == qiniuKodoProvider && next.CDNBaseURL == "" {
|
||||
return next, BadAuthRequest("请填写七牛云 Kodo 绑定域名")
|
||||
}
|
||||
if next.AccessKeyID == "" {
|
||||
return next, BadAuthRequest("请填写访问密钥 AccessKey")
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user