ouidade 3 years ago
parent
commit
f5cb936c97
100 changed files with 1739 additions and 706 deletions
  1. 63 0
      CHANGELOG.md
  2. 12 11
      composer.json
  3. 319 210
      composer.lock
  4. 64 14
      system/blueprints/config/system.yaml
  5. 16 4
      system/config/system.yaml
  6. 1 1
      system/defines.php
  7. BIN
      system/images/watermark.png
  8. 11 0
      system/languages/ar.yaml
  9. 16 0
      system/languages/ca.yaml
  10. 9 5
      system/languages/fr.yaml
  11. 3 0
      system/languages/gl.yaml
  12. 81 31
      system/languages/id.yaml
  13. 147 0
      system/languages/mn.yaml
  14. 3 0
      system/languages/pt.yaml
  15. 9 0
      system/languages/si.yaml
  16. 2 0
      system/languages/tr.yaml
  17. 16 1
      system/languages/zh-tw.yaml
  18. 19 2
      system/router.php
  19. 5 1
      system/src/Grav/Common/Assets/BaseAsset.php
  20. 1 1
      system/src/Grav/Common/Assets/Pipeline.php
  21. 2 4
      system/src/Grav/Common/Assets/Traits/AssetUtilsTrait.php
  22. 2 3
      system/src/Grav/Common/Backup/Backups.php
  23. 2 6
      system/src/Grav/Common/Cache.php
  24. 2 2
      system/src/Grav/Common/Data/Blueprint.php
  25. 11 2
      system/src/Grav/Common/Data/BlueprintSchema.php
  26. 1 1
      system/src/Grav/Common/Data/Data.php
  27. 12 4
      system/src/Grav/Common/Data/Validation.php
  28. 19 4
      system/src/Grav/Common/Data/ValidationException.php
  29. 1 1
      system/src/Grav/Common/Debugger.php
  30. 4 3
      system/src/Grav/Common/Filesystem/Folder.php
  31. 3 4
      system/src/Grav/Common/Filesystem/ZipArchiver.php
  32. 1 1
      system/src/Grav/Common/Flex/FlexObject.php
  33. 3 4
      system/src/Grav/Common/Flex/Types/Pages/PageCollection.php
  34. 17 10
      system/src/Grav/Common/Flex/Types/Pages/PageIndex.php
  35. 8 6
      system/src/Grav/Common/Flex/Types/Pages/PageObject.php
  36. 4 1
      system/src/Grav/Common/Flex/Types/Pages/Traits/PageLegacyTrait.php
  37. 2 2
      system/src/Grav/Common/Flex/Types/Users/UserCollection.php
  38. 2 2
      system/src/Grav/Common/Flex/Types/Users/UserIndex.php
  39. 17 5
      system/src/Grav/Common/Flex/Types/Users/UserObject.php
  40. 2 142
      system/src/Grav/Common/GPM/Response.php
  41. 4 0
      system/src/Grav/Common/Getters.php
  42. 16 16
      system/src/Grav/Common/Grav.php
  43. 130 0
      system/src/Grav/Common/HTTP/Client.php
  44. 96 0
      system/src/Grav/Common/HTTP/Response.php
  45. 6 1
      system/src/Grav/Common/Helpers/Excerpts.php
  46. 12 10
      system/src/Grav/Common/Helpers/LogViewer.php
  47. 1 3
      system/src/Grav/Common/Iterator.php
  48. 6 11
      system/src/Grav/Common/Language/LanguageCodes.php
  49. 8 0
      system/src/Grav/Common/Media/Traits/ImageMediaTrait.php
  50. 1 1
      system/src/Grav/Common/Media/Traits/MediaUploadTrait.php
  51. 2 1
      system/src/Grav/Common/Page/Collection.php
  52. 1 1
      system/src/Grav/Common/Page/Interfaces/PageContentInterface.php
  53. 2 0
      system/src/Grav/Common/Page/Media.php
  54. 2 0
      system/src/Grav/Common/Page/Medium/GlobalMedia.php
  55. 62 0
      system/src/Grav/Common/Page/Medium/ImageMedium.php
  56. 22 3
      system/src/Grav/Common/Page/Page.php
  57. 74 12
      system/src/Grav/Common/Page/Pages.php
  58. 25 0
      system/src/Grav/Common/Plugin.php
  59. 1 1
      system/src/Grav/Common/Plugins.php
  60. 4 4
      system/src/Grav/Common/Processors/InitializeProcessor.php
  61. 17 3
      system/src/Grav/Common/Processors/PagesProcessor.php
  62. 1 1
      system/src/Grav/Common/Scheduler/Job.php
  63. 9 13
      system/src/Grav/Common/Security.php
  64. 1 1
      system/src/Grav/Common/Service/ConfigServiceProvider.php
  65. 2 2
      system/src/Grav/Common/Session.php
  66. 1 1
      system/src/Grav/Common/Taxonomy.php
  67. 28 11
      system/src/Grav/Common/Twig/Extension/GravExtension.php
  68. 15 20
      system/src/Grav/Common/Twig/Twig.php
  69. 25 23
      system/src/Grav/Common/Uri.php
  70. 2 0
      system/src/Grav/Common/User/DataUser/User.php
  71. 1 1
      system/src/Grav/Common/User/Group.php
  72. 29 17
      system/src/Grav/Common/Utils.php
  73. 3 3
      system/src/Grav/Common/Yaml.php
  74. 4 5
      system/src/Grav/Console/Cli/CleanCommand.php
  75. 0 2
      system/src/Grav/Console/Gpm/IndexCommand.php
  76. 2 2
      system/src/Grav/Framework/Acl/PermissionsReader.php
  77. 3 3
      system/src/Grav/Framework/Collection/AbstractFileCollection.php
  78. 14 2
      system/src/Grav/Framework/Collection/AbstractIndexCollection.php
  79. 1 1
      system/src/Grav/Framework/Collection/AbstractLazyCollection.php
  80. 1 1
      system/src/Grav/Framework/Collection/ArrayCollection.php
  81. 5 5
      system/src/Grav/Framework/Collection/CollectionInterface.php
  82. 3 3
      system/src/Grav/Framework/Collection/FileCollection.php
  83. 1 1
      system/src/Grav/Framework/Collection/FileCollectionInterface.php
  84. 11 2
      system/src/Grav/Framework/Controller/Traits/ControllerResponseTrait.php
  85. 1 1
      system/src/Grav/Framework/Flex/Flex.php
  86. 17 0
      system/src/Grav/Framework/Flex/FlexDirectoryForm.php
  87. 17 0
      system/src/Grav/Framework/Flex/FlexForm.php
  88. 6 2
      system/src/Grav/Framework/Flex/FlexIndex.php
  89. 50 12
      system/src/Grav/Framework/Flex/FlexObject.php
  90. 1 0
      system/src/Grav/Framework/Flex/Interfaces/FlexIndexInterface.php
  91. 4 4
      system/src/Grav/Framework/Flex/Pages/FlexPageObject.php
  92. 4 6
      system/src/Grav/Framework/Flex/Storage/FolderStorage.php
  93. 27 1
      system/src/Grav/Framework/Form/Traits/FormTrait.php
  94. 34 0
      system/src/Grav/Framework/Logger/Processors/UserProcessor.php
  95. 4 0
      system/src/Grav/Framework/Object/Access/ArrayAccessTrait.php
  96. 4 0
      system/src/Grav/Framework/Object/Access/NestedArrayAccessTrait.php
  97. 0 2
      system/src/Grav/Framework/Object/Base/ObjectCollectionTrait.php
  98. 1 1
      system/src/Grav/Framework/Object/Interfaces/NestedObjectCollectionInterface.php
  99. 2 1
      system/src/Grav/Framework/Object/Interfaces/ObjectCollectionInterface.php
  100. 1 1
      system/src/Grav/Framework/Object/ObjectCollection.php

+ 63 - 0
CHANGELOG.md

