mirror of
https://github.com/getgrav/grav-plugin-admin.git
synced 2025-12-05 15:29:58 +01:00
@@ -16,6 +16,7 @@ namespace Grav\Plugin\Admin;
|
||||
|
||||
use Grav\Common\Filesystem\Folder;
|
||||
use Grav\Common\GPM\Installer;
|
||||
use Grav\Common\GPM\GPM;
|
||||
use Grav\Common\GPM\Upgrader;
|
||||
use Grav\Common\Grav;
|
||||
use Grav\Common\HTTP\Response;
|
||||
@@ -23,6 +24,7 @@ use Grav\Common\Recovery\RecoveryManager;
|
||||
use Grav\Common\Upgrade\SafeUpgradeService;
|
||||
use Grav\Common\Utils;
|
||||
use Grav\Installer\Install;
|
||||
use Grav\Common\Yaml;
|
||||
use Symfony\Component\Process\PhpExecutableFinder;
|
||||
use Symfony\Component\Process\Process;
|
||||
use RuntimeException;
|
||||
@@ -33,19 +35,25 @@ use function dirname;
|
||||
use function file_exists;
|
||||
use function file_get_contents;
|
||||
use function file_put_contents;
|
||||
use function array_key_exists;
|
||||
use function glob;
|
||||
use function iterator_to_array;
|
||||
use function is_array;
|
||||
use function is_dir;
|
||||
use function is_file;
|
||||
use function is_link;
|
||||
use function json_decode;
|
||||
use function json_encode;
|
||||
use function max;
|
||||
use function preg_match;
|
||||
use function rsort;
|
||||
use function sprintf;
|
||||
use function strftime;
|
||||
use function strtotime;
|
||||
use function time;
|
||||
use function uniqid;
|
||||
use function strtolower;
|
||||
use function strpos;
|
||||
use const GRAV_ROOT;
|
||||
use const GRAV_SCHEMA;
|
||||
|
||||
@@ -77,6 +85,8 @@ class SafeUpgradeManager
|
||||
private $progressPath;
|
||||
/** @var string|null */
|
||||
private $tmp;
|
||||
/** @var array */
|
||||
private $preflightDecisions = [];
|
||||
|
||||
/**
|
||||
* SafeUpgradeManager constructor.
|
||||
@@ -705,6 +715,10 @@ class SafeUpgradeManager
|
||||
],
|
||||
];
|
||||
|
||||
$report = $this->collectLocalPreflightReport($remote ?? $local);
|
||||
$payload['preflight'] = $report;
|
||||
$payload['preflight']['blocking'] = $report['blocking'] ?? [];
|
||||
|
||||
Installer::isValidDestination(GRAV_ROOT . '/system');
|
||||
$payload['symlinked'] = Installer::IS_LINK === Installer::lastErrorCode();
|
||||
|
||||
@@ -732,6 +746,7 @@ class SafeUpgradeManager
|
||||
$timeout = (int)($options['timeout'] ?? 30);
|
||||
$overwrite = (bool)($options['overwrite'] ?? false);
|
||||
$decisions = is_array($options['decisions'] ?? null) ? $options['decisions'] : [];
|
||||
$this->preflightDecisions = $decisions;
|
||||
|
||||
$this->setProgress('initializing', 'Preparing safe upgrade...', null);
|
||||
|
||||
@@ -828,6 +843,7 @@ class SafeUpgradeManager
|
||||
Folder::delete($this->tmp);
|
||||
}
|
||||
$this->tmp = null;
|
||||
$this->preflightDecisions = [];
|
||||
}
|
||||
|
||||
$this->setProgress('finalizing', 'Finalizing upgrade...', null);
|
||||
@@ -1099,6 +1115,43 @@ class SafeUpgradeManager
|
||||
return null;
|
||||
}
|
||||
|
||||
protected function enforcePreflightReport(array $preflight, array $decisions): void
|
||||
{
|
||||
if (!$preflight) {
|
||||
return;
|
||||
}
|
||||
|
||||
$pending = $preflight['plugins_pending'] ?? [];
|
||||
if ($pending) {
|
||||
$decision = $decisions['pending'] ?? 'abort';
|
||||
if ($decision !== 'continue') {
|
||||
$list = [];
|
||||
foreach ($pending as $slug => $info) {
|
||||
$current = $info['current'] ?? 'unknown';
|
||||
$available = $info['available'] ?? 'unknown';
|
||||
$list[] = sprintf('%s (%s → %s)', $slug, $current, $available);
|
||||
}
|
||||
|
||||
throw new RuntimeException(
|
||||
"Plugin/theme updates required before upgrading Grav:\n - " . implode("\n - ", $list)
|
||||
);
|
||||
}
|
||||
|
||||
Install::allowPendingPackageOverride(true);
|
||||
$this->setProgress('warning', 'Proceeding despite pending plugin/theme updates.', null);
|
||||
}
|
||||
|
||||
$blocking = $preflight['blocking'] ?? [];
|
||||
if ($blocking) {
|
||||
throw new RuntimeException($blocking[0]);
|
||||
}
|
||||
|
||||
$error = $this->handleConflictDecisions($preflight, $decisions);
|
||||
if ($error !== null) {
|
||||
throw new RuntimeException($error['message'] ?? 'Upgrade aborted due to unresolved conflicts.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string|null $decision
|
||||
* @param array $conflicts
|
||||
@@ -1135,6 +1188,369 @@ class SafeUpgradeManager
|
||||
]);
|
||||
}
|
||||
|
||||
protected function collectLocalPreflightReport(?string $targetVersion): array
|
||||
{
|
||||
$targetVersion = $targetVersion ?: GRAV_VERSION;
|
||||
$report = [
|
||||
'warnings' => [],
|
||||
'psr_log_conflicts' => [],
|
||||
'monolog_conflicts' => [],
|
||||
'plugins_pending' => [],
|
||||
'is_major_minor_upgrade' => $this->isMajorMinorUpgradeLocal($targetVersion),
|
||||
'blocking' => [],
|
||||
];
|
||||
|
||||
$report['plugins_pending'] = $this->detectPendingPackages();
|
||||
$report['psr_log_conflicts'] = $this->detectPsrLogConflictsLocal();
|
||||
$report['monolog_conflicts'] = $this->detectMonologConflictsLocal();
|
||||
|
||||
if ($report['plugins_pending']) {
|
||||
if ($report['is_major_minor_upgrade']) {
|
||||
$report['blocking'][] = 'Plugin and theme updates must be applied before upgrading Grav.';
|
||||
} else {
|
||||
$report['warnings'][] = 'Pending plugin/theme updates detected; updating before upgrading Grav is recommended.';
|
||||
}
|
||||
}
|
||||
|
||||
if ($report['psr_log_conflicts']) {
|
||||
$report['warnings'][] = 'Potential psr/log conflicts detected.';
|
||||
}
|
||||
|
||||
if ($report['monolog_conflicts']) {
|
||||
$report['warnings'][] = 'Potential Monolog API conflicts detected.';
|
||||
}
|
||||
|
||||
return $report;
|
||||
}
|
||||
|
||||
protected function isMajorMinorUpgradeLocal(string $targetVersion): bool
|
||||
{
|
||||
[$currentMajor, $currentMinor] = array_map('intval', array_pad(explode('.', GRAV_VERSION), 2, 0));
|
||||
[$targetMajor, $targetMinor] = array_map('intval', array_pad(explode('.', $targetVersion), 2, 0));
|
||||
|
||||
return $currentMajor !== $targetMajor || $currentMinor !== $targetMinor;
|
||||
}
|
||||
|
||||
protected function detectPendingPackages(): array
|
||||
{
|
||||
$pending = [];
|
||||
|
||||
try {
|
||||
$gpm = new GPM();
|
||||
} catch (Throwable $e) {
|
||||
$this->setProgress('warning', 'Unable to query GPM repository: ' . $e->getMessage(), null);
|
||||
|
||||
return $pending;
|
||||
}
|
||||
|
||||
$repoPlugins = $this->packagesToArray($gpm->getRepositoryPlugins());
|
||||
$repoThemes = $this->packagesToArray($gpm->getRepositoryThemes());
|
||||
|
||||
$localPlugins = $this->scanLocalPackageVersions(GRAV_ROOT . '/user/plugins');
|
||||
foreach ($localPlugins as $slug => $version) {
|
||||
$remote = $repoPlugins[$slug] ?? null;
|
||||
if (!$this->isPackagePublished($remote)) {
|
||||
continue;
|
||||
}
|
||||
$remoteVersion = $this->resolveRemotePackageVersion($remote);
|
||||
if (!$remoteVersion || !$version) {
|
||||
continue;
|
||||
}
|
||||
if (!$this->isPluginEnabledLocally($slug)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (version_compare($remoteVersion, $version, '>')) {
|
||||
$pending[$slug] = [
|
||||
'type' => 'plugin',
|
||||
'current' => $version,
|
||||
'available' => $remoteVersion,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
$localThemes = $this->scanLocalPackageVersions(GRAV_ROOT . '/user/themes');
|
||||
foreach ($localThemes as $slug => $version) {
|
||||
$remote = $repoThemes[$slug] ?? null;
|
||||
if (!$this->isPackagePublished($remote)) {
|
||||
continue;
|
||||
}
|
||||
$remoteVersion = $this->resolveRemotePackageVersion($remote);
|
||||
if (!$remoteVersion || !$version) {
|
||||
continue;
|
||||
}
|
||||
if (!$this->isThemeEnabledLocally($slug)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (version_compare($remoteVersion, $version, '>')) {
|
||||
$pending[$slug] = [
|
||||
'type' => 'theme',
|
||||
'current' => $version,
|
||||
'available' => $remoteVersion,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $pending;
|
||||
}
|
||||
|
||||
protected function scanLocalPackageVersions(string $path): array
|
||||
{
|
||||
$versions = [];
|
||||
if (!is_dir($path)) {
|
||||
return $versions;
|
||||
}
|
||||
|
||||
$entries = glob($path . '/*', GLOB_ONLYDIR) ?: [];
|
||||
foreach ($entries as $dir) {
|
||||
$slug = basename($dir);
|
||||
$version = $this->readBlueprintVersion($dir) ?? $this->readComposerVersion($dir);
|
||||
if ($version !== null) {
|
||||
$versions[$slug] = $version;
|
||||
}
|
||||
}
|
||||
|
||||
return $versions;
|
||||
}
|
||||
|
||||
protected function readBlueprintVersion(string $dir): ?string
|
||||
{
|
||||
$file = $dir . '/blueprints.yaml';
|
||||
if (!is_file($file)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$contents = @file_get_contents($file);
|
||||
if ($contents === false) {
|
||||
return null;
|
||||
}
|
||||
$data = Yaml::parse($contents);
|
||||
if (is_array($data) && isset($data['version'])) {
|
||||
return (string)$data['version'];
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
// ignore parse errors
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
protected function readComposerVersion(string $dir): ?string
|
||||
{
|
||||
$file = $dir . '/composer.json';
|
||||
if (!is_file($file)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$data = json_decode(file_get_contents($file), true);
|
||||
if (is_array($data) && isset($data['version'])) {
|
||||
return (string)$data['version'];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
protected function packagesToArray($packages): array
|
||||
{
|
||||
if (!$packages) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (is_array($packages)) {
|
||||
return $packages;
|
||||
}
|
||||
|
||||
if ($packages instanceof \Traversable) {
|
||||
return iterator_to_array($packages, true);
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
protected function resolveRemotePackageVersion($package): ?string
|
||||
{
|
||||
if (!$package) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (is_array($package)) {
|
||||
return $package['version'] ?? null;
|
||||
}
|
||||
|
||||
if (is_object($package)) {
|
||||
if (isset($package->version)) {
|
||||
return (string)$package->version;
|
||||
}
|
||||
if (method_exists($package, 'offsetGet')) {
|
||||
try {
|
||||
return (string)$package->offsetGet('version');
|
||||
} catch (Throwable $e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
protected function detectPsrLogConflictsLocal(): array
|
||||
{
|
||||
$conflicts = [];
|
||||
$pluginRoots = glob(GRAV_ROOT . '/user/plugins/*', GLOB_ONLYDIR) ?: [];
|
||||
foreach ($pluginRoots as $path) {
|
||||
$composerFile = $path . '/composer.json';
|
||||
if (!is_file($composerFile)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$json = json_decode(file_get_contents($composerFile), true);
|
||||
if (!is_array($json)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$slug = basename($path);
|
||||
if (!$this->isPluginEnabledLocally($slug)) {
|
||||
continue;
|
||||
}
|
||||
$rawConstraint = $json['require']['psr/log'] ?? ($json['require-dev']['psr/log'] ?? null);
|
||||
if (!$rawConstraint) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$constraint = strtolower((string)$rawConstraint);
|
||||
$compatible = $constraint === '*'
|
||||
|| false !== strpos($constraint, '3')
|
||||
|| false !== strpos($constraint, '4')
|
||||
|| (false !== strpos($constraint, '>=') && preg_match('/>=\s*3/', $constraint));
|
||||
|
||||
if ($compatible) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$conflicts[$slug] = [
|
||||
'composer' => $composerFile,
|
||||
'requires' => $rawConstraint,
|
||||
];
|
||||
}
|
||||
|
||||
return $conflicts;
|
||||
}
|
||||
|
||||
protected function detectMonologConflictsLocal(): array
|
||||
{
|
||||
$conflicts = [];
|
||||
$pluginRoots = glob(GRAV_ROOT . '/user/plugins/*', GLOB_ONLYDIR) ?: [];
|
||||
$pattern = '/->add(?:Debug|Info|Notice|Warning|Error|Critical|Alert|Emergency)\s*\(/i';
|
||||
|
||||
foreach ($pluginRoots as $path) {
|
||||
$slug = basename($path);
|
||||
if (!$this->isPluginEnabledLocally($slug)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$iterator = new \RecursiveIteratorIterator(
|
||||
new \RecursiveDirectoryIterator($path, \FilesystemIterator::SKIP_DOTS)
|
||||
);
|
||||
|
||||
foreach ($iterator as $file) {
|
||||
if (!$file->isFile() || strtolower($file->getExtension()) !== 'php') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$contents = @file_get_contents($file->getPathname());
|
||||
if ($contents === false) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (preg_match($pattern, $contents, $match)) {
|
||||
$relative = str_replace(GRAV_ROOT . '/', '', $file->getPathname());
|
||||
$conflicts[$slug][] = [
|
||||
'file' => $relative,
|
||||
'method' => trim($match[0]),
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $conflicts;
|
||||
}
|
||||
|
||||
protected function isPluginEnabledLocally(string $slug): bool
|
||||
{
|
||||
$configPath = GRAV_ROOT . '/user/config/plugins/' . $slug . '.yaml';
|
||||
if (is_file($configPath)) {
|
||||
try {
|
||||
$contents = @file_get_contents($configPath);
|
||||
if ($contents !== false) {
|
||||
$data = Yaml::parse($contents);
|
||||
if (is_array($data) && array_key_exists('enabled', $data)) {
|
||||
return (bool)$data['enabled'];
|
||||
}
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
// ignore parse errors
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
protected function isThemeEnabledLocally(string $slug): bool
|
||||
{
|
||||
$configPath = GRAV_ROOT . '/user/config/system.yaml';
|
||||
if (is_file($configPath)) {
|
||||
try {
|
||||
$contents = @file_get_contents($configPath);
|
||||
if ($contents !== false) {
|
||||
$data = Yaml::parse($contents);
|
||||
if (is_array($data)) {
|
||||
$active = $data['pages']['theme'] ?? ($data['system']['pages']['theme'] ?? null);
|
||||
if ($active !== null) {
|
||||
return $active === $slug;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
// ignore parse errors
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
protected function isPackagePublished($package): bool
|
||||
{
|
||||
if (!$package) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (is_array($package)) {
|
||||
if (array_key_exists('published', $package)) {
|
||||
return $package['published'] !== false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
if (is_object($package) && method_exists($package, 'getData')) {
|
||||
$data = $package->getData();
|
||||
if ($data instanceof \Grav\Common\Data\Data) {
|
||||
$published = $data->get('published');
|
||||
|
||||
return $published !== false;
|
||||
}
|
||||
}
|
||||
|
||||
if (is_object($package) && property_exists($package, 'published')) {
|
||||
return $package->published !== false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @param array $package
|
||||
* @param int $timeout
|
||||
@@ -1195,6 +1611,18 @@ class SafeUpgradeManager
|
||||
}
|
||||
|
||||
try {
|
||||
if (is_object($install) && method_exists($install, 'generatePreflightReport')) {
|
||||
$report = $install->generatePreflightReport();
|
||||
$this->setProgress('preflight', 'Preflight checks completed.', null, [
|
||||
'preflight' => $report,
|
||||
]);
|
||||
$this->enforcePreflightReport($report, $this->preflightDecisions);
|
||||
}
|
||||
if (is_object($install) && method_exists($install, 'setProgressCallback')) {
|
||||
$install->setProgressCallback(function (string $stage, string $message, ?int $percent = null) {
|
||||
$this->setProgress($stage, $message, $percent);
|
||||
});
|
||||
}
|
||||
$this->setProgress('installing', 'Running installer...', null);
|
||||
$install($zip);
|
||||
$this->setProgress('installing', 'Verifying files...', null);
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
$root = \defined('GRAV_ROOT') ? GRAV_ROOT : dirname(__DIR__, 3);
|
||||
$scriptRoot = isset($_SERVER['SCRIPT_FILENAME']) ? dirname($_SERVER['SCRIPT_FILENAME']) : null;
|
||||
$root = \defined('GRAV_ROOT') ? GRAV_ROOT : ($scriptRoot ?: dirname(__DIR__, 3));
|
||||
$jobsDir = $root . '/user/data/upgrades/jobs';
|
||||
$fallbackProgress = $root . '/user/data/upgrades/safe-upgrade-progress.json';
|
||||
|
||||
@@ -46,8 +47,8 @@ $normalizeDir = static function (string $path): string {
|
||||
return rtrim($normalized, '/');
|
||||
};
|
||||
|
||||
$jobsDirNormalized = $normalizeDir($jobsDir);
|
||||
$userDataDirNormalized = $normalizeDir(dirname($jobsDir));
|
||||
$jobsDirNormalized = $normalizeDir(realpath($jobsDir) ?: $jobsDir);
|
||||
$userDataDirNormalized = $normalizeDir(realpath(dirname($jobsDir)) ?: dirname($jobsDir));
|
||||
$toRelative = static function (string $path): string {
|
||||
$normalized = str_replace('\\', '/', $path);
|
||||
$root = str_replace('\\', '/', GRAV_ROOT);
|
||||
@@ -79,8 +80,7 @@ if ($contextParam !== '') {
|
||||
$candidate = str_replace('\\', '/', $candidate);
|
||||
}
|
||||
|
||||
$directory = dirname($candidate);
|
||||
$real = realpath($directory);
|
||||
$real = realpath($candidate);
|
||||
if ($real === false) {
|
||||
return null;
|
||||
}
|
||||
@@ -90,7 +90,7 @@ if ($contextParam !== '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $candidate;
|
||||
return $real;
|
||||
};
|
||||
|
||||
if (!empty($decoded['manifest'])) {
|
||||
|
||||
Reference in New Issue
Block a user