Files
quyun-v2/backend/app/services/media_delivery.go

275 lines
8.6 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}