173 lines
6.7 KiB
Go
173 lines
6.7 KiB
Go
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"`
|
|
DownloadName string `json:"downloadName,omitempty"`
|
|
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
|
|
}
|
|
if options.Purpose != PurposeDownload {
|
|
options.DownloadName = ""
|
|
}
|
|
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 options.Purpose == PurposeDownload {
|
|
// Cross-origin `a[download]` is only advisory. A real browser download must
|
|
// therefore be enforced by the object store response itself. Downloads use
|
|
// a signed origin URL carrying Content-Disposition=attachment; media bytes
|
|
// never pass through the application server or a Blob relay.
|
|
if !storage.PublicOrigin(setting) {
|
|
return nil, AccessError(503, "resource_origin_private", "对象存储源站不可由浏览器直连,无法在不占用服务器带宽的前提下下载")
|
|
}
|
|
access.URL, err = storage.SignedOriginObjectDownloadURL(setting, resource.ObjectKey, expires, options.DownloadName)
|
|
access.Delivery = DeliveryOrigin
|
|
} 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.SignedOriginObjectURL(setting, resource.ObjectKey, expires)
|
|
access.Delivery = DeliveryOrigin
|
|
if setting.CDNBaseURL != "" {
|
|
access.FallbackReason = "cdn_auth_unconfigured"
|
|
}
|
|
} else if options.Purpose == PurposeProvider && setting.Delivery.AllowPrivateProxy {
|
|
// Only the server-side provider-input path may opt into a private-origin
|
|
// proxy. Browser display/copy/process/download traffic for OSS resources
|
|
// must never relay media bytes through the application server.
|
|
access.Delivery = DeliveryProxy
|
|
access.FallbackReason = "private_origin"
|
|
} else {
|
|
return nil, AccessError(503, "resource_origin_private", "对象存储源站不可由浏览器直连,请配置公网 OSS 源站或 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
|
|
}
|