diff --git a/backend/go.mod b/backend/go.mod index 3c00b15e1..5792bf421 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -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 diff --git a/backend/go.sum b/backend/go.sum index 3f181a6b5..28b0a4c9d 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -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= diff --git a/backend/internal/service/resource.go b/backend/internal/service/resource.go index 5b91125eb..f0fc609b5 100644 --- a/backend/internal/service/resource.go +++ b/backend/internal/service/resource.go @@ -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": diff --git a/backend/internal/service/resource_test.go b/backend/internal/service/resource_test.go index 1b441963c..51a9c7bdb 100644 --- a/backend/internal/service/resource_test.go +++ b/backend/internal/service/resource_test.go @@ -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) {})) diff --git a/backend/internal/service/settings.go b/backend/internal/service/settings.go index 90036e66d..ef5934c1c 100644 --- a/backend/internal/service/settings.go +++ b/backend/internal/service/settings.go @@ -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") } diff --git a/web/src/components/layout/user-oss-settings-form.tsx b/web/src/components/layout/user-oss-settings-form.tsx index afba5c048..21a780e3e 100644 --- a/web/src/components/layout/user-oss-settings-form.tsx +++ b/web/src/components/layout/user-oss-settings-form.tsx @@ -116,10 +116,12 @@ export function UserOSSSettingsForm() {