remove safe-upgrade/snapshots/recover

Signed-off-by: Andy Miller <rhuk@mac.com>
This commit is contained in:
Andy Miller
2026-04-15 14:03:10 +01:00
parent 1f8f2e15ff
commit f47feee98d
18 changed files with 273 additions and 2281 deletions

204
bin/test-selfupgrade.sh Executable file
View File

@@ -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" <<JSON
{
"version": "$TO_VERSION",
"date": "$(date -u +%Y-%m-%dT%H:%M:%SZ)",
"assets": {
"grav-update": {
"name": "$zip_name",
"type": "binary/octet-stream",
"size": $zip_size,
"download": "http://localhost:$PORT/$zip_name"
}
},
"url": "http://localhost:$PORT/$zip_name",
"min_php": "8.3.0",
"changelog": {
"$TO_VERSION": {
"date": "$(date +%m/%d/%Y)",
"content": "Local test build"
}
}
}
JSON
echo "wrote $CDN_DIR/grav.json and $CDN_DIR/$zip_name"
}
patch_core() {
echo "=== patching $core (backup at $core.bak) ==="
cp "$core" "$core.bak"
sed -i.tmp "s#https://getgrav.org/downloads/grav.json#http://localhost:$PORT/grav.json#g" "$core"
rm -f "$core.tmp"
grep "repository" "$core" | head -1
}
restore_core() {
[[ -f "$core.bak" ]] && mv "$core.bak" "$core" && echo "restored $core"
}
plant_sentinels() {
echo "=== planting upgrade.php / needs_fixing.txt sentinels in target ==="
touch "$TARGET/upgrade.php" "$TARGET/needs_fixing.txt"
ls -l "$TARGET/upgrade.php" "$TARGET/needs_fixing.txt"
}
start_server() {
echo "=== starting php -S localhost:$PORT (log at /tmp/grav-cdn.log) ==="
pushd "$CDN_DIR" >/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 <<EOF
=== admin UI test mode ===
Fake CDN running at: http://localhost:$PORT/grav.json
Zip being served: $(basename "$ZIP_PATH")
Target install: $TARGET
GravCore.php patched: yes (backup at $core.bak)
Open your admin (e.g. http://localhost/grav-admin-180b29/admin) and trigger
the Grav core update. The admin should see v$TO_VERSION available.
If the admin doesn't see the update, hard-refresh or visit:
http://localhost/grav-admin-180b29/admin/update (force GPM refresh)
After the upgrade completes in the browser, come back here and press Ctrl+C
to tear down (server stops, GravCore.php restored).
Tip: snapshot first so you can re-run —
bin/test-selfupgrade.sh snapshot
bin/test-selfupgrade.sh admin
# ... test ...
# Ctrl+C
bin/test-selfupgrade.sh restore
EOF
trap 'echo; echo "tearing down..."; kill $server_pid 2>/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

View File

@@ -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;
}

View File

@@ -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<TKey o
f (int|string),T>::$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<string,T of
Grav\Framework\Contracts\Object\IdentifierInterface>::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<string,Grav\Framework\Contracts\Relationships
\RelationshipInterface<T of Grav\Framework\Contracts\Obje
ct\IdentifierInterface, P of
Grav\Framework\Contracts\Object\IdentifierInterface>>::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<string,Grav\Framework\Contracts\Relationships
\RelationshipInterface<T of Grav\Framework\Contracts\Obje
ct\IdentifierInterface, P of
Grav\Framework\Contracts\Object\IdentifierInterface>>::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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -1,373 +0,0 @@
<?php
use Grav\Common\Filesystem\Folder;
use Grav\Common\Recovery\RecoveryManager;
if (!\defined('GRAV_ROOT')) {
\define('GRAV_ROOT', dirname(__DIR__));
}
session_start([
'name' => '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. <a href="' . htmlspecialchars($_SERVER['REQUEST_URI'], ENT_QUOTES, 'UTF-8') . '">Reload the page</a> 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. <a href="' . htmlspecialchars($_SERVER['REQUEST_URI'], ENT_QUOTES, 'UTF-8') . '">Reload the page</a> 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');
?><!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Grav Recovery Mode</title>
<style>
* { box-sizing: border-box; }
body {
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
margin: 0;
padding: 20px;
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
min-height: 100vh;
color: #e8e8e8;
line-height: 1.6;
}
.container {
max-width: 800px;
margin: 0 auto;
}
.header {
text-align: center;
padding: 30px 0;
}
.header img {
width: 80px;
height: auto;
margin-bottom: 16px;
}
.header h1 {
font-size: 1.8rem;
margin: 0 0 8px 0;
color: #fff;
font-weight: 600;
}
.header .subtitle {
color: #a0a0a0;
font-size: 1rem;
margin: 0;
}
.alert {
padding: 16px 20px;
border-radius: 8px;
margin-bottom: 20px;
display: flex;
align-items: flex-start;
gap: 12px;
}
.alert-error {
background: rgba(239, 68, 68, 0.15);
border: 1px solid rgba(239, 68, 68, 0.3);
color: #fca5a5;
}
.alert-warning {
background: rgba(245, 158, 11, 0.15);
border: 1px solid rgba(245, 158, 11, 0.3);
color: #fcd34d;
}
.alert-success {
background: rgba(34, 197, 94, 0.15);
border: 1px solid rgba(34, 197, 94, 0.3);
color: #86efac;
}
.alert-success a { color: #4ade80; }
.alert-icon {
font-size: 1.5rem;
flex-shrink: 0;
line-height: 1;
}
.alert-content { flex: 1; }
.alert-title {
font-weight: 600;
margin-bottom: 4px;
}
.card {
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 12px;
padding: 24px;
margin-bottom: 20px;
}
.card h2 {
font-size: 1.1rem;
margin: 0 0 16px 0;
color: #fff;
font-weight: 600;
}
.card h3 {
font-size: 1rem;
margin: 0 0 12px 0;
color: #fff;
font-weight: 600;
}
.error-summary {
background: rgba(0, 0, 0, 0.2);
border-radius: 8px;
padding: 16px;
font-family: 'SF Mono', Monaco, 'Cascadia Code', monospace;
font-size: 0.9rem;
overflow-x: auto;
}
.error-summary .error-message {
color: #f87171;
word-break: break-word;
}
.error-summary .error-location {
color: #94a3b8;
margin-top: 8px;
font-size: 0.85rem;
}
.btn-group {
display: flex;
gap: 12px;
flex-wrap: wrap;
margin-top: 16px;
}
button, .btn {
padding: 12px 24px;
border: none;
border-radius: 8px;
font-size: 0.95rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
display: inline-flex;
align-items: center;
gap: 8px;
text-decoration: none;
}
.btn-primary {
background: #3b82f6;
color: #fff;
}
.btn-primary:hover {
background: #2563eb;
}
.btn-secondary {
background: rgba(255, 255, 255, 0.1);
color: #e8e8e8;
border: 1px solid rgba(255, 255, 255, 0.2);
}
.btn-secondary:hover {
background: rgba(255, 255, 255, 0.15);
}
.btn-danger {
background: rgba(239, 68, 68, 0.2);
color: #fca5a5;
border: 1px solid rgba(239, 68, 68, 0.3);
}
.btn-danger:hover {
background: rgba(239, 68, 68, 0.3);
}
details {
margin-top: 16px;
}
summary {
cursor: pointer;
color: #94a3b8;
font-size: 0.9rem;
padding: 8px 0;
user-select: none;
}
summary:hover {
color: #cbd5e1;
}
.stack-trace {
background: rgba(0, 0, 0, 0.3);
border-radius: 8px;
padding: 16px;
margin-top: 12px;
font-family: 'SF Mono', Monaco, 'Cascadia Code', monospace;
font-size: 0.8rem;
overflow-x: auto;
white-space: pre-wrap;
word-break: break-all;
color: #94a3b8;
max-height: 400px;
overflow-y: auto;
}
.info-list {
list-style: none;
padding: 0;
margin: 0;
}
.info-list li {
padding: 8px 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
display: flex;
gap: 12px;
}
.info-list li:last-child {
border-bottom: none;
}
.info-list .label {
color: #94a3b8;
min-width: 120px;
flex-shrink: 0;
}
.info-list .value {
color: #e8e8e8;
word-break: break-word;
}
.help-text {
color: #64748b;
font-size: 0.85rem;
margin-top: 8px;
}
code {
background: rgba(255, 255, 255, 0.1);
padding: 2px 6px;
border-radius: 4px;
font-family: 'SF Mono', Monaco, 'Cascadia Code', monospace;
font-size: 0.85rem;
}
@media (max-width: 600px) {
body { padding: 12px; }
.card { padding: 16px; }
.btn-group { flex-direction: column; }
button, .btn { width: 100%; justify-content: center; }
.info-list li { flex-direction: column; gap: 4px; }
.info-list .label { min-width: auto; }
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<img src="<?php echo htmlspecialchars($baseUrl, ENT_QUOTES, 'UTF-8'); ?>/system/assets/grav.png" alt="Grav" onerror="this.style.display='none'">
<h1>Recovery Mode</h1>
<p class="subtitle">Grav has encountered an error during a recent update</p>
</div>
<?php if ($notice): ?>
<div class="alert alert-success">
<span class="alert-icon">&#10003;</span>
<div class="alert-content"><?php echo $notice; ?></div>
</div>
<?php endif; ?>
<?php if ($errorMessage): ?>
<div class="alert alert-error">
<span class="alert-icon">&#9888;</span>
<div class="alert-content">
<div class="alert-title">Action Failed</div>
<?php echo htmlspecialchars($errorMessage, ENT_QUOTES, 'UTF-8'); ?>
</div>
</div>
<?php endif; ?>
<div class="alert alert-warning">
<span class="alert-icon">&#9888;</span>
<div class="alert-content">
<div class="alert-title">A Fatal Error Occurred</div>
Grav detected a fatal error after a recent upgrade and has entered recovery mode to protect your site.
</div>
</div>
<div class="card">
<h2>Error Details</h2>
<div class="error-summary">
<div class="error-message"><?php echo htmlspecialchars($context['message'] ?? 'Unknown error', ENT_QUOTES, 'UTF-8'); ?></div>
<?php if (!empty($context['file'])): ?>
<div class="error-location">
<?php echo htmlspecialchars($context['file'], ENT_QUOTES, 'UTF-8'); ?><?php if (!empty($context['line'])): ?>:<?php echo htmlspecialchars((string)$context['line'], ENT_QUOTES, 'UTF-8'); ?><?php endif; ?>
</div>
<?php endif; ?>
</div>
<?php if (!empty($context['trace'])): ?>
<details>
<summary>View Stack Trace</summary>
<div class="stack-trace"><?php echo htmlspecialchars($context['trace'], ENT_QUOTES, 'UTF-8'); ?></div>
</details>
<?php endif; ?>
<?php if (!empty($context['plugin'])): ?>
<details open>
<summary>Affected Plugin</summary>
<ul class="info-list" style="margin-top: 12px;">
<li>
<span class="label">Plugin</span>
<span class="value"><strong><?php echo htmlspecialchars($context['plugin'], ENT_QUOTES, 'UTF-8'); ?></strong> (has been automatically disabled)</span>
</li>
</ul>
</details>
<?php endif; ?>
</div>
<div class="card">
<h2>What would you like to do?</h2>
<p style="margin-top: 0; color: #94a3b8;">Choose an action to resolve this issue:</p>
<div class="btn-group">
<form method="post" style="display: contents;">
<input type="hidden" name="action" value="clear-flag">
<button type="submit" class="btn btn-primary">Clear Recovery &amp; Continue</button>
</form>
<form method="post" style="display: contents;">
<input type="hidden" name="action" value="disable-recovery">
<button type="submit" class="btn btn-secondary" title="Prevents recovery mode from activating in the future">Disable Recovery Mode</button>
</form>
</div>
<p class="help-text">
<strong>Clear Recovery &amp; Continue:</strong> Clears the recovery flag and attempts to load your site normally.<br>
<strong>Disable Recovery Mode:</strong> Sets <code>updates.recovery_mode: false</code> in your configuration so recovery mode won't trigger again.
</p>
</div>
</div>
</body>
</html>

View File

@@ -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,
];

View File

@@ -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;
}
}

View File

@@ -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);

