mirror of
https://github.com/navidrome/navidrome.git
synced 2026-10-13 12:57:10 +02:00
refactor(server): extract case-insensitive path routing to a shared helper
This commit is contained in:
parent
8e55e64337
commit
50e0f91032
4 changed files with 127 additions and 70 deletions
62
server/case_insensitive_routes.go
Normal file
62
server/case_insensitive_routes.go
Normal file
|
|
@ -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, "/")
|
||||
}
|
||||
58
server/case_insensitive_routes_test.go
Normal file
58
server/case_insensitive_routes_test.go
Normal file
|
|
@ -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"))
|
||||
})
|
||||
})
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue