mirror of
https://github.com/navidrome/navidrome.git
synced 2026-05-03 06:51:16 +00:00
Compare commits
6 Commits
451f0795ba
...
42890bc526
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
42890bc526 | ||
|
|
e36fef8692 | ||
|
|
9913235542 | ||
|
|
a87b6a50a6 | ||
|
|
2b30ed1520 | ||
|
|
1024d61a5e |
20
.github/workflows/pipeline.yml
vendored
20
.github/workflows/pipeline.yml
vendored
@ -159,23 +159,6 @@ jobs:
|
||||
done
|
||||
- run: ./.github/workflows/validate-translations.sh -v
|
||||
|
||||
migrations-lint:
|
||||
name: Validate migrations
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'pull_request'
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Fetch master branch
|
||||
run: git fetch origin master
|
||||
|
||||
- name: Validate migration timestamps
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
run: ./.github/workflows/validate-migrations.sh
|
||||
|
||||
check-push-enabled:
|
||||
name: Check Docker configuration
|
||||
@ -189,8 +172,7 @@ jobs:
|
||||
|
||||
build:
|
||||
name: Build
|
||||
needs: [js, go, go-lint, i18n-lint, migrations-lint, git-version, check-push-enabled]
|
||||
if: ${{ !failure() && !cancelled() }}
|
||||
needs: [js, go, go-lint, i18n-lint, git-version, check-push-enabled]
|
||||
strategy:
|
||||
matrix:
|
||||
platform: [ linux/amd64, linux/arm64, linux/arm/v5, linux/arm/v6, linux/arm/v7, linux/386, darwin/amd64, darwin/arm64, windows/amd64, windows/386 ]
|
||||
|
||||
103
.github/workflows/validate-migrations.sh
vendored
103
.github/workflows/validate-migrations.sh
vendored
@ -1,103 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Validates that new migrations in a PR have timestamps greater than
|
||||
# the latest migration timestamp on the master branch.
|
||||
#
|
||||
# This prevents migration ordering conflicts when multiple PRs add migrations.
|
||||
# Modified existing migrations only produce warnings, not errors.
|
||||
|
||||
set -e
|
||||
|
||||
# Get the latest migration timestamp from master branch
|
||||
# Filter for files matching the pattern: 14-digit timestamp followed by _ and ending in .sql or .go
|
||||
MASTER_MIGRATIONS=$(git ls-tree --name-only origin/master -- db/migrations/ | grep -E '^db/migrations/[0-9]{14}_.*\.(sql|go)$' || true)
|
||||
|
||||
if [ -z "$MASTER_MIGRATIONS" ]; then
|
||||
echo "No migrations found on master branch"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
MASTER_LATEST=$(echo "$MASTER_MIGRATIONS" | sed 's|db/migrations/||' | cut -c1-14 | sort -n | tail -1)
|
||||
|
||||
# Get NEW migrations (added in this PR)
|
||||
NEW_MIGRATIONS=$(git diff --name-only --diff-filter=A origin/master -- db/migrations/ | grep -E '^db/migrations/[0-9]{14}_.*\.(sql|go)$' || true)
|
||||
|
||||
# Get MODIFIED migrations (existing files that were changed)
|
||||
MODIFIED_MIGRATIONS=$(git diff --name-only --diff-filter=M origin/master -- db/migrations/ | grep -E '^db/migrations/[0-9]{14}_.*\.(sql|go)$' || true)
|
||||
|
||||
if [ -z "$NEW_MIGRATIONS" ] && [ -z "$MODIFIED_MIGRATIONS" ]; then
|
||||
echo "No new or modified migrations found in this PR"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "Latest migration on master: $MASTER_LATEST"
|
||||
|
||||
HAS_ERRORS=false
|
||||
|
||||
# Check NEW migrations - these MUST have valid timestamps (errors)
|
||||
if [ -n "$NEW_MIGRATIONS" ]; then
|
||||
echo ""
|
||||
echo "New migrations in this PR:"
|
||||
for migration in $NEW_MIGRATIONS; do
|
||||
TIMESTAMP=$(basename "$migration" | cut -c1-14)
|
||||
echo " - $migration (timestamp: $TIMESTAMP)"
|
||||
|
||||
if [ "$TIMESTAMP" -le "$MASTER_LATEST" ]; then
|
||||
echo "::error file=$migration::Migration timestamp $TIMESTAMP must be greater than latest master timestamp $MASTER_LATEST"
|
||||
HAS_ERRORS=true
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
# Check MODIFIED migrations - only warn, don't fail
|
||||
if [ -n "$MODIFIED_MIGRATIONS" ]; then
|
||||
echo ""
|
||||
echo "Modified existing migrations in this PR:"
|
||||
for migration in $MODIFIED_MIGRATIONS; do
|
||||
TIMESTAMP=$(basename "$migration" | cut -c1-14)
|
||||
echo " - $migration (timestamp: $TIMESTAMP)"
|
||||
echo "::warning file=$migration::Modifying existing migration files may cause issues for users who have already applied them"
|
||||
done
|
||||
|
||||
# Post a PR review comment if running in GitHub Actions with a PR
|
||||
if [ -n "$GITHUB_TOKEN" ] && [ -n "$GITHUB_REPOSITORY" ] && [ -n "$PR_NUMBER" ]; then
|
||||
# Check if a warning comment already exists to avoid duplicates
|
||||
EXISTING_COMMENT=$(curl -s \
|
||||
-H "Authorization: token $GITHUB_TOKEN" \
|
||||
-H "Accept: application/vnd.github.v3+json" \
|
||||
"https://api.github.com/repos/$GITHUB_REPOSITORY/issues/$PR_NUMBER/comments" \
|
||||
| jq -r '.[] | select(.body | startswith("### ⚠️ Modified Migration Files Detected")) | .id' | head -1)
|
||||
|
||||
if [ -n "$EXISTING_COMMENT" ]; then
|
||||
echo "Warning comment already exists (comment ID: $EXISTING_COMMENT), skipping"
|
||||
else
|
||||
COMMENT_BODY="### ⚠️ Modified Migration Files Detected
|
||||
|
||||
This PR modifies existing migration files that may have already been applied by users:
|
||||
|
||||
$(for m in $MODIFIED_MIGRATIONS; do echo "- \`$m\`"; done)
|
||||
|
||||
**Warning:** Modifying migrations that have already been applied can cause issues for existing users. Please ensure this change is intentional and consider the impact on users who have already run these migrations."
|
||||
|
||||
# Use GitHub API to post a PR comment
|
||||
curl -s -X POST \
|
||||
-H "Authorization: token $GITHUB_TOKEN" \
|
||||
-H "Accept: application/vnd.github.v3+json" \
|
||||
"https://api.github.com/repos/$GITHUB_REPOSITORY/issues/$PR_NUMBER/comments" \
|
||||
-d "$(jq -n --arg body "$COMMENT_BODY" '{body: $body}')" > /dev/null
|
||||
|
||||
echo "Posted PR comment about modified migrations"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ "$HAS_ERRORS" = "true" ]; then
|
||||
echo ""
|
||||
echo "ERROR: One or more NEW migrations have timestamps that are not after the latest migration on master."
|
||||
echo "Please regenerate the migration with a newer timestamp using:"
|
||||
echo " make migration-sql name=your_migration_name"
|
||||
echo " make migration-go name=your_migration_name"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "All migration timestamps are valid!"
|
||||
@ -113,9 +113,9 @@ func WithAdminUser(ctx context.Context, ds model.DataStore) context.Context {
|
||||
if err != nil {
|
||||
c, err := ds.User(ctx).CountAll()
|
||||
if c == 0 && err == nil {
|
||||
log.Debug(ctx, "Scanner: No admin user yet!", err)
|
||||
log.Debug(ctx, "No admin user yet!", err)
|
||||
} else {
|
||||
log.Error(ctx, "Scanner: No admin user found!", err)
|
||||
log.Error(ctx, "No admin user found!", err)
|
||||
}
|
||||
u = &model.User{}
|
||||
}
|
||||
|
||||
@ -22,6 +22,7 @@ import (
|
||||
"github.com/navidrome/navidrome/core/metrics/insights"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
"github.com/navidrome/navidrome/plugins/schema"
|
||||
"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) {
|
||||
ctx = auth.WithAdminUser(ctx, c.ds)
|
||||
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 {
|
||||
case <-time.After(consts.InsightsUpdateInterval):
|
||||
continue
|
||||
|
||||
@ -264,6 +264,11 @@ func (r *playlistRepository) refreshSmartPlaylist(pls *model.Playlist) bool {
|
||||
"annotation.item_id = media_file.id" +
|
||||
" AND annotation.item_type = 'media_file'" +
|
||||
" 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)
|
||||
insSql := Insert("playlist_tracks").Columns("id", "playlist_id", "media_file_id").Select(sq)
|
||||
_, err = r.executeSQL(insSql)
|
||||
|
||||
@ -366,4 +366,136 @@ var _ = Describe("PlaylistRepository", func() {
|
||||
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 (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/deluan/rest"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
@ -45,6 +46,9 @@ var _ = Describe("Tag Library Filtering", func() {
|
||||
BeforeEach(func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
|
||||
// Generate unique path suffix to avoid conflicts with other tests
|
||||
uniqueSuffix := time.Now().Format("20060102150405.000")
|
||||
|
||||
// Clean up database
|
||||
db := GetDBXBuilder()
|
||||
_, 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()
|
||||
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})").
|
||||
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())
|
||||
_, 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())
|
||||
|
||||
// Give admin access to all libraries
|
||||
|
||||
@ -1,8 +1,11 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"cmp"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
@ -69,6 +72,13 @@ func (s *Server) Run(ctx context.Context, addr string, port int, tlsCert string,
|
||||
// Determine if TLS is enabled
|
||||
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)
|
||||
var listener net.Listener
|
||||
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
|
||||
errC := make(chan error)
|
||||
go func() {
|
||||
var err error
|
||||
if tlsEnabled {
|
||||
// Start the HTTPS server
|
||||
log.Info("Starting server with TLS (HTTPS) enabled", "tlsCert", tlsCert, "tlsKey", tlsKey)
|
||||
if err := server.ServeTLS(listener, tlsCert, tlsKey); !errors.Is(err, http.ErrServerClosed) {
|
||||
errC <- err
|
||||
}
|
||||
err = server.ServeTLS(listener, tlsCert, tlsKey)
|
||||
} else {
|
||||
// Start the HTTP server
|
||||
if err := server.Serve(listener); !errors.Is(err, http.ErrServerClosed) {
|
||||
errC <- err
|
||||
err = server.Serve(listener)
|
||||
}
|
||||
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()
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"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"
|
||||
)
|
||||
@ -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-mobile,
|
||||
.ril__outer{
|
||||
background-color: #1f1f1f;
|
||||
background-color: #1a1a1a;
|
||||
border: 1px solid #fff1;
|
||||
}
|
||||
.ril__toolbar{
|
||||
background-color: #1d1d1d
|
||||
}
|
||||
.ril__toolbarItem{
|
||||
font-size: 100%;
|
||||
color: #eee
|
||||
}
|
||||
.audio-lists-panel{
|
||||
.audio-lists-panel,
|
||||
.ril__toolbar{
|
||||
background-color: #1f1f1f;
|
||||
border: 1px solid #fff1;
|
||||
border-radius: 6px 6px 0 0;
|
||||
|
||||
@ -137,22 +137,19 @@ export default {
|
||||
albumName: {
|
||||
color: '#eee',
|
||||
},
|
||||
albumSubtitle: {
|
||||
color: '#ccc',
|
||||
},
|
||||
albumPlayButton: {
|
||||
color: '#ff4e6b !important',
|
||||
color: '#ff4e6b',
|
||||
},
|
||||
albumArtistName: {
|
||||
color: '#ff4e6b !important',
|
||||
color: '#ccc',
|
||||
},
|
||||
cover: {
|
||||
borderRadius: '10px !important',
|
||||
borderRadius: '6px',
|
||||
},
|
||||
},
|
||||
NDLogin: {
|
||||
systemNameLink: {
|
||||
color: '#D60017',
|
||||
color: '#ff4e6b',
|
||||
},
|
||||
welcome: {
|
||||
color: '#eee',
|
||||
@ -161,6 +158,9 @@ export default {
|
||||
minWidth: 300,
|
||||
backgroundColor: '#1d1d1d',
|
||||
},
|
||||
icon: {
|
||||
filter: 'hue-rotate(115deg)',
|
||||
},
|
||||
},
|
||||
MuiPaper: {
|
||||
elevation1: {
|
||||
@ -169,6 +169,9 @@ export default {
|
||||
root: {
|
||||
color: '#eee',
|
||||
},
|
||||
rounded: {
|
||||
borderRadius: '6px',
|
||||
},
|
||||
},
|
||||
NDMobileArtistDetails: {
|
||||
bgContainer: {
|
||||
@ -189,6 +192,30 @@ export default {
|
||||
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: {
|
||||
theme: 'dark',
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user