// 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" "sort" "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 // keepClientReleases — сколько последних релизов с файлами отдаём на /download (как prune в CI). const keepClientReleases = 8 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"` Draft bool `json:"draft"` PublishedAt time.Time `json:"published_at"` CreatedAt time.Time `json:"created_at"` 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 *catalog cachedAt time.Time dl sync.Mutex } type catalog struct { latest *Latest all []*Latest } // 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) catalog(ctx context.Context) (*catalog, 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, "/") repo := h.cfg.Repo rels, err := h.listReleases(ctx, base, repo) 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 all []*Latest for _, rel := range rels { if rel.Draft { continue } l := h.parseRelease(ctx, rel) if l == nil { continue } all = append(all, l) if len(all) >= keepClientReleases { break } } if len(all) == 0 { return nil, apperr.NotFound(CodeUnavailable, "release has no client builds") } cat := &catalog{latest: all[0], all: all} h.cached = cat h.cachedAt = time.Now() return cat, nil } func (h *Handler) listReleases(ctx context.Context, base, repo string) ([]release, error) { res, err := h.get(ctx, base+"/api/v1/repos/"+repo+"/releases?limit=20") if err == nil { var rels []release decErr := json.NewDecoder(res.Body).Decode(&rels) _ = res.Body.Close() if decErr != nil { return nil, fmt.Errorf("gitea: decode releases: %w", decErr) } sort.SliceStable(rels, func(i, j int) bool { ti, tj := rels[i].PublishedAt, rels[j].PublishedAt if ti.IsZero() { ti = rels[i].CreatedAt } if tj.IsZero() { tj = rels[j].CreatedAt } return ti.After(tj) }) return rels, nil } res, err = h.get(ctx, base+"/api/v1/repos/"+repo+"/releases/latest") if err != nil { return nil, err } var rel release decErr := json.NewDecoder(res.Body).Decode(&rel) _ = res.Body.Close() if decErr != nil { return nil, fmt.Errorf("gitea: decode release: %w", decErr) } return []release{rel}, nil } func (h *Handler) parseRelease(ctx context.Context, rel release) *Latest { version, code, err := VersionCode(rel.Tag) if err != nil { return nil } files := pickClientFiles(rel.Assets) if len(files) == 0 { return nil } 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, "version", version) delete(files, "android") } else { apk.SHA256 = digest files["android"] = apk } } if len(files) == 0 { return nil } return &Latest{Version: version, VersionCode: code, Notes: rel.Body, Files: files} } func (h *Handler) latest(ctx context.Context) (*Latest, error) { cat, err := h.catalog(ctx) if err != nil { return nil, err } return cat.latest, nil } func (c *catalog) release(version string) *Latest { for _, l := range c.all { if l.Version == version { return l } } return nil } func (c *catalog) keepNames() map[string]struct{} { out := map[string]struct{}{} for _, l := range c.all { for _, f := range l.Files { out[f.Name] = struct{}{} } } return out } 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) { cat, err := h.catalog(ctx) if err != nil { return nil, err } l := cat.latest order := []string{"windows", "macos", "linux", "android"} clients := make([]gen.AppClientFile, 0, len(order)) for _, kind := range order { item, ok := h.clientWithOlder(cat, kind) if !ok { continue } clients = append(clients, item) } if len(clients) == 0 { return nil, apperr.NotFound(CodeUnavailable, "release has no client builds") } return gen.GetAppClients200JSONResponse{ Version: l.Version, VersionCode: l.VersionCode, NotesMd: l.Notes, Clients: clients, }, nil } func (h *Handler) clientWithOlder(cat *catalog, kind string) (gen.AppClientFile, bool) { var head *Latest var file clientFile var older []gen.AppClientBuild for _, rel := range cat.all { f, ok := rel.Files[kind] if !ok { continue } if head == nil { head, file = rel, f continue } older = append(older, gen.AppClientBuild{ Version: rel.Version, Filename: f.Name, Size: f.Size, DownloadUrl: h.fileURL(kind, rel.Version), Sha256: shaPtr(f.SHA256), }) } if head == nil { return gen.AppClientFile{}, false } item := gen.AppClientFile{ Client: gen.AppClient(kind), Version: &head.Version, Filename: file.Name, Size: file.Size, DownloadUrl: h.fileURL(kind, head.Version), Sha256: shaPtr(file.SHA256), } if len(older) > 0 { item.Older = &older } return item, true } func shaPtr(s string) *string { if s == "" { return nil } return &s } // 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) { keep := map[string]struct{}{keepName: {}} h.mu.Lock() if h.cached != nil { for name := range h.cached.keepNames() { keep[name] = struct{}{} } } h.mu.Unlock() matches, _ := filepath.Glob(filepath.Join(h.cfg.Dir, "pipu-music-*")) for _, m := range matches { if _, ok := keep[filepath.Base(m)]; ok { continue } _ = 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 { 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. Версия должна быть в каталоге (до 8 релизов). func (h *Handler) DownloadAndroidApk(ctx context.Context, req gen.DownloadAndroidApkRequestObject) (gen.DownloadAndroidApkResponseObject, error) { cat, err := h.catalog(ctx) if err != nil { return nil, err } l := cat.release(req.Version) if l == nil { return nil, apperr.NotFound(CodeUnavailable, "this version is no longer available") } if _, ok := l.android(); !ok { return nil, apperr.NotFound(CodeUnavailable, "client build is unavailable") } return h.serveFile(ctx, l, "android") } // DownloadAppClient реализует gen.StrictServerInterface. func (h *Handler) DownloadAppClient(ctx context.Context, req gen.DownloadAppClientRequestObject) (gen.DownloadAppClientResponseObject, error) { cat, err := h.catalog(ctx) if err != nil { return nil, err } l := cat.release(req.Version) if l == nil { return nil, apperr.NotFound(CodeUnavailable, "this version is no longer 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 }