Rewrites the internal/api comment blocks to rule 32: task, milestone and rule ids gone, the stories behind them restated as the constraint they left, stale references corrected (the stream secret is wired from config, the timezone handler already refreshes the scheduler, the Android app is the native client, the design docs cited are not in the repo). Also rewraps comment lines over 80 columns left by the earlier batches. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
211 lines
7.6 KiB
Go
211 lines
7.6 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 (
|
|
"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: 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"`
|
|
}
|
|
|
|
// 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)
|
|
}
|