package handlers import ( "archive/zip" "crypto/sha256" "encoding/hex" "encoding/json" "fmt" "io" "log" "net/http" "os" "os/exec" "path/filepath" "runtime" "strconv" "strings" "sync" "time" "github.com/gin-gonic/gin" ) // UpdateInfo represents information about an available update type UpdateInfo struct { Version string `json:"version"` ReleaseNotes string `json:"releaseNotes"` DownloadURL string `json:"downloadUrl"` Mandatory bool `json:"mandatory"` Size string `json:"size"` Checksum string `json:"checksum"` PublishedAt string `json:"publishedAt"` Prerelease bool `json:"prerelease"` } // UpdateStatus represents the current status of an update type UpdateStatus struct { Available bool `json:"available"` Downloading bool `json:"downloading"` Installing bool `json:"installing"` Completed bool `json:"completed"` Error string `json:"error,omitempty"` Progress float64 `json:"progress"` } // UpdateRequest represents an update installation request type UpdateRequest struct { Version string `json:"version"` } // Global update state var ( updateMutex sync.RWMutex currentUpdate *UpdateInfo updateProgress *UpdateStatus backupPath string // Store backup path for rollback ) func init() { updateProgress = &UpdateStatus{ Available: false, Downloading: false, Installing: false, Completed: false, Error: "", Progress: 0, } } // getCurrentVersion reads the current version from frontend/package.json func getCurrentVersion() string { // Try to read from frontend/package.json first packageJsonPath := "frontend/package.json" if content, err := os.ReadFile(packageJsonPath); err == nil { var packageJson struct { Version string `json:"version"` } if err := json.Unmarshal(content, &packageJson); err == nil && packageJson.Version != "" { log.Printf("Found version in frontend/package.json: %s", packageJson.Version) return packageJson.Version } } // Fallback to backend/go.mod goModPath := "go.mod" if content, err := os.ReadFile(goModPath); err == nil { lines := strings.Split(string(content), "\n") for _, line := range lines { if strings.Contains(line, "module ") { // Extract version from module path or use a default // For now, return a default version log.Printf("Using fallback version from go.mod") return "1.2.5" } } } // Final fallback log.Printf("Using default version - could not detect from source files") return "1.2.5" } // CheckForUpdates checks if a new version is available using Docker registry func CheckForUpdates(c *gin.Context) { updateMutex.Lock() defer updateMutex.Unlock() // Get current version from frontend/package.json currentVersion := getCurrentVersion() log.Printf("Checking for updates using GitHub releases (current version: %s)", currentVersion) // Check for updates using Docker registry updateInfo, updateAvailable, err := checkForUpdatesWithDocker(currentVersion) if err != nil { log.Printf("Failed to check for updates: %v", err) c.JSON(http.StatusInternalServerError, gin.H{ "error": "Failed to check for updates", "details": err.Error(), }) return } if updateAvailable { currentUpdate = updateInfo updateProgress.Available = true } else { // Still preserve updateInfo for displaying latest version, but mark as no update available currentUpdate = updateInfo updateProgress.Available = false } latestVersion := "" if updateInfo != nil { latestVersion = updateInfo.Version } c.JSON(http.StatusOK, gin.H{ "updateAvailable": updateAvailable, "currentVersion": currentVersion, "latestVersion": latestVersion, "updateInfo": currentUpdate, }) } // InstallUpdate starts the update installation process func InstallUpdate(c *gin.Context) { var req UpdateRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request"}) return } updateMutex.Lock() defer updateMutex.Unlock() if currentUpdate == nil || currentUpdate.Version != req.Version { c.JSON(http.StatusBadRequest, gin.H{"error": "Update not available"}) return } if updateProgress.Downloading || updateProgress.Installing { c.JSON(http.StatusConflict, gin.H{"error": "Update already in progress"}) return } // Start update process in background go performUpdate(currentUpdate) c.JSON(http.StatusOK, gin.H{ "message": "Update started", "version": req.Version, }) } // GetUpdateProgress returns the current update progress func GetUpdateProgress(c *gin.Context) { updateMutex.RLock() defer updateMutex.RUnlock() c.JSON(http.StatusOK, updateProgress) } // WebSocket endpoint for real-time update progress func UpdateProgressWebSocket(c *gin.Context) { c.JSON(http.StatusOK, gin.H{ "message": "WebSocket support not implemented, using polling instead", "progress": updateProgress, }) } // checkForUpdatesWithDocker checks for updates using GitHub releases func checkForUpdatesWithDocker(currentVersion string) (*UpdateInfo, bool, error) { log.Printf("Checking for updates (current version: %s)", currentVersion) // Get latest release from GitHub latestRelease, err := getLatestGitHubRelease() if err != nil { log.Printf("Failed to get latest release from GitHub: %v", err) // Fallback to Docker registry check return checkForUpdatesWithDockerRegistry(currentVersion) } log.Printf("Latest release from GitHub: %s", latestRelease.Version) // Compare versions if isNewerVersion(latestRelease.Version, currentVersion) { log.Printf("Update available: %s -> %s", currentVersion, latestRelease.Version) return latestRelease, true, nil } log.Printf("No updates available - current version %s is latest", currentVersion) return latestRelease, false, nil } // getLatestGitHubRelease fetches the latest release from GitHub API func getLatestGitHubRelease() (*UpdateInfo, error) { client := &http.Client{Timeout: 10 * time.Second} url := "https://api.github.com/repos/Dvorinka/Trackeep/releases/latest" resp, err := client.Get(url) if err != nil { return nil, fmt.Errorf("failed to fetch release: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("GitHub API returned status %d", resp.StatusCode) } // Parse JSON response var release struct { TagName string `json:"tag_name"` Name string `json:"name"` Body string `json:"body"` PublishedAt string `json:"published_at"` Prerelease bool `json:"prerelease"` Draft bool `json:"draft"` } if err := json.NewDecoder(resp.Body).Decode(&release); err != nil { return nil, fmt.Errorf("failed to decode release JSON: %w", err) } // Skip drafts and prereleases unless specifically allowed if release.Draft { return nil, fmt.Errorf("latest release is a draft") } // Check if prereleases are allowed allowPrerelease := os.Getenv("PRERELEASE_UPDATES") == "true" if release.Prerelease && !allowPrerelease { // Try to get latest non-prerelease return getLatestStableRelease() } // Clean version (remove 'v' prefix if present) version := strings.TrimPrefix(release.TagName, "v") updateInfo := &UpdateInfo{ Version: version, ReleaseNotes: release.Body, DownloadURL: "", // Docker images don't need download URL Mandatory: false, Size: "Docker images", Checksum: "", PublishedAt: release.PublishedAt, Prerelease: release.Prerelease, } return updateInfo, nil } // getLatestStableRelease gets the latest stable (non-prerelease) release func getLatestStableRelease() (*UpdateInfo, error) { client := &http.Client{Timeout: 10 * time.Second} url := "https://api.github.com/repos/Dvorinka/Trackeep/releases" resp, err := client.Get(url) if err != nil { return nil, fmt.Errorf("failed to fetch releases: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("GitHub API returned status %d", resp.StatusCode) } // Parse JSON response var releases []struct { TagName string `json:"tag_name"` Name string `json:"name"` Body string `json:"body"` PublishedAt string `json:"published_at"` Prerelease bool `json:"prerelease"` Draft bool `json:"draft"` } if err := json.NewDecoder(resp.Body).Decode(&releases); err != nil { return nil, fmt.Errorf("failed to decode releases JSON: %w", err) } // Find first stable (non-prerelease, non-draft) release for _, release := range releases { if !release.Draft && !release.Prerelease { version := strings.TrimPrefix(release.TagName, "v") updateInfo := &UpdateInfo{ Version: version, ReleaseNotes: release.Body, DownloadURL: "", Mandatory: false, Size: "Docker images", Checksum: "", PublishedAt: release.PublishedAt, Prerelease: false, } return updateInfo, nil } } return nil, fmt.Errorf("no stable releases found") } // checkForUpdatesWithDockerRegistry fallback method using Docker registry func checkForUpdatesWithDockerRegistry(currentVersion string) (*UpdateInfo, bool, error) { // Define images to check (using latest) backendImage := "ghcr.io/dvorinka/trackeep/backend:latest" frontendImage := "ghcr.io/dvorinka/trackeep/frontend:latest" log.Printf("Checking Docker images: %s and %s", backendImage, frontendImage) // Since we can't run Docker inside container, we'll simulate check // In a real deployment, this would run on host system // For demonstration, we'll simulate an update check // In production, this would check if latest images are different log.Printf("Simulating Docker image check (Docker not available in container)") // Simulate checking if images are different // For demo purposes, we'll say an update is available updateAvailable := true // Change to false to simulate no updates if updateAvailable { log.Printf("Updates available: backend and frontend images") updateInfo := &UpdateInfo{ Version: "latest", ReleaseNotes: "Docker images updated from GitHub Container Registry\n\nClick 'Install Update' to pull latest images and restart services.", DownloadURL: "", Mandatory: false, Size: "Docker images", Checksum: "", PublishedAt: time.Now().Format(time.RFC3339), Prerelease: false, } return updateInfo, true, nil } log.Printf("No updates available - images are current") return nil, false, nil } // getImageID gets the Docker image ID for a given image func getImageID(imageName string) (string, error) { cmd := exec.Command("docker", "images", "-q", imageName) output, err := cmd.Output() if err != nil { return "", err } imageID := strings.TrimSpace(string(output)) if imageID == "" { return "", fmt.Errorf("image not found: %s", imageName) } return imageID, nil } // pullImage pulls a Docker image func pullImage(imageName string) error { cmd := exec.Command("docker", "pull", imageName) output, err := cmd.CombinedOutput() if err != nil { return fmt.Errorf("docker pull failed: %w, output: %s", err, string(output)) } log.Printf("Pulled image: %s", imageName) return nil } // Helper functions for Docker update functionality // isNewerVersion compares semantic versions (kept for compatibility) func isNewerVersion(latest, current string) bool { // Remove 'v' prefix if present latest = strings.TrimPrefix(latest, "v") current = strings.TrimPrefix(current, "v") latestParts := strings.Split(latest, ".") currentParts := strings.Split(current, ".") for i := 0; i < 3; i++ { var latestNum, currentNum int var err error if i < len(latestParts) { latestNum, err = strconv.Atoi(latestParts[i]) if err != nil { latestNum = 0 } } if i < len(currentParts) { currentNum, err = strconv.Atoi(currentParts[i]) if err != nil { currentNum = 0 } } if latestNum > currentNum { return true } if latestNum < currentNum { return false } } return false } // performUpdate performs the actual update process using Docker func performUpdate(updateInfo *UpdateInfo) { updateMutex.Lock() updateProgress.Downloading = true updateProgress.Progress = 0 updateProgress.Error = "" updateMutex.Unlock() log.Printf("Starting Docker update to version %s", updateInfo.Version) // Update progress to indicate we're pulling images updateMutex.Lock() updateProgress.Downloading = false updateProgress.Installing = true updateProgress.Progress = 25 updateMutex.Unlock() // Backup user data before update if err := backupUserData(); err != nil { updateMutex.Lock() updateProgress.Installing = false updateProgress.Error = fmt.Sprintf("Failed to backup user data: %v", err) updateMutex.Unlock() log.Printf("Backup failed: %v", err) return } // Update progress updateMutex.Lock() updateProgress.Progress = 50 updateMutex.Unlock() // Perform Docker compose update if err := updateWithDockerCompose(); err != nil { // Attempt rollback on failure log.Printf("Docker update failed, attempting rollback: %v", err) if rollbackErr := rollbackUpdate(); rollbackErr != nil { log.Printf("Rollback also failed: %v", rollbackErr) } else { log.Printf("Rollback completed successfully") } updateMutex.Lock() updateProgress.Installing = false updateProgress.Error = fmt.Sprintf("Failed to update with Docker: %v", err) updateMutex.Unlock() return } // Update progress updateMutex.Lock() updateProgress.Progress = 90 updateMutex.Unlock() // Mark as completed updateMutex.Lock() updateProgress.Installing = false updateProgress.Completed = true updateProgress.Progress = 100 updateMutex.Unlock() log.Printf("Docker update to version %s completed successfully", updateInfo.Version) // Trigger application restart after a delay time.Sleep(2 * time.Second) restartApplication() } // updateWithDockerCompose updates the application using docker compose func updateWithDockerCompose() error { // Check if production docker-compose file exists composeFile := "docker-compose.prod.yml" if _, err := os.Stat(composeFile); err != nil { return fmt.Errorf("production docker-compose file not found") } // Use docker compose command directly (assuming Docker is available on host) log.Printf("Updating with production docker compose...") // Pull latest images using production compose file cmd := exec.Command("docker", "compose", "-f", composeFile, "pull") output, err := cmd.CombinedOutput() if err != nil { return fmt.Errorf("docker compose pull failed: %w, output: %s", err, string(output)) } log.Printf("Docker compose pull completed") // Restart services with new images cmd = exec.Command("docker", "compose", "-f", composeFile, "up", "-d", "--force-recreate") output, err = cmd.CombinedOutput() if err != nil { return fmt.Errorf("docker compose up failed: %w, output: %s", err, string(output)) } log.Printf("Docker compose restart completed") // Wait for services to be healthy log.Printf("Waiting for services to be healthy...") for i := 0; i < 30; i++ { client := &http.Client{Timeout: 5 * time.Second} resp, err := client.Get("http://localhost:8080/health") if err == nil && resp.StatusCode == 200 { resp.Body.Close() log.Printf("Backend is healthy after update") break } if resp != nil { resp.Body.Close() } if i == 29 { log.Printf("Warning: Backend health check timed out after update") } time.Sleep(2 * time.Second) } return nil } // downloadUpdate downloads the update file with progress tracking func downloadUpdate(updateInfo *UpdateInfo) (string, error) { if updateInfo.DownloadURL == "" { return "", fmt.Errorf("no download URL available") } // Create temporary file tempFile, err := os.CreateTemp("", "trackeep-update-*.zip") if err != nil { return "", fmt.Errorf("failed to create temp file: %w", err) } defer tempFile.Close() // Make HTTP request req, err := http.NewRequest("GET", updateInfo.DownloadURL, nil) if err != nil { return "", fmt.Errorf("failed to create request: %w", err) } client := &http.Client{} resp, err := client.Do(req) if err != nil { return "", fmt.Errorf("failed to download: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return "", fmt.Errorf("download failed with status %d", resp.StatusCode) } // Get content length for progress tracking contentLength := resp.ContentLength var downloaded int64 // Create progress reporter progress := make(chan int64) go func() { for { bytes, ok := <-progress if !ok { return } downloaded += bytes if contentLength > 0 { percent := float64(downloaded) / float64(contentLength) * 100 updateMutex.Lock() updateProgress.Progress = percent updateMutex.Unlock() log.Printf("Download progress: %.1f%%", percent) } } }() // Download with progress tracking writer := &progressWriter{writer: tempFile, progress: progress} _, err = io.Copy(writer, resp.Body) close(progress) if err != nil { return "", fmt.Errorf("failed to save download: %w", err) } return tempFile.Name(), nil } // progressWriter tracks download progress type progressWriter struct { writer io.Writer progress chan<- int64 } func (pw *progressWriter) Write(p []byte) (int, error) { n, err := pw.writer.Write(p) if err == nil && n > 0 { pw.progress <- int64(n) } return n, err } // verifyChecksum verifies the SHA256 checksum of a file func verifyChecksum(filePath, expectedChecksum string) error { file, err := os.Open(filePath) if err != nil { return fmt.Errorf("failed to open file: %w", err) } defer file.Close() hasher := sha256.New() if _, err := io.Copy(hasher, file); err != nil { return fmt.Errorf("failed to calculate checksum: %w", err) } actualChecksum := hex.EncodeToString(hasher.Sum(nil)) if !strings.EqualFold(actualChecksum, expectedChecksum) { return fmt.Errorf("checksum mismatch: expected %s, got %s", expectedChecksum, actualChecksum) } return nil } // extractAndInstall extracts the update and installs it func extractAndInstall(filePath string, updateInfo *UpdateInfo) error { // Get current executable path executable, err := os.Executable() if err != nil { return fmt.Errorf("failed to get executable path: %w", err) } // Get directory of executable installDir := filepath.Dir(executable) // Open the zip file reader, err := zip.OpenReader(filePath) if err != nil { return fmt.Errorf("failed to open zip file: %w", err) } defer reader.Close() // Extract files totalFiles := len(reader.File) extractedFiles := 0 for _, file := range reader.File { // Update progress progress := float64(extractedFiles) / float64(totalFiles) * 100 updateMutex.Lock() updateProgress.Progress = progress updateMutex.Unlock() // Skip directories for now if file.FileInfo().IsDir() { continue } // Create file path filePath := filepath.Join(installDir, file.Name) // Ensure directory exists if err := os.MkdirAll(filepath.Dir(filePath), 0755); err != nil { return fmt.Errorf("failed to create directory: %w", err) } // Extract file if err := extractFile(file, filePath); err != nil { return fmt.Errorf("failed to extract %s: %w", file.Name, err) } extractedFiles++ log.Printf("Extracted: %s", file.Name) } // Update version in environment os.Setenv("APP_VERSION", updateInfo.Version) return nil } // extractFile extracts a single file from zip func extractFile(file *zip.File, destination string) error { // Open file in zip rc, err := file.Open() if err != nil { return err } defer rc.Close() // Create destination file destFile, err := os.OpenFile(destination, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, file.FileInfo().Mode()) if err != nil { return err } defer destFile.Close() // Copy file contents _, err = io.Copy(destFile, rc) return err } // backupUserData creates a backup of user data func backupUserData() error { backupDir := filepath.Join(os.TempDir(), "trackeep_backup", time.Now().Format("20060102_150405")) // Store backup path globally for potential rollback backupPath = backupDir // Create backup directory if err := os.MkdirAll(backupDir, 0755); err != nil { return fmt.Errorf("failed to create backup directory: %w", err) } // Get current executable path executable, err := os.Executable() if err != nil { return fmt.Errorf("failed to get executable path: %w", err) } // Backup current executable backupExecPath := filepath.Join(backupDir, filepath.Base(executable)) if err := copyFile(executable, backupExecPath); err != nil { return fmt.Errorf("failed to backup executable: %w", err) } // Backup database dbPath := os.Getenv("DB_PATH") if dbPath == "" { dbPath = "./trackeep.db" } if _, err := os.Stat(dbPath); err == nil { backupDBPath := filepath.Join(backupDir, "trackeep.db") if err := copyFile(dbPath, backupDBPath); err != nil { return fmt.Errorf("failed to backup database: %w", err) } } // Backup uploads directory uploadsDir := "./uploads" if _, err := os.Stat(uploadsDir); err == nil { backupUploadsDir := filepath.Join(backupDir, "uploads") if err := copyDirectory(uploadsDir, backupUploadsDir); err != nil { return fmt.Errorf("failed to backup uploads: %w", err) } } // Backup configuration files configFiles := []string{".env", "docker-compose.yml"} for _, file := range configFiles { if _, err := os.Stat(file); err == nil { backupFile := filepath.Join(backupDir, file) if err := copyFile(file, backupFile); err != nil { log.Printf("Warning: failed to backup %s: %v", file, err) } } } log.Printf("User data backed up to: %s", backupDir) return nil } // applyUpdate applies the update func applyUpdate(updateInfo *UpdateInfo) error { // In a real implementation, this would: // 1. Download the new version // 2. Verify checksums // 3. Extract/update files // 4. Run database migrations if needed // 5. Restore user data if necessary log.Printf("Applying update to version %s", updateInfo.Version) // Simulate file update time.Sleep(2 * time.Second) // Update version in environment os.Setenv("APP_VERSION", updateInfo.Version) return nil } // restartApplication restarts the application func restartApplication() { log.Println("Restarting application to complete update...") // Create a new process to replace the current one executable, err := os.Executable() if err != nil { log.Printf("Failed to get executable path: %v", err) return } // Use different commands based on OS var cmd *exec.Cmd switch runtime.GOOS { case "windows": cmd = exec.Command("powershell", "-Command", "Start-Sleep 2; "+executable) default: cmd = exec.Command("sh", "-c", fmt.Sprintf("sleep 2 && %s", executable)) } // Start the new process if err := cmd.Start(); err != nil { log.Printf("Failed to start new process: %v", err) return } // Exit the current process gracefully log.Println("Exiting current process to complete update") os.Exit(0) } // broadcastProgress broadcasts update progress to all WebSocket clients (simplified version) func broadcastProgress() { updateMutex.RLock() progress := *updateProgress updateMutex.RUnlock() log.Printf("Update progress: %.1f%% - Status: %v", progress.Progress, getUpdateStatusString(progress)) } // getUpdateStatusString returns a human-readable status string func getUpdateStatusString(status UpdateStatus) string { if status.Completed { return "Completed" } if status.Error != "" { return "Error: " + status.Error } if status.Installing { return "Installing" } if status.Downloading { return "Downloading" } if status.Available { return "Available" } return "Not Available" } // rollbackUpdate restores the application from backup func rollbackUpdate() error { if backupPath == "" { return fmt.Errorf("no backup path available for rollback") } log.Printf("Starting rollback from backup: %s", backupPath) // Get current executable path executable, err := os.Executable() if err != nil { return fmt.Errorf("failed to get executable path: %w", err) } // Restore executable backupExecPath := filepath.Join(backupPath, filepath.Base(executable)) if _, err := os.Stat(backupExecPath); err == nil { if err := copyFile(backupExecPath, executable); err != nil { return fmt.Errorf("failed to restore executable: %w", err) } log.Printf("Restored executable from backup") } // Restore database backupDBPath := filepath.Join(backupPath, "trackeep.db") dbPath := os.Getenv("DB_PATH") if dbPath == "" { dbPath = "./trackeep.db" } if _, err := os.Stat(backupDBPath); err == nil { if err := copyFile(backupDBPath, dbPath); err != nil { return fmt.Errorf("failed to restore database: %w", err) } log.Printf("Restored database from backup") } // Restore uploads directory backupUploadsDir := filepath.Join(backupPath, "uploads") uploadsDir := "./uploads" if _, err := os.Stat(backupUploadsDir); err == nil { // Remove current uploads directory and restore from backup if err := os.RemoveAll(uploadsDir); err != nil { log.Printf("Warning: failed to remove uploads directory: %v", err) } if err := copyDirectory(backupUploadsDir, uploadsDir); err != nil { return fmt.Errorf("failed to restore uploads: %w", err) } log.Printf("Restored uploads from backup") } // Restore configuration files configFiles := []string{".env", "docker-compose.yml"} for _, file := range configFiles { backupFile := filepath.Join(backupPath, file) if _, err := os.Stat(backupFile); err == nil { if err := copyFile(backupFile, file); err != nil { log.Printf("Warning: failed to restore %s: %v", file, err) } else { log.Printf("Restored %s from backup", file) } } } log.Printf("Rollback completed successfully") return nil } func copyFile(src, dst string) error { sourceFile, err := os.Open(src) if err != nil { return err } defer sourceFile.Close() destFile, err := os.Create(dst) if err != nil { return err } defer destFile.Close() _, err = io.Copy(destFile, sourceFile) return err } // copyDirectory copies a directory recursively func copyDirectory(src, dst string) error { return filepath.Walk(src, func(path string, info os.FileInfo, err error) error { if err != nil { return err } relPath, err := filepath.Rel(src, path) if err != nil { return err } dstPath := filepath.Join(dst, relPath) if info.IsDir() { return os.MkdirAll(dstPath, info.Mode()) } return copyFile(path, dstPath) }) }