Files
intotheeast-com-content/plugins/api/classes/Api/Services/GpmService.php
T

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;
}
}