diff --git a/CHANGELOG.md b/CHANGELOG.md index 9133a95ba..ade2bd240 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +- 新增腾讯云 COS 私有对象存储,支持平台和个人存储配置、对象上传、Range 读取与短时签名 URL,并保留原有阿里云 OSS 链路。 + ## v1.0.49 - 创作首页与画布提示词引用图支持点击放大,并为新素材上传补充落库说明、进行中状态和完成反馈。 diff --git a/README.md b/README.md index 43bae4492..2a6eb8160 100644 --- a/README.md +++ b/README.md @@ -49,7 +49,7 @@ - **影视工作流**:结构化分镜脚本、角色卡、批量镜头节点、3D 导演台和控制图回写。 - **任务与素材**:后端异步队列、任务日志、失败重试、素材库及登录后的后端同步。 - **Agent 能力**:网页画布助手、本地 Canvas Agent、Codex App 插件和技能库。 -- **管理与安全**:用户与系统渠道、用量分析、私有 OSS、资源归属校验和敏感配置加密。 +- **管理与安全**:用户与系统渠道、用量分析、阿里云 OSS / 腾讯云 COS 私有对象存储、资源归属校验和敏感配置加密。 ## 界面预览 @@ -254,7 +254,7 @@ docker compose -f docker-compose.local.yml up -d --build - 用户自定义 AI API Key 保存在浏览器本地;登录态拉取模型目录时会临时提交给自部署后端但不会保存,创建异步任务时会加密入队;仅应使用可信部署,生产环境必须启用 HTTPS。 - 画布和素材登录后同步到后端,本地 `localForage` 继续承担缓存和降级存储。 -- 媒体资源在启用 OSS 时保存到私有 OSS,否则保存到后端数据目录;删除业务记录不会自动清理 OSS 对象。 +- 媒体资源在启用对象存储时可保存到私有阿里云 OSS 或腾讯云 COS,否则保存到后端数据目录;删除业务记录不会自动清理远端对象。 - 用户主动上传、Agent 会话附件和 AI 生成资源的单文件上限、账号容量及 UTC 日上传总量由后台“资源与策略”统一维护,默认分别为 50MB、32MB、64MB、2GB 和 200MB;管理员可按可信部署需要调整,单文件业务上限最高 999MB,Nginx 请求体硬上限为 1024MB。 ## 公网部署安全 diff --git a/backend/go.mod b/backend/go.mod index 615792f56..358da8016 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -5,6 +5,7 @@ go 1.25.0 require ( github.com/gin-gonic/gin v1.12.0 github.com/redis/go-redis/v9 v9.21.0 + github.com/tencentyun/cos-go-sdk-v5 v0.7.75 github.com/volcengine/volc-sdk-golang v1.0.253 golang.org/x/crypto v0.54.0 gorm.io/driver/postgres v1.6.2 @@ -18,6 +19,7 @@ require ( github.com/bytedance/sonic/loader v0.5.1 // indirect github.com/cenkalti/backoff/v4 v4.1.2 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/clbanning/mxj v1.8.4 // indirect github.com/cloudwego/base64x v0.1.7 // indirect github.com/gabriel-vasile/mimetype v1.4.13 // indirect github.com/gin-contrib/sse v1.1.1 // indirect @@ -26,6 +28,7 @@ require ( github.com/go-playground/validator/v10 v10.30.3 // indirect github.com/goccy/go-json v0.10.6 // indirect github.com/goccy/go-yaml v1.19.2 // indirect + github.com/google/go-querystring v1.1.0 // indirect github.com/google/uuid v1.3.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect @@ -38,8 +41,10 @@ require ( github.com/leodido/go-urn v1.4.0 // indirect github.com/mattn/go-isatty v0.0.23 // indirect github.com/mattn/go-sqlite3 v1.14.48 // indirect + github.com/mitchellh/mapstructure v1.4.3 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/mozillazg/go-httpheader v0.2.1 // indirect github.com/pelletier/go-toml/v2 v2.4.3 // indirect github.com/quic-go/qpack v0.6.0 // indirect github.com/quic-go/quic-go v0.60.0 // indirect diff --git a/backend/go.sum b/backend/go.sum index 1c74aadbd..6640cc98f 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -91,6 +91,7 @@ github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5P github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= github.com/circonus-labs/circonus-gometrics v2.3.1+incompatible/go.mod h1:nmEj6Dob7S7YxXgwXpfOuvO54S+tGdZdw9fuRZt25Ag= github.com/circonus-labs/circonusllhist v0.1.3/go.mod h1:kMXHVDlOchFAehlya5ePtbp5jckzBHf4XRpQvBOLI+I= +github.com/clbanning/mxj v1.8.4 h1:HuhwZtbyvyOw+3Z1AowPkU87JkJUSv751ELWaiTpj8I= github.com/clbanning/mxj v1.8.4/go.mod h1:BVjHeAH+rl9rs6f+QIpeRl0tfu10SXn1pUSa5PVGJng= github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= github.com/cloudwego/base64x v0.1.7 h1:NppS+Fgzg5ovhn4NkUXaDT3x9jldgH5ToMCqzBSi2zI= @@ -166,6 +167,7 @@ github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5x github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/golang-jwt/jwt/v4 v4.0.0/go.mod h1:/xlHOz8bRuivTWchD4jCa+NbatV+wEUSzwAxVc6locg= +github.com/golang-jwt/jwt/v5 v5.2.3/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= @@ -214,6 +216,8 @@ github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/ github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck= +github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= @@ -226,6 +230,7 @@ github.com/google/pprof v0.0.0-20200229191704-1ebb73c60ed3/go.mod h1:ZgVRPoUq/hf github.com/google/pprof v0.0.0-20200430221834-fc25d7d30c6d/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= github.com/google/pprof v0.0.0-20200708004538-1a94d8640e99/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= +github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= @@ -346,6 +351,8 @@ github.com/mitchellh/go-testing-interface v1.0.0/go.mod h1:kRemZodwjscx+RGhAo8eI github.com/mitchellh/mapstructure v0.0.0-20160808181253-ca63d7c062ee/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= github.com/mitchellh/mapstructure v1.4.2/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/mitchellh/mapstructure v1.4.3 h1:OVowDSCllw/YjdLkam3/sm7wEtOy59d8ndGgCcyj8cs= +github.com/mitchellh/mapstructure v1.4.3/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= @@ -353,6 +360,8 @@ github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lN github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/mozillazg/go-httpheader v0.2.1 h1:geV7TrjbL8KXSyvghnFm+NyTux/hxwueTSrwhe88TQQ= +github.com/mozillazg/go-httpheader v0.2.1/go.mod h1:jJ8xECTlalr6ValeXYdOF8fFUISeBAdw6E61aqQma60= github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= github.com/nats-io/jwt v1.2.2/go.mod h1:/xX356yQA6LuXI9xWW7mZNpxgF2mBmGecH+Fj34sP5Q= @@ -423,6 +432,7 @@ github.com/redis/go-redis/v9 v9.21.0 h1:FPBE4hhbAke+TLmcY3WkpbDffJEomdqPn3HYiqAt github.com/redis/go-redis/v9 v9.21.0/go.mod h1:v/M13XI1PVCDcm01VtPFOADfZtHf8YW3baQf57KlIkA= github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ= github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= +github.com/rs/dnscache v0.0.0-20230804202142-fc85eb664529/go.mod h1:qe5TWALJ8/a1Lqznoc5BDHpYX/8HU60Hm2AwRmqzxqA= github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts= github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc= @@ -455,6 +465,11 @@ github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXl github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.0.563/go.mod h1:7sCQWVkxcsR38nffDW057DRGk8mUjK1Ing/EFOK8s8Y= +github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/kms v1.0.563/go.mod h1:uom4Nvi9W+Qkom0exYiJ9VWJjXwyxtPYTkKkaLMlfE0= +github.com/tencentyun/cos-go-sdk-v5 v0.7.75 h1:eRCGP5chujSYGFnsCPxlhQcN9wtlSO/4eXrxKtLVAIw= +github.com/tencentyun/cos-go-sdk-v5 v0.7.75/go.mod h1:STbTNaNKq03u+gscPEGOahKzLcGSYOj6Dzc5zNay7Pg= +github.com/tencentyun/qcloud-cos-sts-sdk v0.0.0-20250515025012-e0eec8a5d123/go.mod h1:b18KQa4IxHbxeseW1GcZox53d7J0z39VNONTxvvlkXw= github.com/tv42/httpunix v0.0.0-20150427012821-b75d8614f926/go.mod h1:9ESjWnEqriFuLhtthL60Sar/7RFoluCcXsuvEwTV5KM= github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= diff --git a/backend/internal/service/provider.go b/backend/internal/service/provider.go index 2fface31e..5471093e1 100644 --- a/backend/internal/service/provider.go +++ b/backend/internal/service/provider.go @@ -491,7 +491,7 @@ func (s *Service) hydrateGenerationMedia(userID string, input *canvasGenerationI func (s *Service) hydrateProviderMedia(userID string, media *providerMedia, requirePublicURL bool) error { if !strings.HasPrefix(media.StorageKey, "resource:") { if requirePublicURL && strings.HasPrefix(strings.TrimSpace(media.DataURL), "data:") { - return errors.New("当前 JSON 视频协议的参考素材不能使用内嵌数据,请先上传到 OSS 或提供公网素材地址") + return errors.New("当前 JSON 视频协议的参考素材不能使用内嵌数据,请先上传到对象存储或提供公网素材地址") } return nil } @@ -1953,7 +1953,7 @@ func videoGenerationsMediaURL(media providerMedia) (string, error) { if isPublicMediaURL(value) || strings.HasPrefix(value, "data:") { return value, nil } - return "", errors.New("NewAPI Video Generations 的参考素材需要公网 URL;私有素材请先保存到 OSS") + return "", errors.New("NewAPI Video Generations 的参考素材需要公网 URL;私有素材请先保存到对象存储") } func normalizeNewAPIChannel2Ratio(value string, modelName string) string { @@ -2108,7 +2108,7 @@ func newAPIChannel1VideoBody(input canvasGenerationInput) (map[string]interface{ func newAPIChannel1MediaURL(media providerMedia) (string, error) { value := strings.TrimSpace(media.URL) if !isPublicMediaURL(value) { - return "", errors.New("NewAPI 媒体任务的参考素材必须使用公网 HTTP(S) URL,请启用 OSS 或提供公网素材地址") + return "", errors.New("NewAPI 媒体任务的参考素材必须使用公网 HTTP(S) URL,请启用对象存储或提供公网素材地址") } if _, err := ValidateOutboundURL(value); err != nil { return "", err diff --git a/backend/internal/service/resource.go b/backend/internal/service/resource.go index 2cb7f2fc4..a770c63e9 100644 --- a/backend/internal/service/resource.go +++ b/backend/internal/service/resource.go @@ -2,6 +2,7 @@ package service import ( "bytes" + "context" "crypto/hmac" "crypto/sha1" "crypto/sha256" @@ -26,6 +27,8 @@ import ( "time" "infinite-canvas/backend/internal/model" + + cos "github.com/tencentyun/cos-go-sdk-v5" ) const providerResourceURLTTL = 4 * time.Hour @@ -278,7 +281,7 @@ func (s *Service) openResourceRange(userID string, resource *model.Resource, ran return nil, err } if setting.AccessKeyID == "" || setting.AccessKeySecret == "" { - return nil, errors.New("OSS 访问密钥不可用") + return nil, errors.New("对象存储访问密钥不可用") } setting.Provider = firstNonEmpty(resource.Provider, setting.Provider) setting.Endpoint = firstNonEmpty(resource.Endpoint, setting.Endpoint) @@ -637,22 +640,41 @@ func (s *Service) ossSettingForResource(userID string, resource *model.Resource) if err != nil { return ossSettingValue{}, err } - setting.Provider = firstNonEmpty(resource.Provider, setting.Provider) + setting, err = ossSettingForProvider(setting, firstNonEmpty(resource.Provider, setting.Provider)) + if err != nil { + return ossSettingValue{}, err + } setting.Endpoint = firstNonEmpty(resource.Endpoint, setting.Endpoint) setting.Bucket = firstNonEmpty(resource.Bucket, setting.Bucket) if setting.AccessKeyID == "" || setting.AccessKeySecret == "" { - return ossSettingValue{}, errors.New("OSS 访问密钥不可用") + return ossSettingValue{}, errors.New("对象存储访问密钥不可用") } return setting, nil } +func ossSettingForProvider(setting ossSettingValue, provider string) (ossSettingValue, error) { + setting = normalizeOSSSetting(setting) + provider = strings.ToLower(strings.TrimSpace(provider)) + if provider == "" || provider == setting.Provider { + return setting, nil + } + credentials, ok := setting.ArchivedCredentials[provider] + if !ok || credentials.AccessKeyID == "" || credentials.AccessKeySecret == "" { + return ossSettingValue{}, errors.New("历史对象存储访问密钥不可用") + } + setting.Provider = provider + setting.AccessKeyID = credentials.AccessKeyID + setting.AccessKeySecret = credentials.AccessKeySecret + return setting, nil +} + func validateActiveOSSSetting(setting ossSettingValue, disabledMessage string, incompleteMessage string) (ossSettingValue, error) { setting = normalizeOSSSetting(setting) if !setting.Enabled { return ossSettingValue{}, BadAuthRequest(disabledMessage) } - if setting.Provider != "aliyun" { - return ossSettingValue{}, BadAuthRequest("暂时只支持阿里云 OSS") + if setting.Provider != aliyunOSSProvider && setting.Provider != tencentCOSProvider { + return ossSettingValue{}, BadAuthRequest("仅支持阿里云 OSS 和腾讯云 COS") } if setting.Bucket == "" || setting.Endpoint == "" || setting.AccessKeyID == "" || setting.AccessKeySecret == "" { return ossSettingValue{}, BadAuthRequest(incompleteMessage) @@ -686,6 +708,14 @@ func ossObjectKey(setting ossSettingValue, userID string, kind string, fileName } func putOSSObject(setting ossSettingValue, objectKey string, mimeType string, size int64, body io.Reader) (string, error) { + if normalizeOSSSetting(setting).Provider == tencentCOSProvider { + return putCOSObject(setting, objectKey, mimeType, size, body) + } + return putAliyunOSSObject(setting, objectKey, mimeType, size, body) +} + +// 阿里云 OSS 继续沿用原有 V1 签名和请求路径,避免已有部署行为发生变化。 +func putAliyunOSSObject(setting ossSettingValue, objectKey string, mimeType string, size int64, body io.Reader) (string, error) { if mimeType == "" { mimeType = "application/octet-stream" } @@ -717,6 +747,13 @@ type ossObjectStream struct { } func getOSSObjectRange(setting ossSettingValue, objectKey string, rangeHeader string) (*ossObjectStream, error) { + if normalizeOSSSetting(setting).Provider == tencentCOSProvider { + return getCOSObjectRange(setting, objectKey, rangeHeader) + } + return getAliyunOSSObjectRange(setting, objectKey, rangeHeader) +} + +func getAliyunOSSObjectRange(setting ossSettingValue, objectKey string, rangeHeader string) (*ossObjectStream, error) { req, err := newOSSRequest(http.MethodGet, setting, objectKey, "", nil) if err != nil { return nil, err @@ -758,6 +795,13 @@ func decimalDigits(value string) bool { } func signedOSSObjectURL(setting ossSettingValue, objectKey string, expiresAt time.Time) (string, error) { + if normalizeOSSSetting(setting).Provider == tencentCOSProvider { + return signedCOSObjectURL(setting, objectKey, expiresAt) + } + return signedAliyunOSSObjectURL(setting, objectKey, expiresAt) +} + +func signedAliyunOSSObjectURL(setting ossSettingValue, objectKey string, expiresAt time.Time) (string, error) { baseURL, err := ossBucketBaseURL(setting) if err != nil { return "", err @@ -782,6 +826,101 @@ func signedOSSObjectURL(setting ossSettingValue, objectKey string, expiresAt tim return baseURL.String(), nil } +func putCOSObject(setting ossSettingValue, objectKey string, mimeType string, size int64, body io.Reader) (string, error) { + client, err := newCOSClient(setting, 2*time.Minute) + if err != nil { + return "", err + } + if mimeType == "" { + mimeType = "application/octet-stream" + } + options := &cos.ObjectPutOptions{ObjectPutHeaderOptions: &cos.ObjectPutHeaderOptions{ContentType: mimeType, ContentLength: size}} + resp, err := client.Object.Put(context.Background(), objectKey, body, options) + if err != nil { + return "", fmt.Errorf("COS 上传失败:%w", err) + } + return strings.Trim(resp.Header.Get("ETag"), `"`), nil +} + +func getCOSObjectRange(setting ossSettingValue, objectKey string, rangeHeader string) (*ossObjectStream, error) { + client, err := newCOSClient(setting, 2*time.Minute) + if err != nil { + return nil, err + } + options := &cos.ObjectGetOptions{Range: rangeHeader} + resp, err := client.Object.Get(context.Background(), objectKey, options) + if err != nil { + if resp != nil && resp.StatusCode == http.StatusRequestedRangeNotSatisfiable { + return &ossObjectStream{body: io.NopCloser(bytes.NewReader(nil)), statusCode: resp.StatusCode, contentRange: resp.Header.Get("Content-Range"), acceptRanges: firstNonEmpty(resp.Header.Get("Accept-Ranges"), "bytes")}, nil + } + return nil, fmt.Errorf("COS 读取失败:%w", err) + } + return &ossObjectStream{body: resp.Body, statusCode: resp.StatusCode, contentLength: resp.ContentLength, contentRange: resp.Header.Get("Content-Range"), acceptRanges: firstNonEmpty(resp.Header.Get("Accept-Ranges"), "bytes")}, nil +} + +func signedCOSObjectURL(setting ossSettingValue, objectKey string, expiresAt time.Time) (string, error) { + if strings.TrimSpace(setting.AccessKeyID) == "" || strings.TrimSpace(setting.AccessKeySecret) == "" { + return "", errors.New("COS 访问密钥不可用") + } + objectKey = strings.TrimLeft(strings.TrimSpace(objectKey), "/") + if objectKey == "" { + return "", errors.New("COS 对象路径为空") + } + expires := time.Until(expiresAt) + if expires <= 0 { + return "", errors.New("COS 签名有效期必须晚于当前时间") + } + client, err := newCOSClient(setting, 2*time.Minute) + if err != nil { + return "", err + } + signedURL, err := client.Object.GetPresignedURL(context.Background(), http.MethodGet, objectKey, setting.AccessKeyID, setting.AccessKeySecret, expires, nil) + if err != nil { + return "", err + } + return signedURL.String(), nil +} + +func newCOSClient(setting ossSettingValue, timeout time.Duration) (*cos.Client, error) { + bucketURL, err := cosBucketBaseURL(setting) + if err != nil { + return nil, err + } + httpClient := OutboundHTTPClient(timeout) + httpClient.Transport = &cos.AuthorizationTransport{SecretID: setting.AccessKeyID, SecretKey: setting.AccessKeySecret, Transport: httpClient.Transport} + return cos.NewClient(&cos.BaseURL{BucketURL: bucketURL}, httpClient), nil +} + +func cosBucketBaseURL(setting ossSettingValue) (*url.URL, error) { + setting = normalizeOSSSetting(setting) + endpoint := strings.TrimRight(setting.Endpoint, "/") + if endpoint == "" { + return nil, errors.New("COS Endpoint 为空") + } + if !strings.Contains(endpoint, "://") { + endpoint = "https://" + endpoint + } + parsed, err := url.Parse(endpoint) + if err != nil { + return nil, err + } + if parsed.Host == "" || parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" || strings.Trim(parsed.Path, "/") != "" { + return nil, errors.New("COS Endpoint 格式不正确") + } + if setting.Bucket == "" { + return nil, errors.New("COS Bucket 为空") + } + host := strings.ToLower(parsed.Hostname()) + if strings.HasSuffix(host, ".myqcloud.com") || strings.HasSuffix(host, ".tencentcos.cn") { + if strings.HasPrefix(host, "cos.") || strings.HasPrefix(host, "cos-internal.") || strings.HasPrefix(host, "cos-website.") { + parsed.Host = setting.Bucket + "." + parsed.Host + } else if !strings.HasPrefix(host, strings.ToLower(setting.Bucket)+".") { + return nil, errors.New("COS Endpoint 中的 Bucket 与配置不一致") + } + } + return parsed, nil +} + func newOSSRequest(method string, setting ossSettingValue, objectKey string, contentType string, body io.Reader) (*http.Request, error) { baseURL, err := ossBucketBaseURL(setting) if err != nil { diff --git a/backend/internal/service/resource_test.go b/backend/internal/service/resource_test.go index 1c8ea5ca7..e6f8dd432 100644 --- a/backend/internal/service/resource_test.go +++ b/backend/internal/service/resource_test.go @@ -1,7 +1,10 @@ package service import ( + "bytes" "encoding/json" + "hash/crc64" + "io" "net/http" "net/http/httptest" "net/url" @@ -39,6 +42,155 @@ func TestSignedOSSObjectURLUsesExpiringQuerySignature(t *testing.T) { } } +func TestSignedOSSObjectURLSupportsTencentCOS(t *testing.T) { + value, err := signedOSSObjectURL(ossSettingValue{ + Provider: tencentCOSProvider, Region: "ap-guangzhou", Bucket: "private-bucket-1250000000", + AccessKeyID: "secret-id", AccessKeySecret: "secret-key", + }, "users/u-1/image/test image.png", time.Now().Add(time.Hour)) + if err != nil { + t.Fatalf("signedOSSObjectURL() error = %v", err) + } + parsed, err := url.Parse(value) + if err != nil { + t.Fatal(err) + } + query := parsed.Query() + if parsed.Host != "private-bucket-1250000000.cos.ap-guangzhou.myqcloud.com" || query.Get("q-sign-algorithm") != "sha1" || query.Get("q-ak") != "secret-id" || query.Get("q-signature") == "" { + t.Fatalf("signed COS URL = %q", value) + } + if strings.Contains(value, "secret-key") { + t.Fatalf("signed COS URL leaked secret key: %q", value) + } +} + +func TestPutOSSObjectSupportsTencentCOS(t *testing.T) { + t.Setenv("CANVAS_ALLOW_PRIVATE_UPSTREAMS", "true") + payload := []byte("cos upload payload") + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPut || r.URL.Path != "/users/u-1/image/test.png" { + t.Errorf("request = %s %s", r.Method, r.URL.Path) + } + if r.Header.Get("Content-Type") != "image/png" { + t.Errorf("Content-Type = %q", r.Header.Get("Content-Type")) + } + authorization := r.Header.Get("Authorization") + if !strings.Contains(authorization, "q-sign-algorithm=sha1") || !strings.Contains(authorization, "q-ak=secret-id") { + t.Errorf("Authorization = %q", authorization) + } + data, err := io.ReadAll(r.Body) + if err != nil { + t.Error(err) + } + if !bytes.Equal(data, payload) { + t.Errorf("body = %q", data) + } + w.Header().Set("ETag", `"cos-etag"`) + w.Header().Set("x-cos-hash-crc64ecma", strconv.FormatUint(crc64.Checksum(data, crc64.MakeTable(crc64.ECMA)), 10)) + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + etag, err := putOSSObject(ossSettingValue{ + Provider: tencentCOSProvider, Endpoint: server.URL, Bucket: "private-bucket-1250000000", + AccessKeyID: "secret-id", AccessKeySecret: "secret-key", + }, "users/u-1/image/test.png", "image/png", int64(len(payload)), bytes.NewReader(payload)) + if err != nil { + t.Fatal(err) + } + if etag != "cos-etag" { + t.Fatalf("ETag = %q", etag) + } +} + +func TestGetOSSObjectRangeSupportsTencentCOS(t *testing.T) { + t.Setenv("CANVAS_ALLOW_PRIVATE_UPSTREAMS", "true") + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Range") != "bytes=0-3" { + t.Errorf("Range = %q", r.Header.Get("Range")) + } + authorization := r.Header.Get("Authorization") + if !strings.Contains(authorization, "q-sign-algorithm=sha1") || !strings.Contains(authorization, "q-ak=secret-id") { + t.Errorf("Authorization = %q", authorization) + } + w.Header().Set("Accept-Ranges", "bytes") + w.Header().Set("Content-Range", "bytes 0-3/7") + w.WriteHeader(http.StatusPartialContent) + _, _ = w.Write([]byte("data")) + })) + defer server.Close() + + stream, err := getOSSObjectRange(ossSettingValue{ + Provider: tencentCOSProvider, Endpoint: server.URL, Bucket: "private-bucket-1250000000", + AccessKeyID: "secret-id", AccessKeySecret: "secret-key", + }, "users/u-1/image/test.png", "bytes=0-3") + if err != nil { + t.Fatal(err) + } + defer stream.body.Close() + data, err := io.ReadAll(stream.body) + if err != nil { + t.Fatal(err) + } + if stream.statusCode != http.StatusPartialContent || stream.contentRange != "bytes 0-3/7" || string(data) != "data" { + t.Fatalf("stream = %#v, data = %q", stream, data) + } +} + +func TestTencentCOSSettingDerivesEndpointAndDoesNotReuseAliyunSecret(t *testing.T) { + normalized := normalizeOSSSetting(ossSettingValue{Provider: tencentCOSProvider, Region: "ap-shanghai"}) + if normalized.Endpoint != "https://cos.ap-shanghai.myqcloud.com" { + t.Fatalf("Endpoint = %q", normalized.Endpoint) + } + + t.Setenv("CANVAS_ALLOW_PRIVATE_UPSTREAMS", "true") + server := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})) + defer server.Close() + _, err := ossSettingFromRequest(OSSSettingRequest{ + Enabled: true, Provider: tencentCOSProvider, Endpoint: server.URL, Bucket: "private-bucket-1250000000", AccessKeyID: "secret-id", + }, ossSettingValue{Provider: aliyunOSSProvider, AccessKeySecret: "aliyun-secret"}) + if err == nil || !strings.Contains(err.Error(), "AccessKey Secret") { + t.Fatalf("ossSettingFromRequest() error = %v", err) + } +} + +func TestPlatformProviderSwitchKeepsHistoricalCredentials(t *testing.T) { + current := ossSettingValue{Provider: aliyunOSSProvider, AccessKeyID: "aliyun-id", AccessKeySecret: "aliyun-secret"} + next := archiveOSSProviderCredentials(ossSettingValue{Provider: tencentCOSProvider, AccessKeyID: "cos-id", AccessKeySecret: "cos-secret"}, current) + historical, err := ossSettingForProvider(next, aliyunOSSProvider) + if err != nil { + t.Fatal(err) + } + if historical.Provider != aliyunOSSProvider || historical.AccessKeyID != "aliyun-id" || historical.AccessKeySecret != "aliyun-secret" { + t.Fatalf("historical setting = %#v", historical) + } + if _, ok := next.ArchivedCredentials[tencentCOSProvider]; ok { + t.Fatalf("active provider credentials were archived: %#v", next.ArchivedCredentials) + } +} + +func TestArchivedProviderCredentialsAreEncryptedAtRest(t *testing.T) { + svc := &Service{dataDir: t.TempDir()} + value := ossSettingValue{ + Provider: tencentCOSProvider, AccessKeyID: "cos-id", AccessKeySecret: "cos-secret", + ArchivedCredentials: map[string]ossProviderCredentials{ + aliyunOSSProvider: {AccessKeyID: "aliyun-id", AccessKeySecret: "aliyun-secret"}, + }, + } + stored, err := svc.encryptOSSSettingSecrets(value) + if err != nil { + t.Fatal(err) + } + if !strings.HasPrefix(stored.AccessKeySecret, encryptedSettingPrefix) || !strings.HasPrefix(stored.ArchivedCredentials[aliyunOSSProvider].AccessKeySecret, encryptedSettingPrefix) { + t.Fatalf("stored credentials are not encrypted: %#v", stored) + } + if _, err := svc.decryptOSSSettingSecrets(&stored); err != nil { + t.Fatal(err) + } + if stored.AccessKeySecret != "cos-secret" || stored.ArchivedCredentials[aliyunOSSProvider].AccessKeySecret != "aliyun-secret" { + t.Fatalf("decrypted credentials = %#v", stored) + } +} + func TestDirectResourceURLChecksOwnershipAndSignsOSSResource(t *testing.T) { svc := newResourceTestService(t) settingJSON, _ := json.Marshal(ossSettingValue{ diff --git a/backend/internal/service/settings.go b/backend/internal/service/settings.go index 3e312cee0..86cc5dfc7 100644 --- a/backend/internal/service/settings.go +++ b/backend/internal/service/settings.go @@ -22,6 +22,11 @@ import ( const ossSettingKey = "oss" const encryptedSettingPrefix = "enc:v1:" +const ( + aliyunOSSProvider = "aliyun" + tencentCOSProvider = "tencent" +) + type OSSSettingRequest struct { Enabled bool `json:"enabled"` Provider string `json:"provider"` @@ -59,6 +64,13 @@ type ossSettingValue struct { AccessKeySecret string `json:"accessKeySecret"` PublicBaseURL string `json:"publicBaseUrl"` PathPrefix string `json:"pathPrefix"` + // 平台切换云厂商后仍需读取历史资源,因此仅归档非当前厂商的访问密钥。 + ArchivedCredentials map[string]ossProviderCredentials `json:"archivedCredentials,omitempty"` +} + +type ossProviderCredentials struct { + AccessKeyID string `json:"accessKeyId"` + AccessKeySecret string `json:"accessKeySecret"` } func (s *Service) AdminOSSSetting(actor *model.User) (*PublicOSSSetting, error) { @@ -93,8 +105,8 @@ func (s *Service) UpdateOSSSetting(actor *model.User, req OSSSettingRequest) (*P return nil, fmt.Errorf("服务器访问地址无效:%w", err) } } - stored := next - stored.AccessKeySecret, err = s.encryptSettingSecret(next.AccessKeySecret) + next = archiveOSSProviderCredentials(next, currentValue) + stored, err := s.encryptOSSSettingSecrets(next) if err != nil { return nil, err } @@ -141,8 +153,7 @@ func (s *Service) UpdateUserOSSSetting(actor *model.User, req OSSSettingRequest) if err != nil { return nil, err } - stored := next - stored.AccessKeySecret, err = s.encryptSettingSecret(next.AccessKeySecret) + stored, err := s.encryptOSSSettingSecrets(next) if err != nil { return nil, err } @@ -173,14 +184,12 @@ func (s *Service) readOSSSetting() (*model.SystemSetting, ossSettingValue, error return nil, ossSettingValue{}, errors.New("平台 OSS 配置格式无效") } } - storedSecret := value.AccessKeySecret - value.AccessKeySecret, err = s.decryptSettingSecret(value.AccessKeySecret) + needsMigration, err := s.decryptOSSSettingSecrets(&value) if err != nil { return nil, ossSettingValue{}, err } - if storedSecret != "" && !strings.HasPrefix(storedSecret, encryptedSettingPrefix) { - migrated := value - migrated.AccessKeySecret, err = s.encryptSettingSecret(value.AccessKeySecret) + if needsMigration { + migrated, err := s.encryptOSSSettingSecrets(value) if err != nil { return nil, ossSettingValue{}, err } @@ -224,13 +233,49 @@ func (s *Service) userOSSSettingValue(setting *model.UserOSSSetting) (ossSetting return ossSettingValue{}, errors.New("用户 OSS 配置格式无效") } } - secret, err := s.decryptSettingSecret(value.AccessKeySecret) + if _, err := s.decryptOSSSettingSecrets(&value); err != nil { + return ossSettingValue{}, err + } + value.Enabled = setting.Enabled + return normalizeOSSSetting(value), nil +} + +func (s *Service) encryptOSSSettingSecrets(value ossSettingValue) (ossSettingValue, error) { + var err error + value.AccessKeySecret, err = s.encryptSettingSecret(value.AccessKeySecret) if err != nil { return ossSettingValue{}, err } + value.ArchivedCredentials = cloneOSSProviderCredentials(value.ArchivedCredentials) + for provider, credentials := range value.ArchivedCredentials { + credentials.AccessKeySecret, err = s.encryptSettingSecret(credentials.AccessKeySecret) + if err != nil { + return ossSettingValue{}, err + } + value.ArchivedCredentials[provider] = credentials + } + return value, nil +} + +func (s *Service) decryptOSSSettingSecrets(value *ossSettingValue) (bool, error) { + needsMigration := value.AccessKeySecret != "" && !strings.HasPrefix(value.AccessKeySecret, encryptedSettingPrefix) + secret, err := s.decryptSettingSecret(value.AccessKeySecret) + if err != nil { + return false, err + } value.AccessKeySecret = secret - value.Enabled = setting.Enabled - return normalizeOSSSetting(value), nil + value.ArchivedCredentials = cloneOSSProviderCredentials(value.ArchivedCredentials) + for provider, credentials := range value.ArchivedCredentials { + if credentials.AccessKeySecret != "" && !strings.HasPrefix(credentials.AccessKeySecret, encryptedSettingPrefix) { + needsMigration = true + } + credentials.AccessKeySecret, err = s.decryptSettingSecret(credentials.AccessKeySecret) + if err != nil { + return false, err + } + value.ArchivedCredentials[provider] = credentials + } + return needsMigration, nil } func (s *Service) encryptSettingSecret(value string) (string, error) { @@ -397,7 +442,7 @@ func (s *Service) decryptTaskSecrets(value interface{}) error { } func ossSettingFromRequest(req OSSSettingRequest, current ossSettingValue) (ossSettingValue, error) { - next := ossSettingValue{ + next := normalizeOSSSetting(ossSettingValue{ Enabled: req.Enabled, Provider: strings.TrimSpace(req.Provider), Region: strings.TrimSpace(req.Region), @@ -407,22 +452,24 @@ func ossSettingFromRequest(req OSSSettingRequest, current ossSettingValue) (ossS AccessKeySecret: strings.TrimSpace(req.AccessKeySecret), PublicBaseURL: strings.TrimRight(strings.TrimSpace(req.PublicBaseURL), "/"), PathPrefix: strings.Trim(strings.TrimSpace(req.PathPrefix), "/"), + }) + if next.Provider != aliyunOSSProvider && next.Provider != tencentCOSProvider { + return next, BadAuthRequest("仅支持阿里云 OSS 和腾讯云 COS") } - if next.Provider == "" { - next.Provider = "aliyun" - } - if next.Provider != "aliyun" { - return next, BadAuthRequest("暂时只支持阿里云 OSS") - } - if next.AccessKeySecret == "" { + current = normalizeOSSSetting(current) + // 不同云厂商的密钥不能复用;只有继续使用同一厂商时,留空才表示保留原密钥。 + if next.AccessKeySecret == "" && next.Provider == current.Provider { next.AccessKeySecret = current.AccessKeySecret } if next.Enabled { if next.Bucket == "" { - return next, BadAuthRequest("请填写 OSS Bucket") + return next, BadAuthRequest("请填写对象存储 Bucket") } if next.Endpoint == "" { - return next, BadAuthRequest("请填写 OSS Endpoint") + if next.Provider == tencentCOSProvider { + return next, BadAuthRequest("请填写腾讯云 COS Region 或 Endpoint") + } + return next, BadAuthRequest("请填写阿里云 OSS Endpoint") } if _, err := ValidateOutboundURL(next.Endpoint); err != nil { return next, err @@ -437,23 +484,53 @@ func ossSettingFromRequest(req OSSSettingRequest, current ossSettingValue) (ossS return next, nil } +func archiveOSSProviderCredentials(next ossSettingValue, current ossSettingValue) ossSettingValue { + next.ArchivedCredentials = cloneOSSProviderCredentials(current.ArchivedCredentials) + if current.Provider != next.Provider && (current.AccessKeyID != "" || current.AccessKeySecret != "") { + if next.ArchivedCredentials == nil { + next.ArchivedCredentials = make(map[string]ossProviderCredentials) + } + next.ArchivedCredentials[current.Provider] = ossProviderCredentials{AccessKeyID: current.AccessKeyID, AccessKeySecret: current.AccessKeySecret} + } + delete(next.ArchivedCredentials, next.Provider) + return next +} + +func cloneOSSProviderCredentials(source map[string]ossProviderCredentials) map[string]ossProviderCredentials { + if len(source) == 0 { + return nil + } + cloned := make(map[string]ossProviderCredentials, len(source)) + for provider, credentials := range source { + cloned[strings.ToLower(strings.TrimSpace(provider))] = ossProviderCredentials{ + AccessKeyID: strings.TrimSpace(credentials.AccessKeyID), + AccessKeySecret: strings.TrimSpace(credentials.AccessKeySecret), + } + } + return cloned +} + func normalizeOSSSetting(value ossSettingValue) ossSettingValue { - value.Provider = strings.TrimSpace(value.Provider) + value.Provider = strings.ToLower(strings.TrimSpace(value.Provider)) if value.Provider == "" { - value.Provider = "aliyun" + value.Provider = aliyunOSSProvider } value.Region = strings.TrimSpace(value.Region) value.Endpoint = strings.TrimRight(strings.TrimSpace(value.Endpoint), "/") + if value.Provider == tencentCOSProvider && value.Endpoint == "" && value.Region != "" { + value.Endpoint = "https://cos." + value.Region + ".myqcloud.com" + } value.Bucket = strings.TrimSpace(value.Bucket) value.AccessKeyID = strings.TrimSpace(value.AccessKeyID) value.AccessKeySecret = strings.TrimSpace(value.AccessKeySecret) value.PublicBaseURL = strings.TrimRight(strings.TrimSpace(value.PublicBaseURL), "/") value.PathPrefix = strings.Trim(strings.TrimSpace(value.PathPrefix), "/") + value.ArchivedCredentials = cloneOSSProviderCredentials(value.ArchivedCredentials) return value } func defaultOSSSetting() ossSettingValue { - return ossSettingValue{Provider: "aliyun"} + return ossSettingValue{Provider: aliyunOSSProvider} } func publicOSSSetting(setting *model.SystemSetting, value ossSettingValue) PublicOSSSetting { diff --git a/web/src/components/layout/user-oss-settings-form.tsx b/web/src/components/layout/user-oss-settings-form.tsx index 7b4d12de8..fd65df0d8 100644 --- a/web/src/components/layout/user-oss-settings-form.tsx +++ b/web/src/components/layout/user-oss-settings-form.tsx @@ -7,7 +7,7 @@ import { useUserStore } from "@/stores/use-user-store"; type OSSFormValues = { enabled?: boolean; - provider: "aliyun"; + provider: "aliyun" | "tencent"; region?: string; endpoint?: string; bucket?: string; @@ -24,6 +24,11 @@ export function UserOSSSettingsForm() { const [loading, setLoading] = useState(false); const [saving, setSaving] = useState(false); const savedAt = formatSavedAt(setting?.updatedAt); + const provider = Form.useWatch("provider", form) || "aliyun"; + const isTencentCOS = provider === "tencent"; + const hasCurrentProviderSecret = Boolean(setting && setting.provider === provider && setting.hasAccessKeySecret); + const accessKeyIdLabel = isTencentCOS ? "SecretId" : "AccessKey ID"; + const accessKeySecretLabel = isTencentCOS ? "SecretKey" : "AccessKey Secret"; useEffect(() => { if (!actor?.id) return; @@ -35,7 +40,7 @@ export function UserOSSSettingsForm() { setSetting(data.setting); form.setFieldsValue(toFormValues(data.setting)); }) - .catch((error) => active && message.error(error instanceof Error ? error.message : "读取个人 OSS 配置失败")) + .catch((error) => active && message.error(error instanceof Error ? error.message : "读取个人对象存储配置失败")) .finally(() => active && setLoading(false)); return () => { active = false; @@ -43,7 +48,7 @@ export function UserOSSSettingsForm() { }, [actor?.id, form, message]); if (!actor) { - return
启用后,新上传和新生成的媒体优先写入你的 OSS;停用时回退到平台存储。历史资源固定使用创建时的配置版本。
+支持阿里云 OSS 与腾讯云 COS。启用后,新上传和新生成的媒体优先写入你的存储桶;停用时回退到平台存储。历史资源固定使用创建时的配置版本。