0.2.45 - Server side git commits

This commit is contained in:
ponzischeme89
2026-08-10 20:54:00 +12:00
parent 4c47a5f8a0
commit dd2dd497f2
11 changed files with 170 additions and 63 deletions
+27 -7
View File
@@ -521,6 +521,12 @@ type updatePolicyRequest struct {
// toggle rather than exposing "minimum version" directly, because "force this
// update" is the decision an operator actually wants to make.
Required bool `json:"required"`
// Destructive removes sessions for clients below this release. It implies Required,
// but remains separate so a required update can keep viewers signed in.
Destructive bool `json:"destructive"`
// RetireBelowVersion exposes the exact destructive compatibility floor for releases
// where the operator needs to retire only part of the installed fleet.
RetireBelowVersion string `json:"retireBelowVersion"`
// MinimumVersion is honoured when set explicitly, for staged rollouts where the
// forced floor is older than the latest build.
MinimumVersion string `json:"minimumVersion"`
@@ -533,14 +539,15 @@ func (s *Server) handleAdminUpdatePolicy(w http.ResponseWriter, r *http.Request)
return
}
policy := appupdate.Policy{
Enabled: req.Enabled,
LatestVersion: strings.TrimSpace(req.LatestVersion),
MinimumVersion: strings.TrimSpace(req.MinimumVersion),
DownloadURL: strings.TrimSpace(req.DownloadURL),
Notes: strings.TrimSpace(req.Notes),
}
current := s.updatePolicy.get()
policy := appupdate.Policy{
Enabled: req.Enabled,
LatestVersion: strings.TrimSpace(req.LatestVersion),
MinimumVersion: strings.TrimSpace(req.MinimumVersion),
RetireBelowVersion: strings.TrimSpace(req.RetireBelowVersion),
DownloadURL: strings.TrimSpace(req.DownloadURL),
Notes: strings.TrimSpace(req.Notes),
}
if policy.LatestVersion == current.LatestVersion && policy.DownloadURL == current.DownloadURL {
// Changing "required" or release notes must not silently discard integrity
// metadata added by the signed release publisher.
@@ -554,6 +561,10 @@ func (s *Server) handleAdminUpdatePolicy(w http.ResponseWriter, r *http.Request)
// Un-ticking the box must actually release the floor.
policy.MinimumVersion = ""
}
if req.Destructive {
policy.MinimumVersion = policy.LatestVersion
policy.RetireBelowVersion = policy.LatestVersion
}
if policy.Enabled && policy.LatestVersion == "" {
writeError(w, http.StatusBadRequest, "set the latest version before enabling update prompts")
@@ -563,6 +574,15 @@ func (s *Server) handleAdminUpdatePolicy(w http.ResponseWriter, r *http.Request)
writeError(w, http.StatusBadRequest, "set the APK download URL before enabling update prompts")
return
}
if policy.RetireBelowVersion != "" && !releaseVersionPattern.MatchString(policy.RetireBelowVersion) {
writeError(w, http.StatusBadRequest, "the destructive update floor must look like 0.2.44")
return
}
if policy.Enabled && policy.RetireBelowVersion != "" &&
appupdate.CompareVersions(policy.RetireBelowVersion, policy.LatestVersion) > 0 {
writeError(w, http.StatusBadRequest, "the destructive update floor cannot be newer than the latest version")
return
}
if err := s.store.SetUpdatePolicy(r.Context(), policy); err != nil {
s.loggerFor(r.Context()).Error("update policy write failed", "error", err)
@@ -17,11 +17,20 @@
<label class="field"><span>What's new</span>
<em>Shown on the television above the update button.</em>
<input type="text" id="update-notes" placeholder="One line the viewer reads"></label>
<label class="field"><span>Sign out builds below</span>
<em>The destructive compatibility floor. Leave blank to keep every supported viewer
signed in.</em>
<input type="text" id="update-retire-below" placeholder="0.2.44"></label>
<label class="check">
<input type="checkbox" id="update-required">
<span>Require this update<em>Blocks the home screen on every television below this
version.</em></span>
</label>
<label class="check">
<input type="checkbox" id="update-destructive">
<span>Set the destructive floor to this update<em>Deletes sessions on every older
television when it next uses Memby, then shows the required update screen.</em></span>
</label>
<div class="card-foot">
<button class="primary" id="update-save">Save policy</button>
<button id="update-disable">Turn prompts off</button>
+21 -4
View File
@@ -5,16 +5,20 @@ Admin.onStatus((status) => {
// "Required" is not a field of its own: it is the minimum and the latest being the same
// version, which is what the client compares against.
const required = Boolean(policy.minimumVersion) && policy.minimumVersion === policy.latestVersion;
const destructive = Boolean(policy.retireBelowVersion) &&
policy.retireBelowVersion === policy.latestVersion;
$('update-state').innerHTML = !policy.enabled
? ui.tag('off', 'idle')
: ui.tag((required ? 'required · ' : 'optional · ') + policy.latestVersion,
required ? 'warn' : 'ok');
: ui.tag((destructive ? 'sign-out · ' : required ? 'required · ' : 'optional · ') +
policy.latestVersion, required ? 'warn' : 'ok');
Admin.fill($('update-version'), policy.latestVersion || '');
Admin.fill($('update-url'), policy.downloadUrl || '');
Admin.fill($('update-notes'), policy.notes || '');
Admin.fill($('update-retire-below'), policy.retireBelowVersion || '');
Admin.check($('update-required'), required);
Admin.check($('update-destructive'), destructive);
});
const body = (enabled) => JSON.stringify({
@@ -23,12 +27,25 @@ const body = (enabled) => JSON.stringify({
downloadUrl: $('update-url').value.trim(),
notes: $('update-notes').value.trim(),
required: $('update-required').checked,
destructive: $('update-destructive').checked,
retireBelowVersion: $('update-retire-below').value.trim(),
});
Admin.ready(() => {
$('update-destructive').addEventListener('change', () => {
if ($('update-destructive').checked) {
$('update-required').checked = true;
$('update-retire-below').value = $('update-version').value.trim();
} else if ($('update-retire-below').value.trim() === $('update-version').value.trim()) {
$('update-retire-below').value = '';
}
});
$('update-save').addEventListener('click', () => {
if ($('update-required').checked && !confirm('Required updates block the home screen on ' +
'every television below this version. Continue?')) return;
const destructive = $('update-destructive').checked;
const warning = destructive
? 'This will delete sessions on every older television and force viewers to sign in again after updating. Continue?'
: 'Required updates block the home screen on every television below this version. Continue?';
if ($('update-required').checked && !confirm(warning)) return;
Admin.act(() => Admin.api('/admin/api/update-policy', { method: 'POST', body: body(true) }));
});
$('update-disable').addEventListener('click', () =>
+6 -3
View File
@@ -22,6 +22,7 @@ import (
"sync"
"time"
"github.com/ponzischeme89/memby/server/internal/appupdate"
"github.com/ponzischeme89/memby/server/internal/bazarr"
"github.com/ponzischeme89/memby/server/internal/cache"
"github.com/ponzischeme89/memby/server/internal/config"
@@ -269,8 +270,10 @@ func (s *Server) authed(h authedFunc) http.Handler {
}
sess = s.captureClientIdentity(r, sess)
identify(r.Context(), sess)
decision := s.updateDecision(r)
if mustRetireForUpdate(decision, clientVersion(r)) {
policy := s.updatePolicy.get()
decision := appupdate.Decide(effectiveUpdatePolicy(policy), clientVersion(r))
retireBelow := destructiveUpdateFloor(policy)
if mustRetireForUpdate(decision, clientVersion(r), retireBelow) {
// Mirror an ordinary sign-out closely enough that this token cannot be restored
// from either database or Redis. The 401 is intentional: every supported client
// treats it as authoritative and removes the rejected local profile.
@@ -282,7 +285,7 @@ func (s *Server) authed(h authedFunc) http.Handler {
s.loggerFor(r.Context()).Info("signed out for required update",
"device_id", sess.DeviceID,
"from", clientLogValue(clientVersion(r)),
"minimum", forcedUpdateFloor,
"minimum", retireBelow,
"to", decision.Version,
)
w.Header().Set("X-Memby-Update-Required", decision.Version)
+8 -7
View File
@@ -161,13 +161,14 @@ func (s *Server) handleReleasePublish(w http.ResponseWriter, r *http.Request) {
}
policy := appupdate.Policy{
Enabled: true,
LatestVersion: version,
MinimumVersion: current.MinimumVersion,
DownloadURL: s.cfg.PublicURL + s.signedReleasePath(filename),
SHA256: actualSHA256,
SizeBytes: written,
Notes: strings.TrimSpace(r.FormValue("notes")),
Enabled: true,
LatestVersion: version,
MinimumVersion: current.MinimumVersion,
RetireBelowVersion: current.RetireBelowVersion,
DownloadURL: s.cfg.PublicURL + s.signedReleasePath(filename),
SHA256: actualSHA256,
SizeBytes: written,
Notes: strings.TrimSpace(r.FormValue("notes")),
}
if mandatory {
// Setting the floor to the release being published makes every older client
+26 -17
View File
@@ -17,12 +17,6 @@ import (
// 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 {
@@ -96,16 +90,30 @@ func compatibilityFor(r *http.Request) (bool, string) {
return true, ""
}
// effectiveUpdatePolicy applies the server-owned emergency floor without weakening a
// higher minimum the operator has already selected.
// 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 {
if !policy.Enabled || strings.TrimSpace(policy.DownloadURL) == "" ||
appupdate.CompareVersions(policy.LatestVersion, forcedUpdateFloor) < 0 {
floor := destructiveUpdateFloor(policy)
if floor == "" {
return policy
}
if strings.TrimSpace(policy.MinimumVersion) == "" ||
appupdate.CompareVersions(policy.MinimumVersion, forcedUpdateFloor) < 0 {
policy.MinimumVersion = forcedUpdateFloor
appupdate.CompareVersions(policy.MinimumVersion, floor) < 0 {
policy.MinimumVersion = floor
}
return policy
}
@@ -116,10 +124,10 @@ func (s *Server) updateDecision(r *http.Request) appupdate.Decision {
// 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 {
// below the active destructive floor are signed out.
func mustRetireForUpdate(decision appupdate.Decision, version, floor string) bool {
return decision.Status == appupdate.StatusMandatory && decision.DownloadURL != "" &&
appupdate.CompareVersions(version, forcedUpdateFloor) < 0
strings.TrimSpace(floor) != "" && appupdate.CompareVersions(version, floor) < 0
}
// requireSupportedClient prevents a retired build from signing straight back in after
@@ -127,8 +135,9 @@ func mustRetireForUpdate(decision appupdate.Decision, version string) bool {
// 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)) {
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
+53 -13
View File
@@ -67,12 +67,13 @@ func TestUpdateOfferLogNamesTheAffectedViewer(t *testing.T) {
}
}
func TestEmergencyFloorForcesClientsBelow0244(t *testing.T) {
func TestConfiguredFloorForcesClientsBelow0244(t *testing.T) {
server := testServer(config.Config{})
server.updatePolicy.set(appupdate.Policy{
Enabled: true,
LatestVersion: "0.2.44",
DownloadURL: "/updates/memby-0.2.44.apk?token=signed",
Enabled: true,
LatestVersion: "0.2.44",
RetireBelowVersion: "0.2.44",
DownloadURL: "/updates/memby-0.2.44.apk?token=signed",
})
for version, want := range map[string]string{
@@ -95,33 +96,72 @@ func TestEmergencyFloorForcesClientsBelow0244(t *testing.T) {
}
}
func TestEmergencyFloorWaitsForAnActionableRelease(t *testing.T) {
func TestConfiguredFloorWaitsForAnActionableRelease(t *testing.T) {
for name, policy := range map[string]appupdate.Policy{
"disabled": {
LatestVersion: "0.2.44", DownloadURL: "/updates/memby-0.2.44.apk",
LatestVersion: "0.2.44", RetireBelowVersion: "0.2.44",
DownloadURL: "/updates/memby-0.2.44.apk",
},
"missing download": {
Enabled: true, LatestVersion: "0.2.44",
Enabled: true, LatestVersion: "0.2.44", RetireBelowVersion: "0.2.44",
},
"release too old": {
Enabled: true, LatestVersion: "0.2.43", DownloadURL: "/updates/memby-0.2.43.apk",
Enabled: true, LatestVersion: "0.2.43", RetireBelowVersion: "0.2.44",
DownloadURL: "/updates/memby-0.2.43.apk",
},
} {
t.Run(name, func(t *testing.T) {
decision := appupdate.Decide(effectiveUpdatePolicy(policy), "0.2.43")
if mustRetireForUpdate(decision, "0.2.43") {
t.Fatal("client would be retired without an actionable 0.2.44-or-newer release")
if mustRetireForUpdate(decision, "0.2.43", destructiveUpdateFloor(policy)) {
t.Fatal("client would be retired without an actionable release at the configured floor")
}
})
}
}
func TestSelectedDestructiveFloorRetiresClientsBelowRelease(t *testing.T) {
policy := appupdate.Policy{
Enabled: true,
LatestVersion: "0.3.0",
RetireBelowVersion: "0.3.0",
DownloadURL: "/updates/memby-0.3.0.apk?token=signed",
}
for version, want := range map[string]bool{
"0.2.99": true,
"0.3.0": false,
"0.3.1": false,
} {
decision := appupdate.Decide(effectiveUpdatePolicy(policy), version)
if got := mustRetireForUpdate(decision, version, destructiveUpdateFloor(policy)); got != want {
t.Errorf("%s: retired = %t, want %t", version, got, want)
}
}
}
func TestRequiredUpdateDoesNotRetireSupportedClient(t *testing.T) {
policy := appupdate.Policy{
Enabled: true,
LatestVersion: "0.3.0",
MinimumVersion: "0.3.0",
DownloadURL: "/updates/memby-0.3.0.apk?token=signed",
}
decision := appupdate.Decide(effectiveUpdatePolicy(policy), "0.2.44")
if decision.Status != appupdate.StatusMandatory {
t.Fatalf("status = %q, want mandatory", decision.Status)
}
if mustRetireForUpdate(decision, "0.2.44", destructiveUpdateFloor(policy)) {
t.Fatal("ordinary required update retired a supported client")
}
}
func TestRetiredClientCannotSignBackIn(t *testing.T) {
server := testServer(config.Config{})
server.updatePolicy.set(appupdate.Policy{
Enabled: true,
LatestVersion: "0.2.44",
DownloadURL: "/updates/memby-0.2.44.apk?token=signed",
Enabled: true,
LatestVersion: "0.2.44",
RetireBelowVersion: "0.2.44",
DownloadURL: "/updates/memby-0.2.44.apk?token=signed",
})
reached := false
handler := server.requireSupportedClient(func(http.ResponseWriter, *http.Request) {