236637fcd3
Adds SignStreamToken / VerifyStreamToken (HMAC-SHA256 over trackID|exp) and modifies handleGetStream to accept either the existing session cookie OR a valid signed token. Stream route moved out of the authed group so the handler's own auth check runs and the token bypass is reachable. Enables Sonos / UPnP speakers to fetch the stream URL without carrying the user's session cookie - they cannot. The token is short-lived (max 24h per the design); expiry checked at request time only, not per-byte, so long tracks play through. streamSecret field on handlers is nil for now; Task 2 wires the loader (env var with auto-generated fallback persisted in app_preferences). Adds auth.OptionalUser - the permissive sibling of RequireUser that attaches the user to context when a valid cookie / bearer is present but does NOT 401 on absence. The stream route is wrapped with it so the handler can fall through to the token path when no session is present. newLibraryRouter (test fixture) gets a synthetic-user middleware on the stream route so existing media_test tests keep passing without seeding a real session row - production traffic uses auth.OptionalUser, the test path uses auth.UserCtxKeyForTest(). Five tests cover round-trip, tampered token rejection, expiry, wrong-track-ID, and wrong-secret rejection. CI verifies.
199 lines
7.5 KiB
Go
199 lines
7.5 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"
|
|
|
|
"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) {
|
|
track, apiErr := resolveByID(r, "id", dbq.New(h.pool).GetTrackByID, "track")
|
|
if apiErr != nil {
|
|
writeErr(w, apiErr)
|
|
return
|
|
}
|
|
if !h.streamAuthOk(r, uuidToString(track.ID)) {
|
|
writeErr(w, apierror.ErrUnauthorized)
|
|
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)
|
|
}
|