View File

@@ -1,559 +0,0 @@
<?php
/**
* @package Grav\Common\Recovery
*
* @copyright Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.
* @license MIT License; see LICENSE file for details.
*/
namespace Grav\Common\Recovery;
use Grav\Common\Filesystem\Folder;
use Grav\Common\Grav;
use Grav\Common\Yaml;
use RocketTheme\Toolbox\Event\Event;
use function bin2hex;
use function dirname;
use function file_get_contents;
use function file_put_contents;
use function in_array;
use function is_array;
use function is_file;
use function json_decode;
use function json_encode;
use function max;
use function md5;
use function preg_match;
use function random_bytes;
use function uniqid;
use function time;
use function trim;
use function unlink;
use function strtolower;
use const E_COMPILE_ERROR;
use const E_CORE_ERROR;
use const E_ERROR;
use const E_PARSE;
use const E_USER_ERROR;
use const GRAV_ROOT;
use const JSON_PRETTY_PRINT;
use const JSON_UNESCAPED_SLASHES;
/**
* Handles recovery flag lifecycle and plugin quarantine during fatal errors.
*/
class RecoveryManager
{
/** @var bool */
private $registered = false;
/** @var string */
private $rootPath;
/** @var string */
private $userPath;
/** @var bool */
private $failureCaptured = false;
/**
* @param mixed $context Container or root path.
*/
public function __construct($context = null)
{
if ($context instanceof \Grav\Common\Grav) {
$root = GRAV_ROOT;
} elseif (is_string($context) && $context !== '') {
$root = $context;
} else {
$root = GRAV_ROOT;
}
$this->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);
}
}
}

View File

@@ -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',

View File

@@ -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));
}
}

View File

@@ -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('<red>Error:</red> An error occurred while trying to install the packages');
if ($command_exec != 0) {
$io->writeln('<red>Error:</red> An error occurred while trying to install the packages');
return 1;
}
return 0;
} finally {
$recovery->closeUpgradeWindow();
return 1;
}
return 0;
}
/**

View File

@@ -0,0 +1,14 @@
<?php
return [
'preflight' => null,
'postflight' =>
function () {
foreach (['upgrade.php', 'needs_fixing.txt'] as $stale) {
$path = GRAV_ROOT . '/' . $stale;
if (is_file($path)) {
@unlink($path);
}
}
}
];

View File

@@ -1,283 +0,0 @@
<?php
use Grav\Common\Filesystem\Folder;
use Grav\Common\Recovery\RecoveryManager;
use RocketTheme\Toolbox\Event\Event;
class RecoveryManagerTest extends \PHPUnit\Framework\TestCase
{
/** @var string */
private $tmpDir;
protected function setUp(): void
{
parent::setUp();
$this->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', '<?php // plugin');
$manager = new class($this->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');
}
}

View File

@@ -1,6 +1,5 @@
<?php
use Codeception\Util\Fixtures;
use Grav\Console\Gpm\SelfupgradeCommand;
use Symfony\Component\Console\Input\ArrayInput;
use Symfony\Component\Console\Input\InputInterface;
@@ -80,17 +79,6 @@ class SelfupgradeCommandTest extends \PHPUnit\Framework\TestCase
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']);
@@ -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<int, string> */
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

