fix media playback and library workflows

This commit is contained in:
ShukeBta
2026-06-21 12:53:52 +08:00
committed by Shuke
parent ada41e7078
commit c90c134d3a
26 changed files with 921 additions and 81 deletions
+3
View File
@@ -296,6 +296,9 @@ func cloudPlayHandler(svc *service.Container) gin.HandlerFunc {
c.JSON(http.StatusBadRequest, gin.H{"error": "ref required"})
return
}
if !enforceScopedCloudPlaybackToken(c, svc, typ, ref) {
return
}
serveCloudResolvedLink(svc, c, typ, ref)
}
}
+1 -1
View File
@@ -1086,7 +1086,7 @@ func embyAttachTokenToMediaSourcesMap(out map[string]any, token string) {
func embyAttachTokenToMediaSources(sources []map[string]any, token string) {
for _, source := range sources {
for _, key := range []string{"DirectStreamUrl", "TranscodingUrl", "Path"} {
for _, key := range []string{"DirectStreamUrl", "TranscodingUrl"} {
raw, ok := source[key].(string)
if !ok {
continue
+12 -6
View File
@@ -1040,7 +1040,7 @@ func TestEmbyLowercasePlaybackInfoRouteReturnsJSON(t *testing.T) {
}
}
func TestEmbyPlaybackInfoTokenizesCloudPath(t *testing.T) {
func TestEmbyPlaybackInfoDoesNotExposeTokenInCloudPath(t *testing.T) {
gin.SetMode(gin.TestMode)
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
@@ -1099,8 +1099,11 @@ func TestEmbyPlaybackInfoTokenizesCloudPath(t *testing.T) {
}
source := body["MediaSources"].([]any)[0].(map[string]any)
pathURL, _ := source["Path"].(string)
if !strings.HasPrefix(pathURL, "/api/stream/cloud-1") || !strings.Contains(pathURL, "api_key=") {
t.Fatalf("cloud Path should be tokenized stream URL, got %#v", source)
if pathURL != "/api/stream/cloud-1" {
t.Fatalf("cloud Path should stay as non-tokenized display stream URL, got %#v", source)
}
if strings.Contains(pathURL, "api_key=") || strings.Contains(pathURL, "token=") {
t.Fatalf("cloud Path must not expose auth key/token: %#v", source)
}
if strings.Contains(pathURL, "/api/cloud/play/") {
t.Fatalf("cloud Path should not expose naked cloud play URL: %#v", source)
@@ -1117,7 +1120,7 @@ func TestEmbyPlaybackInfoTokenizesCloudPath(t *testing.T) {
}
}
func TestEmbyItemsTokenizesEmbeddedCloudMediaSources(t *testing.T) {
func TestEmbyItemsDoNotExposeTokenInEmbeddedCloudPath(t *testing.T) {
gin.SetMode(gin.TestMode)
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
@@ -1180,8 +1183,11 @@ func TestEmbyItemsTokenizesEmbeddedCloudMediaSources(t *testing.T) {
}
source := items[0].(map[string]any)["MediaSources"].([]any)[0].(map[string]any)
pathURL, _ := source["Path"].(string)
if !strings.HasPrefix(pathURL, "/api/stream/cloud-1") || !strings.Contains(pathURL, "api_key=") {
t.Fatalf("embedded cloud Path should be tokenized stream URL, got %#v", source)
if pathURL != "/api/stream/cloud-1" {
t.Fatalf("embedded cloud Path should stay as non-tokenized display stream URL, got %#v", source)
}
if strings.Contains(pathURL, "api_key=") || strings.Contains(pathURL, "token=") {
t.Fatalf("embedded cloud Path must not expose auth key/token: %#v", source)
}
}
+3
View File
@@ -337,6 +337,9 @@ func streamHandler(svc *service.Container) gin.HandlerFunc {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
if !enforceScopedPlaybackToken(c, m.ID) {
return
}
err = svc.Stream.ServeFileWithCloudMode(c.Writer, c.Request, c.Param("id"), service.CloudPlaybackModeSTRM)
if errors.Is(err, service.ErrMediaNotFound) {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
+5 -2
View File
@@ -138,12 +138,15 @@ func TestListMediaGroupsMultipleVersionsByDefault(t *testing.T) {
if grouped.Total != 1 || len(grouped.Items) != 1 {
t.Fatalf("grouped response total=%d len=%d body=%#v", grouped.Total, len(grouped.Items), grouped)
}
if grouped.Items[0].ID != "movie-2160" {
t.Fatalf("primary id = %q, want highest quality version", grouped.Items[0].ID)
if grouped.Items[0].ID != "movie-1080" {
t.Fatalf("primary id = %q, want local version to remain primary", grouped.Items[0].ID)
}
if len(grouped.Items[0].Versions) != 2 {
t.Fatalf("versions = %#v, want both versions", grouped.Items[0].Versions)
}
if grouped.Items[0].Versions[0].ID != "movie-1080" || grouped.Items[0].Versions[1].ID != "movie-2160" {
t.Fatalf("versions should keep local before cloud: %#v", grouped.Items[0].Versions)
}
raw := requestMediaList(t, svc, "/api/libraries/"+lib.ID+"/media?group_versions=0", lib.ID)
if raw.Total != 2 || len(raw.Items) != 2 {
+6 -7
View File
@@ -28,7 +28,7 @@ func playbackInfoHandler(svc *service.Container) gin.HandlerFunc {
c.JSON(http.StatusNotFound, gin.H{"error": "media not found"})
return
}
token := externalPlaybackToken(c, svc)
token := externalPlaybackToken(c, svc, m.ID, m.DurationSec)
profileQuery := externalProfileQuery(c)
c.JSON(http.StatusOK, gin.H{
"media": m,
@@ -73,7 +73,7 @@ func externalPlayersHandler(svc *service.Container) gin.HandlerFunc {
c.JSON(http.StatusNotFound, gin.H{"error": "media not found"})
return
}
token := externalPlaybackToken(c, svc)
token := externalPlaybackToken(c, svc, m.ID, m.DurationSec)
streamURL := absoluteRequestURL(c, "/api/stream/"+m.ID+"?token="+url.QueryEscape(token)+externalProfileQuery(c))
escapedStream := url.QueryEscape(streamURL)
c.JSON(http.StatusOK, gin.H{
@@ -98,10 +98,9 @@ func externalURLHandler(svc *service.Container) gin.HandlerFunc {
c.JSON(http.StatusNotFound, gin.H{"error": "media not found"})
return
}
token := externalPlaybackToken(c, svc)
token := externalPlaybackToken(c, svc, m.ID, m.DurationSec)
c.JSON(http.StatusOK, gin.H{
"url": absoluteRequestURL(c, "/api/stream/"+m.ID+"?token="+url.QueryEscape(token)+externalProfileQuery(c)),
"token": token,
"url": absoluteRequestURL(c, "/api/stream/"+m.ID+"?token="+url.QueryEscape(token)+externalProfileQuery(c)),
})
}
}
@@ -125,13 +124,13 @@ func externalProfileQuery(c *gin.Context) string {
return query
}
func externalPlaybackToken(c *gin.Context, svc *service.Container) string {
func externalPlaybackToken(c *gin.Context, svc *service.Container, mediaID string, durationSec int) string {
uid, _ := c.Get(middleware.CtxUserID)
u, err := svc.Repo.User.FindByID(c.Request.Context(), toString(uid))
if err != nil || u == nil {
return ""
}
token, err := svc.Auth.IssueToken(u)
token, err := svc.Auth.IssueExternalPlaybackToken(u, mediaID, durationSec)
if err != nil {
return ""
}
+254
View File
@@ -0,0 +1,254 @@
package handler
import (
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/glebarez/sqlite"
"github.com/golang-jwt/jwt/v5"
"go.uber.org/zap"
"gorm.io/gorm"
"github.com/ShukeBta/MediaStationGo/internal/config"
"github.com/ShukeBta/MediaStationGo/internal/middleware"
"github.com/ShukeBta/MediaStationGo/internal/model"
"github.com/ShukeBta/MediaStationGo/internal/repository"
"github.com/ShukeBta/MediaStationGo/internal/service"
)
func TestExternalURLUsesMediaScopedPlaybackToken(t *testing.T) {
router, svc, secret := newPlaybackScopeTestRouter(t)
loginToken := signedTestToken(t, secret)
req := httptest.NewRequest(http.MethodGet, "http://nas.local/api/playback/media-1/external-url", nil)
req.Header.Set("Authorization", "Bearer "+loginToken)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d body=%s", w.Code, w.Body.String())
}
var payload struct {
URL string `json:"url"`
Token string `json:"token"`
}
if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil {
t.Fatalf("decode: %v", err)
}
if payload.Token != "" {
t.Fatalf("external-url should not expose a separate token field")
}
if strings.Contains(payload.URL, loginToken) {
t.Fatalf("external url leaked the caller's login token")
}
streamURL, err := url.Parse(payload.URL)
if err != nil {
t.Fatalf("parse stream url: %v", err)
}
playToken := streamURL.Query().Get("token")
if playToken == "" {
t.Fatalf("external url missing playback token: %q", payload.URL)
}
claims := &service.Claims{}
parsed, err := jwt.ParseWithClaims(playToken, claims, func(*jwt.Token) (interface{}, error) {
return []byte(secret), nil
})
if err != nil || !parsed.Valid {
t.Fatalf("playback token did not parse: %v", err)
}
if claims.Purpose != service.ExternalPlaybackTokenPurpose || claims.MediaID != "media-1" || claims.UserID != "user-1" {
t.Fatalf("unexpected playback claims: %+v", claims)
}
ttl := time.Until(claims.ExpiresAt.Time)
wantTTL := service.ExternalPlaybackTokenDurationForMedia(2 * 60 * 60)
if ttl < wantTTL-time.Minute || ttl > wantTTL+time.Second {
t.Fatalf("playback token ttl = %v, want about %v", ttl, wantTTL)
}
user, err := svc.Repo.User.FindByID(req.Context(), "user-1")
if err != nil || user == nil {
t.Fatalf("find user: %v", err)
}
accountToken, err := svc.Auth.IssueToken(user)
if err != nil {
t.Fatalf("issue account token: %v", err)
}
if playToken == accountToken {
t.Fatalf("playback token should not be the reusable account token")
}
}
func TestScopedPlaybackTokenCannotStreamAnotherMedia(t *testing.T) {
router, svc, _ := newPlaybackScopeTestRouter(t)
user, err := svc.Repo.User.FindByID(t.Context(), "user-1")
if err != nil || user == nil {
t.Fatalf("find user: %v", err)
}
playToken, err := svc.Auth.IssueExternalPlaybackToken(user, "media-1", 2*60*60)
if err != nil {
t.Fatalf("issue playback token: %v", err)
}
req := httptest.NewRequest(http.MethodGet, "http://nas.local/api/stream/media-2?token="+url.QueryEscape(playToken), nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusForbidden {
t.Fatalf("status = %d body=%s", w.Code, w.Body.String())
}
}
func TestScopedPlaybackTokenCannotRetargetCloudRef(t *testing.T) {
router, svc, _ := newPlaybackScopeTestRouter(t)
user, err := svc.Repo.User.FindByID(t.Context(), "user-1")
if err != nil || user == nil {
t.Fatalf("find user: %v", err)
}
playToken, err := svc.Auth.IssueExternalPlaybackToken(user, "media-1", 2*60*60)
if err != nil {
t.Fatalf("issue playback token: %v", err)
}
req := httptest.NewRequest(http.MethodGet, "http://nas.local/api/cloud/play/openlist?ref=other&media_id=media-1&token="+url.QueryEscape(playToken), nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusForbidden {
t.Fatalf("status = %d body=%s", w.Code, w.Body.String())
}
}
func TestScopedPlaybackTokenCannotCallRegularAPI(t *testing.T) {
router, svc, _ := newPlaybackScopeTestRouter(t)
api := router.Group("/api")
api.Use(middleware.AuthRequired(svc.Cfg.Secrets.JWTSecret))
api.GET("/me", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"ok": true})
})
user, err := svc.Repo.User.FindByID(t.Context(), "user-1")
if err != nil || user == nil {
t.Fatalf("find user: %v", err)
}
playToken, err := svc.Auth.IssueExternalPlaybackToken(user, "media-1", 2*60*60)
if err != nil {
t.Fatalf("issue playback token: %v", err)
}
req := httptest.NewRequest(http.MethodGet, "http://nas.local/api/me?token="+url.QueryEscape(playToken), nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusForbidden {
t.Fatalf("status = %d body=%s", w.Code, w.Body.String())
}
}
func TestExternalPlaybackTokenUsesUnknownDurationFallback(t *testing.T) {
_, svc, secret := newPlaybackScopeTestRouter(t)
user, err := svc.Repo.User.FindByID(t.Context(), "user-1")
if err != nil || user == nil {
t.Fatalf("find user: %v", err)
}
playToken, err := svc.Auth.IssueExternalPlaybackToken(user, "media-1", 0)
if err != nil {
t.Fatalf("issue playback token: %v", err)
}
claims := &service.Claims{}
parsed, err := jwt.ParseWithClaims(playToken, claims, func(*jwt.Token) (interface{}, error) {
return []byte(secret), nil
})
if err != nil || !parsed.Valid {
t.Fatalf("parse playback token: %v", err)
}
ttl := time.Until(claims.ExpiresAt.Time)
if ttl < service.ExternalPlaybackTokenUnknownDuration-time.Minute || ttl > service.ExternalPlaybackTokenUnknownDuration+time.Second {
t.Fatalf("unknown duration ttl = %v, want about %v", ttl, service.ExternalPlaybackTokenUnknownDuration)
}
}
func TestExternalPlaybackTokenDurationIsCapped(t *testing.T) {
if got := service.ExternalPlaybackTokenDurationForMedia(int((72 * time.Hour).Seconds())); got != service.ExternalPlaybackTokenMaxDuration {
t.Fatalf("duration cap = %v, want %v", got, service.ExternalPlaybackTokenMaxDuration)
}
}
func newPlaybackScopeTestRouter(t *testing.T) (*gin.Engine, *service.Container, string) {
t.Helper()
gin.SetMode(gin.TestMode)
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err := db.AutoMigrate(
&model.User{},
&model.UserPermission{},
&model.RefreshToken{},
&model.Setting{},
&model.Library{},
&model.Media{},
&model.PlayProfile{},
); err != nil {
t.Fatal(err)
}
repos := repository.New(db)
cfg := &config.Config{}
cfg.Secrets.JWTSecret = "playback-scope-secret"
log := zap.NewNop()
permissions := service.NewPermissionService(log, repos)
auth := service.NewAuthService(cfg, log, repos, service.NewTokenService(cfg, log, repos), permissions)
svc := &service.Container{
Cfg: cfg,
Repo: repos,
Auth: auth,
Media: service.NewMediaService(cfg, log, repos),
Stream: service.NewStreamService(cfg, log, repos, nil),
Permissions: permissions,
}
if err := repos.User.Create(t.Context(), &model.User{
Base: model.Base{ID: "user-1"},
Username: "viewer",
Role: "admin",
Tier: "plus",
IsActive: true,
}); err != nil {
t.Fatal(err)
}
lib := model.Library{Base: model.Base{ID: "lib-1"}, Name: "OpenList", Path: "cloud://openlist/Movies", Type: "movie", Enabled: true}
if err := repos.DB.Create(&lib).Error; err != nil {
t.Fatal(err)
}
rows := []model.Media{
{
Base: model.Base{ID: "media-1"},
LibraryID: lib.ID,
Title: "Cloud 1",
Path: "cloud://openlist/Movies/Movie.mkv",
DurationSec: 2 * 60 * 60,
STRMURL: "/api/cloud/play/openlist?ref=/Movies/Movie.mkv",
},
{
Base: model.Base{ID: "media-2"},
LibraryID: lib.ID,
Title: "Cloud 2",
Path: "cloud://openlist/Movies/Other.mkv",
STRMURL: "/api/cloud/play/openlist?ref=/Movies/Other.mkv",
},
}
if err := repos.DB.Create(&rows).Error; err != nil {
t.Fatal(err)
}
router := gin.New()
api := router.Group("/api")
api.Use(middleware.AuthRequired(cfg.Secrets.JWTSecret))
api.GET("/playback/:id/external-url", externalURLHandler(svc))
api.GET("/stream/:id", streamHandler(svc))
api.GET("/cloud/play/:type", cloudPlayHandler(svc))
return router, svc, cfg.Secrets.JWTSecret
}
+118
View File
@@ -0,0 +1,118 @@
package handler
import (
"net/http"
"net/url"
"strings"
"github.com/gin-gonic/gin"
"github.com/ShukeBta/MediaStationGo/internal/middleware"
"github.com/ShukeBta/MediaStationGo/internal/model"
"github.com/ShukeBta/MediaStationGo/internal/service"
)
func enforceScopedPlaybackToken(c *gin.Context, mediaID string) bool {
mediaID = strings.TrimSpace(mediaID)
purpose, _ := c.Get(middleware.CtxTokenPurpose)
if strings.TrimSpace(toString(purpose)) == "" {
return true
}
if strings.TrimSpace(toString(purpose)) != service.ExternalPlaybackTokenPurpose {
c.JSON(http.StatusForbidden, gin.H{"error": "playback token scope denied"})
return false
}
tokenMediaID, _ := c.Get(middleware.CtxTokenMediaID)
if mediaID == "" || strings.TrimSpace(toString(tokenMediaID)) != mediaID {
c.JSON(http.StatusForbidden, gin.H{"error": "playback token media mismatch"})
return false
}
return true
}
func enforceScopedCloudPlaybackToken(c *gin.Context, svc *service.Container, typ, ref string) bool {
purpose, _ := c.Get(middleware.CtxTokenPurpose)
if strings.TrimSpace(toString(purpose)) == "" {
return true
}
if strings.TrimSpace(toString(purpose)) != service.ExternalPlaybackTokenPurpose {
c.JSON(http.StatusForbidden, gin.H{"error": "playback token scope denied"})
return false
}
tokenMediaID, _ := c.Get(middleware.CtxTokenMediaID)
mediaID := strings.TrimSpace(toString(tokenMediaID))
if mediaID == "" || strings.TrimSpace(c.Query("media_id")) != mediaID {
c.JSON(http.StatusForbidden, gin.H{"error": "playback token media mismatch"})
return false
}
m, err := svc.Repo.Media.FindByID(c.Request.Context(), mediaID)
if err != nil || m == nil || !mediaVisibleForRequest(c, svc, m) {
c.JSON(http.StatusNotFound, gin.H{"error": "media not found"})
return false
}
if !cloudPlaybackTargetMatchesMedia(m, typ, ref) {
c.JSON(http.StatusForbidden, gin.H{"error": "playback token target mismatch"})
return false
}
return true
}
func cloudPlaybackTargetMatchesMedia(m *model.Media, typ, ref string) bool {
if m == nil {
return false
}
if strmTyp, strmRef, ok := parseCloudPlaybackTarget(m.STRMURL); ok &&
strings.EqualFold(strmTyp, typ) && sameCloudPlaybackRef(strmRef, ref) {
return true
}
pathTyp, pathRef, ok := parseCloudMediaPath(m.Path)
return ok && strings.EqualFold(pathTyp, typ) && sameCloudPlaybackRef(pathRef, ref)
}
func parseCloudPlaybackTarget(raw string) (typ, ref string, ok bool) {
raw = strings.TrimSpace(raw)
if raw == "" {
return "", "", false
}
u, err := url.Parse(raw)
if err != nil {
return "", "", false
}
pathValue := strings.ToLower(strings.TrimRight(u.Path, "/"))
const prefix = "/api/cloud/play/"
idx := strings.LastIndex(pathValue, prefix)
if idx < 0 {
return "", "", false
}
typ = strings.TrimSpace(u.Path[idx+len(prefix):])
ref = strings.TrimSpace(u.Query().Get("ref"))
return typ, ref, typ != "" && ref != ""
}
func parseCloudMediaPath(raw string) (typ, ref string, ok bool) {
raw = strings.TrimSpace(raw)
if !strings.HasPrefix(strings.ToLower(raw), "cloud://") {
return "", "", false
}
rest := strings.TrimPrefix(raw, "cloud://")
parts := strings.SplitN(rest, "/", 2)
if len(parts) != 2 {
return "", "", false
}
typ = strings.TrimSpace(parts[0])
ref = strings.TrimSpace(parts[1])
return typ, ref, typ != "" && ref != ""
}
func sameCloudPlaybackRef(a, b string) bool {
return normalizeCloudPlaybackRef(a) == normalizeCloudPlaybackRef(b)
}
func normalizeCloudPlaybackRef(value string) string {
value = strings.TrimSpace(value)
if decoded, err := url.PathUnescape(value); err == nil {
value = decoded
}
value = strings.TrimSpace(value)
return strings.TrimLeft(value, "/")
}
+6
View File
@@ -18,6 +18,9 @@ func hlsPlaylistHandler(svc *service.Container) gin.HandlerFunc {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
if !enforceScopedPlaybackToken(c, m.ID) {
return
}
err = svc.Stream.ServeHLSPlaylist(c.Writer, c.Request, c.Param("id"))
if errors.Is(err, service.ErrMediaNotFound) {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
@@ -45,6 +48,9 @@ func hlsSegmentHandler(svc *service.Container) gin.HandlerFunc {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
if !enforceScopedPlaybackToken(c, m.ID) {
return
}
err = svc.Stream.ServeHLSSegment(c.Writer, c.Request, c.Param("id"), c.Param("seg"))
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})