mirror of
https://github.com/navidrome/navidrome.git
synced 2025-04-01 19:47:37 +03:00
Improve SQL sanitization
This commit is contained in:
parent
d3bb4bb9a1
commit
3107170afd
23 changed files with 259 additions and 159 deletions
|
@ -3,11 +3,11 @@ package model
|
|||
import "time"
|
||||
|
||||
type Annotations struct {
|
||||
PlayCount int64 `structs:"-" json:"playCount"`
|
||||
PlayDate *time.Time `structs:"-" json:"playDate" `
|
||||
Rating int `structs:"-" json:"rating" `
|
||||
Starred bool `structs:"-" json:"starred" `
|
||||
StarredAt *time.Time `structs:"-" json:"starredAt"`
|
||||
PlayCount int64 `structs:"play_count" json:"playCount"`
|
||||
PlayDate *time.Time `structs:"play_date" json:"playDate" `
|
||||
Rating int `structs:"rating" json:"rating" `
|
||||
Starred bool `structs:"starred" json:"starred" `
|
||||
StarredAt *time.Time `structs:"starred_at" json:"starredAt"`
|
||||
}
|
||||
|
||||
type AnnotatedRepository interface {
|
||||
|
|
|
@ -16,7 +16,6 @@ import (
|
|||
|
||||
type albumRepository struct {
|
||||
sqlRepository
|
||||
sqlRestful
|
||||
}
|
||||
|
||||
type dbAlbum struct {
|
||||
|
@ -58,8 +57,7 @@ func NewAlbumRepository(ctx context.Context, db dbx.Builder) model.AlbumReposito
|
|||
r := &albumRepository{}
|
||||
r.ctx = ctx
|
||||
r.db = db
|
||||
r.tableName = "album"
|
||||
r.filterMappings = map[string]filterFunc{
|
||||
r.registerModel(&model.Album{}, map[string]filterFunc{
|
||||
"id": idFilter(r.tableName),
|
||||
"name": fullTextFilter,
|
||||
"compilation": booleanFilter,
|
||||
|
@ -68,12 +66,12 @@ func NewAlbumRepository(ctx context.Context, db dbx.Builder) model.AlbumReposito
|
|||
"recently_played": recentlyPlayedFilter,
|
||||
"starred": booleanFilter,
|
||||
"has_rating": hasRatingFilter,
|
||||
}
|
||||
})
|
||||
if conf.Server.PreferSortTags {
|
||||
r.sortMappings = map[string]string{
|
||||
"name": "COALESCE(NULLIF(sort_album_name,''),order_album_name)",
|
||||
"artist": "compilation asc, COALESCE(NULLIF(sort_album_artist_name,''),order_album_artist_name) asc, COALESCE(NULLIF(sort_album_name,''),order_album_name) asc",
|
||||
"albumArtist": "compilation asc, COALESCE(NULLIF(sort_album_artist_name,''),order_album_artist_name) asc, COALESCE(NULLIF(sort_album_name,''),order_album_name) asc",
|
||||
"album_artist": "compilation asc, COALESCE(NULLIF(sort_album_artist_name,''),order_album_artist_name) asc, COALESCE(NULLIF(sort_album_name,''),order_album_name) asc",
|
||||
"max_year": "coalesce(nullif(original_date,''), cast(max_year as text)), release_date, name, COALESCE(NULLIF(sort_album_name,''),order_album_name) asc",
|
||||
"random": r.seededRandomSort(),
|
||||
"recently_added": recentlyAddedSort(),
|
||||
|
@ -82,7 +80,7 @@ func NewAlbumRepository(ctx context.Context, db dbx.Builder) model.AlbumReposito
|
|||
r.sortMappings = map[string]string{
|
||||
"name": "order_album_name asc, order_album_artist_name asc",
|
||||
"artist": "compilation asc, order_album_artist_name asc, order_album_name asc",
|
||||
"albumArtist": "compilation asc, order_album_artist_name asc, order_album_name asc",
|
||||
"album_artist": "compilation asc, order_album_artist_name asc, order_album_name asc",
|
||||
"max_year": "coalesce(nullif(original_date,''), cast(max_year as text)), release_date, name, order_album_name asc",
|
||||
"random": r.seededRandomSort(),
|
||||
"recently_added": recentlyAddedSort(),
|
||||
|
@ -213,7 +211,7 @@ func (r *albumRepository) Search(q string, offset int, size int) (model.Albums,
|
|||
}
|
||||
|
||||
func (r *albumRepository) Count(options ...rest.QueryOptions) (int64, error) {
|
||||
return r.CountAll(r.parseRestOptions(options...))
|
||||
return r.CountAll(r.parseRestOptions(r.ctx, options...))
|
||||
}
|
||||
|
||||
func (r *albumRepository) Read(id string) (interface{}, error) {
|
||||
|
@ -221,7 +219,7 @@ func (r *albumRepository) Read(id string) (interface{}, error) {
|
|||
}
|
||||
|
||||
func (r *albumRepository) ReadAll(options ...rest.QueryOptions) (interface{}, error) {
|
||||
return r.GetAll(r.parseRestOptions(options...))
|
||||
return r.GetAll(r.parseRestOptions(r.ctx, options...))
|
||||
}
|
||||
|
||||
func (r *albumRepository) EntityName() string {
|
||||
|
|
|
@ -20,7 +20,6 @@ import (
|
|||
|
||||
type artistRepository struct {
|
||||
sqlRepository
|
||||
sqlRestful
|
||||
indexGroups utils.IndexGroups
|
||||
}
|
||||
|
||||
|
@ -60,12 +59,11 @@ func NewArtistRepository(ctx context.Context, db dbx.Builder) model.ArtistReposi
|
|||
r.ctx = ctx
|
||||
r.db = db
|
||||
r.indexGroups = utils.ParseIndexGroups(conf.Server.IndexGroups)
|
||||
r.tableName = "artist"
|
||||
r.filterMappings = map[string]filterFunc{
|
||||
r.registerModel(&model.Artist{}, map[string]filterFunc{
|
||||
"id": idFilter(r.tableName),
|
||||
"name": fullTextFilter,
|
||||
"starred": booleanFilter,
|
||||
}
|
||||
})
|
||||
if conf.Server.PreferSortTags {
|
||||
r.sortMappings = map[string]string{
|
||||
"name": "COALESCE(NULLIF(sort_artist_name,''),order_artist_name)",
|
||||
|
@ -200,7 +198,7 @@ func (r *artistRepository) Search(q string, offset int, size int) (model.Artists
|
|||
}
|
||||
|
||||
func (r *artistRepository) Count(options ...rest.QueryOptions) (int64, error) {
|
||||
return r.CountAll(r.parseRestOptions(options...))
|
||||
return r.CountAll(r.parseRestOptions(r.ctx, options...))
|
||||
}
|
||||
|
||||
func (r *artistRepository) Read(id string) (interface{}, error) {
|
||||
|
@ -208,7 +206,7 @@ func (r *artistRepository) Read(id string) (interface{}, error) {
|
|||
}
|
||||
|
||||
func (r *artistRepository) ReadAll(options ...rest.QueryOptions) (interface{}, error) {
|
||||
return r.GetAll(r.parseRestOptions(options...))
|
||||
return r.GetAll(r.parseRestOptions(r.ctx, options...))
|
||||
}
|
||||
|
||||
func (r *artistRepository) EntityName() string {
|
||||
|
|
|
@ -14,17 +14,15 @@ import (
|
|||
|
||||
type genreRepository struct {
|
||||
sqlRepository
|
||||
sqlRestful
|
||||
}
|
||||
|
||||
func NewGenreRepository(ctx context.Context, db dbx.Builder) model.GenreRepository {
|
||||
r := &genreRepository{}
|
||||
r.ctx = ctx
|
||||
r.db = db
|
||||
r.tableName = "genre"
|
||||
r.filterMappings = map[string]filterFunc{
|
||||
r.registerModel(&model.Genre{}, map[string]filterFunc{
|
||||
"name": containsFilter("name"),
|
||||
}
|
||||
})
|
||||
return r
|
||||
}
|
||||
|
||||
|
@ -60,7 +58,7 @@ func (r *genreRepository) Put(m *model.Genre) error {
|
|||
}
|
||||
|
||||
func (r *genreRepository) Count(options ...rest.QueryOptions) (int64, error) {
|
||||
return r.count(Select(), r.parseRestOptions(options...))
|
||||
return r.count(Select(), r.parseRestOptions(r.ctx, options...))
|
||||
}
|
||||
|
||||
func (r *genreRepository) Read(id string) (interface{}, error) {
|
||||
|
@ -71,7 +69,7 @@ func (r *genreRepository) Read(id string) (interface{}, error) {
|
|||
}
|
||||
|
||||
func (r *genreRepository) ReadAll(options ...rest.QueryOptions) (interface{}, error) {
|
||||
sel := r.newSelect(r.parseRestOptions(options...)).Columns("*")
|
||||
sel := r.newSelect(r.parseRestOptions(r.ctx, options...)).Columns("*")
|
||||
res := model.Genres{}
|
||||
err := r.queryAll(sel, &res)
|
||||
return res, err
|
||||
|
|
|
@ -18,7 +18,7 @@ func NewLibraryRepository(ctx context.Context, db dbx.Builder) model.LibraryRepo
|
|||
r := &libraryRepository{}
|
||||
r.ctx = ctx
|
||||
r.db = db
|
||||
r.tableName = "library"
|
||||
r.registerModel(&model.Library{}, nil)
|
||||
return r
|
||||
}
|
||||
|
||||
|
|
|
@ -18,34 +18,34 @@ import (
|
|||
|
||||
type mediaFileRepository struct {
|
||||
sqlRepository
|
||||
sqlRestful
|
||||
}
|
||||
|
||||
func NewMediaFileRepository(ctx context.Context, db dbx.Builder) *mediaFileRepository {
|
||||
r := &mediaFileRepository{}
|
||||
r.ctx = ctx
|
||||
r.db = db
|
||||
r.tableName = "media_file"
|
||||
r.filterMappings = map[string]filterFunc{
|
||||
r.registerModel(&model.MediaFile{}, map[string]filterFunc{
|
||||
"id": idFilter(r.tableName),
|
||||
"title": fullTextFilter,
|
||||
"starred": booleanFilter,
|
||||
}
|
||||
})
|
||||
if conf.Server.PreferSortTags {
|
||||
r.sortMappings = map[string]string{
|
||||
"title": "COALESCE(NULLIF(sort_title,''),title)",
|
||||
"artist": "COALESCE(NULLIF(sort_artist_name,''),order_artist_name) asc, COALESCE(NULLIF(sort_album_name,''),order_album_name) asc, release_date asc, disc_number asc, track_number asc",
|
||||
"album": "COALESCE(NULLIF(sort_album_name,''),order_album_name) asc, release_date asc, disc_number asc, track_number asc, COALESCE(NULLIF(sort_artist_name,''),order_artist_name) asc, COALESCE(NULLIF(sort_title,''),title) asc",
|
||||
"random": r.seededRandomSort(),
|
||||
"createdAt": "media_file.created_at",
|
||||
"title": "COALESCE(NULLIF(sort_title,''),title)",
|
||||
"artist": "COALESCE(NULLIF(sort_artist_name,''),order_artist_name) asc, COALESCE(NULLIF(sort_album_name,''),order_album_name) asc, release_date asc, disc_number asc, track_number asc",
|
||||
"album": "COALESCE(NULLIF(sort_album_name,''),order_album_name) asc, release_date asc, disc_number asc, track_number asc, COALESCE(NULLIF(sort_artist_name,''),order_artist_name) asc, COALESCE(NULLIF(sort_title,''),title) asc",
|
||||
"random": r.seededRandomSort(),
|
||||
"created_at": "media_file.created_at",
|
||||
"track_number": "album, release_date, disc_number, track_number",
|
||||
}
|
||||
} else {
|
||||
r.sortMappings = map[string]string{
|
||||
"title": "order_title",
|
||||
"artist": "order_artist_name asc, order_album_name asc, release_date asc, disc_number asc, track_number asc",
|
||||
"album": "order_album_name asc, release_date asc, disc_number asc, track_number asc, order_artist_name asc, title asc",
|
||||
"random": r.seededRandomSort(),
|
||||
"createdAt": "media_file.created_at",
|
||||
"title": "order_title",
|
||||
"artist": "order_artist_name asc, order_album_name asc, release_date asc, disc_number asc, track_number asc",
|
||||
"album": "order_album_name asc, release_date asc, disc_number asc, track_number asc, order_artist_name asc, title asc",
|
||||
"random": r.seededRandomSort(),
|
||||
"created_at": "media_file.created_at",
|
||||
"track_number": "album, release_date, disc_number, track_number",
|
||||
}
|
||||
}
|
||||
return r
|
||||
|
@ -209,7 +209,7 @@ func (r *mediaFileRepository) Search(q string, offset int, size int) (model.Medi
|
|||
}
|
||||
|
||||
func (r *mediaFileRepository) Count(options ...rest.QueryOptions) (int64, error) {
|
||||
return r.CountAll(r.parseRestOptions(options...))
|
||||
return r.CountAll(r.parseRestOptions(r.ctx, options...))
|
||||
}
|
||||
|
||||
func (r *mediaFileRepository) Read(id string) (interface{}, error) {
|
||||
|
@ -217,7 +217,7 @@ func (r *mediaFileRepository) Read(id string) (interface{}, error) {
|
|||
}
|
||||
|
||||
func (r *mediaFileRepository) ReadAll(options ...rest.QueryOptions) (interface{}, error) {
|
||||
return r.GetAll(r.parseRestOptions(options...))
|
||||
return r.GetAll(r.parseRestOptions(r.ctx, options...))
|
||||
}
|
||||
|
||||
func (r *mediaFileRepository) EntityName() string {
|
||||
|
|
|
@ -12,17 +12,15 @@ import (
|
|||
|
||||
type playerRepository struct {
|
||||
sqlRepository
|
||||
sqlRestful
|
||||
}
|
||||
|
||||
func NewPlayerRepository(ctx context.Context, db dbx.Builder) model.PlayerRepository {
|
||||
r := &playerRepository{}
|
||||
r.ctx = ctx
|
||||
r.db = db
|
||||
r.tableName = "player"
|
||||
r.filterMappings = map[string]filterFunc{
|
||||
r.registerModel(&model.Player{}, map[string]filterFunc{
|
||||
"name": containsFilter("player.name"),
|
||||
}
|
||||
})
|
||||
return r
|
||||
}
|
||||
|
||||
|
@ -74,7 +72,7 @@ func (r *playerRepository) addRestriction(sql ...Sqlizer) Sqlizer {
|
|||
}
|
||||
|
||||
func (r *playerRepository) Count(options ...rest.QueryOptions) (int64, error) {
|
||||
return r.count(r.newRestSelect(), r.parseRestOptions(options...))
|
||||
return r.count(r.newRestSelect(), r.parseRestOptions(r.ctx, options...))
|
||||
}
|
||||
|
||||
func (r *playerRepository) Read(id string) (interface{}, error) {
|
||||
|
@ -85,7 +83,7 @@ func (r *playerRepository) Read(id string) (interface{}, error) {
|
|||
}
|
||||
|
||||
func (r *playerRepository) ReadAll(options ...rest.QueryOptions) (interface{}, error) {
|
||||
sel := r.newRestSelect(r.parseRestOptions(options...))
|
||||
sel := r.newRestSelect(r.parseRestOptions(r.ctx, options...))
|
||||
res := model.Players{}
|
||||
err := r.queryAll(sel, &res)
|
||||
return res, err
|
||||
|
|
|
@ -19,7 +19,6 @@ import (
|
|||
|
||||
type playlistRepository struct {
|
||||
sqlRepository
|
||||
sqlRestful
|
||||
}
|
||||
|
||||
type dbPlaylist struct {
|
||||
|
@ -51,11 +50,10 @@ func NewPlaylistRepository(ctx context.Context, db dbx.Builder) model.PlaylistRe
|
|||
r := &playlistRepository{}
|
||||
r.ctx = ctx
|
||||
r.db = db
|
||||
r.tableName = "playlist"
|
||||
r.filterMappings = map[string]filterFunc{
|
||||
r.registerModel(&model.Playlist{}, map[string]filterFunc{
|
||||
"q": playlistFilter,
|
||||
"smart": smartPlaylistFilter,
|
||||
}
|
||||
})
|
||||
return r
|
||||
}
|
||||
|
||||
|
@ -372,7 +370,7 @@ func (r *playlistRepository) loadTracks(sel SelectBuilder, id string) (model.Pla
|
|||
}
|
||||
|
||||
func (r *playlistRepository) Count(options ...rest.QueryOptions) (int64, error) {
|
||||
return r.CountAll(r.parseRestOptions(options...))
|
||||
return r.CountAll(r.parseRestOptions(r.ctx, options...))
|
||||
}
|
||||
|
||||
func (r *playlistRepository) Read(id string) (interface{}, error) {
|
||||
|
@ -380,7 +378,7 @@ func (r *playlistRepository) Read(id string) (interface{}, error) {
|
|||
}
|
||||
|
||||
func (r *playlistRepository) ReadAll(options ...rest.QueryOptions) (interface{}, error) {
|
||||
return r.GetAll(r.parseRestOptions(options...))
|
||||
return r.GetAll(r.parseRestOptions(r.ctx, options...))
|
||||
}
|
||||
|
||||
func (r *playlistRepository) EntityName() string {
|
||||
|
|
|
@ -13,7 +13,6 @@ import (
|
|||
|
||||
type playlistTrackRepository struct {
|
||||
sqlRepository
|
||||
sqlRestful
|
||||
playlistId string
|
||||
playlist *model.Playlist
|
||||
playlistRepo *playlistRepository
|
||||
|
@ -26,6 +25,7 @@ func (r *playlistRepository) Tracks(playlistId string, refreshSmartPlaylist bool
|
|||
p.ctx = r.ctx
|
||||
p.db = r.db
|
||||
p.tableName = "playlist_tracks"
|
||||
p.registerModel(&model.PlaylistTrack{}, nil)
|
||||
p.sortMappings = map[string]string{
|
||||
"id": "playlist_tracks.id",
|
||||
"artist": "order_artist_name asc",
|
||||
|
@ -51,7 +51,7 @@ func (r *playlistRepository) Tracks(playlistId string, refreshSmartPlaylist bool
|
|||
}
|
||||
|
||||
func (r *playlistTrackRepository) Count(options ...rest.QueryOptions) (int64, error) {
|
||||
return r.count(Select().Where(Eq{"playlist_id": r.playlistId}), r.parseRestOptions(options...))
|
||||
return r.count(Select().Where(Eq{"playlist_id": r.playlistId}), r.parseRestOptions(r.ctx, options...))
|
||||
}
|
||||
|
||||
func (r *playlistTrackRepository) Read(id string) (interface{}, error) {
|
||||
|
@ -112,7 +112,7 @@ func (r *playlistTrackRepository) GetAlbumIDs(options ...model.QueryOptions) ([]
|
|||
}
|
||||
|
||||
func (r *playlistTrackRepository) ReadAll(options ...rest.QueryOptions) (interface{}, error) {
|
||||
return r.GetAll(r.parseRestOptions(options...))
|
||||
return r.GetAll(r.parseRestOptions(r.ctx, options...))
|
||||
}
|
||||
|
||||
func (r *playlistTrackRepository) EntityName() string {
|
||||
|
|
|
@ -15,17 +15,15 @@ import (
|
|||
|
||||
type radioRepository struct {
|
||||
sqlRepository
|
||||
sqlRestful
|
||||
}
|
||||
|
||||
func NewRadioRepository(ctx context.Context, db dbx.Builder) model.RadioRepository {
|
||||
r := &radioRepository{}
|
||||
r.ctx = ctx
|
||||
r.db = db
|
||||
r.tableName = "radio"
|
||||
r.filterMappings = map[string]filterFunc{
|
||||
r.registerModel(&model.Radio{}, map[string]filterFunc{
|
||||
"name": containsFilter("name"),
|
||||
}
|
||||
})
|
||||
r.sortMappings = map[string]string{
|
||||
"name": "(name collate nocase), name",
|
||||
}
|
||||
|
@ -96,7 +94,7 @@ func (r *radioRepository) Put(radio *model.Radio) error {
|
|||
}
|
||||
|
||||
func (r *radioRepository) Count(options ...rest.QueryOptions) (int64, error) {
|
||||
return r.CountAll(r.parseRestOptions(options...))
|
||||
return r.CountAll(r.parseRestOptions(r.ctx, options...))
|
||||
}
|
||||
|
||||
func (r *radioRepository) EntityName() string {
|
||||
|
@ -112,7 +110,7 @@ func (r *radioRepository) Read(id string) (interface{}, error) {
|
|||
}
|
||||
|
||||
func (r *radioRepository) ReadAll(options ...rest.QueryOptions) (interface{}, error) {
|
||||
return r.GetAll(r.parseRestOptions(options...))
|
||||
return r.GetAll(r.parseRestOptions(r.ctx, options...))
|
||||
}
|
||||
|
||||
func (r *radioRepository) Save(entity interface{}) (string, error) {
|
||||
|
|
|
@ -17,14 +17,13 @@ import (
|
|||
|
||||
type shareRepository struct {
|
||||
sqlRepository
|
||||
sqlRestful
|
||||
}
|
||||
|
||||
func NewShareRepository(ctx context.Context, db dbx.Builder) model.ShareRepository {
|
||||
r := &shareRepository{}
|
||||
r.ctx = ctx
|
||||
r.db = db
|
||||
r.tableName = "share"
|
||||
r.registerModel(&model.Share{}, map[string]filterFunc{})
|
||||
return r
|
||||
}
|
||||
|
||||
|
@ -166,7 +165,7 @@ func (r *shareRepository) CountAll(options ...model.QueryOptions) (int64, error)
|
|||
}
|
||||
|
||||
func (r *shareRepository) Count(options ...rest.QueryOptions) (int64, error) {
|
||||
return r.CountAll(r.parseRestOptions(options...))
|
||||
return r.CountAll(r.parseRestOptions(r.ctx, options...))
|
||||
}
|
||||
|
||||
func (r *shareRepository) EntityName() string {
|
||||
|
@ -185,7 +184,7 @@ func (r *shareRepository) Read(id string) (interface{}, error) {
|
|||
}
|
||||
|
||||
func (r *shareRepository) ReadAll(options ...rest.QueryOptions) (interface{}, error) {
|
||||
sq := r.selectShare(r.parseRestOptions(options...))
|
||||
sq := r.selectShare(r.parseRestOptions(r.ctx, options...))
|
||||
res := model.Shares{}
|
||||
err := r.queryAll(sq, &res)
|
||||
return res, err
|
||||
|
|
|
@ -19,11 +19,25 @@ import (
|
|||
"github.com/pocketbase/dbx"
|
||||
)
|
||||
|
||||
// sqlRepository is the base repository for all SQL repositories. It provides common functions to interact with the DB.
|
||||
// When creating a new repository using this base, you must:
|
||||
//
|
||||
// - Embed this struct.
|
||||
// - Set ctx and db fields. ctx should be the context passed to the constructor method, usually obtained from the request
|
||||
// - Call registerModel with the model instance and any possible filters.
|
||||
// - If the model has a different table name than the default (lowercase of the model name), it should be set manually
|
||||
// using the tableName field.
|
||||
// - Sort mappings should be set in the sortMappings field. If the sort field is not in the map, it will be used as is.
|
||||
//
|
||||
// All fields in filters and sortMappings must be in snake_case. Only sorts and filters based on real field names or
|
||||
// defined in the mappings will be allowed.
|
||||
type sqlRepository struct {
|
||||
ctx context.Context
|
||||
tableName string
|
||||
db dbx.Builder
|
||||
sortMappings map[string]string
|
||||
ctx context.Context
|
||||
tableName string
|
||||
db dbx.Builder
|
||||
sortMappings map[string]string
|
||||
filterMappings map[string]filterFunc
|
||||
isFieldWhiteListed fieldWhiteListedFunc
|
||||
}
|
||||
|
||||
const invalidUserId = "-1"
|
||||
|
@ -44,6 +58,16 @@ func loggedUser(ctx context.Context) *model.User {
|
|||
}
|
||||
}
|
||||
|
||||
func (r *sqlRepository) registerModel(instance any, filters map[string]filterFunc) {
|
||||
if r.tableName == "" {
|
||||
r.tableName = strings.TrimPrefix(reflect.TypeOf(instance).String(), "*model.")
|
||||
r.tableName = toSnakeCase(r.tableName)
|
||||
}
|
||||
r.tableName = strings.ToLower(r.tableName)
|
||||
r.isFieldWhiteListed = registerModelWhiteList(instance)
|
||||
r.filterMappings = filters
|
||||
}
|
||||
|
||||
func (r sqlRepository) getTableName() string {
|
||||
return r.tableName
|
||||
}
|
||||
|
|
|
@ -73,40 +73,61 @@ var _ = Describe("sqlRepository", func() {
|
|||
})
|
||||
})
|
||||
|
||||
Describe("sortMapping", func() {
|
||||
Describe("sanitizeSort", func() {
|
||||
BeforeEach(func() {
|
||||
r.registerModel(&struct {
|
||||
Field string `structs:"field"`
|
||||
}{}, nil)
|
||||
r.sortMappings = map[string]string{
|
||||
"sort1": "mappedSort1",
|
||||
"sortTwo": "mappedSort2",
|
||||
"sort_three": "mappedSort3",
|
||||
"sort1": "mappedSort1",
|
||||
}
|
||||
})
|
||||
|
||||
It("returns the mapped value when sort key exists", func() {
|
||||
Expect(r.sortMapping("sort1")).To(Equal("mappedSort1"))
|
||||
})
|
||||
When("sanitizing sort", func() {
|
||||
It("returns empty if the sort key is not found in the model nor in the mappings", func() {
|
||||
sort, _ := r.sanitizeSort("unknown", "")
|
||||
Expect(sort).To(BeEmpty())
|
||||
})
|
||||
|
||||
Context("when sort key does not exist", func() {
|
||||
It("returns the original sort key, snake cased", func() {
|
||||
Expect(r.sortMapping("NotFoundSort")).To(Equal("not_found_sort"))
|
||||
It("returns the mapped value when sort key exists", func() {
|
||||
sort, _ := r.sanitizeSort("sort1", "")
|
||||
Expect(sort).To(Equal("mappedSort1"))
|
||||
})
|
||||
|
||||
It("is case insensitive", func() {
|
||||
sort, _ := r.sanitizeSort("Sort1", "")
|
||||
Expect(sort).To(Equal("mappedSort1"))
|
||||
})
|
||||
|
||||
It("returns the field if it is a valid field", func() {
|
||||
sort, _ := r.sanitizeSort("field", "")
|
||||
Expect(sort).To(Equal("field"))
|
||||
})
|
||||
|
||||
It("is case insensitive for fields", func() {
|
||||
sort, _ := r.sanitizeSort("FIELD", "")
|
||||
Expect(sort).To(Equal("field"))
|
||||
})
|
||||
})
|
||||
When("sanitizing order", func() {
|
||||
It("returns 'asc' if order is empty", func() {
|
||||
_, order := r.sanitizeSort("", "")
|
||||
Expect(order).To(Equal(""))
|
||||
})
|
||||
|
||||
Context("when sort key is camel cased", func() {
|
||||
It("returns the mapped value when camel case sort key exists", func() {
|
||||
Expect(r.sortMapping("sortTwo")).To(Equal("mappedSort2"))
|
||||
It("returns 'asc' if order is 'asc'", func() {
|
||||
_, order := r.sanitizeSort("", "ASC")
|
||||
Expect(order).To(Equal("asc"))
|
||||
})
|
||||
It("returns the mapped value when passing a snake case key", func() {
|
||||
Expect(r.sortMapping("sort_two")).To(Equal("mappedSort2"))
|
||||
})
|
||||
})
|
||||
|
||||
Context("when sort key is snake cased", func() {
|
||||
It("returns the mapped value when snake case sort key exists", func() {
|
||||
Expect(r.sortMapping("sort_three")).To(Equal("mappedSort3"))
|
||||
It("returns 'desc' if order is 'desc'", func() {
|
||||
_, order := r.sanitizeSort("", "desc")
|
||||
Expect(order).To(Equal("desc"))
|
||||
})
|
||||
It("returns the mapped value when passing a camel case key", func() {
|
||||
Expect(r.sortMapping("sortThree")).To(Equal("mappedSort3"))
|
||||
|
||||
It("returns 'asc' if order is unknown", func() {
|
||||
_, order := r.sanitizeSort("", "something")
|
||||
Expect(order).To(Equal("asc"))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
@ -1,61 +1,94 @@
|
|||
package persistence
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
. "github.com/Masterminds/squirrel"
|
||||
"github.com/deluan/rest"
|
||||
"github.com/fatih/structs"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
)
|
||||
|
||||
type filterFunc = func(field string, value interface{}) Sqlizer
|
||||
type filterFunc = func(field string, value any) Sqlizer
|
||||
|
||||
type sqlRestful struct {
|
||||
filterMappings map[string]filterFunc
|
||||
}
|
||||
|
||||
func (r sqlRestful) parseRestFilters(options rest.QueryOptions) Sqlizer {
|
||||
func (r *sqlRepository) parseRestFilters(ctx context.Context, options rest.QueryOptions) Sqlizer {
|
||||
if len(options.Filters) == 0 {
|
||||
return nil
|
||||
}
|
||||
filters := And{}
|
||||
for f, v := range options.Filters {
|
||||
// Ignore filters with empty values
|
||||
if v == "" {
|
||||
continue
|
||||
}
|
||||
// Look for a custom filter function
|
||||
f = strings.ToLower(f)
|
||||
if ff, ok := r.filterMappings[f]; ok {
|
||||
filters = append(filters, ff(f, v))
|
||||
} else if strings.HasSuffix(strings.ToLower(f), "id") {
|
||||
filters = append(filters, eqFilter(f, v))
|
||||
} else {
|
||||
filters = append(filters, startsWithFilter(f, v))
|
||||
continue
|
||||
}
|
||||
// Ignore invalid filters (not based on a field or filter function)
|
||||
if r.isFieldWhiteListed != nil && !r.isFieldWhiteListed(f) {
|
||||
log.Warn(ctx, "Ignoring filter not whitelisted", "filter", f)
|
||||
continue
|
||||
}
|
||||
// For fields ending in "id", use an exact match
|
||||
if strings.HasSuffix(f, "id") {
|
||||
filters = append(filters, eqFilter(f, v))
|
||||
continue
|
||||
}
|
||||
// Default to a "starts with" filter
|
||||
filters = append(filters, startsWithFilter(f, v))
|
||||
}
|
||||
return filters
|
||||
}
|
||||
|
||||
func (r sqlRestful) parseRestOptions(options ...rest.QueryOptions) model.QueryOptions {
|
||||
func (r *sqlRepository) parseRestOptions(ctx context.Context, options ...rest.QueryOptions) model.QueryOptions {
|
||||
qo := model.QueryOptions{}
|
||||
if len(options) > 0 {
|
||||
qo.Sort = options[0].Sort
|
||||
qo.Order = strings.ToLower(options[0].Order)
|
||||
qo.Sort, qo.Order = r.sanitizeSort(options[0].Sort, options[0].Order)
|
||||
qo.Max = options[0].Max
|
||||
qo.Offset = options[0].Offset
|
||||
if seed, ok := options[0].Filters["seed"].(string); ok {
|
||||
qo.Seed = seed
|
||||
delete(options[0].Filters, "seed")
|
||||
}
|
||||
qo.Filters = r.parseRestFilters(options[0])
|
||||
qo.Filters = r.parseRestFilters(ctx, options[0])
|
||||
}
|
||||
return qo
|
||||
}
|
||||
|
||||
func eqFilter(field string, value interface{}) Sqlizer {
|
||||
func (r sqlRepository) sanitizeSort(sort, order string) (string, string) {
|
||||
if sort != "" {
|
||||
sort = toSnakeCase(sort)
|
||||
if mapped, ok := r.sortMappings[sort]; ok {
|
||||
sort = mapped
|
||||
} else {
|
||||
if !r.isFieldWhiteListed(sort) {
|
||||
log.Warn(r.ctx, "Ignoring sort not whitelisted", "sort", sort)
|
||||
sort = ""
|
||||
}
|
||||
}
|
||||
}
|
||||
if order != "" {
|
||||
order = strings.ToLower(order)
|
||||
if order != "desc" {
|
||||
order = "asc"
|
||||
}
|
||||
}
|
||||
return sort, order
|
||||
}
|
||||
|
||||
func eqFilter(field string, value any) Sqlizer {
|
||||
return Eq{field: value}
|
||||
}
|
||||
|
||||
func startsWithFilter(field string, value interface{}) Sqlizer {
|
||||
func startsWithFilter(field string, value any) Sqlizer {
|
||||
return Like{field: fmt.Sprintf("%s%%", value)}
|
||||
}
|
||||
|
||||
|
@ -65,16 +98,16 @@ func containsFilter(field string) func(string, any) Sqlizer {
|
|||
}
|
||||
}
|
||||
|
||||
func booleanFilter(field string, value interface{}) Sqlizer {
|
||||
func booleanFilter(field string, value any) Sqlizer {
|
||||
v := strings.ToLower(value.(string))
|
||||
return Eq{field: strings.ToLower(v) == "true"}
|
||||
}
|
||||
|
||||
func fullTextFilter(field string, value interface{}) Sqlizer {
|
||||
func fullTextFilter(_ string, value any) Sqlizer {
|
||||
return fullTextExpr(value.(string))
|
||||
}
|
||||
|
||||
func substringFilter(field string, value interface{}) Sqlizer {
|
||||
func substringFilter(field string, value any) Sqlizer {
|
||||
parts := strings.Split(value.(string), " ")
|
||||
filters := And{}
|
||||
for _, part := range parts {
|
||||
|
@ -83,8 +116,57 @@ func substringFilter(field string, value interface{}) Sqlizer {
|
|||
return filters
|
||||
}
|
||||
|
||||
func idFilter(tableName string) func(string, interface{}) Sqlizer {
|
||||
return func(field string, value interface{}) Sqlizer {
|
||||
func idFilter(tableName string) func(string, any) Sqlizer {
|
||||
return func(field string, value any) Sqlizer {
|
||||
return Eq{tableName + ".id": value}
|
||||
}
|
||||
}
|
||||
|
||||
func invalidFilter(ctx context.Context) func(string, any) Sqlizer {
|
||||
return func(field string, value any) Sqlizer {
|
||||
log.Warn(ctx, "Invalid filter", "fieldName", field, "value", value)
|
||||
return Eq{"1": "0"}
|
||||
}
|
||||
}
|
||||
|
||||
var (
|
||||
whiteList = map[string]map[string]struct{}{}
|
||||
mutex sync.RWMutex
|
||||
)
|
||||
|
||||
func registerModelWhiteList(instance any) fieldWhiteListedFunc {
|
||||
name := reflect.TypeOf(instance).String()
|
||||
registerFieldWhiteList(name, instance)
|
||||
return getFieldWhiteListedFunc(name)
|
||||
}
|
||||
|
||||
func registerFieldWhiteList(name string, instance any) {
|
||||
mutex.Lock()
|
||||
defer mutex.Unlock()
|
||||
if whiteList[name] != nil {
|
||||
return
|
||||
}
|
||||
m := structs.Map(instance)
|
||||
whiteList[name] = map[string]struct{}{}
|
||||
for k := range m {
|
||||
whiteList[name][toSnakeCase(k)] = struct{}{}
|
||||
}
|
||||
ma := structs.Map(model.Annotations{})
|
||||
for k := range ma {
|
||||
whiteList[name][toSnakeCase(k)] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
type fieldWhiteListedFunc func(field string) bool
|
||||
|
||||
func getFieldWhiteListedFunc(tableName string) fieldWhiteListedFunc {
|
||||
return func(field string) bool {
|
||||
mutex.RLock()
|
||||
defer mutex.RUnlock()
|
||||
if _, ok := whiteList[tableName]; !ok {
|
||||
return false
|
||||
}
|
||||
_, ok := whiteList[tableName][field]
|
||||
return ok
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,6 +1,8 @@
|
|||
package persistence
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/Masterminds/squirrel"
|
||||
"github.com/deluan/rest"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
|
@ -9,31 +11,31 @@ import (
|
|||
|
||||
var _ = Describe("sqlRestful", func() {
|
||||
Describe("parseRestFilters", func() {
|
||||
var r sqlRestful
|
||||
var r sqlRepository
|
||||
var options rest.QueryOptions
|
||||
|
||||
BeforeEach(func() {
|
||||
r = sqlRestful{}
|
||||
r = sqlRepository{}
|
||||
})
|
||||
|
||||
It("returns nil if filters is empty", func() {
|
||||
options.Filters = nil
|
||||
Expect(r.parseRestFilters(options)).To(BeNil())
|
||||
Expect(r.parseRestFilters(context.Background(), options)).To(BeNil())
|
||||
})
|
||||
|
||||
It("returns a '=' condition for 'id' filter", func() {
|
||||
options.Filters = map[string]interface{}{"id": "123"}
|
||||
Expect(r.parseRestFilters(options)).To(Equal(squirrel.And{squirrel.Eq{"id": "123"}}))
|
||||
Expect(r.parseRestFilters(context.Background(), options)).To(Equal(squirrel.And{squirrel.Eq{"id": "123"}}))
|
||||
})
|
||||
|
||||
It("returns a 'in' condition for multiples 'id' filters", func() {
|
||||
options.Filters = map[string]interface{}{"id": []string{"123", "456"}}
|
||||
Expect(r.parseRestFilters(options)).To(Equal(squirrel.And{squirrel.Eq{"id": []string{"123", "456"}}}))
|
||||
Expect(r.parseRestFilters(context.Background(), options)).To(Equal(squirrel.And{squirrel.Eq{"id": []string{"123", "456"}}}))
|
||||
})
|
||||
|
||||
It("returns a 'like' condition for other filters", func() {
|
||||
options.Filters = map[string]interface{}{"name": "joe"}
|
||||
Expect(r.parseRestFilters(options)).To(Equal(squirrel.And{squirrel.Like{"name": "joe%"}}))
|
||||
Expect(r.parseRestFilters(context.Background(), options)).To(Equal(squirrel.And{squirrel.Like{"name": "joe%"}}))
|
||||
})
|
||||
|
||||
It("uses the custom filter", func() {
|
||||
|
@ -43,7 +45,7 @@ var _ = Describe("sqlRestful", func() {
|
|||
},
|
||||
}
|
||||
options.Filters = map[string]interface{}{"test": 100}
|
||||
Expect(r.parseRestFilters(options)).To(Equal(squirrel.And{squirrel.Gt{"test": 100}}))
|
||||
Expect(r.parseRestFilters(context.Background(), options)).To(Equal(squirrel.And{squirrel.Gt{"test": 100}}))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
@ -12,14 +12,13 @@ import (
|
|||
|
||||
type transcodingRepository struct {
|
||||
sqlRepository
|
||||
sqlRestful
|
||||
}
|
||||
|
||||
func NewTranscodingRepository(ctx context.Context, db dbx.Builder) model.TranscodingRepository {
|
||||
r := &transcodingRepository{}
|
||||
r.ctx = ctx
|
||||
r.db = db
|
||||
r.tableName = "transcoding"
|
||||
r.registerModel(&model.Transcoding{}, nil)
|
||||
return r
|
||||
}
|
||||
|
||||
|
@ -47,7 +46,7 @@ func (r *transcodingRepository) Put(t *model.Transcoding) error {
|
|||
}
|
||||
|
||||
func (r *transcodingRepository) Count(options ...rest.QueryOptions) (int64, error) {
|
||||
return r.count(Select(), r.parseRestOptions(options...))
|
||||
return r.count(Select(), r.parseRestOptions(r.ctx, options...))
|
||||
}
|
||||
|
||||
func (r *transcodingRepository) Read(id string) (interface{}, error) {
|
||||
|
@ -55,7 +54,7 @@ func (r *transcodingRepository) Read(id string) (interface{}, error) {
|
|||
}
|
||||
|
||||
func (r *transcodingRepository) ReadAll(options ...rest.QueryOptions) (interface{}, error) {
|
||||
sel := r.newSelect(r.parseRestOptions(options...)).Columns("*")
|
||||
sel := r.newSelect(r.parseRestOptions(r.ctx, options...)).Columns("*")
|
||||
res := model.Transcodings{}
|
||||
err := r.queryAll(sel, &res)
|
||||
return res, err
|
||||
|
|
|
@ -22,7 +22,6 @@ import (
|
|||
|
||||
type userRepository struct {
|
||||
sqlRepository
|
||||
sqlRestful
|
||||
}
|
||||
|
||||
var (
|
||||
|
@ -34,7 +33,9 @@ func NewUserRepository(ctx context.Context, db dbx.Builder) model.UserRepository
|
|||
r := &userRepository{}
|
||||
r.ctx = ctx
|
||||
r.db = db
|
||||
r.tableName = "user"
|
||||
r.registerModel(&model.User{}, map[string]filterFunc{
|
||||
"password": invalidFilter(ctx),
|
||||
})
|
||||
once.Do(func() {
|
||||
_ = r.initPasswordEncryptionKey()
|
||||
})
|
||||
|
@ -91,7 +92,7 @@ func (r *userRepository) FindFirstAdmin() (*model.User, error) {
|
|||
}
|
||||
|
||||
func (r *userRepository) FindByUsername(username string) (*model.User, error) {
|
||||
sel := r.newSelect().Columns("*").Where(Like{"user_name": username})
|
||||
sel := r.newSelect().Columns("*").Where(Expr("user_name = ? COLLATE NOCASE", username))
|
||||
var usr model.User
|
||||
err := r.queryOne(sel, &usr)
|
||||
return &usr, err
|
||||
|
@ -123,10 +124,10 @@ func (r *userRepository) Count(options ...rest.QueryOptions) (int64, error) {
|
|||
if !usr.IsAdmin {
|
||||
return 0, rest.ErrPermissionDenied
|
||||
}
|
||||
return r.CountAll(r.parseRestOptions(options...))
|
||||
return r.CountAll(r.parseRestOptions(r.ctx, options...))
|
||||
}
|
||||
|
||||
func (r *userRepository) Read(id string) (interface{}, error) {
|
||||
func (r *userRepository) Read(id string) (any, error) {
|
||||
usr := loggedUser(r.ctx)
|
||||
if !usr.IsAdmin && usr.ID != id {
|
||||
return nil, rest.ErrPermissionDenied
|
||||
|
@ -138,23 +139,23 @@ func (r *userRepository) Read(id string) (interface{}, error) {
|
|||
return usr, err
|
||||
}
|
||||
|
||||
func (r *userRepository) ReadAll(options ...rest.QueryOptions) (interface{}, error) {
|
||||
func (r *userRepository) ReadAll(options ...rest.QueryOptions) (any, error) {
|
||||
usr := loggedUser(r.ctx)
|
||||
if !usr.IsAdmin {
|
||||
return nil, rest.ErrPermissionDenied
|
||||
}
|
||||
return r.GetAll(r.parseRestOptions(options...))
|
||||
return r.GetAll(r.parseRestOptions(r.ctx, options...))
|
||||
}
|
||||
|
||||
func (r *userRepository) EntityName() string {
|
||||
return "user"
|
||||
}
|
||||
|
||||
func (r *userRepository) NewInstance() interface{} {
|
||||
func (r *userRepository) NewInstance() any {
|
||||
return &model.User{}
|
||||
}
|
||||
|
||||
func (r *userRepository) Save(entity interface{}) (string, error) {
|
||||
func (r *userRepository) Save(entity any) (string, error) {
|
||||
usr := loggedUser(r.ctx)
|
||||
if !usr.IsAdmin {
|
||||
return "", rest.ErrPermissionDenied
|
||||
|
@ -170,7 +171,7 @@ func (r *userRepository) Save(entity interface{}) (string, error) {
|
|||
return u.ID, err
|
||||
}
|
||||
|
||||
func (r *userRepository) Update(id string, entity interface{}, cols ...string) error {
|
||||
func (r *userRepository) Update(id string, entity any, _ ...string) error {
|
||||
u := entity.(*model.User)
|
||||
u.ID = id
|
||||
usr := loggedUser(r.ctx)
|
||||
|
|
|
@ -97,12 +97,7 @@ const AlbumSongs = (props) => {
|
|||
const toggleableFields = useMemo(() => {
|
||||
return {
|
||||
trackNumber: isDesktop && (
|
||||
<TextField
|
||||
source="trackNumber"
|
||||
sortBy="releaseDate asc, discNumber asc, trackNumber asc"
|
||||
label="#"
|
||||
sortable={false}
|
||||
/>
|
||||
<TextField source="trackNumber" label="#" sortable={false} />
|
||||
),
|
||||
title: (
|
||||
<SongTitleField
|
||||
|
|
|
@ -150,8 +150,7 @@ const AlbumTableView = ({
|
|||
<TextField source="name" />
|
||||
{columns}
|
||||
<AlbumContextMenu
|
||||
source={'starred'}
|
||||
sortBy={'starred ASC, starredAt ASC'}
|
||||
source={'starred_at'}
|
||||
sortByOrder={'DESC'}
|
||||
sortable={config.enableFavourites}
|
||||
className={classes.contextMenu}
|
||||
|
|
|
@ -145,8 +145,7 @@ const ArtistListView = ({ hasShow, hasEdit, hasList, width, ...rest }) => {
|
|||
<TextField source="name" />
|
||||
{columns}
|
||||
<ArtistContextMenu
|
||||
source={'starred'}
|
||||
sortBy={'starred ASC, starredAt ASC'}
|
||||
source={'starred_at'}
|
||||
sortByOrder={'DESC'}
|
||||
sortable={config.enableFavourites}
|
||||
className={classes.contextMenu}
|
||||
|
|
|
@ -203,7 +203,7 @@ export const AlbumContextMenu = (props) =>
|
|||
resource={'album'}
|
||||
songQueryParams={{
|
||||
pagination: { page: 1, perPage: -1 },
|
||||
sort: { field: 'releaseDate, discNumber, trackNumber', order: 'ASC' },
|
||||
sort: { field: 'trackNumber', order: 'ASC' },
|
||||
filter: {
|
||||
album_id: props.record.id,
|
||||
release_date: props.releaseDate,
|
||||
|
@ -234,7 +234,7 @@ export const ArtistContextMenu = (props) =>
|
|||
songQueryParams={{
|
||||
pagination: { page: 1, perPage: 200 },
|
||||
sort: {
|
||||
field: 'album, releaseDate, discNumber, trackNumber',
|
||||
field: 'trackNumber',
|
||||
order: 'ASC',
|
||||
},
|
||||
filter: { album_artist_id: props.record.id },
|
||||
|
|
|
@ -21,7 +21,7 @@ export const PlayButton = ({ record, size, className }) => {
|
|||
dataProvider
|
||||
.getList('song', {
|
||||
pagination: { page: 1, perPage: -1 },
|
||||
sort: { field: 'releaseDate, discNumber, trackNumber', order: 'ASC' },
|
||||
sort: { field: 'trackNumber', order: 'ASC' },
|
||||
filter: {
|
||||
album_id: record.id,
|
||||
release_date: record.releaseDate,
|
||||
|
|
|
@ -98,15 +98,7 @@ const SongList = (props) => {
|
|||
|
||||
const toggleableFields = React.useMemo(() => {
|
||||
return {
|
||||
album: isDesktop && (
|
||||
<AlbumLinkField
|
||||
source="album"
|
||||
sortBy={
|
||||
'album, order_album_artist_name, release_date, disc_number, track_number, title'
|
||||
}
|
||||
sortByOrder={'ASC'}
|
||||
/>
|
||||
),
|
||||
album: isDesktop && <AlbumLinkField source="album" sortByOrder={'ASC'} />,
|
||||
artist: <ArtistLinkField source="artist" />,
|
||||
albumArtist: <ArtistLinkField source="albumArtist" />,
|
||||
trackNumber: isDesktop && <NumberField source="trackNumber" />,
|
||||
|
@ -179,8 +171,7 @@ const SongList = (props) => {
|
|||
<SongTitleField source="title" showTrackNumbers={false} />
|
||||
{columns}
|
||||
<SongContextMenu
|
||||
source={'starred'}
|
||||
sortBy={'starred ASC, starredAt ASC'}
|
||||
source={'starred_at'}
|
||||
sortByOrder={'DESC'}
|
||||
sortable={config.enableFavourites}
|
||||
className={classes.contextMenu}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue