mirror of
https://github.com/navidrome/navidrome.git
synced 2026-05-03 06:51:16 +00:00
Compare commits
8 Commits
451f0795ba
...
42890bc526
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
42890bc526 | ||
|
|
e36fef8692 | ||
|
|
9913235542 | ||
|
|
a87b6a50a6 | ||
|
|
2b30ed1520 | ||
|
|
1024d61a5e | ||
|
|
bbe8fe164d | ||
|
|
72969711d2 |
@ -113,9 +113,9 @@ func WithAdminUser(ctx context.Context, ds model.DataStore) context.Context {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
c, err := ds.User(ctx).CountAll()
|
c, err := ds.User(ctx).CountAll()
|
||||||
if c == 0 && err == nil {
|
if c == 0 && err == nil {
|
||||||
log.Debug(ctx, "Scanner: No admin user yet!", err)
|
log.Debug(ctx, "No admin user yet!", err)
|
||||||
} else {
|
} else {
|
||||||
log.Error(ctx, "Scanner: No admin user found!", err)
|
log.Error(ctx, "No admin user found!", err)
|
||||||
}
|
}
|
||||||
u = &model.User{}
|
u = &model.User{}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -130,58 +130,99 @@ func (s *Stream) EstimatedContentLength() int {
|
|||||||
return int(s.mf.Duration * float32(s.bitRate) / 8 * 1024)
|
return int(s.mf.Duration * float32(s.bitRate) / 8 * 1024)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO This function deserves some love (refactoring)
|
|
||||||
func selectTranscodingOptions(ctx context.Context, ds model.DataStore, mf *model.MediaFile, reqFormat string, reqBitRate int) (format string, bitRate int) {
|
func selectTranscodingOptions(ctx context.Context, ds model.DataStore, mf *model.MediaFile, reqFormat string, reqBitRate int) (format string, bitRate int) {
|
||||||
format = "raw"
|
// Default case
|
||||||
if reqFormat == "raw" {
|
|
||||||
return format, 0
|
|
||||||
}
|
|
||||||
if reqFormat == mf.Suffix && reqBitRate == 0 {
|
|
||||||
bitRate = mf.BitRate
|
|
||||||
return format, bitRate
|
|
||||||
}
|
|
||||||
trc, hasDefault := request.TranscodingFrom(ctx)
|
|
||||||
var cFormat string
|
|
||||||
var cBitRate int
|
|
||||||
if reqFormat != "" {
|
|
||||||
cFormat = reqFormat
|
|
||||||
} else {
|
|
||||||
if hasDefault {
|
|
||||||
cFormat = trc.TargetFormat
|
|
||||||
cBitRate = trc.DefaultBitRate
|
|
||||||
if p, ok := request.PlayerFrom(ctx); ok {
|
|
||||||
cBitRate = p.MaxBitRate
|
|
||||||
}
|
|
||||||
} else if reqBitRate > 0 && reqBitRate < mf.BitRate && conf.Server.DefaultDownsamplingFormat != "" {
|
|
||||||
// If no format is specified and no transcoding associated to the player, but a bitrate is specified,
|
|
||||||
// and there is no transcoding set for the player, we use the default downsampling format.
|
|
||||||
// But only if the requested bitRate is lower than the original bitRate.
|
|
||||||
log.Debug("Default Downsampling", "Using default downsampling format", conf.Server.DefaultDownsamplingFormat)
|
|
||||||
cFormat = conf.Server.DefaultDownsamplingFormat
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if reqBitRate > 0 {
|
|
||||||
cBitRate = reqBitRate
|
|
||||||
}
|
|
||||||
if cBitRate == 0 && cFormat == "" {
|
|
||||||
return format, bitRate
|
|
||||||
}
|
|
||||||
t, err := ds.Transcoding(ctx).FindByFormat(cFormat)
|
|
||||||
if err == nil {
|
|
||||||
format = t.TargetFormat
|
|
||||||
if cBitRate != 0 {
|
|
||||||
bitRate = cBitRate
|
|
||||||
} else {
|
|
||||||
bitRate = t.DefaultBitRate
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if format == mf.Suffix && bitRate >= mf.BitRate {
|
|
||||||
format = "raw"
|
format = "raw"
|
||||||
bitRate = 0
|
bitRate = 0
|
||||||
}
|
|
||||||
|
// If the client explicitly requests "raw"
|
||||||
|
// then always serve the original
|
||||||
|
if reqFormat == "raw" {
|
||||||
return format, bitRate
|
return format, bitRate
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If requested format matches the file’s suffix and
|
||||||
|
// no bitrate reduction is requested then
|
||||||
|
// stream the file without transcoding
|
||||||
|
if reqFormat == mf.Suffix && reqBitRate == 0 {
|
||||||
|
return format, mf.BitRate
|
||||||
|
}
|
||||||
|
|
||||||
|
targetFormat, targetBitRate := findTargetTranscodingOptions(ctx, mf, reqFormat, reqBitRate)
|
||||||
|
|
||||||
|
// If nothing was found then stream raw
|
||||||
|
if targetFormat == "" && targetBitRate == 0 {
|
||||||
|
return format, 0
|
||||||
|
}
|
||||||
|
|
||||||
|
t, err := ds.Transcoding(ctx).FindByFormat(targetFormat)
|
||||||
|
if err != nil {
|
||||||
|
// TODO: log error?
|
||||||
|
return format, 0
|
||||||
|
}
|
||||||
|
|
||||||
|
format = t.TargetFormat
|
||||||
|
|
||||||
|
// If no target bitrate was specified
|
||||||
|
// fall back to the transcoding’s configuration
|
||||||
|
// default bitrate
|
||||||
|
if targetBitRate == 0 {
|
||||||
|
bitRate = t.DefaultBitRate
|
||||||
|
} else {
|
||||||
|
bitRate = targetBitRate
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the final format is the same as the original
|
||||||
|
// and does not reduce bitrate
|
||||||
|
// there’s no reason to transcode
|
||||||
|
if format == mf.Suffix && bitRate >= mf.BitRate {
|
||||||
|
return "raw", 0
|
||||||
|
}
|
||||||
|
|
||||||
|
return format, bitRate
|
||||||
|
}
|
||||||
|
|
||||||
|
func findTargetTranscodingOptions(ctx context.Context, mf *model.MediaFile, reqFormat string, reqBitRate int) (string, int) {
|
||||||
|
// If a format is requested use that
|
||||||
|
if reqFormat != "" {
|
||||||
|
return reqFormat, reqBitRate
|
||||||
|
}
|
||||||
|
|
||||||
|
// If a default transcoding configuration exists for this context
|
||||||
|
if trc, ok := request.TranscodingFrom(ctx); ok {
|
||||||
|
targetFormat := trc.TargetFormat
|
||||||
|
targetBitRate := trc.DefaultBitRate
|
||||||
|
|
||||||
|
// If a player is configured adjust bitrate based on
|
||||||
|
// user request or player limits
|
||||||
|
if p, hasPlayer := request.PlayerFrom(ctx); hasPlayer {
|
||||||
|
if reqBitRate > 0 {
|
||||||
|
targetBitRate = reqBitRate
|
||||||
|
} else if p.MaxBitRate > 0 {
|
||||||
|
targetBitRate = p.MaxBitRate
|
||||||
|
}
|
||||||
|
} else if reqBitRate > 0 {
|
||||||
|
targetBitRate = reqBitRate
|
||||||
|
}
|
||||||
|
|
||||||
|
return targetFormat, targetBitRate
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use the default downsampling format the server is configured to but
|
||||||
|
// only if the requested bitrate is reduced
|
||||||
|
isBitrateReduced := reqBitRate > 0 && reqBitRate < mf.BitRate
|
||||||
|
hasDefaultDownsamplingFormat := conf.Server.DefaultDownsamplingFormat != ""
|
||||||
|
|
||||||
|
if isBitrateReduced && hasDefaultDownsamplingFormat {
|
||||||
|
log.Debug("Default Downsampling",
|
||||||
|
"Using default downsampling format",
|
||||||
|
conf.Server.DefaultDownsamplingFormat)
|
||||||
|
return conf.Server.DefaultDownsamplingFormat, reqBitRate
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", 0
|
||||||
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
onceTranscodingCache sync.Once
|
onceTranscodingCache sync.Once
|
||||||
instanceTranscodingCache TranscodingCache
|
instanceTranscodingCache TranscodingCache
|
||||||
|
|||||||
@ -22,6 +22,7 @@ import (
|
|||||||
"github.com/navidrome/navidrome/core/metrics/insights"
|
"github.com/navidrome/navidrome/core/metrics/insights"
|
||||||
"github.com/navidrome/navidrome/log"
|
"github.com/navidrome/navidrome/log"
|
||||||
"github.com/navidrome/navidrome/model"
|
"github.com/navidrome/navidrome/model"
|
||||||
|
"github.com/navidrome/navidrome/model/request"
|
||||||
"github.com/navidrome/navidrome/plugins/schema"
|
"github.com/navidrome/navidrome/plugins/schema"
|
||||||
"github.com/navidrome/navidrome/utils/singleton"
|
"github.com/navidrome/navidrome/utils/singleton"
|
||||||
)
|
)
|
||||||
@ -64,9 +65,16 @@ func GetInstance(ds model.DataStore, pluginLoader PluginLoader) Insights {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (c *insightsCollector) Run(ctx context.Context) {
|
func (c *insightsCollector) Run(ctx context.Context) {
|
||||||
ctx = auth.WithAdminUser(ctx, c.ds)
|
|
||||||
for {
|
for {
|
||||||
c.sendInsights(ctx)
|
// Refresh admin context on each iteration to handle cases where
|
||||||
|
// admin user wasn't available on previous runs
|
||||||
|
insightsCtx := auth.WithAdminUser(ctx, c.ds)
|
||||||
|
u, _ := request.UserFrom(insightsCtx)
|
||||||
|
if !u.IsAdmin {
|
||||||
|
log.Trace(insightsCtx, "No admin user available, skipping insights collection")
|
||||||
|
} else {
|
||||||
|
c.sendInsights(insightsCtx)
|
||||||
|
}
|
||||||
select {
|
select {
|
||||||
case <-time.After(consts.InsightsUpdateInterval):
|
case <-time.After(consts.InsightsUpdateInterval):
|
||||||
continue
|
continue
|
||||||
|
|||||||
@ -264,6 +264,11 @@ func (r *playlistRepository) refreshSmartPlaylist(pls *model.Playlist) bool {
|
|||||||
"annotation.item_id = media_file.id" +
|
"annotation.item_id = media_file.id" +
|
||||||
" AND annotation.item_type = 'media_file'" +
|
" AND annotation.item_type = 'media_file'" +
|
||||||
" AND annotation.user_id = '" + usr.ID + "')")
|
" AND annotation.user_id = '" + usr.ID + "')")
|
||||||
|
|
||||||
|
// Only include media files from libraries the user has access to
|
||||||
|
sq = r.applyLibraryFilter(sq, "media_file")
|
||||||
|
|
||||||
|
// Apply the criteria rules
|
||||||
sq = r.addCriteria(sq, rules)
|
sq = r.addCriteria(sq, rules)
|
||||||
insSql := Insert("playlist_tracks").Columns("id", "playlist_id", "media_file_id").Select(sq)
|
insSql := Insert("playlist_tracks").Columns("id", "playlist_id", "media_file_id").Select(sq)
|
||||||
_, err = r.executeSQL(insSql)
|
_, err = r.executeSQL(insSql)
|
||||||
|
|||||||
@ -366,4 +366,136 @@ var _ = Describe("PlaylistRepository", func() {
|
|||||||
Expect(foundWithoutGrouping).To(BeTrue())
|
Expect(foundWithoutGrouping).To(BeTrue())
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
Describe("Smart Playlists Library Filtering", func() {
|
||||||
|
var mfRepo model.MediaFileRepository
|
||||||
|
var testPlaylistID string
|
||||||
|
var lib2ID int
|
||||||
|
var restrictedUserID string
|
||||||
|
var uniqueLibPath string
|
||||||
|
|
||||||
|
BeforeEach(func() {
|
||||||
|
db := GetDBXBuilder()
|
||||||
|
|
||||||
|
// Generate unique IDs for this test run
|
||||||
|
uniqueSuffix := time.Now().Format("20060102150405.000")
|
||||||
|
restrictedUserID = "restricted-user-" + uniqueSuffix
|
||||||
|
uniqueLibPath = "/music/lib2-" + uniqueSuffix
|
||||||
|
|
||||||
|
// Create a second library with unique name and path to avoid conflicts with other tests
|
||||||
|
_, err := db.DB().Exec("INSERT INTO library (name, path, created_at, updated_at) VALUES (?, ?, datetime('now'), datetime('now'))", "Library 2-"+uniqueSuffix, uniqueLibPath)
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
err = db.DB().QueryRow("SELECT last_insert_rowid()").Scan(&lib2ID)
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
|
||||||
|
// Create a restricted user with access only to library 1
|
||||||
|
_, err = db.DB().Exec("INSERT INTO user (id, user_name, name, is_admin, password, created_at, updated_at) VALUES (?, ?, 'Restricted User', false, 'pass', datetime('now'), datetime('now'))", restrictedUserID, restrictedUserID)
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
_, err = db.DB().Exec("INSERT INTO user_library (user_id, library_id) VALUES (?, 1)", restrictedUserID)
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
|
||||||
|
// Create test media files in each library
|
||||||
|
ctx := log.NewContext(GinkgoT().Context())
|
||||||
|
ctx = request.WithUser(ctx, model.User{ID: "userid", UserName: "userid", IsAdmin: true})
|
||||||
|
mfRepo = NewMediaFileRepository(ctx, db)
|
||||||
|
|
||||||
|
// Song in library 1 (accessible by restricted user)
|
||||||
|
songLib1 := model.MediaFile{
|
||||||
|
ID: "lib1-song",
|
||||||
|
Title: "Song in Lib1",
|
||||||
|
Artist: "Test Artist",
|
||||||
|
ArtistID: "1",
|
||||||
|
Album: "Test Album",
|
||||||
|
AlbumID: "101",
|
||||||
|
Path: "/music/lib1/song.mp3",
|
||||||
|
LibraryID: 1,
|
||||||
|
Participants: model.Participants{},
|
||||||
|
Tags: model.Tags{},
|
||||||
|
Lyrics: "[]",
|
||||||
|
}
|
||||||
|
Expect(mfRepo.Put(&songLib1)).To(Succeed())
|
||||||
|
|
||||||
|
// Song in library 2 (NOT accessible by restricted user)
|
||||||
|
songLib2 := model.MediaFile{
|
||||||
|
ID: "lib2-song",
|
||||||
|
Title: "Song in Lib2",
|
||||||
|
Artist: "Test Artist",
|
||||||
|
ArtistID: "1",
|
||||||
|
Album: "Test Album",
|
||||||
|
AlbumID: "101",
|
||||||
|
Path: uniqueLibPath + "/song.mp3",
|
||||||
|
LibraryID: lib2ID,
|
||||||
|
Participants: model.Participants{},
|
||||||
|
Tags: model.Tags{},
|
||||||
|
Lyrics: "[]",
|
||||||
|
}
|
||||||
|
Expect(mfRepo.Put(&songLib2)).To(Succeed())
|
||||||
|
})
|
||||||
|
|
||||||
|
AfterEach(func() {
|
||||||
|
db := GetDBXBuilder()
|
||||||
|
if testPlaylistID != "" {
|
||||||
|
_ = repo.Delete(testPlaylistID)
|
||||||
|
testPlaylistID = ""
|
||||||
|
}
|
||||||
|
// Clean up test data
|
||||||
|
_, _ = db.Delete("media_file", dbx.HashExp{"id": "lib1-song"}).Execute()
|
||||||
|
_, _ = db.Delete("media_file", dbx.HashExp{"id": "lib2-song"}).Execute()
|
||||||
|
_, _ = db.Delete("user_library", dbx.HashExp{"user_id": restrictedUserID}).Execute()
|
||||||
|
_, _ = db.Delete("user", dbx.HashExp{"id": restrictedUserID}).Execute()
|
||||||
|
_, _ = db.DB().Exec("DELETE FROM library WHERE id = ?", lib2ID)
|
||||||
|
})
|
||||||
|
|
||||||
|
It("should only include tracks from libraries the user has access to (issue #4738)", func() {
|
||||||
|
db := GetDBXBuilder()
|
||||||
|
ctx := log.NewContext(GinkgoT().Context())
|
||||||
|
|
||||||
|
// Create the smart playlist as the restricted user
|
||||||
|
restrictedUser := model.User{ID: restrictedUserID, UserName: restrictedUserID, IsAdmin: false}
|
||||||
|
ctx = request.WithUser(ctx, restrictedUser)
|
||||||
|
restrictedRepo := NewPlaylistRepository(ctx, db)
|
||||||
|
|
||||||
|
// Create a smart playlist that matches all songs
|
||||||
|
rules := &criteria.Criteria{
|
||||||
|
Expression: criteria.All{
|
||||||
|
criteria.Gt{"playCount": -1}, // Matches everything
|
||||||
|
},
|
||||||
|
}
|
||||||
|
newPls := model.Playlist{Name: "All Songs", OwnerID: restrictedUserID, Rules: rules}
|
||||||
|
Expect(restrictedRepo.Put(&newPls)).To(Succeed())
|
||||||
|
testPlaylistID = newPls.ID
|
||||||
|
|
||||||
|
By("refreshing the smart playlist")
|
||||||
|
conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second // Force refresh
|
||||||
|
pls, err := restrictedRepo.GetWithTracks(newPls.ID, true, false)
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
|
||||||
|
By("verifying only the track from library 1 is in the playlist")
|
||||||
|
var foundLib1Song, foundLib2Song bool
|
||||||
|
for _, track := range pls.Tracks {
|
||||||
|
if track.MediaFileID == "lib1-song" {
|
||||||
|
foundLib1Song = true
|
||||||
|
}
|
||||||
|
if track.MediaFileID == "lib2-song" {
|
||||||
|
foundLib2Song = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Expect(foundLib1Song).To(BeTrue(), "Song from library 1 should be in the playlist")
|
||||||
|
Expect(foundLib2Song).To(BeFalse(), "Song from library 2 should NOT be in the playlist")
|
||||||
|
|
||||||
|
By("verifying playlist_tracks table only contains the accessible track")
|
||||||
|
var playlistTracksCount int
|
||||||
|
err = db.DB().QueryRow("SELECT count(*) FROM playlist_tracks WHERE playlist_id = ?", newPls.ID).Scan(&playlistTracksCount)
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
// Count should only include tracks visible to the user (lib1-song)
|
||||||
|
// The count may include other test songs from library 1, but NOT lib2-song
|
||||||
|
var lib2TrackCount int
|
||||||
|
err = db.DB().QueryRow("SELECT count(*) FROM playlist_tracks WHERE playlist_id = ? AND media_file_id = 'lib2-song'", newPls.ID).Scan(&lib2TrackCount)
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
Expect(lib2TrackCount).To(Equal(0), "lib2-song should not be in playlist_tracks")
|
||||||
|
|
||||||
|
By("verifying SongCount matches visible tracks")
|
||||||
|
Expect(pls.SongCount).To(Equal(len(pls.Tracks)), "SongCount should match the number of visible tracks")
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@ -2,6 +2,7 @@ package persistence
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/deluan/rest"
|
"github.com/deluan/rest"
|
||||||
"github.com/navidrome/navidrome/conf/configtest"
|
"github.com/navidrome/navidrome/conf/configtest"
|
||||||
@ -45,6 +46,9 @@ var _ = Describe("Tag Library Filtering", func() {
|
|||||||
BeforeEach(func() {
|
BeforeEach(func() {
|
||||||
DeferCleanup(configtest.SetupConfig())
|
DeferCleanup(configtest.SetupConfig())
|
||||||
|
|
||||||
|
// Generate unique path suffix to avoid conflicts with other tests
|
||||||
|
uniqueSuffix := time.Now().Format("20060102150405.000")
|
||||||
|
|
||||||
// Clean up database
|
// Clean up database
|
||||||
db := GetDBXBuilder()
|
db := GetDBXBuilder()
|
||||||
_, err := db.NewQuery("DELETE FROM library_tag").Execute()
|
_, err := db.NewQuery("DELETE FROM library_tag").Execute()
|
||||||
@ -57,12 +61,12 @@ var _ = Describe("Tag Library Filtering", func() {
|
|||||||
_, err = db.NewQuery("DELETE FROM library WHERE id > 1").Execute()
|
_, err = db.NewQuery("DELETE FROM library WHERE id > 1").Execute()
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
|
||||||
// Create test libraries
|
// Create test libraries with unique names and paths to avoid conflicts with other tests
|
||||||
_, err = db.NewQuery("INSERT INTO library (id, name, path) VALUES ({:id}, {:name}, {:path})").
|
_, err = db.NewQuery("INSERT INTO library (id, name, path) VALUES ({:id}, {:name}, {:path})").
|
||||||
Bind(dbx.Params{"id": libraryID2, "name": "Library 2", "path": "/music/lib2"}).Execute()
|
Bind(dbx.Params{"id": libraryID2, "name": "Library 2-" + uniqueSuffix, "path": "/music/lib2-" + uniqueSuffix}).Execute()
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
_, err = db.NewQuery("INSERT INTO library (id, name, path) VALUES ({:id}, {:name}, {:path})").
|
_, err = db.NewQuery("INSERT INTO library (id, name, path) VALUES ({:id}, {:name}, {:path})").
|
||||||
Bind(dbx.Params{"id": libraryID3, "name": "Library 3", "path": "/music/lib3"}).Execute()
|
Bind(dbx.Params{"id": libraryID3, "name": "Library 3-" + uniqueSuffix, "path": "/music/lib3-" + uniqueSuffix}).Execute()
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
|
||||||
// Give admin access to all libraries
|
// Give admin access to all libraries
|
||||||
|
|||||||
@ -1,8 +1,11 @@
|
|||||||
package server
|
package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"cmp"
|
"cmp"
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/tls"
|
||||||
|
"encoding/pem"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
"net"
|
||||||
@ -69,6 +72,13 @@ func (s *Server) Run(ctx context.Context, addr string, port int, tlsCert string,
|
|||||||
// Determine if TLS is enabled
|
// Determine if TLS is enabled
|
||||||
tlsEnabled := tlsCert != "" && tlsKey != ""
|
tlsEnabled := tlsCert != "" && tlsKey != ""
|
||||||
|
|
||||||
|
// Validate TLS certificates before starting the server
|
||||||
|
if tlsEnabled {
|
||||||
|
if err := validateTLSCertificates(tlsCert, tlsKey); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Create a listener based on the address type (either Unix socket or TCP)
|
// Create a listener based on the address type (either Unix socket or TCP)
|
||||||
var listener net.Listener
|
var listener net.Listener
|
||||||
var err error
|
var err error
|
||||||
@ -89,17 +99,17 @@ func (s *Server) Run(ctx context.Context, addr string, port int, tlsCert string,
|
|||||||
// Start the server in a new goroutine and send an error signal to errC if there's an error
|
// Start the server in a new goroutine and send an error signal to errC if there's an error
|
||||||
errC := make(chan error)
|
errC := make(chan error)
|
||||||
go func() {
|
go func() {
|
||||||
|
var err error
|
||||||
if tlsEnabled {
|
if tlsEnabled {
|
||||||
// Start the HTTPS server
|
// Start the HTTPS server
|
||||||
log.Info("Starting server with TLS (HTTPS) enabled", "tlsCert", tlsCert, "tlsKey", tlsKey)
|
log.Info("Starting server with TLS (HTTPS) enabled", "tlsCert", tlsCert, "tlsKey", tlsKey)
|
||||||
if err := server.ServeTLS(listener, tlsCert, tlsKey); !errors.Is(err, http.ErrServerClosed) {
|
err = server.ServeTLS(listener, tlsCert, tlsKey)
|
||||||
errC <- err
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
// Start the HTTP server
|
// Start the HTTP server
|
||||||
if err := server.Serve(listener); !errors.Is(err, http.ErrServerClosed) {
|
err = server.Serve(listener)
|
||||||
errC <- err
|
|
||||||
}
|
}
|
||||||
|
if !errors.Is(err, http.ErrServerClosed) {
|
||||||
|
errC <- err
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
@ -249,3 +259,56 @@ func AbsoluteURL(r *http.Request, u string, params url.Values) string {
|
|||||||
}
|
}
|
||||||
return buildUrl.String()
|
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 {
|
||||||
|
// Read the key file to check for encryption
|
||||||
|
keyData, err := os.ReadFile(keyFile)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("reading TLS key file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse PEM blocks and check for encryption
|
||||||
|
block, _ := pem.Decode(keyData)
|
||||||
|
if block == nil {
|
||||||
|
return errors.New("TLS key file does not contain a valid PEM block")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for encrypted private key indicators
|
||||||
|
if isEncryptedPEM(block, keyData) {
|
||||||
|
return errors.New("TLS private key is encrypted (password-protected). " +
|
||||||
|
"Navidrome does not support encrypted private keys. " +
|
||||||
|
"Please decrypt your key using: openssl pkey -in <encrypted-key> -out <decrypted-key>")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to load the certificate pair to validate it
|
||||||
|
_, err = tls.LoadX509KeyPair(certFile, keyFile)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("loading TLS certificate/key pair: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// isEncryptedPEM checks if a PEM block represents an encrypted private key.
|
||||||
|
func isEncryptedPEM(block *pem.Block, rawData []byte) bool {
|
||||||
|
// Check for PKCS#8 encrypted format (BEGIN ENCRYPTED PRIVATE KEY)
|
||||||
|
if block.Type == "ENCRYPTED PRIVATE KEY" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for legacy encrypted format with Proc-Type header
|
||||||
|
if block.Headers != nil {
|
||||||
|
if procType, ok := block.Headers["Proc-Type"]; ok && strings.Contains(procType, "ENCRYPTED") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also check raw data for DEK-Info header (in case pem.Decode doesn't parse headers correctly)
|
||||||
|
if bytes.Contains(rawData, []byte("DEK-Info:")) || bytes.Contains(rawData, []byte("Proc-Type: 4,ENCRYPTED")) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|||||||
@ -1,13 +1,20 @@
|
|||||||
package server
|
package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/tls"
|
||||||
|
"crypto/x509"
|
||||||
|
"fmt"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/navidrome/navidrome/conf"
|
"github.com/navidrome/navidrome/conf"
|
||||||
|
"github.com/navidrome/navidrome/conf/configtest"
|
||||||
|
"github.com/navidrome/navidrome/tests"
|
||||||
. "github.com/onsi/ginkgo/v2"
|
. "github.com/onsi/ginkgo/v2"
|
||||||
. "github.com/onsi/gomega"
|
. "github.com/onsi/gomega"
|
||||||
)
|
)
|
||||||
@ -107,3 +114,146 @@ var _ = Describe("createUnixSocketFile", func() {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
var _ = Describe("TLS support", func() {
|
||||||
|
Describe("validateTLSCertificates", func() {
|
||||||
|
const testDataDir = "server/testdata"
|
||||||
|
|
||||||
|
When("certificate and key are valid and unencrypted", func() {
|
||||||
|
It("returns nil", func() {
|
||||||
|
certFile := filepath.Join(testDataDir, "test_cert.pem")
|
||||||
|
keyFile := filepath.Join(testDataDir, "test_key.pem")
|
||||||
|
err := validateTLSCertificates(certFile, keyFile)
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
When("private key is encrypted with PKCS#8 format", func() {
|
||||||
|
It("returns an error with helpful message", func() {
|
||||||
|
certFile := filepath.Join(testDataDir, "test_cert_encrypted.pem")
|
||||||
|
keyFile := filepath.Join(testDataDir, "test_key_encrypted.pem")
|
||||||
|
err := validateTLSCertificates(certFile, keyFile)
|
||||||
|
Expect(err).To(HaveOccurred())
|
||||||
|
Expect(err.Error()).To(ContainSubstring("encrypted"))
|
||||||
|
Expect(err.Error()).To(ContainSubstring("openssl"))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
When("private key is encrypted with legacy format (Proc-Type header)", func() {
|
||||||
|
It("returns an error with helpful message", func() {
|
||||||
|
certFile := filepath.Join(testDataDir, "test_cert.pem")
|
||||||
|
keyFile := filepath.Join(testDataDir, "test_key_encrypted_legacy.pem")
|
||||||
|
err := validateTLSCertificates(certFile, keyFile)
|
||||||
|
Expect(err).To(HaveOccurred())
|
||||||
|
Expect(err.Error()).To(ContainSubstring("encrypted"))
|
||||||
|
Expect(err.Error()).To(ContainSubstring("openssl"))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
When("key file does not exist", func() {
|
||||||
|
It("returns an error", func() {
|
||||||
|
certFile := filepath.Join(testDataDir, "test_cert.pem")
|
||||||
|
keyFile := filepath.Join(testDataDir, "nonexistent.pem")
|
||||||
|
err := validateTLSCertificates(certFile, keyFile)
|
||||||
|
Expect(err).To(HaveOccurred())
|
||||||
|
Expect(err.Error()).To(ContainSubstring("reading TLS key file"))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
When("key file does not contain valid PEM", func() {
|
||||||
|
It("returns an error", func() {
|
||||||
|
// Create a temp file with invalid PEM content
|
||||||
|
tmpFile, err := os.CreateTemp("", "invalid_key*.pem")
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
DeferCleanup(func() {
|
||||||
|
_ = os.Remove(tmpFile.Name())
|
||||||
|
})
|
||||||
|
_, err = tmpFile.WriteString("not a valid PEM file")
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
_ = tmpFile.Close()
|
||||||
|
|
||||||
|
certFile := filepath.Join(testDataDir, "test_cert.pem")
|
||||||
|
err = validateTLSCertificates(certFile, tmpFile.Name())
|
||||||
|
Expect(err).To(HaveOccurred())
|
||||||
|
Expect(err.Error()).To(ContainSubstring("valid PEM block"))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
When("certificate file does not exist", func() {
|
||||||
|
It("returns an error from tls.LoadX509KeyPair", func() {
|
||||||
|
certFile := filepath.Join(testDataDir, "nonexistent_cert.pem")
|
||||||
|
keyFile := filepath.Join(testDataDir, "test_key.pem")
|
||||||
|
err := validateTLSCertificates(certFile, keyFile)
|
||||||
|
Expect(err).To(HaveOccurred())
|
||||||
|
Expect(err.Error()).To(ContainSubstring("loading TLS certificate/key pair"))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
Describe("Server TLS", func() {
|
||||||
|
const testDataDir = "server/testdata"
|
||||||
|
|
||||||
|
When("server is started with valid TLS certificates", func() {
|
||||||
|
It("accepts HTTPS connections", func() {
|
||||||
|
DeferCleanup(configtest.SetupConfig())
|
||||||
|
|
||||||
|
// Create server with mock dependencies
|
||||||
|
ds := &tests.MockDataStore{}
|
||||||
|
server := New(ds, nil, nil)
|
||||||
|
|
||||||
|
// Load the test certificate to create a trusted CA pool
|
||||||
|
certFile := filepath.Join(testDataDir, "test_cert.pem")
|
||||||
|
keyFile := filepath.Join(testDataDir, "test_key.pem")
|
||||||
|
caCert, err := os.ReadFile(certFile)
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
|
||||||
|
caCertPool := x509.NewCertPool()
|
||||||
|
caCertPool.AppendCertsFromPEM(caCert)
|
||||||
|
|
||||||
|
// Create an HTTPS client that trusts our test certificate
|
||||||
|
httpClient := &http.Client{
|
||||||
|
Timeout: 5 * time.Second,
|
||||||
|
Transport: &http.Transport{
|
||||||
|
TLSClientConfig: &tls.Config{
|
||||||
|
RootCAs: caCertPool,
|
||||||
|
MinVersion: tls.VersionTLS12,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start the server in a goroutine
|
||||||
|
ctx, cancel := context.WithCancel(GinkgoT().Context())
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
errChan := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
errChan <- server.Run(ctx, "127.0.0.1", 14534, certFile, keyFile)
|
||||||
|
}()
|
||||||
|
|
||||||
|
Eventually(func() error {
|
||||||
|
// Make an HTTPS request to the server
|
||||||
|
resp, err := httpClient.Get("https://127.0.0.1:14534/ping")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return fmt.Errorf("unexpected status code: %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}, 2*time.Second, 100*time.Millisecond).Should(Succeed())
|
||||||
|
|
||||||
|
// Stop the server
|
||||||
|
cancel()
|
||||||
|
|
||||||
|
// Wait for server to stop (with timeout)
|
||||||
|
select {
|
||||||
|
case <-errChan:
|
||||||
|
// Server stopped
|
||||||
|
case <-time.After(2 * time.Second):
|
||||||
|
Fail("Server did not stop in time")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
23
server/testdata/test_cert.pem
vendored
Normal file
23
server/testdata/test_cert.pem
vendored
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
-----BEGIN CERTIFICATE-----
|
||||||
|
MIIDwzCCAqugAwIBAgIUXqdUxUOo8kmsDe71iTR+Vr7btP8wDQYJKoZIhvcNAQEL
|
||||||
|
BQAwYjELMAkGA1UEBhMCVVMxDTALBgNVBAgMBFRlc3QxDTALBgNVBAcMBFRlc3Qx
|
||||||
|
EjAQBgNVBAoMCU5hdmlkcm9tZTENMAsGA1UECwwEVGVzdDESMBAGA1UEAwwJbG9j
|
||||||
|
YWxob3N0MCAXDTI1MTEyODE5NTkxNVoYDzIxMjUxMTA0MTk1OTE1WjBiMQswCQYD
|
||||||
|
VQQGEwJVUzENMAsGA1UECAwEVGVzdDENMAsGA1UEBwwEVGVzdDESMBAGA1UECgwJ
|
||||||
|
TmF2aWRyb21lMQ0wCwYDVQQLDARUZXN0MRIwEAYDVQQDDAlsb2NhbGhvc3QwggEi
|
||||||
|
MA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCkB/TQgl5ei5KRSHt5OJim8rKS
|
||||||
|
MzRlkK4BjSEM4D9ESbebdpEVjX48QuBYACrCvgvVp7mQGF5anl8Hm89trvd8ooVQ
|
||||||
|
x9IPQQ6gRKM+4gLrt9FHvFGGzZQS8UTQXN5oBi11E+8/Vs47HLUNXC2TRtRLCMyK
|
||||||
|
LYXQIXbhdp9anImlt+IHUxIQUchK6Zkld/gCm56X1bbzN/Zq91PQLpx2FZ0eZTjN
|
||||||
|
KaNgztLa+K/BDnTuk3iTTs9GEp6VCvqQE/6fk/UN/tkk2dLwKIFvPVR/YeAhVdz/
|
||||||
|
OHC4L3B36QN3+VQ2yDjsp1PVAPX07UnzXO3Oj7uGYnMQxwprGMEubm3nADDxAgMB
|
||||||
|
AAGjbzBtMB0GA1UdDgQWBBRAZHUVuLyzc0CfuZR9ApqMbawIqzAfBgNVHSMEGDAW
|
||||||
|
gBRAZHUVuLyzc0CfuZR9ApqMbawIqzAPBgNVHRMBAf8EBTADAQH/MBoGA1UdEQQT
|
||||||
|
MBGCCWxvY2FsaG9zdIcEfwAAATANBgkqhkiG9w0BAQsFAAOCAQEAmDLXcPx9LNHs
|
||||||
|
GxQIE6Q5BXbVO7c8qrWmJf5FK5VWaifNZ9U+IBi+VlB4jCLK/OkwsviN/jOnwRYx
|
||||||
|
owjq0QG0YdRT4uD9fEMrAj+EwbnrQYZQvT0yGEWA+KW5TW08wt+/qnGJDwEgbjYJ
|
||||||
|
HTdICVMhs/e8Ex48fAgO8WSsdTDekOrhuwzIfeJ1LU4ZptLsD2ePFxuzutdIuW51
|
||||||
|
/mspQGsjXqZ1qnLsavLXh/lds2g602rTpYBNZVjV9WiOvaQS8vviOxBN6f+9vgRz
|
||||||
|
a8SEbHqBG6jeyVqVZ7MjxcYxaIkxeBwMyMwgb+wwDfVXo2FZzX2TVeB7ZppI+IKv
|
||||||
|
TXYurWPYsQ==
|
||||||
|
-----END CERTIFICATE-----
|
||||||
22
server/testdata/test_cert_encrypted.pem
vendored
Normal file
22
server/testdata/test_cert_encrypted.pem
vendored
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
-----BEGIN CERTIFICATE-----
|
||||||
|
MIIDpzCCAo+gAwIBAgIUEa7gEJYwJqYEJjTY7otQ+oUyELwwDQYJKoZIhvcNAQEL
|
||||||
|
BQAwYjELMAkGA1UEBhMCVVMxDTALBgNVBAgMBFRlc3QxDTALBgNVBAcMBFRlc3Qx
|
||||||
|
EjAQBgNVBAoMCU5hdmlkcm9tZTENMAsGA1UECwwEVGVzdDESMBAGA1UEAwwJbG9j
|
||||||
|
YWxob3N0MCAXDTI1MTEyODE5NTI0OVoYDzIxMjUxMTA0MTk1MjQ5WjBiMQswCQYD
|
||||||
|
VQQGEwJVUzENMAsGA1UECAwEVGVzdDENMAsGA1UEBwwEVGVzdDESMBAGA1UECgwJ
|
||||||
|
TmF2aWRyb21lMQ0wCwYDVQQLDARUZXN0MRIwEAYDVQQDDAlsb2NhbGhvc3QwggEi
|
||||||
|
MA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDBHgqJ1d9EnNxqoSZ6xXrIz/mV
|
||||||
|
Y0nWJW16/qIAvCdovSeTZhG9iqG8dUqcuu2BdD9MMHndJ2oFn3iD8EJR92dH8KBA
|
||||||
|
8xOmtZ0BEEWgXPBivywZVd1ChIflEWj6m5wwLNjb57SPpUiwaLxBQB8ByEaAAZE/
|
||||||
|
bLqvHI3vW/4s5apky17SPIqmkmqEYlRcg97tlRXsPuwoAVM9cvLMMEqtIR1CB/72
|
||||||
|
gboY2Gi2r/plLF/Rg3Dom6QljMWi57XXWJFwGYSXaZuM0gvn04e3oLu+1E+WMoq/
|
||||||
|
9rExWij2DlsmXd/RiScliFp6R4H84wQUyqrAUNytvgRO+oVnRjEA0l3oCYdRAgMB
|
||||||
|
AAGjUzBRMB0GA1UdDgQWBBQQKpB1UaKm98FnBdl8uKdRscrVTzAfBgNVHSMEGDAW
|
||||||
|
gBQQKpB1UaKm98FnBdl8uKdRscrVTzAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3
|
||||||
|
DQEBCwUAA4IBAQBP07l+2LmpFtcxqMGmsiNYwFuHpQCxJd4YRZHjLX7O+oJExMgR
|
||||||
|
2yP4mpMKurgKOv7unTDLwvjQRa6ZTYJCsYtvC6hbyqlGc7AfNTu6DKz8r35/2/V5
|
||||||
|
hPsG5lNb91HhvHE839mLAvpi02LoFH2Sr8BR7s6qxfNKYcP8PUOJQXltJ6yAa8YJ
|
||||||
|
syeXQQ3RIyGsJANeaC06S3UdkBM5H5BLfIHnHu3GybJjwL51va4WCdHe8QV6GI0g
|
||||||
|
RDiThDVkBSXAr136vnMdlrYCxMoxY56itJ0zbYg2ELQKU9o1w/ZJQo9uvmy9jCoZ
|
||||||
|
Hy1L5a2vUDbsdONdvRkYZRHqMpG4bdD8D3j2
|
||||||
|
-----END CERTIFICATE-----
|
||||||
28
server/testdata/test_key.pem
vendored
Normal file
28
server/testdata/test_key.pem
vendored
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
-----BEGIN PRIVATE KEY-----
|
||||||
|
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCkB/TQgl5ei5KR
|
||||||
|
SHt5OJim8rKSMzRlkK4BjSEM4D9ESbebdpEVjX48QuBYACrCvgvVp7mQGF5anl8H
|
||||||
|
m89trvd8ooVQx9IPQQ6gRKM+4gLrt9FHvFGGzZQS8UTQXN5oBi11E+8/Vs47HLUN
|
||||||
|
XC2TRtRLCMyKLYXQIXbhdp9anImlt+IHUxIQUchK6Zkld/gCm56X1bbzN/Zq91PQ
|
||||||
|
Lpx2FZ0eZTjNKaNgztLa+K/BDnTuk3iTTs9GEp6VCvqQE/6fk/UN/tkk2dLwKIFv
|
||||||
|
PVR/YeAhVdz/OHC4L3B36QN3+VQ2yDjsp1PVAPX07UnzXO3Oj7uGYnMQxwprGMEu
|
||||||
|
bm3nADDxAgMBAAECggEABqJFvesP2v4FEvgd+kSWM+ZL34rPmy3zQ5/MDuPA20ep
|
||||||
|
89EjQ/5hdRl1TknPcOnTu7PZVuENa9fM2xdrl7GEU9eU0bQLJE/KwiOUgJYObS8V
|
||||||
|
eTO+DlghHXUBhfXDjux1CS+htOuTUqOyFNS+CR9Lta8o6ou1xjmcP7kW78i17mxF
|
||||||
|
TuH5SZlS8W9PFLXHCInbMtqGFaT2ss09kvoPk2FDvHfxEdy6M9tKkguz02g+4bqI
|
||||||
|
aAMp2N7AOfmRpC0HvVa1ZfZo5Z8/KMoNcIm3pV9DEVM369J9EzhnMNpkGben90aT
|
||||||
|
FqO2JNsy52wmXFZUc9xe8uPdfDahALCkBGncLyLNmQKBgQDZREjocjdzOoPSlCdx
|
||||||
|
mRNe9suHz2FpUpsHCPOCotG63hFVKpah/ZvpHSsQx5rXs/mawDTmzGY9GQiBrSvg
|
||||||
|
OhfHIyT3NOhVaNcMxTqJX7rs7OG8D0MBacD9ASSeZ89MUn8q1EHZr5qxLtXl5Ikw
|
||||||
|
mHtiGRdiKGFFrG9H0zncbGhy7QKBgQDBRhQ9RAasTdmUiNQly9GVFkXto4T/9UHx
|
||||||
|
rVU44htCI2IVZUMTGlNfclfxpByDrzyA56rMzN9SAkiIp4nPpMDs5hayXaaPoojs
|
||||||
|
CPzV7r2OjemZ6CTeQ1ODImRL8L/E3jJSgWd6YYoHSQ5hjEX4yT6ft0u0tZUfdMKd
|
||||||
|
VENWIJ/hlQKBgQCo2hXjeOi5R8+tN3EUKwhP9HOnX7dv+D/9jqpZa5qdpPpJeyjI
|
||||||
|
SmYCHKYci1Q+sWOaLiiu+km20B65UVFZGSzjmd+fs+GghzMifKGKo/iNK2ggFKhZ
|
||||||
|
j8vplRrVdQ45XZ/xNDbdLEmHzEN2QE+Skd7KFYADzCgU0vdFFdbRBPuD3QKBgGIq
|
||||||
|
fQctMRJ9LCE0akSURGwr9vKflmMHKCpfdqTAu0WZgS0K1Mm0GlqlUiPKzizYaauz
|
||||||
|
f14sRNV7kWnPZsDPlqn8p9SKmpnj3RW97uWeMCtiyx6/+VHm8ljts/GaY1zT2s1r
|
||||||
|
KqrPNfNDWQmU3MljNeqbh9lOTWK/xEVy0gzB31MNAoGAQNWrZvVdAbL95XW6STUu
|
||||||
|
JmQlqJTlluuqS0Rrd/uVEQwW0Vd1dZjRQcFAFiSiCQWTbtId5gFZd6hiIQl53Xz0
|
||||||
|
5cd+9mcyA/TaoCJYbMOFYsKbZMCBhefsovJlVQXedqJrIY6BdeGlet4GTAH5Qyl0
|
||||||
|
ytEIUnvn5YmmbI7PDz80XpU=
|
||||||
|
-----END PRIVATE KEY-----
|
||||||
30
server/testdata/test_key_encrypted.pem
vendored
Normal file
30
server/testdata/test_key_encrypted.pem
vendored
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
-----BEGIN ENCRYPTED PRIVATE KEY-----
|
||||||
|
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
|
||||||
|
-----END ENCRYPTED PRIVATE KEY-----
|
||||||
30
server/testdata/test_key_encrypted_legacy.pem
vendored
Normal file
30
server/testdata/test_key_encrypted_legacy.pem
vendored
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
-----BEGIN RSA PRIVATE KEY-----
|
||||||
|
Proc-Type: 4,ENCRYPTED
|
||||||
|
DEK-Info: AES-256-CBC,3C969050EAB73F121B7F0E6B75C42525
|
||||||
|
|
||||||
|
V6pSaAsrn9CQNo4p88QshJLbg8zkQJEom81dPbYSVqQSZa9YlPtpLZ9YtuLj/Ay0
|
||||||
|
TScEKIj/gzQ32wNl6nhcSNIL9yy+X11r5gNv1kIHkecf+EbDW20VOiJsfD+6LUyW
|
||||||
|
hA96AIbPOwc76iCuvsKHPKU9MlEmjGipmk/C2RQLHCZJ3WkiDRgCM8KQ7vKhfACT
|
||||||
|
w908yj4cB1e/P0JPq8t/3F7kPJ+6SVM1vMEffHl0otQR3rAyrK8QikwJ0K9qX62d
|
||||||
|
cqchTVlEyyZBYovR8DrRRUDbsXS5j1ZmX3NQpvTSTFowr+33fMrY+4Oz8sdR4yx1
|
||||||
|
CQc0A0sHHxSEIr2xu4KzczwOYVJN8PVdU0pgvFj9KEm66N6EY5CSFIBHyO/ycOt9
|
||||||
|
U+wpkRjf3zS6ZaUU0NKdOcop4YX33i99/tZF2RNR1i7ETLYph+/LCf09286Bi3u/
|
||||||
|
UCCuWedyECPdz0c6j0s27Fdfc/HEK90OEzeWh/fc+H2gJZhqJYK9V47HPTQNNMnB
|
||||||
|
U1a6FsJlrKE3E6nfSnTLxrSx9m/XTV7HV+HkgX+q8VhN7Q2VHUqkPzE7ZOPYpZ+A
|
||||||
|
dQzsm1TmEMxym6osYqFzQScXR1NZasrV2MTQ2J16dUgCdGAM2YMUD9JaoJR+u77M
|
||||||
|
WAjYzDiRg84rLr/KbJPAwHbsfo2KpiapJGSBBEDhz4W1/LOrFhsjaqIMSy4yZDGm
|
||||||
|
1KqXGHIlqmuHI7v4fD8vuzhj7GUujRx85HSZWakE/uc6s5WrhkSeVKYJWPfpsxTv
|
||||||
|
dT3oLOGJ+nRzWxM3aFtuJghX0nIGdKxT4EAUNXz0/vLT3OP1QCZR+oELrriFzmtj
|
||||||
|
+O30bGH2SAFZEQJ/uTQg6celoNh89IzH4DJkcn67hqpX6mUiU9CrIr/eR9C/en8Q
|
||||||
|
smTbbC1C1pDUaCwR26Z+zgM90amh4yfOFKK2geO2Kj+TmwFHUvi6ZnSzMzCvty3t
|
||||||
|
+wdIrUtf55Lw51JCpLGl70mg4b/zBj5hqBkU2YvAAnz/htjfH/wrD6ZAF1TCdlRO
|
||||||
|
gyODrJjGRnLd/v0XLk0wp+RkAjBcSlRlkUvZY5BtugL7dIdwiNGGQPcOni9IVeG0
|
||||||
|
6vDUEQnDOLYDj4d/JcckTLuHdrP+SW+0RQl2HK5+/w1hScGXN4O48gccu7yR/MN8
|
||||||
|
DmpCg5rD/nq8sxJosmSt07GrN36KppYt8LCXQbSg3NG2Ad715caS2C+0Qtdm5MPD
|
||||||
|
rM1UyTXQYSJXgUN9yZS/pmzlguCywnnvsBPU6j3ljZwcoD41QJ/1OU09/W6sIMQR
|
||||||
|
IAiM35JHiLJiccFgxSE1qx5F1UZqX4P47jF0Wzi/sE/DYXg5qw2DoauqXNzqnumH
|
||||||
|
71UDGK1V6wQIV7UCZDa0WUfFzu470XpuFb8VmMOuHSQxkZESc9cz8k/ueAuO438Q
|
||||||
|
jnlkF1Ge2EEPuaK2zeaTj/lGyYA1AUfHRRgt/EMUQSBntmhlpnwVPYTVvYtHO2N5
|
||||||
|
wp7/y39KirnlTl99i3XiOJ4WF4gIU2IaSlqMo4+e/A32h2JFi9QfNyfItXe6Fm1X
|
||||||
|
d0j2XGHzwMfHEFKdWyrgtVZwc38/1d6xWYAhs02b2basV/0AQhFTaKf5Z268eBNJ
|
||||||
|
-----END RSA PRIVATE KEY-----
|
||||||
@ -47,17 +47,15 @@ const stylesheet = `
|
|||||||
.react-jinke-music-player-main .music-player-panel,
|
.react-jinke-music-player-main .music-player-panel,
|
||||||
.react-jinke-music-player-mobile,
|
.react-jinke-music-player-mobile,
|
||||||
.ril__outer{
|
.ril__outer{
|
||||||
background-color: #1f1f1f;
|
background-color: #1a1a1a;
|
||||||
border: 1px solid #fff1;
|
border: 1px solid #fff1;
|
||||||
}
|
}
|
||||||
.ril__toolbar{
|
|
||||||
background-color: #1d1d1d
|
|
||||||
}
|
|
||||||
.ril__toolbarItem{
|
.ril__toolbarItem{
|
||||||
font-size: 100%;
|
font-size: 100%;
|
||||||
color: #eee
|
color: #eee
|
||||||
}
|
}
|
||||||
.audio-lists-panel{
|
.audio-lists-panel,
|
||||||
|
.ril__toolbar{
|
||||||
background-color: #1f1f1f;
|
background-color: #1f1f1f;
|
||||||
border: 1px solid #fff1;
|
border: 1px solid #fff1;
|
||||||
border-radius: 6px 6px 0 0;
|
border-radius: 6px 6px 0 0;
|
||||||
|
|||||||
@ -137,22 +137,19 @@ export default {
|
|||||||
albumName: {
|
albumName: {
|
||||||
color: '#eee',
|
color: '#eee',
|
||||||
},
|
},
|
||||||
albumSubtitle: {
|
|
||||||
color: '#ccc',
|
|
||||||
},
|
|
||||||
albumPlayButton: {
|
albumPlayButton: {
|
||||||
color: '#ff4e6b !important',
|
color: '#ff4e6b',
|
||||||
},
|
},
|
||||||
albumArtistName: {
|
albumArtistName: {
|
||||||
color: '#ff4e6b !important',
|
color: '#ccc',
|
||||||
},
|
},
|
||||||
cover: {
|
cover: {
|
||||||
borderRadius: '10px !important',
|
borderRadius: '6px',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
NDLogin: {
|
NDLogin: {
|
||||||
systemNameLink: {
|
systemNameLink: {
|
||||||
color: '#D60017',
|
color: '#ff4e6b',
|
||||||
},
|
},
|
||||||
welcome: {
|
welcome: {
|
||||||
color: '#eee',
|
color: '#eee',
|
||||||
@ -161,6 +158,9 @@ export default {
|
|||||||
minWidth: 300,
|
minWidth: 300,
|
||||||
backgroundColor: '#1d1d1d',
|
backgroundColor: '#1d1d1d',
|
||||||
},
|
},
|
||||||
|
icon: {
|
||||||
|
filter: 'hue-rotate(115deg)',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
MuiPaper: {
|
MuiPaper: {
|
||||||
elevation1: {
|
elevation1: {
|
||||||
@ -169,6 +169,9 @@ export default {
|
|||||||
root: {
|
root: {
|
||||||
color: '#eee',
|
color: '#eee',
|
||||||
},
|
},
|
||||||
|
rounded: {
|
||||||
|
borderRadius: '6px',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
NDMobileArtistDetails: {
|
NDMobileArtistDetails: {
|
||||||
bgContainer: {
|
bgContainer: {
|
||||||
@ -189,6 +192,30 @@ export default {
|
|||||||
paddingBottom: '1rem',
|
paddingBottom: '1rem',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
RaDeleteWithConfirmButton: {
|
||||||
|
deleteButton: {
|
||||||
|
color: 'unset',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
RaPaginationActions: {
|
||||||
|
currentPageButton: {
|
||||||
|
border: '2px solid #D60017',
|
||||||
|
background: 'transparent',
|
||||||
|
},
|
||||||
|
button: {
|
||||||
|
border: '2px solid #D60017',
|
||||||
|
},
|
||||||
|
actions: {
|
||||||
|
'@global': {
|
||||||
|
'.next-page': {
|
||||||
|
border: '0 none',
|
||||||
|
},
|
||||||
|
'.previous-page': {
|
||||||
|
border: '0 none',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
player: {
|
player: {
|
||||||
theme: 'dark',
|
theme: 'dark',
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user