fix(media): 对象存储 CDN - 修复对象路径编码并明确访问控制边界

This commit is contained in:
ddCat
2026-08-15 10:00:26 +08:00
parent 3e8f1f1d9e
commit 8f2232feee
5 changed files with 10 additions and 8 deletions

View File

@@ -254,7 +254,7 @@ docker compose -f docker-compose.local.yml up -d --build
- 用户自定义 AI API Key 保存在浏览器本地;登录态拉取模型目录时会临时提交给自部署后端但不会保存,创建异步任务时会加密入队;仅应使用可信部署,生产环境必须启用 HTTPS。
- 画布和素材登录后同步到后端,本地 `localForage` 继续承担缓存和降级存储。
- 媒体资源在启用对象存储时可保存到私有阿里云 OSS 或腾讯云 COS,否则保存到后端数据目录;两种对象存储均可选填 CDN 加速域名,让上传继续使用 Endpoint、下载与预览使用 CDN;删除业务记录不会自动清理远端对象。
- 媒体资源在启用对象存储时可保存到私有阿里云 OSS 或腾讯云 COS,否则保存到后端数据目录;两种对象存储均可选填 CDN 加速域名,让上传继续使用 Endpoint、下载与预览使用 CDN。CDN 地址不携带 OSS/COS 签名,敏感资源必须在 CDN 侧配置 URL 鉴权;删除业务记录不会自动清理远端对象。
- 用户主动上传、Agent 会话附件和 AI 生成资源的单文件上限、账号容量及 UTC 日上传总量由后台“资源与策略”统一维护,默认分别为 50MB、32MB、64MB、2GB 和 200MB;管理员可按可信部署需要调整,单文件业务上限最高 999MB,Nginx 请求体硬上限为 1024MB。
## 公网部署安全

View File

@@ -979,7 +979,8 @@ func ossCDNObjectURL(raw string, objectKey string) (string, error) {
return "", errors.New("对象存储对象路径为空")
}
// CDN 使用自己的访问鉴权与私有桶回源鉴权,不能携带 OSS/COS 的预签名参数。
baseURL.Path = "/" + escapeObjectKey(objectKey)
// url.URL.String 会负责转义 Path;这里保留未转义值,避免把 %20 再编码为 %2520。
baseURL.Path = "/" + objectKey
return baseURL.String(), nil
}

View File

