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}