refactor(media): 资源分发 - 统一资源访问策略与云存储重定向
This commit is contained in:
1 parent
af6633047c
commit
487c34bfeb
50 files changed
+2112
-1266
No files matched your search
@@ -0,0 +1,155 @@
|
||||
package assets
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"infinite-canvas/backend/internal/kernel"
|
||||
"infinite-canvas/backend/internal/model"
|
||||
"infinite-canvas/backend/internal/storage"
|
||||
)
|
||||
|
||||
type AccessPurpose string
|
||||
type ResourceVariant string
|
||||
type DeliveryMode string
|
||||
|
||||
const (
|
||||
PurposeDisplay AccessPurpose = "display"
|
||||
PurposeCopy AccessPurpose = "copy"
|
||||
PurposeDownload AccessPurpose = "download"
|
||||
PurposeProcess AccessPurpose = "browser-process"
|
||||
PurposeProvider AccessPurpose = "provider-input"
|
||||
VariantOriginal ResourceVariant = "original"
|
||||
VariantPlayback ResourceVariant = "playback"
|
||||
DeliveryCDN DeliveryMode = "cdn"
|
||||
DeliveryOrigin DeliveryMode = "origin"
|
||||
DeliveryLocal DeliveryMode = "platform-local"
|
||||
DeliveryProxy DeliveryMode = "platform-proxy"
|
||||
)
|
||||
|
||||
// AccessOptions contains use intent, never a client-controlled authorization or transport override.
|
||||
type AccessOptions struct {
|
||||
Purpose AccessPurpose `json:"purpose"`
|
||||
Variant ResourceVariant `json:"variant"`
|
||||
ExpiresAt time.Time `json:"-"`
|
||||
DescribeOnly bool `json:"-"`
|
||||
}
|
||||
|
||||
type AccessRequest struct {
|
||||
ResourceID string `json:"resourceId"`
|
||||
AccessOptions
|
||||
}
|
||||
|
||||
type ResourceAccess struct {
|
||||
ResourceID string `json:"resourceId"`
|
||||
RequestedVariant ResourceVariant `json:"requestedVariant"`
|
||||
ActualVariant ResourceVariant `json:"actualVariant"`
|
||||
URL string `json:"url"`
|
||||
Delivery DeliveryMode `json:"delivery"`
|
||||
IssuedAt time.Time `json:"issuedAt"`
|
||||
ExpiresAt *time.Time `json:"expiresAt"`
|
||||
RefreshAt time.Time `json:"refreshAt"`
|
||||
Revision string `json:"revision"`
|
||||
FallbackReason string `json:"fallbackReason,omitempty"`
|
||||
}
|
||||
|
||||
func AccessError(status int, reason, message string) *kernel.AppError {
|
||||
err := kernel.NewAppError(status, message)
|
||||
err.Reason = kernel.ErrorReason(reason)
|
||||
return err
|
||||
}
|
||||
|
||||
func NormalizeAccessOptions(options AccessOptions, allowProvider bool) (AccessOptions, error) {
|
||||
if options.Purpose == "" {
|
||||
options.Purpose = PurposeDisplay
|
||||
}
|
||||
if options.Variant == "" {
|
||||
options.Variant = VariantOriginal
|
||||
}
|
||||
switch options.Purpose {
|
||||
case PurposeDisplay, PurposeCopy, PurposeDownload, PurposeProcess:
|
||||
case PurposeProvider:
|
||||
if !allowProvider {
|
||||
return options, kernel.Forbidden("不允许请求模型输入凭据")
|
||||
}
|
||||
default:
|
||||
return options, kernel.BadAuthRequest("资源访问用途无效")
|
||||
}
|
||||
if options.Variant != VariantOriginal && options.Variant != VariantPlayback {
|
||||
return options, kernel.BadAuthRequest("资源变体无效")
|
||||
}
|
||||
if options.Purpose == PurposeCopy || options.Purpose == PurposeDownload || options.Purpose == PurposeProvider {
|
||||
options.Variant = VariantOriginal
|
||||
}
|
||||
return options, nil
|
||||
}
|
||||
|
||||
// ResolveAccess is the sole delivery policy. The caller must authorize resource ownership/scene first.
|
||||
// Local URL signing is injected by the composition root; storage adapters never know application keys.
|
||||
func ResolveAccess(resource *model.Resource, setting storage.Settings, options AccessOptions, now time.Time,
|
||||
platformURL func(ResourceVariant, time.Time) (string, error)) (*ResourceAccess, error) {
|
||||
options, err := NormalizeAccessOptions(options, true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resource == nil {
|
||||
return nil, kernel.NotFound("资源不存在")
|
||||
}
|
||||
if resource.Status != model.ResourceStatusReady {
|
||||
return nil, AccessError(http.StatusConflict, "resource_not_ready", "资源尚未上传完成")
|
||||
}
|
||||
ttl := 5 * time.Minute
|
||||
if options.Purpose == PurposeProvider {
|
||||
ttl = 4 * time.Hour
|
||||
}
|
||||
expires := now.Add(ttl)
|
||||
if !options.ExpiresAt.IsZero() && options.ExpiresAt.Before(expires) {
|
||||
expires = options.ExpiresAt
|
||||
}
|
||||
if !expires.After(now) {
|
||||
return nil, kernel.Forbidden("资源授权已过期")
|
||||
}
|
||||
access := &ResourceAccess{ResourceID: resource.ID, RequestedVariant: options.Variant, ActualVariant: VariantOriginal,
|
||||
IssuedAt: now, ExpiresAt: &expires, RefreshAt: now.Add(expires.Sub(now) * 4 / 5), Revision: resource.ETag + ":" + resource.PlaybackStatus + ":" + storage.DeliveryRevision(setting)}
|
||||
if options.Variant == VariantPlayback {
|
||||
if resource.Provider == "local" && resource.PlaybackStatus == model.PlaybackStatusReady && resource.PlaybackObjectKey != "" {
|
||||
access.ActualVariant = VariantPlayback
|
||||
} else {
|
||||
access.FallbackReason = "playback_not_ready"
|
||||
}
|
||||
}
|
||||
if resource.Provider == "local" {
|
||||
access.Delivery = DeliveryLocal
|
||||
} else if storage.CDNEnabled(setting) {
|
||||
access.URL, err = storage.SignCDNURL(setting, resource.ObjectKey, expires)
|
||||
access.Delivery = DeliveryCDN
|
||||
if setting.Delivery.CDNAuthMode == "public" {
|
||||
access.ExpiresAt = nil
|
||||
}
|
||||
} else if setting.Delivery.RequireCDN {
|
||||
return nil, AccessError(503, "resource_cdn_unconfigured", "CDN 访问鉴权未配置,请检查存储分发设置")
|
||||
} else if storage.PublicOrigin(setting) {
|
||||
access.URL, err = storage.SignedOSSObjectURL(setting, resource.ObjectKey, expires)
|
||||
access.Delivery = DeliveryOrigin
|
||||
if setting.CDNBaseURL != "" {
|
||||
access.FallbackReason = "cdn_auth_unconfigured"
|
||||
}
|
||||
} else if setting.Delivery.AllowPrivateProxy {
|
||||
access.Delivery = DeliveryProxy
|
||||
access.FallbackReason = "private_origin"
|
||||
} else {
|
||||
return nil, AccessError(503, "resource_origin_private", "资源源站不可公网访问,请配置 CDN 或明确启用私网源站代理")
|
||||
}
|
||||
if err != nil {
|
||||
failure := AccessError(503, "resource_signing_failed", "资源访问地址签发失败,请检查存储分发设置")
|
||||
failure.Cause = err
|
||||
return nil, failure
|
||||
}
|
||||
if access.Delivery == DeliveryLocal || access.Delivery == DeliveryProxy {
|
||||
access.URL, err = platformURL(access.ActualVariant, expires)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return access, nil
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
package assets
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"infinite-canvas/backend/internal/kernel"
|
||||
"infinite-canvas/backend/internal/model"
|
||||
"infinite-canvas/backend/internal/storage"
|
||||
)
|
||||
|
||||
func testReadyResource(provider string) *model.Resource {
|
||||
return &model.Resource{
|
||||
ID: "resource-1", UserID: "user-1", Provider: provider, ObjectKey: "users/user-1/image/test.png",
|
||||
Status: model.ResourceStatusReady, MimeType: "image/png", ETag: "etag-1",
|
||||
}
|
||||
}
|
||||
|
||||
func testPlatformURL(variants *[]ResourceVariant) func(ResourceVariant, time.Time) (string, error) {
|
||||
return func(variant ResourceVariant, _ time.Time) (string, error) {
|
||||
*variants = append(*variants, variant)
|
||||
return "https://canvas.example/api/resources/resource-1/file", nil
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAccessPolicyMatrix(t *testing.T) {
|
||||
now := time.Date(2026, time.January, 2, 3, 4, 5, 0, time.UTC)
|
||||
tests := []struct {
|
||||
name string
|
||||
resource *model.Resource
|
||||
setting storage.Settings
|
||||
options AccessOptions
|
||||
wantMode DeliveryMode
|
||||
wantReason string
|
||||
wantURL string
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "local uses platform controlled URL",
|
||||
resource: testReadyResource("local"),
|
||||
options: AccessOptions{Purpose: PurposeDisplay},
|
||||
wantMode: DeliveryLocal,
|
||||
wantURL: "https://canvas.example/api/resources/resource-1/file",
|
||||
},
|
||||
{
|
||||
name: "public CDN uses CDN URL",
|
||||
resource: testReadyResource("aliyun"),
|
||||
setting: storage.Settings{Provider: "aliyun", CDNBaseURL: "https://media.example.com", Delivery: storage.DeliverySettings{CDNAuthMode: "public"}},
|
||||
options: AccessOptions{Purpose: PurposeCopy},
|
||||
wantMode: DeliveryCDN,
|
||||
wantURL: "https://media.example.com/users/user-1/image/test.png",
|
||||
},
|
||||
{
|
||||
name: "CDN without supported auth falls back to public origin",
|
||||
resource: testReadyResource("aliyun"),
|
||||
setting: storage.Settings{Provider: "aliyun", Endpoint: "https://s3.amazonaws.com", CDNBaseURL: "https://media.example.com", AccessKeyID: "id", AccessKeySecret: "secret"},
|
||||
options: AccessOptions{Purpose: PurposeDisplay},
|
||||
wantMode: DeliveryOrigin, wantReason: "cdn_auth_unconfigured",
|
||||
},
|
||||
{
|
||||
name: "require CDN rejects incomplete CDN auth",
|
||||
resource: testReadyResource("aliyun"),
|
||||
setting: storage.Settings{Provider: "aliyun", Endpoint: "https://s3.amazonaws.com", CDNBaseURL: "https://media.example.com", AccessKeyID: "id", AccessKeySecret: "secret", Delivery: storage.DeliverySettings{RequireCDN: true}},
|
||||
options: AccessOptions{Purpose: PurposeDisplay},
|
||||
wantErr: "resource_cdn_unconfigured",
|
||||
},
|
||||
{
|
||||
name: "private origin requires explicit proxy",
|
||||
resource: testReadyResource("aliyun"),
|
||||
setting: storage.Settings{Provider: "aliyun", Endpoint: "http://storage.internal", Delivery: storage.DeliverySettings{AllowPrivateProxy: true}},
|
||||
options: AccessOptions{Purpose: PurposeDisplay},
|
||||
wantMode: DeliveryProxy, wantReason: "private_origin",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var variants []ResourceVariant
|
||||
access, err := ResolveAccess(tt.resource, tt.setting, tt.options, now, testPlatformURL(&variants))
|
||||
if tt.wantErr != "" {
|
||||
if err == nil || !strings.Contains(string(errorReason(err)), tt.wantErr) {
|
||||
t.Fatalf("ResolveAccess() error = %v, want reason %q", err, tt.wantErr)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveAccess() error = %v", err)
|
||||
}
|
||||
if access.Delivery != tt.wantMode || access.FallbackReason != tt.wantReason {
|
||||
t.Fatalf("ResolveAccess() = %#v, want mode=%q reason=%q", access, tt.wantMode, tt.wantReason)
|
||||
}
|
||||
if tt.wantURL != "" && access.URL != tt.wantURL {
|
||||
t.Fatalf("ResolveAccess().URL = %q, want %q", access.URL, tt.wantURL)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAccessVariantAndExpiryContract(t *testing.T) {
|
||||
now := time.Date(2026, time.January, 2, 3, 4, 5, 0, time.UTC)
|
||||
resource := testReadyResource("local")
|
||||
resource.Kind = "video"
|
||||
resource.PlaybackStatus = model.PlaybackStatusReady
|
||||
resource.PlaybackObjectKey = "playback/resource-1.mp4"
|
||||
var variants []ResourceVariant
|
||||
access, err := ResolveAccess(resource, storage.Settings{}, AccessOptions{Purpose: PurposeProcess, Variant: VariantPlayback, ExpiresAt: now.Add(90 * time.Second)}, now, testPlatformURL(&variants))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if access.ActualVariant != VariantPlayback || access.ExpiresAt == nil || !access.ExpiresAt.Equal(now.Add(90*time.Second)) {
|
||||
t.Fatalf("playback access = %#v", access)
|
||||
}
|
||||
if len(variants) != 1 || variants[0] != VariantPlayback {
|
||||
t.Fatalf("platform URL variants = %#v, want playback", variants)
|
||||
}
|
||||
|
||||
provider, err := ResolveAccess(resource, storage.Settings{}, AccessOptions{Purpose: PurposeProvider}, now, testPlatformURL(&variants))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if provider.ExpiresAt == nil || !provider.ExpiresAt.Equal(now.Add(4*time.Hour)) {
|
||||
t.Fatalf("provider access expiry = %v, want %v", provider.ExpiresAt, now.Add(4*time.Hour))
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAccessRejectsPendingResource(t *testing.T) {
|
||||
resource := testReadyResource("local")
|
||||
resource.Status = model.ResourceStatusPending
|
||||
_, err := ResolveAccess(resource, storage.Settings{}, AccessOptions{Purpose: PurposeDisplay}, time.Now(), func(ResourceVariant, time.Time) (string, error) {
|
||||
return "", nil
|
||||
})
|
||||
if err == nil || !strings.Contains(string(errorReason(err)), "resource_not_ready") {
|
||||
t.Fatalf("ResolveAccess() error = %v, want resource_not_ready", err)
|
||||
}
|
||||
}
|
||||
|
||||
func errorReason(err error) string {
|
||||
if appErr, ok := err.(*kernel.AppError); ok {
|
||||
return string(appErr.Reason)
|
||||
}
|
||||
return err.Error()
|
||||
}
|
||||
@@ -15,13 +15,8 @@ type ResourceStream struct {
|
||||
AcceptRanges string
|
||||
}
|
||||
|
||||
type ResourceDeliveryOptions struct {
|
||||
ForceDirect bool
|
||||
ForceProxy bool
|
||||
}
|
||||
|
||||
type ResourceDelivery struct {
|
||||
Resource *model.Resource
|
||||
Stream *ResourceStream
|
||||
RedirectURL string
|
||||
Resource *model.Resource
|
||||
Stream *ResourceStream
|
||||
Access *ResourceAccess
|
||||
}
|
||||
@@ -8,7 +8,7 @@ func TestIDFromFileURL(t *testing.T) {
|
||||
want string
|
||||
}{
|
||||
{"/api/resources/abc_123/file", "abc_123"},
|
||||
{"https://host.example/api/resources/abc_123/file?proxy=1", "abc_123"},
|
||||
{"https://host.example/api/resources/abc_123/file?variant=original", "abc_123"},
|
||||
{"/api/projects/abc_123", ""},
|
||||
{"", ""},
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user