From c4e73d3827cdbcf8109f2ba8c54e976a574540b4 Mon Sep 17 00:00:00 2001 From: Andy Miller Date: Tue, 24 Feb 2026 21:53:15 -0700 Subject: [PATCH] Merge release/1.8.0 fixes: YamlUpdater undefine, upgrade resilience, PHP 8.3 guard, standalone upgrade script - Fix YamlUpdater.undefine() to actually remove lines from YAML files - Add maintenance mode (503) during core upgrades via .upgrading flag - Add opcache_reset in sophisticatedInstall() for reliable file operations - Add early PHP 8.3 version check in bin/grav - Add PHP CLI vs web version mismatch hint in Install error messages - Update 1.7 bridge version references from 1.7.50 to 1.7.51 - Disable recovery_mode by default until stability confirmed - Add standalone upgrade.php script (CLI + web) - Update markdowndocs to ^3.0 --- bin/grav | 8 + composer.json | 4 +- composer.lock | 17 +- index.php | 13 + system/config/system.yaml | 2 +- system/src/Grav/Common/GPM/Installer.php | 13 + system/src/Grav/Installer/Install.php | 27 +- system/src/Grav/Installer/YamlUpdater.php | 46 ++- upgrade.php | 387 ++++++++++++++++++++++ 9 files changed, 497 insertions(+), 20 deletions(-) create mode 100644 upgrade.php diff --git a/bin/grav b/bin/grav index 793946245..bf0ccac36 100755 --- a/bin/grav +++ b/bin/grav @@ -13,6 +13,14 @@ use Grav\Console\Application\GravApplication; \define('GRAV_CLI', true); \define('GRAV_REQUEST_TIME', microtime(true)); +// Early PHP version check before loading any Grav code +if (PHP_VERSION_ID < 80300) { + echo "Grav 1.8 requires PHP 8.3.0+. You are running PHP " . PHP_VERSION . "\n"; + echo "Your web server may use a different PHP version.\n"; + echo "Check your hosting control panel for the correct CLI PHP path.\n"; + exit(1); +} + if (!file_exists(__DIR__ . '/../vendor/autoload.php')){ // Before we can even start, we need to run composer first require_once __DIR__ . '/../system/src/Grav/Common/Composer.php'; diff --git a/composer.json b/composer.json index 9330d04b2..1fac85073 100644 --- a/composer.json +++ b/composer.json @@ -69,7 +69,7 @@ "phpstan/phpstan": "^2.1", "phpstan/phpstan-deprecation-rules": "^2.0", "phpunit/php-code-coverage": "^11.0", - "getgrav/markdowndocs": "^2.0", + "getgrav/markdowndocs": "^3.0", "codeception/module-asserts": "*", "codeception/module-phpbrowser": "*", "rector/rector": "^2.1" @@ -134,7 +134,7 @@ ] }, "scripts": { - "api": "vendor/bin/phpdoc-md generate system/src > user/pages/17/14.api/default.md", + "api": "vendor/bin/phpdoc-md generate --html system/src > ~/workspace/grav-learn/user/pages/api/default.md", "post-create-project-cmd": "bin/grav install", "rector": "vendor/bin/rector", "rector:php-compat": "@php vendor/bin/rector process --config=system/rector.php --ansi --no-progress-bar", diff --git a/composer.lock b/composer.lock index 19b4445a2..9fe1aff12 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "b39160710f90a14d6be86b6c7237404a", + "content-hash": "853eaabfa780ccf3609b909cc345eb0b", "packages": [ { "name": "antoligy/dom-string-iterators", @@ -4902,20 +4902,21 @@ }, { "name": "getgrav/markdowndocs", - "version": "2.0.1", + "version": "3.0.1", "source": { "type": "git", "url": "https://github.com/getgrav/PHP-Markdown-Documentation-Generator.git", - "reference": "4a24d1b64a88da17e8f1696dc64969f5ca769064" + "reference": "b441b225709cf08a62c21c7c5dd775c59b7aaffe" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/getgrav/PHP-Markdown-Documentation-Generator/zipball/4a24d1b64a88da17e8f1696dc64969f5ca769064", - "reference": "4a24d1b64a88da17e8f1696dc64969f5ca769064", + "url": "https://api.github.com/repos/getgrav/PHP-Markdown-Documentation-Generator/zipball/b441b225709cf08a62c21c7c5dd775c59b7aaffe", + "reference": "b441b225709cf08a62c21c7c5dd775c59b7aaffe", "shasum": "" }, "require": { - "php": ">=5.5.0", + "erusev/parsedown": "^1.7", + "php": ">=8.0", "symfony/console": ">=2.6" }, "require-dev": { @@ -4948,9 +4949,9 @@ "description": "Command line tool for generating markdown-formatted class documentation", "homepage": "https://github.com/victorjonsson/PHP-Markdown-Documentation-Generator", "support": { - "source": "https://github.com/getgrav/PHP-Markdown-Documentation-Generator/tree/2.0.1" + "source": "https://github.com/getgrav/PHP-Markdown-Documentation-Generator/tree/3.0.1" }, - "time": "2021-04-20T06:04:42+00:00" + "time": "2026-02-17T01:09:39+00:00" }, { "name": "guzzlehttp/guzzle", diff --git a/index.php b/index.php index abde8d26d..df16d9814 100644 --- a/index.php +++ b/index.php @@ -61,6 +61,19 @@ if (PHP_SAPI !== 'cli') { } } +// Maintenance mode during core upgrade +if (file_exists(__DIR__ . '/.upgrading')) { + if (time() - filemtime(__DIR__ . '/.upgrading') > 300) { + @unlink(__DIR__ . '/.upgrading'); // Stale flag (>5 min), remove it + } else { + http_response_code(503); + header('Retry-After: 60'); + echo 'Upgrading'; + echo '

