0.2.45 - Advanced analytics, logout old versions
This commit is contained in:
@@ -720,7 +720,29 @@ func (s *Server) handleAdminAnalytics(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, http.StatusInternalServerError, "could not read analytics")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"days": days, "rows": stats})
|
||||
users, err := s.store.AnalyticsUsers(r.Context(), since)
|
||||
if err != nil {
|
||||
s.loggerFor(r.Context()).Error("user analytics failed", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "could not read analytics")
|
||||
return
|
||||
}
|
||||
payload := map[string]any{"days": days, "retentionDays": int(s.cfg.AnalyticsRetention / (24 * time.Hour)), "rows": stats, "users": users}
|
||||
userID := strings.TrimSpace(r.URL.Query().Get("userId"))
|
||||
if userID != "" {
|
||||
features, featureErr := s.store.UserFeatureStats(r.Context(), userID, since)
|
||||
paths, pathErr := s.store.UserPaths(r.Context(), userID, since)
|
||||
events, eventErr := s.store.UserJourneyEvents(r.Context(), userID, since, 1000)
|
||||
if featureErr != nil || pathErr != nil || eventErr != nil {
|
||||
s.loggerFor(r.Context()).Error("user journey read failed", "user_id", userID)
|
||||
writeError(w, http.StatusInternalServerError, "could not read user journey")
|
||||
return
|
||||
}
|
||||
payload["userId"] = userID
|
||||
payload["features"] = features
|
||||
payload["paths"] = paths
|
||||
payload["events"] = events
|
||||
}
|
||||
writeJSON(w, http.StatusOK, payload)
|
||||
}
|
||||
|
||||
// syncerHandle is the slice of the syncer the API needs, so api does not depend on the
|
||||
|
||||
@@ -8,8 +8,9 @@
|
||||
<label class="field narrow"><span>Window</span>
|
||||
<select id="engagement-days">
|
||||
<option value="1">24 hours</option>
|
||||
<option value="7" selected>7 days</option>
|
||||
<option value="30">30 days</option>
|
||||
<option value="7">7 days</option>
|
||||
<option value="30" selected>30 days</option>
|
||||
<option value="90">90 days</option>
|
||||
</select></label>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
@@ -24,3 +25,53 @@
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<div class="card-head split">
|
||||
<div>
|
||||
<h2 class="card-title" data-icon="people" data-icon-tone="info">User journeys</h2>
|
||||
<p class="card-note">Choose an Emby profile to review feature use, common paths and
|
||||
significant actions in time order. Search text, content titles and setting values
|
||||
are not stored in journey analytics.</p>
|
||||
</div>
|
||||
<label class="field narrow"><span>User</span>
|
||||
<select id="engagement-user"><option value="">Choose a user</option></select></label>
|
||||
</div>
|
||||
<div class="tiles" id="engagement-user-tiles"></div>
|
||||
</section>
|
||||
|
||||
<section class="card" id="engagement-feature-card" hidden>
|
||||
<div class="card-head"><div>
|
||||
<h2 class="card-title" data-icon="pulse" data-icon-tone="data">Feature use</h2>
|
||||
<p class="card-note">Rare and unused features are shown explicitly against Memby's
|
||||
major feature catalogue.</p>
|
||||
</div></div>
|
||||
<div class="table-wrap"><table>
|
||||
<thead><tr><th>Feature</th><th class="num">Uses</th><th>Last used</th><th>Status</th></tr></thead>
|
||||
<tbody id="engagement-features"></tbody>
|
||||
</table></div>
|
||||
</section>
|
||||
|
||||
<section class="card" id="engagement-path-card" hidden>
|
||||
<div class="card-head"><div>
|
||||
<h2 class="card-title" data-icon="list" data-icon-tone="note">Common paths</h2>
|
||||
<p class="card-note">Repeated transitions reveal routes into playback and places a
|
||||
viewer commonly leaves a flow.</p>
|
||||
</div></div>
|
||||
<div class="table-wrap"><table>
|
||||
<thead><tr><th>From</th><th>To</th><th class="num">Times</th></tr></thead>
|
||||
<tbody id="engagement-paths"></tbody>
|
||||
</table></div>
|
||||
</section>
|
||||
|
||||
<section class="card" id="engagement-journey-card" hidden>
|
||||
<div class="card-head"><div>
|
||||
<h2 class="card-title" data-icon="clock" data-icon-tone="info">Chronological journey</h2>
|
||||
<p class="card-note">Newest journeys first; actions within each journey run from start
|
||||
to finish. A journey without an end event indicates an interruption or abandonment.</p>
|
||||
</div></div>
|
||||
<div class="table-wrap"><table>
|
||||
<thead><tr><th>Time</th><th>Journey</th><th>Action</th><th>Screen / path</th><th>Feature</th><th>Content reference</th><th>Outcome</th></tr></thead>
|
||||
<tbody id="engagement-events"></tbody>
|
||||
</table></div>
|
||||
</section>
|
||||
|
||||
@@ -1,11 +1,81 @@
|
||||
const { fmt, ui, $ } = Admin;
|
||||
|
||||
const featureCatalogue = [
|
||||
'home', 'movies', 'shows', 'favorites', 'search', 'recent_searches',
|
||||
'genre_browse', 'for_you', 'for_you_time', 'recommendation', 'continue',
|
||||
'latest', 'my_shows', 'details', 'playback', 'notifications', 'profiles', 'settings',
|
||||
];
|
||||
|
||||
const label = (value) => {
|
||||
const text = String(value || '—').replaceAll('_', ' ');
|
||||
if (text === 'favorites') return 'Favourites';
|
||||
if (text === 'abandoned') return 'Abandoned / interrupted';
|
||||
return text;
|
||||
};
|
||||
|
||||
function renderUser(payload) {
|
||||
const selected = $('engagement-user').value;
|
||||
const users = payload.users || [];
|
||||
const current = users.find((user) => user.userId === selected);
|
||||
$('engagement-user-tiles').innerHTML = current ? ui.tiles([
|
||||
['events', fmt.number(current.events), { icon: 'pulse', tone: 'info' }],
|
||||
['journeys', fmt.number(current.journeys), { icon: 'list', tone: 'data' }],
|
||||
['last active', fmt.when(current.lastActiveAt), { icon: 'clock', tone: 'note', small: true }],
|
||||
['history kept', payload.retentionDays + ' days', { icon: 'clock', small: true }],
|
||||
]) : '';
|
||||
|
||||
['feature', 'path', 'journey'].forEach((name) => {
|
||||
$('engagement-' + name + '-card').hidden = !current;
|
||||
});
|
||||
if (!current) return;
|
||||
|
||||
const used = new Map((payload.features || []).map((feature) => [feature.feature, feature]));
|
||||
const features = [...new Set([...featureCatalogue, ...used.keys()])];
|
||||
$('engagement-features').innerHTML = features.map((name) => {
|
||||
const stat = used.get(name);
|
||||
const uses = stat?.uses || 0;
|
||||
const status = uses === 0 ? ui.tag('not used', 'warn') : uses < 3 ? ui.tag('rare', 'note') : ui.tag('used', 'ok');
|
||||
return '<tr><td>' + fmt.escape(label(name)) + '</td><td class="num">' + fmt.number(uses) +
|
||||
'</td><td class="muted">' + (stat ? fmt.when(stat.lastUsedAt) : '—') + '</td><td>' + status + '</td></tr>';
|
||||
}).join('');
|
||||
|
||||
const paths = payload.paths || [];
|
||||
$('engagement-paths').innerHTML = paths.length ? paths.map((path) =>
|
||||
'<tr><td>' + fmt.escape(label(path.from)) + '</td><td>' + fmt.escape(label(path.to)) +
|
||||
'</td><td class="num">' + fmt.number(path.count) + '</td></tr>').join('')
|
||||
: ui.emptyRow(3, 'No repeated paths in this window.');
|
||||
|
||||
const grouped = new Map();
|
||||
(payload.events || []).forEach((event) => {
|
||||
if (!grouped.has(event.journeyId)) grouped.set(event.journeyId, []);
|
||||
grouped.get(event.journeyId).push(event);
|
||||
});
|
||||
let journeyNumber = grouped.size;
|
||||
const rows = [];
|
||||
grouped.forEach((events) => {
|
||||
events.sort((a, b) => a.sequence - b.sequence);
|
||||
const number = journeyNumber--;
|
||||
events.forEach((event) => {
|
||||
const path = event.source && event.target ? label(event.source) + ' → ' + label(event.target)
|
||||
: label(event.target || event.screen);
|
||||
const content = event.itemId ? label(event.itemType) + ' · ' + event.itemId : '—';
|
||||
rows.push('<tr><td class="muted">' + fmt.when(event.occurredAt) + '</td>' +
|
||||
'<td class="num">' + number + '</td><td>' + fmt.escape(label(event.action)) + '</td>' +
|
||||
'<td>' + fmt.escape(path) + '</td><td>' + fmt.escape(label(event.feature)) + '</td>' +
|
||||
'<td class="muted">' + fmt.escape(content) + '</td><td>' + fmt.escape(label(event.outcome)) + '</td></tr>');
|
||||
});
|
||||
});
|
||||
$('engagement-events').innerHTML = rows.length ? rows.join('') : ui.emptyRow(7, 'No journey events in this window.');
|
||||
}
|
||||
|
||||
Admin.onRefresh(async () => {
|
||||
const payload = await Admin.api('/admin/api/analytics?days=' + $('engagement-days').value);
|
||||
const selected = $('engagement-user').value;
|
||||
const payload = await Admin.api('/admin/api/analytics?days=' + $('engagement-days').value +
|
||||
(selected ? '&userId=' + encodeURIComponent(selected) : ''));
|
||||
const rows = payload.rows || [];
|
||||
$('engagement-rows').innerHTML = rows.length ? rows.map((row) =>
|
||||
'<tr><td>' + fmt.escape(row.rowId) + '</td>' +
|
||||
'<td class="muted">' + fmt.escape(row.rowKind || '—') + '</td>' +
|
||||
'<tr><td>' + fmt.escape(label(row.rowId)) + '</td>' +
|
||||
'<td class="muted">' + fmt.escape(label(row.rowKind)) + '</td>' +
|
||||
'<td class="num">' + fmt.duration(row.dwellMs) + '</td>' +
|
||||
'<td class="num">' + fmt.number(row.impressions) + '</td>' +
|
||||
'<td class="num">' + fmt.number(row.focuses) + '</td>' +
|
||||
@@ -13,6 +83,14 @@ Admin.onRefresh(async () => {
|
||||
'<td class="num">' + Math.round((row.selectRate || 0) * 100) + '%</td>' +
|
||||
'<td class="num">' + fmt.number(row.viewers) + '</td></tr>').join('')
|
||||
: ui.emptyRow(8, 'No events in this window.');
|
||||
|
||||
const users = payload.users || [];
|
||||
const existing = $('engagement-user').value;
|
||||
$('engagement-user').innerHTML = '<option value="">Choose a user</option>' + users.map((user) =>
|
||||
'<option value="' + fmt.escape(user.userId) + '">' + fmt.escape(user.username || user.userId) + '</option>').join('');
|
||||
if (users.some((user) => user.userId === existing)) $('engagement-user').value = existing;
|
||||
renderUser(payload);
|
||||
});
|
||||
|
||||
Admin.ready(() => $('engagement-days').addEventListener('change', Admin.refresh));
|
||||
Admin.ready(() => $('engagement-user').addEventListener('change', Admin.refresh));
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/ponzischeme89/memby/server/internal/store"
|
||||
@@ -30,6 +31,108 @@ type analyticsRequest struct {
|
||||
Events []rowEventPayload `json:"events"`
|
||||
}
|
||||
|
||||
type journeyEventPayload struct {
|
||||
UserID string `json:"userId"`
|
||||
JourneyID string `json:"journeyId"`
|
||||
Sequence int `json:"sequence"`
|
||||
Category string `json:"category"`
|
||||
Action string `json:"action"`
|
||||
Screen string `json:"screen"`
|
||||
Feature string `json:"feature"`
|
||||
Source string `json:"source"`
|
||||
Target string `json:"target"`
|
||||
ItemID string `json:"itemId"`
|
||||
ItemType string `json:"itemType"`
|
||||
Outcome string `json:"outcome"`
|
||||
OccurredAt string `json:"occurredAt"`
|
||||
}
|
||||
|
||||
type journeyAnalyticsRequest struct {
|
||||
Events []journeyEventPayload `json:"events"`
|
||||
}
|
||||
|
||||
var journeyCategories = allowedAnalyticsValues("session", "navigation", "content", "search", "playback", "settings", "recommendations", "library", "profile", "notifications")
|
||||
var journeyActions = allowedAnalyticsValues(
|
||||
"journey_start", "journey_end", "screen_view", "open", "close", "select",
|
||||
"submit", "request", "start", "stop", "complete", "abandon", "change",
|
||||
"toggle", "follow", "unfollow", "favourite", "unfavourite", "mark_played",
|
||||
"mark_unplayed", "retry", "dismiss", "switch",
|
||||
)
|
||||
var journeyOutcomes = allowedAnalyticsValues("", "success", "failure", "cancelled", "completed", "abandoned")
|
||||
|
||||
func allowedAnalyticsValues(values ...string) map[string]bool {
|
||||
out := make(map[string]bool, len(values))
|
||||
for _, value := range values {
|
||||
out[value] = true
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// handleJourneyAnalytics accepts privacy-bounded journey steps. The payload's user id is
|
||||
// only a profile-switch guard: authority always comes from the bearer session.
|
||||
func (s *Server) handleJourneyAnalytics(w http.ResponseWriter, r *http.Request, sess store.Session) {
|
||||
var req journeyAnalyticsRequest
|
||||
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 128<<10)).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "malformed request body")
|
||||
return
|
||||
}
|
||||
if len(req.Events) > maxAnalyticsBatch {
|
||||
req.Events = req.Events[:maxAnalyticsBatch]
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
events := make([]store.JourneyEvent, 0, len(req.Events))
|
||||
for _, payload := range req.Events {
|
||||
if event, ok := toJourneyEvent(payload, sess.EmbyUserID, now); ok {
|
||||
events = append(events, event)
|
||||
}
|
||||
}
|
||||
if err := s.store.InsertJourneyEvents(r.Context(), events); err != nil {
|
||||
s.loggerFor(r.Context()).Warn("journey analytics write failed", "error", err)
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func toJourneyEvent(payload journeyEventPayload, userID string, now time.Time) (store.JourneyEvent, bool) {
|
||||
if payload.UserID != userID || !safeAnalyticsValue(payload.JourneyID, 80) ||
|
||||
payload.Sequence < 0 || !journeyCategories[payload.Category] || !journeyActions[payload.Action] ||
|
||||
!journeyOutcomes[payload.Outcome] {
|
||||
return store.JourneyEvent{}, false
|
||||
}
|
||||
fields := []string{payload.Screen, payload.Feature, payload.Source, payload.Target, payload.ItemID, payload.ItemType}
|
||||
for _, field := range fields {
|
||||
if !safeAnalyticsValue(field, 100) {
|
||||
return store.JourneyEvent{}, false
|
||||
}
|
||||
}
|
||||
occurredAt := analyticsOccurredAt(payload.OccurredAt, now)
|
||||
return store.JourneyEvent{OccurredAt: occurredAt, UserID: userID, JourneyID: payload.JourneyID,
|
||||
Sequence: payload.Sequence, Category: payload.Category, Action: payload.Action,
|
||||
Screen: payload.Screen, Feature: payload.Feature, Source: payload.Source,
|
||||
Target: payload.Target, ItemID: payload.ItemID, ItemType: payload.ItemType,
|
||||
Outcome: payload.Outcome}, true
|
||||
}
|
||||
|
||||
func safeAnalyticsValue(value string, max int) bool {
|
||||
if len(value) > max {
|
||||
return false
|
||||
}
|
||||
for _, char := range value {
|
||||
if !(char == '-' || char == '_' || char == '.' || char == ':' ||
|
||||
char >= 'a' && char <= 'z' || char >= 'A' && char <= 'Z' || char >= '0' && char <= '9') {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func analyticsOccurredAt(value string, now time.Time) time.Time {
|
||||
if parsed, err := time.Parse(time.RFC3339, strings.TrimSpace(value)); err == nil &&
|
||||
parsed.After(now.Add(-24*time.Hour)) && parsed.Before(now.Add(time.Hour)) {
|
||||
return parsed.UTC()
|
||||
}
|
||||
return now
|
||||
}
|
||||
|
||||
// handleRowAnalytics accepts a batch of row engagement events from a TV.
|
||||
//
|
||||
// Fire-and-forget by design: the client does not retry, and a rejected event is never
|
||||
@@ -81,16 +184,7 @@ func toRowEvent(payload rowEventPayload, userID string, now time.Time) (store.Ro
|
||||
return store.RowEvent{}, false
|
||||
}
|
||||
|
||||
occurredAt := now
|
||||
if payload.OccurredAt != "" {
|
||||
if parsed, err := time.Parse(time.RFC3339, payload.OccurredAt); err == nil {
|
||||
// Trust the device's clock only within a sane window; TVs are notorious for
|
||||
// waking up in 1970.
|
||||
if parsed.After(now.Add(-24*time.Hour)) && parsed.Before(now.Add(time.Hour)) {
|
||||
occurredAt = parsed.UTC()
|
||||
}
|
||||
}
|
||||
}
|
||||
occurredAt := analyticsOccurredAt(payload.OccurredAt, now)
|
||||
|
||||
dwell := payload.DwellMs
|
||||
if dwell < 0 {
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestJourneyEventUsesAuthenticatedUser(t *testing.T) {
|
||||
now := time.Date(2026, 8, 10, 12, 0, 0, 0, time.UTC)
|
||||
payload := journeyEventPayload{UserID: "user-1", JourneyID: "journey-1", Sequence: 3,
|
||||
Category: "navigation", Action: "open", Screen: "home", Feature: "search", Target: "search"}
|
||||
|
||||
event, ok := toJourneyEvent(payload, "user-1", now)
|
||||
if !ok {
|
||||
t.Fatal("valid event was rejected")
|
||||
}
|
||||
if event.UserID != "user-1" || event.Sequence != 3 {
|
||||
t.Fatalf("unexpected event: %+v", event)
|
||||
}
|
||||
if _, ok := toJourneyEvent(payload, "user-2", now); ok {
|
||||
t.Fatal("an event buffered under another profile was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestJourneyEventRejectsFreeTextAndUnknownVocabulary(t *testing.T) {
|
||||
now := time.Now().UTC()
|
||||
base := journeyEventPayload{UserID: "u1", JourneyID: "j1", Category: "content", Action: "open"}
|
||||
withTitle := base
|
||||
withTitle.Feature = "A Film Title"
|
||||
if _, ok := toJourneyEvent(withTitle, "u1", now); ok {
|
||||
t.Fatal("free text feature was accepted")
|
||||
}
|
||||
unknown := base
|
||||
unknown.Action = "typed_query"
|
||||
if _, ok := toJourneyEvent(unknown, "u1", now); ok {
|
||||
t.Fatal("unknown action was accepted")
|
||||
}
|
||||
}
|
||||
@@ -126,7 +126,7 @@ func (s *Server) Routes() http.Handler {
|
||||
// once, without the gate ever touching health checks or the admin page.
|
||||
v1 := http.NewServeMux()
|
||||
|
||||
v1.HandleFunc("POST /v1/auth/login", s.handleLogin)
|
||||
v1.HandleFunc("POST /v1/auth/login", s.requireSupportedClient(s.handleLogin))
|
||||
v1.Handle("POST /v1/auth/logout", s.authed(s.handleLogout))
|
||||
v1.Handle("GET /v1/auth/session", s.authed(s.handleSession))
|
||||
v1.Handle("GET /v1/auth/devices", s.authed(s.handleDevices))
|
||||
@@ -196,6 +196,7 @@ func (s *Server) Routes() http.Handler {
|
||||
|
||||
v1.Handle("POST /v1/playback/{phase}", s.authed(s.handlePlaybackReport))
|
||||
v1.Handle("POST /v1/analytics/rows", s.authed(s.handleRowAnalytics))
|
||||
v1.Handle("POST /v1/analytics/events", s.authed(s.handleJourneyAnalytics))
|
||||
|
||||
v1.Handle("GET /v1/images/{itemId}/{imageType}", s.authed(s.handleImage))
|
||||
|
||||
@@ -204,8 +205,8 @@ func (s *Server) Routes() http.Handler {
|
||||
mux.HandleFunc("GET /readyz", s.handleReady)
|
||||
// Update policy is app-scoped, not user-scoped. Keep it outside authentication and
|
||||
// maintenance so a fresh install, a signed-out TV, and a retired build can all learn
|
||||
// whether the server requires an update without touching a viewer session.
|
||||
mux.HandleFunc("GET /v1/update", s.handleUpdate)
|
||||
// whether the server requires an update. A valid session enriches only its log context.
|
||||
mux.Handle("GET /v1/update", s.identifyOptionalSession(http.HandlerFunc(s.handleUpdate)))
|
||||
// Exact route outside the maintenance gate: signed-in clients poll this lightweight
|
||||
// status even while every normal /v1 operation is deliberately unavailable.
|
||||
mux.Handle("GET /v1/status", s.authed(s.handleServiceStatus))
|
||||
@@ -230,6 +231,21 @@ func (s *Server) Routes() http.Handler {
|
||||
|
||||
type authedFunc func(http.ResponseWriter, *http.Request, store.Session)
|
||||
|
||||
// identifyOptionalSession gives public routes the viewer and television attached to a
|
||||
// valid bearer token without turning authentication into a condition of access. The
|
||||
// update check must remain reachable before sign-in, but an offer made to a signed-in
|
||||
// client should still say whose session is affected in the logs.
|
||||
func (s *Server) identifyOptionalSession(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if token := bearerToken(r); token != "" {
|
||||
if sess, err := s.sessionFor(r.Context(), token); err == nil {
|
||||
identify(r.Context(), sess)
|
||||
}
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// authed resolves the bearer token to a session before running h.
|
||||
//
|
||||
// Images are also accepted with a `t=` query parameter: Coil builds plain URLs from the
|
||||
@@ -253,6 +269,26 @@ 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)) {
|
||||
// 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.
|
||||
if err := s.store.DeleteSession(r.Context(), sess.TokenHash); err != nil {
|
||||
s.loggerFor(r.Context()).Error("required-update session delete failed", "error", err)
|
||||
}
|
||||
_ = s.cache.Delete(r.Context(), cache.SessionKey(hexHash(sess.TokenHash)))
|
||||
_ = s.cache.InvalidateUser(r.Context(), sess.EmbyUserID)
|
||||
s.loggerFor(r.Context()).Info("signed out for required update",
|
||||
"device_id", sess.DeviceID,
|
||||
"from", clientLogValue(clientVersion(r)),
|
||||
"minimum", forcedUpdateFloor,
|
||||
"to", decision.Version,
|
||||
)
|
||||
w.Header().Set("X-Memby-Update-Required", decision.Version)
|
||||
writeError(w, http.StatusUnauthorized, "Memby must be updated before signing in again")
|
||||
return
|
||||
}
|
||||
h(w, r, sess)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -17,6 +17,12 @@ 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 {
|
||||
@@ -90,13 +96,54 @@ 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.
|
||||
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 only client input is its build-version header and
|
||||
// the answer comes from memory, so checking it never reads or mutates a user session.
|
||||
// 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 := appupdate.Decide(s.updatePolicy.get(), clientVersion(r))
|
||||
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.
|
||||
|
||||
@@ -2,12 +2,15 @@ package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/ponzischeme89/memby/server/internal/appupdate"
|
||||
"github.com/ponzischeme89/memby/server/internal/config"
|
||||
serverlogging "github.com/ponzischeme89/memby/server/internal/logging"
|
||||
"github.com/ponzischeme89/memby/server/internal/store"
|
||||
)
|
||||
|
||||
@@ -38,3 +41,105 @@ func TestUpdateStatusIsPublicAndAvailableDuringMaintenance(t *testing.T) {
|
||||
t.Fatalf("status = %q, want mandatory", decision.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateOfferLogNamesTheAffectedViewer(t *testing.T) {
|
||||
logger, events := serverlogging.NewBuffered(io.Discard, slog.LevelInfo, 10, serverlogging.FormatConsole)
|
||||
server := New(config.Config{}, Deps{Log: logger, Events: events})
|
||||
server.updatePolicy.set(appupdate.Policy{
|
||||
Enabled: true,
|
||||
LatestVersion: "0.2.44",
|
||||
DownloadURL: "/updates/memby-0.2.44.apk",
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/v1/update", nil)
|
||||
req.Header.Set("X-Memby-Version", "0.2.38")
|
||||
req, _ = withRequestIdentity(req)
|
||||
identify(req.Context(), store.Session{Username: "matt", DeviceName: "Living room"})
|
||||
server.handleUpdate(httptest.NewRecorder(), req)
|
||||
|
||||
page := events.Events(0, 10)
|
||||
if len(page.Events) != 1 {
|
||||
t.Fatalf("events = %d, want 1", len(page.Events))
|
||||
}
|
||||
event := page.Events[0]
|
||||
if event.Message != "update offered" || event.Attributes["user"] != "matt" {
|
||||
t.Fatalf("update event was not attributed to the viewer: %+v", event)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmergencyFloorForcesClientsBelow0244(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",
|
||||
})
|
||||
|
||||
for version, want := range map[string]string{
|
||||
"0.2.43": appupdate.StatusMandatory,
|
||||
"0.2.44": appupdate.StatusNone,
|
||||
"0.2.45": appupdate.StatusNone,
|
||||
} {
|
||||
req := httptest.NewRequest(http.MethodGet, "/v1/update", nil)
|
||||
req.Header.Set("X-Memby-Version", version)
|
||||
rec := httptest.NewRecorder()
|
||||
server.handleUpdate(rec, req)
|
||||
|
||||
var decision appupdate.Decision
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &decision); err != nil {
|
||||
t.Fatalf("%s: decode decision: %v", version, err)
|
||||
}
|
||||
if decision.Status != want {
|
||||
t.Errorf("%s: status = %q, want %q", version, decision.Status, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmergencyFloorWaitsForAnActionableRelease(t *testing.T) {
|
||||
for name, policy := range map[string]appupdate.Policy{
|
||||
"disabled": {
|
||||
LatestVersion: "0.2.44", DownloadURL: "/updates/memby-0.2.44.apk",
|
||||
},
|
||||
"missing download": {
|
||||
Enabled: true, LatestVersion: "0.2.44",
|
||||
},
|
||||
"release too old": {
|
||||
Enabled: true, LatestVersion: "0.2.43", 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")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
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",
|
||||
})
|
||||
reached := false
|
||||
handler := server.requireSupportedClient(func(http.ResponseWriter, *http.Request) {
|
||||
reached = true
|
||||
})
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/auth/login", nil)
|
||||
req.Header.Set("X-Memby-Version", "0.2.43")
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
handler(rec, req)
|
||||
|
||||
if reached {
|
||||
t.Fatal("retired client reached the login handler")
|
||||
}
|
||||
if rec.Code != http.StatusUpgradeRequired {
|
||||
t.Fatalf("status = %d, want 426", rec.Code)
|
||||
}
|
||||
if rec.Header().Get("X-Memby-Update-Required") != "0.2.44" {
|
||||
t.Fatalf("required update header = %q", rec.Header().Get("X-Memby-Update-Required"))
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user