mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-29 11:36:36 +08:00
fix media playback and library workflows
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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"})
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 ""
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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, "/")
|
||||
}
|
||||
@@ -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()})
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 分钟的普通
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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})
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user