feat(server): add EnableCoverArtUpload config option

Allow administrators to disable playlist cover art upload/removal for
non-admin users via the new EnableCoverArtUpload config option (default: true).

- Guard uploadPlaylistImage and deletePlaylistImage endpoints (403 for non-admin when disabled)
- Set CoverArtRole in Subsonic GetUser/GetUsers responses based on config and admin status
- Pass config to frontend and conditionally hide upload/remove UI controls
- Admins always retain upload capability regardless of setting
This commit is contained in:
Deluan 2026-03-02 16:59:05 -05:00
parent 6fd044fb09
commit 435fb0b076
8 changed files with 131 additions and 33 deletions

View file

@ -17,9 +17,11 @@ import (
"github.com/deluan/rest"
"github.com/go-chi/chi/v5"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/core/playlists"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/utils/req"
_ "golang.org/x/image/webp"
)
@ -237,6 +239,11 @@ const maxImageSize = 10 << 20 // 10MB
func uploadPlaylistImage(pls playlists.Playlists) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
user, _ := request.UserFrom(ctx)
if !conf.Server.EnableCoverArtUpload && !user.IsAdmin {
http.Error(w, "cover art upload is disabled", http.StatusForbidden)
return
}
p := req.Params(r)
playlistId, _ := p.String(":id")
@ -306,6 +313,11 @@ func uploadPlaylistImage(pls playlists.Playlists) http.HandlerFunc {
func deletePlaylistImage(pls playlists.Playlists) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
user, _ := request.UserFrom(ctx)
if !conf.Server.EnableCoverArtUpload && !user.IsAdmin {
http.Error(w, "cover art upload is disabled", http.StatusForbidden)
return
}
p := req.Params(r)
playlistId, _ := p.String(":id")

View file

@ -3,6 +3,7 @@ package nativeapi
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"time"
@ -14,50 +15,56 @@ import (
"github.com/navidrome/navidrome/core/auth"
"github.com/navidrome/navidrome/core/playlists"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/server"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
type mockPlaylistTrackRepo struct {
model.PlaylistTrackRepository
tracks model.PlaylistTracks
}
var _ = Describe("Playlist Image Endpoints", func() {
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
})
func (m *mockPlaylistTrackRepo) Count(...rest.QueryOptions) (int64, error) {
return int64(len(m.tracks)), nil
}
DescribeTable("uploadPlaylistImage guard",
func(enableCoverArtUpload, isAdmin bool, expectedStatus int) {
conf.Server.EnableCoverArtUpload = enableCoverArtUpload
handler := uploadPlaylistImage(&mockPlaylistsService{})
func (m *mockPlaylistTrackRepo) ReadAll(...rest.QueryOptions) (any, error) {
return m.tracks, nil
}
req := httptest.NewRequest("POST", "/playlist/pls-1/image", nil)
ctx := request.WithUser(GinkgoT().Context(), model.User{ID: "user-1", IsAdmin: isAdmin})
req = req.WithContext(ctx)
func (m *mockPlaylistTrackRepo) EntityName() string {
return "playlist_track"
}
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
Expect(w.Code).To(Equal(expectedStatus))
},
Entry("enabled, regular user passes guard", true, false, http.StatusBadRequest),
Entry("enabled, admin passes guard", true, true, http.StatusBadRequest),
Entry("disabled, admin passes guard", false, true, http.StatusBadRequest),
Entry("disabled, regular user is forbidden", false, false, http.StatusForbidden),
)
func (m *mockPlaylistTrackRepo) NewInstance() any {
return &model.PlaylistTrack{}
}
DescribeTable("deletePlaylistImage guard",
func(enableCoverArtUpload, isAdmin bool, expectedStatus int) {
conf.Server.EnableCoverArtUpload = enableCoverArtUpload
handler := deletePlaylistImage(&mockPlaylistsService{})
func (m *mockPlaylistTrackRepo) Read(id string) (any, error) {
for _, t := range m.tracks {
if t.ID == id {
return &t, nil
}
}
return nil, rest.ErrNotFound
}
req := httptest.NewRequest("DELETE", "/playlist/pls-1/image", nil)
ctx := request.WithUser(GinkgoT().Context(), model.User{ID: "user-1", IsAdmin: isAdmin})
req = req.WithContext(ctx)
type mockPlaylistsService struct {
playlists.Playlists
tracksRepo rest.Repository
}
func (m *mockPlaylistsService) TracksRepository(_ context.Context, _ string, _ bool) rest.Repository {
return m.tracksRepo
}
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
Expect(w.Code).To(Equal(expectedStatus))
},
Entry("enabled, regular user passes guard", true, false, http.StatusNotFound),
Entry("enabled, admin passes guard", true, true, http.StatusNotFound),
Entry("disabled, admin passes guard", false, true, http.StatusNotFound),
Entry("disabled, regular user is forbidden", false, false, http.StatusForbidden),
)
})
var _ = Describe("Playlist Tracks Endpoint", func() {
var (
@ -174,3 +181,58 @@ var _ = Describe("Playlist Tracks Endpoint", func() {
})
})
})
type mockPlaylistTrackRepo struct {
model.PlaylistTrackRepository
tracks model.PlaylistTracks
}
func (m *mockPlaylistTrackRepo) Count(...rest.QueryOptions) (int64, error) {
return int64(len(m.tracks)), nil
}
func (m *mockPlaylistTrackRepo) ReadAll(...rest.QueryOptions) (any, error) {
return m.tracks, nil
}
func (m *mockPlaylistTrackRepo) EntityName() string {
return "playlist_track"
}
func (m *mockPlaylistTrackRepo) NewInstance() any {
return &model.PlaylistTrack{}
}
func (m *mockPlaylistTrackRepo) Read(id string) (any, error) {
for _, t := range m.tracks {
if t.ID == id {
return &t, nil
}
}
return nil, rest.ErrNotFound
}
type mockPlaylistsService struct {
playlists.Playlists
tracksRepo rest.Repository
removeImageFn func(ctx context.Context, id string) error
setImageFn func(ctx context.Context, id string, reader io.Reader, ext string) error
}
func (m *mockPlaylistsService) RemoveImage(ctx context.Context, id string) error {
if m.removeImageFn != nil {
return m.removeImageFn(ctx, id)
}
return model.ErrNotFound
}
func (m *mockPlaylistsService) SetImage(ctx context.Context, id string, reader io.Reader, ext string) error {
if m.setImageFn != nil {
return m.setImageFn(ctx, id, reader, ext)
}
return model.ErrNotFound
}
func (m *mockPlaylistsService) TracksRepository(_ context.Context, _ string, _ bool) rest.Repository {
return m.tracksRepo
}

