Streaming with range requests and path safety, cover art discovery/resize cache
This commit is contained in:
@@ -9,6 +9,7 @@ require (
|
|||||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
golang.org/x/crypto v0.55.0 // 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
|
golang.org/x/sys v0.47.0 // indirect
|
||||||
modernc.org/libc v1.74.4 // indirect
|
modernc.org/libc v1.74.4 // indirect
|
||||||
modernc.org/mathutil v1.7.1 // indirect
|
modernc.org/mathutil v1.7.1 // indirect
|
||||||
|
|||||||
@@ -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=
|
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 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
|
||||||
golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
|
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 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
modernc.org/libc v1.74.4 h1:fX1Omw4o2/1C2iRkkIsrQTasJQldLhRmuPreXLoWs9k=
|
modernc.org/libc v1.74.4 h1:fX1Omw4o2/1C2iRkkIsrQTasJQldLhRmuPreXLoWs9k=
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user