versioncheck.go

  1package server
  2
  3import (
  4	"bufio"
  5	"bytes"
  6	"context"
  7	"crypto/sha256"
  8	"encoding/hex"
  9	"encoding/json"
 10	"errors"
 11	"fmt"
 12	"io"
 13	"io/fs"
 14	"net/http"
 15	"os"
 16	"os/exec"
 17	"runtime"
 18	"sort"
 19	"strings"
 20	"sync"
 21	"time"
 22
 23	"github.com/fynelabs/selfupdate"
 24
 25	"shelley.exe.dev/version"
 26)
 27
 28// VersionChecker checks for new versions of Shelley from GitHub releases.
 29type VersionChecker struct {
 30	mu          sync.Mutex
 31	lastCheck   time.Time
 32	cachedInfo  *VersionInfo
 33	skipCheck   bool
 34	githubOwner string
 35	githubRepo  string
 36}
 37
 38// VersionInfo contains version check results.
 39type VersionInfo struct {
 40	CurrentVersion      string         `json:"current_version"`
 41	CurrentTag          string         `json:"current_tag,omitempty"`
 42	CurrentCommit       string         `json:"current_commit,omitempty"`
 43	CurrentCommitTime   string         `json:"current_commit_time,omitempty"`
 44	LatestVersion       string         `json:"latest_version,omitempty"`
 45	LatestTag           string         `json:"latest_tag,omitempty"`
 46	PublishedAt         time.Time      `json:"published_at,omitempty"`
 47	HasUpdate           bool           `json:"has_update"`    // True if minor version is newer (for showing upgrade button)
 48	ShouldNotify        bool           `json:"should_notify"` // True if should show red dot (newer + 5 days old)
 49	DownloadURL         string         `json:"download_url,omitempty"`
 50	ExecutablePath      string         `json:"executable_path,omitempty"`
 51	Commits             []CommitInfo   `json:"commits,omitempty"`
 52	CheckedAt           time.Time      `json:"checked_at"`
 53	Error               string         `json:"error,omitempty"`
 54	RunningUnderSystemd bool           `json:"running_under_systemd"` // True if INVOCATION_ID env var is set (systemd)
 55	ReleaseInfo         *GitHubRelease `json:"-"`                     // Internal, not exposed to JSON
 56}
 57
 58// CommitInfo represents a commit in the changelog.
 59type CommitInfo struct {
 60	SHA     string    `json:"sha"`
 61	Message string    `json:"message"`
 62	Author  string    `json:"author"`
 63	Date    time.Time `json:"date"`
 64}
 65
 66// GitHubRelease represents a GitHub release from the API.
 67type GitHubRelease struct {
 68	TagName     string    `json:"tag_name"`
 69	Name        string    `json:"name"`
 70	PublishedAt time.Time `json:"published_at"`
 71	Assets      []struct {
 72		Name               string `json:"name"`
 73		BrowserDownloadURL string `json:"browser_download_url"`
 74	} `json:"assets"`
 75}
 76
 77// GitHubCommit represents a commit from the GitHub API.
 78type GitHubCommit struct {
 79	SHA    string `json:"sha"`
 80	Commit struct {
 81		Message string `json:"message"`
 82		Author  struct {
 83			Name string    `json:"name"`
 84			Date time.Time `json:"date"`
 85		} `json:"author"`
 86	} `json:"commit"`
 87}
 88
 89// NewVersionChecker creates a new version checker.
 90func NewVersionChecker() *VersionChecker {
 91	skipCheck := os.Getenv("SHELLEY_SKIP_VERSION_CHECK") == "true"
 92	return &VersionChecker{
 93		skipCheck:   skipCheck,
 94		githubOwner: "boldsoftware",
 95		githubRepo:  "shelley",
 96	}
 97}
 98
 99// Check checks for a new version, using the cache if still valid.
