mirror of
https://github.com/getgrav/grav.git
synced 2025-12-16 05:09:42 +01:00
Compare commits
198 Commits
1.8.0-beta
...
15cb068f95
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
15cb068f95 | ||
|
|
d34213232b | ||
|
|
7a6b8a90d4 | ||
|
|
306f33f4ae | ||
|
|
6cb8229806 | ||
|
|
95e285efa4 | ||
|
|
80410dae13 | ||
|
|
fae70e5fc9 | ||
|
|
9d9247a32f | ||
|
|
94d85cd873 | ||
|
|
0f879bd1d4 | ||
|
|
fd828d452e | ||
|
|
63bbc1cac6 | ||
|
|
528032b11a | ||
|
|
a4c3a3af6d | ||
|
|
b7e1958a6e | ||
|
|
0c38968c58 | ||
|
|
9d11094e41 | ||
|
|
ed640a1314 | ||
|
|
e37259527d | ||
|
|
3462d94d57 | ||
|
|
19c2f8da76 | ||
|
|
a161399c84 | ||
|
|
5f120c328b | ||
|
|
db924c4a26 | ||
|
|
9fc1b42d59 | ||
|
|
c8878dfc80 | ||
|
|
779661ab8a | ||
|
|
3985638a8f | ||
|
|
a78789b291 | ||
|
|
caa127cd53 | ||
|
|
5f087d3a43 | ||
|
|
1bc6e5e13a | ||
|
|
f339bb83c5 | ||
|
|
27789991ae | ||
|
|
114aebae7c | ||
|
|
370dfd6016 | ||
|
|
1d05e6bdc4 | ||
|
|
3acff8a9f8 | ||
|
|
ea59bdb1d4 | ||
|
|
02330b96d9 | ||
|
|
2b1d73fd26 | ||
|
|
4e11ca7c8e | ||
|
|
591e2e4563 | ||
|
|
2161ffeb5e | ||
|
|
b856978211 | ||
|
|
19ee2d883e | ||
|
|
93089241c3 | ||
|
|
3b1c332932 | ||
|
|
7fd614f8b6 | ||
|
|
5567a5a1cd | ||
|
|
334e1dcabc | ||
|
|
cbf5ec57c6 | ||
|
|
9f33e247cf | ||
|
|
8c7e970603 | ||
|
|
360b418c97 | ||
|
|
af0db0c2a1 | ||
|
|
4c74192191 | ||
|
|
ee5fccd2c8 | ||
|
|
5bc89bf32b | ||
|
|
0b021e2114 | ||
|
|
15c1b1cc06 | ||
|
|
ee1b55e929 | ||
|
|
73d3a90c0b | ||
|
|
0764e37c8b | ||
|
|
bd5b2633f7 | ||
|
|
6b0c0486aa | ||
|
|
07ac3d3bb9 | ||
|
|
72e9d57e2e | ||
|
|
07965c6c61 | ||
|
|
72cc8e91a2 | ||
|
|
678eacaae5 | ||
|
|
cb7a3ccfdf | ||
|
|
076c10d34b | ||
|
|
2d75649a08 | ||
|
|
c8acc9a499 | ||
|
|
af499184ea | ||
|
|
ebac0a082c | ||
|
|
4d31bbb43a | ||
|
|
be20cf2e2c | ||
|
|
c33a1f57bc | ||
|
|
83817428c7 | ||
|
|
d2970a92b5 | ||
|
|
7b1bcf7789 | ||
|
|
44bdd1283d | ||
|
|
32dafbb1cb | ||
|
|
e622326285 | ||
|
|
d0287043c2 | ||
|
|
6c5b801c6f | ||
|
|
460bf241a5 | ||
|
|
ee179e19e5 | ||
|
|
3618a129df | ||
|
|
787146cc2c | ||
|
|
a1fe19f465 | ||
|
|
f2c26c116a | ||
|
|
d1d70c4d0c | ||
|
|
e5a659d445 | ||
|
|
39c4ecfe6a | ||
|
|
3e3aa00a1b | ||
|
|
9c2497460b | ||
|
|
f2f58d11d6 | ||
|
|
2d8be2f859 | ||
|
|
f6c57a44de | ||
|
|
0d2d0bdc11 | ||
|
|
e110701079 | ||
|
|
c10acd1837 | ||
|
|
f9f3b9a8ba | ||
|
|
e5b7449483 | ||
|
|
7077b0b71a | ||
|
|
57a446862f | ||
|
|
b2f2e7bd45 | ||
|
|
3fbd6771e9 | ||
|
|
8a10d6bc54 | ||
|
|
0bdde9dec2 | ||
|
|
348fa04c47 | ||
|
|
52f0d5f1d7 | ||
|
|
9c6111c368 | ||
|
|
9806533f56 | ||
|
|
e30245789c | ||
|
|
20b95c4585 | ||
|
|
6d0fc78462 | ||
|
|
5420ca2200 | ||
|
|
942f523f18 | ||
|
|
c812def317 | ||
|
|
9b2d352f8a | ||
|
|
d932875e66 | ||
|
|
7a2c151a4b | ||
|
|
81b0f0ec04 | ||
|
|
70ddb549b7 | ||
|
|
be3cb77f28 | ||
|
|
345b5e9577 | ||
|
|
e88f38bd10 | ||
|
|
bdc06afea2 | ||
|
|
f9348a4d9d | ||
|
|
44fd1172b8 | ||
|
|
c9c1267284 | ||
|
|
4fa5996414 | ||
|
|
920642411c | ||
|
|
2999c06a3a | ||
|
|
d97b2d70bd | ||
|
|
5e7b482972 | ||
|
|
9230a5a40f | ||
|
|
c3d1d4ae26 | ||
|
|
286b5a5179 | ||
|
|
c79d2ecfc4 | ||
|
|
70d6aec1a7 | ||
|
|
60a97dcf56 | ||
|
|
679a6db61d | ||
|
|
b70ae844a8 | ||
|
|
9dd507b717 | ||
|
|
e6de9db77e | ||
|
|
b6a37cfff3 | ||
|
|
42e37c1d02 | ||
|
|
09aa2fb8fd | ||
|
|
e764d2ce1c | ||
|
|
3f0b204728 | ||
|
|
f711cb3208 | ||
|
|
f10894fe47 | ||
|
|
b68872e3fd | ||
|
|
43126b09e4 | ||
|
|
6751d28839 | ||
|
|
8118d6b980 | ||
|
|
2c4b69f9ec | ||
|
|
d6cbc263e7 | ||
|
|
ba2536136b | ||
|
|
c56d24c0d7 | ||
|
|
ee49305053 | ||
|
|
b4d664fcb0 | ||
|
|
7192cfe549 | ||
|
|
7fcb1d1cb7 | ||
|
|
dbeaa8ad46 | ||
|
|
a3da588829 | ||
|
|
a3387c106b | ||
|
|
d9d241d806 | ||
|
|
bb5cdad333 | ||
|
|
44f90cbce0 | ||
|
|
a5c6f1dbe9 | ||
|
|
c8227b38fc | ||
|
|
77114ecdd0 | ||
|
|
23da92d0ff | ||
|
|
f88c09adca | ||
|
|
7dd5c8a0ba | ||
|
|
cf2ac28be2 | ||
|
|
43ddf45057 | ||
|
|
57212ec9a5 | ||
|
|
b55e86a8ba | ||
|
|
2b1a7d3fb6 | ||
|
|
250568bae5 | ||
|
|
cc97e2ff45 | ||
|
|
d92c430b8a | ||
|
|
184cdea75d | ||
|
|
7b9567ec28 | ||
|
|
75d8356f1b | ||
|
|
c82645a42a | ||
|
|
9e84d5d004 | ||
|
|
fd0d3dc463 | ||
|
|
eb985e875d | ||
|
|
ba3493adce |
4
.github/workflows/build.yaml
vendored
4
.github/workflows/build.yaml
vendored
@@ -16,6 +16,8 @@ jobs:
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
with:
|
||||
ref: ${{ github.ref }}
|
||||
|
||||
- name: Extract Tag
|
||||
run: echo "PACKAGE_VERSION=${{ github.ref }}" >> $GITHUB_ENV
|
||||
@@ -23,7 +25,7 @@ jobs:
|
||||
- name: Setup PHP
|
||||
uses: shivammathur/setup-php@v2
|
||||
with:
|
||||
php-version: 8.2
|
||||
php-version: 8.3
|
||||
extensions: opcache, gd
|
||||
tools: composer:v2
|
||||
coverage: none
|
||||
|
||||
4
.github/workflows/tests.yaml
vendored
4
.github/workflows/tests.yaml
vendored
@@ -13,7 +13,7 @@ jobs:
|
||||
unit-tests:
|
||||
strategy:
|
||||
matrix:
|
||||
php: [8.4, 8.3, 8.2]
|
||||
php: [8.5, 8.4, 8.3]
|
||||
os: [ubuntu-latest]
|
||||
|
||||
runs-on: ${{ matrix.os }}
|
||||
@@ -46,7 +46,7 @@ jobs:
|
||||
run: composer install --prefer-dist --no-progress
|
||||
|
||||
- name: Run test suite
|
||||
run: vendor/bin/codecept run
|
||||
run: php -d register_argc_argv=On vendor/bin/codecept run
|
||||
|
||||
# slack:
|
||||
# name: Slack
|
||||
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -48,3 +48,7 @@ tests/cache/*
|
||||
tests/error.log
|
||||
system/templates/testing/*
|
||||
/user/config/versions.yaml
|
||||
/user/data/recovery.window
|
||||
tmp/*
|
||||
/AGENTS.md
|
||||
/.claude
|
||||
|
||||
198
CHANGELOG.md
198
CHANGELOG.md
@@ -1,3 +1,189 @@
|
||||
# v1.8.0-beta.28
|
||||
## 12/08/2025
|
||||
|
||||
1. [](#new)
|
||||
* Added `updates.recovery_mode` config option to enable/disable recovery mode
|
||||
* Added admin blueprint toggle for recovery mode setting
|
||||
1. [](#improved)
|
||||
* Redesigned recovery mode screen with clearer messaging and modern UI
|
||||
* Added collapsible stack trace details to recovery mode screen
|
||||
* Added "Clear Recovery Mode" button that works without token authentication
|
||||
* Added "Disable Recovery Mode" option to disable via config from recovery screen
|
||||
* Added stack trace capture for exceptions in recovery context
|
||||
* Added PHP version validation from package's `defines.php` during safe upgrade
|
||||
* Added proxy methods to `Twig3CompatibilityLoader` for backwards compatibility with plugins that call loader methods directly (addPath, prependPath, getPaths, etc.)
|
||||
1. [](#bugfix)
|
||||
* Fixed recovery mode image path for Grav installations in subdirectories
|
||||
* Fixed backup restriction preventing backups on systems with Grav installed under `/var/www` - Fixes [#4002](https://github.com/getgrav/grav/issues/4002)
|
||||
* Fixed XSS false positives for legitimate HTML tags containing 'on' (caption, button, section) - Fixes [grav-plugin-admin#2472](https://github.com/getgrav/grav-plugin-admin/issues/2472)
|
||||
|
||||
# v1.8.0-beta.27
|
||||
## 11/30/2025
|
||||
|
||||
1. [](#improved)
|
||||
* Hardened Twig sandbox with expanded blacklist blocking 150+ dangerous functions and attack patterns
|
||||
* Added static regex caching in Security class for improved performance
|
||||
* Added path traversal protection to backup root configuration
|
||||
* Added validation for language codes to prevent regex injection DoS
|
||||
1. [](#bugfix)
|
||||
* Fixed path traversal vulnerability in username during account creation
|
||||
* Fixed username uniqueness bypass allowing duplicate accounts
|
||||
* Fixed arbitrary file read via `read_file()` Twig function
|
||||
* Fixed DoS via malformed cron expressions in scheduler
|
||||
* Fixed password hash exposure to frontend via JSON serialization
|
||||
* Fixed email disclosure in user edit page title
|
||||
* Fixed XSS via `isindex` tag bypass (CVE-2023-31506)
|
||||
* Fixed issue with FlexObjects caching [flex-objects#187](https://github.com/trilbymedia/grav-plugin-flex-objects/issues/187)
|
||||
|
||||
# v1.8.0-beta.26
|
||||
## 11/29/2025
|
||||
|
||||
1. [](#improved)
|
||||
* Improvements for JS minification and now pulls any broken JS out of pipeline
|
||||
* Disallow xref/xhref in SVGs
|
||||
* Upgraded to recently released Symfony 7.4
|
||||
1. [](#bugfix)
|
||||
* fix range requests for partial content in Utils::downloads() - Fixes [#3990](https://github.com/getgrav/grav-plugin-admin/issues/3990)
|
||||
|
||||
# v1.8.0-beta.25
|
||||
## 11/22/2025
|
||||
|
||||
1. [](#bugfix)
|
||||
* Fixed Twig version
|
||||
|
||||
# v1.8.0-beta.24
|
||||
## 11/20/2025
|
||||
|
||||
1. [](#improved)
|
||||
* More Twig3 compatibility fixes and tests
|
||||
* Changed snapshot creationg to use copy instead of move for improved reliability
|
||||
* Lazy load page optimization
|
||||
* Regex caching optimization
|
||||
* Gated Debugger `addEvent()` optimization
|
||||
* Various SafeUpgrade performance optimizations
|
||||
* Improved Twig Deferred block implementation
|
||||
1. [](#bugfix)
|
||||
* Fix various Twig3 deprecated notices
|
||||
* Fixed slow purge snapshot functionality and test
|
||||
|
||||
# v1.8.0-beta.23
|
||||
## 11/14/2025
|
||||
|
||||
1. [](#improved)
|
||||
* Refactored safe-upgrade from scratch with simplified 'install' step
|
||||
|
||||
# v1.8.0-beta.22
|
||||
## 11/06/2025
|
||||
|
||||
1. [](#bugfix)
|
||||
* Removed over zealous safety checks
|
||||
* Removed .gitattributes which was causing some unintended issues
|
||||
|
||||
# v1.8.0-beta.21
|
||||
## 11/05/2025
|
||||
|
||||
1. [](#improved)
|
||||
* Exclude dev files from exports
|
||||
1. [](#bugfix)
|
||||
* Ignore .github and .phan folders during self-upgrade
|
||||
* Fixed path check in self-upgrade
|
||||
|
||||
# v1.8.0-beta.20
|
||||
## 11/05/2025
|
||||
|
||||
1. [](#bugfix)
|
||||
* Fixed an issue where non-upgradable root-level folders were snapshotted
|
||||
|
||||
# v1.8.0-beta.19
|
||||
## 11/05/2025
|
||||
|
||||
1. [](#new)
|
||||
* 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
|
||||
* Cache fallbacks for unsupported Cache drivers
|
||||
* More safe-upgrade fixes around safe guarding `/user/` and maintaining permissions better
|
||||
1. [](#bugfix)
|
||||
* Fixed a regex issue that corrupted safe-upgrade output
|
||||
|
||||
# v1.8.0-beta.18
|
||||
## 10/31/2025
|
||||
|
||||
1. [](#improved)
|
||||
* Replaced legacy Doctrine cache dependency with Symfony-backed provider while keeping compatibility layer
|
||||
* More safe-upgrade improvements
|
||||
|
||||
# v1.8.0-beta.17
|
||||
## 10/23/2025
|
||||
|
||||
1. [](#improved)
|
||||
* Reworked `Monolog3` ship for better compatibility
|
||||
* Latest vendor libraries
|
||||
* Don't crash if `getManifest()` is not available
|
||||
|
||||
# v1.8.0-beta.16
|
||||
## 10/20/2025
|
||||
|
||||
1. [](#improved)
|
||||
* Set `bin/*` binaries to `+x` permission when upgrading via CLI
|
||||
* Improved Twig3 compatibility fixes
|
||||
|
||||
# v1.8.0-beta.15
|
||||
## 10/19/2025
|
||||
|
||||
1. [](#improved)
|
||||
* Safe handling of disabled plugins
|
||||
* Move `recover.flag` into `user://data`
|
||||
|
||||
# v1.8.0-beta.14
|
||||
## 10/18/2025
|
||||
|
||||
1. [](#improved)
|
||||
* Implemented more robust snapshot management via the `bin/restore` command
|
||||
|
||||
# v1.8.0-beta.13
|
||||
## 10/17/2025
|
||||
|
||||
1. [](#improved)
|
||||
* Refactored safe-upgrade check to use copy-based snapshot/install/restore system
|
||||
|
||||
# v1.8.0-beta.12
|
||||
## 10/17/2025
|
||||
|
||||
1. [](#bugfix)
|
||||
* new low-level routing for safe-upgrade check
|
||||
|
||||
# v1.8.0-beta.11
|
||||
## 10/16/2025
|
||||
|
||||
1. [](#bugfix)
|
||||
* Sync 1.7 changes to 1.8 branch
|
||||
|
||||
# v1.8.0-beta.10
|
||||
## 10/16/2025
|
||||
|
||||
1. [](#bugfix)
|
||||
* Fixed an issue with **safe upgrade** losing dot files
|
||||
|
||||
# v1.8.0-beta.9
|
||||
## 10/16/2025
|
||||
|
||||
1. [](#new)
|
||||
* Added new **core safe upgrade** installer with staging, validation, and rollback support
|
||||
|
||||
# v1.8.0-beta.8
|
||||
## 10/14/2025
|
||||
|
||||
1. [](#improved)
|
||||
* Upgraded to latest Symfony 7 (might cause issues with some plugins)
|
||||
* `wordCount` twig filter (merged from 1.7 branch)
|
||||
* More PHP 8.4 compatibility fixes
|
||||
* Update all vendor libraries to latest
|
||||
1. [](#bugfix)
|
||||
* Fixed some CLI level bugs
|
||||
* Fixed a Twig Sandbox bybpass issue
|
||||
|
||||
# v1.8.0-beta.7
|
||||
## 09/22/2025
|
||||
|
||||
@@ -58,7 +244,7 @@
|
||||
## 10/23/2024
|
||||
|
||||
1. [](#new)
|
||||
* Set minimum requirements to **PHP 8.2**
|
||||
* Set minimum requirements to **PHP 8.3**
|
||||
* Updated to **Twig 2.14**
|
||||
* Updated to **Symfony 6.4**
|
||||
* Updated to **Monolog 2.3**
|
||||
@@ -69,6 +255,15 @@
|
||||
* Removed `system.umask_fix` setting for security reasons
|
||||
* Support phpstan level 6 in Framework classes
|
||||
|
||||
|
||||
# v1.7.50
|
||||
## UNRELEASED
|
||||
|
||||
1. [](#new)
|
||||
* Added staged self-upgrade pipeline with manifest snapshots and atomic swaps for Grav core updates.
|
||||
* Introduced recovery mode with token-gated UI, plugin quarantine, and CLI rollback support.
|
||||
* Added `bin/gpm preflight` compatibility scanner and `bin/gpm rollback` utility.
|
||||
|
||||
# v1.7.49.5
|
||||
## 09/10/2025
|
||||
|
||||
@@ -120,7 +315,6 @@
|
||||
* Bug in `exif_read_data` [#3878](https://github.com/getgrav/grav/pull/3878)
|
||||
* Fix parser error in URI: [#3894](https://github.com/getgrav/grav/issues/3894)
|
||||
|
||||
>>>>>>> develop
|
||||
|
||||
# v1.7.48
|
||||
## 10/28/2024
|
||||
|
||||
@@ -12,7 +12,7 @@ The underlying architecture of Grav is designed to use well-established and _bes
|
||||
* [Markdown](https://en.wikipedia.org/wiki/Markdown): for easy content creation
|
||||
* [YAML](https://yaml.org): for simple configuration
|
||||
* [Parsedown](https://parsedown.org/): for fast Markdown and Markdown Extra support
|
||||
* [Doctrine Cache](https://www.doctrine-project.org/projects/doctrine-orm/en/latest/reference/caching.html): layer for performance
|
||||
* [Symfony Cache](https://symfony.com/doc/current/components/cache.html): backend layer for performance
|
||||
* [Pimple Dependency Injection Container](https://github.com/silexphp/Pimple): for extensibility and maintainability
|
||||
* [Symfony Event Dispatcher](https://symfony.com/doc/current/components/event_dispatcher/introduction.html): for plugin event handling
|
||||
* [Symfony Console](https://symfony.com/doc/current/components/console/introduction.html): for CLI interface
|
||||
@@ -20,7 +20,7 @@ The underlying architecture of Grav is designed to use well-established and _bes
|
||||
|
||||
# Requirements
|
||||
|
||||
- PHP 8.2 or higher. Check the [required modules list](https://learn.getgrav.org/basics/requirements#php-requirements)
|
||||
- PHP 8.3 or higher. Check the [required modules list](https://learn.getgrav.org/basics/requirements#php-requirements)
|
||||
- Check the [Apache](https://learn.getgrav.org/basics/requirements#apache-requirements) or [IIS](https://learn.getgrav.org/basics/requirements#iis-requirements) requirements
|
||||
|
||||
# Documentation
|
||||
|
||||
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);
|
||||
}
|
||||
Binary file not shown.
634
bin/restore
Executable file
634
bin/restore
Executable file
@@ -0,0 +1,634 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Grav Snapshot Restore Utility
|
||||
*
|
||||
* Lightweight CLI that can list and apply safe-upgrade snapshots without
|
||||
* bootstrapping the full Grav application (or any plugins).
|
||||
*/
|
||||
|
||||
$root = dirname(__DIR__);
|
||||
|
||||
define('GRAV_CLI', true);
|
||||
define('GRAV_REQUEST_TIME', microtime(true));
|
||||
|
||||
if (!file_exists($root . '/vendor/autoload.php')) {
|
||||
fwrite(STDERR, "Unable to locate vendor/autoload.php. Run composer install first.\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
$autoload = require $root . '/vendor/autoload.php';
|
||||
|
||||
if (!file_exists($root . '/index.php')) {
|
||||
fwrite(STDERR, "FATAL: Must be run from Grav root directory.\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
use Grav\Common\Filesystem\Folder;
|
||||
use Grav\Common\Recovery\RecoveryManager;
|
||||
use Grav\Common\Upgrade\SafeUpgradeService;
|
||||
use Symfony\Component\Yaml\Yaml;
|
||||
|
||||
const RESTORE_USAGE = <<<USAGE
|
||||
Grav Restore Utility
|
||||
|
||||
Usage:
|
||||
bin/restore list [--staging-root=/absolute/path]
|
||||
Lists all available snapshots (most recent first).
|
||||
|
||||
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;
|
||||
|
||||
/**
|
||||
* @param array $args
|
||||
* @return array{command:string,arguments:array,options:array}
|
||||
*/
|
||||
function parseArguments(array $args): array
|
||||
{
|
||||
array_shift($args); // remove script name
|
||||
|
||||
$command = null;
|
||||
$arguments = [];
|
||||
$options = [];
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
if (null === $command) {
|
||||
$command = $arg;
|
||||
} else {
|
||||
$arguments[] = $arg;
|
||||
}
|
||||
}
|
||||
|
||||
if (null === $command) {
|
||||
$command = 'interactive';
|
||||
}
|
||||
|
||||
return [
|
||||
'command' => $command,
|
||||
'arguments' => $arguments,
|
||||
'options' => $options,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array $options
|
||||
* @return SafeUpgradeService
|
||||
*/
|
||||
function createUpgradeService(array $options): SafeUpgradeService
|
||||
{
|
||||
$serviceOptions = ['root' => GRAV_ROOT];
|
||||
|
||||
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,label:?string,source_version:?string,target_version:?string,created_at:int}>
|
||||
*/
|
||||
function loadSnapshots(): array
|
||||
{
|
||||
$manifestDir = GRAV_ROOT . '/user/data/upgrades';
|
||||
if (!is_dir($manifestDir)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$files = glob($manifestDir . '/*.json') ?: [];
|
||||
rsort($files);
|
||||
|
||||
$snapshots = [];
|
||||
foreach ($files as $file) {
|
||||
$decoded = json_decode(file_get_contents($file) ?: '', true);
|
||||
if (!is_array($decoded) || empty($decoded['id'])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$snapshots[] = [
|
||||
'id' => $decoded['id'],
|
||||
'label' => $decoded['label'] ?? null,
|
||||
'source_version' => $decoded['source_version'] ?? null,
|
||||
'target_version' => $decoded['target_version'] ?? null,
|
||||
'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) {
|
||||
echo "No snapshots found. Run bin/gpm self-upgrade (with safe upgrade enabled) to create one.\n";
|
||||
exit(0);
|
||||
}
|
||||
|
||||
echo "Available snapshots:\n";
|
||||
foreach ($snapshots as $snapshot) {
|
||||
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) {
|
||||
echo "Missing snapshot id.\n\n" . RESTORE_USAGE . "\n";
|
||||
exit(1);
|
||||
}
|
||||
|
||||
applySnapshot($snapshotId, $options);
|
||||
break;
|
||||
|
||||
case 'snapshot':
|
||||
createManualSnapshot($options);
|
||||
break;
|
||||
|
||||
case 'recovery':
|
||||
$action = strtolower($arguments[0] ?? 'status');
|
||||
$manager = new RecoveryManager(GRAV_ROOT);
|
||||
|
||||
switch ($action) {
|
||||
case 'clear':
|
||||
if ($manager->isActive()) {
|
||||
$manager->clear();
|
||||
echo "Recovery flag cleared.\n";
|
||||
} else {
|
||||
echo "Recovery mode is not active.\n";
|
||||
}
|
||||
exit(0);
|
||||
|
||||
case 'status':
|
||||
if (!$manager->isActive()) {
|
||||
echo "Recovery mode is not active.\n";
|
||||
exit(0);
|
||||
}
|
||||
|
||||
$context = $manager->getContext();
|
||||
if (!$context) {
|
||||
echo "Recovery flag present but context could not be parsed.\n";
|
||||
exit(1);
|
||||
}
|
||||
|
||||
$created = isset($context['created_at']) ? date('c', (int)$context['created_at']) : 'unknown';
|
||||
$token = $context['token'] ?? '(missing)';
|
||||
$message = $context['message'] ?? '(no message)';
|
||||
$plugin = $context['plugin'] ?? '(none detected)';
|
||||
$file = $context['file'] ?? '(unknown file)';
|
||||
$line = $context['line'] ?? '(unknown line)';
|
||||
|
||||
echo "Recovery flag context:\n";
|
||||
echo " Token: {$token}\n";
|
||||
echo " Message: {$message}\n";
|
||||
echo " Plugin: {$plugin}\n";
|
||||
echo " File: {$file}\n";
|
||||
echo " Line: {$line}\n";
|
||||
echo " Created: {$created}\n";
|
||||
|
||||
$window = $manager->getUpgradeWindow();
|
||||
if ($window) {
|
||||
$expires = isset($window['expires_at']) ? date('c', (int)$window['expires_at']) : 'unknown';
|
||||
$reason = $window['reason'] ?? '(unknown)';
|
||||
echo " Window: active ({$reason}, expires {$expires})\n";
|
||||
} else {
|
||||
echo " Window: inactive\n";
|
||||
}
|
||||
exit(0);
|
||||
|
||||
default:
|
||||
echo "Unknown recovery action: {$action}\n\n" . RESTORE_USAGE . "\n";
|
||||
exit(1);
|
||||
}
|
||||
|
||||
case 'help':
|
||||
default:
|
||||
echo RESTORE_USAGE . "\n";
|
||||
exit($command === 'help' ? 0 : 1);
|
||||
}
|
||||
@@ -12,7 +12,7 @@
|
||||
"homepage": "https://getgrav.org",
|
||||
"license": "MIT",
|
||||
"require": {
|
||||
"php": "^8.2",
|
||||
"php": "^8.3",
|
||||
"ext-json": "*",
|
||||
"ext-openssl": "*",
|
||||
"ext-curl": "*",
|
||||
@@ -24,24 +24,22 @@
|
||||
"symfony/polyfill-iconv": "^1.24",
|
||||
"symfony/polyfill-php80": "^1.24",
|
||||
"symfony/polyfill-php81": "^1.24",
|
||||
"psr/simple-cache": "^1.0",
|
||||
"psr/http-message": "^1.1",
|
||||
"psr/simple-cache": "^1.0 || ^2.0 || ^3.0",
|
||||
"psr/http-message": "^1.1 || ^2.0",
|
||||
"psr/http-server-middleware": "^1.0",
|
||||
"psr/container": "^1.1",
|
||||
"psr/log": "^1.1",
|
||||
"symfony/cache": "^6.4",
|
||||
"symfony/yaml": "^6.4",
|
||||
"symfony/console": "^6.4",
|
||||
"symfony/event-dispatcher": "^6.4",
|
||||
"symfony/var-exporter": "^6.4",
|
||||
"symfony/var-dumper": "^6.4",
|
||||
"symfony/process": "^6.4",
|
||||
"symfony/http-client": "^6.4",
|
||||
"psr/container": "^1.1 || ^2.0",
|
||||
"psr/log": "^1.1 || ^2.0 || ^3.0",
|
||||
"symfony/cache": "^6.4 || ^7.0",
|
||||
"symfony/yaml": "^6.4 || ^7.0",
|
||||
"symfony/console": "^6.4 || ^7.0",
|
||||
"symfony/event-dispatcher": "^6.4 || ^7.0",
|
||||
"symfony/var-exporter": "^6.4 || ^7.0",
|
||||
"symfony/var-dumper": "^6.4 || ^7.0",
|
||||
"symfony/process": "^6.4 || ^7.0",
|
||||
"symfony/http-client": "^6.4 || ^7.0",
|
||||
"twig/twig": "3.x-dev",
|
||||
"monolog/monolog": "^2.0",
|
||||
"doctrine/cache": "^2.2",
|
||||
"monolog/monolog": "^3.0",
|
||||
"doctrine/collections": "^2.2",
|
||||
"pimple/pimple": "~3.5.0",
|
||||
"nyholm/psr7-server": "^1.1",
|
||||
"nyholm/psr7": "^1.8",
|
||||
"erusev/parsedown": "^1.7",
|
||||
@@ -52,7 +50,8 @@
|
||||
"dragonmantank/cron-expression": "^3.3",
|
||||
"willdurand/negotiation": "^3.1",
|
||||
"rhukster/dom-sanitizer": "^1.0",
|
||||
"matthiasmullie/minify": "^1.3",
|
||||
"tubalmartin/cssmin": "^4.1",
|
||||
"tedivm/jshrink": "^1.7",
|
||||
"donatj/phpuseragentparser": "~1.9",
|
||||
"guzzlehttp/psr7": "^2.7",
|
||||
"filp/whoops": "~2.16",
|
||||
@@ -69,7 +68,7 @@
|
||||
"codeception/codeception": "^5.1",
|
||||
"phpstan/phpstan": "^2.1",
|
||||
"phpstan/phpstan-deprecation-rules": "^2.0",
|
||||
"phpunit/php-code-coverage": "~9.2",
|
||||
"phpunit/php-code-coverage": "^11.0",
|
||||
"getgrav/markdowndocs": "^2.0",
|
||||
"codeception/module-asserts": "*",
|
||||
"codeception/module-phpbrowser": "*",
|
||||
@@ -107,7 +106,7 @@
|
||||
"config": {
|
||||
"apcu-autoloader": true,
|
||||
"platform": {
|
||||
"php": "8.2"
|
||||
"php": "8.3"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
@@ -115,7 +114,8 @@
|
||||
"Grav\\": "system/src/Grav",
|
||||
"Doctrine\\": "system/src/Doctrine",
|
||||
"RocketTheme\\": "system/src/RocketTheme",
|
||||
"Twig\\": "system/src/Twig"
|
||||
"Twig\\": "system/src/Twig",
|
||||
"Pimple\\": "system/src/Pimple"
|
||||
},
|
||||
"files": [
|
||||
"system/defines.php",
|
||||
@@ -141,8 +141,8 @@
|
||||
"phpstan": "vendor/bin/phpstan analyse -l 2 -c ./tests/phpstan/phpstan.neon --memory-limit=720M system/src",
|
||||
"phpstan-framework": "vendor/bin/phpstan analyse -l 6 -c ./tests/phpstan/phpstan.neon --memory-limit=480M system/src/Grav/Framework system/src/Grav/Events system/src/Grav/Installer",
|
||||
"phpstan-plugins": "vendor/bin/phpstan analyse -l 1 -c ./tests/phpstan/plugins.neon --memory-limit=400M user/plugins",
|
||||
"test": "vendor/bin/codecept run unit",
|
||||
"test-windows": "vendor\\bin\\codecept run unit"
|
||||
"test": "php -d register_argc_argv=On vendor/bin/codecept run unit",
|
||||
"test-windows": "php -d register_argc_argv=On vendor\\bin\\codecept run unit"
|
||||
},
|
||||
"extra": {
|
||||
"branch-alias": {
|
||||
|
||||
1830
composer.lock
generated
1830
composer.lock
generated
File diff suppressed because it is too large
Load Diff
77
index.php
77
index.php
@@ -10,7 +10,8 @@
|
||||
namespace Grav;
|
||||
|
||||
\define('GRAV_REQUEST_TIME', microtime(true));
|
||||
\define('GRAV_PHP_MIN', '8.2.0');
|
||||
|
||||
\define('GRAV_PHP_MIN', '8.3.0');
|
||||
|
||||
if (PHP_SAPI === 'cli-server') {
|
||||
$symfony_server = stripos(getenv('_'), 'symfony') !== false || stripos($_SERVER['SERVER_SOFTWARE'] ?? '', 'symfony') !== false || stripos($_ENV['SERVER_SOFTWARE'] ?? '', 'symfony') !== false;
|
||||
@@ -20,6 +21,46 @@ if (PHP_SAPI === 'cli-server') {
|
||||
}
|
||||
}
|
||||
|
||||
if (PHP_SAPI !== 'cli') {
|
||||
if (!isset($_SERVER['argv']) && !ini_get('register_argc_argv')) {
|
||||
$queryString = $_SERVER['QUERY_STRING'] ?? '';
|
||||
$_SERVER['argv'] = $queryString !== '' ? [$queryString] : [];
|
||||
$_SERVER['argc'] = $queryString !== '' ? 1 : 0;
|
||||
}
|
||||
|
||||
$requestUri = $_SERVER['REQUEST_URI'] ?? '';
|
||||
$scriptName = $_SERVER['SCRIPT_NAME'] ?? '';
|
||||
$path = parse_url($requestUri, PHP_URL_PATH) ?? '/';
|
||||
$path = str_replace('\\', '/', $path);
|
||||
|
||||
$scriptDir = str_replace('\\', '/', dirname($scriptName));
|
||||
if ($scriptDir && $scriptDir !== '/' && $scriptDir !== '.') {
|
||||
if (strpos($path, $scriptDir) === 0) {
|
||||
$path = substr($path, strlen($scriptDir));
|
||||
$path = $path === '' ? '/' : $path;
|
||||
}
|
||||
}
|
||||
|
||||
if ($path === '/___safe-upgrade-status') {
|
||||
$statusEndpoint = __DIR__ . '/user/plugins/admin/safe-upgrade-status.php';
|
||||
if (!\defined('GRAV_ROOT')) {
|
||||
// Minimal bootstrap so the status script has the expected constants.
|
||||
require_once __DIR__ . '/system/defines.php';
|
||||
}
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
if (is_file($statusEndpoint)) {
|
||||
require $statusEndpoint;
|
||||
} else {
|
||||
http_response_code(404);
|
||||
echo json_encode([
|
||||
'status' => 'error',
|
||||
'message' => 'Safe upgrade status endpoint unavailable.',
|
||||
]);
|
||||
}
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure vendor libraries exist
|
||||
$autoload = __DIR__ . '/vendor/autoload.php';
|
||||
if (!is_file($autoload)) {
|
||||
@@ -36,6 +77,34 @@ date_default_timezone_set(@date_default_timezone_get());
|
||||
@ini_set('default_charset', 'UTF-8');
|
||||
mb_internal_encoding('UTF-8');
|
||||
|
||||
// Use getcwd() for paths to support symlinked index.php (GRAV_ROOT uses getcwd())
|
||||
$gravRoot = rtrim(str_replace(DIRECTORY_SEPARATOR, '/', getenv('GRAV_ROOT') ?: getcwd()), '/');
|
||||
|
||||
// Helper function to check if recovery mode is enabled in config (updates.recovery_mode)
|
||||
$isRecoveryEnabled = static function () use ($gravRoot) {
|
||||
$userConfig = $gravRoot . '/user/config/system.yaml';
|
||||
if (!is_file($userConfig)) {
|
||||
return true; // Default enabled
|
||||
}
|
||||
$content = file_get_contents($userConfig);
|
||||
if ($content === false) {
|
||||
return true;
|
||||
}
|
||||
if (preg_match('/^\s*updates:\s*\n(?:\s+\w+:.*\n)*?\s+recovery_mode:\s*(true|false|1|0)\s*$/m', $content, $matches)) {
|
||||
return in_array(strtolower($matches[1]), ['true', '1'], true);
|
||||
}
|
||||
return true; // Default enabled
|
||||
};
|
||||
|
||||
$recoveryFlag = $gravRoot . '/user/data/recovery.flag';
|
||||
if (PHP_SAPI !== 'cli' && is_file($recoveryFlag) && $isRecoveryEnabled()) {
|
||||
if (!defined('GRAV_ROOT')) {
|
||||
define('GRAV_ROOT', $gravRoot);
|
||||
}
|
||||
require __DIR__ . '/system/recovery.php';
|
||||
return 0;
|
||||
}
|
||||
|
||||
use Grav\Common\Grav;
|
||||
use RocketTheme\Toolbox\Event\Event;
|
||||
|
||||
@@ -47,5 +116,11 @@ try {
|
||||
$grav->process();
|
||||
} catch (\Error|\Exception $e) {
|
||||
$grav->fireEvent('onFatalException', new Event(['exception' => $e]));
|
||||
|
||||
if (PHP_SAPI !== 'cli' && is_file($recoveryFlag) && $isRecoveryEnabled()) {
|
||||
require __DIR__ . '/system/recovery.php';
|
||||
return 0;
|
||||
}
|
||||
|
||||
throw $e;
|
||||
}
|
||||
|
||||
505
needs_fixing.txt
Normal file
505
needs_fixing.txt
Normal file
@@ -0,0 +1,505 @@
|
||||
------ ----------------------------------------------------
|
||||
Line src/Grav/Common/GPM/Response.php
|
||||
------ ----------------------------------------------------
|
||||
3 Class Grav\Common\GPM\Response not found.
|
||||
🪪 class.notFound
|
||||
💡 Learn more at
|
||||
https://phpstan.org/user-guide/discovering-symbols
|
||||
------ ----------------------------------------------------
|
||||
|
||||
------ -----------------------------------------------------------
|
||||
Line src/Grav/Common/Grav.php
|
||||
------ -----------------------------------------------------------
|
||||
148 No error to ignore is reported on line 148.
|
||||
681 Unsafe usage of new static().
|
||||
🪪 new.static
|
||||
💡 See:
|
||||
https://phpstan.org/blog/solving-phpstan-error-unsafe-usa
|
||||
ge-of-new-static
|
||||
------ -----------------------------------------------------------
|
||||
|
||||
------ -----------------------------------------------------------
|
||||
Line src/Grav/Common/Page/Collection.php
|
||||
------ -----------------------------------------------------------
|
||||
112 Unsafe usage of new static().
|
||||
🪪 new.static
|
||||
💡 See:
|
||||
https://phpstan.org/blog/solving-phpstan-error-unsafe-usa
|
||||
ge-of-new-static
|
||||
209 Unsafe usage of new static().
|
||||
🪪 new.static
|
||||
💡 See:
|
||||
https://phpstan.org/blog/solving-phpstan-error-unsafe-usa
|
||||
ge-of-new-static
|
||||
------ -----------------------------------------------------------
|
||||
|
||||
------ -----------------------------------------------------------
|
||||
Line src/Grav/Common/Processors/InitializeProcessor.php
|
||||
------ -----------------------------------------------------------
|
||||
58 Unsafe usage of new static().
|
||||
🪪 new.static
|
||||
💡 See:
|
||||
https://phpstan.org/blog/solving-phpstan-error-unsafe-usa
|
||||
ge-of-new-static
|
||||
------ -----------------------------------------------------------
|
||||
|
||||
------ -------------------------------------------------------
|
||||
Line src/Grav/Common/Scheduler/Job.php
|
||||
------ -------------------------------------------------------
|
||||
574 Call to static method sendEmail() on an unknown class
|
||||
Grav\Plugin\Email\Utils.
|
||||
🪪 class.notFound
|
||||
💡 Learn more at
|
||||
https://phpstan.org/user-guide/discovering-symbols
|
||||
------ -------------------------------------------------------
|
||||
|
||||
------ ----------------------------------------------------------
|
||||
Line src/Grav/Common/Scheduler/SchedulerController.php
|
||||
------ ----------------------------------------------------------
|
||||
41 Class Grav\Common\Scheduler\ModernScheduler not found.
|
||||
🪪 class.notFound
|
||||
💡 Learn more at
|
||||
https://phpstan.org/user-guide/discovering-symbols
|
||||
45 Instantiated class Grav\Common\Scheduler\ModernScheduler
|
||||
not found.
|
||||
🪪 class.notFound
|
||||
💡 Learn more at
|
||||
https://phpstan.org/user-guide/discovering-symbols
|
||||
------ ----------------------------------------------------------
|
||||
|
||||
------ --------------------------------------------------------
|
||||
Line src/Grav/Common/Service/SchedulerServiceProvider.php
|
||||
------ --------------------------------------------------------
|
||||
55 Instantiated class Grav\Common\Scheduler\JobWorker not
|
||||
found.
|
||||
🪪 class.notFound
|
||||
💡 Learn more at
|
||||
https://phpstan.org/user-guide/discovering-symbols
|
||||
------ --------------------------------------------------------
|
||||
|
||||
------ ---------------------------------------------
|
||||
Line src/Grav/Common/Session.php
|
||||
------ ---------------------------------------------
|
||||
132 No error to ignore is reported on line 132.
|
||||
137 No error to ignore is reported on line 137.
|
||||
------ ---------------------------------------------
|
||||
|
||||
------ -----------------------------------------------------------
|
||||
Line src/Grav/Common/Uri.php
|
||||
------ -----------------------------------------------------------
|
||||
1131 Unsafe usage of new static().
|
||||
🪪 new.static
|
||||
💡 See:
|
||||
https://phpstan.org/blog/solving-phpstan-error-unsafe-usa
|
||||
ge-of-new-static
|
||||
------ -----------------------------------------------------------
|
||||
|
||||
------ --------------------------------------------------------
|
||||
Line src/Grav/Console/Application/Application.php
|
||||
------ --------------------------------------------------------
|
||||
125 Return type mixed of method
|
||||
Grav\Console\Application\Application::configureIO() is
|
||||
not covariant with return type void of method
|
||||
Symfony\Component\Console\Application::configureIO().
|
||||
------ --------------------------------------------------------
|
||||
|
||||
------ ---------------------------------------------------------
|
||||
Line src/Grav/Console/ConsoleCommand.php
|
||||
------ ---------------------------------------------------------
|
||||
29 Return type mixed of method
|
||||
Grav\Console\ConsoleCommand::execute() is not covariant
|
||||
with return type int of method
|
||||
Symfony\Component\Console\Command\Command::execute().
|
||||
------ ---------------------------------------------------------
|
||||
|
||||
------ -----------------------------------------------------------
|
||||
Line src/Grav/Console/ConsoleTrait.php (in context of class
|
||||
Grav\Console\ConsoleCommand)
|
||||
------ -----------------------------------------------------------
|
||||
89 Method Grav\Console\ConsoleCommand::addOption() overrides
|
||||
method
|
||||
Symfony\Component\Console\Command\Command::addOption()
|
||||
but misses parameter #6 $suggestedValues.
|
||||
------ -----------------------------------------------------------
|
||||
|
||||
------ --------------------------------------------------------
|
||||
Line src/Grav/Console/ConsoleTrait.php (in context of class
|
||||
Grav\Console\GpmCommand)
|
||||
------ --------------------------------------------------------
|
||||
89 Method Grav\Console\GpmCommand::addOption() overrides
|
||||
method
|
||||
Symfony\Component\Console\Command\Command::addOption()
|
||||
but misses parameter #6 $suggestedValues.
|
||||
------ --------------------------------------------------------
|
||||
|
||||
------ --------------------------------------------------------
|
||||
Line src/Grav/Console/ConsoleTrait.php (in context of class
|
||||
Grav\Console\GravCommand)
|
||||
------ --------------------------------------------------------
|
||||
89 Method Grav\Console\GravCommand::addOption() overrides
|
||||
method
|
||||
Symfony\Component\Console\Command\Command::addOption()
|
||||
but misses parameter #6 $suggestedValues.
|
||||
------ --------------------------------------------------------
|
||||
|
||||
------ ----------------------------------------------------------
|
||||
Line src/Grav/Console/GpmCommand.php
|
||||
------ ----------------------------------------------------------
|
||||
31 Return type mixed of method
|
||||
Grav\Console\GpmCommand::execute() is not covariant with
|
||||
return type int of method
|
||||
Symfony\Component\Console\Command\Command::execute().
|
||||
39 No error to ignore is reported on line 39.
|
||||
------ ----------------------------------------------------------
|
||||
|
||||
------ -----------------------------------------------------------
|
||||
Line src/Grav/Console/GravCommand.php
|
||||
------ -----------------------------------------------------------
|
||||
29 Return type mixed of method
|
||||
Grav\Console\GravCommand::execute() is not covariant with
|
||||
return type int of method
|
||||
Symfony\Component\Console\Command\Command::execute().
|
||||
------ -----------------------------------------------------------
|
||||
|
||||
------ -----------------------------------------------------------
|
||||
Line src/Grav/Framework/Acl/RecursiveActionIterator.php
|
||||
------ -----------------------------------------------------------
|
||||
62 Unsafe usage of new static().
|
||||
🪪 new.static
|
||||
💡 See:
|
||||
https://phpstan.org/blog/solving-phpstan-error-unsafe-usa
|
||||
ge-of-new-static
|
||||
------ -----------------------------------------------------------
|
||||
|
||||
------ ----------------------------------------------------------
|
||||
Line src/Grav/Framework/Cache/CacheTrait.php (in context of
|
||||
class Grav\Framework\Cache\AbstractCache)
|
||||
------ ----------------------------------------------------------
|
||||
87 Return type mixed of method
|
||||
Grav\Framework\Cache\AbstractCache::get() is not
|
||||
covariant with return type mixed of method
|
||||
Psr\SimpleCache\CacheInterface::get().
|
||||
102 Return type mixed of method
|
||||
Grav\Framework\Cache\AbstractCache::set() is not
|
||||
covariant with return type bool of method
|
||||
Psr\SimpleCache\CacheInterface::set().
|
||||
117 Return type mixed of method
|
||||
Grav\Framework\Cache\AbstractCache::delete() is not
|
||||
covariant with return type bool of method
|
||||
Psr\SimpleCache\CacheInterface::delete().
|
||||
127 Return type mixed of method
|
||||
Grav\Framework\Cache\AbstractCache::clear() is not
|
||||
covariant with return type bool of method
|
||||
Psr\SimpleCache\CacheInterface::clear().
|
||||
138 Return type mixed of method
|
||||
Grav\Framework\Cache\AbstractCache::getMultiple() is not
|
||||
covariant with return type iterable of method
|
||||
Psr\SimpleCache\CacheInterface::getMultiple().
|
||||
181 Return type mixed of method
|
||||
Grav\Framework\Cache\AbstractCache::setMultiple() is not
|
||||
covariant with return type bool of method
|
||||
Psr\SimpleCache\CacheInterface::setMultiple().
|
||||
214 Return type mixed of method
|
||||
Grav\Framework\Cache\AbstractCache::deleteMultiple() is
|
||||
not covariant with return type bool of method
|
||||
Psr\SimpleCache\CacheInterface::deleteMultiple().
|
||||
242 Return type mixed of method
|
||||
Grav\Framework\Cache\AbstractCache::has() is not
|
||||
covariant with return type bool of method
|
||||
Psr\SimpleCache\CacheInterface::has().
|
||||
------ ----------------------------------------------------------
|
||||
|
||||
------ ----------------------------------------------------------
|
||||
Line src/Grav/Framework/Collection/AbstractFileCollection.php
|
||||
------ ----------------------------------------------------------
|
||||
95 No error to ignore is reported on line 95.
|
||||
------ ----------------------------------------------------------
|
||||
|
||||
------ -----------------------------------------------------------
|
||||
Line src/Grav/Framework/Collection/AbstractIndexCollection.php
|
||||
------ -----------------------------------------------------------
|
||||
154 No error to ignore is reported on line 154.
|
||||
168 No error to ignore is reported on line 168.
|
||||
185 No error to ignore is reported on line 185.
|
||||
201 No error to ignore is reported on line 201.
|
||||
507 Unsafe usage of new static().
|
||||
🪪 new.static
|
||||
💡 See:
|
||||
https://phpstan.org/blog/solving-phpstan-error-unsafe-usa
|
||||
ge-of-new-static
|
||||
------ -----------------------------------------------------------
|
||||
|
||||
------ -----------------------------------------------------------
|
||||
Line src/Grav/Framework/Collection/AbstractLazyCollection.php
|
||||
------ -----------------------------------------------------------
|
||||
29 Property
|
||||
Grav\Framework\Collection\AbstractLazyCollection::$collec
|
||||
tion overriding property
|
||||
Doctrine\Common\Collections\AbstractLazyCollection<TKey o
|
||||
f (int|string),T>::$collection (Doctrine\Common\Collectio
|
||||
ns\Collection|null) should also have native type
|
||||
Doctrine\Common\Collections\Collection|null.
|
||||
------ -----------------------------------------------------------
|
||||
|
||||
------ -----------------------------------------------------------
|
||||
Line src/Grav/Framework/Contracts/Relationships/RelationshipIn
|
||||
terface.php
|
||||
------ -----------------------------------------------------------
|
||||
80 Return type iterable of method
|
||||
Grav\Framework\Contracts\Relationships\RelationshipInterf
|
||||
ace::getIterator() is not covariant with tentative return
|
||||
type Traversable of method IteratorAggregate<string,T of
|
||||
Grav\Framework\Contracts\Object\IdentifierInterface>::get
|
||||
Iterator().
|
||||
💡 Make it covariant, or use the #[\ReturnTypeWillChange]
|
||||
attribute to temporarily suppress the error.
|
||||
------ -----------------------------------------------------------
|
||||
|
||||
------ -----------------------------------------------------------
|
||||
Line src/Grav/Framework/Filesystem/Filesystem.php
|
||||
------ -----------------------------------------------------------
|
||||
51 Unsafe usage of new static().
|
||||
🪪 new.static
|
||||
💡 See:
|
||||
https://phpstan.org/blog/solving-phpstan-error-unsafe-usa
|
||||
ge-of-new-static
|
||||
252 No error to ignore is reported on line 252.
|
||||
------ -----------------------------------------------------------
|
||||
|
||||
------ ---------------------------------------------
|
||||
Line src/Grav/Framework/Flex/FlexCollection.php
|
||||
------ ---------------------------------------------
|
||||
102 No error to ignore is reported on line 102.
|
||||
------ ---------------------------------------------
|
||||
|
||||
------ -----------------------------------------------------------
|
||||
Line src/Grav/Framework/Flex/FlexIdentifier.php
|
||||
------ -----------------------------------------------------------
|
||||
27 Unsafe usage of new static().
|
||||
🪪 new.static
|
||||
💡 See:
|
||||
https://phpstan.org/blog/solving-phpstan-error-unsafe-usa
|
||||
ge-of-new-static
|
||||
------ -----------------------------------------------------------
|
||||
|
||||
------ ----------------------------------------------------------
|
||||
Line src/Grav/Framework/Flex/FlexIndex.php
|
||||
------ ----------------------------------------------------------
|
||||
109 No error to ignore is reported on line 109.
|
||||
934 Method Grav\Framework\Flex\FlexIndex::reduce() should
|
||||
return TInitial|TReturn but return statement is missing.
|
||||
🪪 return.missing
|
||||
------ ----------------------------------------------------------
|
||||
|
||||
------ -----------------------------------------------------------
|
||||
Line src/Grav/Framework/Form/FormFlashFile.php
|
||||
------ -----------------------------------------------------------
|
||||
62 Return type mixed of method
|
||||
Grav\Framework\Form\FormFlashFile::getStream() is not
|
||||
covariant with return type
|
||||
Psr\Http\Message\StreamInterface of method
|
||||
Psr\Http\Message\UploadedFileInterface::getStream().
|
||||
83 Return type mixed of method
|
||||
Grav\Framework\Form\FormFlashFile::moveTo() is not
|
||||
covariant with return type void of method
|
||||
Psr\Http\Message\UploadedFileInterface::moveTo().
|
||||
123 Return type mixed of method
|
||||
Grav\Framework\Form\FormFlashFile::getSize() is not
|
||||
covariant with return type int|null of method
|
||||
Psr\Http\Message\UploadedFileInterface::getSize().
|
||||
131 Return type mixed of method
|
||||
Grav\Framework\Form\FormFlashFile::getError() is not
|
||||
covariant with return type int of method
|
||||
Psr\Http\Message\UploadedFileInterface::getError().
|
||||
139 Return type mixed of method
|
||||
Grav\Framework\Form\FormFlashFile::getClientFilename() is
|
||||
not covariant with return type string|null of method
|
||||
Psr\Http\Message\UploadedFileInterface::getClientFilename
|
||||
().
|
||||
147 Return type mixed of method
|
||||
Grav\Framework\Form\FormFlashFile::getClientMediaType()
|
||||
is not covariant with return type string|null of method
|
||||
Psr\Http\Message\UploadedFileInterface::getClientMediaTyp
|
||||
e().
|
||||
------ -----------------------------------------------------------
|
||||
|
||||
------ -----------------------------------------------------------
|
||||
Line src/Grav/Framework/Logger/Processors/UserProcessor.php
|
||||
------ -----------------------------------------------------------
|
||||
24 Parameter #1 $record (array) of method
|
||||
Grav\Framework\Logger\Processors\UserProcessor::__invoke(
|
||||
) is not contravariant with parameter #1 $record
|
||||
(Monolog\LogRecord) of method
|
||||
Monolog\Processor\ProcessorInterface::__invoke().
|
||||
------ -----------------------------------------------------------
|
||||
|
||||
------ -----------------------------------------------------------
|
||||
Line src/Grav/Framework/Media/MediaIdentifier.php
|
||||
------ -----------------------------------------------------------
|
||||
30 Unsafe usage of new static().
|
||||
🪪 new.static
|
||||
💡 See:
|
||||
https://phpstan.org/blog/solving-phpstan-error-unsafe-usa
|
||||
ge-of-new-static
|
||||
------ -----------------------------------------------------------
|
||||
|
||||
------ -----------------------------------------------------------
|
||||
Line src/Grav/Framework/Media/UploadedMediaObject.php
|
||||
------ -----------------------------------------------------------
|
||||
36 Unsafe usage of new static().
|
||||
🪪 new.static
|
||||
💡 See:
|
||||
https://phpstan.org/blog/solving-phpstan-error-unsafe-usa
|
||||
ge-of-new-static
|
||||
------ -----------------------------------------------------------
|
||||
|
||||
------ -----------------------------------------------------------
|
||||
Line src/Grav/Framework/Mime/MimeTypes.php
|
||||
------ -----------------------------------------------------------
|
||||
42 Unsafe usage of new static().
|
||||
🪪 new.static
|
||||
💡 See:
|
||||
https://phpstan.org/blog/solving-phpstan-error-unsafe-usa
|
||||
ge-of-new-static
|
||||
------ -----------------------------------------------------------
|
||||
|
||||
------ ------------------------------------------------
|
||||
Line src/Grav/Framework/Object/ObjectCollection.php
|
||||
------ ------------------------------------------------
|
||||
96 No error to ignore is reported on line 96.
|
||||
------ ------------------------------------------------
|
||||
|
||||
------ ---------------------------------------------
|
||||
Line src/Grav/Framework/Object/ObjectIndex.php
|
||||
------ ---------------------------------------------
|
||||
193 No error to ignore is reported on line 193.
|
||||
------ ---------------------------------------------
|
||||
|
||||
------ -----------------------------------------------------------
|
||||
Line src/Grav/Framework/Psr7/Stream.php
|
||||
------ -----------------------------------------------------------
|
||||
31 Unsafe usage of new static().
|
||||
🪪 new.static
|
||||
💡 See:
|
||||
https://phpstan.org/blog/solving-phpstan-error-unsafe-usa
|
||||
ge-of-new-static
|
||||
------ -----------------------------------------------------------
|
||||
|
||||
------ -----------------------------------------------------------
|
||||
Line src/Grav/Framework/Psr7/Traits/ServerRequestDecoratorTrai
|
||||
t.php (in context of class
|
||||
Grav\Framework\Psr7\ServerRequest)
|
||||
------ -----------------------------------------------------------
|
||||
51 Return type mixed of method
|
||||
Grav\Framework\Psr7\ServerRequest::getAttributes() is not
|
||||
covariant with return type array of method
|
||||
Psr\Http\Message\ServerRequestInterface::getAttributes().
|
||||
60 Return type mixed of method
|
||||
Grav\Framework\Psr7\ServerRequest::getCookieParams() is
|
||||
not covariant with return type array of method
|
||||
Psr\Http\Message\ServerRequestInterface::getCookieParams(
|
||||
).
|
||||
76 Return type mixed of method
|
||||
Grav\Framework\Psr7\ServerRequest::getQueryParams() is
|
||||
not covariant with return type array of method
|
||||
Psr\Http\Message\ServerRequestInterface::getQueryParams()
|
||||
.
|
||||
84 Return type mixed of method
|
||||
Grav\Framework\Psr7\ServerRequest::getServerParams() is
|
||||
not covariant with return type array of method
|
||||
Psr\Http\Message\ServerRequestInterface::getServerParams(
|
||||
).
|
||||
92 Return type mixed of method
|
||||
Grav\Framework\Psr7\ServerRequest::getUploadedFiles() is
|
||||
not covariant with return type array of method
|
||||
Psr\Http\Message\ServerRequestInterface::getUploadedFiles
|
||||
().
|
||||
100 Return type mixed of method
|
||||
Grav\Framework\Psr7\ServerRequest::withAttribute() is not
|
||||
covariant with return type
|
||||
Psr\Http\Message\ServerRequestInterface of method
|
||||
Psr\Http\Message\ServerRequestInterface::withAttribute().
|
||||
125 Return type mixed of method
|
||||
Grav\Framework\Psr7\ServerRequest::withoutAttribute() is
|
||||
not covariant with return type
|
||||
Psr\Http\Message\ServerRequestInterface of method
|
||||
Psr\Http\Message\ServerRequestInterface::withoutAttribute
|
||||
().
|
||||
136 Return type mixed of method
|
||||
Grav\Framework\Psr7\ServerRequest::withCookieParams() is
|
||||
not covariant with return type
|
||||
Psr\Http\Message\ServerRequestInterface of method
|
||||
Psr\Http\Message\ServerRequestInterface::withCookieParams
|
||||
().
|
||||
147 Return type mixed of method
|
||||
Grav\Framework\Psr7\ServerRequest::withParsedBody() is
|
||||
not covariant with return type
|
||||
Psr\Http\Message\ServerRequestInterface of method
|
||||
Psr\Http\Message\ServerRequestInterface::withParsedBody()
|
||||
.
|
||||
158 Return type mixed of method
|
||||
Grav\Framework\Psr7\ServerRequest::withQueryParams() is
|
||||
not covariant with return type
|
||||
Psr\Http\Message\ServerRequestInterface of method
|
||||
Psr\Http\Message\ServerRequestInterface::withQueryParams(
|
||||
).
|
||||
169 Return type mixed of method
|
||||
Grav\Framework\Psr7\ServerRequest::withUploadedFiles() is
|
||||
not covariant with return type
|
||||
Psr\Http\Message\ServerRequestInterface of method
|
||||
Psr\Http\Message\ServerRequestInterface::withUploadedFile
|
||||
s().
|
||||
------ -----------------------------------------------------------
|
||||
|
||||
------ -----------------------------------------------------------
|
||||
Line src/Grav/Framework/Relationships/Relationships.php
|
||||
------ -----------------------------------------------------------
|
||||
107 Return type mixed of method
|
||||
Grav\Framework\Relationships\Relationships::offsetSet()
|
||||
is not covariant with tentative return type void of
|
||||
method
|
||||
ArrayAccess<string,Grav\Framework\Contracts\Relationships
|
||||
\RelationshipInterface<T of Grav\Framework\Contracts\Obje
|
||||
ct\IdentifierInterface, P of
|
||||
Grav\Framework\Contracts\Object\IdentifierInterface>>::of
|
||||
fsetSet().
|
||||
💡 Make it covariant, or use the #[\ReturnTypeWillChange]
|
||||
attribute to temporarily suppress the error.
|
||||
116 Return type mixed of method
|
||||
Grav\Framework\Relationships\Relationships::offsetUnset()
|
||||
is not covariant with tentative return type void of
|
||||
method
|
||||
ArrayAccess<string,Grav\Framework\Contracts\Relationships
|
||||
\RelationshipInterface<T of Grav\Framework\Contracts\Obje
|
||||
ct\IdentifierInterface, P of
|
||||
Grav\Framework\Contracts\Object\IdentifierInterface>>::of
|
||||
fsetUnset().
|
||||
💡 Make it covariant, or use the #[\ReturnTypeWillChange]
|
||||
attribute to temporarily suppress the error.
|
||||
------ -----------------------------------------------------------
|
||||
|
||||
------ -----------------------------------------------------------
|
||||
Line src/Twig/DeferredExtension/DeferredNodeVisitorCompat.php
|
||||
------ -----------------------------------------------------------
|
||||
30 Parameter #1 $node (Twig_NodeInterface) of method
|
||||
Twig\DeferredExtension\DeferredNodeVisitorCompat::enterNo
|
||||
de() is not contravariant with parameter #1 $node
|
||||
(Twig\Node\Node) of method
|
||||
Twig\NodeVisitor\NodeVisitorInterface::enterNode().
|
||||
30 Parameter $node of method
|
||||
Twig\DeferredExtension\DeferredNodeVisitorCompat::enterNo
|
||||
de() has invalid type Twig_NodeInterface.
|
||||
🪪 class.notFound
|
||||
46 Parameter #1 $node (Twig_NodeInterface) of method
|
||||
Twig\DeferredExtension\DeferredNodeVisitorCompat::leaveNo
|
||||
de() is not contravariant with parameter #1 $node
|
||||
(Twig\Node\Node) of method
|
||||
Twig\NodeVisitor\NodeVisitorInterface::leaveNode().
|
||||
46 Parameter $node of method
|
||||
Twig\DeferredExtension\DeferredNodeVisitorCompat::leaveNo
|
||||
de() has invalid type Twig_NodeInterface.
|
||||
🪪 class.notFound
|
||||
------ -----------------------------------------------------------
|
||||
|
||||
[ERROR] Found 74 errors
|
||||
|
||||
@@ -122,4 +122,13 @@ form:
|
||||
default: '* 3 * * *'
|
||||
validate:
|
||||
required: true
|
||||
.schedule_environment:
|
||||
type: select
|
||||
label: PLUGIN_ADMIN.BACKUPS_PROFILE_ENVIRONMENT
|
||||
help: PLUGIN_ADMIN.BACKUPS_PROFILE_ENVIRONMENT_HELP
|
||||
default: ''
|
||||
options:
|
||||
'': 'Default (cli)'
|
||||
localhost: 'Localhost'
|
||||
cli: 'CLI'
|
||||
|
||||
|
||||
@@ -1580,6 +1580,43 @@ form:
|
||||
validate:
|
||||
type: bool
|
||||
|
||||
updates_section:
|
||||
type: section
|
||||
title: PLUGIN_ADMIN.UPDATES_SECTION
|
||||
|
||||
updates.safe_upgrade:
|
||||
type: toggle
|
||||
label: PLUGIN_ADMIN.SAFE_UPGRADE
|
||||
help: PLUGIN_ADMIN.SAFE_UPGRADE_HELP
|
||||
highlight: 1
|
||||
default: true
|
||||
options:
|
||||
1: PLUGIN_ADMIN.YES
|
||||
0: PLUGIN_ADMIN.NO
|
||||
validate:
|
||||
type: bool
|
||||
|
||||
updates.safe_upgrade_snapshot_limit:
|
||||
type: number
|
||||
label: PLUGIN_ADMIN.SAFE_UPGRADE_SNAPSHOT_LIMIT
|
||||
help: PLUGIN_ADMIN.SAFE_UPGRADE_SNAPSHOT_LIMIT_HELP
|
||||
default: 5
|
||||
validate:
|
||||
type: int
|
||||
min: 0
|
||||
|
||||
updates.recovery_mode:
|
||||
type: toggle
|
||||
label: PLUGIN_ADMIN.RECOVERY_MODE
|
||||
help: PLUGIN_ADMIN.RECOVERY_MODE_HELP
|
||||
highlight: 1
|
||||
default: true
|
||||
options:
|
||||
1: PLUGIN_ADMIN.YES
|
||||
0: PLUGIN_ADMIN.NO
|
||||
validate:
|
||||
type: bool
|
||||
|
||||
http_section:
|
||||
type: section
|
||||
title: PLUGIN_ADMIN.HTTP_SECTION
|
||||
|
||||
@@ -72,7 +72,7 @@ config:
|
||||
# Edit view
|
||||
edit:
|
||||
title:
|
||||
template: "{{ form.value('fullname') ?? form.value('username') }} <{{ form.value('email') }}>"
|
||||
template: "{{ form.value('fullname') ?? form.value('username') }}"
|
||||
|
||||
# Configure view
|
||||
configure:
|
||||
|
||||
@@ -10,6 +10,7 @@ profiles:
|
||||
root: '/'
|
||||
schedule: false
|
||||
schedule_at: '0 3 * * *'
|
||||
schedule_environment: ''
|
||||
exclude_paths: "/backup\r\n/cache\r\n/images\r\n/logs\r\n/tmp"
|
||||
exclude_files: ".DS_Store\r\n.git\r\n.svn\r\n.hg\r\n.idea\r\n.vscode\r\nnode_modules"
|
||||
|
||||
|
||||
@@ -1,47 +1,50 @@
|
||||
xss_whitelist: [admin.super] # Whitelist of user access that should 'skip' XSS checking
|
||||
xss_whitelist:
|
||||
- admin.super
|
||||
xss_enabled:
|
||||
on_events: true
|
||||
invalid_protocols: true
|
||||
moz_binding: true
|
||||
html_inline_styles: true
|
||||
dangerous_tags: true
|
||||
on_events: true
|
||||
invalid_protocols: true
|
||||
moz_binding: true
|
||||
html_inline_styles: true
|
||||
dangerous_tags: true
|
||||
xss_invalid_protocols:
|
||||
- javascript
|
||||
- livescript
|
||||
- vbscript
|
||||
- mocha
|
||||
- feed
|
||||
- data
|
||||
- javascript
|
||||
- livescript
|
||||
- vbscript
|
||||
- mocha
|
||||
- feed
|
||||
- data
|
||||
xss_dangerous_tags:
|
||||
- applet
|
||||
- meta
|
||||
- xml
|
||||
- blink
|
||||
- link
|
||||
- style
|
||||
- script
|
||||
- embed
|
||||
- object
|
||||
- iframe
|
||||
- frame
|
||||
- frameset
|
||||
- ilayer
|
||||
- layer
|
||||
- bgsound
|
||||
- title
|
||||
- base
|
||||
- applet
|
||||
- meta
|
||||
- xml
|
||||
- blink
|
||||
- link
|
||||
- style
|
||||
- script
|
||||
- embed
|
||||
- object
|
||||
- iframe
|
||||
- frame
|
||||
- frameset
|
||||
- ilayer
|
||||
- layer
|
||||
- bgsound
|
||||
- title
|
||||
- base
|
||||
- isindex
|
||||
uploads_dangerous_extensions:
|
||||
- php
|
||||
- php2
|
||||
- php3
|
||||
- php4
|
||||
- php5
|
||||
- phar
|
||||
- phtml
|
||||
- html
|
||||
- htm
|
||||
- shtml
|
||||
- shtm
|
||||
- js
|
||||
- exe
|
||||
- php
|
||||
- php2
|
||||
- php3
|
||||
- php4
|
||||
- php5
|
||||
- phar
|
||||
- phtml
|
||||
- html
|
||||
- htm
|
||||
- shtml
|
||||
- shtm
|
||||
- js
|
||||
- exe
|
||||
sanitize_svg: true
|
||||
salt: SbmgUJQ62MqNc0
|
||||
|
||||
@@ -203,6 +203,11 @@ gpm:
|
||||
releases: stable # Set to either 'stable' or 'testing'
|
||||
official_gpm_only: true # By default GPM direct-install will only allow URLs via the official GPM proxy to ensure security
|
||||
|
||||
updates:
|
||||
safe_upgrade: true # Enable guarded staging+rollback pipeline for Grav self-updates
|
||||
safe_upgrade_snapshot_limit: 5 # Maximum number of safe-upgrade snapshots to retain (0 = unlimited)
|
||||
recovery_mode: true # Enable recovery mode when fatal errors occur during upgrades
|
||||
|
||||
http:
|
||||
method: auto # Either 'curl', 'fopen' or 'auto'. 'auto' will try fopen first and if not available cURL
|
||||
enable_proxy: true # Enable proxy server configuration
|
||||
|
||||
@@ -9,13 +9,13 @@
|
||||
|
||||
// Some standard defines
|
||||
define('GRAV', true);
|
||||
define('GRAV_VERSION', '1.8.0-beta.7');
|
||||
define('GRAV_VERSION', '1.8.0-beta.28');
|
||||
define('GRAV_SCHEMA', '1.8.0_2025-09-21_0');
|
||||
define('GRAV_TESTING', true);
|
||||
|
||||
// PHP minimum requirement
|
||||
if (!defined('GRAV_PHP_MIN')) {
|
||||
define('GRAV_PHP_MIN', '8.2.0');
|
||||
define('GRAV_PHP_MIN', '8.3.0');
|
||||
}
|
||||
|
||||
// Directory separator
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -119,3 +119,10 @@ GRAV:
|
||||
ERROR2: Bad number of elements
|
||||
ERROR3: The jquery_element should be set into jqCron settings
|
||||
ERROR4: Unrecognized expression
|
||||
|
||||
PLUGIN_ADMIN:
|
||||
UPDATES_SECTION: Updates
|
||||
SAFE_UPGRADE: Safe self-upgrade
|
||||
SAFE_UPGRADE_HELP: When enabled, Grav core updates use staged installation with automatic rollback support.
|
||||
SAFE_UPGRADE_SNAPSHOT_LIMIT: Safe-upgrade snapshots to keep
|
||||
SAFE_UPGRADE_SNAPSHOT_LIMIT_HELP: Maximum number of snapshots to retain for safe upgrades (0 disables pruning).
|
||||
|
||||
547
system/recovery.php
Normal file
547
system/recovery.php
Normal file
@@ -0,0 +1,547 @@
|
||||
<?php
|
||||
|
||||
use Grav\Common\Filesystem\Folder;
|
||||
use Grav\Common\Recovery\RecoveryManager;
|
||||
use Grav\Common\Upgrade\SafeUpgradeService;
|
||||
|
||||
if (!\defined('GRAV_ROOT')) {
|
||||
\define('GRAV_ROOT', dirname(__DIR__));
|
||||
}
|
||||
|
||||
session_start([
|
||||
'name' => 'grav-recovery',
|
||||
'cookie_httponly' => true,
|
||||
'cookie_secure' => !empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off',
|
||||
'cookie_samesite' => 'Lax',
|
||||
]);
|
||||
|
||||
$manager = new RecoveryManager();
|
||||
$context = $manager->getContext() ?? [];
|
||||
$token = $context['token'] ?? null;
|
||||
$authenticated = $token && isset($_SESSION['grav_recovery_authenticated']) && hash_equals($_SESSION['grav_recovery_authenticated'], $token);
|
||||
$errorMessage = null;
|
||||
$notice = null;
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$action = $_POST['action'] ?? '';
|
||||
if ($action === 'authenticate') {
|
||||
$provided = trim($_POST['token'] ?? '');
|
||||
if ($token && hash_equals($token, $provided)) {
|
||||
$_SESSION['grav_recovery_authenticated'] = $token;
|
||||
header('Location: ' . $_SERVER['REQUEST_URI']);
|
||||
exit;
|
||||
}
|
||||
$errorMessage = 'Invalid recovery token.';
|
||||
} elseif ($action === 'clear-flag') {
|
||||
// Clear recovery flag - allowed without authentication
|
||||
$manager->clear();
|
||||
$_SESSION['grav_recovery_authenticated'] = null;
|
||||
$notice = 'Recovery flag cleared. <a href="' . htmlspecialchars($_SERVER['REQUEST_URI'], ENT_QUOTES, 'UTF-8') . '">Reload the page</a> to continue.';
|
||||
} elseif ($action === 'disable-recovery') {
|
||||
// Disable recovery mode in config (updates.recovery_mode) - allowed without authentication
|
||||
$configDir = GRAV_ROOT . '/user/config';
|
||||
$configFile = $configDir . '/system.yaml';
|
||||
Folder::create($configDir);
|
||||
|
||||
$config = [];
|
||||
if (is_file($configFile)) {
|
||||
$content = file_get_contents($configFile);
|
||||
if ($content !== false) {
|
||||
// Simple YAML parsing for this specific case
|
||||
$config = \Symfony\Component\Yaml\Yaml::parse($content) ?? [];
|
||||
}
|
||||
}
|
||||
|
||||
if (!isset($config['updates'])) {
|
||||
$config['updates'] = [];
|
||||
}
|
||||
$config['updates']['recovery_mode'] = false;
|
||||
$yaml = \Symfony\Component\Yaml\Yaml::dump($config, 4, 2);
|
||||
file_put_contents($configFile, $yaml);
|
||||
|
||||
// Also clear the recovery flag
|
||||
$manager->clear();
|
||||
$_SESSION['grav_recovery_authenticated'] = null;
|
||||
$notice = 'Recovery mode has been disabled. <a href="' . htmlspecialchars($_SERVER['REQUEST_URI'], ENT_QUOTES, 'UTF-8') . '">Reload the page</a> to continue.';
|
||||
} elseif ($authenticated) {
|
||||
$service = new SafeUpgradeService();
|
||||
try {
|
||||
if ($action === 'rollback' && !empty($_POST['manifest'])) {
|
||||
$service->rollback(trim($_POST['manifest']));
|
||||
$manager->clear();
|
||||
$_SESSION['grav_recovery_authenticated'] = null;
|
||||
$notice = 'Rollback complete. Please reload Grav.';
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$errorMessage = $e->getMessage();
|
||||
}
|
||||
} else {
|
||||
$errorMessage = 'Authentication required for this action.';
|
||||
}
|
||||
}
|
||||
|
||||
$quarantineFile = GRAV_ROOT . '/user/data/upgrades/quarantine.json';
|
||||
$quarantine = [];
|
||||
if (is_file($quarantineFile)) {
|
||||
$decoded = json_decode(file_get_contents($quarantineFile), true);
|
||||
if (is_array($decoded)) {
|
||||
$quarantine = $decoded;
|
||||
}
|
||||
}
|
||||
|
||||
$manifestDir = GRAV_ROOT . '/user/data/upgrades';
|
||||
$snapshots = [];
|
||||
if (is_dir($manifestDir)) {
|
||||
$files = glob($manifestDir . '/*.json');
|
||||
if ($files) {
|
||||
foreach ($files as $file) {
|
||||
$decoded = json_decode(file_get_contents($file), true);
|
||||
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;
|
||||
|
||||
// Determine base URL for assets
|
||||
$scriptName = $_SERVER['SCRIPT_NAME'] ?? '/index.php';
|
||||
$baseUrl = rtrim(dirname($scriptName), '/\\');
|
||||
if ($baseUrl === '.' || $baseUrl === '') {
|
||||
$baseUrl = '';
|
||||
}
|
||||
|
||||
header('Content-Type: text/html; charset=utf-8');
|
||||
|
||||
?><!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Grav Recovery Mode</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
margin: 0;
|
||||
padding: 20px;
|
||||
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
|
||||
min-height: 100vh;
|
||||
color: #e8e8e8;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.container {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.header {
|
||||
text-align: center;
|
||||
padding: 30px 0;
|
||||
}
|
||||
.header img {
|
||||
width: 80px;
|
||||
height: auto;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.header h1 {
|
||||
font-size: 1.8rem;
|
||||
margin: 0 0 8px 0;
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
}
|
||||
.header .subtitle {
|
||||
color: #a0a0a0;
|
||||
font-size: 1rem;
|
||||
margin: 0;
|
||||
}
|
||||
.alert {
|
||||
padding: 16px 20px;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 20px;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
}
|
||||
.alert-error {
|
||||
background: rgba(239, 68, 68, 0.15);
|
||||
border: 1px solid rgba(239, 68, 68, 0.3);
|
||||
color: #fca5a5;
|
||||
}
|
||||
.alert-warning {
|
||||
background: rgba(245, 158, 11, 0.15);
|
||||
border: 1px solid rgba(245, 158, 11, 0.3);
|
||||
color: #fcd34d;
|
||||
}
|
||||
.alert-success {
|
||||
background: rgba(34, 197, 94, 0.15);
|
||||
border: 1px solid rgba(34, 197, 94, 0.3);
|
||||
color: #86efac;
|
||||
}
|
||||
.alert-success a { color: #4ade80; }
|
||||
.alert-icon {
|
||||
font-size: 1.5rem;
|
||||
flex-shrink: 0;
|
||||
line-height: 1;
|
||||
}
|
||||
.alert-content { flex: 1; }
|
||||
.alert-title {
|
||||
font-weight: 600;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.card {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.card h2 {
|
||||
font-size: 1.1rem;
|
||||
margin: 0 0 16px 0;
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
}
|
||||
.card h3 {
|
||||
font-size: 1rem;
|
||||
margin: 0 0 12px 0;
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
}
|
||||
.error-summary {
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
font-family: 'SF Mono', Monaco, 'Cascadia Code', monospace;
|
||||
font-size: 0.9rem;
|
||||
overflow-x: auto;
|
||||
}
|
||||
.error-summary .error-message {
|
||||
color: #f87171;
|
||||
word-break: break-word;
|
||||
}
|
||||
.error-summary .error-location {
|
||||
color: #94a3b8;
|
||||
margin-top: 8px;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.btn-group {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 16px;
|
||||
}
|
||||
button, .btn {
|
||||
padding: 12px 24px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
text-decoration: none;
|
||||
}
|
||||
.btn-primary {
|
||||
background: #3b82f6;
|
||||
color: #fff;
|
||||
}
|
||||
.btn-primary:hover {
|
||||
background: #2563eb;
|
||||
}
|
||||
.btn-secondary {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: #e8e8e8;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
.btn-secondary:hover {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
.btn-danger {
|
||||
background: rgba(239, 68, 68, 0.2);
|
||||
color: #fca5a5;
|
||||
border: 1px solid rgba(239, 68, 68, 0.3);
|
||||
}
|
||||
.btn-danger:hover {
|
||||
background: rgba(239, 68, 68, 0.3);
|
||||
}
|
||||
details {
|
||||
margin-top: 16px;
|
||||
}
|
||||
summary {
|
||||
cursor: pointer;
|
||||
color: #94a3b8;
|
||||
font-size: 0.9rem;
|
||||
padding: 8px 0;
|
||||
user-select: none;
|
||||
}
|
||||
summary:hover {
|
||||
color: #cbd5e1;
|
||||
}
|
||||
.stack-trace {
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
margin-top: 12px;
|
||||
font-family: 'SF Mono', Monaco, 'Cascadia Code', monospace;
|
||||
font-size: 0.8rem;
|
||||
overflow-x: auto;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
color: #94a3b8;
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.info-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
.info-list li {
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
.info-list li:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
.info-list .label {
|
||||
color: #94a3b8;
|
||||
min-width: 120px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.info-list .value {
|
||||
color: #e8e8e8;
|
||||
word-break: break-word;
|
||||
}
|
||||
input[type="text"] {
|
||||
width: 100%;
|
||||
padding: 12px 16px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
border-radius: 8px;
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
color: #fff;
|
||||
font-size: 0.95rem;
|
||||
margin-top: 8px;
|
||||
}
|
||||
input[type="text"]:focus {
|
||||
outline: none;
|
||||
border-color: #3b82f6;
|
||||
}
|
||||
label {
|
||||
color: #94a3b8;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.help-text {
|
||||
color: #64748b;
|
||||
font-size: 0.85rem;
|
||||
margin-top: 8px;
|
||||
}
|
||||
code {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-family: 'SF Mono', Monaco, 'Cascadia Code', monospace;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.quarantine-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
.quarantine-list li {
|
||||
padding: 12px;
|
||||
background: rgba(245, 158, 11, 0.1);
|
||||
border-radius: 6px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.quarantine-list .plugin-name {
|
||||
font-weight: 600;
|
||||
color: #fcd34d;
|
||||
}
|
||||
.quarantine-list .plugin-time {
|
||||
color: #94a3b8;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.snapshot-info {
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
border-radius: 8px;
|
||||
padding: 12px 16px;
|
||||
margin: 12px 0;
|
||||
}
|
||||
.snapshot-info code {
|
||||
color: #60a5fa;
|
||||
}
|
||||
.snapshot-info small {
|
||||
color: #64748b;
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
}
|
||||
@media (max-width: 600px) {
|
||||
body { padding: 12px; }
|
||||
.card { padding: 16px; }
|
||||
.btn-group { flex-direction: column; }
|
||||
button, .btn { width: 100%; justify-content: center; }
|
||||
.info-list li { flex-direction: column; gap: 4px; }
|
||||
.info-list .label { min-width: auto; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<img src="<?php echo htmlspecialchars($baseUrl, ENT_QUOTES, 'UTF-8'); ?>/system/assets/grav.png" alt="Grav" onerror="this.style.display='none'">
|
||||
<h1>Recovery Mode</h1>
|
||||
<p class="subtitle">Grav has encountered an error during a recent update</p>
|
||||
</div>
|
||||
|
||||
<?php if ($notice): ?>
|
||||
<div class="alert alert-success">
|
||||
<span class="alert-icon">✓</span>
|
||||
<div class="alert-content"><?php echo $notice; ?></div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ($errorMessage): ?>
|
||||
<div class="alert alert-error">
|
||||
<span class="alert-icon">⚠</span>
|
||||
<div class="alert-content">
|
||||
<div class="alert-title">Action Failed</div>
|
||||
<?php echo htmlspecialchars($errorMessage, ENT_QUOTES, 'UTF-8'); ?>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="alert alert-warning">
|
||||
<span class="alert-icon">⚠</span>
|
||||
<div class="alert-content">
|
||||
<div class="alert-title">A Fatal Error Occurred</div>
|
||||
Grav detected a fatal error after a recent upgrade and has entered recovery mode to protect your site.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Error Details</h2>
|
||||
<div class="error-summary">
|
||||
<div class="error-message"><?php echo htmlspecialchars($context['message'] ?? 'Unknown error', ENT_QUOTES, 'UTF-8'); ?></div>
|
||||
<?php if (!empty($context['file'])): ?>
|
||||
<div class="error-location">
|
||||
<?php echo htmlspecialchars($context['file'], ENT_QUOTES, 'UTF-8'); ?><?php if (!empty($context['line'])): ?>:<?php echo htmlspecialchars((string)$context['line'], ENT_QUOTES, 'UTF-8'); ?><?php endif; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<?php if (!empty($context['trace'])): ?>
|
||||
<details>
|
||||
<summary>View Stack Trace</summary>
|
||||
<div class="stack-trace"><?php echo htmlspecialchars($context['trace'], ENT_QUOTES, 'UTF-8'); ?></div>
|
||||
</details>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if (!empty($context['plugin'])): ?>
|
||||
<details open>
|
||||
<summary>Affected Plugin</summary>
|
||||
<ul class="info-list" style="margin-top: 12px;">
|
||||
<li>
|
||||
<span class="label">Plugin</span>
|
||||
<span class="value"><strong><?php echo htmlspecialchars($context['plugin'], ENT_QUOTES, 'UTF-8'); ?></strong> (has been automatically disabled)</span>
|
||||
</li>
|
||||
</ul>
|
||||
</details>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<?php if ($quarantine): ?>
|
||||
<div class="card">
|
||||
<h2>Quarantined Plugins</h2>
|
||||
<p class="help-text" style="margin-top: 0;">These plugins have been automatically disabled due to errors:</p>
|
||||
<ul class="quarantine-list">
|
||||
<?php foreach ($quarantine as $entry): ?>
|
||||
<li>
|
||||
<span class="plugin-name"><?php echo htmlspecialchars($entry['slug'], ENT_QUOTES, 'UTF-8'); ?></span>
|
||||
<span class="plugin-time">Disabled at <?php echo date('Y-m-d H:i:s', $entry['disabled_at']); ?></span>
|
||||
<?php if (!empty($entry['message'])): ?>
|
||||
<div style="margin-top: 4px; font-size: 0.85rem; color: #94a3b8;"><?php echo htmlspecialchars($entry['message'], ENT_QUOTES, 'UTF-8'); ?></div>
|
||||
<?php endif; ?>
|
||||
</li>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="card">
|
||||
<h2>What would you like to do?</h2>
|
||||
<p style="margin-top: 0; color: #94a3b8;">Choose an action to resolve this issue:</p>
|
||||
|
||||
<div class="btn-group">
|
||||
<form method="post" style="display: contents;">
|
||||
<input type="hidden" name="action" value="clear-flag">
|
||||
<button type="submit" class="btn btn-primary">Clear Recovery & Continue</button>
|
||||
</form>
|
||||
<form method="post" style="display: contents;">
|
||||
<input type="hidden" name="action" value="disable-recovery">
|
||||
<button type="submit" class="btn btn-secondary" title="Prevents recovery mode from activating in the future">Disable Recovery Mode</button>
|
||||
</form>
|
||||
</div>
|
||||
<p class="help-text">
|
||||
<strong>Clear Recovery & Continue:</strong> Clears the recovery flag and attempts to load your site normally.<br>
|
||||
<strong>Disable Recovery Mode:</strong> Sets <code>updates.recovery_mode: false</code> in your configuration so recovery mode won't trigger again.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<?php if ($latestSnapshot): ?>
|
||||
<div class="card">
|
||||
<h2>Rollback to Previous Version</h2>
|
||||
<p style="margin-top: 0; color: #94a3b8;">If the error persists, you can rollback to a previous Grav version.</p>
|
||||
|
||||
<div class="snapshot-info">
|
||||
<code><?php echo htmlspecialchars($latestSnapshot['id'], ENT_QUOTES, 'UTF-8'); ?></code>
|
||||
<?php if (!empty($latestSnapshot['label'])): ?>
|
||||
<small><?php echo htmlspecialchars($latestSnapshot['label'], ENT_QUOTES, 'UTF-8'); ?></small>
|
||||
<?php endif; ?>
|
||||
<small>Grav <?php echo htmlspecialchars($latestSnapshot['target_version'] ?? 'unknown', ENT_QUOTES, 'UTF-8'); ?> — Created <?php echo date('Y-m-d H:i:s', (int)$latestSnapshot['created_at']); ?></small>
|
||||
</div>
|
||||
|
||||
<?php if (!$authenticated): ?>
|
||||
<p class="help-text">To rollback, enter the recovery token found in <code>user/data/recovery.flag</code></p>
|
||||
<form method="post">
|
||||
<input type="hidden" name="action" value="authenticate">
|
||||
<label for="token">Recovery Token</label>
|
||||
<input id="token" name="token" type="text" autocomplete="one-time-code" placeholder="Enter token from recovery.flag" required>
|
||||
<div class="btn-group">
|
||||
<button type="submit" class="btn btn-secondary">Authenticate for Rollback</button>
|
||||
</div>
|
||||
</form>
|
||||
<?php else: ?>
|
||||
<form method="post">
|
||||
<input type="hidden" name="action" value="rollback">
|
||||
<input type="hidden" name="manifest" value="<?php echo htmlspecialchars($latestSnapshot['id'], ENT_QUOTES, 'UTF-8'); ?>">
|
||||
<div class="btn-group">
|
||||
<button type="submit" class="btn btn-danger">Rollback to This Snapshot</button>
|
||||
</div>
|
||||
</form>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
79
system/src/Doctrine/Common/Cache/Cache.php
Normal file
79
system/src/Doctrine/Common/Cache/Cache.php
Normal file
@@ -0,0 +1,79 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* This file provides a lightweight replacement for the legacy Doctrine Cache
|
||||
* interfaces so that existing Grav extensions depending on the Doctrine
|
||||
* namespace continue to function without the abandoned package.
|
||||
*/
|
||||
|
||||
namespace Doctrine\Common\Cache;
|
||||
|
||||
/**
|
||||
* Interface for cache drivers.
|
||||
*
|
||||
* @link www.doctrine-project.org
|
||||
*/
|
||||
interface Cache
|
||||
{
|
||||
public const STATS_HITS = 'hits';
|
||||
public const STATS_MISSES = 'misses';
|
||||
public const STATS_UPTIME = 'uptime';
|
||||
public const STATS_MEMORY_USAGE = 'memory_usage';
|
||||
public const STATS_MEMORY_AVAILABLE = 'memory_available';
|
||||
/**
|
||||
* Only for backward compatibility (may be removed in next major release)
|
||||
*
|
||||
* @deprecated
|
||||
*/
|
||||
public const STATS_MEMORY_AVAILIABLE = 'memory_available';
|
||||
|
||||
/**
|
||||
* Fetches an entry from the cache.
|
||||
*
|
||||
* @param string $id The id of the cache entry to fetch.
|
||||
*
|
||||
* @return mixed The cached data or FALSE, if no cache entry exists for the given id.
|
||||
*/
|
||||
public function fetch($id);
|
||||
|
||||
/**
|
||||
* Tests if an entry exists in the cache.
|
||||
*
|
||||
* @param string $id The cache id of the entry to check for.
|
||||
*
|
||||
* @return bool TRUE if a cache entry exists for the given cache id, FALSE otherwise.
|
||||
*/
|
||||
public function contains($id);
|
||||
|
||||
/**
|
||||
* Puts data into the cache.
|
||||
*
|
||||
* If a cache entry with the given id already exists, its data will be replaced.
|
||||
*
|
||||
* @param string $id The cache id.
|
||||
* @param mixed $data The cache entry/data.
|
||||
* @param int $lifeTime The lifetime in number of seconds for this cache entry.
|
||||
* If zero (the default), the entry never expires (although it may be deleted from the cache
|
||||
* to make place for other entries).
|
||||
*
|
||||
* @return bool TRUE if the entry was successfully stored in the cache, FALSE otherwise.
|
||||
*/
|
||||
public function save($id, $data, $lifeTime = 0);
|
||||
|
||||
/**
|
||||
* Deletes a cache entry.
|
||||
*
|
||||
* @param string $id The cache id.
|
||||
*
|
||||
* @return bool TRUE if the cache entry was successfully deleted, FALSE otherwise.
|
||||
* Deleting a non-existing entry is considered successful.
|
||||
*/
|
||||
public function delete($id);
|
||||
|
||||
/**
|
||||
* Retrieves cached information from the data store.
|
||||
*
|
||||
* @return mixed[]|null An associative array with server's statistics if available, NULL otherwise.
|
||||
*/
|
||||
public function getStats();
|
||||
}
|
||||
329
system/src/Doctrine/Common/Cache/CacheProvider.php
Normal file
329
system/src/Doctrine/Common/Cache/CacheProvider.php
Normal file
@@ -0,0 +1,329 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Lightweight compatibility layer for the abandoned Doctrine Cache package.
|
||||
*/
|
||||
|
||||
namespace Doctrine\Common\Cache;
|
||||
|
||||
use function array_combine;
|
||||
use function array_key_exists;
|
||||
use function array_map;
|
||||
use function sprintf;
|
||||
|
||||
/**
|
||||
* Base class for cache provider implementations.
|
||||
*/
|
||||
abstract class CacheProvider implements Cache, FlushableCache, ClearableCache, MultiOperationCache
|
||||
{
|
||||
public const DOCTRINE_NAMESPACE_CACHEKEY = 'DoctrineNamespaceCacheKey[%s]';
|
||||
|
||||
/**
|
||||
* The namespace to prefix all cache ids with.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
private $namespace = '';
|
||||
|
||||
/**
|
||||
* The namespace version.
|
||||
*
|
||||
* @var int|null
|
||||
*/
|
||||
private $namespaceVersion;
|
||||
|
||||
/**
|
||||
* Sets the namespace to prefix all cache ids with.
|
||||
*
|
||||
* @param string $namespace
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function setNamespace($namespace)
|
||||
{
|
||||
$this->namespace = (string) $namespace;
|
||||
$this->namespaceVersion = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the namespace that prefixes all cache ids.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function getNamespace()
|
||||
{
|
||||
return $this->namespace;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function fetch($id)
|
||||
{
|
||||
return $this->doFetch($this->getNamespacedId($id));
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function fetchMultiple(array $keys)
|
||||
{
|
||||
if (empty($keys)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// note: the array_combine() is in place to keep an association between our $keys and the $namespacedKeys
|
||||
$namespacedKeys = array_combine($keys, array_map([$this, 'getNamespacedId'], $keys));
|
||||
$items = $this->doFetchMultiple($namespacedKeys);
|
||||
$foundItems = [];
|
||||
|
||||
// no internal array function supports this sort of mapping: needs to be iterative
|
||||
// this filters and combines keys in one pass
|
||||
foreach ($namespacedKeys as $requestedKey => $namespacedKey) {
|
||||
if (! isset($items[$namespacedKey]) && ! array_key_exists($namespacedKey, $items)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$foundItems[$requestedKey] = $items[$namespacedKey];
|
||||
}
|
||||
|
||||
return $foundItems;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function saveMultiple(array $keysAndValues, $lifetime = 0)
|
||||
{
|
||||
$namespacedKeysAndValues = [];
|
||||
foreach ($keysAndValues as $key => $value) {
|
||||
$namespacedKeysAndValues[$this->getNamespacedId($key)] = $value;
|
||||
}
|
||||
|
||||
return $this->doSaveMultiple($namespacedKeysAndValues, $lifetime);
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function contains($id)
|
||||
{
|
||||
return $this->doContains($this->getNamespacedId($id));
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function save($id, $data, $lifeTime = 0)
|
||||
{
|
||||
return $this->doSave($this->getNamespacedId($id), $data, $lifeTime);
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function deleteMultiple(array $keys)
|
||||
{
|
||||
return $this->doDeleteMultiple(array_map([$this, 'getNamespacedId'], $keys));
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function delete($id)
|
||||
{
|
||||
return $this->doDelete($this->getNamespacedId($id));
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function getStats()
|
||||
{
|
||||
return $this->doGetStats();
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
public function flushAll()
|
||||
{
|
||||
return $this->doFlush();
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
public function deleteAll()
|
||||
{
|
||||
$namespaceCacheKey = $this->getNamespaceCacheKey();
|
||||
$namespaceVersion = $this->getNamespaceVersion() + 1;
|
||||
|
||||
if ($this->doSave($namespaceCacheKey, $namespaceVersion)) {
|
||||
$this->namespaceVersion = $namespaceVersion;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prefixes the passed id with the configured namespace value.
|
||||
*
|
||||
* @param string $id The id to namespace.
|
||||
*
|
||||
* @return string The namespaced id.
|
||||
*/
|
||||
private function getNamespacedId(string $id): string
|
||||
{
|
||||
$namespaceVersion = $this->getNamespaceVersion();
|
||||
|
||||
return sprintf('%s[%s][%s]', $this->namespace, $id, $namespaceVersion);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the namespace cache key.
|
||||
*/
|
||||
private function getNamespaceCacheKey(): string
|
||||
{
|
||||
return sprintf(self::DOCTRINE_NAMESPACE_CACHEKEY, $this->namespace);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the namespace version.
|
||||
*/
|
||||
private function getNamespaceVersion(): int
|
||||
{
|
||||
if ($this->namespaceVersion !== null) {
|
||||
return $this->namespaceVersion;
|
||||
}
|
||||
|
||||
$namespaceCacheKey = $this->getNamespaceCacheKey();
|
||||
$this->namespaceVersion = (int) $this->doFetch($namespaceCacheKey) ?: 1;
|
||||
|
||||
return $this->namespaceVersion;
|
||||
}
|
||||
|
||||
/**
|
||||
* Default implementation of doFetchMultiple. Each driver that supports multi-get should overwrite it.
|
||||
*
|
||||
* @param string[] $keys Array of keys to retrieve from cache
|
||||
*
|
||||
* @return mixed[] Array of values retrieved for the given keys.
|
||||
*/
|
||||
protected function doFetchMultiple(array $keys)
|
||||
{
|
||||
$returnValues = [];
|
||||
|
||||
foreach ($keys as $key) {
|
||||
$item = $this->doFetch($key);
|
||||
if ($item === false && ! $this->doContains($key)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$returnValues[$key] = $item;
|
||||
}
|
||||
|
||||
return $returnValues;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches an entry from the cache.
|
||||
*
|
||||
* @param string $id The id of the cache entry to fetch.
|
||||
*
|
||||
* @return mixed|false The cached data or FALSE, if no cache entry exists for the given id.
|
||||
*/
|
||||
abstract protected function doFetch($id);
|
||||
|
||||
/**
|
||||
* Tests if an entry exists in the cache.
|
||||
*
|
||||
* @param string $id The cache id of the entry to check for.
|
||||
*
|
||||
* @return bool TRUE if a cache entry exists for the given cache id, FALSE otherwise.
|
||||
*/
|
||||
abstract protected function doContains($id);
|
||||
|
||||
/**
|
||||
* Default implementation of doSaveMultiple. Each driver that supports multi-put should override it.
|
||||
*
|
||||
* @param mixed[] $keysAndValues Array of keys and values to save in cache
|
||||
* @param int $lifetime The lifetime. If != 0, sets a specific lifetime for these
|
||||
* cache entries (0 => infinite lifeTime).
|
||||
*
|
||||
* @return bool TRUE if the operation was successful, FALSE if it wasn't.
|
||||
*/
|
||||
protected function doSaveMultiple(array $keysAndValues, $lifetime = 0)
|
||||
{
|
||||
$success = true;
|
||||
|
||||
foreach ($keysAndValues as $key => $value) {
|
||||
if ($this->doSave($key, $value, $lifetime)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$success = false;
|
||||
}
|
||||
|
||||
return $success;
|
||||
}
|
||||
|
||||
/**
|
||||
* Puts data into the cache.
|
||||
*
|
||||
* @param string $id The cache id.
|
||||
* @param string $data The cache entry/data.
|
||||
* @param int $lifeTime The lifetime. If != 0, sets a specific lifetime for this
|
||||
* cache entry (0 => infinite lifeTime).
|
||||
*
|
||||
* @return bool TRUE if the entry was successfully stored in the cache, FALSE otherwise.
|
||||
*/
|
||||
abstract protected function doSave($id, $data, $lifeTime = 0);
|
||||
|
||||
/**
|
||||
* Default implementation of doDeleteMultiple. Each driver that supports multi-delete should override it.
|
||||
*
|
||||
* @param string[] $keys Array of keys to delete from cache
|
||||
*
|
||||
* @return bool TRUE if the operation was successful, FALSE if it wasn't
|
||||
*/
|
||||
protected function doDeleteMultiple(array $keys)
|
||||
{
|
||||
$success = true;
|
||||
|
||||
foreach ($keys as $key) {
|
||||
if ($this->doDelete($key)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$success = false;
|
||||
}
|
||||
|
||||
return $success;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a cache entry.
|
||||
*
|
||||
* @param string $id The cache id.
|
||||
*
|
||||
* @return bool TRUE if the cache entry was successfully deleted, FALSE otherwise.
|
||||
*/
|
||||
abstract protected function doDelete($id);
|
||||
|
||||
/**
|
||||
* Flushes all cache entries.
|
||||
*
|
||||
* @return bool TRUE if the cache entries were successfully flushed, FALSE otherwise.
|
||||
*/
|
||||
abstract protected function doFlush();
|
||||
|
||||
/**
|
||||
* Retrieves cached information from the data store.
|
||||
*
|
||||
* @return mixed[]|null An associative array with server's statistics if available, NULL otherwise.
|
||||
*/
|
||||
abstract protected function doGetStats();
|
||||
}
|
||||
25
system/src/Doctrine/Common/Cache/ClearableCache.php
Normal file
25
system/src/Doctrine/Common/Cache/ClearableCache.php
Normal file
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Lightweight compatibility layer for the abandoned Doctrine Cache package.
|
||||
*/
|
||||
|
||||
namespace Doctrine\Common\Cache;
|
||||
|
||||
/**
|
||||
* Interface for cache that can be flushed.
|
||||
*
|
||||
* Intended to be used for partial clearing of a cache namespace. For a more
|
||||
* global "flushing", see {@see FlushableCache}.
|
||||
*
|
||||
* @link www.doctrine-project.org
|
||||
*/
|
||||
interface ClearableCache
|
||||
{
|
||||
/**
|
||||
* Deletes all cache entries in the current cache namespace.
|
||||
*
|
||||
* @return bool TRUE if the cache entries were successfully deleted, FALSE otherwise.
|
||||
*/
|
||||
public function deleteAll();
|
||||
}
|
||||
@@ -2,92 +2,23 @@
|
||||
|
||||
namespace Doctrine\Common\Cache;
|
||||
|
||||
use Grav\Common\Cache\SymfonyCacheProvider;
|
||||
use Symfony\Component\Cache\Adapter\FilesystemAdapter;
|
||||
|
||||
/**
|
||||
* Filesystem cache driver (backwards compatibility).
|
||||
*/
|
||||
class FilesystemCache extends CacheProvider
|
||||
class FilesystemCache extends SymfonyCacheProvider
|
||||
{
|
||||
public const EXTENSION = '.doctrinecache.data';
|
||||
|
||||
/** @var FilesystemAdapter */
|
||||
private $pool;
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
* @param string $directory
|
||||
* @param string $extension
|
||||
* @param int $umask
|
||||
*/
|
||||
public function __construct($directory, $extension = self::EXTENSION, $umask = 0002)
|
||||
{
|
||||
user_error(self::class . ' is deprecated since Grav 1.8, use Symfony cache instead', E_USER_DEPRECATED);
|
||||
|
||||
$this->pool = new FilesystemAdapter('', 0, $directory);
|
||||
parent::__construct(new FilesystemAdapter('', 0, $directory));
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected function doFetch($id)
|
||||
{
|
||||
$item = $this->pool->getItem(rawurlencode($id));
|
||||
|
||||
return $item->isHit() ? $item->get() : false;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
protected function doContains($id)
|
||||
{
|
||||
return $this->pool->hasItem(rawurlencode($id));
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
protected function doSave($id, $data, $lifeTime = 0)
|
||||
{
|
||||
$item = $this->pool->getItem(rawurlencode($id));
|
||||
|
||||
if (0 < $lifeTime) {
|
||||
$item->expiresAfter($lifeTime);
|
||||
}
|
||||
|
||||
return $this->pool->save($item->set($data));
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
protected function doDelete($id)
|
||||
{
|
||||
return $this->pool->deleteItem(rawurlencode($id));
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
protected function doFlush()
|
||||
{
|
||||
return $this->pool->clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*
|
||||
* @return array|null
|
||||
*/
|
||||
protected function doGetStats()
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
22
system/src/Doctrine/Common/Cache/FlushableCache.php
Normal file
22
system/src/Doctrine/Common/Cache/FlushableCache.php
Normal file
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Lightweight compatibility layer for the abandoned Doctrine Cache package.
|
||||
*/
|
||||
|
||||
namespace Doctrine\Common\Cache;
|
||||
|
||||
/**
|
||||
* Interface for cache that can be flushed.
|
||||
*
|
||||
* @link www.doctrine-project.org
|
||||
*/
|
||||
interface FlushableCache
|
||||
{
|
||||
/**
|
||||
* Flushes all cache entries, globally.
|
||||
*
|
||||
* @return bool TRUE if the cache entries were successfully flushed, FALSE otherwise.
|
||||
*/
|
||||
public function flushAll();
|
||||
}
|
||||
26
system/src/Doctrine/Common/Cache/MultiDeleteCache.php
Normal file
26
system/src/Doctrine/Common/Cache/MultiDeleteCache.php
Normal file
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Lightweight compatibility layer for the abandoned Doctrine Cache package.
|
||||
*/
|
||||
|
||||
namespace Doctrine\Common\Cache;
|
||||
|
||||
/**
|
||||
* Interface for cache drivers that allows to delete many items at once.
|
||||
*
|
||||
* @deprecated
|
||||
*
|
||||
* @link www.doctrine-project.org
|
||||
*/
|
||||
interface MultiDeleteCache
|
||||
{
|
||||
/**
|
||||
* Deletes several cache entries.
|
||||
*
|
||||
* @param string[] $keys Array of keys to delete from cache
|
||||
*
|
||||
* @return bool TRUE if the operation was successful, FALSE if it wasn't.
|
||||
*/
|
||||
public function deleteMultiple(array $keys);
|
||||
}
|
||||
27
system/src/Doctrine/Common/Cache/MultiGetCache.php
Normal file
27
system/src/Doctrine/Common/Cache/MultiGetCache.php
Normal file
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Lightweight compatibility layer for the abandoned Doctrine Cache package.
|
||||
*/
|
||||
|
||||
namespace Doctrine\Common\Cache;
|
||||
|
||||
/**
|
||||
* Interface for cache drivers that allows to get many items at once.
|
||||
*
|
||||
* @deprecated
|
||||
*
|
||||
* @link www.doctrine-project.org
|
||||
*/
|
||||
interface MultiGetCache
|
||||
{
|
||||
/**
|
||||
* Returns an associative array of values for keys is found in cache.
|
||||
*
|
||||
* @param string[] $keys Array of keys to retrieve from cache
|
||||
*
|
||||
* @return mixed[] Array of retrieved values, indexed by the specified keys.
|
||||
* Values that couldn't be retrieved are not contained in this array.
|
||||
*/
|
||||
public function fetchMultiple(array $keys);
|
||||
}
|
||||
16
system/src/Doctrine/Common/Cache/MultiOperationCache.php
Normal file
16
system/src/Doctrine/Common/Cache/MultiOperationCache.php
Normal file
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Lightweight compatibility layer for the abandoned Doctrine Cache package.
|
||||
*/
|
||||
|
||||
namespace Doctrine\Common\Cache;
|
||||
|
||||
/**
|
||||
* Interface for cache drivers that supports multiple items manipulation.
|
||||
*
|
||||
* @link www.doctrine-project.org
|
||||
*/
|
||||
interface MultiOperationCache extends MultiGetCache, MultiDeleteCache, MultiPutCache
|
||||
{
|
||||
}
|
||||
28
system/src/Doctrine/Common/Cache/MultiPutCache.php
Normal file
28
system/src/Doctrine/Common/Cache/MultiPutCache.php
Normal file
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Lightweight compatibility layer for the abandoned Doctrine Cache package.
|
||||
*/
|
||||
|
||||
namespace Doctrine\Common\Cache;
|
||||
|
||||
/**
|
||||
* Interface for cache drivers that allows to put many items at once.
|
||||
*
|
||||
* @deprecated
|
||||
*
|
||||
* @link www.doctrine-project.org
|
||||
*/
|
||||
interface MultiPutCache
|
||||
{
|
||||
/**
|
||||
* Returns a boolean value indicating if the operation succeeded.
|
||||
*
|
||||
* @param mixed[] $keysAndValues Array of keys and values to save in cache
|
||||
* @param int $lifetime The lifetime. If != 0, sets a specific lifetime for these
|
||||
* cache entries (0 => infinite lifeTime).
|
||||
*
|
||||
* @return bool TRUE if the operation was successful, FALSE if it wasn't.
|
||||
*/
|
||||
public function saveMultiple(array $keysAndValues, $lifetime = 0);
|
||||
}
|
||||
@@ -462,8 +462,34 @@ 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);
|
||||
$result = $pipeline->$render_pipeline($pipeline_group['assets'], $group, $group_attributes);
|
||||
|
||||
// Handle different return types from pipeline
|
||||
if ($result === false) {
|
||||
// No assets to render
|
||||
continue;
|
||||
} elseif (is_array($result)) {
|
||||
// Array result contains pipelined output and any failed assets
|
||||
$pipeline_output .= $result['output'];
|
||||
// Render failed assets individually (they couldn't be minified)
|
||||
foreach ($result['failed'] as $asset) {
|
||||
$pipeline_output .= $asset->render();
|
||||
}
|
||||
} else {
|
||||
// String result (no minification or CSS)
|
||||
$pipeline_output .= $result;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
foreach ($pipeline_assets as $asset) {
|
||||
$pipeline_output .= $asset->render();
|
||||
@@ -583,4 +609,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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,13 +11,14 @@ namespace Grav\Common\Assets;
|
||||
|
||||
use Grav\Common\Assets\Traits\AssetUtilsTrait;
|
||||
use Grav\Common\Config\Config;
|
||||
use Grav\Common\Debugger;
|
||||
use Grav\Common\Filesystem\Folder;
|
||||
use Grav\Common\Grav;
|
||||
use Grav\Common\Uri;
|
||||
use Grav\Common\Utils;
|
||||
use Grav\Framework\Object\PropertyObject;
|
||||
use MatthiasMullie\Minify\CSS;
|
||||
use MatthiasMullie\Minify\JS;
|
||||
use tubalmartin\CssMin\Minifier as CSSMinifier;
|
||||
use JShrink\Minifier as JSMinifier;
|
||||
use RocketTheme\Toolbox\ResourceLocator\UniformResourceLocator;
|
||||
use function array_key_exists;
|
||||
|
||||
@@ -144,9 +145,8 @@ class Pipeline extends PropertyObject
|
||||
|
||||
// Minify if required
|
||||
if ($this->shouldMinify('css')) {
|
||||
$minifier = new CSS();
|
||||
$minifier->add($buffer);
|
||||
$buffer = $minifier->minify();
|
||||
$minifier = new CSSMinifier();
|
||||
$buffer = $minifier->run($buffer);
|
||||
}
|
||||
|
||||
// Write file
|
||||
@@ -171,7 +171,9 @@ class Pipeline extends PropertyObject
|
||||
* @param array $assets
|
||||
* @param string $group
|
||||
* @param array $attributes
|
||||
* @return bool|string URL or generated content if available, else false
|
||||
* @param int $type
|
||||
* @return array{output: string, failed: array}|string|false Returns array with output and failed assets when minifying,
|
||||
* string when not minifying, or false if no assets
|
||||
*/
|
||||
public function renderJs($assets, $group, $attributes = [], $type = self::JS_ASSET)
|
||||
{
|
||||
@@ -186,44 +188,55 @@ class Pipeline extends PropertyObject
|
||||
// Store Attributes
|
||||
$this->attributes = $attributes;
|
||||
|
||||
// Compute uid based on assets and timestamp
|
||||
$json_assets = json_encode($assets);
|
||||
$uid = md5($json_assets . $this->js_minify . $group);
|
||||
$file = $uid . '.js';
|
||||
$relative_path = "{$this->base_url}{$this->assets_url}/{$file}";
|
||||
|
||||
$filepath = "{$this->assets_dir}/{$file}";
|
||||
if (file_exists($filepath)) {
|
||||
$buffer = file_get_contents($filepath) . "\n";
|
||||
} else {
|
||||
//if nothing found get out of here!
|
||||
if (empty($assets)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Concatenate files
|
||||
$buffer = $this->gatherLinks($assets, $type);
|
||||
|
||||
// Minify if required
|
||||
if ($this->shouldMinify('js')) {
|
||||
$minifier = new JS();
|
||||
$minifier->add($buffer);
|
||||
$buffer = $minifier->minify();
|
||||
}
|
||||
|
||||
// Write file
|
||||
if (trim($buffer) !== '') {
|
||||
file_put_contents($filepath, $buffer);
|
||||
}
|
||||
//if nothing found get out of here!
|
||||
if (empty($assets)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($inline_group) {
|
||||
$shouldMinify = $this->shouldMinify('js');
|
||||
$failedAssets = [];
|
||||
|
||||
// When minifying, process each file individually to isolate failures
|
||||
if ($shouldMinify) {
|
||||
$result = $this->gatherAndMinifyJs($assets, $type);
|
||||
$buffer = $result['buffer'];
|
||||
$failedAssets = $result['failed'];
|
||||
|
||||
// Compute uid based on successful assets only
|
||||
$successfulAssets = array_diff_key($assets, array_flip(array_keys($failedAssets)));
|
||||
$json_assets = json_encode($successfulAssets);
|
||||
} else {
|
||||
$buffer = $this->gatherLinks($assets, $type);
|
||||
$json_assets = json_encode($assets);
|
||||
}
|
||||
|
||||
$uid = md5($json_assets . (int)$shouldMinify . $group);
|
||||
$file = $uid . '.js';
|
||||
$relative_path = "{$this->base_url}{$this->assets_url}/{$file}";
|
||||
$filepath = "{$this->assets_dir}/{$file}";
|
||||
|
||||
// Check for cached version (only if no failed assets, as cache key changes)
|
||||
if (empty($failedAssets) && file_exists($filepath)) {
|
||||
$buffer = file_get_contents($filepath) . "\n";
|
||||
} elseif (trim($buffer) !== '') {
|
||||
// Write file
|
||||
file_put_contents($filepath, $buffer);
|
||||
}
|
||||
|
||||
if (trim($buffer) === '') {
|
||||
$output = '';
|
||||
} elseif ($inline_group) {
|
||||
$output = '<script' . $this->renderAttributes(). ">\n" . $buffer . "\n</script>\n";
|
||||
} else {
|
||||
$this->asset = $relative_path;
|
||||
$output = '<script src="' . $relative_path . $this->renderQueryString() . '"' . $this->renderAttributes() . BaseAsset::integrityHash($this->asset) . "></script>\n";
|
||||
}
|
||||
|
||||
// Return array with failed assets if minifying, otherwise just the output string
|
||||
if ($shouldMinify) {
|
||||
return ['output' => $output, 'failed' => $failedAssets];
|
||||
}
|
||||
|
||||
return $output;
|
||||
}
|
||||
|
||||
@@ -344,4 +357,75 @@ class Pipeline extends PropertyObject
|
||||
|
||||
return $minify;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gather JS files and minify each one individually.
|
||||
* Files that fail minification are tracked and returned separately.
|
||||
*
|
||||
* @param array $assets Array of asset objects
|
||||
* @param int $type Asset type (JS_ASSET or JS_MODULE_ASSET)
|
||||
* @return array{buffer: string, failed: array} Combined minified content and failed assets
|
||||
*/
|
||||
private function gatherAndMinifyJs(array $assets, int $type): array
|
||||
{
|
||||
$buffer = '';
|
||||
$failed = [];
|
||||
|
||||
/** @var Debugger $debugger */
|
||||
$debugger = Grav::instance()['debugger'];
|
||||
|
||||
foreach ($assets as $key => $asset) {
|
||||
$local = true;
|
||||
$link = $asset->getAsset();
|
||||
$relative_path = $link;
|
||||
|
||||
if (static::isRemoteLink($link)) {
|
||||
$local = false;
|
||||
if (str_starts_with((string) $link, '//')) {
|
||||
$link = 'http:' . $link;
|
||||
}
|
||||
$relative_dir = dirname((string) $relative_path);
|
||||
} else {
|
||||
// Fix to remove relative dir if grav is in one
|
||||
if (($this->base_url !== '/') && Utils::startsWith($relative_path, $this->base_url)) {
|
||||
$base_url = '#' . preg_quote($this->base_url, '#') . '#';
|
||||
$relative_path = ltrim((string) preg_replace($base_url, '/', (string) $link, 1), '/');
|
||||
}
|
||||
|
||||
$relative_dir = dirname((string) $relative_path);
|
||||
$link = GRAV_ROOT . '/' . $relative_path;
|
||||
}
|
||||
|
||||
$file = $this->fetch_command instanceof \Closure ? @$this->fetch_command->__invoke($link) : @file_get_contents($link);
|
||||
|
||||
// No file found, skip it...
|
||||
if ($file === false) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Ensure proper termination
|
||||
$file = rtrim((string) $file, ' ;') . ';';
|
||||
|
||||
// Rewrite imports for JS modules
|
||||
if ($type === self::JS_MODULE_ASSET) {
|
||||
$file = $this->jsRewrite($file, $relative_dir, $local);
|
||||
}
|
||||
|
||||
// Try to minify this individual file
|
||||
try {
|
||||
$file = JSMinifier::minify($file);
|
||||
$file = rtrim($file) . PHP_EOL;
|
||||
$buffer .= $file;
|
||||
} catch (\Exception $e) {
|
||||
// Track failed asset for individual rendering
|
||||
$failed[$key] = $asset;
|
||||
|
||||
$message = "JS Minification failed for '{$asset->getAsset()}': {$e->getMessage()}";
|
||||
$debugger->addMessage($message, 'error');
|
||||
Grav::instance()['log']->error($message);
|
||||
}
|
||||
}
|
||||
|
||||
return ['buffer' => $buffer, 'failed' => $failed];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,8 +89,9 @@ class Backups
|
||||
$at = $profile['schedule_at'];
|
||||
$name = $inflector::hyphenize($profile['name']);
|
||||
$logs = 'logs/backup-' . $name . '.out';
|
||||
$environment = $profile['schedule_environment'] ?? null;
|
||||
/** @var Job $job */
|
||||
$job = $scheduler->addFunction('Grav\Common\Backup\Backups::backup', [$id], $name);
|
||||
$job = $scheduler->addFunction('Grav\Common\Backup\Backups::backup', [$id, null, $environment], $name);
|
||||
$job->at($at);
|
||||
$job->output($logs);
|
||||
$job->backlink('/tools/backups');
|
||||
@@ -192,12 +193,19 @@ class Backups
|
||||
*
|
||||
* @param int $id
|
||||
* @param callable|null $status
|
||||
* @param string|null $environment Optional environment to load config from
|
||||
* @return string|null
|
||||
*/
|
||||
public static function backup($id = 0, ?callable $status = null)
|
||||
public static function backup($id = 0, ?callable $status = null, ?string $environment = null)
|
||||
{
|
||||
$grav = Grav::instance();
|
||||
|
||||
// If environment is specified and different from current, reload config
|
||||
if ($environment && $environment !== $grav['config']->get('setup.environment')) {
|
||||
$grav->setup($environment);
|
||||
$grav['config']->reload();
|
||||
}
|
||||
|
||||
$profiles = static::getBackupProfiles();
|
||||
/** @var UniformResourceLocator $locator */
|
||||
$locator = $grav['locator'];
|
||||
@@ -225,6 +233,30 @@ class Backups
|
||||
throw new RuntimeException("Backup location: {$backup_root} does not exist...");
|
||||
}
|
||||
|
||||
// Security: Resolve real path and ensure it's within GRAV_ROOT to prevent path traversal
|
||||
$realBackupRoot = realpath($backup_root);
|
||||
$realGravRoot = realpath(GRAV_ROOT);
|
||||
|
||||
if ($realBackupRoot === false || $realGravRoot === false) {
|
||||
throw new RuntimeException("Invalid backup location: {$backup_root}");
|
||||
}
|
||||
|
||||
// Check if backup root is within GRAV_ROOT
|
||||
$isWithinGravRoot = strpos($realBackupRoot, $realGravRoot) === 0;
|
||||
|
||||
// Only apply blocklist to paths outside GRAV_ROOT to prevent backing up system directories
|
||||
// This allows backups within Grav installations under /var/www while still blocking /var/log, etc.
|
||||
if (!$isWithinGravRoot) {
|
||||
$blockedPaths = ['/etc', '/root', '/home', '/var', '/usr', '/bin', '/sbin', '/tmp', '/proc', '/sys', '/dev'];
|
||||
foreach ($blockedPaths as $blocked) {
|
||||
if (strpos($realBackupRoot, $blocked) === 0) {
|
||||
throw new RuntimeException("Backup location not allowed: {$backup_root}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$backup_root = $realBackupRoot;
|
||||
|
||||
$options = [
|
||||
'exclude_files' => static::convertExclude($backup->exclude_files ?? ''),
|
||||
'exclude_paths' => static::convertExclude($backup->exclude_paths ?? ''),
|
||||
|
||||
@@ -11,21 +11,23 @@ namespace Grav\Common;
|
||||
|
||||
use DirectoryIterator;
|
||||
use Doctrine\Common\Cache\CacheProvider;
|
||||
use Doctrine\Common\Cache\Psr6\DoctrineProvider;
|
||||
use Exception;
|
||||
use Grav\Common\Config\Config;
|
||||
use Grav\Common\Filesystem\Folder;
|
||||
use Grav\Common\Scheduler\Scheduler;
|
||||
use Grav\Common\Cache\SymfonyCacheProvider;
|
||||
use LogicException;
|
||||
use Psr\SimpleCache\CacheInterface;
|
||||
use RocketTheme\Toolbox\Event\Event;
|
||||
use Symfony\Component\Cache\Adapter\AdapterInterface;
|
||||
use Symfony\Component\Cache\Adapter\ArrayAdapter;
|
||||
use Symfony\Component\Cache\Adapter\ApcuAdapter;
|
||||
use Symfony\Component\Cache\Adapter\FilesystemAdapter;
|
||||
use Symfony\Component\Cache\Adapter\MemcachedAdapter;
|
||||
use Symfony\Component\Cache\Adapter\RedisAdapter;
|
||||
use Symfony\Component\Cache\Psr16Cache;
|
||||
use Symfony\Component\EventDispatcher\EventDispatcher;
|
||||
use Throwable;
|
||||
use function dirname;
|
||||
use function extension_loaded;
|
||||
use function function_exists;
|
||||
@@ -34,7 +36,7 @@ use function is_array;
|
||||
|
||||
/**
|
||||
* The GravCache object is used throughout Grav to store and retrieve cached data.
|
||||
* It uses Symfony library (adding backward compatibility to Doctrine Cache) and supports a variety of caching mechanisms. Those include:
|
||||
* It uses Symfony cache pools (while exposing the historic Doctrine cache API for backward compatibility) and supports a variety of caching mechanisms. Those include:
|
||||
*
|
||||
* APCu
|
||||
* RedisCache
|
||||
@@ -295,10 +297,28 @@ class Cache extends Getters
|
||||
public function getCacheAdapter(?string $namespace = null, ?int $defaultLifetime = null): AdapterInterface
|
||||
{
|
||||
$setting = $this->driver_setting ?? 'auto';
|
||||
$original_setting = $setting;
|
||||
$driver_name = 'file';
|
||||
$adapter = null;
|
||||
$compatibility = [
|
||||
'filesystem' => 'file',
|
||||
'files' => 'file',
|
||||
'doctrine' => 'file',
|
||||
'apc' => 'apcu',
|
||||
'memcache' => 'memcached',
|
||||
];
|
||||
|
||||
if (in_array($setting, ['apc', 'xcache', 'wincache', 'memcache'], true)) {
|
||||
throw new LogicException(sprintf('Cache driver for %s has been removed, use auto, file, apcu or memcached instead!', $setting));
|
||||
if (isset($compatibility[$setting])) {
|
||||
$mapped = $compatibility[$setting];
|
||||
if ($mapped !== $setting) {
|
||||
$this->logCacheFallback($original_setting, $mapped, 'legacy cache driver detected');
|
||||
}
|
||||
$setting = $mapped;
|
||||
}
|
||||
|
||||
if (in_array($setting, ['xcache', 'wincache'], true)) {
|
||||
$this->logCacheFallback($original_setting, 'file', 'unsupported cache driver removed in Grav 1.8');
|
||||
$setting = 'file';
|
||||
}
|
||||
|
||||
// CLI compatibility requires a non-volatile cache driver
|
||||
@@ -314,69 +334,123 @@ class Cache extends Getters
|
||||
$driver_name = $setting;
|
||||
}
|
||||
|
||||
$this->driver_name = $driver_name;
|
||||
$namespace ??= $this->key;
|
||||
$defaultLifetime ??= 0;
|
||||
$resolved_driver_name = $driver_name;
|
||||
|
||||
switch ($driver_name) {
|
||||
case 'apc':
|
||||
case 'apcu':
|
||||
$adapter = new ApcuAdapter($namespace, $defaultLifetime);
|
||||
if (extension_loaded('apcu')) {
|
||||
$adapter = new ApcuAdapter($namespace, $defaultLifetime);
|
||||
$resolved_driver_name = 'apcu';
|
||||
} else {
|
||||
$this->logCacheFallback($driver_name, 'file', 'APCu extension not loaded');
|
||||
$adapter = $this->createFilesystemAdapter($namespace, $defaultLifetime);
|
||||
$resolved_driver_name = 'file';
|
||||
}
|
||||
break;
|
||||
|
||||
case 'memcached':
|
||||
if (extension_loaded('memcached')) {
|
||||
$memcached = new \Memcached();
|
||||
$memcached->addServer(
|
||||
$connected = $memcached->addServer(
|
||||
$this->config->get('system.cache.memcached.server', 'localhost'),
|
||||
$this->config->get('system.cache.memcached.port', 11211)
|
||||
);
|
||||
$adapter = new MemcachedAdapter($memcached, $namespace, $defaultLifetime);
|
||||
if ($connected) {
|
||||
$adapter = new MemcachedAdapter($memcached, $namespace, $defaultLifetime);
|
||||
$resolved_driver_name = 'memcached';
|
||||
} else {
|
||||
$this->logCacheFallback($driver_name, 'file', 'Memcached server configuration failed');
|
||||
$adapter = $this->createFilesystemAdapter($namespace, $defaultLifetime);
|
||||
$resolved_driver_name = 'file';
|
||||
}
|
||||
} else {
|
||||
throw new LogicException('Memcached PHP extension has not been installed');
|
||||
$this->logCacheFallback($driver_name, 'file', 'Memcached extension not installed');
|
||||
$adapter = $this->createFilesystemAdapter($namespace, $defaultLifetime);
|
||||
$resolved_driver_name = 'file';
|
||||
}
|
||||
break;
|
||||
|
||||
case 'redis':
|
||||
if (extension_loaded('redis')) {
|
||||
$redis = new \Redis();
|
||||
$socket = $this->config->get('system.cache.redis.socket', false);
|
||||
$password = $this->config->get('system.cache.redis.password', false);
|
||||
$databaseId = $this->config->get('system.cache.redis.database', 0);
|
||||
try {
|
||||
$socket = $this->config->get('system.cache.redis.socket', false);
|
||||
$password = $this->config->get('system.cache.redis.password', false);
|
||||
$databaseId = $this->config->get('system.cache.redis.database', 0);
|
||||
|
||||
if ($socket) {
|
||||
$redis->connect($socket);
|
||||
} else {
|
||||
$redis->connect(
|
||||
$this->config->get('system.cache.redis.server', 'localhost'),
|
||||
$this->config->get('system.cache.redis.port', 6379)
|
||||
);
|
||||
if ($socket) {
|
||||
$redis->connect($socket);
|
||||
} else {
|
||||
$redis->connect(
|
||||
$this->config->get('system.cache.redis.server', 'localhost'),
|
||||
$this->config->get('system.cache.redis.port', 6379)
|
||||
);
|
||||
}
|
||||
|
||||
// Authenticate with password if set
|
||||
if ($password && !$redis->auth($password)) {
|
||||
throw new \RedisException('Redis authentication failed');
|
||||
}
|
||||
|
||||
// Select alternate ( !=0 ) database ID if set
|
||||
if ($databaseId && !$redis->select($databaseId)) {
|
||||
throw new \RedisException('Could not select alternate Redis database ID');
|
||||
}
|
||||
|
||||
$adapter = new RedisAdapter($redis, $namespace, $defaultLifetime);
|
||||
$resolved_driver_name = 'redis';
|
||||
} catch (Throwable $e) {
|
||||
$this->logCacheFallback($driver_name, 'file', $e->getMessage());
|
||||
$adapter = $this->createFilesystemAdapter($namespace, $defaultLifetime);
|
||||
$resolved_driver_name = 'file';
|
||||
}
|
||||
|
||||
// Authenticate with password if set
|
||||
if ($password && !$redis->auth($password)) {
|
||||
throw new \RedisException('Redis authentication failed');
|
||||
}
|
||||
|
||||
// Select alternate ( !=0 ) database ID if set
|
||||
if ($databaseId && !$redis->select($databaseId)) {
|
||||
throw new \RedisException('Could not select alternate Redis database ID');
|
||||
}
|
||||
|
||||
$adapter = new RedisAdapter($redis, $namespace, $defaultLifetime);
|
||||
} else {
|
||||
throw new LogicException('Redis PHP extension has not been installed');
|
||||
$this->logCacheFallback($driver_name, 'file', 'Redis extension not installed');
|
||||
$adapter = $this->createFilesystemAdapter($namespace, $defaultLifetime);
|
||||
$resolved_driver_name = 'file';
|
||||
}
|
||||
break;
|
||||
|
||||
case 'array':
|
||||
$adapter = new ArrayAdapter($defaultLifetime, false);
|
||||
$adapter->setNamespace($namespace);
|
||||
$resolved_driver_name = 'array';
|
||||
break;
|
||||
|
||||
default:
|
||||
$adapter = new FilesystemAdapter($namespace, $defaultLifetime, $this->cache_dir);
|
||||
if (!in_array($driver_name, ['file', 'filesystem'], true)) {
|
||||
$this->logCacheFallback($driver_name, 'file', 'unknown cache driver');
|
||||
}
|
||||
$adapter = $this->createFilesystemAdapter($namespace, $defaultLifetime);
|
||||
$resolved_driver_name = 'file';
|
||||
break;
|
||||
}
|
||||
|
||||
$this->driver_name = $resolved_driver_name;
|
||||
|
||||
return $adapter;
|
||||
}
|
||||
|
||||
protected function createFilesystemAdapter(string $namespace, int $defaultLifetime): FilesystemAdapter
|
||||
{
|
||||
return new FilesystemAdapter($namespace, $defaultLifetime, $this->cache_dir);
|
||||
}
|
||||
|
||||
protected function logCacheFallback(string $from, string $to, string $reason): void
|
||||
{
|
||||
try {
|
||||
$log = Grav::instance()['log'] ?? null;
|
||||
if ($log) {
|
||||
$log->warning(sprintf('Cache driver "%s" unavailable (%s); falling back to "%s".', $from, $reason, $to));
|
||||
}
|
||||
} catch (Throwable) {
|
||||
// Logging failed, continue silently.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Automatically picks the cache mechanism to use. If you pick one manually it will use that
|
||||
* If there is no config option for $driver in the config, or it's set to 'auto', it will
|
||||
@@ -390,12 +464,12 @@ class Cache extends Getters
|
||||
$adapter = $this->getCacheAdapter();
|
||||
}
|
||||
|
||||
$cache = DoctrineProvider::wrap($adapter);
|
||||
if (!$cache instanceof CacheProvider) {
|
||||
throw new \RuntimeException('Internal error');
|
||||
$driver = new SymfonyCacheProvider($adapter);
|
||||
if ($adapter === $this->adapter) {
|
||||
$driver->setNamespace($this->key);
|
||||
}
|
||||
|
||||
return $cache;
|
||||
return $driver;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -493,7 +567,10 @@ class Cache extends Getters
|
||||
public function setKey($key)
|
||||
{
|
||||
$this->key = $key;
|
||||
$this->driver->setNamespace($this->key);
|
||||
if ($this->driver instanceof CacheProvider) {
|
||||
$this->driver->setNamespace($this->key);
|
||||
}
|
||||
$this->simpleCache = null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -573,6 +650,9 @@ class Cache extends Getters
|
||||
$anything = true;
|
||||
}
|
||||
} elseif (is_dir($file)) {
|
||||
if (basename($file) === 'grav-snapshots') {
|
||||
continue;
|
||||
}
|
||||
if (Folder::delete($file, false)) {
|
||||
$anything = true;
|
||||
}
|
||||
@@ -692,7 +772,7 @@ class Cache extends Getters
|
||||
*/
|
||||
public function isVolatileDriver($setting)
|
||||
{
|
||||
return $setting === 'apcu';
|
||||
return in_array($setting, ['apcu', 'array'], true);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
171
system/src/Grav/Common/Cache/SymfonyCacheProvider.php
Normal file
171
system/src/Grav/Common/Cache/SymfonyCacheProvider.php
Normal file
@@ -0,0 +1,171 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Symfony-backed cache provider that implements the legacy Doctrine Cache API.
|
||||
*/
|
||||
|
||||
namespace Grav\Common\Cache;
|
||||
|
||||
use Doctrine\Common\Cache\CacheProvider;
|
||||
use Psr\Cache\InvalidArgumentException;
|
||||
use Symfony\Component\Cache\Adapter\AdapterInterface;
|
||||
use function array_map;
|
||||
use function rawurlencode;
|
||||
|
||||
class SymfonyCacheProvider extends CacheProvider
|
||||
{
|
||||
/** @var AdapterInterface */
|
||||
private $adapter;
|
||||
|
||||
public function __construct(AdapterInterface $adapter)
|
||||
{
|
||||
$this->adapter = $adapter;
|
||||
}
|
||||
|
||||
/**
|
||||
* Expose the underlying Symfony cache pool for callers needing direct access.
|
||||
*/
|
||||
public function getAdapter(): AdapterInterface
|
||||
{
|
||||
return $this->adapter;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected function doFetch($id)
|
||||
{
|
||||
try {
|
||||
$item = $this->adapter->getItem($this->encode($id));
|
||||
} catch (InvalidArgumentException) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $item->isHit() ? $item->get() : false;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected function doFetchMultiple(array $keys)
|
||||
{
|
||||
if (!$keys) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$encoded = array_map([$this, 'encode'], $keys);
|
||||
|
||||
try {
|
||||
$items = $this->adapter->getItems($encoded);
|
||||
} catch (InvalidArgumentException) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$results = [];
|
||||
foreach ($items as $encodedKey => $item) {
|
||||
if ($item->isHit()) {
|
||||
$results[$encodedKey] = $item->get();
|
||||
}
|
||||
}
|
||||
|
||||
return $results;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected function doContains($id)
|
||||
{
|
||||
try {
|
||||
return $this->adapter->hasItem($this->encode($id));
|
||||
} catch (InvalidArgumentException) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected function doSave($id, $data, $lifeTime = 0)
|
||||
{
|
||||
try {
|
||||
$item = $this->adapter->getItem($this->encode($id));
|
||||
} catch (InvalidArgumentException) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($lifeTime > 0) {
|
||||
$item->expiresAfter($lifeTime);
|
||||
}
|
||||
|
||||
return $this->adapter->save($item->set($data));
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected function doSaveMultiple(array $keysAndValues, $lifetime = 0)
|
||||
{
|
||||
if (!$keysAndValues) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$success = true;
|
||||
foreach ($keysAndValues as $key => $value) {
|
||||
if (!$this->doSave($key, $value, $lifetime)) {
|
||||
$success = false;
|
||||
}
|
||||
}
|
||||
|
||||
return $success;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected function doDelete($id)
|
||||
{
|
||||
try {
|
||||
return $this->adapter->deleteItem($this->encode($id));
|
||||
} catch (InvalidArgumentException) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected function doDeleteMultiple(array $keys)
|
||||
{
|
||||
if (!$keys) {
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
return $this->adapter->deleteItems(array_map([$this, 'encode'], $keys));
|
||||
} catch (InvalidArgumentException) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected function doFlush()
|
||||
{
|
||||
return $this->adapter->clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected function doGetStats()
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
private function encode(string $id): string
|
||||
{
|
||||
return rawurlencode($id);
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,7 @@ trait CompiledFile
|
||||
/**
|
||||
* Get/set parsed file contents.
|
||||
*
|
||||
* @param mixed $var
|
||||
* @return array
|
||||
*/
|
||||
public function content(mixed $var = null)
|
||||
@@ -36,19 +37,44 @@ trait CompiledFile
|
||||
$filename = $this->filename;
|
||||
// If nothing has been loaded, attempt to get pre-compiled version of the file first.
|
||||
if ($var === null && $this->raw === null && $this->content === null) {
|
||||
$key = md5((string) $filename);
|
||||
$key = md5($filename);
|
||||
$file = PhpFile::instance(CACHE_DIR . "compiled/files/{$key}{$this->extension}.php");
|
||||
$cacheFilename = $file->filename();
|
||||
|
||||
// Always check file modification time for cache invalidation.
|
||||
// This respects Grav's cache.check.method setting and user expectations.
|
||||
// filemtime() is cheap and ensures changes are detected.
|
||||
$modified = $this->modified();
|
||||
if (!$modified) {
|
||||
|
||||
// If file hasn't been modified and cache exists, load from compiled cache.
|
||||
// When opcache is enabled, this benefits from bytecode caching.
|
||||
if (!$modified && is_file($cacheFilename)) {
|
||||
try {
|
||||
return $this->decode($this->raw());
|
||||
// Include the file directly to trigger loading from opcache
|
||||
$var = (array) include $cacheFilename;
|
||||
|
||||
if (is_array($var) && isset($var['data'])) {
|
||||
$var = $var['data'];
|
||||
} else {
|
||||
$var = null;
|
||||
}
|
||||
|
||||
if (!is_array($var)) {
|
||||
$var = $this->decode($this->raw());
|
||||
}
|
||||
|
||||
return $var;
|
||||
} catch (Throwable) {
|
||||
// If the compiled file is broken, we can safely ignore the error and continue.
|
||||
}
|
||||
}
|
||||
|
||||
$class = $this::class;
|
||||
$class = get_class($this);
|
||||
|
||||
// Check if the source file exists before getting its size
|
||||
if (!is_file($filename)) {
|
||||
return parent::content($var);
|
||||
}
|
||||
|
||||
$size = filesize($filename);
|
||||
$cache = $file->exists() ? $file->content() : null;
|
||||
@@ -88,11 +114,9 @@ trait CompiledFile
|
||||
|
||||
// Compile cached file into bytecode cache
|
||||
if (function_exists('opcache_invalidate') && filter_var(ini_get('opcache.enable'), \FILTER_VALIDATE_BOOLEAN)) {
|
||||
$lockName = $file->filename();
|
||||
|
||||
// Silence error if function exists, but is restricted.
|
||||
@opcache_invalidate($lockName, true);
|
||||
@opcache_compile_file($lockName);
|
||||
@opcache_invalidate($cacheFilename, true);
|
||||
@opcache_compile_file($cacheFilename);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -134,7 +158,7 @@ trait CompiledFile
|
||||
if ($locked) {
|
||||
$modified = $this->modified();
|
||||
$filename = $this->filename;
|
||||
$class = $this::class;
|
||||
$class = get_class($this);
|
||||
$size = filesize($filename);
|
||||
|
||||
// windows doesn't play nicely with this as it can't read when locked
|
||||
@@ -158,10 +182,10 @@ trait CompiledFile
|
||||
|
||||
// Compile cached file into bytecode cache
|
||||
if (function_exists('opcache_invalidate') && filter_var(ini_get('opcache.enable'), \FILTER_VALIDATE_BOOLEAN)) {
|
||||
$lockName = $file->filename();
|
||||
$cacheFilename = $file->filename();
|
||||
// Silence error if function exists, but is restricted.
|
||||
@opcache_invalidate($lockName, true);
|
||||
@opcache_compile_file($lockName);
|
||||
@opcache_invalidate($cacheFilename, true);
|
||||
@opcache_compile_file($cacheFilename);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -478,12 +478,22 @@ abstract class Folder
|
||||
* @return bool
|
||||
* @throws RuntimeException
|
||||
*/
|
||||
public static function rcopy($src, $dest)
|
||||
public static function rcopy($src, $dest, $preservePermissions = false)
|
||||
{
|
||||
|
||||
// If the src is not a directory do a simple file copy
|
||||
if (!is_dir($src)) {
|
||||
copy($src, $dest);
|
||||
if ($preservePermissions) {
|
||||
$perm = @fileperms($src);
|
||||
if ($perm !== false) {
|
||||
@chmod($dest, $perm & 0777);
|
||||
}
|
||||
$mtime = @filemtime($src);
|
||||
if ($mtime !== false) {
|
||||
@touch($dest, $mtime);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -492,14 +502,32 @@ abstract class Folder
|
||||
static::create($dest);
|
||||
}
|
||||
|
||||
if ($preservePermissions) {
|
||||
$perm = @fileperms($src);
|
||||
if ($perm !== false) {
|
||||
@chmod($dest, $perm & 0777);
|
||||
}
|
||||
}
|
||||
|
||||
// Open the source directory to read in files
|
||||
$i = new DirectoryIterator($src);
|
||||
foreach ($i as $f) {
|
||||
if ($f->isFile()) {
|
||||
copy($f->getRealPath(), "{$dest}/" . $f->getFilename());
|
||||
$target = "{$dest}/" . $f->getFilename();
|
||||
copy($f->getRealPath(), $target);
|
||||
if ($preservePermissions) {
|
||||
$perm = @fileperms($f->getRealPath());
|
||||
if ($perm !== false) {
|
||||
@chmod($target, $perm & 0777);
|
||||
}
|
||||
$mtime = @filemtime($f->getRealPath());
|
||||
if ($mtime !== false) {
|
||||
@touch($target, $mtime);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (!$f->isDot() && $f->isDir()) {
|
||||
static::rcopy($f->getRealPath(), "{$dest}/{$f}");
|
||||
static::rcopy($f->getRealPath(), "{$dest}/{$f}", $preservePermissions);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -349,6 +349,22 @@ class UserObject extends FlexObject implements UserInterface, Countable
|
||||
return $this->getGroups();
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
* Override to filter out sensitive fields like password hashes
|
||||
*/
|
||||
public function jsonSerialize(): array
|
||||
{
|
||||
$elements = parent::jsonSerialize();
|
||||
|
||||
// Security: Remove sensitive fields that should never be exposed to frontend
|
||||
unset($elements['hashed_password']);
|
||||
unset($elements['secret']); // 2FA secret
|
||||
unset($elements['twofa_secret']); // Alternative 2FA field name
|
||||
|
||||
return $elements;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert object into an array.
|
||||
*
|
||||
@@ -583,10 +599,19 @@ class UserObject extends FlexObject implements UserInterface, Countable
|
||||
{
|
||||
// TODO: We may want to handle this in the storage layer in the future.
|
||||
$key = $this->getStorageKey();
|
||||
if (!$key || strpos($key, '@@')) {
|
||||
$isNewUser = !$key || strpos($key, '@@');
|
||||
|
||||
if ($isNewUser) {
|
||||
$storage = $this->getFlexDirectory()->getStorage();
|
||||
if ($storage instanceof FileStorage) {
|
||||
$this->setStorageKey($this->getKey());
|
||||
$newKey = $this->getKey();
|
||||
|
||||
// Check if a user with this username already exists (prevent overwriting)
|
||||
if ($storage->hasKey($newKey)) {
|
||||
throw new RuntimeException('User account with this username already exists');
|
||||
}
|
||||
|
||||
$this->setStorageKey($newKey);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -315,6 +317,10 @@ class GPM extends Iterator
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!$this->isRemotePackagePublished($plugins[$slug])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$local_version = $plugin->version ?? 'Unknown';
|
||||
$remote_version = $plugins[$slug]->version;
|
||||
|
||||
@@ -407,6 +413,10 @@ class GPM extends Iterator
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!$this->isRemotePackagePublished($themes[$slug])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$local_version = $plugin->version ?? 'Unknown';
|
||||
$remote_version = $themes[$slug]->version;
|
||||
|
||||
@@ -461,6 +471,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 */
|
||||
|
||||
@@ -46,6 +46,7 @@ use Grav\Common\Service\SessionServiceProvider;
|
||||
use Grav\Common\Service\StreamsServiceProvider;
|
||||
use Grav\Common\Service\TaskServiceProvider;
|
||||
use Grav\Common\Twig\Twig;
|
||||
use Grav\Common\Recovery\RecoveryManager;
|
||||
use Grav\Framework\DI\Container;
|
||||
use Grav\Framework\Psr7\Response;
|
||||
use Grav\Framework\RequestHandler\Middlewares\MultipartRequestSupport;
|
||||
@@ -110,6 +111,7 @@ class Grav extends Container
|
||||
'scheduler' => Scheduler::class,
|
||||
'taxonomy' => Taxonomy::class,
|
||||
'themes' => Themes::class,
|
||||
'recovery' => RecoveryManager::class,
|
||||
'twig' => Twig::class,
|
||||
'uri' => Uri::class,
|
||||
];
|
||||
@@ -570,7 +572,9 @@ class Grav extends Container
|
||||
|
||||
/** @var Debugger $debugger */
|
||||
$debugger = $this['debugger'];
|
||||
$debugger->addEvent($eventName, $event, $events, $timestamp);
|
||||
if ($debugger->enabled()) {
|
||||
$debugger->addEvent($eventName, $event, $events, $timestamp);
|
||||
}
|
||||
|
||||
return $event;
|
||||
}
|
||||
|
||||
@@ -134,7 +134,17 @@ class Language
|
||||
*/
|
||||
public function setLanguages($langs)
|
||||
{
|
||||
$this->languages = $langs;
|
||||
// Validate and sanitize language codes to prevent regex injection
|
||||
$validLangs = [];
|
||||
foreach ((array)$langs as $lang) {
|
||||
$lang = (string)$lang;
|
||||
// Only allow valid language codes (alphanumeric, hyphens, underscores)
|
||||
// Examples: en, en-US, en_US, zh-Hans, pt-BR
|
||||
if (preg_match('/^[a-zA-Z]{2,3}(?:[-_][a-zA-Z0-9]{2,8})?$/', $lang)) {
|
||||
$validLangs[] = $lang;
|
||||
}
|
||||
}
|
||||
$this->languages = $validLangs;
|
||||
|
||||
$this->init();
|
||||
}
|
||||
@@ -234,7 +244,8 @@ class Language
|
||||
*/
|
||||
public function setActiveFromUri($uri)
|
||||
{
|
||||
$regex = '/(^\/(' . $this->getAvailable() . '))(?:\/|\?|$)/i';
|
||||
// Pass delimiter '/' to getAvailable() to properly escape language codes for regex
|
||||
$regex = '/(^\/(' . $this->getAvailable('/') . '))(?:\/|\?|$)/i';
|
||||
|
||||
// if languages set
|
||||
if ($this->enabled()) {
|
||||
|
||||
@@ -408,7 +408,6 @@ class Page implements PageInterface
|
||||
$file = $this->file();
|
||||
if ($file) {
|
||||
try {
|
||||
$this->raw_content = $file->markdown();
|
||||
$this->frontmatter = $file->frontmatter();
|
||||
$this->header = (object)$file->header();
|
||||
|
||||
@@ -443,6 +442,7 @@ class Page implements PageInterface
|
||||
$this->frontmatter = $file->frontmatter();
|
||||
$this->header = (object)$file->header();
|
||||
}
|
||||
$file->free();
|
||||
$var = true;
|
||||
}
|
||||
}
|
||||
@@ -788,7 +788,7 @@ class Page implements PageInterface
|
||||
// if no cached-content run everything
|
||||
if ($never_cache_twig) {
|
||||
if ($this->content === false || $cache_enable === false) {
|
||||
$this->content = $this->raw_content;
|
||||
$this->content = $this->rawMarkdown();
|
||||
Grav::instance()->fireEvent('onPageContentRaw', new Event(['page' => $this]));
|
||||
|
||||
if ($process_markdown) {
|
||||
@@ -808,7 +808,7 @@ class Page implements PageInterface
|
||||
}
|
||||
} else {
|
||||
if ($this->content === false || $cache_enable === false) {
|
||||
$this->content = $this->raw_content;
|
||||
$this->content = $this->rawMarkdown();
|
||||
Grav::instance()->fireEvent('onPageContentRaw', new Event(['page' => $this]));
|
||||
|
||||
if ($twig_first) {
|
||||
@@ -1031,7 +1031,7 @@ class Page implements PageInterface
|
||||
public function value($name, $default = null)
|
||||
{
|
||||
if ($name === 'content') {
|
||||
return $this->raw_content;
|
||||
return $this->rawMarkdown();
|
||||
}
|
||||
if ($name === 'route') {
|
||||
$parent = $this->parent();
|
||||
@@ -1117,6 +1117,14 @@ class Page implements PageInterface
|
||||
$this->raw_content = $var;
|
||||
}
|
||||
|
||||
if ($this->raw_content === null) {
|
||||
$file = $this->file();
|
||||
if ($file) {
|
||||
$this->raw_content = $file->markdown();
|
||||
$file->free();
|
||||
}
|
||||
}
|
||||
|
||||
return $this->raw_content;
|
||||
}
|
||||
|
||||
@@ -1157,7 +1165,7 @@ class Page implements PageInterface
|
||||
if ($file) {
|
||||
$file->filename($this->filePath());
|
||||
$file->header((array)$this->header());
|
||||
$file->markdown($this->raw_content);
|
||||
$file->markdown($this->rawMarkdown());
|
||||
$file->save();
|
||||
}
|
||||
|
||||
|
||||
@@ -98,6 +98,8 @@ class Pages
|
||||
protected $initialized = false;
|
||||
/** @var string */
|
||||
protected $active_lang;
|
||||
/** @var string|null */
|
||||
protected $page_extension_regex;
|
||||
/** @var bool */
|
||||
protected $fire_events = false;
|
||||
/** @var Types|null */
|
||||
@@ -902,7 +904,6 @@ class Pages
|
||||
public function children($path)
|
||||
{
|
||||
$children = $this->children[(string)$path] ?? [];
|
||||
|
||||
return new Collection($children, [], $this);
|
||||
}
|
||||
|
||||
@@ -1848,11 +1849,9 @@ class Pages
|
||||
throw new RuntimeException('Fatal error when creating page instances.');
|
||||
}
|
||||
|
||||
$page_extensions = $language->getFallbackPageExtensions();
|
||||
$regex = '/^[^\.]*(' . implode('|', array_map(
|
||||
static fn($str) => preg_quote((string) $str, '/'),
|
||||
$page_extensions
|
||||
)) . ')$/';
|
||||
$page_extensions = array_flip($language->getFallbackPageExtensions());
|
||||
|
||||
// $regex = $this->page_extension_regex;
|
||||
|
||||
$folders = [];
|
||||
$page_found = null;
|
||||
@@ -1892,12 +1891,15 @@ class Pages
|
||||
}
|
||||
|
||||
// Page is the one that matches to $page_extensions list with the lowest index number.
|
||||
if (preg_match($regex, $filename, $matches, PREG_OFFSET_CAPTURE)) {
|
||||
$ext = $matches[1][0];
|
||||
|
||||
if ($page_found === null || array_search($ext, $page_extensions, true) < array_search($page_extension, $page_extensions, true)) {
|
||||
$page_found = $file;
|
||||
$page_extension = $ext;
|
||||
// Optimized version avoiding preg_match
|
||||
$pos = strpos($filename, '.');
|
||||
if ($pos !== false && $pos > 0) {
|
||||
$ext = substr($filename, $pos);
|
||||
if (isset($page_extensions[$ext])) {
|
||||
if ($page_found === null || $page_extensions[$ext] < $page_extensions[$page_extension]) {
|
||||
$page_found = $file;
|
||||
$page_extension = $ext;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -78,6 +78,9 @@ class InitializeProcessor extends ProcessorBase
|
||||
// Initialize error handlers.
|
||||
$this->initializeErrors();
|
||||
|
||||
// Register recovery shutdown handler early in the lifecycle.
|
||||
$this->container['recovery']->registerHandlers();
|
||||
|
||||
// Initialize debugger.
|
||||
$debugger = $this->initializeDebugger();
|
||||
|
||||
@@ -143,6 +146,9 @@ class InitializeProcessor extends ProcessorBase
|
||||
// Disable debugger.
|
||||
$this->container['debugger']->enabled(false);
|
||||
|
||||
// Register recovery handler for CLI commands as well.
|
||||
$this->container['recovery']->registerHandlers();
|
||||
|
||||
// Set timezone, locale.
|
||||
$this->initializeLocale($config);
|
||||
|
||||
|
||||
538
system/src/Grav/Common/Recovery/RecoveryManager.php
Normal file
538
system/src/Grav/Common/Recovery/RecoveryManager.php
Normal file
@@ -0,0 +1,538 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @package Grav\Common\Recovery
|
||||
*
|
||||
* @copyright Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.
|
||||
* @license MIT License; see LICENSE file for details.
|
||||
*/
|
||||
|
||||
namespace Grav\Common\Recovery;
|
||||
|
||||
use Grav\Common\Filesystem\Folder;
|
||||
use Grav\Common\Grav;
|
||||
use Grav\Common\Yaml;
|
||||
use RocketTheme\Toolbox\Event\Event;
|
||||
use function bin2hex;
|
||||
use function dirname;
|
||||
use function file_get_contents;
|
||||
use function file_put_contents;
|
||||
use function in_array;
|
||||
use function is_array;
|
||||
use function is_file;
|
||||
use function json_decode;
|
||||
use function json_encode;
|
||||
use function max;
|
||||
use function md5;
|
||||
use function preg_match;
|
||||
use function random_bytes;
|
||||
use function uniqid;
|
||||
use function time;
|
||||
use function trim;
|
||||
use function unlink;
|
||||
use const E_COMPILE_ERROR;
|
||||
use const E_CORE_ERROR;
|
||||
use const E_ERROR;
|
||||
use const E_PARSE;
|
||||
use const E_USER_ERROR;
|
||||
use const GRAV_ROOT;
|
||||
use const JSON_PRETTY_PRINT;
|
||||
use const JSON_UNESCAPED_SLASHES;
|
||||
|
||||
/**
|
||||
* Handles recovery flag lifecycle and plugin quarantine during fatal errors.
|
||||
*/
|
||||
class RecoveryManager
|
||||
{
|
||||
/** @var bool */
|
||||
private $registered = false;
|
||||
/** @var string */
|
||||
private $rootPath;
|
||||
/** @var string */
|
||||
private $userPath;
|
||||
/** @var bool */
|
||||
private $failureCaptured = false;
|
||||
|
||||
/**
|
||||
* @param mixed $context Container or root path.
|
||||
*/
|
||||
public function __construct($context = null)
|
||||
{
|
||||
if ($context instanceof \Grav\Common\Grav) {
|
||||
$root = GRAV_ROOT;
|
||||
} elseif (is_string($context) && $context !== '') {
|
||||
$root = $context;
|
||||
} else {
|
||||
$root = GRAV_ROOT;
|
||||
}
|
||||
|
||||
$this->rootPath = rtrim($root, DIRECTORY_SEPARATOR);
|
||||
$this->userPath = $this->rootPath . '/user';
|
||||
}
|
||||
|
||||
/**
|
||||
* Register shutdown handler to capture fatal errors at runtime.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function registerHandlers(): void
|
||||
{
|
||||
if ($this->registered) {
|
||||
return;
|
||||
}
|
||||
|
||||
register_shutdown_function([$this, 'handleShutdown']);
|
||||
$events = null;
|
||||
try {
|
||||
$events = Grav::instance()['events'] ?? null;
|
||||
} catch (\Throwable $e) {
|
||||
$events = null;
|
||||
}
|
||||
if ($events && method_exists($events, 'addListener')) {
|
||||
$events->addListener('onFatalException', [$this, 'onFatalException']);
|
||||
}
|
||||
$this->registered = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if recovery mode flag is active.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function isActive(): bool
|
||||
{
|
||||
return is_file($this->flagPath());
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove recovery flag.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function clear(): void
|
||||
{
|
||||
$flag = $this->flagPath();
|
||||
if (is_file($flag)) {
|
||||
@unlink($flag);
|
||||
}
|
||||
|
||||
$this->closeUpgradeWindow();
|
||||
$this->failureCaptured = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shutdown handler capturing fatal errors.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function handleShutdown(): void
|
||||
{
|
||||
if ($this->failureCaptured) {
|
||||
return;
|
||||
}
|
||||
|
||||
$error = $this->resolveLastError();
|
||||
if (!$error) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->processFailure($error);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle uncaught exceptions bubbled to the top-level handler.
|
||||
*
|
||||
* @param \Throwable $exception
|
||||
* @return void
|
||||
*/
|
||||
public function handleException(\Throwable $exception): void
|
||||
{
|
||||
if ($this->failureCaptured) {
|
||||
return;
|
||||
}
|
||||
|
||||
$error = [
|
||||
'type' => E_ERROR,
|
||||
'message' => $exception->getMessage(),
|
||||
'file' => $exception->getFile(),
|
||||
'line' => $exception->getLine(),
|
||||
'trace' => $exception->getTraceAsString(),
|
||||
];
|
||||
|
||||
$this->processFailure($error);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Event $event
|
||||
* @return void
|
||||
*/
|
||||
public function onFatalException(Event $event): void
|
||||
{
|
||||
$exception = $event['exception'] ?? null;
|
||||
if ($exception instanceof \Throwable) {
|
||||
$this->handleException($exception);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Activate recovery mode and record context.
|
||||
*
|
||||
* @param array $context
|
||||
* @return void
|
||||
*/
|
||||
public function activate(array $context): void
|
||||
{
|
||||
$flag = $this->flagPath();
|
||||
Folder::create(dirname($flag));
|
||||
if (empty($context['token'])) {
|
||||
$context['token'] = $this->generateToken();
|
||||
}
|
||||
if (!is_file($flag)) {
|
||||
file_put_contents($flag, json_encode($context, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n");
|
||||
} else {
|
||||
// Merge context if flag already exists.
|
||||
$existing = json_decode(file_get_contents($flag), true);
|
||||
if (is_array($existing)) {
|
||||
$context = $context + $existing;
|
||||
if (empty($context['token'])) {
|
||||
$context['token'] = $this->generateToken();
|
||||
}
|
||||
}
|
||||
file_put_contents($flag, json_encode($context, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array $error
|
||||
* @return void
|
||||
*/
|
||||
private function processFailure(array $error): void
|
||||
{
|
||||
$type = (int)($error['type'] ?? 0);
|
||||
if (!$this->isFatal($type)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$file = $error['file'] ?? '';
|
||||
$plugin = $this->detectPluginFromPath($file);
|
||||
|
||||
$context = [
|
||||
'created_at' => time(),
|
||||
'message' => $error['message'] ?? '',
|
||||
'file' => $file,
|
||||
'line' => $error['line'] ?? null,
|
||||
'type' => $type,
|
||||
'plugin' => $plugin,
|
||||
'trace' => $error['trace'] ?? null,
|
||||
];
|
||||
|
||||
if (!$this->shouldEnterRecovery($context)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->activate($context);
|
||||
if ($plugin) {
|
||||
$this->quarantinePlugin($plugin, $context);
|
||||
}
|
||||
|
||||
$this->failureCaptured = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return last recorded recovery context.
|
||||
*
|
||||
* @return array|null
|
||||
*/
|
||||
public function getContext(): ?array
|
||||
{
|
||||
$flag = $this->flagPath();
|
||||
if (!is_file($flag)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$decoded = json_decode(file_get_contents($flag), true);
|
||||
|
||||
return is_array($decoded) ? $decoded : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $slug
|
||||
* @param array $context
|
||||
* @return void
|
||||
*/
|
||||
public function disablePlugin(string $slug, array $context = []): void
|
||||
{
|
||||
$context += [
|
||||
'message' => $context['message'] ?? 'Disabled during upgrade preflight',
|
||||
'file' => $context['file'] ?? '',
|
||||
'line' => $context['line'] ?? null,
|
||||
'created_at' => $context['created_at'] ?? time(),
|
||||
'plugin' => $context['plugin'] ?? $slug,
|
||||
];
|
||||
|
||||
$this->quarantinePlugin($slug, $context);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $slug
|
||||
* @param array $context
|
||||
* @return void
|
||||
*/
|
||||
protected function quarantinePlugin(string $slug, array $context): void
|
||||
{
|
||||
$slug = trim($slug);
|
||||
if ($slug === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
$configPath = $this->userPath . '/config/plugins/' . $slug . '.yaml';
|
||||
Folder::create(dirname($configPath));
|
||||
|
||||
$configuration = is_file($configPath) ? Yaml::parse(file_get_contents($configPath)) : [];
|
||||
if (!is_array($configuration)) {
|
||||
$configuration = [];
|
||||
}
|
||||
|
||||
if (($configuration['enabled'] ?? true) === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
$configuration['enabled'] = false;
|
||||
$yaml = Yaml::dump($configuration);
|
||||
file_put_contents($configPath, $yaml);
|
||||
|
||||
$quarantineFile = $this->userPath . '/data/upgrades/quarantine.json';
|
||||
Folder::create(dirname($quarantineFile));
|
||||
|
||||
$quarantine = [];
|
||||
if (is_file($quarantineFile)) {
|
||||
$decoded = json_decode(file_get_contents($quarantineFile), true);
|
||||
if (is_array($decoded)) {
|
||||
$quarantine = $decoded;
|
||||
}
|
||||
}
|
||||
|
||||
$quarantine[$slug] = [
|
||||
'slug' => $slug,
|
||||
'disabled_at' => time(),
|
||||
'message' => $context['message'] ?? '',
|
||||
'file' => $context['file'] ?? '',
|
||||
'line' => $context['line'] ?? null,
|
||||
];
|
||||
|
||||
file_put_contents($quarantineFile, json_encode($quarantine, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if error type is fatal.
|
||||
*
|
||||
* @param int $type
|
||||
* @return bool
|
||||
*/
|
||||
private function isFatal(int $type): bool
|
||||
{
|
||||
return in_array($type, [E_ERROR, E_CORE_ERROR, E_COMPILE_ERROR, E_PARSE, E_USER_ERROR], true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to derive plugin slug from file path.
|
||||
*
|
||||
* @param string $file
|
||||
* @return string|null
|
||||
*/
|
||||
private function detectPluginFromPath(string $file): ?string
|
||||
{
|
||||
if (!$file) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Standard path: /user/plugins/plugin-name/
|
||||
if (preg_match('#/user/plugins/([^/]+)/#', $file, $matches)) {
|
||||
return $matches[1] ?? null;
|
||||
}
|
||||
|
||||
// Symlinked plugin path: /grav-plugin-plugin-name/ (common dev setup)
|
||||
if (preg_match('#/grav-plugin-([^/]+)/#', $file, $matches)) {
|
||||
return $matches[1] ?? null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string
|
||||
*/
|
||||
private function flagPath(): string
|
||||
{
|
||||
return $this->userPath . '/data/recovery.flag';
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string
|
||||
*/
|
||||
private function windowPath(): string
|
||||
{
|
||||
return $this->userPath . '/data/recovery.window';
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array|null
|
||||
*/
|
||||
private function resolveUpgradeWindow(): ?array
|
||||
{
|
||||
$path = $this->windowPath();
|
||||
if (!is_file($path)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$decoded = json_decode(file_get_contents($path), true);
|
||||
if (!is_array($decoded)) {
|
||||
@unlink($path);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
$expiresAt = (int)($decoded['expires_at'] ?? 0);
|
||||
if ($expiresAt > 0 && $expiresAt < time()) {
|
||||
@unlink($path);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return $decoded;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array $context
|
||||
* @return bool
|
||||
*/
|
||||
private function shouldEnterRecovery(array $context): bool
|
||||
{
|
||||
// Check if recovery mode is enabled in config
|
||||
if (!$this->isEnabled()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$window = $this->resolveUpgradeWindow();
|
||||
if (null === $window) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$scope = $window['scope'] ?? null;
|
||||
if ($scope === 'plugin') {
|
||||
$expected = $window['plugin'] ?? null;
|
||||
if ($expected && ($context['plugin'] ?? null) !== $expected) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if recovery mode is enabled in system config (updates.recovery_mode).
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function isEnabled(): bool
|
||||
{
|
||||
$configFile = $this->userPath . '/config/system.yaml';
|
||||
if (!is_file($configFile)) {
|
||||
return true; // Default enabled
|
||||
}
|
||||
|
||||
$content = file_get_contents($configFile);
|
||||
if ($content === false) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Simple regex-based check to avoid loading full YAML parser
|
||||
if (preg_match('/^\s*updates:\s*\n(?:\s+\w+:.*\n)*?\s+recovery_mode:\s*(true|false|1|0)\s*$/m', $content, $matches)) {
|
||||
return in_array(strtolower($matches[1]), ['true', '1'], true);
|
||||
}
|
||||
|
||||
return true; // Default enabled
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string
|
||||
*/
|
||||
protected function generateToken(): string
|
||||
{
|
||||
try {
|
||||
return bin2hex($this->randomBytes(10));
|
||||
} catch (\Throwable $e) {
|
||||
return md5(uniqid('grav-recovery', true));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param int $length
|
||||
* @return string
|
||||
*/
|
||||
protected function randomBytes(int $length): string
|
||||
{
|
||||
return random_bytes($length);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array|null
|
||||
*/
|
||||
protected function resolveLastError(): ?array
|
||||
{
|
||||
return error_get_last();
|
||||
}
|
||||
|
||||
/**
|
||||
* Begin an upgrade window; during this window fatal plugin errors may trigger recovery mode.
|
||||
*
|
||||
* @param string $reason
|
||||
* @param array $metadata
|
||||
* @param int $ttlSeconds
|
||||
* @return void
|
||||
*/
|
||||
public function markUpgradeWindow(string $reason, array $metadata = [], int $ttlSeconds = 604800): void
|
||||
{
|
||||
$ttl = max(60, $ttlSeconds);
|
||||
$createdAt = time();
|
||||
|
||||
$payload = $metadata + [
|
||||
'reason' => $reason,
|
||||
'created_at' => $createdAt,
|
||||
'expires_at' => $createdAt + $ttl,
|
||||
];
|
||||
|
||||
$path = $this->windowPath();
|
||||
Folder::create(dirname($path));
|
||||
file_put_contents($path, json_encode($payload, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n");
|
||||
$this->failureCaptured = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return bool
|
||||
*/
|
||||
public function isUpgradeWindowActive(): bool
|
||||
{
|
||||
return $this->resolveUpgradeWindow() !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array|null
|
||||
*/
|
||||
public function getUpgradeWindow(): ?array
|
||||
{
|
||||
return $this->resolveUpgradeWindow();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return void
|
||||
*/
|
||||
public function closeUpgradeWindow(): void
|
||||
{
|
||||
$window = $this->windowPath();
|
||||
if (is_file($window)) {
|
||||
@unlink($window);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -21,14 +21,19 @@ trait IntervalTrait
|
||||
{
|
||||
/**
|
||||
* Set the Job execution time.
|
||||
*compo
|
||||
*
|
||||
* @param string $expression
|
||||
* @return self
|
||||
*/
|
||||
public function at($expression)
|
||||
{
|
||||
$this->at = $expression;
|
||||
$this->executionTime = CronExpression::factory($expression);
|
||||
try {
|
||||
$this->executionTime = CronExpression::factory($expression);
|
||||
} catch (InvalidArgumentException $e) {
|
||||
// Invalid cron expression - set to null to prevent DoS
|
||||
$this->executionTime = null;
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
@@ -193,11 +193,32 @@ class Job
|
||||
}
|
||||
|
||||
/**
|
||||
* @return CronExpression
|
||||
* @return CronExpression|null
|
||||
*/
|
||||
public function getCronExpression()
|
||||
{
|
||||
return CronExpression::factory($this->at);
|
||||
try {
|
||||
return CronExpression::factory($this->at);
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
// Invalid cron expression - return null to prevent DoS
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a cron expression
|
||||
*
|
||||
* @param string $expression
|
||||
* @return bool
|
||||
*/
|
||||
public static function isValidCronExpression(string $expression): bool
|
||||
{
|
||||
try {
|
||||
CronExpression::factory($expression);
|
||||
return true;
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
return 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);
|
||||
|
||||
@@ -222,8 +224,9 @@ class Security
|
||||
|
||||
// Set the patterns we'll test against
|
||||
$patterns = [
|
||||
// Match any attribute starting with "on" or xmlns
|
||||
'on_events' => '#(<[^>]+[a-z\x00-\x20\"\'\/])(on[a-z]+|xmlns)\s*=[\s|\'\"].*[\s|\'\"]>#iUu',
|
||||
// Match any attribute starting with "on" or xmlns (must be preceded by whitespace/special chars)
|
||||
// Allow optional whitespace between 'on' and event name to catch obfuscation attempts
|
||||
'on_events' => '#(<[^>]+[\s\x00-\x20\"\'\/])(on\s*[a-z]+|xmlns)\s*=[\s|\'\"].*[\s|\'\"]>#iUu',
|
||||
|
||||
// Match javascript:, livescript:, vbscript:, mocha:, feed: and data: protocols
|
||||
'invalid_protocols' => '#(' . implode('|', array_map('preg_quote', $invalid_protocols, ['#'])) . ')(:|\&\#58)\S.*?#iUu',
|
||||
@@ -241,8 +244,16 @@ class Security
|
||||
// Iterate over rules and return label if fail
|
||||
foreach ($patterns as $name => $regex) {
|
||||
if (!empty($enabled_rules[$name])) {
|
||||
if (preg_match($regex, (string) $string) || preg_match($regex, (string) $stripped) || preg_match($regex, $orig)) {
|
||||
return $name;
|
||||
// Skip testing 'on_events' against stripped version to avoid false positives
|
||||
// with tags like <caption>, <button>, <section> that end with 'on' or contain 'on'
|
||||
if ($name === 'on_events') {
|
||||
if (preg_match($regex, (string) $string) || preg_match($regex, $orig)) {
|
||||
return $name;
|
||||
}
|
||||
} else {
|
||||
if (preg_match($regex, (string) $string) || preg_match($regex, (string) $stripped) || preg_match($regex, $orig)) {
|
||||
return $name;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -262,24 +273,162 @@ class Security
|
||||
];
|
||||
}
|
||||
|
||||
/** @var string|null Cached regex pattern for dangerous functions in Twig blocks */
|
||||
private static ?string $dangerousTwigFunctionsPattern = null;
|
||||
|
||||
/** @var string|null Cached regex pattern for dangerous properties */
|
||||
private static ?string $dangerousTwigPropertiesPattern = null;
|
||||
|
||||
/** @var string|null Cached regex pattern for dangerous function calls */
|
||||
private static ?string $dangerousFunctionCallsPattern = null;
|
||||
|
||||
/** @var string|null Cached regex pattern for string concatenation bypass */
|
||||
private static ?string $dangerousJoinPattern = null;
|
||||
|
||||
/**
|
||||
* Get compiled dangerous Twig patterns (cached for performance)
|
||||
*
|
||||
* @return array{functions: string, properties: string, calls: string, join: string}
|
||||
*/
|
||||
private static function getDangerousTwigPatterns(): array
|
||||
{
|
||||
if (self::$dangerousTwigFunctionsPattern === null) {
|
||||
// Dangerous Twig functions and methods that should be blocked
|
||||
$bad_twig_functions = [
|
||||
// Twig internals
|
||||
'twig_array_map', 'twig_array_filter', 'call_user_func', 'call_user_func_array',
|
||||
'forward_static_call', 'forward_static_call_array',
|
||||
// Twig environment manipulation
|
||||
'registerUndefinedFunctionCallback', 'registerUndefinedFilterCallback',
|
||||
'undefined_functions', 'undefined_filters',
|
||||
// File operations
|
||||
'read_file', 'file_get_contents', 'file_put_contents', 'fopen', 'fread', 'fwrite',
|
||||
'fclose', 'readfile', 'file', 'fpassthru', 'fgetcsv', 'fputcsv', 'ftruncate',
|
||||
'fputs', 'fgets', 'fgetc', 'fflush', 'flock', 'glob', 'rename', 'copy', 'unlink',
|
||||
'rmdir', 'mkdir', 'symlink', 'link', 'chmod', 'chown', 'chgrp', 'touch', 'tempnam',
|
||||
'parse_ini_file', 'highlight_file', 'show_source',
|
||||
// Code execution
|
||||
'exec', 'shell_exec', 'system', 'passthru', 'popen', 'proc_open', 'proc_close',
|
||||
'proc_terminate', 'proc_nice', 'proc_get_status', 'pcntl_exec', 'pcntl_fork',
|
||||
'pcntl_signal', 'pcntl_alarm', 'pcntl_setpriority', 'eval', 'assert',
|
||||
'create_function', 'preg_replace', 'preg_replace_callback', 'ob_start',
|
||||
// Dynamic evaluation
|
||||
'evaluate_twig', 'evaluate',
|
||||
// Serialization
|
||||
'unserialize', 'serialize', 'var_export', 'token_get_all',
|
||||
// Network functions (SSRF)
|
||||
'curl_init', 'curl_exec', 'curl_multi_exec', 'fsockopen', 'pfsockopen',
|
||||
'socket_create', 'stream_socket_client', 'stream_socket_server',
|
||||
// Info disclosure
|
||||
'phpinfo', 'getenv', 'putenv', 'get_current_user', 'getmyuid', 'getmygid',
|
||||
'getmypid', 'get_cfg_var', 'ini_get', 'ini_set', 'ini_alter', 'ini_restore',
|
||||
'get_defined_vars', 'get_defined_functions', 'get_defined_constants',
|
||||
'get_loaded_extensions', 'get_extension_funcs', 'phpversion', 'php_uname',
|
||||
// Reflection
|
||||
'ReflectionClass', 'ReflectionFunction', 'ReflectionMethod',
|
||||
'ReflectionProperty', 'ReflectionObject',
|
||||
// Include/require
|
||||
'include', 'include_once', 'require', 'require_once',
|
||||
// Callback arrays
|
||||
'array_map', 'array_filter', 'array_reduce', 'array_walk', 'array_walk_recursive',
|
||||
'usort', 'uasort', 'uksort', 'iterator_apply',
|
||||
// Output manipulation
|
||||
'header', 'headers_sent', 'header_remove', 'http_response_code',
|
||||
// Mail
|
||||
'mail',
|
||||
// Misc dangerous
|
||||
'extract', 'parse_str', 'register_shutdown_function', 'register_tick_function',
|
||||
'set_error_handler', 'set_exception_handler', 'spl_autoload_register',
|
||||
'apache_child_terminate', 'posix_kill', 'posix_setpgid', 'posix_setsid',
|
||||
'posix_setuid', 'posix_setgid', 'posix_mkfifo', 'dl',
|
||||
// XML (XXE)
|
||||
'simplexml_load_file', 'simplexml_load_string', 'DOMDocument', 'XMLReader',
|
||||
// Database
|
||||
'mysqli_query', 'pg_query', 'sqlite_query',
|
||||
];
|
||||
|
||||
// Dangerous property/method access patterns (regex patterns)
|
||||
$bad_twig_properties = [
|
||||
// Twig environment access
|
||||
'twig\.twig\b', 'grav\.twig\.twig\b', 'twig\.(?:get|add|set)(?:Function|Filter|Extension|Loader|Cache|Runtime)',
|
||||
'twig\.addRuntimeLoader',
|
||||
// Config modification
|
||||
'config\.set\s*\(', 'grav\.config\.set\s*\(', '\.safe_functions', '\.safe_filters',
|
||||
'\.undefined_functions', '\.undefined_filters', 'twig_vars\[', 'config\.join\s*\(',
|
||||
// Scheduler access
|
||||
'grav\.scheduler\b', 'scheduler\.(?:addCommand|save|run|add|remove)\s*\(?',
|
||||
// Core escaper
|
||||
'core\.setEscaper', 'setEscaper\s*\(',
|
||||
// Context access
|
||||
'_context\b', '_self\b', '_charset\b',
|
||||
// User modification
|
||||
'grav\.user\.(?:update|save)\s*\(', 'grav\.accounts\.user\s*\([^)]*\)\.(?:update|save)',
|
||||
'\.(?:set|setNested)Property\s*\(',
|
||||
// Flex objects
|
||||
'(?:get)?[Ff]lexDirectory\s*\(',
|
||||
// Locator write mode
|
||||
'grav\.locator\.findResource\s*\([^)]*,\s*true',
|
||||
// Plugin/theme manipulation
|
||||
'grav\.(?:plugins|themes)\.get\s*\(',
|
||||
// Session manipulation
|
||||
'session\.(?:set|setFlash)\s*\(',
|
||||
// Cache manipulation
|
||||
'cache\.(?:delete|clear|purge)',
|
||||
// Backups and GPM
|
||||
'grav\.(?:backups|gpm)\b',
|
||||
];
|
||||
|
||||
// Build combined patterns (compile once, use many times)
|
||||
// Use word boundaries to avoid false positives (e.g., 'mail' matching 'email')
|
||||
$quotedFunctions = array_map(fn($f) => '\b' . preg_quote($f, '/') . '\b', $bad_twig_functions);
|
||||
$functionsPattern = implode('|', $quotedFunctions);
|
||||
|
||||
// Pattern for functions in Twig blocks
|
||||
self::$dangerousTwigFunctionsPattern = '/(({{\s*|{%\s*)[^}]*?(' . $functionsPattern . ')[^}]*?(\s*}}|\s*%}))/i';
|
||||
|
||||
// Pattern for properties (already regex patterns, just combine)
|
||||
$propertiesPattern = implode('|', $bad_twig_properties);
|
||||
self::$dangerousTwigPropertiesPattern = '/(({{\s*|{%\s*)[^}]*?(' . $propertiesPattern . ')[^}]*?(\s*}}|\s*%}))/i';
|
||||
|
||||
// Pattern for function calls outside Twig blocks (for nested eval)
|
||||
self::$dangerousFunctionCallsPattern = '/\b(' . $functionsPattern . ')\s*\(/i';
|
||||
|
||||
// Pattern for string concatenation bypass attempts
|
||||
$suspiciousFragments = ['safe_func', 'safe_filt', 'undefined_', 'scheduler', 'registerUndefined', '_context', 'setEscaper'];
|
||||
$fragmentsPattern = implode('|', array_map(fn($f) => preg_quote($f, '/'), $suspiciousFragments));
|
||||
self::$dangerousJoinPattern = '/(({{\s*|{%\s*)[^}]*?\[[^\]]*[\'"](' . $fragmentsPattern . ')[\'"][^\]]*\]\s*\|\s*join[^}]*?(\s*}}|\s*%}))/i';
|
||||
}
|
||||
|
||||
return [
|
||||
'functions' => self::$dangerousTwigFunctionsPattern,
|
||||
'properties' => self::$dangerousTwigPropertiesPattern,
|
||||
'calls' => self::$dangerousFunctionCallsPattern,
|
||||
'join' => self::$dangerousJoinPattern,
|
||||
];
|
||||
}
|
||||
|
||||
public static function cleanDangerousTwig(string $string): string
|
||||
{
|
||||
if ($string === '') {
|
||||
// Early exit for empty strings or strings without Twig
|
||||
if ($string === '' || (strpos($string, '{{') === false && strpos($string, '{%') === false)) {
|
||||
return $string;
|
||||
}
|
||||
|
||||
$bad_twig = [
|
||||
'twig_array_map',
|
||||
'twig_array_filter',
|
||||
'call_user_func',
|
||||
'registerUndefinedFunctionCallback',
|
||||
'undefined_functions',
|
||||
'twig.getFunction',
|
||||
'core.setEscaper',
|
||||
'twig.safe_functions',
|
||||
'read_file',
|
||||
];
|
||||
$string = preg_replace('/(({{\s*|{%\s*)[^}]*?(' . implode('|', $bad_twig) . ')[^}]*?(\s*}}|\s*%}))/i', '{# $1 #}', $string);
|
||||
// Get cached compiled patterns
|
||||
$patterns = self::getDangerousTwigPatterns();
|
||||
|
||||
// Pass 1: Block dangerous functions in Twig blocks
|
||||
$string = preg_replace($patterns['functions'], '{# BLOCKED: $1 #}', $string);
|
||||
|
||||
// Pass 2: Block dangerous property access patterns
|
||||
$string = preg_replace($patterns['properties'], '{# BLOCKED: $1 #}', $string);
|
||||
|
||||
// Pass 3: Block dangerous function calls (for nested eval bypass)
|
||||
$string = preg_replace($patterns['calls'], '{# BLOCKED: $0 #}', $string);
|
||||
|
||||
// Pass 4: Block string concatenation bypass attempts
|
||||
$string = preg_replace($patterns['join'], '{# BLOCKED: $1 #}', $string);
|
||||
|
||||
return $string;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ use Grav\Common\Language\Language;
|
||||
use Grav\Framework\Mime\MimeTypes;
|
||||
use Pimple\Container;
|
||||
use Pimple\ServiceProviderInterface;
|
||||
use RocketTheme\Toolbox\File\PhpFile;
|
||||
use RocketTheme\Toolbox\File\YamlFile;
|
||||
use RocketTheme\Toolbox\ResourceLocator\UniformResourceLocator;
|
||||
|
||||
@@ -85,15 +86,30 @@ class ConfigServiceProvider implements ServiceProviderInterface
|
||||
/** @var UniformResourceLocator $locator */
|
||||
$locator = $container['locator'];
|
||||
|
||||
$cache = $locator->findResource('cache://compiled/blueprints', true, true);
|
||||
$cache = $locator->findResource('cache://compiled/blueprints', true, true);
|
||||
|
||||
$files = [];
|
||||
$paths = $locator->findResources('blueprints://config');
|
||||
$files += (new ConfigFileFinder)->locateFiles($paths);
|
||||
$paths = $locator->findResources('plugins://');
|
||||
$files += (new ConfigFileFinder)->setBase('plugins')->locateInFolders($paths, 'blueprints');
|
||||
$paths = $locator->findResources('themes://');
|
||||
$files += (new ConfigFileFinder)->setBase('themes')->locateInFolders($paths, 'blueprints');
|
||||
// Try to load cached file list to avoid filesystem scanning on every request
|
||||
$files = static::loadCachedFileList($locator, $cache, 'blueprints', $setup->environment);
|
||||
|
||||
if ($files === null) {
|
||||
// Cache miss - scan filesystem for blueprint files
|
||||
$files = [];
|
||||
$paths = $locator->findResources('blueprints://config');
|
||||
$files += (new ConfigFileFinder)->locateFiles($paths);
|
||||
$paths = $locator->findResources('plugins://');
|
||||
$files += (new ConfigFileFinder)->setBase('plugins')->locateInFolders($paths, 'blueprints');
|
||||
$paths = $locator->findResources('themes://');
|
||||
$files += (new ConfigFileFinder)->setBase('themes')->locateInFolders($paths, 'blueprints');
|
||||
|
||||
// Save file list cache for next request
|
||||
static::saveCachedFileList($locator, $cache, 'blueprints', $setup->environment, $files);
|
||||
|
||||
// Also invalidate the compiled blueprints cache to force rebuild
|
||||
$masterBlueprints = "{$cache}/master-{$setup->environment}.php";
|
||||
if (file_exists($masterBlueprints)) {
|
||||
@unlink($masterBlueprints);
|
||||
}
|
||||
}
|
||||
|
||||
$blueprints = new CompiledBlueprints($cache, $files, GRAV_ROOT);
|
||||
|
||||
@@ -112,15 +128,30 @@ class ConfigServiceProvider implements ServiceProviderInterface
|
||||
/** @var UniformResourceLocator $locator */
|
||||
$locator = $container['locator'];
|
||||
|
||||
$cache = $locator->findResource('cache://compiled/config', true, true);
|
||||
$cache = $locator->findResource('cache://compiled/config', true, true);
|
||||
|
||||
$files = [];
|
||||
$paths = $locator->findResources('config://');
|
||||
$files += (new ConfigFileFinder)->locateFiles($paths);
|
||||
$paths = $locator->findResources('plugins://');
|
||||
$files += (new ConfigFileFinder)->setBase('plugins')->locateInFolders($paths);
|
||||
$paths = $locator->findResources('themes://');
|
||||
$files += (new ConfigFileFinder)->setBase('themes')->locateInFolders($paths);
|
||||
// Try to load cached file list to avoid filesystem scanning on every request
|
||||
$files = static::loadCachedFileList($locator, $cache, 'config', $setup->environment);
|
||||
|
||||
if ($files === null) {
|
||||
// Cache miss - scan filesystem for config files
|
||||
$files = [];
|
||||
$paths = $locator->findResources('config://');
|
||||
$files += (new ConfigFileFinder)->locateFiles($paths);
|
||||
$paths = $locator->findResources('plugins://');
|
||||
$files += (new ConfigFileFinder)->setBase('plugins')->locateInFolders($paths);
|
||||
$paths = $locator->findResources('themes://');
|
||||
$files += (new ConfigFileFinder)->setBase('themes')->locateInFolders($paths);
|
||||
|
||||
// Save file list cache for next request
|
||||
static::saveCachedFileList($locator, $cache, 'config', $setup->environment, $files);
|
||||
|
||||
// Also invalidate the compiled config cache to force rebuild
|
||||
$masterConfig = "{$cache}/master-{$setup->environment}.php";
|
||||
if (file_exists($masterConfig)) {
|
||||
@unlink($masterConfig);
|
||||
}
|
||||
}
|
||||
|
||||
$compiled = new CompiledConfig($cache, $files, GRAV_ROOT);
|
||||
$compiled->setBlueprints(fn() => $container['blueprints']);
|
||||
@@ -151,12 +182,28 @@ class ConfigServiceProvider implements ServiceProviderInterface
|
||||
|
||||
// Process languages only if enabled in configuration.
|
||||
if ($config->get('system.languages.translations', true)) {
|
||||
$paths = $locator->findResources('languages://');
|
||||
$files += (new ConfigFileFinder)->locateFiles($paths);
|
||||
$paths = $locator->findResources('plugins://');
|
||||
$files += (new ConfigFileFinder)->setBase('plugins')->locateInFolders($paths, 'languages');
|
||||
$paths = static::pluginFolderPaths($paths, 'languages');
|
||||
$files += (new ConfigFileFinder)->locateFiles($paths);
|
||||
// Try to load cached file list to avoid filesystem scanning on every request
|
||||
$files = static::loadCachedFileList($locator, $cache, 'languages', $setup->environment);
|
||||
|
||||
if ($files === null) {
|
||||
// Cache miss - scan filesystem for language files
|
||||
$files = [];
|
||||
$paths = $locator->findResources('languages://');
|
||||
$files += (new ConfigFileFinder)->locateFiles($paths);
|
||||
$paths = $locator->findResources('plugins://');
|
||||
$files += (new ConfigFileFinder)->setBase('plugins')->locateInFolders($paths, 'languages');
|
||||
$paths = static::pluginFolderPaths($paths, 'languages');
|
||||
$files += (new ConfigFileFinder)->locateFiles($paths);
|
||||
|
||||
// Save file list cache for next request
|
||||
static::saveCachedFileList($locator, $cache, 'languages', $setup->environment, $files);
|
||||
|
||||
// Also invalidate the compiled languages cache to force rebuild
|
||||
$masterLanguages = "{$cache}/master-{$setup->environment}.php";
|
||||
if (file_exists($masterLanguages)) {
|
||||
@unlink($masterLanguages);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$languages = new CompiledLanguages($cache, $files, GRAV_ROOT);
|
||||
@@ -195,4 +242,154 @@ class ConfigServiceProvider implements ServiceProviderInterface
|
||||
}
|
||||
return $paths;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load cached file list if still valid (based on directory and file mtimes).
|
||||
*
|
||||
* @param UniformResourceLocator $locator
|
||||
* @param string $cacheDir
|
||||
* @param string $type
|
||||
* @param string $environment
|
||||
* @return array|null Returns cached files array or null if cache is invalid
|
||||
*/
|
||||
protected static function loadCachedFileList(UniformResourceLocator $locator, string $cacheDir, string $type, string $environment): ?array
|
||||
{
|
||||
$cacheFile = "{$cacheDir}/filelist-{$type}-{$environment}.php";
|
||||
|
||||
if (!file_exists($cacheFile)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$cache = include $cacheFile;
|
||||
|
||||
if (!is_array($cache) || !isset($cache['directories'], $cache['files'])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Validate cache by checking directory mtimes
|
||||
foreach ($cache['directories'] as $dir => $mtime) {
|
||||
// Check if directory still exists and mtime hasn't changed
|
||||
$currentMtime = @filemtime($dir);
|
||||
if ($currentMtime === false || $currentMtime !== $mtime) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Validate cache by checking individual file mtimes
|
||||
if (isset($cache['file_mtimes'])) {
|
||||
foreach ($cache['file_mtimes'] as $file => $mtime) {
|
||||
$currentMtime = @filemtime($file);
|
||||
if ($currentMtime === false || $currentMtime !== $mtime) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $cache['files'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Save file list to cache with directory and file mtimes for validation.
|
||||
*
|
||||
* @param UniformResourceLocator $locator
|
||||
* @param string $cacheDir
|
||||
* @param string $type
|
||||
* @param string $environment
|
||||
* @param array $files
|
||||
* @return void
|
||||
*/
|
||||
protected static function saveCachedFileList(UniformResourceLocator $locator, string $cacheDir, string $type, string $environment, array $files): void
|
||||
{
|
||||
// Collect all directories that were scanned based on type
|
||||
$directories = [];
|
||||
|
||||
// Collect mtimes for all individual config files
|
||||
$fileMtimes = [];
|
||||
foreach ($files as $group) {
|
||||
foreach ($group as $item) {
|
||||
if (isset($item['file'])) {
|
||||
$filePath = GRAV_ROOT . '/' . $item['file'];
|
||||
if (file_exists($filePath)) {
|
||||
$fileMtimes[$filePath] = filemtime($filePath);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Type-specific base directories
|
||||
if ($type === 'config') {
|
||||
$basePaths = $locator->findResources('config://');
|
||||
foreach ($basePaths as $path) {
|
||||
if (is_dir($path)) {
|
||||
$directories[$path] = filemtime($path);
|
||||
}
|
||||
}
|
||||
} elseif ($type === 'blueprints') {
|
||||
$basePaths = $locator->findResources('blueprints://config');
|
||||
foreach ($basePaths as $path) {
|
||||
if (is_dir($path)) {
|
||||
$directories[$path] = filemtime($path);
|
||||
}
|
||||
}
|
||||
} elseif ($type === 'languages') {
|
||||
$basePaths = $locator->findResources('languages://');
|
||||
foreach ($basePaths as $path) {
|
||||
if (is_dir($path)) {
|
||||
$directories[$path] = filemtime($path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get plugin directories (used by all types)
|
||||
$pluginPaths = $locator->findResources('plugins://');
|
||||
foreach ($pluginPaths as $path) {
|
||||
if (is_dir($path)) {
|
||||
$directories[$path] = filemtime($path);
|
||||
// Also track individual plugin directories for granular invalidation
|
||||
$iterator = new DirectoryIterator($path);
|
||||
foreach ($iterator as $dir) {
|
||||
if ($dir->isDir() && !$dir->isDot()) {
|
||||
$directories[$dir->getPathname()] = $dir->getMTime();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get theme directories (used by config and blueprints)
|
||||
if ($type !== 'languages') {
|
||||
$themePaths = $locator->findResources('themes://');
|
||||
foreach ($themePaths as $path) {
|
||||
if (is_dir($path)) {
|
||||
$directories[$path] = filemtime($path);
|
||||
// Also track individual theme directories
|
||||
$iterator = new DirectoryIterator($path);
|
||||
foreach ($iterator as $dir) {
|
||||
if ($dir->isDir() && !$dir->isDot()) {
|
||||
$directories[$dir->getPathname()] = $dir->getMTime();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$cache = [
|
||||
'@class' => static::class,
|
||||
'type' => $type,
|
||||
'environment' => $environment,
|
||||
'timestamp' => time(),
|
||||
'directories' => $directories,
|
||||
'file_mtimes' => $fileMtimes,
|
||||
'files' => $files,
|
||||
];
|
||||
|
||||
// Ensure cache directory exists
|
||||
if (!is_dir($cacheDir)) {
|
||||
mkdir($cacheDir, 0775, true);
|
||||
}
|
||||
|
||||
$cacheFile = "{$cacheDir}/filelist-{$type}-{$environment}.php";
|
||||
$file = PhpFile::instance($cacheFile);
|
||||
$file->save($cache);
|
||||
$file->free();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,11 +4,17 @@ declare(strict_types=1);
|
||||
|
||||
namespace Grav\Common\Twig\Compatibility;
|
||||
|
||||
use Twig\Loader\ChainLoader;
|
||||
use Twig\Loader\FilesystemLoader;
|
||||
use Twig\Loader\LoaderInterface;
|
||||
use Twig\Source;
|
||||
|
||||
/**
|
||||
* Decorates the active Twig loader to rewrite legacy Twig 1/2 constructs on the fly.
|
||||
*
|
||||
* This loader wraps the ChainLoader and transforms template source code for Twig 3 compatibility.
|
||||
* It also proxies common FilesystemLoader methods to maintain backwards compatibility with
|
||||
* plugins that may call these methods on the loader.
|
||||
*/
|
||||
class Twig3CompatibilityLoader implements LoaderInterface
|
||||
{
|
||||
@@ -18,6 +24,110 @@ class Twig3CompatibilityLoader implements LoaderInterface
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the inner loader (ChainLoader).
|
||||
*
|
||||
* @return LoaderInterface
|
||||
*/
|
||||
public function getInnerLoader(): LoaderInterface
|
||||
{
|
||||
return $this->inner;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the FilesystemLoader from the inner ChainLoader.
|
||||
*
|
||||
* @return FilesystemLoader|null
|
||||
*/
|
||||
public function getFilesystemLoader(): ?FilesystemLoader
|
||||
{
|
||||
if ($this->inner instanceof ChainLoader) {
|
||||
foreach ($this->inner->getLoaders() as $loader) {
|
||||
if ($loader instanceof FilesystemLoader) {
|
||||
return $loader;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Proxy addPath to the FilesystemLoader.
|
||||
*
|
||||
* @param string $path
|
||||
* @param string $namespace
|
||||
* @return void
|
||||
*/
|
||||
public function addPath(string $path, string $namespace = FilesystemLoader::MAIN_NAMESPACE): void
|
||||
{
|
||||
$loader = $this->getFilesystemLoader();
|
||||
if ($loader !== null) {
|
||||
$loader->addPath($path, $namespace);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Proxy prependPath to the FilesystemLoader.
|
||||
*
|
||||
* @param string $path
|
||||
* @param string $namespace
|
||||
* @return void
|
||||
*/
|
||||
public function prependPath(string $path, string $namespace = FilesystemLoader::MAIN_NAMESPACE): void
|
||||
{
|
||||
$loader = $this->getFilesystemLoader();
|
||||
if ($loader !== null) {
|
||||
$loader->prependPath($path, $namespace);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Proxy getPaths to the FilesystemLoader.
|
||||
*
|
||||
* @param string $namespace
|
||||
* @return array
|
||||
*/
|
||||
public function getPaths(string $namespace = FilesystemLoader::MAIN_NAMESPACE): array
|
||||
{
|
||||
$loader = $this->getFilesystemLoader();
|
||||
if ($loader !== null) {
|
||||
return $loader->getPaths($namespace);
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Proxy getNamespaces to the FilesystemLoader.
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function getNamespaces(): array
|
||||
{
|
||||
$loader = $this->getFilesystemLoader();
|
||||
if ($loader !== null) {
|
||||
return $loader->getNamespaces();
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Proxy setPaths to the FilesystemLoader.
|
||||
*
|
||||
* @param array $paths
|
||||
* @param string $namespace
|
||||
* @return void
|
||||
*/
|
||||
public function setPaths(array $paths, string $namespace = FilesystemLoader::MAIN_NAMESPACE): void
|
||||
{
|
||||
$loader = $this->getFilesystemLoader();
|
||||
if ($loader !== null) {
|
||||
$loader->setPaths($paths, $namespace);
|
||||
}
|
||||
}
|
||||
|
||||
public function getSourceContext(string $name): Source
|
||||
{
|
||||
$source = $this->inner->getSourceContext($name);
|
||||
|
||||
@@ -18,7 +18,10 @@ class Twig3CompatibilityTransformer
|
||||
$code = $this->rewriteSpacelessBlocks($code);
|
||||
$code = $this->rewriteFilterBlocks($code);
|
||||
$code = $this->rewriteSameAsTests($code);
|
||||
$code = $this->rewriteDivisibleByTests($code);
|
||||
$code = $this->rewriteNoneTests($code);
|
||||
$code = $this->rewriteReplaceFilterSignatures($code);
|
||||
$code = $this->rewriteRawBlocks($code);
|
||||
|
||||
return $code;
|
||||
}
|
||||
@@ -135,14 +138,22 @@ class Twig3CompatibilityTransformer
|
||||
|
||||
private function rewriteSameAsTests(string $code): string
|
||||
{
|
||||
$pattern = '/\bis\s+sameas(\s*\()/i';
|
||||
$pattern = '/([\'"])(?:\\\\.|(?!\\1).)*\\1|\\bis\\s+(?:not\\s+)?sameas\\b/is';
|
||||
|
||||
return (string) preg_replace($pattern, 'is same as$1', $code);
|
||||
return (string) preg_replace_callback($pattern, static function ($matches) {
|
||||
// If group 1 is not set, it means 'is sameas' was matched.
|
||||
if (!isset($matches[1])) {
|
||||
return str_ireplace('sameas', 'same as', $matches[0]);
|
||||
}
|
||||
|
||||
// Otherwise, it's a quoted string, so return it as is.
|
||||
return $matches[0];
|
||||
}, $code);
|
||||
}
|
||||
|
||||
private function rewriteReplaceFilterSignatures(string $code): string
|
||||
{
|
||||
$pattern = '/\|replace\(\s*(["\])(.*?)\1\s*,\s*(["\])(.*?)\3\s*\)/';
|
||||
$pattern = '/\|replace\(\s*(["\'])(.*?)\1\s*,\s*(["\'])(.*?)\3\s*\)/';
|
||||
$code = (string) preg_replace_callback($pattern, static function (array $matches): string {
|
||||
$keyQuote = $matches[1];
|
||||
$key = $matches[2];
|
||||
@@ -155,6 +166,56 @@ class Twig3CompatibilityTransformer
|
||||
return $code;
|
||||
}
|
||||
|
||||
private function rewriteRawBlocks(string $code): string
|
||||
{
|
||||
$openPattern = '/\{%(\-?)\s*raw\s*(\-?)%\}/i';
|
||||
$code = (string) preg_replace_callback($openPattern, static function (array $matches): string {
|
||||
$leading = $matches[1] === '-' ? '-' : '';
|
||||
$trailing = $matches[2] === '-' ? '-' : '';
|
||||
|
||||
return '{%' . $leading . ' verbatim ' . $trailing . '%}';
|
||||
}, $code);
|
||||
|
||||
$closePattern = '/\{%(\-?)\s*endraw\s*(\-?)%\}/i';
|
||||
|
||||
return (string) preg_replace_callback($closePattern, static function (array $matches): string {
|
||||
$leading = $matches[1] === '-' ? '-' : '';
|
||||
$trailing = $matches[2] === '-' ? '-' : '';
|
||||
|
||||
return '{%' . $leading . ' endverbatim ' . $trailing . '%}';
|
||||
}, $code);
|
||||
}
|
||||
|
||||
private function rewriteDivisibleByTests(string $code): string
|
||||
{
|
||||
$pattern = '/([\'"])(?:\\\\.|(?!\\1).)*\\1|\\bis\\s+(?:not\\s+)?divisibleby\\b/is';
|
||||
|
||||
return (string) preg_replace_callback($pattern, static function ($matches) {
|
||||
// If group 1 is not set, it means 'is divisibleby' was matched.
|
||||
if (!isset($matches[1])) {
|
||||
return str_ireplace('divisibleby', 'divisible by', $matches[0]);
|
||||
}
|
||||
|
||||
// Otherwise, it's a quoted string, so return it as is.
|
||||
return $matches[0];
|
||||
}, $code);
|
||||
}
|
||||
|
||||
private function rewriteNoneTests(string $code): string
|
||||
{
|
||||
$pattern = '/([\'"])(?:\\\\.|(?!\\1).)*\\1|\\bis\\s+(?:not\\s+)?none\\b/is';
|
||||
|
||||
return (string) preg_replace_callback($pattern, static function ($matches) {
|
||||
// If group 1 is not set, it means 'is none' was matched.
|
||||
if (!isset($matches[1])) {
|
||||
return str_ireplace('none', 'null', $matches[0]);
|
||||
}
|
||||
|
||||
// Otherwise, it's a quoted string, so return it as is.
|
||||
return $matches[0];
|
||||
}, $code);
|
||||
}
|
||||
|
||||
private function ensureWrapped(string $expression): string
|
||||
{
|
||||
$trimmed = trim($expression);
|
||||
|
||||
@@ -140,6 +140,7 @@ class GravExtension extends AbstractExtension implements GlobalsInterface
|
||||
new TwigFilter('starts_with', $this->startsWithFilter(...)),
|
||||
new TwigFilter('truncate', Utils::truncate(...)),
|
||||
new TwigFilter('truncate_html', Utils::truncateHTML(...)),
|
||||
new TwigFilter('wordcount', [$this, 'wordCountFilter']),
|
||||
new TwigFilter('json_decode', $this->jsonDecodeFilter(...)),
|
||||
new TwigFilter('array_unique', 'array_unique'),
|
||||
new TwigFilter('basename', 'basename'),
|
||||
@@ -577,15 +578,76 @@ class GravExtension extends AbstractExtension implements GlobalsInterface
|
||||
return $str;
|
||||
}
|
||||
|
||||
/**
|
||||
* Count words in text with improved accuracy for multiple languages
|
||||
*
|
||||
* @param string $text The text to count words from
|
||||
* @param string $locale Optional locale for language-specific counting (default: 'en')
|
||||
* @return int Number of words
|
||||
*/
|
||||
public function wordCountFilter($text, string $locale = 'en'): int
|
||||
{
|
||||
if (empty($text)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Strip HTML tags and decode entities
|
||||
$cleanText = html_entity_decode(strip_tags($text), ENT_QUOTES, 'UTF-8');
|
||||
|
||||
// Remove extra whitespace and normalize
|
||||
$cleanText = trim(preg_replace('/\s+/', ' ', $cleanText));
|
||||
|
||||
if (empty($cleanText)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Handle different languages
|
||||
switch (strtolower($locale)) {
|
||||
case 'zh':
|
||||
case 'zh-cn':
|
||||
case 'zh-tw':
|
||||
case 'chinese':
|
||||
// Chinese: count characters (excluding spaces and punctuation)
|
||||
return mb_strlen(preg_replace('/[\s\p{P}]/u', '', $cleanText), 'UTF-8');
|
||||
|
||||
case 'ja':
|
||||
case 'japanese':
|
||||
// Japanese: count characters (excluding spaces)
|
||||
return mb_strlen(preg_replace('/\s/', '', $cleanText), 'UTF-8');
|
||||
|
||||
case 'ko':
|
||||
case 'korean':
|
||||
// Korean: count characters (excluding spaces)
|
||||
return mb_strlen(preg_replace('/\s/', '', $cleanText), 'UTF-8');
|
||||
|
||||
default:
|
||||
// Western languages: use improved word counting
|
||||
// Handle contractions, hyphenated words, and numbers better
|
||||
$words = preg_split('/\s+/', $cleanText, -1, PREG_SPLIT_NO_EMPTY);
|
||||
|
||||
// Filter out pure punctuation
|
||||
$words = array_filter($words, function($word) {
|
||||
return preg_match('/\w/', $word);
|
||||
});
|
||||
|
||||
return count($words);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Cron object for a crontab 'at' format
|
||||
*
|
||||
* @param string $at
|
||||
* @return CronExpression
|
||||
* @return CronExpression|null
|
||||
*/
|
||||
public function cronFunc($at)
|
||||
{
|
||||
return CronExpression::factory($at);
|
||||
try {
|
||||
return CronExpression::factory($at);
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
// Invalid cron expression - return null to prevent DoS
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1381,11 +1443,42 @@ class GravExtension extends AbstractExtension implements GlobalsInterface
|
||||
$filepath = $locator->findResource($filepath);
|
||||
}
|
||||
|
||||
if ($filepath && file_exists($filepath)) {
|
||||
return file_get_contents($filepath);
|
||||
if (!$filepath || !file_exists($filepath)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return false;
|
||||
// Security: Get the real path to prevent path traversal
|
||||
$realpath = realpath($filepath);
|
||||
if ($realpath === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Security: Ensure the file is within GRAV_ROOT
|
||||
$gravRoot = realpath(GRAV_ROOT);
|
||||
if ($gravRoot === false || strpos($realpath, $gravRoot) !== 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Security: Block access to sensitive files and directories
|
||||
$blockedPatterns = [
|
||||
'/\/accounts\/[^\/]+\.yaml$/', // User account files
|
||||
'/\/config\/security\.yaml$/', // Security config
|
||||
'/\/\.env/', // Environment files
|
||||
'/\/\.git/', // Git directory
|
||||
'/\/\.htaccess/', // Apache config
|
||||
'/\/\.htpasswd/', // Apache passwords
|
||||
'/\/vendor\//', // Composer vendor (may contain sensitive info)
|
||||
'/\/logs\//', // Log files
|
||||
'/\/backup\//', // Backup files
|
||||
];
|
||||
|
||||
foreach ($blockedPatterns as $pattern) {
|
||||
if (preg_match($pattern, $realpath)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return file_get_contents($realpath);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1688,6 +1781,10 @@ class GravExtension extends AbstractExtension implements GlobalsInterface
|
||||
throw new RuntimeError('Twig |filter("' . $arrow . '") is not allowed.');
|
||||
}
|
||||
|
||||
if ($array === null) {
|
||||
$array = [];
|
||||
}
|
||||
|
||||
return twig_array_filter($env, $array, $arrow);
|
||||
}
|
||||
|
||||
@@ -1704,6 +1801,10 @@ class GravExtension extends AbstractExtension implements GlobalsInterface
|
||||
throw new RuntimeError('Twig |map("' . $arrow . '") is not allowed.');
|
||||
}
|
||||
|
||||
if ($array === null) {
|
||||
$array = [];
|
||||
}
|
||||
|
||||
return twig_array_map($env, $array, $arrow);
|
||||
}
|
||||
|
||||
@@ -1720,6 +1821,10 @@ class GravExtension extends AbstractExtension implements GlobalsInterface
|
||||
throw new RuntimeError('Twig |reduce("' . $arrow . '") is not allowed.');
|
||||
}
|
||||
|
||||
if ($array === null) {
|
||||
$array = [];
|
||||
}
|
||||
|
||||
return twig_array_map($env, $array, $arrow);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ class TwigNodeSwitch extends Node
|
||||
$nodes = ['value' => $value, 'cases' => $cases, 'default' => $default];
|
||||
$nodes = array_filter($nodes);
|
||||
|
||||
parent::__construct($nodes, [], $lineno, $tag);
|
||||
parent::__construct($nodes, [], $lineno);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -44,9 +44,9 @@ class TwigTokenParserCache extends AbstractTokenParser
|
||||
$lifetime = null;
|
||||
while (!$stream->test(Token::BLOCK_END_TYPE)) {
|
||||
if ($stream->test(Token::STRING_TYPE)) {
|
||||
$key = $this->parser->getExpressionParser()->parseExpression();
|
||||
$key = $this->parser->parseExpression();
|
||||
} elseif ($stream->test(Token::NUMBER_TYPE)) {
|
||||
$lifetime = $this->parser->getExpressionParser()->parseExpression();
|
||||
$lifetime = $this->parser->parseExpression();
|
||||
} else {
|
||||
throw new \Twig\Error\SyntaxError("Unexpected token type in cache tag.", $token->getLine(), $stream->getSourceContext());
|
||||
}
|
||||
|
||||
@@ -73,23 +73,23 @@ class TwigTokenParserLink extends AbstractTokenParser
|
||||
|
||||
$file = null;
|
||||
if (!$stream->test(Token::NAME_TYPE) && !$stream->test(Token::BLOCK_END_TYPE)) {
|
||||
$file = $this->parser->getExpressionParser()->parseExpression();
|
||||
$file = $this->parser->parseExpression();
|
||||
}
|
||||
|
||||
$group = null;
|
||||
if ($stream->nextIf(Token::NAME_TYPE, 'at')) {
|
||||
$group = $this->parser->getExpressionParser()->parseExpression();
|
||||
$group = $this->parser->parseExpression();
|
||||
}
|
||||
|
||||
$priority = null;
|
||||
if ($stream->nextIf(Token::NAME_TYPE, 'priority')) {
|
||||
$stream->expect(Token::PUNCTUATION_TYPE, ':');
|
||||
$priority = $this->parser->getExpressionParser()->parseExpression();
|
||||
$priority = $this->parser->parseExpression();
|
||||
}
|
||||
|
||||
$attributes = null;
|
||||
if ($stream->nextIf(Token::NAME_TYPE, 'with')) {
|
||||
$attributes = $this->parser->getExpressionParser()->parseExpression();
|
||||
$attributes = $this->parser->parseExpression();
|
||||
}
|
||||
|
||||
$stream->expect(Token::BLOCK_END_TYPE);
|
||||
|
||||
@@ -44,17 +44,17 @@ class TwigTokenParserRender extends AbstractTokenParser
|
||||
{
|
||||
$stream = $this->parser->getStream();
|
||||
|
||||
$object = $this->parser->getExpressionParser()->parseExpression();
|
||||
$object = $this->parser->parseExpression();
|
||||
|
||||
$layout = null;
|
||||
if ($stream->nextIf(Token::NAME_TYPE, 'layout')) {
|
||||
$stream->expect(Token::PUNCTUATION_TYPE, ':');
|
||||
$layout = $this->parser->getExpressionParser()->parseExpression();
|
||||
$layout = $this->parser->parseExpression();
|
||||
}
|
||||
|
||||
$context = null;
|
||||
if ($stream->nextIf(Token::NAME_TYPE, 'with')) {
|
||||
$context = $this->parser->getExpressionParser()->parseExpression();
|
||||
$context = $this->parser->parseExpression();
|
||||
}
|
||||
|
||||
$stream->expect(Token::BLOCK_END_TYPE);
|
||||
|
||||
@@ -87,23 +87,23 @@ class TwigTokenParserScript extends AbstractTokenParser
|
||||
|
||||
$file = null;
|
||||
if (!$stream->test(Token::NAME_TYPE) && !$stream->test(Token::OPERATOR_TYPE, 'in') && !$stream->test(Token::BLOCK_END_TYPE)) {
|
||||
$file = $this->parser->getExpressionParser()->parseExpression();
|
||||
$file = $this->parser->parseExpression();
|
||||
}
|
||||
|
||||
$group = null;
|
||||
if ($stream->nextIf(Token::NAME_TYPE, 'at') || $stream->nextIf(Token::OPERATOR_TYPE, 'in')) {
|
||||
$group = $this->parser->getExpressionParser()->parseExpression();
|
||||
$group = $this->parser->parseExpression();
|
||||
}
|
||||
|
||||
$priority = null;
|
||||
if ($stream->nextIf(Token::NAME_TYPE, 'priority')) {
|
||||
$stream->expect(Token::PUNCTUATION_TYPE, ':');
|
||||
$priority = $this->parser->getExpressionParser()->parseExpression();
|
||||
$priority = $this->parser->parseExpression();
|
||||
}
|
||||
|
||||
$attributes = null;
|
||||
if ($stream->nextIf(Token::NAME_TYPE, 'with')) {
|
||||
$attributes = $this->parser->getExpressionParser()->parseExpression();
|
||||
$attributes = $this->parser->parseExpression();
|
||||
}
|
||||
|
||||
$stream->expect(Token::BLOCK_END_TYPE);
|
||||
|
||||
@@ -74,23 +74,23 @@ class TwigTokenParserStyle extends AbstractTokenParser
|
||||
|
||||
$file = null;
|
||||
if (!$stream->test(Token::NAME_TYPE) && !$stream->test(Token::OPERATOR_TYPE, 'in') && !$stream->test(Token::BLOCK_END_TYPE)) {
|
||||
$file = $this->parser->getExpressionParser()->parseExpression();
|
||||
$file = $this->parser->parseExpression();
|
||||
}
|
||||
|
||||
$group = null;
|
||||
if ($stream->nextIf(Token::NAME_TYPE, 'at') || $stream->nextIf(Token::OPERATOR_TYPE, 'in')) {
|
||||
$group = $this->parser->getExpressionParser()->parseExpression();
|
||||
$group = $this->parser->parseExpression();
|
||||
}
|
||||
|
||||
$priority = null;
|
||||
if ($stream->nextIf(Token::NAME_TYPE, 'priority')) {
|
||||
$stream->expect(Token::PUNCTUATION_TYPE, ':');
|
||||
$priority = $this->parser->getExpressionParser()->parseExpression();
|
||||
$priority = $this->parser->parseExpression();
|
||||
}
|
||||
|
||||
$attributes = null;
|
||||
if ($stream->nextIf(Token::NAME_TYPE, 'with')) {
|
||||
$attributes = $this->parser->getExpressionParser()->parseExpression();
|
||||
$attributes = $this->parser->parseExpression();
|
||||
}
|
||||
|
||||
$stream->expect(Token::BLOCK_END_TYPE);
|
||||
|
||||
@@ -13,6 +13,7 @@ namespace Grav\Common\Twig\TokenParser;
|
||||
use Grav\Common\Twig\Node\TwigNodeSwitch;
|
||||
use Twig\Error\SyntaxError;
|
||||
use Twig\Node\Node;
|
||||
use Twig\Node\Nodes;
|
||||
use Twig\Token;
|
||||
use Twig\TokenParser\AbstractTokenParser;
|
||||
|
||||
@@ -40,21 +41,22 @@ class TwigTokenParserSwitch extends AbstractTokenParser
|
||||
$lineno = $token->getLine();
|
||||
$stream = $this->parser->getStream();
|
||||
|
||||
$name = $this->parser->getExpressionParser()->parseExpression();
|
||||
$name = $this->parser->parseExpression();
|
||||
$stream->expect(Token::BLOCK_END_TYPE);
|
||||
|
||||
// There can be some whitespace between the {% switch %} and first {% case %} tag.
|
||||
while ($stream->getCurrent()->getType() === Token::TEXT_TYPE && trim((string) $stream->getCurrent()->getValue()) === '') {
|
||||
while ($stream->getCurrent()->test(Token::TEXT_TYPE) && trim((string) $stream->getCurrent()->getValue()) === '') {
|
||||
$stream->next();
|
||||
}
|
||||
|
||||
$stream->expect(Token::BLOCK_START_TYPE);
|
||||
|
||||
$expressionParser = $this->parser->getExpressionParser();
|
||||
|
||||
$default = null;
|
||||
$cases = [];
|
||||
$end = false;
|
||||
|
||||
// 'or' operator precedence is 10. We want to stop parsing if we encounter it.
|
||||
$orPrecedence = 10;
|
||||
|
||||
while (!$end) {
|
||||
$next = $stream->next();
|
||||
@@ -64,7 +66,7 @@ class TwigTokenParserSwitch extends AbstractTokenParser
|
||||
$values = [];
|
||||
|
||||
while (true) {
|
||||
$values[] = $expressionParser->parsePrimaryExpression();
|
||||
$values[] = $this->parser->parseExpression($orPrecedence + 1);
|
||||
// Multiple allowed values?
|
||||
if ($stream->test(Token::OPERATOR_TYPE, 'or')) {
|
||||
$stream->next();
|
||||
@@ -75,10 +77,10 @@ class TwigTokenParserSwitch extends AbstractTokenParser
|
||||
|
||||
$stream->expect(Token::BLOCK_END_TYPE);
|
||||
$body = $this->parser->subparse($this->decideIfFork(...));
|
||||
$cases[] = new Node([
|
||||
'values' => new Node($values),
|
||||
$cases[] = new class([
|
||||
'values' => new Nodes($values),
|
||||
'body' => $body
|
||||
]);
|
||||
]) extends Node {};
|
||||
break;
|
||||
|
||||
case 'default':
|
||||
@@ -97,7 +99,7 @@ class TwigTokenParserSwitch extends AbstractTokenParser
|
||||
|
||||
$stream->expect(Token::BLOCK_END_TYPE);
|
||||
|
||||
return new TwigNodeSwitch($name, new Node($cases), $default, $lineno, $this->getTag());
|
||||
return new TwigNodeSwitch($name, new Nodes($cases), $default, $lineno, $this->getTag());
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -37,7 +37,7 @@ class TwigTokenParserThrow extends AbstractTokenParser
|
||||
$stream = $this->parser->getStream();
|
||||
|
||||
$code = $stream->expect(Token::NUMBER_TYPE)->getValue();
|
||||
$message = $this->parser->getExpressionParser()->parseExpression();
|
||||
$message = $this->parser->parseExpression();
|
||||
$stream->expect(Token::BLOCK_END_TYPE);
|
||||
|
||||
return new TwigNodeThrow((int)$code, $message, $lineno, $this->getTag());
|
||||
|
||||
@@ -11,8 +11,11 @@ namespace Grav\Common\Twig;
|
||||
|
||||
use Twig\Environment;
|
||||
use Twig\Error\LoaderError;
|
||||
use Twig\Extension\EscaperExtension;
|
||||
use Twig\Extension\ExtensionInterface;
|
||||
use Twig\Loader\ExistsLoaderInterface;
|
||||
use Twig\Loader\LoaderInterface;
|
||||
use Twig\Runtime\EscaperRuntime;
|
||||
use Twig\Template;
|
||||
use Twig\TemplateWrapper;
|
||||
|
||||
@@ -22,6 +25,39 @@ use Twig\TemplateWrapper;
|
||||
*/
|
||||
class TwigEnvironment extends Environment
|
||||
{
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function getExtension(string $name): ExtensionInterface
|
||||
{
|
||||
$extension = parent::getExtension($name);
|
||||
|
||||
if ($name === EscaperExtension::class && class_exists(EscaperRuntime::class)) {
|
||||
return new class($extension, $this) extends EscaperExtension {
|
||||
private $original;
|
||||
private $env;
|
||||
|
||||
public function __construct($original, $env)
|
||||
{
|
||||
$this->original = $original;
|
||||
$this->env = $env;
|
||||
}
|
||||
|
||||
public function setEscaper($strategy, $callable)
|
||||
{
|
||||
$this->env->getRuntime(EscaperRuntime::class)->setEscaper($strategy, $callable);
|
||||
}
|
||||
|
||||
public function getDefaultStrategy($filename)
|
||||
{
|
||||
return $this->original->getDefaultStrategy($filename);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return $extension;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*
|
||||
|
||||
1281
system/src/Grav/Common/Upgrade/SafeUpgradeService.php
Normal file
1281
system/src/Grav/Common/Upgrade/SafeUpgradeService.php
Normal file
File diff suppressed because it is too large
Load Diff
@@ -128,8 +128,20 @@ class User extends Data implements UserInterface
|
||||
if ($file) {
|
||||
$username = $this->filterUsername((string)$this->get('username'));
|
||||
|
||||
// Validate username to prevent path traversal attacks
|
||||
if (!self::isValidUsername($username)) {
|
||||
throw new \RuntimeException('Invalid username: contains invalid characters or sequences');
|
||||
}
|
||||
|
||||
if (!$file->filename()) {
|
||||
$locator = Grav::instance()['locator'];
|
||||
|
||||
// Check if a user with this username already exists (prevent overwriting)
|
||||
$existingFile = $locator->findResource('account://' . $username . YAML_EXT);
|
||||
if ($existingFile) {
|
||||
throw new \RuntimeException('User account with this username already exists');
|
||||
}
|
||||
|
||||
$file->filename($locator->findResource('account://' . $username . YAML_EXT, true, true));
|
||||
}
|
||||
|
||||
@@ -304,6 +316,22 @@ class User extends Data implements UserInterface
|
||||
return parent::count();
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
* Override to filter out sensitive fields like password hashes
|
||||
*/
|
||||
public function jsonSerialize(): array
|
||||
{
|
||||
$items = parent::jsonSerialize();
|
||||
|
||||
// Security: Remove sensitive fields that should never be exposed to frontend
|
||||
unset($items['hashed_password']);
|
||||
unset($items['secret']); // 2FA secret
|
||||
unset($items['twofa_secret']); // Alternative 2FA field name
|
||||
|
||||
return $items;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $username
|
||||
* @return string
|
||||
@@ -313,6 +341,37 @@ class User extends Data implements UserInterface
|
||||
return mb_strtolower($username);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a username to prevent path traversal and other attacks.
|
||||
*
|
||||
* @param string $username
|
||||
* @return bool
|
||||
*/
|
||||
public static function isValidUsername(string $username): bool
|
||||
{
|
||||
// Username must not be empty
|
||||
if (!$username) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Username must not contain filesystem-dangerous characters: \ / ? * : ; { } or newlines
|
||||
if (!preg_match('/^[^\\/?*:;{}\\\\\\n]+$/u', $username)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Username must not contain path traversal sequences (..)
|
||||
if (str_contains($username, '..')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Username must not start with a dot (hidden files)
|
||||
if (str_starts_with($username, '.')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string|null
|
||||
*/
|
||||
|
||||
@@ -691,6 +691,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('=', (string) $_SERVER['HTTP_RANGE'], 2);
|
||||
@@ -703,7 +714,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 {
|
||||
@@ -712,19 +723,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((string) $_SERVER['HTTP_IF_MODIFIED_SINCE']) >= filemtime($file)) {
|
||||
header('HTTP/1.1 304 Not Modified');
|
||||
http_response_code(304);
|
||||
exit();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -122,7 +122,7 @@ class Application extends \Symfony\Component\Console\Application
|
||||
* @param OutputInterface $output
|
||||
* @return void
|
||||
*/
|
||||
protected function configureIO(InputInterface $input, OutputInterface $output)
|
||||
protected function configureIO(InputInterface $input, OutputInterface $output): void
|
||||
{
|
||||
$formatter = $output->getFormatter();
|
||||
$formatter->setStyle('normal', new OutputFormatterStyle('white'));
|
||||
|
||||
@@ -13,6 +13,8 @@ use Grav\Console\Gpm\DirectInstallCommand;
|
||||
use Grav\Console\Gpm\IndexCommand;
|
||||
use Grav\Console\Gpm\InfoCommand;
|
||||
use Grav\Console\Gpm\InstallCommand;
|
||||
use Grav\Console\Gpm\PreflightCommand;
|
||||
use Grav\Console\Gpm\RollbackCommand;
|
||||
use Grav\Console\Gpm\SelfupgradeCommand;
|
||||
use Grav\Console\Gpm\UninstallCommand;
|
||||
use Grav\Console\Gpm\UpdateCommand;
|
||||
@@ -36,6 +38,8 @@ class GpmApplication extends Application
|
||||
new UninstallCommand(),
|
||||
new UpdateCommand(),
|
||||
new SelfupgradeCommand(),
|
||||
new PreflightCommand(),
|
||||
new RollbackCommand(),
|
||||
new DirectInstallCommand(),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ use Grav\Console\Cli\NewProjectCommand;
|
||||
use Grav\Console\Cli\PageSystemValidatorCommand;
|
||||
use Grav\Console\Cli\SandboxCommand;
|
||||
use Grav\Console\Cli\SchedulerCommand;
|
||||
use Grav\Console\Cli\SafeUpgradeRunCommand;
|
||||
use Grav\Console\Cli\SecurityCommand;
|
||||
use Grav\Console\Cli\ServerCommand;
|
||||
use Grav\Console\Cli\YamlLinterCommand;
|
||||
@@ -47,6 +48,7 @@ class GravApplication extends Application
|
||||
new YamlLinterCommand(),
|
||||
new ServerCommand(),
|
||||
new PageSystemValidatorCommand(),
|
||||
new SafeUpgradeRunCommand(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
96
system/src/Grav/Console/Cli/SafeUpgradeRunCommand.php
Normal file
96
system/src/Grav/Console/Cli/SafeUpgradeRunCommand.php
Normal file
@@ -0,0 +1,96 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @package Grav\Console\Cli
|
||||
*
|
||||
* Background worker for Safe Upgrade jobs.
|
||||
*/
|
||||
|
||||
namespace Grav\Console\Cli;
|
||||
|
||||
use Grav\Console\GravCommand;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
use Throwable;
|
||||
|
||||
class SafeUpgradeRunCommand extends GravCommand
|
||||
{
|
||||
protected function configure(): void
|
||||
{
|
||||
$this
|
||||
->setName('safe-upgrade:run')
|
||||
->setDescription('Execute a queued Grav safe-upgrade job')
|
||||
->addOption(
|
||||
'job',
|
||||
null,
|
||||
InputOption::VALUE_REQUIRED,
|
||||
'Job identifier to execute'
|
||||
);
|
||||
}
|
||||
|
||||
protected function serve(): int
|
||||
{
|
||||
$input = $this->getInput();
|
||||
/** @var SymfonyStyle $io */
|
||||
$io = $this->getIO();
|
||||
|
||||
$jobId = $input->getOption('job');
|
||||
if (!$jobId) {
|
||||
$io->error('Missing required --job option.');
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (method_exists($this, 'initializePlugins')) {
|
||||
$this->initializePlugins();
|
||||
}
|
||||
|
||||
if (!class_exists(\Grav\Plugin\Admin\SafeUpgradeManager::class)) {
|
||||
$path = GRAV_ROOT . '/user/plugins/admin/classes/plugin/SafeUpgradeManager.php';
|
||||
if (is_file($path)) {
|
||||
require_once $path;
|
||||
}
|
||||
}
|
||||
|
||||
if (!class_exists(\Grav\Plugin\Admin\SafeUpgradeManager::class)) {
|
||||
$io->error('SafeUpgradeManager is not available. Ensure the Admin plugin is installed.');
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
$manager = new \Grav\Plugin\Admin\SafeUpgradeManager();
|
||||
$manifest = $manager->loadJob($jobId);
|
||||
|
||||
if (!$manifest) {
|
||||
$io->error(sprintf('Safe upgrade job "%s" could not be found.', $jobId));
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
$options = $manifest['options'] ?? [];
|
||||
$manager->updateJob([
|
||||
'status' => 'running',
|
||||
'started_at' => $manifest['started_at'] ?? time(),
|
||||
]);
|
||||
|
||||
try {
|
||||
$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;
|
||||
} catch (Throwable $e) {
|
||||
$manager->ensureJobResult([
|
||||
'status' => 'error',
|
||||
'message' => $e->getMessage(),
|
||||
]);
|
||||
$io->error($e->getMessage());
|
||||
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -215,9 +215,8 @@ class SchedulerCommand extends GravCommand
|
||||
$job_state = $job_states[$job->getId()];
|
||||
$error = isset($job_state['error']) ? trim((string) $job_state['error']) : false;
|
||||
|
||||
/** @var CronExpression $expression */
|
||||
/** @var CronExpression|null $expression */
|
||||
$expression = $job->getCronExpression();
|
||||
$next_run = $expression->getNextRunDate();
|
||||
|
||||
$row = [];
|
||||
$row[] = $job->getId();
|
||||
@@ -226,7 +225,13 @@ class SchedulerCommand extends GravCommand
|
||||
} else {
|
||||
$row[] = '<yellow>Never</yellow>';
|
||||
}
|
||||
$row[] = '<yellow>' . $next_run->format('Y-m-d H:i') . '</yellow>';
|
||||
|
||||
if ($expression) {
|
||||
$next_run = $expression->getNextRunDate();
|
||||
$row[] = '<yellow>' . $next_run->format('Y-m-d H:i') . '</yellow>';
|
||||
} else {
|
||||
$row[] = '<error>Invalid cron</error>';
|
||||
}
|
||||
|
||||
if ($error) {
|
||||
$row[] = "<error>{$error}</error>";
|
||||
|
||||
@@ -26,11 +26,13 @@ class ConsoleCommand 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);
|
||||
|
||||
return $this->serve();
|
||||
$result = $this->serve();
|
||||
|
||||
return is_int($result) ? $result : self::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
|
||||
namespace Grav\Console;
|
||||
|
||||
use Closure;
|
||||
use Exception;
|
||||
use Grav\Common\Cache;
|
||||
use Grav\Common\Grav;
|
||||
@@ -83,13 +84,14 @@ trait ConsoleTrait
|
||||
* @param int|null $mode The option mode: One of the InputOption::VALUE_* constants
|
||||
* @param string $description A description text
|
||||
* @param string|string[]|int|bool|null $default The default value (must be null for InputOption::VALUE_NONE)
|
||||
* @param \Closure|array $suggestedValues Suggested values or completion callback
|
||||
* @return $this
|
||||
* @throws InvalidArgumentException If option mode is invalid or incompatible
|
||||
*/
|
||||
public function addOption(string $name, array|string|null $shortcut = null, ?int $mode = null, string $description = '', mixed $default = null): static
|
||||
public function addOption(string $name, array|string|null $shortcut = null, ?int $mode = null, string $description = '', mixed $default = null, Closure|array $suggestedValues = []): static
|
||||
{
|
||||
if ($name !== 'env' && $name !== 'lang') {
|
||||
parent::addOption($name, $shortcut, $mode, $description, $default);
|
||||
parent::addOption($name, $shortcut, $mode, $description, $default, $suggestedValues);
|
||||
}
|
||||
|
||||
return $this;
|
||||
|
||||
101
system/src/Grav/Console/Gpm/PreflightCommand.php
Normal file
101
system/src/Grav/Console/Gpm/PreflightCommand.php
Normal file
@@ -0,0 +1,101 @@
|
||||
<?php
|
||||
|
||||
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 json_encode;
|
||||
use const JSON_PRETTY_PRINT;
|
||||
|
||||
class PreflightCommand extends GpmCommand
|
||||
{
|
||||
protected function configure(): void
|
||||
{
|
||||
$this
|
||||
->setName('preflight')
|
||||
->addOption('json', null, InputOption::VALUE_NONE, 'Output report as JSON')
|
||||
->setDescription('Run Grav upgrade preflight checks without modifying the installation.');
|
||||
}
|
||||
|
||||
protected function serve(): int
|
||||
{
|
||||
$io = $this->getIO();
|
||||
$service = $this->createSafeUpgradeService();
|
||||
$report = $service->preflight();
|
||||
|
||||
$hasIssues = !empty($report['plugins_pending']) || !empty($report['psr_log_conflicts']) || !empty($report['monolog_conflicts']) || !empty($report['warnings']);
|
||||
|
||||
if ($this->getInput()->getOption('json')) {
|
||||
$io->writeln(json_encode($report, JSON_PRETTY_PRINT));
|
||||
|
||||
return $hasIssues ? 2 : 0;
|
||||
}
|
||||
|
||||
$io->title('Grav Upgrade Preflight');
|
||||
|
||||
if (!empty($report['warnings'])) {
|
||||
$io->writeln('<comment>Warnings</comment>');
|
||||
foreach ($report['warnings'] as $warning) {
|
||||
$io->writeln(' - ' . $warning);
|
||||
}
|
||||
$io->newLine();
|
||||
}
|
||||
|
||||
if (!empty($report['plugins_pending'])) {
|
||||
$io->writeln('<comment>Packages pending update</comment>');
|
||||
foreach ($report['plugins_pending'] as $slug => $info) {
|
||||
$io->writeln(sprintf(' - %s (%s) %s → %s', $slug, $info['type'] ?? 'plugin', $info['current'] ?? 'unknown', $info['available'] ?? 'unknown'));
|
||||
}
|
||||
$io->newLine();
|
||||
}
|
||||
|
||||
if (!empty($report['psr_log_conflicts'])) {
|
||||
$io->writeln('<comment>Potential psr/log conflicts</comment>');
|
||||
foreach ($report['psr_log_conflicts'] as $slug => $info) {
|
||||
$io->writeln(sprintf(' - %s (requires psr/log %s)', $slug, $info['requires'] ?? '*'));
|
||||
}
|
||||
$io->writeln(' › Update the plugin or add "replace": {"psr/log": "*"} to its composer.json and reinstall dependencies.');
|
||||
$io->newLine();
|
||||
}
|
||||
|
||||
if (!empty($report['monolog_conflicts'])) {
|
||||
$io->writeln('<comment>Potential Monolog logger conflicts</comment>');
|
||||
foreach ($report['monolog_conflicts'] as $slug => $entries) {
|
||||
foreach ($entries as $entry) {
|
||||
$file = $entry['file'] ?? 'unknown file';
|
||||
$method = $entry['method'] ?? 'add*';
|
||||
$io->writeln(sprintf(' - %s (%s in %s)', $slug, $method, $file));
|
||||
}
|
||||
}
|
||||
$io->writeln(' › Update the plugin to use PSR-3 style logger calls (e.g. $logger->error()).');
|
||||
$io->newLine();
|
||||
}
|
||||
|
||||
if (!$hasIssues) {
|
||||
$io->success('No blocking issues detected.');
|
||||
} else {
|
||||
$io->warning('Resolve the findings above before upgrading Grav.');
|
||||
}
|
||||
|
||||
return $hasIssues ? 2 : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return SafeUpgradeService
|
||||
*/
|
||||
protected function createSafeUpgradeService(): SafeUpgradeService
|
||||
{
|
||||
$config = null;
|
||||
try {
|
||||
$config = Grav::instance()['config'] ?? null;
|
||||
} catch (\Throwable $e) {
|
||||
$config = null;
|
||||
}
|
||||
|
||||
return new SafeUpgradeService([
|
||||
'config' => $config,
|
||||
]);
|
||||
}
|
||||
}
|
||||
135
system/src/Grav/Console/Gpm/RollbackCommand.php
Normal file
135
system/src/Grav/Console/Gpm/RollbackCommand.php
Normal file
@@ -0,0 +1,135 @@
|
||||
<?php
|
||||
|
||||
namespace Grav\Console\Gpm;
|
||||
|
||||
use Grav\Common\Upgrade\SafeUpgradeService;
|
||||
use Grav\Console\GpmCommand;
|
||||
use RuntimeException;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Question\ConfirmationQuestion;
|
||||
use function basename;
|
||||
use function file_get_contents;
|
||||
use function glob;
|
||||
use function is_array;
|
||||
use function json_decode;
|
||||
use function pathinfo;
|
||||
use const PATHINFO_FILENAME;
|
||||
use const GRAV_ROOT;
|
||||
|
||||
class RollbackCommand extends GpmCommand
|
||||
{
|
||||
/** @var bool */
|
||||
private $allYes = false;
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this
|
||||
->setName('rollback')
|
||||
->addArgument('manifest', InputArgument::OPTIONAL, 'Manifest identifier to roll back to. Defaults to the latest snapshot.')
|
||||
->addOption('list', 'l', InputOption::VALUE_NONE, 'List available snapshots')
|
||||
->addOption('all-yes', 'y', InputOption::VALUE_NONE, 'Skip confirmation prompts')
|
||||
->setDescription('Rollback Grav to a previously staged snapshot.');
|
||||
}
|
||||
|
||||
protected function serve(): int
|
||||
{
|
||||
$input = $this->getInput();
|
||||
$io = $this->getIO();
|
||||
$this->allYes = (bool)$input->getOption('all-yes');
|
||||
|
||||
$snapshots = $this->collectSnapshots();
|
||||
if ($input->getOption('list')) {
|
||||
if (!$snapshots) {
|
||||
$io->writeln('No snapshots found.');
|
||||
return 0;
|
||||
}
|
||||
|
||||
$io->writeln('<info>Available snapshots:</info>');
|
||||
foreach ($snapshots as $snapshot) {
|
||||
$io->writeln(sprintf(' - %s (Grav %s)', $snapshot['id'], $snapshot['target_version'] ?? 'unknown'));
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (!$snapshots) {
|
||||
$io->error('No snapshots available to roll back to.');
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
$targetId = $input->getArgument('manifest') ?: $snapshots[0]['id'];
|
||||
$target = null;
|
||||
foreach ($snapshots as $snapshot) {
|
||||
if ($snapshot['id'] === $targetId) {
|
||||
$target = $snapshot;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!$target) {
|
||||
$io->error(sprintf('Snapshot %s not found.', $targetId));
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (!$this->allYes) {
|
||||
$question = new ConfirmationQuestion(sprintf('Rollback to snapshot %s (Grav %s)? [y|N] ', $target['id'], $target['target_version'] ?? 'unknown'), false);
|
||||
if (!$io->askQuestion($question)) {
|
||||
$io->writeln('Rollback aborted.');
|
||||
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
$service = $this->createSafeUpgradeService();
|
||||
|
||||
try {
|
||||
$service->rollback($target['id']);
|
||||
$service->clearRecoveryFlag();
|
||||
} catch (RuntimeException $e) {
|
||||
$io->error($e->getMessage());
|
||||
return 1;
|
||||
}
|
||||
|
||||
$io->success(sprintf('Rolled back to snapshot %s.', $target['id']));
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array>
|
||||
*/
|
||||
protected function collectSnapshots(): array
|
||||
{
|
||||
$manifestDir = GRAV_ROOT . '/user/data/upgrades';
|
||||
$files = glob($manifestDir . '/*.json');
|
||||
if (!$files) {
|
||||
return [];
|
||||
}
|
||||
|
||||
rsort($files);
|
||||
$snapshots = [];
|
||||
foreach ($files as $file) {
|
||||
$decoded = json_decode(file_get_contents($file), true);
|
||||
if (!is_array($decoded)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$decoded['id'] = $decoded['id'] ?? pathinfo($file, PATHINFO_FILENAME);
|
||||
$decoded['file'] = basename($file);
|
||||
$snapshots[] = $decoded;
|
||||
}
|
||||
|
||||
return $snapshots;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return SafeUpgradeService
|
||||
*/
|
||||
protected function createSafeUpgradeService(): SafeUpgradeService
|
||||
{
|
||||
return new SafeUpgradeService();
|
||||
}
|
||||
}
|
||||
@@ -16,14 +16,20 @@ use Grav\Common\GPM\Installer;
|
||||
use Grav\Common\GPM\Upgrader;
|
||||
use Grav\Common\Grav;
|
||||
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;
|
||||
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
|
||||
@@ -41,6 +47,16 @@ class SelfupgradeCommand extends GpmCommand
|
||||
private $tmp;
|
||||
/** @var Upgrader */
|
||||
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;
|
||||
@@ -82,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');
|
||||
}
|
||||
@@ -93,145 +121,240 @@ 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();
|
||||
|
||||
$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 fn($match) => "\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);
|
||||
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');
|
||||
|
||||
$io->write(" |- Downloading upgrade [{$this->formatBytes($update['size'])}]... 0%");
|
||||
$this->file = $this->download($update);
|
||||
$this->displayGPMRelease();
|
||||
|
||||
$io->write(' |- Installing upgrade... ');
|
||||
$installation = $this->upgrade();
|
||||
// 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.
|
||||
|
||||
$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();
|
||||
}
|
||||
$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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -261,14 +384,200 @@ class SelfupgradeCommand extends GpmCommand
|
||||
return $this->tmp . DS . $package['name'];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array $preflight
|
||||
* @return bool
|
||||
*/
|
||||
protected function handlePreflightReport(array $preflight): bool
|
||||
{
|
||||
$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);
|
||||
|
||||
$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();
|
||||
$io->writeln('<magenta>Preflight warnings detected:</magenta>');
|
||||
foreach ($warnings as $warning) {
|
||||
$io->writeln(' • ' . $warning);
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
$type = $info['type'] ?? 'plugin';
|
||||
$current = $info['current'] ?? 'unknown';
|
||||
$available = $info['available'] ?? 'unknown';
|
||||
$io->writeln(sprintf(' - %s (%s) %s → %s', $slug, $type, $current, $available));
|
||||
}
|
||||
|
||||
$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.');
|
||||
|
||||
$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(
|
||||
$conflicts,
|
||||
static function (SymfonyStyle $io, array $conflicts): void {
|
||||
$io->newLine();
|
||||
$io->writeln('<yellow>Potential psr/log incompatibilities:</yellow>');
|
||||
foreach ($conflicts as $slug => $info) {
|
||||
$requires = $info['requires'] ?? '*';
|
||||
$io->writeln(sprintf(' - %s (requires psr/log %s)', $slug, $requires));
|
||||
}
|
||||
},
|
||||
'Update the plugin or add "replace": {"psr/log": "*"} to its composer.json and reinstall dependencies.',
|
||||
'Aborting self-upgrade. Adjust composer requirements or update affected plugins.',
|
||||
'Proceeding with potential psr/log incompatibilities still active.',
|
||||
'Disabled before upgrade because of psr/log conflict'
|
||||
);
|
||||
|
||||
if (!$handled) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$handledMonolog = $this->handleConflicts(
|
||||
$monologConflicts,
|
||||
static function (SymfonyStyle $io, array $conflicts): void {
|
||||
$io->newLine();
|
||||
$io->writeln('<yellow>Potential Monolog logger API incompatibilities:</yellow>');
|
||||
foreach ($conflicts as $slug => $entries) {
|
||||
foreach ($entries as $entry) {
|
||||
$file = $entry['file'] ?? 'unknown file';
|
||||
$method = $entry['method'] ?? 'add*';
|
||||
$io->writeln(sprintf(' - %s (%s in %s)', $slug, $method, $file));
|
||||
}
|
||||
}
|
||||
},
|
||||
'Update the plugin to use PSR-3 style logger methods (e.g. $logger->error()) before upgrading.',
|
||||
'Aborting self-upgrade. Update plugins to remove deprecated Monolog add* calls.',
|
||||
'Proceeding with potential Monolog API incompatibilities still active.',
|
||||
'Disabled before upgrade because of Monolog API conflict'
|
||||
);
|
||||
|
||||
if (!$handledMonolog) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array $conflicts
|
||||
* @param callable $printer
|
||||
* @param string $advice
|
||||
* @param string $abortMessage
|
||||
* @param string $continueMessage
|
||||
* @param string $disableNote
|
||||
* @return bool
|
||||
*/
|
||||
private function handleConflicts(array $conflicts, callable $printer, string $advice, string $abortMessage, string $continueMessage, string $disableNote): bool
|
||||
{
|
||||
if (empty($conflicts)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$io = $this->getIO();
|
||||
$printer($io, $conflicts);
|
||||
$io->writeln(' › ' . $advice);
|
||||
|
||||
$choice = $this->all_yes ? 'abort' : $io->choice(
|
||||
'How would you like to proceed?',
|
||||
['disable', 'continue', 'abort'],
|
||||
'abort'
|
||||
);
|
||||
|
||||
if ($choice === 'abort') {
|
||||
$io->writeln($abortMessage);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/** @var \Grav\Common\Recovery\RecoveryManager $recovery */
|
||||
$recovery = Grav::instance()['recovery'];
|
||||
|
||||
if ($choice === 'disable') {
|
||||
foreach (array_keys($conflicts) as $slug) {
|
||||
$recovery->disablePlugin($slug, ['message' => $disableNote]);
|
||||
$io->writeln(sprintf(' - Disabled plugin %s.', $slug));
|
||||
}
|
||||
$io->writeln('Continuing with conflicted plugins disabled.');
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
$io->writeln($continueMessage);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return bool
|
||||
*/
|
||||
private function upgrade(): bool
|
||||
{
|
||||
$io = $this->getIO();
|
||||
$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) {
|
||||
@@ -280,10 +589,16 @@ 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> ');
|
||||
|
||||
$this->ensureExecutablePermissions();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -323,14 +638,32 @@ class SelfupgradeCommand extends GpmCommand
|
||||
*/
|
||||
private function upgradeGrav(string $zip): void
|
||||
{
|
||||
$io = $this->getIO();
|
||||
|
||||
try {
|
||||
$io->write("\x0D |- Extracting update... ");
|
||||
$folder = Installer::unZip($zip, $this->tmp . '/zip');
|
||||
if ($folder === false) {
|
||||
throw new RuntimeException(Installer::lastErrorMsg());
|
||||
}
|
||||
$io->write("\x0D");
|
||||
$io->writeln(' |- Extracting update... <green>ok</green> ');
|
||||
|
||||
$script = $folder . '/system/install.php';
|
||||
if ((file_exists($script) && $install = include $script) && is_callable($install)) {
|
||||
if (is_object($install) && method_exists($install, 'setProgressCallback')) {
|
||||
$install->setProgressCallback(function (string $stage, string $message, ?int $percent = null, array $extra = []) {
|
||||
$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');
|
||||
@@ -339,4 +672,110 @@ class SelfupgradeCommand extends GpmCommand
|
||||
Installer::setError($e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
$suffix = '';
|
||||
if (null !== $percent) {
|
||||
$suffix = sprintf(' (%d%%)', $percent);
|
||||
}
|
||||
$io->writeln(sprintf(' |- %s%s', $message, $suffix));
|
||||
}
|
||||
|
||||
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);
|
||||
$desired = ($mode & 0777) | 0111;
|
||||
if (($mode & 0111) !== 0111) {
|
||||
@chmod($path, $desired);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ namespace Grav\Console\Gpm;
|
||||
use Grav\Common\GPM\GPM;
|
||||
use Grav\Common\GPM\Installer;
|
||||
use Grav\Common\GPM\Upgrader;
|
||||
use Grav\Common\Grav;
|
||||
use Grav\Console\GpmCommand;
|
||||
use Symfony\Component\Console\Input\ArrayInput;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
@@ -116,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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -212,6 +236,10 @@ class UpdateCommand extends GpmCommand
|
||||
}
|
||||
}
|
||||
|
||||
/** @var \Grav\Common\Recovery\RecoveryManager $recovery */
|
||||
$recovery = Grav::instance()['recovery'];
|
||||
$recovery->markUpgradeWindow('package-update', ['scope' => 'core']);
|
||||
|
||||
// finally update
|
||||
$install_command = $this->getApplication()->find('install');
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -38,7 +38,9 @@ class GpmCommand extends Command
|
||||
// @phpstan-ignore-next-line
|
||||
$grav['accounts'];
|
||||
|
||||
return $this->serve();
|
||||
$result = $this->serve();
|
||||
|
||||
return is_int($result) ? $result : self::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -26,7 +26,7 @@ class GravCommand 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);
|
||||
|
||||
@@ -36,7 +36,9 @@ class GravCommand extends Command
|
||||
$this->initializeGrav();
|
||||
}
|
||||
|
||||
return $this->serve();
|
||||
$result = $this->serve();
|
||||
|
||||
return is_int($result) ? $result : self::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -21,6 +21,11 @@ class PluginListCommand extends ConsoleCommand
|
||||
{
|
||||
protected static $defaultName = 'plugins:list';
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct(self::$defaultName);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return void
|
||||
*/
|
||||
|
||||
@@ -46,7 +46,7 @@ trait CacheTrait
|
||||
* @return void
|
||||
* @throws InvalidArgumentException
|
||||
*/
|
||||
protected function init($namespace = '', $defaultLifetime = null)
|
||||
protected function init(string $namespace = '', DateInterval|int|null $defaultLifetime = null): void
|
||||
{
|
||||
$this->namespace = (string) $namespace;
|
||||
$this->defaultLifetime = $this->convertTtl($defaultLifetime);
|
||||
@@ -57,7 +57,7 @@ trait CacheTrait
|
||||
* @param bool $validation
|
||||
* @return void
|
||||
*/
|
||||
public function setValidation($validation)
|
||||
public function setValidation(bool $validation): void
|
||||
{
|
||||
$this->validation = (bool) $validation;
|
||||
}
|
||||
@@ -65,7 +65,7 @@ trait CacheTrait
|
||||
/**
|
||||
* @return string
|
||||
*/
|
||||
protected function getNamespace()
|
||||
protected function getNamespace(): string
|
||||
{
|
||||
return $this->namespace;
|
||||
}
|
||||
@@ -73,7 +73,7 @@ trait CacheTrait
|
||||
/**
|
||||
* @return int|null
|
||||
*/
|
||||
protected function getDefaultLifetime()
|
||||
protected function getDefaultLifetime(): ?int
|
||||
{
|
||||
return $this->defaultLifetime;
|
||||
}
|
||||
@@ -84,7 +84,7 @@ trait CacheTrait
|
||||
* @return mixed|null
|
||||
* @throws InvalidArgumentException
|
||||
*/
|
||||
public function get($key, $default = null)
|
||||
public function get(string $key, mixed $default = null): mixed
|
||||
{
|
||||
$this->validateKey($key);
|
||||
|
||||
@@ -99,7 +99,7 @@ trait CacheTrait
|
||||
* @return bool
|
||||
* @throws InvalidArgumentException
|
||||
*/
|
||||
public function set($key, mixed $value, $ttl = null)
|
||||
public function set(string $key, mixed $value, DateInterval|int|null $ttl = null): bool
|
||||
{
|
||||
$this->validateKey($key);
|
||||
|
||||
@@ -114,7 +114,7 @@ trait CacheTrait
|
||||
* @return bool
|
||||
* @throws InvalidArgumentException
|
||||
*/
|
||||
public function delete($key)
|
||||
public function delete(string $key): bool
|
||||
{
|
||||
$this->validateKey($key);
|
||||
|
||||
@@ -124,7 +124,7 @@ trait CacheTrait
|
||||
/**
|
||||
* @return bool
|
||||
*/
|
||||
public function clear()
|
||||
public function clear(): bool
|
||||
{
|
||||
return $this->doClear();
|
||||
}
|
||||
@@ -135,7 +135,7 @@ trait CacheTrait
|
||||
* @return iterable
|
||||
* @throws InvalidArgumentException
|
||||
*/
|
||||
public function getMultiple($keys, $default = null)
|
||||
public function getMultiple(iterable $keys, mixed $default = null): iterable
|
||||
{
|
||||
if ($keys instanceof Traversable) {
|
||||
$keys = iterator_to_array($keys, false);
|
||||
@@ -178,7 +178,7 @@ trait CacheTrait
|
||||
* @return bool
|
||||
* @throws InvalidArgumentException
|
||||
*/
|
||||
public function setMultiple($values, $ttl = null)
|
||||
public function setMultiple(iterable $values, DateInterval|int|null $ttl = null): bool
|
||||
{
|
||||
if ($values instanceof Traversable) {
|
||||
$values = iterator_to_array($values, true);
|
||||
@@ -211,7 +211,7 @@ trait CacheTrait
|
||||
* @return bool
|
||||
* @throws InvalidArgumentException
|
||||
*/
|
||||
public function deleteMultiple($keys)
|
||||
public function deleteMultiple(iterable $keys): bool
|
||||
{
|
||||
if ($keys instanceof Traversable) {
|
||||
$keys = iterator_to_array($keys, false);
|
||||
@@ -239,7 +239,7 @@ trait CacheTrait
|
||||
* @return bool
|
||||
* @throws InvalidArgumentException
|
||||
*/
|
||||
public function has($key)
|
||||
public function has(string $key): bool
|
||||
{
|
||||
$this->validateKey($key);
|
||||
|
||||
@@ -300,7 +300,7 @@ trait CacheTrait
|
||||
* @return void
|
||||
* @throws InvalidArgumentException
|
||||
*/
|
||||
protected function validateKey($key)
|
||||
protected function validateKey(mixed $key): void
|
||||
{
|
||||
if (!is_string($key)) {
|
||||
throw new InvalidArgumentException(
|
||||
@@ -330,7 +330,7 @@ trait CacheTrait
|
||||
* @return void
|
||||
* @throws InvalidArgumentException
|
||||
*/
|
||||
protected function validateKeys($keys)
|
||||
protected function validateKeys(iterable $keys): void
|
||||
{
|
||||
if (!$this->validation) {
|
||||
return;
|
||||
@@ -346,7 +346,7 @@ trait CacheTrait
|
||||
* @return int|null
|
||||
* @throws InvalidArgumentException
|
||||
*/
|
||||
protected function convertTtl($ttl)
|
||||
protected function convertTtl(DateInterval|int|null $ttl): ?int
|
||||
{
|
||||
if ($ttl === null) {
|
||||
return $this->getDefaultLifetime();
|
||||
@@ -359,6 +359,8 @@ trait CacheTrait
|
||||
if ($ttl instanceof DateInterval) {
|
||||
$date = DateTime::createFromFormat('U', '0');
|
||||
$ttl = $date ? (int)$date->add($ttl)->format('U') : 0;
|
||||
|
||||
return $ttl;
|
||||
}
|
||||
|
||||
throw new InvalidArgumentException(
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
namespace Grav\Framework\Collection;
|
||||
|
||||
use Doctrine\Common\Collections\AbstractLazyCollection as BaseAbstractLazyCollection;
|
||||
use Doctrine\Common\Collections\Collection as DoctrineCollection;
|
||||
|
||||
/**
|
||||
* General JSON serializable collection.
|
||||
@@ -26,7 +27,7 @@ abstract class AbstractLazyCollection extends BaseAbstractLazyCollection impleme
|
||||
* @par ArrayCollection
|
||||
* @phpstan-var ArrayCollection<TKey,T>
|
||||
*/
|
||||
protected $collection;
|
||||
protected ?DoctrineCollection $collection = null;
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
|
||||
187
system/src/Grav/Framework/Compat/Monolog/Utils.php
Normal file
187
system/src/Grav/Framework/Compat/Monolog/Utils.php
Normal file
@@ -0,0 +1,187 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Backport of Monolog\Utils providing DEFAULT_JSON_FLAGS for older Monolog versions.
|
||||
*
|
||||
* This is a trimmed copy of the Monolog 1.x Utils class with a compatible constant so
|
||||
* that Grav 1.7 can interoperate with code targeting Monolog 3.
|
||||
*/
|
||||
|
||||
namespace Grav\Framework\Compat\Monolog;
|
||||
|
||||
if (!class_exists(\Monolog\Utils::class, false)) {
|
||||
class Utils
|
||||
{
|
||||
public const DEFAULT_JSON_FLAGS = JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
public static function getClass($object)
|
||||
{
|
||||
$class = \get_class($object);
|
||||
|
||||
return 'c' === $class[0] && 0 === strpos($class, "class@anonymous\0") ? get_parent_class($class).'@anonymous' : $class;
|
||||
}
|
||||
|
||||
/**
|
||||
* Makes sure if a relative path is passed in it is turned into an absolute path
|
||||
*
|
||||
* @param string $streamUrl stream URL or path without protocol
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public static function canonicalizePath($streamUrl)
|
||||
{
|
||||
$prefix = '';
|
||||
if ('file://' === substr($streamUrl, 0, 7)) {
|
||||
$streamUrl = substr($streamUrl, 7);
|
||||
$prefix = 'file://';
|
||||
}
|
||||
|
||||
if (false !== strpos($streamUrl, '://')) {
|
||||
return $streamUrl;
|
||||
}
|
||||
|
||||
if (substr($streamUrl, 0, 1) === '/' || substr($streamUrl, 1, 1) === ':' || substr($streamUrl, 0, 2) === '\\\\') {
|
||||
return $prefix.$streamUrl;
|
||||
}
|
||||
|
||||
$streamUrl = getcwd() . '/' . $streamUrl;
|
||||
|
||||
return $prefix.$streamUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the JSON representation of a value
|
||||
*
|
||||
* @param mixed $data
|
||||
* @param int $encodeFlags
|
||||
* @param bool $ignoreErrors
|
||||
* @return string
|
||||
*/
|
||||
public static function jsonEncode($data, $encodeFlags = null, $ignoreErrors = false)
|
||||
{
|
||||
if (null === $encodeFlags) {
|
||||
$encodeFlags = self::DEFAULT_JSON_FLAGS;
|
||||
if (defined('JSON_PRESERVE_ZERO_FRACTION')) {
|
||||
$encodeFlags |= JSON_PRESERVE_ZERO_FRACTION;
|
||||
}
|
||||
if (defined('JSON_INVALID_UTF8_SUBSTITUTE')) {
|
||||
$encodeFlags |= JSON_INVALID_UTF8_SUBSTITUTE;
|
||||
}
|
||||
if (defined('JSON_PARTIAL_OUTPUT_ON_ERROR')) {
|
||||
$encodeFlags |= JSON_PARTIAL_OUTPUT_ON_ERROR;
|
||||
}
|
||||
}
|
||||
|
||||
if ($ignoreErrors) {
|
||||
$json = @json_encode($data, $encodeFlags);
|
||||
if (false === $json) {
|
||||
return 'null';
|
||||
}
|
||||
|
||||
return $json;
|
||||
}
|
||||
|
||||
$json = json_encode($data, $encodeFlags);
|
||||
if (false === $json) {
|
||||
$json = self::handleJsonError(json_last_error(), $data);
|
||||
}
|
||||
|
||||
return $json;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle a json_encode failure.
|
||||
*
|
||||
* @param int $code
|
||||
* @param mixed $data
|
||||
* @param int $encodeFlags
|
||||
* @return string
|
||||
*/
|
||||
public static function handleJsonError($code, $data, $encodeFlags = null)
|
||||
{
|
||||
if ($code !== JSON_ERROR_UTF8) {
|
||||
self::throwEncodeError($code, $data);
|
||||
}
|
||||
|
||||
if (is_string($data)) {
|
||||
self::detectAndCleanUtf8($data);
|
||||
} elseif (is_array($data)) {
|
||||
array_walk_recursive($data, [self::class, 'detectAndCleanUtf8']);
|
||||
} else {
|
||||
self::throwEncodeError($code, $data);
|
||||
}
|
||||
|
||||
if (null === $encodeFlags) {
|
||||
$encodeFlags = self::DEFAULT_JSON_FLAGS;
|
||||
if (defined('JSON_PRESERVE_ZERO_FRACTION')) {
|
||||
$encodeFlags |= JSON_PRESERVE_ZERO_FRACTION;
|
||||
}
|
||||
if (defined('JSON_INVALID_UTF8_SUBSTITUTE')) {
|
||||
$encodeFlags |= JSON_INVALID_UTF8_SUBSTITUTE;
|
||||
}
|
||||
if (defined('JSON_PARTIAL_OUTPUT_ON_ERROR')) {
|
||||
$encodeFlags |= JSON_PARTIAL_OUTPUT_ON_ERROR;
|
||||
}
|
||||
}
|
||||
|
||||
$json = json_encode($data, $encodeFlags);
|
||||
|
||||
if ($json === false) {
|
||||
self::throwEncodeError(json_last_error(), $data);
|
||||
}
|
||||
|
||||
return $json;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param int $code
|
||||
* @param mixed $data
|
||||
* @throws \RuntimeException
|
||||
*/
|
||||
private static function throwEncodeError($code, $data)
|
||||
{
|
||||
switch ($code) {
|
||||
case JSON_ERROR_DEPTH:
|
||||
$msg = 'Maximum stack depth exceeded';
|
||||
break;
|
||||
case JSON_ERROR_STATE_MISMATCH:
|
||||
$msg = 'Underflow or the modes mismatch';
|
||||
break;
|
||||
case JSON_ERROR_CTRL_CHAR:
|
||||
$msg = 'Unexpected control character found';
|
||||
break;
|
||||
case JSON_ERROR_UTF8:
|
||||
$msg = 'Malformed UTF-8 characters, possibly incorrectly encoded';
|
||||
break;
|
||||
default:
|
||||
$msg = 'Unknown error';
|
||||
}
|
||||
|
||||
throw new \RuntimeException('JSON encoding failed: '.$msg.'. Encoding: '.var_export($data, true));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param mixed $data
|
||||
*/
|
||||
public static function detectAndCleanUtf8(&$data)
|
||||
{
|
||||
if (is_string($data) && !preg_match('//u', $data)) {
|
||||
$data = preg_replace_callback(
|
||||
'/[\x80-\xFF]+/',
|
||||
static function ($m) { return utf8_encode($m[0]); },
|
||||
$data
|
||||
);
|
||||
$data = str_replace(
|
||||
['¤', '¦', '¨', '´', '¸', '¼', '½', '¾'],
|
||||
['€', 'Š', 'š', 'Ž', 'ž', 'Œ', 'œ', 'Ÿ'],
|
||||
$data
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class_alias(__NAMESPACE__ . '\Utils', \Monolog\Utils::class);
|
||||
}
|
||||
28
system/src/Grav/Framework/Compat/Monolog/bootstrap.php
Normal file
28
system/src/Grav/Framework/Compat/Monolog/bootstrap.php
Normal file
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @package Grav\Framework\Compat
|
||||
*
|
||||
* Provides lightweight shims for legacy Monolog installations used in Grav 1.7
|
||||
* so that newer Grav code (targeting Monolog 3) can run without fatal errors.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Grav\Framework\Compat\Monolog;
|
||||
|
||||
if (!class_exists(\Monolog\Utils::class, false)) {
|
||||
spl_autoload_register(
|
||||
static function (string $class): bool {
|
||||
if ($class === 'Monolog\\Utils') {
|
||||
require __DIR__ . '/Utils.php';
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
true,
|
||||
true
|
||||
);
|
||||
}
|
||||
@@ -479,6 +479,29 @@ class FlexDirectory implements FlexDirectoryInterface
|
||||
return $cache;
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode a storage key for use as a cache key.
|
||||
* Symfony cache reserves characters: {}()/\@:
|
||||
*
|
||||
* @param string $key
|
||||
* @return string
|
||||
*/
|
||||
protected function encodeCacheKey(string $key): string
|
||||
{
|
||||
return str_replace(['/', '\\', '@', ':'], ['__SLASH__', '__BSLASH__', '__AT__', '__COLON__'], $key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode a cache key back to the original storage key.
|
||||
*
|
||||
* @param string $key
|
||||
* @return string
|
||||
*/
|
||||
protected function decodeCacheKey(string $key): string
|
||||
{
|
||||
return str_replace(['__SLASH__', '__BSLASH__', '__AT__', '__COLON__'], ['/', '\\', '@', ':'], $key);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return $this
|
||||
*/
|
||||
@@ -720,7 +743,12 @@ class FlexDirectory implements FlexDirectoryInterface
|
||||
//$debugger->addMessage(sprintf('Flex: Caching %d %s', \count($entries), $this->type), 'debug');
|
||||
}
|
||||
try {
|
||||
$cache->setMultiple($updated);
|
||||
// Encode storage keys for cache compatibility (Symfony cache reserves certain characters)
|
||||
$encodedUpdated = [];
|
||||
foreach ($updated as $key => $value) {
|
||||
$encodedUpdated[$this->encodeCacheKey($key)] = $value;
|
||||
}
|
||||
$cache->setMultiple($encodedUpdated);
|
||||
} catch (InvalidArgumentException $e) {
|
||||
$debugger->addException($e);
|
||||
// TODO: log about the issue.
|
||||
@@ -752,7 +780,15 @@ class FlexDirectory implements FlexDirectoryInterface
|
||||
|
||||
$debugger->startTimer('flex-objects', sprintf('Flex: Loading %d %s', $loading, $this->type));
|
||||
|
||||
$fetched = (array)$cache->getMultiple($fetch);
|
||||
// Encode storage keys for cache compatibility (Symfony cache reserves certain characters)
|
||||
$encodedFetch = array_map([$this, 'encodeCacheKey'], $fetch);
|
||||
$encodedFetched = (array)$cache->getMultiple($encodedFetch);
|
||||
|
||||
// Decode the keys back to original storage keys
|
||||
foreach ($encodedFetched as $encodedKey => $value) {
|
||||
$fetched[$this->decodeCacheKey($encodedKey)] = $value;
|
||||
}
|
||||
|
||||
if ($fetched) {
|
||||
$index = $this->loadIndex('storage_key');
|
||||
|
||||
|
||||
@@ -220,6 +220,39 @@ abstract class AbstractFilesystemStorage implements FlexStorageInterface
|
||||
*/
|
||||
protected function validateKey(string $key): bool
|
||||
{
|
||||
return $key && (bool) preg_match('/^[^\\/?*:;{}\\\\\\n]+$/u', $key);
|
||||
// Key must not be empty
|
||||
if (!$key) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Key must not contain filesystem-dangerous characters: \ / ? * : ; { } or newlines
|
||||
if (!preg_match('/^[^\\/?*:;{}\\\\\\n]+$/u', $key)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Key must not contain path traversal sequences (..)
|
||||
if (str_contains($key, '..')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Key must not start with a dot (hidden files)
|
||||
if (str_starts_with($key, '.')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a key and throws an exception if invalid.
|
||||
*
|
||||
* @param string $key
|
||||
* @throws \InvalidArgumentException
|
||||
*/
|
||||
public function assertValidKey(string $key): void
|
||||
{
|
||||
if (!$this->validateKey($key)) {
|
||||
throw new \InvalidArgumentException(sprintf('Invalid storage key: "%s"', $key));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -419,6 +419,9 @@ class FolderStorage extends AbstractFilesystemStorage
|
||||
|
||||
$key = $this->normalizeKey($key);
|
||||
|
||||
// Validate the key to prevent path traversal and other attacks
|
||||
$this->assertValidKey($key);
|
||||
|
||||
// Check if the row already exists and if the key has been changed.
|
||||
$oldKey = $row['__META']['storage_key'] ?? null;
|
||||
if (is_string($oldKey) && $oldKey !== $key) {
|
||||
|
||||
@@ -19,7 +19,6 @@ use Psr\Http\Message\UploadedFileInterface;
|
||||
use RuntimeException;
|
||||
use function copy;
|
||||
use function fopen;
|
||||
use function is_string;
|
||||
use function sprintf;
|
||||
|
||||
/**
|
||||
@@ -59,7 +58,7 @@ class FormFlashFile implements UploadedFileInterface, JsonSerializable
|
||||
/**
|
||||
* @return StreamInterface
|
||||
*/
|
||||
public function getStream()
|
||||
public function getStream(): StreamInterface
|
||||
{
|
||||
$this->validateActive();
|
||||
|
||||
@@ -80,11 +79,11 @@ class FormFlashFile implements UploadedFileInterface, JsonSerializable
|
||||
* @param string $targetPath
|
||||
* @return void
|
||||
*/
|
||||
public function moveTo($targetPath)
|
||||
public function moveTo(string $targetPath): void
|
||||
{
|
||||
$this->validateActive();
|
||||
|
||||
if (!is_string($targetPath) || empty($targetPath)) {
|
||||
if ($targetPath === '') {
|
||||
throw new InvalidArgumentException('Invalid path provided for move operation; must be a non-empty string');
|
||||
}
|
||||
$tmpFile = $this->getTmpFile();
|
||||
@@ -118,33 +117,33 @@ class FormFlashFile implements UploadedFileInterface, JsonSerializable
|
||||
}
|
||||
|
||||
/**
|
||||
* @return int
|
||||
* @return int|null
|
||||
*/
|
||||
public function getSize()
|
||||
public function getSize(): ?int
|
||||
{
|
||||
return $this->upload['size'];
|
||||
return $this->upload['size'] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return int
|
||||
*/
|
||||
public function getError()
|
||||
public function getError(): int
|
||||
{
|
||||
return $this->upload['error'] ?? \UPLOAD_ERR_OK;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string
|
||||
* @return string|null
|
||||
*/
|
||||
public function getClientFilename()
|
||||
public function getClientFilename(): ?string
|
||||
{
|
||||
return $this->upload['name'] ?? 'unknown';
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string
|
||||
* @return string|null
|
||||
*/
|
||||
public function getClientMediaType()
|
||||
public function getClientMediaType(): ?string
|
||||
{
|
||||
return $this->upload['type'] ?? 'application/octet-stream';
|
||||
}
|
||||
|
||||
@@ -23,26 +23,26 @@ abstract class AbstractUri implements UriInterface
|
||||
{
|
||||
/** @var array */
|
||||
protected static $defaultPorts = [
|
||||
'http' => 80,
|
||||
'https' => 443
|
||||
"http" => 80,
|
||||
"https" => 443,
|
||||
];
|
||||
|
||||
/** @var string Uri scheme. */
|
||||
private $scheme = '';
|
||||
private $scheme = "";
|
||||
/** @var string Uri user. */
|
||||
private $user = '';
|
||||
private $user = "";
|
||||
/** @var string Uri password. */
|
||||
private $password = '';
|
||||
private $password = "";
|
||||
/** @var string Uri host. */
|
||||
private $host = '';
|
||||
private $host = "";
|
||||
/** @var int|null Uri port. */
|
||||
private $port;
|
||||
/** @var string Uri path. */
|
||||
private $path = '';
|
||||
private $path = "";
|
||||
/** @var string Uri query string (without ?). */
|
||||
private $query = '';
|
||||
private $query = "";
|
||||
/** @var string Uri fragment (without #). */
|
||||
private $fragment = '';
|
||||
private $fragment = "";
|
||||
|
||||
/**
|
||||
* Please define constructor which calls $this->init().
|
||||
@@ -52,7 +52,7 @@ abstract class AbstractUri implements UriInterface
|
||||
/**
|
||||
* @inheritdoc
|
||||
*/
|
||||
public function getScheme()
|
||||
public function getScheme(): string
|
||||
{
|
||||
return $this->scheme;
|
||||
}
|
||||
@@ -60,17 +60,17 @@ abstract class AbstractUri implements UriInterface
|
||||
/**
|
||||
* @inheritdoc
|
||||
*/
|
||||
public function getAuthority()
|
||||
public function getAuthority(): string
|
||||
{
|
||||
$authority = $this->host;
|
||||
|
||||
$userInfo = $this->getUserInfo();
|
||||
if ($userInfo !== '') {
|
||||
$authority = $userInfo . '@' . $authority;
|
||||
if ($userInfo !== "") {
|
||||
$authority = $userInfo . "@" . $authority;
|
||||
}
|
||||
|
||||
if ($this->port !== null) {
|
||||
$authority .= ':' . $this->port;
|
||||
$authority .= ":" . $this->port;
|
||||
}
|
||||
|
||||
return $authority;
|
||||
@@ -79,12 +79,12 @@ abstract class AbstractUri implements UriInterface
|
||||
/**
|
||||
* @inheritdoc
|
||||
*/
|
||||
public function getUserInfo()
|
||||
public function getUserInfo(): string
|
||||
{
|
||||
$userInfo = $this->user;
|
||||
|
||||
if ($this->password !== '') {
|
||||
$userInfo .= ':' . $this->password;
|
||||
if ($this->password !== "") {
|
||||
$userInfo .= ":" . $this->password;
|
||||
}
|
||||
|
||||
return $userInfo;
|
||||
@@ -93,7 +93,7 @@ abstract class AbstractUri implements UriInterface
|
||||
/**
|
||||
* @inheritdoc
|
||||
*/
|
||||
public function getHost()
|
||||
public function getHost(): string
|
||||
{
|
||||
return $this->host;
|
||||
}
|
||||
@@ -101,7 +101,7 @@ abstract class AbstractUri implements UriInterface
|
||||
/**
|
||||
* @inheritdoc
|
||||
*/
|
||||
public function getPort()
|
||||
public function getPort(): ?int
|
||||
{
|
||||
return $this->port;
|
||||
}
|
||||
@@ -109,7 +109,7 @@ abstract class AbstractUri implements UriInterface
|
||||
/**
|
||||
* @inheritdoc
|
||||
*/
|
||||
public function getPath()
|
||||
public function getPath(): string
|
||||
{
|
||||
return $this->path;
|
||||
}
|
||||
@@ -117,7 +117,7 @@ abstract class AbstractUri implements UriInterface
|
||||
/**
|
||||
* @inheritdoc
|
||||
*/
|
||||
public function getQuery()
|
||||
public function getQuery(): string
|
||||
{
|
||||
return $this->query;
|
||||
}
|
||||
@@ -125,7 +125,7 @@ abstract class AbstractUri implements UriInterface
|
||||
/**
|
||||
* @inheritdoc
|
||||
*/
|
||||
public function getFragment()
|
||||
public function getFragment(): string
|
||||
{
|
||||
return $this->fragment;
|
||||
}
|
||||
@@ -133,7 +133,7 @@ abstract class AbstractUri implements UriInterface
|
||||
/**
|
||||
* @inheritdoc
|
||||
*/
|
||||
public function withScheme($scheme)
|
||||
public function withScheme(string $scheme): UriInterface
|
||||
{
|
||||
$scheme = UriPartsFilter::filterScheme($scheme);
|
||||
|
||||
@@ -153,10 +153,10 @@ abstract class AbstractUri implements UriInterface
|
||||
* @inheritdoc
|
||||
* @throws InvalidArgumentException
|
||||
*/
|
||||
public function withUserInfo($user, $password = null)
|
||||
public function withUserInfo(string $user, ?string $password = null): UriInterface
|
||||
{
|
||||
$user = UriPartsFilter::filterUserInfo($user);
|
||||
$password = UriPartsFilter::filterUserInfo($password ?? '');
|
||||
$password = UriPartsFilter::filterUserInfo($password ?? "");
|
||||
|
||||
if ($this->user === $user && $this->password === $password) {
|
||||
return $this;
|
||||
@@ -164,7 +164,7 @@ abstract class AbstractUri implements UriInterface
|
||||
|
||||
$new = clone $this;
|
||||
$new->user = $user;
|
||||
$new->password = $user !== '' ? $password : '';
|
||||
$new->password = $user !== "" ? $password : "";
|
||||
$new->validate();
|
||||
|
||||
return $new;
|
||||
@@ -173,7 +173,7 @@ abstract class AbstractUri implements UriInterface
|
||||
/**
|
||||
* @inheritdoc
|
||||
*/
|
||||
public function withHost($host)
|
||||
public function withHost(string $host): UriInterface
|
||||
{
|
||||
$host = UriPartsFilter::filterHost($host);
|
||||
|
||||
@@ -191,7 +191,7 @@ abstract class AbstractUri implements UriInterface
|
||||
/**
|
||||
* @inheritdoc
|
||||
*/
|
||||
public function withPort($port)
|
||||
public function withPort(?int $port): UriInterface
|
||||
{
|
||||
$port = UriPartsFilter::filterPort($port);
|
||||
|
||||
@@ -210,7 +210,7 @@ abstract class AbstractUri implements UriInterface
|
||||
/**
|
||||
* @inheritdoc
|
||||
*/
|
||||
public function withPath($path)
|
||||
public function withPath(string $path): UriInterface
|
||||
{
|
||||
$path = UriPartsFilter::filterPath($path);
|
||||
|
||||
@@ -228,7 +228,7 @@ abstract class AbstractUri implements UriInterface
|
||||
/**
|
||||
* @inheritdoc
|
||||
*/
|
||||
public function withQuery($query)
|
||||
public function withQuery(string $query): UriInterface
|
||||
{
|
||||
$query = UriPartsFilter::filterQueryOrFragment($query);
|
||||
|
||||
@@ -246,7 +246,7 @@ abstract class AbstractUri implements UriInterface
|
||||
* @inheritdoc
|
||||
* @throws InvalidArgumentException
|
||||
*/
|
||||
public function withFragment($fragment)
|
||||
public function withFragment(string $fragment): UriInterface
|
||||
{
|
||||
$fragment = UriPartsFilter::filterQueryOrFragment($fragment);
|
||||
|
||||
@@ -275,14 +275,14 @@ abstract class AbstractUri implements UriInterface
|
||||
protected function getParts()
|
||||
{
|
||||
return [
|
||||
'scheme' => $this->scheme,
|
||||
'host' => $this->host,
|
||||
'port' => $this->port,
|
||||
'user' => $this->user,
|
||||
'pass' => $this->password,
|
||||
'path' => $this->path,
|
||||
'query' => $this->query,
|
||||
'fragment' => $this->fragment
|
||||
"scheme" => $this->scheme,
|
||||
"host" => $this->host,
|
||||
"port" => $this->port,
|
||||
"user" => $this->user,
|
||||
"pass" => $this->password,
|
||||
"path" => $this->path,
|
||||
"query" => $this->query,
|
||||
"fragment" => $this->fragment,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -295,16 +295,16 @@ abstract class AbstractUri implements UriInterface
|
||||
*/
|
||||
protected function getBaseUrl()
|
||||
{
|
||||
$uri = '';
|
||||
$uri = "";
|
||||
|
||||
$scheme = $this->getScheme();
|
||||
if ($scheme !== '') {
|
||||
$uri .= $scheme . ':';
|
||||
if ($scheme !== "") {
|
||||
$uri .= $scheme . ":";
|
||||
}
|
||||
|
||||
$authority = $this->getAuthority();
|
||||
if ($authority !== '' || $scheme === 'file') {
|
||||
$uri .= '//' . $authority;
|
||||
if ($authority !== "" || $scheme === "file") {
|
||||
$uri .= "//" . $authority;
|
||||
}
|
||||
|
||||
return $uri;
|
||||
@@ -318,13 +318,13 @@ abstract class AbstractUri implements UriInterface
|
||||
$uri = $this->getBaseUrl() . $this->getPath();
|
||||
|
||||
$query = $this->getQuery();
|
||||
if ($query !== '') {
|
||||
$uri .= '?' . $query;
|
||||
if ($query !== "") {
|
||||
$uri .= "?" . $query;
|
||||
}
|
||||
|
||||
$fragment = $this->getFragment();
|
||||
if ($fragment !== '') {
|
||||
$uri .= '#' . $fragment;
|
||||
if ($fragment !== "") {
|
||||
$uri .= "#" . $fragment;
|
||||
}
|
||||
|
||||
return $uri;
|
||||
@@ -353,14 +353,30 @@ abstract class AbstractUri implements UriInterface
|
||||
*/
|
||||
protected function initParts(array $parts)
|
||||
{
|
||||
$this->scheme = isset($parts['scheme']) ? UriPartsFilter::filterScheme($parts['scheme']) : '';
|
||||
$this->user = isset($parts['user']) ? UriPartsFilter::filterUserInfo($parts['user']) : '';
|
||||
$this->password = isset($parts['pass']) ? UriPartsFilter::filterUserInfo($parts['pass']) : '';
|
||||
$this->host = isset($parts['host']) ? UriPartsFilter::filterHost($parts['host']) : '';
|
||||
$this->port = isset($parts['port']) ? UriPartsFilter::filterPort((int)$parts['port']) : null;
|
||||
$this->path = isset($parts['path']) ? UriPartsFilter::filterPath($parts['path']) : '';
|
||||
$this->query = isset($parts['query']) ? UriPartsFilter::filterQueryOrFragment($parts['query']) : '';
|
||||
$this->fragment = isset($parts['fragment']) ? UriPartsFilter::filterQueryOrFragment($parts['fragment']) : '';
|
||||
$this->scheme = isset($parts["scheme"])
|
||||
? UriPartsFilter::filterScheme($parts["scheme"])
|
||||
: "";
|
||||
$this->user = isset($parts["user"])
|
||||
? UriPartsFilter::filterUserInfo($parts["user"])
|
||||
: "";
|
||||
$this->password = isset($parts["pass"])
|
||||
? UriPartsFilter::filterUserInfo($parts["pass"])
|
||||
: "";
|
||||
$this->host = isset($parts["host"])
|
||||
? UriPartsFilter::filterHost($parts["host"])
|
||||
: "";
|
||||
$this->port = isset($parts["port"])
|
||||
? UriPartsFilter::filterPort((int) $parts["port"])
|
||||
: null;
|
||||
$this->path = isset($parts["path"])
|
||||
? UriPartsFilter::filterPath($parts["path"])
|
||||
: "";
|
||||
$this->query = isset($parts["query"])
|
||||
? UriPartsFilter::filterQueryOrFragment($parts["query"])
|
||||
: "";
|
||||
$this->fragment = isset($parts["fragment"])
|
||||
? UriPartsFilter::filterQueryOrFragment($parts["fragment"])
|
||||
: "";
|
||||
|
||||
$this->unsetDefaultPort();
|
||||
$this->validate();
|
||||
@@ -372,19 +388,33 @@ abstract class AbstractUri implements UriInterface
|
||||
*/
|
||||
private function validate()
|
||||
{
|
||||
if ($this->host === '' && ($this->scheme === 'http' || $this->scheme === 'https')) {
|
||||
throw new InvalidArgumentException('Uri with a scheme must have a host');
|
||||
if (
|
||||
$this->host === "" &&
|
||||
($this->scheme === "http" || $this->scheme === "https")
|
||||
) {
|
||||
throw new InvalidArgumentException(
|
||||
"Uri with a scheme must have a host"
|
||||
);
|
||||
}
|
||||
|
||||
if ($this->getAuthority() === '') {
|
||||
if (str_starts_with($this->path, '//')) {
|
||||
throw new InvalidArgumentException('The path of a URI without an authority must not start with two slashes \'//\'');
|
||||
if ($this->getAuthority() === "") {
|
||||
if (str_starts_with($this->path, "//")) {
|
||||
throw new InvalidArgumentException(
|
||||
'The path of a URI without an authority must not start with two slashes \'//\''
|
||||
);
|
||||
}
|
||||
if ($this->scheme === '' && str_contains(explode('/', $this->path, 2)[0], ':')) {
|
||||
throw new InvalidArgumentException('A relative URI must not have a path beginning with a segment containing a colon');
|
||||
if (
|
||||
$this->scheme === "" &&
|
||||
str_contains(explode("/", $this->path, 2)[0], ":")
|
||||
) {
|
||||
throw new InvalidArgumentException(
|
||||
"A relative URI must not have a path beginning with a segment containing a colon"
|
||||
);
|
||||
}
|
||||
} elseif (isset($this->path[0]) && $this->path[0] !== '/') {
|
||||
throw new InvalidArgumentException('The path of a URI with an authority must start with a slash \'/\' or be empty');
|
||||
} elseif (isset($this->path[0]) && $this->path[0] !== "/") {
|
||||
throw new InvalidArgumentException(
|
||||
'The path of a URI with an authority must start with a slash \'/\' or be empty'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -396,8 +426,9 @@ abstract class AbstractUri implements UriInterface
|
||||
$scheme = $this->scheme;
|
||||
$port = $this->port;
|
||||
|
||||
return $this->port === null
|
||||
|| (isset(static::$defaultPorts[$scheme]) && $port === static::$defaultPorts[$scheme]);
|
||||
return $this->port === null ||
|
||||
(isset(static::$defaultPorts[$scheme]) &&
|
||||
$port === static::$defaultPorts[$scheme]);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -12,14 +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\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.
|
||||
@@ -46,8 +83,8 @@ final class Install
|
||||
'grav' => [
|
||||
'name' => 'Grav',
|
||||
'versions' => [
|
||||
'1.6' => '1.6.0',
|
||||
'' => '1.6.28'
|
||||
'1.7' => '1.7.50',
|
||||
'' => '1.7.50'
|
||||
]
|
||||
],
|
||||
'plugins' => [
|
||||
@@ -119,8 +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
|
||||
@@ -134,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
|
||||
@@ -170,11 +250,11 @@ final class Install
|
||||
if (\defined('GRAV_CLI') && GRAV_CLI) {
|
||||
$errors = "\n\n" . strip_tags($errors) . "\n\n";
|
||||
$errors .= <<<ERR
|
||||
Please install Grav 1.6.31 first by running following commands:
|
||||
Please install Grav 1.7.50 first by running following commands:
|
||||
|
||||
wget -q https://getgrav.org/download/core/grav-update/1.6.31 -O tmp/grav-update-v1.6.31.zip
|
||||
bin/gpm direct-install -y tmp/grav-update-v1.6.31.zip
|
||||
rm tmp/grav-update.zip
|
||||
wget -q https://getgrav.org/download/core/grav-update/1.7.50 -O tmp/grav-update-v1.7.50.zip
|
||||
bin/gpm direct-install -y tmp/grav-update-v1.7.50.zip
|
||||
rm tmp/grav-update-v1.7.50.zip
|
||||
ERR;
|
||||
}
|
||||
|
||||
@@ -186,6 +266,20 @@ ERR;
|
||||
$this->finalize();
|
||||
}
|
||||
|
||||
public function setProgressCallback(?callable $callback): self
|
||||
{
|
||||
$this->progressCallback = $callback;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
private function relayProgress(string $stage, string $message, ?int $percent = null): void
|
||||
{
|
||||
if ($this->progressCallback) {
|
||||
($this->progressCallback)($stage, $message, $percent);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* NOTE: This method can only be called after $grav['plugins']->init().
|
||||
*
|
||||
@@ -209,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');
|
||||
|
||||
@@ -251,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');
|
||||
@@ -260,6 +357,31 @@ ERR;
|
||||
// Update user/config/version.yaml before copying the files to avoid frontend from setting the version schema.
|
||||
$this->updater->install();
|
||||
|
||||
$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,
|
||||
@@ -267,8 +389,12 @@ ERR;
|
||||
$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();
|
||||
@@ -280,12 +406,317 @@ ERR;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return bool
|
||||
*/
|
||||
private function shouldUseSafeUpgrade(): bool
|
||||
{
|
||||
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'])) {
|
||||
$configValue = $grav['config']->get('system.updates.safe_upgrade');
|
||||
if ($configValue !== null) {
|
||||
return (bool) $configValue;
|
||||
}
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
// ignore bootstrap failures
|
||||
}
|
||||
|
||||
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');
|
||||
@@ -295,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);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -392,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;
|
||||
}
|
||||
}
|
||||
|
||||
332
system/src/Pimple/Container.php
Normal file
332
system/src/Pimple/Container.php
Normal file
@@ -0,0 +1,332 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* This file is part of Pimple.
|
||||
*
|
||||
* Copyright (c) 2009 Fabien Potencier
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is furnished
|
||||
* to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
* THE SOFTWARE.
|
||||
*/
|
||||
|
||||
namespace Pimple;
|
||||
|
||||
use ArrayAccess;
|
||||
use Pimple\Exception\ExpectedInvokableException;
|
||||
use Pimple\Exception\FrozenServiceException;
|
||||
use Pimple\Exception\InvalidServiceIdentifierException;
|
||||
use Pimple\Exception\UnknownIdentifierException;
|
||||
use SplObjectStorage;
|
||||
use function array_keys;
|
||||
use function is_object;
|
||||
use function is_string;
|
||||
use function method_exists;
|
||||
use function sprintf;
|
||||
use function trigger_error;
|
||||
|
||||
/**
|
||||
* Container main class.
|
||||
*
|
||||
* @author Fabien Potencier
|
||||
*/
|
||||
class Container implements ArrayAccess
|
||||
{
|
||||
/** @var array<string,mixed> */
|
||||
private array $values = [];
|
||||
|
||||
private SplObjectStorage $factories;
|
||||
|
||||
private SplObjectStorage $protected;
|
||||
|
||||
/** @var array<string,bool> */
|
||||
private array $frozen = [];
|
||||
|
||||
/** @var array<string,mixed> */
|
||||
private array $raw = [];
|
||||
|
||||
/** @var array<string,bool> */
|
||||
private array $keys = [];
|
||||
|
||||
/**
|
||||
* Instantiates the container.
|
||||
*
|
||||
* Objects and parameters can be passed as argument to the constructor.
|
||||
*
|
||||
* @param array $values The parameters or objects
|
||||
*/
|
||||
public function __construct(array $values = [])
|
||||
{
|
||||
$this->factories = new SplObjectStorage();
|
||||
$this->protected = new SplObjectStorage();
|
||||
|
||||
foreach ($values as $key => $value) {
|
||||
$this->offsetSet($key, $value);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets a parameter or an object.
|
||||
*
|
||||
* Objects must be defined as Closures.
|
||||
*
|
||||
* Allowing any PHP callable leads to difficult to debug problems
|
||||
* as function names (strings) are callable (creating a function with
|
||||
* the same name as an existing parameter would break your container).
|
||||
*
|
||||
* @param string $id The unique identifier for the parameter or object
|
||||
* @param mixed $value The value of the parameter or a closure to define an object
|
||||
*
|
||||
* @return void
|
||||
*
|
||||
* @throws FrozenServiceException Prevent override of a frozen service
|
||||
*/
|
||||
public function offsetSet(mixed $id, mixed $value): void
|
||||
{
|
||||
if (!is_string($id)) {
|
||||
throw new InvalidServiceIdentifierException($id);
|
||||
}
|
||||
|
||||
if (isset($this->frozen[$id])) {
|
||||
throw new FrozenServiceException($id);
|
||||
}
|
||||
|
||||
$this->values[$id] = $value;
|
||||
$this->keys[$id] = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a parameter or an object.
|
||||
*
|
||||
* @param string $id The unique identifier for the parameter or object
|
||||
*
|
||||
* @return mixed The value of the parameter or an object
|
||||
*
|
||||
* @throws UnknownIdentifierException If the identifier is not defined
|
||||
*/
|
||||
public function offsetGet(mixed $id): mixed
|
||||
{
|
||||
if (!is_string($id)) {
|
||||
throw new InvalidServiceIdentifierException($id);
|
||||
}
|
||||
|
||||
if (!isset($this->keys[$id])) {
|
||||
throw new UnknownIdentifierException($id);
|
||||
}
|
||||
|
||||
if (
|
||||
isset($this->raw[$id])
|
||||
|| !is_object($this->values[$id])
|
||||
|| isset($this->protected[$this->values[$id]])
|
||||
|| !method_exists($this->values[$id], '__invoke')
|
||||
) {
|
||||
return $this->values[$id];
|
||||
}
|
||||
|
||||
if (isset($this->factories[$this->values[$id]])) {
|
||||
return $this->values[$id]($this);
|
||||
}
|
||||
|
||||
$raw = $this->values[$id];
|
||||
$val = $this->values[$id] = $raw($this);
|
||||
$this->raw[$id] = $raw;
|
||||
|
||||
$this->frozen[$id] = true;
|
||||
|
||||
return $val;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a parameter or an object is set.
|
||||
*
|
||||
* @param string $id The unique identifier for the parameter or object
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function offsetExists(mixed $id): bool
|
||||
{
|
||||
return is_string($id) && isset($this->keys[$id]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Unsets a parameter or an object.
|
||||
*
|
||||
* @param string $id The unique identifier for the parameter or object
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function offsetUnset(mixed $id): void
|
||||
{
|
||||
if (!is_string($id)) {
|
||||
throw new InvalidServiceIdentifierException($id);
|
||||
}
|
||||
|
||||
if (isset($this->keys[$id])) {
|
||||
if (is_object($this->values[$id])) {
|
||||
unset($this->factories[$this->values[$id]], $this->protected[$this->values[$id]]);
|
||||
}
|
||||
|
||||
unset($this->values[$id], $this->frozen[$id], $this->raw[$id], $this->keys[$id]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks a callable as being a factory service.
|
||||
*
|
||||
* @param callable $callable A service definition to be used as a factory
|
||||
*
|
||||
* @return callable The passed callable
|
||||
*
|
||||
* @throws ExpectedInvokableException Service definition has to be a closure or an invokable object
|
||||
*/
|
||||
public function factory(object $callable): object
|
||||
{
|
||||
if (!method_exists($callable, '__invoke')) {
|
||||
throw new ExpectedInvokableException('Service definition is not a Closure or invokable object.');
|
||||
}
|
||||
|
||||
$this->factories->attach($callable);
|
||||
|
||||
return $callable;
|
||||
}
|
||||
|
||||
/**
|
||||
* Protects a callable from being interpreted as a service.
|
||||
*
|
||||
* This is useful when you want to store a callable as a parameter.
|
||||
*
|
||||
* @param callable $callable A callable to protect from being evaluated
|
||||
*
|
||||
* @return callable The passed callable
|
||||
*
|
||||
* @throws ExpectedInvokableException Service definition has to be a closure or an invokable object
|
||||
*/
|
||||
public function protect(object $callable): object
|
||||
{
|
||||
if (!method_exists($callable, '__invoke')) {
|
||||
throw new ExpectedInvokableException('Callable is not a Closure or invokable object.');
|
||||
}
|
||||
|
||||
$this->protected->attach($callable);
|
||||
|
||||
return $callable;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a parameter or the closure defining an object.
|
||||
*
|
||||
* @param string $id The unique identifier for the parameter or object
|
||||
*
|
||||
* @return mixed The value of the parameter or the closure defining an object
|
||||
*
|
||||
* @throws UnknownIdentifierException If the identifier is not defined
|
||||
*/
|
||||
public function raw(string $id): mixed
|
||||
{
|
||||
if (!isset($this->keys[$id])) {
|
||||
throw new UnknownIdentifierException($id);
|
||||
}
|
||||
|
||||
if (isset($this->raw[$id])) {
|
||||
return $this->raw[$id];
|
||||
}
|
||||
|
||||
return $this->values[$id];
|
||||
}
|
||||
|
||||
/**
|
||||
* Extends an object definition.
|
||||
*
|
||||
* Useful when you want to extend an existing object definition,
|
||||
* without necessarily loading that object.
|
||||
*
|
||||
* @param string $id The unique identifier for the object
|
||||
* @param callable $callable A service definition to extend the original
|
||||
*
|
||||
* @return callable The wrapped callable
|
||||
*
|
||||
* @throws UnknownIdentifierException If the identifier is not defined
|
||||
* @throws FrozenServiceException If the service is frozen
|
||||
* @throws InvalidServiceIdentifierException If the identifier belongs to a parameter
|
||||
* @throws ExpectedInvokableException If the extension callable is not a closure or an invokable object
|
||||
*/
|
||||
public function extend(string $id, object $callable): object
|
||||
{
|
||||
if (!isset($this->keys[$id])) {
|
||||
throw new UnknownIdentifierException($id);
|
||||
}
|
||||
|
||||
if (isset($this->frozen[$id])) {
|
||||
throw new FrozenServiceException($id);
|
||||
}
|
||||
|
||||
if (!is_object($this->values[$id]) || !method_exists($this->values[$id], '__invoke')) {
|
||||
throw new InvalidServiceIdentifierException($id);
|
||||
}
|
||||
|
||||
if (isset($this->protected[$this->values[$id]])) {
|
||||
@trigger_error(sprintf('How Pimple behaves when extending protected closures will be fixed in Pimple 4. Are you sure "%s" should be protected?', $id), E_USER_DEPRECATED);
|
||||
}
|
||||
|
||||
if (!method_exists($callable, '__invoke')) {
|
||||
throw new ExpectedInvokableException('Extension service definition is not a Closure or invokable object.');
|
||||
}
|
||||
|
||||
$factory = $this->values[$id];
|
||||
|
||||
$extended = function (self $container) use ($callable, $factory): mixed {
|
||||
return $callable($factory($container), $container);
|
||||
};
|
||||
|
||||
if (isset($this->factories[$factory])) {
|
||||
$this->factories->detach($factory);
|
||||
$this->factories->attach($extended);
|
||||
}
|
||||
|
||||
return $this[$id] = $extended;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all defined value names.
|
||||
*
|
||||
* @return array An array of value names
|
||||
*/
|
||||
public function keys(): array
|
||||
{
|
||||
return array_keys($this->values);
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers a service provider.
|
||||
*
|
||||
* @param array $values An array of values that customizes the provider
|
||||
*
|
||||
* @return static
|
||||
*/
|
||||
public function register(ServiceProviderInterface $provider, array $values = []): static
|
||||
{
|
||||
$provider->register($this);
|
||||
|
||||
foreach ($values as $key => $value) {
|
||||
$this[$key] = $value;
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
38
system/src/Pimple/Exception/ExpectedInvokableException.php
Normal file
38
system/src/Pimple/Exception/ExpectedInvokableException.php
Normal file
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of Pimple.
|
||||
*
|
||||
* Copyright (c) 2009 Fabien Potencier
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is furnished
|
||||
* to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
* THE SOFTWARE.
|
||||
*/
|
||||
|
||||
namespace Pimple\Exception;
|
||||
|
||||
use Psr\Container\ContainerExceptionInterface;
|
||||
|
||||
/**
|
||||
* A closure or invokable object was expected.
|
||||
*
|
||||
* @author Pascal Luna <skalpa@zetareticuli.org>
|
||||
*/
|
||||
class ExpectedInvokableException extends \InvalidArgumentException implements ContainerExceptionInterface
|
||||
{
|
||||
}
|
||||
45
system/src/Pimple/Exception/FrozenServiceException.php
Normal file
45
system/src/Pimple/Exception/FrozenServiceException.php
Normal file
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of Pimple.
|
||||
*
|
||||
* Copyright (c) 2009 Fabien Potencier
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is furnished
|
||||
* to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
* THE SOFTWARE.
|
||||
*/
|
||||
|
||||
namespace Pimple\Exception;
|
||||
|
||||
use Psr\Container\ContainerExceptionInterface;
|
||||
|
||||
/**
|
||||
* An attempt to modify a frozen service was made.
|
||||
*
|
||||
* @author Pascal Luna <skalpa@zetareticuli.org>
|
||||
*/
|
||||
class FrozenServiceException extends \RuntimeException implements ContainerExceptionInterface
|
||||
{
|
||||
/**
|
||||
* @param string $id Identifier of the frozen service
|
||||
*/
|
||||
public function __construct($id)
|
||||
{
|
||||
parent::__construct(\sprintf('Cannot override frozen service "%s".', $id));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of Pimple.
|
||||
*
|
||||
* Copyright (c) 2009 Fabien Potencier
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is furnished
|
||||
* to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
* THE SOFTWARE.
|
||||
*/
|
||||
|
||||
namespace Pimple\Exception;
|
||||
|
||||
use Psr\Container\NotFoundExceptionInterface;
|
||||
|
||||
/**
|
||||
* An attempt to perform an operation that requires a service identifier was made.
|
||||
*
|
||||
* @author Pascal Luna <skalpa@zetareticuli.org>
|
||||
*/
|
||||
class InvalidServiceIdentifierException extends \InvalidArgumentException implements NotFoundExceptionInterface
|
||||
{
|
||||
/**
|
||||
* @param string $id The invalid identifier
|
||||
*/
|
||||
public function __construct($id)
|
||||
{
|
||||
parent::__construct(\sprintf('Identifier "%s" does not contain an object definition.', $id));
|
||||
}
|
||||
}
|
||||
45
system/src/Pimple/Exception/UnknownIdentifierException.php
Normal file
45
system/src/Pimple/Exception/UnknownIdentifierException.php
Normal file
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of Pimple.
|
||||
*
|
||||
* Copyright (c) 2009 Fabien Potencier
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is furnished
|
||||
* to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
* THE SOFTWARE.
|
||||
*/
|
||||
|
||||
namespace Pimple\Exception;
|
||||
|
||||
use Psr\Container\NotFoundExceptionInterface;
|
||||
|
||||
/**
|
||||
* The identifier of a valid service or parameter was expected.
|
||||
*
|
||||
* @author Pascal Luna <skalpa@zetareticuli.org>
|
||||
*/
|
||||
class UnknownIdentifierException extends \InvalidArgumentException implements NotFoundExceptionInterface
|
||||
{
|
||||
/**
|
||||
* @param string $id The unknown identifier
|
||||
*/
|
||||
public function __construct($id)
|
||||
{
|
||||
parent::__construct(\sprintf('Identifier "%s" is not defined.', $id));
|
||||
}
|
||||
}
|
||||
55
system/src/Pimple/Psr11/Container.php
Normal file
55
system/src/Pimple/Psr11/Container.php
Normal file
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of Pimple.
|
||||
*
|
||||
* Copyright (c) 2009-2017 Fabien Potencier
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is furnished
|
||||
* to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
* THE SOFTWARE.
|
||||
*/
|
||||
|
||||
namespace Pimple\Psr11;
|
||||
|
||||
use Pimple\Container as PimpleContainer;
|
||||
use Psr\Container\ContainerInterface;
|
||||
|
||||
/**
|
||||
* PSR-11 compliant wrapper.
|
||||
*
|
||||
* @author Pascal Luna <skalpa@zetareticuli.org>
|
||||
*/
|
||||
final class Container implements ContainerInterface
|
||||
{
|
||||
private $pimple;
|
||||
|
||||
public function __construct(PimpleContainer $pimple)
|
||||
{
|
||||
$this->pimple = $pimple;
|
||||
}
|
||||
|
||||
public function get(string $id)
|
||||
{
|
||||
return $this->pimple[$id];
|
||||
}
|
||||
|
||||
public function has(string $id): bool
|
||||
{
|
||||
return isset($this->pimple[$id]);
|
||||
}
|
||||
}
|
||||
75
system/src/Pimple/Psr11/ServiceLocator.php
Normal file
75
system/src/Pimple/Psr11/ServiceLocator.php
Normal file
@@ -0,0 +1,75 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of Pimple.
|
||||
*
|
||||
* Copyright (c) 2009 Fabien Potencier
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is furnished
|
||||
* to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
* THE SOFTWARE.
|
||||
*/
|
||||
|
||||
namespace Pimple\Psr11;
|
||||
|
||||
use Pimple\Container as PimpleContainer;
|
||||
use Pimple\Exception\UnknownIdentifierException;
|
||||
use Psr\Container\ContainerInterface;
|
||||
|
||||
/**
|
||||
* Pimple PSR-11 service locator.
|
||||
*
|
||||
* @author Pascal Luna <skalpa@zetareticuli.org>
|
||||
*/
|
||||
class ServiceLocator implements ContainerInterface
|
||||
{
|
||||
private $container;
|
||||
private $aliases = [];
|
||||
|
||||
/**
|
||||
* @param PimpleContainer $container The Container instance used to locate services
|
||||
* @param array $ids Array of service ids that can be located. String keys can be used to define aliases
|
||||
*/
|
||||
public function __construct(PimpleContainer $container, array $ids)
|
||||
{
|
||||
$this->container = $container;
|
||||
|
||||
foreach ($ids as $key => $id) {
|
||||
$this->aliases[\is_int($key) ? $id : $key] = $id;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function get(string $id)
|
||||
{
|
||||
if (!isset($this->aliases[$id])) {
|
||||
throw new UnknownIdentifierException($id);
|
||||
}
|
||||
|
||||
return $this->container[$this->aliases[$id]];
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function has(string $id): bool
|
||||
{
|
||||
return isset($this->aliases[$id]) && isset($this->container[$this->aliases[$id]]);
|
||||
}
|
||||
}
|
||||
81
system/src/Pimple/ServiceIterator.php
Normal file
81
system/src/Pimple/ServiceIterator.php
Normal file
@@ -0,0 +1,81 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* This file is part of Pimple.
|
||||
*
|
||||
* Copyright (c) 2009 Fabien Potencier
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is furnished
|
||||
* to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
* THE SOFTWARE.
|
||||
*/
|
||||
|
||||
namespace Pimple;
|
||||
|
||||
use Iterator;
|
||||
use function current;
|
||||
use function key;
|
||||
use function next;
|
||||
use function reset;
|
||||
|
||||
/**
|
||||
* Lazy service iterator.
|
||||
*
|
||||
* @author Pascal Luna <skalpa@zetareticuli.org>
|
||||
*/
|
||||
final class ServiceIterator implements Iterator
|
||||
{
|
||||
private Container $container;
|
||||
|
||||
/** @var list<string|int> */
|
||||
private array $ids;
|
||||
|
||||
public function __construct(Container $container, array $ids)
|
||||
{
|
||||
$this->container = $container;
|
||||
$this->ids = $ids;
|
||||
}
|
||||
|
||||
public function rewind(): void
|
||||
{
|
||||
reset($this->ids);
|
||||
}
|
||||
|
||||
public function current(): mixed
|
||||
{
|
||||
return $this->container[current($this->ids)];
|
||||
}
|
||||
|
||||
public function key(): string|int|null
|
||||
{
|
||||
$key = current($this->ids);
|
||||
|
||||
return $key === false ? null : $key;
|
||||
}
|
||||
|
||||
public function next(): void
|
||||
{
|
||||
next($this->ids);
|
||||
}
|
||||
|
||||
public function valid(): bool
|
||||
{
|
||||
return null !== key($this->ids);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user