diff --git a/backend/internal/app/resource_test.go b/backend/internal/app/resource_test.go index e8bc1279e..c056391f6 100644 --- a/backend/internal/app/resource_test.go +++ b/backend/internal/app/resource_test.go @@ -524,7 +524,7 @@ func TestPrepareResourceDeliverySignsPrivateQiniuCDNURL(t *testing.T) { } } -func TestPrepareResourceDeliveryAllowsExplicitPrivateOriginProxy(t *testing.T) { +func TestResourceAccessBatchRejectsPrivateOriginForBrowserAccess(t *testing.T) { t.Setenv("CANVAS_ALLOW_PRIVATE_UPSTREAMS", "true") svc := newResourceTestService(t) settingJSON, _ := json.Marshal(ossSettingValue{ @@ -547,8 +547,8 @@ func TestPrepareResourceDeliveryAllowsExplicitPrivateOriginProxy(t *testing.T) { if err != nil { t.Fatal(err) } - if len(results) != 1 || results[0].Access == nil || results[0].Access.Delivery != assets.DeliveryProxy { - t.Fatalf("ResourceAccessBatch() = %#v, want backend proxy delivery", results) + if len(results) != 1 || results[0].Access != nil || results[0].Error == nil || results[0].Error.Reason != "resource_origin_private" { + t.Fatalf("ResourceAccessBatch() = %#v, want resource_origin_private", results) } } diff --git a/backend/internal/assets/access.go b/backend/internal/assets/access.go index 7550d6d97..83c2f8a81 100644 --- a/backend/internal/assets/access.go +++ b/backend/internal/assets/access.go @@ -31,6 +31,7 @@ const ( type AccessOptions struct { Purpose AccessPurpose `json:"purpose"` Variant ResourceVariant `json:"variant"` + DownloadName string `json:"downloadName,omitempty"` ExpiresAt time.Time `json:"-"` DescribeOnly bool `json:"-"` } @@ -81,6 +82,9 @@ func NormalizeAccessOptions(options AccessOptions, allowProvider bool) (AccessOp if options.Purpose == PurposeCopy || options.Purpose == PurposeDownload || options.Purpose == PurposeProvider { options.Variant = VariantOriginal } + if options.Purpose != PurposeDownload { + options.DownloadName = "" + } return options, nil } @@ -120,6 +124,16 @@ func ResolveAccess(resource *model.Resource, setting storage.Settings, options A } if resource.Provider == "local" { access.Delivery = DeliveryLocal + } 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 + // a signed origin URL carrying Content-Disposition=attachment; media bytes + // never pass through the application server or a Blob relay. + if !storage.PublicOrigin(setting) { + return nil, AccessError(503, "resource_origin_private", "对象存储源站不可由浏览器直连,无法在不占用服务器带宽的前提下下载") + } + access.URL, err = storage.SignedOriginObjectDownloadURL(setting, resource.ObjectKey, expires, options.DownloadName) + access.Delivery = DeliveryOrigin } else if storage.CDNEnabled(setting) { access.URL, err = storage.SignCDNURL(setting, resource.ObjectKey, expires) access.Delivery = DeliveryCDN @@ -134,11 +148,14 @@ func ResolveAccess(resource *model.Resource, setting storage.Settings, options A if setting.CDNBaseURL != "" { access.FallbackReason = "cdn_auth_unconfigured" } - } else if setting.Delivery.AllowPrivateProxy { + } else if options.Purpose == PurposeProvider && setting.Delivery.AllowPrivateProxy { + // Only the server-side provider-input path may opt into a private-origin + // proxy. Browser display/copy/process/download traffic for OSS resources + // must never relay media bytes through the application server. access.Delivery = DeliveryProxy access.FallbackReason = "private_origin" } else { - return nil, AccessError(503, "resource_origin_private", "资源源站不可公网访问,请配置 CDN 或明确启用私网源站代理") + return nil, AccessError(503, "resource_origin_private", "对象存储源站不可由浏览器直连,请配置公网 OSS 源站或 CDN") } if err != nil { failure := AccessError(503, "resource_signing_failed", "资源访问地址签发失败,请检查存储分发设置") diff --git a/backend/internal/assets/access_test.go b/backend/internal/assets/access_test.go index c18580ab2..bf3205451 100644 --- a/backend/internal/assets/access_test.go +++ b/backend/internal/assets/access_test.go @@ -1,6 +1,7 @@ package assets import ( + "net/url" "strings" "testing" "time" @@ -66,10 +67,17 @@ func TestResolveAccessPolicyMatrix(t *testing.T) { wantErr: "resource_cdn_unconfigured", }, { - name: "private origin requires explicit proxy", + name: "browser access never proxies a private OSS origin", resource: testReadyResource("aliyun"), setting: storage.Settings{Provider: "aliyun", Endpoint: "http://storage.internal", Delivery: storage.DeliverySettings{AllowPrivateProxy: true}}, options: AccessOptions{Purpose: PurposeDisplay}, + wantErr: "resource_origin_private", + }, + { + name: "provider input may use an explicitly enabled private proxy", + resource: testReadyResource("aliyun"), + setting: storage.Settings{Provider: "aliyun", Endpoint: "http://storage.internal", Delivery: storage.DeliverySettings{AllowPrivateProxy: true}}, + options: AccessOptions{Purpose: PurposeProvider}, wantMode: DeliveryProxy, wantReason: "private_origin", }, } @@ -124,6 +132,42 @@ func TestResolveAccessVariantAndExpiryContract(t *testing.T) { } } +func TestResolveDownloadUsesObjectOriginAttachmentInsteadOfCDNOrProxy(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", + Delivery: storage.DeliverySettings{CDNAuthMode: "public"}, + } + access, err := ResolveAccess(resource, setting, AccessOptions{Purpose: PurposeDownload, DownloadName: "画布_镜头01.mp4"}, now, testPlatformURL(&[]ResourceVariant{})) + if err != nil { + t.Fatal(err) + } + parsed, err := url.Parse(access.URL) + if err != nil { + 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''") { + t.Fatalf("download access = %#v, disposition=%q", access, disposition) + } +} + +func TestResolveDownloadRejectsPrivateOriginInsteadOfProxyingMediaBytes(t *testing.T) { + setting := storage.Settings{ + Provider: "aliyun", Endpoint: "http://storage.internal", Bucket: "private-bucket", + Delivery: storage.DeliverySettings{AllowPrivateProxy: true}, + } + _, err := ResolveAccess(testReadyResource("aliyun"), setting, AccessOptions{Purpose: PurposeDownload}, time.Now(), testPlatformURL(&[]ResourceVariant{})) + if err == nil || errorReason(err) != "resource_origin_private" { + t.Fatalf("ResolveAccess() error = %v, want resource_origin_private", err) + } +} + func TestResolveAccessRejectsPendingResource(t *testing.T) { resource := testReadyResource("local") resource.Status = model.ResourceStatusPending diff --git a/backend/internal/storage/objects.go b/backend/internal/storage/objects.go index caf462751..ceb19d5b8 100644 --- a/backend/internal/storage/objects.go +++ b/backend/internal/storage/objects.go @@ -16,9 +16,11 @@ import ( "infinite-canvas/backend/internal/kernel" "infinite-canvas/backend/internal/outbound" "io" + "mime" "net" "net/http" "net/url" + "path" "strconv" "strings" "time" @@ -177,7 +179,57 @@ func SignedOriginObjectURL(setting Settings, objectKey string, expiresAt time.Ti return SignedAliyunOSSObjectURL(setting, objectKey, expiresAt) } +// SignedOriginObjectDownloadURL signs a browser-facing object-store URL whose +// response is forced to download by Content-Disposition. This is intentionally +// an origin URL: a generic CDN URL cannot guarantee that response header +// overrides are forwarded and signed correctly. +func SignedOriginObjectDownloadURL(setting Settings, objectKey string, expiresAt time.Time, fileName string) (string, error) { + disposition := objectDownloadContentDisposition(fileName, objectKey) + setting = NormalizeSettings(setting) + if setting.Provider == s3Provider { + return SignedS3ObjectDownloadURL(setting, objectKey, expiresAt, disposition) + } + if setting.Provider == qiniuKodoProvider { + return SignedQiniuS3ObjectDownloadURL(setting, objectKey, expiresAt, disposition) + } + if setting.Provider == tencentCOSProvider { + return SignedCOSObjectDownloadURL(setting, objectKey, expiresAt, disposition) + } + return SignedAliyunOSSObjectDownloadURL(setting, objectKey, expiresAt, disposition) +} + +func objectDownloadContentDisposition(fileName string, objectKey string) string { + name := strings.TrimSpace(fileName) + name = strings.ReplaceAll(name, "\\", "_") + name = strings.ReplaceAll(name, "/", "_") + name = strings.Map(func(char rune) rune { + if char < 0x20 || char == 0x7f { + return -1 + } + return char + }, name) + if name == "" || name == "." { + name = path.Base(strings.TrimSpace(objectKey)) + } + if name == "" || name == "." || name == "/" { + name = "download" + } + value := mime.FormatMediaType("attachment", map[string]string{"filename": name}) + if value == "" { + return `attachment; filename="download"` + } + return value +} + func SignedAliyunOSSObjectURL(setting Settings, objectKey string, expiresAt time.Time) (string, error) { + return signedAliyunOSSObjectURL(setting, objectKey, expiresAt, "") +} + +func SignedAliyunOSSObjectDownloadURL(setting Settings, objectKey string, expiresAt time.Time, disposition string) (string, error) { + return signedAliyunOSSObjectURL(setting, objectKey, expiresAt, disposition) +} + +func signedAliyunOSSObjectURL(setting Settings, objectKey string, expiresAt time.Time, disposition string) (string, error) { baseURL, err := OssBucketBaseURL(setting) if err != nil { return "", err @@ -191,10 +243,15 @@ func SignedAliyunOSSObjectURL(setting Settings, objectKey string, expiresAt time } baseURL.Path = strings.TrimRight(baseURL.Path, "/") + "/" + strings.TrimLeft(objectKey, "/") expires := strconv.FormatInt(expiresAt.UTC().Unix(), 10) - stringToSign := strings.Join([]string{http.MethodGet, "", "", expires, "/" + setting.Bucket + "/" + objectKey}, "\n") + canonicalResource := "/" + setting.Bucket + "/" + objectKey + query := baseURL.Query() + if disposition != "" { + query.Set("response-content-disposition", disposition) + canonicalResource += "?response-content-disposition=" + disposition + } + stringToSign := strings.Join([]string{http.MethodGet, "", "", expires, canonicalResource}, "\n") mac := hmac.New(sha1.New, []byte(setting.AccessKeySecret)) _, _ = mac.Write([]byte(stringToSign)) - query := baseURL.Query() query.Set("OSSAccessKeyId", setting.AccessKeyID) query.Set("Expires", expires) query.Set("Signature", base64.StdEncoding.EncodeToString(mac.Sum(nil))) @@ -284,6 +341,14 @@ func GetQiniuObjectRange(setting Settings, objectKey string, rangeHeader string) } func SignedCOSObjectURL(setting Settings, objectKey string, expiresAt time.Time) (string, error) { + return signedCOSObjectURL(setting, objectKey, expiresAt, "") +} + +func SignedCOSObjectDownloadURL(setting Settings, objectKey string, expiresAt time.Time, disposition string) (string, error) { + return signedCOSObjectURL(setting, objectKey, expiresAt, disposition) +} + +func signedCOSObjectURL(setting Settings, objectKey string, expiresAt time.Time, disposition string) (string, error) { if strings.TrimSpace(setting.AccessKeyID) == "" || strings.TrimSpace(setting.AccessKeySecret) == "" { return "", errors.New("COS 访问密钥不可用") } @@ -299,7 +364,11 @@ func SignedCOSObjectURL(setting Settings, objectKey string, expiresAt time.Time) if err != nil { return "", err } - signedURL, err := client.Object.GetPresignedURL(context.Background(), http.MethodGet, objectKey, setting.AccessKeyID, setting.AccessKeySecret, expires, nil) + var options interface{} + if disposition != "" { + options = &cos.ObjectGetOptions{ResponseContentDisposition: disposition} + } + signedURL, err := client.Object.GetPresignedURL(context.Background(), http.MethodGet, objectKey, setting.AccessKeyID, setting.AccessKeySecret, expires, options) if err != nil { return "", err } @@ -328,6 +397,14 @@ func SignedQiniuObjectURL(setting Settings, objectKey string, expiresAt time.Tim // SignedQiniuS3ObjectURL 用七牛兼容 S3 的 AWS Signature V4 访问私有空间。 // 没有绑定域名时,浏览器不直接访问该地址,而是由后端代理读取并返回文件。 func SignedQiniuS3ObjectURL(setting Settings, objectKey string, expiresAt time.Time) (string, error) { + return signedQiniuS3ObjectURL(setting, objectKey, expiresAt, "") +} + +func SignedQiniuS3ObjectDownloadURL(setting Settings, objectKey string, expiresAt time.Time, disposition string) (string, error) { + return signedQiniuS3ObjectURL(setting, objectKey, expiresAt, disposition) +} + +func signedQiniuS3ObjectURL(setting Settings, objectKey string, expiresAt time.Time, disposition string) (string, error) { region := QiniuS3Region(setting) if region == "" { return "", errors.New("七牛云 Kodo S3 Region 不可用") @@ -335,6 +412,11 @@ func SignedQiniuS3ObjectURL(setting Settings, objectKey string, expiresAt time.T baseURL := &url.URL{Scheme: "https", Host: setting.Bucket + ".s3." + region + ".qiniucs.com"} // 保留对象键的原始路径,让 url.URL 和 AWS signer 只做一次 RFC 3986 转义。 baseURL.Path = "/" + objectKey + if disposition != "" { + query := baseURL.Query() + query.Set("response-content-disposition", disposition) + baseURL.RawQuery = query.Encode() + } req, err := http.NewRequest(http.MethodGet, baseURL.String(), nil) if err != nil { return "", err diff --git a/backend/internal/storage/s3.go b/backend/internal/storage/s3.go index e278286c2..87bdbf07c 100644 --- a/backend/internal/storage/s3.go +++ b/backend/internal/storage/s3.go @@ -135,6 +135,14 @@ func GetS3ObjectRange(setting Settings, objectKey string, rangeHeader string) (* } func SignedS3ObjectURL(setting Settings, objectKey string, expiresAt time.Time) (string, error) { + return signedS3ObjectURL(setting, objectKey, expiresAt, "") +} + +func SignedS3ObjectDownloadURL(setting Settings, objectKey string, expiresAt time.Time, disposition string) (string, error) { + return signedS3ObjectURL(setting, objectKey, expiresAt, disposition) +} + +func signedS3ObjectURL(setting Settings, objectKey string, expiresAt time.Time, disposition string) (string, error) { client, err := NewS3Client(setting, 2*time.Minute) if err != nil { return "", err @@ -143,7 +151,11 @@ func SignedS3ObjectURL(setting Settings, objectKey string, expiresAt time.Time) if duration <= 0 { return "", errors.New("S3 签名有效期必须晚于当前时间") } - req, _ := client.GetObjectRequest(&awss3.GetObjectInput{Bucket: aws.String(setting.Bucket), Key: aws.String(strings.TrimLeft(objectKey, "/"))}) + input := &awss3.GetObjectInput{Bucket: aws.String(setting.Bucket), Key: aws.String(strings.TrimLeft(objectKey, "/"))} + if disposition != "" { + input.ResponseContentDisposition = aws.String(disposition) + } + req, _ := client.GetObjectRequest(input) value, err := req.Presign(duration) if err != nil { return "", fmt.Errorf("S3 下载地址签名失败:%w", err) diff --git a/web/src/services/api/resources.ts b/web/src/services/api/resources.ts index 593752404..cca926d8d 100644 --- a/web/src/services/api/resources.ts +++ b/web/src/services/api/resources.ts @@ -291,17 +291,17 @@ export function refreshResource(id: string): Promise { }); } -export async function getResourceAccess(storageKey: string | undefined, purpose: ResourceAccessPurpose = "display", variant: ResourceAccessVariant = "original") { +export async function getResourceAccess(storageKey: string | undefined, purpose: ResourceAccessPurpose = "display", variant: ResourceAccessVariant = "original", downloadName = "") { const id = resourceIdFromStorageKey(storageKey); if (!id) throw new Error("当前媒体尚未上传到后端资源存储"); - const key = `${resourceCacheKey(id)}:${purpose}:${variant}`; + const key = `${resourceCacheKey(id)}:${purpose}:${variant}:${downloadName}`; const cached = accessCache.get(key); if (cached && cached.expiresAt > Date.now()) return cached.value; const pending = accessRequests.get(key); if (pending) return pending; const request = (async () => { try { - const data = await http.post<{ items: Array<{ resourceId: string; access?: ResourceAccess; error?: { msg?: string } }> }>("/resources/access", [{ resourceId: id, purpose, variant }]); + const data = await http.post<{ items: Array<{ resourceId: string; access?: ResourceAccess; error?: { msg?: string } }> }>("/resources/access", [{ resourceId: id, purpose, variant, ...(downloadName ? { downloadName } : {}) }]); const item = data.items?.[0]; if (!item?.access?.url) throw new Error(item?.error?.msg || "后端未返回资源访问地址"); const value = item.access; diff --git a/web/src/services/browser-download.ts b/web/src/services/browser-download.ts new file mode 100644 index 000000000..697d6c2f2 --- /dev/null +++ b/web/src/services/browser-download.ts @@ -0,0 +1,39 @@ +import { + getResourceAccess, + resolveResourceAccessURL, + resourceIdFromStorageKey, + type ResourceAccessVariant, +} from "@/services/api/resources"; + +export type BrowserMediaDownload = { + fileName: string; + storageKey?: string; + url?: string; + variant?: ResourceAccessVariant; +}; + +/** + * Hand a media download directly to the browser. + * + * Remote resources use the OSS/CDN download access contract so the object + * store sends the bytes and Content-Disposition header. This function must + * not fetch the payload or materialize a Blob: doing so wastes browser memory + * and can accidentally turn the platform into a media relay. + */ +export async function downloadBrowserMedia({ fileName, storageKey, url, variant = "original" }: BrowserMediaDownload) { + const normalizedStorageKey = storageKey?.trim(); + const normalizedUrl = url?.trim(); + const href = normalizedStorageKey && resourceIdFromStorageKey(normalizedStorageKey) + ? resolveResourceAccessURL((await getResourceAccess(normalizedStorageKey, "download", variant, fileName)).url) + : normalizedUrl; + if (!href) throw new Error("媒体资源不存在"); + + const anchor = document.createElement("a"); + anchor.href = href; + anchor.download = fileName; + anchor.rel = "noopener noreferrer"; + anchor.style.display = "none"; + document.body.appendChild(anchor); + anchor.click(); + anchor.remove(); +} diff --git a/web/src/services/file-storage.ts b/web/src/services/file-storage.ts index 265f63694..0593f8d24 100644 --- a/web/src/services/file-storage.ts +++ b/web/src/services/file-storage.ts @@ -3,7 +3,7 @@ import { nanoid } from "nanoid"; import { getActiveUserScope } from "@/lib/user-scope"; import { captureVideoPoster, detectVideoAudioTrackFromBlob } from "@/lib/video-poster"; -import { resourceFileUrl, resourceIdFromStorageKey, resourceStorageKey, ResourceUploadError, uploadResourceFile } from "@/services/api/resources"; +import { getResourceAccess, resolveResourceAccessURL, resourceFileUrl, resourceIdFromStorageKey, resourceStorageKey, ResourceUploadError, uploadResourceFile } from "@/services/api/resources"; import { uploadImage, type UploadedImage } from "@/services/image-storage"; import { getCachedResourceBlob, primeResourceBlobCache } from "@/services/resource-blob-cache"; @@ -115,8 +115,8 @@ export async function resolveMediaUrl(storageKey?: string, fallback = "") { if (!storageKey) return fallback; const resourceId = resourceIdFromStorageKey(storageKey); if (resourceId) { - // 远程资源展示统一走稳定的云端/资源文件地址;Blob 缓存仅服务于字节读取和媒体处理。 - return resourceFileUrl(resourceId); + // 展示直接命中 OSS/CDN;平台资源文件接口只保留给私有源站代理或本地存储兜底。 + return resolveResourceAccessURL((await getResourceAccess(storageKey, "display")).url); } const cached = objectUrls.get(storageKey); if (cached) return cached; diff --git a/web/src/services/image-storage.ts b/web/src/services/image-storage.ts index 832c1980c..eec09818b 100644 --- a/web/src/services/image-storage.ts +++ b/web/src/services/image-storage.ts @@ -3,7 +3,7 @@ import localforage from "localforage"; import { nanoid } from "nanoid"; import { readImageMeta } from "@/lib/image-utils"; import { getActiveUserScope } from "@/lib/user-scope"; -import { importResourceFromUrl, isResourceUrl, resourceFileUrl, resourceIdFromStorageKey, resourceStorageKey, ResourceUploadError, uploadResourceFile } from "@/services/api/resources"; +import { getResourceAccess, importResourceFromUrl, isResourceUrl, resolveResourceAccessURL, resourceFileUrl, resourceIdFromStorageKey, resourceStorageKey, ResourceUploadError, uploadResourceFile } from "@/services/api/resources"; import { getCachedResourceBlob, primeResourceBlobCache } from "@/services/resource-blob-cache"; export type UploadedImage = { @@ -81,8 +81,8 @@ export async function resolveImageUrl(storageKey?: string, fallback = "", option if (!storageKey) return fallback; const resourceId = resourceIdFromStorageKey(storageKey); if (resourceId) { - // 远程资源展示不返回 blob: URL,确保节点、素材库和弹窗都使用云端稳定地址。 - return resourceFileUrl(resourceId); + // 远程资源展示直接使用 OSS/CDN 授权地址,不把媒体内容读进浏览器 Blob。 + return resolveResourceAccessURL((await getResourceAccess(storageKey, "display")).url); } const cached = objectUrls.get(storageKey); if (cached) return cached;