mirror of
https://github.com/navidrome/navidrome.git
synced 2026-10-08 02:17:25 +02:00
fix: apply the per-user library filter to bookmarks, playlists and now-playing (#6179)
On a multi-library instance, a few reads and writes built their own queries without the per-user library filter that every other media read applies. A user granted only some libraries could see, and store, tracks from libraries they had no access to. - getBookmarks now filters the query. It has to be the query and not the result: the loop below it pre-sizes the response from the bookmark count, so a row dropped afterwards would emit an empty bookmark entry. - createBookmark rejects an id the caller cannot read, returning error 70 to match getSong. Stored rows are left alone rather than purged, so a temporary revoke does not lose saved playback positions. - playlistTrackRepository Read, Count and GetAlbumIDs get the filter their siblings CountAll and GetMediaFileIDs already had. Read is the one that mattered most: its id is the integer playlist position, so it needed no track id at all. - Playlist track writes are filtered in playlistRepository.addTracks, the only writer of playlist_tracks rows apart from smart playlists, so Add, Insert, AddAlbums/AddArtists/AddDiscs and a full replace through Put all go through it. Insert reserves a slot per requested id, so when the filter drops one it renumbers to close the hole. - playTracker.GetNowPlaying honours its context instead of discarding it. The cache is process-global, so the filter belongs in the tracker rather than in the Subsonic handler, and any future caller inherits it. Admins and single-library installs are unaffected: applyLibraryFilter and HasLibraryAccess both short-circuit for them. Scanner playlist sync runs as admin, and M3U and CLI imports already resolve tracks through FindByPaths as the same user, so neither changes.
This commit is contained in:
parent
a5334ee46b
commit
8e784b6af7
12 changed files with 401 additions and 12 deletions
|
|
@ -17,6 +17,7 @@ import (
|
|||
"github.com/navidrome/navidrome/server/events"
|
||||
"github.com/navidrome/navidrome/utils/cache"
|
||||
"github.com/navidrome/navidrome/utils/singleton"
|
||||
"github.com/navidrome/navidrome/utils/slice"
|
||||
)
|
||||
|
||||
const (
|
||||
|
|
@ -444,8 +445,14 @@ func (p *playTracker) ReportPlayback(ctx context.Context, params ReportPlaybackP
|
|||
return nil
|
||||
}
|
||||
|
||||
func (p *playTracker) GetNowPlaying(_ context.Context) ([]PlaybackSession, error) {
|
||||
func (p *playTracker) GetNowPlaying(ctx context.Context) ([]PlaybackSession, error) {
|
||||
// The cache is process-global, so it holds every user's playback, across all libraries.
|
||||
res := p.playMap.Values()
|
||||
if user, ok := request.UserFrom(ctx); ok {
|
||||
res = slice.Filter(res, func(s PlaybackSession) bool {
|
||||
return user.HasLibraryAccess(s.MediaFile.LibraryID)
|
||||
})
|
||||
}
|
||||
slices.SortFunc(res, func(a, b PlaybackSession) int {
|
||||
return b.Start.Compare(a.Start)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -92,7 +92,7 @@ var _ = Describe("PlayTracker", func() {
|
|||
BeforeEach(func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
ctx = GinkgoT().Context()
|
||||
ctx = request.WithUser(ctx, model.User{ID: "u-1"})
|
||||
ctx = request.WithUser(ctx, model.User{ID: "u-1", Libraries: model.Libraries{{ID: 1}}})
|
||||
ctx = request.WithPlayer(ctx, model.Player{ScrobbleEnabled: true})
|
||||
ds = &tests.MockDataStore{}
|
||||
fake = &fakeScrobbler{Authorized: true}
|
||||
|
|
@ -108,6 +108,7 @@ var _ = Describe("PlayTracker", func() {
|
|||
|
||||
track = model.MediaFile{
|
||||
ID: "123",
|
||||
LibraryID: 1,
|
||||
Title: "Track Title",
|
||||
Album: "Track Album",
|
||||
AlbumID: "al-1",
|
||||
|
|
@ -174,6 +175,46 @@ var _ = Describe("PlayTracker", func() {
|
|||
Expect(playing[1].Username).To(Equal("user-1"))
|
||||
Expect(playing[1].MediaFile.ID).To(Equal("123"))
|
||||
})
|
||||
|
||||
It("hides sessions playing from libraries the caller cannot access", func() {
|
||||
hidden := track
|
||||
hidden.ID = "789"
|
||||
hidden.LibraryID = 2
|
||||
_ = ds.MediaFile(ctx).Put(&hidden)
|
||||
reporter := request.WithPlayer(
|
||||
request.WithUser(GinkgoT().Context(), model.User{ID: "u-2", UserName: "user-2"}),
|
||||
model.Player{ScrobbleEnabled: true},
|
||||
)
|
||||
_ = tracker.ReportPlayback(reporter, ReportPlaybackParams{
|
||||
MediaId: "789", PositionMs: 0, State: StatePlaying, PlaybackRate: 1.0, ClientId: "player-2", ClientName: "player-two",
|
||||
})
|
||||
|
||||
playing, err := tracker.GetNowPlaying(ctx)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(playing).To(BeEmpty(), "u-1 is granted library 1 only")
|
||||
})
|
||||
|
||||
It("shows every session to an admin", func() {
|
||||
hidden := track
|
||||
hidden.ID = "789"
|
||||
hidden.LibraryID = 2
|
||||
_ = ds.MediaFile(ctx).Put(&hidden)
|
||||
reporter := request.WithPlayer(
|
||||
request.WithUser(GinkgoT().Context(), model.User{ID: "u-2", UserName: "user-2"}),
|
||||
model.Player{ScrobbleEnabled: true},
|
||||
)
|
||||
_ = tracker.ReportPlayback(reporter, ReportPlaybackParams{
|
||||
MediaId: "789", PositionMs: 0, State: StatePlaying, PlaybackRate: 1.0, ClientId: "player-2", ClientName: "player-two",
|
||||
})
|
||||
|
||||
adminCtx := request.WithUser(GinkgoT().Context(), model.User{ID: "adm", IsAdmin: true})
|
||||
playing, err := tracker.GetNowPlaying(adminCtx)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(playing).To(HaveLen(1))
|
||||
Expect(playing[0].MediaFile.ID).To(Equal("789"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Expiration events", func() {
|
||||
|
|
|
|||
|
|
@ -169,6 +169,28 @@ func p(path string) string {
|
|||
return filepath.FromSlash(path)
|
||||
}
|
||||
|
||||
// restrictedFixture creates a second library plus a non-admin user granted library 1 only, so
|
||||
// specs can assert that a query filters by library. Cleans itself up after the spec.
|
||||
func restrictedFixture(name string) (context.Context, model.Library, model.User) {
|
||||
adminCtx := request.WithUser(log.NewContext(GinkgoT().Context()), adminUser)
|
||||
db := GetDBXBuilder()
|
||||
|
||||
lib := model.Library{Name: name + " Library", Path: "/" + name}
|
||||
lr := NewLibraryRepository(adminCtx, db)
|
||||
Expect(lr.Put(&lib)).To(Succeed())
|
||||
|
||||
user := createUserWithLibraries(name+"-restricted", []int{1})
|
||||
ur := NewUserRepository(adminCtx, db)
|
||||
Expect(ur.Put(&user)).To(Succeed())
|
||||
Expect(ur.SetUserLibraries(user.ID, []int{1})).To(Succeed())
|
||||
|
||||
DeferCleanup(func() {
|
||||
_ = NewUserRepository(adminCtx, db).Delete(user.ID)
|
||||
_ = NewLibraryRepository(adminCtx, db).(*libraryRepository).delete(squirrel.Eq{"id": lib.ID})
|
||||
})
|
||||
return adminCtx, lib, user
|
||||
}
|
||||
|
||||
var _ = BeforeSuite(func() {
|
||||
conn := GetDBXBuilder()
|
||||
ctx := log.NewContext(context.TODO())
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import (
|
|||
"github.com/deluan/rest"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/utils/slice"
|
||||
"github.com/pocketbase/dbx"
|
||||
)
|
||||
|
||||
|
|
@ -276,10 +277,17 @@ func (r *playlistRepository) updatePlaylist(playlistId string, mediaFileIds []st
|
|||
return err
|
||||
}
|
||||
|
||||
return r.addTracks(playlistId, 1, mediaFileIds)
|
||||
_, err = r.addTracks(playlistId, 1, mediaFileIds)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *playlistRepository) addTracks(playlistId string, startingPos int, mediaFileIds []string) error {
|
||||
// addTracks is the only path that writes playlist_tracks rows (smart playlists aside), so it owns
|
||||
// the library check: every caller, including a full replace through Put, goes through it.
|
||||
func (r *playlistRepository) addTracks(playlistId string, startingPos int, mediaFileIds []string) (int, error) {
|
||||
mediaFileIds, err := r.keepAccessible(mediaFileIds)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
// Break the track list in chunks to avoid hitting SQLITE_MAX_VARIABLE_NUMBER limit
|
||||
// Add new tracks, chunk by chunk
|
||||
pos := startingPos
|
||||
|
|
@ -289,14 +297,36 @@ func (r *playlistRepository) addTracks(playlistId string, startingPos int, media
|
|||
ins = ins.Values(playlistId, t, pos)
|
||||
pos++
|
||||
}
|
||||
_, err := r.executeSQL(ins)
|
||||
if err != nil {
|
||||
return err
|
||||
if _, err := r.executeSQL(ins); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
}
|
||||
|
||||
r.enqueueCoverRebuild(playlistId)
|
||||
return r.refreshCounters(&model.Playlist{ID: playlistId})
|
||||
return len(mediaFileIds), r.refreshCounters(&model.Playlist{ID: playlistId})
|
||||
}
|
||||
|
||||
// keepAccessible drops ids the caller cannot read, preserving order and duplicates. Chunked
|
||||
// because callers pass unbounded id lists (M3U import), well past SQLITE_MAX_VARIABLE_NUMBER.
|
||||
func (r *playlistRepository) keepAccessible(mediaFileIds []string) ([]string, error) {
|
||||
if visible, err := r.visibleLibraryIDs(); err == nil && r.userSeesAllLibraries(visible) {
|
||||
return mediaFileIds, nil
|
||||
}
|
||||
accessible := make(map[string]struct{}, len(mediaFileIds))
|
||||
for chunk := range slices.Chunk(slice.Unique(mediaFileIds), 200) {
|
||||
sq := r.applyLibraryFilter(Select("id").From("media_file").Where(Eq{"id": chunk}), "media_file")
|
||||
var found []string
|
||||
if err := r.queryAllSlice(sq, &found); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, id := range found {
|
||||
accessible[id] = struct{}{}
|
||||
}
|
||||
}
|
||||
return slice.Filter(mediaFileIds, func(id string) bool {
|
||||
_, ok := accessible[id]
|
||||
return ok
|
||||
}), nil
|
||||
}
|
||||
|
||||
// refreshCounters updates total playlist duration, size and count
|
||||
|
|
|
|||
|
|
@ -91,6 +91,7 @@ func (r *playlistTrackRepository) Count(options ...rest.QueryOptions) (int64, er
|
|||
query := Select().
|
||||
LeftJoin("media_file f on f.id = media_file_id").
|
||||
Where(Eq{"playlist_id": r.playlistId})
|
||||
query = r.applyLibraryFilter(query, "f")
|
||||
return r.count(query, r.parseRestOptions(r.ctx, options...))
|
||||
}
|
||||
|
||||
|
|
@ -113,6 +114,7 @@ func (r *playlistTrackRepository) Read(id string) (any, error) {
|
|||
).
|
||||
Join("media_file f on f.id = media_file_id").
|
||||
Where(And{Eq{"playlist_id": r.playlistId}, Eq{"playlist_tracks.id": id}})
|
||||
sel = r.applyLibraryFilter(sel, "f")
|
||||
var trk dbPlaylistTrack
|
||||
err := r.queryOne(sel, &trk)
|
||||
return trk.PlaylistTrack, err
|
||||
|
|
@ -157,6 +159,7 @@ func (r *playlistTrackRepository) GetAlbumIDs(options ...model.QueryOptions) ([]
|
|||
query := r.newSelect(options...).Columns("distinct mf.album_id").
|
||||
Join("media_file mf on mf.id = media_file_id").
|
||||
Where(Eq{"playlist_id": r.playlistId})
|
||||
query = r.applyLibraryFilter(query, "mf")
|
||||
var ids []string
|
||||
err := r.queryAllSlice(query, &ids)
|
||||
if err != nil {
|
||||
|
|
@ -187,12 +190,11 @@ func (r *playlistTrackRepository) Add(mediaFileIds []string) (int, error) {
|
|||
// Get next pos (ID) in playlist
|
||||
sq := r.newSelect().Columns("max(id) as max").Where(Eq{"playlist_id": r.playlistId})
|
||||
var res struct{ Max sql.NullInt32 }
|
||||
err := r.queryOne(sq, &res)
|
||||
if err != nil {
|
||||
if err := r.queryOne(sq, &res); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
return len(mediaFileIds), r.playlistRepo.addTracks(r.playlistId, int(res.Max.Int32+1), mediaFileIds)
|
||||
return r.playlistRepo.addTracks(r.playlistId, int(res.Max.Int32+1), mediaFileIds)
|
||||
}
|
||||
|
||||
// Insert adds tracks before the 1-based position pos, shifting the following entries down; a
|
||||
|
|
@ -215,7 +217,13 @@ func (r *playlistTrackRepository) Insert(mediaFileIds []string, pos int) (int, e
|
|||
if res == 0 {
|
||||
return r.Add(mediaFileIds)
|
||||
}
|
||||
return n, r.playlistRepo.addTracks(r.playlistId, pos, mediaFileIds)
|
||||
inserted, err := r.playlistRepo.addTracks(r.playlistId, pos, mediaFileIds)
|
||||
if err != nil || inserted == n {
|
||||
return inserted, err
|
||||
}
|
||||
// The shift above reserved a slot per requested id, so ids dropped by the library filter
|
||||
// leave a hole. Close it.
|
||||
return inserted, r.playlistRepo.renumber(r.playlistId)
|
||||
}
|
||||
|
||||
func (r *playlistTrackRepository) addMediaFileIds(cond Sqlizer) (int, error) {
|
||||
|
|
|
|||
|
|
@ -1,11 +1,13 @@
|
|||
package persistence
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
"github.com/navidrome/navidrome/utils/slice"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
|
@ -221,4 +223,151 @@ var _ = Describe("PlaylistTrackRepository", func() {
|
|||
Expect(tracks.CountAll()).To(BeZero())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("library access", func() {
|
||||
var otherLib model.Library
|
||||
var restrictedUser model.User
|
||||
var adminCtx, userCtx context.Context
|
||||
var userTracks model.PlaylistTrackRepository
|
||||
var plsID string
|
||||
|
||||
BeforeEach(func() {
|
||||
adminCtx, otherLib, restrictedUser = restrictedFixture("pls")
|
||||
userCtx = request.WithUser(log.NewContext(GinkgoT().Context()), restrictedUser)
|
||||
db := GetDBXBuilder()
|
||||
|
||||
adminMr := NewMediaFileRepository(adminCtx, db)
|
||||
Expect(adminMr.Put(&model.MediaFile{
|
||||
ID: "pls-otherlib-track", LibraryID: otherLib.ID, AlbumID: "pls-hidden-album",
|
||||
Path: "hidden/in-playlist.mp3", Title: "Hidden In Playlist",
|
||||
})).To(Succeed())
|
||||
DeferCleanup(func() { _ = adminMr.Delete("pls-otherlib-track") })
|
||||
|
||||
adminPls := NewPlaylistRepository(adminCtx, db)
|
||||
pls := model.Playlist{Name: "Public Mixed", OwnerID: adminUser.ID, OwnerName: adminUser.UserName, Public: true}
|
||||
Expect(adminPls.Put(&pls)).To(Succeed())
|
||||
plsID = pls.ID
|
||||
DeferCleanup(func() { _ = adminPls.Delete(plsID) })
|
||||
Expect(adminPls.Tracks(plsID, false).Add([]string{songDayInALife.ID, "pls-otherlib-track"})).To(Equal(2))
|
||||
|
||||
userTracks = NewPlaylistRepository(userCtx, db).Tracks(plsID, false)
|
||||
})
|
||||
|
||||
It("Read does not return a track outside the user's libraries", func() {
|
||||
_, err := userTracks.Read("2")
|
||||
Expect(err).To(MatchError(model.ErrNotFound), "position 2 holds a track the user cannot access")
|
||||
})
|
||||
|
||||
It("Read still returns a track inside the user's libraries", func() {
|
||||
trk, err := userTracks.Read("1")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(trk.(*model.PlaylistTrack).MediaFile.ID).To(Equal(songDayInALife.ID))
|
||||
})
|
||||
|
||||
It("Count excludes tracks outside the user's libraries", func() {
|
||||
Expect(userTracks.Count()).To(Equal(int64(1)), "Count must agree with the filtered listing")
|
||||
})
|
||||
|
||||
It("GetAlbumIDs excludes albums outside the user's libraries", func() {
|
||||
Expect(userTracks.GetAlbumIDs()).ToNot(ContainElement("pls-hidden-album"))
|
||||
})
|
||||
|
||||
Describe("Add", func() {
|
||||
var ownTracks model.PlaylistTrackRepository
|
||||
|
||||
BeforeEach(func() {
|
||||
userPls := NewPlaylistRepository(userCtx, GetDBXBuilder())
|
||||
own := model.Playlist{Name: "Own Playlist", OwnerID: restrictedUser.ID, OwnerName: restrictedUser.UserName}
|
||||
Expect(userPls.Put(&own)).To(Succeed())
|
||||
DeferCleanup(func() { _ = NewPlaylistRepository(adminCtx, GetDBXBuilder()).Delete(own.ID) })
|
||||
ownTracks = userPls.Tracks(own.ID, false)
|
||||
})
|
||||
|
||||
It("drops ids outside the user's libraries", func() {
|
||||
Expect(ownTracks.Add([]string{songDayInALife.ID, "pls-otherlib-track"})).To(Equal(1))
|
||||
Expect(ownTracks.GetMediaFileIDs()).To(ConsistOf(songDayInALife.ID))
|
||||
})
|
||||
|
||||
It("drops them when reached through AddAlbums", func() {
|
||||
Expect(ownTracks.AddAlbums([]string{"pls-hidden-album"})).To(BeZero())
|
||||
})
|
||||
|
||||
It("drops them when reached through Insert", func() {
|
||||
Expect(ownTracks.Add([]string{songDayInALife.ID})).To(Equal(1))
|
||||
|
||||
Expect(ownTracks.Insert([]string{"pls-otherlib-track", songComeTogether.ID}, 1)).To(Equal(1))
|
||||
|
||||
Expect(ownTracks.GetMediaFileIDs()).To(Equal([]string{songComeTogether.ID, songDayInALife.ID}))
|
||||
trks, err := ownTracks.GetAll(model.QueryOptions{Sort: "id"})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(slice.Map(trks, func(t model.PlaylistTrack) string { return t.ID })).To(Equal([]string{"1", "2"}),
|
||||
"positions must stay contiguous when an id is dropped")
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Put", func() {
|
||||
storedIDs := func(id string) []string {
|
||||
ids, err := NewPlaylistRepository(adminCtx, GetDBXBuilder()).Tracks(id, false).GetMediaFileIDs()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
return ids
|
||||
}
|
||||
put := func(ctx context.Context, owner model.User, pls *model.Playlist, ids ...string) string {
|
||||
pls.OwnerID = owner.ID
|
||||
pls.Tracks = nil
|
||||
pls.AddMediaFilesByID(ids)
|
||||
Expect(NewPlaylistRepository(ctx, GetDBXBuilder()).Put(pls)).To(Succeed())
|
||||
DeferCleanup(func() { _ = NewPlaylistRepository(adminCtx, GetDBXBuilder()).Delete(pls.ID) })
|
||||
return pls.ID
|
||||
}
|
||||
|
||||
It("drops ids outside the user's libraries when creating a playlist", func() {
|
||||
id := put(userCtx, restrictedUser, &model.Playlist{Name: "Created"}, songDayInALife.ID, "pls-otherlib-track")
|
||||
|
||||
Expect(storedIDs(id)).To(Equal([]string{songDayInALife.ID}))
|
||||
})
|
||||
|
||||
It("drops them when replacing the tracks of an existing playlist", func() {
|
||||
pls := &model.Playlist{Name: "Replaced"}
|
||||
put(userCtx, restrictedUser, pls, songDayInALife.ID)
|
||||
|
||||
put(userCtx, restrictedUser, pls, "pls-otherlib-track")
|
||||
|
||||
Expect(storedIDs(pls.ID)).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("does not count a dropped id, so it cannot be told apart from an unknown one", func() {
|
||||
hidden := put(userCtx, restrictedUser, &model.Playlist{Name: "Hidden"}, songDayInALife.ID, "pls-otherlib-track")
|
||||
unknown := put(userCtx, restrictedUser, &model.Playlist{Name: "Unknown"}, songDayInALife.ID, "no-such-track")
|
||||
|
||||
userPls := NewPlaylistRepository(userCtx, GetDBXBuilder())
|
||||
h, err := userPls.Get(hidden)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
u, err := userPls.Get(unknown)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(h.SongCount).To(Equal(u.SongCount))
|
||||
Expect(h.Duration).To(Equal(u.Duration))
|
||||
Expect(h.Size).To(Equal(u.Size))
|
||||
})
|
||||
|
||||
It("keeps order and duplicates of the accessible ids", func() {
|
||||
id := put(userCtx, restrictedUser, &model.Playlist{Name: "Ordered"},
|
||||
songDayInALife.ID, "pls-otherlib-track", songComeTogether.ID, songDayInALife.ID)
|
||||
|
||||
Expect(storedIDs(id)).To(Equal([]string{songDayInALife.ID, songComeTogether.ID, songDayInALife.ID}))
|
||||
})
|
||||
|
||||
It("keeps every id when run as an admin, as the scanner's playlist sync does", func() {
|
||||
id := put(adminCtx, adminUser, &model.Playlist{Name: "Synced"}, songDayInALife.ID, "pls-otherlib-track")
|
||||
|
||||
Expect(storedIDs(id)).To(Equal([]string{songDayInALife.ID, "pls-otherlib-track"}))
|
||||
})
|
||||
})
|
||||
|
||||
It("still shows everything to an admin", func() {
|
||||
adminTracks := NewPlaylistRepository(adminCtx, GetDBXBuilder()).Tracks(plsID, false)
|
||||
Expect(adminTracks.Count()).To(Equal(int64(2)))
|
||||
_, err := adminTracks.Read("2")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -103,6 +103,7 @@ func (r sqlRepository) GetBookmarks() (model.Bookmarks, error) {
|
|||
sq := r.newSelect().Columns(r.tableName + ".*")
|
||||
sq = r.withAnnotation(sq, idField)
|
||||
sq = r.withBookmark(sq, idField).Where(NotEq{bookmarkTable + ".item_id": nil})
|
||||
sq = r.applyLibraryFilter(sq)
|
||||
var mfs dbMediaFiles // TODO Decouple from media_file
|
||||
err := r.queryAll(sq, &mfs)
|
||||
if err != nil {
|
||||
|
|
|
|||
|
|
@ -71,4 +71,52 @@ var _ = Describe("sqlBookmarks", func() {
|
|||
Expect(mr.GetBookmarks()).To(BeEmpty())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("library access", func() {
|
||||
var otherLib model.Library
|
||||
var restrictedUser model.User
|
||||
var adminCtx context.Context
|
||||
var userMr model.MediaFileRepository
|
||||
|
||||
BeforeEach(func() {
|
||||
adminCtx, otherLib, restrictedUser = restrictedFixture("bmk")
|
||||
|
||||
adminMr := NewMediaFileRepository(adminCtx, GetDBXBuilder())
|
||||
Expect(adminMr.Put(&model.MediaFile{
|
||||
ID: "bmk-otherlib-track", LibraryID: otherLib.ID,
|
||||
Path: "hidden/bookmarked.mp3", Title: "Hidden Bookmarked",
|
||||
})).To(Succeed())
|
||||
DeferCleanup(func() { _ = adminMr.Delete("bmk-otherlib-track") })
|
||||
|
||||
userCtx := request.WithUser(log.NewContext(GinkgoT().Context()), restrictedUser)
|
||||
userMr = NewMediaFileRepository(userCtx, GetDBXBuilder())
|
||||
})
|
||||
|
||||
It("does not return bookmarks for tracks outside the user's libraries", func() {
|
||||
Expect(userMr.AddBookmark("bmk-otherlib-track", "sneaky", 1)).To(Succeed())
|
||||
|
||||
Expect(userMr.GetBookmarks()).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("still returns the bookmark for an admin", func() {
|
||||
adminMr := NewMediaFileRepository(adminCtx, GetDBXBuilder())
|
||||
Expect(adminMr.AddBookmark("bmk-otherlib-track", "mine", 1)).To(Succeed())
|
||||
DeferCleanup(func() { _ = adminMr.DeleteBookmark("bmk-otherlib-track") })
|
||||
|
||||
bms, err := adminMr.GetBookmarks()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(bms).To(HaveLen(1))
|
||||
Expect(bms[0].Item.ID).To(Equal("bmk-otherlib-track"))
|
||||
})
|
||||
|
||||
It("keeps returning bookmarks for tracks inside the user's libraries", func() {
|
||||
Expect(userMr.AddBookmark(songAntenna.ID, "allowed", 5)).To(Succeed())
|
||||
DeferCleanup(func() { _ = userMr.DeleteBookmark(songAntenna.ID) })
|
||||
|
||||
bms, err := userMr.GetBookmarks()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(bms).To(HaveLen(1))
|
||||
Expect(bms[0].Item.ID).To(Equal(songAntenna.ID))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -47,6 +47,14 @@ func (api *Router) CreateBookmark(r *http.Request) (*responses.Subsonic, error)
|
|||
position := p.Int64Or("position", 0)
|
||||
|
||||
repo := api.ds.MediaFile(r.Context())
|
||||
ok, err := repo.Exists(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !ok {
|
||||
return nil, newError(responses.ErrorDataNotFound, "Song not found")
|
||||
}
|
||||
|
||||
err = repo.AddBookmark(id, comment, position)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
|
|
|||
48
server/subsonic/bookmarks_test.go
Normal file
48
server/subsonic/bookmarks_test.go
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
package subsonic
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
"github.com/navidrome/navidrome/server/subsonic/responses"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("Bookmarks", func() {
|
||||
var router *Router
|
||||
var ds *tests.MockDataStore
|
||||
var mfRepo *tests.MockMediaFileRepo
|
||||
var ctx context.Context
|
||||
|
||||
BeforeEach(func() {
|
||||
ds = &tests.MockDataStore{}
|
||||
router = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
ctx = request.WithUser(context.Background(), model.User{ID: "u1", UserName: "u1"})
|
||||
mfRepo = ds.MediaFile(ctx).(*tests.MockMediaFileRepo)
|
||||
mfRepo.SetData(model.MediaFiles{{ID: "visible"}})
|
||||
})
|
||||
|
||||
Describe("CreateBookmark", func() {
|
||||
It("rejects an id the user cannot read", func() {
|
||||
r := newGetRequest("id=hidden", "position=1").WithContext(ctx)
|
||||
|
||||
_, err := router.CreateBookmark(r)
|
||||
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(mapToSubsonicError(err).code).To(Equal(responses.ErrorDataNotFound))
|
||||
Expect(mfRepo.BookmarksAdded).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("accepts an id the user can read", func() {
|
||||
r := newGetRequest("id=visible", "position=1").WithContext(ctx)
|
||||
|
||||
_, err := router.CreateBookmark(r)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(mfRepo.BookmarksAdded).To(ConsistOf("visible"))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -224,6 +224,24 @@ var _ = Describe("Multi-Library Support", Ordered, func() {
|
|||
Expect(resp.Playlist.Entry).To(HaveLen(1))
|
||||
Expect(resp.Playlist.Entry[0].Id).To(Equal(lib1SongID))
|
||||
})
|
||||
|
||||
It("non-admin user cannot store a song from another library through createPlaylist", func() {
|
||||
resp := doReqWithUser(userLib1Only, "createPlaylist",
|
||||
"name", "Restricted Playlist", "songId", lib1SongID, "songId", lib2SongID)
|
||||
Expect(resp.Status).To(Equal(responses.StatusOK))
|
||||
ownID := resp.Playlist.Id
|
||||
|
||||
stored := doReqWithUser(adminWithLibs, "getPlaylist", "id", ownID)
|
||||
Expect(stored.Playlist.Entry).To(HaveLen(1), "the lib2 song must not be persisted")
|
||||
Expect(stored.Playlist.Entry[0].Id).To(Equal(lib1SongID))
|
||||
|
||||
By("replacing the tracks of the same playlist")
|
||||
resp = doReqWithUser(userLib1Only, "createPlaylist", "playlistId", ownID, "songId", lib2SongID)
|
||||
Expect(resp.Status).To(Equal(responses.StatusOK))
|
||||
|
||||
stored = doReqWithUser(adminWithLibs, "getPlaylist", "id", ownID)
|
||||
Expect(stored.Playlist.Entry).To(BeEmpty())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Cross-library shares", Ordered, func() {
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ type MockMediaFileRepo struct {
|
|||
FindRecentFilesByPropertiesFunc func(missing model.MediaFile, since time.Time) (model.MediaFiles, error)
|
||||
MatchesCriteriaValue bool
|
||||
MatchesCriteriaErr error
|
||||
BookmarksAdded []string
|
||||
}
|
||||
|
||||
func (m *MockMediaFileRepo) SetError(err bool) {
|
||||
|
|
@ -76,6 +77,14 @@ func (m *MockMediaFileRepo) Get(id string) (*model.MediaFile, error) {
|
|||
return nil, model.ErrNotFound
|
||||
}
|
||||
|
||||
func (m *MockMediaFileRepo) AddBookmark(id, _ string, _ int64) error {
|
||||
if m.Err {
|
||||
return errors.New("error")
|
||||
}
|
||||
m.BookmarksAdded = append(m.BookmarksAdded, id)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *MockMediaFileRepo) GetWithParticipants(id string) (*model.MediaFile, error) {
|
||||
if m.Err {
|
||||
return nil, errors.New("error")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue