mirror of
https://github.com/navidrome/navidrome.git
synced 2026-10-14 05:17:13 +02:00
* fix: return 404 instead of 500 for missing native API resources The deluan/rest controller only maps rest.ErrNotFound to 404, comparing with ==. Most repositories return model.ErrNotFound, which had the same message but was a different value, so requesting a missing playlist, album, artist, song, radio, player, transcoding or library returned 500. This also applied to other users' private playlists. Make model.ErrNotFound the same value as rest.ErrNotFound. This fixes every REST route at once, with no per-route wrapping. errors.Is checks against either error keep working, and nothing wraps model.ErrNotFound before it reaches the controller. Fixes #6130 * fix(radio): return not found when deleting a missing radio station radioRepository.Delete used the shared delete helper, which never reports a missing row because SQL DELETE on zero rows is not an error. Deleting an unknown id silently succeeded: DELETE /api/radio/{id} returned 200, and the Subsonic deleteInternetRadioStation endpoint returned ok. Delete now checks the affected row count and returns model.ErrNotFound when nothing was deleted. The native API returns 404, and deleteInternetRadioStation returns error 70 (data not found). This matches Subsonic 6.1.6, gonic (both verified live) and Ampache (verified in source). Airsonic-Advanced does not implement this endpoint. The shared delete helper is unchanged, as several callers rely on deletes of absent rows succeeding. * fix(ui): return 404 for missing files that are not missing or do not exist missingRepository.Read filtered media files by bare "id" and "missing" columns. The media file query joins the library table, so SQLite rejected the query as ambiguous and GET /api/missing/{id} returned 500. Read now loads the file with MediaFileRepository.Get, which qualifies the column, and returns not found when the file does not exist or is not marked missing. * fix: report missing rows on single-item deletes and adopt deluan/rest errors.Is Bump github.com/deluan/rest to the version whose controller matches errors with errors.Is and errors.As. model.ErrNotFound stays the same value as rest.ErrNotFound, so the many hand-written conversions from model.ErrNotFound to rest.ErrNotFound in repositories, core services and test mocks did nothing. Remove them, along with the duplicate rest.ErrNotFound check in the Subsonic error mapper. Mappings from model.ErrNotAuthorized stay, as those are different errors. User and transcoding deletes had the same silent success as radio: the shared delete helper never reports a missing row, so their not-found checks never fired and DELETE /api/user/{id} and /api/transcoding/{id} returned 200 for unknown ids. Add deleteByID, which returns model.ErrNotFound when no row matched, and use it for radio, user and transcoding. Also drop the dead sql.ErrNoRows branch from delete, since a DELETE never returns it. The Subsonic deleteUser endpoint is not implemented (501), so this does not change the Subsonic API. Plugin deletes keep the silent helper: there is no REST route for them, and the plugin manager only deletes rows it just read. * chore: drop ErrNotFound comment and its identity test The alias to rest.ErrNotFound is self-explanatory, and the identity test only restated the declaration. * refactor: alias model.ErrNotAuthorized to rest.ErrPermissionDenied Like ErrNotFound, make model.ErrNotAuthorized the same value as the rest library's error, so REST endpoints map it to 403 directly. This removes the ErrNotAuthorized to rest.ErrPermissionDenied mappings in the library and playlist REST adapters and the duplicate check in the Subsonic error mapper. Handlers that check model.ErrNotAuthorized now also recognize rest.ErrPermissionDenied returned by repositories, so writePlaylistError, the image upload handlers and the public share handler return 403 for it instead of their fallback status. The error message changes from "not authorized" to "permission denied".
528 lines
14 KiB
Go
528 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/model/request"
|
|
"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")
|
|
|
|
// The epoch bump rides the password UPDATE: as two statements they can interleave with a
|
|
// concurrent change and leave a session valid that the other change should have revoked.
|
|
update := Update(r.tableName).Where(Eq{"id": u.ID}).SetMap(values)
|
|
var isNewUser bool
|
|
var epoch int
|
|
if u.NewPassword != "" {
|
|
var res struct{ TokenEpoch int }
|
|
err = r.queryOne(update.Set("token_epoch", Expr("token_epoch + 1")).
|
|
Suffix("RETURNING token_epoch"), &res)
|
|
switch {
|
|
case errors.Is(err, model.ErrNotFound):
|
|
isNewUser = true
|
|
case err != nil:
|
|
return err
|
|
default:
|
|
epoch = res.TokenEpoch
|
|
}
|
|
} else {
|
|
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)
|
|
}
|
|
}
|
|
|
|
// Only the caller's own token can be refreshed in-flight; an admin resetting another
|
|
// user must keep their own epoch.
|
|
if u.NewPassword != "" && !isNewUser && loggedUser(r.ctx).ID == u.ID {
|
|
request.SetTokenEpoch(r.ctx, epoch)
|
|
}
|
|
|
|
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
|
|
}
|
|
return r.Get(id)
|
|
}
|
|
|
|
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
|
|
}
|
|
return r.Put(u)
|
|
}
|
|
|
|
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
|
|
}
|
|
if err := r.deleteByID(id); 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)
|