diff --git a/backend/internal/app/resource_test.go b/backend/internal/app/resource_test.go index a7be96c74..5b9ad9864 100644 --- a/backend/internal/app/resource_test.go +++ b/backend/internal/app/resource_test.go @@ -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) diff --git a/backend/internal/assets/access.go b/backend/internal/assets/access.go index 8d091c3bc..d55913849 100644 --- a/backend/internal/assets/access.go +++ b/backend/internal/assets/access.go @@ -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 diff --git a/backend/internal/assets/access_test.go b/backend/internal/assets/access_test.go index 60d620355..e78094b65 100644 --- a/backend/internal/assets/access_test.go +++ b/backend/internal/assets/access_test.go @@ -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) } } diff --git a/backend/internal/storage/objects.go b/backend/internal/storage/objects.go index 7e3fdefd4..e079c3fba 100644 --- a/backend/internal/storage/objects.go +++ b/backend/internal/storage/objects.go @@ -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, "\\", "_") diff --git a/backend/internal/storage/signing_test.go b/backend/internal/storage/signing_test.go index 39a556d16..19dc2ebb6 100644 --- a/backend/internal/storage/signing_test.go +++ b/backend/internal/storage/signing_test.go @@ -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)