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:
@@ -76,6 +76,7 @@ type configOptions struct {
|
|||||||
EnableFavourites bool
|
EnableFavourites bool
|
||||||
EnableStarRating bool
|
EnableStarRating bool
|
||||||
EnableUserEditing bool
|
EnableUserEditing bool
|
||||||
|
EnableCoverArtUpload bool
|
||||||
EnableSharing bool
|
EnableSharing bool
|
||||||
ShareURL string
|
ShareURL string
|
||||||
DefaultShareExpiration time.Duration
|
DefaultShareExpiration time.Duration
|
||||||
@@ -668,6 +669,7 @@ func setViperDefaults() {
|
|||||||
viper.SetDefault("enablereplaygain", true)
|
viper.SetDefault("enablereplaygain", true)
|
||||||
viper.SetDefault("enablecoveranimation", true)
|
viper.SetDefault("enablecoveranimation", true)
|
||||||
viper.SetDefault("enablenowplaying", true)
|
viper.SetDefault("enablenowplaying", true)
|
||||||
|
viper.SetDefault("enablecoverartupload", true)
|
||||||
viper.SetDefault("enablesharing", false)
|
viper.SetDefault("enablesharing", false)
|
||||||
viper.SetDefault("shareurl", "")
|
viper.SetDefault("shareurl", "")
|
||||||
viper.SetDefault("defaultshareexpiration", 8760*time.Hour)
|
viper.SetDefault("defaultshareexpiration", 8760*time.Hour)
|
||||||
|
|||||||
@@ -17,9 +17,11 @@ import (
|
|||||||
|
|
||||||
"github.com/deluan/rest"
|
"github.com/deluan/rest"
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/navidrome/navidrome/conf"
|
||||||
"github.com/navidrome/navidrome/core/playlists"
|
"github.com/navidrome/navidrome/core/playlists"
|
||||||
"github.com/navidrome/navidrome/log"
|
"github.com/navidrome/navidrome/log"
|
||||||
"github.com/navidrome/navidrome/model"
|
"github.com/navidrome/navidrome/model"
|
||||||
|
"github.com/navidrome/navidrome/model/request"
|
||||||
"github.com/navidrome/navidrome/utils/req"
|
"github.com/navidrome/navidrome/utils/req"
|
||||||
_ "golang.org/x/image/webp"
|
_ "golang.org/x/image/webp"
|
||||||
)
|
)
|
||||||
@@ -237,6 +239,11 @@ const maxImageSize = 10 << 20 // 10MB
|
|||||||
func uploadPlaylistImage(pls playlists.Playlists) http.HandlerFunc {
|
func uploadPlaylistImage(pls playlists.Playlists) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
ctx := r.Context()
|
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)
|
p := req.Params(r)
|
||||||
playlistId, _ := p.String(":id")
|
playlistId, _ := p.String(":id")
|
||||||
|
|
||||||
@@ -306,6 +313,11 @@ func uploadPlaylistImage(pls playlists.Playlists) http.HandlerFunc {
|
|||||||
func deletePlaylistImage(pls playlists.Playlists) http.HandlerFunc {
|
func deletePlaylistImage(pls playlists.Playlists) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
ctx := r.Context()
|
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)
|
p := req.Params(r)
|
||||||
playlistId, _ := p.String(":id")
|
playlistId, _ := p.String(":id")
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package nativeapi
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"time"
|
"time"
|
||||||
@@ -14,50 +15,56 @@ import (
|
|||||||
"github.com/navidrome/navidrome/core/auth"
|
"github.com/navidrome/navidrome/core/auth"
|
||||||
"github.com/navidrome/navidrome/core/playlists"
|
"github.com/navidrome/navidrome/core/playlists"
|
||||||
"github.com/navidrome/navidrome/model"
|
"github.com/navidrome/navidrome/model"
|
||||||
|
"github.com/navidrome/navidrome/model/request"
|
||||||
"github.com/navidrome/navidrome/server"
|
"github.com/navidrome/navidrome/server"
|
||||||
"github.com/navidrome/navidrome/tests"
|
"github.com/navidrome/navidrome/tests"
|
||||||
. "github.com/onsi/ginkgo/v2"
|
. "github.com/onsi/ginkgo/v2"
|
||||||
. "github.com/onsi/gomega"
|
. "github.com/onsi/gomega"
|
||||||
)
|
)
|
||||||
|
|
||||||
type mockPlaylistTrackRepo struct {
|
var _ = Describe("Playlist Image Endpoints", func() {
|
||||||
model.PlaylistTrackRepository
|
BeforeEach(func() {
|
||||||
tracks model.PlaylistTracks
|
DeferCleanup(configtest.SetupConfig())
|
||||||
}
|
})
|
||||||
|
|
||||||
func (m *mockPlaylistTrackRepo) Count(...rest.QueryOptions) (int64, error) {
|
DescribeTable("uploadPlaylistImage guard",
|
||||||
return int64(len(m.tracks)), nil
|
func(enableCoverArtUpload, isAdmin bool, expectedStatus int) {
|
||||||
}
|
conf.Server.EnableCoverArtUpload = enableCoverArtUpload
|
||||||
|
handler := uploadPlaylistImage(&mockPlaylistsService{})
|
||||||
|
|
||||||
func (m *mockPlaylistTrackRepo) ReadAll(...rest.QueryOptions) (any, error) {
|
req := httptest.NewRequest("POST", "/playlist/pls-1/image", nil)
|
||||||
return m.tracks, nil
|
ctx := request.WithUser(GinkgoT().Context(), model.User{ID: "user-1", IsAdmin: isAdmin})
|
||||||
}
|
req = req.WithContext(ctx)
|
||||||
|
|
||||||
func (m *mockPlaylistTrackRepo) EntityName() string {
|
w := httptest.NewRecorder()
|
||||||
return "playlist_track"
|
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 {
|
DescribeTable("deletePlaylistImage guard",
|
||||||
return &model.PlaylistTrack{}
|
func(enableCoverArtUpload, isAdmin bool, expectedStatus int) {
|
||||||
}
|
conf.Server.EnableCoverArtUpload = enableCoverArtUpload
|
||||||
|
handler := deletePlaylistImage(&mockPlaylistsService{})
|
||||||
|
|
||||||
func (m *mockPlaylistTrackRepo) Read(id string) (any, error) {
|
req := httptest.NewRequest("DELETE", "/playlist/pls-1/image", nil)
|
||||||
for _, t := range m.tracks {
|
ctx := request.WithUser(GinkgoT().Context(), model.User{ID: "user-1", IsAdmin: isAdmin})
|
||||||
if t.ID == id {
|
req = req.WithContext(ctx)
|
||||||
return &t, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil, rest.ErrNotFound
|
|
||||||
}
|
|
||||||
|
|
||||||
type mockPlaylistsService struct {
|
w := httptest.NewRecorder()
|
||||||
playlists.Playlists
|
handler.ServeHTTP(w, req)
|
||||||
tracksRepo rest.Repository
|
Expect(w.Code).To(Equal(expectedStatus))
|
||||||
}
|
},
|
||||||
|
Entry("enabled, regular user passes guard", true, false, http.StatusNotFound),
|
||||||
func (m *mockPlaylistsService) TracksRepository(_ context.Context, _ string, _ bool) rest.Repository {
|
Entry("enabled, admin passes guard", true, true, http.StatusNotFound),
|
||||||
return m.tracksRepo
|
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 _ = Describe("Playlist Tracks Endpoint", func() {
|
||||||
var (
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ func serveIndex(ds model.DataStore, fs fs.FS, shareInfo *model.Share) http.Handl
|
|||||||
"losslessFormats": strings.ToUpper(strings.Join(mime.LosslessFormats, ",")),
|
"losslessFormats": strings.ToUpper(strings.Join(mime.LosslessFormats, ",")),
|
||||||
"devActivityPanel": conf.Server.DevActivityPanel,
|
"devActivityPanel": conf.Server.DevActivityPanel,
|
||||||
"enableUserEditing": conf.Server.EnableUserEditing,
|
"enableUserEditing": conf.Server.EnableUserEditing,
|
||||||
|
"enableCoverArtUpload": conf.Server.EnableCoverArtUpload,
|
||||||
"enableSharing": conf.Server.EnableSharing,
|
"enableSharing": conf.Server.EnableSharing,
|
||||||
"shareURL": conf.Server.ShareURL,
|
"shareURL": conf.Server.ShareURL,
|
||||||
"defaultDownloadableShare": conf.Server.DefaultDownloadableShare,
|
"defaultDownloadableShare": conf.Server.DefaultDownloadableShare,
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ func buildUserResponse(user model.User) responses.User {
|
|||||||
ScrobblingEnabled: true,
|
ScrobblingEnabled: true,
|
||||||
DownloadRole: conf.Server.EnableDownloads,
|
DownloadRole: conf.Server.EnableDownloads,
|
||||||
ShareRole: conf.Server.EnableSharing,
|
ShareRole: conf.Server.EnableSharing,
|
||||||
|
CoverArtRole: conf.Server.EnableCoverArtUpload || user.IsAdmin,
|
||||||
Folder: slice.Map(user.Libraries, func(lib model.Library) int32 { return int32(lib.ID) }),
|
Folder: slice.Map(user.Libraries, func(lib model.Library) int32 { return int32(lib.ID) }),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ var _ = Describe("Users", func() {
|
|||||||
Expect(userResponse.User.ScrobblingEnabled).To(BeTrue())
|
Expect(userResponse.User.ScrobblingEnabled).To(BeTrue())
|
||||||
Expect(userResponse.User.DownloadRole).To(BeTrue())
|
Expect(userResponse.User.DownloadRole).To(BeTrue())
|
||||||
Expect(userResponse.User.ShareRole).To(BeTrue())
|
Expect(userResponse.User.ShareRole).To(BeTrue())
|
||||||
|
Expect(userResponse.User.CoverArtRole).To(BeTrue())
|
||||||
Expect(userResponse.User.Folder).To(ContainElements(int32(10), int32(20)))
|
Expect(userResponse.User.Folder).To(ContainElements(int32(10), int32(20)))
|
||||||
|
|
||||||
// Verify GetUsers response structure
|
// Verify GetUsers response structure
|
||||||
@@ -81,6 +82,7 @@ var _ = Describe("Users", func() {
|
|||||||
Expect(singleUser.ScrobblingEnabled).To(Equal(userFromList.ScrobblingEnabled))
|
Expect(singleUser.ScrobblingEnabled).To(Equal(userFromList.ScrobblingEnabled))
|
||||||
Expect(singleUser.DownloadRole).To(Equal(userFromList.DownloadRole))
|
Expect(singleUser.DownloadRole).To(Equal(userFromList.DownloadRole))
|
||||||
Expect(singleUser.ShareRole).To(Equal(userFromList.ShareRole))
|
Expect(singleUser.ShareRole).To(Equal(userFromList.ShareRole))
|
||||||
|
Expect(singleUser.CoverArtRole).To(Equal(userFromList.CoverArtRole))
|
||||||
Expect(singleUser.JukeboxRole).To(Equal(userFromList.JukeboxRole))
|
Expect(singleUser.JukeboxRole).To(Equal(userFromList.JukeboxRole))
|
||||||
Expect(singleUser.Folder).To(Equal(userFromList.Folder))
|
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),
|
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() {
|
Describe("Folder list population", func() {
|
||||||
It("should populate Folder field with user's accessible library IDs", func() {
|
It("should populate Folder field with user's accessible library IDs", func() {
|
||||||
testUser.Libraries = model.Libraries{
|
testUser.Libraries = model.Libraries{
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ const defaultConfig = {
|
|||||||
defaultUIVolume: 100,
|
defaultUIVolume: 100,
|
||||||
uiSearchDebounceMs: 200,
|
uiSearchDebounceMs: 200,
|
||||||
enableUserEditing: true,
|
enableUserEditing: true,
|
||||||
|
enableCoverArtUpload: true,
|
||||||
enableSharing: true,
|
enableSharing: true,
|
||||||
shareURL: '',
|
shareURL: '',
|
||||||
defaultDownloadableShare: true,
|
defaultDownloadableShare: true,
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
SizeField,
|
SizeField,
|
||||||
isWritable,
|
isWritable,
|
||||||
} from '../common'
|
} from '../common'
|
||||||
|
import config from '../config'
|
||||||
import subsonic from '../subsonic'
|
import subsonic from '../subsonic'
|
||||||
import { REST_URL } from '../consts'
|
import { REST_URL } from '../consts'
|
||||||
import { httpClient } from '../dataProvider'
|
import { httpClient } from '../dataProvider'
|
||||||
@@ -134,7 +135,9 @@ const PlaylistDetails = (props) => {
|
|||||||
|
|
||||||
const imageUrl = subsonic.getCoverArtUrl(record, 300, true)
|
const imageUrl = subsonic.getCoverArtUrl(record, 300, true)
|
||||||
const fullImageUrl = subsonic.getCoverArtUrl(record)
|
const fullImageUrl = subsonic.getCoverArtUrl(record)
|
||||||
const canEdit = isWritable(record.ownerId)
|
const canEdit =
|
||||||
|
isWritable(record.ownerId) &&
|
||||||
|
(config.enableCoverArtUpload || localStorage.getItem('role') === 'admin')
|
||||||
|
|
||||||
// Reset image state when playlist changes
|
// Reset image state when playlist changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
Reference in New Issue
Block a user