ouidade 2 years ago
parent
commit
eeefd826e5
70 changed files with 3022 additions and 376 deletions
  1. 78 0
      CHANGELOG.md
  2. 2 1
      composer.json
  3. 209 172
      composer.lock
  4. 7 0
      system/blueprints/config/system.yaml
  5. 11 0
      system/blueprints/flex/user-accounts.yaml
  6. 2 0
      system/config/system.yaml
  7. 1 1
      system/defines.php
  8. 4 3
      system/src/Grav/Common/Config/Setup.php
  9. 4 4
      system/src/Grav/Common/Data/Blueprint.php
  10. 20 2
      system/src/Grav/Common/Data/Validation.php
  11. 21 0
      system/src/Grav/Common/Errors/SystemFacade.php
  12. 81 9
      system/src/Grav/Common/File/CompiledFile.php
  13. 58 52
      system/src/Grav/Common/Filesystem/Folder.php
  14. 3 6
      system/src/Grav/Common/Flex/Types/Pages/PageIndex.php
  15. 19 10
      system/src/Grav/Common/Flex/Types/Pages/PageObject.php
  16. 6 4
      system/src/Grav/Common/Flex/Types/Users/UserIndex.php
  17. 80 0
      system/src/Grav/Common/Flex/Types/Users/UserObject.php
  18. 1 0
      system/src/Grav/Common/GPM/GPM.php
  19. 1 1
      system/src/Grav/Common/GPM/Remote/AbstractPackageCollection.php
  20. 24 5
      system/src/Grav/Common/Grav.php
  21. 2 0
      system/src/Grav/Common/Language/LanguageCodes.php
  22. 0 20
      system/src/Grav/Common/Media/Interfaces/MediaObjectInterface.php
  23. 1 1
      system/src/Grav/Common/Page/Media.php
  24. 6 6
      system/src/Grav/Common/Page/Medium/ImageMedium.php
  25. 6 1
      system/src/Grav/Common/Page/Page.php
  26. 49 17
      system/src/Grav/Common/Page/Pages.php
  27. 2 1
      system/src/Grav/Common/Processors/InitializeProcessor.php
  28. 14 16
      system/src/Grav/Common/Security.php
  29. 6 2
      system/src/Grav/Common/Service/FlexServiceProvider.php
  30. 38 0
      system/src/Grav/Common/Twig/Extension/GravExtension.php
  31. 2 1
      system/src/Grav/Common/User/DataUser/UserCollection.php
  32. 17 7
      system/src/Grav/Common/Utils.php
  33. 1 1
      system/src/Grav/Console/Cli/InstallCommand.php
  34. 1 1
      system/src/Grav/Console/ConsoleTrait.php
  35. 1 2
      system/src/Grav/Console/Gpm/DirectInstallCommand.php
  36. 2 2
      system/src/Grav/Console/Gpm/InstallCommand.php
  37. 1 1
      system/src/Grav/Console/Gpm/SelfupgradeCommand.php
  38. 0 3
      system/src/Grav/Framework/Cache/CacheTrait.php
  39. 52 0
      system/src/Grav/Framework/Contracts/Media/MediaObjectInterface.php
  40. 27 0
      system/src/Grav/Framework/Contracts/Object/IdentifierInterface.php
  41. 28 0
      system/src/Grav/Framework/Contracts/Relationships/RelationshipIdentifierInterface.php
  42. 81 0
      system/src/Grav/Framework/Contracts/Relationships/RelationshipInterface.php
  43. 53 0
      system/src/Grav/Framework/Contracts/Relationships/RelationshipsInterface.php
  44. 55 0
      system/src/Grav/Framework/Contracts/Relationships/ToManyRelationshipInterface.php
  45. 37 0
      system/src/Grav/Framework/Contracts/Relationships/ToOneRelationshipInterface.php
  46. 4 0
      system/src/Grav/Framework/Flex/FlexCollection.php
  47. 76 0
      system/src/Grav/Framework/Flex/FlexDirectory.php
  48. 1 0
      system/src/Grav/Framework/Flex/FlexDirectoryForm.php
  49. 1 0
      system/src/Grav/Framework/Flex/FlexForm.php
  50. 75 0
      system/src/Grav/Framework/Flex/FlexIdentifier.php
  51. 4 0
      system/src/Grav/Framework/Flex/FlexIndex.php
  52. 3 11
      system/src/Grav/Framework/Flex/FlexObject.php
  53. 54 2
      system/src/Grav/Framework/Flex/Traits/FlexMediaTrait.php
  54. 61 0
      system/src/Grav/Framework/Flex/Traits/FlexRelationshipsTrait.php
  55. 17 3
      system/src/Grav/Framework/Form/FormFlash.php
  56. 9 0
      system/src/Grav/Framework/Form/FormFlashFile.php
  57. 7 0
      system/src/Grav/Framework/Form/Interfaces/FormFlashInterface.php
  58. 26 3
      system/src/Grav/Framework/Form/Traits/FormTrait.php
  59. 30 0
      system/src/Grav/Framework/Media/Interfaces/MediaObjectInterface.php
  60. 150 0
      system/src/Grav/Framework/Media/MediaIdentifier.php
  61. 215 0
      system/src/Grav/Framework/Media/MediaObject.php
  62. 172 0
      system/src/Grav/Framework/Media/UploadedMediaObject.php
  63. 66 0
      system/src/Grav/Framework/Object/Identifiers/Identifier.php
  64. 217 0
      system/src/Grav/Framework/Relationships/Relationships.php
  65. 259 0
      system/src/Grav/Framework/Relationships/ToManyRelationship.php
  66. 207 0
      system/src/Grav/Framework/Relationships/ToOneRelationship.php
  67. 128 0
      system/src/Grav/Framework/Relationships/Traits/RelationshipTrait.php
  68. 1 3
      system/src/Grav/Framework/RequestHandler/Middlewares/Exceptions.php
  69. 123 0
      system/src/Grav/Framework/RequestHandler/Middlewares/MultipartRequestSupport.php
  70. 2 2
      system/src/Grav/Framework/RequestHandler/Traits/RequestHandlerTrait.php

+ 78 - 0
CHANGELOG.md

