diff --git a/server/nativeapi/image_upload.go b/server/nativeapi/image_upload.go index c4ff62e42..95d15b1e2 100644 --- a/server/nativeapi/image_upload.go +++ b/server/nativeapi/image_upload.go @@ -31,10 +31,16 @@ func checkImageUploadPermission(w http.ResponseWriter, r *http.Request) bool { } func handleImageUpload(saveFn func(ctx context.Context, reader io.Reader, ext string) error) http.HandlerFunc { + return handleImageUploadGated(true, saveFn) +} + +// handleImageUploadGated is handleImageUpload with an opt-out from the EnableArtworkUpload gate, +// so avatar uploads (gated separately by EnableUserAvatarUpload) can reuse this handler. +func handleImageUploadGated(checkArtworkFlag bool, saveFn func(ctx context.Context, reader io.Reader, ext string) error) http.HandlerFunc { maxImageSize := artwork.MaxImageUploadSize() return func(w http.ResponseWriter, r *http.Request) { ctx := r.Context() - if !checkImageUploadPermission(w, r) { + if checkArtworkFlag && !checkImageUploadPermission(w, r) { return } r.Body = http.MaxBytesReader(w, r.Body, maxImageSize) @@ -97,9 +103,15 @@ func handleImageUpload(saveFn func(ctx context.Context, reader io.Reader, ext st } func handleImageDelete(deleteFn func(ctx context.Context) error) http.HandlerFunc { + return handleImageDeleteGated(true, deleteFn) +} + +// handleImageDeleteGated is handleImageDelete with the same EnableArtworkUpload opt-out as +// handleImageUploadGated. +func handleImageDeleteGated(checkArtworkFlag bool, deleteFn func(ctx context.Context) error) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { ctx := r.Context() - if !checkImageUploadPermission(w, r) { + if checkArtworkFlag && !checkImageUploadPermission(w, r) { return } if err := deleteFn(ctx); err != nil { diff --git a/server/nativeapi/native_api.go b/server/nativeapi/native_api.go index 57a712a20..0bd897865 100644 --- a/server/nativeapi/native_api.go +++ b/server/nativeapi/native_api.go @@ -67,7 +67,7 @@ func (api *Router) routes() http.Handler { r.Use(server.Authenticator(api.ds)) r.Use(server.JWTRefresher) r.Use(server.UpdateLastAccessMiddleware(api.ds)) - api.RX(r, "/user", api.users.NewRepository, true) + api.addUserRoute(r) api.R(r, "/song", model.MediaFile{}, false) api.R(r, "/album", model.Album{}, false) api.addArtistRoute(r) diff --git a/server/nativeapi/users.go b/server/nativeapi/users.go new file mode 100644 index 000000000..fe7e241d0 --- /dev/null +++ b/server/nativeapi/users.go @@ -0,0 +1,87 @@ +package nativeapi + +import ( + "context" + "errors" + "io" + "net/http" + + "github.com/deluan/rest" + "github.com/go-chi/chi/v5" + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" + "github.com/navidrome/navidrome/server" +) + +func (api *Router) addUserRoute(r chi.Router) { + constructor := func(ctx context.Context) rest.Repository { + return api.users.NewRepository(ctx) + } + r.Route("/user", func(r chi.Router) { + r.Get("/", rest.GetAll(constructor)) + r.Post("/", rest.Post(constructor)) + r.Route("/{id}", func(r chi.Router) { + r.Use(server.URLParamsMiddleware) + r.Get("/", rest.Get(constructor)) + r.Put("/", rest.Put(constructor)) + r.Delete("/", rest.Delete(constructor)) + r.Post("/image", api.uploadUserAvatar()) + r.Delete("/image", api.deleteUserAvatar()) + }) + }) +} + +// canEditAvatar allows the target user or any admin, and honors the feature flag for everyone. +func canEditAvatar(ctx context.Context, targetID string) error { + if !conf.Server.EnableUserAvatarUpload { + return model.ErrNotAuthorized + } + usr, _ := request.UserFrom(ctx) + if !usr.IsAdmin && usr.ID != targetID { + return model.ErrNotAuthorized + } + return nil +} + +func (api *Router) uploadUserAvatar() http.HandlerFunc { + // false: avatars are gated by EnableUserAvatarUpload, never by EnableArtworkUpload. + return handleImageUploadGated(false, func(ctx context.Context, reader io.Reader, ext string) error { + userID := chi.URLParamFromCtx(ctx, "id") + if err := canEditAvatar(ctx, userID); err != nil { + return err + } + usr, err := api.ds.User(ctx).Get(userID) + if err != nil { + if errors.Is(err, model.ErrNotFound) { + return model.ErrNotFound + } + return err + } + filename, err := api.imgUpload.SetAvatar(ctx, usr.ID, usr.UserName, usr.UploadedImagePath(), reader, ext) + if err != nil { + return err + } + return api.ds.User(ctx).UpdateImage(usr.ID, filename) + }) +} + +func (api *Router) deleteUserAvatar() http.HandlerFunc { + return handleImageDeleteGated(false, func(ctx context.Context) error { + userID := chi.URLParamFromCtx(ctx, "id") + if err := canEditAvatar(ctx, userID); err != nil { + return err + } + usr, err := api.ds.User(ctx).Get(userID) + if err != nil { + if errors.Is(err, model.ErrNotFound) { + return model.ErrNotFound + } + return err + } + if err := api.imgUpload.RemoveImage(ctx, usr.UploadedImagePath()); err != nil { + return err + } + return api.ds.User(ctx).UpdateImage(usr.ID, "") + }) +} diff --git a/server/nativeapi/users_test.go b/server/nativeapi/users_test.go new file mode 100644 index 000000000..d0ddf06b2 --- /dev/null +++ b/server/nativeapi/users_test.go @@ -0,0 +1,109 @@ +package nativeapi + +import ( + "bytes" + "context" + "image" + "image/png" + "io" + "mime/multipart" + "net/http" + "net/http/httptest" + + "github.com/go-chi/chi/v5" + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/core/artwork" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func testRouter(ds model.DataStore) http.Handler { + api := &Router{ds: ds, imgUpload: artwork.NewUploader(ds)} + r := chi.NewRouter() + api.addUserRoute(r) + return r +} + +var _ = Describe("User avatar routes", func() { + var router http.Handler + var ds *tests.MockDataStore + + newRequest := func(method, path string, body io.Reader, contentType string, asUser model.User) *http.Request { + r := httptest.NewRequest(method, path, body) + if contentType != "" { + r.Header.Set("Content-Type", contentType) + } + return r.WithContext(request.WithUser(r.Context(), asUser)) + } + + pngUpload := func() (io.Reader, string) { + var buf bytes.Buffer + mw := multipart.NewWriter(&buf) + part, err := mw.CreateFormFile("image", "avatar.png") + Expect(err).ToNot(HaveOccurred()) + Expect(png.Encode(part, image.NewRGBA(image.Rect(0, 0, 8, 8)))).To(Succeed()) + Expect(mw.Close()).To(Succeed()) + return &buf, mw.FormDataContentType() + } + + regularUser := model.User{ID: "u1", UserName: "regular"} + adminUser := model.User{ID: "admin", UserName: "admin", IsAdmin: true} + otherUser := model.User{ID: "u2", UserName: "other"} + + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + conf.Server.DataFolder = conf.NewDir(GinkgoT().TempDir()) + conf.Server.EnableUserAvatarUpload = true + ds = &tests.MockDataStore{} + Expect(ds.User(context.Background()).Put(®ularUser)).To(Succeed()) + Expect(ds.User(context.Background()).Put(&adminUser)).To(Succeed()) + Expect(ds.User(context.Background()).Put(&otherUser)).To(Succeed()) + router = testRouter(ds) + }) + + It("lets a user upload their own avatar", func() { + body, ct := pngUpload() + w := httptest.NewRecorder() + router.ServeHTTP(w, newRequest("POST", "/user/u1/image", body, ct, regularUser)) + Expect(w.Code).To(Equal(http.StatusOK)) + + usr, _ := ds.User(context.Background()).Get("u1") + Expect(usr.UploadedImage).To(Equal("u1_regular.png")) + }) + + It("lets an admin upload someone else's avatar", func() { + body, ct := pngUpload() + w := httptest.NewRecorder() + router.ServeHTTP(w, newRequest("POST", "/user/u1/image", body, ct, adminUser)) + Expect(w.Code).To(Equal(http.StatusOK)) + }) + + It("refuses a third party", func() { + body, ct := pngUpload() + w := httptest.NewRecorder() + router.ServeHTTP(w, newRequest("POST", "/user/u1/image", body, ct, otherUser)) + Expect(w.Code).To(Equal(http.StatusForbidden)) + }) + + It("refuses everyone, admins included, when the flag is off", func() { + conf.Server.EnableUserAvatarUpload = false + body, ct := pngUpload() + w := httptest.NewRecorder() + router.ServeHTTP(w, newRequest("POST", "/user/u1/image", body, ct, adminUser)) + Expect(w.Code).To(Equal(http.StatusForbidden)) + }) + + It("clears the filename on delete", func() { + Expect(ds.User(context.Background()).UpdateImage("u1", "u1_regular.png")).To(Succeed()) + w := httptest.NewRecorder() + router.ServeHTTP(w, newRequest("DELETE", "/user/u1/image", nil, "", regularUser)) + Expect(w.Code).To(Equal(http.StatusOK)) + + usr, _ := ds.User(context.Background()).Get("u1") + Expect(usr.UploadedImage).To(BeEmpty()) + }) +})