Compare commits

...

198 Commits

Author SHA1 Message Date
Andy Miller
15cb068f95 fix for grav not picking up config + page changes
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-12-12 16:29:43 -07:00
Andy Miller
d34213232b avoid mail in twig content trigger security error
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-12-12 16:20:35 -07:00
Andy Miller
7a6b8a90d4 prepare for beta release
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-12-08 20:46:53 -07:00
Andy Miller
306f33f4ae fixes for twig3 loader + improve recovery mode
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-12-08 18:07:32 -07:00
Andy Miller
6cb8229806 fix for missing file
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-12-08 10:58:46 -07:00
Andy Miller
95e285efa4 Merge branch 'performance-part-3' into 1.8 2025-12-05 21:00:33 -07:00
Andy Miller
80410dae13 opcache fix in CompiledFile
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-12-05 20:59:46 -07:00
Andy Miller
fae70e5fc9 fixes #4002 - Backups blocking /var/www
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-12-03 19:30:32 -07:00
Andy Miller
9d9247a32f fix false positives in Security with on_events
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-12-03 14:17:17 -07:00
Andy Miller
94d85cd873 add support for environment in grav scheduler
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-12-03 10:41:29 -07:00
Andy Miller
0f879bd1d4 prepare for beta.27 release
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-11-30 16:17:37 -07:00
Andy Miller
fd828d452e trim down default user/config/system.yaml
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-11-30 16:14:35 -07:00
Andy Miller
63bbc1cac6 flex-objects caching fix
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-11-30 16:06:31 -07:00
Andy Miller
528032b11a update changelog
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-11-29 21:18:57 -07:00
Andy Miller
a4c3a3af6d Add isindex to XSS dangerous tags (CVE-2023-31506 / GHSA-h85h-xm8x-vfw7)
The original CVE-2023-31506 fix missed the deprecated <isindex> HTML tag,
which can still be used for XSS via event handlers like onmouseover.

The <isindex> tag is deprecated in HTML5 and has no legitimate modern use.
2025-11-29 21:07:23 -07:00
Andy Miller
b7e1958a6e Merge branch 'fix/GHSA-4cwq-j7jv-qmwg-title-email-leak' into 1.8 2025-11-29 18:29:39 -07:00
Andy Miller
0c38968c58 Fix email disclosure in user edit page title (GHSA-4cwq-j7jv-qmwg)
Security fix for IDOR-style information disclosure where the admin
email address was leaked in the <title> tag even on 403 Forbidden
responses.

The edit view title template previously included the email:
  {{ fullname ?? username }} <{{ email }}>

Now shows only the name/username without email:
  {{ fullname ?? username }}

This prevents low-privilege users from enumerating admin email
addresses by accessing /admin/accounts/users/{username} URLs.
2025-11-29 18:27:08 -07:00
Andy Miller
9d11094e41 Merge branch 'fix/GHSA-x62q-p736-3997-GHSA-gq3g-666w-7h85-admin-security' into 1.8 2025-11-29 17:52:03 -07:00
Andy Miller
ed640a1314 Merge branch 'fix/GHSA-p4ww-mcp9-j6f2-GHSA-m8vh-v6r6-w7p6-GHSA-j422-qmxp-hv94-file-path-security' into 1.8 2025-11-29 17:45:33 -07:00
Andy Miller
e37259527d Merge branch 'fix/GHSA-662m-56v4-3r8f-GHSA-858q-77wx-hhx6-GHSA-8535-hvm8-2hmv-GHSA-gjc5-8cfh-653x-GHSA-52hh-vxfw-p6rg-ssti-sandbox' into 1.8 2025-11-29 17:30:39 -07:00
Andy Miller
3462d94d57 Merge branch 'fix/GHSA-h756-wh59-hhjv-GHSA-cjcp-qxvg-4rjm-username-validation' into 1.8 2025-11-29 17:29:15 -07:00
Andy Miller
19c2f8da76 Fix path traversal and uniqueness vulnerabilities in username validation
Security fixes for:
- GHSA-h756-wh59-hhjv: Path traversal via username during account creation
- GHSA-cjcp-qxvg-4rjm: Username uniqueness bypass

