diff --git a/CHANGELOG.md b/CHANGELOG.md index 6d7753906..55669194f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +- 阿里云 OSS 与腾讯云 COS 的平台、个人存储支持配置可选 CDN 加速域名,上传继续使用 Endpoint,下载与预览在配置后改用 CDN 域名。 + ## v1.0.57 - 修复已有媒体节点再次生成失败时污染原成功节点状态的问题,视频新版本仅在生成成功后切换为主版本。 diff --git a/README.md b/README.md index 2a6eb8160..37ab5774d 100644 --- a/README.md +++ b/README.md @@ -254,7 +254,7 @@ docker compose -f docker-compose.local.yml up -d --build - 用户自定义 AI API Key 保存在浏览器本地;登录态拉取模型目录时会临时提交给自部署后端但不会保存,创建异步任务时会加密入队;仅应使用可信部署,生产环境必须启用 HTTPS。 - 画布和素材登录后同步到后端,本地 `localForage` 继续承担缓存和降级存储。 -- 媒体资源在启用对象存储时可保存到私有阿里云 OSS 或腾讯云 COS,否则保存到后端数据目录;删除业务记录不会自动清理远端对象。 +- 媒体资源在启用对象存储时可保存到私有阿里云 OSS 或腾讯云 COS,否则保存到后端数据目录;两种对象存储均可选填 CDN 加速域名,让上传继续使用 Endpoint、下载与预览使用 CDN;删除业务记录不会自动清理远端对象。 - 用户主动上传、Agent 会话附件和 AI 生成资源的单文件上限、账号容量及 UTC 日上传总量由后台“资源与策略”统一维护,默认分别为 50MB、32MB、64MB、2GB 和 200MB;管理员可按可信部署需要调整,单文件业务上限最高 999MB,Nginx 请求体硬上限为 1024MB。 ## 公网部署安全 diff --git a/backend/internal/service/resource.go b/backend/internal/service/resource.go index 9f782abf6..27636851e 100644 --- a/backend/internal/service/resource.go +++ b/backend/internal/service/resource.go @@ -747,7 +747,11 @@ type ossObjectStream struct { } func getOSSObjectRange(setting ossSettingValue, objectKey string, rangeHeader string) (*ossObjectStream, error) { - if normalizeOSSSetting(setting).Provider == tencentCOSProvider { + setting = normalizeOSSSetting(setting) + if setting.CDNBaseURL != "" { + return getOSSObjectRangeViaCDN(setting, objectKey, rangeHeader) + } + if setting.Provider == tencentCOSProvider { return getCOSObjectRange(setting, objectKey, rangeHeader) } return getAliyunOSSObjectRange(setting, objectKey, rangeHeader) @@ -795,7 +799,11 @@ func decimalDigits(value string) bool { } func signedOSSObjectURL(setting ossSettingValue, objectKey string, expiresAt time.Time) (string, error) { - if normalizeOSSSetting(setting).Provider == tencentCOSProvider { + setting = normalizeOSSSetting(setting) + if setting.CDNBaseURL != "" { + return ossCDNObjectURL(setting.CDNBaseURL, objectKey) + } + if setting.Provider == tencentCOSProvider { return signedCOSObjectURL(setting, objectKey, expiresAt) } return signedAliyunOSSObjectURL(setting, objectKey, expiresAt) @@ -858,6 +866,31 @@ func getCOSObjectRange(setting ossSettingValue, objectKey string, rangeHeader st return &ossObjectStream{body: resp.Body, statusCode: resp.StatusCode, contentLength: resp.ContentLength, contentRange: resp.Header.Get("Content-Range"), acceptRanges: firstNonEmpty(resp.Header.Get("Accept-Ranges"), "bytes")}, nil } +func getOSSObjectRangeViaCDN(setting ossSettingValue, objectKey string, rangeHeader string) (*ossObjectStream, error) { + signedURL, err := signedOSSObjectURL(setting, objectKey, time.Now().Add(directResourceURLTTL)) + if err != nil { + return nil, err + } + req, err := http.NewRequest(http.MethodGet, signedURL, nil) + if err != nil { + return nil, err + } + if rangeHeader != "" { + req.Header.Set("Range", rangeHeader) + } + ApplyDefaultOutboundHeaders(req) + resp, err := OutboundHTTPClient(2 * time.Minute).Do(req) + if err != nil { + return nil, fmt.Errorf("对象存储 CDN 读取失败:%w", err) + } + if (resp.StatusCode < 200 || resp.StatusCode >= 300) && resp.StatusCode != http.StatusRequestedRangeNotSatisfiable { + defer resp.Body.Close() + detail, _ := io.ReadAll(io.LimitReader(resp.Body, 512)) + return nil, fmt.Errorf("对象存储 CDN 读取失败:%s %s", resp.Status, strings.TrimSpace(string(detail))) + } + return &ossObjectStream{body: resp.Body, statusCode: resp.StatusCode, contentLength: resp.ContentLength, contentRange: resp.Header.Get("Content-Range"), acceptRanges: firstNonEmpty(resp.Header.Get("Accept-Ranges"), "bytes")}, nil +} + func signedCOSObjectURL(setting ossSettingValue, objectKey string, expiresAt time.Time) (string, error) { if strings.TrimSpace(setting.AccessKeyID) == "" || strings.TrimSpace(setting.AccessKeySecret) == "" { return "", errors.New("COS 访问密钥不可用") @@ -921,6 +954,35 @@ func cosBucketBaseURL(setting ossSettingValue) (*url.URL, error) { return parsed, nil } +func ossCDNBaseURL(raw string) (*url.URL, error) { + parsed, err := url.Parse(strings.TrimSpace(raw)) + if err != nil || parsed.Hostname() == "" { + return nil, errors.New("对象存储 CDN 加速域名格式不正确") + } + if parsed.Scheme != "https" && parsed.Scheme != "http" { + return nil, errors.New("对象存储 CDN 加速域名只支持 http/https") + } + if parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" || strings.Trim(parsed.Path, "/") != "" { + return nil, errors.New("对象存储 CDN 加速域名不能包含认证信息、路径、查询参数或片段") + } + parsed.Path = "" + return parsed, nil +} + +func ossCDNObjectURL(raw string, objectKey string) (string, error) { + baseURL, err := ossCDNBaseURL(raw) + if err != nil { + return "", err + } + objectKey = strings.TrimLeft(strings.TrimSpace(objectKey), "/") + if objectKey == "" { + return "", errors.New("对象存储对象路径为空") + } + // CDN 使用自己的访问鉴权与私有桶回源鉴权,不能携带 OSS/COS 的预签名参数。 + baseURL.Path = "/" + escapeObjectKey(objectKey) + return baseURL.String(), nil +} + func newOSSRequest(method string, setting ossSettingValue, objectKey string, contentType string, body io.Reader) (*http.Request, error) { baseURL, err := ossBucketBaseURL(setting) if err != nil { diff --git a/backend/internal/service/resource_test.go b/backend/internal/service/resource_test.go index e6f8dd432..e56bf0ca1 100644 --- a/backend/internal/service/resource_test.go +++ b/backend/internal/service/resource_test.go @@ -63,6 +63,53 @@ func TestSignedOSSObjectURLSupportsTencentCOS(t *testing.T) { } } +func TestSignedOSSObjectURLUsesAliyunCDNBaseURLForDownloads(t *testing.T) { + value, err := signedOSSObjectURL(ossSettingValue{ + Provider: aliyunOSSProvider, Endpoint: "https://oss-cn-test.aliyuncs.com", CDNBaseURL: "https://media.example.com", + Bucket: "private-bucket", AccessKeyID: "access-id", AccessKeySecret: "secret-value", + }, "users/u-1/image/test image.png", time.Now().Add(time.Hour)) + if err != nil { + t.Fatalf("signedOSSObjectURL() error = %v", 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" || parsed.RawQuery != "" { + t.Fatalf("Aliyun OSS CDN URL = %q", value) + } +} + +func TestAliyunOSSUploadRequestStillUsesEndpointWhenCDNConfigured(t *testing.T) { + req, err := newOSSRequest(http.MethodPut, ossSettingValue{ + Provider: aliyunOSSProvider, Endpoint: "https://oss-cn-test.aliyuncs.com", CDNBaseURL: "https://media.example.com", + Bucket: "private-bucket", AccessKeyID: "access-id", AccessKeySecret: "secret-value", + }, "users/u-1/image/test.png", "image/png", strings.NewReader("payload")) + if err != nil { + t.Fatal(err) + } + if req.URL.Host != "private-bucket.oss-cn-test.aliyuncs.com" || req.URL.Path != "/users/u-1/image/test.png" { + t.Fatalf("Aliyun OSS upload URL = %q", req.URL.String()) + } +} + +func TestSignedOSSObjectURLUsesTencentCOSCDNBaseURLWithoutCOSSignature(t *testing.T) { + value, err := signedOSSObjectURL(ossSettingValue{ + Provider: tencentCOSProvider, Region: "ap-guangzhou", Bucket: "private-bucket-1250000000", + CDNBaseURL: "https://media.example.com", AccessKeyID: "secret-id", AccessKeySecret: "secret-key", + }, "users/u-1/image/test image.png", time.Now().Add(time.Hour)) + if err != nil { + t.Fatalf("signedOSSObjectURL() error = %v", 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" || parsed.RawQuery != "" { + t.Fatalf("signed COS CDN URL = %q", value) + } +} + func TestPutOSSObjectSupportsTencentCOS(t *testing.T) { t.Setenv("CANVAS_ALLOW_PRIVATE_UPSTREAMS", "true") payload := []byte("cos upload payload") @@ -92,7 +139,7 @@ func TestPutOSSObjectSupportsTencentCOS(t *testing.T) { etag, err := putOSSObject(ossSettingValue{ Provider: tencentCOSProvider, Endpoint: server.URL, Bucket: "private-bucket-1250000000", - AccessKeyID: "secret-id", AccessKeySecret: "secret-key", + CDNBaseURL: "https://media.example.com", AccessKeyID: "secret-id", AccessKeySecret: "secret-key", }, "users/u-1/image/test.png", "image/png", int64(len(payload)), bytes.NewReader(payload)) if err != nil { t.Fatal(err) @@ -136,6 +183,72 @@ func TestGetOSSObjectRangeSupportsTencentCOS(t *testing.T) { } } +func TestGetOSSObjectRangeUsesTencentCOSCDNBaseURL(t *testing.T) { + t.Setenv("CANVAS_ALLOW_PRIVATE_UPSTREAMS", "true") + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Range") != "bytes=0-3" { + t.Errorf("Range = %q", r.Header.Get("Range")) + } + if r.Header.Get("Authorization") != "" || r.URL.RawQuery != "" { + t.Errorf("Tencent CDN request should not carry COS authentication: header %q, query %q", r.Header.Get("Authorization"), r.URL.RawQuery) + } + w.Header().Set("Accept-Ranges", "bytes") + w.Header().Set("Content-Range", "bytes 0-3/7") + w.WriteHeader(http.StatusPartialContent) + _, _ = w.Write([]byte("data")) + })) + defer server.Close() + + stream, err := getOSSObjectRange(ossSettingValue{ + Provider: tencentCOSProvider, Endpoint: "https://cos.ap-guangzhou.myqcloud.com", CDNBaseURL: server.URL, + Bucket: "private-bucket-1250000000", AccessKeyID: "secret-id", AccessKeySecret: "secret-key", + }, "users/u-1/image/test.png", "bytes=0-3") + if err != nil { + t.Fatal(err) + } + defer stream.body.Close() + data, err := io.ReadAll(stream.body) + if err != nil { + t.Fatal(err) + } + if stream.statusCode != http.StatusPartialContent || stream.contentRange != "bytes 0-3/7" || string(data) != "data" { + t.Fatalf("stream = %#v, data = %q", stream, data) + } +} + +func TestGetOSSObjectRangeUsesAliyunCDNBaseURLWithoutOSSSignature(t *testing.T) { + t.Setenv("CANVAS_ALLOW_PRIVATE_UPSTREAMS", "true") + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Range") != "bytes=0-3" { + t.Errorf("Range = %q", r.Header.Get("Range")) + } + if r.Header.Get("Authorization") != "" || r.URL.RawQuery != "" { + t.Errorf("Aliyun CDN request should not carry OSS authentication: header %q, query %q", r.Header.Get("Authorization"), r.URL.RawQuery) + } + w.Header().Set("Accept-Ranges", "bytes") + w.Header().Set("Content-Range", "bytes 0-3/7") + w.WriteHeader(http.StatusPartialContent) + _, _ = w.Write([]byte("data")) + })) + defer server.Close() + + stream, err := getOSSObjectRange(ossSettingValue{ + Provider: aliyunOSSProvider, Endpoint: "https://oss-cn-test.aliyuncs.com", CDNBaseURL: server.URL, + Bucket: "private-bucket", AccessKeyID: "access-id", AccessKeySecret: "secret-value", + }, "users/u-1/image/test.png", "bytes=0-3") + if err != nil { + t.Fatal(err) + } + defer stream.body.Close() + data, err := io.ReadAll(stream.body) + if err != nil { + t.Fatal(err) + } + if stream.statusCode != http.StatusPartialContent || stream.contentRange != "bytes 0-3/7" || string(data) != "data" { + t.Fatalf("stream = %#v, data = %q", stream, data) + } +} + func TestTencentCOSSettingDerivesEndpointAndDoesNotReuseAliyunSecret(t *testing.T) { normalized := normalizeOSSSetting(ossSettingValue{Provider: tencentCOSProvider, Region: "ap-shanghai"}) if normalized.Endpoint != "https://cos.ap-shanghai.myqcloud.com" { @@ -151,6 +264,34 @@ func TestTencentCOSSettingDerivesEndpointAndDoesNotReuseAliyunSecret(t *testing. if err == nil || !strings.Contains(err.Error(), "AccessKey Secret") { t.Fatalf("ossSettingFromRequest() error = %v", err) } + next, err := ossSettingFromRequest(OSSSettingRequest{ + Enabled: true, Provider: tencentCOSProvider, Endpoint: server.URL, CDNBaseURL: server.URL, + Bucket: "private-bucket-1250000000", AccessKeyID: "secret-id", AccessKeySecret: "secret-key", + }, ossSettingValue{}) + if err != nil || next.CDNBaseURL != server.URL { + t.Fatalf("Tencent COS CDN setting = %#v, %v", next, err) + } +} + +func TestAliyunOSSSettingKeepsCDNBaseURL(t *testing.T) { + t.Setenv("CANVAS_ALLOW_PRIVATE_UPSTREAMS", "true") + server := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})) + defer server.Close() + next, err := ossSettingFromRequest(OSSSettingRequest{ + Enabled: true, Provider: aliyunOSSProvider, Endpoint: server.URL, CDNBaseURL: server.URL, + Bucket: "private-bucket", AccessKeyID: "access-id", AccessKeySecret: "secret-value", + }, ossSettingValue{}) + if err != nil || next.CDNBaseURL != server.URL { + t.Fatalf("Aliyun OSS CDN setting = %#v, %v", next, err) + } +} + +func TestOSSCDNBaseURLRejectsNonDomainParts(t *testing.T) { + for _, value := range []string{"ftp://media.example.com", "https://media.example.com/assets", "https://media.example.com?token=value", "https://user@media.example.com"} { + if _, err := ossCDNBaseURL(value); err == nil { + t.Fatalf("ossCDNBaseURL(%q) should fail", value) + } + } } func TestPlatformProviderSwitchKeepsHistoricalCredentials(t *testing.T) { diff --git a/backend/internal/service/settings.go b/backend/internal/service/settings.go index 86cc5dfc7..020d93130 100644 --- a/backend/internal/service/settings.go +++ b/backend/internal/service/settings.go @@ -32,6 +32,7 @@ type OSSSettingRequest struct { Provider string `json:"provider"` Region string `json:"region"` Endpoint string `json:"endpoint"` + CDNBaseURL string `json:"cdnBaseUrl"` Bucket string `json:"bucket"` AccessKeyID string `json:"accessKeyId"` AccessKeySecret string `json:"accessKeySecret"` @@ -44,6 +45,7 @@ type PublicOSSSetting struct { Provider string `json:"provider"` Region string `json:"region"` Endpoint string `json:"endpoint"` + CDNBaseURL string `json:"cdnBaseUrl"` Bucket string `json:"bucket"` AccessKeyID string `json:"accessKeyId"` HasAccessKeySecret bool `json:"hasAccessKeySecret"` @@ -59,6 +61,7 @@ type ossSettingValue struct { Provider string `json:"provider"` Region string `json:"region"` Endpoint string `json:"endpoint"` + CDNBaseURL string `json:"cdnBaseUrl"` Bucket string `json:"bucket"` AccessKeyID string `json:"accessKeyId"` AccessKeySecret string `json:"accessKeySecret"` @@ -447,6 +450,7 @@ func ossSettingFromRequest(req OSSSettingRequest, current ossSettingValue) (ossS Provider: strings.TrimSpace(req.Provider), Region: strings.TrimSpace(req.Region), Endpoint: strings.TrimRight(strings.TrimSpace(req.Endpoint), "/"), + CDNBaseURL: strings.TrimRight(strings.TrimSpace(req.CDNBaseURL), "/"), Bucket: strings.TrimSpace(req.Bucket), AccessKeyID: strings.TrimSpace(req.AccessKeyID), AccessKeySecret: strings.TrimSpace(req.AccessKeySecret), @@ -474,6 +478,14 @@ func ossSettingFromRequest(req OSSSettingRequest, current ossSettingValue) (ossS if _, err := ValidateOutboundURL(next.Endpoint); err != nil { return next, err } + if next.CDNBaseURL != "" { + if _, err := ossCDNBaseURL(next.CDNBaseURL); err != nil { + return next, BadAuthRequest(err.Error()) + } + if _, err := ValidateOutboundURL(next.CDNBaseURL); err != nil { + return next, err + } + } if next.AccessKeyID == "" { return next, BadAuthRequest("请填写 AccessKey ID") } @@ -520,6 +532,7 @@ func normalizeOSSSetting(value ossSettingValue) ossSettingValue { if value.Provider == tencentCOSProvider && value.Endpoint == "" && value.Region != "" { value.Endpoint = "https://cos." + value.Region + ".myqcloud.com" } + value.CDNBaseURL = strings.TrimRight(strings.TrimSpace(value.CDNBaseURL), "/") value.Bucket = strings.TrimSpace(value.Bucket) value.AccessKeyID = strings.TrimSpace(value.AccessKeyID) value.AccessKeySecret = strings.TrimSpace(value.AccessKeySecret) @@ -539,6 +552,7 @@ func publicOSSSetting(setting *model.SystemSetting, value ossSettingValue) Publi Provider: value.Provider, Region: value.Region, Endpoint: value.Endpoint, + CDNBaseURL: value.CDNBaseURL, Bucket: value.Bucket, AccessKeyID: value.AccessKeyID, HasAccessKeySecret: strings.TrimSpace(value.AccessKeySecret) != "", @@ -559,6 +573,7 @@ func publicUserOSSSetting(setting *model.UserOSSSetting, value ossSettingValue) Provider: value.Provider, Region: value.Region, Endpoint: value.Endpoint, + CDNBaseURL: value.CDNBaseURL, Bucket: value.Bucket, AccessKeyID: value.AccessKeyID, HasAccessKeySecret: strings.TrimSpace(value.AccessKeySecret) != "", diff --git a/web/src/components/layout/user-oss-settings-form.tsx b/web/src/components/layout/user-oss-settings-form.tsx index fd65df0d8..13fd697a9 100644 --- a/web/src/components/layout/user-oss-settings-form.tsx +++ b/web/src/components/layout/user-oss-settings-form.tsx @@ -10,6 +10,7 @@ type OSSFormValues = { provider: "aliyun" | "tencent"; region?: string; endpoint?: string; + cdnBaseUrl?: string; bucket?: string; accessKeyId?: string; accessKeySecret?: string; @@ -60,6 +61,7 @@ export function UserOSSSettingsForm() { provider: values.provider || "aliyun", region: values.region?.trim() || "", endpoint: values.endpoint?.trim() || "", + cdnBaseUrl: values.cdnBaseUrl?.trim() || "", bucket: values.bucket?.trim() || "", accessKeyId: values.accessKeyId?.trim() || "", accessKeySecret: values.accessKeySecret?.trim() || "", @@ -101,7 +103,7 @@ export function UserOSSSettingsForm() { + + + @@ -148,6 +161,7 @@ function toFormValues(setting: UserOSSSetting): OSSFormValues { provider: setting.provider || "aliyun", region: setting.region, endpoint: setting.endpoint, + cdnBaseUrl: setting.cdnBaseUrl, bucket: setting.bucket, accessKeyId: setting.accessKeyId, accessKeySecret: "", diff --git a/web/src/pages/admin/settings/storage-settings-page.tsx b/web/src/pages/admin/settings/storage-settings-page.tsx index 703e59a50..82250f093 100644 --- a/web/src/pages/admin/settings/storage-settings-page.tsx +++ b/web/src/pages/admin/settings/storage-settings-page.tsx @@ -8,7 +8,7 @@ import { AdminPageFrame } from "../components/admin-shell"; import { configuredSecretText, SettingsSectionCard } from "../components/admin-ui"; type StorageMode = "local" | "aliyun" | "tencent"; -type OSSFormValues = { mode: StorageMode; publicBaseUrl?: string; region?: string; endpoint?: string; bucket?: string; accessKeyId?: string; accessKeySecret?: string; pathPrefix?: string }; +type OSSFormValues = { mode: StorageMode; publicBaseUrl?: string; region?: string; endpoint?: string; cdnBaseUrl?: string; bucket?: string; accessKeyId?: string; accessKeySecret?: string; pathPrefix?: string }; export default function StorageSettingsPage() { const { message } = App.useApp(); @@ -45,7 +45,7 @@ export default function StorageSettingsPage() { if (values.mode !== "local" && !values.accessKeyId?.trim()) return message.error(`请填写${values.mode === "tencent" ? " SecretId" : " AccessKey ID"}`); setSaving(true); try { - const result = await updateAdminOSSSetting({ enabled: values.mode !== "local", provider: values.mode === "local" ? setting?.provider || "aliyun" : values.mode, region: values.region?.trim() || "", endpoint: values.endpoint?.trim() || "", bucket: values.bucket?.trim() || "", accessKeyId: values.accessKeyId?.trim() || "", accessKeySecret: values.accessKeySecret?.trim() || "", publicBaseUrl: values.publicBaseUrl?.trim() || "", pathPrefix: values.pathPrefix?.trim() || "" }); + const result = await updateAdminOSSSetting({ enabled: values.mode !== "local", provider: values.mode === "local" ? setting?.provider || "aliyun" : values.mode, region: values.region?.trim() || "", endpoint: values.endpoint?.trim() || "", cdnBaseUrl: values.cdnBaseUrl?.trim() || "", bucket: values.bucket?.trim() || "", accessKeyId: values.accessKeyId?.trim() || "", accessKeySecret: values.accessKeySecret?.trim() || "", publicBaseUrl: values.publicBaseUrl?.trim() || "", pathPrefix: values.pathPrefix?.trim() || "" }); setSetting(result.setting); form.setFieldsValue(formValues(result.setting)); message.success("存储配置已保存"); @@ -82,7 +82,7 @@ export default function StorageSettingsPage() { onChange={(value) => { const nextMode = value as StorageMode; const switchingProvider = nextMode !== "local" && ((mode !== "local" && mode !== nextMode) || (mode === "local" && setting?.provider !== nextMode)); - if (switchingProvider) form.setFieldsValue({ region: "", endpoint: "", bucket: "", accessKeyId: "", accessKeySecret: "" }); + if (switchingProvider) form.setFieldsValue({ region: "", endpoint: "", cdnBaseUrl: "", bucket: "", accessKeyId: "", accessKeySecret: "" }); }} /> @@ -90,6 +90,9 @@ export default function StorageSettingsPage() { <> + + + @@ -130,7 +133,7 @@ export default function StorageSettingsPage() { ); } -function formValues(setting?: AdminOSSSetting | null): OSSFormValues { return { mode: setting?.enabled ? setting.provider === "tencent" ? "tencent" : "aliyun" : "local", publicBaseUrl: setting?.publicBaseUrl || "", region: setting?.region || "", endpoint: setting?.endpoint || "", bucket: setting?.bucket || "", accessKeyId: setting?.accessKeyId || "", accessKeySecret: "", pathPrefix: setting?.pathPrefix || "" }; } +function formValues(setting?: AdminOSSSetting | null): OSSFormValues { return { mode: setting?.enabled ? setting.provider === "tencent" ? "tencent" : "aliyun" : "local", publicBaseUrl: setting?.publicBaseUrl || "", region: setting?.region || "", endpoint: setting?.endpoint || "", cdnBaseUrl: setting?.cdnBaseUrl || "", bucket: setting?.bucket || "", accessKeyId: setting?.accessKeyId || "", accessKeySecret: "", pathPrefix: setting?.pathPrefix || "" }; } function storageProviderLabel(provider?: AdminOSSSetting["provider"]) { return provider === "tencent" ? "腾讯云 COS" : "阿里云 OSS"; } function formatTime(value?: string) { return value ? new Date(value).toLocaleString("zh-CN", { hour12: false }) : "--"; } function Notice({ icon, text }: { icon: ReactNode; text: string }) { return
{icon}{text}
; } diff --git a/web/src/services/api/auth.ts b/web/src/services/api/auth.ts index 5d3467fa9..c9d123855 100644 --- a/web/src/services/api/auth.ts +++ b/web/src/services/api/auth.ts @@ -252,6 +252,7 @@ export type AdminOSSSetting = { provider: "aliyun" | "tencent"; region: string; endpoint: string; + cdnBaseUrl: string; bucket: string; accessKeyId: string; accessKeySecret?: string; diff --git a/web/src/services/api/resources.ts b/web/src/services/api/resources.ts index 840bc660d..33d6dc49f 100644 --- a/web/src/services/api/resources.ts +++ b/web/src/services/api/resources.ts @@ -28,6 +28,7 @@ export type UserOSSSetting = { provider: "aliyun" | "tencent"; region: string; endpoint: string; + cdnBaseUrl: string; bucket: string; accessKeyId: string; hasAccessKeySecret: boolean; @@ -36,7 +37,7 @@ export type UserOSSSetting = { updatedAt?: string; }; -export type UserOSSSettingInput = Pick & { +export type UserOSSSettingInput = Pick & { accessKeySecret?: string; };