188 lines
6.8 KiB
Go
188 lines
6.8 KiB
Go
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
|
|
|
|
// 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, ""
|
|
}
|
|
|
|
// destructiveUpdateFloor returns the operator-selected compatibility floor once an
|
|
// actionable release at or above it exists. This keeps a policy saved ahead of its APK
|
|
// from locking televisions out.
|
|
func destructiveUpdateFloor(policy appupdate.Policy) string {
|
|
if !policy.Enabled || strings.TrimSpace(policy.DownloadURL) == "" {
|
|
return ""
|
|
}
|
|
floor := strings.TrimSpace(policy.RetireBelowVersion)
|
|
if floor == "" || appupdate.CompareVersions(policy.LatestVersion, floor) < 0 {
|
|
return ""
|
|
}
|
|
return floor
|
|
}
|
|
|
|
// effectiveUpdatePolicy applies the destructive floor without weakening a higher
|
|
// non-destructive minimum the operator has already selected.
|
|
func effectiveUpdatePolicy(policy appupdate.Policy) appupdate.Policy {
|
|
floor := destructiveUpdateFloor(policy)
|
|
if floor == "" {
|
|
return policy
|
|
}
|
|
if strings.TrimSpace(policy.MinimumVersion) == "" ||
|
|
appupdate.CompareVersions(policy.MinimumVersion, floor) < 0 {
|
|
policy.MinimumVersion = floor
|
|
}
|
|
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 active destructive floor are signed out.
|
|
func mustRetireForUpdate(decision appupdate.Decision, version, floor string) bool {
|
|
return decision.Status == appupdate.StatusMandatory && decision.DownloadURL != "" &&
|
|
strings.TrimSpace(floor) != "" && appupdate.CompareVersions(version, floor) < 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) {
|
|
policy := s.updatePolicy.get()
|
|
decision := appupdate.Decide(effectiveUpdatePolicy(policy), clientVersion(r))
|
|
if mustRetireForUpdate(decision, clientVersion(r), destructiveUpdateFloor(policy)) {
|
|
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: compatibility belongs to the
|
|
// app build, not a viewer or login. The verdict comes from memory; a valid bearer token
|
|
// also lets one viewer mute an optional prompt, but never a mandatory update.
|
|
func (s *Server) handleUpdate(w http.ResponseWriter, r *http.Request) {
|
|
decision := s.updateDecision(r)
|
|
// A signed-in viewer may decline optional update prompts. Mandatory decisions remain
|
|
// authoritative: this preference is about notifications, not compatibility or the
|
|
// operator's ability to retire an unsafe build. Signed-out checks have no person to
|
|
// consult and retain the ordinary app-scoped policy.
|
|
if decision.Status == appupdate.StatusOptional {
|
|
if identity := identityFrom(r.Context()); identity != nil && identity.userID != "" {
|
|
if prefs, err := s.notificationPreferencesFor(r.Context(), identity.userID); err != nil {
|
|
s.loggerFor(r.Context()).Warn("update notification preferences unavailable", "error", err)
|
|
} else {
|
|
decision = updateDecisionForPreferences(decision, prefs.Enabled && prefs.UpdateAlerts)
|
|
}
|
|
}
|
|
}
|
|
// 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)
|
|
}
|
|
|
|
func updateDecisionForPreferences(decision appupdate.Decision, updateAlerts bool) appupdate.Decision {
|
|
if decision.Status == appupdate.StatusOptional && !updateAlerts {
|
|
return appupdate.Decision{Status: appupdate.StatusNone}
|
|
}
|
|
return decision
|
|
}
|