mirror of
https://github.com/navidrome/navidrome.git
synced 2026-10-08 10:27:08 +02:00
feat(nativeapi): add user avatar upload and delete endpoints
Adds POST/DELETE /api/user/{id}/image, gated by EnableUserAvatarUpload
(never EnableArtworkUpload) and restricted to the target user or an
admin. Reuses the artist/playlist/radio image-upload handlers via a new
gate opt-out so their EnableArtworkUpload behavior is unchanged.
This commit is contained in:
parent
61bb68fc6b
commit
16c62e075e
4 changed files with 211 additions and 3 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
87
server/nativeapi/users.go
Normal file
87
server/nativeapi/users.go
Normal file
|
|
@ -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, "")
|
||||
})
|
||||
}
|
||||
109
server/nativeapi/users_test.go
Normal file
109
server/nativeapi/users_test.go
Normal file
|
|
@ -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())
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue