| 
									
										
										
										
											2025-10-16 10:59:50 -06:00
										 |  |  | <?php | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  | /** | 
					
						
							|  |  |  |  * @package    Grav\Plugin\Admin | 
					
						
							|  |  |  |  * | 
					
						
							|  |  |  |  * Handles Safe Upgrade orchestration for the Admin plugin. | 
					
						
							|  |  |  |  * | 
					
						
							|  |  |  |  * This class mirrors the behaviour offered by the CLI `bin/gpm self-upgrade` | 
					
						
							|  |  |  |  * command while exposing a task-oriented API suitable for AJAX interactions. | 
					
						
							|  |  |  |  * | 
					
						
							|  |  |  |  * IMPORTANT: Keep this implementation aligned with | 
					
						
							|  |  |  |  * `Grav\Console\Gpm\SelfupgradeCommand` whenever logic changes there. | 
					
						
							|  |  |  |  */ | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  | namespace Grav\Plugin\Admin; | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  | use Grav\Common\Filesystem\Folder; | 
					
						
							|  |  |  | use Grav\Common\GPM\Installer; | 
					
						
							|  |  |  | use Grav\Common\GPM\Upgrader; | 
					
						
							|  |  |  | use Grav\Common\Grav; | 
					
						
							|  |  |  | use Grav\Common\HTTP\Response; | 
					
						
							|  |  |  | use Grav\Common\Recovery\RecoveryManager; | 
					
						
							|  |  |  | use Grav\Common\Upgrade\SafeUpgradeService; | 
					
						
							| 
									
										
										
										
											2025-10-16 17:31:57 -06:00
										 |  |  | use Grav\Common\Utils; | 
					
						
							| 
									
										
										
										
											2025-10-16 10:59:50 -06:00
										 |  |  | use Grav\Installer\Install; | 
					
						
							| 
									
										
										
										
											2025-10-16 17:59:05 -06:00
										 |  |  | use Symfony\Component\Process\PhpExecutableFinder; | 
					
						
							|  |  |  | use Symfony\Component\Process\Process; | 
					
						
							| 
									
										
										
										
											2025-10-16 10:59:50 -06:00
										 |  |  | use RuntimeException; | 
					
						
							|  |  |  | use Throwable; | 
					
						
							|  |  |  | use ZipArchive; | 
					
						
							|  |  |  | use function class_exists; | 
					
						
							|  |  |  | use function dirname; | 
					
						
							|  |  |  | use function file_exists; | 
					
						
							|  |  |  | use function file_get_contents; | 
					
						
							|  |  |  | use function file_put_contents; | 
					
						
							|  |  |  | use function glob; | 
					
						
							|  |  |  | use function is_array; | 
					
						
							|  |  |  | use function is_dir; | 
					
						
							|  |  |  | use function is_file; | 
					
						
							|  |  |  | use function json_decode; | 
					
						
							|  |  |  | use function json_encode; | 
					
						
							|  |  |  | use function max; | 
					
						
							|  |  |  | use function rsort; | 
					
						
							|  |  |  | use function sprintf; | 
					
						
							|  |  |  | use function strftime; | 
					
						
							|  |  |  | use function strtotime; | 
					
						
							|  |  |  | use function time; | 
					
						
							|  |  |  | use function uniqid; | 
					
						
							|  |  |  | use const GRAV_ROOT; | 
					
						
							|  |  |  | use const GRAV_SCHEMA; | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  | class SafeUpgradeManager | 
					
						
							|  |  |  | { | 
					
						
							|  |  |  |     private const PROGRESS_FILENAME = 'safe-upgrade-progress.json'; | 
					
						
							| 
									
										
										
										
											2025-10-16 17:31:57 -06:00
										 |  |  |     private const JOB_MANIFEST = 'manifest.json'; | 
					
						
							|  |  |  |     private const JOB_PROGRESS = 'progress.json'; | 
					
						
							| 
									
										
										
										
											2025-10-16 10:59:50 -06:00
										 |  |  | 
 | 
					
						
							|  |  |  |     /** @var Grav */ | 
					
						
							|  |  |  |     private $grav; | 
					
						
							|  |  |  |     /** @var Upgrader|null */ | 
					
						
							|  |  |  |     private $upgrader; | 
					
						
							|  |  |  |     /** @var SafeUpgradeService|null */ | 
					
						
							|  |  |  |     private $safeUpgrade; | 
					
						
							|  |  |  |     /** @var RecoveryManager */ | 
					
						
							|  |  |  |     private $recovery; | 
					
						
							|  |  |  |     /** @var string */ | 
					
						
							| 
									
										
										
										
											2025-10-16 17:31:57 -06:00
										 |  |  |     private $progressDir; | 
					
						
							|  |  |  |     /** @var string */ | 
					
						
							|  |  |  |     private $jobsDir; | 
					
						
							| 
									
										
										
										
											2025-10-16 17:59:05 -06:00
										 |  |  |     /** @var \Psr\Log\LoggerInterface|null */ | 
					
						
							|  |  |  |     private $logger; | 
					
						
							| 
									
										
										
										
											2025-10-16 17:31:57 -06:00
										 |  |  |     /** @var string|null */ | 
					
						
							|  |  |  |     private $jobId; | 
					
						
							|  |  |  |     /** @var string|null */ | 
					
						
							|  |  |  |     private $jobManifestPath; | 
					
						
							|  |  |  |     /** @var string */ | 
					
						
							| 
									
										
										
										
											2025-10-16 10:59:50 -06:00
										 |  |  |     private $progressPath; | 
					
						
							|  |  |  |     /** @var string|null */ | 
					
						
							|  |  |  |     private $tmp; | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |     /** | 
					
						
							|  |  |  |      * SafeUpgradeManager constructor. | 
					
						
							|  |  |  |      * | 
					
						
							|  |  |  |      * @param Grav|null $grav | 
					
						
							|  |  |  |      */ | 
					
						
							|  |  |  |     public function __construct(?Grav $grav = null) | 
					
						
							|  |  |  |     { | 
					
						
							|  |  |  |         $this->grav = $grav ?? Grav::instance(); | 
					
						
							|  |  |  |         $this->recovery = $this->grav['recovery']; | 
					
						
							| 
									
										
										
										
											2025-10-16 17:59:05 -06:00
										 |  |  |         $this->logger = $this->grav['log'] ?? null; | 
					
						
							| 
									
										
										
										
											2025-10-16 10:59:50 -06:00
										 |  |  | 
 | 
					
						
							|  |  |  |         $locator = $this->grav['locator']; | 
					
						
							| 
									
										
										
										
											2025-10-16 17:31:57 -06:00
										 |  |  |         $this->progressDir = $locator->findResource('user://data/upgrades', true, true); | 
					
						
							|  |  |  |         $this->jobsDir = $this->progressDir . '/jobs'; | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |         Folder::create($this->jobsDir); | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |         $this->setJobId(null); | 
					
						
							|  |  |  |     } | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |     protected function setJobId(?string $jobId): void | 
					
						
							|  |  |  |     { | 
					
						
							|  |  |  |         $this->jobId = $jobId ?: null; | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |         if ($this->jobId) { | 
					
						
							|  |  |  |             $jobDir = $this->getJobDir($this->jobId); | 
					
						
							|  |  |  |             Folder::create($jobDir); | 
					
						
							|  |  |  |             $this->jobManifestPath = $jobDir . '/' . self::JOB_MANIFEST; | 
					
						
							|  |  |  |             $this->progressPath = $jobDir . '/' . self::JOB_PROGRESS; | 
					
						
							| 
									
										
										
										
											2025-10-16 17:59:05 -06:00
										 |  |  |             $this->log(sprintf('Safe upgrade job %s activated', $this->jobId), 'debug'); | 
					
						
							| 
									
										
										
										
											2025-10-16 17:31:57 -06:00
										 |  |  |         } else { | 
					
						
							|  |  |  |             $this->jobManifestPath = null; | 
					
						
							|  |  |  |             $this->progressPath = $this->progressDir . '/' . self::PROGRESS_FILENAME; | 
					
						
							| 
									
										
										
										
											2025-10-16 17:59:05 -06:00
										 |  |  |             $this->log('Safe upgrade job context cleared', 'debug'); | 
					
						
							| 
									
										
										
										
											2025-10-16 17:31:57 -06:00
										 |  |  |         } | 
					
						
							|  |  |  |     } | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |     public function clearJobContext(): void | 
					
						
							|  |  |  |     { | 
					
						
							|  |  |  |         $this->setJobId(null); | 
					
						
							|  |  |  |     } | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |     protected function getJobDir(string $jobId): string | 
					
						
							|  |  |  |     { | 
					
						
							|  |  |  |         return $this->jobsDir . '/' . $jobId; | 
					
						
							|  |  |  |     } | 
					
						
							|  |  |  | 
 | 
					
						
							| 
									
										
										
										
											2025-10-16 17:59:05 -06:00
										 |  |  |     protected function generateJobId(): string | 
					
						
							| 
									
										
										
										
											2025-10-16 17:31:57 -06:00
										 |  |  |     { | 
					
						
							| 
									
										
										
										
											2025-10-16 17:59:05 -06:00
										 |  |  |         return 'job-' . gmdate('YmdHis') . '-' . substr(md5(uniqid('', true)), 0, 8); | 
					
						
							| 
									
										
										
										
											2025-10-16 17:31:57 -06:00
										 |  |  |     } | 
					
						
							|  |  |  | 
 | 
					
						
							| 
									
										
										
										
											2025-10-16 17:59:05 -06:00
										 |  |  |     protected function log(string $message, string $level = 'info'): void | 
					
						
							| 
									
										
										
										
											2025-10-16 17:31:57 -06:00
										 |  |  |     { | 
					
						
							| 
									
										
										
										
											2025-10-16 17:59:05 -06:00
										 |  |  |         if (!$this->logger) { | 
					
						
							|  |  |  |             return; | 
					
						
							|  |  |  |         } | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |         try { | 
					
						
							|  |  |  |             if (method_exists($this->logger, $level)) { | 
					
						
							|  |  |  |                 $this->logger->$level('[SafeUpgrade] ' . $message); | 
					
						
							|  |  |  |             } else { | 
					
						
							|  |  |  |                 $this->logger->info('[SafeUpgrade] ' . $message); | 
					
						
							|  |  |  |             } | 
					
						
							|  |  |  |         } catch (Throwable $e) { | 
					
						
							|  |  |  |             // ignore logging errors
 | 
					
						
							|  |  |  |         } | 
					
						
							| 
									
										
										
										
											2025-10-16 17:31:57 -06:00
										 |  |  |     } | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |     protected function writeManifest(array $data): void | 
					
						
							|  |  |  |     { | 
					
						
							|  |  |  |         if (!$this->jobManifestPath) { | 
					
						
							|  |  |  |             return; | 
					
						
							|  |  |  |         } | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |         try { | 
					
						
							|  |  |  |             $existing = []; | 
					
						
							|  |  |  |             if (is_file($this->jobManifestPath)) { | 
					
						
							|  |  |  |                 $decoded = json_decode((string)file_get_contents($this->jobManifestPath), true); | 
					
						
							|  |  |  |                 if (is_array($decoded)) { | 
					
						
							|  |  |  |                     $existing = $decoded; | 
					
						
							|  |  |  |                 } | 
					
						
							|  |  |  |             } | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |             $payload = $existing + [ | 
					
						
							|  |  |  |                 'id' => $this->jobId, | 
					
						
							|  |  |  |                 'created_at' => time(), | 
					
						
							|  |  |  |             ]; | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |             $payload = array_merge($payload, $data, [ | 
					
						
							|  |  |  |                 'updated_at' => time(), | 
					
						
							|  |  |  |             ]); | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |             Folder::create(dirname($this->jobManifestPath)); | 
					
						
							|  |  |  |             file_put_contents($this->jobManifestPath, json_encode($payload, JSON_PRETTY_PRINT)); | 
					
						
							| 
									
										
										
										
											2025-10-16 17:59:05 -06:00
										 |  |  |             if (!empty($data['status'])) { | 
					
						
							|  |  |  |                 $this->log(sprintf('Job %s status -> %s', $payload['id'] ?? $this->jobId ?? 'unknown', $data['status']), 'debug'); | 
					
						
							|  |  |  |             } | 
					
						
							| 
									
										
										
										
											2025-10-16 17:31:57 -06:00
										 |  |  |         } catch (Throwable $e) { | 
					
						
							|  |  |  |             // ignore manifest write failures
 | 
					
						
							|  |  |  |         } | 
					
						
							|  |  |  |     } | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |     public function updateJob(array $data): void | 
					
						
							|  |  |  |     { | 
					
						
							|  |  |  |         $this->writeManifest($data); | 
					
						
							|  |  |  |     } | 
					
						
							|  |  |  | 
 | 
					
						
							| 
									
										
										
										
											2025-10-16 21:28:43 -06:00
										 |  |  |     public function ensureJobResult(array $result): void | 
					
						
							|  |  |  |     { | 
					
						
							|  |  |  |         if (!$this->jobManifestPath) { | 
					
						
							|  |  |  |             return; | 
					
						
							|  |  |  |         } | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |         $status = $result['status'] ?? null; | 
					
						
							|  |  |  |         $progress = $this->getProgress(); | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |         if ($status === 'success') { | 
					
						
							|  |  |  |             $targetVersion = $result['version'] ?? ($result['manifest']['target_version'] ?? null); | 
					
						
							|  |  |  |             $manifest = $result['manifest'] ?? null; | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |             if (($progress['stage'] ?? null) !== 'complete') { | 
					
						
							|  |  |  |                 $extras = []; | 
					
						
							|  |  |  |                 if ($targetVersion !== null) { | 
					
						
							|  |  |  |                     $extras['target_version'] = $targetVersion; | 
					
						
							|  |  |  |                 } | 
					
						
							|  |  |  |                 if ($manifest !== null) { | 
					
						
							|  |  |  |                     $extras['manifest'] = $manifest; | 
					
						
							|  |  |  |                 } | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |                 $this->setProgress('complete', 'Upgrade complete.', 100, $extras); | 
					
						
							|  |  |  |                 $progress = $this->getProgress(); | 
					
						
							|  |  |  |             } | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |             $this->updateJob([ | 
					
						
							|  |  |  |                 'status' => 'success', | 
					
						
							|  |  |  |                 'completed_at' => time(), | 
					
						
							|  |  |  |                 'result' => $result, | 
					
						
							|  |  |  |                 'progress' => $progress, | 
					
						
							|  |  |  |             ]); | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |             return; | 
					
						
							|  |  |  |         } | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |         if ($status === 'error') { | 
					
						
							|  |  |  |             $message = $result['message'] ?? 'Safe upgrade failed.'; | 
					
						
							|  |  |  |             if (($progress['stage'] ?? null) !== 'error') { | 
					
						
							|  |  |  |                 $this->setProgress('error', $message, null, ['message' => $message]); | 
					
						
							|  |  |  |                 $progress = $this->getProgress(); | 
					
						
							|  |  |  |             } | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |             $this->updateJob([ | 
					
						
							|  |  |  |                 'status' => 'error', | 
					
						
							|  |  |  |                 'completed_at' => time(), | 
					
						
							|  |  |  |                 'result' => $result, | 
					
						
							|  |  |  |                 'progress' => $progress, | 
					
						
							|  |  |  |                 'error' => $message, | 
					
						
							|  |  |  |             ]); | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |             return; | 
					
						
							|  |  |  |         } | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |         if ($status === 'noop' || $status === 'finalized') { | 
					
						
							|  |  |  |             if (($progress['stage'] ?? null) !== 'complete') { | 
					
						
							|  |  |  |                 $this->setProgress('complete', $progress['message'] ?? 'Upgrade complete.', 100, [ | 
					
						
							|  |  |  |                     'target_version' => $result['version'] ?? null, | 
					
						
							|  |  |  |                     'manifest' => $result['manifest'] ?? null, | 
					
						
							|  |  |  |                 ]); | 
					
						
							|  |  |  |                 $progress = $this->getProgress(); | 
					
						
							|  |  |  |             } | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |             $this->updateJob([ | 
					
						
							|  |  |  |                 'status' => $status, | 
					
						
							|  |  |  |                 'completed_at' => time(), | 
					
						
							|  |  |  |                 'result' => $result, | 
					
						
							|  |  |  |                 'progress' => $progress, | 
					
						
							|  |  |  |             ]); | 
					
						
							|  |  |  |         } | 
					
						
							|  |  |  |     } | 
					
						
							|  |  |  | 
 | 
					
						
							| 
									
										
										
										
											2025-10-16 17:31:57 -06:00
										 |  |  |     public function markJobError(string $message): void | 
					
						
							|  |  |  |     { | 
					
						
							|  |  |  |         $this->setProgress('error', $message, null, ['message' => $message]); | 
					
						
							|  |  |  |     } | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |     protected function readManifest(?string $path = null): array | 
					
						
							|  |  |  |     { | 
					
						
							|  |  |  |         $target = $path ?? $this->jobManifestPath; | 
					
						
							|  |  |  |         if (!$target || !is_file($target)) { | 
					
						
							|  |  |  |             return []; | 
					
						
							|  |  |  |         } | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |         $decoded = json_decode((string)file_get_contents($target), true); | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |         return is_array($decoded) ? $decoded : []; | 
					
						
							|  |  |  |     } | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |     public function loadJob(string $jobId): array | 
					
						
							|  |  |  |     { | 
					
						
							|  |  |  |         $this->setJobId($jobId); | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |         return $this->readManifest(); | 
					
						
							|  |  |  |     } | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |     public function getJobStatus(string $jobId): array | 
					
						
							|  |  |  |     { | 
					
						
							|  |  |  |         $manifest = $this->loadJob($jobId); | 
					
						
							|  |  |  |         $progress = $this->getProgress(); | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |         $result = [ | 
					
						
							|  |  |  |             'job' => $manifest ?: null, | 
					
						
							|  |  |  |             'progress' => $progress, | 
					
						
							| 
									
										
										
										
											2025-10-17 10:02:22 -06:00
										 |  |  |             'context' => $this->buildStatusContext(), | 
					
						
							| 
									
										
										
										
											2025-10-16 17:31:57 -06:00
										 |  |  |         ]; | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |         $this->clearJobContext(); | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |         return $result; | 
					
						
							|  |  |  |     } | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |     public function queue(array $options = []): array | 
					
						
							|  |  |  |     { | 
					
						
							|  |  |  |         $jobId = $this->generateJobId(); | 
					
						
							|  |  |  |         $this->setJobId($jobId); | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |         $jobDir = $this->getJobDir($jobId); | 
					
						
							|  |  |  |         Folder::create($jobDir); | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |         $logPath = $jobDir . '/worker.log'; | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |         $timestamp = time(); | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |         $manifest = [ | 
					
						
							|  |  |  |             'id' => $jobId, | 
					
						
							|  |  |  |             'status' => 'queued', | 
					
						
							|  |  |  |             'options' => $options, | 
					
						
							|  |  |  |             'log' => $logPath, | 
					
						
							|  |  |  |             'created_at' => $timestamp, | 
					
						
							|  |  |  |             'started_at' => null, | 
					
						
							|  |  |  |             'completed_at' => null, | 
					
						
							|  |  |  |         ]; | 
					
						
							|  |  |  |         $this->writeManifest($manifest); | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |         try { | 
					
						
							|  |  |  |             file_put_contents($logPath, '[' . gmdate('c') . "] Job {$jobId} queued\n"); | 
					
						
							|  |  |  |         } catch (Throwable $e) { | 
					
						
							|  |  |  |             // ignore log write failures
 | 
					
						
							|  |  |  |         } | 
					
						
							|  |  |  | 
 | 
					
						
							| 
									
										
										
										
											2025-10-16 17:59:05 -06:00
										 |  |  |         $this->log(sprintf('Queued safe upgrade job %s', $jobId)); | 
					
						
							|  |  |  | 
 | 
					
						
							| 
									
										
										
										
											2025-10-16 19:56:59 -06:00
										 |  |  |         $this->setProgress('queued', 'Waiting for upgrade worker...', 0, ['job_id' => $jobId, 'status' => 'queued']); | 
					
						
							| 
									
										
										
										
											2025-10-16 17:31:57 -06:00
										 |  |  | 
 | 
					
						
							|  |  |  |         if (!function_exists('proc_open')) { | 
					
						
							|  |  |  |             $message = 'proc_open() is disabled on this server; unable to run safe upgrade worker.'; | 
					
						
							|  |  |  |             $this->writeManifest([ | 
					
						
							|  |  |  |                 'status' => 'error', | 
					
						
							|  |  |  |                 'error' => $message, | 
					
						
							|  |  |  |             ]); | 
					
						
							|  |  |  |             $this->setProgress('error', $message, null, ['job_id' => $jobId]); | 
					
						
							|  |  |  |             $this->clearJobContext(); | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |             return [ | 
					
						
							|  |  |  |                 'status' => 'error', | 
					
						
							|  |  |  |                 'message' => $message, | 
					
						
							|  |  |  |             ]; | 
					
						
							|  |  |  |         } | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |         try { | 
					
						
							| 
									
										
										
										
											2025-10-16 17:59:05 -06:00
										 |  |  |             $finder = new PhpExecutableFinder(); | 
					
						
							|  |  |  |             $phpPath = $finder->find(false) ?: PHP_BINARY; | 
					
						
							|  |  |  |             if (!$phpPath) { | 
					
						
							|  |  |  |                 throw new RuntimeException('Unable to locate PHP CLI to start safe upgrade worker.'); | 
					
						
							|  |  |  |             } | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |             $gravPath = Utils::isWindows() | 
					
						
							|  |  |  |                 ? GRAV_ROOT . '\\bin\\grav' | 
					
						
							|  |  |  |                 : GRAV_ROOT . '/bin/grav'; | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |             if (!is_file($gravPath)) { | 
					
						
							|  |  |  |                 throw new RuntimeException('Unable to locate Grav CLI binary.'); | 
					
						
							|  |  |  |             } | 
					
						
							| 
									
										
										
										
											2025-10-16 17:31:57 -06:00
										 |  |  | 
 | 
					
						
							|  |  |  |             if (Utils::isWindows()) { | 
					
						
							|  |  |  |                 $commandLine = sprintf( | 
					
						
							|  |  |  |                     'start /B "" %s %s safe-upgrade:run --job=%s >> %s 2>&1', | 
					
						
							| 
									
										
										
										
											2025-10-16 17:59:05 -06:00
										 |  |  |                     escapeshellarg($phpPath), | 
					
						
							|  |  |  |                     escapeshellarg($gravPath), | 
					
						
							|  |  |  |                     escapeshellarg($jobId), | 
					
						
							|  |  |  |                     escapeshellarg($logPath) | 
					
						
							| 
									
										
										
										
											2025-10-16 17:31:57 -06:00
										 |  |  |                 ); | 
					
						
							|  |  |  |             } else { | 
					
						
							|  |  |  |                 $commandLine = sprintf( | 
					
						
							| 
									
										
										
										
											2025-10-16 17:59:05 -06:00
										 |  |  |                     'nohup %s %s safe-upgrade:run --job=%s >> %s 2>&1 &', | 
					
						
							|  |  |  |                     escapeshellarg($phpPath), | 
					
						
							|  |  |  |                     escapeshellarg($gravPath), | 
					
						
							|  |  |  |                     escapeshellarg($jobId), | 
					
						
							|  |  |  |                     escapeshellarg($logPath) | 
					
						
							| 
									
										
										
										
											2025-10-16 17:31:57 -06:00
										 |  |  |                 ); | 
					
						
							|  |  |  |             } | 
					
						
							|  |  |  | 
 | 
					
						
							| 
									
										
										
										
											2025-10-16 17:59:05 -06:00
										 |  |  |             try { | 
					
						
							|  |  |  |                 file_put_contents($logPath, '[' . gmdate('c') . "] Command: {$commandLine}\n", FILE_APPEND); | 
					
						
							|  |  |  |             } catch (Throwable $e) { | 
					
						
							|  |  |  |                 // ignore log write failures
 | 
					
						
							| 
									
										
										
										
											2025-10-16 17:31:57 -06:00
										 |  |  |             } | 
					
						
							|  |  |  | 
 | 
					
						
							| 
									
										
										
										
											2025-10-16 17:59:05 -06:00
										 |  |  |             $this->log(sprintf('Spawn command for job %s: %s', $jobId, $commandLine), 'debug'); | 
					
						
							| 
									
										
										
										
											2025-10-16 17:31:57 -06:00
										 |  |  | 
 | 
					
						
							| 
									
										
										
										
											2025-10-16 17:59:05 -06:00
										 |  |  |             $process = Process::fromShellCommandline($commandLine, GRAV_ROOT, null, null, 3); | 
					
						
							|  |  |  |             $process->disableOutput(); | 
					
						
							|  |  |  |             $process->run(); | 
					
						
							| 
									
										
										
										
											2025-10-16 17:31:57 -06:00
										 |  |  |         } catch (Throwable $e) { | 
					
						
							|  |  |  |             $message = $e->getMessage(); | 
					
						
							|  |  |  |             $this->writeManifest([ | 
					
						
							|  |  |  |                 'status' => 'error', | 
					
						
							|  |  |  |                 'error' => $message, | 
					
						
							|  |  |  |             ]); | 
					
						
							|  |  |  |             $this->setProgress('error', $message, null, ['job_id' => $jobId]); | 
					
						
							|  |  |  |             $this->clearJobContext(); | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |             return [ | 
					
						
							|  |  |  |                 'status' => 'error', | 
					
						
							|  |  |  |                 'message' => $message, | 
					
						
							|  |  |  |             ]; | 
					
						
							|  |  |  |         } | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |         $this->writeManifest([ | 
					
						
							|  |  |  |             'status' => 'running', | 
					
						
							|  |  |  |             'started_at' => time(), | 
					
						
							|  |  |  |         ]); | 
					
						
							|  |  |  | 
 | 
					
						
							| 
									
										
										
										
											2025-10-16 17:59:05 -06:00
										 |  |  |         $this->log(sprintf('Safe upgrade job %s worker started', $jobId)); | 
					
						
							|  |  |  | 
 | 
					
						
							| 
									
										
										
										
											2025-10-16 17:31:57 -06:00
										 |  |  |         return [ | 
					
						
							|  |  |  |             'status' => 'queued', | 
					
						
							|  |  |  |             'job_id' => $jobId, | 
					
						
							|  |  |  |             'log' => $logPath, | 
					
						
							|  |  |  |             'progress' => $this->getProgress(), | 
					
						
							|  |  |  |             'job' => $this->readManifest(), | 
					
						
							| 
									
										
										
										
											2025-10-17 10:02:22 -06:00
										 |  |  |             'context' => $this->buildStatusContext(), | 
					
						
							| 
									
										
										
										
											2025-10-16 17:31:57 -06:00
										 |  |  |         ]; | 
					
						
							| 
									
										
										
										
											2025-10-16 10:59:50 -06:00
										 |  |  |     } | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |     /** | 
					
						
							|  |  |  |      * Execute preflight checks and return upgrade readiness data. | 
					
						
							|  |  |  |      * | 
					
						
							|  |  |  |      * @param bool $force | 
					
						
							|  |  |  |      * @return array | 
					
						
							|  |  |  |      */ | 
					
						
							|  |  |  |     public function preflight(bool $force = false): array | 
					
						
							|  |  |  |     { | 
					
						
							|  |  |  |         $this->resetProgress(); | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |         if (!class_exists(ZipArchive::class)) { | 
					
						
							|  |  |  |             return [ | 
					
						
							|  |  |  |                 'status' => 'error', | 
					
						
							|  |  |  |                 'message' => 'php-zip extension needs to be enabled.', | 
					
						
							|  |  |  |             ]; | 
					
						
							|  |  |  |         } | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |         try { | 
					
						
							|  |  |  |             $this->upgrader = new Upgrader($force); | 
					
						
							|  |  |  |         } catch (Throwable $e) { | 
					
						
							|  |  |  |             return [ | 
					
						
							|  |  |  |                 'status' => 'error', | 
					
						
							|  |  |  |                 'message' => $e->getMessage(), | 
					
						
							|  |  |  |             ]; | 
					
						
							|  |  |  |         } | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |         $local = $this->upgrader->getLocalVersion(); | 
					
						
							|  |  |  |         $remote = $this->upgrader->getRemoteVersion(); | 
					
						
							|  |  |  |         $releaseDate = $this->upgrader->getReleaseDate(); | 
					
						
							|  |  |  |         $assets = $this->upgrader->getAssets(); | 
					
						
							| 
									
										
										
										
											2025-10-16 20:57:46 -06:00
										 |  |  |         $package = $this->resolveAsset($assets, 'grav-update'); | 
					
						
							| 
									
										
										
										
											2025-10-16 10:59:50 -06:00
										 |  |  | 
 | 
					
						
							|  |  |  |         $payload = [ | 
					
						
							|  |  |  |             'status' => 'ready', | 
					
						
							|  |  |  |             'version' => [ | 
					
						
							|  |  |  |                 'local' => $local, | 
					
						
							|  |  |  |                 'remote' => $remote, | 
					
						
							|  |  |  |                 'release_date' => $releaseDate ? strftime('%c', strtotime($releaseDate)) : null, | 
					
						
							|  |  |  |                 'package_size' => $package['size'] ?? null, | 
					
						
							|  |  |  |             ], | 
					
						
							|  |  |  |             'upgrade_available' => $this->upgrader->isUpgradable(), | 
					
						
							|  |  |  |             'requirements' => [ | 
					
						
							|  |  |  |                 'meets' => $this->upgrader->meetsRequirements(), | 
					
						
							|  |  |  |                 'minimum_php' => $this->upgrader->minPHPVersion(), | 
					
						
							|  |  |  |             ], | 
					
						
							|  |  |  |             'symlinked' => false, | 
					
						
							|  |  |  |             'safe_upgrade' => [ | 
					
						
							|  |  |  |                 'enabled' => $this->isSafeUpgradeEnabled(), | 
					
						
							|  |  |  |                 'staging_ready' => true, | 
					
						
							|  |  |  |                 'error' => null, | 
					
						
							|  |  |  |             ], | 
					
						
							|  |  |  |             'preflight' => [ | 
					
						
							|  |  |  |                 'warnings' => [], | 
					
						
							|  |  |  |                 'plugins_pending' => [], | 
					
						
							|  |  |  |                 'psr_log_conflicts' => [], | 
					
						
							|  |  |  |                 'monolog_conflicts' => [], | 
					
						
							|  |  |  |             ], | 
					
						
							|  |  |  |         ]; | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |         Installer::isValidDestination(GRAV_ROOT . '/system'); | 
					
						
							|  |  |  |         $payload['symlinked'] = Installer::IS_LINK === Installer::lastErrorCode(); | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |         try { | 
					
						
							|  |  |  |             $safeUpgrade = $this->getSafeUpgradeService(); | 
					
						
							|  |  |  |             $payload['preflight'] = $safeUpgrade->preflight(); | 
					
						
							|  |  |  |         } catch (RuntimeException $e) { | 
					
						
							|  |  |  |             $payload['safe_upgrade']['staging_ready'] = false; | 
					
						
							|  |  |  |             $payload['safe_upgrade']['error'] = $e->getMessage(); | 
					
						
							|  |  |  |         } catch (Throwable $e) { | 
					
						
							|  |  |  |             $payload['safe_upgrade']['staging_ready'] = false; | 
					
						
							|  |  |  |             $payload['safe_upgrade']['error'] = $e->getMessage(); | 
					
						
							|  |  |  |         } | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |         return $payload; | 
					
						
							|  |  |  |     } | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |     /** | 
					
						
							|  |  |  |      * Run the safe upgrade lifecycle. | 
					
						
							|  |  |  |      * | 
					
						
							|  |  |  |      * @param array $options | 
					
						
							|  |  |  |      * @return array | 
					
						
							|  |  |  |      */ | 
					
						
							|  |  |  |     public function run(array $options = []): array | 
					
						
							|  |  |  |     { | 
					
						
							|  |  |  |         $force = (bool)($options['force'] ?? false); | 
					
						
							|  |  |  |         $timeout = (int)($options['timeout'] ?? 30); | 
					
						
							|  |  |  |         $overwrite = (bool)($options['overwrite'] ?? false); | 
					
						
							|  |  |  |         $decisions = is_array($options['decisions'] ?? null) ? $options['decisions'] : []; | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |         $this->setProgress('initializing', 'Preparing safe upgrade...', null); | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |         if (!class_exists(ZipArchive::class)) { | 
					
						
							|  |  |  |             return $this->errorResult('php-zip extension needs to be enabled.'); | 
					
						
							|  |  |  |         } | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |         try { | 
					
						
							|  |  |  |             $this->upgrader = new Upgrader($force); | 
					
						
							|  |  |  |         } catch (Throwable $e) { | 
					
						
							|  |  |  |             return $this->errorResult($e->getMessage()); | 
					
						
							|  |  |  |         } | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |         $safeUpgradeEnabled = $this->isSafeUpgradeEnabled(); | 
					
						
							|  |  |  |         if (!$safeUpgradeEnabled) { | 
					
						
							|  |  |  |             return $this->errorResult('Safe upgrade is disabled in configuration.'); | 
					
						
							|  |  |  |         } | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |         $remoteVersion = $this->upgrader->getRemoteVersion(); | 
					
						
							|  |  |  |         $localVersion = $this->upgrader->getLocalVersion(); | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |         if (!$this->upgrader->meetsRequirements()) { | 
					
						
							|  |  |  |             $minPhp = $this->upgrader->minPHPVersion(); | 
					
						
							|  |  |  |             $message = sprintf( | 
					
						
							|  |  |  |                 'Grav requires PHP %s, current PHP version is %s.', | 
					
						
							|  |  |  |                 $minPhp, | 
					
						
							|  |  |  |                 PHP_VERSION | 
					
						
							|  |  |  |             ); | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |             return $this->errorResult($message, [ | 
					
						
							|  |  |  |                 'minimum_php' => $minPhp, | 
					
						
							|  |  |  |                 'current_php' => PHP_VERSION, | 
					
						
							|  |  |  |             ]); | 
					
						
							|  |  |  |         } | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |         if (!$overwrite && !$this->upgrader->isUpgradable()) { | 
					
						
							|  |  |  |             $result = $this->runFinalizeIfNeeded($localVersion); | 
					
						
							|  |  |  |             if ($result) { | 
					
						
							|  |  |  |                 return $result; | 
					
						
							|  |  |  |             } | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |             return [ | 
					
						
							|  |  |  |                 'status' => 'noop', | 
					
						
							|  |  |  |                 'version' => $localVersion, | 
					
						
							|  |  |  |                 'message' => 'Grav is already up to date.', | 
					
						
							| 
									
										
										
										
											2025-10-17 10:02:22 -06:00
										 |  |  |                 'context' => $this->buildStatusContext(), | 
					
						
							| 
									
										
										
										
											2025-10-16 10:59:50 -06:00
										 |  |  |             ]; | 
					
						
							|  |  |  |         } | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |         Installer::isValidDestination(GRAV_ROOT . '/system'); | 
					
						
							|  |  |  |         if (Installer::IS_LINK === Installer::lastErrorCode()) { | 
					
						
							|  |  |  |             return $this->errorResult('Grav installation is symlinked, cannot perform upgrade.'); | 
					
						
							|  |  |  |         } | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |         try { | 
					
						
							|  |  |  |             $safeUpgrade = $this->getSafeUpgradeService(); | 
					
						
							|  |  |  |         } catch (Throwable $e) { | 
					
						
							|  |  |  |             return $this->errorResult($e->getMessage()); | 
					
						
							|  |  |  |         } | 
					
						
							|  |  |  | 
 | 
					
						
							| 
									
										
										
										
											2025-10-16 15:47:33 -06:00
										 |  |  |         if (defined('Monolog\\Logger::API') && \Monolog\Logger::API < 3) { | 
					
						
							|  |  |  |             class_exists(\Monolog\Logger::class); | 
					
						
							|  |  |  |             class_exists(\Monolog\Handler\AbstractHandler::class); | 
					
						
							|  |  |  |             class_exists(\Monolog\Handler\AbstractProcessingHandler::class); | 
					
						
							|  |  |  |             class_exists(\Monolog\Handler\StreamHandler::class); | 
					
						
							|  |  |  |             class_exists(\Monolog\Formatter\LineFormatter::class); | 
					
						
							|  |  |  |         } | 
					
						
							|  |  |  | 
 | 
					
						
							| 
									
										
										
										
											2025-10-16 10:59:50 -06:00
										 |  |  |         $preflight = $safeUpgrade->preflight(); | 
					
						
							|  |  |  |         if (!empty($preflight['plugins_pending'])) { | 
					
						
							|  |  |  |             return $this->errorResult('Plugins and/or themes require updates before upgrading Grav.', [ | 
					
						
							|  |  |  |                 'plugins_pending' => $preflight['plugins_pending'], | 
					
						
							|  |  |  |             ]); | 
					
						
							|  |  |  |         } | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |         $conflictError = $this->handleConflictDecisions($preflight, $decisions); | 
					
						
							|  |  |  |         if ($conflictError !== null) { | 
					
						
							|  |  |  |             return $conflictError; | 
					
						
							|  |  |  |         } | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |         $assets = $this->upgrader->getAssets(); | 
					
						
							| 
									
										
										
										
											2025-10-16 20:57:46 -06:00
										 |  |  |         $package = $this->resolveAsset($assets, 'grav-update'); | 
					
						
							| 
									
										
										
										
											2025-10-16 10:59:50 -06:00
										 |  |  |         if (!$package) { | 
					
						
							|  |  |  |             return $this->errorResult('Unable to locate Grav update package information.'); | 
					
						
							|  |  |  |         } | 
					
						
							|  |  |  | 
 | 
					
						
							| 
									
										
										
										
											2025-10-16 13:49:53 -06:00
										 |  |  |         if ($this->recovery && method_exists($this->recovery, 'markUpgradeWindow')) { | 
					
						
							|  |  |  |             // Newer Grav exposes upgrade window helpers; guard for older cores.
 | 
					
						
							|  |  |  |             $this->recovery->markUpgradeWindow('core-upgrade', [ | 
					
						
							|  |  |  |                 'scope' => 'core', | 
					
						
							|  |  |  |                 'target_version' => $remoteVersion, | 
					
						
							|  |  |  |             ]); | 
					
						
							|  |  |  |         } | 
					
						
							| 
									
										
										
										
											2025-10-16 10:59:50 -06:00
										 |  |  | 
 | 
					
						
							|  |  |  |         try { | 
					
						
							|  |  |  |             $file = $this->download($package, $timeout); | 
					
						
							|  |  |  |             $this->setProgress('installing', 'Installing update...', null); | 
					
						
							|  |  |  |             $this->performInstall($file); | 
					
						
							|  |  |  |         } catch (Throwable $e) { | 
					
						
							|  |  |  |             $this->setProgress('error', $e->getMessage(), null); | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |             return $this->errorResult($e->getMessage()); | 
					
						
							|  |  |  |         } finally { | 
					
						
							|  |  |  |             if ($this->tmp && is_dir($this->tmp)) { | 
					
						
							|  |  |  |                 Folder::delete($this->tmp); | 
					
						
							|  |  |  |             } | 
					
						
							|  |  |  |             $this->tmp = null; | 
					
						
							|  |  |  |         } | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |         $this->setProgress('finalizing', 'Finalizing upgrade...', 100); | 
					
						
							|  |  |  |         $safeUpgrade->clearRecoveryFlag(); | 
					
						
							| 
									
										
										
										
											2025-10-16 13:49:53 -06:00
										 |  |  |         if ($this->recovery && method_exists($this->recovery, 'closeUpgradeWindow')) { | 
					
						
							|  |  |  |             $this->recovery->closeUpgradeWindow(); | 
					
						
							|  |  |  |         } | 
					
						
							| 
									
										
										
										
											2025-10-16 10:59:50 -06:00
										 |  |  | 
 | 
					
						
							| 
									
										
										
										
											2025-10-16 18:16:27 -06:00
										 |  |  |         $this->ensureExecutablePermissions(); | 
					
						
							|  |  |  | 
 | 
					
						
							| 
									
										
										
										
											2025-10-16 10:59:50 -06:00
										 |  |  |         $manifest = $this->resolveLatestManifest(); | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |         $this->setProgress('complete', 'Upgrade complete.', 100, [ | 
					
						
							|  |  |  |             'target_version' => $remoteVersion, | 
					
						
							|  |  |  |             'manifest' => $manifest, | 
					
						
							|  |  |  |         ]); | 
					
						
							|  |  |  | 
 | 
					
						
							| 
									
										
										
										
											2025-10-16 17:31:57 -06:00
										 |  |  |         if ($this->jobManifestPath) { | 
					
						
							|  |  |  |             $this->updateJob([ | 
					
						
							|  |  |  |                 'result' => [ | 
					
						
							|  |  |  |                     'status' => 'success', | 
					
						
							|  |  |  |                     'version' => $remoteVersion, | 
					
						
							|  |  |  |                     'previous_version' => $localVersion, | 
					
						
							|  |  |  |                     'manifest' => $manifest, | 
					
						
							|  |  |  |                 ], | 
					
						
							|  |  |  |             ]); | 
					
						
							|  |  |  |         } | 
					
						
							|  |  |  | 
 | 
					
						
							| 
									
										
										
										
											2025-10-16 10:59:50 -06:00
										 |  |  |         return [ | 
					
						
							|  |  |  |             'status' => 'success', | 
					
						
							|  |  |  |             'version' => $remoteVersion, | 
					
						
							|  |  |  |             'manifest' => $manifest, | 
					
						
							|  |  |  |             'previous_version' => $localVersion, | 
					
						
							| 
									
										
										
										
											2025-10-17 10:02:22 -06:00
										 |  |  |             'context' => $this->buildStatusContext(), | 
					
						
							| 
									
										
										
										
											2025-10-16 10:59:50 -06:00
										 |  |  |         ]; | 
					
						
							|  |  |  |     } | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |     /** | 
					
						
							|  |  |  |      * Retrieve current progress payload. | 
					
						
							|  |  |  |      * | 
					
						
							|  |  |  |      * @return array | 
					
						
							|  |  |  |      */ | 
					
						
							|  |  |  |     public function getProgress(): array | 
					
						
							|  |  |  |     { | 
					
						
							|  |  |  |         if (!is_file($this->progressPath)) { | 
					
						
							|  |  |  |             return [ | 
					
						
							|  |  |  |                 'stage' => 'idle', | 
					
						
							|  |  |  |                 'message' => '', | 
					
						
							|  |  |  |                 'percent' => null, | 
					
						
							|  |  |  |                 'timestamp' => time(), | 
					
						
							|  |  |  |             ]; | 
					
						
							|  |  |  |         } | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |         $decoded = json_decode((string)file_get_contents($this->progressPath), true); | 
					
						
							|  |  |  |         if (!is_array($decoded)) { | 
					
						
							|  |  |  |             return [ | 
					
						
							|  |  |  |                 'stage' => 'idle', | 
					
						
							|  |  |  |                 'message' => '', | 
					
						
							|  |  |  |                 'percent' => null, | 
					
						
							|  |  |  |                 'timestamp' => time(), | 
					
						
							|  |  |  |             ]; | 
					
						
							|  |  |  |         } | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |         return $decoded; | 
					
						
							|  |  |  |     } | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |     /** | 
					
						
							|  |  |  |      * Reset progress file to idle state. | 
					
						
							|  |  |  |      * | 
					
						
							|  |  |  |      * @return void | 
					
						
							|  |  |  |      */ | 
					
						
							|  |  |  |     public function resetProgress(): void | 
					
						
							|  |  |  |     { | 
					
						
							|  |  |  |         $this->setProgress('idle', '', null); | 
					
						
							|  |  |  |     } | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |     /** | 
					
						
							|  |  |  |      * @return SafeUpgradeService | 
					
						
							|  |  |  |      */ | 
					
						
							|  |  |  |     protected function getSafeUpgradeService(): SafeUpgradeService | 
					
						
							|  |  |  |     { | 
					
						
							|  |  |  |         if ($this->safeUpgrade instanceof SafeUpgradeService) { | 
					
						
							|  |  |  |             return $this->safeUpgrade; | 
					
						
							|  |  |  |         } | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |         $config = null; | 
					
						
							|  |  |  |         try { | 
					
						
							|  |  |  |             $config = $this->grav['config'] ?? null; | 
					
						
							|  |  |  |         } catch (Throwable $e) { | 
					
						
							|  |  |  |             $config = null; | 
					
						
							|  |  |  |         } | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |         $stagingRoot = $config ? $config->get('system.updates.staging_root') : null; | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |         $this->safeUpgrade = new SafeUpgradeService([ | 
					
						
							|  |  |  |             'staging_root' => $stagingRoot, | 
					
						
							|  |  |  |         ]); | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |         return $this->safeUpgrade; | 
					
						
							|  |  |  |     } | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |     /** | 
					
						
							|  |  |  |      * @return bool | 
					
						
							|  |  |  |      */ | 
					
						
							|  |  |  |     protected function isSafeUpgradeEnabled(): bool | 
					
						
							|  |  |  |     { | 
					
						
							|  |  |  |         try { | 
					
						
							|  |  |  |             $config = $this->grav['config'] ?? null; | 
					
						
							|  |  |  |             if ($config === null) { | 
					
						
							|  |  |  |                 return true; | 
					
						
							|  |  |  |             } | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |             return (bool)$config->get('system.updates.safe_upgrade', true); | 
					
						
							|  |  |  |         } catch (Throwable $e) { | 
					
						
							|  |  |  |             return true; | 
					
						
							|  |  |  |         } | 
					
						
							|  |  |  |     } | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |     /** | 
					
						
							|  |  |  |      * @param array $preflight | 
					
						
							|  |  |  |      * @param array $decisions | 
					
						
							|  |  |  |      * @return array|null | 
					
						
							|  |  |  |      */ | 
					
						
							|  |  |  |     protected function handleConflictDecisions(array $preflight, array $decisions): ?array | 
					
						
							|  |  |  |     { | 
					
						
							|  |  |  |         $psrConflicts = $preflight['psr_log_conflicts'] ?? []; | 
					
						
							|  |  |  |         $monologConflicts = $preflight['monolog_conflicts'] ?? []; | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |         if ($psrConflicts) { | 
					
						
							|  |  |  |             $decision = $decisions['psr_log'] ?? null; | 
					
						
							|  |  |  |             $error = $this->applyConflictDecision( | 
					
						
							|  |  |  |                 $decision, | 
					
						
							|  |  |  |                 $psrConflicts, | 
					
						
							|  |  |  |                 'Disabled before upgrade because of psr/log conflict' | 
					
						
							|  |  |  |             ); | 
					
						
							|  |  |  |             if ($error !== null) { | 
					
						
							|  |  |  |                 return $error; | 
					
						
							|  |  |  |             } | 
					
						
							|  |  |  |         } | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |         if ($monologConflicts) { | 
					
						
							|  |  |  |             $decision = $decisions['monolog'] ?? null; | 
					
						
							|  |  |  |             $error = $this->applyConflictDecision( | 
					
						
							|  |  |  |                 $decision, | 
					
						
							|  |  |  |                 $monologConflicts, | 
					
						
							|  |  |  |                 'Disabled before upgrade because of Monolog API conflict' | 
					
						
							|  |  |  |             ); | 
					
						
							|  |  |  |             if ($error !== null) { | 
					
						
							|  |  |  |                 return $error; | 
					
						
							|  |  |  |             } | 
					
						
							|  |  |  |         } | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |         return null; | 
					
						
							|  |  |  |     } | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |     /** | 
					
						
							|  |  |  |      * @param string|null $decision | 
					
						
							|  |  |  |      * @param array $conflicts | 
					
						
							|  |  |  |      * @param string $disableNote | 
					
						
							|  |  |  |      * @return array|null | 
					
						
							|  |  |  |      */ | 
					
						
							|  |  |  |     protected function applyConflictDecision(?string $decision, array $conflicts, string $disableNote): ?array | 
					
						
							|  |  |  |     { | 
					
						
							|  |  |  |         if (!$conflicts) { | 
					
						
							|  |  |  |             return null; | 
					
						
							|  |  |  |         } | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |         $choice = $decision ?: 'abort'; | 
					
						
							|  |  |  |         if ($choice === 'abort') { | 
					
						
							|  |  |  |             return $this->errorResult('Upgrade aborted due to unresolved conflicts.', [ | 
					
						
							|  |  |  |                 'conflicts' => $conflicts, | 
					
						
							|  |  |  |             ]); | 
					
						
							|  |  |  |         } | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |         if ($choice === 'disable') { | 
					
						
							|  |  |  |             foreach (array_keys($conflicts) as $slug) { | 
					
						
							|  |  |  |                 $this->recovery->disablePlugin($slug, ['message' => $disableNote]); | 
					
						
							|  |  |  |             } | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |             return null; | 
					
						
							|  |  |  |         } | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |         if ($choice === 'continue') { | 
					
						
							|  |  |  |             return null; | 
					
						
							|  |  |  |         } | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |         return $this->errorResult('Unknown conflict decision provided.', [ | 
					
						
							|  |  |  |             'conflicts' => $conflicts, | 
					
						
							|  |  |  |         ]); | 
					
						
							|  |  |  |     } | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |     /** | 
					
						
							|  |  |  |      * @param array $package | 
					
						
							|  |  |  |      * @param int $timeout | 
					
						
							|  |  |  |      * @return string | 
					
						
							|  |  |  |      */ | 
					
						
							|  |  |  |     protected function download(array $package, int $timeout): string | 
					
						
							|  |  |  |     { | 
					
						
							|  |  |  |         $tmpDir = $this->grav['locator']->findResource('tmp://', true, true); | 
					
						
							|  |  |  |         $this->tmp = $tmpDir . '/grav-update-' . uniqid('', false); | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |         Folder::create($this->tmp); | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |         $this->setProgress('downloading', 'Downloading update...', 0, [ | 
					
						
							|  |  |  |             'package_size' => $package['size'] ?? null, | 
					
						
							|  |  |  |         ]); | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |         $options = [ | 
					
						
							|  |  |  |             'timeout' => max(0, $timeout), | 
					
						
							|  |  |  |         ]; | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |         $progressCallback = function (array $progress): void { | 
					
						
							|  |  |  |             $this->setProgress('downloading', 'Downloading update...', $progress['percent'], [ | 
					
						
							|  |  |  |                 'transferred' => $progress['transferred'], | 
					
						
							|  |  |  |                 'filesize' => $progress['filesize'], | 
					
						
							|  |  |  |             ]); | 
					
						
							|  |  |  |         }; | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |         $output = Response::get($package['download'], $options, $progressCallback); | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |         $this->setProgress('downloading', 'Download complete.', 100); | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |         $target = $this->tmp . '/' . $package['name']; | 
					
						
							|  |  |  |         file_put_contents($target, $output); | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |         return $target; | 
					
						
							|  |  |  |     } | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |     /** | 
					
						
							|  |  |  |      * @param string $zip | 
					
						
							|  |  |  |      * @return void | 
					
						
							|  |  |  |      */ | 
					
						
							|  |  |  |     protected function performInstall(string $zip): void | 
					
						
							|  |  |  |     { | 
					
						
							|  |  |  |         $folder = Installer::unZip($zip, $this->tmp . '/zip'); | 
					
						
							|  |  |  |         if ($folder === false) { | 
					
						
							|  |  |  |             throw new RuntimeException(Installer::lastErrorMsg()); | 
					
						
							|  |  |  |         } | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |         $script = $folder . '/system/install.php'; | 
					
						
							|  |  |  |         if (!file_exists($script)) { | 
					
						
							|  |  |  |             throw new RuntimeException('Downloaded archive is not a valid Grav package.'); | 
					
						
							|  |  |  |         } | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |         $install = include $script; | 
					
						
							|  |  |  |         if (!is_callable($install)) { | 
					
						
							|  |  |  |             throw new RuntimeException('Unable to bootstrap installer from downloaded package.'); | 
					
						
							|  |  |  |         } | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |         try { | 
					
						
							|  |  |  |             $install($zip); | 
					
						
							|  |  |  |         } catch (Throwable $e) { | 
					
						
							|  |  |  |             throw new RuntimeException($e->getMessage(), 0, $e); | 
					
						
							|  |  |  |         } | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |         $errorCode = Installer::lastErrorCode(); | 
					
						
							|  |  |  |         if ($errorCode) { | 
					
						
							|  |  |  |             throw new RuntimeException(Installer::lastErrorMsg()); | 
					
						
							|  |  |  |         } | 
					
						
							|  |  |  |     } | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |     /** | 
					
						
							|  |  |  |      * Attempt to run finalize scripts if Grav is already up to date but schema mismatched. | 
					
						
							|  |  |  |      * | 
					
						
							|  |  |  |      * @param string $localVersion | 
					
						
							|  |  |  |      * @return array|null | 
					
						
							|  |  |  |      */ | 
					
						
							|  |  |  |     protected function runFinalizeIfNeeded(string $localVersion): ?array | 
					
						
							|  |  |  |     { | 
					
						
							|  |  |  |         $config = $this->grav['config']; | 
					
						
							|  |  |  |         $schema = $config->get('versions.core.grav.schema'); | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |         if ($schema !== GRAV_SCHEMA && version_compare((string)$schema, GRAV_SCHEMA, '<')) { | 
					
						
							|  |  |  |             $this->setProgress('finalizing', 'Running post-install scripts...', null); | 
					
						
							|  |  |  |             Install::instance()->finalize(); | 
					
						
							|  |  |  |             $this->setProgress('complete', 'Post-install scripts executed.', 100, [ | 
					
						
							|  |  |  |                 'target_version' => $localVersion, | 
					
						
							|  |  |  |             ]); | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |             return [ | 
					
						
							|  |  |  |                 'status' => 'finalized', | 
					
						
							|  |  |  |                 'version' => $localVersion, | 
					
						
							|  |  |  |                 'message' => 'Post-install scripts completed.', | 
					
						
							| 
									
										
										
										
											2025-10-17 10:02:22 -06:00
										 |  |  |                 'context' => $this->buildStatusContext(), | 
					
						
							| 
									
										
										
										
											2025-10-16 10:59:50 -06:00
										 |  |  |             ]; | 
					
						
							|  |  |  |         } | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |         return null; | 
					
						
							|  |  |  |     } | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |     /** | 
					
						
							|  |  |  |      * Fetch most recent safe upgrade manifest if available. | 
					
						
							|  |  |  |      * | 
					
						
							|  |  |  |      * @return array|null | 
					
						
							|  |  |  |      */ | 
					
						
							|  |  |  |     protected function resolveLatestManifest(): ?array | 
					
						
							|  |  |  |     { | 
					
						
							|  |  |  |         $store = $this->grav['locator']->findResource('user://data/upgrades', false); | 
					
						
							|  |  |  |         if (!$store || !is_dir($store)) { | 
					
						
							|  |  |  |             return null; | 
					
						
							|  |  |  |         } | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |         $files = glob($store . '/*.json') ?: []; | 
					
						
							|  |  |  |         if (!$files) { | 
					
						
							|  |  |  |             return null; | 
					
						
							|  |  |  |         } | 
					
						
							|  |  |  |         rsort($files); | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |         $latest = $files[0]; | 
					
						
							|  |  |  |         $decoded = json_decode(file_get_contents($latest), true); | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |         return is_array($decoded) ? $decoded : null; | 
					
						
							|  |  |  |     } | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |     /** | 
					
						
							|  |  |  |      * Persist progress payload. | 
					
						
							|  |  |  |      * | 
					
						
							|  |  |  |      * @param string $stage | 
					
						
							|  |  |  |      * @param string $message | 
					
						
							|  |  |  |      * @param int|null $percent | 
					
						
							|  |  |  |      * @param array $extra | 
					
						
							|  |  |  |      * @return void | 
					
						
							|  |  |  |      */ | 
					
						
							|  |  |  |     protected function setProgress(string $stage, string $message, ?int $percent = null, array $extra = []): void | 
					
						
							|  |  |  |     { | 
					
						
							|  |  |  |         $payload = [ | 
					
						
							|  |  |  |             'stage' => $stage, | 
					
						
							|  |  |  |             'message' => $message, | 
					
						
							|  |  |  |             'percent' => $percent, | 
					
						
							|  |  |  |             'timestamp' => time(), | 
					
						
							|  |  |  |         ] + $extra; | 
					
						
							|  |  |  | 
 | 
					
						
							| 
									
										
										
										
											2025-10-16 17:31:57 -06:00
										 |  |  |         if ($this->jobId) { | 
					
						
							|  |  |  |             $payload['job_id'] = $this->jobId; | 
					
						
							|  |  |  |         } | 
					
						
							|  |  |  | 
 | 
					
						
							| 
									
										
										
										
											2025-10-16 10:59:50 -06:00
										 |  |  |         try { | 
					
						
							|  |  |  |             Folder::create(dirname($this->progressPath)); | 
					
						
							|  |  |  |             file_put_contents($this->progressPath, json_encode($payload, JSON_PRETTY_PRINT)); | 
					
						
							| 
									
										
										
										
											2025-10-16 17:59:05 -06:00
										 |  |  |             if ($this->jobId) { | 
					
						
							|  |  |  |                 $this->log(sprintf('Job %s stage -> %s (%s)', $this->jobId, $stage, $message), $stage === 'error' ? 'error' : 'debug'); | 
					
						
							|  |  |  |             } | 
					
						
							| 
									
										
										
										
											2025-10-16 10:59:50 -06:00
										 |  |  |         } catch (Throwable $e) { | 
					
						
							|  |  |  |             // ignore write failures
 | 
					
						
							|  |  |  |         } | 
					
						
							| 
									
										
										
										
											2025-10-16 17:31:57 -06:00
										 |  |  | 
 | 
					
						
							|  |  |  |         if ($this->jobManifestPath) { | 
					
						
							|  |  |  |             $status = 'running'; | 
					
						
							|  |  |  |             if ($stage === 'error') { | 
					
						
							|  |  |  |                 $status = 'error'; | 
					
						
							|  |  |  |             } elseif ($stage === 'complete') { | 
					
						
							|  |  |  |                 $status = 'success'; | 
					
						
							|  |  |  |             } | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |             $manifest = [ | 
					
						
							|  |  |  |                 'status' => $status, | 
					
						
							|  |  |  |                 'progress' => $payload, | 
					
						
							|  |  |  |             ]; | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |             if ($status === 'success') { | 
					
						
							|  |  |  |                 $manifest['completed_at'] = time(); | 
					
						
							|  |  |  |             } | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |             if ($status === 'error' && isset($extra['message'])) { | 
					
						
							|  |  |  |                 $manifest['error'] = $extra['message']; | 
					
						
							|  |  |  |             } | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |             $this->writeManifest($manifest); | 
					
						
							|  |  |  |         } | 
					
						
							| 
									
										
										
										
											2025-10-16 10:59:50 -06:00
										 |  |  |     } | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |     /** | 
					
						
							|  |  |  |      * Helper for building an error result payload. | 
					
						
							|  |  |  |      * | 
					
						
							|  |  |  |      * @param string $message | 
					
						
							|  |  |  |      * @param array $extra | 
					
						
							|  |  |  |      * @return array | 
					
						
							|  |  |  |      */ | 
					
						
							|  |  |  |     protected function errorResult(string $message, array $extra = []): array | 
					
						
							|  |  |  |     { | 
					
						
							| 
									
										
										
										
											2025-10-16 17:31:57 -06:00
										 |  |  |         $extraWithMessage = ['message' => $message] + $extra; | 
					
						
							|  |  |  |         $this->setProgress('error', $message, null, $extraWithMessage); | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |         if ($this->jobManifestPath) { | 
					
						
							|  |  |  |             $this->updateJob([ | 
					
						
							|  |  |  |                 'result' => [ | 
					
						
							|  |  |  |                     'status' => 'error', | 
					
						
							|  |  |  |                     'message' => $message, | 
					
						
							|  |  |  |                     'details' => $extra, | 
					
						
							|  |  |  |                 ], | 
					
						
							| 
									
										
										
										
											2025-10-16 17:59:05 -06:00
										 |  |  |                 'status' => 'error', | 
					
						
							|  |  |  |                 'completed_at' => time(), | 
					
						
							| 
									
										
										
										
											2025-10-16 17:31:57 -06:00
										 |  |  |             ]); | 
					
						
							| 
									
										
										
										
											2025-10-16 17:59:05 -06:00
										 |  |  |             $this->log(sprintf('Safe upgrade job %s failed: %s', $this->jobId ?? 'n/a', $message), 'error'); | 
					
						
							| 
									
										
										
										
											2025-10-16 17:31:57 -06:00
										 |  |  |         } | 
					
						
							| 
									
										
										
										
											2025-10-16 10:59:50 -06:00
										 |  |  | 
 | 
					
						
							|  |  |  |         return [ | 
					
						
							|  |  |  |             'status' => 'error', | 
					
						
							|  |  |  |             'message' => $message, | 
					
						
							| 
									
										
										
										
											2025-10-17 10:02:22 -06:00
										 |  |  |             'context' => $this->buildStatusContext(), | 
					
						
							| 
									
										
										
										
											2025-10-16 10:59:50 -06:00
										 |  |  |         ] + $extra; | 
					
						
							|  |  |  |     } | 
					
						
							| 
									
										
										
										
											2025-10-16 18:16:27 -06:00
										 |  |  | 
 | 
					
						
							| 
									
										
										
										
											2025-10-17 10:02:22 -06:00
										 |  |  |     protected function buildStatusContext(): ?string | 
					
						
							|  |  |  |     { | 
					
						
							|  |  |  |         $context = []; | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |         if ($this->jobManifestPath) { | 
					
						
							|  |  |  |             $context['manifest'] = $this->jobManifestPath; | 
					
						
							|  |  |  |         } | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |         if ($this->progressPath) { | 
					
						
							|  |  |  |             $context['progress'] = $this->progressPath; | 
					
						
							|  |  |  |         } | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |         if (!$context) { | 
					
						
							|  |  |  |             return null; | 
					
						
							|  |  |  |         } | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |         $encoded = json_encode($context); | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |         return $encoded === false ? null : base64_encode($encoded); | 
					
						
							|  |  |  |     } | 
					
						
							|  |  |  | 
 | 
					
						
							| 
									
										
										
										
											2025-10-16 18:16:27 -06:00
										 |  |  |     protected function ensureExecutablePermissions(): void | 
					
						
							|  |  |  |     { | 
					
						
							|  |  |  |         $executables = [ | 
					
						
							|  |  |  |             'bin/grav', | 
					
						
							|  |  |  |             'bin/plugin', | 
					
						
							|  |  |  |             'bin/gpm', | 
					
						
							|  |  |  |             'bin/restore', | 
					
						
							|  |  |  |             'bin/composer.phar' | 
					
						
							|  |  |  |         ]; | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |         foreach ($executables as $relative) { | 
					
						
							|  |  |  |             $path = GRAV_ROOT . '/' . $relative; | 
					
						
							|  |  |  |             if (!is_file($path) || is_link($path)) { | 
					
						
							|  |  |  |                 continue; | 
					
						
							|  |  |  |             } | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |             $perms = @fileperms($path); | 
					
						
							|  |  |  |             $mode = $perms !== false ? ($perms & 0777) : null; | 
					
						
							|  |  |  |             if ($mode !== 0755) { | 
					
						
							|  |  |  |                 @chmod($path, 0755); | 
					
						
							|  |  |  |                 $this->log(sprintf('Adjusted permissions on %s to 0755', $relative), 'debug'); | 
					
						
							|  |  |  |             } | 
					
						
							|  |  |  |         } | 
					
						
							|  |  |  |     } | 
					
						
							| 
									
										
										
										
											2025-10-16 20:57:46 -06:00
										 |  |  | 
 | 
					
						
							|  |  |  |     protected function resolveAsset(array $assets, string $prefix): ?array | 
					
						
							|  |  |  |     { | 
					
						
							|  |  |  |         if (isset($assets[$prefix])) { | 
					
						
							|  |  |  |             return $assets[$prefix]; | 
					
						
							|  |  |  |         } | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |         foreach ($assets as $key => $asset) { | 
					
						
							|  |  |  |             $name = is_array($asset) ? ($asset['name'] ?? '') : ''; | 
					
						
							|  |  |  |             $haystack = $key . ' ' . $name; | 
					
						
							|  |  |  |             if (stripos($haystack, $prefix) === 0 || stripos($haystack, '/' . $prefix) !== false) { | 
					
						
							|  |  |  |                 return $asset; | 
					
						
							|  |  |  |             } | 
					
						
							|  |  |  |         } | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |         return null; | 
					
						
							|  |  |  |     } | 
					
						
							| 
									
										
										
										
											2025-10-16 10:59:50 -06:00
										 |  |  | } |