Changes:

Framework/Flex/Storage/AbstractFilesystemStorage.php:
- Added validateKey() to check for path traversal attempts
- Blocks: .., /, \, null bytes, control characters
- Added assertValidKey() public method for external validation

Framework/Flex/Storage/FolderStorage.php:
- Key validation now enforced in createRows()

User/DataUser/User.php:
- Added isValidUsername() static method for reusable validation
- Added uniqueness check in save() - blocks if user already exists
- Validation blocks: path traversal, hidden files, dangerous characters

Flex/Types/Users/UserObject.php:
- Validation now blocks hidden files (starting with .)

Added unit tests:
- tests/unit/Grav/Common/Security/UsernameValidationTest.php
- 50 tests covering path traversal, dangerous characters, and valid usernames
2025-11-29 17:25:02 -07:00
Andy Miller
a161399c84 Fix DoS via cron expressions and password hash exposure
Security fixes for:
- GHSA-x62q-p736-3997: DoS via invalid cron expression in scheduler
- GHSA-gq3g-666w-7h85: Password hash exposure to frontend

Changes:

Scheduler/Job.php:
- Added try-catch around CronExpression::factory() to prevent DoS
- Added isValidCronExpression() static validation method
- Returns null instead of throwing on invalid expressions

Scheduler/IntervalTrait.php:
- Added try-catch in at() method for graceful handling

Twig/Extension/GravExtension.php:
- Protected cronFunc() from invalid expressions

Console/Cli/SchedulerCommand.php:
- Handle null cron expressions (shows "Invalid cron" error)

Flex/Types/Users/UserObject.php:
- Override jsonSerialize() to filter out hashed_password, secret, twofa_secret
- Prevents sensitive data from being exposed to frontend/HTML

User/DataUser/User.php:
- Same jsonSerialize() override for DataUser implementation

Added unit tests:
- tests/unit/Grav/Common/Security/AdminSecurityTest.php
- 53 tests covering cron validation and password hash protection
2025-11-29 17:24:41 -07:00
Andy Miller
5f120c328b Fix file read, DoS, and path traversal vulnerabilities
Security fixes for:
- GHSA-p4ww-mcp9-j6f2: Arbitrary file read via read_file() Twig function
- GHSA-m8vh-v6r6-w7p6: DoS via malformed language code in regex
- GHSA-j422-qmxp-hv94: Path traversal in backup root configuration

Changes:

