navidrome/core/publicurl/publicurl_test.go
Deluan Quintão 1f861d27ef
fix(plugins): build public URLs on the caller's address instead of localhost (#6059)
* fix(plugins): build public URLs on the caller's address instead of localhost

The artwork host service had no `*http.Request`, so it passed `nil` to
`publicurl.ImageURL`. With neither `ShareURL` nor `BaseURL` configured, that
produced `http://localhost/share/img/...`, which is useless to anything outside
the server. The Discord Rich Presence plugin explicitly drops localhost URLs, so
it fell back to the Navidrome logo instead of the real cover art.

`serverAddressMiddleware` already works out the client-facing scheme and host
from the `X-Forwarded-*` headers. It now also records them in the request
context, and `publicurl` takes a `context.Context` instead of an `*http.Request`
so any caller can reach them. Extism passes the caller's context through to host
functions, so plugins invoked during a request now get a reachable URL with no
configuration.

Switching the parameter also removes the need for a second, parallel entry
point: the package previously wanted only a scheme, a host, and a context, and
took a whole request to get them. `AbsoluteURL` no longer dereferences a
possibly-nil request on its parse-error path.

Plugin calls that start from `context.Background()` (scheduler and websocket
callbacks, the buffered scrobble drain) still fall back to localhost, since they
have no request to learn from. A debug log now points at `ShareURL` when that
happens.

* fix(publicurl): include the configured port in the localhost fallback

The last-resort fallback built `http://localhost/...`, which points at port 80
and so is unreachable for a server listening anywhere else — the default 4533
included. Use `conf.Server.Port` so a consumer on the same machine can actually
fetch the URL.

* fix(publicurl): use https in the localhost fallback when TLS is configured

The fallback hardcoded the http scheme, so a TLS-only server with no BaseURL
advertised a URL it does not answer on. Mirror the server's own switch, which
requires both a certificate and a key.

* refactor(publicurl): tidy the localhost fallback and its tests

Use gg.If for the fallback scheme so it reads as an expression, like the
BaseScheme branch above it, instead of assigning http and overwriting it.

Drop two tests the ctx refactor left redundant: one asserted PublicURL "works
without a request" but became a byte-identical copy of the ShareURL spec once
the *http.Request parameter went away, and the two port specs differed only in
the integer, where the non-default port is the stronger assertion.

* refactor(conf): add TLSEnabled and use it instead of repeating the predicate

Whether the server speaks HTTPS was decided inline in three unconnected
places. This PR added the third, in a URL-building package that has no
business inferring the transport config.

Move the rule to conf, next to the fields it derives from, and call it from
publicurl and the insights collector. server.Run keeps its own expression: it
takes the certificate and key as parameters, and its test passes values that
do not come from the config.
2026-08-31 21:27:43 -04:00

232 lines
8.1 KiB
Go

