diff --git a/bin/test-selfupgrade.sh b/bin/test-selfupgrade.sh new file mode 100755 index 000000000..20e031b60 --- /dev/null +++ b/bin/test-selfupgrade.sh @@ -0,0 +1,204 @@ +#!/usr/bin/env bash +# Local self-upgrade test harness. +# Spins up a fake GPM feed, points a target Grav install at it, and exercises +# the upgrade flow against a locally-built grav-update zip. +# +# Modes: +# cli run `bin/gpm selfupgrade` end-to-end non-interactively, then tear down +# admin patch URL + start server, keep both running so you can trigger the +# upgrade from the admin UI in a browser; Ctrl+C to tear down +# snapshot snapshot the target install to $SNAPSHOT so you can re-run tests +# restore restore the target install from $SNAPSHOT +# +# Usage: bin/test-selfupgrade.sh [cli|admin|snapshot|restore] (default: cli) +# +# Env overrides: TARGET, SNAPSHOT, ZIP_PATH, TO_VERSION, PORT, CDN_DIR + +set -euo pipefail + +MODE="${1:-cli}" + +TARGET="${TARGET:-$HOME/workspace/grav-admin-180b29}" +SNAPSHOT="${SNAPSHOT:-${TARGET}.snapshot}" +ZIP_PATH="${ZIP_PATH:-/tmp/grav-build-local/grav-dist/grav-update-v1.8.0-beta.30.zip}" +TO_VERSION="${TO_VERSION:-1.8.0-beta.30}" +PORT="${PORT:-8765}" +CDN_DIR="${CDN_DIR:-/tmp/grav-fake-cdn}" + +core="$TARGET/system/src/Grav/Common/GPM/Remote/GravCore.php" + +require_target() { + [[ -d "$TARGET" ]] || { echo "ERROR: target install not found at $TARGET" >&2; exit 1; } +} + +require_zip() { + [[ -f "$ZIP_PATH" ]] || { echo "ERROR: zip not found at $ZIP_PATH" >&2; exit 1; } +} + +do_snapshot() { + require_target + if [[ -d "$SNAPSHOT" ]]; then + echo "snapshot already exists at $SNAPSHOT — delete it first if you want a fresh one" + exit 1 + fi + echo "snapshotting $TARGET -> $SNAPSHOT" + cp -R "$TARGET" "$SNAPSHOT" + echo "done." +} + +do_restore() { + [[ -d "$SNAPSHOT" ]] || { echo "ERROR: no snapshot at $SNAPSHOT" >&2; exit 1; } + echo "restoring $TARGET from $SNAPSHOT" + rm -rf "$TARGET" + cp -R "$SNAPSHOT" "$TARGET" + echo "done." +} + +stage_cdn() { + require_zip + local zip_name zip_size + zip_name="$(basename "$ZIP_PATH")" + zip_size="$(stat -f%z "$ZIP_PATH" 2>/dev/null || stat -c%s "$ZIP_PATH")" + + echo "=== staging fake CDN at $CDN_DIR ===" + rm -rf "$CDN_DIR" + mkdir -p "$CDN_DIR" + cp "$ZIP_PATH" "$CDN_DIR/$zip_name" + + cat > "$CDN_DIR/grav.json" </dev/null + php -S "localhost:$PORT" >/tmp/grav-cdn.log 2>&1 & + server_pid=$! + popd >/dev/null + sleep 1 + curl -sf "http://localhost:$PORT/grav.json" >/dev/null && echo "server up, feed reachable" +} + +clear_gpm_cache() { + rm -rf "$TARGET/cache/gpm" + echo "cleared $TARGET/cache/gpm" +} + +verify() { + echo "=== verify ===" + echo -n "GRAV_VERSION now: " + grep "GRAV_VERSION" "$TARGET/system/defines.php" + echo -n "schema stamp now: " + grep "schema:" "$TARGET/user/config/versions.yaml" | head -1 + echo -n "upgrade.php: " + [[ -f "$TARGET/upgrade.php" ]] && echo "STILL EXISTS (fail)" || echo "removed (good)" + echo -n "needs_fixing.txt:" + [[ -f "$TARGET/needs_fixing.txt" ]] && echo " STILL EXISTS (fail)" || echo " removed (good)" +} + +case "$MODE" in + snapshot) do_snapshot ;; + restore) do_restore ;; + + cli) + require_target + plant_sentinels + stage_cdn + patch_core + clear_gpm_cache + start_server + trap 'kill $server_pid 2>/dev/null || true; restore_core' EXIT + + echo + echo "=== running bin/gpm selfupgrade -y -f ===" + pushd "$TARGET" >/dev/null + bin/gpm selfupgrade -y -f || echo "(selfupgrade exited non-zero)" + popd >/dev/null + + echo + verify + echo + echo "Done. GravCore.php restored, server stopped." + ;; + + admin) + require_target + plant_sentinels + stage_cdn + patch_core + clear_gpm_cache + start_server + + cat </dev/null || true; restore_core; echo "done."; verify' EXIT INT TERM + wait $server_pid + ;; + + *) + echo "usage: $0 [cli|admin|snapshot|restore]" >&2 + exit 2 + ;; +esac diff --git a/index.php b/index.php index 6337cd797..18c3c491c 100644 --- a/index.php +++ b/index.php @@ -74,24 +74,6 @@ if (PHP_SAPI !== 'cli') { } } - if ($path === '/___safe-upgrade-status') { - $statusEndpoint = __DIR__ . '/user/plugins/admin/safe-upgrade-status.php'; - if (!\defined('GRAV_ROOT')) { - // Minimal bootstrap so the status script has the expected constants. - require_once __DIR__ . '/system/defines.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; - } } // Maintenance mode during core upgrade @@ -123,34 +105,6 @@ date_default_timezone_set(@date_default_timezone_get()); @ini_set('default_charset', 'UTF-8'); mb_internal_encoding('UTF-8'); -// Use getcwd() for paths to support symlinked index.php (GRAV_ROOT uses getcwd()) -$gravRoot = rtrim(str_replace(DIRECTORY_SEPARATOR, '/', getenv('GRAV_ROOT') ?: getcwd()), '/'); - -// Helper function to check if recovery mode is enabled in config (updates.recovery_mode) -$isRecoveryEnabled = static function () use ($gravRoot) { - $userConfig = $gravRoot . '/user/config/system.yaml'; - if (!is_file($userConfig)) { - return true; // Default enabled - } - $content = file_get_contents($userConfig); - if ($content === false) { - return true; - } - if (preg_match('/^\s*updates:\s*\n(?:\s+\w+:.*\n)*?\s+recovery_mode:\s*(true|false|1|0)\s*$/m', $content, $matches)) { - return in_array(strtolower($matches[1]), ['true', '1'], true); - } - return true; // Default enabled -}; - -$recoveryFlag = $gravRoot . '/user/data/recovery.flag'; -if (PHP_SAPI !== 'cli' && is_file($recoveryFlag) && $isRecoveryEnabled()) { - if (!defined('GRAV_ROOT')) { - define('GRAV_ROOT', $gravRoot); - } - require __DIR__ . '/system/recovery.php'; - return 0; -} - use Grav\Common\Grav; use RocketTheme\Toolbox\Event\Event; @@ -163,10 +117,5 @@ try { } catch (\Error|\Exception $e) { $grav->fireEvent('onFatalException', new Event(['exception' => $e])); - if (PHP_SAPI !== 'cli' && is_file($recoveryFlag) && $isRecoveryEnabled()) { - require __DIR__ . '/system/recovery.php'; - return 0; - } - throw $e; } diff --git a/needs_fixing.txt b/needs_fixing.txt deleted file mode 100644 index bc9122204..000000000 --- a/needs_fixing.txt +++ /dev/null @@ -1,505 +0,0 @@ - ------ ---------------------------------------------------- - Line src/Grav/Common/GPM/Response.php - ------ ---------------------------------------------------- - 3 Class Grav\Common\GPM\Response not found. - 🪪 class.notFound - 💡 Learn more at - https://phpstan.org/user-guide/discovering-symbols - ------ ---------------------------------------------------- - - ------ ----------------------------------------------------------- - Line src/Grav/Common/Grav.php - ------ ----------------------------------------------------------- - 148 No error to ignore is reported on line 148. - 681 Unsafe usage of new static(). - 🪪 new.static - 💡 See: - https://phpstan.org/blog/solving-phpstan-error-unsafe-usa - ge-of-new-static - ------ ----------------------------------------------------------- - - ------ ----------------------------------------------------------- - Line src/Grav/Common/Page/Collection.php - ------ ----------------------------------------------------------- - 112 Unsafe usage of new static(). - 🪪 new.static - 💡 See: - https://phpstan.org/blog/solving-phpstan-error-unsafe-usa - ge-of-new-static - 209 Unsafe usage of new static(). - 🪪 new.static - 💡 See: - https://phpstan.org/blog/solving-phpstan-error-unsafe-usa - ge-of-new-static - ------ ----------------------------------------------------------- - - ------ ----------------------------------------------------------- - Line src/Grav/Common/Processors/InitializeProcessor.php - ------ ----------------------------------------------------------- - 58 Unsafe usage of new static(). - 🪪 new.static - 💡 See: - https://phpstan.org/blog/solving-phpstan-error-unsafe-usa - ge-of-new-static - ------ ----------------------------------------------------------- - - ------ ------------------------------------------------------- - Line src/Grav/Common/Scheduler/Job.php - ------ ------------------------------------------------------- - 574 Call to static method sendEmail() on an unknown class - Grav\Plugin\Email\Utils. - 🪪 class.notFound - 💡 Learn more at - https://phpstan.org/user-guide/discovering-symbols - ------ ------------------------------------------------------- - - ------ ---------------------------------------------------------- - Line src/Grav/Common/Scheduler/SchedulerController.php - ------ ---------------------------------------------------------- - 41 Class Grav\Common\Scheduler\ModernScheduler not found. - 🪪 class.notFound - 💡 Learn more at - https://phpstan.org/user-guide/discovering-symbols - 45 Instantiated class Grav\Common\Scheduler\ModernScheduler - not found. - 🪪 class.notFound - 💡 Learn more at - https://phpstan.org/user-guide/discovering-symbols - ------ ---------------------------------------------------------- - - ------ -------------------------------------------------------- - Line src/Grav/Common/Service/SchedulerServiceProvider.php - ------ -------------------------------------------------------- - 55 Instantiated class Grav\Common\Scheduler\JobWorker not - found. - 🪪 class.notFound - 💡 Learn more at - https://phpstan.org/user-guide/discovering-symbols - ------ -------------------------------------------------------- - - ------ --------------------------------------------- - Line src/Grav/Common/Session.php - ------ --------------------------------------------- - 132 No error to ignore is reported on line 132. - 137 No error to ignore is reported on line 137. - ------ --------------------------------------------- - - ------ ----------------------------------------------------------- - Line src/Grav/Common/Uri.php - ------ ----------------------------------------------------------- - 1131 Unsafe usage of new static(). - 🪪 new.static - 💡 See: - https://phpstan.org/blog/solving-phpstan-error-unsafe-usa - ge-of-new-static - ------ ----------------------------------------------------------- - - ------ -------------------------------------------------------- - Line src/Grav/Console/Application/Application.php - ------ -------------------------------------------------------- - 125 Return type mixed of method - Grav\Console\Application\Application::configureIO() is - not covariant with return type void of method - Symfony\Component\Console\Application::configureIO(). - ------ -------------------------------------------------------- - - ------ --------------------------------------------------------- - Line src/Grav/Console/ConsoleCommand.php - ------ --------------------------------------------------------- - 29 Return type mixed of method - Grav\Console\ConsoleCommand::execute() is not covariant - with return type int of method - Symfony\Component\Console\Command\Command::execute(). - ------ --------------------------------------------------------- - - ------ ----------------------------------------------------------- - Line src/Grav/Console/ConsoleTrait.php (in context of class - Grav\Console\ConsoleCommand) - ------ ----------------------------------------------------------- - 89 Method Grav\Console\ConsoleCommand::addOption() overrides - method - Symfony\Component\Console\Command\Command::addOption() - but misses parameter #6 $suggestedValues. - ------ ----------------------------------------------------------- - - ------ -------------------------------------------------------- - Line src/Grav/Console/ConsoleTrait.php (in context of class - Grav\Console\GpmCommand) - ------ -------------------------------------------------------- - 89 Method Grav\Console\GpmCommand::addOption() overrides - method - Symfony\Component\Console\Command\Command::addOption() - but misses parameter #6 $suggestedValues. - ------ -------------------------------------------------------- - - ------ -------------------------------------------------------- - Line src/Grav/Console/ConsoleTrait.php (in context of class - Grav\Console\GravCommand) - ------ -------------------------------------------------------- - 89 Method Grav\Console\GravCommand::addOption() overrides - method - Symfony\Component\Console\Command\Command::addOption() - but misses parameter #6 $suggestedValues. - ------ -------------------------------------------------------- - - ------ ---------------------------------------------------------- - Line src/Grav/Console/GpmCommand.php - ------ ---------------------------------------------------------- - 31 Return type mixed of method - Grav\Console\GpmCommand::execute() is not covariant with - return type int of method - Symfony\Component\Console\Command\Command::execute(). - 39 No error to ignore is reported on line 39. - ------ ---------------------------------------------------------- - - ------ ----------------------------------------------------------- - Line src/Grav/Console/GravCommand.php - ------ ----------------------------------------------------------- - 29 Return type mixed of method - Grav\Console\GravCommand::execute() is not covariant with - return type int of method - Symfony\Component\Console\Command\Command::execute(). - ------ ----------------------------------------------------------- - - ------ ----------------------------------------------------------- - Line src/Grav/Framework/Acl/RecursiveActionIterator.php - ------ ----------------------------------------------------------- - 62 Unsafe usage of new static(). - 🪪 new.static - 💡 See: - https://phpstan.org/blog/solving-phpstan-error-unsafe-usa - ge-of-new-static - ------ ----------------------------------------------------------- - - ------ ---------------------------------------------------------- - Line src/Grav/Framework/Cache/CacheTrait.php (in context of - class Grav\Framework\Cache\AbstractCache) - ------ ---------------------------------------------------------- - 87 Return type mixed of method - Grav\Framework\Cache\AbstractCache::get() is not - covariant with return type mixed of method - Psr\SimpleCache\CacheInterface::get(). - 102 Return type mixed of method - Grav\Framework\Cache\AbstractCache::set() is not - covariant with return type bool of method - Psr\SimpleCache\CacheInterface::set(). - 117 Return type mixed of method - Grav\Framework\Cache\AbstractCache::delete() is not - covariant with return type bool of method - Psr\SimpleCache\CacheInterface::delete(). - 127 Return type mixed of method - Grav\Framework\Cache\AbstractCache::clear() is not - covariant with return type bool of method - Psr\SimpleCache\CacheInterface::clear(). - 138 Return type mixed of method - Grav\Framework\Cache\AbstractCache::getMultiple() is not - covariant with return type iterable of method - Psr\SimpleCache\CacheInterface::getMultiple(). - 181 Return type mixed of method - Grav\Framework\Cache\AbstractCache::setMultiple() is not - covariant with return type bool of method - Psr\SimpleCache\CacheInterface::setMultiple(). - 214 Return type mixed of method - Grav\Framework\Cache\AbstractCache::deleteMultiple() is - not covariant with return type bool of method - Psr\SimpleCache\CacheInterface::deleteMultiple(). - 242 Return type mixed of method - Grav\Framework\Cache\AbstractCache::has() is not - covariant with return type bool of method - Psr\SimpleCache\CacheInterface::has(). - ------ ---------------------------------------------------------- - - ------ ---------------------------------------------------------- - Line src/Grav/Framework/Collection/AbstractFileCollection.php - ------ ---------------------------------------------------------- - 95 No error to ignore is reported on line 95. - ------ ---------------------------------------------------------- - - ------ ----------------------------------------------------------- - Line src/Grav/Framework/Collection/AbstractIndexCollection.php - ------ ----------------------------------------------------------- - 154 No error to ignore is reported on line 154. - 168 No error to ignore is reported on line 168. - 185 No error to ignore is reported on line 185. - 201 No error to ignore is reported on line 201. - 507 Unsafe usage of new static(). - 🪪 new.static - 💡 See: - https://phpstan.org/blog/solving-phpstan-error-unsafe-usa - ge-of-new-static - ------ ----------------------------------------------------------- - - ------ ----------------------------------------------------------- - Line src/Grav/Framework/Collection/AbstractLazyCollection.php - ------ ----------------------------------------------------------- - 29 Property - Grav\Framework\Collection\AbstractLazyCollection::$collec - tion overriding property - Doctrine\Common\Collections\AbstractLazyCollection::$collection (Doctrine\Common\Collectio - ns\Collection|null) should also have native type - Doctrine\Common\Collections\Collection|null. - ------ ----------------------------------------------------------- - - ------ ----------------------------------------------------------- - Line src/Grav/Framework/Contracts/Relationships/RelationshipIn - terface.php - ------ ----------------------------------------------------------- - 80 Return type iterable of method - Grav\Framework\Contracts\Relationships\RelationshipInterf - ace::getIterator() is not covariant with tentative return - type Traversable of method IteratorAggregate::get - Iterator(). - 💡 Make it covariant, or use the #[\ReturnTypeWillChange] - attribute to temporarily suppress the error. - ------ ----------------------------------------------------------- - - ------ ----------------------------------------------------------- - Line src/Grav/Framework/Filesystem/Filesystem.php - ------ ----------------------------------------------------------- - 51 Unsafe usage of new static(). - 🪪 new.static - 💡 See: - https://phpstan.org/blog/solving-phpstan-error-unsafe-usa - ge-of-new-static - 252 No error to ignore is reported on line 252. - ------ ----------------------------------------------------------- - - ------ --------------------------------------------- - Line src/Grav/Framework/Flex/FlexCollection.php - ------ --------------------------------------------- - 102 No error to ignore is reported on line 102. - ------ --------------------------------------------- - - ------ ----------------------------------------------------------- - Line src/Grav/Framework/Flex/FlexIdentifier.php - ------ ----------------------------------------------------------- - 27 Unsafe usage of new static(). - 🪪 new.static - 💡 See: - https://phpstan.org/blog/solving-phpstan-error-unsafe-usa - ge-of-new-static - ------ ----------------------------------------------------------- - - ------ ---------------------------------------------------------- - Line src/Grav/Framework/Flex/FlexIndex.php - ------ ---------------------------------------------------------- - 109 No error to ignore is reported on line 109. - 934 Method Grav\Framework\Flex\FlexIndex::reduce() should - return TInitial|TReturn but return statement is missing. - 🪪 return.missing - ------ ---------------------------------------------------------- - - ------ ----------------------------------------------------------- - Line src/Grav/Framework/Form/FormFlashFile.php - ------ ----------------------------------------------------------- - 62 Return type mixed of method - Grav\Framework\Form\FormFlashFile::getStream() is not - covariant with return type - Psr\Http\Message\StreamInterface of method - Psr\Http\Message\UploadedFileInterface::getStream(). - 83 Return type mixed of method - Grav\Framework\Form\FormFlashFile::moveTo() is not - covariant with return type void of method - Psr\Http\Message\UploadedFileInterface::moveTo(). - 123 Return type mixed of method - Grav\Framework\Form\FormFlashFile::getSize() is not - covariant with return type int|null of method - Psr\Http\Message\UploadedFileInterface::getSize(). - 131 Return type mixed of method - Grav\Framework\Form\FormFlashFile::getError() is not - covariant with return type int of method - Psr\Http\Message\UploadedFileInterface::getError(). - 139 Return type mixed of method - Grav\Framework\Form\FormFlashFile::getClientFilename() is - not covariant with return type string|null of method - Psr\Http\Message\UploadedFileInterface::getClientFilename - (). - 147 Return type mixed of method - Grav\Framework\Form\FormFlashFile::getClientMediaType() - is not covariant with return type string|null of method - Psr\Http\Message\UploadedFileInterface::getClientMediaTyp - e(). - ------ ----------------------------------------------------------- - - ------ ----------------------------------------------------------- - Line src/Grav/Framework/Logger/Processors/UserProcessor.php - ------ ----------------------------------------------------------- - 24 Parameter #1 $record (array) of method - Grav\Framework\Logger\Processors\UserProcessor::__invoke( - ) is not contravariant with parameter #1 $record - (Monolog\LogRecord) of method - Monolog\Processor\ProcessorInterface::__invoke(). - ------ ----------------------------------------------------------- - - ------ ----------------------------------------------------------- - Line src/Grav/Framework/Media/MediaIdentifier.php - ------ ----------------------------------------------------------- - 30 Unsafe usage of new static(). - 🪪 new.static - 💡 See: - https://phpstan.org/blog/solving-phpstan-error-unsafe-usa - ge-of-new-static - ------ ----------------------------------------------------------- - - ------ ----------------------------------------------------------- - Line src/Grav/Framework/Media/UploadedMediaObject.php - ------ ----------------------------------------------------------- - 36 Unsafe usage of new static(). - 🪪 new.static - 💡 See: - https://phpstan.org/blog/solving-phpstan-error-unsafe-usa - ge-of-new-static - ------ ----------------------------------------------------------- - - ------ ----------------------------------------------------------- - Line src/Grav/Framework/Mime/MimeTypes.php - ------ ----------------------------------------------------------- - 42 Unsafe usage of new static(). - 🪪 new.static - 💡 See: - https://phpstan.org/blog/solving-phpstan-error-unsafe-usa - ge-of-new-static - ------ ----------------------------------------------------------- - - ------ ------------------------------------------------ - Line src/Grav/Framework/Object/ObjectCollection.php - ------ ------------------------------------------------ - 96 No error to ignore is reported on line 96. - ------ ------------------------------------------------ - - ------ --------------------------------------------- - Line src/Grav/Framework/Object/ObjectIndex.php - ------ --------------------------------------------- - 193 No error to ignore is reported on line 193. - ------ --------------------------------------------- - - ------ ----------------------------------------------------------- - Line src/Grav/Framework/Psr7/Stream.php - ------ ----------------------------------------------------------- - 31 Unsafe usage of new static(). - 🪪 new.static - 💡 See: - https://phpstan.org/blog/solving-phpstan-error-unsafe-usa - ge-of-new-static - ------ ----------------------------------------------------------- - - ------ ----------------------------------------------------------- - Line src/Grav/Framework/Psr7/Traits/ServerRequestDecoratorTrai - t.php (in context of class - Grav\Framework\Psr7\ServerRequest) - ------ ----------------------------------------------------------- - 51 Return type mixed of method - Grav\Framework\Psr7\ServerRequest::getAttributes() is not - covariant with return type array of method - Psr\Http\Message\ServerRequestInterface::getAttributes(). - 60 Return type mixed of method - Grav\Framework\Psr7\ServerRequest::getCookieParams() is - not covariant with return type array of method - Psr\Http\Message\ServerRequestInterface::getCookieParams( - ). - 76 Return type mixed of method - Grav\Framework\Psr7\ServerRequest::getQueryParams() is - not covariant with return type array of method - Psr\Http\Message\ServerRequestInterface::getQueryParams() - . - 84 Return type mixed of method - Grav\Framework\Psr7\ServerRequest::getServerParams() is - not covariant with return type array of method - Psr\Http\Message\ServerRequestInterface::getServerParams( - ). - 92 Return type mixed of method - Grav\Framework\Psr7\ServerRequest::getUploadedFiles() is - not covariant with return type array of method - Psr\Http\Message\ServerRequestInterface::getUploadedFiles - (). - 100 Return type mixed of method - Grav\Framework\Psr7\ServerRequest::withAttribute() is not - covariant with return type - Psr\Http\Message\ServerRequestInterface of method - Psr\Http\Message\ServerRequestInterface::withAttribute(). - 125 Return type mixed of method - Grav\Framework\Psr7\ServerRequest::withoutAttribute() is - not covariant with return type - Psr\Http\Message\ServerRequestInterface of method - Psr\Http\Message\ServerRequestInterface::withoutAttribute - (). - 136 Return type mixed of method - Grav\Framework\Psr7\ServerRequest::withCookieParams() is - not covariant with return type - Psr\Http\Message\ServerRequestInterface of method - Psr\Http\Message\ServerRequestInterface::withCookieParams - (). - 147 Return type mixed of method - Grav\Framework\Psr7\ServerRequest::withParsedBody() is - not covariant with return type - Psr\Http\Message\ServerRequestInterface of method - Psr\Http\Message\ServerRequestInterface::withParsedBody() - . - 158 Return type mixed of method - Grav\Framework\Psr7\ServerRequest::withQueryParams() is - not covariant with return type - Psr\Http\Message\ServerRequestInterface of method - Psr\Http\Message\ServerRequestInterface::withQueryParams( - ). - 169 Return type mixed of method - Grav\Framework\Psr7\ServerRequest::withUploadedFiles() is - not covariant with return type - Psr\Http\Message\ServerRequestInterface of method - Psr\Http\Message\ServerRequestInterface::withUploadedFile - s(). - ------ ----------------------------------------------------------- - - ------ ----------------------------------------------------------- - Line src/Grav/Framework/Relationships/Relationships.php - ------ ----------------------------------------------------------- - 107 Return type mixed of method - Grav\Framework\Relationships\Relationships::offsetSet() - is not covariant with tentative return type void of - method - ArrayAccess>::of - fsetSet(). - 💡 Make it covariant, or use the #[\ReturnTypeWillChange] - attribute to temporarily suppress the error. - 116 Return type mixed of method - Grav\Framework\Relationships\Relationships::offsetUnset() - is not covariant with tentative return type void of - method - ArrayAccess>::of - fsetUnset(). - 💡 Make it covariant, or use the #[\ReturnTypeWillChange] - attribute to temporarily suppress the error. - ------ ----------------------------------------------------------- - - ------ ----------------------------------------------------------- - Line src/Twig/DeferredExtension/DeferredNodeVisitorCompat.php - ------ ----------------------------------------------------------- - 30 Parameter #1 $node (Twig_NodeInterface) of method - Twig\DeferredExtension\DeferredNodeVisitorCompat::enterNo - de() is not contravariant with parameter #1 $node - (Twig\Node\Node) of method - Twig\NodeVisitor\NodeVisitorInterface::enterNode(). - 30 Parameter $node of method - Twig\DeferredExtension\DeferredNodeVisitorCompat::enterNo - de() has invalid type Twig_NodeInterface. - 🪪 class.notFound - 46 Parameter #1 $node (Twig_NodeInterface) of method - Twig\DeferredExtension\DeferredNodeVisitorCompat::leaveNo - de() is not contravariant with parameter #1 $node - (Twig\Node\Node) of method - Twig\NodeVisitor\NodeVisitorInterface::leaveNode(). - 46 Parameter $node of method - Twig\DeferredExtension\DeferredNodeVisitorCompat::leaveNo - de() has invalid type Twig_NodeInterface. - 🪪 class.notFound - ------ ----------------------------------------------------------- - - [ERROR] Found 74 errors - diff --git a/system/blueprints/config/system.yaml b/system/blueprints/config/system.yaml index eaebf0556..77611f655 100644 --- a/system/blueprints/config/system.yaml +++ b/system/blueprints/config/system.yaml @@ -1580,43 +1580,6 @@ 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 - - updates.safe_upgrade_snapshot_limit: - type: number - label: PLUGIN_ADMIN.SAFE_UPGRADE_SNAPSHOT_LIMIT - help: PLUGIN_ADMIN.SAFE_UPGRADE_SNAPSHOT_LIMIT_HELP - default: 5 - validate: - type: int - min: 0 - - updates.recovery_mode: - type: toggle - label: PLUGIN_ADMIN.RECOVERY_MODE - help: PLUGIN_ADMIN.RECOVERY_MODE_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 diff --git a/system/config/system.yaml b/system/config/system.yaml index 764f59774..09e04555c 100644 --- a/system/config/system.yaml +++ b/system/config/system.yaml @@ -203,11 +203,6 @@ 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 - safe_upgrade_snapshot_limit: 5 # Maximum number of safe-upgrade snapshots to retain (0 = unlimited) - 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 enable_proxy: true # Enable proxy server configuration diff --git a/system/languages/en.yaml b/system/languages/en.yaml index b9e9ef6b6..abab21a33 100644 --- a/system/languages/en.yaml +++ b/system/languages/en.yaml @@ -121,11 +121,6 @@ GRAV: 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. - SAFE_UPGRADE_SNAPSHOT_LIMIT: Safe-upgrade snapshots to keep - SAFE_UPGRADE_SNAPSHOT_LIMIT_HELP: Maximum number of snapshots to retain for safe upgrades (0 disables pruning). MEDIA: Media MEDIA_TYPES: Media Types FILE_EXTENSION: File Extension diff --git a/system/recovery.php b/system/recovery.php deleted file mode 100644 index 7b4189d3b..000000000 --- a/system/recovery.php +++ /dev/null @@ -1,373 +0,0 @@ - 'grav-recovery', - 'cookie_httponly' => true, - 'cookie_secure' => !empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off', - 'cookie_samesite' => 'Lax', -]); - -$manager = new RecoveryManager(); -$context = $manager->getContext() ?? []; -$errorMessage = null; -$notice = null; - -if ($_SERVER['REQUEST_METHOD'] === 'POST') { - $action = $_POST['action'] ?? ''; - if ($action === 'clear-flag') { - $manager->clear(); - $notice = 'Recovery flag cleared. Reload the page to continue.'; - } elseif ($action === 'disable-recovery') { - $configDir = GRAV_ROOT . '/user/config'; - $configFile = $configDir . '/system.yaml'; - Folder::create($configDir); - - $config = []; - if (is_file($configFile)) { - $content = file_get_contents($configFile); - if ($content !== false) { - $config = \Symfony\Component\Yaml\Yaml::parse($content) ?? []; - } - } - - if (!isset($config['updates'])) { - $config['updates'] = []; - } - $config['updates']['recovery_mode'] = false; - $yaml = \Symfony\Component\Yaml\Yaml::dump($config, 4, 2); - file_put_contents($configFile, $yaml); - - $manager->clear(); - $notice = 'Recovery mode has been disabled. Reload the page to continue.'; - } -} - -// Determine base URL for assets -$scriptName = $_SERVER['SCRIPT_NAME'] ?? '/index.php'; -$baseUrl = rtrim(dirname($scriptName), '/\\'); -if ($baseUrl === '.' || $baseUrl === '') { - $baseUrl = ''; -} - -header('Content-Type: text/html; charset=utf-8'); - -?> - - - - - Grav Recovery Mode - - - -
-
- Grav -

