diff --git a/core/publicurl/publicurl.go b/core/publicurl/publicurl.go new file mode 100644 index 000000000..b92727c3b --- /dev/null +++ b/core/publicurl/publicurl.go @@ -0,0 +1,70 @@ +package publicurl + +import ( + "cmp" + "net/http" + "net/url" + "path" + "strconv" + "strings" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/core/auth" + "github.com/navidrome/navidrome/model" +) + +// ImageURL generates a public URL for artwork images. +// It creates a signed token for the artwork ID and builds a complete public URL. +func ImageURL(req *http.Request, artID model.ArtworkID, size int) string { + token, _ := auth.CreatePublicToken(map[string]any{"id": artID.String()}) + uri := path.Join(consts.URLPathPublicImages, token) + params := url.Values{} + if size > 0 { + params.Add("size", strconv.Itoa(size)) + } + return PublicURL(req, uri, params) +} + +// PublicURL builds a full URL for public-facing resources. +// It uses ShareURL from config if available, otherwise falls back to extracting +// the scheme and host from the provided http.Request. +// If req is nil and ShareURL is not set, it defaults to http://localhost. +func PublicURL(req *http.Request, u string, params url.Values) string { + if conf.Server.ShareURL != "" { + shareUrl, _ := url.Parse(conf.Server.ShareURL) + buildUrl, _ := url.Parse(u) + buildUrl.Scheme = shareUrl.Scheme + buildUrl.Host = shareUrl.Host + if len(params) > 0 { + buildUrl.RawQuery = params.Encode() + } + return buildUrl.String() + } + return AbsoluteURL(req, u, params) +} + +// AbsoluteURL builds an absolute URL from a relative path. +// It uses BaseHost/BaseScheme from config if available, otherwise extracts +// the scheme and host from the http.Request. +// If req is nil and BaseHost is not set, it defaults to http://localhost. +func AbsoluteURL(req *http.Request, u string, params url.Values) string { + buildUrl, _ := url.Parse(u) + if strings.HasPrefix(u, "/") { + buildUrl.Path = path.Join(conf.Server.BasePath, buildUrl.Path) + if conf.Server.BaseHost != "" { + buildUrl.Scheme = cmp.Or(conf.Server.BaseScheme, "http") + buildUrl.Host = conf.Server.BaseHost + } else if req != nil { + buildUrl.Scheme = req.URL.Scheme + buildUrl.Host = req.Host + } else { + buildUrl.Scheme = "http" + buildUrl.Host = "localhost" + } + } + if len(params) > 0 { + buildUrl.RawQuery = params.Encode() + } + return buildUrl.String() +} diff --git a/core/publicurl/publicurl_test.go b/core/publicurl/publicurl_test.go new file mode 100644 index 000000000..18f8f8129 --- /dev/null +++ b/core/publicurl/publicurl_test.go @@ -0,0 +1,174 @@ +package publicurl_test + +import ( + "net/http" + "net/url" + "testing" + + "github.com/go-chi/jwtauth/v5" + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/core/auth" + "github.com/navidrome/navidrome/core/publicurl" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestPublicURL(t *testing.T) { + tests.Init(t, false) + log.SetLevel(log.LevelFatal) + RegisterFailHandler(Fail) + RunSpecs(t, "Public URL Suite") +} + +var _ = Describe("Public URL Utilities", func() { + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + }) + + Describe("PublicURL", func() { + When("ShareURL is set", func() { + BeforeEach(func() { + conf.Server.ShareURL = "https://share.example.com" + }) + + It("uses ShareURL as the base", func() { + r, _ := http.NewRequest("GET", "http://localhost/test", nil) + result := publicurl.PublicURL(r, "/path/to/resource", nil) + Expect(result).To(Equal("https://share.example.com/path/to/resource")) + }) + + It("includes query parameters", func() { + r, _ := http.NewRequest("GET", "http://localhost/test", nil) + params := url.Values{"size": []string{"300"}, "format": []string{"png"}} + result := publicurl.PublicURL(r, "/image/123", params) + Expect(result).To(ContainSubstring("https://share.example.com/image/123")) + Expect(result).To(ContainSubstring("size=300")) + Expect(result).To(ContainSubstring("format=png")) + }) + + It("works without a request", func() { + result := publicurl.PublicURL(nil, "/path/to/resource", nil) + Expect(result).To(Equal("https://share.example.com/path/to/resource")) + }) + }) + + When("ShareURL is not set", func() { + BeforeEach(func() { + conf.Server.ShareURL = "" + }) + + It("falls back to AbsoluteURL with request", func() { + r, _ := http.NewRequest("GET", "https://myserver.com/test", nil) + r.Host = "myserver.com" + result := publicurl.PublicURL(r, "/path/to/resource", nil) + Expect(result).To(Equal("https://myserver.com/path/to/resource")) + }) + + It("falls back to localhost without request", func() { + result := publicurl.PublicURL(nil, "/path/to/resource", nil) + Expect(result).To(Equal("http://localhost/path/to/resource")) + }) + }) + }) + + Describe("AbsoluteURL", func() { + When("BaseHost is set", func() { + BeforeEach(func() { + conf.Server.BaseHost = "configured.example.com" + conf.Server.BaseScheme = "https" + conf.Server.BasePath = "" + }) + + It("uses BaseHost and BaseScheme", func() { + r, _ := http.NewRequest("GET", "http://localhost/test", nil) + result := publicurl.AbsoluteURL(r, "/path/to/resource", nil) + Expect(result).To(Equal("https://configured.example.com/path/to/resource")) + }) + + It("defaults to http scheme if BaseScheme is empty", func() { + conf.Server.BaseScheme = "" + r, _ := http.NewRequest("GET", "http://localhost/test", nil) + result := publicurl.AbsoluteURL(r, "/path/to/resource", nil) + Expect(result).To(Equal("http://configured.example.com/path/to/resource")) + }) + }) + + When("BaseHost is not set", func() { + BeforeEach(func() { + conf.Server.BaseHost = "" + conf.Server.BasePath = "" + }) + + It("extracts host from request", func() { + r, _ := http.NewRequest("GET", "https://request.example.com/test", nil) + r.Host = "request.example.com" + result := publicurl.AbsoluteURL(r, "/path/to/resource", nil) + Expect(result).To(Equal("https://request.example.com/path/to/resource")) + }) + + It("falls back to localhost without request", func() { + result := publicurl.AbsoluteURL(nil, "/path/to/resource", nil) + Expect(result).To(Equal("http://localhost/path/to/resource")) + }) + }) + + When("BasePath is set", func() { + BeforeEach(func() { + conf.Server.BasePath = "/navidrome" + conf.Server.BaseHost = "example.com" + conf.Server.BaseScheme = "https" + }) + + It("prepends BasePath to the URL", func() { + r, _ := http.NewRequest("GET", "http://localhost/test", nil) + result := publicurl.AbsoluteURL(r, "/path/to/resource", nil) + Expect(result).To(Equal("https://example.com/navidrome/path/to/resource")) + }) + }) + + It("passes through absolute URLs unchanged", func() { + r, _ := http.NewRequest("GET", "http://localhost/test", nil) + result := publicurl.AbsoluteURL(r, "https://other.example.com/path", nil) + Expect(result).To(Equal("https://other.example.com/path")) + }) + + It("includes query parameters", func() { + conf.Server.BaseHost = "example.com" + conf.Server.BaseScheme = "https" + r, _ := http.NewRequest("GET", "http://localhost/test", nil) + params := url.Values{"key": []string{"value"}} + result := publicurl.AbsoluteURL(r, "/path", params) + Expect(result).To(Equal("https://example.com/path?key=value")) + }) + }) + + Describe("ImageURL", func() { + BeforeEach(func() { + conf.Server.ShareURL = "https://share.example.com" + // Initialize JWT auth for token generation + auth.TokenAuth = jwtauth.New("HS256", []byte("test secret"), nil) + }) + + It("generates a URL with the artwork token", func() { + artID := model.NewArtworkID(model.KindAlbumArtwork, "album-123", nil) + result := publicurl.ImageURL(nil, artID, 0) + Expect(result).To(HavePrefix("https://share.example.com/share/img/")) + }) + + It("includes size parameter when provided", func() { + artID := model.NewArtworkID(model.KindArtistArtwork, "artist-1", nil) + result := publicurl.ImageURL(nil, artID, 300) + Expect(result).To(ContainSubstring("size=300")) + }) + + It("omits size parameter when zero", func() { + artID := model.NewArtworkID(model.KindMediaFileArtwork, "track-1", nil) + result := publicurl.ImageURL(nil, artID, 0) + Expect(result).ToNot(ContainSubstring("size=")) + }) + }) +}) diff --git a/plugins/host_artwork.go b/plugins/host_artwork.go index b1ce838c8..49b9a285d 100644 --- a/plugins/host_artwork.go +++ b/plugins/host_artwork.go @@ -2,15 +2,8 @@ package plugins import ( "context" - "fmt" - "net/http" - "net/url" - "path" - "strconv" - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/consts" - "github.com/navidrome/navidrome/core/auth" + "github.com/navidrome/navidrome/core/publicurl" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/plugins/host" ) @@ -23,70 +16,22 @@ func newArtworkService() host.ArtworkService { func (a *artworkServiceImpl) GetArtistUrl(_ context.Context, id string, size int32) (string, error) { artID := model.ArtworkID{Kind: model.KindArtistArtwork, ID: id} - return a.imageURL(artID, int(size)), nil + return publicurl.ImageURL(nil, artID, int(size)), nil } func (a *artworkServiceImpl) GetAlbumUrl(_ context.Context, id string, size int32) (string, error) { artID := model.ArtworkID{Kind: model.KindAlbumArtwork, ID: id} - return a.imageURL(artID, int(size)), nil + return publicurl.ImageURL(nil, artID, int(size)), nil } func (a *artworkServiceImpl) GetTrackUrl(_ context.Context, id string, size int32) (string, error) { artID := model.ArtworkID{Kind: model.KindMediaFileArtwork, ID: id} - return a.imageURL(artID, int(size)), nil + return publicurl.ImageURL(nil, artID, int(size)), nil } func (a *artworkServiceImpl) GetPlaylistUrl(_ context.Context, id string, size int32) (string, error) { artID := model.ArtworkID{Kind: model.KindPlaylistArtwork, ID: id} - return a.imageURL(artID, int(size)), nil -} - -// imageURL generates a public URL for artwork, replicating the logic from server/public -// to avoid import cycles. -func (a *artworkServiceImpl) imageURL(artID model.ArtworkID, size int) string { - token, _ := auth.CreatePublicToken(map[string]any{"id": artID.String()}) - uri := path.Join(consts.URLPathPublicImages, token) - params := url.Values{} - if size > 0 { - params.Add("size", strconv.Itoa(size)) - } - return a.publicURL(uri, params) -} - -// publicURL builds the full URL using ShareURL config or falling back to localhost. -func (a *artworkServiceImpl) publicURL(u string, params url.Values) string { - var scheme, host string - if conf.Server.ShareURL != "" { - shareURL, _ := url.Parse(conf.Server.ShareURL) - scheme = shareURL.Scheme - host = shareURL.Host - } else { - scheme = "http" - host = "localhost" - } - buildURL, _ := url.Parse(u) - buildURL.Scheme = scheme - buildURL.Host = host - if len(params) > 0 { - buildURL.RawQuery = params.Encode() - } - return buildURL.String() -} - -// createRequest creates a dummy HTTP request for URL generation. -// Kept for reference but no longer used after refactoring. -func (a *artworkServiceImpl) createRequest() *http.Request { - var scheme, host string - if conf.Server.ShareURL != "" { - shareURL, _ := url.Parse(conf.Server.ShareURL) - scheme = shareURL.Scheme - host = shareURL.Host - } else { - scheme = "http" - host = "localhost" - } - r, _ := http.NewRequest("GET", fmt.Sprintf("%s://%s", scheme, host), nil) - return r + return publicurl.ImageURL(nil, artID, int(size)), nil } var _ host.ArtworkService = (*artworkServiceImpl)(nil) diff --git a/server/public/encode_id.go b/server/public/encode_id.go deleted file mode 100644 index 6adf0e71f..000000000 --- a/server/public/encode_id.go +++ /dev/null @@ -1,71 +0,0 @@ -package public - -import ( - "context" - "errors" - "net/http" - "net/url" - "path" - "strconv" - - "github.com/lestrrat-go/jwx/v2/jwt" - "github.com/navidrome/navidrome/consts" - "github.com/navidrome/navidrome/core/auth" - "github.com/navidrome/navidrome/model" - . "github.com/navidrome/navidrome/utils/gg" -) - -func ImageURL(r *http.Request, artID model.ArtworkID, size int) string { - token := encodeArtworkID(artID) - uri := path.Join(consts.URLPathPublicImages, token) - params := url.Values{} - if size > 0 { - params.Add("size", strconv.Itoa(size)) - } - return publicURL(r, uri, params) -} - -func encodeArtworkID(artID model.ArtworkID) string { - token, _ := auth.CreatePublicToken(map[string]any{"id": artID.String()}) - return token -} - -func decodeArtworkID(tokenString string) (model.ArtworkID, error) { - token, err := auth.TokenAuth.Decode(tokenString) - if err != nil { - return model.ArtworkID{}, err - } - if token == nil { - return model.ArtworkID{}, errors.New("unauthorized") - } - err = jwt.Validate(token, jwt.WithRequiredClaim("id")) - if err != nil { - return model.ArtworkID{}, err - } - claims, err := token.AsMap(context.Background()) - if err != nil { - return model.ArtworkID{}, err - } - id, ok := claims["id"].(string) - if !ok { - return model.ArtworkID{}, errors.New("invalid id type") - } - artID, err := model.ParseArtworkID(id) - if err == nil { - return artID, nil - } - // Try to default to mediafile artworkId (if used with a mediafileShare token) - return model.ParseArtworkID("mf-" + id) -} - -func encodeMediafileShare(s model.Share, id string) string { - claims := map[string]any{"id": id} - if s.Format != "" { - claims["f"] = s.Format - } - if s.MaxBitRate != 0 { - claims["b"] = s.MaxBitRate - } - token, _ := auth.CreateExpiringPublicToken(V(s.ExpiresAt), claims) - return token -} diff --git a/server/public/encode_id_test.go b/server/public/encode_id_test.go deleted file mode 100644 index efd252e4e..000000000 --- a/server/public/encode_id_test.go +++ /dev/null @@ -1,39 +0,0 @@ -package public - -import ( - "github.com/go-chi/jwtauth/v5" - "github.com/navidrome/navidrome/core/auth" - "github.com/navidrome/navidrome/model" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("encodeArtworkID", func() { - Context("Public ID Encoding", func() { - BeforeEach(func() { - auth.TokenAuth = jwtauth.New("HS256", []byte("super secret"), nil) - }) - It("returns a reversible string representation", func() { - id := model.NewArtworkID(model.KindArtistArtwork, "1234", nil) - encoded := encodeArtworkID(id) - decoded, err := decodeArtworkID(encoded) - Expect(err).ToNot(HaveOccurred()) - Expect(decoded).To(Equal(id)) - }) - It("fails to decode an invalid token", func() { - _, err := decodeArtworkID("xx-123") - Expect(err).To(MatchError("invalid JWT")) - }) - It("defaults to kind mediafile", func() { - encoded := encodeArtworkID(model.ArtworkID{}) - id, err := decodeArtworkID(encoded) - Expect(err).ToNot(HaveOccurred()) - Expect(id.Kind).To(Equal(model.KindMediaFileArtwork)) - }) - It("fails to decode a token without an id", func() { - token, _ := auth.CreatePublicToken(map[string]any{}) - _, err := decodeArtworkID(token) - Expect(err).To(HaveOccurred()) - }) - }) -}) diff --git a/server/public/handle_images.go b/server/public/handle_images.go index 55a851c6f..6de44ddd2 100644 --- a/server/public/handle_images.go +++ b/server/public/handle_images.go @@ -7,7 +7,9 @@ import ( "net/http" "time" + "github.com/lestrrat-go/jwx/v2/jwt" "github.com/navidrome/navidrome/core/artwork" + "github.com/navidrome/navidrome/core/auth" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/utils/req" @@ -65,3 +67,31 @@ func (pub *Router) handleImages(w http.ResponseWriter, r *http.Request) { log.Warn(ctx, "Error sending image", "count", cnt, err) } } + +func decodeArtworkID(tokenString string) (model.ArtworkID, error) { + token, err := auth.TokenAuth.Decode(tokenString) + if err != nil { + return model.ArtworkID{}, err + } + if token == nil { + return model.ArtworkID{}, errors.New("unauthorized") + } + err = jwt.Validate(token, jwt.WithRequiredClaim("id")) + if err != nil { + return model.ArtworkID{}, err + } + claims, err := token.AsMap(context.Background()) + if err != nil { + return model.ArtworkID{}, err + } + id, ok := claims["id"].(string) + if !ok { + return model.ArtworkID{}, errors.New("invalid id type") + } + artID, err := model.ParseArtworkID(id) + if err == nil { + return artID, nil + } + // Try to default to mediafile artworkId (if used with a mediafileShare token) + return model.ParseArtworkID("mf-" + id) +} diff --git a/server/public/handle_images_test.go b/server/public/handle_images_test.go new file mode 100644 index 000000000..0995f4f61 --- /dev/null +++ b/server/public/handle_images_test.go @@ -0,0 +1,33 @@ +package public + +import ( + "github.com/go-chi/jwtauth/v5" + "github.com/navidrome/navidrome/core/auth" + "github.com/navidrome/navidrome/model" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("decodeArtworkID", func() { + BeforeEach(func() { + auth.TokenAuth = jwtauth.New("HS256", []byte("super secret"), nil) + }) + + It("fails to decode an invalid token", func() { + _, err := decodeArtworkID("xx-123") + Expect(err).To(MatchError("invalid JWT")) + }) + + It("defaults to kind mediafile for empty artwork ID", func() { + token, _ := auth.CreatePublicToken(map[string]any{"id": ""}) + id, err := decodeArtworkID(token) + Expect(err).ToNot(HaveOccurred()) + Expect(id.Kind).To(Equal(model.KindMediaFileArtwork)) + }) + + It("fails to decode a token without an id", func() { + token, _ := auth.CreatePublicToken(map[string]any{}) + _, err := decodeArtworkID(token) + Expect(err).To(HaveOccurred()) + }) +}) diff --git a/server/public/handle_shares.go b/server/public/handle_shares.go index 61f3fba71..ad8a5da6b 100644 --- a/server/public/handle_shares.go +++ b/server/public/handle_shares.go @@ -7,10 +7,13 @@ import ( "path" "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/core/auth" + "github.com/navidrome/navidrome/core/publicurl" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/server" "github.com/navidrome/navidrome/ui" + . "github.com/navidrome/navidrome/utils/gg" "github.com/navidrome/navidrome/utils/req" ) @@ -78,7 +81,7 @@ func checkShareError(ctx context.Context, w http.ResponseWriter, err error, id s func (pub *Router) mapShareInfo(r *http.Request, s model.Share) *model.Share { s.URL = ShareURL(r, s.ID) - s.ImageURL = ImageURL(r, s.CoverArtID(), consts.UICoverArtSize) + s.ImageURL = publicurl.ImageURL(r, s.CoverArtID(), consts.UICoverArtSize) for i := range s.Tracks { s.Tracks[i].ID = encodeMediafileShare(s, s.Tracks[i].ID) } @@ -88,7 +91,19 @@ func (pub *Router) mapShareInfo(r *http.Request, s model.Share) *model.Share { func (pub *Router) mapShareToM3U(r *http.Request, s model.Share) *model.Share { for i := range s.Tracks { id := encodeMediafileShare(s, s.Tracks[i].ID) - s.Tracks[i].Path = publicURL(r, path.Join(consts.URLPathPublic, "s", id), nil) + s.Tracks[i].Path = publicurl.PublicURL(r, path.Join(consts.URLPathPublic, "s", id), nil) } return &s } + +func encodeMediafileShare(s model.Share, id string) string { + claims := map[string]any{"id": id} + if s.Format != "" { + claims["f"] = s.Format + } + if s.MaxBitRate != 0 { + claims["b"] = s.MaxBitRate + } + token, _ := auth.CreateExpiringPublicToken(V(s.ExpiresAt), claims) + return token +} diff --git a/server/public/public.go b/server/public/public.go index 03ccaeebe..ebccb01d2 100644 --- a/server/public/public.go +++ b/server/public/public.go @@ -2,7 +2,6 @@ package public import ( "net/http" - "net/url" "path" "github.com/go-chi/chi/v5" @@ -11,6 +10,7 @@ import ( "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/core" "github.com/navidrome/navidrome/core/artwork" + "github.com/navidrome/navidrome/core/publicurl" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/server" @@ -67,19 +67,5 @@ func (pub *Router) routes() http.Handler { func ShareURL(r *http.Request, id string) string { uri := path.Join(consts.URLPathPublic, id) - return publicURL(r, uri, nil) -} - -func publicURL(r *http.Request, u string, params url.Values) string { - if conf.Server.ShareURL != "" { - shareUrl, _ := url.Parse(conf.Server.ShareURL) - buildUrl, _ := url.Parse(u) - buildUrl.Scheme = shareUrl.Scheme - buildUrl.Host = shareUrl.Host - if len(params) > 0 { - buildUrl.RawQuery = params.Encode() - } - return buildUrl.String() - } - return server.AbsoluteURL(r, u, params) + return publicurl.PublicURL(r, uri, nil) } diff --git a/server/public/public_test.go b/server/public/public_test.go deleted file mode 100644 index c45fadf65..000000000 --- a/server/public/public_test.go +++ /dev/null @@ -1,56 +0,0 @@ -package public - -import ( - "net/http" - "net/url" - "path" - - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/consts" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("publicURL", func() { - When("ShareURL is set", func() { - BeforeEach(func() { - conf.Server.ShareURL = "http://share.myotherserver.com" - }) - It("uses the config value instead of AbsoluteURL", func() { - r, _ := http.NewRequest("GET", "https://myserver.com/share/123", nil) - uri := path.Join(consts.URLPathPublic, "123") - actual := publicURL(r, uri, nil) - Expect(actual).To(Equal("http://share.myotherserver.com/share/123")) - }) - It("concatenates params if provided", func() { - r, _ := http.NewRequest("GET", "https://myserver.com/share/123", nil) - uri := path.Join(consts.URLPathPublicImages, "123") - params := url.Values{ - "size": []string{"300"}, - } - actual := publicURL(r, uri, params) - Expect(actual).To(Equal("http://share.myotherserver.com/share/img/123?size=300")) - - }) - }) - When("ShareURL is not set", func() { - BeforeEach(func() { - conf.Server.ShareURL = "" - }) - It("uses AbsoluteURL", func() { - r, _ := http.NewRequest("GET", "https://myserver.com/share/123", nil) - uri := path.Join(consts.URLPathPublic, "123") - actual := publicURL(r, uri, nil) - Expect(actual).To(Equal("https://myserver.com/share/123")) - }) - It("concatenates params if provided", func() { - r, _ := http.NewRequest("GET", "https://myserver.com/share/123", nil) - uri := path.Join(consts.URLPathPublicImages, "123") - params := url.Values{ - "size": []string{"300"}, - } - actual := publicURL(r, uri, params) - Expect(actual).To(Equal("https://myserver.com/share/img/123?size=300")) - }) - }) -}) diff --git a/server/server.go b/server/server.go index 39475a225..79cc51917 100644 --- a/server/server.go +++ b/server/server.go @@ -2,7 +2,6 @@ package server import ( "bytes" - "cmp" "context" "crypto/tls" "encoding/pem" @@ -10,7 +9,6 @@ import ( "fmt" "net" "net/http" - "net/url" "os" "path" "strconv" @@ -242,24 +240,6 @@ func (s *Server) frontendAssetsHandler() http.Handler { return r } -func AbsoluteURL(r *http.Request, u string, params url.Values) string { - buildUrl, _ := url.Parse(u) - if strings.HasPrefix(u, "/") { - buildUrl.Path = path.Join(conf.Server.BasePath, buildUrl.Path) - if conf.Server.BaseHost != "" { - buildUrl.Scheme = cmp.Or(conf.Server.BaseScheme, "http") - buildUrl.Host = conf.Server.BaseHost - } else { - buildUrl.Scheme = r.URL.Scheme - buildUrl.Host = r.Host - } - } - if len(params) > 0 { - buildUrl.RawQuery = params.Encode() - } - return buildUrl.String() -} - // validateTLSCertificates validates the TLS certificate and key files before starting the server. // It provides detailed error messages for common issues like encrypted private keys. func validateTLSCertificates(certFile, keyFile string) error { diff --git a/server/server_test.go b/server/server_test.go index 5ca03bf7e..245fa013a 100644 --- a/server/server_test.go +++ b/server/server_test.go @@ -7,68 +7,16 @@ import ( "fmt" "io/fs" "net/http" - "net/url" "os" "path/filepath" "time" - "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf/configtest" "github.com/navidrome/navidrome/tests" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) -var _ = Describe("AbsoluteURL", func() { - When("BaseURL is empty", func() { - BeforeEach(func() { - conf.Server.BasePath = "" - }) - It("uses the scheme/host from the request", func() { - r, _ := http.NewRequest("GET", "https://myserver.com/rest/ping?id=123", nil) - actual := AbsoluteURL(r, "/share/img/123", url.Values{"a": []string{"xyz"}}) - Expect(actual).To(Equal("https://myserver.com/share/img/123?a=xyz")) - }) - It("does not override provided schema/host", func() { - r, _ := http.NewRequest("GET", "http://localhost/rest/ping?id=123", nil) - actual := AbsoluteURL(r, "http://public.myserver.com/share/img/123", url.Values{"a": []string{"xyz"}}) - Expect(actual).To(Equal("http://public.myserver.com/share/img/123?a=xyz")) - }) - }) - When("BaseURL has only path", func() { - BeforeEach(func() { - conf.Server.BasePath = "/music" - }) - It("uses the scheme/host from the request", func() { - r, _ := http.NewRequest("GET", "https://myserver.com/rest/ping?id=123", nil) - actual := AbsoluteURL(r, "/share/img/123", url.Values{"a": []string{"xyz"}}) - Expect(actual).To(Equal("https://myserver.com/music/share/img/123?a=xyz")) - }) - It("does not override provided schema/host", func() { - r, _ := http.NewRequest("GET", "http://localhost/rest/ping?id=123", nil) - actual := AbsoluteURL(r, "http://public.myserver.com/share/img/123", url.Values{"a": []string{"xyz"}}) - Expect(actual).To(Equal("http://public.myserver.com/share/img/123?a=xyz")) - }) - }) - When("BaseURL has full URL", func() { - BeforeEach(func() { - conf.Server.BaseScheme = "https" - conf.Server.BaseHost = "myserver.com:8080" - conf.Server.BasePath = "/music" - }) - It("use the configured scheme/host/path", func() { - r, _ := http.NewRequest("GET", "https://localhost:4533/rest/ping?id=123", nil) - actual := AbsoluteURL(r, "/share/img/123", url.Values{"a": []string{"xyz"}}) - Expect(actual).To(Equal("https://myserver.com:8080/music/share/img/123?a=xyz")) - }) - It("does not override provided schema/host", func() { - r, _ := http.NewRequest("GET", "http://localhost/rest/ping?id=123", nil) - actual := AbsoluteURL(r, "http://public.myserver.com/share/img/123", url.Values{"a": []string{"xyz"}}) - Expect(actual).To(Equal("http://public.myserver.com/share/img/123?a=xyz")) - }) - }) -}) - var _ = Describe("createUnixSocketFile", func() { var socketPath string diff --git a/server/subsonic/browsing.go b/server/subsonic/browsing.go index c8584543d..ba3fb058a 100644 --- a/server/subsonic/browsing.go +++ b/server/subsonic/browsing.go @@ -8,9 +8,9 @@ import ( "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/core/publicurl" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/server/public" "github.com/navidrome/navidrome/server/subsonic/filter" "github.com/navidrome/navidrome/server/subsonic/responses" "github.com/navidrome/navidrome/utils/req" @@ -230,9 +230,9 @@ func (api *Router) GetAlbumInfo(r *http.Request) (*responses.Subsonic, error) { response := newResponse() response.AlbumInfo = &responses.AlbumInfo{} response.AlbumInfo.Notes = album.Description - response.AlbumInfo.SmallImageUrl = public.ImageURL(r, album.CoverArtID(), 300) - response.AlbumInfo.MediumImageUrl = public.ImageURL(r, album.CoverArtID(), 600) - response.AlbumInfo.LargeImageUrl = public.ImageURL(r, album.CoverArtID(), 1200) + response.AlbumInfo.SmallImageUrl = publicurl.ImageURL(r, album.CoverArtID(), 300) + response.AlbumInfo.MediumImageUrl = publicurl.ImageURL(r, album.CoverArtID(), 600) + response.AlbumInfo.LargeImageUrl = publicurl.ImageURL(r, album.CoverArtID(), 1200) response.AlbumInfo.LastFmUrl = album.ExternalUrl response.AlbumInfo.MusicBrainzID = album.MbzAlbumID @@ -296,9 +296,9 @@ func (api *Router) getArtistInfo(r *http.Request) (*responses.ArtistInfoBase, *m base := responses.ArtistInfoBase{} base.Biography = artist.Biography - base.SmallImageUrl = public.ImageURL(r, artist.CoverArtID(), 300) - base.MediumImageUrl = public.ImageURL(r, artist.CoverArtID(), 600) - base.LargeImageUrl = public.ImageURL(r, artist.CoverArtID(), 1200) + base.SmallImageUrl = publicurl.ImageURL(r, artist.CoverArtID(), 300) + base.MediumImageUrl = publicurl.ImageURL(r, artist.CoverArtID(), 600) + base.LargeImageUrl = publicurl.ImageURL(r, artist.CoverArtID(), 1200) base.LastFmUrl = artist.ExternalUrl base.MusicBrainzID = artist.MbzArtistID diff --git a/server/subsonic/helpers.go b/server/subsonic/helpers.go index f9733bb3f..3cf5ae6b2 100644 --- a/server/subsonic/helpers.go +++ b/server/subsonic/helpers.go @@ -13,9 +13,9 @@ import ( "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/core/publicurl" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" - "github.com/navidrome/navidrome/server/public" "github.com/navidrome/navidrome/server/subsonic/responses" "github.com/navidrome/navidrome/utils/number" "github.com/navidrome/navidrome/utils/req" @@ -99,7 +99,7 @@ func toArtist(r *http.Request, a model.Artist) responses.Artist { Name: a.Name, UserRating: int32(a.Rating), CoverArt: a.CoverArtID().String(), - ArtistImageUrl: public.ImageURL(r, a.CoverArtID(), 600), + ArtistImageUrl: publicurl.ImageURL(r, a.CoverArtID(), 600), } if a.Starred { artist.Starred = a.StarredAt @@ -113,7 +113,7 @@ func toArtistID3(r *http.Request, a model.Artist) responses.ArtistID3 { Name: a.Name, AlbumCount: getArtistAlbumCount(&a), CoverArt: a.CoverArtID().String(), - ArtistImageUrl: public.ImageURL(r, a.CoverArtID(), 600), + ArtistImageUrl: publicurl.ImageURL(r, a.CoverArtID(), 600), UserRating: int32(a.Rating), } if a.Starred { diff --git a/server/subsonic/searching.go b/server/subsonic/searching.go index ba1071320..5a19e0a3b 100644 --- a/server/subsonic/searching.go +++ b/server/subsonic/searching.go @@ -10,9 +10,9 @@ import ( . "github.com/Masterminds/squirrel" "github.com/deluan/sanitize" + "github.com/navidrome/navidrome/core/publicurl" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/server/public" "github.com/navidrome/navidrome/server/subsonic/responses" "github.com/navidrome/navidrome/utils/req" "github.com/navidrome/navidrome/utils/slice" @@ -119,7 +119,7 @@ func (api *Router) Search2(r *http.Request) (*responses.Subsonic, error) { Name: artist.Name, UserRating: int32(artist.Rating), CoverArt: artist.CoverArtID().String(), - ArtistImageUrl: public.ImageURL(r, artist.CoverArtID(), 600), + ArtistImageUrl: publicurl.ImageURL(r, artist.CoverArtID(), 600), } if artist.Starred { a.Starred = artist.StarredAt