|null Preflight report captured during the last selfupgrade(). */ protected static ?array $lastPreflightReport = null; /** * Default options for install operations. * * @var array */ protected static array $options = [ 'destination' => GRAV_ROOT, 'overwrite' => true, 'ignore_symlinks' => true, 'skip_invalid' => true, 'install_deps' => false, 'theme' => false, ]; public static function GPM(): GravGPM { if (self::$GPM === null) { self::$GPM = new GravGPM(); } return self::$GPM; } /** * Install one or more packages. * * @param Package[]|string[]|string $packages * @param array $options * @return string|bool */ public static function install($packages, array $options) { $options = array_merge(self::$options, $options); if (!Installer::isGravInstance($options['destination']) || !Installer::isValidDestination($options['destination'], [Installer::EXISTS, Installer::IS_LINK]) ) { return false; } $packages = is_array($packages) ? $packages : [$packages]; $count = count($packages); $packages = array_filter(array_map(static function ($p) { return !is_string($p) ? ($p instanceof Package ? $p : false) : self::GPM()->findPackage($p); }, $packages)); if (!$options['skip_invalid'] && $count !== count($packages)) { return false; } $messages = ''; foreach ($packages as $package) { // Dependency resolution is the caller's responsibility (see // GpmController::install / ::update which use GPM::getDependencies()). // The blueprint `dependencies` structure is a list of // ['name' => slug, 'version' => constraint] entries, not slugs or // Package objects, so it can't be passed back into install(). Installer::isValidDestination($options['destination'] . DS . $package->install_path); if (!$options['overwrite'] && Installer::lastErrorCode() === Installer::EXISTS) { return false; } if (!$options['ignore_symlinks'] && Installer::lastErrorCode() === Installer::IS_LINK) { return false; } $license = Licenses::get($package->slug); $local = static::download($package, $license); Installer::install( $local, $options['destination'], ['install_path' => $package->install_path, 'theme' => $options['theme']] ); Folder::delete(dirname($local)); $errorCode = Installer::lastErrorCode(); if ($errorCode) { throw new \RuntimeException(Installer::lastErrorMsg()); } if (count($packages) === 1) { $message = Installer::getMessage(); if ($message) { return $message; } $messages .= $message; } } Cache::clearCache(); return $messages !== '' ? $messages : true; } /** * Update one or more packages. * * @param Package[]|string[]|string $packages * @param array $options * @return string|bool */ public static function update($packages, array $options) { $options['overwrite'] = true; return static::install($packages, $options); } /** * Uninstall one or more packages. * * @param Package[]|string[]|string $packages * @param array $options * @return string|bool */ public static function uninstall($packages, array $options) { $options = array_merge(self::$options, $options); $packages = (array) $packages; $count = count($packages); $packages = array_filter(array_map(static function ($p) { if (is_string($p)) { $p = strtolower($p); $plugin = self::GPM()->getInstalledPlugin($p); $p = $plugin ?: self::GPM()->getInstalledTheme($p); } return $p instanceof Package ? $p : false; }, $packages)); if (!$options['skip_invalid'] && $count !== count($packages)) { return false; } foreach ($packages as $package) { $location = Grav::instance()['locator']->findResource($package->package_type . '://' . $package->slug); Installer::isValidDestination($location); if (!$options['ignore_symlinks'] && Installer::lastErrorCode() === Installer::IS_LINK) { return false; } Installer::uninstall($location); $errorCode = Installer::lastErrorCode(); if ($errorCode && $errorCode !== Installer::IS_LINK && $errorCode !== Installer::EXISTS) { throw new \RuntimeException(Installer::lastErrorMsg()); } if (count($packages) === 1) { $message = Installer::getMessage(); if ($message) { return $message; } } } Cache::clearCache(); return true; } /** * Install a package directly from a local zip or remote URL. * * @param string $packageFile * @return string|bool */ public static function directInstall(string $packageFile) { if ($packageFile === '') { return 'No package file provided.'; } $tmpDir = static::getTempDir(); $tmpZip = $tmpDir . '/Grav-' . uniqid('', false); if (Response::isRemote($packageFile)) { $zip = GravGPM::downloadPackage($packageFile, $tmpZip); } else { $zip = GravGPM::copyPackage($packageFile, $tmpZip); } if (!file_exists($zip)) { return 'Zip package not found.'; } $tmpSource = $tmpDir . '/Grav-' . uniqid('', false); $extracted = Installer::unZip($zip, $tmpSource); if (!$extracted) { Folder::delete($tmpSource); Folder::delete($tmpZip); return 'Package extraction failed.'; } $type = GravGPM::getPackageType($extracted); if (!$type) { Folder::delete($tmpSource); Folder::delete($tmpZip); return 'Not a valid Grav package.'; } if ($type === 'grav') { Installer::isValidDestination(GRAV_ROOT . '/system'); if (Installer::IS_LINK === Installer::lastErrorCode()) { Folder::delete($tmpSource); Folder::delete($tmpZip); return 'Cannot overwrite symlinks.'; } static::upgradeGrav($zip, $extracted); } else { $name = GravGPM::getPackageName($extracted); if (!$name) { Folder::delete($tmpSource); Folder::delete($tmpZip); return 'Package name could not be determined.'; } $installPath = GravGPM::getInstallPath($type, $name); $isUpdate = file_exists($installPath); Installer::isValidDestination(GRAV_ROOT . DS . $installPath); if (Installer::lastErrorCode() === Installer::IS_LINK) { Folder::delete($tmpSource); Folder::delete($tmpZip); return 'Cannot overwrite symlinks.'; } Installer::install( $zip, GRAV_ROOT, ['install_path' => $installPath, 'theme' => $type === 'theme', 'is_update' => $isUpdate], $extracted ); } Folder::delete($tmpSource); if (Installer::lastErrorCode()) { return Installer::lastErrorMsg(); } Folder::delete($tmpZip); Cache::clearCache(); return true; } /** * Self-upgrade Grav core to the latest release. * * @param array $options Supported: 'override' (bool) to bypass * blocking preflight checks, mirroring the CLI. * @return bool */ public static function selfupgrade(array $options = []): bool { static::$lastError = null; static::$lastPreflightReport = null; $upgrader = new Upgrader(); if (!Installer::isGravInstance(GRAV_ROOT)) { static::$lastError = 'Target directory is not a valid Grav instance.'; return false; } if (is_link(GRAV_ROOT . DS . 'index.php')) { Installer::setError(Installer::IS_LINK); static::$lastError = 'Cannot self-upgrade: index.php is a symlink.'; return false; } if (method_exists($upgrader, 'meetsRequirements') && method_exists($upgrader, 'minPHPVersion') && !$upgrader->meetsRequirements()) { $error = []; $error[] = '

Grav has increased the minimum PHP requirement.
'; $error[] = 'You are currently running PHP ' . phpversion() . ''; $error[] = ', but PHP ' . $upgrader->minPHPVersion() . ' is required.

'; Installer::setError(implode("\n", $error)); static::$lastError = sprintf( 'PHP %s or higher is required; this server runs PHP %s.', $upgrader->minPHPVersion(), phpversion() ); return false; } $update = $upgrader->getAssets()['grav-update']; $tmp = static::getTempDir() . '/Grav-' . uniqid('', false); $file = static::downloadSelfupgrade($update, $tmp); $folder = Installer::unZip($file, $tmp . '/zip'); static::upgradeGrav($file, $folder, false, $options); $errorCode = Installer::lastErrorCode(); Folder::delete($tmp); $success = !(is_string($errorCode) || ($errorCode & (Installer::ZIP_OPEN_ERROR | Installer::ZIP_EXTRACT_ERROR))); // Capture the real reason so the controller can surface it instead of a generic 500. if (!$success && null === static::$lastError) { $msg = Installer::lastErrorMsg(); static::$lastError = ('' !== $msg && 'No Error' !== $msg) ? $msg : 'Failed to upgrade Grav core.'; } return $success; } /** * The raw installer error from the last selfupgrade() attempt, if any. */ public static function getLastError(): ?string { return static::$lastError; } /** * The preflight report from the last selfupgrade() attempt, if one was generated. * * @return array|null */ public static function getLastPreflightReport(): ?array { return static::$lastPreflightReport; } /** * Download a GPM package zip into a temp directory. */ private static function download(Package $package, ?string $license = null): string { $query = ''; if ($package->premium) { $query = \json_encode(array_merge($package->premium, [ 'slug' => $package->slug, 'license_key' => $license, 'sid' => md5(GRAV_ROOT), ])); $query = '?d=' . base64_encode($query); } try { $contents = Response::get($package->zipball_url . $query, []); } catch (\Exception $e) { throw new \RuntimeException($e->getMessage()); } $tmpDir = static::getTempDir() . '/Grav-' . uniqid('', false); Folder::mkdir($tmpDir); $badChars = array_merge(array_map('chr', range(0, 31)), ['<', '>', ':', '"', '/', '\\', '|', '?', '*']); $filename = $package->slug . str_replace($badChars, '', Utils::basename($package->zipball_url)); $filename = preg_replace('/[\\\\\/:"*?&<>|]+/m', '-', $filename); file_put_contents($tmpDir . DS . $filename . '.zip', $contents); return $tmpDir . DS . $filename . '.zip'; } /** * Download the Grav self-upgrade zip. * * @param array $package */ private static function downloadSelfupgrade(array $package, string $tmp): string { $output = Response::get($package['download'], []); Folder::mkdir($tmp); file_put_contents($tmp . DS . $package['name'], $output); return $tmp . DS . $package['name']; } /** * Run the Grav core upgrade install script against an extracted zip. */ private static function upgradeGrav(string $zip, string $folder, bool $keepFolder = false, array $options = []): void { static $ignores = [ 'backup', 'cache', 'images', 'logs', 'tmp', 'user', '.htaccess', 'robots.txt', ]; if (!is_dir($folder)) { Installer::setError('Invalid source folder'); return; } try { $script = $folder . '/system/install.php'; if ((file_exists($script) && $install = include $script) && is_callable($install)) { // Preflight parity with `bin/gpm self-upgrade`: inspect the blocking checks // and honor an explicit override, rather than failing with an opaque error. if (is_object($install) && method_exists($install, 'generatePreflightReport')) { $report = $install->generatePreflightReport(); static::$lastPreflightReport = $report; if (!empty($report['blocking'] ?? [])) { if (!empty($options['override'])) { if (method_exists($install, 'allowIncompatibleOverride')) { $install::allowIncompatibleOverride(true); } if (method_exists($install, 'allowPendingOverride')) { $install::allowPendingOverride(true); } // Recompute so install() reuses an unblocked, cached report. $report = $install->generatePreflightReport(); static::$lastPreflightReport = $report; } if (!empty($report['blocking'] ?? [])) { Installer::setError('Upgrade preflight checks failed.'); return; } } } $install($zip); } else { Installer::install( $zip, GRAV_ROOT, ['sophisticated' => true, 'overwrite' => true, 'ignore_symlinks' => true, 'ignores' => $ignores], $folder, $keepFolder ); Cache::clearCache(); } } catch (\Throwable $e) { Installer::setError($e->getMessage()); static::$lastError = $e->getMessage(); } } /** * Resolve a writable temporary directory, falling back to cache/tmp if tmp:// * isn't configured. */ private static function getTempDir(): string { try { $tmpDir = Grav::instance()['locator']->findResource('tmp://', true, true); } catch (\Exception $e) { $tmpDir = Grav::instance()['locator']->findResource('cache://', true, true) . '/tmp'; } return $tmpDir; } }