fix(media): 对象存储 CDN - 统一资源预览与下载走 CDN
统一登录资源、公开画布分享和管理后台媒体的 CDN 预览与下载出口,并保留同源代理读取能力。
This commit is contained in:
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user