View File

@@ -1,387 +0,0 @@
#!/usr/bin/env php
<?php
/**
* Grav Standalone Upgrade Script
*
* Self-contained PHP file to upgrade any Grav 1.7.x installation to 1.8.0.
* No Grav dependencies required — drop into GRAV_ROOT and run via CLI or browser.
*
* Usage (CLI): php upgrade.php
* Usage (Web): Navigate to http://yoursite.com/upgrade.php
*
* @package Grav\Upgrade
* @copyright Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.
* @license MIT License; see LICENSE file for details.
*/
// Configuration
$gravDownloadUrl = 'https://getgrav.org/download/core/grav-update/latest';
$minPhpVersion = '8.3.0';
// Detect environment
$isCli = PHP_SAPI === 'cli' || PHP_SAPI === 'phpdbg';
$gravRoot = $isCli ? getcwd() : dirname(__FILE__);
// Output helpers
function out($msg, $isCli) {
if ($isCli) {
echo $msg . "\n";
} else {
echo htmlspecialchars($msg) . "<br>\n";
if (ob_get_level()) { ob_flush(); }
flush();
}
}
function outError($msg, $isCli) {
if ($isCli) {
fwrite(STDERR, "ERROR: " . $msg . "\n");
} else {
echo "<strong style='color:red'>ERROR: " . htmlspecialchars($msg) . "</strong><br>\n";
if (ob_get_level()) { ob_flush(); }
flush();
}
}
function outSuccess($msg, $isCli) {
if ($isCli) {
echo "\033[32m" . $msg . "\033[0m\n";
} else {
echo "<strong style='color:green'>" . htmlspecialchars($msg) . "</strong><br>\n";
if (ob_get_level()) { ob_flush(); }
flush();
}
}
// Start output
if (!$isCli) {
echo '<!DOCTYPE html><html><head><title>Grav Upgrade</title>';
echo '<style>body{font-family:monospace;padding:20px;max-width:800px;margin:0 auto;}</style>';
echo '</head><body>';
echo '<h1>Grav Standalone Upgrade</h1><pre>';
}
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 "</pre>";
echo "<p>Ready to upgrade Grav from <strong>{$currentVersion}</strong> to latest 1.8.x</p>";
echo "<p><a href='?confirm=yes' style='background:#4CAF50;color:white;padding:10px 20px;text-decoration:none;border-radius:4px;'>Confirm Upgrade</a></p>";
echo "</body></html>";
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 "</pre></body></html>";
}