Site Upgrading

Please try again in a moment.

'; + exit; + } +} + // Ensure vendor libraries exist $autoload = __DIR__ . '/vendor/autoload.php'; if (!is_file($autoload)) { diff --git a/system/config/system.yaml b/system/config/system.yaml index bb2cf700c..764f59774 100644 --- a/system/config/system.yaml +++ b/system/config/system.yaml @@ -206,7 +206,7 @@ gpm: updates: safe_upgrade: true # Enable guarded staging+rollback pipeline for Grav self-updates safe_upgrade_snapshot_limit: 5 # Maximum number of safe-upgrade snapshots to retain (0 = unlimited) - recovery_mode: true # Enable recovery mode when fatal errors occur during upgrades + recovery_mode: false # Enable recovery mode when fatal errors occur during upgrades http: method: auto # Either 'curl', 'fopen' or 'auto'. 'auto' will try fopen first and if not available cURL diff --git a/system/src/Grav/Common/GPM/Installer.php b/system/src/Grav/Common/GPM/Installer.php index 2263d0c60..bed374c2e 100644 --- a/system/src/Grav/Common/GPM/Installer.php +++ b/system/src/Grav/Common/GPM/Installer.php @@ -298,6 +298,12 @@ class Installer */ public static function sophisticatedInstall($source_path, $install_path, $ignores = [], $keep_source = false) { + // Set maintenance mode flag and clear opcache before file operations + @file_put_contents(GRAV_ROOT . '/.upgrading', date('Y-m-d H:i:s')); + if (function_exists('opcache_reset')) { + @opcache_reset(); + } + foreach (new DirectoryIterator($source_path) as $file) { if ($file->isLink() || $file->isDot() || in_array($file->getFilename(), $ignores, true)) { continue; @@ -325,6 +331,13 @@ class Installer } } + // Remove maintenance mode flag and clear opcache after file operations + @unlink(GRAV_ROOT . '/.upgrading'); + clearstatcache(true); + if (function_exists('opcache_reset')) { + @opcache_reset(); + } + return true; } diff --git a/system/src/Grav/Installer/Install.php b/system/src/Grav/Installer/Install.php index 6aa0d2503..5a1c84b1f 100644 --- a/system/src/Grav/Installer/Install.php +++ b/system/src/Grav/Installer/Install.php @@ -83,8 +83,8 @@ final class Install 'grav' => [ 'name' => 'Grav', 'versions' => [ - '1.7' => '1.7.50', - '' => '1.7.50' + '1.7' => '1.7.51', + '' => '1.7.51' ] ], 'plugins' => [ @@ -249,12 +249,25 @@ final class Install $errors = implode("
\n", $error); if (\defined('GRAV_CLI') && GRAV_CLI) { $errors = "\n\n" . strip_tags($errors) . "\n\n"; - $errors .= <<items) { - throw new \RuntimeException('Failed saving the content'); + // Lines and items are out of sync — fall back to dumping + // from items directly. Loses original formatting but + // guarantees the file content matches the intended state. + $yaml = Yaml::dump($this->items, 5, 2); } } @@ -186,7 +189,46 @@ final class YamlUpdater return; } - // TODO: support also removing property from handwritten configuration file. + $parts = explode('.', $variable); + $lineNos = $this->findPath($this->lines, $parts); + + // Get the line number of the target property itself (last in the path). + $targetLine = end($lineNos); + + // Negative value means the property was not found in the lines. + if ($targetLine === false || $targetLine < 0) { + return; + } + + // Determine how many lines to remove: the target line plus any + // more-deeply-indented child lines that follow it. + $targetIndent = $this->getLineIndentation($this->lines[$targetLine]); + $endLine = $targetLine + 1; + $total = count($this->lines); + + while ($endLine < $total) { + $line = $this->lines[$endLine]; + if ($this->isLineEmpty($line)) { + // Blank/comment lines — tentatively include them; they may + // belong to the block being removed or to a following sibling. + $endLine++; + continue; + } + if ($this->getLineIndentation($line) > $targetIndent) { + // Child line — part of the value being removed. + $endLine++; + continue; + } + // Reached a sibling or parent — stop. + break; + } + + // Don't swallow trailing blank lines that belong to the next section. + while ($endLine > $targetLine + 1 && $this->isLineBlank($this->lines[$endLine - 1])) { + $endLine--; + } + + array_splice($this->lines, $targetLine, $endLine - $targetLine); } private function __construct(string $filename) diff --git a/upgrade.php b/upgrade.php new file mode 100644 index 000000000..01686e3c8 --- /dev/null +++ b/upgrade.php @@ -0,0 +1,387 @@ +#!/usr/bin/env php +\n"; + if (ob_get_level()) { ob_flush(); } + flush(); + } +} + +function outError($msg, $isCli) { + if ($isCli) { + fwrite(STDERR, "ERROR: " . $msg . "\n"); + } else { + echo "ERROR: " . htmlspecialchars($msg) . "
\n"; + if (ob_get_level()) { ob_flush(); } + flush(); + } +} + +function outSuccess($msg, $isCli) { + if ($isCli) { + echo "\033[32m" . $msg . "\033[0m\n"; + } else { + echo "" . htmlspecialchars($msg) . "
\n"; + if (ob_get_level()) { ob_flush(); } + flush(); + } +} + +// Start output +if (!$isCli) { + echo 'Grav Upgrade'; + echo ''; + echo ''; + echo '

Grav Standalone Upgrade

';
+}
+
+out("Grav Standalone Upgrade Script", $isCli);
+out("==============================", $isCli);
+out("", $isCli);
+
+// Step 1: Validate environment
+out("[1/7] Validating environment...", $isCli);
+
+// Check PHP version
+if (version_compare(PHP_VERSION, $minPhpVersion, '<')) {
+    outError("PHP {$minPhpVersion}+ required. You are running PHP " . PHP_VERSION, $isCli);
+    if (!$isCli) {
+        out("If your web server shows a different PHP version, try running this script via CLI:", $isCli);
+        out("  php8.3 upgrade.php", $isCli);
+    }
+    exit(1);
+}
+out("  PHP " . PHP_VERSION . " — OK", $isCli);
+
+// Verify this is a Grav installation
+if (!is_file($gravRoot . '/index.php') || !is_dir($gravRoot . '/system') || !is_dir($gravRoot . '/bin')) {
+    outError("This does not appear to be a Grav installation.", $isCli);
+    outError("Place this script in your Grav root directory.", $isCli);
+    exit(1);
+}
+out("  Grav root: {$gravRoot} — OK", $isCli);
+
+// Check write permissions
+if (!is_writable($gravRoot . '/system')) {
+    outError("The system/ directory is not writable.", $isCli);
+    exit(1);
+}
+out("  Write permissions — OK", $isCli);
+
+// Read current version
+$currentVersion = 'unknown';
+$definesFile = $gravRoot . '/system/defines.php';
+if (is_file($definesFile)) {
+    $content = file_get_contents($definesFile);
+    if (preg_match("/define\('GRAV_VERSION',\s*'([^']+)'\)/", $content, $m)) {
+        $currentVersion = $m[1];
+    }
+}
+out("  Current Grav version: {$currentVersion}", $isCli);
+
+// Check if ZipArchive is available
+if (!class_exists('ZipArchive')) {
+    outError("PHP ZipArchive extension is required but not available.", $isCli);
+    exit(1);
+}
+out("  ZipArchive — OK", $isCli);
+out("", $isCli);
+
+// Step 2: Confirm upgrade
+if ($isCli) {
+    out("[2/7] About to upgrade Grav from {$currentVersion} to latest 1.8.x", $isCli);
+    out("  Press Enter to continue or Ctrl+C to abort...", $isCli);
+    if (defined('STDIN')) {
+        fgets(STDIN);
+    }
+} else {
+    // In browser mode, check for confirmation parameter
+    if (!isset($_GET['confirm']) || $_GET['confirm'] !== 'yes') {
+        echo "
"; + echo "

Ready to upgrade Grav from {$currentVersion} to latest 1.8.x

"; + echo "

Confirm Upgrade

"; + echo ""; + exit(0); + } + out("[2/7] Upgrade confirmed.", $isCli); +} +out("", $isCli); + +// Step 3: Download update package +out("[3/7] Downloading Grav update package...", $isCli); +$tmpDir = $gravRoot . '/tmp'; +if (!is_dir($tmpDir)) { + @mkdir($tmpDir, 0775, true); +} +$zipPath = $tmpDir . '/grav-upgrade-' . date('YmdHis') . '.zip'; + +$downloaded = false; + +// Try curl first +if (function_exists('curl_init')) { + $ch = curl_init($gravDownloadUrl); + $fp = fopen($zipPath, 'w'); + if ($fp) { + curl_setopt($ch, CURLOPT_FILE, $fp); + curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true); + curl_setopt($ch, CURLOPT_MAXREDIRS, 5); + curl_setopt($ch, CURLOPT_TIMEOUT, 300); + curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true); + curl_exec($ch); + $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + $error = curl_error($ch); + curl_close($ch); + fclose($fp); + + if ($httpCode === 200 && filesize($zipPath) > 0) { + $downloaded = true; + } else { + @unlink($zipPath); + if ($error) { + out(" curl failed: {$error}", $isCli); + } + } + } +} + +// Fallback to file_get_contents +if (!$downloaded && ini_get('allow_url_fopen')) { + $context = stream_context_create([ + 'http' => [ + 'timeout' => 300, + 'follow_location' => true, + ] + ]); + $data = @file_get_contents($gravDownloadUrl, false, $context); + if ($data !== false && strlen($data) > 0) { + file_put_contents($zipPath, $data); + $downloaded = true; + } +} + +if (!$downloaded) { + outError("Failed to download update package.", $isCli); + outError("You can manually download it and place it at: {$zipPath}", $isCli); + exit(1); +} + +$sizeMb = round(filesize($zipPath) / 1048576, 1); +out(" Downloaded {$sizeMb}MB to {$zipPath}", $isCli); +out("", $isCli); + +// Step 4: Create backup of system/ and vendor/ +out("[4/7] Backing up system/ and vendor/...", $isCli); +$backupDir = $gravRoot . '/backup/pre-upgrade-' . date('YmdHis'); +@mkdir($backupDir, 0775, true); + +function recursiveCopy($src, $dst) { + if (is_link($src)) { + $target = readlink($src); + if ($target !== false) { + @symlink($target, $dst); + } + return; + } + + if (is_dir($src)) { + @mkdir($dst, 0775, true); + $files = scandir($src); + foreach ($files as $file) { + if ($file === '.' || $file === '..') continue; + recursiveCopy($src . '/' . $file, $dst . '/' . $file); + } + } else { + @copy($src, $dst); + } +} + +recursiveCopy($gravRoot . '/system', $backupDir . '/system'); +out(" Backed up system/", $isCli); +recursiveCopy($gravRoot . '/vendor', $backupDir . '/vendor'); +out(" Backed up vendor/", $isCli); + +// Also backup key root files +foreach (['index.php', 'composer.json', 'composer.lock'] as $rootFile) { + if (is_file($gravRoot . '/' . $rootFile)) { + @copy($gravRoot . '/' . $rootFile, $backupDir . '/' . $rootFile); + } +} +out(" Backed up root files (index.php, composer.json, composer.lock)", $isCli); +out(" Backup location: {$backupDir}", $isCli); +out("", $isCli); + +// Step 5: Set maintenance mode and clear opcache +out("[5/7] Setting maintenance mode...", $isCli); +@file_put_contents($gravRoot . '/.upgrading', date('Y-m-d H:i:s')); +if (function_exists('opcache_reset')) { + @opcache_reset(); + out(" OPcache cleared", $isCli); +} +out(" Maintenance mode enabled", $isCli); +out("", $isCli); + +// Step 6: Extract update +out("[6/7] Extracting update package...", $isCli); +$zip = new ZipArchive(); +$result = $zip->open($zipPath); +if ($result !== true) { + outError("Failed to open zip file (error code: {$result})", $isCli); + @unlink($gravRoot . '/.upgrading'); + exit(1); +} + +$extractDir = $tmpDir . '/grav-extract-' . uniqid(); +@mkdir($extractDir, 0775, true); +$zip->extractTo($extractDir); +$zip->close(); + +// Find the extracted package root (usually grav-update/ or grav/) +$extractedRoot = null; +$entries = scandir($extractDir); +foreach ($entries as $entry) { + if ($entry === '.' || $entry === '..') continue; + if (is_dir($extractDir . '/' . $entry)) { + $extractedRoot = $extractDir . '/' . $entry; + break; + } +} + +if (!$extractedRoot || !is_file($extractedRoot . '/system/defines.php')) { + outError("Invalid update package structure.", $isCli); + @unlink($gravRoot . '/.upgrading'); + exit(1); +} + +// Read target version +$targetVersion = 'unknown'; +$targetDefines = file_get_contents($extractedRoot . '/system/defines.php'); +if (preg_match("/define\('GRAV_VERSION',\s*'([^']+)'\)/", $targetDefines, $m)) { + $targetVersion = $m[1]; +} +out(" Target version: {$targetVersion}", $isCli); + +// Items to skip during upgrade (user data, caches, etc.) +$ignores = ['backup', 'cache', 'images', 'logs', 'tmp', 'user', '.htaccess', 'robots.txt']; + +// Copy files from extracted package to Grav root +$sourceEntries = scandir($extractedRoot); +$copied = 0; +foreach ($sourceEntries as $entry) { + if ($entry === '.' || $entry === '..' || in_array($entry, $ignores)) continue; + + $src = $extractedRoot . '/' . $entry; + $dst = $gravRoot . '/' . $entry; + + if (is_dir($src)) { + // Remove old directory and copy new one + function recursiveDelete($dir) { + if (!is_dir($dir)) return; + $files = scandir($dir); + foreach ($files as $file) { + if ($file === '.' || $file === '..') continue; + $path = $dir . '/' . $file; + if (is_dir($path) && !is_link($path)) { + recursiveDelete($path); + } else { + @unlink($path); + } + } + @rmdir($dir); + } + + if (is_dir($dst) && !is_link($dst)) { + recursiveDelete($dst); + } + recursiveCopy($src, $dst); + + // Set executable permissions on bin files + if ($entry === 'bin') { + $binFiles = glob($dst . '/*'); + if ($binFiles) { + foreach ($binFiles as $binFile) { + @chmod($binFile, 0755); + } + } + } + } else { + @unlink($dst); + @copy($src, $dst); + } + $copied++; +} +out(" Copied {$copied} items", $isCli); +out("", $isCli); + +// Step 7: Cleanup and finalize +out("[7/7] Finalizing...", $isCli); + +// Remove maintenance mode +@unlink($gravRoot . '/.upgrading'); +out(" Maintenance mode disabled", $isCli); + +// Clear opcache +if (function_exists('opcache_reset')) { + @opcache_reset(); + out(" OPcache cleared", $isCli); +} + +// Clear Grav cache +$cacheDir = $gravRoot . '/cache'; +if (is_dir($cacheDir)) { + $cacheDirs = glob($cacheDir . '/*'); + if ($cacheDirs) { + foreach ($cacheDirs as $dir) { + if (is_dir($dir) && !is_link($dir) && basename($dir) !== '.gitkeep') { + recursiveDelete($dir); + } + } + } + out(" Cache cleared", $isCli); +} + +// Clean up temp files +@unlink($zipPath); +if (is_dir($extractDir)) { + recursiveDelete($extractDir); +} +out(" Temporary files cleaned up", $isCli); + +// Clear stat cache +clearstatcache(true); + +out("", $isCli); +outSuccess("Upgrade complete! Grav {$currentVersion} -> {$targetVersion}", $isCli); +out("", $isCli); +out("Backup saved to: {$backupDir}", $isCli); +out("", $isCli); +out("IMPORTANT: Delete this upgrade.php file from your Grav root for security.", $isCli); + +if (!$isCli) { + echo ""; +}