diff --git a/core/playlists/rest_adapter.go b/core/playlists/rest_adapter.go index c9b7c4ea6..3f886aadd 100644 --- a/core/playlists/rest_adapter.go +++ b/core/playlists/rest_adapter.go @@ -4,11 +4,13 @@ import ( "context" "errors" "reflect" + "strings" "github.com/deluan/rest" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/criteria" "github.com/navidrome/navidrome/model/request" + "github.com/navidrome/navidrome/utils/slice" ) // --- REST adapter (follows Share/Library pattern) --- @@ -34,8 +36,8 @@ func (r *playlistRepositoryWrapper) Save(entity any) (string, error) { return r.service.savePlaylist(r.ctx, entity.(*model.Playlist)) } -func (r *playlistRepositoryWrapper) Update(id string, entity any, _ ...string) error { - return r.service.updatePlaylistEntity(r.ctx, id, entity.(*model.Playlist)) +func (r *playlistRepositoryWrapper) Update(id string, entity any, cols ...string) error { + return r.service.updatePlaylistEntity(r.ctx, id, entity.(*model.Playlist), cols...) } func (r *playlistRepositoryWrapper) Delete(id string) error { @@ -79,7 +81,15 @@ func (s *playlists) savePlaylist(ctx context.Context, pls *model.Playlist) (stri // updatePlaylistEntity updates playlist metadata with permission checks. // Used by the REST API wrapper. -func (s *playlists) updatePlaylistEntity(ctx context.Context, id string, entity *model.Playlist) error { +// +// cols names the fields the client actually sent in the JSON body (extracted by +// rest.Put). When non-empty, fields outside cols are not considered changed and +// are left untouched — this prevents partial requests like bulk "Make Public" +// (body: {"public": true}) from wiping fields that just happen to be zero in +// the deserialized entity (see issue #5541). An empty cols means "treat the +// entity as a complete record" — preserved for callers that don't use the REST +// wrapper. +func (s *playlists) updatePlaylistEntity(ctx context.Context, id string, entity *model.Playlist, cols ...string) error { current, err := s.checkWritable(ctx, id) if err != nil { switch { @@ -91,41 +101,92 @@ func (s *playlists) updatePlaylistEntity(ctx context.Context, id string, entity return err } } + + sent := sentFields(cols) + usr, _ := request.UserFrom(ctx) - if !usr.IsAdmin && entity.OwnerID != "" && entity.OwnerID != current.OwnerID { + ownerChanged := sent("ownerId") && entity.OwnerID != "" && entity.OwnerID != current.OwnerID + if !usr.IsAdmin && ownerChanged { return rest.ErrPermissionDenied } - contentChanged := entity.Name != current.Name || - entity.Comment != current.Comment || - (entity.OwnerID != "" && entity.OwnerID != current.OwnerID) || - !rulesEqual(current.Rules, entity.Rules) + nameChanged := sent("name") && entity.Name != current.Name + commentChanged := sent("comment") && entity.Comment != current.Comment + rulesChanged := sent("rules") && !rulesEqual(current.Rules, entity.Rules) - if contentChanged { - if entity.OwnerID != "" { - current.OwnerID = entity.OwnerID - } + if nameChanged || commentChanged || ownerChanged || rulesChanged { + return s.applyContentUpdate(ctx, current, entity, sent, + nameChanged, commentChanged, ownerChanged, rulesChanged) + } + return s.applyFlagsOnly(ctx, current, entity, sent) +} + +// applyContentUpdate handles updates that change at least one of name/comment/ +// owner/rules. It goes through updateMetadata, which always bumps updatedAt +// (invalidating cached cover-art URLs). namePtr/commentPtr are nil when the +// field is absent from the request OR present-but-unchanged (so updateMetadata +// skips them); publicPtr is nil only when public is absent from the request +// (an idempotent public value is still forwarded). +func (s *playlists) applyContentUpdate(ctx context.Context, current, entity *model.Playlist, + sent func(string) bool, nameChanged, commentChanged, ownerChanged, rulesChanged bool, +) error { + if ownerChanged { + current.OwnerID = entity.OwnerID + } + if rulesChanged { current.Rules = entity.Rules - if current.Path != "" && current.Sync != entity.Sync { - current.Sync = entity.Sync - } - return s.updateMetadata(ctx, s.ds, current, &entity.Name, &entity.Comment, &entity.Public) } - - // Only sync/public changed — skip updatedAt so cover art URLs stay stable - var cols []string - if current.Path != "" && current.Sync != entity.Sync { + if sent("sync") && current.Path != "" && current.Sync != entity.Sync { current.Sync = entity.Sync - cols = append(cols, "sync") } - if current.Public != entity.Public { + var namePtr, commentPtr *string + var publicPtr *bool + if nameChanged { + namePtr = &entity.Name + } + if commentChanged { + commentPtr = &entity.Comment + } + if sent("public") { + publicPtr = &entity.Public + } + return s.updateMetadata(ctx, s.ds, current, namePtr, commentPtr, publicPtr) +} + +// applyFlagsOnly handles updates that only toggle sync/public — skips +// updatedAt so cover art URLs stay stable. +func (s *playlists) applyFlagsOnly(ctx context.Context, current, entity *model.Playlist, + sent func(string) bool, +) error { + var updateCols []string + if sent("sync") && current.Path != "" && current.Sync != entity.Sync { + current.Sync = entity.Sync + updateCols = append(updateCols, "sync") + } + if sent("public") && current.Public != entity.Public { current.Public = entity.Public - cols = append(cols, "public") + updateCols = append(updateCols, "public") } - if len(cols) == 0 { + if len(updateCols) == 0 { return nil } - return s.ds.Playlist(ctx).Put(current, cols...) + return s.ds.Playlist(ctx).Put(current, updateCols...) +} + +// sentFields returns a predicate that reports whether a JSON field was present +// in the request body. Matching is case-insensitive to mirror Go's json +// decoder, which populates struct fields from case-variant keys like +// {"Name":"x"} or {"OWNERID":"y"}. An empty cols list means "treat the entity +// as a full record" — every field is considered sent. +func sentFields(cols []string) func(string) bool { + if len(cols) == 0 { + return func(string) bool { return true } + } + set := slice.ToMap(cols, func(c string) (string, struct{}) { return strings.ToLower(c), struct{}{} }) + return func(field string) bool { + _, ok := set[strings.ToLower(field)] + return ok + } } func rulesEqual(a, b *criteria.Criteria) bool { diff --git a/core/playlists/rest_adapter_test.go b/core/playlists/rest_adapter_test.go index 68461b259..79d72d147 100644 --- a/core/playlists/rest_adapter_test.go +++ b/core/playlists/rest_adapter_test.go @@ -125,6 +125,25 @@ var _ = Describe("REST Adapter", func() { Expect(err).To(Equal(rest.ErrPermissionDenied)) }) + DescribeTable("denies regular user from changing ownership under any case-variant JSON key", + func(colName string) { + // rest.Put's field-name extraction is case-sensitive, but Go's + // json decoder is case-insensitive on struct fields, so any + // {"OwnerId":"x"} / {"OWNERID":"x"} / {"ownerid":"x"} populates + // entity.OwnerID. sentFields normalizes both sides so the + // permission gate fires regardless of casing. + ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false}) + repo = ps.NewRepository(ctx).(rest.Persistable) + pls := &model.Playlist{OwnerID: "other-user"} + err := repo.Update("pls-1", pls, colName) + Expect(err).To(Equal(rest.ErrPermissionDenied)) + }, + Entry("canonical camelCase", "ownerId"), + Entry("PascalCase", "OwnerId"), + Entry("all upper", "OWNERID"), + Entry("all lower", "ownerid"), + ) + It("updates smart playlist rules", func() { mockPlsRepo.Data["smart-1"] = &model.Playlist{ ID: "smart-1", @@ -218,6 +237,156 @@ var _ = Describe("REST Adapter", func() { err := repo.Update("nonexistent", pls) Expect(err).To(Equal(rest.ErrNotFound)) }) + + // Regression tests for #5541: partial REST updates (e.g. bulk "Make Public") + // must only touch the fields the client actually sent. The cols list from + // rest.Put names those fields; fields outside it must be left alone, even + // when the deserialized entity has zero values for them. + Context("with partial updates (cols)", func() { + BeforeEach(func() { + ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false}) + mockPlsRepo.Data["partial"] = &model.Playlist{ + ID: "partial", + Name: "Original Name", + Comment: "Original comment", + OwnerID: "user-1", + Public: false, + } + }) + + It("preserves name and comment when only public is sent (bulk Make Public)", func() { + repo = ps.NewRepository(ctx).(rest.Persistable) + err := repo.Update("partial", &model.Playlist{Public: true}, "public") + Expect(err).ToNot(HaveOccurred()) + Expect(mockPlsRepo.Last.Name).To(Equal("Original Name")) + Expect(mockPlsRepo.Last.Comment).To(Equal("Original comment")) + Expect(mockPlsRepo.Last.Public).To(BeTrue()) + }) + + It("preserves name when only sync is sent for a file-backed playlist", func() { + mockPlsRepo.Data["file-partial"] = &model.Playlist{ + ID: "file-partial", + Name: "Keep Me", + OwnerID: "user-1", + Path: "/music/p.m3u", + Sync: true, + } + repo = ps.NewRepository(ctx).(rest.Persistable) + err := repo.Update("file-partial", &model.Playlist{Sync: false}, "sync") + Expect(err).ToNot(HaveOccurred()) + Expect(mockPlsRepo.Last.Name).To(Equal("Keep Me")) + Expect(mockPlsRepo.Last.Sync).To(BeFalse()) + }) + + It("renames the playlist when only name is sent", func() { + repo = ps.NewRepository(ctx).(rest.Persistable) + err := repo.Update("partial", &model.Playlist{Name: "Renamed"}, "name") + Expect(err).ToNot(HaveOccurred()) + Expect(mockPlsRepo.Last.Name).To(Equal("Renamed")) + Expect(mockPlsRepo.Last.Comment).To(Equal("Original comment")) + Expect(mockPlsRepo.Last.Public).To(BeFalse()) + }) + + It("clears the comment when an empty comment is sent explicitly", func() { + repo = ps.NewRepository(ctx).(rest.Persistable) + err := repo.Update("partial", &model.Playlist{Comment: ""}, "comment") + Expect(err).ToNot(HaveOccurred()) + Expect(mockPlsRepo.Last.Comment).To(BeEmpty()) + Expect(mockPlsRepo.Last.Name).To(Equal("Original Name")) + }) + + It("updates rules-only on a smart playlist (Feishin-style edit)", func() { + mockPlsRepo.Data["smart-partial"] = &model.Playlist{ + ID: "smart-partial", + Name: "Smart Original", + Comment: "smart comment", + OwnerID: "user-1", + Public: true, + Rules: &criteria.Criteria{Expression: criteria.Is{"genre": "Rock"}}, + } + repo = ps.NewRepository(ctx).(rest.Persistable) + newRules := &criteria.Criteria{Expression: criteria.Is{"genre": "Jazz"}, Sort: "year DESC"} + err := repo.Update("smart-partial", &model.Playlist{Rules: newRules}, "rules") + Expect(err).ToNot(HaveOccurred()) + Expect(mockPlsRepo.Last.Rules).To(Equal(newRules)) + Expect(mockPlsRepo.Last.Name).To(Equal("Smart Original")) + Expect(mockPlsRepo.Last.Comment).To(Equal("smart comment")) + Expect(mockPlsRepo.Last.Public).To(BeTrue()) + }) + + It("updates name and rules together (smart-playlist Edit form)", func() { + mockPlsRepo.Data["smart-edit"] = &model.Playlist{ + ID: "smart-edit", + Name: "Smart Original", + Comment: "smart comment", + OwnerID: "user-1", + Rules: &criteria.Criteria{Expression: criteria.Is{"genre": "Rock"}}, + } + repo = ps.NewRepository(ctx).(rest.Persistable) + newRules := &criteria.Criteria{Expression: criteria.Is{"artist": "Miles Davis"}, Sort: "album"} + err := repo.Update("smart-edit", + &model.Playlist{Name: "Smart Renamed", Rules: newRules}, + "name", "rules") + Expect(err).ToNot(HaveOccurred()) + Expect(mockPlsRepo.Last.Name).To(Equal("Smart Renamed")) + Expect(mockPlsRepo.Last.Rules).To(Equal(newRules)) + Expect(mockPlsRepo.Last.Comment).To(Equal("smart comment")) + }) + + It("does not bump the saved rules on an idempotent rules-only PUT", func() { + rules := &criteria.Criteria{Expression: criteria.Is{"genre": "Rock"}} + mockPlsRepo.Data["smart-idempotent"] = &model.Playlist{ + ID: "smart-idempotent", + Name: "Smart Idempotent", + OwnerID: "user-1", + Rules: rules, + } + repo = ps.NewRepository(ctx).(rest.Persistable) + // Same rules sent back — rulesEqual should report no change and + // the request should no-op (no Put call). + sameRules := &criteria.Criteria{Expression: criteria.Is{"genre": "Rock"}} + err := repo.Update("smart-idempotent", &model.Playlist{Rules: sameRules}, "rules") + Expect(err).ToNot(HaveOccurred()) + Expect(mockPlsRepo.Last).To(BeNil()) // no Put happened + }) + + It("preserves rules when only public is sent (smart playlist + bulk Make Public)", func() { + rules := &criteria.Criteria{Expression: criteria.Is{"genre": "Rock"}} + mockPlsRepo.Data["smart-public"] = &model.Playlist{ + ID: "smart-public", + Name: "Smart Public", + OwnerID: "user-1", + Public: false, + Rules: rules, + } + repo = ps.NewRepository(ctx).(rest.Persistable) + err := repo.Update("smart-public", &model.Playlist{Public: true}, "public") + Expect(err).ToNot(HaveOccurred()) + Expect(mockPlsRepo.Last.Public).To(BeTrue()) + Expect(mockPlsRepo.Last.Rules).To(Equal(rules)) + Expect(mockPlsRepo.Last.Name).To(Equal("Smart Public")) + }) + + It("does not treat a missing ownerId as an ownership transfer attempt", func() { + // A non-admin user sending only {public:true} should not be blocked + // just because OwnerID is the zero value in the deserialized entity. + repo = ps.NewRepository(ctx).(rest.Persistable) + err := repo.Update("partial", &model.Playlist{Public: true}, "public") + Expect(err).ToNot(HaveOccurred()) + }) + + It("matches cols case-insensitively (mirrors json decoder behavior)", func() { + // Go's json decoder populates struct fields from case-variant keys + // like {"Name":"x"}, but rest.Put's field-name extraction is + // case-sensitive. sentFields normalizes both sides so a request + // with {"Name":"Renamed"} is honored, not silently ignored. + repo = ps.NewRepository(ctx).(rest.Persistable) + err := repo.Update("partial", &model.Playlist{Name: "Renamed"}, "Name") + Expect(err).ToNot(HaveOccurred()) + Expect(mockPlsRepo.Last.Name).To(Equal("Renamed")) + Expect(mockPlsRepo.Last.Comment).To(Equal("Original comment")) + }) + }) }) Describe("Delete", func() {