mirror of
https://github.com/getgrav/grav.git
synced 2026-07-12 23:42:34 +02:00
remove safe-upgrade/snapshots/recover
Signed-off-by: Andy Miller <rhuk@mac.com>
This commit is contained in:
204
bin/test-selfupgrade.sh
Executable file
204
bin/test-selfupgrade.sh
Executable 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
|
||||
51
index.php
51
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;
|
||||
}
|
||||
|
||||
505
needs_fixing.txt
505
needs_fixing.txt
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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">✓</span>
|
||||
<div class="alert-content"><?php echo $notice; ?></div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ($errorMessage): ?>
|
||||
<div class="alert alert-error">
|
||||
<span class="alert-icon">⚠</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">⚠</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 & 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 & 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>
|
||||
@@ -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,
|
||||
];
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
14
system/src/Grav/Installer/updates/1.8.0_2026-04-15_0.php
Normal file
14
system/src/Grav/Installer/updates/1.8.0_2026-04-15_0.php
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
];
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
387
upgrade.php
387
upgrade.php
@@ -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>";
|
||||
}
|
||||
Reference in New Issue
Block a user