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

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

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

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=

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":

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) {}))

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")
}

View File

@@ -116,10 +116,12 @@ export function UserOSSSettingsForm() {
</Form.Item>
<Form.Item
name="cdnBaseUrl"
label={isQiniuKodo ? "绑定域名" : "CDN 加速域名"}
label={isQiniuKodo ? "绑定域名(可选)" : "CDN 加速域名"}
extra={isTencentCOS
? "选填。上传仍走 Endpoint,下载与预览改走 CDN;私有桶需开启 CDN 私有存储桶访问。CDN URL 不附带 COS 签名,未配置 CDN URL 鉴权时链接将长期可访问。"
: "选填。上传仍走 Endpoint,下载与预览改走 CDN;阿里云私有 Bucket 需开启 CDN 私有 Bucket 回源。CDN URL 不附带 OSS 签名,未配置 CDN URL 鉴权时链接将长期可访问。"}
: isQiniuKodo
? "选填。填写后浏览器直连七牛私有下载地址;留空时采用“浏览器 → 当前后端 /api/resources/:id/file → 七牛 S3 Endpoint”的代理链路,后端使用 AK/SK 读取并返回文件,无需绑定域名。"
: "选填。上传仍走 Endpoint,下载与预览改走 CDN;阿里云私有 Bucket 需开启 CDN 私有 Bucket 回源。CDN URL 不附带 OSS 签名,未配置 CDN URL 鉴权时链接将长期可访问。"}
rules={[{ type: "url", message: "请填写完整的 http/https CDN 加速域名" }]}
className="mb-3"
>

View File

@@ -56,7 +56,6 @@ export default function StorageSettingsPage() {
if (values.mode === "aliyun" && !values.endpoint?.trim()) return message.error("请填写阿里云 OSS Endpoint");
if (values.mode === "tencent" && !values.endpoint?.trim() && !values.region?.trim()) return message.error("请填写腾讯云 COS Region 或 Endpoint");
if (values.mode === "qiniu" && !values.endpoint?.trim()) return message.error("请填写七牛云 Kodo 上传 Endpoint");
if (values.mode === "qiniu" && !values.cdnBaseUrl?.trim()) return message.error("请填写七牛云 Kodo 绑定域名");
setSaving(true);
try {
@@ -140,7 +139,12 @@ export default function StorageSettingsPage() {
<Form.Item className="xl:col-span-2" name="endpoint" label={isQiniuKodo ? "上传 Endpoint" : "Endpoint"}>
<Input autoComplete="off" inputMode="url" placeholder={isTencentCOS ? "https://cos.ap-guangzhou.myqcloud.com" : isQiniuKodo ? "https://up-z0.qiniup.com" : "https://oss-cn-hangzhou.aliyuncs.com"} />
</Form.Item>
<Form.Item name="cdnBaseUrl" label={isQiniuKodo ? "绑定域名" : "CDN 加速域名"} rules={[{ type: "url", message: "请填写完整的 http/https 地址" }]}>
<Form.Item
name="cdnBaseUrl"
label={isQiniuKodo ? "绑定域名(可选)" : "CDN 加速域名"}
extra={isQiniuKodo ? "可选。填写后浏览器直连七牛私有下载地址;留空时采用“浏览器 → 当前后端 /api/resources/:id/file → 七牛 S3 Endpoint”的代理链路,后端使用 AK/SK 读取并返回文件,无需绑定域名。" : undefined}
rules={[{ type: "url", message: "请填写完整的 http/https 地址" }]}
>
<Input autoComplete="off" inputMode="url" placeholder="https://media.example.com" />
</Form.Item>
</div>

View File

@@ -137,7 +137,7 @@ function resourceCacheKey(id: string) {
export function resourceFileUrl(id: string) {
const base = String(apiBaseURL).replace(/\/+$/, "");
return `${base}/resources/${encodeURIComponent(id)}/file?direct=1`;
return `${base}/resources/${encodeURIComponent(id)}/file`;
}
function resourceProxyFileUrl(id: string) {