package publicurl_test
import (
"context"
"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/model/request"
"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() {
result := publicurl.PublicURL(context.Background(), "/path/to/resource", nil)
Expect(result).To(Equal("https://share.example.com/path/to/resource"))
})
It("includes query parameters", func() {
params := url.Values{"size": []string{"300"}, "format": []string{"png"}}
result := publicurl.PublicURL(context.Background(), "/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"))
})
})
When("ShareURL includes a path", func() {
BeforeEach(func() {
conf.Server.ShareURL = "https://example.com/navi"
})
It("prepends the ShareURL path to the resource", func() {
result := publicurl.PublicURL(context.Background(), "/share/img/hash", nil)
Expect(result).To(Equal("https://example.com/navi/share/img/hash"))
})
It("prepends the ShareURL path and includes query parameters", func() {
params := url.Values{"size": []string{"600"}}
result := publicurl.PublicURL(context.Background(), "/share/img/hash", params)
Expect(result).To(Equal("https://example.com/navi/share/img/hash?size=600"))
})
It("handles trailing slash in ShareURL path", func() {
conf.Server.ShareURL = "https://example.com/navi/"
result := publicurl.PublicURL(context.Background(), "/share/img/hash", nil)
Expect(result).To(Equal("https://example.com/navi/share/img/hash"))
})
})
When("ShareURL is not set", func() {
BeforeEach(func() {
conf.Server.ShareURL = ""
})
It("falls back to AbsoluteURL with request", func() {
ctx := request.WithServerAddress(context.Background(), "https", "myserver.com")
result := publicurl.PublicURL(ctx, "/path/to/resource", nil)
Expect(result).To(Equal("https://myserver.com/path/to/resource"))
})
It("falls back to localhost on the configured port without request", func() {
conf.Server.Port = 4533
result := publicurl.PublicURL(context.Background(), "/path/to/resource", nil)
Expect(result).To(Equal("http://localhost:4533/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() {
result := publicurl.AbsoluteURL(context.Background(), "/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 = ""
result := publicurl.AbsoluteURL(context.Background(), "/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() {
ctx := request.WithServerAddress(context.Background(), "https", "request.example.com")
result := publicurl.AbsoluteURL(ctx, "/path/to/resource", nil)
Expect(result).To(Equal("https://request.example.com/path/to/resource"))
})
It("falls back to localhost on the configured port without request", func() {
conf.Server.Port = 8080
result := publicurl.AbsoluteURL(context.Background(), "/path/to/resource", nil)
Expect(result).To(Equal("http://localhost:8080/path/to/resource"))
})
It("uses https in the fallback when TLS is configured", func() {
conf.Server.Port = 4533
conf.Server.TLSCert = "cert.pem"
conf.Server.TLSKey = "key.pem"
result := publicurl.AbsoluteURL(context.Background(), "/path/to/resource", nil)
Expect(result).To(Equal("https://localhost:4533/path/to/resource"))
})
It("stays on http when only the certificate is configured", func() {
conf.Server.Port = 4533
conf.Server.TLSCert = "cert.pem"
result := publicurl.AbsoluteURL(context.Background(), "/path/to/resource", nil)
Expect(result).To(Equal("http://localhost:4533/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() {
result := publicurl.AbsoluteURL(context.Background(), "/path/to/resource", nil)
Expect(result).To(Equal("https://example.com/navidrome/path/to/resource"))
})
})
It("passes through absolute URLs unchanged", func() {
result := publicurl.AbsoluteURL(context.Background(), "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"
params := url.Values{"key": []string{"value"}}
result := publicurl.AbsoluteURL(context.Background(), "/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.PublicTokenAuth = 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(context.Background(), 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(context.Background(), 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(context.Background(), artID, 0)
Expect(result).ToNot(ContainSubstring("size="))
})
})
Describe("ImageURL address precedence", func() {
var artID model.ArtworkID
BeforeEach(func() {
auth.PublicTokenAuth = jwtauth.New("HS256", []byte("test secret"), nil)
artID = model.NewArtworkID(model.KindMediaFileArtwork, "track-1", nil)
})
It("uses the address of the request that triggered the call", func() {
ctx := request.WithServerAddress(context.Background(), "https", "music.example.com")
result := publicurl.ImageURL(ctx, artID, 300)
Expect(result).To(HavePrefix("https://music.example.com/share/img/"))
Expect(result).To(ContainSubstring("size=300"))
})
It("prefers ShareURL over the address in the context", func() {
conf.Server.ShareURL = "https://share.example.com"
ctx := request.WithServerAddress(context.Background(), "https", "music.example.com")
result := publicurl.ImageURL(ctx, artID, 0)
Expect(result).To(HavePrefix("https://share.example.com/share/img/"))
})
It("falls back to localhost on the configured port when no address is available", func() {
conf.Server.Port = 4533
result := publicurl.ImageURL(context.Background(), artID, 0)
Expect(result).To(HavePrefix("http://localhost:4533/share/img/"))
})
})
})