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
-3
View File
@@ -1,9 +1,6 @@
## 0.2.45 - 2026-08-10
- Bug fixes
## Server 0.1.31 — 2026-08-10
- Added: The admin console now has a Journeys page for visit health, feature use, significant actions, common paths and per-profile event history.
## 0.2.43 — 2026-08-10
- Fixed: App no longer crashes.
- Fixed: Manual surround-sound choices now reliably override the automatically detected audio output.
+15 -8
View File
@@ -424,6 +424,13 @@ while left open; the verdict is `none`, `optional` or `mandatory`. This endpoint
deliberately public and remains available during maintenance: update policy is checked
before login and never reads, validates, or mutates a viewer session.
The App updates page separates a required update from a destructive one. Required covers
the home screen until the APK is installed but keeps the viewer's session. “Set the
destructive floor to this update” records the release as the destructive floor: the next request
from an older build deletes its session, and signing in again is refused until that build
has updated. “Sign out builds below” lets the operator set that floor to an exact version
instead; leaving it blank disables destructive retirement.
### First-time TV installation
The gateway hosts a public bootstrap page at:
@@ -522,7 +529,8 @@ For a server-only emergency deployment, explicitly opt out:
```
Set it on the admin page: **latest version**, **APK URL** (normally the same file the
landing page serves), release notes, and a **Require this update** toggle.
landing page serves), release notes, the required-update toggle, and an optional
destructive compatibility floor.
- *Optional* — a dismissable prompt. Dismissal lasts for that session only.
- *Required* — a full-screen panel over the home screen with no way past it. Back is
@@ -533,13 +541,12 @@ landing page serves), release notes, and a **Require this update** toggle.
`minimumVersion` can also be set directly for a staged rollout where the forced floor is
older than the newest build.
Builds below 0.2.44 are permanently retired once the enabled policy points at an
actionable 0.2.44-or-newer release. On their next authenticated request the gateway
deletes the session and returns 401, which makes the TV remove the rejected local profile;
the public update check continues to return the mandatory update screen. The gateway also
refuses a new login from a retired build, so signing in again cannot bypass the update.
This floor remains dormant when the policy has no download URL or its latest release is
older than 0.2.44.
When **Sign out builds below** is set, a build below that version is retired on its next
authenticated request: the gateway deletes the session and returns 401, which makes the TV
remove the rejected local profile. The public update check continues to return the
mandatory update screen, and the gateway refuses a new login from the retired build, so
signing in again cannot bypass the update. The floor remains dormant when the policy has no
download URL or its latest release is older than the selected floor.
Two deliberate safeguards, both tested in `internal/appupdate`:
+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) {
+4
View File
@@ -32,6 +32,10 @@ type Policy struct {
// an old release) to keep updates optional; set it to LatestVersion to make the
// current release mandatory for everyone.
MinimumVersion string `json:"minimumVersion"`
// RetireBelowVersion is the destructive floor. Clients below it have their session
// removed before receiving the mandatory update screen. It is separate from
// MinimumVersion so an ordinary required update does not sign viewers out.
RetireBelowVersion string `json:"retireBelowVersion,omitempty"`
// DownloadURL points at the APK — normally the same file the landing page serves.
DownloadURL string `json:"downloadUrl"`
// SHA256 and SizeBytes let the TV reject a truncated, stale or substituted download
+1 -1
View File
@@ -1 +1 @@
0.1.31
0.1.32