perf(media): OSS 资源 - 通过签名地址直连下载并禁止浏览器代理媒体

This commit is contained in:
Codex Audit
2026-09-23 11:10:20 +08:00
parent 8436904316
commit 7737047a8d
9 changed files with 213 additions and 19 deletions

View File

@@ -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)
}
}

View File

@@ -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", "资源访问地址签发失败,请检查存储分发设置")

View File

@@ -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

View File

@@ -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

View File

@@ -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)

View File

@@ -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;

View 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();
}

View File

@@ -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;

View File

@@ -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;