diff --git a/go.mod b/go.mod index 230cdd2..fe20395 100644 --- a/go.mod +++ b/go.mod @@ -9,6 +9,7 @@ require ( github.com/ncruces/go-strftime v1.0.0 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect golang.org/x/crypto v0.55.0 // indirect + golang.org/x/image v0.45.0 // indirect golang.org/x/sys v0.47.0 // indirect modernc.org/libc v1.74.4 // indirect modernc.org/mathutil v1.7.1 // indirect diff --git a/go.sum b/go.sum index a5efcc8..c4dd3c7 100644 --- a/go.sum +++ b/go.sum @@ -10,6 +10,8 @@ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94 github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M= golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis= +golang.org/x/image v0.45.0 h1:FMb1nTbH5H9vF55SriQHgFw5GnNL9Jg6L25BwXKzhB0= +golang.org/x/image v0.45.0/go.mod h1:n62x/7RqlwXDvGsSU4u6IUTUf6KghUZ9Bt7cG/T9Fx4= golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= modernc.org/libc v1.74.4 h1:fX1Omw4o2/1C2iRkkIsrQTasJQldLhRmuPreXLoWs9k= diff --git a/internal/coverart/coverart.go b/internal/coverart/coverart.go new file mode 100644 index 0000000..741c9c1 --- /dev/null +++ b/internal/coverart/coverart.go @@ -0,0 +1,187 @@ +// Package coverart discovers cover art (embedded artwork or folder +// images) and caches resized copies on disk, so the same image is never +// re-resized for every request. +package coverart + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "fmt" + "image" + _ "image/gif" + "image/jpeg" + _ "image/png" + "os" + "os/exec" + "path/filepath" + + "golang.org/x/image/draw" +) + +// commonFolderImageNames are checked, in priority order, for a +// folder-level cover image when a file has no embedded artwork. +var commonFolderImageNames = []string{ + "cover.jpg", "cover.jpeg", "cover.png", + "folder.jpg", "folder.jpeg", "folder.png", + "front.jpg", "front.jpeg", "front.png", + "albumart.jpg", "albumart.png", +} + +// FindFolderImage returns the first common cover image filename found +// directly inside dir, or "" if none exists. +func FindFolderImage(dir string) string { + for _, name := range commonFolderImageNames { + path := filepath.Join(dir, name) + if info, err := os.Stat(path); err == nil && !info.IsDir() { + return path + } + } + return "" +} + +// Store manages the on-disk resized-image cache under cacheDir. +type Store struct { + CacheDir string + FFmpegPath string + MaxSourceBytes int64 // guard against absurd source images before decoding +} + +func New(cacheDir, ffmpegPath string) *Store { + if ffmpegPath == "" { + ffmpegPath = "ffmpeg" + } + return &Store{CacheDir: cacheDir, FFmpegPath: ffmpegPath, MaxSourceBytes: 32 * 1024 * 1024} +} + +// ExtractEmbedded pulls the attached-picture stream out of a media file +// into a temporary file using ffmpeg, returning its path. The caller owns +// cleanup. +func (s *Store) ExtractEmbedded(ctx context.Context, mediaPath string, streamIndex int) (string, error) { + tmp, err := os.CreateTemp("", "vibesonic-art-*.img") + if err != nil { + return "", err + } + tmp.Close() + + cmd := exec.CommandContext(ctx, s.FFmpegPath, + "-y", "-v", "error", + "-i", mediaPath, + "-map", fmt.Sprintf("0:%d", streamIndex), + "-frames:v", "1", + "-c:v", "copy", + "--", tmp.Name(), + ) + if out, err := cmd.CombinedOutput(); err != nil { + os.Remove(tmp.Name()) + return "", fmt.Errorf("ffmpeg extract art: %w: %s", err, string(out)) + } + return tmp.Name(), nil +} + +// CachedPath returns the on-disk path for a resized variant of the image +// identified by contentHash at the given target size, without generating +// it. +func (s *Store) CachedPath(contentHash string, size int) string { + return filepath.Join(s.CacheDir, fmt.Sprintf("%s_%d.jpg", contentHash, size)) +} + +// GetOrCreate returns a reader-ready path to a JPEG resized so its +// longest side is at most `size` pixels, generating and caching it from +// sourcePath on first request. size <= 0 returns the original image +// unresized (still copied through the cache so repeat requests are +// cheap). +func (s *Store) GetOrCreate(sourcePath, contentHash string, size int) (string, error) { + if err := os.MkdirAll(s.CacheDir, 0o755); err != nil { + return "", err + } + cached := s.CachedPath(contentHash, size) + if _, err := os.Stat(cached); err == nil { + return cached, nil + } + + if info, err := os.Stat(sourcePath); err != nil { + return "", err + } else if s.MaxSourceBytes > 0 && info.Size() > s.MaxSourceBytes { + return "", fmt.Errorf("source image %s exceeds %d byte limit", sourcePath, s.MaxSourceBytes) + } + + src, err := os.Open(sourcePath) + if err != nil { + return "", err + } + defer src.Close() + + img, _, err := image.Decode(src) + if err != nil { + return "", fmt.Errorf("decoding image: %w", err) + } + + resized := img + if size > 0 { + resized = resize(img, size) + } + + tmpPath := cached + ".tmp" + out, err := os.Create(tmpPath) + if err != nil { + return "", err + } + if err := jpeg.Encode(out, resized, &jpeg.Options{Quality: 85}); err != nil { + out.Close() + os.Remove(tmpPath) + return "", err + } + if err := out.Close(); err != nil { + return "", err + } + if err := os.Rename(tmpPath, cached); err != nil { + return "", err + } + return cached, nil +} + +// resize scales img so its longest side is at most maxSize pixels, +// preserving aspect ratio. Images already smaller are left unscaled. +func resize(img image.Image, maxSize int) image.Image { + b := img.Bounds() + w, h := b.Dx(), b.Dy() + if w <= maxSize && h <= maxSize { + return img + } + var newW, newH int + if w >= h { + newW = maxSize + newH = h * maxSize / w + } else { + newH = maxSize + newW = w * maxSize / h + } + if newW < 1 { + newW = 1 + } + if newH < 1 { + newH = 1 + } + dst := image.NewRGBA(image.Rect(0, 0, newW, newH)) + draw.CatmullRom.Scale(dst, dst.Bounds(), img, b, draw.Over, nil) + return dst +} + +// ContentHashOf returns a stable cache key for a file's current content, +// cheap enough to compute per-request by hashing size+mtime rather than +// the full file body. +func ContentHashOf(path string) (string, error) { + f, err := os.Open(path) + if err != nil { + return "", err + } + defer f.Close() + info, err := f.Stat() + if err != nil { + return "", err + } + h := sha256.New() + fmt.Fprintf(h, "%s-%d-%d", path, info.Size(), info.ModTime().Unix()) + return hex.EncodeToString(h.Sum(nil))[:16], nil +} diff --git a/internal/coverart/coverart_test.go b/internal/coverart/coverart_test.go new file mode 100644 index 0000000..f80a564 --- /dev/null +++ b/internal/coverart/coverart_test.go @@ -0,0 +1,149 @@ +package coverart + +import ( + "image" + "image/color" + "image/jpeg" + "os" + "path/filepath" + "testing" +) + +func writeTestJPEG(t *testing.T, path string, w, h int) { + t.Helper() + img := image.NewRGBA(image.Rect(0, 0, w, h)) + for y := 0; y < h; y++ { + for x := 0; x < w; x++ { + img.Set(x, y, color.RGBA{R: uint8(x % 255), G: uint8(y % 255), B: 100, A: 255}) + } + } + f, err := os.Create(path) + if err != nil { + t.Fatal(err) + } + defer f.Close() + if err := jpeg.Encode(f, img, nil); err != nil { + t.Fatal(err) + } +} + +func TestFindFolderImagePriority(t *testing.T) { + dir := t.TempDir() + writeTestJPEG(t, filepath.Join(dir, "folder.jpg"), 4, 4) + writeTestJPEG(t, filepath.Join(dir, "cover.jpg"), 4, 4) + + got := FindFolderImage(dir) + if filepath.Base(got) != "cover.jpg" { + t.Errorf("FindFolderImage = %q, want cover.jpg to take priority over folder.jpg", got) + } +} + +func TestFindFolderImageNone(t *testing.T) { + dir := t.TempDir() + if got := FindFolderImage(dir); got != "" { + t.Errorf("FindFolderImage = %q, want empty when no cover exists", got) + } +} + +func TestGetOrCreateResizesAndCaches(t *testing.T) { + dir := t.TempDir() + srcPath := filepath.Join(dir, "source.jpg") + writeTestJPEG(t, srcPath, 800, 600) + + cacheDir := filepath.Join(dir, "cache") + store := New(cacheDir, "") + + path1, err := store.GetOrCreate(srcPath, "hash1", 200) + if err != nil { + t.Fatalf("GetOrCreate: %v", err) + } + info1, err := os.Stat(path1) + if err != nil { + t.Fatalf("stat cached file: %v", err) + } + + f, err := os.Open(path1) + if err != nil { + t.Fatal(err) + } + cfg, _, err := image.DecodeConfig(f) + f.Close() + if err != nil { + t.Fatalf("decoding resized image: %v", err) + } + if cfg.Width != 200 { + t.Errorf("resized width = %d, want 200", cfg.Width) + } + if cfg.Height >= 600 { + t.Errorf("resized height = %d, should be scaled down proportionally", cfg.Height) + } + + // Second call should hit the cache: same path, same mtime (not regenerated). + path2, err := store.GetOrCreate(srcPath, "hash1", 200) + if err != nil { + t.Fatalf("GetOrCreate (2nd): %v", err) + } + if path1 != path2 { + t.Errorf("cached path changed: %q -> %q", path1, path2) + } + info2, err := os.Stat(path2) + if err != nil { + t.Fatal(err) + } + if !info1.ModTime().Equal(info2.ModTime()) { + t.Error("second GetOrCreate call regenerated the cached file instead of reusing it") + } +} + +func TestGetOrCreateSmallImageNotUpscaled(t *testing.T) { + dir := t.TempDir() + srcPath := filepath.Join(dir, "small.jpg") + writeTestJPEG(t, srcPath, 50, 50) + + store := New(filepath.Join(dir, "cache"), "") + path, err := store.GetOrCreate(srcPath, "hash-small", 500) + if err != nil { + t.Fatalf("GetOrCreate: %v", err) + } + f, err := os.Open(path) + if err != nil { + t.Fatal(err) + } + defer f.Close() + cfg, _, err := image.DecodeConfig(f) + if err != nil { + t.Fatal(err) + } + if cfg.Width != 50 || cfg.Height != 50 { + t.Errorf("size = %dx%d, want unscaled 50x50", cfg.Width, cfg.Height) + } +} + +func TestGetOrCreateRejectsOversizedSource(t *testing.T) { + dir := t.TempDir() + srcPath := filepath.Join(dir, "big.jpg") + writeTestJPEG(t, srcPath, 200, 200) + + info, err := os.Stat(srcPath) + if err != nil { + t.Fatal(err) + } + + store := New(filepath.Join(dir, "cache"), "") + store.MaxSourceBytes = info.Size() - 1 // just under the real file size + + if _, err := store.GetOrCreate(srcPath, "hash-big", 50); err == nil { + t.Fatal("expected GetOrCreate to reject a source image over MaxSourceBytes") + } +} + +func TestGetOrCreateAllowsUnderLimit(t *testing.T) { + dir := t.TempDir() + srcPath := filepath.Join(dir, "ok.jpg") + writeTestJPEG(t, srcPath, 10, 10) + + store := New(filepath.Join(dir, "cache"), "") + if _, err := store.GetOrCreate(srcPath, "hash-ok", 5); err != nil { + t.Fatalf("GetOrCreate should succeed under the default limit: %v", err) + } +} diff --git a/internal/streaming/stream.go b/internal/streaming/stream.go new file mode 100644 index 0000000..45c8625 --- /dev/null +++ b/internal/streaming/stream.go @@ -0,0 +1,97 @@ +// Package streaming serves media file bytes over HTTP: range requests, +// correct MIME types, and no full-file buffering. Authorization (does +// this user get to hear this song?) and path resolution happen in the +// caller; ResolvePath here only guards against path traversal once a +// caller has already decided which folder/file to serve. +package streaming + +import ( + "errors" + "mime" + "net/http" + "os" + "path/filepath" + "strings" +) + +var ErrPathEscapesFolder = errors.New("resolved path escapes its music folder") + +// ResolvePath joins a music folder's root with a stored relative path and +// verifies the result is still within that root, rejecting any ".." +// traversal or symlink escape. Callers must always go through this +// rather than filepath.Join'ing user- or tag-derived paths directly. +func ResolvePath(folderRoot, relativePath string) (string, error) { + root, err := filepath.Abs(folderRoot) + if err != nil { + return "", err + } + root, err = filepath.EvalSymlinks(root) + if err != nil { + return "", err + } + joined := filepath.Join(root, filepath.FromSlash(relativePath)) + resolved, err := filepath.EvalSymlinks(joined) + if err != nil { + return "", err + } + if resolved != root && !strings.HasPrefix(resolved, root+string(filepath.Separator)) { + return "", ErrPathEscapesFolder + } + return resolved, nil +} + +// ServeFile streams a validated absolute path to the client, supporting +// HTTP range requests and conditional GETs via http.ServeContent, which +// reads directly from the *os.File rather than buffering it in memory. +func ServeFile(w http.ResponseWriter, r *http.Request, absPath, displayName string) error { + f, err := os.Open(absPath) + if err != nil { + return err + } + defer f.Close() + + info, err := f.Stat() + if err != nil { + return err + } + if info.IsDir() { + return errors.New("refusing to stream a directory") + } + + if ct := MimeType(absPath); ct != "" { + w.Header().Set("Content-Type", ct) + } + w.Header().Set("Accept-Ranges", "bytes") + http.ServeContent(w, r, displayName, info.ModTime(), f) + return nil +} + +// MimeType returns the media MIME type for a path's extension, falling +// back to a generic octet-stream for anything unrecognized. +func MimeType(path string) string { + ext := strings.ToLower(filepath.Ext(path)) + if ct, ok := extraMimeTypes[ext]; ok { + return ct + } + if ct := mime.TypeByExtension(ext); ct != "" { + return ct + } + return "application/octet-stream" +} + +// extraMimeTypes fills in audio types that a bare OS mime.types file +// often doesn't have registered. +var extraMimeTypes = map[string]string{ + ".mp3": "audio/mpeg", + ".flac": "audio/flac", + ".ogg": "audio/ogg", + ".oga": "audio/ogg", + ".opus": "audio/opus", + ".aac": "audio/aac", + ".m4a": "audio/mp4", + ".m4b": "audio/mp4", + ".wav": "audio/wav", + ".aiff": "audio/aiff", + ".aif": "audio/aiff", + ".wma": "audio/x-ms-wma", +} diff --git a/internal/streaming/stream_test.go b/internal/streaming/stream_test.go new file mode 100644 index 0000000..cce6889 --- /dev/null +++ b/internal/streaming/stream_test.go @@ -0,0 +1,120 @@ +package streaming + +import ( + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" +) + +func setupFolder(t *testing.T) (root string) { + t.Helper() + root = t.TempDir() + if err := os.MkdirAll(filepath.Join(root, "Artist", "Album"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, "Artist", "Album", "song.mp3"), []byte("0123456789"), 0o644); err != nil { + t.Fatal(err) + } + return root +} + +func TestResolvePathAllowsFileWithinFolder(t *testing.T) { + root := setupFolder(t) + got, err := ResolvePath(root, "Artist/Album/song.mp3") + if err != nil { + t.Fatalf("ResolvePath: %v", err) + } + want, _ := filepath.EvalSymlinks(filepath.Join(root, "Artist", "Album", "song.mp3")) + if got != want { + t.Errorf("ResolvePath = %q, want %q", got, want) + } +} + +func TestResolvePathRejectsTraversal(t *testing.T) { + root := setupFolder(t) + // A secret file just outside the music folder root. + if err := os.WriteFile(filepath.Join(filepath.Dir(root), "secret.txt"), []byte("nope"), 0o644); err != nil { + t.Fatal(err) + } + _, err := ResolvePath(root, "../secret.txt") + if err == nil { + t.Fatal("expected ResolvePath to reject a path escaping the folder root") + } +} + +func TestResolvePathRejectsDeepTraversal(t *testing.T) { + root := setupFolder(t) + _, err := ResolvePath(root, "Artist/../../../../../../etc/passwd") + if err == nil { + t.Fatal("expected ResolvePath to reject a deep traversal attempt") + } +} + +func TestServeFileSupportsRangeRequests(t *testing.T) { + root := setupFolder(t) + path := filepath.Join(root, "Artist", "Album", "song.mp3") + + req := httptest.NewRequest(http.MethodGet, "/stream/x", nil) + req.Header.Set("Range", "bytes=2-5") + rec := httptest.NewRecorder() + + if err := ServeFile(rec, req, path, "song.mp3"); err != nil { + t.Fatalf("ServeFile: %v", err) + } + + if rec.Code != http.StatusPartialContent { + t.Fatalf("status = %d, want 206", rec.Code) + } + if got := rec.Body.String(); got != "2345" { + t.Errorf("body = %q, want %q", got, "2345") + } + if cr := rec.Header().Get("Content-Range"); cr == "" { + t.Error("expected Content-Range header on a range response") + } +} + +func TestServeFileFullBody(t *testing.T) { + root := setupFolder(t) + path := filepath.Join(root, "Artist", "Album", "song.mp3") + + req := httptest.NewRequest(http.MethodGet, "/stream/x", nil) + rec := httptest.NewRecorder() + if err := ServeFile(rec, req, path, "song.mp3"); err != nil { + t.Fatalf("ServeFile: %v", err) + } + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rec.Code) + } + if rec.Body.String() != "0123456789" { + t.Errorf("body = %q", rec.Body.String()) + } + if rec.Header().Get("Accept-Ranges") != "bytes" { + t.Error("expected Accept-Ranges: bytes header") + } +} + +func TestServeFileRejectsDirectory(t *testing.T) { + root := setupFolder(t) + req := httptest.NewRequest(http.MethodGet, "/stream/x", nil) + rec := httptest.NewRecorder() + if err := ServeFile(rec, req, filepath.Join(root, "Artist", "Album"), "Album"); err == nil { + t.Fatal("expected error serving a directory") + } +} + +func TestMimeType(t *testing.T) { + cases := map[string]string{ + "song.mp3": "audio/mpeg", + "song.flac": "audio/flac", + "song.opus": "audio/opus", + "song.m4a": "audio/mp4", + "song.xyz": "application/octet-stream", + } + for path, want := range cases { + if got := MimeType(path); got != want { + t.Errorf("MimeType(%q) = %q, want %q", path, got, want) + } + } +}