mirror of
https://github.com/navidrome/navidrome.git
synced 2026-10-28 14:17:10 +01:00
* feat(scrobbler): add scrobble_filter column to user * feat(scrobbler): validate scrobble filter criteria on user save * refactor(persistence): make smart playlist join helpers package-level * feat(scrobbler): add MediaFileRepository.MatchesCriteria * feat(scrobbler): filter external scrobbles with per-user criteria * feat(ui): add scrobble filter field to user form * fix(scrobbler): default scrobble_filter to empty string for existing users * refactor(scrobbler): also gate playback reports on the scrobble filter Playback reports carry the same track metadata to plugin scrobblers, so a filtered track leaked through that third dispatch path. Skip the filter evaluation entirely when no scrobbler is active. * refactor(persistence): move criteria join building into criteria_sql.go The join set a criteria needs was decided in criteria_sql.go but built in smart_playlist_repository.go, so both callers had to pair the two by hand. * refactor(persistence): unexport smartPlaylistCriteria methods The type never leaves the package, so the exported names advertised an API that callers outside persistence could never reach. Also disambiguates where/orderBy from squirrel's SelectBuilder methods of the same name. * fix(ui): cap the scrobble filter field width fullWidth stretched it across the whole page next to 256px inputs. Bounded at 40em, with two rows and a resize handle so JSON rules stay readable. * refactor(ui): move scrobble filter input in UserEdit component * feat(ui): add pt-BR translations for the scrobble filter * fix(scrobbler): take the filter verdict before incPlay incPlay mutates play counts and dates a filter can test on, so evaluating at dispatch time let one play decide differently on either side of the increment: a track could be scrobbled despite matching, or lose only its stopped report and strand presence plugins. Reject limit/offset too, rather than silently ignoring part of a rule copied from a smart playlist. * fix(scrobbler): filter the report from an expired session The expiry callback runs with a stub user carrying no filter, so evaluating there always returned false and leaked the track to plugin scrobblers. That is the normal path for clients that never send stopped, such as legacy Subsonic now-playing. Carry the last verdict on the session instead. * refactor(scrobbler): skip the now-playing enqueue instead of threading the verdict Queuing an entry only to drop it at dispatch also cancelled a pending announcement for the previous, unfiltered track, since the queue is keyed by player and a new entry replaces the old one. * fix(scrobbler): evaluate the filter regardless of active scrobblers The verdict is stored on the session and dispatched at expiry, so skipping evaluation when no scrobbler was active let a plugin enabled mid-session receive a filtered track. The empty-filter guard above already gives servers without scrobbling the same free path, so the shortcut only ever applied to users who had a filter set.
517 lines
14 KiB
Go
517 lines
14 KiB
Go
package persistence
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
. "github.com/Masterminds/squirrel"
|
|
"github.com/deluan/rest"
|
|
"github.com/navidrome/navidrome/conf"
|
|
"github.com/navidrome/navidrome/consts"
|
|
"github.com/navidrome/navidrome/log"
|
|
"github.com/navidrome/navidrome/model"
|
|
"github.com/navidrome/navidrome/model/criteria"
|
|
"github.com/navidrome/navidrome/model/id"
|
|
"github.com/navidrome/navidrome/utils"
|
|
"github.com/navidrome/navidrome/utils/slice"
|
|
"github.com/pocketbase/dbx"
|
|
)
|
|
|
|
type userRepository struct {
|
|
sqlRepository
|
|
}
|
|
|
|
type dbUser struct {
|
|
*model.User `structs:",flatten"`
|
|
LibrariesJSON string `structs:"-" json:"-"`
|
|
}
|
|
|
|
func (u *dbUser) PostScan() error {
|
|
if u.LibrariesJSON != "" {
|
|
if err := json.Unmarshal([]byte(u.LibrariesJSON), &u.User.Libraries); err != nil {
|
|
return fmt.Errorf("parsing user libraries from db: %w", err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
type dbUsers []dbUser
|
|
|
|
func (us dbUsers) toModels() model.Users {
|
|
return slice.Map(us, func(u dbUser) model.User { return *u.User })
|
|
}
|
|
|
|
var (
|
|
once sync.Once
|
|
encKey []byte
|
|
)
|
|
|
|
func NewUserRepository(ctx context.Context, db dbx.Builder) model.UserRepository {
|
|
r := &userRepository{}
|
|
r.ctx = ctx
|
|
r.db = db
|
|
r.tableName = "user"
|
|
r.registerModel(&model.User{}, map[string]filterFunc{
|
|
"id": idFilter(r.tableName),
|
|
"password": invalidFilter(ctx),
|
|
"name": startsWithFilter(r.tableName + ".name"),
|
|
})
|
|
once.Do(func() {
|
|
_ = r.initPasswordEncryptionKey()
|
|
})
|
|
return r
|
|
}
|
|
|
|
// selectUserWithLibraries returns a SelectBuilder that includes library information
|
|
func (r *userRepository) selectUserWithLibraries(options ...model.QueryOptions) SelectBuilder {
|
|
return r.newSelect(options...).
|
|
Columns(`user.*`,
|
|
`COALESCE(json_group_array(json_object(
|
|
'id', library.id,
|
|
'name', library.name,
|
|
'path', library.path,
|
|
'remote_path', library.remote_path,
|
|
'last_scan_at', library.last_scan_at,
|
|
'last_scan_started_at', library.last_scan_started_at,
|
|
'full_scan_in_progress', library.full_scan_in_progress,
|
|
'updated_at', library.updated_at,
|
|
'created_at', library.created_at
|
|
)) FILTER (WHERE library.id IS NOT NULL), '[]') AS libraries_json`).
|
|
LeftJoin("user_library ul ON user.id = ul.user_id").
|
|
LeftJoin("library ON ul.library_id = library.id").
|
|
GroupBy("user.id")
|
|
}
|
|
|
|
func (r *userRepository) CountAll(qo ...model.QueryOptions) (int64, error) {
|
|
return r.count(Select(), qo...)
|
|
}
|
|
|
|
func (r *userRepository) Get(id string) (*model.User, error) {
|
|
sel := r.selectUserWithLibraries().Where(Eq{"user.id": id})
|
|
var res dbUser
|
|
err := r.queryOne(sel, &res)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return res.User, nil
|
|
}
|
|
|
|
func (r *userRepository) GetAll(options ...model.QueryOptions) (model.Users, error) {
|
|
sel := r.selectUserWithLibraries(options...)
|
|
var res dbUsers
|
|
err := r.queryAll(sel, &res)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return res.toModels(), nil
|
|
}
|
|
|
|
func (r *userRepository) Put(u *model.User) error {
|
|
if u.ID == "" {
|
|
u.ID = id.NewRandom()
|
|
}
|
|
u.UpdatedAt = time.Now()
|
|
if u.NewPassword != "" {
|
|
_ = r.encryptPassword(u)
|
|
}
|
|
values, err := toSQLArgs(*u)
|
|
if err != nil {
|
|
return fmt.Errorf("error converting user to SQL args: %w", err)
|
|
}
|
|
delete(values, "current_password")
|
|
|
|
// Save/update the user
|
|
update := Update(r.tableName).Where(Eq{"id": u.ID}).SetMap(values)
|
|
count, err := r.executeSQL(update)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
isNewUser := count == 0
|
|
if isNewUser {
|
|
values["created_at"] = time.Now()
|
|
insert := Insert(r.tableName).SetMap(values)
|
|
_, err = r.executeSQL(insert)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
// Auto-assign all libraries to admin users in a single SQL operation
|
|
if u.IsAdmin {
|
|
sql := Expr(
|
|
"INSERT OR IGNORE INTO user_library (user_id, library_id) SELECT ?, id FROM library",
|
|
u.ID,
|
|
)
|
|
if _, err := r.executeSQL(sql); err != nil {
|
|
return fmt.Errorf("failed to assign all libraries to admin user: %w", err)
|
|
}
|
|
} else if isNewUser { // Only for new regular users
|
|
// Auto-assign default libraries to new regular users
|
|
sql := Expr(
|
|
"INSERT OR IGNORE INTO user_library (user_id, library_id) SELECT ?, id FROM library WHERE default_new_users = true",
|
|
u.ID,
|
|
)
|
|
if _, err := r.executeSQL(sql); err != nil {
|
|
return fmt.Errorf("failed to assign default libraries to new user: %w", err)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (r *userRepository) FindFirstAdmin() (*model.User, error) {
|
|
sel := r.selectUserWithLibraries(model.QueryOptions{Sort: "updated_at", Max: 1}).Where(Eq{"user.is_admin": true})
|
|
var usr dbUser
|
|
err := r.queryOne(sel, &usr)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return usr.User, nil
|
|
}
|
|
|
|
func (r *userRepository) FindByUsername(username string) (*model.User, error) {
|
|
sel := r.selectUserWithLibraries().Where(Expr("user.user_name = ? COLLATE NOCASE", username))
|
|
var usr dbUser
|
|
err := r.queryOne(sel, &usr)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return usr.User, nil
|
|
}
|
|
|
|
func (r *userRepository) FindByUsernameWithPassword(username string) (*model.User, error) {
|
|
usr, err := r.FindByUsername(username)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
_ = r.decryptPassword(usr)
|
|
return usr, nil
|
|
}
|
|
|
|
func (r *userRepository) UpdateLastLoginAt(id string) error {
|
|
upd := Update(r.tableName).Where(Eq{"id": id}).Set("last_login_at", time.Now())
|
|
_, err := r.executeSQL(upd)
|
|
return err
|
|
}
|
|
|
|
func (r *userRepository) UpdateLastAccessAt(id string) error {
|
|
now := time.Now()
|
|
upd := Update(r.tableName).Where(Eq{"id": id}).Set("last_access_at", now)
|
|
_, err := r.executeSQL(upd)
|
|
return err
|
|
}
|
|
|
|
func (r *userRepository) Count(options ...rest.QueryOptions) (int64, error) {
|
|
usr := loggedUser(r.ctx)
|
|
if !usr.IsAdmin {
|
|
return 0, rest.ErrPermissionDenied
|
|
}
|
|
return r.CountAll(r.parseRestOptions(r.ctx, options...))
|
|
}
|
|
|
|
func (r *userRepository) Read(id string) (any, error) {
|
|
usr := loggedUser(r.ctx)
|
|
if !usr.IsAdmin && usr.ID != id {
|
|
return nil, rest.ErrPermissionDenied
|
|
}
|
|
usr, err := r.Get(id)
|
|
if errors.Is(err, model.ErrNotFound) {
|
|
return nil, rest.ErrNotFound
|
|
}
|
|
return usr, err
|
|
}
|
|
|
|
func (r *userRepository) ReadAll(options ...rest.QueryOptions) (any, error) {
|
|
usr := loggedUser(r.ctx)
|
|
if !usr.IsAdmin {
|
|
return nil, rest.ErrPermissionDenied
|
|
}
|
|
return r.GetAll(r.parseRestOptions(r.ctx, options...))
|
|
}
|
|
|
|
func (r *userRepository) EntityName() string {
|
|
return "user"
|
|
}
|
|
|
|
func (r *userRepository) NewInstance() any {
|
|
return &model.User{}
|
|
}
|
|
|
|
func (r *userRepository) Save(entity any) (string, error) {
|
|
usr := loggedUser(r.ctx)
|
|
if !usr.IsAdmin {
|
|
return "", rest.ErrPermissionDenied
|
|
}
|
|
u := entity.(*model.User)
|
|
if err := validateUsernameUnique(r, u); err != nil {
|
|
return "", err
|
|
}
|
|
if err := validateScrobbleFilter(u); err != nil {
|
|
return "", err
|
|
}
|
|
err := r.Put(u)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return u.ID, err
|
|
}
|
|
|
|
func (r *userRepository) Update(id string, entity any, _ ...string) error {
|
|
u := entity.(*model.User)
|
|
u.ID = id
|
|
usr := loggedUser(r.ctx)
|
|
if !usr.IsAdmin && usr.ID != u.ID {
|
|
return rest.ErrPermissionDenied
|
|
}
|
|
if !usr.IsAdmin {
|
|
if !conf.Server.EnableUserEditing {
|
|
return rest.ErrPermissionDenied
|
|
}
|
|
u.IsAdmin = false
|
|
u.UserName = usr.UserName
|
|
}
|
|
|
|
// Decrypt the user's existing password before validating. This is required otherwise the existing password entered by the user will never match.
|
|
if err := r.decryptPassword(usr); err != nil {
|
|
return err
|
|
}
|
|
if err := validatePasswordChange(u, usr); err != nil {
|
|
return err
|
|
}
|
|
if err := validateUsernameUnique(r, u); err != nil {
|
|
return err
|
|
}
|
|
if err := validateScrobbleFilter(u); err != nil {
|
|
return err
|
|
}
|
|
err := r.Put(u)
|
|
if errors.Is(err, model.ErrNotFound) {
|
|
return rest.ErrNotFound
|
|
}
|
|
return err
|
|
}
|
|
|
|
func validatePasswordChange(newUser *model.User, logged *model.User) error {
|
|
err := &rest.ValidationError{Errors: map[string]string{}}
|
|
if logged.IsAdmin && newUser.ID != logged.ID {
|
|
return nil
|
|
}
|
|
if newUser.NewPassword == "" {
|
|
if newUser.CurrentPassword == "" {
|
|
return nil
|
|
}
|
|
err.Errors["password"] = "ra.validation.required"
|
|
}
|
|
|
|
if !strings.HasPrefix(logged.Password, consts.PasswordAutogenPrefix) {
|
|
if newUser.CurrentPassword == "" {
|
|
err.Errors["currentPassword"] = "ra.validation.required"
|
|
}
|
|
if newUser.CurrentPassword != logged.Password {
|
|
err.Errors["currentPassword"] = "ra.validation.passwordDoesNotMatch"
|
|
}
|
|
}
|
|
if len(err.Errors) > 0 {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func validateUsernameUnique(r model.UserRepository, u *model.User) error {
|
|
usr, err := r.FindByUsername(u.UserName)
|
|
if errors.Is(err, model.ErrNotFound) {
|
|
return nil
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if usr.ID != u.ID {
|
|
return &rest.ValidationError{Errors: map[string]string{"userName": "ra.validation.unique"}}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func validateScrobbleFilter(u *model.User) error {
|
|
u.ScrobbleFilter = strings.TrimSpace(u.ScrobbleFilter)
|
|
if u.ScrobbleFilter == "" {
|
|
return nil
|
|
}
|
|
var c criteria.Criteria
|
|
if err := json.Unmarshal([]byte(u.ScrobbleFilter), &c); err != nil {
|
|
return invalidScrobbleFilter()
|
|
}
|
|
// A filter is a per-track test, so a result-set size means nothing here. Reject it
|
|
// rather than silently ignoring part of a rule copied from a smart playlist.
|
|
if c.Limit > 0 || c.LimitPercent > 0 || c.Offset > 0 || c.RefreshDelay > 0 {
|
|
return invalidScrobbleFilter()
|
|
}
|
|
// Building the WHERE clause is what validates field names and operators
|
|
if _, err := newSmartPlaylistCriteria(c).where(); err != nil {
|
|
return invalidScrobbleFilter()
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func invalidScrobbleFilter() error {
|
|
return &rest.ValidationError{Errors: map[string]string{
|
|
"scrobbleFilter": "resources.user.validation.invalidScrobbleFilter",
|
|
}}
|
|
}
|
|
|
|
func (r *userRepository) Delete(id string) error {
|
|
usr := loggedUser(r.ctx)
|
|
if !usr.IsAdmin {
|
|
return rest.ErrPermissionDenied
|
|
}
|
|
err := r.delete(Eq{"id": id})
|
|
if errors.Is(err, model.ErrNotFound) {
|
|
return rest.ErrNotFound
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Clean up orphaned plugin references for the deleted user
|
|
if err := cleanupPluginUserReferences(r.db, id); err != nil {
|
|
log.Error(r.ctx, "Failed to cleanup plugin user references", "userID", id, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func keyTo32Bytes(input string) []byte {
|
|
data := sha256.Sum256([]byte(input))
|
|
return data[0:]
|
|
}
|
|
|
|
func (r *userRepository) initPasswordEncryptionKey() error {
|
|
encKey = keyTo32Bytes(consts.DefaultEncryptionKey)
|
|
if conf.Server.PasswordEncryptionKey == "" {
|
|
return nil
|
|
}
|
|
|
|
key := keyTo32Bytes(conf.Server.PasswordEncryptionKey)
|
|
keySum := fmt.Sprintf("%x", sha256.Sum256(key))
|
|
|
|
props := NewPropertyRepository(r.ctx, r.db)
|
|
savedKeySum, err := props.Get(consts.PasswordsEncryptedKey)
|
|
|
|
// If passwords are already encrypted
|
|
if err == nil {
|
|
if savedKeySum != keySum {
|
|
log.Error("Password Encryption Key changed! Users won't be able to login!")
|
|
return errors.New("passwordEncryptionKey changed")
|
|
}
|
|
encKey = key
|
|
return nil
|
|
}
|
|
|
|
// if not, try to re-encrypt all current passwords with new encryption key,
|
|
// assuming they were encrypted with the DefaultEncryptionKey
|
|
sql := r.newSelect().Columns("id", "user_name", "password")
|
|
users := model.Users{}
|
|
err = r.queryAll(sql, &users)
|
|
if err != nil {
|
|
log.Error("Could not encrypt all passwords", err)
|
|
return err
|
|
}
|
|
log.Warn("New PasswordEncryptionKey set. Encrypting all passwords", "numUsers", len(users))
|
|
if err = r.decryptAllPasswords(users); err != nil {
|
|
return err
|
|
}
|
|
encKey = key
|
|
for i := range users {
|
|
u := users[i]
|
|
u.NewPassword = u.Password
|
|
if err := r.encryptPassword(&u); err == nil {
|
|
upd := Update(r.tableName).Set("password", u.NewPassword).Where(Eq{"id": u.ID})
|
|
_, err = r.executeSQL(upd)
|
|
if err != nil {
|
|
log.Error("Password NOT encrypted! This may cause problems!", "user", u.UserName, "id", u.ID, err)
|
|
} else {
|
|
log.Warn("Password encrypted successfully", "user", u.UserName, "id", u.ID)
|
|
}
|
|
}
|
|
}
|
|
|
|
err = props.Put(consts.PasswordsEncryptedKey, keySum)
|
|
if err != nil {
|
|
log.Error("Could not flag passwords as encrypted. It will cause login errors", err)
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// encrypts u.NewPassword
|
|
func (r *userRepository) encryptPassword(u *model.User) error {
|
|
encPassword, err := utils.Encrypt(r.ctx, encKey, u.NewPassword)
|
|
if err != nil {
|
|
log.Error(r.ctx, "Error encrypting user's password", "user", u.UserName, err)
|
|
return err
|
|
}
|
|
u.NewPassword = encPassword
|
|
return nil
|
|
}
|
|
|
|
// decrypts u.Password
|
|
func (r *userRepository) decryptPassword(u *model.User) error {
|
|
plaintext, err := utils.Decrypt(r.ctx, encKey, u.Password)
|
|
if err != nil {
|
|
log.Error(r.ctx, "Error decrypting user's password", "user", u.UserName, err)
|
|
return err
|
|
}
|
|
u.Password = plaintext
|
|
return nil
|
|
}
|
|
|
|
func (r *userRepository) decryptAllPasswords(users model.Users) error {
|
|
for i := range users {
|
|
if err := r.decryptPassword(&users[i]); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Library association methods
|
|
|
|
func (r *userRepository) GetUserLibraries(userID string) (model.Libraries, error) {
|
|
sel := Select("l.*").
|
|
From("library l").
|
|
Join("user_library ul ON l.id = ul.library_id").
|
|
Where(Eq{"ul.user_id": userID}).
|
|
OrderBy("l.name")
|
|
|
|
var res model.Libraries
|
|
err := r.queryAll(sel, &res)
|
|
return res, err
|
|
}
|
|
|
|
func (r *userRepository) SetUserLibraries(userID string, libraryIDs []int) error {
|
|
// Remove existing associations
|
|
delSql := Delete("user_library").Where(Eq{"user_id": userID})
|
|
if _, err := r.executeSQL(delSql); err != nil {
|
|
return err
|
|
}
|
|
|
|
// Add new associations
|
|
if len(libraryIDs) > 0 {
|
|
insert := Insert("user_library").Columns("user_id", "library_id")
|
|
for _, libID := range libraryIDs {
|
|
insert = insert.Values(userID, libID)
|
|
}
|
|
_, err := r.executeSQL(insert)
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
var _ model.UserRepository = (*userRepository)(nil)
|
|
var _ rest.Repository = (*userRepository)(nil)
|
|
var _ rest.Persistable = (*userRepository)(nil)
|