perf(media): OSS 资源 - 通过签名地址直连下载并禁止浏览器代理媒体
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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", "资源访问地址签发失败,请检查存储分发设置")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -291,17 +291,17 @@ export function refreshResource(id: string): Promise<RemoteResource> {
|
||||
});
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
39
web/src/services/browser-download.ts
Normal file
39
web/src/services/browser-download.ts
Normal file
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user