View file

@ -61,6 +61,7 @@ func serveIndex(ds model.DataStore, fs fs.FS, shareInfo *model.Share) http.Handl
"losslessFormats": strings.ToUpper(strings.Join(mime.LosslessFormats, ",")),
"devActivityPanel": conf.Server.DevActivityPanel,
"enableUserEditing": conf.Server.EnableUserEditing,
"enableCoverArtUpload": conf.Server.EnableCoverArtUpload,
"enableSharing": conf.Server.EnableSharing,
"shareURL": conf.Server.ShareURL,
"defaultDownloadableShare": conf.Server.DefaultDownloadableShare,

View file

@ -22,6 +22,7 @@ func buildUserResponse(user model.User) responses.User {
ScrobblingEnabled: true,
DownloadRole: conf.Server.EnableDownloads,
ShareRole: conf.Server.EnableSharing,
CoverArtRole: conf.Server.EnableCoverArtUpload || user.IsAdmin,
Folder: slice.Map(user.Libraries, func(lib model.Library) int32 { return int32(lib.ID) }),
}

View file

@ -63,6 +63,7 @@ var _ = Describe("Users", func() {
Expect(userResponse.User.ScrobblingEnabled).To(BeTrue())
Expect(userResponse.User.DownloadRole).To(BeTrue())
Expect(userResponse.User.ShareRole).To(BeTrue())
Expect(userResponse.User.CoverArtRole).To(BeTrue())
Expect(userResponse.User.Folder).To(ContainElements(int32(10), int32(20)))
// Verify GetUsers response structure
@ -81,6 +82,7 @@ var _ = Describe("Users", func() {
Expect(singleUser.ScrobblingEnabled).To(Equal(userFromList.ScrobblingEnabled))
Expect(singleUser.DownloadRole).To(Equal(userFromList.DownloadRole))
Expect(singleUser.ShareRole).To(Equal(userFromList.ShareRole))
Expect(singleUser.CoverArtRole).To(Equal(userFromList.CoverArtRole))
Expect(singleUser.JukeboxRole).To(Equal(userFromList.JukeboxRole))
Expect(singleUser.Folder).To(Equal(userFromList.Folder))
})
@ -102,6 +104,20 @@ var _ = Describe("Users", func() {
Entry("jukebox enabled, admin-only, admin user", true, true, true, true),
)
DescribeTable("CoverArt role permissions",
func(enableCoverArtUpload, isAdmin, expectedCoverArtRole bool) {
conf.Server.EnableCoverArtUpload = enableCoverArtUpload
testUser.IsAdmin = isAdmin
response := buildUserResponse(testUser)
Expect(response.CoverArtRole).To(Equal(expectedCoverArtRole))
},
Entry("enabled, regular user", true, false, true),
Entry("enabled, admin user", true, true, true),
Entry("disabled, regular user", false, false, false),
Entry("disabled, admin user", false, true, true),
)
Describe("Folder list population", func() {
It("should populate Folder field with user's accessible library IDs", func() {
testUser.Libraries = model.Libraries{