@@ -1,3 +1,66 @@
+# v1.7.25
+## 11/16/2021
+
+1. [](#new)
+    * Updated phpstan to v1.0
+    * Added `FlexObject::getDiff()` to see difference to the saved object
+2. [](#improved)
+    * Use Symfony `dump` instead of PHP's `vardump` in side the `{{ vardump(x) }}` Twig vardump function
+    * Added `route` and `request` to `onPagesInitialized` event
+    * Improved page cloning, added method `Page::initialize()`
+    * Improved `FlexObject::getChanges()`: return changed lists and arrays as whole instead of just changed keys/values
+    * Improved form validation JSON responses to contain list of failed fields with their error messages
+    * Improved redirects: send redirect response in JSON if the request was in JSON
+3. [](#bugfix)
+    * Fixed path traversal vulnerability when using `bin/grav server`
+    * Fixed unescaped error messages in JSON error responses
+    * Fixed `|t(variable)` twig filter in admin
+    * Fixed `FlexObject::getChanges()` always returning empty array
+    * Fixed form validation exceptions to use `400 Bad Request` instead of `500 Internal Server Error`
+
+# v1.7.24
+## 10/26/2021
+
+1. [](#new)
+    * Added support for image watermarks
+    * Added support to disable a form, making it readonly
+2. [](#improved)
+    * Flex `$user->authorize()` now checks user groups before `admin.super`, allowing deny rules to work properly
+3. [](#bugfix)
+    * Fixed a bug in `PermissionsReader` in PHP 7.3
+    * Fixed `session_store_active` language option (#3464)
+    * Fixed deprecated warnings on `ArrayAccess` in PHP 8.1
+    * Fixed XSS detection with `:`
+
+# v1.7.23
+## 09/29/2021
+
+1. [](#new)
+    * Added method `Pages::referrerRoute()` to get the referrer route and language
+    * Added true unique `Utils::uniqueId()` / `{{ unique_id() }}` utilities  with length, prefix, and suffix support
+    * Added `UserObject::isMyself()` method to check if flex user is currently logged in
+    * Added support for custom form field options validation with `validate: options: key|ignore`
+2. [](#improved)
+   * Replaced GPL `SVG-Sanitizer` with MIT licensed `DOM-Sanitizer`
+   * `Uri::referrer()` now accepts third parameter, if set to `true`, it returns route without base or language code [#3411](https://github.com/getgrav/grav/issues/3411)
+   * Updated vendor libs with latest
+   * Updated with latest language strings via Crowdin.com
+3. [](#bugfix)
+    * Fixed `Folder::move()` throwing an error when target folder is changed by only appending characters to the end [#3445](https://github.com/getgrav/grav/issues/3445)
+    * Fixed some phpstan issues (all code back to level 1, Framework level 3)
+    * Fixed form reset causing image uploads to fail when using Flex
+
+# v1.7.22
+## 09/16/2021
+
+1. [](#new)
+    * Register plugin autoloaders into plugin objects
+2. [](#improved)
+    * Improve Twig 2 compatibility
+    * Update to customized version of Twig DeferredExtension (Twig 1/2 compatible)
+3. [](#bugfix)
+    * Fixed conflicting `$_original` variable in `Flex Pages`
+
 # v1.7.21
 ## 09/14/2021
 

+ 12 - 11
composer.json

@@ -20,9 +20,10 @@
         "ext-dom": "*",
         "ext-libxml": "*",
         "symfony/polyfill-mbstring": "~1.20",
-        "symfony/polyfill-iconv": "^1.20",
-        "symfony/polyfill-php74": "^1.20",
-        "symfony/polyfill-php80": "^1.20",
+        "symfony/polyfill-iconv": "^1.23",
+        "symfony/polyfill-php74": "^1.23",
+        "symfony/polyfill-php80": "^1.23",
+        "symfony/polyfill-php81": "^1.23",
         "psr/simple-cache": "^1.0",
         "psr/http-message": "^1.0",
         "psr/http-server-middleware": "^1.0",
@@ -55,17 +56,16 @@
         "miljar/php-exif": "^0.6",
         "composer/ca-bundle": "^1.2",
         "dragonmantank/cron-expression": "^1.2",
-        "phive/twig-extensions-deferred": "^1.0",
         "willdurand/negotiation": "^3.0",
         "itsgoingd/clockwork": "^5.0",
-        "enshrined/svg-sanitize": "~0.13",
         "symfony/http-client": "^4.4",
-        "composer/semver": "^1.4"
+        "composer/semver": "^1.4",
+        "rhukster/dom-sanitizer": "^1.0"
     },
     "require-dev": {
         "codeception/codeception": "^4.1",
-        "phpstan/phpstan": "^0.12",
-        "phpstan/phpstan-deprecation-rules": "^0.12",
+        "phpstan/phpstan": "^1.0",
+        "phpstan/phpstan-deprecation-rules": "^1.0",
         "phpunit/php-code-coverage": "~9.2",
         "getgrav/markdowndocs": "^2.0",
         "codeception/module-asserts": "^1.3",
@@ -93,7 +93,8 @@
     },
     "autoload": {
         "psr-4": {
-            "Grav\\": "system/src/Grav"
+            "Grav\\": "system/src/Grav",
+            "Twig\\": "system/src/Twig"
         },
         "files": [
             "system/defines.php"
@@ -107,8 +108,8 @@
     "scripts": {
         "api-17": "vendor/bin/phpdoc-md generate system/src > user/pages/14.api/default.17.md",
         "post-create-project-cmd": "bin/grav install",
-        "phpstan": "vendor/bin/phpstan analyse -l 1 -c ./tests/phpstan/phpstan.neon --memory-limit=480M system/src",
-        "phpstan-framework": "vendor/bin/phpstan analyse -l 1 -c ./tests/phpstan/phpstan.neon --memory-limit=480M system/src/Grav/Framework system/src/Grav/Events system/src/Grav/Installer",
+        "phpstan": "vendor/bin/phpstan analyse -l 1 -c ./tests/phpstan/phpstan.neon --memory-limit=520M system/src",
+        "phpstan-framework": "vendor/bin/phpstan analyse -l 3 -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"

File diff suppressed because it is too large
+ 319 - 210
composer.lock


+ 64 - 14
system/blueprints/config/system.yaml

@@ -1446,6 +1446,10 @@ form:
               title: PLUGIN_ADMIN.ADVANCED
               underline: true
 
+            gpm_section:
+              type: section
+              title: PLUGIN_ADMIN.GPM_SECTION
+
             gpm.releases:
               type: toggle
               label: PLUGIN_ADMIN.GPM_RELEASES
@@ -1455,14 +1459,23 @@ form:
                 stable: PLUGIN_ADMIN.STABLE
                 testing: PLUGIN_ADMIN.TESTING
 
-            gpm.proxy_url:
-              type: text
-              size: medium
-              placeholder: "e.g. 127.0.0.1:3128"
-              label: PLUGIN_ADMIN.PROXY_URL
-              help: PLUGIN_ADMIN.PROXY_URL_HELP
+            gpm.official_gpm_only:
+              type: toggle
+              label: PLUGIN_ADMIN.GPM_OFFICIAL_ONLY
+              highlight: 1
+              help: PLUGIN_ADMIN.GPM_OFFICIAL_ONLY_HELP
+              options:
+                1: PLUGIN_ADMIN.YES
+                0: PLUGIN_ADMIN.NO
+              default: true
+              validate:
+                type: bool
 
-            gpm.method:
+            http_section:
+              type: section
+              title: PLUGIN_ADMIN.HTTP_SECTION
+
+            http.method:
               type: toggle
               label: PLUGIN_ADMIN.GPM_METHOD
               highlight: auto
@@ -1472,29 +1485,66 @@ form:
                 fopen: PLUGIN_ADMIN.FOPEN
                 curl: PLUGIN_ADMIN.CURL
 
-            gpm.official_gpm_only:
+            http.enable_proxy:
               type: toggle
-              label: PLUGIN_ADMIN.GPM_OFFICIAL_ONLY
+              label: PLUGIN_ADMIN.SSL_ENABLE_PROXY
               highlight: 1
-              help: PLUGIN_ADMIN.GPM_OFFICIAL_ONLY_HELP
               options:
                 1: PLUGIN_ADMIN.YES
                 0: PLUGIN_ADMIN.NO
-              default: true
+              default: false
+              validate:
+                type: bool
+
+            http.proxy_url:
+              type: text
+              size: medium
+              placeholder: "e.g. 127.0.0.1:3128"
+              label: PLUGIN_ADMIN.PROXY_URL
+              help: PLUGIN_ADMIN.PROXY_URL_HELP
+
+            http.proxy_cert_path:
+              type: text
+              size: medium
+              placeholder: "e.g. /Users/bob/certs/"
+              label: PLUGIN_ADMIN.PROXY_CERT
+              help: PLUGIN_ADMIN.PROXY_CERT_HELP
+
+            http.verify_peer:
+              type: toggle
+              label: PLUGIN_ADMIN.SSL_VERIFY_PEER
+              highlight: 1
+              help: PLUGIN_ADMIN.SSL_VERIFY_PEER_HELP
+              options:
+                1: PLUGIN_ADMIN.YES
+                0: PLUGIN_ADMIN.NO
               validate:
                 type: bool
 
-            gpm.verify_peer:
+            http.verify_host:
               type: toggle
-              label: PLUGIN_ADMIN.GPM_VERIFY_PEER
+              label: PLUGIN_ADMIN.SSL_VERIFY_HOST
               highlight: 1
-              help: PLUGIN_ADMIN.GPM_VERIFY_PEER_HELP
+              help: PLUGIN_ADMIN.SSL_VERIFY_HOST_HELP
               options:
                 1: PLUGIN_ADMIN.YES
                 0: PLUGIN_ADMIN.NO
               validate:
                 type: bool
 
+            http.concurrent_connections:
+              type: number
+              size: x-small
+              label: PLUGIN_ADMIN.HTTP_CONNECTIONS
+              help: PLUGIN_ADMIN.HTTP_CONNECTIONS_HELP
+              validate:
+                min: 1
+                max: 20
+
+            misc_section:
+              type: section
+              title: PLUGIN_ADMIN.MISC_SECTION
+
             reverse_proxy_setup:
               type: toggle
               label: PLUGIN_ADMIN.REVERSE_PROXY

+ 16 - 4
system/config/system.yaml

@@ -96,7 +96,7 @@ cache:
   purge_at: '0 4 * * *'                          # How often to purge old file cache (using new scheduler)
   clear_at: '0 3 * * *'                           # How often to clear cache (using new scheduler)
   clear_job_type: 'standard'                     # Type to clear when processing the scheduled clear job `standard`|`all`
-  clear_images_by_default: false                  # By default grav does not include processed images in cache clear, this can be enabled
+  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, memcache, etc.)
   lifetime: 604800                               # Lifetime of cached data in seconds (0 = infinite)
   gzip: false                                    # GZip compress the page output
@@ -162,6 +162,12 @@ images:
     retina_scale: 1                              # scale to adjust auto-sizes for better handling of HiDPI resolutions
   defaults:
     loading: auto                                # Let browser pick [auto|lazy|eager]
+  watermark:
+    image: 'system://images/watermark.png'       # Path to a watermark image
+    position_y: 'center'                         # top|center|bottom
+    position_x: 'center'                         # left|center|right
+    scale: 33                                    # percentage of watermark scale
+    watermark_all: false                         # automatically watermark all images
 
 media:
   enable_media_timestamp: false                  # Enable media timestamps
@@ -184,11 +190,17 @@ session:
 
 gpm:
   releases: stable                               # Set to either 'stable' or 'testing'
-  proxy_url:                                     # Configure a manual proxy URL for GPM (eg 127.0.0.1:3128)
-  method: 'auto'                                 # Either 'curl', 'fopen' or 'auto'. 'auto' will try fopen first and if not available cURL
-  verify_peer: true                              # Sometimes on some systems (Windows most commonly) GPM is unable to connect because the SSL certificate cannot be verified. Disabling this setting might help.
   official_gpm_only: true                        # By default GPM direct-install will only allow URLs via the official GPM proxy to ensure security
 
+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
+  proxy_url:                                     # Configure a manual proxy URL for GPM (eg 127.0.0.1:3128)
+  proxy_cert_path:                               # Local path to proxy certificate folder containing pem files
+  concurrent_connections: 5                      # Concurrent HTTP connections when multiplexing
+  verify_peer: true                              # Enable/Disable SSL verification of peer certificates
+  verify_host: true                              # Enable/Disable SSL verification of host certificates
+
 accounts:
   type: regular                                  # EXPERIMENTAL: Account type: regular or flex
   storage: file                                  # EXPERIMENTAL: Flex storage type: file or folder

+ 1 - 1
system/defines.php

@@ -9,7 +9,7 @@
 
 // Some standard defines
 define('GRAV', true);
-define('GRAV_VERSION', '1.7.21');
+define('GRAV_VERSION', '1.7.25');
 define('GRAV_SCHEMA', '1.7.0_2020-11-20_1');
 define('GRAV_TESTING', false);
 

BIN
system/images/watermark.png


+ 11 - 0
system/languages/ar.yaml

@@ -51,6 +51,7 @@ GRAV:
     VALIDATION_FAIL: '<b>فشل التحقق من صحة:</b>'
     INVALID_INPUT: 'إدخال غير صحيح في'
     MISSING_REQUIRED_FIELD: 'حقل مطلوب مفقود:'
+    XSS_ISSUES: "مشاكل XSS محتملة تم اكتشافها في حقل '%s' '"
   MONTHS_OF_THE_YEAR:
     - 'كانون الثاني'
     - 'شباط'
@@ -72,6 +73,8 @@ GRAV:
     - 'الجمعة'
     - 'السبت'
     - 'الأحد'
+  YES: "نعم"
+  NO: "لا"
   CRON:
     EVERY: كل
     EVERY_HOUR: كل ساعة
@@ -80,3 +83,11 @@ GRAV:
     EVERY_DAY_OF_MONTH: كل يوم في الشهر
     EVERY_MONTH: ' كل شهر'
     TEXT_PERIOD: كل <b />
+    TEXT_MINS: ' في <b /> دقيقة(دقائق) بعد الساعة'
+    TEXT_TIME: ' في <b />:<b />'
+    TEXT_DOW: ' في <b />'
+    TEXT_MONTH: ' من <b />'
+    TEXT_DOM: ' في <b />'
+    ERROR1: الوسم %s غير مدعوم!
+    ERROR2: عدد عناصر غير صالح.
+    ERROR4: تعبير غير معروف

+ 16 - 0
system/languages/ca.yaml

@@ -15,6 +15,7 @@ GRAV:
     BAD_DATE: Data invàlida
     AGO: abans
     FROM_NOW: des d'ara
+    JUST_NOW: Ara mateix
     SECOND: segon
     MINUTE: minut
     HOUR: hora
@@ -48,6 +49,7 @@ GRAV:
     VALIDATION_FAIL: '<b>Ha fallat la validació:</b>'
     INVALID_INPUT: 'Entrada no vàlida a'
     MISSING_REQUIRED_FIELD: 'Falta camp obligatori:'
+    XSS_ISSUES: "Detectats potencials problemes XSS al camp '%s'"
   MONTHS_OF_THE_YEAR:
     - 'Gener'
     - 'Febrer'
@@ -69,3 +71,17 @@ GRAV:
     - 'Divendres'
     - 'Dissabte'
     - 'Diumenge'
+  YES: "Sí"
+  NO: "No"
+  CRON:
+    EVERY: cada
+    EVERY_HOUR: cada hora
+    EVERY_MINUTE: cada minut
+    EVERY_DAY_OF_WEEK: cada dia de la setmana
+    EVERY_DAY_OF_MONTH: cada dia del mes
+    EVERY_MONTH: cada mes
+    TEXT_PERIOD: Cada <b />
+    ERROR1: L'etiqueta %s no està suportada!
+    ERROR2: Nombre d'elements incorrecte
+    ERROR3: El jquery_element s'ha d'establir a la configuració de jqCron
+    ERROR4: Expressió no reconeguda

+ 9 - 5
system/languages/fr.yaml

@@ -24,6 +24,7 @@ GRAV:
     '/(quiz)zes$/i': '\1'
     '/(alias|status)es$/i': '\1'
     '/([octop|vir])i$/i': '\1us'
+    '/(n)ews$/i': '\1ouvelles'
   INFLECTOR_UNCOUNTABLE:
     - 'équipement'
     - 'information'
@@ -58,10 +59,10 @@ GRAV:
     MONTH: mois
     YEAR: année
     DECADE: décennie
-    SEC: s
-    MIN: m
-    HR: h
-    WK: sem
+    SEC: sec.
+    MIN: min.
+    HR: hr.
+    WK: sem.
     MO: m
     YR: an
     DEC: déc
@@ -84,6 +85,7 @@ GRAV:
     VALIDATION_FAIL: '<b>La validation a échoué :</b>'
     INVALID_INPUT: 'Saisie non valide'
     MISSING_REQUIRED_FIELD: 'Champ obligatoire manquant :'
+    XSS_ISSUES: "Erreurs XSS probablement détectées dans le champ '%s'"
   MONTHS_OF_THE_YEAR:
     - 'janvier'
     - 'février'
@@ -105,6 +107,8 @@ GRAV:
     - 'vendredi'
     - 'samedi'
     - 'dimanche'
+  YES: "Oui"
+  NO: "Non"
   CRON:
     EVERY: chaque
     EVERY_HOUR: toutes les heures
@@ -118,7 +122,7 @@ GRAV:
     TEXT_DOW: ' sur <b/>'
     TEXT_MONTH: ' de <b />'
     TEXT_DOM: ' sur <b/>'
-    ERROR1: La balise %s n'est pas supportée!
+    ERROR1: La balise %s n'est pas prise en charge !
     ERROR2: Nombre invalide d'éléments
     ERROR3: L'élément jquery_element doit être défini dans les paramètres jqCron
     ERROR4: Expression non reconnue

+ 3 - 0
system/languages/gl.yaml

@@ -104,6 +104,7 @@ GRAV:
     VALIDATION_FAIL: '<b>Fallou a validación:</b>'
     INVALID_INPUT: 'Entrada incorrecta en'
     MISSING_REQUIRED_FIELD: 'Falta un campo requirido:'
+    XSS_ISSUES: "Detectáronse posibles problemas XSS no campo '% s'"
   MONTHS_OF_THE_YEAR:
     - 'xaneiro'
     - 'febreiro'
@@ -125,6 +126,8 @@ GRAV:
     - 'venres'
     - 'sábado'
     - 'domingo'
+  YES: "Si"
+  NO: "Non"
   CRON:
     EVERY: cada
     EVERY_HOUR: Cada hora

+ 81 - 31
system/languages/id.yaml

@@ -3,26 +3,72 @@ GRAV:
   FRONTMATTER_ERROR_PAGE: "---\ntitle: %1$s\n---\n\n# Error: Frontmatter tidak valid\n\nLokasi: `%2$s`\n\n**%3$s**\n\n```\n%4$s\n```"
   INFLECTOR_PLURALS:
     '/(quiz)$/i': '\1zes'
+    '/^(ox)$/i': '\1en'
+    '/([m|l])ouse$/i': '\1ice'
+    '/(matr|vert|ind)ix|ex$/i': '\1ices'
+    '/(x|ch|ss|sh)$/i': '\1es'
+    '/([^aeiouy]|qu)ies$/i': '\1y'
+    '/([^aeiouy]|qu)y$/i': '\1ies'
+    '/(hive)$/i': '\1s'
+    '/(?:([^f])fe|([lr])f)$/i': '\1\2ves'
+    '/sis$/i': 'ses'
+    '/([ti])um$/i': '\1a'
+    '/(buffal|tomat)o$/i': '\1oes'
+    '/(bu)s$/i': '\1ses'
+    '/(alias|status)/i': '\1es'
+    '/(octop|vir)us$/i': '\1i'
+    '/(ax|test)is$/i': '\1es'
+    '/s$/i': 's'
+    '/$/': 's'
+  INFLECTOR_SINGULAR:
+    '/(quiz)zes$/i': '\1'
+    '/(matr)ices$/i': '\1ix'
+    '/(vert|ind)ices$/i': '\1ex'
+    '/^(ox)en/i': '\1'
+    '/(alias|status)es$/i': '\1'
+    '/([octop|vir])i$/i': '\1us'
+    '/(cris|ax|test)es$/i': '\1is'
+    '/(shoe)s$/i': '\1'
+    '/(o)es$/i': '\1'
+    '/(bus)es$/i': '\1'
+    '/([m|l])ice$/i': '\1ouse'
+    '/(x|ch|ss|sh)es$/i': '\1'
+    '/(m)ovies$/i': '\1ovie'
+    '/(s)eries$/i': '\1eries'
+    '/([^aeiouy]|qu)ies$/i': '\1y'
+    '/([lr])ves$/i': '\1f'
+    '/(tive)s$/i': '\1'
+    '/(hive)s$/i': '\1'
+    '/([^f])ves$/i': '\1fe'
+    '/(^analy)ses$/i': '\1sis'
+    '/((a)naly|(b)a|(d)iagno|(p)arenthe|(p)rogno|(s)ynop|(t)he)ses$/i': '\1\2sis'
+    '/([ti])a$/i': '\1um'
+    '/(n)ews$/i': '\1ews'
   INFLECTOR_UNCOUNTABLE:
-    - 'peralatan'
-    - 'informasi'
-    - 'nasi'
-    - 'uang'
-    - 'spesies'
-    - 'rangkaian'
-    - 'ikan'
-    - 'domba'
+    - 'Peralatan'
+    - 'Informasi '
+    - 'Nasi'
+    - 'Uang'
+    - 'Jenis'
+    - 'Seri'
+    - 'Ikan'
+    - 'Domba'
   INFLECTOR_IRREGULAR:
-    'person': 'orang-orang'
-    'man': 'laki-laki'
-    'child': 'anak-anak'
-    'sex': 'jenis kelamin'
+    'person': 'Orang-orang'
+    'man': 'Pria'
+    'child': 'Balita'
+    'sex': 'Jenis Kelamin'
     'move': 'pindahkan'
+  INFLECTOR_ORDINALS:
+    'default': 'ke'
+    'first': 'pertama'
+    'second': 'nd'
+    'third': 'rd'
   NICETIME:
-    NO_DATE_PROVIDED: Tanggal tidak tersedia
+    NO_DATE_PROVIDED: Tidak ada tanggal yang disediakan
     BAD_DATE: Format tanggal salah
     AGO: yang lalu
-    FROM_NOW: dari saat ini
+    FROM_NOW: dari sekarang
     JUST_NOW: baru saja
     SECOND: detik
     MINUTE: menit
@@ -32,12 +78,12 @@ GRAV:
     MONTH: bulan
     YEAR: tahun
     DECADE: dekade
-    SEC: dtk
-    MIN: mnt
-    HR: j
-    WK: mng
-    MO: bln
-    YR: thn
+    SEC: detik
+    MIN: menit
+    HR: ' jam'
+    WK: minggu
+    MO: bulan
+    YR: tahun
     DEC: desimal
     SECOND_PLURAL: detik
     MINUTE_PLURAL: menit
@@ -47,17 +93,18 @@ GRAV:
     MONTH_PLURAL: bulan
     YEAR_PLURAL: tahun
     DECADE_PLURAL: dekade
-    SEC_PLURAL: dtk
-    MIN_PLURAL: mnt
-    HR_PLURAL: j
-    WK_PLURAL: mgg
-    MO_PLURAL: bln
-    YR_PLURAL: thn
+    SEC_PLURAL: detik
+    MIN_PLURAL: menit
+    HR_PLURAL: jam
+    WK_PLURAL: minggu
+    MO_PLURAL: bulan
+    YR_PLURAL: tahun
     DEC_PLURAL: dekade
   FORM:
     VALIDATION_FAIL: '<b>Validasi gagal:</b>'
     INVALID_INPUT: 'Input tidak valid di'
     MISSING_REQUIRED_FIELD: 'Data yang diperlukan belum terisi:'
+    XSS_ISSUES: "Isu berpotensial XSS terdeteksi dalam baris %s"
   MONTHS_OF_THE_YEAR:
     - 'Januari'
     - 'Februari'
@@ -76,22 +123,25 @@ GRAV:
     - 'Selasa'
     - 'Rabu'
     - 'Kamis'
-    - 'Jumat'
+    - 'Jum''at'
     - 'Sabtu'
     - 'Minggu'
+  YES: "Ya"
+  NO: "Tidak"
   CRON:
     EVERY: Setiap
     EVERY_HOUR: Setiap jam
     EVERY_MINUTE: Setiap menit
     EVERY_DAY_OF_WEEK: Setiap hari selama seminggu
-    EVERY_DAY_OF_MONTH: pada tanggal setiap bulannya
+    EVERY_DAY_OF_MONTH: Setiap hari dalam sebulan
     EVERY_MONTH: setiap bulan
     TEXT_PERIOD: Setiap <b />
+    TEXT_MINS: 'dalam <b />  menit setelah jam yang lalu'
     TEXT_TIME: ' pada <b />:<b />'
     TEXT_DOW: ' pada <b />'
     TEXT_MONTH: ' pada <b />'
     TEXT_DOM: ' pada <b />'
     ERROR1: Tag %s tidak didukung!
-    ERROR2: Jumlah elemen tidak valid
-    ERROR3: jquery_element harus ditetapkan ke pengaturan jqCron
-    ERROR4: Ekspresi tidak dikenali
+    ERROR2: Jumlah elemen yang buruk
+    ERROR3: jquery_element harus diatur ke dalam pengaturan jqCron
+    ERROR4: Ekspresi tidak dikenal

+ 147 - 0
system/languages/mn.yaml

@@ -0,0 +1,147 @@
+---
+GRAV:
+  FRONTMATTER_ERROR_PAGE: "---\nГарчиг: %1$s\n---\n\n# Алдаа: Буруу Формат\n\nЗам: `%2$s`\n\n**%3$s**\n\n```\n%4$s\n```"
+  INFLECTOR_PLURALS:
+    '/(quiz)$/i': '\1зүүд'
+    '/^(ox)$/i': '\1ууд'
+    '/([m|l])ouse$/i': '\1ууд'
+    '/(matr|vert|ind)ix|ex$/i': '\1иксүүд'
+    '/(x|ch|ss|sh)$/i': '\1үүд'
+    '/([^aeiouy]|qu)ies$/i': '\1үүд'
+    '/([^aeiouy]|qu)y$/i': '\1үүд'
+    '/(hive)$/i': '\1үүд'
+    '/(?:([^f])fe|([lr])f)$/i': '\1\2үүд'
+    '/sis$/i': 'үүд'
+    '/([ti])um$/i': '\1үүд'
+    '/(buffal|tomat)o$/i': '\1үүд'
+    '/(bu)s$/i': '\1үүд'
+    '/(alias|status)/i': '\1үүд'
+    '/(octop|vir)us$/i': '\1үүд'
+    '/(ax|test)is$/i': '\1үүд'
+    '/s$/i': 'үүд'
+    '/$/': 'үүд'
+  INFLECTOR_SINGULAR:
+    '/(quiz)zes$/i': '\1'
+    '/(matr)ices$/i': '\1икс'
+    '/(vert|ind)ices$/i': '\1икс'
+    '/^(ox)en/i': '\1'
+    '/(alias|status)es$/i': '\1'
+    '/([octop|vir])i$/i': '\1'
+    '/(cris|ax|test)es$/i': '\1'
+    '/(shoe)s$/i': '\1'
+    '/(o)es$/i': '\1'
+    '/(bus)es$/i': '\1'
+    '/([m|l])ice$/i': '\1'
+    '/(x|ch|ss|sh)es$/i': '\1'
+    '/(m)ovies$/i': '\1'
+    '/(s)eries$/i': '\1'
+    '/([^aeiouy]|qu)ies$/i': '\1үүд'
+    '/([lr])ves$/i': '\1'
+    '/(tive)s$/i': '\1'
+    '/(hive)s$/i': '\1'
+    '/([^f])ves$/i': '\1'
+    '/(^analy)ses$/i': '\1'
+    '/((a)naly|(b)a|(d)iagno|(p)arenthe|(p)rogno|(s)ynop|(t)he)ses$/i': '\1\2үүд'
+    '/([ti])a$/i': '\1'
+    '/(n)ews$/i': '\1'
+  INFLECTOR_UNCOUNTABLE:
+    - 'тоног төхөөрөмж'
+    - 'Мэдээлэл'
+    - 'будаа'
+    - 'мөнгө'
+    - 'төрөл зүйл'
+    - 'цуврал'
+    - 'загас'
+    - 'хонь'
+  INFLECTOR_IRREGULAR:
+    'person': 'хүмүүс'
+    'man': 'эрчүүд'
+    'child': 'хүүхэд'
+    'sex': 'хүйс'
+    'move': 'хөдөлгөөн'
+  INFLECTOR_ORDINALS:
+    'default': 'th'
+    'first': 'st'
+    'second': 'nd'
+    'third': 'rd'
+  NICETIME:
+    NO_DATE_PROVIDED: Огноо алга
+    BAD_DATE: Буруу огноо
+    AGO: өмнө 
+    FROM_NOW: одооноос
+    JUST_NOW: дөнгөж сая
+    SECOND: секунд
+    MINUTE: минут
+    HOUR: цаг
+    DAY: өдөр
+    WEEK: долоо хоног
+    MONTH: сар
+    YEAR: он
+    DECADE: арван жил
+    SEC: сек
+    MIN: мин
+    HR: цаг
+    WK: д.х.
+    MO: сар
+    YR: он
+    DEC: арван жил
+    SECOND_PLURAL: секунд
+    MINUTE_PLURAL: минут
+    HOUR_PLURAL: цаг
+    DAY_PLURAL: өдрүүд
+    WEEK_PLURAL: долоо хоногууд
+    MONTH_PLURAL: сарууд
+    YEAR_PLURAL: онууд
+    DECADE_PLURAL: арван жилүүд
+    SEC_PLURAL: сек.-үүд
+    MIN_PLURAL: мин.-ууд
+    HR_PLURAL: цагууд
+    WK_PLURAL: д.х.-ууд
+    MO_PLURAL: сарууд
+    YR_PLURAL: жилүүд
+    DEC_PLURAL: арван жилүүд
+  FORM:
+    VALIDATION_FAIL: '<b>Баталгаажуулалт амжилтгүй боллоо:</b>'
+    INVALID_INPUT: 'Буруу өгөгдөл дараахид'
+    MISSING_REQUIRED_FIELD: 'Шаардлагатай талбар дутуу байна:'
+    XSS_ISSUES: "'%s' талбарт XSS -ийн болзошгүй асуудлууд илэрсэн"
+  MONTHS_OF_THE_YEAR:
+    - '1-р сар'
+    - '2-р сар'
+    - '3-р сар'
+    - '4-р сар'
+    - '5 сар'
+    - '6 сар'
+    - '7 сар'
+    - '8 сар'
+    - '9 сар'
+    - '10 сар'
+    - '11 сар'
+    - '12 сар'
+  DAYS_OF_THE_WEEK:
+    - 'Даваа гараг'
+    - 'Мягмар гараг'
+    - 'Лхагва гараг'
+    - 'Пүрэв гараг'
+    - 'Баасан гараг'
+    - 'Бямба гараг'
+    - 'Ням гараг'
+  YES: "Тийм"
+  NO: "Үгүй"
+  CRON:
+    EVERY: бүрийн
+    EVERY_HOUR: цаг бүрийн
+    EVERY_MINUTE: минут бүрийн
+    EVERY_DAY_OF_WEEK: долоо хоногийн өдөр болгонд
+    EVERY_DAY_OF_MONTH: сарын өдөр болгонд
+    EVERY_MONTH: сар болгон
+    TEXT_PERIOD: Бүрийн  <b />
+    TEXT_MINS: '  <b /> энэ сүүлийн цагийн минутад'
+    TEXT_TIME: '  <b />:<b /> -д'
+    TEXT_DOW: '  <b /> -д'
+    TEXT_MONTH: '  <b /> -ын'
+    TEXT_DOM: '  <b /> -т'
+    ERROR1: '%s -н утга нь дэмжигддэггүй!'
+    ERROR2: Элементүүдийн тоо хэмжээ буруу
+    ERROR3: jquery_element нь jqCron тохиргоонд хийгдсэн байх ёстой
+    ERROR4: Танигдаагүй илэрхийлэл

+ 3 - 0
system/languages/pt.yaml

@@ -104,6 +104,7 @@ GRAV:
     VALIDATION_FAIL: '<b>Falha na validação:</b>'
     INVALID_INPUT: 'Dados inseridos são inválidos em'
     MISSING_REQUIRED_FIELD: 'Campo obrigatório em falta:'
+    XSS_ISSUES: "Potenciais problemas de XSS detectados no campo '%s'"
   MONTHS_OF_THE_YEAR:
     - 'Janeiro'
     - 'Fevereiro'
@@ -125,6 +126,8 @@ GRAV:
     - 'Sexta-feira'
     - 'Sábado'
     - 'Domingo'
+  YES: "Sim"
+  NO: "Não"
   CRON:
     EVERY: cada
     EVERY_HOUR: cada hora

+ 9 - 0
system/languages/si.yaml

@@ -0,0 +1,9 @@
+---
+GRAV:
+  INFLECTOR_SINGULAR:
+    '/(quiz)zes$/i': '\1'
+    '/^(ox)en/i': '\1'
+    '/(alias|status)es$/i': '\1'
+    '/(o)es$/i': '\1'
+    '/(bus)es$/i': '\1'
+    '/(x|ch|ss|sh)es$/i': '\1'

+ 2 - 0
system/languages/tr.yaml

@@ -82,6 +82,8 @@ GRAV:
     - 'Cuma'
     - 'Cumartesi'
     - 'Pazar'
+  YES: "Evet"
+  NO: "Hayır"
   CRON:
     EVERY: her
     EVERY_HOUR: saatte bir

+ 16 - 1
system/languages/zh-tw.yaml

@@ -38,7 +38,9 @@ GRAV:
     YR_PLURAL: 年
     DEC_PLURAL: 十年
   FORM:
-    MISSING_REQUIRED_FIELD: 遺漏必填欄位:
+    VALIDATION_FAIL: '<b>確驗證失敗:</b>'
+    INVALID_INPUT: '無效輸入:'
+    MISSING_REQUIRED_FIELD: '遺漏必填欄位:'
   MONTHS_OF_THE_YEAR:
     - '一月'
     - '二月'
@@ -60,3 +62,16 @@ GRAV:
     - '星期五'
     - '星期六'
     - '星期日'
+  CRON:
+    EVERY: 每
+    EVERY_HOUR: 每小時
+    EVERY_MINUTE: 每分鐘
+    EVERY_DAY_OF_WEEK: 每一天
+    EVERY_DAY_OF_MONTH: 每一天
+    EVERY_MONTH: 每個月
+    TEXT_PERIOD: 每 <b />
+    TEXT_MINS: ' 的 <b /> 分'
+    TEXT_TIME: ' <b />:<b />'
+    TEXT_DOW: ' 的 <b />'
+    TEXT_MONTH: ' 的 <b />'
+    TEXT_DOM: ' 的 <b />'

+ 19 - 2
system/router.php

@@ -13,8 +13,25 @@ if (PHP_SAPI !== 'cli-server') {
 
 $_SERVER['PHP_CLI_ROUTER'] = true;
 
-if (is_file($_SERVER['DOCUMENT_ROOT'] . DIRECTORY_SEPARATOR . $_SERVER['SCRIPT_NAME'])) {
-    return false;
+$root = $_SERVER['DOCUMENT_ROOT'];
+$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
+        // Block all direct access for these folders
+        || preg_match('`^/(\.git|cache|bin|logs|backup|webserver-configs|tests)/`ui', $path)
+        // Block access to specific file types for these system folders
+        || preg_match('`^/(system|vendor)/(.*)\.(txt|xml|md|html|yaml|yml|php|pl|py|cgi|twig|sh|bat)$`ui', $path)
+        // Block access to specific file types for these user folders
+        || preg_match('`^/(user)/(.*)\.(txt|md|yaml|yml|php|pl|py|cgi|twig|sh|bat)$`ui', $path)
+        // Block all direct access to .md files
+        || preg_match('`\.md$`ui', $path)
+        // Block access to specific files in the root folder
+        || preg_match('`^/(LICENSE\.txt|composer\.lock|composer\.json|\.htaccess)$`ui', $path)
+    )) {
+        return false;
+    }
 }
 
 $grav_index = 'index.php';

+ 5 - 1
system/src/Grav/Common/Assets/BaseAsset.php

@@ -92,6 +92,10 @@ abstract class BaseAsset extends PropertyObject
      */
     public function init($asset, $options)
     {
+        if (!$asset) {
+            return false;
+        }
+
         $config = Grav::instance()['config'];
         $uri = Grav::instance()['uri'];
 
@@ -259,6 +263,6 @@ abstract class BaseAsset extends PropertyObject
      */
     protected function cssRewrite($file, $dir, $local)
     {
-        return;
+        return '';
     }
 }

+ 1 - 1
system/src/Grav/Common/Assets/Pipeline.php

@@ -254,7 +254,7 @@ class Pipeline extends PropertyObject
                 $old_url = ltrim($old_url, '/');
             }
 
-            $new_url = ($local ? $this->base_url: '') . $old_url;
+            $new_url = ($local ? $this->base_url : '') . $old_url;
 
             return str_replace($matches[2], $new_url, $matches[0]);
         }, $file);

+ 2 - 4
system/src/Grav/Common/Assets/Traits/AssetUtilsTrait.php

@@ -68,8 +68,6 @@ trait AssetUtilsTrait
     protected function gatherLinks(array $assets, $css = true)
     {
         $buffer = '';
-
-
         foreach ($assets as $id => $asset) {
             $local = true;
 
@@ -135,7 +133,7 @@ trait AssetUtilsTrait
 
         $imports = [];
 
-        $file = (string)preg_replace_callback($regex, function ($matches) use (&$imports) {
+        $file = (string)preg_replace_callback($regex, static function ($matches) use (&$imports) {
             $imports[] = $matches[0];
 
             return '';
@@ -200,7 +198,7 @@ trait AssetUtilsTrait
         }
 
         if ($this->timestamp) {
-            if (Utils::contains($asset, '?') || $querystring) {
+            if ($querystring || Utils::contains($asset, '?')) {
                 $querystring .=  '&' . $this->timestamp;
             } else {
                 $querystring .= '?' . $this->timestamp;

+ 2 - 3
system/src/Grav/Common/Backup/Backups.php

@@ -144,9 +144,8 @@ class Backups
     public static function getTotalBackupsSize()
     {
         $backups = static::getAvailableBackups();
-        $size = array_sum(array_column($backups, 'size'));
 
-        return $size ?? 0;
+        return array_sum(array_column($backups, 'size'));
     }
 
     /**
@@ -222,7 +221,7 @@ class Backups
             $backup_root = rtrim(GRAV_ROOT . $backup_root, '/');
         }
 
-        if (!file_exists($backup_root)) {
+        if (!$backup_root || !file_exists($backup_root)) {
             throw new RuntimeException("Backup location: {$backup_root} does not exist...");
         }
 

+ 2 - 6
system/src/Grav/Common/Cache.php

@@ -141,7 +141,7 @@ class Cache extends Getters
         $uniqueness = substr(md5($uri->rootUrl(true) . $this->config->key() . GRAV_VERSION), 2, 8);
 
         // Cache key allows us to invalidate all cache on configuration changes.
-        $this->key = ($prefix ? $prefix : 'g') . '-' . $uniqueness;
+        $this->key = ($prefix ?: 'g') . '-' . $uniqueness;
         $this->cache_dir = $grav['locator']->findResource('cache://doctrine/' . $uniqueness, true, true);
         $this->driver_setting = $this->config->get('system.cache.driver');
         $this->driver = $this->getCacheDriver();
@@ -618,11 +618,7 @@ class Cache extends Getters
      */
     public function isVolatileDriver($setting)
     {
-        if (in_array($setting, ['apc', 'apcu', 'xcache', 'wincache'])) {
-            return true;
-        }
-
-        return false;
+        return in_array($setting, ['apc', 'apcu', 'xcache', 'wincache'], true);
     }
 
     /**

+ 2 - 2
system/src/Grav/Common/Data/Blueprint.php

@@ -37,7 +37,7 @@ class Blueprint extends BlueprintForm
     /** @var string|null */
     protected $scope;
 
-    /** @var BlueprintSchema */
+    /** @var BlueprintSchema|null */
     protected $blueprintSchema;
 
     /** @var object|null */
@@ -54,7 +54,7 @@ class Blueprint extends BlueprintForm
      */
     public function __clone()
     {
-        if ($this->blueprintSchema) {
+        if (null !== $this->blueprintSchema) {
             $this->blueprintSchema = clone $this->blueprintSchema;
         }
     }

+ 11 - 2
system/src/Grav/Common/Data/BlueprintSchema.php

@@ -56,6 +56,15 @@ class BlueprintSchema extends BlueprintSchemaBase implements ExportInterface
         return $this->types[$name] ?? [];
     }
 
+    /**
+     * @param string $name
+     * @return array|null
+     */
+    public function getNestedRules(string $name)
+    {
+        return $this->getNested($name);
+    }
+
     /**
      * Validate data against blueprints.
      *
@@ -74,7 +83,7 @@ class BlueprintSchema extends BlueprintSchemaBase implements ExportInterface
         }
 
         if (!empty($messages)) {
-            throw (new ValidationException())->setMessages($messages);
+            throw (new ValidationException('', 400))->setMessages($messages);
         }
     }
 
@@ -190,7 +199,7 @@ class BlueprintSchema extends BlueprintSchemaBase implements ExportInterface
                 /** @var Config $config */
                 $config = Grav::instance()['config'];
                 if (!$config->get('system.strict_mode.blueprint_strict_compat', true)) {
-                    throw new RuntimeException(sprintf('%s is not defined in blueprints', $key));
+                    throw new RuntimeException(sprintf('%s is not defined in blueprints', $key), 400);
                 }
 
                 user_error(sprintf('Having extra key %s in your data is deprecated with blueprint having \'validation: strict\'', $key), E_USER_DEPRECATED);

+ 1 - 1
system/src/Grav/Common/Data/Data.php

@@ -264,7 +264,7 @@ class Data implements DataInterface, ArrayAccess, \Countable, JsonSerializable,
      */
     public function blueprints()
     {
-        if (!$this->blueprints) {
+        if (null === $this->blueprints) {
             $this->blueprints = new Blueprint();
         } elseif (is_callable($this->blueprints)) {
             // Lazy load blueprints.

+ 12 - 4
system/src/Grav/Common/Data/Validation.php

@@ -608,7 +608,7 @@ class Validation
      */
     public static function typeColor($value, array $params, array $field)
     {
-        return preg_match('/^\#[0-9a-fA-F]{3}[0-9a-fA-F]{3}?$/u', $value);
+        return (bool)preg_match('/^\#[0-9a-fA-F]{3}[0-9a-fA-F]{3}?$/u', $value);
     }
 
     /**
@@ -781,14 +781,22 @@ class Validation
         }
 
         // If creating new values is allowed, no further checks are needed.
-        if (!empty($field['selectize']['create'])) {
+        $validateOptions = $field['validate']['options'] ?? null;
+        if (!empty($field['selectize']['create']) || $validateOptions === 'ignore') {
             return true;
         }
 
         $options = $field['options'] ?? [];
         $use = $field['use'] ?? 'values';
 
-        if (empty($field['selectize']) || empty($field['multiple'])) {
+        if ($validateOptions) {
+            // Use custom options structure.
+            foreach ($options as &$option) {
+                $option = $option[$validateOptions] ?? null;
+            }
+            unset($option);
+            $options = array_values($options);
+        } elseif (empty($field['selectize']) || empty($field['multiple'])) {
             $options = array_keys($options);
         }
         if ($use === 'keys') {
@@ -1189,7 +1197,7 @@ class Validation
      */
     public static function filterItem_List($value, $params)
     {
-        return array_values(array_filter($value, function ($v) {
+        return array_values(array_filter($value, static function ($v) {
             return !empty($v);
         }));
     }

+ 19 - 4
system/src/Grav/Common/Data/ValidationException.php

@@ -10,16 +10,18 @@
 namespace Grav\Common\Data;
 
 use Grav\Common\Grav;
+use JsonSerializable;
 use RuntimeException;
 
 /**
  * Class ValidationException
  * @package Grav\Common\Data
  */
-class ValidationException extends RuntimeException
+class ValidationException extends RuntimeException implements JsonSerializable
 {
     /** @var array */
     protected $messages = [];
+    protected $escape = true;
 
     /**
      * @param array $messages
@@ -32,21 +34,34 @@ class ValidationException extends RuntimeException
         $language = Grav::instance()['language'];
         $this->message = $language->translate('GRAV.FORM.VALIDATION_FAIL', null, true) . ' ' . $this->message;
 
-        foreach ($messages as $variable => &$list) {
+        foreach ($messages as $list) {
             $list = array_unique($list);
             foreach ($list as $message) {
-                $this->message .= "<br/>$message";
+                $this->message .= '<br/>' . htmlspecialchars($message, ENT_QUOTES | ENT_HTML5, 'UTF-8');
             }
         }
 
         return $this;
     }
 
+    public function setSimpleMessage(bool $escape = true): void
+    {
+        $first = reset($this->messages);
+        $message = reset($first);
+
+        $this->message = $escape ? htmlspecialchars($message, ENT_QUOTES | ENT_HTML5, 'UTF-8') : $message;
+    }
+
     /**
      * @return array
      */
-    public function getMessages()
+    public function getMessages(): array
     {
         return $this->messages;
     }
+
+    public function jsonSerialize(): array
+    {
+        return ['validation' => $this->messages];
+    }
 }

+ 1 - 1
system/src/Grav/Common/Debugger.php

@@ -332,7 +332,7 @@ class Debugger
             return new Response(404, $headers, json_encode($response));
         }
 
-        $data = is_array($data) ? array_map(function ($item) {
+        $data = is_array($data) ? array_map(static function ($item) {
             return $item->toArray();
         }, $data) : $data->toArray();
 

+ 4 - 3
system/src/Grav/Common/Filesystem/Folder.php

@@ -197,7 +197,7 @@ abstract class Folder
      * Shift first directory out of the path.
      *
      * @param string $path
-     * @return string
+     * @return string|null
      */
     public static function shift(&$path)
     {
@@ -371,7 +371,7 @@ abstract class Folder
             return;
         }
 
-        if (strpos($target, $source) === 0) {
+        if (strpos($target, $source . '/') === 0) {
             throw new RuntimeException('Cannot move folder to itself');
         }
 
@@ -417,7 +417,8 @@ abstract class Folder
 
         if (!$success) {
             $error = error_get_last();
-            throw new RuntimeException($error['message']);
+
+            throw new RuntimeException($error['message'] ?? 'Unknown error');
         }
 
         // Make sure that the change will be detected when caching.

+ 3 - 4
system/src/Grav/Common/Filesystem/ZipArchiver.php

@@ -57,7 +57,9 @@ class ZipArchiver extends Archiver
             throw new InvalidArgumentException('ZipArchiver: Zip PHP module not installed...');
         }
 
-        if (!file_exists($source)) {
+        // Get real path for our folder
+        $rootPath = realpath($source);
+        if (!$rootPath) {
             throw new InvalidArgumentException('ZipArchiver: ' . $source . ' cannot be found...');
         }
 
@@ -66,9 +68,6 @@ class ZipArchiver extends Archiver
             throw new InvalidArgumentException('ZipArchiver:' . $this->archive_file . ' cannot be created...');
         }
 
-        // Get real path for our folder
-        $rootPath = realpath($source);
-
         $files = $this->getArchiveFiles($rootPath);
 
         $status && $status([

+ 1 - 1
system/src/Grav/Common/Flex/FlexObject.php

@@ -43,7 +43,7 @@ abstract class FlexObject extends \Grav\Framework\Flex\FlexObject implements Med
 
         // Handle media fields.
         $settings = $this->getFieldSettings($name);
-        if ($settings['media_field'] ?? false === true) {
+        if (($settings['media_field'] ?? false) === true) {
             return $this->parseFileProperty($value, $settings);
         }
 

+ 3 - 4
system/src/Grav/Common/Flex/Types/Pages/PageCollection.php

@@ -19,7 +19,6 @@ use Grav\Common\Page\Header;
 use Grav\Common\Page\Interfaces\PageCollectionInterface;
 use Grav\Common\Page\Interfaces\PageInterface;
 use Grav\Common\Utils;
-use Grav\Framework\Flex\Interfaces\FlexObjectInterface;
 use Grav\Framework\Flex\Pages\FlexPageCollection;
 use Collator;
 use InvalidArgumentException;
@@ -159,7 +158,7 @@ class PageCollection extends FlexPageCollection implements PageCollectionInterfa
      */
     public function addPage(PageInterface $page)
     {
-        if (!$page instanceof FlexObjectInterface) {
+        if (!$page instanceof PageObject) {
             throw new InvalidArgumentException('$page is not a flex page.');
         }
 
@@ -400,8 +399,8 @@ class PageCollection extends FlexPageCollection implements PageCollectionInterfa
             $i = count($manual);
             $new_list = [];
             foreach ($list as $key => $dummy) {
-                $child = $this[$key];
-                $order = array_search($child->slug, $manual, true);
+                $child = $this[$key] ?? null;
+                $order = $child ? array_search($child->slug, $manual, true) : false;
                 if ($order === false) {
                     $order = $i++;
                 }

+ 17 - 10
system/src/Grav/Common/Flex/Types/Pages/PageIndex.php

@@ -109,6 +109,10 @@ class PageIndex extends FlexPageIndex implements PageCollectionInterface
         }
 
         $element = parent::get($key);
+        if (null === $element) {
+            return null;
+        }
+
         if (isset($params)) {
             $element = $element->getTranslation(ltrim($params, '.'));
         }
@@ -331,7 +335,10 @@ class PageIndex extends FlexPageIndex implements PageCollectionInterface
      */
     protected function filterByParent(array $filters)
     {
-        return parent::filterBy($filters);
+        /** @var static $index */
+        $index = parent::filterBy($filters);
+
+        return $index;
     }
 
     /**
@@ -547,6 +554,9 @@ class PageIndex extends FlexPageIndex implements PageCollectionInterface
         $filters = array_filter($filters, static function($val) { return $val !== null && $val !== ''; });
 
         if ($page) {
+            $status = 'success';
+            $msg = 'PLUGIN_ADMIN.PAGE_ROUTE_FOUND';
+
             if ($page->root() && (!$filter_type || in_array('root', $filter_type, true))) {
                 if ($field) {
                     $response[] = [
@@ -586,9 +596,6 @@ class PageIndex extends FlexPageIndex implements PageCollectionInterface
                 }
             }
 
-            $status = 'success';
-            $msg = 'PLUGIN_ADMIN.PAGE_ROUTE_FOUND';
-
             /** @var PageIndex $children */
             $children = $page->children()->getIndex();
             $selectedChildren = $children->filterBy($filters, true);
@@ -673,12 +680,13 @@ class PageIndex extends FlexPageIndex implements PageCollectionInterface
                     $child_count = $tmp->count();
                     $count = $filters ? $tmp->filterBy($filters, true)->count() : null;
                     $route = $child->getRoute();
+                    $route = $route ? ($route->toString(false) ?: '/') : '';
                     $payload = [
                         'item-key' => htmlspecialchars(basename($child->rawRoute() ?? $child->getKey())),
                         'icon' => $icon,
                         'title' => htmlspecialchars($child->menu()),
                         'route' => [
-                            'display' => htmlspecialchars(($route ? ($route->toString(false) ?: '/') : null) ?? ''),
+                            'display' => htmlspecialchars($route) ?: null,
                             'raw' => htmlspecialchars($child->rawRoute()),
                         ],
                         'modified' => $this->jsDate($child->modified()),
@@ -713,7 +721,7 @@ class PageIndex extends FlexPageIndex implements PageCollectionInterface
             $response = Utils::arrayFlatten($sorted);
         }
 
-        return [$status, $msg ?? 'PLUGIN_ADMIN.NO_ROUTE_PROVIDED', $response, $path];
+        return [$status, $msg, $response, $path];
     }
 
     /**
@@ -834,12 +842,11 @@ class PageIndex extends FlexPageIndex implements PageCollectionInterface
     /**
      * Remove item from the list.
      *
-     * @param PageInterface|string|null $key
-     *
-     * @return $this
+     * @param string $key
+     * @return PageObject|null
      * @throws InvalidArgumentException
      */
-    public function remove($key = null)
+    public function remove($key)
     {
         return $this->getCollection()->remove($key);
     }

+ 8 - 6
system/src/Grav/Common/Flex/Types/Pages/PageObject.php

@@ -104,12 +104,12 @@ class PageObject extends FlexPageObject
      */
     public function getRoute($query = []): ?Route
     {
-        $route = $this->route();
-        if (null === $route) {
+        $path = $this->route();
+        if (null === $path) {
             return null;
         }
 
-        $route = RouteFactory::createFromString($route);
+        $route = RouteFactory::createFromString($path);
         if ($lang = $route->getLanguage()) {
             $grav = Grav::instance();
             if (!$grav['config']->get('system.languages.include_default_lang')) {
@@ -311,7 +311,7 @@ class PageObject extends FlexPageObject
         }
 
         // Reset original after save events have all been called.
-        $this->_original = null;
+        $this->_originalObject = null;
 
         return $instance;
     }
@@ -441,7 +441,8 @@ class PageObject extends FlexPageObject
 
         // Add missing siblings into the end of the list, keeping the previous ordering between them.
         foreach ($siblings as $sibling) {
-            $basename = preg_replace('|^\d+\.|', '', $sibling->getProperty('folder'));
+            $folder = (string)$sibling->getProperty('folder');
+            $basename = preg_replace('|^\d+\.|', '', $folder);
             if (!in_array($basename, $ordering, true)) {
                 $ordering[] = $basename;
             }
@@ -451,7 +452,8 @@ class PageObject extends FlexPageObject
         $ordering = array_flip(array_values($ordering));
         $count = count($ordering);
         foreach ($siblings as $sibling) {
-            $basename = preg_replace('|^\d+\.|', '', $sibling->getProperty('folder'));
+            $folder = (string)$sibling->getProperty('folder');
+            $basename = preg_replace('|^\d+\.|', '', $folder);
             $newOrder = $ordering[$basename] ?? null;
             $newOrder = null !== $newOrder ? $newOrder + 1 : (int)$sibling->order() + $count;
             $sibling->order($newOrder);

+ 4 - 1
system/src/Grav/Common/Flex/Types/Pages/Traits/PageLegacyTrait.php

@@ -103,7 +103,10 @@ trait PageLegacyTrait
         $parent = $this->parent();
         $collection = $parent ? $parent->collection('content', false) : null;
         if (null !== $path && $collection instanceof PageCollectionInterface) {
-            return $collection->adjacentSibling($path, $direction);
+            $child = $collection->adjacentSibling($path, $direction);
+            if ($child instanceof PageInterface) {
+                return $child;
+            }
         }
 
         return false;

+ 2 - 2
system/src/Grav/Common/Flex/Types/Users/UserCollection.php

@@ -92,7 +92,7 @@ class UserCollection extends FlexCollection implements UserCollectionInterface
                 } else {
                     $user = parent::find($query, $field);
                 }
-                if ($user) {
+                if ($user instanceof UserObject) {
                     return $user;
                 }
             }
@@ -123,7 +123,7 @@ class UserCollection extends FlexCollection implements UserCollectionInterface
      * @param string $key
      * @return string
      */
-    protected function filterUsername(string $key)
+    protected function filterUsername(string $key): string
     {
         $storage = $this->getFlexDirectory()->getStorage();
         if (method_exists($storage, 'normalizeKey')) {

+ 2 - 2
system/src/Grav/Common/Flex/Types/Users/UserIndex.php

@@ -62,7 +62,7 @@ class UserIndex extends FlexIndex implements UserCollectionInterface
      * @param FlexStorageInterface $storage
      * @return void
      */
-    public static function updateObjectMeta(array &$meta, array $data, FlexStorageInterface $storage)
+    public static function updateObjectMeta(array &$meta, array $data, FlexStorageInterface $storage): void
     {
         // Username can also be number and stored as such.
         $key = (string)($data['username'] ?? $meta['key'] ?? $meta['storage_key']);
@@ -187,7 +187,7 @@ class UserIndex extends FlexIndex implements UserCollectionInterface
      * @param array $updated
      * @param array $removed
      */
-    protected static function onChanges(array $entries, array $added, array $updated, array $removed)
+    protected static function onChanges(array $entries, array $added, array $updated, array $removed): void
     {
         $message = sprintf('Flex: User index updated, %d objects (%d added, %d updated, %d removed).', count($entries), count($added), count($updated), count($removed));
 

+ 17 - 5
system/src/Grav/Common/Flex/Types/Users/UserObject.php

@@ -230,6 +230,16 @@ class UserObject extends FlexObject implements UserInterface, Countable
         return $this;
     }
 
+    /**
+     * @return bool
+     */
+    public function isMyself(): bool
+    {
+        $me = $this->getActiveUser();
+
+        return $me && $me->authenticated && $this->username === $me->username;
+    }
+
     /**
      * Checks user authorization to the action.
      *
@@ -264,6 +274,7 @@ class UserObject extends FlexObject implements UserInterface, Countable
             }
         }
 
+        // Check custom application access.
         $authorizeCallable = static::$authorizeCallable;
         if ($authorizeCallable instanceof Closure) {
             $authorizeCallable->bindTo($this);
@@ -280,13 +291,14 @@ class UserObject extends FlexObject implements UserInterface, Countable
             return $authorized;
         }
 
-        // If specific rule isn't hit, check if user is super user.
-        if ($access->authorize('admin.super') === true) {
-            return true;
+        // Check group access.
+        $authorized = $this->getGroups()->authorize($action, $scope);
+        if (is_bool($authorized)) {
+            return $authorized;
         }
 
-        // Check group access.
-        return $this->getGroups()->authorize($action, $scope);
+        // If any specific rule isn't hit, check if user is a superuser.
+        return $access->authorize('admin.super') === true;
     }
 
     /**

+ 2 - 142
system/src/Grav/Common/GPM/Response.php

@@ -1,143 +1,3 @@
 <?php
-
-/**
- * @package    Grav\Common\GPM
- *
- * @copyright  Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved.
- * @license    MIT License; see LICENSE file for details.
- */
-
-namespace Grav\Common\GPM;
-
-use Exception;
-use Grav\Common\Utils;
-use Grav\Common\Grav;
-use Symfony\Component\HttpClient\CurlHttpClient;
-use Symfony\Component\HttpClient\Exception\TransportException;
-use Symfony\Component\HttpClient\HttpClient;
-use Symfony\Component\HttpClient\HttpOptions;
-use Symfony\Component\HttpClient\NativeHttpClient;
-use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
-use function call_user_func;
-use function defined;
-use function function_exists;
-
-/**
- * Class Response
- * @package Grav\Common\GPM
- */
-class Response
-{
-    /** @var callable    The callback for the progress, either a function or callback in array notation */
-    public static $callback = null;
-    /** @var string[] */
-    private static $headers = [
-        'User-Agent' => 'Grav CMS'
-    ];
-
-    /**
-     * Makes a request to the URL by using the preferred method
-     *
-     * @param string $uri URL to call
-     * @param array $overrides An array of parameters for both `curl` and `fopen`
-     * @param callable|null $callback Either a function or callback in array notation
-     * @return string The response of the request
-     * @throws TransportExceptionInterface
-     */
-    public static function get($uri = '', $overrides = [], $callback = null)
-    {
-        if (empty($uri)) {
-            throw new TransportException('missing URI');
-        }
-
-        // check if this function is available, if so use it to stop any timeouts
-        try {
-            if (Utils::functionExists('set_time_limit')) {
-                @set_time_limit(0);
-            }
-        } catch (Exception $e) {
-        }
-
-        $config = Grav::instance()['config'];
-        $referer = defined('GRAV_CLI') ? 'grav_cli' : Grav::instance()['uri']->rootUrl(true);
-        $options = new HttpOptions();
-
-        // Set default Headers
-        $options->setHeaders(array_merge([ 'Referer' => $referer ], self::$headers));
-
-        // Disable verify Peer if required
-        $verify_peer = $config->get('system.gpm.verify_peer', true);
-        if ($verify_peer !== true) {
-            $options->verifyPeer($verify_peer);
-        }
-
-        // Set proxy url if provided
-        $proxy_url = $config->get('system.gpm.proxy_url', false);
-        if ($proxy_url) {
-            $options->setProxy($proxy_url);
-        }
-
-        // Use callback if provided
-        if ($callback) {
-            self::$callback = $callback;
-            $options->setOnProgress([Response::class, 'progress']);
-        }
-
-        $preferred_method = $config->get('system.gpm.method', 'auto');
-
-        $settings = array_merge_recursive($options->toArray(), $overrides);
-
-        switch ($preferred_method) {
-            case 'curl':
-                $client = new CurlHttpClient($settings);
-                break;
-            case 'fopen':
-            case 'native':
-                $client = new NativeHttpClient($settings);
-                break;
-            default:
-                $client = HttpClient::create($settings);
-        }
-
-        $response = $client->request('GET', $uri);
-
-        return $response->getContent();
-    }
-
-
-    /**
-     * Is this a remote file or not
-     *
-     * @param string $file
-     * @return bool
-     */
-    public static function isRemote($file)
-    {
-        return (bool) filter_var($file, FILTER_VALIDATE_URL);
-    }
-
-    /**
-     * Progress normalized for cURL and Fopen
-     * Accepts a variable length of arguments passed in by stream method
-     *
-     * @return void
-     */
-    public static function progress(int $bytes_transferred, int $filesize, array $info)
-    {
-
-        if ($bytes_transferred > 0) {
-            $percent = $filesize <= 0 ? 0 : (int)(($bytes_transferred * 100) / $filesize);
-
-            $progress = [
-                'code'        => $info['http_code'],
-                'filesize'    => $filesize,
-                'transferred' => $bytes_transferred,
-                'percent'     => $percent < 100 ? $percent : 100
-            ];
-
-            if (self::$callback !== null) {
-                call_user_func(self::$callback, $progress);
-            }
-        }
-    }
-}
+// Create alias for the deprecated class.
+class_alias(\Grav\Common\HTTP\Response::class, \Grav\Common\GPM\Response::class);

+ 4 - 0
system/src/Grav/Common/Getters.php

@@ -69,6 +69,7 @@ abstract class Getters implements ArrayAccess, Countable
      * @param int|string $offset
      * @return bool
      */
+    #[\ReturnTypeWillChange]
     public function offsetExists($offset)
     {
         if ($this->gettersVariable) {
@@ -84,6 +85,7 @@ abstract class Getters implements ArrayAccess, Countable
      * @param int|string $offset
      * @return mixed
      */
+    #[\ReturnTypeWillChange]
     public function offsetGet($offset)
     {
         if ($this->gettersVariable) {
@@ -99,6 +101,7 @@ abstract class Getters implements ArrayAccess, Countable
      * @param int|string $offset
      * @param mixed $value
      */
+    #[\ReturnTypeWillChange]
     public function offsetSet($offset, $value)
     {
         if ($this->gettersVariable) {
@@ -112,6 +115,7 @@ abstract class Getters implements ArrayAccess, Countable
     /**
      * @param int|string $offset
      */
+    #[\ReturnTypeWillChange]
     public function offsetUnset($offset)
     {
         if ($this->gettersVariable) {

+ 16 - 16
system/src/Grav/Common/Grav.php

@@ -135,7 +135,7 @@ class Grav extends Container
      *
      * @return void
      */
-    public static function resetInstance()
+    public static function resetInstance(): void
     {
         if (self::$instance) {
             // @phpstan-ignore-next-line
@@ -242,7 +242,7 @@ class Grav extends Container
      *
      * @return void
      */
-    public function process()
+    public function process(): void
     {
         if (isset($this->initialized['process'])) {
             return;
@@ -464,6 +464,10 @@ class Grav extends Container
             }
         }
 
+        if ($uri->extension() === 'json') {
+            return new Response(200, ['Content-Type' => 'application/json'], json_encode(['code' => $code, 'redirect' => $url], JSON_THROW_ON_ERROR));
+        }
+
         return new Response($code, ['Location' => $url]);
     }
 
@@ -474,7 +478,7 @@ class Grav extends Container
      * @param int    $code  Redirection code (30x)
      * @return void
      */
-    public function redirectLangSafe($route, $code = null)
+    public function redirectLangSafe($route, $code = null): void
     {
         if (!$this['uri']->isExternal($route)) {
             $this->redirect($this['pages']->route($route), $code);
@@ -489,7 +493,7 @@ class Grav extends Container
      * @param ResponseInterface|null $response
      * @return void
      */
-    public function header(ResponseInterface $response = null)
+    public function header(ResponseInterface $response = null): void
     {
         if (null === $response) {
             /** @var PageInterface $page */
@@ -514,7 +518,7 @@ class Grav extends Container
      *
      * @return void
      */
-    public function setLocale()
+    public function setLocale(): void
     {
         // Initialize Locale if set and configured.
         if ($this['language']->enabled() && $this['config']->get('system.languages.override_locale')) {
@@ -575,7 +579,7 @@ class Grav extends Container
      *
      * @return void
      */
-    public function shutdown()
+    public function shutdown(): void
     {
         // Prevent user abort allowing onShutdown event to run without interruptions.
         if (function_exists('ignore_user_abort')) {
@@ -694,7 +698,7 @@ class Grav extends Container
      *
      * @return void
      */
-    protected function registerServices()
+    protected function registerServices(): void
     {
         foreach (self::$diMap as $serviceKey => $serviceClass) {
             if (is_int($serviceKey)) {
@@ -761,12 +765,10 @@ class Grav extends Container
             // unsupported media type, try to download it...
             if ($uri_extension) {
                 $extension = $uri_extension;
+            } elseif (isset($path_parts['extension'])) {
+                $extension = $path_parts['extension'];
             } else {
-                if (isset($path_parts['extension'])) {
-                    $extension = $path_parts['extension'];
-                } else {
-                    $extension = null;
-                }
+                $extension = null;
             }
 
             if ($extension) {
@@ -776,11 +778,9 @@ class Grav extends Container
                 }
                 Utils::download($page->path() . DIRECTORY_SEPARATOR . $uri->basename(), $download);
             }
-
-            // Nothing found
-            return false;
         }
 
-        return $page;
+        // Nothing found
+        return false;
     }
 }

+ 130 - 0
system/src/Grav/Common/HTTP/Client.php

@@ -0,0 +1,130 @@
+<?php
+
+/**
+ * @package    Grav\Common\HTTP
+ *
+ * @copyright  Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved.
+ * @license    MIT License; see LICENSE file for details.
+ */
+
+namespace Grav\Common\HTTP;
+
+use Grav\Common\Grav;
+use Symfony\Component\HttpClient\CurlHttpClient;
+use Symfony\Component\HttpClient\HttpClient;
+use Symfony\Component\HttpClient\HttpOptions;
+use Symfony\Component\HttpClient\NativeHttpClient;
+use Symfony\Contracts\HttpClient\HttpClientInterface;
+
+class Client
+{
+    /** @var callable    The callback for the progress, either a function or callback in array notation */
+    public static $callback = null;
+    /** @var string[] */
+    private static $headers = [
+        'User-Agent' => 'Grav CMS'
+    ];
+
+    public static function getClient(array $overrides = [], int $connections = 6, callable $callback = null): HttpClientInterface
+    {
+        $config = Grav::instance()['config'];
+        $options = static::getOptions();
+
+        // Use callback if provided
+        if ($callback) {
+            self::$callback = $callback;
+            $options->setOnProgress([Client::class, 'progress']);
+        }
+
+        $settings = array_merge($options->toArray(), $overrides);
+        $preferred_method = $config->get('system.http.method');
+        // Try old GPM setting if value is the same as system default
+        if ($preferred_method === 'auto') {
+            $preferred_method = $config->get('system.gpm.method', 'auto');
+        }
+
+        switch ($preferred_method) {
+            case 'curl':
+                $client = new CurlHttpClient($settings, $connections);
+                break;
+            case 'fopen':
+            case 'native':
+                $client = new NativeHttpClient($settings, $connections);
+                break;
+            default:
+                $client = HttpClient::create($settings, $connections);
+        }
+
+        return $client;
+    }
+
+    /**
+     * Get HTTP Options
+     *
+     * @return HttpOptions
+     */
+    public static function getOptions(): HttpOptions
+    {
+        $config = Grav::instance()['config'];
+        $referer = defined('GRAV_CLI') ? 'grav_cli' : Grav::instance()['uri']->rootUrl(true);
+
+        $options = new HttpOptions();
+
+        // Set default Headers
+        $options->setHeaders(array_merge([ 'Referer' => $referer ], self::$headers));
+
+        // Disable verify Peer if required
+        $verify_peer = $config->get('system.http.verify_peer');
+        // Try old GPM setting if value is default
+        if ($verify_peer === true) {
+            $verify_peer = $config->get('system.gpm.verify_peer', null) ?? $verify_peer;
+        }
+        $options->verifyPeer($verify_peer);
+
+        // Set verify Host
+        $verify_host = $config->get('system.http.verify_host', true);
+        $options->verifyHost($verify_host);
+
+        // New setting and must be enabled for Proxy to work
+        if ($config->get('system.http.enable_proxy', true)) {
+            // Set proxy url if provided
+            $proxy_url = $config->get('system.http.proxy_url', $config->get('system.gpm.proxy_url', null));
+            if ($proxy_url !== null) {
+                $options->setProxy($proxy_url);
+            }
+
+            // Certificate
+            $proxy_cert = $config->get('system.http.proxy_cert_path', null);
+            if ($proxy_cert !== null) {
+                $options->setCaPath($proxy_cert);
+            }
+        }
+
+        return $options;
+    }
+
+    /**
+     * Progress normalized for cURL and Fopen
+     * Accepts a variable length of arguments passed in by stream method
+     *
+     * @return void
+     */
+    public static function progress(int $bytes_transferred, int $filesize, array $info)
+    {
+
+        if ($bytes_transferred > 0) {
+            $percent = $filesize <= 0 ? 0 : (int)(($bytes_transferred * 100) / $filesize);
+
+            $progress = [
+                'code'        => $info['http_code'],
+                'filesize'    => $filesize,
+                'transferred' => $bytes_transferred,
+                'percent'     => $percent < 100 ? $percent : 100
+            ];
+
+            if (self::$callback !== null) {
+                call_user_func(self::$callback, $progress);
+            }
+        }
+    }
+}

+ 96 - 0
system/src/Grav/Common/HTTP/Response.php

@@ -0,0 +1,96 @@
+<?php
+
+/**
+ * @package    Grav\Common\HTTP
+ *
+ * @copyright  Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved.
+ * @license    MIT License; see LICENSE file for details.
+ */
+
+namespace Grav\Common\HTTP;
+
+use Exception;
+use Grav\Common\Utils;
+use Grav\Common\Grav;
+use Symfony\Component\HttpClient\CurlHttpClient;
+use Symfony\Component\HttpClient\Exception\TransportException;
+use Symfony\Component\HttpClient\HttpClient;
+use Symfony\Component\HttpClient\HttpOptions;
+use Symfony\Component\HttpClient\NativeHttpClient;
+use Symfony\Contracts\HttpClient\Exception\ClientExceptionInterface;
+use Symfony\Contracts\HttpClient\Exception\RedirectionExceptionInterface;
+use Symfony\Contracts\HttpClient\Exception\ServerExceptionInterface;
+use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
+use Symfony\Contracts\HttpClient\HttpClientInterface;
+use Symfony\Contracts\HttpClient\ResponseInterface;
+use function call_user_func;
+use function defined;
+
+/**
+ * Class Response
+ * @package Grav\Common\GPM
+ */
+class Response
+{
+    /**
+     * Backwards compatible helper method
+     *
+     * @param string $uri
+     * @param array $overrides
+     * @param callable|null $callback
+     * @return string
+     * @throws TransportExceptionInterface|RedirectionExceptionInterface|ServerExceptionInterface|TransportExceptionInterface|ClientExceptionInterface
+     */
+    public static function get(string $uri = '', array $overrides = [], callable $callback = null): string
+    {
+        $response = static::request('GET', $uri, $overrides, $callback);
+        return $response->getContent();
+    }
+
+
+    /**
+     * Makes a request to the URL by using the preferred method
+     *
+     * @param string $method method to call such as GET, PUT, etc
+     * @param string $uri URL to call
+     * @param array $overrides An array of parameters for both `curl` and `fopen`
+     * @param callable|null $callback Either a function or callback in array notation
+     * @return ResponseInterface
+     * @throws TransportExceptionInterface
+     */
+    public static function request(string $method, string $uri, array $overrides = [], callable $callback = null): ResponseInterface
+    {
+        if (empty($method)) {
+            throw new TransportException('missing method (GET, PUT, etc.)');
+        }
+
+        if (empty($uri)) {
+            throw new TransportException('missing URI');
+        }
+
+        // check if this function is available, if so use it to stop any timeouts
+        try {
+            if (Utils::functionExists('set_time_limit')) {
+                @set_time_limit(0);
+            }
+        } catch (Exception $e) {}
+
+        $client = Client::getClient($overrides, 6, $callback);
+
+        return $client->request($method, $uri);
+    }
+
+
+    /**
+     * Is this a remote file or not
+     *
+     * @param string $file
+     * @return bool
+     */
+    public static function isRemote($file): bool
+    {
+        return (bool) filter_var($file, FILTER_VALIDATE_URL);
+    }
+
+
+}

+ 6 - 1
system/src/Grav/Common/Helpers/Excerpts.php

@@ -33,6 +33,9 @@ class Excerpts
     public static function processImageHtml($html, PageInterface $page = null)
     {
         $excerpt = static::getExcerptFromHtml($html, 'img');
+        if (null === $excerpt) {
+            return '';
+        }
 
         $original_src = $excerpt['element']['attributes']['src'];
         $excerpt['element']['attributes']['href'] = $original_src;
@@ -61,6 +64,9 @@ class Excerpts
     public static function processLinkHtml($html, PageInterface $page = null)
     {
         $excerpt = static::getExcerptFromHtml($html, 'a');
+        if (null === $excerpt) {
+            return '';
+        }
 
         $original_href = $excerpt['element']['attributes']['href'];
         $excerpt = static::processLinkExcerpt($excerpt, $page, 'link');
@@ -89,7 +95,6 @@ class Excerpts
         $excerpt = null;
         $inner = [];
 
-        /** @var DOMElement $element */
         foreach ($elements as $element) {
             $attributes = [];
             foreach ($element->attributes as $name => $value) {

+ 12 - 10
system/src/Grav/Common/Helpers/LogViewer.php

@@ -53,7 +53,6 @@ class LogViewer
      */
     public function tail($filepath, $lines = 1)
     {
-
         $f = $filepath ? @fopen($filepath, 'rb') : false;
         if ($f === false) {
             return false;
@@ -62,13 +61,12 @@ class LogViewer
         $buffer = ($lines < 2 ? 64 : ($lines < 10 ? 512 : 4096));
 
         fseek($f, -1, SEEK_END);
-        if (fread($f, 1) != "\n") {
-            $lines -= 1;
+        if (fread($f, 1) !== "\n") {
+            --$lines;
         }
 
         // Start reading
         $output = '';
-        $chunk = '';
         // While we would like more
         while (ftell($f) > 0 && $lines >= 0) {
             // Figure out how far back we should jump
@@ -76,7 +74,11 @@ class LogViewer
             // Do the jump (backwards, relative to where we are)
             fseek($f, -$seek, SEEK_CUR);
             // Read a chunk and prepend it to our output
-            $output = ($chunk = fread($f, $seek)) . $output;
+            $chunk = fread($f, $seek);
+            if ($chunk === false) {
+                throw new \RuntimeException('Cannot read file');
+            }
+            $output = $chunk . $output;
             // Jump back to where we started reading
             fseek($f, -mb_strlen($chunk, '8bit'), SEEK_CUR);
             // Decrease our line counter
@@ -123,13 +125,13 @@ class LogViewer
      */
     public function parse($line)
     {
-        if (!is_string($line) || strlen($line) === 0) {
-            return array();
+        if (!is_string($line) || $line === '') {
+            return [];
         }
 
         preg_match($this->pattern, $line, $data);
         if (!isset($data['date'])) {
-            return array();
+            return [];
         }
 
         preg_match('/(.*)- Trace:(.*)/', $data['message'], $matches);
@@ -138,7 +140,7 @@ class LogViewer
             $data['trace'] = trim($matches[2]);
         }
 
-        return array(
+        return [
             'date' => DateTime::createFromFormat('Y-m-d H:i:s', $data['date']),
             'logger' => $data['logger'],
             'level' => $data['level'],
@@ -146,7 +148,7 @@ class LogViewer
             'trace' => isset($data['trace']) ? $this->parseTrace($data['trace']) : null,
             'context' => json_decode($data['context'], true),
             'extra' => json_decode($data['extra'], true)
-        );
+        ];
     }
 
     /**

+ 1 - 3
system/src/Grav/Common/Iterator.php

@@ -230,9 +230,7 @@ class Iterator implements \ArrayAccess, \Iterator, \Countable, \Serializable
     public function filter(callable $callback = null)
     {
         foreach ($this->items as $key => $value) {
-            if ((!$callback && !(bool)$value) ||
-                ($callback && !$callback($value, $key))
-            ) {
+            if ((!$callback && !(bool)$value) || ($callback && !$callback($value, $key))) {
                 unset($this->items[$key]);
             }
         }

+ 6 - 11
system/src/Grav/Common/Language/LanguageCodes.php

@@ -86,12 +86,14 @@ class LanguageCodes
         'ja-JP'      => [ 'name' => 'Japanese',                  'nativeName' => '日本語' ], // not iso-639-1
         'ka'         => [ 'name' => 'Georgian',                  'nativeName' => 'ქართული' ],
         'kk'         => [ 'name' => 'Kazakh',                    'nativeName' => 'Қазақ' ],
+        'km'         => [ 'name' => 'Khmer',                     'nativeName' => 'Khmer' ],
         'kn'         => [ 'name' => 'Kannada',                   'nativeName' => 'ಕನ್ನಡ' ],
         'ko'         => [ 'name' => 'Korean',                    'nativeName' => '한국어' ],
         'ku'         => [ 'name' => 'Kurdish',                   'nativeName' => 'Kurdî' ],
         'la'         => [ 'name' => 'Latin',                     'nativeName' => 'Latina' ],
         'lb'         => [ 'name' => 'Luxembourgish',             'nativeName' => 'Lëtzebuergesch' ],
         'lg'         => [ 'name' => 'Luganda',                   'nativeName' => 'Luganda' ],
+        'lo'         => [ 'name' => 'Lao',                       'nativeName' => 'Lao' ],        
         'lt'         => [ 'name' => 'Lithuanian',                'nativeName' => 'Lietuvių' ],
         'lv'         => [ 'name' => 'Latvian',                   'nativeName' => 'Latviešu' ],
         'mai'        => [ 'name' => 'Maithili',                  'nativeName' => 'मैथिली মৈথিলী' ],
@@ -101,6 +103,7 @@ class LanguageCodes
         'ml'         => [ 'name' => 'Malayalam',                 'nativeName' => 'മലയാളം' ],
         'mn'         => [ 'name' => 'Mongolian',                 'nativeName' => 'Монгол' ],
         'mr'         => [ 'name' => 'Marathi',                   'nativeName' => 'मराठी' ],
+        'my'         => [ 'name' => 'Myanmar (Burmese)',         'nativeName' => 'ဗမာी' ],        
         'no'         => [ 'name' => 'Norwegian',                 'nativeName' => 'Norsk' ],
         'nb'         => [ 'name' => 'Norwegian',                 'nativeName' => 'Norsk' ],
         'nb-NO'      => [ 'name' => 'Norwegian (Bokmål)',        'nativeName' => 'Norsk bokmål' ],
@@ -132,6 +135,7 @@ class LanguageCodes
         'st'         => [ 'name' => 'Southern Sotho',            'nativeName' => 'Sesotho' ],
         'sv'         => [ 'name' => 'Swedish',                   'nativeName' => 'Svenska' ],
         'sv-SE'      => [ 'name' => 'Swedish',                   'nativeName' => 'Svenska' ],
+        'sw'         => [ 'name' => 'Swahili',                   'nativeName' => 'Swahili' ],
         'ta'         => [ 'name' => 'Tamil',                     'nativeName' => 'தமிழ்' ],
         'ta-IN'      => [ 'name' => 'Tamil (India)',             'nativeName' => 'தமிழ் (இந்தியா)' ],
         'ta-LK'      => [ 'name' => 'Tamil (Sri Lanka)',         'nativeName' => 'தமிழ் (இலங்கை)' ],
@@ -187,12 +191,7 @@ class LanguageCodes
      */
     public static function getOrientation($code)
     {
-        if (isset(static::$codes[$code])) {
-            if (isset(static::$codes[$code]['orientation'])) {
-                return static::get($code, 'orientation');
-            }
-        }
-        return 'ltr';
+        return static::$codes[$code]['orientation'] ?? 'ltr';
     }
 
     /**
@@ -226,11 +225,7 @@ class LanguageCodes
      */
     public static function get($code, $type)
     {
-        if (isset(static::$codes[$code][$type])) {
-            return static::$codes[$code][$type];
-        }
-
-        return false;
+        return static::$codes[$code][$type] ?? false;
     }
 
     /**

+ 8 - 0
system/src/Grav/Common/Media/Traits/ImageMediaTrait.php

@@ -50,6 +50,8 @@ trait ImageMediaTrait
     /** @var integer */
     protected $retina_scale;
 
+    /** @var bool */
+    protected $watermark;
 
     /** @var array */
     public static $magic_actions = [
@@ -379,6 +381,8 @@ trait ImageMediaTrait
         $this->aspect_ratio = $config->get('system.images.cls.aspect_ratio', false);
         $this->retina_scale = $config->get('system.images.cls.retina_scale', 1);
 
+        $this->watermark = $config->get('system.images.watermark.watermark_all', false);
+
         return $this;
     }
 
@@ -415,6 +419,10 @@ trait ImageMediaTrait
             $this->image->merge(ImageFile::open($overlay));
         }
 
+        if ($this->watermark) {
+            $this->watermark();
+        }
+
         return $this->image->cacheFile($this->format, $this->quality, false, [$this->get('width'), $this->get('height'), $this->get('modified')]);
     }
 }

+ 1 - 1
system/src/Grav/Common/Media/Traits/MediaUploadTrait.php

@@ -108,7 +108,7 @@ trait MediaUploadTrait
      *
      * @param array $metadata
      * @param array|null $settings
-     * @return string|null
+     * @return string
      * @throws RuntimeException
      */
     public function checkFileMetadata(array $metadata, string $filename = null, array $settings = null): string

+ 2 - 1
system/src/Grav/Common/Page/Collection.php

@@ -47,7 +47,7 @@ class Collection extends Iterator implements PageCollectionInterface
         parent::__construct($items);
 
         $this->params = $params;
-        $this->pages = $pages ? $pages : Grav::instance()->offsetGet('pages');
+        $this->pages = $pages ?: Grav::instance()->offsetGet('pages');
     }
 
     /**
@@ -187,6 +187,7 @@ class Collection extends Iterator implements PageCollectionInterface
      * @param string $offset
      * @return PageInterface|null
      */
+    #[\ReturnTypeWillChange]
     public function offsetGet($offset)
     {
         return $this->pages->get($offset) ?: null;

+ 1 - 1
system/src/Grav/Common/Page/Interfaces/PageContentInterface.php

@@ -58,7 +58,7 @@ interface PageContentInterface
     /**
      * Needed by the onPageContentProcessed event to set the raw page content
      *
-     * @param string $content
+     * @param string|null $content
      */
     public function setRawContent($content);
 

+ 2 - 0
system/src/Grav/Common/Page/Media.php

@@ -63,6 +63,7 @@ class Media extends AbstractMedia
      * @param string $offset
      * @return bool
      */
+    #[\ReturnTypeWillChange]
     public function offsetExists($offset)
     {
         return parent::offsetExists($offset) ?: isset(static::$global[$offset]);
@@ -72,6 +73,7 @@ class Media extends AbstractMedia
      * @param string $offset
      * @return MediaObjectInterface|null
      */
+    #[\ReturnTypeWillChange]
     public function offsetGet($offset)
     {
         return parent::offsetGet($offset) ?: static::$global[$offset];

+ 2 - 0
system/src/Grav/Common/Page/Medium/GlobalMedia.php

@@ -46,6 +46,7 @@ class GlobalMedia extends AbstractMedia
      * @param string $offset
      * @return bool
      */
+    #[\ReturnTypeWillChange]
     public function offsetExists($offset)
     {
         return parent::offsetExists($offset) ?: !empty($this->resolveStream($offset));
@@ -55,6 +56,7 @@ class GlobalMedia extends AbstractMedia
      * @param string $offset
      * @return MediaObjectInterface|null
      */
+    #[\ReturnTypeWillChange]
     public function offsetGet($offset)
     {
         return parent::offsetGet($offset) ?: $this->addMedium($offset);

+ 62 - 0
system/src/Grav/Common/Page/Medium/ImageMedium.php

@@ -17,6 +17,7 @@ use Grav\Common\Media\Interfaces\MediaLinkInterface;
 use Grav\Common\Media\Traits\ImageLoadingTrait;
 use Grav\Common\Media\Traits\ImageMediaTrait;
 use Grav\Common\Utils;
+use Gregwar\Image\Image;
 use RocketTheme\Toolbox\ResourceLocator\UniformResourceLocator;
 use function func_get_args;
 use function in_array;
@@ -325,6 +326,67 @@ class ImageMedium extends Medium implements ImageMediaInterface, ImageManipulate
         return $this;
     }
 
+    public function watermark($image = null, $position = null, $scale = null)
+    {
+        $grav = $this->getGrav();
+
+        $locator = $grav['locator'];
+        $config = $grav['config'];
+
+        $args = func_get_args();
+
+        $file = $args[0] ?? '1'; // using '1' because of markdown. doing ![](image.jpg?watermark) returns $args[0]='1';
+        $file = $file === '1' ? $config->get('system.images.watermark.image') : $args[0];
+
+        $watermark = $locator->findResource($file);
+        $watermark = ImageFile::open($watermark);
+
+        // Scaling operations
+        $scale     = ($scale ?? $config->get('system.images.watermark.scale', 100)) / 100;
+        $wwidth    = $this->get('width')  * $scale;
+        $wheight   = $this->get('height') * $scale;
+        $watermark->resize($wwidth, $wheight);
+
+        // Position operations
+        $position = !empty($args[1]) ? explode('-',  $args[1]) : ['center', 'center']; // todo change to config
+        $positionY = $position[0] ?? $config->get('system.images.watermark.position_y', 'center');
+        $positionX = $position[1] ?? $config->get('system.images.watermark.position_x', 'center');
+
+        switch ($positionY)
+        {
+            case 'top':
+                $positionY = 0;
+                break;
+
+            case 'bottom':
+                $positionY = $this->get('height')-$wheight;
+                break;
+
+            case 'center':
+                $positionY = ($this->get('height')/2) - ($wheight/2);
+                break;
+        }
+
+        switch ($positionX)
+        {
+            case 'left':
+                $positionX = 0;
+                break;
+
+            case 'right':
+                $positionX = $this->get('width')-$wwidth;
+                break;
+
+            case 'center':
+                $positionX = ($this->get('width')/2) - ($wwidth/2);
+                break;
+        }
+
+        $this->__call('merge', [$watermark,$positionX, $positionY]);
+
+        return $this;
+    }
+
     /**
      * Handle this commonly used variant
      *

+ 22 - 3
system/src/Grav/Common/Page/Page.php

@@ -218,6 +218,25 @@ class Page implements PageInterface
         return $this;
     }
 
+    public function __clone()
+    {
+        $this->initialized = false;
+        $this->header = $this->header ? clone $this->header : null;
+    }
+
+    /**
+     * @return void
+     */
+    public function initialize(): void
+    {
+        if (!$this->initialized) {
+            $this->initialized = true;
+            $this->route = null;
+            $this->raw_route = null;
+            $this->_forms = null;
+        }
+    }
+
     /**
      * @return void
      */
@@ -975,7 +994,7 @@ class Page implements PageInterface
     /**
      * Needed by the onPageContentProcessed event to set the raw page content
      *
-     * @param string $content
+     * @param string|null $content
      * @return void
      */
     public function setRawContent($content)
@@ -2274,11 +2293,11 @@ class Page implements PageInterface
     {
         if ($var !== null) {
             // make sure first level are arrays
-            array_walk($var, function (&$value) {
+            array_walk($var, static function (&$value) {
                 $value = (array) $value;
             });
             // make sure all values are strings
-            array_walk_recursive($var, function (&$value) {
+            array_walk_recursive($var, static function (&$value) {
                 $value = (string) $value;
             });
             $this->taxonomy = $var;

+ 74 - 12
system/src/Grav/Common/Page/Pages.php

@@ -196,6 +196,58 @@ class Pages
         return $this->baseRoute($lang) . $route;
     }
 
+    /**
+     * Get relative referrer route and language code. Returns null if the route isn't within the current base, language (if set) and route.
+     *
+     * @example `$langCode = null; $referrer = $pages->referrerRoute($langCode, '/admin');` returns relative referrer url within /admin and updates $langCode
+     * @example `$langCode = 'en'; $referrer = $pages->referrerRoute($langCode, '/admin');` returns relative referrer url within the /en/admin
+     *
+     * @param string|null $langCode Variable to store the language code. If already set, check only against that language.
+     * @param string $route Optional route within the site.
+     * @return string|null
+     * @since 1.7.23
+     */
+    public function referrerRoute(?string &$langCode, string $route = '/'): ?string
+    {
+        $referrer = $_SERVER['HTTP_REFERER'] ?? null;
+
+        // Start by checking that referrer came from our site.
+        $root = $this->grav['base_url_absolute'];
+        if (!is_string($referrer) || !str_starts_with($referrer, $root)) {
+            return null;
+        }
+
+        /** @var Language $language */
+        $language = $this->grav['language'];
+
+        // Get all language codes and append no language.
+        if (null === $langCode) {
+            $languages = $language->enabled() ? $language->getLanguages() : [];
+            $languages[] = '';
+        } else {
+            $languages[] = $langCode;
+        }
+
+        $path_base = rtrim($this->base(), '/');
+        $path_route = rtrim($route, '/');
+
+        // Try to figure out the language code.
+        foreach ($languages as $code) {
+            $path_lang = $code ? "/{$code}" : '';
+
+            $base = $path_base . $path_lang . $path_route;
+            if ($referrer === $base || str_starts_with($referrer, "{$base}/")) {
+                if (null === $langCode) {
+                    $langCode = $code;
+                }
+
+                return substr($referrer, \strlen($base));
+            }
+        }
+
+        return null;
+    }
+
     /**
      *
      * Get base URL for Grav pages.
@@ -274,7 +326,7 @@ class Pages
      *
      * @return void
      */
-    public function reset()
+    public function reset(): void
     {
         $this->initialized = false;
 
@@ -554,7 +606,7 @@ class Pages
 
             if (is_array($sort_flags)) {
                 $sort_flags = array_map('constant', $sort_flags); //transform strings to constant value
-                $sort_flags = array_reduce($sort_flags, function ($a, $b) {
+                $sort_flags = array_reduce($sort_flags, static function ($a, $b) {
                     return $a | $b;
                 }, 0); //merge constant values using bit or
             }
@@ -597,7 +649,7 @@ class Pages
             $cmd = $value;
             $params = [];
         } elseif (is_array($value) && count($value) === 1 && !is_int(key($value))) {
-            // Format: @command.param: { attr1: value1, attr2: value2 }
+            // Format: @command.param: { attr1: value1, attr2: value2 }
             $cmd = (string)key($value);
             $params = (array)current($value);
         } else {
@@ -663,29 +715,39 @@ class Pages
 
         switch ($type) {
             case 'all':
-                return $page->children();
+                $collection = $page->children();
+                break;
             case 'modules':
             case 'modular':
-                return $page->children()->modules();
+                $collection = $page->children()->modules();
+                break;
             case 'pages':
             case 'children':
-                return $page->children()->pages();
+                $collection = $page->children()->pages();
+                break;
             case 'page':
             case 'self':
-                return !$page->root() ? (new Collection())->addPage($page) : new Collection();
+                $collection = !$page->root() ? (new Collection())->addPage($page) : new Collection();
+                break;
             case 'parent':
                 $parent = $page->parent();
                 $collection = new Collection();
-                return $parent ? $collection->addPage($parent) : $collection;
+                $collection = $parent ? $collection->addPage($parent) : $collection;
+                break;
             case 'siblings':
                 $parent = $page->parent();
-                return $parent ? $parent->children()->remove($page->path()) : new Collection();
+                $collection = $parent ? $parent->children()->remove($page->path()) : new Collection();
+                break;
             case 'descendants':
-                return $this->all($page)->remove($page->path())->pages();
+                $collection = $this->all($page)->remove($page->path())->pages();
+                break;
             default:
                 // Unknown type; return empty collection.
-                return new Collection();
+                $collection = new Collection();
+                break;
         }
+
+        return $collection;
     }
 
     /**
@@ -1761,7 +1823,7 @@ class Pages
         // Build regular expression for all the allowed page extensions.
         $page_extensions = $language->getFallbackPageExtensions();
         $regex = '/^[^\.]*(' . implode('|', array_map(
-            function ($str) {
+            static function ($str) {
                 return preg_quote($str, '/');
             },
             $page_extensions

+ 25 - 0
system/src/Grav/Common/Plugin.php

@@ -10,6 +10,7 @@
 namespace Grav\Common;
 
 use ArrayAccess;
+use Composer\Autoload\ClassLoader;
 use Grav\Common\Data\Blueprint;
 use Grav\Common\Data\Data;
 use Grav\Common\Page\Interfaces\PageInterface;
@@ -42,6 +43,8 @@ class Plugin implements EventSubscriberInterface, ArrayAccess
     protected $active = true;
     /** @var Blueprint|null */
     protected $blueprint;
+    /** @var ClassLoader|null */
+    protected $loader;
 
     /**
      * By default assign all methods as listeners using the default priority.
@@ -79,6 +82,24 @@ class Plugin implements EventSubscriberInterface, ArrayAccess
         }
     }
 
+    /**
+     * @return ClassLoader|null
+     * @internal
+     */
+    final public function getAutoloader(): ?ClassLoader
+    {
+        return $this->loader;
+    }
+
+    /**
+     * @param ClassLoader|null $loader
+     * @internal
+     */
+    final public function setAutoloader(?ClassLoader $loader): void
+    {
+        $this->loader = $loader;
+    }
+
     /**
      * @param Config $config
      * @return $this
@@ -206,6 +227,7 @@ class Plugin implements EventSubscriberInterface, ArrayAccess
      * @param string $offset  An offset to check for.
      * @return bool          Returns TRUE on success or FALSE on failure.
      */
+    #[\ReturnTypeWillChange]
     public function offsetExists($offset)
     {
         if ($offset === 'title') {
@@ -223,6 +245,7 @@ class Plugin implements EventSubscriberInterface, ArrayAccess
      * @param string $offset  The offset to retrieve.
      * @return mixed         Can return all value types.
      */
+    #[\ReturnTypeWillChange]
     public function offsetGet($offset)
     {
         if ($offset === 'title') {
@@ -241,6 +264,7 @@ class Plugin implements EventSubscriberInterface, ArrayAccess
      * @param mixed $value   The value to set.
      * @throws LogicException
      */
+    #[\ReturnTypeWillChange]
     public function offsetSet($offset, $value)
     {
         throw new LogicException(__CLASS__ . ' blueprints cannot be modified.');
@@ -252,6 +276,7 @@ class Plugin implements EventSubscriberInterface, ArrayAccess
      * @param string $offset  The offset to unset.
      * @throws LogicException
      */
+    #[\ReturnTypeWillChange]
     public function offsetUnset($offset)
     {
         throw new LogicException(__CLASS__ . ' blueprints cannot be modified.');

+ 1 - 1
system/src/Grav/Common/Plugins.php

@@ -143,7 +143,7 @@ class Plugins extends Iterator
                 $instance->setConfig($config);
                 // Register autoloader.
                 if (method_exists($instance, 'autoload')) {
-                    $instance->autoload();
+                    $instance->setAutoloader($instance->autoload());
                 }
                 // Register event listeners.
                 $events->addSubscriber($instance);

+ 4 - 4
system/src/Grav/Common/Processors/InitializeProcessor.php

@@ -44,7 +44,7 @@ class InitializeProcessor extends ProcessorBase
     public $title = 'Initialize';
 
     /** @var bool */
-    private static $cli_initialized = false;
+    protected static $cli_initialized = false;
 
     /**
      * @param Grav $grav
@@ -105,12 +105,12 @@ class InitializeProcessor extends ProcessorBase
         // TODO: remove in 2.0.
         $this->container['accounts'];
 
+        // Initialize session (used by URI, see issue #3269).
+        $this->initializeSession($config);
+
         // Initialize URI (uses session, see issue #3269).
         $this->initializeUri($config);
 
-        // Initialize session.
-        $this->initializeSession($config);
-
         // Grav may return redirect response right away.
         $redirectCode = (int)$config->get('system.pages.redirect_trailing_slash', 1);
         if ($redirectCode) {

+ 17 - 3
system/src/Grav/Common/Processors/PagesProcessor.php

@@ -42,15 +42,29 @@ class PagesProcessor extends ProcessorBase
         $this->container['debugger']->addMessage($this->container['cache']->getCacheStatus());
 
         $this->container['pages']->init();
-        $this->container->fireEvent('onPagesInitialized', new Event(['pages' => $this->container['pages']]));
-        $this->container->fireEvent('onPageInitialized', new Event(['page' => $this->container['page']]));
+
+        $route = $this->container['route'];
+
+        $this->container->fireEvent('onPagesInitialized', new Event(
+            [
+                'pages' => $this->container['pages'],
+                'route' => $route,
+                'request' => $request
+            ]
+        ));
+        $this->container->fireEvent('onPageInitialized', new Event(
+            [
+                'page' => $this->container['page'],
+                'route' => $route,
+                'request' => $request
+            ]
+        ));
 
         /** @var PageInterface $page */
         $page = $this->container['page'];
 
         if (!$page->routable()) {
             $exception = new RequestException($request, 'Page Not Found', 404);
-            $route = $this->container['route'];
             // If no page found, fire event
             $event = new Event([
                 'page' => $page,

+ 1 - 1
system/src/Grav/Common/Scheduler/Job.php

@@ -271,7 +271,7 @@ class Job
         if ($whenOverlapping) {
             $this->whenOverlapping = $whenOverlapping;
         } else {
-            $this->whenOverlapping = function () {
+            $this->whenOverlapping = static function () {
                 return false;
             };
         }

+ 9 - 13
system/src/Grav/Common/Security.php

@@ -9,11 +9,11 @@
 
 namespace Grav\Common;
 
-use enshrined\svgSanitize\Sanitizer;
 use Exception;
 use Grav\Common\Config\Config;
 use Grav\Common\Filesystem\Folder;
 use Grav\Common\Page\Pages;
+use Rhukster\DomSanitizer\DOMSanitizer;
 use function chr;
 use function count;
 use function is_array;
@@ -34,7 +34,7 @@ class Security
     public static function sanitizeSvgString(string $svg): string
     {
         if (Grav::instance()['config']->get('security.sanitize_svg')) {
-            $sanitizer = new Sanitizer();
+            $sanitizer = new DOMSanitizer(DOMSanitizer::SVG);
             $sanitized = $sanitizer->sanitize($svg);
             if (is_string($sanitized)) {
                 $svg = $sanitized;
@@ -53,7 +53,7 @@ class Security
     public static function sanitizeSVG(string $file): void
     {
         if (file_exists($file) && Grav::instance()['config']->get('security.sanitize_svg')) {
-            $sanitizer = new Sanitizer();
+            $sanitizer = new DOMSanitizer(DOMSanitizer::SVG);
             $original_svg = file_get_contents($file);
             $clean_svg = $sanitizer->sanitize($original_svg);
 
@@ -107,7 +107,7 @@ class Security
                 $content = $page->value('content');
 
                 $data = ['header' => $header, 'content' => $content];
-                $results = Security::detectXssFromArray($data);
+                $results = static::detectXssFromArray($data);
 
                 if (!empty($results)) {
                     if ($route) {
@@ -138,7 +138,7 @@ class Security
             $options = static::getXssDefaults();
         }
 
-        $list = [];
+        $list = [[]];
         foreach ($array as $key => $value) {
             if (is_array($value)) {
                 $list[] = static::detectXssFromArray($value, $prefix . $key . '.', $options);
@@ -148,11 +148,7 @@ class Security
             }
         }
 
-        if (!empty($list)) {
-            return array_merge(...$list);
-        }
-
-        return $list;
+        return array_merge(...$list);
     }
 
     /**
@@ -199,7 +195,7 @@ class Security
         $string = urldecode($string);
 
         // Convert Hexadecimals
-        $string = (string)preg_replace_callback('!(&#|\\\)[xX]([0-9a-fA-F]+);?!u', function ($m) {
+        $string = (string)preg_replace_callback('!(&#|\\\)[xX]([0-9a-fA-F]+);?!u', static function ($m) {
             return chr(hexdec($m[2]));
         }, $string);
 
@@ -207,7 +203,7 @@ class Security
         $string = preg_replace('!(&#0+[0-9]+)!u', '$1;', $string);
 
         // Decode entities
-        $string = html_entity_decode($string, ENT_NOQUOTES, 'UTF-8');
+        $string = html_entity_decode($string, ENT_NOQUOTES | ENT_HTML5, 'UTF-8');
 
         // Strip whitespace characters
         $string = preg_replace('!\s!u', '', $string);
@@ -239,7 +235,7 @@ class Security
             }
         }
 
-        return false;
+        return null;
     }
 
     public static function getXssDefaults(): array

+ 1 - 1
system/src/Grav/Common/Service/ConfigServiceProvider.php

@@ -179,7 +179,7 @@ class ConfigServiceProvider implements ServiceProviderInterface
      * @param string $folder_path
      * @return array
      */
-    private static function pluginFolderPaths($plugins, $folder_path)
+    protected static function pluginFolderPaths($plugins, $folder_path)
     {
         $paths = [];
 

+ 2 - 2
system/src/Grav/Common/Session.php

@@ -129,12 +129,12 @@ class Session extends \Grav\Framework\Session\Session
                 /** @var Uri $uri */
                 $uri = $grav['uri'];
                 /** @var Forms|null $form */
-                $form = $grav['forms']->getActiveForm(); // @phpstan-ignore-line
+                $form = $grav['forms']->getActiveForm(); // @phpstan-ignore-line (form plugin)
 
                 $sessionField = base64_encode($uri->url);
 
                 /** @var FormFlash|null $flash */
-                $flash = $form ? $form->getFlash() : null; // @phpstan-ignore-line
+                $flash = $form ? $form->getFlash() : null; // @phpstan-ignore-line (form plugin)
                 $object = $flash && method_exists($flash, 'getLegacyFiles') ? [$sessionField => $flash->getLegacyFiles()] : null;
             }
         }

+ 1 - 1
system/src/Grav/Common/Taxonomy.php

@@ -105,7 +105,7 @@ class Taxonomy
             }
         } elseif (is_string($value)) {
             if (!empty($key)) {
-                $taxonomy = $taxonomy . $key;
+                $taxonomy .= $key;
             }
             $this->taxonomy_map[$taxonomy][(string) $value][$page->path()] = ['slug' => $page->slug()];
         }

+ 28 - 11
system/src/Grav/Common/Twig/Extension/GravExtension.php

@@ -90,7 +90,7 @@ class GravExtension extends AbstractExtension implements GlobalsInterface
      *
      * @return array
      */
-    public function getGlobals()
+    public function getGlobals(): array
     {
         return [
             'grav' => $this->grav,
@@ -102,7 +102,7 @@ class GravExtension extends AbstractExtension implements GlobalsInterface
      *
      * @return array
      */
-    public function getFilters()
+    public function getFilters(): array
     {
         return [
             new TwigFilter('*ize', [$this, 'inflectorFilter']),
@@ -172,7 +172,7 @@ class GravExtension extends AbstractExtension implements GlobalsInterface
      *
      * @return array
      */
-    public function getFunctions()
+    public function getFunctions(): array
     {
         return [
             new TwigFunction('array', [$this, 'arrayFilter']),
@@ -217,6 +217,7 @@ class GravExtension extends AbstractExtension implements GlobalsInterface
             new TwigFunction('cron', [$this, 'cronFunc']),
             new TwigFunction('svg_image', [$this, 'svgImageFunction']),
             new TwigFunction('xss', [$this, 'xssFunc']),
+            new TwigFunction('unique_id', [$this, 'uniqueId']),
 
             // Translations
             new TwigFunction('t', [$this, 'translate'], ['needs_environment' => true]),
@@ -243,7 +244,7 @@ class GravExtension extends AbstractExtension implements GlobalsInterface
     /**
      * @return array
      */
-    public function getTokenParsers()
+    public function getTokenParsers(): array
     {
         return [
             new TwigTokenParserRender(),
@@ -654,6 +655,20 @@ class GravExtension extends AbstractExtension implements GlobalsInterface
         return implode(', ', $results_parts);
     }
 
+    /**
+     * Generates a random string with configurable length, prefix and suffix.
+     * Unlike the built-in `uniqid()`, this string is non-conflicting and safe
+     *
+     * @param int $length
+     * @param array $options
+     * @return string
+     * @throws \Exception
+     */
+    public function uniqueId(int $length = 9, array $options = ['prefix' => '', 'suffix' => '']): string
+    {
+        return Utils::uniqueId($length, $options);
+    }
+
     /**
      * @param string $string
      * @return string
@@ -823,12 +838,8 @@ class GravExtension extends AbstractExtension implements GlobalsInterface
      * @param Environment $twig
      * @return string
      */
-    public function translate(Environment $twig)
+    public function translate(Environment $twig, ...$args)
     {
-        // shift off the environment
-        $args = func_get_args();
-        array_shift($args);
-
         // If admin and tu filter provided, use it
         if (isset($this->grav['admin'])) {
             $numargs = count($args);
@@ -836,6 +847,12 @@ class GravExtension extends AbstractExtension implements GlobalsInterface
 
             if (($numargs === 3 && is_array($args[1])) || ($numargs === 2 && !is_array($args[1]))) {
                 $lang = array_pop($args);
+                /** @var Language $language */
+                $language = $this->grav['language'];
+                if (is_string($lang) && !$language->getLanguageCode($lang)) {
+                    $args[] = $lang;
+                    $lang = null;
+                }
             } elseif ($numargs === 2 && is_array($args[1])) {
                 $subs = array_pop($args);
                 $args = array_merge($args, $subs);
@@ -1343,7 +1360,7 @@ class GravExtension extends AbstractExtension implements GlobalsInterface
      */
     public function vardumpFunc($var)
     {
-        var_dump($var);
+        dump($var);
     }
 
     /**
@@ -1407,7 +1424,7 @@ class GravExtension extends AbstractExtension implements GlobalsInterface
      * @param array $context      Twig Context
      * @param string $var variable to be found (using dot notation)
      * @param null $default the default value to be used as last resort
-     * @param null $page an optional page to use for the current page
+     * @param PageInterface|null $page an optional page to use for the current page
      * @param bool $exists toggle to simply return the page where the variable is set, else null
      * @return mixed
      */

+ 15 - 20
system/src/Grav/Common/Twig/Twig.php

@@ -22,9 +22,9 @@ use Grav\Common\Twig\Extension\GravExtension;
 use Grav\Common\Utils;
 use RocketTheme\Toolbox\ResourceLocator\UniformResourceLocator;
 use RocketTheme\Toolbox\Event\Event;
-use Phive\Twig\Extensions\Deferred\DeferredExtension;
 use RuntimeException;
 use Twig\Cache\FilesystemCache;
+use Twig\DeferredExtension\DeferredExtension;
 use Twig\Environment;
 use Twig\Error\LoaderError;
 use Twig\Error\RuntimeError;
@@ -33,7 +33,6 @@ use Twig\Extension\DebugExtension;
 use Twig\Extension\StringLoaderExtension;
 use Twig\Loader\ArrayLoader;
 use Twig\Loader\ChainLoader;
-use Twig\Loader\ExistsLoaderInterface;
 use Twig\Loader\FilesystemLoader;
 use Twig\Profiler\Profile;
 use Twig\TwigFilter;
@@ -515,25 +514,21 @@ class Twig
         $twig_extension = $extension ? '.'. $extension .TWIG_EXT : TEMPLATE_EXT;
         $template_file = $this->template($page->template() . $twig_extension);
 
-        $page_template = null;
-
         $loader = $this->twig->getLoader();
-        if ($loader instanceof ExistsLoaderInterface) {
-            if ($loader->exists($template_file)) {
-                // template.xxx.twig
-                $page_template = $template_file;
-            } elseif ($twig_extension !== TEMPLATE_EXT && $loader->exists($template . TEMPLATE_EXT)) {
-                // template.html.twig
-                $page_template = $template . TEMPLATE_EXT;
-                $format = 'html';
-            } elseif ($loader->exists($default . $twig_extension)) {
-                // default.xxx.twig
-                $page_template = $default . $twig_extension;
-            } else {
-                // default.html.twig
-                $page_template = $default . TEMPLATE_EXT;
-                $format = 'html';
-            }
+        if ($loader->exists($template_file)) {
+            // template.xxx.twig
+            $page_template = $template_file;
+        } elseif ($twig_extension !== TEMPLATE_EXT && $loader->exists($template . TEMPLATE_EXT)) {
+            // template.html.twig
+            $page_template = $template . TEMPLATE_EXT;
+            $format = 'html';
+        } elseif ($loader->exists($default . $twig_extension)) {
+            // default.xxx.twig
+            $page_template = $default . $twig_extension;
+        } else {
+            // default.html.twig
+            $page_template = $default . TEMPLATE_EXT;
+            $format = 'html';
         }
 
         return $page_template;

+ 25 - 23
system/src/Grav/Common/Uri.php

@@ -160,8 +160,8 @@ class Uri
         $language = $grav['language'];
 
         // add the port to the base for non-standard ports
-        if ($this->port !== null && $config->get('system.reverse_proxy_setup') === false) {
-            $this->base .= ':' . (string)$this->port;
+        if ($this->port && $config->get('system.reverse_proxy_setup') === false) {
+            $this->base .= ':' . $this->port;
         }
 
         // Handle custom base
@@ -176,8 +176,8 @@ class Uri
             if (isset($custom_parts['scheme'])) {
                 $this->base = $custom_parts['scheme'] . '://' . $custom_parts['host'];
                 $this->port = $custom_parts['port'] ?? null;
-                if ($this->port !== null && $config->get('system.reverse_proxy_setup') === false) {
-                    $this->base .= ':' . (string)$this->port;
+                if ($this->port && $config->get('system.reverse_proxy_setup') === false) {
+                    $this->base .= ':' . $this->port;
                 }
                 $this->root = $custom_base;
             } else {
@@ -462,8 +462,8 @@ class Uri
     public function port($raw = false)
     {
         $port = $this->port;
-        // If not in raw mode and port is not set, figure it out from scheme.
-        if (!$raw && $port === null) {
+        // If not in raw mode and port is not set or is 0, figure it out from scheme.
+        if (!$raw && !$port) {
             if ($this->scheme === 'http') {
                 $this->port = 80;
             } elseif ($this->scheme === 'https') {
@@ -471,7 +471,7 @@ class Uri
             }
         }
 
-        return $this->port;
+        return $this->port ?: null;
     }
 
     /**
@@ -586,33 +586,38 @@ class Uri
     /**
      * Return relative path to the referrer defaulting to current or given page.
      *
+     * You should set the third parameter to `true` for redirects as long as you came from the same sub-site and language.
+     *
      * @param string|null $default
      * @param string|null $attributes
+     * @param bool $withoutBaseRoute
      * @return string
      */
-    public function referrer($default = null, $attributes = null)
+    public function referrer($default = null, $attributes = null, bool $withoutBaseRoute = false)
     {
         $referrer = $_SERVER['HTTP_REFERER'] ?? null;
 
         // Check that referrer came from our site.
-        $root = $this->rootUrl(true);
-        if ($referrer) {
-            // Referrer should always have host set and it should come from the same base address.
-            if (stripos($referrer, $root) !== 0) {
-                $referrer = null;
-            }
+        if ($withoutBaseRoute) {
+            /** @var Pages $pages */
+            $pages = Grav::instance()['pages'];
+            $base = $pages->baseUrl(null, true);
+        } else {
+            $base = $this->rootUrl(true);
         }
 
-        if (!$referrer) {
+        // Referrer should always have host set and it should come from the same base address.
+        if (!is_string($referrer) || !str_starts_with($referrer, $base)) {
             $referrer = $default ?: $this->route(true, true);
         }
 
+        // Relative path from grav root.
+        $referrer = substr($referrer, strlen($base));
         if ($attributes) {
             $referrer .= $attributes;
         }
 
-        // Return relative path.
-        return substr($referrer, strlen($root));
+        return $referrer;
     }
 
     /**
@@ -648,7 +653,7 @@ class Uri
         return [
             'scheme'    => $this->scheme,
             'host'      => $this->host,
-            'port'      => $this->port,
+            'port'      => $this->port ?: null,
             'user'      => $this->user,
             'pass'      => $this->password,
             'path'      => $path,
@@ -1146,11 +1151,8 @@ class Uri
     public static function isValidUrl($url)
     {
         $regex = '/^(?:(https?|ftp|telnet):)?\/\/((?:[a-z0-9@:.-]|%[0-9A-F]{2}){3,})(?::(\d+))?((?:\/(?:[a-z0-9-._~!$&\'\(\)\*\+\,\;\=\:\@]|%[0-9A-F]{2})*)*)(?:\?((?:[a-z0-9-._~!$&\'\(\)\*\+\,\;\=\:\/?@]|%[0-9A-F]{2})*))?(?:#((?:[a-z0-9-._~!$&\'\(\)\*\+\,\;\=\:\/?@]|%[0-9A-F]{2})*))?/';
-        if (preg_match($regex, $url)) {
-            return true;
-        }
 
-        return false;
+        return (bool)preg_match($regex, $url);
     }
 
     /**
@@ -1301,7 +1303,7 @@ class Uri
      */
     protected function hasStandardPort()
     {
-        return ($this->port === 80 || $this->port === 443);
+        return (!$this->port || $this->port === 80 || $this->port === 443);
     }
 
     /**

+ 2 - 0
system/src/Grav/Common/User/DataUser/User.php

@@ -57,6 +57,7 @@ class User extends Data implements UserInterface
      * @param string $offset
      * @return bool
      */
+    #[\ReturnTypeWillChange]
     public function offsetExists($offset)
     {
         $value = parent::offsetExists($offset);
@@ -73,6 +74,7 @@ class User extends Data implements UserInterface
      * @param string $offset
      * @return mixed
      */
+    #[\ReturnTypeWillChange]
     public function offsetGet($offset)
     {
         $value = parent::offsetGet($offset);

+ 1 - 1
system/src/Grav/Common/User/Group.php

@@ -27,7 +27,7 @@ class Group extends Data
      * @return array
      * @deprecated 1.7, use $grav['user_groups'] Flex UserGroupCollection instead
      */
-    private static function groups()
+    protected static function groups()
     {
         user_error(__METHOD__ . '() is deprecated since Grav 1.7, use $grav[\'user_groups\'] Flex UserGroupCollection instead', E_USER_DEPRECATED);
 

+ 29 - 17
system/src/Grav/Common/Utils.php

@@ -21,6 +21,7 @@ use Grav\Common\Page\Markdown\Excerpts;
 use Grav\Common\Page\Pages;
 use Grav\Framework\Flex\Flex;
 use Grav\Framework\Flex\Interfaces\FlexObjectInterface;
+use Grav\Framework\Media\Interfaces\MediaInterface;
 use InvalidArgumentException;
 use Negotiation\Accept;
 use Negotiation\Negotiator;
@@ -150,7 +151,7 @@ abstract class Utils
 
         $domain = $domain ?: $grav['config']->get('system.absolute_urls', false);
 
-        return rtrim($uri->rootUrl($domain), '/') . '/' . ($resource ?? '');
+        return rtrim($uri->rootUrl($domain), '/') . '/' . ($resource ?: '');
     }
 
     /**
@@ -166,9 +167,9 @@ abstract class Utils
 
         if ($locator->isStream($path)) {
             $path = $locator->findResource($path, true);
-        } elseif (!Utils::startsWith($path, GRAV_ROOT)) {
+        } elseif (!static::startsWith($path, GRAV_ROOT)) {
             $base_url = Grav::instance()['base_url'];
-            $path = GRAV_ROOT . '/' . ltrim(Utils::replaceFirstOccurrence($base_url, '', $path), '/');
+            $path = GRAV_ROOT . '/' . ltrim(static::replaceFirstOccurrence($base_url, '', $path), '/');
         }
 
         return $path;
@@ -628,6 +629,23 @@ abstract class Utils
         return substr(str_shuffle('0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'), 0, $length);
     }
 
+    /**
+     * Generates a random string with configurable length, prefix and suffix.
+     * Unlike the built-in `uniqid()`, this string is non-conflicting and safe
+     *
+     * @param int $length
+     * @param array $options
+     * @return string
+     * @throws Exception
+     */
+    public static function uniqueId(int $length = 13, array $options = []): string
+    {
+        $options = array_merge(['prefix' => '', 'suffix' => ''], $options);
+        $bytes = random_bytes(ceil($length / 2));
+
+        return $options['prefix'] . substr(bin2hex($bytes), 0, $length) . $options['suffix'];
+    }
+
     /**
      * Provides the ability to download a file to the browser
      *
@@ -750,13 +768,13 @@ abstract class Utils
         if (is_string($http_accept)) {
             $negotiator = new Negotiator();
 
-            $supported_types = Utils::getSupportPageTypes(['html', 'json']);
-            $priorities = Utils::getMimeTypes($supported_types);
+            $supported_types = static::getSupportPageTypes(['html', 'json']);
+            $priorities = static::getMimeTypes($supported_types);
 
             $media_type = $negotiator->getBest($http_accept, $priorities);
             $mimetype = $media_type instanceof Accept ? $media_type->getValue() : '';
 
-            return Utils::getExtensionByMime($mimetype);
+            return static::getExtensionByMime($mimetype);
         }
 
         return 'html';
@@ -791,13 +809,7 @@ abstract class Utils
 
         $media_types = Grav::instance()['config']->get('media.types');
 
-        if (isset($media_types[$extension])) {
-            if (isset($media_types[$extension]['mime'])) {
-                return $media_types[$extension]['mime'];
-            }
-        }
-
-        return $default;
+        return $media_types[$extension]['mime'] ?? $default;
     }
 
     /**
@@ -1555,7 +1567,7 @@ abstract class Utils
 
         switch ($matches[0]) {
             case 'self':
-                if (null === $object) {
+                if (!$object instanceof MediaInterface) {
                     throw new RuntimeException(sprintf('Page not available for self@ reference: %s', $path));
                 }
 
@@ -1629,7 +1641,7 @@ abstract class Utils
      * @param string $path
      * @return string[]|null
      */
-    private static function resolveTokenPath(string $path): ?array
+    protected static function resolveTokenPath(string $path): ?array
     {
         if (strpos($path, '@') !== false) {
             $regex = '/^(@\w+|\w+@|@\w+@)([^:]*)(.*)$/u';
@@ -1739,7 +1751,7 @@ abstract class Utils
     {
         $enc_url = preg_replace_callback(
             '%[^:/@?&=#]+%usD',
-            function ($matches) {
+            static function ($matches) {
                 return urlencode($matches[0]);
             },
             $url
@@ -1763,7 +1775,7 @@ abstract class Utils
      *
      * @param string $string
      * @param bool $block Block or Line processing
-     * @param null $page
+     * @param PageInterface|null $page
      * @return string
      * @throws Exception
      */

+ 3 - 3
system/src/Grav/Common/Yaml.php

@@ -17,8 +17,8 @@ use Grav\Framework\File\Formatter\YamlFormatter;
  */
 abstract class Yaml
 {
-    /** @var YamlFormatter */
-    private static $yaml;
+    /** @var YamlFormatter|null */
+    protected static $yaml;
 
     /**
      * @param string $data
@@ -51,7 +51,7 @@ abstract class Yaml
     /**
      * @return void
      */
-    private static function init()
+    protected static function init()
     {
         $config = [
             'inline' => 5,

+ 4 - 5
system/src/Grav/Console/Cli/CleanCommand.php

@@ -102,11 +102,10 @@ class CleanCommand extends Command
         'vendor/dragonmantank/cron-expression/composer.json',
         'vendor/dragonmantank/cron-expression/tests',
         'vendor/dragonmantank/cron-expression/CHANGELOG.md',
-        'vendor/enshrined/svg-sanitize/tests',
-        'vendor/enshrined/svg-sanitize/.gitignore',
-        'vendor/enshrined/svg-sanitize/.travis.yml',
-        'vendor/enshrined/svg-sanitize/composer.json',
-        'vendor/enshrined/svg-sanitize/phpunit.xml',
+        'vendor/rhukster/dom-sanitizer/tests',
+        'vendor/rhukster/dom-sanitizer/.gitignore',
+        'vendor/rhukster/dom-sanitizer/composer.json',
+        'vendor/rhukster/dom-sanitizer/composer.lock',
         'vendor/erusev/parsedown/composer.json',
         'vendor/erusev/parsedown/phpunit.xml.dist',
         'vendor/erusev/parsedown/.travis.yml',

+ 0 - 2
system/src/Grav/Console/Gpm/IndexCommand.php

@@ -200,7 +200,6 @@ class IndexCommand extends GpmCommand
      */
     private function installed(Package $package): string
     {
-        $package   = $list[$package->slug] ?? $package;
         $type      = ucfirst(preg_replace('/s$/', '', $package->package_type));
         $method = 'is' . $type . 'Installed';
         $installed = $this->gpm->{$method}($package->slug);
@@ -214,7 +213,6 @@ class IndexCommand extends GpmCommand
      */
     private function enabled(Package $package): string
     {
-        $package   = $list[$package->slug] ?? $package;
         $type      = ucfirst(preg_replace('/s$/', '', $package->package_type));
         $method = 'is' . $type . 'Installed';
         $installed = $this->gpm->{$method}($package->slug);

+ 2 - 2
system/src/Grav/Framework/Acl/PermissionsReader.php

@@ -21,7 +21,7 @@ use function is_array;
 class PermissionsReader
 {
     /** @var array */
-    private static $types;
+    protected static $types;
 
     /**
      * @param string $filename
@@ -131,7 +131,7 @@ class PermissionsReader
      */
     protected static function getDependencies(array $dependencies): array
     {
-        $list = [];
+        $list = [[]];
         foreach ($dependencies as $name => $deps) {
             $current = $deps ? static::getDependencies($deps) : [];
             $current[] = $name;

+ 3 - 3
system/src/Grav/Framework/Collection/AbstractFileCollection.php

@@ -24,10 +24,10 @@ use function array_slice;
  * Collection of objects stored into a filesystem.
  *
  * @package Grav\Framework\Collection
- * @template TKey
- * @template T
+ * @template TKey of array-key
+ * @template T of object
  * @extends AbstractLazyCollection<TKey,T>
- * @mplements FileCollectionInterface<TKey,T>
+ * @implements FileCollectionInterface<TKey,T>
  */
 class AbstractFileCollection extends AbstractLazyCollection implements FileCollectionInterface
 {

+ 14 - 2
system/src/Grav/Framework/Collection/AbstractIndexCollection.php

@@ -20,8 +20,9 @@ use function count;
 
 /**
  * Abstract Index Collection.
- * @template TKey
+ * @template TKey of array-key
  * @template T
+ * @template C of CollectionInterface
  * @implements CollectionInterface<TKey,T>
  */
 abstract class AbstractIndexCollection implements CollectionInterface
@@ -144,6 +145,7 @@ abstract class AbstractIndexCollection implements CollectionInterface
      *
      * {@inheritDoc}
      */
+    #[\ReturnTypeWillChange]
     public function offsetExists($offset)
     {
         return $this->containsKey($offset);
@@ -154,6 +156,7 @@ abstract class AbstractIndexCollection implements CollectionInterface
      *
      * {@inheritDoc}
      */
+    #[\ReturnTypeWillChange]
     public function offsetGet($offset)
     {
         return $this->get($offset);
@@ -164,6 +167,7 @@ abstract class AbstractIndexCollection implements CollectionInterface
      *
      * {@inheritDoc}
      */
+    #[\ReturnTypeWillChange]
     public function offsetSet($offset, $value)
     {
         if (null === $offset) {
@@ -178,9 +182,10 @@ abstract class AbstractIndexCollection implements CollectionInterface
      *
      * {@inheritDoc}
      */
+    #[\ReturnTypeWillChange]
     public function offsetUnset($offset)
     {
-        return $this->remove($offset);
+        $this->remove($offset);
     }
 
     /**
@@ -361,6 +366,7 @@ abstract class AbstractIndexCollection implements CollectionInterface
      * @param int $start
      * @param int|null $limit
      * @return static
+     * @phpstan-return static<TKey,T,C>
      */
     public function limit($start, $limit = null)
     {
@@ -371,6 +377,7 @@ abstract class AbstractIndexCollection implements CollectionInterface
      * Reverse the order of the items.
      *
      * @return static
+     * @phpstan-return static<TKey,T,C>
      */
     public function reverse()
     {
@@ -381,6 +388,7 @@ abstract class AbstractIndexCollection implements CollectionInterface
      * Shuffle items.
      *
      * @return static
+     * @phpstan-return static<TKey,T,C>
      */
     public function shuffle()
     {
@@ -397,6 +405,7 @@ abstract class AbstractIndexCollection implements CollectionInterface
      *
      * @param array $keys
      * @return static
+     * @phpstan-return static<TKey,T,C>
      */
     public function select(array $keys)
     {
@@ -415,6 +424,7 @@ abstract class AbstractIndexCollection implements CollectionInterface
      *
      * @param array $keys
      * @return static
+     * @phpstan-return static<TKey,T,C>
      */
     public function unselect(array $keys)
     {
@@ -469,6 +479,7 @@ abstract class AbstractIndexCollection implements CollectionInterface
      *
      * @param array $entries Elements.
      * @return static
+     * @phpstan-return static<TKey,T,C>
      */
     protected function createFrom(array $entries)
     {
@@ -521,6 +532,7 @@ abstract class AbstractIndexCollection implements CollectionInterface
     /**
      * @param array|null $entries
      * @return CollectionInterface
+     * @phpstan-return C
      */
     abstract protected function loadCollection(array $entries = null): CollectionInterface;
 

+ 1 - 1
system/src/Grav/Framework/Collection/AbstractLazyCollection.php

@@ -15,7 +15,7 @@ use Doctrine\Common\Collections\AbstractLazyCollection as BaseAbstractLazyCollec
  * General JSON serializable collection.
  *
  * @package Grav\Framework\Collection
- * @template TKey
+ * @template TKey of array-key
  * @template T
  * @extends BaseAbstractLazyCollection<TKey,T>
  * @implements CollectionInterface<TKey,T>

+ 1 - 1
system/src/Grav/Framework/Collection/ArrayCollection.php

@@ -15,7 +15,7 @@ use Doctrine\Common\Collections\ArrayCollection as BaseArrayCollection;
  * General JSON serializable collection.
  *
  * @package Grav\Framework\Collection
- * @template TKey
+ * @template TKey of array-key
  * @template T
  * @extends BaseArrayCollection<TKey,T>
  * @implements CollectionInterface<TKey,T>

+ 5 - 5
system/src/Grav/Framework/Collection/CollectionInterface.php

@@ -16,7 +16,7 @@ use JsonSerializable;
  * Collection Interface.
  *
  * @package Grav\Framework\Collection
- * @template TKey
+ * @template TKey of array-key
  * @template T
  * @extends Collection<TKey,T>
  */
@@ -26,7 +26,7 @@ interface CollectionInterface extends Collection, JsonSerializable
      * Reverse the order of the items.
      *
      * @return CollectionInterface
-     * @phpstan-return CollectionInterface<TKey,T>
+     * @phpstan-return static<TKey,T>
      */
     public function reverse();
 
@@ -34,7 +34,7 @@ interface CollectionInterface extends Collection, JsonSerializable
      * Shuffle items.
      *
      * @return CollectionInterface
-     * @phpstan-return CollectionInterface<TKey,T>
+     * @phpstan-return static<TKey,T>
      */
     public function shuffle();
 
@@ -53,7 +53,7 @@ interface CollectionInterface extends Collection, JsonSerializable
      *
      * @param array<int|string> $keys
      * @return CollectionInterface
-     * @phpstan-return CollectionInterface<TKey,T>
+     * @phpstan-return static<TKey,T>
      */
     public function select(array $keys);
 
@@ -62,7 +62,7 @@ interface CollectionInterface extends Collection, JsonSerializable
      *
      * @param array<int|string> $keys
      * @return CollectionInterface
-     * @phpstan-return CollectionInterface<TKey,T>
+     * @phpstan-return static<TKey,T>
      */
     public function unselect(array $keys);
 }

+ 3 - 3
system/src/Grav/Framework/Collection/FileCollection.php

@@ -9,13 +9,13 @@
 
 namespace Grav\Framework\Collection;
 
+use stdClass;
+
 /**
  * Collection of objects stored into a filesystem.
  *
  * @package Grav\Framework\Collection
- * @template TKey
- * @template T
- * @extends AbstractFileCollection<TKey,T>
+ * @extends AbstractFileCollection<array-key,stdClass>
  */
 class FileCollection extends AbstractFileCollection
 {

+ 1 - 1
system/src/Grav/Framework/Collection/FileCollectionInterface.php

@@ -15,7 +15,7 @@ use Doctrine\Common\Collections\Selectable;
  * Collection of objects stored into a filesystem.
  *
  * @package Grav\Framework\Collection
- * @template TKey
+ * @template TKey of array-key
  * @template T
  * @extends CollectionInterface<TKey,T>
  * @extends Selectable<TKey,T>

+ 11 - 2
system/src/Grav/Framework/Controller/Traits/ControllerResponseTrait.php

@@ -12,12 +12,14 @@ declare(strict_types=1);
 namespace Grav\Framework\Controller\Traits;
 
 use Grav\Common\Config\Config;
+use Grav\Common\Data\ValidationException;
 use Grav\Common\Debugger;
 use Grav\Common\Grav;
 use Grav\Common\Utils;
 use Grav\Framework\Psr7\Response;
 use Grav\Framework\RequestHandler\Exception\RequestException;
 use Grav\Framework\Route\Route;
+use JsonSerializable;
 use Psr\Http\Message\ResponseInterface;
 use Psr\Http\Message\ServerRequestInterface;
 use Psr\Http\Message\StreamInterface;
@@ -203,7 +205,14 @@ trait ControllerResponseTrait
     protected function getErrorJson(Throwable $e): array
     {
         $code = $this->getErrorCode($e instanceof RequestException ? $e->getHttpCode() : $e->getCode());
-        $message = $e->getMessage();
+        if ($e instanceof ValidationException) {
+            $message = $e->getMessage();
+        } else {
+            $message = htmlspecialchars($e->getMessage(), ENT_QUOTES | ENT_HTML5, 'UTF-8');
+        }
+
+        $extra = $e instanceof JsonSerializable ? $e->jsonSerialize() : [];
+
         $response = [
             'code' => $code,
             'status' => 'error',
@@ -211,7 +220,7 @@ trait ControllerResponseTrait
             'error' => [
                 'code' => $code,
                 'message' => $message
-            ]
+            ] + $extra
         ];
 
         /** @var Debugger $debugger */

+ 1 - 1
system/src/Grav/Framework/Flex/Flex.php

@@ -256,7 +256,7 @@ class Flex implements FlexInterface
         }
 
         // Remove missing objects if not asked to keep them.
-        if (empty($option['keep_missing'])) {
+        if (empty($options['keep_missing'])) {
             $list = array_filter($list);
         }
 

+ 17 - 0
system/src/Grav/Framework/Flex/FlexDirectoryForm.php

@@ -15,6 +15,7 @@ use Grav\Common\Data\Blueprint;
 use Grav\Common\Data\Data;
 use Grav\Common\Grav;
 use Grav\Common\Twig\Twig;
+use Grav\Common\Utils;
 use Grav\Framework\Flex\Interfaces\FlexDirectoryFormInterface;
 use Grav\Framework\Flex\Interfaces\FlexFormInterface;
 use Grav\Framework\Form\Interfaces\FormFlashInterface;
@@ -94,9 +95,14 @@ class FlexDirectoryForm implements FlexDirectoryFormInterface, JsonSerializable
             $uniqueId = md5($directory->getFlexType() . '-directory-' . $this->name);
         }
         $this->setUniqueId($uniqueId);
+
         $this->setFlashLookupFolder($directory->getDirectoryBlueprint()->get('form/flash_folder') ?? 'tmp://forms/[SESSIONID]');
         $this->form = $options['form'] ?? null;
 
+        if (Utils::isPositive($this->form['disabled'] ?? false)) {
+            $this->disable();
+        }
+
         $this->initialize();
     }
 
@@ -129,6 +135,17 @@ class FlexDirectoryForm implements FlexDirectoryFormInterface, JsonSerializable
         return $this;
     }
 
+    /**
+     * @param string $uniqueId
+     * @return void
+     */
+    public function setUniqueId(string $uniqueId): void
+    {
+        if ($uniqueId !== '') {
+            $this->uniqueid = $uniqueId;
+        }
+    }
+
     /**
      * @param string $name
      * @param mixed $default

+ 17 - 0
system/src/Grav/Framework/Flex/FlexForm.php

@@ -15,6 +15,7 @@ use Grav\Common\Data\Blueprint;
 use Grav\Common\Data\Data;
 use Grav\Common\Grav;
 use Grav\Common\Twig\Twig;
+use Grav\Common\Utils;
 use Grav\Framework\Flex\Interfaces\FlexFormInterface;
 use Grav\Framework\Flex\Interfaces\FlexObjectFormInterface;
 use Grav\Framework\Flex\Interfaces\FlexObjectInterface;
@@ -125,10 +126,15 @@ class FlexForm implements FlexObjectFormInterface, JsonSerializable
             $uniqueId = md5($uniqueId);
         }
         $this->setUniqueId($uniqueId);
+
         $directory = $object->getFlexDirectory();
         $this->setFlashLookupFolder($options['flash_folder'] ?? $directory->getBlueprint()->get('form/flash_folder') ?? 'tmp://forms/[SESSIONID]');
         $this->form = $options['form'] ?? null;
 
+        if (Utils::isPositive($this->items['disabled'] ?? $this->form['disabled'] ?? false)) {
+            $this->disable();
+        }
+
         if (!empty($options['reset'])) {
             $this->getFlash()->delete();
         }
@@ -172,6 +178,17 @@ class FlexForm implements FlexObjectFormInterface, JsonSerializable
         return $this;
     }
 
+    /**
+     * @param string $uniqueId
+     * @return void
+     */
+    public function setUniqueId(string $uniqueId): void
+    {
+        if ($uniqueId !== '') {
+            $this->uniqueid = $uniqueId;
+        }
+    }
+
     /**
      * @param string $name
      * @param mixed $default

+ 6 - 2
system/src/Grav/Framework/Flex/FlexIndex.php

@@ -35,7 +35,7 @@ use function in_array;
  * @package Grav\Framework\Flex
  * @template T of FlexObjectInterface
  * @template C of FlexCollectionInterface
- * @extends ObjectIndex<string,T>
+ * @extends ObjectIndex<string,T,C>
  * @implements FlexIndexInterface<T>
  * @mixin C
  */
@@ -540,6 +540,7 @@ class FlexIndex extends ObjectIndex implements FlexCollectionInterface, FlexInde
      */
     protected function createFrom(array $entries, string $keyField = null)
     {
+        /** @phpstan-var static<T,C> $index */
         $index = new static($entries, $this->getFlexDirectory());
         $index->setKeyField($keyField ?? $this->_keyField);
 
@@ -630,7 +631,10 @@ class FlexIndex extends ObjectIndex implements FlexCollectionInterface, FlexInde
      */
     protected function loadCollection(array $entries = null): CollectionInterface
     {
-        return $this->getFlexDirectory()->loadCollection($entries ?? $this->getEntries(), $this->_keyField);
+        /** @var C $collection */
+        $collection = $this->getFlexDirectory()->loadCollection($entries ?? $this->getEntries(), $this->_keyField);
+
+        return $collection;
     }
 
     /**

+ 50 - 12
system/src/Grav/Framework/Flex/FlexObject.php

@@ -12,6 +12,7 @@ namespace Grav\Framework\Flex;
 use ArrayAccess;
 use Exception;
 use Grav\Common\Data\Blueprint;
+use Grav\Common\Data\Data;
 use Grav\Common\Debugger;
 use Grav\Common\Grav;
 use Grav\Common\Inflector;
@@ -72,8 +73,6 @@ class FlexObject implements FlexObjectInterface, FlexAuthorizeInterface
     private $_meta;
     /** @var array */
     protected $_original;
-    /** @var array */
-    protected $_changes;
     /** @var string */
     protected $storage_key;
     /** @var int */
@@ -454,13 +453,50 @@ class FlexObject implements FlexObjectInterface, FlexAuthorizeInterface
     }
 
     /**
-     * Get any changes based on data sent to update
+     * Get diff array from the object.
+     *
+     * @return array
+     */
+    public function getDiff(): array
+    {
+        $blueprint = $this->getBlueprint();
+
+        $flattenOriginal = $blueprint->flattenData($this->getOriginalData());
+        $flattenElements = $blueprint->flattenData($this->getElements());
+        $removedElements = array_diff_key($flattenOriginal, $flattenElements);
+        $diff = [];
+
+        // Include all added or changed keys.
+        foreach ($flattenElements as $key => $value) {
+            $orig = $flattenOriginal[$key] ?? null;
+            if ($orig !== $value) {
+                $diff[$key] = ['old' => $orig, 'new' => $value];
+            }
+        }
+
+        // Include all removed keys.
+        foreach ($removedElements as $key => $value) {
+            $diff[$key] = ['old' => $value, 'new' => null];
+        }
+
+        return $diff;
+    }
+
+    /**
+     * Get any changes from the object.
      *
      * @return array
      */
     public function getChanges(): array
     {
-        return $this->_changes ?? [];
+        $diff = $this->getDiff();
+
+        $data = new Data();
+        foreach ($diff as $key => $change) {
+            $data->set($key, $change['new']);
+        }
+
+        return $data->toArray();
     }
 
     /**
@@ -641,14 +677,19 @@ class FlexObject implements FlexObjectInterface, FlexAuthorizeInterface
     public function update(array $data, array $files = [])
     {
         if ($data) {
+            // Get currently stored data.
+            $elements = $this->getElements();
+
+            // Store original version of the object.
+            if ($this->_original === null) {
+                $this->_original = $elements;
+            }
+
             $blueprint = $this->getBlueprint();
 
             // Process updated data through the object filters.
             $this->filterElements($data);
 
-            // Get currently stored data.
-            $elements = $this->getElements();
-
             // Merge existing object to the test data to be validated.
             $test = $blueprint->mergeData($elements, $data);
 
@@ -657,17 +698,14 @@ class FlexObject implements FlexObjectInterface, FlexAuthorizeInterface
             $data = $blueprint->filter($data, true, true);
 
             // Finally update the object.
-            foreach ($blueprint->flattenData($data) as $key => $value) {
+            $flattenData = $blueprint->flattenData($data);
+            foreach ($flattenData as $key => $value) {
                 if ($value === null) {
                     $this->unsetNestedProperty($key);
                 } else {
                     $this->setNestedProperty($key, $value);
                 }
             }
-
-            // Store the changes
-            $this->_original = $this->getElements();
-            $this->_changes = Utils::arrayDiffMultidimensional($this->_original, $elements);
         }
 
         if ($files && method_exists($this, 'setUpdatedMedia')) {

+ 1 - 0
system/src/Grav/Framework/Flex/Interfaces/FlexIndexInterface.php

@@ -51,6 +51,7 @@ interface FlexIndexInterface extends FlexCollectionInterface
      *
      * @param string|null $keyField Switch key field of the collection.
      * @return static  Returns a new Flex Collection with new key field.
+     * @phpstan-return static<T>
      * @api
      */
     public function withKeyField(string $keyField = null);

+ 4 - 4
system/src/Grav/Framework/Flex/Pages/FlexPageObject.php

@@ -50,7 +50,7 @@ class FlexPageObject extends FlexObject implements PageInterface, FlexTranslateI
     /** @var array|null */
     protected $_reorder;
     /** @var FlexPageObject|null */
-    protected $_original;
+    protected $_originalObject;
 
     /**
      * Clone page.
@@ -264,7 +264,7 @@ class FlexPageObject extends FlexObject implements PageInterface, FlexTranslateI
      */
     public function getOriginal()
     {
-        return $this->_original;
+        return $this->_originalObject;
     }
 
     /**
@@ -276,8 +276,8 @@ class FlexPageObject extends FlexObject implements PageInterface, FlexTranslateI
      */
     public function storeOriginal(): void
     {
-        if (null === $this->_original) {
-            $this->_original = clone $this;
+        if (null === $this->_originalObject) {
+            $this->_originalObject = clone $this;
         }
     }
 

+ 4 - 6
system/src/Grav/Framework/Flex/Storage/FolderStorage.php

@@ -224,6 +224,7 @@ class FolderStorage extends AbstractFilesystemStorage
      * @param string $src
      * @param string $dst
      * @return bool
+     * @throws RuntimeException
      */
     public function copyRow(string $src, string $dst): bool
     {
@@ -247,6 +248,7 @@ class FolderStorage extends AbstractFilesystemStorage
     /**
      * {@inheritdoc}
      * @see FlexStorageInterface::renameRow()
+     * @throws RuntimeException
      */
     public function renameRow(string $src, string $dst): bool
     {
@@ -634,7 +636,7 @@ class FolderStorage extends AbstractFilesystemStorage
         $flags = FilesystemIterator::KEY_AS_PATHNAME | FilesystemIterator::CURRENT_AS_FILEINFO | FilesystemIterator::SKIP_DOTS | FilesystemIterator::UNIX_PATHS;
 
         $iterator = new FilesystemIterator($path, $flags);
-        $list = [];
+        $list = [[]];
         /** @var SplFileInfo $info */
         foreach ($iterator as $filename => $info) {
             if (!$info->isDir() || strpos($info->getFilename(), '.') === 0) {
@@ -644,11 +646,7 @@ class FolderStorage extends AbstractFilesystemStorage
             $list[] = $this->buildIndexFromFilesystem($filename);
         }
 
-        if (!$list) {
-            return [];
-        }
-
-        return count($list) > 1 ? array_merge(...$list) : $list[0];
+        return array_merge(...$list);
     }
 
     /**

+ 27 - 1
system/src/Grav/Framework/Form/Traits/FormTrait.php

@@ -57,6 +57,8 @@ trait FormTrait
     private $name;
     /** @var string */
     private $id;
+    /** @var bool */
+    private $enabled = true;
     /** @var string */
     private $uniqueid;
     /** @var string */
@@ -90,6 +92,30 @@ trait FormTrait
         $this->id = $id;
     }
 
+    /**
+     * @return void
+     */
+    public function disable(): void
+    {
+        $this->enabled = false;
+    }
+
+    /**
+     * @return void
+     */
+    public function enable(): void
+    {
+        $this->enabled = true;
+    }
+
+    /**
+     * @return bool
+     */
+    public function isEnabled(): bool
+    {
+        return $this->enabled;
+    }
+
     /**
      * @return string
      */
@@ -685,7 +711,7 @@ trait FormTrait
 
         return [
             $data,
-            $files ?? []
+            $files
         ];
     }
 

+ 34 - 0
system/src/Grav/Framework/Logger/Processors/UserProcessor.php

@@ -0,0 +1,34 @@
+<?php
+
+/**
+ * @package    Grav\Framework\Logger
+ *
+ * @copyright  Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved.
+ * @license    MIT License; see LICENSE file for details.
+ */
+
+namespace Grav\Framework\Logger\Processors;
+
+use Grav\Common\Grav;
+use Grav\Common\User\Interfaces\UserInterface;
+use Monolog\Processor\ProcessorInterface;
+
+/**
+ * Adds username and email to log messages.
+ */
+class UserProcessor implements ProcessorInterface
+{
+    /**
+     * {@inheritDoc}
+     */
+    public function __invoke(array $record): array
+    {
+        /** @var UserInterface|null $user */
+        $user = Grav::instance()['user'] ?? null;
+        if ($user && $user->exists()) {
+            $record['extra']['user'] = ['username' => $user->username, 'email' => $user->email];
+        }
+
+        return $record;
+    }
+}

+ 4 - 0
system/src/Grav/Framework/Object/Access/ArrayAccessTrait.php

@@ -21,6 +21,7 @@ trait ArrayAccessTrait
      * @param mixed $offset  An offset to check for.
      * @return bool          Returns TRUE on success or FALSE on failure.
      */
+    #[\ReturnTypeWillChange]
     public function offsetExists($offset)
     {
         return $this->hasProperty($offset);
@@ -32,6 +33,7 @@ trait ArrayAccessTrait
      * @param mixed $offset  The offset to retrieve.
      * @return mixed         Can return all value types.
      */
+    #[\ReturnTypeWillChange]
     public function offsetGet($offset)
     {
         return $this->getProperty($offset);
@@ -44,6 +46,7 @@ trait ArrayAccessTrait
      * @param mixed $value   The value to set.
      * @return void
      */
+    #[\ReturnTypeWillChange]
     public function offsetSet($offset, $value)
     {
         $this->setProperty($offset, $value);
@@ -55,6 +58,7 @@ trait ArrayAccessTrait
      * @param mixed $offset  The offset to unset.
      * @return void
      */
+    #[\ReturnTypeWillChange]
     public function offsetUnset($offset)
     {
         $this->unsetProperty($offset);

+ 4 - 0
system/src/Grav/Framework/Object/Access/NestedArrayAccessTrait.php

@@ -21,6 +21,7 @@ trait NestedArrayAccessTrait
      * @param mixed $offset  An offset to check for.
      * @return bool          Returns TRUE on success or FALSE on failure.
      */
+    #[\ReturnTypeWillChange]
     public function offsetExists($offset)
     {
         return $this->hasNestedProperty($offset);
@@ -32,6 +33,7 @@ trait NestedArrayAccessTrait
      * @param mixed $offset  The offset to retrieve.
      * @return mixed         Can return all value types.
      */
+    #[\ReturnTypeWillChange]
     public function offsetGet($offset)
     {
         return $this->getNestedProperty($offset);
@@ -44,6 +46,7 @@ trait NestedArrayAccessTrait
      * @param mixed $value   The value to set.
      * @return void
      */
+    #[\ReturnTypeWillChange]
     public function offsetSet($offset, $value)
     {
         $this->setNestedProperty($offset, $value);
@@ -55,6 +58,7 @@ trait NestedArrayAccessTrait
      * @param mixed $offset  The offset to unset.
      * @return void
      */
+    #[\ReturnTypeWillChange]
     public function offsetUnset($offset)
     {
         $this->unsetNestedProperty($offset);

+ 0 - 2
system/src/Grav/Framework/Object/Base/ObjectCollectionTrait.php

@@ -207,8 +207,6 @@ trait ObjectCollectionTrait
 
     /**
      * Create a copy from this collection by cloning all objects in the collection.
-     *
-     * @return static
      */
     public function copy()
     {

+ 1 - 1
system/src/Grav/Framework/Object/Interfaces/NestedObjectCollectionInterface.php

@@ -15,7 +15,7 @@ use RuntimeException;
  * Common Interface for both Objects and Collections
  * @package Grav\Framework\Object
  *
- * @template TKey
+ * @template TKey of array-key
  * @template T
  * @extends ObjectCollectionInterface<TKey,T>
  */

+ 2 - 1
system/src/Grav/Framework/Object/Interfaces/ObjectCollectionInterface.php

@@ -16,7 +16,7 @@ use Serializable;
 /**
  * ObjectCollection Interface
  * @package Grav\Framework\Collection
- * @template TKey
+ * @template TKey of array-key
  * @template T
  * @extends CollectionInterface<TKey,T>
  * @extends Selectable<TKey,T>
@@ -76,6 +76,7 @@ interface ObjectCollectionInterface extends CollectionInterface, Selectable, Ser
      * Create a copy from this collection by cloning all objects in the collection.
      *
      * @return static
+     * @phpstan-return static<TKey,T>
      */
     public function copy();
 

+ 1 - 1
system/src/Grav/Framework/Object/ObjectCollection.php

@@ -21,7 +21,7 @@ use function array_slice;
 /**
  * Class contains a collection of objects.
  *
- * @template TKey
+ * @template TKey of array-key
  * @template T
  * @extends ArrayCollection<TKey,T>
  * @implements NestedObjectCollectionInterface<TKey,T>

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