Compare commits

...

261 Commits

Author SHA1 Message Date
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
Andy Miller
d785042a0d set YamlUpdater get/set to public
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-09-22 15:20:53 -06:00
Andy Miller
49096b61f3 fixed sessions to use 1.7 style..
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-09-22 15:12:38 -06:00
Andy Miller
70e986074c prepare beta release
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-09-22 14:06:13 -06:00
Andy Miller
4af22edd36 add missing YamlLinter::exists() method
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-09-22 14:05:32 -06:00
Andy Miller
5bc7d6943f added cache check interval
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-09-22 11:24:50 -06:00
Andy Miller
8eb4085bcd opcache improvements for first hit
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-09-22 11:02:12 -06:00
Andy Miller
b47758e3c7 tweaked changelog
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-09-22 10:26:04 -06:00
Andy Miller
972ec26035 prepare for beta release
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-09-22 10:20:44 -06:00
Andy Miller
9116079e97 twig3 compatiblity layer
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-09-21 11:40:23 -06:00
Andy Miller
51ddb3984c rector casting clarity
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-09-20 22:49:21 -06:00
Andy Miller
22de638e52 composer updates + php fixes
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-09-20 22:33:56 -06:00
Andy Miller
365ab93e7e PHP 8.2+ fixes
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-09-20 22:12:55 -06:00
Andy Miller
c172964025 fix for cache blowing up
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-09-20 19:16:12 -06:00
Andy Miller
cb0bbcdb8b Deferred support in Twig 3
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-09-20 18:38:27 -06:00
Andy Miller
35f5d2f329 Merge branch 'develop' into 1.8
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-09-20 17:57:14 -06:00
Andy Miller
03849923d4 Merge tag '1.7.49.5' into develop
Release v1.7.49.5

# -----BEGIN PGP SIGNATURE-----
#
# iQIzBAABCAAdFiEEwbiolRD/eEYBHGp5nyzziuvbCuAFAmjB3LMACgkQnyzziuvb
# CuC9CQ/8CURB0sfbeDcjZbFIxNei0nGSCQkD8Wqp8IWOpSQ4iu5KiS6y6aQu7OIn
# LzvM6/wlbxJ6BhcYjt7uwMLQOH9mA4uPYMF7SQd5ElZZnoIp0zLcZ3CgDvznwOQj
# m2Kcuij3z749ORl8HjG56uhDwP5pk1C6u8OriGbb/z7uLTirLwxGE9yOoNJjlvWv
# K4ddGYcxF0OphRrIBmwbnMFwx+CoRBB1wg9pWtIba4cpgwu2OUYU9orNcVxGxPMI
# 8kXglIPrQZCKbzkjW2TNxdOVlycLKvw4J0jq+E8zgHVYFuS8yFX8JMR21wBxgCgH
# iCtigNUNwApvQafTpqWNhk+kY7nA2823NjaLNqIRRjEr3jQS7/zNorlRoaGyM7MB
# 8utneW8bH0Km2s6V4KDAKJex1Q3iE8KzFTIdM8kZ/Xnani8unrstHBAkcGkTWz0x
# NgKGEjB8moYb9+t+yRdXj7hcqHh4VkaRXm1Ac1UitaEfeEP+WwfyeBhOWBvIPDHQ
# vrdoSDWGvBHngR6Iq0cXKph7NzzQsxGVAPvKYVMlCtkFRDYNAsQOksVl5UunF5P9
# gpuLkANT7hr0WDWhCi7OMHgQW8IAMkiApp+phfrTK0GNBU2P7O+0MkeJ8LKd7mAi
# cOmZ/FDvtAzZUlwVpTiWuMq2MknwwfTU1BJblXxKGvt5Ysmx7HA=
# =gegb
# -----END PGP SIGNATURE-----
# gpg: Signature made Wed Sep 10 14:16:51 2025 MDT
# gpg:                using RSA key C1B8A89510FF7846011C6A799F2CF38AEBDB0AE0
# gpg: Good signature from "Andy Miller <rhuk@mac.com>" [ultimate]
2025-09-10 14:16:51 -06:00
Andy Miller
3664096550 Merge branch 'release/1.7.49.5' 2025-09-10 14:16:51 -06:00
Andy Miller
6664d98de8 prepare for release
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-09-10 14:16:37 -06:00
Andy Miller
17323ec76c prepare for release
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-09-10 14:16:04 -06:00
Andy Miller
9b9079bdd7 Backup not honoring ignored paths - fixes #3952
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-09-03 18:09:17 -06:00
Andy Miller
db3df738e8 Merge tag '1.7.49.4' into develop
Release v1.7.49.4

# -----BEGIN PGP SIGNATURE-----
#
# iQIzBAABCAAdFiEEwbiolRD/eEYBHGp5nyzziuvbCuAFAmi40TMACgkQnyzziuvb
# CuDw6g//edg76Q77jQBXrdYyuR+WChQrGE3G+AOBJmTjff7YMdQQZG3dl4bUK0L3
# 52Ft0q5dQHdn1gUyAgwxJBnH19TxNrjCC/GqBXFWFOnmJSiKzAwqB2FeWtgKTMY4
# 5RS7AmzCGEGDvqTzhIB9N1x1omHAFZDlKplevUhyTzQ+12/GKd1hIJ3V5JrlVmt2
# HRrOU403mOHEqfGH5Z4kPFM2nCddeHLGqBA/3kosh0Dq548pYgE26o3A02z537F0
# KwulRDITSoR8lN3fwq2MDjhCOJzu9MLiRcWl3s3nMcVC6LhYmZUdZjRvuBF6DmSH
# SdkZ3K1aibdknwWcm1cCPD23QwLblXr9zhTaXNvHEkSY7n8elKLxEVekh7OKDTFF
# gDdtIsaMHb891TucaF/Ejo0fFdwzacaoph4Lr+CYX/Xfs9eh2V+8+Y6hrG2ZOHdd
# M4GvI+U9+LjBbN3pKAbzRF6jiXCXl5qjJSrp7DQd4SBxXCOhO/VfTanXm+8I9f58
# Srhn9kzb2/EqQA64sGjZIZwrV7/yXfqoF5dBpVrsEi1sH4zzm0g9/DCwqGxAhH+b
# zNrxVlHu845wW7K8pWDQX0YbnvYcE83mzOhEYiP5qFUew03fDSIPs30QflApmy+8
# qfXLc4kM/XTMKYnq9febhmjLg2RXBMorxUovCaNPjHGXa0UaK4Q=
# =1m/V
# -----END PGP SIGNATURE-----
# gpg: Signature made Wed Sep  3 17:37:23 2025 MDT
# gpg:                using RSA key C1B8A89510FF7846011C6A799F2CF38AEBDB0AE0
# gpg: Good signature from "Andy Miller <rhuk@mac.com>" [ultimate]
2025-09-03 17:37:23 -06:00
Andy Miller
38075f9c86 Merge branch 'release/1.7.49.4' 2025-09-03 17:37:22 -06:00
Andy Miller
b0dac5f4b4 prepare for release
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-09-03 17:36:39 -06:00
Andy Miller
f7c77d1173 fix for cron force running
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-09-03 17:32:48 -06:00
Andy Miller
0fd734c8df Merge tag '1.7.49.3' into develop
Release v1.7.49.3

# -----BEGIN PGP SIGNATURE-----
#
# iQIzBAABCAAdFiEEwbiolRD/eEYBHGp5nyzziuvbCuAFAmi3XKUACgkQnyzziuvb
# CuCOzw//SUVGgXNxcvWvBGFya/vKhsO9V39buLzDx7DZk5acYA2E0SHN5aRucGmq
# 3p4GaXjHQ9aSRwIB3DTNoW9fWPGd8DFURLGTti7vh4EkvQclF1fI+22sNdlXrs6P
# uRO2DvuJ+5VFDNv7LzyNhx8HdYXlANrC8jFmBS2ehAgkTUQDI3T1JmY+/H/ayyik
# pFmzty9y3jy1IG/8qAhL+g8VPWccDIHTvxM+6EQjJvCQzHybrUk1nb3zjFGqsjQD
# ocIu+jwx05qvymmqvb0S5ULlqK6BBZTz2zBH3xF4viSF4ZF9YqDLhepOrA5wsVs+
# rNRSjUvMs05tIzA7stxwapxTB8s6C8Bm8mSu3XA+0ERtde5F3E40bhptDCwhlCLI
# dTgKb0+UaEcDPl1E7VSZJ8UlrZtvO3dtg5fTt7Ieebv0b1fbRoyxWNldhxbEcgDo
# HaeZAHNu8RMuTq36ACH7vgx2BEghAfe1/qXNfHtGokNdRxQSdvCUPGl8FS5hpBYG
# JVLOkS9mABKNENpqzWyp0pYJ0MORdrv74GnhY62/ue9mG4+Eo/kFcsZtoTUsFiHI
# 7eIhZTdazWxW27BkeI94g/B6sPxcQGFs8qHhaiOZLvveKvRJHynZez5zdHnm2X1G
# 4qa5m2ebjGpxhKK8zDF1nlZEKPsaK6ZZQid4o8LL1nv6PwRcsIk=
# =xk/M
# -----END PGP SIGNATURE-----
# gpg: Signature made Tue Sep  2 15:07:49 2025 MDT
# gpg:                using RSA key C1B8A89510FF7846011C6A799F2CF38AEBDB0AE0
# gpg: Good signature from "Andy Miller <rhuk@mac.com>" [ultimate]
2025-09-02 15:07:49 -06:00
Andy Miller
b642b2c999 Merge branch 'release/1.7.49.3' 2025-09-02 15:07:48 -06:00
Andy Miller
ae147fa53b prepare for release
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-09-02 15:07:38 -06:00
Andy Miller
9e6df39bff fix duplicate job issue
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-09-02 15:05:38 -06:00
Andy Miller
8ac6076f88 allow you to run -r and -d in command line together
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-09-02 15:05:30 -06:00
Andy Miller
cef7812472 fix an error in ZipArchive that sometimes occurs
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-09-02 15:04:51 -06:00
Andy Miller
6f461395a7 update changelog
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-09-02 14:41:40 -06:00
Andy Miller
162fe1acc2 namespace change for cron expression
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-09-02 14:40:44 -06:00
Andy Miller
332748a1f9 removed hardcoded setup commands
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-09-02 14:08:46 -06:00
Andy Miller
1a9a60115d Merge branch 'release/1.7.49.2' 2025-08-28 11:37:21 -06:00
Andy Miller
a55052b8b0 Merge tag '1.7.49.2' into develop
Release v1.7.49.2

