e774097fd8
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>
84 lines
2.4 KiB
Go
84 lines
2.4 KiB
Go
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
|
|
}
|