fix(storage): COS 资源下载 - 公开 CDN 模式使用附件响应头 (#718)
This commit is contained in:
@@ -474,6 +474,40 @@ func TestPrepareResourceDeliveryPrefersConfiguredCDN(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrepareResourceDeliveryUsesPublicCDNForDownloadWhenOriginIsPrivate(t *testing.T) {
|
||||
svc := newResourceTestService(t)
|
||||
settingJSON, _ := json.Marshal(ossSettingValue{
|
||||
Enabled: true, Provider: tencentCOSProvider, Endpoint: "http://storage.internal", CDNBaseURL: "https://media.example.com",
|
||||
Bucket: "private-bucket-1250000000", AccessKeyID: "secret-id", AccessKeySecret: "secret-key",
|
||||
Delivery: storage.DeliverySettings{CDNAuthMode: "public"},
|
||||
})
|
||||
if err := svc.repo.SaveSystemSetting(&model.SystemSetting{Key: ossSettingKey, ValueJSON: string(settingJSON)}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resource := model.Resource{
|
||||
ID: "resource-public-cdn-download", UserID: "user-1", Kind: "image", Status: model.ResourceStatusReady,
|
||||
Provider: tencentCOSProvider, Endpoint: "http://storage.internal", Bucket: "private-bucket-1250000000",
|
||||
ObjectKey: "users/user-1/image/test image.png", MimeType: "image/png",
|
||||
}
|
||||
if err := svc.repo.CreateResource(&resource); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
delivery, err := svc.PrepareResourceDelivery("user-1", resource.ID, ResourceAccessOptions{Purpose: assets.PurposeDownload, DownloadName: "下载图片.png"}, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if delivery.Access == nil || delivery.Access.Delivery != assets.DeliveryCDN || delivery.Access.ExpiresAt != nil {
|
||||
t.Fatalf("PrepareResourceDelivery() = %#v", delivery)
|
||||
}
|
||||
parsed, err := url.Parse(delivery.Access.URL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if parsed.Host != "media.example.com" || parsed.Path != "/users/user-1/image/test image.png" || !strings.HasPrefix(parsed.Query().Get("response-content-disposition"), "attachment") {
|
||||
t.Fatalf("download URL = %q", delivery.Access.URL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrepareResourceDeliveryFallsBackToOriginWhenCDNAuthIsMissing(t *testing.T) {
|
||||
outboundtest.PublicDNS(t, "s3.amazonaws.com")
|
||||
svc := newResourceTestService(t)
|
||||
|
||||
@@ -144,6 +144,13 @@ func ResolveAccess(resource *model.Resource, setting storage.Settings, options A
|
||||
// A redirect cannot enforce our attachment and sandbox response headers.
|
||||
access.Delivery = DeliveryProxy
|
||||
access.FallbackReason = "attachment_required"
|
||||
} else if options.Purpose == PurposeDownload && setting.Delivery.CDNAuthMode == "public" && storage.CDNEnabled(setting) {
|
||||
// Public COS/OSS CDN deployments can forward this response override to
|
||||
// their origin. Keeping it on the download-only URL lets images and
|
||||
// videos continue to use their normal inline CDN URL for display.
|
||||
access.URL, err = storage.PublicCDNObjectDownloadURL(setting, resource.ObjectKey, options.DownloadName)
|
||||
access.Delivery = DeliveryCDN
|
||||
access.ExpiresAt = nil
|
||||
} else if options.Purpose == PurposeDownload {
|
||||
// Cross-origin `a[download]` is only advisory. A real browser download must
|
||||
// therefore be enforced by the object store response itself. Downloads use
|
||||
|
||||
@@ -140,15 +140,11 @@ func TestResolveAccessVariantAndExpiryContract(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveDownloadUsesObjectOriginAttachmentInsteadOfCDNOrProxy(t *testing.T) {
|
||||
func TestResolveDownloadUsesPublicCDNAttachment(t *testing.T) {
|
||||
now := time.Date(2026, time.January, 2, 3, 4, 5, 0, time.UTC)
|
||||
resource := testReadyResource("aliyun")
|
||||
setting := storage.Settings{
|
||||
// A public IP keeps the test deterministic: PublicOrigin intentionally
|
||||
// resolves hostnames to reject private DNS answers, so a made-up OSS host
|
||||
// would depend on the machine's DNS configuration.
|
||||
Provider: "aliyun", Endpoint: "https://1.1.1.1", Bucket: "private-bucket",
|
||||
AccessKeyID: "access-id", AccessKeySecret: "secret-value", CDNBaseURL: "https://media.example.com",
|
||||
Provider: "aliyun", CDNBaseURL: "https://media.example.com",
|
||||
Delivery: storage.DeliverySettings{CDNAuthMode: "public"},
|
||||
}
|
||||
access, err := ResolveAccess(resource, setting, AccessOptions{Purpose: PurposeDownload, DownloadName: "画布_镜头01.mp4"}, now, testPlatformURL(&[]ResourceVariant{}))
|
||||
@@ -160,7 +156,7 @@ func TestResolveDownloadUsesObjectOriginAttachmentInsteadOfCDNOrProxy(t *testing
|
||||
t.Fatal(err)
|
||||
}
|
||||
disposition := parsed.Query().Get("response-content-disposition")
|
||||
if access.Delivery != DeliveryOrigin || parsed.Host != "1.1.1.1" || !strings.HasPrefix(disposition, "attachment") || !strings.Contains(strings.ToLower(disposition), "utf-8''") {
|
||||
if access.Delivery != DeliveryCDN || access.ExpiresAt != nil || parsed.Host != "media.example.com" || !strings.HasPrefix(disposition, "attachment") || !strings.Contains(strings.ToLower(disposition), "utf-8''") {
|
||||
t.Fatalf("download access = %#v, disposition=%q", access, disposition)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -248,6 +248,28 @@ func SignedOriginObjectDownloadURL(setting Settings, objectKey string, expiresAt
|
||||
return signedOriginObjectURL(setting, objectKey, objectURLSigning{ExpiresAt: expiresAt, ContentDisposition: disposition})
|
||||
}
|
||||
|
||||
// PublicCDNObjectDownloadURL returns a public CDN URL that asks the object
|
||||
// storage origin to emit an attachment response. The query parameter keeps
|
||||
// ordinary display URLs inline while allowing a browser to download this one
|
||||
// without routing object bytes through the application server.
|
||||
//
|
||||
// Public CDN deployments must preserve response-content-disposition when
|
||||
// forwarding requests to the object store.
|
||||
func PublicCDNObjectDownloadURL(setting Settings, objectKey string, fileName string) (string, error) {
|
||||
value, err := OssCDNObjectURL(setting.CDNBaseURL, objectKey)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
parsed, err := url.Parse(value)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
query := parsed.Query()
|
||||
query.Set("response-content-disposition", objectDownloadContentDisposition(fileName, objectKey))
|
||||
parsed.RawQuery = query.Encode()
|
||||
return parsed.String(), nil
|
||||
}
|
||||
|
||||
func objectDownloadContentDisposition(fileName string, objectKey string) string {
|
||||
name := strings.TrimSpace(fileName)
|
||||
name = strings.ReplaceAll(name, "\\", "_")
|
||||
|
||||
@@ -76,6 +76,28 @@ func TestOneShotURLsKeepCurrentTimeSigningWithoutCacheOverride(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublicCDNObjectDownloadURLForcesAttachmentOnlyOnDownloadURL(t *testing.T) {
|
||||
setting := Settings{CDNBaseURL: "https://media.example.com"}
|
||||
value, err := PublicCDNObjectDownloadURL(setting, "users/u-1/image/test image.png", "下载图片.png")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
parsed, err := url.Parse(value)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if parsed.Host != "media.example.com" || parsed.Path != "/users/u-1/image/test image.png" || !strings.HasPrefix(parsed.Query().Get("response-content-disposition"), "attachment") {
|
||||
t.Fatalf("download URL = %q", value)
|
||||
}
|
||||
inline, err := OssCDNObjectURL(setting.CDNBaseURL, "users/u-1/image/test image.png")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if parsedInline, err := url.Parse(inline); err != nil || parsedInline.RawQuery != "" {
|
||||
t.Fatalf("inline CDN URL = %q, %v", inline, err)
|
||||
}
|
||||
}
|
||||
|
||||
func mustParseQuery(t *testing.T, raw string) url.Values {
|
||||
t.Helper()
|
||||
parsed, err := url.Parse(raw)
|
||||
|
||||
Reference in New Issue
Block a user