275 lines
8.6 KiB
Go
275 lines
8.6 KiB
Go
package services
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"errors"
|
||
"os"
|
||
"path/filepath"
|
||
"strings"
|
||
"time"
|
||
|
||
"quyun/v2/app/errorx"
|
||
"quyun/v2/database/models"
|
||
"quyun/v2/pkg/consts"
|
||
provider_jwt "quyun/v2/providers/jwt"
|
||
|
||
jwtlib "github.com/golang-jwt/jwt/v4"
|
||
log "github.com/sirupsen/logrus"
|
||
"gorm.io/gorm"
|
||
)
|
||
|
||
// mediaDelivery 负责“媒体播放 token -> 实际播放地址”的安全下发。
|
||
// 当前版本只返回短时效 token 与 play endpoint;真实对象存储签名将在后续接入。
|
||
//
|
||
// @provider
|
||
type mediaDelivery struct {
|
||
jwt *provider_jwt.JWT
|
||
}
|
||
|
||
const defaultMediaPlayTokenTTL = 5 * time.Minute
|
||
|
||
type mediaPlayClaims struct {
|
||
TenantID int64 `json:"tenant_id"`
|
||
ContentID int64 `json:"content_id"`
|
||
AssetID int64 `json:"asset_id"`
|
||
Role consts.ContentAssetRole `json:"role"`
|
||
ViewerUserID int64 `json:"viewer_user_id,omitempty"`
|
||
jwtlib.RegisteredClaims
|
||
}
|
||
|
||
type MediaPlayResolutionKind string
|
||
|
||
const (
|
||
MediaPlayResolutionKindRedirect MediaPlayResolutionKind = "redirect"
|
||
MediaPlayResolutionKindLocalFile MediaPlayResolutionKind = "local_file"
|
||
)
|
||
|
||
type MediaPlayResolution struct {
|
||
Kind MediaPlayResolutionKind
|
||
|
||
RedirectURL string
|
||
|
||
LocalFilePath string
|
||
ContentType string
|
||
}
|
||
|
||
const (
|
||
defaultLocalMediaRoot = "var/media"
|
||
envLocalMediaRoot = "MEDIA_LOCAL_ROOT"
|
||
)
|
||
|
||
func (s *mediaDelivery) CreatePlayToken(tenantID, contentID, assetID int64, role consts.ContentAssetRole, viewerUserID int64, ttl time.Duration, now time.Time) (string, *time.Time, error) {
|
||
if tenantID <= 0 || contentID <= 0 || assetID <= 0 {
|
||
return "", nil, errorx.ErrInvalidParameter.WithMsg("tenant_id/content_id/asset_id must be > 0")
|
||
}
|
||
if ttl <= 0 {
|
||
ttl = defaultMediaPlayTokenTTL
|
||
}
|
||
if now.IsZero() {
|
||
now = time.Now()
|
||
}
|
||
|
||
exp := now.Add(ttl).UTC()
|
||
claims := &mediaPlayClaims{
|
||
TenantID: tenantID,
|
||
ContentID: contentID,
|
||
AssetID: assetID,
|
||
Role: role,
|
||
ViewerUserID: viewerUserID,
|
||
RegisteredClaims: jwtlib.RegisteredClaims{
|
||
Issuer: "v2-media",
|
||
IssuedAt: jwtlib.NewNumericDate(now.UTC()),
|
||
NotBefore: jwtlib.NewNumericDate(now.Add(-10 * time.Second).UTC()),
|
||
ExpiresAt: jwtlib.NewNumericDate(exp),
|
||
},
|
||
}
|
||
|
||
token := jwtlib.NewWithClaims(jwtlib.SigningMethodHS256, claims)
|
||
signed, err := token.SignedString(s.jwt.SigningKey)
|
||
if err != nil {
|
||
return "", nil, errorx.Wrap(err).WithMsg("sign play token failed")
|
||
}
|
||
return signed, &exp, nil
|
||
}
|
||
|
||
func (s *mediaDelivery) ParsePlayToken(tokenString string) (*mediaPlayClaims, error) {
|
||
token, err := jwtlib.ParseWithClaims(tokenString, &mediaPlayClaims{}, func(token *jwtlib.Token) (interface{}, error) {
|
||
if _, ok := token.Method.(*jwtlib.SigningMethodHMAC); !ok {
|
||
return nil, errorx.ErrSignatureInvalid.WithMsg("unexpected signing method")
|
||
}
|
||
return s.jwt.SigningKey, nil
|
||
})
|
||
if err != nil {
|
||
var ve *jwtlib.ValidationError
|
||
if errors.As(err, &ve) {
|
||
switch {
|
||
case ve.Errors&jwtlib.ValidationErrorExpired != 0:
|
||
return nil, errorx.ErrDataExpired.WithMsg("play token expired")
|
||
case ve.Errors&jwtlib.ValidationErrorNotValidYet != 0:
|
||
return nil, errorx.ErrPreconditionFailed.WithMsg("play token not active yet")
|
||
case ve.Errors&jwtlib.ValidationErrorMalformed != 0:
|
||
return nil, errorx.ErrInvalidParameter.WithMsg("play token malformed")
|
||
default:
|
||
return nil, errorx.ErrSignatureInvalid.WithMsg("play token invalid")
|
||
}
|
||
}
|
||
return nil, errorx.ErrSignatureInvalid.WithMsg("play token invalid")
|
||
}
|
||
|
||
claims, ok := token.Claims.(*mediaPlayClaims)
|
||
if !ok || !token.Valid || claims == nil {
|
||
return nil, errorx.ErrSignatureInvalid.WithMsg("play token invalid")
|
||
}
|
||
if claims.TenantID <= 0 || claims.ContentID <= 0 || claims.AssetID <= 0 {
|
||
return nil, errorx.ErrInvalidParameter.WithMsg("play token payload invalid")
|
||
}
|
||
return claims, nil
|
||
}
|
||
|
||
func localMediaRoot() string {
|
||
if v := strings.TrimSpace(os.Getenv(envLocalMediaRoot)); v != "" {
|
||
return v
|
||
}
|
||
return defaultLocalMediaRoot
|
||
}
|
||
|
||
func localMediaFilePath(root, objectKey string) (string, error) {
|
||
root = strings.TrimSpace(root)
|
||
if root == "" {
|
||
return "", errorx.ErrInternalError.WithMsg("local media root is empty")
|
||
}
|
||
if strings.TrimSpace(objectKey) == "" {
|
||
return "", errorx.ErrInternalError.WithMsg("object_key is empty")
|
||
}
|
||
if filepath.IsAbs(objectKey) {
|
||
return "", errorx.ErrForbidden.WithMsg("invalid object_key")
|
||
}
|
||
cleanKey := filepath.Clean(objectKey)
|
||
if cleanKey == "." || strings.HasPrefix(cleanKey, ".."+string(filepath.Separator)) || cleanKey == ".." {
|
||
return "", errorx.ErrForbidden.WithMsg("invalid object_key")
|
||
}
|
||
|
||
rootClean := filepath.Clean(root)
|
||
full := filepath.Join(rootClean, cleanKey)
|
||
rel, err := filepath.Rel(rootClean, full)
|
||
if err != nil || rel == "." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) || rel == ".." {
|
||
return "", errorx.ErrForbidden.WithMsg("invalid object_key")
|
||
}
|
||
return full, nil
|
||
}
|
||
|
||
func contentTypeFromAsset(asset *models.MediaAsset) string {
|
||
if asset == nil || len(asset.Meta) == 0 {
|
||
return ""
|
||
}
|
||
// 尽量从 meta.content_type 读取,避免本地文件无扩展名导致错误推断。
|
||
// meta 的具体结构不稳定,因此只做最佳努力解析。
|
||
var m map[string]any
|
||
_ = json.Unmarshal(asset.Meta, &m)
|
||
if v, ok := m["content_type"]; ok {
|
||
if s, ok := v.(string); ok {
|
||
return strings.TrimSpace(s)
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
|
||
// ResolvePlay resolves a play token to a redirect URL or a local file to serve.
|
||
// C1(local): 当 provider=local 时返回本地文件路径(不暴露 object_key)。
|
||
func (s *mediaDelivery) ResolvePlay(ctx context.Context, tenantID int64, token string) (*MediaPlayResolution, error) {
|
||
if tenantID <= 0 {
|
||
return nil, errorx.ErrInvalidParameter.WithMsg("tenant_id must be > 0")
|
||
}
|
||
claims, err := s.ParsePlayToken(token)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if claims.TenantID != tenantID {
|
||
return nil, errorx.ErrForbidden.WithMsg("tenant mismatch")
|
||
}
|
||
|
||
log.WithFields(log.Fields{
|
||
"tenant_id": tenantID,
|
||
"content_id": claims.ContentID,
|
||
"asset_id": claims.AssetID,
|
||
"role": claims.Role,
|
||
"viewer_user_id": claims.ViewerUserID,
|
||
"exp": claims.ExpiresAt,
|
||
}).Info("services.media_delivery.resolve_play_redirect")
|
||
|
||
tblAsset, queryAsset := models.MediaAssetQuery.QueryContext(ctx)
|
||
asset, err := queryAsset.Where(
|
||
tblAsset.TenantID.Eq(tenantID),
|
||
tblAsset.ID.Eq(claims.AssetID),
|
||
tblAsset.DeletedAt.IsNull(),
|
||
).First()
|
||
if err != nil {
|
||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||
return nil, errorx.ErrRecordNotFound.WithMsg("media asset not found")
|
||
}
|
||
return nil, err
|
||
}
|
||
if asset.Status != consts.MediaAssetStatusReady {
|
||
return nil, errorx.ErrPreconditionFailed.WithMsg("media asset not ready")
|
||
}
|
||
|
||
// 二次校验:token 必须对应“该内容 + 该角色”的绑定关系,避免 token 被滥用到非预期内容。
|
||
tblCA, queryCA := models.ContentAssetQuery.QueryContext(ctx)
|
||
if _, err := queryCA.Where(
|
||
tblCA.TenantID.Eq(tenantID),
|
||
tblCA.ContentID.Eq(claims.ContentID),
|
||
tblCA.AssetID.Eq(claims.AssetID),
|
||
tblCA.Role.Eq(claims.Role),
|
||
).First(); err != nil {
|
||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||
return nil, errorx.ErrRecordNotFound.WithMsg("content asset binding not found")
|
||
}
|
||
return nil, err
|
||
}
|
||
|
||
// 约束:play endpoint 不返回 bucket/object_key:
|
||
// - local: 直接下发本地文件(由 /media/play 进行 sendfile),不暴露路径结构;
|
||
// - remote: 返回短时效签名 URL(后续接入)。
|
||
switch asset.Provider {
|
||
case "local":
|
||
path, err := localMediaFilePath(localMediaRoot(), asset.ObjectKey)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if _, err := os.Stat(path); err != nil {
|
||
if os.IsNotExist(err) {
|
||
return nil, errorx.ErrRecordNotFound.WithMsg("media file not found")
|
||
}
|
||
return nil, errorx.Wrap(err).WithMsg("stat media file failed")
|
||
}
|
||
|
||
ct := contentTypeFromAsset(asset)
|
||
if ct == "" {
|
||
ct = "application/octet-stream"
|
||
}
|
||
|
||
return &MediaPlayResolution{
|
||
Kind: MediaPlayResolutionKindLocalFile,
|
||
LocalFilePath: path,
|
||
ContentType: ct,
|
||
}, nil
|
||
case "stub":
|
||
return nil, errorx.ErrServiceUnavailable.WithMsg("storage provider not configured")
|
||
default:
|
||
return nil, errorx.ErrServiceUnavailable.WithMsg("storage provider not implemented: " + asset.Provider)
|
||
}
|
||
}
|
||
|
||
// ResolvePlayRedirect is kept for compatibility with earlier code paths.
|
||
func (s *mediaDelivery) ResolvePlayRedirect(ctx context.Context, tenantID int64, token string) (string, error) {
|
||
res, err := s.ResolvePlay(ctx, tenantID, token)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
if res.Kind != MediaPlayResolutionKindRedirect || strings.TrimSpace(res.RedirectURL) == "" {
|
||
return "", errorx.ErrServiceUnavailable.WithMsg("play redirect not available")
|
||
}
|
||
return res.RedirectURL, nil
|
||
}
|