mirror of
https://github.com/navidrome/navidrome.git
synced 2026-10-28 14:17:10 +01:00
Replace the 131 SQLite migrations with a single goose migration that creates the whole schema in PostgreSQL (the user table becomes user_account, json columns become jsonb, seed rows for the default library and transcodings). Port the repositories' SQL just far enough for Navidrome to start, scan, log in and browse artists and albums: json_agg/jsonb_* for the SQLite JSON functions, lower() for COLLATE NOCASE, ON CONFLICT DO NOTHING, coalesce on annotations, stricter GROUP BY, explicit casts under the simple protocol, sequential queries in library.RefreshStats. db/ is now Postgres-only, with backup/restore stubbed and DevExternalScanner forced off. Embed the PGlite module like go-taglib: pglite.wasi.gz is go:embed'ed, decompressed in memory and compiled by wazero with an on-disk compilation cache, so the module itself is never written to disk. The cluster is created by initdb at runtime on the first start; initdb runs in a throwaway wasm instance and the backend in a fresh one, otherwise the next start cannot find a valid checkpoint. Since initdb creates only template1, a short-lived backend then runs CREATE DATABASE navidrome and the real backend starts on it. The build recipe and patches for the -O2, no-wizer module live in db/pglite/build.
540 lines
14 KiB
Go
540 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_account"
|
|
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_account.*`,
|
|
`COALESCE(json_agg(json_build_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_account.id = ul.user_id").
|
|
LeftJoin("library ON ul.library_id = library.id").
|
|
GroupBy("user_account.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_account.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 INTO user_library (user_id, library_id) SELECT ?, id FROM library ON CONFLICT DO NOTHING",
|
|
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 INTO user_library (user_id, library_id) SELECT ?, id FROM library WHERE default_new_users = true ON CONFLICT DO NOTHING",
|
|
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_account.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("lower(user_account.user_name) = lower(?)", 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_account"
|
|
}
|
|
|
|
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_account.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_account", u.UserName, "id", u.ID, err)
|
|
} else {
|
|
log.Warn("Password encrypted successfully", "user_account", 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_account", 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_account", 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)
|