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 "presentation.fontFamily": if v, ok := value.(string); ok && (v == "system" || v == "inter") { document.Presentation.FontFamily = 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) }