mirror of
https://github.com/getgrav/grav.git
synced 2025-12-06 07:49:57 +01:00
Compare commits
107 Commits
1.8.0-beta
...
develop
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
58002f4903 | ||
|
|
19a9fafe37 | ||
|
|
8ad4c006a2 | ||
|
|
52fd9a6e7b | ||
|
|
45e6ed941f | ||
|
|
2c2b2fc2e4 | ||
|
|
b0301beee3 | ||
|
|
ce6a1b3bcb | ||
|
|
d42adcd593 | ||
|
|
bcd93c321b | ||
|
|
8bd711f6b1 | ||
|
|
fa707eb7eb | ||
|
|
18d285ec36 | ||
|
|
04c6bdf287 | ||
|
|
3ddc548d51 | ||
|
|
48343d7714 | ||
|
|
9c27496cc1 | ||
|
|
fd51d33d3f | ||
|
|
7304612d3a | ||
|
|
e6025670ea | ||
|
|
92b3d5b1f8 | ||
|
|
2ee3ff074c | ||
|
|
4fab5f99bb | ||
|
|
1d5d1357b8 | ||
|
|
eb649c35a3 | ||
|
|
9b75d96bbf | ||
|
|
41d771da7c | ||
|
|
7e3fccce54 | ||
|
|
48c6d2eb93 | ||
|
|
e86820d438 | ||
|
|
4c324ef4b8 | ||
|
|
a07a1b332a | ||
|
|
c8204f442a | ||
|
|
ba479007ac | ||
|
|
38494b2c1c | ||
|
|
ba3e0686a6 | ||
|
|
f0ed8e0ea0 | ||
|
|
02fbe27efd | ||
|
|
cfa18a8fd1 | ||
|
|
89f44631bd | ||
|
|
2f2f1e518d | ||
|
|
682109bf3b | ||
|
|
f420db0eea | ||
|
|
c6764f9815 | ||
|
|
a2f944e6c7 | ||
|
|
68ff6ae342 | ||
|
|
505fc208bb | ||
|
|
cd50bd6d63 | ||
|
|
0278eb17cb | ||
|
|
14fba5170e | ||
|
|
5af47f0634 | ||
|
|
6263a34c09 | ||
|
|
5a6c00f68c | ||
|
|
52a8854f6b | ||
|
|
945cd6aa8f | ||
|
|
070b53180d | ||
|
|
042b845b8d | ||
|
|
84b3a9e68e | ||
|
|
70a2e668ec | ||
|
|
e04391484e | ||
|
|
6d72867bef | ||
|
|
7e8c0e3f6f | ||
|
|
4adc7672ac | ||
|
|
dd89d7e25b | ||
|
|
ce817c1bd1 | ||
|
|
918bfc6f2b | ||
|
|
0afbce518d | ||
|
|
ff0de91bab | ||
|
|
2a18c07a64 | ||
|
|
16b0b562fb | ||
|
|
ef48476c88 | ||
|
|
f73df193ad | ||
|
|
8c1e4252f2 | ||
|
|
de260489ee | ||
|
|
1c5c2ac08d | ||
|
|
841259ca2a | ||
|
|
20809f3fea | ||
|
|
1b75df73ef | ||
|
|
a620556e4c | ||
|
|
975a2a8dd3 | ||
|
|
915991ac6a | ||
|
|
3fad2a8173 | ||
|
|
06471eb8cf | ||
|
|
71eb774a39 | ||
|
|
65689101ab | ||
|
|
23d92e6a41 | ||
|
|
1982717272 | ||
|
|
e82a0ce8bd | ||
|
|
38840ff080 | ||
|
|
3cf616e609 | ||
|
|
ea5ba5dda3 | ||
|
|
f437235eb9 | ||
|
|
80b8389432 | ||
|
|
5815c8cae5 | ||
|
|
0ac77271cc | ||
|
|
269bf78084 | ||
|
|
cd5f3842ed | ||
|
|
997bdfff07 | ||
|
|
da0fbf9dd6 | ||
|
|
6a4ab16529 | ||
|
|
c9640d7258 | ||
|
|
7325eb2cfe | ||
|
|
f30cd26956 | ||
|
|
17706d5647 | ||
|
|
a0b64b6d88 | ||
|
|
4650bd073e | ||
|
|
4cab0a7ba1 |
12
.github/dependabot.yaml
vendored
Normal file
12
.github/dependabot.yaml
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
# https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file
|
||||
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "monthly"
|
||||
groups:
|
||||
github-actions:
|
||||
patterns:
|
||||
- "*"
|
||||
6
.github/workflows/build.yaml
vendored
6
.github/workflows/build.yaml
vendored
@@ -15,7 +15,9 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ github.ref }}
|
||||
|
||||
- name: Extract Tag
|
||||
run: echo "PACKAGE_VERSION=${{ github.ref }}" >> $GITHUB_ENV
|
||||
@@ -65,7 +67,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
if: always()
|
||||
steps:
|
||||
- uses: technote-space/workflow-conclusion-action@v2
|
||||
- uses: technote-space/workflow-conclusion-action@v3
|
||||
- uses: 8398a7/action-slack@v3
|
||||
with:
|
||||
status: failure
|
||||
|
||||
2
.github/workflows/tests.yaml
vendored
2
.github/workflows/tests.yaml
vendored
@@ -19,7 +19,7 @@ jobs:
|
||||
runs-on: ${{ matrix.os }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Setup PHP ${{ matrix.php }}
|
||||
uses: shivammathur/setup-php@v2
|
||||
|
||||
2
.github/workflows/trigger-skeletons.yml
vendored
2
.github/workflows/trigger-skeletons.yml
vendored
@@ -22,7 +22,7 @@ jobs:
|
||||
WORKFLOW: "build-skeleton.yml"
|
||||
AUTH: ":${{secrets.GLOBAL_TOKEN}}"
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/checkout@v6
|
||||
- name: Make it rain ☔️
|
||||
run: |
|
||||
SKELETONS=`curl -s "${{secrets.SKELETONS_JSON_LIST}}"`
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -50,3 +50,6 @@ system/templates/testing/*
|
||||
/user/config/versions.yaml
|
||||
/system/recovery.window
|
||||
tmp/*
|
||||
#needs_fixing.txt
|
||||
/AGENTS.md
|
||||
/.claude
|
||||
|
||||
96
.travis.yml
96
.travis.yml
@@ -1,96 +0,0 @@
|
||||
language: php
|
||||
php:
|
||||
- '7.1'
|
||||
- '7.2'
|
||||
- '7.3'
|
||||
- '7.4'
|
||||
branches:
|
||||
only:
|
||||
- build_test
|
||||
notifications:
|
||||
email:
|
||||
on_success: never
|
||||
on_failure: always
|
||||
slack:
|
||||
secure: dowksPsxxCxGKT6nis5hUgkp6+ZDAhoqzQHF9rJnx4hx0iEygPhVBs7pKl9yL2jubYJoLs+EXwE7z1dYgDAEJh4BnfrCokCMLpFGcxVxQC/HeAUdSQ2/RtdBYR5PRT75ScaFpqM/SfXXZVtnwVXAw9Z+JC6BjQ9vmn23m51Jw4k=
|
||||
env:
|
||||
global:
|
||||
# Colors!
|
||||
- TEXTRESET=$(tput sgr0) # reset the foreground colour
|
||||
- RED=$(tput setaf 1)
|
||||
- GREEN=$(tput setaf 2)
|
||||
- YELLOW=$(tput setaf 3)
|
||||
- BLUE=$(tput setaf 4)
|
||||
- BOLD=$(tput bold)
|
||||
# User
|
||||
- GH_USER="getgrav"
|
||||
# Paths
|
||||
- RT_DEVTOOLS=$HOME/devtools
|
||||
- GOPATH="$HOME/go"
|
||||
- PATH="$GOPATH/bin:$PATH"
|
||||
# GH_TOKEN [API Key]
|
||||
- secure: "NR9pV7YteY9OoPmjDTQG0fDfocVu+tCeiDH1F2GFhXCu71UOIvqWXpOxp0RHkG5GIXdCFHx59yu+ZO275lbaHkbF8+4lVSVrV4RcGn+pIncvxr6iZCVW05dbAxV3H8alK+xYJRGmbyfQl5wIM49WvmuGHZjcmIloS4t/omQ3N+I="
|
||||
# BB_TOKEN value => "user:pass@"
|
||||
- secure: "einUtSEkUWy2IrqLXyVjwUU+mwaaoiOXRRVdLBpA3Zye6bZx8cm5h/5AplkPWhM/NmCJoW/MwNZHHkFhlr3mDRov5iOxVmTTYfnXB+I5lxYTSgduOLLErS7mU8hfADpVDU8bHNU44fNGD3UEiG1PD4qQBX4DMlqIFmR20mjs81k="
|
||||
# GH_API_USER [for curl]
|
||||
- secure: "AQGcX1B2NrI8ajflY4AimZDNcK2kBA3F6mbtEFQ78NkDoWhMipsQHayWXiSTzRc0YJKvQl2Y16MTwQF4VHzjTAiiZFATgA8J88vQUjIPabi/kKjqSmcLFoaAOAxStQbW6e0z2GiQ6KBMcNF1y5iUuI63xVrBvtKrYX/w5y+ako8="
|
||||
|
||||
before_install:
|
||||
- export TZ=Pacific/Honolulu
|
||||
- echo $TRAVIS_PHP_VERSION
|
||||
- echo $TRAVIS_BRANCH
|
||||
- echo $TRAVIS_PULL_REQUEST
|
||||
- composer self-update
|
||||
- if [ $TRAVIS_BRANCH == 'develop' ] || [ $TRAVIS_PULL_REQUEST != 'false' ]; then
|
||||
composer install --dev --prefer-dist;
|
||||
fi
|
||||
- |
|
||||
if [ $TRAVIS_BRANCH != 'develop' ] && [ $TRAVIS_PHP_VERSION == "7.1" ] && [ $TRAVIS_PULL_REQUEST == "false" ]; then
|
||||
export TRAVIS_TAG=$(curl -H "Authorization: token ${GH_TOKEN}" --fail -s https://api.github.com/repos/getgrav/grav/releases/latest | grep tag_name | head -n 1 | cut -d '"' -f 4);
|
||||
eval "$(curl -sL https://raw.githubusercontent.com/travis-ci/gimme/master/gimme | GIMME_GO_VERSION=1.13 bash)";
|
||||
go get github.com/github-release/github-release;
|
||||
git clone --quiet --depth=50 --branch=master https://${BB_TOKEN}bitbucket.org/rockettheme/grav-devtools.git $RT_DEVTOOLS &>/dev/null;
|
||||
if [ ! -z "$TRAVIS_TAG" ]; then
|
||||
cd ${RT_DEVTOOLS};
|
||||
./build-grav.sh skeletons.txt;
|
||||
fi;
|
||||
fi
|
||||
before_script:
|
||||
- phpenv config-rm xdebug.ini
|
||||
script:
|
||||
- if [ $TRAVIS_BRANCH == 'develop' ] || [ $TRAVIS_PULL_REQUEST != 'false' ]; then
|
||||
vendor/bin/codecept run;
|
||||
fi
|
||||
- echo "Latest Release Tag - ${TRAVIS_TAG}"
|
||||
- if [ ! -z "$TRAVIS_TAG" ] && [ $TRAVIS_BRANCH != 'develop' ] && [ $TRAVIS_PHP_VERSION == "7.1" ] && [ $TRAVIS_PULL_REQUEST == "false" ]; then
|
||||
FILES="$RT_DEVTOOLS/grav-dist/*.zip";
|
||||
for file in ${FILES[@]}; do
|
||||
NAME=${file##*/};
|
||||
if [[ "$NAME" == *"-rc"* ]]; then
|
||||
REPO="$(echo ${NAME} | rev | cut -f 3- -d "-" | rev)";
|
||||
else
|
||||
REPO="$(echo ${NAME} | rev | cut -f 2- -d "-" | rev)";
|
||||
fi;
|
||||
if [[ $REPO == 'grav' || $REPO == 'grav-admin' || $REPO == 'grav-update' ]]; then
|
||||
REPO="grav";
|
||||
fi;
|
||||
API="$(curl --fail --user "${GH_API_USER}" -s https://api.github.com/repos/${GH_USER}/${REPO}/releases/latest)";
|
||||
ASSETS="$(echo "${API}" | node gh-assets.js)";
|
||||
TAG="$(echo "${API}" | grep tag_name | head -n 1 | cut -d '"' -f 4)";
|
||||
if [ $REPO == "grav" ]; then
|
||||
TAG="$TRAVIS_TAG";
|
||||
fi;
|
||||
if [ ! -z "$ASSETS" ]; then
|
||||
for asset in ${ASSETS[@]}; do
|
||||
asset_id=$(echo ${asset} | cut -d ':' -f 1);
|
||||
asset_name=$(echo ${asset} | cut -d ':' -f 2);
|
||||
if [ "${NAME}" == "${asset_name}" ]; then
|
||||
echo -e "\nAsset ${BOLD}${BLUE}${NAME}${TEXTRESET} already exists in ${YELLOW}${REPO}${TEXTRESET}@${BOLD}${YELLOW}${TAG}${TEXTRESET}... deleting id ${BOLD}${RED}${asset_id}${TEXTRESET}...";
|
||||
curl -X DELETE --fail --user "${GH_API_USER}" "https://api.github.com/repos/${GH_USER}/${REPO}/releases/assets/${asset_id}";
|
||||
fi;
|
||||
done;
|
||||
fi;
|
||||
echo "Uploading package ${BOLD}${BLUE}${NAME}${TEXTRESET} to ${YELLOW}${REPO}${TEXTRESET}@${YELLOW}${TAG}${TEXTRESET}";
|
||||
github-release upload --security-token $GH_TOKEN --user ${GH_USER} --repo $REPO --tag "$TAG" --name "$NAME" --file "$file";
|
||||
done;
|
||||
fi
|
||||
75
CHANGELOG.md
75
CHANGELOG.md
@@ -1,10 +1,79 @@
|
||||
# v1.7.50
|
||||
## UNRELEASED
|
||||
# v1.7.50.9
|
||||
## 11/09/2025
|
||||
|
||||
1. [](#improved)
|
||||
* Better error warnings regarding upgrading from 1.7 -> 1.7 vs 1.7 -> 1.8
|
||||
1. [](#bugfix)
|
||||
* Fix for update-provided `Install.php` not used if local version called first
|
||||
* Fix class loading error when trying to use `bin/gpm self-upgrade --safe`
|
||||
|
||||
# v1.7.50.8
|
||||
## 11/06/2025
|
||||
|
||||
1. [](#bugfix)
|
||||
* Removed over zealous safety checks
|
||||
* Removed .gitattributes which was causing some unintended issues
|
||||
|
||||
# v1.7.50.7
|
||||
## 11/05/2025
|
||||
|
||||
1. [](#improved)
|
||||
* Exclude dev files from exports
|
||||
* Remove dev file in clean command
|
||||
1. [](#bugfix)
|
||||
* Ignore .github and .phan folders during self-upgrade
|
||||
* Fixed path check in self-upgrade
|
||||
|
||||
# v1.7.50.6
|
||||
## 11/05/2025
|
||||
|
||||
1. [](#bugfix)
|
||||
* Fixed an issue where non-upgradable root-level folders were snapshotted
|
||||
|
||||
# v1.7.50.5
|
||||
## 11/05/2025
|
||||
|
||||
1. [](#new)
|
||||
* Added staged self-upgrade pipeline with manifest snapshots and atomic swaps for Grav core updates.
|
||||
* Added new `bin/gpm preflight` command
|
||||
* Added `--safe` and `--legacy` overrides for `bin/gpm self-upgrade` command
|
||||
1. [](#improved)
|
||||
* Improved JS assets pipeline handling to support different loading strategies
|
||||
* More safe-upgrade fixes around safe guarding `/user/` and maintaining permissions better
|
||||
1. [](#bugfix)
|
||||
* Fixed a regex issue that corrupted safe-upgrade output
|
||||
|
||||
# v1.7.50.4
|
||||
## 10/31/2025
|
||||
|
||||
1. [](#improved)
|
||||
* More fixes and improvements for safe-uprade process
|
||||
|
||||
# v1.7.50.3
|
||||
## 10/21/2025
|
||||
|
||||
1. [](#bugfix)
|
||||
* Restored `user/config/system.yaml` to 1.7 branch version (testing mode off)
|
||||
|
||||
# v1.7.50.2
|
||||
## 10/21/2025
|
||||
|
||||
1. [](#bugfix)
|
||||
* Fix for `SafeUpgradeService::getLastManifest()` fatal error on upgrade [#3966](https://github.com/getgrav/grav/issues/3966)
|
||||
|
||||
# v1.7.50.1
|
||||
## 10/20/2025
|
||||
|
||||
1. [](#bugfix)
|
||||
* Fix for broken `GRAV_ROOT`
|
||||
|
||||
# v1.7.50
|
||||
## 10/19/2025
|
||||
|
||||
1. [](#new)
|
||||
* Added new **Safe Core Upgrade** process with snapshots for backup and restore, better preflight and postflight checks, as well as exception checking post-install for easy rollback.
|
||||
* Introduced recovery mode with token-gated UI, plugin quarantine, and CLI rollback support.
|
||||
* Added `bin/gpm preflight` compatibility scanner and `bin/gpm rollback` utility.
|
||||
* Added `wordCount` Twig filter [#3957](https://github.com/getgrav/grav/pulls/3957)
|
||||
|
||||
# v1.7.49.5
|
||||
## 09/10/2025
|
||||
|
||||
209
bin/build-test-update.php
Executable file
209
bin/build-test-update.php
Executable file
@@ -0,0 +1,209 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Grav\Common\Filesystem\Folder;
|
||||
|
||||
|
||||
require __DIR__ . '/../vendor/autoload.php';
|
||||
|
||||
if (!\defined('GRAV_ROOT')) {
|
||||
\define('GRAV_ROOT', realpath(__DIR__ . '/..') ?: getcwd());
|
||||
}
|
||||
|
||||
if (!\extension_loaded('zip')) {
|
||||
fwrite(STDERR, "The PHP zip extension is required.\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
$options = getopt('', [
|
||||
'version:',
|
||||
'output::',
|
||||
'port::',
|
||||
'base-url::',
|
||||
'serve',
|
||||
]);
|
||||
|
||||
if (!isset($options['version'])) {
|
||||
fwrite(
|
||||
STDERR,
|
||||
"Usage: php bin/build-test-update.php --version=1.7.999 [--output=tmp/test-gpm] [--port=8043] [--base-url=http://127.0.0.1:8043] [--serve]\n"
|
||||
);
|
||||
exit(1);
|
||||
}
|
||||
|
||||
$version = trim((string) $options['version']);
|
||||
if ($version === '') {
|
||||
fwrite(STDERR, "A non-empty --version value is required.\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
$root = GRAV_ROOT;
|
||||
|
||||
$output = $options['output'] ?? $root . '/tmp/test-gpm';
|
||||
if (!str_starts_with($output, DIRECTORY_SEPARATOR)) {
|
||||
$output = $root . '/' . ltrim($output, '/');
|
||||
}
|
||||
$output = rtrim($output, DIRECTORY_SEPARATOR);
|
||||
|
||||
$defaultPort = isset($options['port']) ? (int) $options['port'] : 8043;
|
||||
$baseUrl = $options['base-url'] ?? sprintf('http://127.0.0.1:%d', $defaultPort);
|
||||
$serve = array_key_exists('serve', $options);
|
||||
|
||||
Folder::create($output);
|
||||
|
||||
$downloadName = sprintf('grav-update-%s.zip', $version);
|
||||
$zipPath = $output . '/' . $downloadName;
|
||||
$jsonPath = $output . '/grav.json';
|
||||
$zipPrefix = 'grav-update/';
|
||||
|
||||
$excludeDirs = [
|
||||
'.build',
|
||||
'.crush',
|
||||
'.ddev',
|
||||
'.git',
|
||||
'.github',
|
||||
'.gitlab',
|
||||
'.circleci',
|
||||
'.idea',
|
||||
'.vscode',
|
||||
'.pytest_cache',
|
||||
'backup',
|
||||
'cache',
|
||||
'images',
|
||||
'logs',
|
||||
'node_modules',
|
||||
'tests',
|
||||
'tmp',
|
||||
'user',
|
||||
];
|
||||
|
||||
$excludeFiles = [
|
||||
'.htaccess',
|
||||
'.DS_Store',
|
||||
'robots.txt',
|
||||
];
|
||||
|
||||
$directory = new RecursiveDirectoryIterator($root, RecursiveDirectoryIterator::SKIP_DOTS);
|
||||
$filtered = new RecursiveCallbackFilterIterator(
|
||||
$directory,
|
||||
function (SplFileInfo $current) use ($root, $excludeDirs, $excludeFiles): bool {
|
||||
$relative = ltrim(str_replace($root, '', $current->getPathname()), DIRECTORY_SEPARATOR);
|
||||
$relative = str_replace('\\', '/', $relative);
|
||||
|
||||
if ($relative === '') {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (str_contains($relative, '..')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach ($excludeDirs as $prefix) {
|
||||
$prefix = trim($prefix, '/');
|
||||
if ($prefix === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($relative === $prefix || str_starts_with($relative, $prefix . '/')) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (in_array($current->getFilename(), $excludeFiles, true)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
);
|
||||
|
||||
$zip = new ZipArchive();
|
||||
if ($zip->open($zipPath, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) {
|
||||
throw new RuntimeException(sprintf('Unable to open archive at %s', $zipPath));
|
||||
}
|
||||
|
||||
$zip->addEmptyDir($zipPrefix);
|
||||
|
||||
$iterator = new RecursiveIteratorIterator($filtered, RecursiveIteratorIterator::SELF_FIRST);
|
||||
/** @var SplFileInfo $fileInfo */
|
||||
foreach ($iterator as $fileInfo) {
|
||||
$fullPath = $fileInfo->getPathname();
|
||||
$relative = ltrim(str_replace($root, '', $fullPath), DIRECTORY_SEPARATOR);
|
||||
$relative = str_replace('\\', '/', $relative);
|
||||
$targetPath = $zipPrefix . $relative;
|
||||
|
||||
if ($fileInfo->isDir()) {
|
||||
$zip->addEmptyDir(rtrim($targetPath, '/') . '/');
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($fileInfo->isLink()) {
|
||||
$target = readlink($fullPath);
|
||||
$zip->addFromString($targetPath, $target === false ? '' : $target);
|
||||
$zip->setExternalAttributesName($targetPath, ZipArchive::OPSYS_UNIX, 0120000 << 16);
|
||||
continue;
|
||||
}
|
||||
|
||||
$zip->addFile($fullPath, $targetPath);
|
||||
|
||||
$perms = @fileperms($fullPath);
|
||||
if ($perms !== false) {
|
||||
$zip->setExternalAttributesName($targetPath, ZipArchive::OPSYS_UNIX, ($perms & 0xFFFF) << 16);
|
||||
}
|
||||
}
|
||||
|
||||
$zip->close();
|
||||
|
||||
$size = filesize($zipPath);
|
||||
$sha256 = hash_file('sha256', $zipPath);
|
||||
$timestamp = date('c');
|
||||
$downloadUrl = rtrim($baseUrl, '/') . '/' . rawurlencode($downloadName);
|
||||
|
||||
$manifest = [
|
||||
'version' => $version,
|
||||
'date' => $timestamp,
|
||||
'min_php' => '8.3.0',
|
||||
'assets' => [
|
||||
'grav-update' => [
|
||||
'name' => $downloadName,
|
||||
'slug' => 'grav-update',
|
||||
'version' => $version,
|
||||
'date' => $timestamp,
|
||||
'testing' => false,
|
||||
'description' => 'Local test update package generated for safe-upgrade validation.',
|
||||
'download' => $downloadUrl,
|
||||
'size' => $size,
|
||||
'checksum' => 'sha256:' . $sha256,
|
||||
'sha256' => $sha256,
|
||||
'host' => parse_url($downloadUrl, PHP_URL_HOST),
|
||||
],
|
||||
],
|
||||
'changelog' => [
|
||||
$version => [
|
||||
'date' => $timestamp,
|
||||
'content' => "- Local test update package generated by build-test-update.\n",
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
file_put_contents($jsonPath, json_encode($manifest, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . PHP_EOL);
|
||||
|
||||
$manifestUrl = rtrim($baseUrl, '/') . '/grav.json';
|
||||
|
||||
echo "Update package created at: {$zipPath}\n";
|
||||
echo "Manifest written to: {$jsonPath}\n";
|
||||
echo "Manifest URL: {$manifestUrl}\n";
|
||||
echo "Download URL: {$downloadUrl}\n";
|
||||
echo "Archive size: {$size} bytes\n";
|
||||
echo "SHA256: {$sha256}\n";
|
||||
|
||||
if ($serve) {
|
||||
$host = parse_url($baseUrl, PHP_URL_HOST) ?: '127.0.0.1';
|
||||
$port = parse_url($baseUrl, PHP_URL_PORT) ?: $defaultPort;
|
||||
$command = sprintf('php -S %s:%d -t %s', $host, $port, escapeshellarg($output));
|
||||
echo "\nServing files using PHP built-in server. Press Ctrl+C to stop.\n";
|
||||
echo $command . "\n\n";
|
||||
passthru($command);
|
||||
}
|
||||
462
bin/restore
462
bin/restore
@@ -25,6 +25,7 @@ if (!file_exists($root . '/index.php')) {
|
||||
exit(1);
|
||||
}
|
||||
|
||||
use Grav\Common\Filesystem\Folder;
|
||||
use Grav\Common\Recovery\RecoveryManager;
|
||||
use Grav\Common\Upgrade\SafeUpgradeService;
|
||||
use Symfony\Component\Yaml\Yaml;
|
||||
@@ -39,16 +40,24 @@ Usage:
|
||||
bin/restore apply <snapshot-id> [--staging-root=/absolute/path]
|
||||
Restores the specified snapshot created by safe-upgrade.
|
||||
|
||||
bin/restore remove [<snapshot-id> ...] [--staging-root=/absolute/path]
|
||||
Deletes one or more snapshots (interactive selection when no id provided).
|
||||
|
||||
bin/restore snapshot [--label=\"optional description\"] [--staging-root=/absolute/path]
|
||||
Creates a manual snapshot of the current Grav core files.
|
||||
|
||||
bin/restore recovery [status|clear]
|
||||
Shows the recovery flag context or clears it.
|
||||
|
||||
Options:
|
||||
--staging-root Overrides the staging directory (defaults to configured value).
|
||||
--label Optional label to store with the manual snapshot.
|
||||
|
||||
Examples:
|
||||
bin/restore list
|
||||
bin/restore apply stage-68eff31cc4104
|
||||
bin/restore apply stage-68eff31cc4104 --staging-root=/var/grav-backups
|
||||
bin/restore snapshot --label=\"Before plugin install\"
|
||||
bin/restore recovery status
|
||||
bin/restore recovery clear
|
||||
USAGE;
|
||||
@@ -61,17 +70,35 @@ function parseArguments(array $args): array
|
||||
{
|
||||
array_shift($args); // remove script name
|
||||
|
||||
$command = $args[0] ?? 'help';
|
||||
$command = null;
|
||||
$arguments = [];
|
||||
$options = [];
|
||||
|
||||
foreach (array_slice($args, 1) as $arg) {
|
||||
if (substr($arg, 0, 2) === '--') {
|
||||
echo "Unknown option: {$arg}\n";
|
||||
exit(1);
|
||||
while ($args) {
|
||||
$arg = array_shift($args);
|
||||
if (strncmp($arg, '--', 2) === 0) {
|
||||
$parts = explode('=', substr($arg, 2), 2);
|
||||
$name = $parts[0] ?? '';
|
||||
if ($name === '') {
|
||||
continue;
|
||||
}
|
||||
$value = $parts[1] ?? null;
|
||||
if ($value === null && $args && substr($args[0], 0, 2) !== '--') {
|
||||
$value = array_shift($args);
|
||||
}
|
||||
$options[$name] = $value ?? true;
|
||||
continue;
|
||||
}
|
||||
|
||||
$arguments[] = $arg;
|
||||
if (null === $command) {
|
||||
$command = $arg;
|
||||
} else {
|
||||
$arguments[] = $arg;
|
||||
}
|
||||
}
|
||||
|
||||
if (null === $command) {
|
||||
$command = 'interactive';
|
||||
}
|
||||
|
||||
return [
|
||||
@@ -81,22 +108,23 @@ function parseArguments(array $args): array
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string|null
|
||||
*/
|
||||
/**
|
||||
* @param array $options
|
||||
* @return SafeUpgradeService
|
||||
*/
|
||||
function createUpgradeService(array $options): SafeUpgradeService
|
||||
{
|
||||
$options['root'] = GRAV_ROOT;
|
||||
$serviceOptions = ['root' => GRAV_ROOT];
|
||||
|
||||
return new SafeUpgradeService($options);
|
||||
if (isset($options['staging-root']) && is_string($options['staging-root']) && $options['staging-root'] !== '') {
|
||||
$serviceOptions['staging_root'] = $options['staging-root'];
|
||||
}
|
||||
|
||||
return new SafeUpgradeService($serviceOptions);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array{id:string,source_version:?string,target_version:?string,created_at:int}>
|
||||
* @return list<array{id:string,label:?string,source_version:?string,target_version:?string,created_at:int}>
|
||||
*/
|
||||
function loadSnapshots(): array
|
||||
{
|
||||
@@ -117,21 +145,355 @@ function loadSnapshots(): array
|
||||
|
||||
$snapshots[] = [
|
||||
'id' => $decoded['id'],
|
||||
'label' => $decoded['label'] ?? null,
|
||||
'source_version' => $decoded['source_version'] ?? null,
|
||||
'target_version' => $decoded['target_version'] ?? null,
|
||||
'created_at' => $decoded['created_at'] ?? 0,
|
||||
'created_at' => (int)($decoded['created_at'] ?? 0),
|
||||
];
|
||||
}
|
||||
|
||||
return $snapshots;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array{id:string,label:?string,source_version:?string,target_version:?string,created_at:int}> $snapshots
|
||||
* @return string
|
||||
*/
|
||||
function formatSnapshotListLine(array $snapshot): string
|
||||
{
|
||||
$restoreVersion = $snapshot['source_version'] ?? $snapshot['target_version'] ?? 'unknown';
|
||||
$timeLabel = formatSnapshotTimestamp($snapshot['created_at']);
|
||||
$label = $snapshot['label'] ?? null;
|
||||
$display = $label ? sprintf('%s [%s]', $label, $snapshot['id']) : $snapshot['id'];
|
||||
|
||||
return sprintf('%s (restore to Grav %s, %s)', $display, $restoreVersion, $timeLabel);
|
||||
}
|
||||
|
||||
function formatSnapshotTimestamp(int $timestamp): string
|
||||
{
|
||||
if ($timestamp <= 0) {
|
||||
return 'time unknown';
|
||||
}
|
||||
|
||||
try {
|
||||
$timezone = resolveTimezone();
|
||||
$dt = new DateTime('@' . $timestamp);
|
||||
$dt->setTimezone($timezone);
|
||||
$formatted = $dt->format('Y-m-d H:i:s T');
|
||||
} catch (\Throwable $e) {
|
||||
$formatted = date('Y-m-d H:i:s T', $timestamp);
|
||||
}
|
||||
|
||||
return $formatted . ' (' . formatRelative(time() - $timestamp) . ')';
|
||||
}
|
||||
|
||||
function resolveTimezone(): DateTimeZone
|
||||
{
|
||||
static $resolved = null;
|
||||
if ($resolved instanceof DateTimeZone) {
|
||||
return $resolved;
|
||||
}
|
||||
|
||||
$timezone = null;
|
||||
$configFile = GRAV_ROOT . '/user/config/system.yaml';
|
||||
if (is_file($configFile)) {
|
||||
try {
|
||||
$data = Yaml::parse(file_get_contents($configFile) ?: '') ?: [];
|
||||
if (!empty($data['system']['timezone']) && is_string($data['system']['timezone'])) {
|
||||
$timezone = $data['system']['timezone'];
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
// ignore parse errors, fallback below
|
||||
}
|
||||
}
|
||||
|
||||
if (!$timezone) {
|
||||
$timezone = ini_get('date.timezone') ?: 'UTC';
|
||||
}
|
||||
|
||||
try {
|
||||
$resolved = new DateTimeZone($timezone);
|
||||
} catch (\Throwable $e) {
|
||||
$resolved = new DateTimeZone('UTC');
|
||||
}
|
||||
|
||||
return $resolved;
|
||||
}
|
||||
|
||||
function formatRelative(int $seconds): string
|
||||
{
|
||||
if ($seconds < 5) {
|
||||
return 'just now';
|
||||
}
|
||||
$negative = $seconds < 0;
|
||||
$seconds = abs($seconds);
|
||||
$units = [
|
||||
31536000 => 'y',
|
||||
2592000 => 'mo',
|
||||
604800 => 'w',
|
||||
86400 => 'd',
|
||||
3600 => 'h',
|
||||
60 => 'm',
|
||||
1 => 's',
|
||||
];
|
||||
foreach ($units as $size => $label) {
|
||||
if ($seconds >= $size) {
|
||||
$value = (int)floor($seconds / $size);
|
||||
$suffix = $label === 'mo' ? 'month' : ($label === 'y' ? 'year' : ($label === 'w' ? 'week' : ($label === 'd' ? 'day' : ($label === 'h' ? 'hour' : ($label === 'm' ? 'minute' : 'second')))));
|
||||
if ($value !== 1) {
|
||||
$suffix .= 's';
|
||||
}
|
||||
$phrase = $value . ' ' . $suffix;
|
||||
return $negative ? 'in ' . $phrase : $phrase . ' ago';
|
||||
}
|
||||
}
|
||||
|
||||
return $negative ? 'in 0 seconds' : '0 seconds ago';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $snapshotId
|
||||
* @param array $options
|
||||
* @return void
|
||||
*/
|
||||
function applySnapshot(string $snapshotId, array $options): void
|
||||
{
|
||||
try {
|
||||
$service = createUpgradeService($options);
|
||||
$manifest = $service->rollback($snapshotId);
|
||||
} catch (\Throwable $e) {
|
||||
fwrite(STDERR, "Restore failed: " . $e->getMessage() . "\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
if (!$manifest) {
|
||||
fwrite(STDERR, "Snapshot {$snapshotId} not found.\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
$version = $manifest['source_version'] ?? $manifest['target_version'] ?? 'unknown';
|
||||
echo "Restored snapshot {$snapshotId} (Grav {$version}).\n";
|
||||
if (!empty($manifest['id'])) {
|
||||
echo "Snapshot manifest: {$manifest['id']}\n";
|
||||
}
|
||||
if (!empty($manifest['backup_path'])) {
|
||||
echo "Snapshot path: {$manifest['backup_path']}\n";
|
||||
}
|
||||
exit(0);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array $options
|
||||
* @return void
|
||||
*/
|
||||
function createManualSnapshot(array $options): void
|
||||
{
|
||||
$label = null;
|
||||
if (isset($options['label']) && is_string($options['label'])) {
|
||||
$label = trim($options['label']);
|
||||
if ($label === '') {
|
||||
$label = null;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
$service = createUpgradeService($options);
|
||||
$manifest = $service->createSnapshot($label);
|
||||
} catch (\Throwable $e) {
|
||||
fwrite(STDERR, "Snapshot creation failed: " . $e->getMessage() . "\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
$snapshotId = $manifest['id'] ?? null;
|
||||
if (!$snapshotId) {
|
||||
$snapshotId = 'unknown';
|
||||
}
|
||||
$version = $manifest['source_version'] ?? $manifest['target_version'] ?? 'unknown';
|
||||
|
||||
echo "Created snapshot {$snapshotId} (Grav {$version}).\n";
|
||||
if ($label) {
|
||||
echo "Label: {$label}\n";
|
||||
}
|
||||
if (!empty($manifest['backup_path'])) {
|
||||
echo "Snapshot path: {$manifest['backup_path']}\n";
|
||||
}
|
||||
|
||||
exit(0);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array{id:string,source_version:?string,target_version:?string,created_at:int}> $snapshots
|
||||
* @return string|null
|
||||
*/
|
||||
function promptSnapshotSelection(array $snapshots): ?string
|
||||
{
|
||||
echo "Available snapshots:\n";
|
||||
foreach ($snapshots as $index => $snapshot) {
|
||||
$line = formatSnapshotListLine($snapshot);
|
||||
$number = $index + 1;
|
||||
echo sprintf(" [%d] %s\n", $number, $line);
|
||||
}
|
||||
|
||||
$default = $snapshots[0]['id'];
|
||||
echo "\nSelect a snapshot to restore [1]: ";
|
||||
$input = trim((string)fgets(STDIN));
|
||||
|
||||
if ($input === '') {
|
||||
return $default;
|
||||
}
|
||||
|
||||
if (ctype_digit($input)) {
|
||||
$idx = (int)$input - 1;
|
||||
if (isset($snapshots[$idx])) {
|
||||
return $snapshots[$idx]['id'];
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($snapshots as $snapshot) {
|
||||
if (strcasecmp($snapshot['id'], $input) === 0) {
|
||||
return $snapshot['id'];
|
||||
}
|
||||
}
|
||||
|
||||
echo "Invalid selection. Aborting.\n";
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array{id:string,source_version:?string,target_version:?string,created_at:int}> $snapshots
|
||||
* @return array<string>
|
||||
*/
|
||||
function promptSnapshotsRemoval(array $snapshots): array
|
||||
{
|
||||
echo "Available snapshots:\n";
|
||||
foreach ($snapshots as $index => $snapshot) {
|
||||
$line = formatSnapshotListLine($snapshot);
|
||||
$number = $index + 1;
|
||||
echo sprintf(" [%d] %s\n", $number, $line);
|
||||
}
|
||||
|
||||
echo "\nSelect snapshots to remove (comma or space separated numbers / ids, 'all' for everything, empty to cancel): ";
|
||||
$input = trim((string)fgets(STDIN));
|
||||
|
||||
if ($input === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
$inputLower = strtolower($input);
|
||||
if ($inputLower === 'all' || $inputLower === '*') {
|
||||
return array_values(array_unique(array_column($snapshots, 'id')));
|
||||
}
|
||||
|
||||
$tokens = preg_split('/[\\s,]+/', $input, -1, PREG_SPLIT_NO_EMPTY) ?: [];
|
||||
$selected = [];
|
||||
foreach ($tokens as $token) {
|
||||
if (ctype_digit($token)) {
|
||||
$idx = (int)$token - 1;
|
||||
if (isset($snapshots[$idx])) {
|
||||
$selected[] = $snapshots[$idx]['id'];
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($snapshots as $snapshot) {
|
||||
if (strcasecmp($snapshot['id'], $token) === 0) {
|
||||
$selected[] = $snapshot['id'];
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return array_values(array_unique(array_filter($selected)));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $snapshotId
|
||||
* @return array{success:bool,message:string}
|
||||
*/
|
||||
function removeSnapshot(string $snapshotId): array
|
||||
{
|
||||
$manifestDir = GRAV_ROOT . '/user/data/upgrades';
|
||||
$manifestPath = $manifestDir . '/' . $snapshotId . '.json';
|
||||
if (!is_file($manifestPath)) {
|
||||
return [
|
||||
'success' => false,
|
||||
'message' => "Snapshot {$snapshotId} not found."
|
||||
];
|
||||
}
|
||||
|
||||
$manifest = json_decode(file_get_contents($manifestPath) ?: '', true);
|
||||
if (!is_array($manifest)) {
|
||||
return [
|
||||
'success' => false,
|
||||
'message' => "Snapshot {$snapshotId} manifest is invalid."
|
||||
];
|
||||
}
|
||||
|
||||
$pathsToDelete = [];
|
||||
foreach (['package_path', 'backup_path'] as $key) {
|
||||
if (!empty($manifest[$key]) && is_string($manifest[$key])) {
|
||||
$pathsToDelete[] = $manifest[$key];
|
||||
}
|
||||
}
|
||||
|
||||
$errors = [];
|
||||
|
||||
foreach ($pathsToDelete as $path) {
|
||||
if (!$path) {
|
||||
continue;
|
||||
}
|
||||
if (!file_exists($path)) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
if (is_dir($path)) {
|
||||
Folder::delete($path);
|
||||
} else {
|
||||
@unlink($path);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$errors[] = "Unable to remove {$path}: " . $e->getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
if (!@unlink($manifestPath)) {
|
||||
$errors[] = "Unable to delete manifest file {$manifestPath}.";
|
||||
}
|
||||
|
||||
if ($errors) {
|
||||
return [
|
||||
'success' => false,
|
||||
'message' => implode(' ', $errors)
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'message' => "Removed snapshot {$snapshotId}."
|
||||
];
|
||||
}
|
||||
|
||||
$cli = parseArguments($argv);
|
||||
$command = $cli['command'];
|
||||
$arguments = $cli['arguments'];
|
||||
$options = $cli['options'];
|
||||
|
||||
switch ($command) {
|
||||
case 'interactive':
|
||||
$snapshots = loadSnapshots();
|
||||
if (!$snapshots) {
|
||||
echo "No snapshots found. Run bin/gpm self-upgrade (with safe upgrade enabled) to create one.\n";
|
||||
exit(0);
|
||||
}
|
||||
|
||||
$selection = promptSnapshotSelection($snapshots);
|
||||
if (!$selection) {
|
||||
exit(1);
|
||||
}
|
||||
|
||||
applySnapshot($selection, $options);
|
||||
break;
|
||||
|
||||
case 'list':
|
||||
$snapshots = loadSnapshots();
|
||||
if (!$snapshots) {
|
||||
@@ -141,12 +503,60 @@ switch ($command) {
|
||||
|
||||
echo "Available snapshots:\n";
|
||||
foreach ($snapshots as $snapshot) {
|
||||
$time = $snapshot['created_at'] ? date('c', (int)$snapshot['created_at']) : 'unknown';
|
||||
$restoreVersion = $snapshot['source_version'] ?? $snapshot['target_version'] ?? 'unknown';
|
||||
echo sprintf(" - %s (restore to Grav %s, %s)\n", $snapshot['id'], $restoreVersion, $time);
|
||||
echo ' - ' . formatSnapshotListLine($snapshot) . "\n";
|
||||
}
|
||||
exit(0);
|
||||
|
||||
case 'remove':
|
||||
$snapshots = loadSnapshots();
|
||||
if (!$snapshots) {
|
||||
echo "No snapshots found. Nothing to remove.\n";
|
||||
exit(0);
|
||||
}
|
||||
|
||||
$selectedIds = [];
|
||||
if ($arguments) {
|
||||
foreach ($arguments as $arg) {
|
||||
if (!$arg) {
|
||||
continue;
|
||||
}
|
||||
$selectedIds[] = $arg;
|
||||
}
|
||||
} else {
|
||||
$selectedIds = promptSnapshotsRemoval($snapshots);
|
||||
if (!$selectedIds) {
|
||||
echo "No snapshots selected. Aborting.\n";
|
||||
exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
$selectedIds = array_values(array_unique($selectedIds));
|
||||
echo "Snapshots selected for removal:\n";
|
||||
foreach ($selectedIds as $id) {
|
||||
echo " - {$id}\n";
|
||||
}
|
||||
|
||||
$autoConfirm = isset($options['yes']) || isset($options['y']);
|
||||
if (!$autoConfirm) {
|
||||
echo "\nThis action cannot be undone. Proceed? [y/N] ";
|
||||
$confirmation = strtolower(trim((string)fgets(STDIN)));
|
||||
if (!in_array($confirmation, ['y', 'yes'], true)) {
|
||||
echo "Aborted.\n";
|
||||
exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
$success = 0;
|
||||
foreach ($selectedIds as $id) {
|
||||
$result = removeSnapshot($id);
|
||||
echo $result['message'] . "\n";
|
||||
if ($result['success']) {
|
||||
$success++;
|
||||
}
|
||||
}
|
||||
|
||||
exit($success > 0 ? 0 : 1);
|
||||
|
||||
case 'apply':
|
||||
$snapshotId = $arguments[0] ?? null;
|
||||
if (!$snapshotId) {
|
||||
@@ -154,22 +564,12 @@ switch ($command) {
|
||||
exit(1);
|
||||
}
|
||||
|
||||
try {
|
||||
$service = createUpgradeService($options);
|
||||
$manifest = $service->rollback($snapshotId);
|
||||
} catch (\Throwable $e) {
|
||||
fwrite(STDERR, "Restore failed: " . $e->getMessage() . "\n");
|
||||
exit(1);
|
||||
}
|
||||
applySnapshot($snapshotId, $options);
|
||||
break;
|
||||
|
||||
if (!$manifest) {
|
||||
fwrite(STDERR, "Snapshot {$snapshotId} not found.\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
$version = $manifest['source_version'] ?? $manifest['target_version'] ?? 'unknown';
|
||||
echo "Restored snapshot {$snapshotId} (Grav {$version}).\n";
|
||||
exit(0);
|
||||
case 'snapshot':
|
||||
createManualSnapshot($options);
|
||||
break;
|
||||
|
||||
case 'recovery':
|
||||
$action = strtolower($arguments[0] ?? 'status');
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
"symfony/polyfill-php80": "^1.23",
|
||||
"symfony/polyfill-php81": "^1.23",
|
||||
"psr/simple-cache": "^1.0",
|
||||
"psr/cache": "^1.0",
|
||||
"psr/http-message": "^1.0",
|
||||
"psr/http-server-middleware": "^1.0",
|
||||
"psr/container": "~1.1.0",
|
||||
@@ -55,7 +56,8 @@
|
||||
"league/climate": "^3.6",
|
||||
"miljar/php-exif": "^0.6",
|
||||
"composer/ca-bundle": "^1.2",
|
||||
"dragonmantank/cron-expression": "^3.3",
|
||||
"dragonmantank/cron-expression": "~3.3.0",
|
||||
"symfony/deprecation-contracts": "^2.2",
|
||||
"willdurand/negotiation": "^3.0",
|
||||
"itsgoingd/clockwork": "^5.0",
|
||||
"symfony/http-client": "^4.4",
|
||||
@@ -64,13 +66,15 @@
|
||||
"multiavatar/multiavatar-php": "^1.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"behat/gherkin": "~4.10.0",
|
||||
"codeception/codeception": "^4.1",
|
||||
"phpstan/phpstan": "^1.8",
|
||||
"phpstan/phpstan-deprecation-rules": "^1.0",
|
||||
"phpunit/php-code-coverage": "~9.2",
|
||||
"getgrav/markdowndocs": "^2.0",
|
||||
"codeception/module-asserts": "^1.3",
|
||||
"codeception/module-phpbrowser": "^1.0"
|
||||
"codeception/module-phpbrowser": "^1.0",
|
||||
"doctrine/instantiator": "^1.4"
|
||||
},
|
||||
"replace": {
|
||||
"symfony/polyfill-php72": "*",
|
||||
@@ -87,7 +91,10 @@
|
||||
"ext-exif": "Needed to use exif data from images."
|
||||
},
|
||||
"config": {
|
||||
"apcu-autoloader": true
|
||||
"apcu-autoloader": true,
|
||||
"audit": {
|
||||
"block-insecure": false
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
|
||||
295
composer.lock
generated
295
composer.lock
generated
@@ -4,20 +4,20 @@
|
||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||
"This file is @generated automatically"
|
||||
],
|
||||
"content-hash": "8d681f74b0bd1f5099bb8fbf788ab3eb",
|
||||
"content-hash": "2d55f03bde4cf99b4790e878792a9ce0",
|
||||
"packages": [
|
||||
{
|
||||
"name": "composer/ca-bundle",
|
||||
"version": "1.5.8",
|
||||
"version": "1.5.9",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/composer/ca-bundle.git",
|
||||
"reference": "719026bb30813accb68271fee7e39552a58e9f65"
|
||||
"reference": "1905981ee626e6f852448b7aaa978f8666c5bc54"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/composer/ca-bundle/zipball/719026bb30813accb68271fee7e39552a58e9f65",
|
||||
"reference": "719026bb30813accb68271fee7e39552a58e9f65",
|
||||
"url": "https://api.github.com/repos/composer/ca-bundle/zipball/1905981ee626e6f852448b7aaa978f8666c5bc54",
|
||||
"reference": "1905981ee626e6f852448b7aaa978f8666c5bc54",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -64,7 +64,7 @@
|
||||
"support": {
|
||||
"irc": "irc://irc.freenode.org/composer",
|
||||
"issues": "https://github.com/composer/ca-bundle/issues",
|
||||
"source": "https://github.com/composer/ca-bundle/tree/1.5.8"
|
||||
"source": "https://github.com/composer/ca-bundle/tree/1.5.9"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
@@ -76,7 +76,7 @@
|
||||
"type": "github"
|
||||
}
|
||||
],
|
||||
"time": "2025-08-20T18:49:47+00:00"
|
||||
"time": "2025-11-06T11:46:17+00:00"
|
||||
},
|
||||
{
|
||||
"name": "composer/semver",
|
||||
@@ -255,6 +255,7 @@
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"abandoned": true,
|
||||
"time": "2022-05-20T20:06:54+00:00"
|
||||
},
|
||||
{
|
||||
@@ -377,16 +378,16 @@
|
||||
},
|
||||
{
|
||||
"name": "donatj/phpuseragentparser",
|
||||
"version": "v1.10.0",
|
||||
"version": "v1.11.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/donatj/PhpUserAgent.git",
|
||||
"reference": "3ba73057d2a4a275badb88b7708e91e159c40367"
|
||||
"reference": "c98541c5198bb75564d7db4a8971773bc848361e"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/donatj/PhpUserAgent/zipball/3ba73057d2a4a275badb88b7708e91e159c40367",
|
||||
"reference": "3ba73057d2a4a275badb88b7708e91e159c40367",
|
||||
"url": "https://api.github.com/repos/donatj/PhpUserAgent/zipball/c98541c5198bb75564d7db4a8971773bc848361e",
|
||||
"reference": "c98541c5198bb75564d7db4a8971773bc848361e",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -431,7 +432,7 @@
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/donatj/PhpUserAgent/issues",
|
||||
"source": "https://github.com/donatj/PhpUserAgent/tree/v1.10.0"
|
||||
"source": "https://github.com/donatj/PhpUserAgent/tree/v1.11.0"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
@@ -447,20 +448,20 @@
|
||||
"type": "ko_fi"
|
||||
}
|
||||
],
|
||||
"time": "2024-10-30T15:45:03+00:00"
|
||||
"time": "2025-09-10T21:58:40+00:00"
|
||||
},
|
||||
{
|
||||
"name": "dragonmantank/cron-expression",
|
||||
"version": "v3.4.0",
|
||||
"version": "v3.3.3",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/dragonmantank/cron-expression.git",
|
||||
"reference": "8c784d071debd117328803d86b2097615b457500"
|
||||
"reference": "adfb1f505deb6384dc8b39804c5065dd3c8c8c0a"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/dragonmantank/cron-expression/zipball/8c784d071debd117328803d86b2097615b457500",
|
||||
"reference": "8c784d071debd117328803d86b2097615b457500",
|
||||
"url": "https://api.github.com/repos/dragonmantank/cron-expression/zipball/adfb1f505deb6384dc8b39804c5065dd3c8c8c0a",
|
||||
"reference": "adfb1f505deb6384dc8b39804c5065dd3c8c8c0a",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -473,14 +474,10 @@
|
||||
"require-dev": {
|
||||
"phpstan/extension-installer": "^1.0",
|
||||
"phpstan/phpstan": "^1.0",
|
||||
"phpstan/phpstan-webmozart-assert": "^1.0",
|
||||
"phpunit/phpunit": "^7.0|^8.0|^9.0"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"branch-alias": {
|
||||
"dev-master": "3.x-dev"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Cron\\": "src/Cron/"
|
||||
@@ -504,7 +501,7 @@
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/dragonmantank/cron-expression/issues",
|
||||
"source": "https://github.com/dragonmantank/cron-expression/tree/v3.4.0"
|
||||
"source": "https://github.com/dragonmantank/cron-expression/tree/v3.3.3"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
@@ -512,7 +509,7 @@
|
||||
"type": "github"
|
||||
}
|
||||
],
|
||||
"time": "2024-10-09T13:47:03+00:00"
|
||||
"time": "2023-08-10T19:36:49+00:00"
|
||||
},
|
||||
{
|
||||
"name": "erusev/parsedown",
|
||||
@@ -903,16 +900,16 @@
|
||||
},
|
||||
{
|
||||
"name": "itsgoingd/clockwork",
|
||||
"version": "v5.3.4",
|
||||
"version": "v5.3.5",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/itsgoingd/clockwork.git",
|
||||
"reference": "c27ad77a08a9e58bf0049de46969fa4fe3b506e5"
|
||||
"reference": "d928483e231f042dbff9258795cb17aadaebc7d0"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/itsgoingd/clockwork/zipball/c27ad77a08a9e58bf0049de46969fa4fe3b506e5",
|
||||
"reference": "c27ad77a08a9e58bf0049de46969fa4fe3b506e5",
|
||||
"url": "https://api.github.com/repos/itsgoingd/clockwork/zipball/d928483e231f042dbff9258795cb17aadaebc7d0",
|
||||
"reference": "d928483e231f042dbff9258795cb17aadaebc7d0",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -967,7 +964,7 @@
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/itsgoingd/clockwork/issues",
|
||||
"source": "https://github.com/itsgoingd/clockwork/tree/v5.3.4"
|
||||
"source": "https://github.com/itsgoingd/clockwork/tree/v5.3.5"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
@@ -975,7 +972,7 @@
|
||||
"type": "github"
|
||||
}
|
||||
],
|
||||
"time": "2025-02-09T15:57:21+00:00"
|
||||
"time": "2025-09-14T15:34:49+00:00"
|
||||
},
|
||||
{
|
||||
"name": "league/climate",
|
||||
@@ -2090,16 +2087,16 @@
|
||||
},
|
||||
{
|
||||
"name": "rhukster/dom-sanitizer",
|
||||
"version": "1.0.7",
|
||||
"version": "1.0.8",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/rhukster/dom-sanitizer.git",
|
||||
"reference": "c2a98f27ad742668b254282ccc5581871d0fb601"
|
||||
"reference": "757e4d6ac03afe9afa4f97cbef453fc5c25f0729"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/rhukster/dom-sanitizer/zipball/c2a98f27ad742668b254282ccc5581871d0fb601",
|
||||
"reference": "c2a98f27ad742668b254282ccc5581871d0fb601",
|
||||
"url": "https://api.github.com/repos/rhukster/dom-sanitizer/zipball/757e4d6ac03afe9afa4f97cbef453fc5c25f0729",
|
||||
"reference": "757e4d6ac03afe9afa4f97cbef453fc5c25f0729",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -2129,9 +2126,9 @@
|
||||
"description": "A simple but effective DOM/SVG/MathML Sanitizer for PHP 7.4+",
|
||||
"support": {
|
||||
"issues": "https://github.com/rhukster/dom-sanitizer/issues",
|
||||
"source": "https://github.com/rhukster/dom-sanitizer/tree/1.0.7"
|
||||
"source": "https://github.com/rhukster/dom-sanitizer/tree/1.0.8"
|
||||
},
|
||||
"time": "2023-11-06T16:46:48+00:00"
|
||||
"time": "2024-04-15T08:48:55+00:00"
|
||||
},
|
||||
{
|
||||
"name": "rockettheme/toolbox",
|
||||
@@ -2426,6 +2423,73 @@
|
||||
],
|
||||
"time": "2022-07-20T09:59:04+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/deprecation-contracts",
|
||||
"version": "v2.5.4",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/deprecation-contracts.git",
|
||||
"reference": "605389f2a7e5625f273b53960dc46aeaf9c62918"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/605389f2a7e5625f273b53960dc46aeaf9c62918",
|
||||
"reference": "605389f2a7e5625f273b53960dc46aeaf9c62918",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": ">=7.1"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"thanks": {
|
||||
"url": "https://github.com/symfony/contracts",
|
||||
"name": "symfony/contracts"
|
||||
},
|
||||
"branch-alias": {
|
||||
"dev-main": "2.5-dev"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"files": [
|
||||
"function.php"
|
||||
]
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Nicolas Grekas",
|
||||
"email": "p@tchwork.com"
|
||||
},
|
||||
{
|
||||
"name": "Symfony Community",
|
||||
"homepage": "https://symfony.com/contributors"
|
||||
}
|
||||
],
|
||||
"description": "A generic function and convention to trigger deprecation notices",
|
||||
"homepage": "https://symfony.com",
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/deprecation-contracts/tree/v2.5.4"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://symfony.com/sponsor",
|
||||
"type": "custom"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/fabpot",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2024-09-25T14:11:13+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/event-dispatcher",
|
||||
"version": "v4.4.44",
|
||||
@@ -3391,28 +3455,28 @@
|
||||
},
|
||||
{
|
||||
"name": "webmozart/assert",
|
||||
"version": "1.11.0",
|
||||
"version": "1.12.1",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/webmozarts/assert.git",
|
||||
"reference": "11cb2199493b2f8a3b53e7f19068fc6aac760991"
|
||||
"reference": "9be6926d8b485f55b9229203f962b51ed377ba68"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/webmozarts/assert/zipball/11cb2199493b2f8a3b53e7f19068fc6aac760991",
|
||||
"reference": "11cb2199493b2f8a3b53e7f19068fc6aac760991",
|
||||
"url": "https://api.github.com/repos/webmozarts/assert/zipball/9be6926d8b485f55b9229203f962b51ed377ba68",
|
||||
"reference": "9be6926d8b485f55b9229203f962b51ed377ba68",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"ext-ctype": "*",
|
||||
"ext-date": "*",
|
||||
"ext-filter": "*",
|
||||
"php": "^7.2 || ^8.0"
|
||||
},
|
||||
"conflict": {
|
||||
"phpstan/phpstan": "<0.12.20",
|
||||
"vimeo/psalm": "<4.6.1 || 4.6.2"
|
||||
},
|
||||
"require-dev": {
|
||||
"phpunit/phpunit": "^8.5.13"
|
||||
"suggest": {
|
||||
"ext-intl": "",
|
||||
"ext-simplexml": "",
|
||||
"ext-spl": ""
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
@@ -3443,9 +3507,9 @@
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/webmozarts/assert/issues",
|
||||
"source": "https://github.com/webmozarts/assert/tree/1.11.0"
|
||||
"source": "https://github.com/webmozarts/assert/tree/1.12.1"
|
||||
},
|
||||
"time": "2022-06-03T18:03:27+00:00"
|
||||
"time": "2025-10-29T15:56:20+00:00"
|
||||
},
|
||||
{
|
||||
"name": "willdurand/negotiation",
|
||||
@@ -4548,16 +4612,11 @@
|
||||
},
|
||||
{
|
||||
"name": "phpstan/phpstan",
|
||||
"version": "1.12.28",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/phpstan/phpstan.git",
|
||||
"reference": "fcf8b71aeab4e1a1131d1783cef97b23a51b87a9"
|
||||
},
|
||||
"version": "1.12.32",
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/phpstan/phpstan/zipball/fcf8b71aeab4e1a1131d1783cef97b23a51b87a9",
|
||||
"reference": "fcf8b71aeab4e1a1131d1783cef97b23a51b87a9",
|
||||
"url": "https://api.github.com/repos/phpstan/phpstan/zipball/2770dcdf5078d0b0d53f94317e06affe88419aa8",
|
||||
"reference": "2770dcdf5078d0b0d53f94317e06affe88419aa8",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -4602,7 +4661,7 @@
|
||||
"type": "github"
|
||||
}
|
||||
],
|
||||
"time": "2025-07-17T17:15:39+00:00"
|
||||
"time": "2025-09-30T10:16:31+00:00"
|
||||
},
|
||||
{
|
||||
"name": "phpstan/phpstan-deprecation-rules",
|
||||
@@ -4972,16 +5031,16 @@
|
||||
},
|
||||
{
|
||||
"name": "phpunit/phpunit",
|
||||
"version": "9.6.25",
|
||||
"version": "9.6.29",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/sebastianbergmann/phpunit.git",
|
||||
"reference": "049c011e01be805202d8eebedef49f769a8ec7b7"
|
||||
"reference": "9ecfec57835a5581bc888ea7e13b51eb55ab9dd3"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/049c011e01be805202d8eebedef49f769a8ec7b7",
|
||||
"reference": "049c011e01be805202d8eebedef49f769a8ec7b7",
|
||||
"url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/9ecfec57835a5581bc888ea7e13b51eb55ab9dd3",
|
||||
"reference": "9ecfec57835a5581bc888ea7e13b51eb55ab9dd3",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -5006,7 +5065,7 @@
|
||||
"sebastian/comparator": "^4.0.9",
|
||||
"sebastian/diff": "^4.0.6",
|
||||
"sebastian/environment": "^5.1.5",
|
||||
"sebastian/exporter": "^4.0.6",
|
||||
"sebastian/exporter": "^4.0.8",
|
||||
"sebastian/global-state": "^5.0.8",
|
||||
"sebastian/object-enumerator": "^4.0.4",
|
||||
"sebastian/resource-operations": "^3.0.4",
|
||||
@@ -5055,7 +5114,7 @@
|
||||
"support": {
|
||||
"issues": "https://github.com/sebastianbergmann/phpunit/issues",
|
||||
"security": "https://github.com/sebastianbergmann/phpunit/security/policy",
|
||||
"source": "https://github.com/sebastianbergmann/phpunit/tree/9.6.25"
|
||||
"source": "https://github.com/sebastianbergmann/phpunit/tree/9.6.29"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
@@ -5079,7 +5138,7 @@
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2025-08-20T14:38:31+00:00"
|
||||
"time": "2025-09-24T06:29:11+00:00"
|
||||
},
|
||||
{
|
||||
"name": "psr/http-client",
|
||||
@@ -5574,16 +5633,16 @@
|
||||
},
|
||||
{
|
||||
"name": "sebastian/exporter",
|
||||
"version": "4.0.6",
|
||||
"version": "4.0.8",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/sebastianbergmann/exporter.git",
|
||||
"reference": "78c00df8f170e02473b682df15bfcdacc3d32d72"
|
||||
"reference": "14c6ba52f95a36c3d27c835d65efc7123c446e8c"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/78c00df8f170e02473b682df15bfcdacc3d32d72",
|
||||
"reference": "78c00df8f170e02473b682df15bfcdacc3d32d72",
|
||||
"url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/14c6ba52f95a36c3d27c835d65efc7123c446e8c",
|
||||
"reference": "14c6ba52f95a36c3d27c835d65efc7123c446e8c",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -5639,15 +5698,27 @@
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/sebastianbergmann/exporter/issues",
|
||||
"source": "https://github.com/sebastianbergmann/exporter/tree/4.0.6"
|
||||
"source": "https://github.com/sebastianbergmann/exporter/tree/4.0.8"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://github.com/sebastianbergmann",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://liberapay.com/sebastianbergmann",
|
||||
"type": "liberapay"
|
||||
},
|
||||
{
|
||||
"url": "https://thanks.dev/u/gh/sebastianbergmann",
|
||||
"type": "thanks_dev"
|
||||
},
|
||||
{
|
||||
"url": "https://tidelift.com/funding/github/packagist/sebastian/exporter",
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2024-03-02T06:33:00+00:00"
|
||||
"time": "2025-09-24T06:03:27+00:00"
|
||||
},
|
||||
{
|
||||
"name": "sebastian/global-state",
|
||||
@@ -6270,73 +6341,6 @@
|
||||
],
|
||||
"time": "2024-09-25T14:11:13+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/deprecation-contracts",
|
||||
"version": "v2.5.4",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/deprecation-contracts.git",
|
||||
"reference": "605389f2a7e5625f273b53960dc46aeaf9c62918"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/605389f2a7e5625f273b53960dc46aeaf9c62918",
|
||||
"reference": "605389f2a7e5625f273b53960dc46aeaf9c62918",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": ">=7.1"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"thanks": {
|
||||
"url": "https://github.com/symfony/contracts",
|
||||
"name": "symfony/contracts"
|
||||
},
|
||||
"branch-alias": {
|
||||
"dev-main": "2.5-dev"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"files": [
|
||||
"function.php"
|
||||
]
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Nicolas Grekas",
|
||||
"email": "p@tchwork.com"
|
||||
},
|
||||
{
|
||||
"name": "Symfony Community",
|
||||
"homepage": "https://symfony.com/contributors"
|
||||
}
|
||||
],
|
||||
"description": "A generic function and convention to trigger deprecation notices",
|
||||
"homepage": "https://symfony.com",
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/deprecation-contracts/tree/v2.5.4"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://symfony.com/sponsor",
|
||||
"type": "custom"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/fabpot",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2024-09-25T14:11:13+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/dom-crawler",
|
||||
"version": "v5.4.48",
|
||||
@@ -6477,16 +6481,16 @@
|
||||
},
|
||||
{
|
||||
"name": "theseer/tokenizer",
|
||||
"version": "1.2.3",
|
||||
"version": "1.3.1",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/theseer/tokenizer.git",
|
||||
"reference": "737eda637ed5e28c3413cb1ebe8bb52cbf1ca7a2"
|
||||
"reference": "b7489ce515e168639d17feec34b8847c326b0b3c"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/theseer/tokenizer/zipball/737eda637ed5e28c3413cb1ebe8bb52cbf1ca7a2",
|
||||
"reference": "737eda637ed5e28c3413cb1ebe8bb52cbf1ca7a2",
|
||||
"url": "https://api.github.com/repos/theseer/tokenizer/zipball/b7489ce515e168639d17feec34b8847c326b0b3c",
|
||||
"reference": "b7489ce515e168639d17feec34b8847c326b0b3c",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -6515,7 +6519,7 @@
|
||||
"description": "A small library for converting tokenized PHP source code into XML and potentially other formats",
|
||||
"support": {
|
||||
"issues": "https://github.com/theseer/tokenizer/issues",
|
||||
"source": "https://github.com/theseer/tokenizer/tree/1.2.3"
|
||||
"source": "https://github.com/theseer/tokenizer/tree/1.3.1"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
@@ -6523,7 +6527,7 @@
|
||||
"type": "github"
|
||||
}
|
||||
],
|
||||
"time": "2024-03-03T12:36:25+00:00"
|
||||
"time": "2025-11-17T20:03:58+00:00"
|
||||
}
|
||||
],
|
||||
"aliases": [],
|
||||
@@ -6542,8 +6546,5 @@
|
||||
"ext-gd": "*"
|
||||
},
|
||||
"platform-dev": {},
|
||||
"platform-overrides": {
|
||||
"php": "7.3.6"
|
||||
},
|
||||
"plugin-api-version": "2.6.0"
|
||||
"plugin-api-version": "2.9.0"
|
||||
}
|
||||
|
||||
14
index.php
14
index.php
@@ -11,9 +11,6 @@ namespace Grav;
|
||||
|
||||
\define('GRAV_REQUEST_TIME', microtime(true));
|
||||
\define('GRAV_PHP_MIN', '7.3.6');
|
||||
if (!\defined('GRAV_ROOT')) {
|
||||
\define('GRAV_ROOT', __DIR__);
|
||||
}
|
||||
|
||||
if (PHP_SAPI === 'cli-server') {
|
||||
$symfony_server = stripos(getenv('_'), 'symfony') !== false || stripos($_SERVER['SERVER_SOFTWARE'] ?? '', 'symfony') !== false || stripos($_ENV['SERVER_SOFTWARE'] ?? '', 'symfony') !== false;
|
||||
@@ -81,7 +78,7 @@ date_default_timezone_set(@date_default_timezone_get());
|
||||
@ini_set('default_charset', 'UTF-8');
|
||||
mb_internal_encoding('UTF-8');
|
||||
|
||||
$recoveryFlag = __DIR__ . '/system/recovery.flag';
|
||||
$recoveryFlag = __DIR__ . '/user/data/recovery.flag';
|
||||
if (PHP_SAPI !== 'cli' && is_file($recoveryFlag)) {
|
||||
require __DIR__ . '/system/recovery.php';
|
||||
return 0;
|
||||
@@ -97,6 +94,13 @@ $grav = Grav::instance(array('loader' => $loader));
|
||||
try {
|
||||
$grav->process();
|
||||
} catch (\Error|\Exception $e) {
|
||||
$grav->fireEvent('onFatalException', new Event(array('exception' => $e)));
|
||||
$grav->fireEvent('onFatalException', new Event(['exception' => $e]));
|
||||
|
||||
if (PHP_SAPI !== 'cli' && is_file($recoveryFlag)) {
|
||||
require __DIR__ . '/system/recovery.php';
|
||||
return 0;
|
||||
}
|
||||
|
||||
throw $e;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
# Grav Safe Self-Upgrade Prototype
|
||||
|
||||
This document tracks the design decisions behind the new self-upgrade prototype for Grav 1.8.
|
||||
|
||||
## Goals
|
||||
|
||||
- Prevent in-place mutation of the running Grav tree.
|
||||
- Guarantee a restorable snapshot before any destructive change.
|
||||
- Detect high-risk plugin incompatibilities (eg. `psr/log`) prior to upgrading.
|
||||
- Provide a recovery surface that does not depend on a working Admin plugin.
|
||||
|
||||
## High-Level Flow
|
||||
|
||||
1. **Preflight**
|
||||
- Ensure PHP & extensions satisfy the target release requirements.
|
||||
- Refresh GPM metadata and require all plugins/themes to be on their latest compatible release.
|
||||
- Scan plugin `composer.json` files for dependencies that are known to break under Grav 1.8 (eg. `psr/log` < 3) and surface actionable warnings.
|
||||
2. **Stage**
|
||||
- Download the Grav update archive into a staging area (`tmp://grav-snapshots/{timestamp}`).
|
||||
- Extract the package, then write a manifest describing the target version, PHP info, and enabled packages.
|
||||
- Snapshot the live `user/` directory and relevant metadata into the same stage folder.
|
||||
3. **Promote**
|
||||
- Copy the staged package into place, overwriting Grav core files while leaving hydrated user content intact.
|
||||
- Clear caches in the staged tree before promotion.
|
||||
- Run Grav CLI smoke checks (`bin/grav check`) while still holding maintenance state; restore from the snapshot automatically on failure.
|
||||
4. **Finalize**
|
||||
- Record the manifest under `user/data/upgrades`.
|
||||
- Resume normal traffic by removing the maintenance flag.
|
||||
- Leave the previous tree and manifest available for manual rollback commands.
|
||||
|
||||
## Recovery Mode
|
||||
|
||||
- Introduce a `system/recovery.flag` sentinel written whenever a fatal error occurs during bootstrap or when a promoted release fails validation.
|
||||
- While the flag is present, Grav forces a minimal Recovery UI served outside of Admin, protected by a short-lived signed token.
|
||||
- The Recovery UI lists recent manifests, quarantined plugins, and offers rollback/disabling actions.
|
||||
- Clearing the flag requires either a successful rollback or a full Grav request cycle without fatal errors.
|
||||
|
||||
## CLI Additions
|
||||
|
||||
- `bin/gpm preflight grav@<version>`: runs the same preflight checks without executing the upgrade.
|
||||
- `bin/gpm rollback [<manifest-id>]`: swaps the live tree with a stored rollback snapshot.
|
||||
- Existing `self-upgrade` command now wraps the stage/promote pipeline and respects the snapshot manifest.
|
||||
|
||||
## Open Items
|
||||
|
||||
- Finalize compatibility heuristics (initial pass focuses on `psr/log` and removed logging APIs).
|
||||
- UX polish for the Recovery UI (initial prototype will expose basic actions only).
|
||||
- Decide retention policy for old manifests and snapshots (prototype keeps the most recent three).
|
||||
@@ -1614,6 +1614,15 @@ form:
|
||||
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
|
||||
|
||||
http_section:
|
||||
type: section
|
||||
title: PLUGIN_ADMIN.HTTP_SECTION
|
||||
|
||||
@@ -205,6 +205,7 @@ gpm:
|
||||
|
||||
updates:
|
||||
safe_upgrade: true # Enable guarded staging+rollback pipeline for Grav self-updates
|
||||
safe_upgrade_snapshot_limit: 5 # Maximum number of safe-upgrade snapshots to retain (0 = unlimited)
|
||||
|
||||
http:
|
||||
method: auto # Either 'curl', 'fopen' or 'auto'. 'auto' will try fopen first and if not available cURL
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
|
||||
// Some standard defines
|
||||
define('GRAV', true);
|
||||
define('GRAV_VERSION', '1.7.50');
|
||||
define('GRAV_VERSION', '1.7.50.9');
|
||||
define('GRAV_SCHEMA', '1.7.0_2020-11-20_1');
|
||||
define('GRAV_TESTING', false);
|
||||
|
||||
|
||||
@@ -10,6 +10,43 @@ if (!defined('GRAV_ROOT')) {
|
||||
die();
|
||||
}
|
||||
|
||||
// Check if Install class is already loaded (from an older Grav version)
|
||||
// This happens when upgrading from older versions where the OLD Install class
|
||||
// was loaded via autoloader before extracting the update package (e.g., via Install::forceSafeUpgrade())
|
||||
$logInstallerSource = static function ($install, string $source) {
|
||||
$sourceLabel = $source === 'extracted update package' ? 'update package' : 'existing installation';
|
||||
if (PHP_SAPI === 'cli' || PHP_SAPI === 'phpdbg') {
|
||||
echo sprintf(" |- Using installer from %s\n", $sourceLabel);
|
||||
}
|
||||
};
|
||||
|
||||
if (class_exists('Grav\\Installer\\Install', false)) {
|
||||
// OLD Install class is already loaded. We cannot load the NEW one due to PHP limitations.
|
||||
// However, we can work around this by:
|
||||
// 1. Using a different class name for the NEW installer
|
||||
// 2. Or, accepting that the OLD Install class will run but ensuring it can still upgrade properly
|
||||
|
||||
// For now, use the OLD Install class but set its location to this extracted package
|
||||
// so it processes files from here
|
||||
$install = Grav\Installer\Install::instance();
|
||||
|
||||
// Use reflection to update the location property to point to this package
|
||||
$reflection = new \ReflectionClass($install);
|
||||
if ($reflection->hasProperty('location')) {
|
||||
$locationProp = $reflection->getProperty('location');
|
||||
$locationProp->setAccessible(true);
|
||||
$locationProp->setValue($install, __DIR__ . '/..');
|
||||
}
|
||||
|
||||
$logInstallerSource($install, 'existing installation');
|
||||
|
||||
return $install;
|
||||
}
|
||||
|
||||
// Normal case: Install class not yet loaded, load the NEW one
|
||||
require_once __DIR__ . '/src/Grav/Installer/Install.php';
|
||||
|
||||
return Grav\Installer\Install::instance();
|
||||
$install = Grav\Installer\Install::instance();
|
||||
$logInstallerSource($install, 'extracted update package');
|
||||
|
||||
return $install;
|
||||
|
||||
@@ -124,3 +124,5 @@ 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).
|
||||
|
||||
@@ -63,21 +63,37 @@ if (is_file($quarantineFile)) {
|
||||
}
|
||||
|
||||
$manifestDir = GRAV_ROOT . '/user/data/upgrades';
|
||||
$manifests = [];
|
||||
$snapshots = [];
|
||||
if (is_dir($manifestDir)) {
|
||||
$files = glob($manifestDir . '/*.json');
|
||||
if ($files) {
|
||||
rsort($files);
|
||||
foreach ($files as $file) {
|
||||
$decoded = json_decode(file_get_contents($file), true);
|
||||
if (is_array($decoded)) {
|
||||
$decoded['file'] = basename($file);
|
||||
$manifests[] = $decoded;
|
||||
if (!is_array($decoded)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$id = $decoded['id'] ?? pathinfo($file, PATHINFO_FILENAME);
|
||||
if (!is_string($id) || $id === '' || strncmp($id, 'snapshot-', 9) !== 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$decoded['id'] = $id;
|
||||
$decoded['file'] = basename($file);
|
||||
$decoded['created_at'] = (int)($decoded['created_at'] ?? filemtime($file) ?: 0);
|
||||
$snapshots[] = $decoded;
|
||||
}
|
||||
|
||||
if ($snapshots) {
|
||||
usort($snapshots, static function (array $a, array $b): int {
|
||||
return ($b['created_at'] ?? 0) <=> ($a['created_at'] ?? 0);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$latestSnapshot = $snapshots[0] ?? null;
|
||||
|
||||
header('Content-Type: text/html; charset=utf-8');
|
||||
|
||||
?><!doctype html>
|
||||
@@ -89,7 +105,8 @@ header('Content-Type: text/html; charset=utf-8');
|
||||
<style>
|
||||
body { font-family: system-ui, -apple-system, BlinkMacSystemFont, sans-serif; margin: 0; padding: 40px; background: #111; color: #eee; }
|
||||
.panel { max-width: 720px; margin: 0 auto; background: #1d1d1f; padding: 24px 32px; border-radius: 12px; box-shadow: 0 10px 45px rgba(0,0,0,0.4); }
|
||||
h1 { margin-top: 0; color: #9ef; }
|
||||
h1 { font-size: 2.5rem; margin-top: 0; color: #fff; display:flex;align-items:center; }
|
||||
h1 > img {margin-right:1rem;}
|
||||
code { background: rgba(255,255,255,0.08); padding: 2px 4px; border-radius: 4px; }
|
||||
form { margin-top: 16px; }
|
||||
input[type="text"] { width: 100%; padding: 10px; border: 1px solid #333; border-radius: 6px; background: #151517; color: #fff; }
|
||||
@@ -106,7 +123,7 @@ header('Content-Type: text/html; charset=utf-8');
|
||||
</head>
|
||||
<body>
|
||||
<div class="panel">
|
||||
<h1>Grav Recovery Mode</h1>
|
||||
<h1><img src="system/assets/grav.png">Grav Recovery Mode</h1>
|
||||
<?php if ($notice): ?>
|
||||
<div class="message notice"><?php echo htmlspecialchars($notice, ENT_QUOTES, 'UTF-8'); ?></div>
|
||||
<?php endif; ?>
|
||||
@@ -116,7 +133,7 @@ header('Content-Type: text/html; charset=utf-8');
|
||||
|
||||
<?php if (!$authenticated): ?>
|
||||
<p>This site is running in recovery mode because Grav detected a fatal error.</p>
|
||||
<p>Locate the recovery token in <code>system/recovery.flag</code> and enter it below.</p>
|
||||
<p>Locate the recovery token in <code>user/data/recovery.flag</code> and enter it below.</p>
|
||||
<form method="post">
|
||||
<input type="hidden" name="action" value="authenticate">
|
||||
<label for="token">Recovery token</label>
|
||||
@@ -153,18 +170,22 @@ header('Content-Type: text/html; charset=utf-8');
|
||||
|
||||
<div class="card">
|
||||
<h3>Rollback</h3>
|
||||
<?php if ($manifests): ?>
|
||||
<?php if ($latestSnapshot): ?>
|
||||
<form method="post">
|
||||
<input type="hidden" name="action" value="rollback">
|
||||
<label for="manifest">Choose a snapshot</label>
|
||||
<select id="manifest" name="manifest">
|
||||
<?php foreach ($manifests as $manifest): ?>
|
||||
<option value="<?php echo htmlspecialchars($manifest['id'], ENT_QUOTES, 'UTF-8'); ?>">
|
||||
<?php echo htmlspecialchars($manifest['id'], ENT_QUOTES, 'UTF-8'); ?> — Grav <?php echo htmlspecialchars($manifest['target_version'] ?? 'unknown', ENT_QUOTES, 'UTF-8'); ?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
<button type="submit" class="secondary">Rollback to Selected Snapshot</button>
|
||||
<input type="hidden" name="manifest" value="<?php echo htmlspecialchars($latestSnapshot['id'], ENT_QUOTES, 'UTF-8'); ?>">
|
||||
<p>
|
||||
Latest snapshot:
|
||||
<code><?php echo htmlspecialchars($latestSnapshot['id'], ENT_QUOTES, 'UTF-8'); ?></code>
|
||||
<?php if (!empty($latestSnapshot['label'])): ?>
|
||||
<br><small><?php echo htmlspecialchars($latestSnapshot['label'], ENT_QUOTES, 'UTF-8'); ?></small>
|
||||
<?php endif; ?>
|
||||
— Grav <?php echo htmlspecialchars($latestSnapshot['target_version'] ?? 'unknown', ENT_QUOTES, 'UTF-8'); ?>
|
||||
<?php if (!empty($latestSnapshot['created_at'])): ?>
|
||||
<br><small>Created <?php echo htmlspecialchars(date('c', (int)$latestSnapshot['created_at']), ENT_QUOTES, 'UTF-8'); ?></small>
|
||||
<?php endif; ?>
|
||||
</p>
|
||||
<button type="submit" class="secondary">Rollback to Latest Snapshot</button>
|
||||
</form>
|
||||
<?php else: ?>
|
||||
<p>No upgrade snapshots were found.</p>
|
||||
|
||||
@@ -464,8 +464,18 @@ class Assets extends PropertyObject
|
||||
if ($this->{$pipeline_enabled} ?? false) {
|
||||
$options = array_merge($this->pipeline_options, ['timestamp' => $this->timestamp]);
|
||||
|
||||
$pipeline = new Pipeline($options);
|
||||
$pipeline_output = $pipeline->$render_pipeline($pipeline_assets, $group, $attributes);
|
||||
$grouped_pipeline_assets = $this->splitPipelineAssetsByAttribute($pipeline_assets, 'loading');
|
||||
|
||||
foreach ($grouped_pipeline_assets as $pipeline_group) {
|
||||
if (empty($pipeline_group['assets'])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$group_attributes = array_merge($attributes, $pipeline_group['attributes']);
|
||||
|
||||
$pipeline = new Pipeline($options);
|
||||
$pipeline_output .= $pipeline->$render_pipeline($pipeline_group['assets'], $group, $group_attributes);
|
||||
}
|
||||
} else {
|
||||
foreach ($pipeline_assets as $asset) {
|
||||
$pipeline_output .= $asset->render();
|
||||
@@ -592,4 +602,71 @@ class Assets extends PropertyObject
|
||||
|
||||
return $base_type;
|
||||
}
|
||||
|
||||
/**
|
||||
* Split pipeline assets into ordered groups based on the value of a given attribute.
|
||||
*
|
||||
* This preserves the original order of the assets while ensuring assets that require
|
||||
* special handling (such as different loading strategies) are rendered separately.
|
||||
*
|
||||
* @param array $assets
|
||||
* @param string $attribute
|
||||
* @return array<int, array{assets: array, attributes: array}>
|
||||
*/
|
||||
protected function splitPipelineAssetsByAttribute(array $assets, string $attribute): array
|
||||
{
|
||||
$groups = [];
|
||||
$currentAssets = [];
|
||||
$currentValue = null;
|
||||
$hasCurrentGroup = false;
|
||||
|
||||
foreach ($assets as $key => $asset) {
|
||||
$value = null;
|
||||
|
||||
if (method_exists($asset, 'hasNestedProperty')) {
|
||||
if ($asset->hasNestedProperty($attribute)) {
|
||||
$value = $asset->getNestedProperty($attribute);
|
||||
} elseif ($asset->hasNestedProperty('attributes.' . $attribute)) {
|
||||
$value = $asset->getNestedProperty('attributes.' . $attribute);
|
||||
}
|
||||
}
|
||||
|
||||
if ($value === null && isset($asset[$attribute])) {
|
||||
$value = $asset[$attribute];
|
||||
}
|
||||
|
||||
if ($value === '' || $value === false) {
|
||||
$value = null;
|
||||
}
|
||||
|
||||
if (!$hasCurrentGroup) {
|
||||
$currentAssets = [$key => $asset];
|
||||
$currentValue = $value;
|
||||
$hasCurrentGroup = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($value === $currentValue) {
|
||||
$currentAssets[$key] = $asset;
|
||||
continue;
|
||||
}
|
||||
|
||||
$groups[] = [
|
||||
'assets' => $currentAssets,
|
||||
'attributes' => $currentValue !== null ? [$attribute => $currentValue] : []
|
||||
];
|
||||
|
||||
$currentAssets = [$key => $asset];
|
||||
$currentValue = $value;
|
||||
}
|
||||
|
||||
if ($hasCurrentGroup) {
|
||||
$groups[] = [
|
||||
'assets' => $currentAssets,
|
||||
'attributes' => $currentValue !== null ? [$attribute => $currentValue] : []
|
||||
];
|
||||
}
|
||||
|
||||
return $groups;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -550,6 +550,9 @@ class Cache extends Getters
|
||||
$anything = true;
|
||||
}
|
||||
} elseif (is_dir($file)) {
|
||||
if (basename($file) === 'grav-snapshots') {
|
||||
continue;
|
||||
}
|
||||
if (Folder::delete($file, false)) {
|
||||
$anything = true;
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
namespace Grav\Common\GPM;
|
||||
|
||||
use Exception;
|
||||
use Grav\Common\Data\Data;
|
||||
use Grav\Common\Grav;
|
||||
use Grav\Common\Filesystem\Folder;
|
||||
use Grav\Common\HTTP\Response;
|
||||
@@ -24,6 +25,7 @@ use function count;
|
||||
use function in_array;
|
||||
use function is_array;
|
||||
use function is_object;
|
||||
use function property_exists;
|
||||
|
||||
/**
|
||||
* Class GPM
|
||||
@@ -322,6 +324,10 @@ class GPM extends Iterator
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!$this->isRemotePackagePublished($plugins[$slug])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$local_version = $plugin->version ?? 'Unknown';
|
||||
$remote_version = $plugins[$slug]->version;
|
||||
|
||||
@@ -414,6 +420,10 @@ class GPM extends Iterator
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!$this->isRemotePackagePublished($themes[$slug])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$local_version = $plugin->version ?? 'Unknown';
|
||||
$remote_version = $themes[$slug]->version;
|
||||
|
||||
@@ -468,6 +478,42 @@ class GPM extends Iterator
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether a remote package is marked as published.
|
||||
*
|
||||
* Remote package metadata introduced a `published` flag to hide releases that are not yet public.
|
||||
* Older repository payloads may omit the key, so we default to treating packages as published
|
||||
* unless the flag is explicitly set to `false`.
|
||||
*
|
||||
* @param object|array $package
|
||||
* @return bool
|
||||
*/
|
||||
protected function isRemotePackagePublished($package): bool
|
||||
{
|
||||
if (is_object($package) && method_exists($package, 'getData')) {
|
||||
$data = $package->getData();
|
||||
if ($data instanceof Data) {
|
||||
$published = $data->get('published');
|
||||
return $published !== false;
|
||||
}
|
||||
}
|
||||
|
||||
if (is_array($package)) {
|
||||
if (array_key_exists('published', $package)) {
|
||||
return $package['published'] !== false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
$value = null;
|
||||
if (is_object($package) && property_exists($package, 'published')) {
|
||||
$value = $package->published;
|
||||
}
|
||||
|
||||
return $value !== false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the package latest release is stable
|
||||
*
|
||||
|
||||
@@ -21,7 +21,6 @@ class GravCore extends AbstractPackageCollection
|
||||
{
|
||||
/** @var string */
|
||||
protected $repository = 'https://getgrav.org/downloads/grav.json';
|
||||
|
||||
/** @var array */
|
||||
private $data;
|
||||
/** @var string */
|
||||
|
||||
@@ -143,7 +143,31 @@ class Plugins extends Iterator
|
||||
$instance->setConfig($config);
|
||||
// Register autoloader.
|
||||
if (method_exists($instance, 'autoload')) {
|
||||
$instance->setAutoloader($instance->autoload());
|
||||
try {
|
||||
$instance->setAutoloader($instance->autoload());
|
||||
} catch (\Throwable $e) {
|
||||
// Log the autoload failure and disable the plugin
|
||||
$grav['log']->error(
|
||||
sprintf("Plugin '%s' autoload failed: %s", $instance->name, $e->getMessage())
|
||||
);
|
||||
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
// Register event listeners.
|
||||
$events->addSubscriber($instance);
|
||||
|
||||
@@ -10,7 +10,9 @@
|
||||
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;
|
||||
@@ -32,6 +34,7 @@ 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;
|
||||
@@ -47,6 +50,8 @@ class RecoveryManager
|
||||
private $rootPath;
|
||||
/** @var string */
|
||||
private $userPath;
|
||||
/** @var bool */
|
||||
private $failureCaptured = false;
|
||||
|
||||
/**
|
||||
* @param mixed $context Container or root path.
|
||||
@@ -77,6 +82,15 @@ class RecoveryManager
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -103,6 +117,7 @@ class RecoveryManager
|
||||
}
|
||||
|
||||
$this->closeUpgradeWindow();
|
||||
$this->failureCaptured = false;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -112,21 +127,93 @@ class RecoveryManager
|
||||
*/
|
||||
public function handleShutdown(): void
|
||||
{
|
||||
if ($this->failureCaptured) {
|
||||
return;
|
||||
}
|
||||
|
||||
$error = $this->resolveLastError();
|
||||
if (!$error) {
|
||||
return;
|
||||
}
|
||||
|
||||
$type = $error['type'] ?? 0;
|
||||
$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(),
|
||||
];
|
||||
|
||||
$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);
|
||||
if (!$plugin) {
|
||||
return;
|
||||
}
|
||||
|
||||
$context = [
|
||||
'created_at' => time(),
|
||||
@@ -145,33 +232,8 @@ class RecoveryManager
|
||||
if ($plugin) {
|
||||
$this->quarantinePlugin($plugin, $context);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Activate recovery mode and record context.
|
||||
*
|
||||
* @param array $context
|
||||
* @return void
|
||||
*/
|
||||
public function activate(array $context): void
|
||||
{
|
||||
$flag = $this->flagPath();
|
||||
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");
|
||||
}
|
||||
$this->failureCaptured = true;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -267,7 +329,7 @@ class RecoveryManager
|
||||
*/
|
||||
private function isFatal(int $type): bool
|
||||
{
|
||||
return in_array($type, [E_ERROR, E_CORE_ERROR, E_COMPILE_ERROR, E_PARSE], true);
|
||||
return in_array($type, [E_ERROR, E_CORE_ERROR, E_COMPILE_ERROR, E_PARSE, E_USER_ERROR], true);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -294,7 +356,7 @@ class RecoveryManager
|
||||
*/
|
||||
private function flagPath(): string
|
||||
{
|
||||
return $this->rootPath . '/system/recovery.flag';
|
||||
return $this->userPath . '/data/recovery.flag';
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -302,7 +364,7 @@ class RecoveryManager
|
||||
*/
|
||||
private function windowPath(): string
|
||||
{
|
||||
return $this->rootPath . '/system/recovery.window';
|
||||
return $this->userPath . '/data/recovery.window';
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -402,7 +464,10 @@ class RecoveryManager
|
||||
'expires_at' => $createdAt + $ttl,
|
||||
];
|
||||
|
||||
file_put_contents($this->windowPath(), json_encode($payload, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n");
|
||||
$path = $this->windowPath();
|
||||
Folder::create(dirname($path));
|
||||
file_put_contents($path, json_encode($payload, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n");
|
||||
$this->failureCaptured = false;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -51,6 +51,7 @@ class Security
|
||||
{
|
||||
if (Grav::instance()['config']->get('security.sanitize_svg')) {
|
||||
$sanitizer = new DOMSanitizer(DOMSanitizer::SVG);
|
||||
$sanitizer->addDisallowedAttributes(['href', 'xlink:href']);
|
||||
$sanitized = $sanitizer->sanitize($svg);
|
||||
if (is_string($sanitized)) {
|
||||
$svg = $sanitized;
|
||||
@@ -70,6 +71,7 @@ class Security
|
||||
{
|
||||
if (file_exists($file) && Grav::instance()['config']->get('security.sanitize_svg')) {
|
||||
$sanitizer = new DOMSanitizer(DOMSanitizer::SVG);
|
||||
$sanitizer->addDisallowedAttributes(['href', 'xlink:href']);
|
||||
$original_svg = file_get_contents($file);
|
||||
$clean_svg = $sanitizer->sanitize($original_svg);
|
||||
|
||||
|
||||
@@ -143,6 +143,7 @@ class GravExtension extends AbstractExtension implements GlobalsInterface
|
||||
new TwigFilter('wordcount', [$this, 'wordCountFilter']),
|
||||
new TwigFilter('json_decode', [$this, 'jsonDecodeFilter']),
|
||||
new TwigFilter('array_unique', 'array_unique'),
|
||||
new TwigFilter('array_group_by', [$this, 'arrayGroupByFilter'], ['needs_environment' => true]),
|
||||
new TwigFilter('basename', 'basename'),
|
||||
new TwigFilter('dirname', 'dirname'),
|
||||
new TwigFilter('print_r', [$this, 'print_r']),
|
||||
@@ -192,6 +193,7 @@ class GravExtension extends AbstractExtension implements GlobalsInterface
|
||||
new TwigFunction('array_key_exists', 'array_key_exists'),
|
||||
new TwigFunction('array_unique', 'array_unique'),
|
||||
new TwigFunction('array_intersect', [$this, 'arrayIntersectFunc']),
|
||||
new TwigFunction('array_group_by', [$this, 'arrayGroupByFilter'], ['needs_environment' => true]),
|
||||
new TwigFunction('array_diff', 'array_diff'),
|
||||
new TwigFunction('authorize', [$this, 'authorize']),
|
||||
new TwigFunction('debug', [$this, 'dump'], ['needs_context' => true, 'needs_environment' => true]),
|
||||
@@ -1281,6 +1283,67 @@ class GravExtension extends AbstractExtension implements GlobalsInterface
|
||||
return json_decode(html_entity_decode($str, ENT_COMPAT | ENT_HTML401, 'UTF-8'), $assoc, $depth, $options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Group items in an array by the results of a callback function
|
||||
*
|
||||
* @param Environment $env The Twig environment
|
||||
* @param array|\Traversable $array The array or collection to group
|
||||
* @param string|callable $callback Property name or callable to determine group key
|
||||
* @return array Grouped array with keys as group identifiers and values as arrays of items
|
||||
*/
|
||||
public function arrayGroupByFilter(Environment $env, $array, $callback): array
|
||||
{
|
||||
$groups = [];
|
||||
|
||||
// Convert to array if it's a Traversable object (like Grav Collections)
|
||||
if ($array instanceof \Traversable) {
|
||||
$array = iterator_to_array($array);
|
||||
}
|
||||
|
||||
if (!is_array($array)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
foreach ($array as $key => $item) {
|
||||
// If callback is a string, treat it as a property/method name
|
||||
if (is_string($callback)) {
|
||||
// Try to get the value using different methods
|
||||
if (is_array($item) && isset($item[$callback])) {
|
||||
$groupKey = $item[$callback];
|
||||
} elseif (is_object($item)) {
|
||||
if (method_exists($item, $callback)) {
|
||||
$groupKey = $item->$callback();
|
||||
} elseif (property_exists($item, $callback)) {
|
||||
$groupKey = $item->$callback;
|
||||
} elseif (method_exists($item, '__get')) {
|
||||
$groupKey = $item->$callback;
|
||||
} else {
|
||||
$groupKey = 'undefined';
|
||||
}
|
||||
} else {
|
||||
$groupKey = 'undefined';
|
||||
}
|
||||
} else {
|
||||
// Execute the callback function
|
||||
try {
|
||||
$groupKey = call_user_func($callback, $item, $key, $env);
|
||||
} catch (\Exception $e) {
|
||||
$groupKey = 'undefined';
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize group array if it doesn't exist
|
||||
if (!isset($groups[$groupKey])) {
|
||||
$groups[$groupKey] = [];
|
||||
}
|
||||
|
||||
// Add item to its group
|
||||
$groups[$groupKey][] = $item;
|
||||
}
|
||||
|
||||
return $groups;
|
||||
}
|
||||
|
||||
/**
|
||||
* Used to retrieve a cookie value
|
||||
*
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
namespace Grav\Common\Upgrade;
|
||||
|
||||
use DirectoryIterator;
|
||||
use Grav\Common\Data\Data;
|
||||
use Grav\Common\Filesystem\Folder;
|
||||
use Grav\Common\GPM\GPM;
|
||||
use Grav\Common\Grav;
|
||||
@@ -17,10 +18,13 @@ use Grav\Common\Yaml;
|
||||
use InvalidArgumentException;
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
use RecursiveCallbackFilterIterator;
|
||||
use RecursiveDirectoryIterator;
|
||||
use RecursiveIteratorIterator;
|
||||
use FilesystemIterator;
|
||||
use function array_key_exists;
|
||||
use function basename;
|
||||
use function chmod;
|
||||
use function copy;
|
||||
use function count;
|
||||
use function dirname;
|
||||
@@ -33,17 +37,18 @@ use function is_file;
|
||||
use function json_decode;
|
||||
use function json_encode;
|
||||
use function preg_match;
|
||||
use function preg_replace;
|
||||
use function property_exists;
|
||||
use function rename;
|
||||
use function rsort;
|
||||
use function sort;
|
||||
use function time;
|
||||
use function rtrim;
|
||||
use function uniqid;
|
||||
use function trim;
|
||||
use function strpos;
|
||||
use function time;
|
||||
use function trim;
|
||||
use function uniqid;
|
||||
use function unlink;
|
||||
use function ltrim;
|
||||
use function preg_replace;
|
||||
use const GRAV_ROOT;
|
||||
use const GLOB_ONLYDIR;
|
||||
use const JSON_PRETTY_PRINT;
|
||||
@@ -53,6 +58,17 @@ use const JSON_PRETTY_PRINT;
|
||||
*/
|
||||
class SafeUpgradeService
|
||||
{
|
||||
/**
|
||||
* Version identifier for this SafeUpgradeService implementation.
|
||||
* This is used to verify that the correct version is loaded during upgrades.
|
||||
*
|
||||
* IMPORTANT: Increment this with each release that changes SafeUpgradeService.
|
||||
* Format: YYYYMMDD (release date)
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
public const IMPLEMENTATION_VERSION = '20251118'; // 2025-11-18 - Fixed snapshot creation (copy vs move) and implemented pruning
|
||||
|
||||
/** @var string */
|
||||
private $rootPath;
|
||||
/** @var string */
|
||||
@@ -61,6 +77,8 @@ class SafeUpgradeService
|
||||
private $manifestStore;
|
||||
/** @var \Grav\Common\Config\ConfigInterface|null */
|
||||
private $config;
|
||||
/** @var array|null */
|
||||
private $lastManifest = null;
|
||||
|
||||
/** @var array */
|
||||
private $ignoredDirs = [
|
||||
@@ -70,6 +88,8 @@ class SafeUpgradeService
|
||||
'tmp',
|
||||
'cache',
|
||||
'user',
|
||||
'.github',
|
||||
'.phan',
|
||||
];
|
||||
/** @var callable|null */
|
||||
private $progressCallback = null;
|
||||
@@ -117,11 +137,28 @@ class SafeUpgradeService
|
||||
/**
|
||||
* Run preflight validations before attempting an upgrade.
|
||||
*
|
||||
* @return array{plugins_pending: array<string, array>, psr_log_conflicts: array<string, array>, warnings: string[]}
|
||||
* @param string|null $targetVersion The target Grav version being upgraded to (e.g., '1.8.0')
|
||||
* @return array{plugins_pending: array<string, array>, psr_log_conflicts: array<string, array>, warnings: string[], is_major_minor_upgrade: bool}
|
||||
*/
|
||||
public function preflight(): array
|
||||
public function preflight(?string $targetVersion = null): array
|
||||
{
|
||||
$warnings = [];
|
||||
$isMajorMinorUpgrade = false;
|
||||
|
||||
// Determine if this is a major/minor version upgrade (e.g., 1.7.x -> 1.8.y)
|
||||
if ($targetVersion !== null) {
|
||||
$currentVersion = GRAV_VERSION;
|
||||
$currentParts = explode('.', $currentVersion);
|
||||
$targetParts = explode('.', $targetVersion);
|
||||
|
||||
$currentMajor = (int)($currentParts[0] ?? 0);
|
||||
$currentMinor = (int)($currentParts[1] ?? 0);
|
||||
$targetMajor = (int)($targetParts[0] ?? 0);
|
||||
$targetMinor = (int)($targetParts[1] ?? 0);
|
||||
|
||||
$isMajorMinorUpgrade = ($currentMajor !== $targetMajor) || ($currentMinor !== $targetMinor);
|
||||
}
|
||||
|
||||
try {
|
||||
$pending = $this->detectPendingPluginUpdates();
|
||||
} catch (RuntimeException $e) {
|
||||
@@ -131,8 +168,13 @@ class SafeUpgradeService
|
||||
|
||||
$psrLogConflicts = $this->detectPsrLogConflicts();
|
||||
$monologConflicts = $this->detectMonologConflicts();
|
||||
|
||||
if ($pending) {
|
||||
$warnings[] = 'One or more plugins/themes are not up to date.';
|
||||
if ($isMajorMinorUpgrade) {
|
||||
$warnings[] = 'Because this is a major Grav upgrade, update pending plugins and themes before continuing.';
|
||||
} else {
|
||||
$warnings[] = 'Pending plugin/theme updates detected. Update them before running Grav upgrade.';
|
||||
}
|
||||
}
|
||||
if ($psrLogConflicts) {
|
||||
$warnings[] = 'Potential psr/log signature conflicts detected.';
|
||||
@@ -146,6 +188,7 @@ class SafeUpgradeService
|
||||
'psr_log_conflicts' => $psrLogConflicts,
|
||||
'monolog_conflicts' => $monologConflicts,
|
||||
'warnings' => $warnings,
|
||||
'is_major_minor_upgrade' => $isMajorMinorUpgrade,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -168,11 +211,21 @@ class SafeUpgradeService
|
||||
$packagePath = $stagePath . DIRECTORY_SEPARATOR . 'package';
|
||||
$backupPath = $this->stagingRoot . DIRECTORY_SEPARATOR . 'snapshot-' . $stageId;
|
||||
|
||||
Folder::create($packagePath);
|
||||
Folder::create(dirname($packagePath));
|
||||
|
||||
// Copy extracted package into staging area.
|
||||
Folder::rcopy($extractedPath, $packagePath, true);
|
||||
$this->reportProgress('installing', 'Preparing staged package...', null);
|
||||
$stagingMode = $this->stageExtractedPackage($extractedPath, $packagePath);
|
||||
$this->reportProgress('installing', 'Preparing staged package...', null, ['mode' => $stagingMode]);
|
||||
|
||||
$packageEntries = $this->collectPackageEntries($packagePath);
|
||||
|
||||
// CRITICAL SAFETY CHECK: Verify 'user' is never in package entries before proceeding
|
||||
if (in_array('user', $packageEntries, true)) {
|
||||
throw new RuntimeException(
|
||||
'SAFETY VIOLATION: user directory found in package entries. ' .
|
||||
'This should never happen and could result in data loss. Aborting upgrade.'
|
||||
);
|
||||
}
|
||||
|
||||
$this->carryOverRootDotfiles($packagePath);
|
||||
|
||||
@@ -180,16 +233,26 @@ class SafeUpgradeService
|
||||
$this->hydrateIgnoredDirectories($packagePath, $ignores);
|
||||
$this->carryOverRootFiles($packagePath, $ignores);
|
||||
|
||||
$entries = $this->collectPackageEntries($packagePath);
|
||||
if (!$entries) {
|
||||
// IMPORTANT: Snapshot should ONLY include files from the original package, NOT custom directories
|
||||
// that were carried over. This prevents snapshotting huge custom folders like /media-test, /downloads, etc.
|
||||
// The carryOverRootFiles() method preserves custom files during upgrade, but they should not be snapshotted
|
||||
// because they are not being replaced and don't need to be rolled back.
|
||||
if (!$packageEntries) {
|
||||
throw new RuntimeException('Staged package does not contain any files to promote.');
|
||||
}
|
||||
|
||||
$this->reportProgress('snapshot', 'Creating backup snapshot...', null);
|
||||
$this->createBackupSnapshot($entries, $backupPath);
|
||||
$this->syncGitDirectory($this->rootPath, $backupPath);
|
||||
// FINAL SAFETY CHECK: Verify 'user' is not in the entries that will be deployed
|
||||
if (in_array('user', $packageEntries, true)) {
|
||||
throw new RuntimeException(
|
||||
'SAFETY VIOLATION: user directory found in deployment entries. ' .
|
||||
'Aborting upgrade to protect user data.'
|
||||
);
|
||||
}
|
||||
|
||||
$manifest = $this->buildManifest($stageId, $targetVersion, $packagePath, $backupPath, $entries);
|
||||
$this->reportProgress('snapshot', 'Creating backup snapshot...', null);
|
||||
$this->createBackupSnapshot($packageEntries, $backupPath);
|
||||
|
||||
$manifest = $this->buildManifest($stageId, $targetVersion, $packagePath, $backupPath, $packageEntries);
|
||||
$manifestPath = $stagePath . DIRECTORY_SEPARATOR . 'manifest.json';
|
||||
Folder::create(dirname($manifestPath));
|
||||
file_put_contents($manifestPath, json_encode($manifest, JSON_PRETTY_PRINT));
|
||||
@@ -197,18 +260,119 @@ class SafeUpgradeService
|
||||
$this->reportProgress('installing', 'Copying update files...', null);
|
||||
|
||||
try {
|
||||
$this->copyEntries($entries, $packagePath, $this->rootPath);
|
||||
$this->copyEntries($packageEntries, $packagePath, $this->rootPath, 'installing', 'Deploying', true);
|
||||
} catch (Throwable $e) {
|
||||
$this->copyEntries($entries, $backupPath, $this->rootPath);
|
||||
$this->syncGitDirectory($backupPath, $this->rootPath);
|
||||
// Rollback: restore from snapshot
|
||||
$this->reportProgress('rollback', 'Upgrade failed, restoring from snapshot...', null);
|
||||
$this->copyEntries($packageEntries, $backupPath, $this->rootPath, 'rollback', 'Restoring', false);
|
||||
throw new RuntimeException('Failed to promote staged Grav release.', 0, $e);
|
||||
}
|
||||
|
||||
$this->reportProgress('finalizing', 'Finalizing upgrade...', null);
|
||||
$this->syncGitDirectory($backupPath, $this->rootPath);
|
||||
$this->persistManifest($manifest);
|
||||
$this->lastManifest = $manifest;
|
||||
$this->pruneOldSnapshots();
|
||||
Folder::delete($stagePath);
|
||||
|
||||
// Clean up staging directory
|
||||
// Wrap in try-catch because autoloader may have stale paths after file copy
|
||||
try {
|
||||
Folder::delete($stagePath);
|
||||
} catch (\Throwable $e) {
|
||||
// Staging cleanup failed, but upgrade succeeded
|
||||
// Directory will be cleaned up on next request
|
||||
error_log('Warning: Failed to delete staging directory: ' . $e->getMessage());
|
||||
}
|
||||
|
||||
return $manifest;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a manual snapshot of the current Grav installation.
|
||||
*
|
||||
* @param string|null $label
|
||||
* @return array
|
||||
*/
|
||||
public function createSnapshot(?string $label = null): array
|
||||
{
|
||||
$entries = $this->collectPackageEntries($this->rootPath);
|
||||
if (!$entries) {
|
||||
throw new RuntimeException('Unable to locate files to snapshot.');
|
||||
}
|
||||
|
||||
$stageId = uniqid('snapshot-', false);
|
||||
$backupPath = $this->stagingRoot . DIRECTORY_SEPARATOR . 'snapshot-' . $stageId;
|
||||
|
||||
$this->reportProgress('snapshot', 'Creating manual snapshot...', null, [
|
||||
'operation' => 'snapshot',
|
||||
'label' => $label,
|
||||
'mode' => 'manual',
|
||||
]);
|
||||
|
||||
$this->createBackupSnapshot($entries, $backupPath);
|
||||
|
||||
$manifest = $this->buildManifest($stageId, GRAV_VERSION, $this->rootPath, $backupPath, $entries);
|
||||
$manifest['package_path'] = null;
|
||||
if ($label !== null && $label !== '') {
|
||||
$manifest['label'] = $label;
|
||||
}
|
||||
$manifest['operation'] = 'snapshot';
|
||||
$manifest['mode'] = 'manual';
|
||||
|
||||
$this->persistManifest($manifest);
|
||||
$this->lastManifest = $manifest;
|
||||
$this->pruneOldSnapshots();
|
||||
|
||||
$this->reportProgress('complete', sprintf('Snapshot %s created.', $stageId), 100, [
|
||||
'operation' => 'snapshot',
|
||||
'snapshot' => $stageId,
|
||||
'version' => $manifest['target_version'] ?? null,
|
||||
'mode' => 'manual',
|
||||
]);
|
||||
|
||||
return $manifest;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a snapshot specifically for automated upgrades.
|
||||
*
|
||||
* @param string $targetVersion
|
||||
* @param string|null $label
|
||||
* @return array
|
||||
*/
|
||||
public function createUpgradeSnapshot(string $targetVersion, ?string $label = null): array
|
||||
{
|
||||
$entries = $this->collectPackageEntries($this->rootPath);
|
||||
if (!$entries) {
|
||||
throw new RuntimeException('Unable to locate files to snapshot.');
|
||||
}
|
||||
|
||||
$stageId = uniqid('upgrade-', false);
|
||||
$backupPath = $this->stagingRoot . DIRECTORY_SEPARATOR . 'snapshot-' . $stageId;
|
||||
|
||||
$this->reportProgress('snapshot', sprintf('Capturing snapshot before upgrading to %s...', $targetVersion), null, [
|
||||
'operation' => 'upgrade',
|
||||
'target_version' => $targetVersion,
|
||||
]);
|
||||
|
||||
$this->createBackupSnapshot($entries, $backupPath);
|
||||
|
||||
$manifest = $this->buildManifest($stageId, $targetVersion, $this->rootPath, $backupPath, $entries);
|
||||
$manifest['package_path'] = null;
|
||||
if ($label !== null && $label !== '') {
|
||||
$manifest['label'] = $label;
|
||||
}
|
||||
$manifest['operation'] = 'upgrade';
|
||||
$manifest['mode'] = 'pre-upgrade';
|
||||
|
||||
$this->persistManifest($manifest);
|
||||
$this->lastManifest = $manifest;
|
||||
$this->pruneOldSnapshots();
|
||||
|
||||
$this->reportProgress('snapshot', sprintf('Snapshot %s captured.', $stageId), 100, [
|
||||
'operation' => 'upgrade',
|
||||
'snapshot' => $stageId,
|
||||
'target_version' => $targetVersion,
|
||||
]);
|
||||
|
||||
return $manifest;
|
||||
}
|
||||
@@ -222,7 +386,18 @@ class SafeUpgradeService
|
||||
continue;
|
||||
}
|
||||
|
||||
$entries[] = $fileinfo->getFilename();
|
||||
$name = $fileinfo->getFilename();
|
||||
if (in_array($name, $this->ignoredDirs, true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// CRITICAL SAFETY CHECK: Never allow 'user' directory to be collected
|
||||
// This prevents any scenario where user/ could be overwritten during upgrade
|
||||
if ($name === 'user') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$entries[] = $name;
|
||||
}
|
||||
|
||||
sort($entries);
|
||||
@@ -230,21 +405,68 @@ class SafeUpgradeService
|
||||
return $entries;
|
||||
}
|
||||
|
||||
private function stageExtractedPackage(string $sourcePath, string $packagePath): string
|
||||
{
|
||||
if (is_dir($packagePath)) {
|
||||
Folder::delete($packagePath);
|
||||
}
|
||||
|
||||
if (@rename($sourcePath, $packagePath)) {
|
||||
return 'move';
|
||||
}
|
||||
|
||||
Folder::create($packagePath);
|
||||
$entries = $this->collectPackageEntries($sourcePath);
|
||||
$this->copyEntries($entries, $sourcePath, $packagePath, 'installing', 'Staging', true);
|
||||
Folder::delete($sourcePath);
|
||||
|
||||
return 'copy';
|
||||
}
|
||||
|
||||
private function createBackupSnapshot(array $entries, string $backupPath): void
|
||||
{
|
||||
Folder::create($backupPath);
|
||||
$this->copyEntries($entries, $this->rootPath, $backupPath);
|
||||
$this->copyEntries($entries, $this->rootPath, $backupPath, 'snapshot', 'Snapshotting', false);
|
||||
}
|
||||
|
||||
private function copyEntries(array $entries, string $sourceBase, string $targetBase): void
|
||||
private function copyEntries(array $entries, string $sourceBase, string $targetBase, ?string $progressStage = null, ?string $progressPrefix = null, bool $useMove = false): void
|
||||
{
|
||||
foreach ($entries as $entry) {
|
||||
$total = count($entries);
|
||||
foreach ($entries as $index => $entry) {
|
||||
// CRITICAL SAFETY CHECK: Absolutely prevent any operations on 'user' directory
|
||||
// This is a fail-safe to ensure user data is never touched during upgrades
|
||||
if ($entry === 'user' || strpos($entry, 'user' . DIRECTORY_SEPARATOR) === 0) {
|
||||
throw new RuntimeException(
|
||||
'SAFETY VIOLATION: Attempted to copy user directory during upgrade. ' .
|
||||
'This should never happen. Aborting upgrade to protect user data.'
|
||||
);
|
||||
}
|
||||
|
||||
$source = $sourceBase . DIRECTORY_SEPARATOR . $entry;
|
||||
if (!is_file($source) && !is_dir($source) && !is_link($source)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($progressStage) {
|
||||
$message = sprintf(
|
||||
'%s %s (%d/%d)',
|
||||
$progressPrefix ?? 'Processing',
|
||||
$entry,
|
||||
$index + 1,
|
||||
max($total, 1)
|
||||
);
|
||||
$percent = $total > 0 ? (int)floor((($index + 1) / $total) * 100) : null;
|
||||
$this->reportProgress($progressStage, $message, $percent ?: null, [
|
||||
'entry' => $entry,
|
||||
'index' => $index + 1,
|
||||
'total' => $total,
|
||||
]);
|
||||
}
|
||||
|
||||
$destination = $targetBase . DIRECTORY_SEPARATOR . $entry;
|
||||
|
||||
// Use the same simple approach as traditional upgrade:
|
||||
// Delete old, copy new, let filesystem handle ownership
|
||||
$this->removeEntry($destination);
|
||||
|
||||
if (is_link($source)) {
|
||||
@@ -254,19 +476,38 @@ class SafeUpgradeService
|
||||
}
|
||||
} elseif (is_dir($source)) {
|
||||
Folder::create(dirname($destination));
|
||||
Folder::rcopy($source, $destination, true);
|
||||
|
||||
if ($useMove) {
|
||||
// Use move() like traditional upgrade - faster than copy
|
||||
Folder::move($source, $destination);
|
||||
} else {
|
||||
Folder::rcopy($source, $destination, true);
|
||||
}
|
||||
|
||||
// Set bin/ permissions like traditional upgrade does
|
||||
if ($entry === 'bin') {
|
||||
$binFiles = glob($destination . DIRECTORY_SEPARATOR . '*') ?: [];
|
||||
foreach ($binFiles as $binFile) {
|
||||
@chmod($binFile, 0755);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Folder::create(dirname($destination));
|
||||
if (!@copy($source, $destination)) {
|
||||
throw new RuntimeException(sprintf('Failed to copy file "%s" to "%s".', $source, $destination));
|
||||
}
|
||||
$perm = @fileperms($source);
|
||||
if ($perm !== false) {
|
||||
@chmod($destination, $perm & 0777);
|
||||
}
|
||||
$mtime = @filemtime($source);
|
||||
if ($mtime !== false) {
|
||||
@touch($destination, $mtime);
|
||||
if ($useMove) {
|
||||
if (!@rename($source, $destination)) {
|
||||
if (!@copy($source, $destination)) {
|
||||
throw new RuntimeException(sprintf('Failed to move file "%s" to "%s".', $source, $destination));
|
||||
}
|
||||
@unlink($source);
|
||||
}
|
||||
} else {
|
||||
if (!@copy($source, $destination)) {
|
||||
throw new RuntimeException(sprintf('Failed to copy file "%s" to "%s".', $source, $destination));
|
||||
}
|
||||
$perms = @fileperms($source);
|
||||
if ($perms !== false) {
|
||||
@chmod($destination, $perms & 0777);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -322,9 +563,9 @@ class SafeUpgradeService
|
||||
}
|
||||
|
||||
$this->reportProgress('rollback', 'Restoring snapshot...', null);
|
||||
$this->copyEntries($entries, $backupPath, $this->rootPath);
|
||||
$this->syncGitDirectory($backupPath, $this->rootPath);
|
||||
$this->copyEntries($entries, $backupPath, $this->rootPath, 'rollback', 'Restoring', false);
|
||||
$this->markRollback($manifest['id']);
|
||||
$this->lastManifest = $manifest;
|
||||
|
||||
return $manifest;
|
||||
}
|
||||
@@ -334,12 +575,20 @@ class SafeUpgradeService
|
||||
*/
|
||||
public function clearRecoveryFlag(): void
|
||||
{
|
||||
$flag = $this->rootPath . '/system/recovery.flag';
|
||||
$flag = $this->rootPath . '/user/data/recovery.flag';
|
||||
if (is_file($flag)) {
|
||||
@unlink($flag);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array|null
|
||||
*/
|
||||
public function getLastManifest(): ?array
|
||||
{
|
||||
return $this->lastManifest;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, array>
|
||||
*/
|
||||
@@ -357,6 +606,18 @@ class SafeUpgradeService
|
||||
continue;
|
||||
}
|
||||
foreach ($packages as $slug => $package) {
|
||||
if (!$this->isGpmPackagePublished($package)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($type === 'plugins' && !$this->isPluginEnabled($slug)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($type === 'themes' && !$this->isThemeEnabled($slug)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$pending[$slug] = [
|
||||
'type' => $type,
|
||||
'current' => $package->version ?? null,
|
||||
@@ -368,6 +629,41 @@ class SafeUpgradeService
|
||||
return $pending;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if the provided GPM package metadata is marked as published.
|
||||
*
|
||||
* By default the GPM repository omits the `published` flag, so we only treat the package as unpublished
|
||||
* when the value exists and evaluates to `false`.
|
||||
*
|
||||
* @param mixed $package
|
||||
* @return bool
|
||||
*/
|
||||
protected function isGpmPackagePublished($package): bool
|
||||
{
|
||||
if (is_object($package) && method_exists($package, 'getData')) {
|
||||
$data = $package->getData();
|
||||
if ($data instanceof Data) {
|
||||
$published = $data->get('published');
|
||||
return $published !== false;
|
||||
}
|
||||
}
|
||||
|
||||
if (is_array($package)) {
|
||||
if (array_key_exists('published', $package)) {
|
||||
return $package['published'] !== false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
$value = null;
|
||||
if (is_object($package) && property_exists($package, 'published')) {
|
||||
$value = $package->published;
|
||||
}
|
||||
|
||||
return $value !== false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check plugins for psr/log requirements that conflict with Grav 1.8 vendor stack.
|
||||
*
|
||||
@@ -444,6 +740,37 @@ class SafeUpgradeService
|
||||
return true;
|
||||
}
|
||||
|
||||
protected function isThemeEnabled(string $slug): bool
|
||||
{
|
||||
if ($this->config) {
|
||||
try {
|
||||
$active = $this->config->get('system.pages.theme');
|
||||
if ($active !== null) {
|
||||
return $active === $slug;
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
$configPath = $this->rootPath . '/user/config/system.yaml';
|
||||
if (is_file($configPath)) {
|
||||
try {
|
||||
$data = Yaml::parseFile($configPath);
|
||||
if (is_array($data)) {
|
||||
$active = $data['pages']['theme'] ?? ($data['system']['pages']['theme'] ?? null);
|
||||
if ($active !== null) {
|
||||
return $active === $slug;
|
||||
}
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
// ignore parse errors and assume current theme
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect usage of deprecated Monolog `add*` methods removed in newer releases.
|
||||
*
|
||||
@@ -461,16 +788,24 @@ class SafeUpgradeService
|
||||
continue;
|
||||
}
|
||||
|
||||
$iterator = new RecursiveIteratorIterator(
|
||||
new RecursiveDirectoryIterator($path, FilesystemIterator::SKIP_DOTS)
|
||||
);
|
||||
$directory = new RecursiveDirectoryIterator($path, FilesystemIterator::SKIP_DOTS);
|
||||
$filter = new RecursiveCallbackFilterIterator($directory, static function ($current, $key, $iterator) {
|
||||
// Skip hidden files/dirs (starting with .)
|
||||
if ($current->getFilename()[0] === '.') {
|
||||
return false;
|
||||
}
|
||||
if ($iterator->hasChildren()) {
|
||||
// Exclude vendor and node_modules directories
|
||||
return !in_array($current->getFilename(), ['vendor', 'node_modules'], true);
|
||||
}
|
||||
// Only include PHP files
|
||||
return $current->getExtension() === 'php';
|
||||
});
|
||||
|
||||
$iterator = new RecursiveIteratorIterator($filter);
|
||||
|
||||
foreach ($iterator as $file) {
|
||||
/** @var \SplFileInfo $file */
|
||||
if (!$file->isFile() || strtolower($file->getExtension()) !== 'php') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$contents = @file_get_contents($file->getPathname());
|
||||
if ($contents === false) {
|
||||
continue;
|
||||
@@ -506,6 +841,14 @@ class SafeUpgradeService
|
||||
continue;
|
||||
}
|
||||
|
||||
// CRITICAL: Ensure 'user' is always in the ignored directories list
|
||||
if (!in_array('user', $strategic, true)) {
|
||||
throw new RuntimeException(
|
||||
'SAFETY VIOLATION: user directory is not in the ignored directories list. ' .
|
||||
'This is a critical configuration error that could result in data loss.'
|
||||
);
|
||||
}
|
||||
|
||||
$live = $this->rootPath . '/' . $relative;
|
||||
$stage = $packagePath . '/' . $relative;
|
||||
|
||||
@@ -515,14 +858,8 @@ class SafeUpgradeService
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip caches to avoid stale data.
|
||||
if (in_array($relative, ['cache', 'tmp'], true)) {
|
||||
Folder::create($stage);
|
||||
continue;
|
||||
}
|
||||
|
||||
Folder::create(dirname($stage));
|
||||
Folder::rcopy($live, $stage, true);
|
||||
// Use empty placeholders to preserve directory structure without duplicating data.
|
||||
Folder::create($stage);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -587,6 +924,11 @@ class SafeUpgradeService
|
||||
});
|
||||
$skip = array_values(array_unique($skip));
|
||||
|
||||
// CRITICAL: Ensure 'user' is always in the skip list
|
||||
if (!in_array('user', $skip, true)) {
|
||||
$skip[] = 'user';
|
||||
}
|
||||
|
||||
$iterator = new DirectoryIterator($this->rootPath);
|
||||
foreach ($iterator as $entry) {
|
||||
if ($entry->isDot()) {
|
||||
@@ -598,6 +940,11 @@ class SafeUpgradeService
|
||||
continue;
|
||||
}
|
||||
|
||||
// CRITICAL SAFETY CHECK: Never copy 'user' directory
|
||||
if ($name === 'user') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (in_array($name, $skip, true)) {
|
||||
continue;
|
||||
}
|
||||
@@ -676,25 +1023,6 @@ class SafeUpgradeService
|
||||
* @param string $destination
|
||||
* @return void
|
||||
*/
|
||||
private function syncGitDirectory(string $source, string $destination): void
|
||||
{
|
||||
if (!$source || !$destination) {
|
||||
return;
|
||||
}
|
||||
|
||||
$sourceGit = rtrim($source, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . '.git';
|
||||
if (!is_dir($sourceGit)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$destinationGit = rtrim($destination, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . '.git';
|
||||
if (is_dir($destinationGit)) {
|
||||
Folder::delete($destinationGit);
|
||||
}
|
||||
|
||||
Folder::rcopy($sourceGit, $destinationGit, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist manifest into Grav data directory.
|
||||
*
|
||||
@@ -817,25 +1145,75 @@ class SafeUpgradeService
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep only the three newest snapshots.
|
||||
* Keep only the newest snapshots based on configuration.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
private function pruneOldSnapshots(): void
|
||||
{
|
||||
$files = glob($this->manifestStore . DIRECTORY_SEPARATOR . '*.json') ?: [];
|
||||
if (count($files) <= 3) {
|
||||
$limit = 5;
|
||||
|
||||
if ($this->config) {
|
||||
$limit = (int)$this->config->get('system.updates.safe_upgrade_snapshot_limit', 5);
|
||||
} else {
|
||||
try {
|
||||
$grav = Grav::instance();
|
||||
if (isset($grav['config'])) {
|
||||
$limit = (int)$grav['config']->get('system.updates.safe_upgrade_snapshot_limit', 5);
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
// Fallback to default
|
||||
}
|
||||
}
|
||||
|
||||
if ($limit <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
sort($files);
|
||||
$excess = array_slice($files, 0, count($files) - 3);
|
||||
foreach ($excess as $file) {
|
||||
$data = json_decode(file_get_contents($file), true);
|
||||
if (isset($data['backup_path']) && is_dir($data['backup_path'])) {
|
||||
Folder::delete($data['backup_path']);
|
||||
$files = glob($this->manifestStore . DIRECTORY_SEPARATOR . '*.json') ?: [];
|
||||
if (count($files) <= $limit) {
|
||||
return;
|
||||
}
|
||||
|
||||
$manifests = [];
|
||||
foreach ($files as $file) {
|
||||
$content = file_get_contents($file);
|
||||
if ($content === false) {
|
||||
continue;
|
||||
}
|
||||
$data = json_decode($content, true);
|
||||
if (is_array($data) && isset($data['created_at'])) {
|
||||
$manifests[] = [
|
||||
'path' => $file,
|
||||
'created_at' => $data['created_at'],
|
||||
'backup_path' => $data['backup_path'] ?? null
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by created_at descending
|
||||
usort($manifests, static function ($a, $b) {
|
||||
$result = $b['created_at'] <=> $a['created_at'];
|
||||
if ($result === 0) {
|
||||
return strcmp($b['path'], $a['path']);
|
||||
}
|
||||
|
||||
return $result;
|
||||
});
|
||||
|
||||
$toDelete = array_slice($manifests, $limit);
|
||||
|
||||
foreach ($toDelete as $item) {
|
||||
// Delete manifest
|
||||
@unlink($item['path']);
|
||||
|
||||
// Delete backup directory if it exists
|
||||
if ($item['backup_path'] && is_dir($item['backup_path'])) {
|
||||
// Ensure we are deleting a directory inside staging root to be safe
|
||||
if (strpos($item['backup_path'], $this->stagingRoot) === 0) {
|
||||
Folder::delete($item['backup_path']);
|
||||
}
|
||||
}
|
||||
@unlink($file);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -693,6 +693,17 @@ abstract class Utils
|
||||
header('Content-Disposition: attachment; filename="' . ($options['download_name'] ?? $file_parts['basename']) . '"');
|
||||
}
|
||||
|
||||
if ($grav['config']->get('system.cache.enabled')) {
|
||||
$expires = $options['expires'] ?? $grav['config']->get('system.pages.expires');
|
||||
if ($expires > 0) {
|
||||
$expires_date = gmdate('D, d M Y H:i:s T', time() + $expires);
|
||||
header('Cache-Control: max-age=' . $expires);
|
||||
header('Expires: ' . $expires_date);
|
||||
header('Pragma: cache');
|
||||
}
|
||||
header('Last-Modified: ' . gmdate('D, d M Y H:i:s T', filemtime($file)));
|
||||
}
|
||||
|
||||
// multipart-download and download resuming support
|
||||
if (isset($_SERVER['HTTP_RANGE'])) {
|
||||
[$a, $range] = explode('=', $_SERVER['HTTP_RANGE'], 2);
|
||||
@@ -705,7 +716,7 @@ abstract class Utils
|
||||
$range_end = (int)$range_end;
|
||||
}
|
||||
$new_length = $range_end - $range + 1;
|
||||
header('HTTP/1.1 206 Partial Content');
|
||||
http_response_code(206);
|
||||
header("Content-Length: {$new_length}");
|
||||
header("Content-Range: bytes {$range}-{$range_end}/{$size}");
|
||||
} else {
|
||||
@@ -714,19 +725,10 @@ abstract class Utils
|
||||
header('Content-Length: ' . $size);
|
||||
|
||||
if ($grav['config']->get('system.cache.enabled')) {
|
||||
$expires = $options['expires'] ?? $grav['config']->get('system.pages.expires');
|
||||
if ($expires > 0) {
|
||||
$expires_date = gmdate('D, d M Y H:i:s T', time() + $expires);
|
||||
header('Cache-Control: max-age=' . $expires);
|
||||
header('Expires: ' . $expires_date);
|
||||
header('Pragma: cache');
|
||||
}
|
||||
header('Last-Modified: ' . gmdate('D, d M Y H:i:s T', filemtime($file)));
|
||||
|
||||
// Return 304 Not Modified if the file is already cached in the browser
|
||||
if (isset($_SERVER['HTTP_IF_MODIFIED_SINCE']) &&
|
||||
strtotime($_SERVER['HTTP_IF_MODIFIED_SINCE']) >= filemtime($file)) {
|
||||
header('HTTP/1.1 304 Not Modified');
|
||||
strtotime((string) $_SERVER['HTTP_IF_MODIFIED_SINCE']) >= filemtime($file)) {
|
||||
http_response_code(304);
|
||||
exit();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,6 +29,9 @@ class CleanCommand extends Command
|
||||
|
||||
/** @var array */
|
||||
protected $paths_to_remove = [
|
||||
'.gitattributes',
|
||||
'.github/',
|
||||
'.phan/',
|
||||
'codeception.yml',
|
||||
'tests/',
|
||||
'user/plugins/admin/vendor/bacon/bacon-qr-code/tests',
|
||||
|
||||
@@ -74,7 +74,12 @@ class SafeUpgradeRunCommand extends GravCommand
|
||||
]);
|
||||
|
||||
try {
|
||||
$result = $manager->run($options);
|
||||
$operation = $options['operation'] ?? 'upgrade';
|
||||
if ($operation === 'restore') {
|
||||
$result = $manager->runRestore($options);
|
||||
} else {
|
||||
$result = $manager->run($options);
|
||||
}
|
||||
$manager->ensureJobResult($result);
|
||||
|
||||
return ($result['status'] ?? null) === 'success' ? 0 : 1;
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
|
||||
namespace Grav\Console\Gpm;
|
||||
|
||||
use Grav\Common\Grav;
|
||||
use Grav\Common\Upgrade\SafeUpgradeService;
|
||||
use Grav\Console\GpmCommand;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use function count;
|
||||
use function json_encode;
|
||||
use const JSON_PRETTY_PRINT;
|
||||
|
||||
@@ -87,6 +87,15 @@ class PreflightCommand extends GpmCommand
|
||||
*/
|
||||
protected function createSafeUpgradeService(): SafeUpgradeService
|
||||
{
|
||||
return new SafeUpgradeService();
|
||||
$config = null;
|
||||
try {
|
||||
$config = Grav::instance()['config'] ?? null;
|
||||
} catch (\Throwable $e) {
|
||||
$config = null;
|
||||
}
|
||||
|
||||
return new SafeUpgradeService([
|
||||
'config' => $config,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,8 +15,9 @@ use Grav\Common\HTTP\Response;
|
||||
use Grav\Common\GPM\Installer;
|
||||
use Grav\Common\GPM\Upgrader;
|
||||
use Grav\Common\Grav;
|
||||
use Grav\Common\Upgrade\SafeUpgradeService;
|
||||
use Grav\Console\GpmCommand;
|
||||
// NOTE: SafeUpgradeService removed - no longer used in this file
|
||||
// Preflight is now handled in Install.php after downloading the package
|
||||
use Grav\Installer\Install;
|
||||
use RuntimeException;
|
||||
use Symfony\Component\Console\Input\ArrayInput;
|
||||
@@ -24,9 +25,11 @@ use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Question\ConfirmationQuestion;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
use ZipArchive;
|
||||
use function date;
|
||||
use function count;
|
||||
use function is_callable;
|
||||
use function strlen;
|
||||
use function stripos;
|
||||
|
||||
/**
|
||||
* Class SelfupgradeCommand
|
||||
@@ -46,6 +49,14 @@ class SelfupgradeCommand extends GpmCommand
|
||||
private $upgrader;
|
||||
/** @var string|null */
|
||||
private $lastProgressMessage = null;
|
||||
/** @var float|null */
|
||||
private $operationTimerStart = null;
|
||||
/** @var string|null */
|
||||
private $currentProgressStage = null;
|
||||
/** @var float|null */
|
||||
private $currentStageStartedAt = null;
|
||||
/** @var array */
|
||||
private $currentStageExtras = [];
|
||||
|
||||
/** @var string */
|
||||
protected $all_yes;
|
||||
@@ -87,6 +98,18 @@ class SelfupgradeCommand extends GpmCommand
|
||||
'Option to set the timeout in seconds when downloading the update (0 for no timeout)',
|
||||
30
|
||||
)
|
||||
->addOption(
|
||||
'safe',
|
||||
null,
|
||||
InputOption::VALUE_NONE,
|
||||
'Force safe upgrade staging even if disabled in configuration'
|
||||
)
|
||||
->addOption(
|
||||
'legacy',
|
||||
null,
|
||||
InputOption::VALUE_NONE,
|
||||
'Force legacy in-place upgrade even if safe upgrade is enabled'
|
||||
)
|
||||
->setDescription('Detects and performs an update of Grav itself when available')
|
||||
->setHelp('The <info>update</info> command updates Grav itself when a new version is available');
|
||||
}
|
||||
@@ -98,161 +121,241 @@ class SelfupgradeCommand extends GpmCommand
|
||||
{
|
||||
$input = $this->getInput();
|
||||
$io = $this->getIO();
|
||||
$forceSafe = (bool) $input->getOption('safe');
|
||||
$forceLegacy = (bool) $input->getOption('legacy');
|
||||
$forcedMode = null;
|
||||
|
||||
if (!class_exists(ZipArchive::class)) {
|
||||
$io->title('GPM Self Upgrade');
|
||||
$io->error('php-zip extension needs to be enabled!');
|
||||
if ($forceSafe && $forceLegacy) {
|
||||
$io->error('Cannot force safe and legacy upgrade modes simultaneously.');
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
$this->upgrader = new Upgrader($input->getOption('force'));
|
||||
$this->all_yes = $input->getOption('all-yes');
|
||||
$this->overwrite = $input->getOption('overwrite');
|
||||
$this->timeout = (int) $input->getOption('timeout');
|
||||
|
||||
$this->displayGPMRelease();
|
||||
|
||||
$safeUpgrade = $this->createSafeUpgradeService();
|
||||
$preflight = $safeUpgrade->preflight();
|
||||
if (!$this->handlePreflightReport($preflight)) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
$update = $this->upgrader->getAssets()['grav-update'];
|
||||
|
||||
$local = $this->upgrader->getLocalVersion();
|
||||
$remote = $this->upgrader->getRemoteVersion();
|
||||
$release = strftime('%c', strtotime($this->upgrader->getReleaseDate()));
|
||||
|
||||
if (!$this->upgrader->meetsRequirements()) {
|
||||
$io->writeln('<red>ATTENTION:</red>');
|
||||
$io->writeln(' Grav has increased the minimum PHP requirement.');
|
||||
$io->writeln(' You are currently running PHP <red>' . phpversion() . '</red>, but PHP <green>' . $this->upgrader->minPHPVersion() . '</green> is required.');
|
||||
$io->writeln(' Additional information: <white>http://getgrav.org/blog/changing-php-requirements</white>');
|
||||
$io->newLine();
|
||||
$io->writeln('Selfupgrade aborted.');
|
||||
$io->newLine();
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (!$this->overwrite && !$this->upgrader->isUpgradable()) {
|
||||
$io->writeln("You are already running the latest version of <green>Grav v{$local}</green>");
|
||||
$io->writeln("which was released on {$release}");
|
||||
|
||||
$config = Grav::instance()['config'];
|
||||
$schema = $config->get('versions.core.grav.schema');
|
||||
if ($schema !== GRAV_SCHEMA && version_compare($schema, GRAV_SCHEMA, '<')) {
|
||||
$io->newLine();
|
||||
$io->writeln('However post-install scripts have not been run.');
|
||||
if (!$this->all_yes) {
|
||||
$question = new ConfirmationQuestion(
|
||||
'Would you like to run the scripts? [Y|n] ',
|
||||
true
|
||||
);
|
||||
$answer = $io->askQuestion($question);
|
||||
} else {
|
||||
$answer = true;
|
||||
}
|
||||
|
||||
if ($answer) {
|
||||
// Finalize installation.
|
||||
Install::instance()->finalize();
|
||||
|
||||
$io->write(' |- Running post-install scripts... ');
|
||||
$io->writeln(" '- <green>Success!</green> ");
|
||||
$io->newLine();
|
||||
if ($forceSafe || $forceLegacy) {
|
||||
$forcedMode = $forceSafe ? true : false;
|
||||
// NOTE: Do not call Install::forceSafeUpgrade() here as it would load the old Install class
|
||||
// before the upgrade package is extracted, causing a class redeclaration error.
|
||||
// Instead, we set the config and also use an environment variable as a fallback.
|
||||
putenv('GRAV_FORCE_SAFE_UPGRADE=' . ($forcedMode ? '1' : '0'));
|
||||
try {
|
||||
$grav = Grav::instance();
|
||||
if ($grav && isset($grav['config'])) {
|
||||
$grav['config']->set('system.updates.safe_upgrade', $forcedMode);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
// Ignore container bootstrap failures; mode override still applies via env var.
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
Installer::isValidDestination(GRAV_ROOT . '/system');
|
||||
if (Installer::IS_LINK === Installer::lastErrorCode()) {
|
||||
$io->writeln('<red>ATTENTION:</red> Grav is symlinked, cannot upgrade, aborting...');
|
||||
$io->newLine();
|
||||
$io->writeln("You are currently running a symbolically linked Grav v{$local}. Latest available is v{$remote}.");
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
// not used but preloaded just in case!
|
||||
new ArrayInput([]);
|
||||
|
||||
$io->writeln("Grav v<cyan>{$remote}</cyan> is now available [release date: {$release}].");
|
||||
$io->writeln('You are currently using v<cyan>' . GRAV_VERSION . '</cyan>.');
|
||||
|
||||
if (!$this->all_yes) {
|
||||
$question = new ConfirmationQuestion(
|
||||
'Would you like to read the changelog before proceeding? [y|N] ',
|
||||
false
|
||||
);
|
||||
$answer = $io->askQuestion($question);
|
||||
|
||||
if ($answer) {
|
||||
$changelog = $this->upgrader->getChangelog(GRAV_VERSION);
|
||||
|
||||
$io->newLine();
|
||||
foreach ($changelog as $version => $log) {
|
||||
$title = $version . ' [' . $log['date'] . ']';
|
||||
$content = preg_replace_callback('/\d\.\s\[\]\(#(.*)\)/', static function ($match) {
|
||||
return "\n" . ucfirst($match[1]) . ':';
|
||||
}, $log['content']);
|
||||
|
||||
$io->writeln($title);
|
||||
$io->writeln(str_repeat('-', strlen($title)));
|
||||
$io->writeln($content);
|
||||
$io->newLine();
|
||||
}
|
||||
|
||||
$question = new ConfirmationQuestion('Press [ENTER] to continue.', true);
|
||||
$io->askQuestion($question);
|
||||
if ($forceSafe) {
|
||||
$io->note('Safe upgrade staging forced for this run.');
|
||||
} else {
|
||||
$io->warning('Legacy in-place upgrade forced for this run.');
|
||||
}
|
||||
}
|
||||
|
||||
$question = new ConfirmationQuestion('Would you like to upgrade now? [y|N] ', false);
|
||||
$answer = $io->askQuestion($question);
|
||||
|
||||
if (!$answer) {
|
||||
$io->writeln('Aborting...');
|
||||
try {
|
||||
if (!class_exists(ZipArchive::class)) {
|
||||
$io->title('GPM Self Upgrade');
|
||||
$io->error('php-zip extension needs to be enabled!');
|
||||
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
$io->newLine();
|
||||
$io->writeln("Preparing to upgrade to v<cyan>{$remote}</cyan>..");
|
||||
$this->upgrader = new Upgrader($input->getOption('force'));
|
||||
$this->all_yes = $input->getOption('all-yes');
|
||||
$this->overwrite = $input->getOption('overwrite');
|
||||
$this->timeout = (int) $input->getOption('timeout');
|
||||
|
||||
/** @var \Grav\Common\Recovery\RecoveryManager $recovery */
|
||||
$recovery = Grav::instance()['recovery'];
|
||||
$recovery->markUpgradeWindow('core-upgrade', [
|
||||
'scope' => 'core',
|
||||
'target_version' => $remote,
|
||||
]);
|
||||
$this->displayGPMRelease();
|
||||
|
||||
$io->write(" |- Downloading upgrade [{$this->formatBytes($update['size'])}]... 0%");
|
||||
$this->file = $this->download($update);
|
||||
// NOTE: Preflight checks are now run in Install.php AFTER downloading the package.
|
||||
// This ensures we use the NEW SafeUpgradeService from the package, not the old one.
|
||||
// Running preflight here would load the OLD class into memory and prevent the new one from loading.
|
||||
|
||||
$io->write(' |- Installing upgrade... ');
|
||||
$installation = $this->upgrade();
|
||||
$update = $this->upgrader->getAssets()['grav-update'];
|
||||
|
||||
$local = $this->upgrader->getLocalVersion();
|
||||
$remote = $this->upgrader->getRemoteVersion();
|
||||
$release = strftime('%c', strtotime($this->upgrader->getReleaseDate()));
|
||||
|
||||
if (!$this->upgrader->meetsRequirements()) {
|
||||
$io->writeln('<red>ATTENTION:</red>');
|
||||
$io->writeln(' Grav has increased the minimum PHP requirement.');
|
||||
$io->writeln(' You are currently running PHP <red>' . phpversion() . '</red>, but PHP <green>' . $this->upgrader->minPHPVersion() . '</green> is required.');
|
||||
$io->writeln(' Additional information: <white>http://getgrav.org/blog/changing-php-requirements</white>');
|
||||
$io->newLine();
|
||||
$io->writeln('Selfupgrade aborted.');
|
||||
$io->newLine();
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (!$this->overwrite && !$this->upgrader->isUpgradable()) {
|
||||
$io->writeln("You are already running the latest version of <green>Grav v{$local}</green>");
|
||||
$io->writeln("which was released on {$release}");
|
||||
|
||||
$config = Grav::instance()['config'];
|
||||
$schema = $config->get('versions.core.grav.schema');
|
||||
if ($schema !== GRAV_SCHEMA && version_compare($schema, GRAV_SCHEMA, '<')) {
|
||||
$io->newLine();
|
||||
$io->writeln('However post-install scripts have not been run.');
|
||||
if (!$this->all_yes) {
|
||||
$question = new ConfirmationQuestion(
|
||||
'Would you like to run the scripts? [Y|n] ',
|
||||
true
|
||||
);
|
||||
$answer = $io->askQuestion($question);
|
||||
} else {
|
||||
$answer = true;
|
||||
}
|
||||
|
||||
if ($answer) {
|
||||
// Finalize installation.
|
||||
Install::instance()->finalize();
|
||||
|
||||
$io->write(' |- Running post-install scripts... ');
|
||||
$io->writeln(" |- <green>Success!</green> ");
|
||||
$io->newLine();
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
Installer::isValidDestination(GRAV_ROOT . '/system');
|
||||
if (Installer::IS_LINK === Installer::lastErrorCode()) {
|
||||
$io->writeln('<red>ATTENTION:</red> Grav is symlinked, cannot upgrade, aborting...');
|
||||
$io->newLine();
|
||||
$io->writeln("You are currently running a symbolically linked Grav v{$local}. Latest available is v{$remote}.");
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
// not used but preloaded just in case!
|
||||
new ArrayInput([]);
|
||||
|
||||
$io->writeln("Grav v<cyan>{$remote}</cyan> is now available [release date: {$release}].");
|
||||
$io->writeln('You are currently using v<cyan>' . GRAV_VERSION . '</cyan>.');
|
||||
|
||||
if (!$this->all_yes) {
|
||||
$question = new ConfirmationQuestion(
|
||||
'Would you like to read the changelog before proceeding? [y|N] ',
|
||||
false
|
||||
);
|
||||
$answer = $io->askQuestion($question);
|
||||
|
||||
if ($answer) {
|
||||
$changelog = $this->upgrader->getChangelog(GRAV_VERSION);
|
||||
|
||||
$io->newLine();
|
||||
foreach ($changelog as $version => $log) {
|
||||
$title = $version . ' [' . $log['date'] . ']';
|
||||
$content = preg_replace_callback('/\d\.\s\[\]\(#(.*)\)/', static function ($match) {
|
||||
return "\n" . ucfirst((string) $match[1]) . ':';
|
||||
}, (string) $log['content']);
|
||||
|
||||
$io->writeln($title);
|
||||
$io->writeln(str_repeat('-', strlen($title)));
|
||||
$io->writeln($content);
|
||||
$io->newLine();
|
||||
}
|
||||
|
||||
$question = new ConfirmationQuestion('Press [ENTER] to continue.', true);
|
||||
$io->askQuestion($question);
|
||||
}
|
||||
|
||||
$question = new ConfirmationQuestion('Would you like to upgrade now? [y|N] ', false);
|
||||
$answer = $io->askQuestion($question);
|
||||
|
||||
if (!$answer) {
|
||||
$io->writeln('Aborting...');
|
||||
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
$error = 0;
|
||||
if (!$installation) {
|
||||
$io->writeln(" '- <red>Installation failed or aborted.</red>");
|
||||
$io->newLine();
|
||||
$error = 1;
|
||||
} else {
|
||||
$io->writeln(" '- <green>Success!</green> ");
|
||||
$io->newLine();
|
||||
$safeUpgrade->clearRecoveryFlag();
|
||||
}
|
||||
$io->writeln("Preparing to upgrade to v<cyan>{$remote}</cyan>..");
|
||||
|
||||
if ($this->tmp && is_dir($this->tmp)) {
|
||||
Folder::delete($this->tmp);
|
||||
}
|
||||
/** @var \Grav\Common\Recovery\RecoveryManager $recovery */
|
||||
$recovery = Grav::instance()['recovery'];
|
||||
$recovery->markUpgradeWindow('core-upgrade', [
|
||||
'scope' => 'core',
|
||||
'target_version' => $remote,
|
||||
]);
|
||||
|
||||
return $error;
|
||||
$io->write(" |- Downloading upgrade [{$this->formatBytes($update['size'])}]... 0%");
|
||||
$this->file = $this->download($update);
|
||||
|
||||
$io->write(' |- Installing upgrade... ');
|
||||
$this->operationTimerStart = microtime(true);
|
||||
$installation = $this->upgrade();
|
||||
|
||||
$error = 0;
|
||||
if (!$installation) {
|
||||
$io->writeln(" |- <red>Installation failed or aborted.</red>");
|
||||
$io->newLine();
|
||||
$error = 1;
|
||||
} else {
|
||||
$io->writeln(" |- <green>Success!</green> ");
|
||||
|
||||
$manifest = Install::instance()->getLastManifest();
|
||||
if (is_array($manifest) && ($manifest['id'] ?? null)) {
|
||||
$snapshotId = (string) $manifest['id'];
|
||||
$snapshotTimestamp = isset($manifest['created_at']) ? (int) $manifest['created_at'] : null;
|
||||
$manifestPath = null;
|
||||
if (isset($manifest['id'])) {
|
||||
$manifestPath = 'user/data/upgrades/' . $manifest['id'] . '.json';
|
||||
}
|
||||
$metadata = [
|
||||
'scope' => 'core',
|
||||
'target_version' => $remote,
|
||||
'snapshot' => $snapshotId,
|
||||
];
|
||||
if (null !== $snapshotTimestamp) {
|
||||
$metadata['snapshot_created_at'] = $snapshotTimestamp;
|
||||
}
|
||||
if ($manifestPath) {
|
||||
$metadata['snapshot_manifest'] = $manifestPath;
|
||||
}
|
||||
|
||||
$recovery->markUpgradeWindow('core-upgrade', $metadata);
|
||||
|
||||
$io->writeln(sprintf(" |- Recovery snapshot: <cyan>%s</cyan>", $snapshotId));
|
||||
if (null !== $snapshotTimestamp) {
|
||||
$io->writeln(sprintf(" |- Snapshot captured: <white>%s</white>", date('c', $snapshotTimestamp)));
|
||||
}
|
||||
if ($manifestPath) {
|
||||
$io->writeln(sprintf(" |- Manifest stored at: <white>%s</white>", $manifestPath));
|
||||
}
|
||||
} else {
|
||||
// Ensure recovery window remains active even if manifest could not be resolved.
|
||||
$recovery->markUpgradeWindow('core-upgrade', [
|
||||
'scope' => 'core',
|
||||
'target_version' => $remote,
|
||||
]);
|
||||
}
|
||||
|
||||
$io->newLine();
|
||||
// Clear recovery flag - upgrade completed successfully
|
||||
$recovery->closeUpgradeWindow();
|
||||
}
|
||||
|
||||
if ($this->tmp && is_dir($this->tmp)) {
|
||||
Folder::delete($this->tmp);
|
||||
}
|
||||
|
||||
return $error;
|
||||
} finally {
|
||||
if (null !== $forcedMode) {
|
||||
// Clean up environment variable
|
||||
putenv('GRAV_FORCE_SAFE_UPGRADE');
|
||||
// Only call Install::forceSafeUpgrade if Install class has been loaded
|
||||
if (class_exists(\Grav\Installer\Install::class, false)) {
|
||||
Install::forceSafeUpgrade(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -282,23 +385,6 @@ class SelfupgradeCommand extends GpmCommand
|
||||
return $this->tmp . DS . $package['name'];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return SafeUpgradeService
|
||||
*/
|
||||
protected function createSafeUpgradeService(): SafeUpgradeService
|
||||
{
|
||||
$config = null;
|
||||
try {
|
||||
$config = Grav::instance()['config'] ?? null;
|
||||
} catch (\Throwable $e) {
|
||||
$config = null;
|
||||
}
|
||||
|
||||
return new SafeUpgradeService([
|
||||
'config' => $config,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array $preflight
|
||||
* @return bool
|
||||
@@ -307,13 +393,25 @@ class SelfupgradeCommand extends GpmCommand
|
||||
{
|
||||
$io = $this->getIO();
|
||||
$pending = $preflight['plugins_pending'] ?? [];
|
||||
$blocking = $preflight['blocking'] ?? [];
|
||||
$conflicts = $preflight['psr_log_conflicts'] ?? [];
|
||||
$monologConflicts = $preflight['monolog_conflicts'] ?? [];
|
||||
$warnings = $preflight['warnings'] ?? [];
|
||||
$isMajorMinorUpgrade = $preflight['is_major_minor_upgrade'] ?? null;
|
||||
if ($isMajorMinorUpgrade === null && $this->upgrader) {
|
||||
$local = $this->upgrader->getLocalVersion();
|
||||
$remote = $this->upgrader->getRemoteVersion();
|
||||
$localParts = explode('.', $local);
|
||||
$remoteParts = explode('.', $remote);
|
||||
|
||||
if (empty($pending) && empty($conflicts) && empty($monologConflicts)) {
|
||||
return true;
|
||||
$localMajor = (int)($localParts[0] ?? 0);
|
||||
$localMinor = (int)($localParts[1] ?? 0);
|
||||
$remoteMajor = (int)($remoteParts[0] ?? 0);
|
||||
$remoteMinor = (int)($remoteParts[1] ?? 0);
|
||||
|
||||
$isMajorMinorUpgrade = ($localMajor !== $remoteMajor) || ($localMinor !== $remoteMinor);
|
||||
}
|
||||
$isMajorMinorUpgrade = (bool)$isMajorMinorUpgrade;
|
||||
|
||||
if ($warnings) {
|
||||
$io->newLine();
|
||||
@@ -323,7 +421,24 @@ class SelfupgradeCommand extends GpmCommand
|
||||
}
|
||||
}
|
||||
|
||||
if ($pending) {
|
||||
if ($blocking && empty($pending)) {
|
||||
$io->newLine();
|
||||
$io->writeln('<red>Upgrade blocked:</red>');
|
||||
foreach ($blocking as $reason) {
|
||||
$io->writeln(' - ' . $reason);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
if (empty($pending) && empty($conflicts) && empty($monologConflicts)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if ($pending && $isMajorMinorUpgrade) {
|
||||
$local = $this->upgrader ? $this->upgrader->getLocalVersion() : 'unknown';
|
||||
$remote = $this->upgrader ? $this->upgrader->getRemoteVersion() : 'unknown';
|
||||
|
||||
$io->newLine();
|
||||
$io->writeln('<yellow>The following packages need updating before Grav upgrade:</yellow>');
|
||||
foreach ($pending as $slug => $info) {
|
||||
@@ -333,10 +448,24 @@ class SelfupgradeCommand extends GpmCommand
|
||||
$io->writeln(sprintf(' - %s (%s) %s → %s', $slug, $type, $current, $available));
|
||||
}
|
||||
|
||||
$io->writeln(' › Please run `bin/gpm update` to bring these packages current before upgrading Grav.');
|
||||
$io->writeln('Aborting self-upgrade. Run `bin/gpm update` first.');
|
||||
$io->writeln(' › For major version upgrades (v' . $local . ' → v' . $remote . '), plugins must be updated to their latest');
|
||||
$io->writeln(' compatible versions BEFORE upgrading Grav core to ensure compatibility.');
|
||||
$io->writeln(' Please run `bin/gpm update` to update these packages, then retry self-upgrade.');
|
||||
|
||||
return false;
|
||||
$proceed = false;
|
||||
if (!$this->all_yes) {
|
||||
$question = new ConfirmationQuestion('Proceed anyway? [y|N] ', false);
|
||||
$proceed = $io->askQuestion($question);
|
||||
}
|
||||
|
||||
if (!$proceed) {
|
||||
$io->writeln('Aborting self-upgrade. Run `bin/gpm update` first.');
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
Install::allowPendingPackageOverride(true);
|
||||
$io->writeln(' › Proceeding despite pending plugin/theme updates.');
|
||||
}
|
||||
|
||||
$handled = $this->handleConflicts(
|
||||
@@ -443,6 +572,13 @@ class SelfupgradeCommand extends GpmCommand
|
||||
$this->lastProgressMessage = null;
|
||||
|
||||
$this->upgradeGrav($this->file);
|
||||
$this->finalizeStageTracking();
|
||||
|
||||
$elapsed = null;
|
||||
if (null !== $this->operationTimerStart) {
|
||||
$elapsed = microtime(true) - $this->operationTimerStart;
|
||||
$this->operationTimerStart = null;
|
||||
}
|
||||
|
||||
$errorCode = Installer::lastErrorCode();
|
||||
if ($errorCode) {
|
||||
@@ -454,6 +590,10 @@ class SelfupgradeCommand extends GpmCommand
|
||||
return false;
|
||||
}
|
||||
|
||||
if (null !== $elapsed) {
|
||||
$io->writeln(sprintf(' |- Safe upgrade staging completed in %s', $this->formatDuration($elapsed)));
|
||||
}
|
||||
|
||||
$io->write("\x0D");
|
||||
// extra white spaces to clear out the buffer properly
|
||||
$io->writeln(' |- Installing upgrade... <green>ok</green> ');
|
||||
@@ -517,6 +657,14 @@ class SelfupgradeCommand extends GpmCommand
|
||||
$this->handleServiceProgress($stage, $message, $percent);
|
||||
});
|
||||
}
|
||||
if (is_object($install) && method_exists($install, 'generatePreflightReport')) {
|
||||
$report = $install->generatePreflightReport();
|
||||
if (!$this->handlePreflightReport($report)) {
|
||||
Installer::setError('Upgrade aborted due to preflight requirements.');
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
$install($zip);
|
||||
} else {
|
||||
throw new RuntimeException('Uploaded archive file is not a valid Grav update package');
|
||||
@@ -528,13 +676,19 @@ class SelfupgradeCommand extends GpmCommand
|
||||
|
||||
private function handleServiceProgress(string $stage, string $message, ?int $percent = null, array $extra = []): void
|
||||
{
|
||||
$this->trackStageProgress($stage, $message, $extra);
|
||||
|
||||
if ($this->lastProgressMessage === $message) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->lastProgressMessage = $message;
|
||||
$io = $this->getIO();
|
||||
$io->writeln(sprintf(' |- %s', $message));
|
||||
$suffix = '';
|
||||
if (null !== $percent) {
|
||||
$suffix = sprintf(' (%d%%)', $percent);
|
||||
}
|
||||
$io->writeln(sprintf(' |- %s%s', $message, $suffix));
|
||||
}
|
||||
|
||||
private function ensureExecutablePermissions(): void
|
||||
@@ -560,4 +714,69 @@ class SelfupgradeCommand extends GpmCommand
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function trackStageProgress(string $stage, string $message, array $extra = []): void
|
||||
{
|
||||
$now = microtime(true);
|
||||
|
||||
if (null !== $this->currentProgressStage && $stage !== $this->currentProgressStage && null !== $this->currentStageStartedAt) {
|
||||
$elapsed = $now - $this->currentStageStartedAt;
|
||||
$this->emitStageSummary($this->currentProgressStage, $elapsed, $this->currentStageExtras);
|
||||
$this->currentStageExtras = [];
|
||||
}
|
||||
|
||||
if ($stage !== $this->currentProgressStage) {
|
||||
$this->currentProgressStage = $stage;
|
||||
$this->currentStageStartedAt = $now;
|
||||
$this->currentStageExtras = [];
|
||||
}
|
||||
|
||||
if (!isset($this->currentStageExtras['label'])) {
|
||||
$this->currentStageExtras['label'] = $message;
|
||||
}
|
||||
|
||||
if ($extra) {
|
||||
$this->currentStageExtras = array_merge($this->currentStageExtras, $extra);
|
||||
}
|
||||
}
|
||||
|
||||
private function finalizeStageTracking(): void
|
||||
{
|
||||
if (null !== $this->currentProgressStage && null !== $this->currentStageStartedAt) {
|
||||
$elapsed = microtime(true) - $this->currentStageStartedAt;
|
||||
$this->emitStageSummary($this->currentProgressStage, $elapsed, $this->currentStageExtras);
|
||||
}
|
||||
|
||||
$this->currentProgressStage = null;
|
||||
$this->currentStageStartedAt = null;
|
||||
$this->currentStageExtras = [];
|
||||
}
|
||||
|
||||
private function emitStageSummary(string $stage, float $seconds, array $extra = []): void
|
||||
{
|
||||
$io = $this->getIO();
|
||||
$label = $extra['label'] ?? ucfirst($stage);
|
||||
$modeText = '';
|
||||
if (isset($extra['mode'])) {
|
||||
$modeText = sprintf(' [%s]', $extra['mode']);
|
||||
}
|
||||
|
||||
$io->writeln(sprintf(' |- %s completed in %s%s', $label, $this->formatDuration($seconds), $modeText));
|
||||
}
|
||||
|
||||
private function formatDuration(float $seconds): string
|
||||
{
|
||||
if ($seconds < 1) {
|
||||
return sprintf('%0.3fs', $seconds);
|
||||
}
|
||||
|
||||
$minutes = (int)floor($seconds / 60);
|
||||
$remaining = $seconds - ($minutes * 60);
|
||||
|
||||
if ($minutes === 0) {
|
||||
return sprintf('%0.1fs', $remaining);
|
||||
}
|
||||
|
||||
return sprintf('%dm %0.1fs', $minutes, $remaining);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -117,15 +117,38 @@ class UpdateCommand extends GpmCommand
|
||||
$local = $this->upgrader->getLocalVersion();
|
||||
$remote = $this->upgrader->getRemoteVersion();
|
||||
if ($local !== $remote) {
|
||||
$io->writeln('<yellow>WARNING</yellow>: A new version of Grav is available. You should update Grav before updating plugins and themes. If you continue without updating Grav, some plugins or themes may stop working.');
|
||||
$io->newLine();
|
||||
$question = new ConfirmationQuestion('Continue with the update process? [Y|n] ', true);
|
||||
$answer = $io->askQuestion($question);
|
||||
// Determine if this is a major/minor version upgrade by comparing versions
|
||||
$localParts = explode('.', $local);
|
||||
$remoteParts = explode('.', $remote);
|
||||
|
||||
if (!$answer) {
|
||||
$io->writeln('<red>Update aborted. Exiting...</red>');
|
||||
$localMajor = (int)($localParts[0] ?? 0);
|
||||
$localMinor = (int)($localParts[1] ?? 0);
|
||||
$remoteMajor = (int)($remoteParts[0] ?? 0);
|
||||
$remoteMinor = (int)($remoteParts[1] ?? 0);
|
||||
|
||||
return 1;
|
||||
// Check if this is a major/minor version change (e.g., 1.7.x -> 1.8.y)
|
||||
$isMajorMinorUpgrade = ($localMajor !== $remoteMajor) || ($localMinor !== $remoteMinor);
|
||||
|
||||
if ($isMajorMinorUpgrade) {
|
||||
// For major/minor upgrades (e.g., 1.7.x -> 1.8.y), recommend updating plugins FIRST
|
||||
$io->writeln('<yellow>WARNING</yellow>: A new major version of Grav is available (v' . $local . ' -> v' . $remote . ').');
|
||||
$io->writeln('For major version upgrades, you should update plugins and themes to their latest compatible versions BEFORE upgrading Grav core.');
|
||||
$io->writeln('This ensures plugins have any necessary compatibility fixes for the new Grav version.');
|
||||
$io->newLine();
|
||||
$io->writeln('<green>It is recommended to proceed with updating plugins and themes now.</green>');
|
||||
} else {
|
||||
// For patch upgrades (e.g., 1.7.45 -> 1.7.46), recommend updating Grav FIRST
|
||||
$io->writeln('<yellow>WARNING</yellow>: A new version of Grav is available (v' . $local . ' -> v' . $remote . ').');
|
||||
$io->writeln('You should update Grav before updating plugins and themes. If you continue without updating Grav, some plugins or themes may stop working.');
|
||||
$io->newLine();
|
||||
$question = new ConfirmationQuestion('Continue with the update process? [Y|n] ', true);
|
||||
$answer = $io->askQuestion($question);
|
||||
|
||||
if (!$answer) {
|
||||
$io->writeln('<red>Update aborted. Exiting...</red>');
|
||||
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ class GpmCommand extends Command
|
||||
* @param OutputInterface $output
|
||||
* @return int
|
||||
*/
|
||||
protected function execute(InputInterface $input, OutputInterface $output)
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$this->setupConsole($input, $output);
|
||||
|
||||
|
||||
@@ -12,15 +12,51 @@ namespace Grav\Installer;
|
||||
use Composer\Autoload\ClassLoader;
|
||||
use Exception;
|
||||
use Grav\Common\Cache;
|
||||
use Grav\Common\Filesystem\Folder;
|
||||
use Grav\Common\GPM\GPM;
|
||||
use Grav\Common\GPM\Installer;
|
||||
use Grav\Common\Grav;
|
||||
use Grav\Common\Plugins;
|
||||
use Grav\Common\Upgrade\SafeUpgradeService;
|
||||
use Grav\Common\Yaml;
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
use function array_slice;
|
||||
use function basename;
|
||||
use function class_exists;
|
||||
use function count;
|
||||
use function date;
|
||||
use function dirname;
|
||||
use function explode;
|
||||
use function floor;
|
||||
use function function_exists;
|
||||
use function file_get_contents;
|
||||
use function glob;
|
||||
use function iterator_to_array;
|
||||
use function is_dir;
|
||||
use function is_file;
|
||||
use function is_link;
|
||||
use function method_exists;
|
||||
use function is_string;
|
||||
use function is_writable;
|
||||
use function json_encode;
|
||||
use function json_decode;
|
||||
use function readlink;
|
||||
use function array_fill_keys;
|
||||
use function array_map;
|
||||
use function array_pad;
|
||||
use function array_key_exists;
|
||||
use function rsort;
|
||||
use function sort;
|
||||
use function sprintf;
|
||||
use function strtolower;
|
||||
use function strpos;
|
||||
use function preg_match;
|
||||
use function symlink;
|
||||
use function time;
|
||||
use function uniqid;
|
||||
use function unlink;
|
||||
use const GRAV_ROOT;
|
||||
use const JSON_PRETTY_PRINT;
|
||||
|
||||
/**
|
||||
* Grav installer.
|
||||
@@ -120,10 +156,21 @@ final class Install
|
||||
/** @var VersionUpdater|null */
|
||||
private $updater;
|
||||
|
||||
/** @var array|null */
|
||||
private $lastManifest = null;
|
||||
|
||||
/** @var static */
|
||||
private static $instance;
|
||||
/** @var bool|null */
|
||||
private static $forceSafeUpgrade = null;
|
||||
/** @var bool */
|
||||
private static $allowPendingOverride = false;
|
||||
/** @var int|null */
|
||||
private static $snapshotLimit = null;
|
||||
/** @var callable|null */
|
||||
private $progressCallback = null;
|
||||
/** @var array|null */
|
||||
private $pendingPreflight = null;
|
||||
|
||||
/**
|
||||
* @return static
|
||||
@@ -137,10 +184,40 @@ final class Install
|
||||
return self::$instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Force safe-upgrade mode independently of system configuration.
|
||||
*
|
||||
* @param bool|null $state
|
||||
* @return void
|
||||
*/
|
||||
public static function forceSafeUpgrade(?bool $state = true): void
|
||||
{
|
||||
self::$forceSafeUpgrade = $state;
|
||||
}
|
||||
|
||||
private function __construct()
|
||||
{
|
||||
}
|
||||
|
||||
public static function allowPendingPackageOverride(?bool $state = true): void
|
||||
{
|
||||
if ($state === null) {
|
||||
self::$allowPendingOverride = false;
|
||||
} else {
|
||||
self::$allowPendingOverride = (bool)$state;
|
||||
}
|
||||
}
|
||||
|
||||
private function ensureLocation(): void
|
||||
{
|
||||
if (null === $this->location) {
|
||||
$path = realpath(__DIR__);
|
||||
if ($path) {
|
||||
$this->location = dirname($path, 4);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string|null $zip
|
||||
* @return $this
|
||||
@@ -226,6 +303,7 @@ ERR;
|
||||
public function prepare(): void
|
||||
{
|
||||
// Locate the new Grav update and the target site from the filesystem.
|
||||
$this->ensureLocation();
|
||||
$location = realpath(__DIR__);
|
||||
$target = realpath(GRAV_ROOT . '/index.php');
|
||||
|
||||
@@ -268,6 +346,8 @@ ERR;
|
||||
throw new RuntimeException('Oops, installer was run without prepare()!', 500);
|
||||
}
|
||||
|
||||
$this->lastManifest = null;
|
||||
|
||||
try {
|
||||
if (null === $this->updater) {
|
||||
$versions = Versions::instance(USER_DIR . 'config/versions.yaml');
|
||||
@@ -277,36 +357,44 @@ ERR;
|
||||
// Update user/config/version.yaml before copying the files to avoid frontend from setting the version schema.
|
||||
$this->updater->install();
|
||||
|
||||
if ($this->shouldUseSafeUpgrade()) {
|
||||
$options = [];
|
||||
try {
|
||||
$grav = Grav::instance();
|
||||
if ($grav && isset($grav['config'])) {
|
||||
$options['config'] = $grav['config'];
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
// ignore
|
||||
}
|
||||
|
||||
$service = new SafeUpgradeService($options);
|
||||
if ($this->progressCallback) {
|
||||
$service->setProgressCallback(function (string $stage, string $message, ?int $percent = null, array $extra = []) {
|
||||
$this->relayProgress($stage, $message, $percent);
|
||||
});
|
||||
}
|
||||
$service->promote($this->location, $this->getVersion(), $this->ignores);
|
||||
Installer::setError(Installer::OK);
|
||||
} else {
|
||||
Installer::install(
|
||||
$this->zip ?? '',
|
||||
GRAV_ROOT,
|
||||
['sophisticated' => true, 'overwrite' => true, 'ignore_symlinks' => true, 'ignores' => $this->ignores],
|
||||
$this->location,
|
||||
!($this->zip && is_file($this->zip))
|
||||
);
|
||||
$safeUpgradeRequested = $this->shouldUseSafeUpgrade();
|
||||
$targetVersion = $this->getVersion();
|
||||
if (null === $this->pendingPreflight) {
|
||||
$this->pendingPreflight = $this->runPreflightChecks($targetVersion);
|
||||
}
|
||||
if (!empty($this->pendingPreflight['blocking'] ?? [])) {
|
||||
$this->relayProgress('error', 'Upgrade blocked by preflight checks.', null);
|
||||
Installer::setError('Upgrade preflight checks failed.');
|
||||
|
||||
return;
|
||||
}
|
||||
$snapshotManifest = null;
|
||||
if ($safeUpgradeRequested) {
|
||||
$snapshotManifest = $this->captureCoreSnapshot($targetVersion);
|
||||
if ($snapshotManifest) {
|
||||
$this->relayProgress('snapshot', sprintf('Snapshot %s captured.', $snapshotManifest['id']), 100);
|
||||
} else {
|
||||
$this->relayProgress('snapshot', 'Snapshot capture unavailable; continuing without it.', null);
|
||||
}
|
||||
}
|
||||
$progressMessage = $safeUpgradeRequested
|
||||
? 'Running Grav standard installer (safe mode)...'
|
||||
: 'Running Grav standard installer...';
|
||||
$this->relayProgress('installing', $progressMessage, null);
|
||||
|
||||
Installer::install(
|
||||
$this->zip ?? '',
|
||||
GRAV_ROOT,
|
||||
['sophisticated' => true, 'overwrite' => true, 'ignore_symlinks' => true, 'ignores' => $this->ignores],
|
||||
$this->location,
|
||||
!($this->zip && is_file($this->zip))
|
||||
);
|
||||
|
||||
$this->relayProgress('complete', 'Grav standard installer finished.', 100);
|
||||
} catch (Exception $e) {
|
||||
Installer::setError($e->getMessage());
|
||||
} finally {
|
||||
self::$allowPendingOverride = false;
|
||||
}
|
||||
|
||||
$errorCode = Installer::lastErrorCode();
|
||||
@@ -323,28 +411,312 @@ ERR;
|
||||
*/
|
||||
private function shouldUseSafeUpgrade(): bool
|
||||
{
|
||||
if (!class_exists(SafeUpgradeService::class)) {
|
||||
return false;
|
||||
if (null !== self::$forceSafeUpgrade) {
|
||||
return self::$forceSafeUpgrade;
|
||||
}
|
||||
|
||||
$envValue = getenv('GRAV_FORCE_SAFE_UPGRADE');
|
||||
if (false !== $envValue && '' !== $envValue) {
|
||||
return $envValue === '1';
|
||||
}
|
||||
|
||||
try {
|
||||
$grav = Grav::instance();
|
||||
if ($grav && isset($grav['config'])) {
|
||||
return (bool) $grav['config']->get('system.updates.safe_upgrade', true);
|
||||
$configValue = $grav['config']->get('system.updates.safe_upgrade');
|
||||
if ($configValue !== null) {
|
||||
return (bool) $configValue;
|
||||
}
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
// Grav container may not be initialised yet, default to safe upgrade.
|
||||
// ignore bootstrap failures
|
||||
}
|
||||
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
private function getSafeUpgradeSnapshotLimit(): int
|
||||
{
|
||||
if (null !== self::$snapshotLimit) {
|
||||
return self::$snapshotLimit;
|
||||
}
|
||||
|
||||
$limit = 5;
|
||||
|
||||
try {
|
||||
$grav = Grav::instance();
|
||||
if ($grav && isset($grav['config'])) {
|
||||
$configured = $grav['config']->get('system.updates.safe_upgrade_snapshot_limit');
|
||||
if ($configured !== null) {
|
||||
$limit = (int)$configured;
|
||||
}
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
// ignore bootstrap failures
|
||||
}
|
||||
|
||||
if ($limit < 0) {
|
||||
$limit = 0;
|
||||
}
|
||||
|
||||
self::$snapshotLimit = $limit;
|
||||
|
||||
return $limit;
|
||||
}
|
||||
|
||||
private function captureCoreSnapshot(string $targetVersion): ?array
|
||||
{
|
||||
$entries = $this->collectSnapshotEntries();
|
||||
if (!$entries) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$snapshotRoot = $this->resolveSnapshotStore();
|
||||
if (!$snapshotRoot) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$snapshotId = 'snapshot-' . date('YmdHis');
|
||||
$snapshotPath = $snapshotRoot . '/' . $snapshotId;
|
||||
try {
|
||||
Folder::create($snapshotPath);
|
||||
} catch (\Throwable $e) {
|
||||
error_log('[Grav Upgrade] Unable to create snapshot directory: ' . $e->getMessage());
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
$total = count($entries);
|
||||
foreach ($entries as $index => $entry) {
|
||||
$percent = $total > 0 ? (int)floor((($index + 1) / $total) * 100) : null;
|
||||
$this->relayProgress('snapshot', sprintf('Snapshotting %s (%d/%d)', $entry, $index + 1, $total), $percent);
|
||||
|
||||
$source = GRAV_ROOT . '/' . $entry;
|
||||
$destination = $snapshotPath . '/' . $entry;
|
||||
|
||||
try {
|
||||
$this->snapshotCopyEntry($source, $destination);
|
||||
} catch (\Throwable $e) {
|
||||
error_log('[Grav Upgrade] Snapshot copy failed for ' . $entry . ': ' . $e->getMessage());
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
$manifest = [
|
||||
'id' => $snapshotId,
|
||||
'created_at' => time(),
|
||||
'source_version' => GRAV_VERSION,
|
||||
'target_version' => $targetVersion,
|
||||
'php_version' => PHP_VERSION,
|
||||
'entries' => $entries,
|
||||
'package_path' => null,
|
||||
'backup_path' => $snapshotPath,
|
||||
'operation' => 'upgrade',
|
||||
'mode' => 'pre-upgrade',
|
||||
];
|
||||
|
||||
$this->persistSnapshotManifest($manifest);
|
||||
$this->lastManifest = $manifest;
|
||||
$this->pruneOldSnapshots($snapshotRoot);
|
||||
|
||||
return $manifest;
|
||||
}
|
||||
|
||||
private function collectSnapshotEntries(): array
|
||||
{
|
||||
$ignores = array_fill_keys($this->ignores, true);
|
||||
$ignores['user'] = true;
|
||||
|
||||
$entries = [];
|
||||
try {
|
||||
$iterator = new \DirectoryIterator(GRAV_ROOT);
|
||||
foreach ($iterator as $item) {
|
||||
if ($item->isDot()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$name = $item->getFilename();
|
||||
if (isset($ignores[$name])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$entries[] = $name;
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
error_log('[Grav Upgrade] Unable to enumerate snapshot entries: ' . $e->getMessage());
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
sort($entries);
|
||||
|
||||
return $entries;
|
||||
}
|
||||
|
||||
private function snapshotCopyEntry(string $source, string $destination): void
|
||||
{
|
||||
if (is_link($source)) {
|
||||
$linkTarget = readlink($source);
|
||||
Folder::create(dirname($destination));
|
||||
if (is_link($destination) || is_file($destination)) {
|
||||
@unlink($destination);
|
||||
}
|
||||
if ($linkTarget !== false) {
|
||||
@symlink($linkTarget, $destination);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (is_dir($source)) {
|
||||
Folder::rcopy($source, $destination);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
Folder::create(dirname($destination));
|
||||
if (!@copy($source, $destination)) {
|
||||
throw new RuntimeException(sprintf('Failed to copy file %s during snapshot.', $source));
|
||||
}
|
||||
}
|
||||
|
||||
private function resolveSnapshotStore(): ?string
|
||||
{
|
||||
$candidates = [];
|
||||
try {
|
||||
$grav = Grav::instance();
|
||||
if ($grav && isset($grav['locator'])) {
|
||||
$path = $grav['locator']->findResource('tmp://grav-snapshots', true, true);
|
||||
if ($path) {
|
||||
$candidates[] = $path;
|
||||
}
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
// ignore locator issues
|
||||
}
|
||||
$candidates[] = GRAV_ROOT . '/tmp/grav-snapshots';
|
||||
|
||||
foreach ($candidates as $candidate) {
|
||||
if (!$candidate) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
Folder::create($candidate);
|
||||
} catch (\Throwable $e) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (is_dir($candidate) && is_writable($candidate)) {
|
||||
return rtrim($candidate, '\\/');
|
||||
}
|
||||
}
|
||||
|
||||
error_log('[Grav Upgrade] Unable to locate writable snapshot directory; skipping snapshot.');
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function persistSnapshotManifest(array $manifest): void
|
||||
{
|
||||
$store = GRAV_ROOT . '/user/data/upgrades';
|
||||
|
||||
try {
|
||||
Folder::create($store);
|
||||
$path = $store . '/' . $manifest['id'] . '.json';
|
||||
@file_put_contents($path, json_encode($manifest, JSON_PRETTY_PRINT));
|
||||
} catch (\Throwable $e) {
|
||||
error_log('[Grav Upgrade] Unable to write snapshot manifest: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private function pruneOldSnapshots(?string $snapshotRoot): void
|
||||
{
|
||||
$limit = $this->getSafeUpgradeSnapshotLimit();
|
||||
if ($limit <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
$manifestDir = GRAV_ROOT . '/user/data/upgrades';
|
||||
$files = glob($manifestDir . '/*.json');
|
||||
if (!$files) {
|
||||
return;
|
||||
}
|
||||
|
||||
rsort($files);
|
||||
if (count($files) <= $limit) {
|
||||
return;
|
||||
}
|
||||
|
||||
$obsolete = array_slice($files, $limit);
|
||||
$removed = 0;
|
||||
|
||||
foreach ($obsolete as $manifestPath) {
|
||||
$manifest = null;
|
||||
try {
|
||||
$contents = @file_get_contents($manifestPath);
|
||||
if ($contents !== false) {
|
||||
$decoded = json_decode($contents, true);
|
||||
if (is_array($decoded)) {
|
||||
$manifest = $decoded;
|
||||
}
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
// ignore malformed manifests
|
||||
}
|
||||
|
||||
$snapshotId = $manifest['id'] ?? basename($manifestPath, '.json');
|
||||
$backupPath = $manifest['backup_path'] ?? null;
|
||||
|
||||
if ($backupPath && is_dir($backupPath)) {
|
||||
try {
|
||||
Folder::delete($backupPath);
|
||||
} catch (\Throwable $e) {
|
||||
error_log('[Grav Upgrade] Unable to delete snapshot directory ' . $backupPath . ': ' . $e->getMessage());
|
||||
}
|
||||
} elseif ($snapshotRoot && $snapshotId) {
|
||||
$candidate = $snapshotRoot . '/' . $snapshotId;
|
||||
if (is_dir($candidate)) {
|
||||
try {
|
||||
Folder::delete($candidate);
|
||||
} catch (\Throwable $e) {
|
||||
error_log('[Grav Upgrade] Unable to delete snapshot directory ' . $candidate . ': ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!@unlink($manifestPath)) {
|
||||
error_log('[Grav Upgrade] Unable to remove snapshot manifest: ' . $manifestPath);
|
||||
continue;
|
||||
}
|
||||
|
||||
$removed++;
|
||||
}
|
||||
|
||||
if ($removed > 0) {
|
||||
$this->relayProgress(
|
||||
'snapshot',
|
||||
sprintf(
|
||||
'Pruned %d old snapshot%s (keeping latest %d).',
|
||||
$removed,
|
||||
$removed === 1 ? '' : 's',
|
||||
$limit
|
||||
),
|
||||
null
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @return void
|
||||
* @throws RuntimeException
|
||||
*/
|
||||
public function finalize(): void
|
||||
{
|
||||
$start = microtime(true);
|
||||
$this->relayProgress('finalizing', 'Running postflight tasks...', null);
|
||||
// Finalize can be run without installing Grav first.
|
||||
if (null === $this->updater) {
|
||||
$versions = Versions::instance(USER_DIR . 'config/versions.yaml');
|
||||
@@ -354,12 +726,17 @@ ERR;
|
||||
|
||||
$this->updater->postflight();
|
||||
|
||||
$this->ensureExecutablePermissions();
|
||||
|
||||
Cache::clearCache('all');
|
||||
|
||||
clearstatcache();
|
||||
if (function_exists('opcache_reset')) {
|
||||
@opcache_reset();
|
||||
}
|
||||
|
||||
$elapsed = microtime(true) - $start;
|
||||
$this->relayProgress('finalizing', sprintf('Postflight tasks complete in %.3fs.', $elapsed), null);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -451,9 +828,437 @@ ERR;
|
||||
return $matches[1] ?? '';
|
||||
}
|
||||
|
||||
|
||||
|
||||
protected function legacySupport(): void
|
||||
{
|
||||
// Support install for Grav 1.6.0 - 1.6.20 by loading the original class from the older version of Grav.
|
||||
class_exists(\Grav\Console\Cli\CacheCommand::class, true);
|
||||
}
|
||||
|
||||
private function ensureExecutablePermissions(): void
|
||||
{
|
||||
$executables = [
|
||||
'bin/grav',
|
||||
'bin/plugin',
|
||||
'bin/gpm',
|
||||
'bin/restore',
|
||||
'bin/composer.phar'
|
||||
];
|
||||
|
||||
foreach ($executables as $relative) {
|
||||
$path = GRAV_ROOT . '/' . $relative;
|
||||
if (!is_file($path) || is_link($path)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$mode = @fileperms($path);
|
||||
$current = $mode !== false ? ($mode & 0777) : 0644;
|
||||
if (($current & 0111) === 0111) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@chmod($path, $current | 0111);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array|null
|
||||
*/
|
||||
public function getLastManifest(): ?array
|
||||
{
|
||||
return $this->lastManifest;
|
||||
}
|
||||
|
||||
public function generatePreflightReport(): array
|
||||
{
|
||||
$this->ensureLocation();
|
||||
$version = $this->getVersion();
|
||||
|
||||
$report = $this->runPreflightChecks($version ?: GRAV_VERSION);
|
||||
$this->pendingPreflight = $report;
|
||||
|
||||
return $report;
|
||||
}
|
||||
|
||||
public function getPreflightReport(): ?array
|
||||
{
|
||||
return $this->pendingPreflight;
|
||||
}
|
||||
|
||||
private function runPreflightChecks(string $targetVersion): array
|
||||
{
|
||||
$start = microtime(true);
|
||||
$this->relayProgress('initializing', 'Running preflight checks...', null);
|
||||
$report = [
|
||||
'warnings' => [],
|
||||
'psr_log_conflicts' => [],
|
||||
'monolog_conflicts' => [],
|
||||
'plugins_pending' => [],
|
||||
'is_major_minor_upgrade' => $this->isMajorMinorUpgrade($targetVersion),
|
||||
'blocking' => [],
|
||||
];
|
||||
|
||||
$report['plugins_pending'] = $this->detectPendingPackageUpdates();
|
||||
$report['psr_log_conflicts'] = $this->detectPsrLogConflicts();
|
||||
$report['monolog_conflicts'] = $this->detectMonologConflicts();
|
||||
|
||||
if ($report['plugins_pending']) {
|
||||
if (self::$allowPendingOverride) {
|
||||
$report['warnings'][] = 'Pending plugin/theme updates ignored for this upgrade run.';
|
||||
} elseif ($report['is_major_minor_upgrade']) {
|
||||
$report['blocking'][] = 'Pending plugin/theme updates detected. Because this is a major Grav upgrade, update them before continuing.';
|
||||
}
|
||||
}
|
||||
|
||||
$elapsed = microtime(true) - $start;
|
||||
$this->relayProgress('initializing', sprintf('Preflight checks complete in %.3fs.', $elapsed), null);
|
||||
|
||||
return $report;
|
||||
}
|
||||
|
||||
private function isMajorMinorUpgrade(string $targetVersion): bool
|
||||
{
|
||||
[$currentMajor, $currentMinor] = array_map('intval', array_pad(explode('.', GRAV_VERSION), 2, 0));
|
||||
[$targetMajor, $targetMinor] = array_map('intval', array_pad(explode('.', $targetVersion), 2, 0));
|
||||
|
||||
return $currentMajor !== $targetMajor || $currentMinor !== $targetMinor;
|
||||
}
|
||||
|
||||
private function detectPendingPackageUpdates(): array
|
||||
{
|
||||
$pending = [];
|
||||
|
||||
if (!class_exists(GPM::class)) {
|
||||
return $pending;
|
||||
}
|
||||
|
||||
try {
|
||||
$gpm = new GPM();
|
||||
} catch (Throwable $e) {
|
||||
$this->relayProgress('warning', 'Unable to query GPM: ' . $e->getMessage(), null);
|
||||
|
||||
return $pending;
|
||||
}
|
||||
|
||||
$repoPlugins = $this->packagesToArray($gpm->getRepositoryPlugins());
|
||||
$repoThemes = $this->packagesToArray($gpm->getRepositoryThemes());
|
||||
|
||||
$scanRoot = GRAV_ROOT ?: getcwd();
|
||||
|
||||
$localPlugins = $this->scanLocalPackageVersions($scanRoot . '/user/plugins');
|
||||
foreach ($localPlugins as $slug => $version) {
|
||||
$remote = $repoPlugins[$slug] ?? null;
|
||||
if (!$this->isGpmPackagePublished($remote)) {
|
||||
continue;
|
||||
}
|
||||
$remoteVersion = $this->resolveRemotePackageVersion($remote);
|
||||
if (!$remoteVersion || !$version) {
|
||||
continue;
|
||||
}
|
||||
if (!$this->isPluginEnabled($slug)) {
|
||||
if (str_contains($version, 'dev-')) {
|
||||
$this->relayProgress('warning', sprintf('Skipping dev plugin %s (%s).', $slug, $version), null);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (version_compare($remoteVersion, $version, '>')) {
|
||||
$pending[$slug] = [
|
||||
'type' => 'plugins',
|
||||
'current' => $version,
|
||||
'available' => $remoteVersion,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
$localThemes = $this->scanLocalPackageVersions($scanRoot . '/user/themes');
|
||||
foreach ($localThemes as $slug => $version) {
|
||||
$remote = $repoThemes[$slug] ?? null;
|
||||
if (!$this->isGpmPackagePublished($remote)) {
|
||||
if (str_contains($version, 'dev-')) {
|
||||
$this->relayProgress('warning', sprintf('Skipping dev theme %s (%s).', $slug, $version), null);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
$remoteVersion = $this->resolveRemotePackageVersion($remote);
|
||||
if (!$remoteVersion || !$version) {
|
||||
continue;
|
||||
}
|
||||
if (!$this->isThemeEnabled($slug)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (version_compare($remoteVersion, $version, '>')) {
|
||||
$pending[$slug] = [
|
||||
'type' => 'themes',
|
||||
'current' => $version,
|
||||
'available' => $remoteVersion,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
$this->relayProgress('initializing', sprintf('Detected %d updatable packages (including symlinks).', count($pending)), null);
|
||||
|
||||
return $pending;
|
||||
}
|
||||
|
||||
private function scanLocalPackageVersions(string $path): array
|
||||
{
|
||||
$versions = [];
|
||||
if (!is_dir($path)) {
|
||||
return $versions;
|
||||
}
|
||||
|
||||
$entries = glob($path . '/*', GLOB_ONLYDIR) ?: [];
|
||||
foreach ($entries as $dir) {
|
||||
$slug = basename($dir);
|
||||
$version = $this->readBlueprintVersion($dir) ?? $this->readComposerVersion($dir);
|
||||
if ($version !== null) {
|
||||
$versions[$slug] = $version;
|
||||
}
|
||||
}
|
||||
|
||||
return $versions;
|
||||
}
|
||||
|
||||
private function readBlueprintVersion(string $dir): ?string
|
||||
{
|
||||
$file = $dir . '/blueprints.yaml';
|
||||
if (!is_file($file)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$contents = @file_get_contents($file);
|
||||
if ($contents === false) {
|
||||
return null;
|
||||
}
|
||||
$data = Yaml::parse($contents);
|
||||
if (is_array($data) && isset($data['version'])) {
|
||||
return (string)$data['version'];
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
// ignore parse errors
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function readComposerVersion(string $dir): ?string
|
||||
{
|
||||
$file = $dir . '/composer.json';
|
||||
if (!is_file($file)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$data = json_decode(file_get_contents($file), true);
|
||||
if (is_array($data) && isset($data['version'])) {
|
||||
return (string)$data['version'];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function packagesToArray($packages): array
|
||||
{
|
||||
if (!$packages) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (is_array($packages)) {
|
||||
return $packages;
|
||||
}
|
||||
|
||||
if ($packages instanceof \Traversable) {
|
||||
return iterator_to_array($packages, true);
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
private function resolveRemotePackageVersion($package): ?string
|
||||
{
|
||||
if (!$package) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (is_array($package)) {
|
||||
return $package['version'] ?? null;
|
||||
}
|
||||
|
||||
if (is_object($package)) {
|
||||
if (isset($package->version)) {
|
||||
return (string)$package->version;
|
||||
}
|
||||
if (method_exists($package, 'offsetGet')) {
|
||||
try {
|
||||
return (string)$package->offsetGet('version');
|
||||
} catch (Throwable $e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function detectPsrLogConflicts(): array
|
||||
{
|
||||
$conflicts = [];
|
||||
$pluginRoots = glob(GRAV_ROOT . '/user/plugins/*', GLOB_ONLYDIR) ?: [];
|
||||
foreach ($pluginRoots as $path) {
|
||||
$composerFile = $path . '/composer.json';
|
||||
if (!is_file($composerFile)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$json = json_decode(file_get_contents($composerFile), true);
|
||||
if (!is_array($json)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$slug = basename($path);
|
||||
if (!$this->isPluginEnabled($slug)) {
|
||||
continue;
|
||||
}
|
||||
$rawConstraint = $json['require']['psr/log'] ?? ($json['require-dev']['psr/log'] ?? null);
|
||||
if (!$rawConstraint) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$constraint = strtolower((string)$rawConstraint);
|
||||
$compatible = $constraint === '*'
|
||||
|| false !== strpos($constraint, '3')
|
||||
|| false !== strpos($constraint, '4')
|
||||
|| (false !== strpos($constraint, '>=') && preg_match('/>=\s*3/', $constraint));
|
||||
|
||||
if ($compatible) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$conflicts[$slug] = [
|
||||
'composer' => $composerFile,
|
||||
'requires' => $rawConstraint,
|
||||
];
|
||||
}
|
||||
|
||||
return $conflicts;
|
||||
}
|
||||
|
||||
private function detectMonologConflicts(): array
|
||||
{
|
||||
$conflicts = [];
|
||||
$pluginRoots = glob(GRAV_ROOT . '/user/plugins/*', GLOB_ONLYDIR) ?: [];
|
||||
$pattern = '/->add(?:Debug|Info|Notice|Warning|Error|Critical|Alert|Emergency)\s*\(/i';
|
||||
|
||||
foreach ($pluginRoots as $path) {
|
||||
$slug = basename($path);
|
||||
if (!$this->isPluginEnabled($slug)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$directory = new \RecursiveDirectoryIterator($path, \FilesystemIterator::SKIP_DOTS);
|
||||
$filter = new \RecursiveCallbackFilterIterator($directory, static function ($current, $key, $iterator) {
|
||||
// Skip hidden files/dirs (starting with .)
|
||||
if ($current->getFilename()[0] === '.') {
|
||||
return false;
|
||||
}
|
||||
if ($iterator->hasChildren()) {
|
||||
// Exclude vendor and node_modules directories
|
||||
return !in_array($current->getFilename(), ['vendor', 'node_modules'], true);
|
||||
}
|
||||
// Only include PHP files
|
||||
return $current->getExtension() === 'php';
|
||||
});
|
||||
|
||||
$iterator = new \RecursiveIteratorIterator($filter);
|
||||
|
||||
foreach ($iterator as $file) {
|
||||
$contents = @file_get_contents($file->getPathname());
|
||||
if ($contents === false) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (preg_match($pattern, $contents, $match)) {
|
||||
$relative = str_replace(GRAV_ROOT . '/', '', $file->getPathname());
|
||||
$conflicts[$slug][] = [
|
||||
'file' => $relative,
|
||||
'method' => trim($match[0]),
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $conflicts;
|
||||
}
|
||||
|
||||
private function isPluginEnabled(string $slug): bool
|
||||
{
|
||||
$configPath = GRAV_ROOT . '/user/config/plugins/' . $slug . '.yaml';
|
||||
if (is_file($configPath)) {
|
||||
try {
|
||||
$contents = @file_get_contents($configPath);
|
||||
if ($contents !== false) {
|
||||
$data = Yaml::parse($contents);
|
||||
if (is_array($data) && array_key_exists('enabled', $data)) {
|
||||
return (bool)$data['enabled'];
|
||||
}
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
// ignore parse errors
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private function isThemeEnabled(string $slug): bool
|
||||
{
|
||||
$configPath = GRAV_ROOT . '/user/config/system.yaml';
|
||||
if (is_file($configPath)) {
|
||||
try {
|
||||
$contents = @file_get_contents($configPath);
|
||||
if ($contents !== false) {
|
||||
$data = Yaml::parse($contents);
|
||||
if (is_array($data)) {
|
||||
$active = $data['pages']['theme'] ?? ($data['system']['pages']['theme'] ?? null);
|
||||
if ($active !== null) {
|
||||
return $active === $slug;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
// ignore parse errors
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private function isGpmPackagePublished($package): bool
|
||||
{
|
||||
if (is_object($package) && method_exists($package, 'getData')) {
|
||||
$data = $package->getData();
|
||||
if ($data instanceof \Grav\Common\Data\Data) {
|
||||
$published = $data->get('published');
|
||||
|
||||
return $published !== false;
|
||||
}
|
||||
}
|
||||
|
||||
if (is_array($package)) {
|
||||
if (array_key_exists('published', $package)) {
|
||||
return $package['published'] !== false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
if (is_object($package) && property_exists($package, 'published')) {
|
||||
return $package->published !== false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
use Grav\Common\Filesystem\Folder;
|
||||
use Grav\Common\Recovery\RecoveryManager;
|
||||
use RocketTheme\Toolbox\Event\Event;
|
||||
|
||||
class RecoveryManagerTest extends \Codeception\TestCase\Test
|
||||
{
|
||||
@@ -13,6 +14,7 @@ class RecoveryManagerTest extends \Codeception\TestCase\Test
|
||||
$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 . '/system');
|
||||
}
|
||||
|
||||
@@ -59,7 +61,7 @@ class RecoveryManagerTest extends \Codeception\TestCase\Test
|
||||
$manager->markUpgradeWindow('core-upgrade', ['scope' => 'core']);
|
||||
$manager->handleShutdown();
|
||||
|
||||
$flag = $this->tmpDir . '/system/recovery.flag';
|
||||
$flag = $this->tmpDir . '/user/data/recovery.flag';
|
||||
self::assertFileExists($flag);
|
||||
$context = json_decode(file_get_contents($flag), true);
|
||||
self::assertSame('Fatal failure', $context['message']);
|
||||
@@ -76,6 +78,81 @@ class RecoveryManagerTest extends \Codeception\TestCase\Test
|
||||
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 {
|
||||
@@ -87,12 +164,12 @@ class RecoveryManagerTest extends \Codeception\TestCase\Test
|
||||
|
||||
$manager->handleShutdown();
|
||||
|
||||
self::assertFileDoesNotExist($this->tmpDir . '/system/recovery.flag');
|
||||
self::assertFileDoesNotExist($this->tmpDir . '/user/data/recovery.flag');
|
||||
}
|
||||
|
||||
public function testClearRemovesFlag(): void
|
||||
{
|
||||
$flag = $this->tmpDir . '/system/recovery.flag';
|
||||
$flag = $this->tmpDir . '/user/data/recovery.flag';
|
||||
file_put_contents($flag, 'flag');
|
||||
|
||||
$manager = new RecoveryManager($this->tmpDir);
|
||||
@@ -130,7 +207,7 @@ class RecoveryManagerTest extends \Codeception\TestCase\Test
|
||||
$manager = new RecoveryManager($this->tmpDir);
|
||||
$manager->disablePlugin('problem', ['message' => 'Manual disable']);
|
||||
|
||||
$flag = $this->tmpDir . '/system/recovery.flag';
|
||||
$flag = $this->tmpDir . '/user/data/recovery.flag';
|
||||
self::assertFileDoesNotExist($flag);
|
||||
|
||||
$configFile = $this->tmpDir . '/user/config/plugins/problem.yaml';
|
||||
|
||||
@@ -123,7 +123,7 @@ class SafeUpgradeServiceTest extends \Codeception\TestCase\Test
|
||||
]);
|
||||
|
||||
$manifests = [];
|
||||
for ($i = 0; $i < 4; $i++) {
|
||||
for ($i = 0; $i < 6; $i++) {
|
||||
$package = $this->preparePackage((string)$i);
|
||||
$manifests[] = $service->promote($package, '1.8.' . $i, ['backup', 'cache', 'images', 'logs', 'tmp', 'user']);
|
||||
// Ensure subsequent promotions have a marker to restore.
|
||||
@@ -131,8 +131,17 @@ class SafeUpgradeServiceTest extends \Codeception\TestCase\Test
|
||||
}
|
||||
|
||||
$files = glob($manifestStore . '/*.json');
|
||||
self::assertCount(3, $files);
|
||||
self::assertFalse(is_dir($manifests[0]['backup_path']));
|
||||
self::assertCount(5, $files);
|
||||
|
||||
// Verify the oldest one (index 0) is gone
|
||||
$oldestManifestId = $manifests[0]['id'];
|
||||
self::assertFileDoesNotExist($manifestStore . '/' . $oldestManifestId . '.json');
|
||||
self::assertDirectoryDoesNotExist($manifests[0]['backup_path']);
|
||||
|
||||
// Verify the newest one (index 5) exists
|
||||
$newestManifestId = $manifests[5]['id'];
|
||||
self::assertFileExists($manifestStore . '/' . $newestManifestId . '.json');
|
||||
self::assertDirectoryExists($manifests[5]['backup_path']);
|
||||
}
|
||||
|
||||
public function testDetectsPsrLogConflictsFromFilesystem(): void
|
||||
@@ -186,9 +195,12 @@ PHP;
|
||||
public function testClearRecoveryFlagRemovesFile(): void
|
||||
{
|
||||
[$root] = $this->prepareLiveEnvironment();
|
||||
$flag = $root . '/system/recovery.flag';
|
||||
$flag = $root . '/user/data/recovery.flag';
|
||||
$window = $root . '/user/data/recovery.window';
|
||||
Folder::create(dirname($flag));
|
||||
file_put_contents($flag, 'flag');
|
||||
Folder::create(dirname($window));
|
||||
file_put_contents($window, json_encode(['expires_at' => time() + 120]));
|
||||
|
||||
$service = new SafeUpgradeService([
|
||||
'root' => $root,
|
||||
@@ -196,6 +208,7 @@ PHP;
|
||||
$service->clearRecoveryFlag();
|
||||
|
||||
self::assertFileDoesNotExist($flag);
|
||||
self::assertFileExists($window);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -106,7 +106,7 @@ class StubSafeUpgradeService extends SafeUpgradeService
|
||||
parent::__construct([]);
|
||||
}
|
||||
|
||||
public function preflight(): array
|
||||
public function preflight(?string $targetVersion = null): array
|
||||
{
|
||||
return $this->report;
|
||||
}
|
||||
|
||||
@@ -17,7 +17,8 @@ class SelfupgradeCommandTest extends \Codeception\TestCase\Test
|
||||
$result = $command->runHandle([
|
||||
'plugins_pending' => [],
|
||||
'psr_log_conflicts' => [],
|
||||
'warnings' => []
|
||||
'warnings' => [],
|
||||
'is_major_minor_upgrade' => false
|
||||
]);
|
||||
|
||||
self::assertTrue($result);
|
||||
@@ -33,7 +34,8 @@ class SelfupgradeCommandTest extends \Codeception\TestCase\Test
|
||||
$result = $command->runHandle([
|
||||
'plugins_pending' => ['foo' => ['type' => 'plugin', 'current' => '1', 'available' => '2']],
|
||||
'psr_log_conflicts' => ['bar' => ['requires' => '^1.0']],
|
||||
'warnings' => ['pending']
|
||||
'warnings' => ['pending'],
|
||||
'is_major_minor_upgrade' => true
|
||||
]);
|
||||
|
||||
self::assertFalse($result);
|
||||
@@ -44,13 +46,14 @@ class SelfupgradeCommandTest extends \Codeception\TestCase\Test
|
||||
public function testHandlePreflightReportAbortsOnPendingWhenDeclined(): void
|
||||
{
|
||||
$command = new TestSelfupgradeCommand();
|
||||
[$style] = $this->injectIo($command);
|
||||
[$style] = $this->injectIo($command, [false]);
|
||||
$this->setAllYes($command, false);
|
||||
|
||||
$result = $command->runHandle([
|
||||
'plugins_pending' => ['foo' => ['type' => 'plugin', 'current' => '1', 'available' => '2']],
|
||||
'psr_log_conflicts' => [],
|
||||
'warnings' => []
|
||||
'warnings' => [],
|
||||
'is_major_minor_upgrade' => true
|
||||
]);
|
||||
|
||||
self::assertFalse($result);
|
||||
@@ -66,7 +69,8 @@ class SelfupgradeCommandTest extends \Codeception\TestCase\Test
|
||||
$result = $command->runHandle([
|
||||
'plugins_pending' => [],
|
||||
'psr_log_conflicts' => ['foo' => ['requires' => '^1.0']],
|
||||
'warnings' => []
|
||||
'warnings' => [],
|
||||
'is_major_minor_upgrade' => false
|
||||
]);
|
||||
|
||||
self::assertFalse($result);
|
||||
@@ -92,7 +96,8 @@ class SelfupgradeCommandTest extends \Codeception\TestCase\Test
|
||||
$result = $command->runHandle([
|
||||
'plugins_pending' => [],
|
||||
'psr_log_conflicts' => ['foo' => ['requires' => '^1.0']],
|
||||
'warnings' => []
|
||||
'warnings' => [],
|
||||
'is_major_minor_upgrade' => false
|
||||
]);
|
||||
|
||||
self::assertTrue($result);
|
||||
@@ -109,7 +114,8 @@ class SelfupgradeCommandTest extends \Codeception\TestCase\Test
|
||||
$result = $command->runHandle([
|
||||
'plugins_pending' => [],
|
||||
'psr_log_conflicts' => ['foo' => ['requires' => '^1.0']],
|
||||
'warnings' => []
|
||||
'warnings' => [],
|
||||
'is_major_minor_upgrade' => false
|
||||
]);
|
||||
|
||||
self::assertTrue($result);
|
||||
|
||||
0
user/config/media.yaml
Normal file
0
user/config/media.yaml
Normal file
Reference in New Issue
Block a user