// 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} } // configured — адрес и репозиторий заданы, и это не заглушка из .env.example. func (h *Handler) configured() bool { return h.cfg.GiteaURL != "" && h.cfg.Repo != "" && !strings.Contains(h.cfg.GiteaURL, "example.") && !strings.HasPrefix(h.cfg.Repo, "owner/") } 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 }