diff --git a/bin/restore b/bin/restore index e949471fe..6ef0cfc9f 100755 --- a/bin/restore +++ b/bin/restore @@ -253,6 +253,15 @@ switch ($command) { echo " File: {$file}\n"; echo " Line: {$line}\n"; echo " Created: {$created}\n"; + + $window = $manager->getUpgradeWindow(); + if ($window) { + $expires = isset($window['expires_at']) ? date('c', (int)$window['expires_at']) : 'unknown'; + $reason = $window['reason'] ?? '(unknown)'; + echo " Window: active ({$reason}, expires {$expires})\n"; + } else { + echo " Window: inactive\n"; + } exit(0); default: diff --git a/system/src/Grav/Common/Recovery/RecoveryManager.php b/system/src/Grav/Common/Recovery/RecoveryManager.php index c179e4b24..75691b9e5 100644 --- a/system/src/Grav/Common/Recovery/RecoveryManager.php +++ b/system/src/Grav/Common/Recovery/RecoveryManager.php @@ -20,6 +20,7 @@ use function is_array; use function is_file; use function json_decode; use function json_encode; +use function max; use function md5; use function preg_match; use function random_bytes; @@ -100,6 +101,8 @@ class RecoveryManager if (is_file($flag)) { @unlink($flag); } + + $this->closeUpgradeWindow(); } /** @@ -121,6 +124,10 @@ class RecoveryManager $file = $error['file'] ?? ''; $plugin = $this->detectPluginFromPath($file); + if (!$plugin) { + return; + } + $context = [ 'created_at' => time(), 'message' => $error['message'] ?? '', @@ -130,6 +137,10 @@ class RecoveryManager 'plugin' => $plugin, ]; + if (!$this->shouldEnterRecovery($context)) { + return; + } + $this->activate($context); if ($plugin) { $this->quarantinePlugin($plugin, $context); @@ -286,6 +297,63 @@ class RecoveryManager return $this->rootPath . '/system/recovery.flag'; } + /** + * @return string + */ + private function windowPath(): string + { + return $this->rootPath . '/system/recovery.window'; + } + + /** + * @return array|null + */ + private function resolveUpgradeWindow(): ?array + { + $path = $this->windowPath(); + if (!is_file($path)) { + return null; + } + + $decoded = json_decode(file_get_contents($path), true); + if (!is_array($decoded)) { + @unlink($path); + + return null; + } + + $expiresAt = (int)($decoded['expires_at'] ?? 0); + if ($expiresAt > 0 && $expiresAt < time()) { + @unlink($path); + + return null; + } + + return $decoded; + } + + /** + * @param array $context + * @return bool + */ + private function shouldEnterRecovery(array $context): bool + { + $window = $this->resolveUpgradeWindow(); + if (null === $window) { + return false; + } + + $scope = $window['scope'] ?? null; + if ($scope === 'plugin') { + $expected = $window['plugin'] ?? null; + if ($expected && ($context['plugin'] ?? null) !== $expected) { + return false; + } + } + + return true; + } + /** * @return string */ @@ -314,4 +382,54 @@ class RecoveryManager { return error_get_last(); } + + /** + * Begin an upgrade window; during this window fatal plugin errors may trigger recovery mode. + * + * @param string $reason + * @param array $metadata + * @param int $ttlSeconds + * @return void + */ + public function markUpgradeWindow(string $reason, array $metadata = [], int $ttlSeconds = 604800): void + { + $ttl = max(60, $ttlSeconds); + $createdAt = time(); + + $payload = $metadata + [ + 'reason' => $reason, + 'created_at' => $createdAt, + 'expires_at' => $createdAt + $ttl, + ]; + + file_put_contents($this->windowPath(), json_encode($payload, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n"); + } + + /** + * @return bool + */ + public function isUpgradeWindowActive(): bool + { + return $this->resolveUpgradeWindow() !== null; + } + + /** + * @return array|null + */ + public function getUpgradeWindow(): ?array + { + return $this->resolveUpgradeWindow(); + } + + /** + * @return void + */ + public function closeUpgradeWindow(): void + { + $window = $this->windowPath(); + if (is_file($window)) { + @unlink($window); + } + } + } diff --git a/system/src/Grav/Console/Gpm/SelfupgradeCommand.php b/system/src/Grav/Console/Gpm/SelfupgradeCommand.php index e2eac0acf..ea42f4b3d 100644 --- a/system/src/Grav/Console/Gpm/SelfupgradeCommand.php +++ b/system/src/Grav/Console/Gpm/SelfupgradeCommand.php @@ -222,6 +222,13 @@ class SelfupgradeCommand extends GpmCommand $io->newLine(); $io->writeln("Preparing to upgrade to v{$remote}.."); + /** @var \Grav\Common\Recovery\RecoveryManager $recovery */ + $recovery = Grav::instance()['recovery']; + $recovery->markUpgradeWindow('core-upgrade', [ + 'scope' => 'core', + 'target_version' => $remote, + ]); + $io->write(" |- Downloading upgrade [{$this->formatBytes($update['size'])}]... 0%"); $this->file = $this->download($update); diff --git a/system/src/Grav/Console/Gpm/UpdateCommand.php b/system/src/Grav/Console/Gpm/UpdateCommand.php index 6e20c9391..897026f67 100644 --- a/system/src/Grav/Console/Gpm/UpdateCommand.php +++ b/system/src/Grav/Console/Gpm/UpdateCommand.php @@ -12,6 +12,7 @@ namespace Grav\Console\Gpm; use Grav\Common\GPM\GPM; use Grav\Common\GPM\Installer; use Grav\Common\GPM\Upgrader; +use Grav\Common\Grav; use Grav\Console\GpmCommand; use Symfony\Component\Console\Input\ArrayInput; use Symfony\Component\Console\Input\InputArgument; @@ -212,6 +213,10 @@ class UpdateCommand extends GpmCommand } } + /** @var \Grav\Common\Recovery\RecoveryManager $recovery */ + $recovery = Grav::instance()['recovery']; + $recovery->markUpgradeWindow('package-update', ['scope' => 'core']); + // finally update $install_command = $this->getApplication()->find('install'); diff --git a/tests/unit/Grav/Common/Recovery/RecoveryManagerTest.php b/tests/unit/Grav/Common/Recovery/RecoveryManagerTest.php index f7e7e7375..1c21cc67d 100644 --- a/tests/unit/Grav/Common/Recovery/RecoveryManagerTest.php +++ b/tests/unit/Grav/Common/Recovery/RecoveryManagerTest.php @@ -56,6 +56,7 @@ class RecoveryManagerTest extends \Codeception\TestCase\Test } }; + $manager->markUpgradeWindow('core-upgrade', ['scope' => 'core']); $manager->handleShutdown(); $flag = $this->tmpDir . '/system/recovery.flag';