The client compares names while Android installs by versionCode, and the wire had no way to close that gap: the sidecar was one positional line and the endpoint returned a name only. This is the plumbing that makes the ordering key decidable by the client at all. The sidecar is now JSON rather than a grown positional string. That shape was chosen against a specific failure: the obvious growth path was "<name> <code>", which a first-space split silently mangles the moment a third field appears — the code stops parsing as an integer and the reader falls back to name comparison WITHOUT erroring. JSON cannot mistake a new field for an old one. code is a POINTER on both sides, and omitempty on the wire. Absent has to stay distinguishable from zero: a build published before ordering keys were recorded genuinely has no code, and zero would claim it is infinitely old rather than unknown. A malformed sidecar now fails loudly instead of serving a blank version. If an unreadable file produced an empty name, every client would compare against nothing, conclude it was current, and go quiet — "I cannot read this" and "there is nothing newer" would return the same answer, which is the failure mode nobody reports because nobody is offered anything to report. The non-tag :latest path no longer RECONSTRUCTS the bundled APK's version. android-release now publishes the sidecar as a release asset beside the APK, and the image build downloads it. The old reconstruction duplicated a derivation formula across two files, and could only ever recover the name — the ordering key is build-time minutes and exists nowhere once that build ends. Releases predating the sidecar report their name with a null code, which is the honest answer rather than a guessed one. image-release also drops to a shallow checkout: it needed full history and tags only to re-derive versions from the tagged commit, and now touches git for nothing. MINSTREL_VERSION comes from GITHUB_REF. Two things checked rather than assumed. The Android Json sets ignoreUnknownKeys, so the added fields cannot break already-installed apps. It also sets coerceInputValues, which will silently turn a null code into 0 if step 4 declares the field non-nullable — recorded on task #3811, because reading the field declaration alone would never reveal it. Also fixes a stale comment block describing "the Flutter client", deleted in v2026.08.18. Step 3 of 5 — Scribe task #3810, milestone #390. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SQ31KQpYbStyK5y58UmPLH
214 lines
7.8 KiB
Go
214 lines
7.8 KiB
Go
package api
|
|
|
|
// In-app update endpoints (#397). The Android APK ships bundled with
|
|
// the server image so the client can self-update without an external
|
|
// app store. CI sequencing bakes the APK + sidecar version file into
|
|
// /app/client/ at image build time.
|
|
//
|
|
// Both endpoints are authenticated — the bandwidth cost of the APK
|
|
// (~30-60 MB) makes anonymous access an abuse vector. The client only
|
|
// polls after login, so this gate is invisible to the actual update flow.
|
|
//
|
|
// /api/client/apk additionally rate-limits per user to a single
|
|
// download every 60s. Real install flows fire one download per
|
|
// update; anything tighter is scripted/abusive.
|
|
//
|
|
// Returns 404 gracefully when the APK isn't present (dev environments,
|
|
// pre-CI-wiring); the client treats 404 as "no update channel available."
|
|
//
|
|
// (These paragraphs said "the Flutter client" until 2026-09-10. That client
|
|
// was deleted in v2026.08.18 — the Android app is the only one now.)
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
|
|
syncpkg "git.fabledsword.com/bvandeusen/minstrel/internal/sync"
|
|
)
|
|
|
|
const (
|
|
defaultClientAPKDir = "/app/client"
|
|
clientAPKFilename = "minstrel.apk"
|
|
clientVersionFile = "minstrel.apk.version"
|
|
|
|
// clientAPKMinInterval throttles per-user APK downloads. 60s is
|
|
// generous for a real install flow (one download) and tight enough
|
|
// to suppress accidental hammering or scripted abuse.
|
|
clientAPKMinInterval = 60 * time.Second
|
|
)
|
|
|
|
// clientAPKDir resolves the directory holding the bundled APK. Env
|
|
// var MINSTREL_CLIENT_APK_DIR overrides for dev; default matches the
|
|
// Dockerfile's COPY destination.
|
|
func clientAPKDir() string {
|
|
if d, ok := os.LookupEnv("MINSTREL_CLIENT_APK_DIR"); ok && d != "" {
|
|
return d
|
|
}
|
|
return defaultClientAPKDir
|
|
}
|
|
|
|
// clientAPKLastDownload tracks the last APK-download timestamp per
|
|
// user id (UUID hex string). Cheap in-memory map under mutex; a single
|
|
// household has at most a handful of users so the map never grows.
|
|
// Reset on process restart, which is fine — abuse protection, not
|
|
// audit. testResetClientAPKRateLimit() lets tests start clean.
|
|
var (
|
|
clientAPKLastDownload = map[string]time.Time{}
|
|
clientAPKLastDownloadMu sync.Mutex
|
|
)
|
|
|
|
func testResetClientAPKRateLimit() {
|
|
clientAPKLastDownloadMu.Lock()
|
|
defer clientAPKLastDownloadMu.Unlock()
|
|
clientAPKLastDownload = map[string]time.Time{}
|
|
}
|
|
|
|
// clientAPKAllowDownload checks + updates the per-user rate-limit
|
|
// state. Returns the wait duration if blocked, or 0 if allowed.
|
|
func clientAPKAllowDownload(userID string, now time.Time) time.Duration {
|
|
clientAPKLastDownloadMu.Lock()
|
|
defer clientAPKLastDownloadMu.Unlock()
|
|
if last, ok := clientAPKLastDownload[userID]; ok {
|
|
elapsed := now.Sub(last)
|
|
if elapsed < clientAPKMinInterval {
|
|
return clientAPKMinInterval - elapsed
|
|
}
|
|
}
|
|
clientAPKLastDownload[userID] = now
|
|
return 0
|
|
}
|
|
|
|
// clientVersionSidecar is the JSON written beside the bundled APK by
|
|
// release.yml. It carries three values that are deliberately separate:
|
|
//
|
|
// - Name is a LABEL for people, "YYYY.MM.DD.HHMM" from the commit's
|
|
// timestamp. Two channels carrying the same code report the same name.
|
|
// - Code is the ORDERING KEY, minutes since 2020-01-01 at build time, and
|
|
// is the value Android itself installs by. It answers "may this be
|
|
// installed over that?" — the name never does.
|
|
// - Channel is a SIBLING FIELD, never a suffix inside the name.
|
|
//
|
|
// JSON rather than a positional line on purpose. The obvious growth path for
|
|
// the old one-value file was "<name> <code>", which a first-space split
|
|
// silently mangles the moment a third field appears: the code stops parsing,
|
|
// and the reader falls back to name comparison WITHOUT erroring.
|
|
type clientVersionSidecar struct {
|
|
Name string `json:"name"`
|
|
// Pointer, not int64: absent must stay distinguishable from zero. An
|
|
// artifact published before codes were recorded genuinely has no code —
|
|
// zero would claim it is infinitely old rather than unknown.
|
|
Code *int64 `json:"code"`
|
|
Channel string `json:"channel"`
|
|
}
|
|
|
|
type clientVersionResponse struct {
|
|
Version string `json:"version"`
|
|
// omitempty on both: the client must be able to tell "this server does
|
|
// not report a code" from "this build's code is 0", because those call
|
|
// for different behaviour on the other end.
|
|
Code *int64 `json:"code,omitempty"`
|
|
Channel string `json:"channel,omitempty"`
|
|
APKURL string `json:"apk_url"`
|
|
SizeBytes int64 `json:"size_bytes"`
|
|
}
|
|
|
|
// handleClientVersion returns the bundled Android client version + a
|
|
// URL to fetch the APK. 404 when no APK is bundled.
|
|
func (h *handlers) handleClientVersion(w http.ResponseWriter, _ *http.Request) {
|
|
dir := clientAPKDir()
|
|
apkPath := filepath.Join(dir, clientAPKFilename)
|
|
versionPath := filepath.Join(dir, clientVersionFile)
|
|
|
|
stat, err := os.Stat(apkPath)
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
http.Error(w, `{"error":{"code":"no_client_apk","message":"no bundled client apk"}}`, http.StatusNotFound)
|
|
return
|
|
}
|
|
if err != nil {
|
|
writeErrWithLog(w, h.logger, "client_version: stat apk", err)
|
|
return
|
|
}
|
|
|
|
versionBytes, err := os.ReadFile(versionPath)
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
http.Error(w, `{"error":{"code":"no_client_version","message":"apk present but version file missing"}}`, http.StatusNotFound)
|
|
return
|
|
}
|
|
if err != nil {
|
|
writeErrWithLog(w, h.logger, "client_version: read version", err)
|
|
return
|
|
}
|
|
|
|
var sidecar clientVersionSidecar
|
|
if err := json.Unmarshal(versionBytes, &sidecar); err != nil {
|
|
// Fail LOUDLY rather than serving a blank version. The failure mode
|
|
// this avoids is the one that never gets reported: if an unreadable
|
|
// sidecar produced an empty name, every client would compare against
|
|
// nothing, conclude it was current, and go quiet — "I cannot read
|
|
// this" and "there is nothing newer" would be the same answer.
|
|
writeErrWithLog(w, h.logger, "client_version: sidecar is not valid JSON", err)
|
|
return
|
|
}
|
|
if sidecar.Name == "" {
|
|
http.Error(w, `{"error":{"code":"bad_client_version","message":"version sidecar has no name"}}`, http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, clientVersionResponse{
|
|
Version: strings.TrimSpace(sidecar.Name),
|
|
Code: sidecar.Code,
|
|
Channel: strings.TrimSpace(sidecar.Channel),
|
|
APKURL: "/api/client/apk",
|
|
SizeBytes: stat.Size(),
|
|
})
|
|
}
|
|
|
|
// handleClientAPK streams the bundled APK with the correct
|
|
// Content-Type so Android's PackageInstaller accepts it. Per-user
|
|
// rate-limited (clientAPKMinInterval).
|
|
func (h *handlers) handleClientAPK(w http.ResponseWriter, r *http.Request) {
|
|
user, ok := auth.UserFromContext(r.Context())
|
|
if !ok {
|
|
http.Error(w, `{"error":{"code":"unauthenticated","message":"login required"}}`, http.StatusUnauthorized)
|
|
return
|
|
}
|
|
userID := syncpkg.FormatUUID(user.ID)
|
|
if wait := clientAPKAllowDownload(userID, time.Now()); wait > 0 {
|
|
w.Header().Set("Retry-After", strconv.Itoa(int(wait.Seconds())+1))
|
|
http.Error(w, `{"error":{"code":"rate_limited","message":"too many downloads; try again shortly"}}`, http.StatusTooManyRequests)
|
|
return
|
|
}
|
|
|
|
apkPath := filepath.Join(clientAPKDir(), clientAPKFilename)
|
|
f, err := os.Open(apkPath)
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
http.Error(w, `{"error":{"code":"no_client_apk","message":"no bundled client apk"}}`, http.StatusNotFound)
|
|
return
|
|
}
|
|
if err != nil {
|
|
writeErrWithLog(w, h.logger, "client_apk: open", err)
|
|
return
|
|
}
|
|
defer func() { _ = f.Close() }()
|
|
|
|
stat, err := f.Stat()
|
|
if err != nil {
|
|
writeErrWithLog(w, h.logger, "client_apk: stat", err)
|
|
return
|
|
}
|
|
|
|
// Use http.ServeContent so Range requests work — install flows on
|
|
// flaky networks may resume rather than restart.
|
|
w.Header().Set("Content-Type", "application/vnd.android.package-archive")
|
|
w.Header().Set("Content-Disposition", `attachment; filename="minstrel.apk"`)
|
|
http.ServeContent(w, r, clientAPKFilename, stat.ModTime(), f)
|
|
}
|