fix(media): 七牛对象存储 - 支持无绑定域名通过后端代理读取
This commit is contained in:
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user