fix(media): 对象存储 CDN - 统一资源预览与下载走 CDN
统一登录资源、公开画布分享和管理后台媒体的 CDN 预览与下载出口,并保留同源代理读取能力。
This commit is contained in:
@@ -2,6 +2,8 @@
|
||||
|
||||
## Unreleased
|
||||
|
||||
- 修复任务中心等资源入口仍由应用服务器转发对象存储媒体的问题:配置 CDN 加速域名后,登录资源、公开画布分享和管理员媒体预览统一重定向到 CDN;内部 Blob 读取保留同源代理兼容。
|
||||
|
||||
## v1.0.59
|
||||
|
||||
- 画布媒体工作流支持批量连线、失败重试与模型能力增强。
|
||||
|
||||
@@ -258,7 +258,7 @@ docker compose -f docker-compose.local.yml up -d --build
|
||||
|
||||
- 用户自定义 AI API Key 保存在浏览器本地;登录态拉取模型目录时会临时提交给自部署后端但不会保存,创建异步任务时会加密入队;仅应使用可信部署,生产环境必须启用 HTTPS。
|
||||
- 画布和素材登录后同步到后端,本地 `localForage` 继续承担缓存和降级存储。
|
||||
- 媒体资源在启用对象存储时可保存到私有阿里云 OSS 或腾讯云 COS,否则保存到后端数据目录;两种对象存储均可选填 CDN 加速域名,让上传继续使用 Endpoint、下载与预览使用 CDN。CDN 地址不携带 OSS/COS 签名,敏感资源必须在 CDN 侧配置 URL 鉴权;删除业务记录不会自动清理远端对象。
|
||||
- 媒体资源在启用对象存储时可保存到私有阿里云 OSS 或腾讯云 COS,否则保存到后端数据目录;两种对象存储均可选填 CDN 加速域名。上传继续使用 Endpoint,配置 CDN 后登录资源、公开画布分享及管理后台媒体的下载与预览会在权限校验后统一重定向到 CDN;需要同源 Blob 的内部读取仍由后端代理,但后端同样通过 CDN 取源。CDN 地址不携带 OSS/COS 签名,敏感资源必须在 CDN 侧配置 URL 鉴权,并为需要浏览器跨域读取的场景配置 CORS;删除业务记录不会自动清理远端对象。
|
||||
- 用户主动上传、Agent 会话附件和 AI 生成资源的单文件上限、账号容量及 UTC 日上传总量由后台“资源与策略”统一维护,默认分别为 50MB、32MB、64MB、2GB 和 200MB;管理员可按可信部署需要调整,单文件业务上限最高 999MB,Nginx 请求体硬上限为 1024MB。
|
||||
|
||||
## 公网部署安全
|
||||
|
||||
@@ -590,11 +590,19 @@ func RegisterAdminRoutes(r *gin.RouterGroup, svc *service.Service) {
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
stream, err := svc.OpenAdminAPICallLogMediaRange(user, c.Param("id"), c.GetHeader("Range"))
|
||||
delivery, err := svc.PrepareAdminAPICallLogMediaDelivery(user, c.Param("id"), c.GetHeader("Range"))
|
||||
if err != nil {
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
if delivery.RedirectURL != "" {
|
||||
c.Header("Cache-Control", "private, no-store")
|
||||
c.Header("Referrer-Policy", "no-referrer")
|
||||
c.Header("X-Content-Type-Options", "nosniff")
|
||||
c.Redirect(http.StatusTemporaryRedirect, delivery.RedirectURL)
|
||||
return
|
||||
}
|
||||
stream := delivery.Stream
|
||||
defer stream.Body.Close()
|
||||
resource := stream.Resource
|
||||
mimeType := resource.MimeType
|
||||
|
||||
@@ -78,11 +78,21 @@ func RegisterCanvasShareRoutes(r *gin.RouterGroup, svc *service.Service) {
|
||||
if !enforceRateLimit(c, "public-canvas-resource:"+c.ClientIP(), 300, time.Minute) {
|
||||
return
|
||||
}
|
||||
stream, err := svc.OpenSharedCanvasResourceRange(c.Param("token"), c.Param("resourceId"), c.GetHeader("Range"))
|
||||
delivery, err := svc.PrepareSharedCanvasResourceDelivery(c.Param("token"), c.Param("resourceId"), c.GetHeader("Range"))
|
||||
if err != nil {
|
||||
fail(c, http.StatusNotFound, errors.New("分享资源不存在"))
|
||||
return
|
||||
}
|
||||
if delivery.RedirectURL != "" {
|
||||
c.Header("Cache-Control", "no-store")
|
||||
c.Header("Content-Security-Policy", "sandbox")
|
||||
c.Header("Referrer-Policy", "no-referrer")
|
||||
c.Header("X-Content-Type-Options", "nosniff")
|
||||
c.Header("X-Robots-Tag", "noindex, nofollow")
|
||||
c.Redirect(http.StatusTemporaryRedirect, delivery.RedirectURL)
|
||||
return
|
||||
}
|
||||
stream := delivery.Stream
|
||||
defer stream.Body.Close()
|
||||
resource := stream.Resource
|
||||
mimeType := resource.MimeType
|
||||
|
||||
@@ -214,24 +214,23 @@ func RegisterUserDataRoutes(r *gin.RouterGroup, svc *service.Service) {
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
resource, err := svc.Resource(user.ID, c.Param("id"))
|
||||
delivery, err := svc.PrepareResourceDelivery(user.ID, c.Param("id"), service.ResourceDeliveryOptions{
|
||||
ForceDirect: c.Query("direct") == "1",
|
||||
ForceProxy: c.Query("proxy") == "1",
|
||||
})
|
||||
if err != nil {
|
||||
fail(c, http.StatusNotFound, err)
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
if c.Query("direct") == "1" && resource.Provider != "local" {
|
||||
directURL, err := svc.DirectResourceURL(user.ID, resource.ID)
|
||||
if err != nil {
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
// 签名 URL 不进入应用、代理或浏览器缓存,也不作为后续请求的 Referer 泄露。
|
||||
if delivery.RedirectURL != "" {
|
||||
// CDN 或对象存储直连地址不进入应用缓存,也不作为后续请求的 Referer 泄露。
|
||||
c.Header("Cache-Control", "private, no-store")
|
||||
c.Header("Referrer-Policy", "no-referrer")
|
||||
c.Header("X-Content-Type-Options", "nosniff")
|
||||
c.Redirect(http.StatusTemporaryRedirect, directURL)
|
||||
c.Redirect(http.StatusTemporaryRedirect, delivery.RedirectURL)
|
||||
return
|
||||
}
|
||||
resource := delivery.Resource
|
||||
etag := resourceResponseETag(resource)
|
||||
// 私有资源允许浏览器保存响应,但每次复用前必须重新鉴权;304 会在读取 OSS 前返回。
|
||||
c.Header("Cache-Control", "private, no-cache")
|
||||
|
||||
@@ -12,7 +12,7 @@ type Resource struct {
|
||||
Provider string `json:"provider" gorm:"size:24"`
|
||||
Endpoint string `json:"endpoint"`
|
||||
Bucket string `json:"bucket" gorm:"size:160"`
|
||||
// 用户 OSS 每次修改都会生成新版本,资源固定引用创建时的版本,避免历史资源因换密钥失效。
|
||||
// 用户 OSS 每次修改都会生成新版本,资源固定引用创建时的存储与密钥;同一存储位置的 CDN 域名可跟随当前配置。
|
||||
StorageSettingID string `json:"-" gorm:"index;size:36"`
|
||||
ObjectKey string `json:"objectKey" gorm:"index"`
|
||||
PublicURL string `json:"publicUrl"`
|
||||
|
||||
@@ -280,33 +280,58 @@ func (s *Service) decorateAPICallLogs(logs []model.ApiCallLog) error {
|
||||
|
||||
// 管理员媒体读取必须同时校验日志、任务和资源归属,不能绕过用户资源边界按资源 ID 任意读取。
|
||||
func (s *Service) OpenAdminAPICallLogMediaRange(actor *model.User, logID string, rangeHeader string) (*ResourceStream, error) {
|
||||
if err := s.RequireAdmin(actor); err != nil {
|
||||
userID, resource, err := s.adminAPICallLogMediaResource(actor, logID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.openResourceRange(userID, resource, rangeHeader)
|
||||
}
|
||||
|
||||
func (s *Service) PrepareAdminAPICallLogMediaDelivery(actor *model.User, logID string, rangeHeader string) (*ResourceDelivery, error) {
|
||||
userID, resource, err := s.adminAPICallLogMediaResource(actor, logID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
delivery, err := s.prepareResourceDelivery(userID, resource, ResourceDeliveryOptions{})
|
||||
if err != nil || delivery.RedirectURL != "" {
|
||||
return delivery, err
|
||||
}
|
||||
stream, err := s.openResourceRange(userID, resource, rangeHeader)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
delivery.Stream = stream
|
||||
return delivery, nil
|
||||
}
|
||||
|
||||
func (s *Service) adminAPICallLogMediaResource(actor *model.User, logID string) (string, *model.Resource, error) {
|
||||
if err := s.RequireAdmin(actor); err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
log, err := s.repo.APICallLog(strings.TrimSpace(logID))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return "", nil, err
|
||||
}
|
||||
if log.TaskID == "" || (log.Capability != "image" && log.Capability != "video") {
|
||||
return nil, BadAuthRequest("该请求没有可预览媒体")
|
||||
return "", nil, BadAuthRequest("该请求没有可预览媒体")
|
||||
}
|
||||
task, err := s.repo.Task(log.TaskID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return "", nil, err
|
||||
}
|
||||
if task.UserID != log.UserID {
|
||||
return nil, BadAuthRequest("请求与媒体归属不一致")
|
||||
return "", nil, BadAuthRequest("请求与媒体归属不一致")
|
||||
}
|
||||
previewURL, _ := taskMediaPreview(task.ResultJSON, task.Type)
|
||||
resourceID := canvasResourceID(previewURL)
|
||||
if resourceID == "" {
|
||||
return nil, BadAuthRequest("该请求没有已持久化媒体")
|
||||
return "", nil, BadAuthRequest("该请求没有已持久化媒体")
|
||||
}
|
||||
resource, err := s.repo.ResourceForUser(log.UserID, resourceID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return "", nil, err
|
||||
}
|
||||
return s.openResourceRange(log.UserID, resource, rangeHeader)
|
||||
return log.UserID, resource, nil
|
||||
}
|
||||
|
||||
func (s *Service) AdminAPICallLog(actor *model.User, id string) (*model.ApiCallLog, error) {
|
||||
|
||||
@@ -70,6 +70,10 @@ func BadAuthRequest(message string) *AuthError {
|
||||
return &AuthError{Status: 400, Message: message}
|
||||
}
|
||||
|
||||
func NotFound(message string) *AuthError {
|
||||
return &AuthError{Status: 404, Message: message}
|
||||
}
|
||||
|
||||
func Unauthorized(message string) *AuthError {
|
||||
return &AuthError{Status: 401, Message: message}
|
||||
}
|
||||
|
||||
@@ -137,18 +137,47 @@ func (s *Service) OpenSharedCanvasResource(token string, resourceID string) (*mo
|
||||
}
|
||||
|
||||
func (s *Service) OpenSharedCanvasResourceRange(token string, resourceID string, rangeHeader string) (*ResourceStream, error) {
|
||||
share, project, err := s.sharedCanvasProject(token)
|
||||
userID, resource, err := s.sharedCanvasResource(token, resourceID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.openResourceRange(userID, resource, rangeHeader)
|
||||
}
|
||||
|
||||
func (s *Service) PrepareSharedCanvasResourceDelivery(token string, resourceID string, rangeHeader string) (*ResourceDelivery, error) {
|
||||
userID, resource, err := s.sharedCanvasResource(token, resourceID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
delivery, err := s.prepareResourceDelivery(userID, resource, ResourceDeliveryOptions{})
|
||||
if err != nil || delivery.RedirectURL != "" {
|
||||
return delivery, err
|
||||
}
|
||||
stream, err := s.openResourceRange(userID, resource, rangeHeader)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
delivery.Stream = stream
|
||||
return delivery, nil
|
||||
}
|
||||
|
||||
func (s *Service) sharedCanvasResource(token string, resourceID string) (string, *model.Resource, error) {
|
||||
share, project, err := s.sharedCanvasProject(token)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
_, allowedResources, err := publicCanvasProject(project, token)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return "", nil, err
|
||||
}
|
||||
if !allowedResources[resourceID] {
|
||||
return nil, gorm.ErrRecordNotFound
|
||||
return "", nil, gorm.ErrRecordNotFound
|
||||
}
|
||||
return s.OpenResourceRange(share.UserID, resourceID, rangeHeader)
|
||||
resource, err := s.repo.ResourceForUser(share.UserID, resourceID)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
return share.UserID, resource, nil
|
||||
}
|
||||
|
||||
func (s *Service) sharedCanvasProject(token string) (*model.CanvasShare, *model.CanvasProject, error) {
|
||||
|
||||
@@ -29,6 +29,7 @@ import (
|
||||
"infinite-canvas/backend/internal/model"
|
||||
|
||||
cos "github.com/tencentyun/cos-go-sdk-v5"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const providerResourceURLTTL = 4 * time.Hour
|
||||
@@ -45,6 +46,17 @@ type ResourceStream struct {
|
||||
AcceptRanges string
|
||||
}
|
||||
|
||||
type ResourceDeliveryOptions struct {
|
||||
ForceDirect bool
|
||||
ForceProxy bool
|
||||
}
|
||||
|
||||
type ResourceDelivery struct {
|
||||
Resource *model.Resource
|
||||
Stream *ResourceStream
|
||||
RedirectURL string
|
||||
}
|
||||
|
||||
func (s *Service) Resources(userID string, limit int) ([]model.Resource, error) {
|
||||
resources, err := s.repo.Resources(userID, limit)
|
||||
for index := range resources {
|
||||
@@ -90,6 +102,48 @@ func (s *Service) directResourceURL(resource *model.Resource, expiresAt time.Tim
|
||||
return signedOSSObjectURL(setting, resource.ObjectKey, expiresAt)
|
||||
}
|
||||
|
||||
// PrepareResourceDelivery 统一决定浏览器资源出口:配置 CDN 时默认直连 CDN,显式代理仅用于需要同源 Blob 的内部读取。
|
||||
func (s *Service) PrepareResourceDelivery(userID string, id string, options ResourceDeliveryOptions) (*ResourceDelivery, error) {
|
||||
resource, err := s.repo.ResourceForUser(userID, id)
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, NotFound("资源不存在")
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return s.prepareResourceDelivery(userID, resource, options)
|
||||
}
|
||||
|
||||
func (s *Service) prepareResourceDelivery(userID string, resource *model.Resource, options ResourceDeliveryOptions) (*ResourceDelivery, error) {
|
||||
if resource == nil {
|
||||
return nil, errors.New("资源不存在")
|
||||
}
|
||||
if resource.Status != model.ResourceStatusReady {
|
||||
return nil, BadAuthRequest("资源尚未上传完成")
|
||||
}
|
||||
if resource.Provider != "local" && !options.ForceProxy {
|
||||
setting, err := s.ossSettingForResource(userID, resource)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if setting.CDNBaseURL != "" {
|
||||
redirectURL, err := ossCDNObjectURL(setting.CDNBaseURL, resource.ObjectKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &ResourceDelivery{Resource: resource, RedirectURL: redirectURL}, nil
|
||||
}
|
||||
if options.ForceDirect {
|
||||
redirectURL, err := signedOSSObjectURL(setting, resource.ObjectKey, time.Now().Add(directResourceURLTTL))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &ResourceDelivery{Resource: resource, RedirectURL: redirectURL}, nil
|
||||
}
|
||||
}
|
||||
return &ResourceDelivery{Resource: resource}, nil
|
||||
}
|
||||
|
||||
func (s *Service) signedPublicResourceURL(resourceID string, expiresAt time.Time) (string, error) {
|
||||
baseURL, err := s.publicResourceBaseURL()
|
||||
if err != nil {
|
||||
@@ -634,6 +688,16 @@ func (s *Service) ossSettingForResource(userID string, resource *model.Resource)
|
||||
var err error
|
||||
if resource.StorageSettingID != "" {
|
||||
_, setting, err = s.readUserOSSSettingByID(userID, resource.StorageSettingID)
|
||||
if err == nil {
|
||||
_, current, currentErr := s.readUserOSSSetting(userID)
|
||||
if currentErr != nil {
|
||||
return ossSettingValue{}, currentErr
|
||||
}
|
||||
// 密钥继续固定在历史版本;同一存储位置的 CDN 域名跟随当前配置,使已有资源也立即切换。
|
||||
if resourceStorageMatches(current, resource) {
|
||||
setting.CDNBaseURL = current.CDNBaseURL
|
||||
}
|
||||
}
|
||||
} else {
|
||||
_, setting, err = s.readOSSSetting()
|
||||
}
|
||||
@@ -652,6 +716,16 @@ func (s *Service) ossSettingForResource(userID string, resource *model.Resource)
|
||||
return setting, nil
|
||||
}
|
||||
|
||||
func resourceStorageMatches(setting ossSettingValue, resource *model.Resource) bool {
|
||||
if resource == nil {
|
||||
return false
|
||||
}
|
||||
setting = normalizeOSSSetting(setting)
|
||||
return setting.Provider == strings.ToLower(strings.TrimSpace(resource.Provider)) &&
|
||||
setting.Endpoint == strings.TrimRight(strings.TrimSpace(resource.Endpoint), "/") &&
|
||||
setting.Bucket == strings.TrimSpace(resource.Bucket)
|
||||
}
|
||||
|
||||
func ossSettingForProvider(setting ossSettingValue, provider string) (ossSettingValue, error) {
|
||||
setting = normalizeOSSSetting(setting)
|
||||
provider = strings.ToLower(strings.TrimSpace(provider))
|
||||
|
||||
@@ -358,6 +358,123 @@ func TestDirectResourceURLChecksOwnershipAndSignsOSSResource(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrepareResourceDeliveryPrefersConfiguredCDN(t *testing.T) {
|
||||
svc := newResourceTestService(t)
|
||||
settingJSON, _ := json.Marshal(ossSettingValue{
|
||||
Enabled: true, Provider: tencentCOSProvider, Endpoint: "https://cos.ap-shanghai.myqcloud.com", CDNBaseURL: "https://media.example.com",
|
||||
Bucket: "private-bucket-1250000000", AccessKeyID: "secret-id", AccessKeySecret: "secret-key",
|
||||
})
|
||||
if err := svc.repo.SaveSystemSetting(&model.SystemSetting{Key: ossSettingKey, ValueJSON: string(settingJSON)}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resource := model.Resource{
|
||||
ID: "resource-cdn-delivery", UserID: "user-1", Kind: "image", Status: model.ResourceStatusReady,
|
||||
Provider: tencentCOSProvider, Endpoint: "https://cos.ap-shanghai.myqcloud.com", Bucket: "private-bucket-1250000000",
|
||||
ObjectKey: "users/user-1/image/test image.png", MimeType: "image/png",
|
||||
}
|
||||
if err := svc.repo.CreateResource(&resource); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
delivery, err := svc.PrepareResourceDelivery("user-1", resource.ID, ResourceDeliveryOptions{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if delivery.Resource == nil || delivery.Resource.ID != resource.ID || delivery.RedirectURL != "https://media.example.com/users/user-1/image/test%20image.png" {
|
||||
t.Fatalf("PrepareResourceDelivery() = %#v", delivery)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrepareResourceDeliveryAllowsExplicitProxyWithCDN(t *testing.T) {
|
||||
svc := newResourceTestService(t)
|
||||
settingJSON, _ := json.Marshal(ossSettingValue{
|
||||
Enabled: true, Provider: aliyunOSSProvider, Endpoint: "https://oss-cn-test.aliyuncs.com", CDNBaseURL: "https://media.example.com",
|
||||
Bucket: "private-bucket", AccessKeyID: "access-id", AccessKeySecret: "secret-value",
|
||||
})
|
||||
if err := svc.repo.SaveSystemSetting(&model.SystemSetting{Key: ossSettingKey, ValueJSON: string(settingJSON)}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resource := model.Resource{
|
||||
ID: "resource-cdn-proxy", UserID: "user-1", Kind: "image", Status: model.ResourceStatusReady,
|
||||
Provider: aliyunOSSProvider, Endpoint: "https://oss-cn-test.aliyuncs.com", Bucket: "private-bucket",
|
||||
ObjectKey: "users/user-1/image/proxy.png", MimeType: "image/png",
|
||||
}
|
||||
if err := svc.repo.CreateResource(&resource); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
delivery, err := svc.PrepareResourceDelivery("user-1", resource.ID, ResourceDeliveryOptions{ForceProxy: true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if delivery.Resource == nil || delivery.Resource.ID != resource.ID || delivery.RedirectURL != "" {
|
||||
t.Fatalf("PrepareResourceDelivery(force proxy) = %#v", delivery)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCurrentUserCDNSettingAppliesToHistoricalResourcesInSameStorage(t *testing.T) {
|
||||
t.Setenv("CANVAS_ALLOW_PRIVATE_UPSTREAMS", "true")
|
||||
server := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
|
||||
defer server.Close()
|
||||
svc := newResourceTestService(t)
|
||||
actor := &model.User{ID: "user-1"}
|
||||
if _, err := svc.UpdateUserOSSSetting(actor, OSSSettingRequest{
|
||||
Enabled: true, Provider: aliyunOSSProvider, Endpoint: server.URL, Bucket: "private-bucket",
|
||||
AccessKeyID: "access-id", AccessKeySecret: "secret-value",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
historical, _, err := svc.readUserOSSSetting(actor.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resource := model.Resource{
|
||||
ID: "resource-user-cdn", UserID: actor.ID, Kind: "image", Status: model.ResourceStatusReady,
|
||||
Provider: aliyunOSSProvider, Endpoint: server.URL, Bucket: "private-bucket", StorageSettingID: historical.ID,
|
||||
ObjectKey: "users/user-1/image/historical.png", MimeType: "image/png",
|
||||
}
|
||||
if err := svc.repo.CreateResource(&resource); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := svc.UpdateUserOSSSetting(actor, OSSSettingRequest{
|
||||
Enabled: true, Provider: aliyunOSSProvider, Endpoint: server.URL, CDNBaseURL: server.URL, Bucket: "private-bucket",
|
||||
AccessKeyID: "access-id", AccessKeySecret: "secret-value",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
delivery, err := svc.PrepareResourceDelivery(actor.ID, resource.ID, ResourceDeliveryOptions{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if delivery.RedirectURL != server.URL+"/users/user-1/image/historical.png" {
|
||||
t.Fatalf("PrepareResourceDelivery(historical user resource) = %#v", delivery)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrepareResourceDeliveryKeepsForcedOriginDirectWithoutCDN(t *testing.T) {
|
||||
svc := newResourceTestService(t)
|
||||
settingJSON, _ := json.Marshal(ossSettingValue{
|
||||
Enabled: true, Provider: aliyunOSSProvider, Endpoint: "https://oss-cn-test.aliyuncs.com", Bucket: "private-bucket",
|
||||
AccessKeyID: "access-id", AccessKeySecret: "secret-value",
|
||||
})
|
||||
if err := svc.repo.SaveSystemSetting(&model.SystemSetting{Key: ossSettingKey, ValueJSON: string(settingJSON)}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resource := model.Resource{
|
||||
ID: "resource-origin-direct", UserID: "user-1", Kind: "image", Status: model.ResourceStatusReady,
|
||||
Provider: aliyunOSSProvider, Endpoint: "https://oss-cn-test.aliyuncs.com", Bucket: "private-bucket",
|
||||
ObjectKey: "users/user-1/image/direct.png", MimeType: "image/png",
|
||||
}
|
||||
if err := svc.repo.CreateResource(&resource); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
delivery, err := svc.PrepareResourceDelivery("user-1", resource.ID, ResourceDeliveryOptions{ForceDirect: true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(delivery.RedirectURL, "private-bucket.oss-cn-test.aliyuncs.com/users/user-1/image/direct.png") || !strings.Contains(delivery.RedirectURL, "Signature=") {
|
||||
t.Fatalf("PrepareResourceDelivery(force direct) = %#v", delivery)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeSingleByteRange(t *testing.T) {
|
||||
tests := map[string]string{
|
||||
"bytes=0-1023": "bytes=0-1023",
|
||||
|
||||
@@ -85,7 +85,7 @@ export function UserOSSSettingsForm() {
|
||||
<Cloud className="size-4" />
|
||||
我的对象存储
|
||||
</div>
|
||||
<p className="mt-1 max-w-3xl text-xs leading-5 text-foreground/55">支持阿里云 OSS 与腾讯云 COS。启用后,新上传和新生成的媒体优先写入你的存储桶;停用时回退到平台存储。历史资源固定使用创建时的配置版本。</p>
|
||||
<p className="mt-1 max-w-3xl text-xs leading-5 text-foreground/55">支持阿里云 OSS 与腾讯云 COS。启用后,新上传和新生成的媒体优先写入你的存储桶;停用时回退到平台存储。历史资源继续使用创建时的存储与密钥配置,同一存储位置的 CDN 域名会跟随当前配置。</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 gap-2">
|
||||
<Tag color={setting?.enabled ? "success" : "default"}>{setting?.enabled ? "已启用" : "未启用"}</Tag>
|
||||
|
||||
@@ -142,7 +142,7 @@ export function resourceFileUrl(id: string) {
|
||||
|
||||
function resourceProxyFileUrl(id: string) {
|
||||
const base = String(apiBaseURL).replace(/\/+$/, "");
|
||||
return `${base}/resources/${encodeURIComponent(id)}/file`;
|
||||
return `${base}/resources/${encodeURIComponent(id)}/file?proxy=1`;
|
||||
}
|
||||
|
||||
export async function resolveResourceUrl(storageKey?: string, fallback = "") {
|
||||
|
||||
Reference in New Issue
Block a user