7d15f57e86
On-device test against Sonos showed SetAVTransportURI returning UPnP error 714 (IllegalMimeType). Logcat: POST /api/cast/stream-token -> 200 (token minted) SetAVTransportURI to http://minstrel.fabledsword.com/... <-- 500 from Sonos: SoapFaultException SOAP fault 714 The server is behind a TLS-terminating reverse proxy, so r.TLS is nil and the URL builder emitted http://. Sonos does a HEAD probe to detect the audio MIME type; against an http:// URL that 301s to https://, the probe finds no audio body and bails with 714. The Task 2 code-quality reviewer flagged this exact scenario at the time. Closing it now: honor X-Forwarded-Proto + X-Forwarded-Host before falling back to r.TLS + r.Host. Public URL the speaker fetches now matches the scheme/host the client used to reach the endpoint.
98 lines
3.1 KiB
Go
98 lines
3.1 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)
|
|
|
|
// Behind a TLS-terminating reverse proxy, r.TLS is nil even though
|
|
// the public-facing URL is https://. UPnP devices (Sonos especially)
|
|
// reject SetAVTransportURI with error 714 (IllegalMimeType) when
|
|
// they hit an http:// URL that immediately 301s to https:// — the
|
|
// MIME probe fails to find an audio body. Honor X-Forwarded-Proto +
|
|
// X-Forwarded-Host first so the URL we hand to the speaker reaches
|
|
// it on the same scheme/host the client used. Falls back to
|
|
// r.TLS-based detection for direct (no-proxy) deployments.
|
|
scheme := "http"
|
|
if proto := r.Header.Get("X-Forwarded-Proto"); proto != "" {
|
|
scheme = proto
|
|
} else if r.TLS != nil {
|
|
scheme = "https"
|
|
}
|
|
host := r.Host
|
|
if h := r.Header.Get("X-Forwarded-Host"); h != "" {
|
|
host = h
|
|
}
|
|
url := scheme + "://" + 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
|
|
}
|