feat: enhance artist library filtering based on user permissions and optimize library ID retrieval

Signed-off-by: Deluan <deluan@navidrome.org>
This commit is contained in:
Deluan 2025-06-27 14:39:58 -04:00
commit 739856cda4
5 changed files with 716 additions and 600 deletions

View file

@ -2,6 +2,8 @@ package model
import (
"time"
"github.com/navidrome/navidrome/utils/slice"
)
type Library struct {
@ -31,6 +33,10 @@ const (
type Libraries []Library
func (l Libraries) IDs() []int {
return slice.Map(l, func(lib Library) int { return lib.ID })
}
type LibraryRepository interface {
Get(id int) (*Library, error)
// GetPath returns the path of the library with the given ID.

View file

@ -16,7 +16,7 @@ type User struct {
UpdatedAt time.Time `structs:"updated_at" json:"updatedAt"`
// Library associations (many-to-many relationship)
Libraries []Library `structs:"-" json:"libraries,omitempty"`
Libraries Libraries `structs:"-" json:"libraries,omitempty"`
// This is only available on the backend, and it is never sent over the wire
Password string `structs:"-" json:"-"`
@ -41,15 +41,7 @@ func (u User) HasLibraryAccess(libraryID int) bool {
// AccessibleLibraryIDs returns a slice of library IDs that the user has access to
func (u User) AccessibleLibraryIDs() []int {
if len(u.Libraries) == 0 {
return nil
}
ids := make([]int, len(u.Libraries))
for i, lib := range u.Libraries {
ids[i] = lib.ID
}
return ids
return u.Libraries.IDs()
}
func (u User) FilteredLibraries(libraryIds []int) Libraries {

View file

@ -146,7 +146,7 @@ var _ = Describe("User", func() {
It("returns nil when user has no libraries", func() {
user.Libraries = nil
ids := user.AccessibleLibraryIDs()
Expect(ids).To(BeNil())
Expect(ids).To(BeEmpty())
})
})
})

View file

@ -6,6 +6,7 @@ import (
"encoding/json"
"fmt"
"slices"
"strconv"
"strings"
"time"
@ -118,7 +119,7 @@ func NewArtistRepository(ctx context.Context, db dbx.Builder) model.ArtistReposi
"starred": booleanFilter,
"role": roleFilter,
"missing": booleanFilter,
"library_id": artistLibraryFilter,
"library_id": r.artistLibraryFilter,
})
r.setSortMappings(map[string]string{
"name": "order_artist_name",
@ -145,11 +146,51 @@ func roleFilter(_ string, role any) Sqlizer {
}
// artistLibraryFilter handles filtering artists by library_id through the library_artist junction table
func artistLibraryFilter(_ string, value any) Sqlizer {
// It checks the logged-in user's permissions and returns a Sqlizer that can be used in a query.
// If the user is an admin, it returns a condition that always evaluates to true.
// If the value is nil, it uses the user's accessible library IDs.
func (r *artistRepository) artistLibraryFilter(_ string, value any) Sqlizer {
user := loggedUser(r.ctx)
// Admin users see all content
if user.IsAdmin {
return Eq{"1": 1} // Always true, no filtering needed
}
var accessibleLibraryIDs []int
if value == nil {
accessibleLibraryIDs = user.AccessibleLibraryIDs()
} else {
// Convert various input types to []int
var libIds []int
switch v := value.(type) {
case []int:
libIds = v
case []string:
for _, str := range v {
if id, err := strconv.Atoi(str); err == nil {
libIds = append(libIds, id)
}
}
case int:
libIds = []int{v}
case string:
if id, err := strconv.Atoi(v); err == nil {
libIds = []int{id}
}
}
accessibleLibraryIDs = user.FilteredLibraries(libIds).IDs()
if len(accessibleLibraryIDs) == 0 {
// User has no library access - return empty result set
return Eq{"1": "0"}
}
}
// Use a subquery to find artists that belong to the specified library
subquery := Select("1").From("library_artist la").Where(And{
Expr("la.artist_id = artist.id"),
Eq{"la.library_id": value},
Eq{"la.library_id": accessibleLibraryIDs},
})
return Expr("EXISTS (?)", subquery)
}
@ -157,22 +198,8 @@ func artistLibraryFilter(_ string, value any) Sqlizer {
// applyArtistLibraryFilter adds library filtering based on user permissions for artists
// This ensures users only see artists from libraries they have access to
func (r *artistRepository) applyArtistLibraryFilter(sq SelectBuilder) SelectBuilder {
user := loggedUser(r.ctx)
// Admin users see all content
if user.IsAdmin {
return sq
}
// Get user's accessible library IDs using User model method
accessibleLibraryIDs := user.AccessibleLibraryIDs()
if len(accessibleLibraryIDs) == 0 {
// User has no library access - return empty result set
return sq.Where(Eq{"1": "0"})
}
// Use existing artistLibraryFilter with the user's accessible library IDs
libFilter := artistLibraryFilter("library_id", accessibleLibraryIDs)
libFilter := r.artistLibraryFilter("library_id", nil)
return sq.Where(libFilter)
}
@ -190,26 +217,10 @@ func (r *artistRepository) CountAll(options ...model.QueryOptions) (int64, error
}
func (r *artistRepository) Exists(id string) (bool, error) {
// For Exists, we need to check if the artist exists AND the user has access to it
user := loggedUser(r.ctx)
// Admin users see all content
if user.IsAdmin {
return r.exists(Eq{"artist.id": id})
}
// Get user's accessible library IDs using User model method
accessibleLibraryIDs := user.AccessibleLibraryIDs()
if len(accessibleLibraryIDs) == 0 {
// User has no library access - return false
return false, nil
}
// Check if artist exists AND user has access to it via library permissions
libFilter := artistLibraryFilter("library_id", accessibleLibraryIDs)
condition := And{
Eq{"artist.id": id},
libFilter,
r.artistLibraryFilter("library_id", nil),
}
return r.exists(condition)
@ -293,7 +304,7 @@ func (r *artistRepository) GetIndex(includeMissing bool, libraryIds []int, roles
}
}
libFilter := artistLibraryFilter("library_id", libraryIds)
libFilter := r.artistLibraryFilter("library_id", libraryIds)
if options.Filters == nil {
options.Filters = libFilter
} else {

File diff suppressed because it is too large Load diff