. */ declare(strict_types=1); namespace App\Services\System; use App\Settings\SystemSettings\PrivacySettings; use Psr\Log\LoggerInterface; use Shivas\VersioningBundle\Service\VersionManagerInterface; use Symfony\Component\DependencyInjection\Attribute\Autowire; use Symfony\Component\Process\Process; use Symfony\Contracts\Cache\CacheInterface; use Symfony\Contracts\Cache\ItemInterface; use Symfony\Contracts\HttpClient\HttpClientInterface; use Version\Version; /** * Enhanced update checker that fetches release information including changelogs. */ class UpdateChecker { private const GITHUB_API_BASE = 'https://api.github.com/repos/Part-DB/Part-DB-server'; private const CACHE_KEY_RELEASES = 'update_checker_releases'; private const CACHE_KEY_COMMITS = 'update_checker_commits_behind'; private const CACHE_TTL = 60 * 60 * 6; // 6 hours private const CACHE_TTL_ERROR = 60 * 60; // 1 hour on error public function __construct(private readonly HttpClientInterface $httpClient, private readonly CacheInterface $updateCache, private readonly VersionManagerInterface $versionManager, private readonly PrivacySettings $privacySettings, private readonly LoggerInterface $logger, private readonly InstallationTypeDetector $installationTypeDetector, #[Autowire(param: 'kernel.debug')] private readonly bool $is_dev_mode, #[Autowire(param: 'kernel.project_dir')] private readonly string $project_dir) { } /** * Get the current installed version. */ public function getCurrentVersion(): Version { return $this->versionManager->getVersion(); } /** * Get the current version as string. */ public function getCurrentVersionString(): string { return $this->getCurrentVersion()->toString(); } /** * Get Git repository information. */ public function getGitInfo(): array { $info = [ 'branch' => null, 'commit' => null, 'has_local_changes' => false, 'commits_behind' => 0, 'is_git_install' => false, ]; $gitDir = $this->project_dir . '/.git'; if (!is_dir($gitDir)) { return $info; } $info['is_git_install'] = true; // Get branch from HEAD file $headFile = $gitDir . '/HEAD'; if (file_exists($headFile)) { $head = file_get_contents($headFile); if (preg_match('#ref: refs/heads/(.+)#', $head, $matches)) { $info['branch'] = trim($matches[1]); } } // Get current commit $process = new Process(['git', 'rev-parse', '--short', 'HEAD'], $this->project_dir); $process->run(); if ($process->isSuccessful()) { $info['commit'] = trim($process->getOutput()); } // Check for local changes $process = new Process(['git', 'status', '--porcelain'], $this->project_dir); $process->run(); $info['has_local_changes'] = !empty(trim($process->getOutput())); // Get commits behind (fetch first) if ($info['branch']) { // Try to get cached commits behind count $info['commits_behind'] = $this->getCommitsBehind($info['branch']); } return $info; } /** * Get number of commits behind the remote branch (cached). */ private function getCommitsBehind(string $branch): int { if (!$this->privacySettings->checkForUpdates) { return 0; } $cacheKey = self::CACHE_KEY_COMMITS . '_' . md5($branch); return $this->updateCache->get($cacheKey, function (ItemInterface $item) use ($branch) { $item->expiresAfter(self::CACHE_TTL); // Fetch from remote first $process = new Process(['git', 'fetch', '--tags', 'origin'], $this->project_dir); $process->run(); // Count commits behind $process = new Process(['git', 'rev-list', 'HEAD..origin/' . $branch, '--count'], $this->project_dir); $process->run(); return $process->isSuccessful() ? (int) trim($process->getOutput()) : 0; }); } /** * Force refresh git information by invalidating cache. */ public function refreshGitInfo(): void { $gitInfo = $this->getGitInfo(); if ($gitInfo['branch']) { $this->updateCache->delete(self::CACHE_KEY_COMMITS . '_' . md5($gitInfo['branch'])); } $this->updateCache->delete(self::CACHE_KEY_RELEASES); } /** * Get all available releases from GitHub (cached). * * @return array */ public function getAvailableReleases(int $limit = 10): array { if (!$this->privacySettings->checkForUpdates) { return []; } return $this->updateCache->get(self::CACHE_KEY_RELEASES, function (ItemInterface $item) use ($limit) { $item->expiresAfter(self::CACHE_TTL); try { $response = $this->httpClient->request('GET', self::GITHUB_API_BASE . '/releases', [ 'query' => ['per_page' => $limit], 'headers' => [ 'Accept' => 'application/vnd.github.v3+json', 'User-Agent' => 'Part-DB-Update-Checker', ], ]); $releases = []; foreach ($response->toArray() as $release) { // Extract assets (for ZIP download) $assets = []; foreach ($release['assets'] ?? [] as $asset) { if (str_ends_with($asset['name'], '.zip') || str_ends_with($asset['name'], '.tar.gz')) { $assets[] = [ 'name' => $asset['name'], 'url' => $asset['browser_download_url'], 'size' => $asset['size'], ]; } } $releases[] = [ 'version' => ltrim($release['tag_name'], 'v'), 'tag' => $release['tag_name'], 'name' => $release['name'] ?? $release['tag_name'], 'url' => $release['html_url'], 'published_at' => $release['published_at'], 'body' => $release['body'] ?? '', 'prerelease' => $release['prerelease'] ?? false, 'draft' => $release['draft'] ?? false, 'assets' => $assets, 'tarball_url' => $release['tarball_url'] ?? null, 'zipball_url' => $release['zipball_url'] ?? null, ]; } return $releases; } catch (\Exception $e) { $this->logger->error('Failed to fetch releases from GitHub: ' . $e->getMessage()); $item->expiresAfter(self::CACHE_TTL_ERROR); if ($this->is_dev_mode) { throw $e; } return []; } }); } /** * Get the latest stable release. */ public function getLatestRelease(bool $includePrerelease = false): ?array { $releases = $this->getAvailableReleases(); foreach ($releases as $release) { // Skip drafts always if ($release['draft']) { continue; } // Skip prereleases unless explicitly included if (!$includePrerelease && $release['prerelease']) { continue; } return $release; } return null; } /** * Check if a specific version is newer than current. */ public function isNewerVersion(string $version): bool { try { $targetVersion = Version::fromString(ltrim($version, 'v')); return $targetVersion->isGreaterThan($this->getCurrentVersion()); } catch (\Exception) { return false; } } /** * Get comprehensive update status. */ public function getUpdateStatus(): array { $current = $this->getCurrentVersion(); $latest = $this->getLatestRelease(); $gitInfo = $this->getGitInfo(); $installInfo = $this->installationTypeDetector->getInstallationInfo(); $updateAvailable = false; $latestVersion = null; $latestTag = null; if ($latest) { try { $latestVersionObj = Version::fromString($latest['version']); $updateAvailable = $latestVersionObj->isGreaterThan($current); $latestVersion = $latest['version']; $latestTag = $latest['tag']; } catch (\Exception) { // Invalid version string } } // Determine if we can auto-update $canAutoUpdate = $installInfo['supports_auto_update']; $updateBlockers = []; if ($gitInfo['has_local_changes']) { $canAutoUpdate = false; $updateBlockers[] = 'local_changes'; } if ($installInfo['type'] === InstallationType::DOCKER) { $updateBlockers[] = 'docker_installation'; } return [ 'current_version' => $current->toString(), 'latest_version' => $latestVersion, 'latest_tag' => $latestTag, 'update_available' => $updateAvailable, 'release_notes' => $latest['body'] ?? null, 'release_url' => $latest['url'] ?? null, 'published_at' => $latest['published_at'] ?? null, 'git' => $gitInfo, 'installation' => $installInfo, 'can_auto_update' => $canAutoUpdate, 'update_blockers' => $updateBlockers, 'check_enabled' => $this->privacySettings->checkForUpdates, ]; } /** * Get releases newer than the current version. */ public function getAvailableUpdates(bool $includePrerelease = false): array { $releases = $this->getAvailableReleases(); $current = $this->getCurrentVersion(); $updates = []; foreach ($releases as $release) { if ($release['draft']) { continue; } if (!$includePrerelease && $release['prerelease']) { continue; } try { $releaseVersion = Version::fromString($release['version']); if ($releaseVersion->isGreaterThan($current)) { $updates[] = $release; } } catch (\Exception) { continue; } } return $updates; } }