mirror of
https://github.com/navidrome/navidrome.git
synced 2026-05-01 21:00:25 +00:00
* feat(playlist): add migration for playlist image field rename and external URL * refactor(playlist): rename ImageFile to UploadedImage and ArtworkPath to UploadedImagePath Rename playlist model fields and methods for clarity in preparation for adding external image URL and sidecar image support. Add the new ExternalImageURL field to the Playlist model. * feat(playlist): parse #EXTALBUMARTURL directive in M3U imports * feat(playlist): always sync ExternalImageURL on re-scan, preserve UploadedImage * feat(artwork): add sidecar image discovery and cache invalidation for playlists Add playlist sidecar image support to the artwork reader fallback chain. A sidecar image (e.g., MyPlaylist.jpg next to MyPlaylist.m3u) is discovered via case-insensitive base name matching using model.IsImageFile(). Cache invalidation uses max(playlist.UpdatedAt, imageFile.ModTime()) to bust stale artwork when sidecar or ExternalImageURL local files change. * feat(artwork): add external image URL source to playlist artwork reader Add fromPlaylistExternalImage source function that resolves playlist cover art from ExternalImageURL, supporting both HTTP(S) URLs (via the existing fromURL helper) and local file paths (via os.Open). Insert it in the Reader() fallback chain between sidecar and tiled cover. * refactor(artwork): simplify playlist artwork source functions Extract shared fromLocalFile helper, use url.Parse for scheme check, and collapse sidecar directory scan conditions. * test(artwork): remove redundant fromPlaylistSidecar tests These tests duplicated scenarios already covered by findPlaylistSidecarPath tests combined with fromLocalFile (tested via fromPlaylistExternalImage). After refactoring fromPlaylistSidecar to a one-liner composing those two functions, the wrapper tests add no value. * fix(playlist): address security review comments from PR #5131: - Use url.PathUnescape instead of url.QueryUnescape for file:// URLs so that '+' in filenames is preserved (not decoded as space). - Validate all local image paths (file://, absolute, relative) against known library boundaries via libraryMatcher, rejecting paths outside any configured library. - Harden #EXTALBUMARTURL against path traversal and SSRF by adding EnableM3UExternalAlbumArt config flag (default false, also disabled by EnableExternalServices=false) to gate HTTP(S) URL storage at parse time and fetching at read time (defense in depth). - Log a warning when os.ReadDir fails in findPlaylistSidecarPath for diagnosability. - Extract resolveLocalPath helper to simplify resolveImageURL. Signed-off-by: Deluan <deluan@navidrome.org> * feat(playlist): implement human-friendly filename generation for uploaded playlist cover images Signed-off-by: Deluan <deluan@navidrome.org> --------- Signed-off-by: Deluan <deluan@navidrome.org>
221 lines
6.2 KiB
Go
221 lines
6.2 KiB
Go
package utils_test
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"github.com/navidrome/navidrome/utils"
|
|
. "github.com/onsi/ginkgo/v2"
|
|
. "github.com/onsi/gomega"
|
|
)
|
|
|
|
var _ = Describe("TempFileName", func() {
|
|
It("creates a temporary file name with prefix and suffix", func() {
|
|
prefix := "test-"
|
|
suffix := ".tmp"
|
|
result := utils.TempFileName(prefix, suffix)
|
|
|
|
Expect(result).To(ContainSubstring(prefix))
|
|
Expect(result).To(HaveSuffix(suffix))
|
|
Expect(result).To(ContainSubstring(os.TempDir()))
|
|
})
|
|
|
|
It("creates unique file names on multiple calls", func() {
|
|
prefix := "unique-"
|
|
suffix := ".test"
|
|
|
|
result1 := utils.TempFileName(prefix, suffix)
|
|
result2 := utils.TempFileName(prefix, suffix)
|
|
|
|
Expect(result1).NotTo(Equal(result2))
|
|
})
|
|
|
|
It("handles empty prefix and suffix", func() {
|
|
result := utils.TempFileName("", "")
|
|
|
|
Expect(result).To(ContainSubstring(os.TempDir()))
|
|
Expect(len(result)).To(BeNumerically(">", len(os.TempDir())))
|
|
})
|
|
|
|
It("creates proper file path separators", func() {
|
|
prefix := "path-test-"
|
|
suffix := ".ext"
|
|
result := utils.TempFileName(prefix, suffix)
|
|
|
|
expectedDir := os.TempDir()
|
|
Expect(result).To(HavePrefix(expectedDir))
|
|
Expect(strings.Count(result, string(filepath.Separator))).To(BeNumerically(">=", strings.Count(expectedDir, string(filepath.Separator))))
|
|
})
|
|
})
|
|
|
|
var _ = Describe("BaseName", func() {
|
|
It("extracts basename from a simple filename", func() {
|
|
result := utils.BaseName("test.mp3")
|
|
Expect(result).To(Equal("test"))
|
|
})
|
|
|
|
It("extracts basename from a file path", func() {
|
|
result := utils.BaseName("/path/to/file.txt")
|
|
Expect(result).To(Equal("file"))
|
|
})
|
|
|
|
It("handles files without extension", func() {
|
|
result := utils.BaseName("/path/to/filename")
|
|
Expect(result).To(Equal("filename"))
|
|
})
|
|
|
|
It("handles files with multiple dots", func() {
|
|
result := utils.BaseName("archive.tar.gz")
|
|
Expect(result).To(Equal("archive.tar"))
|
|
})
|
|
|
|
It("handles hidden files", func() {
|
|
// For hidden files without additional extension, path.Ext returns the entire name
|
|
// So basename becomes empty string after TrimSuffix
|
|
result := utils.BaseName(".hidden")
|
|
Expect(result).To(Equal(""))
|
|
})
|
|
|
|
It("handles hidden files with extension", func() {
|
|
result := utils.BaseName(".config.json")
|
|
Expect(result).To(Equal(".config"))
|
|
})
|
|
|
|
It("handles empty string", func() {
|
|
// The actual behavior returns empty string for empty input
|
|
result := utils.BaseName("")
|
|
Expect(result).To(Equal(""))
|
|
})
|
|
|
|
It("handles path ending with separator", func() {
|
|
result := utils.BaseName("/path/to/dir/")
|
|
Expect(result).To(Equal("dir"))
|
|
})
|
|
|
|
It("handles complex nested path", func() {
|
|
result := utils.BaseName("/very/long/path/to/my/favorite/song.mp3")
|
|
Expect(result).To(Equal("song"))
|
|
})
|
|
})
|
|
|
|
var _ = Describe("CleanFileName", func() {
|
|
It("lowercases and replaces spaces with underscores", func() {
|
|
Expect(utils.CleanFileName("My Cool Playlist")).To(Equal("my_cool_playlist"))
|
|
})
|
|
|
|
It("strips special characters", func() {
|
|
Expect(utils.CleanFileName("Rock & Roll! (2024)")).To(Equal("rock__roll_2024"))
|
|
})
|
|
|
|
It("handles unicode characters", func() {
|
|
Expect(utils.CleanFileName("Música Favorita")).To(Equal("msica_favorita"))
|
|
})
|
|
|
|
It("preserves hyphens", func() {
|
|
Expect(utils.CleanFileName("lo-fi beats")).To(Equal("lo-fi_beats"))
|
|
})
|
|
|
|
It("returns empty string for empty input", func() {
|
|
Expect(utils.CleanFileName("")).To(BeEmpty())
|
|
})
|
|
|
|
It("returns empty string for whitespace-only input", func() {
|
|
Expect(utils.CleanFileName(" ")).To(BeEmpty())
|
|
})
|
|
|
|
It("returns empty string when all characters are stripped", func() {
|
|
Expect(utils.CleanFileName("!!!@@@###")).To(BeEmpty())
|
|
})
|
|
|
|
It("truncates to 50 characters", func() {
|
|
long := strings.Repeat("abcdefghij", 10) // 100 chars
|
|
result := utils.CleanFileName(long)
|
|
Expect(len(result)).To(Equal(50))
|
|
})
|
|
|
|
It("trims trailing underscores and hyphens after truncation", func() {
|
|
// 49 a's + space + "b" = after clean: 49 a's + "_b" = 51 chars, truncated to 50 = 49 a's + "_"
|
|
name := strings.Repeat("a", 49) + " b"
|
|
result := utils.CleanFileName(name)
|
|
Expect(result).To(Equal(strings.Repeat("a", 49)))
|
|
})
|
|
})
|
|
|
|
var _ = Describe("FileExists", func() {
|
|
var tempFile *os.File
|
|
var tempDir string
|
|
|
|
BeforeEach(func() {
|
|
var err error
|
|
tempFile, err = os.CreateTemp("", "fileexists-test-*.txt")
|
|
Expect(err).NotTo(HaveOccurred())
|
|
|
|
tempDir, err = os.MkdirTemp("", "fileexists-test-dir-*")
|
|
Expect(err).NotTo(HaveOccurred())
|
|
})
|
|
|
|
AfterEach(func() {
|
|
if tempFile != nil {
|
|
os.Remove(tempFile.Name())
|
|
tempFile.Close()
|
|
}
|
|
if tempDir != "" {
|
|
os.RemoveAll(tempDir)
|
|
}
|
|
})
|
|
|
|
It("returns true for existing file", func() {
|
|
Expect(utils.FileExists(tempFile.Name())).To(BeTrue())
|
|
})
|
|
|
|
It("returns true for existing directory", func() {
|
|
Expect(utils.FileExists(tempDir)).To(BeTrue())
|
|
})
|
|
|
|
It("returns false for non-existing file", func() {
|
|
nonExistentPath := filepath.Join(tempDir, "does-not-exist.txt")
|
|
Expect(utils.FileExists(nonExistentPath)).To(BeFalse())
|
|
})
|
|
|
|
It("returns false for empty path", func() {
|
|
Expect(utils.FileExists("")).To(BeFalse())
|
|
})
|
|
|
|
It("handles nested non-existing path", func() {
|
|
nonExistentPath := "/this/path/definitely/does/not/exist/file.txt"
|
|
Expect(utils.FileExists(nonExistentPath)).To(BeFalse())
|
|
})
|
|
|
|
Context("when file is deleted after creation", func() {
|
|
It("returns false after file deletion", func() {
|
|
filePath := tempFile.Name()
|
|
Expect(utils.FileExists(filePath)).To(BeTrue())
|
|
|
|
err := os.Remove(filePath)
|
|
Expect(err).NotTo(HaveOccurred())
|
|
tempFile = nil // Prevent cleanup attempt
|
|
|
|
Expect(utils.FileExists(filePath)).To(BeFalse())
|
|
})
|
|
})
|
|
|
|
Context("when directory is deleted after creation", func() {
|
|
It("returns false after directory deletion", func() {
|
|
dirPath := tempDir
|
|
Expect(utils.FileExists(dirPath)).To(BeTrue())
|
|
|
|
err := os.RemoveAll(dirPath)
|
|
Expect(err).NotTo(HaveOccurred())
|
|
tempDir = "" // Prevent cleanup attempt
|
|
|
|
Expect(utils.FileExists(dirPath)).To(BeFalse())
|
|
})
|
|
})
|
|
|
|
It("handles permission denied scenarios gracefully", func() {
|
|
// This test might be platform specific, but we test the general case
|
|
result := utils.FileExists("/root/.ssh/id_rsa") // Likely to not exist or be inaccessible
|
|
Expect(result).To(Or(BeTrue(), BeFalse())) // Should not panic
|
|
})
|
|
})
|