diff --git a/server/case_insensitive_routes.go b/server/case_insensitive_routes.go new file mode 100644 index 000000000..04217bc9f --- /dev/null +++ b/server/case_insensitive_routes.go @@ -0,0 +1,62 @@ +package server + +import ( + "net/http" + "strings" + + "github.com/go-chi/chi/v5" +) + +// CaseInsensitivePaths wraps r with a handler that normalizes each request path's literal +// segments to the case they were registered with, then delegates to r. This is needed because +// some clients (e.g. real Jellyfin clients) route case-insensitively while chi's default matching +// is case-sensitive. Chi param placeholders (e.g. "{itemId}") are never treated as literals, so +// id segments always pass through untouched. +func CaseInsensitivePaths(r chi.Router) http.Handler { + canon := canonicalRouteSegments(r) + return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + normalizeRequestPath(req, canon) + r.ServeHTTP(w, req) + }) +} + +// canonicalRouteSegments walks every registered route and records, for each literal (non-param) +// "/"-separated segment, the case it was registered with, keyed by its lower-cased form (e.g. +// "audio" -> "Audio"). +func canonicalRouteSegments(router chi.Router) map[string]string { + canon := map[string]string{} + _ = chi.Walk(router, func(_, route string, _ http.Handler, _ ...func(http.Handler) http.Handler) error { + for seg := range strings.SplitSeq(route, "/") { + if seg == "" || strings.Contains(seg, "{") { + continue + } + canon[strings.ToLower(seg)] = seg + } + return nil + }) + return canon +} + +// normalizeRequestPath rewrites literal path segments to the case routes were registered with. +// It must run before chi's matching. When the router is mounted under a parent, chi has already +// stripped the mount prefix and matches against RouteContext.RoutePath rather than r.URL.Path, so +// that's what must be normalized here. +func normalizeRequestPath(r *http.Request, canon map[string]string) { + if rctx := chi.RouteContext(r.Context()); rctx != nil && rctx.RoutePath != "" { + rctx.RoutePath = normalizeCase(rctx.RoutePath, canon) + return + } + r.URL.Path = normalizeCase(r.URL.Path, canon) +} + +// normalizeCase rewrites each "/"-separated literal segment of path to the case it was +// registered with in canon. Segments with no match (e.g. case-sensitive ids) are left untouched. +func normalizeCase(path string, canon map[string]string) string { + segs := strings.Split(path, "/") + for i, seg := range segs { + if canonical, ok := canon[strings.ToLower(seg)]; ok { + segs[i] = canonical + } + } + return strings.Join(segs, "/") +} diff --git a/server/case_insensitive_routes_test.go b/server/case_insensitive_routes_test.go new file mode 100644 index 000000000..ebf99357b --- /dev/null +++ b/server/case_insensitive_routes_test.go @@ -0,0 +1,58 @@ +package server + +import ( + "net/http" + "net/http/httptest" + + "github.com/go-chi/chi/v5" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("CaseInsensitivePaths", func() { + var handler http.Handler + var gotID string + + BeforeEach(func() { + gotID = "" + r := chi.NewRouter() + r.Get("/Foo/{id}/Bar", func(w http.ResponseWriter, req *http.Request) { + gotID = chi.URLParam(req, "id") + w.WriteHeader(http.StatusOK) + }) + handler = CaseInsensitivePaths(r) + }) + + It("matches a lower-cased request path against mixed-case registered literals", func() { + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/foo/ID/bar", nil) + handler.ServeHTTP(w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + }) + + It("preserves the id segment's original casing", func() { + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/foo/ID/bar", nil) + handler.ServeHTTP(w, r) + Expect(gotID).To(Equal("ID")) + }) + + It("leaves a real mixed-case id untouched while still matching literals", func() { + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/foo/cjsFeXbNOaaSjASu3DM93g/bar", nil) + handler.ServeHTTP(w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(gotID).To(Equal("cjsFeXbNOaaSjASu3DM93g")) + }) +}) + +var _ = Describe("normalizeCase", func() { + It("rewrites known literal segments to their canonical case", func() { + canon := map[string]string{ + "audio": "Audio", + "stream": "stream", + } + got := normalizeCase("/audio/XyZ123NotARoute/STREAM", canon) + Expect(got).To(Equal("/Audio/XyZ123NotARoute/stream")) + }) +}) diff --git a/server/jellyfin/api.go b/server/jellyfin/api.go index 5487fe0aa..b7cf7311f 100644 --- a/server/jellyfin/api.go +++ b/server/jellyfin/api.go @@ -3,7 +3,6 @@ package jellyfin import ( "encoding/json" "net/http" - "strings" "sync" "github.com/go-chi/chi/v5" @@ -14,6 +13,7 @@ import ( "github.com/navidrome/navidrome/core/stream" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/server" ) type Router struct { @@ -27,10 +27,6 @@ type Router struct { playlists playlists.Playlists serverIDOnce sync.Once serverIDVal string - // canon maps each lower-cased literal route segment to the case it was registered with - // (e.g. "audio" -> "Audio"). Populated by routes(); exposed here mainly so tests can - // verify normalizeCase against the real, registered route table. - canon map[string]string } func New(ds model.DataStore, artwork artwork.Artwork, streamer stream.MediaStreamer, @@ -111,54 +107,8 @@ func (api *Router) routes() http.Handler { inner.NotFound(api.notFound) inner.MethodNotAllowed(api.notFound) - api.canon = canonicalRouteSegments(inner) - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - normalizeRequestPath(r, api.canon) - inner.ServeHTTP(w, r) - }) -} - -// canonicalRouteSegments walks every registered route and records, for each literal (non-param) -// "/"-separated segment, the case it was registered with, keyed by its lower-cased form (e.g. -// "audio" -> "Audio"). Chi param placeholders (e.g. "{itemId}") are skipped so id segments are -// never treated as literals. -func canonicalRouteSegments(router chi.Router) map[string]string { - canon := map[string]string{} - _ = chi.Walk(router, func(_, route string, _ http.Handler, _ ...func(http.Handler) http.Handler) error { - for _, seg := range strings.Split(route, "/") { - if seg == "" || strings.Contains(seg, "{") { - continue - } - canon[strings.ToLower(seg)] = seg - } - return nil - }) - return canon -} - -// normalizeRequestPath rewrites literal path segments to the case routes were registered with, -// since real Jellyfin clients route case-insensitively while chi does not. It must run before -// chi's matching. When this router is mounted under a parent (as it is in production via -// server.MountRouter), chi has already stripped the mount prefix and matches against -// RouteContext.RoutePath rather than r.URL.Path, so that's what must be normalized here. -func normalizeRequestPath(r *http.Request, canon map[string]string) { - if rctx := chi.RouteContext(r.Context()); rctx != nil && rctx.RoutePath != "" { - rctx.RoutePath = normalizeCase(rctx.RoutePath, canon) - return - } - r.URL.Path = normalizeCase(r.URL.Path, canon) -} - -// normalizeCase rewrites each "/"-separated literal segment of path to the case it was -// registered with in canon. Segments with no match (e.g. case-sensitive ids) are left untouched. -func normalizeCase(path string, canon map[string]string) string { - segs := strings.Split(path, "/") - for i, seg := range segs { - if canonical, ok := canon[strings.ToLower(seg)]; ok { - segs[i] = canonical - } - } - return strings.Join(segs, "/") + // Real Jellyfin clients route case-insensitively; chi does not. + return server.CaseInsensitivePaths(inner) } // ok writes payload as JSON. diff --git a/server/jellyfin/routing_test.go b/server/jellyfin/routing_test.go index 9f0f8e6c8..70351cef2 100644 --- a/server/jellyfin/routing_test.go +++ b/server/jellyfin/routing_test.go @@ -11,9 +11,10 @@ import ( ) // Real Jellyfin servers route path segments case-insensitively, but chi's default matching is -// case-sensitive. These tests prove requests using non-canonical casing are still routed -// correctly, both when the router is used directly and when mounted under a parent (as it is in -// production via server.MountRouter), while case-sensitive id segments are left untouched. +// case-sensitive. Jellyfin wires up server.CaseInsensitivePaths (see server/case_insensitive_routes.go +// for the unit-level tests of that helper) to work around this. These tests are an end-to-end proof +// that requests using non-canonical casing are still routed correctly, both when the router is used +// directly and when mounted under a parent (as it is in production via server.MountRouter). var _ = Describe("Case-insensitive routing", func() { var api *Router @@ -52,18 +53,4 @@ var _ = Describe("Case-insensitive routing", func() { parent.ServeHTTP(w, r) Expect(w.Code).To(Equal(http.StatusOK)) }) - - It("preserves a case-sensitive id segment while correcting literal casing, using the real route table", func() { - got := normalizeCase("/audio/cjsFeXbNOaaSjASu3DM93g/stream", api.canon) - Expect(got).To(Equal("/Audio/cjsFeXbNOaaSjASu3DM93g/stream")) - }) - - It("leaves an unrecognized (id) segment untouched while correcting known literals (synthetic canon)", func() { - canon := map[string]string{ - "audio": "Audio", - "stream": "stream", - } - got := normalizeCase("/audio/XyZ123NotARoute/STREAM", canon) - Expect(got).To(Equal("/Audio/XyZ123NotARoute/stream")) - }) })