@@ -75,7 +75,7 @@ func TestSignedOSSObjectURLUsesAliyunCDNBaseURLForDownloads(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if parsed.Host != "media.example.com" || parsed.Path != "/users/u-1/image/test image.png" || parsed.RawQuery != "" {
if parsed.Host != "media.example.com" || parsed.Path != "/users/u-1/image/test image.png" || parsed.RawQuery != "" || !strings.Contains(value, "test%20image.png") {
t.Fatalf("Aliyun OSS CDN URL = %q", value)
}
}
@@ -105,7 +105,7 @@ func TestSignedOSSObjectURLUsesTencentCOSCDNBaseURLWithoutCOSSignature(t *testin
if err != nil {
t.Fatal(err)
}
if parsed.Host != "media.example.com" || parsed.Path != "/users/u-1/image/test image.png" || parsed.RawQuery != "" {
if parsed.Host != "media.example.com" || parsed.Path != "/users/u-1/image/test image.png" || parsed.RawQuery != "" || !strings.Contains(value, "test%20image.png") {
t.Fatalf("signed COS CDN URL = %q", value)
}
}

View File

@@ -117,8 +117,8 @@ export function UserOSSSettingsForm() {
name="cdnBaseUrl"
label="CDN 加速域名"
extra={isTencentCOS
? "选填。上传仍走 Endpoint,下载与预览改走 CDN;私有桶需开启 CDN 私有存储桶访问,CDN URL 按官方要求不附带 COS 签名。"
: "选填。上传仍走 Endpoint,下载与预览改走 CDN;阿里云私有 Bucket 需开启 CDN 私有 Bucket 回源,CDN URL 按官方要求不附带 OSS 签名。"}
? "选填。上传仍走 Endpoint,下载与预览改走 CDN;私有桶需开启 CDN 私有存储桶访问。CDN URL 不附带 COS 签名,未配置 CDN URL 鉴权时链接将长期可访问。"
: "选填。上传仍走 Endpoint,下载与预览改走 CDN;阿里云私有 Bucket 需开启 CDN 私有 Bucket 回源。CDN URL 不附带 OSS 签名,未配置 CDN URL 鉴权时链接将长期可访问。"}
rules={[{ type: "url", message: "请填写完整的 http/https CDN 加速域名" }]}
className="mb-3"
>

View File

@@ -18,6 +18,7 @@ export default function StorageSettingsPage() {
const [saving, setSaving] = useState(false);
const [form] = Form.useForm<OSSFormValues>();
const mode = Form.useWatch("mode", form) || "local";
const cdnBaseUrl = Form.useWatch("cdnBaseUrl", form);
const isObjectStorage = mode !== "local";
const isTencentCOS = mode === "tencent";
const selectedProviderLabel = isTencentCOS ? "腾讯云 COS" : "阿里云 OSS";
@@ -90,7 +91,7 @@ export default function StorageSettingsPage() {
<>
<Form.Item name="region" label="Region"><Input autoComplete="off" placeholder={isTencentCOS ? "例如:ap-guangzhou" : "例如:oss-cn-hangzhou"} /></Form.Item>
<Form.Item name="endpoint" label="Endpoint" extra={isTencentCOS ? "可留空,系统会根据 Region 生成标准 COS Endpoint。" : undefined}><Input autoComplete="off" placeholder={isTencentCOS ? "https://cos.ap-guangzhou.myqcloud.com" : "https://oss-cn-hangzhou.aliyuncs.com"} /></Form.Item>
<Form.Item name="cdnBaseUrl" label="CDN 加速域名" extra={isTencentCOS ? "选填。上传仍走 Endpoint,下载与预览改走 CDN;私有桶需开启 CDN 私有存储桶访问,CDN URL 按官方要求不附带 COS 签名。" : "选填。上传仍走 Endpoint,下载与预览改走 CDN;阿里云私有 Bucket 需开启 CDN 私有 Bucket 回源,CDN URL 按官方要求不附带 OSS 签名。"} rules={[{ type: "url", message: "请填写完整的 http/https CDN 加速域名" }]}>
<Form.Item name="cdnBaseUrl" label="CDN 加速域名" extra={isTencentCOS ? "选填。上传仍走 Endpoint,下载与预览改走 CDN;私有桶需开启 CDN 私有存储桶访问。CDN URL 不附带 COS 签名,未配置 CDN URL 鉴权时链接将长期可访问。" : "选填。上传仍走 Endpoint,下载与预览改走 CDN;阿里云私有 Bucket 需开启 CDN 私有 Bucket 回源。CDN URL 不附带 OSS 签名,未配置 CDN URL 鉴权时链接将长期可访问。"} rules={[{ type: "url", message: "请填写完整的 http/https CDN 加速域名" }]}>
<Input autoComplete="off" inputMode="url" placeholder="https://media.example.com" />
</Form.Item>
<Form.Item name="bucket" label="Bucket"><Input autoComplete="off" placeholder={isTencentCOS ? "例如:my-canvas-assets-1250000000" : "例如:my-canvas-assets"} /></Form.Item>
@@ -127,7 +128,7 @@ export default function StorageSettingsPage() {
</div>
</Form>
</SettingsSectionCard>
<div className="grid border-y border-border text-xs text-foreground/55 sm:grid-cols-3 sm:divide-x sm:divide-border"><Notice icon={isObjectStorage ? <Cloud className="size-3.5" /> : <HardDrive className="size-3.5" />} text={isObjectStorage ? `新资源写入${selectedProviderLabel}` : "新资源写入服务器数据卷"} /><Notice icon={<ShieldCheck className="size-3.5" />} text="历史资源位置保持不变" /><Notice icon={<KeyRound className="size-3.5" />} text="外部链接仅在签名有效期内可用" /></div>
<div className="grid border-y border-border text-xs text-foreground/55 sm:grid-cols-3 sm:divide-x sm:divide-border"><Notice icon={isObjectStorage ? <Cloud className="size-3.5" /> : <HardDrive className="size-3.5" />} text={isObjectStorage ? `新资源写入${selectedProviderLabel}` : "新资源写入服务器数据卷"} /><Notice icon={<ShieldCheck className="size-3.5" />} text="历史资源位置保持不变" /><Notice icon={<KeyRound className="size-3.5" />} text={isObjectStorage && cdnBaseUrl?.trim() ? "CDN 链接依赖 CDN 自身访问控制" : "外部链接仅在签名有效期内可用"} /></div>
</div>
</AdminPageFrame>
);