feat(api): Android self-update endpoints proxied from Gitea releases (M5)
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 43s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m12s
ci / api / api (push) Successful in 35s
ci / ts / ts (packages + web) (push) Successful in 1m20s

GET /api/v1/app/android/latest reads the latest Gitea release with the
server token (cached for 10 minutes); the download endpoint caches the
APK under /updates, verifies its SHA-256 and serves it with Range.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mrleo1nidandClaude Opus 5.5 committed 2026-10-20 12:52:41 +03:00
1 parent 9fd616da57
commit 6a67c27f50
10 files changed
+940 -173

No files matched your search

@@ -0,0 +1,293 @@
// Package appupdate — самообновление Android-приложения через сервер (docs/mobile.md#самообновление):
// сервер читает последний релиз Gitea своим токеном, кэширует ответ и APK.
package appupdate
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"sync"
"time"
"pipu/api/internal/api/gen"
"pipu/api/internal/platform/apperr"
"pipu/api/internal/platform/httpx"
)
// CodeUnavailable — обновления не настроены или релиза с APK нет.
const CodeUnavailable = "app.update_unavailable"
const cacheTTL = 10 * time.Minute
var tagRe = regexp.MustCompile(`^v?(\d+)\.(\d+)\.(\d+)(?:-rc\.(\d+))?$`)
// VersionCode — versionCode по тегу; та же формула, что в scripts/version.mjs:
// MAJOR·1 000 000 + MINOR·10 000 + PATCH·100 + (99 для релиза | N для -rc.N).
func VersionCode(tag string) (string, int, error) {
m := tagRe.FindStringSubmatch(strings.TrimSpace(tag))
if m == nil {
return "", 0, fmt.Errorf("appupdate: not a release tag: %q", tag)
}
n := func(s string) int { v, _ := strconv.Atoi(s); return v }
major, minor, patch := n(m[1]), n(m[2]), n(m[3])
if minor > 99 || patch > 99 {
return "", 0, fmt.Errorf("appupdate: minor and patch must be <= 99: %q", tag)
}
rc, version := 99, fmt.Sprintf("%d.%d.%d", major, minor, patch)
if m[4] != "" {
rc = n(m[4])
version += "-rc." + m[4]
}
return version, major*1_000_000 + minor*10_000 + patch*100 + rc, nil
}
// Config — доступ к Gitea и каталог кэша.
type Config struct {
GiteaURL string
Repo string // owner/name
Token string
Dir string // /updates
MinSupported int
}
type asset struct {
Name string `json:"name"`
Size int64 `json:"size"`
URL string `json:"browser_download_url"`
}
type release struct {
Tag string `json:"tag_name"`
Body string `json:"body"`
Assets []asset `json:"assets"`
}
// Latest — сведения о последней версии.
type Latest struct {
Version string
VersionCode int
Notes string
Size int64
SHA256 string
apkURL string
}
// Handler реализует app-часть gen.StrictServerInterface.
type Handler struct {
cfg Config
client *http.Client
log *slog.Logger
mu sync.Mutex
cached *Latest
cachedAt time.Time
dl sync.Mutex // одна загрузка APK за раз
}
// NewHandler создаёт обработчик.
func NewHandler(cfg Config, log *slog.Logger) *Handler {
return &Handler{cfg: cfg, client: &http.Client{Timeout: 10 * time.Minute}, log: log}
}
func (h *Handler) configured() bool {
return h.cfg.GiteaURL != "" && h.cfg.Repo != ""
}
func (h *Handler) get(ctx context.Context, url string) (*http.Response, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, err
}
if h.cfg.Token != "" {
req.Header.Set("Authorization", "token "+h.cfg.Token)
}
res, err := h.client.Do(req)
if err != nil {
return nil, fmt.Errorf("gitea: %w", err)
}
if res.StatusCode != http.StatusOK {
_ = res.Body.Close()
return nil, fmt.Errorf("gitea: %s: status %d", url, res.StatusCode)
}
return res, nil
}
// latest — последний релиз с APK (кэш на 10 минут).
func (h *Handler) latest(ctx context.Context) (*Latest, error) {
if !h.configured() {
return nil, apperr.NotFound(CodeUnavailable, "gitea is not configured")
}
h.mu.Lock()
defer h.mu.Unlock()
if h.cached != nil && time.Since(h.cachedAt) < cacheTTL {
return h.cached, nil
}
ctx, cancel := context.WithTimeout(ctx, 15*time.Second)
defer cancel()
base := strings.TrimRight(h.cfg.GiteaURL, "/")
res, err := h.get(ctx, base+"/api/v1/repos/"+h.cfg.Repo+"/releases/latest")
if err != nil {
h.log.WarnContext(ctx, "app update: gitea unavailable", "err", err)
if h.cached != nil {
return h.cached, nil // устаревший ответ лучше, чем никакого
}
return nil, apperr.NotFound(CodeUnavailable, "release is unavailable")
}
var rel release
err = json.NewDecoder(res.Body).Decode(&rel)
_ = res.Body.Close()
if err != nil {
return nil, fmt.Errorf("gitea: decode release: %w", err)
}
version, code, err := VersionCode(rel.Tag)
if err != nil {
return nil, apperr.NotFound(CodeUnavailable, err.Error())
}
name := "pipu-music-" + version + ".apk"
var apk, sum *asset
for i := range rel.Assets {
switch rel.Assets[i].Name {
case name:
apk = &rel.Assets[i]
case name + ".sha256":
sum = &rel.Assets[i]
}
}
if apk == nil || sum == nil {
return nil, apperr.NotFound(CodeUnavailable, "release has no APK yet")
}
sres, err := h.get(ctx, sum.URL)
if err != nil {
return nil, apperr.NotFound(CodeUnavailable, "checksum is unavailable")
}
raw, err := io.ReadAll(io.LimitReader(sres.Body, 256))
_ = sres.Body.Close()
if err != nil {
return nil, err
}
digest := strings.ToLower(strings.Fields(string(raw) + " x")[0])
if len(digest) != 64 {
return nil, apperr.NotFound(CodeUnavailable, "malformed checksum")
}
h.cached = &Latest{Version: version, VersionCode: code, Notes: rel.Body, Size: apk.Size, SHA256: digest, apkURL: apk.URL}
h.cachedAt = time.Now()
return h.cached, nil
}
// GetAndroidLatest реализует gen.StrictServerInterface.
func (h *Handler) GetAndroidLatest(ctx context.Context, _ gen.GetAndroidLatestRequestObject) (gen.GetAndroidLatestResponseObject, error) {
l, err := h.latest(ctx)
if err != nil {
return nil, err
}
return gen.GetAndroidLatest200JSONResponse{
Version: l.Version, VersionCode: l.VersionCode, NotesMd: l.Notes, Size: l.Size, Sha256: l.SHA256,
DownloadUrl: "/api/v1/app/android/download/" + l.Version, MinSupported: h.cfg.MinSupported,
}, nil
}
// ensure скачивает APK в кэш (если его там нет) и сверяет SHA-256 из релиза.
func (h *Handler) ensure(ctx context.Context, l *Latest) (string, error) {
dst := filepath.Join(h.cfg.Dir, "pipu-music-"+l.Version+".apk")
if _, err := os.Stat(dst); err == nil {
return dst, nil
}
h.dl.Lock()
defer h.dl.Unlock()
if _, err := os.Stat(dst); err == nil {
return dst, nil
}
if err := os.MkdirAll(h.cfg.Dir, 0o755); err != nil {
return "", err
}
res, err := h.get(ctx, l.apkURL)
if err != nil {
return "", err
}
defer func() { _ = res.Body.Close() }()
tmp, err := os.CreateTemp(h.cfg.Dir, ".apk-*")
if err != nil {
return "", err
}
defer func() { _ = os.Remove(tmp.Name()) }()
hash := sha256.New()
if _, err := io.Copy(io.MultiWriter(tmp, hash), res.Body); err != nil {
_ = tmp.Close()
return "", fmt.Errorf("appupdate: download: %w", err)
}
if err := tmp.Close(); err != nil {
return "", err
}
if got := hex.EncodeToString(hash.Sum(nil)); got != l.SHA256 {
return "", fmt.Errorf("appupdate: checksum mismatch: %s != %s", got, l.SHA256)
}
if err := os.Rename(tmp.Name(), dst); err != nil {
return "", err
}
h.cleanup(dst)
return dst, nil
}
// cleanup оставляет в кэше только текущий APK.
func (h *Handler) cleanup(keep string) {
matches, _ := filepath.Glob(filepath.Join(h.cfg.Dir, "pipu-music-*.apk"))
for _, m := range matches {
if m != keep {
_ = os.Remove(m)
}
}
}
// DownloadAndroidApk реализует gen.StrictServerInterface. Отдаётся только последняя версия.
func (h *Handler) DownloadAndroidApk(ctx context.Context, req gen.DownloadAndroidApkRequestObject) (gen.DownloadAndroidApkResponseObject, error) {
l, err := h.latest(ctx)
if err != nil {
return nil, err
}
if req.Version != l.Version {
return nil, apperr.NotFound(CodeUnavailable, "only the latest version is available")
}
path, err := h.ensure(ctx, l)
if err != nil {
h.log.ErrorContext(ctx, "app update: apk download failed", "err", err)
return nil, apperr.New(http.StatusBadGateway, CodeUnavailable, "could not fetch the APK")
}
return apkResponse{path: path, name: filepath.Base(path), req: httpx.RequestFrom(ctx)}, nil
}
type apkResponse struct {
path string
name string
req *http.Request
}
// VisitDownloadAndroidApkResponse реализует gen.DownloadAndroidApkResponseObject (Range — для докачки).
func (a apkResponse) VisitDownloadAndroidApkResponse(w http.ResponseWriter) error {
f, err := os.Open(a.path)
if err != nil {
return err
}
defer func() { _ = f.Close() }()
info, err := f.Stat()
if err != nil {
return err
}
if a.req == nil {
return errors.New("appupdate: request missing in context")
}
w.Header().Set("Content-Type", "application/vnd.android.package-archive")
w.Header().Set("Content-Disposition", `attachment; filename="`+a.name+`"`)
http.ServeContent(w, a.req, a.name, info.ModTime(), f)
return nil
}
@@ -0,0 +1,77 @@
package appupdate
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"log/slog"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestVersionCode(t *testing.T) {
cases := map[string]int{"v0.6.1": 60_199, "1.2.0-rc.3": 1_020_003, "v10.0.0": 10_000_099}
for tag, want := range cases {
_, got, err := VersionCode(tag)
require.NoError(t, err, tag)
assert.Equal(t, want, got, tag)
}
for _, bad := range []string{"latest", "v1.100.0", "1.2"} {
_, _, err := VersionCode(bad)
assert.Error(t, err, bad)
}
}
func TestLatestAndDownload(t *testing.T) {
apk := []byte("fake apk bytes")
sum := sha256.Sum256(apk)
var srv *httptest.Server
srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "token secret", r.Header.Get("Authorization"))
switch r.URL.Path {
case "/api/v1/repos/o/r/releases/latest":
_ = json.NewEncoder(w).Encode(map[string]any{
"tag_name": "v0.6.1", "body": "## Новое",
"assets": []map[string]any{
{"name": "pipu-music-0.6.1.apk", "size": len(apk), "browser_download_url": srv.URL + "/apk"},
{"name": "pipu-music-0.6.1.apk.sha256", "size": 65, "browser_download_url": srv.URL + "/sum"},
},
})
case "/apk":
_, _ = w.Write(apk)
case "/sum":
_, _ = w.Write([]byte(hex.EncodeToString(sum[:]) + "\n"))
default:
http.NotFound(w, r)
}
}))
defer srv.Close()
dir := t.TempDir()
h := NewHandler(Config{GiteaURL: srv.URL, Repo: "o/r", Token: "secret", Dir: dir, MinSupported: 60_000}, slog.Default())
l, err := h.latest(context.Background())
require.NoError(t, err)
assert.Equal(t, "0.6.1", l.Version)
assert.Equal(t, 60_199, l.VersionCode)
assert.Equal(t, hex.EncodeToString(sum[:]), l.SHA256)
path, err := h.ensure(context.Background(), l)
require.NoError(t, err)
got, _ := os.ReadFile(path)
assert.Equal(t, apk, got)
// Подменённый файл не пройдёт сверку.
require.NoError(t, os.Remove(path))
l.SHA256 = "00" + l.SHA256[2:]
_, err = h.ensure(context.Background(), l)
assert.ErrorContains(t, err, "checksum mismatch")
_, statErr := os.Stat(filepath.Join(dir, "pipu-music-0.6.1.apk"))
assert.True(t, os.IsNotExist(statErr))
}