fix(media): 七牛对象存储 - 支持无绑定域名通过后端代理读取

This commit is contained in:
ddCat committed 2026-08-20 16:21:59 +08:00
1 parent 1c919991b0
commit 192cb12758
8 files changed
+130 -12

No files matched your search

+1
View File
@@ -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
+1
View File
@@ -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=
+57 -4
View File
@@ -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":
+60
View File
@@ -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) {}))
-3
View File
@@ -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")
}