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 " ", 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) }