fix(media): 资源访问 - 签名地址有效期跟随后台策略并放开服务器访问地址限制

平台展示与厂商读取的签名地址有效期改为读取「资源与请求策略」,默认均为 4 小时,不再硬编码 5 分钟。服务器访问地址只校验 http/https 格式,允许本机、局域网或 HTTP 地址,模型读取平台资源不再强制 HTTPS 公网地址。
This commit is contained in:
ddcat666
2026-09-30 03:38:31 +08:00
parent 4957f8bc22
commit 4272b0e499
9 changed files with 40 additions and 14 deletions

View File

@@ -805,7 +805,7 @@ func (s *Service) hydrateProviderMedia(userID string, media *providerMedia, poli
}
useObjectURL := policy.requireURL || (policy.preferURL && resourceUsesObjectStorage(resource))
if useObjectURL {
signedURL, err := s.providerResourceURL(resource, time.Now().Add(providerResourceURLTTL))
signedURL, err := s.providerResourceURL(resource, time.Time{})
if err != nil {
return fmt.Errorf("生成参考素材地址失败:%w", err)
}

View File

@@ -18,6 +18,7 @@ func (s *Service) ResourceForUser(actor *model.User, id string) (*model.Resource
// providerResourceURL 只能接收已经完成用户归属和 ready 状态校验的资源,
// 并为上游签发短时地址;它不是绕过权限校验的通用资源 URL 生成器。
// expiresAt 为零值时使用后台「厂商访问地址有效期」(默认 4 小时),非零时只能进一步缩短。
func (s *Service) providerResourceURL(resource *model.Resource, expiresAt time.Time) (string, error) {
access, err := s.resolveResourceAccess(resource, ResourceAccessOptions{Purpose: assets.PurposeProvider, ExpiresAt: expiresAt})
if err != nil {

View File

@@ -32,8 +32,6 @@ import (
"gorm.io/gorm"
)
const providerResourceURLTTL = 4 * time.Hour
var errInvalidGeneratedDataURL = errors.New("生成内容 data URL 无效")
type ResourceStream = assets.ResourceStream
@@ -95,10 +93,18 @@ func (s *Service) publicResourceBaseURL() (*url.URL, error) {
return validatePublicResourceBaseURL(raw)
}
// 服务器访问地址是本服务对外暴露的根地址,由管理员自行决定,
// 允许本机、局域网或任意主机;这里只校验格式,不套用出站私网策略。
func validatePublicResourceBaseURL(raw string) (*url.URL, error) {
parsed, err := ValidateOutboundURL(raw)
if err != nil {
return nil, err
parsed, err := url.Parse(strings.TrimSpace(raw))
if err != nil || parsed.Hostname() == "" {
return nil, BadAuthRequest("服务器访问地址必须是完整的 http/https 地址")
}
if parsed.Scheme != "https" && parsed.Scheme != "http" {
return nil, BadAuthRequest("服务器访问地址只支持 http/https")
}
if parsed.User != nil {
return nil, BadAuthRequest("服务器访问地址不能包含认证信息")
}
if parsed.RawQuery != "" || parsed.Fragment != "" {
return nil, BadAuthRequest("服务器访问地址不能包含查询参数或片段")

View File

@@ -70,6 +70,10 @@ func (s *Service) resolveResourceAccess(resource *model.Resource, options Resour
if err != nil {
return nil, WrapAppError(503, "无法解析资源实际存储位置", err)
}
setting = s.storageSettingWithRuntimePolicy(setting)
} else {
// 本地资源无需存储配置,只注入运行时策略,让平台签名地址同样遵循后台配置的有效期。
setting.Runtime = s.storageSettingWithRuntimePolicy(ossSettingValue{}).Runtime
}
return assets.ResolveAccess(resource, setting, options, time.Now().UTC(), func(variant assets.ResourceVariant, expires time.Time) (string, error) {
return s.signedResourceAccessURL(resource, variant, expires, options.Purpose == assets.PurposeProvider)
@@ -134,9 +138,6 @@ func (s *Service) signedResourceAccessURL(resource *model.Resource, variant asse
if err != nil {
return "", err
}
if base.Scheme != "https" {
return "", BadAuthRequest("模型读取平台资源需要配置 HTTPS 公网访问地址")
}
return base.ResolveReference(u).String(), nil
}

View File

@@ -102,9 +102,11 @@ func ResolveAccess(resource *model.Resource, setting storage.Settings, options A
if resource.Status != model.ResourceStatusReady {
return nil, AccessError(http.StatusConflict, "resource_not_ready", "资源尚未上传完成")
}
ttl := 5 * time.Minute
// 有效期来自后台「资源与请求策略」;未注入运行时策略时回落到存储层默认值(均为 4 小时)。
runtime := storage.NormalizeSettings(storage.Settings{Runtime: setting.Runtime}).Runtime
ttl := runtime.AccessURLTTL
if options.Purpose == PurposeProvider {
ttl = 4 * time.Hour
ttl = runtime.ProviderAccessURLTTL
}
expires := now.Add(ttl)
if !options.ExpiresAt.IsZero() && options.ExpiresAt.Before(expires) {

View File

@@ -185,3 +185,19 @@ func errorReason(err error) string {
}
return err.Error()
}
func TestResolveAccessUsesRuntimePolicyTTL(t *testing.T) {
now := time.Date(2026, time.January, 2, 3, 4, 5, 0, time.UTC)
resource := testReadyResource("local")
setting := storage.Settings{Runtime: storage.RuntimePolicy{AccessURLTTL: 2 * time.Hour, ProviderAccessURLTTL: 6 * time.Hour}}
var variants []ResourceVariant
for purpose, want := range map[AccessPurpose]time.Duration{PurposeDisplay: 2 * time.Hour, PurposeProvider: 6 * time.Hour} {
access, err := ResolveAccess(resource, setting, AccessOptions{Purpose: purpose}, now, testPlatformURL(&variants))
if err != nil {
t.Fatal(err)
}
if access.ExpiresAt == nil || !access.ExpiresAt.Equal(now.Add(want)) {
t.Fatalf("%s access expiry = %v, want %v", purpose, access.ExpiresAt, now.Add(want))
}
}
}

View File

@@ -161,7 +161,7 @@ func DefaultRuntimePolicy() RuntimePolicySetting {
},
Storage: RuntimeStoragePolicy{
TransferTimeoutSeconds: 120,
AccessURLTTLSeconds: 300,
AccessURLTTLSeconds: 14_400,
ProviderAccessURLTTLSeconds: 14_400,
NonSeekableBufferMB: 64,
ErrorBodyKB: 1,

View File

@@ -11,7 +11,7 @@ const (
qiniuKodoProvider = "qiniu"
s3Provider = "s3"
defaultOSSPathPrefix = "open-ai-canvas"
resourceAccessURLTTL = 5 * time.Minute
resourceAccessURLTTL = 4 * time.Hour
)
type Settings struct {

View File

@@ -462,7 +462,7 @@ export default function StorageSettingsPage() {
<div className="admin-storage-form-section">
<FormSectionTitle icon={<Globe2 className="size-4" />} title="公开访问根地址" description="用于生成本地资源的短时签名链接;填写站点根地址,不要附带 /api、查询参数或片段。" />
<div className="admin-storage-local-field">
<Form.Item name="publicBaseUrl" label="服务器访问地址" extra="服务端还会按部署安全策略校验协议、主机及私网访问许可。">
<Form.Item name="publicBaseUrl" label="服务器访问地址" extra="支持本机、局域网或公网地址,例如 http://192.168.1.10:8080。">
<div className="admin-storage-address-control">
<Input aria-label="服务器访问地址" autoComplete="off" inputMode="url" placeholder="https://canvas.example.com" prefix={<Globe2 className="size-4 text-foreground/35" />} />
<Button