mirror of
https://github.com/navidrome/navidrome.git
synced 2026-10-08 18:37:09 +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".
411 lines
12 KiB
Go
411 lines
12 KiB
Go
package core
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"io/fs"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/Masterminds/squirrel"
|
|
"github.com/deluan/rest"
|
|
"github.com/navidrome/navidrome/core/storage"
|
|
"github.com/navidrome/navidrome/log"
|
|
"github.com/navidrome/navidrome/model"
|
|
"github.com/navidrome/navidrome/model/request"
|
|
"github.com/navidrome/navidrome/server/events"
|
|
"github.com/navidrome/navidrome/utils/slice"
|
|
)
|
|
|
|
// Watcher interface for managing file system watchers
|
|
type Watcher interface {
|
|
Watch(ctx context.Context, lib *model.Library) error
|
|
StopWatching(ctx context.Context, libraryID int) error
|
|
}
|
|
|
|
// Library provides business logic for library management and user-library associations
|
|
type Library interface {
|
|
GetUserLibraries(ctx context.Context, userID string) (model.Libraries, error)
|
|
SetUserLibraries(ctx context.Context, userID string, libraryIDs []int) error
|
|
ValidateLibraryAccess(ctx context.Context, userID string, libraryID int) error
|
|
|
|
NewRepository(ctx context.Context) rest.Repository
|
|
}
|
|
|
|
type libraryService struct {
|
|
ds model.DataStore
|
|
scanner model.Scanner
|
|
watcher Watcher
|
|
broker events.Broker
|
|
pluginManager PluginUnloader
|
|
}
|
|
|
|
// NewLibrary creates a new Library service
|
|
func NewLibrary(ds model.DataStore, scanner model.Scanner, watcher Watcher, broker events.Broker, pluginManager PluginUnloader) Library {
|
|
return &libraryService{
|
|
ds: ds,
|
|
scanner: scanner,
|
|
watcher: watcher,
|
|
broker: broker,
|
|
pluginManager: pluginManager,
|
|
}
|
|
}
|
|
|
|
// User-library association operations
|
|
|
|
func (s *libraryService) GetUserLibraries(ctx context.Context, userID string) (model.Libraries, error) {
|
|
// Verify user exists
|
|
if _, err := s.ds.User(ctx).Get(userID); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return s.ds.User(ctx).GetUserLibraries(userID)
|
|
}
|
|
|
|
func (s *libraryService) SetUserLibraries(ctx context.Context, userID string, libraryIDs []int) error {
|
|
// Verify user exists
|
|
user, err := s.ds.User(ctx).Get(userID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Admin users get all libraries automatically - don't allow manual assignment
|
|
if user.IsAdmin {
|
|
return fmt.Errorf("%w: cannot manually assign libraries to admin users", model.ErrValidation)
|
|
}
|
|
|
|
// Regular users must have at least one library
|
|
if len(libraryIDs) == 0 {
|
|
return fmt.Errorf("%w: at least one library must be assigned to non-admin users", model.ErrValidation)
|
|
}
|
|
|
|
// Validate all library IDs exist
|
|
if len(libraryIDs) > 0 {
|
|
if err := s.validateLibraryIDs(ctx, libraryIDs); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
// Set user libraries
|
|
err = s.ds.User(ctx).SetUserLibraries(userID, libraryIDs)
|
|
if err != nil {
|
|
return fmt.Errorf("error setting user libraries: %w", err)
|
|
}
|
|
|
|
// Send refresh event to all clients
|
|
event := &events.RefreshResource{}
|
|
libIDs := slice.Map(libraryIDs, func(id int) string { return strconv.Itoa(id) })
|
|
event = event.With("user", userID).With("library", libIDs...)
|
|
s.broker.SendBroadcastMessage(ctx, event)
|
|
return nil
|
|
}
|
|
|
|
func (s *libraryService) ValidateLibraryAccess(ctx context.Context, userID string, libraryID int) error {
|
|
user, ok := request.UserFrom(ctx)
|
|
if !ok {
|
|
return fmt.Errorf("user not found in context")
|
|
}
|
|
|
|
// Admin users have access to all libraries
|
|
if user.IsAdmin {
|
|
return nil
|
|
}
|
|
|
|
// Check if user has explicit access to this library
|
|
libraries, err := s.ds.User(ctx).GetUserLibraries(userID)
|
|
if err != nil {
|
|
log.Error(ctx, "Error checking library access", "userID", userID, "libraryID", libraryID, err)
|
|
return fmt.Errorf("error checking library access: %w", err)
|
|
}
|
|
|
|
for _, lib := range libraries {
|
|
if lib.ID == libraryID {
|
|
return nil
|
|
}
|
|
}
|
|
|
|
return fmt.Errorf("%w: user does not have access to library %d", model.ErrNotAuthorized, libraryID)
|
|
}
|
|
|
|
// REST repository wrapper
|
|
|
|
func (s *libraryService) NewRepository(ctx context.Context) rest.Repository {
|
|
repo := s.ds.Library(ctx)
|
|
wrapper := &libraryRepositoryWrapper{
|
|
ctx: ctx,
|
|
LibraryRepository: repo,
|
|
Repository: repo.(rest.Repository),
|
|
ds: s.ds,
|
|
scanner: s.scanner,
|
|
watcher: s.watcher,
|
|
broker: s.broker,
|
|
pluginManager: s.pluginManager,
|
|
}
|
|
return wrapper
|
|
}
|
|
|
|
type libraryRepositoryWrapper struct {
|
|
rest.Repository
|
|
model.LibraryRepository
|
|
ctx context.Context
|
|
ds model.DataStore
|
|
scanner model.Scanner
|
|
watcher Watcher
|
|
broker events.Broker
|
|
pluginManager PluginUnloader
|
|
}
|
|
|
|
func (r *libraryRepositoryWrapper) Save(entity any) (string, error) {
|
|
lib := entity.(*model.Library)
|
|
if err := r.validateLibrary(lib); err != nil {
|
|
return "", err
|
|
}
|
|
|
|
err := r.LibraryRepository.Put(lib)
|
|
if err != nil {
|
|
return "", r.mapError(err)
|
|
}
|
|
|
|
// Start watcher and trigger scan after successful library creation
|
|
if r.watcher != nil {
|
|
if err := r.watcher.Watch(r.ctx, lib); err != nil {
|
|
log.Warn(r.ctx, "Failed to start watcher for new library", "libraryID", lib.ID, "name", lib.Name, "path", lib.Path, err)
|
|
}
|
|
}
|
|
|
|
if r.scanner != nil {
|
|
go r.triggerScan(lib, "new")
|
|
}
|
|
|
|
// Send library refresh event to all clients
|
|
if r.broker != nil {
|
|
event := &events.RefreshResource{}
|
|
r.broker.SendBroadcastMessage(r.ctx, event.With("library", strconv.Itoa(lib.ID)))
|
|
log.Debug(r.ctx, "Library created - sent refresh event", "libraryID", lib.ID, "name", lib.Name)
|
|
}
|
|
|
|
return strconv.Itoa(lib.ID), nil
|
|
}
|
|
|
|
func (r *libraryRepositoryWrapper) Update(id string, entity any, cols ...string) error {
|
|
lib := entity.(*model.Library)
|
|
libID, err := strconv.Atoi(id)
|
|
if err != nil {
|
|
return fmt.Errorf("invalid library ID: %s", id)
|
|
}
|
|
|
|
lib.ID = libID
|
|
if err := r.validateLibrary(lib); err != nil {
|
|
return err
|
|
}
|
|
|
|
// Get the original library to check if path changed
|
|
originalLib, err := r.Get(libID)
|
|
if err != nil {
|
|
return r.mapError(err)
|
|
}
|
|
|
|
pathChanged := originalLib.Path != lib.Path
|
|
|
|
err = r.LibraryRepository.Put(lib, cols...)
|
|
if err != nil {
|
|
return r.mapError(err)
|
|
}
|
|
|
|
// Restart watcher and trigger scan if path was updated
|
|
if pathChanged {
|
|
if r.watcher != nil {
|
|
if err := r.watcher.Watch(r.ctx, lib); err != nil {
|
|
log.Warn(r.ctx, "Failed to restart watcher for updated library", "libraryID", lib.ID, "name", lib.Name, "path", lib.Path, err)
|
|
}
|
|
}
|
|
|
|
if r.scanner != nil {
|
|
go r.triggerScan(lib, "updated")
|
|
}
|
|
}
|
|
|
|
// Send library refresh event to all clients
|
|
if r.broker != nil {
|
|
event := &events.RefreshResource{}
|
|
r.broker.SendBroadcastMessage(r.ctx, event.With("library", id))
|
|
log.Debug(r.ctx, "Library updated - sent refresh event", "libraryID", libID, "name", lib.Name)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (r *libraryRepositoryWrapper) Delete(id string) error {
|
|
libID, err := strconv.Atoi(id)
|
|
if err != nil {
|
|
return &rest.ValidationError{Errors: map[string]string{
|
|
"id": "invalid library ID format",
|
|
}}
|
|
}
|
|
|
|
// Get library info before deletion for logging
|
|
lib, err := r.Get(libID)
|
|
if err != nil {
|
|
return r.mapError(err)
|
|
}
|
|
|
|
// Run the deletion in a transaction so the cascade delete and the orphaned-artist
|
|
// reconciliation it triggers (see libraryRepository.Delete) commit atomically.
|
|
err = r.ds.WithTx(func(tx model.DataStore) error {
|
|
return tx.Library(r.ctx).Delete(libID)
|
|
}, "delete library")
|
|
if err != nil {
|
|
return r.mapError(err)
|
|
}
|
|
|
|
// Stop watcher and trigger scan after successful library deletion to clean up orphaned data
|
|
if r.watcher != nil {
|
|
if err := r.watcher.StopWatching(r.ctx, libID); err != nil {
|
|
log.Warn(r.ctx, "Failed to stop watcher for deleted library", "libraryID", libID, "name", lib.Name, "path", lib.Path, err)
|
|
}
|
|
}
|
|
|
|
if r.scanner != nil {
|
|
go r.triggerScan(lib, "deleted")
|
|
}
|
|
|
|
// Send library refresh event to all clients
|
|
if r.broker != nil {
|
|
event := &events.RefreshResource{}
|
|
r.broker.SendBroadcastMessage(r.ctx, event.With("library", id))
|
|
log.Debug(r.ctx, "Library deleted - sent refresh event", "libraryID", libID, "name", lib.Name)
|
|
}
|
|
|
|
// After successful deletion, check if any plugins were auto-disabled
|
|
// and need to be unloaded from memory
|
|
r.pluginManager.UnloadDisabledPlugins(r.ctx)
|
|
|
|
return nil
|
|
}
|
|
|
|
// Helper methods
|
|
|
|
func (r *libraryRepositoryWrapper) mapError(err error) error {
|
|
if err == nil {
|
|
return nil
|
|
}
|
|
|
|
errStr := err.Error()
|
|
|
|
// Handle database constraint violations.
|
|
// TODO: Being tied to react-admin translations is not ideal, but this will probably go away with the new UI/API
|
|
if strings.Contains(errStr, "UNIQUE constraint failed") {
|
|
if strings.Contains(errStr, "library.name") {
|
|
return &rest.ValidationError{Errors: map[string]string{"name": "ra.validation.unique"}}
|
|
}
|
|
if strings.Contains(errStr, "library.path") {
|
|
return &rest.ValidationError{Errors: map[string]string{"path": "ra.validation.unique"}}
|
|
}
|
|
}
|
|
|
|
return err
|
|
}
|
|
|
|
func (r *libraryRepositoryWrapper) validateLibrary(library *model.Library) error {
|
|
validationErrors := make(map[string]string)
|
|
|
|
if library.Name == "" {
|
|
validationErrors["name"] = "ra.validation.required"
|
|
}
|
|
|
|
if library.Path == "" {
|
|
validationErrors["path"] = "ra.validation.required"
|
|
} else {
|
|
// Validate path format and accessibility
|
|
if err := r.validateLibraryPath(library); err != nil {
|
|
validationErrors["path"] = err.Error()
|
|
}
|
|
}
|
|
|
|
if len(validationErrors) > 0 {
|
|
return &rest.ValidationError{Errors: validationErrors}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (r *libraryRepositoryWrapper) validateLibraryPath(library *model.Library) error {
|
|
// Validate path format
|
|
if !filepath.IsAbs(library.Path) {
|
|
return fmt.Errorf("library path must be absolute")
|
|
}
|
|
|
|
// Clean the path to normalize it
|
|
cleanPath := filepath.Clean(library.Path)
|
|
library.Path = cleanPath
|
|
|
|
// Check if path exists and is accessible using storage abstraction
|
|
fileStore, err := storage.For(library.Path)
|
|
if err != nil {
|
|
return fmt.Errorf("invalid storage scheme: %w", err)
|
|
}
|
|
|
|
fsys, err := fileStore.FS()
|
|
if err != nil {
|
|
log.Warn(r.ctx, "Error validating library.path", "path", library.Path, err)
|
|
return fmt.Errorf("resources.library.validation.pathInvalid")
|
|
}
|
|
|
|
// Check if root directory exists
|
|
info, err := fs.Stat(fsys, ".")
|
|
if err != nil {
|
|
// Parse the error message to check for "not a directory"
|
|
log.Warn(r.ctx, "Error stating library.path", "path", library.Path, err)
|
|
errStr := err.Error()
|
|
if strings.Contains(errStr, "not a directory") ||
|
|
strings.Contains(errStr, "The directory name is invalid.") {
|
|
return fmt.Errorf("resources.library.validation.pathNotDirectory")
|
|
} else if os.IsNotExist(err) {
|
|
return fmt.Errorf("resources.library.validation.pathNotFound")
|
|
} else if os.IsPermission(err) {
|
|
return fmt.Errorf("resources.library.validation.pathNotAccessible")
|
|
} else {
|
|
return fmt.Errorf("resources.library.validation.pathInvalid")
|
|
}
|
|
}
|
|
|
|
if !info.IsDir() {
|
|
return fmt.Errorf("resources.library.validation.pathNotDirectory")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (s *libraryService) validateLibraryIDs(ctx context.Context, libraryIDs []int) error {
|
|
if len(libraryIDs) == 0 {
|
|
return nil
|
|
}
|
|
|
|
// Use CountAll to efficiently validate library IDs exist
|
|
count, err := s.ds.Library(ctx).CountAll(model.QueryOptions{
|
|
Filters: squirrel.Eq{"id": libraryIDs},
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("error validating library IDs: %w", err)
|
|
}
|
|
|
|
if int(count) != len(libraryIDs) {
|
|
return fmt.Errorf("%w: one or more library IDs are invalid", model.ErrValidation)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (r *libraryRepositoryWrapper) triggerScan(lib *model.Library, action string) {
|
|
log.Info(r.ctx, fmt.Sprintf("Triggering scan for %s library", action), "libraryID", lib.ID, "name", lib.Name, "path", lib.Path)
|
|
start := time.Now()
|
|
warnings, err := r.scanner.ScanAll(r.ctx, false) // Quick scan for new library
|
|
if err != nil {
|
|
log.Error(r.ctx, fmt.Sprintf("Error scanning %s library", action), "libraryID", lib.ID, "name", lib.Name, err)
|
|
} else {
|
|
log.Info(r.ctx, fmt.Sprintf("Scan completed for %s library", action), "libraryID", lib.ID, "name", lib.Name, "warnings", len(warnings), "elapsed", time.Since(start))
|
|
}
|
|
}
|