Files
memby/server/internal/api/update.go
T

112 lines
3.5 KiB
Go
Raw Normal View History

package api
import (
"context"
"net/http"
2026-07-29 15:26:27 +12:00
"strconv"
"strings"
"sync"
"time"
"github.com/ponzischeme89/memby/server/internal/appupdate"
)
2026-08-06 22:33:56 +12:00
// ProtocolVersion changes only when the client/server wire contract is no longer
2026-07-29 15:26:27 +12:00
// mutually compatible. App release versions remain independent and are handled by the
2026-08-06 22:33:56 +12:00
// update policy. Exported so the startup line can state which contract this build
// speaks, next to the build's own version.
const ProtocolVersion = 1
2026-07-29 15:26:27 +12:00
// 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"))
}
2026-07-29 15:26:27 +12:00
func clientProtocol(r *http.Request) string {
return strings.TrimSpace(r.Header.Get("X-Memby-Protocol"))
}
2026-08-02 22:10:19 +12:00
func clientProtocolNumber(r *http.Request) int {
reported, _ := strconv.Atoi(clientProtocol(r))
return reported
}
2026-07-29 15:26:27 +12:00
func compatibilityFor(r *http.Request) (bool, string) {
reported, err := strconv.Atoi(clientProtocol(r))
2026-08-06 22:33:56 +12:00
if err != nil || reported != ProtocolVersion {
2026-07-29 15:26:27 +12:00
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) +
2026-08-06 22:33:56 +12:00
", server protocol " + strconv.Itoa(ProtocolVersion) + ". Update the app or server."
2026-07-29 15:26:27 +12:00
}
return true, ""
}
// handleUpdate answers the client's version check.
//
2026-08-02 22:10:19 +12:00
// Its own public endpoint rather than a field on /v1/home: update policy belongs to the
// app build, not a viewer or login. The only client input is its build-version header and
// the answer comes from memory, so checking it never reads or mutates a user session.
func (s *Server) handleUpdate(w http.ResponseWriter, r *http.Request) {
decision := appupdate.Decide(s.updatePolicy.get(), clientVersion(r))
2026-08-06 22:33:56 +12:00
// 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)
}