515 lines
17 KiB
PHP
515 lines
17 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace Grav\Plugin\Api\Services;
|
|
|
|
use Grav\Common\Cache;
|
|
use Grav\Common\Filesystem\Folder;
|
|
use Grav\Common\GPM\Common\Package;
|
|
use Grav\Common\GPM\GPM as GravGPM;
|
|
use Grav\Common\GPM\Installer;
|
|
use Grav\Common\GPM\Licenses;
|
|
use Grav\Common\GPM\Upgrader;
|
|
use Grav\Common\Grav;
|
|
use Grav\Common\HTTP\Response;
|
|
use Grav\Common\Utils;
|
|
|
|
/**
|
|
* GpmService — GPM write operations (install / update / remove / direct-install / self-upgrade).
|
|
*
|
|
* This is a port of Grav\Plugin\Admin\Gpm that removes the dependency on the
|
|
* classic admin plugin so admin-next / admin2 users can manage packages
|
|
* without needing the old admin plugin installed.
|
|
*
|
|
* Admin-specific callsites (Admin::translate, Admin::getTempDir) have been
|
|
* replaced with inlined English strings and a local temp-dir resolver.
|
|
*/
|
|
class GpmService
|
|
{
|
|
/** @var GravGPM|null */
|
|
protected static ?GravGPM $GPM = null;
|
|
|
|
/** @var string|null Raw installer error captured during the last selfupgrade(). */
|
|
protected static ?string $lastError = null;
|
|
|
|
/** @var array<string, mixed>|null Preflight report captured during the last selfupgrade(). */
|
|
protected static ?array $lastPreflightReport = null;
|
|
|
|
/**
|
|
* Default options for install operations.
|
|
*
|
|
* @var array<string, mixed>
|
|
*/
|
|
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<string, mixed> $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<string, mixed> $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<string, mixed> $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<string, mixed> $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[] = '<p>Grav has increased the minimum PHP requirement.<br />';
|
|
$error[] = 'You are currently running PHP <strong>' . phpversion() . '</strong>';
|
|
$error[] = ', but PHP <strong>' . $upgrader->minPHPVersion() . '</strong> is required.</p>';
|
|
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<string, mixed>|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<string, mixed> $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;
|
|
}
|
|
}
|