update_service.go 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686
  1. package service
  2. import (
  3. "archive/tar"
  4. "bufio"
  5. "compress/gzip"
  6. "context"
  7. "crypto/sha256"
  8. "encoding/hex"
  9. "encoding/json"
  10. "fmt"
  11. "io"
  12. "net/url"
  13. "os"
  14. "path/filepath"
  15. "runtime"
  16. "sort"
  17. "strconv"
  18. "strings"
  19. "time"
  20. infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
  21. )
  22. var (
  23. ErrManagedUpdateRequired = infraerrors.Forbidden("MANAGED_UPDATE_REQUIRED", "定制站仅支持经过验证的本地构建或镜像升级,已禁用原版在线更新和回滚")
  24. ErrNoUpdateAvailable = infraerrors.Conflict("ALREADY_UP_TO_DATE", "no update available; current version is latest")
  25. ErrRollbackVersionNotAllowed = infraerrors.BadRequest("ROLLBACK_VERSION_NOT_ALLOWED", "version is not in the allowed rollback list")
  26. )
  27. const (
  28. updateCacheKey = "update_check_cache"
  29. updateCacheTTL = 1200 // 20 minutes
  30. githubRepo = "Wei-Shaw/sub2api"
  31. // Security: allowed download domains for updates
  32. allowedDownloadHost = "github.com"
  33. allowedAssetHost = "objects.githubusercontent.com"
  34. // Security: max download size (500MB)
  35. maxDownloadSize = 500 * 1024 * 1024
  36. // Rollback: expose at most the 3 most recent versions older than current
  37. maxRollbackVersions = 3
  38. // Fetch a few extra releases so filtering (current/newer/prerelease) still leaves enough candidates
  39. rollbackFetchPageSize = 15
  40. )
  41. // UpdateCache defines cache operations for update service
  42. type UpdateCache interface {
  43. GetUpdateInfo(ctx context.Context) (string, error)
  44. SetUpdateInfo(ctx context.Context, data string, ttl time.Duration) error
  45. }
  46. // GitHubReleaseClient 获取 GitHub release 信息的接口
  47. type GitHubReleaseClient interface {
  48. FetchLatestRelease(ctx context.Context, repo string) (*GitHubRelease, error)
  49. FetchRecentReleases(ctx context.Context, repo string, perPage int) ([]*GitHubRelease, error)
  50. DownloadFile(ctx context.Context, url, dest string, maxSize int64) error
  51. FetchChecksumFile(ctx context.Context, url string) ([]byte, error)
  52. }
  53. // UpdateService handles software updates
  54. type UpdateService struct {
  55. inPlaceUpdatesEnabled bool // Never enabled by this distribution's constructor.
  56. cache UpdateCache
  57. githubClient GitHubReleaseClient
  58. currentVersion string
  59. buildType string // "source" for manual builds, "release" for CI builds
  60. }
  61. // NewUpdateService creates a new UpdateService
  62. func NewUpdateService(cache UpdateCache, githubClient GitHubReleaseClient, version, buildType string) *UpdateService {
  63. return &UpdateService{
  64. cache: cache,
  65. githubClient: githubClient,
  66. currentVersion: version,
  67. buildType: buildType,
  68. }
  69. }
  70. // UpdateInfo contains update information
  71. type UpdateInfo struct {
  72. CurrentVersion string `json:"current_version"`
  73. LatestVersion string `json:"latest_version"`
  74. HasUpdate bool `json:"has_update"`
  75. ReleaseInfo *ReleaseInfo `json:"release_info,omitempty"`
  76. Cached bool `json:"cached"`
  77. Warning string `json:"warning,omitempty"`
  78. BuildType string `json:"build_type"` // "source" or "release"
  79. }
  80. // ReleaseInfo contains GitHub release details
  81. type ReleaseInfo struct {
  82. Name string `json:"name"`
  83. Body string `json:"body"`
  84. PublishedAt string `json:"published_at"`
  85. HTMLURL string `json:"html_url"`
  86. Assets []Asset `json:"assets,omitempty"`
  87. }
  88. // Asset represents a release asset
  89. type Asset struct {
  90. Name string `json:"name"`
  91. DownloadURL string `json:"download_url"`
  92. Size int64 `json:"size"`
  93. }
  94. // GitHubRelease represents GitHub API response
  95. type GitHubRelease struct {
  96. TagName string `json:"tag_name"`
  97. Name string `json:"name"`
  98. Body string `json:"body"`
  99. PublishedAt string `json:"published_at"`
  100. HTMLURL string `json:"html_url"`
  101. Draft bool `json:"draft"`
  102. Prerelease bool `json:"prerelease"`
  103. Assets []GitHubAsset `json:"assets"`
  104. }
  105. // RollbackVersion describes a release version the system can roll back to
  106. type RollbackVersion struct {
  107. Version string `json:"version"` // without "v" prefix, e.g. "0.1.146"
  108. PublishedAt string `json:"published_at"`
  109. HTMLURL string `json:"html_url"`
  110. }
  111. type GitHubAsset struct {
  112. Name string `json:"name"`
  113. BrowserDownloadURL string `json:"browser_download_url"`
  114. Size int64 `json:"size"`
  115. }
  116. // CheckUpdate checks for available updates
  117. func (s *UpdateService) CheckUpdate(ctx context.Context, force bool) (*UpdateInfo, error) {
  118. if !s.inPlaceUpdatesEnabled {
  119. return &UpdateInfo{CurrentVersion: s.currentVersion, LatestVersion: s.currentVersion, BuildType: "source", Warning: ErrManagedUpdateRequired.Message}, nil
  120. }
  121. // Try cache first
  122. if !force {
  123. if cached, err := s.getFromCache(ctx); err == nil && cached != nil {
  124. return cached, nil
  125. }
  126. }
  127. // Fetch from GitHub
  128. info, err := s.fetchLatestRelease(ctx)
  129. if err != nil {
  130. // Return cached on error
  131. if cached, cacheErr := s.getFromCache(ctx); cacheErr == nil && cached != nil {
  132. cached.Warning = "Using cached data: " + err.Error()
  133. return cached, nil
  134. }
  135. return &UpdateInfo{
  136. CurrentVersion: s.currentVersion,
  137. LatestVersion: s.currentVersion,
  138. HasUpdate: false,
  139. Warning: err.Error(),
  140. BuildType: s.buildType,
  141. }, nil
  142. }
  143. // Cache result
  144. s.saveToCache(ctx, info)
  145. return info, nil
  146. }
  147. // PerformUpdate downloads and applies the update
  148. // Uses atomic file replacement pattern for safe in-place updates
  149. func (s *UpdateService) PerformUpdate(ctx context.Context) error {
  150. if !s.inPlaceUpdatesEnabled {
  151. return ErrManagedUpdateRequired
  152. }
  153. info, err := s.CheckUpdate(ctx, true)
  154. if err != nil {
  155. return err
  156. }
  157. if !info.HasUpdate {
  158. return ErrNoUpdateAvailable
  159. }
  160. return s.applyReleaseAssets(ctx, info.ReleaseInfo.Assets)
  161. }
  162. // applyReleaseAssets downloads the platform archive from the given release assets,
  163. // verifies its checksum, and atomically swaps the running binary.
  164. // Shared by PerformUpdate (latest) and RollbackToVersion (specific older version).
  165. func (s *UpdateService) applyReleaseAssets(ctx context.Context, releaseAssets []Asset) error {
  166. // Find matching archive and checksum for current platform
  167. archiveName := s.getArchiveName()
  168. var downloadURL string
  169. var checksumURL string
  170. for _, asset := range releaseAssets {
  171. if strings.Contains(asset.Name, archiveName) && !strings.HasSuffix(asset.Name, ".txt") {
  172. downloadURL = asset.DownloadURL
  173. }
  174. if asset.Name == "checksums.txt" {
  175. checksumURL = asset.DownloadURL
  176. }
  177. }
  178. if downloadURL == "" {
  179. return fmt.Errorf("no compatible release found for %s/%s", runtime.GOOS, runtime.GOARCH)
  180. }
  181. // SECURITY: Validate download URL is from trusted domain
  182. if err := validateDownloadURL(downloadURL); err != nil {
  183. return fmt.Errorf("invalid download URL: %w", err)
  184. }
  185. if checksumURL != "" {
  186. if err := validateDownloadURL(checksumURL); err != nil {
  187. return fmt.Errorf("invalid checksum URL: %w", err)
  188. }
  189. }
  190. // Get current executable path
  191. exePath, err := os.Executable()
  192. if err != nil {
  193. return fmt.Errorf("failed to get executable path: %w", err)
  194. }
  195. exePath, err = filepath.EvalSymlinks(exePath)
  196. if err != nil {
  197. return fmt.Errorf("failed to resolve symlinks: %w", err)
  198. }
  199. exeDir := filepath.Dir(exePath)
  200. // Create temp directory in the SAME directory as executable
  201. // This ensures os.Rename is atomic (same filesystem)
  202. tempDir, err := os.MkdirTemp(exeDir, ".sub2api-update-*")
  203. if err != nil {
  204. return fmt.Errorf("failed to create temp dir: %w", err)
  205. }
  206. defer func() { _ = os.RemoveAll(tempDir) }()
  207. // Download archive
  208. archivePath := filepath.Join(tempDir, filepath.Base(downloadURL))
  209. if err := s.downloadFile(ctx, downloadURL, archivePath); err != nil {
  210. return fmt.Errorf("download failed: %w", err)
  211. }
  212. // Verify checksum if available
  213. if checksumURL != "" {
  214. if err := s.verifyChecksum(ctx, archivePath, checksumURL); err != nil {
  215. return fmt.Errorf("checksum verification failed: %w", err)
  216. }
  217. }
  218. // Extract binary from archive
  219. newBinaryPath := filepath.Join(tempDir, "sub2api")
  220. if err := s.extractBinary(archivePath, newBinaryPath); err != nil {
  221. return fmt.Errorf("extraction failed: %w", err)
  222. }
  223. // Set executable permission before replacement
  224. if err := os.Chmod(newBinaryPath, 0755); err != nil {
  225. return fmt.Errorf("chmod failed: %w", err)
  226. }
  227. // Atomic replacement using rename pattern:
  228. // 1. Rename current -> backup (atomic on Unix)
  229. // 2. Rename new -> current (atomic on Unix, same filesystem)
  230. // If step 2 fails, restore backup
  231. backupPath := exePath + ".backup"
  232. // Remove old backup if exists
  233. _ = os.Remove(backupPath)
  234. // Step 1: Move current binary to backup
  235. if err := os.Rename(exePath, backupPath); err != nil {
  236. return fmt.Errorf("backup failed: %w", err)
  237. }
  238. // Step 2: Move new binary to target location (atomic, same filesystem)
  239. if err := os.Rename(newBinaryPath, exePath); err != nil {
  240. // Restore backup on failure
  241. if restoreErr := os.Rename(backupPath, exePath); restoreErr != nil {
  242. return fmt.Errorf("replace failed and restore failed: %w (restore error: %v)", err, restoreErr)
  243. }
  244. return fmt.Errorf("replace failed (restored backup): %w", err)
  245. }
  246. // Success - backup file is kept for rollback capability
  247. // It will be cleaned up on next successful update
  248. return nil
  249. }
  250. // Rollback restores the previous version
  251. func (s *UpdateService) Rollback() error {
  252. if !s.inPlaceUpdatesEnabled {
  253. return ErrManagedUpdateRequired
  254. }
  255. exePath, err := os.Executable()
  256. if err != nil {
  257. return fmt.Errorf("failed to get executable path: %w", err)
  258. }
  259. exePath, err = filepath.EvalSymlinks(exePath)
  260. if err != nil {
  261. return fmt.Errorf("failed to resolve symlinks: %w", err)
  262. }
  263. backupFile := exePath + ".backup"
  264. if _, err := os.Stat(backupFile); os.IsNotExist(err) {
  265. return fmt.Errorf("no backup found")
  266. }
  267. // Replace current with backup
  268. if err := os.Rename(backupFile, exePath); err != nil {
  269. return fmt.Errorf("rollback failed: %w", err)
  270. }
  271. return nil
  272. }
  273. // ListRollbackVersions returns up to maxRollbackVersions release versions that are
  274. // strictly older than the current version (the current version itself is excluded),
  275. // newest first. Draft and prerelease entries are skipped.
  276. func (s *UpdateService) ListRollbackVersions(ctx context.Context) ([]RollbackVersion, error) {
  277. if !s.inPlaceUpdatesEnabled {
  278. return []RollbackVersion{}, nil
  279. }
  280. releases, err := s.fetchRollbackCandidates(ctx)
  281. if err != nil {
  282. return nil, err
  283. }
  284. versions := make([]RollbackVersion, 0, len(releases))
  285. for _, r := range releases {
  286. versions = append(versions, RollbackVersion{
  287. Version: strings.TrimPrefix(r.TagName, "v"),
  288. PublishedAt: r.PublishedAt,
  289. HTMLURL: r.HTMLURL,
  290. })
  291. }
  292. return versions, nil
  293. }
  294. // RollbackToVersion downloads and installs a specific older version.
  295. // The target must be one of the versions returned by ListRollbackVersions;
  296. // anything else (including the current version) is rejected.
  297. func (s *UpdateService) RollbackToVersion(ctx context.Context, version string) error {
  298. if !s.inPlaceUpdatesEnabled {
  299. return ErrManagedUpdateRequired
  300. }
  301. target := strings.TrimPrefix(strings.TrimSpace(version), "v")
  302. if target == "" {
  303. return ErrRollbackVersionNotAllowed
  304. }
  305. releases, err := s.fetchRollbackCandidates(ctx)
  306. if err != nil {
  307. return err
  308. }
  309. var match *GitHubRelease
  310. for _, r := range releases {
  311. if strings.TrimPrefix(r.TagName, "v") == target {
  312. match = r
  313. break
  314. }
  315. }
  316. if match == nil {
  317. return ErrRollbackVersionNotAllowed
  318. }
  319. assets := make([]Asset, len(match.Assets))
  320. for i, a := range match.Assets {
  321. assets[i] = Asset{
  322. Name: a.Name,
  323. DownloadURL: a.BrowserDownloadURL,
  324. Size: a.Size,
  325. }
  326. }
  327. return s.applyReleaseAssets(ctx, assets)
  328. }
  329. // fetchRollbackCandidates fetches recent releases and keeps the newest
  330. // maxRollbackVersions entries strictly older than the current version.
  331. func (s *UpdateService) fetchRollbackCandidates(ctx context.Context) ([]*GitHubRelease, error) {
  332. releases, err := s.githubClient.FetchRecentReleases(ctx, githubRepo, rollbackFetchPageSize)
  333. if err != nil {
  334. return nil, err
  335. }
  336. seen := make(map[string]bool, len(releases))
  337. candidates := make([]*GitHubRelease, 0, maxRollbackVersions)
  338. for _, r := range releases {
  339. if r == nil || r.Draft || r.Prerelease {
  340. continue
  341. }
  342. v := strings.TrimPrefix(r.TagName, "v")
  343. if v == "" || seen[v] {
  344. continue
  345. }
  346. // Only versions strictly older than current (also excludes current itself)
  347. if compareVersions(v, s.currentVersion) >= 0 {
  348. continue
  349. }
  350. seen[v] = true
  351. candidates = append(candidates, r)
  352. }
  353. sort.SliceStable(candidates, func(i, j int) bool {
  354. return compareVersions(
  355. strings.TrimPrefix(candidates[i].TagName, "v"),
  356. strings.TrimPrefix(candidates[j].TagName, "v"),
  357. ) > 0
  358. })
  359. if len(candidates) > maxRollbackVersions {
  360. candidates = candidates[:maxRollbackVersions]
  361. }
  362. return candidates, nil
  363. }
  364. func (s *UpdateService) fetchLatestRelease(ctx context.Context) (*UpdateInfo, error) {
  365. release, err := s.githubClient.FetchLatestRelease(ctx, githubRepo)
  366. if err != nil {
  367. return nil, err
  368. }
  369. latestVersion := strings.TrimPrefix(release.TagName, "v")
  370. assets := make([]Asset, len(release.Assets))
  371. for i, a := range release.Assets {
  372. assets[i] = Asset{
  373. Name: a.Name,
  374. DownloadURL: a.BrowserDownloadURL,
  375. Size: a.Size,
  376. }
  377. }
  378. return &UpdateInfo{
  379. CurrentVersion: s.currentVersion,
  380. LatestVersion: latestVersion,
  381. HasUpdate: compareVersions(s.currentVersion, latestVersion) < 0,
  382. ReleaseInfo: &ReleaseInfo{
  383. Name: release.Name,
  384. Body: release.Body,
  385. PublishedAt: release.PublishedAt,
  386. HTMLURL: release.HTMLURL,
  387. Assets: assets,
  388. },
  389. Cached: false,
  390. BuildType: s.buildType,
  391. }, nil
  392. }
  393. func (s *UpdateService) downloadFile(ctx context.Context, downloadURL, dest string) error {
  394. return s.githubClient.DownloadFile(ctx, downloadURL, dest, maxDownloadSize)
  395. }
  396. func (s *UpdateService) getArchiveName() string {
  397. osName := runtime.GOOS
  398. arch := runtime.GOARCH
  399. return fmt.Sprintf("%s_%s", osName, arch)
  400. }
  401. // validateDownloadURL checks if the URL is from an allowed domain
  402. // SECURITY: This prevents SSRF and ensures downloads only come from trusted GitHub domains
  403. func validateDownloadURL(rawURL string) error {
  404. parsedURL, err := url.Parse(rawURL)
  405. if err != nil {
  406. return fmt.Errorf("invalid URL: %w", err)
  407. }
  408. // Must be HTTPS
  409. if parsedURL.Scheme != "https" {
  410. return fmt.Errorf("only HTTPS URLs are allowed")
  411. }
  412. // Check against allowed hosts
  413. host := parsedURL.Host
  414. // GitHub release URLs can be from github.com or objects.githubusercontent.com
  415. if host != allowedDownloadHost &&
  416. !strings.HasSuffix(host, "."+allowedDownloadHost) &&
  417. host != allowedAssetHost &&
  418. !strings.HasSuffix(host, "."+allowedAssetHost) {
  419. return fmt.Errorf("download from untrusted host: %s", host)
  420. }
  421. return nil
  422. }
  423. func (s *UpdateService) verifyChecksum(ctx context.Context, filePath, checksumURL string) error {
  424. // Download checksums file
  425. checksumData, err := s.githubClient.FetchChecksumFile(ctx, checksumURL)
  426. if err != nil {
  427. return fmt.Errorf("failed to download checksums: %w", err)
  428. }
  429. // Calculate file hash
  430. f, err := os.Open(filePath)
  431. if err != nil {
  432. return err
  433. }
  434. defer func() { _ = f.Close() }()
  435. h := sha256.New()
  436. if _, err := io.Copy(h, f); err != nil {
  437. return err
  438. }
  439. actualHash := hex.EncodeToString(h.Sum(nil))
  440. // Find expected hash in checksums file
  441. fileName := filepath.Base(filePath)
  442. scanner := bufio.NewScanner(strings.NewReader(string(checksumData)))
  443. for scanner.Scan() {
  444. line := scanner.Text()
  445. parts := strings.Fields(line)
  446. if len(parts) == 2 && parts[1] == fileName {
  447. if parts[0] == actualHash {
  448. return nil
  449. }
  450. return fmt.Errorf("checksum mismatch: expected %s, got %s", parts[0], actualHash)
  451. }
  452. }
  453. return fmt.Errorf("checksum not found for %s", fileName)
  454. }
  455. func (s *UpdateService) extractBinary(archivePath, destPath string) error {
  456. f, err := os.Open(archivePath)
  457. if err != nil {
  458. return err
  459. }
  460. defer func() { _ = f.Close() }()
  461. var reader io.Reader = f
  462. // Handle gzip compression
  463. if strings.HasSuffix(archivePath, ".gz") || strings.HasSuffix(archivePath, ".tar.gz") || strings.HasSuffix(archivePath, ".tgz") {
  464. gzr, err := gzip.NewReader(f)
  465. if err != nil {
  466. return err
  467. }
  468. defer func() { _ = gzr.Close() }()
  469. reader = gzr
  470. }
  471. // Handle tar archive
  472. if strings.Contains(archivePath, ".tar") {
  473. tr := tar.NewReader(reader)
  474. for {
  475. hdr, err := tr.Next()
  476. if err == io.EOF {
  477. break
  478. }
  479. if err != nil {
  480. return err
  481. }
  482. // SECURITY: Prevent Zip Slip / Path Traversal attack
  483. // Only allow files with safe base names, no directory traversal
  484. baseName := filepath.Base(hdr.Name)
  485. // Check for path traversal attempts
  486. if strings.Contains(hdr.Name, "..") {
  487. return fmt.Errorf("path traversal attempt detected: %s", hdr.Name)
  488. }
  489. // Validate the entry is a regular file
  490. if hdr.Typeflag != tar.TypeReg {
  491. continue // Skip directories and special files
  492. }
  493. // Only extract the specific binary we need
  494. if baseName == "sub2api" || baseName == "sub2api.exe" {
  495. // Additional security: limit file size (max 500MB)
  496. const maxBinarySize = 500 * 1024 * 1024
  497. if hdr.Size > maxBinarySize {
  498. return fmt.Errorf("binary too large: %d bytes (max %d)", hdr.Size, maxBinarySize)
  499. }
  500. out, err := os.Create(destPath)
  501. if err != nil {
  502. return err
  503. }
  504. // Use LimitReader to prevent decompression bombs
  505. limited := io.LimitReader(tr, maxBinarySize)
  506. if _, err := io.Copy(out, limited); err != nil {
  507. _ = out.Close()
  508. return err
  509. }
  510. if err := out.Close(); err != nil {
  511. return err
  512. }
  513. return nil
  514. }
  515. }
  516. return fmt.Errorf("binary not found in archive")
  517. }
  518. // Direct copy for non-tar files (with size limit)
  519. const maxBinarySize = 500 * 1024 * 1024
  520. out, err := os.Create(destPath)
  521. if err != nil {
  522. return err
  523. }
  524. limited := io.LimitReader(reader, maxBinarySize)
  525. if _, err := io.Copy(out, limited); err != nil {
  526. _ = out.Close()
  527. return err
  528. }
  529. return out.Close()
  530. }
  531. func (s *UpdateService) getFromCache(ctx context.Context) (*UpdateInfo, error) {
  532. data, err := s.cache.GetUpdateInfo(ctx)
  533. if err != nil {
  534. return nil, err
  535. }
  536. var cached struct {
  537. Latest string `json:"latest"`
  538. ReleaseInfo *ReleaseInfo `json:"release_info"`
  539. Timestamp int64 `json:"timestamp"`
  540. }
  541. if err := json.Unmarshal([]byte(data), &cached); err != nil {
  542. return nil, err
  543. }
  544. if time.Now().Unix()-cached.Timestamp > updateCacheTTL {
  545. return nil, fmt.Errorf("cache expired")
  546. }
  547. return &UpdateInfo{
  548. CurrentVersion: s.currentVersion,
  549. LatestVersion: cached.Latest,
  550. HasUpdate: compareVersions(s.currentVersion, cached.Latest) < 0,
  551. ReleaseInfo: cached.ReleaseInfo,
  552. Cached: true,
  553. BuildType: s.buildType,
  554. }, nil
  555. }
  556. func (s *UpdateService) saveToCache(ctx context.Context, info *UpdateInfo) {
  557. cacheData := struct {
  558. Latest string `json:"latest"`
  559. ReleaseInfo *ReleaseInfo `json:"release_info"`
  560. Timestamp int64 `json:"timestamp"`
  561. }{
  562. Latest: info.LatestVersion,
  563. ReleaseInfo: info.ReleaseInfo,
  564. Timestamp: time.Now().Unix(),
  565. }
  566. data, _ := json.Marshal(cacheData)
  567. _ = s.cache.SetUpdateInfo(ctx, string(data), time.Duration(updateCacheTTL)*time.Second)
  568. }
  569. // compareVersions compares two semantic versions
  570. func compareVersions(current, latest string) int {
  571. currentParts := parseVersion(current)
  572. latestParts := parseVersion(latest)
  573. for i := 0; i < 3; i++ {
  574. if currentParts[i] < latestParts[i] {
  575. return -1
  576. }
  577. if currentParts[i] > latestParts[i] {
  578. return 1
  579. }
  580. }
  581. return 0
  582. }
  583. func parseVersion(v string) [3]int {
  584. v = strings.TrimPrefix(v, "v")
  585. if idx := strings.IndexByte(v, '-'); idx != -1 {
  586. v = v[:idx]
  587. }
  588. parts := strings.Split(v, ".")
  589. result := [3]int{0, 0, 0}
  590. for i := 0; i < len(parts) && i < 3; i++ {
  591. if parsed, err := strconv.Atoi(parts[i]); err == nil {
  592. result[i] = parsed
  593. }
  594. }
  595. return result
  596. }