feat(media): 对象存储 CDN - 支持阿里云 OSS 与腾讯云 COS 下载加速
合并平台与个人对象存储 CDN 下载域名配置,上传继续使用原始 Endpoint。\n\n后续修复对象路径二次编码并补充 CDN 访问控制说明。
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) != "",
|
||||
|
||||
Reference in New Issue
Block a user