feat(media): 对象存储 CDN - 支持阿里云 OSS 与腾讯云 COS 下载加速

合并平台与个人对象存储 CDN 下载域名配置,上传继续使用原始 Endpoint。\n\n后续修复对象路径二次编码并补充 CDN 访问控制说明。
This commit is contained in:
MasterChiefCN
2026-08-15 11:21:42 +08:00
committed by GitHub
parent 52131a53fc
commit 7ade97e0a3
9 changed files with 249 additions and 10 deletions

View File

@@ -747,7 +747,11 @@ type ossObjectStream struct {
}
func getOSSObjectRange(setting ossSettingValue, objectKey string, rangeHeader string) (*ossObjectStream, error) {
if normalizeOSSSetting(setting).Provider == tencentCOSProvider {
setting = normalizeOSSSetting(setting)
if setting.CDNBaseURL != "" {
return getOSSObjectRangeViaCDN(setting, objectKey, rangeHeader)
}
if setting.Provider == tencentCOSProvider {
return getCOSObjectRange(setting, objectKey, rangeHeader)
}
return getAliyunOSSObjectRange(setting, objectKey, rangeHeader)
@@ -795,7 +799,11 @@ func decimalDigits(value string) bool {
}
func signedOSSObjectURL(setting ossSettingValue, objectKey string, expiresAt time.Time) (string, error) {
if normalizeOSSSetting(setting).Provider == tencentCOSProvider {
setting = normalizeOSSSetting(setting)
if setting.CDNBaseURL != "" {
return ossCDNObjectURL(setting.CDNBaseURL, objectKey)
}
if setting.Provider == tencentCOSProvider {
return signedCOSObjectURL(setting, objectKey, expiresAt)
}
return signedAliyunOSSObjectURL(setting, objectKey, expiresAt)
@@ -858,6 +866,31 @@ func getCOSObjectRange(setting ossSettingValue, objectKey string, rangeHeader st
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 getOSSObjectRangeViaCDN(setting ossSettingValue, objectKey string, rangeHeader string) (*ossObjectStream, error) {
signedURL, err := signedOSSObjectURL(setting, objectKey, time.Now().Add(directResourceURLTTL))
if err != nil {
return nil, err
}
req, err := http.NewRequest(http.MethodGet, signedURL, nil)
if err != nil {
return nil, err
}
if rangeHeader != "" {
req.Header.Set("Range", rangeHeader)
}
ApplyDefaultOutboundHeaders(req)
resp, err := OutboundHTTPClient(2 * time.Minute).Do(req)
if err != nil {
return nil, fmt.Errorf("对象存储 CDN 读取失败:%w", err)
}
if (resp.StatusCode < 200 || resp.StatusCode >= 300) && resp.StatusCode != http.StatusRequestedRangeNotSatisfiable {
defer resp.Body.Close()
detail, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
return nil, fmt.Errorf("对象存储 CDN 读取失败:%s %s", resp.Status, strings.TrimSpace(string(detail)))
}
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 访问密钥不可用")
@@ -921,6 +954,35 @@ func cosBucketBaseURL(setting ossSettingValue) (*url.URL, error) {
return parsed, nil
}
func ossCDNBaseURL(raw string) (*url.URL, error) {
parsed, err := url.Parse(strings.TrimSpace(raw))
if err != nil || parsed.Hostname() == "" {
return nil, errors.New("对象存储 CDN 加速域名格式不正确")
}
if parsed.Scheme != "https" && parsed.Scheme != "http" {
return nil, errors.New("对象存储 CDN 加速域名只支持 http/https")
}
if parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" || strings.Trim(parsed.Path, "/") != "" {
return nil, errors.New("对象存储 CDN 加速域名不能包含认证信息、路径、查询参数或片段")
}
parsed.Path = ""
return parsed, nil
}
func ossCDNObjectURL(raw string, objectKey string) (string, error) {
baseURL, err := ossCDNBaseURL(raw)
if err != nil {
return "", err
}
objectKey = strings.TrimLeft(strings.TrimSpace(objectKey), "/")
if objectKey == "" {
return "", errors.New("对象存储对象路径为空")
}
// CDN 使用自己的访问鉴权与私有桶回源鉴权,不能携带 OSS/COS 的预签名参数。
baseURL.Path = "/" + escapeObjectKey(objectKey)
return baseURL.String(), nil
}
func newOSSRequest(method string, setting ossSettingValue, objectKey string, contentType string, body io.Reader) (*http.Request, error) {
baseURL, err := ossBucketBaseURL(setting)
if err != nil {

View File

@@ -63,6 +63,53 @@ func TestSignedOSSObjectURLSupportsTencentCOS(t *testing.T) {
}
}
func TestSignedOSSObjectURLUsesAliyunCDNBaseURLForDownloads(t *testing.T) {
value, err := signedOSSObjectURL(ossSettingValue{
Provider: aliyunOSSProvider, Endpoint: "https://oss-cn-test.aliyuncs.com", CDNBaseURL: "https://media.example.com",
Bucket: "private-bucket", AccessKeyID: "access-id", AccessKeySecret: "secret-value",
}, "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)
}
if parsed.Host != "media.example.com" || parsed.Path != "/users/u-1/image/test image.png" || parsed.RawQuery != "" {
t.Fatalf("Aliyun OSS CDN URL = %q", value)
}
}
func TestAliyunOSSUploadRequestStillUsesEndpointWhenCDNConfigured(t *testing.T) {
req, err := newOSSRequest(http.MethodPut, ossSettingValue{
Provider: aliyunOSSProvider, Endpoint: "https://oss-cn-test.aliyuncs.com", CDNBaseURL: "https://media.example.com",
Bucket: "private-bucket", AccessKeyID: "access-id", AccessKeySecret: "secret-value",
}, "users/u-1/image/test.png", "image/png", strings.NewReader("payload"))
if err != nil {
t.Fatal(err)
}
if req.URL.Host != "private-bucket.oss-cn-test.aliyuncs.com" || req.URL.Path != "/users/u-1/image/test.png" {
t.Fatalf("Aliyun OSS upload URL = %q", req.URL.String())
}
}
func TestSignedOSSObjectURLUsesTencentCOSCDNBaseURLWithoutCOSSignature(t *testing.T) {
value, err := signedOSSObjectURL(ossSettingValue{
Provider: tencentCOSProvider, Region: "ap-guangzhou", Bucket: "private-bucket-1250000000",
CDNBaseURL: "https://media.example.com", 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)
}
if parsed.Host != "media.example.com" || parsed.Path != "/users/u-1/image/test image.png" || parsed.RawQuery != "" {
t.Fatalf("signed COS CDN URL = %q", value)
}
}
func TestPutOSSObjectSupportsTencentCOS(t *testing.T) {
t.Setenv("CANVAS_ALLOW_PRIVATE_UPSTREAMS", "true")
payload := []byte("cos upload payload")
@@ -92,7 +139,7 @@ func TestPutOSSObjectSupportsTencentCOS(t *testing.T) {
etag, err := putOSSObject(ossSettingValue{
Provider: tencentCOSProvider, Endpoint: server.URL, Bucket: "private-bucket-1250000000",
AccessKeyID: "secret-id", AccessKeySecret: "secret-key",
CDNBaseURL: "https://media.example.com", 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)
@@ -136,6 +183,72 @@ func TestGetOSSObjectRangeSupportsTencentCOS(t *testing.T) {
}
}
func TestGetOSSObjectRangeUsesTencentCOSCDNBaseURL(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"))
}
if r.Header.Get("Authorization") != "" || r.URL.RawQuery != "" {
t.Errorf("Tencent CDN request should not carry COS authentication: header %q, query %q", r.Header.Get("Authorization"), r.URL.RawQuery)
}
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: "https://cos.ap-guangzhou.myqcloud.com", CDNBaseURL: 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 TestGetOSSObjectRangeUsesAliyunCDNBaseURLWithoutOSSSignature(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"))
}
if r.Header.Get("Authorization") != "" || r.URL.RawQuery != "" {
t.Errorf("Aliyun CDN request should not carry OSS authentication: header %q, query %q", r.Header.Get("Authorization"), r.URL.RawQuery)
}
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: aliyunOSSProvider, Endpoint: "https://oss-cn-test.aliyuncs.com", CDNBaseURL: server.URL,
Bucket: "private-bucket", AccessKeyID: "access-id", AccessKeySecret: "secret-value",
}, "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" {
@@ -151,6 +264,34 @@ func TestTencentCOSSettingDerivesEndpointAndDoesNotReuseAliyunSecret(t *testing.
if err == nil || !strings.Contains(err.Error(), "AccessKey Secret") {
t.Fatalf("ossSettingFromRequest() error = %v", err)
}
next, err := ossSettingFromRequest(OSSSettingRequest{
Enabled: true, Provider: tencentCOSProvider, Endpoint: server.URL, CDNBaseURL: server.URL,
Bucket: "private-bucket-1250000000", AccessKeyID: "secret-id", AccessKeySecret: "secret-key",
}, ossSettingValue{})
if err != nil || next.CDNBaseURL != server.URL {
t.Fatalf("Tencent COS CDN setting = %#v, %v", next, err)
}
}
func TestAliyunOSSSettingKeepsCDNBaseURL(t *testing.T) {
t.Setenv("CANVAS_ALLOW_PRIVATE_UPSTREAMS", "true")
server := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
defer server.Close()
next, err := ossSettingFromRequest(OSSSettingRequest{
Enabled: true, Provider: aliyunOSSProvider, Endpoint: server.URL, CDNBaseURL: server.URL,
Bucket: "private-bucket", AccessKeyID: "access-id", AccessKeySecret: "secret-value",
}, ossSettingValue{})
if err != nil || next.CDNBaseURL != server.URL {
t.Fatalf("Aliyun OSS CDN setting = %#v, %v", next, err)
}
}
func TestOSSCDNBaseURLRejectsNonDomainParts(t *testing.T) {
for _, value := range []string{"ftp://media.example.com", "https://media.example.com/assets", "https://media.example.com?token=value", "https://user@media.example.com"} {
if _, err := ossCDNBaseURL(value); err == nil {
t.Fatalf("ossCDNBaseURL(%q) should fail", value)
}
}
}
func TestPlatformProviderSwitchKeepsHistoricalCredentials(t *testing.T) {

View File

@@ -32,6 +32,7 @@ type OSSSettingRequest struct {
Provider string `json:"provider"`
Region string `json:"region"`
Endpoint string `json:"endpoint"`
CDNBaseURL string `json:"cdnBaseUrl"`
Bucket string `json:"bucket"`
AccessKeyID string `json:"accessKeyId"`
AccessKeySecret string `json:"accessKeySecret"`
@@ -44,6 +45,7 @@ type PublicOSSSetting struct {
Provider string `json:"provider"`
Region string `json:"region"`
Endpoint string `json:"endpoint"`
CDNBaseURL string `json:"cdnBaseUrl"`
Bucket string `json:"bucket"`
AccessKeyID string `json:"accessKeyId"`
HasAccessKeySecret bool `json:"hasAccessKeySecret"`
@@ -59,6 +61,7 @@ type ossSettingValue struct {
Provider string `json:"provider"`
Region string `json:"region"`
Endpoint string `json:"endpoint"`
CDNBaseURL string `json:"cdnBaseUrl"`
Bucket string `json:"bucket"`
AccessKeyID string `json:"accessKeyId"`
AccessKeySecret string `json:"accessKeySecret"`
@@ -447,6 +450,7 @@ func ossSettingFromRequest(req OSSSettingRequest, current ossSettingValue) (ossS
Provider: strings.TrimSpace(req.Provider),
Region: strings.TrimSpace(req.Region),
Endpoint: strings.TrimRight(strings.TrimSpace(req.Endpoint), "/"),
CDNBaseURL: strings.TrimRight(strings.TrimSpace(req.CDNBaseURL), "/"),
Bucket: strings.TrimSpace(req.Bucket),
AccessKeyID: strings.TrimSpace(req.AccessKeyID),
AccessKeySecret: strings.TrimSpace(req.AccessKeySecret),
@@ -474,6 +478,14 @@ func ossSettingFromRequest(req OSSSettingRequest, current ossSettingValue) (ossS
if _, err := ValidateOutboundURL(next.Endpoint); err != nil {
return next, err
}
if next.CDNBaseURL != "" {
if _, err := ossCDNBaseURL(next.CDNBaseURL); err != nil {
return next, BadAuthRequest(err.Error())
}
if _, err := ValidateOutboundURL(next.CDNBaseURL); err != nil {
return next, err
}
}
if next.AccessKeyID == "" {
return next, BadAuthRequest("请填写 AccessKey ID")
}
@@ -520,6 +532,7 @@ func normalizeOSSSetting(value ossSettingValue) ossSettingValue {
if value.Provider == tencentCOSProvider && value.Endpoint == "" && value.Region != "" {
value.Endpoint = "https://cos." + value.Region + ".myqcloud.com"
}
value.CDNBaseURL = strings.TrimRight(strings.TrimSpace(value.CDNBaseURL), "/")
value.Bucket = strings.TrimSpace(value.Bucket)
value.AccessKeyID = strings.TrimSpace(value.AccessKeyID)
value.AccessKeySecret = strings.TrimSpace(value.AccessKeySecret)
@@ -539,6 +552,7 @@ func publicOSSSetting(setting *model.SystemSetting, value ossSettingValue) Publi
Provider: value.Provider,
Region: value.Region,
Endpoint: value.Endpoint,
CDNBaseURL: value.CDNBaseURL,
Bucket: value.Bucket,
AccessKeyID: value.AccessKeyID,
HasAccessKeySecret: strings.TrimSpace(value.AccessKeySecret) != "",
@@ -559,6 +573,7 @@ func publicUserOSSSetting(setting *model.UserOSSSetting, value ossSettingValue)
Provider: value.Provider,
Region: value.Region,
Endpoint: value.Endpoint,
CDNBaseURL: value.CDNBaseURL,
Bucket: value.Bucket,
AccessKeyID: value.AccessKeyID,
HasAccessKeySecret: strings.TrimSpace(value.AccessKeySecret) != "",