Files

166 lines
5.6 KiB
Go
Raw Permalink Normal View History

2026-08-12 09:57:56 +12:00
package api
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"net/http"
"strconv"
2026-08-22 12:38:26 +12:00
"strings"
"github.com/ponzischeme89/memby/server/internal/config"
"github.com/ponzischeme89/memby/server/internal/store"
2026-08-12 09:57:56 +12:00
)
2026-08-22 12:38:26 +12:00
// handleRemoteConfig serves one app-scoped, versioned document. It is public
2026-08-12 09:57:56 +12:00
// 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) {
2026-08-22 12:38:26 +12:00
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)
2026-08-12 09:57:56 +12:00
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)
2026-08-22 12:38:26 +12:00
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, ","))
2026-08-12 09:57:56 +12:00
if r.Header.Get("If-None-Match") == etag {
w.WriteHeader(http.StatusNotModified)
return
}
writeRaw(w, http.StatusOK, body)
}
2026-08-22 12:38:26 +12:00
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
}
2026-08-22 18:40:50 +12:00
case "presentation.fontFamily":
if v, ok := value.(string); ok && (v == "system" || v == "inter") {
document.Presentation.FontFamily = v
}
2026-08-22 12:38:26 +12:00
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
}
2026-08-12 09:57:56 +12:00
func configVersionHeader(version int64) string {
return strconv.FormatInt(version, 10)
}