Recovery Mode

-

Grav has encountered an error during a recent update

-
- - -
- -
-
- - - -
- -
-
Action Failed
- -
-
- - -
- -
-
A Fatal Error Occurred
- Grav detected a fatal error after a recent upgrade and has entered recovery mode to protect your site. -
-
- -
-

Error Details

-
-
- -
- : -
- -
- - -
- View Stack Trace -
-
- - - -
- Affected Plugin -
    -
  • - Plugin - (has been automatically disabled) -
  • -
-
- -
- - -
-

What would you like to do?

-

Choose an action to resolve this issue:

- -
-
- - -
-
- - -
-
-

- Clear Recovery & Continue: Clears the recovery flag and attempts to load your site normally.
- Disable Recovery Mode: Sets updates.recovery_mode: false in your configuration so recovery mode won't trigger again. -

-
- -
- - diff --git a/system/src/Grav/Common/Grav.php b/system/src/Grav/Common/Grav.php index cc6eb6b46..e0a6bd7e2 100644 --- a/system/src/Grav/Common/Grav.php +++ b/system/src/Grav/Common/Grav.php @@ -46,7 +46,6 @@ 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; @@ -111,7 +110,6 @@ 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/Plugins.php b/system/src/Grav/Common/Plugins.php index a5fe084dd..9fdad5994 100644 --- a/system/src/Grav/Common/Plugins.php +++ b/system/src/Grav/Common/Plugins.php @@ -154,18 +154,6 @@ class Plugins extends Iterator // Disable the plugin to prevent further errors $config["plugins.{$instance->name}.enabled"] = false; - // If we're in an upgrade window, quarantine the plugin - if (isset($grav['recovery']) && method_exists($grav['recovery'], 'isUpgradeWindowActive')) { - $recovery = $grav['recovery']; - if ($recovery->isUpgradeWindowActive()) { - $recovery->disablePlugin($instance->name, [ - 'message' => 'Autoloader failed: ' . $e->getMessage(), - 'file' => $e->getFile(), - 'line' => $e->getLine(), - ]); - } - } - continue; } } diff --git a/system/src/Grav/Common/Processors/InitializeProcessor.php b/system/src/Grav/Common/Processors/InitializeProcessor.php index 8f2010387..43657957e 100644 --- a/system/src/Grav/Common/Processors/InitializeProcessor.php +++ b/system/src/Grav/Common/Processors/InitializeProcessor.php @@ -78,9 +78,6 @@ 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(); @@ -146,9 +143,6 @@ 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 deleted file mode 100644 index 47c6f0664..000000000 --- a/system/src/Grav/Common/Recovery/RecoveryManager.php +++ /dev/null @@ -1,559 +0,0 @@ -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']); - $events = null; - try { - $events = Grav::instance()['events'] ?? null; - } catch (\Throwable $e) { - $events = null; - } - if ($events && method_exists($events, 'addListener')) { - $events->addListener('onFatalException', [$this, 'onFatalException']); - } - $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(); - $this->failureCaptured = false; - } - - /** - * Shutdown handler capturing fatal errors. - * - * @return void - */ - public function handleShutdown(): void - { - if ($this->failureCaptured) { - return; - } - - $error = $this->resolveLastError(); - if (!$error) { - return; - } - - $this->processFailure($error); - } - - /** - * Handle uncaught exceptions bubbled to the top-level handler. - * - * @param \Throwable $exception - * @return void - */ - public function handleException(\Throwable $exception): void - { - if ($this->failureCaptured) { - return; - } - - $error = [ - 'type' => E_ERROR, - 'message' => $exception->getMessage(), - 'file' => $exception->getFile(), - 'line' => $exception->getLine(), - 'trace' => $exception->getTraceAsString(), - ]; - - $this->processFailure($error); - } - - /** - * @param Event $event - * @return void - */ - public function onFatalException(Event $event): void - { - $exception = $event['exception'] ?? null; - if ($exception instanceof \Throwable) { - $this->handleException($exception); - } - } - - /** - * 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"); - } - } - - /** - * @param array $error - * @return void - */ - private function processFailure(array $error): void - { - $type = (int)($error['type'] ?? 0); - if (!$this->isFatal($type)) { - return; - } - - $file = $error['file'] ?? ''; - $plugin = $this->detectPluginFromPath($file); - - $context = [ - 'created_at' => time(), - 'message' => $error['message'] ?? '', - 'file' => $file, - 'line' => $error['line'] ?? null, - 'type' => $type, - 'plugin' => $plugin, - 'trace' => $error['trace'] ?? null, - ]; - - if (!$this->shouldEnterRecovery($context)) { - return; - } - - $this->activate($context); - if ($plugin) { - $this->quarantinePlugin($plugin, $context); - } - - $this->failureCaptured = true; - } - - /** - * 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, E_USER_ERROR], 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; - } - - // Standard path: /user/plugins/plugin-name/ - if (preg_match('#/user/plugins/([^/]+)/#', $file, $matches)) { - return $matches[1] ?? null; - } - - // Symlinked plugin path: /grav-plugin-plugin-name/ (common dev setup) - if (preg_match('#/grav-plugin-([^/]+)/#', $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->userPath . '/data/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 - { - // Check if recovery mode is enabled in config - if (!$this->isEnabled()) { - return false; - } - - $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; - } - - /** - * Check if recovery mode is enabled in system config (updates.recovery_mode). - * - * @return bool - */ - public function isEnabled(): bool - { - if ($this->rootPath === rtrim(GRAV_ROOT, DIRECTORY_SEPARATOR)) { - try { - $grav = Grav::instance(); - if ($grav && isset($grav['config'])) { - $value = $grav['config']->get('system.updates.recovery_mode', false); - if (is_bool($value)) { - return $value; - } - if (is_int($value)) { - return $value === 1; - } - if (is_string($value)) { - return in_array(strtolower($value), ['true', '1', 'yes', 'on'], true); - } - } - } catch (\Throwable $e) { - // fall back to file-based read below - } - } - - $configFile = $this->userPath . '/config/system.yaml'; - if (!is_file($configFile)) { - return false; // Default disabled - } - - $content = file_get_contents($configFile); - if ($content === false) { - return false; - } - - // Simple regex-based check to avoid loading full YAML parser - if (preg_match('/^\s*updates:\s*\n(?:\s+\w+:.*\n)*?\s+recovery_mode:\s*(true|false|1|0)\s*$/m', $content, $matches)) { - return in_array(strtolower($matches[1]), ['true', '1'], true); - } - - return false; // Default disabled - } - - /** - * @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 = 3600): void - { - $ttl = max(60, $ttlSeconds); - $createdAt = time(); - - $payload = $metadata + [ - 'reason' => $reason, - 'created_at' => $createdAt, - 'expires_at' => $createdAt + $ttl, - ]; - - $path = $this->windowPath(); - Folder::create(dirname($path)); - file_put_contents($path, json_encode($payload, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n"); - $this->failureCaptured = false; - } - - /** - * @return bool - */ - public function isUpgradeWindowActive(): bool - { - return $this->resolveUpgradeWindow() !== null; - } - - /** - * @return array|null - */ - public function getUpgradeWindow(): ?array - { - return $this->resolveUpgradeWindow(); - } - - /** - * @return void - */ - public function closeUpgradeWindow(): void - { - $window = $this->windowPath(); - if (is_file($window)) { - @unlink($window); - } - } - -} diff --git a/system/src/Grav/Console/Cli/CleanCommand.php b/system/src/Grav/Console/Cli/CleanCommand.php index e5a5eab06..554ad3876 100644 --- a/system/src/Grav/Console/Cli/CleanCommand.php +++ b/system/src/Grav/Console/Cli/CleanCommand.php @@ -32,6 +32,7 @@ class CleanCommand extends Command '.gitattributes', '.github/', '.phan/', + 'bin/test-selfupgrade.sh', 'codeception.yml', 'tests/', 'user/plugins/admin/vendor/bacon/bacon-qr-code/tests', diff --git a/system/src/Grav/Console/Gpm/SelfupgradeCommand.php b/system/src/Grav/Console/Gpm/SelfupgradeCommand.php index 95b993515..1e51d48f0 100644 --- a/system/src/Grav/Console/Gpm/SelfupgradeCommand.php +++ b/system/src/Grav/Console/Gpm/SelfupgradeCommand.php @@ -22,6 +22,7 @@ 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 Symfony\Component\Yaml\Yaml; use ZipArchive; use function date; use function count; @@ -394,12 +395,9 @@ class SelfupgradeCommand extends GpmCommand return false; } - /** @var \Grav\Common\Recovery\RecoveryManager $recovery */ - $recovery = Grav::instance()['recovery']; - if ($choice === 'disable') { foreach (array_keys($incompatibleBlocking) as $slug) { - $recovery->disablePlugin($slug, ['message' => 'Disabled before upgrade — not marked as compatible with Grav ' . $incompatibleTarget]); + $this->disablePluginConfig($slug); $io->writeln(sprintf(' - Disabled %s.', $slug)); } $io->writeln('Continuing with incompatible plugins disabled.'); @@ -498,12 +496,9 @@ class SelfupgradeCommand extends GpmCommand 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]); + $this->disablePluginConfig($slug); $io->writeln(sprintf(' - Disabled plugin %s.', $slug)); } $io->writeln('Continuing with conflicted plugins disabled.'); @@ -731,4 +726,30 @@ class SelfupgradeCommand extends GpmCommand return sprintf('%dm %0.1fs', $minutes, $remaining); } + + /** + * Mark a plugin as disabled by writing enabled:false to its user config. + */ + protected function disablePluginConfig(string $slug): void + { + $slug = trim($slug); + if ($slug === '') { + return; + } + + $configPath = GRAV_ROOT . '/user/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; + file_put_contents($configPath, Yaml::dump($configuration)); + } } diff --git a/system/src/Grav/Console/Gpm/UpdateCommand.php b/system/src/Grav/Console/Gpm/UpdateCommand.php index 11e9bdd14..57056bf1e 100644 --- a/system/src/Grav/Console/Gpm/UpdateCommand.php +++ b/system/src/Grav/Console/Gpm/UpdateCommand.php @@ -255,33 +255,25 @@ 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'); - try { - // finally update - $install_command = $this->getApplication()->find('install'); + $args = new ArrayInput([ + 'command' => 'install', + 'package' => $slugs, + '-f' => $input->getOption('force'), + '-d' => $this->destination, + '-y' => true + ]); + $command_exec = $install_command->run($args, $io); - $args = new ArrayInput([ - 'command' => 'install', - 'package' => $slugs, - '-f' => $input->getOption('force'), - '-d' => $this->destination, - '-y' => true - ]); - $command_exec = $install_command->run($args, $io); + if ($command_exec != 0) { + $io->writeln('Error: An error occurred while trying to install the packages'); - if ($command_exec != 0) { - $io->writeln('Error: An error occurred while trying to install the packages'); - - return 1; - } - - return 0; - } finally { - $recovery->closeUpgradeWindow(); + return 1; } + + return 0; } /** diff --git a/system/src/Grav/Installer/updates/1.8.0_2026-04-15_0.php b/system/src/Grav/Installer/updates/1.8.0_2026-04-15_0.php new file mode 100644 index 000000000..985b8e4e0 --- /dev/null +++ b/system/src/Grav/Installer/updates/1.8.0_2026-04-15_0.php @@ -0,0 +1,14 @@ + null, + 'postflight' => + function () { + foreach (['upgrade.php', 'needs_fixing.txt'] as $stale) { + $path = GRAV_ROOT . '/' . $stale; + if (is_file($path)) { + @unlink($path); + } + } + } +]; diff --git a/tests/unit/Grav/Common/Recovery/RecoveryManagerTest.php b/tests/unit/Grav/Common/Recovery/RecoveryManagerTest.php deleted file mode 100644 index 18b490e32..000000000 --- a/tests/unit/Grav/Common/Recovery/RecoveryManagerTest.php +++ /dev/null @@ -1,283 +0,0 @@ -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 . '/user/config'); - Folder::create($this->tmpDir . '/system'); - file_put_contents( - $this->tmpDir . '/user/config/system.yaml', - "updates:\n recovery_mode: true\n" - ); - } - - protected function tearDown(): void - { - if (is_dir($this->tmpDir)) { - Folder::delete($this->tmpDir); - } - - parent::tearDown(); - } - - 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 testHandleShutdownCreatesFlagWithoutPlugin(): void - { - $manager = new class($this->tmpDir) extends RecoveryManager { - protected $error; - public function __construct(string $rootPath) - { - parent::__construct($rootPath); - $this->error = [ - 'type' => E_ERROR, - 'file' => $this->getRootPathValue() . '/system/index.php', - 'message' => 'Core failure', - 'line' => 13, - ]; - } - - protected function resolveLastError(): ?array - { - return $this->error; - } - - private function getRootPathValue(): string - { - $prop = new \ReflectionProperty(RecoveryManager::class, 'rootPath'); - $prop->setAccessible(true); - - return $prop->getValue($this); - } - }; - - $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::assertArrayHasKey('plugin', $context); - self::assertNull($context['plugin']); - self::assertSame('Core failure', $context['message']); - - $quarantine = $this->tmpDir . '/user/data/upgrades/quarantine.json'; - self::assertFileDoesNotExist($quarantine); - } - - public function testHandleExceptionCreatesFlag(): void - { - $manager = new RecoveryManager($this->tmpDir); - $manager->markUpgradeWindow('core-upgrade', ['scope' => 'core']); - - $manager->handleException(new \RuntimeException('Unhandled failure')); - - $flag = $this->tmpDir . '/user/data/recovery.flag'; - self::assertFileExists($flag); - $context = json_decode(file_get_contents($flag), true); - self::assertSame('Unhandled failure', $context['message']); - self::assertArrayHasKey('plugin', $context); - self::assertNull($context['plugin']); - - $manager->clear(); - } - - public function testOnFatalExceptionDispatchesToHandler(): void - { - $manager = new RecoveryManager($this->tmpDir); - $manager->markUpgradeWindow('core-upgrade', ['scope' => 'core']); - - $manager->onFatalException(new Event(['exception' => new \RuntimeException('Event failure')])); - - $flag = $this->tmpDir . '/user/data/recovery.flag'; - self::assertFileExists($flag); - $context = json_decode(file_get_contents($flag), true); - self::assertSame('Event failure', $context['message']); - - $manager->clear(); - } - - 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']); - } - - public function testIsEnabledDefaultsFalseWhenNotConfigured(): void - { - @unlink($this->tmpDir . '/user/config/system.yaml'); - - $manager = new RecoveryManager($this->tmpDir); - self::assertFalse($manager->isEnabled()); - } - - public function testIsEnabledReadsConfigFromSystemYaml(): void - { - file_put_contents( - $this->tmpDir . '/user/config/system.yaml', - "updates:\n recovery_mode: false\n" - ); - - $manager = new RecoveryManager($this->tmpDir); - self::assertFalse($manager->isEnabled()); - - file_put_contents( - $this->tmpDir . '/user/config/system.yaml', - "updates:\n recovery_mode: true\n" - ); - - self::assertTrue($manager->isEnabled()); - } - - public function testMarkUpgradeWindowUsesOneHourDefaultTtl(): void - { - $manager = new RecoveryManager($this->tmpDir); - $manager->markUpgradeWindow('core-upgrade', ['scope' => 'core']); - - $window = $manager->getUpgradeWindow(); - self::assertIsArray($window); - self::assertSame('core', $window['scope']); - self::assertSame('core-upgrade', $window['reason']); - self::assertSame(3600, $window['expires_at'] - $window['created_at']); - } - - public function testHandleExceptionDoesNothingWhenRecoveryDisabled(): void - { - file_put_contents( - $this->tmpDir . '/user/config/system.yaml', - "updates:\n recovery_mode: false\n" - ); - - $manager = new RecoveryManager($this->tmpDir); - $manager->markUpgradeWindow('core-upgrade', ['scope' => 'core']); - $manager->handleException(new \RuntimeException('Should not activate recovery')); - - self::assertFileDoesNotExist($this->tmpDir . '/user/data/recovery.flag'); - } -} diff --git a/tests/unit/Grav/Console/Gpm/SelfupgradeCommandTest.php b/tests/unit/Grav/Console/Gpm/SelfupgradeCommandTest.php index 5c4e6b2eb..69eab0a32 100644 --- a/tests/unit/Grav/Console/Gpm/SelfupgradeCommandTest.php +++ b/tests/unit/Grav/Console/Gpm/SelfupgradeCommandTest.php @@ -1,6 +1,5 @@ disabled[] = $slug; - } - }; - $grav['recovery'] = $stub; - $command = new TestSelfupgradeCommand(); [$style] = $this->injectIo($command, ['disable']); @@ -102,7 +90,7 @@ class SelfupgradeCommandTest extends \PHPUnit\Framework\TestCase ]); self::assertTrue($result); - self::assertSame(['foo'], $stub->disabled); + self::assertSame(['foo'], $command->disabled); $output = implode("\n", $style->messages); self::assertStringContainsString('Continuing with conflicted plugins disabled.', $output); } @@ -159,17 +147,6 @@ class SelfupgradeCommandTest extends \PHPUnit\Framework\TestCase public function testHandlePreflightReportDisablesIncompatibleWhenRequested(): 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']); @@ -195,7 +172,7 @@ class SelfupgradeCommandTest extends \PHPUnit\Framework\TestCase ]); self::assertTrue($result); - self::assertSame(['old-plugin'], $stub->disabled); + self::assertSame(['old-plugin'], $command->disabled); } public function testHandlePreflightReportContinuesWithIncompatibleOverride(): void @@ -265,10 +242,18 @@ class SelfupgradeCommandTest extends \PHPUnit\Framework\TestCase class TestSelfupgradeCommand extends SelfupgradeCommand { + /** @var array */ + public $disabled = []; + public function runHandle(array $report): bool { return $this->handlePreflightReport($report); } + + protected function disablePluginConfig(string $slug): void + { + $this->disabled[] = $slug; + } } class SelfUpgradeMemoryStyle extends SymfonyStyle diff --git a/upgrade.php b/upgrade.php deleted file mode 100644 index 01686e3c8..000000000 --- a/upgrade.php +++ /dev/null @@ -1,387 +0,0 @@ -#!/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 ""; -}