@@ -1,3 +1,81 @@
+# v1.7.37.1
+## 10/05/2022
+
+1. [](#bugfix)
+    * Fixed a bad return type [#3630](https://github.com/getgrav/grav/issues/3630)
+
+# v1.7.37
+## 10/05/2022
+
+1. [](#new)
+    * Added new `onPageHeaders()` event to allow for header modification as needed
+    * Added a `system.pages.dirs` configuration option to allow for configurable paths, and multiple page paths
+    * Added new `Pages::getSimplePagesHash` which is useful for caching pages specific data
+    * Updated to latest vendor libraries
+1. [](#bugfix)
+    * An attempt to workaround windows reading locked file issue [getgrav/grav-plugin-admin#2299](https://github.com/getgrav/grav-plugin-admin/issues/2299)
+    * Force user index file to be updated to fix email addresses [getgrav/grav-plugin-login#229](https://github.com/getgrav/grav-plugin-login/issues/229)
+
+# v1.7.36
+## 09/08/2022
+
+1. [](#new)
+    * Added `authorize-*@:` support for Flex blueprints, e.g. `authorize-disabled@: not delete` disables the field if user does not have access to delete object
+    * Added support for `flex-ignore@` to hide all the nested fields in the blueprint
+1. [](#bugfix)
+    * Fixed login with a capitalised email address when using old users [getgrav/grav-plugin-login#229](https://github.com/getgrav/grav-plugin-login/issues/229)
+
+# v1.7.35
+## 08/04/2022
+
+1. [](#new)
+   * Added support for `multipart/form-data` content type in PUT and PATCH requests
+   * Added support for object relationships
+   * Added variables `$environment` (string), `$request` (PSR-7 ServerRequestInterface|null) and `$uri` (PSR-7 Uri|null) to be used in `setup.php`
+1. [](#improved)
+   * Minor vendor updates
+
+# v1.7.34
+## 06/14/2022
+
+1. [](#new)
+    * Added back Yiddish to Language Codes [#3336](https://github.com/getgrav/grav/pull/3336)
+    * Ignore upcoming `media.json` file in media
+1. [](#bugfix)
+    * Regression: Fixed saving page with a new language causing cache corruption [getgrav/grav-plugin-admin#2282](https://github.com/getgrav/grav-plugin-admin/issues/2282)
+    * Fixed a potential fatal error when using watermark in images
+    * Fixed `bin/grav install` command with arbitrary destination folder name
+    * Fixed Twig `|filter()` allowing code execution
+    * Fixed login and user search by email not being case-insensitive when using Flex Users
+
+# v1.7.33
+## 04/25/2022
+
+1. [](#improved)
+    * When saving yaml and markdown, create also a cached version of the file and recompile it in opcache
+2. [](#bugfix)
+    * Fixed missing changes in **yaml** & **markdown** files if saved multiple times during the same second because of a caching issue
+    * Fixed XSS check not detecting onX events without quotes
+    * Fixed default collection ordering in pages admin
+
+# v1.7.32
+## 03/28/2022
+
+1. [](#new)
+    * Added `|replace_last(search, replace)` filter
+    * Added `parseurl` Twig function to expose PHP's `parse_url` function
+2. [](#improved)
+    * Added multi-language support for page routes in `Utils::url()`
+    * Set default maximum length for text fields
+      - `password`: 256
+      - `email`: 320
+      - `text`, `url`, `hidden`, `commalist`: 2048
+      - `text` (multiline), `textarea`: 65536
+3. [](#bugfix)
+   * Fixed issue with `system.cache.gzip: true` resulted in "Fetch Failed" for PHP 8.0.17 and PHP 8.1.4 [PHP issue #8218](https://github.com/php/php-src/issues/8218)
+   * Fix for multi-lang issues with Security Report
+   * Fixed page search not working with selected language [#3316](https://github.com/getgrav/grav/issues/3316)
+
 # v1.7.31
 ## 03/14/2022
 

+ 2 - 1
composer.json

@@ -19,6 +19,7 @@
         "ext-zip": "*",
         "ext-dom": "*",
         "ext-libxml": "*",
+        "ext-gd": "*",
         "symfony/polyfill-mbstring": "~1.23",
         "symfony/polyfill-iconv": "^1.23",
         "symfony/polyfill-php74": "^1.23",
@@ -64,7 +65,7 @@
     },
     "require-dev": {
         "codeception/codeception": "^4.1",
-        "phpstan/phpstan": "^1.2",
+        "phpstan/phpstan": "^1.8",
         "phpstan/phpstan-deprecation-rules": "^1.0",
         "phpunit/php-code-coverage": "~9.2",
         "getgrav/markdowndocs": "^2.0",

File diff suppressed because it is too large
+ 209 - 172
composer.lock


+ 7 - 0
system/blueprints/config/system.yaml

@@ -1156,6 +1156,13 @@ form:
                 local6: local6
                 local7: local7
 
+            log.syslog.tag:
+              type: text
+              size: small
+              label: PLUGIN_ADMIN.SYSLOG_TAG
+              help: PLUGIN_ADMIN.SYSLOG_TAG_HELP
+              placeholder: "grav"
+
         debugger:
           type: tab
           title: PLUGIN_ADMIN.DEBUGGER

+ 11 - 0
system/blueprints/flex/user-accounts.yaml

@@ -125,6 +125,17 @@ config:
         - username
         - fullname
 
+  relationships:
+    media:
+      type: media
+      cardinality: to-many
+    avatar:
+      type: media
+      cardinality: to-one
+#    roles:
+#      type: user-groups
+#      cardinality: to-many
+
 blueprints:
   configure:
     fields:

+ 2 - 0
system/config/system.yaml

@@ -35,6 +35,7 @@ home:
 
 pages:
   type: regular                                  # EXPERIMENTAL: Page type: regular or flex
+  dirs: ['page://']                              # Advanced functionality, allows for multiple page paths
   theme: quark                                   # Default theme (defaults to "quark" theme)
   order:
     by: default                                  # Order pages by "default", "alpha" or "date"
@@ -144,6 +145,7 @@ log:
   handler: file                                 # Log handler. Currently supported: file | syslog
   syslog:
     facility: local6                            # Syslog facilities output
+    tag: grav                                   # Syslog tag. Default: "grav".
 
 debugger:
   enabled: false                                 # Enable Grav debugger and following settings

+ 1 - 1
system/defines.php

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

+ 4 - 3
system/src/Grav/Common/Config/Setup.php

@@ -182,13 +182,14 @@ class Setup extends Data
         // If no environment is set, make sure we get one (CLI or hostname).
         if (null === $environment) {
             if (defined('GRAV_CLI')) {
+                $request = null;
+                $uri = null;
                 $environment = 'cli';
             } else {
                 /** @var ServerRequestInterface $request */
                 $request = $container['request'];
-                $host = $request->getUri()->getHost();
-
-                $environment = Utils::substrToString($host, ':');
+                $uri = $request->getUri();
+                $environment = $uri->getHost();
             }
         }
 

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

@@ -515,7 +515,7 @@ class Blueprint extends BlueprintForm
             $success = $this->resolveActions($user, $actions);
         }
         if (!$success) {
-            $this->addPropertyRecursive($field, 'validate', ['ignore' => true]);
+            static::addPropertyRecursive($field, 'validate', ['ignore' => true]);
         }
     }
 
@@ -566,7 +566,7 @@ class Blueprint extends BlueprintForm
         }
 
         if ($matches) {
-            $this->addPropertyRecursive($field, 'validate', ['ignore' => true]);
+            static::addPropertyRecursive($field, 'validate', ['ignore' => true]);
             return;
         }
     }
@@ -577,7 +577,7 @@ class Blueprint extends BlueprintForm
      * @param mixed $value
      * @return void
      */
-    protected function addPropertyRecursive(array &$field, $property, $value)
+    public static function addPropertyRecursive(array &$field, $property, $value)
     {
         if (is_array($value) && isset($field[$property]) && is_array($field[$property])) {
             $field[$property] = array_merge_recursive($field[$property], $value);
@@ -587,7 +587,7 @@ class Blueprint extends BlueprintForm
 
         if (!empty($field['fields'])) {
             foreach ($field['fields'] as $key => &$child) {
-                $this->addPropertyRecursive($child, $property, $value);
+                static::addPropertyRecursive($child, $property, $value);
             }
         }
     }

+ 20 - 2
system/src/Grav/Common/Data/Validation.php

@@ -246,7 +246,9 @@ class Validation
             return false;
         }
 
-        $max = (int)($params['max'] ?? 0);
+        $multiline = isset($params['multiline']) && $params['multiline'];
+
+        $max = (int)($params['max'] ?? ($multiline ? 65536 : 2048));
         if ($max && $len > $max) {
             return false;
         }
@@ -256,7 +258,7 @@ class Validation
             return false;
         }
 
-        if ((!isset($params['multiline']) || !$params['multiline']) && preg_match('/\R/um', $value)) {
+        if (!$multiline && preg_match('/\R/um', $value)) {
             return false;
         }
 
@@ -317,6 +319,10 @@ class Validation
      */
     public static function typeCommaList($value, array $params, array $field)
     {
+        if (!isset($params['max'])) {
+            $params['max'] = 2048;
+        }
+
         return is_array($value) ? true : self::typeText($value, $params, $field);
     }
 
@@ -379,6 +385,10 @@ class Validation
      */
     public static function typePassword($value, array $params, array $field)
     {
+        if (!isset($params['max'])) {
+            $params['max'] = 256;
+        }
+
         return self::typeText($value, $params, $field);
     }
 
@@ -621,6 +631,10 @@ class Validation
      */
     public static function typeEmail($value, array $params, array $field)
     {
+        if (!isset($params['max'])) {
+            $params['max'] = 320;
+        }
+
         $values = !is_array($value) ? explode(',', preg_replace('/\s+/', '', $value)) : $value;
 
         foreach ($values as $val) {
@@ -642,6 +656,10 @@ class Validation
      */
     public static function typeUrl($value, array $params, array $field)
     {
+        if (!isset($params['max'])) {
+            $params['max'] = 2048;
+        }
+
         return self::typeText($value, $params, $field) && filter_var($value, FILTER_VALIDATE_URL);
     }
 

+ 21 - 0
system/src/Grav/Common/Errors/SystemFacade.php

@@ -43,4 +43,25 @@ class SystemFacade extends \Whoops\Util\SystemFacade
             $handler();
         }
     }
+
+
+    /**
+     * @param int $httpCode
+     *
+     * @return int
+     */
+    public function setHttpResponseCode($httpCode)
+    {
+        if (!headers_sent()) {
+            // Ensure that no 'location' header is present as otherwise this
+            // will override the HTTP code being set here, and mask the
+            // expected error page.
+            header_remove('location');
+
+            // Work around PHP bug #8218 (8.0.17 & 8.1.4).
+            header_remove('Content-Encoding');
+        }
+
+        return http_response_code($httpCode);
+    }
 }

+ 81 - 9
system/src/Grav/Common/File/CompiledFile.php

@@ -10,6 +10,8 @@
 namespace Grav\Common\File;
 
 use Exception;
+use Grav\Common\Debugger;
+use Grav\Common\Grav;
 use Grav\Common\Utils;
 use RocketTheme\Toolbox\File\PhpFile;
 use RuntimeException;
@@ -32,9 +34,10 @@ trait CompiledFile
     public function content($var = null)
     {
         try {
+            $filename = $this->filename;
             // If nothing has been loaded, attempt to get pre-compiled version of the file first.
             if ($var === null && $this->raw === null && $this->content === null) {
-                $key = md5($this->filename);
+                $key = md5($filename);
                 $file = PhpFile::instance(CACHE_DIR . "compiled/files/{$key}{$this->extension}.php");
 
                 $modified = $this->modified();
@@ -48,39 +51,49 @@ trait CompiledFile
 
                 $class = get_class($this);
 
+                $size = filesize($filename);
                 $cache = $file->exists() ? $file->content() : null;
 
                 // Load real file if cache isn't up to date (or is invalid).
                 if (!isset($cache['@class'])
                     || $cache['@class'] !== $class
                     || $cache['modified'] !== $modified
-                    || $cache['filename'] !== $this->filename
+                    || ($cache['size'] ?? null) !== $size
+                    || $cache['filename'] !== $filename
                 ) {
                     // Attempt to lock the file for writing.
                     try {
-                        $file->lock(false);
+                        $locked = $file->lock(false);
                     } catch (Exception $e) {
-                        // Another process has locked the file; we will check this in a bit.
+                        $locked = false;
+
+                        /** @var Debugger $debugger */
+                        $debugger = Grav::instance()['debugger'];
+                        $debugger->addMessage(sprintf('%s(): Cannot obtain a lock for compiling cache file for %s: %s', __METHOD__, $this->filename, $e->getMessage()), 'warning');
                     }
 
                     // Decode RAW file into compiled array.
                     $data = (array)$this->decode($this->raw());
                     $cache = [
                         '@class' => $class,
-                        'filename' => $this->filename,
+                        'filename' => $filename,
                         'modified' => $modified,
+                        'size' => $size,
                         'data' => $data
                     ];
 
                     // If compiled file wasn't already locked by another process, save it.
-                    if ($file->locked() !== false) {
+                    if ($locked) {
                         $file->save($cache);
                         $file->unlock();
 
                         // Compile cached file into bytecode cache
-                        if (function_exists('opcache_invalidate')) {
+                        if (function_exists('opcache_invalidate') && filter_var(ini_get('opcache.enable'), \FILTER_VALIDATE_BOOLEAN)) {
+                            $lockName = $file->filename();
+
                             // Silence error if function exists, but is restricted.
-                            @opcache_invalidate($file->filename(), true);
+                            @opcache_invalidate($lockName, true);
+                            @opcache_compile_file($lockName);
                         }
                     }
                 }
@@ -89,12 +102,71 @@ trait CompiledFile
                 $this->content = $cache['data'];
             }
         } catch (Exception $e) {
-            throw new RuntimeException(sprintf('Failed to read %s: %s', Utils::basename($this->filename), $e->getMessage()), 500, $e);
+            throw new RuntimeException(sprintf('Failed to read %s: %s', Utils::basename($filename), $e->getMessage()), 500, $e);
         }
 
         return parent::content($var);
     }
 
+    /**
+     * Save file.
+     *
+     * @param  mixed  $data  Optional data to be saved, usually array.
+     * @return void
+     * @throws RuntimeException
+     */
+    public function save($data = null)
+    {
+        // Make sure that the cache file is always up to date!
+        $key = md5($this->filename);
+        $file = PhpFile::instance(CACHE_DIR . "compiled/files/{$key}{$this->extension}.php");
+        try {
+            $locked = $file->lock();
+        } catch (Exception $e) {
+            $locked = false;
+
+            /** @var Debugger $debugger */
+            $debugger = Grav::instance()['debugger'];
+            $debugger->addMessage(sprintf('%s(): Cannot obtain a lock for compiling cache file for %s: %s', __METHOD__, $this->filename, $e->getMessage()), 'warning');
+        }
+
+        parent::save($data);
+
+        if ($locked) {
+            $modified = $this->modified();
+            $filename = $this->filename;
+            $class = get_class($this);
+            $size = filesize($filename);
+
+            // windows doesn't play nicely with this as it can't read when locked
+            if (!Utils::isWindows()) {
+                // Reload data from the filesystem. This ensures that we always cache the correct data (see issue #2282).
+                $this->raw = $this->content = null;
+                $data = (array)$this->decode($this->raw());
+            }
+
+            // Decode data into compiled array.
+            $cache = [
+                '@class' => $class,
+                'filename' => $filename,
+                'modified' => $modified,
+                'size' => $size,
+                'data' => $data
+            ];
+
+            $file->save($cache);
+            $file->unlock();
+
+            // Compile cached file into bytecode cache
+            if (function_exists('opcache_invalidate') && filter_var(ini_get('opcache.enable'), \FILTER_VALIDATE_BOOLEAN)) {
+                $lockName = $file->filename();
+                // Silence error if function exists, but is restricted.
+                @opcache_invalidate($lockName, true);
+                @opcache_compile_file($lockName);
+            }
+        }
+    }
+
     /**
      * Serialize file.
      *

+ 58 - 52
system/src/Grav/Common/Filesystem/Folder.php

@@ -31,32 +31,34 @@ abstract class Folder
     /**
      * Recursively find the last modified time under given path.
      *
-     * @param  string $path
+     * @param array $paths
      * @return int
      */
-    public static function lastModifiedFolder($path)
+    public static function lastModifiedFolder(array $paths): int
     {
-        if (!file_exists($path)) {
-            return 0;
-        }
-
         $last_modified = 0;
 
         /** @var UniformResourceLocator $locator */
         $locator = Grav::instance()['locator'];
         $flags = RecursiveDirectoryIterator::SKIP_DOTS;
-        if ($locator->isStream($path)) {
-            $directory = $locator->getRecursiveIterator($path, $flags);
-        } else {
-            $directory = new RecursiveDirectoryIterator($path, $flags);
-        }
-        $filter  = new RecursiveFolderFilterIterator($directory);
-        $iterator = new RecursiveIteratorIterator($filter, RecursiveIteratorIterator::SELF_FIRST);
 
-        foreach ($iterator as $dir) {
-            $dir_modified = $dir->getMTime();
-            if ($dir_modified > $last_modified) {
-                $last_modified = $dir_modified;
+        foreach ($paths as $path) {
+            if (!file_exists($path)) {
+                return 0;
+            }
+            if ($locator->isStream($path)) {
+                $directory = $locator->getRecursiveIterator($path, $flags);
+            } else {
+                $directory = new RecursiveDirectoryIterator($path, $flags);
+            }
+            $filter  = new RecursiveFolderFilterIterator($directory);
+            $iterator = new RecursiveIteratorIterator($filter, RecursiveIteratorIterator::SELF_FIRST);
+
+            foreach ($iterator as $dir) {
+                $dir_modified = $dir->getMTime();
+                if ($dir_modified > $last_modified) {
+                    $last_modified = $dir_modified;
+                }
             }
         }
 
@@ -66,38 +68,40 @@ abstract class Folder
     /**
      * Recursively find the last modified time under given path by file.
      *
-     * @param string  $path
+     * @param array  $paths
      * @param string  $extensions   which files to search for specifically
      * @return int
      */
-    public static function lastModifiedFile($path, $extensions = 'md|yaml')
+    public static function lastModifiedFile(array $paths, $extensions = 'md|yaml'): int
     {
-        if (!file_exists($path)) {
-            return 0;
-        }
-
         $last_modified = 0;
 
         /** @var UniformResourceLocator $locator */
         $locator = Grav::instance()['locator'];
         $flags = RecursiveDirectoryIterator::SKIP_DOTS;
-        if ($locator->isStream($path)) {
-            $directory = $locator->getRecursiveIterator($path, $flags);
-        } else {
-            $directory = new RecursiveDirectoryIterator($path, $flags);
-        }
-        $recursive = new RecursiveIteratorIterator($directory, RecursiveIteratorIterator::SELF_FIRST);
-        $iterator = new RegexIterator($recursive, '/^.+\.'.$extensions.'$/i');
 
-        /** @var RecursiveDirectoryIterator $file */
-        foreach ($iterator as $filepath => $file) {
-            try {
-                $file_modified = $file->getMTime();
-                if ($file_modified > $last_modified) {
-                    $last_modified = $file_modified;
+        foreach($paths as $path) {
+            if (!file_exists($path)) {
+                return 0;
+            }
+            if ($locator->isStream($path)) {
+                $directory = $locator->getRecursiveIterator($path, $flags);
+            } else {
+                $directory = new RecursiveDirectoryIterator($path, $flags);
+            }
+            $recursive = new RecursiveIteratorIterator($directory, RecursiveIteratorIterator::SELF_FIRST);
+            $iterator = new RegexIterator($recursive, '/^.+\.'.$extensions.'$/i');
+
+            /** @var RecursiveDirectoryIterator $file */
+            foreach ($iterator as $file) {
+                try {
+                    $file_modified = $file->getMTime();
+                    if ($file_modified > $last_modified) {
+                        $last_modified = $file_modified;
+                    }
+                } catch (Exception $e) {
+                    Grav::instance()['log']->error('Could not process file: ' . $e->getMessage());
                 }
-            } catch (Exception $e) {
-                Grav::instance()['log']->error('Could not process file: ' . $e->getMessage());
             }
         }
 
@@ -107,28 +111,30 @@ abstract class Folder
     /**
      * Recursively md5 hash all files in a path
      *
-     * @param string $path
+     * @param array $paths
      * @return string
      */
-    public static function hashAllFiles($path)
+    public static function hashAllFiles(array $paths): string
     {
         $files = [];
 
-        if (file_exists($path)) {
-            $flags = RecursiveDirectoryIterator::SKIP_DOTS;
+        foreach ($paths as $path) {
+            if (file_exists($path)) {
+                $flags = RecursiveDirectoryIterator::SKIP_DOTS;
 
-            /** @var UniformResourceLocator $locator */
-            $locator = Grav::instance()['locator'];
-            if ($locator->isStream($path)) {
-                $directory = $locator->getRecursiveIterator($path, $flags);
-            } else {
-                $directory = new RecursiveDirectoryIterator($path, $flags);
-            }
+                /** @var UniformResourceLocator $locator */
+                $locator = Grav::instance()['locator'];
+                if ($locator->isStream($path)) {
+                    $directory = $locator->getRecursiveIterator($path, $flags);
+                } else {
+                    $directory = new RecursiveDirectoryIterator($path, $flags);
+                }
 
-            $iterator = new RecursiveIteratorIterator($directory, RecursiveIteratorIterator::SELF_FIRST);
+                $iterator = new RecursiveIteratorIterator($directory, RecursiveIteratorIterator::SELF_FIRST);
 
-            foreach ($iterator as $file) {
-                $files[] = $file->getPathname() . '?'. $file->getMTime();
+                foreach ($iterator as $file) {
+                    $files[] = $file->getPathname() . '?'. $file->getMTime();
+                }
             }
         }
 

+ 3 - 6
system/src/Grav/Common/Flex/Types/Pages/PageIndex.php

@@ -454,7 +454,7 @@ class PageIndex extends FlexPageIndex implements PageCollectionInterface
                 continue;
             }
 
-            // Get the main key without template and langauge.
+            // Get the main key without template and language.
             [$main_key,] = explode('|', $entry['storage_key'] . '|', 2);
 
             // Update storage key and language.
@@ -527,10 +527,7 @@ class PageIndex extends FlexPageIndex implements PageCollectionInterface
         $language = $options['lang'];
 
         $status = 'error';
-        $msg = null;
         $response = [];
-        $children = null;
-        $sub_route = null;
         $extra = null;
 
         // Handle leaf_route
@@ -610,12 +607,12 @@ class PageIndex extends FlexPageIndex implements PageCollectionInterface
             $children = $page->children();
             /** @var PageIndex $children */
             $children = $children->getIndex();
-            $selectedChildren = $children->filterBy($filters, true);
+            $selectedChildren = $children->filterBy($filters + ['language' => $language], true);
 
             /** @var Header $header */
             $header = $page->header();
 
-            if (!$field && $header->get('admin.children_display_order') === 'collection' && ($orderby = $header->get('content.order.by'))) {
+            if (!$field && $header->get('admin.children_display_order', 'collection') === 'collection' && ($orderby = $header->get('content.order.by'))) {
                 // Use custom sorting by page header.
                 $sortby = $orderby;
                 $order = $header->get('content.order.dir', $order);

+ 19 - 10
system/src/Grav/Common/Flex/Types/Pages/PageObject.php

@@ -242,6 +242,7 @@ class PageObject extends FlexPageObject
     {
         /** @var PageCollection $siblings */
         $siblings = $variables['siblings'];
+        /** @var PageObject $sibling */
         foreach ($siblings as $sibling) {
             $sibling->save(false);
         }
@@ -585,38 +586,46 @@ class PageObject extends FlexPageObject
      */
     public function filterBy(array $filters, bool $recursive = false): bool
     {
+        $language = $filters['language'] ?? null;
+        if (null !== $language) {
+            /** @var PageObject $test */
+            $test = $this->getTranslation($language) ?? $this;
+        } else {
+            $test = $this;
+        }
+
         foreach ($filters as $key => $value) {
             switch ($key) {
                 case 'search':
-                    $matches = $this->search((string)$value) > 0.0;
+                    $matches = $test->search((string)$value) > 0.0;
                     break;
                 case 'page_type':
                     $types = $value ? explode(',', $value) : [];
-                    $matches = in_array($this->template(), $types, true);
+                    $matches = in_array($test->template(), $types, true);
                     break;
                 case 'extension':
-                    $matches = Utils::contains((string)$value, $this->extension());
+                    $matches = Utils::contains((string)$value, $test->extension());
                     break;
                 case 'routable':
-                    $matches = $this->isRoutable() === (bool)$value;
+                    $matches = $test->isRoutable() === (bool)$value;
                     break;
                 case 'published':
-                    $matches = $this->isPublished() === (bool)$value;
+                    $matches = $test->isPublished() === (bool)$value;
                     break;
                 case 'visible':
-                    $matches = $this->isVisible() === (bool)$value;
+                    $matches = $test->isVisible() === (bool)$value;
                     break;
                 case 'module':
-                    $matches = $this->isModule() === (bool)$value;
+                    $matches = $test->isModule() === (bool)$value;
                     break;
                 case 'page':
-                    $matches = $this->isPage() === (bool)$value;
+                    $matches = $test->isPage() === (bool)$value;
                     break;
                 case 'folder':
-                    $matches = $this->isPage() === !$value;
+                    $matches = $test->isPage() === !$value;
                     break;
                 case 'translated':
-                    $matches = $this->hasTranslation() === (bool)$value;
+                    $matches = $test->hasTranslation() === (bool)$value;
                     break;
                 default:
                     $matches = true;

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

@@ -30,7 +30,7 @@ use function is_string;
  */
 class UserIndex extends FlexIndex implements UserCollectionInterface
 {
-    public const VERSION = parent::VERSION . '.1';
+    public const VERSION = parent::VERSION . '.2';
 
     /**
      * @param FlexStorageInterface $storage
@@ -50,7 +50,7 @@ class UserIndex extends FlexIndex implements UserCollectionInterface
         //    return $index['index'];
         //}
 
-        // Load up to date index.
+        // Load up-to-date index.
         $entries = parent::loadEntriesFromStorage($storage);
 
         return static::updateIndexFile($storage, $index['index'], $entries, ['force_update' => $force]);
@@ -142,9 +142,11 @@ class UserIndex extends FlexIndex implements UserCollectionInterface
                 } elseif ($field === 'flex_key') {
                     $user = $this->withKeyField('flex_key')->get($query);
                 } elseif ($field === 'email') {
-                    $user = $this->withKeyField('email')->get($query);
+                    $email = mb_strtolower($query);
+                    $user = $this->withKeyField('email')->get($email);
                 } elseif ($field === 'username') {
-                    $user = $this->get(static::filterUsername($query, $this->getFlexDirectory()->getStorage()));
+                    $username = static::filterUsername($query, $this->getFlexDirectory()->getStorage());
+                    $user = $this->get($username);
                 } else {
                     $user = $this->__call('find', [$query, $field]);
                 }

+ 80 - 0
system/src/Grav/Common/Flex/Types/Users/UserObject.php

@@ -31,6 +31,7 @@ use Grav\Common\Flex\Types\UserGroups\UserGroupIndex;
 use Grav\Common\User\Interfaces\UserInterface;
 use Grav\Common\User\Traits\UserTrait;
 use Grav\Common\Utils;
+use Grav\Framework\Contracts\Relationships\ToOneRelationshipInterface;
 use Grav\Framework\File\Formatter\JsonFormatter;
 use Grav\Framework\File\Formatter\YamlFormatter;
 use Grav\Framework\Filesystem\Filesystem;
@@ -38,7 +39,10 @@ use Grav\Framework\Flex\Flex;
 use Grav\Framework\Flex\FlexDirectory;
 use Grav\Framework\Flex\Storage\FileStorage;
 use Grav\Framework\Flex\Traits\FlexMediaTrait;
+use Grav\Framework\Flex\Traits\FlexRelationshipsTrait;
 use Grav\Framework\Form\FormFlashFile;
+use Grav\Framework\Media\MediaIdentifier;
+use Grav\Framework\Media\UploadedMediaObject;
 use Psr\Http\Message\UploadedFileInterface;
 use RocketTheme\Toolbox\Event\Event;
 use RocketTheme\Toolbox\File\FileInterface;
@@ -77,6 +81,7 @@ class UserObject extends FlexObject implements UserInterface, Countable
     }
     use UserTrait;
     use UserObjectLegacyTrait;
+    use FlexRelationshipsTrait;
 
     /** @var Closure|null */
     static public $authorizeCallable;
@@ -672,6 +677,81 @@ class UserObject extends FlexObject implements UserInterface, Countable
         return $folder;
     }
 
+    /**
+     * @param string $name
+     * @return array|object|null
+     * @internal
+     */
+    public function initRelationship(string $name)
+    {
+        switch ($name) {
+            case 'media':
+                $list = [];
+                foreach ($this->getMedia()->all() as $filename => $object) {
+                    $list[] = $this->buildMediaObject(null, $filename, $object);
+                }
+
+                return $list;
+            case 'avatar':
+                return $this->buildMediaObject('avatar', basename($this->getAvatarUrl()), $this->getAvatarImage());
+        }
+
+        throw new \InvalidArgumentException(sprintf('%s: Relationship %s does not exist', $this->getFlexType(), $name));
+    }
+
+    /**
+     * @return bool Return true if relationships were updated.
+     */
+    protected function updateRelationships(): bool
+    {
+        $modified = $this->getRelationships()->getModified();
+        if ($modified) {
+            foreach ($modified as $relationship) {
+                $name = $relationship->getName();
+                switch ($name) {
+                    case 'avatar':
+                        \assert($relationship instanceof ToOneRelationshipInterface);
+                        $this->updateAvatarRelationship($relationship);
+                        break;
+                    default:
+                        throw new \InvalidArgumentException(sprintf('%s: Relationship %s cannot be modified', $this->getFlexType(), $name), 400);
+                }
+            }
+
+            $this->resetRelationships();
+
+            return true;
+        }
+
+        return false;
+    }
+
+    /**
+     * @param ToOneRelationshipInterface $relationship
+     */
+    protected function updateAvatarRelationship(ToOneRelationshipInterface $relationship): void
+    {
+        $files = [];
+        $avatar = $this->getAvatarImage();
+        if ($avatar) {
+            $files['avatar'][$avatar->filename] = null;
+        }
+
+        $identifier = $relationship->getIdentifier();
+        if ($identifier) {
+            \assert($identifier instanceof MediaIdentifier);
+            $object = $identifier->getObject();
+            if ($object instanceof UploadedMediaObject) {
+                $uploadedFile = $object->getUploadedFile();
+                if ($uploadedFile) {
+                    $files['avatar'][$uploadedFile->getClientFilename()] = $uploadedFile;
+                }
+            }
+        }
+
+        $this->update([], $files);
+    }
+
     /**
      * @param string $name
      * @return Blueprint

+ 1 - 0
system/src/Grav/Common/GPM/GPM.php

@@ -12,6 +12,7 @@ namespace Grav\Common\GPM;
 use Exception;
 use Grav\Common\Grav;
 use Grav\Common\Filesystem\Folder;
+use Grav\Common\HTTP\Response;
 use Grav\Common\Inflector;
 use Grav\Common\Iterator;
 use Grav\Common\Utils;

+ 1 - 1
system/src/Grav/Common/GPM/Remote/AbstractPackageCollection.php

@@ -10,8 +10,8 @@
 namespace Grav\Common\GPM\Remote;
 
 use Grav\Common\Grav;
+use Grav\Common\HTTP\Response;
 use Grav\Common\GPM\Common\AbstractPackageCollection as BaseCollection;
-use Grav\Common\GPM\Response;
 use \Doctrine\Common\Cache\FilesystemCache;
 use RuntimeException;
 

+ 24 - 5
system/src/Grav/Common/Grav.php

@@ -48,6 +48,7 @@ use Grav\Common\Service\TaskServiceProvider;
 use Grav\Common\Twig\Twig;
 use Grav\Framework\DI\Container;
 use Grav\Framework\Psr7\Response;
+use Grav\Framework\RequestHandler\Middlewares\MultipartRequestSupport;
 use Grav\Framework\RequestHandler\RequestHandler;
 use Grav\Framework\Route\Route;
 use Grav\Framework\Session\Messages;
@@ -117,6 +118,7 @@ class Grav extends Container
      * @var array All middleware processors that are processed in $this->process()
      */
     protected $middleware = [
+        'multipartRequestSupport',
         'initializeProcessor',
         'pluginsProcessor',
         'themesProcessor',
@@ -259,6 +261,9 @@ class Grav extends Container
 
         $container = new Container(
             [
+                'multipartRequestSupport' => function () {
+                    return new MultipartRequestSupport();
+                },
                 'initializeProcessor' => function () {
                     return new InitializeProcessor($this);
                 },
@@ -341,6 +346,23 @@ class Grav extends Container
         }
     }
 
+    /**
+     * Clean any output buffers. Useful when exiting from the application.
+     *
+     * Please use $grav->close() and $grav->redirect() instead of calling this one!
+     *
+     * @return void
+     */
+    public function cleanOutputBuffers(): void
+    {
+        // Make sure nothing extra gets written to the response.
+        while (ob_get_level()) {
+            ob_end_clean();
+        }
+        // Work around PHP bug #8218 (8.0.17 & 8.1.4).
+        header_remove('Content-Encoding');
+    }
+
     /**
      * Terminates Grav request with a response.
      *
@@ -351,10 +373,7 @@ class Grav extends Container
      */
     public function close(ResponseInterface $response): void
     {
-        // Make sure nothing extra gets written to the response.
-        while (ob_get_level()) {
-            ob_end_clean();
-        }
+        $this->cleanOutputBuffers();
 
         // Close the session.
         if (isset($this['session'])) {
@@ -400,7 +419,7 @@ class Grav extends Container
     /**
      * @param ResponseInterface $response
      * @return never-return
-     * @deprecated 1.7 Do not use
+     * @deprecated 1.7 Use $grav->close() instead.
      */
     public function exit(ResponseInterface $response): void
     {

+ 2 - 0
system/src/Grav/Common/Language/LanguageCodes.php

@@ -153,6 +153,8 @@ class LanguageCodes
         'vi'         => [ 'name' => 'Vietnamese',                'nativeName' => 'Tiếng Việt' ],
         'wo'         => [ 'name' => 'Wolof',                     'nativeName' => 'Wolof' ],
         'xh'         => [ 'name' => 'Xhosa',                     'nativeName' => 'isiXhosa' ],
+        'yi'         => [ 'name' => 'Yiddish',                   'nativeName' => 'ייִדיש', 'orientation' => 'rtl'  ],
+        'ydd'        => [ 'name' => 'Yiddish',                   'nativeName' => 'ייִדיש', 'orientation' => 'rtl'  ],
         'zh'         => [ 'name' => 'Chinese (Simplified)',      'nativeName' => '中文 (简体)' ],
         'zh-CN'      => [ 'name' => 'Chinese (Simplified)',      'nativeName' => '中文 (简体)' ],
         'zh-TW'      => [ 'name' => 'Chinese (Traditional)',     'nativeName' => '正體中文 (繁體)' ],

+ 0 - 20
system/src/Grav/Common/Media/Interfaces/MediaObjectInterface.php

@@ -158,14 +158,6 @@ interface MediaObjectInterface extends \Grav\Framework\Media\Interfaces\MediaObj
      */
     public function thumbnail($type = 'auto');
 
-    /**
-     * Return URL to file.
-     *
-     * @param bool $reset
-     * @return string
-     */
-    public function url($reset = true);
-
     /**
      * Turn the current Medium into a Link
      *
@@ -221,18 +213,6 @@ interface MediaObjectInterface extends \Grav\Framework\Media\Interfaces\MediaObj
     #[\ReturnTypeWillChange]
     public function __call($method, $args);
 
-    /**
-     * Get value by using dot notation for nested arrays/objects.
-     *
-     * @example $value = $this->get('this.is.my.nested.variable');
-     *
-     * @param string $name Dot separated path to the requested value.
-     * @param mixed $default Default value (or null).
-     * @param string|null $separator Separator, defaults to '.'
-     * @return mixed Value.
-     */
-    public function get($name, $default = null, $separator = null);
-
     /**
      * Set value by using dot notation for nested arrays/objects.
      *

+ 1 - 1
system/src/Grav/Common/Page/Media.php

@@ -152,7 +152,7 @@ class Media extends AbstractMedia
         foreach ($iterator as $file => $info) {
             // Ignore folders and Markdown files.
             $filename = $info->getFilename();
-            if (!$info->isFile() || $info->getExtension() === 'md' || $filename === 'frontmatter.yaml' || strpos($filename, '.') === 0) {
+            if (!$info->isFile() || $info->getExtension() === 'md' || $filename === 'frontmatter.yaml' || $filename === 'media.json' || strpos($filename, '.') === 0) {
                 continue;
             }
 

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

@@ -361,8 +361,8 @@ class ImageMedium extends Medium implements ImageMediaInterface, ImageManipulate
 
         // Scaling operations
         $scale     = ($scale ?? $config->get('system.images.watermark.scale', 100)) / 100;
-        $wwidth    = $this->get('width')  * $scale;
-        $wheight   = $this->get('height') * $scale;
+        $wwidth    = (int)$this->get('width')  * $scale;
+        $wheight   = (int)$this->get('height') * $scale;
         $watermark->resize($wwidth, $wheight);
 
         // Position operations
@@ -377,11 +377,11 @@ class ImageMedium extends Medium implements ImageMediaInterface, ImageManipulate
                 break;
 
             case 'bottom':
-                $positionY = $this->get('height')-$wheight;
+                $positionY = (int)$this->get('height')-$wheight;
                 break;
 
             case 'center':
-                $positionY = ($this->get('height')/2) - ($wheight/2);
+                $positionY = ((int)$this->get('height')/2) - ($wheight/2);
                 break;
         }
 
@@ -392,11 +392,11 @@ class ImageMedium extends Medium implements ImageMediaInterface, ImageManipulate
                 break;
 
             case 'right':
-                $positionX = $this->get('width')-$wwidth;
+                $positionX = (int)$this->get('width')-$wwidth;
                 break;
 
             case 'center':
-                $positionX = ($this->get('width')/2) - ($wwidth/2);
+                $positionX = ((int)$this->get('width')/2) - ($wwidth/2);
                 break;
         }
 

+ 6 - 1
system/src/Grav/Common/Page/Page.php

@@ -622,7 +622,12 @@ class Page implements PageInterface
             $headers['Vary'] = 'Accept-Encoding';
         }
 
-        return $headers;
+
+        // Added new Headers event
+        $headers_obj = (object) $headers;
+        Grav::instance()->fireEvent('onPageHeaders', new Event(['headers' => $headers_obj]));
+
+        return (array)$headers_obj;
     }
 
     /**

+ 49 - 17
system/src/Grav/Common/Page/Pages.php

@@ -88,6 +88,8 @@ class Pages
     /** @var string */
     protected $check_method;
     /** @var string */
+    protected $simple_pages_hash;
+    /** @var string */
     protected $pages_cache_id;
     /** @var bool */
     protected $initialized = false;
@@ -100,6 +102,7 @@ class Pages
     /** @var string|null */
     protected static $home_route;
 
+
     /**
      * Constructor
      *
@@ -1712,10 +1715,7 @@ class Pages
         /** @var Language $language */
         $language = $this->grav['language'];
 
-        $pages_dir = $locator->findResource('page://');
-        if (!is_string($pages_dir)) {
-            throw new RuntimeException('Internal Error');
-        }
+        $pages_dirs = $this->getPagesPaths();
 
         // Set active language
         $this->active_lang = $language->getActive();
@@ -1731,16 +1731,17 @@ class Pages
                     $hash = 0;
                     break;
                 case 'folder':
-                    $hash = Folder::lastModifiedFolder($pages_dir);
+                    $hash = Folder::lastModifiedFolder($pages_dirs);
                     break;
                 case 'hash':
-                    $hash = Folder::hashAllFiles($pages_dir);
+                    $hash = Folder::hashAllFiles($pages_dirs);
                     break;
                 default:
-                    $hash = Folder::lastModifiedFile($pages_dir);
+                    $hash = Folder::lastModifiedFile($pages_dirs);
             }
 
-            $this->pages_cache_id = md5($pages_dir . $hash . $language->getActive() . $config->checksum());
+            $this->simple_pages_hash = json_encode($pages_dirs) . $hash . $config->checksum();
+            $this->pages_cache_id = md5($this->simple_pages_hash . $language->getActive());
 
             /** @var Cache $cache */
             $cache = $this->grav['cache'];
@@ -1760,18 +1761,39 @@ class Pages
             $this->grav['debugger']->addMessage('Page cache disabled, rebuilding pages..');
         }
 
-        $this->resetPages($pages_dir);
+        $this->resetPages($pages_dirs);
+    }
+
+    protected function getPagesPaths(): array
+    {
+        $grav = Grav::instance();
+        $locator = $grav['locator'];
+        $paths = [];
+
+        $dirs = (array) $grav['config']->get('system.pages.dirs', ['page://']);
+        foreach ($dirs as $dir) {
+            $path = $locator->findResource($dir);
+            if (file_exists($path)) {
+                $paths[] = $path;
+            }
+        }
+
+        return $paths;
     }
 
     /**
      * Accessible method to manually reset the pages cache
      *
-     * @param string $pages_dir
+     * @param array $pages_dirs
      */
-    public function resetPages($pages_dir): void
+    public function resetPages(array $pages_dirs): void
     {
         $this->sort = [];
-        $this->recurse($pages_dir);
+
+        foreach ($pages_dirs as $dir) {
+            $this->recurse($dir);
+        }
+
         $this->buildRoutes();
 
         // cache if needed
@@ -1795,7 +1817,7 @@ class Pages
      * @throws RuntimeException
      * @internal
      */
-    protected function recurse($directory, PageInterface $parent = null)
+    protected function recurse(string $directory, PageInterface $parent = null)
     {
         $directory = rtrim($directory, DS);
         $page = new Page;
@@ -2177,7 +2199,7 @@ class Pages
      * @param array $list
      * @return array
      */
-    protected function arrayShuffle($list)
+    protected function arrayShuffle(array $list): array
     {
         $keys = array_keys($list);
         shuffle($keys);
@@ -2193,7 +2215,7 @@ class Pages
     /**
      * @return string
      */
-    protected function getVersion()
+    protected function getVersion(): string
     {
         return $this->directory ? 'flex' : 'regular';
     }
@@ -2204,10 +2226,20 @@ class Pages
      * this is particularly useful to know if pages have changed and you want
      * to sync another cache with pages cache - works best in `onPagesInitialized()`
      *
-     * @return string
+     * @return null|string
      */
-    public function getPagesCacheId()
+    public function getPagesCacheId(): ?string
     {
         return $this->pages_cache_id;
     }
+
+    /**
+     * Get the simple pages hash that is not md5 encoded, and isn't specific to language
+     *
+     * @return null|string
+     */
+    public function getSimplePagesHash(): ?string
+    {
+        return $this->simple_pages_hash;
+    }
 }

+ 2 - 1
system/src/Grav/Common/Processors/InitializeProcessor.php

@@ -251,7 +251,8 @@ class InitializeProcessor extends ProcessorBase
             $log->popHandler();
 
             $facility = $config->get('system.log.syslog.facility', 'local6');
-            $logHandler = new SyslogHandler('grav', $facility);
+            $tag = $config->get('system.log.syslog.tag', 'grav');
+            $logHandler = new SyslogHandler($tag, $facility);
             $formatter = new LineFormatter("%channel%.%level_name%: %message% %extra%");
             $logHandler->setFormatter($formatter);
 

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

@@ -97,7 +97,7 @@ class Security
      */
     public static function detectXssFromPages(Pages $pages, $route = true, callable $status = null)
     {
-        $routes = $pages->routes();
+        $routes = $pages->getList(null, 0, true);
 
         // Remove duplicate for homepage
         unset($routes['/']);
@@ -110,26 +110,23 @@ class Security
             'steps' => count($routes),
         ]);
 
-        foreach ($routes as $path) {
+        foreach (array_keys($routes) as $route) {
             $status && $status([
                 'type' => 'progress',
             ]);
 
             try {
-                $page = $pages->get($path);
+                $page = $pages->find($route);
+                if ($page->exists()) {
+                    // call the content to load/cache it
+                    $header = (array) $page->header();
+                    $content = $page->value('content');
 
-                // call the content to load/cache it
-                $header = (array) $page->header();
-                $content = $page->value('content');
+                    $data = ['header' => $header, 'content' => $content];
+                    $results = static::detectXssFromArray($data);
 
-                $data = ['header' => $header, 'content' => $content];
-                $results = static::detectXssFromArray($data);
-
-                if (!empty($results)) {
-                    if ($route) {
-                        $list[$page->route()] = $results;
-                    } else {
-                        $list[$page->filePathClean()] = $results;
+                    if (!empty($results)) {
+                        $list[$page->rawRoute()] = $results;
                     }
                 }
             } catch (Exception $e) {
@@ -222,7 +219,8 @@ class Security
         $string = html_entity_decode($string, ENT_NOQUOTES | ENT_HTML5, 'UTF-8');
 
         // Strip whitespace characters
-        $string = preg_replace('!\s!u', '', $string);
+        $string = preg_replace('!\s!u', ' ', $string);
+        $stripped = preg_replace('!\s!u', '', $string);
 
         // Set the patterns we'll test against
         $patterns = [
@@ -245,7 +243,7 @@ class Security
         // Iterate over rules and return label if fail
         foreach ($patterns as $name => $regex) {
             if (!empty($enabled_rules[$name])) {
-                if (preg_match($regex, $string) || preg_match($regex, $orig)) {
+                if (preg_match($regex, $string) || preg_match($regex, $stripped) || preg_match($regex, $orig)) {
                     return $name;
                 }
             }

+ 6 - 2
system/src/Grav/Common/Service/FlexServiceProvider.php

@@ -97,7 +97,9 @@ class FlexServiceProvider implements ServiceProviderInterface
                 'options' => [
                     'file' => 'user',
                     'pattern' => '{FOLDER}/{KEY:2}/{KEY}/{FILE}{EXT}',
-                    'key' => 'storage_key'
+                    'key' => 'storage_key',
+                    'indexed' => true,
+                    'case_sensitive' => false
                 ],
             ];
         }
@@ -107,7 +109,9 @@ class FlexServiceProvider implements ServiceProviderInterface
                 'class' => UserFileStorage::class,
                 'options' => [
                     'pattern' => '{FOLDER}/{KEY}{EXT}',
-                    'key' => 'username'
+                    'key' => 'username',
+                    'indexed' => true,
+                    'case_sensitive' => false
                 ],
             ];
         }

+ 38 - 0
system/src/Grav/Common/Twig/Extension/GravExtension.php

@@ -9,6 +9,7 @@
 
 namespace Grav\Common\Twig\Extension;
 
+use CallbackFilterIterator;
 use Cron\CronExpression;
 use Grav\Common\Config\Config;
 use Grav\Common\Data\Data;
@@ -41,6 +42,7 @@ use JsonSerializable;
 use RocketTheme\Toolbox\ResourceLocator\UniformResourceLocator;
 use Traversable;
 use Twig\Environment;
+use Twig\Error\RuntimeError;
 use Twig\Extension\AbstractExtension;
 use Twig\Extension\GlobalsInterface;
 use Twig\Loader\FilesystemLoader;
@@ -145,6 +147,7 @@ class GravExtension extends AbstractExtension implements GlobalsInterface
             new TwigFilter('yaml_encode', [$this, 'yamlEncodeFilter']),
             new TwigFilter('yaml_decode', [$this, 'yamlDecodeFilter']),
             new TwigFilter('nicecron', [$this, 'niceCronFilter']),
+            new TwigFilter('replace_last', [$this, 'replaceLastFilter']),
 
             // Translations
             new TwigFilter('t', [$this, 'translate'], ['needs_environment' => true]),
@@ -166,6 +169,9 @@ class GravExtension extends AbstractExtension implements GlobalsInterface
             // PHP methods
             new TwigFilter('count', 'count'),
             new TwigFilter('array_diff', 'array_diff'),
+
+            // Security fix
+            new TwigFilter('filter', [$this, 'filterFilter'], ['needs_environment' => true]),
         ];
     }
 
@@ -194,6 +200,7 @@ class GravExtension extends AbstractExtension implements GlobalsInterface
             new TwigFunction('gist', [$this, 'gistFunc']),
             new TwigFunction('nonce_field', [$this, 'nonceFieldFunc']),
             new TwigFunction('pathinfo', 'pathinfo'),
+            new TwigFunction('parseurl', 'parse_url'),
             new TwigFunction('random_string', [$this, 'randomStringFunc']),
             new TwigFunction('repeat', [$this, 'repeatFunc']),
             new TwigFunction('regex_replace', [$this, 'regexReplace']),
@@ -547,6 +554,21 @@ class GravExtension extends AbstractExtension implements GlobalsInterface
         return $cron->getText('en');
     }
 
+    /**
+     * @param string|mixed $str
+     * @param string $search
+     * @param string $replace
+     * @return string|mixed
+     */
+    public function replaceLastFilter($str, $search, $replace)
+    {
+        if (is_string($str) && ($pos = mb_strrpos($str, $search)) !== false) {
+            $str = mb_substr($str, 0, $pos) . $replace . mb_substr($str, $pos + mb_strlen($search));
+        }
+
+        return $str;
+    }
+
     /**
      * Get Cron object for a crontab 'at' format
      *
@@ -1659,4 +1681,20 @@ class GravExtension extends AbstractExtension implements GlobalsInterface
                 return is_string($var);
         }
     }
+
+    /**
+     * @param Environment $env
+     * @param array $array
+     * @param callable|string $arrow
+     * @return array|CallbackFilterIterator
+     * @throws RuntimeError
+     */
+    function filterFilter(Environment $env, $array, $arrow)
+    {
+        if (is_string($arrow) && Utils::isDangerousFunction($arrow)) {
+            throw new RuntimeError('Twig |filter("' . $arrow . '") is not allowed.');
+        }
+
+        return twig_array_filter($env, $array, $arrow);
+    }
 }

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

@@ -112,11 +112,12 @@ class UserCollection implements UserCollectionInterface
 
         // If not found, try the fields
         if (!$user->exists()) {
+            $query = mb_strtolower($query);
             foreach ($files as $file) {
                 if (Utils::endsWith($file, YAML_EXT)) {
                     $find_user = $this->load(trim(Utils::pathinfo($file, PATHINFO_FILENAME)));
                     foreach ($fields as $field) {
-                        if (isset($find_user[$field]) && $find_user[$field] === $query) {
+                        if (isset($find_user[$field]) && mb_strtolower($find_user[$field]) === $query) {
                             return $find_user;
                         }
                     }

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

@@ -83,6 +83,7 @@ abstract class Utils
 
         $resource = false;
         if (static::contains((string)$input, '://')) {
+            // Url contains a scheme (https:// , user:// etc).
             /** @var UniformResourceLocator $locator */
             $locator = $grav['locator'];
 
@@ -134,6 +135,16 @@ abstract class Utils
                 $resource = $locator->findResource($input, false);
             }
         } else {
+            // Just a path.
+            /** @var Pages $pages */
+            $pages = $grav['pages'];
+
+            // Is this a page?
+            $page = $pages->find($input, true);
+            if ($page && $page->routable()) {
+                return $page->url($domain);
+            }
+
             $root = preg_quote($uri->rootUrl(), '#');
             $pattern = '#(' . $root . '$|' . $root . '/)#';
             if (!empty($root) && preg_match($pattern, $input, $matches)) {
@@ -657,18 +668,17 @@ abstract class Utils
      */
     public static function download($file, $force_download = true, $sec = 0, $bytes = 1024, array $options = [])
     {
+        $grav = Grav::instance();
+
         if (file_exists($file)) {
             // fire download event
-            Grav::instance()->fireEvent('onBeforeDownload', new Event(['file' => $file, 'options' => &$options]));
+            $grav->fireEvent('onBeforeDownload', new Event(['file' => $file, 'options' => &$options]));
 
             $file_parts = static::pathinfo($file);
             $mimetype = $options['mime'] ?? static::getMimeByExtension($file_parts['extension']);
             $size = filesize($file); // File size
 
-            // clean all buffers
-            while (ob_get_level()) {
-                ob_end_clean();
-            }
+            $grav->cleanOutputBuffers();
 
             // required for IE, otherwise Content-Disposition may be ignored
             if (ini_get('zlib.output_compression')) {
@@ -703,8 +713,8 @@ abstract class Utils
                 $new_length = $size;
                 header('Content-Length: ' . $size);
 
-                if (Grav::instance()['config']->get('system.cache.enabled')) {
-                    $expires = $options['expires'] ?? Grav::instance()['config']->get('system.pages.expires');
+                if ($grav['config']->get('system.cache.enabled')) {
+                    $expires = $options['expires'] ?? $grav['config']->get('system.pages.expires');
                     if ($expires > 0) {
                         $expires_date = gmdate('D, d M Y H:i:s T', time() + $expires);
                         header('Cache-Control: max-age=' . $expires);

+ 1 - 1
system/src/Grav/Console/Cli/InstallCommand.php

@@ -147,7 +147,7 @@ class InstallCommand extends GravCommand
         foreach ($this->config['git'] as $repo => $data) {
             $path = $this->destination . DS . $data['path'];
             if (!file_exists($path)) {
-                exec('cd "' . $this->destination . '" && git clone -b ' . $data['branch'] . ' --depth 1 ' . $data['url'] . ' ' . $data['path'], $output, $return);
+                exec('cd ' . escapeshellarg($this->destination) . ' && git clone -b ' . $data['branch'] . ' --depth 1 ' . $data['url'] . ' ' . $data['path'], $output, $return);
 
                 if (!$return) {
                     $io->writeln('<green>SUCCESS</green> cloned <magenta>' . $data['url'] . '</magenta> -> <cyan>' . $path . '</cyan>');

+ 1 - 1
system/src/Grav/Console/ConsoleTrait.php

@@ -288,7 +288,7 @@ trait ConsoleTrait
     {
         $composer = Composer::getComposerExecutor();
 
-        return system($composer . ' --working-dir="'.$path.'" --no-interaction --no-dev --prefer-dist -o '. $action);
+        return system($composer . ' --working-dir=' . escapeshellarg($path) . ' --no-interaction --no-dev --prefer-dist -o '. $action);
     }
 
     /**

+ 1 - 2
system/src/Grav/Console/Gpm/DirectInstallCommand.php

@@ -10,12 +10,11 @@
 namespace Grav\Console\Gpm;
 
 use Exception;
-use Grav\Common\Cache;
 use Grav\Common\Grav;
 use Grav\Common\Filesystem\Folder;
+use Grav\Common\HTTP\Response;
 use Grav\Common\GPM\GPM;
 use Grav\Common\GPM\Installer;
-use Grav\Common\GPM\Response;
 use Grav\Console\GpmCommand;
 use RuntimeException;
 use Symfony\Component\Console\Input\InputArgument;

+ 2 - 2
system/src/Grav/Console/Gpm/InstallCommand.php

@@ -11,10 +11,10 @@ namespace Grav\Console\Gpm;
 
 use Exception;
 use Grav\Common\Filesystem\Folder;
+use Grav\Common\HTTP\Response;
 use Grav\Common\GPM\GPM;
 use Grav\Common\GPM\Installer;
 use Grav\Common\GPM\Licenses;
-use Grav\Common\GPM\Response;
 use Grav\Common\GPM\Remote\Package;
 use Grav\Common\Grav;
 use Grav\Common\Utils;
@@ -485,7 +485,7 @@ class InstallCommand extends GpmCommand
     {
         $io = $this->getIO();
 
-        exec('cd ' . $this->destination);
+        exec('cd ' . escapeshellarg($this->destination));
 
         $to = $this->destination . DS . $package->install_path;
         $from = $this->getSymlinkSource($package);

+ 1 - 1
system/src/Grav/Console/Gpm/SelfupgradeCommand.php

@@ -11,8 +11,8 @@ namespace Grav\Console\Gpm;
 
 use Exception;
 use Grav\Common\Filesystem\Folder;
+use Grav\Common\HTTP\Response;
 use Grav\Common\GPM\Installer;
-use Grav\Common\GPM\Response;
 use Grav\Common\GPM\Upgrader;
 use Grav\Common\Grav;
 use Grav\Console\GpmCommand;

+ 0 - 3
system/src/Grav/Framework/Cache/CacheTrait.php

@@ -157,9 +157,6 @@ trait CacheTrait
         $this->validateKeys($keys);
         $keys = array_unique($keys);
         $keys = array_combine($keys, $keys);
-        if (empty($keys)) {
-            return [];
-        }
 
         $list = $this->doGetMultiple($keys, $this->miss);
 

+ 52 - 0
system/src/Grav/Framework/Contracts/Media/MediaObjectInterface.php

@@ -0,0 +1,52 @@
+<?php declare(strict_types=1);
+
+namespace Grav\Framework\Contracts\Media;
+
+use Grav\Framework\Contracts\Object\IdentifierInterface;
+use Psr\Http\Message\ResponseInterface;
+
+/**
+ * Media Object Interface
+ */
+interface MediaObjectInterface extends IdentifierInterface
+{
+    /**
+     * Returns true if the object exists.
+     *
+     * @return bool
+     * @phpstan-pure
+     */
+    public function exists(): bool;
+
+    /**
+     * Get metadata associated to the media object.
+     *
+     * @return array
+     * @phpstan-pure
+     */
+    public function getMeta(): array;
+
+    /**
+     * @param string $field
+     * @return mixed
+     * @phpstan-pure
+     */
+    public function get(string $field);
+
+    /**
+     * Return URL pointing to the media object.
+     *
+     * @return string
+     * @phpstan-pure
+     */
+    public function getUrl(): string;
+
+    /**
+     * Create media response.
+     *
+     * @param array $actions
+     * @return ResponseInterface
+     * @phpstan-pure
+     */
+    public function createResponse(array $actions): ResponseInterface;
+}

+ 27 - 0
system/src/Grav/Framework/Contracts/Object/IdentifierInterface.php

@@ -0,0 +1,27 @@
+<?php declare(strict_types=1);
+
+namespace Grav\Framework\Contracts\Object;
+
+use JsonSerializable;
+
+/**
+ * Interface IdentifierInterface
+ */
+interface IdentifierInterface extends JsonSerializable
+{
+    /**
+     * Get identifier's ID.
+     *
+     * @return string
+     * @phpstan-pure
+     */
+    public function getId(): string;
+
+    /**
+     * Get identifier's type.
+     *
+     * @return string
+     * @phpstan-pure
+     */
+    public function getType(): string;
+}

+ 28 - 0
system/src/Grav/Framework/Contracts/Relationships/RelationshipIdentifierInterface.php

@@ -0,0 +1,28 @@
+<?php declare(strict_types=1);
+
+namespace Grav\Framework\Contracts\Relationships;
+
+use ArrayAccess;
+use Grav\Framework\Contracts\Object\IdentifierInterface;
+
+/**
+ * Interface RelationshipIdentifierInterface
+ */
+interface RelationshipIdentifierInterface extends IdentifierInterface
+{
+    /**
+     * If identifier has meta.
+     *
+     * @return bool
+     * @phpstan-pure
+     */
+    public function hasIdentifierMeta(): bool;
+
+    /**
+     * Get identifier meta.
+     *
+     * @return array<string,mixed>|ArrayAccess<string,mixed>
+     * @phpstan-pure
+     */
+    public function getIdentifierMeta();
+}

+ 81 - 0
system/src/Grav/Framework/Contracts/Relationships/RelationshipInterface.php

@@ -0,0 +1,81 @@
+<?php declare(strict_types=1);
+
+namespace Grav\Framework\Contracts\Relationships;
+
+use Countable;
+use Grav\Framework\Contracts\Object\IdentifierInterface;
+use IteratorAggregate;
+use JsonSerializable;
+use Serializable;
+
+/**
+ * Interface Relationship
+ *
+ * @template T of IdentifierInterface
+ * @template P of IdentifierInterface
+ * @extends IteratorAggregate<string, T>
+ */
+interface RelationshipInterface extends Countable, IteratorAggregate, JsonSerializable, Serializable
+{
+    /**
+     * @return string
+     * @phpstan-pure
+     */
+    public function getName(): string;
+
+    /**
+     * @return string
+     * @phpstan-pure
+     */
+    public function getType(): string;
+
+    /**
+     * @return bool
+     * @phpstan-pure
+     */
+    public function isModified(): bool;
+
+    /**
+     * @return string
+     * @phpstan-pure
+     */
+    public function getCardinality(): string;
+
+    /**
+     * @return P
+     * @phpstan-pure
+     */
+    public function getParent(): IdentifierInterface;
+
+    /**
+     * @param string $id
+     * @param string|null $type
+     * @return bool
+     * @phpstan-pure
+     */
+    public function has(string $id, string $type = null): bool;
+
+    /**
+     * @param T $identifier
+     * @return bool
+     * @phpstan-pure
+     */
+    public function hasIdentifier(IdentifierInterface $identifier): bool;
+
+    /**
+     * @param T $identifier
+     * @return bool
+     */
+    public function addIdentifier(IdentifierInterface $identifier): bool;
+
+    /**
+     * @param T|null $identifier
+     * @return bool
+     */
+    public function removeIdentifier(IdentifierInterface $identifier = null): bool;
+
+    /**
+     * @return iterable<T>
+     */
+    public function getIterator(): iterable;
+}

+ 53 - 0
system/src/Grav/Framework/Contracts/Relationships/RelationshipsInterface.php

@@ -0,0 +1,53 @@
+<?php declare(strict_types=1);
+
+namespace Grav\Framework\Contracts\Relationships;
+
+use ArrayAccess;
+use Countable;
+use Iterator;
+use JsonSerializable;
+
+/**
+ * Interface RelationshipsInterface
+ *
+ * @template T of \Grav\Framework\Contracts\Object\IdentifierInterface
+ * @template P of \Grav\Framework\Contracts\Object\IdentifierInterface
+ * @extends ArrayAccess<string,RelationshipInterface<T,P>>
+ * @extends Iterator<string,RelationshipInterface<T,P>>
+ */
+interface RelationshipsInterface extends Countable, ArrayAccess, Iterator, JsonSerializable
+{
+    /**
+     * @return bool
+     * @phpstan-pure
+     */
+    public function isModified(): bool;
+
+    /**
+     * @return array
+     */
+    public function getModified(): array;
+
+    /**
+     * @return int
+     * @phpstan-pure
+     */
+    public function count(): int;
+
+    /**
+     * @param string $offset
+     * @return RelationshipInterface<T,P>|null
+     */
+    public function offsetGet($offset): ?RelationshipInterface;
+
+    /**
+     * @return RelationshipInterface<T,P>|null
+     */
+    public function current(): ?RelationshipInterface;
+
+    /**
+     * @return string
+     * @phpstan-pure
+     */
+    public function key(): string;
+}

+ 55 - 0
system/src/Grav/Framework/Contracts/Relationships/ToManyRelationshipInterface.php

@@ -0,0 +1,55 @@
+<?php declare(strict_types=1);
+
+namespace Grav\Framework\Contracts\Relationships;
+
+use Grav\Framework\Contracts\Object\IdentifierInterface;
+
+/**
+ * Interface ToManyRelationshipInterface
+ *
+ * @template T of IdentifierInterface
+ * @template P of IdentifierInterface
+ * @template-extends RelationshipInterface<T,P>
+ */
+interface ToManyRelationshipInterface extends RelationshipInterface
+{
+    /**
+     * @param positive-int $pos
+     * @return IdentifierInterface|null
+     */
+    public function getNthIdentifier(int $pos): ?IdentifierInterface;
+
+    /**
+     * @param string $id
+     * @param string|null $type
+     * @return T|null
+     * @phpstan-pure
+     */
+    public function getIdentifier(string $id, string $type = null): ?IdentifierInterface;
+
+    /**
+     * @param string $id
+     * @param string|null $type
+     * @return T|null
+     * @phpstan-pure
+     */
+    public function getObject(string $id, string $type = null): ?object;
+
+    /**
+     * @param iterable<T> $identifiers
+     * @return bool
+     */
+    public function addIdentifiers(iterable $identifiers): bool;
+
+    /**
+     * @param iterable<T> $identifiers
+     * @return bool
+     */
+    public function replaceIdentifiers(iterable $identifiers): bool;
+
+    /**
+     * @param iterable<T> $identifiers
+     * @return bool
+     */
+    public function removeIdentifiers(iterable $identifiers): bool;
+}

+ 37 - 0
system/src/Grav/Framework/Contracts/Relationships/ToOneRelationshipInterface.php

@@ -0,0 +1,37 @@
+<?php declare(strict_types=1);
+
+namespace Grav\Framework\Contracts\Relationships;
+
+use Grav\Framework\Contracts\Object\IdentifierInterface;
+
+/**
+ * Interface ToOneRelationshipInterface
+ *
+ * @template T of IdentifierInterface
+ * @template P of IdentifierInterface
+ * @template-extends RelationshipInterface<T,P>
+ */
+interface ToOneRelationshipInterface extends RelationshipInterface
+{
+    /**
+     * @param string|null $id
+     * @param string|null $type
+     * @return T|null
+     * @phpstan-pure
+     */
+    public function getIdentifier(string $id = null, string $type = null): ?IdentifierInterface;
+
+    /**
+     * @param string|null $id
+     * @param string|null $type
+     * @return T|null
+     * @phpstan-pure
+     */
+    public function getObject(string $id = null, string $type = null): ?object;
+
+    /**
+     * @param T|null $identifier
+     * @return bool
+     */
+    public function replaceIdentifier(IdentifierInterface $identifier = null): bool;
+}

+ 4 - 0
system/src/Grav/Framework/Flex/FlexCollection.php

@@ -147,6 +147,10 @@ class FlexCollection extends ObjectCollection implements FlexCollectionInterface
      */
     public function search(string $search, $properties = null, array $options = null)
     {
+        $directory = $this->getFlexDirectory();
+        $properties = $directory->getSearchProperties($properties);
+        $options = $directory->getSearchOptions($options);
+
         $matching = $this->call('search', [$search, $properties, $options]);
         $matching = array_filter($matching);
 

+ 76 - 0
system/src/Grav/Framework/Flex/FlexDirectory.php

@@ -170,6 +170,44 @@ class FlexDirectory implements FlexDirectoryInterface
         return null === $name ? $this->config : $this->config->get($name, $default);
     }
 
+    /**
+     * @param string|string[]|null $properties
+     * @return array
+     */
+    public function getSearchProperties($properties = null): array
+    {
+        if (null !== $properties) {
+            return (array)$properties;
+        }
+
+        $properties = $this->getConfig('data.search.fields');
+        if (!$properties) {
+            $fields = $this->getConfig('admin.views.list.fields') ?? $this->getConfig('admin.list.fields', []);
+            foreach ($fields as $property => $value) {
+                if (!empty($value['link'])) {
+                    $properties[] = $property;
+                }
+            }
+        }
+
+        return $properties;
+    }
+
+    /**
+     * @param array|null $options
+     * @return array
+     */
+    public function getSearchOptions(array $options = null): array
+    {
+        if (empty($options['merge'])) {
+            return $options ?? (array)$this->getConfig('data.search.options');
+        }
+
+        unset($options['merge']);
+
+        return $options + (array)$this->getConfig('data.search.options');
+    }
+
     /**
      * @param string|null $name
      * @param array $options
@@ -750,6 +788,7 @@ class FlexDirectory implements FlexDirectoryInterface
     public function reloadIndex(): void
     {
         $this->getCache('index')->clear();
+        $this->getIndex()::loadEntriesFromStorage($this->getStorage());
 
         $this->indexes = [];
         $this->objects = [];
@@ -797,6 +836,9 @@ class FlexDirectory implements FlexDirectoryInterface
             $blueprint->addDynamicHandler('flex', function (array &$field, $property, array &$call) {
                 $this->dynamicFlexField($field, $property, $call);
             });
+            $blueprint->addDynamicHandler('authorize', function (array &$field, $property, array &$call) {
+                $this->dynamicAuthorizeField($field, $property, $call);
+            });
 
             if ($context) {
                 $blueprint->setContext($context);
@@ -878,6 +920,40 @@ class FlexDirectory implements FlexDirectoryInterface
             if (is_array($value) && isset($field[$property]) && is_array($field[$property])) {
                 $value = $this->mergeArrays($field[$property], $value);
             }
+            $value = $not ? !$value : $value;
+
+            if ($property === 'ignore' && $value) {
+                Blueprint::addPropertyRecursive($field, 'validate', ['ignore' => true]);
+            } else {
+                $field[$property] = $value;
+            }
+        }
+    }
+
+    /**
+     * @param array $field
+     * @param string $property
+     * @param array $call
+     * @return void
+     */
+    protected function dynamicAuthorizeField(array &$field, $property, array $call): void
+    {
+        $params = (array)$call['params'];
+        $object = $call['object'] ?? null;
+        $permission = array_shift($params);
+        $not = false;
+        if (str_starts_with($permission, '!')) {
+            $permission = substr($permission, 1);
+            $not = true;
+        } elseif (str_starts_with($permission, 'not ')) {
+            $permission = substr($permission, 4);
+            $not = true;
+        }
+        $permission = trim($permission);
+
+        if ($object) {
+            $value = $object->isAuthorized($permission) ?? false;
+
             $field[$property] = $not ? !$value : $value;
         }
     }

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

@@ -275,6 +275,7 @@ class FlexDirectoryForm implements FlexDirectoryFormInterface, JsonSerializable
                 'unique_id' => $this->getUniqueId(),
                 'form_name' => $this->getName(),
                 'folder' => $this->getFlashFolder(),
+                'id' => $this->getFlashId(),
                 'directory' => $this->getDirectory()
             ];
 

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

@@ -326,6 +326,7 @@ class FlexForm implements FlexObjectFormInterface, JsonSerializable
                 'unique_id' => $this->getUniqueId(),
                 'form_name' => $this->getName(),
                 'folder' => $this->getFlashFolder(),
+                'id' => $this->getFlashId(),
                 'object' => $this->getObject()
             ];
 

+ 75 - 0
system/src/Grav/Framework/Flex/FlexIdentifier.php

@@ -0,0 +1,75 @@
+<?php declare(strict_types=1);
+
+namespace Grav\Framework\Flex;
+
+use Grav\Common\Grav;
+use Grav\Framework\Flex\Interfaces\FlexObjectInterface;
+use Grav\Framework\Object\Identifiers\Identifier;
+use RuntimeException;
+
+/**
+ * Interface IdentifierInterface
+ *
+ * @template T of FlexObjectInterface
+ * @extends Identifier<T>
+ */
+class FlexIdentifier extends Identifier
+{
+    /** @var string */
+    private $keyField;
+    /** @var FlexObjectInterface|null */
+    private $object = null;
+
+    /**
+     * @param FlexObjectInterface $object
+     * @return FlexIdentifier<T>
+     */
+    public static function createFromObject(FlexObjectInterface $object): FlexIdentifier
+    {
+        $instance = new static($object->getKey(), $object->getFlexType(), 'key');
+        $instance->setObject($object);
+
+        return $instance;
+    }
+
+    /**
+     * IdentifierInterface constructor.
+     * @param string $id
+     * @param string $type
+     * @param string $keyField
+     */
+    public function __construct(string $id, string $type, string $keyField = 'key')
+    {
+        parent::__construct($id, $type);
+
+        $this->keyField = $keyField;
+    }
+
+    /**
+     * @return T
+     */
+    public function getObject(): ?FlexObjectInterface
+    {
+        if (!isset($this->object)) {
+            /** @var Flex $flex */
+            $flex = Grav::instance()['flex'];
+
+            $this->object = $flex->getObject($this->getId(), $this->getType(), $this->keyField);
+        }
+
+        return $this->object;
+    }
+
+    /**
+     * @param T $object
+     */
+    public function setObject(FlexObjectInterface $object): void
+    {
+        $type = $this->getType();
+        if ($type !== $object->getFlexType()) {
+            throw new RuntimeException(sprintf('Object has to be type %s, %s given', $type, $object->getFlexType()));
+        }
+
+        $this->object = $object;
+    }
+}

+ 4 - 0
system/src/Grav/Framework/Flex/FlexIndex.php

@@ -162,6 +162,10 @@ class FlexIndex extends ObjectIndex implements FlexIndexInterface
      */
     public function search(string $search, $properties = null, array $options = null)
     {
+        $directory = $this->getFlexDirectory();
+        $properties = $directory->getSearchProperties($properties);
+        $options = $directory->getSearchOptions($options);
+
         return $this->__call('search', [$search, $properties, $options]);
     }
 

+ 3 - 11
system/src/Grav/Framework/Flex/FlexObject.php

@@ -287,17 +287,9 @@ class FlexObject implements FlexObjectInterface, FlexAuthorizeInterface
      */
     public function search(string $search, $properties = null, array $options = null): float
     {
-        $properties = (array)($properties ?? $this->getFlexDirectory()->getConfig('data.search.fields'));
-        if (!$properties) {
-            $fields = $this->getFlexDirectory()->getConfig('admin.views.list.fields') ?? $this->getFlexDirectory()->getConfig('admin.list.fields', []);
-            foreach ($fields as $property => $value) {
-                if (!empty($value['link'])) {
-                    $properties[] = $property;
-                }
-            }
-        }
-
-        $options = $options ?? (array)$this->getFlexDirectory()->getConfig('data.search.options');
+        $directory = $this->getFlexDirectory();
+        $properties = $directory->getSearchProperties($properties);
+        $options = $directory->getSearchOptions($options);
 
         $weight = 0;
         foreach ($properties as $property) {

+ 54 - 2
system/src/Grav/Framework/Flex/Traits/FlexMediaTrait.php

@@ -22,6 +22,9 @@ use Grav\Framework\Cache\CacheInterface;
 use Grav\Framework\Filesystem\Filesystem;
 use Grav\Framework\Flex\FlexDirectory;
 use Grav\Framework\Form\FormFlashFile;
+use Grav\Framework\Media\Interfaces\MediaObjectInterface;
+use Grav\Framework\Media\MediaObject;
+use Grav\Framework\Media\UploadedMediaObject;
 use Psr\Http\Message\UploadedFileInterface;
 use RocketTheme\Toolbox\ResourceLocator\UniformResourceLocator;
 use RuntimeException;
@@ -30,7 +33,6 @@ use function in_array;
 use function is_array;
 use function is_callable;
 use function is_int;
-use function is_object;
 use function is_string;
 use function strpos;
 
@@ -297,6 +299,55 @@ trait FlexMediaTrait
             ];
     }
 
+    /**
+     * @param string|null $field
+     * @param string $filename
+     * @param MediaObjectInterface|null $image
+     * @return MediaObject|UploadedMediaObject
+     */
+    protected function buildMediaObject(?string $field, string $filename, MediaObjectInterface $image = null)
+    {
+        if (!$image) {
+            $media = $field ? $this->getMediaField($field) : null;
+            if ($media) {
+                $image = $media[$filename];
+            }
+        }
+
+        return new MediaObject($field, $filename, $image, $this);
+    }
+
+    /**
+     * @param string|null $field
+     * @return array
+     */
+    protected function buildMediaList(?string $field): array
+    {
+        $names = $field ? (array)$this->getNestedProperty($field) : [];
+        $media = $field ? $this->getMediaField($field) : null;
+        if (null === $media) {
+            $media = $this->getMedia();
+        }
+
+        $list = [];
+        foreach ($names as $key => $val) {
+            $name = is_string($val) ? $val : $key;
+            $medium = $media[$name];
+            if ($medium) {
+                if ($medium->uploaded_file) {
+                    $upload = $medium->uploaded_file;
+                    $id = $upload instanceof FormFlashFile ? $upload->getId() : "{$field}-{$name}";
+
+                    $list[] = new UploadedMediaObject($id, $field, $name, $upload);
+                } else {
+                    $list[] = $this->buildMediaObject($field, $name, $medium);
+                }
+            }
+        }
+
+        return $list;
+    }
+
     /**
      * @param array $files
      * @return void
@@ -375,7 +426,7 @@ trait FlexMediaTrait
         $updated = false;
         foreach ($this->getUpdatedMedia() as $filename => $upload) {
             if (is_array($upload)) {
-                // Uses new format with [UploadedFileInterface, array].
+                /** @var array{UploadedFileInterface,array} $upload */
                 $settings = $upload[1];
                 if (isset($settings['destination']) && $settings['destination'] === $media->getPath()) {
                     $upload = $upload[0];
@@ -388,6 +439,7 @@ trait FlexMediaTrait
                 $updated = true;
                 if ($medium) {
                     $medium->uploaded = true;
+                    $medium->uploaded_file = $upload;
                     $media->add($filename, $medium);
                 } elseif (is_callable([$media, 'hide'])) {
                     $media->hide($filename);

+ 61 - 0
system/src/Grav/Framework/Flex/Traits/FlexRelationshipsTrait.php

@@ -0,0 +1,61 @@
+<?php declare(strict_types=1);
+
+namespace Grav\Framework\Flex\Traits;
+
+use Grav\Framework\Contracts\Relationships\RelationshipInterface;
+use Grav\Framework\Contracts\Relationships\RelationshipsInterface;
+use Grav\Framework\Flex\FlexIdentifier;
+use Grav\Framework\Relationships\Relationships;
+
+/**
+ * Trait FlexRelationshipsTrait
+ */
+trait FlexRelationshipsTrait
+{
+    /** @var RelationshipsInterface|null */
+    private $_relationships = null;
+
+    /**
+     * @return Relationships
+     */
+    public function getRelationships(): Relationships
+    {
+        if (!isset($this->_relationships)) {
+            $blueprint = $this->getBlueprint();
+            $options = $blueprint->get('config/relationships', []);
+            $parent = FlexIdentifier::createFromObject($this);
+
+            $this->_relationships = new Relationships($parent, $options);
+        }
+
+        return $this->_relationships;
+    }
+
+    /**
+     * @param string $name
+     * @return RelationshipInterface|null
+     */
+    public function getRelationship(string $name): ?RelationshipInterface
+    {
+        return $this->getRelationships()[$name];
+    }
+
+    protected function resetRelationships(): void
+    {
+        $this->_relationships = null;
+    }
+
+    /**
+     * @param iterable $collection
+     * @return array
+     */
+    protected function buildFlexIdentifierList(iterable $collection): array
+    {
+        $list = [];
+        foreach ($collection as $object) {
+            $list[] = FlexIdentifier::createFromObject($object);
+        }
+
+        return $list;
+    }
+}

+ 17 - 3
system/src/Grav/Framework/Form/FormFlash.php

@@ -31,6 +31,8 @@ class FormFlash implements FormFlashInterface
     /** @var bool */
     protected $exists;
     /** @var string */
+    protected $id;
+    /** @var string */
     protected $sessionId;
     /** @var string */
     protected $uniqueId;
@@ -75,9 +77,12 @@ class FormFlash implements FormFlashInterface
             });
         }
 
-        $this->sessionId = $config['session_id'] ?? 'no-session';
+        $this->id = $config['id'] ?? '';
+        $this->sessionId = $config['session_id'] ?? '';
         $this->uniqueId = $config['unique_id'] ?? '';
 
+        $this->setUser($config['user'] ?? null);
+
         $folder = $config['folder'] ?? ($this->sessionId ? 'tmp://forms/' . $this->sessionId : '');
 
         /** @var UniformResourceLocator $locator */
@@ -133,6 +138,14 @@ class FormFlash implements FormFlashInterface
         return $data;
     }
 
+    /**
+     * @inheritDoc
+     */
+    public function getId(): string
+    {
+        return $this->id && $this->uniqueId ? $this->id . '/' . $this->uniqueId : '';
+    }
+
     /**
      * @inheritDoc
      */
@@ -390,8 +403,8 @@ class FormFlash implements FormFlashInterface
      */
     public function clearFiles()
     {
-        foreach ($this->files as $field => $files) {
-            foreach ($files as $name => $upload) {
+        foreach ($this->files as $files) {
+            foreach ($files as $upload) {
                 $this->removeTmpFile($upload['tmp_name'] ?? '');
             }
         }
@@ -406,6 +419,7 @@ class FormFlash implements FormFlashInterface
     {
         return [
             'form' => $this->formName,
+            'id' => $this->getId(),
             'unique_id' => $this->uniqueId,
             'url' => $this->url,
             'user' => $this->user,

+ 9 - 0
system/src/Grav/Framework/Form/FormFlashFile.php

@@ -28,6 +28,8 @@ use function sprintf;
  */
 class FormFlashFile implements UploadedFileInterface, JsonSerializable
 {
+    /** @var string */
+    private $id;
     /** @var string */
     private $field;
     /** @var bool */
@@ -45,6 +47,7 @@ class FormFlashFile implements UploadedFileInterface, JsonSerializable
      */
     public function __construct(string $field, array $upload, FormFlash $flash)
     {
+        $this->id = $flash->getId() ?: $flash->getUniqueId();
         $this->field = $field;
         $this->upload = $upload;
         $this->flash = $flash;
@@ -107,6 +110,11 @@ class FormFlashFile implements UploadedFileInterface, JsonSerializable
         }
     }
 
+    public function getId(): string
+    {
+        return $this->id;
+    }
+
     /**
      * @return string
      */
@@ -222,6 +230,7 @@ class FormFlashFile implements UploadedFileInterface, JsonSerializable
     public function __debugInfo()
     {
         return [
+            'id:private' => $this->id,
             'field:private' => $this->field,
             'moved:private' => $this->moved,
             'upload:private' => $this->upload,

+ 7 - 0
system/src/Grav/Framework/Form/Interfaces/FormFlashInterface.php

@@ -22,6 +22,13 @@ interface FormFlashInterface extends \JsonSerializable
      */
     public function __construct($config);
 
+    /**
+     * Get unique form flash id if set.
+     *
+     * @return string
+     */
+    public function getId(): string;
+
     /**
      * Get session Id associated to this form instance.
      *

+ 26 - 3
system/src/Grav/Framework/Form/Traits/FormTrait.php

@@ -453,10 +453,10 @@ trait FormTrait
                 'session_id' => $this->getSessionId(),
                 'unique_id' => $this->getUniqueId(),
                 'form_name' => $this->getName(),
-                'folder' => $this->getFlashFolder()
+                'folder' => $this->getFlashFolder(),
+                'id' => $this->getFlashId()
             ];
 
-
             $this->flash = new FormFlash($config);
             $this->flash->setUrl($grav['uri']->url)->setUser($grav['user'] ?? null);
         }
@@ -486,7 +486,8 @@ trait FormTrait
                 'session_id' => $this->getSessionId(),
                 'unique_id' => $uniqueId,
                 'form_name' => $name,
-                'folder' => $this->getFlashFolder()
+                'folder' => $this->getFlashFolder(),
+                'id' => $this->getFlashId()
             ];
             $flash = new FormFlash($config);
             if ($flash->exists() && $flash->getFormName() === $name) {
@@ -614,6 +615,28 @@ trait FormTrait
         return strpos($path, '!!') === false ? rtrim($path, '/') : null;
     }
 
+    /**
+     * @return string|null
+     */
+    protected function getFlashId(): ?string
+    {
+        // Fill template token keys/value pairs.
+        $dataMap = [
+            '[FORM_NAME]' => $this->getName(),
+            '[SESSIONID]' => 'session',
+            '[USERNAME]' => '!!',
+            '[USERNAME_OR_SESSIONID]' => '!!',
+            '[ACCOUNT]' => 'account'
+        ];
+
+        $flashLookupFolder = $this->getFlashLookupFolder();
+
+        $path = str_replace(array_keys($dataMap), array_values($dataMap), $flashLookupFolder);
+
+        // Make sure we only return valid paths.
+        return strpos($path, '!!') === false ? rtrim($path, '/') : null;
+    }
+
     /**
      * @return string
      */

+ 30 - 0
system/src/Grav/Framework/Media/Interfaces/MediaObjectInterface.php

@@ -9,9 +9,39 @@
 
 namespace Grav\Framework\Media\Interfaces;
 
+use Psr\Http\Message\UploadedFileInterface;
+
 /**
  * Class implements media object interface.
+ *
+ * @property UploadedFileInterface|null $uploaded_file
  */
 interface MediaObjectInterface
 {
+    /**
+     * Returns an array containing the file metadata
+     *
+     * @return array
+     */
+    public function getMeta();
+
+    /**
+     * Return URL to file.
+     *
+     * @param bool $reset
+     * @return string
+     */
+    public function url($reset = true);
+
+    /**
+     * Get value by using dot notation for nested arrays/objects.
+     *
+     * @example $value = $this->get('this.is.my.nested.variable');
+     *
+     * @param string $name Dot separated path to the requested value.
+     * @param mixed $default Default value (or null).
+     * @param string|null $separator Separator, defaults to '.'
+     * @return mixed Value.
+     */
+    public function get($name, $default = null, $separator = null);
 }

+ 150 - 0
system/src/Grav/Framework/Media/MediaIdentifier.php

@@ -0,0 +1,150 @@
+<?php declare(strict_types=1);
+
+namespace Grav\Framework\Media;
+
+use Grav\Common\Grav;
+use Grav\Common\User\Interfaces\UserInterface;
+use Grav\Framework\Contracts\Media\MediaObjectInterface;
+use Grav\Framework\Flex\Flex;
+use Grav\Framework\Flex\FlexFormFlash;
+use Grav\Framework\Flex\Interfaces\FlexObjectInterface;
+use Grav\Framework\Object\Identifiers\Identifier;
+
+/**
+ * Interface IdentifierInterface
+ *
+ * @template T of MediaObjectInterface
+ * @extends Identifier<T>
+ */
+class MediaIdentifier extends Identifier
+{
+    /** @var MediaObjectInterface|null */
+    private $object = null;
+
+    /**
+     * @param MediaObjectInterface $object
+     * @return MediaIdentifier<T>
+     */
+    public static function createFromObject(MediaObjectInterface $object): MediaIdentifier
+    {
+        $instance = new static($object->getId());
+        $instance->setObject($object);
+
+        return $instance;
+    }
+
+    /**
+     * @param string $id
+     */
+    public function __construct(string $id)
+    {
+        parent::__construct($id, 'media');
+    }
+
+    /**
+     * @return T
+     */
+    public function getObject(): ?MediaObjectInterface
+    {
+        if (!isset($this->object)) {
+            $type = $this->getType();
+            $id = $this->getId();
+
+            $parts = explode('/', $id);
+            if ($type === 'media' && str_starts_with($id, 'uploads/')) {
+                array_shift($parts);
+                [, $folder, $uniqueId, $field, $filename] = $this->findFlash($parts);
+
+                $flash = $this->getFlash($folder, $uniqueId);
+                if ($flash->exists()) {
+
+                    $uploadedFile = $flash->getFilesByField($field)[$filename] ?? null;
+
+                    $this->object = UploadedMediaObject::createFromFlash($flash, $field, $filename, $uploadedFile);
+                }
+            } else {
+                $type = array_shift($parts);
+                $key = array_shift($parts);
+                $field = array_shift($parts);
+                $filename = implode('/', $parts);
+
+                $flexObject = $this->getFlexObject($type, $key);
+                if ($flexObject && method_exists($flexObject, 'getMediaField') && method_exists($flexObject, 'getMedia')) {
+                    $media = $field !== 'media' ? $flexObject->getMediaField($field) : $flexObject->getMedia();
+                    $image = null;
+                    if ($media) {
+                        $image = $media[$filename];
+                    }
+
+                    $this->object = new MediaObject($field, $filename, $image, $flexObject);
+                }
+            }
+
+            if (!isset($this->object)) {
+                throw new \RuntimeException(sprintf('Object not found for identifier {type: "%s", id: "%s"}', $type, $id));
+            }
+        }
+
+        return $this->object;
+    }
+
+    /**
+     * @param T $object
+     */
+    public function setObject(MediaObjectInterface $object): void
+    {
+        $type = $this->getType();
+        $objectType = $object->getType();
+
+        if ($type !== $objectType) {
+            throw new \RuntimeException(sprintf('Object has to be type %s, %s given', $type, $objectType));
+        }
+
+        $this->object = $object;
+    }
+
+    protected function findFlash(array $parts): ?array
+    {
+        $type = array_shift($parts);
+        if ($type === 'account') {
+            /** @var UserInterface|null $user */
+            $user = Grav::instance()['user'] ?? null;
+            $folder = $user->getMediaFolder();
+        } else {
+            $folder = 'tmp://';
+        }
+
+        if (!$folder) {
+            return null;
+        }
+
+        do {
+            $part = array_shift($parts);
+            $folder .= "/{$part}";
+        } while (!str_starts_with($part, 'flex-'));
+
+        $uniqueId = array_shift($parts);
+        $field = array_shift($parts);
+        $filename = implode('/', $parts);
+
+        return [$type, $folder, $uniqueId, $field, $filename];
+    }
+
+    protected function getFlash(string $folder, string $uniqueId): FlexFormFlash
+    {
+        $config = [
+            'unique_id' => $uniqueId,
+            'folder' => $folder
+        ];
+
+        return new FlexFormFlash($config);
+    }
+
+    protected function getFlexObject(string $type, string $key): ?FlexObjectInterface
+    {
+        /** @var Flex $flex */
+        $flex = Grav::instance()['flex'];
+
+        return $flex->getObject($key, $type);
+    }
+}

+ 215 - 0
system/src/Grav/Framework/Media/MediaObject.php

@@ -0,0 +1,215 @@
+<?php declare(strict_types=1);
+
+namespace Grav\Framework\Media;
+
+use Grav\Common\Page\Medium\ImageMedium;
+use Grav\Framework\Contracts\Media\MediaObjectInterface;
+use Grav\Framework\Flex\Interfaces\FlexObjectInterface;
+use Grav\Framework\Media\Interfaces\MediaObjectInterface as GravMediaObjectInterface;
+use Grav\Framework\Psr7\Response;
+use Psr\Http\Message\ResponseInterface;
+use Throwable;
+
+/**
+ * Class MediaObject
+ */
+class MediaObject implements MediaObjectInterface
+{
+    /** @var string */
+    static public $placeholderImage = 'image://media/thumb.png';
+
+    /** @var FlexObjectInterface */
+    public $object;
+    /** @var GravMediaObjectInterface|null */
+    public $media;
+
+    /** @var string|null */
+    private $field;
+    /** @var string */
+    private $filename;
+
+    /**
+     * MediaObject constructor.
+     * @param string|null $field
+     * @param string $filename
+     * @param GravMediaObjectInterface|null $media
+     * @param FlexObjectInterface $object
+     */
+    public function __construct(?string $field, string $filename, ?GravMediaObjectInterface $media, FlexObjectInterface $object)
+    {
+        $this->field = $field;
+        $this->filename = $filename;
+        $this->media = $media;
+        $this->object = $object;
+    }
+
+    /**
+     * @return string
+     */
+    public function getType(): string
+    {
+        return 'media';
+    }
+
+    /**
+     * @return string
+     */
+    public function getId(): string
+    {
+        $field = $this->field;
+        $object = $this->object;
+        $path = $field ? "/{$field}/" : '/media/';
+
+        return $object->getType() . '/' . $object->getKey() . $path . basename($this->filename);
+    }
+
+    /**
+     * @return bool
+     */
+    public function exists(): bool
+    {
+        return $this->media !== null;
+    }
+
+    /**
+     * @return array
+     */
+    public function getMeta(): array
+    {
+        if (!isset($this->media)) {
+            return [];
+        }
+
+        return $this->media->getMeta();
+    }
+
+    /**
+     * @param string $field
+     * @return mixed|null
+     */
+    public function get(string $field)
+    {
+        if (!isset($this->media)) {
+            return null;
+        }
+
+        return $this->media->get($field);
+    }
+
+    /**
+     * @return string
+     */
+    public function getUrl(): string
+    {
+        if (!isset($this->media)) {
+            return '';
+        }
+
+        return $this->media->url();
+    }
+
+    /**
+     * Create media response.
+     *
+     * @param array $actions
+     * @return Response
+     */
+    public function createResponse(array $actions): ResponseInterface
+    {
+        if (!isset($this->media)) {
+            return $this->create404Response($actions);
+        }
+
+        $media = $this->media;
+
+        if ($actions) {
+            $media = $this->processMediaActions($media, $actions);
+        }
+
+        // FIXME: This only works for images
+        if (!$media instanceof ImageMedium) {
+            throw new \RuntimeException('Not Implemented', 500);
+        }
+
+        $filename = $media->path(false);
+        $time = filemtime($filename);
+        $size = filesize($filename);
+        $body = fopen($filename, 'rb');
+        $headers = [
+            'Content-Type' => $media->get('mime'),
+            'Last-Modified' => gmdate('D, d M Y H:i:s', $time) . ' GMT',
+            'ETag' => sprintf('%x-%x', $size, $time)
+        ];
+
+        return new Response(200, $headers, $body);
+    }
+
+    /**
+     * Process media actions
+     *
+     * @param GravMediaObjectInterface $medium
+     * @param array $actions
+     * @return GravMediaObjectInterface
+     */
+    protected function processMediaActions(GravMediaObjectInterface $medium, array $actions): GravMediaObjectInterface
+    {
+        // loop through actions for the image and call them
+        foreach ($actions as $method => $params) {
+            $matches = [];
+
+            if (preg_match('/\[(.*)]/', $params, $matches)) {
+                $args = [explode(',', $matches[1])];
+            } else {
+                $args = explode(',', $params);
+            }
+
+            try {
+                $medium->{$method}(...$args);
+            } catch (Throwable $e) {
+                // Ignore all errors for now and just skip the action.
+            }
+        }
+
+        return $medium;
+    }
+
+    /**
+     * @param array $actions
+     * @return Response
+     */
+    protected function create404Response(array $actions): Response
+    {
+        // Display placeholder image.
+        $filename = static::$placeholderImage;
+
+        $time = filemtime($filename);
+        $size = filesize($filename);
+        $body = fopen($filename, 'rb');
+        $headers = [
+            'Content-Type' => 'image/svg',
+            'Last-Modified' => gmdate('D, d M Y H:i:s', $time) . ' GMT',
+            'ETag' => sprintf('%x-%x', $size, $time)
+        ];
+
+        return new Response(404, $headers, $body);
+    }
+
+    /**
+     * @return array
+     */
+    public function jsonSerialize(): array
+    {
+        return [
+            'type' => $this->getType(),
+            'id' => $this->getId()
+        ];
+    }
+
+    /**
+     * @return string[]
+     */
+    public function __debugInfo(): array
+    {
+        return $this->jsonSerialize();
+    }
+}

+ 172 - 0
system/src/Grav/Framework/Media/UploadedMediaObject.php

@@ -0,0 +1,172 @@
+<?php declare(strict_types=1);
+
+namespace Grav\Framework\Media;
+
+use Grav\Framework\Contracts\Media\MediaObjectInterface;
+use Grav\Framework\Flex\FlexFormFlash;
+use Grav\Framework\Form\Interfaces\FormFlashInterface;
+use Grav\Framework\Psr7\Response;
+use Psr\Http\Message\ResponseInterface;
+use Psr\Http\Message\UploadedFileInterface;
+
+/**
+ * Class UploadedMediaObject
+ */
+class UploadedMediaObject implements MediaObjectInterface
+{
+    /** @var string */
+    static public $placeholderImage = 'image://media/thumb.png';
+
+    /** @var FormFlashInterface */
+    public $object;
+
+    /** @var string */
+    private $id;
+    /** @var string|null */
+    private $field;
+    /** @var string */
+    private $filename;
+    /** @var array */
+    private $meta;
+    /** @var UploadedFileInterface|null */
+    private $uploadedFile;
+
+    /**
+     * @param FlexFormFlash $flash
+     * @param string|null $field
+     * @param string $filename
+     * @param UploadedFileInterface|null $uploadedFile
+     * @return static
+     */
+    public static function createFromFlash(FlexFormFlash $flash, ?string $field, string $filename, ?UploadedFileInterface $uploadedFile = null)
+    {
+        $id = $flash->getId();
+
+        return new static($id, $field, $filename, $uploadedFile);
+    }
+
+    /**
+     * @param string $id
+     * @param string|null $field
+     * @param string $filename
+     * @param UploadedFileInterface|null $uploadedFile
+     */
+    public function __construct(string $id, ?string $field, string $filename, ?UploadedFileInterface $uploadedFile = null)
+    {
+        $this->id = $id;
+        $this->field = $field;
+        $this->filename = $filename;
+        $this->uploadedFile = $uploadedFile;
+        if ($uploadedFile) {
+            $this->meta = [
+                'filename' => $uploadedFile->getClientFilename(),
+                'mime' => $uploadedFile->getClientMediaType(),
+                'size' => $uploadedFile->getSize()
+            ];
+        } else {
+            $this->meta = [];
+        }
+    }
+
+    /**
+     * @return string
+     */
+    public function getType(): string
+    {
+        return 'media';
+    }
+
+    /**
+     * @return string
+     */
+    public function getId(): string
+    {
+        $id = $this->id;
+        $field = $this->field;
+        $path = $field ? "/{$field}/" : '';
+
+        return 'uploads/' . $id . $path . basename($this->filename);
+    }
+
+    /**
+     * @return bool
+     */
+    public function exists(): bool
+    {
+        //return $this->uploadedFile !== null;
+        return false;
+    }
+
+    /**
+     * @return array
+     */
+    public function getMeta(): array
+    {
+        return $this->meta;
+    }
+
+    /**
+     * @param string $field
+     * @return mixed|null
+     */
+    public function get(string $field)
+    {
+        return $this->meta[$field] ?? null;
+    }
+
+    /**
+     * @return string
+     */
+    public function getUrl(): string
+    {
+        return '';
+    }
+
+    /**
+     * @return UploadedFileInterface|null
+     */
+    public function getUploadedFile(): ?UploadedFileInterface
+    {
+        return $this->uploadedFile;
+    }
+
+    /**
+     * @param array $actions
+     * @return Response
+     */
+    public function createResponse(array $actions): ResponseInterface
+    {
+        // Display placeholder image.
+        $filename = static::$placeholderImage;
+
+        $time = filemtime($filename);
+        $size = filesize($filename);
+        $body = fopen($filename, 'rb');
+        $headers = [
+            'Content-Type' => 'image/svg',
+            'Last-Modified' => gmdate('D, d M Y H:i:s', $time) . ' GMT',
+            'ETag' => sprintf('%x-%x', $size, $time)
+        ];
+
+        return new Response(404, $headers, $body);
+    }
+
+    /**
+     * @return array
+     */
+    public function jsonSerialize(): array
+    {
+        return [
+            'type' => $this->getType(),
+            'id' => $this->getId()
+        ];
+    }
+
+    /**
+     * @return string[]
+     */
+    public function __debugInfo(): array
+    {
+        return $this->jsonSerialize();
+    }
+}

+ 66 - 0
system/src/Grav/Framework/Object/Identifiers/Identifier.php

@@ -0,0 +1,66 @@
+<?php declare(strict_types=1);
+
+namespace Grav\Framework\Object\Identifiers;
+
+use Grav\Framework\Contracts\Object\IdentifierInterface;
+
+/**
+ * Interface IdentifierInterface
+ *
+ * @template T of object
+ */
+class Identifier implements IdentifierInterface
+{
+    /** @var string */
+    private $id;
+    /** @var string */
+    private $type;
+
+    /**
+     * IdentifierInterface constructor.
+     * @param string $id
+     * @param string $type
+     */
+    public function __construct(string $id, string $type)
+    {
+        $this->id = $id;
+        $this->type = $type;
+    }
+
+    /**
+     * @return string
+     * @phpstan-pure
+     */
+    public function getId(): string
+    {
+        return $this->id;
+    }
+
+    /**
+     * @return string
+     * @phpstan-pure
+     */
+    public function getType(): string
+    {
+        return $this->type;
+    }
+
+    /**
+     * @return array
+     */
+    public function jsonSerialize(): array
+    {
+        return [
+            'type' => $this->type,
+            'id' => $this->id
+        ];
+    }
+
+    /**
+     * @return array
+     */
+    public function __debugInfo(): array
+    {
+        return $this->jsonSerialize();
+    }
+}

+ 217 - 0
system/src/Grav/Framework/Relationships/Relationships.php

@@ -0,0 +1,217 @@
+<?php declare(strict_types=1);
+
+namespace Grav\Framework\Relationships;
+
+use Grav\Framework\Contracts\Object\IdentifierInterface;
+use Grav\Framework\Contracts\Relationships\RelationshipInterface;
+use Grav\Framework\Contracts\Relationships\RelationshipsInterface;
+use Grav\Framework\Flex\FlexIdentifier;
+use RuntimeException;
+use function count;
+
+/**
+ * Class Relationships
+ *
+ * @template T of \Grav\Framework\Contracts\Object\IdentifierInterface
+ * @template P of \Grav\Framework\Contracts\Object\IdentifierInterface
+ * @implements RelationshipsInterface<T,P>
+ */
+class Relationships implements RelationshipsInterface
+{
+    /** @var P */
+    protected $parent;
+    /** @var array */
+    protected $options;
+
+    /** @var RelationshipInterface<T,P>[] */
+    protected $relationships;
+
+    /**
+     * Relationships constructor.
+     * @param P $parent
+     * @param array $options
+     */
+    public function __construct(IdentifierInterface $parent, array $options)
+    {
+        $this->parent = $parent;
+        $this->options = $options;
+        $this->relationships = [];
+    }
+
+    /**
+     * @return bool
+     * @phpstan-pure
+     */
+    public function isModified(): bool
+    {
+        return !empty($this->getModified());
+    }
+
+    /**
+     * @return RelationshipInterface<T,P>[]
+     * @phpstan-pure
+     */
+    public function getModified(): array
+    {
+        $list = [];
+        foreach ($this->relationships as $name => $relationship) {
+            if ($relationship->isModified()) {
+                $list[$name] = $relationship;
+            }
+        }
+
+        return $list;
+    }
+
+    /**
+     * @return int
+     * @phpstan-pure
+     */
+    public function count(): int
+    {
+        return count($this->options);
+    }
+
+    /**
+     * @param string $offset
+     * @return bool
+     * @phpstan-pure
+     */
+    public function offsetExists($offset): bool
+    {
+        return isset($this->options[$offset]);
+    }
+
+    /**
+     * @param string $offset
+     * @return RelationshipInterface<T,P>|null
+     */
+    public function offsetGet($offset): ?RelationshipInterface
+    {
+        if (!isset($this->relationships[$offset])) {
+            $options = $this->options[$offset] ?? null;
+            if (null === $options) {
+                return null;
+            }
+
+            $this->relationships[$offset] = $this->createRelationship($offset, $options);
+        }
+
+        return $this->relationships[$offset];
+    }
+
+    /**
+     * @param string $offset
+     * @param mixed $value
+     * @return never-return
+     */
+    public function offsetSet($offset, $value)
+    {
+        throw new RuntimeException('Setting relationship is not supported', 500);
+    }
+
+    /**
+     * @param string $offset
+     * @return never-return
+     */
+    public function offsetUnset($offset)
+    {
+        throw new RuntimeException('Removing relationship is not allowed', 500);
+    }
+
+    /**
+     * @return RelationshipInterface<T,P>|null
+     */
+    public function current(): ?RelationshipInterface
+    {
+        $name = key($this->options);
+        if ($name === null) {
+            return null;
+        }
+
+        return $this->offsetGet($name);
+    }
+
+    /**
+     * @return string
+     * @phpstan-pure
+     */
+    public function key(): string
+    {
+        return key($this->options);
+    }
+
+    /**
+     * @return void
+     * @phpstan-pure
+     */
+    public function next(): void
+    {
+        next($this->options);
+    }
+
+    /**
+     * @return void
+     * @phpstan-pure
+     */
+    public function rewind(): void
+    {
+        reset($this->options);
+    }
+
+    /**
+     * @return bool
+     * @phpstan-pure
+     */
+    public function valid(): bool
+    {
+        return key($this->options) !== null;
+    }
+
+    /**
+     * @return array
+     */
+    public function jsonSerialize(): array
+    {
+        $list = [];
+        foreach ($this as $name => $relationship) {
+            $list[$name] = $relationship->jsonSerialize();
+        }
+
+        return $list;
+    }
+
+    /**
+     * @param string $name
+     * @param array $options
+     * @return ToOneRelationship|ToManyRelationship
+     */
+    private function createRelationship(string $name, array $options): RelationshipInterface
+    {
+        $data = null;
+
+        $parent = $this->parent;
+        if ($parent instanceof FlexIdentifier) {
+            $object = $parent->getObject();
+            if (!method_exists($object, 'initRelationship')) {
+                throw new RuntimeException(sprintf('Bad relationship %s', $name), 500);
+            }
+
+            $data = $object->initRelationship($name);
+        }
+
+        $cardinality = $options['cardinality'] ?? '';
+        switch ($cardinality) {
+            case 'to-one':
+                $relationship = new ToOneRelationship($parent, $name, $options, $data);
+                break;
+            case 'to-many':
+                $relationship = new ToManyRelationship($parent, $name, $options, $data ?? []);
+                break;
+            default:
+                throw new RuntimeException(sprintf('Bad relationship cardinality %s', $cardinality), 500);
+        }
+
+        return $relationship;
+    }
+}

+ 259 - 0
system/src/Grav/Framework/Relationships/ToManyRelationship.php

@@ -0,0 +1,259 @@
+<?php declare(strict_types=1);
+
+namespace Grav\Framework\Relationships;
+
+use ArrayIterator;
+use Grav\Framework\Compat\Serializable;
+use Grav\Framework\Contracts\Object\IdentifierInterface;
+use Grav\Framework\Contracts\Relationships\ToManyRelationshipInterface;
+use Grav\Framework\Relationships\Traits\RelationshipTrait;
+use function count;
+use function is_callable;
+
+/**
+ * Class ToManyRelationship
+ *
+ * @template T of IdentifierInterface
+ * @template P of IdentifierInterface
+ * @template-implements ToManyRelationshipInterface<T,P>
+ */
+class ToManyRelationship implements ToManyRelationshipInterface
+{
+    /** @template-use RelationshipTrait<T> */
+    use RelationshipTrait;
+    use Serializable;
+
+    /** @var IdentifierInterface[] */
+    protected $identifiers = [];
+
+    /**
+     * ToManyRelationship constructor.
+     * @param string $name
+     * @param IdentifierInterface $parent
+     * @param iterable<IdentifierInterface> $identifiers
+     */
+    public function __construct(IdentifierInterface $parent, string $name, array $options, iterable $identifiers = [])
+    {
+        $this->parent = $parent;
+        $this->name = $name;
+
+        $this->parseOptions($options);
+        $this->addIdentifiers($identifiers);
+
+        $this->modified = false;
+    }
+
+    /**
+     * @return string
+     * @phpstan-pure
+     */
+    public function getCardinality(): string
+    {
+        return 'to-many';
+    }
+
+    /**
+     * @return int
+     * @phpstan-pure
+     */
+    public function count(): int
+    {
+        return count($this->identifiers);
+    }
+
+    /**
+     * @return array
+     */
+    public function fetch(): array
+    {
+        $list = [];
+        foreach ($this->identifiers as $identifier) {
+            if (is_callable([$identifier, 'getObject'])) {
+                $identifier = $identifier->getObject();
+            }
+            $list[] = $identifier;
+        }
+
+        return $list;
+    }
+
+    /**
+     * @param string $id
+     * @param string|null $type
+     * @return bool
+     * @phpstan-pure
+     */
+    public function has(string $id, string $type = null): bool
+    {
+        return $this->getIdentifier($id, $type) !== null;
+    }
+
+    /**
+     * @param positive-int $pos
+     * @return IdentifierInterface|null
+     */
+    public function getNthIdentifier(int $pos): ?IdentifierInterface
+    {
+        $items = array_keys($this->identifiers);
+        $key = $items[$pos - 1] ?? null;
+        if (null === $key) {
+            return null;
+        }
+
+        return $this->identifiers[$key] ?? null;
+    }
+
+    /**
+     * @param string $id
+     * @param string|null $type
+     * @return IdentifierInterface|null
+     * @phpstan-pure
+     */
+    public function getIdentifier(string $id, string $type = null): ?IdentifierInterface
+    {
+        if (null === $type) {
+            $type = $this->getType();
+        }
+
+        if ($type === 'media' && !str_contains($id, '/')) {
+            $name = $this->name;
+            $id = $this->parent->getType() . '/' . $this->parent->getId() . '/'. $name . '/' . $id;
+        }
+
+        $key = "{$type}/{$id}";
+
+        return $this->identifiers[$key] ?? null;
+    }
+
+    /**
+     * @param string $id
+     * @param string|null $type
+     * @return T|null
+     */
+    public function getObject(string $id, string $type = null): ?object
+    {
+        $identifier = $this->getIdentifier($id, $type);
+        if ($identifier && is_callable([$identifier, 'getObject'])) {
+            $identifier = $identifier->getObject();
+        }
+
+        return $identifier;
+    }
+
+    /**
+     * @param IdentifierInterface $identifier
+     * @return bool
+     */
+    public function addIdentifier(IdentifierInterface $identifier): bool
+    {
+        return $this->addIdentifiers([$identifier]);
+    }
+
+    /**
+     * @param IdentifierInterface|null $identifier
+     * @return bool
+     */
+    public function removeIdentifier(IdentifierInterface $identifier = null): bool
+    {
+        return !$identifier || $this->removeIdentifiers([$identifier]);
+    }
+
+    /**
+     * @param iterable<IdentifierInterface> $identifiers
+     * @return bool
+     */
+    public function addIdentifiers(iterable $identifiers): bool
+    {
+        foreach ($identifiers as $identifier) {
+            $type = $identifier->getType();
+            $id = $identifier->getId();
+            $key = "{$type}/{$id}";
+
+            $this->identifiers[$key] = $this->checkIdentifier($identifier);
+            $this->modified = true;
+        }
+
+        return true;
+    }
+
+    /**
+     * @param iterable<IdentifierInterface> $identifiers
+     * @return bool
+     */
+    public function replaceIdentifiers(iterable $identifiers): bool
+    {
+        $this->identifiers = [];
+        $this->modified = true;
+
+        return $this->addIdentifiers($identifiers);
+    }
+
+    /**
+     * @param iterable<IdentifierInterface> $identifiers
+     * @return bool
+     */
+    public function removeIdentifiers(iterable $identifiers): bool
+    {
+        foreach ($identifiers as $identifier) {
+            $type = $identifier->getType();
+            $id = $identifier->getId();
+            $key = "{$type}/{$id}";
+
+            unset($this->identifiers[$key]);
+            $this->modified = true;
+        }
+
+        return true;
+    }
+
+    /**
+     * @return iterable<IdentifierInterface>
+     * @phpstan-pure
+     */
+    public function getIterator(): iterable
+    {
+        return new ArrayIterator($this->identifiers);
+    }
+
+    /**
+     * @return array
+     */
+    public function jsonSerialize(): array
+    {
+        $list = [];
+        foreach ($this->getIterator() as $item) {
+            $list[] = $item->jsonSerialize();
+        }
+
+        return $list;
+    }
+
+    /**
+     * @return array
+     */
+    public function __serialize(): array
+    {
+        return [
+            'parent' => $this->parent,
+            'name' => $this->name,
+            'type' => $this->type,
+            'options' => $this->options,
+            'modified' => $this->modified,
+            'identifiers' => $this->identifiers,
+        ];
+    }
+
+    /**
+     * @param array $data
+     * @return void
+     */
+    public function __unserialize(array $data): void
+    {
+        $this->parent = $data['parent'];
+        $this->name = $data['name'];
+        $this->type = $data['type'];
+        $this->options = $data['options'];
+        $this->modified = $data['modified'];
+        $this->identifiers = $data['identifiers'];
+    }
+}

+ 207 - 0
system/src/Grav/Framework/Relationships/ToOneRelationship.php

@@ -0,0 +1,207 @@
+<?php declare(strict_types=1);
+
+namespace Grav\Framework\Relationships;
+
+use ArrayIterator;
+use Grav\Framework\Compat\Serializable;
+use Grav\Framework\Contracts\Object\IdentifierInterface;
+use Grav\Framework\Contracts\Relationships\ToOneRelationshipInterface;
+use Grav\Framework\Relationships\Traits\RelationshipTrait;
+use function is_callable;
+
+/**
+ * Class ToOneRelationship
+ *
+ * @template T of IdentifierInterface
+ * @template P of IdentifierInterface
+ * @template-implements ToOneRelationshipInterface<T,P>
+ */
+class ToOneRelationship implements ToOneRelationshipInterface
+{
+    /** @template-use RelationshipTrait<T> */
+    use RelationshipTrait;
+    use Serializable;
+
+    /** @var IdentifierInterface|null */
+    protected $identifier = null;
+
+    public function __construct(IdentifierInterface $parent, string $name, array $options, IdentifierInterface $identifier = null)
+    {
+        $this->parent = $parent;
+        $this->name = $name;
+
+        $this->parseOptions($options);
+        $this->replaceIdentifier($identifier);
+
+        $this->modified = false;
+    }
+
+    /**
+     * @return string
+     * @phpstan-pure
+     */
+    public function getCardinality(): string
+    {
+        return 'to-one';
+    }
+
+    /**
+     * @return int
+     * @phpstan-pure
+     */
+    public function count(): int
+    {
+        return $this->identifier ? 1 : 0;
+    }
+
+    /**
+     * @return object|null
+     */
+    public function fetch(): ?object
+    {
+        $identifier = $this->identifier;
+        if (is_callable([$identifier, 'getObject'])) {
+            $identifier = $identifier->getObject();
+        }
+
+        return $identifier;
+    }
+
+
+    /**
+     * @param string|null $id
+     * @param string|null $type
+     * @return bool
+     * @phpstan-pure
+     */
+    public function has(string $id = null, string $type = null): bool
+    {
+        return $this->getIdentifier($id, $type) !== null;
+    }
+
+    /**
+     * @param string|null $id
+     * @param string|null $type
+     * @return IdentifierInterface|null
+     * @phpstan-pure
+     */
+    public function getIdentifier(string $id = null, string $type = null): ?IdentifierInterface
+    {
+        if ($id && $this->getType() === 'media' && !str_contains($id, '/')) {
+            $name = $this->name;
+            $id = $this->parent->getType() . '/' . $this->parent->getId() . '/'. $name . '/' . $id;
+        }
+
+        $identifier = $this->identifier ?? null;
+        if (null === $identifier || ($type && $type !== $identifier->getType()) || ($id && $id !== $identifier->getId())) {
+            return null;
+        }
+
+        return $identifier;
+    }
+
+    /**
+     * @param string|null $id
+     * @param string|null $type
+     * @return T|null
+     */
+    public function getObject(string $id = null, string $type = null): ?object
+    {
+        $identifier = $this->getIdentifier($id, $type);
+        if ($identifier && is_callable([$identifier, 'getObject'])) {
+            $identifier = $identifier->getObject();
+        }
+
+        return $identifier;
+    }
+
+    /**
+     * @param IdentifierInterface $identifier
+     * @return bool
+     */
+    public function addIdentifier(IdentifierInterface $identifier): bool
+    {
+        $this->identifier = $this->checkIdentifier($identifier);
+        $this->modified = true;
+
+        return true;
+    }
+
+    /**
+     * @param IdentifierInterface|null $identifier
+     * @return bool
+     */
+    public function replaceIdentifier(IdentifierInterface $identifier = null): bool
+    {
+        if ($identifier === null) {
+            $this->identifier = null;
+            $this->modified = true;
+
+            return true;
+        }
+
+        return $this->addIdentifier($identifier);
+    }
+
+    /**
+     * @param IdentifierInterface|null $identifier
+     * @return bool
+     */
+    public function removeIdentifier(IdentifierInterface $identifier = null): bool
+    {
+        if (null === $identifier || $this->has($identifier->getId(), $identifier->getType())) {
+            $this->identifier = null;
+            $this->modified = true;
+
+            return true;
+        }
+
+        return false;
+    }
+
+    /**
+     * @return iterable<IdentifierInterface>
+     * @phpstan-pure
+     */
+    public function getIterator(): iterable
+    {
+        return new ArrayIterator((array)$this->identifier);
+    }
+
+    /**
+     * @return array|null
+     */
+    public function jsonSerialize(): ?array
+    {
+        return $this->identifier ? $this->identifier->jsonSerialize() : null;
+    }
+
+    /**
+     * @return array
+     */
+    public function __serialize(): array
+    {
+        return [
+            'parent' => $this->parent,
+            'name' => $this->name,
+            'type' => $this->type,
+            'options' => $this->options,
+            'modified' => $this->modified,
+            'identifier' => $this->identifier,
+        ];
+    }
+
+    /**
+     * @param array $data
+     * @return void
+     */
+    public function __unserialize(array $data): void
+    {
+        $this->parent = $data['parent'];
+        $this->name = $data['name'];
+        $this->type = $data['type'];
+        $this->options = $data['options'];
+        $this->modified = $data['modified'];
+        $this->identifier = $data['identifier'];
+    }
+}

+ 128 - 0
system/src/Grav/Framework/Relationships/Traits/RelationshipTrait.php

@@ -0,0 +1,128 @@
+<?php declare(strict_types=1);
+
+namespace Grav\Framework\Relationships\Traits;
+
+use Grav\Framework\Contracts\Object\IdentifierInterface;
+use Grav\Framework\Flex\FlexIdentifier;
+use Grav\Framework\Media\MediaIdentifier;
+use Grav\Framework\Object\Identifiers\Identifier;
+use RuntimeException;
+use function get_class;
+
+/**
+ * Trait RelationshipTrait
+ *
+ * @template T of object
+ */
+trait RelationshipTrait
+{
+    /** @var IdentifierInterface */
+    protected $parent;
+    /** @var string */
+    protected $name;
+    /** @var string */
+    protected $type;
+    /** @var array */
+    protected $options;
+    /** @var bool */
+    protected $modified = false;
+
+    /**
+     * @return string
+     * @phpstan-pure
+     */
+    public function getName(): string
+    {
+        return $this->name;
+    }
+
+    /**
+     * @return string
+     * @phpstan-pure
+     */
+    public function getType(): string
+    {
+        return $this->type;
+    }
+
+    /**
+     * @return bool
+     * @phpstan-pure
+     */
+    public function isModified(): bool
+    {
+        return $this->modified;
+    }
+
+    /**
+     * @return IdentifierInterface
+     * @phpstan-pure
+     */
+    public function getParent(): IdentifierInterface
+    {
+        return $this->parent;
+    }
+
+    /**
+     * @param IdentifierInterface $identifier
+     * @return bool
+     * @phpstan-pure
+     */
+    public function hasIdentifier(IdentifierInterface $identifier): bool
+    {
+        return $this->getIdentifier($identifier->getId(), $identifier->getType()) !== null;
+    }
+
+    /**
+     * @return int
+     * @phpstan-pure
+     */
+    abstract public function count(): int;
+
+    /**
+     * @return void
+     * @phpstan-pure
+     */
+    public function check(): void
+    {
+        $min = $this->options['min'] ?? 0;
+        $max = $this->options['max'] ?? 0;
+
+        if ($min || $max) {
+            $count = $this->count();
+            if ($min && $count < $min) {
+                throw new RuntimeException(sprintf('%s relationship has too few objects in it', $this->name));
+            }
+            if ($max && $count > $max) {
+                throw new RuntimeException(sprintf('%s relationship has too many objects in it', $this->name));
+            }
+        }
+    }
+
+    /**
+     * @param IdentifierInterface $identifier
+     * @return IdentifierInterface
+     */
+    private function checkIdentifier(IdentifierInterface $identifier): IdentifierInterface
+    {
+        if ($this->type !== $identifier->getType()) {
+            throw new RuntimeException(sprintf('Bad identifier type %s', $identifier->getType()));
+        }
+
+        if (get_class($identifier) !== Identifier::class) {
+            return $identifier;
+        }
+
+        if ($this->type === 'media') {
+            return new MediaIdentifier($identifier->getId());
+        }
+
+        return new FlexIdentifier($identifier->getId(), $identifier->getType());
+    }
+
+    private function parseOptions(array $options): void
+    {
+        $this->type = $options['type'];
+        $this->options = $options;
+    }
+}

+ 1 - 3
system/src/Grav/Framework/RequestHandler/Middlewares/Exceptions.php

@@ -1,4 +1,4 @@
-<?php
+<?php declare(strict_types=1);
 
 /**
  * @package    Grav\Framework\RequestHandler
@@ -7,8 +7,6 @@
  * @license    MIT License; see LICENSE file for details.
  */
 
-declare(strict_types=1);
-
 namespace Grav\Framework\RequestHandler\Middlewares;
 
 use Grav\Common\Data\ValidationException;

+ 123 - 0
system/src/Grav/Framework/RequestHandler/Middlewares/MultipartRequestSupport.php

@@ -0,0 +1,123 @@
+<?php declare(strict_types=1);
+
+/**
+ * @package    Grav\Framework\RequestHandler
+ *
+ * @copyright  Copyright (c) 2015 - 2022 Trilby Media, LLC. All rights reserved.
+ * @license    MIT License; see LICENSE file for details.
+ */
+
+namespace Grav\Framework\RequestHandler\Middlewares;
+
+use Grav\Framework\Psr7\UploadedFile;
+use Nyholm\Psr7\Stream;
+use Psr\Http\Message\ResponseInterface;
+use Psr\Http\Message\ServerRequestInterface;
+use Psr\Http\Message\UploadedFileInterface;
+use Psr\Http\Server\MiddlewareInterface;
+use Psr\Http\Server\RequestHandlerInterface;
+use function array_slice;
+use function count;
+use function in_array;
+use function is_array;
+use function strlen;
+
+/**
+ * Multipart request support for PUT and PATCH.
+ */
+class MultipartRequestSupport implements MiddlewareInterface
+{
+    /**
+     * @param ServerRequestInterface $request
+     * @param RequestHandlerInterface $handler
+     * @return ResponseInterface
+     */
+    public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
+    {
+        $contentType = $request->getHeaderLine('content-type');
+        $method = $request->getMethod();
+        if (!str_starts_with($contentType, 'multipart/form-data') || !in_array($method, ['PUT', 'PATH'], true)) {
+            return $handler->handle($request);
+        }
+
+        $boundary = explode('; boundary=', $contentType, 2)[1] ?? '';
+        $parts = explode("--{$boundary}", $request->getBody()->getContents());
+        $parts = array_slice($parts, 1, count($parts) - 2);
+
+        $params = [];
+        $files = [];
+        foreach ($parts as $part) {
+            $this->processPart($params, $files, $part);
+        }
+
+        return $handler->handle($request->withParsedBody($params)->withUploadedFiles($files));
+    }
+
+    /**
+     * @param array $params
+     * @param array $files
+     * @param string $part
+     * @return void
+     */
+    protected function processPart(array &$params, array &$files, string $part): void
+    {
+        $part = ltrim($part, "\r\n");
+        [$rawHeaders, $body] = explode("\r\n\r\n", $part, 2);
+
+        // Parse headers.
+        $rawHeaders = explode("\r\n", $rawHeaders);
+        $headers = array_reduce(
+            $rawHeaders,
+            static function (array $headers, $header) {
+                [$name, $value] = explode(':', $header);
+                $headers[strtolower($name)] = ltrim($value, ' ');
+
+                return $headers;
+            },
+            []
+        );
+
+        if (!isset($headers['content-disposition'])) {
+            return;
+        }
+
+        // Parse content disposition header.
+        $contentDisposition = $headers['content-disposition'];
+        preg_match('/^(.+); *name="([^"]+)"(; *filename="([^"]+)")?/', $contentDisposition, $matches);
+        $name = $matches[2];
+        $filename = $matches[4] ?? null;
+
+        if ($filename !== null) {
+            $stream = Stream::create($body);
+            $this->addFile($files, $name, new UploadedFile($stream, strlen($body), UPLOAD_ERR_OK, $filename, $headers['content-type'] ?? null));
+        } elseif (strpos($contentDisposition, 'filename') !== false) {
+            // Not uploaded file.
+             $stream = Stream::create('');
+            $this->addFile($files, $name, new UploadedFile($stream, 0, UPLOAD_ERR_NO_FILE));
+        } else {
+            // Regular field.
+            $params[$name] = substr($body, 0, -2);
+        }
+    }
+
+    /**
+     * @param array $files
+     * @param string $name
+     * @param UploadedFileInterface $file
+     * @return void
+     */
+    protected function addFile(array &$files, string $name, UploadedFileInterface $file): void
+    {
+        if (strpos($name, '[]') === strlen($name) - 2) {
+            $name = substr($name, 0, -2);
+
+            if (isset($files[$name]) && is_array($files[$name])) {
+                $files[$name][] = $file;
+            } else {
+                $files[$name] = [$file];
+            }
+        } else {
+            $files[$name] = $file;
+        }
+    }
+}

+ 2 - 2
system/src/Grav/Framework/RequestHandler/Traits/RequestHandlerTrait.php

@@ -28,10 +28,10 @@ trait RequestHandlerTrait
     protected $middleware;
 
     /** @var callable */
-    private $handler;
+    protected $handler;
 
     /** @var ContainerInterface|null */
-    private $container;
+    protected $container;
 
     /**
      * {@inheritdoc}

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