feat(server): POST /api/cast/stream-token + secret bootstrap (UPnP slice 2/6)
Adds the client-facing endpoint that issues a signed stream URL for
the current track. Authenticated via the standard session cookie.
Returns {token, exp, url} where url is a fully-formed stream URL
the client passes verbatim to a UPnP / Sonos device's
AVTransport.SetAVTransportURI call.
expSeconds clamped to [60, 86400]; default 21600 (6h) - long enough
to play through any typical track without re-minting mid-playback.
MINSTREL_STREAM_SECRET is loaded from env var with a per-machine
fallback persisted at <Storage.DataDir>/stream_secret (auto-generated
on first boot via 64 random bytes, base64-url-encoded, 0600). The
file-based fallback is operator-machine-scoped runtime state, not a
user-facing setting - chosen over a DB column to avoid a migration
and keep the secret out of cross-instance restores. Operator can
override at any time via the env var; default path requires zero
config.
Tests cover happy-path token issuance + URL formatting, bad-UUID
rejection, unauthenticated rejection, the expSeconds clamp at all
boundaries, secret env override, auto-gen + file persistence at 0600,
second-boot reuse of the persisted file, and rejection of a malformed
env value.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,83 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
|
||||
)
|
||||
|
||||
const (
|
||||
castTokenMinExpSeconds = 60
|
||||
castTokenMaxExpSeconds = 86400 // 24h
|
||||
castTokenDefaultExp = 21600 // 6h
|
||||
)
|
||||
|
||||
type castTokenRequest struct {
|
||||
TrackID string `json:"trackId"`
|
||||
ExpSeconds int `json:"expSeconds,omitempty"`
|
||||
}
|
||||
|
||||
type castTokenResponse struct {
|
||||
Token string `json:"token"`
|
||||
Exp int64 `json:"exp"`
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
// handleCastStreamToken issues a short-lived HMAC stream token for the
|
||||
// given trackId. Authenticated via the standard session cookie / bearer.
|
||||
//
|
||||
// The returned URL is a fully-formed stream URL (token + exp embedded
|
||||
// as query params) that the client passes verbatim to a UPnP / Sonos
|
||||
// device's AVTransport.SetAVTransportURI call — those devices cannot
|
||||
// carry the user's session, so the signed query string is the only way
|
||||
// they can fetch the bytes.
|
||||
//
|
||||
// expSeconds is clamped to [60, 86400]; default 21600 (6h) — long enough
|
||||
// to play through any typical track without re-minting mid-playback.
|
||||
//
|
||||
// Part of the output-picker UPnP slice. See
|
||||
// docs/superpowers/specs/2026-06-03-android-output-picker-upnp-design.md.
|
||||
func (h *handlers) handleCastStreamToken(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := requireUser(w, r); !ok {
|
||||
return
|
||||
}
|
||||
var req castTokenRequest
|
||||
if !decodeBody(w, r, &req) {
|
||||
return
|
||||
}
|
||||
trackUUID, ok := parseUUID(req.TrackID)
|
||||
if !ok || !trackUUID.Valid {
|
||||
writeErr(w, apierror.BadRequest("invalid_track_id", "trackId must be a UUID"))
|
||||
return
|
||||
}
|
||||
expSec := clampExpSeconds(req.ExpSeconds)
|
||||
exp := time.Now().Add(time.Duration(expSec) * time.Second).Unix()
|
||||
token := SignStreamToken(h.streamSecret, req.TrackID, exp)
|
||||
|
||||
scheme := "http"
|
||||
if r.TLS != nil {
|
||||
scheme = "https"
|
||||
}
|
||||
url := scheme + "://" + r.Host + "/api/tracks/" + req.TrackID +
|
||||
"/stream?token=" + token + "&exp=" + strconv.FormatInt(exp, 10)
|
||||
|
||||
writeJSON(w, http.StatusOK, castTokenResponse{Token: token, Exp: exp, URL: url})
|
||||
}
|
||||
|
||||
// clampExpSeconds applies the [60, 86400] window with a 6h default for
|
||||
// non-positive inputs. Extracted so it doesn't bloat the handler's
|
||||
// detekt-equivalent line count and so the test can exercise edges directly.
|
||||
func clampExpSeconds(v int) int {
|
||||
if v <= 0 {
|
||||
return castTokenDefaultExp
|
||||
}
|
||||
if v < castTokenMinExpSeconds {
|
||||
return castTokenMinExpSeconds
|
||||
}
|
||||
if v > castTokenMaxExpSeconds {
|
||||
return castTokenMaxExpSeconds
|
||||
}
|
||||
return v
|
||||
}
|
||||
Reference in New Issue
Block a user