Streaming with range requests and path safety, cover art discovery/resize cache
This commit is contained in:
@@ -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