100func (vc *VersionChecker) Check(ctx context.Context, forceRefresh bool) (*VersionInfo, error) {
101	if vc.skipCheck {
102		info := version.GetInfo()
103		return &VersionInfo{
104			CurrentVersion:      info.Version,
105			CurrentTag:          info.Tag,
106			CurrentCommit:       info.Commit,
107			HasUpdate:           false,
108			CheckedAt:           time.Now(),
109			RunningUnderSystemd: os.Getenv("INVOCATION_ID") != "",
110		}, nil
111	}
112
113	vc.mu.Lock()
114	defer vc.mu.Unlock()
115
116	// Return cached info if still valid (6 hours) and not forcing refresh
117	if !forceRefresh && vc.cachedInfo != nil && time.Since(vc.lastCheck) < 6*time.Hour {
118		return vc.cachedInfo, nil
119	}
120
121	info, err := vc.fetchVersionInfo(ctx)
122	if err != nil {
123		// On error, return current version info with error
124		currentInfo := version.GetInfo()
125		return &VersionInfo{
126			CurrentVersion:      currentInfo.Version,
127			CurrentTag:          currentInfo.Tag,
128			CurrentCommit:       currentInfo.Commit,
129			HasUpdate:           false,
130			CheckedAt:           time.Now(),
131			Error:               err.Error(),
132			RunningUnderSystemd: os.Getenv("INVOCATION_ID") != "",
133		}, nil
134	}
135
136	vc.cachedInfo = info
137	vc.lastCheck = time.Now()
138	return info, nil
139}
140
141// fetchVersionInfo fetches the latest release info from GitHub.
142func (vc *VersionChecker) fetchVersionInfo(ctx context.Context) (*VersionInfo, error) {
143	currentInfo := version.GetInfo()
144	execPath, _ := os.Executable()
145	info := &VersionInfo{
146		CurrentVersion:      currentInfo.Version,
147		CurrentTag:          currentInfo.Tag,
148		CurrentCommit:       currentInfo.Commit,
149		CurrentCommitTime:   currentInfo.CommitTime,
150		ExecutablePath:      execPath,
151		CheckedAt:           time.Now(),
152		RunningUnderSystemd: os.Getenv("INVOCATION_ID") != "",
153	}
154
155	// Fetch latest release
156	latestRelease, err := vc.fetchLatestRelease(ctx)
157	if err != nil {
158		return nil, fmt.Errorf("failed to fetch latest release: %w", err)
159	}
160
161	info.LatestTag = latestRelease.TagName
162	info.LatestVersion = latestRelease.TagName
163	info.PublishedAt = latestRelease.PublishedAt
164	info.ReleaseInfo = latestRelease
165
166	// Find the download URL for the current platform
167	info.DownloadURL = vc.findDownloadURL(latestRelease)
168
169	// Check if latest has a newer minor version
170	info.HasUpdate = vc.isNewerMinor(currentInfo.Tag, latestRelease.TagName)
171
172	// For ShouldNotify, we need to check if the versions are 5+ days apart
173	// Fetch the current version's release to compare dates
174	if info.HasUpdate && currentInfo.Tag != "" {
175		currentRelease, err := vc.fetchRelease(ctx, currentInfo.Tag)
176		if err == nil && currentRelease != nil {
177			// Show notification if the latest release is 5+ days newer than current
178			timeBetween := latestRelease.PublishedAt.Sub(currentRelease.PublishedAt)
179			info.ShouldNotify = timeBetween >= 5*24*time.Hour
180		} else {
181			// Can't fetch current release info, just notify if there's an update
182			info.ShouldNotify = true
183		}
184	}
185
186	return info, nil
187}
188
189// FetchChangelog fetches the commits between current and latest versions.
190func (vc *VersionChecker) FetchChangelog(ctx context.Context, currentTag, latestTag string) ([]CommitInfo, error) {
191	if currentTag == "" || latestTag == "" {
192		return nil, nil
193	}
194
195	url := fmt.Sprintf("https://api.github.com/repos/%s/%s/compare/%s...%s",
196		vc.githubOwner, vc.githubRepo, currentTag, latestTag)
197
198	req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
199	if err != nil {
200		return nil, err
201	}
202	req.Header.Set("Accept", "application/vnd.github.v3+json")
203	req.Header.Set("User-Agent", "Shelley-VersionChecker")
204
205	resp, err := http.DefaultClient.Do(req)
206	if err != nil {
207		return nil, err
208	}
209	defer resp.Body.Close()
210
211	if resp.StatusCode != http.StatusOK {
212		return nil, fmt.Errorf("GitHub API returned status %d", resp.StatusCode)
213	}
214
215	var compareResp struct {
216		Commits []GitHubCommit `json:"commits"`
217	}
218	if err := json.NewDecoder(resp.Body).Decode(&compareResp); err != nil {
219		return nil, err
220	}
221
222	var commits []CommitInfo
223	for _, c := range compareResp.Commits {
224		// Get first line of commit message
225		message := c.Commit.Message
226		if idx := indexOf(message, '\n'); idx != -1 {
227			message = message[:idx]
228		}
229		commits = append(commits, CommitInfo{
230			SHA:     c.SHA[:7],
231			Message: message,
232			Author:  c.Commit.Author.Name,
233			Date:    c.Commit.Author.Date,
234		})
235	}
236
237	// Sort commits by date, newest first
238	sort.Slice(commits, func(i, j int) bool {
239		return commits[i].Date.After(commits[j].Date)
240	})
241
242	return commits, nil
243}
244
245func indexOf(s string, c byte) int {
246	for i := 0; i < len(s); i++ {
247		if s[i] == c {
248			return i
249		}
250	}
251	return -1
252}
253
254// fetchRelease fetches a specific release by tag from GitHub.
255func (vc *VersionChecker) fetchRelease(ctx context.Context, tag string) (*GitHubRelease, error) {
256	url := fmt.Sprintf("https://api.github.com/repos/%s/%s/releases/tags/%s",
257		vc.githubOwner, vc.githubRepo, tag)
258
259	req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
260	if err != nil {
261		return nil, err
262	}
263	req.Header.Set("Accept", "application/vnd.github.v3+json")
264	req.Header.Set("User-Agent", "Shelley-VersionChecker")
265
266	resp, err := http.DefaultClient.Do(req)
267	if err != nil {
268		return nil, err
269	}
270	defer resp.Body.Close()
271
272	if resp.StatusCode != http.StatusOK {
273		return nil, fmt.Errorf("GitHub API returned status %d", resp.StatusCode)
274	}
275
276	var release GitHubRelease
277	if err := json.NewDecoder(resp.Body).Decode(&release); err != nil {
278		return nil, err
279	}
280
281	return &release, nil
282}
283
284// fetchLatestRelease fetches the latest release from GitHub.
285func (vc *VersionChecker) fetchLatestRelease(ctx context.Context) (*GitHubRelease, error) {
286	url := fmt.Sprintf("https://api.github.com/repos/%s/%s/releases/latest",
287		vc.githubOwner, vc.githubRepo)
288
289	req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
290	if err != nil {
291		return nil, err
292	}
293	req.Header.Set("Accept", "application/vnd.github.v3+json")
294	req.Header.Set("User-Agent", "Shelley-VersionChecker")
295
296	resp, err := http.DefaultClient.Do(req)
297	if err != nil {
298		return nil, err
299	}
300	defer resp.Body.Close()
301
302	if resp.StatusCode != http.StatusOK {
303		return nil, fmt.Errorf("GitHub API returned status %d", resp.StatusCode)
304	}
305
306	var release GitHubRelease
307	if err := json.NewDecoder(resp.Body).Decode(&release); err != nil {
308		return nil, err
309	}
310
311	return &release, nil
312}
313
314// findDownloadURL finds the appropriate download URL for the current platform.
315func (vc *VersionChecker) findDownloadURL(release *GitHubRelease) string {
316	// Build expected asset name: shelley_<os>_<arch>
317	expectedName := fmt.Sprintf("shelley_%s_%s", runtime.GOOS, runtime.GOARCH)
318
319	for _, asset := range release.Assets {
320		if asset.Name == expectedName {
321			return asset.BrowserDownloadURL
322		}
323	}
324
325	return ""
326}
327
328// isNewerMinor checks if latest has a higher minor version than current.
329func (vc *VersionChecker) isNewerMinor(currentTag, latestTag string) bool {
330	currentMinor := parseMinorVersion(currentTag)
331	latestMinor := parseMinorVersion(latestTag)
332	return latestMinor > currentMinor
333}
334
335// parseMinorVersion extracts the X from v0.X.Y format.
336func parseMinorVersion(tag string) int {
337	if len(tag) < 2 || tag[0] != 'v' {
338		return 0
339	}
340
341	// Skip 'v'
342	s := tag[1:]
343
344	// Find first dot
345	firstDot := -1
346	for i := 0; i < len(s); i++ {
347		if s[i] == '.' {
348			firstDot = i
349			break
350		}
351	}
352	if firstDot == -1 {
353		return 0
354	}
355
356	// Skip major version and dot
357	s = s[firstDot+1:]
358
359	// Parse minor version
360	var minor int
361	for i := 0; i < len(s); i++ {
362		if s[i] >= '0' && s[i] <= '9' {
363			minor = minor*10 + int(s[i]-'0')
364		} else {
365			break
366		}
367	}
368
369	return minor
370}
371
372// DoUpgrade downloads and applies the update with checksum verification.
373func (vc *VersionChecker) DoUpgrade(ctx context.Context) error {
374	if vc.skipCheck {
375		return fmt.Errorf("version checking is disabled")
376	}
377
378	// Get cached info or fetch fresh
379	info, err := vc.Check(ctx, false)
380	if err != nil {
381		return fmt.Errorf("failed to check version: %w", err)
382	}
383
384	if !info.HasUpdate {
385		return fmt.Errorf("no update available")
386	}
387
388	if info.DownloadURL == "" {
389		return fmt.Errorf("no download URL for %s/%s", runtime.GOOS, runtime.GOARCH)
390	}
391
392	if info.ReleaseInfo == nil {
393		return fmt.Errorf("no release info available")
394	}
395
396	// Find and download checksums.txt
397	expectedChecksum, err := vc.fetchExpectedChecksum(ctx, info.ReleaseInfo)
398	if err != nil {
399		return fmt.Errorf("failed to fetch checksum: %w", err)
400	}
401
402	// Download the binary
403	resp, err := http.Get(info.DownloadURL)
404	if err != nil {
405		return fmt.Errorf("failed to download update: %w", err)
406	}
407	defer resp.Body.Close()
408
409	if resp.StatusCode != http.StatusOK {
410		return fmt.Errorf("download returned status %d", resp.StatusCode)
411	}
412
413	// Read the entire binary to verify checksum before applying
414	binaryData, err := io.ReadAll(resp.Body)
415	if err != nil {
416		return fmt.Errorf("failed to read update: %w", err)
417	}
418
419	// Verify checksum
420	actualChecksum := sha256.Sum256(binaryData)
421	actualChecksumHex := hex.EncodeToString(actualChecksum[:])
422
423	if actualChecksumHex != expectedChecksum {
424		return fmt.Errorf("checksum mismatch: expected %s, got %s", expectedChecksum, actualChecksumHex)
425	}
426
427	// Apply the update
428	err = selfupdate.Apply(bytes.NewReader(binaryData), selfupdate.Options{})
429	if err == nil {
430		return nil
431	}
432
433	// Check if the error is permission-related and sudo is available
434	if !isPermissionError(err) {
435		return fmt.Errorf("failed to apply update: %w", err)
436	}
437
438	if !isSudoAvailable() {
439		return fmt.Errorf("failed to apply update (no write permission and sudo not available): %w", err)
440	}
441
442	// Fall back to sudo-based upgrade
443	return vc.doSudoUpgrade(binaryData)
444}
445
446// isPermissionError checks if the error is related to file permissions.
447func isPermissionError(err error) bool {
448	return errors.Is(err, fs.ErrPermission) || errors.Is(err, os.ErrPermission)
449}
450
451// doSudoUpgrade performs the upgrade using sudo when the binary isn't writable.
452func (vc *VersionChecker) doSudoUpgrade(binaryData []byte) error {
453	// Get the path to the current executable
454	exePath, err := os.Executable()
455	if err != nil {
456		return fmt.Errorf("failed to get executable path: %w", err)
457	}
458
459	// Write the new binary to a temp file
460	tmpFile, err := os.CreateTemp("", "shelley-upgrade-*")
461	if err != nil {
462		return fmt.Errorf("failed to create temp file: %w", err)
463	}
464	tmpPath := tmpFile.Name()
465	defer os.Remove(tmpPath)
466
467	if _, err := tmpFile.Write(binaryData); err != nil {
468		tmpFile.Close()
469		return fmt.Errorf("failed to write temp file: %w", err)
470	}
471	if err := tmpFile.Close(); err != nil {
472		return fmt.Errorf("failed to close temp file: %w", err)
473	}
474
475	// Make the temp file executable
476	if err := os.Chmod(tmpPath, 0o755); err != nil {
477		return fmt.Errorf("failed to chmod temp file: %w", err)
478	}
479
480	// Use sudo to install the new binary. We can't cp over a running binary ("Text file busy"),
481	// so we cp to a .new file and then mv (which is atomic and works on running binaries).
482	newPath := exePath + ".new"
483	oldPath := exePath + ".old"
484
485	// Copy new binary to .new location
486	cmd := exec.Command("sudo", "cp", tmpPath, newPath)
487	if output, err := cmd.CombinedOutput(); err != nil {
488		return fmt.Errorf("failed to copy new binary: %w: %s", err, output)
489	}
490
491	// Copy ownership and permissions from original
492	cmd = exec.Command("sudo", "chown", "--reference="+exePath, newPath)
493	if output, err := cmd.CombinedOutput(); err != nil {
494		exec.Command("sudo", "rm", "-f", newPath).Run()
495		return fmt.Errorf("failed to set ownership: %w: %s", err, output)
496	}
497
498	cmd = exec.Command("sudo", "chmod", "--reference="+exePath, newPath)
499	if output, err := cmd.CombinedOutput(); err != nil {
500		exec.Command("sudo", "rm", "-f", newPath).Run()
501		return fmt.Errorf("failed to set permissions: %w: %s", err, output)
502	}
503
504	// Rename old binary to .old (backup)
505	cmd = exec.Command("sudo", "mv", exePath, oldPath)
506	if output, err := cmd.CombinedOutput(); err != nil {
507		exec.Command("sudo", "rm", "-f", newPath).Run()
508		return fmt.Errorf("failed to backup old binary: %w: %s", err, output)
509	}
510
511	// Rename .new to target (atomic replacement)
512	cmd = exec.Command("sudo", "mv", newPath, exePath)
513	if output, err := cmd.CombinedOutput(); err != nil {
514		// Try to restore the old binary
515		exec.Command("sudo", "mv", oldPath, exePath).Run()
516		return fmt.Errorf("failed to install new binary: %w: %s", err, output)
517	}
518
519	// Remove the backup
520	cmd = exec.Command("sudo", "rm", "-f", oldPath)
521	cmd.Run() // Best effort, ignore errors
522
523	return nil
524}
525
526// fetchExpectedChecksum downloads checksums.txt and extracts the expected checksum for our binary.
527func (vc *VersionChecker) fetchExpectedChecksum(ctx context.Context, release *GitHubRelease) (string, error) {
528	// Find checksums.txt URL
529	var checksumURL string
530	for _, asset := range release.Assets {
531		if asset.Name == "checksums.txt" {
532			checksumURL = asset.BrowserDownloadURL
533			break
534		}
535	}
536	if checksumURL == "" {
537		return "", fmt.Errorf("checksums.txt not found in release")
538	}
539
540	// Download checksums.txt
541	req, err := http.NewRequestWithContext(ctx, "GET", checksumURL, nil)
542	if err != nil {
543		return "", err
544	}
545
546	resp, err := http.DefaultClient.Do(req)
547	if err != nil {
548		return "", err
549	}
550	defer resp.Body.Close()
551
552	if resp.StatusCode != http.StatusOK {
553		return "", fmt.Errorf("failed to download checksums: status %d", resp.StatusCode)
554	}
555
556	// Parse checksums.txt (format: "checksum  filename")
557	expectedBinaryName := fmt.Sprintf("shelley_%s_%s", runtime.GOOS, runtime.GOARCH)
558
559	scanner := bufio.NewScanner(resp.Body)
560	for scanner.Scan() {
561		line := scanner.Text()
562		parts := strings.Fields(line)
563		if len(parts) >= 2 {
564			checksum := parts[0]
565			filename := parts[1]
566			if filename == expectedBinaryName {
567				return checksum, nil
568			}
569		}
570	}
571
572	if err := scanner.Err(); err != nil {
573		return "", fmt.Errorf("error reading checksums: %w", err)
574	}
575
576	return "", fmt.Errorf("checksum for %s not found in checksums.txt", expectedBinaryName)
577}