perf(media): OSS 资源 - 通过签名地址直连下载并禁止浏览器代理媒体

This commit is contained in:
Codex Audit
2026-09-23 11:10:20 +08:00
parent 8436904316
commit 7737047a8d
9 changed files with 213 additions and 19 deletions

View File

@@ -16,9 +16,11 @@ import (
"infinite-canvas/backend/internal/kernel"
"infinite-canvas/backend/internal/outbound"
"io"
"mime"
"net"
"net/http"
"net/url"
"path"
"strconv"
"strings"
"time"
@@ -177,7 +179,57 @@ func SignedOriginObjectURL(setting Settings, objectKey string, expiresAt time.Ti
return SignedAliyunOSSObjectURL(setting, objectKey, expiresAt)
}
// SignedOriginObjectDownloadURL signs a browser-facing object-store URL whose
// response is forced to download by Content-Disposition. This is intentionally
// an origin URL: a generic CDN URL cannot guarantee that response header
// overrides are forwarded and signed correctly.
func SignedOriginObjectDownloadURL(setting Settings, objectKey string, expiresAt time.Time, fileName string) (string, error) {
disposition := objectDownloadContentDisposition(fileName, objectKey)
setting = NormalizeSettings(setting)
if setting.Provider == s3Provider {
return SignedS3ObjectDownloadURL(setting, objectKey, expiresAt, disposition)
}
if setting.Provider == qiniuKodoProvider {
return SignedQiniuS3ObjectDownloadURL(setting, objectKey, expiresAt, disposition)
}
if setting.Provider == tencentCOSProvider {
return SignedCOSObjectDownloadURL(setting, objectKey, expiresAt, disposition)
}
return SignedAliyunOSSObjectDownloadURL(setting, objectKey, expiresAt, disposition)
}
func objectDownloadContentDisposition(fileName string, objectKey string) string {
name := strings.TrimSpace(fileName)
name = strings.ReplaceAll(name, "\\", "_")
name = strings.ReplaceAll(name, "/", "_")
name = strings.Map(func(char rune) rune {
if char < 0x20 || char == 0x7f {
return -1
}
return char
}, name)
if name == "" || name == "." {
name = path.Base(strings.TrimSpace(objectKey))
}
if name == "" || name == "." || name == "/" {
name = "download"
}
value := mime.FormatMediaType("attachment", map[string]string{"filename": name})
if value == "" {
return `attachment; filename="download"`
}
return value
}
func SignedAliyunOSSObjectURL(setting Settings, objectKey string, expiresAt time.Time) (string, error) {
return signedAliyunOSSObjectURL(setting, objectKey, expiresAt, "")
}
func SignedAliyunOSSObjectDownloadURL(setting Settings, objectKey string, expiresAt time.Time, disposition string) (string, error) {
return signedAliyunOSSObjectURL(setting, objectKey, expiresAt, disposition)
}
func signedAliyunOSSObjectURL(setting Settings, objectKey string, expiresAt time.Time, disposition string) (string, error) {
baseURL, err := OssBucketBaseURL(setting)
if err != nil {
return "", err
@@ -191,10 +243,15 @@ func SignedAliyunOSSObjectURL(setting Settings, objectKey string, expiresAt time
}
baseURL.Path = strings.TrimRight(baseURL.Path, "/") + "/" + strings.TrimLeft(objectKey, "/")
expires := strconv.FormatInt(expiresAt.UTC().Unix(), 10)
stringToSign := strings.Join([]string{http.MethodGet, "", "", expires, "/" + setting.Bucket + "/" + objectKey}, "\n")
canonicalResource := "/" + setting.Bucket + "/" + objectKey
query := baseURL.Query()
if disposition != "" {
query.Set("response-content-disposition", disposition)
canonicalResource += "?response-content-disposition=" + disposition
}
stringToSign := strings.Join([]string{http.MethodGet, "", "", expires, canonicalResource}, "\n")
mac := hmac.New(sha1.New, []byte(setting.AccessKeySecret))
_, _ = mac.Write([]byte(stringToSign))
query := baseURL.Query()
query.Set("OSSAccessKeyId", setting.AccessKeyID)
query.Set("Expires", expires)
query.Set("Signature", base64.StdEncoding.EncodeToString(mac.Sum(nil)))
@@ -284,6 +341,14 @@ func GetQiniuObjectRange(setting Settings, objectKey string, rangeHeader string)
}
func SignedCOSObjectURL(setting Settings, objectKey string, expiresAt time.Time) (string, error) {
return signedCOSObjectURL(setting, objectKey, expiresAt, "")
}
func SignedCOSObjectDownloadURL(setting Settings, objectKey string, expiresAt time.Time, disposition string) (string, error) {
return signedCOSObjectURL(setting, objectKey, expiresAt, disposition)
}
func signedCOSObjectURL(setting Settings, objectKey string, expiresAt time.Time, disposition string) (string, error) {
if strings.TrimSpace(setting.AccessKeyID) == "" || strings.TrimSpace(setting.AccessKeySecret) == "" {
return "", errors.New("COS 访问密钥不可用")
}
@@ -299,7 +364,11 @@ func SignedCOSObjectURL(setting Settings, objectKey string, expiresAt time.Time)
if err != nil {
return "", err
}
signedURL, err := client.Object.GetPresignedURL(context.Background(), http.MethodGet, objectKey, setting.AccessKeyID, setting.AccessKeySecret, expires, nil)
var options interface{}
if disposition != "" {
options = &cos.ObjectGetOptions{ResponseContentDisposition: disposition}
}
signedURL, err := client.Object.GetPresignedURL(context.Background(), http.MethodGet, objectKey, setting.AccessKeyID, setting.AccessKeySecret, expires, options)
if err != nil {
return "", err
}
@@ -328,6 +397,14 @@ func SignedQiniuObjectURL(setting Settings, objectKey string, expiresAt time.Tim
// SignedQiniuS3ObjectURL 用七牛兼容 S3 的 AWS Signature V4 访问私有空间。
// 没有绑定域名时,浏览器不直接访问该地址,而是由后端代理读取并返回文件。
func SignedQiniuS3ObjectURL(setting Settings, objectKey string, expiresAt time.Time) (string, error) {
return signedQiniuS3ObjectURL(setting, objectKey, expiresAt, "")
}
func SignedQiniuS3ObjectDownloadURL(setting Settings, objectKey string, expiresAt time.Time, disposition string) (string, error) {
return signedQiniuS3ObjectURL(setting, objectKey, expiresAt, disposition)
}
func signedQiniuS3ObjectURL(setting Settings, objectKey string, expiresAt time.Time, disposition string) (string, error) {
region := QiniuS3Region(setting)
if region == "" {
return "", errors.New("七牛云 Kodo S3 Region 不可用")
@@ -335,6 +412,11 @@ func SignedQiniuS3ObjectURL(setting Settings, objectKey string, expiresAt time.T
baseURL := &url.URL{Scheme: "https", Host: setting.Bucket + ".s3." + region + ".qiniucs.com"}
// 保留对象键的原始路径,让 url.URL 和 AWS signer 只做一次 RFC 3986 转义。
baseURL.Path = "/" + objectKey
if disposition != "" {
query := baseURL.Query()
query.Set("response-content-disposition", disposition)
baseURL.RawQuery = query.Encode()
}
req, err := http.NewRequest(http.MethodGet, baseURL.String(), nil)
if err != nil {
return "", err