ci / api / api (push) Successful in 50s
ci / db / db (push) Successful in 50s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Failing after 49s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m13s
ci / ts / ts (packages + web) (push) Canceled after 2m4s
ci / desktop / rust (desktop proxy) (push) Successful in 5m33s
release / release (версия и заметки) (push) Successful in 9s
release / android (APK в релиз) (push) Failing after 4m6s
release / desktop (Linux и Windows в релиз) (push) Successful in 17m20s
Co-authored-by: Cursor <cursoragent@cursor.com>
456 lines
13 KiB
Go
456 lines
13 KiB
Go
// Package appupdate — раздача клиентов через сервер (docs/mobile.md#самообновление):
|
|
// сервер читает последний релиз Gitea своим токеном, кэширует ответ и файлы.
|
|
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 — обновления не настроены или релиза с клиентами нет.
|
|
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"`
|
|
}
|
|
|
|
type clientFile struct {
|
|
Kind, Name, URL, ChecksumURL, SHA256 string
|
|
Size int64
|
|
}
|
|
|
|
// Latest — сведения о последней версии и файлах клиентов.
|
|
type Latest struct {
|
|
Version string
|
|
VersionCode int
|
|
Notes string
|
|
Files map[string]clientFile
|
|
}
|
|
|
|
func (l *Latest) android() (clientFile, bool) {
|
|
f, ok := l.Files["android"]
|
|
return f, ok && f.SHA256 != ""
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// 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 != "" &&
|
|
!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
|
|
}
|
|
|
|
func classifyAsset(name string) string {
|
|
n := strings.ToLower(name)
|
|
if strings.HasSuffix(n, ".sha256") {
|
|
return ""
|
|
}
|
|
switch {
|
|
case strings.HasSuffix(n, ".apk"):
|
|
return "android"
|
|
case strings.HasSuffix(n, ".exe"), strings.HasSuffix(n, ".msi"):
|
|
return "windows"
|
|
case strings.HasSuffix(n, ".appimage"), strings.HasSuffix(n, ".deb"), strings.HasSuffix(n, ".rpm"):
|
|
return "linux"
|
|
case strings.HasSuffix(n, ".dmg"), strings.Contains(n, ".app.tar"):
|
|
return "macos"
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
func assetRank(kind, name string) int {
|
|
n := strings.ToLower(name)
|
|
switch kind {
|
|
case "windows":
|
|
if strings.Contains(n, "setup") {
|
|
return 2
|
|
}
|
|
return 1
|
|
case "linux":
|
|
switch {
|
|
case strings.HasSuffix(n, ".deb"):
|
|
return 3
|
|
case strings.HasSuffix(n, ".rpm"):
|
|
return 2
|
|
default:
|
|
return 1
|
|
}
|
|
default:
|
|
return 1
|
|
}
|
|
}
|
|
|
|
func pickClientFiles(assets []asset) map[string]clientFile {
|
|
sums := map[string]string{}
|
|
for _, a := range assets {
|
|
if strings.HasSuffix(strings.ToLower(a.Name), ".sha256") {
|
|
sums[strings.TrimSuffix(a.Name, ".sha256")] = a.URL
|
|
sums[strings.TrimSuffix(a.Name, ".SHA256")] = a.URL
|
|
}
|
|
}
|
|
out := map[string]clientFile{}
|
|
for _, a := range assets {
|
|
kind := classifyAsset(a.Name)
|
|
if kind == "" {
|
|
continue
|
|
}
|
|
cur, ok := out[kind]
|
|
if ok && assetRank(kind, a.Name) <= assetRank(kind, cur.Name) {
|
|
continue
|
|
}
|
|
out[kind] = clientFile{Kind: kind, Name: a.Name, URL: a.URL, Size: a.Size, ChecksumURL: sums[a.Name]}
|
|
}
|
|
return out
|
|
}
|
|
|
|
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())
|
|
}
|
|
files := pickClientFiles(rel.Assets)
|
|
if len(files) == 0 {
|
|
return nil, apperr.NotFound(CodeUnavailable, "release has no client builds")
|
|
}
|
|
if apk, ok := files["android"]; ok {
|
|
if apk.ChecksumURL == "" {
|
|
delete(files, "android")
|
|
} else if digest, err := h.readDigest(ctx, apk.ChecksumURL); err != nil {
|
|
h.log.WarnContext(ctx, "app update: apk checksum unavailable", "err", err)
|
|
delete(files, "android")
|
|
} else {
|
|
apk.SHA256 = digest
|
|
files["android"] = apk
|
|
}
|
|
if len(files) == 0 {
|
|
return nil, apperr.NotFound(CodeUnavailable, "release has no APK yet")
|
|
}
|
|
}
|
|
h.cached = &Latest{Version: version, VersionCode: code, Notes: rel.Body, Files: files}
|
|
h.cachedAt = time.Now()
|
|
return h.cached, nil
|
|
}
|
|
|
|
func (h *Handler) readDigest(ctx context.Context, url string) (string, error) {
|
|
sres, err := h.get(ctx, url)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
raw, err := io.ReadAll(io.LimitReader(sres.Body, 256))
|
|
_ = sres.Body.Close()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
digest := strings.ToLower(strings.Fields(string(raw) + " x")[0])
|
|
if len(digest) != 64 {
|
|
return "", fmt.Errorf("malformed checksum")
|
|
}
|
|
return digest, nil
|
|
}
|
|
|
|
func (h *Handler) fileURL(kind, version string) string {
|
|
return "/api/v1/app/download/" + kind + "/" + version
|
|
}
|
|
|
|
// GetAppClients реализует gen.StrictServerInterface.
|
|
func (h *Handler) GetAppClients(ctx context.Context, _ gen.GetAppClientsRequestObject) (gen.GetAppClientsResponseObject, error) {
|
|
l, err := h.latest(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
order := []string{"windows", "macos", "linux", "android"}
|
|
clients := make([]gen.AppClientFile, 0, len(l.Files))
|
|
for _, kind := range order {
|
|
f, ok := l.Files[kind]
|
|
if !ok {
|
|
continue
|
|
}
|
|
item := gen.AppClientFile{
|
|
Client: gen.AppClient(kind),
|
|
Filename: f.Name,
|
|
Size: f.Size,
|
|
DownloadUrl: h.fileURL(kind, l.Version),
|
|
}
|
|
if f.SHA256 != "" {
|
|
item.Sha256 = &f.SHA256
|
|
}
|
|
clients = append(clients, item)
|
|
}
|
|
return gen.GetAppClients200JSONResponse{
|
|
Version: l.Version, VersionCode: l.VersionCode, NotesMd: l.Notes, Clients: clients,
|
|
}, 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
|
|
}
|
|
f, ok := l.android()
|
|
if !ok {
|
|
return nil, apperr.NotFound(CodeUnavailable, "release has no APK yet")
|
|
}
|
|
return gen.GetAndroidLatest200JSONResponse{
|
|
Version: l.Version, VersionCode: l.VersionCode, NotesMd: l.Notes, Size: f.Size, Sha256: f.SHA256,
|
|
DownloadUrl: "/api/v1/app/android/download/" + l.Version, MinSupported: h.cfg.MinSupported,
|
|
}, nil
|
|
}
|
|
|
|
func (h *Handler) ensure(ctx context.Context, f clientFile) (string, error) {
|
|
dst := filepath.Join(h.cfg.Dir, f.Name)
|
|
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, f.URL)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
defer func() { _ = res.Body.Close() }()
|
|
tmp, err := os.CreateTemp(h.cfg.Dir, ".dl-*")
|
|
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 f.SHA256 != "" {
|
|
if got := hex.EncodeToString(hash.Sum(nil)); got != f.SHA256 {
|
|
return "", fmt.Errorf("appupdate: checksum mismatch: %s != %s", got, f.SHA256)
|
|
}
|
|
}
|
|
if err := os.Rename(tmp.Name(), dst); err != nil {
|
|
return "", err
|
|
}
|
|
h.cleanup(f.Name)
|
|
return dst, nil
|
|
}
|
|
|
|
func (h *Handler) cleanup(keepName string) {
|
|
matches, _ := filepath.Glob(filepath.Join(h.cfg.Dir, "pipu-music-*"))
|
|
ver := versionFromName(keepName)
|
|
for _, m := range matches {
|
|
base := filepath.Base(m)
|
|
if ver != "" && strings.Contains(base, ver) {
|
|
continue
|
|
}
|
|
if base != keepName {
|
|
_ = os.Remove(m)
|
|
}
|
|
}
|
|
}
|
|
|
|
func versionFromName(name string) string {
|
|
m := regexp.MustCompile(`(\d+\.\d+\.\d+(?:-rc\.\d+)?)`).FindStringSubmatch(name)
|
|
if m == nil {
|
|
return ""
|
|
}
|
|
return m[1]
|
|
}
|
|
|
|
func (h *Handler) serveFile(ctx context.Context, l *Latest, kind string) (fileResponse, error) {
|
|
f, ok := l.Files[kind]
|
|
if !ok || (kind == "android" && f.SHA256 == "") {
|
|
return fileResponse{}, apperr.NotFound(CodeUnavailable, "client build is unavailable")
|
|
}
|
|
path, err := h.ensure(ctx, f)
|
|
if err != nil {
|
|
h.log.ErrorContext(ctx, "app update: download failed", "err", err, "client", kind)
|
|
return fileResponse{}, apperr.New(http.StatusBadGateway, CodeUnavailable, "could not fetch the file")
|
|
}
|
|
return fileResponse{path: path, name: f.Name, req: httpx.RequestFrom(ctx)}, nil
|
|
}
|
|
|
|
// 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")
|
|
}
|
|
return h.serveFile(ctx, l, "android")
|
|
}
|
|
|
|
// DownloadAppClient реализует gen.StrictServerInterface.
|
|
func (h *Handler) DownloadAppClient(ctx context.Context, req gen.DownloadAppClientRequestObject) (gen.DownloadAppClientResponseObject, 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")
|
|
}
|
|
return h.serveFile(ctx, l, string(req.Client))
|
|
}
|
|
|
|
type fileResponse struct {
|
|
path string
|
|
name string
|
|
req *http.Request
|
|
}
|
|
|
|
func (a fileResponse) VisitDownloadAndroidApkResponse(w http.ResponseWriter) error {
|
|
return a.write(w)
|
|
}
|
|
|
|
func (a fileResponse) VisitDownloadAppClientResponse(w http.ResponseWriter) error {
|
|
return a.write(w)
|
|
}
|
|
|
|
func (a fileResponse) write(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")
|
|
}
|
|
ctype := "application/octet-stream"
|
|
if strings.HasSuffix(strings.ToLower(a.name), ".apk") {
|
|
ctype = "application/vnd.android.package-archive"
|
|
}
|
|
w.Header().Set("Content-Type", ctype)
|
|
w.Header().Set("Content-Disposition", `attachment; filename="`+a.name+`"`)
|
|
http.ServeContent(w, a.req, a.name, info.ModTime(), f)
|
|
return nil
|
|
}
|