diff --git a/.gitignore b/.gitignore index a2a78f5f2..7b556f1cb 100644 --- a/.gitignore +++ b/.gitignore @@ -48,3 +48,5 @@ tests/cache/* tests/error.log system/templates/testing/* /user/config/versions.yaml +/system/recovery.window +tmp/* diff --git a/CHANGELOG.md b/CHANGELOG.md index 97a532fb8..76e9b5e6e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,12 @@ +# v1.7.50 +## 10/19/2025 + +1. [](#new) + * Added new **Safe Core Upgrade** process with snapshots for backup and restore, better preflight and postflight checks, as well as exception checking post-install for easy rollback. + * Introduced recovery mode with token-gated UI, plugin quarantine, and CLI rollback support. + * Added `bin/gpm preflight` compatibility scanner and `bin/gpm rollback` utility. + * Added `wordCount` Twig filter [#3957](https://github.com/getgrav/grav/pulls/3957) + # v1.7.49.5 ## 09/10/2025 diff --git a/bin/restore b/bin/restore new file mode 100755 index 000000000..0a8e7d373 --- /dev/null +++ b/bin/restore @@ -0,0 +1,634 @@ +#!/usr/bin/env php + [--staging-root=/absolute/path] + Restores the specified snapshot created by safe-upgrade. + + bin/restore remove [ ...] [--staging-root=/absolute/path] + Deletes one or more snapshots (interactive selection when no id provided). + + bin/restore snapshot [--label=\"optional description\"] [--staging-root=/absolute/path] + Creates a manual snapshot of the current Grav core files. + + bin/restore recovery [status|clear] + Shows the recovery flag context or clears it. + +Options: + --staging-root Overrides the staging directory (defaults to configured value). + --label Optional label to store with the manual snapshot. + +Examples: + bin/restore list + bin/restore apply stage-68eff31cc4104 + bin/restore apply stage-68eff31cc4104 --staging-root=/var/grav-backups + bin/restore snapshot --label=\"Before plugin install\" + bin/restore recovery status + bin/restore recovery clear +USAGE; + +/** + * @param array $args + * @return array{command:string,arguments:array,options:array} + */ +function parseArguments(array $args): array +{ + array_shift($args); // remove script name + + $command = null; + $arguments = []; + $options = []; + + while ($args) { + $arg = array_shift($args); + if (strncmp($arg, '--', 2) === 0) { + $parts = explode('=', substr($arg, 2), 2); + $name = $parts[0] ?? ''; + if ($name === '') { + continue; + } + $value = $parts[1] ?? null; + if ($value === null && $args && substr($args[0], 0, 2) !== '--') { + $value = array_shift($args); + } + $options[$name] = $value ?? true; + continue; + } + + if (null === $command) { + $command = $arg; + } else { + $arguments[] = $arg; + } + } + + if (null === $command) { + $command = 'interactive'; + } + + return [ + 'command' => $command, + 'arguments' => $arguments, + 'options' => $options, + ]; +} + +/** + * @param array $options + * @return SafeUpgradeService + */ +function createUpgradeService(array $options): SafeUpgradeService +{ + $serviceOptions = ['root' => GRAV_ROOT]; + + if (isset($options['staging-root']) && is_string($options['staging-root']) && $options['staging-root'] !== '') { + $serviceOptions['staging_root'] = $options['staging-root']; + } + + return new SafeUpgradeService($serviceOptions); +} + +/** + * @return list + */ +function loadSnapshots(): array +{ + $manifestDir = GRAV_ROOT . '/user/data/upgrades'; + if (!is_dir($manifestDir)) { + return []; + } + + $files = glob($manifestDir . '/*.json') ?: []; + rsort($files); + + $snapshots = []; + foreach ($files as $file) { + $decoded = json_decode(file_get_contents($file) ?: '', true); + if (!is_array($decoded) || empty($decoded['id'])) { + continue; + } + + $snapshots[] = [ + 'id' => $decoded['id'], + 'label' => $decoded['label'] ?? null, + 'source_version' => $decoded['source_version'] ?? null, + 'target_version' => $decoded['target_version'] ?? null, + 'created_at' => (int)($decoded['created_at'] ?? 0), + ]; + } + + return $snapshots; +} + +/** + * @param list $snapshots + * @return string + */ +function formatSnapshotListLine(array $snapshot): string +{ + $restoreVersion = $snapshot['source_version'] ?? $snapshot['target_version'] ?? 'unknown'; + $timeLabel = formatSnapshotTimestamp($snapshot['created_at']); + $label = $snapshot['label'] ?? null; + $display = $label ? sprintf('%s [%s]', $label, $snapshot['id']) : $snapshot['id']; + + return sprintf('%s (restore to Grav %s, %s)', $display, $restoreVersion, $timeLabel); +} + +function formatSnapshotTimestamp(int $timestamp): string +{ + if ($timestamp <= 0) { + return 'time unknown'; + } + + try { + $timezone = resolveTimezone(); + $dt = new DateTime('@' . $timestamp); + $dt->setTimezone($timezone); + $formatted = $dt->format('Y-m-d H:i:s T'); + } catch (\Throwable $e) { + $formatted = date('Y-m-d H:i:s T', $timestamp); + } + + return $formatted . ' (' . formatRelative(time() - $timestamp) . ')'; +} + +function resolveTimezone(): DateTimeZone +{ + static $resolved = null; + if ($resolved instanceof DateTimeZone) { + return $resolved; + } + + $timezone = null; + $configFile = GRAV_ROOT . '/user/config/system.yaml'; + if (is_file($configFile)) { + try { + $data = Yaml::parse(file_get_contents($configFile) ?: '') ?: []; + if (!empty($data['system']['timezone']) && is_string($data['system']['timezone'])) { + $timezone = $data['system']['timezone']; + } + } catch (\Throwable $e) { + // ignore parse errors, fallback below + } + } + + if (!$timezone) { + $timezone = ini_get('date.timezone') ?: 'UTC'; + } + + try { + $resolved = new DateTimeZone($timezone); + } catch (\Throwable $e) { + $resolved = new DateTimeZone('UTC'); + } + + return $resolved; +} + +function formatRelative(int $seconds): string +{ + if ($seconds < 5) { + return 'just now'; + } + $negative = $seconds < 0; + $seconds = abs($seconds); + $units = [ + 31536000 => 'y', + 2592000 => 'mo', + 604800 => 'w', + 86400 => 'd', + 3600 => 'h', + 60 => 'm', + 1 => 's', + ]; + foreach ($units as $size => $label) { + if ($seconds >= $size) { + $value = (int)floor($seconds / $size); + $suffix = $label === 'mo' ? 'month' : ($label === 'y' ? 'year' : ($label === 'w' ? 'week' : ($label === 'd' ? 'day' : ($label === 'h' ? 'hour' : ($label === 'm' ? 'minute' : 'second'))))); + if ($value !== 1) { + $suffix .= 's'; + } + $phrase = $value . ' ' . $suffix; + return $negative ? 'in ' . $phrase : $phrase . ' ago'; + } + } + + return $negative ? 'in 0 seconds' : '0 seconds ago'; +} + +/** + * @param string $snapshotId + * @param array $options + * @return void + */ +function applySnapshot(string $snapshotId, array $options): void +{ + try { + $service = createUpgradeService($options); + $manifest = $service->rollback($snapshotId); + } catch (\Throwable $e) { + fwrite(STDERR, "Restore failed: " . $e->getMessage() . "\n"); + exit(1); + } + + if (!$manifest) { + fwrite(STDERR, "Snapshot {$snapshotId} not found.\n"); + exit(1); + } + + $version = $manifest['source_version'] ?? $manifest['target_version'] ?? 'unknown'; + echo "Restored snapshot {$snapshotId} (Grav {$version}).\n"; + if (!empty($manifest['id'])) { + echo "Snapshot manifest: {$manifest['id']}\n"; + } + if (!empty($manifest['backup_path'])) { + echo "Snapshot path: {$manifest['backup_path']}\n"; + } + exit(0); +} + +/** + * @param array $options + * @return void + */ +function createManualSnapshot(array $options): void +{ + $label = null; + if (isset($options['label']) && is_string($options['label'])) { + $label = trim($options['label']); + if ($label === '') { + $label = null; + } + } + + try { + $service = createUpgradeService($options); + $manifest = $service->createSnapshot($label); + } catch (\Throwable $e) { + fwrite(STDERR, "Snapshot creation failed: " . $e->getMessage() . "\n"); + exit(1); + } + + $snapshotId = $manifest['id'] ?? null; + if (!$snapshotId) { + $snapshotId = 'unknown'; + } + $version = $manifest['source_version'] ?? $manifest['target_version'] ?? 'unknown'; + + echo "Created snapshot {$snapshotId} (Grav {$version}).\n"; + if ($label) { + echo "Label: {$label}\n"; + } + if (!empty($manifest['backup_path'])) { + echo "Snapshot path: {$manifest['backup_path']}\n"; + } + + exit(0); +} + +/** + * @param list $snapshots + * @return string|null + */ +function promptSnapshotSelection(array $snapshots): ?string +{ + echo "Available snapshots:\n"; + foreach ($snapshots as $index => $snapshot) { + $line = formatSnapshotListLine($snapshot); + $number = $index + 1; + echo sprintf(" [%d] %s\n", $number, $line); + } + + $default = $snapshots[0]['id']; + echo "\nSelect a snapshot to restore [1]: "; + $input = trim((string)fgets(STDIN)); + + if ($input === '') { + return $default; + } + + if (ctype_digit($input)) { + $idx = (int)$input - 1; + if (isset($snapshots[$idx])) { + return $snapshots[$idx]['id']; + } + } + + foreach ($snapshots as $snapshot) { + if (strcasecmp($snapshot['id'], $input) === 0) { + return $snapshot['id']; + } + } + + echo "Invalid selection. Aborting.\n"; + return null; +} + +/** + * @param list $snapshots + * @return array + */ +function promptSnapshotsRemoval(array $snapshots): array +{ + echo "Available snapshots:\n"; + foreach ($snapshots as $index => $snapshot) { + $line = formatSnapshotListLine($snapshot); + $number = $index + 1; + echo sprintf(" [%d] %s\n", $number, $line); + } + + echo "\nSelect snapshots to remove (comma or space separated numbers / ids, 'all' for everything, empty to cancel): "; + $input = trim((string)fgets(STDIN)); + + if ($input === '') { + return []; + } + + $inputLower = strtolower($input); + if ($inputLower === 'all' || $inputLower === '*') { + return array_values(array_unique(array_column($snapshots, 'id'))); + } + + $tokens = preg_split('/[\\s,]+/', $input, -1, PREG_SPLIT_NO_EMPTY) ?: []; + $selected = []; + foreach ($tokens as $token) { + if (ctype_digit($token)) { + $idx = (int)$token - 1; + if (isset($snapshots[$idx])) { + $selected[] = $snapshots[$idx]['id']; + continue; + } + } + + foreach ($snapshots as $snapshot) { + if (strcasecmp($snapshot['id'], $token) === 0) { + $selected[] = $snapshot['id']; + break; + } + } + } + + return array_values(array_unique(array_filter($selected))); +} + +/** + * @param string $snapshotId + * @return array{success:bool,message:string} + */ +function removeSnapshot(string $snapshotId): array +{ + $manifestDir = GRAV_ROOT . '/user/data/upgrades'; + $manifestPath = $manifestDir . '/' . $snapshotId . '.json'; + if (!is_file($manifestPath)) { + return [ + 'success' => false, + 'message' => "Snapshot {$snapshotId} not found." + ]; + } + + $manifest = json_decode(file_get_contents($manifestPath) ?: '', true); + if (!is_array($manifest)) { + return [ + 'success' => false, + 'message' => "Snapshot {$snapshotId} manifest is invalid." + ]; + } + + $pathsToDelete = []; + foreach (['package_path', 'backup_path'] as $key) { + if (!empty($manifest[$key]) && is_string($manifest[$key])) { + $pathsToDelete[] = $manifest[$key]; + } + } + + $errors = []; + + foreach ($pathsToDelete as $path) { + if (!$path) { + continue; + } + if (!file_exists($path)) { + continue; + } + try { + if (is_dir($path)) { + Folder::delete($path); + } else { + @unlink($path); + } + } catch (\Throwable $e) { + $errors[] = "Unable to remove {$path}: " . $e->getMessage(); + } + } + + if (!@unlink($manifestPath)) { + $errors[] = "Unable to delete manifest file {$manifestPath}."; + } + + if ($errors) { + return [ + 'success' => false, + 'message' => implode(' ', $errors) + ]; + } + + return [ + 'success' => true, + 'message' => "Removed snapshot {$snapshotId}." + ]; +} + +$cli = parseArguments($argv); +$command = $cli['command']; +$arguments = $cli['arguments']; +$options = $cli['options']; + +switch ($command) { + case 'interactive': + $snapshots = loadSnapshots(); + if (!$snapshots) { + echo "No snapshots found. Run bin/gpm self-upgrade (with safe upgrade enabled) to create one.\n"; + exit(0); + } + + $selection = promptSnapshotSelection($snapshots); + if (!$selection) { + exit(1); + } + + applySnapshot($selection, $options); + break; + + case 'list': + $snapshots = loadSnapshots(); + if (!$snapshots) { + echo "No snapshots found. Run bin/gpm self-upgrade (with safe upgrade enabled) to create one.\n"; + exit(0); + } + + echo "Available snapshots:\n"; + foreach ($snapshots as $snapshot) { + echo ' - ' . formatSnapshotListLine($snapshot) . "\n"; + } + exit(0); + + case 'remove': + $snapshots = loadSnapshots(); + if (!$snapshots) { + echo "No snapshots found. Nothing to remove.\n"; + exit(0); + } + + $selectedIds = []; + if ($arguments) { + foreach ($arguments as $arg) { + if (!$arg) { + continue; + } + $selectedIds[] = $arg; + } + } else { + $selectedIds = promptSnapshotsRemoval($snapshots); + if (!$selectedIds) { + echo "No snapshots selected. Aborting.\n"; + exit(1); + } + } + + $selectedIds = array_values(array_unique($selectedIds)); + echo "Snapshots selected for removal:\n"; + foreach ($selectedIds as $id) { + echo " - {$id}\n"; + } + + $autoConfirm = isset($options['yes']) || isset($options['y']); + if (!$autoConfirm) { + echo "\nThis action cannot be undone. Proceed? [y/N] "; + $confirmation = strtolower(trim((string)fgets(STDIN))); + if (!in_array($confirmation, ['y', 'yes'], true)) { + echo "Aborted.\n"; + exit(1); + } + } + + $success = 0; + foreach ($selectedIds as $id) { + $result = removeSnapshot($id); + echo $result['message'] . "\n"; + if ($result['success']) { + $success++; + } + } + + exit($success > 0 ? 0 : 1); + + case 'apply': + $snapshotId = $arguments[0] ?? null; + if (!$snapshotId) { + echo "Missing snapshot id.\n\n" . RESTORE_USAGE . "\n"; + exit(1); + } + + applySnapshot($snapshotId, $options); + break; + + case 'snapshot': + createManualSnapshot($options); + break; + + case 'recovery': + $action = strtolower($arguments[0] ?? 'status'); + $manager = new RecoveryManager(GRAV_ROOT); + + switch ($action) { + case 'clear': + if ($manager->isActive()) { + $manager->clear(); + echo "Recovery flag cleared.\n"; + } else { + echo "Recovery mode is not active.\n"; + } + exit(0); + + case 'status': + if (!$manager->isActive()) { + echo "Recovery mode is not active.\n"; + exit(0); + } + + $context = $manager->getContext(); + if (!$context) { + echo "Recovery flag present but context could not be parsed.\n"; + exit(1); + } + + $created = isset($context['created_at']) ? date('c', (int)$context['created_at']) : 'unknown'; + $token = $context['token'] ?? '(missing)'; + $message = $context['message'] ?? '(no message)'; + $plugin = $context['plugin'] ?? '(none detected)'; + $file = $context['file'] ?? '(unknown file)'; + $line = $context['line'] ?? '(unknown line)'; + + echo "Recovery flag context:\n"; + echo " Token: {$token}\n"; + echo " Message: {$message}\n"; + echo " Plugin: {$plugin}\n"; + 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: + echo "Unknown recovery action: {$action}\n\n" . RESTORE_USAGE . "\n"; + exit(1); + } + + case 'help': + default: + echo RESTORE_USAGE . "\n"; + exit($command === 'help' ? 0 : 1); +} diff --git a/index.php b/index.php index 242794a05..efb32572a 100644 --- a/index.php +++ b/index.php @@ -11,6 +11,9 @@ namespace Grav; \define('GRAV_REQUEST_TIME', microtime(true)); \define('GRAV_PHP_MIN', '7.3.6'); +if (!\defined('GRAV_ROOT')) { + \define('GRAV_ROOT', __DIR__); +} if (PHP_SAPI === 'cli-server') { $symfony_server = stripos(getenv('_'), 'symfony') !== false || stripos($_SERVER['SERVER_SOFTWARE'] ?? '', 'symfony') !== false || stripos($_ENV['SERVER_SOFTWARE'] ?? '', 'symfony') !== false; @@ -20,6 +23,36 @@ if (PHP_SAPI === 'cli-server') { } } +if (PHP_SAPI !== 'cli') { + $requestUri = $_SERVER['REQUEST_URI'] ?? ''; + $scriptName = $_SERVER['SCRIPT_NAME'] ?? ''; + $path = parse_url($requestUri, PHP_URL_PATH) ?? '/'; + $path = str_replace('\\', '/', $path); + + $scriptDir = str_replace('\\', '/', dirname($scriptName)); + if ($scriptDir && $scriptDir !== '/' && $scriptDir !== '.') { + if (strpos($path, $scriptDir) === 0) { + $path = substr($path, strlen($scriptDir)); + $path = $path === '' ? '/' : $path; + } + } + + if ($path === '/___safe-upgrade-status') { + $statusEndpoint = __DIR__ . '/user/plugins/admin/safe-upgrade-status.php'; + header('Content-Type: application/json; charset=utf-8'); + if (is_file($statusEndpoint)) { + require $statusEndpoint; + } else { + http_response_code(404); + echo json_encode([ + 'status' => 'error', + 'message' => 'Safe upgrade status endpoint unavailable.', + ]); + } + exit; + } +} + // Ensure vendor libraries exist $autoload = __DIR__ . '/vendor/autoload.php'; if (!is_file($autoload)) { @@ -29,6 +62,18 @@ if (!is_file($autoload)) { // Register the auto-loader. $loader = require $autoload; +if (!class_exists(\Symfony\Component\ErrorHandler\Exception\FlattenException::class, false) && class_exists(\Symfony\Component\HttpKernel\Exception\FlattenException::class)) { + class_alias(\Symfony\Component\HttpKernel\Exception\FlattenException::class, \Symfony\Component\ErrorHandler\Exception\FlattenException::class); +} + +if (!class_exists(\Monolog\Logger::class, false)) { + class_exists(\Monolog\Logger::class); +} + +if (defined('Monolog\Logger::API') && \Monolog\Logger::API < 3) { + require_once __DIR__ . '/system/src/Grav/Framework/Compat/Monolog/bootstrap.php'; +} + // Set timezone to default, falls back to system if php.ini not set date_default_timezone_set(@date_default_timezone_get()); @@ -36,6 +81,12 @@ date_default_timezone_set(@date_default_timezone_get()); @ini_set('default_charset', 'UTF-8'); mb_internal_encoding('UTF-8'); +$recoveryFlag = __DIR__ . '/user/data/recovery.flag'; +if (PHP_SAPI !== 'cli' && is_file($recoveryFlag)) { + require __DIR__ . '/system/recovery.php'; + return 0; +} + use Grav\Common\Grav; use RocketTheme\Toolbox\Event\Event; diff --git a/system/blueprints/config/system.yaml b/system/blueprints/config/system.yaml index 13b04d2f2..c90bb2d5f 100644 --- a/system/blueprints/config/system.yaml +++ b/system/blueprints/config/system.yaml @@ -1598,6 +1598,22 @@ form: validate: type: bool + updates_section: + type: section + title: PLUGIN_ADMIN.UPDATES_SECTION + + updates.safe_upgrade: + type: toggle + label: PLUGIN_ADMIN.SAFE_UPGRADE + help: PLUGIN_ADMIN.SAFE_UPGRADE_HELP + highlight: 1 + default: true + options: + 1: PLUGIN_ADMIN.YES + 0: PLUGIN_ADMIN.NO + validate: + type: bool + http_section: type: section title: PLUGIN_ADMIN.HTTP_SECTION @@ -1912,6 +1928,3 @@ form: # # pages.type: # type: hidden - - - diff --git a/system/config/system.yaml b/system/config/system.yaml index 984f46771..87b6d4c1c 100644 --- a/system/config/system.yaml +++ b/system/config/system.yaml @@ -203,6 +203,9 @@ gpm: releases: stable # Set to either 'stable' or 'testing' official_gpm_only: true # By default GPM direct-install will only allow URLs via the official GPM proxy to ensure security +updates: + safe_upgrade: true # Enable guarded staging+rollback pipeline for Grav self-updates + http: method: auto # Either 'curl', 'fopen' or 'auto'. 'auto' will try fopen first and if not available cURL enable_proxy: true # Enable proxy server configuration diff --git a/system/defines.php b/system/defines.php index 7c1e89007..8b28519a2 100644 --- a/system/defines.php +++ b/system/defines.php @@ -9,7 +9,7 @@ // Some standard defines define('GRAV', true); -define('GRAV_VERSION', '1.7.49.5'); +define('GRAV_VERSION', '1.7.50'); define('GRAV_SCHEMA', '1.7.0_2020-11-20_1'); define('GRAV_TESTING', false); diff --git a/system/languages/en.yaml b/system/languages/en.yaml index 7a8a68c00..3b34a93db 100644 --- a/system/languages/en.yaml +++ b/system/languages/en.yaml @@ -119,3 +119,8 @@ GRAV: ERROR2: Bad number of elements ERROR3: The jquery_element should be set into jqCron settings ERROR4: Unrecognized expression + +PLUGIN_ADMIN: + UPDATES_SECTION: Updates + SAFE_UPGRADE: Safe self-upgrade + SAFE_UPGRADE_HELP: When enabled, Grav core updates use staged installation with automatic rollback support. diff --git a/system/recovery.php b/system/recovery.php new file mode 100644 index 000000000..9c62903dd --- /dev/null +++ b/system/recovery.php @@ -0,0 +1,181 @@ + 'grav-recovery', + 'cookie_httponly' => true, + 'cookie_secure' => !empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off', + 'cookie_samesite' => 'Lax', +]); + +$manager = new RecoveryManager(); +$context = $manager->getContext() ?? []; +$token = $context['token'] ?? null; +$authenticated = $token && isset($_SESSION['grav_recovery_authenticated']) && hash_equals($_SESSION['grav_recovery_authenticated'], $token); +$errorMessage = null; +$notice = null; + +if ($_SERVER['REQUEST_METHOD'] === 'POST') { + $action = $_POST['action'] ?? ''; + if ($action === 'authenticate') { + $provided = trim($_POST['token'] ?? ''); + if ($token && hash_equals($token, $provided)) { + $_SESSION['grav_recovery_authenticated'] = $token; + header('Location: ' . $_SERVER['REQUEST_URI']); + exit; + } + $errorMessage = 'Invalid recovery token.'; + } elseif ($authenticated) { + $service = new SafeUpgradeService(); + try { + if ($action === 'rollback' && !empty($_POST['manifest'])) { + $service->rollback(trim($_POST['manifest'])); + $manager->clear(); + $_SESSION['grav_recovery_authenticated'] = null; + $notice = 'Rollback complete. Please reload Grav.'; + } + if ($action === 'clear-flag') { + $manager->clear(); + $_SESSION['grav_recovery_authenticated'] = null; + $notice = 'Recovery flag cleared.'; + } + } catch (\Throwable $e) { + $errorMessage = $e->getMessage(); + } + } else { + $errorMessage = 'Authentication required.'; + } +} + +$quarantineFile = GRAV_ROOT . '/user/data/upgrades/quarantine.json'; +$quarantine = []; +if (is_file($quarantineFile)) { + $decoded = json_decode(file_get_contents($quarantineFile), true); + if (is_array($decoded)) { + $quarantine = $decoded; + } +} + +$manifestDir = GRAV_ROOT . '/user/data/upgrades'; +$manifests = []; +if (is_dir($manifestDir)) { + $files = glob($manifestDir . '/*.json'); + if ($files) { + rsort($files); + foreach ($files as $file) { + $decoded = json_decode(file_get_contents($file), true); + if (is_array($decoded)) { + $decoded['file'] = basename($file); + $manifests[] = $decoded; + } + } + } +} + +header('Content-Type: text/html; charset=utf-8'); + +?> + + + + + Grav Recovery Mode + + + +
+

Grav Recovery Mode

+ +
+ + +
+ + + +

This site is running in recovery mode because Grav detected a fatal error.

+

Locate the recovery token in user/data/recovery.flag and enter it below.

+
+ + + + +
+ +
+

Failure Details

+
    +
  • Message:
  • +
  • File:
  • +
  • Line:
  • + +
  • Quarantined plugin:
  • + +
+
+ + +
+

Quarantined Plugins

+
    + +
  • + + (disabled at )
    + +
  • + +
+
+ + +
+

Rollback

+ +
+ + + + +
+ +

No upgrade snapshots were found.

+ +
+ +
+ + +
+ +
+ + diff --git a/system/src/Grav/Common/Cache.php b/system/src/Grav/Common/Cache.php index c7afcbe55..337733ae1 100644 --- a/system/src/Grav/Common/Cache.php +++ b/system/src/Grav/Common/Cache.php @@ -550,6 +550,9 @@ class Cache extends Getters $anything = true; } } elseif (is_dir($file)) { + if (basename($file) === 'grav-snapshots') { + continue; + } if (Folder::delete($file, false)) { $anything = true; } diff --git a/system/src/Grav/Common/Filesystem/Folder.php b/system/src/Grav/Common/Filesystem/Folder.php index 019342f9e..c01ab36bb 100644 --- a/system/src/Grav/Common/Filesystem/Folder.php +++ b/system/src/Grav/Common/Filesystem/Folder.php @@ -478,12 +478,22 @@ abstract class Folder * @return bool * @throws RuntimeException */ - public static function rcopy($src, $dest) + public static function rcopy($src, $dest, $preservePermissions = false) { // If the src is not a directory do a simple file copy if (!is_dir($src)) { copy($src, $dest); + if ($preservePermissions) { + $perm = @fileperms($src); + if ($perm !== false) { + @chmod($dest, $perm & 0777); + } + $mtime = @filemtime($src); + if ($mtime !== false) { + @touch($dest, $mtime); + } + } return true; } @@ -492,14 +502,32 @@ abstract class Folder static::create($dest); } + if ($preservePermissions) { + $perm = @fileperms($src); + if ($perm !== false) { + @chmod($dest, $perm & 0777); + } + } + // Open the source directory to read in files $i = new DirectoryIterator($src); foreach ($i as $f) { if ($f->isFile()) { - copy($f->getRealPath(), "{$dest}/" . $f->getFilename()); + $target = "{$dest}/" . $f->getFilename(); + copy($f->getRealPath(), $target); + if ($preservePermissions) { + $perm = @fileperms($f->getRealPath()); + if ($perm !== false) { + @chmod($target, $perm & 0777); + } + $mtime = @filemtime($f->getRealPath()); + if ($mtime !== false) { + @touch($target, $mtime); + } + } } else { if (!$f->isDot() && $f->isDir()) { - static::rcopy($f->getRealPath(), "{$dest}/{$f}"); + static::rcopy($f->getRealPath(), "{$dest}/{$f}", $preservePermissions); } } } diff --git a/system/src/Grav/Common/GPM/GPM.php b/system/src/Grav/Common/GPM/GPM.php index d4690c272..4baeafe42 100644 --- a/system/src/Grav/Common/GPM/GPM.php +++ b/system/src/Grav/Common/GPM/GPM.php @@ -10,6 +10,7 @@ namespace Grav\Common\GPM; use Exception; +use Grav\Common\Data\Data; use Grav\Common\Grav; use Grav\Common\Filesystem\Folder; use Grav\Common\HTTP\Response; @@ -24,6 +25,7 @@ use function count; use function in_array; use function is_array; use function is_object; +use function property_exists; /** * Class GPM @@ -322,6 +324,10 @@ class GPM extends Iterator continue; } + if (!$this->isRemotePackagePublished($plugins[$slug])) { + continue; + } + $local_version = $plugin->version ?? 'Unknown'; $remote_version = $plugins[$slug]->version; @@ -414,6 +420,10 @@ class GPM extends Iterator continue; } + if (!$this->isRemotePackagePublished($themes[$slug])) { + continue; + } + $local_version = $plugin->version ?? 'Unknown'; $remote_version = $themes[$slug]->version; @@ -468,6 +478,42 @@ class GPM extends Iterator return null; } + /** + * Determine whether a remote package is marked as published. + * + * Remote package metadata introduced a `published` flag to hide releases that are not yet public. + * Older repository payloads may omit the key, so we default to treating packages as published + * unless the flag is explicitly set to `false`. + * + * @param object|array $package + * @return bool + */ + protected function isRemotePackagePublished($package): bool + { + if (is_object($package) && method_exists($package, 'getData')) { + $data = $package->getData(); + if ($data instanceof Data) { + $published = $data->get('published'); + return $published !== false; + } + } + + if (is_array($package)) { + if (array_key_exists('published', $package)) { + return $package['published'] !== false; + } + + return true; + } + + $value = null; + if (is_object($package) && property_exists($package, 'published')) { + $value = $package->published; + } + + return $value !== false; + } + /** * Returns true if the package latest release is stable * diff --git a/system/src/Grav/Common/Grav.php b/system/src/Grav/Common/Grav.php index 892f1d065..f11e90c9a 100644 --- a/system/src/Grav/Common/Grav.php +++ b/system/src/Grav/Common/Grav.php @@ -46,6 +46,7 @@ use Grav\Common\Service\SessionServiceProvider; use Grav\Common\Service\StreamsServiceProvider; use Grav\Common\Service\TaskServiceProvider; use Grav\Common\Twig\Twig; +use Grav\Common\Recovery\RecoveryManager; use Grav\Framework\DI\Container; use Grav\Framework\Psr7\Response; use Grav\Framework\RequestHandler\Middlewares\MultipartRequestSupport; @@ -110,6 +111,7 @@ class Grav extends Container 'scheduler' => Scheduler::class, 'taxonomy' => Taxonomy::class, 'themes' => Themes::class, + 'recovery' => RecoveryManager::class, 'twig' => Twig::class, 'uri' => Uri::class, ]; diff --git a/system/src/Grav/Common/Processors/InitializeProcessor.php b/system/src/Grav/Common/Processors/InitializeProcessor.php index 43a88d05d..bf869be22 100644 --- a/system/src/Grav/Common/Processors/InitializeProcessor.php +++ b/system/src/Grav/Common/Processors/InitializeProcessor.php @@ -78,6 +78,9 @@ class InitializeProcessor extends ProcessorBase // Initialize error handlers. $this->initializeErrors(); + // Register recovery shutdown handler early in the lifecycle. + $this->container['recovery']->registerHandlers(); + // Initialize debugger. $debugger = $this->initializeDebugger(); @@ -145,6 +148,9 @@ class InitializeProcessor extends ProcessorBase // Disable debugger. $this->container['debugger']->enabled(false); + // Register recovery handler for CLI commands as well. + $this->container['recovery']->registerHandlers(); + // Set timezone, locale. $this->initializeLocale($config); diff --git a/system/src/Grav/Common/Recovery/RecoveryManager.php b/system/src/Grav/Common/Recovery/RecoveryManager.php new file mode 100644 index 000000000..e75f7267e --- /dev/null +++ b/system/src/Grav/Common/Recovery/RecoveryManager.php @@ -0,0 +1,436 @@ +rootPath = rtrim($root, DIRECTORY_SEPARATOR); + $this->userPath = $this->rootPath . '/user'; + } + + /** + * Register shutdown handler to capture fatal errors at runtime. + * + * @return void + */ + public function registerHandlers(): void + { + if ($this->registered) { + return; + } + + register_shutdown_function([$this, 'handleShutdown']); + $this->registered = true; + } + + /** + * Check if recovery mode flag is active. + * + * @return bool + */ + public function isActive(): bool + { + return is_file($this->flagPath()); + } + + /** + * Remove recovery flag. + * + * @return void + */ + public function clear(): void + { + $flag = $this->flagPath(); + if (is_file($flag)) { + @unlink($flag); + } + + $this->closeUpgradeWindow(); + } + + /** + * Shutdown handler capturing fatal errors. + * + * @return void + */ + public function handleShutdown(): void + { + $error = $this->resolveLastError(); + if (!$error) { + return; + } + + $type = $error['type'] ?? 0; + if (!$this->isFatal($type)) { + return; + } + + $file = $error['file'] ?? ''; + $plugin = $this->detectPluginFromPath($file); + if (!$plugin) { + return; + } + + $context = [ + 'created_at' => time(), + 'message' => $error['message'] ?? '', + 'file' => $file, + 'line' => $error['line'] ?? null, + 'type' => $type, + 'plugin' => $plugin, + ]; + + if (!$this->shouldEnterRecovery($context)) { + return; + } + + $this->activate($context); + if ($plugin) { + $this->quarantinePlugin($plugin, $context); + } + } + + /** + * Activate recovery mode and record context. + * + * @param array $context + * @return void + */ + public function activate(array $context): void + { + $flag = $this->flagPath(); + Folder::create(dirname($flag)); + if (empty($context['token'])) { + $context['token'] = $this->generateToken(); + } + if (!is_file($flag)) { + file_put_contents($flag, json_encode($context, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n"); + } else { + // Merge context if flag already exists. + $existing = json_decode(file_get_contents($flag), true); + if (is_array($existing)) { + $context = $context + $existing; + if (empty($context['token'])) { + $context['token'] = $this->generateToken(); + } + } + file_put_contents($flag, json_encode($context, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n"); + } + } + + /** + * Return last recorded recovery context. + * + * @return array|null + */ + public function getContext(): ?array + { + $flag = $this->flagPath(); + if (!is_file($flag)) { + return null; + } + + $decoded = json_decode(file_get_contents($flag), true); + + return is_array($decoded) ? $decoded : null; + } + + /** + * @param string $slug + * @param array $context + * @return void + */ + public function disablePlugin(string $slug, array $context = []): void + { + $context += [ + 'message' => $context['message'] ?? 'Disabled during upgrade preflight', + 'file' => $context['file'] ?? '', + 'line' => $context['line'] ?? null, + 'created_at' => $context['created_at'] ?? time(), + 'plugin' => $context['plugin'] ?? $slug, + ]; + + $this->quarantinePlugin($slug, $context); + } + + /** + * @param string $slug + * @param array $context + * @return void + */ + protected function quarantinePlugin(string $slug, array $context): void + { + $slug = trim($slug); + if ($slug === '') { + return; + } + + $configPath = $this->userPath . '/config/plugins/' . $slug . '.yaml'; + Folder::create(dirname($configPath)); + + $configuration = is_file($configPath) ? Yaml::parse(file_get_contents($configPath)) : []; + if (!is_array($configuration)) { + $configuration = []; + } + + if (($configuration['enabled'] ?? true) === false) { + return; + } + + $configuration['enabled'] = false; + $yaml = Yaml::dump($configuration); + file_put_contents($configPath, $yaml); + + $quarantineFile = $this->userPath . '/data/upgrades/quarantine.json'; + Folder::create(dirname($quarantineFile)); + + $quarantine = []; + if (is_file($quarantineFile)) { + $decoded = json_decode(file_get_contents($quarantineFile), true); + if (is_array($decoded)) { + $quarantine = $decoded; + } + } + + $quarantine[$slug] = [ + 'slug' => $slug, + 'disabled_at' => time(), + 'message' => $context['message'] ?? '', + 'file' => $context['file'] ?? '', + 'line' => $context['line'] ?? null, + ]; + + file_put_contents($quarantineFile, json_encode($quarantine, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n"); + } + + /** + * Determine if error type is fatal. + * + * @param int $type + * @return bool + */ + private function isFatal(int $type): bool + { + return in_array($type, [E_ERROR, E_CORE_ERROR, E_COMPILE_ERROR, E_PARSE], true); + } + + /** + * Attempt to derive plugin slug from file path. + * + * @param string $file + * @return string|null + */ + private function detectPluginFromPath(string $file): ?string + { + if (!$file) { + return null; + } + + if (preg_match('#/user/plugins/([^/]+)/#', $file, $matches)) { + return $matches[1] ?? null; + } + + return null; + } + + /** + * @return string + */ + private function flagPath(): string + { + return $this->userPath . '/data/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 + */ + protected function generateToken(): string + { + try { + return bin2hex($this->randomBytes(10)); + } catch (\Throwable $e) { + return md5(uniqid('grav-recovery', true)); + } + } + + /** + * @param int $length + * @return string + */ + protected function randomBytes(int $length): string + { + return random_bytes($length); + } + + /** + * @return array|null + */ + protected function resolveLastError(): ?array + { + 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/Common/Security.php b/system/src/Grav/Common/Security.php index 4dcf8fb7d..14cb772e3 100644 --- a/system/src/Grav/Common/Security.php +++ b/system/src/Grav/Common/Security.php @@ -281,7 +281,13 @@ class Security 'twig.safe_functions', 'read_file', ]; + $string = preg_replace('/(({{\s*|{%\s*)[^}]*?(' . implode('|', $bad_twig) . ')[^}]*?(\s*}}|\s*%}))/i', '{# $1 #}', $string); + + foreach ($bad_twig as $func) { + $string = preg_replace('/\b' . preg_quote($func, '/') . '(\s*\([^)]*\))?\b/i', '{# $1 #}', $string); + } + return $string; } } diff --git a/system/src/Grav/Common/Twig/Extension/GravExtension.php b/system/src/Grav/Common/Twig/Extension/GravExtension.php index 9a8eeb22e..9dcb49af4 100644 --- a/system/src/Grav/Common/Twig/Extension/GravExtension.php +++ b/system/src/Grav/Common/Twig/Extension/GravExtension.php @@ -140,6 +140,7 @@ class GravExtension extends AbstractExtension implements GlobalsInterface new TwigFilter('starts_with', [$this, 'startsWithFilter']), new TwigFilter('truncate', [Utils::class, 'truncate']), new TwigFilter('truncate_html', [Utils::class, 'truncateHTML']), + new TwigFilter('wordcount', [$this, 'wordCountFilter']), new TwigFilter('json_decode', [$this, 'jsonDecodeFilter']), new TwigFilter('array_unique', 'array_unique'), new TwigFilter('basename', 'basename'), @@ -578,6 +579,62 @@ class GravExtension extends AbstractExtension implements GlobalsInterface return $str; } + /** + * Count words in text with improved accuracy for multiple languages + * + * @param string $text The text to count words from + * @param string $locale Optional locale for language-specific counting (default: 'en') + * @return int Number of words + */ + public function wordCountFilter($text, string $locale = 'en'): int + { + if (empty($text)) { + return 0; + } + + // Strip HTML tags and decode entities + $cleanText = html_entity_decode(strip_tags($text), ENT_QUOTES, 'UTF-8'); + + // Remove extra whitespace and normalize + $cleanText = trim(preg_replace('/\s+/', ' ', $cleanText)); + + if (empty($cleanText)) { + return 0; + } + + // Handle different languages + switch (strtolower($locale)) { + case 'zh': + case 'zh-cn': + case 'zh-tw': + case 'chinese': + // Chinese: count characters (excluding spaces and punctuation) + return mb_strlen(preg_replace('/[\s\p{P}]/u', '', $cleanText), 'UTF-8'); + + case 'ja': + case 'japanese': + // Japanese: count characters (excluding spaces) + return mb_strlen(preg_replace('/\s/', '', $cleanText), 'UTF-8'); + + case 'ko': + case 'korean': + // Korean: count characters (excluding spaces) + return mb_strlen(preg_replace('/\s/', '', $cleanText), 'UTF-8'); + + default: + // Western languages: use improved word counting + // Handle contractions, hyphenated words, and numbers better + $words = preg_split('/\s+/', $cleanText, -1, PREG_SPLIT_NO_EMPTY); + + // Filter out pure punctuation + $words = array_filter($words, function($word) { + return preg_match('/\w/', $word); + }); + + return count($words); + } + } + /** * Get Cron object for a crontab 'at' format * diff --git a/system/src/Grav/Common/Upgrade/SafeUpgradeService.php b/system/src/Grav/Common/Upgrade/SafeUpgradeService.php new file mode 100644 index 000000000..b8cab0a8a --- /dev/null +++ b/system/src/Grav/Common/Upgrade/SafeUpgradeService.php @@ -0,0 +1,967 @@ +rootPath = rtrim($root, DIRECTORY_SEPARATOR); + $this->config = $options['config'] ?? null; + + $locator = null; + try { + $locator = Grav::instance()['locator'] ?? null; + } catch (Throwable $e) { + $locator = null; + } + + $primary = null; + if ($locator && method_exists($locator, 'findResource')) { + try { + $primary = $locator->findResource('tmp://grav-snapshots', true, true); + } catch (Throwable $e) { + $primary = null; + } + } + + if (!$primary) { + $primary = $this->rootPath . DIRECTORY_SEPARATOR . 'tmp' . DIRECTORY_SEPARATOR . 'grav-snapshots'; + } + + $this->stagingRoot = $this->resolveStagingPath($primary); + + if (null === $this->stagingRoot) { + throw new RuntimeException('Unable to locate writable staging directory. Ensure tmp://grav-snapshots is writable.'); + } + $this->manifestStore = $options['manifest_store'] ?? ($this->rootPath . DIRECTORY_SEPARATOR . 'user' . DIRECTORY_SEPARATOR . 'data' . DIRECTORY_SEPARATOR . 'upgrades'); + if (isset($options['ignored_dirs']) && is_array($options['ignored_dirs'])) { + $this->ignoredDirs = $options['ignored_dirs']; + } + } + + /** + * Run preflight validations before attempting an upgrade. + * + * @return array{plugins_pending: array, psr_log_conflicts: array, warnings: string[]} + */ + public function preflight(): array + { + $warnings = []; + try { + $pending = $this->detectPendingPluginUpdates(); + } catch (RuntimeException $e) { + $pending = []; + $warnings[] = $e->getMessage(); + } + + $psrLogConflicts = $this->detectPsrLogConflicts(); + $monologConflicts = $this->detectMonologConflicts(); + if ($pending) { + $warnings[] = 'One or more plugins/themes are not up to date.'; + } + if ($psrLogConflicts) { + $warnings[] = 'Potential psr/log signature conflicts detected.'; + } + if ($monologConflicts) { + $warnings[] = 'Potential Monolog logger API incompatibilities detected.'; + } + + return [ + 'plugins_pending' => $pending, + 'psr_log_conflicts' => $psrLogConflicts, + 'monolog_conflicts' => $monologConflicts, + 'warnings' => $warnings, + ]; + } + + /** + * Stage and promote a Grav update from an extracted folder. + * + * @param string $extractedPath Path to the extracted update package. + * @param string $targetVersion Target Grav version. + * @param array $ignores + * @return array Manifest data. + */ + public function promote(string $extractedPath, string $targetVersion, array $ignores): array + { + if (!is_dir($extractedPath)) { + throw new InvalidArgumentException(sprintf('Extracted package path "%s" is not a directory.', $extractedPath)); + } + + $stageId = uniqid('stage-', false); + $stagePath = $this->stagingRoot . DIRECTORY_SEPARATOR . $stageId; + $packagePath = $stagePath . DIRECTORY_SEPARATOR . 'package'; + $backupPath = $this->stagingRoot . DIRECTORY_SEPARATOR . 'snapshot-' . $stageId; + + Folder::create(dirname($packagePath)); + + $this->reportProgress('installing', 'Preparing staged package...', null); + $stagingMode = $this->stageExtractedPackage($extractedPath, $packagePath); + $this->reportProgress('installing', 'Preparing staged package...', null, ['mode' => $stagingMode]); + + $this->carryOverRootDotfiles($packagePath); + + // Ensure ignored directories are replaced with live copies. + $this->hydrateIgnoredDirectories($packagePath, $ignores); + $this->carryOverRootFiles($packagePath, $ignores); + + $entries = $this->collectPackageEntries($packagePath); + if (!$entries) { + throw new RuntimeException('Staged package does not contain any files to promote.'); + } + + $this->reportProgress('snapshot', 'Creating backup snapshot...', null); + $this->createBackupSnapshot($entries, $backupPath); + + $manifest = $this->buildManifest($stageId, $targetVersion, $packagePath, $backupPath, $entries); + $manifestPath = $stagePath . DIRECTORY_SEPARATOR . 'manifest.json'; + Folder::create(dirname($manifestPath)); + file_put_contents($manifestPath, json_encode($manifest, JSON_PRETTY_PRINT)); + + $this->reportProgress('installing', 'Copying update files...', null); + + try { + $this->copyEntries($entries, $packagePath, $this->rootPath, 'installing', 'Deploying'); + } catch (Throwable $e) { + $this->copyEntries($entries, $backupPath, $this->rootPath, 'installing', 'Restoring'); + throw new RuntimeException('Failed to promote staged Grav release.', 0, $e); + } + + $this->reportProgress('finalizing', 'Finalizing upgrade...', null); + $this->persistManifest($manifest); + $this->pruneOldSnapshots(); + Folder::delete($stagePath); + + return $manifest; + } + + /** + * Create a manual snapshot of the current Grav installation. + * + * @param string|null $label + * @return array + */ + public function createSnapshot(?string $label = null): array + { + $entries = $this->collectPackageEntries($this->rootPath); + if (!$entries) { + throw new RuntimeException('Unable to locate files to snapshot.'); + } + + $stageId = uniqid('snapshot-', false); + $backupPath = $this->stagingRoot . DIRECTORY_SEPARATOR . 'snapshot-' . $stageId; + + $this->reportProgress('snapshot', 'Creating manual snapshot...', null, [ + 'operation' => 'snapshot', + 'label' => $label, + 'mode' => 'manual', + ]); + + $this->createBackupSnapshot($entries, $backupPath); + + $manifest = $this->buildManifest($stageId, GRAV_VERSION, $this->rootPath, $backupPath, $entries); + $manifest['package_path'] = null; + if ($label !== null && $label !== '') { + $manifest['label'] = $label; + } + $manifest['operation'] = 'snapshot'; + $manifest['mode'] = 'manual'; + + $this->persistManifest($manifest); + $this->pruneOldSnapshots(); + + $this->reportProgress('complete', sprintf('Snapshot %s created.', $stageId), 100, [ + 'operation' => 'snapshot', + 'snapshot' => $stageId, + 'version' => $manifest['target_version'] ?? null, + 'mode' => 'manual', + ]); + + return $manifest; + } + + private function collectPackageEntries(string $packagePath): array + { + $entries = []; + $iterator = new DirectoryIterator($packagePath); + foreach ($iterator as $fileinfo) { + if ($fileinfo->isDot()) { + continue; + } + + $name = $fileinfo->getFilename(); + if (in_array($name, $this->ignoredDirs, true)) { + continue; + } + + $entries[] = $name; + } + + sort($entries); + + return $entries; + } + + private function stageExtractedPackage(string $sourcePath, string $packagePath): string + { + if (is_dir($packagePath)) { + Folder::delete($packagePath); + } + + if (@rename($sourcePath, $packagePath)) { + return 'move'; + } + + Folder::create($packagePath); + $entries = $this->collectPackageEntries($sourcePath); + $this->copyEntries($entries, $sourcePath, $packagePath, 'installing', 'Staging'); + Folder::delete($sourcePath); + + return 'copy'; + } + + private function createBackupSnapshot(array $entries, string $backupPath): void + { + Folder::create($backupPath); + $this->copyEntries($entries, $this->rootPath, $backupPath, 'snapshot', 'Snapshotting'); + } + + private function copyEntries(array $entries, string $sourceBase, string $targetBase, ?string $progressStage = null, ?string $progressPrefix = null): void + { + $total = count($entries); + foreach ($entries as $index => $entry) { + $source = $sourceBase . DIRECTORY_SEPARATOR . $entry; + if (!is_file($source) && !is_dir($source) && !is_link($source)) { + continue; + } + + if ($progressStage) { + $message = sprintf( + '%s %s (%d/%d)', + $progressPrefix ?? 'Processing', + $entry, + $index + 1, + max($total, 1) + ); + $percent = $total > 0 ? (int)floor((($index + 1) / $total) * 100) : null; + $this->reportProgress($progressStage, $message, $percent ?: null, [ + 'entry' => $entry, + 'index' => $index + 1, + 'total' => $total, + ]); + } + + $destination = $targetBase . DIRECTORY_SEPARATOR . $entry; + $this->removeEntry($destination); + + if (is_link($source)) { + Folder::create(dirname($destination)); + if (!@symlink(readlink($source), $destination)) { + throw new RuntimeException(sprintf('Failed to replicate symlink "%s".', $source)); + } + } elseif (is_dir($source)) { + Folder::create(dirname($destination)); + Folder::rcopy($source, $destination, true); + } else { + Folder::create(dirname($destination)); + if (!@copy($source, $destination)) { + throw new RuntimeException(sprintf('Failed to copy file "%s" to "%s".', $source, $destination)); + } + $perm = @fileperms($source); + if ($perm !== false) { + @chmod($destination, $perm & 0777); + } + $mtime = @filemtime($source); + if ($mtime !== false) { + @touch($destination, $mtime); + } + } + } + } + + private function removeEntry(string $path): void + { + if (is_link($path) || is_file($path)) { + @unlink($path); + } elseif (is_dir($path)) { + Folder::delete($path); + } + } + + public function setProgressCallback(?callable $callback): self + { + $this->progressCallback = $callback; + + return $this; + } + + private function reportProgress(string $stage, string $message, ?int $percent = null, array $extra = []): void + { + if ($this->progressCallback) { + ($this->progressCallback)($stage, $message, $percent, $extra); + } + } + + /** + * Roll back to the most recent snapshot. + * + * @param string|null $id + * @return array|null + */ + public function rollback(?string $id = null): ?array + { + $manifest = $this->resolveManifest($id); + if (!$manifest) { + return null; + } + + $backupPath = $manifest['backup_path'] ?? null; + if (!$backupPath || !is_dir($backupPath)) { + throw new RuntimeException('Rollback snapshot is no longer available.'); + } + + $entries = $manifest['entries'] ?? []; + if (!$entries) { + $entries = $this->collectPackageEntries($backupPath); + } + if (!$entries) { + throw new RuntimeException('Rollback snapshot entries are missing from the manifest.'); + } + + $this->reportProgress('rollback', 'Restoring snapshot...', null); + $this->copyEntries($entries, $backupPath, $this->rootPath, 'rollback', 'Restoring'); + $this->markRollback($manifest['id']); + + return $manifest; + } + + /** + * @return void + */ + public function clearRecoveryFlag(): void + { + $flag = $this->rootPath . '/user/data/recovery.flag'; + if (is_file($flag)) { + @unlink($flag); + } + } + + /** + * @return array + */ + protected function detectPendingPluginUpdates(): array + { + try { + $gpm = new GPM(); + } catch (Throwable $e) { + throw new RuntimeException('Unable to query GPM: ' . $e->getMessage(), 0, $e); + } + $updates = $gpm->getUpdatable(['plugins' => true, 'themes' => true]); + $pending = []; + foreach ($updates as $type => $packages) { + if (!is_array($packages)) { + continue; + } + foreach ($packages as $slug => $package) { + if (!$this->isGpmPackagePublished($package)) { + continue; + } + + if ($type === 'plugins' && !$this->isPluginEnabled($slug)) { + continue; + } + + if ($type === 'themes' && !$this->isThemeEnabled($slug)) { + continue; + } + + $pending[$slug] = [ + 'type' => $type, + 'current' => $package->version ?? null, + 'available' => $package->available ?? null, + ]; + } + } + + return $pending; + } + + /** + * Determine if the provided GPM package metadata is marked as published. + * + * By default the GPM repository omits the `published` flag, so we only treat the package as unpublished + * when the value exists and evaluates to `false`. + * + * @param mixed $package + * @return bool + */ + protected function isGpmPackagePublished($package): bool + { + if (is_object($package) && method_exists($package, 'getData')) { + $data = $package->getData(); + if ($data instanceof Data) { + $published = $data->get('published'); + return $published !== false; + } + } + + if (is_array($package)) { + if (array_key_exists('published', $package)) { + return $package['published'] !== false; + } + + return true; + } + + $value = null; + if (is_object($package) && property_exists($package, 'published')) { + $value = $package->published; + } + + return $value !== false; + } + + /** + * Check plugins for psr/log requirements that conflict with Grav 1.8 vendor stack. + * + * @return array + */ + protected function detectPsrLogConflicts(): array + { + $conflicts = []; + $pluginRoots = glob($this->rootPath . '/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->isPluginEnabled($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 isPluginEnabled(string $slug): bool + { + if ($this->config) { + try { + $value = $this->config->get("plugins.{$slug}.enabled"); + if ($value !== null) { + return (bool)$value; + } + } catch (Throwable $e) { + // ignore and fall back to file checks + } + } + + $configPath = $this->rootPath . '/user/config/plugins/' . $slug . '.yaml'; + if (is_file($configPath)) { + try { + $data = Yaml::parseFile($configPath); + if (is_array($data) && array_key_exists('enabled', $data)) { + return (bool)$data['enabled']; + } + } catch (Throwable $e) { + // ignore parse errors and treat as enabled + } + } + + return true; + } + + protected function isThemeEnabled(string $slug): bool + { + if ($this->config) { + try { + $active = $this->config->get('system.pages.theme'); + if ($active !== null) { + return $active === $slug; + } + } catch (Throwable $e) { + // ignore + } + } + + $configPath = $this->rootPath . '/user/config/system.yaml'; + if (is_file($configPath)) { + try { + $data = Yaml::parseFile($configPath); + 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 and assume current theme + } + } + + return true; + } + + /** + * Detect usage of deprecated Monolog `add*` methods removed in newer releases. + * + * @return array + */ + protected function detectMonologConflicts(): array + { + $conflicts = []; + $pluginRoots = glob($this->rootPath . '/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->isPluginEnabled($slug)) { + continue; + } + + $iterator = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($path, FilesystemIterator::SKIP_DOTS) + ); + + foreach ($iterator as $file) { + /** @var \SplFileInfo $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($this->rootPath . '/', '', $file->getPathname()); + $conflicts[$slug][] = [ + 'file' => $relative, + 'method' => trim($match[0]), + ]; + } + } + } + + return $conflicts; + } + + /** + * Ensure directories flagged for ignoring get hydrated from the current installation. + * + * @param string $packagePath + * @param array $ignores + * @return void + */ + private function hydrateIgnoredDirectories(string $packagePath, array $ignores): void + { + $strategic = $ignores ?: $this->ignoredDirs; + + foreach ($strategic as $relative) { + $relative = trim($relative, '/'); + if ($relative === '') { + continue; + } + + $live = $this->rootPath . '/' . $relative; + $stage = $packagePath . '/' . $relative; + + Folder::delete($stage); + + if (!is_dir($live)) { + continue; + } + + // Use empty placeholders to preserve directory structure without duplicating data. + Folder::create($stage); + } + } + + /** + * Preserve critical root-level dotfiles that may not ship in update packages. + * + * @param string $packagePath + * @return void + */ + private function carryOverRootDotfiles(string $packagePath): void + { + $skip = [ + '.git', + '.DS_Store', + ]; + + $iterator = new DirectoryIterator($this->rootPath); + foreach ($iterator as $entry) { + if ($entry->isDot()) { + continue; + } + + $name = $entry->getFilename(); + if ($name === '' || $name[0] !== '.') { + continue; + } + + if (in_array($name, $skip, true)) { + continue; + } + + $target = $packagePath . DIRECTORY_SEPARATOR . $name; + if (file_exists($target)) { + continue; + } + + $source = $entry->getPathname(); + if ($entry->isDir()) { + Folder::rcopy($source, $target, true); + } elseif ($entry->isFile()) { + Folder::create(dirname($target)); + copy($source, $target); + } + } + } + + /** + * Carry over non-dot root files that are absent from the staged package. + * + * @param string $packagePath + * @param array $ignores + * @return void + */ + private function carryOverRootFiles(string $packagePath, array $ignores): void + { + $strategic = $ignores ?: $this->ignoredDirs; + $skip = array_map(static function ($value) { + return trim((string)$value, '/'); + }, $strategic); + $skip = array_filter($skip, static function ($value) { + return $value !== ''; + }); + $skip = array_values(array_unique($skip)); + + $iterator = new DirectoryIterator($this->rootPath); + foreach ($iterator as $entry) { + if ($entry->isDot()) { + continue; + } + + $name = $entry->getFilename(); + if ($name === '' || $name[0] === '.') { + continue; + } + + if (in_array($name, $skip, true)) { + continue; + } + + if (!$entry->isDir() || $entry->isLink()) { + continue; + } + + $target = $packagePath . DIRECTORY_SEPARATOR . $name; + if (file_exists($target)) { + continue; + } + + $source = $entry->getPathname(); + Folder::create(dirname($target)); + + Folder::rcopy($source, $target, true); + } + } + + /** + * Build manifest metadata for a staged upgrade. + * + * @param string $stageId + * @param string $targetVersion + * @param string $packagePath + * @param string $backupPath + * @return array + */ + private function buildManifest(string $stageId, string $targetVersion, string $packagePath, string $backupPath, array $entries): array + { + $plugins = []; + $pluginRoots = glob($this->rootPath . '/user/plugins/*', GLOB_ONLYDIR) ?: []; + foreach ($pluginRoots as $path) { + $slug = basename($path); + $blueprint = $path . '/blueprints.yaml'; + $details = [ + 'version' => null, + 'name' => $slug, + ]; + + if (is_file($blueprint)) { + try { + $yaml = Yaml::parse(file_get_contents($blueprint)); + if (isset($yaml['version'])) { + $details['version'] = $yaml['version']; + } + if (isset($yaml['name'])) { + $details['name'] = $yaml['name']; + } + } catch (\RuntimeException $e) { + // ignore parse errors, keep defaults + } + } + + $plugins[$slug] = $details; + } + + return [ + 'id' => $stageId, + 'created_at' => time(), + 'source_version' => GRAV_VERSION, + 'target_version' => $targetVersion, + 'php_version' => PHP_VERSION, + 'package_path' => $packagePath, + 'backup_path' => $backupPath, + 'entries' => array_values($entries), + 'plugins' => $plugins, + ]; + } + + /** + * Ensure Git metadata is retained after stage promotion. + * + * @param string $source + * @param string $destination + * @return void + */ + /** + * Persist manifest into Grav data directory. + * + * @param array $manifest + * @return void + */ + private function persistManifest(array $manifest): void + { + Folder::create($this->manifestStore); + $target = $this->manifestStore . DIRECTORY_SEPARATOR . $manifest['id'] . '.json'; + file_put_contents($target, json_encode($manifest, JSON_PRETTY_PRINT)); + } + + /** + * Ensure directory exists and is writable. + * + * @param string $path + * @return bool + */ + private function resolveStagingPath(?string $path): ?string + { + if (null === $path || $path === '') { + return null; + } + + $expanded = $path; + if (0 === strpos($expanded, '~')) { + $home = getenv('HOME'); + if ($home) { + $expanded = rtrim($home, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . ltrim($expanded, '~\/'); + } else { + return null; + } + } + if (!$this->isAbsolutePath($expanded)) { + $expanded = $this->rootPath . DIRECTORY_SEPARATOR . ltrim($expanded, DIRECTORY_SEPARATOR); + } + + $expanded = $this->normalizePath($expanded); + + try { + Folder::create($expanded); + } catch (\RuntimeException $e) { + return null; + } + + return is_writable($expanded) ? $expanded : null; + } + + private function isAbsolutePath(string $path): bool + { + if ($path === '') { + return false; + } + + if ($path[0] === '/' || $path[0] === '\\') { + return true; + } + + return (bool)preg_match('#^[A-Za-z]:[\\/]#', $path); + } + + private function normalizePath(string $path): string + { + $path = str_replace('\\', '/', $path); + $path = preg_replace('#/+#', '/', $path); + $path = rtrim($path, '/'); + + return str_replace('/', DIRECTORY_SEPARATOR, $path); + } + + /** + * @param string|null $id + * @return array|null + */ + private function resolveManifest(?string $id): ?array + { + $path = null; + + if ($id) { + $candidate = $this->manifestStore . DIRECTORY_SEPARATOR . $id . '.json'; + if (!is_file($candidate)) { + return null; + } + $path = $candidate; + } else { + $files = glob($this->manifestStore . DIRECTORY_SEPARATOR . '*.json') ?: []; + if (!$files) { + return null; + } + rsort($files); + $path = $files[0]; + } + + $decoded = json_decode(file_get_contents($path), true); + + return $decoded ?: null; + } + + /** + * Record rollback event in manifest store. + * + * @param string $id + * @return void + */ + private function markRollback(string $id): void + { + $target = $this->manifestStore . DIRECTORY_SEPARATOR . $id . '.json'; + if (!is_file($target)) { + return; + } + + $manifest = json_decode(file_get_contents($target), true); + if (!is_array($manifest)) { + return; + } + + $manifest['rolled_back_at'] = time(); + file_put_contents($target, json_encode($manifest, JSON_PRETTY_PRINT)); + } + + /** + * Keep only the three newest snapshots. + * + * @return void + */ + private function pruneOldSnapshots(): void + { + // Retain all snapshots; administrators can prune manually if desired. + // Legacy behaviour removed to ensure full history remains available. + return; + } +} diff --git a/system/src/Grav/Console/Application/GpmApplication.php b/system/src/Grav/Console/Application/GpmApplication.php index 61e73d23b..4dc6f779d 100644 --- a/system/src/Grav/Console/Application/GpmApplication.php +++ b/system/src/Grav/Console/Application/GpmApplication.php @@ -13,6 +13,8 @@ use Grav\Console\Gpm\DirectInstallCommand; use Grav\Console\Gpm\IndexCommand; use Grav\Console\Gpm\InfoCommand; use Grav\Console\Gpm\InstallCommand; +use Grav\Console\Gpm\PreflightCommand; +use Grav\Console\Gpm\RollbackCommand; use Grav\Console\Gpm\SelfupgradeCommand; use Grav\Console\Gpm\UninstallCommand; use Grav\Console\Gpm\UpdateCommand; @@ -36,6 +38,8 @@ class GpmApplication extends Application new UninstallCommand(), new UpdateCommand(), new SelfupgradeCommand(), + new PreflightCommand(), + new RollbackCommand(), new DirectInstallCommand(), ]); } diff --git a/system/src/Grav/Console/Application/GravApplication.php b/system/src/Grav/Console/Application/GravApplication.php index 81e320d3b..3045e7d9c 100644 --- a/system/src/Grav/Console/Application/GravApplication.php +++ b/system/src/Grav/Console/Application/GravApplication.php @@ -19,6 +19,7 @@ use Grav\Console\Cli\NewProjectCommand; use Grav\Console\Cli\PageSystemValidatorCommand; use Grav\Console\Cli\SandboxCommand; use Grav\Console\Cli\SchedulerCommand; +use Grav\Console\Cli\SafeUpgradeRunCommand; use Grav\Console\Cli\SecurityCommand; use Grav\Console\Cli\ServerCommand; use Grav\Console\Cli\YamlLinterCommand; @@ -47,6 +48,7 @@ class GravApplication extends Application new YamlLinterCommand(), new ServerCommand(), new PageSystemValidatorCommand(), + new SafeUpgradeRunCommand(), ]); } } diff --git a/system/src/Grav/Console/Cli/SafeUpgradeRunCommand.php b/system/src/Grav/Console/Cli/SafeUpgradeRunCommand.php new file mode 100644 index 000000000..0984767d5 --- /dev/null +++ b/system/src/Grav/Console/Cli/SafeUpgradeRunCommand.php @@ -0,0 +1,96 @@ +setName('safe-upgrade:run') + ->setDescription('Execute a queued Grav safe-upgrade job') + ->addOption( + 'job', + null, + InputOption::VALUE_REQUIRED, + 'Job identifier to execute' + ); + } + + protected function serve(): int + { + $input = $this->getInput(); + /** @var SymfonyStyle $io */ + $io = $this->getIO(); + + $jobId = $input->getOption('job'); + if (!$jobId) { + $io->error('Missing required --job option.'); + + return 1; + } + + if (method_exists($this, 'initializePlugins')) { + $this->initializePlugins(); + } + + if (!class_exists(\Grav\Plugin\Admin\SafeUpgradeManager::class)) { + $path = GRAV_ROOT . '/user/plugins/admin/classes/plugin/SafeUpgradeManager.php'; + if (is_file($path)) { + require_once $path; + } + } + + if (!class_exists(\Grav\Plugin\Admin\SafeUpgradeManager::class)) { + $io->error('SafeUpgradeManager is not available. Ensure the Admin plugin is installed.'); + + return 1; + } + + $manager = new \Grav\Plugin\Admin\SafeUpgradeManager(); + $manifest = $manager->loadJob($jobId); + + if (!$manifest) { + $io->error(sprintf('Safe upgrade job "%s" could not be found.', $jobId)); + + return 1; + } + + $options = $manifest['options'] ?? []; + $manager->updateJob([ + 'status' => 'running', + 'started_at' => $manifest['started_at'] ?? time(), + ]); + + try { + $operation = $options['operation'] ?? 'upgrade'; + if ($operation === 'restore') { + $result = $manager->runRestore($options); + } else { + $result = $manager->run($options); + } + $manager->ensureJobResult($result); + + return ($result['status'] ?? null) === 'success' ? 0 : 1; + } catch (Throwable $e) { + $manager->ensureJobResult([ + 'status' => 'error', + 'message' => $e->getMessage(), + ]); + $io->error($e->getMessage()); + + return 1; + } + } +} diff --git a/system/src/Grav/Console/Gpm/PreflightCommand.php b/system/src/Grav/Console/Gpm/PreflightCommand.php new file mode 100644 index 000000000..50a33331d --- /dev/null +++ b/system/src/Grav/Console/Gpm/PreflightCommand.php @@ -0,0 +1,92 @@ +setName('preflight') + ->addOption('json', null, InputOption::VALUE_NONE, 'Output report as JSON') + ->setDescription('Run Grav upgrade preflight checks without modifying the installation.'); + } + + protected function serve(): int + { + $io = $this->getIO(); + $service = $this->createSafeUpgradeService(); + $report = $service->preflight(); + + $hasIssues = !empty($report['plugins_pending']) || !empty($report['psr_log_conflicts']) || !empty($report['monolog_conflicts']) || !empty($report['warnings']); + + if ($this->getInput()->getOption('json')) { + $io->writeln(json_encode($report, JSON_PRETTY_PRINT)); + + return $hasIssues ? 2 : 0; + } + + $io->title('Grav Upgrade Preflight'); + + if (!empty($report['warnings'])) { + $io->writeln('Warnings'); + foreach ($report['warnings'] as $warning) { + $io->writeln(' - ' . $warning); + } + $io->newLine(); + } + + if (!empty($report['plugins_pending'])) { + $io->writeln('Packages pending update'); + foreach ($report['plugins_pending'] as $slug => $info) { + $io->writeln(sprintf(' - %s (%s) %s → %s', $slug, $info['type'] ?? 'plugin', $info['current'] ?? 'unknown', $info['available'] ?? 'unknown')); + } + $io->newLine(); + } + + if (!empty($report['psr_log_conflicts'])) { + $io->writeln('Potential psr/log conflicts'); + foreach ($report['psr_log_conflicts'] as $slug => $info) { + $io->writeln(sprintf(' - %s (requires psr/log %s)', $slug, $info['requires'] ?? '*')); + } + $io->writeln(' › Update the plugin or add "replace": {"psr/log": "*"} to its composer.json and reinstall dependencies.'); + $io->newLine(); + } + + if (!empty($report['monolog_conflicts'])) { + $io->writeln('Potential Monolog logger conflicts'); + foreach ($report['monolog_conflicts'] as $slug => $entries) { + foreach ($entries as $entry) { + $file = $entry['file'] ?? 'unknown file'; + $method = $entry['method'] ?? 'add*'; + $io->writeln(sprintf(' - %s (%s in %s)', $slug, $method, $file)); + } + } + $io->writeln(' › Update the plugin to use PSR-3 style logger calls (e.g. $logger->error()).'); + $io->newLine(); + } + + if (!$hasIssues) { + $io->success('No blocking issues detected.'); + } else { + $io->warning('Resolve the findings above before upgrading Grav.'); + } + + return $hasIssues ? 2 : 0; + } + + /** + * @return SafeUpgradeService + */ + protected function createSafeUpgradeService(): SafeUpgradeService + { + return new SafeUpgradeService(); + } +} diff --git a/system/src/Grav/Console/Gpm/RollbackCommand.php b/system/src/Grav/Console/Gpm/RollbackCommand.php new file mode 100644 index 000000000..ffe60fc0b --- /dev/null +++ b/system/src/Grav/Console/Gpm/RollbackCommand.php @@ -0,0 +1,135 @@ +setName('rollback') + ->addArgument('manifest', InputArgument::OPTIONAL, 'Manifest identifier to roll back to. Defaults to the latest snapshot.') + ->addOption('list', 'l', InputOption::VALUE_NONE, 'List available snapshots') + ->addOption('all-yes', 'y', InputOption::VALUE_NONE, 'Skip confirmation prompts') + ->setDescription('Rollback Grav to a previously staged snapshot.'); + } + + protected function serve(): int + { + $input = $this->getInput(); + $io = $this->getIO(); + $this->allYes = (bool)$input->getOption('all-yes'); + + $snapshots = $this->collectSnapshots(); + if ($input->getOption('list')) { + if (!$snapshots) { + $io->writeln('No snapshots found.'); + return 0; + } + + $io->writeln('Available snapshots:'); + foreach ($snapshots as $snapshot) { + $io->writeln(sprintf(' - %s (Grav %s)', $snapshot['id'], $snapshot['target_version'] ?? 'unknown')); + } + + return 0; + } + + if (!$snapshots) { + $io->error('No snapshots available to roll back to.'); + + return 1; + } + + $targetId = $input->getArgument('manifest') ?: $snapshots[0]['id']; + $target = null; + foreach ($snapshots as $snapshot) { + if ($snapshot['id'] === $targetId) { + $target = $snapshot; + break; + } + } + + if (!$target) { + $io->error(sprintf('Snapshot %s not found.', $targetId)); + + return 1; + } + + if (!$this->allYes) { + $question = new ConfirmationQuestion(sprintf('Rollback to snapshot %s (Grav %s)? [y|N] ', $target['id'], $target['target_version'] ?? 'unknown'), false); + if (!$io->askQuestion($question)) { + $io->writeln('Rollback aborted.'); + + return 1; + } + } + + $service = $this->createSafeUpgradeService(); + + try { + $service->rollback($target['id']); + $service->clearRecoveryFlag(); + } catch (RuntimeException $e) { + $io->error($e->getMessage()); + return 1; + } + + $io->success(sprintf('Rolled back to snapshot %s.', $target['id'])); + + return 0; + } + + /** + * @return array + */ + protected function collectSnapshots(): array + { + $manifestDir = GRAV_ROOT . '/user/data/upgrades'; + $files = glob($manifestDir . '/*.json'); + if (!$files) { + return []; + } + + rsort($files); + $snapshots = []; + foreach ($files as $file) { + $decoded = json_decode(file_get_contents($file), true); + if (!is_array($decoded)) { + continue; + } + + $decoded['id'] = $decoded['id'] ?? pathinfo($file, PATHINFO_FILENAME); + $decoded['file'] = basename($file); + $snapshots[] = $decoded; + } + + return $snapshots; + } + + /** + * @return SafeUpgradeService + */ + protected function createSafeUpgradeService(): SafeUpgradeService + { + return new SafeUpgradeService(); + } +} diff --git a/system/src/Grav/Console/Gpm/SelfupgradeCommand.php b/system/src/Grav/Console/Gpm/SelfupgradeCommand.php index a3c7c6f14..f6fb1a7dd 100644 --- a/system/src/Grav/Console/Gpm/SelfupgradeCommand.php +++ b/system/src/Grav/Console/Gpm/SelfupgradeCommand.php @@ -15,13 +15,16 @@ use Grav\Common\HTTP\Response; use Grav\Common\GPM\Installer; use Grav\Common\GPM\Upgrader; use Grav\Common\Grav; +use Grav\Common\Upgrade\SafeUpgradeService; use Grav\Console\GpmCommand; use Grav\Installer\Install; use RuntimeException; use Symfony\Component\Console\Input\ArrayInput; use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Question\ConfirmationQuestion; +use Symfony\Component\Console\Style\SymfonyStyle; use ZipArchive; +use function count; use function is_callable; use function strlen; @@ -41,6 +44,16 @@ class SelfupgradeCommand extends GpmCommand private $tmp; /** @var Upgrader */ private $upgrader; + /** @var string|null */ + private $lastProgressMessage = null; + /** @var float|null */ + private $operationTimerStart = null; + /** @var string|null */ + private $currentProgressStage = null; + /** @var float|null */ + private $currentStageStartedAt = null; + /** @var array */ + private $currentStageExtras = []; /** @var string */ protected $all_yes; @@ -108,6 +121,12 @@ class SelfupgradeCommand extends GpmCommand $this->displayGPMRelease(); + $safeUpgrade = $this->createSafeUpgradeService(); + $preflight = $safeUpgrade->preflight(); + if (!$this->handlePreflightReport($preflight)) { + return 1; + } + $update = $this->upgrader->getAssets()['grav-update']; $local = $this->upgrader->getLocalVersion(); @@ -213,10 +232,18 @@ 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); $io->write(' |- Installing upgrade... '); + $this->operationTimerStart = microtime(true); $installation = $this->upgrade(); $error = 0; @@ -227,6 +254,7 @@ class SelfupgradeCommand extends GpmCommand } else { $io->writeln(" '- Success! "); $io->newLine(); + $safeUpgrade->clearRecoveryFlag(); } if ($this->tmp && is_dir($this->tmp)) { @@ -263,14 +291,174 @@ class SelfupgradeCommand extends GpmCommand return $this->tmp . DS . $package['name']; } + /** + * @return SafeUpgradeService + */ + protected function createSafeUpgradeService(): SafeUpgradeService + { + $config = null; + try { + $config = Grav::instance()['config'] ?? null; + } catch (\Throwable $e) { + $config = null; + } + + return new SafeUpgradeService([ + 'config' => $config, + ]); + } + + /** + * @param array $preflight + * @return bool + */ + protected function handlePreflightReport(array $preflight): bool + { + $io = $this->getIO(); + $pending = $preflight['plugins_pending'] ?? []; + $conflicts = $preflight['psr_log_conflicts'] ?? []; + $monologConflicts = $preflight['monolog_conflicts'] ?? []; + $warnings = $preflight['warnings'] ?? []; + + if (empty($pending) && empty($conflicts) && empty($monologConflicts)) { + return true; + } + + if ($warnings) { + $io->newLine(); + $io->writeln('Preflight warnings detected:'); + foreach ($warnings as $warning) { + $io->writeln(' • ' . $warning); + } + } + + if ($pending) { + $io->newLine(); + $io->writeln('The following packages need updating before Grav upgrade:'); + foreach ($pending as $slug => $info) { + $type = $info['type'] ?? 'plugin'; + $current = $info['current'] ?? 'unknown'; + $available = $info['available'] ?? 'unknown'; + $io->writeln(sprintf(' - %s (%s) %s → %s', $slug, $type, $current, $available)); + } + + $io->writeln(' › Please run `bin/gpm update` to bring these packages current before upgrading Grav.'); + $io->writeln('Aborting self-upgrade. Run `bin/gpm update` first.'); + + return false; + } + + $handled = $this->handleConflicts( + $conflicts, + static function (SymfonyStyle $io, array $conflicts): void { + $io->newLine(); + $io->writeln('Potential psr/log incompatibilities:'); + foreach ($conflicts as $slug => $info) { + $requires = $info['requires'] ?? '*'; + $io->writeln(sprintf(' - %s (requires psr/log %s)', $slug, $requires)); + } + }, + 'Update the plugin or add "replace": {"psr/log": "*"} to its composer.json and reinstall dependencies.', + 'Aborting self-upgrade. Adjust composer requirements or update affected plugins.', + 'Proceeding with potential psr/log incompatibilities still active.', + 'Disabled before upgrade because of psr/log conflict' + ); + + if (!$handled) { + return false; + } + + $handledMonolog = $this->handleConflicts( + $monologConflicts, + static function (SymfonyStyle $io, array $conflicts): void { + $io->newLine(); + $io->writeln('Potential Monolog logger API incompatibilities:'); + foreach ($conflicts as $slug => $entries) { + foreach ($entries as $entry) { + $file = $entry['file'] ?? 'unknown file'; + $method = $entry['method'] ?? 'add*'; + $io->writeln(sprintf(' - %s (%s in %s)', $slug, $method, $file)); + } + } + }, + 'Update the plugin to use PSR-3 style logger methods (e.g. $logger->error()) before upgrading.', + 'Aborting self-upgrade. Update plugins to remove deprecated Monolog add* calls.', + 'Proceeding with potential Monolog API incompatibilities still active.', + 'Disabled before upgrade because of Monolog API conflict' + ); + + if (!$handledMonolog) { + return false; + } + + return true; + } + + /** + * @param array $conflicts + * @param callable $printer + * @param string $advice + * @param string $abortMessage + * @param string $continueMessage + * @param string $disableNote + * @return bool + */ + private function handleConflicts(array $conflicts, callable $printer, string $advice, string $abortMessage, string $continueMessage, string $disableNote): bool + { + if (empty($conflicts)) { + return true; + } + + $io = $this->getIO(); + $printer($io, $conflicts); + $io->writeln(' › ' . $advice); + + $choice = $this->all_yes ? 'abort' : $io->choice( + 'How would you like to proceed?', + ['disable', 'continue', 'abort'], + 'abort' + ); + + if ($choice === 'abort') { + $io->writeln($abortMessage); + + return false; + } + + /** @var \Grav\Common\Recovery\RecoveryManager $recovery */ + $recovery = Grav::instance()['recovery']; + + if ($choice === 'disable') { + foreach (array_keys($conflicts) as $slug) { + $recovery->disablePlugin($slug, ['message' => $disableNote]); + $io->writeln(sprintf(' - Disabled plugin %s.', $slug)); + } + $io->writeln('Continuing with conflicted plugins disabled.'); + + return true; + } + + $io->writeln($continueMessage); + + return true; + } + /** * @return bool */ private function upgrade(): bool { $io = $this->getIO(); + $this->lastProgressMessage = null; $this->upgradeGrav($this->file); + $this->finalizeStageTracking(); + + $elapsed = null; + if (null !== $this->operationTimerStart) { + $elapsed = microtime(true) - $this->operationTimerStart; + $this->operationTimerStart = null; + } $errorCode = Installer::lastErrorCode(); if ($errorCode) { @@ -282,10 +470,16 @@ class SelfupgradeCommand extends GpmCommand return false; } + if (null !== $elapsed) { + $io->writeln(sprintf(' |- Safe upgrade staging completed in %s', $this->formatDuration($elapsed))); + } + $io->write("\x0D"); // extra white spaces to clear out the buffer properly $io->writeln(' |- Installing upgrade... ok '); + $this->ensureExecutablePermissions(); + return true; } @@ -325,14 +519,24 @@ class SelfupgradeCommand extends GpmCommand */ private function upgradeGrav(string $zip): void { + $io = $this->getIO(); + try { + $io->write("\x0D |- Extracting update... "); $folder = Installer::unZip($zip, $this->tmp . '/zip'); if ($folder === false) { throw new RuntimeException(Installer::lastErrorMsg()); } + $io->write("\x0D"); + $io->writeln(' |- Extracting update... ok '); $script = $folder . '/system/install.php'; if ((file_exists($script) && $install = include $script) && is_callable($install)) { + if (is_object($install) && method_exists($install, 'setProgressCallback')) { + $install->setProgressCallback(function (string $stage, string $message, ?int $percent = null, array $extra = []) { + $this->handleServiceProgress($stage, $message, $percent); + }); + } $install($zip); } else { throw new RuntimeException('Uploaded archive file is not a valid Grav update package'); @@ -341,4 +545,110 @@ class SelfupgradeCommand extends GpmCommand Installer::setError($e->getMessage()); } } + + private function handleServiceProgress(string $stage, string $message, ?int $percent = null, array $extra = []): void + { + $this->trackStageProgress($stage, $message, $extra); + + if ($this->lastProgressMessage === $message) { + return; + } + + $this->lastProgressMessage = $message; + $io = $this->getIO(); + $suffix = ''; + if (null !== $percent) { + $suffix = sprintf(' (%d%%)', $percent); + } + $io->writeln(sprintf(' |- %s%s', $message, $suffix)); + } + + private function ensureExecutablePermissions(): void + { + $executables = [ + 'bin/grav', + 'bin/plugin', + 'bin/gpm', + 'bin/restore', + 'bin/composer.phar' + ]; + + foreach ($executables as $relative) { + $path = GRAV_ROOT . '/' . $relative; + if (!is_file($path) || is_link($path)) { + continue; + } + + $mode = @fileperms($path); + $desired = ($mode & 0777) | 0111; + if (($mode & 0111) !== 0111) { + @chmod($path, $desired); + } + } + } + + private function trackStageProgress(string $stage, string $message, array $extra = []): void + { + $now = microtime(true); + + if (null !== $this->currentProgressStage && $stage !== $this->currentProgressStage && null !== $this->currentStageStartedAt) { + $elapsed = $now - $this->currentStageStartedAt; + $this->emitStageSummary($this->currentProgressStage, $elapsed, $this->currentStageExtras); + $this->currentStageExtras = []; + } + + if ($stage !== $this->currentProgressStage) { + $this->currentProgressStage = $stage; + $this->currentStageStartedAt = $now; + $this->currentStageExtras = []; + } + + if (!isset($this->currentStageExtras['label'])) { + $this->currentStageExtras['label'] = $message; + } + + if ($extra) { + $this->currentStageExtras = array_merge($this->currentStageExtras, $extra); + } + } + + private function finalizeStageTracking(): void + { + if (null !== $this->currentProgressStage && null !== $this->currentStageStartedAt) { + $elapsed = microtime(true) - $this->currentStageStartedAt; + $this->emitStageSummary($this->currentProgressStage, $elapsed, $this->currentStageExtras); + } + + $this->currentProgressStage = null; + $this->currentStageStartedAt = null; + $this->currentStageExtras = []; + } + + private function emitStageSummary(string $stage, float $seconds, array $extra = []): void + { + $io = $this->getIO(); + $label = $extra['label'] ?? ucfirst($stage); + $modeText = ''; + if (isset($extra['mode'])) { + $modeText = sprintf(' [%s]', $extra['mode']); + } + + $io->writeln(sprintf(' |- %s completed in %s%s', $label, $this->formatDuration($seconds), $modeText)); + } + + private function formatDuration(float $seconds): string + { + if ($seconds < 1) { + return sprintf('%0.3fs', $seconds); + } + + $minutes = (int)floor($seconds / 60); + $remaining = $seconds - ($minutes * 60); + + if ($minutes === 0) { + return sprintf('%0.1fs', $remaining); + } + + return sprintf('%dm %0.1fs', $minutes, $remaining); + } } 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/system/src/Grav/Framework/Compat/Monolog/Utils.php b/system/src/Grav/Framework/Compat/Monolog/Utils.php new file mode 100644 index 000000000..529ab06a7 --- /dev/null +++ b/system/src/Grav/Framework/Compat/Monolog/Utils.php @@ -0,0 +1,183 @@ +finalize(); } + public function setProgressCallback(?callable $callback): self + { + $this->progressCallback = $callback; + + return $this; + } + + private function relayProgress(string $stage, string $message, ?int $percent = null): void + { + if ($this->progressCallback) { + ($this->progressCallback)($stage, $message, $percent); + } + } + /** * NOTE: This method can only be called after $grav['plugins']->init(). * @@ -260,13 +277,34 @@ ERR; // Update user/config/version.yaml before copying the files to avoid frontend from setting the version schema. $this->updater->install(); - Installer::install( - $this->zip ?? '', - GRAV_ROOT, - ['sophisticated' => true, 'overwrite' => true, 'ignore_symlinks' => true, 'ignores' => $this->ignores], - $this->location, - !($this->zip && is_file($this->zip)) - ); + if ($this->shouldUseSafeUpgrade()) { + $options = []; + try { + $grav = Grav::instance(); + if ($grav && isset($grav['config'])) { + $options['config'] = $grav['config']; + } + } catch (\Throwable $e) { + // ignore + } + + $service = new SafeUpgradeService($options); + if ($this->progressCallback) { + $service->setProgressCallback(function (string $stage, string $message, ?int $percent = null, array $extra = []) { + $this->relayProgress($stage, $message, $percent); + }); + } + $service->promote($this->location, $this->getVersion(), $this->ignores); + Installer::setError(Installer::OK); + } else { + Installer::install( + $this->zip ?? '', + GRAV_ROOT, + ['sophisticated' => true, 'overwrite' => true, 'ignore_symlinks' => true, 'ignores' => $this->ignores], + $this->location, + !($this->zip && is_file($this->zip)) + ); + } } catch (Exception $e) { Installer::setError($e->getMessage()); } @@ -280,6 +318,27 @@ ERR; } } + /** + * @return bool + */ + private function shouldUseSafeUpgrade(): bool + { + if (!class_exists(SafeUpgradeService::class)) { + return false; + } + + try { + $grav = Grav::instance(); + if ($grav && isset($grav['config'])) { + return (bool) $grav['config']->get('system.updates.safe_upgrade', true); + } + } catch (\Throwable $e) { + // Grav container may not be initialised yet, default to safe upgrade. + } + + return true; + } + /** * @return void * @throws RuntimeException diff --git a/tests/unit/Grav/Common/Recovery/RecoveryManagerTest.php b/tests/unit/Grav/Common/Recovery/RecoveryManagerTest.php new file mode 100644 index 000000000..24f8f3884 --- /dev/null +++ b/tests/unit/Grav/Common/Recovery/RecoveryManagerTest.php @@ -0,0 +1,146 @@ +tmpDir = sys_get_temp_dir() . '/grav-recovery-' . uniqid('', true); + Folder::create($this->tmpDir); + Folder::create($this->tmpDir . '/user'); + Folder::create($this->tmpDir . '/user/data'); + Folder::create($this->tmpDir . '/system'); + } + + protected function _after(): void + { + if (is_dir($this->tmpDir)) { + Folder::delete($this->tmpDir); + } + } + + public function testHandleShutdownQuarantinesPluginAndCreatesFlag(): void + { + $plugin = $this->tmpDir . '/user/plugins/bad'; + Folder::create($plugin); + file_put_contents($plugin . '/plugin.php', 'tmpDir) extends RecoveryManager { + protected $error; + public function __construct(string $rootPath) + { + parent::__construct($rootPath); + $this->error = [ + 'type' => E_ERROR, + 'file' => $this->getRootPath() . '/user/plugins/bad/plugin.php', + 'message' => 'Fatal failure', + 'line' => 42, + ]; + } + + public function getRootPath(): string + { + $prop = new \ReflectionProperty(RecoveryManager::class, 'rootPath'); + $prop->setAccessible(true); + + return $prop->getValue($this); + } + + protected function resolveLastError(): ?array + { + return $this->error; + } + }; + + $manager->markUpgradeWindow('core-upgrade', ['scope' => 'core']); + $manager->handleShutdown(); + + $flag = $this->tmpDir . '/user/data/recovery.flag'; + self::assertFileExists($flag); + $context = json_decode(file_get_contents($flag), true); + self::assertSame('Fatal failure', $context['message']); + self::assertSame('bad', $context['plugin']); + self::assertNotEmpty($context['token']); + + $configFile = $this->tmpDir . '/user/config/plugins/bad.yaml'; + self::assertFileExists($configFile); + self::assertStringContainsString('enabled: false', file_get_contents($configFile)); + + $quarantine = $this->tmpDir . '/user/data/upgrades/quarantine.json'; + self::assertFileExists($quarantine); + $decoded = json_decode(file_get_contents($quarantine), true); + self::assertArrayHasKey('bad', $decoded); + } + + public function testHandleShutdownIgnoresNonFatalErrors(): void + { + $manager = new class($this->tmpDir) extends RecoveryManager { + protected function resolveLastError(): ?array + { + return ['type' => E_USER_WARNING, 'message' => 'Notice']; + } + }; + + $manager->handleShutdown(); + + self::assertFileDoesNotExist($this->tmpDir . '/user/data/recovery.flag'); + } + + public function testClearRemovesFlag(): void + { + $flag = $this->tmpDir . '/user/data/recovery.flag'; + file_put_contents($flag, 'flag'); + + $manager = new RecoveryManager($this->tmpDir); + $manager->clear(); + + self::assertFileDoesNotExist($flag); + } + + public function testGenerateTokenFallbackOnRandomFailure(): void + { + $manager = new class($this->tmpDir) extends RecoveryManager { + protected function randomBytes(int $length): string + { + throw new \RuntimeException('No randomness'); + } + }; + + $manager->activate([]); + $context = $manager->getContext(); + + self::assertNotEmpty($context['token']); + } + + public function testGetContextWithoutFlag(): void + { + $manager = new RecoveryManager($this->tmpDir); + self::assertNull($manager->getContext()); + } + + public function testDisablePluginRecordsQuarantineWithoutFlag(): void + { + $plugin = $this->tmpDir . '/user/plugins/problem'; + Folder::create($plugin); + + $manager = new RecoveryManager($this->tmpDir); + $manager->disablePlugin('problem', ['message' => 'Manual disable']); + + $flag = $this->tmpDir . '/user/data/recovery.flag'; + self::assertFileDoesNotExist($flag); + + $configFile = $this->tmpDir . '/user/config/plugins/problem.yaml'; + self::assertFileExists($configFile); + self::assertStringContainsString('enabled: false', file_get_contents($configFile)); + + $quarantine = $this->tmpDir . '/user/data/upgrades/quarantine.json'; + self::assertFileExists($quarantine); + $decoded = json_decode(file_get_contents($quarantine), true); + self::assertSame('Manual disable', $decoded['problem']['message']); + } +} diff --git a/tests/unit/Grav/Common/Upgrade/SafeUpgradeServiceTest.php b/tests/unit/Grav/Common/Upgrade/SafeUpgradeServiceTest.php new file mode 100644 index 000000000..31c4882b9 --- /dev/null +++ b/tests/unit/Grav/Common/Upgrade/SafeUpgradeServiceTest.php @@ -0,0 +1,233 @@ +tmpDir = sys_get_temp_dir() . '/grav-safe-upgrade-' . uniqid('', true); + Folder::create($this->tmpDir); + } + + protected function _after(): void + { + if (is_dir($this->tmpDir)) { + Folder::delete($this->tmpDir); + } + } + + public function testPreflightAggregatesWarnings(): void + { + $service = new class(['root' => $this->tmpDir]) extends SafeUpgradeService { + public $pending = [ + 'alpha' => ['type' => 'plugins', 'current' => '1.0.0', 'available' => '1.1.0'] + ]; + public $conflicts = [ + 'beta' => ['requires' => '^1.0'] + ]; + public $monolog = [ + 'gamma' => [ + ['file' => 'user/plugins/gamma/gamma.php', 'method' => '->addError('] + ] + ]; + + protected function detectPendingPluginUpdates(): array + { + return $this->pending; + } + + protected function detectPsrLogConflicts(): array + { + return $this->conflicts; + } + + protected function detectMonologConflicts(): array + { + return $this->monolog; + } + }; + + $result = $service->preflight(); + + self::assertArrayHasKey('warnings', $result); + self::assertCount(3, $result['warnings']); + self::assertArrayHasKey('alpha', $result['plugins_pending']); + self::assertArrayHasKey('beta', $result['psr_log_conflicts']); + self::assertArrayHasKey('gamma', $result['monolog_conflicts']); + } + + public function testPreflightHandlesDetectionFailure(): void + { + $service = new class(['root' => $this->tmpDir]) extends SafeUpgradeService { + protected function detectPendingPluginUpdates(): array + { + throw new RuntimeException('Cannot reach GPM'); + } + + protected function detectPsrLogConflicts(): array + { + return []; + } + + protected function detectMonologConflicts(): array + { + return []; + } + }; + + $result = $service->preflight(); + + self::assertSame([], $result['plugins_pending']); + self::assertSame([], $result['psr_log_conflicts']); + self::assertSame([], $result['monolog_conflicts']); + self::assertCount(1, $result['warnings']); + self::assertStringContainsString('Cannot reach GPM', $result['warnings'][0]); + } + + public function testPromoteAndRollback(): void + { + [$root, $manifestStore] = $this->prepareLiveEnvironment(); + $service = new SafeUpgradeService([ + 'root' => $root, + 'manifest_store' => $manifestStore, + ]); + + $package = $this->preparePackage(); + $manifest = $service->promote($package, '1.8.0', ['backup', 'cache', 'images', 'logs', 'tmp', 'user']); + + self::assertFileExists($root . '/system/new.txt'); + self::assertFileExists($root . '/ORIGINAL'); + + $manifestFile = $manifestStore . '/' . $manifest['id'] . '.json'; + self::assertFileExists($manifestFile); + + $service->rollback($manifest['id']); + + self::assertFileExists($root . '/ORIGINAL'); + self::assertFileDoesNotExist($root . '/system/new.txt'); + + self::assertDirectoryExists($manifest['backup_path']); + } + + public function testKeepsAllSnapshots(): void + { + [$root, $manifestStore] = $this->prepareLiveEnvironment(); + $service = new SafeUpgradeService([ + 'root' => $root, + 'manifest_store' => $manifestStore, + ]); + + $manifests = []; + for ($i = 0; $i < 4; $i++) { + $package = $this->preparePackage((string)$i); + $manifests[] = $service->promote($package, '1.8.' . $i, ['backup', 'cache', 'images', 'logs', 'tmp', 'user']); + // Ensure subsequent promotions have a marker to restore. + file_put_contents($root . '/ORIGINAL', 'state-' . $i); + } + + $files = glob($manifestStore . '/*.json'); + self::assertCount(4, $files); + self::assertTrue(is_dir($manifests[0]['backup_path'])); + } + + public function testDetectsPsrLogConflictsFromFilesystem(): void + { + [$root] = $this->prepareLiveEnvironment(); + $plugin = $root . '/user/plugins/problem'; + Folder::create($plugin); + file_put_contents($plugin . '/composer.json', json_encode(['require' => ['psr/log' => '^1.0']], JSON_PRETTY_PRINT)); + + $service = new SafeUpgradeService([ + 'root' => $root, + ]); + + $method = new ReflectionMethod(SafeUpgradeService::class, 'detectPsrLogConflicts'); + $method->setAccessible(true); + $conflicts = $method->invoke($service); + + self::assertArrayHasKey('problem', $conflicts); + } + + public function testDetectsMonologConflictsFromFilesystem(): void + { + [$root] = $this->prepareLiveEnvironment(); + $plugin = $root . '/user/plugins/logger'; + Folder::create($plugin . '/src'); + $code = <<<'PHP' +addError('deprecated'); + } +} +PHP; + file_put_contents($plugin . '/src/logger.php', $code); + + $service = new SafeUpgradeService([ + 'root' => $root, + ]); + + $method = new ReflectionMethod(SafeUpgradeService::class, 'detectMonologConflicts'); + $method->setAccessible(true); + $conflicts = $method->invoke($service); + + self::assertArrayHasKey('logger', $conflicts); + self::assertNotEmpty($conflicts['logger']); + self::assertStringContainsString('addError', $conflicts['logger'][0]['method']); + } + + public function testClearRecoveryFlagRemovesFile(): void + { + [$root] = $this->prepareLiveEnvironment(); + $flag = $root . '/user/data/recovery.flag'; + Folder::create(dirname($flag)); + file_put_contents($flag, 'flag'); + + $service = new SafeUpgradeService([ + 'root' => $root, + ]); + $service->clearRecoveryFlag(); + + self::assertFileDoesNotExist($flag); + } + + /** + * @return array{0:string,1:string} + */ + private function prepareLiveEnvironment(): array + { + $root = $this->tmpDir . '/root'; + $manifestStore = $root . '/user/data/upgrades'; + + Folder::create($root . '/user/plugins/sample'); + Folder::create($root . '/system'); + file_put_contents($root . '/system/original.txt', 'original'); + file_put_contents($root . '/ORIGINAL', 'original-root'); + file_put_contents($root . '/user/plugins/sample/blueprints.yaml', "name: Sample Plugin\nversion: 1.0.0\n"); + file_put_contents($root . '/user/plugins/sample/composer.json', json_encode(['require' => ['php' => '^8.0']], JSON_PRETTY_PRINT)); + + return [$root, $manifestStore]; + } + + /** + * @param string $suffix + * @return string + */ + private function preparePackage(string $suffix = ''): string + { + $package = $this->tmpDir . '/package-' . uniqid('', true); + Folder::create($package . '/system'); + Folder::create($package . '/user'); + file_put_contents($package . '/index.php', 'new-release' . $suffix); + file_put_contents($package . '/system/new.txt', 'release' . $suffix); + + return $package; + } +} diff --git a/tests/unit/Grav/Console/Gpm/PreflightCommandTest.php b/tests/unit/Grav/Console/Gpm/PreflightCommandTest.php new file mode 100644 index 000000000..85b1fb6eb --- /dev/null +++ b/tests/unit/Grav/Console/Gpm/PreflightCommandTest.php @@ -0,0 +1,150 @@ + [], + 'psr_log_conflicts' => [], + 'monolog_conflicts' => [], + 'warnings' => [] + ]); + $command = new TestPreflightCommand($service); + + [$style, $output] = $this->injectIo($command, new ArrayInput(['--json' => true])); + $status = $command->runServe(); + + self::assertSame(0, $status); + $buffer = $output->fetch(); + self::assertJson(trim($buffer)); + } + + public function testServeWarnsWhenIssuesDetected(): void + { + $service = new StubSafeUpgradeService([ + 'plugins_pending' => ['alpha' => ['type' => 'plugin', 'current' => '1', 'available' => '2']], + 'psr_log_conflicts' => ['beta' => ['requires' => '^1']], + 'monolog_conflicts' => ['gamma' => [['file' => 'user/plugins/gamma/gamma.php', 'method' => '->addError(']]], + 'warnings' => ['pending updates'] + ]); + $command = new TestPreflightCommand($service); + + [$style] = $this->injectIo($command, new ArrayInput([])); + $status = $command->runServe(); + + self::assertSame(2, $status); + $output = implode("\n", $style->messages); + self::assertStringContainsString('pending updates', $output); + self::assertStringContainsString('beta', $output); + self::assertStringContainsString('gamma', $output); + } + + /** + * @param TestPreflightCommand $command + * @param ArrayInput $input + * @return array{0:PreflightMemoryStyle,1:BufferedOutput} + */ + private function injectIo(TestPreflightCommand $command, ArrayInput $input): array + { + $buffer = new BufferedOutput(); + $style = new PreflightMemoryStyle($input, $buffer); + + $this->setProtectedProperty($command, 'input', $input); + $this->setProtectedProperty($command, 'output', $style); + + $input->bind($command->getDefinition()); + + return [$style, $buffer]; + } + + private function setProtectedProperty(object $object, string $property, $value): void + { + $ref = new \ReflectionProperty(\Grav\Console\GpmCommand::class, $property); + $ref->setAccessible(true); + $ref->setValue($object, $value); + } +} + +class TestPreflightCommand extends PreflightCommand +{ + /** @var SafeUpgradeService */ + private $service; + + public function __construct(SafeUpgradeService $service) + { + parent::__construct(); + $this->service = $service; + } + + protected function createSafeUpgradeService(): SafeUpgradeService + { + return $this->service; + } + + public function runServe(): int + { + return $this->serve(); + } +} + +class StubSafeUpgradeService extends SafeUpgradeService +{ + /** @var array */ + private $report; + + public function __construct(array $report) + { + $this->report = $report; + parent::__construct([]); + } + + public function preflight(): array + { + return $this->report; + } +} + +class PreflightMemoryStyle extends SymfonyStyle +{ + /** @var array */ + public $messages = []; + + public function __construct(InputInterface $input, BufferedOutput $output) + { + parent::__construct($input, $output); + } + + public function title($message): void + { + $this->messages[] = 'title:' . $message; + parent::title($message); + } + + public function writeln($messages, $type = self::OUTPUT_NORMAL): void + { + foreach ((array)$messages as $message) { + $this->messages[] = (string)$message; + } + parent::writeln($messages, $type); + } + + public function warning($message): void + { + $this->messages[] = 'warning:' . $message; + parent::warning($message); + } + + public function success($message): void + { + $this->messages[] = 'success:' . $message; + parent::success($message); + } +} diff --git a/tests/unit/Grav/Console/Gpm/RollbackCommandTest.php b/tests/unit/Grav/Console/Gpm/RollbackCommandTest.php new file mode 100644 index 000000000..5a7807417 --- /dev/null +++ b/tests/unit/Grav/Console/Gpm/RollbackCommandTest.php @@ -0,0 +1,244 @@ +setSnapshots([ + ['id' => 'snap-1', 'target_version' => '1.7.49'], + ['id' => 'snap-2', 'target_version' => '1.7.50'] + ]); + + [$style] = $this->injectIo($command, new ArrayInput(['--list' => true])); + $status = $command->runServe(); + + self::assertSame(0, $status); + $output = implode("\n", $style->messages); + self::assertStringContainsString('snap-1', $output); + self::assertStringContainsString('snap-2', $output); + self::assertFalse($service->rollbackCalled); + } + + public function testListSnapshotsHandlesAbsence(): void + { + $service = new StubRollbackService(); + $command = new TestRollbackCommand($service); + + [$style] = $this->injectIo($command, new ArrayInput(['--list' => true])); + $status = $command->runServe(); + + self::assertSame(0, $status); + self::assertStringContainsString('No snapshots found', implode("\n", $style->messages)); + } + + public function testRollbackAbortsWhenNoSnapshotsAvailable(): void + { + $service = new StubRollbackService(); + $command = new TestRollbackCommand($service); + + [$style] = $this->injectIo($command, new ArrayInput([])); + $status = $command->runServe(); + + self::assertSame(1, $status); + self::assertStringContainsString('No snapshots available', implode("\n", $style->messages)); + } + + public function testRollbackAbortsWhenSnapshotMissing(): void + { + $service = new StubRollbackService(); + $command = new TestRollbackCommand($service); + $command->setSnapshots([ + ['id' => 'snap-1', 'target_version' => '1.7.49'] + ]); + + [$style] = $this->injectIo($command, new ArrayInput(['manifest' => 'missing'])); + $status = $command->runServe(); + + self::assertSame(1, $status); + self::assertStringContainsString('Snapshot missing not found.', implode("\n", $style->messages)); + } + + public function testRollbackCancelsWhenUserDeclines(): void + { + $service = new StubRollbackService(); + $command = new TestRollbackCommand($service, [false]); + $command->setSnapshots([ + ['id' => 'snap-1', 'target_version' => '1.7.49'] + ]); + + [$style] = $this->injectIo($command, new ArrayInput([])); + $status = $command->runServe(); + + self::assertSame(1, $status); + self::assertStringContainsString('Rollback aborted.', implode("\n", $style->messages)); + } + + public function testRollbackSucceedsAndClearsRecoveryFlag(): void + { + $service = new StubRollbackService(); + $command = new TestRollbackCommand($service, [true]); + $command->setSnapshots([ + ['id' => 'snap-1', 'target_version' => '1.7.49'] + ]); + $this->setAllYes($command, true); + + $this->injectIo($command, new ArrayInput([])); + $status = $command->runServe(); + + self::assertSame(0, $status); + self::assertTrue($service->rollbackCalled); + self::assertTrue($service->clearFlagCalled); + } + + private function setAllYes(RollbackCommand $command, bool $value): void + { + $ref = new \ReflectionProperty(RollbackCommand::class, 'allYes'); + $ref->setAccessible(true); + $ref->setValue($command, $value); + } + + /** + * @param TestRollbackCommand $command + * @param ArrayInput $input + * @return array{0:RollbackMemoryStyle} + */ + private function injectIo(TestRollbackCommand $command, ArrayInput $input): array + { + $buffer = new BufferedOutput(); + $style = new RollbackMemoryStyle($input, $buffer, $command->responses); + + $this->setProtectedProperty($command, 'input', $input); + $this->setProtectedProperty($command, 'output', $style); + + $input->bind($command->getDefinition()); + + return [$style]; + } + + private function setProtectedProperty(object $object, string $property, $value): void + { + $ref = new \ReflectionProperty(\Grav\Console\GpmCommand::class, $property); + $ref->setAccessible(true); + $ref->setValue($object, $value); + } +} + +class TestRollbackCommand extends RollbackCommand +{ + /** @var SafeUpgradeService */ + private $service; + /** @var array */ + private $snapshots = []; + /** @var array */ + public $responses = []; + + public function __construct(SafeUpgradeService $service, array $responses = []) + { + parent::__construct(); + $this->service = $service; + $this->responses = $responses; + } + + public function setSnapshots(array $snapshots): void + { + $this->snapshots = $snapshots; + } + + protected function createSafeUpgradeService(): SafeUpgradeService + { + return $this->service; + } + + protected function collectSnapshots(): array + { + return $this->snapshots; + } + + public function runServe(): int + { + return $this->serve(); + } +} + +class StubRollbackService extends SafeUpgradeService +{ + public $rollbackCalled = false; + public $clearFlagCalled = false; + + public function __construct() + { + parent::__construct([]); + } + + public function rollback(?string $id = null): ?array + { + $this->rollbackCalled = true; + + return ['id' => $id]; + } + + public function clearRecoveryFlag(): void + { + $this->clearFlagCalled = true; + } +} + +class RollbackMemoryStyle extends SymfonyStyle +{ + /** @var array */ + public $messages = []; + /** @var array */ + private $responses; + + public function __construct(InputInterface $input, BufferedOutput $output, array $responses = []) + { + parent::__construct($input, $output); + $this->responses = $responses; + } + + public function newLine($count = 1): void + { + for ($i = 0; $i < $count; $i++) { + $this->messages[] = ''; + } + parent::newLine($count); + } + + public function writeln($messages, $type = self::OUTPUT_NORMAL): void + { + foreach ((array)$messages as $message) { + $this->messages[] = (string)$message; + } + parent::writeln($messages, $type); + } + + public function error($message): void + { + $this->messages[] = 'error:' . $message; + parent::error($message); + } + + public function success($message): void + { + $this->messages[] = 'success:' . $message; + parent::success($message); + } + + public function askQuestion($question) + { + if ($this->responses) { + return array_shift($this->responses); + } + + return parent::askQuestion($question); + } +} diff --git a/tests/unit/Grav/Console/Gpm/SelfupgradeCommandTest.php b/tests/unit/Grav/Console/Gpm/SelfupgradeCommandTest.php new file mode 100644 index 000000000..0542f0870 --- /dev/null +++ b/tests/unit/Grav/Console/Gpm/SelfupgradeCommandTest.php @@ -0,0 +1,213 @@ +injectIo($command); + + $result = $command->runHandle([ + 'plugins_pending' => [], + 'psr_log_conflicts' => [], + 'warnings' => [] + ]); + + self::assertTrue($result); + self::assertSame([], $style->messages); + } + + public function testHandlePreflightReportFailsWhenPendingEvenWithAllYes(): void + { + $command = new TestSelfupgradeCommand(); + [$style] = $this->injectIo($command); + $this->setAllYes($command, true); + + $result = $command->runHandle([ + 'plugins_pending' => ['foo' => ['type' => 'plugin', 'current' => '1', 'available' => '2']], + 'psr_log_conflicts' => ['bar' => ['requires' => '^1.0']], + 'warnings' => ['pending'] + ]); + + self::assertFalse($result); + $output = implode("\n", $style->messages); + self::assertStringContainsString('Run `bin/gpm update` first', $output); + } + + public function testHandlePreflightReportAbortsOnPendingWhenDeclined(): void + { + $command = new TestSelfupgradeCommand(); + [$style] = $this->injectIo($command); + $this->setAllYes($command, false); + + $result = $command->runHandle([ + 'plugins_pending' => ['foo' => ['type' => 'plugin', 'current' => '1', 'available' => '2']], + 'psr_log_conflicts' => [], + 'warnings' => [] + ]); + + self::assertFalse($result); + self::assertStringContainsString('Run `bin/gpm update` first', implode("\n", $style->messages)); + } + + public function testHandlePreflightReportAbortsOnConflictWhenDeclined(): void + { + $command = new TestSelfupgradeCommand(); + [$style] = $this->injectIo($command, ['abort']); + $this->setAllYes($command, false); + + $result = $command->runHandle([ + 'plugins_pending' => [], + 'psr_log_conflicts' => ['foo' => ['requires' => '^1.0']], + 'warnings' => [] + ]); + + self::assertFalse($result); + self::assertStringContainsString('Adjust composer requirements', implode("\n", $style->messages)); + } + + public function testHandlePreflightReportDisablesPluginsWhenRequested(): void + { + $gravFactory = Fixtures::get('grav'); + $grav = $gravFactory(); + $stub = new class { + public $disabled = []; + public function disablePlugin(string $slug, array $context = []): void + { + $this->disabled[] = $slug; + } + }; + $grav['recovery'] = $stub; + + $command = new TestSelfupgradeCommand(); + [$style] = $this->injectIo($command, ['disable']); + + $result = $command->runHandle([ + 'plugins_pending' => [], + 'psr_log_conflicts' => ['foo' => ['requires' => '^1.0']], + 'warnings' => [] + ]); + + self::assertTrue($result); + self::assertSame(['foo'], $stub->disabled); + $output = implode("\n", $style->messages); + self::assertStringContainsString('Continuing with conflicted plugins disabled.', $output); + } + + public function testHandlePreflightReportContinuesWhenRequested(): void + { + $command = new TestSelfupgradeCommand(); + [$style] = $this->injectIo($command, ['continue']); + + $result = $command->runHandle([ + 'plugins_pending' => [], + 'psr_log_conflicts' => ['foo' => ['requires' => '^1.0']], + 'warnings' => [] + ]); + + self::assertTrue($result); + $output = implode("\n", $style->messages); + self::assertStringContainsString('Proceeding with potential psr/log incompatibilities still active.', $output); + } + + /** + * @param TestSelfupgradeCommand $command + * @param array $responses + * @return array{0:SelfUpgradeMemoryStyle,1:InputInterface} + */ + private function injectIo(TestSelfupgradeCommand $command, array $responses = []): array + { + $input = new ArrayInput([]); + $buffer = new BufferedOutput(); + $style = new SelfUpgradeMemoryStyle($input, $buffer, $responses); + + $this->setProtectedProperty($command, 'input', $input); + $this->setProtectedProperty($command, 'output', $style); + + $input->bind($command->getDefinition()); + + return [$style, $input]; + } + + private function setAllYes(SelfupgradeCommand $command, bool $value): void + { + $ref = new \ReflectionProperty(SelfupgradeCommand::class, 'all_yes'); + $ref->setAccessible(true); + $ref->setValue($command, $value); + } + + private function setProtectedProperty(object $object, string $property, $value): void + { + $ref = new \ReflectionProperty(\Grav\Console\GpmCommand::class, $property); + $ref->setAccessible(true); + $ref->setValue($object, $value); + } +} + +class TestSelfupgradeCommand extends SelfupgradeCommand +{ + public function runHandle(array $report): bool + { + return $this->handlePreflightReport($report); + } +} + +class SelfUpgradeMemoryStyle extends SymfonyStyle +{ + /** @var array */ + public $messages = []; + /** @var array */ + private $responses; + + /** + * @param InputInterface $input + * @param BufferedOutput $output + * @param array $responses + */ + public function __construct(InputInterface $input, BufferedOutput $output, array $responses = []) + { + parent::__construct($input, $output); + $this->responses = $responses; + } + + public function newLine($count = 1): void + { + for ($i = 0; $i < $count; $i++) { + $this->messages[] = ''; + } + parent::newLine($count); + } + + public function writeln($messages, $type = self::OUTPUT_NORMAL): void + { + foreach ((array)$messages as $message) { + $this->messages[] = (string)$message; + } + parent::writeln($messages, $type); + } + + public function askQuestion($question) + { + if ($this->responses) { + return array_shift($this->responses); + } + + return parent::askQuestion($question); + } + + public function choice($question, array $choices, $default = null, $attempts = null, $errorMessage = 'Invalid value.') + { + if ($this->responses) { + return array_shift($this->responses); + } + + return parent::choice($question, $choices, $default, $attempts, $errorMessage); + } +}