feat(media): 本地存储 - 支持自定义访问地址与短时签名链接
This commit is contained in:
1 parent
660da45fdf
commit
8e0deaff48
6 files changed
+225
-61
No files matched your search
@@ -261,6 +261,31 @@ func RegisterUserDataRoutes(r *gin.RouterGroup, svc *service.Service) {
|
||||
}
|
||||
c.DataFromReader(stream.StatusCode, stream.ContentLength, resource.MimeType, stream.Body, nil)
|
||||
})
|
||||
r.GET("/public/resources/:id/file", func(c *gin.Context) {
|
||||
stream, err := svc.OpenPublicResourceRange(c.Param("id"), c.Query("expires"), c.Query("signature"), c.GetHeader("Range"))
|
||||
if err != nil {
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
defer stream.Body.Close()
|
||||
resource := stream.Resource
|
||||
if resource.MimeType == "" {
|
||||
resource.MimeType = "application/octet-stream"
|
||||
}
|
||||
c.Header("Cache-Control", "public, max-age=0, must-revalidate")
|
||||
c.Header("Accept-Ranges", "bytes")
|
||||
c.Header("Referrer-Policy", "no-referrer")
|
||||
c.Header("X-Content-Type-Options", "nosniff")
|
||||
if stream.ContentRange != "" {
|
||||
c.Header("Content-Range", stream.ContentRange)
|
||||
}
|
||||
if seeker, ok := stream.Body.(io.ReadSeeker); ok {
|
||||
c.Header("Content-Type", resource.MimeType)
|
||||
http.ServeContent(c.Writer, c.Request, resource.ID, resource.UpdatedAt, seeker)
|
||||
return
|
||||
}
|
||||
c.DataFromReader(stream.StatusCode, stream.ContentLength, resource.MimeType, stream.Body, nil)
|
||||
})
|
||||
r.GET("/assets", func(c *gin.Context) {
|
||||
user, err := currentUser(c, svc)
|
||||
if err != nil {
|
||||
|
||||
@@ -15,7 +15,6 @@ import (
|
||||
"net/http"
|
||||
"net/textproto"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -251,50 +250,7 @@ func (s *Service) hydrateProviderMedia(userID string, media *providerMedia, requ
|
||||
if resource.Status != "ready" {
|
||||
return errors.New("任务参考资源尚未上传完成")
|
||||
}
|
||||
// 本地素材自动迁移到 OSS
|
||||
if resource.Provider == "local" {
|
||||
setting, storageSettingID, useOSS, ossErr := s.activeResourceOSSSetting(userID)
|
||||
if ossErr != nil || !useOSS || setting.Provider != "aliyun" {
|
||||
return errors.New("当前 JSON 视频协议的参考素材需要公网可访问地址,请启用 OSS 后重新上传该素材")
|
||||
}
|
||||
// 读取本地文件并上传到 OSS
|
||||
_, body, readErr := s.OpenResource(userID, resourceID)
|
||||
if readErr != nil {
|
||||
return fmt.Errorf("读取本地素材失败:%w", readErr)
|
||||
}
|
||||
ossKey := ossObjectKey(setting, userID, resource.Kind, filepath.Base(resource.ObjectKey), time.Now())
|
||||
if _, uploadErr := putOSSObject(setting, ossKey, resource.MimeType, resource.Size, body); uploadErr != nil {
|
||||
body.Close()
|
||||
return fmt.Errorf("素材迁移到 OSS 失败:%w", uploadErr)
|
||||
}
|
||||
body.Close()
|
||||
// 更新资源记录
|
||||
resource.Provider = "aliyun"
|
||||
resource.ObjectKey = ossKey
|
||||
resource.Endpoint = setting.Endpoint
|
||||
resource.Bucket = setting.Bucket
|
||||
resource.StorageSettingID = storageSettingID
|
||||
if saveErr := s.repo.SaveResource(resource); saveErr != nil {
|
||||
return fmt.Errorf("更新资源记录失败:%w", saveErr)
|
||||
}
|
||||
// 生成签名 URL
|
||||
signedURL, signErr := signedOSSObjectURL(setting, ossKey, time.Now().Add(providerResourceURLTTL))
|
||||
if signErr != nil {
|
||||
return fmt.Errorf("生成 OSS 签名地址失败:%w", signErr)
|
||||
}
|
||||
media.URL = signedURL
|
||||
media.DataURL = ""
|
||||
media.MimeType = firstNonEmpty(media.MimeType, resource.MimeType)
|
||||
return nil
|
||||
}
|
||||
setting, err := s.ossSettingForResource(userID, resource)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if setting.Provider != "aliyun" {
|
||||
return errors.New("当前 JSON 视频协议的私有参考素材暂时只支持阿里云 OSS 签名地址")
|
||||
}
|
||||
signedURL, err := signedOSSObjectURL(setting, resource.ObjectKey, time.Now().Add(providerResourceURLTTL))
|
||||
signedURL, err := s.directResourceURL(resource, time.Now().Add(providerResourceURLTTL))
|
||||
if err != nil {
|
||||
return fmt.Errorf("生成 JSON 视频协议参考素材地址失败:%w", err)
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"bytes"
|
||||
"crypto/hmac"
|
||||
"crypto/sha1"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
@@ -57,26 +58,105 @@ func (s *Service) Resource(userID string, id string) (*model.Resource, error) {
|
||||
return resource, err
|
||||
}
|
||||
|
||||
// DirectResourceURL 先校验资源归属,再为私有 OSS 对象签发短时下载地址;本地资源继续由应用流式读取。
|
||||
// DirectResourceURL 先校验资源归属,再按实际存储位置签发短时下载地址。
|
||||
func (s *Service) DirectResourceURL(userID string, id string) (string, error) {
|
||||
resource, err := s.repo.ResourceForUser(userID, id)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return s.directResourceURL(resource, time.Now().Add(directResourceURLTTL))
|
||||
}
|
||||
|
||||
func (s *Service) directResourceURL(resource *model.Resource, expiresAt time.Time) (string, error) {
|
||||
if resource == nil {
|
||||
return "", errors.New("资源不存在")
|
||||
}
|
||||
if resource.Status != model.ResourceStatusReady {
|
||||
return "", BadAuthRequest("资源尚未上传完成")
|
||||
}
|
||||
if resource.Provider == "local" {
|
||||
return "", BadAuthRequest("当前资源未存储在 OSS")
|
||||
return s.signedPublicResourceURL(resource.ID, expiresAt)
|
||||
}
|
||||
setting, err := s.ossSettingForResource(userID, resource)
|
||||
setting, err := s.ossSettingForResource(resource.UserID, resource)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
setting.Provider = firstNonEmpty(resource.Provider, setting.Provider)
|
||||
setting.Endpoint = firstNonEmpty(resource.Endpoint, setting.Endpoint)
|
||||
setting.Bucket = firstNonEmpty(resource.Bucket, setting.Bucket)
|
||||
return signedOSSObjectURL(setting, resource.ObjectKey, time.Now().Add(directResourceURLTTL))
|
||||
return signedOSSObjectURL(setting, resource.ObjectKey, expiresAt)
|
||||
}
|
||||
|
||||
func (s *Service) signedPublicResourceURL(resourceID string, expiresAt time.Time) (string, error) {
|
||||
baseURL, err := s.publicResourceBaseURL()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
expires := strconv.FormatInt(expiresAt.UTC().Unix(), 10)
|
||||
signature, err := s.signPublicResource(resourceID, expires)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
baseURL.Path = strings.TrimRight(baseURL.Path, "/") + "/api/public/resources/" + url.PathEscape(resourceID) + "/file"
|
||||
query := baseURL.Query()
|
||||
query.Set("expires", expires)
|
||||
query.Set("signature", signature)
|
||||
baseURL.RawQuery = query.Encode()
|
||||
return baseURL.String(), nil
|
||||
}
|
||||
|
||||
func (s *Service) verifyPublicResourceSignature(resourceID string, expires string, signature string) error {
|
||||
if strings.TrimSpace(signature) == "" || !decimalDigits(expires) {
|
||||
return Forbidden("匿名下载链接无效")
|
||||
}
|
||||
expiresAt, err := strconv.ParseInt(expires, 10, 64)
|
||||
if err != nil || time.Now().UTC().Unix() > expiresAt {
|
||||
return Forbidden("匿名下载链接已过期")
|
||||
}
|
||||
expected, err := s.signPublicResource(resourceID, expires)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !hmac.Equal([]byte(expected), []byte(signature)) {
|
||||
return Forbidden("匿名下载链接无效")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) signPublicResource(resourceID string, expires string) (string, error) {
|
||||
key, err := s.settingsEncryptionKey()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
mac := hmac.New(sha256.New, key)
|
||||
_, _ = mac.Write([]byte(resourceID + "\n" + expires))
|
||||
return base64.RawURLEncoding.EncodeToString(mac.Sum(nil)), nil
|
||||
}
|
||||
|
||||
func (s *Service) publicResourceBaseURL() (*url.URL, error) {
|
||||
_, setting, err := s.readOSSSetting()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
raw := firstNonEmpty(setting.PublicBaseURL, os.Getenv("CANVAS_PUBLIC_BASE_URL"))
|
||||
if raw == "" {
|
||||
return nil, BadAuthRequest("服务器本地存储尚未配置服务器访问地址")
|
||||
}
|
||||
return validatePublicResourceBaseURL(raw)
|
||||
}
|
||||
|
||||
func validatePublicResourceBaseURL(raw string) (*url.URL, error) {
|
||||
parsed, err := ValidateOutboundURL(raw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if parsed.RawQuery != "" || parsed.Fragment != "" {
|
||||
return nil, BadAuthRequest("服务器访问地址不能包含查询参数或片段")
|
||||
}
|
||||
if strings.HasSuffix(strings.TrimRight(parsed.Path, "/"), "/api") {
|
||||
return nil, BadAuthRequest("服务器访问地址请填写根地址,不要包含 /api")
|
||||
}
|
||||
return parsed, nil
|
||||
}
|
||||
|
||||
func (s *Service) UploadResource(userID string, header *multipart.FileHeader, kind string, width int, height int, durationMs int64) (*model.Resource, error) {
|
||||
@@ -168,6 +248,20 @@ func (s *Service) OpenResourceRange(userID string, id string, rangeHeader string
|
||||
return s.openResourceRange(userID, resource, rangeHeader)
|
||||
}
|
||||
|
||||
func (s *Service) OpenPublicResourceRange(id string, expires string, signature string, rangeHeader string) (*ResourceStream, error) {
|
||||
resource, err := s.repo.Resource(id)
|
||||
if err != nil {
|
||||
return nil, Forbidden("匿名下载链接无效")
|
||||
}
|
||||
if resource.Provider != "local" {
|
||||
return nil, Forbidden("匿名下载链接无效")
|
||||
}
|
||||
if err := s.verifyPublicResourceSignature(resource.ID, expires, signature); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.openResourceRange(resource.UserID, resource, rangeHeader)
|
||||
}
|
||||
|
||||
func (s *Service) openResourceRange(userID string, resource *model.Resource, rangeHeader string) (*ResourceStream, error) {
|
||||
if resource.Status != model.ResourceStatusReady {
|
||||
return nil, BadAuthRequest("资源尚未上传完成")
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -109,16 +110,74 @@ func TestHydrateNewAPIChannel1ResourceUsesSignedOSSURL(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHydrateNewAPIChannel1ResourceRejectsLocalStorage(t *testing.T) {
|
||||
func TestHydrateNewAPIChannel1ResourceUsesSignedLocalURL(t *testing.T) {
|
||||
t.Setenv("CANVAS_ALLOW_PRIVATE_UPSTREAMS", "true")
|
||||
server := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
|
||||
defer server.Close()
|
||||
svc := newResourceTestService(t)
|
||||
settingJSON, _ := json.Marshal(ossSettingValue{Provider: "aliyun", PublicBaseURL: server.URL})
|
||||
if err := svc.repo.SaveSystemSetting(&model.SystemSetting{Key: ossSettingKey, ValueJSON: string(settingJSON)}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resource := model.Resource{ID: "resource-local", UserID: "user-1", Status: model.ResourceStatusReady, Provider: "local", ObjectKey: "local.png"}
|
||||
if err := svc.repo.CreateResource(&resource); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
err := svc.hydrateProviderMedia("user-1", &providerMedia{StorageKey: "resource:resource-local"}, true)
|
||||
if err == nil || !strings.Contains(err.Error(), "启用 OSS") {
|
||||
media := providerMedia{StorageKey: "resource:resource-local"}
|
||||
if err := svc.hydrateProviderMedia("user-1", &media, true); err != nil {
|
||||
t.Fatalf("hydrateProviderMedia() error = %v", err)
|
||||
}
|
||||
if !strings.HasPrefix(media.URL, server.URL+"/api/public/resources/resource-local/file?") || !strings.Contains(media.URL, "signature=") || media.DataURL != "" {
|
||||
t.Fatalf("media = %#v", media)
|
||||
}
|
||||
stored, err := svc.repo.Resource("resource-local")
|
||||
if err != nil || stored.Provider != "local" {
|
||||
t.Fatalf("resource provider changed: %#v, %v", stored, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublicResourceSignatureRejectsExpiredAndAlteredLinks(t *testing.T) {
|
||||
svc := newResourceTestService(t)
|
||||
expires := strconv.FormatInt(time.Now().Add(time.Minute).Unix(), 10)
|
||||
signature, err := svc.signPublicResource("resource-local", expires)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := svc.verifyPublicResourceSignature("resource-local", expires, signature); err != nil {
|
||||
t.Fatalf("verifyPublicResourceSignature() error = %v", err)
|
||||
}
|
||||
if err := svc.verifyPublicResourceSignature("resource-other", expires, signature); err == nil {
|
||||
t.Fatal("verifyPublicResourceSignature() accepted another resource ID")
|
||||
}
|
||||
expired := strconv.FormatInt(time.Now().Add(-time.Minute).Unix(), 10)
|
||||
expiredSignature, err := svc.signPublicResource("resource-local", expired)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := svc.verifyPublicResourceSignature("resource-local", expired, expiredSignature); err == nil {
|
||||
t.Fatal("verifyPublicResourceSignature() accepted an expired link")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateOSSSettingRequiresLocalServerAddress(t *testing.T) {
|
||||
t.Setenv("CANVAS_ALLOW_PRIVATE_UPSTREAMS", "true")
|
||||
server := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
|
||||
defer server.Close()
|
||||
svc := newResourceTestService(t)
|
||||
admin := &model.User{ID: "admin-1", Role: model.UserRoleAdmin}
|
||||
if _, err := svc.UpdateOSSSetting(admin, OSSSettingRequest{Provider: "aliyun"}); err == nil || !strings.Contains(err.Error(), "服务器访问地址") {
|
||||
t.Fatalf("UpdateOSSSetting() error = %v", err)
|
||||
}
|
||||
if _, err := svc.UpdateOSSSetting(admin, OSSSettingRequest{Provider: "aliyun", PublicBaseURL: server.URL + "/api"}); err == nil || !strings.Contains(err.Error(), "不要包含 /api") {
|
||||
t.Fatalf("UpdateOSSSetting(/api) error = %v", err)
|
||||
}
|
||||
setting, err := svc.UpdateOSSSetting(admin, OSSSettingRequest{Provider: "aliyun", PublicBaseURL: server.URL})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if setting.Enabled || setting.PublicBaseURL != server.URL {
|
||||
t.Fatalf("setting = %#v", setting)
|
||||
}
|
||||
}
|
||||
|
||||
func TestActiveResourceOSSSettingPrefersUserVersion(t *testing.T) {
|
||||
|
||||
@@ -85,6 +85,14 @@ func (s *Service) UpdateOSSSetting(actor *model.User, req OSSSettingRequest) (*P
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !next.Enabled {
|
||||
if next.PublicBaseURL == "" {
|
||||
return nil, BadAuthRequest("服务器本地存储需要填写服务器访问地址")
|
||||
}
|
||||
if _, err := validatePublicResourceBaseURL(next.PublicBaseURL); err != nil {
|
||||
return nil, fmt.Errorf("服务器访问地址无效:%w", err)
|
||||
}
|
||||
}
|
||||
stored := next
|
||||
stored.AccessKeySecret, err = s.encryptSettingSecret(next.AccessKeySecret)
|
||||
if err != nil {
|
||||
|
||||
Reference in new issue
Block a user