feat(server): /api/client/version + /api/client/apk endpoints (#397 phase 1)
Phase 1 of the in-app update flow — server side. Endpoints serve the bundled Android APK + sidecar version file from /app/client/. Returns 404 gracefully when files aren't present, so dev environments and pre-CI-wiring images degrade cleanly to "no update available." - internal/api/client_assets.go: handleClientVersion + handleClientAPK. Both unauthenticated (matches /healthz) so install flow doesn't depend on a live session. APK served with proper application/vnd.android.package-archive Content-Type + http.ServeContent so Range requests work for resumable downloads on flaky networks. - Path resolves to /app/client/ by default; MINSTREL_CLIENT_APK_DIR env var overrides for dev. - Dockerfile creates /app/client/ + commented COPY hooks for the CI sequencing phase. - Tests cover all four states: missing apk, apk-but-no-version, both present (200 with correct shape), apk stream (200 with correct Content-Type + body bytes). Phases 2 (Flutter client provider + banner + install intent + Android manifest changes) and 3 (CI sequencing to bake the APK into the image) land in follow-up commits. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
+10
-1
@@ -33,9 +33,18 @@ COPY config.example.yaml /etc/smartmusic/config.yaml
|
||||
# A non-writable path at this location silently breaks every downstream
|
||||
# cache, so we create + chown it once at image build. Operators mount a
|
||||
# named volume on top to persist across container recreates.
|
||||
RUN mkdir -p /app/data && chown -R minstrel:minstrel /app
|
||||
RUN mkdir -p /app/data /app/client && chown -R minstrel:minstrel /app
|
||||
WORKDIR /app
|
||||
|
||||
# In-app update channel (#397): bundled Android APK + sidecar version
|
||||
# file. CI populates these on tag releases via a build context COPY;
|
||||
# leaving the COPY commented during scaffold means the endpoints return
|
||||
# 404 until the CI sequencing lands. Operators can also drop these in
|
||||
# at runtime via a volume mount on /app/client.
|
||||
#
|
||||
# COPY minstrel.apk /app/client/minstrel.apk
|
||||
# COPY minstrel.apk.version /app/client/minstrel.apk.version
|
||||
|
||||
USER minstrel
|
||||
EXPOSE 4533
|
||||
ENV MINSTREL_STORAGE_DATA_DIR=/app/data
|
||||
|
||||
@@ -52,6 +52,11 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
|
||||
api.Post("/auth/register", h.handleRegister)
|
||||
api.Post("/auth/forgot-password", h.handleForgotPassword)
|
||||
api.Post("/auth/reset-password", h.handleResetPassword)
|
||||
|
||||
// Self-hosted in-app update channel (#397). Unauthenticated so
|
||||
// the install flow doesn't depend on a live session.
|
||||
api.Get("/client/version", h.handleClientVersion)
|
||||
api.Get("/client/apk", h.handleClientAPK)
|
||||
api.Group(func(authed chi.Router) {
|
||||
authed.Use(auth.RequireUser(pool))
|
||||
authed.Post("/auth/logout", h.handleLogout)
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
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 unauthenticated — install flow shouldn't depend
|
||||
// on a live session, and the URL is hard to guess at scale.
|
||||
//
|
||||
// Returns 404 gracefully when the APK isn't present (dev environments,
|
||||
// pre-CI-wiring); the Flutter client treats 404 as "no update channel
|
||||
// available" and falls back to the manual download.
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultClientAPKDir = "/app/client"
|
||||
clientAPKFilename = "minstrel.apk"
|
||||
clientVersionFile = "minstrel.apk.version"
|
||||
)
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
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.
|
||||
func (h *handlers) handleClientAPK(w http.ResponseWriter, r *http.Request) {
|
||||
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 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)
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// withClientAPKDir points the handlers at a fresh temp dir for each
|
||||
// test and restores the env var on cleanup. Returns the dir.
|
||||
func withClientAPKDir(t *testing.T) string {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
prev, hadPrev := os.LookupEnv("MINSTREL_CLIENT_APK_DIR")
|
||||
os.Setenv("MINSTREL_CLIENT_APK_DIR", dir)
|
||||
t.Cleanup(func() {
|
||||
if hadPrev {
|
||||
os.Setenv("MINSTREL_CLIENT_APK_DIR", prev)
|
||||
} else {
|
||||
os.Unsetenv("MINSTREL_CLIENT_APK_DIR")
|
||||
}
|
||||
})
|
||||
return dir
|
||||
}
|
||||
|
||||
func TestClientVersion_404WhenNoAPK(t *testing.T) {
|
||||
withClientAPKDir(t)
|
||||
h := &handlers{logger: slog.New(slog.NewTextHandler(io.Discard, nil))}
|
||||
rr := httptest.NewRecorder()
|
||||
h.handleClientVersion(rr, httptest.NewRequest(http.MethodGet, "/api/client/version", nil))
|
||||
if rr.Code != http.StatusNotFound {
|
||||
t.Errorf("want 404, got %d", rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientVersion_404WhenAPKButNoVersion(t *testing.T) {
|
||||
dir := withClientAPKDir(t)
|
||||
if err := os.WriteFile(filepath.Join(dir, clientAPKFilename), []byte("fake apk"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h := &handlers{logger: slog.New(slog.NewTextHandler(io.Discard, nil))}
|
||||
rr := httptest.NewRecorder()
|
||||
h.handleClientVersion(rr, httptest.NewRequest(http.MethodGet, "/api/client/version", nil))
|
||||
if rr.Code != http.StatusNotFound {
|
||||
t.Errorf("want 404, got %d", rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientVersion_200WithBothFiles(t *testing.T) {
|
||||
dir := withClientAPKDir(t)
|
||||
body := []byte("fake apk content")
|
||||
if err := os.WriteFile(filepath.Join(dir, clientAPKFilename), body, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, clientVersionFile), []byte("v2026.05.10\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h := &handlers{logger: slog.New(slog.NewTextHandler(io.Discard, nil))}
|
||||
rr := httptest.NewRecorder()
|
||||
h.handleClientVersion(rr, httptest.NewRequest(http.MethodGet, "/api/client/version", nil))
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("want 200, got %d (body: %s)", rr.Code, rr.Body.String())
|
||||
}
|
||||
var resp clientVersionResponse
|
||||
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if resp.Version != "v2026.05.10" {
|
||||
t.Errorf("version: want trimmed v2026.05.10, got %q", resp.Version)
|
||||
}
|
||||
if resp.APKURL != "/api/client/apk" {
|
||||
t.Errorf("apk_url: want /api/client/apk, got %q", resp.APKURL)
|
||||
}
|
||||
if resp.SizeBytes != int64(len(body)) {
|
||||
t.Errorf("size_bytes: want %d, got %d", len(body), resp.SizeBytes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientAPK_404WhenMissing(t *testing.T) {
|
||||
withClientAPKDir(t)
|
||||
h := &handlers{logger: slog.New(slog.NewTextHandler(io.Discard, nil))}
|
||||
rr := httptest.NewRecorder()
|
||||
h.handleClientAPK(rr, httptest.NewRequest(http.MethodGet, "/api/client/apk", nil))
|
||||
if rr.Code != http.StatusNotFound {
|
||||
t.Errorf("want 404, got %d", rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientAPK_StreamsWithCorrectContentType(t *testing.T) {
|
||||
dir := withClientAPKDir(t)
|
||||
body := []byte("PK\x03\x04 fake apk bytes")
|
||||
if err := os.WriteFile(filepath.Join(dir, clientAPKFilename), body, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h := &handlers{logger: slog.New(slog.NewTextHandler(io.Discard, nil))}
|
||||
rr := httptest.NewRecorder()
|
||||
h.handleClientAPK(rr, httptest.NewRequest(http.MethodGet, "/api/client/apk", nil))
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("want 200, got %d", rr.Code)
|
||||
}
|
||||
if got := rr.Header().Get("Content-Type"); got != "application/vnd.android.package-archive" {
|
||||
t.Errorf("Content-Type: want application/vnd.android.package-archive, got %q", got)
|
||||
}
|
||||
if rr.Body.Len() != len(body) {
|
||||
t.Errorf("body length: want %d, got %d", len(body), rr.Body.Len())
|
||||
}
|
||||
if got := rr.Body.Bytes(); string(got) != string(body) {
|
||||
t.Errorf("body bytes mismatch")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user