GravExtension.php - readFileFunc():
- Added realpath validation to prevent path traversal
- Blocked reading files outside GRAV_ROOT
- Blocked sensitive files: accounts/*.yaml, .env, .git, logs, backups, vendor

Language.php:
- Fixed regex delimiter in setActiveFromUri() to properly escape language codes
- Added validation in setLanguages() to only allow valid language codes
- Pattern: /^[a-zA-Z]{2,3}(?:[-_][a-zA-Z0-9]{2,8})?$/

Backups.php:
- Added path traversal protection with realpath validation
- Blocked access to system directories: /etc, /root, /home, /var, etc.

Added unit tests:
- tests/unit/Grav/Common/Security/FilePathSecurityTest.php
- 55 tests covering language code validation and regex injection prevention
2025-11-29 17:24:22 -07:00
Andy Miller
db924c4a26 Expand SSTI sandbox blacklist to block known attack vectors
Security fixes for:
- GHSA-662m-56v4-3r8f: SSTI sandbox bypass via nested evaluate_twig
- GHSA-858q-77wx-hhx6: Privilege escalation via grav.user/scheduler
- GHSA-8535-hvm8-2hmv: Context leak via Forms _context access
- GHSA-gjc5-8cfh-653x: Sandbox bypass via grav.config.set
- GHSA-52hh-vxfw-p6rg: CVE-2024-28116 bypass via string concatenation

Changes to cleanDangerousTwig():
- Added 150+ dangerous PHP functions to blacklist
- Blocked access to grav.scheduler, grav.twig.twig, grav.backups, grav.gpm
- Blocked config modification via config.set()
- Blocked user modification via grav.user.update()/save()
- Blocked context/internal access via _context, _self, twig_vars
- Blocked evaluate_twig/evaluate to prevent nested bypass
- Added string concatenation pattern detection for bypass attempts
- Blocked SSRF vectors (curl, fsockopen, stream_socket)
- Blocked file operations, serialization, reflection classes

Performance optimizations:
- Early exit if string has no Twig blocks ({{ or {%})
- Static caching of compiled regex patterns (built once, reused)
- Combined all patterns into 4 single regex operations instead of ~190 loops
- Consolidated property patterns using regex alternation groups

Added unit tests:
- tests/unit/Grav/Common/Security/CleanDangerousTwigTest.php
- 104 tests covering all GHSA advisories and dangerous patterns
2025-11-29 17:24:06 -07:00
Andy Miller
9fc1b42d59 prepare beta release
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-11-29 11:02:20 -07:00
Andy Miller
c8878dfc80 upgrade to symfony 7.4 stable
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-11-29 10:58:03 -07:00
Andy Miller
779661ab8a more improvements for JS minification and now pulls any broken JS out of pipeline
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-11-27 20:56:07 +00:00
Andy Miller
3985638a8f more debug in the Pipeline.php to identify issues
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-11-27 19:19:02 +00:00
Andy Miller
a78789b291 upgrade compoer libs
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-11-24 21:05:46 +00:00
Andy Miller
caa127cd53 disallow xref/xhref in SVGs
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-11-24 21:04:44 +00:00
Andy Miller
5f087d3a43 fix range requests for partial content in Utils::downloads() - Fixes #3990
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-11-23 17:55:28 +00:00
Andy Miller
1bc6e5e13a prepare for beta release
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-11-22 11:17:37 +00:00
Andy Miller
f339bb83c5 update composer
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-11-22 11:16:08 +00:00
Andy Miller
27789991ae prepare for beta release
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-11-21 12:45:03 +00:00
Andy Miller
114aebae7c more robust deferred logic + deprecated fixes
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-11-21 12:25:58 +00:00
Andy Miller
370dfd6016 updated vendor libs
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-11-21 11:37:28 +00:00
Andy Miller
1d05e6bdc4 pages rebuild optimization
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-11-21 11:16:34 +00:00
Andy Miller
3acff8a9f8 update changelog
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-11-20 11:18:20 +00:00
Andy Miller
ea59bdb1d4 Fix double execution of preflight checks during self-upgrade 2025-11-20 11:16:07 +00:00
Andy Miller
02330b96d9 Optimize preflight Monolog checks by skipping vendor directories 2025-11-20 11:12:03 +00:00
Andy Miller
2b1d73fd26 fix for slow tests
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-11-20 10:54:24 +00:00
Andy Miller
4e11ca7c8e Fix slow SafeUpgradeServiceTest by optimizing snapshot pruning 2025-11-20 10:51:45 +00:00
Andy Miller
591e2e4563 revert missing line
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-11-19 22:26:59 +00:00
Andy Miller
2161ffeb5e gated the debugger addEvent call
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-11-18 21:55:55 +00:00
Andy Miller
b856978211 reuse regex for better optimization
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-11-18 21:50:35 +00:00
Andy Miller
19ee2d883e lazy load page optimization
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-11-18 21:24:50 +00:00
Andy Miller
93089241c3 Ensure file permissions are preserved during safe-upgrade copy operations 2025-11-18 18:28:46 +00:00
Andy Miller
3b1c332932 Fix safe-upgrade snapshot creation (copy vs move) and implement pruning 2025-11-18 18:21:34 +00:00
Andy Miller
7fd614f8b6 Add Twig 3 compatibility transformations for raw, divisibleby, and none 2025-11-18 17:46:20 +00:00
Andy Miller
5567a5a1cd twig3 compatibility fixes + tests
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-11-18 17:25:07 +00:00
Andy Miller
334e1dcabc prepare for beta release
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-11-14 14:42:09 +00:00
Andy Miller
cbf5ec57c6 test fixes + major/minor plugin warnings
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-11-14 11:33:24 +00:00
Andy Miller
9f33e247cf added configurable snapshot pruning amount
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-11-14 11:33:07 +00:00
Andy Miller
8c7e970603 some installer fixes
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-11-14 11:33:04 +00:00
Andy Miller
360b418c97 checkout correct version
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-11-14 11:33:01 +00:00
Andy Miller
af0db0c2a1 preflight integration for cli
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-11-14 11:32:58 +00:00
Andy Miller
4c74192191 ui things
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-11-14 11:32:56 +00:00
Andy Miller
ee5fccd2c8 added back snapshots in Install.php
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-11-14 11:32:52 +00:00
Andy Miller
5bc89bf32b simplified safe-upgrade
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-11-14 11:32:49 +00:00
Andy Miller
0b021e2114 more simplification
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-11-14 11:29:12 +00:00
Andy Miller
15c1b1cc06 simplify copy/permission process + fix safe-upgrade check
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-11-14 11:29:07 +00:00
Andy Miller
ee1b55e929 don’t error when trying to force —safe
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-11-09 15:50:51 +00:00
Andy Miller
73d3a90c0b test fixes
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-11-09 13:11:17 +00:00
Andy Miller
0764e37c8b major/minor upgrade warnings
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-11-09 12:51:54 +00:00
Andy Miller
bd5b2633f7 less confusing messages
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-11-09 12:07:34 +00:00
Andy Miller
6b0c0486aa new minifier libraries
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-11-09 11:58:35 +00:00
Andy Miller
07ac3d3bb9 vendor updates
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-11-09 11:47:42 +00:00
Andy Miller
72e9d57e2e fall back to safe upgrade
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-11-08 20:05:25 +00:00
Andy Miller
07965c6c61 revert testing repo
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-11-08 12:18:09 +00:00
Andy Miller
72cc8e91a2 some more fixes
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-11-08 11:44:18 +00:00
Andy Miller
678eacaae5 fix some errors after upgrade
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-11-08 11:31:27 +00:00
Andy Miller
cb7a3ccfdf mostly working
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-11-08 11:31:26 +00:00
Andy Miller
076c10d34b prepare for beta release
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-11-06 18:54:16 +00:00
Andy Miller
2d75649a08 removed check causing false positives
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-11-06 18:36:50 +00:00
Andy Miller
c8acc9a499 has some legit uses - this is actually causing problems
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-11-06 18:36:27 +00:00
Andy Miller
af499184ea update clean commant
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-11-05 23:37:26 +00:00
Andy Miller
ebac0a082c prepare for beta release
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-11-05 23:26:39 +00:00
Andy Miller
4d31bbb43a ignore .github and .phan folders, fixed path check
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-11-05 23:25:49 +00:00
Andy Miller
be20cf2e2c prepare for beta release
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-11-05 21:24:47 +00:00
Andy Miller
c33a1f57bc don’t copy non-upgrade root folders
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-11-05 21:23:11 +00:00
Andy Miller
83817428c7 prepare for beta release
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-11-05 18:35:45 +00:00
Andy Miller
d2970a92b5 more safe upgrade fixes
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-11-05 18:30:24 +00:00
Andy Miller
7b1bcf7789 sync regex fix
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-11-04 14:15:05 +00:00
Andy Miller
44bdd1283d add preflight command and —safe and —legacy for self-upgrade
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-11-04 14:02:38 +00:00
Andy Miller
32dafbb1cb cache fallbacks for unsupported drivers
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-11-04 13:57:22 +00:00
Andy Miller
e622326285 improved js assets pipline handling to support defer
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-11-03 23:48:50 +00:00
Andy Miller
d0287043c2 prepare for release
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-10-31 19:13:57 +00:00
Andy Miller
6c5b801c6f test update script
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-10-31 19:08:10 +00:00
Andy Miller
460bf241a5 safe upgrae improvements
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-10-31 18:02:06 +00:00
Andy Miller
ee179e19e5 strict types
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-10-28 17:40:43 +00:00
Andy Miller
3618a129df bring pimple ‘in-house’ for continued development
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-10-28 12:22:09 +00:00
Andy Miller
787146cc2c register_argc_argv fixes
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-10-27 13:17:43 -06:00
Andy Miller
a1fe19f465 replace doctrine/cache with symfony/cache
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-10-27 12:31:15 -06:00
Andy Miller
f2c26c116a 8.5 not available yet
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-10-27 11:56:34 -06:00
Andy Miller
d1d70c4d0c set PHP minimum to 8.3
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-10-27 10:37:43 -06:00
Andy Miller
e5a659d445 fix for PHP 8.5+
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-10-27 10:31:29 -06:00
Andy Miller
39c4ecfe6a prepare for beta release
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-10-23 09:01:01 -06:00
Andy Miller
3e3aa00a1b rework monolog shim for better compatibility
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-10-23 08:58:58 -06:00
Andy Miller
9c2497460b don’t crash if getManifest is not available
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-10-23 08:16:08 -06:00
Andy Miller
f2f58d11d6 vendor updates
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-10-21 14:05:36 -06:00
Andy Miller
2d8be2f859 fix for recovery window/manifest via bin/gpm
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-10-20 13:21:54 -06:00
Andy Miller
f6c57a44de prepare for beta upgrade
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-10-20 11:38:17 -06:00
Andy Miller
0d2d0bdc11 Solution for handling Event errors on upgrade
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-10-20 11:30:44 -06:00
Andy Miller
e110701079 force +x permssions on grav/* via CLI upgrade
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-10-20 11:30:17 -06:00
Andy Miller
c10acd1837 fix for filterFunc and mapFunc
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-10-20 11:14:33 -06:00
Andy Miller
f9f3b9a8ba support labels in recovery mode
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-10-19 21:44:29 -06:00
Andy Miller
e5b7449483 updated .gitignore
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-10-19 21:26:31 -06:00
Andy Miller
7077b0b71a fix recovery mode
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-10-19 21:09:43 -06:00
Andy Miller
57a446862f jump into recovery mode
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-10-19 20:49:02 -06:00
Andy Miller
b2f2e7bd45 more recovery manage fixes
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-10-19 18:37:12 -06:00
Andy Miller
3fbd6771e9 more recovery fixes
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-10-19 18:18:20 -06:00
Andy Miller
8a10d6bc54 fixing tests
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-10-19 18:05:48 -06:00
Andy Miller
0bdde9dec2 Merge branch '1.8' of github.com:getgrav/grav into 1.8
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-10-19 17:59:58 -06:00
Andy Miller
348fa04c47 recovery/command fixes
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-10-19 17:59:17 -06:00
Andy Miller
52f0d5f1d7 more fixes for recovery.window and recovery.flag
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-10-19 17:33:44 -06:00
Andy Miller
9c6111c368 prepare for beta release
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-10-19 16:15:07 -06:00
Andy Miller
9806533f56 remove plan document
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-10-19 16:03:16 -06:00
Andy Miller
e30245789c move recover.flag
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-10-19 16:02:51 -06:00
Andy Miller
20b95c4585 ignore unpublished plugins - part 2 2025-10-19 11:13:16 -06:00
Andy Miller
6d0fc78462 ignore unpublished plugins 2025-10-19 11:13:15 -06:00
Andy Miller
5420ca2200 prepare for beta release
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-10-18 20:34:38 -06:00
Andy Miller
942f523f18 fix test
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-10-18 19:09:59 -06:00
Andy Miller
c812def317 better label handling for snapshots
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-10-18 19:07:23 -06:00
Andy Miller
9b2d352f8a more restore bin fixes
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-10-18 19:07:23 -06:00
Andy Miller
d932875e66 create adhoc snapshot
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-10-18 19:07:23 -06:00
Andy Miller
7a2c151a4b run / restore feature
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-10-18 19:07:23 -06:00
Andy Miller
81b0f0ec04 bin/restore enhancement
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-10-17 22:19:49 -06:00
Andy Miller
70ddb549b7 stop cache clearing snapshots
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-10-17 21:08:42 -06:00
Andy Miller
be3cb77f28 more refactoring of safe install
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-10-17 20:48:55 -06:00
Andy Miller
345b5e9577 filter out extra folders
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-10-17 19:55:55 -06:00
Andy Miller
e88f38bd10 Optimized staged package
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-10-17 19:35:43 -06:00
Andy Miller
bdc06afea2 prepare for beta release
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-10-17 18:38:29 -06:00
Andy Miller
f9348a4d9d Merge branch 'develop' into 1.8
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-10-17 18:35:14 -06:00
Andy Miller
44fd1172b8 more granular install for self upgrade
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-10-17 18:18:53 -06:00
Andy Miller
c9c1267284 ignore recovery file
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-10-17 18:16:12 -06:00
Andy Miller
4fa5996414 fix for safe upgrade on 1.8
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-10-17 18:15:13 -06:00
Andy Miller
920642411c move back to cp instead of mv for snapshots
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-10-17 17:53:48 -06:00
Andy Miller
2999c06a3a change snapshot storage 2025-10-17 16:49:42 -06:00
Andy Miller
d97b2d70bd logic fixes 2025-10-17 16:18:40 -06:00
Andy Miller
5e7b482972 test fix
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-10-17 11:34:35 -06:00
Andy Miller
9230a5a40f ingore recovery window
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-10-17 11:32:38 -06:00
Andy Miller
c3d1d4ae26 fix for binary permissions in CLI 2025-10-17 11:27:49 -06:00
Andy Miller
286b5a5179 fix for binary permissions in CLI 2025-10-17 11:26:43 -06:00
Andy Miller
c79d2ecfc4 another fix for safe upgrade
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-10-17 11:11:12 -06:00
Andy Miller
70d6aec1a7 another fix for safe upgrade 2025-10-17 11:07:17 -06:00
Andy Miller
60a97dcf56 test fixes
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-10-17 10:14:22 -06:00
Andy Miller
679a6db61d prepare for beta release
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-10-17 10:08:33 -06:00
Andy Miller
b70ae844a8 route safeupgrade status 2025-10-16 23:31:38 -06:00
Andy Miller
9dd507b717 route safeupgrade status 2025-10-16 23:31:05 -06:00
Andy Miller
e6de9db77e preserver root files 2025-10-16 23:28:23 -06:00
Andy Miller
b6a37cfff3 preserver root files 2025-10-16 23:17:34 -06:00
Andy Miller
42e37c1d02 ensureJobResult
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-10-16 21:30:05 -06:00
Andy Miller
09aa2fb8fd ensureJobResult
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-10-16 21:28:08 -06:00
Andy Miller
e764d2ce1c more safeupgrade logic
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-10-16 18:51:16 -06:00
Andy Miller
3f0b204728 Add new SafeUpgradeRun CLI command
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-10-16 17:32:43 -06:00
Andy Miller
f711cb3208 fixes for permission retention
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-10-16 15:25:01 -06:00
Andy Miller
f10894fe47 fixes for permission retention
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-10-16 15:24:12 -06:00
Andy Miller
b68872e3fd Monolog 3 compatible shim to handle upgrades
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-10-16 14:32:05 -06:00
Andy Miller
43126b09e4 fixes for 1.8 upgrades
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-10-16 14:19:16 -06:00
Andy Miller
6751d28839 prepare for beta release
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-10-16 12:09:49 -06:00
Andy Miller
8118d6b980 source fix in restore bin + missing dot files after upgrade
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-10-16 12:01:48 -06:00
Andy Miller
2c4b69f9ec Merge branch 'develop' of github.com:getgrav/grav into develop 2025-10-16 12:01:14 -06:00
Andy Miller
d6cbc263e7 source fix in restore bin + missing dot files after upgrade
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-10-16 11:56:40 -06:00
Andy Miller
ba2536136b prepare for release
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-10-16 10:42:33 -06:00
Andy Miller
c56d24c0d7 timelimt on recovery status
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-10-16 09:08:53 -06:00
Andy Miller
ee49305053 timelimit on recovery status
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-10-16 09:08:32 -06:00
Andy Miller
b4d664fcb0 built in composer update
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-10-16 08:23:22 -06:00
Andy Miller
7192cfe549 synced restore changes
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-10-16 08:09:47 -06:00
Andy Miller
7fcb1d1cb7 renamed to bin/restore
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-10-16 08:09:02 -06:00
Andy Miller
dbeaa8ad46 remove accidental recovery flag + add functionality in grav-restore
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-10-16 08:07:56 -06:00
Andy Miller
a3da588829 should fix tests this time
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-10-16 07:54:44 -06:00
Andy Miller
a3387c106b more test fixes
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-10-16 07:50:24 -06:00
Andy Miller
d9d241d806 fix for RecoveryManagerTest
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-10-16 07:47:50 -06:00
Andy Miller
bb5cdad333 require grav 1.7.50
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-10-15 20:55:50 -06:00
Andy Miller
44f90cbce0 Merge branch 'develop' into 1.8
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-10-15 20:22:14 -06:00
Andy Miller
a5c6f1dbe9 Merge branch 'feature/installer-rewrite' into develop 2025-10-15 20:15:29 -06:00
Andy Miller
c8227b38fc standalone grav-restore fixes
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-10-15 20:14:15 -06:00
Andy Miller
77114ecdd0 grav/restore dedicated binary
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-10-15 14:20:30 -06:00
Andy Miller
23da92d0ff honor staging_root
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-10-15 13:49:36 -06:00
Andy Miller
f88c09adca update GRAV_VERSION for testing
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-10-15 13:12:28 -06:00
Andy Miller
7dd5c8a0ba staging root config option
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-10-15 13:00:05 -06:00
Andy Miller
cf2ac28be2 bugfixes in safeupgradeservice
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-10-15 12:50:54 -06:00
Andy Miller
43ddf45057 latest tweak
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-10-15 12:42:38 -06:00
Andy Miller
57212ec9a5 better plugin checks
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-10-15 11:24:50 -06:00
Andy Miller
b55e86a8ba force upgrades before updating
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-10-15 11:00:54 -06:00
Andy Miller
2b1a7d3fb6 upgrade manager fix
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-10-15 10:44:19 -06:00
Andy Miller
250568bae5 initial safeupgrade work 2025-10-15 10:29:26 -06:00
Andy Miller
cc97e2ff45 prepare for beta release
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-10-14 14:30:24 -06:00
Andy Miller
d92c430b8a updated changelog
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-10-14 14:30:01 -06:00
Andy Miller
184cdea75d Merge branch 'develop' into 1.8
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-10-13 14:39:29 -06:00
Andy Miller
7b9567ec28 update vendor libs
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-10-13 14:35:58 -06:00
Nakkouch Tarek
75d8356f1b Fixed Twig Sandbox Bypass due to nested expression (#3939) 2025-10-13 13:36:49 -06:00
pmoreno.rodriguez
c82645a42a wordCount Filter for Grav (#3957) 2025-10-13 13:35:33 -06:00
Andy Miller
9e84d5d004 more fixes for Symfony7 PHP 8+
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-10-07 13:30:40 -06:00
Andy Miller
fd0d3dc463 PHP 8.4 fixes
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-10-07 13:18:04 -06:00
Andy Miller
eb985e875d vendor updates and some fixes
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-10-03 18:16:40 -06:00
Andy Miller
ba3493adce vendor update
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-10-03 16:13:05 -06:00
116 changed files with 12614 additions and 1662 deletions

View File

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

View File

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

@@ -48,3 +48,7 @@ tests/cache/*
tests/error.log
system/templates/testing/*
/user/config/versions.yaml
/user/data/recovery.window
tmp/*
/AGENTS.md
/.claude

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

@@ -72,7 +72,7 @@ config:
# Edit view
edit:
title:
template: "{{ form.value('fullname') ?? form.value('username') }} &lt;{{ form.value('email') }}&gt;"
template: "{{ form.value('fullname') ?? form.value('username') }}"
# Configure view
configure:

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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
View 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">&#10003;</span>
<div class="alert-content"><?php echo $notice; ?></div>
</div>
<?php endif; ?>
<?php if ($errorMessage): ?>
<div class="alert alert-error">
<span class="alert-icon">&#9888;</span>
<div class="alert-content">
<div class="alert-title">Action Failed</div>
<?php echo htmlspecialchars($errorMessage, ENT_QUOTES, 'UTF-8'); ?>
</div>
</div>
<?php endif; ?>
<div class="alert alert-warning">
<span class="alert-icon">&#9888;</span>
<div class="alert-content">
<div class="alert-title">A Fatal Error Occurred</div>
Grav detected a fatal error after a recent upgrade and has entered recovery mode to protect your site.
</div>
</div>
<div class="card">
<h2>Error Details</h2>
<div class="error-summary">
<div class="error-message"><?php echo htmlspecialchars($context['message'] ?? 'Unknown error', ENT_QUOTES, 'UTF-8'); ?></div>
<?php if (!empty($context['file'])): ?>
<div class="error-location">
<?php echo htmlspecialchars($context['file'], ENT_QUOTES, 'UTF-8'); ?><?php if (!empty($context['line'])): ?>:<?php echo htmlspecialchars((string)$context['line'], ENT_QUOTES, 'UTF-8'); ?><?php endif; ?>
</div>
<?php endif; ?>
</div>
<?php if (!empty($context['trace'])): ?>
<details>
<summary>View Stack Trace</summary>
<div class="stack-trace"><?php echo htmlspecialchars($context['trace'], ENT_QUOTES, 'UTF-8'); ?></div>
</details>
<?php endif; ?>
<?php if (!empty($context['plugin'])): ?>
<details open>
<summary>Affected Plugin</summary>
<ul class="info-list" style="margin-top: 12px;">
<li>
<span class="label">Plugin</span>
<span class="value"><strong><?php echo htmlspecialchars($context['plugin'], ENT_QUOTES, 'UTF-8'); ?></strong> (has been automatically disabled)</span>
</li>
</ul>
</details>
<?php endif; ?>
</div>
<?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 &amp; Continue</button>
</form>
<form method="post" style="display: contents;">
<input type="hidden" name="action" value="disable-recovery">
<button type="submit" class="btn btn-secondary" title="Prevents recovery mode from activating in the future">Disable Recovery Mode</button>
</form>
</div>
<p class="help-text">
<strong>Clear Recovery &amp; Continue:</strong> Clears the recovery flag and attempts to load your site normally.<br>
<strong>Disable Recovery Mode:</strong> Sets <code>updates.recovery_mode: false</code> in your configuration so recovery mode won't trigger again.
</p>
</div>
<?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'); ?> &mdash; 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>

View 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();
}

View 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();
}

View 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();
}

View File

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

View 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();
}

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

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

View 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
{
}

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -21,7 +21,6 @@ class GravCore extends AbstractPackageCollection
{
/** @var string */
protected $repository = 'https://getgrav.org/downloads/grav.json';
/** @var array */
private $data;
/** @var string */

View File

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

View File

@@ -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()) {

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View 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,
]);
}
}

View 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();
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -21,6 +21,11 @@ class PluginListCommand extends ConsoleCommand
{
protected static $defaultName = 'plugins:list';
public function __construct()
{
parent::__construct(self::$defaultName);
}
/**
* @return void
*/

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View 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
{
}

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

View 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;
/**
* 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));
}
}

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

View 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]);
}
}

View 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]]);
}
}

View 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