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 Flutter // client's polling only fires after login (banner mounts in the post- // login shell), 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 Flutter client treats 404 as "no update channel // available." import ( "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 } type clientVersionResponse struct { Version string `json:"version"` 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 } writeJSON(w, http.StatusOK, clientVersionResponse{ Version: strings.TrimSpace(string(versionBytes)), 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) }