package api import ( "archive/tar" "compress/bzip2" "compress/gzip" "fmt" "io" "log" "net/http" "os" "os/exec" "path/filepath" "regexp" "sort" "strings" "sync" "time" ) const ( BlenderDownloadBaseURL = "https://download.blender.org/release/" BlenderVersionCacheTTL = 1 * time.Hour ) // BlenderVersion represents a parsed Blender version type BlenderVersion struct { Major int `json:"major"` Minor int `json:"minor"` Patch int `json:"patch"` Full string `json:"full"` // e.g., "4.2.3" DirName string `json:"dir_name"` // e.g., "Blender4.2" Filename string `json:"filename"` // e.g., "blender-4.2.3-linux-x64.tar.xz" URL string `json:"url"` // Full download URL } // BlenderVersionCache caches available Blender versions type BlenderVersionCache struct { versions []BlenderVersion fetchedAt time.Time mu sync.RWMutex } var blenderVersionCache = &BlenderVersionCache{} // FetchBlenderVersions fetches available Blender versions from download.blender.org // Returns versions sorted by version number (newest first) func (s *Manager) FetchBlenderVersions() ([]BlenderVersion, error) { // Check cache first blenderVersionCache.mu.RLock() if time.Since(blenderVersionCache.fetchedAt) < BlenderVersionCacheTTL && len(blenderVersionCache.versions) > 0 { versions := make([]BlenderVersion, len(blenderVersionCache.versions)) copy(versions, blenderVersionCache.versions) blenderVersionCache.mu.RUnlock() return versions, nil } blenderVersionCache.mu.RUnlock() // Fetch from website with timeout client := &http.Client{ Timeout: WSWriteDeadline, } resp, err := client.Get(BlenderDownloadBaseURL) if err != nil { return nil, fmt.Errorf("failed to fetch blender releases: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("failed to fetch blender releases: status %d", resp.StatusCode) } body, err := io.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("failed to read response: %w", err) } // Parse directory listing for Blender version folders // Looking for patterns like href="Blender4.2/" or href="Blender3.6/" dirPattern := regexp.MustCompile(`href="Blender(\d+)\.(\d+)/"`) log.Printf("Fetching Blender versions from %s", BlenderDownloadBaseURL) matches := dirPattern.FindAllStringSubmatch(string(body), -1) // Fetch sub-versions concurrently to speed up the process type versionResult struct { versions []BlenderVersion err error } results := make(chan versionResult, len(matches)) var wg sync.WaitGroup for _, match := range matches { if len(match) < 3 { continue } major := 0 minor := 0 fmt.Sscanf(match[1], "%d", &major) fmt.Sscanf(match[2], "%d", &minor) // Skip very old versions (pre-2.80) if major < 2 || (major == 2 && minor < 80) { continue } dirName := fmt.Sprintf("Blender%d.%d", major, minor) // Fetch the specific version directory concurrently wg.Add(1) go func(dn string, maj, min int) { defer wg.Done() subVersions, err := fetchSubVersions(dn, maj, min) results <- versionResult{versions: subVersions, err: err} }(dirName, major, minor) } // Close results channel when all goroutines complete go func() { wg.Wait() close(results) }() var versions []BlenderVersion for result := range results { if result.err != nil { log.Printf("Warning: failed to fetch sub-versions: %v", result.err) continue } versions = append(versions, result.versions...) } // Sort by version (newest first) sort.Slice(versions, func(i, j int) bool { if versions[i].Major != versions[j].Major { return versions[i].Major > versions[j].Major } if versions[i].Minor != versions[j].Minor { return versions[i].Minor > versions[j].Minor } return versions[i].Patch > versions[j].Patch }) // Update cache blenderVersionCache.mu.Lock() blenderVersionCache.versions = versions blenderVersionCache.fetchedAt = time.Now() blenderVersionCache.mu.Unlock() return versions, nil } // fetchSubVersions fetches specific version files from a Blender release directory func fetchSubVersions(dirName string, major, minor int) ([]BlenderVersion, error) { url := BlenderDownloadBaseURL + dirName + "/" client := &http.Client{ Timeout: WSWriteDeadline, } resp, err := client.Get(url) if err != nil { return nil, err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("status %d", resp.StatusCode) } body, err := io.ReadAll(resp.Body) if err != nil { return nil, err } // Look for linux 64-bit tar.xz/bz2 files // Various naming conventions across versions: // - Modern (2.93+): blender-4.2.3-linux-x64.tar.xz // - 2.83 early: blender-2.83.0-linux64.tar.xz // - 2.80-2.82: blender-2.80-linux-glibc217-x86_64.tar.bz2 // Skip: rc versions, alpha/beta, i686 (32-bit) filePatterns := []*regexp.Regexp{ // Modern format: blender-X.Y.Z-linux-x64.tar.xz regexp.MustCompile(`blender-(\d+)\.(\d+)\.(\d+)-linux-x64\.tar\.(xz|bz2)`), // Older format: blender-X.Y.Z-linux64.tar.xz regexp.MustCompile(`blender-(\d+)\.(\d+)\.(\d+)-linux64\.tar\.(xz|bz2)`), // glibc format: blender-X.Y.Z-linux-glibc217-x86_64.tar.bz2 (prefer glibc217 for compatibility) regexp.MustCompile(`blender-(\d+)\.(\d+)\.(\d+)-linux-glibc217-x86_64\.tar\.(xz|bz2)`), } var versions []BlenderVersion seen := make(map[string]bool) for _, filePattern := range filePatterns { matches := filePattern.FindAllStringSubmatch(string(body), -1) for _, match := range matches { if len(match) < 5 { continue } patch := 0 fmt.Sscanf(match[3], "%d", &patch) full := fmt.Sprintf("%d.%d.%d", major, minor, patch) if seen[full] { continue } seen[full] = true filename := match[0] versions = append(versions, BlenderVersion{ Major: major, Minor: minor, Patch: patch, Full: full, DirName: dirName, Filename: filename, URL: url + filename, }) } } return versions, nil } // GetLatestBlenderForMajorMinor returns the latest patch version for a given major.minor // If exact match not found, uses fuzzy matching to find the closest available version func (s *Manager) GetLatestBlenderForMajorMinor(major, minor int) (*BlenderVersion, error) { versions, err := s.FetchBlenderVersions() if err != nil { return nil, err } if len(versions) == 0 { return nil, fmt.Errorf("no blender versions available") } // Try exact match first - find the highest patch for this major.minor var exactMatch *BlenderVersion for i := range versions { v := &versions[i] if v.Major == major && v.Minor == minor { if exactMatch == nil || v.Patch > exactMatch.Patch { exactMatch = v } } } if exactMatch != nil { log.Printf("Found Blender %d.%d.%d for requested %d.%d", exactMatch.Major, exactMatch.Minor, exactMatch.Patch, major, minor) return exactMatch, nil } // Fuzzy matching: find closest version // Priority: same major with closest minor > closest major log.Printf("No exact match for Blender %d.%d, using fuzzy matching", major, minor) var bestMatch *BlenderVersion bestScore := -1000000 // Large negative number for i := range versions { v := &versions[i] score := 0 if v.Major == major { // Same major version - prefer this score = 10000 // Prefer lower minor versions (more stable/compatible) // but not too far back minorDiff := minor - v.Minor if minorDiff >= 0 { // v.Minor <= minor (older or same) - prefer closer score += 1000 - minorDiff*10 } else { // v.Minor > minor (newer) - less preferred but acceptable score += 500 + minorDiff*10 } // Higher patch is better score += v.Patch } else { // Different major - less preferred majorDiff := major - v.Major if majorDiff > 0 { // v.Major < major (older major) - acceptable fallback score = 5000 - majorDiff*1000 + v.Minor*10 + v.Patch } else { // v.Major > major (newer major) - avoid if possible score = -majorDiff * 1000 } } if score > bestScore { bestScore = score bestMatch = v } } if bestMatch != nil { log.Printf("Fuzzy match: requested %d.%d, using %d.%d.%d", major, minor, bestMatch.Major, bestMatch.Minor, bestMatch.Patch) return bestMatch, nil } return nil, fmt.Errorf("no blender version found for %d.%d", major, minor) } // GetBlenderArchivePath returns the path to the cached blender archive for a specific version // Downloads from blender.org and decompresses to .tar if not already cached // The manager caches as uncompressed .tar to save decompression time on runners func (s *Manager) GetBlenderArchivePath(version *BlenderVersion) (string, error) { // Base directory for blender archives blenderDir := filepath.Join(s.storage.BasePath(), "blender-versions") if err := os.MkdirAll(blenderDir, 0755); err != nil { return "", fmt.Errorf("failed to create blender directory: %w", err) } // Cache as uncompressed .tar for faster runner downloads // Convert filename like "blender-4.2.3-linux-x64.tar.xz" to "blender-4.2.3-linux-x64.tar" tarFilename := version.Filename tarFilename = strings.TrimSuffix(tarFilename, ".xz") tarFilename = strings.TrimSuffix(tarFilename, ".bz2") archivePath := filepath.Join(blenderDir, tarFilename) // Check if already cached as .tar if _, err := os.Stat(archivePath); err == nil { log.Printf("Using cached Blender %s at %s", version.Full, archivePath) // Clean up any extracted folders that might exist s.cleanupExtractedBlenderFolders(blenderDir, version) return archivePath, nil } // Need to download and decompress log.Printf("Downloading Blender %s from %s", version.Full, version.URL) client := &http.Client{ Timeout: 0, // No timeout for large downloads } resp, err := client.Get(version.URL) if err != nil { return "", fmt.Errorf("failed to download blender: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return "", fmt.Errorf("failed to download blender: status %d", resp.StatusCode) } // Download to temp file first compressedPath := filepath.Join(blenderDir, "download-"+version.Filename) compressedFile, err := os.Create(compressedPath) if err != nil { return "", fmt.Errorf("failed to create temp file: %w", err) } if _, err := io.Copy(compressedFile, resp.Body); err != nil { compressedFile.Close() os.Remove(compressedPath) return "", fmt.Errorf("failed to download blender: %w", err) } compressedFile.Close() log.Printf("Downloaded Blender %s, decompressing to .tar...", version.Full) // Decompress to .tar if err := decompressToTar(compressedPath, archivePath); err != nil { os.Remove(compressedPath) os.Remove(archivePath) return "", fmt.Errorf("failed to decompress blender archive: %w", err) } // Remove compressed file os.Remove(compressedPath) // Clean up any extracted folders for this version (if they exist) s.cleanupExtractedBlenderFolders(blenderDir, version) log.Printf("Blender %s cached at %s", version.Full, archivePath) return archivePath, nil } // decompressToTar decompresses a .tar.xz or .tar.bz2 file to a plain .tar file func decompressToTar(compressedPath, tarPath string) error { if strings.HasSuffix(compressedPath, ".tar.xz") { // Use xz command for decompression cmd := exec.Command("xz", "-d", "-k", "-c", compressedPath) outFile, err := os.Create(tarPath) if err != nil { return err } defer outFile.Close() cmd.Stdout = outFile if err := cmd.Run(); err != nil { return fmt.Errorf("xz decompression failed: %w", err) } return nil } else if strings.HasSuffix(compressedPath, ".tar.bz2") { // Use bzip2 for decompression inFile, err := os.Open(compressedPath) if err != nil { return err } defer inFile.Close() bzReader := bzip2.NewReader(inFile) outFile, err := os.Create(tarPath) if err != nil { return err } defer outFile.Close() if _, err := io.Copy(outFile, bzReader); err != nil { return fmt.Errorf("bzip2 decompression failed: %w", err) } return nil } return fmt.Errorf("unsupported compression format: %s", compressedPath) } // cleanupExtractedBlenderFolders removes any extracted Blender folders for the given version // This ensures we only keep the .tar file and not extracted folders func (s *Manager) cleanupExtractedBlenderFolders(blenderDir string, version *BlenderVersion) { // Look for folders matching the version (e.g., "4.2.3", "2.83.20") versionDirs := []string{ filepath.Join(blenderDir, version.Full), // e.g., "4.2.3" filepath.Join(blenderDir, fmt.Sprintf("%d.%d", version.Major, version.Minor)), // e.g., "4.2" } for _, dir := range versionDirs { if info, err := os.Stat(dir); err == nil && info.IsDir() { log.Printf("Removing extracted Blender folder: %s", dir) if err := os.RemoveAll(dir); err != nil { log.Printf("Warning: failed to remove extracted folder %s: %v", dir, err) } else { log.Printf("Removed extracted Blender folder: %s", dir) } } } } // ParseBlenderVersionFromFile parses the Blender version that a .blend file was saved with // This reads the file header to determine the version func ParseBlenderVersionFromFile(blendPath string) (major, minor int, err error) { file, err := os.Open(blendPath) if err != nil { return 0, 0, fmt.Errorf("failed to open blend file: %w", err) } defer file.Close() return ParseBlenderVersionFromReader(file) } // ParseBlenderVersionFromReader parses the Blender version from a reader // Useful for reading from uploaded files without saving to disk first func ParseBlenderVersionFromReader(r io.ReadSeeker) (major, minor int, err error) { // Read the first 12 bytes of the blend file header // Format: BLENDER-v or BLENDER_v // The header is: "BLENDER" (7 bytes) + pointer size (1 byte: '-' for 64-bit, '_' for 32-bit) // + endianness (1 byte: 'v' for little-endian, 'V' for big-endian) // + version (3 bytes: e.g., "402" for 4.02) header := make([]byte, 12) n, err := r.Read(header) if err != nil || n < 12 { return 0, 0, fmt.Errorf("failed to read blend file header: %w", err) } // Check for BLENDER magic if string(header[:7]) != "BLENDER" { // Might be compressed - try to decompress r.Seek(0, 0) return parseCompressedBlendVersion(r) } // Parse version from bytes 9-11 (3 digits) versionStr := string(header[9:12]) var vMajor, vMinor int // Version format changed in Blender 3.0 // Pre-3.0: "279" = 2.79, "280" = 2.80 // 3.0+: "300" = 3.0, "402" = 4.02, "410" = 4.10 if len(versionStr) == 3 { // First digit is major version fmt.Sscanf(string(versionStr[0]), "%d", &vMajor) // Next two digits are minor version fmt.Sscanf(versionStr[1:3], "%d", &vMinor) } return vMajor, vMinor, nil } // parseCompressedBlendVersion handles gzip and zstd compressed blend files func parseCompressedBlendVersion(r io.ReadSeeker) (major, minor int, err error) { // Check for compression magic bytes magic := make([]byte, 4) if _, err := r.Read(magic); err != nil { return 0, 0, err } r.Seek(0, 0) if magic[0] == 0x1f && magic[1] == 0x8b { // gzip compressed gzReader, err := gzip.NewReader(r) if err != nil { return 0, 0, fmt.Errorf("failed to create gzip reader: %w", err) } defer gzReader.Close() header := make([]byte, 12) n, err := gzReader.Read(header) if err != nil || n < 12 { return 0, 0, fmt.Errorf("failed to read compressed blend header: %w", err) } if string(header[:7]) != "BLENDER" { return 0, 0, fmt.Errorf("invalid blend file format") } versionStr := string(header[9:12]) var vMajor, vMinor int if len(versionStr) == 3 { fmt.Sscanf(string(versionStr[0]), "%d", &vMajor) fmt.Sscanf(versionStr[1:3], "%d", &vMinor) } return vMajor, vMinor, nil } // Check for zstd magic (Blender 3.0+): 0x28 0xB5 0x2F 0xFD if magic[0] == 0x28 && magic[1] == 0xb5 && magic[2] == 0x2f && magic[3] == 0xfd { return parseZstdBlendVersion(r) } return 0, 0, fmt.Errorf("unknown blend file format") } // parseZstdBlendVersion handles zstd-compressed blend files (Blender 3.0+) // Uses zstd command line tool since Go doesn't have native zstd support func parseZstdBlendVersion(r io.ReadSeeker) (major, minor int, err error) { r.Seek(0, 0) // We need to decompress just enough to read the header // Use zstd command to decompress from stdin cmd := exec.Command("zstd", "-d", "-c") cmd.Stdin = r stdout, err := cmd.StdoutPipe() if err != nil { return 0, 0, fmt.Errorf("failed to create zstd stdout pipe: %w", err) } if err := cmd.Start(); err != nil { return 0, 0, fmt.Errorf("failed to start zstd decompression: %w", err) } // Read just the header (12 bytes) header := make([]byte, 12) n, readErr := io.ReadFull(stdout, header) // Kill the process early - we only need the header cmd.Process.Kill() cmd.Wait() if readErr != nil || n < 12 { return 0, 0, fmt.Errorf("failed to read zstd compressed blend header: %v", readErr) } if string(header[:7]) != "BLENDER" { return 0, 0, fmt.Errorf("invalid blend file format in zstd archive") } versionStr := string(header[9:12]) var vMajor, vMinor int if len(versionStr) == 3 { fmt.Sscanf(string(versionStr[0]), "%d", &vMajor) fmt.Sscanf(versionStr[1:3], "%d", &vMinor) } return vMajor, vMinor, nil } // handleGetBlenderVersions returns available Blender versions func (s *Manager) handleGetBlenderVersions(w http.ResponseWriter, r *http.Request) { versions, err := s.FetchBlenderVersions() if err != nil { s.respondError(w, http.StatusInternalServerError, fmt.Sprintf("failed to fetch blender versions: %v", err)) return } // Group by major.minor for easier frontend display type VersionGroup struct { MajorMinor string `json:"major_minor"` Latest BlenderVersion `json:"latest"` All []BlenderVersion `json:"all"` } groups := make(map[string]*VersionGroup) for _, v := range versions { key := fmt.Sprintf("%d.%d", v.Major, v.Minor) if groups[key] == nil { groups[key] = &VersionGroup{ MajorMinor: key, Latest: v, // First one is latest due to sorting All: []BlenderVersion{v}, } } else { groups[key].All = append(groups[key].All, v) } } // Convert to slice and sort by version var groupedResult []VersionGroup for _, g := range groups { groupedResult = append(groupedResult, *g) } sort.Slice(groupedResult, func(i, j int) bool { // Parse major.minor for comparison var iMaj, iMin, jMaj, jMin int fmt.Sscanf(groupedResult[i].MajorMinor, "%d.%d", &iMaj, &iMin) fmt.Sscanf(groupedResult[j].MajorMinor, "%d.%d", &jMaj, &jMin) if iMaj != jMaj { return iMaj > jMaj } return iMin > jMin }) // Return both flat list and grouped for flexibility response := map[string]interface{}{ "versions": versions, // Flat list of all versions (newest first) "grouped": groupedResult, // Grouped by major.minor } s.respondJSON(w, http.StatusOK, response) } // handleDownloadBlender serves a cached Blender archive to runners func (s *Manager) handleDownloadBlender(w http.ResponseWriter, r *http.Request) { version := r.URL.Query().Get("version") if version == "" { s.respondError(w, http.StatusBadRequest, "version parameter required") return } // Parse version string (e.g., "4.2.3" or "4.2") var major, minor, patch int parts := strings.Split(version, ".") if len(parts) < 2 { s.respondError(w, http.StatusBadRequest, "invalid version format, expected major.minor or major.minor.patch") return } fmt.Sscanf(parts[0], "%d", &major) fmt.Sscanf(parts[1], "%d", &minor) if len(parts) >= 3 { fmt.Sscanf(parts[2], "%d", &patch) } // Find the version var blenderVersion *BlenderVersion if len(parts) >= 3 { // Exact patch version requested - find it versions, err := s.FetchBlenderVersions() if err != nil { s.respondError(w, http.StatusInternalServerError, fmt.Sprintf("failed to fetch versions: %v", err)) return } for _, v := range versions { if v.Major == major && v.Minor == minor && v.Patch == patch { blenderVersion = &v break } } if blenderVersion == nil { s.respondError(w, http.StatusNotFound, fmt.Sprintf("blender version %s not found", version)) return } } else { // Major.minor only - use helper to get latest patch version var err error blenderVersion, err = s.GetLatestBlenderForMajorMinor(major, minor) if err != nil { s.respondError(w, http.StatusNotFound, fmt.Sprintf("blender version %s not found: %v", version, err)) return } } // Get or download the archive archivePath, err := s.GetBlenderArchivePath(blenderVersion) if err != nil { s.respondError(w, http.StatusInternalServerError, fmt.Sprintf("failed to get blender archive: %v", err)) return } // Serve the file file, err := os.Open(archivePath) if err != nil { s.respondError(w, http.StatusInternalServerError, fmt.Sprintf("failed to open archive: %v", err)) return } defer file.Close() stat, err := file.Stat() if err != nil { s.respondError(w, http.StatusInternalServerError, fmt.Sprintf("failed to stat archive: %v", err)) return } // Filename is now .tar (decompressed) tarFilename := blenderVersion.Filename tarFilename = strings.TrimSuffix(tarFilename, ".xz") tarFilename = strings.TrimSuffix(tarFilename, ".bz2") w.Header().Set("Content-Type", "application/x-tar") w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%s", tarFilename)) w.Header().Set("Content-Length", fmt.Sprintf("%d", stat.Size())) w.Header().Set("X-Blender-Version", blenderVersion.Full) io.Copy(w, file) } // Unused functions from extraction - keeping for reference but not needed on manager var _ = extractBlenderArchive var _ = extractTarXz var _ = extractTar // extractBlenderArchive extracts a blender archive (already decompressed to .tar by GetBlenderArchivePath) func extractBlenderArchive(archivePath string, version *BlenderVersion, destDir string) error { file, err := os.Open(archivePath) if err != nil { return err } defer file.Close() // The archive is already decompressed to .tar by GetBlenderArchivePath // Just extract it directly if strings.HasSuffix(archivePath, ".tar") { tarReader := tar.NewReader(file) return extractTar(tarReader, version, destDir) } // Fallback for any other format (shouldn't happen with current flow) if strings.HasSuffix(archivePath, ".tar.xz") { return extractTarXz(archivePath, version, destDir) } else if strings.HasSuffix(archivePath, ".tar.bz2") { bzReader := bzip2.NewReader(file) tarReader := tar.NewReader(bzReader) return extractTar(tarReader, version, destDir) } return fmt.Errorf("unsupported archive format: %s", archivePath) } // extractTarXz extracts a tar.xz archive using the xz command func extractTarXz(archivePath string, version *BlenderVersion, destDir string) error { versionDir := filepath.Join(destDir, version.Full) if err := os.MkdirAll(versionDir, 0755); err != nil { return err } cmd := exec.Command("tar", "-xJf", archivePath, "-C", versionDir, "--strip-components=1") output, err := cmd.CombinedOutput() if err != nil { return fmt.Errorf("tar extraction failed: %v, output: %s", err, string(output)) } return nil } // extractTar extracts files from a tar reader func extractTar(tarReader *tar.Reader, version *BlenderVersion, destDir string) error { versionDir := filepath.Join(destDir, version.Full) if err := os.MkdirAll(versionDir, 0755); err != nil { return err } stripPrefix := "" for { header, err := tarReader.Next() if err == io.EOF { break } if err != nil { return err } if stripPrefix == "" { parts := strings.SplitN(header.Name, "/", 2) if len(parts) > 0 { stripPrefix = parts[0] + "/" } } name := strings.TrimPrefix(header.Name, stripPrefix) if name == "" { continue } targetPath := filepath.Join(versionDir, name) switch header.Typeflag { case tar.TypeDir: if err := os.MkdirAll(targetPath, os.FileMode(header.Mode)); err != nil { return err } case tar.TypeReg: if err := os.MkdirAll(filepath.Dir(targetPath), 0755); err != nil { return err } outFile, err := os.OpenFile(targetPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, os.FileMode(header.Mode)) if err != nil { return err } if _, err := io.Copy(outFile, tarReader); err != nil { outFile.Close() return err } outFile.Close() case tar.TypeSymlink: if err := os.MkdirAll(filepath.Dir(targetPath), 0755); err != nil { return err } if err := os.Symlink(header.Linkname, targetPath); err != nil { return err } } } return nil }