mirror of
https://github.com/navidrome/navidrome.git
synced 2026-04-26 10:30:46 +00:00
Some checks failed
Pipeline: Test, Lint, Build / Get version info (push) Has been cancelled
Pipeline: Test, Lint, Build / Lint Go code (push) Has been cancelled
Pipeline: Test, Lint, Build / Test Go code (push) Has been cancelled
Pipeline: Test, Lint, Build / Test Go code (Windows) (push) Has been cancelled
Pipeline: Test, Lint, Build / Test JS code (push) Has been cancelled
Pipeline: Test, Lint, Build / Lint i18n files (push) Has been cancelled
Pipeline: Test, Lint, Build / Check Docker configuration (push) Has been cancelled
Pipeline: Test, Lint, Build / Build-4 (push) Has been cancelled
Pipeline: Test, Lint, Build / Build Windows installers (push) Has been cancelled
Pipeline: Test, Lint, Build / Package/Release (push) Has been cancelled
Pipeline: Test, Lint, Build / Upload Linux PKG (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (push) Has been cancelled
Pipeline: Test, Lint, Build / Build-1 (push) Has been cancelled
Pipeline: Test, Lint, Build / Build-2 (push) Has been cancelled
Pipeline: Test, Lint, Build / Build-3 (push) Has been cancelled
Pipeline: Test, Lint, Build / Build-5 (push) Has been cancelled
Pipeline: Test, Lint, Build / Build-6 (push) Has been cancelled
Pipeline: Test, Lint, Build / Build-7 (push) Has been cancelled
Pipeline: Test, Lint, Build / Build-8 (push) Has been cancelled
Pipeline: Test, Lint, Build / Build-9 (push) Has been cancelled
Pipeline: Test, Lint, Build / Build-10 (push) Has been cancelled
Pipeline: Test, Lint, Build / Push to GHCR (push) Has been cancelled
Pipeline: Test, Lint, Build / Push to Docker Hub (push) Has been cancelled
Pipeline: Test, Lint, Build / Cleanup digest artifacts (push) Has been cancelled
* fix: split html sanitization from plaintext handling Add a dedicated SanitizeHTML helper for HTML-rendered values so entity-encoded markup is decoded before bluemonday sanitization. Use the new helper for the login welcome message and artist biographies while preserving SanitizeText semantics for lyrics and other plaintext callers. Add regression coverage for both helpers and the serveIndex welcomeMessage path. * docs: add SanitizeText and SanitizeHTML godoc Signed-off-by: Deluan <deluan@navidrome.org> * fix: preserve plain text in artist biographies Revert artist biography storage to SanitizeText so entity-encoded plain text remains decoded for Subsonic consumers. This avoids double-escaping values like R&B in XML responses while keeping the new welcomeMessage HTML sanitization in place, and adds a regression test covering the biography storage behavior. --------- Signed-off-by: Deluan <deluan@navidrome.org>
346 lines
13 KiB
Go
346 lines
13 KiB
Go
package server
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/navidrome/navidrome/conf"
|
|
"github.com/navidrome/navidrome/conf/configtest"
|
|
"github.com/navidrome/navidrome/conf/mime"
|
|
"github.com/navidrome/navidrome/consts"
|
|
"github.com/navidrome/navidrome/model"
|
|
"github.com/navidrome/navidrome/tests"
|
|
. "github.com/onsi/ginkgo/v2"
|
|
. "github.com/onsi/gomega"
|
|
)
|
|
|
|
var _ = Describe("serveIndex", func() {
|
|
var ds model.DataStore
|
|
mockUser := &mockedUserRepo{}
|
|
fs := os.DirFS("tests/fixtures")
|
|
|
|
BeforeEach(func() {
|
|
ds = &tests.MockDataStore{MockedUser: mockUser}
|
|
DeferCleanup(configtest.SetupConfig())
|
|
})
|
|
|
|
It("adds app_config to index.html", func() {
|
|
r := httptest.NewRequest("GET", "/index.html", nil)
|
|
w := httptest.NewRecorder()
|
|
|
|
serveIndex(ds, fs, nil)(w, r)
|
|
|
|
Expect(w.Code).To(Equal(200))
|
|
config := extractAppConfig(w.Body.String())
|
|
Expect(config).To(BeAssignableToTypeOf(map[string]any{}))
|
|
})
|
|
|
|
It("sets firstTime = true when User table is empty", func() {
|
|
mockUser.empty = true
|
|
r := httptest.NewRequest("GET", "/index.html", nil)
|
|
w := httptest.NewRecorder()
|
|
|
|
serveIndex(ds, fs, nil)(w, r)
|
|
|
|
config := extractAppConfig(w.Body.String())
|
|
Expect(config).To(HaveKeyWithValue("firstTime", true))
|
|
})
|
|
|
|
It("sets firstTime = false when User table is not empty", func() {
|
|
mockUser.empty = false
|
|
r := httptest.NewRequest("GET", "/index.html", nil)
|
|
w := httptest.NewRecorder()
|
|
|
|
serveIndex(ds, fs, nil)(w, r)
|
|
|
|
config := extractAppConfig(w.Body.String())
|
|
Expect(config).To(HaveKeyWithValue("firstTime", false))
|
|
})
|
|
|
|
DescribeTable("sets configuration values",
|
|
func(configSetter func(), configKey string, expectedValue any) {
|
|
configSetter()
|
|
r := httptest.NewRequest("GET", "/index.html", nil)
|
|
w := httptest.NewRecorder()
|
|
|
|
serveIndex(ds, fs, nil)(w, r)
|
|
|
|
config := extractAppConfig(w.Body.String())
|
|
Expect(config).To(HaveKeyWithValue(configKey, expectedValue))
|
|
},
|
|
Entry("baseURL", func() { conf.Server.BasePath = "base_url_test" }, "baseURL", "base_url_test"),
|
|
Entry("welcomeMessage", func() { conf.Server.UIWelcomeMessage = "Hello" }, "welcomeMessage", "Hello"),
|
|
Entry("maxSidebarPlaylists", func() { conf.Server.MaxSidebarPlaylists = 42 }, "maxSidebarPlaylists", float64(42)),
|
|
Entry("enableTranscodingConfig", func() { conf.Server.EnableTranscodingConfig = true }, "enableTranscodingConfig", true),
|
|
Entry("enableDownloads", func() { conf.Server.EnableDownloads = true }, "enableDownloads", true),
|
|
Entry("enableFavourites", func() { conf.Server.EnableFavourites = true }, "enableFavourites", true),
|
|
Entry("enableStarRating", func() { conf.Server.EnableStarRating = true }, "enableStarRating", true),
|
|
Entry("defaultTheme", func() { conf.Server.DefaultTheme = "Light" }, "defaultTheme", "Light"),
|
|
Entry("defaultLanguage", func() { conf.Server.DefaultLanguage = "pt" }, "defaultLanguage", "pt"),
|
|
Entry("defaultUIVolume", func() { conf.Server.DefaultUIVolume = 45 }, "defaultUIVolume", float64(45)),
|
|
Entry("uiSearchDebounceMs", func() { conf.Server.UISearchDebounceMs = 500 }, "uiSearchDebounceMs", float64(500)),
|
|
Entry("uiCoverArtSize", func() { conf.Server.UICoverArtSize = 300 }, "uiCoverArtSize", float64(300)),
|
|
Entry("enableCoverAnimation", func() { conf.Server.EnableCoverAnimation = true }, "enableCoverAnimation", true),
|
|
Entry("enableNowPlaying", func() { conf.Server.EnableNowPlaying = true }, "enableNowPlaying", true),
|
|
Entry("gaTrackingId", func() { conf.Server.GATrackingID = "UA-12345" }, "gaTrackingId", "UA-12345"),
|
|
Entry("defaultDownloadableShare", func() { conf.Server.DefaultDownloadableShare = true }, "defaultDownloadableShare", true),
|
|
Entry("devSidebarPlaylists", func() { conf.Server.DevSidebarPlaylists = true }, "devSidebarPlaylists", true),
|
|
Entry("lastFMEnabled", func() { conf.Server.LastFM.Enabled = true }, "lastFMEnabled", true),
|
|
Entry("devShowArtistPage", func() { conf.Server.DevShowArtistPage = true }, "devShowArtistPage", true),
|
|
Entry("devUIShowConfig", func() { conf.Server.DevUIShowConfig = true }, "devUIShowConfig", true),
|
|
Entry("listenBrainzEnabled", func() { conf.Server.ListenBrainz.Enabled = true }, "listenBrainzEnabled", true),
|
|
Entry("enableReplayGain", func() { conf.Server.EnableReplayGain = true }, "enableReplayGain", true),
|
|
Entry("enableExternalServices", func() { conf.Server.EnableExternalServices = true }, "enableExternalServices", true),
|
|
Entry("devActivityPanel", func() { conf.Server.DevActivityPanel = true }, "devActivityPanel", true),
|
|
Entry("shareURL", func() { conf.Server.ShareURL = "https://share.example.com" }, "shareURL", "https://share.example.com"),
|
|
Entry("enableInspect", func() { conf.Server.Inspect.Enabled = true }, "enableInspect", true),
|
|
Entry("defaultDownsamplingFormat", func() { conf.Server.DefaultDownsamplingFormat = "mp3" }, "defaultDownsamplingFormat", "mp3"),
|
|
Entry("enableUserEditing", func() { conf.Server.EnableUserEditing = false }, "enableUserEditing", false),
|
|
Entry("enableSharing", func() { conf.Server.EnableSharing = true }, "enableSharing", true),
|
|
Entry("devNewEventStream", func() { conf.Server.DevNewEventStream = true }, "devNewEventStream", true),
|
|
Entry("extAuthLogoutURL", func() { conf.Server.ExtAuth.LogoutURL = "https://auth.example.com/logout" }, "extAuthLogoutURL", "https://auth.example.com/logout"),
|
|
)
|
|
|
|
It("sanitizes entity-encoded welcomeMessage as html", func() {
|
|
conf.Server.UIWelcomeMessage = `<img src=x onerror=alert(1)><b>Hello</b>`
|
|
r := httptest.NewRequest("GET", "/index.html", nil)
|
|
w := httptest.NewRecorder()
|
|
|
|
serveIndex(ds, fs, nil)(w, r)
|
|
|
|
config := extractAppConfig(w.Body.String())
|
|
Expect(config).To(HaveKey("welcomeMessage"))
|
|
Expect(config["welcomeMessage"]).To(Equal(`<img src="x"><b>Hello</b>`))
|
|
})
|
|
|
|
DescribeTable("sets other UI configuration values",
|
|
func(configKey string, expectedValueFunc func() any) {
|
|
r := httptest.NewRequest("GET", "/index.html", nil)
|
|
w := httptest.NewRecorder()
|
|
|
|
serveIndex(ds, fs, nil)(w, r)
|
|
|
|
config := extractAppConfig(w.Body.String())
|
|
Expect(config).To(HaveKeyWithValue(configKey, expectedValueFunc()))
|
|
},
|
|
Entry("version", "version", func() any { return consts.Version }),
|
|
Entry("variousArtistsId", "variousArtistsId", func() any { return consts.VariousArtistsID }),
|
|
Entry("losslessFormats", "losslessFormats", func() any {
|
|
return strings.ToUpper(strings.Join(mime.LosslessFormats, ","))
|
|
}),
|
|
Entry("separator", "separator", func() any { return string(os.PathSeparator) }),
|
|
)
|
|
|
|
Describe("loginBackgroundURL", func() {
|
|
Context("empty BaseURL", func() {
|
|
BeforeEach(func() {
|
|
conf.Server.BasePath = "/"
|
|
})
|
|
When("it is the default URL", func() {
|
|
It("points to the default URL", func() {
|
|
conf.Server.UILoginBackgroundURL = consts.DefaultUILoginBackgroundURL
|
|
r := httptest.NewRequest("GET", "/index.html", nil)
|
|
w := httptest.NewRecorder()
|
|
|
|
serveIndex(ds, fs, nil)(w, r)
|
|
|
|
config := extractAppConfig(w.Body.String())
|
|
Expect(config).To(HaveKeyWithValue("loginBackgroundURL", consts.DefaultUILoginBackgroundURL))
|
|
})
|
|
})
|
|
When("it is the default offline URL", func() {
|
|
It("points to the offline URL", func() {
|
|
conf.Server.UILoginBackgroundURL = consts.DefaultUILoginBackgroundURLOffline
|
|
r := httptest.NewRequest("GET", "/index.html", nil)
|
|
w := httptest.NewRecorder()
|
|
|
|
serveIndex(ds, fs, nil)(w, r)
|
|
|
|
config := extractAppConfig(w.Body.String())
|
|
Expect(config).To(HaveKeyWithValue("loginBackgroundURL", consts.DefaultUILoginBackgroundURLOffline))
|
|
})
|
|
})
|
|
When("it is a custom URL", func() {
|
|
It("points to the offline URL", func() {
|
|
conf.Server.UILoginBackgroundURL = "https://example.com/images/1.jpg"
|
|
r := httptest.NewRequest("GET", "/index.html", nil)
|
|
w := httptest.NewRecorder()
|
|
|
|
serveIndex(ds, fs, nil)(w, r)
|
|
|
|
config := extractAppConfig(w.Body.String())
|
|
Expect(config).To(HaveKeyWithValue("loginBackgroundURL", "https://example.com/images/1.jpg"))
|
|
})
|
|
})
|
|
})
|
|
Context("with a BaseURL", func() {
|
|
BeforeEach(func() {
|
|
conf.Server.BasePath = "/music"
|
|
})
|
|
When("it is the default URL", func() {
|
|
It("points to the default URL with BaseURL prefix", func() {
|
|
conf.Server.UILoginBackgroundURL = consts.DefaultUILoginBackgroundURL
|
|
r := httptest.NewRequest("GET", "/index.html", nil)
|
|
w := httptest.NewRecorder()
|
|
|
|
serveIndex(ds, fs, nil)(w, r)
|
|
|
|
config := extractAppConfig(w.Body.String())
|
|
Expect(config).To(HaveKeyWithValue("loginBackgroundURL", "/music"+consts.DefaultUILoginBackgroundURL))
|
|
})
|
|
})
|
|
When("it is the default offline URL", func() {
|
|
It("points to the offline URL", func() {
|
|
conf.Server.UILoginBackgroundURL = consts.DefaultUILoginBackgroundURLOffline
|
|
r := httptest.NewRequest("GET", "/index.html", nil)
|
|
w := httptest.NewRecorder()
|
|
|
|
serveIndex(ds, fs, nil)(w, r)
|
|
|
|
config := extractAppConfig(w.Body.String())
|
|
Expect(config).To(HaveKeyWithValue("loginBackgroundURL", consts.DefaultUILoginBackgroundURLOffline))
|
|
})
|
|
})
|
|
When("it is a custom URL", func() {
|
|
It("points to the offline URL", func() {
|
|
conf.Server.UILoginBackgroundURL = "https://example.com/images/1.jpg"
|
|
r := httptest.NewRequest("GET", "/index.html", nil)
|
|
w := httptest.NewRecorder()
|
|
|
|
serveIndex(ds, fs, nil)(w, r)
|
|
|
|
config := extractAppConfig(w.Body.String())
|
|
Expect(config).To(HaveKeyWithValue("loginBackgroundURL", "https://example.com/images/1.jpg"))
|
|
})
|
|
})
|
|
})
|
|
})
|
|
})
|
|
|
|
var _ = Describe("addShareData", func() {
|
|
var (
|
|
r *http.Request
|
|
data map[string]any
|
|
shareInfo *model.Share
|
|
)
|
|
|
|
BeforeEach(func() {
|
|
data = make(map[string]any)
|
|
r = httptest.NewRequest("GET", "/", nil)
|
|
})
|
|
|
|
Context("when shareInfo is nil or has an empty ID", func() {
|
|
It("should not modify data", func() {
|
|
addShareData(r, data, nil)
|
|
Expect(data).To(BeEmpty())
|
|
|
|
shareInfo = &model.Share{}
|
|
addShareData(r, data, shareInfo)
|
|
Expect(data).To(BeEmpty())
|
|
})
|
|
})
|
|
|
|
Context("when shareInfo is not nil and has a non-empty ID", func() {
|
|
BeforeEach(func() {
|
|
shareInfo = &model.Share{
|
|
ID: "testID",
|
|
Description: "Test description",
|
|
Downloadable: true,
|
|
Tracks: []model.MediaFile{
|
|
{
|
|
ID: "track1",
|
|
Title: "Track 1",
|
|
Artist: "Artist 1",
|
|
Album: "Album 1",
|
|
Duration: 100,
|
|
UpdatedAt: time.Date(2023, time.Month(3), 27, 0, 0, 0, 0, time.UTC),
|
|
},
|
|
{
|
|
ID: "track2",
|
|
Title: "Track 2",
|
|
Artist: "Artist 2",
|
|
Album: "Album 2",
|
|
Duration: 200,
|
|
UpdatedAt: time.Date(2023, time.Month(3), 26, 0, 0, 0, 0, time.UTC),
|
|
},
|
|
},
|
|
Contents: "Test contents",
|
|
URL: "https://example.com/share/testID",
|
|
ImageURL: "https://example.com/share/testID/image",
|
|
}
|
|
})
|
|
|
|
It("should populate data with shareInfo data", func() {
|
|
addShareData(r, data, shareInfo)
|
|
|
|
Expect(data["ShareDescription"]).To(Equal(shareInfo.Description))
|
|
Expect(data["ShareURL"]).To(Equal(shareInfo.URL))
|
|
Expect(data["ShareImageURL"]).To(Equal(shareInfo.ImageURL))
|
|
|
|
var shareData shareData
|
|
err := json.Unmarshal([]byte(data["ShareInfo"].(string)), &shareData)
|
|
Expect(err).NotTo(HaveOccurred())
|
|
Expect(shareData.ID).To(Equal(shareInfo.ID))
|
|
Expect(shareData.Description).To(Equal(shareInfo.Description))
|
|
Expect(shareData.Downloadable).To(Equal(shareInfo.Downloadable))
|
|
|
|
Expect(shareData.Tracks).To(HaveLen(len(shareInfo.Tracks)))
|
|
for i, track := range shareData.Tracks {
|
|
Expect(track.ID).To(Equal(shareInfo.Tracks[i].ID))
|
|
Expect(track.Title).To(Equal(shareInfo.Tracks[i].Title))
|
|
Expect(track.Artist).To(Equal(shareInfo.Tracks[i].Artist))
|
|
Expect(track.Album).To(Equal(shareInfo.Tracks[i].Album))
|
|
Expect(track.Duration).To(Equal(shareInfo.Tracks[i].Duration))
|
|
Expect(track.UpdatedAt).To(Equal(shareInfo.Tracks[i].UpdatedAt))
|
|
}
|
|
})
|
|
|
|
Context("when shareInfo has an empty description", func() {
|
|
BeforeEach(func() {
|
|
shareInfo.Description = ""
|
|
})
|
|
|
|
It("should use shareInfo.Contents as ShareDescription", func() {
|
|
addShareData(r, data, shareInfo)
|
|
Expect(data["ShareDescription"]).To(Equal(shareInfo.Contents))
|
|
})
|
|
})
|
|
})
|
|
})
|
|
|
|
var appConfigRegex = regexp.MustCompile(`(?m)window.__APP_CONFIG__=(.*);</script>`)
|
|
|
|
func extractAppConfig(body string) map[string]any {
|
|
config := make(map[string]any)
|
|
match := appConfigRegex.FindStringSubmatch(body)
|
|
if match == nil {
|
|
return config
|
|
}
|
|
str, err := strconv.Unquote(match[1])
|
|
if err != nil {
|
|
panic(fmt.Sprintf("%s: %s", match[1], err))
|
|
}
|
|
if err := json.Unmarshal([]byte(str), &config); err != nil {
|
|
panic(err)
|
|
}
|
|
return config
|
|
}
|
|
|
|
type mockedUserRepo struct {
|
|
model.UserRepository
|
|
empty bool
|
|
}
|
|
|
|
func (u *mockedUserRepo) CountAll(...model.QueryOptions) (int64, error) {
|
|
if u.empty {
|
|
return 0, nil
|
|
}
|
|
return 1, nil
|
|
}
|