fix(server): return 404 instead of 500 for missing native API resources (#6131)

* 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".
This commit is contained in:
Deluan Quintão 2026-09-14 22:46:21 -04:00 • committed by GitHub
commit 3f4b6a642c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
23 changed files with 124 additions and 117 deletions

View file

@ -2,7 +2,6 @@ package core
import (
"context"
"errors"
"fmt"
"io/fs"
"os"
@ -307,14 +306,7 @@ func (r *libraryRepositoryWrapper) mapError(err error) error {
}
}
switch {
case errors.Is(err, model.ErrNotFound):
return rest.ErrNotFound
case errors.Is(err, model.ErrNotAuthorized):
return rest.ErrPermissionDenied
default:
return err
}
return err
}
func (r *libraryRepositoryWrapper) validateLibrary(library *model.Library) error {

View file

@ -2,7 +2,6 @@ package playlists
import (
"context"
"errors"
"reflect"
"strings"
@ -41,15 +40,7 @@ func (r *playlistRepositoryWrapper) Update(id string, entity any, cols ...string
}
func (r *playlistRepositoryWrapper) Delete(id string) error {
err := r.service.Delete(r.ctx, id)
switch {
case errors.Is(err, model.ErrNotFound):
return rest.ErrNotFound
case errors.Is(err, model.ErrNotAuthorized):
return rest.ErrPermissionDenied
default:
return err
}
return r.service.Delete(r.ctx, id)
}
func (s *playlists) TracksRepository(ctx context.Context, playlistId string, refreshSmartPlaylist bool) rest.Repository {
@ -92,14 +83,7 @@ func (s *playlists) savePlaylist(ctx context.Context, pls *model.Playlist) (stri
func (s *playlists) updatePlaylistEntity(ctx context.Context, id string, entity *model.Playlist, cols ...string) error {
current, err := s.checkWritable(ctx, id)
if err != nil {
switch {
case errors.Is(err, model.ErrNotFound):
return rest.ErrNotFound
case errors.Is(err, model.ErrNotAuthorized):
return rest.ErrPermissionDenied
default:
return err
}
return err
}
sent := sentFields(cols)

2
go.mod
View file

@ -9,7 +9,7 @@ require (
github.com/Masterminds/squirrel v1.5.4
github.com/andybalholm/cascadia v1.3.5
github.com/bmatcuk/doublestar/v4 v4.10.0
github.com/deluan/rest v0.0.0-20211102003136-6260bc399cbf
github.com/deluan/rest v0.0.0-20260913134927-47b21f30cc12
github.com/deluan/sanitize v0.0.0-20241120162836-fdfd8fdfaa55
github.com/dexterlb/mpvipc v0.0.0-20260722094525-0cf47d745b36
github.com/djherbis/atime v1.1.0

4
go.sum
View file

@ -31,8 +31,8 @@ github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 h1:5RVFMOWjMyRy8cARdy79nAmgYw3h
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40=
github.com/deluan/go-taglib v0.0.0-20260913142955-d55e0c9353cb h1:CGVY6RtDsqaleUFogGP03m3a/9OKi3ZMDr5nhm51Emk=
github.com/deluan/go-taglib v0.0.0-20260913142955-d55e0c9353cb/go.mod h1:QGxQ4Z1IWyY9w56xNEFjYAaWE8uSxA/gneQ7RPcFJrY=
github.com/deluan/rest v0.0.0-20211102003136-6260bc399cbf h1:tb246l2Zmpt/GpF9EcHCKTtwzrd0HGfEmoODFA/qnk4=
github.com/deluan/rest v0.0.0-20211102003136-6260bc399cbf/go.mod h1:tSgDythFsl0QgS/PFWfIZqcJKnkADWneY80jaVRlqK8=
github.com/deluan/rest v0.0.0-20260913134927-47b21f30cc12 h1:x4N/tx0XC9zcnipk5rpdI4qaG9JmovHW5HL97XYnO1I=
github.com/deluan/rest v0.0.0-20260913134927-47b21f30cc12/go.mod h1:tSgDythFsl0QgS/PFWfIZqcJKnkADWneY80jaVRlqK8=
github.com/deluan/sanitize v0.0.0-20241120162836-fdfd8fdfaa55 h1:wSCnggTs2f2ji6nFwQmfwgINcmSMj0xF0oHnoyRSPe4=
github.com/deluan/sanitize v0.0.0-20241120162836-fdfd8fdfaa55/go.mod h1:ZNCLJfehvEf34B7BbLKjgpsL9lyW7q938w/GY1XgV4E=
github.com/dexterlb/mpvipc v0.0.0-20260722094525-0cf47d745b36 h1:KtPfdSST6e0vJbMzMmVqPa5mO1u8vMBlybRCW2ieXpA=

View file

@ -1,11 +1,15 @@
package model
import "errors"
import (
"errors"
"github.com/deluan/rest"
)
var (
ErrNotFound = errors.New("data not found")
ErrNotFound = rest.ErrNotFound
ErrInvalidAuth = errors.New("invalid authentication")
ErrNotAuthorized = errors.New("not authorized")
ErrNotAuthorized = rest.ErrPermissionDenied
ErrExpired = errors.New("access expired")
ErrNotAvailable = errors.New("functionality not available")
ErrValidation = errors.New("validation error")

View file

@ -2,7 +2,6 @@ package persistence
import (
"context"
"errors"
. "github.com/Masterminds/squirrel"
"github.com/deluan/rest"
@ -127,11 +126,7 @@ func (r *playerRepository) Save(entity any) (string, error) {
if !r.isPermitted(t) {
return "", rest.ErrPermissionDenied
}
id, err := r.put(t.ID, t)
if errors.Is(err, model.ErrNotFound) {
return "", rest.ErrNotFound
}
return id, err
return r.put(t.ID, t)
}
func (r *playerRepository) Update(id string, entity any, cols ...string) error {

View file

@ -416,9 +416,6 @@ func (r *playlistRepository) Update(id string, entity any, cols ...string) error
pls.ID = id
pls.UpdatedAt = time.Now()
_, err := r.put(id, pls, append(cols, "updatedAt")...)
if errors.Is(err, model.ErrNotFound) {
return rest.ErrNotFound
}
return err
}

View file

@ -2,7 +2,6 @@ package persistence
import (
"context"
"errors"
"time"
. "github.com/Masterminds/squirrel"
@ -47,7 +46,7 @@ func (r *radioRepository) Delete(id string) error {
return rest.ErrPermissionDenied
}
return r.delete(Eq{"id": id})
return r.deleteByID(id)
}
func (r *radioRepository) Get(id string) (*model.Radio, error) {
@ -132,9 +131,6 @@ func (r *radioRepository) Save(entity any) (string, error) {
return "", rest.ErrPermissionDenied
}
err := r.Put(t)
if errors.Is(err, model.ErrNotFound) {
return "", rest.ErrNotFound
}
return t.ID, err
}
@ -144,11 +140,7 @@ func (r *radioRepository) Update(id string, entity any, cols ...string) error {
if !r.isPermitted() {
return rest.ErrPermissionDenied
}
err := r.Put(t, cols...)
if errors.Is(err, model.ErrNotFound) {
return rest.ErrNotFound
}
return err
return r.Put(t, cols...)
}
var _ model.RadioRepository = (*radioRepository)(nil)

View file

@ -52,6 +52,10 @@ var _ = Describe("RadioRepository", func() {
_, err = repo.Get(radioWithHomePage.ID)
Expect(err).To(MatchError(model.ErrNotFound))
})
It("errors when missing", func() {
Expect(repo.Delete("notanid")).To(MatchError(model.ErrNotFound))
})
})
Describe("Get", func() {

View file

@ -2,7 +2,6 @@ package persistence
import (
"context"
"errors"
"fmt"
"strings"
"time"
@ -178,11 +177,7 @@ func (r *shareRepository) Save(entity any) (string, error) {
}
s.CreatedAt = time.Now()
s.UpdatedAt = time.Now()
id, err := r.put(s.ID, s)
if errors.Is(err, model.ErrNotFound) {
return "", rest.ErrNotFound
}
return id, err
return r.put(s.ID, s)
}
func (r *shareRepository) CountAll(options ...model.QueryOptions) (int64, error) {

View file

@ -614,12 +614,20 @@ func (r sqlRepository) put(id string, m any, colsToUpdate ...string) (newId stri
}
func (r sqlRepository) delete(cond Sqlizer) error {
del := Delete(r.tableName).Where(cond)
_, err := r.executeSQL(del)
if errors.Is(err, sql.ErrNoRows) {
_, err := r.executeSQL(Delete(r.tableName).Where(cond))
return err
}
// deleteByID is for single-item deletes that must report a missing row; delete succeeds silently.
func (r sqlRepository) deleteByID(id string) error {
count, err := r.executeSQL(Delete(r.tableName).Where(Eq{"id": id}))
if err != nil {
return err
}
if count == 0 {
return model.ErrNotFound
}
return err
return nil
}
func (r sqlRepository) logSQL(sql string, args dbx.Params, err error, rowsAffected int64, start time.Time) {

View file

@ -2,7 +2,6 @@ package persistence
import (
"context"
"errors"
. "github.com/Masterminds/squirrel"
"github.com/deluan/rest"
@ -91,11 +90,7 @@ func (r *transcodingRepository) Save(entity any) (string, error) {
return "", rest.ErrPermissionDenied
}
t := entity.(*model.Transcoding)
id, err := r.put(t.ID, t)
if errors.Is(err, model.ErrNotFound) {
return "", rest.ErrNotFound
}
return id, err
return r.put(t.ID, t)
}
func (r *transcodingRepository) Update(id string, entity any, cols ...string) error {
@ -105,9 +100,6 @@ func (r *transcodingRepository) Update(id string, entity any, cols ...string) er
t := entity.(*model.Transcoding)
t.ID = id
_, err := r.put(id, t)
if errors.Is(err, model.ErrNotFound) {
return rest.ErrNotFound
}
return err
}
@ -115,11 +107,7 @@ func (r *transcodingRepository) Delete(id string) error {
if !loggedUser(r.ctx).IsAdmin {
return rest.ErrPermissionDenied
}
err := r.delete(Eq{"id": id})
if errors.Is(err, model.ErrNotFound) {
return rest.ErrNotFound
}
return err
return r.deleteByID(id)
}
var _ model.TranscodingRepository = (*transcodingRepository)(nil)

View file

@ -65,6 +65,11 @@ var _ = Describe("TranscodingRepository", func() {
Expect(err).To(MatchError(model.ErrNotFound))
})
It("returns not found when deleting a missing transcoding", func() {
err := adminRepo.(*transcodingRepository).Delete("does-not-exist")
Expect(err).To(MatchError(model.ErrNotFound))
})
It("reads the Command field via the REST Read method", func() {
tr := &model.Transcoding{ID: "adminread", Name: "temp", TargetFormat: "test_format", DefaultBitRate: 64, Command: "ffmpeg -secret"}
Expect(adminRepo.Put(tr)).To(Succeed())

View file

@ -244,11 +244,7 @@ func (r *userRepository) Read(id string) (any, error) {
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
return r.Get(id)
}
func (r *userRepository) ReadAll(options ...rest.QueryOptions) (any, error) {
@ -314,11 +310,7 @@ func (r *userRepository) Update(id string, entity any, _ ...string) error {
if err := validateScrobbleFilter(u); err != nil {
return err
}
err := r.Put(u)
if errors.Is(err, model.ErrNotFound) {
return rest.ErrNotFound
}
return err
return r.Put(u)
}
func validatePasswordChange(newUser *model.User, logged *model.User) error {
@ -393,11 +385,7 @@ func (r *userRepository) Delete(id string) error {
if !usr.IsAdmin {
return rest.ErrPermissionDenied
}
err := r.delete(Eq{"id": id})
if errors.Is(err, model.ErrNotFound) {
return rest.ErrNotFound
}
if err != nil {
if err := r.deleteByID(id); err != nil {
return err
}

View file

@ -230,6 +230,14 @@ var _ = Describe("UserRepository", func() {
})
})
Describe("Delete", func() {
It("returns not found for a missing user", func() {
adminCtx := request.WithUser(log.NewContext(GinkgoT().Context()), adminUser)
adminRepo := NewUserRepository(adminCtx, GetDBXBuilder()).(*userRepository)
Expect(adminRepo.Delete("does-not-exist")).To(MatchError(model.ErrNotFound))
})
})
Describe("ReadAll name filter", func() {
var adminRepo model.ResourceRepository

View file

@ -6,7 +6,6 @@ import (
"maps"
"net/http"
"github.com/Masterminds/squirrel"
"github.com/deluan/rest"
"github.com/navidrome/navidrome/core"
"github.com/navidrome/navidrome/log"
@ -46,17 +45,14 @@ func (r *missingRepository) parseOptions(options []rest.QueryOptions) rest.Query
}
func (r *missingRepository) Read(id string) (any, error) {
all, err := r.mfRepo.GetAll(model.QueryOptions{Filters: squirrel.And{
squirrel.Eq{"id": id},
squirrel.Eq{"missing": true},
}})
mf, err := r.mfRepo.Get(id)
if err != nil {
return nil, err
}
if len(all) == 0 {
if !mf.Missing {
return nil, model.ErrNotFound
}
return all[0], nil
return mf, nil
}
func (r *missingRepository) EntityName() string {

View file

@ -0,0 +1,56 @@
package nativeapi
import (
"bytes"
"net/http"
"net/http/httptest"
"time"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
"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("Missing Files Endpoint", func() {
var router http.Handler
var token string
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
conf.Server.SessionTimeout = time.Minute
conf.Server.EnableSharing = false
mfRepo := tests.CreateMockMediaFileRepo()
mfRepo.SetData(model.MediaFiles{
{ID: "missing-1", Title: "Gone", Missing: true},
{ID: "present-1", Title: "Here"},
})
userRepo := tests.CreateMockUserRepo()
ds := &tests.MockDataStore{MockedMediaFile: mfRepo, MockedUser: userRepo, MockedProperty: &tests.MockedPropertyRepo{}}
auth.Init(ds)
user := model.User{ID: "user-1", UserName: "user", NewPassword: "pass"}
Expect(userRepo.Put(&user)).To(Succeed())
var err error
token, err = auth.CreateToken(&user)
Expect(err).ToNot(HaveOccurred())
router = server.JWTVerifier(New(ds, nil, nil, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, nil, nil, nil))
})
DescribeTable("GET /missing/{id}",
func(id string, status int) {
w := httptest.NewRecorder()
router.ServeHTTP(w, createAuthenticatedRequest(http.MethodGet, "/missing/"+id, &bytes.Buffer{}, token))
Expect(w.Code).To(Equal(status), w.Body.String())
},
Entry("returns a missing file", "missing-1", http.StatusOK),
Entry("returns 404 for a file that is not missing", "present-1", http.StatusNotFound),
Entry("returns 404 for an unknown id", "unknown", http.StatusNotFound),
)
})

View file

@ -192,6 +192,7 @@ var _ = Describe("writePlaylistError", func() {
},
Entry("not found -> 404", model.ErrNotFound, http.StatusNotFound),
Entry("not authorized -> 403", model.ErrNotAuthorized, http.StatusForbidden),
Entry("rest permission denied -> 403", rest.ErrPermissionDenied, http.StatusForbidden),
Entry("not editable -> 409", model.ErrPlaylistNotEditable, http.StatusConflict),
Entry("unrecognized -> default", model.ErrValidation, http.StatusBadRequest),
)

View file

@ -9,7 +9,6 @@ import (
"regexp"
"strconv"
"github.com/deluan/rest"
"github.com/go-chi/chi/v5"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/core"
@ -302,10 +301,9 @@ func mapToSubsonicError(err error) subError {
err = newError(responses.ErrorMissingParameter, err.Error())
case errors.Is(err, req.ErrInvalidParam):
err = newError(responses.ErrorGeneric, err.Error())
case errors.Is(err, model.ErrNotFound), errors.Is(err, rest.ErrNotFound):
case errors.Is(err, model.ErrNotFound):
err = newError(responses.ErrorDataNotFound, "data not found")
case errors.Is(err, model.ErrNotAuthorized), errors.Is(err, rest.ErrPermissionDenied),
errors.Is(err, model.ErrPlaylistNotEditable): // Subsonic has no code for "read-only resource"
case errors.Is(err, model.ErrNotAuthorized), errors.Is(err, model.ErrPlaylistNotEditable): // Subsonic has no code for "read-only resource"
err = newError(responses.ErrorAuthorizationFail)
case errors.Is(err, stream.ErrTooManyTranscodes):
err = newError(responses.ErrorGeneric, "too many concurrent transcodes, please retry shortly")

View file

@ -130,4 +130,12 @@ var _ = Describe("Internet Radio Endpoints", Ordered, func() {
Expect(resp.InternetRadioStations).ToNot(BeNil())
Expect(resp.InternetRadioStations.Radios).To(BeEmpty())
})
It("deleteInternetRadioStation returns not found for a missing station", func() {
resp := doReq("deleteInternetRadioStation", "id", radioID)
Expect(resp.Status).To(Equal(responses.StatusFailed))
Expect(resp.Error).ToNot(BeNil())
Expect(resp.Error.Code).To(Equal(responses.ErrorDataNotFound))
})
})

View file

@ -172,11 +172,7 @@ func (m *MockLibraryRepo) Count(options ...rest.QueryOptions) (int64, error) {
func (m *MockLibraryRepo) Read(id string) (any, error) {
idInt, _ := strconv.Atoi(id)
mf, err := m.Get(idInt)
if errors.Is(err, model.ErrNotFound) {
return nil, rest.ErrNotFound
}
return mf, err
return m.Get(idInt)
}
func (m *MockLibraryRepo) ReadAll(options ...rest.QueryOptions) (any, error) {

View file

@ -302,11 +302,7 @@ func (m *MockMediaFileRepo) Count(...rest.QueryOptions) (int64, error) {
}
func (m *MockMediaFileRepo) Read(id string) (any, error) {
mf, err := m.Get(id)
if errors.Is(err, model.ErrNotFound) {
return nil, rest.ErrNotFound
}
return mf, err
return m.Get(id)
}
func (m *MockMediaFileRepo) ReadAll(...rest.QueryOptions) (any, error) {

View file

@ -69,11 +69,7 @@ func (m *MockPluginRepo) Get(id string) (*model.Plugin, error) {
}
func (m *MockPluginRepo) Read(id string) (any, error) {
p, err := m.Get(id)
if errors.Is(err, model.ErrNotFound) {
return nil, rest.ErrNotFound
}
return p, err
return m.Get(id)
}
func (m *MockPluginRepo) Put(p *model.Plugin) error {