package api import ( "context" "net/http" "strconv" "strings" "sync" "time" "github.com/ponzischeme89/memby/server/internal/appupdate" ) // ProtocolVersion changes only when the client/server wire contract is no longer // mutually compatible. App release versions remain independent and are handled by the // update policy. Exported so the startup line can state which contract this build // speaks, next to the build's own version. const ProtocolVersion = 1 // forcedUpdateFloor retires builds whose update behaviour is no longer reliable enough // to leave optional. The floor only takes effect once an enabled policy points at this // version (or a newer one) and carries a download URL, so deploying the gateway before // publishing the APK cannot lock televisions out. const forcedUpdateFloor = "0.2.44" // updatePolicyCache keeps the policy in memory. It is read on every home request, and a // database round trip per home load to answer "nothing to say" would be wasteful. type updatePolicyCache struct { mu sync.RWMutex value appupdate.Policy } func (c *updatePolicyCache) get() appupdate.Policy { c.mu.RLock() defer c.mu.RUnlock() return c.value } func (c *updatePolicyCache) set(value appupdate.Policy) { c.mu.Lock() defer c.mu.Unlock() c.value = value } // LoadUpdatePolicy primes the cached policy. Called at boot and after every change. func (s *Server) LoadUpdatePolicy(ctx context.Context) error { policy, err := s.store.UpdatePolicy(ctx) if err != nil { return err } s.updatePolicy.set(policy) return nil } // WatchUpdatePolicy re-reads the policy periodically, so a change made directly in the // database is picked up without a restart. func (s *Server) WatchUpdatePolicy(ctx context.Context, interval time.Duration) { ticker := time.NewTicker(interval) defer ticker.Stop() for { select { case <-ctx.Done(): return case <-ticker.C: if err := s.LoadUpdatePolicy(ctx); err != nil { s.log.Warn("update policy refresh failed", "error", err) } } } } // clientVersion reads the version a TV reports. Absent means an older build that predates // the header — [appupdate.Decide] treats that as "say nothing". func clientVersion(r *http.Request) string { return strings.TrimSpace(r.Header.Get("X-Memby-Version")) } func clientProtocol(r *http.Request) string { return strings.TrimSpace(r.Header.Get("X-Memby-Protocol")) } func clientProtocolNumber(r *http.Request) int { reported, _ := strconv.Atoi(clientProtocol(r)) return reported } func compatibilityFor(r *http.Request) (bool, string) { reported, err := strconv.Atoi(clientProtocol(r)) if err != nil || reported != ProtocolVersion { if clientProtocol(r) == "" { return false, "This Memby app is too old to verify compatibility with the server. Update the app." } return false, "Memby app/server mismatch: app protocol " + clientProtocol(r) + ", server protocol " + strconv.Itoa(ProtocolVersion) + ". Update the app or server." } return true, "" } // effectiveUpdatePolicy applies the server-owned emergency floor without weakening a // higher minimum the operator has already selected. func effectiveUpdatePolicy(policy appupdate.Policy) appupdate.Policy { if !policy.Enabled || strings.TrimSpace(policy.DownloadURL) == "" || appupdate.CompareVersions(policy.LatestVersion, forcedUpdateFloor) < 0 { return policy } if strings.TrimSpace(policy.MinimumVersion) == "" || appupdate.CompareVersions(policy.MinimumVersion, forcedUpdateFloor) < 0 { policy.MinimumVersion = forcedUpdateFloor } return policy } func (s *Server) updateDecision(r *http.Request) appupdate.Decision { return appupdate.Decide(effectiveUpdatePolicy(s.updatePolicy.get()), clientVersion(r)) } // mustRetireForUpdate is narrower than "mandatory": an operator may temporarily force a // newer release without wanting every otherwise supported session destroyed. Only builds // below the permanent compatibility floor are signed out. func mustRetireForUpdate(decision appupdate.Decision, version string) bool { return decision.Status == appupdate.StatusMandatory && decision.DownloadURL != "" && appupdate.CompareVersions(version, forcedUpdateFloor) < 0 } // requireSupportedClient prevents a retired build from signing straight back in after // the authenticated gate has removed its old session. Its public update check remains // available and will keep returning the actionable mandatory verdict. func (s *Server) requireSupportedClient(next http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { decision := s.updateDecision(r) if mustRetireForUpdate(decision, clientVersion(r)) { w.Header().Set("X-Memby-Update-Required", decision.Version) writeJSON(w, http.StatusUpgradeRequired, decision) return } next(w, r) } } // handleUpdate answers the client's version check. // // Its own public endpoint rather than a field on /v1/home: update policy belongs to the // app build, not a viewer or login. The verdict comes from memory; when a bearer token is // present the route resolves it only to attribute an offered update to the affected viewer. func (s *Server) handleUpdate(w http.ResponseWriter, r *http.Request) { decision := s.updateDecision(r) // Only a verdict that asks a television to do something is worth a line. Every TV // checks on every launch, and "nothing to say" logged each time would bury the // launch where an update was actually offered — or forced. if decision.Status != "" && decision.Status != appupdate.StatusNone { s.loggerFor(r.Context()).Info("update offered", "status", decision.Status, "from", clientLogValue(clientVersion(r)), "to", decision.Version, ) } writeJSON(w, http.StatusOK, decision) }