feat(ratings): enhance user rating stats and item retrieval with user-specific access control

This commit is contained in:
Платон Петров 2026-06-04 17:21:53 +03:00
parent ae203bc872
commit b438ac5436
5 changed files with 230 additions and 4 deletions

View File

@ -490,7 +490,7 @@ func (r *userRepository) RatingStats() ([]model.UserRatingStats, error) {
sel := Select(`a.user_id`, `u.user_name`, `a.item_type`, `a.rating`, `count(*) as cnt`).
From(`annotation a`).
Join(`"user" u ON a.user_id = u.id`).
Where(Gt{"a.rating": 0}).
Where(And{Gt{"a.rating": 0}, Eq{"a.item_type": []interface{}{"media_file", "album"}}}).
GroupBy(`a.user_id`, `u.user_name`, `a.item_type`, `a.rating`).
OrderBy(`u.user_name`, `a.item_type`, `a.rating DESC`)

View File

@ -8,6 +8,7 @@ import (
"github.com/go-chi/chi/v5"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
)
func (api *Router) addRatingStatsRoute(r chi.Router) {
@ -17,12 +18,23 @@ func (api *Router) addRatingStatsRoute(r chi.Router) {
func getRatingStats(ds model.DataStore) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
currentUser, _ := request.UserFrom(r.Context())
stats, err := ds.User(r.Context()).RatingStats()
if err != nil {
log.Error(r.Context(), "Error getting rating stats", err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if !currentUser.IsAdmin {
filtered := make([]model.UserRatingStats, 0, 1)
for _, s := range stats {
if s.UserID == currentUser.ID {
filtered = append(filtered, s)
break
}
}
stats = filtered
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(stats); err != nil {
log.Error(r.Context(), "Error encoding rating stats", err)
@ -32,6 +44,7 @@ func getRatingStats(ds model.DataStore) http.HandlerFunc {
func getRatingItems(ds model.DataStore) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
currentUser, _ := request.UserFrom(r.Context())
userID := r.URL.Query().Get("userId")
itemType := r.URL.Query().Get("type")
ratingStr := r.URL.Query().Get("rating")
@ -42,6 +55,11 @@ func getRatingItems(ds model.DataStore) http.HandlerFunc {
return
}
if !currentUser.IsAdmin && currentUser.ID != userID {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
items, err := ds.User(r.Context()).RatingItems(userID, itemType, rating)
if err != nil {
log.Error(r.Context(), "Error getting rating items", err)

View File

@ -0,0 +1,189 @@
package nativeapi
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/core/auth"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/server"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("Rating Stats API", func() {
var ds *tests.MockDataStore
var router http.Handler
var adminUser, regularUser model.User
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
ds = &tests.MockDataStore{}
auth.Init(ds)
nativeRouter := New(ds, nil, nil, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, nil, nil)
router = server.JWTVerifier(nativeRouter)
adminUser = model.User{
ID: "admin-1",
UserName: "admin",
Name: "Admin User",
IsAdmin: true,
NewPassword: "adminpass",
}
regularUser = model.User{
ID: "user-1",
UserName: "regular",
Name: "Regular User",
IsAdmin: false,
NewPassword: "userpass",
}
Expect(ds.User(context.TODO()).Put(&adminUser)).To(Succeed())
Expect(ds.User(context.TODO()).Put(&regularUser)).To(Succeed())
mockedUserRepo := ds.MockedUser.(*tests.MockedUserRepo)
mockedUserRepo.RatingStatsData = []model.UserRatingStats{
{
UserID: "admin-1",
UserName: "admin",
SongStats: []model.RatingStat{
{Rating: 5, Count: 3},
},
},
{
UserID: "user-1",
UserName: "regular",
SongStats: []model.RatingStat{
{Rating: 4, Count: 2},
},
},
}
mockedUserRepo.RatingItemsData = []model.RatedItem{
{ID: "song-1", Name: "Test Song", Artist: "Test Artist"},
}
})
Describe("GET /ratingStats", func() {
Context("as admin user", func() {
It("returns all users stats", func() {
adminToken, err := auth.CreateToken(&adminUser)
Expect(err).ToNot(HaveOccurred())
req := createAuthenticatedRequest("GET", "/ratingStats", nil, adminToken)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
Expect(w.Code).To(Equal(http.StatusOK))
var stats []model.UserRatingStats
Expect(json.Unmarshal(w.Body.Bytes(), &stats)).To(Succeed())
Expect(stats).To(HaveLen(2))
})
})
Context("as regular user", func() {
It("returns only own stats", func() {
userToken, err := auth.CreateToken(&regularUser)
Expect(err).ToNot(HaveOccurred())
req := createAuthenticatedRequest("GET", "/ratingStats", nil, userToken)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
Expect(w.Code).To(Equal(http.StatusOK))
var stats []model.UserRatingStats
Expect(json.Unmarshal(w.Body.Bytes(), &stats)).To(Succeed())
Expect(stats).To(HaveLen(1))
Expect(stats[0].UserID).To(Equal("user-1"))
})
})
Context("without authentication", func() {
It("returns 401", func() {
req := createUnauthenticatedRequest("GET", "/ratingStats", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
Expect(w.Code).To(Equal(http.StatusUnauthorized))
})
})
})
Describe("GET /ratingItems", func() {
Context("as admin user", func() {
It("can query any user's items", func() {
adminToken, err := auth.CreateToken(&adminUser)
Expect(err).ToNot(HaveOccurred())
req := createAuthenticatedRequest("GET", "/ratingItems?userId=user-1&type=song&rating=4", nil, adminToken)
req.Header.Set(consts.UIAuthorizationHeader, "Bearer "+adminToken)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
Expect(w.Code).To(Equal(http.StatusOK))
})
})
Context("as regular user", func() {
var userToken string
BeforeEach(func() {
var err error
userToken, err = auth.CreateToken(&regularUser)
Expect(err).ToNot(HaveOccurred())
})
It("can query own items", func() {
req := createAuthenticatedRequest("GET", "/ratingItems?userId=user-1&type=song&rating=4", nil, userToken)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
Expect(w.Code).To(Equal(http.StatusOK))
})
It("returns 403 when querying another user's items", func() {
req := createAuthenticatedRequest("GET", "/ratingItems?userId=admin-1&type=song&rating=5", nil, userToken)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
Expect(w.Code).To(Equal(http.StatusForbidden))
})
It("returns 400 for invalid rating", func() {
req := createAuthenticatedRequest("GET", "/ratingItems?userId=user-1&type=song&rating=9", nil, userToken)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
Expect(w.Code).To(Equal(http.StatusBadRequest))
})
It("returns 400 for invalid type", func() {
req := createAuthenticatedRequest("GET", "/ratingItems?userId=user-1&type=artist&rating=3", nil, userToken)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
Expect(w.Code).To(Equal(http.StatusBadRequest))
})
It("returns 400 when userId is missing", func() {
req := createAuthenticatedRequest("GET", "/ratingItems?type=song&rating=3", nil, userToken)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
Expect(w.Code).To(Equal(http.StatusBadRequest))
})
})
Context("without authentication", func() {
It("returns 401", func() {
req := createUnauthenticatedRequest("GET", "/ratingItems?userId=user-1&type=song&rating=4", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
Expect(w.Code).To(Equal(http.StatusUnauthorized))
})
})
})
})

View File

@ -18,9 +18,11 @@ func CreateMockUserRepo() *MockedUserRepo {
type MockedUserRepo struct {
model.UserRepository
Error error
Data map[string]*model.User
UserLibraries map[string][]int // userID -> libraryIDs
Error error
Data map[string]*model.User
UserLibraries map[string][]int // userID -> libraryIDs
RatingStatsData []model.UserRatingStats
RatingItemsData []model.RatedItem
}
func (u *MockedUserRepo) CountAll(qo ...model.QueryOptions) (int64, error) {
@ -134,6 +136,20 @@ func (u *MockedUserRepo) SetUserLibraries(userID string, libraryIDs []int) error
return nil
}
func (u *MockedUserRepo) RatingStats() ([]model.UserRatingStats, error) {
if u.Error != nil {
return nil, u.Error
}
return u.RatingStatsData, nil
}
func (u *MockedUserRepo) RatingItems(userID, itemType string, rating int) ([]model.RatedItem, error) {
if u.Error != nil {
return nil, u.Error
}
return u.RatingItemsData, nil
}
func (u *MockedUserRepo) Delete(id string) error {
if u.Error != nil {
return u.Error

View File

@ -128,6 +128,9 @@ const RatingTable = ({ stats, label, userId, userName, type }) => {
className={classes.row}
style={{ cursor: count > 0 ? 'pointer' : 'default' }}
onClick={() => count > 0 && history.push(linkTo)}
tabIndex={count > 0 ? 0 : undefined}
role={count > 0 ? 'button' : undefined}
onKeyDown={count > 0 ? (e) => (e.key === 'Enter' || e.key === ' ') && history.push(linkTo) : undefined}
>
<td className={classes.ratingCell}>{r}.0</td>
<td className={classes.countCell}>{count}</td>