Files
minstrel/internal/api/client_assets.go
T
bvandeusenandClaude Opus 5.5 fa75bd08d7
release / govulncheck (push) Successful in 21s
release / web (push) Successful in 1m41s
release / go (push) Successful in 1m55s
release / android (push) Successful in 5m19s
release / Build signed APK (releases and dev) (push) Successful in 5m31s
release / integration (push) Successful in 5m39s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 28s
release / Verify release artifacts (tag releases only) (push) Skipped
feat: a newer app is told in a snackbar on Home and installed from a sheet holding Settings' card, fetched ahead on Wi-Fi (#6452)
Mirrors Librarian (#6446, #6448), after Gallery's update tray (canon #6367).

- Home shows "Version X of the app is on your server." / "…is downloaded
  and ready." with Update / Install; the button opens a bottom sheet
  holding the same About card Settings shows, so there is one install
  path. Each build is told at most once a day, kept on disk.
- The shell-wide update banner and its in-memory dismissals are gone.
- AppUpdates (singleton) holds the offer and the install, so Home and
  Settings show the same state. Checks run when either screen opens, on
  every return to the front, every 5 minutes while shown, and on Home's
  pull to refresh.
- UpdateFetchWorker downloads the newer APK ahead on an unmetered network
  (battery and storage not low, three tries); the download writes a .part
  file and keeps it under the build's name only once its size and sha256
  match, so Install is usually one tap.
- Back from allowing installs, the update the listener asked for carries
  on (kept in SavedStateHandle) instead of asking them to tap again.
- An offer needs a higher code AND a different name: the same app built
  again at a later minute is not offered.
- Server: /api/client/version reports the APK's sha256, worked out from
  the served file and cached by size and mtime.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 07:46:01 -04:00

261 lines
9.1 KiB
Go

package api
// In-app update endpoints. 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 (a local build); the
// client treats 404 as "no update channel available."
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"io"
"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
)
// clientAPKSum is the bundled APK's sha256, kept until the file changes
// under it (another size or modification time), so a version check every
// few minutes from every phone does not re-read tens of megabytes each time.
type clientAPKSum struct {
size int64
mod time.Time
hex string
}
var (
clientAPKSumCache clientAPKSum
clientAPKSumCacheMu sync.Mutex
)
// clientAPKDigest returns the sha256 of the APK at path, in hex.
func clientAPKDigest(path string, st os.FileInfo) (string, error) {
clientAPKSumCacheMu.Lock()
defer clientAPKSumCacheMu.Unlock()
c := clientAPKSumCache
if c.hex != "" && c.size == st.Size() && c.mod.Equal(st.ModTime()) {
return c.hex, nil
}
f, err := os.Open(path)
if err != nil {
return "", err
}
defer func() { _ = f.Close() }()
h := sha256.New()
if _, err := io.Copy(h, f); err != nil {
return "", err
}
clientAPKSumCache = clientAPKSum{size: st.Size(), mod: st.ModTime(), hex: hex.EncodeToString(h.Sum(nil))}
return clientAPKSumCache.hex, nil
}
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: a "<name> <code>" line split
// on the first space 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"`
// SHA256 is worked out from the file being served, not copied from the
// sidecar, so it cannot disagree with the bytes the phone receives. The
// app downloads ahead on Wi-Fi and installs later; this is how it knows
// the file it kept is the whole build that was offered.
SHA256 string `json:"sha256"`
}
// 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
}
sum, err := clientAPKDigest(apkPath, stat)
if err != nil {
writeErrWithLog(w, h.logger, "client_version: digest apk", err)
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(),
SHA256: sum,
})
}
// 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)
}