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 }