diff --git a/server/jellyfin/e2e/images_test.go b/server/jellyfin/e2e/images_test.go index 0c53d75f3..7fb85cba8 100644 --- a/server/jellyfin/e2e/images_test.go +++ b/server/jellyfin/e2e/images_test.go @@ -28,9 +28,9 @@ var _ = Describe("Item images", func() { Expect(artworkSpy.lastID).To(ContainSubstring(id)) }) - It("resolves a private playlist's cover for its owner under an elevated context", func() { - // The route carries no user in ctx (public); the owner is identified by the request token, - // and resolution then runs elevated so the visibility filter doesn't eat the cover. + It("resolves a private playlist's cover under an elevated context", func() { + // The route carries no user in ctx (public); resolution runs elevated so the visibility + // filter doesn't eat the cover. plID := createPlaylist("Private Mix", nil) w := get("/Items/" + enc(plID) + "/Images/Primary") Expect(w.Code).To(Equal(http.StatusOK)) @@ -48,27 +48,11 @@ var _ = Describe("Item images", func() { Expect(w.Body.String()).To(Equal("IMG")) }) - Describe("private playlist covers", func() { - It("does not resolve a private playlist's cover for an unauthenticated caller", func() { - plID := createPlaylist("Secret Mix", nil) // owned by admin, private - w := rawReq("GET", "/Items/"+enc(plID)+"/Images/Primary", "") - Expect(w.Code).To(Equal(http.StatusOK)) // placeholder, not an auth error - Expect(artworkSpy.lastID).ToNot(ContainSubstring(plID)) - }) - - It("does not resolve a private playlist's cover for another user", func() { - plID := createPlaylist("Secret Mix", nil) - w := getAs(regularUser, "/Items/"+enc(plID)+"/Images/Primary") - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(artworkSpy.lastID).ToNot(ContainSubstring(plID)) - }) - - It("resolves a public playlist's cover for anyone", func() { - plID := createPlaylist("Shared Mix", nil) - Expect(post("/Playlists/"+enc(plID), `{"IsPublic":true}`).Code).To(Equal(http.StatusNoContent)) - w := rawReq("GET", "/Items/"+enc(plID)+"/Images/Primary", "") - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(artworkSpy.lastID).To(ContainSubstring(plID)) - }) + // Covers are served regardless of playlist visibility — see getItemImage for the rationale. + It("resolves a private playlist's cover for an unauthenticated caller", func() { + plID := createPlaylist("Secret Mix", nil) // owned by admin, private + w := rawReq("GET", "/Items/"+enc(plID)+"/Images/Primary", "") + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(artworkSpy.lastID).To(ContainSubstring(plID)) }) }) diff --git a/server/jellyfin/images.go b/server/jellyfin/images.go index 722e828c5..0ec34f491 100644 --- a/server/jellyfin/images.go +++ b/server/jellyfin/images.go @@ -25,14 +25,13 @@ import ( ) func (api *Router) getItemImage(w http.ResponseWriter, r *http.Request) { - // Public endpoint (no user in ctx): library artwork isn't user-sensitive, so resolution runs - // under an elevated context to bypass the persistence visibility filter; playlist access is - // gated inside resolveArtworkID. + // Public endpoint, like real Jellyfin's image routes: clients fetch cover URLs without credentials + // and item ids are unguessable, so resolution runs elevated to bypass the visibility filter. ctx := request.WithUser(r.Context(), model.User{IsAdmin: true}) itemId := api.resolveItemID(ctx, dto.DecodeID(chi.URLParam(r, "itemId"))) size, _ := strconv.Atoi(r.URL.Query().Get("maxwidth")) - artID := api.resolveArtworkID(ctx, r, itemId) + artID := api.resolveArtworkID(ctx, itemId) reader, _, err := api.artwork.GetOrPlaceholder(ctx, artID, size, false) switch { case errors.Is(err, context.Canceled): @@ -49,7 +48,7 @@ func (api *Router) getItemImage(w http.ResponseWriter, r *http.Request) { // resolveArtworkID maps a Jellyfin item id to a Navidrome ArtworkID, probing // album -> artist -> media file -> playlist. -func (api *Router) resolveArtworkID(ctx context.Context, r *http.Request, itemId string) string { +func (api *Router) resolveArtworkID(ctx context.Context, itemId string) string { if al, err := api.ds.Album(ctx).Get(itemId); err == nil { return al.CoverArtID().String() } @@ -60,12 +59,7 @@ func (api *Router) resolveArtworkID(ctx context.Context, r *http.Request, itemId return mf.CoverArtID().String() } if pl, err := api.ds.Playlist(ctx).Get(itemId); err == nil { - // Playlist covers are user-scoped: serve a private one only for a public playlist or a - // token identifying its owner/an admin, so this public route can't probe others' covers. - u, ok := api.userFromToken(r) - if pl.Public || (ok && (u.IsAdmin || pl.OwnerID == u.ID)) { - return pl.CoverArtID().String() - } + return pl.CoverArtID().String() } return (model.ArtworkID{}).String() } diff --git a/server/jellyfin/images_test.go b/server/jellyfin/images_test.go index 8099fcf94..e435b99fd 100644 --- a/server/jellyfin/images_test.go +++ b/server/jellyfin/images_test.go @@ -85,20 +85,7 @@ var _ = Describe("Images", func() { Expect(w.Header().Get("Content-Type")).To(Equal("image/png")) }) - It("resolves a public playlist id to its cover artwork", func() { - ds := &tests.MockDataStore{} - ds.Playlist(context.Background()).(*tests.MockPlaylistRepo).SetData(model.Playlists{{ID: "pl1", Name: "Mix", Public: true}}) - fa := &fakeArtwork{} - api := &Router{ds: ds, artwork: fa} - - w, r := newImageRequest(dto.EncodeID("pl1")) - api.getItemImage(w, r) - - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(fa.recvId).To(ContainSubstring("pl1")) - }) - - It("serves the placeholder, not the cover, for a private playlist and an anonymous caller", func() { + It("resolves a playlist's cover regardless of visibility, even for an anonymous caller", func() { ds := &tests.MockDataStore{} ds.Playlist(context.Background()).(*tests.MockPlaylistRepo).SetData(model.Playlists{{ID: "pl1", Name: "Mix", OwnerID: "someone"}}) fa := &fakeArtwork{} @@ -108,7 +95,7 @@ var _ = Describe("Images", func() { api.getItemImage(w, r) Expect(w.Code).To(Equal(http.StatusOK)) - Expect(fa.recvId).ToNot(ContainSubstring("pl1")) + Expect(fa.recvId).To(ContainSubstring("pl1")) }) // This endpoint is public (no user in the request), so artwork must be resolved under an diff --git a/server/jellyfin/middlewares.go b/server/jellyfin/middlewares.go index 840143ac8..c90f9c088 100644 --- a/server/jellyfin/middlewares.go +++ b/server/jellyfin/middlewares.go @@ -152,7 +152,7 @@ func tokenFromRequest(r *http.Request) string { } // userFromToken resolves the user for the request's token; ok is false for a missing/invalid token -// or unknown subject. Used by authenticate and by public routes that optionally identify the caller. +// or unknown subject. func (api *Router) userFromToken(r *http.Request) (model.User, bool) { token := tokenFromRequest(r) if token == "" {