From efca3cbe6935b190cf8c8e2bfebaad48c709bbdd Mon Sep 17 00:00:00 2001 From: ShukeBta <272197458+ShukeBta@users.noreply.github.com> Date: Sun, 21 Jun 2026 12:53:52 +0800 Subject: [PATCH] fix media playback and library workflows --- internal/handler/cloud.go | 3 + internal/handler/emby.go | 2 +- internal/handler/emby_test.go | 18 +- internal/handler/media.go | 3 + internal/handler/media_test.go | 7 +- internal/handler/playback_extra.go | 13 +- internal/handler/playback_extra_test.go | 254 ++++++++++++++++++++++++ internal/handler/playback_scope.go | 118 +++++++++++ internal/handler/streaming.go | 6 + internal/middleware/middleware.go | 56 +++++- internal/service/auth.go | 47 +++++ internal/service/emby_compat.go | 36 +++- internal/service/emby_compat_test.go | 96 +++++++++ internal/service/media.go | 10 + internal/service/media_test.go | 6 + internal/service/stream.go | 32 ++- internal/service/stream_test.go | 12 ++ internal/service/token_svc.go | 8 +- web/public/artwork-cache-sw.js | 48 +++++ web/src/api/playback.ts | 2 +- web/src/components/APIConfigsPanel.tsx | 4 +- web/src/components/Layout.tsx | 11 +- web/src/main.tsx | 6 + web/src/pages/FileManagerPage.tsx | 99 ++++++--- web/src/pages/LibraryPage.tsx | 67 ++++++- web/src/pages/PlayerPage.tsx | 38 ++-- 26 files changed, 921 insertions(+), 81 deletions(-) create mode 100644 internal/handler/playback_extra_test.go create mode 100644 internal/handler/playback_scope.go create mode 100644 web/public/artwork-cache-sw.js diff --git a/internal/handler/cloud.go b/internal/handler/cloud.go index 17aed6d..d005b5c 100644 --- a/internal/handler/cloud.go +++ b/internal/handler/cloud.go @@ -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) } } diff --git a/internal/handler/emby.go b/internal/handler/emby.go index bc34ddd..edc839d 100644 --- a/internal/handler/emby.go +++ b/internal/handler/emby.go @@ -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 diff --git a/internal/handler/emby_test.go b/internal/handler/emby_test.go index 01eeb86..f78d07a 100644 --- a/internal/handler/emby_test.go +++ b/internal/handler/emby_test.go @@ -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) } } diff --git a/internal/handler/media.go b/internal/handler/media.go index 3938132..5b27eb9 100644 --- a/internal/handler/media.go +++ b/internal/handler/media.go @@ -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"}) diff --git a/internal/handler/media_test.go b/internal/handler/media_test.go index c76c0c2..6603e77 100644 --- a/internal/handler/media_test.go +++ b/internal/handler/media_test.go @@ -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 { diff --git a/internal/handler/playback_extra.go b/internal/handler/playback_extra.go index 3e7e040..3ef7429 100644 --- a/internal/handler/playback_extra.go +++ b/internal/handler/playback_extra.go @@ -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 "" } diff --git a/internal/handler/playback_extra_test.go b/internal/handler/playback_extra_test.go new file mode 100644 index 0000000..db07390 --- /dev/null +++ b/internal/handler/playback_extra_test.go @@ -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 +} diff --git a/internal/handler/playback_scope.go b/internal/handler/playback_scope.go new file mode 100644 index 0000000..1fe5d05 --- /dev/null +++ b/internal/handler/playback_scope.go @@ -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, "/") +} diff --git a/internal/handler/streaming.go b/internal/handler/streaming.go index 5f5876b..c9541de 100644 --- a/internal/handler/streaming.go +++ b/internal/handler/streaming.go @@ -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()}) diff --git a/internal/middleware/middleware.go b/internal/middleware/middleware.go index 75c18a6..2766216 100644 --- a/internal/middleware/middleware.go +++ b/internal/middleware/middleware.go @@ -16,9 +16,11 @@ import ( // Context keys for values produced by the auth middleware. const ( - CtxUserID = "ctx_user_id" - CtxUserRole = "ctx_user_role" - CtxUserTier = "ctx_user_tier" + CtxUserID = "ctx_user_id" + CtxUserRole = "ctx_user_role" + CtxUserTier = "ctx_user_tier" + CtxTokenPurpose = "ctx_token_purpose" + CtxTokenMediaID = "ctx_token_media_id" ) // RequestLogger logs one structured line per request. @@ -165,9 +167,11 @@ func RateLimit(limiter *RateLimiter) gin.HandlerFunc { // Claims is the JWT payload we issue. type Claims struct { - UserID string `json:"uid"` - Role string `json:"role"` - Tier string `json:"tier,omitempty"` + UserID string `json:"uid"` + Role string `json:"role"` + Tier string `json:"tier,omitempty"` + Purpose string `json:"purpose,omitempty"` + MediaID string `json:"media_id,omitempty"` jwt.RegisteredClaims } @@ -191,13 +195,53 @@ func AuthRequired(secret string) gin.HandlerFunc { c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"code": 40101, "message": "invalid token"}) return } + if !scopedTokenAllowedForRequest(c, claims) { + c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"code": 40304, "message": "token scope denied"}) + return + } c.Set(CtxUserID, claims.UserID) c.Set(CtxUserRole, claims.Role) c.Set(CtxUserTier, claims.Tier) + c.Set(CtxTokenPurpose, claims.Purpose) + c.Set(CtxTokenMediaID, claims.MediaID) c.Next() } } +func scopedTokenAllowedForRequest(c *gin.Context, claims *Claims) bool { + if claims == nil || strings.TrimSpace(claims.Purpose) == "" { + return true + } + switch strings.TrimSpace(claims.Purpose) { + case "external_play": + return externalPlaybackTokenAllowedPath(c, strings.TrimSpace(claims.MediaID)) + default: + return false + } +} + +func externalPlaybackTokenAllowedPath(c *gin.Context, mediaID string) bool { + if c == nil || c.Request == nil || strings.TrimSpace(mediaID) == "" { + return false + } + pathValue := strings.Trim(c.Request.URL.Path, "/") + segments := strings.Split(pathValue, "/") + for i := range segments { + segments[i] = strings.TrimSpace(segments[i]) + } + if len(segments) >= 3 && strings.EqualFold(segments[0], "api") { + switch strings.ToLower(segments[1]) { + case "stream", "hls": + return segments[2] == mediaID + case "cloud": + return len(segments) >= 3 && + strings.EqualFold(segments[2], "play") && + strings.TrimSpace(c.Query("media_id")) == mediaID + } + } + return false +} + // AdminRequired must run AFTER AuthRequired; it enforces role == "admin". func AdminRequired() gin.HandlerFunc { return func(c *gin.Context) { diff --git a/internal/service/auth.go b/internal/service/auth.go index ae52b8a..297643c 100644 --- a/internal/service/auth.go +++ b/internal/service/auth.go @@ -259,6 +259,53 @@ func (s *AuthService) IssueToken(u *model.User) (string, error) { return t.SignedString([]byte(s.cfg.Secrets.JWTSecret)) } +const ( + ExternalPlaybackTokenPurpose = "external_play" + ExternalPlaybackTokenMinDuration = 15 * time.Minute + ExternalPlaybackTokenGraceDuration = 30 * time.Minute + ExternalPlaybackTokenUnknownDuration = 6 * time.Hour + ExternalPlaybackTokenMaxDuration = 24 * time.Hour +) + +func ExternalPlaybackTokenDurationForMedia(durationSec int) time.Duration { + if durationSec <= 0 { + return ExternalPlaybackTokenUnknownDuration + } + duration := time.Duration(durationSec)*time.Second + ExternalPlaybackTokenGraceDuration + if duration < ExternalPlaybackTokenMinDuration { + return ExternalPlaybackTokenMinDuration + } + if duration > ExternalPlaybackTokenMaxDuration { + return ExternalPlaybackTokenMaxDuration + } + return duration +} + +// IssueExternalPlaybackToken signs a short-lived, media-scoped JWT for URLs +// that are handed to third-party players. It must not be accepted as a +// reusable account/session token for arbitrary media playback. +func (s *AuthService) IssueExternalPlaybackToken(u *model.User, mediaID string, durationSec int) (string, error) { + mediaID = strings.TrimSpace(mediaID) + if mediaID == "" { + return "", errors.New("media id required") + } + claims := Claims{ + UserID: u.ID, + Role: u.Role, + Tier: u.Tier, + Purpose: ExternalPlaybackTokenPurpose, + MediaID: mediaID, + RegisteredClaims: jwt.RegisteredClaims{ + IssuedAt: jwt.NewNumericDate(time.Now()), + ExpiresAt: jwt.NewNumericDate(time.Now().Add(ExternalPlaybackTokenDurationForMedia(durationSec))), + Issuer: "mediastationgo", + Subject: u.ID, + }, + } + t := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + return t.SignedString([]byte(s.cfg.Secrets.JWTSecret)) +} + // EmbyTokenDuration 是第三方 Emby/Jellyfin 客户端访问令牌的有效期。 // Emby 协议没有 refresh token 机制——客户端登录一次后把 AccessToken // 长期保存并反复使用,直到用户主动登出。若给它们签发 60 分钟的普通 diff --git a/internal/service/emby_compat.go b/internal/service/emby_compat.go index 09cdaf0..158c6e7 100644 --- a/internal/service/emby_compat.go +++ b/internal/service/emby_compat.go @@ -1031,10 +1031,12 @@ func (e *EmbyService) itemPayload(ctx context.Context, m *model.Media, fav bool, } imageTags := map[string]string{} backdropTags := []string{} - if m.PosterURL != "" { + primaryArtwork := e.mediaPrimaryArtwork(ctx, m) + backdropArtwork := e.mediaBackdropArtwork(ctx, m) + if primaryArtwork != "" { imageTags["Primary"] = m.ID } - if m.BackdropURL != "" { + if backdropArtwork != "" { backdropTags = append(backdropTags, m.ID+"-bd") } @@ -1624,7 +1626,13 @@ func (e *EmbyService) ImageURL(ctx context.Context, id, imageType string) (strin } m, err := e.repo.Media.FindByID(ctx, id) if err == nil && m != nil { - return pick(m.PosterURL, m.BackdropURL), nil + if e.mediaShouldBeEpisode(ctx, m) { + switch strings.ToLower(imageType) { + case "backdrop", "art": + return "", nil + } + } + return pick(e.mediaPrimaryArtwork(ctx, m), e.mediaBackdropArtwork(ctx, m)), nil } if err != nil { return "", err @@ -1637,6 +1645,26 @@ func (e *EmbyService) ImageURL(ctx context.Context, id, imageType string) (strin return "", nil } +func (e *EmbyService) mediaPrimaryArtwork(ctx context.Context, m *model.Media) string { + if m == nil { + return "" + } + if e.mediaShouldBeEpisode(ctx, m) && strings.TrimSpace(m.BackdropURL) != "" { + return m.BackdropURL + } + return m.PosterURL +} + +func (e *EmbyService) mediaBackdropArtwork(ctx context.Context, m *model.Media) string { + if m == nil { + return "" + } + if e.mediaShouldBeEpisode(ctx, m) { + return "" + } + return m.BackdropURL +} + func (e *EmbyService) seriesIDForMedia(m *model.Media) string { if strings.TrimSpace(m.SeriesID) != "" { return m.SeriesID @@ -1645,7 +1673,7 @@ func (e *EmbyService) seriesIDForMedia(m *model.Media) string { } func (e *EmbyService) seasonIDForMedia(m *model.Media) string { - return seasonID(e.seriesIDForMedia(m), maxInt(m.SeasonNum, 1)) + return seasonID(e.seriesIDForMedia(m), m.SeasonNum) } func (e *EmbyService) seriesNameForMedia(m *model.Media) string { diff --git a/internal/service/emby_compat_test.go b/internal/service/emby_compat_test.go index 5454054..3dfc80e 100644 --- a/internal/service/emby_compat_test.go +++ b/internal/service/emby_compat_test.go @@ -103,6 +103,102 @@ func TestEmbyItemsExposeSeriesSeasonEpisodeHierarchy(t *testing.T) { } } +func TestEmbyItemsKeepSpecialsInSeasonZero(t *testing.T) { + svc := newTestEmbyService(t) + lib := model.Library{Name: "番剧", Path: `F:\downloads\日番`, Type: "anime", Enabled: true} + if err := svc.repo.Library.Create(t.Context(), &lib); err != nil { + t.Fatalf("create library: %v", err) + } + media := model.Media{ + Base: model.Base{ID: "sp-1"}, + LibraryID: lib.ID, + Title: "间谍过家家", + Path: `F:\downloads\日番\间谍过家家\Specials\间谍过家家 - S00E01.mkv`, + PosterURL: `F:\episode-still.jpg`, + SeasonNum: 0, + EpisodeNum: 1, + } + if err := svc.repo.DB.Create(&media).Error; err != nil { + t.Fatalf("create media: %v", err) + } + + root, err := svc.Items(t.Context(), ItemsParams{ParentID: lib.ID, Limit: 50}) + if err != nil { + t.Fatalf("library items: %v", err) + } + rootItems := root["Items"].([]map[string]any) + if len(rootItems) != 1 || rootItems[0]["Type"] != "Series" { + t.Fatalf("expected one series card, got %#v", rootItems) + } + + seasons, err := svc.Items(t.Context(), ItemsParams{ParentID: rootItems[0]["Id"].(string), Limit: 50}) + if err != nil { + t.Fatalf("series seasons: %v", err) + } + seasonItems := seasons["Items"].([]map[string]any) + if len(seasonItems) != 1 || seasonItems[0]["Type"] != "Season" || seasonItems[0]["IndexNumber"] != 0 || seasonItems[0]["Name"] != "特别篇" { + t.Fatalf("specials should be exposed as season zero: %#v", seasonItems) + } + + episodes, err := svc.Items(t.Context(), ItemsParams{ParentID: seasonItems[0]["Id"].(string), IncludeItemTypes: []string{"Episode"}, Recursive: true, Limit: 50}) + if err != nil { + t.Fatalf("special episodes: %v", err) + } + episodeItems := episodes["Items"].([]map[string]any) + if len(episodeItems) != 1 { + t.Fatalf("expected one special episode, got %#v", episodeItems) + } + if episodeItems[0]["ParentIndexNumber"] != 0 || episodeItems[0]["SeasonId"] != seasonItems[0]["Id"] || episodeItems[0]["ParentId"] != seasonItems[0]["Id"] { + t.Fatalf("special episode linked to wrong season: %#v season=%#v", episodeItems[0], seasonItems[0]) + } + if tags, ok := episodeItems[0]["ImageTags"].(map[string]string); !ok || tags["Primary"] != "sp-1" { + t.Fatalf("episode still should be exposed as Primary image: %#v", episodeItems[0]["ImageTags"]) + } +} + +func TestEmbyEpisodeStillIsPrimaryImageNotArt(t *testing.T) { + svc := newTestEmbyService(t) + lib := model.Library{Name: "剧集", Path: `/media/tv`, Type: "tv", Enabled: true} + if err := svc.repo.Library.Create(t.Context(), &lib); err != nil { + t.Fatalf("create library: %v", err) + } + media := model.Media{ + Base: model.Base{ID: "ep-still"}, + LibraryID: lib.ID, + Title: "间谍过家家", + Path: `/media/tv/间谍过家家/Season 02/间谍过家家 - S02E01.mkv`, + PosterURL: `https://image.example/show-poster.jpg`, + BackdropURL: `https://image.example/episode-still.jpg`, + SeasonNum: 2, + EpisodeNum: 1, + } + if err := svc.repo.DB.Create(&media).Error; err != nil { + t.Fatalf("create media: %v", err) + } + + item := svc.itemPayload(t.Context(), &media, false, 0) + if tags, ok := item["ImageTags"].(map[string]string); !ok || tags["Primary"] != "ep-still" { + t.Fatalf("episode should expose a primary image tag: %#v", item["ImageTags"]) + } + if tags, ok := item["BackdropImageTags"].([]string); !ok || len(tags) != 0 { + t.Fatalf("episode still must not be exposed as art/backdrop: %#v", item["BackdropImageTags"]) + } + primary, err := svc.ImageURL(t.Context(), "ep-still", "Primary") + if err != nil { + t.Fatalf("primary image url: %v", err) + } + if primary != media.BackdropURL { + t.Fatalf("episode Primary image = %q, want still %q", primary, media.BackdropURL) + } + art, err := svc.ImageURL(t.Context(), "ep-still", "Art") + if err != nil { + t.Fatalf("art image url: %v", err) + } + if art == media.BackdropURL { + t.Fatalf("episode still must not be returned as Art image") + } +} + func TestEmbyLatestItemsIncludesMergedCloudMovieLibrary(t *testing.T) { svc := newTestEmbyService(t) local := model.Library{Name: "国产电影", Path: `/media/国产电影`, Type: "movie", Enabled: true} diff --git a/internal/service/media.go b/internal/service/media.go index 5c015e4..4b30291 100644 --- a/internal/service/media.go +++ b/internal/service/media.go @@ -696,6 +696,11 @@ func normalizeMediaVersionText(value string) string { } func betterMediaVersion(candidate, current model.Media) bool { + candidateCloud := isCloudMediaVersion(candidate) + currentCloud := isCloudMediaVersion(current) + if candidateCloud != currentCloud { + return !candidateCloud + } candidatePixels := candidate.Width * candidate.Height currentPixels := current.Width * current.Height if candidatePixels != currentPixels { @@ -707,6 +712,11 @@ func betterMediaVersion(candidate, current model.Media) bool { return candidate.CreatedAt.After(current.CreatedAt) } +func isCloudMediaVersion(media model.Media) bool { + return strings.HasPrefix(strings.ToLower(strings.TrimSpace(media.Path)), "cloud://") || + strings.Contains(strings.ToLower(strings.TrimSpace(media.STRMURL)), "/api/cloud/play/") +} + // SearchMedia performs a simple LIKE search across titles. func (s *MediaService) SearchMedia(ctx context.Context, query string, limit int) ([]model.Media, error) { return s.SearchMediaVisible(ctx, query, limit, MediaVisibility{IncludeNSFW: true}) diff --git a/internal/service/media_test.go b/internal/service/media_test.go index f858508..2ad0e3e 100644 --- a/internal/service/media_test.go +++ b/internal/service/media_test.go @@ -257,6 +257,12 @@ func TestGroupMediaVersionsMergesEpisodeByExternalIDAcrossLibraries(t *testing.T if len(grouped[0].Versions) != 2 { t.Fatalf("versions len = %d, want 2: %#v", len(grouped[0].Versions), grouped[0].Versions) } + if grouped[0].Media.Path != local.Path { + t.Fatalf("local version should remain primary, got %q want %q", grouped[0].Media.Path, local.Path) + } + if grouped[0].Versions[0].Path != local.Path || grouped[0].Versions[1].Path != cloud.Path { + t.Fatalf("versions should be ordered local before cloud, got %#v", grouped[0].Versions) + } } func TestUpdateMediaMetadataMarksManualMatch(t *testing.T) { diff --git a/internal/service/stream.go b/internal/service/stream.go index 3f1b300..863cf5a 100644 --- a/internal/service/stream.go +++ b/internal/service/stream.go @@ -148,11 +148,41 @@ func withAuthTokenForInternalRedirect(target string, r *http.Request, publicBase q := u.Query() if q.Get("token") == "" { q.Set("token", tok) - u.RawQuery = q.Encode() } + if q.Get("media_id") == "" && strings.HasPrefix(strings.ToLower(u.Path), "/api/cloud/play/") { + if mediaID := playbackMediaIDFromRequestPath(r.URL.Path); mediaID != "" { + q.Set("media_id", mediaID) + } + } + u.RawQuery = q.Encode() return u.String() } +func playbackMediaIDFromRequestPath(pathValue string) string { + pathValue = strings.TrimSpace(pathValue) + if pathValue == "" { + return "" + } + segments := strings.Split(strings.Trim(pathValue, "/"), "/") + lower := make([]string, len(segments)) + for i, segment := range segments { + lower[i] = strings.ToLower(segment) + } + var mediaID string + switch { + case len(segments) >= 3 && lower[0] == "api" && lower[1] == "stream": + mediaID = segments[2] + case len(segments) >= 4 && lower[0] == "emby" && lower[1] == "api" && lower[2] == "stream": + mediaID = segments[3] + case len(segments) >= 3 && lower[0] == "videos": + mediaID = segments[1] + } + if decoded, err := url.PathUnescape(mediaID); err == nil { + mediaID = decoded + } + return strings.TrimSpace(mediaID) +} + func absoluteInternalRedirect(target string, r *http.Request) string { if r == nil || target == "" || strings.HasPrefix(target, "//") { return target diff --git a/internal/service/stream_test.go b/internal/service/stream_test.go index efb28ca..ebf5cbc 100644 --- a/internal/service/stream_test.go +++ b/internal/service/stream_test.go @@ -58,6 +58,18 @@ func TestWithAuthTokenPropagatesToSameOriginAbsoluteInternalURL(t *testing.T) { } } +func TestWithAuthTokenAddsMediaIDToCloudPlaybackRedirect(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "http://media.example/api/stream/media-1?token=jwt123", nil) + got := withAuthTokenForInternalRedirect("/api/cloud/play/openlist?ref=abc", req, "") + u, err := url.Parse(got) + if err != nil { + t.Fatalf("parse: %v", err) + } + if u.Query().Get("token") != "jwt123" || u.Query().Get("media_id") != "media-1" { + t.Fatalf("cloud redirect should carry token and media_id, got %q", got) + } +} + func TestServeFileRedirectsInternalSTRMAsAbsoluteURLWithToken(t *testing.T) { repos := newStreamTestRepo(t) if err := repos.DB.Create(&model.Media{ diff --git a/internal/service/token_svc.go b/internal/service/token_svc.go index fb2f029..016e22e 100644 --- a/internal/service/token_svc.go +++ b/internal/service/token_svc.go @@ -30,9 +30,11 @@ const loginRefreshTokenStoreTimeout = 750 * time.Millisecond // Claims 是 JWT 载荷(复制自 middleware 以避免循环导入)。 type Claims struct { - UserID string `json:"uid"` - Role string `json:"role"` - Tier string `json:"tier,omitempty"` + UserID string `json:"uid"` + Role string `json:"role"` + Tier string `json:"tier,omitempty"` + Purpose string `json:"purpose,omitempty"` + MediaID string `json:"media_id,omitempty"` jwt.RegisteredClaims } diff --git a/web/public/artwork-cache-sw.js b/web/public/artwork-cache-sw.js new file mode 100644 index 0000000..1faa511 --- /dev/null +++ b/web/public/artwork-cache-sw.js @@ -0,0 +1,48 @@ +const ARTWORK_CACHE = 'mediastationgo-artwork-v1' +const STRIP_QUERY_KEYS = ['token', 'profile_id', 'profile_pin_token'] + +function isArtworkRequest(url) { + if (url.origin !== self.location.origin) return false + if (url.pathname === '/api/img') return true + return url.pathname.startsWith('/api/cloud/play/') +} + +function normalizedArtworkRequest(request) { + const url = new URL(request.url) + for (const key of STRIP_QUERY_KEYS) { + url.searchParams.delete(key) + } + return new Request(url.toString(), { + method: 'GET', + headers: { + Accept: request.headers.get('Accept') || 'image/avif,image/webp,image/apng,image/*,*/*;q=0.8', + }, + credentials: 'same-origin', + mode: 'same-origin', + redirect: 'follow', + }) +} + +self.addEventListener('fetch', (event) => { + const request = event.request + if (request.method !== 'GET') return + + const url = new URL(request.url) + if (!isArtworkRequest(url)) return + + event.respondWith(cacheArtwork(request)) +}) + +async function cacheArtwork(request) { + const cache = await caches.open(ARTWORK_CACHE) + const cacheKey = normalizedArtworkRequest(request) + const cached = await cache.match(cacheKey) + if (cached) return cached + + const response = await fetch(request) + const contentType = response.headers.get('Content-Type') || '' + if (response.ok && contentType.toLowerCase().startsWith('image/')) { + await cache.put(cacheKey, response.clone()) + } + return response +} diff --git a/web/src/api/playback.ts b/web/src/api/playback.ts index e2dec76..9665d49 100644 --- a/web/src/api/playback.ts +++ b/web/src/api/playback.ts @@ -74,5 +74,5 @@ export const playbackAPI = { .then((r) => r.data), externalURL: (mediaId: string) => - api.get<{ url: string; token: string }>(`/playback/${mediaId}/external-url`).then((r) => r.data), + api.get<{ url: string }>(`/playback/${mediaId}/external-url`).then((r) => r.data), } diff --git a/web/src/components/APIConfigsPanel.tsx b/web/src/components/APIConfigsPanel.tsx index ece2b25..d7f4b34 100644 --- a/web/src/components/APIConfigsPanel.tsx +++ b/web/src/components/APIConfigsPanel.tsx @@ -1,6 +1,6 @@ import { FormEvent, useEffect, useState } from 'react' import toast from 'react-hot-toast' -import { Eye, KeyRound, Save, Trash2, X } from 'lucide-react' +import { Eye, KeyRound, Pencil, Save, Trash2, X } from 'lucide-react' import { apiConfigsAPI, type APIConfig } from '../api/api_configs' import { confirmAction } from './ConfirmDialog' @@ -100,7 +100,7 @@ export function APIConfigsPanel() { className="rounded-lg p-1.5 text-ink-50 transition hover:bg-gray-50 hover:text-white" title="编辑" > - + + + )}