162 lines
5.4 KiB
Go
162 lines
5.4 KiB
Go
package api
|
|
|
|
import (
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/ponzischeme89/memby/server/internal/config"
|
|
"github.com/ponzischeme89/memby/server/internal/store"
|
|
)
|
|
|
|
// handleRemoteConfig serves one app-scoped, versioned document. It is public
|
|
// for the same reason the update verdict is public: a fresh install and a signed-out TV
|
|
// must be able to warm the next launch. No viewer or session data belongs in this answer.
|
|
func (s *Server) handleRemoteConfig(w http.ResponseWriter, r *http.Request) {
|
|
document := s.cfg.RemoteConfig
|
|
clientSchema := parseClientSchema(r)
|
|
components := parseCapabilities(r.Header.Get("X-Memby-Components"))
|
|
if clientSchema > 0 && clientSchema < document.SchemaVersion {
|
|
document.SchemaVersion = clientSchema
|
|
}
|
|
policy := s.currentFeaturePolicy(r.Context())
|
|
applyGlobalConfiguration(&document, policy)
|
|
negotiateRemoteSections(&document, components)
|
|
if s.log != nil {
|
|
s.loggerFor(r.Context()).Info("remote configuration delivered", "client_version", r.Header.Get("X-Memby-Version"), "schema", clientSchema, "delivered_schema", document.SchemaVersion, "components", components, "config_version", document.ConfigVersion)
|
|
}
|
|
body, err := json.Marshal(document)
|
|
if err != nil {
|
|
// Config is validated during start-up, so this is defensive rather than an expected
|
|
// operational failure.
|
|
writeError(w, http.StatusInternalServerError, "remote configuration unavailable")
|
|
return
|
|
}
|
|
digest := sha256.Sum256(body)
|
|
etag := `"rc-` + hex.EncodeToString(digest[:12]) + `"`
|
|
w.Header().Set("Cache-Control", "no-cache")
|
|
w.Header().Set("ETag", etag)
|
|
w.Header().Set("X-Memby-Config-Version", configVersionHeader(document.ConfigVersion))
|
|
w.Header().Set("X-Memby-Delivered-Schema", strconv.Itoa(document.SchemaVersion))
|
|
w.Header().Set("X-Memby-Delivered-Components", strings.Join(components, ","))
|
|
if r.Header.Get("If-None-Match") == etag {
|
|
w.WriteHeader(http.StatusNotModified)
|
|
return
|
|
}
|
|
writeRaw(w, http.StatusOK, body)
|
|
}
|
|
|
|
func parseClientSchema(r *http.Request) int {
|
|
value, _ := strconv.Atoi(strings.TrimSpace(r.Header.Get("X-Memby-Config-Schema")))
|
|
return value
|
|
}
|
|
|
|
func negotiateRemoteSections(document *config.RemoteConfig, components []string) {
|
|
if len(components) == 0 {
|
|
return
|
|
}
|
|
supported := make(map[string]bool, len(components))
|
|
for _, component := range components {
|
|
supported[component] = true
|
|
}
|
|
for _, sections := range []*[]config.RemoteSectionDefinition{&document.Home.SectionDefinitions, &document.Movies.SectionDefinitions, &document.TV.SectionDefinitions} {
|
|
for index := range *sections {
|
|
section := &(*sections)[index]
|
|
if supported[section.Component] {
|
|
continue
|
|
}
|
|
if section.Component == "landscapeMediaCardV2" && supported["mediaRow"] {
|
|
section.Component = "mediaRow"
|
|
continue
|
|
}
|
|
section.Enabled = false
|
|
}
|
|
}
|
|
}
|
|
|
|
// applyGlobalConfiguration is intentionally small and typed. The public startup
|
|
// document has no viewer identity, so user/device assignments are resolved by the
|
|
// authenticated feature endpoint rather than leaking into this cacheable response.
|
|
func applyGlobalConfiguration(document *config.RemoteConfig, policy store.FeaturePolicy) {
|
|
for key, raw := range policy.Values {
|
|
var value any
|
|
if json.Unmarshal(raw, &value) != nil {
|
|
continue
|
|
}
|
|
switch key {
|
|
case "forYou.enabled":
|
|
if v, ok := value.(bool); ok {
|
|
document.ForYou.Enabled = v
|
|
document.Features.Flags["forYou.enabled"] = v
|
|
document.Features.Flags["for_you"] = v
|
|
}
|
|
case "continueWatching.enabled":
|
|
if v, ok := value.(bool); ok {
|
|
document.ContinueWatching.Enabled = v
|
|
document.Features.Flags["continueWatching.enabled"] = v
|
|
document.Features.Flags["continue_watching"] = v
|
|
}
|
|
case "continueWatching.showNextUp":
|
|
if v, ok := value.(bool); ok {
|
|
document.ContinueWatching.IncludeNextUp = v
|
|
}
|
|
case "continueWatching.progressColour":
|
|
if v, ok := value.(string); ok {
|
|
document.ContinueWatching.ProgressColour = v
|
|
}
|
|
case "home.heroRefreshSeconds":
|
|
if v, ok := value.(float64); ok {
|
|
document.Home.HeroRefreshSeconds = int(v)
|
|
}
|
|
case "home.maxItemsPerRow":
|
|
if v, ok := value.(float64); ok {
|
|
document.Home.MaxItemsPerRow = int(v)
|
|
}
|
|
case "home.sectionDefinitions":
|
|
if err := json.Unmarshal(raw, &document.Home.SectionDefinitions); err == nil {
|
|
document.Home.Sections = sectionIDs(document.Home.SectionDefinitions)
|
|
}
|
|
case "movies.sectionDefinitions":
|
|
if err := json.Unmarshal(raw, &document.Movies.SectionDefinitions); err == nil {
|
|
document.Movies.Sections = sectionIDs(document.Movies.SectionDefinitions)
|
|
}
|
|
case "tv.sectionDefinitions":
|
|
if err := json.Unmarshal(raw, &document.TV.SectionDefinitions); err == nil {
|
|
document.TV.Sections = sectionIDs(document.TV.SectionDefinitions)
|
|
}
|
|
case "hero.enabled":
|
|
if v, ok := value.(bool); ok {
|
|
document.Features.Flags["hero.enabled"] = v
|
|
}
|
|
case "branding.markUrl":
|
|
if v, ok := value.(string); ok {
|
|
document.Branding.MarkURL = v
|
|
}
|
|
case "branding.markVersion":
|
|
if v, ok := value.(string); ok {
|
|
document.Branding.MarkVersion = v
|
|
}
|
|
}
|
|
if v, ok := value.(bool); ok {
|
|
document.Features.Flags[key] = v
|
|
}
|
|
}
|
|
}
|
|
|
|
func sectionIDs(definitions []config.RemoteSectionDefinition) []string {
|
|
ids := make([]string, 0, len(definitions))
|
|
for _, definition := range definitions {
|
|
if definition.Enabled {
|
|
ids = append(ids, definition.ID)
|
|
}
|
|
}
|
|
return ids
|
|
}
|
|
|
|
func configVersionHeader(version int64) string {
|
|
return strconv.FormatInt(version, 10)
|
|
}
|