98 lines
2.8 KiB
Go
98 lines
2.8 KiB
Go
// 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",
|
|
}
|