0.2.55 - Remote config/Request fixes
This commit is contained in:
@@ -45,6 +45,9 @@ type Config struct {
|
||||
RecommendTimeout time.Duration
|
||||
// RecommendationWeights is an optional JSON overlay on the weighted defaults.
|
||||
RecommendationWeights string
|
||||
// RemoteConfig is the complete, validated presentation document served to TVs.
|
||||
// It is app-scoped and intentionally contains no account, playback or routing state.
|
||||
RemoteConfig RemoteConfig
|
||||
|
||||
UpstreamTimeout time.Duration
|
||||
|
||||
@@ -138,6 +141,10 @@ type Config struct {
|
||||
}
|
||||
|
||||
func Load() (Config, error) {
|
||||
remoteConfig, err := loadRemoteConfig(os.Getenv("MEMBY_REMOTE_CONFIG_JSON"))
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
c := Config{
|
||||
ListenAddr: env("MEMBY_LISTEN_ADDR", ":8080"),
|
||||
EmbyURL: strings.TrimRight(os.Getenv("MEMBY_EMBY_URL"), "/"),
|
||||
@@ -156,6 +163,7 @@ func Load() (Config, error) {
|
||||
RecommendationWeights: strings.TrimSpace(
|
||||
os.Getenv("MEMBY_RECOMMENDATION_WEIGHTS"),
|
||||
),
|
||||
RemoteConfig: remoteConfig,
|
||||
|
||||
AdminToken: strings.TrimSpace(os.Getenv("MEMBY_ADMIN_TOKEN")),
|
||||
PublicURL: strings.TrimRight(strings.TrimSpace(os.Getenv("MEMBY_PUBLIC_URL")), "/"),
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// RemoteConfig is the deliberately small, presentation-only document offered to TVs.
|
||||
// It must never contain authentication, playback or navigation-routing decisions: an
|
||||
// unavailable document is required to be indistinguishable from an ordinary offline
|
||||
// launch apart from its wording and safe presentation choices.
|
||||
type RemoteConfig struct {
|
||||
SchemaVersion int `json:"schemaVersion"`
|
||||
ConfigVersion int64 `json:"configVersion"`
|
||||
MinimumAppVersion string `json:"minimumAppVersion,omitempty"`
|
||||
MaximumAppVersion string `json:"maximumAppVersion,omitempty"`
|
||||
Copy RemoteConfigCopy `json:"copy"`
|
||||
Features RemoteConfigFeatures `json:"features"`
|
||||
Presentation RemoteConfigPresentation `json:"presentation"`
|
||||
}
|
||||
|
||||
type RemoteConfigCopy struct {
|
||||
Navigation RemoteConfigNavigationCopy `json:"navigation"`
|
||||
Tagline string `json:"tagline"`
|
||||
}
|
||||
|
||||
type RemoteConfigNavigationCopy struct {
|
||||
Home string `json:"home"`
|
||||
ForYou string `json:"forYou"`
|
||||
Search string `json:"search"`
|
||||
Movies string `json:"movies"`
|
||||
TVShows string `json:"tvShows"`
|
||||
TVCalendar string `json:"tvCalendar"`
|
||||
Favourites string `json:"favourites"`
|
||||
User string `json:"user"`
|
||||
Settings string `json:"settings"`
|
||||
}
|
||||
|
||||
type RemoteConfigFeatures struct {
|
||||
ShowNavigationVersion bool `json:"showNavigationVersion"`
|
||||
}
|
||||
|
||||
type RemoteConfigPresentation struct {
|
||||
NavigationRailExpandedWidthDp int `json:"navigationRailExpandedWidthDp"`
|
||||
NavigationContentShiftDp int `json:"navigationContentShiftDp"`
|
||||
}
|
||||
|
||||
// DefaultRemoteConfig mirrors the APK's bundled values. Serving it is still useful: it
|
||||
// establishes the schema and ETag contract before an operator chooses an override.
|
||||
func DefaultRemoteConfig() RemoteConfig {
|
||||
return RemoteConfig{
|
||||
SchemaVersion: 1,
|
||||
ConfigVersion: 1,
|
||||
Copy: RemoteConfigCopy{
|
||||
Tagline: "Matt’s Android TV client",
|
||||
Navigation: RemoteConfigNavigationCopy{
|
||||
Home: "Home", ForYou: "For You", Search: "Search", Movies: "Movies",
|
||||
TVShows: "TV Shows", TVCalendar: "TV Calendar", Favourites: "Favourites",
|
||||
User: "User", Settings: "Settings",
|
||||
},
|
||||
},
|
||||
Features: RemoteConfigFeatures{ShowNavigationVersion: true},
|
||||
Presentation: RemoteConfigPresentation{
|
||||
NavigationRailExpandedWidthDp: 184,
|
||||
NavigationContentShiftDp: 112,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func loadRemoteConfig(raw string) (RemoteConfig, error) {
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
return DefaultRemoteConfig(), nil
|
||||
}
|
||||
var document RemoteConfig
|
||||
decoder := json.NewDecoder(strings.NewReader(raw))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(&document); err != nil {
|
||||
return RemoteConfig{}, fmt.Errorf("MEMBY_REMOTE_CONFIG_JSON: %w", err)
|
||||
}
|
||||
var trailing any
|
||||
if err := decoder.Decode(&trailing); err != io.EOF {
|
||||
return RemoteConfig{}, fmt.Errorf("MEMBY_REMOTE_CONFIG_JSON must contain exactly one document")
|
||||
}
|
||||
if err := validateRemoteConfig(document); err != nil {
|
||||
return RemoteConfig{}, fmt.Errorf("MEMBY_REMOTE_CONFIG_JSON: %w", err)
|
||||
}
|
||||
return document, nil
|
||||
}
|
||||
|
||||
func validateRemoteConfig(document RemoteConfig) error {
|
||||
if document.SchemaVersion != 1 {
|
||||
return fmt.Errorf("schemaVersion must be 1")
|
||||
}
|
||||
if document.ConfigVersion < 1 {
|
||||
return fmt.Errorf("configVersion must be positive")
|
||||
}
|
||||
minimum, minimumSet, err := parseRemoteConfigVersion(document.MinimumAppVersion)
|
||||
if err != nil {
|
||||
return fmt.Errorf("minimumAppVersion must be a three-part version")
|
||||
}
|
||||
maximum, maximumSet, err := parseRemoteConfigVersion(document.MaximumAppVersion)
|
||||
if err != nil {
|
||||
return fmt.Errorf("maximumAppVersion must be a three-part version")
|
||||
}
|
||||
if minimumSet && maximumSet && compareRemoteConfigVersions(minimum, maximum) > 0 {
|
||||
return fmt.Errorf("minimumAppVersion must not be newer than maximumAppVersion")
|
||||
}
|
||||
labels := []string{
|
||||
document.Copy.Tagline,
|
||||
document.Copy.Navigation.Home,
|
||||
document.Copy.Navigation.ForYou,
|
||||
document.Copy.Navigation.Search,
|
||||
document.Copy.Navigation.Movies,
|
||||
document.Copy.Navigation.TVShows,
|
||||
document.Copy.Navigation.TVCalendar,
|
||||
document.Copy.Navigation.Favourites,
|
||||
document.Copy.Navigation.User,
|
||||
document.Copy.Navigation.Settings,
|
||||
}
|
||||
for _, label := range labels {
|
||||
trimmed := strings.TrimSpace(label)
|
||||
if trimmed == "" || len([]rune(trimmed)) > 64 || strings.ContainsAny(trimmed, "\r\n\t") {
|
||||
return fmt.Errorf("copy must be between 1 and 64 characters and contain no control whitespace")
|
||||
}
|
||||
}
|
||||
width := document.Presentation.NavigationRailExpandedWidthDp
|
||||
shift := document.Presentation.NavigationContentShiftDp
|
||||
if width < 160 || width > 240 {
|
||||
return fmt.Errorf("navigationRailExpandedWidthDp must be between 160 and 240")
|
||||
}
|
||||
if shift < 80 || shift > 160 || shift >= width {
|
||||
return fmt.Errorf("navigationContentShiftDp must be between 80 and 160 and less than the rail width")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseRemoteConfigVersion(raw string) ([3]int, bool, error) {
|
||||
var version [3]int
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
return version, false, nil
|
||||
}
|
||||
parts := strings.Split(raw, ".")
|
||||
if len(parts) != len(version) {
|
||||
return version, false, fmt.Errorf("invalid version")
|
||||
}
|
||||
for index, part := range parts {
|
||||
value, err := strconv.Atoi(part)
|
||||
if err != nil || value < 0 {
|
||||
return version, false, fmt.Errorf("invalid version")
|
||||
}
|
||||
version[index] = value
|
||||
}
|
||||
return version, true, nil
|
||||
}
|
||||
|
||||
func compareRemoteConfigVersions(left, right [3]int) int {
|
||||
for index := range left {
|
||||
if left[index] < right[index] {
|
||||
return -1
|
||||
}
|
||||
if left[index] > right[index] {
|
||||
return 1
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRemoteConfigDefaultsAreComplete(t *testing.T) {
|
||||
document, err := loadRemoteConfig("")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if document.SchemaVersion != 1 || document.ConfigVersion != 1 {
|
||||
t.Fatalf("unexpected versions: %+v", document)
|
||||
}
|
||||
if document.Copy.Navigation.Favourites != "Favourites" {
|
||||
t.Fatalf("favourites label = %q", document.Copy.Navigation.Favourites)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoteConfigRejectsMalformedAndUnsafeDocuments(t *testing.T) {
|
||||
document := DefaultRemoteConfig()
|
||||
document.Presentation.NavigationRailExpandedWidthDp = 500
|
||||
raw, err := json.Marshal(document)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := loadRemoteConfig(string(raw)); err == nil {
|
||||
t.Fatal("unsafe presentation value was accepted")
|
||||
}
|
||||
|
||||
if _, err := loadRemoteConfig(`{"schemaVersion":1,"unknown":true}`); err == nil {
|
||||
t.Fatal("unknown fields were accepted")
|
||||
}
|
||||
|
||||
document = DefaultRemoteConfig()
|
||||
document.MinimumAppVersion = "0.3.0"
|
||||
document.MaximumAppVersion = "0.2.54"
|
||||
raw, err = json.Marshal(document)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := loadRemoteConfig(string(raw)); err == nil {
|
||||
t.Fatal("reversed app-version bounds were accepted")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user