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