diff --git a/Dockerfile b/Dockerfile index 981b97b2..07b8f17b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 diff --git a/internal/api/api.go b/internal/api/api.go index afedd2ed..b9e2d204 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -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) diff --git a/internal/api/client_assets.go b/internal/api/client_assets.go new file mode 100644 index 00000000..b39dbff0 --- /dev/null +++ b/internal/api/client_assets.go @@ -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) +} diff --git a/internal/api/client_assets_test.go b/internal/api/client_assets_test.go new file mode 100644 index 00000000..e5bdc952 --- /dev/null +++ b/internal/api/client_assets_test.go @@ -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") + } +}