7c11cdc4d1
TestRoutesRegisteredInMount failed because handleGetStream did the DB lookup (404 on missing track) BEFORE streamAuthOk (401 on unauth). For an unauth request to a non-existent track, the test saw 404 and concluded the route wasn't registered when actually it was - the handler just bailed at the lookup before auth. Reorder: extract trackID via chi.URLParam, run streamAuthOk on the raw path id first (the HMAC token is signed over the same id string so we don't need the resolved row yet), then do the DB lookup. Test now sees 401 on the unauth probe as it expected. Also closes a small info-leak: previously a 404/401 differential let unauth callers probe which track IDs exist. Now both unknown and known IDs return 401 for unauth requests.
207 lines
7.8 KiB
Go
207 lines
7.8 KiB
Go
// Package api — media endpoints serve raw bytes (cover art, audio streams).
|
|
// The helpers below duplicate shape with internal/subsonic/stream.go by
|
|
// design; the two packages are kept independent so subsonic can freeze as a
|
|
// compatibility surface while /api evolves. See project memory
|
|
// `project_subsonic_legacy.md` for the rationale.
|
|
|
|
package api
|
|
|
|
import (
|
|
"context"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/coverart"
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
|
)
|
|
|
|
// resolveAlbumCoverPath returns the filesystem path to the album's cover art.
|
|
// It prefers an explicit cover_art_path (set by the scanner in a future
|
|
// milestone) and falls back to a sidecar next to the first track in the
|
|
// album's directory. "" means no art was found.
|
|
func resolveAlbumCoverPath(ctx context.Context, q *dbq.Queries, album dbq.Album) string {
|
|
if album.CoverArtPath != nil && *album.CoverArtPath != "" {
|
|
if _, err := os.Stat(*album.CoverArtPath); err == nil {
|
|
return *album.CoverArtPath
|
|
}
|
|
}
|
|
tracks, err := q.ListTracksByAlbum(ctx, dbq.ListTracksByAlbumParams{AlbumID: album.ID})
|
|
if err != nil || len(tracks) == 0 {
|
|
return ""
|
|
}
|
|
return coverart.FindSidecar(filepath.Dir(tracks[0].FilePath))
|
|
}
|
|
|
|
// audioContentType maps the short file_format recorded on tracks (mp3, flac,
|
|
// ogg, opus, m4a, aac, wav) to a MIME type for the Content-Type header.
|
|
// Unknown formats fall back to octet-stream so the browser downloads them
|
|
// rather than attempting to decode.
|
|
//
|
|
// Divergences from internal/subsonic/types.go's contentTypeForFormat are
|
|
// intentional: opus→audio/ogg (library .opus files are Ogg-encapsulated, so
|
|
// this matches real library contents), aac→audio/aac (raw AAC is ADTS, not
|
|
// MP4, so audio/mp4 would mislead codec sniffers), and there is no "oga" case
|
|
// (we don't record that format). Don't "fix" these to match subsonic.
|
|
func audioContentType(format string) string {
|
|
switch strings.ToLower(format) {
|
|
case "mp3":
|
|
return "audio/mpeg"
|
|
case "flac":
|
|
return "audio/flac"
|
|
case "ogg", "opus":
|
|
return "audio/ogg"
|
|
case "m4a":
|
|
return "audio/mp4"
|
|
case "aac":
|
|
return "audio/aac"
|
|
case "wav":
|
|
return "audio/wav"
|
|
}
|
|
return "application/octet-stream"
|
|
}
|
|
|
|
// imageContentType maps a file extension to a MIME type for cover art.
|
|
// Unknown extensions fall back to octet-stream.
|
|
func imageContentType(path string) string {
|
|
switch strings.ToLower(filepath.Ext(path)) {
|
|
case ".jpg", ".jpeg":
|
|
return "image/jpeg"
|
|
case ".png":
|
|
return "image/png"
|
|
case ".webp":
|
|
return "image/webp"
|
|
case ".gif":
|
|
return "image/gif"
|
|
}
|
|
return "application/octet-stream"
|
|
}
|
|
|
|
// handleGetCover implements GET /api/albums/{id}/cover. Resolves the cover
|
|
// path (explicit column, else sidecar next to first track), then delegates
|
|
// byte-serving to http.ServeContent so clients get Range / If-Modified-Since
|
|
// / ETag for free.
|
|
func (h *handlers) handleGetCover(w http.ResponseWriter, r *http.Request) {
|
|
q := dbq.New(h.pool)
|
|
album, apiErr := resolveByID(r, "id", q.GetAlbumByID, "album")
|
|
if apiErr != nil {
|
|
writeErr(w, apiErr)
|
|
return
|
|
}
|
|
path := resolveAlbumCoverPath(r.Context(), q, album)
|
|
if path == "" {
|
|
writeErr(w, &apierror.Error{Status: 404, Code: "not_found", Message: "cover not found"})
|
|
return
|
|
}
|
|
f, err := os.Open(path)
|
|
if err != nil {
|
|
// resolveAlbumCoverPath saw the file a moment ago; if it vanished
|
|
// between stat and open, treat it the same as no-art — the user
|
|
// experience (404) matches, and 500 would misleadingly imply a server
|
|
// bug rather than a filesystem race.
|
|
writeErr(w, &apierror.Error{Status: 404, Code: "not_found", Message: "cover not found"})
|
|
return
|
|
}
|
|
defer func() { _ = f.Close() }()
|
|
info, err := f.Stat()
|
|
if err != nil {
|
|
h.logger.Error("api: stat cover failed", "err", err)
|
|
writeErr(w, apierror.InternalMsg("server error", err))
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", imageContentType(path))
|
|
// Cover bytes change rarely (cover-source enrichment, manual rescan,
|
|
// rare MBID-driven re-fetch). One-day max-age + must-revalidate means
|
|
// clients skip the conditional GET for the bulk of a session, but
|
|
// stale art clears within 24h after a re-scan. ServeContent below
|
|
// still emits Last-Modified for the conditional path when needed.
|
|
w.Header().Set("Cache-Control", "public, max-age=86400, must-revalidate")
|
|
http.ServeContent(w, r, filepath.Base(path), info.ModTime(), f)
|
|
}
|
|
|
|
// streamAuthOk returns true if the request is authorized to fetch the
|
|
// stream — either via the standard session path (cookie OR bearer token
|
|
// resolved to a user-in-context by auth.OptionalUser, the middleware
|
|
// the route is wrapped with in api.go) OR via a valid short-lived HMAC
|
|
// token in the ?token=&exp= query (UPnP / Sonos path, new for the
|
|
// output-picker UPnP slice).
|
|
//
|
|
// The token path lets network speakers fetch the stream URL without
|
|
// carrying the user's session cookie — they cannot. See the design at
|
|
// docs/superpowers/specs/2026-06-03-android-output-picker-upnp-design.md.
|
|
//
|
|
// When h.streamSecret is nil (slice-1 default, until slice 2 wires the
|
|
// loader), the token path always rejects because the HMAC of an empty
|
|
// key won't match any token a client could mint. The session path keeps
|
|
// working.
|
|
func (h *handlers) streamAuthOk(r *http.Request, trackID string) bool {
|
|
if _, ok := auth.UserFromContext(r.Context()); ok {
|
|
return true
|
|
}
|
|
tok := r.URL.Query().Get("token")
|
|
expStr := r.URL.Query().Get("exp")
|
|
if tok == "" || expStr == "" {
|
|
return false
|
|
}
|
|
exp, err := strconv.ParseInt(expStr, 10, 64)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
return VerifyStreamToken(h.streamSecret, trackID, exp, tok)
|
|
}
|
|
|
|
// handleGetStream implements GET /api/tracks/{id}/stream. Opens the file on
|
|
// disk and delegates byte-serving to http.ServeContent, which handles Range,
|
|
// If-Modified-Since, and ETag based on the file's mod time.
|
|
//
|
|
// The route lives outside the authed.Group so this handler can accept
|
|
// EITHER a session-resolved user (attached by auth.OptionalUser, the
|
|
// permissive middleware the route is wrapped with) OR a signed token
|
|
// (UPnP slice). streamAuthOk gates both paths.
|
|
func (h *handlers) handleGetStream(w http.ResponseWriter, r *http.Request) {
|
|
// Auth check before the DB lookup: a 404 ahead of the auth check
|
|
// would let unauth callers probe which track IDs exist via the
|
|
// 404/401 response differential. streamAuthOk is keyed on the
|
|
// path's id directly (the HMAC token is signed over the same id
|
|
// string, so we don't need the resolved row yet).
|
|
rawID := chi.URLParam(r, "id")
|
|
if !h.streamAuthOk(r, rawID) {
|
|
writeErr(w, apierror.ErrUnauthorized)
|
|
return
|
|
}
|
|
track, apiErr := resolveByID(r, "id", dbq.New(h.pool).GetTrackByID, "track")
|
|
if apiErr != nil {
|
|
writeErr(w, apiErr)
|
|
return
|
|
}
|
|
f, err := os.Open(track.FilePath)
|
|
if err != nil {
|
|
// File vanished (scanner indexed it, filesystem lost it). Treat as
|
|
// 404 so clients can fall back to the next item in a queue.
|
|
writeErr(w, &apierror.Error{Status: 404, Code: "not_found", Message: "track file not found"})
|
|
return
|
|
}
|
|
defer func() { _ = f.Close() }()
|
|
info, err := f.Stat()
|
|
if err != nil {
|
|
h.logger.Error("api: stat track failed", "err", err)
|
|
writeErr(w, apierror.InternalMsg("server error", err))
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", audioContentType(track.FileFormat))
|
|
w.Header().Set("Accept-Ranges", "bytes")
|
|
// Track bytes are immutable for a given track id (the scanner
|
|
// indexes by file_path; re-encoded files take new ids). One-year
|
|
// max-age + immutable lets the client cache (LockCachingAudioSource
|
|
// on the Flutter side, browser cache on web) skip even the
|
|
// conditional GET on repeat plays.
|
|
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
|
|
http.ServeContent(w, r, filepath.Base(track.FilePath), info.ModTime(), f)
|
|
}
|