mirror of
https://github.com/navidrome/navidrome.git
synced 2026-10-27 13:57:30 +01:00
* 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.
249 lines
7.1 KiB
Go
249 lines
7.1 KiB
Go
//go:build !windows
|
|
|
|
package plugins
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"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/model/request"
|
|
"github.com/navidrome/navidrome/tests"
|
|
. "github.com/onsi/ginkgo/v2"
|
|
. "github.com/onsi/gomega"
|
|
)
|
|
|
|
var _ = Describe("ArtworkService", Ordered, func() {
|
|
var (
|
|
manager *Manager
|
|
tmpDir string
|
|
)
|
|
|
|
BeforeAll(func() {
|
|
var err error
|
|
tmpDir, err = os.MkdirTemp("", "artwork-test-*")
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
// Copy the test-artwork plugin
|
|
srcPath := filepath.Join(testdataDir, "test-artwork"+PackageExtension)
|
|
destPath := filepath.Join(tmpDir, "test-artwork"+PackageExtension)
|
|
data, err := os.ReadFile(srcPath)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
err = os.WriteFile(destPath, data, 0600)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
// Compute SHA256 for the plugin
|
|
hash := sha256.Sum256(data)
|
|
hashHex := hex.EncodeToString(hash[:])
|
|
|
|
// Setup config
|
|
DeferCleanup(configtest.SetupConfig())
|
|
conf.Server.Plugins.Enabled = true
|
|
conf.Server.Plugins.Folder = conf.NewDir(tmpDir)
|
|
conf.Server.Plugins.AutoReload = false
|
|
|
|
// Initialize auth (required for token generation)
|
|
ds := &tests.MockDataStore{MockedProperty: &tests.MockedPropertyRepo{}}
|
|
auth.Init(ds)
|
|
|
|
// Setup mock DataStore with pre-enabled plugin
|
|
mockPluginRepo := tests.CreateMockPluginRepo()
|
|
mockPluginRepo.Permitted = true
|
|
mockPluginRepo.SetData(model.Plugins{{
|
|
ID: "test-artwork",
|
|
Path: destPath,
|
|
SHA256: hashHex,
|
|
Enabled: true,
|
|
}})
|
|
dataStore := &tests.MockDataStore{
|
|
MockedProperty: &tests.MockedPropertyRepo{},
|
|
MockedPlugin: mockPluginRepo,
|
|
}
|
|
|
|
// Create and start manager
|
|
manager = &Manager{
|
|
plugins: make(map[string]*plugin),
|
|
ds: dataStore,
|
|
subsonicRouter: http.NotFoundHandler(),
|
|
}
|
|
err = manager.Start(GinkgoT().Context())
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
DeferCleanup(func() {
|
|
_ = manager.Stop()
|
|
_ = os.RemoveAll(tmpDir)
|
|
})
|
|
})
|
|
|
|
Describe("Plugin Loading", func() {
|
|
It("should load plugin with artwork permission", func() {
|
|
manager.mu.RLock()
|
|
p, ok := manager.plugins["test-artwork"]
|
|
manager.mu.RUnlock()
|
|
Expect(ok).To(BeTrue())
|
|
Expect(p.manifest.Permissions).ToNot(BeNil())
|
|
Expect(p.manifest.Permissions.Artwork).ToNot(BeNil())
|
|
})
|
|
})
|
|
|
|
Describe("Artwork URL Generation", func() {
|
|
type testArtworkInput struct {
|
|
ArtworkType string `json:"artwork_type"`
|
|
ID string `json:"id"`
|
|
Size int32 `json:"size"`
|
|
}
|
|
type testArtworkOutput struct {
|
|
URL string `json:"url,omitempty"`
|
|
Error *string `json:"error,omitempty"`
|
|
}
|
|
|
|
callTestArtwork := func(ctx context.Context, artworkType, id string, size int32) (string, error) {
|
|
manager.mu.RLock()
|
|
p := manager.plugins["test-artwork"]
|
|
manager.mu.RUnlock()
|
|
|
|
instance, err := p.instance(ctx)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
defer instance.Close(ctx)
|
|
|
|
input := testArtworkInput{
|
|
ArtworkType: artworkType,
|
|
ID: id,
|
|
Size: size,
|
|
}
|
|
inputBytes, _ := json.Marshal(input)
|
|
_, outputBytes, err := instance.CallWithContext(ctx, "nd_test_artwork", inputBytes)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
var output testArtworkOutput
|
|
if err := json.Unmarshal(outputBytes, &output); err != nil {
|
|
return "", err
|
|
}
|
|
if output.Error != nil {
|
|
return "", Errorf(*output.Error)
|
|
}
|
|
return output.URL, nil
|
|
}
|
|
|
|
It("should generate artist artwork URL", func() {
|
|
url, err := callTestArtwork(GinkgoT().Context(), "artist", "ar-123", 0)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(url).To(ContainSubstring("/img/"))
|
|
Expect(url).ToNot(ContainSubstring("size="))
|
|
|
|
// Decode JWT and verify artwork ID
|
|
artID := decodeArtworkURL(url)
|
|
Expect(artID.Kind).To(Equal(model.KindArtistArtwork))
|
|
Expect(artID.ID).To(Equal("ar-123"))
|
|
})
|
|
|
|
It("should generate album artwork URL", func() {
|
|
url, err := callTestArtwork(GinkgoT().Context(), "album", "al-456", 0)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(url).To(ContainSubstring("/img/"))
|
|
|
|
artID := decodeArtworkURL(url)
|
|
Expect(artID.Kind).To(Equal(model.KindAlbumArtwork))
|
|
Expect(artID.ID).To(Equal("al-456"))
|
|
})
|
|
|
|
It("should generate track artwork URL", func() {
|
|
url, err := callTestArtwork(GinkgoT().Context(), "track", "mf-789", 0)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(url).To(ContainSubstring("/img/"))
|
|
|
|
artID := decodeArtworkURL(url)
|
|
Expect(artID.Kind).To(Equal(model.KindMediaFileArtwork))
|
|
Expect(artID.ID).To(Equal("mf-789"))
|
|
})
|
|
|
|
It("should generate playlist artwork URL", func() {
|
|
url, err := callTestArtwork(GinkgoT().Context(), "playlist", "pl-abc", 0)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(url).To(ContainSubstring("/img/"))
|
|
|
|
artID := decodeArtworkURL(url)
|
|
Expect(artID.Kind).To(Equal(model.KindPlaylistArtwork))
|
|
Expect(artID.ID).To(Equal("pl-abc"))
|
|
})
|
|
|
|
It("should include size parameter when specified", func() {
|
|
url, err := callTestArtwork(GinkgoT().Context(), "album", "al-456", 300)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(url).To(ContainSubstring("size=300"))
|
|
|
|
artID := decodeArtworkURL(url)
|
|
Expect(artID.Kind).To(Equal(model.KindAlbumArtwork))
|
|
Expect(artID.ID).To(Equal("al-456"))
|
|
})
|
|
|
|
It("uses the address of the request that triggered the plugin", func() {
|
|
ctx := request.WithServerAddress(GinkgoT().Context(), "https", "music.example.com")
|
|
|
|
url, err := callTestArtwork(ctx, "track", "mf-789", 300)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(url).To(HavePrefix("https://music.example.com/share/img/"))
|
|
})
|
|
|
|
It("should handle unknown artwork type", func() {
|
|
_, err := callTestArtwork(GinkgoT().Context(), "unknown", "id-123", 0)
|
|
Expect(err).To(HaveOccurred())
|
|
Expect(err.Error()).To(ContainSubstring("unknown artwork type"))
|
|
})
|
|
})
|
|
})
|
|
|
|
// Errorf creates an error from a format string (helper for tests)
|
|
func Errorf(format string, args ...any) error {
|
|
return &errorString{s: format}
|
|
}
|
|
|
|
type errorString struct {
|
|
s string
|
|
}
|
|
|
|
func (e *errorString) Error() string {
|
|
return e.s
|
|
}
|
|
|
|
// decodeArtworkURL extracts and decodes the JWT token from an artwork URL,
|
|
// returning the parsed ArtworkID. Panics on error (test helper).
|
|
func decodeArtworkURL(artworkURL string) model.ArtworkID {
|
|
// URL format: http://localhost/img/<token>?size=...
|
|
// Extract token from path after /img/
|
|
idx := strings.Index(artworkURL, "/img/")
|
|
Expect(idx).To(BeNumerically(">=", 0), "URL should contain /img/")
|
|
|
|
tokenPart := artworkURL[idx+5:] // skip "/img/"
|
|
// Remove query string if present
|
|
if qIdx := strings.Index(tokenPart, "?"); qIdx >= 0 {
|
|
tokenPart = tokenPart[:qIdx]
|
|
}
|
|
|
|
// Decode JWT token
|
|
token, err := auth.PublicTokenAuth.Decode(tokenPart)
|
|
Expect(err).ToNot(HaveOccurred(), "Failed to decode JWT token")
|
|
|
|
c := auth.ClaimsFromToken(token)
|
|
|
|
id := c.ID
|
|
Expect(id).ToNot(BeEmpty(), "Token should contain 'id' claim")
|
|
|
|
artID, err := model.ParseArtworkID(id)
|
|
Expect(err).ToNot(HaveOccurred(), "Failed to parse artwork ID from token")
|
|
|
|
return artID
|
|
}
|