# -----BEGIN PGP SIGNATURE-----
#
# iQIzBAABCAAdFiEEwbiolRD/eEYBHGp5nyzziuvbCuAFAmiwk9EACgkQnyzziuvb
# CuDx/A//dPvNxTkFAMbAy6j7W1UrinLF8QZV7jnSR8ig8LYN6gprwRVWKOT8Oui2
# vyfgvt9NfOHaW29EDXGdHRIeuqORTxztHOY/PrsRdP7zKQzZcNTJ4LjEcR2uGc1S
# UndhkPr4KL2hOq66JgoFif8O7Z0zl7EjLshy2d9pA7PU0LXxbLzYhNEmvkZjdLSZ
# RBmq0GmpQGTFV7l+4Rrsr0nK9KX8D/YgbBXwOo9oxIHMQzcH0i0MXFCIZdKX6V6V
# iKPbbA5A4xq7+F3zufUld2PSMIyfsdyPuoA2uJKeMGIdj4hjPUHhsOY2688EUyyf
# MRqLwoX/ZSeT5dIacXitNrS9vhLWwmxvIbNrGbZn/Jrppmwcj6r6ACkbJwkOPjaN
# kSSu6gU6SFEc7gILZXhNH1e5DCE7r1VjTDr/BoebQGZHQWafr9PG5lMMXhZRWpo+
# zPvn5cRKtI8/zDZwwR9RzJx/lUkaO7pJNg5tW5z4oo/XBmtwxYoD++JMxF3BqQvy
# Q1e+uyWhi01YpG+ofPKA5tRbD0H1++N5e5gghqhqdmazLZV3Doz2igRQUy6eVwxL
# KoSZdweJLDIFPIoSMCchU0O9wR+ZAVNNdYuQe9snezNYiBJqXcXOq3nLiEJhMVV+
# CySVvLrM85Yq0cOq6FXsVsYwHhnH8PeW8ebs7+c8EPmzxrLHAdk=
# =qw1P
# -----END PGP SIGNATURE-----
# gpg: Signature made Thu Aug 28 11:37:21 2025 MDT
# gpg:                using RSA key C1B8A89510FF7846011C6A799F2CF38AEBDB0AE0
# gpg: Good signature from "Andy Miller <rhuk@mac.com>" [ultimate]
2025-08-28 11:37:21 -06:00
Andy Miller
dc7354e7e1 prepare for release
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-08-28 11:37:09 -06:00
pmoreno.rodriguez
afc1513aad Fix translation key for image adapter label and help (#3944) 2025-08-25 18:56:12 +01:00
Andy Miller
466b2a16e8 Merge branch 'release/1.7.49.1' 2025-08-25 14:16:45 +01:00
Andy Miller
a21640ace6 Merge tag '1.7.49.1' into develop
Release v1.7.49.1

# -----BEGIN PGP SIGNATURE-----
#
# iQIzBAABCAAdFiEEwbiolRD/eEYBHGp5nyzziuvbCuAFAmisYj0ACgkQnyzziuvb
# CuA6NxAAmqhEkRNcLhnyVDyZrwcinrANkYvD/Z9MKoy9NQfUaQaQmb5v7rl2Qj+T
# etmPUj9qNQI1KpDrwB2lYVAvvrQoVt73Y72GsnX/6OwNtsVNWCT2NhWN8MkRwu5d
# 6h+k7jhiotVAGMGDiI+Srk7v1LbXj+Y29pOalUDA+fZFRVHdXmQGFrYafV7LmMvH
# 7kMRS3Sj8oPII9BTBAWCiBEQ/DVil9Q+RuKwL35u76RRbh3yYJRO6fZym+IWD4YW
# ARXaWDsfG7ayoW12dg+Ufj4yooCxIghLFEXyPBr/2FqXn9/iAndQlEINGN5dSya/
# PvmhWer0bH2Wz3NUFqkTpBoK8QPjaPJThGhl2C6O2RscXIIrAvKa+VViWzNE7AU4
# Jdx7quFyC3xuElxBEPF99vabBpisxdzdtyu5v5VhlpTClNTQswbnRjax7qrUKHt8
# SLzp6tdM3SKE/AN+nXtexpVWjZTUaskl241/MPjRORtatniWSnemddjbfU9aBPmF
# /B8HjRFzGT+UFHt8b+AnP02Bt032JEdPvL8TfEAmfO55WiMpK6+Qutr2oogOznrN
# zn2D+1rUNs+8V3lKKJwaGe4nNe/BVZ1yoPQXWu3q8Z2cxJXRY2+4L66y5RdoNoew
# 1S2HyESWInAjVwHKIoyGoeabnnhVTHJpSLb8fiLNp017BTIISto=
# =LlNI
# -----END PGP SIGNATURE-----
# gpg: Signature made Mon Aug 25 14:16:45 2025 BST
# gpg:                using RSA key C1B8A89510FF7846011C6A799F2CF38AEBDB0AE0
# gpg: Good signature from "Andy Miller <rhuk@mac.com>" [ultimate]
2025-08-25 14:16:45 +01:00
Andy Miller
09920deabc prepare for release
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-08-25 14:16:32 +01:00
Andy Miller
0bd72f4bb9 Merge tag '1.7.49' into develop
Release v1.7.49

# -----BEGIN PGP SIGNATURE-----
#
# iQIzBAABCAAdFiEEwbiolRD/eEYBHGp5nyzziuvbCuAFAmisWskACgkQnyzziuvb
# CuDTlg//TOFe5VqIBV03SDHVFW3frF/nK+2pOGUXgfLXN9sfPHrT8F89drsPDBar
# YwxTynix8NvPXumt/DeeGvEtDJOZt8UYDUiAlTc++F3duNGjCBId/H1XqRKk91jL
# jilxrXPboemQ+nqzVFTcACFi5/GjYKXvscOVPJ6Pflr/EsLjUfzIF11zXG2vCJsv
# rdI2W3J+cGS6VoaxvGF8vfC9k1g+rC9E1bKcj5wSI98bVQf5HCR8kLRxfyfz4fvX
# w0mLvI9qCqJ4CsIa7kLw5AxPI3bFLBQjhQtiuH6WJMNkUNvcUJ51MmU5S4hEZMLt
# BSQAD4xfG5Hb98YZaQyISBVMw3ed4rA3MLYfHVi6kYmL5FHl3lTSIL8RTe9sQSqI
# cojdIlCNJTAOL65TQik78BqmjjfMzi28B8DBCfKF5ZL5SrpHDYNqcKojzSscbytJ
# S7h9mucquI5MaMDKdMi79vZHPrmbk21ML6DbuefVxpGHDQbLGflVIpyDRzMOgvzs
# MPtYVo8gG7ljlD0EcVkUO4lZgSrImw2Ko6U8RccF8rzSG2ZmrlQEik10t/e6SorO
# g20CbctHKYgWq38rkgZmmJ+mUrtpHaYI2N/HrCTPqaaqDV0toG8lvM2/TyKq7TKh
# Aq9Kwhqz9mqDnMqMqb0l0SjA2DFdIKc9Y3Uzqi/VXRgxOmjDoq8=
# =3bpd
# -----END PGP SIGNATURE-----
# gpg: Signature made Mon Aug 25 13:44:57 2025 BST
# gpg:                using RSA key C1B8A89510FF7846011C6A799F2CF38AEBDB0AE0
# gpg: Good signature from "Andy Miller <rhuk@mac.com>" [ultimate]
2025-08-25 13:44:58 +01:00
Andy Miller
ec55a80183 Merge branch 'release/1.7.49' 2025-08-25 13:44:57 +01:00
Andy Miller
d07ac5b6ea prepare for release
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-08-25 13:44:47 +01:00
Andy Miller
fa29f6672a updated changelog + vendor libs again
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-08-25 13:37:44 +01:00
Andy Miller
006d8c85a0 Merge branch 'feature/modern-scheduler' into develop 2025-08-25 13:27:37 +01:00
Andy Miller
c608ed10cf implement a better purge strategy
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-08-25 13:27:23 +01:00
Andy Miller
9d71de8e54 more scheduler improvements
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-08-25 12:53:47 +01:00
Andy Miller
89764a51fb more scheduler fixes
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-08-25 12:36:06 +01:00
Andy Miller
b851d9bf9d added scheduler logging
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-08-24 23:58:28 +01:00
Andy Miller
a0679fc050 scheduler improvements
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-08-24 22:23:18 +01:00
Andy Miller
e497a93da6 simplify extended jobs logic
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-08-24 22:14:14 +01:00
Andy Miller
56cc894c1d initial improved schedular functionality
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-08-24 20:27:28 +01:00
Andy Miller
d07f3770bc chagne order
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-08-17 13:12:08 +01:00
Andy Miller
3baaf19c31 update lock file
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-08-16 13:00:39 +01:00
Andy Miller
7236862a15 Add Imagick adapter support
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-08-16 12:52:55 +01:00
Andy Miller
639be5ac0d pages optimizations
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-06-25 16:17:24 -06:00
Andy Miller
8811b7aad0 revert PHP 8.4 fixes
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-06-12 10:41:38 -06:00
Andy Miller
4a22f3dc8d better match
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-06-05 11:22:28 -06:00
Andy Miller
d3e32799ab use forked copy of parsedown 1.7.x for PHP 8.4 compatibility
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-04-02 05:34:07 -06:00
Andy Miller
3bebfc6dac fixed for tests
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-04-01 17:03:42 -06:00
Andy Miller
9d80a4d992 upgraded to latest phpdebugbar + fixed browser caching
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-04-01 16:55:29 -06:00
Andy Miller
fc7f72f89d Merge branch 'develop' into 1.8
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-03-31 19:32:54 -06:00
Andy Miller
fa56984dc3 use lang string
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-03-31 19:26:01 -06:00
Andy Miller
90fd68d4a5 handle empty value on require with ignore
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-03-31 14:12:05 -06:00
Andy Miller
7613e38b6d Add support for validate match
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-03-31 14:11:35 -06:00
Andy Miller
830a442faa update vendor libs
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-03-30 15:23:22 -06:00
Andy Miller
8d7b658aa6 Some fixes that impacted file uploads
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-03-30 15:22:45 -06:00
Andy Miller
83d098b891 throws error
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-03-07 12:06:58 -07:00
Rotzbua
d798859acd chore(ci): update GH Actions php test (#3867)
- php versions should be strings
- caching updated to current recommendation
- fixed env var name
2025-02-05 11:16:51 -07:00
Andy Miller
08d74df6e3 Fix parse error: #3894
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-01-24 15:23:06 -07:00
160 changed files with 16673 additions and 2244 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

@@ -10,20 +10,18 @@ permissions:
contents: read # to fetch code (actions/checkout)
jobs:
unit-tests:
strategy:
matrix:
php: [8.5, 8.4, 8.3]
os: [ubuntu-latest]
runs-on: ${{ matrix.os }}
strategy:
matrix:
php: [8.4, 8.3, 8.2]
os: [ubuntu-latest]
steps:
- uses: actions/checkout@v2
- uses: actions/checkout@v4
- name: Setup PHP
- name: Setup PHP ${{ matrix.php }}
uses: shivammathur/setup-php@v2
with:
php-version: ${{ matrix.php }}
@@ -31,17 +29,11 @@ jobs:
tools: composer:v2
coverage: none
env:
COMPOSER_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# - name: Update composer
# run: composer update
#
# - name: Validate composer.json and composer.lock
# run: composer validate
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Get composer cache directory
id: composer-cache
run: echo "::set-output name=dir::$(composer config cache-files-dir)"
run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT
- name: Cache dependencies
uses: actions/cache@v4
@@ -54,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,199 @@
# v1.8.0-beta.28
## 12/03/2025
1. [](#bugfix)
* 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
1. [](#bugfix)
* Changed `private` to `public` for YamlUpdater::get() and YamUpdater::set() methods
* Fixed a session cookie issue that manifested when logging-in to client side
# v1.8.0-beta.6
## 09/22/2025
1. [](#bugfix)
* Fixed a missing YamlUpdater::exists() method
# v1.8.0-beta.5
## 09/22/2025
1. [](#new)
* Deferred Extension support in Forked version of Twig 3
* Added separate `strict_mode.twig2_compat` and `strict_mode.twig3_compat` toggles to manage auto-escape behaviour and automatic Twig 3 compatible template rewrites
1. [](#bugfix)
* Fix for cache blowing up when upgrading from 1.7 to 1.8 via CLI
# v1.8.0-beta.4
## 01/27/2025
@@ -36,7 +232,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**
@@ -47,6 +243,67 @@
* 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
1. [](#bugfix)
* Backup not honoring ignored paths [#3952](https://github.com/getgrav/grav/issues/3952)
# v1.7.49.4
## 09/03/2025
1. [](#bugfix)
* Fixed cron force running jobs severy minute! [#3951](https://github.com/getgrav/grav/issues/3951)
# v1.7.49.3
## 09/02/2025
1. [](#bugfix)
* Fixed an error in ZipArchive that was causing issues on some systems
* Fixed namespace change for `Cron\Expression`
* Removed broken cron install field... use 'instructions' instead
* Fixed duplicate jobs listing in some CLI commands
# v1.7.49.2
## 08/28/2025
1. [](#bugfix)
* Fix translation of key for image adapter [#3944](https://github.com/getgrav/grav/pull/3944)
# v1.7.49.1
## 08/25/2025
1. [](#new)
* Rerelease to include all updated plugins/theme etc.
# v1.7.49
## 08/25/2025
1. [](#new)
* Revamped Grav Scheduler to support webhook to call call scheduler + concurrent jobs + jobs queue + logging, and other improvements
* Revamped Grav Cache purge capabilities to only clear obsolete old cache items
* Added full imagick support in Grav Image library
* Added support for Validate `match` and `match_any` in forms
1. [](#improved)
* Handle empty values on require with ignore fields in Forms
* Use `actions/cache@v4` in github workflows
* Use `actions/checkout@v4`in github workflows [#3867](https://github.com/getgrav/grav/pull/3867)
* Update code block in README.md [#3886](https://github.com/getgrav/grav/pull/3886)
* Updated vendor libs to latest
1. [](#bugfix)
* 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)
# 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",
"twig/twig": "2.x-dev",
"monolog/monolog": "^2.0",
"doctrine/cache": "^2.2",
"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": "^3.0",
"doctrine/collections": "^2.2",
"pimple/pimple": "~3.5.0",
"nyholm/psr7-server": "^1.1",
"nyholm/psr7": "^1.8",
"erusev/parsedown": "^1.7",
@@ -49,15 +47,16 @@
"rockettheme/toolbox": "v2.x-dev",
"composer/ca-bundle": "^1.5",
"composer/semver": "^3.4",
"dragonmantank/cron-expression": "^3.0",
"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",
"itsgoingd/clockwork": "^5.3",
"maximebf/debugbar": "~1.23",
"php-debugbar/php-debugbar": "~2.1",
"getgrav/image": "^4.0",
"getgrav/cache": "^2.0",
"antoligy/dom-string-iterators": "^1.0",
@@ -67,13 +66,13 @@
},
"require-dev": {
"codeception/codeception": "^5.1",
"phpstan/phpstan": "^1.12",
"phpstan/phpstan-deprecation-rules": "^1.2",
"phpunit/php-code-coverage": "~9.2",
"phpstan/phpstan": "^2.1",
"phpstan/phpstan-deprecation-rules": "^2.0",
"phpunit/php-code-coverage": "^11.0",
"getgrav/markdowndocs": "^2.0",
"codeception/module-asserts": "*",
"codeception/module-phpbrowser": "*",
"rector/rector": "^1.2"
"rector/rector": "^2.1"
},
"repositories": [
{
@@ -83,6 +82,10 @@
{
"type": "vcs",
"url": "https://github.com/getgrav/twig"
},
{
"type": "vcs",
"url": "https://github.com/getgrav/parsedown"
}
],
"replace": {
@@ -103,7 +106,7 @@
"config": {
"apcu-autoloader": true,
"platform": {
"php": "8.2"
"php": "8.3"
}
},
"autoload": {
@@ -111,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",
@@ -133,11 +137,12 @@
"api-18": "vendor/bin/phpdoc-md generate system/src > user/pages/14.api/default.18.md",
"post-create-project-cmd": "bin/grav install",
"rector": "vendor/bin/rector",
"rector:php-compat": "@php vendor/bin/rector process --config=system/rector.php --ansi --no-progress-bar",
"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": {

2711
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,16 +77,28 @@ date_default_timezone_set(@date_default_timezone_get());
@ini_set('default_charset', 'UTF-8');
mb_internal_encoding('UTF-8');
$recoveryFlag = __DIR__ . '/user/data/recovery.flag';
if (PHP_SAPI !== 'cli' && is_file($recoveryFlag)) {
require __DIR__ . '/system/recovery.php';
return 0;
}
use Grav\Common\Grav;
use RocketTheme\Toolbox\Event\Event;
// Get the Grav instance
$grav = Grav::instance(array('loader' => $loader));
$grav = Grav::instance(['loader' => $loader]);
// Process the page
try {
$grav->process();
} catch (\Error|\Exception $e) {
$grav->fireEvent('onFatalException', new Event(array('exception' => $e)));
$grav->fireEvent('onFatalException', new Event(['exception' => $e]));
if (PHP_SAPI !== 'cli' && is_file($recoveryFlag)) {
require __DIR__ . '/system/recovery.php';
return 0;
}
throw $e;
}

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

@@ -1,67 +1,5 @@
div.phpdebugbar {
font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
div.phpdebugbar a.phpdebugbar-restore-btn::after {
background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAMAAACdt4HsAAAA/1BMVEUAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAeHh4AAAD///8EBAT7+/sLCwv29vYVFRUvLy/t7e3m5ubCwsKxsbE/Pz+mpqZMTEwcHBzy8vLp6emfn5+AgIA2Njbi4uLf39+rq6tzc3NWVlYhISHa2trW1tbS0tLMzMy7u7uZmZmUlJSMjIxvb29kZGRHR0c7Ozt5eXkqKiq1tbWQkJBqampbW1tSUlLHx8eHh4ckJCRDQ0M3wD42AAAAI3RSTlMA/PibTbQ0x76TVAlw4LhZLOuEYCAN9Hjx0a2ppGZEGYw97djhXHwAAATZSURBVFjDlVcHW+MwDO1eFCjj2McNOzvdpXTTXVbL/P+/5SQ7QSSX5Di1X1onfi/Sk+Q4sTDbKqWK+YuznZ2zi3wxVdqK/Zf92M1nT9gnO8rmd398GX6Z3xaoOFoiAQcx3E5efgmeSuN8F6Xg1x3G06l/wjNpMR1B0uif4EhnIuFb+0diIoFXk3IVfokisR+h52GO4JKgyjmfaMhAFNlSaPR7DpwI+lzn/E4QKIqmKIJirxCMP4izBPPZPXhgXwMBYgULw0nfg/BF5scDbslb7QeJ08yqqTEmGYoB95d4H8ETL8+n9wBqrLu6ao3bBsMwAnxISf/9BHcqxNB8Y7cWl3Zz7TAUfPrvAT6AoNEFFXvsjutL01yOuMrtBxnFXsmT/1wQHmdWAFNnI3uI48Yj0FUcHbKf62GfUfr8eeQt7Uk3mQZpZNoVRPEui5vtEz5zFEpgWnyqVBZMc6oaGNriH2hGVZ0OxEvInPeMaZWJBA7vmPbCr5jjws5HBnAUxvDMH40aCIf4G5BjRQSs8E8HFFYf8bGxgDvD55bzGhwWkoBcuIyHR/AMdaCagxXDhtL6tSqoWpd4BMnlIR+Or+rYTK/a3EAGcc6e4AWHISnWv20iCCojsHoVlQdjrMexFF2C7UMg2A2WEGWbQhXN6l3eXC6XGp4b9qxbuEB2EBGBwtocrK90cVG5mbRXm6vmx/0phq1sIAGKDgLOBiN1MrO5a9aDl+D0W6x0Ar9BCTRuIIANa90Y7LrLVRXzwVtDInCqMRWcf2bUOEAsa4wJqFowQALL9EiAtVRk8QC4OW+1pOM9jIaVASwYagyNXDj+W0NcfuZNzjtXOiL0Zzg30Llj+ptfxQs4+vBPNiL5PawFCBkgXpUaVtqGl+A8dgZHL34BcBUQrwPptToW+o37Ku+UH9eYByJIx3YkAeFnMFuGO7S5gEp7YhXxa5OOAM39RXDPXb0qmpROsswZe+twXdU55oUIZAiEv3bD1UFwIYKkmGqytPCDCwKFQCKK0yL7qtSAPX54UAbtsLuBHkb9zyLmPQSNjsSgmQwKUOIfEY8F8t4B34DvndJY9BA8tNBJq1Nev9axmaStFcQLhgYoCTo0salkIaW8OUDdWjMTR2sHPhrAFZqx6cqcKE4pl2BJJ4K6hfwvqNgAnXfKX/HU6X3Zrhnu0k7tLNZtTBRv1hkwTDBY1NzFU6doDYjJbWdQkQhWwuU7/LvhTh3SDoco4ECL4i5dwURbc8NdDZz2IwKicE8d0KIqWetLE3+lL4hvUuGSeRfVWNLfj/gpOw4smBJBkKQHCzlHGwvAj4woB1gq5NGGLSXtORBPnUQPV5/MPVkDMxbpwG7w4x0xL6Ltxka0A/4NBvV09UVk4DoSn/jl2+JQS9q9KYawisAD4CfhsZ4TH3htylsdEHARIQBusqCKyUpymycgbbkkXEXjT3z7/oKQFTFVuZD2FMJHZIDsO5x2d4aAr2jR+GLwZhtAb028/0yJ9J8dE87jQyKObcjtTXT8dH+fDuKF4/eiPwzH44wTf/yUi6wrpRIOZ9lM1EtXAifFI+CJn9+iX/t2xMQwOMth/UZbASi8btAwR9FHWSpJr75g9Oqbin3VDg+SpwlP6k6TB4ex/7JvmcJx8jydy6XPk8eFTKhyfwCgX71MSvaBHgAAAABJRU5ErkJggg==) !important;
width: 32px;
}
.phpdebugbar pre {
padding: 1rem;
}
.phpdebugbar div.phpdebugbar-header > div > * {
padding: 5px 15px;
}
.phpdebugbar div.phpdebugbar-header > div.phpdebugbar-header-right > * {
padding: 5px 8px;
}
.phpdebugbar a.phpdebugbar-restore-btn {
background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAMAAACdt4HsAAAA/1BMVEUAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAeHh4AAAD///8EBAT7+/sLCwv29vYVFRUvLy/t7e3m5ubCwsKxsbE/Pz+mpqZMTEwcHBzy8vLp6emfn5+AgIA2Njbi4uLf39+rq6tzc3NWVlYhISHa2trW1tbS0tLMzMy7u7uZmZmUlJSMjIxvb29kZGRHR0c7Ozt5eXkqKiq1tbWQkJBqampbW1tSUlLHx8eHh4ckJCRDQ0M3wD42AAAAI3RSTlMA/PibTbQ0x76TVAlw4LhZLOuEYCAN9Hjx0a2ppGZEGYw97djhXHwAAATZSURBVFjDlVcHW+MwDO1eFCjj2McNOzvdpXTTXVbL/P+/5SQ7QSSX5Di1X1onfi/Sk+Q4sTDbKqWK+YuznZ2zi3wxVdqK/Zf92M1nT9gnO8rmd398GX6Z3xaoOFoiAQcx3E5efgmeSuN8F6Xg1x3G06l/wjNpMR1B0uif4EhnIuFb+0diIoFXk3IVfokisR+h52GO4JKgyjmfaMhAFNlSaPR7DpwI+lzn/E4QKIqmKIJirxCMP4izBPPZPXhgXwMBYgULw0nfg/BF5scDbslb7QeJ08yqqTEmGYoB95d4H8ETL8+n9wBqrLu6ao3bBsMwAnxISf/9BHcqxNB8Y7cWl3Zz7TAUfPrvAT6AoNEFFXvsjutL01yOuMrtBxnFXsmT/1wQHmdWAFNnI3uI48Yj0FUcHbKf62GfUfr8eeQt7Uk3mQZpZNoVRPEui5vtEz5zFEpgWnyqVBZMc6oaGNriH2hGVZ0OxEvInPeMaZWJBA7vmPbCr5jjws5HBnAUxvDMH40aCIf4G5BjRQSs8E8HFFYf8bGxgDvD55bzGhwWkoBcuIyHR/AMdaCagxXDhtL6tSqoWpd4BMnlIR+Or+rYTK/a3EAGcc6e4AWHISnWv20iCCojsHoVlQdjrMexFF2C7UMg2A2WEGWbQhXN6l3eXC6XGp4b9qxbuEB2EBGBwtocrK90cVG5mbRXm6vmx/0phq1sIAGKDgLOBiN1MrO5a9aDl+D0W6x0Ar9BCTRuIIANa90Y7LrLVRXzwVtDInCqMRWcf2bUOEAsa4wJqFowQALL9EiAtVRk8QC4OW+1pOM9jIaVASwYagyNXDj+W0NcfuZNzjtXOiL0Zzg30Llj+ptfxQs4+vBPNiL5PawFCBkgXpUaVtqGl+A8dgZHL34BcBUQrwPptToW+o37Ku+UH9eYByJIx3YkAeFnMFuGO7S5gEp7YhXxa5OOAM39RXDPXb0qmpROsswZe+twXdU55oUIZAiEv3bD1UFwIYKkmGqytPCDCwKFQCKK0yL7qtSAPX54UAbtsLuBHkb9zyLmPQSNjsSgmQwKUOIfEY8F8t4B34DvndJY9BA8tNBJq1Nev9axmaStFcQLhgYoCTo0salkIaW8OUDdWjMTR2sHPhrAFZqx6cqcKE4pl2BJJ4K6hfwvqNgAnXfKX/HU6X3Zrhnu0k7tLNZtTBRv1hkwTDBY1NzFU6doDYjJbWdQkQhWwuU7/LvhTh3SDoco4ECL4i5dwURbc8NdDZz2IwKicE8d0KIqWetLE3+lL4hvUuGSeRfVWNLfj/gpOw4smBJBkKQHCzlHGwvAj4woB1gq5NGGLSXtORBPnUQPV5/MPVkDMxbpwG7w4x0xL6Ltxka0A/4NBvV09UVk4DoSn/jl2+JQS9q9KYawisAD4CfhsZ4TH3htylsdEHARIQBusqCKyUpymycgbbkkXEXjT3z7/oKQFTFVuZD2FMJHZIDsO5x2d4aAr2jR+GLwZhtAb028/0yJ9J8dE87jQyKObcjtTXT8dH+fDuKF4/eiPwzH44wTf/yUi6wrpRIOZ9lM1EtXAifFI+CJn9+iX/t2xMQwOMth/UZbASi8btAwR9FHWSpJr75g9Oqbin3VDg+SpwlP6k6TB4ex/7JvmcJx8jydy6XPk8eFTKhyfwCgX71MSvaBHgAAAABJRU5ErkJggg==);
width: 13px;
}
.phpdebugbar a.phpdebugbar-tab.phpdebugbar-active {
background: #3DB9EC;
color: #fff;
margin-top: -1px;
padding-top: 6px;
}
.phpdebugbar .phpdebugbar-widgets-toolbar {
border-top: 1px solid #ddd;
padding-left: 5px;
padding-right: 2px;
padding-top: 2px;
background-color: #fafafa !important;
width: auto !important;
left: 0;
right: 0;
}
.phpdebugbar .phpdebugbar-widgets-toolbar input {
background: transparent !important;
}
.phpdebugbar .phpdebugbar-widgets-toolbar .phpdebugbar-widgets-filter {
}
.phpdebugbar input[type=text] {
padding: 0;
display: inline;
}
.phpdebugbar dl.phpdebugbar-widgets-varlist, ul.phpdebugbar-widgets-timeline li span.phpdebugbar-widgets-label {
font-family: "DejaVu Sans Mono", Menlo, Monaco, Consolas, Courier, monospace;
font-size: 12px;
}
ul.phpdebugbar-widgets-timeline li span.phpdebugbar-widgets-label {
text-shadow: -1px -1px 0 #fff, 1px -1px 0 #fff, -1px 1px 0 #fff, 1px 1px 0 #fff;
top: 0;
}
.phpdebugbar pre, .phpdebugbar code {
margin: 0;
font-size: 14px;
}

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

@@ -4,74 +4,788 @@ form:
validation: loose
fields:
scheduler_tabs:
type: tabs
active: 1
status_title:
type: section
title: PLUGIN_ADMIN.SCHEDULER_STATUS
underline: true
fields:
status_tab:
type: tab
title: PLUGIN_ADMIN.SCHEDULER_STATUS
status:
type: cronstatus
validate:
type: commalist
fields:
status_title:
type: section
title: PLUGIN_ADMIN.SCHEDULER_STATUS
underline: true
jobs_title:
type: section
title: PLUGIN_ADMIN.SCHEDULER_JOBS
underline: true
status:
type: cronstatus
validate:
type: commalist
webhook_status_override:
type: display
label:
content: |
<script>
(function() {
function updateSchedulerStatus() {
// Find all notice bars
var notices = document.querySelectorAll('.notice');
var webhookStatusChecked = false;
// Check for modern scheduler and webhook settings
fetch(window.location.origin + '/grav-editor-pro/scheduler/health')
.then(response => response.json())
.then(data => {
if (data.webhook_enabled) {
notices.forEach(function(notice) {
if (notice.textContent.includes('Not Enabled for user:')) {
// This is the cron status notice - replace it
notice.className = 'notice info';
notice.innerHTML = '<i class="fa fa-fw fa-check-circle"></i> <strong>Webhook Active</strong> - Scheduler can be triggered via webhook. Cron is not configured.';
}
});
// Also update the main status if it exists
var statusDiv = document.querySelector('.cronstatus-status');
if (statusDiv && statusDiv.textContent.includes('Not Enabled')) {
statusDiv.className = 'cronstatus-status success';
statusDiv.innerHTML = '<i class="fa fa-fw fa-check"></i> Webhook Ready';
}
}
})
.catch(error => {
console.log('Webhook status check failed:', error);
});
}
// Run on page load
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', updateSchedulerStatus);
} else {
updateSchedulerStatus();
}
// Also run after a short delay to catch any late-rendered elements
setTimeout(updateSchedulerStatus, 500);
})();
</script>
markdown: false
status_enhanced:
type: display
label:
content: |
<script>
document.addEventListener('DOMContentLoaded', function() {
// Check if webhook is enabled
var webhookEnabled = document.querySelector('[name="data[scheduler][modern][webhook][enabled]"]:checked');
var statusDiv = document.querySelector('.cronstatus-status');
// Also find the parent notice bar
var noticeBar = document.querySelector('.notice.alert');
if (statusDiv) {
var currentStatus = statusDiv.textContent || statusDiv.innerText;
var cronReady = currentStatus.includes('Ready');
var cronNotEnabled = currentStatus.includes('Not Enabled');
// Check if scheduler-webhook plugin exists
var webhookPluginInstalled = false;
fetch(window.location.origin + '/grav-editor-pro/scheduler/health')
.then(response => response.json())
.then(data => {
webhookPluginInstalled = true;
updateStatusDisplay(data);
})
.catch(error => {
updateStatusDisplay(null);
});
function updateStatusDisplay(healthData) {
var isWebhookEnabled = webhookEnabled && webhookEnabled.value == '1';
var isWebhookReady = webhookPluginInstalled && isWebhookEnabled && healthData && healthData.webhook_enabled;
// Update the main status text
var mainStatusText = '';
var mainStatusClass = '';
if (cronReady && isWebhookReady) {
mainStatusText = 'Cron and Webhook Ready';
mainStatusClass = 'success';
} else if (cronReady) {
mainStatusText = 'Cron Ready';
mainStatusClass = 'success';
} else if (isWebhookReady) {
mainStatusText = 'Webhook Ready (No Cron)';
mainStatusClass = 'success'; // Changed from warning to success
} else if (cronNotEnabled && !isWebhookReady) {
mainStatusText = 'Not Configured';
mainStatusClass = 'error';
} else {
mainStatusText = 'Configuration Pending';
mainStatusClass = 'warning';
}
// Update the notice bar if webhooks are ready
if (noticeBar && isWebhookReady) {
// Change from error (red) to success (green) or info (blue)
noticeBar.classList.remove('alert');
noticeBar.classList.add('info');
var noticeIcon = noticeBar.querySelector('i.fa');
if (noticeIcon) {
noticeIcon.classList.remove('fa-times-circle');
noticeIcon.classList.add('fa-check-circle');
}
var noticeText = noticeBar.querySelector('strong') || noticeBar;
var username = noticeText.textContent.match(/user:\s*(\w+)/);
if (username) {
noticeText.innerHTML = 'Webhook Ready for user: <b>' + username[1] + '</b> (Cron not configured)';
} else {
noticeText.innerHTML = mainStatusText;
}
}
// Update the main status div
if (statusDiv) {
statusDiv.innerHTML = '<i class="fa fa-fw fa-' +
(mainStatusClass === 'success' ? 'check' : mainStatusClass === 'warning' ? 'exclamation' : 'times') +
'"></i> ' + mainStatusText;
statusDiv.className = 'cronstatus-status ' + mainStatusClass;
}
// Update install instructions button/content
var installButton = document.querySelector('.cronstatus-install-button');
var installDiv = document.querySelector('.cronstatus-install');
if (installDiv) {
var installHtml = '<div class="alert alert-info">';
installHtml += '<h4>Setup Instructions:</h4>';
var hasInstructions = false;
// Cron setup
if (!cronReady) {
installHtml += '<p><strong>Option 1: Traditional Cron</strong><br>';
installHtml += 'Run: <code>bin/grav scheduler --install</code><br>';
installHtml += 'This will add a cron job that runs every minute.</p>';
hasInstructions = true;
}
// Webhook setup
if (!webhookPluginInstalled) {
installHtml += '<p><strong>Option 2: Webhook Support</strong><br>';
installHtml += '1. Install plugin: <code>bin/gpm install scheduler-webhook</code><br>';
installHtml += '2. Configure webhook token in Advanced Features tab<br>';
installHtml += '3. Use webhook URL in your CI/CD or cloud scheduler</p>';
hasInstructions = true;
} else if (!isWebhookEnabled) {
installHtml += '<p><strong>Webhook Plugin Installed</strong><br>';
installHtml += 'Enable webhooks in Advanced Features tab and set a secure token.</p>';
hasInstructions = true;
} else if (isWebhookReady) {
installHtml += '<p><strong>✅ Webhook is Active!</strong><br>';
installHtml += 'Trigger URL: <code>' + window.location.origin + '/grav-editor-pro/scheduler/webhook</code><br>';
installHtml += 'Use with Authorization header: <code>Bearer YOUR_TOKEN</code></p>';
if (!cronReady) {
installHtml += '<p class="text-muted"><small>Note: No cron job configured. Scheduler runs only via webhook triggers.</small></p>';
}
}
if (!hasInstructions && cronReady) {
installHtml += '<p><strong>✅ Cron is configured and ready!</strong><br>';
installHtml += 'The scheduler runs automatically every minute via system cron.</p>';
}
installHtml += '</div>';
installDiv.innerHTML = installHtml;
// Update button text based on status
if (installButton) {
if (cronReady && isWebhookReady) {
installButton.innerHTML = '<i class="fa fa-info-circle"></i> Configuration Details';
} else if (cronReady || isWebhookReady) {
installButton.innerHTML = '<i class="fa fa-plus-circle"></i> Add More Triggers';
} else {
installButton.innerHTML = '<i class="fa fa-exclamation-triangle"></i> Install Instructions';
}
}
}
}
}
});
</script>
custom_jobs:
type: list
style: vertical
label:
classes: cron-job-list compact
key: id
fields:
.id:
type: key
label: ID
placeholder: 'process-name'
validate:
required: true
pattern: '[a-zа-я0-9_\-]+'
max: 20
message: 'ID must be lowercase with dashes/underscores only and less than 20 characters'
.command:
type: text
label: PLUGIN_ADMIN.COMMAND
placeholder: 'ls'
validate:
required: true
.args:
type: text
label: PLUGIN_ADMIN.EXTRA_ARGUMENTS
placeholder: '-lah'
.at:
type: text
wrapper_classes: cron-selector
label: PLUGIN_ADMIN.SCHEDULER_RUNAT
help: PLUGIN_ADMIN.SCHEDULER_RUNAT_HELP
placeholder: '* * * * *'
validate:
required: true
.output:
type: text
label: PLUGIN_ADMIN.SCHEDULER_OUTPUT
help: PLUGIN_ADMIN.SCHEDULER_OUTPUT_HELP
placeholder: 'logs/ls-cron.out'
.output_mode:
type: select
label: PLUGIN_ADMIN.SCHEDULER_OUTPUT_TYPE
help: PLUGIN_ADMIN.SCHEDULER_OUTPUT_TYPE_HELP
default: append
options:
append: Append
overwrite: Overwrite
.email:
type: text
label: PLUGIN_ADMIN.SCHEDULER_EMAIL
help: PLUGIN_ADMIN.SCHEDULER_EMAIL_HELP
placeholder: 'notifications@yoursite.com'
modern_health:
type: display
label: Health Status
content: |
<div id="scheduler-health-status">
<div class="text-muted">Checking health...</div>
</div>
<script>
(function() {
function loadHealthStatus() {
fetch(window.location.origin + '/grav-editor-pro/scheduler/health')
.then(response => response.json())
.then(data => {
var statusEl = document.getElementById('scheduler-health-status');
if (!statusEl) return;
// Modern card-based layout
var statusColor = '#6c757d';
var statusLabel = data.status || 'unknown';
if (data.status === 'healthy') statusColor = '#28a745';
else if (data.status === 'warning') statusColor = '#ffc107';
else if (data.status === 'critical') statusColor = '#dc3545';
var html = '<div style="display: flex; flex-direction: column; gap: 1rem;">';
// Status card
html += '<div style="display: flex; align-items: center; justify-content: space-between; padding: 0.75rem 1rem; background: linear-gradient(135deg, #f8f9fa 0%, #fff 100%); border-radius: 6px; border: 1px solid #e9ecef; box-shadow: 0 1px 3px rgba(0,0,0,0.05);">';
html += '<span style="font-weight: 500; color: #495057;">Status:</span>';
html += '<span style="background: ' + statusColor + '; color: white; padding: 0.375rem 0.75rem; font-size: 0.875rem; font-weight: 500; border-radius: 4px; text-transform: uppercase; letter-spacing: 0.025em;">' + statusLabel + '</span>';
html += '</div>';
// Info grid
html += '<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 0.75rem;">';
// Last run card
html += '<div style="background: white; border: 1px solid #e9ecef; border-radius: 6px; padding: 0.75rem; box-shadow: 0 1px 2px rgba(0,0,0,0.03);">';
html += '<div style="color: #6c757d; font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 0.25rem;">Last Run</div>';
if (data.last_run) {
var age = data.last_run_age;
var ageText = 'just now';
if (age > 86400) {
ageText = Math.floor(age / 86400) + ' day(s) ago';
} else if (age > 3600) {
ageText = Math.floor(age / 3600) + ' hour(s) ago';
} else if (age > 60) {
ageText = Math.floor(age / 60) + ' minute(s) ago';
} else if (age > 0) {
ageText = age + ' second(s) ago';
}
html += '<div style="font-size: 1rem; color: #212529; font-weight: 500;">' + ageText + '</div>';
} else {
html += '<div style="font-size: 1rem; color: #6c757d;">Never</div>';
}
html += '</div>';
// Jobs count card
html += '<div style="background: white; border: 1px solid #e9ecef; border-radius: 6px; padding: 0.75rem; box-shadow: 0 1px 2px rgba(0,0,0,0.03);">';
html += '<div style="color: #6c757d; font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 0.25rem;">Scheduled Jobs</div>';
html += '<div style="font-size: 1rem; color: #212529; font-weight: 500;">' + (data.scheduled_jobs || 0) + '</div>';
html += '</div>';
html += '</div>'; // Close grid
// Additional info if available
if (data.modern_features && data.queue_size !== undefined) {
html += '<div style="background: white; border: 1px solid #e9ecef; border-radius: 6px; padding: 0.75rem; box-shadow: 0 1px 2px rgba(0,0,0,0.03);">';
html += '<span style="color: #6c757d; font-size: 0.875rem;">Queue Size: </span>';
html += '<span style="font-weight: 500;">' + data.queue_size + '</span>';
html += '</div>';
}
// Failed jobs warning
if (data.failed_jobs_24h > 0) {
html += '<div style="background: #fff5f5; border: 1px solid #feb2b2; border-radius: 6px; padding: 0.75rem; color: #c53030;">';
html += '<strong>⚠️ Failed Jobs (24h):</strong> ' + data.failed_jobs_24h;
html += '</div>';
}
html += '</div>'; // Close main container
statusEl.innerHTML = html;
})
.catch(error => {
var statusEl = document.getElementById('scheduler-health-status');
if (statusEl) {
statusEl.innerHTML = '<div class="alert alert-warning">Unable to fetch health status. Ensure scheduler-webhook plugin is installed.</div>';
}
});
}
// Load on page ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', loadHealthStatus);
} else {
loadHealthStatus();
}
// Refresh every 30 seconds
setInterval(loadHealthStatus, 30000);
})();
</script>
markdown: false
trigger_methods:
type: display
label: Active Triggers
content: |
<div id="scheduler-triggers">
<div class="text-muted">Checking triggers...</div>
</div>
<script>
(function() {
function loadTriggers() {
// Check cron status from the main status field
var cronReady = false;
var statusDiv = document.querySelector('.cronstatus-status');
if (statusDiv) {
var statusText = statusDiv.textContent || statusDiv.innerText;
cronReady = statusText.includes('Ready');
}
// Check webhook status
fetch(window.location.origin + '/grav-editor-pro/scheduler/health')
.then(response => response.json())
.then(data => {
var triggersEl = document.getElementById('scheduler-triggers');
if (!triggersEl) return;
var html = '<div style="display: flex; flex-direction: column; gap: 0.5rem;">';
// Cron trigger card
var cronIcon = cronReady ? '✅' : '❌';
var cronStatus = cronReady ? 'Active' : 'Not Configured';
var cronStatusColor = cronReady ? '#28a745' : '#6c757d';
var cardBg = cronReady ? '#f8f9fa' : '#fff';
html += '<div style="display: flex; align-items: center; justify-content: space-between; padding: 0.75rem 1rem; background: ' + cardBg + '; border: 1px solid #e9ecef; border-radius: 4px;">';
html += '<div style="display: flex; align-items: center; gap: 0.75rem;">';
html += '<span style="font-size: 1.25rem; line-height: 1;">' + cronIcon + '</span>';
html += '<span style="font-weight: 500; color: #212529; font-size: 1rem;">Cron:</span>';
html += '</div>';
html += '<span style="background: ' + cronStatusColor + '; color: white; padding: 0.25rem 0.75rem; font-size: 0.875rem; font-weight: 500; border-radius: 3px; text-transform: uppercase; letter-spacing: 0.025em;">' + cronStatus + '</span>';
html += '</div>';
// Webhook trigger card
if (data.webhook_enabled) {
html += '<div style="display: flex; align-items: center; justify-content: space-between; padding: 0.75rem 1rem; background: #f8f9fa; border: 1px solid #e9ecef; border-radius: 4px;">';
html += '<div style="display: flex; align-items: center; gap: 0.75rem;">';
html += '<span style="font-size: 1.25rem; line-height: 1;">✅</span>';
html += '<span style="font-weight: 500; color: #212529; font-size: 1rem;">Webhook:</span>';
html += '</div>';
html += '<span style="background: #28a745; color: white; padding: 0.25rem 0.75rem; font-size: 0.875rem; font-weight: 500; border-radius: 3px; text-transform: uppercase; letter-spacing: 0.025em;">ACTIVE</span>';
html += '</div>';
} else {
// Show webhook as not configured/disabled
html += '<div style="display: flex; align-items: center; justify-content: space-between; padding: 0.75rem 1rem; background: #fff; border: 1px solid #e9ecef; border-radius: 4px;">';
html += '<div style="display: flex; align-items: center; gap: 0.75rem;">';
html += '<span style="font-size: 1.25rem; line-height: 1;">⚠️</span>';
html += '<span style="font-weight: 500; color: #212529; font-size: 1rem;">Webhook:</span>';
html += '</div>';
html += '<span style="background: #ffc107; color: #212529; padding: 0.25rem 0.75rem; font-size: 0.875rem; font-weight: 500; border-radius: 3px; text-transform: uppercase; letter-spacing: 0.025em;">DISABLED</span>';
html += '</div>';
}
html += '</div>';
// Add warning if no triggers active
if (!cronReady && !data.webhook_enabled) {
html += '<div class="alert alert-warning" style="margin-top: 1rem;"><i class="fa fa-exclamation-triangle"></i> No triggers active! Configure cron or enable webhooks.</div>';
}
triggersEl.innerHTML = html;
})
.catch(error => {
var triggersEl = document.getElementById('scheduler-triggers');
if (triggersEl) {
// Show just cron status if health endpoint not available
var html = '<ul class="list-unstyled">';
if (cronReady) {
html += '<li>✅ <strong>Cron:</strong> <span class="badge badge-success">Active</span></li>';
} else {
html += '<li>❌ <strong>Cron:</strong> <span class="badge badge-secondary">Not Configured</span></li>';
}
html += '<li>⚠️ <strong>Webhook:</strong> <span class="badge badge-secondary">Plugin Not Installed</span></li>';
html += '</ul>';
triggersEl.innerHTML = html;
}
});
}
// Load on page ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', loadTriggers);
} else {
loadTriggers();
}
})();
</script>
markdown: false
jobs_tab:
type: tab
title: PLUGIN_ADMIN.SCHEDULER_JOBS
fields:
jobs_title:
type: section
title: PLUGIN_ADMIN.SCHEDULER_JOBS
underline: true
custom_jobs:
type: list
style: vertical
label:
classes: cron-job-list compact
key: id
fields:
.id:
type: key
label: ID
placeholder: 'process-name'
validate:
required: true
pattern: '[a-zа-я0-9_\-]+'
max: 20
message: 'ID must be lowercase with dashes/underscores only and less than 20 characters'
.command:
type: text
label: PLUGIN_ADMIN.COMMAND
placeholder: 'ls'
validate:
required: true
.args:
type: text
label: PLUGIN_ADMIN.EXTRA_ARGUMENTS
placeholder: '-lah'
.at:
type: text
wrapper_classes: cron-selector
label: PLUGIN_ADMIN.SCHEDULER_RUNAT
help: PLUGIN_ADMIN.SCHEDULER_RUNAT_HELP
placeholder: '* * * * *'
validate:
required: true
.output:
type: text
label: PLUGIN_ADMIN.SCHEDULER_OUTPUT
help: PLUGIN_ADMIN.SCHEDULER_OUTPUT_HELP
placeholder: 'logs/ls-cron.out'
.output_mode:
type: select
label: PLUGIN_ADMIN.SCHEDULER_OUTPUT_TYPE
help: PLUGIN_ADMIN.SCHEDULER_OUTPUT_TYPE_HELP
default: append
options:
append: Append
overwrite: Overwrite
.email:
type: text
label: PLUGIN_ADMIN.SCHEDULER_EMAIL
help: PLUGIN_ADMIN.SCHEDULER_EMAIL_HELP
placeholder: 'notifications@yoursite.com'
modern_tab:
type: tab
title: Advanced Features
fields:
workers_section:
type: section
title: Worker Configuration
underline: true
fields:
modern.workers:
type: number
label: Concurrent Workers
help: Number of jobs that can run simultaneously (1 = sequential)
default: 4
size: x-small
append: workers
validate:
type: int
min: 1
max: 10
retry_section:
type: section
title: Retry Configuration
underline: true
fields:
modern.retry.enabled:
type: toggle
label: Enable Job Retry
help: Automatically retry failed jobs
highlight: 1
default: 1
options:
1: PLUGIN_ADMIN.ENABLED
0: PLUGIN_ADMIN.DISABLED
validate:
type: bool
modern.retry.max_attempts:
type: number
label: Maximum Retry Attempts
help: Maximum number of times to retry a failed job
default: 3
size: x-small
append: retries
validate:
type: int
min: 1
max: 10
modern.retry.backoff:
type: select
label: Retry Backoff Strategy
help: How to calculate delay between retries
default: exponential
options:
linear: Linear (fixed delay)
exponential: Exponential (increasing delay)
queue_section:
type: section
title: Queue Configuration
underline: true
fields:
modern.queue.path:
type: text
label: Queue Storage Path
help: Where to store queued jobs
default: 'user-data://scheduler/queue'
placeholder: 'user-data://scheduler/queue'
modern.queue.max_size:
type: number
label: Maximum Queue Size
help: Maximum number of jobs that can be queued
default: 1000
size: x-small
append: jobs
validate:
type: int
min: 100
max: 10000
history_section:
type: section
title: Job History
underline: true
fields:
modern.history.enabled:
type: toggle
label: Enable Job History
help: Track execution history for all jobs
highlight: 1
default: 1
options:
1: PLUGIN_ADMIN.ENABLED
0: PLUGIN_ADMIN.DISABLED
validate:
type: bool
modern.history.retention_days:
type: number
label: History Retention (days)
help: How long to keep job history
default: 30
size: x-small
append: days
validate:
type: int
min: 1
max: 365
webhook_section:
type: section
title: Webhook Configuration
underline: true
fields:
webhook_plugin_status:
type: webhook-status
label:
modern.webhook.enabled:
type: toggle
label: Enable Webhook Triggers
help: Allow triggering scheduler via HTTP webhook
highlight: 0
default: 0
options:
1: PLUGIN_ADMIN.ENABLED
0: PLUGIN_ADMIN.DISABLED
validate:
type: bool
modern.webhook.token:
type: text
label: Webhook Security Token
help: Secret token for authenticating webhook requests. Keep this secret!
placeholder: 'Click Generate to create a secure token'
autocomplete: 'off'
webhook_token_generate:
type: display
label:
content: |
<div style="margin-top: -10px; margin-bottom: 15px;">
<button type="button" class="button button-primary" onclick="generateWebhookToken()">
<i class="fa fa-refresh"></i> Generate Token
</button>
</div>
<script>
function generateWebhookToken() {
try {
// Generate token
const array = new Uint8Array(32);
crypto.getRandomValues(array);
const token = Array.from(array, byte => byte.toString(16).padStart(2, '0')).join('');
// Try multiple selectors to find the field
let field = document.querySelector('[name="data[scheduler][modern][webhook][token]"]');
if (!field) {
field = document.querySelector('input[name*="webhook][token"]');
}
if (!field) {
field = document.getElementById('scheduler-modern-webhook-token');
}
if (!field) {
// Look for any text input in the webhook section
const webhookSection = document.querySelector('.webhook_section');
if (webhookSection) {
const inputs = webhookSection.querySelectorAll('input[type="text"]');
// Find the token field by checking for the placeholder
for (let input of inputs) {
if (input.placeholder && input.placeholder.includes('Generate')) {
field = input;
break;
}
}
}
}
if (field) {
field.value = token;
field.dispatchEvent(new Event('change', { bubbles: true }));
field.dispatchEvent(new Event('input', { bubbles: true }));
// Flash the field to show it was updated
field.style.backgroundColor = '#d4edda';
setTimeout(function() {
field.style.backgroundColor = '';
}, 500);
// Also try to trigger Grav's form change detection
if (window.jQuery) {
jQuery(field).trigger('change');
}
} else {
// Log more debugging info
console.error('Token field not found. Looking for input fields...');
console.log('All inputs:', document.querySelectorAll('input[type="text"]'));
alert('Could not find the token field. Please ensure you are in the Advanced Features tab and the Webhook Configuration section is visible.');
}
} catch (e) {
console.error('Error generating token:', e);
alert('Error generating token: ' + e.message);
}
}
</script>
markdown: false
modern.webhook.path:
type: text
label: Webhook Path
help: URL path for webhook endpoint
default: '/scheduler/webhook'
placeholder: '/scheduler/webhook'
health_section:
type: section
title: Health Check Configuration
underline: true
fields:
modern.health.enabled:
type: toggle
label: Enable Health Check
help: Provide health status endpoint for monitoring
highlight: 1
default: 1
options:
1: PLUGIN_ADMIN.ENABLED
0: PLUGIN_ADMIN.DISABLED
validate:
type: bool
modern.health.path:
type: text
label: Health Check Path
help: URL path for health check endpoint
default: '/scheduler/health'
placeholder: '/scheduler/health'
webhook_usage:
type: section
title: Usage Examples
underline: true
fields:
webhook_examples:
type: display
label:
content: |
<script src="{{ url('plugin://admin/themes/grav/js/clipboard-helper.js') }}"></script>
<div class="webhook-examples">
<script>
// Initialize webhook commands when page loads
document.addEventListener('DOMContentLoaded', function() {
if (typeof GravClipboard !== 'undefined') {
GravClipboard.initWebhookCommands();
}
});
</script>
<div class="alert alert-info">
<h4>How to use webhooks:</h4>
<div style="margin-bottom: 1rem;">
<label style="display: block; margin-bottom: 0.25rem; font-weight: 500;">Trigger all due jobs (respects schedule):</label>
<div class="form-input-wrapper form-input-addon-wrapper">
<textarea id="webhook-all-cmd" readonly rows="2" style="font-family: monospace; background: #f5f5f5; resize: none;">Loading...</textarea>
<div class="form-input-addon form-input-append" style="cursor: pointer;" onclick="GravClipboard.copy(this)"><i class="fa fa-copy"></i> Copy</div>
</div>
</div>
<div style="margin-bottom: 1rem;">
<label style="display: block; margin-bottom: 0.25rem; font-weight: 500;">Force-run specific job (ignores schedule):</label>
<div class="form-input-wrapper form-input-addon-wrapper">
<textarea id="webhook-job-cmd" readonly rows="2" style="font-family: monospace; background: #f5f5f5; resize: none;">Loading...</textarea>
<div class="form-input-addon form-input-append" style="cursor: pointer;" onclick="GravClipboard.copy(this)"><i class="fa fa-copy"></i> Copy</div>
</div>
</div>
<div style="margin-bottom: 1rem;">
<label style="display: block; margin-bottom: 0.25rem; font-weight: 500;">Check health status:</label>
<div class="form-input-wrapper form-input-addon-wrapper">
<input type="text" id="webhook-health-cmd" readonly value="Loading..." style="font-family: monospace; background: #f5f5f5;">
<div class="form-input-addon form-input-append" style="cursor: pointer;" onclick="GravClipboard.copy(this)"><i class="fa fa-copy"></i> Copy</div>
</div>
</div>
<div style="margin-top: 1rem;">
<p><strong>GitHub Actions example:</strong></p>
<pre>- name: Trigger Scheduler
run: |
curl -X POST ${{ secrets.SITE_URL }}/scheduler/webhook \
-H "Authorization: Bearer ${{ secrets.WEBHOOK_TOKEN }}"</pre>
</div>
</div>
</div>
markdown: false

View File

@@ -610,6 +610,15 @@ form:
hash: All files timestamps
none: No timestamp checking
cache.check.interval:
type: number
size: x-small
label: Cache Check Interval
help: Seconds to reuse the previously computed filesystem hash before checking again. Zero keeps existing per-request checks.
validate:
type: int
min: 0
cache.driver:
type: select
size: small
@@ -631,6 +640,19 @@ form:
help: PLUGIN_ADMIN.CACHE_PREFIX_HELP
placeholder: PLUGIN_ADMIN.CACHE_PREFIX_PLACEHOLDER
cache.purge_max_age_days:
type: text
size: x-small
append: GRAV.NICETIME.DAY_PLURAL
label: PLUGIN_ADMIN.CACHE_PURGE_AGE
help: PLUGIN_ADMIN.CACHE_PURGE_AGE_HELP
validate:
type: number
min: 1
max: 365
step: 1
default: 30
cache.purge_at:
type: cron
label: PLUGIN_ADMIN.CACHE_PURGE_JOB
@@ -762,8 +784,8 @@ form:
flex.cache.index.enabled:
type: toggle
label: PLUGIN_ADMIN.FLEX_INDEX_CACHE_ENABLED
highlight: 1
default: 1
highlight: 0
default: 0
options:
1: PLUGIN_ADMIN.ENABLED
0: PLUGIN_ADMIN.DISABLED
@@ -780,8 +802,8 @@ form:
flex.cache.object.enabled:
type: toggle
label: PLUGIN_ADMIN.FLEX_OBJECT_CACHE_ENABLED
highlight: 1
default: 1
highlight: 0
default: 0
options:
1: PLUGIN_ADMIN.ENABLED
0: PLUGIN_ADMIN.DISABLED
@@ -1211,6 +1233,16 @@ form:
title: PLUGIN_ADMIN.MEDIA
underline: true
images.adapter:
type: select
size: small
label: PLUGIN_ADMIN.IMAGE_ADAPTER
help: PLUGIN_ADMIN.IMAGE_ADAPTER_HELP
highlight: gd
options:
gd: GD (PHP built-in)
imagick: Imagick
images.default_image_quality:
type: range
append: '%'
@@ -1548,6 +1580,31 @@ 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
http_section:
type: section
title: PLUGIN_ADMIN.HTTP_SECTION
@@ -1733,8 +1790,8 @@ form:
http_x_forwarded.host:
type: toggle
label: HTTP_X_FORWARDED_HOST Enabled
highlight: 0
default: 0
highlight: 1
default: 1
options:
1: PLUGIN_ADMIN.YES
0: PLUGIN_ADMIN.NO
@@ -1767,8 +1824,8 @@ form:
strict_mode.blueprint_compat:
type: toggle
label: PLUGIN_ADMIN.STRICT_BLUEPRINT_COMPAT
highlight: 0
default: 0
highlight: 1
default: 1
help: PLUGIN_ADMIN.STRICT_BLUEPRINT_COMPAT_HELP
options:
1: PLUGIN_ADMIN.YES
@@ -1788,7 +1845,7 @@ form:
validate:
type: bool
strict_mode.twig_compat:
strict_mode.twig2_compat:
type: toggle
label: PLUGIN_ADMIN.STRICT_TWIG_COMPAT
highlight: 0
@@ -1800,6 +1857,18 @@ form:
validate:
type: bool
strict_mode.twig3_compat:
type: toggle
label: Twig 3 Compatibility
highlight: 0
default: 0
help: Enable automatic rewrites for legacy Twig 1/2 syntax that breaks on Twig 3 (e.g. `for ... if ...` guards)
options:
1: PLUGIN_ADMIN.YES
0: PLUGIN_ADMIN.NO
validate:
type: bool
accounts:
type: tab
@@ -1862,6 +1931,3 @@ form:
#
# pages.type:
# type: hidden

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

@@ -0,0 +1,68 @@
# Grav Scheduler Configuration
# Default scheduler settings (backward compatible)
defaults:
output: true
output_type: file
email: null
# Status of individual jobs (enabled/disabled)
status: {}
# Custom scheduled jobs
custom_jobs: {}
# Modern scheduler features (disabled by default for backward compatibility)
modern:
# Enable modern scheduler features
enabled: false
# Number of concurrent workers (1 = sequential execution like legacy)
workers: 1
# Job retry configuration
retry:
enabled: true
max_attempts: 3
backoff: exponential # 'linear' or 'exponential'
# Job queue configuration
queue:
path: user-data://scheduler/queue
max_size: 1000
# Webhook trigger configuration
webhook:
enabled: false
token: null # Set a secure token to enable webhook triggers
path: /scheduler/webhook
# Health check endpoint
health:
enabled: true
path: /scheduler/health
# Job execution history
history:
enabled: true
retention_days: 30
path: user-data://scheduler/history
# Performance settings
performance:
job_timeout: 300 # Default timeout in seconds
lock_timeout: 10 # Lock acquisition timeout in seconds
# Monitoring and alerts
monitoring:
enabled: false
alert_on_failure: true
alert_email: null
webhook_url: null
# Trigger detection methods
triggers:
check_cron: true
check_systemd: true
check_webhook: true
check_external: true

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

@@ -93,6 +93,7 @@ cache:
enabled: true # Set to true to enable caching
check:
method: file # Method to check for updates in pages: file|folder|hash|none
interval: 0 # Seconds to reuse previous filesystem hash before rechecking (0 = every request)
driver: auto # One of: auto|file|apcu|memcached|redis
prefix: 'g' # Cache prefix string (prevents cache conflicts)
purge_at: '0 4 * * *' # How often to purge old file cache (using new scheduler)
@@ -101,6 +102,7 @@ cache:
clear_images_by_default: false # By default grav does not include processed images in cache clear, this can be enabled
cli_compatibility: false # Ensures only non-volatile drivers are used (file, redis, memcached, etc.)
lifetime: 604800 # Lifetime of cached data in seconds (0 = infinite)
purge_max_age_days: 30 # Maximum age of cache items in days before they are purged
gzip: false # GZip compress the page output
allow_webserver_gzip: false # If true, `content-encoding: identity` but connection isn't closed before `onShutDown()` event
redis:
@@ -155,6 +157,7 @@ debugger:
close_connection: true # Close the connection before calling onShutdown(). false for debugging
images:
adapter: gd # Image adapter to use: gd | imagick
default_image_quality: 85 # Default image quality to use when resampling images (85%)
cache_all: false # Cache all image by default
cache_perms: '0755' # MUST BE IN QUOTES!! Default cache folder perms. Usually '0755' or '0775'
@@ -200,6 +203,10 @@ 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)
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
@@ -228,5 +235,6 @@ flex:
strict_mode:
yaml_compat: false # Set to true to enable YAML backwards compatibility
twig_compat: false # Set to true to enable deprecated Twig settings (autoescape: false)
twig2_compat: false # Set to true to enable deprecated Twig settings (autoescape: false)
twig3_compat: true # Set to true to enable automatic fixes for Twig 3 syntax changes
blueprint_compat: false # Set to true to enable backward compatible strict support for blueprints

View File

@@ -9,13 +9,13 @@
// Some standard defines
define('GRAV', true);
define('GRAV_VERSION', '1.8.0-beta.4');
define('GRAV_SCHEMA', '1.7.0_2020-11-20_1');
define('GRAV_VERSION', '1.8.0-beta.27');
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).

202
system/recovery.php Normal file
View File

@@ -0,0 +1,202 @@
<?php
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 ($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.';
}
if ($action === 'clear-flag') {
$manager->clear();
$_SESSION['grav_recovery_authenticated'] = null;
$notice = 'Recovery flag cleared.';
}
} catch (\Throwable $e) {
$errorMessage = $e->getMessage();
}
} else {
$errorMessage = 'Authentication required.';
}
}
$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;
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>
body { font-family: system-ui, -apple-system, BlinkMacSystemFont, sans-serif; margin: 0; padding: 40px; background: #111; color: #eee; }
.panel { max-width: 720px; margin: 0 auto; background: #1d1d1f; padding: 24px 32px; border-radius: 12px; box-shadow: 0 10px 45px rgba(0,0,0,0.4); }
h1 { font-size: 2.5rem; margin-top: 0; color: #fff; display:flex;align-items:center; }
h1 > img {margin-right:1rem;}
code { background: rgba(255,255,255,0.08); padding: 2px 4px; border-radius: 4px; }
form { margin-top: 16px; }
input[type="text"] { width: 100%; padding: 10px; border: 1px solid #333; border-radius: 6px; background: #151517; color: #fff; }
button { margin-top: 12px; padding: 10px 16px; border: 0; border-radius: 6px; cursor: pointer; background: #3c8bff; color: #fff; font-weight: 600; }
button.secondary { background: #444; }
.message { padding: 10px 14px; border-radius: 6px; margin-top: 12px; }
.error { background: rgba(220, 53, 69, 0.15); color: #ffb3b8; }
.notice { background: rgba(25, 135, 84, 0.2); color: #bdf8d4; }
ul { padding-left: 20px; }
li { margin-bottom: 8px; }
.card { border: 1px solid #2a2a2d; border-radius: 8px; padding: 14px 16px; margin-top: 16px; background: #161618; }
small { color: #888; }
</style>
</head>
<body>
<div class="panel">
<h1><img src="system/assets/grav.png">Grav Recovery Mode</h1>
<?php if ($notice): ?>
<div class="message notice"><?php echo htmlspecialchars($notice, ENT_QUOTES, 'UTF-8'); ?></div>
<?php endif; ?>
<?php if ($errorMessage): ?>
<div class="message error"><?php echo htmlspecialchars($errorMessage, ENT_QUOTES, 'UTF-8'); ?></div>
<?php endif; ?>
<?php if (!$authenticated): ?>
<p>This site is running in recovery mode because Grav detected a fatal error.</p>
<p>Locate the recovery token in <code>user/data/recovery.flag</code> and enter it below.</p>
<form method="post">
<input type="hidden" name="action" value="authenticate">
<label for="token">Recovery token</label>
<input id="token" name="token" type="text" autocomplete="one-time-code" required>
<button type="submit">Unlock Recovery</button>
</form>
<?php else: ?>
<div class="card">
<h2>Failure Details</h2>
<ul>
<li><strong>Message:</strong> <?php echo htmlspecialchars($context['message'] ?? 'Unknown', ENT_QUOTES, 'UTF-8'); ?></li>
<li><strong>File:</strong> <?php echo htmlspecialchars($context['file'] ?? 'n/a', ENT_QUOTES, 'UTF-8'); ?></li>
<li><strong>Line:</strong> <?php echo htmlspecialchars((string)($context['line'] ?? 'n/a'), ENT_QUOTES, 'UTF-8'); ?></li>
<?php if (!empty($context['plugin'])): ?>
<li><strong>Quarantined plugin:</strong> <?php echo htmlspecialchars($context['plugin'], ENT_QUOTES, 'UTF-8'); ?></li>
<?php endif; ?>
</ul>
</div>
<?php if ($quarantine): ?>
<div class="card">
<h3>Quarantined Plugins</h3>
<ul>
<?php foreach ($quarantine as $entry): ?>
<li>
<strong><?php echo htmlspecialchars($entry['slug'], ENT_QUOTES, 'UTF-8'); ?></strong>
<small>(disabled at <?php echo date('c', $entry['disabled_at']); ?>)</small><br>
<?php echo htmlspecialchars($entry['message'] ?? '', ENT_QUOTES, 'UTF-8'); ?>
</li>
<?php endforeach; ?>
</ul>
</div>
<?php endif; ?>
<div class="card">
<h3>Rollback</h3>
<?php if ($latestSnapshot): ?>
<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'); ?>">
<p>
Latest snapshot:
<code><?php echo htmlspecialchars($latestSnapshot['id'], ENT_QUOTES, 'UTF-8'); ?></code>
<?php if (!empty($latestSnapshot['label'])): ?>
<br><small><?php echo htmlspecialchars($latestSnapshot['label'], ENT_QUOTES, 'UTF-8'); ?></small>
<?php endif; ?>
— Grav <?php echo htmlspecialchars($latestSnapshot['target_version'] ?? 'unknown', ENT_QUOTES, 'UTF-8'); ?>
<?php if (!empty($latestSnapshot['created_at'])): ?>
<br><small>Created <?php echo htmlspecialchars(date('c', (int)$latestSnapshot['created_at']), ENT_QUOTES, 'UTF-8'); ?></small>
<?php endif; ?>
</p>
<button type="submit" class="secondary">Rollback to Latest Snapshot</button>
</form>
<?php else: ?>
<p>No upgrade snapshots were found.</p>
<?php endif; ?>
</div>
<form method="post">
<input type="hidden" name="action" value="clear-flag">
<button type="submit" class="secondary">Exit Recovery Mode</button>
</form>
<?php endif; ?>
</div>
</body>
</html>

16
system/rector.php Normal file
View File

@@ -0,0 +1,16 @@
<?php
use Rector\Config\RectorConfig;
return RectorConfig::configure()
->withSkip([
__DIR__ . '/vendor',
])
->withPaths([
__DIR__
])
->withPhpSets(php82: true)
->withPhpVersion(Rector\ValueObject\PhpVersion::PHP_84)
->withRules([
Rector\Php84\Rector\Param\ExplicitNullableParamTypeRector::class,
]);

View File

@@ -18,17 +18,17 @@ $path = $_SERVER['SCRIPT_NAME'];
if ($path !== '/index.php' && is_file($root . $path)) {
if (!(
// Block all direct access to files and folders beginning with a dot
strpos($path, '/.') !== false
str_contains((string) $path, '/.')
// Block all direct access for these folders
|| preg_match('`^/(\.git|cache|bin|logs|backup|webserver-configs|tests)/`ui', $path)
|| preg_match('`^/(\.git|cache|bin|logs|backup|webserver-configs|tests)/`ui', (string) $path)
// Block access to specific file types for these system folders
|| preg_match('`^/(system|vendor)/(.*)\.(txt|xml|md|html|json|yaml|yml|php|pl|py|cgi|twig|sh|bat)$`ui', $path)
|| preg_match('`^/(system|vendor)/(.*)\.(txt|xml|md|html|json|yaml|yml|php|pl|py|cgi|twig|sh|bat)$`ui', (string) $path)
// Block access to specific file types for these user folders
|| preg_match('`^/(user)/(.*)\.(txt|md|json|yaml|yml|php|pl|py|cgi|twig|sh|bat)$`ui', $path)
|| preg_match('`^/(user)/(.*)\.(txt|md|json|yaml|yml|php|pl|py|cgi|twig|sh|bat)$`ui', (string) $path)
// Block all direct access to .md files
|| preg_match('`\.md$`ui', $path)
|| preg_match('`\.md$`ui', (string) $path)
// Block access to specific files in the root folder
|| preg_match('`^/(LICENSE\.txt|composer\.lock|composer\.json|\.htaccess)$`ui', $path)
|| preg_match('`^/(LICENSE\.txt|composer\.lock|composer\.json|\.htaccess)$`ui', (string) $path)
)) {
return false;
}

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

@@ -170,9 +170,9 @@ trait AssetUtilsTrait
}
if (in_array($key, $no_key, true)) {
$element = htmlentities($value, ENT_QUOTES, 'UTF-8', false);
$element = htmlentities((string) $value, ENT_QUOTES, 'UTF-8', false);
} else {
$element = $key . '="' . htmlentities($value, ENT_QUOTES, 'UTF-8', false) . '"';
$element = $key . '="' . htmlentities((string) $value, ENT_QUOTES, 'UTF-8', false) . '"';
}
$html .= ' ' . $element;

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 ?? ''),
@@ -315,7 +347,10 @@ class Backups
*/
protected static function convertExclude($exclude)
{
$lines = preg_split("/[\s,]+/", $exclude);
// Split by newlines, commas, or multiple spaces
$lines = preg_split("/[\r\n,]+|[\s]{2,}/", $exclude);
// Remove empty values and trim
$lines = array_filter(array_map('trim', $lines));
return array_map('trim', $lines, array_fill(0, count($lines), '/'));
}

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
@@ -176,24 +178,75 @@ class Cache extends Getters
}
/**
* Deletes the old out of date file-based caches
* Deletes old cache files based on age
*
* @return int
*/
public function purgeOldCache()
{
// Get the max age for cache files from config (default 30 days)
$max_age_days = $this->config->get('system.cache.purge_max_age_days', 30);
$max_age_seconds = $max_age_days * 86400; // Convert days to seconds
$now = time();
$count = 0;
// First, clean up old orphaned cache directories (not the current one)
$cache_dir = dirname($this->cache_dir);
$current = Utils::basename($this->cache_dir);
$count = 0;
foreach (new DirectoryIterator($cache_dir) as $file) {
$dir = $file->getBasename();
if ($dir === $current || $file->isDot() || $file->isFile()) {
continue;
}
Folder::delete($file->getPathname());
$count++;
// Check if directory is old and empty or very old (90+ days)
$dir_age = $now - $file->getMTime();
if ($dir_age > 7776000) { // 90 days
Folder::delete($file->getPathname());
$count++;
}
}
// Now clean up old cache files within the current cache directory
if (is_dir($this->cache_dir)) {
$iterator = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($this->cache_dir, \RecursiveDirectoryIterator::SKIP_DOTS),
\RecursiveIteratorIterator::CHILD_FIRST
);
foreach ($iterator as $file) {
if ($file->isFile()) {
$file_age = $now - $file->getMTime();
if ($file_age > $max_age_seconds) {
@unlink($file->getPathname());
$count++;
}
}
}
}
// Also clean up old files in compiled cache
$grav = Grav::instance();
$compiled_dir = $this->config->get('system.cache.compiled_dir', 'cache://compiled');
$compiled_path = $grav['locator']->findResource($compiled_dir, true);
if ($compiled_path && is_dir($compiled_path)) {
$iterator = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($compiled_path, \RecursiveDirectoryIterator::SKIP_DOTS),
\RecursiveIteratorIterator::CHILD_FIRST
);
foreach ($iterator as $file) {
if ($file->isFile()) {
$file_age = $now - $file->getMTime();
// Compiled files can be kept longer (60 days)
if ($file_age > ($max_age_seconds * 2)) {
@unlink($file->getPathname());
$count++;
}
}
}
}
return $count;
@@ -244,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
@@ -263,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
@@ -339,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;
}
/**
@@ -442,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;
}
/**
@@ -486,8 +614,17 @@ class Cache extends Getters
// Delete entries in the doctrine cache if required
if (in_array($remove, ['all', 'standard'])) {
$cache = Grav::instance()['cache'];
$cache->driver->deleteAll();
try {
$grav = Grav::instance();
if ($grav->offsetExists('cache')) {
$cache = $grav['cache'];
if (isset($cache->driver)) {
$cache->driver->deleteAll();
}
}
} catch (\Throwable $e) {
$output[] = 'cache: ' . $e->getMessage();
}
}
// Clearing cache event to add paths to clear
@@ -513,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;
}
@@ -632,7 +772,7 @@ class Cache extends Getters
*/
public function isVolatileDriver($setting)
{
return $setting === 'apcu';
return in_array($setting, ['apcu', 'array'], true);
}
/**
@@ -646,8 +786,10 @@ class Cache extends Getters
{
/** @var Cache $cache */
$cache = Grav::instance()['cache'];
$deleted_folders = $cache->purgeOldCache();
$msg = 'Purged ' . $deleted_folders . ' old cache folders...';
$deleted_items = $cache->purgeOldCache();
$max_age = $cache->config->get('system.cache.purge_max_age_days', 30);
$msg = 'Purged ' . $deleted_items . ' old cache items (files older than ' . $max_age . ' days)';
if ($echo) {
echo $msg;

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

@@ -13,7 +13,10 @@ use BadMethodCallException;
use Exception;
use RocketTheme\Toolbox\File\PhpFile;
use RuntimeException;
use function filter_var;
use function function_exists;
use function get_class;
use function ini_get;
use function is_array;
/**
@@ -254,6 +257,9 @@ abstract class CompiledBase
$file->save($cache);
$file->unlock();
$this->preloadOpcodeCache($file);
$file->free();
$this->modified();
@@ -266,4 +272,40 @@ abstract class CompiledBase
{
return $this->object->toArray();
}
/**
* Ensure compiled cache file is primed into OPcache when available.
*/
protected function preloadOpcodeCache(PhpFile $file): void
{
if (!function_exists('opcache_invalidate') || !$this->isOpcacheEnabled()) {
return;
}
$filename = $file->filename();
if (!$filename) {
return;
}
// Silence errors for restricted functions while keeping best effort behavior.
@opcache_invalidate($filename, true);
if (function_exists('opcache_compile_file')) {
@opcache_compile_file($filename);
}
}
/**
* Detect if OPcache is active for current SAPI.
*/
protected function isOpcacheEnabled(): bool
{
$enabled = filter_var(ini_get('opcache.enable'), \FILTER_VALIDATE_BOOLEAN);
if (PHP_SAPI === 'cli') {
$enabled = $enabled || filter_var(ini_get('opcache.enable_cli'), \FILTER_VALIDATE_BOOLEAN);
}
return $enabled;
}
}

View File

@@ -142,7 +142,7 @@ class Blueprint extends BlueprintForm
{
foreach ($this->dynamic as $key => $data) {
// Locate field.
$path = explode('/', $key);
$path = explode('/', (string) $key);
$current = &$this->items;
foreach ($path as $field) {

View File

@@ -196,6 +196,38 @@ class BlueprintSchema extends BlueprintSchemaBase implements ExportInterface
$messages += Validation::validate($child, $rule);
if (isset($rule['validate']['match']) || isset($rule['validate']['match_exact']) || isset($rule['validate']['match_any'])) {
$ruleKey = current(array_intersect(['match', 'match_exact', 'match_any'], array_keys($rule['validate'])));
$otherKey = $rule['validate'][$ruleKey] ?? null;
$otherVal = $data[$otherKey] ?? null;
$otherLabel = $this->items[$otherKey]['label'] ?? $otherKey;
$currentVal = $data[$key] ?? null;
$currentLabel = $this->items[$key]['label'] ?? $key;
// Determine comparison type (loose, strict, substring)
// Perform comparison:
$isValid = false;
if ($ruleKey === 'match') {
$isValid = ($currentVal == $otherVal);
} elseif ($ruleKey === 'match_exact') {
$isValid = ($currentVal === $otherVal);
} elseif ($ruleKey === 'match_any') {
// If strings:
if (is_string($currentVal) && is_string($otherVal)) {
$isValid = (strlen($currentVal) && strlen($otherVal) && (str_contains($currentVal,
$otherVal) || str_contains($otherVal, $currentVal)));
}
// If arrays:
if (is_array($currentVal) && is_array($otherVal)) {
$common = array_intersect($currentVal, $otherVal);
$isValid = !empty($common);
}
}
if (!$isValid) {
$messages[$rule['name']][] = sprintf(Grav::instance()['language']->translate('PLUGIN_FORM.VALIDATION_MATCH'), $currentLabel, $otherLabel);
}
}
} elseif (is_array($child) && is_array($val)) {
// Array has been defined in blueprints.
$messages += $this->validateArray($child, $val, $strict);

View File

@@ -47,12 +47,14 @@ class Validation
}
$validate = (array)($field['validate'] ?? null);
$type = $validate['type'] ?? $field['type'];
$validate_type = $validate['type'] ?? null;
$required = $validate['required'] ?? false;
$type = $validate_type ?? $field['type'];
$required = $required && ($validate_type !== 'ignore');
// If value isn't required, we will stop validation if empty value is given.
if ($required !== true && ($value === null || $value === '' || (($field['type'] === 'checkbox' || $field['type'] === 'switch') && $value == false))
) {
if ($required !== true && ($value === null || $value === '' || empty($value) || (($field['type'] === 'checkbox' || $field['type'] === 'switch') && $value == false))) {
return [];
}

View File

@@ -623,7 +623,7 @@ class Debugger
$table = [];
foreach ($timings as $key => $timing) {
$parts = explode('==>', $key);
$parts = explode('==>', (string) $key);
$method = $this->parseProfilerCall(array_pop($parts));
$context = $this->parseProfilerCall(array_pop($parts));
@@ -924,7 +924,6 @@ class Debugger
if ($object instanceof TemplateWrapper) {
$reflection = new ReflectionObject($object);
$property = $reflection->getProperty('template');
$property->setAccessible(true);
$object = $property->getValue($object);
}

View File

@@ -36,7 +36,7 @@ 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($filename);
$key = md5((string) $filename);
$file = PhpFile::instance(CACHE_DIR . "compiled/files/{$key}{$this->extension}.php");
$modified = $this->modified();

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

@@ -57,17 +57,54 @@ class RecursiveDirectoryFilterIterator extends RecursiveFilterIterator
$relative_filename = str_replace($this::$root . '/', '', $file->getPathname());
if ($file->isDir()) {
// Check if the directory path is in the ignore list
if (in_array($relative_filename, $this::$ignore_folders, true)) {
return false;
}
if (!in_array($filename, $this::$ignore_files, true)) {
// Check if any parent directory is in the ignore list
foreach ($this::$ignore_folders as $ignore_folder) {
$ignore_folder = trim((string) $ignore_folder, '/');
if (str_starts_with($relative_filename, $ignore_folder . '/') || $relative_filename === $ignore_folder) {
return false;
}
}
if (!$this->matchesPattern($filename, $this::$ignore_files)) {
return true;
}
} elseif ($file->isFile() && !in_array($filename, $this::$ignore_files, true)) {
} elseif ($file->isFile() && !$this->matchesPattern($filename, $this::$ignore_files)) {
return true;
}
return false;
}
/**
* Check if filename matches any pattern in the list
*
* @param string $filename
* @param array $patterns
* @return bool
*/
protected function matchesPattern($filename, $patterns)
{
foreach ($patterns as $pattern) {
// Check for exact match
if ($filename === $pattern) {
return true;
}
// Check for extension patterns like .pdf
if (str_starts_with((string) $pattern, '.') && str_ends_with($filename, (string) $pattern)) {
return true;
}
// Check for wildcard patterns
if (str_contains((string) $pattern, '*')) {
$regex = '/^' . str_replace('\\*', '.*', preg_quote((string) $pattern, '/')) . '$/';
if (preg_match($regex, $filename)) {
return true;
}
}
}
return false;
}
/**
* @return RecursiveDirectoryFilterIterator|RecursiveFilterIterator

View File

@@ -64,8 +64,21 @@ class ZipArchiver extends Archiver
}
$zip = new ZipArchive();
if (!$zip->open($this->archive_file, ZipArchive::CREATE)) {
throw new InvalidArgumentException('ZipArchiver:' . $this->archive_file . ' cannot be created...');
$result = $zip->open($this->archive_file, ZipArchive::CREATE);
if ($result !== true) {
$error = 'unknown error';
if ($result === ZipArchive::ER_NOENT) {
$error = 'file does not exist';
} elseif ($result === ZipArchive::ER_EXISTS) {
$error = 'file already exists';
} elseif ($result === ZipArchive::ER_OPEN) {
$error = 'cannot open file';
} elseif ($result === ZipArchive::ER_READ) {
$error = 'read error';
} elseif ($result === ZipArchive::ER_SEEK) {
$error = 'seek error';
}
throw new InvalidArgumentException('ZipArchiver: ' . $this->archive_file . ' cannot be created: ' . $error);
}
$files = $this->getArchiveFiles($rootPath);
@@ -112,8 +125,21 @@ class ZipArchiver extends Archiver
}
$zip = new ZipArchive();
if (!$zip->open($this->archive_file)) {
throw new InvalidArgumentException('ZipArchiver: ' . $this->archive_file . ' cannot be opened...');
$result = $zip->open($this->archive_file);
if ($result !== true) {
$error = 'unknown error';
if ($result === ZipArchive::ER_NOENT) {
$error = 'file does not exist';
} elseif ($result === ZipArchive::ER_EXISTS) {
$error = 'file already exists';
} elseif ($result === ZipArchive::ER_OPEN) {
$error = 'cannot open file';
} elseif ($result === ZipArchive::ER_READ) {
$error = 'read error';
} elseif ($result === ZipArchive::ER_SEEK) {
$error = 'seek error';
}
throw new InvalidArgumentException('ZipArchiver: ' . $this->archive_file . ' cannot be opened: ' . $error);
}
$status && $status([
@@ -122,7 +148,12 @@ class ZipArchiver extends Archiver
]);
foreach ($folders as $folder) {
$zip->addEmptyDir($folder);
if ($zip->addEmptyDir($folder) === false) {
$status && $status([
'type' => 'message',
'message' => 'Warning: Could not add empty directory: ' . $folder
]);
}
$status && $status([
'type' => 'progress',
]);

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

@@ -28,7 +28,7 @@ class FormFlash extends FrameworkFormFlash
{
$fields = [];
foreach ($this->files as $field => $files) {
if (strpos($field, '/')) {
if (strpos((string) $field, '/')) {
continue;
}
foreach ($files as $file) {

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
@@ -119,7 +121,7 @@ class GPM extends Iterator
if ($type_installed === false) {
continue;
}
$methodInstallableType = 'getInstalled' . ucfirst($type);
$methodInstallableType = 'getInstalled' . ucfirst((string) $type);
$to_install = $this->$methodInstallableType();
$items[$type] = $to_install;
$items['total'] += count($to_install);
@@ -279,7 +281,7 @@ class GPM extends Iterator
if ($type_updatable === false) {
continue;
}
$methodUpdatableType = 'getUpdatable' . ucfirst($type);
$methodUpdatableType = 'getUpdatable' . ucfirst((string) $type);
$to_update = $this->$methodUpdatableType();
$items[$type] = $to_update;
$items['total'] += count($to_update);
@@ -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 */
@@ -82,7 +81,7 @@ class GravCore extends AbstractPackageCollection
$diffLog = [];
foreach ((array)$this->data['changelog'] as $version => $changelog) {
preg_match("/[\w\-\.]+/", $version, $cleanVersion);
preg_match("/[\w\-\.]+/", (string) $version, $cleanVersion);
if (!$cleanVersion || version_compare($diff, $cleanVersion[0], '>=')) {
continue;

View File

@@ -52,7 +52,7 @@ class Package extends BasePackage implements \JsonSerializable
$diffLog = [];
foreach ((array)$this->data['changelog'] as $version => $changelog) {
preg_match("/[\w\-.]+/", $version, $cleanVersion);
preg_match("/[\w\-.]+/", (string) $version, $cleanVersion);
if (!$cleanVersion || version_compare($diff, $cleanVersion[0], '>=')) {
continue;

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,
];
@@ -505,7 +507,7 @@ class Grav extends Container
header("HTTP/{$response->getProtocolVersion()} {$response->getStatusCode()} {$response->getReasonPhrase()}");
foreach ($response->getHeaders() as $key => $values) {
// Skip internal Grav headers.
if (str_starts_with($key, 'Grav-Internal-')) {
if (str_starts_with((string) $key, 'Grav-Internal-')) {
continue;
}
foreach ($values as $i => $value) {
@@ -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

@@ -60,7 +60,7 @@ class Truncator
$words = $currentWordPosition[2];
$curNode->nodeValue = substr(
$curNode->nodeValue,
(string) $curNode->nodeValue,
0,
$words[$offset][1] + strlen((string) $words[$offset][0])
);
@@ -110,7 +110,7 @@ class Truncator
// If we have exceeded the limit, we want to delete the remainder of this document.
if ($letters->key() >= $limit) {
$currentText = $letters->currentTextPosition();
$currentText[0]->nodeValue = mb_substr($currentText[0]->nodeValue, 0, $currentText[1] + 1);
$currentText[0]->nodeValue = mb_substr((string) $currentText[0]->nodeValue, 0, $currentText[1] + 1);
self::removeProceedingNodes($currentText[0], $container);
if (!empty($ellipsis)) {
@@ -231,7 +231,7 @@ class Truncator
}
} else {
// Append to current node
$domNode->nodeValue = rtrim($domNode->nodeValue) . $ellipsis;
$domNode->nodeValue = rtrim((string) $domNode->nodeValue) . $ellipsis;
}
}

View File

@@ -126,7 +126,7 @@ class Inflector
if (is_array(static::$irregular)) {
foreach (static::$irregular as $_plural => $_singular) {
if (preg_match('/(' . $_singular . ')$/i', $word, $arr)) {
return preg_replace('/(' . $_singular . ')$/i', substr($arr[0], 0, 1) . substr($_plural, 1), $word);
return preg_replace('/(' . $_singular . ')$/i', substr($arr[0], 0, 1) . substr((string) $_plural, 1), $word);
}
}
}

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

@@ -31,6 +31,29 @@ use function in_array;
*/
class ImageFile extends Image
{
/**
* Image constructor with adapter configuration from Grav.
*
* @param string|null $originalFile
* @param int|null $width
* @param int|null $height
*/
public function __construct($originalFile = null, $width = null, $height = null)
{
parent::__construct($originalFile, $width, $height);
// Set the adapter based on Grav configuration
$grav = Grav::instance();
$adapter = $grav['config']->get('system.images.adapter', 'gd');
try {
$this->setAdapter($adapter);
} catch (Exception) {
$grav['log']->error(
'Image adapter "' . $adapter . '" is not available. Falling back to GD adapter.'
);
}
}
/**
* Destruct also image object.
*/

View File

@@ -13,6 +13,7 @@ use Exception;
use Grav\Common\Cache;
use Grav\Common\Config\Config;
use Grav\Common\Data\Blueprint;
use Grav\Common\File\CompiledMarkdownFile;
use Grav\Common\File\CompiledYamlFile;
use Grav\Common\Filesystem\Folder;
use Grav\Common\Grav;
@@ -31,7 +32,6 @@ use Grav\Common\Yaml;
use Grav\Framework\Flex\Flex;
use InvalidArgumentException;
use RocketTheme\Toolbox\Event\Event;
use RocketTheme\Toolbox\File\MarkdownFile;
use RuntimeException;
use SplFileInfo;
use function dirname;
@@ -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;
}
@@ -1132,12 +1140,12 @@ class Page implements PageInterface
/**
* Get file object to the page.
*
* @return MarkdownFile|null
* @return CompiledMarkdownFile|null
*/
public function file()
{
if ($this->name) {
return MarkdownFile::instance($this->filePath());
return CompiledMarkdownFile::instance($this->filePath());
}
return null;
@@ -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();
}
@@ -1739,7 +1747,7 @@ class Page implements PageInterface
$config = Grav::instance()['config'];
$escape = !$config->get('system.strict_mode.twig_compat', false) || $config->get('system.twig.autoescape', true);
$escape = !$config->get('system.strict_mode.twig2_compat', false) || $config->get('system.twig.autoescape', true);
// Get initial metadata for the page
$metadata = array_merge($metadata, $config->get('site.metadata', []));
@@ -1752,7 +1760,7 @@ class Page implements PageInterface
// Build an array of meta objects..
foreach ((array)$metadata as $key => $value) {
// Lowercase the key
$key = strtolower($key);
$key = strtolower((string) $key);
// If this is a property type metadata: "og", "twitter", "facebook" etc
// Backward compatibility for nested arrays in metas
if (is_array($value)) {

View File

@@ -48,6 +48,8 @@ use function in_array;
use function is_array;
use function is_int;
use function is_string;
use function json_encode;
use function md5;
/**
* Class Pages
@@ -96,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 */
@@ -900,7 +904,6 @@ class Pages
public function children($path)
{
$children = $this->children[(string)$path] ?? [];
return new Collection($children, [], $this);
}
@@ -1009,7 +1012,7 @@ class Pages
} else {
// Use reverse order because of B/C (previously matched multiple and returned the last match).
foreach (array_reverse($site_routes, true) as $pattern => $replace) {
$pattern = '#^' . str_replace('/', '\/', ltrim($pattern, '^')) . '#';
$pattern = '#^' . str_replace('/', '\/', ltrim((string) $pattern, '^')) . '#';
try {
$found = preg_replace($pattern, (string) $replace, $route);
if ($found && $found !== $route) {
@@ -1089,7 +1092,7 @@ class Pages
$site_redirects = $config->get('site.redirects');
if (is_array($site_redirects)) {
foreach ((array)$site_redirects as $pattern => $replace) {
$pattern = ltrim($pattern, '^');
$pattern = ltrim((string) $pattern, '^');
$pattern = '#^' . str_replace('/', '\/', $pattern) . '#';
try {
/** @var string $found */
@@ -1198,10 +1201,17 @@ class Pages
{
$grav = Grav::instance();
/** @var Pages $pages */
$pages = $grav['pages'];
/** @var Cache $cache */
$cache = $grav['cache'];
$cache_id = 'parents-' . ($rawRoutes ? 'raw-' : '') . $grav['language']->getActive();
$parents = $cache->fetch($cache_id);
$parents = $pages->getList(null, 0, $rawRoutes);
if ($parents === false) {
/** @var Pages $pages */
$pages = $grav['pages'];
$parents = $pages->getList(null, 0, $rawRoutes);
$cache->save($cache_id, $parents);
}
if (isset($grav['admin'])) {
// Remove current route from parents
@@ -1723,13 +1733,8 @@ class Pages
/** @var Language $language */
$language = $this->grav['language'];
// how should we check for last modified? Default is by file
$hash = match ($this->check_method) {
'none', 'off' => 0,
'folder' => Folder::lastModifiedFolder($pages_dirs),
'hash' => Folder::hashAllFiles($pages_dirs),
default => Folder::lastModifiedFile($pages_dirs),
};
$interval = (int)$config->get('system.cache.check.interval', 0);
$hash = $this->resolvePagesHash($pages_dirs, $interval, (string)$this->check_method);
$this->simple_pages_hash = json_encode($pages_dirs) . $hash . $config->checksum();
$this->pages_cache_id = md5($this->simple_pages_hash . $language->getActive());
@@ -1844,18 +1849,18 @@ class Pages
throw new RuntimeException('Fatal error when creating page instances.');
}
// Build regular expression for all the allowed page extensions.
$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;
$page_extension = '.md';
$last_modified = 0;
$ignore_files = array_flip($this->ignore_files);
$ignore_folders = array_flip($this->ignore_folders);
$iterator = new FilesystemIterator($directory);
foreach ($iterator as $file) {
$filename = $file->getFilename();
@@ -1868,14 +1873,14 @@ class Pages
// Handle folders later.
if ($file->isDir()) {
// But ignore all folders in ignore list.
if (!in_array($filename, $this->ignore_folders, true)) {
if (!isset($ignore_folders[$filename])) {
$folders[] = $file;
}
continue;
}
// Ignore all files in ignore list.
if (in_array($filename, $this->ignore_files, true)) {
if (isset($ignore_files[$filename])) {
continue;
}
@@ -1886,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;
}
}
}
}
@@ -2211,6 +2219,47 @@ class Pages
return $new;
}
/**
* Resolve filesystem hash for pages with optional throttling to avoid expensive scans every request.
*/
protected function resolvePagesHash(array $pagesDirs, int $interval, string $method): string|int
{
if ($method === 'none' || $method === 'off') {
return 0;
}
$resolver = static function () use ($pagesDirs, $method) {
return match ($method) {
'folder' => Folder::lastModifiedFolder($pagesDirs),
'hash' => Folder::hashAllFiles($pagesDirs),
default => Folder::lastModifiedFile($pagesDirs),
};
};
if ($interval <= 0) {
return $resolver();
}
/** @var Cache $cache */
$cache = $this->grav['cache'];
if (!$cache) {
return $resolver();
}
$configChecksum = $this->grav['config']->checksum();
$cacheKey = 'pages-hash-' . $method . '-' . md5(json_encode($pagesDirs) . $configChecksum);
$cached = $cache->fetch($cacheKey);
if ($cached !== false) {
return $cached;
}
$hash = $resolver();
$cache->save($cacheKey, $hash, $interval);
return $hash;
}
/**
* @return string
*/

View File

@@ -125,7 +125,7 @@ class Types implements \ArrayAccess, \Iterator, \Countable
{
$list = [];
foreach ($this->items as $name => $file) {
if (strpos($name, '/')) {
if (strpos((string) $name, '/')) {
continue;
}
$list[$name] = ucfirst(str_replace('_', ' ', $name));
@@ -142,7 +142,7 @@ class Types implements \ArrayAccess, \Iterator, \Countable
{
$list = [];
foreach ($this->items as $name => $file) {
if (!str_starts_with($name, 'modular/')) {
if (!str_starts_with((string) $name, 'modular/')) {
continue;
}
$list[$name] = ucfirst(trim(str_replace('_', ' ', Utils::basename($name))));

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);
@@ -205,14 +211,14 @@ class InitializeProcessor extends ProcessorBase
$keys = $aliases = [];
$env = $_ENV + $_SERVER;
foreach ($env as $key => $value) {
if (!str_starts_with($key, $prefix)) {
if (!str_starts_with((string) $key, $prefix)) {
continue;
}
if (str_starts_with($key, $cPrefix)) {
$key = str_replace('__', '.', substr($key, $cLen));
if (str_starts_with((string) $key, $cPrefix)) {
$key = str_replace('__', '.', substr((string) $key, $cLen));
$keys[$key] = $value;
} elseif (str_starts_with($key, $aPrefix)) {
$key = substr($key, $aLen);
} elseif (str_starts_with((string) $key, $aPrefix)) {
$key = substr((string) $key, $aLen);
$aliases[$key] = $value;
}
}

View File

@@ -44,7 +44,11 @@ class RenderProcessor extends ProcessorBase
}
/** @var PageInterface $page */
$page = $this->container['page'];
$page = $container['page'];
if ($container['debugger']->enabled()) {
$page->cacheControl('no-store, no-cache, must-revalidate, max-age=0');
}
// Use internal Grav output.
$container->output = $output;

View File

@@ -0,0 +1,500 @@
<?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(),
];
$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,
];
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;
}
if (preg_match('#/user/plugins/([^/]+)/#', $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
{
$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;
}
/**
* @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

@@ -75,6 +75,40 @@ class Job
private $successful = false;
/** @var string|null */
private $backlink;
// Modern Job features
/** @var int */
protected $maxAttempts = 3;
/** @var int */
protected $retryCount = 0;
/** @var int */
protected $retryDelay = 60; // seconds
/** @var string */
protected $retryStrategy = 'exponential'; // 'linear' or 'exponential'
/** @var float */
protected $executionStartTime;
/** @var float */
protected $executionDuration = 0;
/** @var int */
protected $timeout = 300; // 5 minutes default
/** @var array */
protected $dependencies = [];
/** @var array */
protected $chainedJobs = [];
/** @var string|null */
protected $queueId;
/** @var string */
protected $priority = 'normal'; // 'high', 'normal', 'low'
/** @var array */
protected $metadata = [];
/** @var array */
protected $tags = [];
/** @var callable|null */
protected $onSuccess;
/** @var callable|null */
protected $onFailure;
/** @var callable|null */
protected $onRetry;
/**
* Create a new Job instance.
@@ -147,13 +181,44 @@ class Job
return null;
}
/**
* Get raw arguments (array or string)
*
* @return array|string
*/
public function getRawArguments()
{
return $this->args;
}
/**
* @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;
}
}
/**
@@ -310,6 +375,13 @@ class Job
*/
public function run()
{
// Check dependencies (modern feature)
if (!$this->checkDependencies()) {
$this->output = 'Dependencies not met';
$this->successful = false;
return false;
}
// If the truthTest failed, don't run
if ($this->truthTest !== true) {
return false;
@@ -335,6 +407,11 @@ class Job
$args = is_string($this->args) ? explode(' ', $this->args) : $this->args;
$command = array_merge([$this->command], $args);
$process = new Process($command);
// Apply timeout if set (modern feature)
if ($this->timeout > 0) {
$process->setTimeout($this->timeout);
}
$this->process = $process;
@@ -557,4 +634,454 @@ class Job
return $this;
}
// Modern Job Methods
/**
* Set maximum retry attempts
*
* @param int $attempts
* @return self
*/
public function maxAttempts(int $attempts): self
{
$this->maxAttempts = $attempts;
return $this;
}
/**
* Get maximum retry attempts
*
* @return int
*/
public function getMaxAttempts(): int
{
return $this->maxAttempts;
}
/**
* Set retry delay
*
* @param int $seconds
* @param string $strategy 'linear' or 'exponential'
* @return self
*/
public function retryDelay(int $seconds, string $strategy = 'exponential'): self
{
$this->retryDelay = $seconds;
$this->retryStrategy = $strategy;
return $this;
}
/**
* Get current retry count
*
* @return int
*/
public function getRetryCount(): int
{
return $this->retryCount;
}
/**
* Set job timeout
*
* @param int $seconds
* @return self
*/
public function timeout(int $seconds): self
{
$this->timeout = $seconds;
return $this;
}
/**
* Set job priority
*
* @param string $priority 'high', 'normal', or 'low'
* @return self
*/
public function priority(string $priority): self
{
if (!in_array($priority, ['high', 'normal', 'low'])) {
throw new InvalidArgumentException('Priority must be high, normal, or low');
}
$this->priority = $priority;
return $this;
}
/**
* Get job priority
*
* @return string
*/
public function getPriority(): string
{
return $this->priority;
}
/**
* Add job dependency
*
* @param string $jobId
* @return self
*/
public function dependsOn(string $jobId): self
{
$this->dependencies[] = $jobId;
return $this;
}
/**
* Chain another job to run after this one
*
* @param Job $job
* @param bool $onlyOnSuccess Run only if current job succeeds
* @return self
*/
public function chain(Job $job, bool $onlyOnSuccess = true): self
{
$this->chainedJobs[] = [
'job' => $job,
'onlyOnSuccess' => $onlyOnSuccess,
];
return $this;
}
/**
* Add metadata to the job
*
* @param string $key
* @return self
*/
public function withMetadata(string $key, mixed $value): self
{
$this->metadata[$key] = $value;
return $this;
}
/**
* Add tags to the job
*
* @param array $tags
* @return self
*/
public function withTags(array $tags): self
{
$this->tags = array_merge($this->tags, $tags);
return $this;
}
/**
* Set success callback
*
* @param callable $callback
* @return self
*/
public function onSuccess(callable $callback): self
{
$this->onSuccess = $callback;
return $this;
}
/**
* Set failure callback
*
* @param callable $callback
* @return self
*/
public function onFailure(callable $callback): self
{
$this->onFailure = $callback;
return $this;
}
/**
* Set retry callback
*
* @param callable $callback
* @return self
*/
public function onRetry(callable $callback): self
{
$this->onRetry = $callback;
return $this;
}
/**
* Run the job with retry support
*
* @return bool
*/
public function runWithRetry(): bool
{
$attempts = 0;
$lastException = null;
while ($attempts < $this->maxAttempts) {
$attempts++;
$this->retryCount = $attempts - 1;
try {
// Record execution start time
$this->executionStartTime = microtime(true);
// Run the job
$result = $this->run();
// Record execution time
$this->executionDuration = microtime(true) - $this->executionStartTime;
if ($result && $this->isSuccessful()) {
// Call success callback
if ($this->onSuccess) {
call_user_func($this->onSuccess, $this);
}
// Run chained jobs
$this->runChainedJobs(true);
return true;
}
throw new RuntimeException('Job execution failed');
} catch (\Exception $e) {
$lastException = $e;
$this->output = $e->getMessage();
$this->successful = false;
if ($attempts < $this->maxAttempts) {
// Call retry callback
if ($this->onRetry) {
call_user_func($this->onRetry, $this, $attempts, $e);
}
// Calculate delay before retry
$delay = $this->calculateRetryDelay($attempts);
if ($delay > 0) {
sleep($delay);
}
} else {
// Final failure
if ($this->onFailure) {
call_user_func($this->onFailure, $this, $e);
}
// Run chained jobs that should run on failure
$this->runChainedJobs(false);
}
}
}
return false;
}
/**
* Get execution time in seconds
*
* @return float
*/
public function getExecutionTime(): float
{
return $this->executionDuration;
}
/**
* Get job metadata
*
* @param string|null $key
* @return mixed
*/
public function getMetadata(?string $key = null)
{
if ($key === null) {
return $this->metadata;
}
return $this->metadata[$key] ?? null;
}
/**
* Get job tags
*
* @return array
*/
public function getTags(): array
{
return $this->tags;
}
/**
* Check if job has a specific tag
*
* @param string $tag
* @return bool
*/
public function hasTag(string $tag): bool
{
return in_array($tag, $this->tags);
}
/**
* Set queue ID
*
* @param string $queueId
* @return self
*/
public function setQueueId(string $queueId): self
{
$this->queueId = $queueId;
return $this;
}
/**
* Get queue ID
*
* @return string|null
*/
public function getQueueId(): ?string
{
return $this->queueId;
}
/**
* Get process (for background jobs)
*
* @return Process|null
*/
public function getProcess(): ?Process
{
return $this->process;
}
/**
* Calculate retry delay based on strategy
*
* @param int $attempt
* @return int
*/
protected function calculateRetryDelay(int $attempt): int
{
if ($this->retryStrategy === 'exponential') {
return min($this->retryDelay * 2 ** ($attempt - 1), 3600); // Max 1 hour
}
return $this->retryDelay;
}
/**
* Check if dependencies are met
*
* @return bool
*/
protected function checkDependencies(): bool
{
if (empty($this->dependencies)) {
return true;
}
// This would need to check against job history or status
// For now, we'll assume dependencies are met
// In a real implementation, this would check the Scheduler's job status
return true;
}
/**
* Run chained jobs
*
* @param bool $success Whether the current job succeeded
* @return void
*/
protected function runChainedJobs(bool $success): void
{
foreach ($this->chainedJobs as $chainedJob) {
$shouldRun = !$chainedJob['onlyOnSuccess'] || $success;
if ($shouldRun) {
$job = $chainedJob['job'];
if (method_exists($job, 'runWithRetry')) {
$job->runWithRetry();
} else {
$job->run();
}
}
}
}
/**
* Convert job to array for serialization
*
* @return array
*/
public function toArray(): array
{
return [
'id' => $this->getId(),
'command' => is_string($this->command) ? $this->command : 'Closure',
'at' => $this->getAt(),
'enabled' => $this->getEnabled(),
'priority' => $this->priority,
'max_attempts' => $this->maxAttempts,
'retry_count' => $this->retryCount,
'retry_delay' => $this->retryDelay,
'retry_strategy' => $this->retryStrategy,
'timeout' => $this->timeout,
'dependencies' => $this->dependencies,
'metadata' => $this->metadata,
'tags' => $this->tags,
'execution_time' => $this->executionDuration,
'successful' => $this->successful,
'output' => $this->output,
];
}
/**
* Create job from array
*
* @param array $data
* @return self
*/
public static function fromArray(array $data): self
{
$job = new self($data['command'] ?? '', [], $data['id'] ?? null);
if (isset($data['at'])) {
$job->at($data['at']);
}
if (isset($data['priority'])) {
$job->priority($data['priority']);
}
if (isset($data['max_attempts'])) {
$job->maxAttempts($data['max_attempts']);
}
if (isset($data['retry_delay']) && isset($data['retry_strategy'])) {
$job->retryDelay($data['retry_delay'], $data['retry_strategy']);
}
if (isset($data['timeout'])) {
$job->timeout($data['timeout']);
}
if (isset($data['dependencies'])) {
foreach ($data['dependencies'] as $dep) {
$job->dependsOn($dep);
}
}
if (isset($data['metadata'])) {
foreach ($data['metadata'] as $key => $value) {
$job->withMetadata($key, $value);
}
}
if (isset($data['tags'])) {
$job->withTags($data['tags']);
}
return $job;
}
}

View File

@@ -0,0 +1,462 @@
<?php
/**
* @package Grav\Common\Scheduler
*
* @copyright Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.
* @license MIT License; see LICENSE file for details.
*/
namespace Grav\Common\Scheduler;
use DateTime;
use RocketTheme\Toolbox\File\JsonFile;
/**
* Job History Manager
*
* Provides comprehensive job execution history, logging, and analytics
*
* @package Grav\Common\Scheduler
*/
class JobHistory
{
/** @var string */
protected $historyPath;
/** @var int */
protected $retentionDays = 30;
/** @var int */
protected $maxOutputLength = 5000;
/**
* Constructor
*
* @param string $historyPath
* @param int $retentionDays
*/
public function __construct(string $historyPath, int $retentionDays = 30)
{
$this->historyPath = $historyPath;
$this->retentionDays = $retentionDays;
// Ensure history directory exists
if (!is_dir($this->historyPath)) {
mkdir($this->historyPath, 0755, true);
}
}
/**
* Log job execution
*
* @param Job $job
* @param array $metadata Additional metadata to store
* @return string Log entry ID
*/
public function logExecution(Job $job, array $metadata = []): string
{
$entryId = uniqid($job->getId() . '_', true);
$timestamp = new DateTime();
$entry = [
'id' => $entryId,
'job_id' => $job->getId(),
'command' => is_string($job->getCommand()) ? $job->getCommand() : 'Closure',
'arguments' => method_exists($job, 'getRawArguments') ? $job->getRawArguments() : $job->getArguments(),
'executed_at' => $timestamp->format('c'),
'timestamp' => $timestamp->getTimestamp(),
'success' => $job->isSuccessful(),
'output' => $this->captureOutput($job),
'execution_time' => method_exists($job, 'getExecutionTime') ? $job->getExecutionTime() : null,
'retry_count' => method_exists($job, 'getRetryCount') ? $job->getRetryCount() : 0,
'priority' => method_exists($job, 'getPriority') ? $job->getPriority() : 'normal',
'tags' => method_exists($job, 'getTags') ? $job->getTags() : [],
'metadata' => array_merge(
method_exists($job, 'getMetadata') ? $job->getMetadata() : [],
$metadata
),
];
// Store in daily file
$this->storeEntry($entry);
// Also store in job-specific history
$this->storeJobHistory($job->getId(), $entry);
return $entryId;
}
/**
* Capture job output with length limit
*
* @param Job $job
* @return array
*/
protected function captureOutput(Job $job): array
{
$output = $job->getOutput();
$truncated = false;
if (strlen((string) $output) > $this->maxOutputLength) {
$output = substr((string) $output, 0, $this->maxOutputLength);
$truncated = true;
}
return [
'content' => $output,
'truncated' => $truncated,
'length' => strlen((string) $job->getOutput()),
];
}
/**
* Store entry in daily log file
*
* @param array $entry
* @return void
*/
protected function storeEntry(array $entry): void
{
$date = date('Y-m-d');
$filename = $this->historyPath . '/' . $date . '.json';
$jsonFile = JsonFile::instance($filename);
$entries = $jsonFile->content() ?: [];
$entries[] = $entry;
$jsonFile->save($entries);
}
/**
* Store job-specific history
*
* @param string $jobId
* @param array $entry
* @return void
*/
protected function storeJobHistory(string $jobId, array $entry): void
{
$jobDir = $this->historyPath . '/jobs';
if (!is_dir($jobDir)) {
mkdir($jobDir, 0755, true);
}
$filename = $jobDir . '/' . $jobId . '.json';
$jsonFile = JsonFile::instance($filename);
$history = $jsonFile->content() ?: [];
// Keep only last 100 executions per job
$history[] = $entry;
if (count($history) > 100) {
$history = array_slice($history, -100);
}
$jsonFile->save($history);
}
/**
* Get job history
*
* @param string $jobId
* @param int $limit
* @return array
*/
public function getJobHistory(string $jobId, int $limit = 50): array
{
$filename = $this->historyPath . '/jobs/' . $jobId . '.json';
if (!file_exists($filename)) {
return [];
}
$jsonFile = JsonFile::instance($filename);
$history = $jsonFile->content() ?: [];
// Return most recent first
$history = array_reverse($history);
if ($limit > 0) {
$history = array_slice($history, 0, $limit);
}
return $history;
}
/**
* Get history for a date range
*
* @param DateTime $startDate
* @param DateTime $endDate
* @param string|null $jobId Filter by job ID
* @return array
*/
public function getHistoryRange(DateTime $startDate, DateTime $endDate, ?string $jobId = null): array
{
$history = [];
$current = clone $startDate;
while ($current <= $endDate) {
$filename = $this->historyPath . '/' . $current->format('Y-m-d') . '.json';
if (file_exists($filename)) {
$jsonFile = JsonFile::instance($filename);
$entries = $jsonFile->content() ?: [];
foreach ($entries as $entry) {
if ($jobId === null || $entry['job_id'] === $jobId) {
$history[] = $entry;
}
}
}
$current->modify('+1 day');
}
return $history;
}
/**
* Get job statistics
*
* @param string $jobId
* @param int $days Number of days to analyze
* @return array
*/
public function getJobStatistics(string $jobId, int $days = 7): array
{
$startDate = new DateTime("-{$days} days");
$endDate = new DateTime('now');
$history = $this->getHistoryRange($startDate, $endDate, $jobId);
if (empty($history)) {
return [
'total_runs' => 0,
'successful_runs' => 0,
'failed_runs' => 0,
'success_rate' => 0,
'average_execution_time' => 0,
'last_run' => null,
'last_success' => null,
'last_failure' => null,
];
}
$totalRuns = count($history);
$successfulRuns = 0;
$executionTimes = [];
$lastRun = null;
$lastSuccess = null;
$lastFailure = null;
foreach ($history as $entry) {
if ($entry['success']) {
$successfulRuns++;
if (!$lastSuccess || $entry['timestamp'] > $lastSuccess['timestamp']) {
$lastSuccess = $entry;
}
} else {
if (!$lastFailure || $entry['timestamp'] > $lastFailure['timestamp']) {
$lastFailure = $entry;
}
}
if (!$lastRun || $entry['timestamp'] > $lastRun['timestamp']) {
$lastRun = $entry;
}
if (isset($entry['execution_time']) && $entry['execution_time'] > 0) {
$executionTimes[] = $entry['execution_time'];
}
}
return [
'total_runs' => $totalRuns,
'successful_runs' => $successfulRuns,
'failed_runs' => $totalRuns - $successfulRuns,
'success_rate' => $totalRuns > 0 ? round(($successfulRuns / $totalRuns) * 100, 2) : 0,
'average_execution_time' => !empty($executionTimes) ? round(array_sum($executionTimes) / count($executionTimes), 3) : 0,
'last_run' => $lastRun,
'last_success' => $lastSuccess,
'last_failure' => $lastFailure,
];
}
/**
* Get global statistics
*
* @param int $days
* @return array
*/
public function getGlobalStatistics(int $days = 7): array
{
$startDate = new DateTime("-{$days} days");
$endDate = new DateTime('now');
$history = $this->getHistoryRange($startDate, $endDate);
$jobStats = [];
foreach ($history as $entry) {
$jobId = $entry['job_id'];
if (!isset($jobStats[$jobId])) {
$jobStats[$jobId] = [
'runs' => 0,
'success' => 0,
'failed' => 0,
];
}
$jobStats[$jobId]['runs']++;
if ($entry['success']) {
$jobStats[$jobId]['success']++;
} else {
$jobStats[$jobId]['failed']++;
}
}
return [
'total_executions' => count($history),
'unique_jobs' => count($jobStats),
'job_statistics' => $jobStats,
'period_days' => $days,
'from_date' => $startDate->format('Y-m-d'),
'to_date' => $endDate->format('Y-m-d'),
];
}
/**
* Search history
*
* @param array $criteria
* @return array
*/
public function searchHistory(array $criteria): array
{
$results = [];
// Determine date range
$startDate = isset($criteria['start_date']) ? new DateTime($criteria['start_date']) : new DateTime('-7 days');
$endDate = isset($criteria['end_date']) ? new DateTime($criteria['end_date']) : new DateTime('now');
$history = $this->getHistoryRange($startDate, $endDate, $criteria['job_id'] ?? null);
foreach ($history as $entry) {
$match = true;
// Filter by success status
if (isset($criteria['success']) && $entry['success'] !== $criteria['success']) {
$match = false;
}
// Filter by output content
if (isset($criteria['output_contains']) &&
stripos((string) $entry['output']['content'], (string) $criteria['output_contains']) === false) {
$match = false;
}
// Filter by tags
if (isset($criteria['tags']) && is_array($criteria['tags'])) {
$entryTags = $entry['tags'] ?? [];
if (empty(array_intersect($criteria['tags'], $entryTags))) {
$match = false;
}
}
if ($match) {
$results[] = $entry;
}
}
// Sort results
if (isset($criteria['sort_by'])) {
usort($results, function($a, $b) use ($criteria) {
$field = $criteria['sort_by'];
$order = $criteria['sort_order'] ?? 'desc';
$aVal = $a[$field] ?? 0;
$bVal = $b[$field] ?? 0;
if ($order === 'asc') {
return $aVal <=> $bVal;
} else {
return $bVal <=> $aVal;
}
});
}
// Limit results
if (isset($criteria['limit'])) {
$results = array_slice($results, 0, $criteria['limit']);
}
return $results;
}
/**
* Clean old history files
*
* @return int Number of files deleted
*/
public function cleanOldHistory(): int
{
$deleted = 0;
$cutoffDate = new DateTime("-{$this->retentionDays} days");
$files = glob($this->historyPath . '/*.json');
foreach ($files as $file) {
$filename = basename($file, '.json');
// Check if filename is a date
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $filename)) {
$fileDate = new DateTime($filename);
if ($fileDate < $cutoffDate) {
unlink($file);
$deleted++;
}
}
}
return $deleted;
}
/**
* Export history to CSV
*
* @param array $history
* @param string $filename
* @return bool
*/
public function exportToCsv(array $history, string $filename): bool
{
$handle = fopen($filename, 'w');
if (!$handle) {
return false;
}
// Write headers
fputcsv($handle, [
'Job ID',
'Executed At',
'Success',
'Execution Time',
'Output Length',
'Retry Count',
'Priority',
'Tags',
]);
// Write data
foreach ($history as $entry) {
fputcsv($handle, [
$entry['job_id'],
$entry['executed_at'],
$entry['success'] ? 'Yes' : 'No',
$entry['execution_time'] ?? '',
$entry['output']['length'] ?? 0,
$entry['retry_count'] ?? 0,
$entry['priority'] ?? 'normal',
implode(', ', $entry['tags'] ?? []),
]);
}
fclose($handle);
return true;
}
}

View File

@@ -0,0 +1,588 @@
<?php
/**
* @package Grav\Common\Scheduler
*
* @copyright Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.
* @license MIT License; see LICENSE file for details.
*/
namespace Grav\Common\Scheduler;
use RocketTheme\Toolbox\File\JsonFile;
use RuntimeException;
/**
* File-based job queue implementation
*
* @package Grav\Common\Scheduler
*/
class JobQueue
{
/** @var string */
protected $queuePath;
/** @var string */
protected $lockFile;
/** @var array Priority levels */
const PRIORITY_HIGH = 'high';
const PRIORITY_NORMAL = 'normal';
const PRIORITY_LOW = 'low';
/**
* JobQueue constructor
*
* @param string $queuePath
*/
public function __construct(string $queuePath)
{
$this->queuePath = $queuePath;
$this->lockFile = $queuePath . '/.lock';
// Create queue directories
$this->initializeDirectories();
}
/**
* Initialize queue directories
*
* @return void
*/
protected function initializeDirectories(): void
{
$dirs = [
$this->queuePath . '/pending',
$this->queuePath . '/processing',
$this->queuePath . '/failed',
$this->queuePath . '/completed',
];
foreach ($dirs as $dir) {
if (!file_exists($dir)) {
mkdir($dir, 0755, true);
}
}
}
/**
* Push a job to the queue
*
* @param Job $job
* @param string $priority
* @return string Job queue ID
*/
public function push(Job $job, string $priority = self::PRIORITY_NORMAL): string
{
$queueId = $this->generateQueueId($job);
$timestamp = microtime(true);
$queueItem = [
'id' => $queueId,
'job_id' => $job->getId(),
'command' => is_string($job->getCommand()) ? $job->getCommand() : 'Closure',
'arguments' => method_exists($job, 'getRawArguments') ? $job->getRawArguments() : $job->getArguments(),
'priority' => $priority,
'timestamp' => $timestamp,
'attempts' => 0,
'max_attempts' => method_exists($job, 'getMaxAttempts') ? $job->getMaxAttempts() : 1,
'created_at' => date('c'),
'scheduled_for' => null,
'metadata' => [],
];
// Always serialize the job to preserve its full state
$queueItem['serialized_job'] = base64_encode(serialize($job));
$this->writeQueueItem($queueItem, 'pending');
return $queueId;
}
/**
* Push a job for delayed execution
*
* @param Job $job
* @param \DateTime $scheduledFor
* @param string $priority
* @return string
*/
public function pushDelayed(Job $job, \DateTime $scheduledFor, string $priority = self::PRIORITY_NORMAL): string
{
$queueId = $this->push($job, $priority);
// Update the scheduled time
$item = $this->getQueueItem($queueId, 'pending');
if ($item) {
$item['scheduled_for'] = $scheduledFor->format('c');
$this->writeQueueItem($item, 'pending');
}
return $queueId;
}
/**
* Pop the next job from the queue
*
* @return Job|null
*/
public function pop(): ?Job
{
if (!$this->lock()) {
return null;
}
try {
// Get all pending items
$items = $this->getPendingItems();
if (empty($items)) {
$this->unlock();
return null;
}
// Sort by priority and timestamp
usort($items, function($a, $b) {
$priorityOrder = [
self::PRIORITY_HIGH => 0,
self::PRIORITY_NORMAL => 1,
self::PRIORITY_LOW => 2,
];
$aPriority = $priorityOrder[$a['priority']] ?? 1;
$bPriority = $priorityOrder[$b['priority']] ?? 1;
if ($aPriority !== $bPriority) {
return $aPriority - $bPriority;
}
return $a['timestamp'] <=> $b['timestamp'];
});
// Get the first item that's ready to run
$now = new \DateTime();
foreach ($items as $item) {
if ($item['scheduled_for']) {
$scheduledTime = new \DateTime($item['scheduled_for']);
if ($scheduledTime > $now) {
continue; // Skip items not yet due
}
}
// Move to processing
$this->moveQueueItem($item['id'], 'pending', 'processing');
// Reconstruct the job
$job = $this->reconstructJob($item);
$this->unlock();
return $job;
}
$this->unlock();
return null;
} catch (\Exception $e) {
$this->unlock();
throw $e;
}
}
/**
* Pop a job from the queue with its queue ID
*
* @return array|null Array with 'job' and 'id' keys
*/
public function popWithId(): ?array
{
if (!$this->lock()) {
return null;
}
try {
// Get all pending items
$items = $this->getPendingItems();
if (empty($items)) {
$this->unlock();
return null;
}
// Sort by priority and timestamp
usort($items, function($a, $b) {
$priorityOrder = [
self::PRIORITY_HIGH => 0,
self::PRIORITY_NORMAL => 1,
self::PRIORITY_LOW => 2,
];
$aPriority = $priorityOrder[$a['priority']] ?? 1;
$bPriority = $priorityOrder[$b['priority']] ?? 1;
if ($aPriority !== $bPriority) {
return $aPriority - $bPriority;
}
return $a['timestamp'] <=> $b['timestamp'];
});
// Get the first item that's ready to run
$now = new \DateTime();
foreach ($items as $item) {
if ($item['scheduled_for']) {
$scheduledTime = new \DateTime($item['scheduled_for']);
if ($scheduledTime > $now) {
continue; // Skip items not yet due
}
}
// Reconstruct the job first before moving it
$job = $this->reconstructJob($item);
if (!$job) {
// Failed to reconstruct, skip this item
continue;
}
// Move to processing only if we can reconstruct the job
$this->moveQueueItem($item['id'], 'pending', 'processing');
$this->unlock();
return ['job' => $job, 'id' => $item['id']];
}
$this->unlock();
return null;
} catch (\Exception $e) {
$this->unlock();
throw $e;
}
}
/**
* Mark a job as completed
*
* @param string $queueId
* @return void
*/
public function complete(string $queueId): void
{
$this->moveQueueItem($queueId, 'processing', 'completed');
// Clean up old completed items
$this->cleanupCompleted();
}
/**
* Mark a job as failed
*
* @param string $queueId
* @param string $error
* @return void
*/
public function fail(string $queueId, string $error = ''): void
{
$item = $this->getQueueItem($queueId, 'processing');
if ($item) {
$item['attempts']++;
$item['last_error'] = $error;
$item['failed_at'] = date('c');
if ($item['attempts'] < $item['max_attempts']) {
// Move back to pending for retry
$item['retry_at'] = $this->calculateRetryTime($item['attempts']);
$item['scheduled_for'] = $item['retry_at'];
$this->writeQueueItem($item, 'pending');
$this->deleteQueueItem($queueId, 'processing');
} else {
// Move to failed (dead letter queue)
$this->writeQueueItem($item, 'failed');
$this->deleteQueueItem($queueId, 'processing');
}
}
}
/**
* Get queue size
*
* @return int
*/
public function size(): int
{
return count($this->getPendingItems());
}
/**
* Check if queue is empty
*
* @return bool
*/
public function isEmpty(): bool
{
return $this->size() === 0;
}
/**
* Get queue statistics
*
* @return array
*/
public function getStatistics(): array
{
return [
'pending' => count($this->getPendingItems()),
'processing' => count($this->getItemsInDirectory('processing')),
'failed' => count($this->getItemsInDirectory('failed')),
'completed_today' => $this->countCompletedToday(),
];
}
/**
* Generate a unique queue ID
*
* @param Job $job
* @return string
*/
protected function generateQueueId(Job $job): string
{
return $job->getId() . '_' . uniqid('', true);
}
/**
* Write queue item to disk
*
* @param array $item
* @param string $directory
* @return void
*/
protected function writeQueueItem(array $item, string $directory): void
{
$path = $this->queuePath . '/' . $directory . '/' . $item['id'] . '.json';
$file = JsonFile::instance($path);
$file->save($item);
}
/**
* Read queue item from disk
*
* @param string $queueId
* @param string $directory
* @return array|null
*/
protected function getQueueItem(string $queueId, string $directory): ?array
{
$path = $this->queuePath . '/' . $directory . '/' . $queueId . '.json';
if (!file_exists($path)) {
return null;
}
$file = JsonFile::instance($path);
return $file->content();
}
/**
* Delete queue item
*
* @param string $queueId
* @param string $directory
* @return void
*/
protected function deleteQueueItem(string $queueId, string $directory): void
{
$path = $this->queuePath . '/' . $directory . '/' . $queueId . '.json';
if (file_exists($path)) {
unlink($path);
}
}
/**
* Move queue item between directories
*
* @param string $queueId
* @param string $fromDir
* @param string $toDir
* @return void
*/
protected function moveQueueItem(string $queueId, string $fromDir, string $toDir): void
{
$fromPath = $this->queuePath . '/' . $fromDir . '/' . $queueId . '.json';
$toPath = $this->queuePath . '/' . $toDir . '/' . $queueId . '.json';
if (file_exists($fromPath)) {
rename($fromPath, $toPath);
}
}
/**
* Get all pending items
*
* @return array
*/
protected function getPendingItems(): array
{
return $this->getItemsInDirectory('pending');
}
/**
* Get items in a specific directory
*
* @param string $directory
* @return array
*/
protected function getItemsInDirectory(string $directory): array
{
$items = [];
$path = $this->queuePath . '/' . $directory;
if (!is_dir($path)) {
return $items;
}
$files = glob($path . '/*.json');
foreach ($files as $file) {
$jsonFile = JsonFile::instance($file);
$items[] = $jsonFile->content();
}
return $items;
}
/**
* Reconstruct a job from queue item
*
* @param array $item
* @return Job|null
*/
protected function reconstructJob(array $item): ?Job
{
if (isset($item['serialized_job'])) {
// Unserialize the job
try {
$job = unserialize(base64_decode((string) $item['serialized_job']));
if ($job instanceof Job) {
return $job;
}
} catch (\Exception) {
// Failed to unserialize
return null;
}
}
// Create a new job from command
if (isset($item['command'])) {
$args = $item['arguments'] ?? [];
$job = new Job($item['command'], $args, $item['job_id']);
return $job;
}
return null;
}
/**
* Calculate retry time with exponential backoff
*
* @param int $attempts
* @return string
*/
protected function calculateRetryTime(int $attempts): string
{
$backoffSeconds = min(2 ** $attempts * 60, 3600); // Max 1 hour
$retryTime = new \DateTime();
$retryTime->modify("+{$backoffSeconds} seconds");
return $retryTime->format('c');
}
/**
* Clean up old completed items
*
* @return void
*/
protected function cleanupCompleted(): void
{
$items = $this->getItemsInDirectory('completed');
$cutoff = new \DateTime('-24 hours');
foreach ($items as $item) {
if (isset($item['created_at'])) {
$createdAt = new \DateTime($item['created_at']);
if ($createdAt < $cutoff) {
$this->deleteQueueItem($item['id'], 'completed');
}
}
}
}
/**
* Count completed jobs today
*
* @return int
*/
protected function countCompletedToday(): int
{
$items = $this->getItemsInDirectory('completed');
$today = new \DateTime('today');
$count = 0;
foreach ($items as $item) {
if (isset($item['created_at'])) {
$createdAt = new \DateTime($item['created_at']);
if ($createdAt >= $today) {
$count++;
}
}
}
return $count;
}
/**
* Acquire lock for queue operations
*
* @return bool
*/
protected function lock(): bool
{
$attempts = 0;
$maxAttempts = 50; // 5 seconds total
while ($attempts < $maxAttempts) {
// Check if lock file exists and is stale (older than 30 seconds)
if (file_exists($this->lockFile)) {
$lockAge = time() - filemtime($this->lockFile);
if ($lockAge > 30) {
// Stale lock, remove it
@unlink($this->lockFile);
}
}
// Try to acquire lock atomically
$handle = @fopen($this->lockFile, 'x');
if ($handle !== false) {
fclose($handle);
return true;
}
$attempts++;
usleep(100000); // 100ms
}
// Could not acquire lock
return false;
}
/**
* Release queue lock
*
* @return void
*/
protected function unlock(): void
{
if (file_exists($this->lockFile)) {
unlink($this->lockFile);
}
}
}

View File

@@ -17,6 +17,9 @@ use InvalidArgumentException;
use Symfony\Component\Process\PhpExecutableFinder;
use Symfony\Component\Process\Process;
use RocketTheme\Toolbox\File\YamlFile;
use Symfony\Component\Yaml\Yaml;
use Monolog\Logger;
use Monolog\Handler\StreamHandler;
use function is_callable;
use function is_string;
@@ -49,19 +52,57 @@ class Scheduler
/** @var string */
private $status_path;
// Modern features (backward compatible - disabled by default)
/** @var JobQueue|null */
protected $jobQueue = null;
/** @var array */
protected $workers = [];
/** @var int */
protected $maxWorkers = 1;
/** @var bool */
protected $webhookEnabled = false;
/** @var string|null */
protected $webhookToken = null;
/** @var bool */
protected $healthEnabled = true;
/** @var string */
protected $queuePath;
/** @var string */
protected $historyPath;
/** @var Logger|null */
protected $logger = null;
/** @var array */
protected $modernConfig = [];
/**
* Create new instance.
*/
public function __construct()
{
$config = Grav::instance()['config']->get('scheduler.defaults', []);
$grav = Grav::instance();
$config = $grav['config']->get('scheduler.defaults', []);
$this->config = $config;
$this->status_path = Grav::instance()['locator']->findResource('user-data://scheduler', true, true);
$locator = $grav['locator'];
$this->status_path = $locator->findResource('user-data://scheduler', true, true);
if (!file_exists($this->status_path)) {
Folder::create($this->status_path);
}
// Initialize modern features (always enabled now)
$this->modernConfig = $grav['config']->get('scheduler.modern', []);
// Always initialize modern features - they're now part of core
$this->initializeModernFeatures($locator);
}
/**
@@ -71,12 +112,33 @@ class Scheduler
*/
public function loadSavedJobs()
{
// Only load saved jobs if they haven't been loaded yet
if (!empty($this->saved_jobs)) {
return $this;
}
$this->saved_jobs = [];
$saved_jobs = (array) Grav::instance()['config']->get('scheduler.custom_jobs', []);
foreach ($saved_jobs as $id => $j) {
$args = $j['args'] ?? [];
$id = Grav::instance()['inflector']->hyphenize($id);
// Check if job already exists to prevent duplicates
$existingJob = null;
foreach ($this->jobs as $existingJobItem) {
if ($existingJobItem->getId() === $id) {
$existingJob = $existingJobItem;
break;
}
}
if ($existingJob) {
// Job already exists, just update saved_jobs reference
$this->saved_jobs[] = $existingJob;
continue;
}
$job = $this->addCommand($j['command'], $args, $id);
if (isset($j['at'])) {
@@ -121,6 +183,16 @@ class Scheduler
return [$background, $foreground];
}
/**
* Get the job queue
*
* @return JobQueue|null
*/
public function getJobQueue(): ?JobQueue
{
return $this->jobQueue;
}
/**
* Get all jobs if they are disabled or not as one array
*
@@ -190,6 +262,13 @@ class Scheduler
*/
public function run(?DateTime $runTime = null, $force = false)
{
// Initialize system jobs if not already done
$grav = Grav::instance();
if (count($this->jobs) === 0) {
// Trigger event to load system jobs (cache-purge, cache-clear, backups, etc.)
$grav->fireEvent('onSchedulerInitialized', new \RocketTheme\Toolbox\Event\Event(['scheduler' => $this]));
}
$this->loadSavedJobs();
[$background, $foreground] = $this->getQueuedJobs(false);
@@ -199,24 +278,92 @@ class Scheduler
$runTime = new DateTime('now');
}
// Star processing jobs
foreach ($alljobs as $job) {
if ($job->isDue($runTime) || $force) {
$job->run();
$this->jobs_run[] = $job;
// Log scheduler run
if ($this->logger) {
$jobCount = count($alljobs);
$forceStr = $force ? ' (forced)' : '';
$this->logger->debug("Scheduler run started - {$jobCount} jobs available{$forceStr}", [
'time' => $runTime->format('Y-m-d H:i:s')
]);
}
// Process jobs based on modern features
if ($this->jobQueue && ($this->modernConfig['queue']['enabled'] ?? false)) {
// Queue jobs for processing
$queuedCount = 0;
foreach ($alljobs as $job) {
if ($job->isDue($runTime) || $force) {
// Add to queue for concurrent processing
$this->jobQueue->push($job);
$queuedCount++;
}
}
if ($this->logger && $queuedCount > 0) {
$this->logger->debug("Queued {$queuedCount} job(s) for processing");
}
// Process queue with workers
$this->processJobsWithWorkers();
// When using queue, states are saved by executeJob when jobs complete
// Don't save states here as jobs may still be processing
} else {
// Legacy processing (one at a time)
foreach ($alljobs as $job) {
if ($job->isDue($runTime) || $force) {
$job->run();
$this->jobs_run[] = $job;
}
}
// Finish handling any background jobs
foreach ($background as $job) {
$job->finalize();
}
// Store states for legacy mode
$this->saveJobStates();
// Save history if enabled
if (($this->modernConfig['history']['enabled'] ?? false) && $this->historyPath) {
$this->saveJobHistory();
}
}
// Finish handling any background jobs
foreach ($background as $job) {
$job->finalize();
// Log run summary
if ($this->logger) {
$successCount = 0;
$failureCount = 0;
$failedJobNames = [];
$executedJobs = array_merge($this->executed_jobs, $this->jobs_run);
foreach ($executedJobs as $job) {
if ($job->isSuccessful()) {
$successCount++;
} else {
$failureCount++;
$failedJobNames[] = $job->getId();
}
}
if (count($executedJobs) > 0) {
if ($failureCount > 0) {
$failedList = implode(', ', $failedJobNames);
$this->logger->warning("Scheduler completed: {$successCount} succeeded, {$failureCount} failed (failed: {$failedList})");
} else {
$this->logger->info("Scheduler completed: {$successCount} job(s) succeeded");
}
} else {
$this->logger->debug('Scheduler completed: no jobs were due');
}
}
// Store states
$this->saveJobStates();
// Store run date
file_put_contents("logs/lastcron.run", (new DateTime("now"))->format("Y-m-d H:i:s"), LOCK_EX);
// Update last run timestamp for health checks
$this->updateLastRun();
}
/**
@@ -374,6 +521,113 @@ class Scheduler
}
/**
* Initialize modern features
*
* @return void
*/
protected function initializeModernFeatures(mixed $locator): void
{
// Set up paths
$this->queuePath = $this->modernConfig['queue']['path'] ?? 'user-data://scheduler/queue';
$this->queuePath = $locator->findResource($this->queuePath, true, true);
$this->historyPath = $this->modernConfig['history']['path'] ?? 'user-data://scheduler/history';
$this->historyPath = $locator->findResource($this->historyPath, true, true);
// Create directories if they don't exist
if (!file_exists($this->queuePath)) {
Folder::create($this->queuePath);
}
if (!file_exists($this->historyPath)) {
Folder::create($this->historyPath);
}
// Initialize job queue (always enabled)
$this->jobQueue = new JobQueue($this->queuePath);
// Initialize scheduler logger
$this->initializeLogger($locator);
// Configure workers (default to 4 for concurrent processing)
$this->maxWorkers = $this->modernConfig['workers'] ?? 4;
// Configure webhook
$this->webhookEnabled = $this->modernConfig['webhook']['enabled'] ?? false;
$this->webhookToken = $this->modernConfig['webhook']['token'] ?? null;
// Configure health check
$this->healthEnabled = $this->modernConfig['health']['enabled'] ?? true;
}
/**
* Get the job queue
*
* @return JobQueue|null
*/
public function getQueue(): ?JobQueue
{
return $this->jobQueue;
}
/**
* Initialize the scheduler logger
*
* @param $locator
* @return void
*/
protected function initializeLogger($locator): void
{
$this->logger = new Logger('scheduler');
// Single scheduler log file - all levels
$logFile = $locator->findResource('log://scheduler.log', true, true);
$this->logger->pushHandler(new StreamHandler($logFile, Logger::DEBUG));
}
/**
* Get the scheduler logger
*
* @return Logger|null
*/
public function getLogger(): ?Logger
{
return $this->logger;
}
/**
* Check if webhook is enabled
*
* @return bool
*/
public function isWebhookEnabled(): bool
{
return $this->webhookEnabled;
}
/**
* Get active trigger methods
*
* @return array
*/
public function getActiveTriggers(): array
{
$triggers = [];
$cronStatus = $this->isCrontabSetup();
if ($cronStatus === 1) {
$triggers[] = 'cron';
}
// Check if webhook is enabled
if ($this->isWebhookEnabled()) {
$triggers[] = 'webhook';
}
return $triggers;
}
/**
* Queue a job for execution in the correct queue.
*
@@ -440,4 +694,410 @@ class Scheduler
return $job;
}
/**
* Process jobs using multiple workers
*
* @return void
*/
protected function processJobsWithWorkers(): void
{
if (!$this->jobQueue) {
return;
}
// Process all queued jobs
while (!$this->jobQueue->isEmpty()) {
// Wait if we've reached max workers
while (count($this->workers) >= $this->maxWorkers) {
foreach ($this->workers as $workerId => $worker) {
$process = null;
if (is_array($worker) && isset($worker['process'])) {
$process = $worker['process'];
} elseif ($worker instanceof Process) {
$process = $worker;
}
if ($process instanceof Process && !$process->isRunning()) {
// Finalize job if needed
if (is_array($worker) && isset($worker['job'])) {
$worker['job']->finalize();
// Save job state
$this->saveJobState($worker['job']);
// Update queue status
if (isset($worker['queueId']) && $this->jobQueue) {
if ($worker['job']->isSuccessful()) {
$this->jobQueue->complete($worker['queueId']);
} else {
$this->jobQueue->fail($worker['queueId'], $worker['job']->getOutput() ?: 'Job failed');
}
}
}
unset($this->workers[$workerId]);
}
}
if (count($this->workers) >= $this->maxWorkers) {
usleep(100000); // Wait 100ms
}
}
// Get next job from queue
$queueItem = $this->jobQueue->popWithId();
if ($queueItem) {
$this->executeJob($queueItem['job'], $queueItem['id']);
}
}
// Wait for all remaining workers to complete
foreach ($this->workers as $workerId => $worker) {
if (is_array($worker) && isset($worker['process'])) {
$process = $worker['process'];
if ($process instanceof Process) {
$process->wait();
// Finalize and save state for background jobs
if (isset($worker['job'])) {
$worker['job']->finalize();
$this->saveJobState($worker['job']);
// Log background job completion
if ($this->logger) {
$job = $worker['job'];
$jobId = $job->getId();
$command = is_string($job->getCommand()) ? $job->getCommand() : 'Closure';
if ($job->isSuccessful()) {
$execTime = method_exists($job, 'getExecutionTime') ? $job->getExecutionTime() : null;
$timeStr = $execTime ? sprintf(' (%.2fs)', $execTime) : '';
$this->logger->info("Job '{$jobId}' completed successfully{$timeStr}", [
'command' => $command,
'background' => true
]);
} else {
$error = trim((string) $job->getOutput()) ?: 'Unknown error';
$this->logger->error("Job '{$jobId}' failed: {$error}", [
'command' => $command,
'background' => true
]);
}
}
}
// Update queue status for background jobs
if (isset($worker['queueId']) && $this->jobQueue) {
$job = $worker['job'];
if ($job->isSuccessful()) {
$this->jobQueue->complete($worker['queueId']);
} else {
$this->jobQueue->fail($worker['queueId'], $job->getOutput() ?: 'Job execution failed');
}
}
unset($this->workers[$workerId]);
}
} elseif ($worker instanceof Process) {
// Legacy format
$worker->wait();
unset($this->workers[$workerId]);
}
}
}
/**
* Process existing queued jobs
*
* @return void
*/
protected function processQueuedJobs(): void
{
if (!$this->jobQueue) {
return;
}
// Process any existing queued jobs from previous runs
while (!$this->jobQueue->isEmpty() && count($this->workers) < $this->maxWorkers) {
$job = $this->jobQueue->pop();
if ($job) {
$this->executeJob($job);
}
}
}
/**
* Execute a job
*
* @param Job $job
* @param string|null $queueId Queue ID if job came from queue
* @return void
*/
protected function executeJob(Job $job, ?string $queueId = null): void
{
$job->run();
$this->jobs_run[] = $job;
// Save job state after execution
$this->saveJobState($job);
// Check if job runs in background
if ($job->runInBackground()) {
// Background job - track it for later completion
$process = $job->getProcess();
if ($process && $process->isStarted()) {
$this->workers[] = [
'process' => $process,
'job' => $job,
'queueId' => $queueId
];
// Don't update queue status yet - will be done when process completes
return;
}
}
// Foreground job or background job that didn't start - update queue status immediately
if ($queueId && $this->jobQueue) {
// Job has already been finalized if it ran in foreground
if (!$job->runInBackground()) {
$job->finalize();
}
if ($job->isSuccessful()) {
// Move from processing to completed
$this->jobQueue->complete($queueId);
} else {
// Move from processing to failed
$this->jobQueue->fail($queueId, $job->getOutput() ?: 'Job execution failed');
}
}
// Log foreground jobs immediately
if (!$job->runInBackground() && $this->logger) {
$jobId = $job->getId();
$command = is_string($job->getCommand()) ? $job->getCommand() : 'Closure';
if ($job->isSuccessful()) {
$execTime = method_exists($job, 'getExecutionTime') ? $job->getExecutionTime() : null;
$timeStr = $execTime ? sprintf(' (%.2fs)', $execTime) : '';
$this->logger->info("Job '{$jobId}' completed successfully{$timeStr}", [
'command' => $command
]);
} else {
$error = trim((string) $job->getOutput()) ?: 'Unknown error';
$this->logger->error("Job '{$jobId}' failed: {$error}", [
'command' => $command
]);
}
}
}
/**
* Save state for a single job
*
* @param Job $job
* @return void
*/
protected function saveJobState(Job $job): void
{
$grav = Grav::instance();
$locator = $grav['locator'];
$statusFile = $locator->findResource('user-data://scheduler/status.yaml', true, true);
$status = [];
if (file_exists($statusFile)) {
$status = Yaml::parseFile($statusFile) ?: [];
}
// Update job status
$status[$job->getId()] = [
'state' => $job->isSuccessful() ? 'success' : 'failure',
'last-run' => time(),
];
// Add error if job failed
if (!$job->isSuccessful()) {
$output = $job->getOutput();
if ($output) {
$status[$job->getId()]['error'] = $output;
} else {
$status[$job->getId()]['error'] = null;
}
}
file_put_contents($statusFile, Yaml::dump($status));
}
/**
* Save job execution history
*
* @return void
*/
protected function saveJobHistory(): void
{
if (!$this->historyPath) {
return;
}
$history = [];
foreach ($this->jobs_run as $job) {
$history[] = [
'id' => $job->getId(),
'executed_at' => date('c'),
'success' => $job->isSuccessful(),
'output' => substr((string) $job->getOutput(), 0, 1000),
];
}
if (!empty($history)) {
$filename = $this->historyPath . '/' . date('Y-m-d') . '.json';
$existing = file_exists($filename) ? json_decode(file_get_contents($filename), true) : [];
$existing = array_merge($existing, $history);
file_put_contents($filename, json_encode($existing, JSON_PRETTY_PRINT));
}
}
/**
* Update last run timestamp
*
* @return void
*/
protected function updateLastRun(): void
{
$lastRunFile = $this->status_path . '/last_run.txt';
file_put_contents($lastRunFile, date('Y-m-d H:i:s'));
}
/**
* Get health status
*
* @return array
*/
public function getHealthStatus(): array
{
$lastRunFile = $this->status_path . '/last_run.txt';
$lastRun = file_exists($lastRunFile) ? file_get_contents($lastRunFile) : null;
// Initialize system jobs if not already done
$grav = Grav::instance();
if (count($this->jobs) === 0) {
// Trigger event to load system jobs (cache-purge, cache-clear, backups, etc.)
$grav->fireEvent('onSchedulerInitialized', new \RocketTheme\Toolbox\Event\Event(['scheduler' => $this]));
}
// Load custom jobs
$this->loadSavedJobs();
// Get only enabled jobs for health status
[$background, $foreground] = $this->getQueuedJobs(false);
$enabledJobs = array_merge($background, $foreground);
$now = new DateTime('now');
$dueJobs = 0;
foreach ($enabledJobs as $job) {
if ($job->isDue($now)) {
$dueJobs++;
}
}
$health = [
'status' => 'healthy',
'last_run' => $lastRun,
'last_run_age' => null,
'queue_size' => 0,
'failed_jobs_24h' => 0,
'scheduled_jobs' => count($enabledJobs),
'jobs_due' => $dueJobs,
'webhook_enabled' => $this->webhookEnabled,
'health_check_enabled' => $this->healthEnabled,
'timestamp' => date('c'),
];
// Calculate last run age
if ($lastRun) {
$lastRunTime = new DateTime($lastRun);
$health['last_run_age'] = $now->getTimestamp() - $lastRunTime->getTimestamp();
}
// Determine status based on whether jobs are due
if ($dueJobs > 0) {
// Jobs are due but haven't been run
if ($health['last_run_age'] === null || $health['last_run_age'] > 300) { // No run or older than 5 minutes
$health['status'] = 'warning';
$health['message'] = $dueJobs . ' job(s) are due to run';
}
} else {
// No jobs are due - this is healthy
$health['status'] = 'healthy';
$health['message'] = 'No jobs currently due';
}
// Add queue stats if available
if ($this->jobQueue) {
$stats = $this->jobQueue->getStatistics();
$health['queue_size'] = $stats['pending'] ?? 0;
$health['failed_jobs_24h'] = $stats['failed'] ?? 0;
}
return $health;
}
/**
* Process webhook trigger
*
* @param string|null $token
* @param string|null $jobId
* @return array
*/
public function processWebhookTrigger($token = null, $jobId = null): array
{
if (!$this->webhookEnabled) {
return ['success' => false, 'message' => 'Webhook triggers are not enabled'];
}
if ($this->webhookToken && $token !== $this->webhookToken) {
return ['success' => false, 'message' => 'Invalid webhook token'];
}
// Initialize system jobs if not already done
$grav = Grav::instance();
if (count($this->jobs) === 0) {
// Trigger event to load system jobs (cache-purge, cache-clear, backups, etc.)
$grav->fireEvent('onSchedulerInitialized', new \RocketTheme\Toolbox\Event\Event(['scheduler' => $this]));
}
// Load custom jobs
$this->loadSavedJobs();
if ($jobId) {
// Force run specific job
$job = $this->getJob($jobId);
if ($job) {
$job->inForeground()->run();
$this->jobs_run[] = $job;
$this->saveJobStates();
$this->updateLastRun();
return [
'success' => $job->isSuccessful(),
'message' => $job->isSuccessful() ? 'Job force-executed successfully' : 'Job execution failed',
'job_id' => $jobId,
'forced' => true,
'output' => $job->getOutput(),
];
} else {
return ['success' => false, 'message' => 'Job not found: ' . $jobId];
}
} else {
// Run all due jobs
$this->run();
return [
'success' => true,
'message' => 'Scheduler executed (due jobs only)',
'jobs_run' => count($this->jobs_run),
'timestamp' => date('c'),
];
}
}
}

View File

@@ -0,0 +1,270 @@
<?php
/**
* @package Grav\Common\Scheduler
*
* @copyright Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.
* @license MIT License; see LICENSE file for details.
*/
namespace Grav\Common\Scheduler;
use Grav\Common\Grav;
use Grav\Common\Utils;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
/**
* Scheduler Controller for handling HTTP endpoints
*
* @package Grav\Common\Scheduler
*/
class SchedulerController
{
/** @var Grav */
protected $grav;
/** @var ModernScheduler */
protected $scheduler;
/**
* SchedulerController constructor
*
* @param Grav $grav
*/
public function __construct(Grav $grav)
{
$this->grav = $grav;
// Get scheduler instance
$scheduler = $grav['scheduler'];
if ($scheduler instanceof ModernScheduler) {
$this->scheduler = $scheduler;
} else {
// Create ModernScheduler instance if not already
$this->scheduler = new ModernScheduler();
}
}
/**
* Handle health check endpoint
*
* @param ServerRequestInterface $request
* @return ResponseInterface
*/
public function health(ServerRequestInterface $request): ResponseInterface
{
$config = $this->grav['config']->get('scheduler.modern', []);
// Check if health endpoint is enabled
if (!($config['health']['enabled'] ?? true)) {
return $this->jsonResponse(['error' => 'Health check disabled'], 403);
}
// Get health status
$health = $this->scheduler->getHealthStatus();
return $this->jsonResponse($health);
}
/**
* Handle webhook trigger endpoint
*
* @param ServerRequestInterface $request
* @return ResponseInterface
*/
public function webhook(ServerRequestInterface $request): ResponseInterface
{
$config = $this->grav['config']->get('scheduler.modern', []);
// Check if webhook is enabled
if (!($config['webhook']['enabled'] ?? false)) {
return $this->jsonResponse(['error' => 'Webhook triggers disabled'], 403);
}
// Get authorization header
$authHeader = $request->getHeaderLine('Authorization');
$token = null;
if (preg_match('/Bearer\s+(.+)$/i', $authHeader, $matches)) {
$token = $matches[1];
}
// Get query parameters
$params = $request->getQueryParams();
$jobId = $params['job'] ?? null;
// Process webhook
$result = $this->scheduler->processWebhookTrigger($token, $jobId);
$statusCode = $result['success'] ? 200 : 400;
return $this->jsonResponse($result, $statusCode);
}
/**
* Handle statistics endpoint
*
* @param ServerRequestInterface $request
* @return ResponseInterface
*/
public function statistics(ServerRequestInterface $request): ResponseInterface
{
// Check if user is admin
$user = $this->grav['user'] ?? null;
if (!$user || !$user->authorize('admin.super')) {
return $this->jsonResponse(['error' => 'Unauthorized'], 401);
}
$stats = $this->scheduler->getStatistics();
return $this->jsonResponse($stats);
}
/**
* Handle admin AJAX requests for scheduler status
*
* @param ServerRequestInterface $request
* @return ResponseInterface
*/
public function adminStatus(ServerRequestInterface $request): ResponseInterface
{
// Check if user is admin
$user = $this->grav['user'] ?? null;
if (!$user || !$user->authorize('admin.scheduler')) {
return $this->jsonResponse(['error' => 'Unauthorized'], 401);
}
$health = $this->scheduler->getHealthStatus();
// Format for admin display
$response = [
'health' => $this->formatHealthStatus($health),
'triggers' => $this->formatTriggers($health['trigger_methods'] ?? [])
];
return $this->jsonResponse($response);
}
/**
* Format health status for display
*
* @param array $health
* @return string
*/
protected function formatHealthStatus(array $health): string
{
$status = $health['status'] ?? 'unknown';
$lastRun = $health['last_run'] ?? null;
$queueSize = $health['queue_size'] ?? 0;
$failedJobs = $health['failed_jobs_24h'] ?? 0;
$jobsDue = $health['jobs_due'] ?? 0;
$message = $health['message'] ?? '';
$statusBadge = match($status) {
'healthy' => '<span class="badge badge-success">Healthy</span>',
'warning' => '<span class="badge badge-warning">Warning</span>',
'critical' => '<span class="badge badge-danger">Critical</span>',
default => '<span class="badge badge-secondary">Unknown</span>'
};
$html = '<div class="scheduler-health">';
$html .= '<p>Status: ' . $statusBadge;
if ($message) {
$html .= ' - ' . htmlspecialchars((string) $message);
}
$html .= '</p>';
if ($lastRun) {
$lastRunTime = new \DateTime($lastRun);
$now = new \DateTime();
$diff = $now->diff($lastRunTime);
$timeAgo = '';
if ($diff->d > 0) {
$timeAgo = $diff->d . ' day' . ($diff->d > 1 ? 's' : '') . ' ago';
} elseif ($diff->h > 0) {
$timeAgo = $diff->h . ' hour' . ($diff->h > 1 ? 's' : '') . ' ago';
} elseif ($diff->i > 0) {
$timeAgo = $diff->i . ' minute' . ($diff->i > 1 ? 's' : '') . ' ago';
} else {
$timeAgo = 'Less than a minute ago';
}
$html .= '<p>Last Run: <strong>' . $timeAgo . '</strong></p>';
} else {
$html .= '<p>Last Run: <strong>Never</strong></p>';
}
$html .= '<p>Jobs Due: <strong>' . $jobsDue . '</strong></p>';
$html .= '<p>Queue Size: <strong>' . $queueSize . '</strong></p>';
if ($failedJobs > 0) {
$html .= '<p class="text-danger">Failed Jobs (24h): <strong>' . $failedJobs . '</strong></p>';
}
$html .= '</div>';
return $html;
}
/**
* Format triggers for display
*
* @param array $triggers
* @return string
*/
protected function formatTriggers(array $triggers): string
{
if (empty($triggers)) {
return '<div class="alert alert-warning">No active triggers detected. Please set up cron, systemd, or webhook triggers.</div>';
}
$html = '<div class="scheduler-triggers">';
$html .= '<ul class="list-unstyled">';
foreach ($triggers as $trigger) {
$icon = match($trigger) {
'cron' => '⏰',
'systemd' => '⚙️',
'webhook' => '🔗',
'external' => '🌐',
default => '•'
};
$label = match($trigger) {
'cron' => 'Cron Job',
'systemd' => 'Systemd Timer',
'webhook' => 'Webhook Triggers',
'external' => 'External Triggers',
default => ucfirst((string) $trigger)
};
$html .= '<li>' . $icon . ' <strong>' . $label . '</strong> <span class="badge badge-success">Active</span></li>';
}
$html .= '</ul>';
$html .= '</div>';
return $html;
}
/**
* Create JSON response
*
* @param array $data
* @param int $statusCode
* @return ResponseInterface
*/
protected function jsonResponse(array $data, int $statusCode = 200): ResponseInterface
{
$response = $this->grav['response'] ?? new \Nyholm\Psr7\Response();
$response = $response->withStatus($statusCode)
->withHeader('Content-Type', 'application/json');
$body = $response->getBody();
$body->write(json_encode($data));
return $response;
}
}

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,161 @@ 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)
$quotedFunctions = array_map(fn($f) => preg_quote($f, '/'), $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

@@ -10,6 +10,8 @@
namespace Grav\Common\Service;
use Grav\Common\Scheduler\Scheduler;
use Grav\Common\Scheduler\JobQueue;
use Grav\Common\Scheduler\JobWorker;
use Pimple\Container;
use Pimple\ServiceProviderInterface;
@@ -25,6 +27,38 @@ class SchedulerServiceProvider implements ServiceProviderInterface
*/
public function register(Container $container)
{
$container['scheduler'] = fn() => new Scheduler();
$container['scheduler'] = function ($c) {
$config = $c['config'];
$scheduler = new Scheduler();
// Configure modern features if enabled
$modernConfig = $config->get('scheduler.modern', []);
if ($modernConfig['enabled'] ?? false) {
// Initialize components
$queuePath = $c['locator']->findResource('user-data://scheduler/queue', true, true);
$statusPath = $c['locator']->findResource('user-data://scheduler/status.yaml', true, true);
// Set modern configuration on scheduler
$scheduler->setModernConfig($modernConfig);
// Initialize job queue if enabled
if ($modernConfig['queue']['enabled'] ?? false) {
$jobQueue = new JobQueue($queuePath);
$scheduler->setJobQueue($jobQueue);
}
// Initialize workers if enabled
if ($modernConfig['workers']['enabled'] ?? false) {
$workerCount = $modernConfig['workers']['count'] ?? 2;
$workers = [];
for ($i = 0; $i < $workerCount; $i++) {
$workers[] = new JobWorker("worker-{$i}");
}
$scheduler->setWorkers($workers);
}
}
return $scheduler;
};
}
}

View File

@@ -152,7 +152,7 @@ class Session extends \Grav\Framework\Session\Session
*/
public function setFlashCookieObject($name, mixed $object, $time = 60)
{
setcookie($name, json_encode($object, JSON_THROW_ON_ERROR), ['expires' => $this->getCookieOptions($time)]);
setcookie($name, json_encode($object, JSON_THROW_ON_ERROR), $this->getCookieOptions($time));
return $this;
}
@@ -168,7 +168,7 @@ class Session extends \Grav\Framework\Session\Session
{
if (isset($_COOKIE[$name])) {
$cookie = $_COOKIE[$name];
setcookie($name, '', ['expires' => $this->getCookieOptions(-42000)]);
setcookie($name, '', $this->getCookieOptions(-42000));
return json_decode((string) $cookie, false, 512, JSON_THROW_ON_ERROR);
}

View File

@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace Grav\Common\Twig\Compatibility;
use Twig\Loader\LoaderInterface;
use Twig\Source;
/**
* Decorates the active Twig loader to rewrite legacy Twig 1/2 constructs on the fly.
*/
class Twig3CompatibilityLoader implements LoaderInterface
{
public function __construct(
private readonly LoaderInterface $inner,
private readonly Twig3CompatibilityTransformer $transformer
) {
}
public function getSourceContext(string $name): Source
{
$source = $this->inner->getSourceContext($name);
return new Source(
$this->transformer->transform($source->getCode()),
$source->getName(),
$source->getPath()
);
}
public function exists(string $name): bool
{
return $this->inner->exists($name);
}
public function getCacheKey(string $name): string
{
return $this->inner->getCacheKey($name);
}
public function isFresh(string $name, int $time): bool
{
return $this->inner->isFresh($name, $time);
}
}

View File

@@ -0,0 +1,231 @@
<?php
declare(strict_types=1);
namespace Grav\Common\Twig\Compatibility;
/**
* Applies automatic rewrites that help legacy Twig 1/2 templates compile under Twig 3.
*/
class Twig3CompatibilityTransformer
{
/**
* Transform raw Twig source code.
*/
public function transform(string $code): string
{
$code = $this->rewriteForLoopGuards($code);
$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;
}
/**
* Convert legacy "{% for ... if ... %}" guard syntax to a Twig 3 friendly form that
* filters the sequence before iteration.
*/
private function rewriteForLoopGuards(string $code): string
{
$pattern = '/(\{%-?\s*for\s+)(.+?)(\s*-?%\})/s';
return (string) preg_replace_callback($pattern, function (array $matches) {
$clause = $matches[2];
// Find the last " if " (including leading whitespace) to reduce false positives
if (!preg_match_all('/\sif\s+/i', $clause, $ifs, PREG_OFFSET_CAPTURE)) {
return $matches[0];
}
$lastIf = end($ifs[0]);
if ($lastIf === false) {
return $matches[0];
}
$ifPos = (int) $lastIf[1];
$ifLength = strlen($lastIf[0]);
$head = trim(substr($clause, 0, $ifPos));
$condition = trim(substr($clause, $ifPos + $ifLength));
if ($head === '' || $condition === '') {
return $matches[0];
}
if (!preg_match('/^(.*)\s+in\s+(.*)$/is', $head, $parts)) {
return $matches[0];
}
$targetSpec = trim($parts[1]);
$sequence = trim($parts[2]);
if ($targetSpec === '' || $sequence === '') {
return $matches[0];
}
$targets = array_map(static fn (string $value): string => trim($value), explode(',', $targetSpec));
if (count($targets) === 1) {
$arrow = sprintf('%s => %s', $targets[0], $condition);
} elseif (count($targets) === 2) {
[$keyVar, $valueVar] = $targets;
if ($valueVar === '') {
return $matches[0];
}
$arrow = sprintf('(%s, %s) => %s', $valueVar, $keyVar, $condition);
} else {
// Unsupported target list: fall back to the original clause.
return $matches[0];
}
$sequence = $this->ensureWrapped($sequence);
$rewrittenClause = sprintf('%s in %s|filter(%s)', $targetSpec, $sequence, $arrow);
return $matches[1] . $rewrittenClause . $matches[3];
}, $code);
}
private function rewriteSpacelessBlocks(string $code): string
{
$openPattern = '/\{%(\-?)\s*spaceless\s*(\-?)%\}/i';
$code = (string) preg_replace_callback($openPattern, static function (array $matches): string {
$leading = $matches[1] === '-' ? '-' : '';
$trailing = $matches[2] === '-' ? '-' : '';
return '{%' . $leading . ' apply spaceless ' . $trailing . '%}';
}, $code);
$closePattern = '/\{%(\-?)\s*endspaceless\s*(\-?)%\}/i';
return (string) preg_replace_callback($closePattern, static function (array $matches): string {
$leading = $matches[1] === '-' ? '-' : '';
$trailing = $matches[2] === '-' ? '-' : '';
return '{%' . $leading . ' endapply ' . $trailing . '%}';
}, $code);
}
private function rewriteFilterBlocks(string $code): string
{
$openPattern = '/\{%(\-?)\s*filter\s+(.+?)\s*(\-?)%\}/i';
$code = (string) preg_replace_callback($openPattern, static function (array $matches): string {
$leading = $matches[1] === '-' ? '-' : '';
$expression = trim($matches[2]);
$trailing = $matches[3] === '-' ? '-' : '';
if ($expression === '') {
return $matches[0];
}
return '{%' . $leading . ' apply ' . $expression . ' ' . $trailing . '%}';
}, $code);
$closePattern = '/\{%(\-?)\s*endfilter\s*(\-?)%\}/i';
return (string) preg_replace_callback($closePattern, static function (array $matches): string {
$leading = $matches[1] === '-' ? '-' : '';
$trailing = $matches[2] === '-' ? '-' : '';
return '{%' . $leading . ' endapply ' . $trailing . '%}';
}, $code);
}
private function rewriteSameAsTests(string $code): string
{
$pattern = '/([\'"])(?:\\\\.|(?!\\1).)*\\1|\\bis\\s+(?:not\\s+)?sameas\\b/is';
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*\)/';
$code = (string) preg_replace_callback($pattern, static function (array $matches): string {
$keyQuote = $matches[1];
$key = $matches[2];
$valueQuote = $matches[3];
$value = $matches[4];
return sprintf('|replace({%1$s%2$s%1$s: %3$s%4$s%3$s})', $keyQuote, $key, $valueQuote, $value);
}, $code);
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);
if ($trimmed === '') {
return $expression;
}
$startsWithParen = str_starts_with($trimmed, '(') && str_ends_with($trimmed, ')');
return $startsWithParen ? $expression : '(' . $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($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

@@ -17,6 +17,8 @@ use Grav\Common\Language\LanguageCodes;
use Grav\Common\Page\Interfaces\PageInterface;
use Grav\Common\Page\Pages;
use Grav\Common\Security;
use Grav\Common\Twig\Compatibility\Twig3CompatibilityLoader;
use Grav\Common\Twig\Compatibility\Twig3CompatibilityTransformer;
use Grav\Common\Twig\Exception\TwigException;
use Grav\Common\Twig\Extension\FilesystemExtension;
use Grav\Common\Twig\Extension\GravExtension;
@@ -154,7 +156,7 @@ class Twig
$twig_paths = array_merge($twig_paths, $locator->findResources('theme://'.$prefix.'templates'));
$namespace = trim($prefix, '/');
$namespace = trim((string) $prefix, '/');
$this->loader->setPaths($twig_paths, $namespace);
}
@@ -163,13 +165,19 @@ class Twig
$this->loaderArray = new ArrayLoader([]);
$loader_chain = new ChainLoader([$this->loaderArray, $this->loader]);
$activeLoader = $loader_chain;
if ($config->get('system.strict_mode.twig3_compat', false)) {
$transformer = new Twig3CompatibilityTransformer();
$activeLoader = new Twig3CompatibilityLoader($loader_chain, $transformer);
}
$params = $config->get('system.twig');
if (!empty($params['cache'])) {
$cachePath = $locator->findResource('cache://twig', true, true);
$params['cache'] = new FilesystemCache($cachePath, FilesystemCache::FORCE_BYTECODE_INVALIDATION);
}
if (!$config->get('system.strict_mode.twig_compat', false)) {
if (!$config->get('system.strict_mode.twig2_compat', false)) {
// Force autoescape on for all files if in strict mode.
$params['autoescape'] = 'html';
} elseif (!empty($this->autoescape)) {
@@ -177,10 +185,10 @@ class Twig
}
if (empty($params['autoescape'])) {
user_error('Grav 2.0 will have Twig auto-escaping forced on (can be emulated by turning off \'system.strict_mode.twig_compat\' setting in your configuration)', E_USER_DEPRECATED);
user_error('Grav 2.0 will have Twig auto-escaping forced on (can be emulated by turning off \'system.strict_mode.twig2_compat\' setting in your configuration)', E_USER_DEPRECATED);
}
$this->twig = new TwigEnvironment($loader_chain, $params);
$this->twig = new TwigEnvironment($activeLoader, $params);
$this->twig->registerUndefinedFunctionCallback(function (string $name) use ($config) {
$allowed = $config->get('system.twig.safe_functions');

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;
@@ -24,10 +27,42 @@ class TwigEnvironment extends Environment
{
/**
* @inheritDoc
*
* TODO: Needed for Twig 1 compatibility.
*/
public function resolveTemplate($names)
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
*
*/
public function resolveTemplate($names): TemplateWrapper
{
if (!\is_array($names)) {
$names = [$names];
@@ -52,7 +87,7 @@ class TwigEnvironment extends Environment
}
// Throws LoaderError: Unable to find template "%s".
return $this->loadTemplate($name);
return $this->load($name);
}
throw new LoaderError(sprintf('Unable to find one of the following templates: "%s".', implode('", "', $names)));

File diff suppressed because it is too large Load Diff

View File

@@ -970,7 +970,7 @@ class Uri implements \Stringable
}
foreach ($parts as $name => $value) {
$parts[$name] = rawurldecode($value);
$parts[$name] = rawurldecode((string) $value);
}
if (!isset($parts['path'])) {
@@ -1453,8 +1453,8 @@ class Uri implements \Stringable
if (!function_exists('getallheaders')) {
$headers = [];
foreach ($_SERVER as $name => $value) {
if (str_starts_with($name, 'HTTP_')) {
$headers[str_replace(' ', '-', ucwords(strtolower(str_replace('_', ' ', substr($name, 5)))))] = $value;
if (str_starts_with((string) $name, 'HTTP_')) {
$headers[str_replace(' ', '-', ucwords(strtolower(str_replace('_', ' ', substr((string) $name, 5)))))] = $value;
}
}
return $headers;

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();
}
}
@@ -1264,7 +1266,7 @@ abstract class Utils
{
$newArray = [];
foreach ($array as $key => $value) {
$dots = explode($separator, $key);
$dots = explode($separator, (string) $key);
if (count($dots) > 1) {
$last = &$newArray[$dots[0]];
foreach ($dots as $k => $dot) {
@@ -1834,7 +1836,7 @@ abstract class Utils
}
foreach ($parts as $name => $value) {
$parts[$name] = urldecode($value);
$parts[$name] = urldecode((string) $value);
}
return $parts;

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

@@ -57,8 +57,14 @@ class SchedulerCommand extends GravCommand
'Force run all jobs or a specific job if you specify a specific Job ID',
false
)
->addOption(
'force',
'f',
InputOption::VALUE_NONE,
'Force all due jobs to run regardless of their schedule'
)
->setDescription('Run the Grav Scheduler. Best when integrated with system cron')
->setHelp("Running without any options will force the Scheduler to run through it's jobs and process them");
->setHelp("Running without any options will process the Scheduler jobs based on their cron schedule. Use --force to run all jobs immediately.");
}
/**
@@ -82,8 +88,76 @@ class SchedulerCommand extends GravCommand
$error = 0;
$run = $input->getOption('run');
$showDetails = $input->getOption('details');
$showJobs = $input->getOption('jobs');
$forceRun = $input->getOption('force');
if ($input->getOption('jobs')) {
// Handle running jobs first if -r flag is present
if ($run !== false) {
if ($run === null || $run === '') {
// Run all jobs when -r is provided without a specific job ID
$io->title('Force Run All Jobs');
$jobs = $scheduler->getAllJobs();
$hasOutput = false;
foreach ($jobs as $job) {
if ($job->getEnabled()) {
$io->section('Running: ' . $job->getId());
$job->inForeground()->run();
if ($job->isSuccessful()) {
$io->success('Job ' . $job->getId() . ' ran successfully');
} else {
$error = 1;
$io->error('Job ' . $job->getId() . ' failed to run');
}
$output = $job->getOutput();
if ($output) {
$io->write($output);
$hasOutput = true;
}
}
}
if (!$hasOutput) {
$io->note('All enabled jobs completed');
}
} else {
// Run specific job
$io->title('Force Run Job: ' . $run);
$job = $scheduler->getJob($run);
if ($job) {
$job->inForeground()->run();
if ($job->isSuccessful()) {
$io->success('Job ran successfully...');
} else {
$error = 1;
$io->error('Job failed to run successfully...');
}
$output = $job->getOutput();
if ($output) {
$io->write($output);
}
} else {
$error = 1;
$io->error('Could not find a job with id: ' . $run);
}
}
// Add separator if we're going to show details after
if ($showDetails) {
$io->newLine();
}
}
if ($showJobs) {
// Show jobs list
$jobs = $scheduler->getAllJobs();
@@ -124,7 +198,9 @@ class SchedulerCommand extends GravCommand
$io->newLine();
$io->note('For error details run "bin/grav scheduler -d"');
$io->newLine();
} elseif ($input->getOption('details')) {
}
if ($showDetails) {
$jobs = $scheduler->getAllJobs();
$job_states = (array)$scheduler->getJobStates()->content();
@@ -139,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();
@@ -150,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>";
@@ -162,31 +243,9 @@ class SchedulerCommand extends GravCommand
$table->setRows($rows);
$table->render();
} elseif ($run !== false && $run !== null) {
$io->title('Force Run Job: ' . $run);
$job = $scheduler->getJob($run);
if ($job) {
$job->inForeground()->run();
if ($job->isSuccessful()) {
$io->success('Job ran successfully...');
} else {
$error = 1;
$io->error('Job failed to run successfully...');
}
$output = $job->getOutput();
if ($output) {
$io->write($output);
}
} else {
$error = 1;
$io->error('Could not find a job with id: ' . $run);
}
} elseif ($input->getOption('install')) {
}
if ($input->getOption('install')) {
$io->title('Install Scheduler');
$verb = 'install';
@@ -207,10 +266,9 @@ class SchedulerCommand extends GravCommand
$io->note("To $verb, create a scheduled task in Windows.");
$io->text('Learn more at https://learn.getgrav.org/advanced/scheduler');
}
} else {
// Run scheduler
$force = $run === null;
$scheduler->run(null, $force);
} elseif (!$showJobs && !$showDetails && $run === false) {
// Run scheduler only if no other options were provided
$scheduler->run(null, $forceRun);
if ($input->getOption('verbose')) {
$io->title('Running Scheduled Jobs');

Some files were not shown because too many files have changed in this diff Show More